A few months ago I noticed something annoying about how I actually use focus timers: I'd start a 25-minute session, and within a couple of minutes I was checking the countdown more than I was working. The number itself had become a little compulsion — not the work, the clock.
So I built Embralynn on a deliberately restrictive bet: what if the app told you nothing numeric at all? No countdown, no percentage, no session count, nowhere in the UI. Just a small pixel fire that grows while you're genuinely focused, settles to embers if you take a break, and fades if you wander off. That's the entire feedback loop.
Here's what I learned building it — the technical decisions that turned out to matter more than I expected.
Tauri over Electron, because idle is the whole product
Embralynn's job is to sit in the corner of your screen indefinitely, doing basically nothing most of the time. That makes idle footprint the actual product requirement, not a nice-to-have.
Electron ships a full Chromium + Node process per app — realistically 150-400MB of RAM for something that's supposed to just glow quietly. That's not a rounding error for an "ambient" widget; it's the difference between "install and forget" and "why is this thing eating my battery."
Tauri v2 wraps a Rust backend around the system WebView (WKWebView on macOS), so the bundle is a few MB of Rust plus web assets instead of a bundled browser. That gets idle RAM comfortably under 80MB. The whole design system — the fire, the tokens, the flicker/spark/crackle interactions — was already built as React + CSS, so Tauri's web-based frontend meant reusing it almost unchanged. Rewriting all of that natively in Swift would have meant redoing every animation from scratch for a footprint win we'd get for free with Tauri anyway.
The state machine: six states, and the two that matter most
The fire isn't just "on" or "off." There are six states — idle, low, medium, full, smolder, dying — and the two design decisions I'm most glad I made are the ones that aren't obvious from a state diagram:
Reversible dying. If you walk away and the fire dies from inactivity, your accumulated focus isn't just gone. The embers stay warm for a short window — relighting within that window resumes the stage you were at instead of starting over from idle. Stepping away to think for two minutes shouldn't torch a 40-minute deep-work session. Only after the ember window closes does the focus bank actually reset.
Smolder isn't eternal. Early on, a "break" (smolder) could sit there indefinitely. But a break that runs an hour isn't a break — it's abandonment wearing a break's clothes. So smolder now self-cools to idle after a longer timeout and releases the focus bank. The fire is honest about the difference between "I stepped away for coffee" and "I stopped working."
Neither of these shows up as a number anywhere. They're just... how the fire behaves. That was the actual design challenge — encoding nuance into behavior instead of into a label.
Procedural pixel art, not sprite sheets
The fire isn't a set of pre-rendered PNG frames flipping through a loop. It's CSS pixel art generated and animated in code, driven by a continuous signal from the Rust backend rather than snapping between a fixed set of frames.
Practically, this means the Rust side streams a small snapshot every second — state, intensity (how deep into a stage you are), an idle-drift fraction, and whether the embers are still warm — and the frontend renders continuously off of that instead of just switching sprite sheets on state change. The fire can lean and dim slightly as you approach an idle timeout — a quiet "you still there?" — without needing a seventh discrete state for it. It also means the landing page's live interactive demo and the actual app are pixel-identical, because they're running the same rendering code, not a marketing mockup next to a different runtime implementation.
The tradeoff is that "just swap the image" is no longer an option if you want to change how the fire looks — you're touching animation code, not asset files. For a project where the fire is the entire product, that tradeoff was worth it.
One shared 8fps heartbeat instead of free-running CSS animations
This is the detail I didn't expect to matter as much as it did: all the continuous "life" in the fire — flicker, glow, sparks, ember pops — samples off a single shared heartbeat timer running at 8fps, instead of each element running its own free-running CSS animation.
8fps is a classic pixel-art-game frame rate — smooth enough to read as alive, slow enough that the browser's compositor only wakes up 8 times a second instead of 60-120. Measured effect: roughly a third of the CPU of letting everything animate freely, for a burning fire. And when there's genuinely nothing to animate — a cold idle fire with no subscribers, or the widget hidden in another Space — the heartbeat stops entirely rather than idling. document.getAnimations() reports zero at rest, on purpose.
The one-shot transitions (a stage surging up, embers fading into smolder, a last wisp of smoke on dying) stay as regular CSS animations — they fire once and stop, so there's no ongoing cost to amortize.
Idle detection lives in Rust, not a JS timer
The dying transition is driven by OS-level idle time (seconds since the last mouse/keyboard input), read on the Rust side, not a setTimeout in the webview. That matters because a JS timer in a backgrounded or throttled webview isn't reliable — the whole point of idle detection is that it has to keep working exactly when the frontend might not be. The state machine itself is unit-tested in Rust (stage growth, focus banking on break/idle, the warm-ember window, smolder cooldown), which is a nice side effect of keeping the logic out of the UI layer entirely.
What I'd want feedback on
The bet underneath all of this is that seeing progress (a fire growing) reads differently than reading it (a number ticking down) — calmer, less compulsively checkable. I think it does, for me. I'm genuinely not sure it's not just a stylistic preference dressed up as a UX insight.
If you've built something in the menu-bar-utility or ambient-focus-tool space — or you've just used enough Pomodoro apps to have opinions — I'd love to hear whether removing the number entirely reads as calming or just as missing information to you. That's the actual open question I don't have a confident answer to yet.
Top comments (0)