{"$schema":"https://ejfox.com/schema/page-twin@1.json","kind":"project","url":"https://ejfox.com/projects/pixel-canvas","json_url":"https://ejfox.com/projects/pixel-canvas.json","generator":"ejfox.com/json-twin@1","data":{"cacheVersion":"2026-05-13-gear-cards","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>","title":"Pixel Canvas","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":532,"images":13,"imageDetails":{"total":13,"cloudinary":13,"withDimensions":0},"links":13,"codeBlocks":0,"headers":{"h2":1},"toc":[{"text":"How it works","slug":"how-it-works","level":"h2","children":[]}],"type":"post"}},"_links":{"self":"https://ejfox.com/projects/pixel-canvas.json","html":"https://ejfox.com/projects/pixel-canvas","index":"/projects.json"}}