From fe7dd82f3349f46aecbfe108d029e64f4f1168bc Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 20 Jul 2026 14:28:47 +0200 Subject: [PATCH] Implement the Pico CSS theme editor. --- README.md | 2 +- crates/bds-core/src/engine/meta.rs | 37 +- crates/bds-core/src/engine/preview.rs | 181 +++++++--- crates/bds-core/src/engine/site_assets.rs | 7 + crates/bds-core/src/model/metadata.rs | 31 ++ crates/bds-core/src/model/mod.rs | 5 +- crates/bds-core/src/render/routes.rs | 29 +- crates/bds-core/src/render/site.rs | 7 +- crates/bds-ui/src/app.rs | 244 ++++++++++++- crates/bds-ui/src/app/preview_handlers.rs | 27 +- crates/bds-ui/src/components/inputs.rs | 36 +- crates/bds-ui/src/views/mod.rs | 1 + crates/bds-ui/src/views/style_view.rs | 406 ++++++++++++++++++++++ crates/bds-ui/src/views/welcome.rs | 33 -- crates/bds-ui/src/views/workspace.rs | 93 +++-- locales/ui/de.ftl | 11 +- locales/ui/en.ftl | 11 +- locales/ui/es.ftl | 11 +- locales/ui/fr.ftl | 11 +- locales/ui/it.ftl | 11 +- 20 files changed, 1033 insertions(+), 161 deletions(-) create mode 100644 crates/bds-ui/src/views/style_view.rs diff --git a/README.md b/README.md index 1fed6f2..7e59aae 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The project is under active development. Core blogging workflows are broadly ava ## Available Features -- Native Iced desktop workspace with localized menus, tabs, automatically paged post/media sidebars, dialogs, tasks, and embedded Wry previews. +- Native Iced desktop workspace with localized menus, tabs, automatically paged post/media sidebars, dialogs, tasks, embedded Wry previews, and a live Pico CSS theme editor. - Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, media, and batch gallery-image import. - Media import, thumbnails, metadata translations, filters, validation, and post assignment. - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. diff --git a/crates/bds-core/src/engine/meta.rs b/crates/bds-core/src/engine/meta.rs index f1d2735..ec685c2 100644 --- a/crates/bds-core/src/engine/meta.rs +++ b/crates/bds-core/src/engine/meta.rs @@ -4,8 +4,8 @@ use std::path::Path; use crate::db::DbConnection as Connection; use crate::engine::EngineResult; -use crate::model::PublishingPreferences; use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry}; +use crate::model::{DomainEntity, NotificationAction, Project, PublishingPreferences}; use crate::util::atomic_write_str; // ── project.json ──────────────────────────────────────────────────── @@ -26,6 +26,41 @@ pub fn write_project_json(data_dir: &Path, meta: &ProjectMetadata) -> EngineResu Ok(()) } +/// Persist the shared project row and its filesystem metadata, then publish +/// the normal project-updated domain event. +pub fn update_project_metadata( + conn: &Connection, + data_dir: &Path, + project: &Project, + metadata: &ProjectMetadata, +) -> EngineResult { + if project.id.is_empty() { + return Err(crate::engine::EngineError::Validation( + "project id is required".to_string(), + )); + } + metadata + .validate() + .map_err(crate::engine::EngineError::Validation)?; + + let mut persisted_project = project.clone(); + persisted_project.name = metadata.name.clone(); + persisted_project.description = metadata.description.clone(); + persisted_project.updated_at = crate::util::now_unix_ms(); + + let category_metadata = read_category_meta_json(data_dir)?; + write_project_json(data_dir, metadata)?; + write_category_meta_json(data_dir, &category_metadata)?; + crate::db::queries::project::update_project(conn, &persisted_project)?; + crate::engine::domain_events::entity_changed( + &project.id, + DomainEntity::Project, + &project.id, + NotificationAction::Updated, + ); + Ok(metadata.clone()) +} + /// Copy the project fields persisted in both representations from project.json /// into the machine-local project row. pub fn sync_project_from_file( diff --git a/crates/bds-core/src/engine/preview.rs b/crates/bds-core/src/engine/preview.rs index ddab096..fea3315 100644 --- a/crates/bds-core/src/engine/preview.rs +++ b/crates/bds-core/src/engine/preview.rs @@ -13,7 +13,7 @@ use tokio::sync::oneshot; use crate::db::{Database, queries}; use crate::engine::generation::PublishedPostSource; use crate::engine::{EngineError, EngineResult}; -use crate::model::{Post, PostStatus, ProjectMetadata}; +use crate::model::{Post, PostStatus, ProjectMetadata, pico_stylesheet_href}; use crate::render::build_preview_response; use crate::util::frontmatter::{read_post_file, read_translation_file}; @@ -148,12 +148,31 @@ async fn handle_draft_preview( } } -async fn handle_style_preview(Query(query): Query) -> Response { - let theme = query.theme.unwrap_or_else(|| "default".to_string()); - let mode = query.mode.unwrap_or_else(|| "auto".to_string()); +async fn handle_style_preview( + State(state): State, + Query(query): Query, +) -> Response { + let metadata = match crate::engine::meta::read_project_json(&state.data_dir) { + Ok(metadata) => metadata, + Err(error) => { + return error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()); + } + }; + let theme = query.theme.as_deref().unwrap_or("default"); + let stylesheet = pico_stylesheet_href(Some(theme)); + let mode = match query.mode.as_deref().map(str::trim) { + Some("light") => Some("light"), + Some("dark") => Some("dark"), + _ => None, + }; + let mode_attributes = mode + .map(|mode| format!(" data-theme=\"{mode}\" data-mode=\"{mode}\"")) + .unwrap_or_default(); + let project_name = escape_html(&metadata.name); + let language = escape_html(metadata.main_language.as_deref().unwrap_or("en")); + let description = escape_html(metadata.description.as_deref().unwrap_or(&metadata.name)); let html = format!( - "Style Preview

Style Preview

Theme: {}

Mode: {}

", - theme, mode, theme, mode, + "{project_name}

{project_name}

{description}

