Skip to main content

max / audiofiles

UX audit fixes: payment errors, trim handles, onboarding theme From the pre-launch ux-audit pass. Verified beta-blockers + clean wins: - Sync payment path: subscribe()/queue_cap_change() Err arms now set last_error; sync_panel clears checkout_loading the moment an error surfaces, so the error banner + Retry replace the 30s grey-out. - Editor: draggable trim handles on the waveform (8px hit area, resize cursor, hover thickening); only the dragged handle moves; playhead now renders while paused. - Onboarding: apply_theme runs at the top of AudioFilesApp::ui so Activation/VaultSetup are themed from frame one (no first-run snap). - Export wizard added to the Escape back-out chain. - Default theme fg_muted #707070 -> #858585 (WCAG AA on all surfaces). - Drop dangling peak separator; remove stale emoji-exception comment. 806 tests green, 0 warnings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-06 00:48 UTC
Commit: f0fa1abf6eba06e7c384a40e510198b9b95e1dae
Parent: f36eac9
8 files changed, +96 insertions, -41 deletions
@@ -436,6 +436,15 @@ impl eframe::App for AudioFilesApp {
436 436 fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
437 437 let ctx = ui.ctx().clone();
438 438 let ctx = &ctx;
439 +
440 + // Apply the audiofiles theme every frame, before any screen draws. The
441 + // browser draw path also applies it, but onboarding (Activation / Vault
442 + // setup) renders before a browser exists — without this, first-run paints
443 + // on stock egui dark and then visibly snaps to the theme once the browser
444 + // loads. Applying here uses the global theme (the audiofiles default until
445 + // the browser loads a saved choice), so every screen is themed from frame 1.
446 + theme::apply_theme(ctx);
447 +
439 448 // Pump GTK events so libappindicator (tray) stays responsive on Linux.
440 449 #[cfg(target_os = "linux")]
441 450 if self.gtk_ok {
@@ -245,9 +245,11 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
245 245 | ImportMode::TagFolders { .. }
246 246 | ImportMode::ConfigureAnalysis { .. }
247 247 | ImportMode::ReviewSuggestions { .. }
248 + | ImportMode::ConfigureExport { .. }
248 249 | ImportMode::ReviewErrors
249 250 ) {
250 - // Safe import-wizard screens (no in-flight work) — Escape backs out.
251 + // Safe wizard screens (no in-flight work) — Escape backs out. This
252 + // covers both the import wizard and the export configuration screen.
251 253 // Active modes (Importing/Analyzing/Cleaning/Exporting) require the
252 254 // explicit Cancel button to avoid losing in-progress work.
253 255 state.cancel_import();
@@ -69,26 +69,20 @@ pub fn draw_edit_window(ctx: &egui::Context, state: &mut BrowserState) {
69 69 /// Waveform display with playback cursor and click-to-seek.
70 70 fn draw_waveform_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) {
71 71 if let Some(ref waveform_data) = state.selected_waveform {
72 + // Show the playhead whenever this sample is the active preview and a
73 + // buffer is loaded — including while paused, so the user can see where
74 + // playback sits before auditioning a trim.
72 75 let playback_pos = if state.previewing_hash.as_deref() == Some(hash) {
73 76 let playback = state.shared.preview.lock();
74 - if playback.playing {
75 - if let Some(ref buf) = playback.buffer {
76 - let total_frames = if playback.streaming {
77 - playback.total_frames_estimate.unwrap_or(playback.decoded_frames)
78 - } else {
79 - buf.data.len() / 2
80 - };
81 - if total_frames > 0 {
82 - Some((playback.position_frac / total_frames as f64) as f32)
83 - } else {
84 - None
85 - }
77 + playback.buffer.as_ref().and_then(|buf| {
78 + let total_frames = if playback.streaming {
79 + playback.total_frames_estimate.unwrap_or(playback.decoded_frames)
86 80 } else {
87 - None
88 - }
89 - } else {
90 - None
91 - }
81 + buf.data.len() / 2
82 + };
83 + (total_frames > 0)
84 + .then(|| (playback.position_frac / total_frames as f64) as f32)
85 + })
92 86 } else {
93 87 None
94 88 };
@@ -123,22 +117,27 @@ fn draw_waveform_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str
123 117 overlay,
124 118 );
125 119 }
126 - // Boundary markers reinforce the cut points when the overlay is
127 - // ambiguous (e.g. nearly-zero trim where the dimmed strip is thin).
128 - let stroke = egui::Stroke::new(1.0, theme::accent_yellow());
129 - if trim_start > 0.0 {
130 - let x = rect.left() + rect.width() * trim_start;
131 - painter.line_segment(
132 - [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
133 - stroke,
134 - );
120 + }
121 +
122 + // Draggable trim handles drawn over the waveform. These are the primary
123 + // way to set the cut region; the Start/End sliders below are the numeric
124 + // path. Handles are always present (even at the 0.0/1.0 default) so the
125 + // user can grab either edge directly.
126 + {
127 + let rect = resp.rect;
128 + let (new_start, start_dragged) =
129 + trim_handle(ui, rect, state.edit.trim_start, "edit_trim_handle_start");
130 + let (new_end, end_dragged) =
131 + trim_handle(ui, rect, state.edit.trim_end, "edit_trim_handle_end");
132 + // Only the handle the user is actually dragging moves; it clamps
133 + // against the other so the untouched handle never jumps (and a
134 + // minimum region is preserved).
135 + const MIN_GAP: f32 = 0.001;
136 + if start_dragged {
137 + state.edit.trim_start = new_start.clamp(0.0, state.edit.trim_end - MIN_GAP);
135 138 }
136 - if trim_end < 1.0 {
137 - let x = rect.left() + rect.width() * trim_end;
138 - painter.line_segment(
139 - [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
140 - stroke,
141 - );
139 + if end_dragged {
140 + state.edit.trim_end = new_end.clamp(state.edit.trim_start + MIN_GAP, 1.0);
142 141 }
143 142 }
144 143
@@ -164,6 +163,36 @@ fn draw_waveform_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str
164 163 }
165 164 }
166 165
166 + /// Draw a draggable trim-boundary handle over the waveform at `frac` (0..1).
167 + /// Returns `(new_frac, dragged)`. The hit area is wider than the painted line
168 + /// (Fitts) so it is easy to grab, the cursor switches to a horizontal resize on
169 + /// hover, and the line thickens while hovered or dragged for tactile feedback.
170 + fn trim_handle(ui: &mut egui::Ui, rect: egui::Rect, frac: f32, id_salt: &str) -> (f32, bool) {
171 + let frac = frac.clamp(0.0, 1.0);
172 + let x = rect.left() + rect.width() * frac;
173 + let hit = egui::Rect::from_min_max(
174 + egui::pos2(x - 4.0, rect.top()),
175 + egui::pos2(x + 4.0, rect.bottom()),
176 + );
177 + let resp = ui.interact(hit, ui.id().with(id_salt), egui::Sense::drag());
178 + let active = resp.hovered() || resp.dragged();
179 + if active {
180 + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
181 + }
182 + let mut new_frac = frac;
183 + if resp.dragged() {
184 + if let Some(p) = resp.interact_pointer_pos() {
185 + new_frac = ((p.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
186 + }
187 + }
188 + let width = if active { 2.5 } else { 1.0 };
189 + ui.painter_at(rect).line_segment(
190 + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
191 + egui::Stroke::new(width, theme::accent_yellow()),
192 + );
193 + (new_frac, resp.dragged())
194 + }
195 +
167 196 /// Info line: name, sample rate, duration, peak dB.
168 197 fn draw_info_line(ui: &mut egui::Ui, state: &BrowserState) {
169 198 if let Some(ref analysis) = state.selected_analysis {
@@ -172,14 +201,16 @@ fn draw_info_line(ui: &mut egui::Ui, state: &BrowserState) {
172 201 .unwrap_or_default();
173 202 let duration = analysis.duration;
174 203 let sr = analysis.sample_rate;
175 - let peak_str = analysis.peak_db
176 - .map(|p| format!("{:.1} dBFS", p))
204 + // Only append the peak segment (and its separator) when a value exists,
205 + // so a missing peak_db doesn't leave a dangling " \u{00B7} ".
206 + let peak_suffix = analysis.peak_db
207 + .map(|p| format!(" \u{00B7} {:.1} dBFS", p))
177 208 .unwrap_or_default();
178 209
179 210 ui.horizontal_wrapped(|ui| {
180 211 ui.label(egui::RichText::new(&name).strong().size(12.0));
181 212 ui.label(
182 - egui::RichText::new(format!("{} Hz \u{00B7} {:.3}s \u{00B7} {}", sr, duration, peak_str))
213 + egui::RichText::new(format!("{} Hz \u{00B7} {:.3}s{}", sr, duration, peak_suffix))
183 214 .color(theme::text_muted())
184 215 .size(11.0),
185 216 );
@@ -152,6 +152,16 @@ fn draw_subscription_section(
152 152 state.status = "Checkout timed out. The browser tab may have closed; try again.".to_string();
153 153 }
154 154 }
155 + // If the checkout / cap-change call reported an error, the sync manager surfaces
156 + // it in `last_error` (shown by the error banner above). Clear the checkout
157 + // loading flag immediately so the Subscribe / cap-change button re-enables for a
158 + // retry, rather than staying greyed out until the 30s timeout above. (Only the
159 + // checkout flag — subscription fetch failures don't set `last_error`, so a
160 + // general sync error must not interrupt the "Checking subscription..." spinner.)
161 + if sync_status.last_error.is_some() && state.sync.checkout_loading {
162 + state.sync.checkout_loading = false;
163 + state.sync.checkout_loading_at = None;
164 + }
155 165
156 166 // Trigger initial fetch if not yet loaded
157 167 if sync_status.subscription.is_none() && !state.sync.subscription_loading {
@@ -132,7 +132,7 @@ impl Default for ThemeColors {
132 132 bg_surface: Color32::from_rgb(0x05, 0x05, 0x05),
133 133 fg_primary: Color32::from_rgb(0xff, 0xff, 0xff),
134 134 fg_secondary: Color32::from_rgb(0xd0, 0xd0, 0xd0),
135 - fg_muted: Color32::from_rgb(0x70, 0x70, 0x70),
135 + fg_muted: Color32::from_rgb(0x85, 0x85, 0x85),
136 136 accent_red: Color32::from_rgb(0xff, 0x3b, 0x30),
137 137 accent_green: Color32::from_rgb(0x30, 0xd1, 0x58),
138 138 accent_blue: Color32::from_rgb(0x0a, 0x84, 0xff),
@@ -11,9 +11,6 @@
11 11 //!
12 12 //! * Sort-direction arrows (`U+25B2`, `U+25BC`) in `file_list.rs::draw_sort_header`
13 13 //! — functional column-header affordance, no word equivalent fits the layout.
14 - //! * File-tree node-type prefixes (`U+1F4C1` folder, `U+2601` cloud, `U+1F50A`
15 - //! speaker) in `file_list.rs::draw_name_column` — visual hierarchy for the
16 - //! table primary column; revisit during the Phase 3 surface audit.
17 14 //! * Typography (em-dash `U+2014`, right-arrow `U+2192`, middle-dot `U+00B7`,
18 15 //! bullet `U+2022`) in prose — these are punctuation, not emoji.
19 16 //!
@@ -13,7 +13,9 @@ surface = "#050505"
13 13 [foreground]
14 14 primary = "#ffffff"
15 15 secondary = "#d0d0d0"
16 - muted = "#707070"
16 + # muted lifted from #707070 (~4.2:1 on the black surface, under WCAG AA) to
17 + # #858585 (~5.7:1 on #000, >=4.7:1 on the #1a1a1a tertiary surface).
18 + muted = "#858585"
17 19
18 20 [accent]
19 21 red = "#ff3b30"
@@ -382,6 +382,8 @@ impl SyncManager {
382 382 }
383 383 Err(e) => {
384 384 tracing::error!("Failed to create checkout: {e}");
385 + status.lock().last_error =
386 + Some(format!("Could not start checkout: {e}"));
385 387 }
386 388 }
387 389 });
@@ -399,6 +401,8 @@ impl SyncManager {
399 401 }
400 402 Err(e) => {
401 403 tracing::error!("Failed to queue cap change: {e}");
404 + status.lock().last_error =
405 + Some(format!("Could not update storage cap: {e}"));
402 406 }
403 407 }
404 408 });