Find your way around the workspace for writing, hearing and shaping a song, from Source and the notebook to the mixer, Rack and timeline.
The first finishing batch adds calibrated Mixer strips, a shared Notebook datum and refined Bay sockets/cables. Its as-built contract is Read Studio's visual feedback; the remaining screen treatments are tracked in Read Studio's visual feedback.
What it is#
A set of cross-surface rules the native workbench (volta-studio, and the same volta-ui crate compiled to wasm) applies to every panel: how big interactive targets are, which text carries state versus decoration, how much energy the reactive backdrop gets, whether a panel may shrink its content or must scroll, and what a passive monitoring surface is allowed to claim. They are contracts rather than styling — most are covered by tests in ui/tests/ and ui/src/theme.rs.
The shell also has one information architecture across desktop and browser. Work selects the authoring mode (Source or Bench), Context selects the sound/library/guide surface beside or instead of it, and Show controls independent tools. Source and Timeline can therefore both be active without being competing tabs: Source owns the work buffer while Timeline projects its compiled sound.
Body composition is destination-aware rather than one universal three-column shell:
| Width / destination | Composition |
|---|---|
| ≥1440 px, a song with declarations, companion sound view | Instrument — song outline + Source + context. |
| 900–1439 px, or a focused editor companion | Split — Source + context; the song outline yields its width. |
Any width with split source on (⌘\) | Source beside Source — the context slot hosts a second source pane with its own tab strip and focus; the song outline is untouched at Instrument width. Below 900 px it collapses into the existing tab switch, both panes' state intact. |
| Song chooser | Bounded modal over the current work; explicit expansion. |
| Sounds, ≥760 px | Resizable side browser beside Source or Bench; Samples, Patches, Modules and Models share one frame. Below 760 px or explicitly expanded, Sounds owns the body. |
| Mixer | Resizable bottom dock, starting at 334 px (limited by window height), with explicit expansion. Sounds and Mixer can coexist. |
| Reading/discovery destination | Context canvas — Reference, Discover, and the Learn course map own the useful canvas. |
| <900 px | Source only or Context only, preserving both states while the selected surface owns the canvas. |
The composition thresholds use the width left after resource docks reserve space. A short or narrow combination uses one readable work/context surface; select Source or a context to switch. Selecting a destination hides Sounds when it owns the compact canvas, and docks an expanded Mixer. Docked tools otherwise retain their state. Rack painting stays inside its allocated canvas.
Sounds keeps its title, tabs and close control above the browser. The result list has its own viewport. The selected item's main actions stay above a separate scrollable description; compact layouts place details alongside the list when space permits, and below it otherwise. Sample actions use separate rows for Preview/Add sampler track, selected-track replacement, and Insert/Favorite. Add sampler track is primary until a track is selected, when Replace selected instrument becomes primary. Labels stay whole when the dock narrows; unavailable sample actions remain disabled.
Floating tools and task forms use an opaque, bounded frame, a left-aligned heading and a fixed close control. Escape closes the top window and restores focus to its opener; an open menu takes Escape first. Recovery requires an explicit choice. Closing Export or Autosample leaves an active job running. Save As focuses its name field once, supports Tab, and offers Cancel and Save. Primary actions are filled; secondary and destructive actions have explicit roles rather than deriving their styling from their labels.
Bench moves secondary display preferences into Tools below 900 px. Bay Mix opens at readable scale with channel cards wrapped across the available width; panning reaches additional rows. Ordered prose lists preserve their starting number and continue across blank lines between items.
The Library surfaces use proportional interface text, quiet outlined secondary actions, and a filled primary action. The chooser separates its header, location navigation, result list, and selected-song footer. Resource rows carry an icon, name and useful metadata; Sounds gives the selected resource a distinct detail surface. Mixer has one card per channel under a track-colour cap and an anchored Master. The Signal light/dark palette uses the September 8 design study's softer surfaces and muted ink. Other theme families retain their colors. Evidence: Library Visual Finish 2026 09 08.
Source-beside-Source (Songs and files Stage 3) settled the spec's open question in favour of the context surface rather than the macro rail, because a compare wants the rail and the transport where they were. There is one EditorUi per file, so the two panes may never show the same tab: the strip greys the tab the other pane holds and a collision swaps.
The 900 px split is intentionally a real middle state: it keeps live code next to Timeline/Rack or an active lesson instead of jumping directly between a three-rail instrument and one surface. A destination may still claim a canvas at that width when side-by-side code would be unrelated to its job.
Working from a track#
Click a track name in Cycles or Mixer to open Track. Notes, Instrument, Effects, Routing, Automation and Recording share that selection. The transport's Track button reopens it. Selecting another song clears track ownership.
Edit notes opens the existing roll; double-clicking a Cycles lane carries its local cycle, including entry origins and custom clocks. Double-clicking a Timeline clip opens the roll with context: every other track that sounds across the same song bars is drawn behind the phrase as faint ghosts in its track colour, under a context: N tracks line whose chips hide or show each track. Ghosts are never selectable, and a click on one writes nothing; a roll opened from the Bench, Cycles or this page has none. Edit generator opens the owning declaration. Make editable copy preserves the original pattern declaration and creates an explicit result for this track. It retains simultaneous notes, durations, velocity, audio inserts and placement. Live note controls, sliding voices and other results that cannot be faithfully copied to an authored roll report the reason and keep their generator.
In Instrument, choose a replacement and audition it on the selected pattern before applying it. Sounds offers targeted sample replacement and new sampler tracks; dropping a sample on Cycles adds a track. New tracks join the selected scene (or the opening scene while stopped). Effects appends a chosen effect expression. All source changes use full-song compiler validation and Undo; Run the live song's current source before acting on installed track metadata.
Automation shows track lanes, parameter dependencies and song controls. Scene ramps can be added or edited. Readouts distinguish declared and audible values and identify pattern, scene or live-override ownership. Restore releases a live override back to automation.
The main bar distinguishes pending source edits (a badge on RUN CODE), previews, arrangement playback and manual performance (the hero readout's mode caption). Resume arrangement returns scene control to the current song position and retains committed graph decisions. On a song with a written timeline it returns every receiver a hand is holding to its own row at the current position; on a legacy arrange song a manual gesture still suspends the whole walk, and Resume restores that walk, which is all the scripted walk can offer (Write a song as a timeline and Unified Timeline §10). A shell whose bridge cannot scope a return says it can only return the whole song rather than returning every track when one was asked for.
Run feedback. When a Run (⌘↵, RUN CODE, or an import tab's run) is accepted, the lines it changed since the buffer's previous install glow once in the accent for about 0.7 s, with a gutter tick per row (semdiff::changed_spans: bindings are diffed line by line, so an untouched binding stays dark). The glow fires when the install is accepted — at once on the desktop, on the worker's receipt in the browser. There is no countdown: a Run swap is not bar-quantized (the engine installs it on its next audio block). A Run that fails to compile shakes the offending line by about 3 px for 0.35 s, flashes it in the alert hue, and draws its error squiggle in left to right over 0.15 s. Reduce motion keeps the glow and the alert flash and drops the shake and the sweep; the Motion & flashes amount scales all of them (ui/src/editor/runjuice.rs).
The change glow is the same glow for every change the editor did not watch you type, and it lights exactly the lines changed (semdiff::changed_lines, an exact whole-buffer line diff, where a Run uses the binding-level changed_spans): Undo/Redo, Format (a pure reindent counts, since a Format only changes whitespace), a command-bar plan that edited the source, and an edit a copilot reply or MCP agent applied. Your own changes glow in the accent; a copilot's or agent's glow in the theme's "not you" hue (Theme::agent: a theme may pin --agent, otherwise it is the theme or rate-palette hue farthest from both the accent and the alert hue, raised to 3:1 on the screen). The Undo rule: an Undo/Redo step that stays in the caret's neighbourhood (its line, one line either side, at most one line added or removed) does not glow — that is a typing burst coming back out where you are already looking, and ⌘Z through a sentence would otherwise strobe; a step that changes more, or changes text away from the caret, glows. Auto-format follows the same rule; an explicit Format always glows. Any edit ends a glow (its spans would name the wrong bytes), and two requests at one revision merge — an agent edit and the Run it triggers glow once, each line in its first claimant's hue.
Editor margin marks. A row holding a live node shows a small LED-ladder level meter at the pane's right margin, lit to its level and held (empty) for 2 s after the node falls silent so a short hit does not blink out; hovering it names the node spans. A problem anchored inside a comment draws a warning triangle or info ring in the gutter (not a bare dot), and its hover names the severity. Comment text gets a light markdown treatment — -- # headings and **strong** in the semibold face (same monospace cell), punctuation dimmed — and the footer's first diagnostic carries a ▸/▾ mark: click toggles the problems list, where every message wraps in full.
Errors dissolve when fixed. When an analysis comes back without an error whose squiggle the previous paint drew, that squiggle fades out over 0.25 s, drifting up about 3 px (the drift goes under Reduce motion; the fade stays), however the fix happened — typing, Undo, a Run. It happens only when the new analysis reports fewer errors (a reshuffle resolves nothing), only where no remaining error sits on the same lines, and never for a squiggle whose text was deleted outright or that was never painted (scrolled away, a hidden tab).
A receiver a manual start, stop or launch is holding is marked on its Arrange track row, not by a global flag: its identity dot gains a ring, its name is tinted, and a return chip appears in the row gutter beside the row's + place chip. Clicking it returns that receiver alone; unrelated tracks keep playing what is written. A row that is following its written line draws none of this.
On a written playback = "loop" song the position reading carries the run's musical pass — the hero's caption reads TIMELINE · PASS 2, and its accessible reading bar 6 · beat 3.3 · 120bpm · pass 2 — and the Arrange canvas footer repeats it beside ▶ bar : beat : tick. The pass is the repetition coordinate a deliberately evolving region reads; it is separate from the administrative epoch, which a seek or a recompile moves without changing any music. The position readout's hover carries the rest of the run's coordinates — launch instance, epoch, the unwrapped run position — and names the receivers currently held. A legacy arranged song shows no pass: the reading is the one it has always had.
Shift-drag the Timeline ruler to select a musical passage; drag its handles to trim it. In the Map (and Arrange) a plain drag on the bar strip selects one, its ends on bar lines (Alt keeps the shared snap), while a click still seeks; the passage is shaded down every lane, brighter and marked loop while it loops. Snap, Play selection and Zoom to selection share that range between the views, and Clear selection drops it, releasing a running loop. Roll controls transfer a song selection into local phrase coordinates and send local selections back to transport. Loop live tracks repeats the passage on the engine's sample clock, suspending arrangement following and clearing queued entrances outside the loop. On a written timeline the rows keep playing through it. A scene song's loop holds one track set, so while the transport runs the workbench seeks into the passage (when the playhead is outside it) and applies the passage's scene; a passage that crosses a scene change holds the scene where it starts and says so in a toast. Resume arrangement returns control to the song. Offline range audition remains available separately.
DAW quick start (GUIDE ▾ or MORE ▾ → Help, and a chip in the navigation strip while the practice song is live) creates its own practice song and walks through choosing a sound, editing notes, adding bass, making a variation, arranging sections and exporting. The panel is anchored and fixed-size, so the steps do not move under the pointer as they unlock, and it says which of the three states it is in: Opening the practice song…, Practice song open, or Start a practice song first. The six steps are enabled by one question — does the LIVE buffer begin with the practice song's marker comment (app::workflow_coach::MARKER) — so focusing another song tab no longer disables them. A disabled step is dimmed whole and hovers "Start a practice song first". Recording review and performance arrangements are described in Recording and takes.
Capture performance turns a finished performance recording into a written timeline: regions at the boundaries where the receivers actually sounded, rather than padded to scene-length steps. It sits in the Performance recording review beside the note score that review already offered, is present only while a recording exists, and writes a new song through the same guarded save path — one new file, no second editor and no second Undo stack.
It states what it may claim before you commit to it. A clean take reads "Exact: every accepted change was recorded". A take that overflowed its recorder, crossed a seek or loop jump, or ran through a source change is still converted — but the review says NOT an exact replay with the reasons, the button's own words repeat it, and the saved file carries the same statement at its head, where it outlives the dialog. Printing audio remains the way to freeze the sound itself.
Shell, commands, and control language#
The header is two rows (ui/src/app/header.rs, transport.rs, hero.rs). The main bar (11 pt) holds the VOLTA wordmark, the SONG picker, RUN CODE ⌘↵, ▶ PLAY / ■ STOP, ▮▮ PAUSE, ● REC ▾, METRONOME ▾, the hero readout, the SCENE picker and, with the rest of the line, a timeline song's auto: <mode> ▾ and the direct scene jumps (SCENES entry … +N). Everything through the SCENE picker is a fixed slot, the same in every view and state: a scene-less song gets a disabled No scenes picker, REC keeps its width while recording (■ 01:23) and METRONOME reserves · ON, so the hero never moves (transport_row.rs). The picker, the jumps and every scene count list the song's named scenes and its inline [a, b] arrange steps (read as their tracks, [kick, bass]); an edge's fill (a -> b : [1, [riser]]) is not a scene you pick, so it is left out, and while one plays alone the picker reads Transition (ModuleInfo::listed_scenes). The quieter, recessed navigation strip (10 pt) chooses where you work: SOURCE · BENCH · HISTORY (HISTORY opens the History pane — its own pane in a workspace that has one, else a column on the right — and lights while it shows; Save ideas, compare versions and record takes), PANEL ▾, SOUNDS · MIXER · DISCOVER on the left; GUIDE ▾ · COPILOT · ROOM · DISPLAY · MORE ▾ on the right, with background-work dots and the contextual cluster against them. The main bar is one line from 1180 px (MAIN_BAR_ONE_LINE_W) and wraps below; under 1440 px (MAIN_BAR_ROOMY_W) the song slot narrows, ⌘↵ drops and METRO ▾ shortens.
The hero readout is one recessed block: a beat lamp, BAR.BEAT.16TH in tabular numerals (4.1.2, the bar right-aligned in three blank cells so the beat never moves — no ghost zeros), the BPM at the playhead, and a DSP meter with a 2 s peak tick ramping --pri → --acc → --alert. The lamp flashes each beat (--acc on the downbeat, --pri otherwise) with beat-of-bar pips under it. A mode caption reads ARRANGE, TIMELINE (· PASS n when looping), PATTERNS, FREE PLAY (a hand launch holds the arrangement), PAUSED, PREVIEW, REC PERF, COUNT-IN or STOPPED. It reads the old readout's data (song clock, meter/tempo maps, dsp_load) and requests no repaint of its own; the lamp follows the song beat, so it stands still with the transport. AccessKit hears the old sentence (bar 5 · beat 4.6 · 126bpm); the DSP cell is a dsp 34% button opening the breakdown, absent while stopped.
The contextual cluster replaces the old DAW quick start · Manual performance · Resume arrangement row; each item shows only while it applies: RESUME ARRANGEMENT (mode FREE PLAY; a ghost key like RUN CODE; only in a song with a form — a timeline, arrange or a scene graph), ▶ START N NEW TRACKS for declared tracks not yet played (a Run while a form-less song plays starts them), Stop preview, the performance-take control, Track: name, DAW quick start while the practice song is live, and a failed Run's pointer. It fills the gap between the two nav groups and never moves either; chips that would not fit fold into the Workflow disclosure (always, below 900 px — CONTEXT_IN_NAV_W), the run-failed pointer excepted. Pending edits are an --acc badge on RUN CODE. The coach itself is under GUIDE ▾ and MORE ▾ → Help (DAW quick start…).
The navigation strip uses compact navigation below 1280 px, independently of the body composition. Its wide form exposes direct Sounds, Mixer and Discover controls; the compact form keeps these under More. Sound Views, Library (the compatibility command family), Guide, Assist, Show, Session and Appearance remain command families. The current song name in the transport opens the song chooser. Bay is a Bench layout, with the legacy bay route retained as a compatibility alias.
The rail's panel slot — the chip whose menu chooses the right column's body — keeps one label, PANEL ▾, at every width, so the navigation reads the same on every screen. The slot lights when a panel is chosen and names it in the hover; the panel's own 02 … pane tag is where its name is read.
MORE ▾ → Help carries Welcome… (ShellCommand::ShowWelcome), which re-opens the first-run chooser. Help and GUIDE ▾ name the same two destinations the same way — Course and Reference — rather than "Continue Course" and "Volta Reference" beside "Course" and "Reference".
The frame readout, folded under Display ▸ Diagnostics (a developer's fact, not a MORE ▾ item), names both its numbers: Display · 120 fps · worst frame 31.7 ms (perf_readout_forms, ui/src/app/hdr.rs).
Navigation rests without borders; the selected destination has a quiet well and underline. The strip's labels are one step smaller than the main bar's (hdr_font_size = 10 pt at every width) so the transport reads first. Toggle lamps, menu chevrons and keyboard focus remain explicit. Timeline groups its mode and zoom controls, with gesture instructions and the note legend in Help. Snap shares the navigation rail, and the Cycles track cards center their names and controls above instrument subtitles and live meters. A card says the name the song wrote: the compiler lifts a bare play "keys" line's voice to a synthetic keys$voice$knob1 decl (compiler/src/lower/firstsound.rs), and timeline::track_display_name (tracks) and timeline::synth_label (every surface naming a voice: subtitles, Track Home, the rail) read it as keys — $sampler as samples, a track's own newTrack$0 as newTrack; Track Home offers no generated name as a replacement. Track controls follow the selected interface size, with hover, press and keyboard-focus feedback. Workflow actions use the same chips as the transport. See Read Studio's visual feedback for the September 15 finishing pass. These refinements are specified in Workbench Visual Refinement.
The main bar remains in the same conceptual and vertical home across Source, Bench, Learn, Reference, and contextual surfaces. Run code sits beside Play/Stop but remains a distinct operation: Run compiles and hot-swaps the buffer, while Play/Stop controls musical time. Play/Stop and position stay visible on reading and browse surfaces. Record audio, capture as code, and take arming live under ● REC ▾; click, count-in, and tap tempo live under METRONOME ▾; scenes keep their own current-value picker. An active stop action remains direct, and a failed Run reports beside the hand. The live Source pane uses this shell Run action without a duplicate footer button. Standalone editors and parked documents retain their local Run action.
That readout (run failed · line 3, col 1) is a failed Run's alone (EditorUi::fail_run): a refused gesture — a mix move, an Automate, a region edit — says what was refused and why on a short-lived message, never "run failed". It belongs to the document that produced it: the transport forgets it when the live buffer changes (forget_stale_run_failure), and a later successful Run replaces it. Messages stack where they hide the fewest controls (top right, unless a toolbar is there; toasts.rs), and a later success retires the refusals before it.
The SONG picker says the song's title — The Escalator Has Roots, from the library manifest — under a small SONG caption, with the file name in its hover, and sits in a fixed slot (204 pt, 176 pt below 1440 px) with the title elided to fit (SONG_CHIP_W, value_picker, ui/src/app/transport.rs), so Play, Pause, Stop and RUN CODE start at the same x for every song; ui/tests/transport_row.rs pins it. Both tab strips say the title too, with the file in the hover. While a LEARN lesson or the Structure Lab borrows the editor, the slot names the borrowed buffer (LESSON over its title, LAB SCRATCH over Structure lab), outlined in --pri; the hover says the song is safe until you leave.
The Source pane states the analysis once, in its footer, in words: a quiet ✓ no problems, or ✓ compiles / ✗ won't run followed by a ghost disclosure chip spelling the count (2 warnings, 1 error · 2 warnings, 1 note) that opens the problems list and takes the caret to the first one (editor::problems_summary). Pane headings carry the pane's name only (SOURCE, TIMELINE, PATCH INSPECTOR); the old 00–04 channel numbers named no stable position once the context slot changed with the view.
The scene control is the same fixed-width picker: a SCENE caption over the reading (Custom, None, groove, groove · queued), outlined in --pri while a scene is playing — so a queued arm never slides the chips after it. The inline scene chips beside it carry a SCENES caption and fill whatever the line has left, with a +N overflow.
A ★ KEEP chip (history.keep, ⌥⌘S; ★ alone on a compact header) sits beside HISTORY on the strip's second row: it keeps what is sounding as an idea in History (Save ideas, compare versions and record takes). A ▮▮ PAUSE chip sits beside Play/Stop (transport.pause-all, ⌘⇧P, also under Transport ▾) — always on the rail, greyed while stopped so the chips to its right never shift when playback ends. Pause is a hold, not a stop: the engine keeps its clock and running set, pins the beat (the playhead stands still), freezes in-flight voices and lets effect tails ring out (Engine::pause_all). The chip reads ▶ RESUME while held; Resume, Play, a hand-started track, or a scene apply all release the hold and play on from the same beat, while ■ STOP still re-anchors the transport. A seek while paused moves the hold. Sample-stamped scene barriers are not held — a boundary that falls inside the pause lands on the first resumed block.
The chip's ▶/■ state is a latch over viz-frame arrival (PLAYING_LATCH_SEC, ui/src/app.rs), not "did this poll hand out a frame". A frame is consumed once and each producer keeps its own cadence — the browser pumps viz over rAF-driven RPC while egui paints on its own schedule — so a running song routinely gives the shell a frameless poll, including the poll that processes a click. An explicit Play/Stop snaps the latch so the chip never lags the gesture that caused it.
ui/src/actions.rs is the canonical product catalogue. Its 72 stable CommandSpec records own family, label, behavior, shortcut, availability, disabled reason, placement, and dispatch identity. The wide/compact headers, keyboard routing, native menu, and fallback menu consume that registry rather than carrying parallel product labels or orders. Runtime selection and checked state remain separate from the immutable catalogue.
Song outline#
The column down the left of the Instrument shell is the song outline (ui/src/outline.rs, painted by app/macrostrip.rs; tests in ui/tests/song_outline.rs):
- Macros come first, one compact row each: the name, a bar that writes the macro where it takes effect — the sounding scene's
with { m = … }when that scene sets it, else its default (macrowrite.rs; the Rack's macro knob asks the same) — heard while it moves, written on release as one Undo step that puts the sound back too (app/macro_transactions.rs); a sceneramp, an expression, an imported macro or unrun edits refuse at the press, saying why. Then the live value with its ridden range and a tick at the effective value, and the MIDI-learn socket. Right-click is the knob's menu. From two macros on, the XY pad under the rows writes its pair the same way, as one Undo step (Esc cancels). The Macros pane's knobs and pad stay live and capturable. These rows are the LIVE song's and dim while the editor shows a parked song. - Declarations of the entry buffer follow in sections — Tracks, Patterns, Patches, Songs, Functions, Other — in buffer order, with the Bench rail's labels (a track's voice and span, a patch's chain and cost Σ). A plain binding shows as what it evaluates to (
kickT = kick |> …typedTrackis a track; aPattern …binding is a pattern). While the buffer on screen is the one playing, a track row carries its level and a pattern row its consuming track's playhead. - Clicking a row (or a macro's name) opens the declaration in Source; hovering flashes it there. A heading folds its section; folds persist (
outlinefold=). - Right-click a declaration row or a macro's name for ⟲ history of …: the part's versions to hear and use (Save ideas, compare versions and record takes § Find a remembered change). A row's ⟲ lights when the part differs from your last idea or this session's first version; clicking it opens the same popover.
dolanes and open constraint-hole cards keep their sections at the foot.
The full perform surface — the knob grid, XY pad, lanes, the mod matrix and the hole cards — is the workspace's Macros pane, which the Perform profile opens (Workspace layouts). Routing is read in the Rack's case cables and Routes.
Shell facilities#
Two things the workbench cannot discover for itself are declared by the host through VoltaUi::set_shell_facilities (ShellFacilities), never inferred from cfg!(target_os) — the same crate compiles into both shells:
menu_bar— the shell draws a menu bar outside the egui canvas (app-native: native menus, ormenubar_fallback.rs), which already carries the File/Edit/Transport catalogue and its keyboard accelerators. Without it (the browser workbench) the header's MORE ▾ renders File, Edit and Transport itself, and the egui shell honours the catalogue's own chords — otherwise those commands have no route at all, and the chords the menus print do nothing. Chords the editor also claims (⌘. — Stop All vs the compile-verified code-action request) stay with whatever holds keyboard focus.file_dialogs— the shell can put an operating-system file chooser on screen. Commands requiring an OS path (Open Song Beside…, Reveal Song) are omitted without it rather than dispatching into nothing. Save As… is the exception: the browser has a workspace and asave_source_asseam, so it asks for the name in an in-app form and feeds the identicalShellCommand::SaveAspath. Open Song is available in both hosts because it opens the shared chooser.
Shortcut labels follow the shell's runtime platform (ctx.os(), published once per frame through actions::set_label_platform_is_mac). The wasm build's target_os is unknown, so a compiled-in guess told browser users on macOS "Ctrl+4" for a chord their keyboard spells ⌘4. Help ▸ Keyboard Shortcuts lists each family once, on a Mac in Apple's symbols only (⌃ and ⌥, which the faces lack, are drawn: ⌥⌘] beside ⇧⌘S); the in-app command menus paint their chords through the same routine (dialogs::paint_chord), so no menu spells Option+. Text-only places (tooltips) still spell it.
Paint tier and behavior are also separate. Commands use pushbutton semantics, destinations expose current selection, toggles expose on/off state, disclosures name the menu they open, destructive controls carry an explicit destructive role, and passive readouts do not masquerade as buttons. Standard, Compact, and Stage retain their 24/16/32 px target contracts.
Musical relationship focus#
One shared relationship bus follows a compiled track across Source, Timeline, Mixer, and Rack. Hover is transient preview; pinning is persistent until cleared. Source exposes a named pin control and Alt+P; Timeline lanes, Mixer strips, and Rack nodes project the same stable generation-plus-track identity without changing navigation, playback, or each surface's own selection. Ambiguous or stale mappings paint nothing rather than guessing. Reduce Motion uses static contrast instead of a pulse. Source's pin control is a RELATION row above the editor (ui/src/app/sourcepane.rs): it appears when the caret enters a track declaration (or a track is pinned) and pushes the editor down one row, so editor geometry is measured with the caret already parked there. It stands down inside a lesson (Learn with guided exercises).
Surface#
The shared Display window opens directly from the wide header's display button, from More → Display Settings… at any width, or from the desktop View → Appearance → Display Settings… command. Changes preview immediately and save automatically. Theme, interface size, and panel opacity appear first; background controls stay together, while Background details and Editor & notebook disclose the less-used settings. Sliders keep the window open. The same controls serve native and browser workbenches.
- Panel opacity — 0–100% (default 94%) for the background behind Source, split Source, Bench, the song outline, and contextual panels. At 0% the panel ground is transparent; at 100% it is solid theme screen color. Text and controls retain their opacity. This is independent of background intensity and works with animated backgrounds enabled or disabled
- Theme — every existing light and dark theme remains available; a fresh workbench boots
luminous-dark(theme::DEFAULT_THEME), and a saved pick in shell state overrides it. Shell state carrieslook=2from the luminous release on; a state without it that namesspectrum-dark(the previous default, which nobody chose) moves to luminous once - Right-column width — remembered per composition, never inherited. A canvas destination sets its panel to the whole window, so the slots are
wb-col(the ordinary split),wb-col-learn(the lesson side-by-side, 50/50 by default forLessonCodeand 0.57 forSplitCode's wider reading column) andwb-col-canvas.VoltaUi::request_right_fractionasks for a fraction on the next paint by forgetting the remembered width;VoltaUi::right_fraction_ofreads one back - Panels —
OutlineandTimelinehide the song outline and the right column's default body (02 timelineover the patch inspector). On a fresh shell the inspector is open, showing the Rack — which follows the caret into a patch, track or module (Patch Bay and Rack § See a patch as a case) — at half the column (40% in a window under ~720 points of column height); a savedinspector=0and a dragged height still win. Layout only: hidden panels keep their state, their macros keep responding to MIDI, and the freed width goes to the editor. A chosen destination — mixer, rack, reference — still opens in that column, and picking Timeline turns it back on. Both are opt-outs in shell state (macrocol=0,tlcol=0) - Interface size / density —
compact·standard(default) ·stage - Backdrop level —
auto(default) ·work·monitor·performance - Backdrop scene — 44 restorable scenes (
liquidplus 43 fragment presets), presented as 16 featured choices, the Living Light section, and visual-family groups. On compute-capable adapters, Chladni Dust adds a 24,576-grain shockwave and vortex simulation over its animated portable resonance-membrane fallback - Living Light — seven organic scenes, each grown from one physical or botanical rule:
lightshow(a projector liquid light show — subtractive magenta/yellow/cyan dyes in immiscible globules on two counter-turning glasses; the downbeat presses the glass, strong onsets drip dye),emulsion(oil droplets in water as ball lenses carrying an inverted image of the light behind; onsets ripple the water),fronds(bipinnate tree ferns against a low sun; onsets gust, tension ripens the green),canopy(leaves against the sun with bokeh; the high band sets them fluttering),mycelium(foxfire hyphal web; onsets pulse out along the threads),medusae(jellyfish whose bells contract on a two- or four-beat grid) andflorets(phyllotactic flower heads lit in growth order each bar). They also appear under More Scenes → living light in the native menu bar - Backdrop recipe — five curated fragment scene+finish combinations; the five older print-native scenes remain in More Scenes → Authored Print for exact shell-state and screenshot-CLI restoration
- Backdrop effect — none or one of 15 reusable print/optical treatments, with independent amount and physical scale
- Audio motion — on by default; off keeps each scene's autonomous flow and smooth harmony/level color response while removing music-driven geometry, velocity, spectrum deformation, beat blooms, and onset injections. It is a global preference: both backdrop renderers and the Lens animate from the same cleared features (the Lens is a Monitor, so its readouts still report the live values)
- Reduce motion — in Display ▸ Motion, for the whole workbench: every feedback effect keeps its colour cue and drops its movement (§ Motion). On the backdrop it freezes ambient and beat-driven fragment geometry, suppresses transient blooms, and parks Chladni's particle integrator while retaining live color/energy; Liquid is deliberately outside its scope
- Motion & flashes — 0–100 % (default 100,
motion=in the shell state): scales every feedback effect, colour included; 0 leaves only the state itself (a lit note, a value) with no flash, glow, pulse or motion - Timeline zoom — fit to width through 64× in Cycles and Map; +/−, Ctrl/Cmd-scroll, or pinch. Horizontal scroll and Shift-scroll pan the time axis.
- Quiet cells — off by default; the bench notebook hides each cell's tool chrome (grip, management verbs, the dock-chip cluster) entirely until the pointer is inside that cell, and cells wear a lighter frame. Readouts (transport, mute, meters, LED rows, open docks) keep full weight. It is also a vertical diet: slimmer cell margins, a head row without the touch-target floor, thinner insert gaps and column rhythm, and compact code rows (
EditorUi::compact_rows, 1.18 leading vs the airy 1.34) — a one-line cell drops from ~93 px of column height to ~69, so roughly a third more song fits on screen. The geometry is constant while the mode is on: pointing at a cell reveals its chrome without moving the page. Also aquietchip in the bench top strip. A guided Learn lesson carries its own copy of the preference, quiet by default (benchcourseloud=1records the opt-out), so the course default never leaks into the ordinary notebook (benchquiet=1) - Note shading — 0–1 slider (default 0.65) washing the raised-well tone (
control_fill, not the screen tint — invisible on dark pages) behind the notebook's--prose cells so notes read as cards against the page;0restores the bare-page look. Persists asbenchproseshadeonly once moved
CRT film, screen grain, per-glyph text effects, and text glow are removed from rendering and settings. Restoring their old preference keys has no effect. Low power mode pauses the animated background and reduces repaint cadence without changing the saved appearance preferences.
Shell state is one flat key=value;… string, including panelopacity, density, backdrop, and tlzoom. Every key is declared once, as a row of SHELL_PREFS (ui/src/app/shell_prefs.rs) holding its writer and reader side by side; shell_state walks the rows in order and restore_shell_state looks each blob key up in them (plus RESTORE_ONLY for legacy spellings). Unit tests fail a row whose writer emits an undeclared key, a reader that drops what its writer wrote, and a pre-table blob whose pairs do not survive a restore. The native menu bar's checkmarks read appearance_marks(), the same fields, rather than re-parsing the blob:
theme=…;overlay=…;surface=…;inspector=…;panelopacity=…;backdrop=…;preset=…;field=…;
print=…;printamt=…;printscale=…;audiomotion=…;reduce=…;density=…;tlmap=…;tlzoom=…;learn=…;
learndone=…;structuredone=…;wrap=…
All display values survive a shell-state round trip. panelopacity is a 0–1 fraction, clamped on restore; invalid or non-finite values are ignored, and older sessions without the key use the default. tlzoom is clamped to a supported step on restore, and an unknown density/backdrop label falls back to the default rather than failing the parse.
Motion#
Feedback motion follows two clocks. Music-driven effects run on the song clock, so they freeze on pause, vanish on stop and cost no extra frames:
- Header readout — every beat tints the beat digit and the BPM, and the bar's downbeat (found through the meter map) tints the whole readout in the accent; colour only, the row never moves (
ui/src/app/hero.rs). - Activity LEDs — a fixed-size dot on bench track cells (Quiet off) and beside the rack's patch picker, lit by the track's level with the timeline gutter's attack/release.
- Rack envelope traces — on each hit an ADSR glyph's curve lights from its start, the plateau stretched over the held note and release in real time; a Decay glyph traces over the note's length (0.15–0.8 s). Graph and Grid layouts; Wiring is untouched.
- Timeline — a hit note pops ~4 pt taller, dips below rest and settles within the 0.22 s flash; the play-head leaves an eighth-note trail; a lane whose clip or scene visit starts washes once in its track colour for a beat.
- Scene countdown — a scene launched while the transport runs lands on the next bar line (found through the meter map, as the engine quantizes). Until it does, its chip (the transport's scene chips and the bench strip's) fills from the click toward that downbeat under a steady armed outline, the Scene picker shows the same fill as a 2 pt bar under its reading, and so does the scene's band on the song map. It holds with a paused transport; a stopped transport applies at once with no countdown. It lands when the running set matches the scene or, failing that, a sixteenth past the bar line (a written
arrangeresumes each listed row, so a track with no region there never starts); a seek back behind the click or a stop drops it. On landing the chip flashes once (0.4 s, wall clock — the one piece that repaints on its own), lit or not.
User-driven effects run on the wall clock and repaint only while live: the Run feedback, change glow and error dissolve above, and the value controls — mixer faders (track, group, master), pans and sends, and Perform macro knobs. They track the pointer exactly while held and glide (a critically damped spring) to any value the user did not drag in (undo, reset, automation, a command). Faders stick for 8 pt of travel at 0 dB, pans at centre, macros at their initial value and 0 (Shift, the fine-drag key, passes through), with a brief tick flash on entry. A readout with the unit floats beside the control during the drag, and double-click glides back to the default.
Mute and solo dim a timeline lane by easing, not snapping: 0.12 s in and out, whatever changed the mute (the lane's M, the mixer, MIDI, the command bar, a recompile that edits a mute field). Lanes silenced by another track's solo dip a touch past their resting dim for 0.25 s and settle — a spotlight on the soloed lane; an own mute never dips. Keyed by track name, so reordering lanes animates nothing, and a lane that was not on screen snaps. Mixer strips and bench cells do not dim for mute (their M/S keys carry it), so there is nothing to fade there.
Under Reduce motion the pop, trail, traces' travel, shake, glides and the scene landing's outward ring go; the tints, LEDs, washes, flashes, a whole-curve rack flash, the mute fades and the solo dip stay (colour). The scene countdown is information, so it stays too, but advances in whole beats instead of sweeping; it ignores the Motion & flashes amount, which scales the landing flash and the solo dip (0 snaps the mute dim).
Panel and view transitions ease PAINT, never layout. A dock or panel takes its full size on the frame it opens and gives it back on the frame it closes, so its neighbours reflow once and no text rewraps mid-animation; the persisted panel widths are untouched. What eases:
- Docks — Sounds, the Mixer dock, the song outline, the keys strip, the split's second source pane, and inside the Timeline column the patch inspector and the DSP probe: over 0.16 s (cubic-out) a veil of the panel's own ground fades off the contents while they glide 12 pt in from their docked edge. The keys strip has no ground of its own, so its contents fade instead.
- Views — the source pane, the bench takeover, the wide Mixer and Sounds canvases, and the context column keyed by what it shows (Timeline, Mixer, Rack, Reference, Copilot…): a new view fades in over its ground in 0.12 s. No slide, no second view painted underneath.
- Closing — whatever a closed panel vacated sits under a fading veil of that panel's ground (0.12 s), unless a newly opened surface already covers most of it (a view switch is one fade, not two).
- Windows — every shared tool window (command bar ⌘⇧K, export, I/O, history, save-as, feedback, …): rises 6 pt and fades in over 0.16 s; on close its last frame sinks 4 pt and fades over 0.12 s.
The glide, rise and sink are movement; the veils and window fades are colour. Reduce motion keeps the fades at the same short lengths and drops every glide, rise and sink; Motion & flashes scales both, and 0 % opens and closes every surface at once. Every veil and fading window is paint with no egui area, so it never takes a click, and widgets are interactive from the first frame — a click lands where the layout put the widget. A settled surface requests no frames. Left as they were: egui menus, popups and combo boxes, egui::Modal dialogs (the song chooser keeps egui's built-in fade), toasts, and the bench's own floating overlays.
Density#
Density (ui/src/theme.rs) sets the interaction target (Compact 16 px, Standard 24 px, Stage 32 px) and — through the type roles below — every text size.
Spacing#
Gaps, margins and item spacing take their distance from a named step in ui/src/space.rs — Hair, Xs, Sm, Md, Lg, Xl, Xxl, Huge = 2 / 4 / 6 / 8 / 12 / 16 / 24 / 32 px at Standard, scaled down at Compact and up at Stage, so density moves the gaps along with the type. The gap between groups sits at least two steps above the gap within them. A ratchet test counts the literal spacings left in ui/src and only lets the number fall.
Typography#
Direction C, "hardware panel" (2026-09-23). Text takes its face, size and tracking from a named role in ui/src/type_scale.rs, resolved at the active density; literal sizes are legacy, counted by a ratchet test that only lets their number fall.
| Role | Face | Compact / Standard / Stage | Carries |
|---|---|---|---|
| Caption | Barlow Semi Condensed Medium, caps | 10 / 10.5 / 12 | eyebrows, meta |
| Label | Barlow Semi Condensed SemiBold | 11 / 12 / 13.5 | chips, buttons, tabs, nav |
| Heading | Barlow Semi Condensed SemiBold, caps | 12.5 / 14 / 16 | panel headings |
| Body | Barlow Regular | 12.5 / 13.5 / 15 | prose |
| Code | Geist Mono Regular | 11.5 / 12.5 / 14 | code, identifiers |
| Value | Geist Mono Medium | 11.5 / 12.5 / 14 | numbers, readouts |
| Title | Barlow SemiBold | 14 / 16 / 18 | dialog, card, song titles |
| Display | Geist Mono SemiBold | 26 / 30 / 34 | the hero readout |
egui's text styles ride the roles (Small = Caption, Body and Monospace = Code, Button = Label, Heading = Title). Chips paint in Label, scaled by the size a caller passes relative to the old 9 px chip. Control labels paint in caps with the accessible name untransformed; song, file and track names never change case. An egui Label announces what it paints, so a label built from type_scale::text in Caption or Heading would announce capitals (and a kittest query_by_label on its words would miss): caps labels go through widgets::role_label (capitals painted, words announced), chips through chip_display, board text through widgets::paint_role. A track, patch, file or unit (dB, Hz) never sits inside a caps role; it takes the Code or Value face at the Caption rank (font_at(ctx, Role::Value, Role::Caption.size(d))). ui/src/fonts.rs binds each weight as its own named family (egui has no synthetic bold); Fragment Mono stays in every stack as a glyph fallback.
Theme shape#
Themes differ in shape as well as hue. The SHAPE section at the end of design/tokens/colors.css gives every family nine geometry tokens; the web pages carry them as unused custom properties, and Theme (ui/src/theme.rs) reads them through clamped accessors:
| Token | Accessor | Default | Drives |
|---|---|---|---|
--radius-control | radius_control() | 3 px | egui widget corners (buttons, combo boxes, fields, sliders) |
--radius-card | radius_card() | 4 px | in-pane cards: Rack node cards |
--radius-panel | radius_panel() | 6 px | window, menu, popup and tooltip corners |
--radius-screen | radius_screen() | 7 px | the visible corner of the inset screen (app::chrome::bezel_mask) |
--stroke-hair | stroke_hair() | 1 px | window outline, inert widget outline, pane hairlines (chrome::hairline) |
--stroke-control | stroke_control() | 1 px | interactive widget outlines, the selection stroke, Rack node outlines; hover/pressed glyph stroke is +0.2 |
--glow-strength | glow_strength(), glow_scaled() | 1 | multiplier on glow-halo alpha (Rack's cable halo); 0 = no glow |
--shadow-depth | shadow_depth(), shadow() | 1 | scale of egui's window/popup drop shadow; 0 = flat |
--shadow-glow | shadow_glow() | 0 | 0 = black drop shadow, 1 = a centred halo in --glow |
--panel-lift | panel_lift() | 0 px | half the gutter between floating workspace panes (kit::pane_frame / kit::pane_ground); 0 = panes abut on a hairline |
--aurora | aurora() | 0 | strength of the glow pools on the screen ground behind the panes (light::aurora); dark themes only |
The :root values reproduce the pre-token look exactly, and spectrum (the pre-luminous default) keeps them, so an unknown theme renders as before. Theme::apply maps the tokens onto egui's Visuals; custom-painted surfaces read them through the paint kit below. The shared chip (widgets::chip) and card_frame take the family's control corner and weight; spectrum renders pixel-identical. Decorative halos read Theme::halo() — glow_strength() on a dark theme, 0 on paper — multiply their alpha by it, and at 0 draw the crisp light-theme mark instead: the cycle-lane play-head bloom and onset flashes, the lane header level edge and Map dots, the console meter head (kit::meter's bar recipe behind widgets::level_meter, also the lane head's MASTER bar) and the macro knob motion bloom. So a flat dark family (argent, console, vector) gets a clean line where synthwave blooms. Data-carrying glows (the Lens centre, whose bloom IS the level) do not scale.
| Family | Shape intent |
|---|---|
| argent, console, vector | machined: 0–2 px corners, hairlines, no glow, shallow shadow |
| signal, scrapyard | square lab/plate-steel corners; scrapyard has 2 px strokes and a deep shadow |
| analog, bench | chunkier control outlines (2 / 1.5 px); analog is soft-cornered with 1.5 px hairlines |
| synthwave, hologram, ultraviolet, bloom | rounder (5–8 px controls), glowier (1.25–1.5), popups float on a glow halo |
| phosphor | CRT: an 18 px tube corner on dark, glow 1.6, full halo |
| acid | sharp corners running hot: 1.5 px outlines, glow 1.35 |
| sakura, manuscript-light | soft: the roundest corners, a light shadow |
| glacier, verdant, manuscript-dark | moderate corners, low glow |
| luminous | the default family: soft glass (6/10/14/16 px), glow 1.6, deep lift, panes float on an aurora ground, pill buttons, its own vivid rate palette on dark |
| spectrum | the defaults |
| studio, stage, walnut | derived through the skin resolver (Skins § Derived built-in families): grey hardware with a brushed bevel, square high-contrast, wood and paper |
| brutal, pillow, onebit | experimental stress skins for the paint kit (0 px / 3 px; the roundest; two colours) |
Every light theme has --glow-strength: 0 and --shadow-glow: 0, because a glow on paper looks like dirt. every_theme_declares_a_sane_geometry requires every theme to declare all nine tokens within the accessor ranges, requires nested radii (control ≤ card ≤ panel) and zero glow on paper, and checks that the table has at least 12 distinct shapes. root_geometry_reproduces_the_pre_token_look compares the :root style field by field with the old constants.
Light#
ui/src/light.rs is the luminous look's paint primitive (Luminous Instrument). eframe exposes no finished-UI texture, so there is no bloom pass; what reads as bloom is light added around a mark. Every bloom_* call draws a cached 64² Gaussian sprite with a premultiplied colour of alpha 0, which egui's blend turns into a pure add — overlapping glows sum and nothing is darkened. bloom_rect is a nine-slice of the sprite (the rect fully lit, a Gaussian ring radius wide around it), bloom_point a round glow, bloom_line a trace as three widening additive strokes under the caller's crisp core (one continuous Gaussian ribbon mesh, so dense traces do not band). They scale by Theme::halo(), which spectrum-dark and signal-dark also carry, so every luminous effect gates first on light::instrument(theme) (the music's light: meters, faders, knobs, scope traces, cables, the timeline beam and blooms, lit source spans) or light::chrome(theme) (the shell's: wordmark, Play/Stop, hero, lit glass, the welcome) — non-zero only where --aurora > 0 on a dark theme, so every other family renders as before. bloom_raw / bloom_shape skip the gate for surfaces that own their light; chrome_glow_under fills a reserved shape slot so a glow sits under a widget's label.
The shell publishes the music's brightness once per frame (light::publish, read with light::read(ctx)): Light { level, pulse, downbeat } from the smoothed signal features, all zero while the transport is stopped or Audio motion is off. It is colour only, so it survives Reduce motion; light::aurora's slow drift is movement and stops under it.
Psychedelic#
Display ▸ Motion ▸ Psychedelic (0–100 %, default 0; appearance.psychedelic) lays music-locked colour flow over any theme: a luma-preserving hue pass on all paint and the backdrop, ripples on Run, play and view switches, spectral light on playing code, notes, meters, scopes and cables, and kaleidoscope and feedback backdrops. Above 35 % the bench drifts like liquid (focused text holds still), downbeats fringe text, the pointer trails light, and controls, mixer strips, panes and workflow moments (open, save, record, undo) join in. The finished frame then runs through a screen pass: trails that tunnel outward (long in quiet passages, snapping back on loud ones), a bass lens and kick ring, heat haze from the top band, bloom, a red/blue split with the mix's stereo width, a datamosh into each scene landing or view switch, and a rejected Run's line melting down. The colour itself follows the sound: a brighter mix turns the hue one way, a darker one the other, loudness saturates and stereo width spreads the waves. appearance.kaleidoscope ("kaleido") mirrors the bench into six turning wedges round the pointer; clicks still land where things really are. The caret's rows stay plain pixels, and colours at the theme's alarm hue (clip, error, record, mute) keep it, so a warning still reads as one. On a wgpu shell the colour is painted per pixel on the GPU. Motion & flashes apply; Reduce motion and Low power stop all movement (bloom stays). As built: Psychedelic Juice § 5–8.
Paint kit#
ui/src/kit.rs is the one vocabulary custom painters draw with (workbench-skins Stage 3). Three rules: skins pick recipes, they never draw (a skin will choose among recipes that are Rust code here); sizes come from Density, never from the kit; the kit paints inside the rect it is given (only a shadow, a glow or the armed chip's halo reach past it).
kit::surface(painter, rect, theme, Surface)paints a surface kind (Bezel,Screen,Panel,Card,Well,Popup) in the theme's recipe;kit::frame(theme, Surface)is the matchingegui::Frame, andsurface_withtakes an explicitSurfaceRecipe(Flat,Gradient,Bevel,Inset).surface_recipereads the theme's--surface-<kind>and otherwise gives the house look: the screen's top-glow gradient, flat everywhere else.card_frameis theWell;chrome::screen_glowis theScreen.kit::control_frame(painter, rect, theme, Control::Chip { style, role }, ControlState, chevron)paints a chip's fill, outline, finish, lamp, chevron and focus ring and returns the ink for its label; the chip widget keeps layout and semantics.kit::radius(theme, Radius::{Control, Card, Panel, Screen})andkit::stroke(theme, StrokeKind::{Hair, Control, ControlHot}, color)are the shape lookups; the primitives aregradient_rect/gradient_rect_h(vertex-coloured meshes),bevel(lit and shaded 1 px edges from a light bearing),shadow,glow_additive(scaled byTheme::halo(), so nothing on paper or a flat family),dashed,hairlineandbezel_mask.
Every migration onto the kit keeps spectrum byte-identical (a volta-shot pixel diff of the default views); a family whose SHAPE tokens differ from :root picks up its own corners and weights on each surface that moves. scripts/paint-lint.sh (make paint-lint, also in test-fast, gate-smart, premerge and CI) counts raw Color32 constructors and constants, literal CornerRadius::same(n) / .corner_radius(n) and literal Stroke::new(<number>, …) left in ui/src/ outside theme.rs, kit.rs and test code, and fails above PAINT_CEILING; --report lists them per file and --fix-ceiling lowers the ceiling after a migration. Values with no token (the 1–2 px tick radii, 1.5 px focus and accent strokes, data colours) stay literal until Stage 5 decides on tokens for them.
Recipes (Stage 4): kit::knob, kit::fader, kit::meter and the chip body inside control_frame each draw in the theme's recipe — --recipe-knob (arc, dial, dot, notch), --recipe-fader (cap, slot, bar), --recipe-meter (bar, led, needle), --recipe-button (flat, outline, bevel, keycap, pill) — and kit::surface_recipe reads --surface-<kind>; kit::recipes(theme) gathers them. Painters pass state (KnobState, FaderState, LevelMeter, ControlState) and keep their interaction and accessibility; the macro knob, the strip pan pot, the mixer fader and widgets::level_meter (now a thin wrapper) paint through the kit, and each default recipe is the old painter byte for byte. The vocabulary, who picks what and the rule tests are in Recipes.
Three experimental stress skins ship with the built-ins to show which surfaces still ignore the kit: brutal (square, 3 px control and 2 px hair strokes, no glow, a deep shadow), pillow (the largest radii the accessors allow, a soft shadow) and onebit (black and white; paper keeps one grey ramp because the paper-hierarchy gates require it). ui/tests/skin_sheet.rs snapshots a kit sheet under them and spectrum (UPDATE_SNAPSHOTS=1 cargo test -p volta-ui --test skin_sheet re-blesses); a snapshot proves rendering parity, not readability, which the theme contrast gates own.
Text roles#
Four reading roles on Theme, in descending loudness — ink() (primary reading text) · operational() (state, help, instructions) · lab() (labels) · faint() (decorative metadata, never contrast-promoted). brt() is the emphasis ink used by data marks; mid() is the resting mark color.
Ordinary source code uses 13.5-point Geist Mono with the existing line spacing. Comments are the only italic tokens; a unit suffix (ms, Hz, bpm) is painted upright in its number's colour at reduced strength, so 28ms reads as one quantity (ui/src/codeline.rs, shared by the editor and Copilot's code blocks). The live signal underlines under patch nodes stay quiet at rest — a faint dotted rule whose strength follows the node's level — and turn into the full solid bar only for the span under the pointer or holding the caret; the right-edge row bars follow the same level at lower strength (ui/src/editor.rs).
Mixer channel, VCA and Master names and song chooser content read in Barlow; every Mixer number (fader values, dB scale, pan readout, peak hold, LUFS/dBTP) stays in mono so numerics align as a tabular column. Panel headings (Source, Sounds, Mixer, Open a song, the pane tags) share the Heading role.
Backdrop tiers#
The header is chrome: both rows paint the work panels' ground (panel_surface(panel_opacity)), so the field is no louder there than behind Source. (Transparent rows once showed Liquid's harmony-hued field as a green/yellow band at Monitor intensity.) The music reaches the header only as the signal line at the seam of the two rows, a --pri → --sec light pipe whose glow follows the field's relative level and beat while backdrop, audio motion and transport are on and Reduce motion is off.
backdrop::Intensity — Auto · Work · Monitor · Performance. Auto follows the active surface: Work behind Source/Bench/Learn/Reference and the browse and social surfaces; Monitor behind Timeline, Mixer, Rack, and Scopes; Performance behind the Harmonic Lens. Selecting a level pins it. Light themes receive an extra veil so pastel shader forms do not overtake white work surfaces. The Lens then adds only the alpha needed to bring its own figure rect from the global level to the Work ground; it does not double-apply a Work veil.
Musically locked backdrop#
signal::SignalReader turns the engine's wait-free visualization snapshot into one render-rate-independent feature bus. Absolute master and eight-band energy remain available for perceptual brightness, while low/body/detail drives are normalized to a slow running average (six-second rise, four-second fall) and passed through 140–180 ms visual attacks and roughly 0.85–1.1 s releases. A separate 25 ms detector preserves precise onset events; it never reaches a shader directly. A steady section therefore sits near the same neutral drive on quiet and loud masters, while relative lifts still arrive within a few hundred milliseconds. A slow, critically damped follower supplies inertial hero motion without ringing back after a hit.
The engine's active pattern-event transitions are the primary discrete trigger; an adaptive Schmitt-gated flux detector covers live or unsequenced audio. Pitch class maps horizontally/hue-wise, register maps vertically, and the engine song clock supplies extrapolated beat and cycle phase. Liquid consumes each event timestamp exactly once; it does not convert a held flux value into one random injection per render frame. The 15 effects are post-processes shared by Liquid and every fragment scene: their matrices stay physical-pixel locked while adaptive low/body/detail drives change ink, exposure, contour detail, or optical light. Phase-accurate beat and downbeat excitations pass through 0.95 s and 1.6 s persistence stages, respectively, so accents overlap as swells instead of resetting at beat boundaries. Both lanes are published: backgrounds read the swell (pulse / downbeat), while the Lens ring reads the excitation the swell is smoothed from (pulse_impulse / downbeat_impulse), which still returns to rest between beats. Oil deliberately omits a direct beat flash; its music response is slow brightness, pool depth, and fluid motion. Storm uses one smooth local strike without rapid sub-flashes and keeps its cloud-deck lift low contrast.
Fragment output receives a small scene-local exposure correction before the contextual scrim, reducing unintended level jumps while preserving each scene's declared quiet/balanced/vivid character. Scene and effect selections crossfade as one appearance over 360 ms; settled frames evaluate one scene. Audio motion off substitutes a phase-continuous 30 BPM ambient clock for the song clock and clears every form-driving audio feature before either renderer (and before the Lens ring; the Lens harmony/tension readout is data, so it is exempt). The slow master envelope is then routed only through a restrained final saturation lift, while harmony continues to steer palette hue. Each renderer carries that mode in one explicit uniform lane — color_level for fragment scenes, clock.w for Liquid — never as a difference between two lanes. Fragment scenes additionally lift brightness by that drive because they zero level in this mode; Liquid does not, because its field is still lit from level.
Reduce motion pauses the fragment animation, beat, and cycle clocks and removes event-driven fragment motion. It is scoped to the fragment renderer: Liquid keeps its live features, its onset injections, and an unpaused clock lane, so the sim never decays to a static wash under the preference.
Panel roles#
Every Rack surface is labeled Inspect and states its pointer contract in the canvas: Patch Graph (click node → source; drag to pan; Ctrl-scroll to zoom), Routes (edit literal modulation depths; expression routes and source headers → source), Signal Chain (click stage → source; hold the rail to monitor), Node Scopes (click node → source; audition to capture). Timeline and the Bench piano roll identify themselves as Edit surfaces. Scopes and Lens are Monitor surfaces; the Lens states passive readout · no edits.
Visuals (ui/src/visuals.rs, token visuals) is a Monitor surface that exists only for a song whose module draws: its one entry is a visuals chip at the right of the 02 header (and Window ▸ Visuals natively), so an audio-only song grows no chrome. Output chips (several outputs only), the preview letterboxed on black, a Value-face status strip (tick · rate · passes · MiB · canvas, behind + resync, the last tick error), and the layer list (name, gain, opacity). open audience display puts the same picture in a chrome-free second window; reset feedback and the ms picture delay sit beside it. The picture's contract is Visual synthesis § Running it.
Monitor states#
Every bus tile in Scopes names exactly one state (ui/src/scopes.rs):
| State | Long form |
|---|---|
active | — |
silent | silent — no signal on master |
not tapped | not tapped — no ring yet |
stopped | stopped — press ▶ |
A missing ring paints blank. It is never given a synthesized waveform or spectrum, so a silent, untapped, or stopped bus can never be mistaken for a live flat line.
Layout (scopes.rs arrange, BusMemo): the master leads as a full-width hero (waveform beside spectrum). Other buses earn a card by presence — the ~2 s average of the per-poll active verdict, promoted at 0.25, demoted below 0.08 — in track order; the rest (sparse, silent, stopped) sit in a COLLAPSED strip of chips (colour tab, name, state, sparkline), heard-in-the-last-minute first. Order never follows a per-poll value, so nothing re-flows on a beat. The census line counts the taps the way the Mixer counts strips — N channels + master · A active · … — so the two panels' numbers reconcile. Click a card or chip to focus it; all buses returns. Each trace and band wears the bus's Timeline/Mixer lane colour (master: the theme accent). The waveform is a min/max envelope with a gradient body, auto-zoomed in 6 dB steps (the axis prints the scale, the header the real pk level); the spectrum is a filled curve with ballistic release under a held peak line. Halos scale by theme.halo().
Mixer strip#
Each channel strip (ui/src/mixer.rs strip_ui) is one card, top to bottom:
- Colour cap — a band tinted with the track's Timeline lane colour under a solid 3 pt edge in that colour, carrying the activity dot and the name (Barlow, truncated; right-click automates). A click selects the track and opens the Channel inspector; its Effects… and Routing… open the track's Track Home in its place.
- Pan pot — a bipolar knob: a 270° ring whose value arc grows from the centre detent toward the pan position in the track colour, a detent tick at 12 o'clock, and a pointer;
PANand a mono readout (C,L32,R40) sit at the column's right edge. Drag in either axis (Shift = fine), wheel, arrow keys (when focused) and AccessKit increment/decrement adjust; double-click centres. Its accessible name ispan <track>(a numeric slider). Every path feeds the sameMixerGesturephases the old slider did: a drag previews and commits once on release, a wheel flick is one session that commits after 0.4 s of wheel idle (it previews through a rawSetStrip, because the shell cancels any gesture preview whose button is up), and a keyboard step or double-click is one atomic confirm — so each is one source transaction and one undo step. - Fader · meter · scale — a 30 pt fader column (a raised cap with ribs and a centre line in the track colour; the slot fills from the floor to the cap in that colour — except that a track colour within ±22° of the theme's
alert()hue is drawn half-way toward the ink on the fader only (mixer.rsfader_accent), so a red lead's cap does not read as a clip; the header band keeps the full colour), the level meter, and a dB scale. All three map dB over the same travel rect (half a cap in from each end), so the cap line, the meter segment and the scale tick agree. Scale labels print in priority (0, the 12 dB ladder, then ±6) only where they clear each other. - Value row — the live fader value under the fader (mono, with the
(declared)tail when it has parted from themixer { … }block; hover:Fader level … dB) and, right-aligned at the strip's edge, the meter's held peak keyedPKin caption ink (alert at ≥ −0.1 dB; hover:Peak hold … dBFS). On a strip too narrow for both the key drops first, then the peak. - Keys —
M,Sand the ❄ freeze chip in one row.Mfills with the alert colour when the track itself is muted and is outlined in it when a solo or VCA mutes it;Sfills with the theme'sacc(). - Sends (the Channel inspector's, and the strips' when expanded or in a tall enough pane) — a bar per bus, bus name inside, dB at the right; drag writes the send on release, double-click for off. A send riding a macro names it and refuses the drag.
Master is the last strip on the right, with the VCA strips just left of it and a hairline seam between them and the channel bank. The bank is only as wide as its strips (bank_width), so a short song packs the control section against its last channel and the spare desk sits right of Master; a long one takes the width and scrolls. The header counts N channels + master, the same census Scopes prints. Master has the same band/fader/meter/scale geometry, its DIM/MONO keys where a channel's pan pot sits (so every fader's travel lines up), and a tabular readout column: a Loudness · LUFS head over MOM/SHORT/INT, then PEAK (true peak max, … dBTP, alert above −1 dBTP), REF (the declared master { refLevel } operating level, … dB; display only) and FADER (the master fader, the declared level) at its foot — keys in operational ink, values right-aligned in the readout face, each row named in full on hover (Momentary / Short-term / Integrated LUFS, True peak dBTP) — then CORR/SUB. A VCA strip is tagged VCA before its name, has no meter (no audio passes through it), and says controls N tracks at its foot; its band hover lists the members.
The level meter is one shared painter, ui/src/widgets.rs level_meter(painter, rect, theme, &LevelMeter) (a thin wrapper over kit::meter, whose default bar recipe this paragraph describes), built with LevelMeter::new(level_db).peak(db).hold(db).clip(MeterClip::…) plus .range(floor, ceil) (default −60…+6), .horizontal(), .zones(MeterZones) (default −18/−6/0) and .smooth() (default is LED segments: 2 pt lit, 1 pt gap). It paints the whole ramp as ghost segments (so the zones read in silence), the peak span translucent, the body solid with each segment coloured by its own dB, a blurred glow at the head on dark themes, a peak-hold tick, a 0 dB hairline and the clip lamp. MeterPalette::of(theme) derives the ramp from tokens: cool = the theme's pri/sec/acc that is chromatic and nearest teal within hue 75–285° (else --audio), warm = an amber accent (hue 28–58°) if the theme has one (else --control), hot = alert(). Both fallbacks are already darkened for light families, and widgets::tests::every_theme_meter_ramp_is_distinct_and_visible_on_its_well pins that each zone clears 1.8:1 on --kwell in every theme. The painter allocates nothing — callers own the hover and accessibility node. The CORR /SUB correlation bars (ui/src/phase.rs) use the same palette's cool colour and well.
Phase + reference (the Monitor view's two strips)#
Above the bus grid, Scopes carries two strips that read the viz frame rather than the tap rings — so they keep working when no bus is tapped:
PHASE(ui/src/phase.rs) — a card spanning the row: the goniometer (45°-rotated L/R Lissajous, ~24 frames of scatter with an age fade) beside four−1 … +1correlation bars that take the rest of the width:WIDE,<120,MID,HIGH, each with a needle, a zero rule, a slow-release worst-case hold tick, and a signed numeric readout. The mixer MASTER strip carries the same meter compacted to two rows (CORR,SUB). The<120bar is the actionable one — see Mixing tracks and buses §6.6. Accessible names:correlation <band> <value>andgoniometer · N points; a frame with no phase data readscorrelation <band> unavailablerather than a confident+0.00.REFERENCE A/B(ui/src/app/reference.rs) —load reference…opens a file picker; once loaded the strip shows the file name, its LUFS / dBTP, and the computed match gain, beside a◇ MIX/◆ REFflip chip (accessible namereference A/B). The flip is alsoTransport ▸ Reference A/Band ⌘⇧R from any view (CommandId::ToggleReferenceAb); all three route through onetoggle_reference. Flipping in plays the reference soloed over a ducked master, loudness-matched to the session — so the analyser, the bus tiles and the phase meter above all show the reference. The chosen file is remembered per song in the shell state. See Mixing tracks and buses §6.7.
Pads#
View ▸ Sound Views ▸ Pads (route pads) shows the song's sample hits as a pad grid with an inspector and a step row, every gesture one guarded source edit. The whole contract is Play, trim and step the song's samples on pads.
How it's implemented#
- Motion —
ui/src/juice.rsholds the policy (Motion, published by the shell each frame from Reduce motion andmotion_intensity) and the shared curves: transport-clockeddecay/beat_accent, wall-clock one-shots (fire→pulse/progress/shake), andspring. The scene countdown isui/src/scenefill.rs(purelanding_beat/fill/stepped_fill, and the paint), stamped and published once per frame byui/src/app/scene_queue.rs; the mute/solo dim isui/src/mutedim.rs(level, keyed by track name;GroupBoard::solo_cutsays which lanes a solo silenced). Value controls route throughui/src/valuefeel.rs(dragwith detents,shown,readout,reset); Run feedback, the change glow (EditorUi::glow_spans/glow_changewith aGlowKindofOwn,AgentorError) and the error dissolve (note_analysis, called byset_analysis) areui/src/editor/runjuice.rs, queued by callers that hold noContextand fired at the editor's next paint.volta-shot --drivecaptures motion withburst <n> <prefix>and holds a drag open withpress/release. Panel, view and window transitions areui/src/juice/transition.rs:open_amount(egui'sanimate_bool_with_time_and_easingunder the policy),reveal/reveal_begininside a panel's frame (a registry keyed by surface id finds the frame a surface first paints; the glide translates the shapes the body painted, the veil is one rect, and a sublayer the body painted into — anegui::Scenecanvas — fades with it), andend_frame(called by the shell after every panel) for closing veils.ui/src/surfaces.rsfades eachWindow's own layer and keeps a snapshot of its last frame to fade out on a paint-only layer after it closes.
- Density & text roles —
ui/src/theme.rs:Density/DensityMetrics, the role accessors, and the contrast gates (below). Themes come fromdesign/tokens/colors.css, parsed at build time; a rule may name several themes with a selector list (the SHAPE section does), and every rule that names a theme applies in file order. - Backdrop —
ui/src/signal.rsowns feature normalization, event edges, beat extrapolation, and inertia;ui/src/backdrop.rsownsIntensity, the curated scene metadata/recipes, GPU contracts, and veil composition math; fragment scenes and shared effects live inui/src/shaders/presets.wgsl, with Liquid's shared effect stage inviz/src/shaders/composite.wgsl. Composited byApp::update. The motion preferences are applied at exactly two seams:backdrop::motion_features(audio motion, plus reduce motion on the fragment path only —frag_paramspasses it,liquid_framepassesfalse) andVoltaUi::advance_backdrop_visual_clock, whosevisual_*lanes are the pausable fragment clocks and whosebeats/cycle_phaseare Liquid's. - One pipeline per preset —
presets.wgslholds every fragment scene behind onerender_presetswitch, but a pipeline is built for a single scene:backdrop::specialize_fragrewrites the dispatch into that preset's direct call and drops the crossfade branch, so naga strips the other bodies before the platform compiler sees them. This is not a micro-optimization — an entry point is register-allocated for the worst path through it, so the full dispatch cost ~12 ms/frame at 2760x1800 on an M1 Max where the one scene being painted costs ~0.2 ms (measured withVOLTA_SHOT_PERF=1volta-shot). Specialized pipelines build lazily in the callback'sprepareand are cached for the session; the generic pipeline stays for appearance handoffs, which need two scenes in one draw. New presets must be added inside the// SPECIALIZE:DISPATCH:markers. Regression:backdrop::tests::every_preset_specializes_into_a_valid_module. - Shell, commands & transport —
ui/src/actions.rsowns the product command registry;ui/src/app/header.rsrenders the main bar's panel (top_bar), the navigation strip (nav_strip) and the signal line;ui/src/app/transport.rsthe main bar's contents (main_bar, the pickers);ui/src/app/hero.rsthe hero readout;ui/src/app/workflow_transport.rsthe contextual cluster (transport_context).ShellCompositionresolves destination, viewport, lesson state, Mixer mode, and whether the song outline has content (any declaration, macro, hole or lane).COMPACT_SHELL_W = 900switches to one selected surface;INSTRUMENT_SHELL_W = 1440admits the song outline. The middle range keeps Source + Context with a 380 px editor floor. Chrome rows wrap and labels elide as width falls further. Regressions:ui/src/actions.rstests andui/tests/responsive_shell.rs. - Relationship focus —
ui/src/focus.rsowns generation-fenced transient and pinned identity; Source, Timeline, Mixer, and Rack only project it. Regression: focused unit and cross-surface behavior tests. - Rack fit —
ui/src/rack.rs:FIT_ZOOM_FLOOR: f32 = 0.6, scaled by density asFIT_ZOOM_FLOOR * (target_h / 24.0).clamp(1.0, 1.35)(so Stage raises it to 0.8; it never drops). Depth columns wrap into 1–3 bands scoredzoom × visible-fraction ÷ 1.15^(bands−1); column/row gaps are bisected to fill the viewport at the chosen zoom — node bodies never shrink, spacing is the only free variable. Regression: sixrack.rsunit tests plusui/tests/rack_graph_fit.rs. - Monitors —
ui/src/scopes.rs(states, focus/overview gesture) andui/src/lens.rs(the Lens panel: key, chord and tension from the compiled song's harmony analysis over Tonnetz · Voices · Report tabs; see The harmony Lens). Regression:ui/tests/monitor_surfaces.rs,ui/src/surfaces.rs,ui/src/rack.rs,ui/src/benchbay/layout.rs. - Time canvases — Bench piano roll and Timeline in
ui/src/bench.rs/ui/src/app/rightcol.rs: roll columns expand from a 16 px readable floor to 64 px, sparse patterns center in an editing lane, dense patterns hold the floor and scroll. Timeline defaults to fit-to-width; a panel under 520 px reduces its gutter from 110 px to 82 px before sacrificing the time canvas. Track headers stay pinned while the time axis scrolls. Follow keeps the playhead visible — it pages the window on as the song crosses into the next cycle window, and the readout then readsbars 17–32. Pausing it takes a deliberate gesture on the TIME axis: a horizontal pan of more than 2 px, Shift-scroll, or zoom. Scrolling the lanes vertically — the ordinary gesture on a tall track list, and never perfectly vertical on a trackpad — leaves Follow armed. While Follow is paused and the playhead has left the shown page, the window readout says where it is (bars 1–16 · playhead at bar 37). Previous/Next inspect adjacent cycle windows. Fit timeline restores the full window. Zoom reveals a musical grid down to sixteenths. Cycle notes, scene bands and tempo curves share the same meter-aware bar axis. Track phase comes from the engine, including entry-relative phrases, anchors, one-shot phrases and custom clocks. Map preserves fractional scenes and transitions across passes; graphs show states and decisions. Cycles keeps live patterns, chooser alternatives and played notes. Automation/history fold away. See Arrange a song. A track header readskickTovervia kickV— the second line is the patch the track plays through, never a second name. Its first control is the track's own play/stop:▶while stopped, a boxed■in the lane colour while it plays. The MASTER strip readsLUFS M · S · I · TP … dBTP(Momentary / Short-term / Integrated LUFS, True peak dBTP; the hover spells them out). Lanes that overflow fade at the edge under a▾ N more rows · scrollplate, and the patch inspector can never be dragged above a floor that keeps the lanes' header plus about four rows. In Map, a clip is named by its pattern binding (bassPat); an anonymous literal reads as a summary (5 hits / 16 steps,2 chords alternating), with the source in the hover. Bar numbers step by 1, 2, 4, 8 or 16 bars to fit the zoom. A section band keeps its scene name and shows its+in −outbadge only when both fit (the hover lists who enters and leaves). Notes that were played but not placed by the plan (a manual scene, a launched track) are drawn in the lane colour inside a dashedlivebox, never as bare marks in an empty section. - Live ownership — the held set is read off the engine frame's
LV01trailer inui/src/frame.rs(LiveFrame: epoch, pass, launch instance, unwrapped run beat and the owned track indices), so every shell that publishes frames shows it.ui/src/app/workflow_transport.rsresolves those indices to names against the frame's name table;ui/src/app/region_transactions.rsmirrors them onto the Arrange canvas and drains its returns, the same shape the per-lane automation arm already uses — the canvas only draws state and reports a gesture, and the one engine call lives in the shell so exactly one place can fail and say so.EngineBridge::resume_live_scope(Option<&str>)is that call (EngineHandle::resume_arrangement); it refuses by default, so a shell without the scope reports its limit instead of looking as though it worked. Regression:ui/tests/live_ownership.rsdrives the whole workbench against a realAudioEngineand its actual viz frames. - Canvas fill —
ui/src/bench.rsclamps notebook cell width betweenCELL_MIN_W: f32 = 880.0andCELL_MAX_W: f32 = 1160.0; a wide notebook reserves a dedicated left outline rail rather than floating that rail over the page.ui/src/widgets.rsempty_state(…)is the one composed empty-state block (centered, glyph + tracked-caps headline + reading-width body + chip actions, exposed to AccessKit as one labeled group), used by Shelf, Community and Copilot. Mixer defaults to a resizable bottom dock. Channels retain one horizontal order and fixed strip width; Master stays in a reserved right rail. A floating channel inspector leaves strip positions unchanged. Fader columns take whatever height the strip chrome leaves (72–260 pt docked, up to 380 pt expanded); below the floor the channel bank scrolls vertically rather than clipping the strip foot. Strip columns are ~112 pt pitch, as many as fit. Savedlibrarylayout=2distinguishes deliberate expansion from the legacy forced full-screen preference, which migrates to docked. Reference begins with a Symbols search gateway rather than eagerly painting the entire builtin catalogue, and the Learn landing resolves to a responsive 3/2/1-column signal-path map. - Accessibility — density, backdrop, Timeline zoom, Scopes states and focus actions, and Routes' numeric editing and source-navigation controls are all present in the AccessKit tree.
Layout and hit-testing under egui#
The workbench is an immediate-mode egui UI, and several of its layout decisions follow from how egui places and hit-tests widgets.
- Rows overlap rather than reflow. A too-narrow egui row overprints its neighbours instead of wrapping, so chrome rows chunk themselves by measured width, and
horizontal_wrappedcannot wrapFramecards (their width is unknown until the frame closes). Anything docked after a wide scrolling row lands off the viewport, which is why dock inspectors and VCA rows sit on the left and master sections on the right.ui/tests/responsive_shell.rspins the stable rails inside the shell at each tested width (assert_inside). - Panes are budgeted against the space that is left.
resource_docks(ui/src/app/library_tools.rs) claims its docks first, andshell_composition(ui/src/app/shell.rs) then resolves the layout from the width that remains, so Source, Copilot, Sounds and the Mixer dock share one budget rather than each sizing from the full screen;right_column(ui/src/app/rightcol.rs) paints into what is left after that. Inside a pane, inner geometry comes from the visible clip width, notui.available_width(): a long source line can widen a notebook card past its clip rect, and an embedded inspector's grid columns follow the clip. In a modal, a child left unconstrained on both axes claims the whole height and leaves zero-height label rows overprinting each other. A horizontal scroll area gives its child an unbounded width, so prose wraps while names and headers extend inside their scroll area instead of wrapping into single-character columns. - A
Frameinherits its parent's layout. A frame inside a horizontal parent (a sidebar row, a knob card) entersLayout::top_downexplicitly, or its rows sit side by side;SidePanel::exact_widthstill grows to fit, and content sized fromavailable_widthwould then widen every frame. - Layer order decides what is reachable. egui raises a clicked
Areato the top of its order, so a scrim sits at a lowerOrderthan its dialog (or the dialog is a modal) and the card is moved to the top — the first-run card does this withctx.move_to_top(ui/src/app/welcome.rs). AWindowatOrder::Middlebeneath body views at the same order is unreachable by the wheel until a click raises it. A passiveAreastill blocks: eveninteractable(false)with non-selectable labels registers hover rectangles that exclude lower layers from hit-testing, so the completion hint card above the editor toolbar is painted without widgets (ui/src/editor/completion.rs). - Registration order decides who owns a rect and who owns a name. The first
ui.interacton a shared rect takes every click, so a resize handle inside a body registers after the body; registered first, it is findable by a test but unreachable by the pointer (hovered()stays false while the pointer is inside). Such a handle keeps a minimum body width so the two grips never meet.widget_infoprecedeson_hover_text, which registers its own info, or the node has no accessible name. - Chips hold their place. A chip that can become unavailable stays on its rail and is disabled with
add_enabled_uirather than removed, so its neighbours never shift under the pointer or under a positional behaviour test (the chip family is inui/src/widgets.rs).
Contrast gates#
Three tests in ui/src/theme.rs run over every theme in the table:
| Test | Requires |
|---|---|
every_theme_carries_the_chip_tiers | readout ink ≥ 3.0:1 on screen; tier-1 armed ink ≥ 4.5:1 on pri(); tier-1 idle ink ≥ 4.5:1 on screen; readout never louder than body ink |
work_veil_keeps_text_legible_over_a_worst_case_field | under the Work scrim, over the worst field pixels (white, pri(), acc()): ink() ≥ 4.5:1 and operational() ≥ 3.0:1 |
light_themes_carry_legible_accents | every light theme clears its per-token accent floor on both screen tones (--sc0 cards and --sc1 panels) |
Notes & limits#
operational()holds 3.0:1, not 4.5:1. Native Ui Legibility Hierarchy specified 4.5:1 for the secondary role; what shipped is a 3.0:1 floor, becauseoperationalis secondary by design and has no headroom by construction — promoting it to 4.5:1 flattens the instrument hierarchy the same spec exists to protect.ink()carries the 4.5:1 text contract. This is a deliberate as-built divergence, recorded in the test's own doc comment.- Composition is semantic, then responsive. Width alone does not decide whether the song outline, Source, or Context appears. Browse/reading surfaces use contextual tools or a canvas appropriate to their task; active lessons and sound inspectors use Split; only a wide companion view of a song with declarations becomes Instrument. The two numeric gates are 900 px (one surface) and 1440 px (song outline eligible).
- Rack's 0.6 fit-zoom floor is deliberate. A graph that cannot fit at 0.6× clips rather than shrinking further; wrapping into bands is the escape hatch. Do not "fix" it by lowering the floor.
- The Lens asserts candidates, not facts. It reports a triad candidate and an energy center, and relates the candidate only to the key the source DECLARES (
KEY B minor · declared), never a detected one. The native visualization frame carries current pitch classes, not enough harmonic history to assert a key or chord — the wording is intentional and should not be tightened. - Source remains authoritative. Rack wiring, module edits and Routes' literal depths use guarded source transactions. Compiled readouts remain projections of that source; see Shape modulation in Routes.
- Not yet shipped (tracked in Native Ui Canvas Composition): the two-column Bench flow for short cells at ≥1500 px.
- Custom-painted surface accessibility is incomplete. Audit Phase 5 (Rack/Routes/Scopes/Lens summaries, keyboard traversal, focus tooltips, a VoiceOver pass) and Phase 6 (three-profile validation gate + frame-time budget) remain open. Native AccessKit coverage also does not imply screen-reader access to the browser canvas editor — see Ide Browser Native Parity.
- Verify UI changes headlessly with
volta-shot, never macOS screencapture; any new paint that iterates aHashMapmust sort first to preserve the byte-identity capture contract.