" ); Html(html).into_response() } @@ -200,48 +219,82 @@ fn apply_preview_style(html: String, style: Option<&StylePreviewQuery>) -> Strin let Some(style) = style else { return html; }; - if style.theme.as_deref().unwrap_or("").is_empty() - && style.mode.as_deref().unwrap_or("").is_empty() - { - return html; - } - let Some(start) = html.find("') else { - return html; - }; - let end = start + end_rel; - - let mut attrs = String::new(); - if let Some(theme) = style.theme.as_deref().filter(|value| !value.is_empty()) { - attrs.push_str(" data-theme=\""); - attrs.push_str(&escape_html_attr(theme)); - attrs.push('"'); + let mut styled = html; + if let Some(theme) = style.theme.as_deref().filter(|theme| !theme.is_empty()) { + styled = override_pico_stylesheet(&styled, &pico_stylesheet_href(Some(theme))); } - if let Some(mode) = style.mode.as_deref().filter(|value| !value.is_empty()) { - attrs.push_str(" data-mode=\""); - attrs.push_str(&escape_html_attr(mode)); - attrs.push('"'); + if let Some(mode) = style.mode.as_deref() { + let mode = match mode.trim() { + "light" => Some("light"), + "dark" => Some("dark"), + _ => None, + }; + styled = override_html_attribute(&styled, "data-theme", mode); + styled = override_html_attribute(&styled, "data-mode", mode); } - if attrs.is_empty() { - return html; - } - - let mut styled = String::with_capacity(html.len() + attrs.len()); - styled.push_str(&html[..end]); - styled.push_str(&attrs); - styled.push_str(&html[end..]); styled } -fn escape_html_attr(value: &str) -> String { +fn override_pico_stylesheet(html: &str, href: &str) -> String { + let Some(start) = html.find("/assets/pico") else { + return html.to_string(); + }; + let Some(end_offset) = html[start..].find(".min.css") else { + return html.to_string(); + }; + let end = start + end_offset + ".min.css".len(); + format!("{}{}{}", &html[..start], href, &html[end..]) +} + +fn override_html_attribute(html: &str, attribute: &str, value: Option<&str>) -> String { + let Some(html_start) = html.find("') else { + return html.to_string(); + }; + let tag_end = html_start + tag_end_offset; + let attribute_start_pattern = format!(" {attribute}=\""); + let existing = html[html_start..tag_end] + .find(&attribute_start_pattern) + .and_then(|offset| { + let start = html_start + offset; + html[start + attribute_start_pattern.len()..tag_end] + .find('"') + .map(|end_offset| { + ( + start, + start + attribute_start_pattern.len() + end_offset + 1, + ) + }) + }); + + match (existing, value) { + (Some((start, end)), Some(value)) => format!( + "{} {attribute}=\"{}\"{}", + &html[..start], + value, + &html[end..] + ), + (Some((start, end)), None) => format!("{}{}", &html[..start], &html[end..]), + (None, Some(value)) => format!( + "{} {attribute}=\"{}\"{}", + &html[..tag_end], + value, + &html[tag_end..] + ), + (None, None) => html.to_string(), + } +} + +fn escape_html(value: &str) -> String { value .replace('&', "&") - .replace('"', """) .replace('<', "<") .replace('>', ">") + .replace('"', """) + .replace('\'', "'") } fn render_draft_preview( @@ -384,6 +437,13 @@ fn serve_scoped_file( let scope_root = data_dir.join(scope_dir); let candidate = scope_root.join(relative); if !candidate.exists() || !candidate.is_file() { + let bundled_path = format!("{scope_dir}/{relative}"); + if let Some(bytes) = crate::engine::site_assets::bundled_site_asset(&bundled_path) { + let mime = guess_content_type(Path::new(&bundled_path)); + return Ok(Some( + (StatusCode::OK, [(header::CONTENT_TYPE, mime)], bytes).into_response(), + )); + } return Ok(Some(error_response( StatusCode::NOT_FOUND, "preview asset not found", @@ -715,16 +775,28 @@ mod tests { let client = reqwest::blocking::Client::new(); let response = client .get(format!( - "http://{PREVIEW_HOST}:{PREVIEW_PORT}/__style-preview?theme=nightfall&mode=dark" + "http://{PREVIEW_HOST}:{PREVIEW_PORT}/__style-preview?theme=amber&mode=dark" )) .send() .unwrap(); let body = response.text().unwrap(); + let stylesheet = client + .get(format!( + "http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/pico.amber.min.css" + )) + .send() + .unwrap(); + assert!(stylesheet.status().is_success()); + let stylesheet = stylesheet.text().unwrap(); server.stop().unwrap(); - assert!(body.contains("Style Preview")); - assert!(body.contains("nightfall")); - assert!(body.contains("dark")); + assert!(body.contains("
")); + assert!(body.contains("Blog")); + assert!(body.contains("href=\"/assets/pico.amber.min.css\"")); + assert!(body.contains("data-theme=\"dark\"")); + assert!(body.contains("data-mode=\"dark\"")); + assert!(!body.contains("data-theme=\"amber\"")); + assert!(stylesheet.contains("--pico-primary-background:#ffbf00")); } #[test] @@ -742,15 +814,34 @@ mod tests { let client = reqwest::blocking::Client::new(); let response = client .get(format!( - "http://{PREVIEW_HOST}:{PREVIEW_PORT}/?theme=nightfall&mode=dark" + "http://{PREVIEW_HOST}:{PREVIEW_PORT}/?theme=amber&mode=light" )) .send() .unwrap(); let body = response.text().unwrap(); server.stop().unwrap(); - assert!(body.contains("data-theme=\"nightfall\"")); - assert!(body.contains("data-mode=\"dark\"")); + assert!(body.contains("href=\"/assets/pico.amber.min.css\"")); + assert!(body.contains("data-theme=\"light\"")); + assert!(body.contains("data-mode=\"light\"")); + assert!(!body.contains("data-theme=\"amber\"")); + } + + #[test] + fn auto_preview_mode_removes_forced_color_scheme_and_invalid_theme_falls_back() { + let html = "".to_string(); + let styled = apply_preview_style( + html, + Some(&StylePreviewQuery { + theme: Some("../../secret".into()), + mode: Some("auto".into()), + }), + ); + + assert!(styled.contains("href=\"/assets/pico.min.css\"")); + assert!(!styled.contains("data-theme=")); + assert!(!styled.contains("data-mode=")); + assert!(!styled.contains("secret")); } #[test] diff --git a/crates/bds-core/src/engine/site_assets.rs b/crates/bds-core/src/engine/site_assets.rs index cbc7f97..9e25c14 100644 --- a/crates/bds-core/src/engine/site_assets.rs +++ b/crates/bds-core/src/engine/site_assets.rs @@ -231,6 +231,13 @@ const BUNDLED_SITE_ASSETS: &[BundledSiteAsset] = &[ }, ]; +pub(crate) fn bundled_site_asset(relative_path: &str) -> Option<&'static [u8]> { + BUNDLED_SITE_ASSETS + .iter() + .find(|asset| asset.relative_path == relative_path) + .map(|asset| asset.bytes) +} + pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> { for asset in BUNDLED_SITE_ASSETS { let target = project_dir.join(asset.relative_path); diff --git a/crates/bds-core/src/model/metadata.rs b/crates/bds-core/src/model/metadata.rs index 43f7da4..20dd525 100644 --- a/crates/bds-core/src/model/metadata.rs +++ b/crates/bds-core/src/model/metadata.rs @@ -1,5 +1,21 @@ use serde::{Deserialize, Serialize}; +pub const SUPPORTED_PICO_THEMES: [&str; 20] = [ + "default", "amber", "blue", "cyan", "fuchsia", "green", "grey", "indigo", "jade", "lime", + "orange", "pink", "pumpkin", "purple", "red", "sand", "slate", "violet", "yellow", "zinc", +]; + +pub fn is_supported_pico_theme(theme: &str) -> bool { + SUPPORTED_PICO_THEMES.contains(&theme) +} + +pub fn pico_stylesheet_href(theme: Option<&str>) -> String { + match theme.filter(|theme| is_supported_pico_theme(theme)) { + Some("default") | None => "/assets/pico.min.css".to_string(), + Some(theme) => format!("/assets/pico.{theme}.min.css"), + } +} + fn default_max_posts() -> i32 { 50 } @@ -69,6 +85,11 @@ impl ProjectMetadata { self.image_import_concurrency )); } + if let Some(theme) = self.pico_theme.as_deref() + && !is_supported_pico_theme(theme) + { + return Err(format!("unsupported picoTheme: {theme}")); + } Ok(()) } } @@ -209,4 +230,14 @@ mod tests { meta.max_posts_per_page = 500; assert!(meta.validate().is_ok()); } + + #[test] + fn pico_theme_validation_accepts_only_bundled_themes() { + let mut meta: ProjectMetadata = serde_json::from_str(r#"{"name":"Test"}"#).unwrap(); + meta.pico_theme = Some("amber".into()); + assert!(meta.validate().is_ok()); + + meta.pico_theme = Some("nightfall".into()); + assert!(meta.validate().is_err()); + } } diff --git a/crates/bds-core/src/model/mod.rs b/crates/bds-core/src/model/mod.rs index 9727bb1..83b19db 100644 --- a/crates/bds-core/src/model/mod.rs +++ b/crates/bds-core/src/model/mod.rs @@ -26,7 +26,10 @@ pub use import::{ }; pub use mcp::{McpProposal, ProposalKind, ProposalStatus}; pub use media::{Media, MediaTranslation}; -pub use metadata::{CategorySettings, ProjectMetadata, TagEntry}; +pub use metadata::{ + CategorySettings, ProjectMetadata, SUPPORTED_PICO_THEMES, TagEntry, is_supported_pico_theme, + pico_stylesheet_href, +}; pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation}; pub use project::{Project, Setting}; pub use script::{Script, ScriptKind, ScriptStatus}; diff --git a/crates/bds-core/src/render/routes.rs b/crates/bds-core/src/render/routes.rs index 11f4a11..3edc0c0 100644 --- a/crates/bds-core/src/render/routes.rs +++ b/crates/bds-core/src/render/routes.rs @@ -151,10 +151,9 @@ pub fn render_starter_single_post_page_with_media_map( language, language_prefix: language_prefix(language, main_language(metadata)), page_title: &post.title, - pico_stylesheet_href: metadata - .pico_theme - .as_ref() - .map(|_| "/assets/pico.min.css".to_string()), + pico_stylesheet_href: Some(crate::model::pico_stylesheet_href( + metadata.pico_theme.as_deref(), + )), html_theme_attribute: None, alternate_links: build_alternate_links(post, metadata, language), blog_languages: build_blog_languages(post, metadata, language), @@ -226,10 +225,9 @@ pub fn render_starter_list_page_with_media_map( language: language.to_string(), language_prefix: language_prefix(language, main_language(metadata)), page_title: metadata.name.clone(), - pico_stylesheet_href: metadata - .pico_theme - .as_ref() - .map(|_| "/assets/pico.min.css".to_string()), + pico_stylesheet_href: Some(crate::model::pico_stylesheet_href( + metadata.pico_theme.as_deref(), + )), html_theme_attribute: None, alternate_links: vec![], blog_languages: build_blog_languages_for_index(metadata, language), @@ -539,6 +537,21 @@ mod tests { assert!(rendered.html.contains("href=\"/2024/03/09/hello\"")); } + #[test] + fn starter_renderer_uses_the_selected_pico_theme_stylesheet() { + let post = make_post(); + let mut metadata = make_metadata(); + metadata.pico_theme = Some("amber".into()); + + let rendered = render_starter_single_post_page(&post, "Body", &metadata, "en").unwrap(); + + assert!( + rendered + .html + .contains("href=\"/assets/pico.amber.min.css\"") + ); + } + #[test] fn starter_single_post_renderer_rewrites_bds_media_image_links() { let post = make_post(); diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index a69b281..6c45283 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -1598,10 +1598,9 @@ fn starter_partials() -> HashMap { } fn pico_stylesheet_href(metadata: &ProjectMetadata) -> Option { - metadata - .pico_theme - .as_ref() - .map(|_| "/assets/pico.min.css".to_string()) + Some(crate::model::pico_stylesheet_href( + metadata.pico_theme.as_deref(), + )) } fn calendar_initial_parts(post: &Post) -> (i32, u32) { diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 6b610fa..b45f5bd 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -51,6 +51,7 @@ use crate::views::{ default_category_rows, }, site_validation::SiteValidationState, + style_view::{StyleMsg, StyleViewState}, tags_view::{self, TagsMsg, TagsSection, TagsViewState}, template_editor::{TemplateEditorMsg, TemplateEditorState}, workspace, @@ -164,6 +165,7 @@ pub enum Message { }, MainWindowLoaded(Option), EmbeddedPreviewReady(Result<(), String>), + EmbeddedStylePreviewReady(Result<(), String>), // Panel SetPanelTab(PanelTab), @@ -327,6 +329,7 @@ pub enum Message { ScriptEditor(ScriptEditorMsg), Tags(TagsMsg), Settings(SettingsMsg), + Style(StyleMsg), ImportEditor(ImportEditorMsg), MenuEditor(MenuEditorMsg), @@ -954,6 +957,7 @@ pub struct BdsApp { preview_session: Option, mcp_server: Option, embedded_preview: Option, + embedded_style_preview: Option, main_window_id: Option, // Editor states (keyed by entity id) @@ -965,6 +969,7 @@ pub struct BdsApp { chat_editors: HashMap, tags_view_state: Option, settings_state: Option, + style_view_state: Option, dashboard_state: Option, site_validation_state: SiteValidationState, duplicates_state: DuplicatesState, @@ -1135,6 +1140,7 @@ impl BdsApp { preview_session: None, mcp_server, embedded_preview: None, + embedded_style_preview: None, main_window_id: None, post_editors: HashMap::new(), media_editors: HashMap::new(), @@ -1144,6 +1150,7 @@ impl BdsApp { chat_editors: HashMap::new(), tags_view_state: None, settings_state: None, + style_view_state: None, dashboard_state: None, site_validation_state: SiteValidationState::default(), duplicates_state: DuplicatesState::default(), @@ -1227,6 +1234,7 @@ impl BdsApp { preview_session: None, mcp_server: None, embedded_preview: None, + embedded_style_preview: None, main_window_id: None, post_editors: HashMap::new(), media_editors: HashMap::new(), @@ -1236,6 +1244,7 @@ impl BdsApp { chat_editors: HashMap::new(), tags_view_state: None, settings_state: None, + style_view_state: None, dashboard_state: None, site_validation_state: SiteValidationState::default(), duplicates_state: DuplicatesState::default(), @@ -1582,7 +1591,7 @@ impl BdsApp { } self.enforce_panel_tab_fallback(); self.sync_menu_state(); - let mut tasks = vec![self.sync_embedded_preview_for_active_post()]; + let mut tasks = vec![self.sync_embedded_previews()]; if let Some(post_id) = semantic_post_id { tasks.push(Task::done(Message::LoadSemanticTagSuggestions(post_id))); } @@ -1600,7 +1609,7 @@ impl BdsApp { self.git_diffs.remove(&id); self.enforce_panel_tab_fallback(); self.sync_menu_state(); - self.sync_embedded_preview_for_active_post() + self.sync_embedded_previews() } Message::SelectTab(id) => { if self.tabs.iter().any(|t| t.id == id) { @@ -1610,7 +1619,7 @@ impl BdsApp { self.active_tab = Some(id); } self.enforce_panel_tab_fallback(); - self.sync_embedded_preview_for_active_post() + self.sync_embedded_previews() } Message::PinTab(id) => { tabs::pin_tab(&mut self.tabs, &id); @@ -1622,6 +1631,7 @@ impl BdsApp { self.active_tab = None; self.git_diffs.clear(); self.hide_embedded_preview(); + self.hide_embedded_style_preview(); Task::none() } @@ -1692,6 +1702,8 @@ impl BdsApp { self.preview_session = None; self.duplicates_state = DuplicatesState::default(); self.hide_embedded_preview(); + self.hide_embedded_style_preview(); + self.style_view_state = None; self.data_dir = self .active_project .as_ref() @@ -1710,6 +1722,9 @@ impl BdsApp { } } } + if self.tabs.iter().any(|tab| tab.tab_type == TabType::Style) { + self.style_view_state = self.hydrate_style_state(); + } let name = self .active_project .as_ref() @@ -1737,6 +1752,7 @@ impl BdsApp { self.refresh_counts(), self.refresh_git_if_visible(), Task::done(Message::EmbeddingBackfill), + self.sync_embedded_previews(), ]) } Message::ProjectSwitched(result) => { @@ -1862,6 +1878,8 @@ impl BdsApp { self.data_dir = project.data_path.as_ref().map(PathBuf::from); self.preview_session = None; self.hide_embedded_preview(); + self.hide_embedded_style_preview(); + self.style_view_state = None; self.notify( ToastLevel::Success, &tw( @@ -2374,9 +2392,9 @@ impl BdsApp { } Task::none() } - message @ (Message::MainWindowLoaded(_) | Message::EmbeddedPreviewReady(_)) => { - self.handle_preview_message(message) - } + message @ (Message::MainWindowLoaded(_) + | Message::EmbeddedPreviewReady(_) + | Message::EmbeddedStylePreviewReady(_)) => self.handle_preview_message(message), // ── Tasks ── Message::TaskTick => { @@ -2481,6 +2499,8 @@ impl BdsApp { self.data_dir = Some(data_dir.clone()); self.preview_session = None; self.hide_embedded_preview(); + self.hide_embedded_style_preview(); + self.style_view_state = None; if let Ok(meta) = engine::meta::read_project_json(&data_dir) { let main_language = meta.main_language.unwrap_or_else(|| "en".into()); self.content_language = main_language.clone(); @@ -3031,6 +3051,7 @@ impl BdsApp { Message::ScriptEditor(msg) => self.handle_script_editor_msg(msg), Message::Tags(msg) => self.handle_tags_msg(msg), Message::Settings(msg) => self.handle_settings_msg(msg), + Message::Style(msg) => self.handle_style_msg(msg), Message::ImportEditor(msg) => self.handle_import_editor_msg(msg), Message::MenuEditor(msg) => self.handle_menu_editor_msg(msg), @@ -3380,6 +3401,13 @@ impl BdsApp { } else { None }; + let style_preview_widget = if self.active_style_uses_embedded_preview() { + self.embedded_style_preview + .as_ref() + .map(|preview| webview::webview(&preview.controller).into()) + } else { + None + }; native_edit::native_edit( workspace::view( @@ -3418,6 +3446,7 @@ impl BdsApp { self.active_modal.clone(), self.data_dir.as_deref(), post_preview_widget, + style_preview_widget, &self.post_editors, &self.media_editors, &self.template_editors, @@ -3426,6 +3455,7 @@ impl BdsApp { &self.chat_editors, self.tags_view_state.as_ref(), self.settings_state.as_ref(), + self.style_view_state.as_ref(), self.dashboard_state.as_ref(), &self.site_validation_state, &self.duplicates_state, @@ -3981,6 +4011,15 @@ impl BdsApp { .and_then(|project| project.data_path.as_deref()) .map(PathBuf::from); } + if let Some(data_dir) = self.data_dir.as_deref() + && let Ok(metadata) = engine::meta::read_project_json(data_dir) + { + let applied = StyleViewState::new(metadata.pico_theme.as_deref()); + self.theme_badge = applied.applied_theme.clone(); + if let Some(state) = self.style_view_state.as_mut() { + state.refresh_applied_theme(metadata.pico_theme.as_deref()); + } + } previous_active.as_deref() == Some(project_id.as_str()) || self .active_project @@ -5323,9 +5362,8 @@ impl BdsApp { // Read pico theme from project metadata for status bar badge if let Some(ref data_dir) = self.data_dir && let Ok(meta) = engine::meta::read_project_json(data_dir) - && let Some(theme) = meta.pico_theme { - self.theme_badge = theme; + self.theme_badge = StyleViewState::new(meta.pico_theme.as_deref()).applied_theme; } self.dashboard_state = Some(self.hydrate_dashboard_state()); @@ -7104,6 +7142,64 @@ impl BdsApp { Task::none() } + fn hydrate_style_state(&self) -> Option { + self.active_project.as_ref()?; + let data_dir = self.data_dir.as_deref()?; + let metadata = engine::meta::read_project_json(data_dir).ok()?; + Some(StyleViewState::new(metadata.pico_theme.as_deref())) + } + + fn handle_style_msg(&mut self, message: StyleMsg) -> Task { + match message { + StyleMsg::SelectTheme(theme) => { + if let Some(state) = self.style_view_state.as_mut() { + state.select_theme(&theme); + } + self.sync_embedded_preview_for_style() + } + StyleMsg::PreviewModeChanged(mode) => { + if let Some(state) = self.style_view_state.as_mut() { + state.set_preview_mode(mode); + } + self.sync_embedded_preview_for_style() + } + StyleMsg::Apply => { + let Some(theme) = self + .style_view_state + .as_ref() + .filter(|state| state.can_apply()) + .map(|state| state.selected_theme.clone()) + else { + return Task::none(); + }; + let result = (|| { + let db = self.db.as_ref().ok_or("database unavailable")?; + let data_dir = self + .data_dir + .as_deref() + .ok_or("project data directory unavailable")?; + let project = self.active_project.as_ref().ok_or("project unavailable")?; + let mut metadata = engine::meta::read_project_json(data_dir) + .map_err(|error| error.to_string())?; + metadata.pico_theme = Some(theme.clone()); + engine::meta::update_project_metadata(db.conn(), data_dir, project, &metadata) + .map_err(|error| error.to_string()) + })(); + match result { + Ok(_) => { + if let Some(state) = self.style_view_state.as_mut() { + state.mark_applied(); + } + self.theme_badge = theme; + self.notify(ToastLevel::Success, &t(self.ui_locale, "style.applied")); + } + Err(error) => self.notify_operation_failed("style.apply", error), + } + Task::none() + } + } + } + fn handle_settings_msg(&mut self, msg: SettingsMsg) -> Task { // Ensure settings state exists if self.settings_state.is_none() { @@ -7275,11 +7371,9 @@ impl BdsApp { Some(state.data_path.clone()) }; project.updated_at = bds_core::util::now_unix_ms(); - let db_result = - bds_core::db::queries::project::update_project(db.conn(), project); - let file_result = engine::meta::write_project_json(data_dir, &meta); - match (db_result, file_result) { - (Ok(()), Ok(())) => { + match engine::meta::update_project_metadata(db.conn(), data_dir, project, &meta) + { + Ok(_) => { let semantic_should_backfill = state.semantic_similarity_enabled && !semantic_was_enabled; if let Some(listing) = @@ -7295,8 +7389,7 @@ impl BdsApp { return Task::done(Message::EmbeddingBackfill); } } - (Err(e), _) => self.notify_operation_failed("common.save", e), - (_, Err(e)) => self.notify_operation_failed("common.save", e), + Err(e) => self.notify_operation_failed("common.save", e), } } } @@ -8166,6 +8259,9 @@ impl BdsApp { TabType::Settings if self.settings_state.is_none() => { self.settings_state = Some(self.hydrate_settings_state()); } + TabType::Style if self.style_view_state.is_none() => { + self.style_view_state = self.hydrate_style_state(); + } _ => {} } } @@ -8379,6 +8475,72 @@ impl BdsApp { } } + fn active_style_uses_embedded_preview(&self) -> bool { + let active_is_style = self.active_tab.as_ref().is_some_and(|tab_id| { + self.tabs + .iter() + .any(|tab| tab.id == *tab_id && tab.tab_type == TabType::Style) + }); + active_is_style && self.active_project.is_some() && self.style_view_state.is_some() + } + + fn hide_embedded_style_preview(&self) { + if let Some(preview) = &self.embedded_style_preview { + preview.controller.set_visible(false); + } + } + + fn sync_embedded_previews(&mut self) -> Task { + let post = self.sync_embedded_preview_for_active_post(); + let style = self.sync_embedded_preview_for_style(); + Task::batch([post, style]) + } + + fn sync_embedded_preview_for_style(&mut self) -> Task { + if !self.active_style_uses_embedded_preview() { + self.hide_embedded_style_preview(); + return Task::none(); + } + if let Err(error) = self.ensure_preview_server() { + self.notify(ToastLevel::Error, &error); + return Task::none(); + } + let Some(url) = self + .style_view_state + .as_ref() + .map(StyleViewState::preview_url) + else { + return Task::none(); + }; + + if let Some(preview) = &mut self.embedded_style_preview { + preview.current_url = Some(url.clone()); + if preview.controller.is_active() { + preview.controller.navigate(&url); + preview.controller.set_visible(true); + return Task::none(); + } + } else { + self.embedded_style_preview = Some(EmbeddedPreviewState { + controller: WebViewController::new(WebViewConfig::default().url(url.clone())), + current_url: Some(url.clone()), + }); + } + + let Some(window_id) = self.main_window_id else { + return window::get_oldest().map(Message::MainWindowLoaded); + }; + if let Some(preview) = &mut self.embedded_style_preview + && !preview.controller.is_active() + { + preview.controller = WebViewController::new(WebViewConfig::default().url(url)); + return preview + .controller + .create_task(window_id, Message::EmbeddedStylePreviewReady); + } + Task::none() + } + fn sync_embedded_preview_for_active_post(&mut self) -> Task { let Some(active_id) = self.active_tab.clone() else { self.hide_embedded_preview(); @@ -9054,6 +9216,7 @@ mod tests { use crate::views::post_editor::{PostEditorMsg, PostEditorState}; use crate::views::script_editor::{ScriptEditorMsg, ScriptEditorState}; use crate::views::settings_view::{AiModeViewState, SettingsSection, SettingsViewState}; + use crate::views::style_view::{PreviewMode, StyleMsg, StyleViewState}; use crate::views::template_editor::TemplateEditorState; use bds_core::db::Database; use bds_core::db::fts::ensure_fts_tables; @@ -10364,6 +10527,57 @@ mod tests { assert!(app.active_post_uses_embedded_preview()); } + #[test] + fn style_selection_and_preview_mode_stay_local_until_apply() { + let mut state = StyleViewState::new(Some("blue")); + + assert_eq!(state.selected_theme, "blue"); + assert_eq!(state.applied_theme, "blue"); + assert!(!state.can_apply()); + + state.select_theme("green"); + state.set_preview_mode(PreviewMode::Dark); + + assert_eq!(state.selected_theme, "green"); + assert_eq!(state.applied_theme, "blue"); + assert_eq!(state.preview_mode, PreviewMode::Dark); + assert!(state.can_apply()); + + state.mark_applied(); + assert!(!state.can_apply()); + } + + #[test] + fn applying_style_persists_project_metadata_emits_event_and_updates_badge() { + let (db, project, tmp) = setup(); + let events = bds_core::engine::domain_events::subscribe(); + let mut app = make_app(db, project, &tmp); + app.style_view_state = Some(StyleViewState::new(None)); + + let _ = app.update(Message::Style(StyleMsg::SelectTheme("purple".to_string()))); + let _ = app.update(Message::Style(StyleMsg::Apply)); + + let metadata = bds_core::engine::meta::read_project_json(tmp.path()).unwrap(); + assert_eq!(metadata.pico_theme.as_deref(), Some("purple")); + let project_json = std::fs::read_to_string(tmp.path().join("meta/project.json")).unwrap(); + assert!(project_json.contains("\"picoTheme\": \"purple\"")); + assert!(!project_json.contains("previewMode")); + assert_eq!(app.theme_badge, "purple"); + assert_eq!( + app.style_view_state.as_ref().unwrap().applied_theme, + "purple" + ); + assert!(events.drain().iter().any(|event| matches!( + event, + DomainEvent::EntityChanged { + project_id, + entity: bds_core::model::DomainEntity::Project, + entity_id, + action: bds_core::model::NotificationAction::Updated, + } if project_id == "p1" && entity_id == "p1" + ))); + } + #[test] fn task_tick_autosaves_dirty_post_editor() { let (db, project, tmp) = setup(); diff --git a/crates/bds-ui/src/app/preview_handlers.rs b/crates/bds-ui/src/app/preview_handlers.rs index 19b847b..5f2e7d1 100644 --- a/crates/bds-ui/src/app/preview_handlers.rs +++ b/crates/bds-ui/src/app/preview_handlers.rs @@ -5,11 +5,10 @@ impl BdsApp { match message { Message::MainWindowLoaded(window_id) => { self.main_window_id = window_id; - if self.active_post_uses_embedded_preview() { - self.sync_embedded_preview_for_active_post() - } else { - Task::none() - } + Task::batch([ + self.sync_embedded_preview_for_active_post(), + self.sync_embedded_preview_for_style(), + ]) } Message::EmbeddedPreviewReady(result) => { match result { @@ -29,6 +28,24 @@ impl BdsApp { } Task::none() } + Message::EmbeddedStylePreviewReady(result) => { + match result { + Ok(()) => { + let visible = self.active_style_uses_embedded_preview(); + if let Some(preview) = &mut self.embedded_style_preview { + preview.controller.take_staged(); + if let Some(url) = preview.current_url.as_deref() { + preview.controller.navigate(url); + } + preview.controller.set_visible(visible); + } + } + Err(error) => { + self.notify(ToastLevel::Error, &error); + } + } + Task::none() + } _ => unreachable!("non-preview message routed to preview handler"), } } diff --git a/crates/bds-ui/src/components/inputs.rs b/crates/bds-ui/src/components/inputs.rs index dd6e26d..b41cae9 100644 --- a/crates/bds-ui/src/components/inputs.rs +++ b/crates/bds-ui/src/components/inputs.rs @@ -278,25 +278,23 @@ pub fn section_header<'a, Message: 'a>(label: &str) -> Element<'a, Message> { /// Primary action button style. pub fn primary_button(theme: &Theme, status: button::Status) -> button::Style { let _ = theme; - match status { - button::Status::Hovered => button::Style { - background: Some(Background::Color(PRIMARY_HOVER)), - text_color: Color::WHITE, - border: iced::Border { - radius: 6.0.into(), - ..iced::Border::default() - }, - ..button::Style::default() + let background = match status { + button::Status::Hovered => PRIMARY_HOVER, + button::Status::Disabled => PRIMARY_BG.scale_alpha(0.45), + _ => PRIMARY_BG, + }; + button::Style { + background: Some(Background::Color(background)), + text_color: if matches!(status, button::Status::Disabled) { + Color::WHITE.scale_alpha(0.55) + } else { + Color::WHITE }, - _ => button::Style { - background: Some(Background::Color(PRIMARY_BG)), - text_color: Color::WHITE, - border: iced::Border { - radius: 6.0.into(), - ..iced::Border::default() - }, - ..button::Style::default() + border: iced::Border { + radius: 6.0.into(), + ..iced::Border::default() }, + ..button::Style::default() } } @@ -464,6 +462,10 @@ mod tests { primary_button(&theme, button::Status::Active).background, primary_button(&theme, button::Status::Hovered).background ); + assert_ne!( + primary_button(&theme, button::Status::Active).background, + primary_button(&theme, button::Status::Disabled).background + ); assert_ne!( field_style(&theme, text_input::Status::Active).border.color, field_style(&theme, text_input::Status::Focused) diff --git a/crates/bds-ui/src/views/mod.rs b/crates/bds-ui/src/views/mod.rs index 0ad67e0..3924bf5 100644 --- a/crates/bds-ui/src/views/mod.rs +++ b/crates/bds-ui/src/views/mod.rs @@ -18,6 +18,7 @@ pub mod settings_view; pub mod sidebar; pub mod site_validation; pub mod status_bar; +pub mod style_view; pub mod tab_bar; pub mod tags_view; pub mod template_editor; diff --git a/crates/bds-ui/src/views/style_view.rs b/crates/bds-ui/src/views/style_view.rs new file mode 100644 index 0000000..212a8ce --- /dev/null +++ b/crates/bds-ui/src/views/style_view.rs @@ -0,0 +1,406 @@ +use iced::widget::text::Shaping; +use iced::widget::{Column, Space, button, column, container, row, scrollable, text}; +use iced::{Background, Border, Color, Element, Length, Theme}; + +use bds_core::i18n::UiLocale; + +use crate::app::Message; +use crate::components::inputs; +use crate::i18n::t; + +const DEFAULT_THEME: &str = "default"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PreviewMode { + Auto, + Light, + Dark, +} + +impl PreviewMode { + pub fn query_value(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Light => "light", + Self::Dark => "dark", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PreviewModeOption { + mode: PreviewMode, + label: String, +} + +impl std::fmt::Display for PreviewModeOption { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.label) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StyleTheme { + pub name: &'static str, + pub accent_color: &'static str, + pub light_bg_color: &'static str, + pub dark_bg_color: &'static str, +} + +const fn theme(name: &'static str, accent_color: &'static str) -> StyleTheme { + StyleTheme { + name, + accent_color, + light_bg_color: "#ffffff", + dark_bg_color: "#13171f", + } +} + +pub const THEMES: [StyleTheme; 20] = [ + theme("default", "#0172ad"), + theme("amber", "#ffbf00"), + theme("blue", "#2060df"), + theme("cyan", "#047878"), + theme("fuchsia", "#c1208b"), + theme("green", "#398712"), + theme("grey", "#ababab"), + theme("indigo", "#524ed2"), + theme("jade", "#007a50"), + theme("lime", "#a5d601"), + theme("orange", "#d24317"), + theme("pink", "#d92662"), + theme("pumpkin", "#ff9500"), + theme("purple", "#9236a4"), + theme("red", "#c52f21"), + theme("sand", "#ccc6b4"), + theme("slate", "#525f7a"), + theme("violet", "#7540bf"), + theme("yellow", "#f2df0d"), + theme("zinc", "#646b79"), +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StyleViewState { + pub selected_theme: String, + pub applied_theme: String, + pub preview_mode: PreviewMode, +} + +impl StyleViewState { + pub fn new(applied_theme: Option<&str>) -> Self { + let applied_theme = normalize_theme(applied_theme); + Self { + selected_theme: applied_theme.clone(), + applied_theme, + preview_mode: PreviewMode::Auto, + } + } + + pub fn select_theme(&mut self, theme: &str) { + if is_supported_theme(theme) { + self.selected_theme = theme.to_string(); + } + } + + pub fn set_preview_mode(&mut self, mode: PreviewMode) { + self.preview_mode = mode; + } + + pub fn can_apply(&self) -> bool { + self.selected_theme != self.applied_theme + } + + pub fn mark_applied(&mut self) { + self.applied_theme.clone_from(&self.selected_theme); + } + + pub fn refresh_applied_theme(&mut self, applied_theme: Option<&str>) { + let preserve_pending_selection = self.can_apply(); + self.applied_theme = normalize_theme(applied_theme); + if !preserve_pending_selection { + self.selected_theme.clone_from(&self.applied_theme); + } + } + + pub fn preview_url(&self) -> String { + format!( + "http://{}:{}/__style-preview?theme={}&mode={}", + bds_core::engine::preview::PREVIEW_HOST, + bds_core::engine::preview::PREVIEW_PORT, + self.selected_theme, + self.preview_mode.query_value(), + ) + } +} + +#[derive(Debug, Clone)] +pub enum StyleMsg { + SelectTheme(String), + PreviewModeChanged(PreviewMode), + Apply, +} + +pub fn is_supported_theme(theme: &str) -> bool { + THEMES.iter().any(|candidate| candidate.name == theme) +} + +pub fn display_theme_name(theme: &str) -> String { + let replaced = theme.replace('-', " "); + let mut characters = replaced.chars(); + match characters.next() { + Some(first) => first.to_uppercase().chain(characters).collect(), + None => String::new(), + } +} + +fn normalize_theme(theme: Option<&str>) -> String { + theme + .filter(|theme| is_supported_theme(theme)) + .unwrap_or(DEFAULT_THEME) + .to_string() +} + +fn parse_hex_color(hex: &str) -> Color { + let bytes = hex.as_bytes(); + if bytes.len() == 7 && bytes[0] == b'#' { + let component = |start| u8::from_str_radix(&hex[start..start + 2], 16).unwrap_or_default(); + Color::from_rgb8(component(1), component(3), component(5)) + } else { + Color::TRANSPARENT + } +} + +fn theme_button_style(selected: bool, status: button::Status) -> button::Style { + let hovered = matches!(status, button::Status::Hovered); + button::Style { + background: Some(Background::Color(if selected { + Color::from_rgb8(0x1F, 0x45, 0x63) + } else if hovered { + Color::from_rgb8(0x32, 0x32, 0x35) + } else { + Color::from_rgb8(0x24, 0x24, 0x26) + })), + text_color: Color::from_rgb8(0xE4, 0xE4, 0xE4), + border: Border { + color: if selected { + Color::from_rgb8(0x00, 0x7F, 0xD4) + } else { + Color::from_rgb8(0x3C, 0x3C, 0x3C) + }, + width: if selected { 2.0 } else { 1.0 }, + radius: 6.0.into(), + }, + ..button::Style::default() + } +} + +fn swatch(color: &'static str) -> Element<'static, Message> { + container(Space::new(Length::Fill, Length::Fixed(16.0))) + .width(Length::Fill) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(parse_hex_color(color))), + ..container::Style::default() + }) + .into() +} + +fn theme_button<'a>(theme: &StyleTheme, selected_theme: &str) -> Element<'a, Message> { + let selected = theme.name == selected_theme; + let theme_name = theme.name.to_string(); + button( + column![ + row![ + swatch(theme.accent_color), + swatch(theme.light_bg_color), + swatch(theme.dark_bg_color), + ] + .spacing(2), + text(display_theme_name(theme.name)) + .size(12) + .shaping(Shaping::Advanced), + ] + .spacing(7) + .width(Length::Fill), + ) + .on_press(Message::Style(StyleMsg::SelectTheme(theme_name))) + .padding(8) + .width(Length::FillPortion(1)) + .style(move |_: &Theme, status| theme_button_style(selected, status)) + .into() +} + +pub fn view<'a>( + state: &'a StyleViewState, + locale: UiLocale, + preview_widget: Option>, +) -> Element<'a, Message> { + let header = inputs::card( + column![ + text(t(locale, "style.title")) + .size(18) + .shaping(Shaping::Advanced), + text(t(locale, "style.subtitle")) + .size(12) + .color(inputs::LABEL_COLOR) + .shaping(Shaping::Advanced), + ] + .spacing(4), + ); + + let theme_rows = THEMES + .chunks(4) + .map(|themes| { + let mut children = themes + .iter() + .map(|theme| theme_button(theme, &state.selected_theme)) + .collect::>(); + while children.len() < 4 { + children.push(Space::with_width(Length::FillPortion(1)).into()); + } + iced::widget::Row::with_children(children) + .spacing(8) + .width(Length::Fill) + .into() + }) + .collect::>>(); + let picker = inputs::card( + column![ + text(t(locale, "style.themes")) + .size(12) + .color(inputs::SECTION_COLOR) + .shaping(Shaping::Advanced), + scrollable(Column::with_children(theme_rows).spacing(8)) + .direction(scrollable::Direction::Vertical(inputs::compact_scrollbar())) + .style(inputs::scrollable_style) + .height(Length::Fixed(250.0)), + ] + .spacing(8), + ); + + let preview_modes = [ + PreviewModeOption { + mode: PreviewMode::Auto, + label: t(locale, "style.previewMode.auto"), + }, + PreviewModeOption { + mode: PreviewMode::Light, + label: t(locale, "style.previewMode.light"), + }, + PreviewModeOption { + mode: PreviewMode::Dark, + label: t(locale, "style.previewMode.dark"), + }, + ]; + let selected_mode = preview_modes + .iter() + .find(|option| option.mode == state.preview_mode); + let mode_select = container(inputs::labeled_select( + &t(locale, "style.previewMode"), + &preview_modes, + selected_mode, + |option| Message::Style(StyleMsg::PreviewModeChanged(option.mode)), + )) + .width(Length::Fixed(220.0)); + let apply_label = text(t(locale, "style.apply")) + .size(13) + .shaping(Shaping::Advanced); + let apply_button: Element<'a, Message> = if state.can_apply() { + button(apply_label) + .on_press(Message::Style(StyleMsg::Apply)) + .padding([8, 16]) + .style(inputs::primary_button) + .into() + } else { + button(apply_label) + .padding([8, 16]) + .style(inputs::primary_button) + .into() + }; + let controls = inputs::toolbar(vec![mode_select.into()], vec![apply_button]); + + let preview = preview_widget.unwrap_or_else(|| { + container( + text(t(locale, "tabBar.loading")) + .size(14) + .shaping(Shaping::Advanced), + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + }); + let preview_card = inputs::card( + column![ + text(t(locale, "style.preview")) + .size(12) + .color(inputs::SECTION_COLOR) + .shaping(Shaping::Advanced), + preview, + ] + .spacing(8) + .height(Length::Fill), + ) + .height(Length::Fill); + + container( + column![header, picker, controls, preview_card] + .spacing(10) + .height(Length::Fill), + ) + .padding(16) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn themes_match_the_allium_and_bds2_catalog() { + assert_eq!(THEMES.len(), 20); + assert_eq!(THEMES.first().unwrap().name, "default"); + assert_eq!(THEMES.last().unwrap().name, "zinc"); + assert_eq!( + THEMES.iter().map(|theme| theme.name).collect::>(), + bds_core::model::SUPPORTED_PICO_THEMES.to_vec() + ); + assert_eq!(THEMES[1].accent_color, "#ffbf00"); + assert_eq!(THEMES[2].accent_color, "#2060df"); + assert_eq!(THEMES[18].accent_color, "#f2df0d"); + assert_eq!(THEMES[19].accent_color, "#646b79"); + assert_eq!( + THEMES + .iter() + .map(|theme| theme.accent_color) + .collect::>() + .len(), + THEMES.len() + ); + assert!(THEMES.iter().all(|theme| { + theme.light_bg_color == "#ffffff" && theme.dark_bg_color == "#13171f" + })); + } + + #[test] + fn preview_url_tracks_selection_and_local_mode() { + let mut state = StyleViewState::new(Some("blue")); + state.select_theme("pumpkin"); + state.set_preview_mode(PreviewMode::Light); + assert_eq!( + state.preview_url(), + "http://127.0.0.1:4123/__style-preview?theme=pumpkin&mode=light" + ); + assert_eq!(state.applied_theme, "blue"); + } + + #[test] + fn invalid_or_empty_applied_theme_uses_default() { + assert_eq!(StyleViewState::new(None).applied_theme, "default"); + assert_eq!(StyleViewState::new(Some("")).applied_theme, "default"); + assert_eq!( + StyleViewState::new(Some("unknown")).applied_theme, + "default" + ); + } +} diff --git a/crates/bds-ui/src/views/welcome.rs b/crates/bds-ui/src/views/welcome.rs index e0dc148..13c3738 100644 --- a/crates/bds-ui/src/views/welcome.rs +++ b/crates/bds-ui/src/views/welcome.rs @@ -35,36 +35,3 @@ pub fn view(locale: UiLocale) -> Element<'static, Message> { }) .into() } - -pub fn tab_placeholder( - locale: UiLocale, - title: impl Into, - subtitle: Option, -) -> Element<'static, Message> { - let title = title.into(); - let subtitle = subtitle.unwrap_or_else(|| t(locale, "common.tabNotImplemented")); - - container( - column![ - text(title) - .size(24) - .shaping(Shaping::Advanced) - .color(Color::from_rgb(0.85, 0.85, 0.90)), - text(subtitle) - .size(14) - .shaping(Shaping::Advanced) - .color(Color::from_rgb(0.55, 0.55, 0.60)), - ] - .spacing(12) - .align_x(iced::Alignment::Center), - ) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill) - .style(|_theme: &Theme| container::Style { - background: Some(Background::Color(Color::from_rgb(0.11, 0.11, 0.14))), - ..container::Style::default() - }) - .into() -} diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index 0d5f2eb..d4ec4da 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -31,7 +31,9 @@ use crate::views::{ settings_view::{self, SettingsViewState}, sidebar, site_validation::{self, SiteValidationState}, - status_bar, tab_bar, + status_bar, + style_view::{self, StyleViewState}, + tab_bar, tags_view::{self, TagsViewState}, template_editor::{self, TemplateEditorState}, toast, @@ -120,6 +122,7 @@ pub fn view<'a>( // Data directory (for thumbnail paths) data_dir: Option<&'a Path>, post_preview_widget: Option>, + style_preview_widget: Option>, // Editor states post_editors: &'a HashMap, media_editors: &'a HashMap, @@ -129,6 +132,7 @@ pub fn view<'a>( chat_editors: &'a HashMap, tags_view_state: Option<&'a TagsViewState>, settings_state: Option<&'a SettingsViewState>, + style_view_state: Option<&'a StyleViewState>, dashboard_state: Option<&'a DashboardState>, site_validation_state: &'a SiteValidationState, duplicates_state: &'a DuplicatesState, @@ -157,6 +161,7 @@ pub fn view<'a>( offline_mode, data_dir, post_preview_widget, + style_preview_widget, post_editors, media_editors, template_editors, @@ -165,6 +170,7 @@ pub fn view<'a>( chat_editors, tags_view_state, settings_state, + style_view_state, dashboard_state, site_validation_state, duplicates_state, @@ -405,6 +411,7 @@ fn route_content_area<'a>( offline_mode: bool, data_dir: Option<&'a Path>, post_preview_widget: Option>, + style_preview_widget: Option>, post_editors: &'a HashMap, media_editors: &'a HashMap, template_editors: &'a HashMap, @@ -413,6 +420,7 @@ fn route_content_area<'a>( chat_editors: &'a HashMap, tags_view_state: Option<&'a TagsViewState>, settings_state: Option<&'a SettingsViewState>, + style_view_state: Option<&'a StyleViewState>, dashboard_state: Option<&'a DashboardState>, site_validation_state: &'a SiteValidationState, duplicates_state: &'a DuplicatesState, @@ -435,6 +443,7 @@ fn route_content_area<'a>( import_editors, tags_view_state, settings_state, + style_view_state, dashboard_state, site_validation_state, ) { @@ -509,6 +518,13 @@ fn route_content_area<'a>( loading_view(locale) } } + ContentRoute::Style => { + if let Some(state) = style_view_state { + style_view::view(state, locale, style_preview_widget) + } else { + welcome::view(locale) + } + } ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale), ContentRoute::FindDuplicates => duplicates::view(duplicates_state, locale), ContentRoute::Documentation => documentation::view(guide_documentation, locale), @@ -533,7 +549,6 @@ fn route_content_area<'a>( loading_view(locale) } } - ContentRoute::Placeholder(title) => welcome::tab_placeholder(locale, title, None), } } @@ -550,6 +565,7 @@ enum ContentRoute<'a> { Chat(&'a str), Tags, Settings, + Style, SiteValidation, FindDuplicates, Documentation, @@ -560,7 +576,6 @@ enum ContentRoute<'a> { MenuEditor, TranslationValidation, GitDiff(&'a str), - Placeholder(&'a str), } #[expect( @@ -577,6 +592,7 @@ fn route_kind<'a>( import_editors: &'a HashMap, tags_view_state: Option<&'a TagsViewState>, settings_state: Option<&'a SettingsViewState>, + style_view_state: Option<&'a StyleViewState>, dashboard_state: Option<&'a DashboardState>, _site_validation_state: &'a SiteValidationState, ) -> ContentRoute<'a> { @@ -651,7 +667,13 @@ fn route_kind<'a>( TabType::CliDocumentation => ContentRoute::CliDocumentation, TabType::McpDocumentation => ContentRoute::McpDocumentation, TabType::MenuEditor => ContentRoute::MenuEditor, - TabType::Style => ContentRoute::Placeholder(&tab.title), + TabType::Style => { + if style_view_state.is_some() { + ContentRoute::Style + } else { + ContentRoute::Welcome + } + } TabType::TranslationValidation => ContentRoute::TranslationValidation, } } @@ -701,35 +723,46 @@ mod tests { } #[test] - fn unsupported_tool_tabs_do_not_fall_back_to_welcome_route() { + fn style_tab_routes_to_real_editor_only_with_project_state() { let empty_posts = HashMap::new(); let empty_media = HashMap::new(); let empty_templates = HashMap::new(); let empty_scripts = HashMap::new(); let empty_imports = HashMap::new(); let site_validation_state = SiteValidationState::default(); - let unsupported = [TabType::Style]; + let style_state = StyleViewState::new(Some("blue")); + let tabs = vec![tab("style", TabType::Style, "Style")]; + let route = route_kind( + &tabs, + Some("style"), + &empty_posts, + &empty_media, + &empty_templates, + &empty_scripts, + &empty_imports, + None, + None, + Some(&style_state), + None, + &site_validation_state, + ); + assert!(matches!(route, ContentRoute::Style)); - for tab_type in unsupported { - let tabs = vec![tab("tool", tab_type.clone(), "Tool")]; - let route = route_kind( - &tabs, - Some("tool"), - &empty_posts, - &empty_media, - &empty_templates, - &empty_scripts, - &empty_imports, - None, - None, - None, - &site_validation_state, - ); - match route { - ContentRoute::Placeholder(title) => assert_eq!(title, "Tool"), - _ => panic!("expected placeholder route for {tab_type:?}"), - } - } + let no_project_route = route_kind( + &tabs, + Some("style"), + &empty_posts, + &empty_media, + &empty_templates, + &empty_scripts, + &empty_imports, + None, + None, + None, + None, + &site_validation_state, + ); + assert!(matches!(no_project_route, ContentRoute::Welcome)); } #[test] @@ -752,6 +785,7 @@ mod tests { None, None, None, + None, &site_validation, ); assert!(matches!(route, ContentRoute::MenuEditor)); @@ -781,6 +815,7 @@ mod tests { None, None, None, + None, &site_validation, ); assert!(matches!( @@ -814,6 +849,7 @@ mod tests { None, None, None, + None, &site_validation, ); assert!(matches!(route, ContentRoute::FindDuplicates)); @@ -839,6 +875,7 @@ mod tests { None, None, None, + None, &site_validation_state, ); assert!(matches!(route, ContentRoute::GitDiff("git-diff:file.txt"))); @@ -864,6 +901,7 @@ mod tests { None, None, None, + None, &site_validation_state, ); assert!(matches!(route, ContentRoute::Chat("conversation"))); @@ -894,6 +932,7 @@ mod tests { None, None, None, + None, &site_validation_state, ); @@ -928,6 +967,7 @@ mod tests { &empty_imports, None, None, + None, Some(&dashboard), &site_validation_state, ); @@ -962,6 +1002,7 @@ mod tests { None, None, None, + None, &site_validation_state, ); diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index a8c2d07..d0c04bc 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -312,6 +312,16 @@ settings-nav-publishing = Veröffentlichung settings-nav-data = Daten settings-nav-mcp = MCP settings-nav-style = Stil +style-title = Stil +style-subtitle = Wähle das Pico-CSS-Theme für dieses Projekt. +style-themes = Themes +style-previewMode = Vorschaumodus +style-previewMode-auto = Automatisch +style-previewMode-light = Hell +style-previewMode-dark = Dunkel +style-apply = Theme anwenden +style-applied = Theme angewendet. +style-preview = Live-Vorschau tags-nav-cloud = Tag-Wolke tags-nav-manage = Tags verwalten tags-nav-merge = Tags zusammenführen @@ -578,7 +588,6 @@ dashboard-timeline = Beiträge im Zeitverlauf dashboard-recentPosts = Kürzlich aktualisiert dashboard-postCount = { $count } Beiträge dashboard-pin = Anheften -common-tabNotImplemented = Dieser Bereich wird in einem späteren Meilenstein implementiert. editor-noPreviewAvailable = Keine Vorschau verfügbar common-operationFailed = { $operation } fehlgeschlagen: { $error } common-databaseUnavailable = Datenbank nicht verfügbar diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index c6861f1..946ee7c 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -312,6 +312,16 @@ settings-nav-publishing = Publishing settings-nav-data = Data settings-nav-mcp = MCP settings-nav-style = Style +style-title = Style +style-subtitle = Choose the Pico CSS theme used by this project. +style-themes = Themes +style-previewMode = Preview Mode +style-previewMode-auto = Auto +style-previewMode-light = Light +style-previewMode-dark = Dark +style-apply = Apply Theme +style-applied = Theme applied. +style-preview = Live Preview tags-nav-cloud = Tag Cloud tags-nav-manage = Manage Tags tags-nav-merge = Merge Tags @@ -578,7 +588,6 @@ dashboard-timeline = Posts Over Time dashboard-recentPosts = Recently Updated dashboard-postCount = { $count } posts dashboard-pin = Pin -common-tabNotImplemented = This area will be implemented in a later milestone. editor-noPreviewAvailable = No preview available common-operationFailed = { $operation } failed: { $error } common-databaseUnavailable = Database unavailable diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index 16bc549..576009f 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -312,6 +312,16 @@ settings-nav-publishing = Publicación settings-nav-data = Datos settings-nav-mcp = MCP settings-nav-style = Estilo +style-title = Estilo +style-subtitle = Elige el tema de Pico CSS para este proyecto. +style-themes = Temas +style-previewMode = Modo de vista previa +style-previewMode-auto = Automático +style-previewMode-light = Claro +style-previewMode-dark = Oscuro +style-apply = Aplicar tema +style-applied = Tema aplicado. +style-preview = Vista previa en directo tags-nav-cloud = Nube de etiquetas tags-nav-manage = Gestionar etiquetas tags-nav-merge = Fusionar etiquetas @@ -578,7 +588,6 @@ dashboard-timeline = Entradas a lo largo del tiempo dashboard-recentPosts = Actualizadas recientemente dashboard-postCount = { $count } entradas dashboard-pin = Fijar -common-tabNotImplemented = Esta sección se implementará en un hito posterior. editor-noPreviewAvailable = No hay vista previa disponible common-operationFailed = Error en { $operation }: { $error } common-databaseUnavailable = Base de datos no disponible diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 07e3a1c..e2cfcc8 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -312,6 +312,16 @@ settings-nav-publishing = Publication settings-nav-data = Données settings-nav-mcp = MCP settings-nav-style = Style +style-title = Style +style-subtitle = Choisissez le thème Pico CSS de ce projet. +style-themes = Thèmes +style-previewMode = Mode d’aperçu +style-previewMode-auto = Automatique +style-previewMode-light = Clair +style-previewMode-dark = Sombre +style-apply = Appliquer le thème +style-applied = Thème appliqué. +style-preview = Aperçu en direct tags-nav-cloud = Nuage de tags tags-nav-manage = Gérer les tags tags-nav-merge = Fusionner les tags @@ -578,7 +588,6 @@ dashboard-timeline = Articles dans le temps dashboard-recentPosts = Récemment mis à jour dashboard-postCount = { $count } articles dashboard-pin = Épingler -common-tabNotImplemented = Cette section sera mise en œuvre lors d’une étape ultérieure. editor-noPreviewAvailable = Aucun aperçu disponible common-operationFailed = Échec de l’opération « { $operation } » : { $error } common-databaseUnavailable = Base de données indisponible diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index c465b2a..8102308 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -312,6 +312,16 @@ settings-nav-publishing = Pubblicazione settings-nav-data = Dati settings-nav-mcp = MCP settings-nav-style = Stile +style-title = Stile +style-subtitle = Scegli il tema Pico CSS per questo progetto. +style-themes = Temi +style-previewMode = Modalità anteprima +style-previewMode-auto = Automatica +style-previewMode-light = Chiara +style-previewMode-dark = Scura +style-apply = Applica tema +style-applied = Tema applicato. +style-preview = Anteprima in tempo reale tags-nav-cloud = Nuvola di tag tags-nav-manage = Gestisci tag tags-nav-merge = Unisci tag @@ -578,7 +588,6 @@ dashboard-timeline = Post nel tempo dashboard-recentPosts = Aggiornati di recente dashboard-postCount = { $count } post dashboard-pin = Fissa -common-tabNotImplemented = Questa sezione verrà implementata in una fase successiva. editor-noPreviewAvailable = Nessuna anteprima disponibile common-operationFailed = Operazione non riuscita ({ $operation }): { $error } common-databaseUnavailable = Database non disponibile