diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c249039a4..f316976fd 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -5511,9 +5511,10 @@ properties={:.1}ms picked={}", } let eye_dir = r_ucs.transform_vector3(region.snap_direction()); - // Faces snap to a canonical upright orientation (never upside - // down); edges/corners keep the current up-sense so they spin - // smoothly around the clicked feature. + // Faces and edges/corners all snap deterministically (world +Z + // horizon, north +Y for top/bottom) so a cube click is repeatable + // and matches the turntable orbit; snap_to_direction preserves an + // intentional upside-down sense by sign-flipping only. let is_face = matches!(region, scene::CubeRegion::Face(_)); if self.tabs[i].scene.active_viewport.is_some() { if is_face { diff --git a/src/io/mod.rs b/src/io/mod.rs index 7507ea053..7afc5868f 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -319,10 +319,24 @@ async fn open_path_with_phase_attempt( }); callback }; - std::fs::File::open(&path2).map_err(|error| OpenAttemptFailure { - message: format!("failed to open drawing: {error}"), - read_stats: None, - recoverable: false, + std::fs::File::open(&path2).map_err(|error| { + // 32 = ERROR_SHARING_VIOLATION, 33 = ERROR_LOCK_VIOLATION: + // another program holds the drawing without read sharing, + // so nothing on the machine can read the bytes until it + // closes the file — not even read-only. + let message = match error.raw_os_error() { + Some(32) | Some(33) => format!( + "\"{}\" is in use by another program. Close the file there and \ + reopen it here, or open a copy of the file.", + path2.display() + ), + _ => format!("failed to open drawing: {error}"), + }; + OpenAttemptFailure { + message, + read_stats: None, + recoverable: false, + } })?; let outcome = load_file_for_open(&path2, Some(parser_progress), &attempt)?; let read_stats = outcome.stats; @@ -1036,20 +1050,117 @@ fn read_dwg_path( DwgReadOptions::default() }; #[cfg(not(target_arch = "wasm32"))] - let mut reader = { - let mut reader = DwgReader::from_mmap(path).map_err(ReaderFailure::from_reader)?; - reader.options = options; + { + // The memory-mapped read is the fast path, but a mapped file is read + // through the page-fault handler: a file that shrinks while mapped + // (a writer saving over it) or a cloud placeholder that cannot page + // in raises an in-page exception the process cannot catch. Both are + // ordinary I/O errors through plain reads, so anything that can + // fault — or refuses to open under a third-party lock — falls back + // to one in-memory snapshot read below. + if !cloud_placeholder(path) { + match DwgReader::from_mmap(path) { + Ok(mut reader) => { + reader.options = options.clone(); + if let Some(progress) = &progress { + reader.set_progress_callback(progress.clone()); + } + match reader.read_with_stats() { + Ok(outcome) => return Ok(outcome), + // The file shrank or became unreadable mid-parse; + // retry from a snapshot instead of reporting a bare + // I/O error. + Err(acadrust::DxfError::Io(_)) => {} + Err(error) => return Err(ReaderFailure::from_reader(error)), + } + } + // Locked or unmappable: the snapshot read surfaces the real + // error when the file genuinely cannot be read. + Err(_) => {} + } + } + let bytes = read_drawing_snapshot(path).map_err(|error| { + ReaderFailure::terminal(format!( + "failed to open drawing (is it in use by another program?): {error}" + )) + })?; + let mut reader = DwgReader::from_stream_with_options(std::io::Cursor::new(bytes), options); + if let Some(progress) = progress { + reader.set_progress_callback(progress); + } reader - }; + .read_with_stats() + .map_err(ReaderFailure::from_reader) + } #[cfg(target_arch = "wasm32")] - let mut reader = DwgReader::from_file_with_options(path, options) - .map_err(ReaderFailure::from_reader)?; - if let Some(progress) = progress { - reader.set_progress_callback(progress); + { + let mut reader = DwgReader::from_file_with_options(path, options) + .map_err(ReaderFailure::from_reader)?; + if let Some(progress) = progress { + reader.set_progress_callback(progress); + } + reader + .read_with_stats() + .map_err(ReaderFailure::from_reader) + } +} + +/// Windows cloud-placeholder files (OneDrive "Files On Demand" and similar) +/// page their bytes in on demand; faulting a placeholder through a memory map +/// can raise an in-page exception that kills the process. Plain reads hydrate +/// the file instead. Checks `FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS`, +/// `FILE_ATTRIBUTE_RECALL_ON_OPEN` and `FILE_ATTRIBUTE_OFFLINE`. +#[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))] +fn cloud_placeholder(path: &Path) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_OFFLINE: u32 = 0x1000; + const FILE_ATTRIBUTE_RECALL_ON_OPEN: u32 = 0x0004_0000; + const FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS: u32 = 0x0040_0000; + std::fs::metadata(path) + .map(|meta| { + let attrs = meta.file_attributes(); + attrs + & (FILE_ATTRIBUTE_OFFLINE + | FILE_ATTRIBUTE_RECALL_ON_OPEN + | FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS) + != 0 + }) + .unwrap_or(false) +} + +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn cloud_placeholder(_path: &Path) -> bool { + false +} + +/// Read the whole drawing through the most permissive share mode the platform +/// offers. On Windows `std::fs::read` opens without `FILE_SHARE_DELETE`, so a +/// concurrent atomic-save replace makes the read fail even though every byte +/// was readable. A snapshot can never fault: a writer shrinking the file +/// mid-read just ends the read early. +#[cfg(not(target_arch = "wasm32"))] +fn read_drawing_snapshot(path: &Path) -> std::io::Result> { + #[cfg(target_os = "windows")] + { + use std::io::Read; + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + + let mut file = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .open(path)?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + Ok(bytes) + } + #[cfg(not(target_os = "windows"))] + { + std::fs::read(path) } - reader - .read_with_stats() - .map_err(ReaderFailure::from_reader) } fn read_dxf_path(path: &Path, failsafe: bool) -> Result { diff --git a/src/scene/pipeline/viewcube.rs b/src/scene/pipeline/viewcube.rs index 782f10a1e..fc1709aab 100644 --- a/src/scene/pipeline/viewcube.rs +++ b/src/scene/pipeline/viewcube.rs @@ -142,24 +142,39 @@ impl CubeRegion { /// Unit eye-direction vector (from target toward the camera) that /// looks straight at this region. Used by `Camera::snap_to_direction` - /// which derives the full orientation by re-using the current - /// camera's up vector, projected onto the plane perpendicular to - /// this direction — so clicking an edge spins the cube around the - /// edge without rolling the user's "up" sense. + /// / `snap_to_face` which derive a deterministic horizon (world +Z, + /// except top/bottom where north +Y is used) so every cube click + /// lands repeatably. pub fn snap_direction(self) -> glam::Vec3 { let c = region_centroids()[self.id()]; glam::Vec3::new(c[0], c[1], c[2]).normalize_or(glam::Vec3::Z) } pub fn opposite(self) -> CubeRegion { - match self { - CubeRegion::Face(FACE_TOP) => CubeRegion::Face(FACE_BOTTOM), - CubeRegion::Face(FACE_BOTTOM) => CubeRegion::Face(FACE_TOP), - CubeRegion::Face(FACE_FRONT) => CubeRegion::Face(FACE_BACK), - CubeRegion::Face(FACE_BACK) => CubeRegion::Face(FACE_FRONT), - CubeRegion::Face(FACE_RIGHT) => CubeRegion::Face(FACE_LEFT), - CubeRegion::Face(FACE_LEFT) => CubeRegion::Face(FACE_RIGHT), - other => other, + // Generic opposite: the region whose centroid is nearest to -self. + // Covers faces, edges and corners so "already there → flip" works + // for every cube click, not just faces. + let centroids = region_centroids(); + let c = centroids[self.id()]; + let neg = [-c[0], -c[1], -c[2]]; + let mut best = self.id(); + let mut best_d = f32::MAX; + for (i, cc) in centroids.iter().enumerate() { + let dx = cc[0] - neg[0]; + let dy = cc[1] - neg[1]; + let dz = cc[2] - neg[2]; + let d = dx * dx + dy * dy + dz * dz; + if d < best_d { + best_d = d; + best = i; + } + } + if best < 6 { + CubeRegion::Face(best) + } else if best < 18 { + CubeRegion::Edge(best) + } else { + CubeRegion::Corner(best) } } diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index c5364b9f7..c5a92d8c3 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -645,44 +645,29 @@ impl Camera { /// Snap to a canonical view direction (called by ViewCubeSnap). /// `eye_dir` is the unit vector from the target toward the camera. /// - /// Up vector resolution: - /// 1. Take the current up. - /// 2. Pick the world axis (±X, ±Y, ±Z) whose dot product with the - /// current up is highest — skipping any axis (anti-)parallel to - /// the new gaze direction. - /// 3. Project that axis onto the plane ⊥ `new_eye` and use that as - /// the new up. - /// - /// Result: small tilts collapse onto the nearest world axis (so the - /// view always lands cleanly aligned), while genuine flips of the - /// up-sense (e.g. orbited upside-down) are preserved. + /// Deterministic horizon: when looking close to ±Z (top/bottom) the + /// up is north (+Y), otherwise world up (+Z), projected onto the + /// plane ⊥ `new_eye`. This matches the turntable `orbit` (which never + /// banks) so a cube corner always lands with the same roll and the + /// base never appears rotated. The sign is flipped only if the + /// current view is intentionally upside-down, preserving that sense. pub fn snap_to_direction(&mut self, eye_dir: Vec3, ucs: glam::Mat4) { let new_eye = eye_dir.normalize_or(Vec3::Z); - let cur_up = self.rotation * Vec3::Y; - // Candidate up axes are the UCS axes, not world X/Y/Z, so a face snap - // lands the view square to the user's coordinate system (in-plane roll - // included). Identity `ucs` reproduces the world-aligned snap. - let ux = ucs.transform_vector3(Vec3::X).normalize_or(Vec3::X); let uy = ucs.transform_vector3(Vec3::Y).normalize_or(Vec3::Y); let uz = ucs.transform_vector3(Vec3::Z).normalize_or(Vec3::Z); - let cardinals = [ux, -ux, uy, -uy, uz, -uz]; - let mut best_score = f32::NEG_INFINITY; - let mut best_up = uz; - for axis in cardinals { - // Skip axes (nearly) collinear with the new gaze — they can't - // serve as up because the projection onto the plane would - // vanish. - if axis.dot(new_eye).abs() > 0.999 { - continue; - } - let score = axis.dot(cur_up); - if score > best_score { - best_score = score; - best_up = axis; - } - } - // Project the chosen axis onto the plane ⊥ new_eye and normalize. - let projected = best_up - new_eye * best_up.dot(new_eye); + // Deterministic horizon: top/bottom views (eye ≈ ±Z) use north (+Y) + // as up; every other direction uses world up (+Z). This keeps the + // cube's "Top Front Right" etc. repeatable and aligned with the + // turntable orbit (which never banks), and avoids the previous + // adaptive choice that picked the nearest cardinal to the current + // up — from a top view that was +Y, so an oblique top corner kept + // Y as up and tilted the base (roll) instead of keeping the horizon + // level. Preserve the sign only to keep an intentionally inverted + // (upside-down) view inverted. + let cur_up = self.rotation * Vec3::Y; + let raw_ref = if new_eye.dot(uz).abs() > 0.9 { uy } else { uz }; + let up_ref = if cur_up.dot(raw_ref) < 0.0 { -raw_ref } else { raw_ref }; + let projected = up_ref - new_eye * up_ref.dot(new_eye); let new_up = projected.normalize_or(if new_eye.dot(uz).abs() < 0.99 { (uz - new_eye * uz.dot(new_eye)).normalize() } else { diff --git a/src/ui/theme_accessibility_tests.rs b/src/ui/theme_accessibility_tests.rs index 576d167f5..f8e6caecf 100644 --- a/src/ui/theme_accessibility_tests.rs +++ b/src/ui/theme_accessibility_tests.rs @@ -9,6 +9,8 @@ //! - Properties & Dock Panels (field labels, section headers, muted text) //! - Modals & Action Buttons (dialog body, Primary, Danger, Success buttons) //! - Dropdowns & Selection Overlays (visual style picker, item checkmarks) +//! - Block Palette (card labels in normal/hover/pressed/placing states, +//! header icon buttons, empty-state muted text) use crate::ui::style::common::{accessible_accent, accessible_accent_threshold, wcag_contrast}; use iced::{Color, Theme}; @@ -462,3 +464,174 @@ fn test_viewport_controls_toggle_buttons_contrast() { } } +#[test] +fn test_block_palette_contrast() { + use crate::ui::window::block_palette::{ + block_card_border, block_card_colors, block_icon_button_text_color, + }; + use iced::widget::button::Status as BtnStatus; + + for theme in Theme::ALL { + let p = theme.palette(); + + // ── 1. Card label pairs resolve to the theme's own text colors ── + // Regression guard for the light-theme bug where labels were hardcoded + // to `Color::WHITE` (unreadable white-on-light). If anyone reintroduces + // a hardcoded foreground, these equality checks fail on every theme + // whose surface text is not pure white. + let (normal_bg, normal_fg) = block_card_colors(theme, false, BtnStatus::Active); + assert_eq!( + normal_bg, p.background.base.color, + "Theme {:?} block card normal background drifted from base pair", + theme + ); + assert_eq!( + normal_fg, p.background.base.text, + "Theme {:?} block card normal label must use base text (was hardcoded WHITE)", + theme + ); + + let (_, hover_fg) = block_card_colors(theme, false, BtnStatus::Hovered); + assert_eq!( + hover_fg, p.background.strong.text, + "Theme {:?} block card hover label must use strong text (was hardcoded WHITE)", + theme + ); + + let (_, pressed_fg) = block_card_colors(theme, false, BtnStatus::Pressed); + assert_eq!( + pressed_fg, p.background.strong.text, + "Theme {:?} block card pressed label must use strong text (was hardcoded WHITE)", + theme + ); + + // Default-branch statuses (Active/Disabled/Focused/...) share the base pair. + let (_, disabled_fg) = block_card_colors(theme, false, BtnStatus::Disabled); + assert_eq!( + disabled_fg, p.background.base.text, + "Theme {:?} block card default-state label must use base text", + theme + ); + + // Placing (selected) cards always use the primary pair, regardless of + // hover — the background does not switch on hover while placing. + for status in [BtnStatus::Active, BtnStatus::Hovered, BtnStatus::Pressed] { + let (bg, fg) = block_card_colors(theme, true, status); + assert_eq!(bg, p.primary.base.color, "Theme {:?} placing card bg must be primary.base", theme); + assert_eq!(fg, p.primary.base.text, "Theme {:?} placing card label must be primary.base.text", theme); + } + + // ── 2. Card label contrast: normal / hover / pressed ── + // 11px single-line labels are normal text → WCAG AA >= 4.5:1. + let normal_contrast = wcag_contrast(normal_fg, normal_bg); + assert!( + normal_contrast >= 4.5, + "Theme {:?} block card label ({:.2}:1) fails WCAG AA on card background", + theme, normal_contrast + ); + + let (hover_bg, hover_fg) = block_card_colors(theme, false, BtnStatus::Hovered); + let hover_contrast = wcag_contrast(hover_fg, hover_bg); + assert!( + hover_contrast >= 4.5, + "Theme {:?} block card hovered label ({:.2}:1) fails WCAG AA on hovered background", + theme, hover_contrast + ); + + let (pressed_bg, pressed_fg) = block_card_colors(theme, false, BtnStatus::Pressed); + let pressed_contrast = wcag_contrast(pressed_fg, pressed_bg); + assert!( + pressed_contrast >= 4.5, + "Theme {:?} block card pressed label ({:.2}:1) fails WCAG AA on pressed background", + theme, pressed_contrast + ); + + // ── 3. Placing card contrast ── + // Selected-card label on `primary.base`: UI button component threshold + // (>= 3.0:1 per WCAG 1.4.11, matching the Primary-button test above). + let (placing_bg, placing_fg) = block_card_colors(theme, true, BtnStatus::Active); + let placing_contrast = wcag_contrast(placing_fg, placing_bg); + assert!( + placing_contrast >= 3.0, + "Theme {:?} placing block card label ({:.2}:1) fails button threshold (>=3.0:1)", + theme, placing_contrast + ); + // Placing + hover must not silently drop contrast (bg is sticky). + let (placing_hover_bg, placing_hover_fg) = + block_card_colors(theme, true, BtnStatus::Hovered); + let placing_hover_contrast = wcag_contrast(placing_hover_fg, placing_hover_bg); + assert!( + placing_hover_contrast >= 3.0, + "Theme {:?} placing+hover block card label ({:.2}:1) fails button threshold (>=3.0:1)", + theme, placing_hover_contrast + ); + + // ── 4. Card border is theme-driven (no invisible borders) ── + assert_eq!( + block_card_border(theme, false), + p.background.neutral.color, + "Theme {:?} block card border must use neutral color", + theme + ); + assert_eq!( + block_card_border(theme, true), + p.primary.base.color, + "Theme {:?} placing block card border must use primary color", + theme + ); + + // ── 5. Header icon buttons (glyphs inherit button text_color) ── + // Resting buttons are transparent over the dock (`base`) background. + let icon_rest = block_icon_button_text_color(theme, BtnStatus::Active); + assert_eq!(icon_rest, p.background.base.text); + let icon_rest_contrast = wcag_contrast(icon_rest, p.background.base.color); + assert!( + icon_rest_contrast >= 4.5, + "Theme {:?} block palette icon button ({:.2}:1) fails WCAG AA on dock background", + theme, icon_rest_contrast + ); + // Hovered/pressed buttons sit on the `strong` surface with its text. + for status in [BtnStatus::Hovered, BtnStatus::Pressed] { + let icon_fg = block_icon_button_text_color(theme, status); + assert_eq!( + icon_fg, p.background.strong.text, + "Theme {:?} hovered icon button must use strong text", + theme + ); + let c = wcag_contrast(icon_fg, p.background.strong.color); + assert!( + c >= 4.5, + "Theme {:?} hovered icon button ({:.2}:1) fails WCAG AA on hovered background", + theme, c + ); + } + + // ── 6. Empty-state muted text ("No blocks in this drawing") ── + // Rendered as `base.text` at 0.72 alpha over the dock `base` + // background → secondary text threshold >= 3.0:1. (The old hardcoded + // 0.55-gray failed this on light themes.) + let empty_text = composite_over(p.background.base.text.scale_alpha(0.72), p.background.base.color); + let empty_contrast = wcag_contrast(empty_text, p.background.base.color); + assert!( + empty_contrast >= 3.0, + "Theme {:?} block palette empty-state text ({:.2}:1) fails secondary text threshold (>=3.0:1)", + theme, empty_contrast + ); + + // ── 7. Hardcoded-white regression probe ── + // The exact reported bug: pure white labels on the light card + // background. Every theme's card background must NOT be near-white + // with white text — i.e. white-on-card must fail while the real + // theme pair passes (proves the fix matters on light themes and the + // test would catch a WHITE hardcode). + let white_on_normal = wcag_contrast(Color::WHITE, normal_bg); + if white_on_normal < 4.5 { + assert!( + normal_contrast >= 4.5, + "Theme {:?} needs theme-aware labels: white-on-card is {:.2}:1 but theme pair is {:.2}:1", + theme, white_on_normal, normal_contrast + ); + } + } +} + diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs index bd8e7f351..f82ed09c8 100644 --- a/src/ui/window/block_palette.rs +++ b/src/ui/window/block_palette.rs @@ -264,7 +264,20 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< } else { "No matches" }; - container(text(crate::t!(msg)).size(12).color(iced::Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 })) + container( + text(crate::t!(msg)) + .size(12) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some( + theme + .palette() + .background + .base + .text + .scale_alpha(0.72), + ), + }), + ) .center_x(Fill) .center_y(Fill) .width(Fill) @@ -307,6 +320,56 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< .into() } +/// Theme-aware foreground/background pair for a block card. +/// +/// Centralizes the card's text/background mapping so the contrast suite can +/// exercise the exact colors the UI renders (instead of re-implementing the +/// match). Normal cards use the `base` pair, hovered/pressed cards use the +/// `strong` pair, and the actively-placing card uses the `primary.base` pair. +pub(crate) fn block_card_colors( + theme: &Theme, + is_placing: bool, + status: button::Status, +) -> (Color, Color) { + let palette = theme.palette(); + if is_placing { + (palette.primary.base.color, palette.primary.base.text) + } else { + match status { + button::Status::Hovered | button::Status::Pressed => { + (palette.background.strong.color, palette.background.strong.text) + } + _ => ( + palette.background.base.color, + palette.background.base.text, + ), + } + } +} + +/// Border color for a block card in a given state (exposed for contrast tests). +pub(crate) fn block_card_border(theme: &Theme, is_placing: bool) -> Color { + let palette = theme.palette(); + if is_placing { + palette.primary.base.color + } else { + palette.background.neutral.color + } +} + +/// Theme-aware foreground for the palette's header icon buttons. +/// +/// Transparent resting state composites over the dock (`base`) background; +/// hovered/pressed state sits on the `strong` surface, so each state pairs +/// with its own background's text color to preserve contrast. +pub(crate) fn block_icon_button_text_color(theme: &Theme, status: button::Status) -> Color { + let palette = theme.palette(); + match status { + button::Status::Hovered | button::Status::Pressed => palette.background.strong.text, + _ => palette.background.base.text, + } +} + fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'a, Message> { let is_placing = palette.placing.as_deref() == Some(block.name.as_str()); let preview = canvas(BlockPreviewCanvas { wires: &block.wires }) @@ -315,9 +378,13 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<' // Fixed height guarantees the label occupies exactly one line, so every // cell in a row keeps the same height even for long names (the elide budget // handles overflow, and this clips anything that still won't fit). + // + // NOTE: no explicit `.color(...)` here — the label inherits the button's + // `text_color` so normal/hover/placing states each get a theme-aware + // foreground with sufficient contrast on both light and dark themes. + // (A hardcoded `Color::WHITE` is unreadable on light themes.) let label = text(crate::ui::text_util::elide(&block.name, MAX_LABEL_CHARS)) .size(11) - .color(Color::WHITE) .width(Fill) .height(Length::Fixed(LABEL_LINE_H)) .center(); @@ -326,18 +393,8 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<' .on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone()))) .width(Fill) .style(move |theme: &Theme, status| { - let palette = theme.palette(); - let neutral = palette.background.neutral.color; - let base = palette.background.base.color; - let strong = palette.background.strong.color; - let (bg, border) = if is_placing { - (palette.primary.base.color, palette.primary.base.color) - } else { - match status { - button::Status::Hovered | button::Status::Pressed => (strong, neutral), - _ => (base, neutral), - } - }; + let (bg, fg) = block_card_colors(theme, is_placing, status); + let border = block_card_border(theme, is_placing); button::Style { background: Some(Background::Color(bg)), border: Border { @@ -345,7 +402,7 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<' width: 1.0, radius: 3.0.into(), }, - text_color: Color::WHITE, + text_color: fg, ..Default::default() } }) @@ -354,7 +411,9 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<' fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message> { let icon_el: Element<'_, Message> = match icon { - IconKind::Glyph(s) => text(s).size(15).color(Color::WHITE).into(), + // No explicit color — inherits the button's theme-aware `text_color` + // so glyphs stay legible on light themes. + IconKind::Glyph(s) => text(s).size(15).into(), IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, TOOL_H), }; button(icon_el) @@ -372,7 +431,7 @@ fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message> radius: 3.0.into(), ..Default::default() }, - text_color: Color::WHITE, + text_color: block_icon_button_text_color(theme, status), ..Default::default() }) .into()