Tooltips, Error Dots, and the F8 Profiler
This is the third in the polish triptych. First 60 seconds handled onboarding. Wires that help you handled connections. This one handles the three things you look at while you’re working: tooltips, error state, and the frame budget.
All three were in the onboarding audit as separate findings. All three were individually small. All three landed in one session because they’re the same pattern: the information was already there; it just wasn’t getting to the user.
Tooltips with defaults and ranges
Hovering a pin used to give you the pin name and its type:
radius: Number
Which is true, useful, and insufficient. What’s a radius supposed to be? 1? 100? 1000? Is there a unit? Is there a minimum above which nothing draws? None of that information was in the tooltip. It was all in the code — PinDef::default, PinDef::min, PinDef::max — but the tooltip wasn’t looking.
Now the tooltip renders three lines when it has them:
radius: Number
range 0..500
default 50
The rules:
- Default shows unless the default is “zero-shaped” — a
Numberof 0.0, aVec2of[0, 0], an emptyColor. Zero-shaped defaults are the most common, and telling the user “the default is 0” every time is visual noise. I’d rather hide it when it’s a no-op and show it when it’s meaningful. - Range shows when min and max are both set. Partially bounded ranges (
min 0, no max) render asmin 0. Unbounded pins get nothing. - Colors render as
#RRGGBBAArather than the rawColor(0.5, 0.2, 0.8, 1.0)Rust form. This is the only format most users will read without stopping. - Outputs get no default or range lines. Outputs don’t have inputs to default to, and ranges on outputs are meaningless.
Ten tests cover the formatting rules: integer defaults, fractional defaults, color defaults, zero suppression for each type, and every combination of min/max.
The third line I wanted to ship — “suggested source nodes: LFO, Counter, Number, etc.” — didn’t make this commit. It needs a cross-registry lookup: given an input’s pin type, find every node in the registry that has a matching output. The lookup is cheap; the hard part is presenting a reasonable list without overwhelming the tooltip. I’ll come back to this when I have a stronger opinion about what “reasonable” means.
Red error dots
Here was an embarrassing little thing: the theme module defined a BORDER_ERROR colour. It was red. It was completely unused. A node that panicked during process() gave zero visual indication — the graph engine logged the panic and kept running (see the zero-allocation post), but the canvas kept drawing the failed node as if it were fine.
Now the evaluator tracks which nodes errored. EvalState gains an errored_nodes: HashSet<NodeId> that clears at the top of every evaluate() and gets populated by both catch_unwind sites (the auto-spread path and the single-iteration path). After the frame’s evaluation, the canvas reads the set and uses it to paint:
- A small red dot in the top-right corner of the node’s rectangle.
- A tinted red border on the node’s rounded rectangle (using
BORDER_ERROR— finally).
Hover the red dot and the tooltip shows the panic message, if one was captured. If the panic message is noisy or unhelpful (which they often are), the tooltip at least tells you that the node errored, which is better than silence.
The set clears every frame. A transient panic on one frame that recovers the next lights the error dot for one frame and then clears. A persistent error (a node that panics on the same input every frame) keeps the dot lit. A real installation would benefit from a longer hysteresis — “lit for N frames after last error” — but that’s the kind of tweak I’d add when someone reports that their real patch has flickering error dots. For now: one-frame semantics.
This matters because before, debugging a misbehaving patch meant either watching the log output in real time or carefully inspecting each node to see which one looked wrong. Now you glance at the canvas, spot the red dots, fix those nodes. Several minutes of “why doesn’t this work” becomes a few seconds of “that one, obviously.”
F8 profiler HUD
The frame budget used to be vibes. I’d watch the cubes demo, eyeball whether it felt smooth, and call it a day. Sometimes I’d run cargo bench to see a number, but the benches are isolated; they don’t tell you what’s happening in the actual app while you’re running it.
That ends with F8. Press F8, an overlay appears in the top-right corner showing:
- FPS — current frame rate, rolling average over the last 60 frames.
- Budget headroom — how close to the 120 FPS budget of 8.33 ms each frame is running. Green if comfortable, amber if within 20% of the budget, red if over.
- Per-subsystem CPU ms — evaluate, texop, egui, render, other. Tells you exactly which part of the frame is slow.
- P50 / P95 / P99 — percentiles over a 240-frame ring (2 seconds at 120 FPS). Because the worst frames matter more than the average.
- Sparkline — last 60 frames of total frame time as a 220×40 strip, colored green/amber/red by the worst-recent-frame. At a glance you can see “this was smooth, then it spiked.”
The spans are cheap. SpanTimer is an explicit-finish timer — you call .finish() when the span ends, no RAII guard. This avoids the case where a timer drops in some unexpected branch and reports a duration that doesn’t correspond to the code I meant to measure. Explicit is boring and reliable.
Four spans cover the hot path: evaluate() for the graph eval phase, texture_engine.execute() for the GPU texture ops, run_egui_frame() for the UI layer, and the render/present block for the wgpu submit. Anything else — startup, welcome modal, background tasks — doesn’t count against the frame budget.
GPU timings aren’t in the HUD yet. wgpu has a TIMESTAMP_QUERY feature that would let me resolve actual GPU-side timings into the ring, but the plumbing is non-trivial (queries resolve asynchronously; they’d route through the existing ReadbackRing from the async readback post). The HUD has a slot reserved for gpu and shows a placeholder value. I’ll fill it in when the plumbing lands.
Tripwire logging
One piece of the profiler that’s more important than the HUD itself: the tripwire. Any single frame that exceeds 1.5× the budget (so 12.5 ms at the 8.33 ms / 120 FPS budget) emits a log::warn with the per-subsystem breakdown:
2026-07-14T10:12:34Z WARN lux_app::profiler: frame 5241 exceeded budget:
total=14.2ms eval=1.1ms texop=0.3ms egui=2.1ms render=10.5ms
dirty=3/847
Rate-limited to one warning per 500 ms so a storm of bad frames doesn’t drown the log. The breakdown tells you exactly where the time went — 10.5 ms in render means the GPU is the bottleneck; 5 ms in eval means the graph got huge; 5 ms in egui means something in the editor is doing too much work per frame.
The tripwire is the thing that caught several real regressions before they shipped. The one that caught the readback stall from the previous post was a tripwire that started firing as soon as I added a TextureToLayer to a demo patch. Without it, I’d have shrugged, said “feels fine”, and never measured.
Search indexes summaries and tags
One smaller change that rounds out the set. The fuzzy search (both the Create dialog and Search Everywhere) used to match only against the node’s type_name. So typing “plus” wouldn’t find Add, even though Add’s summary says “Add two values together” and its tags include “plus”.
Now the search indexes the type name, the summary, and every tag. Matches are scored with a small bonus for type-name hits (you probably want Add when you type “ad”, not Subtract with its tag “remove”), but a clear hit in summary or tags still ranks highly. “Plus” finds Add. “Bounce” finds Spring. “Blur” finds Blur, LumaBlur, and SceneBloom (whose description mentions blur).
The documentation infrastructure already had all this data — summary, description, tags, see_also — I just wasn’t using it in the scorer. Fifty lines of code. Dramatic discoverability improvement.
What these have in common
Each of these was a single commit. Each one closes a finding from an “onboarding audit” I’d been accumulating in a doc for weeks. The common pattern across all four: the information was already there in the data model; the UI just wasn’t surfacing it.
Pin defaults and ranges were in PinDef for months. Error state was implicit in the catch_unwind handlers from zero-allocation eval. Frame time was being measured by Instant::now in five different places in the code but never aggregated. Search tags were in every node’s doc struct but never indexed. All of it was sitting there, unused.
Which is a reminder I’m going to write down for myself: before adding new features, check what the existing features would do if you actually exposed them. The UI is the narrowest part of most apps, and there’s usually more behind the glass than the glass lets through.
That closes out the 0.10.0 polish batch. Next post starts the biggest structural change in Lux’s history: the graph engine rewrite. Fair warning, this next block is nine posts long.
I have no idea what I’m doing or if any of this is right, but it’s fun. Follow along.