This document records the current state of the Rivulet desktop UI and how the project audits it. It is the living counterpart to the one-shot M2 UI/UX review gate: where that gate produces a snapshot report per milestone, this file describes the ongoing contracts that keep the UI accessible, deterministic, and coherent between milestones.
The audit covers the rivulet-gui (egui/eframe) application: navigation between
the primary views (Record, Scenes, Settings, Diagnostics, ...), theme rendering,
interaction states, accessibility semantics, and the privacy of UI reports. It
is deliberately separate from audio/capture correctness, which is covered by the
engine tests and the streaming smoke tests.
The shared theme module (rivulet-gui/src/theme.rs) implements the visual layer:
- Glassmorphism panels —
theme::glass_frame()renders translucent panel surfaces for the modern, layered look. - Interaction strokes —
theme::hover_stroke()(1.5 px accent at ~60 % alpha) andtheme::active_stroke()(2 px opaque accent) highlight hover/focus and active/pressed controls without overriding egui's disabled visuals. - Preview fade-in —
theme::preview_fade_alpha()wrapsctx.animate_boolso game-preview tint fades in smoothly; the Windows and Linux preview renderers usefrom_white_alphaso the alpha applies consistently across platforms.
These are covered by the theme unit tests in rivulet-gui/src/theme.rs.
The following tests enforce stable UI contracts and run on every CI push in the
Build & Test matrix:
- Automated accessibility — two contracts guard the AT story:
- the confirmation dialog warehouse (
draw_confirmation_modal) renders every destructive action (composition source delete, audio source remove, chat account remove) behind a modal whose Confirm button uses the error palette, whileEsc/backdrop click/Cancel discard it — covered insubject()/ConfirmDecisionstyle tests inrivulet-gui/src/app.rs, - the focus-order contract (
tests/ui_accessibility.rs) asserts that the navigation sidebar renders every view as a focusable, focus-ring drawnselectable_labelin document order.
- the confirmation dialog warehouse (
- UI smoke (
rivulet-gui/tests/ui_smoke.rs) — verifies that- navigation covers all primary views and lists them through a stable
AppView::all()contract, - keyboard handlers do not fire global shortcuts while a text field is focused,
- diagnostics are surfaced in the GUI,
- the deterministic screenshot report redacts secrets (stream keys, ingest URLs, internal secret-field names),
- an accessibility contract requires labels, focus, and contrast.
- navigation covers all primary views and lists them through a stable
- UI regression (
rivulet-gui/tests/ui_regression.rs) — deterministic viewport/interaction snapshot contracts that are independent of the host font and GPU, so visual output stays stable across machines. - UI accessibility (
rivulet-gui/tests/ui_accessibility.rs) — a stable accessibility report that asserts semantic contracts for the primary workflows, including the AccessKit bridge contract (screen_reader_bridge_enabled): the native AT bridge compiles in on non-Linux targets (Windows UIA/Narrator, macOS AX/VoiceOver) via the target-gatedaccesskiteframe feature, and stays off on Linux only because accesskit_unix talks zbus from a foreign thread next to the Tokio reactor (documented inrivulet-gui/Cargo.toml).
scripts/check-theme-contrast.py checks the status palette against WCAG AA in
both dark and light themes and runs in the CI lints job.
For a milestone gate, run the full procedure in m2-ui-ux-review.md (workflow matrix W01-W15, heuristics H01-H14, test matrix P1-P5). For the ongoing/continuous audit, run the following before each feature commit that touches the GUI:
cargo fmt --all -- --checkcargo clippy --workspace --all-targets -- -D warningscargo test -p rivulet-gui --test ui_smoke --verbosecargo test -p rivulet-gui --test ui_regression --verbosecargo test -p rivulet-gui --test ui_accessibility --verbosepython3 scripts/check-theme-contrast.py
Manual spot checks remain necessary for real-target rendering (fonts, DPI scaling, compositor behavior) that the headless contracts cannot fully replace.
| ID | Severity | Area | Finding | Tracker / owner |
|---|---|---|---|---|
| ui-001 | Low | Accessibility scanning | No automated accessibility scan of the rendered UI on every PR; currently only the in-process accessibility report and contrast checker run | Enable a GitHub accessibility/automated scan app (see docs/security.md / GitHub app enablement); owner: maintainer |
| ui-002 | Low | Scaling | Text-scaling (125 %/150 %) was verified in the M2 gate but is not yet a CI contract | Consider extending ui_regression snapshots; owner: maintainer |
| ui-003 | Info | UX audit v0.65a | Screen readers (Windows/Mac) had no native AT bridge: AccessKit was unconditionally disabled | Closed — AccessKit now compiles in on non-Linux targets (target-gated accesskit feature) |
| ui-009 | Low | Accessibility platform | Linux has no native AT bridge: accesskit stays disabled on Linux because accesskit_unix talks zbus from a foreign thread next to the Tokio reactor (documented in rivulet-gui/Cargo.toml); screen-reader users on Linux get no native semantics |
Revisit once upstream accesskit addresses the zbus/foreign-thread constraint; owner: maintainer |
Open findings are intentionally low to medium severity; they track ongoing hardening rather than known release blockers. When a finding is fixed, move it below under "Closed findings" with the commit/branch that resolved it.
| ID | Severity | Area | Finding | Resolved by |
|---|---|---|---|---|
| ui-003 | Info | UX audit v0.65a | Screen readers (Windows/Mac) had no native AT bridge: AccessKit was unconditionally disabled | rivulet-gui/Cargo.toml target-gated accesskit feature for cfg(not(target_os = "linux")) |
| ui-005 | Medium | Motion | No prefers-reduced-motion handling: theme::preview_fade_alpha (animate_bool) and the scene-transition request_repaint_after(16 ms) loop animate regardless of the OS motion setting |
theme::MotionPreference (System/Full/Reduced, persisted) + Settings selector; theme::os_prefers_reduced_motion() reads the OS setting (Windows SystemParametersInfoW, macOS defaults read, Linux gsettings; cached, re-probed every 10 s); reduced motion zeroes egui's animation_time (all animate_bool fades snap) and collapses scene-transition Fades to Cuts. Note: egui 0.36 has no options().reduced_motion yet — the preference is applied manually via theme::apply_motion |
| ui-004 | Info | UX audit v0.65a | Destructive actions (source delete, audio source remove, chat account remove) ran immediately without confirmation | PendingConfirmation + draw_confirmation_modal in rivulet-gui/src/app.rs; Esc/backdrop/cancel discard, confirm uses error palette |
| ui-006 | Medium | Contrast (widget states) | Accent-background text contrast in the active button state fell below WCAG AA: light-gray text on the teal active fill measured ~2.68:1 (dark) / ~3.49:1 (light), and check-theme-contrast.py only checked the status palette against the panel fill |
Deepened the widgets.active/widgets.hovered fills to teal 800 with a pinned light widget text (6.34:1 / 5.68:1, both schemes) in rivulet-gui/src/theme.rs; check-theme-contrast.py now parses the fill/text consts, mirrors linear_multiply exactly, and enforces the pairings (--self-test guards the mirror math) |
| ui-007 | Low | Feedback | Errors surfaced only inline in the owning view (e.g. scene_status, last_error); a failure raised in a background tab stayed invisible until the user navigated there |
RivuletApp::collect_global_issue aggregates the view-local status fields (typed by severity: Error > Warning > Info, ties to the earliest view); the sidebar footer shows the current issue with a severity prefix and expires after 30 s; clicking it jumps to the owning view. Inline displays stay untouched (additional mirror, not a replacement) |
| ui-008 | Low | Target size | Icon-only buttons (⚙, 🗑) use egui's default interact_size.y = 18, which can undershoot the WCAG 2.2 minimum target size of 24×24 CSS px |
theme::MIN_TARGET_SIZE (24 px) applied as the global spacing.interact_size.y floor in theme::init (both schemes — covers Button, DragValue, Slider, Checkbox, ComboBox, collapsing headers); new theme::icon_button pins an explicit 24×24 min_size for glyph-only controls (the glyph is smaller than any floor, and egui's .small() variant skips the floor entirely — the ⚙/🗑/↩/✕ buttons were migrated to it). Tests allocate real buttons via ctx.run_ui and assert the 24 px rects |