max / audiofiles
23 files changed,
+0 insertions,
-6065 deletions
| @@ -1,200 +0,0 @@ | |||
| 1 | - | # audiofiles -- Audit History | |
| 2 | - | ||
| 3 | - | Full chronological audit log. See [audit_review.md](./audit_review.md) for current state. | |
| 4 | - | ||
| 5 | - | ## Changes Since Last Audit | |
| 6 | - | ||
| 7 | - | ### Sixteenth audit (2026-03-28, Run 12 cross-project) | |
| 8 | - | - **Test count:** 611. 0 clippy warnings. 0 failures. | |
| 9 | - | - **Grade:** A (maintained). v0.3.0. | |
| 10 | - | - **No code changes since ML classifier (2026-03-26).** | |
| 11 | - | - **New dependency advisory:** rustls-webpki 0.103.9 (RUSTSEC-2026-0049) — upgrade to 0.103.10 via `cargo update -p rustls-webpki`. | |
| 12 | - | - **Mandatory surprise:** None. Previous surprises (sync/service.rs zero production unwraps, applying_remote crash recovery) all resolved. | |
| 13 | - | - **No new code findings.** All previous items remain resolved. | |
| 14 | - | ||
| 15 | - | ### Two-Layer ML Classifier (2026-03-26) | |
| 16 | - | - **Test count:** 585 -> 610 (+25). 0 clippy warnings. | |
| 17 | - | - **DB version:** v10 -> v11 (migration 011: `classification_confidence REAL` column on `audio_analysis`, sync triggers updated). | |
| 18 | - | - **New module:** `analysis/mfcc.rs` — MFCC feature extraction from existing STFT magnitudes. 26-band mel filterbank, DCT-II, 13 coefficients aggregated as mean+variance. 7 unit tests. | |
| 19 | - | - **Rewritten:** `analysis/classify.rs` — Two-layer ML classifier replacing rule-based `classify_full()`. Layer 1: rule-based broad classifier (Drum/Bass/Vocal/Synth/Pad/Fx/Noise/Music/Ambience/Impact/Foley/Texture). Layer 2: 200-tree Random Forest for drum sub-classification. New types: `BroadClass`, `ClassificationResult`, `TreeNode`, `RandomForestModel`. 35-feature vector (9 scalar + 26 MFCC). Model embedded via `include_bytes!` (7.4MB), lazy init via `OnceLock`. 5 new tests. | |
| 20 | - | - **New crate:** `audiofiles-train` — RF training binary (excluded from default-members). Custom implementation (no linfa dependency). Stratified 5-fold CV, rayon parallel training, JSON serialization. | |
| 21 | - | - **Modified:** `analysis/spectral.rs` — Added `compute_spectral_features_with_frames()` returning magnitude vectors alongside features. Original function delegates and discards frames. | |
| 22 | - | - **Modified:** `analysis/mod.rs` — Pipeline now computes MFCCs from STFT frames, builds extended ClassifyInput, calls `classify_ml()`. `AnalysisResult` gains `classification_confidence: Option<f64>`. | |
| 23 | - | - **Modified:** `analysis/suggest.rs` — Confidence-gated tag suggestions (only suggest if ML confidence >= 0.5). Reason string includes ML confidence percentage. | |
| 24 | - | - **Modified:** `classify_validation.rs` — Reports per-class confidence stats (mean/median/P10) and precision/recall/F1. | |
| 25 | - | - **Accuracy:** 94.4% strict / 95.6% lenient on 4343 labeled drum samples (was 36%/61% rule-based). | |
| 26 | - | - **Model file:** `crates/audiofiles-core/models/layer2_drum.json` (7.4MB, 200 trees, max_depth=25). | |
| 27 | - | - **No grade change expected** — all new code follows existing patterns, clippy clean, comprehensive tests. | |
| 28 | - | ||
| 29 | - | ### Fourteenth audit (2026-03-22, app test coverage) | |
| 30 | - | - **Test count:** 560 -> 585 (+25). 0 clippy warnings. | |
| 31 | - | - **Grade:** A (maintained). audiofiles-app Test B -> A-. | |
| 32 | - | - **updater.rs:** 8 new tests — UpdateStatus default, dismiss/should_show state machine (4 states), UpdateResponse deserialization, CURRENT_VERSION semver validation. | |
| 33 | - | - **main.rs:** 7 new tests — load_api_key (from file, trims whitespace, empty/whitespace-only/missing file), save_api_key, save-and-load roundtrip. | |
| 34 | - | - **tray.rs:** 2 new tests — build_icon produces valid 18x18 RGBA, icon buffer dimensions and pixel content verification. `build_icon` changed from `fn` to `pub(crate) fn` for testability. | |
| 35 | - | - **Still open (audiofiles-app):** eframe UI update loop, start_output_stream, AppTray::new/poll — all platform-dependent, inherently hard to unit test. | |
| 36 | - | ||
| 37 | - | ### Thirteenth audit (2026-03-19, VP-tree addition) | |
| 38 | - | - **Test count:** 560 (+25). 0 clippy warnings. | |
| 39 | - | - **Grade:** A (maintained). Performance A- -> A. v0.3.0. | |
| 40 | - | - **VP-tree module:** New `vp_tree.rs` — generic `VpTree<T>` with `build()`, `find_nearest()`, `find_within()`. 10 tests including brute-force correctness check. | |
| 41 | - | - **FingerprintIndex:** Two-phase near-duplicate search. Phase 1: VP-tree on 16-bin compact Euclidean features (proper metric, O(log n)). Phase 2: full NCC verification on candidates. Build < 1s for 100K. Query ~3ms at 100K (was 2.7s linear scan — ~900x speedup). 4 tests. | |
| 42 | - | - **SimilarityIndex:** VP-tree on fixed-normalization Euclidean features. Ranges computed once from dataset (not per-query). Minor semantic change: single new sample no longer shifts normalization of all others. Sub-ms queries at 100K (was 26ms linear scan). 5 tests. | |
| 43 | - | - **DirectBackend integration:** Both indexes cached as `Mutex<Option<...>>`, lazy build on first query, auto-invalidation on `save_analysis`. | |
| 44 | - | ||
| 45 | - | ### Twelfth audit (2026-03-19, AF-focused) | |
| 46 | - | - **Test count:** 535. 0 clippy warnings. | |
| 47 | - | - **Grade:** A- -> A. v0.3.0. | |
| 48 | - | - **Major additions:** Native macOS drag-out (drag_out/ module: mod.rs, macos.rs, windows.rs), brand refresh (audiofiles theme, af/ logo in Recursive font, app icon with squircle), description.md rewrite. | |
| 49 | - | - **Crate removal:** audiofiles-plugin, audiofiles-ipc, and xtask removed (standalone-only). 7 crates -> 5. nih-plug workspace deps removed. | |
| 50 | - | - **Unsafe surface change:** 17 platform FFI in drag_out/. All justified: objc2 NSDraggingSession on macOS, COM IDataObject on Windows. No production unsafe outside FFI. | |
| 51 | - | - **Verification results:** sync/service.rs has zero production unwraps (all in #[cfg(test)]). theme.rs has zero production unwraps. Rhai engine has 100K ops + 32 call levels limits. note_counter is u64 (no overflow risk). DRAG_ACTIVE is in-memory AtomicBool (resets on restart, no persistence issue). | |
| 52 | - | - **New LOW findings:** sidebar.rs has 2 guarded unwraps (style nit). updater.rs trusts server-provided download URL (acceptable — opens in browser, no auto-install). | |
| 53 | - | - **Mandatory surprise:** sync/service.rs zero production unwraps — Impressive (see above). | |
| 54 | - | ||
| 55 | - | ### Eleventh audit (2026-03-18, Run 9 cross-project) | |
| 56 | - | - **Test count:** 566 (up from 557). 0 clippy warnings. | |
| 57 | - | - **Grade:** A- (maintained). v0.3.0. | |
| 58 | - | - **Clippy fixes:** 3 warnings resolved pre-release build: collapsible `if` in `file_list.rs` and `sidebar.rs`, derivable `Default` impl in `updater.rs`. | |
| 59 | - | - **Release build:** Standalone app + CLAP + VST3 all signed+notarized. | |
| 60 | - | - **OTA updater verified:** `updater.rs` (148 LOC) — proper error handling (15s timeout, version parsing fallback, target/arch detection), Arc-wrapped UpdateStatus for thread safety, 10s initial delay + 6h polling. | |
| 61 | - | - **No new findings.** All previous cold spots remain resolved. | |
| 62 | - | - **Mandatory surprise:** None. Previous surprises (applying_remote crash recovery, find_nodes_by_hashes column mismatch) all resolved. | |
| 63 | - | ||
| 64 | - | ### Concurrency Upgrade (2026-03-13) | |
| 65 | - | - **Concurrency:** A- -> A | |
| 66 | - | - Eliminated double-lock TOCTOU in cancel_import/cancel_analysis/cancel_export -- now uses single lock with .take() pattern (6 lock acquisitions reduced to 3). | |
| 67 | - | ||
| 68 | - | ### Observability Upgrade (2026-03-13) | |
| 69 | - | - **Observability:** A- -> A | |
| 70 | - | - Upgraded tracing subscriber from bare `fmt::init()` to `registry() + EnvFilter + fmt layer` with env-configurable log levels (RUST_LOG) | |
| 71 | - | - Added `features = ["env-filter"]` to `tracing-subscriber` workspace dependency | |
| 72 | - | - Added 12 `#[instrument(skip_all)]` annotations across 6 files: scheduler.rs (2), service.rs (6), auth.rs (1), audio.rs (1), import.rs (1), export.rs (1) | |
| 73 | - | - `cargo check --workspace` passes clean | |
| 74 | - | ||
| 75 | - | ### Adversarial Test Audit (2026-03-13) | |
| 76 | - | ||
| 77 | - | Targeted adversarial testing after seventh audit remediation. Test count: 532 → 546 (+14 tests). 4 critical bugs found and fixed: | |
| 78 | - | ||
| 79 | - | **CRITICAL: `find_nodes_by_hashes` SELECT had wrong column count** | |
| 80 | - | - Missing `s.cloud_only` column and `LEFT JOIN samples` in query | |
| 81 | - | - Query returned 4 columns but code expected 5, causing panic on result extraction | |
| 82 | - | - Fixed: Added missing column and join to match schema | |
| 83 | - | - Added 3 adversarial tests: non-existent hash, mismatched hash count, partial hash set | |
| 84 | - | ||
| 85 | - | **HIGH: `move_node` allowed circular parent chains** | |
| 86 | - | - No cycle detection before UPDATE — could create infinite loops | |
| 87 | - | - Fixed: Added ancestor chain walk with HashSet cycle detection before UPDATE | |
| 88 | - | - Added 3 tests: direct self-parent, indirect circular chain, valid deep move | |
| 89 | - | ||
| 90 | - | **HIGH: Zero-byte files accepted into content store** | |
| 91 | - | - No validation on file size before hashing and import | |
| 92 | - | - Fixed: Reject zero-byte files before hashing | |
| 93 | - | - Added 2 tests: zero-byte rejection, valid non-empty file acceptance | |
| 94 | - | ||
| 95 | - | **HIGH: Non-atomic remove ordered file deletion before DB** | |
| 96 | - | - Deleted file from disk first, then updated DB — crash between steps left orphan DB row | |
| 97 | - | - Fixed: Reversed order to DB first, then file (matches content store pattern) | |
| 98 | - | - Added 3 tests: remove_by_hash atomicity, remove_sample atomicity, remove rollback leaves file intact | |
| 99 | - | ||
| 100 | - | **Security & correctness improvements:** | |
| 101 | - | - All 4 findings had genuine data corruption or crash potential | |
| 102 | - | - Adversarial tests now cover: malformed queries, cycle creation, zero-byte edge case, crash-during-delete atomicity | |
| 103 | - | - DB version unchanged: v8 (hash re-verification migration from earlier audit still current) | |
| 104 | - | ||
| 105 | - | **Test breakdown:** | |
| 106 | - | - Core: 179 → 190 (+11 tests for the 4 fixes above) | |
| 107 | - | - Sync: 21 → 24 (+3 tests for edge cases) | |
| 108 | - | ||
| 109 | - | ### Eighth audit (2026-03-16, Run 6 cross-project) | |
| 110 | - | - **Test count:** 546 -> 557 (+11 tests) | |
| 111 | - | - **Grade:** A- (maintained). | |
| 112 | - | - **New findings (LOW):** unix_now().expect() in error.rs:134 could crash DAW host (should use non-panicking fallback). Only 12 #[instrument] annotations on 86 files — observability density gap. | |
| 113 | - | - **Mandatory surprise:** unix_now().expect() in CLAP/VST3 plugin context — Genuine issue (panic = DAW crash). | |
| 114 | - | - **Previous items verified:** All previous remediated items confirmed intact (applying_remote, browser tracing, import_screens split, theme paths). | |
| 115 | - | ||
| 116 | - | ### Seventh audit (2026-03-13, pre-launch skeptical lens) | |
| 117 | - | ||
| 118 | - | Grade holds at A-. 528 tests (was 518). Audio thread safety confirmed excellent. New minor findings: | |
| 119 | - | - Vec allocation in audio path for MIDI events (should be SmallVec/pre-allocated) | |
| 120 | - | - query_sample_field uses string interpolation for SQL column name (safe — values from match arms, not user input — but fragile if pattern copied) | |
| 121 | - | - No hash re-verification on read from content store (import validates, but read path trusts stored hash) | |
| 122 | - | ||
| 123 | - | Previous applying_remote crash recovery issue remains open (CRITICAL). | |
| 124 | - | ||
| 125 | - | **Post-audit remediation (2026-03-13):** | |
| 126 | - | - All 3 seventh-audit findings resolved: SmallVec for MIDI events, query_sample_field allowlist, hash re-verification | |
| 127 | - | - All 5 prior cold spots resolved: applying_remote cleared on startup, browser tracing migration, import_screens split, E2E tests (3), theme paths fixed | |
| 128 | - | - Test count: 528 -> 532 (+4 tests) | |
| 129 | - | - DB version: v7 -> v8 | |
| 130 | - | - Documentation upgraded to A: SpectralFeatures fields documented with units, SampleStore struct doc added, architecture.md created (168 lines), README created (58 lines). All pub functions now have /// doc comments. | |
| 131 | - | ||
| 132 | - | ### Sixth-run full audit (2026-03-11) | |
| 133 | - | ||
| 134 | - | Fresh audit of entire codebase per audit.md. Test count: 518 (was 429). 2 trivial clippy warnings. Near-zero `.unwrap()` in production code. 2 `unsafe` blocks (both in test helpers, test-only). 7 crates (was 5). | |
| 135 | - | ||
| 136 | - | ### Major changes since fifth audit | |
| 137 | - | ||
| 138 | - | - **New crate: audiofiles-sync (1,838 LOC)** -- Full SyncKit integration: push/pull sync engine, OAuth2 PKCE auth, background scheduler, changelog triggers across 9 tables. 21 tests. | |
| 139 | - | - **New crate: audiofiles-rhai (1,462 LOC)** -- Rhai scripting for hardware sampler export profiles. Sandboxed engine, plugin discovery/loading, 4 hook points, bundled device plugins. 33 tests. | |
| 140 | - | - **Test count: 429 -> 518** -- New tests across sync (21), rhai (33), plus additional core/browser tests. | |
| 141 | - | - **LOC: ~22K -> 25.6K** -- Growth from sync and rhai crates. | |
| 142 | - | - **DB version: v6 -> v7** -- Migration 007 adds sync_state table, sync_changelog table, 27 changelog triggers. | |
| 143 | - | - **Backend trait: 42 -> 47 methods** -- Added sync-related methods (get/set VFS sync_files, sync manager integration). | |
| 144 | - | - **Alpha polish** -- VFS management UI (context menus, create/rename modals), directory management. | |
| 145 | - | ||
| 146 | - | ### Grades changed | |
| 147 | - | ||
| 148 | - | | Dimension | Previous | Current | Change | | |
| 149 | - | |-----------|:--------:|:-------:|--------| | |
| 150 | - | | Security | A | A- | applying_remote crash recovery gap | | |
| 151 | - | | Resilience | B+ | A- | Worker Drop cleanup improved, but applying_remote is new concern | | |
| 152 | - | ||
| 153 | - | ### Mandatory surprise assessment | |
| 154 | - | ||
| 155 | - | - **applying_remote flag stuck after crash**: Genuine issue. Silent data loss vector in sync system. Filed as CRITICAL action item. | |
| 156 | - | ||
| 157 | - | ### Audit Grade Corrections (2026-03-13) | |
| 158 | - | ||
| 159 | - | Corrected stale grades where the auditor missed existing code: | |
| 160 | - | - **Resilience:** A- → A. `applying_remote` cleared on startup (`service.rs:102-110`). All other resilience items confirmed working (Worker Drop, per-file error reporting, audio stream failure non-fatal, atomic migrations, CASCADE FKs). | |
| 161 | - | - **Frontend:** A- → A. import_screens split into directory module (configure.rs 229, progress.rs 177, tagging.rs 267). No single UI file exceeds 300 LOC. | |
| 162 | - | ||
| 163 | - | ### Security Deep Dive (2026-03-13) — Complete (2/2) | |
| 164 | - | ||
| 165 | - | - **Extension validation:** `store.rs` — `validate_extension()` function added, only allows alphanumeric characters, dots, and hyphens; prevents path traversal via crafted extensions. 3 new tests (path traversal, path separator, common extensions). | |
| 166 | - | - **OAuth callback safe slicing:** `auth.rs` — OAuth callback parser uses `.get(after_q..end)` for safe slicing instead of direct indexing; `saturating_sub` for fallback calculation; graceful break with `tracing::warn!` on malformed requests. | |
| 167 | - | ||
| 168 | - | ### Still open (5 items) | |
| 169 | - | ||
| 170 | - | - Add `updated_at` columns for sync (Phase 9 prerequisite) | |
| 171 | - | - Consider BrowserState decomposition (40+ field god object) | |
| 172 | - | - app crate: 22 tests now cover updater, key management, icon, audio; remaining gaps are platform-dependent (eframe, cpal, tray) | |
| 173 | - | - Add tests for remaining UI modules (file_list.rs, widgets.rs) | |
| 174 | - | - drag_out/ has no automated tests (platform FFI, manual testing only) | |
| 175 | - | ||
| 176 | - | ## Resolved Items (Previous Audits) | |
| 177 | - | ||
| 178 | - | - [x] state.rs split into directory module (state/mod.rs, navigation.rs, import_workflow.rs, bulk_ops.rs, tests.rs) | |
| 179 | - | - [x] 132 integration tests for state.rs orchestration | |
| 180 | - | - [x] LIKE wildcards escaped via `escape_like()` in search.rs and tags.rs | |
| 181 | - | - [x] DB migrations wrapped in transactions | |
| 182 | - | - [x] Hash validation in store.rs (validate_hash checks 64-char lowercase hex) | |
| 183 | - | - [x] `Result<_, String>` in app/audio.rs replaced with typed AudioError enum | |
| 184 | - | - [x] contents.clone() in file_list.rs confirmed as Arc clone (not deep copy) | |
| 185 | - | - [x] All clippy lints resolved (items_after_test_module, io_other_error, identity_op) | |
| 186 | - | - [x] Moved load_analysis to core (was raw SQL in DirectBackend) | |
| 187 | - | - [x] Moved sample_extension/original_name to core | |
| 188 | - | - [x] Replaced manual transactions with Database::transaction() | |
| 189 | - | - [x] Cleaned up find_nodes_by_hashes | |
| 190 | - | - [x] `export/mod.rs` split (extracted resolve.rs, runner.rs) | |
| 191 | - | - [x] SAFETY comments on unsafe blocks (preview.rs, instrument.rs) | |
| 192 | - | - [x] Scalability notes on fingerprint.rs and similarity.rs (O(n) documented; fingerprint now VP-tree indexed) | |
| 193 | - | - [x] Tag validation docs enhanced | |
| 194 | - | - [x] Theme tests: 44 tests for ui/theme.rs pure functions | |
| 195 | - | - [x] `split_name_ext` centralized into util.rs (3 tests) | |
| 196 | - | - [x] Smart folder error handling: CoreError::Serialization variant | |
| 197 | - | - [x] Entity ID newtypes (VfsId, NodeId, SmartFolderId, CollectionId) | |
| 198 | - | - [x] SampleHash validated newtype with new() returning Result | |
| 199 | - | - [x] NodeType::parse() wildcard fallback replaced with explicit error | |
| 200 | - | - [x] SampleHash consistency fix (ExportItem, ReviewItem) |
| @@ -1,252 +0,0 @@ | |||
| 1 | - | # audiofiles -- Code Audit Review | |
| 2 | - | ||
| 3 | - | **Last audited:** 2026-05-09 (Ultra Fuzz Run 1, 5-axis adversarial audit) | |
| 4 | - | **Previous audit:** 2026-05-04 (Run 20, cross-project) | |
| 5 | - | ||
| 6 | - | ## Overall Grade: A- | |
| 7 | - | ||
| 8 | - | Ultra Fuzz Run 1. 5 parallel adversarial agents (Audio Pipeline, Data & Storage, UX Wiring, Security, Performance). 780 tests (all pass). v0.4.0. ~42.7K LOC. 48 modules audited. 4 SERIOUS, 10 MINOR, 16 NOTE findings. All Run 20 action items verified fixed. Two SERIOUS sync bugs found (snapshot column omission). Security trust model gaps identified (plaintext API key, unsigned OTA updates). | |
| 9 | - | ||
| 10 | - | ## Scorecard | |
| 11 | - | ||
| 12 | - | | Dimension | Grade | Notes | | |
| 13 | - | |-----------|:-----:|-------| | |
| 14 | - | | Audio Pipeline | A | All DSP paths NaN-guarded. Previous fuzz fixes verified. Asymmetric normalize clamp (MINOR). | | |
| 15 | - | | Data & Storage | A- | Content-addressed store excellent. Sync initial snapshot stale since migration 008 (SERIOUS). Cleanup race (MINOR). | | |
| 16 | - | | UX Wiring | A | Clean state machine. Theme preview key prefix wrong (MINOR). truncate_name byte panic (MINOR). | | |
| 17 | - | | Security | A- | Strong sandbox, PKCE, parameterized SQL. Plaintext API key (SERIOUS). No OTA signatures (SERIOUS). | | |
| 18 | - | | Performance | A- | Well-optimized desktop. VP-tree build under DB lock (MINOR). SampleBuffer per-packet alloc (MINOR). No FTS5 (NOTE). | | |
| 19 | - | | Code Quality | A | Typed error hierarchy. No unwraps in prod. 17 unsafe blocks (all FFI, all SAFETY-commented). | | |
| 20 | - | | Architecture | A | 7-crate workspace. Backend trait. Worker channels. Content-addressed storage. | | |
| 21 | - | | Testing | A | 780 tests. Core e2e pipeline. State tests (1653L). Theme roundtrip. Import lifecycle. | | |
| 22 | - | | Type Safety | A | VfsId, NodeId, SampleHash newtypes. Domain enums. Typed errors. Column allowlists. | | |
| 23 | - | | Concurrency | A | try_lock on audio callback. Relaxed→Acquire/Release fixed. Atomic cancel flags. Worker Drop+join. | | |
| 24 | - | ||
| 25 | - | ## Module Heatmap | |
| 26 | - | ||
| 27 | - | ### Audio Pipeline & DSP | |
| 28 | - | ||
| 29 | - | | Module | Correct | Errors | Types | Concurrency | Tests | Quality | Overall | | |
| 30 | - | |--------|:-------:|:------:|:-----:|:-----------:|:-----:|:-------:|:-------:| | |
| 31 | - | | analysis/decode | A | A | A | A | A- | A | A | | |
| 32 | - | | analysis/basic | A | A | A | A | A | A | A | | |
| 33 | - | | analysis/loudness | A | A | A | A | A- | A | A | | |
| 34 | - | | analysis/spectral | A | A | A | A | A | A | A | | |
| 35 | - | | analysis/mfcc | A | A | A | A | A | A | A | | |
| 36 | - | | analysis/bpm | A | A | A | A | A- | A | A | | |
| 37 | - | | analysis/classify | A | A | A | A | A | A | A | | |
| 38 | - | | analysis/worker | A | A | A | A | A- | A | A | | |
| 39 | - | | edit/normalize | A- | A | A | A | A | A- | A- | | |
| 40 | - | | edit/* (other 8) | A | A | A | A | A | A | A | | |
| 41 | - | | export/encode | A | A | A | A | A | A | A | | |
| 42 | - | | export/encode_aiff | A | A | A | A | A | A | A | | |
| 43 | - | | export/sanitize | A | A | A | A | A | A | A | | |
| 44 | - | | preview.rs | A | A | A | A- | A | A | A | | |
| 45 | - | | fingerprint.rs | A | A | A | A | A | A | A | | |
| 46 | - | | vp_tree.rs | A | A | A | A | A | A | A | | |
| 47 | - | ||
| 48 | - | ### Data & Storage | |
| 49 | - | ||
| 50 | - | | Module | Query | Txn Safety | Resources | Migration | Types | Arch | Overall | | |
| 51 | - | |--------|:-----:|:----------:|:---------:|:---------:|:-----:|:----:|:-------:| | |
| 52 | - | | db.rs | A | A | A | A- | A | A | A | | |
| 53 | - | | store.rs | A | A | A | n/a | A | A | A | | |
| 54 | - | | vfs.rs | A | A- | A | n/a | A | A | A | | |
| 55 | - | | vfs_mirror.rs | A- | n/a | A | n/a | A- | A | A- | | |
| 56 | - | | tags.rs | A | A | A | n/a | A | A | A | | |
| 57 | - | | search.rs | A | n/a | A | n/a | A- | A | A | | |
| 58 | - | | sync/service | A- | A | A- | n/a | A | A | A- | | |
| 59 | - | | cleanup.rs | A- | B+ | A- | n/a | A | A- | B+ | | |
| 60 | - | ||
| 61 | - | ### UX Wiring | |
| 62 | - | ||
| 63 | - | | Module | UI | State | Workflow | Platform | Error UX | Quality | Overall | | |
| 64 | - | |--------|:--:|:-----:|:-------:|:--------:|:--------:|:-------:|:-------:| | |
| 65 | - | | state/* | -- | A | A | -- | A | A | A | | |
| 66 | - | | ui/file_list.rs | A | A | -- | A | A | A | A | | |
| 67 | - | | ui/theme.rs | A | -- | -- | -- | A | A | A | | |
| 68 | - | | import_screens | -- | A- | A | -- | A | A- | A- | | |
| 69 | - | | drag_out/* | -- | A | -- | A | A | A | A | | |
| 70 | - | | instrument.rs | A | A | -- | -- | A | A | A | | |
| 71 | - | | app/main.rs | A | A | A | A | A | A | A | | |
| 72 | - | ||
| 73 | - | ### Security | |
| 74 | - | ||
| 75 | - | | Module | Security | Crypto | Sandbox | Path | Update | Unsafe | Overall | | |
| 76 | - | |--------|:--------:|:------:|:-------:|:----:|:------:|:------:|:-------:| | |
| 77 | - | | license/activation | B | n/a | n/a | A | n/a | n/a | B | | |
| 78 | - | | updater.rs | B- | n/a | n/a | A | B- | n/a | B- | | |
| 79 | - | | auth.rs (PKCE) | A | A | n/a | n/a | n/a | n/a | A | | |
| 80 | - | | sync service | A- | A | n/a | A | n/a | n/a | A- | | |
| 81 | - | | rhai engine | A- | n/a | A- | A | n/a | n/a | A- | | |
| 82 | - | | store.rs | A | A | n/a | A | n/a | n/a | A | | |
| 83 | - | | drag_out (FFI) | n/a | n/a | n/a | A- | n/a | A- | A- | | |
| 84 | - | ||
| 85 | - | ### Performance | |
| 86 | - | ||
| 87 | - | | Module | Perf | Scale | Concurrency | Memory | Startup | Efficiency | Overall | | |
| 88 | - | |--------|:----:|:-----:|:-----------:|:------:|:-------:|:----------:|:-------:| | |
| 89 | - | | analysis/worker | A | A | A | A- | A | A | A | | |
| 90 | - | | vp_tree | A | A- | A | A | A | A | A | | |
| 91 | - | | search.rs | A- | B+ | A | A | A | A | A- | | |
| 92 | - | | store.rs | A | A | A | A | A | A | A | | |
| 93 | - | | preview.rs | A- | A | A | A- | A | A | A- | | |
| 94 | - | | backend/direct.rs | A- | B+ | B+ | A | A | A- | A- | | |
| 95 | - | | waveform.rs | A | A | A | A | A | A+ | A+ | | |
| 96 | - | | file_list.rs | A | A | A | A | A | A | A | | |
| 97 | - | ||
| 98 | - | ### Cold Spots | |
| 99 | - | ||
| 100 | - | | # | Module | Grade | Issue | Severity | | |
| 101 | - | |---|--------|:-----:|-------|----------| | |
| 102 | - | | 1 | cleanup.rs | B+ | Non-transactional orphan deletes, race with import worker | MINOR | | |
| 103 | - | | 2 | updater.rs | B- | No signature verification on OTA metadata | SERIOUS | | |
| 104 | - | | 3 | license.rs | B | Plaintext API key, unsigned cache, trial file-deletion bypass | SERIOUS | | |
| 105 | - | | 4 | search.rs | B+ (Scale) | LIKE %...% without FTS5, potential slow at 100K+ nodes | NOTE | | |
| 106 | - | | 5 | backend/direct.rs | B+ (Conc) | VP-tree index build holds DB mutex, blocks UI | MINOR | | |
| 107 | - | ||
| 108 | - | ## Bug Reports | |
| 109 | - | ||
| 110 | - | ### Audio Pipeline — 0 CRITICAL, 0 SERIOUS, 1 MINOR, 6 NOTE | |
| 111 | - | ||
| 112 | - | | # | Sev | Location | Description | | |
| 113 | - | |---|-----|----------|-------------| | |
| 114 | - | | F-01 | MINOR | edit/normalize.rs:56 | LUFS normalize clamps [-1,1] silently; peak normalize does NOT clamp — asymmetric, undocumented | | |
| 115 | - | | F-02 | NOTE | export/encode.rs:31 | Dither seed from pointer address — not reproducible, not truly random | | |
| 116 | - | | F-03 | NOTE | vp_tree.rs:260 | Recursive search on flat chain tail at depth cap — deep for degenerate inputs | | |
| 117 | - | | F-04 | NOTE | preview.rs:258 | Hound WAV fallback in streaming loads entire file — defeats streaming purpose | | |
| 118 | - | | F-05 | NOTE | analysis/classify.rs:428 | Short noisy transients misclassified as Drum (ML layer 2 corrects) | | |
| 119 | - | | F-06 | NOTE | similarity.rs:71 | NormRanges default (0,0) — safe, correct behavior | | |
| 120 | - | | F-07 | NOTE | audio.rs:143 | try_lock drops frames during streaming — acceptable for preview | | |
| 121 | - | ||
| 122 | - | ### Data & Storage — 0 CRITICAL, 2 SERIOUS, 2 MINOR, 4 NOTE | |
| 123 | - | ||
| 124 | - | | # | Sev | Location | Description | | |
| 125 | - | |---|-----|----------|-------------| | |
| 126 | - | | D-01 | SERIOUS | sync/service/state.rs:26 | Initial snapshot `samples` omits `duration` (migration 009) | | |
| 127 | - | | D-02 | SERIOUS | sync/service/state.rs:27 | Initial snapshot `audio_analysis` omits 5 columns (migrations 010-011) | | |
| 128 | - | | D-03 | MINOR | cleanup.rs:200 | Orphan deletes without transaction — race with concurrent import | | |
| 129 | - | | D-04 | MINOR | sync/service/state.rs:134 | `mark_cloud_only_samples` per-row transactions — slow for large libraries | | |
| 130 | - | | D-05 | NOTE | vfs_mirror.rs:207 | `batch_extensions` is N+1 despite name | | |
| 131 | - | | D-06 | NOTE | id_types.rs:85 | `SampleHash::validated` accepts uppercase; `store.rs` rejects it | | |
| 132 | - | | D-07 | NOTE | vfs.rs:593 | `find_nodes_by_hashes` unbounded IN clause | | |
| 133 | - | | D-08 | NOTE | db.rs:582 | `edit_history` lacks FK — dangling refs accumulate | | |
| 134 | - | ||
| 135 | - | ### UX Wiring — 0 CRITICAL, 0 SERIOUS, 3 MINOR, 4 NOTE | |
| 136 | - | ||
| 137 | - | | # | Sev | Location | Description | | |
| 138 | - | |---|-----|----------|-------------| | |
| 139 | - | | U-01 | MINOR | ui/theme.rs:404 | `theme_preview_colors` wrong key prefix — all previews show fallback colors | | |
| 140 | - | | U-02 | MINOR | ui/file_list_menus.rs:316 | `truncate_name` byte-slices — panics on non-ASCII names | | |
| 141 | - | | U-03 | MINOR | state/import_workflow.rs:8 | Import dry-run count doesn't skip macOS metadata dirs | | |
| 142 | - | | U-04 | NOTE | drag_out/mod.rs:60 | Name collision loop 1-999, no fallback | | |
| 143 | - | | U-05 | NOTE | state/library.rs:105 | Dynamic collection sets active_collection=None — breadcrumb gap | | |
| 144 | - | | U-06 | NOTE | state/import_workflow.rs:953 | Batch edit prompts per-sample (self-resolves) | | |
| 145 | - | | U-07 | NOTE | state/bulk_ops.rs:76 | Index subtraction fragile (sound but relies on filter) | | |
| 146 | - | ||
| 147 | - | ### Security — 0 CRITICAL, 2 SERIOUS, 3 MINOR, 2 NOTE | |
| 148 | - | ||
| 149 | - | | # | Sev | Location | Description | | |
| 150 | - | |---|-----|----------|-------------| | |
| 151 | - | | S-01 | SERIOUS | app/main.rs:131 | API key plaintext file, no OS keychain | | |
| 152 | - | | S-02 | SERIOUS | app/updater.rs:86 | No cryptographic signature on OTA update metadata | | |
| 153 | - | | S-03 | MINOR | app/license.rs:204 | Trial bypass via file deletion | | |
| 154 | - | | S-04 | MINOR | app/license.rs:80 | License cache unsigned, no periodic revalidation | | |
| 155 | - | | S-05 | MINOR | rhai/engine.rs:13 | No wall-clock timeout on Rhai scripts | | |
| 156 | - | | S-06 | NOTE | sync/service/resolve.rs:74 | Dynamic SQL names — mitigated by static whitelist | | |
| 157 | - | | S-07 | NOTE | drag_out/mod.rs:36 | Drag temp dir not cleaned on exit | | |
| 158 | - | ||
| 159 | - | ### Performance — 0 CRITICAL, 0 SERIOUS, 4 MINOR, 6 NOTE | |
| 160 | - | ||
| 161 | - | | # | Sev | Location | Description | | |
| 162 | - | |---|-----|----------|-------------| | |
| 163 | - | | P-01 | MINOR | preview.rs:353 | SampleBuffer per-packet in streaming decode | | |
| 164 | - | | P-02 | MINOR | preview.rs:151 | SampleBuffer per-packet in non-streaming decode | | |
| 165 | - | | P-03 | MINOR | backend/direct.rs:543 | VP-tree build holds DB mutex — blocks UI | | |
| 166 | - | | P-04 | MINOR | export/mod.rs:204 | `enrich_with_tags` N+1 on export | | |
| 167 | - | | P-05 | NOTE | analysis/spectral.rs:197 | Magnitude frames cloned per STFT frame | | |
| 168 | - | | P-06 | NOTE | analysis/mfcc.rs:125 | Mel filterbank rebuilt per file | | |
| 169 | - | | P-07 | NOTE | fingerprint.rs:173 | NCC correlation O(n * shift) — bounded, acceptable | | |
| 170 | - | | P-08 | NOTE | search.rs:263 | LIKE %...% without FTS5 | | |
| 171 | - | | P-09 | NOTE | db.rs (migration 007) | No index on sync_changelog.timestamp | | |
| 172 | - | | P-10 | NOTE | import.rs:250 | Sequential import — acceptable given SQLite single-writer | | |
| 173 | - | ||
| 174 | - | ## Cross-Cutting Concerns | |
| 175 | - | ||
| 176 | - | 1. **Sync snapshot staleness** (Data + Security): Initial snapshot queries frozen at migration 008. Both a data integrity issue (D-01, D-02) and security-adjacent — `user_config` table synced bidirectionally could allow remote `unsafe_mode` override. | |
| 177 | - | ||
| 178 | - | 2. **Preview decode pipeline** (Audio + Performance): Hound WAV fallback loads entire file (F-04), and SampleBuffer allocated per-packet (P-01, P-02). Both affect same decode path. | |
| 179 | - | ||
| 180 | - | 3. **Licensing trust model** (Security): API key plaintext (S-01), unsigned OTA (S-02), unsigned license cache (S-04), trial file-deletion (S-03) — cohesive gap in cryptographic trust. | |
| 181 | - | ||
| 182 | - | ## Mandatory Surprises | |
| 183 | - | ||
| 184 | - | | Axis | Finding | | |
| 185 | - | |------|---------| | |
| 186 | - | | Audio | **Positive**: `interleaved_to_stereo` NaN/Inf sanitizer catches `NaN.clamp()` returning NaN — applied across all channel branches. Production-grade defensive programming. | | |
| 187 | - | | Data | **Negative**: Initial snapshot queries stale since migration 008 — silently drops 6 columns on first sync. | | |
| 188 | - | | UX | **Negative**: `theme_preview_colors` uses `"bg.primary"` instead of `"background.primary"` — every theme preview shows fallback colors. | | |
| 189 | - | | Security | **Negative**: Sync can overwrite `unsafe_mode` via `user_config` table — compromised server could silently enable unsafe mode on target device. | | |
| 190 | - | | Performance | **Positive**: Spectral STFT hop=window (no overlap) — intentional 4x speedup for classification without accuracy loss. | | |
| 191 | - | ||
| 192 | - | ## Confidence Assessment | |
| 193 | - | ||
| 194 | - | | Axis | Confidence | Notes | | |
| 195 | - | |------|:----------:|-------| | |
| 196 | - | | Audio Pipeline | HIGH | All DSP paths NaN-guarded, previous fixes verified | | |
| 197 | - | | Data & Storage | HIGH | Content-addressed store correct; sync snapshot is the one gap | | |
| 198 | - | | UX Wiring | HIGH | Clean state machine, proper worker comms, robust FFI | | |
| 199 | - | | Security | MEDIUM | Strong for desktop (sandbox, PKCE, parameterized SQL); weak trust model | | |
| 200 | - | | Performance | HIGH | Well-optimized, no scalability cliffs at expected sizes | | |
| 201 | - | ||
| 202 | - | ## Previous Action Item Status (Run 20 → Ultra Fuzz 1) | |
| 203 | - | ||
| 204 | - | | Item | Run 20 | Ultra Fuzz 1 | | |
| 205 | - | |------|:------:|:------------:| | |
| 206 | - | | Split app/main.rs (MEDIUM) | FIXED | Verified fixed | | |
| 207 | - | | Relaxed → Acquire/Release (LOW, was CHRONIC) | FIXED | Verified fixed | | |
| 208 | - | | Sync tests (LOW) | FIXED | Verified fixed | | |
| 209 | - | | import_directory_recursive dedup (LOW) | FIXED | Verified fixed | | |
| 210 | - | | N1 API key plaintext | Noted | Upgraded to SERIOUS (S-01) | | |
| 211 | - | | N2 No OTA signatures | Noted | Upgraded to SERIOUS (S-02) | | |
| 212 | - | | O11 Orphan cleanup not transactional | "Mitigated" | Promoted to real bug (D-03) — separate connection bypasses Mutex | | |
| 213 | - | | O15 Dither seed | Noted | Improved (now pointer-derived, NOTE) | | |
| 214 | - | | O16 LUFS normalize clip | Noted | Reclassified as asymmetric clamp (F-01, MINOR) | | |
| 215 | - | ||
| 216 | - | No CHRONIC items. All Run 20 action items resolved. | |
| 217 | - | ||
| 218 | - | ## Recommended Priority Order | |
| 219 | - | ||
| 220 | - | 1. Fix sync initial snapshot columns (D-01, D-02) — 2-line fix, data loss on first sync | |
| 221 | - | 2. Fix `truncate_name` byte slicing (U-02) — runtime panic on non-ASCII | |
| 222 | - | 3. Fix `theme_preview_colors` key prefix (U-01) — 3-line fix, all previews wrong | |
| 223 | - | 4. Fix cleanup orphan delete transaction (D-03) — race condition | |
| 224 | - | 5. Exclude `unsafe_mode` from sync triggers — security concern | |
| 225 | - | 6. Hoist SampleBuffer out of decode loops (P-01, P-02) — easy perf win | |
| 226 | - | 7. Release DB lock before VP-tree build (P-03) — UI blocking | |
| 227 | - | 8. Batch `enrich_with_tags` (P-04) — N+1 on export | |
| 228 | - | 9. Add OTA signature verification (S-02) — requires server-side signing | |
| 229 | - | 10. Move API key to OS keychain (S-01) — requires `keyring` crate | |
| 230 | - | 11. Batch `mark_cloud_only_samples` (D-04) — slow for large libraries | |
| 231 | - | 12. Add Rhai wall-clock timeout (S-05) — low risk | |
| 232 | - | ||
| 233 | - | ## Metrics Over Time | |
| 234 | - | ||
| 235 | - | | Metric | Run 15 (04-18) | Run 20 (05-04) | UF1 (05-09) | | |
| 236 | - | |--------|:--------------:|:--------------:|:-----------:| | |
| 237 | - | | Overall | A | A | A- | | |
| 238 | - | | LOC | ~40.2K | ~42.7K | ~42.7K | | |
| 239 | - | | Tests | 688 | 773 | 780 | | |
| 240 | - | | Crates | 5 + train | 5 + train | 5 + train | | |
| 241 | - | | Clippy | 0 | 4 (trivial) | 4 (trivial) | | |
| 242 | - | | Unwrap (prod) | 2 (sidebar) | 0 | 0 | | |
| 243 | - | | Unsafe | 17 (FFI) | 17 (FFI) | 17 (FFI) | | |
| 244 | - | | Cold spots | 6 | 6 | 5 | | |
| 245 | - | | SERIOUS bugs | 0 | 0 | 4 | | |
| 246 | - | | MINOR bugs | 0 | 0 | 10 | | |
| 247 | - | ||
| 248 | - | Note: Grade lowered A → A- due to ultra-fuzz depth finding SERIOUS issues not visible in regular audits (sync snapshot staleness, security trust model). Code quality is unchanged — the issues are design-level. | |
| 249 | - | ||
| 250 | - | --- | |
| 251 | - | ||
| 252 | - | See [audit_history.md](./audit_history.md) for full chronological audit log. |
| @@ -1,282 +0,0 @@ | |||
| 1 | - | # audiofiles -- Competitive Analysis | |
| 2 | - | ||
| 3 | - | Last updated: 2026-03-30 | |
| 4 | - | ||
| 5 | - | ## Positioning | |
| 6 | - | ||
| 7 | - | audiofiles is the only sample manager with content-addressed storage, multiple virtual file systems, and hardware sampler export profiles. It runs as a standalone desktop app. Built in Rust with egui, it provides audio analysis, intelligent tagging, similarity search, native drag-to-DAW, MIDI instrument mode, cloud sync, and device-specific export for 14 hardware samplers. | |
| 8 | - | ||
| 9 | - | ## Pricing Comparison | |
| 10 | - | ||
| 11 | - | | App | Price | Model | | |
| 12 | - | |-----|-------|-------| | |
| 13 | - | | **audiofiles** | **Free (alpha)** | Source-available | | |
| 14 | - | | ADSR Sample Manager | Free | Free + marketplace | | |
| 15 | - | | Argotlunar | Free | Free | | |
| 16 | - | | Sononym | $99 once | Standalone only | | |
| 17 | - | | Atlas 2 | $99 once | Plugin + standalone | | |
| 18 | - | | AudioFinder | $70 once | macOS only | | |
| 19 | - | | XO | $180 once | Plugin + standalone | | |
| 20 | - | | Splice | $13-40/mo | Subscription | | |
| 21 | - | | Loopcloud | $8-22/mo | Subscription | | |
| 22 | - | ||
| 23 | - | ## Feature Matrix | |
| 24 | - | ||
| 25 | - | | Feature | AF | Splice | ADSR | Loopcloud | XO | Atlas 2 | Sononym | AudioFinder | | |
| 26 | - | |---------|:--:|:------:|:----:|:---------:|:--:|:-------:|:-------:|:-----------:| | |
| 27 | - | | **Content-addressed store** | Yes | No | No | No | No | No | No | No | | |
| 28 | - | | **VFS (multiple hierarchies)** | Yes | No | No | No | No | No | No | No | | |
| 29 | - | | **Hardware export profiles** | Yes (14) | No | No | No | No | No | No | No | | |
| 30 | - | | **Rhai scripting** | Yes | No | No | No | No | No | No | No | | |
| 31 | - | | **ML classification (16-class)** | Yes | No | No | No | No | No | Yes | Separate ($10) | | |
| 32 | - | | **Standalone** | Yes | App | Yes | App | Yes | Yes | Yes | Yes | | |
| 33 | - | | **Source-available** | Yes | No | No | No | No | No | No | No | | |
| 34 | - | | **Local-first** | Yes | No | Partial | No | Yes | Yes | Yes | Yes | | |
| 35 | - | | **Cross-platform** | Yes | Mac/Win | Mac/Win | Mac/Win | Mac/Win | Mac/Win/Linux | Mac/Win/Linux | macOS only | | |
| 36 | - | | **BPM detection** | Yes | Metadata | No | Metadata | No | No | Yes | Yes | | |
| 37 | - | | **Key detection** | Yes | Metadata | No | Metadata | No | No | Yes | Yes | | |
| 38 | - | | **LUFS loudness** | Yes | No | No | No | No | No | No | No | | |
| 39 | - | | **Spectral analysis** | Yes | No | No | No | No | No | Yes | No | | |
| 40 | - | | **Classification (16-class)** | Yes | No | No | No | No | No | Yes | Separate ($10) | | |
| 41 | - | | **Loop detection** | Yes | No | No | No | No | No | No | No | | |
| 42 | - | | **Tag suggestions** | Yes | No | Smart tags | No | No | No | Yes (v1.6) | No | | |
| 43 | - | | **Bulk ops with undo** | Yes | No | No | No | No | No | No | Yes (rename) | | |
| 44 | - | | **Similarity search** | Yes | Yes (ML) | No | Yes (ML) | Yes (2D map) | Yes (timbre) | Yes (tunable) | No | | |
| 45 | - | | **Drag-to-DAW** | Yes | Yes | Yes | Yes | Yes | Yes | No | No | | |
| 46 | - | | **Auto tempo/key match** | No | Yes | Via Link | Yes | No | No | Pitch ctrl | No | | |
| 47 | - | | **Sample marketplace** | No | 4M+ | Yes | 4M+ | No | No | No | No | | |
| 48 | - | | **Preview effects** | No | No | Yes (HP/LP/gain) | Yes (10 FX) | No | No | No | Yes (AU hosting) | | |
| 49 | - | | **Built-in sequencer** | No | No | No | 8-track | Yes | Yes | No | No | | |
| 50 | - | | **Mobile app** | No | Yes | No | No | No | No | No | No | | |
| 51 | - | | **Cloud storage** | No | Yes | No | Yes | No | No | No | No | | |
| 52 | - | ||
| 53 | - | ## Competitor Deep Dives | |
| 54 | - | ||
| 55 | - | ### Splice | |
| 56 | - | ||
| 57 | - | Sample marketplace + cloud library with DAW integration plugin and AI-powered discovery tools. | |
| 58 | - | ||
| 59 | - | **Pricing:** Subscription. Sounds+ $12.99/mo (100 credits), Creator $19.99/mo (200 credits), Creator+ $39.99/mo (500 credits). Annual plans at ~17% discount. Rent-to-own plugin marketplace is separate. | |
| 60 | - | ||
| 61 | - | | Feature | Notes | Tag | | |
| 62 | - | |---------|-------|-----| | |
| 63 | - | | 4M+ royalty-free sample marketplace | Credit-based download from curated cloud catalog | | | |
| 64 | - | | Bridge plugin (in-DAW BPM/key-matched preview) | Preview Splice samples in-context inside DAW, auto tempo/key sync | | | |
| 65 | - | | Create mode / Stacks (AI sample layering) | AI selects up to 8 compatible layers matched by key/tempo/genre | | | |
| 66 | - | | Similar Sounds (AI audio fingerprint search) | ML-based sonic similarity across their catalog, not tag-based | | | |
| 67 | - | | Search with Sound (upload audio to find matches) | Drag audio from DAW or upload loop, AI finds complementary samples | | | |
| 68 | - | | Mobile app (iOS/Android) | Browse catalog, Create mode, Splice Mic for vocal recording on phone | | | |
| 69 | - | | Splice Instrument (sample-based virtual instrument) | Play downloaded samples chromatically via plugin keyboard | | | |
| 70 | - | | Collections (shareable playlists) | Curate sample groups, share via URL, accessible on web/desktop/mobile | | | |
| 71 | - | | Rent-to-own plugin marketplace | Pay monthly toward owning third-party plugins (Serum, Arturia, etc.) | [BLOAT] | | |
| 72 | - | | Community Discord + remix contests | Social engagement layer | [BLOAT] | | |
| 73 | - | | CoSo social sharing (video export of Stacks) | Record mute/unmute performances, share to social media | [BLOAT] [DATA-HUNGRY] | | |
| 74 | - | | DAW project file export (Ableton/Studio One) | Export Stacks as full DAW project with warped samples | | | |
| 75 | - | | Companion mode (mini floating browser) | Condensed desktop app for quick browsing alongside DAW | | | |
| 76 | - | | User analytics and listening behavior tracking | Splice tracks usage patterns to personalize recommendations | [INVASIVE] [DATA-HUNGRY] | | |
| 77 | - | ||
| 78 | - | **Key takeaway:** Splice is a marketplace first, manager second. Its strength is the massive catalog and AI discovery. audiofiles competes on the management side -- Splice does not offer content-addressed storage, deduplication, VFS, hardware sampler export, or local-first analysis. Splice requires an internet connection and subscription for core value. | |
| 79 | - | ||
| 80 | - | --- | |
| 81 | - | ||
| 82 | - | ### ADSR Sample Manager | |
| 83 | - | ||
| 84 | - | Free sample browser/manager with cloud marketplace integration and DAW plugin. | |
| 85 | - | ||
| 86 | - | **Pricing:** Free (standalone + plugin). ADSR marketplace uses credit bundles starting at $5. Earn credits from ADSR store purchases. | |
| 87 | - | ||
| 88 | - | | Feature | Notes | Tag | | |
| 89 | - | |---------|-------|-----| | |
| 90 | - | | HP/LP filter on preview | Shape sound before auditioning with highpass/lowpass filters | | | |
| 91 | - | | Fade in/out on preview | Apply fades during preview, render to DAW with settings applied | | | |
| 92 | - | | Gain + normalize on preview | Adjust and normalize volume before dragging to DAW | | | |
| 93 | - | | Ableton Link sync | Sync loop preview playback across devices and Link-enabled apps | | | |
| 94 | - | | True drag-and-drop to DAW (rendered with settings) | Drag applies preview settings (filter, fade, gain) to rendered output | | | |
| 95 | - | | Cloud sample browsing (ADSR marketplace) | Browse and preview purchased ADSR samples from within the manager | [DATA-HUNGRY] | | |
| 96 | - | | Smart tags (auto-generated from filename/path) | Automatic tag generation from folder structure and filename parsing | | | |
| 97 | - | | Looped preview with quantized start points | Loops restart at beat-quantized positions for musical auditioning | | | |
| 98 | - | | Resizable waveform display with zoom | Drag to resize waveform, zoom with snapping to zero crossings and note divisions | | | |
| 99 | - | | Effective length column | Shows actual audio duration excluding silence | | | |
| 100 | - | ||
| 101 | - | **Key takeaway:** ADSR competes on price (free) and workflow convenience (preview manipulation before drag-to-DAW). audiofiles already has stronger analysis, VFS, and deduplication. The preview manipulation features (HP/LP filter, fade, gain, normalize on preview) are genuinely useful workflow additions worth noting. | |
| 102 | - | ||
| 103 | - | --- | |
| 104 | - | ||
| 105 | - | ### Loopcloud | |
| 106 | - | ||
| 107 | - | Sample marketplace + DAW plugin with built-in editor, effects, and cloud storage. | |
| 108 | - | ||
| 109 | - | **Pricing:** Subscription. Artist $7.99/mo (100 sounds, 5GB cloud), Studio $11.99/mo (300 sounds, 50GB cloud), Professional $21.99/mo (600 sounds, 250GB cloud). Annual plans at ~17% discount. | |
| 110 | - | ||
| 111 | - | | Feature | Notes | Tag | | |
| 112 | - | |---------|-------|-----| | |
| 113 | - | | 4M+ royalty-free cloud sample library | Subscription access to Loopmasters catalog | | | |
| 114 | - | | 8-track editor with slice/rearrange | Multi-track sample editor for building loops from sliced parts | | | |
| 115 | - | | 10 built-in effects (reverb, delay, EQ, compressor, grain stretch, tonebox) | FX chain on samples before export, like a mini DAW | [BLOAT] | | |
| 116 | - | | Flip Sample (1000+ pattern transforms) | One-click sample transformation: slice, pitch, sequence, apply effects patterns | | | |
| 117 | - | | Auto time-stretch to project BPM | Samples automatically stretch to match DAW tempo | | | |
| 118 | - | | Auto key transpose (Key Lock) | Samples auto-transpose to match project key | | | |
| 119 | - | | Cloud sample storage (5-250GB by tier) | Store your own samples in the cloud, tagged and organized | | | |
| 120 | - | | Favourites and Collections | Organize samples into playlists across cloud and local libraries | | | |
| 121 | - | | Sample similarity search within catalog | Find similar sounds in the Loopcloud library | | | |
| 122 | - | | In-app sample purchasing | Browse, preview, buy samples without leaving the app | [DATA-HUNGRY] | | |
| 123 | - | ||
| 124 | - | **Key takeaway:** Loopcloud is a marketplace-first tool with a surprisingly capable editor. The Flip Sample feature (pattern-based sample transformation) is creative but moves into sound design territory, not management. audiofiles differentiates with local-first architecture, deduplication, VFS, and hardware export. Loopcloud has no deduplication, no hardware sampler awareness, and requires subscription for core features. | |
| 125 | - | ||
| 126 | - | --- | |
| 127 | - | ||
| 128 | - | ### XO by XLN Audio | |
| 129 | - | ||
| 130 | - | Drum sample organizer with AI similarity mapping and built-in sequencer. | |
| 131 | - | ||
| 132 | - | **Pricing:** One-time $179.95. VST/AU/AAX plugin + standalone. | |
| 133 | - | ||
| 134 | - | | Feature | Notes | Tag | | |
| 135 | - | |---------|-------|-----| | |
| 136 | - | | XO Space (visual similarity map) | 2D point cloud visualization of entire drum library by sonic similarity | | | |
| 137 | - | | Similarity List (nearest-neighbor suggestions) | Click any sample, see ranked list of most similar sounds | | | |
| 138 | - | | 8-track drum sequencer (32-step) | Pattern editor with A/B patterns, groove templates, velocity control | | | |
| 139 | - | | Playground Mode (randomize patterns/sounds) | Non-destructive random exploration of beat and sound combinations | | | |
| 140 | - | | 8700+ bundled one-shot drum samples | Factory content covering all drum categories and genres | | | |
| 141 | - | | 240+ beat presets | Pre-built patterns across genres | | | |
| 142 | - | | Per-channel groove presets (14 templates) | Swing/groove quantization per sequencer channel | | | |
| 143 | - | | Nudge per channel (up to 16th note) | Micro-timing offset per drum channel for humanization | | | |
| 144 | - | | Rolls (up to 4x repeats) | Drum roll effect on sequencer steps | | | |
| 145 | - | | WAV stem export + MIDI export | Export individual channel stems or full beat as audio/MIDI | | | |
| 146 | - | | Drag-and-drop individual sounds or full kit | Export raw or processed sounds to DAW via drag | | | |
| 147 | - | | Duplicate detection and sorting | Identifies and sorts duplicate samples across folders | | | |
| 148 | - | ||
| 149 | - | **Key takeaway:** XO is the closest competitor in the "make sense of a chaotic sample library" space. Its visual similarity map (XO Space) is a genuinely innovative interface that audiofiles lacks. However, XO is drum-only, has no VFS, no content-addressed storage, no tagging system, no hardware sampler export, and no general-purpose sample management. audiofiles covers all sample types and has deeper organizational tools. | |
| 150 | - | ||
| 151 | - | --- | |
| 152 | - | ||
| 153 | - | ### Sononym | |
| 154 | - | ||
| 155 | - | AI-powered sample browser with similarity search and automatic categorization. | |
| 156 | - | ||
| 157 | - | **Pricing:** One-time $99 (or 89 EUR). 30-day free trial. Windows/macOS/Linux. | |
| 158 | - | ||
| 159 | - | | Feature | Notes | Tag | | |
| 160 | - | |---------|-------|-----| | |
| 161 | - | | ML similarity search (multi-aspect) | Find similar sounds using tunable aspects: spectrum, timbre, pitch, amplitude | | | |
| 162 | - | | Aspects Dial (tunable similarity weighting) | Adjust which sonic dimensions matter most in similarity results | | | |
| 163 | - | | Live recording input for similarity search | Record audio live, use recording as similarity search query | | | |
| 164 | - | | Near-duplicate detection (fuzzy matching) | Finds not just exact duplicates but near-identical sounds | | | |
| 165 | - | | Spectrogram visualization | Visual frequency-over-time display for each sample | | | |
| 166 | - | | ML-based automatic categorization | Machine learning groups samples into categories (kick, snare, pad, etc.) | | | |
| 167 | - | | Offline-first, no internet required | Fully functional without network access | | | |
| 168 | - | ||
| 169 | - | **Key takeaway:** Sononym is the most directly comparable competitor in philosophy: local-first, analysis-driven, no subscription. Its ML similarity search with tunable aspects is more sophisticated than audiofiles' weighted Euclidean similarity search. The Aspects Dial (weight different sonic dimensions) is a compelling UX pattern. However, Sononym has no VFS, no content-addressed storage, no deduplication, no DAW plugin mode, no tagging system, and no hardware sampler export. audiofiles has a much broader feature surface. | |
| 170 | - | ||
| 171 | - | --- | |
| 172 | - | ||
| 173 | - | ### Atlas 2 by Algonaut | |
| 174 | - | ||
| 175 | - | AI-driven drum sample organizer with visual map and drum sequencer. | |
| 176 | - | ||
| 177 | - | **Pricing:** One-time $99 (upgrade from v1: $19). Free trial. VST3/AU plugin + standalone. Windows/macOS/Linux. | |
| 178 | - | ||
| 179 | - | | Feature | Notes | Tag | | |
| 180 | - | |---------|-------|-----| | |
| 181 | - | | AI timbre map (point cloud clusters) | Visual 2D map grouping samples by instrument type and timbre | | | |
| 182 | - | | Drum sequencer (step + piano roll hybrid) | Fast step entry with piano-roll flexibility for detailed editing | | | |
| 183 | - | | Variation Engine (humanization/randomization) | Randomize velocity, timing, sample start per trigger for organic feel | | | |
| 184 | - | | 8/16/64 channel drum kits | Scalable kit sizes from minimal to massive | | | |
| 185 | - | | MIDI import | Load MIDI patterns to trigger kit sounds | | | |
| 186 | - | | MIDI export (single file or per-channel) | Export patterns for use in DAW, with or without groove baked in | | | |
| 187 | - | | Mirror edit, rotate notes, nudge | Pattern manipulation tools for creative sequencing | | | |
| 188 | - | | Polyrhythm support | Different time divisions per channel | | | |
| 189 | - | | Record-in mode | Real-time recording of drum patterns | | | |
| 190 | - | ||
| 191 | - | **Key takeaway:** Atlas 2 is very similar to XO in concept (drum-focused, visual map, sequencer) but adds a more flexible sequencer with humanization. Like XO, it is drum-only and lacks general-purpose sample management, VFS, tagging, or hardware export. Not a direct competitor to audiofiles' core value proposition. | |
| 192 | - | ||
| 193 | - | --- | |
| 194 | - | ||
| 195 | - | ### AudioFinder (Iced Audio) | |
| 196 | - | ||
| 197 | - | Veteran macOS sample manager with editing, batch processing, and AudioUnit hosting. | |
| 198 | - | ||
| 199 | - | **Pricing:** One-time ~$69.95 with lifetime updates. macOS only. | |
| 200 | - | ||
| 201 | - | | Feature | Notes | Tag | | |
| 202 | - | |---------|-------|-----| | |
| 203 | - | | Sample Extractor (auto-split by volume threshold) | Split a single audio file into multiple samples at transients | | | |
| 204 | - | | Built-in sample editor (trim, loop, fade, gain, beat slice) | Destructive audio editing within the manager | | | |
| 205 | - | | AudioUnit plugin hosting | Preview samples through AudioUnit effects chains | | | |
| 206 | - | | Batch processing with DSP routines | Apply processing to many files at once | | | |
| 207 | - | | Beat detection | Detect and slice on beat boundaries | | | |
| 208 | - | | MIDI preview keyboard | Play samples at different pitches via built-in keyboard or MIDI input | | | |
| 209 | - | | Apple Loop / BWF / ACID WAV metadata display | Read and display industry-standard metadata formats | | | |
| 210 | - | | Power Rename (batch renaming) | Pattern-based bulk renaming with preview | | | |
| 211 | - | | AudioCortex 2.0 (separate AI product) | ML-based auto-categorization into 443+ categories, sound stacking tool | | | |
| 212 | - | | REX/RX2 file support | Read Propellerhead REX loop format | | | |
| 213 | - | | SYX/MID file support | Browse MIDI and SysEx files alongside audio | | | |
| 214 | - | | Bookmarks, history, favorites | Navigation aids for large libraries | | | |
| 215 | - | | Multi-channel playback and display | Preview and visualize surround/multi-channel audio files | | | |
| 216 | - | | Rating system (star ratings per sample) | Rate samples for quality-based filtering | | | |
| 217 | - | ||
| 218 | - | **Key takeaway:** AudioFinder is the most mature pure sample manager in this list, with deep editing and batch processing features that audiofiles lacks. Its Sample Extractor (auto-split audio into individual hits) is a genuinely useful tool for sample pack creation. However, it is macOS-only, has no DAW plugin mode, no content-addressed storage, no VFS, and no hardware sampler export. AudioCortex (AI categorization) is a separate paid product. The codebase appears to be aging (Objective-C era tooling). | |
| 219 | - | ||
| 220 | - | ## Closed Gaps (since initial analysis) | |
| 221 | - | ||
| 222 | - | Features that were previously missing and are now implemented: | |
| 223 | - | ||
| 224 | - | - **True drag-and-drop to DAW** -- macOS NSDraggingSession, Windows OLE DoDragDrop. Multi-file, friendly filenames via temp symlinks. [Done] | |
| 225 | - | - **Waveform display** -- Custom egui painter with click-to-seek, rendered from pre-generated peak data. [Done] | |
| 226 | - | - **Collections / favorites / playlists** -- Cross-VFS groupings with full CRUD. [Done] | |
| 227 | - | - **Similarity search** -- Weighted Euclidean distance on analysis feature vectors. "Find Similar" context menu. [Done] | |
| 228 | - | - **Near-duplicate detection** -- Peak envelope fingerprint comparison. "Find Duplicates" context menu. [Done] | |
| 229 | - | - **Cloud sync** -- E2E encrypted push/pull via MNW SyncKit, per-VFS file sync toggle, cloud-only browsing. [Done] | |
| 230 | - | - **MIDI instrument mode** -- Chromatic + multi-sample, ADSR, 8-voice polyphony, drag-to-keyboard zone assignment. [Done] | |
| 231 | - | - **ML-based categorization** -- Two-layer system: rule-based broad classifier + 200-tree Random Forest for drum sub-classification. 16 categories, 94.4% accuracy on labeled drums. [Done] | |
| 232 | - | ||
| 233 | - | ## Remaining Gaps | |
| 234 | - | ||
| 235 | - | Features present in multiple competitors that audiofiles still lacks, grouped by priority. | |
| 236 | - | ||
| 237 | - | ### Worth Adding | |
| 238 | - | ||
| 239 | - | - **Auto tempo/key-matched preview** -- Splice Bridge and Loopcloud auto-stretch to project BPM/key. Requires time-stretch library. | |
| 240 | - | - **Preview effects (filter, pitch)** -- ADSR has HP/LP filter, fade, gain, normalize baked into drag-to-DAW output. Loopcloud has 10 built-in effects. HP/LP filter on preview would be a small, useful addition. Full FX chains are bloat. | |
| 241 | - | - **Visual similarity map (2D point cloud)** -- XO, Atlas 2, Sononym. Visually compelling for exploration. Non-trivial in egui but high wow-factor. | |
| 242 | - | - **Spectrogram visualization** -- Sononym, AudioFinder. Frequency-over-time display per sample. | |
| 243 | - | ||
| 244 | - | ### Low Priority / Out of Scope | |
| 245 | - | ||
| 246 | - | - **Built-in sequencer / pattern editor** -- XO and Atlas 2 have drum sequencers. DAW territory; not in AF's scope. | |
| 247 | - | - **Cloud sample marketplace / storefront** -- Splice and Loopcloud each have 4M+ royalty-free samples. Requires massive catalog licensing, content moderation, and ongoing operational cost. Contradicts audiofiles' local-first philosophy. The planned community sharing feature (deferred) is a lighter alternative. | |
| 248 | - | - **Cloud storage** -- Splice and Loopcloud sync samples to cloud. AF has its own optional sync tier planned, but cloud-as-primary contradicts local-first philosophy. | |
| 249 | - | - **Mobile app** -- Splice has iOS/Android. Out of scope until desktop experience is mature. [Deferred] | |
| 250 | - | - **Sample editing (trim, loop, fade, slice)** -- AudioFinder, Loopcloud. Planned as sample forge phases (10-16 in todo.md). Export-time transformations via device profiles cover the basic case. | |
| 251 | - | - **Batch processing / DSP** -- AudioFinder, Loopcloud. Planned as batch forge phase (15 in todo.md). | |
| 252 | - | - **Full effects chain on preview** -- Bloat for a manager. | |
| 253 | - | - **Social features / sharing / community** -- Premature, low signal. | |
| 254 | - | ||
| 255 | - | ## What We Offer That Competitors Don't | |
| 256 | - | ||
| 257 | - | - **Content-addressed storage with SHA-256 deduplication** -- Samples stored by hash, never duplicated regardless of how many VFS trees reference them. No other tool does this. | |
| 258 | - | - **Multiple Virtual File Systems** -- Organize the same samples into unlimited hierarchies without copying files. All operations are metadata-only (instant moves, renames, multi-tree views). | |
| 259 | - | - **Hardware sampler export profiles** -- Rhai-scripted export to 10+ devices (M8, Digitakt, Digitakt II, SP-404 MKII, MPC, Polyend Tracker, Deluge, Blackbox, Volca Sample 2, OP-1) with proper sample rate conversion, bit depth dithering, and filename sanitization. No competitor offers this. | |
| 260 | - | - **8 local analysis types** -- BPM, key, LUFS, spectral (centroid/flatness/rolloff/ZCR), MFCC, ML classification (16 categories), loop detection, tag suggestions. Splice and Loopcloud rely on catalog metadata; Sononym has ML similarity but fewer distinct analysis types. | |
| 261 | - | - **Tag suggestion engine with confidence scores** -- Analyzes audio characteristics and suggests tags with reasoning. Sononym v1.6 added tagging but without confidence scoring. | |
| 262 | - | - **Bulk operations with 50-deep undo** -- Delete, move, rename, tag operations all undoable. No competitor has this depth of undo. | |
| 263 | - | - **Rhai scripting for extensibility** -- Export profiles, future import adapters. Community-extensible via plugin.toml manifests + optional scripts in a sandboxed runtime. No other sample manager has a scripting runtime. | |
| 264 | - | - **Built entirely in Rust** -- Memory-safe, no garbage collector pauses, real-time audio thread safety via try_lock. | |
| 265 | - | - **Sync-only core library (no async runtime)** -- Simple, predictable data layer with no async complexity. | |
| 266 | - | - **SQLite-backed metadata** -- Single-file database, no server, portable, fast. | |
| 267 | - | - **Source-available** -- Unique among all competitors. | |
| 268 | - | ||
| 269 | - | ## Key Dynamics | |
| 270 | - | ||
| 271 | - | - Splice's native DAW integrations (embedded in Pro Tools, Studio One, Ableton without a plugin) represent a shift in the market. Direct DAW embedding is the future for marketplace products, but irrelevant for standalone managers. | |
| 272 | - | - Sononym v1.6 (2025) added tagging and UCS integration, closing the gap on AF's tag system, but AF's confidence-scored ML suggestion engine and content-addressed storage remain unique. | |
| 273 | - | - All major feature gaps from the initial analysis have been closed (drag-to-DAW, waveform, collections, similarity, near-duplicate detection, ML classification, MIDI instrument). Remaining gaps are in the "nice to have" category: preview effects, visual similarity map, spectrogram. | |
| 274 | - | - The competitive moat is the combination of features no single competitor matches: content-addressed storage + VFS + hardware export + ML classification + Rhai scripting + cloud sync. Each individual feature has a competitor; the combination doesn't. | |
| 275 | - | ||
| 276 | - | ## Target Users | |
| 277 | - | ||
| 278 | - | - Music producers who accumulate large sample libraries and need to organize, search, and preview them efficiently | |
| 279 | - | - Hardware sampler owners (Digitakt, SP-404, MPC, OP-1, M8, etc.) who need to format and export samples to device-specific constraints | |
| 280 | - | - Sound designers who need content-addressed deduplication across projects | |
| 281 | - | - Sample pack creators who need bulk tagging, renaming, and export tools | |
| 282 | - |
| @@ -1,100 +0,0 @@ | |||
| 1 | - | # audiofiles — Build & Deploy | |
| 2 | - | ||
| 3 | - | See `_meta/docs/deploy.md` for shared infrastructure (machines, git remotes, collection layout, shared deps). | |
| 4 | - | ||
| 5 | - | ## Artifacts | |
| 6 | - | ||
| 7 | - | | Platform | Artifact | Machine | | |
| 8 | - | |----------|----------|---------| | |
| 9 | - | | macOS aarch64 | .dmg | local | | |
| 10 | - | | Linux aarch64 | AppImage | astra | | |
| 11 | - | | Linux x86_64 | AppImage | pop-os | | |
| 12 | - | | Windows x86_64 | .msi + standalone .exe | windows-x86 | | |
| 13 | - | ||
| 14 | - | ## Build Commands | |
| 15 | - | ||
| 16 | - | ```bash | |
| 17 | - | # macOS (local): | |
| 18 | - | cd ~/Code/Apps/audiofiles && cargo build --release -p audiofiles-app | |
| 19 | - | cp dist/AudioFiles_*_arm64.dmg ~/Dist/audiofiles/macos/ | |
| 20 | - | ||
| 21 | - | # Linux aarch64 (astra): | |
| 22 | - | ssh astra "source ~/.cargo/env && cd ~/Code/Apps/audiofiles && git pull && bash dist/build-appimage.sh" | |
| 23 | - | scp astra:~/Code/Apps/audiofiles/dist/AudioFiles-*-aarch64.AppImage ~/Dist/audiofiles/linux-aarch64/ | |
| 24 | - | ||
| 25 | - | # Linux x86_64 (pop-os): | |
| 26 | - | ssh pop-os "source ~/.cargo/env && cd ~/Code/Apps/audiofiles && git pull && bash dist/build-appimage.sh" | |
| 27 | - | scp pop-os:~/Code/Apps/audiofiles/dist/AudioFiles-*-x86_64.AppImage ~/Dist/audiofiles/linux-x86_64/ | |
| 28 | - | ||
| 29 | - | # Windows (windows-x86) — native build + MSI installer via WiX: | |
| 30 | - | ssh me@windows-x86 "cd C:\Users\me\Code\Apps\audiofiles; git pull; pwsh -File dist\build-msi-native.ps1" | |
| 31 | - | scp me@windows-x86:"C:/Users/me/Code/Apps/audiofiles/dist/AudioFiles_VERSION_x86_64.msi" ~/Dist/audiofiles/windows/ | |
| 32 | - | scp me@windows-x86:"C:/Users/me/Code/Apps/audiofiles/dist/AudioFiles_VERSION_x86_64.exe" ~/Dist/audiofiles/windows/ | |
| 33 | - | ||
| 34 | - | # Emergency cross-compile fallback (macOS/Linux host, msitools wixl + cargo-xwin): | |
| 35 | - | bash dist/build-msi.sh | |
| 36 | - | ``` | |
| 37 | - | ||
| 38 | - | ## Windows Installer | |
| 39 | - | ||
| 40 | - | Canonical builder: `dist/build-msi-native.ps1` runs on **windows-x86** with the | |
| 41 | - | WiX Toolset (`winget install WiXToolset.WiXToolset`). It produces an MSI with | |
| 42 | - | Start Menu shortcut, per-user install scope, and major-upgrade behavior. | |
| 43 | - | ||
| 44 | - | WiX source lives in `dist/audiofiles.wxs.in` (shared by both builders). | |
| 45 | - | `@VERSION@` is substituted from `crates/audiofiles-app/Cargo.toml`. | |
| 46 | - | ||
| 47 | - | ### Code signing | |
| 48 | - | ||
| 49 | - | The PowerShell builder will sign the EXE and MSI with `signtool.exe` when these | |
| 50 | - | environment variables are set: | |
| 51 | - | ||
| 52 | - | | Var | Meaning | | |
| 53 | - | |-----|---------| | |
| 54 | - | | `AF_SIGN_CERT` | Path to a `.pfx`, OR SHA1 thumbprint of a cert in the local Windows cert store | | |
| 55 | - | | `AF_SIGN_PASS` | Password for the `.pfx` (omit if cert is in store) | | |
| 56 | - | | `AF_SIGN_TIMESTAMP_URL` | RFC 3161 timestamp URL (default: `http://timestamp.digicert.com`) | | |
| 57 | - | ||
| 58 | - | If `AF_SIGN_CERT` is unset, the build proceeds **unsigned** with a warning — | |
| 59 | - | the current state (code signing blocked on Azure certificate history | |
| 60 | - | requirement; see the GO deploy doc for context, same blocker applies). | |
| 61 | - | ||
| 62 | - | ## Project-Specific Notes | |
| 63 | - | ||
| 64 | - | - Native egui app (not Tauri). No Tauri CLI or Node.js needed. Windows ships both a standalone `.exe` and a WiX-built `.msi` installer (see § Windows Installer above). | |
| 65 | - | - No OTA updater — users download new versions from the MNW storefront. | |
| 66 | - | - License key activation at first launch via MNW API. | |
| 67 | - | - Version is in workspace root `Cargo.toml` under `[workspace.package]`. | |
| 68 | - | - AppImage packaging via `dist/build-appimage.sh` (downloads appimagetool automatically). | |
| 69 | - | - Requires `libxdo-dev` on Linux for keyboard shortcut handling. | |
| 70 | - | ||
| 71 | - | ## Troubleshooting | |
| 72 | - | ||
| 73 | - | ### Linker error: `libxdo` not found (Linux) | |
| 74 | - | ||
| 75 | - | AF uses `libxdo` for global hotkey support. Install on both Linux machines: | |
| 76 | - | ||
| 77 | - | ```bash | |
| 78 | - | sudo apt install libxdo-dev | |
| 79 | - | ``` | |
| 80 | - | ||
| 81 | - | ### Linker error: `libasound` not found (Linux) | |
| 82 | - | ||
| 83 | - | AF uses ALSA for audio playback. Install: | |
| 84 | - | ||
| 85 | - | ```bash | |
| 86 | - | sudo apt install libasound2-dev | |
| 87 | - | ``` | |
| 88 | - | ||
| 89 | - | ### Windows: `Win32_Storage_FileSystem` feature missing | |
| 90 | - | ||
| 91 | - | If the build fails with unresolved imports from the `windows` crate related to file system APIs, check that `Cargo.toml` enables the `Win32_Storage_FileSystem` feature on the `windows` dependency. This is a compile-time feature gate, not a missing system library. | |
| 92 | - | ||
| 93 | - | ### Windows: sccache interference | |
| 94 | - | ||
| 95 | - | Same as other projects — unset `RUSTC_WRAPPER` if sccache crashes: | |
| 96 | - | ||
| 97 | - | ```powershell | |
| 98 | - | $env:RUSTC_WRAPPER = '' | |
| 99 | - | cargo build --release -p audiofiles-app | |
| 100 | - | ``` |
| @@ -1,166 +0,0 @@ | |||
| 1 | - | # Distribution | |
| 2 | - | ||
| 3 | - | ## Overview | |
| 4 | - | ||
| 5 | - | AudioFiles is distributed as standalone binaries for macOS, Windows, and Linux. All builds are cross-compiled from macOS except Linux aarch64, which builds natively on Astra. Artifacts are hosted at `dl.maxj.phd/af/` via Caddy file_server. | |
| 6 | - | ||
| 7 | - | ## Build Targets | |
| 8 | - | ||
| 9 | - | | Platform | Script | Toolchain | Artifact | | |
| 10 | - | |----------|--------|-----------|----------| | |
| 11 | - | | macOS arm64 | `dist/build-macos.sh` | native cargo | DMG (signed + notarized) | | |
| 12 | - | | Windows x86_64 | `dist/build-msi.sh` | cargo-xwin | MSI + standalone EXE | | |
| 13 | - | | Linux aarch64 | `dist/build-appimage.sh`, `dist/build-deb.sh` | native cargo on Astra | AppImage + .deb | | |
| 14 | - | | Linux x86_64 | `dist/build-appimage.sh`, `dist/build-deb.sh` | native cargo on x86_64 host | AppImage + .deb | | |
| 15 | - | ||
| 16 | - | ### Prerequisites (macOS build host) | |
| 17 | - | ||
| 18 | - | - `cargo-xwin` and `x86_64-pc-windows-msvc` Rust target (Windows cross-compile) | |
| 19 | - | - `wixl` from `msitools` (MSI packaging) | |
| 20 | - | - Apple Developer ID certificate + `notarytool-profile` keychain profile (macOS signing/notarization) | |
| 21 | - | - SSH access to Astra via Tailscale (`root@100.106.221.39`) | |
| 22 | - | ||
| 23 | - | ### Linux aarch64 builds (Astra) | |
| 24 | - | ||
| 25 | - | Astra has Rust + GTK dev headers installed. Linux builds cannot cross-compile from macOS due to GTK/glib pkg-config requirements. | |
| 26 | - | ||
| 27 | - | Build process: | |
| 28 | - | 1. Create source tarball (include `MNW/shared/synckit-client/`, `MNW/shared/docengine/`, `MNW/shared/tagtree/` for path deps) | |
| 29 | - | 2. Upload to Astra, extract, `cargo build --release -p audiofiles-app` | |
| 30 | - | 3. Package deb and AppImage on Astra using `dpkg-deb` and `appimagetool` | |
| 31 | - | 4. Download artifacts back to local `dist/` | |
| 32 | - | ||
| 33 | - | **Never build on Hetzner.** Hetzner is a production server for MNW/MT/PoM only. | |
| 34 | - | ||
| 35 | - | ### Linux x86_64 builds (cross-compile on Astra) | |
| 36 | - | ||
| 37 | - | Cross-compile x86_64 binaries from Astra (aarch64). Requires the `x86_64-unknown-linux-gnu` Rust target, an x86_64 cross-linker (`gcc-x86-64-linux-gnu`), and x86_64 GTK libraries (`libgtk-3-dev:amd64`). | |
| 38 | - | ||
| 39 | - | Setup (one-time, already done on Astra): | |
| 40 | - | ```bash | |
| 41 | - | # On Astra | |
| 42 | - | sudo dpkg --add-architecture amd64 | |
| 43 | - | sudo apt update | |
| 44 | - | sudo apt install gcc-x86-64-linux-gnu libgtk-3-dev:amd64 libssl-dev:amd64 libasound2-dev:amd64 libxcb1-dev:amd64 libxkbcommon-dev:amd64 libgl-dev:amd64 libxdo-dev:amd64 | |
| 45 | - | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable | |
| 46 | - | source "$HOME/.cargo/env" | |
| 47 | - | rustup target add x86_64-unknown-linux-gnu | |
| 48 | - | ``` | |
| 49 | - | ||
| 50 | - | Note: `libpango1.0-dev:amd64` may conflict with the arm64 version — use `dpkg --force-overwrite` if needed. | |
| 51 | - | ||
| 52 | - | Build process: | |
| 53 | - | 1. Create source tarball (include `MNW/shared/synckit-client/`, `MNW/shared/docengine/`, `MNW/shared/tagtree/` for path deps) | |
| 54 | - | 2. Upload to Astra, extract | |
| 55 | - | 3. Cross-compile: `PKG_CONFIG_SYSROOT_DIR=/ PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc cargo build --release -p audiofiles-app --target x86_64-unknown-linux-gnu` | |
| 56 | - | 4. Package AppImage using `appimagetool-x86_64` and deb using `dpkg-deb` with `Architecture: amd64` | |
| 57 | - | 5. Download artifacts back to local `dist/` | |
| 58 | - | ||
| 59 | - | **Never build on Hetzner.** Hetzner is a production server for MNW/MT/PoM only — no Rust, no dev deps, no app builds. | |
| 60 | - | ||
| 61 | - | ## Release Steps | |
| 62 | - | ||
| 63 | - | ### 1. Version bump | |
| 64 | - | ||
| 65 | - | Update version in all 7 crate Cargo.tomls: | |
| 66 | - | - `crates/audiofiles-core/Cargo.toml` | |
| 67 | - | - `crates/audiofiles-app/Cargo.toml` | |
| 68 | - | - `crates/audiofiles-browser/Cargo.toml` | |
| 69 | - | - `crates/audiofiles-sync/Cargo.toml` | |
| 70 | - | - `crates/audiofiles-rhai/Cargo.toml` | |
| 71 | - | - `crates/audiofiles-train/Cargo.toml` | |
| 72 | - | - `crates/audiofiles-bench/Cargo.toml` | |
| 73 | - | ||
| 74 | - | ### 2. Commit and tag | |
| 75 | - | ||
| 76 | - | ``` | |
| 77 | - | git add -A && git commit -m "v0.X.Y: summary" | |
| 78 | - | ``` | |
| 79 | - | ||
| 80 | - | ### 3. Build all platforms | |
| 81 | - | ||
| 82 | - | ```bash | |
| 83 | - | # macOS (includes signing + notarization + stapling) | |
| 84 | - | APPLE_API_KEY_PATH="$HOME/Code/_private/AuthKey_MVR74793B9.p8" bash dist/build-macos.sh | |
| 85 | - | ||
| 86 | - | # Windows (MSI + EXE via cargo-xwin) | |
| 87 | - | bash dist/build-msi.sh | |
| 88 | - | ||
| 89 | - | # Linux aarch64 (on Astra — see above) | |
| 90 | - | # Linux x86_64 (on dedicated x86_64 host — see above) | |
| 91 | - | ``` | |
| 92 | - | ||
| 93 | - | All four can run in parallel. macOS takes longest due to notarization wait. | |
| 94 | - | ||
| 95 | - | ### 4. Pre-upload checklist | |
| 96 | - | ||
| 97 | - | Before uploading, verify all 7 artifacts exist in `dist/`: | |
| 98 | - | ||
| 99 | - | - [ ] `AudioFiles_X.Y.Z_arm64.dmg` (macOS) | |
| 100 | - | - [ ] `AudioFiles_X.Y.Z_x86_64.msi` (Windows MSI) | |
| 101 | - | - [ ] `AudioFiles_X.Y.Z_x86_64.exe` (Windows standalone) | |
| 102 | - | - [ ] `AudioFiles-X.Y.Z-aarch64.AppImage` (Linux aarch64) | |
| 103 | - | - [ ] `audiofiles_X.Y.Z_arm64.deb` (Linux aarch64 deb) | |
| 104 | - | - [ ] `AudioFiles-X.Y.Z-x86_64.AppImage` (Linux x86_64) | |
| 105 | - | - [ ] `audiofiles_X.Y.Z_amd64.deb` (Linux x86_64 deb) | |
| 106 | - | ||
| 107 | - | **Do not upload a partial set.** If a platform build is missing, resolve it before releasing. | |
| 108 | - | ||
| 109 | - | ### 5. Archive old versions on server | |
| 110 | - | ||
| 111 | - | ```bash | |
| 112 | - | ssh root@100.120.174.96 "mv /opt/downloads/af/*OLD_VERSION* /opt/downloads/af/archive/" | |
| 113 | - | ``` | |
| 114 | - | ||
| 115 | - | ### 6. Upload new artifacts | |
| 116 | - | ||
| 117 | - | ```bash | |
| 118 | - | scp dist/AudioFiles_X.Y.Z_arm64.dmg \ | |
| 119 | - | dist/AudioFiles_X.Y.Z_x86_64.msi \ | |
| 120 | - | dist/AudioFiles_X.Y.Z_x86_64.exe \ | |
| 121 | - | dist/AudioFiles-X.Y.Z-aarch64.AppImage \ | |
| 122 | - | dist/audiofiles_X.Y.Z_arm64.deb \ | |
| 123 | - | dist/AudioFiles-X.Y.Z-x86_64.AppImage \ | |
| 124 | - | dist/audiofiles_X.Y.Z_amd64.deb \ | |
| 125 | - | root@100.120.174.96:/opt/downloads/af/ | |
| 126 | - | ``` | |
| 127 | - | ||
| 128 | - | ### 7. Verify | |
| 129 | - | ||
| 130 | - | Browse `https://dl.maxj.phd/af/` and confirm: | |
| 131 | - | - All 7 artifacts for the current version are present | |
| 132 | - | - Old versions are in `archive/` | |
| 133 | - | - Every platform (macOS, Windows, Linux aarch64, Linux x86_64) is covered | |
| 134 | - | ||
| 135 | - | ## File naming conventions | |
| 136 | - | ||
| 137 | - | - macOS: `AudioFiles_VERSION_arm64.dmg` | |
| 138 | - | - Windows MSI: `AudioFiles_VERSION_x86_64.msi` | |
| 139 | - | - Windows EXE: `AudioFiles_VERSION_x86_64.exe` | |
| 140 | - | - Linux AppImage (aarch64): `AudioFiles-VERSION-aarch64.AppImage` | |
| 141 | - | - Linux AppImage (x86_64): `AudioFiles-VERSION-x86_64.AppImage` | |
| 142 | - | - Linux deb (aarch64): `audiofiles_VERSION_arm64.deb` | |
| 143 | - | - Linux deb (x86_64): `audiofiles_VERSION_amd64.deb` | |
| 144 | - | ||
| 145 | - | **Every release must have all 7 artifacts.** A release missing a platform is incomplete. | |
| 146 | - | ||
| 147 | - | ## Server layout | |
| 148 | - | ||
| 149 | - | ``` | |
| 150 | - | /opt/downloads/af/ | |
| 151 | - | AudioFiles_0.3.4_arm64.dmg | |
| 152 | - | AudioFiles_0.3.4_x86_64.msi | |
| 153 | - | AudioFiles_0.3.4_x86_64.exe | |
| 154 | - | AudioFiles-0.3.4-aarch64.AppImage | |
| 155 | - | AudioFiles-0.3.4-x86_64.AppImage | |
| 156 | - | audiofiles_0.3.4_arm64.deb | |
| 157 | - | audiofiles_0.3.4_amd64.deb | |
| 158 | - | archive/ | |
| 159 | - | AudioFiles_0.3.0_* | |
| 160 | - | ... | |
| 161 | - | AudioFiles_0.3.3_* | |
| 162 | - | ``` | |
| 163 | - | ||
| 164 | - | ## .gitignore | |
| 165 | - | ||
| 166 | - | Built artifacts in `dist/` are gitignored: `*.dmg`, `*.exe`, `*.msi`, `*.AppImage`, `*.deb`, `AudioFiles.app/`. |
| @@ -1,100 +0,0 @@ | |||
| 1 | - | # Free Sample Sources for AF | |
| 2 | - | ||
| 3 | - | Curated links for testing, development, and ML classifier training. | |
| 4 | - | ||
| 5 | - | ## ML / Research Datasets (Pre-Labeled) | |
| 6 | - | ||
| 7 | - | Good for training the drum/non-drum classifier and expanding coverage. | |
| 8 | - | ||
| 9 | - | - [x] [ESC-50](https://github.com/karolpiczak/ESC-50) — 2,000 environmental recordings, 50 classes, 5s clips (1.4GB) `samples/datasets/ESC-50/` | |
| 10 | - | - [x] [GitHub pdx-cs-sound/wavs](https://github.com/pdx-cs-sound/wavs) — small WAV collection for CS coursework (47MB) `samples/datasets/pdx-cs-wavs/` | |
| 11 | - | - [ ] [FSD50K](https://annotator.freesound.org/fsd/downloads/) — 51,197 clips, 200 classes, human-labeled (CC BY). Manual download required. | |
| 12 | - | - [ ] [AI Audio Datasets index](https://github.com/Yuan-ManX/ai-audio-datasets) — curated list of speech, music, and SFX datasets for ML (reference only) | |
| 13 | - | - [ ] [DagsHub Audio Datasets](https://dagshub.com/datasets/audio/) — aggregated free audio datasets for ML (reference only) | |
| 14 | - | - [ ] [Freesound Datasets platform](https://labs.freesound.org/datasets/) — browse all Freesound-sourced research datasets (reference only) | |
| 15 | - | ||
| 16 | - | ## Drum Samples (One-Shots) | |
| 17 | - | ||
| 18 | - | Primary training data for layer 2 classifier. Also good for general testing. | |
| 19 | - | ||
| 20 | - | - [x] [Goldbaby Free Packs](https://www.goldbaby.co.nz/freestuff.html) — 38 packs, vintage drum machines through analog tape (910MB) `samples/drums/goldbaby/` | |
| 21 | - | - [x] [Archive.org Drum Machines](https://archive.org/details/drum-machines-collection) — 468 drum machines (4.0GB) `samples/drums/archive-org-drum-machines/` | |
| 22 | - | - [x] [Archive.org Drum Kits](https://archive.org/details/drumkit_samples) — basic drum kit one-shots (1.8MB) `samples/drums/archive-org-drum-kits/` | |
| 23 | - | - [ ] [Samples From Mars Free](https://samplesfrommars.com/collections/free) — 7 free packs (808, MPC60, vinyl, 909, etc.). **Requires Shopify checkout** — open this cart URL in browser: `https://samplesfrommars.com/cart/42612500234430:1,12588675530836:1,42960124422:1,42619621605566:1,32208185229396:1,13628220178516:1,43960036166:1` | |
| 24 | - | - [ ] [Black Octopus Sound Free](https://blackoctopus-sound.com/) — 1.5GB library: percussion, loops, drum kits. Manual download. | |
| 25 | - | - [ ] [Bedroom Producers Blog - 11 Best Free Drum Kits](https://bedroomproducersblog.com/2021/07/02/free-drum-kits/) — curated roundup, updated 2026 (reference only) | |
| 26 | - | - [ ] [HipHopMakers - 20,000+ Free Drum Samples](https://hiphopmakers.com/ultimate-drum-collection-over-20000-free-drum-samples) — massive collection links (reference only) | |
| 27 | - | ||
| 28 | - | ## Instrument Samples (Non-Drum) | |
| 29 | - | ||
| 30 | - | Useful as negative examples for classifier training, and for testing non-drum file handling. | |
| 31 | - | ||
| 32 | - | - [x] [Philharmonia Orchestra](https://archive.org/details/philharmonicorchestrasamples) — 20 orchestral instruments (241MB, MP3 format) `samples/instruments/philharmonia/` | |
| 33 | - | - [ ] [FreeWaveSamples.com](https://freewavesamples.com/) — 2,300 instrument one-shots. Individual downloads only (no bulk). | |
| 34 | - | - [ ] [GhostHack - Guitar & Piano Samples](https://www.ghosthack.de/free_sample_packs/free-guitar-and-piano-samples) — free one-shots, royalty-free. Manual download. | |
| 35 | - | - [ ] [FiftySounds - Piano One-Shots](https://www.fiftysounds.com/royalty-free-music/one-shot-piano-samples.html) — keyboard one-shots, no registration | |
| 36 | - | - [ ] [Samplephonics Free One-Shots](https://www.samplephonics.com/products/free/one-shots) — redirects to noiiz.com. Account required. | |
| 37 | - | - [ ] [Sample Focus - Piano](https://samplefocus.com/categories/piano) — community-uploaded free piano samples | |
| 38 | - | ||
| 39 | - | ## Mixed (Multi-Genre) | |
| 40 | - | ||
| 41 | - | - [x] [MusicRadar SampleRadar](https://www.musicradar.com/news/tech/free-music-samples-royalty-free-loops-hits-and-multis-to-download-sampleradar) — 267 packs, all genres (65GB, 24-bit WAV) `samples/mixed/musicradar/` | |
| 42 | - | ||
| 43 | - | ## Synth / Bass / Pads | |
| 44 | - | ||
| 45 | - | - [ ] [Looperman - Synth Loops](https://www.looperman.com/loops/tags/free-synth-loops-samples-sounds-wavs-download) — community synth loops, WAV. Individual downloads. | |
| 46 | - | - [ ] [Looperman - Synth Pads](https://www.looperman.com/loops/tags/free-synth-pad-loops-samples-sounds-wavs-download) — pad textures. Individual downloads. | |
| 47 | - | - [ ] [HipHopMakers - 900 Free Bass Samples](https://hiphopmakers.com/best-free-bass-samples) — bass one-shots and loops (reference only) | |
| 48 | - | - [ ] [Free Sample Packs - Bass](https://free-sample-packs.com/bass/) — curated bass downloads (reference only) | |
| 49 | - | - [ ] [Function Loops Free](https://www.functionloops.com/free-samples.html) — multi-genre packs, 24-bit WAV. Manual download. | |
| 50 | - | ||
| 51 | - | ## Foley / SFX / Field Recordings | |
| 52 | - | ||
| 53 | - | Non-musical content. Good for classifier edge cases and diverse test coverage. | |
| 54 | - | ||
| 55 | - | - [ ] [99Sounds - Garage Foley](https://99sounds.org/garage-foley/) — 100 foley sounds, 32-bit WAV. **Gumroad checkout** (free, email required): https://99sounds.gumroad.com/l/foley-sounds/ | |
| 56 | - | - [ ] [99Sounds - All Free Sounds](https://99sounds.org/sounds/) — 22 packs. Gumroad checkout required per pack. | |
| 57 | - | - [ ] [BVKER - Footsteps](https://bvker.com/foley-sound-effects/) — 700+ WAVs, CC0 license. Site returned 403. | |
| 58 | - | - [ ] [Zapsplat Foley](https://www.zapsplat.com/sound-effect-category/foley/) — professional foley SFX. Account required. | |
| 59 | - | - [ ] [Pixabay Sound Effects](https://pixabay.com/sound-effects/) — royalty-free, no attribution required. Individual downloads. | |
| 60 | - | - [ ] [Signature Sounds](https://signaturesounds.org) — 130+ packs: field recordings, textures, foley, instruments. Store checkout required. | |
| 61 | - | ||
| 62 | - | ## General-Purpose Sample Sites | |
| 63 | - | ||
| 64 | - | Large catalogs with mixed content across all categories. Reference only. | |
| 65 | - | ||
| 66 | - | - [ ] [Freesound.org](https://www.freesound.org/) — 400k+ sounds, Creative Commons, community-driven | |
| 67 | - | - [ ] [SoundPacks.com](https://soundpacks.com/category/free-sound-packs/) — free packs added daily | |
| 68 | - | - [ ] [Free-Sample-Packs.com](https://free-sample-packs.com/) — curated free downloads by category | |
| 69 | - | - [ ] [Lucid Samples Free](https://www.lucidsamples.com/14-free-sample-packs) — vetted packs with clear licensing | |
| 70 | - | - [ ] [WAVS.com Free](https://wavs.com/free-samples) — curated free section | |
| 71 | - | - [ ] [Cymatics Free Packs](https://cymatics.fm/blogs/production/free-foley-sound-effects) — production-ready packs | |
| 72 | - | - [ ] [Freshloops.net](https://freshloops.net/) — loops, one-shots, MIDI | |
| 73 | - | ||
| 74 | - | ## Testing-Specific (Developer Samples) | |
| 75 | - | ||
| 76 | - | Small files useful for unit/integration test fixtures. | |
| 77 | - | ||
| 78 | - | - [ ] [TheTestData.com WAV](https://thetestdata.com/sample-wav-audio-file-download.php) — sample WAV files for testing, various formats | |
| 79 | - | - [ ] [MySampleFiles.com WAV](https://www.mysamplefiles.com/download-free-wav-files-samples) — WAV test files, multiple configs | |
| 80 | - | ||
| 81 | - | ## Download Summary (2026-04-06) | |
| 82 | - | ||
| 83 | - | | Source | Files | Size | Location | | |
| 84 | - | |--------|-------|------|----------| | |
| 85 | - | | ESC-50 dataset | 2,000 | 1.4GB | `samples/datasets/ESC-50/` | | |
| 86 | - | | pdx-cs-wavs | ~50 | 47MB | `samples/datasets/pdx-cs-wavs/` | | |
| 87 | - | | Goldbaby | 38 ZIPs | 910MB | `samples/drums/goldbaby/` | | |
| 88 | - | | Archive.org Drum Machines | 468 ZIPs | 4.0GB | `samples/drums/archive-org-drum-machines/` | | |
| 89 | - | | Archive.org Drum Kits | 18 files | 1.8MB | `samples/drums/archive-org-drum-kits/` | | |
| 90 | - | | Philharmonia Orchestra | 20 ZIPs | 241MB | `samples/instruments/philharmonia/` | | |
| 91 | - | | MusicRadar SampleRadar | 267 ZIPs | 65GB | `samples/mixed/musicradar/` | | |
| 92 | - | | **Total** | **~2,861** | **~72GB** | | | |
| 93 | - | ||
| 94 | - | ## Notes | |
| 95 | - | ||
| 96 | - | - **Licensing**: MusicRadar is royalty-free (no redistribution). Freesound uses CC licenses (check per-file). ESC-50 is CC-licensed research data. Goldbaby and Archive.org samples are free for personal/production use. | |
| 97 | - | - **Formats**: MusicRadar = 24-bit WAV. ESC-50 = OGG. Philharmonia = MP3. Goldbaby/Archive.org = WAV in ZIPs. Unzip before importing into AF. | |
| 98 | - | - **Classifier training priority**: Drum one-shots (Goldbaby, Archive.org machines) and non-drum one-shots (Philharmonia instruments) are highest value for expanding the layer 2 RF model. ESC-50 environmental sounds are great for testing classifier rejection of non-musical content. | |
| 99 | - | - **Diversity**: MusicRadar packs span all genres (ambient, techno, hip-hop, rock, jazz, world, etc.) with both loops and one-shots — excellent for broad coverage testing. | |
| 100 | - | - **Manual downloads remaining**: Samples From Mars (Shopify cart URL ready), 99Sounds (Gumroad), FSD50K (research dataset portal). |
| @@ -1,167 +0,0 @@ | |||
| 1 | - | # audiofiles — Pre-Launch Manual Testing | |
| 2 | - | ||
| 3 | - | Run before every release. Sign-off table at the bottom. | |
| 4 | - | ||
| 5 | - | ## How to Test | |
| 6 | - | ||
| 7 | - | - Work through P0 first — these are launch-blocking | |
| 8 | - | - Note failures inline and keep going; don't block the whole run | |
| 9 | - | - P1 = core features, P2 = edge cases / platform-specific | |
| 10 | - | - Use a scratch `audiofiles_data` directory so testing doesn't touch your real library | |
| 11 | - | ||
| 12 | - | ### Environment Setup | |
| 13 | - | ||
| 14 | - | - [ ] Built binary in `dist/` (or `cargo run --release` from `Apps/audiofiles/`) | |
| 15 | - | - [ ] A folder of test audio samples covering WAV, MP3, FLAC, AIFF | |
| 16 | - | - [ ] MNW account with at least one blob-sync tier (Light/Standard/Large) for sync tests | |
| 17 | - | - [ ] DAW or file manager available for drag-out verification | |
| 18 | - | ||
| 19 | - | --- | |
| 20 | - | ||
| 21 | - | ## P0 — Critical Path | |
| 22 | - | ||
| 23 | - | > If any of these fail, do not ship. | |
| 24 | - | ||
| 25 | - | ### Launch & Basics | |
| 26 | - | ||
| 27 | - | - [ ] App launches without error on a clean install | |
| 28 | - | - [ ] Main window renders, theme applies | |
| 29 | - | - [ ] No panic in console; no missing-library errors | |
| 30 | - | ||
| 31 | - | ### Import + Content-Addressed Storage | |
| 32 | - | ||
| 33 | - | - [ ] Import a single WAV — sample appears in library | |
| 34 | - | - [ ] Import a directory of mixed formats (WAV, MP3, FLAC, AIFF) — all appear, correct counts | |
| 35 | - | - [ ] **Dedupe**: re-import the same file → no duplicate, same SHA-256 hash | |
| 36 | - | - [ ] Zero-byte file rejected with a clear error | |
| 37 | - | - [ ] Non-audio file rejected gracefully (no crash) | |
| 38 | - | ||
| 39 | - | ### Analysis Pipeline | |
| 40 | - | ||
| 41 | - | - [ ] Run analysis on a batch — progress indicator advances | |
| 42 | - | - [ ] BPM, key, loudness, classification all populate after completion | |
| 43 | - | - [ ] Cancel mid-batch — current sample finishes, batch stops cleanly | |
| 44 | - | - [ ] Re-analyze a sample — values update | |
| 45 | - | ||
| 46 | - | ### Audio Preview | |
| 47 | - | ||
| 48 | - | - [ ] Click a sample — audio plays through default output | |
| 49 | - | - [ ] Click another — previous stops, new one plays | |
| 50 | - | - [ ] Waveform displays during playback | |
| 51 | - | - [ ] Volume / playhead controls work | |
| 52 | - | ||
| 53 | - | ### Drag-Out (the headline feature) | |
| 54 | - | ||
| 55 | - | - [ ] Drag a single sample into a DAW — file lands and plays | |
| 56 | - | - [ ] Drag multiple selected samples — all land | |
| 57 | - | - [ ] Temp symlinks created under `/tmp/audiofiles-drag-{pid}/` (Linux/macOS) | |
| 58 | - | - [ ] Drag works on each shipped platform (see Platform-Specific below) | |
| 59 | - | ||
| 60 | - | ### MNW Blob Sync (Paid Feature) | |
| 61 | - | ||
| 62 | - | - [ ] Without a sync subscription, the sync UI shows "subscribe to sync" | |
| 63 | - | - [ ] Click subscribe → Stripe Checkout (test card `4242…`) | |
| 64 | - | - [ ] After checkout, sync activates automatically (poll completes) | |
| 65 | - | - [ ] Metadata syncs to a second device / fresh install | |
| 66 | - | - [ ] Samples marked `sync_files=true` upload to S3 | |
| 67 | - | - [ ] On the second device, samples download on demand | |
| 68 | - | - [ ] Cloud-only eviction: free local space → sample marked `cloud_only` | |
| 69 | - | - [ ] Re-download a cloud-only sample — file restored, hash matches | |
| 70 | - | ||
| 71 | - | ### Edit Operations | |
| 72 | - | ||
| 73 | - | - [ ] Trim a sample — result saved as new hash, original preserved | |
| 74 | - | - [ ] Normalize — new hash, original preserved | |
| 75 | - | - [ ] Undo an edit — sample reverts | |
| 76 | - | ||
| 77 | - | ### Export | |
| 78 | - | ||
| 79 | - | - [ ] Select samples → export to a target directory — files written and playable | |
| 80 | - | - [ ] Format conversion (e.g., WAV → FLAC) — output validates | |
| 81 | - | - [ ] Metadata sidecar files generated when enabled | |
| 82 | - | ||
| 83 | - | --- | |
| 84 | - | ||
| 85 | - | ## P1 — Core Features | |
| 86 | - | ||
| 87 | - | ### VFS (Virtual File System) | |
| 88 | - | ||
| 89 | - | - [ ] Create a new VFS | |
| 90 | - | - [ ] Create directories within VFS, navigate between them | |
| 91 | - | - [ ] Link samples to a directory; rename a directory | |
| 92 | - | - [ ] Move a sample between directories | |
| 93 | - | - [ ] Delete a directory — samples remain in store | |
| 94 | - | ||
| 95 | - | ### Search & Filter | |
| 96 | - | ||
| 97 | - | - [ ] Filename text search returns matches | |
| 98 | - | - [ ] Filter by duration range / BPM range | |
| 99 | - | - [ ] Filter by classification (Kick, Snare, etc.) | |
| 100 | - | - [ ] Filter by tag | |
| 101 | - | - [ ] Combined filters compose correctly | |
| 102 | - | ||
| 103 | - | ### Tags | |
| 104 | - | ||
| 105 | - | - [ ] Add/remove tag on a single sample | |
| 106 | - | - [ ] Bulk-apply a tag to a selection | |
| 107 | - | - [ ] Search by tag | |
| 108 | - | ||
| 109 | - | ### Collections | |
| 110 | - | ||
| 111 | - | - [ ] Create collection, add and remove samples | |
| 112 | - | - [ ] Open collection — only its members appear | |
| 113 | - | ||
| 114 | - | ### Bulk Rename | |
| 115 | - | ||
| 116 | - | - [ ] Select multiple samples, apply pattern `{class}_{bpm}_{name}` | |
| 117 | - | - [ ] Preview shows expected names | |
| 118 | - | - [ ] Execute — VFS names update; store hashes unchanged | |
| 119 | - | ||
| 120 | - | --- | |
| 121 | - | ||
| 122 | - | ## P2 — Edge Cases & Platform | |
| 123 | - | ||
| 124 | - | ### Platform-Specific | |
| 125 | - | ||
| 126 | - | - [ ] **macOS**: drag-out via NSPasteboardItem; app appears in Dock; no Gatekeeper warning on a signed build | |
| 127 | - | - [ ] **Windows**: drag-out via OLE/COM; standalone .exe runs; uninstall is clean | |
| 128 | - | - [ ] **Linux**: AppImage launches; drag-out works on X11 and Wayland (symlink fallback) | |
| 129 | - | ||
| 130 | - | ### Windows Installer (run before each release on windows-x86) | |
| 131 | - | ||
| 132 | - | Build with `pwsh -File dist\build-msi-native.ps1` (or `bash dist/build-msi.sh` from macOS/Linux as a fallback). | |
| 133 | - | ||
| 134 | - | - [ ] MSI install succeeds (no UAC errors for per-user scope) | |
| 135 | - | - [ ] Start Menu shortcut "AudioFiles" present after install | |
| 136 | - | - [ ] Launching from Start Menu opens the app, library loads | |
| 137 | - | - [ ] Repair-install: re-run the same MSI — completes without error | |
| 138 | - | - [ ] Major-upgrade: install older MSI, then newer MSI — old version removed cleanly, settings preserved | |
| 139 | - | - [ ] Uninstall via Apps & Features removes Start Menu entry and program files | |
| 140 | - | - [ ] If `AF_SIGN_CERT` was set at build: SmartScreen reputation prompt absent or accepts the cert; right-click .exe → Properties → Digital Signatures shows the expected certificate | |
| 141 | - | ||
| 142 | - | ### Robustness | |
| 143 | - | ||
| 144 | - | - [ ] Import a corrupt audio file — rejected, no crash | |
| 145 | - | - [ ] Import 10k+ samples — UI remains responsive | |
| 146 | - | - [ ] Quit during analysis — relaunch resumes cleanly, no DB corruption | |
| 147 | - | - [ ] Disk full during import — graceful error | |
| 148 | - | ||
| 149 | - | ### Loose-files mode | |
| 150 | - | ||
| 151 | - | - [ ] Enable Loose-files mode (intentionally discouraging UI) — warning shown | |
| 152 | - | - [ ] Unsafe operations available; default-off after restart | |
| 153 | - | ||
| 154 | - | --- | |
| 155 | - | ||
| 156 | - | ## Sign-Off | |
| 157 | - | ||
| 158 | - | | Field | Value | | |
| 159 | - | |-------|-------| | |
| 160 | - | | Date | | | |
| 161 | - | | Tester | | | |
| 162 | - | | Version | | | |
| 163 | - | | Platform(s) | macOS / Linux / Windows | | |
| 164 | - | | P0 result | pass / fail | | |
| 165 | - | | P1 result | pass / fail | | |
| 166 | - | | P2 result | pass / fail / skipped | | |
| 167 | - | | Notes | | |
| @@ -1,234 +0,0 @@ | |||
| 1 | - | # Schema — audiofiles | |
| 2 | - | ||
| 3 | - | SQLite database. Inline migrations tracked via `PRAGMA user_version` (current: 12). PRAGMA foreign_keys=ON enforced. | |
| 4 | - | ||
| 5 | - | ## Table Map | |
| 6 | - | ||
| 7 | - | | Domain | Tables | Purpose | | |
| 8 | - | |--------|--------|---------| | |
| 9 | - | | Samples | 2 | Content-addressed storage + analysis results | | |
| 10 | - | | VFS | 3 | Virtual file system trees + smart folders | | |
| 11 | - | | Organization | 3 | Tags, collections, collection members | | |
| 12 | - | | Analysis | 2 | Waveform data, audio fingerprints | | |
| 13 | - | | Edit History | 1 | Non-destructive edit tracking | | |
| 14 | - | | Config | 1 | App preferences | | |
| 15 | - | | SyncKit | 2 | Changelog + state | | |
| 16 | - | ||
| 17 | - | --- | |
| 18 | - | ||
| 19 | - | ## Content-Addressed Storage | |
| 20 | - | ||
| 21 | - | ### samples | |
| 22 | - | The primary entity. **SHA-256 hash is the primary key** — the same file always produces the same row. Re-importing a file with an existing hash is a no-op (dedup by design). | |
| 23 | - | ||
| 24 | - | | Column | Type | Notes | | |
| 25 | - | |--------|------|-------| | |
| 26 | - | | hash | TEXT PK | SHA-256 of file content | | |
| 27 | - | | original_name | TEXT | Filename at import time | | |
| 28 | - | | file_extension | TEXT | e.g., 'wav', 'flac', 'mp3' | | |
| 29 | - | | file_size | INTEGER | Bytes | | |
| 30 | - | | import_date | TEXT | ISO 8601 | | |
| 31 | - | | last_modified | TEXT | | | |
| 32 | - | | cloud_only | INTEGER | Boolean — evicted from local, stored in SyncKit blob | | |
| 33 | - | | duration | REAL | Seconds (set during analysis) | | |
| 34 | - | ||
| 35 | - | **Index:** idx_samples_name. | |
| 36 | - | ||
| 37 | - | **Storage:** Files stored in a content-addressed directory structure: `store/{hash[0..2]}/{hash[2..4]}/{hash}`. | |
| 38 | - | ||
| 39 | - | ### audio_analysis | |
| 40 | - | Analysis results. One row per sample, populated after analysis completes. | |
| 41 | - | ||
| 42 | - | | Column | Type | Notes | | |
| 43 | - | |--------|------|-------| | |
| 44 | - | | hash | TEXT PK FK → samples CASCADE | | | |
| 45 | - | | bpm | REAL | Detected tempo | | |
| 46 | - | | musical_key | TEXT | e.g., 'C major', 'F# minor' | | |
| 47 | - | | duration | REAL | Seconds | | |
| 48 | - | | sample_rate | INTEGER | Hz | | |
| 49 | - | | channels | INTEGER | | | |
| 50 | - | | peak_db / rms_db / lufs | REAL | Loudness measurements | | |
| 51 | - | | is_loop | INTEGER | Boolean — loop point detected | | |
| 52 | - | | classification | TEXT | 'kick', 'snare', 'hihat', 'bass', etc. | | |
| 53 | - | | classification_confidence | REAL | 0.0–1.0 | | |
| 54 | - | | spectral_centroid / spectral_flatness / spectral_rolloff / spectral_bandwidth | REAL | Spectral features | | |
| 55 | - | | zero_crossing_rate / centroid_variance / crest_factor / attack_time | REAL | Time-domain features | | |
| 56 | - | | onset_strength | REAL | Transient detection | | |
| 57 | - | | analyzed_at | TEXT | | | |
| 58 | - | ||
| 59 | - | **Indexes:** bpm, musical_key, duration, classification — for filter queries. | |
| 60 | - | ||
| 61 | - | --- | |
| 62 | - | ||
| 63 | - | ## Virtual File System | |
| 64 | - | ||
| 65 | - | ### vfs | |
| 66 | - | Named virtual file systems. Users can have multiple (e.g., "Main Library", "Project X"). | |
| 67 | - | ||
| 68 | - | | Column | Type | Notes | | |
| 69 | - | |--------|------|-------| | |
| 70 | - | | id | INTEGER PK | | | |
| 71 | - | | name | TEXT UNIQUE | | | |
| 72 | - | | sync_files | INTEGER | Boolean — sync blobs to SyncKit cloud | | |
| 73 | - | | created_at / modified_at | TEXT | | | |
| 74 | - | ||
| 75 | - | ### vfs_nodes | |
| 76 | - | Tree structure of directories and sample references. A sample can appear in multiple directories (hard-link semantics). | |
| 77 | - | ||
| 78 | - | | Column | Type | Notes | | |
| 79 | - | |--------|------|-------| | |
| 80 | - | | id | INTEGER PK | | | |
| 81 | - | | vfs_id | INTEGER FK → vfs CASCADE | | | |
| 82 | - | | parent_id | INTEGER FK → vfs_nodes CASCADE | Self-ref; NULL = root | | |
| 83 | - | | sample_hash | TEXT FK → samples CASCADE | NULL for directories | | |
| 84 | - | | name | TEXT | Display name in this location | | |
| 85 | - | | node_type | TEXT | CHECK: 'directory' or 'sample' | | |
| 86 | - | | created_at | TEXT | | | |
| 87 | - | ||
| 88 | - | **Constraint:** UNIQUE(vfs_id, parent_id, name) — no duplicate names in same directory. | |
| 89 | - | **Indexes:** parent (tree traversal), vfs (list all nodes), hash (find all locations of a sample). | |
| 90 | - | ||
| 91 | - | ### smart_folders | |
| 92 | - | Saved queries that dynamically list matching samples. | |
| 93 | - | ||
| 94 | - | | Column | Type | Notes | | |
| 95 | - | |--------|------|-------| | |
| 96 | - | | id | INTEGER PK | | | |
| 97 | - | | vfs_id | INTEGER FK → vfs CASCADE | | | |
| 98 | - | | name | TEXT | | | |
| 99 | - | | query_json | TEXT | Serialized filter criteria | | |
| 100 | - | ||
| 101 | - | --- | |
| 102 | - | ||
| 103 | - | ## Organization | |
| 104 | - | ||
| 105 | - | ### tags | |
| 106 | - | Sample tags. Normalized to lowercase. A sample can have many tags. | |
| 107 | - | ||
| 108 | - | | Column | Type | Notes | | |
| 109 | - | |--------|------|-------| | |
| 110 | - | | sample_hash | TEXT FK → samples CASCADE | | | |
| 111 | - | | tag | TEXT | Lowercase normalized | | |
| 112 | - | ||
| 113 | - | **PK:** (sample_hash, tag). | |
| 114 | - | **Indexes:** hash (all tags for a sample), tag (all samples with a tag). | |
| 115 | - | ||
| 116 | - | ### collections | |
| 117 | - | Named groups of samples (playlists, kits, project collections). | |
| 118 | - | ||
| 119 | - | | Column | Type | Notes | | |
| 120 | - | |--------|------|-------| | |
| 121 | - | | id | INTEGER PK | | | |
| 122 | - | | name | TEXT UNIQUE | | | |
| 123 | - | | description | TEXT | | | |
| 124 | - | ||
| 125 | - | ### collection_members | |
| 126 | - | Many-to-many between collections and samples. | |
| 127 | - | ||
| 128 | - | - **PK:** (collection_id, sample_hash) | |
| 129 | - | - **FK:** collection_id → collections CASCADE, sample_hash → samples CASCADE | |
| 130 | - | ||
| 131 | - | --- | |
| 132 | - | ||
| 133 | - | ## Analysis Artifacts | |
| 134 | - | ||
| 135 | - | ### waveform_data | |
| 136 | - | Pre-computed waveform envelopes for display. Stored as BLOB for fast retrieval. | |
| 137 | - | ||
| 138 | - | | Column | Type | Notes | | |
| 139 | - | |--------|------|-------| | |
| 140 | - | | hash | TEXT PK FK → samples CASCADE | | | |
| 141 | - | | num_buckets | INTEGER | Resolution (typically 512 or 1024) | | |
| 142 | - | | peak_data | BLOB | Packed float pairs (min, max) per bucket | | |
| 143 | - | | sample_rate | INTEGER | | | |
| 144 | - | | duration | REAL | | | |
| 145 | - | ||
| 146 | - | ### fingerprints | |
| 147 | - | Audio fingerprints for similarity search (VP-tree nearest neighbor). | |
| 148 | - | ||
| 149 | - | | Column | Type | Notes | | |
| 150 | - | |--------|------|-------| | |
| 151 | - | | hash | TEXT PK FK → samples CASCADE | | | |
| 152 | - | | envelope | BLOB | Peak envelope vector | | |
| 153 | - | | sample_rate | INTEGER | | | |
| 154 | - | ||
| 155 | - | --- | |
| 156 | - | ||
| 157 | - | ## Edit History | |
| 158 | - | ||
| 159 | - | ### edit_history | |
| 160 | - | Non-destructive edit tracking. Each edit produces a new sample (new hash). The history links source → result. | |
| 161 | - | ||
| 162 | - | | Column | Type | Notes | | |
| 163 | - | |--------|------|-------| | |
| 164 | - | | id | INTEGER PK AUTOINCREMENT | | | |
| 165 | - | | source_hash | TEXT | Original sample hash | | |
| 166 | - | | result_hash | TEXT | New sample hash after edit | | |
| 167 | - | | operation | TEXT | 'trim', 'reverse', 'normalize', 'fade', 'gain' | | |
| 168 | - | | params_json | TEXT | Operation parameters | | |
| 169 | - | | created_at | TEXT | | | |
| 170 | - | ||
| 171 | - | **Indexes:** source_hash (undo chain), result_hash (provenance). | |
| 172 | - | ||
| 173 | - | **Design:** Edits never modify existing files. Trim a sample → a new file with a new hash is created. Undo = delete the result and its edit_history entry. | |
| 174 | - | ||
| 175 | - | --- | |
| 176 | - | ||
| 177 | - | ## Configuration | |
| 178 | - | ||
| 179 | - | ### user_config | |
| 180 | - | App-wide key-value preferences (theme, default VFS, analysis settings, etc.). | |
| 181 | - | ||
| 182 | - | | Column | Type | Notes | | |
| 183 | - | |--------|------|-------| | |
| 184 | - | | key | TEXT PK | | | |
| 185 | - | | value | TEXT | | | |
| 186 | - | ||
| 187 | - | --- | |
| 188 | - | ||
| 189 | - | ## SyncKit Infrastructure | |
| 190 | - | ||
| 191 | - | ### sync_state | |
| 192 | - | Key-value store for sync configuration. Same schema as GO/BB: | |
| 193 | - | - `device_id` — unique device identifier | |
| 194 | - | - `pull_cursor` — last-pulled sequence number | |
| 195 | - | - `auto_sync_enabled` / `sync_interval_minutes` | |
| 196 | - | - `applying_remote` — suppresses changelog triggers during pull | |
| 197 | - | - `last_sync_at` / `initial_snapshot_done` | |
| 198 | - | ||
| 199 | - | ### sync_changelog | |
| 200 | - | Local change log. Synced tables have INSERT/UPDATE/DELETE triggers that write here (when `applying_remote` != '1'). Full row data serialized as JSON. | |
| 201 | - | ||
| 202 | - | | Column | Type | Notes | | |
| 203 | - | |--------|------|-------| | |
| 204 | - | | id | INTEGER PK | | | |
| 205 | - | | table_name | TEXT | | | |
| 206 | - | | op | TEXT | 'INSERT', 'UPDATE', 'DELETE' | | |
| 207 | - | | row_id | TEXT | | | |
| 208 | - | | timestamp | TEXT | | | |
| 209 | - | | data | TEXT | Full row as JSON | | |
| 210 | - | | pushed | INTEGER | 0 = unpushed | | |
| 211 | - | ||
| 212 | - | **Index:** idx_changelog_pushed. | |
| 213 | - | ||
| 214 | - | **Synced tables:** samples, audio_analysis, vfs, vfs_nodes, tags, collections, collection_members, smart_folders, edit_history, waveform_data, fingerprints. | |
| 215 | - | ||
| 216 | - | --- | |
| 217 | - | ||
| 218 | - | ## Cascade Rules | |
| 219 | - | ||
| 220 | - | - **CASCADE everywhere:** Deleting a sample cascades to audio_analysis, tags, collection_members, waveform_data, fingerprints, and all vfs_nodes referencing it. Deleting a VFS cascades to all its nodes and smart folders. Deleting a collection cascades to its members. | |
| 221 | - | - **No SET NULL or RESTRICT** — the schema is strictly hierarchical with samples as the root entity. | |
| 222 | - | ||
| 223 | - | ## Content-Addressing Implications | |
| 224 | - | ||
| 225 | - | - **No UPDATE on samples.hash** — hash is immutable. An "edit" creates a new sample. | |
| 226 | - | - **Dedup is automatic** — importing the same file twice produces the same hash, which is rejected as a duplicate PK. | |
| 227 | - | - **Cloud eviction:** When `cloud_only=1`, the local file is deleted but the row persists. The sample can be re-downloaded from SyncKit blob storage. | |
| 228 | - | ||
| 229 | - | ## Key Paths | |
| 230 | - | ||
| 231 | - | - `crates/audiofiles-core/src/db.rs` — migration runner + Database struct | |
| 232 | - | - `crates/audiofiles-core/src/store.rs` — content-addressed file storage | |
| 233 | - | - `crates/audiofiles-core/src/vfs.rs` — VFS operations | |
| 234 | - | - `crates/audiofiles-core/src/search.rs` — filter/search queries |
| @@ -1,114 +0,0 @@ | |||
| 1 | - | # Smoke Test Checklist — audiofiles | |
| 2 | - | ||
| 3 | - | Pre-release manual verification. Run after building a new version. | |
| 4 | - | ||
| 5 | - | ## Launch & Basics | |
| 6 | - | ||
| 7 | - | - [ ] App launches without error | |
| 8 | - | - [ ] Main window renders (egui) | |
| 9 | - | - [ ] Theme loads correctly (embedded TOML) | |
| 10 | - | - [ ] No panic in console output | |
| 11 | - | ||
| 12 | - | ## Import | |
| 13 | - | ||
| 14 | - | - [ ] Import a single audio file (WAV) — sample appears in library | |
| 15 | - | - [ ] Import a directory of mixed formats (WAV, MP3, FLAC, AIFF) | |
| 16 | - | - [ ] Verify content-addressed storage: re-import same file → no duplicate (same hash) | |
| 17 | - | - [ ] Import a zero-byte file — rejected with error message | |
| 18 | - | - [ ] Import a non-audio file — rejected gracefully | |
| 19 | - | ||
| 20 | - | ## Analysis | |
| 21 | - | ||
| 22 | - | - [ ] Select samples, run analysis | |
| 23 | - | - [ ] Progress indicator shows batch progress | |
| 24 | - | - [ ] BPM, key, loudness, classification populated after analysis | |
| 25 | - | - [ ] `smart_skip` works: non-rhythmic samples skip BPM/key (if enabled) | |
| 26 | - | - [ ] Cancel mid-analysis — batch stops after current sample finishes | |
| 27 | - | - [ ] Re-analyze a sample — values update | |
| 28 | - | ||
| 29 | - | ## VFS (Virtual File System) | |
| 30 | - | ||
| 31 | - | - [ ] Create a new VFS | |
| 32 | - | - [ ] Create directories within VFS | |
| 33 | - | - [ ] Link samples to directories | |
| 34 | - | - [ ] Navigate between directories | |
| 35 | - | - [ ] Rename a directory | |
| 36 | - | - [ ] Move a sample between directories | |
| 37 | - | - [ ] Delete a directory (samples not deleted from store) | |
| 38 | - | ||
| 39 | - | ## Search & Filter | |
| 40 | - | ||
| 41 | - | - [ ] Search by filename text | |
| 42 | - | - [ ] Filter by duration range | |
| 43 | - | - [ ] Filter by BPM range | |
| 44 | - | - [ ] Filter by classification (e.g., Kick, Snare) | |
| 45 | - | - [ ] Filter by tag | |
| 46 | - | - [ ] Combined filters work together | |
| 47 | - | ||
| 48 | - | ## Tags | |
| 49 | - | ||
| 50 | - | - [ ] Add a tag to a sample | |
| 51 | - | - [ ] Add tags in bulk (select multiple, apply tag) | |
| 52 | - | - [ ] Remove a tag | |
| 53 | - | - [ ] Search by tag | |
| 54 | - | ||
| 55 | - | ## Audio Preview | |
| 56 | - | ||
| 57 | - | - [ ] Click a sample — audio plays | |
| 58 | - | - [ ] Click another — previous stops, new one plays | |
| 59 | - | - [ ] Waveform displays during playback | |
| 60 | - | ||
| 61 | - | ## Drag and Drop | |
| 62 | - | ||
| 63 | - | - [ ] Drag samples out of the app into a DAW or file manager | |
| 64 | - | - [ ] Multiple file drag works | |
| 65 | - | - [ ] Verify temp symlinks created in `/tmp/audiofiles-drag-{pid}/` | |
| 66 | - | ||
| 67 | - | ## Export | |
| 68 | - | ||
| 69 | - | - [ ] Select samples, export to a directory | |
| 70 | - | - [ ] Export with format conversion (e.g., WAV → FLAC) | |
| 71 | - | - [ ] Metadata sidecar files generated (if enabled) | |
| 72 | - | - [ ] Verify exported files are playable | |
| 73 | - | ||
| 74 | - | ## Edit Operations | |
| 75 | - | ||
| 76 | - | - [ ] Trim a sample — result saved as new hash | |
| 77 | - | - [ ] Reverse a sample | |
| 78 | - | - [ ] Normalize a sample | |
| 79 | - | - [ ] Undo an edit operation | |
| 80 | - | ||
| 81 | - | ## Collections | |
| 82 | - | ||
| 83 | - | - [ ] Create a collection | |
| 84 | - | - [ ] Add samples to collection | |
| 85 | - | - [ ] Remove samples from collection | |
| 86 | - | ||
| 87 | - | ## Sync (if configured) | |
| 88 | - | ||
| 89 | - | - [ ] Log in to sync (MNW account) | |
| 90 | - | - [ ] Verify metadata syncs to another device | |
| 91 | - | - [ ] Blob sync: samples with `sync_files=true` upload to cloud | |
| 92 | - | - [ ] Cloud-only eviction: sample marked `cloud_only` after eviction | |
| 93 | - | - [ ] Re-download a cloud-only sample | |
| 94 | - | ||
| 95 | - | ## Bulk Rename | |
| 96 | - | ||
| 97 | - | - [ ] Select multiple samples | |
| 98 | - | - [ ] Apply rename pattern (e.g., `{class}_{bpm}_{name}`) | |
| 99 | - | - [ ] Preview shows expected names | |
| 100 | - | - [ ] Execute rename — names update in VFS | |
| 101 | - | ||
| 102 | - | ## Platform-Specific | |
| 103 | - | ||
| 104 | - | ### macOS | |
| 105 | - | - [ ] Drag-out works (NSPasteboardItem) | |
| 106 | - | - [ ] App appears in Dock correctly | |
| 107 | - | ||
| 108 | - | ### Windows | |
| 109 | - | - [ ] Drag-out works (OLE/COM) | |
| 110 | - | - [ ] Installer/MSI works | |
| 111 | - | ||
| 112 | - | ### Linux | |
| 113 | - | - [ ] AppImage launches | |
| 114 | - | - [ ] Drag-out works (X11/Wayland symlink fallback) |
| @@ -1,173 +0,0 @@ | |||
| 1 | - | # Test Plan — audiofiles | |
| 2 | - | ||
| 3 | - | ## Overview | |
| 4 | - | ||
| 5 | - | ~260 tests across 6 crates. Unit tests (inline) + integration tests (e2e pipeline with real audio). All use in-memory SQLite. | |
| 6 | - | ||
| 7 | - | ## Test Architecture | |
| 8 | - | ||
| 9 | - | **Unit tests:** Inline `#[cfg(test)]` modules in 44+ source files. Synchronous (no tokio in core). | |
| 10 | - | ||
| 11 | - | **Integration tests:** `crates/audiofiles-core/tests/` — end-to-end pipeline tests using programmatically generated audio (sine waves). | |
| 12 | - | ||
| 13 | - | **ML validation:** `crates/audiofiles-core/tests/classify_validation.rs` — tests classification accuracy against labeled drum samples. | |
| 14 | - | ||
| 15 | - | **DB pattern:** `Database::open_in_memory()` creates a fresh SQLite database with inline migrations. `tempfile::TempDir` for isolated sample storage. | |
| 16 | - | ||
| 17 | - | **No mocks:** Tests use real functions with temporary directories. E2E tests generate real audio. | |
| 18 | - | ||
| 19 | - | ## Running Tests | |
| 20 | - | ||
| 21 | - | ```bash | |
| 22 | - | # All tests (all crates) | |
| 23 | - | cargo test | |
| 24 | - | ||
| 25 | - | # Specific crate | |
| 26 | - | cargo test -p audiofiles-core # Core logic + analysis | |
| 27 | - | cargo test -p audiofiles-browser # UI state machine | |
| 28 | - | cargo test -p audiofiles-rhai # Plugin engine | |
| 29 | - | cargo test -p audiofiles-sync # Sync state | |
| 30 | - | ||
| 31 | - | # Specific module | |
| 32 | - | cargo test analysis::classify::tests | |
| 33 | - | cargo test state::tests | |
| 34 | - | ||
| 35 | - | # E2E pipeline | |
| 36 | - | cargo test e2e_import_analyze_search_tag_export | |
| 37 | - | ||
| 38 | - | # ML classification validation (requires labeled test data) | |
| 39 | - | cargo test classify_drum_samples | |
| 40 | - | ||
| 41 | - | # Benchmarks | |
| 42 | - | cargo bench -p audiofiles-bench | |
| 43 | - | ``` | |
| 44 | - | ||
| 45 | - | ## What's Covered | |
| 46 | - | ||
| 47 | - | ### Integration Tests (`crates/audiofiles-core/tests/`) | |
| 48 | - | ||
| 49 | - | | File | Tests | What's Tested | | |
| 50 | - | |------|-------|---------------| | |
| 51 | - | | `e2e_pipeline.rs` | 3 | Full import-analyze-search-tag-export pipeline, analysis roundtrip (440Hz sine → spectral centroid verification), multi-sample search with dedup | | |
| 52 | - | | `classify_validation.rs` | 1 | ML classification accuracy on labeled drum samples (kick/snare/hihat/cymbal/clap/tom/percussion). Reports per-class precision/recall/F1. | | |
| 53 | - | ||
| 54 | - | ### Unit Tests by Crate | |
| 55 | - | ||
| 56 | - | **audiofiles-core** (~180 tests): | |
| 57 | - | ||
| 58 | - | | Module | What's Tested | | |
| 59 | - | |--------|---------------| | |
| 60 | - | | `analysis/classify.rs` | 24 tests: rule-based classification (kick/hihat/snare/noise/bass/impact/ambience/foley/texture), tag format, string roundtrip, feature vector layout | | |
| 61 | - | | `analysis/spectral.rs` | Spectral feature extraction | | |
| 62 | - | | `analysis/mfcc.rs` | MFCC feature extraction | | |
| 63 | - | | `analysis/bpm.rs` | Tempo detection | | |
| 64 | - | | `analysis/loudness.rs` | Peak/RMS/LUFS measurement | | |
| 65 | - | | `analysis/loop_detect.rs` | Loop point detection | | |
| 66 | - | | `analysis/waveform.rs` | Waveform envelope | | |
| 67 | - | | `analysis/decode.rs` | Audio decoding | | |
| 68 | - | | `analysis/config.rs` | Analysis configuration | | |
| 69 | - | | `analysis/worker.rs` | Worker thread protocol | | |
| 70 | - | | `analysis/suggest.rs` | Tag suggestions | | |
| 71 | - | | `export/encode.rs` | WAV, AIFF, FLAC, MP3 encoding | | |
| 72 | - | | `export/convert.rs` | Sample rate/bit depth conversion | | |
| 73 | - | | `export/sanitize.rs` | Filename sanitization | | |
| 74 | - | | `export/dither.rs` | Dithering algorithms | | |
| 75 | - | | `export/profile.rs` | Device profile matching | | |
| 76 | - | | `edit/trim.rs` | Sample trimming | | |
| 77 | - | | `edit/reverse.rs` | Sample reversal | | |
| 78 | - | | `edit/gain.rs` | Amplitude scaling | | |
| 79 | - | | `edit/fade.rs` | Fade in/out curves | | |
| 80 | - | | `edit/normalize.rs` | Normalization | | |
| 81 | - | | `vfs.rs` | Virtual file system operations | | |
| 82 | - | | `tags.rs` | Tag add/remove/list | | |
| 83 | - | | `search.rs` | Text + property + tag filters | | |
| 84 | - | | `store.rs` | Content-addressed storage (hash validation) | | |
| 85 | - | | `similarity.rs` | Fingerprint-based similarity (VP-tree) | | |
| 86 | - | | `fingerprint.rs` | Peak envelope extraction | | |
| 87 | - | | `vp_tree.rs` | Vantage point tree implementation | | |
| 88 | - | | `collections.rs` | Collection management | | |
| 89 | - | | `smart_folders.rs` | Smart folder rules | | |
| 90 | - | | `rename.rs` | Bulk rename patterns | | |
| 91 | - | | `id_types.rs` | Type-safe IDs (SampleHash, VfsId, NodeId) | | |
| 92 | - | | `util.rs` | File extension extraction, filename parsing | | |
| 93 | - | ||
| 94 | - | **audiofiles-browser** (~104 tests): | |
| 95 | - | ||
| 96 | - | `state/tests.rs` — Comprehensive UI state machine tests: | |
| 97 | - | - Selection (6): single, toggle, extend, all, clear, keyboard nav | |
| 98 | - | - Bulk operations & undo (18): tag add/remove, move, delete, undo stack | |
| 99 | - | - Export flow (7): configuration, state transitions, errors | |
| 100 | - | - Import & analysis (18): import workflows, analysis config, errors | |
| 101 | - | - Navigation & filtering (25): VFS switching, directory nav, search | |
| 102 | - | - Column config & sort (14): sort toggles, case-insensitive sorting | |
| 103 | - | - Rename patterns (13): preview, pattern tokens, deduplication | |
| 104 | - | ||
| 105 | - | **audiofiles-rhai** (34 tests): | |
| 106 | - | ||
| 107 | - | | Module | What's Tested | | |
| 108 | - | |--------|---------------| | |
| 109 | - | | `engine.rs` | Sandboxed engine creation, operation limits | | |
| 110 | - | | `loader.rs` | Manifest parsing and loading | | |
| 111 | - | | `hooks.rs` | Hook function execution | | |
| 112 | - | | `manifest.rs` | TOML manifest parsing | | |
| 113 | - | | `registry.rs` | Function registry | | |
| 114 | - | | `bundled.rs` | Bundled plugin handling | | |
| 115 | - | | `host_api.rs` | Host API exposure | | |
| 116 | - | ||
| 117 | - | **audiofiles-sync** (8 tests): Sync changelog state machine. | |
| 118 | - | ||
| 119 | - | **audiofiles-app** (12 tests): License validation, audio device discovery, updater. | |
| 120 | - | ||
| 121 | - | ### Test Helpers | |
| 122 | - | ||
| 123 | - | `crates/audiofiles-core/src/test_helpers.rs`: | |
| 124 | - | ```rust | |
| 125 | - | pub fn insert_fake_sample(db: &Database, hash: &str) | |
| 126 | - | pub fn insert_sample_with_analysis(db, hash, name, vfs_id, bpm, key, duration, class) -> NodeId | |
| 127 | - | ``` | |
| 128 | - | ||
| 129 | - | ## What's Not Tested | |
| 130 | - | ||
| 131 | - | | Area | Reason | | |
| 132 | - | |------|--------| | |
| 133 | - | | GUI rendering | egui immediate-mode; no automated UI test framework. Manual verification. | | |
| 134 | - | | Platform drag-drop FFI | macOS objc2, Windows COM/OLE. Manual per-platform testing. | | |
| 135 | - | | Audio playback (cpal) | Requires audio hardware. Tested manually. | | |
| 136 | - | | MIDI input | Requires MIDI hardware. | | |
| 137 | - | | SyncKit cloud sync E2E | Requires MNW server. Sync state machine is unit-tested. | | |
| 138 | - | | Distribution artifacts | DMG, MSI, AppImage — tested during release builds. | | |
| 139 | - | | Large file handling | Multi-gigabyte imports; tested manually. | | |
| 140 | - | ||
| 141 | - | ## Adding New Tests | |
| 142 | - | ||
| 143 | - | ### Core unit test | |
| 144 | - | ```rust | |
| 145 | - | #[cfg(test)] | |
| 146 | - | mod tests { | |
| 147 | - | use super::*; | |
| 148 | - | ||
| 149 | - | #[test] | |
| 150 | - | fn my_test() { | |
| 151 | - | let dir = tempfile::tempdir().unwrap(); | |
| 152 | - | let db = Database::open_in_memory().unwrap(); | |
| 153 | - | // No async, no external deps | |
| 154 | - | } | |
| 155 | - | } | |
| 156 | - | ``` | |
| 157 | - | ||
| 158 | - | ### E2E test | |
| 159 | - | ```rust | |
| 160 | - | #[test] | |
| 161 | - | fn my_e2e_test() { | |
| 162 | - | let dir = tempfile::tempdir().unwrap(); | |
| 163 | - | let db = Database::open_in_memory().unwrap(); | |
| 164 | - | // Generate audio, import, analyze, assert | |
| 165 | - | } | |
| 166 | - | ``` | |
| 167 | - | ||
| 168 | - | ## Key Paths | |
| 169 | - | ||
| 170 | - | - `crates/audiofiles-core/tests/` — E2E pipeline + ML validation tests | |
| 171 | - | - `crates/audiofiles-core/src/test_helpers.rs` — Fixture helpers | |
| 172 | - | - `crates/audiofiles-browser/src/state/tests.rs` — UI state tests (104 tests) | |
| 173 | - | - `crates/audiofiles-rhai/src/` — Plugin engine tests |
| @@ -1,234 +0,0 @@ | |||
| 1 | - | # audiofiles TODO | |
| 2 | - | ||
| 3 | - | ## Status | |
| 4 | - | Done: All pre-beta phases + Phase 11 + SyncKit parity. Active: None. Next: launch (see below), then sample forge post-launch. | |
| 5 | - | ||
| 6 | - | v0.4.0. Audit grade A- (Ultra Fuzz 1, 2026-05-09). 780 tests. Rust 2024 edition (2026-05-06). rand 0.9. 4 SERIOUS, 10 MINOR findings from 5-axis adversarial audit. Run 20 items all resolved. | |
| 7 | - | ||
| 8 | - | --- | |
| 9 | - | ||
| 10 | - | ## Launch (Locked Scope, 2026-05-16) | |
| 11 | - | ||
| 12 | - | Public-launch blockers. Everything else in this file is post-launch. Do not promote items into this section without explicit user decision. | |
| 13 | - | ||
| 14 | - | 1. **Test full checkout flow against live Stripe** — end-to-end: subscribe → webhook → blob sync gate passes | |
| 15 | - | 2. **Build v0.4.0 Windows installer on windows-x86** — run `pwsh -File dist\build-msi-native.ps1`. Produces signed-or-unsigned `AudioFiles_0.4.0_x86_64.msi` + `.exe`. WiX source `dist/audiofiles.wxs.in`. Macros/cross-compile fallback: `dist/build-msi.sh`. (Installer config + signing hooks landed 2026-05-16.) | |
| 16 | - | 3. **Code-sign Windows binaries** — gated on Azure cert blocker (same as GO/BB). When unblocked: set `AF_SIGN_CERT` / `AF_SIGN_PASS` / `AF_SIGN_TIMESTAMP_URL` and re-run the PS1 builder. Until then, ship unsigned (matches 0.3.x). | |
| 17 | - | 4. **Test installed build on Windows** — install the MSI, verify Start Menu shortcut, run AudioFiles, complete `docs/human_testing.md` Windows-specific section, uninstall cleanly. | |
| 18 | - | ||
| 19 | - | Explicitly **out of launch scope** (defer until after public launch): | |
| 20 | - | - Phase 10 (Plugin/CLAP processing) — entire phase | |
| 21 | - | - Phase 12 (Chop Engine), Phase 13 (Resample/Time-Stretch), Phase 14 (Layer/Stack), Phase 15 (Batch Forge), Phase 16 (Snapshot History) — entire sample-forge pivot | |
| 22 | - | - Vocal layer 2 classifier (needs new training data) | |
| 23 | - | - Multi-sample instrument UI (KeyZone) — grayed out is acceptable | |
| 24 | - | - All dependency-prune items (do in a quiet week post-launch) | |
| 25 | - | - Ultra Fuzz trust-model items (ed25519 sig, keychain, Rhai timeout) — already flagged architectural | |
| 26 | - | - UX audit power-user gaps, rust-fuzz minor items, shared code extraction | |
| 27 | - | ||
| 28 | - | --- | |
| 29 | - | ||
| 30 | - | ## UX audit sweep (phased, post-launch) | |
| 31 | - | ||
| 32 | - | Mirrors the structure MNW server is following (see `MNW/server/docs/ux-audit/`). Two-part plan: a Phase 0 design-system conformance audit first, then a consolidation pass before any surface audits run. Skill: `ux-audit` (egui dispatch — sole tech). | |
| 33 | - | ||
| 34 | - | ### Part 1 — Phase 0 + remediation plan | |
| 35 | - | ||
| 36 | - | - [x] **Phase 0 — Design-system conformance audit.** Inventory what primitives the egui UI actually has today vs reinvents per-screen. Read `crates/audiofiles-browser/src/ui/theme.rs` (Visuals + style tokens), `widgets.rs` (custom widgets), and a representative span across `sidebar.rs`, `toolbar.rs`, `file_list.rs`, `detail.rs`, `edit_panel.rs`, `filter_panel.rs`, `instrument_panel.rs`, `footer.rs`, `overlays.rs`, plus `import_screens/*` and `export_screens.rs`. Produce: (a) the actual primitive set — `egui::Visuals` config, color/spacing/radius/stroke constants, custom widget signatures, panel shapes, button/list-row/header/empty-state recipes; (b) divergence map — where panels build the same kind of row/button/empty-state inline instead of calling a shared widget; (c) gaps — primitives that should exist (shared empty-state widget, confirm dialog, danger-button variant, focus-ring/selected-row recipe, toast/notification, loading spinner); (d) a written charter at `docs/design-system.md` naming every primitive and its single canonical helper in `widgets.rs` or `theme.rs`. Save the audit report at `docs/ux-audit/phase-0.md`. | |
| 37 | - | ||
| 38 | - | - [x] **Consolidation pre-plan (pre-Phase-1).** From the Phase 0 output, write `docs/ux-audit/remediation-plan.md`: list every place the same basic UX is expressed in different ways and pick one. Typical hotspots for an egui app: row rendering (selectable label vs button vs custom widget per panel), section headers (raw `ui.heading` vs `ui.label` with custom font vs separator-with-label), empty-state panels (each screen writes its own centered prompt), modal/overlay (`Window::new` vs `Modal` vs custom centered popup), confirm-on-destructive recipe, focus/selected/hover/disabled vocabulary, color tokens applied via `Visuals` vs hardcoded `Color32` literals, padding/spacing values across panels, button family (primary action vs toolbar icon vs link-style), and side-panel widths and dividers. Pick one canonical helper per pattern, document the migration, and define success criteria (no `Color32::from_rgb` literals outside `theme.rs`; no inline row layouts outside `widgets.rs`; single `selectable_row` and `empty_state` and `confirm_dialog` helpers used everywhere). Surface audits do not start until the consolidation lands. | |
| 39 | - | ||
| 40 | - | ### Consolidation implementation (batches 0–6) | |
| 41 | - | ||
| 42 | - | Implementation of `docs/ux-audit/remediation-plan.md`. All batches landed 2026-05-19. Build clean; 199 tests pass; gates green. | |
| 43 | - | ||
| 44 | - | - [x] **Batch 0 — Spacing + token plumbing.** `theme::space::{XS,SM,MD,LG,SECTION,XL}`, `theme::stroke::{THIN,DEFAULT,FOCUS}`; `window_margin`/`indent` lifted into `ThemeColors`. 151 `add_space` literals migrated. Gate `#R-07` add_space rule: green. | |
| 45 | - | - [x] **Batch 1 — Modal scaffold + confirm flow.** `widgets::modal_window`, `modal_window_with_open`, `confirm_modal`, `confirm_action_row`, `name_modal`. All bulk modals + confirm + unsafe warning + settings + sync routed through helpers. Gate `#R-07` Window::new rule: green (2 documented tool-window exceptions). | |
| 46 | - | - [x] **Batch 2 — Selectable row primitive.** `selectable_row`, `selectable_row_secondary`, `selectable_tag`. Sidebar VFS / collections / tag tree, sort header, settings vault list migrated. | |
| 47 | - | - [x] **Batch 3 — Section headers + filter section.** `section_header`, `subsection_label`, `filter_section`. `filter_panel.rs`: 206 → 181 lines (5-way duplication eliminated). Sidebar and detail subsections migrated. | |
| 48 | - | - [x] **Batch 4 — Button hierarchy.** `primary_button`, `secondary_button`, `danger_button`. Modal action rows route through `confirm_action_row` with `danger: true`. | |
| 49 | - | - [x] **Batch 5 — Toolbar toggle + toggle pills.** `toolbar_toggle`, `toggle_pills`. 6 toolbar toggles + SearchScope + KeyFilterMode migrated. Toolbar emoji glyphs replaced with words. Gate `#R-08` emoji rule: green except for 3 documented file-list node-type icons (deferred to Phase 3 surface audit). | |
| 50 | - | - [x] **Batch 6 — Empty state + info banner.** `empty_state`, `EmptyStateCta`, `info_banner`. file_list "No matches" and "No samples yet", detail "Select a sample", sidebar VFS banner migrated. Welcome screen retains custom 3-step layout intentionally. `inline_text_submit`, `metadata_grid`, `preview_grid`, `toast`, `loading_spinner` deferred to post-consolidation (designed alongside Phase 1 findings). | |
| 51 | - | ||
| 52 | - | ### Part 2 — Surface audits (post-consolidation) | |
| 53 | - | ||
| 54 | - | Each phase runs one `/ux-audit` invocation against the listed surface; findings to `docs/ux-audit/phase-N.md`; fixes follow in subsequent commits, not during the audit. | |
| 55 | - | ||
| 56 | - | - [ ] **Phase 1 — Onboarding & setup.** `crates/audiofiles-app/src/main.rs`, `vault_setup.rs`, `activation.rs`. First-run experience, vault picking, license activation. Forgiveness + error-message review. | |
| 57 | - | - [ ] **Phase 2 — Main browser shell.** `audiofiles-browser/src/ui/sidebar.rs`, `toolbar.rs`, `footer.rs`, `overlays.rs`. Persistent chrome; navigation; global actions. | |
| 58 | - | - [x] **Phase 3 — File list & detail.** `file_list.rs`, `file_list_menus.rs`, `detail.rs`, `edit_panel.rs`. Audit + full implementation landed 2026-05-20: 4 Critical + 8 Major + 10 Minor + 4 Polish shipped; m-6 (paste-files) and m-7 (folder Open) deferred per audit; p-2/p-5 skipped per audit. Build clean, 199+44+439 tests pass, gates green. See `docs/ux-audit/phase-3.md` (implementation note at bottom). | |
| 59 | - | - [~] **Phase 4 — Filter / instrument / settings panels.** `filter_panel.rs`, `instrument_panel.rs`, `settings_panel.rs`, `sync_panel.rs`. Audit landed 2026-05-20: 6 Critical + 14 Major + 18 Minor + 6 Polish documented. Critical batch (C-1 through C-6) and full Major batch (M-1 through M-14) shipped same day: confirm-password gate on encryption setup, Disconnect-confirm, theme-error status posts, loading-flag timeout, sync.cancel_auth() + Copy-URL fallback, vault-row switch wiring, filter sentinels/snap/clear, ADSR tooltips + envelope viz, MIDI empty state, Add-Library Cancel, per-VFS storage stats, sync error Retry/Dismiss, etc. New widgets: `warning_banner`, `format_bytes`. New backend method: `vfs_storage_stats`. New SyncManager method: `cancel_auth`, `clear_last_error`. New ConfirmAction: `DisconnectSync`. Build clean, 199+44+439 tests pass, gates green. **Remaining:** 18 Minor + 6 Polish items in `docs/ux-audit/phase-4.md`. See implementation note to be added at bottom of phase-4.md. | |
| 60 | - | - [ ] **Phase 5 — Import / export flows.** `import_screens/{configure,progress,tagging,summary}.rs`, `export_screens.rs`. Multi-step wizards; cancel + recovery + progress feedback. | |
| 61 | - | - [ ] **Phase 6 — Waveform & editor.** `audiofiles-browser/src/waveform.rs`, `editor.rs`. Direct-manipulation primitives; affordance + Fitts review. | |
| 62 | - | - [ ] **Phase 7 — Cross-cutting flat-design + theme conformance.** Full sweep of `theme.rs` (Visuals), `widgets.rs`, dark/light parity if present, and a roll-up summary across phases 1-6. | |
| 63 | - | ||
| 64 | - | Out of scope: branding direction (egui constraints), full a11y audit (egui has its own a11y model — flag obvious issues only). | |
| 65 | - | ||
| 66 | - | --- | |
| 67 | - | ||
| 68 | - | ## Dependency Pruning (2026-05-13) | |
| 69 | - | ||
| 70 | - | ### High Impact | |
| 71 | - | - [ ] [dependency-prune] Replace `chrono` with `jiff` or inline a tiny `core/util/time.rs` (only `Utc::now`, `to_rfc3339`, `parse_from_rfc3339`, `Duration::{days,minutes}` used across ~30 sites) | |
| 72 | - | - [ ] [dependency-prune] Verify `reqwest` TLS backend isn't doubled — confirm `synckit-client` doesn't pull `default-features = true` and stack native-tls + rustls (`cargo tree -d`, `cargo tree -e features -p reqwest`) | |
| 73 | - | ||
| 74 | - | ### Medium Impact | |
| 75 | - | - [ ] [dependency-prune] Inline `semver` — only `Version::parse` + comparison on `X.Y.Z` strings in `updater.rs` (3 lines) | |
| 76 | - | - [ ] [dependency-prune] Replace `dirs` with a 30-line `core::paths` module wrapping `home_dir`/XDG/known-folder for 6 call sites | |
| 77 | - | - [ ] [dependency-prune] Inline `base64` — single PKCE URL-safe-no-pad encode in `sync/auth.rs` (~15 lines) | |
| 78 | - | - [ ] [dependency-prune] Inline `open` — `open::that(url)` × 2, replace with platform-matched `Command` (~15 lines) | |
| 79 | - | - [ ] [dependency-prune] Tighten `tracing-subscriber` to `default-features = false, features = ["fmt", "env-filter", "ansi"]` | |
| 80 | - | - [ ] [dependency-prune] Verify no transitive crate enables `tokio` `full` feature (`cargo tree -e features -p tokio`) | |
| 81 | - | ||
| 82 | - | --- | |
| 83 | - | ||
| 84 | - | ## Ultra Fuzz Run 1 (2026-05-09) | |
| 85 | - | ||
| 86 | - | ### Trust model (deferred — architectural) | |
| 87 | - | - [ ] Add ed25519 signature verification on OTA update metadata (updater.rs) | |
| 88 | - | - [ ] Move API key to OS keychain via `keyring` crate (app/main.rs:131) | |
| 89 | - | - [ ] Add wall-clock timeout on Rhai script execution (rhai/engine.rs) | |
| 90 | - | ||
| 91 | - | --- | |
| 92 | - | ||
| 93 | - | ## Sync Monetization | |
| 94 | - | ||
| 95 | - | AF is PWYW (suggested $15, floor $0). Metadata sync is free. Blob sync (sample files via `sync_files` VFS flag) is tiered by storage. See `MNW/server/docs/internal/business/app_sync_pricing.md` for full pricing rationale. | |
| 96 | - | ||
| 97 | - | - [ ] Test full checkout flow against live Stripe (end-to-end: subscribe → webhook → blob sync gate passes) | |
| 98 | - | ||
| 99 | - | --- | |
| 100 | - | ||
| 101 | - | ## Classification Pipeline — Remaining | |
| 102 | - | ||
| 103 | - | - [ ] Accuracy on non-Goldbaby data — evaluate with archive-org-drum-machines and mixed sources | |
| 104 | - | ||
| 105 | - | ### Vocal Layer 2 (3 classes) — deferred, needs data | |
| 106 | - | - [ ] Source vocal-chop data (short rhythmic vocal cuts — VocalSet beatbox, Freesound API) | |
| 107 | - | - [ ] Source vocal-choir data (layered voices — VocalSet choir exercises, choral libraries) | |
| 108 | - | - [ ] Train layer2_vocal.json, target 85%+ CV accuracy | |
| 109 | - | - Classes: vocal-chop, vocal-phrase, vocal-choir | |
| 110 | - | ||
| 111 | - | --- | |
| 112 | - | ||
| 113 | - | ## Phase 7: Export — Device Reference | |
| 114 | - | ||
| 115 | - | | Device | Rate | Depth | Channels | Format | | |
| 116 | - | |--------|------|-------|----------|--------| | |
| 117 | - | | Dirtywave M8 | 44.1kHz | 8/16/24 | Mono | WAV | | |
| 118 | - | | Elektron Digitakt | 48kHz | 16-bit | Mono | WAV | | |
| 119 | - | | Elektron Digitakt II | 48kHz | 16-bit | Mono/Stereo | WAV | | |
| 120 | - | | Roland SP-404 MKII | 44.1kHz | 16-bit | Mono/Stereo | WAV | | |
| 121 | - | | Akai MPC | 44.1kHz | 16/24-bit | Mono/Stereo | WAV | | |
| 122 | - | | Polyend Tracker | 44.1kHz | 16/24/32 | Mono | WAV | | |
| 123 | - | | Synthstrom Deluge | 44.1kHz | 16/24-bit | Mono/Stereo | WAV/AIFF | | |
| 124 | - | | 1010music Blackbox | 48kHz | 16/24/32 | Mono/Stereo | WAV | | |
| 125 | - | | Korg Volca Sample 2 | 31.25kHz | 16-bit | Mono | WAV | | |
| 126 | - | | TE OP-1 | 44.1kHz | 16/24-bit | Mono/Stereo | AIFF/WAV | | |
| 127 | - | ||
| 128 | - | ## Phase 8: Distribution | |
| 129 | - | ||
| 130 | - | ### Remaining | |
| 131 | - | - [ ] Windows standalone installer (Inno Setup or WiX) | |
| 132 | - | - [ ] Code-sign Windows binaries | |
| 133 | - | - [ ] Test standalone on Windows | |
| 134 | - | ||
| 135 | - | ## Phase 10: Plugin Processing | |
| 136 | - | ||
| 137 | - | ### Remaining | |
| 138 | - | - [ ] Add `clack-host` + `clack-extensions` git dependencies (pin commit hash) | |
| 139 | - | - [ ] Plugin scanner: walk standard CLAP paths, cache descriptors in SQLite | |
| 140 | - | - [ ] Plugin host engine: load → init → activate → process chunks → deactivate → destroy | |
| 141 | - | - [ ] Parameter discovery: query plugin params, render as egui sliders/knobs | |
| 142 | - | - [ ] Render workflow: process sample → save as new sample in content-addressed store | |
| 143 | - | - [ ] Plugin state save/load: store state blobs in SQLite for preset recall | |
| 144 | - | - [ ] Chain support: multiple plugins in series | |
| 145 | - | - [ ] Wet/dry mix control + A/B preview (original vs processed) | |
| 146 | - | ||
| 147 | - | ## Phase 12: Chop Engine | |
| 148 | - | - [ ] Waveform view with draggable slice markers | |
| 149 | - | - [ ] Auto-chop by transient detection (reuse existing onset analysis) | |
| 150 | - | - [ ] Auto-chop by equal divisions (2, 4, 8, 16, 32 slices) | |
| 151 | - | - [ ] Auto-chop by BPM grid (reuse stratum-dsp BPM detection) | |
| 152 | - | - [ ] Export slices as individual samples into VFS folder | |
| 153 | - | - [ ] Batch chop: apply same slice template to multiple samples | |
| 154 | - | ||
| 155 | - | ## Phase 13: Resample and Time-Stretch | |
| 156 | - | - [ ] Resample to target rate (rubato already in deps — 22.05, 44.1, 48, 96 kHz) | |
| 157 | - | - [ ] Bit-depth conversion (8, 16, 24, 32-bit) | |
| 158 | - | - [ ] Time-stretch without pitch change (rubato or new algorithm) | |
| 159 | - | - [ ] Pitch-shift without time change | |
| 160 | - | - [ ] Varispeed (pitch + time together, tape-style) | |
| 161 | - | ||
| 162 | - | ## Phase 14: Layer and Stack | |
| 163 | - | - [ ] Layer: mix N samples with per-layer gain/pan, render to new sample | |
| 164 | - | - [ ] Concatenate: join samples end-to-end with crossfade options | |
| 165 | - | - [ ] Round-robin export: distribute layers into numbered one-shots for sampler instruments | |
| 166 | - | ||
| 167 | - | ## Phase 15: Batch Forge | |
| 168 | - | - [ ] Batch normalize (peak or LUFS target across selection) | |
| 169 | - | - [ ] Batch resample (convert selection to target rate/depth) | |
| 170 | - | - [ ] Batch trim silence (auto-detect and trim leading/trailing silence) | |
| 171 | - | - [ ] Batch apply plugin chain (run same plugin + params across selection) | |
| 172 | - | - [ ] Batch rename (pattern-based: prefix, suffix, numbering) | |
| 173 | - | ||
| 174 | - | ## Phase 16: Snapshot History | |
| 175 | - | - [ ] Store processing chain as metadata (operations + parameters) | |
| 176 | - | - [ ] Undo to any previous snapshot (content-addressed store makes this free — originals never deleted) | |
| 177 | - | - [ ] Compare snapshots side-by-side (A/B waveform + playback) | |
| 178 | - | - [ ] Fork: branch from any snapshot to try different processing paths | |
| 179 | - | ||
| 180 | - | --- | |
| 181 | - | ||
| 182 | - | ## UX Audit Findings (2026-05-02) | |
| 183 | - | ||
| 184 | - | ### Feature Completeness | |
| 185 | - | ||
| 186 | - | - [ ] Implement multi-sample instrument UI (KeyZone struct exists; radio button grayed out) | |
| 187 | - | - [ ] Export presets: save device + format + destination combos for reuse | |
| 188 | - | ||
| 189 | - | ### Complexity Reduction | |
| 190 | - | ||
| 191 | - | - [ ] Allow toggling loose-files mode per-vault in Settings (with copy-files-into-vault action) | |
| 192 | - | - [ ] Split Settings into tabs: Library, Display, License, Advanced | |
| 193 | - | ||
| 194 | - | ### Power User Gaps | |
| 195 | - | ||
| 196 | - | - [ ] Undo persistence: store deleted node metadata in DB (not just in-memory) | |
| 197 | - | - [ ] Or: add "Recently Deleted" trash section in sidebar with recovery | |
| 198 | - | - [ ] Bulk duplicate (create copies of selected samples) | |
| 199 | - | - [ ] Show sync conflict resolution when two devices edit same sample | |
| 200 | - | - [ ] Smart folders should be dynamic (re-compute on visit, not static snapshots) | |
| 201 | - | ||
| 202 | - | --- | |
| 203 | - | ||
| 204 | - | ## Rust-Fuzz Findings (2026-05-04) | |
| 205 | - | ||
| 206 | - | ### Should Fix | |
| 207 | - | - [ ] [rust-fuzz] `file_list_menus.rs:255,327` — `selected_nodes()` in drag path; iterate indices by reference instead | |
| 208 | - | - [ ] [rust-fuzz] `bulk_ops.rs:248-382` — use `Option::take()` instead of cloning to escape `if let` borrows (4 sites) | |
| 209 | - | - [ ] [rust-fuzz] `sidebar.rs:361` — tag list cloned every frame when search empty; pass reference or cache tree | |
| 210 | - | - [ ] [rust-fuzz] `export/mod.rs:171` — silent row-drop via `.filter_map(|r| r.ok())`; add `tracing::warn!` | |
| 211 | - | - [ ] [rust-fuzz] `vault_setup.rs:195`, `license.rs:247` — silent mkdir/trial-save failures; log warnings | |
| 212 | - | ||
| 213 | - | --- | |
| 214 | - | ||
| 215 | - | ## Shared Code Extraction (Cross-Project) | |
| 216 | - | - [ ] Updater UI: extract updater.js from GO/BB into shared module | |
| 217 | - | - [ ] Saved queries: unify GO saved views, BB query feeds, AF smart folders | |
| 218 | - | ||
| 219 | - | ## Code Fuzz Findings (2026-04-27) | |
| 220 | - | ||
| 221 | - | ### Minor | |
| 222 | - | - [ ] **M8** Trial reset by deleting `trial.json` — accepted as intentionally lenient (`license.rs:201`) | |
| 223 | - | ||
| 224 | - | --- | |
| 225 | - | ||
| 226 | - | ## Key Paths | |
| 227 | - | ``` | |
| 228 | - | crates/audiofiles-core/ Core library (store, VFS, DB, analysis, search) | |
| 229 | - | crates/audiofiles-browser/ egui browser UI | |
| 230 | - | crates/audiofiles-app/ Desktop app (eframe + cpal) | |
| 231 | - | crates/audiofiles-core/src/export/ Export engine | |
| 232 | - | crates/audiofiles-rhai/ Rhai plugin runtime | |
| 233 | - | plugins/bundled/ Device export plugins | |
| 234 | - | ``` |
| @@ -1,284 +0,0 @@ | |||
| 1 | - | # audiofiles — Completed Items | |
| 2 | - | ||
| 3 | - | Items below were moved from todo.md to keep the active todo focused on open work. | |
| 4 | - | ||
| 5 | - | --- | |
| 6 | - | ||
| 7 | - | ## Ultra Fuzz Run 1 (2026-05-09) | |
| 8 | - | ||
| 9 | - | ### Sync (SERIOUS — fix before first multi-device user) | |
| 10 | - | - [x] Add `'duration', duration` to initial snapshot samples query (sync/service/state.rs:26) | |
| 11 | - | - [x] Add 5 missing columns to initial snapshot audio_analysis query (sync/service/state.rs:27) | |
| 12 | - | - [x] Exclude `unsafe_mode` from user_config sync triggers (migration 016 + snapshot filter) | |
| 13 | - | - [x] Batch `mark_cloud_only_samples` into single transaction (sync/service/state.rs:134) | |
| 14 | - | ||
| 15 | - | ### Preview decode (perf) | |
| 16 | - | - [x] Hoist SampleBuffer allocation out of decode loops (preview.rs:151,353) | |
| 17 | - | ||
| 18 | - | ### Backend contention (perf) | |
| 19 | - | - [x] Release DB lock before VP-tree index build (backend/direct.rs — load_data/build_from_data split) | |
| 20 | - | - [x] Batch `enrich_with_tags` query in export (export/mod.rs — single IN query, chunked at 500) | |
| 21 | - | ||
| 22 | - | ### UI fixes (MINOR) | |
| 23 | - | - [x] Fix `truncate_name` to use char boundaries, not byte offsets (ui/file_list_menus.rs:316) | |
| 24 | - | - [x] Fix `theme_preview_colors` key prefix: `bg.`→`background.`, `fg.`→`foreground.` (ui/theme.rs:404) | |
| 25 | - | - [x] Add macOS metadata dir filter to import dry-run count (import_workflow.rs) | |
| 26 | - | ||
| 27 | - | ### Data integrity (MINOR) | |
| 28 | - | - [x] Orphan delete re-checks with NOT EXISTS subquery (cleanup.rs:200) | |
| 29 | - | ||
| 30 | - | --- | |
| 31 | - | ||
| 32 | - | ## Audit Run 20 (2026-05-04) | |
| 33 | - | ||
| 34 | - | All items resolved: | |
| 35 | - | - Split app/main.rs: activation.rs (198L), vault_setup.rs (218L), main.rs 1296→899L | |
| 36 | - | - Fixed Relaxed → Acquire/Release in analysis/worker.rs (6 atomic ops) | |
| 37 | - | - Added 7 sync tests (download query, upload query, resolve upsert/delete edge cases) | |
| 38 | - | - Aligned import_directory_recursive: added sorting, audio filtering, skipped-dir checks | |
| 39 | - | ||
| 40 | - | --- | |
| 41 | - | ||
| 42 | - | ## Sync Monetization (completed items) | |
| 43 | - | ||
| 44 | - | - [x] Stripe pricing: inline price_data (Light $1/$10, Standard $3/$30, Large $8/$80), no pre-created products | |
| 45 | - | - [x] Blob sync gate: server returns 402 on blob endpoints when no subscription, blob errors non-fatal in scheduler | |
| 46 | - | - [x] Metadata sync remains ungated (free for all users) | |
| 47 | - | - [x] Subscription UI: egui tier selector (Light/Standard/Large) with Annual/Monthly buttons, storage usage progress bar | |
| 48 | - | - [x] Storage usage display: progress bar showing used/limit GB from subscription status | |
| 49 | - | - [x] Tier upgrade/downgrade flow — server endpoint, Stripe proration, synckit-client method, AF UI with change buttons | |
| 50 | - | - [x] Annual billing messaging — already in sync_panel.rs:115-121 ("Annual saves you money — fewer Stripe transactions means less processing fees") + per-tier savings shown inline | |
| 51 | - | ||
| 52 | - | ## SyncKit Parity with GoingsOn (2026-05-11) | |
| 53 | - | ||
| 54 | - | Fixes needed to match GO's working end-to-end SyncKit flow: | |
| 55 | - | ||
| 56 | - | - [x] **synckit.toml** — create `synckit.toml` with AF's API key (need to create sync app on MNW dashboard first). Replace `option_env!("SYNCKIT_API_KEY")` / `EMBEDDED_API_KEY` in main.rs with `include_str!("../../../synckit.toml")` + parser. Current approach breaks silently on recompile without env var. | |
| 57 | - | - [x] **OAuth callback CORS** — `audiofiles-sync/src/auth.rs` callback server responses missing `Access-Control-Allow-Origin: *` header. Blocks egui webview (if applicable) or external browser from polling result. | |
| 58 | - | - [x] **OAuth callback not awaited (CRITICAL)** — `start_auth()` returns `AuthSession` with `code_rx` oneshot receiver, but nothing ever awaits `code_rx` or calls `complete_auth()`. The callback result is lost. Fix: spawn a background tokio task in `SyncManager::start_auth()` that awaits the callback and automatically calls `complete_auth()`, updating `SyncStatus` on success. | |
| 59 | - | - [x] **No poll loop** — AF is egui (not Tauri/JS), so there's no browser-based poll. The OAuth flow needs to work via the oneshot channel + background task pattern described above. | |
| 60 | - | ||
| 61 | - | --- | |
| 62 | - | ||
| 63 | - | ## Phase 11: Destructive Edits (Complete) | |
| 64 | - | ||
| 65 | - | - [x] DC offset removal | |
| 66 | - | - [x] Silence insert/remove | |
| 67 | - | - [x] Mono-to-stereo / stereo-to-mono conversion | |
| 68 | - | ||
| 69 | - | ## Aesthetic-Usability Polish (2026-05-05) | |
| 70 | - | ||
| 71 | - | egui Visuals overhaul — the only FAIR grade in the Laws of UX audit. | |
| 72 | - | ||
| 73 | - | - [x] Extend ThemeColors with `section_spacing` (16.0), `grid_row_spacing` (6.0), `button_padding_x` (8.0), `button_padding_y` (4.0) — TOML-configurable with defaults | |
| 74 | - | - [x] Waveform height: 100px → 120px (more breathing room) | |
| 75 | - | - [x] Detail panel section spacing: hardcoded 12/4px → theme-driven `section_spacing()` (16px default) | |
| 76 | - | - [x] Metadata grid row spacing: 4px → `grid_row_spacing()` (6px default) | |
| 77 | - | - [x] Sample name spacing: 4px → 8px after title | |
| 78 | - | - [x] Tag suggestions spacing: 2px → 6px | |
| 79 | - | - [x] Discovery buttons spacing: 4px → 6px | |
| 80 | - | - [x] Softer widget borders: thinner strokes (0.5px) on inactive state, full on hover/active | |
| 81 | - | - [x] Softer separator color: blended with background (40% lighter) | |
| 82 | - | - [x] Widget hover expansion: 1.0px grow on hover for tactile feedback | |
| 83 | - | - [x] Button padding: 6x3 → 8x4 (theme-configurable) | |
| 84 | - | - [x] Window margin: set to 10x10 (was egui default) | |
| 85 | - | - [x] Indent: 18px (was egui default ~21px) | |
| 86 | - | ||
| 87 | - | --- | |
| 88 | - | ||
| 89 | - | ## UX Audit Findings (2026-05-02) (completed items) | |
| 90 | - | ||
| 91 | - | Usability audit across complexity, feature completeness, learnability, and discoverability. | |
| 92 | - | Overall grade: B+. Grades: Complexity B, Completeness B, Learnability B+, Discoverability C+. | |
| 93 | - | ||
| 94 | - | ### Critical Discoverability | |
| 95 | - | ||
| 96 | - | - [x] Add "?" button to toolbar that opens help overlay (F1 not discoverable) | |
| 97 | - | - [x] Add "right-click for options" hint in status bar on first launch | |
| 98 | - | - [x] Add "Find Similar" button in detail panel + Shift+F shortcut | |
| 99 | - | - [x] Add "Find Duplicates" button in detail panel + Shift+D shortcut | |
| 100 | - | - [x] Document drag-out to DAW/Finder: add drag handle icon or tooltip on file list rows | |
| 101 | - | - [x] Show keyboard shortcuts in right-click context menu items (e.g. "Bulk Rename (F2)") | |
| 102 | - | ||
| 103 | - | ### Import Flow Simplification | |
| 104 | - | ||
| 105 | - | - [x] Add "Quick Import" path: choose folder → import with default analysis → done (3 steps) | |
| 106 | - | - [x] Keep current advanced flow behind "Customize" toggle (Import → "Folder (customize)...") | |
| 107 | - | - [x] Default analysis to all enabled (BPM + Key + Loudness + Classify + Fingerprint) | |
| 108 | - | - [x] Add import dry-run: show file count before committing (duplicates detected during import) | |
| 109 | - | - [x] Show import summary after completion (added/skipped/failed counts) | |
| 110 | - | - [x] Auto-generate smart folder names from active filters (e.g. "BPM: 80-120 | Class: kick") | |
| 111 | - | - [x] Make tag folders step optional (skipped in quick import; advanced flow unchanged) | |
| 112 | - | ||
| 113 | - | ### Terminology & Mental Model | |
| 114 | - | ||
| 115 | - | - [x] Use "vault" consistently instead of library/VFS interchangeably | |
| 116 | - | - [x] Clarify in vault setup: "A vault is your sample collection. Files stay where they are." | |
| 117 | - | - [x] Merge Smart Folders + Collections into unified "Collections" (manual + dynamic) | |
| 118 | - | - ~~Rename "Unsafe mode"~~ — intentionally kept as-is (discourages reliance) | |
| 119 | - | - [x] Show dot-notation tag syntax hint in tag input placeholder: "Use dots for hierarchy: genre.house" | |
| 120 | - | - [x] Label search scope toggle explicitly: "in: Folder / All" | |
| 121 | - | ||
| 122 | - | ### Feature Completeness (completed items) | |
| 123 | - | ||
| 124 | - | - [x] Batch edit: apply gain/trim/normalize/reverse to multiple selected samples | |
| 125 | - | - [x] Tag templates/presets: quick-pick buttons for common tag hierarchies (via classification-based suggestions in detail panel) | |
| 126 | - | - [x] Show tag suggestions in detail panel (not only during import review) | |
| 127 | - | - [x] Batch analysis re-run on selected samples with different parameters | |
| 128 | - | - [x] DC offset removal: expose existing internal function as UI button in editor | |
| 129 | - | - [x] Pause and cancel buttons on import/analysis/export progress screens (cancel existed; pause deferred — complex state) | |
| 130 | - | - [x] Check destination disk space before export; warn if insufficient | |
| 131 | - | ||
| 132 | - | ### Export Edge Cases | |
| 133 | - | ||
| 134 | - | - [x] Detect and reject AIFF exports exceeding u32 chunk size (~24 min stereo 24-bit) | |
| 135 | - | - [x] Fix flat export filename collisions when no naming rules set (dedup suffix always) | |
| 136 | - | - [x] Warn when manual audio settings violate device profile constraints (N/A: profile hides manual controls) | |
| 137 | - | - [x] Show file size limit errors in export config screen, not after export starts | |
| 138 | - | ||
| 139 | - | ### Learnability | |
| 140 | - | ||
| 141 | - | - [x] First-launch hint: "Press F1 for keyboard shortcuts" (dismissible) | |
| 142 | - | - [x] Add tooltips showing shortcut on toolbar buttons: "Edit (E)", "Instrument (I)" | |
| 143 | - | - [x] Add loop toggle button in toolbar (L key shortcut shown in tooltip) | |
| 144 | - | - [x] Add Cmd+M shortcut for bulk move | |
| 145 | - | - [x] Explain Collections vs Smart Folders distinction (merged — no longer needed) | |
| 146 | - | ||
| 147 | - | ### Complexity Reduction (completed items) | |
| 148 | - | ||
| 149 | - | - [x] Theme picker: show color swatch/thumbnail per theme instead of flat name dropdown | |
| 150 | - | - [x] Add tag search box above sidebar tag tree with filter | |
| 151 | - | ||
| 152 | - | ### Documentation | |
| 153 | - | ||
| 154 | - | - [x] Expand help overlay beyond shortcuts: add "Features" tab with search, filters, collections, tags, import, export | |
| 155 | - | - [x] Document system tray integration in settings or help — added to Features tab in help overlay | |
| 156 | - | - [x] Show device profile count in export dialog header | |
| 157 | - | ||
| 158 | - | ## UX Audit Findings (2026-05-03) (completed items) | |
| 159 | - | ||
| 160 | - | Usability audit focused on complexity, completeness, learnability, discoverability. | |
| 161 | - | Overall grade: B+. Grades: Complexity B, Completeness B+, Learnability C+, Discoverability C. | |
| 162 | - | ||
| 163 | - | ### Prioritized Fixes | |
| 164 | - | ||
| 165 | - | 1. [x] **Structured first-run onboarding** — welcome screen with 3-step guide + inline Import link on first launch | |
| 166 | - | 2. [x] **Persist filter state across navigation** — filters already persist; added filter-aware empty state ("No matches in this folder" + Clear Filters button) | |
| 167 | - | 3. [x] **Surface MIDI/instrument features** — empty state hint in instrument panel, tooltip on piano keyboard (play/right-click/drag) | |
| 168 | - | 4. [x] **Batch editing** — batch normalize (peak/LUFS), gain, reverse in edit panel when 2+ samples selected | |
| 169 | - | 5. [x] **Numeric range filters** — BPM + duration already existed; added loudness (peak dB) range filter | |
| 170 | - | 6. ~~Rename "Unsafe mode"~~ — intentionally kept as-is (see prior decision above) | |
| 171 | - | 7. [x] **Promote "Save as Collection"** — show persistent "Save" button in toolbar/filter header when filters active | |
| 172 | - | 8. [x] **Simplify Settings** — moved theme import/export and vault mirror to collapsed "Advanced" section | |
| 173 | - | ||
| 174 | - | --- | |
| 175 | - | ||
| 176 | - | ## Rust-Fuzz Findings (2026-05-04) (completed items) | |
| 177 | - | ||
| 178 | - | Rust quality audit: unsafe discipline, memory efficiency, error handling, smart pointers. | |
| 179 | - | Overall grade: A-. Unsafe: CLEAN. Memory: SOME WASTE. Errors: ELEGANT. Pointers: JUSTIFIED. | |
| 180 | - | ||
| 181 | - | ### Must Fix | |
| 182 | - | - [x] [rust-fuzz] `bulk_ops.rs:84-103` — `selected_nodes()` clones full structs; add field-specific accessors that extract ids/hashes without cloning | |
| 183 | - | - [x] [rust-fuzz] `import_workflow.rs:585,598` — full hash Vec cloned to escape `if let` borrow; use `std::mem::replace` to move data out | |
| 184 | - | ||
| 185 | - | ### Should Fix (completed items) | |
| 186 | - | - [x] [rust-fuzz] `export_screens.rs:19,34` — add `// SAFETY:` comments to `statvfs` and `GetDiskFreeSpaceExW` unsafe blocks | |
| 187 | - | ||
| 188 | - | --- | |
| 189 | - | ||
| 190 | - | ## Code Fuzz Findings (2026-05-03) (completed items) | |
| 191 | - | ||
| 192 | - | Second audit of all 7 crates (~42k lines). Items marked [mechanical] are fixable without design changes. | |
| 193 | - | ||
| 194 | - | ### Critical | |
| 195 | - | - [x] **C1** [mechanical] Poison changelog entry blocks sync forever — skipped entries now marked `pushed=1` (`sync/upload.rs:213`) | |
| 196 | - | - [x] **C2** [mechanical] Corrupted JSON pushed as `data: None` — unparseable entries now marked pushed and skipped (`sync/upload.rs:222`) | |
| 197 | - | ||
| 198 | - | ### Serious | |
| 199 | - | - [x] **S1** [mechanical] `applying_remote` flag stuck on error — ROLLBACK on failure in `mark_cloud_only_samples` (`sync/state.rs:131`) | |
| 200 | - | - [x] **S2** [mechanical] Non-atomic blob download — write to temp file then atomic rename (`sync/download.rs:115`) | |
| 201 | - | - [x] **S3** `INSERT OR REPLACE` cascades FK deletes — replaced with `INSERT ... ON CONFLICT DO UPDATE` (`sync/resolve.rs:105`) | |
| 202 | - | - [x] **S4** [mechanical] `set_sync_state` silently no-ops if key missing — uses `INSERT OR REPLACE` now (`sync/mod.rs:140`) | |
| 203 | - | - [x] **S5** [mechanical] Infinity duration when `sample_rate=0` — early return with duration 0.0 (`analysis/waveform.rs:23`) | |
| 204 | - | - [x] **S6** [mechanical] No `sample_rate=0` guard in `measure_lufs` — returns -70.0 sentinel (`analysis/loudness.rs:6`) | |
| 205 | - | ||
| 206 | - | ### Medium | |
| 207 | - | - [x] **M1** [mechanical] `2u64.pow(consecutive_failures)` panics at 64 failures — replaced with `saturating_pow` (`sync/scheduler.rs:75`) | |
| 208 | - | - [x] **M2** [mechanical] `zone_index` OOB panic in audio thread — bounds check deactivates stale voice (`browser/instrument.rs:250`) | |
| 209 | - | - [x] **M3** [mechanical] OOM from unvalidated `n_frames` metadata — capacity capped at ~30 min stereo (`browser/preview.rs:297`) | |
| 210 | - | - [x] **M4** [mechanical] Division by zero panic when `channels=0` in trim — returns error (`core/edit/trim.rs:16`) | |
| 211 | - | - [x] **M5** [mechanical] Division by zero panic when `channels=0` in silence insert/remove — returns error (`core/edit/silence.rs:15`) | |
| 212 | - | - [x] **M6** [mechanical] `apply_mono_to_stereo` doesn't verify input is mono — now takes `channels` param, rejects non-mono (`core/edit/channel_convert.rs:8`) | |
| 213 | - | - [x] **M7** [mechanical] Empty/separator-only rename pattern produces empty filename — rejected at parse time (`core/rename.rs:45`) | |
| 214 | - | - [x] **M8** [mechanical] `start_cleanup` doesn't cancel existing worker — cancels before spawning new one (`browser/backend/direct.rs:850`) | |
| 215 | - | - [x] **M9** [mechanical] `pad_left`/`pad_right`/`format_index` OOM — width capped at 10,000 (`rhai/host_api.rs:27`) | |
| 216 | - | ||
| 217 | - | ### Minor / Notes | |
| 218 | - | - [x] **N1** [mechanical] FFT error silently discarded via `.ok()` (`analysis/spectral.rs:116`). Fixed: skip frame on error. | |
| 219 | - | - [x] **N2** NaN features always traverse right branch in random forest — no NaN guard at classifier boundary (`analysis/classify.rs:546`). Fixed: NaN goes left (conservative path). | |
| 220 | - | - [x] **N3** [mechanical] Zero dither seed produces degenerate xorshift — outputs 0 forever (`export/dither.rs:9`). Fixed: substitute non-zero seed. | |
| 221 | - | - [x] **N4** `peak_db` on empty slice returns -96.0 same as silence — inconsistent with `rms_db` which guards empty (`analysis/basic.rs:8`). Fixed: early return for empty slice. | |
| 222 | - | - [x] **N5** Initial snapshot not transactional — partial failure creates duplicate changelog entries on retry (`sync/state.rs:15`). Fixed: wrapped in transaction. | |
| 223 | - | - [x] **N6** `INFINITY` distance violates VP-tree triangle inequality contract (`similarity.rs:143`). Fixed: use large finite value (1e10). | |
| 224 | - | - [x] **N7** Malformed filter JSON silently downgrades dynamic collection to manual (`collections.rs:94`). Fixed: log warning. | |
| 225 | - | - [x] **N8** No explicit `set_max_expr_depths` in Rhai engine — deeply nested expressions could blow Rust stack (`rhai/engine.rs:9`). Fixed: set depths (64, 32). | |
| 226 | - | - [x] **N9** `build_sample_info` silently swallows non-"not found" DB errors via `unwrap_or` (`browser/backend/direct.rs:234`). Fixed: log warning for non-"not found" errors. | |
| 227 | - | ||
| 228 | - | --- | |
| 229 | - | ||
| 230 | - | ## Code Fuzz Findings (2026-04-27) (completed items) | |
| 231 | - | ||
| 232 | - | Audit of all 7 crates (~40k lines). Items marked [mechanical] are fixable without design changes. | |
| 233 | - | ||
| 234 | - | ### Critical | |
| 235 | - | - [x] **C1** [mechanical] Export path traversal: filenames not sanitized without NamingRules — `../../evil.wav` writes outside dest (`export/resolve.rs`) | |
| 236 | - | - [x] **C2** [mechanical] Streaming buffer OOB: race between `decoded_frames` and `data.extend()` in audio callback (`audio.rs:172`) | |
| 237 | - | ||
| 238 | - | ### Serious | |
| 239 | - | - [x] **S1** `applying_remote` flag stuck on crash — wrapped in transaction for atomicity (`sync/service/resolve.rs:16`) | |
| 240 | - | - [x] **S2** Changelog retention now prefers deleting pushed entries first (`sync/service/state.rs:69`) | |
| 241 | - | - [x] **S3** DB lock released before Rhai script execution (`backend/direct.rs:146`) | |
| 242 | - | - [x] **S4** [mechanical] ML model OOB: `features[*feature]` unchecked in `TreeNode::predict` (`classify.rs:219`) | |
| 243 | - | - [x] **S5** `expect()` replaced with graceful fallback to empty model (`classify.rs:331`) | |
| 244 | - | - [x] **S6** [mechanical] Division by zero when packet reports 0 channels in decode (`decode.rs:126`) | |
| 245 | - | - [x] **S7** [mechanical] `remove_orphaned_samples` manual transaction lacks rollback on error (`store.rs:230`) | |
| 246 | - | - [x] **S8** [mechanical] `purge_missing_unsafe` no atomicity — partial purge + concurrent import can cascade-delete (`store.rs:425`) | |
| 247 | - | - [x] **S9** Edit temp files cleaned up on worker startup (`edit/worker.rs:164`) | |
| 248 | - | - [x] **S10** [mechanical] Rename pattern resolves to empty string when all tokens are None (`rename.rs:92`) | |
| 249 | - | - [x] **S11** `bit_depth` probed from file header at export time (`backend/direct.rs:253`) | |
| 250 | - | ||
| 251 | - | ### Minor (completed items) | |
| 252 | - | - [x] **M1** [mechanical] Division by zero with 0 channels in `convert_channels` and `resample` (`export/convert.rs`) | |
| 253 | - | - [x] **M2** [mechanical] Division by zero with 0 channels in AIFF encoder (`export/encode_aiff.rs:26`) | |
| 254 | - | - [x] **M3** `sanitize_filename` returns "untitled" for empty results (`export/sanitize.rs:13`) | |
| 255 | - | - [x] **M4** Original export now copies instead of hardlinking (`export/runner.rs:155`) | |
| 256 | - | - [x] **M5** [mechanical] Division by zero with sample_rate=0 in `detect_bpm_key` (`bpm.rs:19`) | |
| 257 | - | - [x] **M6** [mechanical] Division by zero with sample_rate=0 in `is_beat_aligned` (`loop_detect.rs:85`) | |
| 258 | - | - [x] **M7** Trial clock rollback detected via `last_seen_date` field (`license.rs:217`) | |
| 259 | - | - [x] **M9** Update URL pinned to makenot.work domain (`updater.rs:122`) | |
| 260 | - | - [x] **M10** VP-tree depth cap now chains all remaining items as linked list (`vp_tree.rs:152`) | |
| 261 | - | - [x] **M11** Added partial unique index for root-level VFS nodes (migration 014) (`db.rs:65`) | |
| 262 | - | - [x] **M12** Migration non-ALTER errors now logged as warnings (`db.rs:696`) | |
| 263 | - | - [x] **M13** `validate_sample` hook error now rejects (fail-closed) (`backend/direct.rs:149`) | |
| 264 | - | - [x] **M14** `transform_filename` output sanitized (path separators stripped) (`backend/direct.rs:185`) | |
| 265 | - | - [x] **M15** User-to-user plugin override now logs a warning (`registry.rs:45`) | |
| 266 | - | - [x] **M16** Windows `GlobalLock` null return checked in OLE drag (`windows.rs:209`) | |
| 267 | - | ||
| 268 | - | ### Notes (all fixed) | |
| 269 | - | - [x] N1: `registry_path()`/`default_vault_path()` now log warning on $HOME fallback (`vault.rs:45`) | |
| 270 | - | - [x] N2: Rayon panics caught with `catch_unwind`, emitted as `SampleError` (`worker.rs:145`) | |
| 271 | - | - [x] N3: File size check (2 GB) + duration check (30 min) before/after decode (`analysis/mod.rs:87`) | |
| 272 | - | - [x] N4: `feature_distance` returns `f64::INFINITY` when both vectors all-None (`similarity.rs:116`) | |
| 273 | - | - [x] N5: Cancel flag uses `Acquire`/`Release` ordering; cancel checks between decode/edit/encode (`edit/worker.rs`) | |
| 274 | - | - [x] N6: Existing worker explicitly cancelled before starting new one (`backend/direct.rs`) | |
| 275 | - | - [x] N7: `discover_plugins` logs warnings for read/parse errors (`rhai/loader.rs:31`) | |
| 276 | - | - [x] N8: Multi-char separator rejected with error instead of silently truncated (`rhai/manifest.rs:137`) | |
| 277 | - | ||
| 278 | - | --- | |
| 279 | - | ||
| 280 | - | ## UX Audit Findings (2026-05-02) (remaining completed items) | |
| 281 | - | ||
| 282 | - | ### Power User Gaps (completed items) | |
| 283 | - | - [x] Copy metadata: apply tags/BPM/key from one sample to selected others | |
| 284 | - | - [x] Sync status indicator in toolbar: synced/syncing/pending count — button label shows ✓/↻/count/–, tooltip shows detail |
| @@ -1,234 +0,0 @@ | |||
| 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. |
| @@ -1,286 +0,0 @@ | |||
| 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. |
| @@ -1,248 +0,0 @@ | |||
| 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. |
| @@ -1,258 +0,0 @@ | |||
| 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. |
| @@ -1,291 +0,0 @@ | |||
| 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). |
| @@ -1,438 +0,0 @@ | |||
| 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. |
| @@ -1,542 +0,0 @@ | |||
| 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
| @@ -1,594 +0,0 @@ | |||
| 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