From f96e6f1df4dd096abf8cffd39738f8a4708b9182 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 18 Jul 2026 18:54:39 +0200 Subject: [PATCH] Modernize the UI and isolate fixture tests. --- AGENTS.md | 6 +- crates/bds-core/tests/fixture_readability.rs | 24 +- crates/bds-core/tests/spec_claims.rs | 11 +- crates/bds-core/tests/support/mod.rs | 34 +++ crates/bds-editor/src/widget.rs | 81 +++--- crates/bds-ui/src/components/inputs.rs | 270 ++++++++++++++++--- crates/bds-ui/src/main.rs | 3 +- crates/bds-ui/src/views/activity_bar.rs | 4 +- crates/bds-ui/src/views/media_editor.rs | 37 ++- crates/bds-ui/src/views/modal.rs | 77 ++---- crates/bds-ui/src/views/panel.rs | 4 +- crates/bds-ui/src/views/post_editor.rs | 81 +++--- crates/bds-ui/src/views/project_selector.rs | 10 +- crates/bds-ui/src/views/script_editor.rs | 52 ++-- crates/bds-ui/src/views/settings_view.rs | 35 ++- crates/bds-ui/src/views/sidebar.rs | 48 ++-- crates/bds-ui/src/views/site_validation.rs | 25 +- crates/bds-ui/src/views/status_bar.rs | 10 +- crates/bds-ui/src/views/tab_bar.rs | 6 +- crates/bds-ui/src/views/tags_view.rs | 222 +++++++-------- crates/bds-ui/src/views/template_editor.rs | 51 ++-- crates/bds-ui/src/views/toast.rs | 2 +- docs/UI_STYLE_GUIDE.md | 49 ++++ 23 files changed, 744 insertions(+), 398 deletions(-) create mode 100644 crates/bds-core/tests/support/mod.rs create mode 100644 docs/UI_STYLE_GUIDE.md diff --git a/AGENTS.md b/AGENTS.md index d5fec88..ec7364e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,11 @@ This project has an allium spec in the folder specs/ - use it to verify behaviou Invariants and behaviours in the allium spec should be covered by unit tests of the application code, to make sure the spec is followed. +## UI styling + +- Before implementing or changing UI, read and follow `docs/UI_STYLE_GUIDE.md`. +- Reuse the shared styling primitives described there so new sidebars and editor areas remain consistent with the post editor. + ## Plan Mode - Make the plan extremely concise. Sacrifice grammar for the sake of concision. @@ -45,4 +50,3 @@ Invariants and behaviours in the allium spec should be covered by unit tests of - when creating rust source code, always follow what the allium spec is saying for that part - when tending the allium spec, make sure you validate the spec with the installed command line utility - don't be lazy. don't defer or skip implementations just because you have to write code for that, that is ridiculous. if the spec says something has to be there, it has to be there. - diff --git a/crates/bds-core/tests/fixture_readability.rs b/crates/bds-core/tests/fixture_readability.rs index 551c228..fd4642b 100644 --- a/crates/bds-core/tests/fixture_readability.rs +++ b/crates/bds-core/tests/fixture_readability.rs @@ -1,9 +1,11 @@ //! Verify that the Rust Diesel models read the compatibility fixture produced by bDS. +mod support; + use std::collections::HashSet; +use std::fs; use std::path::PathBuf; -use bds_core::db::Database; use bds_core::db::queries::{ media, post, post_link, post_media, post_translation, project, script, setting, tag, template, }; @@ -17,11 +19,21 @@ const GHOSTTY_ID: &str = "6745981d-da41-4cfd-80ec-95ad339acf6f"; const CMUX_ID: &str = "2665bfaa-8251-468d-a710-a4cf34dd81e2"; const SPIDER_ID: &str = "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240"; -fn fixture_db() -> Database { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/compatibility-projects/rfc1437-sample/bds.db"); - assert!(path.exists(), "fixture DB not found at {}", path.display()); - Database::open(&path).unwrap() +fn fixture_db() -> support::FixtureDatabase { + support::fixture_database() +} + +#[test] +fn fixture_database_files_are_not_modified_by_tests() { + let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/compatibility-projects/rfc1437-sample"); + let paths = ["bds.db", "bds.db-shm", "bds.db-wal"].map(|name| directory.join(name)); + let before = paths.each_ref().map(|path| fs::read(path).unwrap()); + + drop(fixture_db()); + + let after = paths.each_ref().map(|path| fs::read(path).unwrap()); + assert_eq!(after, before); } #[test] diff --git a/crates/bds-core/tests/spec_claims.rs b/crates/bds-core/tests/spec_claims.rs index 3f5806d..16de74a 100644 --- a/crates/bds-core/tests/spec_claims.rs +++ b/crates/bds-core/tests/spec_claims.rs @@ -1,7 +1,8 @@ //! Executable checks for storage-related Allium claims. +mod support; + use std::collections::HashSet; -use std::path::PathBuf; use bds_core::db::Database; use bds_core::db::queries::{ @@ -13,12 +14,8 @@ use bds_core::model::{ }; use diesel::prelude::*; -fn fixture_db() -> Database { - Database::open( - &PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/compatibility-projects/rfc1437-sample/bds.db"), - ) - .unwrap() +fn fixture_db() -> support::FixtureDatabase { + support::fixture_database() } fn memory_db() -> Database { diff --git a/crates/bds-core/tests/support/mod.rs b/crates/bds-core/tests/support/mod.rs new file mode 100644 index 0000000..a53686f --- /dev/null +++ b/crates/bds-core/tests/support/mod.rs @@ -0,0 +1,34 @@ +use std::fs; +use std::ops::Deref; +use std::path::PathBuf; + +use bds_core::db::Database; +use tempfile::TempDir; + +pub struct FixtureDatabase { + database: Database, + _directory: TempDir, +} + +impl Deref for FixtureDatabase { + type Target = Database; + + fn deref(&self) -> &Self::Target { + &self.database + } +} + +pub fn fixture_database() -> FixtureDatabase { + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/compatibility-projects/rfc1437-sample"); + let directory = TempDir::new().unwrap(); + + for name in ["bds.db", "bds.db-shm", "bds.db-wal"] { + fs::copy(source.join(name), directory.path().join(name)).unwrap(); + } + + FixtureDatabase { + database: Database::open(&directory.path().join("bds.db")).unwrap(), + _directory: directory, + } +} diff --git a/crates/bds-editor/src/widget.rs b/crates/bds-editor/src/widget.rs index 6343487..452fc6d 100644 --- a/crates/bds-editor/src/widget.rs +++ b/crates/bds-editor/src/widget.rs @@ -10,7 +10,7 @@ use iced::advanced::{Clipboard, Shell}; use iced::event::Status; use iced::keyboard; use iced::mouse; -use iced::{Color, Element, Event, Length, Pixels, Point, Rectangle, Size, Theme}; +use iced::{Color, Element, Event, Length, Pixels, Point, Rectangle, Shadow, Size, Theme, Vector}; use crate::buffer::EditorBuffer; use crate::highlight::Highlighter; @@ -76,21 +76,33 @@ pub fn mono_metrics() -> &'static MonoMetrics { }) } -const GUTTER_WIDTH: f32 = 50.0; +const GUTTER_WIDTH: f32 = 44.0; +const TEXT_PADDING: f32 = 12.0; const FONT_SIZE: f32 = 14.0; -const BG_COLOR: Color = Color::from_rgb(0.18, 0.20, 0.25); -const GUTTER_BG: Color = Color::from_rgb(0.15, 0.17, 0.21); -const TEXT_COLOR: Color = Color::from_rgb(0.85, 0.85, 0.85); -const GUTTER_TEXT: Color = Color::from_rgb(0.45, 0.48, 0.55); -const CURSOR_COLOR: Color = Color::from_rgb(0.9, 0.9, 0.2); -const ACTIVE_LINE_NUM: Color = Color::from_rgb(0.75, 0.78, 0.85); -const SELECTION_BG: Color = Color::from_rgba(0.26, 0.54, 0.79, 0.40); +const TEXT_COLOR: Color = rgb8(0xD4, 0xD4, 0xD4); +const GUTTER_TEXT: Color = rgb8(0x85, 0x85, 0x85); +const CURSOR_COLOR: Color = rgb8(0xAE, 0xAF, 0xAD); +const ACTIVE_LINE_NUM: Color = rgb8(0xC6, 0xC6, 0xC6); +const SELECTION_BG: Color = rgba8(0x26, 0x4F, 0x78, 0.85); const SCROLLBAR_WIDTH: f32 = 10.0; -const SCROLLBAR_TRACK: Color = Color::from_rgba(0.25, 0.27, 0.32, 0.5); -const SCROLLBAR_THUMB: Color = Color::from_rgba(0.50, 0.53, 0.60, 0.7); -const SCROLLBAR_THUMB_HOVER: Color = Color::from_rgba(0.60, 0.63, 0.70, 0.9); +const SCROLLBAR_TRACK: Color = Color::TRANSPARENT; +const SCROLLBAR_THUMB: Color = rgba8(0x79, 0x79, 0x79, 0.45); +const SCROLLBAR_THUMB_HOVER: Color = rgba8(0x79, 0x79, 0x79, 0.75); const MIN_THUMB_HEIGHT: f32 = 20.0; +const fn rgb8(red: u8, green: u8, blue: u8) -> Color { + rgba8(red, green, blue, 1.0) +} + +const fn rgba8(red: u8, green: u8, blue: u8, alpha: f32) -> Color { + Color { + r: red as f32 / 255.0, + g: green as f32 / 255.0, + b: blue as f32 / 255.0, + a: alpha, + } +} + fn committed_text_input(text: Option<&str>, is_command_shortcut: bool) -> Option<&str> { if is_command_shortcut { return None; @@ -303,7 +315,7 @@ where &self, tree: &widget::Tree, renderer: &mut Renderer, - _theme: &Theme, + theme: &Theme, _style: &renderer::Style, layout: Layout<'_>, _cursor: mouse::Cursor, @@ -317,24 +329,33 @@ where renderer.fill_quad( renderer::Quad { bounds, - border: iced::Border::default(), - shadow: iced::Shadow::default(), + border: iced::Border { + color: Color::from_rgb8(0x35, 0x35, 0x35), + width: 1.0, + radius: 8.0.into(), + }, + shadow: Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.32), + offset: Vector::new(0.0, 3.0), + blur_radius: 14.0, + }, }, - BG_COLOR, + theme.palette().background, ); - // Gutter background - let gutter_bounds = Rectangle { - width: GUTTER_WIDTH, - ..bounds - }; + // Subtle gutter divider; the shared background keeps the editor visually light. renderer.fill_quad( renderer::Quad { - bounds: gutter_bounds, + bounds: Rectangle { + x: bounds.x + GUTTER_WIDTH, + y: bounds.y, + width: 1.0, + height: bounds.height, + }, border: iced::Border::default(), shadow: iced::Shadow::default(), }, - GUTTER_BG, + Color::from_rgb8(0x2B, 0x2B, 0x2B), ); let metrics = mono_metrics(); @@ -349,7 +370,7 @@ where let highlighted = self.highlighter.highlight_lines(&full_text, syntax); let font = iced::Font::MONOSPACE; - let text_area_width = bounds.width - GUTTER_WIDTH - 8.0; + let text_area_width = bounds.width - GUTTER_WIDTH - TEXT_PADDING; let max_chars = if self.word_wrap && text_area_width > metrics.char_width { (text_area_width / metrics.char_width).floor() as usize } else { @@ -382,7 +403,7 @@ where line_idx += 1; } - let text_x = bounds.x + GUTTER_WIDTH + 8.0; + let text_x = bounds.x + GUTTER_WIDTH + TEXT_PADDING; // Render visual rows for (vis_idx, &(line_idx, char_start, char_end, is_first)) in visual_rows.iter().enumerate() @@ -666,7 +687,7 @@ where let state = tree.state.downcast_mut::(); let bounds = layout.bounds(); let metrics = mono_metrics(); - let text_area_width = bounds.width - GUTTER_WIDTH - 8.0; + let text_area_width = bounds.width - GUTTER_WIDTH - TEXT_PADDING; let cpl = if self.word_wrap && text_area_width > metrics.char_width { (text_area_width / metrics.char_width).floor() as usize } else { @@ -752,8 +773,8 @@ where if let Some(pos) = cursor.position_in(bounds) { let mut buf = self.buffer.borrow_mut(); let vis_row = (pos.y / metrics.line_height) as usize; - let raw_col = - ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize; + let raw_col = ((pos.x - GUTTER_WIDTH - TEXT_PADDING).max(0.0) + / metrics.char_width) as usize; let (line, char_off) = visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl) .unwrap_or((buf.line_count().saturating_sub(1), 0)); @@ -792,8 +813,8 @@ where if let Some(pos) = cursor.position_in(bounds) { let mut buf = self.buffer.borrow_mut(); let vis_row = (pos.y / metrics.line_height) as usize; - let raw_col = - ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize; + let raw_col = ((pos.x - GUTTER_WIDTH - TEXT_PADDING).max(0.0) + / metrics.char_width) as usize; let (line, char_off) = visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl) .unwrap_or((buf.line_count().saturating_sub(1), 0)); diff --git a/crates/bds-ui/src/components/inputs.rs b/crates/bds-ui/src/components/inputs.rs index ca03737..5b6d6ad 100644 --- a/crates/bds-ui/src/components/inputs.rs +++ b/crates/bds-ui/src/components/inputs.rs @@ -1,15 +1,104 @@ use iced::widget::text::Shaping; -use iced::widget::{Space, button, checkbox, column, container, pick_list, row, text, text_input}; -use iced::{Alignment, Background, Color, Element, Length, Theme}; +use iced::widget::{ + Container, button, checkbox, column, container, pick_list, row, text, text_editor, text_input, +}; +use iced::{Alignment, Background, Border, Color, Element, Length, Shadow, Theme, Vector}; /// Standard form field label color. -pub const LABEL_COLOR: Color = Color::from_rgb(0.65, 0.68, 0.75); -const _FIELD_BG: Color = Color::from_rgb(0.15, 0.16, 0.20); -pub const SECTION_COLOR: Color = Color::from_rgb(0.50, 0.52, 0.58); -const DANGER_BG: Color = Color::from_rgb(0.60, 0.15, 0.15); -const DANGER_HOVER: Color = Color::from_rgb(0.70, 0.20, 0.20); -const PRIMARY_BG: Color = Color::from_rgb(0.20, 0.40, 0.70); -const PRIMARY_HOVER: Color = Color::from_rgb(0.25, 0.48, 0.80); +pub const LABEL_COLOR: Color = rgb8(0xB5, 0xBA, 0xC4); +pub const SECTION_COLOR: Color = rgb8(0x9D, 0xA5, 0xB4); +const FIELD_BG: Color = rgb8(0x24, 0x24, 0x26); +const SURFACE_BG: Color = rgb8(0x25, 0x25, 0x26); +const BORDER_COLOR: Color = rgb8(0x3C, 0x3C, 0x3C); +const FOCUS_COLOR: Color = rgb8(0x00, 0x7F, 0xD4); +const DANGER_BG: Color = rgb8(0xB6, 0x23, 0x24); +const DANGER_HOVER: Color = rgb8(0xCF, 0x2F, 0x30); +const PRIMARY_BG: Color = rgb8(0x0E, 0x63, 0x9C); +const PRIMARY_HOVER: Color = rgb8(0x11, 0x77, 0xBB); +const SECONDARY_BG: Color = rgb8(0x2D, 0x2D, 0x30); +const SECONDARY_HOVER: Color = rgb8(0x3A, 0x3D, 0x41); + +const fn rgb8(red: u8, green: u8, blue: u8) -> Color { + Color { + r: red as f32 / 255.0, + g: green as f32 / 255.0, + b: blue as f32 / 255.0, + a: 1.0, + } +} + +/// Application-wide VS Code-like palette for native Iced controls. +pub fn app_theme() -> Theme { + Theme::custom( + "bDS".to_string(), + iced::theme::Palette { + background: Color::from_rgb8(0x1E, 0x1E, 0x1E), + text: Color::from_rgb8(0xCC, 0xCC, 0xCC), + primary: PRIMARY_BG, + success: Color::from_rgb8(0x2E, 0x7D, 0x32), + danger: DANGER_BG, + }, + ) +} + +pub fn field_style(_theme: &Theme, status: text_input::Status) -> text_input::Style { + let border_color = match status { + text_input::Status::Focused => FOCUS_COLOR, + text_input::Status::Hovered => Color::from_rgb8(0x5A, 0x5A, 0x5A), + _ => BORDER_COLOR, + }; + text_input::Style { + background: Background::Color(FIELD_BG), + border: Border { + color: border_color, + width: 1.0, + radius: 6.0.into(), + }, + icon: SECTION_COLOR, + placeholder: Color::from_rgb8(0x7D, 0x7D, 0x7D), + value: Color::from_rgb8(0xE4, 0xE4, 0xE4), + selection: Color::from_rgba8(0x26, 0x4F, 0x78, 0.8), + } +} + +/// Multi-line editor style matching the standard form fields. +pub fn text_editor_style(_theme: &Theme, status: text_editor::Status) -> text_editor::Style { + let border_color = match status { + text_editor::Status::Focused => FOCUS_COLOR, + text_editor::Status::Hovered => Color::from_rgb8(0x5A, 0x5A, 0x5A), + _ => BORDER_COLOR, + }; + text_editor::Style { + background: Background::Color(FIELD_BG), + border: Border { + color: border_color, + width: 1.0, + radius: 6.0.into(), + }, + icon: SECTION_COLOR, + placeholder: Color::from_rgb8(0x7D, 0x7D, 0x7D), + value: Color::from_rgb8(0xE4, 0xE4, 0xE4), + selection: Color::from_rgba8(0x26, 0x4F, 0x78, 0.8), + } +} + +fn select_style(_theme: &Theme, status: pick_list::Status) -> pick_list::Style { + let border_color = match status { + pick_list::Status::Hovered | pick_list::Status::Opened => FOCUS_COLOR, + pick_list::Status::Active => BORDER_COLOR, + }; + pick_list::Style { + text_color: Color::from_rgb8(0xE4, 0xE4, 0xE4), + placeholder_color: Color::from_rgb8(0x7D, 0x7D, 0x7D), + handle_color: SECTION_COLOR, + background: Background::Color(FIELD_BG), + border: Border { + color: border_color, + width: 1.0, + radius: 6.0.into(), + }, + } +} /// A labeled text input field. pub fn labeled_input<'a, Message: Clone + 'a>( @@ -23,9 +112,15 @@ pub fn labeled_input<'a, Message: Clone + 'a>( .size(12) .color(LABEL_COLOR) .shaping(Shaping::Advanced), - text_input(placeholder, value).on_input(on_change).size(14), + text_input(placeholder, value) + .on_input(on_change) + .size(14) + .padding([8, 10]) + .width(Length::Fill) + .style(field_style), ] - .spacing(4) + .spacing(6) + .width(Length::Fill) .into() } @@ -46,9 +141,13 @@ where .size(12) .color(LABEL_COLOR) .shaping(Shaping::Advanced), - pick_list(list, selected.cloned(), on_select), + pick_list(list, selected.cloned(), on_select) + .padding([8, 10]) + .width(Length::Fill) + .style(select_style), ] - .spacing(4) + .spacing(6) + .width(Length::Fill) .into() } @@ -67,21 +166,11 @@ pub fn labeled_checkbox<'a, Message: Clone + 'a>( /// A section header with optional separator line. pub fn section_header<'a, Message: 'a>(label: &str) -> Element<'a, Message> { - column![ - text(label.to_string()) - .size(11) - .color(SECTION_COLOR) - .shaping(Shaping::Advanced), - container(Space::new(0, 0)) - .width(Length::Fill) - .height(Length::Fixed(1.0)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color::from_rgb(0.25, 0.25, 0.30))), - ..container::Style::default() - }), - ] - .spacing(4) - .into() + text(label.to_string()) + .size(12) + .color(SECTION_COLOR) + .shaping(Shaping::Advanced) + .into() } /// Primary action button style. @@ -92,7 +181,7 @@ pub fn primary_button(theme: &Theme, status: button::Status) -> button::Style { background: Some(Background::Color(PRIMARY_HOVER)), text_color: Color::WHITE, border: iced::Border { - radius: 4.0.into(), + radius: 6.0.into(), ..iced::Border::default() }, ..button::Style::default() @@ -101,7 +190,7 @@ pub fn primary_button(theme: &Theme, status: button::Status) -> button::Style { background: Some(Background::Color(PRIMARY_BG)), text_color: Color::WHITE, border: iced::Border { - radius: 4.0.into(), + radius: 6.0.into(), ..iced::Border::default() }, ..button::Style::default() @@ -117,7 +206,7 @@ pub fn danger_button(theme: &Theme, status: button::Status) -> button::Style { background: Some(Background::Color(DANGER_HOVER)), text_color: Color::WHITE, border: iced::Border { - radius: 4.0.into(), + radius: 6.0.into(), ..iced::Border::default() }, ..button::Style::default() @@ -126,7 +215,7 @@ pub fn danger_button(theme: &Theme, status: button::Status) -> button::Style { background: Some(Background::Color(DANGER_BG)), text_color: Color::WHITE, border: iced::Border { - radius: 4.0.into(), + radius: 6.0.into(), ..iced::Border::default() }, ..button::Style::default() @@ -134,6 +223,66 @@ pub fn danger_button(theme: &Theme, status: button::Status) -> button::Style { } } +/// Secondary editor action button style. +pub fn secondary_button(_theme: &Theme, status: button::Status) -> button::Style { + let background = match status { + button::Status::Hovered => SECONDARY_HOVER, + button::Status::Disabled => SECONDARY_BG.scale_alpha(0.45), + _ => SECONDARY_BG, + }; + button::Style { + background: Some(Background::Color(background)), + text_color: if matches!(status, button::Status::Disabled) { + LABEL_COLOR.scale_alpha(0.55) + } else { + Color::from_rgb8(0xE4, 0xE4, 0xE4) + }, + border: Border { + color: BORDER_COLOR, + width: 1.0, + radius: 6.0.into(), + }, + ..button::Style::default() + } +} + +/// Hoverable disclosure row style for collapsible editor sections. +pub fn disclosure_button(_theme: &Theme, status: button::Status) -> button::Style { + button::Style { + background: match status { + button::Status::Hovered => Some(Background::Color(SECONDARY_BG)), + _ => None, + }, + text_color: SECTION_COLOR, + border: Border { + radius: 6.0.into(), + ..Border::default() + }, + ..button::Style::default() + } +} + +/// Raised surface used to group editor metadata and controls. +pub fn card<'a, Message: 'a>(content: impl Into>) -> Container<'a, Message> { + container(content) + .padding(16) + .width(Length::Fill) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(SURFACE_BG)), + border: Border { + color: Color::from_rgb8(0x31, 0x31, 0x33), + width: 1.0, + radius: 10.0.into(), + }, + shadow: Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.28), + offset: Vector::new(0.0, 4.0), + blur_radius: 16.0, + }, + ..container::Style::default() + }) +} + /// Toolbar-style row with right-aligned actions. pub fn toolbar<'a, Message: 'a>( left: Vec>, @@ -151,12 +300,29 @@ pub fn toolbar<'a, Message: 'a>( .align_y(Alignment::Center) .wrap(); - row![left_row, right_row] - .padding(8) - .spacing(8) - .align_y(Alignment::Center) - .width(Length::Fill) - .into() + container( + row![left_row, right_row] + .spacing(10) + .align_y(Alignment::Center) + .width(Length::Fill), + ) + .padding(10) + .width(Length::Fill) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(SURFACE_BG)), + border: Border { + color: Color::from_rgb8(0x31, 0x31, 0x33), + width: 1.0, + radius: 10.0.into(), + }, + shadow: Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.2), + offset: Vector::new(0.0, 2.0), + blur_radius: 10.0, + }, + ..container::Style::default() + }) + .into() } /// Date display (read-only, locale-formatted). @@ -184,3 +350,31 @@ fn format_timestamp(ms: i64) -> String { let min = ((secs % 3600) / 60) as u32; format!("{y}-{m:02}-{d:02} {h:02}:{min:02}") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn editor_controls_have_distinct_hover_and_focus_states() { + let theme = app_theme(); + assert_ne!( + primary_button(&theme, button::Status::Active).background, + primary_button(&theme, button::Status::Hovered).background + ); + assert_ne!( + field_style(&theme, text_input::Status::Active).border.color, + field_style(&theme, text_input::Status::Focused) + .border + .color + ); + assert_ne!( + text_editor_style(&theme, text_editor::Status::Active) + .border + .color, + text_editor_style(&theme, text_editor::Status::Focused) + .border + .color + ); + } +} diff --git a/crates/bds-ui/src/main.rs b/crates/bds-ui/src/main.rs index a546183..4b9b73a 100644 --- a/crates/bds-ui/src/main.rs +++ b/crates/bds-ui/src/main.rs @@ -1,6 +1,7 @@ #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] use bds_ui::BdsApp; +use bds_ui::components::inputs; fn main() -> iced::Result { let icon = @@ -9,7 +10,7 @@ fn main() -> iced::Result { iced::application("bDS", BdsApp::update, BdsApp::view) .subscription(BdsApp::subscription) - .theme(|_| iced::Theme::Dark) + .theme(|_| inputs::app_theme()) .window(iced::window::Settings { size: iced::Size::new(1200.0, 800.0), icon: Some(icon), diff --git a/crates/bds-ui/src/views/activity_bar.rs b/crates/bds-ui/src/views/activity_bar.rs index b5c949e..e8eaf18 100644 --- a/crates/bds-ui/src/views/activity_bar.rs +++ b/crates/bds-ui/src/views/activity_bar.rs @@ -54,7 +54,7 @@ fn active_button_style(_theme: &Theme, _status: button::Status) -> button::Style border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 0.0.into(), + radius: 6.0.into(), }, ..button::Style::default() } @@ -72,7 +72,7 @@ fn inactive_button_style(_theme: &Theme, status: button::Status) -> button::Styl border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 0.0.into(), + radius: 6.0.into(), }, ..button::Style::default() } diff --git a/crates/bds-ui/src/views/media_editor.rs b/crates/bds-ui/src/views/media_editor.rs index c037a66..9fd9f2f 100644 --- a/crates/bds-ui/src/views/media_editor.rs +++ b/crates/bds-ui/src/views/media_editor.rs @@ -238,6 +238,7 @@ pub fn view<'a>( .on_press_maybe( ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi)), ) + .style(inputs::secondary_button) .padding([6, 16]) .into() } else { @@ -247,12 +248,14 @@ pub fn view<'a>( .on_press_maybe( ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::DetectLanguage)), ) + .style(inputs::secondary_button) .padding([6, 16]) .into(), button(text(t(locale, "editor.translate")).size(13)) .on_press_maybe( ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::TranslateMetadata)), ) + .style(inputs::secondary_button) .padding([6, 16]) .into(), button(text(t(locale, "common.save")).size(13)) @@ -287,7 +290,11 @@ pub fn view<'a>( flag.language.clone(), ))) .padding([4, 8]) - .style(|_: &iced::Theme, _| button::Style::default()) + .style(if flag.is_active { + inputs::primary_button + } else { + inputs::secondary_button + }) .into() }) .collect(); @@ -331,6 +338,7 @@ pub fn view<'a>( .color(Color::from_rgb(0.55, 0.58, 0.65)), ] .padding(8); + let preview_card = inputs::card(column![preview, info].spacing(4)).padding(8); // Metadata fields let title_input = inputs::labeled_input( @@ -385,6 +393,7 @@ pub fn view<'a>( Space::with_width(Length::Fill), button(text(t(locale, "editor.linkToPost")).size(12)) .on_press(Message::MediaEditor(MediaEditorMsg::TogglePostPicker)) + .style(inputs::secondary_button) .padding([4, 10]), ] .align_y(iced::Alignment::Center) @@ -478,19 +487,29 @@ pub fn view<'a>( ] .padding(8); - let body = scrollable( + let metadata = inputs::card( column![ - header, - flags_bar, - preview, - info, inputs::section_header(&t(locale, "editor.metadata")), meta_row1, meta_row2, meta_row3, - linked_posts_header, - post_picker, - linked_posts_list, + ] + .spacing(12) + .width(Length::Fill), + ); + let linked_posts = inputs::card( + column![linked_posts_header, post_picker, linked_posts_list] + .spacing(10) + .width(Length::Fill), + ); + + let body = scrollable( + column![ + header, + flags_bar, + preview_card, + metadata, + linked_posts, footer, ] .spacing(12) diff --git a/crates/bds-ui/src/views/modal.rs b/crates/bds-ui/src/views/modal.rs index 17db4d2..913cf14 100644 --- a/crates/bds-ui/src/views/modal.rs +++ b/crates/bds-ui/src/views/modal.rs @@ -4,13 +4,14 @@ use iced::widget::text::Shaping; use iced::widget::{ Space, button, checkbox, column, container, image, row, scrollable, text, text_input, }; -use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; +use iced::{Alignment, Background, Border, Color, Element, Length, Shadow, Theme, Vector}; use bds_core::i18n::UiLocale; use bds_core::model::Media; use bds_core::util::paths::thumbnail_path; use crate::app::Message; +use crate::components::inputs; use crate::i18n::t; use crate::views::post_editor::PostEditorMsg; @@ -138,61 +139,27 @@ fn modal_box_style(_theme: &Theme) -> container::Style { border: Border { color: Color::from_rgb(0.30, 0.30, 0.35), width: 1.0, - radius: 8.0.into(), + radius: 10.0.into(), + }, + shadow: Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.4), + offset: Vector::new(0.0, 8.0), + blur_radius: 28.0, }, ..container::Style::default() } } -fn cancel_button_style(_theme: &Theme, status: button::Status) -> button::Style { - let bg = match status { - button::Status::Hovered => Color::from_rgb(0.28, 0.28, 0.33), - _ => Color::from_rgb(0.22, 0.22, 0.27), - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: Color::from_rgb(0.80, 0.80, 0.85), - border: Border { - color: Color::from_rgb(0.35, 0.35, 0.40), - width: 1.0, - radius: 4.0.into(), - }, - ..button::Style::default() - } +fn cancel_button_style(theme: &Theme, status: button::Status) -> button::Style { + inputs::secondary_button(theme, status) } -fn danger_button_style(_theme: &Theme, status: button::Status) -> button::Style { - let bg = match status { - button::Status::Hovered => Color::from_rgb(0.85, 0.20, 0.20), - _ => Color::from_rgb(0.75, 0.15, 0.15), - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: Color::WHITE, - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 4.0.into(), - }, - ..button::Style::default() - } +fn danger_button_style(theme: &Theme, status: button::Status) -> button::Style { + inputs::danger_button(theme, status) } -fn confirm_button_style(_theme: &Theme, status: button::Status) -> button::Style { - let bg = match status { - button::Status::Hovered => Color::from_rgb(0.20, 0.55, 0.85), - _ => Color::from_rgb(0.15, 0.45, 0.75), - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: Color::WHITE, - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 4.0.into(), - }, - ..button::Style::default() - } +fn confirm_button_style(theme: &Theme, status: button::Status) -> button::Style { + inputs::primary_button(theme, status) } fn resolve_thumbnail_file(data_dir: Option<&Path>, media: &Media) -> Option { @@ -471,7 +438,8 @@ pub fn view( .size(13) .on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkSearch(s))) .padding([6, 10]) - .width(Length::Fill); + .width(Length::Fill) + .style(inputs::field_style); let internal_content: Element<'static, Message> = if results.is_empty() { column![ @@ -540,7 +508,7 @@ pub fn view( border: Border { color: Color::from_rgb(0.30, 0.30, 0.35), width: 1.0, - radius: 4.0.into(), + radius: 6.0.into(), }, ..button::Style::default() }), @@ -557,7 +525,8 @@ pub fn view( .size(13) .on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkUrlChanged(s))) .padding([6, 10]) - .width(Length::Fill), + .width(Length::Fill) + .style(inputs::field_style), text_input( t(locale, "modal.postInsertLink.externalTextPlaceholder").as_str(), &external_text, @@ -565,7 +534,8 @@ pub fn view( .size(13) .on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkTextChanged(s))) .padding([6, 10]) - .width(Length::Fill), + .width(Length::Fill) + .style(inputs::field_style), Space::with_height(12.0), row![ Space::with_width(Length::Fill), @@ -594,7 +564,7 @@ pub fn view( border: Border { color: Color::from_rgb(0.30, 0.30, 0.35), width: 1.0, - radius: 4.0.into(), + radius: 6.0.into(), }, ..button::Style::default() }) @@ -659,7 +629,8 @@ pub fn view( .size(13) .on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertMediaSearch(s))) .padding([6, 10]) - .width(Length::Fill); + .width(Length::Fill) + .style(inputs::field_style); let media_items: Vec> = media_list .iter() diff --git a/crates/bds-ui/src/views/panel.rs b/crates/bds-ui/src/views/panel.rs index d4affa2..c93097d 100644 --- a/crates/bds-ui/src/views/panel.rs +++ b/crates/bds-ui/src/views/panel.rs @@ -26,7 +26,7 @@ fn tab_active(_theme: &Theme, _status: button::Status) -> button::Style { border: Border { color: Color::from_rgb(0.30, 0.55, 0.90), width: 0.0, - radius: 3.0.into(), + radius: 6.0.into(), }, ..button::Style::default() } @@ -44,7 +44,7 @@ fn tab_inactive(_theme: &Theme, status: button::Status) -> button::Style { border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 3.0.into(), + radius: 6.0.into(), }, ..button::Style::default() } diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 1d366ab..b32035a 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -396,7 +396,7 @@ pub fn view<'a>( ) .on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::ToggleQuickActions))) .padding([6, 16]) - .style(status_bar::dropdown_trigger) + .style(inputs::secondary_button) .into(); let mut header_action_items: Vec> = vec![ @@ -421,6 +421,7 @@ pub fn view<'a>( ) .on_press(Message::PostEditor(PostEditorMsg::Duplicate)) .padding([6, 16]) + .style(inputs::secondary_button) .into(), ); } @@ -446,6 +447,7 @@ pub fn view<'a>( ) .on_press(Message::PostEditor(PostEditorMsg::Discard)) .padding([6, 16]) + .style(inputs::secondary_button) .into(), ); } @@ -520,7 +522,7 @@ pub fn view<'a>( Space::new(0, 0).into() }; - let header = column![header_row, header_menu].spacing(6); + let header = inputs::card(column![header_row, header_menu].spacing(6)).padding(10); // ── Collapsible Metadata Section ── let meta_toggle_label = if state.metadata_expanded { @@ -535,15 +537,13 @@ pub fn view<'a>( .shaping(Shaping::Advanced), ) .on_press(Message::PostEditor(PostEditorMsg::ToggleMetadata)) - .padding([4, 0]) - .style(|_, _| button::Style { - background: None, - ..button::Style::default() - }); + .padding([8, 10]) + .width(Length::Fill) + .style(inputs::disclosure_button); // ── Translation Flags Bar (inline with metadata toggle) ── let flags = state.translation_flags(); - let meta_toggle_row: Element<'a, Message> = if flags.is_empty() { + let meta_toggle_content: Element<'a, Message> = if flags.is_empty() { meta_toggle.into() } else { let mut flag_row = row![].spacing(2); @@ -560,11 +560,13 @@ pub fn view<'a>( }); flag_row = flag_row.push(btn); } - row![meta_toggle, Space::with_width(Length::Fill), flag_row] + row![meta_toggle, flag_row] .spacing(8) .align_y(iced::Alignment::Center) .into() }; + let meta_toggle_row: Element<'a, Message> = + inputs::card(meta_toggle_content).padding([2, 4]).into(); let metadata_section: Element<'a, Message> = if state.metadata_expanded && !on_translation { let title_input = inputs::labeled_input( @@ -598,13 +600,6 @@ pub fn view<'a>( Some(&state.language), |lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)), ); - let detect_language = button( - text(t(locale, "editor.detectLanguage")) - .size(12) - .shaping(Shaping::Advanced), - ) - .on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::DetectLanguage))) - .padding([6, 12]); let template_input = inputs::labeled_input( &t(locale, "editor.templateSlug"), "", @@ -616,9 +611,9 @@ pub fn view<'a>( state.do_not_translate, |b| Message::PostEditor(PostEditorMsg::ToggleDoNotTranslate(b)), ); - let language_block = column![language_input, detect_language].spacing(6); - let meta_row2 = row![author_input, language_block, template_input, dnt] + let meta_row2 = row![author_input, language_input, template_input, dnt] .spacing(16) + .align_y(iced::Alignment::End) .width(Length::Fill); // Tags chip input @@ -695,7 +690,7 @@ pub fn view<'a>( ) .on_press(Message::PostEditor(PostEditorMsg::LinkExistingMedia)) .padding([4, 10]) - .style(|_: &Theme, _| button::Style::default()) + .style(inputs::secondary_button) .into(); let linked_media_header: Element<'a, Message> = row![ @@ -783,17 +778,19 @@ pub fn view<'a>( Column::with_children(items).spacing(6).into() }; - column![ - meta_row1, - meta_row2, - tags_section, - categories_section, - outlinks_section, - backlinks_section, - linked_media_section, - ] - .spacing(8) - .width(Length::Fill) + inputs::card( + column![ + meta_row1, + meta_row2, + tags_section, + categories_section, + outlinks_section, + backlinks_section, + linked_media_section, + ] + .spacing(12) + .width(Length::Fill), + ) .into() } else { Space::new(0, 0).into() @@ -812,19 +809,19 @@ pub fn view<'a>( .shaping(Shaping::Advanced), ) .on_press(Message::PostEditor(PostEditorMsg::ToggleExcerpt)) - .padding([4, 0]) - .style(|_, _| button::Style { - background: None, - ..button::Style::default() - }); + .padding([8, 10]) + .width(Length::Fill) + .style(inputs::disclosure_button); + let excerpt_toggle: Element<'a, Message> = inputs::card(excerpt_toggle).padding([2, 4]).into(); let excerpt_section: Element<'a, Message> = if state.excerpt_expanded { - inputs::labeled_input( + inputs::card(inputs::labeled_input( &t(locale, "editor.excerpt"), &t(locale, "editor.excerptPlaceholder"), &state.excerpt, |s| Message::PostEditor(PostEditorMsg::ExcerptChanged(s)), - ) + )) + .into() } else { Space::new(0, 0).into() }; @@ -866,6 +863,7 @@ pub fn view<'a>( ) .on_press(Message::PostEditor(PostEditorMsg::InsertLink)) .padding([6, 16]) + .style(inputs::secondary_button) .into() } else { Space::new(0, 0).into() @@ -878,6 +876,7 @@ pub fn view<'a>( ) .on_press(Message::PostEditor(PostEditorMsg::InsertMedia)) .padding([6, 16]) + .style(inputs::secondary_button) .into() } else { Space::new(0, 0).into() @@ -889,6 +888,7 @@ pub fn view<'a>( ) .on_press(Message::PostEditor(PostEditorMsg::Gallery)) .padding([6, 16]) + .style(inputs::secondary_button) .into(), ], ); @@ -946,14 +946,14 @@ pub fn view<'a>( excerpt_toggle, excerpt_section, ] - .spacing(4) + .spacing(8) .width(Length::Fill), ) .height(Length::Shrink); // ── Full layout: top pane (shrink), editor (fill), footer (shrink) ── column![top_pane, body_toolbar, editor_widget, footer,] - .spacing(4) + .spacing(8) .padding(16) .width(Length::Fill) .height(Length::Fill) @@ -998,7 +998,8 @@ fn chip_input_field<'a>( .on_input(on_input) .on_submit(on_submit) .size(13) - .padding([4, 6]), + .padding([8, 10]) + .style(inputs::field_style), ] .spacing(4) .width(Length::Fill) diff --git a/crates/bds-ui/src/views/project_selector.rs b/crates/bds-ui/src/views/project_selector.rs index 1e19182..3a2535f 100644 --- a/crates/bds-ui/src/views/project_selector.rs +++ b/crates/bds-ui/src/views/project_selector.rs @@ -18,7 +18,7 @@ fn dropdown_bg(_theme: &Theme) -> container::Style { border: Border { color: Color::from_rgb(0.30, 0.30, 0.35), width: 1.0, - radius: 4.0.into(), + radius: 10.0.into(), }, ..container::Style::default() } @@ -35,7 +35,7 @@ fn project_item(_theme: &Theme, status: button::Status) -> button::Style { border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 2.0.into(), + radius: 6.0.into(), }, ..button::Style::default() } @@ -52,7 +52,7 @@ fn project_item_active(_theme: &Theme, status: button::Status) -> button::Style border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 2.0.into(), + radius: 6.0.into(), }, ..button::Style::default() } @@ -69,7 +69,7 @@ fn new_project_btn(_theme: &Theme, status: button::Status) -> button::Style { border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 2.0.into(), + radius: 6.0.into(), }, ..button::Style::default() } @@ -214,7 +214,7 @@ pub fn trigger_style(_theme: &Theme, status: button::Status) -> button::Style { border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 3.0.into(), + radius: 6.0.into(), }, ..button::Style::default() } diff --git a/crates/bds-ui/src/views/script_editor.rs b/crates/bds-ui/src/views/script_editor.rs index 02a159c..4984771 100644 --- a/crates/bds-ui/src/views/script_editor.rs +++ b/crates/bds-ui/src/views/script_editor.rs @@ -132,10 +132,12 @@ pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, M .into(), button(text(t(locale, "editor.run")).size(13)) .on_press(Message::ScriptEditor(ScriptEditorMsg::Run)) + .style(inputs::secondary_button) .padding([6, 16]) .into(), button(text(t(locale, "editor.checkSyntax")).size(13)) .on_press(Message::ScriptEditor(ScriptEditorMsg::CheckSyntax)) + .style(inputs::secondary_button) .padding([6, 16]) .into(), button(text(t(locale, "modal.confirmDelete.delete")).size(13)) @@ -200,6 +202,7 @@ pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, M }); let meta_row2 = row![kind_select, entrypoint_input, enabled_check] .spacing(16) + .align_y(iced::Alignment::End) .width(Length::Fill); // Content editor (CodeEditor with syntax highlighting based on file extension) @@ -208,21 +211,28 @@ pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, M } else { "lua" }; - let content_section: Element<'a, Message> = column![ - inputs::section_header(&t(locale, "editor.content")), - Element::from( - CodeEditor::new(&state.editor_buffer, highlighter(), syntax_ext,).on_change(|msg| { - match msg { - EditorMessage::ContentChanged(s) => { - Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s)) + let content_section: Element<'a, Message> = inputs::card( + column![ + inputs::section_header(&t(locale, "editor.content")), + Element::from( + CodeEditor::new(&state.editor_buffer, highlighter(), syntax_ext,).on_change( + |msg| { + match msg { + EditorMessage::ContentChanged(s) => { + Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s)) + } + EditorMessage::SaveRequested => { + Message::ScriptEditor(ScriptEditorMsg::Save) + } + } } - EditorMessage::SaveRequested => Message::ScriptEditor(ScriptEditorMsg::Save), - } - }) - ), - ] - .spacing(8) - .width(Length::Fill) + ) + ), + ] + .spacing(10) + .width(Length::Fill) + .height(Length::Fill), + ) .height(Length::Fill) .into(); @@ -248,18 +258,18 @@ pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, M .padding(8); // Top pane: header + metadata (scrollable for overflow) - let top_pane = scrollable( - column![header, meta_row1, meta_row2,] + let metadata = inputs::card( + column![meta_row1, meta_row2] .spacing(12) - .padding(16) .width(Length::Fill), - ) - .height(Length::Shrink); + ); + let top_pane = scrollable(column![header, metadata].spacing(12).width(Length::Fill)) + .height(Length::Shrink); // Full layout: top pane (shrink), content (fill), validation + footer (shrink) column![top_pane, content_section, validation, footer,] - .spacing(4) - .padding([0, 16]) + .spacing(8) + .padding(16) .width(Length::Fill) .height(Length::Fill) .into() diff --git a/crates/bds-ui/src/views/settings_view.rs b/crates/bds-ui/src/views/settings_view.rs index 54f9c47..9da539f 100644 --- a/crates/bds-ui/src/views/settings_view.rs +++ b/crates/bds-ui/src/views/settings_view.rs @@ -1,6 +1,6 @@ use iced::widget::text::Shaping; use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input}; -use iced::{Alignment, Color, Element, Length, Theme}; +use iced::{Alignment, Color, Element, Length}; use bds_core::i18n::UiLocale; @@ -347,7 +347,9 @@ pub enum SettingsMsg { pub fn view<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query) .on_input(|s| Message::Settings(SettingsMsg::SearchChanged(s))) - .size(14); + .size(14) + .padding([8, 10]) + .style(inputs::field_style); let query_lower = state.search_query.to_lowercase(); @@ -369,6 +371,7 @@ pub fn view<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, M .color(Color::from_rgb(0.7, 0.72, 0.78)), button(text(t(locale, "common.clear")).size(13)) .on_press(Message::Settings(SettingsMsg::SearchChanged(String::new()))) + .style(inputs::secondary_button) .padding([6, 12]), ] .spacing(8) @@ -413,10 +416,10 @@ fn render_section<'a>( ))) .padding([6, 8]) .width(Length::Fill) - .style(|_: &Theme, _| button::Style::default()); + .style(inputs::disclosure_button); if collapsed { - return column![header].into(); + return inputs::card(header).padding([2, 4]).into(); } let content: Element<'a, Message> = match section { @@ -430,7 +433,7 @@ fn render_section<'a>( SettingsSection::MCP => section_mcp(locale), }; - column![header, content].spacing(4).into() + inputs::card(column![header, content].spacing(8)).into() } fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { @@ -448,7 +451,8 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen text_editor(&state.project_description) .on_action(|a| Message::Settings(SettingsMsg::ProjectDescriptionAction(a))) .height(Length::Fixed(80.0)) - .size(14), + .size(14) + .style(inputs::text_editor_style), ] .spacing(4); let data_path = row![ @@ -457,9 +461,11 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen },), button(text(t(locale, "settings.browse")).size(12)) .on_press(Message::Settings(SettingsMsg::BrowseDataPath)) + .style(inputs::secondary_button) .padding([6, 12]), button(text(t(locale, "settings.reset")).size(12)) .on_press(Message::Settings(SettingsMsg::ResetDataPath)) + .style(inputs::secondary_button) .padding([6, 12]), ] .spacing(8) @@ -694,7 +700,7 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen .spacing(8); column.push( - container( + inputs::card( column![ text(category.name.clone()).size(15).color(Color::WHITE), title, @@ -721,6 +727,7 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen .padding([6, 12]), button(text(t(locale, "settings.resetCategories")).size(13)) .on_press(Message::Settings(SettingsMsg::ResetCategoriesToDefaults)) + .style(inputs::secondary_button) .padding([6, 12]), ] .spacing(8) @@ -788,6 +795,7 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, ); let online_refresh = button(text(t(locale, "settings.refreshModels")).size(13)) .on_press(Message::Settings(SettingsMsg::RefreshOnlineModels)) + .style(inputs::secondary_button) .padding([6, 16]); let airplane_url = inputs::labeled_input( @@ -804,6 +812,7 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, ); let airplane_refresh = button(text(t(locale, "settings.refreshModels")).size(13)) .on_press(Message::Settings(SettingsMsg::RefreshAirplaneModels)) + .style(inputs::secondary_button) .padding([6, 16]); let default_model = inputs::labeled_select( @@ -838,7 +847,8 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, text_editor(&state.system_prompt) .on_action(|a| Message::Settings(SettingsMsg::SystemPromptAction(a))) .height(Length::Fixed(200.0)) - .size(14), + .size(14) + .style(inputs::text_editor_style), ] .spacing(4); let btns = row![ @@ -848,6 +858,7 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, .padding([6, 16]), button(text(t(locale, "settings.resetToDefault")).size(13)) .on_press(Message::Settings(SettingsMsg::ResetSystemPrompt)) + .style(inputs::secondary_button) .padding([6, 16]), ] .spacing(8); @@ -930,30 +941,37 @@ fn section_data<'a>(locale: UiLocale) -> Element<'a, Message> { let rebuild_btns = column![ button(text(t(locale, "settings.rebuildPosts")).size(13)) .on_press(Message::Settings(SettingsMsg::RebuildPosts)) + .style(inputs::secondary_button) .padding([6, 16]) .width(Length::Fill), button(text(t(locale, "settings.rebuildMedia")).size(13)) .on_press(Message::Settings(SettingsMsg::RebuildMedia)) + .style(inputs::secondary_button) .padding([6, 16]) .width(Length::Fill), button(text(t(locale, "settings.rebuildScripts")).size(13)) .on_press(Message::Settings(SettingsMsg::RebuildScripts)) + .style(inputs::secondary_button) .padding([6, 16]) .width(Length::Fill), button(text(t(locale, "settings.rebuildTemplates")).size(13)) .on_press(Message::Settings(SettingsMsg::RebuildTemplates)) + .style(inputs::secondary_button) .padding([6, 16]) .width(Length::Fill), button(text(t(locale, "settings.rebuildLinks")).size(13)) .on_press(Message::Settings(SettingsMsg::RebuildLinks)) + .style(inputs::secondary_button) .padding([6, 16]) .width(Length::Fill), button(text(t(locale, "settings.rebuildSearchIndex")).size(13)) .on_press(Message::Settings(SettingsMsg::RebuildSearchIndex)) + .style(inputs::secondary_button) .padding([6, 16]) .width(Length::Fill), button(text(t(locale, "settings.regenerateThumbnails")).size(13)) .on_press(Message::Settings(SettingsMsg::RegenerateThumbnails)) + .style(inputs::secondary_button) .padding([6, 16]) .width(Length::Fill), ] @@ -961,6 +979,7 @@ fn section_data<'a>(locale: UiLocale) -> Element<'a, Message> { let open = button(text(t(locale, "settings.openDataFolder")).size(13)) .on_press(Message::Settings(SettingsMsg::OpenDataFolder)) + .style(inputs::secondary_button) .padding([6, 16]); column![rebuild_btns, open] diff --git a/crates/bds-ui/src/views/sidebar.rs b/crates/bds-ui/src/views/sidebar.rs index cfda441..87da183 100644 --- a/crates/bds-ui/src/views/sidebar.rs +++ b/crates/bds-ui/src/views/sidebar.rs @@ -8,6 +8,7 @@ use bds_core::i18n::UiLocale; use bds_core::model::{Media, Post, Script, Template}; use crate::app::Message; +use crate::components::inputs; use crate::i18n::t; use crate::state::navigation::SidebarView; use crate::state::sidebar_filter::{CalendarYear, MediaFilter, PostFilter}; @@ -30,7 +31,10 @@ fn item_style(_theme: &Theme, status: button::Status) -> button::Style { button::Style { background: Some(Background::Color(bg)), text_color: Color::from_rgb(0.80, 0.80, 0.85), - border: Border::default(), + border: Border { + radius: 6.0.into(), + ..Border::default() + }, ..button::Style::default() } } @@ -44,7 +48,10 @@ fn item_active_style(_theme: &Theme, status: button::Status) -> button::Style { button::Style { background: Some(Background::Color(bg)), text_color: Color::WHITE, - border: Border::default(), + border: Border { + radius: 6.0.into(), + ..Border::default() + }, ..button::Style::default() } } @@ -54,7 +61,7 @@ fn thumbnail_container_style(_theme: &Theme) -> container::Style { container::Style { background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.17))), border: Border { - radius: 4.0.into(), + radius: 6.0.into(), ..Border::default() }, ..container::Style::default() @@ -145,19 +152,8 @@ fn format_post_date(unix_ms: i64) -> String { } /// Search input style. -fn search_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { - text_input::Style { - background: Background::Color(Color::from_rgb(0.12, 0.12, 0.15)), - border: Border { - color: Color::from_rgb(0.30, 0.30, 0.35), - width: 1.0, - radius: 3.0.into(), - }, - icon: Color::from_rgb(0.50, 0.50, 0.55), - placeholder: Color::from_rgb(0.45, 0.45, 0.50), - value: Color::from_rgb(0.85, 0.85, 0.90), - selection: Color::from_rgba(0.35, 0.55, 0.85, 0.4), - } +fn search_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style { + inputs::field_style(theme, status) } /// Style for filter toggle / clear buttons in the header. @@ -169,7 +165,10 @@ fn filter_button_style(_theme: &Theme, status: button::Status) -> button::Style button::Style { background: Some(Background::Color(bg)), text_color: Color::from_rgb(0.60, 0.60, 0.65), - border: Border::default(), + border: Border { + radius: 6.0.into(), + ..Border::default() + }, ..button::Style::default() } } @@ -183,7 +182,10 @@ fn filter_button_active_style(_theme: &Theme, status: button::Status) -> button: button::Style { background: Some(Background::Color(bg)), text_color: Color::from_rgb(0.55, 0.70, 0.95), - border: Border::default(), + border: Border { + radius: 6.0.into(), + ..Border::default() + }, ..button::Style::default() } } @@ -773,7 +775,7 @@ pub fn view( is_transient: true, is_dirty: false, })) - .padding([3, 6]) + .padding([5, 8]) .width(Length::Fill) .style(style_fn) .into() @@ -1025,7 +1027,7 @@ pub fn view( is_transient: true, is_dirty: false, })) - .padding([3, 6]) + .padding([5, 8]) .width(Length::Fill) .style(style_fn) .into() @@ -1065,7 +1067,7 @@ pub fn view( is_transient: true, is_dirty: false, })) - .padding([3, 6]) + .padding([5, 8]) .width(Length::Fill) .style(style_fn) .into() @@ -1106,7 +1108,7 @@ pub fn view( }; button(container(label_text).width(Length::Fill)) .on_press(msg) - .padding([3, 6]) + .padding([5, 8]) .width(Length::Fill) .style(item_style) .into() @@ -1130,7 +1132,7 @@ pub fn view( is_transient: false, is_dirty: false, })) - .padding([3, 6]) + .padding([5, 8]) .width(Length::Fill) .style(item_style) .into() diff --git a/crates/bds-ui/src/views/site_validation.rs b/crates/bds-ui/src/views/site_validation.rs index 004880e..5493b51 100644 --- a/crates/bds-ui/src/views/site_validation.rs +++ b/crates/bds-ui/src/views/site_validation.rs @@ -5,6 +5,7 @@ use iced::{Background, Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; use crate::app::Message; +use crate::components::inputs; use crate::i18n::t; #[derive(Debug, Clone, Default)] @@ -25,6 +26,8 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, .size(13) .shaping(Shaping::Advanced), ) + .style(inputs::primary_button) + .padding([6, 16]) } else { button( text(t(locale, "siteValidation.run")) @@ -32,6 +35,8 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, .shaping(Shaping::Advanced), ) .on_press(Message::RunSiteValidation) + .style(inputs::primary_button) + .padding([6, 16]) }; let has_issues = !state.missing_files.is_empty() || !state.extra_files.is_empty() @@ -42,6 +47,8 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, .size(13) .shaping(Shaping::Advanced), ) + .style(inputs::secondary_button) + .padding([6, 16]) } else if !state.is_running && state.error_message.is_none() && has_issues { button( text(t(locale, "siteValidation.apply")) @@ -49,12 +56,16 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, .shaping(Shaping::Advanced), ) .on_press(Message::ApplySiteValidation) + .style(inputs::primary_button) + .padding([6, 16]) } else { button( text(t(locale, "siteValidation.apply")) .size(13) .shaping(Shaping::Advanced), ) + .style(inputs::secondary_button) + .padding([6, 16]) }; let mut content = column![ @@ -132,17 +143,12 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, } fn help_text<'a>(value: String) -> Element<'a, Message> { - container( + inputs::card( text(value) .size(14) .shaping(Shaping::Advanced) .color(Color::from_rgb(0.66, 0.66, 0.72)), ) - .padding(16) - .style(|_theme: &Theme| container::Style { - background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.18))), - ..container::Style::default() - }) .into() } @@ -156,7 +162,7 @@ fn section<'a>(title: String, entries: &'a [String], accent: Color) -> Element<' ) }); - container( + inputs::card( column![ text(title.to_string()) .size(16) @@ -166,10 +172,5 @@ fn section<'a>(title: String, entries: &'a [String], accent: Color) -> Element<' ] .spacing(10), ) - .padding(16) - .style(|_theme: &Theme| container::Style { - background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.18))), - ..container::Style::default() - }) .into() } diff --git a/crates/bds-ui/src/views/status_bar.rs b/crates/bds-ui/src/views/status_bar.rs index f8b78bd..ce6e0ac 100644 --- a/crates/bds-ui/src/views/status_bar.rs +++ b/crates/bds-ui/src/views/status_bar.rs @@ -33,7 +33,7 @@ fn airplane_inactive(_theme: &Theme, status: button::Status) -> button::Style { border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 3.0.into(), + radius: 6.0.into(), }, ..button::Style::default() } @@ -51,7 +51,7 @@ fn airplane_active(_theme: &Theme, status: button::Status) -> button::Style { border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 3.0.into(), + radius: 6.0.into(), }, ..button::Style::default() } @@ -69,7 +69,7 @@ pub fn dropdown_trigger(_theme: &Theme, status: button::Status) -> button::Style border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 3.0.into(), + radius: 6.0.into(), }, ..button::Style::default() } @@ -87,7 +87,7 @@ pub fn dropdown_item(_theme: &Theme, status: button::Status) -> button::Style { border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 2.0.into(), + radius: 6.0.into(), }, ..button::Style::default() } @@ -100,7 +100,7 @@ pub fn dropdown_bg(_theme: &Theme) -> container::Style { border: Border { color: Color::from_rgb(0.30, 0.30, 0.35), width: 1.0, - radius: 4.0.into(), + radius: 6.0.into(), }, ..container::Style::default() } diff --git a/crates/bds-ui/src/views/tab_bar.rs b/crates/bds-ui/src/views/tab_bar.rs index e8899f7..e65cb69 100644 --- a/crates/bds-ui/src/views/tab_bar.rs +++ b/crates/bds-ui/src/views/tab_bar.rs @@ -41,7 +41,7 @@ fn tab_active(_theme: &Theme, _status: button::Status) -> button::Style { border: Border { color: Color::from_rgb(0.30, 0.55, 0.90), width: 0.0, - radius: 4.0.into(), + radius: 6.0.into(), }, ..button::Style::default() } @@ -59,7 +59,7 @@ fn tab_inactive(_theme: &Theme, status: button::Status) -> button::Style { border: Border { color: Color::TRANSPARENT, width: 0.0, - radius: 4.0.into(), + radius: 6.0.into(), }, ..button::Style::default() } @@ -86,7 +86,7 @@ fn tooltip_style(_theme: &Theme) -> container::Style { border: Border { color: Color::from_rgb(0.35, 0.35, 0.40), width: 1.0, - radius: 4.0.into(), + radius: 6.0.into(), }, ..container::Style::default() } diff --git a/crates/bds-ui/src/views/tags_view.rs b/crates/bds-ui/src/views/tags_view.rs index fc3c47b..fe1c93a 100644 --- a/crates/bds-ui/src/views/tags_view.rs +++ b/crates/bds-ui/src/views/tags_view.rs @@ -117,30 +117,32 @@ pub enum TagsMsg { /// Render the tags management view. pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> { - let section_nav = row![ - section_tab( - &t(locale, "tags.nav.cloud"), - state.section == TagsSection::Cloud, - TagsSection::Cloud - ), - section_tab( - &t(locale, "tags.nav.manage"), - state.section == TagsSection::Manage, - TagsSection::Manage - ), - section_tab( - &t(locale, "tags.nav.merge"), - state.section == TagsSection::Merge, - TagsSection::Merge - ), - section_tab( - &t(locale, "tags.nav.discover"), - state.section == TagsSection::Discover, - TagsSection::Discover - ), - ] - .spacing(4) - .padding(8); + let section_nav = inputs::card( + row![ + section_tab( + &t(locale, "tags.nav.cloud"), + state.section == TagsSection::Cloud, + TagsSection::Cloud + ), + section_tab( + &t(locale, "tags.nav.manage"), + state.section == TagsSection::Manage, + TagsSection::Manage + ), + section_tab( + &t(locale, "tags.nav.merge"), + state.section == TagsSection::Merge, + TagsSection::Merge + ), + section_tab( + &t(locale, "tags.nav.discover"), + state.section == TagsSection::Discover, + TagsSection::Discover + ), + ] + .spacing(6), + ) + .padding(6); let content: Element<'a, Message> = match state.section { TagsSection::Cloud => view_cloud(state, locale), @@ -150,33 +152,20 @@ pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Messa }; column![section_nav, content] + .spacing(4) .width(Length::Fill) .height(Length::Fill) .into() } fn section_tab<'a>(label: &str, active: bool, section: TagsSection) -> Element<'a, Message> { - let color = if active { - Color::WHITE - } else { - Color::from_rgb(0.55, 0.58, 0.65) - }; - button(text(label.to_string()).size(13).color(color)) + button(text(label.to_string()).size(13)) .on_press(Message::Tags(TagsMsg::SetSection(section))) .padding([6, 12]) - .style(move |_theme: &Theme, _status| { - if active { - button::Style { - background: Some(Background::Color(Color::from_rgb(0.25, 0.27, 0.33))), - border: iced::Border { - radius: 4.0.into(), - ..iced::Border::default() - }, - ..button::Style::default() - } - } else { - button::Style::default() - } + .style(if active { + inputs::primary_button + } else { + inputs::secondary_button }) .into() } @@ -212,8 +201,8 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes } else { 1.0 }; - const MIN_FONT: f32 = 11.0; - const MAX_FONT: f32 = 24.0; + const MIN_FONT: f32 = 12.0; + const MAX_FONT: f32 = 18.0; let chips: Vec> = state .tags @@ -243,16 +232,17 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes ) .on_press(Message::Tags(TagsMsg::ToggleTagSelection(tag.id.clone()))) .padding([vert_pad, 10]) - .style(move |_: &Theme, _| button::Style { - background: Some(Background::Color(color)), + .style(move |_: &Theme, status| button::Style { + background: Some(Background::Color(match (selected, status) { + (true, button::Status::Hovered) => Color::from_rgb(0.20, 0.24, 0.30), + (true, _) => Color::from_rgb(0.17, 0.20, 0.25), + (false, button::Status::Hovered) => Color::from_rgb(0.23, 0.24, 0.26), + (false, _) => Color::from_rgb(0.18, 0.18, 0.19), + })), border: iced::Border { radius: 12.0.into(), - width: if selected { 2.0 } else { 0.0 }, - color: if selected { - Color::WHITE - } else { - Color::TRANSPARENT - }, + width: if selected { 2.0 } else { 1.0 }, + color, }, text_color: Color::WHITE, ..button::Style::default() @@ -277,6 +267,7 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes .color(Color::from_rgb(0.75, 0.77, 0.82)), button(text(t(locale, "tags.clearSelection")).size(12)) .on_press(Message::Tags(TagsMsg::ClearSelection)) + .style(inputs::secondary_button) .padding([4, 8]), ] .spacing(8) @@ -285,13 +276,15 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes }; scrollable( - column![ - inputs::section_header(&t(locale, "tags.cloudSection")), - selection_summary, - row(chips).spacing(6).wrap(), - ] - .spacing(12) - .width(Length::Fill) + column![inputs::card( + column![ + inputs::section_header(&t(locale, "tags.cloudSection")), + selection_summary, + row(chips).spacing(6).wrap(), + ] + .spacing(12) + .width(Length::Fill), + )] .padding(16), ) .width(Length::Fill) @@ -326,7 +319,9 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query) .on_input(|value| Message::Tags(TagsMsg::SearchChanged(value))) - .size(14); + .size(14) + .padding([8, 10]) + .style(inputs::field_style); let filtered: Vec<&Tag> = state .tags @@ -446,11 +441,16 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me scrollable( column![ - create_panel, - inputs::section_header(&t(locale, "tags.manageSection")), - search, - tag_list, - edit_panel, + inputs::card(create_panel), + inputs::card( + column![ + inputs::section_header(&t(locale, "tags.manageSection")), + search, + tag_list, + edit_panel, + ] + .spacing(12) + ), ] .spacing(12) .padding(16) @@ -491,56 +491,62 @@ fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes }) .unwrap_or_default(); - column![ - inputs::section_header(&t(locale, "tags.mergeSection")), - text(tw( - locale, - "tags.selectedCount", - &[("count", &state.selected_tags.len().to_string())] - )) - .size(12) - .color(Color::from_rgb(0.75, 0.77, 0.82)), - inputs::labeled_select( - &t(locale, "tags.mergeTarget"), - &tag_options, - selected_target, - |choice| Message::Tags(TagsMsg::SetMergeTarget(choice)), - ), - if merge_preview.is_empty() { - Element::from(Space::new(0, 0)) - } else { - container(text(tw(locale, "tags.mergePreview", &[("tags", &merge_preview)])).size(12)) + container(inputs::card( + column![ + inputs::section_header(&t(locale, "tags.mergeSection")), + text(tw( + locale, + "tags.selectedCount", + &[("count", &state.selected_tags.len().to_string())] + )) + .size(12) + .color(Color::from_rgb(0.75, 0.77, 0.82)), + inputs::labeled_select( + &t(locale, "tags.mergeTarget"), + &tag_options, + selected_target, + |choice| Message::Tags(TagsMsg::SetMergeTarget(choice)), + ), + if merge_preview.is_empty() { + Element::from(Space::new(0, 0)) + } else { + container( + text(tw(locale, "tags.mergePreview", &[("tags", &merge_preview)])).size(12), + ) .padding([4, 0]) .into() - }, - button(text(t(locale, "tags.merge")).size(13)) - .on_press_maybe( - state - .merge_target - .is_some() - .then_some(Message::Tags(TagsMsg::MergeTags)) - ) - .style(inputs::primary_button) - .padding([6, 16]), - ] - .spacing(12) + }, + button(text(t(locale, "tags.merge")).size(13)) + .on_press_maybe( + state + .merge_target + .is_some() + .then_some(Message::Tags(TagsMsg::MergeTags)) + ) + .style(inputs::primary_button) + .padding([6, 16]), + ] + .spacing(12), + )) .padding(16) .width(Length::Fill) .into() } fn view_discover<'a>(_state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> { - column![ - inputs::section_header(&t(locale, "tags.discoverSection")), - text(t(locale, "tags.discoverDescription")) - .size(12) - .color(Color::from_rgb(0.60, 0.60, 0.65)), - button(text(t(locale, "tags.discoverButton")).size(13)) - .on_press(Message::Tags(TagsMsg::SyncTags)) - .style(inputs::primary_button) - .padding([6, 16]), - ] - .spacing(12) + container(inputs::card( + column![ + inputs::section_header(&t(locale, "tags.discoverSection")), + text(t(locale, "tags.discoverDescription")) + .size(12) + .color(Color::from_rgb(0.60, 0.60, 0.65)), + button(text(t(locale, "tags.discoverButton")).size(13)) + .on_press(Message::Tags(TagsMsg::SyncTags)) + .style(inputs::primary_button) + .padding([6, 16]), + ] + .spacing(12), + )) .padding(16) .width(Length::Fill) .into() diff --git a/crates/bds-ui/src/views/template_editor.rs b/crates/bds-ui/src/views/template_editor.rs index ee63504..ce57fef 100644 --- a/crates/bds-ui/src/views/template_editor.rs +++ b/crates/bds-ui/src/views/template_editor.rs @@ -121,6 +121,7 @@ pub fn view<'a>(state: &'a TemplateEditorState, locale: UiLocale) -> Element<'a, .into(), button(text(t(locale, "editor.validate")).size(13)) .on_press(Message::TemplateEditor(TemplateEditorMsg::Validate)) + .style(inputs::secondary_button) .padding([6, 16]) .into(), button(text(t(locale, "modal.confirmDelete.delete")).size(13)) @@ -168,26 +169,30 @@ pub fn view<'a>(state: &'a TemplateEditorState, locale: UiLocale) -> Element<'a, }); let meta_row2 = row![kind_select, enabled_check] .spacing(16) + .align_y(iced::Alignment::End) .width(Length::Fill); // Content editor (CodeEditor with Liquid/HTML syntax highlighting) - let content_section: Element<'a, Message> = column![ - inputs::section_header(&t(locale, "editor.content")), - Element::from( - CodeEditor::new(&state.editor_buffer, highlighter(), "liquid",).on_change(|msg| { - match msg { - EditorMessage::ContentChanged(s) => { - Message::TemplateEditor(TemplateEditorMsg::ContentChanged(s)) + let content_section: Element<'a, Message> = inputs::card( + column![ + inputs::section_header(&t(locale, "editor.content")), + Element::from( + CodeEditor::new(&state.editor_buffer, highlighter(), "liquid",).on_change(|msg| { + match msg { + EditorMessage::ContentChanged(s) => { + Message::TemplateEditor(TemplateEditorMsg::ContentChanged(s)) + } + EditorMessage::SaveRequested => { + Message::TemplateEditor(TemplateEditorMsg::Save) + } } - EditorMessage::SaveRequested => { - Message::TemplateEditor(TemplateEditorMsg::Save) - } - } - }) - ), - ] - .spacing(8) - .width(Length::Fill) + }) + ), + ] + .spacing(10) + .width(Length::Fill) + .height(Length::Fill), + ) .height(Length::Fill) .into(); @@ -213,18 +218,18 @@ pub fn view<'a>(state: &'a TemplateEditorState, locale: UiLocale) -> Element<'a, .padding(8); // Top pane: header + metadata (scrollable for overflow) - let top_pane = scrollable( - column![header, meta_row1, meta_row2,] + let metadata = inputs::card( + column![meta_row1, meta_row2] .spacing(12) - .padding(16) .width(Length::Fill), - ) - .height(Length::Shrink); + ); + let top_pane = scrollable(column![header, metadata].spacing(12).width(Length::Fill)) + .height(Length::Shrink); // Full layout: top pane (shrink), content (fill), validation + footer (shrink) column![top_pane, content_section, validation, footer,] - .spacing(4) - .padding([0, 16]) + .spacing(8) + .padding(16) .width(Length::Fill) .height(Length::Fill) .into() diff --git a/crates/bds-ui/src/views/toast.rs b/crates/bds-ui/src/views/toast.rs index baa25e3..8967205 100644 --- a/crates/bds-ui/src/views/toast.rs +++ b/crates/bds-ui/src/views/toast.rs @@ -31,7 +31,7 @@ fn toast_style(level: ToastLevel) -> impl Fn(&Theme) -> container::Style { border: Border { color: toast_border(level), width: 1.0, - radius: 4.0.into(), + radius: 8.0.into(), }, ..container::Style::default() } diff --git a/docs/UI_STYLE_GUIDE.md b/docs/UI_STYLE_GUIDE.md new file mode 100644 index 0000000..ca9849a --- /dev/null +++ b/docs/UI_STYLE_GUIDE.md @@ -0,0 +1,49 @@ +# UI Style Guide + +Use the post editor as the visual reference for every screen. New UI must feel like part of the same application in both the sidebar and editor area. + +## Source of truth + +- Reuse the primitives in `crates/bds-ui/src/components/inputs.rs`. +- Reuse the code-editor theme in `crates/bds-editor/src/widget.rs`. +- Follow `crates/bds-ui/src/views/post_editor.rs` for editor hierarchy and spacing. +- Extend a shared primitive when several screens need the same treatment; do not copy color or border closures into each view. + +## Layout + +- Use 16 px outer editor padding and 8–12 px spacing between groups. +- Put the editor header, metadata, summaries, previews, and content editors on `inputs::card` surfaces. +- Use `inputs::toolbar` for content modes and related actions. +- Use `inputs::disclosure_button` inside a compact card for collapsible sections. Expanded content belongs in a separate card directly below it. +- Keep sidebars visually quieter than editor content. Sidebar rows use 6 px corner radii, 5×8 px padding, and a distinct selected state. +- Avoid separators and boxes when spacing or a shared surface already communicates the grouping. + +## Controls + +- Text fields use `inputs::labeled_input` or `inputs::field_style`. +- Selects use `inputs::labeled_select`; multiline fields use `inputs::text_editor_style`. +- Primary actions use `inputs::primary_button`, secondary actions use `inputs::secondary_button`, and destructive actions use `inputs::danger_button`. +- Controls have 6 px corner radii; cards use 10 px; chips and badges may use pill radii. +- Keep one visually dominant primary action per action group. +- Put infrequent operations in Quick Actions instead of adding buttons to metadata forms. +- Do not use Iced's default field or button styling in application screens. + +## Editor consistency + +Every entity editor should have the same order: + +1. Header card with title/status and actions. +2. Metadata card or collapsible metadata section. +3. Content toolbar when the content has modes or insertion actions. +4. Content editor or preview card that receives the remaining height. +5. Validation and timestamps as quiet supporting information. + +Labels, placeholders, actions, empty states, and status text must use the UI localization system. + +## Before merging UI work + +- Compare the changed screen with the post editor at the same window size. +- Check its sidebar as well as its editor area. +- Check collapsed and expanded disclosures, hover/focus states, empty states, and disabled actions. +- Check media, script, template, Settings, and Tags screens when changing a shared primitive. +- Run `cargo test --workspace`, `cargo build --workspace`, and visually inspect the macOS app bundle.