Skip to main content

max / audiofiles

exorcise: remaining public docs (em-dashes) Replaced 69 prose em-dashes with straight punctuation across the docs set. Punctuation only; code fences, Rhai/CSS examples, inline code, and en-dash ranges untouched. Quoted UI strings in design docs edited are spec-only (verified absent from src). ASCII '--' dashes left pending a separate style decision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-12 16:28 UTC
Commit: 529b97f9127184b83a8a3626b441e2478953f01b
Parent: 2ae0e68
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