| 1 |
# audiofiles Design System |
| 2 |
|
| 3 |
**Status:** Charter (phase-0 deliverable, 2026-05-19). Defines the canonical primitive set for the egui UI. Implementations follow during consolidation against the internal UX audit's divergence map. |
| 4 |
|
| 5 |
**Scope:** `crates/audiofiles-browser/src/ui/`. Native egui dispatch only — this app has no web layer. |
| 6 |
|
| 7 |
**Authority:** When this document and an inline implementation disagree, this document wins. New UI code must use a primitive from this charter; if no primitive fits, add one here first and then implement it. |
| 8 |
|
| 9 |
--- |
| 10 |
|
| 11 |
## Layering rules |
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
| Palette + visuals | `ui/theme.rs` | `ThemeColors`, `apply_theme`, all `Color32` and spacing tokens, theme TOML loading. **No widgets here.** | |
| 16 |
| Widgets | `ui/widgets.rs` | Every shared visual recipe (rows, headers, buttons, modals, banners, pills, empty states). | |
| 17 |
| Panels / screens | `ui/sidebar.rs`, `toolbar.rs`, etc. | Composition only — call `widgets::*` and `theme::*`; never reinvent. | |
| 18 |
|
| 19 |
**Hard rules:** |
| 20 |
|
| 21 |
1. **No `Color32::from_rgb(...)` outside `theme.rs`.** Test by `grep -rE 'Color32::(from_rgb|WHITE|BLACK|GRAY|RED|GREEN|BLUE|YELLOW|DARK_GRAY)' src/ui/ | grep -v theme.rs` — must be empty. |
| 22 |
2. **No raw `add_space(N.0)` outside `widgets.rs` and `theme.rs`** — use named spacing tokens (§Spacing). |
| 23 |
3. **No inline `selectable_label(active, RichText::new(label).strong().color(accent_blue()))`** — use `widgets::selectable_row` or one of its 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 |
|
| 27 |
--- |
| 28 |
|
| 29 |
## Tokens (`theme.rs`) |
| 30 |
|
| 31 |
### Colour tokens |
| 32 |
|
| 33 |
All UI colour reads must go through these accessors. The 15 base slots come from `ThemeColors`; the derived slots are computed at runtime. |
| 34 |
|
| 35 |
|
| 36 |
|
| 37 |
| `bg_primary` | base | Deepest layer (table body, scroll background). | |
| 38 |
| `bg_secondary` | base | Panels, sidebars, window fill. | |
| 39 |
| `bg_tertiary` | base | Hover background, highlighted regions. | |
| 40 |
| `bg_surface` | base | Cards, popovers, tag chips. | |
| 41 |
| `fg_primary` | base | Primary text. | |
| 42 |
| `fg_secondary` | base | Secondary text, labels. | |
| 43 |
| `fg_muted` | base | Placeholders, disabled items, empty-state hints. | |
| 44 |
| `accent_blue` | base | Selection/active state. *The* affordance colour. | |
| 45 |
| `accent_red` | base | Errors, danger buttons, destructive confirms. | |
| 46 |
| `accent_green` | base | Success, ready state. | |
| 47 |
| `accent_yellow` | base | Warnings (not yet used as such — reserved). | |
| 48 |
| `accent_purple` | base | Reserved (classification palette). | |
| 49 |
| `accent_cyan` | base | Reserved (classification palette). | |
| 50 |
| `border_default` | base | Separator, panel boundary. | |
| 51 |
| `bg_row_even` | derived | Striped row alternation (even). | |
| 52 |
| `bg_row_odd` | derived | Striped row alternation (odd) = `bg_primary`. | |
| 53 |
| `bg_hover` | derived | = `bg_tertiary`. Shared row/button hover. | |
| 54 |
| `bg_selected` | derived | Selection fill (= `lerp(bg_primary, accent_blue, 0.3)`). | |
| 55 |
| `classification_color(c)` | domain | Sample-class palette. Stable across themes for muscle memory. | |
| 56 |
| `piano_white_key()`, `piano_black_key()` | domain | Instrument panel only. | |
| 57 |
|
| 58 |
### Spacing tokens |
| 59 |
|
| 60 |
Replace every literal `add_space(N.0)` with one of these. New code may not introduce new spacing magnitudes without adding a token here. |
| 61 |
|
| 62 |
|
| 63 |
|
| 64 |
| `space::xs` | 2.0 | Tight inline (button-icon gap, tag chip internal). | |
| 65 |
| `space::sm` | 4.0 | After a label, before its control (52 occurrences today). | |
| 66 |
| `space::md` | 8.0 | Default between unrelated controls in a row (58 today). | |
| 67 |
| `space::lg` | 12.0 | Between minor sections within a panel (19 today). | |
| 68 |
| `space::section` | 16.0 | Between major sections (= existing `section_spacing` token). | |
| 69 |
| `space::xl` | 20.0 | Reserved for headline padding in empty states. | |
| 70 |
|
| 71 |
The existing TOML keys `section_spacing`, `grid_row_spacing`, `item_spacing_x/y`, `button_padding_x/y`, `rounding` remain; the new `space::*` constants live in `theme.rs` as `pub const` and are theme-independent. |
| 72 |
|
| 73 |
### Stroke / radius |
| 74 |
|
| 75 |
|
| 76 |
|
| 77 |
| `rounding` | 4.0 (TOML) | All widget corner radii. | |
| 78 |
| `border_thin` | 0.5 px | Separators, inactive widget border. | |
| 79 |
| `border_default` | 1.0 px | Hovered/active widget border, window stroke. | |
| 80 |
| `focus_ring` | 1.5 px `accent_blue` | New token — focus outline on text fields. | |
| 81 |
|
| 82 |
--- |
| 83 |
|
| 84 |
## Widgets (`widgets.rs`) |
| 85 |
|
| 86 |
The canonical helper for every recurring UI pattern in the codebase. Names are normative — implementations rename existing inline copies to match. |
| 87 |
|
| 88 |
### Rows and selection |
| 89 |
|
| 90 |
- **`selectable_row(ui, active, label) -> Response`** — single source of truth for every "list item that highlights when active in `accent_blue`." Replaces sidebar VFS rows, collection rows, breadcrumb segments, tag tree leaves, sort headers. Internally: builds RichText with `.strong()` + `accent_blue()` when active; `text_secondary()` otherwise; delegates to `ui.selectable_label(active, …)`. |
| 91 |
- **`tree_node(ui, label, active, has_active_descendant, children)`** — for the sidebar tag tree; collapsing header variant of `selectable_row`. |
| 92 |
|
| 93 |
### Headers and section structure |
| 94 |
|
| 95 |
- **`section_header(ui, "Vaults")`** — `.strong()`, `text_secondary()`, followed by `ui.separator()` and `space::sm`. |
| 96 |
- **`subsection_label(ui, "Save as Collection")`** — same colour, no separator. For sub-blocks inside an already-headed section. |
| 97 |
- **`filter_section(ui, label, active, |ui| { … })`** — `CollapsingHeader` with `"* "` marker when active and `default_open(active)`. Replaces the 5-way duplication in `filter_panel.rs`. |
| 98 |
|
| 99 |
### Buttons |
| 100 |
|
| 101 |
- **`primary_button(ui, label) -> Response`** — bold weight, default fill. Singular per modal/dialog. |
| 102 |
- **`secondary_button(ui, label) -> Response`** — current default button. Cancel and peer actions use this. |
| 103 |
- **`danger_button(ui, label) -> Response`** — `accent_red` text or fill (TBD during impl). For Delete / Purge / Discard. |
| 104 |
- **`toolbar_toggle(ui, glyph_or_word, active, tooltip) -> bool`** — used six times in `toolbar.rs:138–204`. Active state colours via `accent_blue` / `text_muted`. Replace the emoji glyphs with words during the rollout (see brand rule). |
| 105 |
- **`icon_button(ui, glyph_or_word, tooltip) -> Response`** — small, non-toggle. (`x`, `+`, settings gear once renamed.) |
| 106 |
|
| 107 |
### Pills, chips, badges |
| 108 |
|
| 109 |
- **`tag_chip(ui, tag) -> Response`** — existing. |
| 110 |
- **`tag_chip_removable(ui, tag) -> bool`** — existing. |
| 111 |
- **`classification_badge(ui, class)`** — existing. |
| 112 |
- **`toggle_pills(ui, current, options: &[(value, label, tooltip)]) -> Option<value>`** — segmented control for mutually-exclusive choices: Folder/All, Exact/Compatible, Add/Remove tag, help tabs. |
| 113 |
|
| 114 |
### Inputs |
| 115 |
|
| 116 |
- **`inline_text_submit(ui, buf, opts) -> SubmitOutcome`** — text field that commits on Enter, cancels on button/Escape. `SubmitOutcome::{None, Submitted(String), Cancelled}`. Used by sidebar rename, sidebar create, detail tag add, toolbar save-collection. |
| 117 |
- **`search_field(ui, buf, hint, on_change)`** — search-box recipe used by the toolbar and the sidebar tag filter. Hint text, optional leading glyph (subject to brand rule). |
| 118 |
|
| 119 |
### Empty states |
| 120 |
|
| 121 |
- **`empty_state(ui, EmptyState { icon: Option<&str>, heading: &str, body: Option<&str>, cta: Option<(&str, Response sink)> })`** — centered, vertical, consistent spacing. Replaces the six divergent empty states in `file_list.rs`, `detail.rs`, and `sidebar.rs`. (Per brand rule, `icon` defaults to a word like "Empty" rather than an emoji.) |
| 122 |
|
| 123 |
### Modals and overlays |
| 124 |
|
| 125 |
- **`modal_window(ctx, title, |ui| { … })`** — pre-configured `Window::new(title).collapsible(false).resizable(false).anchor(CENTER_CENTER, [0,0])` with consistent inner padding. All bulk modals, help overlay, and warning overlays compose this. |
| 126 |
- **`confirm_modal(ctx, prompt, danger: bool, on_confirm, on_cancel)`** — unified destructive-confirm scaffold. Confirm label is `danger_button` when `danger=true`. Replaces both `draw_confirm_dialog` and `draw_unsafe_warning`'s ad-hoc Window. |
| 127 |
- **`name_modal(...)`** — existing; keep, lift to `widgets.rs` so callers outside `overlays.rs` can use it. |
| 128 |
|
| 129 |
### Banners and notifications |
| 130 |
|
| 131 |
- **`info_banner(ui, body)`** — frame with `bg_tertiary()`, `corner_radius(4)`, `space::md` inset; used by the existing VFS first-run banner and any future inline tips. |
| 132 |
- **`toast(ctx, severity, body)`** — *new* primitive. Timed transient notification surfaced from a state-owned queue. Replaces the current "set `state.status = '...'`" pattern for errors and ephemeral confirmations (rename success, copy-to-clipboard). The existing footer status label is retained for *persistent* state ("Sync: 3 pending") but should no longer be the channel for transient error feedback. |
| 133 |
- **`loading_spinner(ui)` / `busy_indicator(ui, label)`** — *new* primitive for in-flight operations that aren't full-screen (sidebar refresh, sync running). Import has its own progress screen and is out of scope here. |
| 134 |
|
| 135 |
### Forms and grids |
| 136 |
|
| 137 |
- **`metadata_grid(ui, id, rows: impl Iterator<Item=(label, value)>)`** — the detail-panel `Grid` recipe; reusable for any read-only "key / value" block. `grid_row_spacing` token applies. |
| 138 |
- **`preview_grid(ui, id, headers, rows)`** — striped two-column grid used by bulk-rename preview; could be reused by export dry-run. |
| 139 |
|
| 140 |
--- |
| 141 |
|
| 142 |
## Panel shapes |
| 143 |
|
| 144 |
These are de-facto today; the charter codifies them so the surface audits (Phase 1+) can rely on them. |
| 145 |
|
| 146 |
|
| 147 |
|
| 148 |
| Left sidebar | `SidePanel::left` | Sections separated by `section_header` + `space::lg` between groups. | |
| 149 |
| Top toolbar | `TopBottomPanel::top` | Two rows max (breadcrumb + search). Right-aligned actions via `Layout::right_to_left`. | |
| 150 |
| Right detail | `SidePanel::right` | Sections paced by `space::section`. Waveform always first. | |
| 151 |
| Central | `CentralPanel::default` | Either the file list table or a full-screen wizard (import/export). | |
| 152 |
| Bottom footer | `TopBottomPanel::bottom` | One row. Transport on the left, status text on the right. | |
| 153 |
| Modal | `modal_window` widget | Centered, fixed-size unless explicitly resizable (bulk rename only). | |
| 154 |
| Inline banner | `info_banner` widget | Inside another panel; for one-time tips or unobtrusive state. | |
| 155 |
|
| 156 |
--- |
| 157 |
|
| 158 |
## Migration phasing |
| 159 |
|
| 160 |
This charter is the target. Migration into ordered batches with success criteria is tracked in the internal remediation plan; surface audits do not begin until consolidation lands. |
| 161 |
|
| 162 |
In short: build the missing widgets first (§Widgets), then rewrite panels to call them, then audit the user-facing surfaces. |
| 163 |
|