max / audiofiles
9 files changed,
+84 insertions,
-84 deletions
| @@ -95,7 +95,7 @@ Export converts VFS subtrees into standalone file hierarchies on disk. The pipel | |||
| 95 | 95 | 1. **Collect items**: Walk the VFS subtree, resolving each sample link to its content-addressed blob path and enriching with tags. | |
| 96 | 96 | 2. **Configure**: User selects format (original, WAV, AIFF), sample rate, bit depth, channel configuration, structure (preserve tree or flatten), naming pattern (with tokens like `{name}`, `{bpm}`, `{key}`, `{class}`), metadata sidecar, and destination directory. | |
| 97 | 97 | 3. **Device profiles**: Optionally select a hardware sampler profile (from the Rhai plugin registry) which pre-fills format constraints and may run custom hook scripts during export. | |
| 98 | - | 4. **Execute**: Background worker copies or transcodes each file, applying format conversion (via hound + rubato for resampling), channel conversion (mono/stereo), and naming rules. Progress is reported per file. Each output is written atomically via a `write_atomic(dest, |tmp| ...)` helper — the encoder/copier targets `dest.audiofiles_tmp`, then `fs::rename`s into place on success. A killed export never leaves a partial file in the user's export directory. | |
| 98 | + | 4. **Execute**: Background worker copies or transcodes each file, applying format conversion (via hound + rubato for resampling), channel conversion (mono/stereo), and naming rules. Progress is reported per file. Each output is written atomically via a `write_atomic(dest, |tmp| ...)` helper: the encoder/copier targets `dest.audiofiles_tmp`, then `fs::rename`s into place on success. A killed export never leaves a partial file in the user's export directory. | |
| 99 | 99 | ||
| 100 | 100 | ## Instrument Engine | |
| 101 | 101 |
| @@ -229,7 +229,7 @@ Music Production, Samples, Audio, Sample Manager, Hardware Sampler, Rust | |||
| 229 | 229 | - **Sync:** synckit-client (ChaCha20-Poly1305, Argon2, reqwest) | |
| 230 | 230 | - **Font:** Recursive Mono Linear Bold (logo) | |
| 231 | 231 | ||
| 232 | - | **Workspace:** 5 crates — `audiofiles-core` (sync-only library), `audiofiles-browser` (egui UI), `audiofiles-app` (standalone desktop), `audiofiles-sync` (cloud sync), `audiofiles-rhai` (export scripting) | |
| 232 | + | **Workspace:** 5 crates: `audiofiles-core` (sync-only library), `audiofiles-browser` (egui UI), `audiofiles-app` (standalone desktop), `audiofiles-sync` (cloud sync), `audiofiles-rhai` (export scripting) | |
| 233 | 233 | ||
| 234 | 234 | ### Testing | |
| 235 | 235 |
| @@ -1,4 +1,4 @@ | |||
| 1 | - | # Sample deletion — tombstone design (proposal) | |
| 1 | + | # Sample deletion: tombstone design (proposal) | |
| 2 | 2 | ||
| 3 | 3 | **Status:** Phases 1-4 implemented (2026-06-19). Schema + read-path filter (Phase 1), tombstone/undelete/purge operations (Phase 2), the Trash section in Settings (Phase 3), and the startup sweep (Phase 4) are live. Phase 5 (pull-side "deleted on another device" notification) remains. | |
| 4 | 4 | ||
| @@ -10,7 +10,7 @@ | |||
| 10 | 10 | ||
| 11 | 11 | ## Problem | |
| 12 | 12 | ||
| 13 | - | Today, `apply_remote_changes` in `crates/audiofiles-sync/src/service/resolve.rs` applies a remote `DELETE samples WHERE hash=X` directly. SQLite enforces `ON DELETE CASCADE` on `vfs_nodes.sample_hash`, `tags.sample_hash`, and `collection_members.sample_hash` at the engine level (not via triggers), so `applying_remote='1'` does not suppress it. The receiving device loses every placement, every tag, and every collection membership of that sample — silently, without confirmation. | |
| 13 | + | Today, `apply_remote_changes` in `crates/audiofiles-sync/src/service/resolve.rs` applies a remote `DELETE samples WHERE hash=X` directly. SQLite enforces `ON DELETE CASCADE` on `vfs_nodes.sample_hash`, `tags.sample_hash`, and `collection_members.sample_hash` at the engine level (not via triggers), so `applying_remote='1'` does not suppress it. The receiving device loses every placement, every tag, and every collection membership of that sample, silently, without confirmation. | |
| 14 | 14 | ||
| 15 | 15 | The user's mental model for an audiofiles library is "my library, accessible from any of my devices, hand-picked launch cohort" (launch plan, 2026-06-01). The current behavior matches the implementation of "global library, but delete on one device wipes everything on every device with no record." Two specific surprises: | |
| 16 | 16 | ||
| @@ -25,7 +25,7 @@ The path forward is **soft-delete with a tombstone column**, replicating across | |||
| 25 | 25 | ||
| 26 | 26 | - **Cross-device "is this sample still in use anywhere?" awareness.** That requires a server-side reference count, which the current sync model (encrypted blob storage + opaque changelog) explicitly does not have. Each device sees only its own references plus the tombstone state. | |
| 27 | 27 | - **Per-VFS or per-collection delete isolation.** Removing a sample from one collection is already handled by `collection_members` delete (no sample deletion involved). Same for VFS placements. | |
| 28 | - | - **Reversing already-shipped purges.** Any `samples` row already deleted before this design lands is gone for good — the sync layer has no way to ask other devices "did you keep a copy." | |
| 28 | + | - **Reversing already-shipped purges.** Any `samples` row already deleted before this design lands is gone for good: the sync layer has no way to ask other devices "did you keep a copy." | |
| 29 | 29 | - **Server-side garbage collection of orphaned blobs.** Blob lifecycle on the cloud is a separate concern; this design only changes the DB row lifecycle. | |
| 30 | 30 | ||
| 31 | 31 | --- | |
| @@ -52,14 +52,14 @@ ALTER TABLE samples ADD COLUMN deleted_at INTEGER; | |||
| 52 | 52 | ||
| 53 | 53 | Every `SELECT FROM samples` (or join through `samples`) gains a `WHERE samples.deleted_at IS NULL` filter unless the caller is explicitly working with tombstoned rows (Trash view, sweep query, undelete). Estimated surface area (preliminary grep, needs verification during implementation): | |
| 54 | 54 | ||
| 55 | - | - `crates/audiofiles-core/src/store.rs` — every `sample_path`, `sample_extension`, dedup check | |
| 56 | - | - `crates/audiofiles-core/src/analysis/decode.rs`, `waveform.rs` — analysis pipeline | |
| 57 | - | - `crates/audiofiles-core/src/search/*` — search queries | |
| 58 | - | - `crates/audiofiles-core/src/vfs.rs` — enriched VFS queries (join to samples) | |
| 59 | - | - `crates/audiofiles-core/src/collections.rs` — collection member queries | |
| 60 | - | - `crates/audiofiles-core/src/tags.rs` — tag queries | |
| 61 | - | - `crates/audiofiles-core/src/fingerprint.rs`, `similarity.rs` — near-duplicate detection | |
| 62 | - | - `crates/audiofiles-browser/src/backend/direct.rs` — Backend trait surface | |
| 55 | + | - `crates/audiofiles-core/src/store.rs`: every `sample_path`, `sample_extension`, dedup check | |
| 56 | + | - `crates/audiofiles-core/src/analysis/decode.rs`, `waveform.rs`: analysis pipeline | |
| 57 | + | - `crates/audiofiles-core/src/search/*`: search queries | |
| 58 | + | - `crates/audiofiles-core/src/vfs.rs`: enriched VFS queries (join to samples) | |
| 59 | + | - `crates/audiofiles-core/src/collections.rs`: collection member queries | |
| 60 | + | - `crates/audiofiles-core/src/tags.rs`: tag queries | |
| 61 | + | - `crates/audiofiles-core/src/fingerprint.rs`, `similarity.rs`: near-duplicate detection | |
| 62 | + | - `crates/audiofiles-browser/src/backend/direct.rs`: Backend trait surface | |
| 63 | 63 | ||
| 64 | 64 | Total ballpark: 30-50 query sites. Most are mechanical (add a clause). A few need a paired explicit-tombstone variant (e.g., the Trash view). | |
| 65 | 65 | ||
| @@ -92,7 +92,7 @@ Tradeoff: a longer window means more recoverability but more disk consumed by to | |||
| 92 | 92 | - **Live sample** in any view: no visual change from today. | |
| 93 | 93 | - **Tombstoned sample** in normal browse: hidden by the read-path filter. | |
| 94 | 94 | - **Trash view**: new entry in the Settings panel showing all tombstoned samples grouped by deletion date. Each row offers "Undelete" (clear `deleted_at`) and "Delete permanently" (skip the retention window, hard-delete now). | |
| 95 | - | - **Pull notification**: when a remote tombstone is applied, surface a status message: "12 samples tombstoned by another device — see Trash to recover." | |
| 95 | + | - **Pull notification**: when a remote tombstone is applied, surface a status message: "12 samples tombstoned by another device, see Trash to recover." | |
| 96 | 96 | ||
| 97 | 97 | ### Sync semantics summary | |
| 98 | 98 | ||
| @@ -101,10 +101,10 @@ Tradeoff: a longer window means more recoverability but more disk consumed by to | |||
| 101 | 101 | | Local placement delete (`vfs_nodes` row) | `vfs_nodes` DELETE | That one placement removed locally | | |
| 102 | 102 | | User-initiated sample delete | `samples` UPDATE (deleted_at set) | Sample tombstoned locally, placements preserved, notification | | |
| 103 | 103 | | Local "Cleanup orphans" | nothing (sync triggers suppressed) | unchanged | | |
| 104 | - | | Sweep after 30d | `samples` DELETE | Same — CASCADE removes their placements too | | |
| 104 | + | | Sweep after 30d | `samples` DELETE | Same: CASCADE removes their placements too | | |
| 105 | 105 | | Undelete on either device | `samples` UPDATE (deleted_at cleared) | Sample restored everywhere | | |
| 106 | 106 | ||
| 107 | - | The 30-day window means a sweep on Device A propagates as a hard delete to Device B 30 days after A's tombstone — at which point B has also had 30 days to undelete if they cared. Both sides converge. | |
| 107 | + | The 30-day window means a sweep on Device A propagates as a hard delete to Device B 30 days after A's tombstone, at which point B has also had 30 days to undelete if they cared. Both sides converge. | |
| 108 | 108 | ||
| 109 | 109 | ### Conflict resolution | |
| 110 | 110 | ||
| @@ -153,15 +153,15 @@ The trigger bodies already serialize `NEW.*` columns by name, so the new `delete | |||
| 153 | 153 | ||
| 154 | 154 | ## Rollout phasing | |
| 155 | 155 | ||
| 156 | - | **Phase 1 — M019 + read-path filter** (DONE). Schema landed, `WHERE deleted_at IS NULL` filter across all read sites. Tombstones don't surface in UI yet; behavior change is invisible to users (everything is still NULL). | |
| 156 | + | **Phase 1: M019 + read-path filter** (DONE). Schema landed, `WHERE deleted_at IS NULL` filter across all read sites. Tombstones don't surface in UI yet; behavior change is invisible to users (everything is still NULL). | |
| 157 | 157 | ||
| 158 | - | **Phase 2 — delete & undelete operations** (DONE, 2026-06-19). `tombstone_sample`, `undelete_sample`, and the unfiltered-extension fix to `SampleStore::remove` (so hard-delete/purge works on tombstoned rows) all live in `core::store`. Note: the cleanup-orphans menu still hard-deletes immediately (orphan = no recovery value), as designed — it does not route through the tombstone path. | |
| 158 | + | **Phase 2: delete & undelete operations** (DONE, 2026-06-19). `tombstone_sample`, `undelete_sample`, and the unfiltered-extension fix to `SampleStore::remove` (so hard-delete/purge works on tombstoned rows) all live in `core::store`. Note: the cleanup-orphans menu still hard-deletes immediately (orphan = no recovery value), as designed, it does not route through the tombstone path. | |
| 159 | 159 | ||
| 160 | - | **Phase 3 — Trash UI** (DONE, 2026-06-19). Trash section in Settings (`ui/settings_panel.rs::draw_trash_section`) lists tombstoned samples with Restore + Delete-permanently (behind a per-row confirm) affordances. Backed by `Backend::{list_tombstoned, undelete_sample, purge_sample}`. | |
| 160 | + | **Phase 3: Trash UI** (DONE, 2026-06-19). Trash section in Settings (`ui/settings_panel.rs::draw_trash_section`) lists tombstoned samples with Restore + Delete-permanently (behind a per-row confirm) affordances. Backed by `Backend::{list_tombstoned, undelete_sample, purge_sample}`. | |
| 161 | 161 | ||
| 162 | - | **Phase 4 — sweep** (DONE, 2026-06-19). `SampleStore::sweep_expired_tombstones` runs once in `DirectBackend::new` at startup, hard-deleting past-retention tombstones. The `samples` DELETE is not sync-suppressed, so it propagates; receiving devices CASCADE their own data (which, by symmetry, has also been tombstoned for >= retention). | |
| 162 | + | **Phase 4: sweep** (DONE, 2026-06-19). `SampleStore::sweep_expired_tombstones` runs once in `DirectBackend::new` at startup, hard-deleting past-retention tombstones. The `samples` DELETE is not sync-suppressed, so it propagates; receiving devices CASCADE their own data (which, by symmetry, has also been tombstoned for >= retention). | |
| 163 | 163 | ||
| 164 | - | **Phase 5 — sync notification** (TODO). On pull, count incoming tombstones and surface a one-shot status: "X samples deleted on another device — see Trash to recover." | |
| 164 | + | **Phase 5: sync notification** (TODO). On pull, count incoming tombstones and surface a one-shot status: "X samples deleted on another device, see Trash to recover." | |
| 165 | 165 | ||
| 166 | 166 | Phases 1-2 are required for correctness. Phases 3-5 are UX polish that can land in any order after Phase 2. | |
| 167 | 167 | ||
| @@ -169,17 +169,17 @@ Phases 1-2 are required for correctness. Phases 3-5 are UX polish that can land | |||
| 169 | 169 | ||
| 170 | 170 | ## Open questions | |
| 171 | 171 | ||
| 172 | - | - **Should tags also be tombstoned?** A tag rename today goes through DELETE+INSERT (composite-PK constraint). If we tombstone samples but not tags, a sample's tag entries vanish at sweep time even if the sample is recovered before sweep. Likely acceptable — tags are derived metadata; the sample's `original_name`, analysis, and placements are the load-bearing recovery. | |
| 173 | - | - **Window default — 30 days, or shorter?** 30 matches OS Trash conventions. Shorter (7) saves disk; longer (60+) maximizes recovery. Probably a `user_config` knob with default 30. | |
| 172 | + | - **Should tags also be tombstoned?** A tag rename today goes through DELETE+INSERT (composite-PK constraint). If we tombstone samples but not tags, a sample's tag entries vanish at sweep time even if the sample is recovered before sweep. Likely acceptable: tags are derived metadata; the sample's `original_name`, analysis, and placements are the load-bearing recovery. | |
| 173 | + | - **Window default: 30 days, or shorter?** 30 matches OS Trash conventions. Shorter (7) saves disk; longer (60+) maximizes recovery. Probably a `user_config` knob with default 30. | |
| 174 | 174 | - **Hard-delete sync semantics post-sweep:** should the sweep push a single "tombstone-expired" event rather than a `samples` DELETE, so receiving devices that haven't swept yet know to skip their own sweep? Or just let each device sweep independently? Independent sweep is simpler and converges correctly. | |
| 175 | 175 | - **What if a device is offline for > retention window?** It receives a hard-delete for samples it still has live placements on. The "Sample deleted on another device" notification probably needs to gain "and the retention window expired, your placements are also being removed" semantics. Surface count + offer Trash recovery before applying. Open detail for Phase 5. | |
| 176 | - | - **Server-side tombstone awareness:** the server doesn't decrypt `data`, so it can't see `deleted_at`. Tombstones are application-level; server stores opaque encrypted blobs. The sync push of a tombstone is just another encrypted update; the server has no special handling needed. Good — keeps the privacy model intact. | |
| 176 | + | - **Server-side tombstone awareness:** the server doesn't decrypt `data`, so it can't see `deleted_at`. Tombstones are application-level; server stores opaque encrypted blobs. The sync push of a tombstone is just another encrypted update; the server has no special handling needed. Good, keeps the privacy model intact. | |
| 177 | 177 | ||
| 178 | 178 | --- | |
| 179 | 179 | ||
| 180 | 180 | ## Why not E (per-device orphan management) | |
| 181 | 181 | ||
| 182 | - | The "each device manages its own purges; samples DELETE never propagates as global destroy" model is lighter (no schema change), but creates an awkward asymmetry: "delete on Device A doesn't propagate to Device B." This breaks the stated product model ("my library, accessible from any of my devices") — users expect "I deleted this" to mean it's gone from their library, not just from this device. E only works for a federation model (collaborator A and B each curate their own subset of a shared blob pool) which isn't what audiofiles is. | |
| 182 | + | The "each device manages its own purges; samples DELETE never propagates as global destroy" model is lighter (no schema change), but creates an awkward asymmetry: "delete on Device A doesn't propagate to Device B." This breaks the stated product model ("my library, accessible from any of my devices"): users expect "I deleted this" to mean it's gone from their library, not just from this device. E only works for a federation model (collaborator A and B each curate their own subset of a shared blob pool) which isn't what audiofiles is. | |
| 183 | 183 | ||
| 184 | 184 | ## Why not G (confirmation-only with current CASCADE) | |
| 185 | 185 |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | **Status:** Charter (phase-0 deliverable, 2026-05-19). Defines the canonical primitive set for the egui UI. Implementations follow during consolidation against the internal UX audit's divergence map. | |
| 4 | 4 | ||
| 5 | - | **Scope:** `crates/audiofiles-browser/src/ui/`. Native egui dispatch only — this app has no web layer. | |
| 5 | + | **Scope:** `crates/audiofiles-browser/src/ui/`. Native egui dispatch only. This app has no web layer. | |
| 6 | 6 | ||
| 7 | 7 | **Authority:** When this document and an inline implementation disagree, this document wins. New UI code must use a primitive from this charter; if no primitive fits, add one here first and then implement it. | |
| 8 | 8 | ||
| @@ -14,15 +14,15 @@ | |||
| 14 | 14 | |------------------|---------------------------------------|----------------------------------------------------------------------| | |
| 15 | 15 | | Palette + visuals | `ui/theme.rs` | `ThemeColors`, `apply_theme`, all `Color32` and spacing tokens, theme TOML loading. **No widgets here.** | | |
| 16 | 16 | | Widgets | `ui/widgets.rs` | Every shared visual recipe (rows, headers, buttons, modals, banners, pills, empty states). | | |
| 17 | - | | Panels / screens | `ui/sidebar.rs`, `toolbar.rs`, etc. | Composition only — call `widgets::*` and `theme::*`; never reinvent. | | |
| 17 | + | | Panels / screens | `ui/sidebar.rs`, `toolbar.rs`, etc. | Composition only: call `widgets::*` and `theme::*`; never reinvent. | | |
| 18 | 18 | ||
| 19 | 19 | **Hard rules:** | |
| 20 | 20 | ||
| 21 | - | 1. **No `Color32::from_rgb(...)` outside `theme.rs`.** Test by `grep -rE 'Color32::(from_rgb|WHITE|BLACK|GRAY|RED|GREEN|BLUE|YELLOW|DARK_GRAY)' src/ui/ | grep -v theme.rs` — must be empty. | |
| 22 | - | 2. **No raw `add_space(N.0)` outside `widgets.rs` and `theme.rs`** — use named spacing tokens (§Spacing). | |
| 23 | - | 3. **No inline `selectable_label(active, RichText::new(label).strong().color(accent_blue()))`** — use `widgets::selectable_row` or one of its specializations. | |
| 24 | - | 4. **No inline `Window::new(...).collapsible(false).resizable(false).anchor(CENTER_CENTER, [0,0])`** — use `widgets::modal_window` or `widgets::confirm_modal`. | |
| 25 | - | 5. **No emoji or checkmark glyphs in user-facing strings** (CLAUDE.md brand rule). Use words. Test by `grep -rE '\\u\{1F[0-9A-Fa-f]{3}\}|\\u\{2[0-9A-Fa-f]{3}\}'` against UI files — flagged glyphs require a documented exception comment. | |
| 21 | + | 1. **No `Color32::from_rgb(...)` outside `theme.rs`.** Test by `grep -rE 'Color32::(from_rgb|WHITE|BLACK|GRAY|RED|GREEN|BLUE|YELLOW|DARK_GRAY)' src/ui/ | grep -v theme.rs`, must be empty. | |
| 22 | + | 2. **No raw `add_space(N.0)` outside `widgets.rs` and `theme.rs`**: use named spacing tokens (§Spacing). | |
| 23 | + | 3. **No inline `selectable_label(active, RichText::new(label).strong().color(accent_blue()))`**: use `widgets::selectable_row` or one of its specializations. | |
| 24 | + | 4. **No inline `Window::new(...).collapsible(false).resizable(false).anchor(CENTER_CENTER, [0,0])`**: use `widgets::modal_window` or `widgets::confirm_modal`. | |
| 25 | + | 5. **No emoji or checkmark glyphs in user-facing strings** (CLAUDE.md brand rule). Use words. Test by `grep -rE '\\u\{1F[0-9A-Fa-f]{3}\}|\\u\{2[0-9A-Fa-f]{3}\}'` against UI files: flagged glyphs require a documented exception comment. | |
| 26 | 26 | ||
| 27 | 27 | --- | |
| 28 | 28 | ||
| @@ -44,7 +44,7 @@ All UI colour reads must go through these accessors. The 15 base slots come from | |||
| 44 | 44 | | `accent_blue` | base | Selection/active state. *The* affordance colour. | | |
| 45 | 45 | | `accent_red` | base | Errors, danger buttons, destructive confirms. | | |
| 46 | 46 | | `accent_green` | base | Success, ready state. | | |
| 47 | - | | `accent_yellow` | base | Warnings (not yet used as such — reserved). | | |
| 47 | + | | `accent_yellow` | base | Warnings (not yet used as such, reserved). | | |
| 48 | 48 | | `accent_purple` | base | Reserved (classification palette). | | |
| 49 | 49 | | `accent_cyan` | base | Reserved (classification palette). | | |
| 50 | 50 | | `border_default` | base | Separator, panel boundary. | | |
| @@ -77,65 +77,65 @@ The existing TOML keys `section_spacing`, `grid_row_spacing`, `item_spacing_x/y` | |||
| 77 | 77 | | `rounding` | 4.0 (TOML) | All widget corner radii. | | |
| 78 | 78 | | `border_thin` | 0.5 px | Separators, inactive widget border. | | |
| 79 | 79 | | `border_default` | 1.0 px | Hovered/active widget border, window stroke. | | |
| 80 | - | | `focus_ring` | 1.5 px `accent_blue` | New token — focus outline on text fields. | | |
| 80 | + | | `focus_ring` | 1.5 px `accent_blue` | New token: focus outline on text fields. | | |
| 81 | 81 | ||
| 82 | 82 | --- | |
| 83 | 83 | ||
| 84 | 84 | ## Widgets (`widgets.rs`) | |
| 85 | 85 | ||
| 86 | - | The canonical helper for every recurring UI pattern in the codebase. Names are normative — implementations rename existing inline copies to match. | |
| 86 | + | The canonical helper for every recurring UI pattern in the codebase. Names are normative: implementations rename existing inline copies to match. | |
| 87 | 87 | ||
| 88 | 88 | ### Rows and selection | |
| 89 | 89 | ||
| 90 | - | - **`selectable_row(ui, active, label) -> Response`** — single source of truth for every "list item that highlights when active in `accent_blue`." Replaces sidebar VFS rows, collection rows, breadcrumb segments, tag tree leaves, sort headers. Internally: builds RichText with `.strong()` + `accent_blue()` when active; `text_secondary()` otherwise; delegates to `ui.selectable_label(active, …)`. | |
| 91 | - | - **`tree_node(ui, label, active, has_active_descendant, children)`** — for the sidebar tag tree; collapsing header variant of `selectable_row`. | |
| 90 | + | - **`selectable_row(ui, active, label) -> Response`**: single source of truth for every "list item that highlights when active in `accent_blue`." Replaces sidebar VFS rows, collection rows, breadcrumb segments, tag tree leaves, sort headers. Internally: builds RichText with `.strong()` + `accent_blue()` when active; `text_secondary()` otherwise; delegates to `ui.selectable_label(active, …)`. | |
| 91 | + | - **`tree_node(ui, label, active, has_active_descendant, children)`**: for the sidebar tag tree; collapsing header variant of `selectable_row`. | |
| 92 | 92 | ||
| 93 | 93 | ### Headers and section structure | |
| 94 | 94 | ||
| 95 | - | - **`section_header(ui, "Vaults")`** — `.strong()`, `text_secondary()`, followed by `ui.separator()` and `space::sm`. | |
| 96 | - | - **`subsection_label(ui, "Save as Collection")`** — same colour, no separator. For sub-blocks inside an already-headed section. | |
| 97 | - | - **`filter_section(ui, label, active, |ui| { … })`** — `CollapsingHeader` with `"* "` marker when active and `default_open(active)`. Replaces the 5-way duplication in `filter_panel.rs`. | |
| 95 | + | - **`section_header(ui, "Vaults")`**: `.strong()`, `text_secondary()`, followed by `ui.separator()` and `space::sm`. | |
| 96 | + | - **`subsection_label(ui, "Save as Collection")`**: same colour, no separator. For sub-blocks inside an already-headed section. | |
| 97 | + | - **`filter_section(ui, label, active, |ui| { … })`**: `CollapsingHeader` with `"* "` marker when active and `default_open(active)`. Replaces the 5-way duplication in `filter_panel.rs`. | |
| 98 | 98 | ||
| 99 | 99 | ### Buttons | |
| 100 | 100 | ||
| 101 | - | - **`primary_button(ui, label) -> Response`** — bold weight, default fill. Singular per modal/dialog. | |
| 102 | - | - **`secondary_button(ui, label) -> Response`** — current default button. Cancel and peer actions use this. | |
| 103 | - | - **`danger_button(ui, label) -> Response`** — `accent_red` text or fill (TBD during impl). For Delete / Purge / Discard. | |
| 104 | - | - **`toolbar_toggle(ui, glyph_or_word, active, tooltip) -> bool`** — used six times in `toolbar.rs:138–204`. Active state colours via `accent_blue` / `text_muted`. Replace the emoji glyphs with words during the rollout (see brand rule). | |
| 105 | - | - **`icon_button(ui, glyph_or_word, tooltip) -> Response`** — small, non-toggle. (`x`, `+`, settings gear once renamed.) | |
| 101 | + | - **`primary_button(ui, label) -> Response`**: bold weight, default fill. Singular per modal/dialog. | |
| 102 | + | - **`secondary_button(ui, label) -> Response`**: current default button. Cancel and peer actions use this. | |
| 103 | + | - **`danger_button(ui, label) -> Response`**: `accent_red` text or fill (TBD during impl). For Delete / Purge / Discard. | |
| 104 | + | - **`toolbar_toggle(ui, glyph_or_word, active, tooltip) -> bool`**: used six times in `toolbar.rs:138–204`. Active state colours via `accent_blue` / `text_muted`. Replace the emoji glyphs with words during the rollout (see brand rule). | |
| 105 | + | - **`icon_button(ui, glyph_or_word, tooltip) -> Response`**: small, non-toggle. (`x`, `+`, settings gear once renamed.) | |
| 106 | 106 | ||
| 107 | 107 | ### Pills, chips, badges | |
| 108 | 108 | ||
| 109 | - | - **`tag_chip(ui, tag) -> Response`** — existing. | |
| 110 | - | - **`tag_chip_removable(ui, tag) -> bool`** — existing. | |
| 111 | - | - **`classification_badge(ui, class)`** — existing. | |
| 112 | - | - **`toggle_pills(ui, current, options: &[(value, label, tooltip)]) -> Option<value>`** — segmented control for mutually-exclusive choices: Folder/All, Exact/Compatible, Add/Remove tag, help tabs. | |
| 109 | + | - **`tag_chip(ui, tag) -> Response`**: existing. | |
| 110 | + | - **`tag_chip_removable(ui, tag) -> bool`**: existing. | |
| 111 | + | - **`classification_badge(ui, class)`**: existing. | |
| 112 | + | - **`toggle_pills(ui, current, options: &[(value, label, tooltip)]) -> Option<value>`**: segmented control for mutually-exclusive choices: Folder/All, Exact/Compatible, Add/Remove tag, help tabs. | |
| 113 | 113 | ||
| 114 | 114 | ### Inputs | |
| 115 | 115 | ||
| 116 | - | - **`inline_text_submit(ui, buf, opts) -> SubmitOutcome`** — text field that commits on Enter, cancels on button/Escape. `SubmitOutcome::{None, Submitted(String), Cancelled}`. Used by sidebar rename, sidebar create, detail tag add, toolbar save-collection. | |
| 117 | - | - **`search_field(ui, buf, hint, on_change)`** — search-box recipe used by the toolbar and the sidebar tag filter. Hint text, optional leading glyph (subject to brand rule). | |
| 116 | + | - **`inline_text_submit(ui, buf, opts) -> SubmitOutcome`**: text field that commits on Enter, cancels on button/Escape. `SubmitOutcome::{None, Submitted(String), Cancelled}`. Used by sidebar rename, sidebar create, detail tag add, toolbar save-collection. | |
| 117 | + | - **`search_field(ui, buf, hint, on_change)`**: search-box recipe used by the toolbar and the sidebar tag filter. Hint text, optional leading glyph (subject to brand rule). | |
| 118 | 118 | ||
| 119 | 119 | ### Empty states | |
| 120 | 120 | ||
| 121 | - | - **`empty_state(ui, EmptyState { icon: Option<&str>, heading: &str, body: Option<&str>, cta: Option<(&str, Response sink)> })`** — centered, vertical, consistent spacing. Replaces the six divergent empty states in `file_list.rs`, `detail.rs`, and `sidebar.rs`. (Per brand rule, `icon` defaults to a word like "Empty" rather than an emoji.) | |
| 121 | + | - **`empty_state(ui, EmptyState { icon: Option<&str>, heading: &str, body: Option<&str>, cta: Option<(&str, Response sink)> })`**: centered, vertical, consistent spacing. Replaces the six divergent empty states in `file_list.rs`, `detail.rs`, and `sidebar.rs`. (Per brand rule, `icon` defaults to a word like "Empty" rather than an emoji.) | |
| 122 | 122 | ||
| 123 | 123 | ### Modals and overlays | |
| 124 | 124 | ||
| 125 | - | - **`modal_window(ctx, title, |ui| { … })`** — pre-configured `Window::new(title).collapsible(false).resizable(false).anchor(CENTER_CENTER, [0,0])` with consistent inner padding. All bulk modals, help overlay, and warning overlays compose this. | |
| 126 | - | - **`confirm_modal(ctx, prompt, danger: bool, on_confirm, on_cancel)`** — unified destructive-confirm scaffold. Confirm label is `danger_button` when `danger=true`. Replaces both `draw_confirm_dialog` and `draw_unsafe_warning`'s ad-hoc Window. | |
| 127 | - | - **`name_modal(...)`** — existing; keep, lift to `widgets.rs` so callers outside `overlays.rs` can use it. | |
| 125 | + | - **`modal_window(ctx, title, |ui| { … })`**: pre-configured `Window::new(title).collapsible(false).resizable(false).anchor(CENTER_CENTER, [0,0])` with consistent inner padding. All bulk modals, help overlay, and warning overlays compose this. | |
| 126 | + | - **`confirm_modal(ctx, prompt, danger: bool, on_confirm, on_cancel)`**: unified destructive-confirm scaffold. Confirm label is `danger_button` when `danger=true`. Replaces both `draw_confirm_dialog` and `draw_unsafe_warning`'s ad-hoc Window. | |
| 127 | + | - **`name_modal(...)`**: existing; keep, lift to `widgets.rs` so callers outside `overlays.rs` can use it. | |
| 128 | 128 | ||
| 129 | 129 | ### Banners and notifications | |
| 130 | 130 | ||
| 131 | - | - **`info_banner(ui, body)`** — frame with `bg_tertiary()`, `corner_radius(4)`, `space::md` inset; used by the existing VFS first-run banner and any future inline tips. | |
| 132 | - | - **`toast(ctx, severity, body)`** — *new* primitive. Timed transient notification surfaced from a state-owned queue. Replaces the current "set `state.status = '...'`" pattern for errors and ephemeral confirmations (rename success, copy-to-clipboard). The existing footer status label is retained for *persistent* state ("Sync: 3 pending") but should no longer be the channel for transient error feedback. | |
| 133 | - | - **`loading_spinner(ui)` / `busy_indicator(ui, label)`** — *new* primitive for in-flight operations that aren't full-screen (sidebar refresh, sync running). Import has its own progress screen and is out of scope here. | |
| 131 | + | - **`info_banner(ui, body)`**: frame with `bg_tertiary()`, `corner_radius(4)`, `space::md` inset; used by the existing VFS first-run banner and any future inline tips. | |
| 132 | + | - **`toast(ctx, severity, body)`**: *new* primitive. Timed transient notification surfaced from a state-owned queue. Replaces the current "set `state.status = '...'`" pattern for errors and ephemeral confirmations (rename success, copy-to-clipboard). The existing footer status label is retained for *persistent* state ("Sync: 3 pending") but should no longer be the channel for transient error feedback. | |
| 133 | + | - **`loading_spinner(ui)` / `busy_indicator(ui, label)`**: *new* primitive for in-flight operations that aren't full-screen (sidebar refresh, sync running). Import has its own progress screen and is out of scope here. | |
| 134 | 134 | ||
| 135 | 135 | ### Forms and grids | |
| 136 | 136 | ||
| 137 | - | - **`metadata_grid(ui, id, rows: impl Iterator<Item=(label, value)>)`** — the detail-panel `Grid` recipe; reusable for any read-only "key / value" block. `grid_row_spacing` token applies. | |
| 138 | - | - **`preview_grid(ui, id, headers, rows)`** — striped two-column grid used by bulk-rename preview; could be reused by export dry-run. | |
| 137 | + | - **`metadata_grid(ui, id, rows: impl Iterator<Item=(label, value)>)`**: the detail-panel `Grid` recipe; reusable for any read-only "key / value" block. `grid_row_spacing` token applies. | |
| 138 | + | - **`preview_grid(ui, id, headers, rows)`**: striped two-column grid used by bulk-rename preview; could be reused by export dry-run. | |
| 139 | 139 | ||
| 140 | 140 | --- | |
| 141 | 141 |
| @@ -8,7 +8,7 @@ In normal mode, every import copies the file into `vault/samples/<hash>.<ext>`. | |||
| 8 | 8 | ||
| 9 | 9 | ## Enabling | |
| 10 | 10 | ||
| 11 | - | Loose-files mode is a vault-level setting, toggled in vault settings. Changing it only affects future imports — samples already in the vault are not moved or copied retroactively. | |
| 11 | + | Loose-files mode is a vault-level setting, toggled in vault settings. Changing it only affects future imports. Samples already in the vault are not moved or copied retroactively. | |
| 12 | 12 | ||
| 13 | 13 | A vault stores its mode as a preference row: | |
| 14 | 14 | ||
| @@ -62,9 +62,9 @@ Loose-files mode makes a best-effort attempt to handle files that have gone miss | |||
| 62 | 62 | ||
| 63 | 63 | If `source_path` points to a file that no longer exists: | |
| 64 | 64 | ||
| 65 | - | 1. Check `vault/samples/<hash>.<ext>` as a fallback — the user may have re-imported in normal mode or manually placed the file there | |
| 65 | + | 1. Check `vault/samples/<hash>.<ext>` as a fallback: the user may have re-imported in normal mode or manually placed the file there | |
| 66 | 66 | 2. If the fallback also misses, mark the sample as **unavailable** in the UI (grayed out, struck-through name, tooltip: "Original file not found at `<path>`") | |
| 67 | - | 3. Do not delete the metadata row — the sample keeps its tags, VFS position, and analysis data | |
| 67 | + | 3. Do not delete the metadata row: the sample keeps its tags, VFS position, and analysis data | |
| 68 | 68 | ||
| 69 | 69 | ### Relocate | |
| 70 | 70 | ||
| @@ -73,19 +73,19 @@ Provide a **Relocate** action on unavailable samples: | |||
| 73 | 73 | - User picks a new file | |
| 74 | 74 | - App verifies the SHA-256 hash matches the sample's `hash` | |
| 75 | 75 | - If it matches, update `source_path` to the new location | |
| 76 | - | - If it doesn't match, reject with: "Hash mismatch — this is a different file" | |
| 76 | + | - If it doesn't match, reject with: "Hash mismatch: this is a different file" | |
| 77 | 77 | ||
| 78 | 78 | No batch relocate, no folder scanning, no automatic search. Keep it manual and simple. | |
| 79 | 79 | ||
| 80 | 80 | ### Vault Integrity Check | |
| 81 | 81 | ||
| 82 | - | Add a "Check vault" action (in vault settings, next to the loose-files mode toggle) that scans all `source_path` entries and reports how many are valid vs. missing. Informational only — it does not fix anything, just gives the user a count. | |
| 82 | + | Add a "Check vault" action (in vault settings, next to the loose-files mode toggle) that scans all `source_path` entries and reports how many are valid vs. missing. Informational only: it does not fix anything, just gives the user a count. | |
| 83 | 83 | ||
| 84 | 84 | ## What This Does NOT Do | |
| 85 | 85 | ||
| 86 | 86 | - Does not watch the filesystem for changes | |
| 87 | 87 | - Does not auto-relocate or search for moved files | |
| 88 | 88 | - Does not create symlinks or hardlinks | |
| 89 | - | - Does not support relative paths — `source_path` is always absolute | |
| 89 | + | - Does not support relative paths: `source_path` is always absolute | |
| 90 | 90 | - Does not convert existing samples between modes (no retroactive copy-in or copy-out) | |
| 91 | - | - Does not sync `source_path` values via SyncKit — paths are device-local and meaningless on other machines | |
| 91 | + | - Does not sync `source_path` values via SyncKit: paths are device-local and meaningless on other machines |
| @@ -5,7 +5,7 @@ ships in the binary: the feature extraction is plain signal measurement, and tag | |||
| 5 | 5 | driven by the user's own library. This keeps the classifier free of any training-data | |
| 6 | 6 | copyright surface. | |
| 7 | 7 | ||
| 8 | - | The system is being built out in phases toward a hybrid tag pipeline — a deterministic | |
| 8 | + | The system is being built out in phases toward a hybrid tag pipeline: a deterministic | |
| 9 | 9 | rules layer plus an exemplar k-NN layer learned from the user's library, feeding one | |
| 10 | 10 | provenance-tracked tag resolution step. The sections below describe what exists today. | |
| 11 | 11 |
| @@ -61,23 +61,23 @@ post_export = "hooks/post.rhai" | |||
| 61 | 61 | ||
| 62 | 62 | ### Required Sections | |
| 63 | 63 | ||
| 64 | - | **`[device]`** — Name, manufacturer, and version string. The name is what appears in the export UI and is used for lookup (case-insensitive). | |
| 64 | + | **`[device]`**: Name, manufacturer, and version string. The name is what appears in the export UI and is used for lookup (case-insensitive). | |
| 65 | 65 | ||
| 66 | - | **`[audio]`** — Export constraints. Formats: `wav`, `aiff`. Channels: `mono`, `stereo`, or `both`. Sample rates and bit depths are integer arrays. | |
| 66 | + | **`[audio]`**: Export constraints. Formats: `wav`, `aiff`. Channels: `mono`, `stereo`, or `both`. Sample rates and bit depths are integer arrays. | |
| 67 | 67 | ||
| 68 | 68 | ### Optional Sections | |
| 69 | 69 | ||
| 70 | - | **`[naming]`** — Filename rules applied during export. If omitted, filenames pass through unchanged. | |
| 70 | + | **`[naming]`**: Filename rules applied during export. If omitted, filenames pass through unchanged. | |
| 71 | 71 | ||
| 72 | - | **`[limits]`** — Hardware capacity constraints. Used to warn or prevent over-exporting. | |
| 72 | + | **`[limits]`**: Hardware capacity constraints. Used to warn or prevent over-exporting. | |
| 73 | 73 | ||
| 74 | - | **`[hooks]`** — Paths to Rhai scripts, relative to the plugin directory. Path traversal outside the plugin directory is blocked. | |
| 74 | + | **`[hooks]`**: Paths to Rhai scripts, relative to the plugin directory. Path traversal outside the plugin directory is blocked. | |
| 75 | 75 | ||
| 76 | 76 | ## Rhai Hooks | |
| 77 | 77 | ||
| 78 | 78 | Four hook points, all optional. Hooks are compiled once when the plugin loads and executed during export. | |
| 79 | 79 | ||
| 80 | - | ### `validate_sample` — Filter samples | |
| 80 | + | ### `validate_sample`: Filter samples | |
| 81 | 81 | ||
| 82 | 82 | Called once per sample before export. Return `true` to include, `false` to skip. | |
| 83 | 83 | ||
| @@ -93,7 +93,7 @@ info.sample_rate == 44100 | |||
| 93 | 93 | info.duration <= 30.0 | |
| 94 | 94 | ``` | |
| 95 | 95 | ||
| 96 | - | ### `transform_filename` — Rename files | |
| 96 | + | ### `transform_filename`: Rename files | |
| 97 | 97 | ||
| 98 | 98 | Called after the initial filename is generated. Return the new filename (stem only, no extension). | |
| 99 | 99 | ||
| @@ -111,13 +111,13 @@ let short = truncate(name, 8); | |||
| 111 | 111 | "SP_" + to_upper(short) | |
| 112 | 112 | ``` | |
| 113 | 113 | ||
| 114 | - | ### `pre_export` — Before batch | |
| 114 | + | ### `pre_export`: Before batch | |
| 115 | 115 | ||
| 116 | 116 | Called once before the export batch starts. No return value. | |
| 117 | 117 | ||
| 118 | 118 | **Input:** `ctx` (export context) | |
| 119 | 119 | ||
| 120 | - | ### `post_export` — After batch | |
| 120 | + | ### `post_export`: After batch | |
| 121 | 121 | ||
| 122 | 122 | Called once after all exports complete. No return value. | |
| 123 | 123 | ||
| @@ -209,7 +209,7 @@ max_length = 8 | |||
| 209 | 209 | strip_special = true | |
| 210 | 210 | ``` | |
| 211 | 211 | ||
| 212 | - | No hooks needed — the manifest alone constrains the export. After saving this file, restart audiofiles and "My Sampler" appears in the device profile dropdown. | |
| 212 | + | No hooks needed: the manifest alone constrains the export. After saving this file, restart audiofiles and "My Sampler" appears in the device profile dropdown. | |
| 213 | 213 | ||
| 214 | 214 | ## Example: Profile with Hooks | |
| 215 | 215 |
| @@ -15,7 +15,7 @@ Allow users to use Audiofiles without a license key by clicking a "I am still te | |||
| 15 | 15 | ||
| 16 | 16 | ### After Day 30 | |
| 17 | 17 | ||
| 18 | - | - The software continues to work identically — no features are disabled | |
| 18 | + | - The software continues to work identically. No features are disabled | |
| 19 | 19 | - The button text changes to: **"I am still "testing" the software :)"** | |
| 20 | 20 | - The days indicator goes negative: `Trial: -4 days` | |
| 21 | 21 | - No nag screens, no popups, no degraded experience | |
| @@ -38,7 +38,7 @@ Allow users to use Audiofiles without a license key by clicking a "I am still te | |||
| 38 | 38 | ### License Key Integration | |
| 39 | 39 | ||
| 40 | 40 | - If a valid MNW/SyncKit license key is entered, the trial indicator and button disappear entirely | |
| 41 | - | - Trial mode and licensed mode are mutually exclusive display states — the underlying functionality is identical | |
| 41 | + | - Trial mode and licensed mode are mutually exclusive display states: the underlying functionality is identical | |
| 42 | 42 | - This serves as a tech demo for MNW/SyncKit license key validation, not as actual DRM | |
| 43 | 43 | ||
| 44 | 44 | ### What This Does NOT Do | |
| @@ -46,4 +46,4 @@ Allow users to use Audiofiles without a license key by clicking a "I am still te | |||
| 46 | 46 | - Does not disable any features | |
| 47 | 47 | - Does not lock the user out | |
| 48 | 48 | - Does not phone home or enforce anything server-side | |
| 49 | - | - Does not reset or tamper-proof the trial date (if someone deletes the config, they get another 30 days — that's fine) | |
| 49 | + | - Does not reset or tamper-proof the trial date (if someone deletes the config, they get another 30 days, that's fine) |
| @@ -1,4 +1,4 @@ | |||
| 1 | - | # Troubleshooting — audiofiles | |
| 1 | + | # Troubleshooting: audiofiles | |
| 2 | 2 | ||
| 3 | 3 | ## Analysis Pipeline Stalls | |
| 4 | 4 | ||
| @@ -36,7 +36,7 @@ | |||
| 36 | 36 | |---------|-------|-----| | |
| 37 | 37 | | "Sample not found: {hash}" | File missing from disk or DB | Check if file exists: `ls ~/.config/audiofiles/samples/{hash}.*`. If missing, re-import from original source. | | |
| 38 | 38 | | "Invalid hash: expected 64 lowercase hex chars" | DB corruption (hash field modified) | Find bad rows: `SELECT hash FROM samples WHERE LENGTH(hash) != 64`, delete them | | |
| 39 | - | | "Cannot import zero-byte file" | Empty file | Skip — only valid audio files with content can be imported | | |
| 39 | + | | "Cannot import zero-byte file" | Empty file | Skip: only valid audio files with content can be imported | | |
| 40 | 40 | | Hash mismatch (file changed on disk) | Disk corruption or manual file edit | Delete corrupted file, remove DB row, re-import original | | |
| 41 | 41 | ||
| 42 | 42 | **Verify storage integrity:** | |
| @@ -85,7 +85,7 @@ AND hash NOT IN (SELECT DISTINCT sample_hash FROM collection_members WHERE sampl | |||
| 85 | 85 | ### All platforms | |
| 86 | 86 | - Drag creates temp symlinks in `/tmp/audiofiles-drag-{pid}/` | |
| 87 | 87 | - Name collisions auto-resolved with `(1)`, `(2)` suffixes | |
| 88 | - | - Large batch drags (100+ files) may be slow — try fewer files | |
| 88 | + | - Large batch drags (100+ files) may be slow, try fewer files | |
| 89 | 89 | ||
| 90 | 90 | ## Database Issues | |
| 91 | 91 |