{"$schema":"https://ejfox.com/schema/page-twin@1.json","kind":"project_index","url":"https://ejfox.com/projects","json_url":"https://ejfox.com/projects.json","generator":"ejfox.com/json-twin@1","data":[{"slug":"projects/terminal-tools","title":"Terminal Tools","html":"<p class=\"\">Fourteen small programs for the terminal: utilities for working inside tmux, a few input and display experiments, and some games and toys. Each name links to its code.</p>\n<h2 class=\"\" id=\"utilities\">Utilities</h2>\n<h3 class=\"\" id=\"git-status-dash\">git-status-dash</h3>\n<figure role=\"figure\" aria-label=\"git-status-dash showing multiple repositories at once\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1743884042/Screenshot_2025-04-05_at_4.13.46_PM.png\" alt=\"git-status-dash showing multiple repositories at once\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1743884042/Screenshot_2025-04-05_at_4.13.46_PM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1743884042/Screenshot_2025-04-05_at_4.13.46_PM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1743884042/Screenshot_2025-04-05_at_4.13.46_PM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1743884042/Screenshot_2025-04-05_at_4.13.46_PM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"git-status-dash showing multiple repositories at once\" width=\"3824\" height=\"2486\" style=\"--splay-rot:2.45deg;\" data-dimensions=\"3824×2486\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">git-status-dash showing multiple repositories at once</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/git-status-dash\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/git-status-dash\">git-status-dash<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> shows the status of every repository in a folder at a glance — one terminal dashboard for all your work in progress. It's written in Go, with commands for config, themes, and status.</p>\n<h3 class=\"\" id=\"github-sloth\">GitHub Sloth</h3>\n<figure role=\"figure\" aria-label=\"The PR rollup logic in Rust — status glyphs and age coloring\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/github-sloth/code.png\" alt=\"The PR rollup logic in Rust — status glyphs and age coloring\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/github-sloth/code.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/github-sloth/code.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/github-sloth/code.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/github-sloth/code.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The PR rollup logic in Rust — status glyphs and age coloring\" width=\"1400\" height=\"900\" style=\"--splay-rot:1.75deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The PR rollup logic in Rust — status glyphs and age coloring</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/github-sloth\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/github-sloth\">GitHub Sloth<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> is a minimalist amber-on-black terminal UI for GitHub PRs, reviews, and checks, written in Rust. It's designed to complement lazygit.</p>\n<h3 class=\"\" id=\"tmux-link-grab\">tmux-link-grab</h3>\n<figure role=\"figure\" aria-label=\"tmux-link-grab in action\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/tmux-link-grab/demo.gif\" alt=\"tmux-link-grab in action\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/tmux-link-grab/demo.gif 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/tmux-link-grab/demo.gif 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/tmux-link-grab/demo.gif 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/tmux-link-grab/demo.gif 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"tmux-link-grab in action\" style=\"--splay-rot:-0.6deg;\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">tmux-link-grab in action</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/tmux-link-grab\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/tmux-link-grab\">tmux-link-grab<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> grabs and opens the links scattered across your terminal panes. No mouse, no scrollback hunting.</p>\n<h3 class=\"\" id=\"music-cli\">Music CLI</h3>\n<figure role=\"figure\" aria-label=\"The command reference — track management, a radio scheduler, and setup/watcher commands\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/music-cli/cli.png\" alt=\"The command reference — track management, a radio scheduler, and setup/watcher commands\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/music-cli/cli.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/music-cli/cli.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/music-cli/cli.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/music-cli/cli.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The command reference — track management, a radio scheduler, and setup/watcher commands\" width=\"1500\" height=\"1180\" style=\"--splay-rot:-0.6deg;\" data-dimensions=\"1500×1180\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The command reference — track management, a radio scheduler, and setup/watcher commands</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/music-cli\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/music-cli\">Music CLI<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> manages the audio that powers music.tools.ejfox.com and the radio — list, move, upload, convert, and tag tracks straight from the shell. Reads hit Cloudflare R2 directly; writes go through wrangler.</p>\n<h3 class=\"\" id=\"robots\">Robots</h3>\n<figure role=\"figure\" aria-label=\"Robots running in a vulpes-themed terminal\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/robots/terminal.png\" alt=\"Robots running in a vulpes-themed terminal\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/robots/terminal.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/robots/terminal.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/robots/terminal.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/robots/terminal.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Robots running in a vulpes-themed terminal\" width=\"1351\" height=\"548\" style=\"--splay-rot:2.49deg;\" data-dimensions=\"1351×548\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Robots running in a vulpes-themed terminal</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/robots\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/robots\">Robots<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> is a live cockpit for every Claude Code agent on the machine, written in Rust. It needs zero instrumentation and works retroactively on sessions that are already running.</p>\n<h2 class=\"\" id=\"input-and-display\">Input and display</h2>\n<h3 class=\"\" id=\"ps5-tmux\">PS5 Tmux</h3>\n<figure role=\"figure\" aria-label=\"PS5 Tmux running in a vulpes-themed terminal\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/ps5-tmux/terminal.png\" alt=\"PS5 Tmux running in a vulpes-themed terminal\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/ps5-tmux/terminal.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/ps5-tmux/terminal.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/ps5-tmux/terminal.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/ps5-tmux/terminal.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"PS5 Tmux running in a vulpes-themed terminal\" width=\"1014\" height=\"594\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1014×594\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">PS5 Tmux running in a vulpes-themed terminal</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/ps5-tmux\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/ps5-tmux\">PS5 Tmux<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> controls tmux with a PS5 DualSense controller. Move between panes with the controller, accept changes with the X button, or hit the middle button to enter dictation mode.</p>\n<h3 class=\"\" id=\"talon-sketchybar\">Talon Sketchybar</h3>\n<figure role=\"figure\" aria-label=\"Talon Sketchybar running in a vulpes-themed terminal\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/talon-sketchybar/terminal.png\" alt=\"Talon Sketchybar running in a vulpes-themed terminal\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/talon-sketchybar/terminal.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/talon-sketchybar/terminal.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/talon-sketchybar/terminal.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/talon-sketchybar/terminal.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Talon Sketchybar running in a vulpes-themed terminal\" width=\"1330\" height=\"547\" style=\"--splay-rot:-1.24deg;\" data-dimensions=\"1330×547\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Talon Sketchybar running in a vulpes-themed terminal</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/talon-sketchybar\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/talon-sketchybar\">Talon Sketchybar<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> pushes Talon Voice state into sketchybar — event-driven, zero polling, about 60 lines of Python. It includes a Karabiner double-shift integration.</p>\n<h3 class=\"\" id=\"showtouch\">showtouch</h3>\n<figure role=\"figure\" aria-label=\"showtouch displaying a keystroke as full-screen centered ASCII art\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/showtouch/demo.png\" alt=\"showtouch displaying a keystroke as full-screen centered ASCII art\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/showtouch/demo.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/showtouch/demo.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/showtouch/demo.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/showtouch/demo.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"showtouch displaying a keystroke as full-screen centered ASCII art\" width=\"1750\" height=\"1880\" style=\"--splay-rot:-1.16deg;\" data-dimensions=\"1750×1880\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">showtouch displaying a keystroke as full-screen centered ASCII art</figcaption>\n</figure>\n<p class=\"\"><a href=\"/projects/showtouch\" class=\"text-blue-600 dark:text-blue-400\">showtouch</a> shows every key you press as large ASCII art on screen, so an audience can follow along during presentations and livestreams. pynput captures the keys and pyfiglet renders them.</p>\n<h3 class=\"\" id=\"ascii_webcam\">ascii_webcam</h3>\n<figure role=\"figure\" aria-label=\"ascii_webcam turning a live scene into terminal ASCII\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/ascii_webcam/demo.png\" alt=\"ascii_webcam turning a live scene into terminal ASCII\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/ascii_webcam/demo.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/ascii_webcam/demo.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/ascii_webcam/demo.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/ascii_webcam/demo.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"ascii_webcam turning a live scene into terminal ASCII\" width=\"1658\" height=\"1880\" style=\"--splay-rot:-1.37deg;\" data-dimensions=\"1658×1880\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">ascii_webcam turning a live scene into terminal ASCII</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/ascii_webcam\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/ascii_webcam\">ascii_webcam<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> shows your webcam as ASCII art in the terminal. It supports customizable cyberpunk 3D scenes, gradient palettes, and Floyd–Steinberg dithering.</p>\n<h2 class=\"\" id=\"games-and-toys\">Games and toys</h2>\n<h3 class=\"\" id=\"cli-ching\">CLI Ching</h3>\n<figure role=\"figure\" aria-label=\"cli-ching — the I Ching oracle in the terminal, with throw history and the coin-toss prompt\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cli-ching/reading.png\" alt=\"cli-ching — the I Ching oracle in the terminal, with throw history and the coin-toss prompt\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/cli-ching/reading.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/cli-ching/reading.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cli-ching/reading.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/cli-ching/reading.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"cli-ching — the I Ching oracle in the terminal, with throw history and the coin-toss prompt\" width=\"1200\" height=\"1000\" style=\"--splay-rot:-0.6deg;\" data-dimensions=\"1200×1000\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">cli-ching — the I Ching oracle in the terminal, with throw history and the coin-toss prompt</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/cli-ching\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/cli-ching\">CLI Ching<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> walks you through six coin tosses, builds your I Ching hexagram, and interprets the reading. It keeps a history of past throws.</p>\n<h3 class=\"\" id=\"cli-conway\">CLI Conway</h3>\n<figure role=\"figure\" aria-label=\"CLI Conway running in a vulpes-themed terminal\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cli-conway/terminal.png\" alt=\"CLI Conway running in a vulpes-themed terminal\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/cli-conway/terminal.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/cli-conway/terminal.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cli-conway/terminal.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/cli-conway/terminal.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"CLI Conway running in a vulpes-themed terminal\" width=\"1309\" height=\"759\" style=\"--splay-rot:2.32deg;\" data-dimensions=\"1309×759\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">CLI Conway running in a vulpes-themed terminal</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/cli-conway\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/cli-conway\">CLI Conway<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> runs Conway's Game of Life in the terminal. The cells are drawn in braille unicode characters.</p>\n<h3 class=\"\" id=\"cli-delta-dojo\">CLI Delta Dojo</h3>\n<figure role=\"figure\" aria-label=\"CLI Delta Dojo title screen\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cli-delta-dojo/title.png\" alt=\"CLI Delta Dojo title screen\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/cli-delta-dojo/title.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/cli-delta-dojo/title.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cli-delta-dojo/title.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/cli-delta-dojo/title.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"CLI Delta Dojo title screen\" width=\"1200\" height=\"700\" style=\"--splay-rot:-2.6deg;\" data-dimensions=\"1200×700\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">CLI Delta Dojo title screen</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/cli-delta-dojo\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/cli-delta-dojo\">CLI Delta Dojo<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> is a terminal reflex game for training your eye to spot differences. Two values flash up and you call them <em class=\"\">Different</em> or <em class=\"\">Same</em> as fast as you can, against the clock.</p>\n<h3 class=\"\" id=\"cli-ascii-3d\">CLI Ascii 3D</h3>\n<figure role=\"figure\" aria-label=\"CLI Ascii 3D running in a vulpes-themed terminal\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cli-ascii-3d/terminal.png\" alt=\"CLI Ascii 3D running in a vulpes-themed terminal\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/cli-ascii-3d/terminal.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/cli-ascii-3d/terminal.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cli-ascii-3d/terminal.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/cli-ascii-3d/terminal.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"CLI Ascii 3D running in a vulpes-themed terminal\" width=\"1437\" height=\"706\" style=\"--splay-rot:1.33deg;\" data-dimensions=\"1437×706\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">CLI Ascii 3D running in a vulpes-themed terminal</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/cli-ascii-3d\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/cli-ascii-3d\">CLI Ascii 3D<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> renders 3D shapes as ASCII art in the command line.</p>\n<h3 class=\"\" id=\"cli-ai-chat\">CLI AI Chat</h3>\n<figure role=\"figure\" aria-label=\"CLI AI Chat running in a vulpes-themed terminal\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cli-ai-chat/terminal.png\" alt=\"CLI AI Chat running in a vulpes-themed terminal\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/cli-ai-chat/terminal.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/cli-ai-chat/terminal.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cli-ai-chat/terminal.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/cli-ai-chat/terminal.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"CLI AI Chat running in a vulpes-themed terminal\" width=\"1562\" height=\"657\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1562×657\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">CLI AI Chat running in a vulpes-themed terminal</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/cli-ai-chat\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/cli-ai-chat\">CLI AI Chat<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> is an IRC-style terminal interface for LLM conversations, in the same channel-and-buffer idiom as an IRC client. Vim-style modes and keybindings handle navigation between threads.</p>","metadata":{"title":"Terminal Tools","date":"2026-09-30T04:00:00.000Z","category":"Tools","featured":false,"draft":false,"tech":["Rust","Go","Python","Node.js","Shell","tmux"],"state":"deployed","ai-involvement":"ai-assisted","tags":["cli","terminal","tools"],"words":546,"images":14,"imageDetails":{"total":14,"cloudinary":14,"withDimensions":0},"links":14,"codeBlocks":0,"headers":{"h2":3},"toc":[{"text":"Utilities","slug":"utilities","level":"h2","children":[{"text":"git-status-dash","slug":"git-status-dash","level":"h3","children":[]},{"text":"GitHub Sloth","slug":"github-sloth","level":"h3","children":[]},{"text":"tmux-link-grab","slug":"tmux-link-grab","level":"h3","children":[]},{"text":"Music CLI","slug":"music-cli","level":"h3","children":[]},{"text":"Robots","slug":"robots","level":"h3","children":[]}]},{"text":"Input and display","slug":"input-and-display","level":"h2","children":[{"text":"PS5 Tmux","slug":"ps5-tmux","level":"h3","children":[]},{"text":"Talon Sketchybar","slug":"talon-sketchybar","level":"h3","children":[]},{"text":"showtouch","slug":"showtouch","level":"h3","children":[]},{"text":"ascii_webcam","slug":"ascii_webcam","level":"h3","children":[]}]},{"text":"Games and toys","slug":"games-and-toys","level":"h2","children":[{"text":"CLI Ching","slug":"cli-ching","level":"h3","children":[]},{"text":"CLI Conway","slug":"cli-conway","level":"h3","children":[]},{"text":"CLI Delta Dojo","slug":"cli-delta-dojo","level":"h3","children":[]},{"text":"CLI Ascii 3D","slug":"cli-ascii-3d","level":"h3","children":[]},{"text":"CLI AI Chat","slug":"cli-ai-chat","level":"h3","children":[]}]}],"type":"post"}},{"slug":"projects/amen-slicer","title":"Amen Slicer","html":"<figure role=\"figure\" aria-label=\"Amen Slicer — running on the Game Boy Advance\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/amen-slicer/gameboy.png\" alt=\"Amen Slicer — running on the Game Boy Advance\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/amen-slicer/gameboy.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/amen-slicer/gameboy.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/amen-slicer/gameboy.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/amen-slicer/gameboy.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Amen Slicer — running on the Game Boy Advance\" width=\"1842\" height=\"1728\" style=\"--splay-rot:-0.6deg;\" data-dimensions=\"1842×1728\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Amen Slicer — running on the Game Boy Advance</figcaption>\n</figure>\n<p class=\"\">A breakbeat slicer / performance instrument for the Game Boy Advance — 13 amen flavors, sync-locked clock, live stutters/FX/crush, and a reactive generative visualizer. Butano/C++</p>\n<p class=\"\">I am SO stoked about this one. This is basically because I am getting my music table set up again — I have a Maschine+ and an FM synthesizer, and I just need a device that makes it easy to drop in, beat sync, and chop up amen breaks in time with the other music hardware.</p>\n<p class=\"\">I made one for the Game Boy (Miyoo Mini, really) to experiment with making my own Game Boy game utilities — or really, shaping the Miyoo Mini into my own dream piece of hacker music gear.</p>\n<p class=\"\">Plug in the Game Boy — the headphone port is a little janky but you can get it — then sync up the 1 from the Game Boy, pick a sample, chop it around while the other instruments happen.</p>\n<p class=\"\">I also made a Rust macOS amen slice breaker app that works with the 64-pad grid controller over MIDI — <a href=\"https://github.com/ejfox/amen-grid\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/amen-grid\">amen-grid<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a>.</p>","metadata":{"title":"Amen Slicer","date":"2026-07-28T05:00:00.000Z","category":"Hardware","featured":false,"draft":false,"url":"https://github.com/ejfox/amen-slicer","tech":["C++","Makefile","Python"],"tags":["hardware"],"words":186,"images":1,"imageDetails":{"total":1,"cloudinary":1,"withDimensions":0},"links":1,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/badusb-remote-desktop","title":"BadUSB Remote Desktop","html":"<figure role=\"figure\" aria-label=\"BadUSB Remote Desktop — a real DuckyScript payload in a vulpes terminal\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/badusb-remote-desktop/terminal.png\" alt=\"BadUSB Remote Desktop — a real DuckyScript payload in a vulpes terminal\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/badusb-remote-desktop/terminal.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/badusb-remote-desktop/terminal.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/badusb-remote-desktop/terminal.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/badusb-remote-desktop/terminal.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"BadUSB Remote Desktop — a real DuckyScript payload in a vulpes terminal\" width=\"1583\" height=\"918\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1583×918\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">BadUSB Remote Desktop — a real DuckyScript payload in a vulpes terminal</figcaption>\n</figure>\n<p class=\"\">Flipper Zero BadUSB payloads that make a fresh, keyboard-less machine reachable on the LAN for remote desktop — macOS Screen Sharing (VNC), Windows RDP, Linux VNC</p>\n<p class=\"\">For setting up new computers, especially desktops and Raspberry Pis, with no keyboard, and getting them on a network where I can manage them from my MacBook.</p>\n<p class=\"\">Just plug the Flipper in with USB-C and run the BadUSB scripts until it's connected.</p>","metadata":{"title":"BadUSB Remote Desktop","date":"2026-06-09T05:00:00.000Z","category":"Hardware","featured":false,"draft":false,"url":"https://github.com/ejfox/badusb-remote-desktop","tech":["Shell"],"tags":["badusb","duckyscript","flipper-zero","headless","linux"],"words":80,"images":1,"imageDetails":{"total":1,"cloudinary":1,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/pixel-canvas","title":"Pixel Canvas","html":"<p class=\"\">I spend all day looking at screens that want something from me. Pixel Canvas is a screen that doesn't — a networked <strong class=\"\">320×240 display that sits on my desk</strong> and quietly shows me my own life in the Vulpes palette.</p>\n<p class=\"\">It's an ESP32-S3-BOX-3B running a little Arduino REST API, driven by a bash CLI that rotates through \"scenes\" every fifteen minutes. Each scene is a tiny self-contained script that fetches from some corner of my data and draws it with primitives over HTTP — the weather, the last film I watched with my rating, the book I'm in the middle of, my best typing speed, a random card from the gear closet, a line from my journal.</p>\n<figure role=\"figure\" aria-label=\"A day of the display&#x27;s rotation, compressed — all fourteen scenes as the device cycles them\" class=\"\"><video class=\"img-full my-8 rounded-sm\" autoplay loop muted playsinline preload=\"metadata\" poster=\"https://res.cloudinary.com/ejf/video/upload/so_0,f_jpg,q_auto,w_1280/projects/pixel-canvas/scene-rotation.jpg\" src=\"https://res.cloudinary.com/ejf/video/upload/q_auto/projects/pixel-canvas/scene-rotation.mp4\" aria-label=\"A day of the display&#x27;s rotation, compressed — all fourteen scenes as the device cycles them\"></video><figcaption class=\"\">A day of the display's rotation, compressed — all fourteen scenes as the device cycles them</figcaption></figure>\n<figure role=\"figure\" aria-label=\"Today&#x27;s weather as a desk-display scene\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/weather.png\" alt=\"Today&#x27;s weather as a desk-display scene\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/pixel-canvas/weather.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/pixel-canvas/weather.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/weather.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/pixel-canvas/weather.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Today&#x27;s weather as a desk-display scene\" width=\"640\" height=\"480\" style=\"--splay-rot:2.82deg;\" data-dimensions=\"640×480\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Today's weather as a desk-display scene</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The last film I watched, with my rating\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/last-film.png\" alt=\"The last film I watched, with my rating\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/pixel-canvas/last-film.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/pixel-canvas/last-film.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/last-film.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/pixel-canvas/last-film.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The last film I watched, with my rating\" width=\"640\" height=\"480\" style=\"--splay-rot:1.28deg;\" data-dimensions=\"640×480\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The last film I watched, with my rating</figcaption>\n</figure>\n<p class=\"\">The whole thing is the opposite of a notification feed. Nothing on it is urgent, nothing is trying to pull me back in. It's ambient — you glance at it the way you glance out a window, and mostly you just let it cycle in the corner of your eye while you work.</p>\n<p class=\"\">That's the part I actually care about. I've been tracking myself for years across a dozen systems, and the usual pitch for all that data is optimization: quantify yourself, find the inefficiency, grind it down. I've never been interested in that. I wanted to turn the panopticon inward and just <em class=\"\">look</em> — not \"how do I do more,\" but \"what does a day of mine actually look like when it's reflected back and I'm not the one narrating it?\"</p>\n<figure role=\"figure\" aria-label=\"A random gear card\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/gear.png\" alt=\"A random gear card\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/pixel-canvas/gear.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/pixel-canvas/gear.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/gear.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/pixel-canvas/gear.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A random gear card\" width=\"640\" height=\"480\" style=\"--splay-rot:-2.02deg;\" data-dimensions=\"640×480\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A random gear card</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Best WPM, pulled from my typing stats\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/wpm.png\" alt=\"Best WPM, pulled from my typing stats\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/pixel-canvas/wpm.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/pixel-canvas/wpm.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/wpm.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/pixel-canvas/wpm.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Best WPM, pulled from my typing stats\" width=\"640\" height=\"480\" style=\"--splay-rot:-1.24deg;\" data-dimensions=\"640×480\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Best WPM, pulled from my typing stats</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The book I&#x27;m currently reading\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/reading.png\" alt=\"The book I&#x27;m currently reading\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/pixel-canvas/reading.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/pixel-canvas/reading.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/reading.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/pixel-canvas/reading.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The book I&#x27;m currently reading\" width=\"640\" height=\"480\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"640×480\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The book I'm currently reading</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Morning greeting\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/greeting.png\" alt=\"Morning greeting\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/pixel-canvas/greeting.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/pixel-canvas/greeting.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/greeting.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/pixel-canvas/greeting.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Morning greeting\" width=\"640\" height=\"480\" style=\"--splay-rot:2.13deg;\" data-dimensions=\"640×480\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Morning greeting</figcaption>\n</figure>\n<h2 class=\"\" id=\"how-it-works\">How it works</h2>\n<ul class=\"list-disc\">\n<li class=\"\"><strong class=\"\">Firmware</strong> — an Arduino sketch (LovyanGFX on the ESP32-S3-BOX-3B) exposes a small drawing REST API: <code class=\"md-inline-code\">/pixel</code>, <code class=\"md-inline-code\">/rect</code>, <code class=\"md-inline-code\">/circle</code>, <code class=\"md-inline-code\">/text</code>, plus a browser drawing UI served at <code class=\"md-inline-code\">/</code>. The device knows nothing about my life; it just knows how to draw.</li>\n<li class=\"\"><strong class=\"\">CLI</strong> — <code class=\"md-inline-code\">pixel &#x3C;scene></code> pings the device, then runs the scene script. Every draw command is also logged, so a companion renderer (<code class=\"md-inline-code\">pixel-render</code>) can snapshot exactly what's on the glass to a PNG — which is where every image on this page came from.</li>\n<li class=\"\"><strong class=\"\">Rotation</strong> — a launchd job picks a scene every fifteen minutes, so it keeps cycling through my data on its own, no input from me.</li>\n</ul>\n<figure role=\"figure\" aria-label=\"A todo scene\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/todos.png\" alt=\"A todo scene\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/pixel-canvas/todos.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/pixel-canvas/todos.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/todos.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/pixel-canvas/todos.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A todo scene\" width=\"640\" height=\"480\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"640×480\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A todo scene</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Clock\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/clock.png\" alt=\"Clock\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/pixel-canvas/clock.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/pixel-canvas/clock.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/clock.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/pixel-canvas/clock.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Clock\" width=\"640\" height=\"480\" style=\"--splay-rot:1.96deg;\" data-dimensions=\"640×480\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Clock</figcaption>\n</figure>\n<p class=\"\">By now there's a scene for nearly every corner of the archive — a random old tweet, a draft note yanked out of Obsidian, this week's intelligence-summary wiki page, the day's site analytics, a line of cipher text, and when there's genuinely nothing to say, a quiet field of ambient dots.</p>\n<figure role=\"figure\" aria-label=\"A random tweet from the archive\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/tweet.png\" alt=\"A random tweet from the archive\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/pixel-canvas/tweet.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/pixel-canvas/tweet.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/tweet.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/pixel-canvas/tweet.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A random tweet from the archive\" width=\"640\" height=\"480\" style=\"--splay-rot:1.04deg;\" data-dimensions=\"640×480\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A random tweet from the archive</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Today&#x27;s site analytics\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/umami.png\" alt=\"Today&#x27;s site analytics\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/pixel-canvas/umami.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/pixel-canvas/umami.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/umami.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/pixel-canvas/umami.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Today&#x27;s site analytics\" width=\"640\" height=\"480\" style=\"--splay-rot:-2.99deg;\" data-dimensions=\"640×480\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Today's site analytics</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"A line of cipher text\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/cipher.png\" alt=\"A line of cipher text\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/pixel-canvas/cipher.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/pixel-canvas/cipher.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/cipher.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/pixel-canvas/cipher.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A line of cipher text\" width=\"640\" height=\"480\" style=\"--splay-rot:-0.63deg;\" data-dimensions=\"640×480\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A line of cipher text</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Ambient dots\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/ambient-dots.png\" alt=\"Ambient dots\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/pixel-canvas/ambient-dots.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/pixel-canvas/ambient-dots.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/pixel-canvas/ambient-dots.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/pixel-canvas/ambient-dots.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Ambient dots\" width=\"640\" height=\"480\" style=\"--splay-rot:-0.6deg;\" data-dimensions=\"640×480\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Ambient dots</figcaption>\n</figure>\n<p class=\"\">The scenes I like best are the ones that catch me off guard — a tweet from a version of me I'd half-forgotten, a book I keep meaning to finish. A small calm machine for remembering there's a life on this side of the screen too.</p>","metadata":{"title":"Pixel Canvas","date":"2026-05-27T04:00:00.000Z","category":"Hardware","featured":true,"draft":false,"tech":["ESP32-S3","Arduino","LovyanGFX","Bash","REST API"],"state":"doing","ai-involvement":"ai-assisted","tags":["hardware","device","dataviz","quantified-self"],"words":530,"images":13,"imageDetails":{"total":13,"cloudinary":13,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{"h2":1},"toc":[{"text":"How it works","slug":"how-it-works","level":"h2","children":[]}],"type":"post"}},{"slug":"projects/scrapbook-core","title":"Scrapbook Core","html":"<p class=\"\">I have gotten pretty good at saving interesting things that I see on the internet. Anytime I read something I think I might want to reference, show someone, or think about later, I chuck it into my bookmarks. I've done this for over 10 years now, making little notes of my favorite things while wading through the ever-expanding deluge of \"content.\" Now I want to take a step back and map the constellations that emerge.</p>\n<figure role=\"figure\" aria-label=\"A phyllotaxis of the scrap corpus — every screenshot placed by the golden angle, the way a sunflower packs its seeds\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/scrapbook-core/phyllotaxis.png\" alt=\"A phyllotaxis of the scrap corpus — every screenshot placed by the golden angle, the way a sunflower packs its seeds\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/scrapbook-core/phyllotaxis.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/scrapbook-core/phyllotaxis.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/scrapbook-core/phyllotaxis.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/scrapbook-core/phyllotaxis.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A phyllotaxis of the scrap corpus — every screenshot placed by the golden angle, the way a sunflower packs its seeds\" width=\"1600\" height=\"1600\" style=\"--splay-rot:1.18deg;\" data-dimensions=\"1600×1600\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A phyllotaxis of the scrap corpus — every screenshot placed by the golden angle, the way a sunflower packs its seeds</figcaption>\n</figure>\n<p class=\"\">All of these things — mastodon posts, pinboard bookmarks, github activity, are.na blocks — can be thought of as various scraps of paper sitting on my desk, clipped out from some source material. But first I needed to reclaim my data from the various services in which they live. Scrapbook-core is that engine: a scraper for my Pinboard bookmarks, are.na blocks, public GitHub actions, and Mastodon posts, all coordinated in an index file that handles rate limiting, sequencing, and the CLI options, landing everything in one database — the substrate everything else (<a href=\"#scrapbook-cli\" class=\"text-blue-600 dark:text-blue-400\">the CLI</a>, <a href=\"#scrapscroller\" class=\"text-blue-600 dark:text-blue-400\">the scrollers</a>) reads from. Every bookmark gets fetched and summarized into a standalone list of facts, tagged, embedded for similarity searching and clustering, and mined for relationships — <code class=\"md-inline-code\">[Person:Stewart Brand] -[:CreatedBy]-> [Publication:Whole Earth Catalog]</code> — that I can easily turn into nodes and edges with some lightweight regex parsing.</p>\n<figure role=\"figure\" aria-label=\"Running the scraper\" class=\"\"><video class=\"img-full my-8 rounded-sm\" autoplay loop muted playsinline preload=\"metadata\" poster=\"https://res.cloudinary.com/ejf/video/upload/so_0,f_jpg,q_auto,w_1280/v1722610899/Screen_Recording_2024-08-02_at_10.59.48_AM.jpg\" src=\"https://res.cloudinary.com/ejf/video/upload/q_auto/v1722610899/Screen_Recording_2024-08-02_at_10.59.48_AM.gif\" aria-label=\"Running the scraper\"></video><figcaption class=\"\">Running the scraper</figcaption></figure>\n<p class=\"\">Even though no one will likely be using this specific tool except for me, I will be practicing a bit of self care in the form of a well-crafted tool that I can find, and most importantly, figure out how to use months or years down the line. The ability to own, possess, remix, and re-explore my own data is crucial for me to make sense of the world. It's how I access my own thoughts, understand <em class=\"\">how</em> I think about things, and to find creative paths forward and decide what is worth focusing on. If I gave up those responsibilities to a faceless algorithm whose goals are quite different from my own, I would be giving up quite a lot. You are what you eat.</p>\n<p class=\"\">The full story: <a href=\"/blog/2024/my-modern-scrapbook\" class=\"text-blue-600 dark:text-blue-400\">My Modern Scrapbook</a>.</p>\n<h2 class=\"\" id=\"the-scrapbook-family\">The scrapbook family</h2>\n<h3 class=\"\" id=\"scrapbook-cli\">Scrapbook CLI</h3>\n<figure role=\"figure\" aria-label=\"The scrapbook CLI in action — browse bookmarks, full-text search, and an AI summary of each scrap\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/scrapbook-cli/app.png\" alt=\"The scrapbook CLI in action — browse bookmarks, full-text search, and an AI summary of each scrap\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/scrapbook-cli/app.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/scrapbook-cli/app.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/scrapbook-cli/app.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/scrapbook-cli/app.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The scrapbook CLI in action — browse bookmarks, full-text search, and an AI summary of each scrap\" width=\"3804\" height=\"2474\" style=\"--splay-rot:0.83deg;\" data-dimensions=\"3804×2474\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The scrapbook CLI in action — browse bookmarks, full-text search, and an AI summary of each scrap</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/scrapbook-cli\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/scrapbook-cli\">Scrapbook CLI<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> is a command-line interface for exploring the scrapbook — browse bookmarks, run full-text search, and read an AI summary of each scrap without leaving the terminal. The interface is built from blessed widgets, including a mini-map.</p>\n<h3 class=\"\" id=\"scrapscroller\">Scrapscroller</h3>\n<figure role=\"figure\" aria-label=\"scrapscroller running: the SCRAP_ZONE feed with source/tag sidebar and stats\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1789191930/projects/scrapscroller/landing.png\" alt=\"scrapscroller running: the SCRAP_ZONE feed with source/tag sidebar and stats\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1789191930/projects/scrapscroller/landing.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1789191930/projects/scrapscroller/landing.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1789191930/projects/scrapscroller/landing.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1789191930/projects/scrapscroller/landing.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"scrapscroller running: the SCRAP_ZONE feed with source/tag sidebar and stats\" width=\"1440\" height=\"813\" style=\"--splay-rot:2.59deg;\" data-dimensions=\"1440×813\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">scrapscroller running: the SCRAP_ZONE feed with source/tag sidebar and stats</figcaption>\n</figure>\n<p class=\"\">Scrapscroller turns the scraps into a scrolling feed, with a source and tag sidebar and running stats.</p>\n<h3 class=\"\" id=\"arena-cards\">Arena Cards</h3>\n<figure role=\"figure\" aria-label=\"Arena Cards — a real are.na channel laid out as draggable spatial cards\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/arena-cards/cards.png\" alt=\"Arena Cards — a real are.na channel laid out as draggable spatial cards\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/arena-cards/cards.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/arena-cards/cards.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/arena-cards/cards.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/arena-cards/cards.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Arena Cards — a real are.na channel laid out as draggable spatial cards\" width=\"2389\" height=\"1593\" style=\"--splay-rot:2.04deg;\" data-dimensions=\"2389×1593\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Arena Cards — a real are.na channel laid out as draggable spatial cards</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/arena-cards\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/arena-cards\">Arena Cards<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> turns an are.na channel into draggable cards for spatial thinking. It was originally made for a YouTube video exploring the are.na API and taking control of your own data.</p>\n<h3 class=\"\" id=\"retroscope\">Retroscope</h3>\n<figure role=\"figure\" aria-label=\"Retroscope running — config verification: Cloudinary initialized, all checks green, health server up\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1789191374/projects/retroscope/landing.png\" alt=\"Retroscope running — config verification: Cloudinary initialized, all checks green, health server up\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1789191374/projects/retroscope/landing.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1789191374/projects/retroscope/landing.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1789191374/projects/retroscope/landing.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1789191374/projects/retroscope/landing.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Retroscope running — config verification: Cloudinary initialized, all checks green, health server up\" width=\"1200\" height=\"800\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1200×800\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Retroscope running — config verification: Cloudinary initialized, all checks green, health server up</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/retroscope\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/retroscope\">Retroscope<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> uses an AI to generate text descriptions of every screenshot stored in Cloudinary.</p>\n<h3 class=\"\" id=\"photos\">Photos</h3>\n<figure role=\"figure\" aria-label=\"A frame from the photo blog\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/photos/hero.png\" alt=\"A frame from the photo blog\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/photos/hero.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/photos/hero.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/photos/hero.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/photos/hero.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A frame from the photo blog\" width=\"1440\" height=\"900\" style=\"--splay-rot:-1.7deg;\" data-dimensions=\"1440×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A frame from the photo blog</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/photos\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/photos\">Photos<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> is the photo blog and the custom media-organizing and publishing app behind it. It's Nuxt and Cloudinary, fed by bash scripts and Apple Automator, for getting photographs from camera to web.</p>","metadata":{"title":"Scrapbook Core","date":"2026-05-05T05:00:00.000Z","category":"Tools","featured":false,"url":"https://github.com/ejfox/scrapbook-core","tech":["JavaScript"],"state":"deployed","ai-involvement":"ai-assisted","tags":["tools"],"words":581,"images":7,"imageDetails":{"total":7,"cloudinary":7,"withDimensions":0},"links":7,"codeBlocks":0,"headers":{"h2":1},"toc":[{"text":"The scrapbook family","slug":"the-scrapbook-family","level":"h2","children":[{"text":"Scrapbook CLI","slug":"scrapbook-cli","level":"h3","children":[]},{"text":"Scrapscroller","slug":"scrapscroller","level":"h3","children":[]},{"text":"Arena Cards","slug":"arena-cards","level":"h3","children":[]},{"text":"Retroscope","slug":"retroscope","level":"h3","children":[]},{"text":"Photos","slug":"photos","level":"h3","children":[]}]}],"type":"post"}},{"slug":"projects/gem-viz","title":"Global Energy Ownership Tracker","html":"<p class=\"\"><a href=\"https://globalenergymonitor.org/projects/global-energy-ownership-tracker\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://globalenergymonitor.org/projects/global-energy-ownership-tracker\">Global Energy Monitor's Ownership Tracker</a> is a giant network: thousands of owners — governments, corporations, private equity firms, and the intermediaries between them — connected to tens of thousands of energy-related assets around the world. It's a rich dataset that had been genuinely hard to explore, so we built a set of tools to open it up. You can trace the full ownership chain behind a single project (down to the stakes held by the likes of BlackRock and EDF), see the whole energy-asset portfolio connected to one owner, and screen owners by what they actually own — coal-based steel plants, proposed mines in BRICS countries, captive gas plants for data centers.</p>\n<p class=\"\">It's built for the digging: investigating a corporation's clean-energy claims, following investment patterns, or tracing who ultimately owns a piece of the world's energy infrastructure. The ownership chain is where a lot of the truth is.</p>\n<p class=\"\">My favorite part was working with Stephen and Nadieh, finding new visual ways to explore ownership patterns this intricate. It started as an ownership chart — Stephen prototyped the forms in Observable, I turned them into Svelte components, and Nadieh brought really cool interactions and visual forms on top. That's how you get things like the ownership \"flowers\" that open an owner's whole portfolio at once, instead of another org chart.</p>\n<p class=\"\">Built with Stephen Osserman and Anna Mowat at Global Energy Monitor, Third Bear Solutions on the data pipeline, and Nadieh Bremer on vis design.</p>\n<figure role=\"figure\" aria-label=\"The live tool — the whole Global Energy Monitor database, 50,223 assets across 194 countries\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/live-map.png\" alt=\"The live tool — the whole Global Energy Monitor database, 50,223 assets across 194 countries\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/gem-viz/live-map.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/gem-viz/live-map.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/live-map.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/gem-viz/live-map.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The live tool — the whole Global Energy Monitor database, 50,223 assets across 194 countries\" width=\"1500\" height=\"950\" style=\"--splay-rot:2.53deg;\" data-dimensions=\"1500×950\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The live tool — the whole Global Energy Monitor database, 50,223 assets across 194 countries</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Owners of the Sines power station, traced live: the coal plant up through EDP to China Three Gorges (22.2%), BlackRock (8.4%), and the Vanguard Group — pie-nodes colored by headquarters country\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/owners-live.png\" alt=\"Owners of the Sines power station, traced live: the coal plant up through EDP to China Three Gorges (22.2%), BlackRock (8.4%), and the Vanguard Group — pie-nodes colored by headquarters country\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/gem-viz/owners-live.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/gem-viz/owners-live.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/owners-live.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/gem-viz/owners-live.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Owners of the Sines power station, traced live: the coal plant up through EDP to China Three Gorges (22.2%), BlackRock (8.4%), and the Vanguard Group — pie-nodes colored by headquarters country\" width=\"1500\" height=\"1050\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1500×1050\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Owners of the Sines power station, traced live: the coal plant up through EDP to China Three Gorges (22.2%), BlackRock (8.4%), and the Vanguard Group — pie-nodes colored by headquarters country</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The screener — pick an asset class (coal plants, captive gas plants for data centers, proposed mines in BRICS countries) and find exactly who owns it\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/screener.png\" alt=\"The screener — pick an asset class (coal plants, captive gas plants for data centers, proposed mines in BRICS countries) and find exactly who owns it\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/gem-viz/screener.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/gem-viz/screener.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/screener.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/gem-viz/screener.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The screener — pick an asset class (coal plants, captive gas plants for data centers, proposed mines in BRICS countries) and find exactly who owns it\" width=\"1500\" height=\"950\" style=\"--splay-rot:1.69deg;\" data-dimensions=\"1500×950\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The screener — pick an asset class (coal plants, captive gas plants for data centers, proposed mines in BRICS countries) and find exactly who owns it</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Ownership network for the Sines power station\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/sines.png\" alt=\"Ownership network for the Sines power station\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/gem-viz/sines.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/gem-viz/sines.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/sines.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/gem-viz/sines.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Ownership network for the Sines power station\" width=\"2880\" height=\"1800\" style=\"--splay-rot:-1.47deg;\" data-dimensions=\"2880×1800\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Ownership network for the Sines power station</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Every energy asset in the Global Energy Monitor database, mapped\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/worldmap.png\" alt=\"Every energy asset in the Global Energy Monitor database, mapped\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/gem-viz/worldmap.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/gem-viz/worldmap.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/worldmap.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/gem-viz/worldmap.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Every energy asset in the Global Energy Monitor database, mapped\" width=\"2880\" height=\"1800\" style=\"--splay-rot:0.97deg;\" data-dimensions=\"2880×1800\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Every energy asset in the Global Energy Monitor database, mapped</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Tracing a power station up to Bank of America\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/drax.png\" alt=\"Tracing a power station up to Bank of America\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/gem-viz/drax.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/gem-viz/drax.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/drax.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/gem-viz/drax.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Tracing a power station up to Bank of America\" width=\"1042\" height=\"516\" style=\"--splay-rot:-1.91deg;\" data-dimensions=\"1042×516\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Tracing a power station up to Bank of America</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"An asset&#x27;s corporate ownership graph\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/network.png\" alt=\"An asset&#x27;s corporate ownership graph\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/gem-viz/network.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/gem-viz/network.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/network.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/gem-viz/network.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"An asset&#x27;s corporate ownership graph\" width=\"2400\" height=\"2262\" style=\"--splay-rot:2.09deg;\" data-dimensions=\"2400×2262\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">An asset's corporate ownership graph</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Drilling into the owners of a binational pipeline\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/owners.png\" alt=\"Drilling into the owners of a binational pipeline\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/gem-viz/owners.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/gem-viz/owners.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/gem-viz/owners.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/gem-viz/owners.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Drilling into the owners of a binational pipeline\" width=\"1310\" height=\"1204\" style=\"--splay-rot:2.21deg;\" data-dimensions=\"1310×1204\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Drilling into the owners of a binational pipeline</figcaption>\n</figure>","metadata":{"title":"Global Energy Ownership Tracker","date":"2026-05-01T04:00:00.000Z","category":"Dataviz","featured":true,"tech":["D3.js","Network Analysis","Data Visualization"],"state":"deployed","ai-involvement":"ai-assisted","context":"client","url":"https://globalenergymonitor.org/projects/global-energy-ownership-tracker","tags":["dataviz","network","energy","climate"],"words":354,"images":8,"imageDetails":{"total":8,"cloudinary":6,"withDimensions":0},"links":1,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/motorcycle-viz","title":"Motorcycle Meta-Visualizations","html":"<p class=\"\">I started filming my rides in case something went wrong. A helmet cam is insurance — if an accident or something crazy happened, at least there'd be a tape. But over a few years the reason quietly changed on me. I'd flip it on for the beautiful stuff instead: a good stretch of road, the light, whatever cool thing was happening. One time I caught a train and a helicopter lining up over the Hudson River exactly as I rode past.</p>\n<p class=\"\">By the end of those first few years I had gigabytes and gigabytes of it, and I got curious what all of it looked like at once — what years of riding might add up to. So I ran the whole corpus through a battery of meta-visualization techniques, pulling images out of the footage that no single frame holds.</p>\n<figure role=\"figure\" aria-label=\"Scrolling the helmet-cam corpus explorer — 294 rides, the frame contact sheet, and GPS route maps\" class=\"\"><video class=\"img-full my-8 rounded-sm\" autoplay loop muted playsinline preload=\"metadata\" poster=\"https://res.cloudinary.com/ejf/video/upload/so_0,f_jpg,q_auto,w_1280/projects/motorcycle-viz/corpus-scroll.jpg\" src=\"https://res.cloudinary.com/ejf/video/upload/q_auto/projects/motorcycle-viz/corpus-scroll.mp4\" aria-label=\"Scrolling the helmet-cam corpus explorer — 294 rides, the frame contact sheet, and GPS route maps\"></video><figcaption class=\"\">Scrolling the helmet-cam corpus explorer — 294 rides, the frame contact sheet, and GPS route maps</figcaption></figure>\n<figure role=\"figure\" aria-label=\"Every frame from 3.5 years of riding — 294 clips, 78 rides — averaged into a single image\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/salavon-overall.png\" alt=\"Every frame from 3.5 years of riding — 294 clips, 78 rides — averaged into a single image\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/motorcycle-viz/salavon-overall.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/motorcycle-viz/salavon-overall.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/salavon-overall.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/motorcycle-viz/salavon-overall.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Every frame from 3.5 years of riding — 294 clips, 78 rides — averaged into a single image\" width=\"640\" height=\"360\" style=\"--splay-rot:-2.13deg;\" data-dimensions=\"640×360\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Every frame from 3.5 years of riding — 294 clips, 78 rides — averaged into a single image</figcaption>\n</figure>\n<p class=\"\"><strong class=\"\">Frame averaging</strong> (after Jason Salavon) stacks thousands of frames into one — the constants of riding (road below, sky above, the fairing) resolve into a soft ghost while everything transient blurs away. Run it per year and you can watch the ghost shift as the routes and seasons change:</p>\n<figure role=\"figure\" aria-label=\"The average of every 2024 frame\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/salavon-2024.png\" alt=\"The average of every 2024 frame\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/motorcycle-viz/salavon-2024.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/motorcycle-viz/salavon-2024.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/salavon-2024.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/motorcycle-viz/salavon-2024.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The average of every 2024 frame\" width=\"640\" height=\"360\" style=\"--splay-rot:-2.37deg;\" data-dimensions=\"640×360\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The average of every 2024 frame</figcaption>\n</figure>\n<p class=\"\"><strong class=\"\">Slit-scan</strong> takes a single column from every frame and lays them side by side, turning a whole ride into a striated band of color and light — both horizontally and vertically.</p>\n<figure role=\"figure\" aria-label=\"Slit-scan: one column per frame, a whole ride compressed into a band\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/slit-scan.png\" alt=\"Slit-scan: one column per frame, a whole ride compressed into a band\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/motorcycle-viz/slit-scan.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/motorcycle-viz/slit-scan.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/slit-scan.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/motorcycle-viz/slit-scan.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Slit-scan: one column per frame, a whole ride compressed into a band\" width=\"2387\" height=\"360\" style=\"--splay-rot:1.88deg;\" data-dimensions=\"2387×360\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Slit-scan: one column per frame, a whole ride compressed into a band</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Vertical slit-scan variant\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/slit-scan-vertical.png\" alt=\"Vertical slit-scan variant\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/motorcycle-viz/slit-scan-vertical.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/motorcycle-viz/slit-scan-vertical.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/slit-scan-vertical.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/motorcycle-viz/slit-scan-vertical.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Vertical slit-scan variant\" width=\"640\" height=\"1085\" style=\"--splay-rot:-0.6deg;\" data-dimensions=\"640×1085\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Vertical slit-scan variant</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Dusk gradient — the color of the light, averaged across the golden-hour rides\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/dusk-gradient.png\" alt=\"Dusk gradient — the color of the light, averaged across the golden-hour rides\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/motorcycle-viz/dusk-gradient.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/motorcycle-viz/dusk-gradient.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/dusk-gradient.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/motorcycle-viz/dusk-gradient.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Dusk gradient — the color of the light, averaged across the golden-hour rides\" width=\"2400\" height=\"804\" style=\"--splay-rot:1.76deg;\" data-dimensions=\"2400×804\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Dusk gradient — the color of the light, averaged across the golden-hour rides</figcaption>\n</figure>\n<p class=\"\"><strong class=\"\">Cinema redux</strong> shrinks every frame to a tile and tiles them into a single mosaic — the entire footage archive as one dense image, a barcode of where I've been.</p>\n<figure role=\"figure\" aria-label=\"Cinema redux — every frame as a tile, the whole archive in one mosaic\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/cinema-redux.png\" alt=\"Cinema redux — every frame as a tile, the whole archive in one mosaic\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/motorcycle-viz/cinema-redux.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/motorcycle-viz/cinema-redux.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/cinema-redux.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/motorcycle-viz/cinema-redux.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Cinema redux — every frame as a tile, the whole archive in one mosaic\" width=\"3000\" height=\"2510\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"3000×2510\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Cinema redux — every frame as a tile, the whole archive in one mosaic</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Cinema redux, frame variant — a denser grid\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/cinema-redux-frames.png\" alt=\"Cinema redux, frame variant — a denser grid\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/motorcycle-viz/cinema-redux-frames.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/motorcycle-viz/cinema-redux-frames.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/cinema-redux-frames.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/motorcycle-viz/cinema-redux-frames.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Cinema redux, frame variant — a denser grid\" width=\"4000\" height=\"3434\" style=\"--splay-rot:-2.69deg;\" data-dimensions=\"4000×3434\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Cinema redux, frame variant — a denser grid</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"A contact-grid of frames pulled from the rides\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/frame-grid.png\" alt=\"A contact-grid of frames pulled from the rides\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/motorcycle-viz/frame-grid.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/motorcycle-viz/frame-grid.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/frame-grid.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/motorcycle-viz/frame-grid.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A contact-grid of frames pulled from the rides\" width=\"4506\" height=\"3870\" style=\"--splay-rot:-0.73deg;\" data-dimensions=\"4506×3870\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A contact-grid of frames pulled from the rides</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Dusk anthology — a tall contact sheet of golden-hour frames\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/dusk-contact.png\" alt=\"Dusk anthology — a tall contact sheet of golden-hour frames\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/motorcycle-viz/dusk-contact.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/motorcycle-viz/dusk-contact.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-viz/dusk-contact.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/motorcycle-viz/dusk-contact.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Dusk anthology — a tall contact sheet of golden-hour frames\" width=\"1940\" height=\"5684\" style=\"--splay-rot:1.7deg;\" data-dimensions=\"1940×5684\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Dusk anthology — a tall contact sheet of golden-hour frames</figcaption>\n</figure>\n<p class=\"\">So what did it all add up to? Honestly, not much you could point at. The specific moments I'd gone looking for — the train and the helicopter, the good light — washed out in the averaging. What was left was just the general shape and colors of the Hudson Valley and its roads. Unremarkable, but with a kind of haunting beauty to it. A consistency.</p>\n<p class=\"\">The real blessing turned out to be in what <em class=\"\">isn't</em> there. Across all of it — every gig of footage, every year — no accident, nothing terrible ever happened. The insurance I started rolling never had to pay out. That's the thing the average quietly confirms.</p>\n<h2 class=\"\" id=\"mapping-the-rides\">Mapping the rides</h2>\n<figure role=\"figure\" aria-label=\"A 56-mile Hudson Valley loop mapped from a Garmin file\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/moto-gpx/map.png\" alt=\"A 56-mile Hudson Valley loop mapped from a Garmin file\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/moto-gpx/map.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/moto-gpx/map.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/moto-gpx/map.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/moto-gpx/map.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A 56-mile Hudson Valley loop mapped from a Garmin file\" width=\"1180\" height=\"1380\" style=\"--splay-rot:2.48deg;\" data-dimensions=\"1180×1380\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A 56-mile Hudson Valley loop mapped from a Garmin file</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://ejfox.github.io/moto-gpx/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://ejfox.github.io/moto-gpx/\">Moto GPX<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M14.435 12.973c.269 0 .492.133.686.396c.192.265.294.588.294.975c0 .385-.102.711-.294.973c-.193.265-.417.396-.686.396c-.278 0-.522-.131-.715-.396c-.192-.262-.294-.588-.294-.973c0-.387.102-.71.294-.975c.192-.264.436-.396.715-.396m3.44-3.559c.746.811 1.125 1.795 1.125 2.953q0 1.122-.259 2.023q-.263.896-.654 1.459a4.3 4.3 0 0 1-.974.989a5 5 0 0 1-1.065.623a5.5 5.5 0 0 1-1.111.306a9 9 0 0 1-.943.123l-.685.014l-.547.015a18 18 0 0 1-1.524 0l-.547-.015l-.685-.014a9 9 0 0 1-.943-.123a5.3 5.3 0 0 1-1.111-.306a5 5 0 0 1-1.064-.623a4.3 4.3 0 0 1-.975-.989q-.392-.563-.654-1.459q-.26-.9-.259-2.023c0-1.158.379-2.143 1.125-2.953q-.123-.062-.008-1.217a7 7 0 0 1 .495-2.132c.934.099 2.09.629 3.471 1.581c.466-.119 1.101-.183 1.917-.183q1.278.002 1.918.184q.943-.638 1.805-1.034c.584-.261 1.005-.416 1.269-.457l.396-.09q.404.974.496 2.132q.114 1.154-.009 1.216m-5.845 7.82c1.688 0 2.954-.202 3.821-.607c.855-.404 1.292-1.238 1.292-2.496q0-1.096-.822-1.828a1.85 1.85 0 0 0-.989-.486q-.563-.091-1.72 0q-1.155.091-1.582.09c-.385 0-.8-.018-1.319-.059q-.78-.06-1.223-.078a3.7 3.7 0 0 0-.958.108a1.9 1.9 0 0 0-.853.425c-.521.469-.79 1.077-.79 1.828q0 1.888 1.28 2.496c.85.405 2.113.607 3.802.607zm-2.434-4.261c.268 0 .492.133.685.396c.192.265.294.588.294.975c0 .385-.102.711-.294.973c-.192.265-.417.396-.685.396c-.279 0-.522-.131-.716-.396c-.192-.262-.294-.588-.294-.973c0-.387.102-.71.294-.975c.193-.264.436-.396.716-.396\" class=\"\"></path></svg></a> dumps a folder of GPX tracks into map-ready GeoJSON split by day, hour, or stage, and can merge photos and videos in via exiftool. It also generates elevation and speed profiles, with the day's peak and top-speed moment marked.</p>","metadata":{"title":"Motorcycle Meta-Visualizations","date":"2026-05-01T04:00:00.000Z","featured":true,"category":"Art","url":"https://github.com/ejfox/motorcycle-viz","tech":["FFmpeg","Python","Generative Art","Data Visualization"],"state":"doing","ai-involvement":"ai-assisted","tags":["motorcycle","dataviz","generative","art","video"],"words":525,"images":11,"imageDetails":{"total":11,"cloudinary":11,"withDimensions":0},"links":1,"codeBlocks":0,"headers":{"h2":1},"toc":[{"text":"Mapping the rides","slug":"mapping-the-rides","level":"h2","children":[]}],"type":"post"}},{"slug":"projects/subway-builder","title":"Subway Builder","html":"<p class=\"\">The mission, as I see it: hyperstitioning public transit into every American city. Subway Builder is a game created by Colin Miller, a member of Room 302 Studio, in 2025 — a sort of Ender's Game for transit, using realistic simulations to let train-people SHOW their elected leaders what is and is not possible.</p>\n<p class=\"\">The game itself: a transit simulation (an Electron build) — design train types with realistic acceleration, capacity, and cost parameters, lay networks over real-world geography, and analyze the demand you create. It launched on Steam in July 2026. My corner of it: code contributions, Steam release infrastructure, and spin-offs like <code class=\"md-inline-code\">subway-builder-mods</code> and a Pico-8 demake.</p>\n<p class=\"\">It's also a success story for <a href=\"https://room302.studio\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://room302.studio\">Room 302 Studio</a> — Colin grew from a studio member to having the studio support his effort, and I wanna do that for more people and their dream apps and games.</p>\n<figure role=\"figure\" aria-label=\"Subway Builder construction mode — laying track in an isometric city\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780066321/projects/subway-builder/build.png\" alt=\"Subway Builder construction mode — laying track in an isometric city\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1780066321/projects/subway-builder/build.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1780066321/projects/subway-builder/build.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780066321/projects/subway-builder/build.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1780066321/projects/subway-builder/build.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Subway Builder construction mode — laying track in an isometric city\" width=\"2560\" height=\"1440\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"2560×1440\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Subway Builder construction mode — laying track in an isometric city</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"A subway network laid over real geography\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780066322/projects/subway-builder/map.png\" alt=\"A subway network laid over real geography\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1780066322/projects/subway-builder/map.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1780066322/projects/subway-builder/map.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780066322/projects/subway-builder/map.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1780066322/projects/subway-builder/map.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A subway network laid over real geography\" width=\"2556\" height=\"1518\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"2556×1518\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A subway network laid over real geography</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Train-type editor with full simulation parameters\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780066323/projects/subway-builder/trains.png\" alt=\"Train-type editor with full simulation parameters\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1780066323/projects/subway-builder/trains.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1780066323/projects/subway-builder/trains.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780066323/projects/subway-builder/trains.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1780066323/projects/subway-builder/trains.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Train-type editor with full simulation parameters\" width=\"1800\" height=\"2262\" style=\"--splay-rot:-1.9deg;\" data-dimensions=\"1800×2262\" class=\"img-full my-8 rounded-sm img-splay aspect-[3/4] w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Train-type editor with full simulation parameters</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"A 3D transit map — blue rail lines and teal stations across a metropolitan area\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1757165888/screenshot_2025-09-06_at_9.37.55_AM.png\" alt=\"A 3D transit map — blue rail lines and teal stations across a metropolitan area\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1757165888/screenshot_2025-09-06_at_9.37.55_AM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1757165888/screenshot_2025-09-06_at_9.37.55_AM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1757165888/screenshot_2025-09-06_at_9.37.55_AM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1757165888/screenshot_2025-09-06_at_9.37.55_AM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A 3D transit map — blue rail lines and teal stations across a metropolitan area\" width=\"2400\" height=\"1600\" style=\"--splay-rot:-0.84deg;\" data-dimensions=\"2400×1600\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A 3D transit map — blue rail lines and teal stations across a metropolitan area</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"A Manhattan line running in simulation — stations and real-time train arrivals\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1757696787/screenshot_2025-09-12_at_1.06.14_PM.png\" alt=\"A Manhattan line running in simulation — stations and real-time train arrivals\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1757696787/screenshot_2025-09-12_at_1.06.14_PM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1757696787/screenshot_2025-09-12_at_1.06.14_PM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1757696787/screenshot_2025-09-12_at_1.06.14_PM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1757696787/screenshot_2025-09-12_at_1.06.14_PM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A Manhattan line running in simulation — stations and real-time train arrivals\" width=\"2400\" height=\"1600\" style=\"--splay-rot:-2.89deg;\" data-dimensions=\"2400×1600\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A Manhattan line running in simulation — stations and real-time train arrivals</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Elevated parallel tracks from Vernon Ave to Bleecker St — $538M of imaginary money\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1757719938/screenshot_2025-09-12_at_7.32.05_PM.png\" alt=\"Elevated parallel tracks from Vernon Ave to Bleecker St — $538M of imaginary money\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1757719938/screenshot_2025-09-12_at_7.32.05_PM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1757719938/screenshot_2025-09-12_at_7.32.05_PM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1757719938/screenshot_2025-09-12_at_7.32.05_PM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1757719938/screenshot_2025-09-12_at_7.32.05_PM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Elevated parallel tracks from Vernon Ave to Bleecker St — $538M of imaginary money\" width=\"1796\" height=\"2262\" style=\"--splay-rot:-1.91deg;\" data-dimensions=\"1796×2262\" class=\"img-full my-8 rounded-sm img-splay aspect-[3/4] w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Elevated parallel tracks from Vernon Ave to Bleecker St — $538M of imaginary money</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Generating train service across the NYC network\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1758896118/screenshot_2025-09-26_at_10.15.05_AM.png\" alt=\"Generating train service across the NYC network\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1758896118/screenshot_2025-09-26_at_10.15.05_AM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1758896118/screenshot_2025-09-26_at_10.15.05_AM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1758896118/screenshot_2025-09-26_at_10.15.05_AM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1758896118/screenshot_2025-09-26_at_10.15.05_AM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Generating train service across the NYC network\" width=\"2400\" height=\"1600\" style=\"--splay-rot:1.11deg;\" data-dimensions=\"2400×1600\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Generating train service across the NYC network</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The construction panel — track and station options with live cost estimates\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1757693265/screenshot_2025-09-12_at_12.07.32_PM.png\" alt=\"The construction panel — track and station options with live cost estimates\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1757693265/screenshot_2025-09-12_at_12.07.32_PM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1757693265/screenshot_2025-09-12_at_12.07.32_PM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1757693265/screenshot_2025-09-12_at_12.07.32_PM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1757693265/screenshot_2025-09-12_at_12.07.32_PM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The construction panel — track and station options with live cost estimates\" width=\"2400\" height=\"1600\" style=\"--splay-rot:-2.09deg;\" data-dimensions=\"2400×1600\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The construction panel — track and station options with live cost estimates</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Achievements: 2 of 9 unlocked — Track Builder and First Station\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1757555519/screenshot_2025-09-10_at_9.51.45_PM.png\" alt=\"Achievements: 2 of 9 unlocked — Track Builder and First Station\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1757555519/screenshot_2025-09-10_at_9.51.45_PM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1757555519/screenshot_2025-09-10_at_9.51.45_PM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1757555519/screenshot_2025-09-10_at_9.51.45_PM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1757555519/screenshot_2025-09-10_at_9.51.45_PM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Achievements: 2 of 9 unlocked — Track Builder and First Station\" width=\"2400\" height=\"1600\" style=\"--splay-rot:1.06deg;\" data-dimensions=\"2400×1600\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Achievements: 2 of 9 unlocked — Track Builder and First Station</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The transit-operations event log — the simulation keeps receipts\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1759077479/screenshot_2025-09-28_at_12.37.44_PM.png\" alt=\"The transit-operations event log — the simulation keeps receipts\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1759077479/screenshot_2025-09-28_at_12.37.44_PM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1759077479/screenshot_2025-09-28_at_12.37.44_PM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1759077479/screenshot_2025-09-28_at_12.37.44_PM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1759077479/screenshot_2025-09-28_at_12.37.44_PM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The transit-operations event log — the simulation keeps receipts\" width=\"3600\" height=\"2338\" style=\"--splay-rot:0.99deg;\" data-dimensions=\"3600×2338\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The transit-operations event log — the simulation keeps receipts</figcaption>\n</figure>\n<h2 class=\"\" id=\"metro-savefile-doctor\">Metro Savefile Doctor</h2>\n<figure role=\"figure\" aria-label=\"Metro Savefile Doctor — current game analysis\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/metro-savefile-doctor/ui-1.png\" alt=\"Metro Savefile Doctor — current game analysis\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/metro-savefile-doctor/ui-1.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/metro-savefile-doctor/ui-1.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/metro-savefile-doctor/ui-1.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/metro-savefile-doctor/ui-1.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Metro Savefile Doctor — current game analysis\" width=\"2000\" height=\"1400\" style=\"--splay-rot:-1.89deg;\" data-dimensions=\"2000×1400\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Metro Savefile Doctor — current game analysis</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/metro-savefile-doctor\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/metro-savefile-doctor\">Metro Savefile Doctor<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> is a TUI (and cross-platform Tauri desktop app) for editing Subway Builder save files, in both JSON and <code class=\"md-inline-code\">.metro</code> binary formats, with auto-backup. It edits money, game time, train count, and ticket price through an interactive terminal interface with a crackintro aesthetic.</p>","metadata":{"title":"Subway Builder","date":"2026-05-01T04:00:00.000Z","category":"Games","featured":true,"url":"https://github.com/ejfox/subway-builder-mods","tech":["JavaScript","Simulation","Transit","Game Modding"],"state":"evolved","ai-involvement":"ai-assisted","tags":["games","simulation","transit","dataviz"],"words":306,"images":11,"imageDetails":{"total":11,"cloudinary":11,"withDimensions":0},"links":2,"codeBlocks":0,"headers":{"h2":1},"toc":[{"text":"Metro Savefile Doctor","slug":"metro-savefile-doctor","level":"h2","children":[]}],"type":"post"}},{"slug":"projects/tv-station","title":"TV Station","html":"<p class=\"\">On-demand broke the one good thing about cable: everybody watching the same thing at the same second — no pausing, nobody skipping ahead, no algorithm picking for you. So I built my own channel. Everyone who tunes in to <a href=\"https://tv.tools.ejfox.com\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://tv.tools.ejfox.com\">tv.tools.ejfox.com</a> lands on the exact same frame, because the playhead is computed deterministically from a fixed epoch and the running sum of every video's duration. No database, no per-client state to keep in sync — just a single-file Deno app on smallweb. There's a live \"N other viewers\" counter too, so you can feel whether it's just you or a whole room.</p>\n<p class=\"\">The room I built it for is a little Discord community — people learning things together. Everyone can tune in and out and comment on what's happening at the same time, even though underneath it's just a playlist looping.</p>\n<figure role=\"figure\" aria-label=\"The station mid-loop — everyone watching the same frame of the 224-hour broadcast\" class=\"\"><video class=\"img-full my-8 rounded-sm\" autoplay loop muted playsinline preload=\"metadata\" poster=\"https://res.cloudinary.com/ejf/video/upload/so_0,f_jpg,q_auto,w_1280/projects/tv-station/demo.jpg\" src=\"https://res.cloudinary.com/ejf/video/upload/q_auto/projects/tv-station/demo.mp4\" aria-label=\"The station mid-loop — everyone watching the same frame of the 224-hour broadcast\"></video><figcaption class=\"\">The station mid-loop — everyone watching the same frame of the 224-hour broadcast</figcaption></figure>\n<p class=\"\">What's actually playing is my own YouTube \"Watch Later\" list — 507 saved links, enriched down to the 475 that are still alive, about 224 hours of runtime looping around. So tuning in is really watching over my shoulder through the backlog I never got around to. It's a whole cable channel, and it's also just... my saved videos.</p>\n<figure role=\"figure\" aria-label=\"The TV station landing — tuned to the channel\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/tv-station/landing.png\" alt=\"The TV station landing — tuned to the channel\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/tv-station/landing.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/tv-station/landing.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/tv-station/landing.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/tv-station/landing.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The TV station landing — tuned to the channel\" width=\"1440\" height=\"900\" style=\"--splay-rot:-1.97deg;\" data-dimensions=\"1440×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The TV station landing — tuned to the channel</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"tv.tools.ejfox.com live\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/tv-station/web-0.png\" alt=\"tv.tools.ejfox.com live\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/tv-station/web-0.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/tv-station/web-0.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/tv-station/web-0.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/tv-station/web-0.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"tv.tools.ejfox.com live\" width=\"1440\" height=\"900\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1440×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">tv.tools.ejfox.com live</figcaption>\n</figure>\n<p class=\"\">I also wrote up a Pi 5 kiosk recipe so it runs full-screen on the wall TV — an actual television, tuned to my channel.</p>\n<p class=\"\">The whole thing is <a href=\"https://github.com/ejfox/tv-station\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/tv-station\">open source<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a>, and the point is that it's <em class=\"\">yours</em>, not mine. It's one ~140-line Deno file plus a small Python recipe that eats a Watch Later export, an Obsidian or Notion vault, or a plain CSV and builds the playlist for you. Point it at your own backlog, drop it on smallweb behind any reverse proxy, and you've got your own public-access channel in an afternoon.</p>","metadata":{"title":"TV Station","date":"2026-05-01T04:00:00.000Z","category":"Art","featured":false,"url":"https://tv.tools.ejfox.com","tech":["TypeScript","Deno","Smallweb"],"state":"deployed","ai-involvement":"ai-assisted","tags":["web","experiment"],"words":318,"images":3,"imageDetails":{"total":3,"cloudinary":3,"withDimensions":0},"links":2,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/github-sloth","title":"GitHub Sloth","html":"<!-- TODO (EJ): NEEDS A HERO IMAGE: a terminal screenshot. -->\n<p class=\"\">A minimalist amber-on-black terminal UI for GitHub PRs, reviews, and checks, written in Rust. It is designed to be complementary to lazygit.</p>\n<p class=\"\">I wanted my own top-level minimalist view of all of my open work — most of my work collaborations happen through GitHub. It's part of a process of redesigning all of my tools and daily interfaces to the world with an eye towards exactly what I need and what serves me.</p>\n<p class=\"\">I've been having a lot of fun lately writing minimalist TUIs I can run in tmux panes — my workspace lately is often just a full-screen terminal with different tmux panes and windows, and I really like it when the interface is just text. It lets me think about the functionality and core offerings rather than diving into padding and spacing and layout the way I do in CSS and front-end.</p>\n<figure role=\"figure\" aria-label=\"The PR rollup logic in Rust — status glyphs and age coloring\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/github-sloth/code.png\" alt=\"The PR rollup logic in Rust — status glyphs and age coloring\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/github-sloth/code.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/github-sloth/code.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/github-sloth/code.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/github-sloth/code.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The PR rollup logic in Rust — status glyphs and age coloring\" width=\"1400\" height=\"900\" style=\"--splay-rot:1.75deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The PR rollup logic in Rust — status glyphs and age coloring</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The TUI rendering — ratatui layout and styled spans for the PR list\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/github-sloth/code2.png\" alt=\"The TUI rendering — ratatui layout and styled spans for the PR list\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/github-sloth/code2.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/github-sloth/code2.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/github-sloth/code2.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/github-sloth/code2.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The TUI rendering — ratatui layout and styled spans for the PR list\" width=\"1400\" height=\"900\" style=\"--splay-rot:1.34deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The TUI rendering — ratatui layout and styled spans for the PR list</figcaption>\n</figure>","metadata":{"title":"GitHub Sloth","date":"2026-05-01T00:00:00.000Z","category":"Tools","featured":false,"draft":false,"url":"https://github.com/ejfox/github-sloth","tech":["Rust"],"state":"deployed","ai-involvement":"ai-assisted","tags":["cli","terminal","git"],"words":180,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/clipgoblin","title":"clipgoblin","html":"<figure role=\"figure\" aria-label=\"Clipgoblin running in a vulpes-themed terminal\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/clipgoblin/terminal.png\" alt=\"Clipgoblin running in a vulpes-themed terminal\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/clipgoblin/terminal.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/clipgoblin/terminal.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/clipgoblin/terminal.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/clipgoblin/terminal.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Clipgoblin running in a vulpes-themed terminal\" width=\"1351\" height=\"600\" style=\"--splay-rot:-1.8deg;\" data-dimensions=\"1351×600\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Clipgoblin running in a vulpes-themed terminal</figcaption>\n</figure>\n<p class=\"\">clipgoblin came out of a real editing problem. I was buried in a corpus of long recorded talks and lectures — hours of it — that I needed to actually navigate for a research project, and scrubbing Final Cut timelines to find the one moment someone said the thing was breaking me. So I built a tool that reads the transcript and drops a marker at every concept-relevant moment straight into the Final Cut timeline. Now I move through hours of footage by hitting <code class=\"md-inline-code\">Ctrl+'</code> instead of scrubbing.</p>\n<figure role=\"figure\" aria-label=\"The CLI — generating concept-tagged FCPXML markers from a words.json transcript\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/clipgoblin/code2.png\" alt=\"The CLI — generating concept-tagged FCPXML markers from a words.json transcript\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/clipgoblin/code2.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/clipgoblin/code2.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/clipgoblin/code2.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/clipgoblin/code2.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The CLI — generating concept-tagged FCPXML markers from a words.json transcript\" width=\"1400\" height=\"900\" style=\"--splay-rot:-1.52deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The CLI — generating concept-tagged FCPXML markers from a words.json transcript</figcaption>\n</figure>\n<p class=\"\">Under the hood it shells out to Whisper for word-level transcription, then generates concept-tagged FCPXML markers from the resulting <code class=\"md-inline-code\">words.json</code> — so each marker lands on the exact word, not a rough guess.</p>\n<figure role=\"figure\" aria-label=\"Resolving and shelling out to whisper for word-level transcription\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/clipgoblin/code.png\" alt=\"Resolving and shelling out to whisper for word-level transcription\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/clipgoblin/code.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/clipgoblin/code.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/clipgoblin/code.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/clipgoblin/code.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Resolving and shelling out to whisper for word-level transcription\" width=\"1400\" height=\"900\" style=\"--splay-rot:1.54deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Resolving and shelling out to whisper for word-level transcription</figcaption>\n</figure>\n<h2 class=\"\" id=\"related-video-tools\">Related video tools</h2>\n<h3 class=\"\" id=\"sentry-search\">Sentry Search</h3>\n<figure role=\"figure\" aria-label=\"sentrysearch CLI help output listing the index, search, img, highlights, shell, and stats commands\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1785421788/projects/sentrysearch/cli-help.png\" alt=\"sentrysearch CLI help output listing the index, search, img, highlights, shell, and stats commands\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1785421788/projects/sentrysearch/cli-help.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1785421788/projects/sentrysearch/cli-help.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1785421788/projects/sentrysearch/cli-help.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1785421788/projects/sentrysearch/cli-help.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"sentrysearch CLI help output listing the index, search, img, highlights, shell, and stats commands\" width=\"1200\" height=\"560\" style=\"--splay-rot:1.96deg;\" data-dimensions=\"1200×560\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">sentrysearch CLI help output listing the index, search, img, highlights, shell, and stats commands</figcaption>\n</figure>\n<p class=\"\">Sentry Search is semantic search over video footage: type a query and get back the matching trimmed clip, instead of scrubbing hours of timeline. Its CLI covers indexing, search, image search, highlights, and stats.</p>\n<h3 class=\"\" id=\"transcript-video\">Transcript Video</h3>\n<figure role=\"figure\" aria-label=\"Synced transcript with concept color-tagging — a word appears in Archivo Black timed to speech, with tagged terms highlighted and a QR provenance code\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1789191612/projects/transcript-video/landing.png\" alt=\"Synced transcript with concept color-tagging — a word appears in Archivo Black timed to speech, with tagged terms highlighted and a QR provenance code\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1789191612/projects/transcript-video/landing.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1789191612/projects/transcript-video/landing.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1789191612/projects/transcript-video/landing.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1789191612/projects/transcript-video/landing.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Synced transcript with concept color-tagging — a word appears in Archivo Black timed to speech, with tagged terms highlighted and a QR provenance code\" width=\"1920\" height=\"1080\" style=\"--splay-rot:2.57deg;\" data-dimensions=\"1920×1080\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Synced transcript with concept color-tagging — a word appears in Archivo Black timed to speech, with tagged terms highlighted and a QR provenance code</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/transcript-video\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/transcript-video\">Transcript Video<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> generates 1080p synced transcript videos from audio, with word-level timing and concept color-tagging. It includes audio-reactive visualizations: a spectrogram, oscilloscopes, and a Lissajous phase scope.</p>","metadata":{"title":"clipgoblin","date":"2026-04-29T05:00:00.000Z","category":"Tools","featured":false,"tech":["Swift"],"state":"deployed","ai-involvement":"ai-assisted","tags":["tools"],"words":256,"images":5,"imageDetails":{"total":5,"cloudinary":5,"withDimensions":0},"links":1,"codeBlocks":0,"headers":{"h2":1},"toc":[{"text":"Related video tools","slug":"related-video-tools","level":"h2","children":[{"text":"Sentry Search","slug":"sentry-search","level":"h3","children":[]},{"text":"Transcript Video","slug":"transcript-video","level":"h3","children":[]}]}],"type":"post"}},{"slug":"projects/cyberdeck-saver","title":"cyberdeck-saver","html":"<p class=\"\">Most cyberpunk desktops are set dressing — fake terminals scrolling gibberish for the vibe. This one is real. cyberdeck-saver is a native macOS screensaver, written in Swift and Metal, that turns your idle screen into an actual command center: a 5×5 grid of 25 terminal panels, each typing itself out with live data and post-processed through a CRT shader chain in the vulpes palette.</p>\n<p class=\"\">The rule behind it is one line from the README — <em class=\"\">no fake data; every byte on screen comes from a real API or a system call.</em> So the panels are wired to things that are actually happening: aircraft crossing the airspace over the Hudson Valley on live ADS-B, earthquakes above magnitude 2.5 in the last day, the planetary K-index off NOAA's space-weather feed, the ISS's current distance from wherever you're sitting. Next to those runs your own telemetry — Apple Health, system thermals, GitHub commits, chess ratings, what's playing on Last.fm — and your scrapbook's extracted knowledge, down to the claim-triples and the entities trending across your last fifty saves.</p>\n<figure role=\"figure\" aria-label=\"The data streams — each terminal panel fetches and parses its own feed; here, haversine math for scrapbook distances\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cyberdeck-saver/streams.png\" alt=\"The data streams — each terminal panel fetches and parses its own feed; here, haversine math for scrapbook distances\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/cyberdeck-saver/streams.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/cyberdeck-saver/streams.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cyberdeck-saver/streams.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/cyberdeck-saver/streams.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The data streams — each terminal panel fetches and parses its own feed; here, haversine math for scrapbook distances\" width=\"1400\" height=\"900\" style=\"--splay-rot:-1.95deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The data streams — each terminal panel fetches and parses its own feed; here, haversine math for scrapbook distances</figcaption>\n</figure>\n<p class=\"\">Every panel is config-driven, the grid auto-scales to however many you switch on, and they share a single process-wide cache so twenty-five live feeds never hammer a source.</p>\n<figure role=\"figure\" aria-label=\"The Metal fragment shader behind the screensaver&#x27;s CRT look\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cyberdeck-saver/code.png\" alt=\"The Metal fragment shader behind the screensaver&#x27;s CRT look\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/cyberdeck-saver/code.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/cyberdeck-saver/code.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cyberdeck-saver/code.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/cyberdeck-saver/code.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The Metal fragment shader behind the screensaver&#x27;s CRT look\" width=\"1400\" height=\"900\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The Metal fragment shader behind the screensaver's CRT look</figcaption>\n</figure>","metadata":{"title":"cyberdeck-saver","date":"2026-04-20T04:00:00.000Z","category":"Tools","featured":false,"url":"https://github.com/ejfox/cyberdeck-saver","tech":["Swift","Metal","macOS","OSINT"],"state":"deployed","ai-involvement":"ai-assisted","tags":["macos","osint","design"],"words":231,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/tempo","title":"Tempo","html":"<p class=\"\">A brutalist Pomodoro timer for iPhone and Apple Watch — edge-trace progress around the screen and adaptive themes</p>\n<p class=\"\">I wanted a Pomodoro that showed a unit chart and had a great visual look — something to include in YouTube videos and show my friends, like a designer watch. Elegant, Swiss-feeling, smooth advancing, with some social features like rings.</p>\n<p class=\"\">Next, I'd love to automatically ping a robot or my VPS when a Pomo session ends, or track it in my Grafana with a quantified-self vibe, or ping my Hue lights to do a little dance.</p>\n<p class=\"\">I've used Tempo a fair bit, and some folks from The Vestibule have been beta testing it for me on TestFlight too.</p>\n<figure role=\"figure\" aria-label=\"Tempo on iPhone\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780065003/projects/tempo/1.png\" alt=\"Tempo on iPhone\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1780065003/projects/tempo/1.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1780065003/projects/tempo/1.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780065003/projects/tempo/1.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1780065003/projects/tempo/1.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Tempo on iPhone\" width=\"968\" height=\"2060\" style=\"--splay-rot:2.4deg;\" data-dimensions=\"968×2060\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Tempo on iPhone</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Tempo session view\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780065005/projects/tempo/2.png\" alt=\"Tempo session view\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1780065005/projects/tempo/2.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1780065005/projects/tempo/2.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780065005/projects/tempo/2.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1780065005/projects/tempo/2.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Tempo session view\" width=\"968\" height=\"2060\" style=\"--splay-rot:-0.63deg;\" data-dimensions=\"968×2060\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Tempo session view</figcaption>\n</figure>","metadata":{"title":"Tempo","date":"2026-04-01T04:00:00.000Z","category":"Apps","featured":false,"draft":false,"tech":["Swift","SwiftUI","iOS","watchOS"],"state":"deployed","ai-involvement":"ai-assisted","tags":["ios","app","design"],"words":122,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/vulpes-devices","title":"Vulpes Devices","html":"<p class=\"\">The hardware arm of the <strong class=\"\">Vulpes</strong> look — one palette, one glyph, generated out to seven device targets: Flipper Zero, OnionOS handhelds, the TD-H3 radio, Meshtastic nodes, a PortaPack, plus iPhone and iMac wallpapers. It takes the cyberpunk-neon Vulpes scheme from my editor and pushes it down onto the little screens, so my devices boot and theme in the same visual language as everything else I make.</p>\n<figure role=\"figure\" aria-label=\"One look, many targets — the Vulpes glyph and palette generated across Flipper, OnionOS, TD-H3, Meshtastic, PortaPack, and the desktop\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-devices/devices.png\" alt=\"One look, many targets — the Vulpes glyph and palette generated across Flipper, OnionOS, TD-H3, Meshtastic, PortaPack, and the desktop\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpes-devices/devices.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpes-devices/devices.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-devices/devices.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpes-devices/devices.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"One look, many targets — the Vulpes glyph and palette generated across Flipper, OnionOS, TD-H3, Meshtastic, PortaPack, and the desktop\" width=\"1524\" height=\"656\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1524×656\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">One look, many targets — the Vulpes glyph and palette generated across Flipper, OnionOS, TD-H3, Meshtastic, PortaPack, and the desktop</figcaption>\n</figure>\n<p class=\"\">Everything derives from a single shared palette file — the same <code class=\"md-inline-code\">#e60067</code> magenta and <code class=\"md-inline-code\">#6eedf7</code> teal that run through the rest of the Vulpes system — so a new device target is mostly a matter of teaching the generator that platform's theme format. The scheme itself is open: it grew out of <a href=\"https://github.com/ejfox/vulpes.nvim\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/vulpes.nvim\">vulpes.nvim<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a>, and <a href=\"https://github.com/ejfox/vulpes-theme-lab\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/vulpes-theme-lab\">vulpes-theme-lab<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> is a live builder for spinning up your own variant.</p>\n<figure role=\"figure\" aria-label=\"The Flipper Zero boot animation, generated straight from the palette — the fox, on a 128×64 monochrome screen\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-devices/boot.gif\" alt=\"The Flipper Zero boot animation, generated straight from the palette — the fox, on a 128×64 monochrome screen\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpes-devices/boot.gif 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpes-devices/boot.gif 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-devices/boot.gif 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpes-devices/boot.gif 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The Flipper Zero boot animation, generated straight from the palette — the fox, on a 128×64 monochrome screen\" style=\"--splay-rot:1.52deg;\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The Flipper Zero boot animation, generated straight from the palette — the fox, on a 128×64 monochrome screen</figcaption>\n</figure>","metadata":{"title":"Vulpes Devices","date":"2026-03-15T04:00:00.000Z","category":"Hardware","featured":false,"tech":["Flipper Zero","OnionOS","Theming","JavaScript"],"state":"deployed","ai-involvement":"ai-assisted","tags":["vulpes","flipper","hardware","device","design"],"words":170,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":2,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/connectology","title":"Connectology","html":"<p class=\"\">Connectology is for me — a custom front-end replacement for Gephi or a raw D3 force layout: build graphs of nodes and links, then analyze the resulting structure with force-directed layout and graph metrics. The v2 rebuild adds AI-powered node generation and multiple import formats (Obsidian, Mermaid, CSV).</p>\n<p class=\"\">Here's the kind of thing it's for. The other day I had a pretty cool idea — I figured I'd put 60 minutes on the clock and give it a shot. I wanted the robot to read all of the articles and bookmarks I've made in the past 10 years — there's over 3,000 of them — and make a huge knowledge graph of the connections between all of the entities in everything I've ever read. I built a little scraper that extracts relationships from the text of an article, and made a huge database of nodes and edges: who influences who, who is associated with who, what movements and philosophies and schools of thought different people come from. That's not always explicitly stated in the text you're reading — it's part of critical thinking, keeping in your mind what a source's worldview is — and this is an attempt at capturing that in data. Then I wanted to actually <em class=\"\">see</em> them, so I pulled the table into Connectology. It didn't yet have a CSV import, so I had to build one real quick, and then there it was: 600 nodes of my mental model as a big network on the screen. You can imagine sending the robot a couple degrees of that network to inform its responses. But I just wanted to see a cool graph, I'm going to be honest, and I did it in an hour.</p>\n<p class=\"\">That session is on YouTube: <a href=\"https://www.youtube.com/watch?v=kHknnQbIoQU\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.youtube.com/watch?v=kHknnQbIoQU\">Turning My Bookmarks Into A Knowledge Graph<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M23.498 6.186a3.02 3.02 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.02 3.02 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.02 3.02 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.02 3.02 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814M9.545 15.568V8.432L15.818 12z\" class=\"\"></path></svg></a>.</p>\n<figure role=\"figure\" aria-label=\"The force layout re-distributing a funding network — scatter, settle, repeat\" class=\"\"><video class=\"img-full my-8 rounded-sm\" autoplay loop muted playsinline preload=\"metadata\" poster=\"https://res.cloudinary.com/ejf/video/upload/so_0,f_jpg,q_auto,w_1280/projects/connectology/force-demo.jpg\" src=\"https://res.cloudinary.com/ejf/video/upload/q_auto/projects/connectology/force-demo.mp4\" aria-label=\"The force layout re-distributing a funding network — scatter, settle, repeat\"></video><figcaption class=\"\">The force layout re-distributing a funding network — scatter, settle, repeat</figcaption></figure>\n<figure role=\"figure\" aria-label=\"The editor: force controls, WebGL rendering, and a network of nodes mid-layout\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/connectology/editor.png\" alt=\"The editor: force controls, WebGL rendering, and a network of nodes mid-layout\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/connectology/editor.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/connectology/editor.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/connectology/editor.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/connectology/editor.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The editor: force controls, WebGL rendering, and a network of nodes mid-layout\" width=\"1440\" height=\"949\" style=\"--splay-rot:2.82deg;\" data-dimensions=\"1440×949\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The editor: force controls, WebGL rendering, and a network of nodes mid-layout</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Your networks — the home shelf with live previews of each graph\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/connectology/networks-home.png\" alt=\"Your networks — the home shelf with live previews of each graph\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/connectology/networks-home.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/connectology/networks-home.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/connectology/networks-home.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/connectology/networks-home.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Your networks — the home shelf with live previews of each graph\" width=\"1440\" height=\"949\" style=\"--splay-rot:-2.02deg;\" data-dimensions=\"1440×949\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Your networks — the home shelf with live previews of each graph</figcaption>\n</figure>","metadata":{"title":"Connectology","date":"2026-03-01T00:00:00.000Z","category":"Dataviz","featured":false,"url":"https://connectology.room302.studio/","tech":["Nuxt 3","Vue 3","D3","graphology","SQLite","Clerk"],"state":"deployed","ai-involvement":"ai-assisted","context":"collaborative","tags":["dataviz","network","web"],"about":"[[project-notes/connectology]]","words":334,"images":3,"imageDetails":{"total":3,"cloudinary":3,"withDimensions":0},"links":1,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/coachartie-memory","title":"Coach Artie — Memory Analysis","html":"<!-- TODO (EJ): voice done. Abstract/structural charts only; no readable memory\n     contents shown. -->\n<p class=\"\">What does an AI agent actually <em class=\"\">remember</em>? Coach Artie has been accumulating memory for as long as he's been running, so I embedded all of it, clustered it, and drew the map.</p>\n<p class=\"\">I expected noise. What came back was sorted — clean clusters, things filed where they belonged, distinctions I never asked him to make.</p>\n<p class=\"\">Reading it was the strange part. The clusters kept surfacing episodes I'd completely forgotten about, sitting right there where he'd put them. He really was listening the whole time, and thinking about what was said.</p>\n<figure role=\"figure\" aria-label=\"Cognitive topology — Coach Artie&#x27;s memories embedded and laid out as a map\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/coachartie-memory/topology.png\" alt=\"Cognitive topology — Coach Artie&#x27;s memories embedded and laid out as a map\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/coachartie-memory/topology.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/coachartie-memory/topology.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/coachartie-memory/topology.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/coachartie-memory/topology.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Cognitive topology — Coach Artie&#x27;s memories embedded and laid out as a map\" width=\"1800\" height=\"1337\" style=\"--splay-rot:-2.37deg;\" data-dimensions=\"1800×1337\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Cognitive topology — Coach Artie's memories embedded and laid out as a map</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Hierarchical clustering of the memory space, as a dendrogram\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/coachartie-memory/hierarchy.png\" alt=\"Hierarchical clustering of the memory space, as a dendrogram\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/coachartie-memory/hierarchy.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/coachartie-memory/hierarchy.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/coachartie-memory/hierarchy.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/coachartie-memory/hierarchy.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Hierarchical clustering of the memory space, as a dendrogram\" width=\"1800\" height=\"897\" style=\"--splay-rot:-0.76deg;\" data-dimensions=\"1800×897\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Hierarchical clustering of the memory space, as a dendrogram</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"t-SNE small multiples across the memory clusters\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/coachartie-memory/tsne.png\" alt=\"t-SNE small multiples across the memory clusters\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/coachartie-memory/tsne.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/coachartie-memory/tsne.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/coachartie-memory/tsne.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/coachartie-memory/tsne.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"t-SNE small multiples across the memory clusters\" width=\"1800\" height=\"1777\" style=\"--splay-rot:0.93deg;\" data-dimensions=\"1800×1777\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">t-SNE small multiples across the memory clusters</figcaption>\n</figure>\n<p class=\"\">Beyond the static map, the analysis tracks how the memory <em class=\"\">changes</em> — which clusters grow, when memories form, and how the topology shifts over time.</p>\n<figure role=\"figure\" aria-label=\"Temporal cluster evolution — how the memory grows and shifts\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/coachartie-memory/temporal.png\" alt=\"Temporal cluster evolution — how the memory grows and shifts\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/coachartie-memory/temporal.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/coachartie-memory/temporal.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/coachartie-memory/temporal.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/coachartie-memory/temporal.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Temporal cluster evolution — how the memory grows and shifts\" width=\"1800\" height=\"1116\" style=\"--splay-rot:-1.68deg;\" data-dimensions=\"1800×1116\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Temporal cluster evolution — how the memory grows and shifts</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Cognitive activity calendar — daily memory-formation patterns\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/coachartie-memory/calendar.png\" alt=\"Cognitive activity calendar — daily memory-formation patterns\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/coachartie-memory/calendar.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/coachartie-memory/calendar.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/coachartie-memory/calendar.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/coachartie-memory/calendar.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Cognitive activity calendar — daily memory-formation patterns\" width=\"1800\" height=\"966\" style=\"--splay-rot:2.13deg;\" data-dimensions=\"1800×966\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Cognitive activity calendar — daily memory-formation patterns</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Qualitative cluster heatmap\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/coachartie-memory/heatmap.png\" alt=\"Qualitative cluster heatmap\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/coachartie-memory/heatmap.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/coachartie-memory/heatmap.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/coachartie-memory/heatmap.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/coachartie-memory/heatmap.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Qualitative cluster heatmap\" width=\"1800\" height=\"1272\" style=\"--splay-rot:-2.48deg;\" data-dimensions=\"1800×1272\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Qualitative cluster heatmap</figcaption>\n</figure>","metadata":{"title":"Coach Artie — Memory Analysis","date":"2026-02-01T05:00:00.000Z","category":"Dataviz","featured":false,"tech":["Python","t-SNE","HDBSCAN","Embeddings","Data Visualization"],"state":"doing","ai-involvement":"ai-collaborative","context":"collaborative","tags":["dataviz","data","ai","embeddings"],"words":178,"images":6,"imageDetails":{"total":6,"cloudinary":6,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/vulpes-browser","title":"Vulpes Browser","html":"<p class=\"\">Minimalist web browser. Zig + Swift + Metal — rendered entirely on the GPU, with particle effects, link glow, and a two-pass bloom.</p>\n<p class=\"\">I really wanted to experiment with creating a browser but making every choice myself, centered around how I use and consume the internet — from first principles, aided by a super-intelligent robot. What would a new browser made today, with speed and performance as a central tenet, look like?</p>\n<p class=\"\">Zig, Swift, Metal: barebones, non-web, performant, and they interest me.</p>\n<figure role=\"figure\" aria-label=\"The vulpes browser rendering ejfox.com\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes/browser.png\" alt=\"The vulpes browser rendering ejfox.com\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpes/browser.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpes/browser.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes/browser.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpes/browser.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The vulpes browser rendering ejfox.com\" width=\"2400\" height=\"1656\" style=\"--splay-rot:2.75deg;\" data-dimensions=\"2400×1656\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The vulpes browser rendering ejfox.com</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The Metal render pipeline — solid, glyph, particle, glow, and a two-pass bloom\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-browser/code.png\" alt=\"The Metal render pipeline — solid, glyph, particle, glow, and a two-pass bloom\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpes-browser/code.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpes-browser/code.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-browser/code.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpes-browser/code.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The Metal render pipeline — solid, glyph, particle, glow, and a two-pass bloom\" width=\"1400\" height=\"900\" style=\"--splay-rot:-0.6deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The Metal render pipeline — solid, glyph, particle, glow, and a two-pass bloom</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The GPU image atlas — texture packing, zero-copy Metal textures, and an LRU cache for fast image rendering\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-browser/atlas.png\" alt=\"The GPU image atlas — texture packing, zero-copy Metal textures, and an LRU cache for fast image rendering\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpes-browser/atlas.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpes-browser/atlas.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-browser/atlas.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpes-browser/atlas.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The GPU image atlas — texture packing, zero-copy Metal textures, and an LRU cache for fast image rendering\" width=\"1400\" height=\"900\" style=\"--splay-rot:0.79deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The GPU image atlas — texture packing, zero-copy Metal textures, and an LRU cache for fast image rendering</figcaption>\n</figure>","metadata":{"title":"Vulpes Browser","date":"2026-01-20T05:00:00.000Z","category":"Tools","featured":false,"draft":false,"url":"https://github.com/ejfox/vulpes-browser","tech":["Swift"],"state":"deployed","ai-involvement":"ai-assisted","tags":["tools"],"words":118,"images":3,"imageDetails":{"total":3,"cloudinary":3,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/vulpes-nvim","title":"vulpes.nvim","html":"<p class=\"\">A cyberpunk neon colorscheme for Neovim — signature vulpes pink, teal comments, dark and light variants, and monthly color rotations. Ships with matching themes for the rest of the terminal: wezterm, kitty, ghostty, alacritty, tmux, lazygit, bat, and fzf. It's the seed of the whole <a href=\"/projects/vulpes-system\" class=\"text-blue-600 dark:text-blue-400\">Vulpes</a> look.</p>\n<figure role=\"figure\" aria-label=\"The vulpes colorscheme rendering its own palette file in Neovim\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-nvim/editor.png\" alt=\"The vulpes colorscheme rendering its own palette file in Neovim\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpes-nvim/editor.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpes-nvim/editor.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-nvim/editor.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpes-nvim/editor.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The vulpes colorscheme rendering its own palette file in Neovim\" width=\"1300\" height=\"820\" style=\"--splay-rot:-1.07deg;\" data-dimensions=\"1300×820\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The vulpes colorscheme rendering its own palette file in Neovim</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The vulpes palette across a full terminal — waveforms, logs, and the fox mark\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-nvim/theme1.png\" alt=\"The vulpes palette across a full terminal — waveforms, logs, and the fox mark\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpes-nvim/theme1.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpes-nvim/theme1.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-nvim/theme1.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpes-nvim/theme1.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The vulpes palette across a full terminal — waveforms, logs, and the fox mark\" width=\"3600\" height=\"2262\" style=\"--splay-rot:2.62deg;\" data-dimensions=\"3600×2262\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The vulpes palette across a full terminal — waveforms, logs, and the fox mark</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"vulpes.nvim editing its own source in Neovim\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-nvim/theme2.png\" alt=\"vulpes.nvim editing its own source in Neovim\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpes-nvim/theme2.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpes-nvim/theme2.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-nvim/theme2.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpes-nvim/theme2.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"vulpes.nvim editing its own source in Neovim\" width=\"3600\" height=\"2338\" style=\"--splay-rot:-0.6deg;\" data-dimensions=\"3600×2338\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">vulpes.nvim editing its own source in Neovim</figcaption>\n</figure>","metadata":{"title":"vulpes.nvim","date":"2026-01-18T05:00:00.000Z","category":"Tools","featured":false,"url":"https://github.com/ejfox/vulpes.nvim","tech":["Lua"],"state":"deployed","ai-involvement":"ai-assisted","tags":["tools"],"words":78,"images":3,"imageDetails":{"total":3,"cloudinary":3,"withDimensions":0},"links":1,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/vulpes-system","title":"Vulpes","html":"<p class=\"\">I believe that if I spend so much time looking at and thinking about my computer, I want to have full control over the fonts and colors I look at — to make it cinematic, aesthetic, \"me,\" and different. And it's so dope that when I go to coffee shops (true story), people stop me and say \"that setup looks sick, can I give you my card in case I need a dev?\"</p>\n<p class=\"\">Vulpes is what that belief turns into when you follow it all the way down. One palette — <code class=\"md-inline-code\">#e60067</code> magenta, <code class=\"md-inline-code\">#6eedf7</code> teal, near-black — and one fox glyph, pushed through every surface I touch: the editor, the terminal, the browser, the screensaver, and the little screens on the hardware in my bag. Each piece is a working tool. Together they're one look.</p>\n<figure role=\"figure\" aria-label=\"The vulpes palette across a full terminal — waveforms, logs, and the fox mark\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-nvim/theme1.png\" alt=\"The vulpes palette across a full terminal — waveforms, logs, and the fox mark\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpes-nvim/theme1.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpes-nvim/theme1.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-nvim/theme1.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpes-nvim/theme1.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The vulpes palette across a full terminal — waveforms, logs, and the fox mark\" width=\"3600\" height=\"2262\" style=\"--splay-rot:2.62deg;\" data-dimensions=\"3600×2262\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The vulpes palette across a full terminal — waveforms, logs, and the fox mark</figcaption>\n</figure>\n<h2 class=\"\" id=\"the-editor\">The editor</h2>\n<p class=\"\">It started with <a href=\"/projects/vulpes-nvim\" class=\"text-blue-600 dark:text-blue-400\">vulpes.nvim</a>: a cyberpunk neon colorscheme for Neovim — signature vulpes pink, teal comments, dark and light variants, and monthly color rotations. It ships matching themes for the rest of the terminal: wezterm, kitty, ghostty, alacritty, tmux, lazygit, bat, and fzf.</p>\n<figure role=\"figure\" aria-label=\"The vulpes colorscheme rendering its own palette file in Neovim\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-nvim/editor.png\" alt=\"The vulpes colorscheme rendering its own palette file in Neovim\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpes-nvim/editor.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpes-nvim/editor.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-nvim/editor.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpes-nvim/editor.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The vulpes colorscheme rendering its own palette file in Neovim\" width=\"1300\" height=\"820\" style=\"--splay-rot:-1.07deg;\" data-dimensions=\"1300×820\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The vulpes colorscheme rendering its own palette file in Neovim</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://ejfox.github.io/vulpes-theme-lab/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://ejfox.github.io/vulpes-theme-lab/\">Vulpes Theme Lab<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M14.435 12.973c.269 0 .492.133.686.396c.192.265.294.588.294.975c0 .385-.102.711-.294.973c-.193.265-.417.396-.686.396c-.278 0-.522-.131-.715-.396c-.192-.262-.294-.588-.294-.973c0-.387.102-.71.294-.975c.192-.264.436-.396.715-.396m3.44-3.559c.746.811 1.125 1.795 1.125 2.953q0 1.122-.259 2.023q-.263.896-.654 1.459a4.3 4.3 0 0 1-.974.989a5 5 0 0 1-1.065.623a5.5 5.5 0 0 1-1.111.306a9 9 0 0 1-.943.123l-.685.014l-.547.015a18 18 0 0 1-1.524 0l-.547-.015l-.685-.014a9 9 0 0 1-.943-.123a5.3 5.3 0 0 1-1.111-.306a5 5 0 0 1-1.064-.623a4.3 4.3 0 0 1-.975-.989q-.392-.563-.654-1.459q-.26-.9-.259-2.023c0-1.158.379-2.143 1.125-2.953q-.123-.062-.008-1.217a7 7 0 0 1 .495-2.132c.934.099 2.09.629 3.471 1.581c.466-.119 1.101-.183 1.917-.183q1.278.002 1.918.184q.943-.638 1.805-1.034c.584-.261 1.005-.416 1.269-.457l.396-.09q.404.974.496 2.132q.114 1.154-.009 1.216m-5.845 7.82c1.688 0 2.954-.202 3.821-.607c.855-.404 1.292-1.238 1.292-2.496q0-1.096-.822-1.828a1.85 1.85 0 0 0-.989-.486q-.563-.091-1.72 0q-1.155.091-1.582.09c-.385 0-.8-.018-1.319-.059q-.78-.06-1.223-.078a3.7 3.7 0 0 0-.958.108a1.9 1.9 0 0 0-.853.425c-.521.469-.79 1.077-.79 1.828q0 1.888 1.28 2.496c.85.405 2.113.607 3.802.607zm-2.434-4.261c.268 0 .492.133.685.396c.192.265.294.588.294.975c0 .385-.102.711-.294.973c-.192.265-.417.396-.685.396c-.279 0-.522-.131-.716-.396c-.192-.262-.294-.588-.294-.973c0-.387.102-.71.294-.975c.193-.264.436-.396.716-.396\" class=\"\"></path></svg></a> is the live builder for it: tweak a base hue and a ±7° offset and watch nvim, lazygit, htop, and tmux recolor in real time.</p>\n<figure role=\"figure\" aria-label=\"The theme lab — palette controls on the left, live nvim / lazygit / htop / tmux previews recoloring as you tune\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-theme-lab/previews.png\" alt=\"The theme lab — palette controls on the left, live nvim / lazygit / htop / tmux previews recoloring as you tune\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpes-theme-lab/previews.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpes-theme-lab/previews.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-theme-lab/previews.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpes-theme-lab/previews.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The theme lab — palette controls on the left, live nvim / lazygit / htop / tmux previews recoloring as you tune\" width=\"1796\" height=\"2262\" style=\"--splay-rot:-2deg;\" data-dimensions=\"1796×2262\" class=\"img-full my-8 rounded-sm img-splay aspect-[3/4] w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The theme lab — palette controls on the left, live nvim / lazygit / htop / tmux previews recoloring as you tune</figcaption>\n</figure>\n<h2 class=\"\" id=\"the-devices\">The devices</h2>\n<p class=\"\"><a href=\"/projects/vulpes-devices\" class=\"text-blue-600 dark:text-blue-400\">Vulpes Devices</a> carries the same palette file out to seven hardware targets — Flipper Zero, OnionOS handhelds, the TD-H3 radio, Meshtastic nodes, a PortaPack, plus iPhone and iMac wallpapers — so everything boots in the same visual language.</p>\n<figure role=\"figure\" aria-label=\"One look, many targets — the Vulpes glyph and palette generated across Flipper, OnionOS, TD-H3, Meshtastic, PortaPack, and the desktop\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-devices/devices.png\" alt=\"One look, many targets — the Vulpes glyph and palette generated across Flipper, OnionOS, TD-H3, Meshtastic, PortaPack, and the desktop\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpes-devices/devices.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpes-devices/devices.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-devices/devices.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpes-devices/devices.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"One look, many targets — the Vulpes glyph and palette generated across Flipper, OnionOS, TD-H3, Meshtastic, PortaPack, and the desktop\" width=\"1524\" height=\"656\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1524×656\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">One look, many targets — the Vulpes glyph and palette generated across Flipper, OnionOS, TD-H3, Meshtastic, PortaPack, and the desktop</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The Flipper Zero boot animation, generated straight from the palette — the fox, on a 128×64 monochrome screen\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-devices/boot.gif\" alt=\"The Flipper Zero boot animation, generated straight from the palette — the fox, on a 128×64 monochrome screen\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpes-devices/boot.gif 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpes-devices/boot.gif 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes-devices/boot.gif 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpes-devices/boot.gif 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The Flipper Zero boot animation, generated straight from the palette — the fox, on a 128×64 monochrome screen\" style=\"--splay-rot:1.52deg;\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The Flipper Zero boot animation, generated straight from the palette — the fox, on a 128×64 monochrome screen</figcaption>\n</figure>\n<h2 class=\"\" id=\"the-screen-when-i-walk-away\">The screen when I walk away</h2>\n<p class=\"\"><a href=\"/projects/cyberdeck-saver\" class=\"text-blue-600 dark:text-blue-400\">cyberdeck-saver</a> is a native macOS screensaver in Swift and Metal: a 5×5 grid of terminal panels typing out live data — ADS-B aircraft over the Hudson Valley, earthquakes, space weather, my own telemetry — through a CRT shader chain in the vulpes palette. No fake data; every byte comes from a real API or a system call.</p>\n<figure role=\"figure\" aria-label=\"The Metal fragment shader behind the screensaver&#x27;s CRT look\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cyberdeck-saver/code.png\" alt=\"The Metal fragment shader behind the screensaver&#x27;s CRT look\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/cyberdeck-saver/code.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/cyberdeck-saver/code.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/cyberdeck-saver/code.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/cyberdeck-saver/code.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The Metal fragment shader behind the screensaver&#x27;s CRT look\" width=\"1400\" height=\"900\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The Metal fragment shader behind the screensaver's CRT look</figcaption>\n</figure>\n<h2 class=\"\" id=\"the-rest-of-the-kit\">The rest of the kit</h2>\n<ul class=\"list-disc\">\n<li class=\"\"><strong class=\"\"><a href=\"https://github.com/ejfox/vulpes-browser\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/vulpes-browser\">Vulpes Browser<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a></strong> — a minimalist web browser in Zig, Swift, and Metal, rendered entirely on the GPU, with particle effects, link glow, and a two-pass bloom.</li>\n<li class=\"\"><strong class=\"\">Vulpes RSS</strong> — a TUI feed reader that renders images <em class=\"\">inline</em> in the terminal by implementing the Kitty Graphics Protocol in pure Rust.</li>\n<li class=\"\"><strong class=\"\">VulpeSVG</strong> — a native visual SVG editor in Tauri and Vue, with a properties inspector for every element's geometry.</li>\n</ul>\n<figure role=\"figure\" aria-label=\"The vulpes browser rendering ejfox.com\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes/browser.png\" alt=\"The vulpes browser rendering ejfox.com\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpes/browser.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpes/browser.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpes/browser.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpes/browser.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The vulpes browser rendering ejfox.com\" width=\"2400\" height=\"1656\" style=\"--splay-rot:2.75deg;\" data-dimensions=\"2400×1656\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The vulpes browser rendering ejfox.com</figcaption>\n</figure>","metadata":{"title":"Vulpes","date":"2026-01-18T05:00:00.000Z","category":"Art","featured":true,"url":"https://github.com/ejfox/vulpes.nvim","tech":["Lua","Swift","Metal","Rust","Zig","Tauri","Flipper Zero"],"state":"doing","ai-involvement":"ai-assisted","tags":["vulpes","design","aesthetic","tools","hardware"],"words":489,"images":7,"imageDetails":{"total":7,"cloudinary":7,"withDimensions":0},"links":5,"codeBlocks":0,"headers":{"h2":4},"toc":[{"text":"The editor","slug":"the-editor","level":"h2","children":[]},{"text":"The devices","slug":"the-devices","level":"h2","children":[]},{"text":"The screen when I walk away","slug":"the-screen-when-i-walk-away","level":"h2","children":[]},{"text":"The rest of the kit","slug":"the-rest-of-the-kit","level":"h2","children":[]}],"type":"post"}},{"slug":"projects/vulpino","title":"Vulpino","html":"<p class=\"\">Any JSON endpoint into a home-screen iOS widget, no code, in about a minute</p>\n<p class=\"\">I have data scattered across a dozen endpoints — analytics, servers, side projects, my own life. Vulpino is the fox that eats all of it and puts one clean number on my home screen. You paste a URL, it fetches the JSON and shows you the whole tree, you tap the values you care about, you pick a template, and you're done. Five taps: URL, data, template, customize, deploy. The whole thing is native SwiftUI and it works at all three widget sizes.</p>\n<p class=\"\">The part I care most about is what it <em class=\"\">doesn't</em> let you do. There's no font picker, no color wheel, no padding slider. You get seven typographic templates — Mono Stat for a single hero number, Dual Stat to compare two, Stat Stack for a little dashboard, plus Headline, List, Grid, and Timestamp — and every one of them is already designed. That's the whole idea behind the vulpes family: the constraint is the product. Vulpino says no to customization theater, and the widgets look good because I made the decisions instead of pushing them onto you at 11pm when you just want to see your visitor count.</p>\n<p class=\"\">It's real infrastructure too. API keys and auth headers live in the Keychain, not in some plist. Data is cached offline so the widget still shows the last good value with a stale indicator when the network's down, and tapping a widget deep-links straight into whatever URL you set or back into the app to edit it. Built with a lot of AI pairing — I was the one saying no to features, the model helped me build the ones that survived.</p>\n<figure role=\"figure\" aria-label=\"The widget editor — a SwiftUI step flow from URL to data to template to customize\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpino/code.png\" alt=\"The widget editor — a SwiftUI step flow from URL to data to template to customize\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpino/code.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpino/code.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpino/code.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpino/code.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The widget editor — a SwiftUI step flow from URL to data to template to customize\" width=\"1400\" height=\"900\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The widget editor — a SwiftUI step flow from URL to data to template to customize</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The typographic design system behind the widgets — opinionated, minimal, restrained\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpino/code2.png\" alt=\"The typographic design system behind the widgets — opinionated, minimal, restrained\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpino/code2.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpino/code2.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpino/code2.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpino/code2.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The typographic design system behind the widgets — opinionated, minimal, restrained\" width=\"1400\" height=\"900\" style=\"--splay-rot:-1.36deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The typographic design system behind the widgets — opinionated, minimal, restrained</figcaption>\n</figure>\n<p class=\"\">Running on device: the widget list, pasting a live JSON endpoint, and the tree Vulpino builds from the response so you can tap the values you want.</p>\n<figure role=\"figure\" aria-label=\"The app&#x27;s home — your widgets live here; a new one is sixty seconds away\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpino/app-home.png\" alt=\"The app&#x27;s home — your widgets live here; a new one is sixty seconds away\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpino/app-home.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpino/app-home.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpino/app-home.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpino/app-home.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The app&#x27;s home — your widgets live here; a new one is sixty seconds away\" width=\"1206\" height=\"2622\" style=\"--splay-rot:1.74deg;\" data-dimensions=\"1206×2622\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The app's home — your widgets live here; a new one is sixty seconds away</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Paste any endpoint and Vulpino validates it live before it fetches\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpino/app-fetch.png\" alt=\"Paste any endpoint and Vulpino validates it live before it fetches\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpino/app-fetch.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpino/app-fetch.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpino/app-fetch.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpino/app-fetch.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Paste any endpoint and Vulpino validates it live before it fetches\" width=\"1206\" height=\"2622\" style=\"--splay-rot:0.79deg;\" data-dimensions=\"1206×2622\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Paste any endpoint and Vulpino validates it live before it fetches</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Vulpino fetches the JSON and lays the whole response out as a tree — tap the values you care about\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpino/app-tree.png\" alt=\"Vulpino fetches the JSON and lays the whole response out as a tree — tap the values you care about\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/vulpino/app-tree.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/vulpino/app-tree.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/vulpino/app-tree.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/vulpino/app-tree.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Vulpino fetches the JSON and lays the whole response out as a tree — tap the values you care about\" width=\"1206\" height=\"2622\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1206×2622\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Vulpino fetches the JSON and lays the whole response out as a tree — tap the values you care about</figcaption>\n</figure>","metadata":{"title":"Vulpino","date":"2026-01-10T05:00:00.000Z","category":"Tools","featured":false,"url":"https://github.com/ejfox/vulpino","tech":["Swift"],"state":"deployed","ai-involvement":"ai-assisted","tags":["tools"],"words":387,"images":5,"imageDetails":{"total":5,"cloudinary":5,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/paperclip","title":"paperclip","html":"<p class=\"\">No chat. Just vibes.</p>\n<p class=\"\">paperclip is a friends-only presence app: you broadcast your vibe with a single emoji and that's the whole thing. There's no feed to scroll, no chat thread, no likes or follower counts or read receipts — none of the machinery that turns \"how are my friends doing\" into a job. You pick an emoji, your friends see it, and when theirs changes you can get a quiet notification. That's it. I wanted the ambient warmth of knowing what your people are feeling without any of the pressure to perform or respond.</p>\n<p class=\"\">It's built native on both platforms — SwiftUI on iOS, Jetpack Compose on Android — over a shared Supabase backend that syncs in real time, so an emoji you set on your iPhone shows up on your friend's Android in seconds. It's monochrome on purpose: the only color on the screen is the emoji itself, which is exactly where I want your eye to go. Friends join by invite code, there's no public anything, and I built the boring-but-real parts too — push notifications with mute and quiet hours, block/report and account deletion for the App Store, a Cloudflare Worker proxy, even a Discord bot that mirrors your friends' emoji board into a server and Siri Shortcuts so you can set your vibe by voice.</p>\n<p class=\"\">I built it with AI help (Claude), which is how one person ships two native apps and a backend at all — but the shape of it, the ruthless subtraction, the decision that the anti-social-network should feel like a piece of paper and not a dashboard, that's mine. Deliberately styled to lean Apple, not startup. Just pushing it steadily forward like a boss.</p>\n<figure role=\"figure\" aria-label=\"paperclip — one-emoji friends-only presence\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780065877/projects/paperclip/promo.png\" alt=\"paperclip — one-emoji friends-only presence\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1780065877/projects/paperclip/promo.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1780065877/projects/paperclip/promo.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780065877/projects/paperclip/promo.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1780065877/projects/paperclip/promo.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"paperclip — one-emoji friends-only presence\" width=\"1230\" height=\"760\" style=\"--splay-rot:2.61deg;\" data-dimensions=\"1230×760\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">paperclip — one-emoji friends-only presence</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"A friend&#x27;s contact card — pinned emoji, notifications, and a scrolling emoji history\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/paperclip/contact.png\" alt=\"A friend&#x27;s contact card — pinned emoji, notifications, and a scrolling emoji history\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/paperclip/contact.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/paperclip/contact.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/paperclip/contact.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/paperclip/contact.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A friend&#x27;s contact card — pinned emoji, notifications, and a scrolling emoji history\" width=\"902\" height=\"1924\" style=\"--splay-rot:2.99deg;\" data-dimensions=\"902×1924\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A friend's contact card — pinned emoji, notifications, and a scrolling emoji history</figcaption>\n</figure>","metadata":{"title":"paperclip","date":"2025-11-01T05:00:00.000Z","category":"Apps","featured":false,"tech":["Swift","SwiftUI","Kotlin","Jetpack Compose","Supabase"],"state":"deployed","ai-involvement":"ai-assisted","tags":["ios","android","app","social"],"words":301,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/newswell","title":"Newswell Studio","html":"<p class=\"\">A browser-based newspaper layout tool, built to take small newsrooms off InDesign. It has three parts: a WordPress plugin that stores issues, layouts and ads; a Nuxt web editor for laying out pages; and a Slack bot for checking issue status without leaving chat.</p>\n<p class=\"\">In the editor, pages are built from drag-and-drop frames on a six-column newspaper grid. Articles come straight in from WordPress, ads are placed from inventory, and text flow is simulated so you can see what fits before you print. GPT-4 suggests headlines and trims articles to fit their frames. Finished pages export to print-ready PDF from the browser, with a preflight check first. Times of San Diego is the test site.</p>\n<figure role=\"figure\" aria-label=\"Newswell Studio — drag-drop frame layout on the six-column grid\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/newswell/layout-1.png\" alt=\"Newswell Studio — drag-drop frame layout on the six-column grid\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/newswell/layout-1.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/newswell/layout-1.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/newswell/layout-1.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/newswell/layout-1.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Newswell Studio — drag-drop frame layout on the six-column grid\" width=\"3600\" height=\"2262\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"3600×2262\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Newswell Studio — drag-drop frame layout on the six-column grid</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Article trimming and page export\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/newswell/layout-2.png\" alt=\"Article trimming and page export\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/newswell/layout-2.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/newswell/layout-2.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/newswell/layout-2.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/newswell/layout-2.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Article trimming and page export\" width=\"3600\" height=\"2262\" style=\"--splay-rot:-0.6deg;\" data-dimensions=\"3600×2262\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Article trimming and page export</figcaption>\n</figure>","metadata":{"title":"Newswell Studio","date":"2025-10-01T00:00:00.000Z","category":"Tools","featured":false,"draft":false,"tech":["Nuxt 4","Vue","WordPress","PHP","GPT-4"],"state":"deployed","ai-involvement":"ai-assisted","tags":["tools","journalism","dataviz"],"words":130,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/paramilitary-leaks","title":"Paramilitary Leaks","html":"<p class=\"\">I was perusing Mastodon, as one does, and noticed a post from journalist <a href=\"https://micahflee.com/exploring-the-paramilitary-leaks/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://micahflee.com/exploring-the-paramilitary-leaks/\">Micah Lee</a> saying he was exploring a 200GB dump from <a href=\"https://www.propublica.org/article/ap3-oath-keepers-militia-mole\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.propublica.org/article/ap3-oath-keepers-militia-mole\">American paramilitary groups</a> — a leak made possible through the incredible bravery of John Williams, who infiltrated these groups and exfiltrated the data. I hadn't heard of the dump before, and immediately sent Micah an email offering to help with any dataviz.</p>\n<figure role=\"figure\" aria-label=\"The leak explorer — search + a dense visual index of the document set\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/paramilitary-leaks/explorer.png\" alt=\"The leak explorer — search + a dense visual index of the document set\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/paramilitary-leaks/explorer.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/paramilitary-leaks/explorer.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/paramilitary-leaks/explorer.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/paramilitary-leaks/explorer.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The leak explorer — search + a dense visual index of the document set\" width=\"1440\" height=\"1209\" style=\"--splay-rot:1.5deg;\" data-dimensions=\"1440×1209\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The leak explorer — search + a dense visual index of the document set</figcaption>\n</figure>\n<p class=\"\">The pipeline chops and screws the data from Telegram HTML exports into SQLite, then Parquet hosted on Cloudflare R2 — a columnar format that's super-fast in the browser. On top of that sits a Nuxt + <a href=\"https://github.com/flekschas/regl-scatterplot\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/flekschas/regl-scatterplot\">regl-scatterplot<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> frontend with full-text search, filters, and a dense visual index of the corpus. We took a 200GB leak and turned it into something a reporter can explore in the browser.</p>\n<figure role=\"figure\" aria-label=\"Full-text search across the corpus\" class=\"\"><video class=\"img-full my-8 rounded-sm\" autoplay loop muted playsinline preload=\"metadata\" poster=\"https://res.cloudinary.com/ejf/video/upload/so_0,f_jpg,q_auto,w_1280/v1741568702/text-search.jpg\" src=\"https://res.cloudinary.com/ejf/video/upload/q_auto/v1741568702/text-search.mp4\" aria-label=\"Full-text search across the corpus\"></video><figcaption class=\"\">Full-text search across the corpus</figcaption></figure>\n<p class=\"\">Explore it at <a href=\"https://para-leaks.ejfox.com\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://para-leaks.ejfox.com\">para-leaks.ejfox.com</a>, read the full write-up — <a href=\"/blog/2025/paramilitary-telegram-leaks-001\" class=\"text-blue-600 dark:text-blue-400\">Processing Telegram Leaks for Fast Web Visualization</a> — or watch the walkthrough: <a href=\"https://www.youtube.com/watch?v=x-OSFUtCdK0\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.youtube.com/watch?v=x-OSFUtCdK0\">Visualizing 200GB Of Leaked Telegram Chats<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M23.498 6.186a3.02 3.02 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.02 3.02 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.02 3.02 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.02 3.02 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814M9.545 15.568V8.432L15.818 12z\" class=\"\"></path></svg></a>.</p>","metadata":{"title":"Paramilitary Leaks","date":"2025-09-01T04:00:00.000Z","category":"Journalism","featured":true,"tech":["Nuxt","Vue","Search","Data Visualization"],"state":"deployed","ai-involvement":"ai-assisted","context":"collaborative","tags":["journalism","data","osint","tools"],"words":178,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":6,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/openrouter-census","title":"OpenRouter Census","html":"<p class=\"\">A full census of every model on OpenRouter, dumped into immutable JSONL/Parquet snapshots and rendered as an Observable Plot dashboard</p>\n<p class=\"\">OpenRouter is the marketplace where you can hit basically any language model through one API, and the roster changes constantly — models appear, prices drift, a provider quietly bumps a context window. I wanted a way to take a dated snapshot of the whole thing and actually look at it, so I built a scraper that pulls all 316 models across 54 providers in about a second and hands me analysis-ready data I can chart.</p>\n<p class=\"\">The interesting part is what falls out once you have every model in one table. Median context length is 128K tokens but the average is 171K, dragged up by outliers like OpenAI's million-plus-token windows. 70% of the catalog is still text-only; the other 30% takes images. And the capability spread is uneven in ways the marketing never tells you — <code class=\"md-inline-code\">max_tokens</code> is table stakes at 99.7%, but tool calling only shows up on about half the models, structured outputs on 45%, and reasoning tokens on 29%. That's the kind of thing you want to know before you pick a model to build on.</p>\n<p class=\"\">I care about provenance more than a leaderboard, so every run writes a dated <code class=\"md-inline-code\">artifacts/</code> directory: raw unmodified API payloads next to the normalized tables, JSON Schemas, SHA-256 checksums, fetch metadata, and a <code class=\"md-inline-code\">CITATION.cff</code> — so a snapshot is something you can actually cite and reproduce, not a screenshot that rots. There's an interactive TUI for browsing and filtering, and the dashboard is seven Observable Plot charts over the processed data.</p>\n<figure role=\"figure\" aria-label=\"OpenRouter Model Census — the dashboard: pricing distribution + the context-price paradox\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/openrouter-census/dashboard.png\" alt=\"OpenRouter Model Census — the dashboard: pricing distribution + the context-price paradox\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/openrouter-census/dashboard.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/openrouter-census/dashboard.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/openrouter-census/dashboard.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/openrouter-census/dashboard.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"OpenRouter Model Census — the dashboard: pricing distribution + the context-price paradox\" width=\"1440\" height=\"2150\" style=\"--splay-rot:-1.29deg;\" data-dimensions=\"1440×2150\" class=\"img-full my-8 rounded-sm img-splay aspect-[3/4] w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">OpenRouter Model Census — the dashboard: pricing distribution + the context-price paradox</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The companion interactive CLI\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/openrouter-census/tui.png\" alt=\"The companion interactive CLI\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/openrouter-census/tui.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/openrouter-census/tui.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/openrouter-census/tui.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/openrouter-census/tui.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The companion interactive CLI\" width=\"1400\" height=\"880\" style=\"--splay-rot:1.11deg;\" data-dimensions=\"1400×880\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The companion interactive CLI</figcaption>\n</figure>","metadata":{"title":"OpenRouter Census","date":"2025-08-21T05:00:00.000Z","category":"Tools","featured":false,"url":"https://github.com/ejfox/openrouter-census","tech":["JavaScript"],"state":"deployed","ai-involvement":"ai-assisted","tags":["tools"],"words":285,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/smallbot-mcp","title":"Smallbot MCP","html":"<p class=\"\"><a href=\"https://www.smallweb.run\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.smallweb.run\">Smallweb</a> is a unique tool to create a filesystem based system for rapidly-deployed deno apps, and it pairs really well with a robot companion.</p>\n<p class=\"\">Nowadays when I have an idea for a website I just want it to exist, without spending three hours setting up a build pipeline and configuring DNS. Smallbot is an incredible tool to defeat that particular form of procrastination—a robot that takes \"make me a site about <a href=\"https://youtube.com/shorts/i6IZSkmqNSo\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://youtube.com/shorts/i6IZSkmqNSo\">northeastern trees<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M23.498 6.186a3.02 3.02 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.02 3.02 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.02 3.02 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.02 3.02 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814M9.545 15.568V8.432L15.818 12z\" class=\"\"></path></svg></a>\" and actually builds the thing.</p>\n<p class=\"\">I made a <a href=\"https://www.youtube.com/watch?v=thIt-JXYbco\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.youtube.com/watch?v=thIt-JXYbco\">video with the creator of Smallweb<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M23.498 6.186a3.02 3.02 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.02 3.02 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.02 3.02 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.02 3.02 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814M9.545 15.568V8.432L15.818 12z\" class=\"\"></path></svg></a> about how easy it is to get set up, and I love how much the project embodies <a href=\"https://www.are.na/ej-fox/neo-internet-philosophy\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.are.na/ej-fox/neo-internet-philosophy\">indieweb philosophies</a></p>\n<p class=\"\">This is a tool built on the MCP (Model Context Protocol) so it can talk to Claude and other AI assistants and give them the tools and documentation to make little Smallweb sites easily, on-the-fly.</p>\n<figure role=\"figure\" aria-label=\"Smallbot — a helpful assistant for creating Smallweb applications\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1741706866/Screenshot_2025-03-11_at_11.27.32_AM.png\" alt=\"Smallbot — a helpful assistant for creating Smallweb applications\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1741706866/Screenshot_2025-03-11_at_11.27.32_AM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1741706866/Screenshot_2025-03-11_at_11.27.32_AM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1741706866/Screenshot_2025-03-11_at_11.27.32_AM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1741706866/Screenshot_2025-03-11_at_11.27.32_AM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Smallbot — a helpful assistant for creating Smallweb applications\" width=\"3348\" height=\"2486\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"3348×2486\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Smallbot — a helpful assistant for creating Smallweb applications</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Smallbot generating a Smallweb app\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1746294443/Screenshot_2025-05-03_at_1.47.10_PM.png\" alt=\"Smallbot generating a Smallweb app\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1746294443/Screenshot_2025-05-03_at_1.47.10_PM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1746294443/Screenshot_2025-05-03_at_1.47.10_PM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1746294443/Screenshot_2025-05-03_at_1.47.10_PM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1746294443/Screenshot_2025-05-03_at_1.47.10_PM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Smallbot generating a Smallweb app\" width=\"2620\" height=\"2486\" style=\"--splay-rot:1.59deg;\" data-dimensions=\"2620×2486\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Smallbot generating a Smallweb app</figcaption>\n</figure>\n<h2 class=\"\" id=\"smallweb-starter\">smallweb-starter</h2>\n<figure role=\"figure\" aria-label=\"The starter running locally — a live HTTP request returning JSON, with rate-limiting and cache-control headers from the Hono middleware\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1789191272/projects/smallweb-starter/landing.png\" alt=\"The starter running locally — a live HTTP request returning JSON, with rate-limiting and cache-control headers from the Hono middleware\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1789191272/projects/smallweb-starter/landing.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1789191272/projects/smallweb-starter/landing.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1789191272/projects/smallweb-starter/landing.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1789191272/projects/smallweb-starter/landing.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The starter running locally — a live HTTP request returning JSON, with rate-limiting and cache-control headers from the Hono middleware\" width=\"1200\" height=\"800\" style=\"--splay-rot:-1.21deg;\" data-dimensions=\"1200×800\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The starter running locally — a live HTTP request returning JSON, with rate-limiting and cache-control headers from the Hono middleware</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/smallweb-starter\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/smallweb-starter\">smallweb-starter<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> is a lightweight application starter for Smallweb, in TypeScript on Deno. It ships Hono middleware for rate-limiting and cache-control headers.</p>","metadata":{"title":"Smallbot MCP","date":"2025-08-02T04:00:00.000Z","category":"Tools","featured":false,"modified":"2025-08-13T17:17:13.000Z","url":"https://github.com/ejfox/smallbot-mcp","tech":["MCP","AI","Web Generation","Node.js"],"state":"deployed","ai-involvement":"ai-enhanced","tags":["ai","web"],"words":198,"images":3,"imageDetails":{"total":3,"cloudinary":3,"withDimensions":0},"links":5,"codeBlocks":0,"headers":{"h2":1},"toc":[{"text":"smallweb-starter","slug":"smallweb-starter","level":"h2","children":[]}],"type":"post"}},{"slug":"projects/towntuner","title":"towntuner","html":"<p class=\"\">A single-purpose podcast app for exactly one podcast: Harmontown, all ~360 episodes</p>\n<p class=\"\">For the past five or six years I've listened to Harmontown almost every single night. I went through it a few times on Spotify, then made my way through the archives on YouTube. And each night I kept getting annoyed: every podcast app I used was full of other podcasts I didn't care about, getting in the way of me listening to Harmontown. This was a problem that needed to be solved.</p>\n<p class=\"\">A few weeks later I had a vibe-coded iOS app that Apple somehow approved for TestFlight. It's a little piece of software made with love, mostly just for myself, but I figured if anyone else would find it useful it would be the Harmontown people — so I <a href=\"https://www.reddit.com/r/Harmontown/comments/1q288e8/fan_app_towntuner_an_singlepurpose_podcast_app/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.reddit.com/r/Harmontown/comments/1q288e8/fan_app_towntuner_an_singlepurpose_podcast_app/\">posted it to r/Harmontown<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 512 512\"><path fill=\"currentColor\" d=\"M0 256C0 114.6 114.6 0 256 0s256 114.6 256 256s-114.6 256-256 256H37.1c-13.7 0-20.5-16.5-10.9-26.2L75 437C28.7 390.7 0 326.7 0 256m349.6-102.4c23.6 0 42.7-19.1 42.7-42.7s-19.1-42.7-42.7-42.7c-20.6 0-37.8 14.6-41.8 34c-34.5 3.7-61.4 33-61.4 68.4v.2c-37.5 1.6-71.8 12.3-99 29.1c-10.1-7.8-22.8-12.5-36.5-12.5c-33 0-59.8 26.8-59.8 59.8c0 24 14.1 44.6 34.4 54.1c2 69.4 77.6 125.2 170.6 125.2s168.7-55.9 170.6-125.3c20.2-9.6 34.1-30.2 34.1-54c0-33-26.8-59.8-59.8-59.8c-13.7 0-26.3 4.6-36.4 12.4c-27.4-17-62.1-27.7-100-29.1v-.2c0-25.4 18.9-46.5 43.4-49.9c4.4 18.8 21.3 32.8 41.5 32.8zm-172.5 93.3c16.7 0 29.5 17.6 28.5 39.3s-13.5 29.6-30.3 29.6s-31.4-8.8-30.4-30.5S160.3 247 177 247zm190.1 38.3c1 21.7-13.7 30.5-30.4 30.5s-29.3-7.9-30.3-29.6s11.8-39.3 28.5-39.3s31.2 16.6 32.1 38.3zm-48.1 56.7c-10.3 24.6-34.6 41.9-63 41.9s-52.7-17.3-63-41.9c-1.2-2.9.8-6.2 3.9-6.5c18.4-1.9 38.3-2.9 59.1-2.9s40.7 1 59.1 2.9c3.1.3 5.1 3.6 3.9 6.5\" class=\"\"></path></svg></a> on New Year's Day.</p>\n<figure role=\"figure\" aria-label=\"towntuner — a Harmontown-only podcast player\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780065876/projects/towntuner/1.png\" alt=\"towntuner — a Harmontown-only podcast player\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1780065876/projects/towntuner/1.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1780065876/projects/towntuner/1.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780065876/projects/towntuner/1.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1780065876/projects/towntuner/1.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"towntuner — a Harmontown-only podcast player\" width=\"988\" height=\"2108\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"988×2108\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">towntuner — a Harmontown-only podcast player</figcaption>\n</figure>\n<h2 class=\"\" id=\"what-it-does\">What it does</h2>\n<ul class=\"list-disc\">\n<li class=\"\"><strong class=\"\">Shuffle all episodes</strong>, skipping the ones you've played recently</li>\n<li class=\"\"><strong class=\"\">Filter episodes by guest</strong></li>\n<li class=\"\"><strong class=\"\">Read the transcript</strong> of whatever's playing</li>\n<li class=\"\"><strong class=\"\">Search across every transcript</strong></li>\n<li class=\"\"><strong class=\"\">See your totals</strong> — episodes listened to, hours listened</li>\n</ul>\n<p class=\"\">It builds on the amazing work of <a href=\"https://harmonsearch.com\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://harmonsearch.com\">HarmonSearch</a>, whose transcripts power both the transcript view and search.</p>\n<figure role=\"figure\" aria-label=\"towntuner on iPhone — the episode browser with prestige progression\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/towntuner/2.png\" alt=\"towntuner on iPhone — the episode browser with prestige progression\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/towntuner/2.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/towntuner/2.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/towntuner/2.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/towntuner/2.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"towntuner on iPhone — the episode browser with prestige progression\" width=\"988\" height=\"2108\" style=\"--splay-rot:1.51deg;\" data-dimensions=\"988×2108\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">towntuner on iPhone — the episode browser with prestige progression</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://testflight.apple.com/join/Theuk8mq\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://testflight.apple.com/join/Theuk8mq\">Join the TestFlight beta<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04c-2.04.027-3.91 1.183-4.961 3.014c-2.117 3.675-.546 9.103 1.519 12.09c1.013 1.454 2.208 3.09 3.792 3.039c1.52-.065 2.09-.987 3.935-.987c1.831 0 2.35.987 3.96.948c1.637-.026 2.676-1.48 3.676-2.948c1.156-1.688 1.636-3.325 1.662-3.415c-.039-.013-3.182-1.221-3.22-4.857c-.026-3.04 2.48-4.494 2.597-4.559c-1.429-2.09-3.623-2.324-4.39-2.376c-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83c-1.207.052-2.662.805-3.532 1.818c-.78.896-1.454 2.338-1.273 3.714c1.338.104 2.715-.688 3.559-1.701\" class=\"\"></path></svg></a> — iOS only, sorry.</p>","metadata":{"title":"towntuner","date":"2025-08-01T04:00:00.000Z","category":"Apps","featured":false,"draft":false,"url":"https://github.com/ejfox/towntuner","tech":["Swift","SwiftUI","iOS"],"state":"deployed","ai-involvement":"ai-collaborative","tags":["ios","app","audio"],"words":222,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":3,"codeBlocks":0,"headers":{"h2":1},"toc":[{"text":"What it does","slug":"what-it-does","level":"h2","children":[]}],"type":"post"}},{"slug":"projects/websdr-vcv","title":"WebSDR VCV","html":"<p class=\"\">A VCV Rack / Cardinal plugin — a C++ Eurorack module — that streams real radio from KiwiSDR servers into a modular synth</p>\n<p class=\"\">I was kind of just doing recordings of WebSDR and integrating them into my music. But I love the organic, generative method of modular synthesis, and incorporating some real-life data — as my broken brain is prone to do — sparked a lot of joy for me. So: what if we controlled a synth from a live frequency turned into CV data? Unlocking hidden ambient music in the structures of unseen information floating all around us.</p>\n<figure role=\"figure\" aria-label=\"The WebSDR module patched into a modular synth rack\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/websdr-vcv/rack.png\" alt=\"The WebSDR module patched into a modular synth rack\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/websdr-vcv/rack.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/websdr-vcv/rack.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/websdr-vcv/rack.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/websdr-vcv/rack.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The WebSDR module patched into a modular synth rack\" width=\"1572\" height=\"1156\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1572×1156\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The WebSDR module patched into a modular synth rack</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The Eurorack module in C++ — presets and KiwiSDR resampling\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/websdr-vcv/code.png\" alt=\"The Eurorack module in C++ — presets and KiwiSDR resampling\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/websdr-vcv/code.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/websdr-vcv/code.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/websdr-vcv/code.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/websdr-vcv/code.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The Eurorack module in C++ — presets and KiwiSDR resampling\" width=\"1400\" height=\"980\" style=\"--splay-rot:-0.6deg;\" data-dimensions=\"1400×980\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The Eurorack module in C++ — presets and KiwiSDR resampling</figcaption>\n</figure>","metadata":{"title":"WebSDR VCV","date":"2025-08-01T00:00:00.000Z","category":"Hardware","featured":false,"url":"https://github.com/ejfox/websdr-vcv","tech":["C++","VCV Rack","Cardinal"],"state":"deployed","ai-involvement":"ai-assisted","tags":["audio","radio","hardware"],"words":118,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/noaa-sat-cli","title":"NOAA Satellite CLI","html":"<figure role=\"figure\" aria-label=\"NOAA Sat CLI running in a vulpes-themed terminal\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/noaa-sat-cli/terminal.png\" alt=\"NOAA Sat CLI running in a vulpes-themed terminal\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/noaa-sat-cli/terminal.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/noaa-sat-cli/terminal.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/noaa-sat-cli/terminal.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/noaa-sat-cli/terminal.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"NOAA Sat CLI running in a vulpes-themed terminal\" width=\"1267\" height=\"547\" style=\"--splay-rot:-1.22deg;\" data-dimensions=\"1267×547\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">NOAA Sat CLI running in a vulpes-themed terminal</figcaption>\n</figure>\n<p class=\"\">The whole planet, from a satellite, on your hard drive, in one command</p>\n<p class=\"\">I wanted the freshest picture of Earth without opening a browser, so I built a CLI that pulls straight from NOAA's GOES-18 and GOES-19 geostationary satellites — the two live birds parked over the Pacific and the Atlantic — and drops the image into a local archive. You can grab the latest GEOCOLOR frame, or reach for the max: a full disk of Earth at 21696x21696, 470 megapixels. It handles GOES' Julian-day timestamps (that <code class=\"md-inline-code\">YYYYJJJHHMM</code> format that isn't a normal date), MD5-hashes every capture so it never stores the same frame twice, and keeps a little JSON database tracking what it's pulled and when. Point cron at it and you've got an hourly archive of the sky building itself.</p>\n<p class=\"\">It's not just NOAA either. It'll pull NASA's EPIC camera — the one on DSCOVR, sitting a million and a half kilometers out at the L1 Lagrange point looking back at the whole sunlit face of Earth — plus MODIS and VIIRS regional imagery for the US, Europe, Asia, Africa, Australia. There's a <code class=\"md-inline-code\">quick grab</code> one-liner for when you just want the shot, and a full ASCII terminal menu when you want to pick the satellite, channel, and resolution yourself. No emoji, all cyan-and-magenta status lines, the whole 90s-terminal thing on purpose.</p>\n<figure role=\"figure\" aria-label=\"EARTH OBSERVATION CLI — grab latest, mass capture, archive, and NASA deep-space views\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/noaa-sat-cli/tui.png\" alt=\"EARTH OBSERVATION CLI — grab latest, mass capture, archive, and NASA deep-space views\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/noaa-sat-cli/tui.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/noaa-sat-cli/tui.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/noaa-sat-cli/tui.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/noaa-sat-cli/tui.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"EARTH OBSERVATION CLI — grab latest, mass capture, archive, and NASA deep-space views\" width=\"1200\" height=\"820\" style=\"--splay-rot:1.66deg;\" data-dimensions=\"1200×820\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">EARTH OBSERVATION CLI — grab latest, mass capture, archive, and NASA deep-space views</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The TUI behind the menu — satellite, channel, and resolution prompts\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/noaa-sat-cli/code.png\" alt=\"The TUI behind the menu — satellite, channel, and resolution prompts\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/noaa-sat-cli/code.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/noaa-sat-cli/code.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/noaa-sat-cli/code.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/noaa-sat-cli/code.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The TUI behind the menu — satellite, channel, and resolution prompts\" width=\"1400\" height=\"980\" style=\"--splay-rot:2.85deg;\" data-dimensions=\"1400×980\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The TUI behind the menu — satellite, channel, and resolution prompts</figcaption>\n</figure>","metadata":{"title":"NOAA Satellite CLI","date":"2025-06-09T05:00:00.000Z","category":"Tools","featured":false,"url":"https://github.com/ejfox/noaa-sat-cli","tech":["TypeScript"],"state":"deployed","ai-involvement":"ai-assisted","tags":["tools"],"words":256,"images":3,"imageDetails":{"total":3,"cloudinary":3,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/flipper-zero-tools","title":"Flipper Zero Tools","html":"<p class=\"\">Two small apps for the same tiny, useless-but-perfect screen: <a href=\"https://github.com/ejfox/flipper-space-calculators\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/flipper-space-calculators\">flipper-space-calculators<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> and <a href=\"https://github.com/ejfox/flipper-generative-art\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/flipper-generative-art\">flipper-generative-art<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a>.</p>\n<h2 class=\"\" id=\"space-calculators\">Space Calculators</h2>\n<p class=\"\">A set of minimalist space- and physics calculators for the Flipper Zero — relativistic time dilation, space-travel figuring, and friends, all on the 128×64 screen for back-of-the-napkin astrophysics in your pocket.</p>\n<p class=\"\">I think I like it because it's so useless but still utilitarian — cyberpunk and gestural. It's a tool for a time/space-traveler who doesn't exist — or maybe does — the kind of thing he'd find handy.</p>\n<p class=\"\">It's also an experiment in pushing what small Flipper apps can do, and imagining utilities that would make sense within its capabilities — using the limits as a method of inspiration. This is what came from that.</p>\n<figure role=\"figure\" aria-label=\"The orbital-mechanics constants and transfer math, in C on the Flipper Zero\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/flipper-space-calculators/code.png\" alt=\"The orbital-mechanics constants and transfer math, in C on the Flipper Zero\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/flipper-space-calculators/code.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/flipper-space-calculators/code.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/flipper-space-calculators/code.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/flipper-space-calculators/code.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The orbital-mechanics constants and transfer math, in C on the Flipper Zero\" width=\"1400\" height=\"900\" style=\"--splay-rot:-2.06deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The orbital-mechanics constants and transfer math, in C on the Flipper Zero</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The transfer solver — delta-v, flight time, and a celestial-body database keyed by semi-major axis\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/flipper-space-calculators/code2.png\" alt=\"The transfer solver — delta-v, flight time, and a celestial-body database keyed by semi-major axis\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/flipper-space-calculators/code2.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/flipper-space-calculators/code2.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/flipper-space-calculators/code2.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/flipper-space-calculators/code2.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The transfer solver — delta-v, flight time, and a celestial-body database keyed by semi-major axis\" width=\"1400\" height=\"900\" style=\"--splay-rot:-0.89deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The transfer solver — delta-v, flight time, and a celestial-body database keyed by semi-major axis</figcaption>\n</figure>\n<h2 class=\"\" id=\"generative-art\">Generative Art</h2>\n<p class=\"\">I often just leave the Flipper on my desk below my monitor, and my feeling was: why not have it show me little evolving art pieces instead of just the time? What would that look and feel like? Is generative art even possible within the constraints of the Flipper's screen and processor? What does 1-bit generative art <em class=\"\">feel</em> like? How can it evolve over the course of a day?</p>\n<p class=\"\">This is the answer: a Flipper Zero app that generates animated patterns in real time on the device's <strong class=\"\">128×64 monochrome screen</strong>. It runs ten gradient families — horizontal, vertical, radial, diagonal, sine waves, interference, checkerboard, noise, spiral — and converts each to crisp 1-bit graphics with <strong class=\"\">Floyd–Steinberg dithering</strong>, evolving the parameters every second at ~30fps.</p>\n<figure role=\"figure\" aria-label=\"More real-time generative patterns\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/flipper-generative-art/gallery-5.png\" alt=\"More real-time generative patterns\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/flipper-generative-art/gallery-5.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/flipper-generative-art/gallery-5.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/flipper-generative-art/gallery-5.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/flipper-generative-art/gallery-5.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"More real-time generative patterns\" width=\"1024\" height=\"512\" style=\"--splay-rot:-0.6deg;\" data-dimensions=\"1024×512\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">More real-time generative patterns</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Dithered radial patterns generated on the Flipper Zero\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/flipper-generative-art/gallery-1.png\" alt=\"Dithered radial patterns generated on the Flipper Zero\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/flipper-generative-art/gallery-1.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/flipper-generative-art/gallery-1.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/flipper-generative-art/gallery-1.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/flipper-generative-art/gallery-1.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Dithered radial patterns generated on the Flipper Zero\" width=\"1024\" height=\"512\" style=\"--splay-rot:0.61deg;\" data-dimensions=\"1024×512\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Dithered radial patterns generated on the Flipper Zero</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Checkerboard and noise fields\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/flipper-generative-art/gallery-2.png\" alt=\"Checkerboard and noise fields\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/flipper-generative-art/gallery-2.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/flipper-generative-art/gallery-2.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/flipper-generative-art/gallery-2.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/flipper-generative-art/gallery-2.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Checkerboard and noise fields\" width=\"1024\" height=\"512\" style=\"--splay-rot:1.55deg;\" data-dimensions=\"1024×512\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Checkerboard and noise fields</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Interference and sine-wave fields, 1-bit dithered\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/flipper-generative-art/gallery-3.png\" alt=\"Interference and sine-wave fields, 1-bit dithered\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/flipper-generative-art/gallery-3.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/flipper-generative-art/gallery-3.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/flipper-generative-art/gallery-3.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/flipper-generative-art/gallery-3.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Interference and sine-wave fields, 1-bit dithered\" width=\"1024\" height=\"512\" style=\"--splay-rot:1.75deg;\" data-dimensions=\"1024×512\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Interference and sine-wave fields, 1-bit dithered</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Diagonal gradients and spirals\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/flipper-generative-art/gallery-4.png\" alt=\"Diagonal gradients and spirals\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/flipper-generative-art/gallery-4.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/flipper-generative-art/gallery-4.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/flipper-generative-art/gallery-4.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/flipper-generative-art/gallery-4.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Diagonal gradients and spirals\" width=\"1024\" height=\"512\" style=\"--splay-rot:-0.6deg;\" data-dimensions=\"1024×512\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Diagonal gradients and spirals</figcaption>\n</figure>\n<p class=\"\">Making something genuinely <em class=\"\">generative</em> look good in 1-bit on a tiny embedded display is the whole challenge — the dithering is what turns smooth math gradients into something the Flipper's screen can actually render without looking like mud.</p>","metadata":{"title":"Flipper Zero Tools","date":"2025-01-15T05:00:00.000Z","category":"Hardware","featured":true,"url":"https://github.com/ejfox/flipper-space-calculators","tech":["Flipper Zero","C","Embedded","Floyd–Steinberg Dithering"],"state":"deployed","ai-involvement":"ai-assisted","tags":["flipper","hardware","device","science","generative","art"],"words":338,"images":7,"imageDetails":{"total":7,"cloudinary":7,"withDimensions":0},"links":2,"codeBlocks":0,"headers":{"h2":2},"toc":[{"text":"Space Calculators","slug":"space-calculators","level":"h2","children":[]},{"text":"Generative Art","slug":"generative-art","level":"h2","children":[]}],"type":"post"}},{"slug":"projects/showtouch","title":"showtouch","html":"<p class=\"\">For presentations and livestreaming: show every key you press as large ASCII art on screen, so an audience can follow along</p>\n<p class=\"\">This was a fun \"can robots make TUIs?\" experiment. Answer: they can, and it's fun. The idea was to have it open in a tmux pane while I work — especially vim stuff, where people wanna know your shortcuts. The question got answered, and that was the point.</p>\n<figure role=\"figure\" aria-label=\"How it works — pynput captures keys, pyfiglet renders them as centered ASCII art\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/showtouch/code.png\" alt=\"How it works — pynput captures keys, pyfiglet renders them as centered ASCII art\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/showtouch/code.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/showtouch/code.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/showtouch/code.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/showtouch/code.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"How it works — pynput captures keys, pyfiglet renders them as centered ASCII art\" width=\"1400\" height=\"900\" style=\"--splay-rot:-2.45deg;\" data-dimensions=\"1400×900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">How it works — pynput captures keys, pyfiglet renders them as centered ASCII art</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"showtouch displaying a keystroke as full-screen centered ASCII art\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/showtouch/demo.png\" alt=\"showtouch displaying a keystroke as full-screen centered ASCII art\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/showtouch/demo.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/showtouch/demo.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/showtouch/demo.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/showtouch/demo.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"showtouch displaying a keystroke as full-screen centered ASCII art\" width=\"1750\" height=\"1880\" style=\"--splay-rot:-1.16deg;\" data-dimensions=\"1750×1880\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">showtouch displaying a keystroke as full-screen centered ASCII art</figcaption>\n</figure>","metadata":{"title":"showtouch","date":"2024-10-01T04:00:00.000Z","category":"Tools","featured":false,"url":"https://github.com/ejfox/showtouch","tech":["Python","ASCII Art","Terminal"],"state":"prototype","ai-involvement":"ai-assisted","tags":["cli","terminal","programming"],"words":92,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/creative-interfaces","title":"Hand-Tracking & Generative Art","html":"<p class=\"\">These are my attempts to make computers that respond to the way humans actually move and gesture.</p>\n<p class=\"\">The other day I had this idea to try and control a modular synth using computer vision. I loaded YOLOv4 into a python script and had it detect objects in an episode of Atlanta I had lying around, sending the X and Y positions of every object as control voltage to the oscillators. It makes this weird, haunting, sort of robotic sound — but I kind of really like it. Then I had a fun idea to run it through the webcam, and I couldn't figure out why it wasn't working — I kept seeing the chair as an object. Then I figured it out: the chair is the instrument. Why did I make a chair an instrument? I don't know. I ended up running dance videos from Sherrie Silver, the This Is America choreographer, through it instead — not just the X and Y positions but the distances between the objects — and it comes up with some really cool, interesting generative effects. I don't really know how I use this right now, or what it's for exactly. That's kind of how I know it's art.</p>\n<p class=\"\">That session: <a href=\"https://www.youtube.com/watch?v=k2u--wFhcS4\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.youtube.com/watch?v=k2u--wFhcS4\">Using Computer Vision As A MIDI Controller<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M23.498 6.186a3.02 3.02 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.02 3.02 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.02 3.02 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.02 3.02 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814M9.545 15.568V8.432L15.818 12z\" class=\"\"></path></svg></a>.</p>\n<p class=\"\">The other end of the spectrum: no camera, no screen at all. I've been using Talon to control my computer, so I tried to do the dishes and code at the same time — talking to Claude while my hands were in the sink, having it answer everything out loud. There are some sharp edges — a couple times it didn't pick up everything I said — but the fact that you can detach from looking at a screen and still engage in the mechanical task of writing code and making things with a computer is completely radical. It's unlike anything I've ever experienced in my life.</p>\n<p class=\"\">That one's on video too: <a href=\"https://www.youtube.com/watch?v=381Wq9jYOGk\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.youtube.com/watch?v=381Wq9jYOGk\">Coding While I Do The Dishes<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M23.498 6.186a3.02 3.02 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.02 3.02 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.02 3.02 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.02 3.02 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814M9.545 15.568V8.432L15.818 12z\" class=\"\"></path></svg></a>.</p>\n<p class=\"\">See also <a href=\"/projects/flipper-zero-tools\" class=\"text-blue-600 dark:text-blue-400\">Flipper Zero Tools</a> and <a href=\"/projects/terminal-tools\" class=\"text-blue-600 dark:text-blue-400\">Terminal Tools</a>.</p>\n<h2 class=\"\" id=\"hand-tracking--gesture\">Hand-tracking &#x26; gesture</h2>\n<h3 class=\"\" id=\"hand-midi-controller\">Hand MIDI Controller</h3>\n<figure role=\"figure\" aria-label=\"Hand MIDI Controller — MediaPipe hand landmarks mapped to MIDI CC\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/hand-midi-controller/diagram.png\" alt=\"Hand MIDI Controller — MediaPipe hand landmarks mapped to MIDI CC\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/hand-midi-controller/diagram.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/hand-midi-controller/diagram.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/hand-midi-controller/diagram.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/hand-midi-controller/diagram.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Hand MIDI Controller — MediaPipe hand landmarks mapped to MIDI CC\" width=\"1956\" height=\"1306\" style=\"--splay-rot:2.54deg;\" data-dimensions=\"1956×1306\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Hand MIDI Controller — MediaPipe hand landmarks mapped to MIDI CC</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/hand-midi-controller\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/hand-midi-controller\">Hand MIDI Controller<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> is a real-time hand-tracking MIDI controller built on MediaPipe and OpenCV, turning hand movements into MIDI control signals for music production, VJing, and performance. It includes TouchDesigner integration.</p>\n<h3 class=\"\" id=\"handtrack-websockets\">Handtrack WebSockets</h3>\n<p class=\"\"><a href=\"https://github.com/ejfox/handtrack-websockets\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/handtrack-websockets\">Handtrack WebSockets<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> does real-time 3D hand tracking with MediaPipe, OpenCV, and FastAPI, streaming hand position and rotation over WebSockets. It includes a cyberpunk visualization layer that renders the tracked hand.</p>\n<h3 class=\"\" id=\"opencv-midi-controller\">OpenCV MIDI Controller</h3>\n<figure role=\"figure\" aria-label=\"OpenCV MIDI Controller — OpenCV object tracking driving MIDI CC\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/opencv-midi-controller/diagram.png\" alt=\"OpenCV MIDI Controller — OpenCV object tracking driving MIDI CC\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/opencv-midi-controller/diagram.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/opencv-midi-controller/diagram.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/opencv-midi-controller/diagram.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/opencv-midi-controller/diagram.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"OpenCV MIDI Controller — OpenCV object tracking driving MIDI CC\" width=\"2082\" height=\"1293\" style=\"--splay-rot:1.05deg;\" data-dimensions=\"2082×1293\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">OpenCV MIDI Controller — OpenCV object tracking driving MIDI CC</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/opencv-midi-controller\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/opencv-midi-controller\">OpenCV MIDI Controller<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> is an experiment in sending MIDI signals based on OpenCV object detection.</p>\n<h3 class=\"\" id=\"speech-echoes\">Speech Echoes</h3>\n<figure role=\"figure\" aria-label=\"Live speech rendered as decaying typographic echoes\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/opencv-talk-typography/echo-1.png\" alt=\"Live speech rendered as decaying typographic echoes\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/opencv-talk-typography/echo-1.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/opencv-talk-typography/echo-1.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/opencv-talk-typography/echo-1.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/opencv-talk-typography/echo-1.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Live speech rendered as decaying typographic echoes\" width=\"3258\" height=\"2262\" style=\"--splay-rot:-1.24deg;\" data-dimensions=\"3258×2262\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Live speech rendered as decaying typographic echoes</figcaption>\n</figure>\n<p class=\"\">Speech Echoes is a Vue app that captures live speech and renders it as decaying typographic \"echoes,\" with a font and weight captured per utterance. It's built with p5, MediaPipe, and d3-force.</p>\n<h3 class=\"\" id=\"glasses-hud\">Glasses HUD</h3>\n<figure role=\"figure\" aria-label=\"The two lenses you compose a HUD across\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/glasses-hud/lenses.png\" alt=\"The two lenses you compose a HUD across\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/glasses-hud/lenses.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/glasses-hud/lenses.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/glasses-hud/lenses.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/glasses-hud/lenses.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The two lenses you compose a HUD across\" width=\"1600\" height=\"600\" style=\"--splay-rot:0.92deg;\" data-dimensions=\"1600×600\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The two lenses you compose a HUD across</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://ejfox.github.io/glasses-hud/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://ejfox.github.io/glasses-hud/\">Glasses HUD<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M14.435 12.973c.269 0 .492.133.686.396c.192.265.294.588.294.975c0 .385-.102.711-.294.973c-.193.265-.417.396-.686.396c-.278 0-.522-.131-.715-.396c-.192-.262-.294-.588-.294-.973c0-.387.102-.71.294-.975c.192-.264.436-.396.715-.396m3.44-3.559c.746.811 1.125 1.795 1.125 2.953q0 1.122-.259 2.023q-.263.896-.654 1.459a4.3 4.3 0 0 1-.974.989a5 5 0 0 1-1.065.623a5.5 5.5 0 0 1-1.111.306a9 9 0 0 1-.943.123l-.685.014l-.547.015a18 18 0 0 1-1.524 0l-.547-.015l-.685-.014a9 9 0 0 1-.943-.123a5.3 5.3 0 0 1-1.111-.306a5 5 0 0 1-1.064-.623a4.3 4.3 0 0 1-.975-.989q-.392-.563-.654-1.459q-.26-.9-.259-2.023c0-1.158.379-2.143 1.125-2.953q-.123-.062-.008-1.217a7 7 0 0 1 .495-2.132c.934.099 2.09.629 3.471 1.581c.466-.119 1.101-.183 1.917-.183q1.278.002 1.918.184q.943-.638 1.805-1.034c.584-.261 1.005-.416 1.269-.457l.396-.09q.404.974.496 2.132q.114 1.154-.009 1.216m-5.845 7.82c1.688 0 2.954-.202 3.821-.607c.855-.404 1.292-1.238 1.292-2.496q0-1.096-.822-1.828a1.85 1.85 0 0 0-.989-.486q-.563-.091-1.72 0q-1.155.091-1.582.09c-.385 0-.8-.018-1.319-.059q-.78-.06-1.223-.078a3.7 3.7 0 0 0-.958.108a1.9 1.9 0 0 0-.853.425c-.521.469-.79 1.077-.79 1.828q0 1.888 1.28 2.496c.85.405 2.113.607 3.802.607zm-2.434-4.261c.268 0 .492.133.685.396c.192.265.294.588.294.975c0 .385-.102.711-.294.973c-.192.265-.417.396-.685.396c-.279 0-.522-.131-.716-.396c-.192-.262-.294-.588-.294-.973c0-.387.102-.71.294-.975c.193-.264.436-.396.716-.396\" class=\"\"></path></svg></a> is a tool for quickly prototyping AR lens visualizations and HUDs. Drag data widgets onto the left and right lenses to compose a heads-up display from live bio, motion, location, environment, and device signals.</p>","metadata":{"title":"Hand-Tracking & Generative Art","date":"2024-07-01T04:00:00.000Z","category":"Art","featured":false,"modified":"2025-08-13T15:46:54.000Z","tech":["Computer Vision","MIDI","WebGL","Hand Tracking","Generative Art","Interactive Media"],"state":"deployed","ai-involvement":"ai-assisted","tags":["creativity","interfaces"],"words":540,"images":4,"imageDetails":{"total":4,"cloudinary":4,"withDimensions":0},"links":8,"codeBlocks":0,"headers":{"h2":1},"toc":[{"text":"Hand-tracking & gesture","slug":"hand-tracking--gesture","level":"h2","children":[{"text":"Hand MIDI Controller","slug":"hand-midi-controller","level":"h3","children":[]},{"text":"Handtrack WebSockets","slug":"handtrack-websockets","level":"h3","children":[]},{"text":"OpenCV MIDI Controller","slug":"opencv-midi-controller","level":"h3","children":[]},{"text":"Speech Echoes","slug":"speech-echoes","level":"h3","children":[]},{"text":"Glasses HUD","slug":"glasses-hud","level":"h3","children":[]}]}],"type":"post"}},{"slug":"projects/obsidian-analysis","title":"Obsidian Analysis","html":"<p class=\"\">I embedded my entire Obsidian vault — around 2,000 notes, chunked — using local Nomic embeddings running in LM Studio, so my private notes never leave my machine, and then I laid the whole thing out as a semantic map. Every note is a point, and points that sit close together are notes that are actually about the same thing. You search it in plain natural language, and you can recolor the map by semantics, recency, note size, or link density to see the vault from a different angle each time.</p>\n<figure role=\"figure\" aria-label=\"~2,000 notes embedded into a single semantic map — search and filter by SEMANTIC / TEMPORAL / SIZE / LINKS\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/obsidian-analysis/map.png\" alt=\"~2,000 notes embedded into a single semantic map — search and filter by SEMANTIC / TEMPORAL / SIZE / LINKS\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/obsidian-analysis/map.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/obsidian-analysis/map.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/obsidian-analysis/map.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/obsidian-analysis/map.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"~2,000 notes embedded into a single semantic map — search and filter by SEMANTIC / TEMPORAL / SIZE / LINKS\" width=\"1440\" height=\"813\" style=\"--splay-rot:2.44deg;\" data-dimensions=\"1440×813\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">~2,000 notes embedded into a single semantic map — search and filter by SEMANTIC / TEMPORAL / SIZE / LINKS</figcaption>\n</figure>\n<p class=\"\">The other half of this is the parameter grid. UMAP has a lot of knobs and the layout you get depends entirely on how you set them, so instead of guessing I ran 64 combinations of <code class=\"md-inline-code\">n_neighbors</code> and <code class=\"md-inline-code\">min_dist</code> at once and put them side by side. It turns \"which settings are right\" into something you can just look at — the same vault laid out 64 different ways, all on one screen.</p>\n<figure role=\"figure\" aria-label=\"A 64-combination UMAP parameter grid — the same vault laid out 64 different ways\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/obsidian-analysis/grid.png\" alt=\"A 64-combination UMAP parameter grid — the same vault laid out 64 different ways\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/obsidian-analysis/grid.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/obsidian-analysis/grid.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/obsidian-analysis/grid.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/obsidian-analysis/grid.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A 64-combination UMAP parameter grid — the same vault laid out 64 different ways\" width=\"1440\" height=\"813\" style=\"--splay-rot:1.59deg;\" data-dimensions=\"1440×813\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A 64-combination UMAP parameter grid — the same vault laid out 64 different ways</figcaption>\n</figure>\n<h2 class=\"\" id=\"more-semantic-maps\">More semantic maps</h2>\n<h3 class=\"\" id=\"criterion-embeddings\">Criterion Embeddings</h3>\n<figure role=\"figure\" aria-label=\"Criterion film embeddings explored as a 2D map\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780060622/projects/data-visualization-suite/gh-4.png\" alt=\"Criterion film embeddings explored as a 2D map\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1780060622/projects/data-visualization-suite/gh-4.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1780060622/projects/data-visualization-suite/gh-4.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1780060622/projects/data-visualization-suite/gh-4.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1780060622/projects/data-visualization-suite/gh-4.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Criterion film embeddings explored as a 2D map\" width=\"2024\" height=\"2474\" style=\"--splay-rot:-2.99deg;\" data-dimensions=\"2024×2474\" class=\"img-full my-8 rounded-sm img-splay aspect-[3/4] w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Criterion film embeddings explored as a 2D map</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/criterion-embedding-viz\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/criterion-embedding-viz\">Criterion Embeddings<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> computes vector embeddings for every Criterion Collection film and projects them into an explorable 2D map. You search by theme (\"films about existentialism\") instead of by keyword.</p>\n<h3 class=\"\" id=\"rdataisbeautiful-embedded\">r/dataisbeautiful, Embedded</h3>\n<figure role=\"figure\" aria-label=\"r/dataisbeautiful embedded — 1,000 posts clustered into 50 thematic bubbles, sized by count\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/reddit-embeddings/map.png\" alt=\"r/dataisbeautiful embedded — 1,000 posts clustered into 50 thematic bubbles, sized by count\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/reddit-embeddings/map.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/reddit-embeddings/map.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/reddit-embeddings/map.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/reddit-embeddings/map.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"r/dataisbeautiful embedded — 1,000 posts clustered into 50 thematic bubbles, sized by count\" width=\"1440\" height=\"953\" style=\"--splay-rot:-2.1deg;\" data-dimensions=\"1440×953\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">r/dataisbeautiful embedded — 1,000 posts clustered into 50 thematic bubbles, sized by count</figcaption>\n</figure>\n<p class=\"\">The top 1,000 posts from r/dataisbeautiful, each embedded with OpenAI and clustered into 50 themes, laid out as a map. The biggest clusters: US politics dataviz, COVID and mortality, Google search trends, climate, creative visualizations, and personal-finance charts.</p>\n<h3 class=\"\" id=\"code-network-gen\">code-network-gen</h3>\n<figure role=\"figure\" aria-label=\"Real output — every script in this site&#x27;s content pipeline as a call-constellation (pink hub = file scope, teal = functions, edges = calls)\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/code-network-gen/graph.png\" alt=\"Real output — every script in this site&#x27;s content pipeline as a call-constellation (pink hub = file scope, teal = functions, edges = calls)\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/code-network-gen/graph.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/code-network-gen/graph.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/code-network-gen/graph.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/code-network-gen/graph.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Real output — every script in this site&#x27;s content pipeline as a call-constellation (pink hub = file scope, teal = functions, edges = calls)\" width=\"1677\" height=\"1000\" style=\"--splay-rot:-0.94deg;\" data-dimensions=\"1677×1000\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Real output — every script in this site's content pipeline as a call-constellation (pink hub = file scope, teal = functions, edges = calls)</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/code-network-gen\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/code-network-gen\">code-network-gen<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> generates a node/edge graph from a JavaScript codebase by walking the AST with acorn and babel. It shows software architecture as an explorable network instead of a file tree — above, the scripts behind this site's own content pipeline.</p>","metadata":{"title":"Obsidian Analysis","date":"2024-06-01T04:00:00.000Z","category":"Dataviz","featured":false,"url":"https://github.com/ejfox/obsidian-analysis","tech":["Python","Embeddings","UMAP","LM Studio"],"state":"deployed","ai-involvement":"ai-collaborative","tags":["data","visualization","ai"],"words":362,"images":5,"imageDetails":{"total":5,"cloudinary":4,"withDimensions":0},"links":2,"codeBlocks":0,"headers":{"h2":1},"toc":[{"text":"More semantic maps","slug":"more-semantic-maps","level":"h2","children":[{"text":"Criterion Embeddings","slug":"criterion-embeddings","level":"h3","children":[]},{"text":"r/dataisbeautiful, Embedded","slug":"rdataisbeautiful-embedded","level":"h3","children":[]},{"text":"code-network-gen","slug":"code-network-gen","level":"h3","children":[]}]}],"type":"post"}},{"slug":"projects/photography-tools","title":"EXIF Printer","html":"<p class=\"\">I love being able to reference the technical details when editing photography—aperture, shutter speed, ISO, all the little choices that go into making a photograph. But most photo prints completely ignore this data, treating it like metadata to be hidden away in EXIF tags that nobody ever sees. So I made a little app to add the EXIF data to the margins of prints you might get from CVS, Walgreens, or Wal-Mart (don't use them for photo prints, I learned the hard way).</p>\n<figure role=\"figure\" aria-label=\"EXIF Photo Printer application displaying five family photos with options to regenerate layouts or save prints at 4x6 size.\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755098311/screenshot_2025-08-13_at_11.18.12_AM.png\" alt=\"EXIF Photo Printer application displaying five family photos with options to regenerate layouts or save prints at 4x6 size.\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1755098311/screenshot_2025-08-13_at_11.18.12_AM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1755098311/screenshot_2025-08-13_at_11.18.12_AM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755098311/screenshot_2025-08-13_at_11.18.12_AM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1755098311/screenshot_2025-08-13_at_11.18.12_AM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"EXIF Photo Printer application displaying five family photos with options to regenerate layouts or save prints at 4x6 size.\" width=\"2400\" height=\"1800\" style=\"--splay-rot:1.21deg;\" data-dimensions=\"2400×1800\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">EXIF Photo Printer application displaying five family photos with options to regenerate layouts or save prints at 4x6 size.</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"The EXIF Photo Printer marketing site\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/photography-tools/exifprinter.png\" alt=\"The EXIF Photo Printer marketing site\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/photography-tools/exifprinter.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/photography-tools/exifprinter.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/photography-tools/exifprinter.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/photography-tools/exifprinter.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The EXIF Photo Printer marketing site\" width=\"2880\" height=\"1900\" style=\"--splay-rot:-0.86deg;\" data-dimensions=\"2880×1900\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The EXIF Photo Printer marketing site</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/exif-printer-simple\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/exif-printer-simple\">exif-printer<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a>: Creates printable photos with camera settings displayed in the borders.</p>","metadata":{"title":"EXIF Printer","date":"2024-04-01T04:00:00.000Z","category":"Tools","featured":false,"modified":"2025-08-13T15:20:07.000Z","tech":["Photography","EXIF","Print Design","Web Design","Camera Metadata"],"state":"deployed","ai-involvement":"ai-assisted","tags":["photography","tools"],"words":119,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":1,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/motorcycle-pico8","title":"Motorcycle.p8","html":"<p class=\"\">This was my first experiment in coding a PICO-8 game, so I wanted it to tie into something that sparks joy for me — motorcycle rides — and try to capture its essence in a minimalist video game sketch. That's the exact transmutation that I think makes PICO-8 magical. I hand-coded the whole thing, referencing the docs — this was pre-robot, June 2023. My brother made the sprites and the music (a tracker engine that escalates through five patterns as speed climbs, then collapses on a crash).</p>\n<p class=\"\">I love how it feels once you get to a high speed and the road is coming at you fast — it does, in fact, remind me of the feeling of riding a motorcycle. Which I think is, like, an artistic achievement of some kind.</p>\n<figure role=\"figure\" aria-label=\"The motorcycle.p8 source in the Pico-8 editor — perlin road sway and the speed-driven music-pattern engine\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-pico8/source.png\" alt=\"The motorcycle.p8 source in the Pico-8 editor — perlin road sway and the speed-driven music-pattern engine\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/motorcycle-pico8/source.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/motorcycle-pico8/source.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/motorcycle-pico8/source.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/motorcycle-pico8/source.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The motorcycle.p8 source in the Pico-8 editor — perlin road sway and the speed-driven music-pattern engine\" width=\"1152\" height=\"636\" style=\"--splay-rot:-0.6deg;\" data-dimensions=\"1152×636\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The motorcycle.p8 source in the Pico-8 editor — perlin road sway and the speed-driven music-pattern engine</figcaption>\n</figure>","metadata":{"title":"Motorcycle.p8","date":"2023-07-02T00:00:00.000Z","category":"Games","featured":false,"tech":["Pico-8","Lua"],"state":"deployed","ai-involvement":"human-only","context":"collaborative","tags":["games","art","motorcycle"],"words":148,"images":1,"imageDetails":{"total":1,"cloudinary":1,"withDimensions":0},"links":0,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/coach-artie","title":"Coach Artie","html":"<p class=\"\">Coach Artie is the AI assistant that lives in the Room 302 Studio Discord. He came online in March 2023 and has been answering the studio's questions, remembering its people and running its errands ever since.</p>\n<figure role=\"figure\" aria-label=\"Coach Artie interface\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1743818354/Screenshot_2025-04-04_at_9.59.00_PM.png\" alt=\"Coach Artie interface\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1743818354/Screenshot_2025-04-04_at_9.59.00_PM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1743818354/Screenshot_2025-04-04_at_9.59.00_PM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1743818354/Screenshot_2025-04-04_at_9.59.00_PM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1743818354/Screenshot_2025-04-04_at_9.59.00_PM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Coach Artie interface\" width=\"3358\" height=\"1884\" style=\"--splay-rot:0.76deg;\" data-dimensions=\"3358×1884\" class=\"img-full my-8 rounded-sm img-splay aspect-video w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Coach Artie interface</figcaption>\n</figure>\n<h2 class=\"\" id=\"version-one-a-studio-assistant\">Version one: a studio assistant</h2>\n<p class=\"\">The <a href=\"https://github.com/room302studio/coachartie\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/room302studio/coachartie\">first Coach Artie<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> was a Discord bot with memory. Alongside each message, he gets the history of his past conversations with that person, the memories he's formed about them, and a random mix of other memories. That mix is what made him feel like a member of the studio rather than a search box. As I wrote in my 2024 year in review, he \"developed into an AI assistant that helps all members of the studio.\" I purposely left out the ability to DM him, to encourage public conversation.</p>\n<h2 class=\"\" id=\"version-two-one-brain-many-doors\">Version two: one brain, many doors</h2>\n<p class=\"\"><a href=\"https://github.com/room302studio/coachartie2\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/room302studio/coachartie2\">Coach Artie 2<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> is a 2025 rebuild as a TypeScript monorepo. One capabilities service sits behind many doors: Discord, Slack, SMS, IRC, email and a web \"brain\" UI for looking inside his head. A Redis queue feeds the language model through OpenRouter, and his memory and state live in SQLite. Capabilities are grouped by what they do: research, memory, the web, development, media, productivity, finance and more.</p>\n<h2 class=\"\" id=\"what-he-remembers\">What he remembers</h2>\n<p class=\"\">What does an assistant actually remember after years of conversations? <a href=\"/projects/coachartie-memory\" class=\"text-blue-600 dark:text-blue-400\">Coach Artie: Memory Analysis</a> embeds all of his memories, clusters them and draws the map.</p>\n<h2 class=\"\" id=\"the-showcase-site\">The showcase site</h2>\n<figure role=\"figure\" aria-label=\"Meet Coach Artie — the showcase landing\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/coachartie-showcase/hero.png\" alt=\"Meet Coach Artie — the showcase landing\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/coachartie-showcase/hero.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/coachartie-showcase/hero.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/coachartie-showcase/hero.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/coachartie-showcase/hero.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Meet Coach Artie — the showcase landing\" width=\"1440\" height=\"1500\" style=\"--splay-rot:-2.09deg;\" data-dimensions=\"1440×1500\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Meet Coach Artie — the showcase landing</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://github.com/ejfox/coachartie_showcase\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/ejfox/coachartie_showcase\">The Coach Artie showcase<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> is a one-page editorial site in big serif display type on black, walking through what the agent does: adapts to its environment, remembers what matters, stays cost-conscious, writes its own release notes. It lays out 28 capabilities like a magazine feature.</p>","metadata":{"title":"Coach Artie","date":"2023-03-21T04:00:00.000Z","category":"Tools","featured":false,"modified":"2025-08-26T19:52:50.000Z","url":"https://github.com/room302studio/coachartie2","tech":["Node.js","TypeScript","Discord.js","OpenRouter","Redis","SQLite"],"state":"evolved","ai-involvement":"ai-enhanced","context":"collaborative","tags":["programming","ai","product","nodejs"],"about":"[[project-notes/coach-artie]]","words":294,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":4,"codeBlocks":0,"headers":{"h2":4},"toc":[{"text":"Version one: a studio assistant","slug":"version-one-a-studio-assistant","level":"h2","children":[]},{"text":"Version two: one brain, many doors","slug":"version-two-one-brain-many-doors","level":"h2","children":[]},{"text":"What he remembers","slug":"what-he-remembers","level":"h2","children":[]},{"text":"The showcase site","slug":"the-showcase-site","level":"h2","children":[]}],"type":"post"}},{"slug":"projects/margin-maps","title":"The Margin","html":"<p class=\"\">Data storytelling for <a href=\"https://themargin.us\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://themargin.us\">The Margin</a>, an independent outlet covering the people and places most affected by environmental injustice. I'm credited for data storytelling on its first two features, which pair the reporting with satellite-based map scrollytelling.</p>\n<h2 class=\"\" id=\"we-glow-in-the-dark\">We Glow in the Dark</h2>\n<p class=\"\">Leanna First-Arai's story on the Memphis Defense Depot, a Superfund site where the Army stored and disposed of munitions and chemical weapons for some 50 years, and the community around it demanding reparations. Made with The Margin and <a href=\"https://www.earthgenome.org/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.earthgenome.org/\">Earth Genome</a>.</p>\n<figure role=\"figure\" aria-label=\"Memphis — a historical city map registered over present-day satellite imagery\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1666630398/project-images/margin_memphis.png\" alt=\"Memphis — a historical city map registered over present-day satellite imagery\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1666630398/project-images/margin_memphis.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1666630398/project-images/margin_memphis.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1666630398/project-images/margin_memphis.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1666630398/project-images/margin_memphis.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Memphis — a historical city map registered over present-day satellite imagery\" width=\"5001\" height=\"2625\" style=\"--splay-rot:2.87deg;\" data-dimensions=\"5001×2625\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Memphis — a historical city map registered over present-day satellite imagery</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://themargin.us/features/we-glow-in-the-dark\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://themargin.us/features/we-glow-in-the-dark\">Read the story →</a></p>\n<h2 class=\"\" id=\"opening-the-gateway\">Opening the Gateway</h2>\n<p class=\"\">Rico Moore's feature on Seattle City Light's three dams on the Skagit River, and the Upper Skagit Tribe's fight over salmon and treaty rights during the dams' federal relicensing.</p>\n<figure role=\"figure\" aria-label=\"Skagit River — a satellite-based feature on hydroelectric dams\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1666630398/project-images/margin_skagit.png\" alt=\"Skagit River — a satellite-based feature on hydroelectric dams\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1666630398/project-images/margin_skagit.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1666630398/project-images/margin_skagit.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1666630398/project-images/margin_skagit.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1666630398/project-images/margin_skagit.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Skagit River — a satellite-based feature on hydroelectric dams\" width=\"5000\" height=\"2625\" style=\"--splay-rot:-1.11deg;\" data-dimensions=\"5000×2625\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Skagit River — a satellite-based feature on hydroelectric dams</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://themargin.us/features/opening-the-gateway\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://themargin.us/features/opening-the-gateway\">Read the story →</a></p>","metadata":{"title":"The Margin","date":"2022-09-21T04:00:00.000Z","category":"Journalism","featured":false,"draft":false,"client":"The Margin","tech":["Mapping","Satellite Imagery","Scrollytelling","Data Visualization"],"state":"deployed","ai-involvement":"human-only","context":"client","tags":["maps","dataviz","journalism","interactive"],"words":143,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":4,"codeBlocks":0,"headers":{"h2":2},"toc":[{"text":"We Glow in the Dark","slug":"we-glow-in-the-dark","level":"h2","children":[]},{"text":"Opening the Gateway","slug":"opening-the-gateway","level":"h2","children":[]}],"type":"post"}},{"slug":"projects/long-lead","title":"Long Lead","html":"<p class=\"\">Production and technical work for <a href=\"https://longlead.com\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://longlead.com\">Long Lead</a>, a story studio that builds each long-form feature from scratch — no CMS, no templates.</p>\n<h2 class=\"\" id=\"the-depths-shell-reach\">The Depths She'll Reach</h2>\n<p class=\"\">Xan Rice's profile of world-record freediver Alenka Artnik, told as an immersive scroll that mirrors the descent. It won two Webbys for Best Sports Website.</p>\n<figure role=\"figure\" aria-label=\"The Depths She&#x27;ll Reach — a freediver descending, with captioned audio\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1666630397/project-images/ll_diver.png\" alt=\"The Depths She&#x27;ll Reach — a freediver descending, with captioned audio\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1666630397/project-images/ll_diver.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1666630397/project-images/ll_diver.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1666630397/project-images/ll_diver.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1666630397/project-images/ll_diver.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The Depths She&#x27;ll Reach — a freediver descending, with captioned audio\" width=\"5000\" height=\"2625\" style=\"--splay-rot:-2.93deg;\" data-dimensions=\"5000×2625\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The Depths She'll Reach — a freediver descending, with captioned audio</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://thedepths.longlead.com/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://thedepths.longlead.com/\">Read the story →</a></p>\n<h2 class=\"\" id=\"querdenken-covid-misinformation-or-freedom-movement\">Querdenken: COVID Misinformation or Freedom Movement?</h2>\n<p class=\"\">Darren Loucaides' investigation into Germany's Querdenken movement and the 2020 storming of the Reichstag. I'm credited for production and technical support. The data graphics trace the audience growth of the Querdenken_711 and Qlobal Change Telegram channels (data: Telemetrio) and map the Telegram universe around Querdenken_711 (network data: Jordan Wildon / Logically).</p>\n<figure role=\"figure\" aria-label=\"The Rise of Qlobal Change &#x26; Querdenken_711 — audience growth beside the Telegram network map\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1666630397/project-images/ll_querdenken.png\" alt=\"The Rise of Qlobal Change &#x26; Querdenken_711 — audience growth beside the Telegram network map\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1666630397/project-images/ll_querdenken.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1666630397/project-images/ll_querdenken.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1666630397/project-images/ll_querdenken.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1666630397/project-images/ll_querdenken.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The Rise of Qlobal Change &#x26; Querdenken_711 — audience growth beside the Telegram network map\" width=\"5001\" height=\"2625\" style=\"--splay-rot:1.24deg;\" data-dimensions=\"5001×2625\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The Rise of Qlobal Change &#x26; Querdenken_711 — audience growth beside the Telegram network map</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://querdenken.longlead.com/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://querdenken.longlead.com/\">Read the story →</a></p>","metadata":{"title":"Long Lead","date":"2021-11-18T05:00:00.000Z","category":"Journalism","featured":false,"draft":false,"client":"Long Lead","tech":["Scrollytelling","Data Visualization","Network Analysis"],"state":"deployed","ai-involvement":"human-only","context":"client","tags":["data","dataviz","journalism","investigations"],"words":144,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":3,"codeBlocks":0,"headers":{"h2":2},"toc":[{"text":"The Depths She'll Reach","slug":"the-depths-shell-reach","level":"h2","children":[]},{"text":"Querdenken: COVID Misinformation or Freedom Movement?","slug":"querdenken-covid-misinformation-or-freedom-movement","level":"h2","children":[]}],"type":"post"}},{"slug":"projects/ccrb-clusters","title":"Clusters of NYPD Misconduct","html":"<figure role=\"figure\" aria-label=\"Force-directed network graph of NYPD civilian complaint clusters showing officers connected by shared misconduct patterns\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1624505769/Screen_Shot_2021-06-21_at_8.58.50_PM.jpg\" alt=\"Force-directed network graph of NYPD civilian complaint clusters showing officers connected by shared misconduct patterns\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1624505769/Screen_Shot_2021-06-21_at_8.58.50_PM.jpg 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1624505769/Screen_Shot_2021-06-21_at_8.58.50_PM.jpg 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1624505769/Screen_Shot_2021-06-21_at_8.58.50_PM.jpg 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1624505769/Screen_Shot_2021-06-21_at_8.58.50_PM.jpg 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Force-directed network graph of NYPD civilian complaint clusters showing officers connected by shared misconduct patterns\" width=\"2800\" height=\"2285\" style=\"--splay-rot:-1.18deg;\" data-dimensions=\"2800×2285\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Force-directed network graph of NYPD civilian complaint clusters showing officers connected by shared misconduct patterns</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Scrolling the published Gothamist investigation — the officer-cluster network as readers met it\" class=\"\"><video class=\"img-full my-8 rounded-sm\" autoplay loop muted playsinline preload=\"metadata\" poster=\"https://res.cloudinary.com/ejf/video/upload/so_0,f_jpg,q_auto,w_1280/projects/ccrb-clusters/published-piece-scroll.jpg\" src=\"https://res.cloudinary.com/ejf/video/upload/q_auto/projects/ccrb-clusters/published-piece-scroll.mp4\" aria-label=\"Scrolling the published Gothamist investigation — the officer-cluster network as readers met it\"></video><figcaption class=\"\">Scrolling the published Gothamist investigation — the officer-cluster network as readers met it</figcaption></figure>\n<figure role=\"figure\" aria-label=\"The network visualization in the published piece\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/ccrb-clusters/article-viz.png\" alt=\"The network visualization in the published piece\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/ccrb-clusters/article-viz.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/ccrb-clusters/article-viz.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/ccrb-clusters/article-viz.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/ccrb-clusters/article-viz.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The network visualization in the published piece\" width=\"1080\" height=\"800\" style=\"--splay-rot:2.31deg;\" data-dimensions=\"1080×800\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The network visualization in the published piece</figcaption>\n</figure>\n<p class=\"\">In 2021, WNYC/Gothamist got the NYPD's full civilian-complaint dataset through a FOIL request and handed it to me — every complaint and every officer since 2000, including officers named only as witnesses and complaints the CCRB never substantiated. Noisier than the data ProPublica had published a year earlier, but for mapping a network that noise is the signal: being named on a complaint alongside another officer, even an unfounded one, means the two of them interacted in a way the public noticed.</p>\n<p class=\"\">Complaints are often the first and only warning that an officer is on a course of escalating violence. Derek Chauvin had at least 22 complaints across 19 years before he killed George Floyd; the officer who choked Eric Garner had 7. Nothing disrupted either pattern.</p>\n<p class=\"\">So I built the network — 29,915 officer nodes, 159,671 relationships, in Neo4j (with a lot of Cypher help from David Allen at Neo4j) and laid out in Gephi (love-hate relationship, but unrivaled for networks). Two officers get a link — a <code class=\"md-inline-code\">CO_OCCURANCE</code> — when they appear on the same complaint, weighted by how many they share: three complaints together, weight 3. I filtered out everything the CCRB marked <em class=\"\">exonerated</em> or <em class=\"\">unfounded</em> first, and only labeled an officer by name if they'd had a complaint <em class=\"\">substantiated</em> — a caution I was told lawyers would appreciate. Everyone else stays an anonymous ID.</p>\n<p class=\"\">One sign it was working: when I ran community detection, the clusters mirrored real NYPD precincts — officers appear on complaints with the officers they actually work beside.</p>\n<p class=\"\">Then the big nodes surfaced — Martinez, Radoncic, Grieco — officers co-appearing on complaint after complaint. When Gothamist reporter George Joseph took those names to the street and interviewed victims, the graph held up: the central nodes were acting as catalysts in real life. One man described a big node, Adnan Radoncic, sparking a group assault — <em class=\"\">\"As soon as he grabbed me, all the officers was hands on. It's like they just followed his lead.\"</em> Math and reality matched up.</p>\n<p class=\"\">I documented every query and every decision so anyone could rebuild this for their own police department: <strong class=\"\"><a href=\"/blog/2021/nypd-ccrb-complaint-clusters\" class=\"text-blue-600 dark:text-blue-400\">the full methodology write-up →</a></strong></p>","metadata":{"title":"Clusters of NYPD Misconduct","date":"2021-06-15T04:00:00.000Z","category":"Journalism","modified":"2025-08-26T19:52:56.000Z","url":"https://gothamist.com/news/mapping-clusters-nypd-officers-repeatedly-accused-misconduct","tech":["Neo4j","SQLite","Gephi","Network Analysis","Cypher"],"featured":true,"state":"deployed","ai-involvement":"human-only","context":"client","tags":["data","nypd","journalism","database"],"words":394,"images":3,"imageDetails":{"total":3,"cloudinary":3,"withDimensions":0},"links":1,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/covidcast","title":"COVIDcast","html":"<p class=\"\">During the early COVID-19 pandemic, I worked with Carnegie Mellon University's Delphi research group on <a href=\"https://delphi.cmu.edu/epidemic-signals/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://delphi.cmu.edu/epidemic-signals/\">COVIDcast</a>, prototyping data visualizations and interactions to help the public understand hospitalization, transmission and movement trends across the country.</p>\n<p class=\"\">COVIDcast launched in April 2020 with a then-unusual idea: no single signal could be trusted on its own, so it mapped many rough ones side by side. These included doctor and telemedicine visits, symptom searches on Google, flu-test statistics, and symptom surveys run through Facebook and Google. Where several signals agreed, you could believe them.</p>\n<figure role=\"figure\" aria-label=\"Scrolling the COVIDcast dashboard as it runs today — indicator tiles, the county-level map, and the signals table\" class=\"\"><video class=\"img-full my-8 rounded-sm\" autoplay loop muted playsinline preload=\"metadata\" poster=\"https://res.cloudinary.com/ejf/video/upload/so_0,f_jpg,q_auto,w_1280/projects/covidcast/dashboard-scroll.jpg\" src=\"https://res.cloudinary.com/ejf/video/upload/q_auto/projects/covidcast/dashboard-scroll.mp4\" aria-label=\"Scrolling the COVIDcast dashboard as it runs today — indicator tiles, the county-level map, and the signals table\"></video><figcaption class=\"\">Scrolling the COVIDcast dashboard as it runs today — indicator tiles, the county-level map, and the signals table</figcaption></figure>\n<figure role=\"figure\" aria-label=\"COVIDcast dashboard: weekly case, hospitalization, and death rates above an interactive county-level map\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1666630395/project-images/cmu_covidcast.png\" alt=\"COVIDcast dashboard: weekly case, hospitalization, and death rates above an interactive county-level map\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1666630395/project-images/cmu_covidcast.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1666630395/project-images/cmu_covidcast.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1666630395/project-images/cmu_covidcast.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1666630395/project-images/cmu_covidcast.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"COVIDcast dashboard: weekly case, hospitalization, and death rates above an interactive county-level map\" width=\"5001\" height=\"2625\" style=\"--splay-rot:0.92deg;\" data-dimensions=\"5001×2625\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">COVIDcast dashboard: weekly case, hospitalization, and death rates above an interactive county-level map</figcaption>\n</figure>\n<p class=\"\">I kept playing with the data afterwards. Using Delphi's open Epidata API, I made time-lapses of doctor visits by county, where each circle is a county sized by the share of outpatient visits due to COVID-like symptoms. One was paired with a data sonification of the same numbers for all of New York State.</p>\n<figure role=\"figure\" aria-label=\"A county-level circle map of the United States, one circle per county\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755099710/screenshot_2025-08-13_at_11.41.38_AM.png\" alt=\"A county-level circle map of the United States, one circle per county\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1755099710/screenshot_2025-08-13_at_11.41.38_AM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1755099710/screenshot_2025-08-13_at_11.41.38_AM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755099710/screenshot_2025-08-13_at_11.41.38_AM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1755099710/screenshot_2025-08-13_at_11.41.38_AM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A county-level circle map of the United States, one circle per county\" width=\"1576\" height=\"1110\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1576×1110\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A county-level circle map of the United States, one circle per county</figcaption>\n</figure>","metadata":{"title":"COVIDcast","date":"2020-04-24T04:00:00.000Z","category":"Journalism","featured":false,"modified":"2025-08-26T19:53:05.000Z","url":"https://campustechnology.com/articles/2020/04/24/carnegie-mellon-maps-offer-more-data-for-covid-19-forecasting.aspx","tech":["Data Visualization","JavaScript","COVID-19 Data","Public Health"],"state":"deployed","ai-involvement":"human-only","context":"client","tags":["covid","dataviz","javascript","health"],"words":186,"images":3,"imageDetails":{"total":3,"cloudinary":3,"withDimensions":0},"links":1,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/twitter-artbot","title":"Twitter Artbot","html":"<p class=\"\">My first generative art bot — <a href=\"https://x.com/417am1975\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://x.com/417am1975\">@417am1975</a>, running since 2017. It picks a random art script, renders a piece with node-canvas, and tweets it, on the hour, on its own.</p>\n<p class=\"\">This was my first generative art bot, and boy did he make some dope shit. It came from node-canvas tick-generations.</p>\n<p class=\"\">I like it as an artifact of how I was using the robots to make art before AI, and how that changes my perspective on things as a computer-aided artist.</p>\n<figure role=\"figure\" aria-label=\"Twitter Artbot — generative piece 1\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-01.png\" alt=\"Twitter Artbot — generative piece 1\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/twitter-artbot/piece-01.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/twitter-artbot/piece-01.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-01.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/twitter-artbot/piece-01.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Twitter Artbot — generative piece 1\" width=\"1080\" height=\"1080\" style=\"--splay-rot:-0.95deg;\" data-dimensions=\"1080×1080\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Twitter Artbot — generative piece 1</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Twitter Artbot — generative piece 2\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-02.png\" alt=\"Twitter Artbot — generative piece 2\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/twitter-artbot/piece-02.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/twitter-artbot/piece-02.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-02.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/twitter-artbot/piece-02.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Twitter Artbot — generative piece 2\" width=\"1080\" height=\"1080\" style=\"--splay-rot:-1.24deg;\" data-dimensions=\"1080×1080\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Twitter Artbot — generative piece 2</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Twitter Artbot — generative piece 3\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-03.png\" alt=\"Twitter Artbot — generative piece 3\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/twitter-artbot/piece-03.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/twitter-artbot/piece-03.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-03.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/twitter-artbot/piece-03.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Twitter Artbot — generative piece 3\" width=\"1080\" height=\"1080\" style=\"--splay-rot:-1.32deg;\" data-dimensions=\"1080×1080\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Twitter Artbot — generative piece 3</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Twitter Artbot — generative piece 4\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-04.png\" alt=\"Twitter Artbot — generative piece 4\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/twitter-artbot/piece-04.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/twitter-artbot/piece-04.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-04.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/twitter-artbot/piece-04.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Twitter Artbot — generative piece 4\" width=\"1080\" height=\"1080\" style=\"--splay-rot:1.87deg;\" data-dimensions=\"1080×1080\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Twitter Artbot — generative piece 4</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Twitter Artbot — generative piece 5\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-05.png\" alt=\"Twitter Artbot — generative piece 5\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/twitter-artbot/piece-05.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/twitter-artbot/piece-05.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-05.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/twitter-artbot/piece-05.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Twitter Artbot — generative piece 5\" width=\"1080\" height=\"1080\" style=\"--splay-rot:-2.55deg;\" data-dimensions=\"1080×1080\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Twitter Artbot — generative piece 5</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Twitter Artbot — generative piece 6\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-06.png\" alt=\"Twitter Artbot — generative piece 6\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/twitter-artbot/piece-06.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/twitter-artbot/piece-06.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-06.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/twitter-artbot/piece-06.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Twitter Artbot — generative piece 6\" width=\"1080\" height=\"1080\" style=\"--splay-rot:-2.81deg;\" data-dimensions=\"1080×1080\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Twitter Artbot — generative piece 6</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Twitter Artbot — generative piece 7\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-07.png\" alt=\"Twitter Artbot — generative piece 7\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/twitter-artbot/piece-07.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/twitter-artbot/piece-07.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-07.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/twitter-artbot/piece-07.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Twitter Artbot — generative piece 7\" width=\"720\" height=\"1280\" style=\"--splay-rot:1.25deg;\" data-dimensions=\"720×1280\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Twitter Artbot — generative piece 7</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Twitter Artbot — generative piece 8\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-08.png\" alt=\"Twitter Artbot — generative piece 8\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/twitter-artbot/piece-08.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/twitter-artbot/piece-08.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-08.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/twitter-artbot/piece-08.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Twitter Artbot — generative piece 8\" width=\"1080\" height=\"1080\" style=\"--splay-rot:0.6deg;\" data-dimensions=\"1080×1080\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Twitter Artbot — generative piece 8</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Twitter Artbot — generative piece 9\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-09.png\" alt=\"Twitter Artbot — generative piece 9\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/twitter-artbot/piece-09.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/twitter-artbot/piece-09.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/twitter-artbot/piece-09.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/twitter-artbot/piece-09.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Twitter Artbot — generative piece 9\" width=\"1080\" height=\"1080\" style=\"--splay-rot:1.16deg;\" data-dimensions=\"1080×1080\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Twitter Artbot — generative piece 9</figcaption>\n</figure>\n<p class=\"\">There's a ton still up at <a href=\"https://x.com/417am1975\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://x.com/417am1975\">@417am1975</a>.</p>","metadata":{"title":"Twitter Artbot","date":"2017-06-11T05:00:00.000Z","category":"Art","featured":false,"draft":false,"url":"https://twitter.com/417am1975","tech":["CoffeeScript","HTML","JavaScript"],"tags":["art","bot","generative"],"words":141,"images":9,"imageDetails":{"total":9,"cloudinary":9,"withDimensions":0},"links":2,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/nbcnews-specials","title":"NBC News Specials","html":"<p class=\"\">From 2016 to 2019 I was a senior interactive graphics specialist at NBC News, on the Foundry team. We built bespoke longform features, the <a href=\"https://www.nbcnews.com/specials\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.nbcnews.com/specials\">specials<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M14.828 4.606c-1.406.006-2.567 1.208-2.416 2.785h1.08s.503-.007.649.338c0 0-1.594.612-1.467 1.609l.992 7.328l3.473-8.432c.53-1.279-.062-3.166-1.698-3.556a2.6 2.6 0 0 0-.613-.072m-5.642.002a2.6 2.6 0 0 0-.628.072c-1.635.387-2.226 2.275-1.697 3.556l3.473 8.432l1.242-9.158c.22-1.63-.956-2.888-2.39-2.903zM4.292 8.31a2.38 2.38 0 0 0-1.559.659c-.803.773-1.427 2.879.371 4.156l6.954 4.926l-3.334-8.076c-.488-1.17-1.479-1.7-2.432-1.665m15.272 0c-.908.02-1.825.553-2.29 1.664l-3.332 8.077l6.953-4.926c1.8-1.275 1.172-3.383.37-4.156a2.38 2.38 0 0 0-1.702-.659m1.939 5.788c-.452.007-.935.157-1.42.502l-6.77 4.794h8.026c1.873 0 3.065-1.582 2.533-3.484c-.26-.926-1.213-1.832-2.369-1.812M2.496 14.1c-1.155-.019-2.108.885-2.369 1.81c-.532 1.902.66 3.484 2.533 3.484h8.025L3.916 14.6c-.485-.344-.968-.493-1.42-.5\" class=\"\"></path></svg></a>, in collaboration with reporters across the newsroom. Every one was designed from scratch around its story, mixing video, data visualization and scrolling narrative.</p>\n<figure role=\"figure\" aria-label=\"Scrolling Journey of a Bullet — the bullet-path diagram, documentary stills, and longform text as readers met it\" class=\"\"><video class=\"img-full my-8 rounded-sm\" autoplay loop muted playsinline preload=\"metadata\" poster=\"https://res.cloudinary.com/ejf/video/upload/so_0,f_jpg,q_auto,w_1280/projects/nbcnews-specials/journey-scroll.jpg\" src=\"https://res.cloudinary.com/ejf/video/upload/q_auto/projects/nbcnews-specials/journey-scroll.mp4\" aria-label=\"Scrolling Journey of a Bullet — the bullet-path diagram, documentary stills, and longform text as readers met it\"></video><figcaption class=\"\">Scrolling Journey of a Bullet — the bullet-path diagram, documentary stills, and longform text as readers met it</figcaption></figure>\n<h2 class=\"\" id=\"journey-of-a-bullet-2017\">Journey of a Bullet (2017)</h2>\n<p class=\"\">The overlapping stories of six gunshot survivors, from Iraq to the streets of Chicago, coping after their injuries. <a href=\"https://www.nbcnews.com/specials/journey-of-a-bullet/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.nbcnews.com/specials/journey-of-a-bullet/\">Read it →<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M14.828 4.606c-1.406.006-2.567 1.208-2.416 2.785h1.08s.503-.007.649.338c0 0-1.594.612-1.467 1.609l.992 7.328l3.473-8.432c.53-1.279-.062-3.166-1.698-3.556a2.6 2.6 0 0 0-.613-.072m-5.642.002a2.6 2.6 0 0 0-.628.072c-1.635.387-2.226 2.275-1.697 3.556l3.473 8.432l1.242-9.158c.22-1.63-.956-2.888-2.39-2.903zM4.292 8.31a2.38 2.38 0 0 0-1.559.659c-.803.773-1.427 2.879.371 4.156l6.954 4.926l-3.334-8.076c-.488-1.17-1.479-1.7-2.432-1.665m15.272 0c-.908.02-1.825.553-2.29 1.664l-3.332 8.077l6.953-4.926c1.8-1.275 1.172-3.383.37-4.156a2.38 2.38 0 0 0-1.702-.659m1.939 5.788c-.452.007-.935.157-1.42.502l-6.77 4.794h8.026c1.873 0 3.065-1.582 2.533-3.484c-.26-.926-1.213-1.832-2.369-1.812M2.496 14.1c-1.155-.019-2.108.885-2.369 1.81c-.532 1.902.66 3.484 2.533 3.484h8.025L3.916 14.6c-.485-.344-.968-.493-1.42-.5\" class=\"\"></path></svg></a></p>\n<figure role=\"figure\" aria-label=\"The introduction to the Journey of a Bullet story\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755100741/screenshot_2025-08-13_at_11.58.49_AM.png\" alt=\"The introduction to the Journey of a Bullet story\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1755100741/screenshot_2025-08-13_at_11.58.49_AM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1755100741/screenshot_2025-08-13_at_11.58.49_AM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755100741/screenshot_2025-08-13_at_11.58.49_AM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1755100741/screenshot_2025-08-13_at_11.58.49_AM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The introduction to the Journey of a Bullet story\" width=\"1774\" height=\"2126\" style=\"--splay-rot:-0.6deg;\" data-dimensions=\"1774×2126\" class=\"img-full my-8 rounded-sm img-splay aspect-[3/4] w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The introduction to the Journey of a Bullet story</figcaption>\n</figure>\n<h2 class=\"\" id=\"post-bail-2017\">Post Bail (2017)</h2>\n<p class=\"\">America's justice system runs on exchanging money for freedom, so can data fix it? Jon Schuppe's investigation includes an interactive version of a risk-assessment algorithm, so readers can see how a score gets made. <a href=\"https://www.nbcnews.com/specials/bail-reform\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.nbcnews.com/specials/bail-reform\">Read it →<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M14.828 4.606c-1.406.006-2.567 1.208-2.416 2.785h1.08s.503-.007.649.338c0 0-1.594.612-1.467 1.609l.992 7.328l3.473-8.432c.53-1.279-.062-3.166-1.698-3.556a2.6 2.6 0 0 0-.613-.072m-5.642.002a2.6 2.6 0 0 0-.628.072c-1.635.387-2.226 2.275-1.697 3.556l3.473 8.432l1.242-9.158c.22-1.63-.956-2.888-2.39-2.903zM4.292 8.31a2.38 2.38 0 0 0-1.559.659c-.803.773-1.427 2.879.371 4.156l6.954 4.926l-3.334-8.076c-.488-1.17-1.479-1.7-2.432-1.665m15.272 0c-.908.02-1.825.553-2.29 1.664l-3.332 8.077l6.953-4.926c1.8-1.275 1.172-3.383.37-4.156a2.38 2.38 0 0 0-1.702-.659m1.939 5.788c-.452.007-.935.157-1.42.502l-6.77 4.794h8.026c1.873 0 3.065-1.582 2.533-3.484c-.26-.926-1.213-1.832-2.369-1.812M2.496 14.1c-1.155-.019-2.108.885-2.369 1.81c-.532 1.902.66 3.484 2.533 3.484h8.025L3.916 14.6c-.485-.344-.968-.493-1.42-.5\" class=\"\"></path></svg></a></p>\n<h2 class=\"\" id=\"does-america-need-another-prison-2018\">Does America Need Another Prison? (2018)</h2>\n<p class=\"\">Letcher County, Kentucky, was sustained by a coal industry that now hardly exists, and a federal prison might replace it. <a href=\"https://www.nbcnews.com/specials/kentucky-prison-coal-country\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.nbcnews.com/specials/kentucky-prison-coal-country\">Read it →<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M14.828 4.606c-1.406.006-2.567 1.208-2.416 2.785h1.08s.503-.007.649.338c0 0-1.594.612-1.467 1.609l.992 7.328l3.473-8.432c.53-1.279-.062-3.166-1.698-3.556a2.6 2.6 0 0 0-.613-.072m-5.642.002a2.6 2.6 0 0 0-.628.072c-1.635.387-2.226 2.275-1.697 3.556l3.473 8.432l1.242-9.158c.22-1.63-.956-2.888-2.39-2.903zM4.292 8.31a2.38 2.38 0 0 0-1.559.659c-.803.773-1.427 2.879.371 4.156l6.954 4.926l-3.334-8.076c-.488-1.17-1.479-1.7-2.432-1.665m15.272 0c-.908.02-1.825.553-2.29 1.664l-3.332 8.077l6.953-4.926c1.8-1.275 1.172-3.383.37-4.156a2.38 2.38 0 0 0-1.702-.659m1.939 5.788c-.452.007-.935.157-1.42.502l-6.77 4.794h8.026c1.873 0 3.065-1.582 2.533-3.484c-.26-.926-1.213-1.832-2.369-1.812M2.496 14.1c-1.155-.019-2.108.885-2.369 1.81c-.532 1.902.66 3.484 2.533 3.484h8.025L3.916 14.6c-.485-.344-.968-.493-1.42-.5\" class=\"\"></path></svg></a></p>\n<figure role=\"figure\" aria-label=\"A map comparing the location of prisons and coal mines in the southeastern United States\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755100778/screenshot_2025-08-13_at_11.59.26_AM.png\" alt=\"A map comparing the location of prisons and coal mines in the southeastern United States\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1755100778/screenshot_2025-08-13_at_11.59.26_AM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1755100778/screenshot_2025-08-13_at_11.59.26_AM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755100778/screenshot_2025-08-13_at_11.59.26_AM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1755100778/screenshot_2025-08-13_at_11.59.26_AM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A map comparing the location of prisons and coal mines in the southeastern United States\" width=\"1548\" height=\"1026\" style=\"--splay-rot:-1.61deg;\" data-dimensions=\"1548×1026\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A map comparing the location of prisons and coal mines in the southeastern United States</figcaption>\n</figure>\n<h2 class=\"\" id=\"how-trump-won-2016\">How Trump Won (2016)</h2>\n<p class=\"\">How a populist message proved everyone wrong, county by county. <a href=\"https://www.nbcnews.com/specials/donald-trump-republican-party/presidency/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.nbcnews.com/specials/donald-trump-republican-party/presidency/\">Read it →<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M14.828 4.606c-1.406.006-2.567 1.208-2.416 2.785h1.08s.503-.007.649.338c0 0-1.594.612-1.467 1.609l.992 7.328l3.473-8.432c.53-1.279-.062-3.166-1.698-3.556a2.6 2.6 0 0 0-.613-.072m-5.642.002a2.6 2.6 0 0 0-.628.072c-1.635.387-2.226 2.275-1.697 3.556l3.473 8.432l1.242-9.158c.22-1.63-.956-2.888-2.39-2.903zM4.292 8.31a2.38 2.38 0 0 0-1.559.659c-.803.773-1.427 2.879.371 4.156l6.954 4.926l-3.334-8.076c-.488-1.17-1.479-1.7-2.432-1.665m15.272 0c-.908.02-1.825.553-2.29 1.664l-3.332 8.077l6.953-4.926c1.8-1.275 1.172-3.383.37-4.156a2.38 2.38 0 0 0-1.702-.659m1.939 5.788c-.452.007-.935.157-1.42.502l-6.77 4.794h8.026c1.873 0 3.065-1.582 2.533-3.484c-.26-.926-1.213-1.832-2.369-1.812M2.496 14.1c-1.155-.019-2.108.885-2.369 1.81c-.532 1.902.66 3.484 2.533 3.484h8.025L3.916 14.6c-.485-.344-.968-.493-1.42-.5\" class=\"\"></path></svg></a></p>\n<figure role=\"figure\" aria-label=\"A table and map showing the counties that swung hardest toward Donald Trump\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755100822/screenshot_2025-08-13_at_12.00.10_PM.png\" alt=\"A table and map showing the counties that swung hardest toward Donald Trump\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1755100822/screenshot_2025-08-13_at_12.00.10_PM.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1755100822/screenshot_2025-08-13_at_12.00.10_PM.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755100822/screenshot_2025-08-13_at_12.00.10_PM.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1755100822/screenshot_2025-08-13_at_12.00.10_PM.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"A table and map showing the counties that swung hardest toward Donald Trump\" width=\"1780\" height=\"1740\" style=\"--splay-rot:2.79deg;\" data-dimensions=\"1780×1740\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">A table and map showing the counties that swung hardest toward Donald Trump</figcaption>\n</figure>\n<h2 class=\"\" id=\"and-more\">And more</h2>\n<ul class=\"list-disc\">\n<li class=\"\"><a href=\"https://www.nbcnews.com/specials/making-of-an-astronaut\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.nbcnews.com/specials/making-of-an-astronaut\">The Making of an Astronaut (2017)<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M14.828 4.606c-1.406.006-2.567 1.208-2.416 2.785h1.08s.503-.007.649.338c0 0-1.594.612-1.467 1.609l.992 7.328l3.473-8.432c.53-1.279-.062-3.166-1.698-3.556a2.6 2.6 0 0 0-.613-.072m-5.642.002a2.6 2.6 0 0 0-.628.072c-1.635.387-2.226 2.275-1.697 3.556l3.473 8.432l1.242-9.158c.22-1.63-.956-2.888-2.39-2.903zM4.292 8.31a2.38 2.38 0 0 0-1.559.659c-.803.773-1.427 2.879.371 4.156l6.954 4.926l-3.334-8.076c-.488-1.17-1.479-1.7-2.432-1.665m15.272 0c-.908.02-1.825.553-2.29 1.664l-3.332 8.077l6.953-4.926c1.8-1.275 1.172-3.383.37-4.156a2.38 2.38 0 0 0-1.702-.659m1.939 5.788c-.452.007-.935.157-1.42.502l-6.77 4.794h8.026c1.873 0 3.065-1.582 2.533-3.484c-.26-.926-1.213-1.832-2.369-1.812M2.496 14.1c-1.155-.019-2.108.885-2.369 1.81c-.532 1.902.66 3.484 2.533 3.484h8.025L3.916 14.6c-.485-.344-.968-.493-1.42-.5\" class=\"\"></path></svg></a>: a rare look inside astronaut training and spaceflight</li>\n<li class=\"\"><a href=\"https://www.nbcnews.com/specials/donald-trump-first-100-days/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.nbcnews.com/specials/donald-trump-first-100-days/\">Donald Trump's First 100 Days (2017)<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M14.828 4.606c-1.406.006-2.567 1.208-2.416 2.785h1.08s.503-.007.649.338c0 0-1.594.612-1.467 1.609l.992 7.328l3.473-8.432c.53-1.279-.062-3.166-1.698-3.556a2.6 2.6 0 0 0-.613-.072m-5.642.002a2.6 2.6 0 0 0-.628.072c-1.635.387-2.226 2.275-1.697 3.556l3.473 8.432l1.242-9.158c.22-1.63-.956-2.888-2.39-2.903zM4.292 8.31a2.38 2.38 0 0 0-1.559.659c-.803.773-1.427 2.879.371 4.156l6.954 4.926l-3.334-8.076c-.488-1.17-1.479-1.7-2.432-1.665m15.272 0c-.908.02-1.825.553-2.29 1.664l-3.332 8.077l6.953-4.926c1.8-1.275 1.172-3.383.37-4.156a2.38 2.38 0 0 0-1.702-.659m1.939 5.788c-.452.007-.935.157-1.42.502l-6.77 4.794h8.026c1.873 0 3.065-1.582 2.533-3.484c-.26-.926-1.213-1.832-2.369-1.812M2.496 14.1c-1.155-.019-2.108.885-2.369 1.81c-.532 1.902.66 3.484 2.533 3.484h8.025L3.916 14.6c-.485-.344-.968-.493-1.42-.5\" class=\"\"></path></svg></a>: historians, activists and White House veterans grade the first major benchmark</li>\n</ul>","metadata":{"title":"NBC News Specials","date":"2016-12-14T20:01:50.000Z","client":"NBC News","category":"Journalism","featured":false,"modified":"2025-08-13T16:01:12.000Z","url":"https://nbcnews.com/specials","state":"deployed","ai-involvement":"human-only","tech":["JavaScript","D3","Longform Design"],"context":"client","tags":["dataviz","journalism","webdesign","elections"],"words":255,"images":4,"imageDetails":{"total":4,"cloudinary":4,"withDimensions":0},"links":7,"codeBlocks":0,"headers":{"h2":5},"toc":[{"text":"Journey of a Bullet (2017)","slug":"journey-of-a-bullet-2017","level":"h2","children":[]},{"text":"Post Bail (2017)","slug":"post-bail-2017","level":"h2","children":[]},{"text":"Does America Need Another Prison? (2018)","slug":"does-america-need-another-prison-2018","level":"h2","children":[]},{"text":"How Trump Won (2016)","slug":"how-trump-won-2016","level":"h2","children":[]},{"text":"And more","slug":"and-more","level":"h2","children":[]}],"type":"post"}},{"slug":"projects/nbc-big-board","title":"NBC News Big Board","html":"<p class=\"\">It started as a prototype I made in January — a touchscreen way to explore county-level election results — that I demoed to Adam, then to Steve, then to Marc, until it got the budget and became the real thing: the \"Big Board\" Steve Kornacki wields on air, live, in front of ten to nineteen million people.</p>\n<p class=\"\">Building something that gets <em class=\"\">performed</em> live is the most stressful thing I've ever done — no room for error, and you hand it off to someone else on a screen you don't control. In the weeks before an election, Adam (Kornacki's producer) was texting and calling me at all hours as he did hits on Maddow and Morning Joe, and I got used to adding new scorecard filters at a moment's notice.</p>\n<p class=\"\">The workflow on election night, from my couch: edit some JSON, VPN into the NBC network, SSH into the server, <code class=\"md-inline-code\">git pull</code> from master, <code class=\"md-inline-code\">pm2 restart all</code>, text the producer that it's updated — then watch my change go out on air a few minutes later through Hulu. Doing that live in an emergency and seeing it on television minutes later is a pretty cool feeling.</p>\n<p class=\"\">Broadcast runs on radio frequencies, mostly silent when things go well, and you can hear things breaking all over — \"the 3A wall went to black,\" \"the rink graphics aren't loading.\" You keep your ears perked for your own name. You hope to god you don't hear it.</p>\n<p class=\"\">By the end it worked. A coworker told me Chuck Todd's producer said it was the first election night Chuck wasn't frustrated with the app. Steve looked amazing with it — panning, zooming, pulling up the scoreboard and the historical drawers we'd added, using motions and ways of storytelling I'd worked out months earlier, playing on TV exactly how I'd wanted them to.</p>\n<p class=\"\">The whole story — the 1AM bacon pancakes, the Montana freeze, what I learned about leading a team — is here: <strong class=\"\"><a href=\"/blog/2022/election-night-2018\" class=\"text-blue-600 dark:text-blue-400\">Election Night 2018 →</a></strong></p>\n<figure role=\"figure\" aria-label=\"The Big Board UI: 2016 Pennsylvania presidential results beside an interactive county map\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1666630396/project-images/nbc_bigboard.png\" alt=\"The Big Board UI: 2016 Pennsylvania presidential results beside an interactive county map\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1666630396/project-images/nbc_bigboard.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1666630396/project-images/nbc_bigboard.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1666630396/project-images/nbc_bigboard.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1666630396/project-images/nbc_bigboard.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The Big Board UI: 2016 Pennsylvania presidential results beside an interactive county map\" width=\"5000\" height=\"2625\" style=\"--splay-rot:-2.63deg;\" data-dimensions=\"5000×2625\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The Big Board UI: 2016 Pennsylvania presidential results beside an interactive county map</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://www.youtube.com/watch?v=p4KIMQsVkt8\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.youtube.com/watch?v=p4KIMQsVkt8\">2016 NBC News County-Level Election Maps<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M23.498 6.186a3.02 3.02 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.02 3.02 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.02 3.02 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.02 3.02 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814M9.545 15.568V8.432L15.818 12z\" class=\"\"></path></svg></a></p>\n<figure role=\"figure\" aria-label=\"Planning board — printed election-graphic studies and notes pinned to the wall\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755101296/IMG_5294_dpmmkr.jpg\" alt=\"Planning board — printed election-graphic studies and notes pinned to the wall\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1755101296/IMG_5294_dpmmkr.jpg 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1755101296/IMG_5294_dpmmkr.jpg 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755101296/IMG_5294_dpmmkr.jpg 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1755101296/IMG_5294_dpmmkr.jpg 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Planning board — printed election-graphic studies and notes pinned to the wall\" width=\"3024\" height=\"4032\" style=\"--splay-rot:2.05deg;\" data-dimensions=\"3024×4032\" class=\"img-full my-8 rounded-sm img-splay aspect-[3/4] w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Planning board — printed election-graphic studies and notes pinned to the wall</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Broadcast setup — the Big Board on a control-room monitor beside a waveform scope\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755101273/IMG_6264_ubgaak.jpg\" alt=\"Broadcast setup — the Big Board on a control-room monitor beside a waveform scope\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1755101273/IMG_6264_ubgaak.jpg 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1755101273/IMG_6264_ubgaak.jpg 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755101273/IMG_6264_ubgaak.jpg 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1755101273/IMG_6264_ubgaak.jpg 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Broadcast setup — the Big Board on a control-room monitor beside a waveform scope\" width=\"4032\" height=\"3024\" style=\"--splay-rot:2.95deg;\" data-dimensions=\"4032×3024\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Broadcast setup — the Big Board on a control-room monitor beside a waveform scope</figcaption>\n</figure>\n<figure role=\"figure\" aria-label=\"Printed design studies for the on-air election graphics, spread across a desk\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755101285/IMG_5444_mn2u7z.jpg\" alt=\"Printed design studies for the on-air election graphics, spread across a desk\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/v1755101285/IMG_5444_mn2u7z.jpg 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/v1755101285/IMG_5444_mn2u7z.jpg 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/v1755101285/IMG_5444_mn2u7z.jpg 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/v1755101285/IMG_5444_mn2u7z.jpg 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"Printed design studies for the on-air election graphics, spread across a desk\" width=\"3024\" height=\"3024\" style=\"--splay-rot:-2.49deg;\" data-dimensions=\"3024×3024\" class=\"img-full my-8 rounded-sm img-splay aspect-square w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">Printed design studies for the on-air election graphics, spread across a desk</figcaption>\n</figure>\n<p class=\"\"><a href=\"https://www.newscaststudio.com/2018/11/05/nbc-big-board-update/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://www.newscaststudio.com/2018/11/05/nbc-big-board-update/\">NewscastStudio wrote up a later update</a>.</p>","metadata":{"title":"NBC News Big Board","date":"2016-11-08T05:00:00.000Z","client":"NBC News","category":"Journalism","modified":"2025-08-13T16:24:29.000Z","tech":["D3.js","JavaScript","Touch Interface","Election Data"],"featured":true,"state":"deployed","ai-involvement":"human-only","context":"client","tags":["d3","dataviz","javascript","elections"],"words":394,"images":4,"imageDetails":{"total":4,"cloudinary":4,"withDimensions":0},"links":3,"codeBlocks":0,"headers":{},"toc":[],"type":"post"}},{"slug":"projects/dataproofer","title":"Dataproofer","html":"<p class=\"\">Along with Gerald Rich and the Vocativ data team, I received a <a href=\"https://knightfoundation.org/articles/20-ideas-receive-support-knight-prototype-fund-media-and-information-projects/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://knightfoundation.org/articles/20-ideas-receive-support-knight-prototype-fund-media-and-information-projects/\">Knight Prototype Fund</a> grant to create <a href=\"https://github.com/dataproofer/Dataproofer\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external-link group inline-flex items-center text-blue-600 dark:text-blue-400\" data-preview-url=\"https://github.com/dataproofer/Dataproofer\">Dataproofer<svg class=\"inline-block w-4 h-4 ml-1 opacity-50 dark:opacity-75 group-hover:opacity-100 transition-opacity align-text-bottom\" xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" class=\"\"></path></svg></a> — an open source tool to check data for reliability, missing data, and outliers. Built as an Electron desktop app for journalists.</p>\n<figure role=\"figure\" aria-label=\"Scrolling dataproofer.org — the pitch, downloads for every platform, and the Knight Foundation / Vocativ credits\" class=\"\"><video class=\"img-full my-8 rounded-sm\" autoplay loop muted playsinline preload=\"metadata\" poster=\"https://res.cloudinary.com/ejf/video/upload/so_0,f_jpg,q_auto,w_1280/projects/dataproofer/site-scroll.jpg\" src=\"https://res.cloudinary.com/ejf/video/upload/q_auto/projects/dataproofer/site-scroll.mp4\" aria-label=\"Scrolling dataproofer.org — the pitch, downloads for every platform, and the Knight Foundation / Vocativ credits\"></video><figcaption class=\"\">Scrolling dataproofer.org — the pitch, downloads for every platform, and the Knight Foundation / Vocativ credits</figcaption></figure>\n<figure role=\"figure\" aria-label=\"The Dataproofer site — a spellchecker, for your data\" class=\"\">\n<img src=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/dataproofer/landing.png\" alt=\"The Dataproofer site — a spellchecker, for your data\" loading=\"lazy\" decoding=\"async\" srcset=\"https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_640/projects/dataproofer/landing.png 640w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_960/projects/dataproofer/landing.png 960w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1280/projects/dataproofer/landing.png 1280w, https://res.cloudinary.com/ejf/image/upload/c_scale,f_auto,q_auto:good,w_1920/projects/dataproofer/landing.png 1920w\" sizes=\"(max-width: 430px) 92vw, (max-width: 768px) 85vw, 900px\" title=\"The Dataproofer site — a spellchecker, for your data\" width=\"1440\" height=\"1651\" style=\"--splay-rot:-2.56deg;\" data-dimensions=\"1440×1651\" class=\"img-full my-8 rounded-sm img-splay w-full mx-auto py-4\" crossorigin=\"anonymous\">\n<figcaption class=\"\">The Dataproofer site — a spellchecker, for your data</figcaption>\n</figure>\n<figure data-rehype-pretty-code-figure=\"\" class=\"\"><pre class=\"md-pre\" style=\"background-color:#09090b;color:#cccac2\" tabindex=\"0\" data-language=\"bash\" data-theme=\"\"><code data-language=\"bash\" data-theme=\"\" style=\"display: grid;\" class=\"md-codeblock\"><span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">❯</span><span style=\"color:#D5FF80\" class=\"\"> dataproofer</span><span style=\"color:#D5FF80\" class=\"\"> Criterion</span><span style=\"color:#95E6CB\" class=\"\">\\ </span><span style=\"color:#D5FF80\" class=\"\">Channel</span><span style=\"color:#95E6CB\" class=\"\">\\ </span><span style=\"color:#D5FF80\" class=\"\">videos</span><span style=\"color:#95E6CB\" class=\"\">\\ </span><span style=\"color:#D5FF80\" class=\"\">v1.2.0-20190621</span><span style=\"color:#95E6CB\" class=\"\">\\ </span><span style=\"color:#D5FF80\" class=\"\">-</span><span style=\"color:#95E6CB\" class=\"\">\\ </span><span style=\"color:#D5FF80\" class=\"\">v1.2.0-20190621.csv</span></span>\n<span class=\"\" data-line=\"\"> </span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">total</span><span style=\"color:#D5FF80\" class=\"\"> rows</span><span style=\"color:#DFBFFF\" class=\"\"> 5035</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">rows</span><span style=\"color:#D5FF80\" class=\"\"> sampled</span><span style=\"color:#DFBFFF\" class=\"\"> 1259</span></span>\n<span class=\"\" data-line=\"\"> </span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Missing</span><span style=\"color:#D5FF80\" class=\"\"> or</span><span style=\"color:#D5FF80\" class=\"\"> duplicate</span><span style=\"color:#D5FF80\" class=\"\"> column</span><span style=\"color:#D5FF80\" class=\"\"> headers:</span><span style=\"color:#D5FF80\" class=\"\"> passed</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Numeric</span><span style=\"color:#D5FF80\" class=\"\"> cells:</span><span style=\"color:#D5FF80\" class=\"\"> info</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">String</span><span style=\"color:#D5FF80\" class=\"\"> cells:</span><span style=\"color:#D5FF80\" class=\"\"> info</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Empty</span><span style=\"color:#D5FF80\" class=\"\"> Cells:</span><span style=\"color:#D5FF80\" class=\"\"> warn</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Special</span><span style=\"color:#D5FF80\" class=\"\"> Letters</span><span style=\"color:#CCCAC2B3\" class=\"\"> &#x26;</span><span style=\"color:#73D0FF\" class=\"\"> Characters:</span><span style=\"color:#D5FF80\" class=\"\"> warn</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Duplicate</span><span style=\"color:#D5FF80\" class=\"\"> Rows:</span><span style=\"color:#D5FF80\" class=\"\"> passed</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Potentially</span><span style=\"color:#D5FF80\" class=\"\"> missing</span><span style=\"color:#D5FF80\" class=\"\"> rows:</span><span style=\"color:#D5FF80\" class=\"\"> passed</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Words</span><span style=\"color:#D5FF80\" class=\"\"> at</span><span style=\"color:#D5FF80\" class=\"\"> their</span><span style=\"color:#D5FF80\" class=\"\"> character</span><span style=\"color:#D5FF80\" class=\"\"> limit:</span><span style=\"color:#D5FF80\" class=\"\"> failed</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Integer</span><span style=\"color:#D5FF80\" class=\"\"> at</span><span style=\"color:#D5FF80\" class=\"\"> its</span><span style=\"color:#D5FF80\" class=\"\"> SQL</span><span style=\"color:#D5FF80\" class=\"\"> upper</span><span style=\"color:#D5FF80\" class=\"\"> limit:</span><span style=\"color:#D5FF80\" class=\"\"> passed</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Summed</span><span style=\"color:#D5FF80\" class=\"\"> integer</span><span style=\"color:#D5FF80\" class=\"\"> at</span><span style=\"color:#D5FF80\" class=\"\"> its</span><span style=\"color:#D5FF80\" class=\"\"> upper</span><span style=\"color:#D5FF80\" class=\"\"> limit:</span><span style=\"color:#D5FF80\" class=\"\"> passed</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Small</span><span style=\"color:#D5FF80\" class=\"\"> integer</span><span style=\"color:#D5FF80\" class=\"\"> at</span><span style=\"color:#D5FF80\" class=\"\"> its</span><span style=\"color:#D5FF80\" class=\"\"> SQL</span><span style=\"color:#D5FF80\" class=\"\"> upper</span><span style=\"color:#D5FF80\" class=\"\"> limit:</span><span style=\"color:#D5FF80\" class=\"\"> passed</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Big</span><span style=\"color:#D5FF80\" class=\"\"> integer</span><span style=\"color:#D5FF80\" class=\"\"> at</span><span style=\"color:#D5FF80\" class=\"\"> its</span><span style=\"color:#D5FF80\" class=\"\"> SQL</span><span style=\"color:#D5FF80\" class=\"\"> upper</span><span style=\"color:#D5FF80\" class=\"\"> limit:</span><span style=\"color:#D5FF80\" class=\"\"> passed</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Outliers</span><span style=\"color:#D5FF80\" class=\"\"> from</span><span style=\"color:#D5FF80\" class=\"\"> the</span><span style=\"color:#D5FF80\" class=\"\"> mean:</span><span style=\"color:#D5FF80\" class=\"\"> info</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Outliers</span><span style=\"color:#D5FF80\" class=\"\"> from</span><span style=\"color:#D5FF80\" class=\"\"> the</span><span style=\"color:#D5FF80\" class=\"\"> median:</span><span style=\"color:#D5FF80\" class=\"\"> info</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Invalid</span><span style=\"color:#D5FF80\" class=\"\"> coordinates:</span><span style=\"color:#D5FF80\" class=\"\"> passed</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">Void</span><span style=\"color:#D5FF80\" class=\"\"> coordinates:</span><span style=\"color:#D5FF80\" class=\"\"> passed</span></span>\n<span class=\"\" data-line=\"\"> </span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">75%</span></span>\n<span class=\"\" data-line=\"\"><span style=\"color:#73D0FF\" class=\"\">9</span><span style=\"color:#D5FF80\" class=\"\"> tests</span><span style=\"color:#D5FF80\" class=\"\"> passed</span><span style=\"color:#D5FF80\" class=\"\"> out</span><span style=\"color:#D5FF80\" class=\"\"> of</span><span style=\"color:#DFBFFF\" class=\"\"> 12</span></span>\n<span class=\"\" data-line=\"\"> </span>\n<span class=\"\" data-line=\"\"><span style=\"color:#B8CFE680;font-style:italic\" class=\"\">### PROOFED ###</span></span><button type=\"button\" title=\"Copy code\" aria-label=\"Copy code\" data=\"❯ dataproofer Criterion\\ Channel\\ videos\\ v1.2.0-20190621\\ -\\ v1.2.0-20190621.csv\n\ntotal rows 5035\nrows sampled 1259\n\nMissing or duplicate column headers: passed\nNumeric cells: info\nString cells: info\nEmpty Cells: warn\nSpecial Letters &#x26; Characters: warn\nDuplicate Rows: passed\nPotentially missing rows: passed\nWords at their character limit: failed\nInteger at its SQL upper limit: passed\nSummed integer at its upper limit: passed\nSmall integer at its SQL upper limit: passed\nBig integer at its SQL upper limit: passed\nOutliers from the mean: info\nOutliers from the median: info\nInvalid coordinates: passed\nVoid coordinates: passed\n\n75%\n9 tests passed out of 12\n\n### PROOFED ###\" class=\"rehype-pretty-copy\" onclick=\"navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);\"><span class=\"ready\"></span><span class=\"success\"></span></button><style class=\"\">:root {--copy-icon: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E\");--success-icon: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E\");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>","metadata":{"title":"Dataproofer","date":"2015-11-03T05:00:00.000Z","category":"Journalism","modified":"2025-08-26T19:53:33.000Z","url":"https://github.com/dataproofer/Dataproofer","tech":["JavaScript","Electron","Data Quality","CSV"],"featured":false,"state":"deployed","ai-involvement":"human-only","context":"collaborative","tags":["data","javascript","tool","journalism"],"words":171,"images":2,"imageDetails":{"total":2,"cloudinary":2,"withDimensions":0},"links":2,"codeBlocks":1,"headers":{},"toc":[],"type":"post"}}],"_links":{"self":"https://ejfox.com/projects.json","html":"https://ejfox.com/projects"}}