Skip to main content

max / audiofiles

Platinum phases 4 and 5: containers face one way, and nothing grows Two container primitives, and the rule that picks between them: does the user look at the thing, or into it? `raised_frame` is a card laid on a panel (surface_raised fill, Raised bevel); `inset_well` is a hole in one (surface_page fill, Inset bevel, one point of inner margin so content does not sit under the frame). info_banner and warning_banner become raised cards. Both were filled `surface_sunken` -- the well colour -- while being shaped like neither a well nor a card, so the fill moved rather than the bevel being pasted over a contradiction. The sync panel's error box is the same shape and now says so through the same helper instead of hand-rolling a Frame. The file-list table body and the sidebar's tag tree become wells. The table needed its column-visibility snapshot hoisted above the well, since a `&state.column_config` borrow cannot survive into a closure that also takes `state` mutably; the local bools it already kept for the same reason inside the table closures now serve both. `clicked_col` stays outside so the sort toggle still runs after every table borrow has ended. Phase 5: `hovered.expansion` to 0. A widget that swells a point under the cursor fights the light model directly -- the bevel is painted on the response rect, so an expanding widget drags its own frame outward while the surface under it stays put. Feedback comes from the bevel inverting on press, which is a stronger signal and costs no layout. settings_panel.rs and footer.rs are named in the phase-4 spec but had nothing left: their paint sites are the theme swatch and the progress bar, both converted in the pills pass. instrument_panel.rs and edit_panel.rs stay untouched, as the spec requires. 345 tests green, clippy at its prior warning count.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 18:31 UTC
Signed with PGP, not checked
Commit: 114de48b30da6e1abc34c4fe0a3b97dde865759c
Parent: cc4fded
6 files changed, +345 insertions, -300 deletions
@@ -237,6 +237,25 @@
237 237 - **`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.
238 238 - **`name_modal(...)`**: existing; keep, lift to `widgets.rs` so callers outside `overlays.rs` can use it.
239 239
240 + ### Containers
241 +
242 + Under Platinum a container says which way it faces, and there are exactly two
243 + answers. Chrome that sits on top of a panel is raised; anything holding a body
244 + of content the user scrolls through is a well, recessed into the panel.
245 +
246 + - **`inset_well(ui, |ui| { … }) -> R`**: a recessed container. `surface_page`
247 + fill, container radius, `Inset` bevel, one point of inner margin so content
248 + does not sit under the frame. The file-list table body and the sidebar's tag
249 + tree are wells. So is a text field, which is [`text_field`] — the same shape
250 + at a different size, on purpose.
251 + - **`info_banner` / `warning_banner`**: raised. A banner is a card laid on the
252 + panel, not a hole in it, so both take `surface_raised` and a `Raised` bevel.
253 + They were filled `surface_sunken` while being shaped like neither.
254 +
255 + The rule to apply when adding one: does the user look *at* this thing, or *into*
256 + it? A notice, a toolbar, a status strip is raised. A list, a table, a tree, a
257 + field is a well.
258 +
240 259 **Floating surfaces get both a raised bevel and a hard shadow.** `modal_window`,
241 260 `modal_window_with_open` and `tool_window` paint a `Raised` bevel over the window
242 261 rect after `.show()` returns, and the theme sets `window_shadow` / `popup_shadow`
@@ -197,50 +197,15 @@
197 197 let has_parent = state.nav.current_dir.is_some();
198 198 let contents = state.nav.contents.clone();
199 199 let offset = usize::from(has_parent);
200 - let col_cfg = &state.column_config;
201 -
202 - // Build columns dynamically based on config
203 - // Icon is merged into the Name column; Play button is merged into the last data column.
204 - let mut table = TableBuilder::new(ui)
205 - .striped(true)
206 - .resizable(true)
207 - .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
208 - .column(Column::remainder().at_least(120.0)); // Name (includes icon)
209 -
210 - // Scroll to focused row when keyboard navigation requests it.
211 - if let Some(row) = state.nav.scroll_to_row.take() {
212 - table = table.scroll_to_row(row, None);
213 - }
214 -
215 - if col_cfg.show_duration {
216 - table = table.column(Column::exact(60.0));
217 - }
218 - if col_cfg.show_classification {
219 - table = table.column(Column::exact(80.0));
220 - }
221 - if col_cfg.show_bpm {
222 - table = table.column(Column::exact(50.0));
223 - }
224 - if col_cfg.show_key {
225 - table = table.column(Column::exact(70.0));
226 - }
227 - if col_cfg.show_peak_db {
228 - table = table.column(Column::exact(60.0));
229 - }
230 - if col_cfg.show_tags {
231 - table = table.column(Column::exact(120.0));
232 - }
233 - table = table.column(Column::exact(36.0)); // Play button
234 -
235 - // Snapshot column visibility flags into local bools. The `col_cfg` borrow
236 - // from `state` can't survive into the table-builder closures which also
237 - // borrow `state`, so we copy the flags here.
238 - let show_classification = col_cfg.show_classification;
239 - let show_bpm = col_cfg.show_bpm;
240 - let show_key = col_cfg.show_key;
241 - let show_duration = col_cfg.show_duration;
242 - let show_peak_db = col_cfg.show_peak_db;
243 - let show_tags = col_cfg.show_tags;
200 + // Snapshot column visibility into local bools. The `&state.column_config`
201 + // borrow cannot survive into the table-builder closures, which also borrow
202 + // `state`, and it cannot survive into the well closure below either.
203 + let show_classification = state.column_config.show_classification;
204 + let show_bpm = state.column_config.show_bpm;
205 + let show_key = state.column_config.show_key;
206 + let show_duration = state.column_config.show_duration;
207 + let show_peak_db = state.column_config.show_peak_db;
208 + let show_tags = state.column_config.show_tags;
244 209
245 210 // Snapshot sort state so the header closure doesn't borrow `state` mutably.
246 211 let sort_col = state.nav.sort_column;
@@ -250,224 +215,264 @@
250 215 // by name" silently in that view would scramble the ranking. Disable
251 216 // header clicks instead so the score order stays trustworthy.
252 217 let sort_enabled = state.search.similarity_search_hash.is_none();
218 + // Declared outside the well because the sort toggle is applied after it,
219 + // once every borrow the table took has ended.
253 220 let clicked_col = std::cell::Cell::new(None::<SortColumn>);
254 221
255 - table
256 - .header(20.0, |mut header| {
257 - header.col(|ui| {
258 - if draw_sort_header(
259 - ui,
260 - "Name",
261 - SortColumn::Name,
262 - sort_col,
263 - &sort_dir,
264 - sort_enabled,
265 - ) {
266 - clicked_col.set(Some(SortColumn::Name));
267 - }
268 - });
269 - if show_duration {
270 - header.col(|ui| {
271 - if draw_sort_header(
272 - ui,
273 - "Dur",
274 - SortColumn::Duration,
275 - sort_col,
276 - &sort_dir,
277 - sort_enabled,
278 - ) {
279 - clicked_col.set(Some(SortColumn::Duration));
280 - }
281 - });
282 - }
283 - if show_classification {
284 - header.col(|ui| {
285 - if draw_sort_header(
286 - ui,
287 - "Class",
288 - SortColumn::Classification,
289 - sort_col,
290 - &sort_dir,
291 - sort_enabled,
292 - ) {
293 - clicked_col.set(Some(SortColumn::Classification));
294 - }
295 - });
296 - }
297 - if show_bpm {
298 - header.col(|ui| {
299 - if draw_sort_header(
300 - ui,
301 - "BPM",
302 - SortColumn::Bpm,
303 - sort_col,
304 - &sort_dir,
305 - sort_enabled,
306 - ) {
307 - clicked_col.set(Some(SortColumn::Bpm));
308 - }
309 - });
310 - }
311 - if show_key {
312 - header.col(|ui| {
313 - if draw_sort_header(
314 - ui,
315 - "Key",
316 - SortColumn::Key,
317 - sort_col,
318 - &sort_dir,
319 - sort_enabled,
320 - ) {
321 - clicked_col.set(Some(SortColumn::Key));
322 - }
323 - });
324 - }
325 - if show_peak_db {
326 - header.col(|ui| {
327 - ui.label(egui::RichText::new("Peak").color(theme::content_secondary()));
328 - });
329 - }
330 - if show_tags {
331 - header.col(|ui| {
332 - ui.label(egui::RichText::new("Tags").color(theme::content_secondary()));
333 - });
334 - }
335 - header.col(|ui| {
336 - ui.label(egui::RichText::new("Play").color(theme::content_muted()));
337 - });
338 - })
339 - .body(|body| {
340 - // Virtualized: egui lays out only the visible rows, so a folder with
341 - // thousands of children no longer re-lays-out every row each frame.
342 - // Display index 0 is the ".." parent entry when present; the rest map
343 - // into `contents`.
344 - let parent_rows = usize::from(has_parent);
345 - body.rows(row_height, parent_rows + contents.len(), |mut row| {
346 - let display_idx = row.index();
347 - if has_parent && display_idx == 0 {
348 - let selected = state.nav.selection.contains(0);
349 - row.set_selected(selected);
350 - row.col(|ui| {
351 - // Parent ".." entry: render muted so it reads as
352 - // navigation rather than a sample row, and is visually
353 - // distinct when scanning a selection with Cmd+A.
354 - let resp = ui.selectable_label(
355 - selected,
356 - egui::RichText::new(" Up").color(theme::content_secondary()),
357 - );
358 - if resp.clicked() {
359 - handle_click(state, 0, ui);
360 - }
361 - if resp.double_clicked() {
362 - state.go_up();
363 - }
364 - });
365 - if show_duration {
366 - row.col(|_ui| {});
367 - }
368 - if show_classification {
369 - row.col(|_ui| {});
370 - }
371 - if show_bpm {
372 - row.col(|_ui| {});
373 - }
374 - if show_key {
375 - row.col(|_ui| {});
376 - }
377 - if show_peak_db {
378 - row.col(|_ui| {});
379 - }
380 - if show_tags {
381 - row.col(|_ui| {});
382 - }
383 - row.col(|_ui| {});
384 - } else {
385 - let i = display_idx - parent_rows;
386 - let node = &contents[i];
387 - let row_idx = i + offset;
388 - let selected = state.nav.selection.contains(row_idx);
389 - row.set_selected(selected);
222 + // The table body is a well: it is the thing the user looks into, and the
223 + // panel chrome around it sits on top. See `docs/design-system.md`.
224 + widgets::inset_well(ui, |ui| {
225 + // Build columns dynamically based on config
226 + // Icon is merged into the Name column; Play button is merged into the last data column.
227 + let mut table = TableBuilder::new(ui)
228 + .striped(true)
229 + .resizable(true)
230 + .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
231 + .column(Column::remainder().at_least(120.0)); // Name (includes icon)
390 232
391 - // Name (with inline icon)
392 - row.col(|ui| {
393 - #[cfg(any(target_os = "macos", target_os = "windows"))]
394 - let drag_blocked = os_drag_blocked;
395 - #[cfg(not(any(target_os = "macos", target_os = "windows")))]
396 - let drag_blocked = false;
397 - draw_name_column(
233 + // Scroll to focused row when keyboard navigation requests it.
234 + if let Some(row) = state.nav.scroll_to_row.take() {
235 + table = table.scroll_to_row(row, None);
236 + }
237 +
238 + if show_duration {
239 + table = table.column(Column::exact(60.0));
240 + }
241 + if show_classification {
242 + table = table.column(Column::exact(80.0));
243 + }
244 + if show_bpm {
245 + table = table.column(Column::exact(50.0));
246 + }
247 + if show_key {
248 + table = table.column(Column::exact(70.0));
249 + }
250 + if show_peak_db {
251 + table = table.column(Column::exact(60.0));
252 + }
253 + if show_tags {
254 + table = table.column(Column::exact(120.0));
255 + }
256 + table = table.column(Column::exact(36.0)); // Play button
257 +
258 + table
259 + .header(20.0, |mut header| {
260 + header.col(|ui| {
261 + if draw_sort_header(
262 + ui,
263 + "Name",
264 + SortColumn::Name,
265 + sort_col,
266 + &sort_dir,
267 + sort_enabled,
268 + ) {
269 + clicked_col.set(Some(SortColumn::Name));
270 + }
271 + });
272 + if show_duration {
273 + header.col(|ui| {
274 + if draw_sort_header(
398 275 ui,
399 - state,
400 - node,
401 - row_idx,
402 - selected,
403 - drag_blocked,
404 - sync_manager,
405 - );
406 - });
407 -
408 - // Analysis columns (duration, classification, BPM, key, peak dB, tags)
409 - draw_analysis_columns(
410 - &mut row,
411 - node,
412 - AnalysisColumnFlags {
413 - duration: show_duration,
414 - classification: show_classification,
415 - bpm: show_bpm,
416 - key: show_key,
417 - peak_db: show_peak_db,
418 - tags: show_tags,
419 - },
420 - );
421 -
422 - // Play (or Download for cloud-only) button. C-1: cloud-only
423 - // samples used to render an empty cell, leaving the row
424 - // looking half-broken. The Download button surfaces the
425 - // recovery path that previously lived only in the
426 - // right-click context menu.
427 - row.col(|ui| {
428 - if node.node.node_type != NodeType::Sample {
429 - return;
430 - }
431 - let Some(hash) = node.node.sample_hash.as_ref() else {
432 - return;
433 - };
434 - if node.cloud_only {
435 - if let Some(sync) = sync_manager
436 - && ui
437 - .button("Download")
438 - .on_hover_text("Fetch this sample from the cloud")
439 - .clicked()
440 - {
441 - let hash_str = hash.to_string();
442 - if sync.download_sample(&hash_str) {
443 - state.status = format!("Downloading {}...", node.node.name);
444 - } else {
445 - state.status =
446 - "Sync not ready: open the Sync panel first".to_string();
447 - }
448 - }
449 - } else {
450 - let is_playing = state.preview.previewing_hash.as_deref() == Some(hash)
451 - && state.shared.preview.lock().playing;
452 - let btn_text = if is_playing { "Stop" } else { "Play" };
453 - let hover = if is_playing {
454 - "Stop preview (Space)"
455 - } else {
456 - "Play preview (Space)"
457 - };
458 - if ui.button(btn_text).on_hover_text(hover).clicked() {
459 - if is_playing {
460 - state.stop_preview();
461 - } else {
462 - let hash = hash.clone();
463 - state.trigger_preview(&hash);
464 - }
465 - }
276 + "Dur",
277 + SortColumn::Duration,
278 + sort_col,
279 + &sort_dir,
280 + sort_enabled,
281 + ) {
282 + clicked_col.set(Some(SortColumn::Duration));
466 283 }
467 284 });
468 285 }
286 + if show_classification {
287 + header.col(|ui| {
288 + if draw_sort_header(
289 + ui,
290 + "Class",
291 + SortColumn::Classification,
292 + sort_col,
293 + &sort_dir,
294 + sort_enabled,
295 + ) {
296 + clicked_col.set(Some(SortColumn::Classification));
297 + }
298 + });
299 + }
300 + if show_bpm {
301 + header.col(|ui| {
302 + if draw_sort_header(
303 + ui,
304 + "BPM",
305 + SortColumn::Bpm,
306 + sort_col,
307 + &sort_dir,
308 + sort_enabled,
309 + ) {
310 + clicked_col.set(Some(SortColumn::Bpm));
311 + }
312 + });
313 + }
314 + if show_key {
315 + header.col(|ui| {
316 + if draw_sort_header(
317 + ui,
318 + "Key",
319 + SortColumn::Key,
320 + sort_col,
321 + &sort_dir,
322 + sort_enabled,
323 + ) {
324 + clicked_col.set(Some(SortColumn::Key));
325 + }
326 + });
327 + }
328 + if show_peak_db {
329 + header.col(|ui| {
330 + ui.label(egui::RichText::new("Peak").color(theme::content_secondary()));
331 + });
332 + }
333 + if show_tags {
334 + header.col(|ui| {
335 + ui.label(egui::RichText::new("Tags").color(theme::content_secondary()));
336 + });
337 + }
338 + header.col(|ui| {
339 + ui.label(egui::RichText::new("Play").color(theme::content_muted()));
340 + });
341 + })
342 + .body(|body| {
343 + // Virtualized: egui lays out only the visible rows, so a folder with
344 + // thousands of children no longer re-lays-out every row each frame.
345 + // Display index 0 is the ".." parent entry when present; the rest map
346 + // into `contents`.
347 + let parent_rows = usize::from(has_parent);
348 + body.rows(row_height, parent_rows + contents.len(), |mut row| {
349 + let display_idx = row.index();
350 + if has_parent && display_idx == 0 {
351 + let selected = state.nav.selection.contains(0);
352 + row.set_selected(selected);
353 + row.col(|ui| {
354 + // Parent ".." entry: render muted so it reads as
355 + // navigation rather than a sample row, and is visually
356 + // distinct when scanning a selection with Cmd+A.
357 + let resp = ui.selectable_label(
358 + selected,
359 + egui::RichText::new(" Up").color(theme::content_secondary()),
360 + );
361 + if resp.clicked() {
362 + handle_click(state, 0, ui);
363 + }
364 + if resp.double_clicked() {
365 + state.go_up();
366 + }
367 + });
368 + if show_duration {
369 + row.col(|_ui| {});
370 + }
371 + if show_classification {
372 + row.col(|_ui| {});
373 + }
374 + if show_bpm {
375 + row.col(|_ui| {});
376 + }
377 + if show_key {
378 + row.col(|_ui| {});
379 + }
380 + if show_peak_db {
381 + row.col(|_ui| {});
382 + }
383 + if show_tags {
384 + row.col(|_ui| {});
385 + }
386 + row.col(|_ui| {});
387 + } else {
388 + let i = display_idx - parent_rows;
389 + let node = &contents[i];
390 + let row_idx = i + offset;
391 + let selected = state.nav.selection.contains(row_idx);
392 + row.set_selected(selected);
393 +
394 + // Name (with inline icon)
395 + row.col(|ui| {
396 + #[cfg(any(target_os = "macos", target_os = "windows"))]
397 + let drag_blocked = os_drag_blocked;
398 + #[cfg(not(any(target_os = "macos", target_os = "windows")))]
399 + let drag_blocked = false;
400 + draw_name_column(
401 + ui,
402 + state,
403 + node,
404 + row_idx,
405 + selected,
406 + drag_blocked,
407 + sync_manager,
408 + );
409 + });
410 +
411 + // Analysis columns (duration, classification, BPM, key, peak dB, tags)
412 + draw_analysis_columns(
413 + &mut row,
414 + node,
415 + AnalysisColumnFlags {
416 + duration: show_duration,
417 + classification: show_classification,
418 + bpm: show_bpm,
419 + key: show_key,
420 + peak_db: show_peak_db,
421 + tags: show_tags,
422 + },
423 + );
424 +
425 + // Play (or Download for cloud-only) button. C-1: cloud-only
426 + // samples used to render an empty cell, leaving the row
427 + // looking half-broken. The Download button surfaces the
428 + // recovery path that previously lived only in the
429 + // right-click context menu.
430 + row.col(|ui| {
431 + if node.node.node_type != NodeType::Sample {
432 + return;
433 + }
434 + let Some(hash) = node.node.sample_hash.as_ref() else {
435 + return;
436 + };
437 + if node.cloud_only {
438 + if let Some(sync) = sync_manager
439 + && ui
440 + .button("Download")
441 + .on_hover_text("Fetch this sample from the cloud")
442 + .clicked()
443 + {
444 + let hash_str = hash.to_string();
445 + if sync.download_sample(&hash_str) {
446 + state.status = format!("Downloading {}...", node.node.name);
Lines truncated
@@ -668,14 +668,19 @@
668 668 ui.add_space(theme::space::bound());
669 669 }
670 670
671 - if filtered_tags.is_empty() {
672 - ui.label(egui::RichText::new("No matching tags").color(theme::content_muted()));
673 - } else {
674 - let tree = build_tag_tree(filtered_tags.as_slice());
675 - for (segment, node) in &tree {
676 - draw_tag_node(ui, "", segment, node, state);
671 + // The tree is a well, like the file list. Everything above it in
672 + // this section -- the filter field, the rename row -- is chrome
673 + // laid on the sidebar; the tree is the body you look into.
674 + widgets::inset_well(ui, |ui| {
675 + if filtered_tags.is_empty() {
676 + ui.label(egui::RichText::new("No matching tags").color(theme::content_muted()));
677 + } else {
678 + let tree = build_tag_tree(filtered_tags.as_slice());
679 + for (segment, node) in &tree {
680 + draw_tag_node(ui, "", segment, node, state);
681 + }
677 682 }
678 - }
683 + });
679 684 }
680 685 });
681 686 }
@@ -114,25 +114,21 @@
114 114 ui.add_space(theme::space::bound());
115 115 ui.separator();
116 116 ui.add_space(theme::space::bound());
117 - egui::Frame::new()
118 - .fill(theme::surface_sunken())
119 - .corner_radius(theme::radius_container())
120 - .inner_margin(egui::Margin::same(theme::space::group() as i8))
121 - .show(ui, |ui| {
122 - ui.label(egui::RichText::new(err).color(theme::danger()));
123 - ui.add_space(theme::space::bound());
124 - ui.horizontal(|ui| {
125 - let retryable =
126 - matches!(status.state, SyncState::Ready | SyncState::Syncing,);
127 - if retryable && widgets::secondary_button(ui, "Retry").clicked() {
128 - sync.clear_last_error();
129 - sync.sync_now();
130 - }
131 - if widgets::secondary_button(ui, "Dismiss").clicked() {
132 - sync.clear_last_error();
133 - }
134 - });
117 + widgets::raised_frame(ui, |ui| {
118 + ui.label(egui::RichText::new(err).color(theme::danger()));
119 + ui.add_space(theme::space::bound());
120 + ui.horizontal(|ui| {
121 + let retryable =
122 + matches!(status.state, SyncState::Ready | SyncState::Syncing,);
123 + if retryable && widgets::secondary_button(ui, "Retry").clicked() {
124 + sync.clear_last_error();
125 + sync.sync_now();
126 + }
127 + if widgets::secondary_button(ui, "Dismiss").clicked() {
128 + sync.clear_last_error();
129 + }
135 130 });
131 + });
136 132 }
137 133 },
138 134 );
@@ -959,8 +959,16 @@
959 959 visuals.widgets.noninteractive.bg_stroke =
960 960 egui::Stroke::new(0.5, lerp_color(t.border, t.surface_overlay, 0.4));
961 961
962 - // Widget expansion on hover for tactile feedback
963 - visuals.widgets.hovered.expansion = 1.0;
962 + // Nothing grows. A widget that swells a point under the cursor is the
963 + // clearest tell that this is a modern immediate-mode toolkit imitating a
964 + // web hover state, and it fights the light model directly: the bevel is
965 + // painted on the response rect, so an expanding widget drags its own frame
966 + // outward while the surface under it stays put.
967 + //
968 + // Platinum state changes are instant and dimensionless. Feedback comes
969 + // from the bevel inverting on press, which is a stronger signal than a
970 + // one-point grow and costs no layout.
971 + visuals.widgets.hovered.expansion = 0.0;
964 972 visuals.widgets.active.expansion = 0.0;
965 973
966 974 drop(t);
@@ -352,20 +352,63 @@
352 352 }
353 353 }
354 354
355 - /// Inline informational banner: container-radius frame, `surface_sunken` fill, body text
356 - /// in `content_secondary`. Used for one-time tips and unobtrusive panel notices.
357 - pub fn info_banner(ui: &mut egui::Ui, body: &str) {
358 - egui::Frame::new()
359 - .fill(theme::surface_sunken())
355 + // --- Containers --------------------------------------------------------------
356 + //
357 + // Two answers and no third: chrome laid on a panel is raised, and anything
358 + // holding a body of content is a well recessed into it. The question to ask
359 + // when adding one is whether the user looks *at* the thing or *into* it.
360 +
361 + /// A raised container: a card laid on the panel it sits in.
362 + ///
363 + /// The `Raised` bevel and a `surface_raised` fill, together. Either alone is
364 + /// the bug this replaced — the banners were filled `surface_sunken`, which is
365 + /// the well colour, while being shaped like neither a well nor a card.
366 + pub fn raised_frame<R>(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui) -> R) -> R {
367 + let framed = egui::Frame::new()
368 + .fill(theme::surface_raised())
360 369 .corner_radius(theme::radius_container())
361 370 .inner_margin(egui::Margin::same(theme::space::group() as i8))
362 - .show(ui, |ui| {
363 - ui.label(
364 - egui::RichText::new(body)
365 - .small()
366 - .color(theme::content_secondary()),
367 - );
368 - });
371 + .show(ui, add_contents);
372 + theme::bevel::paint(
373 + ui.painter(),
374 + framed.response.rect,
375 + theme::bevel::Bevel::Raised,
376 + );
377 + framed.inner
378 + }
379 +
380 + /// A recessed container: a hole in the panel with content down inside it.
381 + ///
382 + /// For anything the user looks *into* — the file-list table body, the sidebar's
383 + /// tag tree. A text field is the same shape at a different size, which is why
384 + /// [`text_field`] paints the same bevel; that is deliberate, not a coincidence.
385 + ///
386 + /// The one-point inner margin keeps content off the frame. Without it the first
387 + /// row of a table sits underneath the bevel and the well loses its top edge.
388 + pub fn inset_well<R>(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui) -> R) -> R {
389 + let framed = egui::Frame::new()
390 + .fill(theme::surface_page())
391 + .corner_radius(theme::radius_container())
392 + .inner_margin(egui::Margin::same(theme::stroke::DEFAULT as i8))
393 + .show(ui, add_contents);
394 + theme::bevel::paint(
395 + ui.painter(),
396 + framed.response.rect,
397 + theme::bevel::Bevel::Inset,
398 + );
399 + framed.inner
400 + }
401 +
402 + /// Inline informational banner: raised card, body text in `content_secondary`.
403 + /// Used for one-time tips and unobtrusive panel notices.
404 + pub fn info_banner(ui: &mut egui::Ui, body: &str) {
405 + raised_frame(ui, |ui| {
406 + ui.label(
407 + egui::RichText::new(body)
408 + .small()
409 + .color(theme::content_secondary()),
410 + );
411 + });
369 412 }
370 413
371 414 /// Inline warning banner: same shape as `info_banner` but body text in
@@ -373,16 +416,12 @@
373 416 /// consequences are important enough that the weak/small footnote style would
374 417 /// under-sell them, currently the irrecoverable encryption-password setup.
375 418 pub fn warning_banner(ui: &mut egui::Ui, body: &str) {
376 - egui::Frame::new()
377 - .fill(theme::surface_sunken())
378 - .corner_radius(theme::radius_container())
379 - .inner_margin(egui::Margin::same(theme::space::group() as i8))
380 - .show(ui, |ui| {
381 - ui.label(egui::RichText::new(body).color(theme::warning()));
382 - });
419 + raised_frame(ui, |ui| {
420 + ui.label(egui::RichText::new(body).color(theme::warning()));
421 + });
383 422 }
384 423
385 - // --- Toolbar toggle and segmented pills --------------------------------------
424 + // --- Toolbar toggle and segmented control ------------------------------------
386 425
387 426 /// Toolbar toggle: a button that stays in.
388 427 ///