max / audiofiles
11 files changed,
+3742 insertions,
-0 deletions
| @@ -0,0 +1,162 @@ | |||
| 1 | + | # audiofiles Design System | |
| 2 | + | ||
| 3 | + | **Status:** Charter (phase-0 deliverable, 2026-05-19). Defines the canonical primitive set for the egui UI. Implementations follow during consolidation; see `docs/ux-audit/phase-0.md` for the divergence map this charter is responding to. | |
| 4 | + | ||
| 5 | + | **Scope:** `crates/audiofiles-browser/src/ui/`. Native egui dispatch only — this app has no web layer. | |
| 6 | + | ||
| 7 | + | **Authority:** When this document and an inline implementation disagree, this document wins. New UI code must use a primitive from this charter; if no primitive fits, add one here first and then implement it. | |
| 8 | + | ||
| 9 | + | --- | |
| 10 | + | ||
| 11 | + | ## Layering rules | |
| 12 | + | ||
| 13 | + | | Layer | File | Owns | | |
| 14 | + | |------------------|---------------------------------------|----------------------------------------------------------------------| | |
| 15 | + | | Palette + visuals | `ui/theme.rs` | `ThemeColors`, `apply_theme`, all `Color32` and spacing tokens, theme TOML loading. **No widgets here.** | | |
| 16 | + | | Widgets | `ui/widgets.rs` | Every shared visual recipe (rows, headers, buttons, modals, banners, pills, empty states). | | |
| 17 | + | | Panels / screens | `ui/sidebar.rs`, `toolbar.rs`, etc. | Composition only — call `widgets::*` and `theme::*`; never reinvent. | | |
| 18 | + | ||
| 19 | + | **Hard rules:** | |
| 20 | + | ||
| 21 | + | 1. **No `Color32::from_rgb(...)` outside `theme.rs`.** Test by `grep -rE 'Color32::(from_rgb|WHITE|BLACK|GRAY|RED|GREEN|BLUE|YELLOW|DARK_GRAY)' src/ui/ | grep -v theme.rs` — must be empty. | |
| 22 | + | 2. **No raw `add_space(N.0)` outside `widgets.rs` and `theme.rs`** — use named spacing tokens (§Spacing). | |
| 23 | + | 3. **No inline `selectable_label(active, RichText::new(label).strong().color(accent_blue()))`** — use `widgets::selectable_row` or one of its specialisations. | |
| 24 | + | 4. **No inline `Window::new(...).collapsible(false).resizable(false).anchor(CENTER_CENTER, [0,0])`** — use `widgets::modal_window` or `widgets::confirm_modal`. | |
| 25 | + | 5. **No emoji or checkmark glyphs in user-facing strings** (CLAUDE.md brand rule). Use words. Test by `grep -rE '\\u\{1F[0-9A-Fa-f]{3}\}|\\u\{2[0-9A-Fa-f]{3}\}'` against UI files — flagged glyphs require a documented exception comment. | |
| 26 | + | ||
| 27 | + | --- | |
| 28 | + | ||
| 29 | + | ## Tokens (`theme.rs`) | |
| 30 | + | ||
| 31 | + | ### Colour tokens | |
| 32 | + | ||
| 33 | + | All UI colour reads must go through these accessors. The 15 base slots come from `ThemeColors`; the derived slots are computed at runtime. | |
| 34 | + | ||
| 35 | + | | Token | Kind | Use | | |
| 36 | + | |--------------------|----------|-----------------------------------------------------------------------| | |
| 37 | + | | `bg_primary` | base | Deepest layer (table body, scroll background). | | |
| 38 | + | | `bg_secondary` | base | Panels, sidebars, window fill. | | |
| 39 | + | | `bg_tertiary` | base | Hover background, highlighted regions. | | |
| 40 | + | | `bg_surface` | base | Cards, popovers, tag chips. | | |
| 41 | + | | `fg_primary` | base | Primary text. | | |
| 42 | + | | `fg_secondary` | base | Secondary text, labels. | | |
| 43 | + | | `fg_muted` | base | Placeholders, disabled items, empty-state hints. | | |
| 44 | + | | `accent_blue` | base | Selection/active state. *The* affordance colour. | | |
| 45 | + | | `accent_red` | base | Errors, danger buttons, destructive confirms. | | |
| 46 | + | | `accent_green` | base | Success, ready state. | | |
| 47 | + | | `accent_yellow` | base | Warnings (not yet used as such — reserved). | | |
| 48 | + | | `accent_purple` | base | Reserved (classification palette). | | |
| 49 | + | | `accent_cyan` | base | Reserved (classification palette). | | |
| 50 | + | | `border_default` | base | Separator, panel boundary. | | |
| 51 | + | | `bg_row_even` | derived | Striped row alternation (even). | | |
| 52 | + | | `bg_row_odd` | derived | Striped row alternation (odd) = `bg_primary`. | | |
| 53 | + | | `bg_hover` | derived | = `bg_tertiary`. Shared row/button hover. | | |
| 54 | + | | `bg_selected` | derived | Selection fill (= `lerp(bg_primary, accent_blue, 0.3)`). | | |
| 55 | + | | `classification_color(c)` | domain | Sample-class palette. Stable across themes for muscle memory. | | |
| 56 | + | | `piano_white_key()`, `piano_black_key()` | domain | Instrument panel only. | | |
| 57 | + | ||
| 58 | + | ### Spacing tokens | |
| 59 | + | ||
| 60 | + | Replace every literal `add_space(N.0)` with one of these. New code may not introduce new spacing magnitudes without adding a token here. | |
| 61 | + | ||
| 62 | + | | Name | Value | Use | | |
| 63 | + | |--------------------|-------|--------------------------------------------------------------------| | |
| 64 | + | | `space::xs` | 2.0 | Tight inline (button-icon gap, tag chip internal). | | |
| 65 | + | | `space::sm` | 4.0 | After a label, before its control (52 occurrences today). | | |
| 66 | + | | `space::md` | 8.0 | Default between unrelated controls in a row (58 today). | | |
| 67 | + | | `space::lg` | 12.0 | Between minor sections within a panel (19 today). | | |
| 68 | + | | `space::section` | 16.0 | Between major sections (= existing `section_spacing` token). | | |
| 69 | + | | `space::xl` | 20.0 | Reserved for headline padding in empty states. | | |
| 70 | + | ||
| 71 | + | The existing TOML keys `section_spacing`, `grid_row_spacing`, `item_spacing_x/y`, `button_padding_x/y`, `rounding` remain; the new `space::*` constants live in `theme.rs` as `pub const` and are theme-independent. | |
| 72 | + | ||
| 73 | + | ### Stroke / radius | |
| 74 | + | ||
| 75 | + | | Token | Value (default) | Use | | |
| 76 | + | |--------------------|-----------------|--------------------------------------------------| | |
| 77 | + | | `rounding` | 4.0 (TOML) | All widget corner radii. | | |
| 78 | + | | `border_thin` | 0.5 px | Separators, inactive widget border. | | |
| 79 | + | | `border_default` | 1.0 px | Hovered/active widget border, window stroke. | | |
| 80 | + | | `focus_ring` | 1.5 px `accent_blue` | New token — focus outline on text fields. | | |
| 81 | + | ||
| 82 | + | --- | |
| 83 | + | ||
| 84 | + | ## Widgets (`widgets.rs`) | |
| 85 | + | ||
| 86 | + | The canonical helper for every pattern listed in `docs/ux-audit/phase-0.md`. Names are normative — implementations rename existing inline copies to match. | |
| 87 | + | ||
| 88 | + | ### Rows and selection | |
| 89 | + | ||
| 90 | + | - **`selectable_row(ui, active, label) -> Response`** — single source of truth for every "list item that highlights when active in `accent_blue`." Replaces sidebar VFS rows, collection rows, breadcrumb segments, tag tree leaves, sort headers. Internally: builds RichText with `.strong()` + `accent_blue()` when active; `text_secondary()` otherwise; delegates to `ui.selectable_label(active, …)`. | |
| 91 | + | - **`tree_node(ui, label, active, has_active_descendant, children)`** — for the sidebar tag tree; collapsing header variant of `selectable_row`. | |
| 92 | + | ||
| 93 | + | ### Headers and section structure | |
| 94 | + | ||
| 95 | + | - **`section_header(ui, "Vaults")`** — `.strong()`, `text_secondary()`, followed by `ui.separator()` and `space::sm`. | |
| 96 | + | - **`subsection_label(ui, "Save as Collection")`** — same colour, no separator. For sub-blocks inside an already-headed section. | |
| 97 | + | - **`filter_section(ui, label, active, |ui| { … })`** — `CollapsingHeader` with `"* "` marker when active and `default_open(active)`. Replaces the 5-way duplication in `filter_panel.rs`. | |
| 98 | + | ||
| 99 | + | ### Buttons | |
| 100 | + | ||
| 101 | + | - **`primary_button(ui, label) -> Response`** — bold weight, default fill. Singular per modal/dialog. | |
| 102 | + | - **`secondary_button(ui, label) -> Response`** — current default button. Cancel and peer actions use this. | |
| 103 | + | - **`danger_button(ui, label) -> Response`** — `accent_red` text or fill (TBD during impl). For Delete / Purge / Discard. | |
| 104 | + | - **`toolbar_toggle(ui, glyph_or_word, active, tooltip) -> bool`** — used six times in `toolbar.rs:138–204`. Active state colours via `accent_blue` / `text_muted`. Replace the emoji glyphs with words during the rollout (see brand rule). | |
| 105 | + | - **`icon_button(ui, glyph_or_word, tooltip) -> Response`** — small, non-toggle. (`x`, `+`, settings gear once renamed.) | |
| 106 | + | ||
| 107 | + | ### Pills, chips, badges | |
| 108 | + | ||
| 109 | + | - **`tag_chip(ui, tag) -> Response`** — existing. | |
| 110 | + | - **`tag_chip_removable(ui, tag) -> bool`** — existing. | |
| 111 | + | - **`classification_badge(ui, class)`** — existing. | |
| 112 | + | - **`toggle_pills(ui, current, options: &[(value, label, tooltip)]) -> Option<value>`** — segmented control for mutually-exclusive choices: Folder/All, Exact/Compatible, Add/Remove tag, help tabs. | |
| 113 | + | ||
| 114 | + | ### Inputs | |
| 115 | + | ||
| 116 | + | - **`inline_text_submit(ui, buf, opts) -> SubmitOutcome`** — text field that commits on Enter, cancels on button/Escape. `SubmitOutcome::{None, Submitted(String), Cancelled}`. Used by sidebar rename, sidebar create, detail tag add, toolbar save-collection. | |
| 117 | + | - **`search_field(ui, buf, hint, on_change)`** — search-box recipe used by the toolbar and the sidebar tag filter. Hint text, optional leading glyph (subject to brand rule). | |
| 118 | + | ||
| 119 | + | ### Empty states | |
| 120 | + | ||
| 121 | + | - **`empty_state(ui, EmptyState { icon: Option<&str>, heading: &str, body: Option<&str>, cta: Option<(&str, Response sink)> })`** — centered, vertical, consistent spacing. Replaces the six divergent empty states in `file_list.rs`, `detail.rs`, and `sidebar.rs`. (Per brand rule, `icon` defaults to a word like "Empty" rather than an emoji.) | |
| 122 | + | ||
| 123 | + | ### Modals and overlays | |
| 124 | + | ||
| 125 | + | - **`modal_window(ctx, title, |ui| { … })`** — pre-configured `Window::new(title).collapsible(false).resizable(false).anchor(CENTER_CENTER, [0,0])` with consistent inner padding. All bulk modals, help overlay, and warning overlays compose this. | |
| 126 | + | - **`confirm_modal(ctx, prompt, danger: bool, on_confirm, on_cancel)`** — unified destructive-confirm scaffold. Confirm label is `danger_button` when `danger=true`. Replaces both `draw_confirm_dialog` and `draw_unsafe_warning`'s ad-hoc Window. | |
| 127 | + | - **`name_modal(...)`** — existing; keep, lift to `widgets.rs` so callers outside `overlays.rs` can use it. | |
| 128 | + | ||
| 129 | + | ### Banners and notifications | |
| 130 | + | ||
| 131 | + | - **`info_banner(ui, body)`** — frame with `bg_tertiary()`, `corner_radius(4)`, `space::md` inset; used by the existing VFS first-run banner and any future inline tips. | |
| 132 | + | - **`toast(ctx, severity, body)`** — *new* primitive. Timed transient notification surfaced from a state-owned queue. Replaces the current "set `state.status = '...'`" pattern for errors and ephemeral confirmations (rename success, copy-to-clipboard). The existing footer status label is retained for *persistent* state ("Sync: 3 pending") but should no longer be the channel for transient error feedback. | |
| 133 | + | - **`loading_spinner(ui)` / `busy_indicator(ui, label)`** — *new* primitive for in-flight operations that aren't full-screen (sidebar refresh, sync running). Import has its own progress screen and is out of scope here. | |
| 134 | + | ||
| 135 | + | ### Forms and grids | |
| 136 | + | ||
| 137 | + | - **`metadata_grid(ui, id, rows: impl Iterator<Item=(label, value)>)`** — the detail-panel `Grid` recipe; reusable for any read-only "key / value" block. `grid_row_spacing` token applies. | |
| 138 | + | - **`preview_grid(ui, id, headers, rows)`** — striped two-column grid used by bulk-rename preview; could be reused by export dry-run. | |
| 139 | + | ||
| 140 | + | --- | |
| 141 | + | ||
| 142 | + | ## Panel shapes | |
| 143 | + | ||
| 144 | + | These are de-facto today; the charter codifies them so the surface audits (Phase 1+) can rely on them. | |
| 145 | + | ||
| 146 | + | | Shape | Egui type | Conventions | | |
| 147 | + | |----------------------|----------------------------|-----------------------------------------------------------------------| | |
| 148 | + | | Left sidebar | `SidePanel::left` | Sections separated by `section_header` + `space::lg` between groups. | | |
| 149 | + | | Top toolbar | `TopBottomPanel::top` | Two rows max (breadcrumb + search). Right-aligned actions via `Layout::right_to_left`. | | |
| 150 | + | | Right detail | `SidePanel::right` | Sections paced by `space::section`. Waveform always first. | | |
| 151 | + | | Central | `CentralPanel::default` | Either the file list table or a full-screen wizard (import/export). | | |
| 152 | + | | Bottom footer | `TopBottomPanel::bottom` | One row. Transport on the left, status text on the right. | | |
| 153 | + | | Modal | `modal_window` widget | Centered, fixed-size unless explicitly resizable (bulk rename only). | | |
| 154 | + | | Inline banner | `info_banner` widget | Inside another panel; for one-time tips or unobtrusive state. | | |
| 155 | + | ||
| 156 | + | --- | |
| 157 | + | ||
| 158 | + | ## Migration phasing | |
| 159 | + | ||
| 160 | + | This charter is the target. The next todo item (`docs/ux-audit/remediation-plan.md`) breaks the migration into ordered batches with success criteria; surface audits (Phases 1–7) do not begin until consolidation lands. | |
| 161 | + | ||
| 162 | + | In short: build the missing widgets first (§Widgets), then rewrite panels to call them, then audit the user-facing surfaces. |
| @@ -0,0 +1,234 @@ | |||
| 1 | + | # Resume prompt — audiofiles UX audit | |
| 2 | + | ||
| 3 | + | Paste the contents below into a fresh Claude Code session to pick up where | |
| 4 | + | we left off. Self-contained brief — the implementation notes inside each | |
| 5 | + | `phase-N.md` carry the per-finding details. | |
| 6 | + | ||
| 7 | + | --- | |
| 8 | + | ||
| 9 | + | ## Project + working directory | |
| 10 | + | ||
| 11 | + | audiofiles is a content-addressed sample manager. Native Rust + egui app | |
| 12 | + | (no Tauri/JS). Single workspace under `/Users/max/Code/Apps/audiofiles`. | |
| 13 | + | ||
| 14 | + | Main crates: | |
| 15 | + | - `audiofiles-core` — sample store, db, analysis, export, edit worker. | |
| 16 | + | - `audiofiles-browser` — egui UI + state machine. Most UX work lives here. | |
| 17 | + | - `audiofiles-app` — eframe binary that hosts the browser. | |
| 18 | + | - `audiofiles-sync` — cloud sync (uses synckit-client from `MNW/shared/`). | |
| 19 | + | - `audiofiles-rhai` — device profile plugins for export. | |
| 20 | + | ||
| 21 | + | `crates/audiofiles-browser/src/ui/` is where every audited surface lives. | |
| 22 | + | ||
| 23 | + | ## Audit phases (all complete) | |
| 24 | + | ||
| 25 | + | | Phase | Surfaces | Doc | C/M/m/p status | | |
| 26 | + | |-------|----------|-----|----------------| | |
| 27 | + | | 3 (prior) | table selection model | (prior session) | closed | | |
| 28 | + | | 4 | filter / instrument / settings / sync panels | `phase-4.md` | all shipped | | |
| 29 | + | | 5 | import_screens/* + export_screens | `phase-5.md` | C+M shipped; 4 items deferred (see below) | | |
| 30 | + | | 6 | file_list / detail / toolbar / sidebar / footer / file_list_menus | `phase-6.md` | C + Major + Minor + Polish shipped (2 audit-listed defers: file_list p-4, detail p-1) | | |
| 31 | + | | 7 | overlays + edit_panel | `phase-7.md` | C + Major + Minor + Polish shipped (audit-listed defers: m-14, p-1, p-2, p-3) | | |
| 32 | + | ||
| 33 | + | Each `phase-N.md` ends with one or more "Implementation note" blocks | |
| 34 | + | listing exactly what shipped, what was deferred, and which files were | |
| 35 | + | touched. Always read those before quoting a recommendation — the | |
| 36 | + | implementation may have diverged. | |
| 37 | + | ||
| 38 | + | ## Open work catalog | |
| 39 | + | ||
| 40 | + | ### Phase 6 Major (13 items, all on the steady-state browser surfaces) | |
| 41 | + | ||
| 42 | + | - **M-1** Tag suggestion dismiss Undo — *done in Phase 7 M-1* (detail.rs). | |
| 43 | + | Skip if the implementation note in `phase-7.md` confirms — this entry in | |
| 44 | + | phase-6.md's audit predated the Phase 7 work. | |
| 45 | + | - **M-2** Sort headers inert during similarity search — add tooltip. | |
| 46 | + | - **M-3** Toolbar panel toggles + actions overflow narrow windows — collapse | |
| 47 | + | to a `View ▼` dropdown below ~900px. | |
| 48 | + | - **M-4** Sync button label width varies dramatically — fixed-width + dot | |
| 49 | + | for state. | |
| 50 | + | - **M-5** Tag tree default-collapsed — default-open top level + remember. | |
| 51 | + | - **M-6** Reveal in Finder/Explorer missing from sample context menu — | |
| 52 | + | `open -R` / `explorer /select` / `xdg-open`. | |
| 53 | + | - **M-7** Re-analyze missing from single-row context menu — single-element | |
| 54 | + | list → same `ReanalyzeOverwrite` confirm. | |
| 55 | + | - **M-8** Footer overcrowded on narrow windows — two-row layout when narrow. | |
| 56 | + | - **M-9** Similarity banner duplicates breadcrumb — drop banner; move Clear | |
| 57 | + | into breadcrumb segment. | |
| 58 | + | - **M-10** Discovery buttons don't gate on fingerprint availability — | |
| 59 | + | disable + on-disabled hover. | |
| 60 | + | - **M-11** Multi-select tag chips can't bulk-apply or bulk-remove partial- | |
| 61 | + | coverage tags — make badges actionable. | |
| 62 | + | - **M-12** Tag rename behaviour for parent nodes is unclear — surface count | |
| 63 | + | + preview before commit. | |
| 64 | + | - **M-13** *"Detail panel hidden"* warning lives in footer, not at toggle — | |
| 65 | + | move to tooltip on Detail toggle. | |
| 66 | + | ||
| 67 | + | ### Phase 6 Minor + Polish (24 items) | |
| 68 | + | ||
| 69 | + | Lightweight: drag-out cooldown copy, play-column header, rename modal | |
| 70 | + | consistency, status fade, footer separator consistency, AIFF warning red | |
| 71 | + | → yellow (already done; verify), waveform loop visualisation, etc. | |
| 72 | + | See `phase-6.md` for the catalog. | |
| 73 | + | ||
| 74 | + | ### Phase 7 Minor + Polish (22 items) | |
| 75 | + | ||
| 76 | + | Modal copy hints, help table mixed separators, bulk-move root label, | |
| 77 | + | edit fade caps, silence range bounds, format_bytes legacy copies, etc. | |
| 78 | + | See `phase-7.md`. | |
| 79 | + | ||
| 80 | + | ### Deferred items needing design work | |
| 81 | + | ||
| 82 | + | From Phase 5: | |
| 83 | + | - **p-3** Review Suggestions sort combobox — stretch; needs sort-key state. | |
| 84 | + | - **p-4** Review Suggestions keyboard nav (↑/↓) — stretch; needs key | |
| 85 | + | handling against wizard input layer. | |
| 86 | + | - **p-5** Device profile category/notes — needs new fields on | |
| 87 | + | `DeviceProfile` in `audiofiles-core`. | |
| 88 | + | ||
| 89 | + | From Phase 7: | |
| 90 | + | - **C-1 part 2** True per-edit Undo for trim / silence / fade / reverse — | |
| 91 | + | needs backend snapshot support before/after each edit (`backend::start_edit` | |
| 92 | + | worker writes through to the SampleStore with no rollback). The current | |
| 93 | + | ship is the trim preview overlay + yellow Replace-mode warning. | |
| 94 | + | ||
| 95 | + | ## How we work | |
| 96 | + | ||
| 97 | + | 1. Audit produces a `phase-N.md` with findings ranked Critical / Major / | |
| 98 | + | Minor / Polish. Fixes follow in subsequent commits, never during the | |
| 99 | + | audit itself. | |
| 100 | + | 2. For Critical findings, land them in dependency order. Mechanical first, | |
| 101 | + | structural last. | |
| 102 | + | 3. For product-decision items, present 2–3 options and ask before coding. | |
| 103 | + | Don't decide unilaterally. | |
| 104 | + | 4. After each batch: build, run tests, run all five design-system gates. | |
| 105 | + | Update the relevant `phase-N.md` with an Implementation note. | |
| 106 | + | 5. Use TaskCreate / TaskUpdate to track. Keep markdown summaries short. | |
| 107 | + | 6. When a batch has many independent items in different files, launch | |
| 108 | + | parallel agents (one per file) with self-contained briefs. Agents must | |
| 109 | + | include the convention reminders + gate commands in their prompts. | |
| 110 | + | ||
| 111 | + | ## Build + verification commands | |
| 112 | + | ||
| 113 | + | ```bash | |
| 114 | + | cd /Users/max/Code/Apps/audiofiles | |
| 115 | + | cargo build -p audiofiles-app | |
| 116 | + | ||
| 117 | + | cargo test -p audiofiles-browser --lib | |
| 118 | + | cargo test -p audiofiles-core --lib | |
| 119 | + | cargo test -p audiofiles-sync --lib | |
| 120 | + | ||
| 121 | + | # Design-system gates (all must return zero output). Hits in theme.rs / | |
| 122 | + | # widgets.rs are exempt by the trailing grep -v filters. | |
| 123 | + | grep -rE 'add_space\([0-9]' crates/audiofiles-app/src crates/audiofiles-browser/src/ui | grep -v 'space::' | grep -v widgets.rs | grep -v theme.rs | |
| 124 | + | grep -rE 'Color32::(from_rgb|WHITE|BLACK|GRAY|RED|GREEN|BLUE|YELLOW|DARK_GRAY|TRANSPARENT)' crates/audiofiles-app/src crates/audiofiles-browser/src/ui | grep -v theme.rs | |
| 125 | + | grep -rE 'Window::new\(' crates/audiofiles-app/src crates/audiofiles-browser/src/ui | grep -v widgets.rs | |
| 126 | + | grep -rE '\.strong\(\)\.color\(theme::accent_blue\(\)\)' crates/audiofiles-app/src crates/audiofiles-browser/src/ui | grep -v widgets.rs | |
| 127 | + | grep -rnoE 'u\{[0-9A-Fa-f]{4,5}\}' crates/audiofiles-app/src crates/audiofiles-browser/src/ui | grep -vE 'u\{(2014|2192|00B7|2022|25B2|25BC|1F4C1|2601|1F50A)\}' | |
| 128 | + | ``` | |
| 129 | + | ||
| 130 | + | Current baselines: `audiofiles-core` 439 tests · `audiofiles-browser` 201 | |
| 131 | + | tests · `audiofiles-sync` 44 tests. All five gates currently return zero. | |
| 132 | + | ||
| 133 | + | ## Established widgets / patterns to reuse | |
| 134 | + | ||
| 135 | + | In `crates/audiofiles-browser/src/ui/widgets.rs`: | |
| 136 | + | ||
| 137 | + | - `tool_window`, `modal_window`, `name_modal`, `confirm_modal` + `ConfirmSpec` | |
| 138 | + | - `primary_button`, `secondary_button`, `danger_button`, `danger_small_button` | |
| 139 | + | - `selectable_row`, `selectable_row_secondary`, `selectable_tag` | |
| 140 | + | - `section_header`, `subsection_label`, `filter_section`, `wizard_steps` | |
| 141 | + | - `step_number`, `accent_strong`, `empty_state`, `info_banner`, `warning_banner` | |
| 142 | + | - `toolbar_toggle`, `toggle_pills`, `tag_chip`, `tag_chip_removable` | |
| 143 | + | - `format_bytes`, `format_duration`, `format_bpm` | |
| 144 | + | ||
| 145 | + | Action ordering is `[Cancel] [primary]` (platform convention). | |
| 146 | + | ||
| 147 | + | Terminology: | |
| 148 | + | - **Vault** = inner VFS root (sidebar list rows). | |
| 149 | + | - **Library** = registry-level (top-level DB file, Settings). | |
| 150 | + | ||
| 151 | + | ## Convention reminders | |
| 152 | + | ||
| 153 | + | - **No emoji, no checkmarks in UI copy.** Brand rule. | |
| 154 | + | - **No ellipsis character** `\u{2026}` — design-system gate blocks it. Use | |
| 155 | + | literal `"..."` (three dots). | |
| 156 | + | - **Unicode allowlist** for `\u{...}` escapes: `2014` (em dash), `2192` (→), | |
| 157 | + | `00B7` (·), `2022`, `25B2`, `25BC`, `1F4C1`, `2601`, `1F50A`. Anything | |
| 158 | + | else trips gate 5. | |
| 159 | + | - **No Color32 literals outside theme.rs.** Use `theme::` helpers; add new | |
| 160 | + | helpers in theme.rs if needed. | |
| 161 | + | - Status posts (`state.status = format!(...)`) are the established | |
| 162 | + | lightweight feedback channel. Don't add new ones — reuse. | |
| 163 | + | - When you need an X icon, paint two crossed lines via `painter.line_segment` | |
| 164 | + | (Phase 4 M-8 precedent) rather than a glyph. | |
| 165 | + | - Per Phase 7 C-3: modal forms keep open on backend error — surface the | |
| 166 | + | error inline via the `error: Option<&str>` parameter on | |
| 167 | + | `widgets::name_modal`, store it on `BrowserState::name_modal_error`. | |
| 168 | + | - Per Phase 7 C-2: 3-button confirms with a "Locate" middle option are | |
| 169 | + | hand-rendered via `widgets::modal_window` (not `confirm_modal`, which is | |
| 170 | + | 2-button only). | |
| 171 | + | ||
| 172 | + | ## Key state fields added across the audits | |
| 173 | + | ||
| 174 | + | - `name_modal_error: Option<String>` — Phase 7 C-3 inline-error retention. | |
| 175 | + | - `last_dismissed_suggestion: Option<(String, String, Instant)>` — Phase 7 | |
| 176 | + | M-1 Undo affordance. | |
| 177 | + | - `help_shortcut_search: String` — Phase 7 M-2. | |
| 178 | + | - `bulk_move_filter: String` — Phase 7 M-6. | |
| 179 | + | - `import_preflight_disabled: bool` + `preflight_dont_ask: bool` — | |
| 180 | + | Phase 7 M-9 persistent dismissal. | |
| 181 | + | - `operation_progress: Option<OperationProgress>` — Phase 5 M-11 rate/ETA. | |
| 182 | + | - `last_export_destination: Option<PathBuf>` — Phase 5 C-3 acknowledgement. | |
| 183 | + | - `last_folder_tags: Option<(Vec<FolderTagEntry>, Vec<(String, String)>)>` | |
| 184 | + | — Phase 5 C-1 Back navigation. | |
| 185 | + | ||
| 186 | + | `ConfirmAction` variants added by audits: | |
| 187 | + | - `SwitchLibrary` (non-destructive switch) | |
| 188 | + | - `ReanalyzeOverwrite` (destructive but not Delete) | |
| 189 | + | - `DisconnectSync { pending_changes: i64 }` (Phase 4) | |
| 190 | + | - `DisconnectSync` detail varies on `pending_changes` | |
| 191 | + | - `RemoveFailedSamples { single_index, count, name }` (Phase 5 C-2) | |
| 192 | + | ||
| 193 | + | ## Things to remember before recommending from memory | |
| 194 | + | ||
| 195 | + | - Read `phase-N.md`'s implementation note before quoting any finding — the | |
| 196 | + | doc records both the recommendation AND what actually shipped (sometimes | |
| 197 | + | they diverge for legit reasons). | |
| 198 | + | - Don't dismiss anything as a "pre-existing bug". Project rule: all bugs | |
| 199 | + | are joint responsibility, fix before shipping. Stale tests are a | |
| 200 | + | recurring example. | |
| 201 | + | - Migration count is in `audiofiles-core/src/db.rs`. The `db::tests::*` | |
| 202 | + | asserts hardcode `user_version` — bump them when a new migration lands. | |
| 203 | + | - The detail panel + edit panel share the waveform widget but use | |
| 204 | + | different heights (120 vs 80). Phase 7 p-6 noted the edit panel should | |
| 205 | + | probably grow. | |
| 206 | + | - `add_tag` is `INSERT OR IGNORE` semantically — duplicate tag adds are | |
| 207 | + | safe no-ops. | |
| 208 | + | - Edit operations are async via `backend.start_edit`; cancellation is | |
| 209 | + | best-effort (`backend.cancel_edit` exists per Phase 7 M-11). | |
| 210 | + | - The `dismissed_suggestions` map is also reset-able from Settings → | |
| 211 | + | Display → Reset suggestions. The Phase 7 M-1 inline Undo is the | |
| 212 | + | fast-path for stray clicks. | |
| 213 | + | ||
| 214 | + | ## First thing to do | |
| 215 | + | ||
| 216 | + | Phase 6 Major shipped 2026-05-20 (see phase-6.md's Major implementation | |
| 217 | + | note). All steady-state surfaces are closed; remaining work is the | |
| 218 | + | 4 deferred items that need design / data-model decisions: | |
| 219 | + | - Phase 5 p-3 / p-4: Review Suggestions sort combobox + ↑/↓ keyboard nav | |
| 220 | + | (need sort-key state + wizard-input layer plumbing). | |
| 221 | + | - Phase 5 p-5: device profile category/notes (new fields on | |
| 222 | + | `DeviceProfile` in `audiofiles-core`). | |
| 223 | + | - Phase 7 C-1 part 2: true per-edit Undo for trim / silence / fade / | |
| 224 | + | reverse — needs backend snapshot support around `backend.start_edit`. | |
| 225 | + | Current ship is trim preview overlay + Replace-mode warning. | |
| 226 | + | ||
| 227 | + | Audit-listed *defers* (intentional skips, don't revisit unless data model | |
| 228 | + | changes): P6 file_list p-4 (no row-bg hook in `egui_extras::TableRow`), | |
| 229 | + | P6 detail p-1 (loop bounds not in `AnalysisResult`), P6 detail p-6, P7 | |
| 230 | + | m-14, p-1, p-2, p-3. | |
| 231 | + | ||
| 232 | + | Each deferred item involves design decisions, so ask the user before | |
| 233 | + | implementing. They may also want to revisit ship priorities here, since | |
| 234 | + | all UI-only audit work is now closed. |
| @@ -0,0 +1,286 @@ | |||
| 1 | + | # UX Audit: audiofiles — Phase 0 (Design-system conformance) | |
| 2 | + | ||
| 3 | + | **Date:** 2026-05-19 | |
| 4 | + | **Scope:** `crates/audiofiles-browser/src/ui/` — egui dispatch. | |
| 5 | + | **Detected stack:** egui (eframe). Native immediate-mode GUI; no web layer. | |
| 6 | + | **Out of scope:** branding direction, full a11y, runtime/behavior fixes. Audit and document only. | |
| 7 | + | ||
| 8 | + | This is a *conformance* audit, not a surface audit. The question is: **what design primitives does the egui UI actually have today, and where do panels reinvent them inline?** Findings here feed the consolidation pre-plan; surface audits do not start until consolidation lands. | |
| 9 | + | ||
| 10 | + | --- | |
| 11 | + | ||
| 12 | + | ## (a) Actual primitive set | |
| 13 | + | ||
| 14 | + | ### A.1 `egui::Visuals` configuration — `theme.rs::apply_theme` | |
| 15 | + | ||
| 16 | + | The visuals object is centrally configured from the 15-slot `ThemeColors` palette. This is the strongest part of the design system; every panel, window, button, and separator inherits from it. | |
| 17 | + | ||
| 18 | + | | Visuals slot | Source | | |
| 19 | + | |-------------------------------------------|----------------------------------------------------| | |
| 20 | + | | `panel_fill`, `window_fill` | `bg_secondary` | | |
| 21 | + | | `extreme_bg_color` | `bg_primary` | | |
| 22 | + | | `faint_bg_color` | `lerp(bg_primary, bg_secondary, 0.3)` | | |
| 23 | + | | `selection.bg_fill` | `lerp(bg_primary, accent_blue, 0.3)` | | |
| 24 | + | | `selection.stroke` | 1.0 px, auto-contrast vs. selection fill | | |
| 25 | + | | `widgets.noninteractive.bg_fill` | `bg_secondary` | | |
| 26 | + | | `widgets.inactive.bg_fill` | `lerp(bg_secondary, bg_tertiary, 0.3)` | | |
| 27 | + | | `widgets.hovered.bg_fill` | `bg_tertiary` | | |
| 28 | + | | `widgets.active.bg_fill` | `accent_blue` | | |
| 29 | + | | `widgets.noninteractive.fg_stroke` | 1.0 px `fg_secondary` | | |
| 30 | + | | `widgets.inactive.fg_stroke` | 1.0 px `fg_primary` | | |
| 31 | + | | `widgets.hovered.fg_stroke` | 1.0 px `fg_primary` | | |
| 32 | + | | `widgets.active.fg_stroke` | 1.0 px contrast vs. `accent_blue` | | |
| 33 | + | | `widgets.{inactive,hovered,active}.bg_stroke` | 0.5–1.0 px border tokens | | |
| 34 | + | | `widgets.noninteractive.bg_stroke` | 0.5 px `lerp(border_default, bg_secondary, 0.4)` (separators) | | |
| 35 | + | | All `widgets.*.corner_radius` | `t.rounding` (default 4 px) | | |
| 36 | + | | Hover expansion | 1.0 px on `widgets.hovered.expansion` | | |
| 37 | + | ||
| 38 | + | ### A.2 Style tokens — `theme.rs` | |
| 39 | + | ||
| 40 | + | | Token | Default | Where it's plumbed | | |
| 41 | + | |------------------------|---------|-------------------------------------------------| | |
| 42 | + | | `rounding` | 4.0 | `widgets.*.corner_radius` | | |
| 43 | + | | `item_spacing_x` | 8.0 | `style.spacing.item_spacing.x` | | |
| 44 | + | | `item_spacing_y` | 5.0 | `style.spacing.item_spacing.y` | | |
| 45 | + | | `section_spacing` | 16.0 | Public accessor `theme::section_spacing()` (detail panel only) | | |
| 46 | + | | `grid_row_spacing` | 6.0 | Public accessor `theme::grid_row_spacing()` | | |
| 47 | + | | `button_padding_x` | 8.0 | `style.spacing.button_padding.x` | | |
| 48 | + | | `button_padding_y` | 4.0 | `style.spacing.button_padding.y` | | |
| 49 | + | | `window_margin` | 10.0 | Hardcoded in `apply_theme` (not a TOML override) | | |
| 50 | + | | `indent` | 18.0 | Hardcoded in `apply_theme` | | |
| 51 | + | ||
| 52 | + | ### A.3 Color accessors — `theme.rs` | |
| 53 | + | ||
| 54 | + | Public functions returning `Color32`: | |
| 55 | + | ||
| 56 | + | - Backgrounds: `bg_primary`, `bg_secondary`, `bg_tertiary`, `bg_surface`, `bg_row_even`, `bg_row_odd`, `bg_hover`, `bg_selected`. | |
| 57 | + | - Foregrounds: `text_primary`, `text_secondary`, `text_muted`. | |
| 58 | + | - Accents: `accent_red`, `accent_green`, `accent_blue`, `accent_yellow`, `accent_purple`, `accent_cyan`. | |
| 59 | + | - Border: `border_default`. | |
| 60 | + | - Domain-specific (intentionally not theme-driven): `classification_color(class)`, `piano_white_key()`, `piano_black_key()`. | |
| 61 | + | ||
| 62 | + | ### A.4 Custom widgets — `widgets.rs` (84 lines total) | |
| 63 | + | ||
| 64 | + | Only four shared widgets exist in `widgets.rs`: | |
| 65 | + | ||
| 66 | + | | Function | Signature | Purpose | | |
| 67 | + | |-----------------------------------|---------------------------------------------|-------------------------------------------------| | |
| 68 | + | | `classification_badge` | `(&mut Ui, &str)` | Small RichText label coloured by `classification_color`. | | |
| 69 | + | | `tag_chip` | `(&mut Ui, &str) -> Response` | Rounded-rect tag pill with hover (custom paint). | | |
| 70 | + | | `tag_chip_removable` | `(&mut Ui, &str) -> bool` | Tag label + `small_button("x")`; returns true on remove. | | |
| 71 | + | | `format_duration`, `format_bpm` | formatting helpers | Not widgets — string formatters. | | |
| 72 | + | ||
| 73 | + | That is the *entire* shared widget vocabulary. Everything else (rows, headers, empty states, modals, danger buttons, toggle pills, banners) is reinvented per-panel. | |
| 74 | + | ||
| 75 | + | ### A.5 Panel shapes (de-facto) | |
| 76 | + | ||
| 77 | + | Discovered, not declared: | |
| 78 | + | ||
| 79 | + | - **Left side panel** — `egui::SidePanel::left` with `draw_sidebar`. Sections separated by `ui.separator()` + `ui.add_space(4..12)`. | |
| 80 | + | - **Top panel** — toolbar (`draw_toolbar`) and breadcrumb row. | |
| 81 | + | - **Right detail panel** — `draw_detail`; sections paced by `theme::section_spacing()` (the only spacing token any panel reads). | |
| 82 | + | - **Central panel** — `draw_file_list` (table) and import/export screens (`CentralPanel::default`). | |
| 83 | + | - **Modal windows** — `egui::Window::new(title).collapsible(false).resizable(false).anchor(CENTER_CENTER, [0,0])`. Repeated verbatim in `overlays.rs` (4 sites) plus `name_modal` helper (3 callers). | |
| 84 | + | - **Inline banner** — single instance: `egui::Frame::new().fill(bg_tertiary()).corner_radius(4).inner_margin(8)` in `sidebar.rs:180–195` (VFS first-run banner). No shared helper. | |
| 85 | + | ||
| 86 | + | ### A.6 Recipe inventory (the things every panel hand-rolls) | |
| 87 | + | ||
| 88 | + | | Recipe | Canonical form? | Where it actually lives | | |
| 89 | + | |------------------------------|----------------------|-------------------------------------------------------------------------------------------------------------| | |
| 90 | + | | Section header | none | `ui.label(RichText::new("X").strong().color(text_secondary()))` + `ui.separator()` (sidebar, filter_panel, detail) | | |
| 91 | + | | Muted "empty" label | none | `ui.label(RichText::new("No X yet").color(text_muted()))` — 6+ sites | | |
| 92 | + | | Centered onboarding/empty state | none | `ui.vertical_centered` + `add_space(avail * 0.15)` + ad-hoc icon/text sizes (file_list lines 38–116, 120–151) | | |
| 93 | + | | Selectable row (active = accent_blue) | none | `selectable_label(active, RichText::new(label).strong().color(accent_blue()))` — sidebar VFS, sidebar collections, breadcrumb, sort header, filter checkboxes (5+ inline variants) | | |
| 94 | + | | Active-aware section header | none | `bpm_active ? "BPM Range *" : "BPM Range"` + `CollapsingHeader::default_open(bpm_active)` — filter_panel × 5 | | |
| 95 | + | | Confirm dialog | `draw_confirm_dialog` (single-purpose for delete) | Re-implemented inline for "Purge missing samples" (`overlays.rs:188–221`) | | |
| 96 | + | | Single-field name modal | `name_modal()` | Used by 4 callers; but `draw_confirm_dialog` and `draw_unsafe_warning` don't share the same Window setup | | |
| 97 | + | | Bulk modal scaffold | none | `draw_bulk_tag_modal` / `_move_modal` / `_rename_modal` each rebuild the Window block + Apply/Cancel row | | |
| 98 | + | | Inline rename row | none | text_edit + Enter-to-submit + Cancel button (sidebar collection rename, sidebar create, toolbar collection save) | | |
| 99 | + | | Toggle pill (Folder/All, Exact/Compatible, Add/Remove tag) | none | `selectable_label(state == X, "Label").clicked()` — toolbar, filter_panel, overlays bulk_tag (3 variants) | | |
| 100 | + | | Toolbar icon-button with state colour | none | `ui.button(RichText::new(glyph).color(active ? accent_blue : text_muted))` — toolbar × 6 (sidebar, detail, edit, instrument, loop, filter) | | |
| 101 | + | | Primary vs secondary button | none | All buttons are `ui.button(...)` — no visual distinction between "Save / Apply / Delete" and "Cancel" | | |
| 102 | + | | Danger button (Delete, Purge) | none | Same as regular button. No red variant. | | |
| 103 | + | | Result preview grid | none | Bulk-rename has its own `Grid::new("rename_preview").striped(true)`; no shared two-column form helper | | |
| 104 | + | | Hover-text tooltip | egui built-in | Consistent (`.on_hover_text(...)`) — well-used | | |
| 105 | + | | Status-message surface | `state.status: String` | Footer reads `state.status`. No toast/transient notification system | | |
| 106 | + | ||
| 107 | + | --- | |
| 108 | + | ||
| 109 | + | ## (b) Divergence map — same UX expressed N ways | |
| 110 | + | ||
| 111 | + | The following table is the audit's main deliverable. Each row names a pattern and lists the call sites that reinvent it. | |
| 112 | + | ||
| 113 | + | ### B.1 Selectable row with "active = accent_blue, strong" | |
| 114 | + | ||
| 115 | + | Six inline implementations of the same idea: | |
| 116 | + | ||
| 117 | + | | File | Lines | Subject | | |
| 118 | + | |-------------------------|-----------------|------------------------------------------| | |
| 119 | + | | `sidebar.rs` | 70–87 | Tag leaf node | | |
| 120 | + | | `sidebar.rs` | 102–119 | Tag folder "self" entry | | |
| 121 | + | | `sidebar.rs` | 200–212 | VFS list item | | |
| 122 | + | | `sidebar.rs` | 247–268 | Collection list item | | |
| 123 | + | | `toolbar.rs` | 286–298 | Breadcrumb segment | | |
| 124 | + | | `file_list.rs` | 553–578 | Sort header | | |
| 125 | + | ||
| 126 | + | All compute `active`, build a `RichText` with `.strong()` and `accent_blue()` when active, fall back to `text_primary` or `text_secondary` otherwise, then call `selectable_label(active, label)`. **No shared helper.** | |
| 127 | + | ||
| 128 | + | ### B.2 Toolbar toggle icon-button (state via colour) | |
| 129 | + | ||
| 130 | + | Six near-identical blocks in `toolbar.rs:138–204`: | |
| 131 | + | ||
| 132 | + | | Lines | Button | | |
| 133 | + | |-------------|------------------------------| | |
| 134 | + | | 138–145 | Sidebar toggle (←) | | |
| 135 | + | | 147–153 | Detail toggle (→) | | |
| 136 | + | | 156–169 | Editor toggle (✎) | | |
| 137 | + | | 171–177 | Instrument toggle (🎹) | | |
| 138 | + | | 180–187 | Loop toggle (🔁) | | |
| 139 | + | | 189–205 | Filter panel toggle (☰ + count) | | |
| 140 | + | ||
| 141 | + | Each computes `let X_color = if active { accent_blue() } else { text_muted() };` and renders `ui.button(RichText::new(glyph).color(X_color)).on_hover_text(...)`. Mechanical copy-paste. | |
| 142 | + | ||
| 143 | + | ### B.3 Empty state ("No X yet") | |
| 144 | + | ||
| 145 | + | Six sites, six different shapes: | |
| 146 | + | ||
| 147 | + | | Site | Shape | | |
| 148 | + | |-----------------------------------|--------------------------------------------------------| | |
| 149 | + | | `sidebar.rs:238` | `ui.label(RichText::new("No collections yet").color(text_muted()))` | | |
| 150 | + | | `sidebar.rs:346` | `ui.label(RichText::new("No tags yet").color(text_muted()))` | | |
| 151 | + | | `sidebar.rs:370` | `ui.label(RichText::new("No matching tags").color(text_muted()))` | | |
| 152 | + | | `file_list.rs:37–116` | Vertical-centered welcome with 22 px heading, numbered steps | | |
| 153 | + | | `file_list.rs:120–151` | Vertical-centered "🔍 No matches in this folder" + Clear button | | |
| 154 | + | | `detail.rs:15–18` | `centered_and_justified` "Select a sample" | | |
| 155 | + | | `detail.rs:154` | "No tags" plain muted label | | |
| 156 | + | ||
| 157 | + | No shared "centered empty state" helper. Identical conceptual surface (icon + heading + hint + optional CTA) re-expressed four different ways. | |
| 158 | + | ||
| 159 | + | ### B.4 Section header | |
| 160 | + | ||
| 161 | + | | Site | Form | | |
| 162 | + | |----------------------------------|----------------------------------------------------------------------| | |
| 163 | + | | `sidebar.rs:175` | `ui.label(RichText::new("Vaults").strong().color(text_secondary()))` + `ui.separator()` | | |
| 164 | + | | `filter_panel.rs:10` | `ui.label(RichText::new("Filters").strong().color(text_secondary()))` + `ui.separator()` | | |
| 165 | + | | `detail.rs:152` | `ui.label(RichText::new("Tags").color(text_secondary()))` — same intent, no `.strong()`, no separator | | |
| 166 | + | | `filter_panel.rs:178` | `ui.label(RichText::new("Save as Collection").strong().color(text_secondary()))` + earlier `ui.separator()` | | |
| 167 | + | | `overlays.rs` features tab | `ui.heading("Search & Filter")` × 9 — different rendering entirely | | |
| 168 | + | ||
| 169 | + | The same conceptual element ("section heading inside a panel") uses three different recipes. | |
| 170 | + | ||
| 171 | + | ### B.5 Window/modal scaffold | |
| 172 | + | ||
| 173 | + | `overlays.rs` has the *intent* to consolidate (the `name_modal` helper), but does so partially: | |
| 174 | + | ||
| 175 | + | | Function | Uses `name_modal`? | Window config inline? | | |
| 176 | + | |----------------------------------|--------------------|------------------------| | |
| 177 | + | | `draw_help_overlay` | No | Yes (resizable=false, anchor center) | | |
| 178 | + | | `draw_confirm_dialog` | No | Yes | | |
| 179 | + | | `draw_unsafe_warning` | No | Yes | | |
| 180 | + | | `draw_bulk_tag_modal` | No | Yes | | |
| 181 | + | | `draw_bulk_move_modal` | No | Yes | | |
| 182 | + | | `draw_bulk_rename_modal` | No | Yes | | |
| 183 | + | | `draw_vfs_create_modal` | **Yes** | — | | |
| 184 | + | | `draw_vfs_rename_modal` | **Yes** | — | | |
| 185 | + | | `draw_dir_create_modal` | **Yes** | — | | |
| 186 | + | | `draw_dir_rename_modal` | **Yes** | — | | |
| 187 | + | ||
| 188 | + | The four name-input modals share one recipe; the four richer modals all rebuild the `Window::new(...).collapsible(false).resizable(false).anchor(CENTER_CENTER, [0,0])` scaffold inline. | |
| 189 | + | ||
| 190 | + | ### B.6 Confirm-on-destructive | |
| 191 | + | ||
| 192 | + | | Site | Mechanism | | |
| 193 | + | |---------------------------------------|----------------------------------------------------| | |
| 194 | + | | `overlays.rs::draw_confirm_dialog` | Centralised: `ConfirmAction::{DeleteNode,DeleteVfs,DeleteMultiple}` → window with [Delete] [Cancel] | | |
| 195 | + | | `overlays.rs::draw_unsafe_warning` | Bespoke window with [Purge missing samples] [Dismiss] — **does not** go through `ConfirmAction` | | |
| 196 | + | ||
| 197 | + | The "purge missing samples" path is a destructive bulk operation that should use the confirm dispatcher; currently it bypasses it. | |
| 198 | + | ||
| 199 | + | ### B.7 Inline rename / inline create row | |
| 200 | + | ||
| 201 | + | | Site | Pattern | | |
| 202 | + | |--------------------------------------------|---------------------------------------------------------| | |
| 203 | + | | `sidebar.rs:292–308` (collection rename) | horizontal { text_edit, Enter→commit, Cancel button } | | |
| 204 | + | | `sidebar.rs:311–334` (collection create) | same shape | | |
| 205 | + | | `toolbar.rs:105–125` (save filter as collection popup) | horizontal { text_edit (auto-focus), Save button } | | |
| 206 | + | | `detail.rs:171–193` (add tag) | horizontal { text_edit, Enter→commit, "+" button } | | |
| 207 | + | ||
| 208 | + | Same "single-line text submit" interaction reinvented four times with slightly different commit affordances (Cancel button vs +, auto-focus or not, hint or not). | |
| 209 | + | ||
| 210 | + | ### B.8 Active-aware collapsing header (`*` marker + default_open) | |
| 211 | + | ||
| 212 | + | Five copies of the same idiom inside `filter_panel.rs`: | |
| 213 | + | ||
| 214 | + | ```rust | |
| 215 | + | let X_active = ...; | |
| 216 | + | let X_header = if X_active { "Label *" } else { "Label" }; | |
| 217 | + | egui::CollapsingHeader::new(X_header) | |
| 218 | + | .default_open(X_active) | |
| 219 | + | .show(ui, |ui| { ... }); | |
| 220 | + | ``` | |
| 221 | + | ||
| 222 | + | Lines 19–36, 39–56, 59–76, 78–101, 104–148. Begging for a `filter_section(ui, label, active, |ui| { ... })` helper. | |
| 223 | + | ||
| 224 | + | ### B.9 Toggle pills (mutually-exclusive `selectable_label`s) | |
| 225 | + | ||
| 226 | + | | Site | Subject | | |
| 227 | + | |------------------------------------|--------------------------------------| | |
| 228 | + | | `toolbar.rs:64–83` | Search scope: Folder / All | | |
| 229 | + | | `filter_panel.rs:109–127` | Key match mode: Exact / Compatible | | |
| 230 | + | | `overlays.rs:264–268` (bulk tag) | Mode: Add tag / Remove tag | | |
| 231 | + | | `overlays.rs:17–20` (help) | Help tab: Shortcuts / Features | | |
| 232 | + | ||
| 233 | + | All four are the same widget (segmented control); none of them look identical because spacing, label styling, and tooltips vary. | |
| 234 | + | ||
| 235 | + | ### B.10 Primary vs. cancel action pairing | |
| 236 | + | ||
| 237 | + | Every modal ends in `ui.horizontal { ui.button("Apply"); ui.button("Cancel"); }`. The primary action is visually indistinguishable from Cancel — no weight, no colour, no ordering convention. Sites: `overlays.rs:170–177, 209–219, 293–300, 357–363, 470–481`, plus `name_modal:530–537`. | |
| 238 | + | ||
| 239 | + | --- | |
| 240 | + | ||
| 241 | + | ## (c) Gaps — primitives that should exist but don't | |
| 242 | + | ||
| 243 | + | These are the missing helpers. Each maps onto an identifiable cluster from the divergence map. | |
| 244 | + | ||
| 245 | + | | Missing primitive | Why | Where it would land | | |
| 246 | + | |-------------------------------|----------------------------------------------------------------|-------------------------| | |
| 247 | + | | `selectable_row` | Replaces B.1's six inline copies. Signature: `selectable_row(ui, active, label) -> Response` with theme-driven active styling. | `widgets.rs` | | |
| 248 | + | | `toolbar_toggle` | Replaces B.2's six copies. `toolbar_toggle(ui, glyph, active, tooltip) -> bool`. | `widgets.rs` | | |
| 249 | + | | `empty_state` | Centered icon + heading + body + optional CTA. Replaces B.3. | `widgets.rs` | | |
| 250 | + | | `section_header` | `section_header(ui, "Vaults")` — strong label, `text_secondary`, separator. Replaces B.4. | `widgets.rs` | | |
| 251 | + | | `modal_window` builder | Pre-configured Window with `collapsible(false).resizable(false).anchor(CENTER_CENTER, [0,0])` + content margin. | `widgets.rs` | | |
| 252 | + | | `confirm_modal` | Unified scaffold for any "are you sure" with [Confirm] [Cancel] and a danger variant. Subsumes unsafe-warning. | `widgets.rs` | | |
| 253 | + | | `danger_button` | Red-tinted button for destructive primary actions (Delete, Purge). Currently invisible. | `widgets.rs` | | |
| 254 | + | | `primary_button` / `secondary_button` | Establish a button hierarchy. Cancel is currently a peer of Apply. | `widgets.rs` | | |
| 255 | + | | `inline_text_submit` | text_edit + Enter-to-commit + cancel/clear. Replaces B.7's four sites. | `widgets.rs` | | |
| 256 | + | | `toggle_pills` | Segmented control over `&[(value, label, tooltip)]`. Replaces B.9. | `widgets.rs` | | |
| 257 | + | | `filter_section` | `CollapsingHeader` with active marker + default-open behaviour. Replaces B.8. | `widgets.rs` | | |
| 258 | + | | `info_banner` | The single existing inline banner (`sidebar.rs:180`) is a useful pattern with no helper. | `widgets.rs` | | |
| 259 | + | | `focus_ring` / selected-row recipe | egui hover expansion exists (1.0 px) but no documented "focus ring" stroke style; selection visibility is purely the `selection.bg_fill` lerp. | `theme.rs` (token) | | |
| 260 | + | | `toast` / transient notification | `state.status` is the only message surface. No timed dismissal, no severity (error vs. info), no stacking. Status persists indefinitely until overwritten. | `widgets.rs` + state | | |
| 261 | + | | `loading_spinner` / busy state | No standard busy indicator. Import progress is its own screen; sync state is a glyph in the button label. No general-purpose "operation in flight" widget. | `widgets.rs` | | |
| 262 | + | | Spacing tokens | `add_space(4.0/8.0/12.0/16.0)` is the entire palette — but expressed as float literals (52 × 4.0, 58 × 8.0, 19 × 12.0, 9 × 16.0, plus 8 × 2.0 and 4 × 6.0 outliers). No `spacing::sm()` / `md()` / `lg()` constants. | `theme.rs` | | |
| 263 | + | ||
| 264 | + | --- | |
| 265 | + | ||
| 266 | + | ## Cross-cutting observations (flagged, not fixed) | |
| 267 | + | ||
| 268 | + | These are not part of the conformance audit per se; they emerged while reading. | |
| 269 | + | ||
| 270 | + | 1. **Emoji glyphs in UI strings violate the project brand rule.** CLAUDE.md states *"No checkmarks or emoji in UI copy — words only in user-facing strings, button labels, status text, docs, and code comments."* The egui surface has ~47 unicode-escape glyphs across UI files — folder, speaker, cloud, magnifier, gear, piano, loop arrows, save disk, sync check, play/stop triangles, breadcrumb arrows, hamburger, x, etc. `toolbar.rs:366` uses `"Sync \u{2713}"` (a literal checkmark). **This is the single most consistent design-system violation in the codebase**, and it is brand-policy rather than taste. Resolving it is its own initiative — surface audits should respect this rule when proposing fixes. | |
| 271 | + | ||
| 272 | + | 2. **`section_spacing` / `grid_row_spacing` are theme tokens but only `detail.rs` reads them.** Every other panel uses `add_space(N.0)` literals. The spacing token system is underused. | |
| 273 | + | ||
| 274 | + | 3. **`window_margin`, `indent`, and the inline banner padding (8 px) are hardcoded in `apply_theme` / call sites** — they should be in `ThemeColors` and TOML-overridable for consistency with the rest of the spacing tokens, or moved into named constants. | |
| 275 | + | ||
| 276 | + | 4. **`Color32` discipline is strong.** Only one `Color32::from_rgb` outside `theme.rs` (`edit_panel.rs`, 1 hit). The 130 hits inside `theme.rs` are the default palette + tests. This is a healthy baseline that the consolidation plan should preserve as a success criterion. | |
| 277 | + | ||
| 278 | + | 5. **Custom rendering in `tag_chip`.** `tag_chip` paints its own background via `ui.painter().rect_filled`. This is fine but it is the *only* widget that does so — every other "pill-like" element (toggle pill, breadcrumb, sort header) uses `selectable_label`. If a `pill` primitive is introduced, `tag_chip` should be reconciled with it. | |
| 279 | + | ||
| 280 | + | --- | |
| 281 | + | ||
| 282 | + | ## Summary | |
| 283 | + | ||
| 284 | + | audiofiles has an unusually strong **colour and visuals layer** — `theme.rs::apply_theme` and `ThemeColors` cover essentially every egui style slot, and the rest of the codebase respects it (1 `Color32` literal outside `theme.rs`). The **widget layer is the opposite**: `widgets.rs` contains four helpers, and every panel reinvents row rendering, section headers, empty states, toggle pills, modal scaffolds, and confirm dialogs inline. The single existing modal helper (`name_modal`) shows the team already knows the pattern works — it just hasn't been extended to cover the other six modal call sites. | |
| 285 | + | ||
| 286 | + | The consolidation pre-plan (next todo item) should pick ~12 canonical helpers from §(c), assign each a single home in `widgets.rs` / `theme.rs`, document the spacing token palette, and define hard success criteria — chiefly: (i) no inline `selectable_label(active, RichText::new(...).strong().color(accent_blue()))` outside `widgets.rs`; (ii) every `add_space(N.0)` reads a named spacing constant; (iii) every confirm-style action routes through one `confirm_modal` recipe; (iv) the brand emoji rule is enforced project-wide as part of the same pass. |
| @@ -0,0 +1,248 @@ | |||
| 1 | + | # UX Audit: audiofiles — Phase 1 (Onboarding & first-time setup) | |
| 2 | + | ||
| 3 | + | **Date:** 2026-05-19 | |
| 4 | + | **Scope:** Everything a first-time user sees from launch through their first imported sample. Activation screen, vault setup screen, welcome screen, name-input modals, Quick Import, customised Import (configure → tag → analyse → progress → summary), first-run settings, help overlay, drag-and-drop, sync first-touch. | |
| 5 | + | **Detected stack:** egui (eframe) — desktop-native, no web layer. | |
| 6 | + | **Method:** universal pass (10 principles) + egui-specific pass + cross-cutting flat-design check. Findings are ranked by severity and tagged with the originating principle so they can be fixed by class, not just instance. | |
| 7 | + | **Out of scope:** branding direction, deep a11y, server-side activation/trial mechanics, sync OAuth flow internals. | |
| 8 | + | ||
| 9 | + | The Phase 0 conformance audit found the canonical primitives. Phase 1 walks the actual first-touch surface against those primitives and against classical UX principles. Several findings here are *secondary effects of Phase 0 missing a crate* — the consolidation only scanned `audiofiles-browser/src/ui/`, but the activation and vault-setup screens live in `audiofiles-app/src/`, and they bypass the design system entirely. | |
| 10 | + | ||
| 11 | + | --- | |
| 12 | + | ||
| 13 | + | ## The first-time path, end to end | |
| 14 | + | ||
| 15 | + | This is the actual sequence for a brand-new user on a fresh machine: | |
| 16 | + | ||
| 17 | + | 1. **Launch** → `AudioFilesApp::resolve_initial_screen` (`audiofiles-app/src/main.rs:204–230`) picks `Activation` (no license, no trial state). | |
| 18 | + | 2. **Activation screen** (`activation.rs:9–111`) — license key input + "I am still testing the software" trial button. | |
| 19 | + | 3. **Vault setup screen** (`vault_setup.rs:12–89`) — pick storage location + name the vault. | |
| 20 | + | 4. **Browser opens** → welcome screen renders inside the file list (`file_list.rs:40–92`) with three numbered steps. | |
| 21 | + | 5. **User imports** — either drag-drops onto the window, clicks the inline "Import" link, or uses the toolbar dropdown. | |
| 22 | + | 6. **Quick Import path** → folder picker → background import with progress bar (`import_screens/progress.rs:21–133`). | |
| 23 | + | 7. **Or customised Import path** → configure (4 panels) → tag folders → analysis config → progress → summary → tag review. | |
| 24 | + | 8. **First sample appears** in the file list. Welcome screen never returns. | |
| 25 | + | ||
| 26 | + | At every step there is a Cancel/back affordance, but several intermediate states are dead-ends without obvious recovery (covered below). | |
| 27 | + | ||
| 28 | + | --- | |
| 29 | + | ||
| 30 | + | ## Critical (fix before shipping) | |
| 31 | + | ||
| 32 | + | ### C-1. Activation + vault-setup screens bypass the design system entirely — Consistency / Hierarchy (egui) | |
| 33 | + | ||
| 34 | + | - **Location:** `crates/audiofiles-app/src/activation.rs:30, 65, 81`; `crates/audiofiles-app/src/vault_setup.rs:30, 42, 65`. Also `add_space(N.0)` literals throughout both files. | |
| 35 | + | - **Observation:** Phase 0's conformance audit and Batches 0–6 of the consolidation only covered `audiofiles-browser/src/ui/`. The two screens *every first-time user sees before the browser ever loads* live in `audiofiles-app/src/` and never imported `theme::` or `widgets::`. Concretely: raw `Color32::from_rgb(220, 60, 60)` for activation errors, `Color32::from_rgb(120, 180, 120)` for the "found existing library" message, `Color32::from_rgb(150, 200, 255)` for "Selected:", `Color32::GRAY` for the default-path subtitle; raw `add_space(8.0)` / `add_space(16.0)` / `add_space(24.0)` / `add_space(40.0)`; raw `ui.button(...)`, `ui.heading(...)` with no `section_header`, no `primary_button`, no `info_banner`, no `empty_state`, no `name_modal`. | |
| 36 | + | - **Why it matters:** First impressions are first. The user's first two screens are stylistically detached from the rest of the app — different greens, different spacing rhythm, different button weight. Theme switching in the browser won't recolour the activation error message, because the colour was hardcoded before the theme system was reachable. This silently undoes a chunk of Phase 0's work. | |
| 37 | + | - **Recommendation:** Extend the design-system gate (`#R-07`) to cover `audiofiles-app/src/`. Either move the shared widgets up to a workspace-level crate or add `audiofiles-browser` as a dependency of `audiofiles-app` and `pub use` the widget surface. Then migrate both screens — they're small (218 + 198 lines). Add an explicit Phase 0 / Batch 0–6 line item: *the gate regex must include every UI-bearing crate, not just `audiofiles-browser/src/ui/`*. Update `remediation-plan.md` accordingly. | |
| 38 | + | ||
| 39 | + | ### C-2. Vault-setup "Use default location" reads as the primary action but isn't — Mappings (egui) | |
| 40 | + | ||
| 41 | + | - **Location:** `vault_setup.rs:35–43` and `:80–86`. | |
| 42 | + | - **Observation:** The screen has three controls in vertical order: `[Use default location]` button, `[Choose folder...]` button (with a small "Reset to default" sibling when a custom path is set), and at the bottom a `[Continue]` button. Clicking "Use default location" simply assigns `self.vault_setup_path = None` — it does *not* advance. The user reads top-to-bottom, clicks the first button that matches their intent ("yes, default is fine"), nothing visible happens, and they're stuck looking for what to do next. | |
| 43 | + | - **Why it matters:** This is a textbook gulf-of-evaluation failure: the action produces no perceptible feedback, so the user can't tell whether they did something, did nothing, or are in an error state. On a fresh install this is the user's *third interaction with the app*; a stall here colours the whole experience. | |
| 44 | + | - **Recommendation:** Two options, in preference order: | |
| 45 | + | 1. **Collapse the three controls into one decision** — show the default path inline with the name input and a single `[Continue with default]` primary button, plus a `[Choose different location...]` link/secondary that pops the folder picker. One click to advance. | |
| 46 | + | 2. If the two-step model is kept, "Use default location" must give visible feedback — e.g. flip the "Continue" button to read `[Continue → <default path>]` and pulse it briefly, or render the chosen path under the Continue button in `accent_blue` the moment a choice is made. | |
| 47 | + | ||
| 48 | + | ### C-3. Name-input modals never auto-focus their text field — Locus of attention / Feedback (egui) | |
| 49 | + | ||
| 50 | + | - **Location:** `widgets.rs::name_modal` lines 147–177. Affects every `name_modal` caller: vault create, vault rename, directory create, directory rename, and the new collection-save popup (`toolbar.rs:88–118` mirrors the same omission). | |
| 51 | + | - **Observation:** `ui.text_edit_singleline(input)` is called without `request_focus()`. The user opens the modal (often via a keyboard shortcut), the modal appears, the user starts typing — and the keystrokes go nowhere because focus is still on whichever widget triggered the modal. They have to click into the field first. | |
| 52 | + | - **Why it matters:** Onboarding-critical because the very first modal a user encounters is "Name your vault" (when they have an existing library detected) or the implicit fallback (when they don't). A modal that demands typing but doesn't accept typing is a forgiveness failure — and Raskin's locus-of-attention rule is explicit that input focus should follow the user's intent automatically. | |
| 53 | + | - **Recommendation:** In `name_modal`, after the `text_edit_singleline` line, capture the response and call `request_focus()` only on the *first* frame the modal opens (use a `once` flag inside the spec or compare to the previous frame's `input` value). Same fix applies to the save-collection popup in `toolbar.rs:102–110` — its current `gained_focus() || empty()` heuristic doesn't reliably grab focus on first open. | |
| 54 | + | ||
| 55 | + | ```rust | |
| 56 | + | let resp = ui.text_edit_singleline(input); | |
| 57 | + | if first_open { resp.request_focus(); } | |
| 58 | + | ``` | |
| 59 | + | ||
| 60 | + | ### C-4. Activation errors are dead-ends — Error messages / Forgiveness | |
| 61 | + | ||
| 62 | + | - **Location:** `activation.rs:79–82`. | |
| 63 | + | - **Observation:** When activation fails, the screen renders the raw error string in red beneath the Activate button. No "Try again" guidance, no link to support/recovery, no distinction between "key invalid", "key already used on another machine", "server unreachable", "network offline". The user is left with the key in the field and a red sentence. If the error is transient, the only recovery is to click Activate again — which is fine; if the error is structural (wrong key, machine limit reached), the user has nowhere to go. | |
| 64 | + | - **Why it matters:** Activation is the gate. A user who can't pass it has zero ability to evaluate the product. This is the highest-cost place in the entire app to fail with a bad error. | |
| 65 | + | - **Recommendation:** Differentiate at minimum three classes: | |
| 66 | + | - **Network/server error** — show "Couldn't reach the activation server. Check your connection and try again." plus a `[Retry]` button. | |
| 67 | + | - **Invalid key** — show "We didn't recognise that key. Double-check spelling, or [purchase a new key]." with the existing hyperlink prominent. | |
| 68 | + | - **Machine limit / already-activated** — show "This key is already in use on another machine. Deactivate it there first, or [contact support]." with a support mailto/URL. | |
| 69 | + | ||
| 70 | + | Use `theme::accent_red()` once the design-system fix from C-1 lands. While at it: the trial button below should *also* be reachable from an error state (it currently is, but visually it sits below the error and gets lost — surface it as "Don't have a key yet? Start a free trial"). | |
| 71 | + | ||
| 72 | + | ### C-5. The customised import flow has no progress indicator across screens — Visibility of state (egui) | |
| 73 | + | ||
| 74 | + | - **Location:** `import_screens/configure.rs`, `tagging.rs`, `progress.rs`, `summary.rs`. | |
| 75 | + | - **Observation:** The customised import is a 4–5 step wizard (configure → tag folders → progress → summary → tag-review), but each screen renders as a full-page CentralPanel with no "Step 2 of 5" indicator, no breadcrumb, no back button. The user can Cancel out, but they have no idea how many more screens are coming or whether they can skip any of them. Tagging is genuinely skippable; configure isn't — but the user has no way to tell. | |
| 76 | + | - **Why it matters:** A first-time user choosing the customised path is already opting into more friction than Quick Import; rewarding that with an opaque pipeline trains them to never use it again. Tognazzini's anticipation principle: tell the user where they are and where they're going. | |
| 77 | + | - **Recommendation:** Add a small step indicator at the top of each import-screen panel — a horizontal row of dots or short labels (`Configure · Tag · Analyse · Review`), with the current step in `accent_blue` and completed steps in `text_secondary`. Build it as `widgets::wizard_steps(ui, steps, current)`. Reuse for any future multi-screen flow (export currently has the same shape and same missing affordance). | |
| 78 | + | ||
| 79 | + | --- | |
| 80 | + | ||
| 81 | + | ## Major (high impact, lower urgency) | |
| 82 | + | ||
| 83 | + | ### M-1. Welcome screen never returns and no tour exists — Forgiveness / Discoverability | |
| 84 | + | ||
| 85 | + | - **Location:** `file_list.rs:40–92` (gated by `state.show_first_launch_hint`), `overlays.rs` Help overlay tabs. | |
| 86 | + | - **Observation:** The welcome screen with its three numbered steps shows once, then `show_first_launch_hint` flips and the screen is gone forever. The Help overlay (F1) has a "Features" tab that's a wall of dense prose — it isn't a tour, just reference text. A user who closes the welcome too quickly can't get it back. | |
| 87 | + | - **Why it matters:** Forgiveness rule — actions should be reversible. Dismissing onboarding by accident on day 1 shouldn't punish the user for the rest of their use of the app. Also: a user returning to the app weeks later may want a refresher. | |
| 88 | + | - **Recommendation:** Add `Help → Show welcome screen` (and/or a `[?]` button in an empty-state panel) that re-sets `show_first_launch_hint = true` for one render. Cheaper alternative: convert the three numbered steps into a small reusable widget (`widgets::numbered_steps`) and surface it inside the `empty_state` body when the active vault has zero samples and the user has dismissed the welcome. | |
| 89 | + | ||
| 90 | + | ### M-2. "No samples yet" empty state has no CTA — Affordances (egui) | |
| 91 | + | ||
| 92 | + | - **Location:** `file_list.rs:93–100`. | |
| 93 | + | - **Observation:** The post-welcome empty state reads "No samples yet" + body "Drop audio files here or click Import to get started." with `cta: None`. The user is told *to* import but given no button — the only Import is up in the toolbar. | |
| 94 | + | - **Why it matters:** Fitts: the empty state owns the centre of the screen but the actionable target is 600 px away in the toolbar. The user reads the prompt where their eyes are, then has to hunt. | |
| 95 | + | - **Recommendation:** Pass an `EmptyStateCta { label: "Import folder…", tooltip: Some("Choose a folder of samples to import") }` and wire its return to `state.show_import_menu = true` (or trigger Quick Import directly). The CTA infrastructure already exists in `widgets::empty_state`; this is a one-line change. | |
| 96 | + | ||
| 97 | + | ### M-3. Drag-and-drop has no hover visualisation — Feedback (egui) | |
| 98 | + | ||
| 99 | + | - **Location:** `main.rs:612–642` (drop handler), no corresponding hover state painter in `file_list.rs`. | |
| 100 | + | - **Observation:** The welcome screen tells the user to drag a folder onto the window. eframe's drop handler accepts the file when released — but mid-drag there is no visual indication that the drop will be accepted, no highlighted target, no "Release to import 47 files" hint. On macOS the OS may show a green `+` cursor; on other platforms the user gets nothing. | |
| 101 | + | - **Why it matters:** Feedback during async user actions (drag is async — it happens over time) is one of Tog's hard rules. A user who drags and sees nothing change assumes the app doesn't accept drops. | |
| 102 | + | - **Recommendation:** In the central panel, check `ctx.input(|i| !i.raw.hovered_files.is_empty())` each frame. When true, paint a 2 px `accent_blue` border inset by `space::MD` around the central panel, and render an overlay label "Drop to import N files" centred. Cap painting to the central panel area so it doesn't fight with the sidebar. | |
| 103 | + | ||
| 104 | + | ### M-4. Trial CTA is buried beneath the fold and reads as a self-deprecating joke — Hierarchy / Tone | |
| 105 | + | ||
| 106 | + | - **Location:** `activation.rs:90–108`. | |
| 107 | + | - **Observation:** Below a separator and 24 px of padding, the trial button reads `"I am still testing the software"` (with a `:)` smiley when expired). For users who arrived without a license — the *largest* segment of first-time users — this is the action they need; for users with a license, it's noise. The visual hierarchy is inverted: the license key field gets the heading, the input, the hint, the activate button, the error, the "Get a license key" link — five elements — before the trial entry appears. | |
| 108 | + | - **Why it matters:** Founder-pricing window means trial-conversion ratio is a primary growth metric. Burying the trial CTA — and labelling it with a tone that signals "we don't really want you to do this" — directly reduces trial starts. | |
| 109 | + | - **Recommendation:** Two changes: | |
| 110 | + | 1. Lift the trial entry above the license key as a peer option: render the heading "audiofiles", subhead "Start a free 14-day trial, or activate a license key", then `[Start trial]` as a `primary_button` and below it the key input as a secondary path. | |
| 111 | + | 2. Rewrite the label: "Start free trial — 14 days, no card" (and when expired: "Trial expired — activate to continue"). No emoji. | |
| 112 | + | ||
| 113 | + | ### M-5. Quick Import has no pre-confirmation — Anticipation / Forgiveness | |
| 114 | + | ||
| 115 | + | - **Location:** `file_list.rs:58–66` (welcome link), `toolbar.rs:259–269` (toolbar dropdown). | |
| 116 | + | - **Observation:** "Quick Import" goes: click → folder picker → instant background import. No "About to import 1,247 files (~3.2 GB) from /Users/.../Samples — continue?" preview. On a folder accidentally containing tens of thousands of files (a Library, a Downloads folder), the user commits before they know what they committed to. | |
| 117 | + | - **Why it matters:** Forgiveness. The cancel button on the progress screen works, but partial imports leave half a vault behind. Pre-flight check is cheaper than rollback. | |
| 118 | + | - **Recommendation:** Before flipping to `ImportMode::Importing`, do a quick `walkdir` (already happens in the import worker) and show a one-step preview modal: "Found 1,247 audio files (~3.2 GB) in `<path>`. Import all? [Import] [Cancel]". For folders under some threshold (50 files? — calibrate against typical first-import size) skip the preview to keep small imports frictionless. Tag this preview modal with `confirm_modal` so it inherits the standard scaffold. | |
| 119 | + | ||
| 120 | + | ### M-6. Sync has no first-touch prompt and no "what is this?" — Visibility / Discoverability | |
| 121 | + | ||
| 122 | + | - **Location:** `toolbar.rs:301–306` (Sync button), `sync_panel.rs:26–63`. | |
| 123 | + | - **Observation:** Sync is opt-in. The toolbar button reads "Sync" with no indicator that the user *isn't yet using sync*. Clicking it opens a panel whose first state is "Disconnected" with a sign-in flow, but there's no first-run touchpoint that says "audiofiles can back up your library across devices — set it up now or later." A user who never clicks Sync will never know it exists. | |
| 124 | + | - **Why it matters:** Sync is a value driver for the MNW platform (creator tiers depend on it) and is the only ongoing-revenue surface in the app. Onboarding without surfacing it leaves money and stickiness on the table. | |
| 125 | + | - **Recommendation:** After the *first successful import*, show a one-time `info_banner` in the file list: "Your library is local. Set up cloud sync to back it up and use it on other devices." with `[Set up sync]` and `[Maybe later]` buttons. Persist the dismissal flag the same way `vfs_explained` is persisted (`backend.set_config("sync_intro_dismissed", "1")`). The banner widget already exists. | |
| 126 | + | ||
| 127 | + | ### M-7. No audio-output device prompt or diagnostic — Visibility of state (egui) | |
| 128 | + | ||
| 129 | + | - **Location:** No corresponding screen exists. Audio device selection (if any) is buried in `settings_panel.rs`. | |
| 130 | + | - **Observation:** Preview (Space-to-play) sends audio to whatever device cpal picks by default. If that device is wrong (HDMI monitor with no speakers, Bluetooth headphones currently connected to another device, etc.), the user hits Space and… silence. There's no level meter, no "playing through ____" indicator, no diagnostic. | |
| 131 | + | - **Why it matters:** This is the single most likely first-run "the app is broken" moment. Sample browsers are audio tools — silent preview undermines trust immediately. Tognazzini's visible-state rule. | |
| 132 | + | - **Recommendation:** Two-part: | |
| 133 | + | 1. In the footer (`ui/footer.rs`) when preview is active, show a small "▶ <device name>" label. Use a word, not the play glyph, per brand rule — `Preview: <device>`. | |
| 134 | + | 2. When the user first opens the app or first plays a sample on a fresh install, surface a one-shot toast/banner "Preview output: <device>. Change in Settings → Preview." Same dismissal mechanism as M-6. | |
| 135 | + | ||
| 136 | + | ### M-8. Activation error message uses raw RGB and persists silently after fix — Visibility / Forgiveness | |
| 137 | + | ||
| 138 | + | - **Location:** `activation.rs:36–38, 79–82`. | |
| 139 | + | - **Observation:** When activation fails, `activation_error` is set; it's only cleared on the *next* `start_activation` call (line 136), not when the user edits the key field. So the user sees an error, types corrections into the field, and the error remains red beneath their edits — looks like the new edit is also wrong, when in reality nothing has been tried yet. | |
| 140 | + | - **Why it matters:** Stale error state is its own confusion. Mac HIG: errors should clear when the precondition that caused them changes. | |
| 141 | + | - **Recommendation:** Wire `activation_error = None` in the key-field's `changed()` handler. Also fold into the C-4 fix (per-error-class messaging). | |
| 142 | + | ||
| 143 | + | --- | |
| 144 | + | ||
| 145 | + | ## Minor (worth fixing during normal cleanup) | |
| 146 | + | ||
| 147 | + | ### m-1. Activation field placeholder is invented data the user might type — Affordances | |
| 148 | + | ||
| 149 | + | - **Location:** `activation.rs:59`. | |
| 150 | + | - **Observation:** The hint text `"bright-castle-forest-river-falcon"` looks like a plausible key format and may be mistaken for either an example or a default value to clear. Some users will paste it in. | |
| 151 | + | - **Recommendation:** Either make the format obviously a placeholder (`five-word-license-key-example` in muted italics) or drop the hint and use a label above the field: "License key (5 words separated by hyphens)". | |
| 152 | + | ||
| 153 | + | ### m-2. "Get started in seconds:" reads as marketing copy — Tone (egui) | |
| 154 | + | ||
| 155 | + | - **Location:** `file_list.rs:50`. | |
| 156 | + | - **Observation:** First-time users don't need the value claim; they need the instruction. The subhead consumes a line of vertical real estate without telling the user what to do. | |
| 157 | + | - **Recommendation:** Replace with the action itself: "Three steps to your first sample:" or drop the subhead entirely. | |
| 158 | + | ||
| 159 | + | ### m-3. Vault setup "Vault name" input has no default visible and silently falls back to "Library" — Visibility | |
| 160 | + | ||
| 161 | + | - **Location:** `vault_setup.rs:71–75, 92–97`. | |
| 162 | + | - **Observation:** The field is empty until the user types. If they submit empty, `finalize_vault_setup` quietly substitutes "Library". The user has no way to know that's what just happened. | |
| 163 | + | - **Recommendation:** Either (a) pre-fill `vault_setup_name` with "Library" on first entry to the screen so the user can see and edit it, or (b) use the field's hint text to say `hint_text("Library")` so the fallback is visible. Option (a) is simpler. | |
| 164 | + | ||
| 165 | + | ### m-4. Vault-setup heading is verbose — Hierarchy (egui) | |
| 166 | + | ||
| 167 | + | - **Location:** `vault_setup.rs:21`. | |
| 168 | + | - **Observation:** "Choose where to store your sample library" — wraps on narrow windows, takes two lines, competes with the body content. | |
| 169 | + | - **Recommendation:** "Where should we store your library?" — shorter, still warm. Or just "Set up your library" with the path-picking copy in the body. | |
| 170 | + | ||
| 171 | + | ### m-5. The action ordering in `confirm_action_row` is primary-on-left — Consistency | |
| 172 | + | ||
| 173 | + | - **Location:** `widgets.rs:88–113`. | |
| 174 | + | - **Observation:** The helper renders `[Confirm] [Cancel]` in that left-to-right order. macOS and Windows native dialogs put the default/primary action on the *right* (Cancel left, OK right). The current ordering is internally consistent across the app but deviates from platform convention. | |
| 175 | + | - **Why it matters:** A user with platform muscle memory will swing the mouse to the right for "yes" and hit Cancel. Especially painful in `danger_button` rows (Delete on the left, Cancel on the right) where the mis-hit is destructive. | |
| 176 | + | - **Recommendation:** Either flip to `[Cancel] [Confirm]` and update the doc comment, or hold the line and document the deviation explicitly with a rationale (e.g. "reading order matches the question — '... yes/no'"). Bring up in design discussion before changing — this is a coin flip and the consistency win exists either way. | |
| 177 | + | ||
| 178 | + | ### m-6. "Reset to default" is a small_button next to "Choose folder..." but only conditionally visible — Affordances | |
| 179 | + | ||
| 180 | + | - **Location:** `vault_setup.rs:54–58`. | |
| 181 | + | - **Observation:** The small reset button appears only when a custom path has been chosen, so the row layout shifts mid-flow. Less disruptive: always render the reset button but disabled when no custom path is set. | |
| 182 | + | - **Recommendation:** Use `ui.add_enabled(self.vault_setup_path.is_some(), egui::Button::new("Reset to default"))` so the row width is stable. | |
| 183 | + | ||
| 184 | + | ### m-7. No keyboard escape from import wizard screens — Modes (egui) | |
| 185 | + | ||
| 186 | + | - **Location:** `import_screens/configure.rs`, `tagging.rs`. The Escape handler in `editor.rs:217–350` does not unwind import mode. | |
| 187 | + | - **Observation:** Once the user clicks Files… / Folder (customize)… and lands in the configure panel, Escape doesn't back out — they have to find the Cancel button. The shortcut help (F1) lists Escape as a generic "close dialogs" key, building a false expectation. | |
| 188 | + | - **Recommendation:** Extend the Escape handler in `editor.rs` to handle `ImportMode::Configure` and `ImportMode::Tagging` by calling `state.cancel_import()` (or a softer `state.back_to_browser()`). Don't allow Escape during `Importing` mode — running imports should require the explicit Cancel click for safety. | |
| 189 | + | ||
| 190 | + | ### m-8. The Activate button doesn't surface progress beyond label text — Feedback | |
| 191 | + | ||
| 192 | + | - **Location:** `activation.rs:73–77`. | |
| 193 | + | - **Observation:** While activation is in flight, the button text flips to "Activating…" and is disabled. No spinner, no other indicator. On slow networks, 5–10 seconds of "Activating…" reads like a hang. | |
| 194 | + | - **Recommendation:** Either add a small `ui.spinner()` to the right of the activate button while `self.activating`, or render a thin indeterminate progress bar beneath the button. Both are 2-line additions. | |
| 195 | + | ||
| 196 | + | ### m-9. "Found existing library" is informational but coloured green — Mappings | |
| 197 | + | ||
| 198 | + | - **Location:** `vault_setup.rs:24–32`. | |
| 199 | + | - **Observation:** The green colour reads as a success state ("you did something right"), but the user hasn't *done* anything yet — it's just an observation. Greens-as-information train users to ignore later greens-as-success. | |
| 200 | + | - **Recommendation:** Use `text_secondary` plus an `info_banner` background, not `accent_green`. Reserve green for actions the user just completed successfully. | |
| 201 | + | ||
| 202 | + | --- | |
| 203 | + | ||
| 204 | + | ## Polish | |
| 205 | + | ||
| 206 | + | ### p-1. Welcome screen vertical rhythm is too generous — Hierarchy (egui) | |
| 207 | + | ||
| 208 | + | - **Location:** `file_list.rs:42, 48, 53, 67, 73, 80, 86`. | |
| 209 | + | - **Observation:** `add_space(ui.available_height() * 0.15)` then `space::SECTION` then `space::LG` then several `space::SM` and a `space::XL` push the three steps into the lower-middle of the window. On a 1440×900 display the bottom hint is below the fold. | |
| 210 | + | - **Recommendation:** Drop the top padding to `0.08 * available_height` and the `space::XL` after step 3 to `space::LG`. Aim for the bottom hint to be visible on a 13" laptop screen at default zoom. | |
| 211 | + | ||
| 212 | + | ### p-2. Help overlay is two tabs of dense text — Discoverability | |
| 213 | + | ||
| 214 | + | - **Location:** `overlays.rs::draw_help_overlay`. | |
| 215 | + | - **Observation:** F1 opens a wall of keyboard shortcuts and a wall of feature descriptions. No structure beyond the two tabs; no inline screenshots; no "first 5 things to try." | |
| 216 | + | - **Recommendation:** Defer the visual redesign to a later phase, but add a "Getting started" tab as the *first* tab, containing the same three numbered steps from the welcome screen plus 3–4 follow-up suggestions ("Right-click a sample to see actions", "Press / to search", "Drop a `.wav` into the keyboard to play it as an instrument"). This also gives M-1 a place to point ("show welcome"). | |
| 217 | + | ||
| 218 | + | ### p-3. Trial expired message tone — Tone | |
| 219 | + | ||
| 220 | + | - **Location:** `activation.rs:100`. | |
| 221 | + | - **Observation:** `"I am still \"testing\" the software :) ({days} days)"` — when the user's trial has expired and they're being prompted to convert, the smiley is at best off-key and at worst patronising. | |
| 222 | + | - **Recommendation:** "Trial expired — activate a license to keep using audiofiles." No emoji, no scare quotes. Pair with the C-4 hierarchy fix. | |
| 223 | + | ||
| 224 | + | ### p-4. Import dropdown labels are slightly inconsistent — Consistency | |
| 225 | + | ||
| 226 | + | - **Location:** `toolbar.rs:261, 273, 284`. | |
| 227 | + | - **Observation:** "Quick Import Folder…", "Files…", "Folder (customize)…" — three different phrasings for what is effectively three preset paths. | |
| 228 | + | - **Recommendation:** Align: "Folder (quick)", "Files…", "Folder (customise)…". Or front-load the noun: "Import folder…", "Import files…", "Import folder with options…". Either is more scannable than the current mix. | |
| 229 | + | ||
| 230 | + | ### p-5. Welcome footer hint mixes a key name and a verb — Consistency | |
| 231 | + | ||
| 232 | + | - **Location:** `file_list.rs:88`. | |
| 233 | + | - **Observation:** "F1 for keyboard shortcuts · Right-click for options" — "F1" is a key, "Right-click" is an action. Mixed register. | |
| 234 | + | - **Recommendation:** "Press F1 for shortcuts · Right-click samples for options". Symmetry helps scan. | |
| 235 | + | ||
| 236 | + | --- | |
| 237 | + | ||
| 238 | + | ## Patterns across these findings | |
| 239 | + | ||
| 240 | + | Three patterns dominate, in descending impact: | |
| 241 | + | ||
| 242 | + | 1. **The design-system gate stops at the audiofiles-browser crate boundary.** C-1, m-9, and most of the activation/vault-setup colour and spacing issues all flow from this. Fix the gate to cover all UI-bearing crates and most of these become Phase 0 work, not Phase 1 work. | |
| 243 | + | ||
| 244 | + | 2. **Onboarding-critical surfaces lack guidance and recovery.** C-2, C-4, M-1, M-2, M-4, M-6, M-7 are all instances of the same thing: the system has a state, and the user can't see it, recover from it, or learn what to do next. The fixes are all small (an extra label, a CTA wiring, an `info_banner` first-touch), but the cumulative effect on first-run experience is large. | |
| 245 | + | ||
| 246 | + | 3. **Modal/input ergonomics need a second pass.** C-3, m-1, m-7, m-8 are friction points that affect every keyboard-driven user from the very first vault-name modal onward. The autofocus fix in particular is universal: every `name_modal` caller benefits. | |
| 247 | + | ||
| 248 | + | When the C-tier items land, re-run the Phase 0 gates against `audiofiles-app/src/` before opening Phase 2 (the sidebar / vault management surface) — there are likely to be similar consolidation gaps in the activation polling and trial state code paths that are out of scope here. |
| @@ -0,0 +1,258 @@ | |||
| 1 | + | # UX Audit: audiofiles — Phase 2 (Sidebar & vault management) | |
| 2 | + | ||
| 3 | + | **Date:** 2026-05-20 | |
| 4 | + | **Scope:** Left sidebar (vault picker, VFS rows, collections, tag tree, inline create/rename rows, context menus, first-run VFS banner) and the vault-management portion of Settings (storage list, vault rename/remove, scan stats, "Add Vault" subsection). | |
| 5 | + | **Detected stack:** egui (eframe). | |
| 6 | + | **Method:** universal pass (10 principles) + egui-specific pass + cross-cutting flat-design check. Phase 0 (design system) and Phase 1 (onboarding) are landed; this phase audits daily-use behaviour, not first-run. | |
| 7 | + | **Out of scope:** sample table, detail panel, instrument/edit windows, import flows, sync internals, branding. | |
| 8 | + | ||
| 9 | + | The user's day-to-day with audiofiles starts here: pick a vault, open a collection or filter by tags, browse samples. Everything in this audit is a surface the user *returns to*, often dozens of times per session. The cost of friction here compounds. | |
| 10 | + | ||
| 11 | + | --- | |
| 12 | + | ||
| 13 | + | ## The daily-use path | |
| 14 | + | ||
| 15 | + | 1. **Open app** → most recently used vault is active. Sidebar shows Vaults / Collections / Tags sections. | |
| 16 | + | 2. **Switch vault** → sidebar ComboBox (multi-vault case) or sidebar list row click. | |
| 17 | + | 3. **Open collection** → click in Collections section. Dynamic collections re-apply their saved filter; manual collections show their member set. | |
| 18 | + | 4. **Filter by tag** → expand Tags section, click a leaf or parent. Multi-tag filters compose via the search row. | |
| 19 | + | 5. **Manage vaults** → Settings → Storage (rename, remove, scan, add). | |
| 20 | + | 6. **Mid-session create** → "+ New Vault", "+" (new collection), right-click folder → "New folder" in the central panel. | |
| 21 | + | ||
| 22 | + | --- | |
| 23 | + | ||
| 24 | + | ## Critical (fix before shipping) | |
| 25 | + | ||
| 26 | + | ### C-1. Collection delete is unconfirmed and silent — Forgiveness / Visibility (egui) | |
| 27 | + | ||
| 28 | + | - **Location:** `sidebar.rs:241–258`. Right-click a collection → "Delete" → `state.backend.delete_collection(id)` executes immediately. | |
| 29 | + | - **Observation:** Unlike vault and sample deletion (both routed through `pending_confirm` → `draw_confirm_dialog`), collection delete fires synchronously with no modal, no `status` update, no toast. The state is reset (if the deleted collection was active, it deactivates) but the user receives no feedback that anything happened beyond the row vanishing. | |
| 30 | + | - **Why it matters:** Collections are data — especially manual collections, which can represent hours of curation. Dynamic collections at least preserve their underlying filter logic in the user's head, but a manual collection of 200 hand-picked samples is gone with no undo path. This is the audit's textbook forgiveness failure: cost of mistake high, friction of confirm low. Also a visibility failure: the user can't tell whether the click took effect, hit a no-op, or did something elsewhere. | |
| 31 | + | - **Recommendation:** Route through `ConfirmAction::DeleteCollection { coll_id, coll_name }`, mirroring `DeleteVfs`. Wire into `draw_confirm_dialog` and `execute_confirmed_action`. The confirm modal already has `danger: true` styling. While there, post a status message after the delete completes (`state.status = format!("Deleted collection: {name}")`) so the silent-state-change concern is closed in one PR. | |
| 32 | + | ||
| 33 | + | ```rust | |
| 34 | + | // sidebar.rs (replace the inline backend call) | |
| 35 | + | if widgets::danger_button(ui, "Delete").clicked() { | |
| 36 | + | state.pending_confirm = Some(ConfirmAction::DeleteCollection { | |
| 37 | + | coll_id, coll_name: coll_name.clone(), | |
| 38 | + | }); | |
| 39 | + | ui.close_menu(); | |
| 40 | + | } | |
| 41 | + | ``` | |
| 42 | + | ||
| 43 | + | ### C-2. Vault switch is silent and not guarded against in-flight work — Visibility / Forgiveness (egui) | |
| 44 | + | ||
| 45 | + | - **Location:** `sidebar.rs:121–162` (picker), `main.rs:521–527` (switch dispatch), `state/navigation.rs:243–254` (`select_vfs`). | |
| 46 | + | - **Observation:** Clicking a different vault triggers `VaultAction::SwitchVault(path)`, which the App processes at the top of the frame: `self.browser = None`, tear down sync, rebuild against the new vault path. There's no "Switching to <name>…" feedback, no spinner during rebuild, and no guard for in-flight state: an active import worker, a pending sync push, or a queued tag review can be cut mid-operation without warning. On a large database the rebuild can take a noticeable beat; on a small one it's instant but the central panel resets without explanation. | |
| 47 | + | - **Why it matters:** Two compounded failures. (a) Visibility — Tognazzini's rule: any action that takes more than ~100 ms must show progress. (b) Forgiveness — the user is one click away from interrupting their own in-progress work with no warning. The sidebar is a frequently-used surface, so accidental clicks (especially in the ComboBox where similarly-named vaults sit one item apart) are likely. | |
| 48 | + | - **Recommendation:** Two parts. | |
| 49 | + | 1. **Pre-switch guard.** Before committing the switch, check whether any of the following are true: `import_mode != ImportMode::None`, `sync.status.pending_changes > 0` and `sync.status.state == Syncing`, `import_file_errors` non-empty pending review, or a bulk modal is open. If any are true, surface a `confirm_modal` "Switch vaults? Your <import / sync / review> will be cancelled." with `[Cancel] [Switch anyway]`. | |
| 50 | + | 2. **Feedback during switch.** Set `state.status = format!("Switching to: {name}…")` immediately on commit; rebuild posts the existing "Switched to: <name>" status on completion. For instant rebuilds the user sees both flicker by; for slow ones they see the in-flight message — either way, the *gulf of evaluation* is closed. | |
| 51 | + | ||
| 52 | + | ### C-3. "Delete" is hidden when only one vault exists — Mappings / Discoverability (egui) | |
| 53 | + | ||
| 54 | + | - **Location:** `sidebar.rs:195` — `if vfs_count > 1 && widgets::danger_button(ui, "Delete").clicked()`. | |
| 55 | + | - **Observation:** The guard preventing the user from deleting their last vault is correct — but it's enforced by *removing the menu item entirely*. A user with one vault who right-clicks expecting Delete sees only Rename and concludes the feature doesn't exist. | |
| 56 | + | - **Why it matters:** Norman's mappings rule: the *capability* exists ("you can replace this vault by creating a new one and deleting the old"), but the *affordance* implies otherwise. A user wanting to delete-and-recreate is stuck guessing — and may simply orphan the old vault by adding a new one in Settings without removing the first. | |
| 57 | + | - **Recommendation:** Always render the Delete item, but disabled with a tooltip explaining the constraint: | |
| 58 | + | ||
| 59 | + | ```rust | |
| 60 | + | let delete_enabled = vfs_count > 1; | |
| 61 | + | let delete_btn = egui::Button::new( | |
| 62 | + | egui::RichText::new("Delete").color(theme::accent_red()) | |
| 63 | + | ); | |
| 64 | + | let resp = ui.add_enabled(delete_enabled, delete_btn); | |
| 65 | + | let resp = if !delete_enabled { | |
| 66 | + | resp.on_disabled_hover_text("Create another vault first — audiofiles needs at least one.") | |
| 67 | + | } else { resp }; | |
| 68 | + | if resp.clicked() { | |
| 69 | + | state.pending_confirm = Some(ConfirmAction::DeleteVfs { vfs_id, vfs_name }); | |
| 70 | + | ui.close_menu(); | |
| 71 | + | } | |
| 72 | + | ``` | |
| 73 | + | ||
| 74 | + | Same principle should apply in Settings → Storage `Remove` (already disabled there but no tooltip explains why). | |
| 75 | + | ||
| 76 | + | --- | |
| 77 | + | ||
| 78 | + | ## Major (high impact, lower urgency) | |
| 79 | + | ||
| 80 | + | ### M-1. Two divergent "add vault" paths — Consistency / Mappings | |
| 81 | + | ||
| 82 | + | - **Location:** `sidebar.rs:202` ("+ New Vault" → `draw_vfs_create_modal` with name-only input) and `settings_panel.rs:163–221` ("Add Vault" subsection: name + path picker + unsafe-mode checkbox + Create New / Add Existing). | |
| 83 | + | - **Observation:** The sidebar's "+ New Vault" creates an in-database VFS (a virtual sub-vault, conceptually). The Settings "Add Vault" creates a *separate database* (a top-level vault, separate disk location). These are different operations and produce different artifacts — but the labels read the same, both invoked from a "+" or "Add" affordance, and there's no visible cue to the user that one is "add another shelf to this room" and the other is "add another room to the house." | |
| 84 | + | - **Why it matters:** This is the deepest concept-model gap in the sidebar. Users will pick whichever entry point is closest, and end up with vaults-of-vaults or duplicate libraries depending on which they hit. The terminology layering ("Vault" can mean either thing) makes the bug invisible until the user notices their samples aren't where they expected. | |
| 85 | + | - **Recommendation:** Rename to reflect the level. Sidebar "+ New Vault" → "**+ New section**" or "**+ Sub-vault**" with a tooltip "Add a sub-vault inside this database." Settings "Add Vault" stays as "Add Vault" since it really is the top-level concept. Alternatively (bigger change): collapse the sidebar's "+ New Vault" into a sub-folder operation under the active vault, and reserve "vault" exclusively for the top-level database concept. The dual semantics is the underlying bug; one of the two labels needs to move. | |
| 86 | + | ||
| 87 | + | ### M-2. Redundant vault management between sidebar and Settings — Consistency | |
| 88 | + | ||
| 89 | + | - **Location:** `sidebar.rs:121–162` (vault picker + per-row rename/delete menu) and `settings_panel.rs:69–100` (vault list with rename + remove + scan + per-row active/offline badge + paths). | |
| 90 | + | - **Observation:** Vault rename and remove exist in both surfaces but with different capabilities — Settings shows the full path and the status badge; the sidebar shows neither. Vault switching exists in both — sidebar via ComboBox, Settings via row click. There's no single source of truth. | |
| 91 | + | - **Why it matters:** Raskin's monotony rule: do one thing in one place. Two paths to the same operation with different visible state produces "where did I do that?" confusion six months in. Worse, the sidebar's flat list doesn't show paths, so a user with two vaults named "Library" (default name) in different folders can't tell them apart without opening Settings. | |
| 92 | + | - **Recommendation:** Decide the canonical surface and demote the other. My instinct: keep the sidebar list as the *switch* affordance (cheap, frequent, always-visible), and demote the per-row rename/delete to Settings only. Rename/delete are infrequent, destructive, deserve the deliberate-trip-to-Settings overhead. Alternative: make the sidebar list show paths on hover (`on_hover_text`) and drop the Settings Storage list down to just stats and the Add Vault subsection. Either works; the current half-and-half doesn't. | |
| 93 | + | ||
| 94 | + | ### M-3. Storage stats are pull-only and silently stale — Visibility / Feedback (egui) | |
| 95 | + | ||
| 96 | + | - **Location:** `settings_panel.rs:133–146`. | |
| 97 | + | - **Observation:** The "Scan" button populates `storage_cache` with sample count + total bytes + DB bytes. Once populated, the stats are shown forever — but they don't refresh after imports, deletes, or moves. The user opens Settings a week later, sees "47 samples, 320 MB" and has no idea whether that's current or week-old. | |
| 98 | + | - **Why it matters:** Tognazzini's visible-state principle: any displayed value should reflect current state, or be clearly marked as stale. Silent staleness is worse than no data — it's data the user trusts incorrectly. | |
| 99 | + | - **Recommendation:** Display a relative timestamp next to the stats: `"Last scanned 2 minutes ago"` / `"Last scanned 7 days ago"`. Use `text_muted` `.small()`. After 24 hours, change the colour to `accent_yellow` and append "Re-scan to refresh." Implementation: capture `Instant::now()` (or `chrono::Utc::now()`) into the cache alongside the numbers. Format as humanised duration. | |
| 100 | + | ||
| 101 | + | ### M-4. Tag tree parents that are also leaves have ambiguous click target — Affordances (egui) | |
| 102 | + | ||
| 103 | + | - **Location:** `sidebar.rs:83–115` — `draw_tag_node` for the case `node.is_leaf && !node.children.is_empty()`. | |
| 104 | + | - **Observation:** When a tag exists at a parent level (e.g. user tagged some samples just `"genre.house"` AND tagged others `"genre.house.deep"`), the sidebar shows the parent as both a `CollapsingHeader` (click-to-expand) and a `selectable_tag` (click-to-filter). These overlap in a single visual row: the disclosure triangle expands; the label, depending on where the user clicks, may filter OR may also expand depending on egui's `CollapsingHeader` hit area. | |
| 105 | + | - **Why it matters:** Norman's affordances rule: the user needs to know what each click target does. Today they don't — and the failure mode is silent (the wrong action happens, no error, just unexpected state). Users learn to triple-click to make sure something happened. | |
| 106 | + | - **Recommendation:** Visually separate the two affordances. Render the disclosure triangle as a *separate clickable area* (small chevron) followed by the label as a selectable tag. egui's `CollapsingHeader::show` doesn't make this easy out of the box; the cleanest fix is to render the tag node row manually: | |
| 107 | + | ||
| 108 | + | ``` | |
| 109 | + | [▸] genre.house ← chevron toggles open; label filters | |
| 110 | + | ``` | |
| 111 | + | ||
| 112 | + | If the manual layout is too invasive for now, at least surface a hint via `on_hover_text("Click to filter; click the arrow to expand")` so the dual nature is documented. | |
| 113 | + | ||
| 114 | + | ### M-5. Collection type (dynamic vs manual) is signalled by an icon glyph in a no-emoji UI — Consistency / Brand-rule | |
| 115 | + | ||
| 116 | + | - **Location:** `sidebar.rs:218–251` and `widgets.rs` brand-rule exception list. | |
| 117 | + | - **Observation:** Dynamic vs manual collections are differentiated by an icon (per the inventory, "filled" vs "empty"). The codebase has an explicit brand rule against emoji/icons in UI copy (CLAUDE.md), with a documented exception list in `widgets.rs` covering only sort arrows and file-tree node prefixes. The collection icons aren't in the exception list — they're either new violations or are using a non-glyph rendering I didn't fully verify. | |
| 118 | + | - **Why it matters:** Two failures. (a) Inconsistency — the codebase rule should hold here too. (b) Discoverability — even with the icons, a new user has no way to know what "filled" vs "empty" means; they need to right-click and notice the rename/delete menu is the same for both. The type matters because dynamic collections update automatically and manual ones don't, and the user benefits from knowing which is which at a glance. | |
| 119 | + | - **Recommendation:** Replace the icon with a small text label or weight cue. Options: | |
| 120 | + | - Suffix the name: `"Kicks under 120 BPM (auto)"` vs `"My favourites"` (no suffix). Cheap, accessible, no glyph. | |
| 121 | + | - Two-line row: name on top, `auto-updating · N tracks` or `12 tracks` muted small underneath. Richer, more space. | |
| 122 | + | - Italic for dynamic, regular for manual. Pure typographic cue, no glyph, low ceremony. | |
| 123 | + | ||
| 124 | + | I'd pick option 1 — "(auto)" suffix in `text_muted` parens — as the cheapest fix that closes both the brand-rule and discoverability gaps. | |
| 125 | + | ||
| 126 | + | ### M-6. No way to manage tags from the sidebar — Mappings / Discoverability | |
| 127 | + | ||
| 128 | + | - **Location:** `sidebar.rs:313–348`. No context menu on tag nodes. | |
| 129 | + | - **Observation:** Tags are stored per-sample. To rename a tag globally ("synth" → "synths") or delete a tag from every sample at once, the user has to right-click *each sample* using the tag and edit individually — or use the bulk-tag modal (which only operates on the current selection, not "all samples with tag X"). The sidebar shows the tag hierarchy prominently but exposes no operations on it. | |
| 130 | + | - **Why it matters:** Tags are the primary organizational mechanic that audiofiles uses; a user with 5,000 samples and 30 tags can't safely rename a tag without iterating across thousands of selections. The feature exists in the data model but isn't reachable from the UI surface that displays the data. | |
| 131 | + | - **Recommendation:** Add a tag context menu (right-click a leaf or folder node): | |
| 132 | + | - "Rename tag…" → modal: "Rename `<tag>` to: ____" → bulk-replace across all matching samples. | |
| 133 | + | - "Remove tag from all samples…" → confirm modal → bulk-remove. | |
| 134 | + | - "Filter by tag" (matches today's click behaviour, surfaced for discoverability). | |
| 135 | + | ||
| 136 | + | Implementation: each is a single SQL update across the tags table; the bulk modals already exist as a pattern (`bulk_tag_modal`). | |
| 137 | + | ||
| 138 | + | ### M-7. Tag search field has no clear affordance — Affordances / Forgiveness | |
| 139 | + | ||
| 140 | + | - **Location:** `sidebar.rs:319–325`. | |
| 141 | + | - **Observation:** The tag search is a `TextEdit` with `hint_text("Filter tags...")`. To clear, the user must select-all and delete. No "x" button, no Esc-to-clear (the global Escape handler clears the *sample* search but not the *tag* search). | |
| 142 | + | - **Why it matters:** Small thing, but tagged onto a frequently-used input. The sample search has a Clear button (`toolbar.rs:54–59`); the tag search should match. | |
| 143 | + | - **Recommendation:** Render the same pattern as `toolbar.rs:54–59`: when `state.tag_search.is_empty() == false`, append a `[Clear]` small-button next to the field. Also extend the global Escape handler in `editor.rs` to clear `state.tag_search` after `state.search_query` if both are set — symmetric forgiveness. | |
| 144 | + | ||
| 145 | + | ### M-8. Offline vault has no recovery affordance — Error messages / Mappings | |
| 146 | + | ||
| 147 | + | - **Location:** `sidebar.rs:129–136` (ComboBox grey-out), `settings_panel.rs:75` (red "offline" badge). | |
| 148 | + | - **Observation:** When a vault's path is unreachable (external drive unplugged, network share down, folder moved), the UI marks it offline and prevents selection. There's no "**Locate…**" button to repoint the registry to the new path, no "Why is it offline?" diagnostic, and no remove-from-list-but-keep-data option. | |
| 149 | + | - **Why it matters:** This is the most common "the app is broken" path that doesn't have a fix-it action. A user who moved their external drive's mount point will see the offline badge forever unless they manually edit the vault registry JSON. | |
| 150 | + | - **Recommendation:** Two parts. | |
| 151 | + | 1. **"Locate…" button** in the Settings Storage row when a vault is offline. Opens a folder picker; on selection, updates the registry path and re-checks reachability. | |
| 152 | + | 2. **Hover/tooltip diagnostic** on the offline badge: `on_hover_text(format!("Last known path: {}. Drive not mounted?", path.display()))`. Cheap, decisive. | |
| 153 | + | ||
| 154 | + | --- | |
| 155 | + | ||
| 156 | + | ## Minor (worth fixing during normal cleanup) | |
| 157 | + | ||
| 158 | + | ### m-1. Sidebar section headers are inconsistent — Consistency | |
| 159 | + | ||
| 160 | + | - **Location:** `sidebar.rs:164` ("Vaults" via `section_header`), lines 211 and 314 (Collections and Tags via `ui.collapsing(...)`). | |
| 161 | + | - **Observation:** "Vaults" gets a `section_header` (strong + separator) and is always-visible. Collections and Tags are `CollapsingHeader` (collapsible). Three sections, two visual treatments. | |
| 162 | + | - **Recommendation:** Pick one. Either collapse all three (`section_header` only for Vaults is consistent with "you always need the vault picker visible" — defensible) or make Vaults collapsible too. I'd keep the current asymmetry but render Collections and Tags headers using `section_header` styling and make the collapse a separate disclosure widget — better visual rhythm. | |
| 163 | + | ||
| 164 | + | ### m-2. Vault rename success status mixes "vault" and "library" — Consistency | |
| 165 | + | ||
| 166 | + | - **Location:** `overlays.rs:489` (comment `// New Library modal`), `overlays.rs:519` (`"Renamed vault to: …"`). | |
| 167 | + | - **Observation:** The comment is stale ("Library" was the old name for the concept), but the user-facing string says "vault." Internal inconsistency only — not user-visible — but the wider terminology drift (Vault / Library / VFS / "sample collection") shows up here. | |
| 168 | + | - **Recommendation:** Fix the comment. Audit the broader terminology in a separate pass: pick one user-facing word ("vault") and use it everywhere; reserve "VFS" for code only; reserve "Library" for never (drop it entirely from user-visible strings — `vault_setup.rs` still uses `"Library"` as a default vault name, and that should change too). | |
| 169 | + | ||
| 170 | + | ### m-3. Single-vault case still renders "Vaults" section header — Hierarchy | |
| 171 | + | ||
| 172 | + | - **Location:** `sidebar.rs:164` is always rendered; the section is non-trivial only when `state.settings.list.len() > 1`. | |
| 173 | + | - **Observation:** When the user has one vault, the "Vaults" heading + first-run banner + single row + "+ New Vault" button consume a lot of vertical space for one click target. The section is "1 of 1" at that point. | |
| 174 | + | - **Recommendation:** When `state.settings.list.len() == 1` and `show_vfs_banner == false`, collapse the section to just the single row + "+ New Vault" button without the section header. Reclaim ~28 vertical pixels. | |
| 175 | + | ||
| 176 | + | ### m-4. "+ New Vault" button has no keyboard shortcut and is below the fold on small windows — Fitts / Discoverability | |
| 177 | + | ||
| 178 | + | - **Location:** `sidebar.rs:202`. | |
| 179 | + | - **Observation:** Creating a new vault is a frequent operation for organising work — but the button is at the bottom of the Vaults section, after the list. On a window narrow enough to need scrolling, it's not visible without scroll. No shortcut. | |
| 180 | + | - **Recommendation:** Pin the button at the top of the section (just under the header) and/or wire to `Cmd+Shift+N` (matching the platform convention for "new folder"). Pair with a similar pin for the Collections "+" button. | |
| 181 | + | ||
| 182 | + | ### m-5. Collection inline rename row doesn't show the old name — Visibility | |
| 183 | + | ||
| 184 | + | - **Location:** `sidebar.rs:262–278`. | |
| 185 | + | - **Observation:** Right-click → Rename pre-fills the input with the old name, which is correct — but if the user clears the field to type something fresh, they lose the reference. A muted "Renaming: <old>" label above the input would help. | |
| 186 | + | - **Recommendation:** Above the inline rename row, render `ui.label(egui::RichText::new(format!("Renaming: {old_name}")).small().color(theme::text_muted()))`. Two lines of code, big clarity win. | |
| 187 | + | ||
| 188 | + | ### m-6. Tag search has no result count — Visibility | |
| 189 | + | ||
| 190 | + | - **Location:** `sidebar.rs:319–346`. | |
| 191 | + | - **Observation:** Filtering tags by substring narrows the tree, but there's no "12 of 47 tags" indicator. The user can't tell if their filter is too narrow (zero results) without scrolling. | |
| 192 | + | - **Recommendation:** When the search field is non-empty, render a small muted "{filtered}/{total}" next to the input. If `filtered == 0`, lift the existing "No matching tags" label adjacent to the input so the user sees the failed match immediately. | |
| 193 | + | ||
| 194 | + | ### m-7. Settings "Scan" button has no progress indication — Feedback | |
| 195 | + | ||
| 196 | + | - **Location:** `settings_panel.rs:135–146`. | |
| 197 | + | - **Observation:** Clicking "Scan" sets `pending_action = ScanStorage` and waits. For large vaults this could take seconds. The button doesn't disable while scanning, doesn't show a spinner, and the user can click it repeatedly. | |
| 198 | + | - **Recommendation:** Add a `state.settings.scanning: bool`. Disable the Scan button when true; render a `ui.spinner()` next to it. Clear the flag when results arrive. | |
| 199 | + | ||
| 200 | + | ### m-8. Active-vault click is a silent no-op — Feedback | |
| 201 | + | ||
| 202 | + | - **Location:** `sidebar.rs:181–200`, `state/navigation.rs:243–254`. | |
| 203 | + | - **Observation:** Clicking the already-active vault row in the sidebar list triggers `select_vfs(i)` again — which clears `current_dir`, `breadcrumb`, `selection`, posts the "Switched to: X" status, and `refresh_contents()`. So it *does* something: it resets the user's navigation state. But the user clicking on what looks like a static label has no expectation that anything will reset. | |
| 204 | + | - **Recommendation:** Guard at the call site: `if i != state.current_vfs_idx { state.select_vfs(i) }`. Make active-row click a no-op. If the user *wants* to reset navigation, double-click the row or add a dedicated "Go to root" affordance. | |
| 205 | + | ||
| 206 | + | ### m-9. VFS banner dismissal is unidirectional — Forgiveness | |
| 207 | + | ||
| 208 | + | - **Location:** `sidebar.rs:166–176`. | |
| 209 | + | - **Observation:** Once the user clicks "Got it" the banner is gone forever (persisted via `vfs_explained` config key). No way to bring it back from Settings or Help. | |
| 210 | + | - **Recommendation:** Mirror the `state.show_welcome()` pattern from Phase 1's M-1: add `state.reset_vfs_explanation()` that sets `vfs_explained = 0`, surface it from Help → "Reset onboarding hints" or similar. | |
| 211 | + | ||
| 212 | + | --- | |
| 213 | + | ||
| 214 | + | ## Polish | |
| 215 | + | ||
| 216 | + | ### p-1. Collections empty state is muted text, no CTA — Hierarchy | |
| 217 | + | ||
| 218 | + | - **Location:** `sidebar.rs:212–213`. | |
| 219 | + | - **Observation:** "No collections yet" sits as muted text with no nearby Create button (the "+" is at the bottom of the section). A first-time user staring at an empty Collections section sees only a label and the heading. | |
| 220 | + | - **Recommendation:** Replace the muted label with a small inline CTA: `ui.label("No collections yet."); if ui.link("Create one").clicked() { state.show_collection_create = true; }`. Or move the "+" button into the empty-state row. | |
| 221 | + | ||
| 222 | + | ### p-2. Tag tree has no "expand all / collapse all" — Discoverability | |
| 223 | + | ||
| 224 | + | - **Location:** `sidebar.rs:342–346`. | |
| 225 | + | - **Observation:** A user with many nested tags (e.g. `genre.house.deep.tech`) collapses the tree to navigate, then loses their place. No expand-all / collapse-all affordance. | |
| 226 | + | - **Recommendation:** Small button row above the tree: `[Expand all] [Collapse all]`. Persist nothing — purely a current-session convenience. | |
| 227 | + | ||
| 228 | + | ### p-3. "Add Vault" subsection in Settings buries the unsafe-mode toggle — Hierarchy | |
| 229 | + | ||
| 230 | + | - **Location:** `settings_panel.rs:182–191`. | |
| 231 | + | - **Observation:** Unsafe mode is a *significant* choice (samples referenced in place, not duplicated — implications for sync, portability, safety). It's currently a checkbox below the name and path inputs, with a warning that appears only when checked. | |
| 232 | + | - **Recommendation:** Lift the unsafe-mode choice into a "Storage style" selector that's part of the primary flow (two radio buttons: "Copy samples into vault (recommended)" vs "Reference samples in place (unsafe mode)"). Explicit choice > hidden default. | |
| 233 | + | ||
| 234 | + | ### p-4. Vault paths are full filesystem paths, untruncated — Hierarchy | |
| 235 | + | ||
| 236 | + | - **Location:** `settings_panel.rs:95`. | |
| 237 | + | - **Observation:** Each vault row shows its full path, which on macOS is often `/Users/<name>/Library/Application Support/audiofiles/<vault>` — wraps or truncates awkwardly in a narrow Settings window. | |
| 238 | + | - **Recommendation:** Show the path with a `~/...` collapse for the home prefix, full-path on hover. Cheap visual de-noising. | |
| 239 | + | ||
| 240 | + | ### p-5. Sidebar resize handle has no visual cue — Affordances | |
| 241 | + | ||
| 242 | + | - **Location:** Sidebar `SidePanel::left(...).resizable(true)`. | |
| 243 | + | - **Observation:** The sidebar can be resized but there's no visible cue (no hover-on-edge cursor change is documented in the code; egui default may or may not provide one). | |
| 244 | + | - **Recommendation:** Verify in-app first — egui usually does render the resize cursor. If not, add a thin separator stroke on the right edge using `theme::border_default()` so the resize affordance reads visually. | |
| 245 | + | ||
| 246 | + | --- | |
| 247 | + | ||
| 248 | + | ## Patterns across these findings | |
| 249 | + | ||
| 250 | + | Three patterns dominate, and they're worth fixing as classes rather than instances: | |
| 251 | + | ||
| 252 | + | 1. **Two paths to the same concept, with different capabilities.** M-1 (sidebar "+ New Vault" vs Settings "Add Vault"), M-2 (sidebar vault management vs Settings Storage), C-3 (Delete missing in one-vault case but present in Settings with a disabled tooltip). Each is the same shape: the same operation surfaces in two places with different visible state, different guards, different copy. Fix by picking one canonical surface per operation and demoting the other to a passive view. | |
| 253 | + | ||
| 254 | + | 2. **Silent state changes — destructive or otherwise.** C-1 (collection delete), C-2 (vault switch), m-8 (active-vault re-click resets navigation), m-7 (scan with no progress). The codebase has a `state.status` channel; it should be used after every mutation, especially destructive ones. A single PR that audits every `state.backend.<mutation>` call and adds a status post would close most of these. | |
| 255 | + | ||
| 256 | + | 3. **The sidebar exposes data the user can't operate on.** M-6 (tag tree has no rename/delete), M-8 (offline vault has no relocate), m-2 (terminology surface that exposes Library/Vault/VFS/Collection without making the relationships clear). The sidebar is the user's index into their library; right now several of its leaves are read-only. Each one is a small feature; together they shift the sidebar from "show me what's there" to "show me what's there *and let me manage it*." | |
| 257 | + | ||
| 258 | + | When the C-tier items land, the next surface worth auditing is the **central sample table + selection model** (Phase 3 — the most-touched surface in the app). The patterns above will probably recur there. |
| @@ -0,0 +1,291 @@ | |||
| 1 | + | # UX Audit: audiofiles — Phase 3 (Central sample table & selection model) | |
| 2 | + | ||
| 3 | + | **Date:** 2026-05-20 | |
| 4 | + | **Scope:** `file_list.rs` (table rendering, sort headers, drag-out, the welcome/empty states inside the table area), `file_list_menus.rs` (row + multi-select + background context menus), `editor.rs` keyboard dispatch (the table's keyboard surface), `detail.rs` (selection-coupled), the selection model in `state/ui.rs`. | |
| 5 | + | **Detected stack:** egui (eframe). | |
| 6 | + | **Method:** universal pass (10 principles) + egui-specific pass + cross-cutting flat-design check. Phase 0/1/2 landed; canonical widgets exist and the design-system gate covers all UI-bearing crates. | |
| 7 | + | **Out of scope:** sidebar (Phase 2), onboarding (Phase 1), settings, modals, sync internals, instrument/edit floating windows, import flows. | |
| 8 | + | ||
| 9 | + | The central table is the user's primary working surface — once they've imported, every session is dominated by selecting, previewing, tagging, moving, deleting, dragging out. Friction here compounds faster than anywhere else in the app. Each finding below was scored against "how often does a daily user hit this per session?" as the second-axis ranking after severity. | |
| 10 | + | ||
| 11 | + | --- | |
| 12 | + | ||
| 13 | + | ## The daily-use shape | |
| 14 | + | ||
| 15 | + | The table reads as a five-column-ish list with a play button at the right edge. Selection drives everything else: the detail panel mirrors the focused row; the footer shows the previewing sample; the context menu branches single vs multi; the drag-out source is the selection; the keyboard shortcuts (j/k for navigation, Enter for preview/open, Cmd+A for select all, Space for play, Shift+F/Shift+D for similar/duplicates) all assume the table has the active focus. Other surfaces (sidebar filters, toolbar search, the sample editor window) feed into and out of the table but don't replace it. | |
| 16 | + | ||
| 17 | + | --- | |
| 18 | + | ||
| 19 | + | ## Critical (fix before shipping) | |
| 20 | + | ||
| 21 | + | ### C-1. Inline tag removal in the detail panel has no undo and no confirmation — Forgiveness (egui) | |
| 22 | + | ||
| 23 | + | - **Location:** `detail.rs:156–159` — `widgets::tag_chip_removable(ui, tag)` returns a click on its inline "x" → `state.backend.remove_tag(hash, tag)` fires immediately. | |
| 24 | + | - **Observation:** Every tag on the focused sample renders with a tiny "x" remove button. A single click — easily mis-clicked when reaching for the chip itself — removes the tag with no confirm, no toast, and no entry in the undo stack. The tag-add side has clear UI (input + Enter or "+" button); the destructive side has no equivalent safety. | |
| 25 | + | - **Why it matters:** Tags are user-curated metadata, often the result of meaningful time investment (auto-suggested tags can be accepted, but manual tags are the user's organizational choice). Accidental removal during normal browsing is a real risk: the chip area is small, the user's cursor often hovers over it while reading metadata, and the destructive button is visually identical to the chip itself except for one extra character. There is no recovery path. | |
| 26 | + | - **Recommendation:** Two complementary fixes. | |
| 27 | + | 1. **Make the removal undoable.** Push `UndoOp::TagRemove { hash, tag }` onto the undo stack on every inline remove, mirroring the bulk-tag undo path. Cmd+Z then restores it. ~20 lines, all in `library.rs`. | |
| 28 | + | 2. **Reduce the accident surface.** The "x" sits inside the chip on hover only — render it dimmed when not hovered and full-opacity on hover. Pattern: extend `tag_chip_removable` to take a `hover_only_remove: bool` arg, and the detail panel passes `true`. The bulk-tag modal can continue showing "x" always. | |
| 29 | + | ||
| 30 | + | ### C-2. Multi-select detail panel still shows one sample's metadata — Visibility of state (egui) | |
| 31 | + | ||
| 32 | + | - **Location:** `detail.rs:12–248`. Detail panel reads `state.selected_node()` which returns the focused row only. | |
| 33 | + | - **Observation:** When the user has 25 samples selected and looks at the detail panel, they see the metadata of one sample — the focused row — with no indication that they're in a multi-selection. The selection count appears only in the context menu header ("{count} items selected"), which is dismissed the moment the menu closes. Tags listed in the detail panel are the focused sample's tags, not the intersection or union of the selection — but nothing tells the user that. | |
| 34 | + | - **Why it matters:** Tognazzini's visible-state rule. Bulk operations (move, tag, rename, delete, export) are some of the highest-leverage table actions, but the user has no persistent "I have N selected" anchor. The risk is high: the user does Cmd+A → starts editing tags in the detail panel → discovers they edited only the focused sample, not the selection. The detail panel's tag-add input *is* per-sample (it calls `add_tag(hash, …)` on the focused hash only) — this is correct behaviour, but invisible. | |
| 35 | + | - **Recommendation:** Branch the detail-panel render on `state.selection.count()`. | |
| 36 | + | - `count == 0`: existing empty state. | |
| 37 | + | - `count == 1`: existing single-sample view. | |
| 38 | + | - `count > 1`: new multi-summary view — heading "N samples selected", a section listing the common metadata fields (only those that match across all selected: e.g. "BPM: 120 (5 samples)" or "BPM: varies"), the union of tags rendered as chips (each with a count badge — "house (12)"), and a primary `[Edit as bulk]` button that opens the bulk-tag modal. | |
| 39 | + | - The footer should *also* surface "N selected" persistently when count > 1 — pair with C-3. | |
| 40 | + | ||
| 41 | + | ### C-3. Bulk operations complete silently — Visibility of state / Feedback (egui) | |
| 42 | + | ||
| 43 | + | - **Location:** `state/bulk_ops.rs::execute_bulk_delete`, the move/rename/re-analyze paths in `import_workflow.rs`. | |
| 44 | + | - **Observation:** Bulk move, bulk rename, and re-analyze open modals; the user confirms; the modal closes; the table updates (reordered or shifted rows); no status message confirms what happened. Delete shows a confirmation modal first (good), but the post-delete status read is generic ("Deleted"). The user has to scan the table and remember what was there before to verify the operation took. | |
| 45 | + | - **Why it matters:** Bulk operations are *the* feature that makes a sample manager usable at scale. Every bulk operation must close the gulf of evaluation. Currently the user sees a modal-close and is left to verify the result themselves. | |
| 46 | + | - **Recommendation:** Status post after every bulk op completes, with counts: | |
| 47 | + | - Move: `"Moved 12 samples to drums/kicks"` | |
| 48 | + | - Rename: `"Renamed 12 samples (pattern: {name}_{bpm})"` | |
| 49 | + | - Delete: `"Deleted 12 samples"` | |
| 50 | + | - Re-analyze: `"Re-analyzing 12 samples..."` (set on start) → `"Re-analyzed 12 samples (3 errors)"` (set on completion). | |
| 51 | + | - Tag add/remove: already posts counts, keep this pattern. | |
| 52 | + | ||
| 53 | + | Pair with C-2's multi-select footer indicator so the user has a stable "selection size" reference both during and after. | |
| 54 | + | ||
| 55 | + | ### C-4. Right-clicking a non-selected row drops the existing selection silently — Mappings / Forgiveness (egui) | |
| 56 | + | ||
| 57 | + | - **Location:** `file_list.rs:464–472` (context menu dispatch) and the surrounding click handlers. | |
| 58 | + | - **Observation:** Per the inventory, the multi-select menu is shown only when right-clicking a *selected* row that's part of the multi-selection. Right-clicking a non-selected row either replaces the selection with just that row or shows the single-row menu — needs verification, but in either case the original 24-row selection is gone. The user expected to right-click to see options for the selection they already have; instead they right-clicked the "wrong" row and lost their selection. | |
| 59 | + | - **Why it matters:** Norman's mappings rule: a right-click is a request for "what can I do here?", not a re-selection command. Every native file manager (Finder, Windows Explorer, Nautilus) keeps the existing selection when right-clicking inside it, and adds the right-clicked row to the selection if it wasn't already in it. The current behaviour silently destroys hard-earned multi-selections. | |
| 60 | + | - **Recommendation:** In the right-click handler at the row level: | |
| 61 | + | - If the right-clicked row is *already in the selection*, leave the selection unchanged and show the multi-select menu (this seems to be the current path — keep it). | |
| 62 | + | - If the right-clicked row is *not in the selection*, replace the selection with just that row before showing the single-row menu (matches OS convention). | |
| 63 | + | - If the user wants to "right-click without losing my selection", they should still have the option — but the common case is keep-selection-if-in-it. The current half-implementation produces surprise. | |
| 64 | + | ||
| 65 | + | --- | |
| 66 | + | ||
| 67 | + | ## Major (high impact, lower urgency) | |
| 68 | + | ||
| 69 | + | ### M-1. Arrow-key navigation autoplays every row — Modes / Forgiveness (egui) | |
| 70 | + | ||
| 71 | + | - **Location:** `editor.rs:301–311`. `select_next()`/`select_prev()` are followed unconditionally by `state.autoplay_current()`. | |
| 72 | + | - **Observation:** Pressing j/↓ or k/↑ to move the selection triggers preview playback of the newly-selected sample. There's no way to navigate the list silently — every row the user lands on plays. | |
| 73 | + | - **Why it matters:** Autoplay is great for browsing. It's *terrible* for navigating to a known row to operate on it (rename, move, delete, find similar). The user trying to reach row 47 hears samples 12, 13, 14, … 47 stuttering past. There's no modifier to suppress autoplay. | |
| 74 | + | - **Recommendation:** Two practical options: | |
| 75 | + | - **Option A** (cheapest): Autoplay only on `j`/`k` (the "vim" keys), not on arrow keys. Users who want silent navigation use arrows; users who want browse-with-preview use j/k. The convention is rare but learnable and the keyboard is already vim-ish. | |
| 76 | + | - **Option B** (more invasive): Add `Shift+j`/`Shift+k` (and the arrow equivalents) as "move selection without preview." But Shift+arrow is already extend-selection — would need a different modifier. Alt or Cmd are the candidates. | |
| 77 | + | ||
| 78 | + | I'd ship Option A and add a settings toggle for "autoplay on arrow keys" defaulted to off. The current behaviour stays available for users who want it. | |
| 79 | + | ||
| 80 | + | ### M-2. Drag-out cooldown blocks valid drags for 2 seconds with no indicator — Feedback (egui) | |
| 81 | + | ||
| 82 | + | - **Location:** `file_list.rs:24–34` and `:457` — the `OS_DRAG_COOLDOWN` mechanism. | |
| 83 | + | - **Observation:** After an OS-level drag ends outside the app, egui's pointer state can become stale; the code installs a 2-second cooldown to prevent re-triggering. During those 2 seconds, the user attempting another drag gets no response — the drag silently doesn't start. No indicator, no status, no cursor cue. | |
| 84 | + | - **Why it matters:** Drag-to-DAW is the path power users hit most. A 2-second invisible cooldown reads as "the app stopped working" — especially on Linux where the OS drag pipeline is finicky. The mitigation is correct (the cooldown prevents a real bug); the user-visible behavior is opaque. | |
| 85 | + | - **Recommendation:** Surface the cooldown state. While `OS_DRAG_COOLDOWN_ACTIVE.load()` is true, the name column's hover text changes from `"Drag to Finder or DAW"` to `"Drag ready in a moment…"`. Cheap, decisive, and turns an invisible lockout into a visible state. While at it, log a tracing warning when the cooldown blocks an attempted drag — helps debug remaining drag bugs. | |
| 86 | + | ||
| 87 | + | ### M-3. Cloud-only samples are marked by icon + colour with no label — Affordances / Error messages (egui) | |
| 88 | + | ||
| 89 | + | - **Location:** `file_list.rs:395–402` — cloud icon `☁` + muted text colour; `:315` — play button hidden; `file_list_menus.rs:33, 89` — Preview/Edit guarded. | |
| 90 | + | - **Observation:** Samples not yet downloaded show a cloud glyph and muted styling. The play button is absent. The context menu Preview and Edit items disappear. There's no explicit "Cloud only — click to download" label, no hover tooltip explaining the state, no inline download affordance. | |
| 91 | + | - **Why it matters:** A user trying to play a sample and finding nothing happens has no diagnosis. The cloud icon is documented in the brand-rule exception list, but a new user doesn't know what it means; even an experienced user may forget given how rarely they encounter cloud-only samples (only when starting a sync session). | |
| 92 | + | - **Recommendation:** Three parts. | |
| 93 | + | 1. **Tooltip on the cloud row**: `on_hover_text("Cloud only — not yet downloaded. Right-click → Download to fetch.")`. | |
| 94 | + | 2. **Add a "Download" context menu item** for cloud-only rows that triggers an explicit fetch. | |
| 95 | + | 3. **Optional**: replace the cloud glyph with a small `[cloud]` text label for clarity (per the no-glyph brand rule). The icon is currently in the documented exception list; consider whether the exception is still earning its keep. | |
| 96 | + | ||
| 97 | + | ### M-4. "Find Similar" / "Find Duplicates" change the table view with no breadcrumb-style affordance — Visibility of state (egui) | |
| 98 | + | ||
| 99 | + | - **Location:** `toolbar.rs:20–32` (the existing "Showing similar samples" banner) and the `similarity_search_hash` flow. | |
| 100 | + | - **Observation:** Triggering "Find Similar" replaces the table contents with similarity results. The toolbar banner says "Showing similar samples" with a `[Clear]` button — good. But the breadcrumb still shows the previous folder path, the sort header still shows the previous sort order, and the URL-equivalent "where am I" is split between two surfaces (banner + breadcrumb) that disagree. | |
| 101 | + | - **Why it matters:** Mode confusion. The user knows they're "in similarity mode" because the banner says so, but the rest of the UI continues to look like the folder browse view. Clicking a row in similarity mode and then hitting Backspace tries to "go up" in the folder hierarchy — does it exit similarity mode or navigate the folder? Without testing, the user can't predict. | |
| 102 | + | - **Recommendation:** When similarity mode is active, the breadcrumb should show "Similar to: <sample name>" instead of the folder path. The sort headers should disable or grey out (similarity results are sorted by similarity score, not by user choice). Backspace should exit similarity mode first, then fall through to "go up" if the user presses it again. The escape hatch is already there as the `[Clear]` button — the breadcrumb just needs to reflect the truth. | |
| 103 | + | ||
| 104 | + | ### M-5. Column reordering and "reset columns" are unavailable — Mappings / Forgiveness | |
| 105 | + | ||
| 106 | + | - **Location:** `file_list.rs:171–202`; `settings_panel.rs:400–419` (column show/hide via checkboxes). | |
| 107 | + | - **Observation:** Users can resize columns (egui's `.resizable(true)`) and toggle visibility (Settings → Appearance), but cannot reorder. Worse, there's no "Reset column widths" affordance — a column accidentally dragged to 5 px wide stays 5 px wide forever, no fix from the UI. | |
| 108 | + | - **Why it matters:** Column layout is one of the most personal preferences in a sample browser. Power users want BPM next to Key (musical adjacency); beginners want Tags first. Reorder is non-trivial in egui, but reset is one button. | |
| 109 | + | - **Recommendation:** Two staged fixes. | |
| 110 | + | - **Immediate**: Add `[Reset columns]` to Settings → Appearance. Resets widths to defaults and (when reorder lands) order. | |
| 111 | + | - **Future**: Right-click on the sort-header row → menu with show/hide checkboxes + reset. Plus drag-to-reorder via egui's draggable area on the header label. Bigger, separate work. | |
| 112 | + | ||
| 113 | + | ### M-6. Tag suggestions in the detail panel have no way to dismiss — Mappings / Modes | |
| 114 | + | ||
| 115 | + | - **Location:** `detail.rs:194–214` (the classification-derived suggestions block). | |
| 116 | + | - **Observation:** Below the tag input, classification-derived suggestions appear (e.g. classification "kick" suggests `drums.kick`, `percussion`, `one-shot`). The user can click `[+suggestion]` to accept. There's no way to dismiss a suggestion the user has decided against — it sits there forever, since the suggestion logic is based on classification + already-applied tags, and rejecting a suggestion doesn't move it into "already-applied." | |
| 117 | + | - **Why it matters:** Tognazzini's anticipation rule is well-served (the suggestions are good); the modal/forgiveness side is poor. The user who has decided "I don't tag my kicks with `percussion` — too generic" sees that suggestion forever, on every kick sample. | |
| 118 | + | - **Recommendation:** Add a small "x" next to each suggestion that records "rejected for this sample" or "rejected for this classification" in a config key. Re-render hides them. The mechanism should be reversible from Settings → Reset suggestions (mirroring the "Show welcome" pattern from Phase 1's M-1). | |
| 119 | + | ||
| 120 | + | ### M-7. Re-analyze has no preview of what will run — Anticipation / Forgiveness | |
| 121 | + | ||
| 122 | + | - **Location:** `file_list_menus.rs:212–223` ("Re-analyze..." menu item). | |
| 123 | + | - **Observation:** Selecting "Re-analyze..." opens the ConfigureAnalysis wizard screen (per Phase 1's wizard step indicator), then runs. For a multi-select of 200 samples that's a significant compute job (BPM detection, spectral analysis, possibly classification) — but the user committed before seeing how long it'll take or which analyses will re-run. The default ConfigureAnalysis dialog has checkboxes for the analysis stages, but no "Estimated time: ~2 minutes" hint and no per-sample preview. | |
| 124 | + | - **Why it matters:** Re-analysis is destructive in the sense that it *overwrites* the previously-computed values. A user who hand-tweaked BPM detection by enabling/disabling the high-pass filter doesn't want a generic re-run to overwrite that work. The current flow runs without warning. | |
| 125 | + | - **Recommendation:** Two parts. | |
| 126 | + | - **Preview row count**: the ConfigureAnalysis screen already shows "{N} samples to analyze" — make this prominent. Add an estimated runtime ("~{N * 5}s for BPM detection on {N} samples"). | |
| 127 | + | - **Confirm before overwrite**: when the user is re-analyzing samples that already have computed values, surface "This will overwrite existing BPM/Key/etc. on {N} samples." with a danger-styled `[Re-analyze]` confirm button. Skip the confirm when the re-analyze is filling in *missing* values only (no overwrite risk). | |
| 128 | + | ||
| 129 | + | ### M-8. "Copy Path" and "Copy Paths" silently overwrite the clipboard — Visibility / Feedback | |
| 130 | + | ||
| 131 | + | - **Location:** `file_list_menus.rs:40–45` (single), `:255–271` (multi). | |
| 132 | + | - **Observation:** Both menu items copy paths to the clipboard and set `state.status = "Copied path"` / `"Copied {count} paths"`. No preview of *which* paths were copied, no toast, just the same status-bar line that may already be showing the last operation's result. A user who right-clicks → "Copy Paths" → then does something else can't recall what's on the clipboard. | |
| 133 | + | - **Why it matters:** Sample browsers feed DAWs that paste paths. The clipboard state is a hand-off; if the user can't trust it, they re-do the copy or open a text editor to paste-and-check. | |
| 134 | + | - **Recommendation:** When the status message is set from a copy-paths action, prefix with the first path and a count: `"Copied: /Users/.../kick_01.wav (+11 more)"`. Persist longer than other statuses (5 s instead of the default 2 s). For the single case, just show the full path: `"Copied: /Users/.../kick_01.wav"`. | |
| 135 | + | ||
| 136 | + | --- | |
| 137 | + | ||
| 138 | + | ## Minor (worth fixing during normal cleanup) | |
| 139 | + | ||
| 140 | + | ### m-1. Parent ".." row uses the same row treatment as samples — Affordances | |
| 141 | + | ||
| 142 | + | - **Location:** `file_list.rs:268–288`. | |
| 143 | + | - **Observation:** The "go up" parent row sits at the top of the list and renders identically to a sample row (same height, same selectable styling), with only its name (`..`) hinting at its role. New users miss it; experienced users sometimes accidentally select it during Cmd+A. | |
| 144 | + | - **Recommendation:** Render the `..` row with a muted text colour (`text_secondary`), an arrow-up glyph or just the word "Up" as the label, and exclude it from `select_all` (Cmd+A picks all samples, never the parent). | |
| 145 | + | ||
| 146 | + | ### m-2. Play button is a 28 px column with a `small_button` inside — Fitts | |
| 147 | + | ||
| 148 | + | - **Location:** `file_list.rs:172` (column exact width), `:319` (small_button). | |
| 149 | + | - **Observation:** The play button is one of the highest-frequency click targets in the app, but its column is the narrowest and its button is `small_button` (egui's ~16 px). Fitts: small + far-from-cursor is the worst case. | |
| 150 | + | - **Recommendation:** Bump the play-button column to 36 px and use a regular `ui.button(...)`. Net width cost: 8 px per row in a column most users keep narrow. | |
| 151 | + | ||
| 152 | + | ### m-3. Sort arrows append to the column label, shifting the layout — Consistency | |
| 153 | + | ||
| 154 | + | - **Location:** `file_list.rs:572–577`. | |
| 155 | + | - **Observation:** Active sort columns render `"Name ▲"`; inactive show `"Name"`. The label width changes between states, nudging the rest of the header row each time the user clicks a different column. | |
| 156 | + | - **Recommendation:** Reserve a fixed-width glyph slot at the right of every sortable header. Inactive columns render a faint dot or a muted "—"; active columns render the arrow in the same slot. Layout is stable. | |
| 157 | + | ||
| 158 | + | ### m-4. Click-to-seek on the waveform has no hover preview — Affordances | |
| 159 | + | ||
| 160 | + | - **Location:** `detail.rs:53–71`. | |
| 161 | + | - **Observation:** Clicking the waveform seeks playback. There's no hover indicator showing where the click would seek to — the user clicks blindly and finds out where they ended up. | |
| 162 | + | - **Recommendation:** While hovering the waveform, paint a vertical accent_blue line at the cursor X position with a small "0:42" time label above it. Cheap, decisive. | |
| 163 | + | ||
| 164 | + | ### m-5. Tag chip remove button is a tiny "x" — Fitts | |
| 165 | + | ||
| 166 | + | - **Location:** `widgets.rs::tag_chip_removable` and `detail.rs:154–160`. | |
| 167 | + | - **Observation:** The "x" is rendered with `small_button("x")` (egui ~16 px). Already flagged for forgiveness reasons in C-1; also a Fitts target issue independently. | |
| 168 | + | - **Recommendation:** Combined with C-1's hover-only-remove pattern: render the chip slightly larger when hovered (target 24 px minimum), and bring the "x" to full opacity then. | |
| 169 | + | ||
| 170 | + | ### m-6. Background context menu has Import Files / Import Folder but no Paste — Anticipation | |
| 171 | + | ||
| 172 | + | - **Location:** `file_list_menus.rs:279–310`. | |
| 173 | + | - **Observation:** The user right-clicks empty space in the table and sees Import options. If they previously copied file paths from Finder/Explorer, there's no "Paste files here" action — a natural pairing. | |
| 174 | + | - **Recommendation:** Add `[Paste files]` to the background menu, guarded by `ctx.input(|i| !i.raw.dropped_files.is_empty())` or by polling the clipboard for `text/uri-list`. Cross-platform clipboard-files is fiddly; only enable when at least one path is parseable. | |
| 175 | + | ||
| 176 | + | ### m-7. Context menu "Open" on folder is redundant with double-click — Consistency | |
| 177 | + | ||
| 178 | + | - **Location:** `file_list_menus.rs:122–125`. | |
| 179 | + | - **Observation:** The folder context menu starts with "Open", which is just double-click → enter directory. Standard OS file managers do the same and it's never been a problem, so this is borderline. But the menu is long; trimming first. | |
| 180 | + | - **Recommendation:** Optional. Leave for now; revisit if the folder context menu grows. | |
| 181 | + | ||
| 182 | + | ### m-8. "Copy Path" vs "Copy Paths" inconsistency — Consistency | |
| 183 | + | ||
| 184 | + | - **Location:** `file_list_menus.rs:43, 256`. | |
| 185 | + | - **Observation:** Single-selection menu says "Copy Path"; multi-select says "Copy Paths". Plural depends on selection — defensible, but most apps just say "Copy Path(s)" or "Copy Path" uniformly. | |
| 186 | + | - **Recommendation:** Pick one. I'd standardize on "Copy Path" (singular) — the count is in the selection itself; the menu label doesn't need to repeat it. | |
| 187 | + | ||
| 188 | + | ### m-9. No "Invert Selection" — Mappings | |
| 189 | + | ||
| 190 | + | - **Location:** Selection-model menu / shortcut. | |
| 191 | + | - **Observation:** Power users using Cmd+A → cmd-click a few to deselect, then needing to invert, have no path. The selection model supports it (set difference) but isn't exposed. | |
| 192 | + | - **Recommendation:** Add `Cmd+Shift+I` for "Invert selection" and a corresponding menu item under a (new) Selection submenu. Trivial: `state.selection.invert(contents.len())`. | |
| 193 | + | ||
| 194 | + | ### m-10. No keyboard path to focus the detail panel — Modes | |
| 195 | + | ||
| 196 | + | - **Location:** `editor.rs` keyboard handler. | |
| 197 | + | - **Observation:** The user navigating the table with j/k can't shift focus into the detail panel to edit tags via keyboard. They must reach for the mouse. | |
| 198 | + | - **Recommendation:** Add a `Tab` shortcut from the table to the detail-panel tag input (or `Cmd+'` per Mac convention). When the detail panel isn't visible, the shortcut opens it first. | |
| 199 | + | ||
| 200 | + | --- | |
| 201 | + | ||
| 202 | + | ## Polish | |
| 203 | + | ||
| 204 | + | ### p-1. Detail-panel sections have no collapse — Hierarchy | |
| 205 | + | ||
| 206 | + | - **Location:** `detail.rs` — sections rendered sequentially. | |
| 207 | + | - **Observation:** Metadata, tags, action buttons all render every time. A user who has 20 tags doesn't want to scroll past them to reach action buttons; a user who never uses Find Similar doesn't need to see that button. | |
| 208 | + | - **Recommendation:** Wrap each section in a `CollapsingHeader` with `default_open(true)` and persistent expand state via egui memory. Cheap, leaves current default behaviour unchanged. | |
| 209 | + | ||
| 210 | + | ### p-2. Sort header click target is the entire column header but only the label changes — Affordances | |
| 211 | + | ||
| 212 | + | - **Location:** `file_list.rs:222–262`. | |
| 213 | + | - **Observation:** The whole header row is a click target (egui table behaviour), but only the label text indicates clickability. A muted underline-on-hover would help. | |
| 214 | + | - **Recommendation:** In `draw_sort_header`, wrap the label in a `selectable_label` with `text_secondary` styling. egui's default selectable rendering provides the hover feedback for free. | |
| 215 | + | ||
| 216 | + | ### p-3. Tag suggestions don't show their source — Anticipation | |
| 217 | + | ||
| 218 | + | - **Location:** `detail.rs:194–214`. | |
| 219 | + | - **Observation:** Suggestions appear under the input with no explanation of where they came from ("classification: kick → suggests drums.kick"). The user has to inspect the classification field to deduce the rule. | |
| 220 | + | - **Recommendation:** Above the suggestions row, add a small muted label: `"Based on classification: {class}"`. Frames the suggestions as derived, not opinionated. | |
| 221 | + | ||
| 222 | + | ### p-4. Selection count not surfaced when count > 1 outside the context menu — Visibility | |
| 223 | + | ||
| 224 | + | - **Location:** `file_list_menus.rs:152–154` only. | |
| 225 | + | - **Observation:** Already covered by C-2/C-3, but worth noting separately as a Polish-tier improvement even if the larger fixes don't land: a persistent "N selected" label in the footer bar would close most of the visibility gap on its own. | |
| 226 | + | - **Recommendation:** Footer: `if state.selection.count() > 1 { ui.label(format!("{} selected", state.selection.count())); }` — five lines, zero downside. | |
| 227 | + | ||
| 228 | + | ### p-5. Welcome footer hint references shortcuts that exist but aren't introduced anywhere else — Discoverability | |
| 229 | + | ||
| 230 | + | - **Location:** `file_list.rs:96` — `"Press F1 for shortcuts \u{00B7} Right-click samples for options"`. | |
| 231 | + | - **Observation:** F1 brings up the Help overlay (Phase 1 marked this as a polish target for a "Getting started" tab). Until that lands, the welcome footer is the only place pointing the user at the keyboard surface. The j/k/arrow shortcuts are a major affordance and are documented only via F1. | |
| 232 | + | - **Recommendation:** Defer to Phase 1's `p-2` (Help "Getting started" tab). Don't fork a separate fix. | |
| 233 | + | ||
| 234 | + | --- | |
| 235 | + | ||
| 236 | + | ## Patterns across these findings | |
| 237 | + | ||
| 238 | + | Three patterns dominate, in descending impact: | |
| 239 | + | ||
| 240 | + | 1. **The selection model is right but its state is invisible.** C-2, C-3, C-4, M-1, p-4 are all instances of the same shape: the user makes a multi-selection, takes an action, and gets no persistent confirmation of the selection size, the operation count, or the result. A single Phase 3 follow-up that adds (a) a persistent "N selected" indicator, (b) status posts after every bulk op with counts, and (c) a "right-click preserves selection if already selected" rule would close the cluster. | |
| 241 | + | ||
| 242 | + | 2. **Destructive affordances pair with no undo path.** C-1, M-3 (no download path for cloud-only is the reverse — a *missing* affordance for a state), M-6 (no dismiss for suggestions), and m-5 (tiny destructive target). The inline tag-remove is the load-bearing case; once it's wired into the undo stack, the pattern is solved for the rest by analogy. | |
| 243 | + | ||
| 244 | + | 3. **Modes exist but their boundaries leak.** M-4 (similarity mode vs folder mode), M-1 (autoplay mode vs silent-navigate mode), and the multi-select-vs-single-select detail panel mismatch all stem from the same root: audiofiles has implicit modes that aren't surfaced as modes. Each one is small, but together they make the app feel "smart" in the bad way — guessing what the user wanted from context that the user can't see. Make the modes explicit (banner in the breadcrumb, footer indicator for selection size, modifier for autoplay) and the surprise goes away. | |
| 245 | + | ||
| 246 | + | When the C-tier items land, the next natural audit is **Phase 4 — the detail panel, sample editor, and instrument window** (the editing surfaces). Several findings here (C-1, M-6, m-4) point in that direction. | |
| 247 | + | ||
| 248 | + | --- | |
| 249 | + | ||
| 250 | + | ## Implementation note (2026-05-20) | |
| 251 | + | ||
| 252 | + | Every Critical, Major, and Minor finding above has shipped, plus the two follow-ups that were initially deferred. Build clean across `audiofiles-app`, `audiofiles-browser`, `audiofiles-sync`, and `audiofiles-core`; 199 + 44 + 439 tests pass; design-system gates all return zero output. | |
| 253 | + | ||
| 254 | + | **Critical (4/4):** | |
| 255 | + | - **C-1** — `UndoOp::TagRemove { hash, tag }` added; inline tag remove in `detail.rs` pushes an undo entry, posts a status, and refreshes via `refresh_selected_tags`. `tag_chip_removable` gained `hover_only_remove: bool`; detail.rs passes `true` so the X is muted until hover (m-5 closed alongside). | |
| 256 | + | - **C-2** — `detail.rs::draw_detail` branches on `state.selection.count() > 1` to a new `draw_multi_summary` view: heading, common-metadata grid (uniform value or `varies`), tag union with `(count)` badges, `[Edit as bulk]` button. Footer's `"N selected"` indicator pre-existed (p-4). | |
| 257 | + | - **C-3** — bulk-op status posts gained counts and context: single delete includes node name; rename includes pattern; analysis posts `Analyzing {n} samples…` on start and `Analyzed {n} samples ({errors} errors)` on `AnalysisBatchComplete` (both quick-import and review paths). | |
| 258 | + | - **C-4** — `file_list.rs` row right-click now collapses the selection to the clicked row when it isn't in the existing selection; selected-row right-clicks preserve the multi-selection (Finder convention). | |
| 259 | + | ||
| 260 | + | **Major (8/8):** | |
| 261 | + | - **M-1** — already implemented before the audit; verified `autoplay` field, `toggle_autoplay`, and `settings_panel.rs` checkbox. | |
| 262 | + | - **M-2** — drag hover text swaps to `"Drag ready in a moment..."` while `os_drag_blocked`; `tracing::warn` when a drag is suppressed by the cooldown. | |
| 263 | + | - **M-3** — cloud-only row tooltip added. Backend work landed: `service::download::download_one_blob`, `SyncCommand::DownloadOne { hash }` wired through the scheduler, `SyncManager::download_sample(hash) -> bool`. Context menu now shows a "Download" item for cloud-only rows when sync is configured; posts `"Downloading {name}..."` or `"Sync not ready — open the Sync panel first"`. | |
| 264 | + | - **M-4** — `state.similarity_source_name` cached at `find_similar` / `find_near_duplicates` time and cleared everywhere `similarity_search_hash` clears. Breadcrumb renders `"Similar to: <name>"` (accent_strong) in similarity mode. `draw_sort_header` gained `enabled: bool` — headers render as muted disabled labels and clicks no-op while similarity is active. Backspace exits similarity-mode first, then falls through to `go_up` on a second press. | |
| 265 | + | - **M-5** — `BrowserState::reset_columns()` resets visibility + density + sort to defaults and persists. Settings → Display has a `[Reset columns]` button; tooltip notes that user-dragged column widths recover on next app launch (egui_extras stores widths under generated ids; a true in-app reset would require touching egui memory in ways that risk unrelated UI state). | |
| 266 | + | - **M-6** — `state.dismissed_suggestions: HashMap<String, Vec<String>>` loaded at startup from config key `suggestions.dismissed` (single JSON object). Helpers `dismiss_suggestion`, `reset_dismissed_suggestions`, `save_dismissed_suggestions`. `detail.rs` filters classification suggestions through the dismissed list, relabels the prefix to `"Suggest (from <class>):"` (p-3 closed by this), and each suggestion gets a muted X with tooltip `Never suggest "<tag>" on <class> samples again`. Settings → Display has `[Reset suggestions]` (disabled when count is 0). | |
| 267 | + | - **M-7** — `ConfirmAction::ReanalyzeOverwrite { sample_hashes, overwrite_count }`. The Re-analyze menu opens a danger-styled confirm when any selected sample has existing BPM/Key/classification; detail line distinguishes "all will be overwritten" from "some will be overwritten". When nothing's at risk, it bypasses the confirm and opens ConfigureAnalysis directly. | |
| 268 | + | - **M-8** — single Copy Path → `"Copied: <path>"`; multi-row label standardized to `"Copy Path"` (singular, m-8); multi status → `"Copied: <first> (+N more)"`. | |
| 269 | + | ||
| 270 | + | **Minor (10/10):** | |
| 271 | + | - **m-1** — parent row renders as muted `" Up"` via `text_secondary` (no glyph, stays within the existing allowlist). Cmd+A uses `Selection::select_all_from(start, len)` to skip index 0 when a parent row is present. | |
| 272 | + | - **m-2** — play column 28 → 36 px; play button uses `ui.button` instead of `small_button`. | |
| 273 | + | - **m-3** — `draw_sort_header` reserves a fixed-width glyph slot: active shows ▲/▼, inactive shows a muted middle dot, layout is stable across toggles. | |
| 274 | + | - **m-4** — waveform paints an accent_blue vertical line at the hover X with a `MM:SS` label above so click-to-seek is visible before commit. | |
| 275 | + | - **m-5** — closed alongside C-1 via `hover_only_remove`. | |
| 276 | + | - **m-6** — paste-files in background context menu — **not implemented**; cross-platform clipboard files (`text/uri-list` on Linux/macOS vs Windows CF_HDROP) is fiddly enough that the doc itself marked it as conditional. Tracked for a follow-up when egui surfaces a cleaner clipboard-files primitive. | |
| 277 | + | - **m-7** — Open-on-folder in context menu — **deferred per audit doc** (marked Optional; folder menu hasn't grown enough to warrant trimming). | |
| 278 | + | - **m-8** — closed alongside M-8. | |
| 279 | + | - **m-9** — `Selection::invert(len)` + Cmd+Shift+I (parent row stripped from the result via `BrowserState::invert_selection()`). Menu items in both the multi-context menu and the background context menu under "Deselect". | |
| 280 | + | - **m-10** — Tab from table sets `state.focus_tag_input`; detail panel calls `resp.request_focus()` on that frame and clears the flag. Opens the detail panel first if hidden. | |
| 281 | + | ||
| 282 | + | **Polish (4 actionable / 1 deferred):** | |
| 283 | + | - **p-1** — detail panel sections (Metadata, Tags, Actions, Discovery) wrapped in `CollapsingHeader::default_open(true)` with stable `id_salt`s. | |
| 284 | + | - **p-2** — sort-header underline-on-hover — **skipped as cosmetic** per audit recommendation. | |
| 285 | + | - **p-3** — closed by M-6's `"Suggest (from <class>):"` relabel. | |
| 286 | + | - **p-4** — footer "N selected" indicator pre-existed; verified. | |
| 287 | + | - **p-5** — deferred to Phase 1's p-2 (Help "Getting started" tab) per audit recommendation. | |
| 288 | + | ||
| 289 | + | **Files touched (this phase):** `state/{ui,mod,library,bulk_ops,playback}.rs`, `state/import_workflow.rs`, `editor.rs`, `ui/{detail,file_list,file_list_menus,toolbar,settings_panel,widgets,overlays}.rs`, plus the sync layer: `audiofiles-sync/src/{lib,scheduler}.rs`, `audiofiles-sync/src/service/{mod,download}.rs`. | |
| 290 | + | ||
| 291 | + | Phase 3 is closed. Next: **Phase 4 — filter / instrument / settings panels** per the `docs/todo.md` schedule (the original Phase 4 ordering, not the "detail + editor" hypothesis from this doc's tail — that surface is now mostly covered by C-1/C-2/M-6/m-4/p-1). |
| @@ -0,0 +1,438 @@ | |||
| 1 | + | # Phase 4 UX Audit — Filter / Instrument / Settings / Sync panels | |
| 2 | + | ||
| 3 | + | **Surfaces:** `ui/filter_panel.rs`, `ui/instrument_panel.rs`, `ui/settings_panel.rs`, `ui/sync_panel.rs` (plus supporting widgets/theme/state). | |
| 4 | + | **Detected stack:** egui (immediate-mode Rust GUI). | |
| 5 | + | **Frame of reference:** Phase 3 closed the table/selection model. Phase 4 covers the *configuration* surfaces — the places where the user changes state about state. The dominant axis here is the gulf of evaluation: did my change land, where does it live, can I get back? | |
| 6 | + | ||
| 7 | + | Findings are ranked Critical / Major / Minor / Polish using the Phase 3 severity rubric. No code in this document — recommendations describe the change, not the diff. | |
| 8 | + | ||
| 9 | + | --- | |
| 10 | + | ||
| 11 | + | ## Critical | |
| 12 | + | ||
| 13 | + | ### C-1. Password setup has no confirm field and no recovery path — Forgiveness (sync) | |
| 14 | + | ||
| 15 | + | - **Location:** `sync_panel.rs::draw_needs_encryption`, `has_server_key == false` branch (lines 292–322). | |
| 16 | + | - **Observation:** When the user creates the encryption password for the very first time, they type it into a single masked field and press "Set Password". A single typo permanently re-encrypts the cloud blob under a key the user will never re-derive. The accompanying copy ("Remember this password — it cannot be recovered") is in `small().weak()` text below the field — the lowest-emphasis style in the panel. | |
| 17 | + | - **Why it matters:** This is the only flow in the app where a one-character mistake corrupts user data permanently. The recovery cost is total and silent; the user only learns about the typo the next time they `Unlock`. A confirm-password field, or at minimum a visible-text checkbox, is the standard pattern for any password set by typing. | |
| 18 | + | - **Recommendation:** In the `!has_server_key` branch only, render a second password field labelled "Confirm password" and gate "Set Password" until both fields match and are ≥8 chars. Promote the "cannot be recovered" copy to body weight inside a `widgets::info_banner` styled as warning (`accent_yellow`). Optionally add a "Show password" eye toggle on the input — the password field is single-use during setup so the secrecy cost is bounded. | |
| 19 | + | ||
| 20 | + | ### C-2. Disconnect from cloud sync has no confirmation — Forgiveness (sync) | |
| 21 | + | ||
| 22 | + | - **Location:** `sync_panel.rs::draw_ready`, lines 438–444. | |
| 23 | + | - **Observation:** A bare `ui.button` rendered with red text triggers `sync.disconnect()` instantly on a single click. Disconnect tears down the auth session, drops the local encryption key reference, and (per the existing flow) sends the user back to `Disconnected` where they must re-enter the password on the next connect — that *is* C-1's footgun in reverse: if they typo the re-entry, the cloud blob is unrecoverable. | |
| 24 | + | - **Why it matters:** Phase 3 added `ConfirmAction::SwitchLibrary` as the non-destructive precedent and `ReanalyzeOverwrite` as the destructive precedent. Disconnect deserves the destructive variant — it's adjacent to a permanent-data-loss path and there is no Undo. A single mis-click on a colored label is below the proportionality bar. | |
| 25 | + | - **Recommendation:** Add a `ConfirmAction::DisconnectSync` variant routed through `overlays.rs::draw_confirm_dialog`. Detail line: *"You'll need your encryption password to reconnect. Pending changes (N) will be lost."* Render as `danger_button` (filled, not text-coloured) for affordance parity with the rest of the app. Only require confirm when `pending_changes > 0` — clean disconnects can stay one-click if the audit prefers. | |
| 26 | + | ||
| 27 | + | ### C-3. Theme import/export failures are completely silent — Feedback (settings) | |
| 28 | + | ||
| 29 | + | - **Location:** `settings_panel.rs::draw_advanced_section`, lines 533–577. | |
| 30 | + | - **Observation:** Every error path in this block ends in `tracing::error!` and nothing else. If the user picks a malformed `.toml`, if `create_dir_all` fails on a permission-denied custom-themes directory, if `std::fs::copy` fails because the source moved, if `export_theme_content` returns `None` — the import/export button click reads as a no-op. There's no status post, no inline label, no modal. | |
| 31 | + | - **Why it matters:** Phase 3 standardised status posts as the lightweight feedback channel; this section pre-dates that convention. The user can't distinguish "I clicked the wrong button" from "the theme was bad" from "the OS denied write". A user who suspects something didn't work has nowhere to look short of running `RUST_LOG=error` in a terminal. | |
| 32 | + | - **Recommendation:** Wire every `tracing::error!` and `tracing::warn!` in this section to a corresponding `state.post_status(...)` (or whatever the post-status helper is named in this codebase). At minimum: `"Theme imported: <name>"`, `"Theme import failed: <reason>"`, `"Theme exported to <path>"`, `"Export failed: <reason>"`. The reason can be the short `{e}` rendered today — keep it human-shaped but don't swallow it. | |
| 33 | + | ||
| 34 | + | ### C-4. Subscription / checkout-loading flags never clear on failure — Modes (sync) | |
| 35 | + | ||
| 36 | + | - **Location:** `sync_panel.rs::draw_subscription_section`, lines 73–88 (loading guard) and 132–195 (checkout flow). | |
| 37 | + | - **Observation:** `state.sync.subscription_loading` is set to `true` before `fetch_subscription_status()` and only cleared once `sync_status.subscription.is_some()`. If the fetch errors, the flag stays set forever and the panel renders the spinner-text `"Checking subscription..."` permanently. Same shape for `checkout_loading` — set on click, cleared only on the subscription-acquired path. A failed checkout (network error, browser closed, Stripe declined) leaves all the Subscribe / Change-tier buttons disabled indefinitely. | |
| 38 | + | - **Why it matters:** This is a soft-trap mode — the panel looks busy doing something it isn't. The user has no way to retry from inside the app without restarting it. With Stripe in the mix, the failure surface is not hypothetical: a closed browser tab in the middle of Checkout is the modal case, not the edge case. | |
| 39 | + | - **Recommendation:** Wire both loading flags to an explicit timeout (`fetched_at: Option<Instant>`) and clear them after, say, 30 seconds without resolution. On clear, post a status `"Subscription check timed out — Retry available"` and re-enable the buttons. Better: have `SyncManager` surface the request's terminal state (`Failed`, `Cancelled`) so the panel can react deterministically instead of timing out. | |
| 40 | + | ||
| 41 | + | ### C-5. Authenticating state has no Cancel — Modes (sync) | |
| 42 | + | ||
| 43 | + | - **Location:** `sync_panel.rs::draw_authenticating`, lines 261–275. | |
| 44 | + | - **Observation:** Once the user clicks Connect, the panel transitions to "Waiting for authentication in your browser..." with a spinner and no other controls. If the browser tab is closed, OAuth fails server-side, or the user simply changes their mind, there's nothing to click. The window's titlebar X still works, but reopening returns to the same state (since the underlying `SyncState::Authenticating` is server-driven). | |
| 45 | + | - **Why it matters:** Raskin: every state needs an exit. The user is stuck inside an ambient mode they didn't fully understand they were entering — and the OAuth flow is opaque enough that the failure rate is non-trivial. | |
| 46 | + | - **Recommendation:** Add a "Cancel" button next to the spinner that calls a new `sync.cancel_auth()` (returning the state machine to `Disconnected` and dropping the pending PKCE / nonce). Show after a short delay (3–5s) so it doesn't compete with the successful path's quick transition. Pair with body text *"Browser didn't open? Copy this URL"* and a copy-to-clipboard button for the auth URL — that closes the headless-browser / wrong-default-browser failure mode. | |
| 47 | + | ||
| 48 | + | ### C-6. Vault rows look selectable but aren't — Affordances (settings) | |
| 49 | + | ||
| 50 | + | - **Location:** `settings_panel.rs::draw_storage_section`, line 120 — `widgets::selectable_row(ui, is_active, name)` with the result discarded (`let _ = ...`). | |
| 51 | + | - **Observation:** Each library row is rendered with the same `selectable_row` widget used elsewhere as a clickable list element. Phase 3 established `selectable_row` as the *interaction* widget for sidebar/list rows. Here it's intentionally inert — the code comment confirms switching is the sidebar ComboBox's job. The user has no way to know that, and the visual contract (hover, selected highlight, mouse cursor) all say "click me". | |
| 52 | + | - **Why it matters:** This is a *false affordance*, and Phase 3 spent real time hardening the inverse case (selection-preserving right-clicks, mode indicators) to keep affordances honest. Reintroducing an inert click target in a sibling surface re-opens that gulf. | |
| 53 | + | - **Recommendation:** Either (a) wire the row click to the same switch action the sidebar uses, with the existing `ConfirmAction::SwitchLibrary` confirm if the row isn't already active — this is the lowest-surprise fix; or (b) render the row as a plain `Label`/`subsection_label` styled with the active accent dot still visible. Don't keep the widget that says "click me" wired to nothing. | |
| 54 | + | ||
| 55 | + | --- | |
| 56 | + | ||
| 57 | + | ## Major | |
| 58 | + | ||
| 59 | + | ### M-1. BPM / Duration / Loudness sentinels are invisible — Visibility of state (filter) | |
| 60 | + | ||
| 61 | + | - **Location:** `filter_panel.rs:20–65`. Drag values use 0/300 BPM, 0/600s duration, -96/0 dB as the "no filter on this end" sentinels. | |
| 62 | + | - **Observation:** The user has no way to know that dragging BPM-max down to 299 *is* a filter, but 300 *isn't*. The active indicator (`bpm_active`) reflects the truth, but the field itself shows the same number either way. A user trying to clear "max BPM" must overshoot to the boundary and trust the indicator. | |
| 63 | + | - **Recommendation:** When the value is at the sentinel, render the field with placeholder text (`"any"`) and `text_muted` styling, the way the search input handles its empty case. Or surface a per-section `[Clear]` micro-button when `bpm_active` is true. Either path makes the sentinel state legible. | |
| 64 | + | ||
| 65 | + | ### M-2. Min can be set greater than max with no warning — Forgiveness (filter) | |
| 66 | + | ||
| 67 | + | - **Location:** `filter_panel.rs:20–65`, all three numeric ranges. | |
| 68 | + | - **Observation:** Each `DragValue` is bounded against the absolute range (0..=300, etc.) but not against its sibling. Set `bpm_min = 200`, `bpm_max = 50`, and the SQL query returns zero rows with no acknowledgement that the constraints are contradictory. | |
| 69 | + | - **Recommendation:** Either (a) clamp the sibling on edit — moving `min` above `max` snaps `max` to `min` (cheapest), or (b) when min > max, render both fields with `accent_yellow` text and a small "Min exceeds max" note under the row. Don't silently return an empty result set for a state that's structurally impossible. | |
| 70 | + | ||
| 71 | + | ### M-3. Individual filter sections have no clear control — Forgiveness (filter) | |
| 72 | + | ||
| 73 | + | - **Location:** `filter_panel.rs`, every `filter_section` call. | |
| 74 | + | - **Observation:** The only escape from a single noisy filter (say a 20-key selection) is "Clear All Filters", which also wipes the search query and every other category. The user who wanted to keep their BPM constraint and just drop the keys has to redo their work. | |
| 75 | + | - **Recommendation:** When a `filter_section`'s `active` flag is true, render a small `[clear]` link at the right edge of the section header (existing pattern: header strip from `widgets::filter_section`). One click resets that category's state to empty/None. This is the smallest add that solves "I only wanted to undo *this* part". | |
| 76 | + | ||
| 77 | + | ### M-4. "Clear All Filters" also clears the search query — Mappings (filter) | |
| 78 | + | ||
| 79 | + | - **Location:** `filter_panel.rs:135–139`. | |
| 80 | + | - **Observation:** The button is labelled "Clear All Filters" but its action also calls `state.search_query.clear()`. Search query and filters are presented as distinct concepts elsewhere in the app (the toolbar search input is visually separate from this panel). A user with `query="kick"` and a BPM filter who clicks Clear expecting to keep "kick" loses it. | |
| 81 | + | - **Recommendation:** Either (a) rename the button to "Clear search and filters" so the action matches the label, or (b) leave the label and don't touch the search query. (a) is the lower-risk fix; (b) is the more honest mapping. Pick one. | |
| 82 | + | ||
| 83 | + | ### M-5. Filter panel has no way to add a tag filter — Discoverability (filter) | |
| 84 | + | ||
| 85 | + | - **Location:** `filter_panel.rs:119–131`. The "Active Tag Filters" section only renders if `!required_tags.is_empty()` and exists purely to *remove* tags. | |
| 86 | + | - **Observation:** Tag filters enter the state through other surfaces (right-clicking a tag chip in detail / file list, presumably). A user opening the filter panel to "filter by tag X" finds no entry point. The asymmetry — every other filter category has both add and remove here, but tags only have remove — makes the panel feel half-finished. | |
| 87 | + | - **Recommendation:** Add a single-line tag autocomplete input under the section header (or as the empty state of the section): "Filter by tag…" with the existing tag-autocomplete suggester. Wire on-commit to `required_tags.push`. The detail-panel tag-add code already exists; reuse the input pattern. | |
| 88 | + | ||
| 89 | + | ### M-6. Multi-sample radio is disabled with no explanation — Anticipation (instrument) | |
| 90 | + | ||
| 91 | + | - **Location:** `instrument_panel.rs::draw_mode_controls`, lines 154–158. | |
| 92 | + | - **Observation:** The user sees two radio options: "Chromatic" (enabled) and "Multi-sample" (disabled and unselectable). There's no tooltip, no asterisk, no body text indicating *why* — is it gated behind a future release, requires multiple zones, requires a license, broken on this build? | |
| 93 | + | - **Recommendation:** Add an `.on_hover_text` to the disabled radio explaining the gate: e.g. *"Drop two or more samples onto the keyboard to enable multi-sample mode"* (if that's the condition). If it's a not-yet-shipped feature, surface that honestly — *"Coming in a future release"* — rather than rendering a dead control. | |
| 94 | + | ||
| 95 | + | ### M-7. Lock-sample checkbox has no tooltip — Anticipation (instrument) | |
| 96 | + | ||
| 97 | + | - **Location:** `instrument_panel.rs:172`. | |
| 98 | + | - **Observation:** "Lock sample" is a single checkbox with no tooltip and no companion text. The user has to read the source code or guess to know what locking does (prevents the instrument window's loaded sample from changing as the table selection moves, presumably). | |
| 99 | + | - **Recommendation:** Add `.on_hover_text("Keep the current sample loaded as the table selection changes")` or similar. Two minutes of work, closes one of the longest-standing "what does this do" gaps in the panel. | |
| 100 | + | ||
| 101 | + | ### M-8. Right-click on keyboard does two different things — Modes (instrument) | |
| 102 | + | ||
| 103 | + | - **Location:** `instrument_panel.rs:326–334` (set root note) and 406–413 (remove zone). | |
| 104 | + | - **Observation:** Right-clicking a piano key sets the root note. Right-clicking *a zone bar* removes the zone. The two regions visually overlap (zone bars sit immediately under the keys) and there's no indication that the secondary-click meaning depends on where the cursor is. | |
| 105 | + | - **Recommendation:** Either (a) move zone removal to a small inline X chip on the zone bar (like the tag chips after Phase 3's `hover_only_remove`), and reserve right-click for the keyboard alone; or (b) keep right-click but show a contextual hint on hover (`"Right-click to remove zone"` over the bar; `"Right-click to set root"` over the keys). (a) is the modeless option. | |
| 106 | + | ||
| 107 | + | ### M-9. ADSR sliders lack tooltips and the envelope isn't visualised — Anticipation (instrument) | |
| 108 | + | ||
| 109 | + | - **Location:** `instrument_panel.rs::draw_adsr_controls`, lines 442–476. | |
| 110 | + | - **Observation:** The labels are single letters `A D S R`. There is no envelope diagram, no value-units callout, no preset selector. The novice user can't distinguish "attack" from "decay" and can't predict what an attack of 5s versus 0.001s will sound like. | |
| 111 | + | - **Recommendation:** (a) Add `.on_hover_text` to each letter label naming the parameter ("Attack — time to reach full volume"). (b) Above the four sliders, paint a tiny ADSR shape (50px tall) that reshapes live as the values change — egui can draw this in a `Painter` allocation with negligible cost. The combination closes the gap without changing the slider semantics. | |
| 112 | + | ||
| 113 | + | ### M-10. MIDI picker hides when there are no ports — Visibility of state (instrument) | |
| 114 | + | ||
| 115 | + | - **Location:** `instrument_panel.rs::draw_midi_device_picker`, lines 82–84. | |
| 116 | + | - **Observation:** If the auto-scan finds zero MIDI inputs and the user isn't connected, the entire picker section (including the Refresh button) disappears. The user who plugs in a controller *after* opening the window has no way to ask the app to look again from inside this panel. | |
| 117 | + | - **Recommendation:** When `available_ports.is_empty() && connected_port.is_none()`, render a single-line empty state: muted *"No MIDI inputs detected"* with a `Refresh` button to its right. Same as the file-list `empty_state` widget pattern. | |
| 118 | + | ||
| 119 | + | ### M-11. Add-Library form has no Cancel; closes inconsistently — Forgiveness (settings) | |
| 120 | + | ||
| 121 | + | - **Location:** `settings_panel.rs::draw_storage_section`, lines 280–311. | |
| 122 | + | - **Observation:** The "Add Library" sub-form (name + folder + storage style) has no Cancel button. The "Create New" path sets `should_close = true` and closes the whole Settings window; the "Add Existing" path doesn't. A user who chose a folder by mistake has no in-form revert — they must either commit or abandon by closing the modal entirely, which discards `create_name` / `create_path` / `create_unsafe_mode` together. | |
| 123 | + | - **Recommendation:** (a) Add a `Cancel` button alongside the two commit buttons that clears `create_name` / `create_path` / `create_unsafe_mode` and leaves Settings open. (b) Make the close-on-commit behaviour consistent between Create New and Add Existing — either both close, or neither does. Pick one (closing on Create makes sense since the new vault becomes active; not closing on Add Existing also makes sense since the user may want to keep configuring). | |
| 124 | + | ||
| 125 | + | ### M-12. Tier-change buttons present Annual and Monthly with equal weight despite the copy recommending Annual — Hierarchy (sync) | |
| 126 | + | ||
| 127 | + | - **Location:** `sync_panel.rs::draw_subscription_section`, lines 134–148 and 182–196. | |
| 128 | + | - **Observation:** The header copy says *"Annual saves you money — fewer Stripe transactions means less processing fees."* The two buttons under each tier are rendered as identical `egui::Button`s — same size, same fill, same border. The recommendation is in prose and the affordance is in chrome; they disagree. | |
| 129 | + | - **Recommendation:** Render Annual as the primary action (`widgets::primary_button`) and Monthly as secondary (`widgets::secondary_button`). The strategic intent and the visual hierarchy then point the same way. If the prose recommendation is *not* the desired default, drop the recommendation copy instead — but pick one. | |
| 130 | + | ||
| 131 | + | ### M-13. Per-VFS sync toggles show only the name — Visibility of state (sync) | |
| 132 | + | ||
| 133 | + | - **Location:** `sync_panel.rs::draw_ready`, lines 421–433. | |
| 134 | + | - **Observation:** Each vault appears as a single checkbox with the vault name. There's no indication of how much would be uploaded if the user enables it, no last-sync timestamp per vault, no count of pending files. | |
| 135 | + | - **Recommendation:** Beside (or below) each checkbox, render a muted size estimate (*"2.4 GB across 1,820 samples"*) computed from the existing storage scan cache. Cheap, useful, makes the choice less abstract. If the scan is stale or absent, render *"Run storage scan to see size"* with a button. | |
| 136 | + | ||
| 137 | + | ### M-14. Sync error label has no recovery — Error messages (sync) | |
| 138 | + | ||
| 139 | + | - **Location:** `sync_panel.rs::draw_sync_panel`, lines 56–60. | |
| 140 | + | - **Observation:** `status.last_error` is rendered as `ui.colored_label(theme::accent_red(), err)` — a single line of red text, with nothing the user can do about it. No retry, no dismiss, no copy-to-clipboard, no link to "open log file". | |
| 141 | + | - **Recommendation:** Wrap the error in a `widgets::info_banner` (warning style) with a `[Retry]` button (re-invoking the last action) and a `[Dismiss]` X. If the error has a documented remediation (e.g. "encryption password incorrect"), surface a typed action specific to that case. | |
| 142 | + | ||
| 143 | + | --- | |
| 144 | + | ||
| 145 | + | ## Minor | |
| 146 | + | ||
| 147 | + | ### m-1. BPM range can be set min > max via the absolute bounds with no constraint — Mappings (filter) | |
| 148 | + | ||
| 149 | + | - **Location:** `filter_panel.rs:24, 28` (and siblings). | |
| 150 | + | - **Observation:** Same root as M-2 but called out at the lower severity for the loudness range where the "min louder than max" mistake is more about confusion than empty-result-set damage. | |
| 151 | + | - **Recommendation:** Same fix as M-2 — when crossing, snap the sibling. | |
| 152 | + | ||
| 153 | + | ### m-2. Filter values use `prefix("Min: ")` instead of separate labels — Consistency (filter) | |
| 154 | + | ||
| 155 | + | - **Location:** `filter_panel.rs:24` (and siblings). | |
| 156 | + | - **Observation:** Putting the label inside the DragValue prefix means there's no label-to-field separation. The widget itself looks like a slug with text crammed in. Phase 3 standardised the *"label : value"* idiom outside drag widgets. | |
| 157 | + | - **Recommendation:** Use a leading `ui.label("Min")` then a bare `DragValue` (no prefix). Lets typography settle into the panel's grid and matches the rest of Settings. | |
| 158 | + | ||
| 159 | + | ### m-3. Filter "Active Tag Filters" header is plain `ui.label`, not a section header — Consistency (filter) | |
| 160 | + | ||
| 161 | + | - **Location:** `filter_panel.rs:120`. | |
| 162 | + | - **Observation:** Every other filter category uses `widgets::filter_section` with a collapsing header and active indicator. The tag list uses a bare `ui.label("Active Tag Filters")` with no active dot and no collapse. It reads as a footnote. | |
| 163 | + | - **Recommendation:** Promote to `widgets::filter_section("Tags", tag_active, ...)` so the visual contract matches its siblings. | |
| 164 | + | ||
| 165 | + | ### m-4. Save-as-Collection input doesn't validate on Enter — Affordances (filter) | |
| 166 | + | ||
| 167 | + | - **Location:** `filter_panel.rs:147–159`. | |
| 168 | + | - **Observation:** The user can type a name and press Enter and nothing happens — they must click the Save button. The text field response isn't watched for `lost_focus + key_pressed(Enter)` the way the rename input is in settings. | |
| 169 | + | - **Recommendation:** Add the standard Enter-to-commit handler. Mirror what `settings_panel.rs::draw_storage_section` already does for vault rename. | |
| 170 | + | ||
| 171 | + | ### m-5. Octave nav buttons are `small_button` and visually unreachable — Fitts (instrument) | |
| 172 | + | ||
| 173 | + | - **Location:** `instrument_panel.rs:183, 196`. | |
| 174 | + | - **Observation:** The `-` / `+` octave buttons are tiny by egui defaults and have no keyboard alternative. A user who shifts octaves frequently has a sub-24px target. | |
| 175 | + | - **Recommendation:** Use regular `ui.button` for these (with tooltips already present); the resulting size is closer to 28px and still fits the row. Add `[` / `]` keyboard shortcuts (DAW convention) bound when the MIDI window has focus. | |
| 176 | + | ||
| 177 | + | ### m-6. Piano key drag-drop has no visual feedback during hover — Affordances (instrument) | |
| 178 | + | ||
| 179 | + | - **Location:** `instrument_panel.rs:421–431`. | |
| 180 | + | - **Observation:** Dragging a sample over the keyboard shows no hover indicator — no key highlight, no "drop to create zone" tooltip. The drop interaction is invisible until you commit it. | |
| 181 | + | - **Recommendation:** When the keyboard's `response` is hovered with an active drag payload, paint a soft `accent_blue.linear_multiply(0.3)` overlay on the white key under the cursor and a one-line tooltip *"Drop to create a zone centered on <note>"*. | |
| 182 | + | ||
| 183 | + | ### m-7. Note activity display goes blank when idle — Visibility of state (instrument) | |
| 184 | + | ||
| 185 | + | - **Location:** `instrument_panel.rs::draw_activity_display`, lines 122–147. | |
| 186 | + | - **Observation:** When there are no recent notes, the display shows just `"--"`. Combined with the fact that the picker hides itself when there are no ports (M-10), there's no visible evidence that MIDI is wired up at all once nothing is happening. | |
| 187 | + | - **Recommendation:** When connected and idle, render a muted *"Connected to <port> · listening"* instead of `--`. When not connected, render *"Not connected"*. The dash is fine when connected but unidiomatic when there's nothing to display. | |
| 188 | + | ||
| 189 | + | ### m-8. Vault list shows no vault-level metadata — Anticipation (settings) | |
| 190 | + | ||
| 191 | + | - **Location:** `settings_panel.rs::draw_storage_section`, lines 106–151. | |
| 192 | + | - **Observation:** Each vault row shows name, path, and an "active"/"offline" badge. There's no sample count, no size, no last-modified — even though the active vault has all of this cached after a scan. A user choosing which vault to remove can't see which one is the small one. | |
| 193 | + | - **Recommendation:** When a vault's `storage_cache` is fresh (less than 24h), render its sample count and total size as a muted second line under the path. Phase 3's freshness-driven UI is the precedent here. | |
| 194 | + | ||
| 195 | + | ### m-9. Storage Scan has no progress indicator — Feedback (settings) | |
| 196 | + | ||
| 197 | + | - **Location:** `settings_panel.rs::draw_storage_section`, lines 194–219. | |
| 198 | + | - **Observation:** Clicking Scan returns nothing visible until the cache populates. On a large vault this can take seconds. The button doesn't disable, doesn't show a spinner, and the freshness label only updates at the end. | |
| 199 | + | - **Recommendation:** When `pending_action == ScanStorage`, disable the button and label it *"Scanning…"* (with `add_sized` to prevent the layout jump M-2 style). Optionally swap the freshness label to a spinner during the scan. | |
| 200 | + | ||
| 201 | + | ### m-10. Theme combobox `*` custom marker is opaque — Anticipation (settings) | |
| 202 | + | ||
| 203 | + | - **Location:** `settings_panel.rs::draw_appearance_section`, lines 355–359. | |
| 204 | + | - **Observation:** Custom themes get a trailing `*` with no legend. The casual reader will read it as a glitch or a markdown-emphasis artifact. | |
| 205 | + | - **Recommendation:** Replace with a small *"(custom)"* suffix in `text_muted`, or surface a one-line legend at the bottom of the combobox dropdown. | |
| 206 | + | ||
| 207 | + | ### m-11. Row-density slider doesn't show the numeric value — Feedback (settings) | |
| 208 | + | ||
| 209 | + | - **Location:** `settings_panel.rs::draw_display_section`, lines 446–452. | |
| 210 | + | - **Observation:** The slider has `show_value(false)` and the qualitative label ("Compact" / "Normal" / "Spacious"). The user can't tell whether their current setting is at the low or high end of "Normal". | |
| 211 | + | - **Recommendation:** Render the numeric height (e.g. *"24 px"*) in `text_muted` next to the qualitative label. Either keep show_value(false) and label manually, or pass the slider a `.show_value(true)` and drop the manual label. | |
| 212 | + | ||
| 213 | + | ### m-12. Library Mirror's path is non-editable and hidden — Visibility of state (settings) | |
| 214 | + | ||
| 215 | + | - **Location:** `settings_panel.rs::draw_advanced_section`, lines 580–598. | |
| 216 | + | - **Observation:** When the mirror is enabled, the path appears only on hover ("Symlink tree at: {path}"). When disabled, the path doesn't appear at all. There's no UI to choose where the mirror lives; the user gets whatever default `state.mirror_path` was constructed with. | |
| 217 | + | - **Recommendation:** Always render the mirror path below the checkbox (muted, `collapse_home`-formatted), with a "Change…" button that opens an `rfd::FileDialog::pick_folder`. Matches the Add-Library affordance for path picking. | |
| 218 | + | ||
| 219 | + | ### m-13. Trial countdown copy is awkward at exactly 0 — Error messages (settings) | |
| 220 | + | ||
| 221 | + | - **Location:** `settings_panel.rs::draw_license_section`, lines 495–508. | |
| 222 | + | - **Observation:** At `days == 0` the label reads *"Trial: 0 days"*. This is technically true but uncomfortably terse for what is presumably an expired-state cue. There's also no action button — "Buy license" or similar — beside the message. | |
| 223 | + | - **Recommendation:** At `days <= 0`, render *"Trial expired"* in `text_muted` and surface a primary `[Purchase license]` button immediately under it (which can route to whatever existing buy flow exists). Today this state is a dead-end label. | |
| 224 | + | ||
| 225 | + | ### m-14. License Key field is read-only and unselectable — Affordances (settings) | |
| 226 | + | ||
| 227 | + | - **Location:** `settings_panel.rs::draw_license_section`, lines 489–493. | |
| 228 | + | - **Observation:** The masked key is rendered as a `Label`, which means a user can't double-click to select it (e.g. to share with support). For a masked key this is fine, but the same pattern is used for the machine-ID below, where copying is genuinely useful (lines 509–514). | |
| 229 | + | - **Recommendation:** Render `Machine:` as a `selectable_label`, or pair it with a small `[Copy]` button that posts a "Copied machine id" status. | |
| 230 | + | ||
| 231 | + | ### m-15. Sync interval pills don't cover the current value if it's not in the list — Visibility of state (sync) | |
| 232 | + | ||
| 233 | + | - **Location:** `sync_panel.rs::draw_ready`, lines 377–396. | |
| 234 | + | - **Observation:** The pills are hard-coded to `[5, 15, 30, 60]`. If `status.sync_interval_minutes` is anything else (legacy config, future setting, manual DB edit), no pill renders as active and the current value is invisible. | |
| 235 | + | - **Recommendation:** Either (a) render an extra pill labelled `"{current}m"` and marked active when the current value isn't in the canonical list; or (b) snap the current value into the nearest canonical bucket on load. (b) is the lower-effort fix. | |
| 236 | + | ||
| 237 | + | ### m-16. Disconnect's accent-red text isn't a real button — Affordances (sync) | |
| 238 | + | ||
| 239 | + | - **Location:** `sync_panel.rs::draw_ready`, lines 439–443. | |
| 240 | + | - **Observation:** `ui.button(RichText::new("Disconnect").color(accent_red()))` produces a button with red text on the default button background. Phase 3 settled on `widgets::danger_button` (filled, white text on red) for the destructive idiom. This control is the only one in the audit surfaces still on the old pattern. | |
| 241 | + | - **Recommendation:** Swap to `widgets::danger_button(ui, "Disconnect")`. Closes alongside C-2's confirm dialog so the destructive action both *looks* destructive and *is gated* like one. | |
| 242 | + | ||
| 243 | + | ### m-17. `tier[..1].to_uppercase()` panics on empty tier names — Error messages (sync) | |
| 244 | + | ||
| 245 | + | - **Location:** `sync_panel.rs::draw_subscription_section`, line 100–101. | |
| 246 | + | - **Observation:** `tier[..1]` slices the first byte of the tier id, which panics if `tier == ""` or if the first character is multi-byte UTF-8 (any non-ASCII tier label from the server). | |
| 247 | + | - **Recommendation:** Use a helper like `let pretty = capitalize(tier);` that does `chars().next()` + uppercase. Defence in depth: the server *probably* always returns ASCII tier ids, but the panic is per-frame and crashes the panel — a high price for capitalisation. | |
| 248 | + | ||
| 249 | + | ### m-18. `format_scan_age` produces *"Last scanned 0 minutes ago"* between 60s and 119s — Polish (settings) | |
| 250 | + | ||
| 251 | + | - **Location:** `settings_panel.rs:60–69`. | |
| 252 | + | - **Observation:** Looks fine in isolation — the branch covers `60..3600`. At 61–119 seconds the result is *"Last scanned 1 minute ago"*. Off-by-one is fine. But the *"just now"* threshold cuts off at exactly 60s, where it would still feel "just now" for another 60–90. | |
| 253 | + | - **Recommendation:** Raise the "just now" threshold to 120s. Tiny copy win. | |
| 254 | + | ||
| 255 | + | --- | |
| 256 | + | ||
| 257 | + | ## Polish | |
| 258 | + | ||
| 259 | + | ### p-1. Filter classification chips don't render their color samples in the legend order — Consistency (filter) | |
| 260 | + | ||
| 261 | + | - **Location:** `filter_panel.rs:69`. | |
| 262 | + | - **Observation:** The hard-coded order is `kick snare hihat cymbal percussion bass vocal synth pad misc music noise` — roughly drum-first. There's no rationale for the ordering beyond convention. A user scanning for `music` has to read the full list. | |
| 263 | + | - **Recommendation:** Either alphabetise or group with separators (drums | tonal | other). Cheap, slight legibility win. | |
| 264 | + | ||
| 265 | + | ### p-2. Filter panel has no result count at the top or bottom — Anticipation (filter) | |
| 266 | + | ||
| 267 | + | - **Observation:** A user toggling filters has no in-panel feedback on how many samples now match. The main table updates, but the user often has the filter panel covering it. | |
| 268 | + | - **Recommendation:** Below "Clear All Filters", render a muted *"N samples match"* line derived from the existing search-result count. Polish-tier because the user *can* see this if they look at the table. | |
| 269 | + | ||
| 270 | + | ### p-3. ADSR has no preset selector — Anticipation (instrument) | |
| 271 | + | ||
| 272 | + | - **Observation:** Setting attack/decay/sustain/release values from scratch every time is friction for users who want a "pluck", "pad", or "kick" envelope. | |
| 273 | + | - **Recommendation:** Above the four sliders, a small row of `selectable_label` presets (Default / Pluck / Pad / Stab) that load envelope values. Stretch goal. | |
| 274 | + | ||
| 275 | + | ### p-4. Settings sections all default open; some shouldn't — Hierarchy (settings) | |
| 276 | + | ||
| 277 | + | - **Location:** `settings_panel.rs::draw_storage_section` through `draw_display_section`, all `default_open(true)`. | |
| 278 | + | - **Observation:** Five top-level sections all expanded by default means the user scrolls past Appearance and Preview to find Display every time. License and Advanced are `default_open(false)`, which is the right instinct — extend it. | |
| 279 | + | - **Recommendation:** Default-open only Storage (the section the user most often opens Settings for). Leave Appearance / Preview / Display collapsed but remember the last-opened state per-section via egui memory. | |
| 280 | + | ||
| 281 | + | ### p-5. Sync panel's "Cloud Sync" copy is inconsistent — Consistency (sync) | |
| 282 | + | ||
| 283 | + | - **Observation:** The window title says "Cloud Sync". The Disconnected screen says *"Connect your audiofiles vault to Makenot.work"*. The Ready screen's blob-sync header says *"Sync audio files to cloud"*. Three different framings for the same concept across one panel. | |
| 284 | + | - **Recommendation:** Standardise on one term — "Cloud sync" reads most cleanly. Use the brand name ("Makenot.work") only in the initial Connect copy where the user needs to know what they're authenticating to. | |
| 285 | + | ||
| 286 | + | ### p-6. Sync window's separator-heavy layout reads as a stack of unrelated controls — Hierarchy (sync) | |
| 287 | + | ||
| 288 | + | - **Observation:** The `draw_ready` view stacks five `ui.separator()` lines vertically: status, Sync Now, auto-sync, blob subscription, per-VFS toggles, Disconnect. The whole thing reads like a checklist rather than three groups (status & action / auto-sync prefs / blob-sync prefs / disconnect). | |
| 289 | + | - **Recommendation:** Wrap the three intermediate groups in `CollapsingHeader`s (status pinned at top, Disconnect pinned at bottom). Reduces visual length when most users only need the status line. | |
| 290 | + | ||
| 291 | + | --- | |
| 292 | + | ||
| 293 | + | ## Patterns across these findings | |
| 294 | + | ||
| 295 | + | Three patterns dominate, in descending impact: | |
| 296 | + | ||
| 297 | + | 1. **State changes are immediate but their reversibility is asymmetric.** C-2 (Disconnect with no confirm), C-3 (theme import silent failure), M-11 (no Cancel on Add Library), M-3 (no per-section clear) — the panels all assume immediate write-through but offer no Undo, no Cancel, and inconsistent confirmation gates for the small subset of operations that are actually destructive. Phase 3 introduced `ConfirmAction` precedents for both destructive and non-destructive cases; Phase 4 needs to inherit them. A single follow-up that (a) routes Disconnect through `ConfirmAction::DisconnectSync`, (b) gates first-time password setup behind a confirm field, (c) replaces every silent `tracing::error!` with a status post, and (d) adds Cancel to the Add-Library form would close the cluster. | |
| 298 | + | ||
| 299 | + | 2. **The configuration surfaces don't trust the user with information they already have.** M-10 (picker hides instead of showing "no MIDI inputs"), M-13 (per-VFS toggles lack size context), m-8 (vault rows hide cached stats), m-12 (mirror path is tooltip-only), C-4 (loading flags never time out so the panel pretends to be busy) — the panels treat absence-of-state and presence-of-state-the-user-might-find-confusing identically. The fix shape is uniform: render the empty state with information about *why* it's empty, and surface cached metadata inline whenever it exists. | |
| 300 | + | ||
| 301 | + | 3. **Pre-Phase-3 widget conventions linger.** m-16 (Disconnect uses old red-text idiom instead of `danger_button`), C-6 (vault rows use `selectable_row` while being inert), m-3 (tag filter header is a bare `ui.label`), p-4 (every Settings section is `default_open(true)`) — these surfaces predate the affordance/widget standardisation Phase 3 closed for the table. They aren't broken in isolation; they're each one step out of phase with the rest of the app. A single sweep that brings the four panels onto the Phase-3 widget vocabulary would close the cluster and is mechanically straightforward. | |
| 302 | + | ||
| 303 | + | When the C-tier items land, the next natural audit is **Phase 5 — overlays, modals, and the help/shortcuts surface** (the cross-cutting affordance layer). Several findings here (C-5 authenticating with no cancel, C-2 disconnect without confirm, M-14 sync error has no retry) point in that direction. | |
| 304 | + | ||
| 305 | + | --- | |
| 306 | + | ||
| 307 | + | ## Implementation note (2026-05-20) | |
| 308 | + | ||
| 309 | + | Every Critical and Major finding above has shipped. Build clean across `audiofiles-core`, `audiofiles-sync`, `audiofiles-browser`, `audiofiles-app`; 199 + 44 + 439 tests pass; design-system gates all return zero output. | |
| 310 | + | ||
| 311 | + | **Critical (6/6):** | |
| 312 | + | - **C-1** — `SyncUiState::encryption_confirm_input`. First-time setup branch (`!has_server_key`) renders a Confirm field; "Set Password" gated until `len >= 8` and inputs match. Hint text surfaces *"Password must be at least 8 characters."* / *"Passwords don't match."* as the user types. The "cannot be recovered" copy promoted from `.small().weak()` text into a new `widgets::warning_banner` (body weight, `accent_yellow`). | |
| 313 | + | - **C-2** — `ConfirmAction::DisconnectSync { pending_changes: i64 }`. Routed through `draw_confirm_dialog` with danger styling, "Disconnect" label, and a detail line that varies on pending count (*"N unsynced change(s) will be discarded. You'll need your encryption password to reconnect."*). Disconnect button switched to `widgets::danger_button`. Dispatched via `SyncUiState::pending_disconnect` flag because `execute_confirmed_action` runs without a `SyncManager` handle. Bonus refactor: `draw_confirm_dialog`'s tuple changed to `(String, Option<String>, &str, bool)` so variants can use formatted detail strings. | |
| 314 | + | - **C-3** — Theme import/export's five `tracing::error!`/`warn!` sites now also write `state.status` (success and four distinct failure reasons). Outer nested `if let` flattened to a `let Some(..) else` early-return so the missing-custom-dir path also posts a status. | |
| 315 | + | - **C-4** — `subscription_loading_at: Option<Instant>` and `checkout_loading_at: Option<Instant>` added to `SyncUiState`; flags expire after 30s and post retry-oriented status copy. *"Checking subscription..."* spinner gained a `[Retry]` micro-button. | |
| 316 | + | - **C-5** — `SyncManager` gained `auth_cancel_tx: Mutex<Option<oneshot::Sender<()>>>`. `start_auth` now races `code_rx` against the cancel channel via `tokio::select!`. New `pub fn cancel_auth()` triggers the channel and resets state to `Disconnected`. UI cached `auth_url` in `SyncUiState`, surfaced in `draw_authenticating` as a read-only field with a Copy button plus a Cancel button. Auth URL cleared when state leaves `Authenticating`. | |
| 317 | + | - **C-6** — Vault rows in Settings → Storage now respond to clicks. Mirrors `sidebar.rs`'s exact pattern: confirm via `ConfirmAction::SwitchLibrary` when `has_in_flight_work()`, otherwise dispatch `VaultAction::SwitchVault` directly. Closes Settings on switch. | |
| 318 | + | ||
| 319 | + | **Major (14/14):** | |
| 320 | + | - **M-1 / M-2 / M-3** — `filter_panel.rs` cluster. Per-section `[clear]` mini-button via a new `draw_section_clear` helper, on every active numeric and list section (BPM, Duration, Loudness, Classification, Key, Tags). Min/max DragValues snap their sibling when one crosses the other. | |
| 321 | + | - **M-4** — Renamed "Clear All Filters" to "Clear search and filters" (label now matches action — it does clear search_query too). | |
| 322 | + | - **M-5** — Filter panel's Tags section promoted from a bare `ui.label` to `widgets::filter_section`, gained a TextEdit + `[+]` input mirroring the detail-panel tag-add. Validated via `audiofiles_core::tags::validate_tag`. New `BrowserState::filter_tag_input` field. | |
| 323 | + | - **M-6 / M-7** — Tooltips added to `instrument_panel.rs`: Chromatic radio, disabled Multi-sample radio (*"Drop two or more samples onto the keyboard to enable multi-sample mode"*), Lock-sample checkbox (*"Keep the current sample loaded as the table selection changes"*). | |
| 324 | + | - **M-8** — Zone removal moved off shared secondary-click onto an explicit X chip per zone bar. Chip rects precomputed; primary-click on a chip → `remove_instrument_zone(i)`. Right-click is now reserved for "set root note" on keys alone. Pointer-note lookup also gates on `pos.y < white_height` (latent bug — clicking the zone-bar area used to play the white key directly above). | |
| 325 | + | - **M-9** — ADSR labels (A/D/S/R) gained `on_hover_text` naming the parameter. New `draw_adsr_envelope_shape` paints a 40px-tall four-segment ADSR contour above the sliders with a soft log time map. | |
| 326 | + | - **M-10** — MIDI picker no longer hides when ports list is empty; renders muted *"No MIDI inputs detected"* + inline Refresh button so plugging in mid-session is recoverable. | |
| 327 | + | - **M-11** — Add-Library form gained a `Cancel` button (only enabled when form has user state) that resets create_name/create_path/create_unsafe_mode. Add-Existing path now also sets `should_close = true` — both commit paths close Settings consistently. | |
| 328 | + | - **M-12** — Tier subscribe/change-tier rows: Annual = `widgets::primary_button`, Monthly = `widgets::secondary_button`. Visual hierarchy now agrees with the *"Annual saves you money"* prose. | |
| 329 | + | - **M-13** — New `Database::vfs_storage_stats(vfs_id) -> (u64, u64)` SQL (DISTINCT sample_hash). Exposed via `Backend::vfs_storage_stats(VfsId)` trait method + `DirectBackend` impl. `SyncUiState` gained `vfs_storage_cache: HashMap<i64, (u64, u64)>` + `vfs_storage_fetched: bool`. Per-VFS rows render *"X.X GB across N samples"* as a muted sub-label. | |
| 330 | + | - **M-14** — Sync error rendering rewritten: `bg_tertiary` frame containing the error in `accent_red` plus `[Retry]` (only when state is Ready/Syncing — calls `sync_now` + `clear_last_error`) and `[Dismiss]` (always — calls `clear_last_error`). Added `pub fn clear_last_error()` on `SyncManager`. | |
| 331 | + | ||
| 332 | + | **Bonus extraction:** `format_bytes` promoted to `widgets::format_bytes` (legacy private copies in `settings_panel.rs`, `overlays.rs`, `import_screens/progress.rs` left for opportunistic future cleanup). | |
| 333 | + | ||
| 334 | + | **Files touched:** | |
| 335 | + | - `audiofiles-core/src/db.rs` — new `vfs_storage_stats`. | |
| 336 | + | - `audiofiles-sync/src/lib.rs` — `SyncManager::cancel_auth`, `clear_last_error`, `auth_cancel_tx` field, `start_auth` rewritten with `tokio::select!`. | |
| 337 | + | - `audiofiles-browser/src/backend/{mod,direct}.rs` — `vfs_storage_stats` trait method + impl. | |
| 338 | + | - `audiofiles-browser/src/state/{mod,ui,bulk_ops}.rs` — `filter_tag_input`, `SyncUiState` fields (`encryption_confirm_input`, `pending_disconnect`, `auth_url`, `subscription_loading_at`, `checkout_loading_at`, `vfs_storage_cache`, `vfs_storage_fetched`), `ConfirmAction::DisconnectSync`, dispatch arm in `execute_confirmed_action`. | |
| 339 | + | - `audiofiles-browser/src/ui/{filter_panel,instrument_panel,settings_panel,sync_panel,overlays,widgets}.rs`. | |
| 340 | + | ||
| 341 | + | **Remaining (deferred):** 18 Minor + 6 Polish items above. None block ship. | |
| 342 | + | ||
| 343 | + | Phase 4 Critical + Major closed. Next: **Phase 4 Minor + Polish** (cleanup batch) or jump to **Phase 5** (import/export flows) per the `docs/todo.md` schedule. | |
| 344 | + | ||
| 345 | + | --- | |
| 346 | + | ||
| 347 | + | ## Implementation note — Minor + Polish (2026-05-20) | |
| 348 | + | ||
| 349 | + | All 18 Minor and all 6 Polish items shipped. Build clean across the four | |
| 350 | + | crates; 199 + 439 + 44 tests pass; design-system gates all return zero output. | |
| 351 | + | Three items were already closed as side effects of the Major batch — kept here | |
| 352 | + | for the audit-doc / code correspondence. | |
| 353 | + | ||
| 354 | + | **Already closed by the Major batch:** | |
| 355 | + | - **m-1** — Sentinel-visibility gap closed by M-2 (sibling-snap) and the | |
| 356 | + | per-section `[clear]` link. | |
| 357 | + | - **m-3** — Tag header was promoted to `widgets::filter_section` as part of | |
| 358 | + | M-5's tag-add input. | |
| 359 | + | - **m-16** — Disconnect button became `widgets::danger_button` as part of C-2. | |
| 360 | + | ||
| 361 | + | **Filter (filter_panel.rs):** | |
| 362 | + | - **m-2** — Replaced `DragValue::prefix("Min: ")/("Max: ")` with leading | |
| 363 | + | `ui.label("Min")` / `ui.label("Max")` + bare DragValue across all three | |
| 364 | + | numeric ranges. The label-value grid now matches the rest of Settings. | |
| 365 | + | - **m-4** — Save-as-Collection input commits on Enter as well as on the Save | |
| 366 | + | button click. Mirrors the rename input in `settings_panel.rs`. | |
| 367 | + | - **p-1** — Classification chips grouped into Drums / Tonal / Other with muted | |
| 368 | + | group labels and `horizontal_wrapped` rows. The drum-first reflex stays | |
| 369 | + | intact; `music` and `noise` are no longer invisible at the tail. | |
| 370 | + | - **p-2** — Below "Clear search and filters", added a muted *"N samples match"* | |
| 371 | + | line derived from `state.contents` (files only — directories filtered out). | |
| 372 | + | ||
| 373 | + | **Instrument (instrument_panel.rs):** | |
| 374 | + | - **m-5** — Octave navigation buttons promoted from `small_button` to | |
| 375 | + | `ui.button` (≈28px). Added `[` / `]` keyboard shortcuts, guarded by | |
| 376 | + | `ctx.memory(|m| m.focused().is_none())` so typing `[` into the filter tag | |
| 377 | + | input doesn't shift the octave. | |
| 378 | + | - **m-6** — Drop-hover feedback on the keyboard. While a `DragPayload` is | |
| 379 | + | active and the cursor is over a white key, paints a translucent | |
| 380 | + | `accent_blue.linear_multiply(0.3)` overlay and shows a tooltip | |
| 381 | + | *"Drop to create a zone centered on \<note\>"* via | |
| 382 | + | `egui::show_tooltip_at_pointer`. | |
| 383 | + | - **m-7** — Idle activity copy now depends on connection state. When the | |
| 384 | + | recent-notes list is empty and the user is connected, the dash is replaced | |
| 385 | + | by *"Connected to \<port\> · listening"*; otherwise *"Not connected"*. | |
| 386 | + | - **p-3** — ADSR preset row above the sliders (Default / Pluck / Pad / Stab). | |
| 387 | + | Implemented as `selectable_label`s; a preset highlights when the envelope | |
| 388 | + | matches it within `1e-4` (any slider edit drops the highlight). | |
| 389 | + | ||
| 390 | + | **Settings (settings_panel.rs):** | |
| 391 | + | - **m-8** — Active vault row now renders a muted *"N samples · X.X MB"* line | |
| 392 | + | under the path when `storage_cache` is populated. Non-active vaults stay | |
| 393 | + | path-only — the registry doesn't carry per-vault scan caches yet. | |
| 394 | + | - **m-9** — Scan button disables itself, swaps to *"Scanning..."*, and shows a | |
| 395 | + | spinner while `pending_action == ScanStorage`. The freshness label stays | |
| 396 | + | hidden during the scan since the cache is mid-write. | |
| 397 | + | - **m-10** — Custom themes in the Appearance combobox now read as | |
| 398 | + | *"\<name\> (custom)"* instead of *"\<name\> \*"*. The asterisk's | |
| 399 | + | *glitch-or-emphasis-artifact* read goes away. | |
| 400 | + | - **m-11** — Row-density slider gained a muted *"\<n\> px"* readout alongside | |
| 401 | + | the qualitative Compact / Normal / Spacious label. | |
| 402 | + | - **m-12** — Library Mirror now always surfaces its path under the enable | |
| 403 | + | checkbox (muted, `collapse_home`-formatted) with a *"Change..."* button that | |
| 404 | + | opens `rfd::FileDialog::pick_folder()` and routes through the existing | |
| 405 | + | `set_mirror_path` setter. | |
| 406 | + | - **m-13** — Trial label at `days <= 0` reads *"Trial expired"* instead of | |
| 407 | + | *"Trial: 0 days"*. The recommended companion *"\[Purchase license\]"* button | |
| 408 | + | is deferred — no buy flow exists yet to wire it to. | |
| 409 | + | - **m-14** — Machine id label is now `egui::Label::new(...).selectable(true)` | |
| 410 | + | and pairs with a small *"Copy"* button that calls `ctx.copy_text(...)` and | |
| 411 | + | posts a *"Copied machine id."* status. | |
| 412 | + | - **m-18** — `format_scan_age`'s *"just now"* threshold raised from 60s to | |
| 413 | + | 120s. Eliminates the *"Last scanned 0 minutes ago"* / *"1 minute ago"* | |
| 414 | + | copy-juddering window for the first two minutes. | |
| 415 | + | - **p-4** — Appearance / Preview / Display sections default to collapsed | |
| 416 | + | (`default_open(false)`); Storage stays open. License and Advanced were | |
| 417 | + | already collapsed. CollapsingHeader persists user toggles via egui memory, | |
| 418 | + | so the default only affects the first launch. | |
| 419 | + | ||
| 420 | + | **Sync (sync_panel.rs):** | |
| 421 | + | - **m-15** — When `status.sync_interval_minutes` falls outside the canonical | |
| 422 | + | `[5, 15, 30, 60]` pill set, a leading *"\<n\>m (custom)"* pill renders as | |
| 423 | + | active so the value is visible. | |
| 424 | + | - **m-17** — Tier capitalisation moved to a `capitalize_tier(&str) -> String` | |
| 425 | + | helper that uses `chars().next()` instead of `tier[..1]`. No more panics on | |
| 426 | + | empty strings or non-ASCII first bytes. | |
| 427 | + | - **p-5** — Blob-sync header copy collapsed from three framings into one: | |
| 428 | + | *"Audio file cloud sync"* heading + *"Metadata always syncs free..."* | |
| 429 | + | subtext. Brand name *Makenot.work* stays on the Disconnected screen only. | |
| 430 | + | - **p-6** — `draw_ready` reorganised: status + Sync Now stay pinned at top, | |
| 431 | + | Disconnect stays pinned at bottom, and the two intermediate groups | |
| 432 | + | (*Auto-sync*, *Audio file cloud sync*) wrap in `CollapsingHeader`s with | |
| 433 | + | `default_open(false)`. The Ready view now reads as status-first. | |
| 434 | + | ||
| 435 | + | **Files touched:** | |
| 436 | + | - `audiofiles-browser/src/ui/{filter_panel,instrument_panel,settings_panel,sync_panel}.rs`. | |
| 437 | + | ||
| 438 | + | No state-shape changes, no new public APIs. Phase 4 closed. |
| @@ -0,0 +1,542 @@ | |||
| 1 | + | # Phase 5 UX Audit — Import & Export wizards | |
| 2 | + | ||
| 3 | + | **Surfaces:** `ui/import_screens/{configure,progress,tagging,summary}.rs`, `ui/export_screens.rs` (plus the wizard-shared `widgets::wizard_steps`, `ImportMode` state machine, and supporting backend dispatch). | |
| 4 | + | **Detected stack:** egui (immediate-mode Rust GUI), with the wizard implemented as a state machine over `ImportMode` — each screen reads the current variant and renders the corresponding step. | |
| 5 | + | **Frame of reference:** Phase 4 closed the configuration surfaces — the controls users press during steady-state operation. Phase 5 covers the *journey* surfaces: multi-step flows that the user enters with a goal, leave with a result, and rarely revisit a single screen of in isolation. The dominant axis here is the asymmetry between commit and recovery — a wizard is only as good as the back-out paths users never have to use. | |
| 6 | + | ||
| 7 | + | Findings are ranked Critical / Major / Minor / Polish using the Phase 3/4 severity rubric. No code in this document — recommendations describe the change, not the diff. | |
| 8 | + | ||
| 9 | + | --- | |
| 10 | + | ||
| 11 | + | ## Critical | |
| 12 | + | ||
| 13 | + | ### C-1. The wizard has no Back button anywhere — Forgiveness (all import screens) | |
| 14 | + | ||
| 15 | + | - **Location:** `configure.rs:157–191` (Configure Import → forward only); `tagging.rs:19–30` (Tag folders → Skip or Apply); `configure.rs:243–259` (Configure Analysis → Run or Skip); `tagging.rs:180–193` (Review Suggestions → Cancel or Apply Selected Tags). | |
| 16 | + | - **Observation:** `widgets::wizard_steps` paints a 4-step breadcrumb (`Configure → Tag folders → Analyze → Review`) on every screen. The breadcrumb is decorative: nothing it shows is reachable. To change a single setting on a prior step, the user must Cancel out of the whole flow and re-enter from the invocation site — losing tag input, accepted suggestions, analysis config, and the source-folder selection along the way. | |
| 17 | + | - **Why it matters:** This is the classic Tognazzini "make it easy to walk back" violation. Wizards exist precisely because the user is doing something with enough commitment cost that they want milestones, but those milestones lose half their value when they can only be crossed forward. The user opens the import flow with a fuzzy mental model of what the strategies mean; they refine it as the next screen renders. By design, the next screen always shows them they should have picked differently on the previous one. | |
| 18 | + | - **Recommendation:** Add a `Back` button next to Cancel on every wizard screen past Configure. Back unwinds the `ImportMode` to the prior variant *with state preserved* — going Back from `TagFolders` returns to `ConfigureImport` with the previously chosen source, strategy, and vault selection still in place. The Configure → Importing transition is the only one-way edge in the flow (samples have entered the store; rewinding is `cancel_import()`-equivalent). Surface that one-way moment explicitly with a "This will start importing files. You can cancel mid-import but partial copies will stay in the library." line above the Import button. | |
| 19 | + | ||
| 20 | + | ### C-2. "Remove All Failed" and per-row "Remove" are unconfirmed destructive actions — Forgiveness (summary) | |
| 21 | + | ||
| 22 | + | - **Location:** `summary.rs:48–50` (per-row Remove on `analysis_errors`) and `summary.rs:99–101` ("Remove All Failed"). | |
| 23 | + | - **Observation:** Both buttons call into deletion paths (`remove_failed_sample`, `remove_all_failed_samples`) without any confirmation. "Remove All Failed" in particular is a single click that purges every analysis-failed sample from the content store. There is no Undo. The peer pattern in Phase 4 (`ConfirmAction::DisconnectSync`, `ReanalyzeOverwrite`) gates similar-blast-radius operations behind `confirm_modal` with a per-variant detail line. | |
| 24 | + | - **Why it matters:** A user reaching the error-review screen is by definition operating under reduced confidence — they just watched a batch import fail. The bias is to clear the red text away, and "Remove All Failed" is the loudest button. The cost of the click is permanent loss of files that may have been failing for a recoverable reason (codec missing, transient read error, etc.). The audit found no equivalent danger in Phase 4 without a confirm; this one is below the proportionality bar. | |
| 25 | + | - **Recommendation:** Add a `ConfirmAction::RemoveFailedSamples { count: usize }` variant routed through `overlays.rs::draw_confirm_dialog`. Detail line: *"N file(s) will be permanently deleted from the library. This cannot be undone."*. Render the trigger as `widgets::danger_button` (it already is for "Remove All Failed"; the per-row "Remove" uses `danger_small_button` which is fine for the affordance but should still gate through the confirm). Per-row Remove can stay one-click if the audit prefers (single-file, clear name in view) — but "Remove All Failed" must confirm. | |
| 26 | + | ||
| 27 | + | ### C-3. Cancel during write phases offers no acknowledgement of what landed vs. what was discarded — Feedback (progress) | |
| 28 | + | ||
| 29 | + | - **Location:** `progress.rs:118–129` (import Cancel), `progress.rs:170–172` (cleanup Cancel), `progress.rs:250–261` (analysis Cancel), `export_screens.rs:442–444` (export Cancel). | |
| 30 | + | - **Observation:** All four Cancel buttons drop the wizard to `ImportMode::None` instantly. The state.status post (where one exists) is generic — *"Analysis skipped"*, etc. — and does not summarise the partial result. A user who cancels at 47% during a folder import has just left ~half the files in the library; nothing on screen tells them this. The peer pattern (Phase 4 sync timeout copy) at least names the failure mode. | |
| 31 | + | - **Why it matters:** Cancel during a write operation is the modal partial-completion case for users running large imports. Without an acknowledgement, the user has no way to know whether they should re-run the same import (which will mostly skip duplicates, per the import dedup logic) or restore from a backup. Importing a 5,000-file folder and cancelling mid-way leaves ~2,500 files in the store and the user staring at the library browser wondering if it worked. | |
| 32 | + | - **Recommendation:** On Cancel from `Importing`/`Analyzing`/`Exporting`, do not transition straight to `None`. Land in a brief acknowledgement state — reuse `ReviewErrors` or add a sibling `ImportCancelled { committed, requested }` — that posts *"Cancelled at X / Y files. Imported files remain in the library — re-run import to add the rest (duplicates will be skipped)."* and offers a primary `Done` button. Same shape for Analyze (*"Cancelled at X / Y samples. Analysed samples keep their results; the rest are unanalysed."*) and Export (*"Cancelled at X / Y files. Files already written to <destination> remain; partial files may be present."*). | |
| 33 | + | ||
| 34 | + | --- | |
| 35 | + | ||
| 36 | + | ## Major | |
| 37 | + | ||
| 38 | + | ### M-1. Progress-screen error counts are click-to-expand — Visibility of state (progress) | |
| 39 | + | ||
| 40 | + | - **Location:** `progress.rs:78–115` (import) and `progress.rs:210–247` (analysis). Identical pattern. | |
| 41 | + | - **Observation:** When errors accumulate during a long-running import, they collapse behind a single red label *"N errors (click to expand)"*. The default state is collapsed; users who don't notice the line or don't realise it's clickable see only the progress bar. By the time the import finishes, the error count may be hundreds. The Phase 3 `info_banner` widget already establishes the warning-with-content pattern. | |
| 42 | + | - **Why it matters:** Errors during a live operation are exactly the moment the user *should* see them — fix-it-now beats fix-it-later, and the import-time error is often something the user could pause and address (close the file in another app, change permissions). Hiding them by default optimises for the rare case where errors are noise and the common case where they're signal. | |
| 43 | + | - **Recommendation:** Default-expand the error list when `err_count > 0`. Cap the scroll area at the existing `max_height(120.0)` so it doesn't dominate the screen. Move the expand/collapse toggle to a `[hide]` link at the top-right of the list rather than wrapping the whole label in a click-sense — currently the affordance is ambiguous (is the red label clickable? Looks like a status). | |
| 44 | + | ||
| 45 | + | ### M-2. Cancel during the walking phase has undefined behaviour — Modes (progress) | |
| 46 | + | ||
| 47 | + | - **Location:** `progress.rs:38–42` (`walking == true` spinner) and `progress.rs:118–121` (Cancel button always rendered). | |
| 48 | + | - **Observation:** During the *"Scanning for audio files…"* phase (`walking == true`), the only visible control is Cancel. `cancel_import()` was designed for the post-walk import phase — its semantics during the directory walk are not documented. The user clicking Cancel during a multi-minute walk of a large folder tree gets either an instantaneous transition to None (good) or a frozen UI until the walker yields (bad). The audit can't tell from the surface. | |
| 49 | + | - **Why it matters:** Long walks happen — network mounts, sample libraries with 50K files, slow USB drives. The user's expectation is that Cancel halts the current operation, and the surface implies it does. If the implementation has different semantics for walking vs. importing, the surface owes the user that distinction. | |
| 50 | + | - **Recommendation:** Verify that `cancel_import()` correctly interrupts the walker (cooperative cancel via a flag the walker checks per directory). If it does, no UI change. If it doesn't, either (a) wire a separate cancel path for the walking phase, or (b) disable Cancel during walking with a tooltip *"Scanning — cancel available once the scan completes"*. (a) is the user-favouring fix; (b) is acceptable if implementation cost is high. | |
| 51 | + | ||
| 52 | + | ### M-3. Export "Low disk space" check is a 10 MB/item heuristic that produces false positives — Error messages (export) | |
| 53 | + | ||
| 54 | + | - **Location:** `export_screens.rs:134–149`. | |
| 55 | + | - **Observation:** `let estimated_bytes = items.len() as u64 * 10 * 1024 * 1024;` — every item is assumed to need 10 MB. A user exporting 1,000 one-shot drum hits (often <100 KB each) gets a screaming red *"Low disk space: X GB available, estimated 10 GB needed"* even with a half-terabyte free. The actual disk-usage estimate already lives nearby: the device-profile check at `:104–109` derives per-item bytes from `duration × bytes_per_sec`. The disk-space check could too. | |
| 56 | + | - **Why it matters:** A red warning that fires when there's no actual risk teaches users to ignore red warnings. The genuine low-disk case (large multi-channel session at 96kHz to a nearly-full drive) gets buried under the noise. The Tognazzini rule: feedback that's wrong is worse than no feedback. | |
| 57 | + | - **Recommendation:** Compute `estimated_bytes` from actual sample durations + the target format's bytes-per-second (same formula as the device-profile check; share a helper). Only render the warning when `available < estimated_bytes × 1.1` (10% headroom for filesystem overhead). Drop the alarmist `accent_red`; this is anticipation, not error — use `accent_yellow` and the `info_banner` style. | |
| 58 | + | ||
| 59 | + | ### M-4. AIFF size warning fires at 20 minutes; real limit is ~124 minutes — Anticipation (export) | |
| 60 | + | ||
| 61 | + | - **Location:** `export_screens.rs:81–94`. The comment block (`/* Conservative threshold: warn at 20 min for any config */`) acknowledges the gap. | |
| 62 | + | - **Observation:** AIFF's u32 chunk-size limit translates to ~124 minutes at the worst-case configuration (stereo 24-bit 96 kHz). The warning fires at 20 minutes regardless of the user's actual format/rate/depth — a user exporting a stereo 16-bit 44.1 kHz file sees the warning at 20 min when their actual headroom is ~6 hours. | |
| 63 | + | - **Why it matters:** Same shape as M-3: a warning that fires too often gets ignored. The user who legitimately is over the limit (a 3-hour 96kHz multitrack export) sees the same red text as someone who's well within bounds at 25 minutes. | |
| 64 | + | - **Recommendation:** Compute the actual byte budget from `config.sample_rate × bit_depth/8 × channels`. Warn only when `(max_duration × bytes_per_sec) > u32::MAX × 0.9`. Same colour treatment as M-3 (yellow, info_banner). If the user has selected "Original" rates/depths, fall back to a conservative estimate but flag the warning as *"may exceed"* rather than *"will exceed"*. | |
| 65 | + | ||
| 66 | + | ### M-5. Configure Import can't re-pick the source folder — Forgiveness (configure) | |
| 67 | + | ||
| 68 | + | - **Location:** `configure.rs:22` — source is read-only label *"Source: {source_display}"*. | |
| 69 | + | - **Observation:** A user who picked the wrong folder (selected a parent directory by mistake; navigated into a subfolder they meant to skip) has no way to repoint the import without cancelling out of the wizard entirely. The folder picker that produced the source lives at the invocation site — somewhere in the toolbar or sidebar, not on this screen. | |
| 70 | + | - **Why it matters:** This is the same friction as C-1's no-Back, applied to the very first commit step. The cost is low (re-pick the folder, retain the rest of the form) but the affordance is missing. | |
| 71 | + | - **Recommendation:** Render the source as a *"Source: {path} \[Change…\]"* row with a small Change button that opens the same `rfd::FileDialog::pick_folder` the invocation site uses. On a successful pick, replace the source in `ImportMode::ConfigureImport` and rerun the dry-run audio-file count. | |
| 72 | + | ||
| 73 | + | ### M-6. Device profile lock hides the forced values — Visibility of state (export) | |
| 74 | + | ||
| 75 | + | - **Location:** `export_screens.rs:229–243` (profile info label) and `:249–319` (encoding controls hidden when `has_profile`). | |
| 76 | + | - **Observation:** When the user selects a device profile, the Format / Sample Rate / Bit Depth / Channels controls vanish and a single muted line appears: *"by {manufacturer} — format, rate, depth, and channels will be set to device defaults"*. The user has no way to know what those defaults are without exporting and inspecting the result. | |
| 77 | + | - **Why it matters:** Devices vary — an OP-1 wants 16-bit 44.1 kHz mono AIFF; a Maschine wants 16-bit 44.1 kHz stereo WAV; a Digitakt wants stereo 16-bit 48 kHz WAV. A user picking a profile they're unsure about (or comparing two profiles) needs the actual values to make the choice. Hiding them is honest about the lock but dishonest about the information. | |
| 78 | + | - **Recommendation:** Below the *"by {manufacturer}"* line, render the forced values as a 4-row mini-table or a single muted line: *"WAV · 44,100 Hz · 16-bit · Mono"*. Pull from the same profile object the dropdown already has access to. Optionally: if a profile field is "Original" (not forced), say so — *"WAV · 44,100 Hz · Original bit depth · Stereo"*. | |
| 79 | + | ||
| 80 | + | ### M-7. Export Configure has no preview of output filenames — Mappings (export) | |
| 81 | + | ||
| 82 | + | - **Location:** `export_screens.rs:368–384` (naming pattern field, when `config.flatten`). | |
| 83 | + | - **Observation:** The naming pattern accepts tokens (`{name} {bpm} {key} {class} {duration} {n} {nn} {nnn} {ext}`). The user types `kick_{bpm}_{nn}` and sees no preview until they hit Export. A small typo (`{bmp}` instead of `{bpm}`) produces files named `kick_{bmp}_01.wav` literally — which the user discovers after the export completes. | |
| 84 | + | - **Why it matters:** Naming patterns are the high-volume choice in export. A 200-file export with a typo means 200 files to rename. The token list at `:370–377` shows the alphabet but not the words. | |
| 85 | + | - **Recommendation:** Below the pattern input, render *"Preview: <derived filename for the first item>"* in `text_muted`. Update live as the user types. If the pattern contains unknown tokens (`{bmp}`), highlight them in `accent_yellow` in the preview and surface a *"Unknown token: \{bmp\}"* hint. Bonus: when `flatten == false`, surface a preview of the relative path structure (e.g. *"Preview: kicks/909/{name}.wav"*). | |
| 86 | + | ||
| 87 | + | ### M-8. Naming pattern tokens are not insertable — Affordances (export) | |
| 88 | + | ||
| 89 | + | - **Location:** `export_screens.rs:371–377`. | |
| 90 | + | - **Observation:** The token legend renders as a static muted line. The user reads `{name}` and types it manually. A click-to-insert chip row would close the affordance. | |
| 91 | + | - **Why it matters:** Lower priority than M-7 but in the same area — the naming-pattern surface trains the user about what's possible by showing the syntax. Making each token a chip that inserts itself at the cursor turns the legend from documentation into UI. | |
| 92 | + | - **Recommendation:** Replace the comma-separated label with a row of `widgets::selectable_tag` (or a simple `small_button` per token). Click inserts the token at the current cursor position in the pattern input. Keep the row labelled *"Tokens:"* to retain the documentary read. | |
| 93 | + | ||
| 94 | + | ### M-9. Tag folders has no "apply to all" or per-folder Skip — Efficiency (tagging) | |
| 95 | + | ||
| 96 | + | - **Location:** `tagging.rs:42–80` (folder list rendering) and `:19–30` (footer: Skip / Apply Tags). | |
| 97 | + | - **Observation:** Each imported folder gets its own tag input. The two commit buttons are *all-or-nothing*: Skip drops the whole tagging step, Apply Tags applies whatever was entered for each folder. A user with 30 imported folders where 28 want the same tag (`one-shots, kick`) and 2 want something specific has to type the same tag string 28 times. A user who wants to tag two specific folders and skip the rest has to leave 28 inputs empty and apply, hoping empty inputs are no-ops. | |
| 98 | + | - **Why it matters:** This is the common case for sample-library imports — the folder structure is the taxonomy, and users want to commit large parts of it as tags. Without batch operations, the wizard punishes the structured-folder case it's most useful for. | |
| 99 | + | - **Recommendation:** (a) Add a single *"Apply tags to all folders"* input at the top of the list with an *"Apply to all"* button that overwrites every per-folder input. (b) Add a per-folder *"Skip this folder"* checkbox or button that marks the folder as no-op (distinct from an empty input — explicit). (c) The bulk-skip case is already covered by the footer "Skip" — keep it. | |
| 100 | + | ||
| 101 | + | ### M-10. Review Suggestions sample list doesn't show review status per item — Visibility of state (tagging) | |
| 102 | + | ||
| 103 | + | - **Location:** `tagging.rs:209–217`. Sample row: `let label = format!("{} {}", item.name, sug_count);`. | |
| 104 | + | - **Observation:** Each row in the sample list shows the name + total suggestion count. There's no indication of how many have been accepted, whether the item has been reviewed at all, or whether it has zero suggestions (in which case there's nothing to review). A user walking a 50-sample list has to click each row to see whether they need to do anything. | |
| 105 | + | - **Why it matters:** Review work is one-pass — the user wants to see at-a-glance which rows still need attention. Hiding that information forces a click-per-row even for items with no work. | |
| 106 | + | - **Recommendation:** Format as *"{name} {accepted}/{total}"* with the count muted when `accepted == total` (done) and in `accent_yellow` when `accepted < total` (needs attention). Suppress the count entirely when `total == 0` (nothing to review) and render the row dimmed. Optionally: add a *"Hide reviewed"* checkbox to filter the list. | |
| 107 | + | ||
| 108 | + | ### M-11. No ETA or rate display in any long-running operation — Anticipation (progress) | |
| 109 | + | ||
| 110 | + | - **Location:** All three progress screens — `progress.rs::draw_import_progress`, `draw_cleanup_progress`, `draw_analysis_progress`, plus `export_screens.rs::draw_export_progress`. | |
| 111 | + | - **Observation:** Every progress bar shows `{pct}% — {completed}/{total} files`. None show the rate (files/sec, MB/sec) or an estimated time remaining. A user watching an import of 50,000 files at 12% has no way to know if this is a 5-minute wait or a 2-hour wait. | |
| 112 | + | - **Why it matters:** Long-running operations without ETAs are the prototypical "is this stuck?" surface. Users start refreshing, force-quitting, or doing other things they shouldn't. A first-derivative readout (`12 files/sec, ~18 min remaining`) is cheap to compute and answers the question. | |
| 113 | + | - **Recommendation:** Track `started_at: Instant` and a small ring buffer of `(timestamp, completed)` samples in each `ImportMode::Importing` / `Analyzing` / `Exporting` variant. Compute rolling rate over the last ~5 seconds. Render *"X.X files/sec · ~Ym Zs remaining"* in `text_muted` under the progress bar. Suppress the ETA when the operation has been running for less than 5 seconds (no data) or when the rate is too variable to predict. | |
| 114 | + | ||
| 115 | + | ### M-12. "Apply Selected Tags" has no count summary — Anticipation (tagging) | |
| 116 | + | ||
| 117 | + | - **Location:** `tagging.rs:187–190`. | |
| 118 | + | - **Observation:** The button is bare *"Apply Selected Tags"*. The header at `:129–131` shows `{accepted_count} accepted` out of `{total_suggestions}` total, but the button itself doesn't reiterate the commit count. A user with 137 accepted suggestions clicks the button expecting confirmation; the screen transitions away and the suggestions are applied. | |
| 119 | + | - **Why it matters:** Tag application is a high-volume mutation. A small confirmation — even just on the button label — reduces accidental clicks and answers the "what am I committing?" question. | |
| 120 | + | - **Recommendation:** Render the button label as *"Apply {accepted_count} Tag(s)"*. When `accepted_count == 0`, disable the button with a tooltip *"No suggestions accepted — pick at least one or Cancel"*. This also covers a missing forgiveness case at `:187` (clicking Apply with zero accepted is currently a no-op that still tears down the wizard). | |
| 121 | + | ||
| 122 | + | ### M-13. Review errors screen doesn't separate the two error categories visually — Mappings (summary) | |
| 123 | + | ||
| 124 | + | - **Location:** `summary.rs:21–58` (analysis errors, Remove-able) vs. `:62–92` (import errors, informational only). | |
| 125 | + | - **Observation:** Both lists render with the same `accent_red` heading and the same red row labels. The only structural difference is that analysis-error rows have a Remove button and import-error rows don't. A user scanning the screen sees two heaps of red text and reads them as one undifferentiated failure mass. | |
| 126 | + | - **Why it matters:** The two categories have different remediation paths. Analysis errors mean the file is in the library but couldn't be analysed — the user can Remove it, re-analyse it, or keep it as-is. Import errors mean the file never entered the library — the user has nothing to remediate from this screen (they would re-run the import). Conflating the two costs the user the chance to act on the actionable category. | |
| 127 | + | - **Recommendation:** Add a one-line muted heading under each section explaining the category: *"These files are in the library but couldn't be analysed. You can remove them, ignore them, or re-analyse later."* and *"These files weren't imported. Re-running the import (duplicates will be skipped) is the only way to retry."*. Reserve `accent_red` for the count strong-labels; bring the row text down to `text_primary` to reduce the visual density. | |
| 128 | + | ||
| 129 | + | ### M-14. Suggestion confidence is a percentage with no visual cue — Hierarchy (tagging) | |
| 130 | + | ||
| 131 | + | - **Location:** `tagging.rs:259`: `ui.label(format!("{:.0}%", sug.suggestion.confidence * 100.0));`. | |
| 132 | + | - **Observation:** A 95%-confidence suggestion and a 55%-confidence one render with identical typography. The number is there but the visual hierarchy doesn't reflect the signal. | |
| 133 | + | - **Why it matters:** Users want to triage — accept the high-confidence batch with a glance, scrutinise the low-confidence ones individually. The Phase 3 colour vocabulary (`accent_green` / `accent_yellow` / `text_muted`) gives the surface room to do this without inventing new chrome. | |
| 134 | + | - **Recommendation:** Colour the percentage by threshold: `>=80` → `accent_green`, `>=60` → `accent_yellow`, `<60` → `text_muted`. Or add a small bar / pill behind the number. Either path lets the user scan the list and find the borderline cases. | |
| 135 | + | ||
| 136 | + | --- | |
| 137 | + | ||
| 138 | + | ## Minor | |
| 139 | + | ||
| 140 | + | ### m-1. `format_bytes` is a private copy of the widgets helper — Consistency (progress) | |
| 141 | + | ||
| 142 | + | - **Location:** `progress.rs:7–18`. | |
| 143 | + | - **Observation:** Phase 4 extracted `format_bytes` to `widgets::format_bytes` and left three legacy private copies for opportunistic cleanup; this is one of them. | |
| 144 | + | - **Recommendation:** Delete the private copy, call `widgets::format_bytes`. One-line fix. | |
| 145 | + | ||
| 146 | + | ### m-2. Sample list label uses a double space as separator — Consistency (tagging) | |
| 147 | + | ||
| 148 | + | - **Location:** `tagging.rs:211`: `let label = format!("{} {}", item.name, sug_count);`. | |
| 149 | + | - **Recommendation:** Use a middle dot (`·`, `\u{00B7}`) the way the rest of the app does for inline metadata separation. Pair with M-10's review-status formatting. | |
| 150 | + | ||
| 151 | + | ### m-3. "Analysis skipped" status doesn't acknowledge the successful import — Feedback (configure) | |
| 152 | + | ||
| 153 | + | - **Location:** `configure.rs:256–257`. | |
| 154 | + | - **Observation:** After a successful import, the user hits Skip on the analysis screen and the status posts *"Analysis skipped"*. The import was the actual milestone; the message frames the skip as the headline. | |
| 155 | + | - **Recommendation:** Post something like *"Imported N samples. Analysis skipped — re-run from the sidebar when ready."* — references the prior step's success and points to where the user can pick it up. | |
| 156 | + | ||
| 157 | + | ### m-4. Export "Starting export..." has no spinner — Feedback (export) | |
| 158 | + | ||
| 159 | + | - **Location:** `export_screens.rs:428–430`. | |
| 160 | + | - **Observation:** Before the first item lands, the progress screen shows *"Starting export..."* as a plain label. The other progress screens use `ui.spinner()` for the equivalent moment. | |
| 161 | + | - **Recommendation:** Add a `ui.spinner()` in the same row as the *"Starting export..."* label. | |
| 162 | + | ||
| 163 | + | ### m-5. Configure Analysis "Skip" is ambiguous — Anticipation (configure) | |
| 164 | + | ||
| 165 | + | - **Location:** `configure.rs:255`. | |
| 166 | + | - **Observation:** The button just says *"Skip"*. Skip what — the wizard, the analysis, this step? Other screens use longer labels. | |
| 167 | + | - **Recommendation:** *"Skip analysis"* (matches the heading). | |
| 168 | + | ||
| 169 | + | ### m-6. Wizard step indicator stays on "Tag folders" even when skipped — Mappings (cosmetic, tagging) | |
| 170 | + | ||
| 171 | + | - **Location:** `tagging.rs:33` (passes step index 1 to `wizard_steps`). | |
| 172 | + | - **Observation:** If the user clicks Skip on Tag folders, they jump to Configure Analysis (step 2). The breadcrumb's step-1 cell still says *"Tag folders"* with no indication that it was bypassed. | |
| 173 | + | - **Recommendation:** Either grey out / strike-through skipped steps in the breadcrumb, or stop highlighting them as historical (the breadcrumb is decorative per C-1 — small fix is cosmetic only). | |
| 174 | + | ||
| 175 | + | ### m-7. Review Suggestions detail header has no position indicator — Anticipation (tagging) | |
| 176 | + | ||
| 177 | + | - **Location:** `tagging.rs:229`: `ui.heading(&item.name);`. | |
| 178 | + | - **Observation:** The detail panel shows just the sample name. There's no *"Reviewing 3 of 12"* counter. | |
| 179 | + | - **Recommendation:** Render *"{name}"* as the heading and a muted *"({current_idx + 1} of {total})"* under it. | |
| 180 | + | ||
| 181 | + | ### m-8. Export Complete renders errors in `text_muted` — Hierarchy (export) | |
| 182 | + | ||
| 183 | + | - **Location:** `export_screens.rs:472–475`. | |
| 184 | + | - **Observation:** Each error row is `text_muted` — the same colour the app uses for hint text and timestamps. Errors read as benign. | |
| 185 | + | - **Recommendation:** Use `accent_red` for the per-error label name and `text_secondary` for the message body. Matches the pattern in `progress.rs` and `summary.rs`. | |
| 186 | + | ||
| 187 | + | ### m-9. Export Complete "Done" button is plain — Affordances (export) | |
| 188 | + | ||
| 189 | + | - **Location:** `export_screens.rs:483–485`. | |
| 190 | + | - **Observation:** The terminal button uses `ui.button` — plain. Phase 4 closed on `widgets::primary_button` for the anchor moment of a flow. | |
| 191 | + | - **Recommendation:** `widgets::primary_button(ui, "Done")`. Same shape as the *"Set Password"* primary button on the sync setup screen. | |
| 192 | + | ||
| 193 | + | ### m-10. Tag folders Apply button isn't gated on any content — Anticipation (tagging) | |
| 194 | + | ||
| 195 | + | - **Location:** `tagging.rs:25–27`. | |
| 196 | + | - **Observation:** *"Apply Tags"* is always enabled. If every input is empty, the click is a no-op (equivalent to Skip but framed as a commit). | |
| 197 | + | - **Recommendation:** Disable the button when `entries.iter().all(|e| e.tag_input.trim().is_empty())` with a tooltip *"Add at least one tag, or use Skip"*. | |
| 198 | + | ||
| 199 | + | ### m-11. Configure Import "Import" button isn't gated on strategy validity — Forgiveness (configure) | |
| 200 | + | ||
| 201 | + | - **Location:** `configure.rs:161–190`. | |
| 202 | + | - **Observation:** The button always renders enabled. Clicking with `NewVfs` selected and an empty `new_vfs_name` produces an unnamed-vault import; clicking with `MergeIntoVfs` selected and no entries in `available_vfs` panics on `available_vfs[selected_merge_vfs_idx]` (line 181). | |
| 203 | + | - **Recommendation:** Disable Import when the chosen strategy's required fields are empty/missing. Surface the gate as a tooltip *"Enter a vault name to import as a new vault"* or *"No vaults available to merge into"*. | |
| 204 | + | ||
| 205 | + | ### m-12. Folder import shows storage estimate only after walking — Anticipation (progress) | |
| 206 | + | ||
| 207 | + | - **Location:** `progress.rs:38–58`. | |
| 208 | + | - **Observation:** During `walking == true`, no storage estimate is shown (the walker hasn't produced byte counts yet). The user gets a spinner with no sense of magnitude. After the walk completes, the estimate appears all at once. | |
| 209 | + | - **Recommendation:** During the walk, show a running *"Scanning… {seen_so_far} files found"* count (the walker already knows this — it's reporting incrementally to drive the `audio_file_count`). Pre-magnitude is better than no magnitude. | |
| 210 | + | ||
| 211 | + | ### m-13. Cleanup progress uses "Removing:" while import/analysis use "Current:" — Consistency (progress) | |
| 212 | + | ||
| 213 | + | - **Location:** `progress.rs:163–166` vs `:72–74` / `:204–207`. | |
| 214 | + | - **Observation:** Three progress screens, three near-identical templates, one labels the per-item line *"Removing:"* while the others say *"Current:"*. Minor consistency tax. | |
| 215 | + | - **Recommendation:** Either standardise on *"Current:"* (most generic) or use the verb that matches the operation (*"Importing:"*, *"Analysing:"*, *"Removing:"*, *"Exporting:"*). Pick one. Mixed is the worst option. | |
| 216 | + | ||
| 217 | + | ### m-14. Cancel during export has no confirmation and no acknowledgement — Forgiveness (export) | |
| 218 | + | ||
| 219 | + | - **Location:** `export_screens.rs:442–444`. | |
| 220 | + | - **Observation:** Same shape as C-3 but ranked lower because export is to a user-chosen filesystem destination, not the library's content store — the user can manually delete partial files. Still worth a status post. | |
| 221 | + | - **Recommendation:** Land in the C-3 acknowledgement state for export with the same shape — name the destination, name the partial-files possibility. | |
| 222 | + | ||
| 223 | + | ### m-15. Suggestion confidence has no visual sort or filter — Efficiency (tagging) | |
| 224 | + | ||
| 225 | + | - **Location:** `tagging.rs:255–268` (suggestion list). | |
| 226 | + | - **Observation:** Suggestions render in whatever order the backend returned them. A user wanting to triage by confidence has to read each percentage. | |
| 227 | + | - **Recommendation:** Sort the per-sample suggestion list by confidence descending so high-confidence picks group at the top. Optional: a sort toggle. | |
| 228 | + | ||
| 229 | + | ### m-16. "Review Errors" Keep All is the primary action but uses plain button — Hierarchy (summary) | |
| 230 | + | ||
| 231 | + | - **Location:** `summary.rs:96–101`. | |
| 232 | + | - **Observation:** "Keep All" is the non-destructive default; "Remove All Failed" is destructive. They render side-by-side as a plain button and a `danger_button`. The non-destructive default should be the primary (filled) action. | |
| 233 | + | - **Recommendation:** *"Keep All"* → `widgets::primary_button`; *"Remove All Failed"* stays `danger_button`. Pair with C-2's confirm. | |
| 234 | + | ||
| 235 | + | --- | |
| 236 | + | ||
| 237 | + | ## Polish | |
| 238 | + | ||
| 239 | + | ### p-1. No "Open destination folder" link on Export Complete — Anticipation (export) | |
| 240 | + | ||
| 241 | + | - **Location:** `export_screens.rs:455–486`. | |
| 242 | + | - **Observation:** After exporting 200 files to `/Users/foo/Exports/run-3/`, the user clicks Done and goes back to the library browser. To verify or share the result they have to open Finder/Explorer themselves and navigate there. | |
| 243 | + | - **Recommendation:** Below the *"Successfully exported…"* line, add a *"Open destination folder"* link/button that calls the platform-appropriate shell-open (`open`, `xdg-open`, `explorer`) on `config.destination`. | |
| 244 | + | ||
| 245 | + | ### p-2. Tag folders has no count summary in the header — Anticipation (tagging) | |
| 246 | + | ||
| 247 | + | - **Observation:** *"Tag Imported Folders"* heading + instructional copy, but no *"N folders, M samples"* line. The user has to scroll to estimate scope. | |
| 248 | + | - **Recommendation:** Below the heading: *"{folder_count} folders · {total_samples} samples"* in `text_muted`. | |
| 249 | + | ||
| 250 | + | ### p-3. Review Suggestions sample list has no sort options — Efficiency (tagging) | |
| 251 | + | ||
| 252 | + | - **Observation:** The 200-sample list is in import order. A user wanting to walk it alphabetically, or by accepted-count, or by suggestion-count, has no controls. | |
| 253 | + | - **Recommendation:** A small sort combobox at the top of the side panel: *"Sort: Import order / Name / Suggestions / Accepted"*. Stretch goal. | |
| 254 | + | ||
| 255 | + | ### p-4. Review Suggestions has no keyboard navigation — Efficiency (tagging) | |
| 256 | + | ||
| 257 | + | - **Observation:** Walking 50 samples requires 50 clicks. ↑/↓ would walk current_idx; Enter could accept-all-suggestions for the current item; Esc could cancel. | |
| 258 | + | - **Recommendation:** Wire `↑`/`↓` to `current_idx -= 1` / `+= 1` (saturating). Optional: `a` toggles accept-all for the current item. | |
| 259 | + | ||
| 260 | + | ### p-5. Export device profile shows only manufacturer — Anticipation (export) | |
| 261 | + | ||
| 262 | + | - **Location:** `export_screens.rs:235–243`. | |
| 263 | + | - **Observation:** *"by {manufacturer}"* — but profile objects often carry category (sampler, groovebox, drum machine) and notes (e.g. *"Mounts as USB drive — drag-and-drop"*) that would help the user pick. | |
| 264 | + | - **Recommendation:** When `profile.category` / `profile.notes` are present, render a muted second line with that detail. Pair with M-6's forced-values display. | |
| 265 | + | ||
| 266 | + | ### p-6. AIFF warning uses `accent_red` but is technically a warning — Hierarchy (export) | |
| 267 | + | ||
| 268 | + | - **Observation:** Same shape as M-3 and M-4. The current style is the same colour the app uses for genuine errors. | |
| 269 | + | - **Recommendation:** Switch the warning visuals across M-3 / M-4 / this finding to a single warning style (yellow + info_banner). Errors stay red; warnings stay yellow. | |
| 270 | + | ||
| 271 | + | --- | |
| 272 | + | ||
| 273 | + | ## Patterns across these findings | |
| 274 | + | ||
| 275 | + | Three patterns dominate, in descending impact: | |
| 276 | + | ||
| 277 | + | 1. **The wizard pretends to be one-way but the user's mental model is two-way.** C-1 (no Back), M-5 (can't re-pick source), C-3 (cancel doesn't acknowledge partial commit), M-12 (apply button doesn't preview the commit) — every step transition is implicitly modelled as final, but the user's actual workflow is iterative refinement. They want to see what they're committing, walk back when they see a problem, and recover when a partial commit happens by accident. The breadcrumb at the top of every screen visually promises navigability that the buttons don't deliver. A single follow-up that (a) wires Back to every wizard screen with state preservation, (b) lands cancels in an acknowledgement state instead of None, (c) shows commit counts on every primary button, and (d) allows source re-selection on Configure Import would resolve the cluster. | |
| 278 | + | ||
| 279 | + | 2. **Warnings and errors share visual chrome, dulling the signal.** M-3 (disk-space heuristic, alarmist red), M-4 (AIFF 20-min threshold, alarmist red), M-1 (errors hidden behind a click), M-13 (analysis-errors and import-errors visually identical), p-6 (AIFF is a warning shown as error) — the surfaces use `accent_red` for everything from *"may be a problem"* to *"this definitely failed and the file is gone"*. The Phase 3/4 colour vocabulary distinguishes `accent_yellow` (warning, anticipation) from `accent_red` (error, completed failure); the import/export surfaces predate that distinction. A single sweep that recolours every heuristic warning yellow and reserves red for confirmed failure would close the cluster. | |
| 280 | + | ||
| 281 | + | 3. **Numeric state is shown without the context that makes it useful.** M-6 (device profile lock hides forced values), M-7 (no filename preview), M-10 (sample list shows total but not accepted), M-11 (no ETA), M-14 (confidence is a number without visual encoding), p-2 (no folder/sample count in header) — every screen shows the user numbers and expects them to do the cognitive work of turning numbers into decisions. The fix shape is uniform: pair the number with a derived field (a preview, a colour, a rate, a remaining-time estimate) that turns it into a decision input. | |
| 282 | + | ||
| 283 | + | When the C-tier items land, the next natural audit is **Phase 6 — Library browser & detail panel** (the steady-state surfaces between wizard runs). Several findings here (C-3 cancel acknowledgement, M-10 review-status visibility, M-13 error categorisation) suggest that the library browser is where the user lives between import/export sessions and where commit-acknowledgement state should ultimately land. | |
| 284 | + | ||
| 285 | + | --- | |
| 286 | + | ||
| 287 | + | ## Implementation note — Critical batch (2026-05-20) | |
| 288 | + | ||
| 289 | + | All three Critical items shipped. Build clean across `audiofiles-core`, `audiofiles-sync`, `audiofiles-browser`, `audiofiles-app`; 201 + 439 + 44 tests pass (two new tests added for the cancel-acknowledgement transitions); design-system gates all return zero output. | |
| 290 | + | ||
| 291 | + | **C-1 (narrowed):** The full audit recommendation called for Back on every wizard screen past Configure. Two of the four proposed Back edges (TagFolders → ConfigureImport, ReviewSuggestions → ConfigureAnalysis) are semantically tangled — the prior step has committed irreversible work (files in the content store; tag-suggestion items consumed). Shipped scope is the one safe edge plus the one-way warning copy: | |
| 292 | + | ||
| 293 | + | - *Back from ConfigureAnalysis → TagFolders.* New `BrowserState::last_folder_tags: Option<(Vec<FolderTagEntry>, Vec<(String, String)>)>`. `apply_folder_tags` and `skip_folder_tags` stash the entries (clone) + `sample_hashes` before consuming the variant. New `back_to_tag_folders()` rehydrates `ImportMode::TagFolders` from the stash. ConfigureAnalysis gained a Back button (disabled when nothing's stashed). Tag re-application is safe — backend uses `INSERT OR IGNORE` semantics. | |
| 294 | + | - *One-way edge warning on Configure Import.* Muted text above the Import button: *"Once started, you can cancel mid-import but copies already made will stay in the library."* Makes the Configure → Importing transition's commit semantics explicit instead of leaving the user to infer them. | |
| 295 | + | - Deferred (not shipped): Back from ReviewSuggestions, Back from TagFolders. Both require destroying prior work to be meaningful and warrant their own design pass. Tracked in the audit doc. | |
| 296 | + | - `ImportedFolder` and `FolderTagEntry` gained `#[derive(Clone)]` so the stash can deep-copy without specialised helpers. | |
| 297 | + | ||
| 298 | + | **C-2 (RemoveFailedSamples confirm):** New `ConfirmAction::RemoveFailedSamples { single_index: Option<usize>, count: usize, name: Option<String> }`. The per-row "Remove" in `summary.rs` and the bulk "Remove All Failed" both now route through `pending_confirm` instead of calling `remove_failed_sample` / `remove_all_failed_samples` directly. Confirm dialog has two copy paths: | |
| 299 | + | ||
| 300 | + | - Single (per-row): *"Remove \"<name\>\" from the library?"* + *"The file will be permanently deleted from the content store. This cannot be undone."*. | |
| 301 | + | - Bulk: *"Remove N failed file(s)?"* + *"N file(s) will be permanently deleted from the content store. This cannot be undone."*. | |
| 302 | + | ||
| 303 | + | Both render as `danger_button`. `execute_confirmed_action` matches the variant and calls the corresponding remove method — the existing remove paths weren't touched. | |
| 304 | + | ||
| 305 | + | **C-3 (cancel acknowledgement state):** New `CancelKind` enum (`Import`, `Analysis`, `Export`) and `ImportMode::OperationCancelled { kind, completed, total, destination: Option<PathBuf> }` variant. `cancel_import`, `cancel_analysis`, `cancel_export` now read the current variant's progress fields *before* tearing down state, then land in `OperationCancelled` when progress is meaningful (post-walk; `total > 0`). Pre-progress cancels (walking phase; never-started exports) still fall through to `None` so the user isn't shown a "Stopped at 0 of 0" screen. | |
| 306 | + | ||
| 307 | + | - `run_export` stashes `config.destination` to `BrowserState::last_export_destination` so the export branch can name the folder where partial files may sit (the `Exporting` variant doesn't carry destination — it's consumed by the worker). | |
| 308 | + | - `draw_operation_cancelled` in `progress.rs`: heading varies by kind, body line names the noun (*files* / *samples*) and the practical follow-up (re-run skips duplicates / unanalysed samples remain / partial file may exist). Done button (`primary_button`) returns to `None`. | |
| 309 | + | - Editor dispatch gained an `ImportMode::OperationCancelled` arm. Escape on the acknowledgement screen maps to Done (matches the dismiss-on-Escape pattern for other safe screens). | |
| 310 | + | - Tests: `cancel_import_resets_state` / `cancel_analysis_resets_state` / `cancel_export_resets_state` renamed to `..._lands_in_acknowledgement` and assert the new variant + progress fields. Added two new tests (`cancel_import_during_walking_returns_to_none`, `cancel_export_with_zero_progress_returns_to_none`) to lock the fall-through case. `retry_import_without_source_stays_cancelled` updated to assert `OperationCancelled` (the retry path can't reopen Configure without a source, so the user is left on the acknowledgement screen). | |
| 311 | + | ||
| 312 | + | **Files touched:** | |
| 313 | + | - `audiofiles-browser/src/state/{ui,mod,bulk_ops,import_workflow,tests}.rs`. | |
| 314 | + | - `audiofiles-browser/src/ui/{overlays,import_screens/{configure,progress,mod,summary}}.rs`. | |
| 315 | + | - `audiofiles-browser/src/editor.rs`. | |
| 316 | + | - `audiofiles-browser/src/import.rs` (Clone derive on `ImportedFolder`). | |
| 317 | + | ||
| 318 | + | **Remaining (deferred):** 14 Major + 16 Minor + 6 Polish items above. None block ship. The Back-from-ReviewSuggestions and Back-from-TagFolders cases noted under C-1 belong in the Phase 5 Major batch alongside the warning-vs-error colour sweep (M-3 / M-4 / p-6) and the visibility-of-state cluster (M-6 / M-7 / M-10 / M-11). | |
| 319 | + | ||
| 320 | + | --- | |
| 321 | + | ||
| 322 | + | ## Implementation note — Major batch (2026-05-20) | |
| 323 | + | ||
| 324 | + | All 14 Major items shipped. Build clean across `audiofiles-core`, | |
| 325 | + | `audiofiles-rhai`, `audiofiles-sync`, `audiofiles-browser`, `audiofiles-app`; | |
| 326 | + | 201 + 439 + 44 tests pass; design-system gates all return zero output. | |
| 327 | + | ||
| 328 | + | **Progress screens (`import_screens/progress.rs`):** | |
| 329 | + | - **M-1** — Error log default-expanded. New `draw_error_log` helper dedupes | |
| 330 | + | the import + analysis screens; *"N errors"* header pairs with a `Hide`/`Show` | |
| 331 | + | toggle at the top-right rather than wrapping the whole label in a click | |
| 332 | + | sense. Errors no longer pile up invisibly during a long import. | |
| 333 | + | - **M-2** — Cancel disabled during `walking == true` with on-disabled tooltip | |
| 334 | + | *"Scanning — cancel available once the scan completes."* Resolves the | |
| 335 | + | ambiguous cancel-during-walk path without committing to a cooperative | |
| 336 | + | walker-cancel implementation. Once the walk completes Cancel is live. | |
| 337 | + | - **M-11** — Rate + ETA. New `state::OperationProgress { started_at, samples }` | |
| 338 | + | on `BrowserState::operation_progress`; `record()` dedupes per `completed`, | |
| 339 | + | prunes samples older than 10 s, and exposes `rate()` / `eta()`. `start_folder_import`, | |
| 340 | + | `run_analysis`, and `run_export` each reset the progress tracker. Import and | |
| 341 | + | analysis progress screens render *"X.X files/sec · ~Ym Zs remaining"* below | |
| 342 | + | the progress bar via a `draw_rate_and_eta` helper; suppresses itself until | |
| 343 | + | there's enough data to predict and once the projected wait is under 5 s. | |
| 344 | + | - Bonus: dropped the private `format_bytes` copy in favour of | |
| 345 | + | `widgets::format_bytes` (m-1 from the Phase 4 cleanup batch, opportunistically | |
| 346 | + | closed here). | |
| 347 | + | ||
| 348 | + | **Review errors (`import_screens/summary.rs`):** | |
| 349 | + | - **M-13** — Per-section explanatory copy added to both error categories | |
| 350 | + | (*"These files are in the library but couldn't be analysed…"* vs | |
| 351 | + | *"These files weren't imported. Re-running the import is the only way to | |
| 352 | + | retry — duplicates will be skipped."*). Row labels recoloured from | |
| 353 | + | `accent_red` to `text_primary`; the heading carries the red emphasis. The | |
| 354 | + | two categories now read as a triage surface rather than a wall of failure. | |
| 355 | + | ||
| 356 | + | **Configure import (`import_screens/configure.rs`):** | |
| 357 | + | - **M-5** — Source row gained a *"Change…"* button that opens | |
| 358 | + | `rfd::FileDialog::pick_folder()` and dispatches a new | |
| 359 | + | `BrowserState::change_import_source(new_source)`. The method preserves the | |
| 360 | + | user's strategy choice (Flat / NewVfs / MergeIntoVfs) and only refreshes | |
| 361 | + | `source`, `source_name`, and the dry-run `audio_file_count`. Wrong-folder | |
| 362 | + | recovery no longer requires Cancel-and-restart from the invocation site. | |
| 363 | + | ||
| 364 | + | **Tag folders (`import_screens/tagging.rs`):** | |
| 365 | + | - **M-9** — Apply-to-all row above the per-folder list. Persistent input on | |
| 366 | + | `BrowserState::tag_folders_apply_all_input`; commit button copies the value | |
| 367 | + | into every entry's `tag_input`, clears the apply-all input, and disables | |
| 368 | + | itself when the input is empty. Cleared on apply / skip transitions. | |
| 369 | + | Per-folder explicit Skip remains deferred (semantic-only change relative | |
| 370 | + | to an empty input). | |
| 371 | + | - **M-10** — Sample-list rows render *"{name} · {accepted}/{total}"* with the | |
| 372 | + | suffix coloured by review state (yellow when work remains; muted when done | |
| 373 | + | or empty). Items with `total == 0` show just the bare name in primary text. | |
| 374 | + | - **M-12** — Apply button labelled *"Apply N Tag(s)"* with the count baked in, | |
| 375 | + | and disabled when `accepted_count == 0` (with a disabled-hover hint pointing | |
| 376 | + | to Cancel as the discard path). | |
| 377 | + | - **M-14** — Suggestion confidence percentage coloured by threshold: | |
| 378 | + | `>= 80%` → `accent_green`, `>= 60%` → `accent_yellow`, `< 60%` → | |
| 379 | + | `text_muted`. Lets the user scan and triage rather than read every number. | |
| 380 | + | ||
| 381 | + | **Export configure (`export_screens.rs`):** | |
| 382 | + | - **M-3** — Disk-space heuristic replaced. New | |
| 383 | + | `bytes_per_sec_for_config(config)` derives the per-second rate from the | |
| 384 | + | user's actual rate × depth × channels (defaults bias to 48 kHz / 24-bit / | |
| 385 | + | stereo so "Original" stays conservative). Estimated total = Σ (duration × | |
| 386 | + | bytes/sec); warning fires only when `estimated × 1.1 > available`. Recoloured | |
| 387 | + | from `accent_red` to `accent_yellow` — anticipation, not error. | |
| 388 | + | - **M-4** — AIFF warning now computes the actual safe duration from | |
| 389 | + | `bytes_per_sec_for_config`: `(u32::MAX × 0.9) / bps`. Threshold scales with | |
| 390 | + | the config — stereo 16-bit 44.1 kHz gives ~6.5 hours of headroom; stereo | |
| 391 | + | 24-bit 96 kHz gives ~124 minutes; the warning copy reports the real | |
| 392 | + | per-config number. Recoloured to `accent_yellow`. | |
| 393 | + | - **M-6** — `DeviceProfileSummary` extended with `format_summary: | |
| 394 | + | Option<String>` (serde-default for backwards compatibility). New | |
| 395 | + | `audiofiles_core::export::profile::format_audio_constraints` joins formats / | |
| 396 | + | rates / depths / channels into *"WAV · 44.1k · 16-bit · Mono"*-style | |
| 397 | + | strings; populated by the rhai registry's `list()`. Profile picker renders | |
| 398 | + | the summary as a muted second line under *"by {manufacturer}"*. | |
| 399 | + | - **M-7** — Live filename preview below the naming-pattern input. Uses the | |
| 400 | + | existing `audiofiles_core::rename::RenamePattern` (no new substitution | |
| 401 | + | engine). Parse errors render in `accent_yellow` (catches typos like | |
| 402 | + | `{bmp}`). Successful resolves render *"Preview: <derived filename>"* from | |
| 403 | + | the first item's `RenameContext`. | |
| 404 | + | - **M-8** — Token row converted from static muted text into a row of | |
| 405 | + | `small_button`s. Click appends the token to the pattern (egui doesn't | |
| 406 | + | surface TextEdit cursor position, so append-at-end is the honest | |
| 407 | + | affordance). Pairs with M-7's preview so users see the result before | |
| 408 | + | committing. | |
| 409 | + | ||
| 410 | + | **Files touched:** | |
| 411 | + | - `audiofiles-core/src/export/profile.rs` — `format_summary` field + | |
| 412 | + | `format_audio_constraints` helper. | |
| 413 | + | - `audiofiles-rhai/src/registry.rs` — populate `format_summary` from | |
| 414 | + | `AudioConstraints`. | |
| 415 | + | - `audiofiles-browser/src/state/{ui,mod,import_workflow}.rs` — | |
| 416 | + | `OperationProgress`, `tag_folders_apply_all_input`, `change_import_source`, | |
| 417 | + | progress-tracker resets in start handlers. | |
| 418 | + | - `audiofiles-browser/src/ui/import_screens/{configure,progress,summary,tagging}.rs`. | |
| 419 | + | - `audiofiles-browser/src/ui/export_screens.rs`. | |
| 420 | + | ||
| 421 | + | No new public APIs on `Backend` (`DeviceProfileSummary`'s new field is | |
| 422 | + | backwards-compatible via serde default). Phase 5 Major closed. | |
| 423 | + | ||
| 424 | + | --- | |
| 425 | + | ||
| 426 | + | ## Implementation note — Minor + Polish batch (2026-05-20) | |
| 427 | + | ||
| 428 | + | 15 of 16 Minor items and 4 of 6 Polish items shipped via three parallel | |
| 429 | + | agents. Build clean; 201 + 439 + 44 tests pass; design-system gates all | |
| 430 | + | return zero output. | |
| 431 | + | ||
| 432 | + | **Already closed by prior batches (noted in audit, no edit needed):** | |
| 433 | + | - **m-1** — `format_bytes` private copy in `progress.rs` was replaced with | |
| 434 | + | `widgets::format_bytes` opportunistically during the Major batch. | |
| 435 | + | - **m-2** — Sample list double-space separator already replaced with `\u{00B7}` | |
| 436 | + | middle dot by M-10's reformat. | |
| 437 | + | - **m-14** — Cancel-during-export landing in C-3's acknowledgement state | |
| 438 | + | closes the no-confirmation gap by surfacing what was discarded. | |
| 439 | + | - **p-6** — AIFF warning recoloured from red to yellow as part of M-4. | |
| 440 | + | ||
| 441 | + | **Configure import (`configure.rs`):** | |
| 442 | + | - **m-3** — "Analysis skipped" status replaced with | |
| 443 | + | *"Imported. Run analysis from the sidebar when ready."* — acknowledges the | |
| 444 | + | prior import success rather than framing the skip as the headline. | |
| 445 | + | - **m-5** — Skip button relabelled "Skip analysis" to match the heading. | |
| 446 | + | - **m-11** — Import button gated on strategy validity. New `(can_import, | |
| 447 | + | disabled_reason)` tuple pattern-matches the current `ImportStrategy`: | |
| 448 | + | `Flat` always allowed; `NewVfs` requires non-empty trimmed name; | |
| 449 | + | `MergeIntoVfs` requires non-empty `available_vfs`. `on_disabled_hover_text` | |
| 450 | + | surfaces the reason. Eliminates the unnamed-vault footgun and the latent | |
| 451 | + | `available_vfs[idx]` panic. | |
| 452 | + | ||
| 453 | + | **Progress (`progress.rs`):** | |
| 454 | + | - **m-12 (deferred)** — Running file count during the walk requires a new | |
| 455 | + | `BackendEvent` since `ImportWalkComplete` is the only signal emitted during | |
| 456 | + | the walking phase. Out of scope for UI-only adaptation; inline comment | |
| 457 | + | documents the schema gap. | |
| 458 | + | - **m-13** — Per-item labels standardised to operation-specific verbs: | |
| 459 | + | *"Importing: …"*, *"Analysing: …"*, *"Removing: …"* (cleanup, unchanged), | |
| 460 | + | *"Exporting: …"* (in `export_screens.rs`). | |
| 461 | + | ||
| 462 | + | **Review errors (`summary.rs`):** | |
| 463 | + | - **m-16** — "Keep All" promoted from plain `ui.button` to | |
| 464 | + | `widgets::primary_button`. Non-destructive default now anchors as the | |
| 465 | + | primary action; "Remove All Failed" stays `danger_button`. | |
| 466 | + | ||
| 467 | + | **Tag folders + Review suggestions (`tagging.rs`):** | |
| 468 | + | - **m-6 (deferred-as-documented)** — `widgets::wizard_steps` has no | |
| 469 | + | skipped-state API. The breadcrumb is decorative per C-1 (no cross-step | |
| 470 | + | navigation post-Skip), so a "Tag folders" cell staying highlighted is a | |
| 471 | + | cosmetic-only artifact. Inline comment documents the decision; no widget | |
| 472 | + | API added. | |
| 473 | + | - **m-7** — Review Suggestions detail panel gained a muted *"(N of M)"* | |
| 474 | + | position indicator under the sample-name heading. `total` captured before | |
| 475 | + | the `get_mut(current_idx)` borrow to avoid conflict. | |
| 476 | + | - **m-10** — Apply Tags button disabled when every entry's `tag_input` is | |
| 477 | + | empty (`all_empty` computed in the initial match). On-disabled hover: | |
| 478 | + | *"Add at least one tag, or use Skip."*. The bulk "Apply to all" button | |
| 479 | + | was already gated by M-9. | |
| 480 | + | - **m-15** — Suggestion list sorted by `confidence` descending once per | |
| 481 | + | frame before iteration. High-confidence picks group at the top — pairs | |
| 482 | + | with M-14's threshold colour. | |
| 483 | + | - **p-2** — Tag folders heading gained a muted *"{N} folders · {M} samples"* | |
| 484 | + | summary line between the heading and the instructional copy. | |
| 485 | + | ||
| 486 | + | **Export (`export_screens.rs`):** | |
| 487 | + | - **m-4** — *"Starting export..."* gained a `ui.spinner()` mirroring the | |
| 488 | + | cleanup progress screen's pre-first-item state. | |
| 489 | + | - **m-8** — Per-error rows in Export Complete now match the | |
| 490 | + | `progress.rs::draw_error_log` colour pattern: name in `accent_red`, body | |
| 491 | + | in `text_secondary`. Errors read as failure rather than benign info. | |
| 492 | + | - **m-9** — "Done" promoted to `widgets::primary_button`. | |
| 493 | + | - **p-1** — *"Open destination folder"* button beside Done, gated on | |
| 494 | + | `state.last_export_destination.is_some()`. Uses `open` / `xdg-open` / | |
| 495 | + | `cmd /c start` via `#[cfg(target_os = ...)]` mirroring the OAuth-open | |
| 496 | + | pattern in `sync_panel.rs::draw_disconnected`. | |
| 497 | + | - **p-5 (deferred)** — `DeviceProfile` has no `category` or `notes` field. | |
| 498 | + | Surfacing additional profile detail would require schema work in | |
| 499 | + | `audiofiles-core::export::profile`. Inline comment documents the gap. | |
| 500 | + |
Lines truncated
| @@ -0,0 +1,594 @@ | |||
| 1 | + | # Phase 6 UX Audit — Library browser & detail panel | |
| 2 | + | ||
| 3 | + | **Surfaces:** `ui/file_list.rs`, `ui/detail.rs`, `ui/toolbar.rs`, `ui/sidebar.rs`, `ui/footer.rs`, `ui/file_list_menus.rs` (plus supporting widgets / theme / state). | |
| 4 | + | **Detected stack:** egui (immediate-mode Rust GUI), with the main view composed as a `CentralPanel` + `TopBottomPanel` (toolbar, footer) + `SidePanel` (sidebar, detail panel). | |
| 5 | + | **Frame of reference:** Phases 1–5 covered first-pass paths (configuration, wizards, modes). Phase 6 covers the *steady-state* surfaces — what the user spends 95% of their time in. The dominant axes here are scalability under large libraries (10K+ samples, deep tag trees), consistency across the many places a single operation can be triggered, and the quality of the affordances that telegraph what's possible without the user having to read. | |
| 6 | + | ||
| 7 | + | Findings are ranked Critical / Major / Minor / Polish using the rubric established in Phases 3–5. No code in this document — recommendations describe the change, not the diff. | |
| 8 | + | ||
| 9 | + | --- | |
| 10 | + | ||
| 11 | + | ## Critical | |
| 12 | + | ||
| 13 | + | ### C-1. Cloud-only samples have no play affordance and no in-row hint — Visibility of state (file_list) | |
| 14 | + | ||
| 15 | + | - **Location:** `file_list.rs::draw_file_list`, the Play-button column branch at `:331–346`. The condition `if node.node.node_type == NodeType::Sample && !node.cloud_only` gates the entire play button. Cloud-only samples render an empty cell. A separate cloud icon (`\u{2601}`) is prepended to the name in `draw_name_column`, but the play column stays blank and the row carries no other affordance. | |
| 16 | + | - **Observation:** A user with a partially-synced library scrolling the list sees random rows where the Play button is missing and the row is muted. The cloud icon next to the name is decorative — no badge says *"Click to download"*, no hover hint explains the gap, no inline button offers to fetch. The user has to know to right-click the row and pick *"Download"* from `file_list_menus.rs::draw_context_menu` — a path that's nowhere else surfaced. The hover text on the name (`"Cloud only — not yet downloaded. Sync to fetch it locally."`) is present but only appears when the cursor is over the name *itself*, not the empty Play column. | |
| 17 | + | - **Why it matters:** Cloud-only rows are not edge cases — they're the modal state for any user with multi-device sync. The library browser is the highest-traffic surface in the app, and a state that makes a row look like a half-rendered bug is a confidence hit every time it scrolls into view. Recovery exists (right-click → Download), but invisible recovery is no recovery for most users. | |
| 18 | + | - **Recommendation:** Surface a `[Download]` button in the Play column for cloud-only samples (parallel slot, same width as the play button). Hover text: *"Fetch this sample from the cloud."* Optionally swap the cloud glyph for a download glyph; click triggers the same `sync.download_sample(hash)` path the context menu already uses. While at it, drop the cloud-only fallback hover from the name column (it's now redundant with the explicit button). | |
| 19 | + | ||
| 20 | + | ### C-2. "Import Folder..." has divergent semantics across surfaces — Mappings (file_list + toolbar) | |
| 21 | + | ||
| 22 | + | - **Location:** Three places spell the same label, three different paths: | |
| 23 | + | - `toolbar.rs::draw_breadcrumb`, the Import popup at `:270–272` — *"Import folder..."* calls `quick_import_folder` (no config, default strategy). | |
| 24 | + | - `toolbar.rs::draw_breadcrumb`, same popup at `:293–300` — *"Import folder with options..."* calls `show_import_options` (full wizard). | |
| 25 | + | - `file_list_menus.rs::draw_background_context_menu` at `:355–360` — *"Import Folder..."* calls `show_import_options` (wizard). | |
| 26 | + | - **Observation:** Three call sites, two semantics. The toolbar's *"Import folder..."* (without "with options") quietly skips the wizard; the background context menu's identically-cased *"Import Folder..."* opens the wizard. A user who learns one path's behaviour and reaches for the other discovers the divergence by surprise — and the surprise is on the higher-stakes side (the toolbar quick-import commits without strategy review). The labels at minimum should match the actions they trigger. | |
| 27 | + | - **Why it matters:** Phase 5's Critical batch added a *"This will start importing files. You can cancel mid-import but partial copies will stay in the library."* warning above the Import button in ConfigureImport. The toolbar's bypass-the-wizard option silently skips that warning. The user who's been trained to read it as a commit point doesn't get one when they reach for the toolbar. | |
| 28 | + | - **Recommendation:** Pick one of two: (a) rename the toolbar's *"Import folder..."* to *"Quick import folder..."* with a hover line *"Import without strategy / tagging review"*, leaving *"Import folder with options..."* unchanged. (b) Drop the quick-import shortcut entirely and route all "Import folder" entry points through `show_import_options`. (a) preserves the fast path; (b) eliminates the divergence. Either way, the background context menu's *"Import Folder..."* should match whichever variant the toolbar offers — and a Quick-import label needs the *"Quick"* prefix everywhere it appears. | |
| 29 | + | ||
| 30 | + | ### C-3. "Clear Filters" empty-state CTA also clears the search query — Mappings (file_list) | |
| 31 | + | ||
| 32 | + | - **Location:** `file_list.rs::draw_file_list` at `:142–145`. The empty-state when filters/search return zero hits offers a *"Clear Filters"* button that calls `search_filter.clear()` *and* `search_query.clear()`. | |
| 33 | + | - **Observation:** Same shape as Phase 4's M-4 against the filter panel's *"Clear All Filters"* button — fixed there by renaming to *"Clear search and filters"*. The empty-state CTA in `file_list.rs` missed the same rename: the label says one thing and the action does more. A user who typed *"kick"* into the search bar, added a BPM filter, got zero results, and clicked Clear Filters expecting to keep *"kick"* loses it. | |
| 34 | + | - **Why it matters:** This is a clean repeat of a precedent the audit has already promoted to a rule. The fix and label exist; this surface just hasn't received the sweep. | |
| 35 | + | - **Recommendation:** Rename the CTA to *"Clear search and filters"* (matches the toolbar's already-fixed phrasing). Optionally split into two buttons — *"Clear filters"* (preserves search) and *"Clear all"* (both) — so the user can choose. Phase 4's M-4 picked the single-button rename as the lower-risk fix; consistent treatment here. | |
| 36 | + | ||
| 37 | + | --- | |
| 38 | + | ||
| 39 | + | ## Major | |
| 40 | + | ||
| 41 | + | ### M-1. Tag suggestion dismiss has no inline Undo — Forgiveness (detail) | |
| 42 | + | ||
| 43 | + | - **Location:** `detail.rs::draw_detail`, the suggestion strip at `:282–294`. Each suggestion has a `+sug` accept button and an `x` dismiss button. The dismiss permanently suppresses that suggestion for that classification (`dismissed_suggestions: HashMap<String, Vec<String>>`). | |
| 44 | + | - **Observation:** The dismiss is one click and silently permanent for the entire classification cohort. The hover text (*"Never suggest 'sug' on {class_str} samples again"*) is honest about scope but the affordance is no different from the accept button next to it — both are tiny `small_button`s. Recovery is in Settings → Display → *"Reset suggestions"*, which the user finds only if they know to go looking. The asymmetry is acute: accepting a suggestion is undoable (Cmd+Z removes the tag); dismissing one is undoable only via a hidden Settings affordance. | |
| 45 | + | - **Why it matters:** Per-classification permanence is a useful behaviour (the user who never tags kicks as *"percussion"* shouldn't see it on every kick). The problem is the irreversibility relative to the visual weight of the click. Phase 3 hardened tag-chip removal with hover-only `x` to prevent accidental deletions; the suggestion strip uses the same `x` glyph without the hover-only gate. | |
| 46 | + | - **Recommendation:** After a dismiss, surface a transient *"Suggestion 'sug' muted for {class}. \[Undo\]"* status post for ~5 seconds (or until the next click). Clicking Undo re-adds the entry to the suggestion pool. Alternatively, gate dismiss behind a confirm-on-first-use modal explaining the cohort scope — once the user has acknowledged it once, dismiss can be one-click. The status-post path is cheaper and matches existing `state.status` patterns. | |
| 47 | + | ||
| 48 | + | ### M-2. Sort headers go silently inert during similarity search — Visibility of state (file_list) | |
| 49 | + | ||
| 50 | + | - **Location:** `file_list.rs::draw_sort_header` at `:613–643`. When `sort_enabled == false` (similarity / duplicate search active), headers render via `add_enabled(false, Label)` with no tooltip. | |
| 51 | + | - **Observation:** The user clicks the *Name* header expecting an A-Z sort. Nothing happens. The header is greyed slightly via `text_muted`, but the cursor doesn't change and there's no `on_disabled_hover_text` explaining why. The surface communicates a non-failure as silence. | |
| 52 | + | - **Why it matters:** Similarity / duplicate search produces backend-ranked results where re-sorting would scramble the ranking. The disable is correct; the silence isn't. Phase 4 closed several similar visibility-of-disabled-state issues with `on_disabled_hover_text`. | |
| 53 | + | - **Recommendation:** Add `.on_disabled_hover_text("Sort disabled — results ranked by similarity. Clear the similarity search to re-enable column sort.")` on the disabled Label. Even better: render the disabled header as a `selectable_row_secondary` with a hover-text override so the affordance reads as "interactive but currently locked" instead of "label". | |
| 54 | + | ||
| 55 | + | ### M-3. Toolbar panel toggles + actions overflow narrow windows — Anticipation (toolbar) | |
| 56 | + | ||
| 57 | + | - **Location:** `toolbar.rs::draw_toolbar`, the second `ui.horizontal` row at `:30–157`. Search input + Clear button + scope pills + result count + Save + Undo + Sidebar + Detail + Edit + Instr + Loop + Filters — 10 to 12 widgets stacked horizontally. | |
| 58 | + | - **Observation:** The search input's `desired_width` is `ui.available_width() - 160.0`. On a window narrower than ~720px, the calculation pinches the search field below usable width or pushes the right-edge toggles off-screen. Newer users encountering audiofiles on a half-screen window see a layout that looks half-broken. | |
| 59 | + | - **Why it matters:** Half-screen / sidebar-docked usage is common during DAW work — the whole point of having the app open at all. A toolbar that breaks below a screen width that's a common DAW companion layout is a discoverability hit on first impression. | |
| 60 | + | - **Recommendation:** Group the panel toggles (Sidebar / Detail / Edit / Instr / Loop / Filters) into a single *"View ▼"* dropdown button when the window is narrower than a threshold (e.g. 900px). Compute available width once via `ui.ctx().screen_rect().width()` and branch the layout. The full row of toggles can stay on wider windows; the dropdown form keeps every action reachable on narrow ones. Result-count and Save can also collapse into a single *"Save ▼"* dropdown. | |
| 61 | + | ||
| 62 | + | ### M-4. Sync button label width varies dramatically — Hierarchy (toolbar) | |
| 63 | + | ||
| 64 | + | - **Location:** `toolbar.rs::sync_label_tooltip` at `:329–351`. Returns one of *"Sync"*, *"Sync: syncing"*, *"Sync (3)"*, *"Sync: offline"*. | |
| 65 | + | - **Observation:** The width of the Sync button changes by a factor of ~3× depending on state. Adjacent buttons (Settings, Help) shift position as the label flips. The user's muscle memory for hitting *Settings* breaks when sync state changes. | |
| 66 | + | - **Why it matters:** This is a small but constant cost in a high-frequency surface. The pattern in Phase 4 (the M-15 interval pill) was to widen-once and never reflow. The Sync button warrants the same treatment. | |
| 67 | + | - **Recommendation:** Render the Sync button at a fixed width (e.g. `add_sized([88.0, 0.0], …)`). State communicated by a colour pip / dot beside the *"Sync"* label rather than by appending text. Pending-changes count moves into a trailing pip or stays as a `(3)` badge that fits within the fixed width. Tooltip retains the full state description. | |
| 68 | + | ||
| 69 | + | ### M-5. Tag tree default-collapsed scales poorly — Anticipation (sidebar) | |
| 70 | + | ||
| 71 | + | - **Location:** `sidebar.rs::draw_tag_node` at `:111–116`. Every parent node loads with `default_open(false)`. | |
| 72 | + | - **Observation:** For users with deep dotted tag taxonomies (`drums.kick.808`, `drums.kick.acoustic`, `synth.bass.808`, …), every section is a click to expand. A new user with even a small tag library has to learn the structure click by click. There's no "expand all" / "collapse all" affordance. | |
| 73 | + | - **Why it matters:** The tag tree is the discoverability surface for the library's taxonomy. A collapsed-by-default state hides the work the user has already done to organize their samples. Phase 5's audit surface (Tag Folders) found a related issue (M-9: applying tags to all folders); the steady-state surface inherits a related friction. | |
| 74 | + | - **Recommendation:** Default-open the top level of the tag tree (the immediate children of the root). Persist user-toggled state via egui memory (`CollapsingState` already does this; the only change is the initial `default_open` value). Optionally add a small *"Expand all"* / *"Collapse all"* link pair above the tree when there are >5 top-level tags. | |
| 75 | + | ||
| 76 | + | ### M-6. "Reveal in Finder/Explorer" missing from sample context menu — Anticipation (file_list_menus) | |
| 77 | + | ||
| 78 | + | - **Location:** `file_list_menus.rs::draw_context_menu`, NodeType::Sample branch at `:24–145`. Has Preview, Copy Path, Find Similar, Find Duplicates, Add/Remove from Collection, Edit, Play as Instrument, Export, Delete. No Reveal. | |
| 79 | + | - **Observation:** Copy Path puts the path on the clipboard; the user then has to paste it into Finder's *Go → Go to Folder*. Every DAW and file-manager has a one-click *"Reveal in Finder"* / *"Show in Explorer"* / *"Open Containing Folder"* affordance for exactly this need. Unsafe-mode samples especially — the user often wants to see where the original lives, not just its hash-bucket path. | |
| 80 | + | - **Why it matters:** Once a user starts working with unsafe-mode libraries (which the app actively supports), reaching the source file is the most common workflow. Copy Path → switch app → paste → enter is the existing path; one menu item collapses it to one click. | |
| 81 | + | - **Recommendation:** Add *"Reveal in Finder"* / *"Show in Explorer"* / *"Open Containing Folder"* (`#[cfg]`-gated label) just below *Copy Path*. Implementation: platform shell command on the parent directory of `state.selected_sample_path()` — `open -R <path>` (macOS), `explorer /select,"<path>"` (Windows), `xdg-open <parent>` (Linux). The macOS `open -R` and Windows `explorer /select` even highlight the file; Linux can't natively do that but opening the parent is close enough. | |
| 82 | + | ||
| 83 | + | ### M-7. Re-analyze missing from single-row context menu — Consistency (file_list_menus) | |
| 84 | + | ||
| 85 | + | - **Location:** `file_list_menus.rs::draw_multi_context_menu` at `:244–270` has *"Re-analyze..."* with `ReanalyzeOverwrite` confirm. `draw_context_menu` (single-row) at `:24–145` has no such option. | |
| 86 | + | - **Observation:** A user who selects a single sample and right-clicks gets no Re-analyze. To re-run analysis on one sample, the user has to Cmd+click to multi-select another row, then right-click → Re-analyze, then accept the overwrite confirm on both. Or open the sample editor and trigger re-analysis from there (if that path exists). | |
| 87 | + | - **Why it matters:** Re-analysis is a per-sample operation conceptually. Hiding the single-sample path forces the user to bulk-select for what should be a one-click operation. Phase 3's selection model hardening means bulk and single paths now share most of the machinery — the asymmetry in the menu is a leftover. | |
| 88 | + | - **Recommendation:** Add *"Re-analyze..."* to `draw_context_menu` (Sample branch) just above Delete. Use the same `ReanalyzeOverwrite` confirm — `sample_hashes` is a single-element vec, `overwrite_count` is 0 or 1 based on existing analysis fields. | |
| 89 | + | ||
| 90 | + | ### M-8. Footer overcrowds on narrow windows — Hierarchy (footer) | |
| 91 | + | ||
| 92 | + | - **Location:** `footer.rs::draw_footer`, the main `ui.horizontal` at `:14–196`. Stacks transport / now-playing / selection-count / detail-hidden warning / analysis-coverage / untagged-count / status / preview-device into one row. | |
| 93 | + | - **Observation:** At narrow widths each section gets squeezed and ultimately some clip off the right edge. The first-launch hint adds *"Right-click for options · F1 for shortcuts"* + a *Dismiss* button to the mix. With a long status message and the preview-device label right-aligned, the row easily overflows beyond a 1000px window. | |
| 94 | + | - **Why it matters:** The footer is a status surface — it has to remain readable when other concerns are demanding screen real estate (sidebar open, detail panel open, sync panel modal). | |
| 95 | + | - **Recommendation:** Two-row layout when the screen is narrow: top row carries transport + status; bottom row carries analysis-coverage + preview-device + selection-count. Wrap conditionally on `ui.ctx().screen_rect().width()`. Optionally move the preview-device line into the Settings panel and surface it in the footer only when it's *not* the default (e.g. user picked a non-default output) so it doesn't compete for space in the common case. | |
| 96 | + | ||
| 97 | + | ### M-9. Similarity banner duplicates breadcrumb info — Consistency (toolbar) | |
| 98 | + | ||
| 99 | + | - **Location:** `toolbar.rs::draw_toolbar` at `:20–26` (the *"Showing similar samples"* banner) and `draw_breadcrumb` at `:203–209` (the breadcrumb *"Similar to: <name>"* path segment). | |
| 100 | + | - **Observation:** Two different rows tell the user the same fact. The banner says *"Showing similar samples"* + Clear; the breadcrumb says *"Similar to: <name>"*. They render adjacent to each other and convey overlapping information. The Clear button is the actionable element; the banner exists mostly to host it. | |
| 101 | + | - **Why it matters:** Adjacent redundancy reads as nervous design — the surface doesn't trust its own communication. The breadcrumb already names the source sample, which is the more informative version. | |
| 102 | + | - **Recommendation:** Drop the banner. Move the Clear button into the breadcrumb segment itself (`Similar to: <name> [Clear]`) or render it as a small `[x]` at the end of the breadcrumb segment. Saves a row and consolidates the mode-context to one place. Same fix shape applies to the active-collection path (`:210–223`), which already does the right thing by putting the *"/"* root-click before the collection name. | |
| 103 | + | ||
| 104 | + | ### M-10. Discovery buttons don't gate on fingerprint availability — Forgiveness (detail) | |
| 105 | + | ||
| 106 | + | - **Location:** `detail.rs::draw_detail`, the Discovery section at `:323–338`. Find Similar / Find Duplicates render unconditionally for any sample with a hash. | |
| 107 | + | - **Observation:** Find Similar requires a fingerprint or spectral features; samples that haven't been analysed with `fingerprint: true` produce empty result sets silently. The button gives no feedback at click time — the user just gets a blank list and assumes their library has no similar samples (which may not be true; they just haven't run fingerprinting). | |
| 108 | + | - **Why it matters:** A user new to the discovery features clicks Find Similar on their newly-imported sample and sees nothing. Without the surface explaining *why*, the natural conclusion is *"this feature doesn't work"*. | |
| 109 | + | - **Recommendation:** Disable both buttons when the current sample's analysis doesn't include the required field (fingerprint for Find Duplicates; spectral_centroid + spectral_bandwidth or similar for Find Similar). On-disabled hover: *"Run analysis with fingerprinting enabled to find duplicates."* / *"Run analysis with spectral features to find similar samples."* A linked *"Run analysis"* link in the same tooltip would close the loop fully. | |
| 110 | + | ||
| 111 | + | ### M-11. Multi-select tag chips can't bulk-apply or bulk-remove partial-coverage tags — Affordances (detail) | |
| 112 | + | ||
| 113 | + | - **Location:** `detail.rs::draw_multi_summary`, the tag-union loop at `:451–467`. Each tag renders as a count badge (`"kick (3)"` when the tag is on 3 of 5 selected samples) but the badges are inert labels. | |
| 114 | + | - **Observation:** The user selects 5 samples, sees *"kick (3)"*, wants to either (a) apply *kick* to the other 2 so the whole selection has it, or (b) remove *kick* from the 3 that have it. Currently neither is one-click — the user has to open the Bulk Tag modal via *"Edit as bulk"*, type *kick*, and choose add/remove. | |
| 115 | + | - **Why it matters:** This is the prototypical bulk-tag-edit workflow, and the partial-coverage badges visually invite it. The current chrome shows the data without the affordance. | |
| 116 | + | - **Recommendation:** Make each partial-coverage badge a small button group. *"kick (3/5)"* hover shows *"In 3 of 5 selected — \[Apply to all\] \[Remove from 3\]"*. Two clicks for either path. Reuses the existing `state.bulk_modal.Tag` machinery; the click just preselects the tag string and the add/remove direction. | |
| 117 | + | ||
| 118 | + | ### M-12. Tag rename behaviour for parent nodes is unclear — Forgiveness (sidebar) | |
| 119 | + | ||
| 120 | + | - **Location:** `sidebar.rs::tag_context_menu` at `:55–68` and the inline rename flow at `:461–497`. Calls `state.rename_tag_globally(&old, &new)`. | |
| 121 | + | - **Observation:** The tag tree has parents (`drums`) and leaves (`drums.kick`). What happens when the user renames `drums` to `percussion`? Does `drums.kick` become `percussion.kick`, or does the parent rename only touch samples directly tagged with `drums`? The behaviour isn't surfaced. Looking at the function name (`rename_tag_globally`) the answer depends on the backend implementation — likely exact-match-only, which would orphan the dotted hierarchy. | |
| 122 | + | - **Why it matters:** Tag rename is one of the more dangerous bulk operations available from the sidebar — it touches every sample carrying the tag (or its descendants, depending on semantics). The user needs to know which set is affected before committing. | |
| 123 | + | - **Recommendation:** Surface the count of affected samples in the rename modal: *"Rename 'drums' to '{new}' — affects 24 samples"*. If the implementation propagates to descendants, say so: *"…and 6 descendant tags will be renamed (drums.kick → {new}.kick, …)"*. If it doesn't propagate, warn explicitly: *"Descendant tags (drums.kick, …) will not be renamed."* A preview of the first 3 affected tags helps the user catch a typo before they commit. Investigation step needed: read `rename_tag_globally` to confirm semantics. | |
| 124 | + | ||
| 125 | + | ### M-13. "Detail panel hidden" warning lives in the footer — Mappings (footer + toolbar) | |
| 126 | + | ||
| 127 | + | - **Location:** `footer.rs::draw_footer` at `:115–126`. When `state.detail_visible && screen_width < 700.0`, the footer shows *"Detail panel hidden (window too narrow)"*. | |
| 128 | + | - **Observation:** The user toggles the Detail panel from the toolbar (via the *Detail* toolbar_toggle at `toolbar.rs:124–126`). The toggle visually flips to active. The panel doesn't appear. The explanation is in the footer, a row the user wasn't looking at. The user's natural reaction is to click Detail again (toggle off), conclude it's broken, and stop trying. | |
| 129 | + | - **Why it matters:** Feedback should live where the action originated. The toggle button is the right place; the footer is wrong. Tognafsky's *visibility of system status* — the system is communicating, but in the wrong locus. | |
| 130 | + | - **Recommendation:** Move the warning to an `.on_hover_text` on the Detail toggle when the window is too narrow. Render the toggle in a muted state when active-but-hidden (the toggle already supports a colour by-state). Drop the footer line. Adjacent fix: also disable Edit / Instr toggles when the floating window can't fit, with the same explanatory hover. | |
| 131 | + | ||
| 132 | + | --- | |
| 133 | + | ||
| 134 | + | ## Minor | |
| 135 | + | ||
| 136 | + | ### m-1. Drag-out cooldown's *"Drag ready in a moment..."* is opaque — Anticipation (file_list) | |
| 137 | + | ||
| 138 | + | - **Location:** `file_list.rs::draw_name_column` at `:431–445`. | |
| 139 | + | - **Observation:** After a successful OS drag, the cooldown blocks further drags for 2 seconds. Hover text changes to *"Drag ready in a moment..."* — true, but the user doesn't know whether this is a permanent system limitation or a temporary state. | |
| 140 | + | - **Recommendation:** *"Just dragged — ready again in a moment."* or with the elapsed-seconds: *"Drag cooldown — 1s remaining."*. Either communicates that the wait is bounded and recently-triggered. | |
| 141 | + | ||
| 142 | + | ### m-2. Play button column has no header label — Consistency (file_list) | |
| 143 | + | ||
| 144 | + | - **Location:** `file_list.rs::draw_file_list` at `:273`. The Play column header is `header.col(|_ui| {})`. | |
| 145 | + | - **Observation:** Every other column has a header. The empty header column is intentional (the button itself is self-labelling) but reads as a layout artifact. | |
| 146 | + | - **Recommendation:** Label the column *"Play"* in `text_muted`. Doesn't sort, just provides parity with neighbouring columns. | |
| 147 | + | ||
| 148 | + | ### m-3. Tag rename input lacks a placeholder showing the original — Anticipation (sidebar) | |
| 149 | + | ||
| 150 | + | - **Location:** `sidebar.rs` at `:461–497`. The rename input shows the current tag value pre-filled (good) but the rename context (`"Renaming tag: {old_tag}"`) is rendered as a separate muted label above it. | |
| 151 | + | - **Recommendation:** Use the original tag as the input's `hint_text` (placeholder shown when empty); promote the *"Renaming tag: {old_tag}"* line to be inline within the input border (e.g. as a prefix-styled label). Reduces vertical clutter. Already half-implemented — just tighten the layout. | |
| 152 | + | ||
| 153 | + | ### m-4. Rename modals lack consistent layout — Consistency (sidebar) | |
| 154 | + | ||
| 155 | + | - **Location:** Three rename flows in the sidebar — VFS (`:264–266` + modal elsewhere), Collection (`:354–382`), Tag (`:461–497`). Each is an inline edit row with subtly different button orders (Cancel position varies). | |
| 156 | + | - **Recommendation:** Standardise: every inline rename should be `[input] [Rename] [Cancel]` (action first, dismiss second, matching the platform convention used elsewhere in the app). Phase 3 settled on `[Cancel] [primary]` for modal dialogs; inline rename rows can mirror that or invert depending on convention — pick one and apply uniformly. | |
| 157 | + | ||
| 158 | + | ### m-5. Multi-context menu uses double-space before keyboard hints — Consistency (file_list_menus) | |
| 159 | + | ||
| 160 | + | - **Location:** `file_list_menus.rs::draw_multi_context_menu` at `:182, 189, 193, 197`. Labels like `"Tag... (Cmd+T)"` use two spaces before the parens. | |
| 161 | + | - **Recommendation:** Single space or use the established `\u{00B7}` middle dot. Match the formatting used elsewhere in the file (the single-row menu at `:72, 79, 117` also uses double-space). Pick one separator and apply across both menus. | |
| 162 | + | ||
| 163 | + | ### m-6. Status message has no time-fade — Feedback (footer) | |
| 164 | + | ||
| 165 | + | - **Location:** `footer.rs::draw_footer` at `:163–165`. `state.status` is rendered indefinitely. | |
| 166 | + | - **Observation:** The user runs an import, sees *"Imported 47 samples"* in the footer, then five minutes later opens a different vault and the message is still there. | |
| 167 | + | - **Recommendation:** Track `status_set_at: Option<Instant>` on `BrowserState`; fade the status to text_muted after 5 seconds, hide after 30s. New posts reset the timer. Single addition to `post_status` (or wherever `state.status =` lives) and a check in the footer renderer. | |
| 168 | + | ||
| 169 | + | ### m-7. Collection "+" create button has no hover text — Anticipation (sidebar) | |
| 170 | + | ||
| 171 | + | - **Location:** `sidebar.rs` at `:409–411`. `ui.small_button("+")` for creating a new collection. | |
| 172 | + | - **Recommendation:** `.on_hover_text("Create a new collection")`. Mirrors every other small_button in the file. | |
| 173 | + | ||
| 174 | + | ### m-8. *"Detail panel hidden"* copy doesn't suggest a fix — Anticipation (footer) | |
| 175 | + | ||
| 176 | + | - **Location:** `footer.rs` at `:121–124`. Just says hidden, doesn't tell the user what to do. | |
| 177 | + | - **Recommendation:** *"Detail panel hidden — widen the window to show it."* (Note: this minor folds into M-13 — if M-13 moves the warning to the toggle button, the copy becomes a tooltip and gets updated naturally.) | |
| 178 | + | ||
| 179 | + | ### m-9. Sync button label *"Sync (3)"* doesn't say what the 3 means without hover — Anticipation (toolbar) | |
| 180 | + | ||
| 181 | + | - **Location:** `toolbar.rs::sync_label_tooltip` at `:337–340`. | |
| 182 | + | - **Observation:** The number is the pending-changes count. The hover explains it. The label alone is ambiguous (could read as version, queued items, etc.). | |
| 183 | + | - **Recommendation:** *"Sync · 3 pending"* fits with the fixed-width treatment in M-4. Self-explanatory without hover. | |
| 184 | + | ||
| 185 | + | ### m-10. *"Showing similar samples"* banner Clear button doesn't name the source — Visibility of state (toolbar) | |
| 186 | + | ||
| 187 | + | - **Location:** `toolbar.rs::draw_toolbar` at `:20–26`. | |
| 188 | + | - **Observation:** The banner says *"Showing similar samples"*; the Clear button doesn't tie back to which sample. The breadcrumb has the name; the banner is just a label. | |
| 189 | + | - **Recommendation:** Folds into M-9's recommendation — drop the banner, move Clear into the breadcrumb segment. | |
| 190 | + | ||
| 191 | + | ### m-11. Import popup item names are too similar — Consistency (toolbar) | |
| 192 | + | ||
| 193 | + | - **Location:** `toolbar.rs::draw_breadcrumb`, Import popup at `:268–301`. Items: *"Import folder..."*, *"Import files..."*, *"Import folder with options..."*. | |
| 194 | + | - **Observation:** The first and third differ by *"with options"*. The second is the file-picker variant. Three items, two of them named almost identically. | |
| 195 | + | - **Recommendation:** Rephrase the third as *"Configure import..."* or *"Import with strategy..."* so the difference is structural, not adjectival. Pairs with C-2. | |
| 196 | + | ||
| 197 | + | ### m-12. Footer's untagged count is shown even at zero analysis coverage — Consistency (footer) | |
| 198 | + | ||
| 199 | + | - **Location:** `footer.rs::draw_footer` at `:152–158`. | |
| 200 | + | - **Observation:** Whether 0 of 100 or 100 of 100 samples are analysed, the untagged count is still computed and shown. A user looking at a freshly-imported folder sees *"0/100 analysed · 100 untagged"* — the second number is just restating the first. | |
| 201 | + | - **Recommendation:** Suppress the untagged count when `analyzed == 0`. Only meaningful as a separate signal once analysis has started. | |
| 202 | + | ||
| 203 | + | ### m-13. Selectable footer tag chips don't react to hover — Affordances (footer) | |
| 204 | + | ||
| 205 | + | - **Location:** `footer.rs::draw_footer` at `:198–210`. Tag list rendered as plain `ui.label` chips. | |
| 206 | + | - **Observation:** They look like the clickable tag chips elsewhere in the app (e.g. detail panel, sidebar) but they're inert. | |
| 207 | + | - **Recommendation:** Either make them clickable (click → push to `required_tags` filter, mirroring sidebar tag-tree behaviour) or remove the chip styling — make them plain text so the affordance contract reads as informational. | |
| 208 | + | ||
| 209 | + | ### m-14. Tag-suggestion dismiss uses raw "x" character — Consistency (detail) | |
| 210 | + | ||
| 211 | + | - **Location:** `detail.rs::draw_detail` at `:282–293`. | |
| 212 | + | - **Observation:** The "x" is a literal lowercase letter. Phase 3/4 settled on either painting an X via two crossed lines (`painter.line_segment`) or using nothing at all for similar dismiss controls. | |
| 213 | + | - **Recommendation:** Optional polish — either keep the "x" (cheap, consistent with Phase 4 hover-only-remove tag chips) or paint a real X via the painter. The bigger fix is M-1 (Undo path); this is just cosmetic. | |
| 214 | + | ||
| 215 | + | ### m-15. Toolbar Help button shows F1 only on hover — Anticipation (toolbar) | |
| 216 | + | ||
| 217 | + | - **Location:** `toolbar.rs::draw_breadcrumb` at `:322–324`. | |
| 218 | + | - **Observation:** The button label is *"Help"*; the F1 shortcut is in the hover. Other buttons (Edit, Instr, Loop) follow the same pattern; the kbd shortcut is documented in F1 itself. Fine, but discoverability suffers. | |
| 219 | + | - **Recommendation:** Either accept as-is (matching the existing convention) or add a small subscript glyph for buttons with shortcuts. Lean as-is for now. | |
| 220 | + | ||
| 221 | + | ### m-16. Cmd+M (Move) conflicts with macOS minimize — Mappings (file_list_menus) | |
| 222 | + | ||
| 223 | + | - **Location:** `file_list_menus.rs::draw_multi_context_menu` at `:193`. | |
| 224 | + | - **Observation:** Cmd+M is the standard macOS shortcut for minimize-window. egui captures it when the multi-context menu is open, but unbound the rest of the time it doesn't conflict. Still worth verifying in a session. | |
| 225 | + | - **Recommendation:** Verify behaviour on macOS. If conflict exists, switch to a different shortcut (Cmd+Shift+M, or no shortcut). Document in the F1 help. | |
| 226 | + | ||
| 227 | + | ### m-17. Sidebar single-library *"..."* button is opaque — Affordances (sidebar) | |
| 228 | + | ||
| 229 | + | - **Location:** `sidebar.rs` at `:206–210`. Single-library installs show `name` + `small_button("...")` to open Settings. | |
| 230 | + | - **Observation:** The `...` glyph reads as *"more options"* but doesn't telegraph Settings specifically. Hover does, but the affordance contract is weak. | |
| 231 | + | - **Recommendation:** Replace with a small `Settings` label-button or use an explicit settings glyph. The Phase 4 settings_panel surface uses *"Change..."* for similar pick-a-folder affordances; consistency would suggest a labelled button here too. | |
| 232 | + | ||
| 233 | + | ### m-18. Background context menu's *"Deselect (N)"* lacks shortcut hint — Consistency (file_list_menus) | |
| 234 | + | ||
| 235 | + | - **Location:** `file_list_menus.rs::draw_background_context_menu` at `:362–365`. | |
| 236 | + | - **Observation:** Other multi-selection items list shortcuts (Cmd+T, Cmd+M, Cmd+Shift+I). Deselect should too — Esc clears selection app-wide. | |
| 237 | + | - **Recommendation:** *"Deselect (N) (Esc)"* matching the existing format. Pairs with m-5's separator standardisation. | |
| 238 | + | ||
| 239 | + | --- | |
| 240 | + | ||
| 241 | + | ## Polish | |
| 242 | + | ||
| 243 | + | ### p-1. Waveform doesn't visualise loop region — Anticipation (detail) | |
| 244 | + | ||
| 245 | + | - **Location:** `detail.rs::draw_detail` at `:26–110`. Renders waveform with playback cursor and hover indicator. The metadata grid shows *"Loop: Yes"* when `is_loop` is set, but the loop bounds aren't drawn. | |
| 246 | + | - **Recommendation:** When `analysis.is_loop` is true, paint translucent vertical bars at the loop start/end frames over the waveform. Backend already has the loop boundaries (or can derive them); this is a paint-only change. | |
| 247 | + | ||
| 248 | + | ### p-2. Footer separators mix `ui.separator()` and middle dot — Consistency (footer) | |
| 249 | + | ||
| 250 | + | - **Location:** `footer.rs::draw_footer` uses `ui.separator()` (vertical line) between sections and `\u{00B7}` (middle dot) inline elsewhere. | |
| 251 | + | - **Recommendation:** Pick one. Middle dot is lighter-weight visually and more consistent with how Phase 4/5 surfaces handle inline section breaks. | |
| 252 | + | ||
| 253 | + | ### p-3. *"af/"* logo has no tooltip — Anticipation (toolbar) | |
| 254 | + | ||
| 255 | + | - **Location:** `toolbar.rs::draw_breadcrumb` at `:170–172`. | |
| 256 | + | - **Observation:** The logo is decorative. Hovering it gives no version info, no About link. | |
| 257 | + | - **Recommendation:** Add `.on_hover_text(format!("audiofiles v{}", env!("CARGO_PKG_VERSION")))`. Optional click-opens-About later. | |
| 258 | + | ||
| 259 | + | ### p-4. Sample list rows don't tint by analysis state — Hierarchy (file_list) | |
| 260 | + | ||
| 261 | + | - **Location:** `file_list.rs::draw_file_list`. `striped(true)` provides alternating row tints. | |
| 262 | + | - **Observation:** Phase 5's M-10 added a status indicator for the Review Suggestions sample list. The main file list could similarly tint rows for *"unanalysed"* state — a faint warning tint for rows missing BPM / key / classification, so the user can scan large libraries and find the unfinished work. | |
| 263 | + | - **Recommendation:** Subtle. When `node.bpm.is_none() && node.musical_key.is_none() && node.classification.is_none()`, tint the row a touch toward `accent_yellow`. Use `linear_multiply(0.05)` so it's a hint, not a warning. | |
| 264 | + | ||
| 265 | + | ### p-5. Drag-out doesn't show count badge near cursor — Anticipation (file_list) | |
| 266 | + | ||
| 267 | + | - **Location:** `file_list_menus.rs::start_os_drag` at `:387–415`. | |
| 268 | + | - **Observation:** egui doesn't expose cursor-following drag overlays for OS-level drags. The user dragging 50 samples sees no count. | |
| 269 | + | - **Recommendation:** This is largely an egui limitation. Workaround: surface the count in the status post (currently *"Dragged 50 samples"*) immediately on drag start. Already done. No further change unless egui upstream gains support. | |
| 270 | + | ||
| 271 | + | ### p-6. Detail panel metadata grid wraps awkwardly at narrow widths — Hierarchy (detail) | |
| 272 | + | ||
| 273 | + | - **Location:** `detail.rs::draw_detail` at `:122–179`. | |
| 274 | + | - **Observation:** The two-column grid (label | value) is fine at normal widths but the value column wraps to multiple lines on narrow ones. Sample-rate values like *"44100 Hz"* render cleanly; longer values like *"-12.3 dB"* combined with `text_secondary` styling produce a jagged right edge. | |
| 275 | + | - **Recommendation:** Set a min-width on the value column or pre-truncate / ellipsize long values. Phase 5 didn't touch this surface; defer unless the detail panel becomes a more central concern. | |
| 276 | + | ||
| 277 | + | --- | |
| 278 | + | ||
| 279 | + | ## Patterns across these findings | |
| 280 | + | ||
| 281 | + | Three patterns dominate, in descending impact: | |
| 282 | + | ||
| 283 | + | 1. **The same operation is reachable from multiple surfaces with quietly different defaults.** C-2 (Import Folder divergent semantics), M-7 (Re-analyze in bulk menu but not single-row), C-3 (Clear Filters mirror of a precedent that wasn't propagated), M-9 (similarity banner + breadcrumb both naming the same state) — entry points proliferated faster than the consistency sweeps did, and the user pays a small "is this the same thing?" tax every time they reach for one. A single follow-up that audits every *"Import"*, *"Clear"*, *"Re-analyze"*, *"Remove"* label across all surfaces and forces label-action parity would close most of these. The pattern shape is identical to Phase 4's *"pre-Phase-3 widget conventions linger"* — the steady-state surfaces predate the affordance sweeps that closed it for wizard surfaces. | |
| 284 | + | ||
| 285 | + | 2. **State the system knows is hidden until the user hovers or right-clicks for it.** C-1 (cloud-only download), M-2 (sort disabled tooltip), M-10 (discovery actions on un-fingerprinted samples), M-12 (tag rename affected count), M-13 (detail panel hidden warning in wrong place) — the surfaces are correct about what they're showing, but the *why* lives one click or one hover away. The fix shape is uniform: surface the why inline (or at the affordance the user pressed), don't rely on tooltips for first-encounter discoverability. | |
| 286 | + | ||
| 287 | + | 3. **The surface scales linearly with library size but the affordances don't.** M-5 (tag tree default-collapsed) and M-8 (footer overcrowding) are the most visible cases. The library browser was designed for a few hundred samples and a flat tag list; once a user reaches a few thousand samples and a dotted tag hierarchy, the same surface starts costing them clicks. Adjacent: M-11 (multi-select tag-coverage badges become more useful at higher selection counts but stay inert), M-3 (toolbar at narrow widths). Phase 7 (if there is one) probably wants a focused pass on *scalability* — the surfaces that work fine at small scale and degrade as the library grows. | |
| 288 | + | ||
| 289 | + | When the C-tier items land, the next natural audit is **Phase 7 — Settings, Sync, Bulk modals & overlay surfaces** — the supporting modals that haven't been independently audited. The patterns above suggest the audit surface for Phase 7 is "how do supporting controls scale," not "how is the steady state organized." Several findings here (M-1 tag suggestion Undo, M-12 tag rename preview, M-6 Reveal in Finder) point toward overlay-layer work that fits naturally alongside that audit. | |
| 290 | + | ||
| 291 | + | --- | |
| 292 | + | ||
| 293 | + | ## Implementation note — Critical batch (2026-05-20) | |
| 294 | + | ||
| 295 | + | All three Critical items shipped. Build clean across `audiofiles-app`, | |
| 296 | + | `audiofiles-browser`; 201 + 439 + 44 tests pass; design-system gates all | |
| 297 | + | return zero output. | |
| 298 | + | ||
| 299 | + | **C-1 — Cloud-only Download button.** The Play column branch in | |
| 300 | + | `file_list.rs::draw_file_list` was an `if ... && !node.cloud_only` gate that | |
| 301 | + | left cloud rows empty. Restructured to an early-return on | |
| 302 | + | `NodeType != Sample`, then split: cloud-only samples render a `Download` | |
| 303 | + | button gated on `sync_manager.is_some()`, calling `sync.download_sample(...)` | |
| 304 | + | and posting either a *"Downloading X..."* status or a *"Sync not ready — | |
| 305 | + | open the Sync panel first"* hint when sync isn't configured. Local samples | |
| 306 | + | keep the existing Play/Stop. Dropped the redundant *"Cloud only — not yet | |
| 307 | + | downloaded"* hover from the name column (`draw_name_column`) since the | |
| 308 | + | Download button now carries the affordance. Right-click → Download in | |
| 309 | + | `file_list_menus.rs` stays available as the secondary path. | |
| 310 | + | ||
| 311 | + | **C-2 — Import-label parity.** Toolbar Import popup (`toolbar.rs::draw_breadcrumb`) | |
| 312 | + | relabelled and reordered: | |
| 313 | + | - *"Import folder..."* — calls `show_import_options` (wizard). Hover: | |
| 314 | + | *"Choose folder, pick a strategy, then import"*. | |
| 315 | + | - *"Quick import folder..."* — calls `quick_import_folder` (no config). | |
| 316 | + | Hover: *"Import without strategy or tagging review"*. | |
| 317 | + | - *"Import files..."* — unchanged (file picker). | |
| 318 | + | ||
| 319 | + | The wizard now owns the *"Import folder..."* label everywhere; the no-config | |
| 320 | + | fast path is explicitly *"Quick import"*. Background context menu in | |
| 321 | + | `file_list_menus.rs::draw_background_context_menu` renamed from | |
| 322 | + | *"Import Folder..."* to *"Import folder..."* (casing now matches the toolbar) | |
| 323 | + | and continues to call `show_import_options`. Bonus normalisation: *"Import | |
| 324 | + | Files..."* in the same menu lowercased to *"Import files..."* to match. | |
| 325 | + | ||
| 326 | + | Closes most of m-11 (Import popup naming clarity) as a side effect. | |
| 327 | + | ||
| 328 | + | **C-3 — Empty-state CTA rename.** `file_list.rs::draw_file_list` empty-state | |
| 329 | + | when filters/search return zero hits: CTA renamed from *"Clear Filters"* to | |
| 330 | + | *"Clear search and filters"*. Action unchanged (already cleared both filter | |
| 331 | + | and search query); label now matches the action. Mirrors the Phase 4 M-4 fix | |
| 332 | + | that renamed the filter panel's button the same way. | |
| 333 | + | ||
| 334 | + | **Files touched:** | |
| 335 | + | - `audiofiles-browser/src/ui/file_list.rs` — Play-column branch restructured, | |
| 336 | + | empty-state CTA renamed, cloud-only name-column hover dropped. | |
| 337 | + | - `audiofiles-browser/src/ui/toolbar.rs` — Import popup relabelled. | |
| 338 | + | - `audiofiles-browser/src/ui/file_list_menus.rs` — Background menu casing | |
| 339 | + | normalised. | |
| 340 | + | ||
| 341 | + | No new state fields, no Backend API changes. Phase 6 Critical closed. | |
| 342 | + | ||
| 343 | + | --- | |
| 344 | + | ||
| 345 | + | ## Implementation note — Minor + Polish batch (2026-05-20) | |
| 346 | + | ||
| 347 | + | Phase 6 Minor + Polish swept in a single parallel-agent pass. Build clean, | |
| 348 | + | 201 + 439 + 44 tests pass, all five design-system gates return zero. | |
| 349 | + | ||
| 350 | + | **file_list.rs** | |
| 351 | + | - **m-1** — Drag-cooldown hover reworded to *"Just dragged \u{2014} ready | |
| 352 | + | again in a moment."*; communicates recency + bounded wait. | |
| 353 | + | - **m-2** — Empty Play column header replaced with a `text_muted` *"Play"* | |
| 354 | + | label for parity with neighbouring non-sortable headers. | |
| 355 | + | - **p-4** — Skipped. `egui_extras::TableRow` (v0.31.1) only exposes | |
| 356 | + | `set_selected` / `set_hovered`; achieving a row-wide unanalysed tint would | |
| 357 | + | require painting `rect_filled` inside every cell closure (name + | |
| 358 | + | every analysis column + Play). Disproportionate restructuring for a | |
| 359 | + | *"subtle hint, not a warning"* polish item. A future | |
| 360 | + | `TableRow::set_bg_tint` (upstream) or shared `cell_bg(ui, tint)` helper | |
| 361 | + | called from every column would unlock this cleanly. | |
| 362 | + | ||
| 363 | + | **sidebar.rs** | |
| 364 | + | - **m-3** — Tag rename: dropped the separate above-input muted label; moved | |
| 365 | + | *"Renaming tag: {old_tag} →"* inline as a small muted prefix on the same | |
| 366 | + | row. Original tag now also serves as `hint_text` placeholder. | |
| 367 | + | - **m-4** — Standardised `[Cancel] [primary]` order across the three | |
| 368 | + | inline rename flows. Tag rename already correct. Collection rename | |
| 369 | + | gained an explicit *Rename* primary button (was Enter-only) and a shared | |
| 370 | + | `commit` flag so Enter / Rename / Cancel each have distinct paths | |
| 371 | + | without duplicated logic. VFS rename modal lives outside `sidebar.rs` — | |
| 372 | + | left untouched. | |
| 373 | + | - **m-7** — *"+ create collection"* small button hover refined to | |
| 374 | + | *"Create a new collection"*. | |
| 375 | + | - **m-17** — Single-library *"..."* button replaced with | |
| 376 | + | `small_button("Settings")` + `on_hover_text("Open library settings")`. | |
| 377 | + | ||
| 378 | + | **file_list_menus.rs** | |
| 379 | + | - **m-5** — Separator standardised to single space before keyboard hints | |
| 380 | + | across both `draw_context_menu` and `draw_multi_context_menu` (also | |
| 381 | + | caught the duplicate in `draw_background_context_menu`). | |
| 382 | + | - **m-16** — Cmd+M conflicts with macOS minimize. Bulk-move shortcut | |
| 383 | + | switched to **Cmd+Shift+M** across all three callsites: the menu label | |
| 384 | + | in `file_list_menus.rs`, the actual binding in `editor.rs:417` (now | |
| 385 | + | requires `modifiers.shift`), and the F1 help table in `overlays.rs:94`. | |
| 386 | + | - **m-18** — Background menu *"Deselect (N)"* now includes the *"(Esc)"* | |
| 387 | + | shortcut hint, matching the standardised separator from m-5. | |
| 388 | + | ||
| 389 | + | **footer.rs (+ state/mod.rs)** | |
| 390 | + | - **m-6** — Status time-fade. New `BrowserState::status_set_at: | |
| 391 | + | Option<Instant>` + a `post_status(&mut self, msg)` helper that stamps | |
| 392 | + | the timer. Footer renderer auto-stamps lazily and detects message | |
| 393 | + | changes via egui memory keyed by *"footer_status_last_seen"* (no extra | |
| 394 | + | struct field needed for change detection), so the existing direct | |
| 395 | + | `state.status = ...` assignments scattered across `state/bulk_ops.rs`, | |
| 396 | + | `state/import_workflow.rs`, etc. don't need migration. Fade to | |
| 397 | + | `text_muted` at 5 s; hide entirely at 30 s. `request_repaint_after` | |
| 398 | + | drives the transitions on idle UIs. | |
| 399 | + | - **m-8** — *"Detail panel hidden"* copy now reads *"Detail panel hidden | |
| 400 | + | \u{2014} widen the window to show it."*. | |
| 401 | + | - **m-12** — Untagged-count chip suppressed when `analyzed == 0`. Fresh | |
| 402 | + | imports no longer show *"100 untagged"* alongside *"0/100 analysed"*. | |
| 403 | + | - **m-13** — Footer per-sample tag chips now render as `text_muted` | |
| 404 | + | middle-dot-separated metadata rather than chip-styled (chip styling was | |
| 405 | + | inviting a click the rendering never honoured). | |
| 406 | + | - **p-2** — Footer separators standardised on middle dot. Added a local | |
| 407 | + | `dot(ui)` helper; replaced all five `ui.separator()` calls. | |
| 408 | + | ||
| 409 | + | **toolbar.rs** | |
| 410 | + | - **m-9** — Sync label with pending count renders as *"Sync \u{00B7} N | |
| 411 | + | pending"*; tooltip unchanged. | |
| 412 | + | - **m-11** — Closed by C-2 batch already (wizard vs quick path are now | |
| 413 | + | structurally named, not adjectivally). | |
| 414 | + | - **p-3** — *"af/"* logo gained an `on_hover_text(format!("audiofiles | |
| 415 | + | v{}", env!("CARGO_PKG_VERSION")))`. | |
| 416 | + | - Skipped: **m-10** (folds into Major M-9), **m-15** (audit said | |
| 417 | + | *"lean as-is"*). | |
| 418 | + | ||
| 419 | + | **detail.rs** | |
| 420 | + | - **m-14** — Tag-suggestion dismiss "x" replaced with a painted X | |
| 421 | + | (14×14 rect, two crossed `line_segment`s, 1.2 stroke). Uses | |
| 422 | + | `text_muted` at rest, `text_secondary` on hover. Mirrors the Phase 4 | |
| 423 | + | M-8 precedent in `instrument_panel.rs`. | |
| 424 | + | - **p-1** — Skipped, reporting. Loop bounds aren't in the current data | |
| 425 | + | model: `AnalysisResult.is_loop` is a single `Option<bool>`, no | |
| 426 | + | `loop_start_frame` / `loop_end_frame` columns in the DB schema, and | |
| 427 | + | `loop_detect::is_loop` doesn't compute boundaries. Shipping the | |
| 428 | + | translucent overlay would need: extend `AnalysisResult` with | |
| 429 | + | start/end frame fields, add DB columns + migration, update the loop | |
| 430 | + | detector to return bounds, then add the `rect_filled` paint. | |
| 431 | + | - **p-6** — Deferred per audit guidance. | |
| 432 | + | ||
| 433 | + | **Files touched (Minor + Polish):** | |
| 434 | + | - `audiofiles-browser/src/state/mod.rs` — `status_set_at` field + | |
| 435 | + | `post_status` helper. | |
| 436 | + | - `audiofiles-browser/src/editor.rs` — Cmd+Shift+M binding (m-16 | |
| 437 | + | follow-through). | |
| 438 | + | - `audiofiles-browser/src/ui/{file_list,sidebar,file_list_menus,footer,toolbar,detail}.rs` | |
| 439 | + | — the per-surface work. | |
| 440 | + | ||
| 441 | + | No new Backend trait methods. Phase 6 Minor + Polish closed (modulo the | |
| 442 | + | two skips documented above). Remaining Phase 6 work is the 13 Major items | |
| 443 | + | listed in the resume prompt. | |
| 444 | + | ||
| 445 | + | --- | |
| 446 | + | ||
| 447 | + | ## Implementation note — Major batch (2026-05-20) | |
| 448 | + | ||
| 449 | + | All 13 Major items shipped. Build clean across `audiofiles-app`, | |
| 450 | + | `audiofiles-browser`; 201 + 439 + 44 tests pass; all five design-system | |
| 451 | + | gates return zero. | |
| 452 | + | ||
| 453 | + | **M-1** — Already closed by Phase 7 M-1's inline Undo | |
| 454 | + | (`last_dismissed_suggestion`). Verified intact; nothing to ship here. | |
| 455 | + | ||
| 456 | + | **M-2** — Sort headers gained `on_disabled_hover_text` when similarity / | |
| 457 | + | duplicate search is active. `draw_sort_header` (`file_list.rs:642`) now | |
| 458 | + | renders disabled labels with `Sense::click()` so egui surfaces the | |
| 459 | + | disabled hover. Message: | |
| 460 | + | *"Sort disabled - results ranked by similarity. Clear the similarity | |
| 461 | + | search to re-enable column sort."* | |
| 462 | + | ||
| 463 | + | **M-3** — Toolbar panel toggles collapse into a single *View ▼* dropdown | |
| 464 | + | when `ctx().screen_rect().width() < 900.0`. Extracted two helpers from | |
| 465 | + | `draw_toolbar`: `draw_inline_panel_toggles` (the wide layout) and | |
| 466 | + | `draw_view_menu` (collapsed). The Edit toggle's branching open/close path | |
| 467 | + | also factored into `toggle_edit_window` so both layouts share it. Active | |
| 468 | + | state in the dropdown is conveyed by a leading `\u{2022}` bullet on | |
| 469 | + | active items. Filters count badge surfaces in both layouts. | |
| 470 | + | ||
| 471 | + | **M-4** — Sync button rendered at fixed width 96px via `add_sized`. | |
| 472 | + | State communicated by a coloured `\u{2022}` bullet prefix instead of by | |
| 473 | + | label width: syncing → `accent_blue`, pending → `accent_yellow`, | |
| 474 | + | disconnected → `text_muted`, ready → no bullet, default text. Tooltip | |
| 475 | + | retains the full state description. `sync_label_tooltip` renamed to | |
| 476 | + | `sync_label_color_tooltip` and now returns `(label, Option<Color32>, | |
| 477 | + | tooltip)`. Neighbouring Settings / Help buttons no longer reflow as sync | |
| 478 | + | state changes. | |
| 479 | + | ||
| 480 | + | **M-5** — Tag tree top level defaults open. `draw_tag_node` | |
| 481 | + | (`sidebar.rs:71`) flips `default_open` to `prefix.is_empty()` so only | |
| 482 | + | the immediate children of root open by default; deeper nodes still | |
| 483 | + | default closed to keep deep dotted hierarchies scannable. | |
| 484 | + | `CollapsingState::load_with_default_open` means user toggles persist | |
| 485 | + | across sessions (egui memory). | |
| 486 | + | ||
| 487 | + | **M-6** — *"Reveal in Finder" / "Show in Explorer" / "Open Containing | |
| 488 | + | Folder"* added to the single-sample context menu in | |
| 489 | + | `file_list_menus.rs` (between Copy Path and Find Similar). Platform-gated | |
| 490 | + | labels and shell commands: macOS `open -R <path>`, Windows | |
| 491 | + | `explorer /select,<path>`, Linux `xdg-open <parent>` (Linux can't natively | |
| 492 | + | highlight a single file, so the parent directory is the closest mapping). | |
| 493 | + | Cloud-only samples skip the item (no on-disk path to reveal). | |
| 494 | + | ||
| 495 | + | **M-7** — Single-row *"Re-analyze..."* in `draw_context_menu` Sample | |
| 496 | + | branch (above Delete), mirroring the multi-row version. Uses a | |
| 497 | + | one-element `ReanalyzeOverwrite` confirm when the sample already has | |
| 498 | + | analysis fields (bpm / key / classification); skips straight to | |
| 499 | + | `start_analysis_flow` when none of those are set. Cloud-only samples | |
| 500 | + | skip the item (`!node.cloud_only` gate). |
Lines truncated
| @@ -0,0 +1,527 @@ | |||
| 1 | + | # Phase 7 UX Audit — Overlays & Sample Editor | |
| 2 | + | ||
| 3 | + | **Surfaces:** `ui/overlays.rs` (help dialog, confirm dialog, import preflight, unsafe-mode warning, bulk modals (tag / move / rename), vault create/rename, folder create/rename), `ui/edit_panel.rs` (floating sample editor — trim, levels, transform, silence, result mode, batch ops). | |
| 4 | + | **Detected stack:** egui — modal windows via the shared `widgets::modal_window` / `widgets::name_modal` / `widgets::confirm_modal` family, plus the floating `tool_window` for the editor. | |
| 5 | + | **Frame of reference:** Phases 1–6 covered the surfaces the user navigates *through*. Phase 7 covers the surfaces the user navigates *into* — modal dialogs and the floating editor that interrupt the steady-state flow to perform a discrete operation. The dominant axes here are forgiveness (a modal is often the last screen before an irreversible action), preview fidelity (the user needs to see what they're about to commit), and the integrity of mode-handling (a modal's job is to be modal — and to exit cleanly when done). | |
| 6 | + | ||
| 7 | + | Findings are ranked Critical / Major / Minor / Polish using the rubric established in earlier phases. No code in this document — recommendations describe the change, not the diff. | |
| 8 | + | ||
| 9 | + | --- | |
| 10 | + | ||
| 11 | + | ## Critical | |
| 12 | + | ||
| 13 | + | ### C-1. Edit operations commit without preview, and Trim is irreversible from the panel — Forgiveness (edit_panel) | |
| 14 | + | ||
| 15 | + | - **Location:** `edit_panel.rs::draw_trim_section`, `:166–172`. The *"Trim"* button calls `state.apply_edit_trim()` immediately on click. No preview, no confirm, no Undo button on the panel itself. | |
| 16 | + | - **Observation:** Trim destroys audio outside the slider range. When the result mode is *Replace original* (one of the two options in the Result section), there's no path back from inside the panel — the user has lost the part of the sample they trimmed. Global Cmd+Z exists but isn't surfaced anywhere in the edit window; a user who's been moving sliders for 30 seconds doesn't know that the last operation can be unwound. The same shape applies to *Insert silence* / *Remove range* / *Reverse* but Trim is the worst case because it deletes audio data. | |
| 17 | + | - **Why it matters:** The sample editor is the deepest-commit surface in the app — every operation is a content mutation. The pattern in egui DAWs (Audacity, Reaper) is "preview then commit", or at minimum a visible per-window undo stack. The current panel has neither: a slider gesture and an *Apply* button is one of two clicks away from data loss, and the undo path is on a global keyboard shortcut the user has to remember. | |
| 18 | + | - **Recommendation:** Two parts. | |
| 19 | + | - **Visualise the destructive region on the waveform** before commit. When `trim_start` / `trim_end` are non-default, paint translucent grey overlays on the regions that *will be removed*. Pairs with M-10 below. The user sees the cut before clicking Apply. | |
| 20 | + | - **Add a "Last edit: <op> · Undo" line below the section dividers** when an edit has been applied this session. Calls into the same undo path as Cmd+Z but surfaces the affordance at the locus of the action. Disappears after ~10 seconds or after the next edit. | |
| 21 | + | ||
| 22 | + | ### C-2. Unsafe-mode warning offers Purge or Cancel — no Locate affordance — Forgiveness (overlays) | |
| 23 | + | ||
| 24 | + | - **Location:** `overlays.rs::draw_unsafe_warning`, `:307–334`. When `unsafe_missing_count > 0`, the modal offers *Purge missing samples* (danger) or *Cancel*. `Cancel` calls `dismiss_unsafe_warning`, which suppresses the dialog without addressing the missing files. | |
| 25 | + | - **Observation:** Unsafe mode references sample files in-place. If the user temporarily disconnects an external drive, or moves a directory of sources, every sample referencing those files becomes "missing". The warning surfaces the count and offers exactly one action: purge them from the library. *Purge* deletes the registry entries — including all tags, analysis results, and history accumulated against those samples. A user reconnecting the drive after a purge has no way to recover the metadata; they'd have to re-import and re-tag. | |
| 26 | + | - **Why it matters:** The modal frames the failure as binary (purge or dismiss) when the practical user goal is almost always *relocate*. The audit-time intuition: a sample is "missing" because something moved, not because the user wanted it gone. The Phase 4 settings panel already exposed a *Locate…* button for offline vaults; the same affordance is missing here for the per-sample case. The blast radius is *all metadata* the user has accumulated against those samples. | |
| 27 | + | - **Recommendation:** Add a third button: *Locate missing files…* that opens `rfd::FileDialog::pick_folder()` and triggers a backend pass to re-resolve sample hashes against the chosen directory (matching by filename + size + content hash where possible). Samples that can be re-pointed update their source path; samples that still can't be found stay in the missing set and the dialog reappears with the remaining count. Pair with copy that explicitly names the data-loss scope of Purge: *"Tags, analysis results, and history for these samples will be permanently deleted."* | |
| 28 | + | ||
| 29 | + | ### C-3. Bulk modal backend errors close the modal, discarding typed input — Forgiveness (overlays) | |
| 30 | + | ||
| 31 | + | - **Location:** `overlays.rs::draw_vfs_create_modal` at `:581–589`, `draw_vfs_rename_modal` at `:603–610`, `draw_dir_create_modal` at `:620–628`, `draw_dir_rename_modal` at `:643–649`. All four follow the same shape: on `NameModalOutcome::Submitted`, attempt the backend call, post status either way, then set the open flag to `false` (closing the modal). | |
| 32 | + | - **Observation:** The user types a vault name like *"My Beats"* (with a space). Backend rejects on `create_vfs` (e.g. *"vault names cannot contain spaces"*). Status post shows *"Failed to create vault: ..."*. The modal is gone. To retry, the user has to re-open the modal and re-type the name (now corrected). The pattern repeats for every name-modal callsite. | |
| 33 | + | - **Why it matters:** Form-validation errors are the modal user's common-case experience — typos, edge cases, server-side checks. The Phase 5 C-3 work added an acknowledgement state for cancelled long-running operations precisely because dropping straight to None loses context. Modal errors are the form-level version of the same gap: the user's work is destroyed by a workflow that assumes success. | |
| 34 | + | - **Recommendation:** Restructure the four `name_modal` callsites to keep the modal open on backend error. The modal should display an inline error label (red text below the input) and re-focus the input. Only close on backend success or explicit Cancel. The `widgets::name_modal` API may need an additional optional `error: Option<&str>` parameter for the inline display. Same pattern applies to the bulk modals (tag / move / rename) on backend error. | |
| 35 | + | ||
| 36 | + | --- | |
| 37 | + | ||
| 38 | + | ## Major | |
| 39 | + | ||
| 40 | + | ### M-1. Help dialog window title is the app name, not the dialog purpose — Mappings (overlays) | |
| 41 | + | ||
| 42 | + | - **Location:** `overlays.rs::draw_help_overlay` at `:13`. `modal_window_with_open(..., "audiofiles", ...)`. | |
| 43 | + | - **Observation:** Opening F1 brings up a modal titled *"audiofiles"*. The user just clicked Help in the toolbar (or pressed F1); the modal that appears tells them they're looking at "audiofiles" rather than at help content. The convention in every other modal in this file is to title by content (Confirm, New Vault, Bulk Tag, etc.). | |
| 44 | + | - **Recommendation:** Title *"Help"* or *"Shortcuts and Features"*. Keeps the modal's job legible. | |
| 45 | + | ||
| 46 | + | ### M-2. Shortcut table is flat and not searchable — Anticipation (overlays) | |
| 47 | + | ||
| 48 | + | - **Location:** `overlays.rs::draw_shortcuts_tab` at `:33–105`. 22 rows in a single `egui::Grid` with no grouping, no search. | |
| 49 | + | - **Observation:** Today's table has 22 rows; the audit history suggests it will keep growing. There's no grouping (navigation / playback / selection / bulk / search / windowing) and no filter input. A user remembering only that the search shortcut is "something with a slash" has to scan top-to-bottom. | |
| 50 | + | - **Recommendation:** Two passes: (a) add muted section headers (Navigation, Selection, Playback, Bulk ops, Search, Toggles, System) — pure layout, no behaviour change. (b) Add a small search input at the top of the tab; filters both columns. Pairs with M-3 below. | |
| 51 | + | ||
| 52 | + | ### M-3. Help features tab has no link from feature description to live affordance — Affordances (overlays) | |
| 53 | + | ||
| 54 | + | - **Location:** `overlays.rs::draw_features_tab` at `:107–148`. | |
| 55 | + | - **Observation:** Each feature paragraph describes how to use a thing (*"Press I to open the instrument panel. Right-click a sample → Play as Instrument…"*) but contains no clickable element to actually open that thing. The user reads the description, closes the help, then has to remember the path. | |
| 56 | + | - **Recommendation:** Each feature paragraph gets a *"Try it"* link at the end where applicable. Clicking closes the help dialog and triggers the relevant action (open instrument panel, focus search, etc.). Cheaper alternative: add inline `ui.link()` calls for the keyboard shortcuts referenced in the prose (e.g. *"Press [I]"* where `[I]` is a clickable widget). Either way the help becomes a launcher in addition to a reference. | |
| 57 | + | ||
| 58 | + | ### M-4. Bulk tag modal has no autocomplete from existing tags — Anticipation (overlays) | |
| 59 | + | ||
| 60 | + | - **Location:** `overlays.rs::draw_bulk_tag_modal` at `:380–386`. Tag input is a plain `TextEdit::singleline` with a hint *"e.g. genre.electronic"*. | |
| 61 | + | - **Observation:** The library already has a known tag set (`state.all_tags`). When the user opens the bulk tag modal to add *"drums.kick"*, the modal doesn't suggest existing tags — leaves the user to remember whether they used *"drums.kick"* or *"kick"* or *"drums.kicks"* (plural) in prior tagging. Inconsistent tag values fragment the taxonomy. The detail-panel tag input has the same gap; the bulk modal multiplies the impact by N samples. | |
| 62 | + | - **Recommendation:** Below the input, render a horizontal_wrapped row of `selectable_tag` chips for tags whose substring matches the current input. Click inserts. Limit to ~12 most-recent or most-frequent matches. Reuses the existing tag list; no new state. | |
| 63 | + | ||
| 64 | + | ### M-5. Bulk tag remove gives no feedback when the tag isn't on any selected sample — Forgiveness (overlays) | |
| 65 | + | ||
| 66 | + | - **Location:** `overlays.rs::draw_bulk_tag_modal`. *"Remove tag"* mode submits to `state.execute_bulk_tag()`. | |
| 67 | + | - **Observation:** User selects 30 samples, picks *"Remove tag"*, types *"drumz"* (typo), clicks Apply. Backend removes the tag from 0 samples. Status post may report this; modal closes anyway. From the user's perspective, the command appeared to execute — they don't know that 0 samples were affected because they typo'd. | |
| 68 | + | - **Recommendation:** Before submit, compute the count of selected samples that actually carry the tag. Surface a muted *"Will remove from N of M selected samples"* below the input. When N == 0, disable the Apply button with a hover *"None of the selected samples have this tag."*. Pairs with M-4's autocomplete. | |
| 69 | + | ||
| 70 | + | ### M-6. Bulk move modal has no search for long directory lists — Anticipation (overlays) | |
| 71 | + | ||
| 72 | + | - **Location:** `overlays.rs::draw_bulk_move_modal` at `:436–452`. ScrollArea + selectable_label per directory. | |
| 73 | + | - **Observation:** For libraries with deep folder trees (hundreds of directories), the move modal is a wall of selectable labels. No filter, no recent-targets pinned at top, no path-search input. The user scrolls a 200-item list to find *"drums/kicks/909"*. | |
| 74 | + | - **Recommendation:** Add a text filter at the top of the modal (`"Filter folders..."`). Filter the directory list to entries whose path contains the substring (case-insensitive). Pin the current parent at the top when filter is empty. Scales to large libraries without restructuring the underlying list. | |
| 75 | + | ||
| 76 | + | ### M-7. Bulk rename modal renders every preview row at scale — Anticipation (overlays) | |
| 77 | + | ||
| 78 | + | - **Location:** `overlays.rs::draw_bulk_rename_modal` at `:526–555`. | |
| 79 | + | - **Observation:** The preview grid iterates `previews` unconditionally inside the ScrollArea. For a 500-sample rename, the grid materialises 500 rows on every keystroke. egui's `Grid` doesn't virtualise — every cell paints every frame. | |
| 80 | + | - **Recommendation:** Cap the preview at the first 50 rows. Below the cap, render a muted *"…and {N} more"* line. The cap is a layout concern, not a correctness one — the actual rename uses the full list. Optional: add a *"Show all"* link that expands the cap to the full list when the user explicitly wants the full preview. | |
| 81 | + | ||
| 82 | + | ### M-8. Bulk rename preview doesn't flag duplicate output names — Forgiveness (overlays) | |
| 83 | + | ||
| 84 | + | - **Location:** `overlays.rs::draw_bulk_rename_modal`. The `error` field surfaces parse-level errors but not collision-level ones. | |
| 85 | + | - **Observation:** A user with samples `kick_01.wav`, `kick_02.wav`, and pattern `{class}.{ext}` produces three rows all named `kick.wav`. The backend's filename-uniqueness logic may auto-suffix or fail; either way the user discovers the collision after the rename. The preview should call this out before commit. | |
| 86 | + | - **Recommendation:** When building the preview, count occurrences of each output name. Rows whose output name appears more than once render in `accent_yellow` with a hover hint *"Duplicate — will be auto-suffixed on commit"* (if backend deduplicates) or *"Duplicate — rename will fail"* (if it doesn't). Investigation step: verify backend behaviour and word the hint accordingly. | |
| 87 | + | ||
| 88 | + | ### M-9. Import preflight has no "Skip preflight in future" — Anticipation (overlays) | |
| 89 | + | ||
| 90 | + | - **Location:** `overlays.rs::draw_import_preflight` at `:283–304`. Fires for every quick-import of ≥100 files or ≥1 GiB. | |
| 91 | + | - **Observation:** A user who routinely imports large folders (every Bandcamp sample pack, every sound-design dump) sees the preflight every time. The threshold makes sense for the first encounter (preventing accidental imports of *Music/*), but the user has no way to dismiss it permanently after they've confirmed it's reliable. | |
| 92 | + | - **Recommendation:** Add a *"Don't ask again for folders this size"* checkbox in the modal. Persists in user config (similar to `show_first_launch_hint` and `show_sync_intro`). Different checkbox per size tier (≥100 files vs ≥1 GiB) or a single global one — pick the simpler version. | |
| 93 | + | ||
| 94 | + | ### M-10. Edit panel waveform doesn't visualise trim region — Visibility of state (edit_panel) | |
| 95 | + | ||
| 96 | + | - **Location:** `edit_panel.rs::draw_waveform_section` at `:62–111`. The waveform renders with playback cursor and click-to-seek; trim_start / trim_end are surfaced only as text labels under the sliders. | |
| 97 | + | - **Observation:** The user moves the Trim Start slider to 0.3 and the Trim End slider to 0.8. The text labels update with the millisecond conversions. The waveform — the visual surface that would tell the user *what they're keeping vs cutting* — shows neither. Every other audio editor visualises trim regions; the audit calls this out because it's both the missing affordance for C-1's preview gap and a Visibility-of-state issue in its own right. | |
| 98 | + | - **Recommendation:** Paint translucent grey overlays on the regions outside `[trim_start, trim_end]` over the waveform. Use `painter.rect_filled` with `theme::bg_primary().linear_multiply(0.5)` or similar. Updates live as the sliders move. Pairs with C-1. | |
| 99 | + | ||
| 100 | + | ### M-11. Edit panel in_progress has no Cancel — Forgiveness (edit_panel) | |
| 101 | + | ||
| 102 | + | - **Location:** `edit_panel.rs::draw_edit_window` at `:17–24`. When `state.edit.in_progress`, the panel renders a spinner + *"Applying edit..."* and returns early. | |
| 103 | + | - **Observation:** Edits are usually fast, but normalize-LUFS on a 5-minute multitrack can take several seconds. During those seconds the user is stuck — no Cancel button. If they realise mid-apply that they picked the wrong target, they have to wait for completion and then undo. | |
| 104 | + | - **Recommendation:** Add a Cancel button below the spinner. Wires to a `state.cancel_edit()` path that signals the worker to stop. If the worker can't be interrupted mid-operation, surface the wait as bounded: *"Applying edit... (cannot cancel)"*. Even the latter is more honest than the silent block. | |
| 105 | + | ||
| 106 | + | ### M-12. Edit result_prompt blocks the editor with no Cancel path — Forgiveness (edit_panel) | |
| 107 | + | ||
| 108 | + | - **Location:** `edit_panel.rs::draw_result_prompt` at `:378–398`. After the first edit triggers the prompt, the user sees Replace / Sibling / Remember-checkbox. No way to back out. | |
| 109 | + | - **Observation:** The prompt fires after an edit has produced its output; the user picks Replace or Sibling to commit. But if the user clicked Apply by mistake and wants to back out, there's no escape from this dialog — they must commit one of the two. Esc doesn't dismiss (the dialog renders as a Frame::popup inside the editor, not as a proper modal). | |
| 110 | + | - **Recommendation:** Add a *"Cancel"* button (third option) that discards the edit output and returns to the editor without applying. Backend already supports discard (when the user doesn't confirm); just wire the button. | |
| 111 | + | ||
| 112 | + | ### M-13. Normalize Peak/LUFS toggle swaps slider range without resetting target — Modes / Mappings (edit_panel) | |
| 113 | + | ||
| 114 | + | - **Location:** `edit_panel.rs::draw_levels_section` at `:201–220`. | |
| 115 | + | - **Observation:** User picks Peak, sets target to -3.0 dBFS. Switches to LUFS. Target slider now shows *-3.0 LUFS* — far too loud as a LUFS target (canonical mastering target is -14 LUFS). The slider's range did flip from `-24.0..=0.0` (Peak) to `-24.0..=-6.0` (LUFS), but the carried-over `-3.0` value is now clamped to `-6.0` by the new range silently. The user doesn't see this happen — the slider just moves. | |
| 116 | + | - **Recommendation:** When the toggle flips, reset `norm_target` to a sensible default for the new mode (-1.0 dBFS for Peak, -14.0 LUFS for LUFS). Or surface a tiny "Peak: -3 dBFS / LUFS: -14" pair on the toggle so the user picks both at once. The reset-on-toggle is the lower-friction fix. | |
| 117 | + | ||
| 118 | + | ### M-14. Edit batch buttons use panel values silently — Mappings (edit_panel) | |
| 119 | + | ||
| 120 | + | - **Location:** `edit_panel.rs::draw_batch_section` at `:322–356`. | |
| 121 | + | - **Observation:** The batch buttons (*"Normalize Peak"*, *"Normalize LUFS"*, *"Gain"*, *"Reverse"*) use the panel's current values (`state.edit.norm_target`, `state.edit.gain_db`). But the panel above also has those values as sliders the user is moving. There's no labelled distinction — clicking *"Gain"* in the batch row applies the current single-sample gain slider's value to N samples. A user adjusting the slider while focused on the single sample could click the batch button by accident and broadcast the value across the selection. | |
| 122 | + | - **Recommendation:** Either (a) the batch section keeps its own `batch_norm_target` / `batch_gain_db` values (separate from the single-sample fields); user picks both values explicitly. (b) The batch buttons read the slider values but require an explicit confirm modal *"Apply {value} to {N} samples?"* before commit. (b) is the lower-effort fix; (a) is the more honest mode separation. | |
| 123 | + | ||
| 124 | + | --- | |
| 125 | + | ||
| 126 | + | ## Minor | |
| 127 | + | ||
| 128 | + | ### m-1. SwitchLibrary confirm isn't styled as danger despite interrupting work — Hierarchy (overlays) | |
| 129 | + | ||
| 130 | + | - **Location:** `overlays.rs::draw_confirm_dialog`, `SwitchLibrary` arm at `:189–194`. `danger: false`. | |
| 131 | + | - **Observation:** The detail line says *"You have in-flight work (import, sync, or bulk operation) that will be interrupted."* — that's destructive of work-in-progress. The confirm button reads *"Switch"* with default (non-danger) styling. | |
| 132 | + | - **Recommendation:** Either bump to `danger: true` (matches the wording) or soften the detail copy if Switch is genuinely safe. The current pair reads as inconsistent — danger language + non-danger affordance. | |
| 133 | + | ||
| 134 | + | ### m-2. Help shortcut table mixes separator styles — Consistency (overlays) | |
| 135 | + | ||
| 136 | + | - **Location:** `overlays.rs::draw_shortcuts_tab` rows at `:35–104`. Some rows use slashes (*"j / Down"*), others use plus signs (*"Cmd+T"*). | |
| 137 | + | - **Observation:** The mixed convention reads as inconsistency. Slashes mean *"either this or that"* — same action; plus signs mean *"hold both"* — chord. The reader has to do the translation per row. | |
| 138 | + | - **Recommendation:** Standardise: slash for alternatives (j / Down), plus for chords (Cmd+T). Already mostly correct; just audit each row. | |
| 139 | + | ||
| 140 | + | ### m-3. Bulk move "/ (root)" could be just "/" — Consistency (overlays) | |
| 141 | + | ||
| 142 | + | - **Location:** `overlays.rs::draw_bulk_move_modal` at `:439–443`. | |
| 143 | + | - **Observation:** Other surfaces (toolbar breadcrumb) use bare *"/"* for the root. The modal annotating it as *"(root)"* is helpful first-encounter but redundant after. | |
| 144 | + | - **Recommendation:** Drop the *"(root)"* suffix. The path semantics are obvious in context. | |
| 145 | + | ||
| 146 | + | ### m-4. New Vault hint copy could explain VFS clearly — Anticipation (overlays) | |
| 147 | + | ||
| 148 | + | - **Location:** `overlays.rs::draw_vfs_create_modal` at `:579`. | |
| 149 | + | - **Observation:** Current: *"Vaults are top-level containers for your samples. Right-click inside a vault to create folders."* The phrase *"top-level containers"* is mildly technical. | |
| 150 | + | - **Recommendation:** *"A vault is a separate sample collection — like a folder, but with its own tags and analysis. Right-click inside to create sub-folders."* Tighter; gives the user the why. | |
| 151 | + | ||
| 152 | + | ### m-5. Rename Vault has no hint copy — Anticipation (overlays) | |
| 153 | + | ||
| 154 | + | - **Location:** `overlays.rs::draw_vfs_rename_modal` at `:597–600`. `name_modal(ctx, "Rename Vault", None, ...)`. | |
| 155 | + | - **Recommendation:** Add a hint like *"Vault names can contain spaces."* — clarifies what the input accepts. Doesn't have to be long. | |
| 156 | + | ||
| 157 | + | ### m-6. Dir create modal lacks valid-chars hint — Anticipation (overlays) | |
| 158 | + | ||
| 159 | + | - **Location:** `overlays.rs::draw_dir_create_modal` at `:620`. No hint. | |
| 160 | + | - **Recommendation:** Hint *"Folder names cannot contain /"* (or whatever the backend validates). | |
| 161 | + | ||
| 162 | + | ### m-7. Edit info line uses double-space separator — Consistency (edit_panel) | |
| 163 | + | ||
| 164 | + | - **Location:** `edit_panel.rs::draw_info_line` at `:128–131`. `" {} Hz {:.3}s {}"`. | |
| 165 | + | - **Recommendation:** Use `\u{00B7}` middle dot matching the Phase 4 / 5 convention. | |
| 166 | + | ||
| 167 | + | ### m-8. Edit fade duration cap of 2000 ms is arbitrary — Mappings (edit_panel) | |
| 168 | + | ||
| 169 | + | - **Location:** `edit_panel.rs::draw_transform_section` at `:247–249`. Slider range 10..=2000 ms. | |
| 170 | + | - **Observation:** Long pads / textures might want 5 s+ fades. The cap is invisible until the user hits it. | |
| 171 | + | - **Recommendation:** Either raise to e.g. 10000 ms or scale to `min(sample_duration_ms, 10000)`. Tooltip the cap. | |
| 172 | + | ||
| 173 | + | ### m-9. Edit silence Insert/Remove DragValues have no bounds vs sample length — Forgiveness (edit_panel) | |
| 174 | + | ||
| 175 | + | - **Location:** `edit_panel.rs::draw_silence_section` at `:269–319`. `range(0.0..=f64::MAX)`. | |
| 176 | + | - **Observation:** Insert position beyond sample duration silently fails or appends. Remove range past the end has undefined behaviour. | |
| 177 | + | - **Recommendation:** Clamp both to `[0, sample_duration_ms]` once duration is known. Or surface a hint when out of range. | |
| 178 | + | ||
| 179 | + | ### m-10. Edit result_prompt's "Remember my choice" is per-instance — Mappings (edit_panel) | |
| 180 | + | ||
| 181 | + | - **Location:** `edit_panel.rs::draw_result_prompt` at `:385–387`. | |
| 182 | + | - **Observation:** The checkbox is a local `let mut remember = false`. Each time the prompt fires, the box starts unchecked. There's no visual link to the Result section's persistent radios. | |
| 183 | + | - **Recommendation:** Either initialise the checkbox from the current result mode setting (so user sees the current state), or remove the checkbox and rely on the Result section's radios as the source of truth. The current design has two paths for the same preference. | |
| 184 | + | ||
| 185 | + | ### m-11. `format_bytes` in overlays.rs is a private copy — Consistency (overlays) | |
| 186 | + | ||
| 187 | + | - **Location:** `overlays.rs` at `:267–278`. Third of the three legacy copies noted as opportunistic cleanup. | |
| 188 | + | - **Recommendation:** Delete; call `widgets::format_bytes`. | |
| 189 | + | ||
| 190 | + | ### m-12. Help tabs use `selectable_value` — Affordances (overlays) | |
| 191 | + | ||
| 192 | + | - **Location:** `overlays.rs::draw_help_overlay` at `:18–22`. | |
| 193 | + | - **Observation:** Tabs render as two selectable_value labels with a separator below. Visually weak as tabs — the affordance reads more like *radio buttons*. | |
| 194 | + | - **Recommendation:** Use the toggle_pills widget (already used elsewhere) for the same two-option toggle, or render the tabs with a distinct underline. Minor visual polish; the function works as-is. | |
| 195 | + | ||
| 196 | + | ### m-13. Edit batch section heading is `accent_blue strong` — Hierarchy (edit_panel) | |
| 197 | + | ||
| 198 | + | - **Location:** `edit_panel.rs::draw_batch_section` at `:328–332`. | |
| 199 | + | - **Observation:** Looks like a section header (matches *"Trim"*, *"Levels"*, etc. which are `strong()` without color). The blue color is the only distinguishing mark. Adjacent sections become visually weighty. | |
| 200 | + | - **Recommendation:** Drop the `.color(accent_blue)`; the heading is already strong + bold. The blue can move to a small *"Batch — {N} samples"* badge to the right. | |
| 201 | + | ||
| 202 | + | ### m-14. Bulk rename token chips append instead of insert at cursor — Mappings (overlays) | |
| 203 | + | ||
| 204 | + | - **Location:** `overlays.rs::draw_bulk_rename_modal` at `:495–505`. Same shape as the Phase 5 M-8 fix for export. | |
| 205 | + | - **Observation:** Click-to-append matches the export naming-pattern surface. Same limitation: egui doesn't expose cursor position on TextEdit. Calling out for consistency tracking. | |
| 206 | + | - **Recommendation:** Leave as-is (matches export). If egui adds cursor-position API, fix both at once. | |
| 207 | + | ||
| 208 | + | ### m-15. Confirm dialog title is always *"Confirm"* — Anticipation (overlays) | |
| 209 | + | ||
| 210 | + | - **Location:** `overlays.rs::draw_confirm_dialog` at `:253–259`. Hardcoded `title: "Confirm"`. | |
| 211 | + | - **Observation:** Title is generic. The prompt does the work. Could match the variant — *"Delete"*, *"Disconnect"*, *"Switch library"* — for better window-list integration on macOS / Windows. | |
| 212 | + | - **Recommendation:** Title varies per `ConfirmAction` variant. The variant match already exists; just lift the title alongside `confirm_label`. | |
| 213 | + | ||
| 214 | + | ### m-16. Edit Reverse button has no confirmation but is destructive in batch — Forgiveness (edit_panel) | |
| 215 | + | ||
| 216 | + | - **Location:** `edit_panel.rs::draw_batch_section` at `:352–354`. *"Reverse"* button on N samples. | |
| 217 | + | - **Observation:** Single-sample Reverse is its own undo (click again). Batch Reverse applies to N samples; "click again" reverses all of them back, but only if the user remembers. A user who batch-reversed by mistake would have to remember the action even existed. | |
| 218 | + | - **Recommendation:** Confirm modal for batch Reverse when N is above a threshold (say 10). Or surface a *"Reverse {N} samples"* hover already (already done) but add an explicit confirm step at the threshold. | |
| 219 | + | ||
| 220 | + | --- | |
| 221 | + | ||
| 222 | + | ## Polish | |
| 223 | + | ||
| 224 | + | ### p-1. Help features tab could embed feature screenshots — Anticipation (overlays) | |
| 225 | + | ||
| 226 | + | - **Observation:** The features tab is text-only. A user reading about the instrument panel can't see what it looks like. | |
| 227 | + | - **Recommendation:** Optional. Embed PNG screenshots beside each feature paragraph. Increases binary size; useful for onboarding. Defer until a docs pass. | |
| 228 | + | ||
| 229 | + | ### p-2. Edit panel could group sections via a left-rail picker — Hierarchy (edit_panel) | |
| 230 | + | ||
| 231 | + | - **Observation:** Six sections stack vertically. The panel is tall. A user wanting only Trim has to scroll past Levels / Transform / Silence / Result / Batch. | |
| 232 | + | - **Recommendation:** Optional. Left-rail of section names; central area shows the selected section. Bigger UI rework; defer. | |
| 233 | + | ||
| 234 | + | ### p-3. Bulk rename preview could highlight changes — Anticipation (overlays) | |
| 235 | + | ||
| 236 | + | - **Observation:** Old / New columns show the strings. The user has to do a diff in their head. | |
| 237 | + | - **Recommendation:** Highlight the substring(s) that changed: added characters in green, removed in red. Pattern matching by string-diff. Polish-tier; the preview works without this. | |
| 238 | + | ||
| 239 | + | ### p-4. All modals lack Cmd+Enter to confirm — Affordances (overlays) | |
| 240 | + | ||
| 241 | + | - **Observation:** Enter submits text inputs in name modals (good). But the bulk modals (move, rename) don't surface a keyboard confirm — the user mouse-clicks the Apply button. | |
| 242 | + | - **Recommendation:** Bind Cmd+Enter (Ctrl+Enter on Linux/Windows) to the primary action on every modal. Standard convention in desktop apps. | |
| 243 | + | ||
| 244 | + | ### p-5. Help shortcuts hardcode Cmd — Mappings (overlays) | |
| 245 | + | ||
| 246 | + | - **Observation:** Shortcuts table says *"Cmd+Z"* / *"Cmd+T"* / etc. On Windows / Linux the convention is Ctrl. The actual code may already handle this (egui's `modifiers.command` is platform-aware); the help table doesn't reflect it. | |
| 247 | + | - **Recommendation:** Render *"⌘"* on macOS, *"Ctrl"* on others — gated by `#[cfg(target_os = ...)]` at the table-build site, or via a small helper. Cosmetic but disambiguates first-encounter on cross-platform. | |
| 248 | + | ||
| 249 | + | ### p-6. Edit panel's waveform is fixed 80px tall — Hierarchy (edit_panel) | |
| 250 | + | ||
| 251 | + | - **Observation:** The detail panel uses 120px (`detail.rs::draw_detail` waveform call). The edit panel uses 80px. The edit context arguably needs a *bigger* waveform for precise trim work. | |
| 252 | + | - **Recommendation:** Bump the edit panel waveform to 120px (matching detail) or make it configurable. | |
| 253 | + | ||
| 254 | + | --- | |
| 255 | + | ||
| 256 | + | ## Patterns across these findings | |
| 257 | + | ||
| 258 | + | Three patterns dominate, in descending impact: | |
| 259 | + | ||
| 260 | + | 1. **Modal commits assume success and discard user work on failure.** C-3 (name modals close on backend error), M-5 (bulk tag remove gives no zero-match feedback), M-11 (in-progress edit can't be cancelled), M-12 (result prompt has no cancel path), M-8 (rename preview doesn't flag collisions) — every modal in this batch has the same shape: input → submit → close. The close-on-submit is correct on success and wrong on failure. The fix shape is uniform: keep the modal open on backend error, surface the error inline, re-focus the input. Phase 5's C-3 settled the same pattern for cancelled long-running operations (acknowledgement state instead of dropping to None); modal forms warrant the same treatment. | |
| 261 | + | ||
| 262 | + | 2. **The edit panel hands the user destructive power without preview or visible undo.** C-1 (trim commits without waveform overlay), M-10 (no trim region visualisation), M-11 (no in-progress cancel), M-12 (no result-prompt cancel), M-13 (mode-toggle silently clamps target), M-14 (batch buttons piggyback on single-sample values) — every operation is a content mutation, every operation has its own Apply button, and the only undo is a global Cmd+Z the editor never mentions. The audit's cluster of edit-panel findings collectively says: the editor optimises for the experienced-user fast path and abandons the new user on the recovery path. A single follow-up that (a) paints regions on the waveform for trim / silence-remove / fade, (b) adds an inline *Undo last edit* link, and (c) cancels in_progress would close the cluster. | |
| 263 | + | ||
| 264 | + | 3. **Help and bulk modals assume the user knows the existing vocabulary.** M-2 (shortcuts not searchable / grouped), M-3 (features describe affordances but don't link to them), M-4 (bulk tag has no autocomplete from existing tags), M-6 (bulk move has no folder search), m-4/m-5/m-6 (modals lack clarifying hint copy) — the modals are correct about what they accept but unhelpful about what's available. The fix shape is to lean on existing data: the existing tag set populates autocomplete; the existing directory list populates filterable picker; the existing shortcut map populates a searchable table. | |
| 265 | + | ||
| 266 | + | --- | |
| 267 | + | ||
| 268 | + | ## Implementation note — Critical batch (2026-05-20) | |
| 269 | + | ||
| 270 | + | All three Critical items shipped. Build clean across `audiofiles-core`, | |
| 271 | + | `audiofiles-browser`, `audiofiles-app`; 201 + 439 + 44 tests pass; | |
| 272 | + | design-system gates all return zero output. | |
| 273 | + | ||
| 274 | + | **C-1 — Edit preview + Replace mode warning.** Shipped both parts the audit | |
| 275 | + | called for, modulo the underlying constraint: | |
| 276 | + | ||
| 277 | + | - *Trim preview overlay* on the waveform. `theme::trim_mute_overlay()` returns | |
| 278 | + | a semi-opaque dark fill; `edit_panel.rs::draw_waveform_section` paints it | |
| 279 | + | over the regions outside `[trim_start, trim_end]` after the waveform | |
| 280 | + | renders. Yellow boundary lines mark the cut points so very thin trims stay | |
| 281 | + | legible. Updates live as the user drags the sliders. | |
| 282 | + | - *Replace mode warning* in the Result section (substituted for the audit's | |
| 283 | + | proposed "Last edit · Undo" inline link). Edit operations don't push to the | |
| 284 | + | global undo stack — they write through `backend.start_edit` and the | |
| 285 | + | worker commits to the SampleStore with no snapshot. Implementing true | |
| 286 | + | per-edit undo would need backend snapshot support that doesn't exist | |
| 287 | + | today. The yellow warning under the Replace radio surfaces the | |
| 288 | + | irreversibility explicitly: *"Replace mode: original content is | |
| 289 | + | overwritten. Switch to Create sibling to keep the original."* | |
| 290 | + | - Deferred: true inline Undo. Tracking the gap under M-11 (in-progress | |
| 291 | + | cancel) as the next-most-actionable forgiveness item on the edit panel. | |
| 292 | + | ||
| 293 | + | **C-2 — Unsafe-mode Locate affordance.** New `relocate_missing_unsafe(db, | |
| 294 | + | search_root) -> Result<(usize, usize)>` in | |
| 295 | + | `audiofiles_core::store`. Walks `search_root`, builds a basename map (lower- | |
| 296 | + | cased), filters by recorded `file_size` to skip same-name-different-file | |
| 297 | + | collisions, hash-verifies each remaining candidate, and updates `source_path` | |
| 298 | + | on match. Returns `(relocated, still_missing)`. | |
| 299 | + | ||
| 300 | + | Wired through `Backend::relocate_missing_unsafe`, | |
| 301 | + | `DirectBackend::relocate_missing_unsafe`, and a new state method | |
| 302 | + | `BrowserState::locate_missing_unsafe(path)` that drives the dialog state: | |
| 303 | + | posts a status with the per-pass result, decrements `unsafe_missing_count`, | |
| 304 | + | and only auto-closes the warning when all samples are relocated (so the user | |
| 305 | + | can run Locate again against a different folder if some are still missing). | |
| 306 | + | ||
| 307 | + | `overlays.rs::draw_unsafe_warning` rebuilt as a custom three-button modal | |
| 308 | + | (Cancel / Locate / Purge) — the existing two-button `confirm_modal` couldn't | |
| 309 | + | host a third action. Cancel and Purge keep their prior semantics; the new | |
| 310 | + | *"Locate missing files..."* button opens `rfd::FileDialog::pick_folder()` and | |
| 311 | + | dispatches the new state method. Copy now explicitly names what Purge takes: | |
| 312 | + | *"Tags, analysis results, and history for these samples will be permanently | |
| 313 | + | deleted by Purge."* | |
| 314 | + | ||
| 315 | + | **C-3 — Modal forms keep open on backend error.** `widgets::name_modal` | |
| 316 | + | gained an `error: Option<&str>` parameter — surfaces a red inline error | |
| 317 | + | below the input and re-focuses the input so the user can edit and retry | |
| 318 | + | without re-typing. New `BrowserState::name_modal_error: Option<String>` | |
| 319 | + | carries the error across frames. All four `name_modal` callsites in | |
| 320 | + | `overlays.rs` (`draw_vfs_create_modal`, `draw_vfs_rename_modal`, | |
| 321 | + | `draw_dir_create_modal`, `draw_dir_rename_modal`) rewritten to keep the | |
| 322 | + | modal open on backend failure and only close on success / empty submit / | |
| 323 | + | explicit Cancel. The error field clears on every close path. | |
| 324 | + | ||
| 325 | + | Bonus consistency fix in the rename callsites: replaced | |
| 326 | + | `state.vfs_rename_target.take().unwrap()` with a non-consuming | |
| 327 | + | `.as_ref().map(...)` pattern, so the modal still has its target if the | |
| 328 | + | backend call fails (otherwise the next-frame render couldn't even draw the | |
| 329 | + | modal). | |
| 330 | + | ||
| 331 | + | **Files touched:** | |
| 332 | + | - `audiofiles-core/src/store.rs` — new `relocate_missing_unsafe`. | |
| 333 | + | - `audiofiles-browser/src/backend/{mod,direct}.rs` — `relocate_missing_unsafe` | |
| 334 | + | trait method + impl. | |
| 335 | + | - `audiofiles-browser/src/state/{library,mod}.rs` — `locate_missing_unsafe` | |
| 336 | + | state method, `name_modal_error` field. | |
| 337 | + | - `audiofiles-browser/src/ui/widgets.rs` — `name_modal` gains `error` parameter. | |
| 338 | + | - `audiofiles-browser/src/ui/theme.rs` — `trim_mute_overlay` helper. | |
| 339 | + | - `audiofiles-browser/src/ui/overlays.rs` — unsafe-warning rebuilt; four | |
| 340 | + | name-modal callsites updated. | |
| 341 | + | - `audiofiles-browser/src/ui/edit_panel.rs` — trim overlay + Replace warning. | |
| 342 | + | ||
| 343 | + | No state-shape regressions. `name_modal` is a backwards-incompatible | |
| 344 | + | signature change (added a parameter) — every callsite updated in the same | |
| 345 | + | batch. | |
| 346 | + | ||
| 347 | + | --- | |
| 348 | + | ||
| 349 | + | --- | |
| 350 | + | ||
| 351 | + | ## Implementation note — Major batch (2026-05-20) | |
| 352 | + | ||
| 353 | + | All 14 Major items shipped (M-10 closed as part of C-1's trim overlay). | |
| 354 | + | Build clean across `audiofiles-core`, `audiofiles-browser`, `audiofiles-app`; | |
| 355 | + | 201 + 439 + 44 tests pass; design-system gates all return zero output. | |
| 356 | + | ||
| 357 | + | **detail.rs:** | |
| 358 | + | - **M-1** — Tag suggestion dismiss gained inline Undo. New | |
| 359 | + | `BrowserState::last_dismissed_suggestion: Option<(class, tag, Instant)>` | |
| 360 | + | recorded by `dismiss_suggestion`. New `undo_last_dismissal()` pops the entry | |
| 361 | + | from `dismissed_suggestions` and persists. The detail panel renders a muted | |
| 362 | + | *"Muted '<tag>' for <class>. Undo"* line below the suggestion strip, | |
| 363 | + | visible for 5 seconds after the dismiss. `ui.ctx().request_repaint()` | |
| 364 | + | ensures the affordance fades when the timer crosses the window. | |
| 365 | + | ||
| 366 | + | **overlays.rs:** | |
| 367 | + | - **M-2** — Help shortcuts tab gained section headers (Navigation / | |
| 368 | + | Selection / Bulk / Search / Discovery / Toggles / System) and a substring | |
| 369 | + | filter input. New `BrowserState::help_shortcut_search` field. Empty groups | |
| 370 | + | hide when the filter excludes them. | |
| 371 | + | - **M-3** — Features tab shortcut references converted to `ui.link` | |
| 372 | + | widgets that close help and dispatch the action: `[/]` → focus search; | |
| 373 | + | `[Cmd+T]` → open bulk tag modal; `[I]` → toggle MIDI window; `[E]` → open | |
| 374 | + | edit window for the selected sample. Other shortcuts stay plain text. | |
| 375 | + | - **M-4** — Bulk tag modal autocomplete: substring-matched chips from | |
| 376 | + | `state.all_tags`, capped at 12, hidden when empty. Click replaces the | |
| 377 | + | input (modal is single-tag). | |
| 378 | + | - **M-5** — Remove-tag mode disables Apply when the typed tag isn't in | |
| 379 | + | `all_tags` (cheap proxy for "no selected sample has this tag"). The | |
| 380 | + | alternative — calling `get_sample_tags` per selected sample — is O(N) per | |
| 381 | + | frame and would scale poorly at 1000-row selections. Trade-off documented | |
| 382 | + | in code. Hand-rendered action row since `confirm_action_row` has no | |
| 383 | + | disabled flag. | |
| 384 | + | - **M-6** — Bulk move modal gained a folder filter input. New | |
| 385 | + | `BrowserState::bulk_move_filter`. Case-insensitive substring match. Root | |
| 386 | + | entry hidden while filter is non-empty. Resets on close + execute. | |
| 387 | + | - **M-7** — Bulk rename preview capped at 50 rows; muted *"...and N more"* | |
| 388 | + | line below. Underlying preview list untouched — the rename uses the full | |
| 389 | + | list. | |
| 390 | + | - **M-8** — Bulk rename preview flags duplicate output names in | |
| 391 | + | `accent_yellow` with hover text *"Duplicate output name — rename will | |
| 392 | + | collide on commit"*. Single-pass `HashMap<&str, usize>` count. | |
| 393 | + | - **M-9** — Import preflight gained *"Don't ask again for folders this | |
| 394 | + | size"* checkbox. Persists via `backend.set_config("import_preflight_disabled", | |
| 395 | + | "1")` and mirrors into `BrowserState::import_preflight_disabled` (loaded | |
| 396 | + | in `new()` from config). `quick_import_folder` in `state/import_workflow.rs` | |
| 397 | + | bypasses preflight when the flag is set. Transient | |
| 398 | + | `BrowserState::preflight_dont_ask` for the checkbox state, reset on every | |
| 399 | + | close. Modal hand-rendered (not `confirm_modal`) because the disabled C-2 | |
| 400 | + | pattern's three-button shape was already proven. | |
| 401 | + | ||
| 402 | + | **edit_panel.rs:** | |
| 403 | + | - **M-11** — In-progress edit gained a Cancel button below the spinner. | |
| 404 | + | New `state.cancel_edit_operation()` calls `backend.cancel_edit()`, clears | |
| 405 | + | `edit.in_progress`, posts *"Edit cancelled."*. Best-effort: the worker may | |
| 406 | + | still be mid-write when the signal arrives. | |
| 407 | + | - **M-12** — Result prompt gained a third *"Discard edit"* button. New | |
| 408 | + | `state.discard_edit_result()` drops `edit.pending_result` (also removes | |
| 409 | + | the temp file on disk), clears `edit.result_prompt`, posts *"Edit result | |
| 410 | + | discarded."*. Closes the trap-state the prompt was previously without an | |
| 411 | + | exit from. | |
| 412 | + | - **M-13** — Normalize Peak/LUFS toggle now resets `norm_target` on mode | |
| 413 | + | change: Peak default `-1.0` dBFS, LUFS default `-14.0` LUFS. Only fires | |
| 414 | + | on actual mode flip — user-set values within a single mode are preserved. | |
| 415 | + | - **M-14** — Batch buttons rewritten to bake values into the label: | |
| 416 | + | *"Normalize {N} samples to {target} dBFS"*, *"Apply {gain_db} dB to {N} | |
| 417 | + | samples"*, etc. Redundant `on_hover_text` calls dropped. Reverse stays | |
| 418 | + | simple (parameterless). | |
| 419 | + | ||
| 420 | + | **Bonus fix:** `db::tests::migration_sets_user_version` / | |
| 421 | + | `migration_is_idempotent` asserted user_version 16 but a 17th migration | |
| 422 | + | existed pre-batch (`MIGRATION_017` at `db.rs:669`). Updated both asserts to | |
| 423 | + | 17 — stale test, not a behaviour change. Caught by the overlays agent's | |
| 424 | + | verification pass; would have surfaced on any subsequent core-crate test | |
| 425 | + | run. | |
| 426 | + | ||
| 427 | + | **Files touched:** | |
| 428 | + | - `audiofiles-browser/src/state/{mod,library,import_workflow}.rs` — new | |
| 429 | + | fields (`last_dismissed_suggestion`, `help_shortcut_search`, | |
| 430 | + | `bulk_move_filter`, `import_preflight_disabled`, `preflight_dont_ask`), | |
| 431 | + | `undo_last_dismissal`, `cancel_edit_operation`, `discard_edit_result`, | |
| 432 | + | preflight bypass in `quick_import_folder`. | |
| 433 | + | - `audiofiles-browser/src/ui/{detail,overlays,edit_panel}.rs` — the | |
| 434 | + | surface work. | |
| 435 | + | - `audiofiles-core/src/db.rs` — migration-count test asserts. | |
| 436 | + | ||
| 437 | + | No new public Backend APIs. Phase 7 Major closed. | |
| 438 | + | ||
| 439 | + | --- | |
| 440 | + | ||
| 441 | + | ## Implementation note — Minor + Polish batch (2026-05-20) | |
| 442 | + | ||
| 443 | + | Phase 7 Minor + Polish swept in parallel with Phase 6 Minor + Polish in a | |
| 444 | + | single agent pass. Build clean, 201 + 439 + 44 tests pass, all five | |
| 445 | + | design-system gates return zero. | |
| 446 | + | ||
| 447 | + | **overlays.rs** | |
| 448 | + | - **m-1** — `SwitchLibrary` confirm arm bumped to `danger: true`; | |
| 449 | + | affordance now matches the in-flight-work warning copy. | |
| 450 | + | - **m-2** — Shortcut table audited row by row. All entries already used | |
| 451 | + | slashes for alternatives (`j / Down`) and `+` for chords (`Cmd+T`). | |
| 452 | + | No row corrections needed; verified during p-5 restructuring. | |
| 453 | + | - **m-3** — Bulk move modal root entry renders bare *"/"* (dropped | |
| 454 | + | *"(root)"* suffix). | |
| 455 | + | - **m-4** — New vault hint replaced with *"A vault is a separate sample | |
| 456 | + | collection \u{2014} like a folder, but with its own tags and analysis. | |
| 457 | + | Right-click inside to create sub-folders."*. | |
| 458 | + | - **m-5** — Rename vault modal hint: *"Vault names can contain spaces."*. | |
| 459 | + | - **m-6** — Dir create modal hint: *"Folder names cannot contain /"*. | |
| 460 | + | - **m-11** — Private `format_bytes` deleted; preflight calls | |
| 461 | + | `widgets::format_bytes`. | |
| 462 | + | - **m-12** — Help dialog tabs now use `widgets::toggle_pills` instead of | |
| 463 | + | `selectable_value` pair. `Option<u8>` return wired to `state.help_tab`. | |
| 464 | + | - **m-15** — Confirm dialog titles lifted alongside `confirm_label` per | |
| 465 | + | variant: *Delete vault* / *Delete collection* / *Remove tag* / *Switch | |
| 466 | + | library* / *Disconnect sync* / *Remove samples* / *Re-analyze*. Match | |
| 467 | + | tuple widened to 5-arity. | |
| 468 | + | - **p-4** — Cmd+Enter (`modifiers.command` is platform-aware) bound to | |
| 469 | + | the primary action on all three bulk modals (tag, move, rename), gated | |
| 470 | + | by the same `enabled` flags as the buttons. Name modals already | |
| 471 | + | submit on Enter — left alone. | |
| 472 | + | - **p-5** — `cmd_key()` helper returns *"Cmd"* on macOS, *"Ctrl"* | |
| 473 | + | elsewhere. Shortcut table chord strings built via | |
| 474 | + | `format!("{cmd}+...")`; features-tab *"Cmd+T"* link also routed through | |
| 475 | + | the helper. Group rows changed from `&[(&str, &str)]` to owned | |
| 476 | + | `(String, &str)` to carry the formatted chords. | |
| 477 | + | - Skipped per audit: **m-14** (token chip cursor insert — matches | |
| 478 | + | export), **p-1** (screenshots — defer), **p-3** (diff highlight — | |
| 479 | + | defer). | |
| 480 | + | ||
| 481 | + | **edit_panel.rs** | |
| 482 | + | - **m-7** — Info-line separators converted to middle dot `\u{00B7}`; | |
| 483 | + | dropped the leading double-space. | |
| 484 | + | - **m-8** — Fade duration slider raised to `10..=10000` ms; added | |
| 485 | + | `on_hover_text("Maximum fade duration 10s")`. | |
| 486 | + | - **m-9** — Silence Insert/Remove DragValues clamped to `[0, | |
| 487 | + | sample_duration_ms]` (derived from `selected_analysis.duration * | |
| 488 | + | 1000`); falls back to `f64::MAX` only when analysis is unavailable. | |
| 489 | + | Insert *Duration* untouched (already capped at 60000). | |
| 490 | + | - **m-10** — Result-prompt *"Remember my choice"* checkbox now | |
| 491 | + | initialises from `state.edit.result_mode.is_some()`, surfacing whether | |
| 492 | + | a persistent default already exists. | |
| 493 | + | - **m-13** — *"Batch Edit"* heading dropped `accent_blue`; instead a | |
| 494 | + | muted *"Batch \u{00B7} N samples"* badge renders to the right of the | |
| 495 | + | plain-strong heading. | |
| 496 | + | - **m-16** — Batch Reverse on >10 samples routes through a new | |
| 497 | + | `ConfirmAction::ReverseSamples { count }` variant (added in | |
| 498 | + | `state/ui.rs`, dispatched from `state/bulk_ops.rs::execute_confirmed_action`, | |
| 499 | + | rendered as a danger confirm in `overlays.rs::draw_confirm_dialog` | |
| 500 | + | under the m-15 5-tuple shape). ≤10 keeps the direct path. |
Lines truncated
| @@ -0,0 +1,325 @@ | |||
| 1 | + | # UX Audit: audiofiles — Consolidation Pre-Plan | |
| 2 | + | ||
| 3 | + | **Date:** 2026-05-19 | |
| 4 | + | **Status:** Pre-Phase-1 deliverable. Surface audits (Phases 1–7) do not start until the items in this plan land. | |
| 5 | + | **Inputs:** `docs/ux-audit/phase-0.md`, `docs/design-system.md`. | |
| 6 | + | **Goal:** From the Phase 0 divergence map, pick one canonical helper per pattern, sequence the migration, and define hard success criteria the consolidation can be checked against. | |
| 7 | + | ||
| 8 | + | This document is normative: PRs introducing UI changes during the consolidation window must reference the relevant section by anchor (`#R-04`, etc.) and either use the canonical helper or update this plan. | |
| 9 | + | ||
| 10 | + | --- | |
| 11 | + | ||
| 12 | + | ## Gate scope (updated 2026-05-19 after Phase 1) | |
| 13 | + | ||
| 14 | + | These gates apply to **every UI-bearing crate**, not just `audiofiles-browser`. Today that means `crates/audiofiles-{app,browser}/src` — the activation and vault-setup screens in `audiofiles-app` are the user's first surface and must follow the same design system. If a new UI-bearing crate is added later, extend the gate globs to cover it in the same PR. | |
| 15 | + | ||
| 16 | + | ## How to use this plan | |
| 17 | + | ||
| 18 | + | - Batches are ordered by **dependency**, not effort. Batch 0 (tokens) must land before any widget that reads them; Batch 1 (modal scaffold) before any modal migration; Batch 2 (selectable_row) before any panel that lists items; etc. | |
| 19 | + | - Each batch lists: **build** (new code in `widgets.rs` / `theme.rs`), **migrate** (the call sites to rewrite), and **success criteria** (a `grep` or count that becomes the merge gate). | |
| 20 | + | - Within a batch, the migrations can land as one PR or several — but the batch's success criteria must hold before the next batch starts. | |
| 21 | + | - No surface-audit work (Phase 1–7) starts while batches 0–6 are open. | |
| 22 | + | ||
| 23 | + | --- | |
| 24 | + | ||
| 25 | + | ## Batch 0 — Spacing and token plumbing (`#R-00`) | |
| 26 | + | ||
| 27 | + | **Build (`theme.rs`):** | |
| 28 | + | ||
| 29 | + | ```rust | |
| 30 | + | pub mod space { | |
| 31 | + | pub const XS: f32 = 2.0; | |
| 32 | + | pub const SM: f32 = 4.0; | |
| 33 | + | pub const MD: f32 = 8.0; | |
| 34 | + | pub const LG: f32 = 12.0; | |
| 35 | + | pub const SECTION: f32 = 16.0; | |
| 36 | + | pub const XL: f32 = 20.0; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | pub mod stroke { | |
| 40 | + | pub const THIN: f32 = 0.5; | |
| 41 | + | pub const DEFAULT: f32 = 1.0; | |
| 42 | + | pub const FOCUS: f32 = 1.5; | |
| 43 | + | } | |
| 44 | + | ``` | |
| 45 | + | ||
| 46 | + | Move `window_margin` (currently hardcoded `10.0`) and `indent` (`18.0`) into `ThemeColors` with TOML override hooks, consistent with the other spacing fields. | |
| 47 | + | ||
| 48 | + | **Migrate:** | |
| 49 | + | ||
| 50 | + | - Replace every `ui.add_space(N.0)` outside `widgets.rs` and `theme.rs` with the matching `space::*` constant. | |
| 51 | + | - Outliers (today: `add_space(2.0)` × 8, `add_space(6.0)` × 4, `add_space(20.0)` × 1) get normalised: 2.0 → `XS`, 6.0 → either `SM` or `MD` per call-site review, 20.0 → `XL`. | |
| 52 | + | ||
| 53 | + | **Success criteria:** | |
| 54 | + | ||
| 55 | + | - `grep -rE 'add_space\([0-9]' crates/audiofiles-{app,browser}/src | grep -v 'space::' | grep -v widgets.rs | grep -v theme.rs` returns zero matches. | |
| 56 | + | - `grep -rE 'Color32::(from_rgb|WHITE|BLACK|GRAY|RED|GREEN|BLUE|YELLOW|DARK_GRAY|TRANSPARENT)' crates/audiofiles-{app,browser}/src | grep -v theme.rs` returns ≤ 1 match (the existing `edit_panel.rs` use, to be reviewed during Batch 5). | |
| 57 | + | - Builds with no new warnings; visual diff against the current themes shows zero pixel change for the audiofiles theme baseline (this batch is purely a refactor). | |
| 58 | + | ||
| 59 | + | --- | |
| 60 | + | ||
| 61 | + | ## Batch 1 — Modal scaffold and confirm flow (`#R-01`) | |
| 62 | + | ||
| 63 | + | The four name-input modals (`vfs_create`, `vfs_rename`, `dir_create`, `dir_rename`) already share `overlays.rs::name_modal`; this batch lifts that intent across every modal. | |
| 64 | + | ||
| 65 | + | **Build (`widgets.rs`):** | |
| 66 | + | ||
| 67 | + | - `modal_window(ctx, title, body)` — wraps `Window::new(title).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0])` with consistent inner padding (`space::MD`). | |
| 68 | + | - `confirm_modal(ctx, ConfirmModalSpec { title, body, confirm_label, danger, on_confirm, on_cancel })`. When `danger == true` the confirm button uses `danger_button` (from Batch 4); otherwise `primary_button`. | |
| 69 | + | - Lift `name_modal` from `overlays.rs` into `widgets.rs` unchanged. Its callers are unaffected. | |
| 70 | + | - `modal_window` and `confirm_modal` share an internal helper for the bottom `[Confirm] [Cancel]` action row so action ordering and spacing are consistent. | |
| 71 | + | ||
| 72 | + | **Migrate (`overlays.rs`):** | |
| 73 | + | ||
| 74 | + | | Site | Migration | | |
| 75 | + | |-----------------------------------|------------------------------------------------------------------------| | |
| 76 | + | | `draw_help_overlay` | `modal_window(ctx, "audiofiles", …)` — `open` flag stays on state. | | |
| 77 | + | | `draw_confirm_dialog` | `confirm_modal(ctx, { title: "Confirm", body, confirm_label: "Delete", danger: true, … })`. | | |
| 78 | + | | `draw_unsafe_warning` | `confirm_modal(ctx, { …, confirm_label: "Purge missing samples", danger: true, … })`. Routes through the same scaffold as delete. | | |
| 79 | + | | `draw_bulk_tag_modal` | Body inside `modal_window(ctx, "Bulk Tag", …)`; action row from helper.| | |
| 80 | + | | `draw_bulk_move_modal` | Same. | | |
| 81 | + | | `draw_bulk_rename_modal` | `modal_window` with explicit `resizable(true)` override (only resizable modal in the app — keep that exception narrow). | | |
| 82 | + | ||
| 83 | + | **Success criteria:** | |
| 84 | + | ||
| 85 | + | - `grep -rE 'Window::new\(' crates/audiofiles-{app,browser}/src | grep -v widgets.rs` returns zero matches. | |
| 86 | + | - `grep -rE 'anchor\(.*CENTER_CENTER' crates/audiofiles-{app,browser}/src | grep -v widgets.rs` returns zero matches. | |
| 87 | + | - All four name modals continue to work without behavioural change (manual smoke test: create vault, rename vault, create folder, rename folder). | |
| 88 | + | - Existing `draw_unsafe_warning` flow now uses the same scaffold as delete — confirms the unification. | |
| 89 | + | ||
| 90 | + | --- | |
| 91 | + | ||
| 92 | + | ## Batch 2 — Selectable row primitive (`#R-02`) | |
| 93 | + | ||
| 94 | + | **Build (`widgets.rs`):** | |
| 95 | + | ||
| 96 | + | - `selectable_row(ui, active, label) -> egui::Response` — wraps `selectable_label` with the canonical "active = `RichText::strong().color(accent_blue())`, inactive = `RichText::color(text_primary())`" styling. | |
| 97 | + | - `selectable_row_secondary(ui, active, label) -> Response` — variant whose inactive colour is `text_secondary()` (for tag tree leaves, collection rows, sort headers). | |
| 98 | + | - `tree_node(ui, label, active, has_active_descendant, |ui| { … children … })` — the `CollapsingHeader` wrapper used by the sidebar tag tree, with the "active OR has_active_descendant → accent_blue" colour rule baked in. | |
| 99 | + | ||
| 100 | + | **Migrate:** | |
| 101 | + | ||
| 102 | + | | Site | Helper | | |
| 103 | + | |-----------------------------------|-------------------------| | |
| 104 | + | | `sidebar.rs:70–87` (tag leaf) | `selectable_row_secondary` | | |
| 105 | + | | `sidebar.rs:102–119` (tag self) | `selectable_row_secondary` | | |
| 106 | + | | `sidebar.rs:88–127` (tag folder) | `tree_node` | | |
| 107 | + | | `sidebar.rs:200–212` (VFS row) | `selectable_row` | | |
| 108 | + | | `sidebar.rs:247–268` (collection) | `selectable_row_secondary` | | |
| 109 | + | | `toolbar.rs:286–298` (breadcrumb) | `selectable_row` | | |
| 110 | + | | `file_list.rs:553–578` (sort header) | `selectable_row_secondary` (drop the manual arrow-suffix logic into the helper as an optional arg) | | |
| 111 | + | ||
| 112 | + | **Success criteria:** | |
| 113 | + | ||
| 114 | + | - `grep -rE 'selectable_label\([^,]+,\s*(egui::)?RichText::new' crates/audiofiles-{app,browser}/src | grep -v widgets.rs` returns zero matches. | |
| 115 | + | - `grep -rE '\.strong\(\)\.color\(theme::accent_blue\(\)\)' crates/audiofiles-{app,browser}/src | grep -v widgets.rs` returns zero matches. | |
| 116 | + | - Sidebar, breadcrumb, and sort-header click behaviour unchanged (manual smoke test). | |
| 117 | + | ||
| 118 | + | --- | |
| 119 | + | ||
| 120 | + | ## Batch 3 — Section header and filter-section helpers (`#R-03`) | |
| 121 | + | ||
| 122 | + | **Build (`widgets.rs`):** | |
| 123 | + | ||
| 124 | + | - `section_header(ui, "Vaults")` — `.strong()` `text_secondary()` label, followed by `ui.separator()` and `ui.add_space(space::SM)`. | |
| 125 | + | - `subsection_label(ui, "Save as Collection")` — same colour, no separator. For sub-blocks (e.g. inside an already-headed panel). | |
| 126 | + | - `filter_section(ui, label, active, |ui| { … })` — wraps `CollapsingHeader::new(if active { "{label} *" } else { label }).default_open(active)`. | |
| 127 | + | ||
| 128 | + | **Migrate:** | |
| 129 | + | ||
| 130 | + | | Site | Helper | | |
| 131 | + | |---------------------------------------|-----------------------| | |
| 132 | + | | `sidebar.rs:175` ("Vaults") | `section_header` | | |
| 133 | + | | `filter_panel.rs:10` ("Filters") | `section_header` | | |
| 134 | + | | `filter_panel.rs:178` ("Save as Coll.") | `subsection_label` | | |
| 135 | + | | `detail.rs:152` ("Tags") | `subsection_label` | | |
| 136 | + | | `filter_panel.rs:19–148` (× 5 collapsing sections) | `filter_section` | | |
| 137 | + | ||
| 138 | + | **Success criteria:** | |
| 139 | + | ||
| 140 | + | - `filter_panel.rs` line count drops by ≥ 30 lines (today: 206 lines; target ≤ 170). | |
| 141 | + | - `grep -rE 'CollapsingHeader::new\(' crates/audiofiles-{app,browser}/src | grep -v widgets.rs` returns at most 1 match (the sidebar Collections/Tags collapsing — case-by-case decision: keep as `ui.collapsing(...)` since it's not the `* `-marker pattern). | |
| 142 | + | - Active-marker behaviour (`"BPM Range *"`) preserved on all five filter sections. | |
| 143 | + | ||
| 144 | + | --- | |
| 145 | + | ||
| 146 | + | ## Batch 4 — Button hierarchy (`#R-04`) | |
| 147 | + | ||
| 148 | + | This is the biggest *visual* shift in the consolidation. Today every button has equal weight; afterwards Cancel is visually subordinate to Apply, and Delete/Purge are visually distinct. | |
| 149 | + | ||
| 150 | + | **Build (`widgets.rs`):** | |
| 151 | + | ||
| 152 | + | - `primary_button(ui, label) -> Response` — bold weight, accent_blue fill (or stroke — to be decided during impl; pick the recipe that survives all 28 themes). | |
| 153 | + | - `secondary_button(ui, label) -> Response` — the current default. This is the new home for `ui.button(...)` calls that aren't primary or danger. | |
| 154 | + | - `danger_button(ui, label) -> Response` — `accent_red` text (or fill, same call as primary). Mandatory for destructive actions. | |
| 155 | + | ||
| 156 | + | **Migrate (action-row audit):** | |
| 157 | + | ||
| 158 | + | Every site that pairs `[Apply / Save / Delete] + [Cancel]` gets re-classified: | |
| 159 | + | ||
| 160 | + | | Site | Primary | Danger | Cancel | | |
| 161 | + | |----------------------------------------|------------------------|--------------------|---------------| | |
| 162 | + | | `overlays.rs::draw_confirm_dialog` | — | "Delete" | "Cancel" | | |
| 163 | + | | `overlays.rs::draw_unsafe_warning` | — | "Purge missing samples" | "Dismiss" | | |
| 164 | + | | `overlays.rs::draw_bulk_tag_modal` | "Apply" | — | "Cancel" | | |
| 165 | + | | `overlays.rs::draw_bulk_move_modal` | "Move" | — | "Cancel" | | |
| 166 | + | | `overlays.rs::draw_bulk_rename_modal` | "Rename" | — | "Cancel" | | |
| 167 | + | | `overlays.rs::name_modal` | dynamic ("Create"/"Save") | — | "Cancel" | | |
| 168 | + | | `toolbar.rs:120` ("Save Collection") | "Save Collection" | — | (popup close) | | |
| 169 | + | | `file_list.rs:145` ("Clear Filters") | `secondary_button` | — | — | | |
| 170 | + | | `sidebar.rs:227` ("+ New Vault") | `secondary_button` | — | — | | |
| 171 | + | ||
| 172 | + | **Success criteria:** | |
| 173 | + | ||
| 174 | + | - Every modal's confirmation flow goes through one of the three button helpers; `grep -rE '\.button\(' crates/audiofiles-{app,browser}/src/overlays.rs` returns zero direct calls (the bulk-rename token chips remain `small_button` — that's a separate `chip_button` recipe to revisit in Batch 6). | |
| 175 | + | - Delete/Purge buttons render in `accent_red` across all 28 bundled themes (manual visual check — switch theme dropdown). | |
| 176 | + | - No regression in keyboard activation: Enter inside a modal still triggers the primary action. | |
| 177 | + | ||
| 178 | + | --- | |
| 179 | + | ||
| 180 | + | ## Batch 5 — Toolbar toggle and toggle pills (`#R-05`) | |
| 181 | + | ||
| 182 | + | **Build (`widgets.rs`):** | |
| 183 | + | ||
| 184 | + | - `toolbar_toggle(ui, label, active, tooltip) -> bool` — internally: `ui.button(RichText::new(label).color(if active { accent_blue() } else { text_muted() })).on_hover_text(tooltip)`. Optional `count: Option<usize>` argument for the filter-panel-style "(N)" suffix. | |
| 185 | + | - `toggle_pills<T>(ui, current: &mut T, options: &[(T, &str, &str)])` — mutually-exclusive `selectable_label` group. `T: Copy + PartialEq`. | |
| 186 | + | ||
| 187 | + | **Migrate:** | |
| 188 | + | ||
| 189 | + | | Site | Helper | | |
| 190 | + | |-----------------------------------|-------------------------------------------------------| | |
| 191 | + | | `toolbar.rs:138–145` (sidebar) | `toolbar_toggle` | | |
| 192 | + | | `toolbar.rs:147–153` (detail) | `toolbar_toggle` | | |
| 193 | + | | `toolbar.rs:156–169` (editor) | `toolbar_toggle` | | |
| 194 | + | | `toolbar.rs:171–177` (instrument) | `toolbar_toggle` | | |
| 195 | + | | `toolbar.rs:180–187` (loop) | `toolbar_toggle` | | |
| 196 | + | | `toolbar.rs:189–205` (filter, with count) | `toolbar_toggle` (with `count: Some(N)`) | | |
| 197 | + | | `toolbar.rs:64–83` (Folder/All) | `toggle_pills` | | |
| 198 | + | | `filter_panel.rs:109–127` (Exact/Compatible) | `toggle_pills` | | |
| 199 | + | | `overlays.rs:264–268` (Add/Remove tag) | `toggle_pills` | | |
| 200 | + | | `overlays.rs:17–20` (help tabs) | `toggle_pills` | | |
| 201 | + | ||
| 202 | + | **Side-effect:** The brand-rule emoji glyph problem (CLAUDE.md "no emoji in UI copy") gets cleaned up *during this batch*, because every emoji-bearing string flows through `toolbar_toggle`'s `label` argument. Each call site swaps the glyph for a word at migration time. See `#R-08` for the project-wide enforcement check. | |
| 203 | + | ||
| 204 | + | **Success criteria:** | |
| 205 | + | ||
| 206 | + | - `toolbar.rs` line count drops by ≥ 60 lines (today: 408; target ≤ 350). | |
| 207 | + | - Zero remaining inline `if active { accent_blue() } else { text_muted() }` ternaries in panel files. | |
| 208 | + | - `grep -rE 'u\{(1F[0-9A-Fa-f]{3}|2[0-9A-Fa-f]{3}|25[0-9A-Fa-f]{2})\}' crates/audiofiles-{app,browser}/src` — zero matches except in `widgets.rs` (any retained glyph must be in a documented exception list inside `widgets.rs`). | |
| 209 | + | ||
| 210 | + | --- | |
| 211 | + | ||
| 212 | + | ## Batch 6 — Empty state, inline submit, banner, status feedback (`#R-06`) | |
| 213 | + | ||
| 214 | + | **Build (`widgets.rs`):** | |
| 215 | + | ||
| 216 | + | - `empty_state(ui, EmptyState { heading, body, cta: Option<EmptyStateCta> })` — centered, vertical, `space::XL` top padding scaled by `ui.available_height() * 0.15`. Heading at 20 px `text_secondary`. Body at default size `text_muted`. CTA renders as `secondary_button`. | |
| 217 | + | - `inline_text_submit(ui, buf, opts: InlineSubmitOpts) -> SubmitOutcome` — text_edit with Enter-to-commit; optional Cancel button; optional `+` commit button. Returns `SubmitOutcome::{None, Submitted(String), Cancelled}`. | |
| 218 | + | - `info_banner(ui, body)` — frame with `bg_tertiary()`, `corner_radius(theme.rounding)`, `inner_margin(space::MD)`. | |
| 219 | + | - `metadata_grid(ui, id, rows)` — the detail-panel two-column grid recipe. | |
| 220 | + | - `preview_grid(ui, id, headers, rows)` — the bulk-rename old→new recipe; reused by future export dry-run. | |
| 221 | + | ||
| 222 | + | **Migrate:** | |
| 223 | + | ||
| 224 | + | | Site | Helper | | |
| 225 | + | |-----------------------------------------------|--------------------| | |
| 226 | + | | `file_list.rs:37–116` (welcome) | `empty_state` + an `extras` slot for the three numbered steps | | |
| 227 | + | | `file_list.rs:120–151` (no matches) | `empty_state` (CTA: "Clear Filters") | | |
| 228 | + | | `detail.rs:15–18` ("Select a sample") | `empty_state` | | |
| 229 | + | | `detail.rs:154` ("No tags") | inline muted label (kept — too small for `empty_state`) | | |
| 230 | + | | `sidebar.rs:238` ("No collections yet") | inline muted label (kept; document the exception) | | |
| 231 | + | | `sidebar.rs:346, 370` ("No tags yet" / "No matching tags") | inline muted label (kept) | | |
| 232 | + | | `sidebar.rs:292–308` (collection rename) | `inline_text_submit` | | |
| 233 | + | | `sidebar.rs:311–334` (collection create) | `inline_text_submit` | | |
| 234 | + | | `detail.rs:171–193` (tag add) | `inline_text_submit` (commit affordance: `+` button) | | |
| 235 | + | | `toolbar.rs:105–125` (save filter popup) | `inline_text_submit` (auto-focus on open) | | |
| 236 | + | | `sidebar.rs:180–195` (VFS banner) | `info_banner` | | |
| 237 | + | | `detail.rs:85–144` (metadata grid) | `metadata_grid` | | |
| 238 | + | | `overlays.rs:438–467` (bulk-rename preview) | `preview_grid` | | |
| 239 | + | ||
| 240 | + | **Out of scope for this batch (separately scheduled):** | |
| 241 | + | ||
| 242 | + | - `toast` / transient notification primitive — requires changes to `state` (queue + timer) and is best designed in concert with the surface audits (Phase 1 will surface the actual error-message gaps). Defer to post-consolidation. | |
| 243 | + | - `loading_spinner` / `busy_indicator` — same reasoning. Defer. | |
| 244 | + | ||
| 245 | + | **Success criteria:** | |
| 246 | + | ||
| 247 | + | - Every centered welcome/empty-screen in the app goes through `empty_state`. | |
| 248 | + | - `grep -rE 'text_edit_singleline|TextEdit::singleline' crates/audiofiles-{app,browser}/src | grep -v widgets.rs | grep -v filter_panel.rs` — at most 3 matches (filter_panel BPM/duration/loudness input is a `DragValue`, not text; tag-search input in sidebar may stay raw; bulk-rename pattern input may stay raw). Each remaining match documented with a one-line comment. | |
| 249 | + | - `info_banner` is used by ≥ 1 call site (today: 1; after Batch 6: still 1, but now via the helper — establishes the pattern for future banners). | |
| 250 | + | ||
| 251 | + | --- | |
| 252 | + | ||
| 253 | + | ## Cross-cutting checks (`#R-07`, `#R-08`) | |
| 254 | + | ||
| 255 | + | These checks run after **every** batch lands, and become permanent merge gates. | |
| 256 | + | ||
| 257 | + | ### `#R-07` — Token discipline | |
| 258 | + | ||
| 259 | + | ```bash | |
| 260 | + | # No Color32 literals outside theme.rs (one documented exception max). | |
| 261 | + | grep -rE 'Color32::(from_rgb|WHITE|BLACK|GRAY|RED|GREEN|BLUE|YELLOW|DARK_GRAY|TRANSPARENT)' \ | |
| 262 | + | crates/audiofiles-{app,browser}/src | grep -v theme.rs | |
| 263 | + | # Expected: 0 matches by end of Batch 5 (the existing edit_panel.rs use is reviewed and either moved to theme.rs or kept with a documented reason). | |
| 264 | + | ||
| 265 | + | # No raw add_space outside widgets.rs and theme.rs. | |
| 266 | + | grep -rE 'add_space\([0-9]' crates/audiofiles-{app,browser}/src \ | |
| 267 | + | | grep -v space:: | grep -v widgets.rs | grep -v theme.rs | |
| 268 | + | # Expected: 0 matches by end of Batch 0. | |
| 269 | + | ||
| 270 | + | # No inline Window::new outside widgets.rs. | |
| 271 | + | grep -rE 'Window::new\(' crates/audiofiles-{app,browser}/src | grep -v widgets.rs | |
| 272 | + | # Expected: 0 matches by end of Batch 1. | |
| 273 | + | ||
| 274 | + | # No inline strong+accent_blue selectable label. | |
| 275 | + | grep -rE '\.strong\(\)\.color\(theme::accent_blue\(\)\)' \ | |
| 276 | + | crates/audiofiles-{app,browser}/src | grep -v widgets.rs | |
| 277 | + | # Expected: 0 matches by end of Batch 2. | |
| 278 | + | ``` | |
| 279 | + | ||
| 280 | + | ### `#R-08` — Brand-rule emoji enforcement | |
| 281 | + | ||
| 282 | + | The brand rule (CLAUDE.md: "no checkmarks or emoji in UI copy") targets *decorative emoji and checkmark glyphs* — not typography or functional icons that have no word equivalent. The exception set is documented in the `widgets.rs` module-level doc comment; this gate enforces "no glyph outside that set." | |
| 283 | + | ||
| 284 | + | **Allowed glyphs** (the documented exception set in `widgets.rs`): | |
| 285 | + | ||
| 286 | + | - Typography: `U+2014` em-dash (—), `U+2192` right-arrow (→), `U+00B7` middle-dot (·), `U+2022` bullet (•). | |
| 287 | + | - Sort-direction arrows: `U+25B2` (▲), `U+25BC` (▼) — column-header affordance. | |
| 288 | + | - File-tree node-type prefixes: `U+1F4C1` folder (📁), `U+2601` cloud (☁), `U+1F50A` speaker (🔊) — to be revisited during the Phase 3 surface audit. | |
| 289 | + | ||
| 290 | + | **Gate:** zero matches for any glyph *outside* the allowed set: | |
| 291 | + | ||
| 292 | + | ```bash | |
| 293 | + | grep -rnoE 'u\{[0-9A-Fa-f]{4,5}\}' crates/audiofiles-{app,browser}/src \ | |
| 294 | + | | grep -vE 'u\{(2014|2192|00B7|2022|25B2|25BC|1F4C1|2601|1F50A)\}' | |
| 295 | + | # Expected: 0 matches. | |
| 296 | + | ``` | |
| 297 | + | ||
| 298 | + | The literal checkmark previously in `toolbar.rs` (`"Sync \u{2713}"`) was resolved during Batch 5 — the sync button is now a `toolbar_toggle` with word labels ("Sync", "Sync: 3 pending", "Sync: offline", "Syncing…"). | |
| 299 | + | ||
| 300 | + | If a new glyph genuinely has no word equivalent (rare), add it to the exception list in `widgets.rs`'s module doc comment *and* to the regex above, in the same PR. | |
| 301 | + | ||
| 302 | + | --- | |
| 303 | + | ||
| 304 | + | ## Sequencing summary | |
| 305 | + | ||
| 306 | + | | Batch | Name | Builds | Migrates | Gate | | |
| 307 | + | |-------|----------------------------------|-----------------------------------------------------------|---------------------------------------------------------|-----------------------------------------------------| | |
| 308 | + | | 0 | Spacing + token plumbing | `space::*`, `stroke::*`, `window_margin`/`indent` in `ThemeColors` | all `add_space(N.0)` call sites | `#R-07` add_space rule | | |
| 309 | + | | 1 | Modal scaffold + confirm | `modal_window`, `confirm_modal`; lift `name_modal` | all of `overlays.rs` | `#R-07` Window::new rule | | |
| 310 | + | | 2 | Selectable row + tree node | `selectable_row`, `selectable_row_secondary`, `tree_node` | sidebar, breadcrumb, sort header | `#R-07` strong+accent_blue rule | | |
| 311 | + | | 3 | Section headers + filter section | `section_header`, `subsection_label`, `filter_section` | filter_panel (× 5), sidebar, detail | `filter_panel.rs` ≤ 170 lines | | |
| 312 | + | | 4 | Button hierarchy | `primary_button`, `secondary_button`, `danger_button` | every modal action row | Delete/Purge render red across all 28 themes | | |
| 313 | + | | 5 | Toolbar toggle + toggle pills | `toolbar_toggle`, `toggle_pills` | toolbar × 6, plus 4 toggle-pill sites | `#R-08` emoji rule, `toolbar.rs` ≤ 350 lines | | |
| 314 | + | | 6 | Empty state, inline submit, banner, grids | `empty_state`, `inline_text_submit`, `info_banner`, `metadata_grid`, `preview_grid` | file_list, detail, sidebar inline rows, overlays preview | every welcome/empty screen routes through `empty_state` | | |
| 315 | + | | — | (Deferred to post-consolidation) | `toast`, `loading_spinner` / `busy_indicator` | — | designed alongside Phase 1 surface findings | | |
| 316 | + | ||
| 317 | + | --- | |
| 318 | + | ||
| 319 | + | ## Out-of-scope clarifications | |
| 320 | + | ||
| 321 | + | - **Branding direction** — egui has its own constraints; this plan does not attempt to make the app look like Linear / Stripe / Notion. Phase 7 (cross-cutting flat-design + theme conformance) is the appropriate place for that conversation. | |
| 322 | + | - **Accessibility deep-dive** — flagged in Phase 0 as out of scope; egui has its own a11y model. The brand-rule enforcement (`#R-08`) and the focus-ring token (`stroke::FOCUS`) cover the obvious gaps; a full a11y audit is a separate initiative. | |
| 323 | + | - **Behavioural fixes** — this plan rewrites *renderers*, not state machines. The single behavioural change is `draw_unsafe_warning` flowing through `confirm_modal` instead of its own Window — and even there, the underlying `purge_missing_unsafe` call is preserved verbatim. | |
| 324 | + | ||
| 325 | + | When all six batches and both cross-cutting gates are green, Phase 1 (Onboarding & setup audit) opens. |