Browse / github.com/storytold/craft-libs / patch
craft-ui: layered UI elevation shadows and egui 0.36 frame adapter
craft-ui: layered UI elevation shadows and egui 0.36 frame adapter Unifies shadows across *craft apps inside craft-ui, replacing ad-hoc single-pass blurs that currently look muddy and dated. Combining ambient and key lighting layers with calibrated falloffs gives the UI a clean, modern depth that fits professional design tools. - Added craft_ui::theme::shadow with ElevationShadow, ShadowLayer, and ShadowTokens across semantic tiers (Flat, Subtle, Raised, Overlay, Floating, Modal). - Added ElevationShadow::to_frame() and as_frame() for direct egui::Frame compatibility (mapping tint, offset, and blur in egui 0.36). - Integrated with DockingSurfaceStyle in dock
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- TheTeslak (imported from pull request #103, open)
- Base
457a078809690994b22debdffb703ff36d3129df- Size
- +827 −6 in 8 files
- Review
- pending
- Shared
- 2026-10-28
- Id
180f4bff0a8fedeb0926bf51
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui/README.md crates/ui/examples/shadows.rs crates/ui/src/docking/surface.rs crates/ui/src/theme/mod.rs crates/ui/src/theme/shadow.rs crates/ui/src/theme/shadow/tests.rs docs/shadows.md docs/theme-infrastructure.md
@@ -2,7 +2,8 @@ `craft-ui` provides small egui components with application-supplied styles. `icons` prepares embedded SVG catalogs; `buttons` paints accessible icon buttons; `tabs` handles tab fitting and-interaction; `layout` provides toolbar fitting, split geometry and resize handles; `theme` shares widget-state styling.+interaction; `layout` provides toolbar fitting, split geometry and resize handles; `theme` shares+widget-state styling and optional layered shadow rendering. `docking` supplies transactional panel moves and an optional docking surface. `printing` supplies printer/job controls and asynchronous feedback used by WordCraft and GridCraft. The current delivery covers nine creative apps (DesignCraft, VectorCraft, PhotoCraft, FilmCraft, EffectCraft, CADCraft, PDFCraft, LightCraft and SoundCraft); prior basic and office adapters are separate. The [suite audit](../../docs/ui-components-audit.md#adoption-and-acceptance)@@ -208,10 +209,16 @@ fn style_widgets(visuals: &mut egui::Visuals, palette: WidgetPalette, text: Colo ``` This is a mechanism for coloring widgets, not a shared theme schema. Fonts, complete palettes,-selection colors, shadows, spacing, context storage and preference serialization remain in each+selection colors, shadow recipes, spacing, context storage and preference serialization remain in each application. See the [eight-app comparison](../../docs/theme-infrastructure.md) for the exact common operation and the differences preserved by the adapters. +`theme::shadow` paints explicit shadow layers, supplies optional PhotoCraft and floating-toolbar+recipes, and offers an owned frame helper. Hosts can opt into a constrained egui 0.36 frame+adapter for built-in menus, ComboBoxes and windows. See [shadow integration and manual+acceptance](../../docs/shadows.md) for contracts, supported consumers, fallback cases and the+comparison-gallery command. Canvas shadows and document effects remain application-owned.+ ## Print settings and feedback `printing::State` holds the transient printer selection, copies, duplex and grayscale settings,new file mode 100644@@ -0,0 +1,67 @@+//! Manual comparison gallery: cargo run -p craft-ui --example shadows -- /tmp/craft-shadows+//! Generates PNGs for human review; there are no approved pixel goldens.++use craft_ui::theme::shadow::{self, Elevation};+use egui::{Color32, CornerRadius, FontId, Pos2, RawInput, Rect, Shadow, Stroke, StrokeKind, pos2, vec2};+use egui_kittest::{TestRenderer, wgpu::WgpuTestRenderer};++fn main() -> Result<(), Box<dyn std::error::Error>> {+ let directory = std::env::args_os().nth(1).map(std::path::PathBuf::from).unwrap_or_else(|| std::env::temp_dir().join("craft-shadows"));+ std::fs::create_dir_all(&directory)?;+ for dark in [false, true] {+ for scale in [1.0, 2.0] {+ let ctx = egui::Context::default();+ ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light });+ let mut renderer = WgpuTestRenderer::new();+ for frame in 0..3 {+ let mut input = RawInput {+ screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(960.0, 520.0))),+ time: Some(frame as f64),+ ..Default::default()+ };+ input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale);+ let mut output = ctx.run_ui(input, |ui| {+ let canvas = if dark { Color32::from_gray(30) } else { Color32::from_gray(245) };+ let panel = if dark { Color32::from_gray(44) } else { Color32::WHITE };+ let border = if dark { Color32::from_gray(68) } else { Color32::from_gray(230) };+ let ink = if dark { Color32::WHITE } else { Color32::BLACK };+ let color = Color32::from_black_alpha(if dark { 90 } else { 40 });+ let painter = ui.painter();+ painter.rect_filled(ui.max_rect(), 0, canvas);+ for row in 0..10 {+ for column in 0..24 {+ if (row + column) % 2 == 0 {+ let rect = Rect::from_min_size(pos2(column as f32 * 40.0, 280.0 + row as f32 * 24.0), vec2(40.0, 24.0));+ painter.rect_filled(rect, 0, if dark { Color32::from_gray(42) } else { Color32::from_gray(230) });+ }+ }+ }+ let source = Shadow { offset: [0, 4], blur: 16, spread: 0, color };+ let recipes: [(&str, Vec<Shadow>); 3] = [+ ("Single layer", vec![source]),+ ("PhotoCraft / Medium", Elevation::Medium.layers(color).into()),+ ("Floating toolbar", shadow::floating_toolbar(color).into()),+ ];+ for (column, (label, layers)) in recipes.iter().enumerate() {+ let x = 36.0 + column as f32 * 310.0;+ painter.text(pos2(x, 24.0), egui::Align2::LEFT_TOP, label, FontId::proportional(16.0), ink);+ for (y, height, radius) in [(90.0, 100.0, 8), (355.0, 48.0, 13)] {+ let rect = Rect::from_min_size(pos2(x, y), vec2(260.0, height));+ shadow::paint(painter, rect, CornerRadius::same(radius), layers);+ painter.rect(rect, radius, panel, Stroke::new(1.0, border), StrokeKind::Inside);+ painter.text(rect.center(), egui::Align2::CENTER_CENTER, "Surface", FontId::proportional(14.0), ink);+ }+ }+ });+ renderer.handle_delta(&mut output.textures_delta);+ let image = renderer.render(&ctx, &output)?;+ if frame == 2 {+ let path = directory.join(format!("{}-{}x.png", if dark { "dark" } else { "light" }, scale as u8));+ image.save(&path)?;+ println!("{}", path.display());+ }+ }+ }+ }+ Ok(())+}@@ -213,14 +213,14 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> { while let Some((node, path)) = nodes.pop() { match node { Node::Tabs { panels, .. } if group(panels) => return true,- Node::Stack { entries } => {+ Node::Stack { entries } if entries.iter().any(|entry| { matches(self.id.with(("tab", &entry.panel))) || matches(self.id.with(("collapse", &entry.panel))) || matches(self.id.with(("stack-resize", &entry.panel)))- }) {- return true;- }+ }) =>+ {+ return true; } Node::Split { first, second, .. } => { if matches(self.id.with(("split", &path))) {@@ -5,6 +5,8 @@ use egui::{Color32, CornerRadius, Stroke, style::Widgets}; +pub mod shadow;+ /// Background colors for egui's five widget interaction states. /// /// These are rendering inputs, not a theme schema: applications construct them from their ownnew file mode 100644@@ -0,0 +1,235 @@+//! Layered UI shadows in logical egui points. Applications own surface roles and colors.+//!+//! Prefer [`paint`] or [`show_frame`] for owned surfaces. [`install`] opts into a constrained+//! egui 0.36 frame adapter for built-in containers, including ComboBoxes and nested menus.++use egui::{Color32, Context, CornerRadius, Frame, InnerResponse, LayerId, Painter, Rect, Shadow, Shape, Ui, layers::ShapeIdx};+use std::collections::HashMap;++/// The PhotoCraft reference recipes. These are optional defaults, not stacking/input policy.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Elevation {+ Low,+ Medium,+ High,+ Modal,+}++impl Elevation {+ /// Back-to-front layers. Preserve the supplied hue and scale its premultiplied opacity.+ pub fn layers(self, color: Color32) -> [Shadow; 4] {+ let parameters = match self {+ Self::Low => [(2, 10), (1, 6), (1, 3), (0, 2)],+ Self::Medium => [(5, 18), (3, 10), (2, 5), (1, 3)],+ Self::High => [(8, 28), (4, 14), (2, 7), (1, 4)],+ Self::Modal => [(12, 44), (6, 20), (3, 10), (1, 5)],+ };+ let weights = [0.30, 0.35, 0.25, 0.10];+ std::array::from_fn(|i| {+ let (y, blur) = parameters[i];+ Shadow { offset: [0, y], blur, spread: 0, color: color.gamma_multiply(weights[i]) }+ })+ }+}++/// A restrained, two-layer starting point for persistent floating toolbars.+pub fn floating_toolbar(color: Color32) -> [Shadow; 2] {+ [+ Shadow { offset: [0, 3], blur: 16, spread: 0, color: color.gamma_multiply(0.45) },+ Shadow { offset: [0, 1], blur: 4, spread: 0, color: color.gamma_multiply(0.30) },+ ]+}++fn valid(rect: Rect) -> bool {+ rect.is_finite() && rect.size().is_finite() && rect.is_positive()+}++/// Generate only shadow shapes, in back-to-front order. An empty recipe means no shadow.+/// `Shadow::blur` is egui's feathered edge width, not Gaussian sigma.+pub fn shapes(rect: Rect, radius: CornerRadius, layers: &[Shadow]) -> Vec<Shape> {+ if !valid(rect) {+ return Vec::new();+ }+ layers.iter().filter(|s| s.color != Color32::TRANSPARENT).map(|s| Shape::Rect(s.as_shape(rect, radius))).collect()+}++/// Paint behind a surface that the caller draws afterward. Retains painter clipping/opacity.+pub fn paint(painter: &Painter, rect: Rect, radius: CornerRadius, layers: &[Shadow]) {+ painter.extend(shapes(rect, radius, layers));+}++/// Bounds of the surface and its visible shadows; does not enlarge the painter's clip rect.+pub fn visual_bounds(rect: Rect, layers: &[Shadow]) -> Rect {+ if !valid(rect) {+ return Rect::NOTHING;+ }+ layers.iter().filter(|s| s.color != Color32::TRANSPARENT).fold(rect, |bounds, s| bounds.union(rect + s.margin()))+}++/// A frame with an owned shadow slot; preserves egui's layout, margins and content response.+/// The supplied layers replace `frame.shadow`, including when the layer slice is empty.+pub fn show_frame<R>(ui: &mut Ui, frame: Frame, layers: &[Shadow], content: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {+ let slot = ui.painter().add(Shape::Noop);+ let viewport = ui.ctx().viewport_id();+ ui.ctx().with_plugin(|plugin: &mut EguiFrames| {+ plugin.passes.entry(viewport).or_default().owned.push((ui.layer_id(), slot));+ });+ let frame = frame.shadow(Shadow::NONE);+ let mut prepared = frame.begin(ui);+ let inner = content(&mut prepared.content_ui);+ let rect = frame.widget_rect(prepared.content_ui.min_rect());+ let mut background = shapes(rect, frame.corner_radius, layers);+ background.push(frame.paint(prepared.content_ui.min_rect()));+ ui.painter().set(slot, Shape::Vec(background));+ let response = prepared.allocate_space(ui);+ InnerResponse { inner, response }+}++/// An explicitly registered single-shadow signature and its replacement layers.+/// Unrecognized frames retain their original shadow. Do not register document effects here.+#[derive(Clone, Debug, PartialEq)]+pub struct FrameShadow {+ pub source: Shadow,+ pub layers: Vec<Shadow>,+}++impl FrameShadow {+ pub fn new(source: Shadow, layers: impl Into<Vec<Shadow>>) -> Self {+ Self { source, layers: layers.into() }+ }+}++/// Configure the egui 0.36 adapter once per theme change. Reconfiguration replaces its rules.+///+/// At the end of each pass it recognizes only an exact two-rectangle egui Frame shape whose+/// caster matches its Area bounds and whose original shadow matches a registered signature.+/// It preserves the painted frame, clipping, layer transform and inherited fade opacity.+/// Other structures (including nested/arbitrary shape vectors) are left untouched.+/// This also covers popups created inside craft-ui without changing their interaction code.+pub fn install(ctx: &Context, rules: Vec<FrameShadow>) {+ ctx.plugin_or_default::<EguiFrames>().lock().rules = rules;+}++/// Override one known container's recipe for this pass, after its `show` call.+/// Returns false if the adapter is not installed. Unsupported frame structures still fall back+/// unchanged. An empty recipe removes a recognized shadow; it never removes the surface/scrim.+pub fn set_layer_shadows(ctx: &Context, layer: LayerId, layers: impl Into<Vec<Shadow>>) -> bool {+ let viewport = ctx.viewport_id();+ ctx.with_plugin(|plugin: &mut EguiFrames| {+ let pass = plugin.passes.entry(viewport).or_default();+ let layers = layers.into();+ if let Some((_, previous)) = pass.overrides.iter_mut().find(|(id, _)| *id == layer) {+ *previous = layers;+ } else {+ pass.overrides.push((layer, layers));+ }+ })+ .is_some()+}++/// Result for an inspected Area in the most recently completed pass.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum FrameStatus {+ Applied,+ /// The original painting was retained, including unsupported or ambiguous structures.+ Unchanged,+}++/// Read after rendering a pass. `None` means the adapter did not inspect this Area (or is not+/// installed). This is a development diagnostic; no fallback warnings are shown in the UI.+pub fn frame_status(ctx: &Context, layer: LayerId) -> Option<FrameStatus> {+ let viewport = ctx.viewport_id();+ ctx.with_plugin(|plugin: &mut EguiFrames| {+ plugin.passes.get(&viewport).and_then(|pass| pass.results.iter().find(|(id, _)| *id == layer).map(|(_, status)| *status))+ })+ .flatten()+}++#[derive(Default)]+struct EguiFrames {+ rules: Vec<FrameShadow>,+ passes: HashMap<egui::ViewportId, FramePass>,+}++#[derive(Default)]+struct FramePass {+ overrides: Vec<(LayerId, Vec<Shadow>)>,+ results: Vec<(LayerId, FrameStatus)>,+ owned: Vec<(LayerId, ShapeIdx)>,+}++impl egui::plugin::Plugin for EguiFrames {+ fn debug_name(&self) -> &'static str {+ "craft-ui shadows (egui 0.36)"+ }++ fn on_begin_pass(&mut self, ui: &mut Ui) {+ // Immediate child viewports can render inside their parent's pass.+ // Clear only this viewport's state and release state for closed viewports.+ ui.ctx().input(|input| self.passes.retain(|id, _| input.raw.viewports.contains_key(id)));+ let pass = self.passes.entry(ui.ctx().viewport_id()).or_default();+ pass.overrides.clear();+ pass.results.clear();+ pass.owned.clear();+ }++ fn on_end_pass(&mut self, ui: &mut Ui) {+ let ctx = ui.ctx();+ let pass = self.passes.entry(ctx.viewport_id()).or_default();+ let areas: Vec<_> = ctx.memory(|m| m.layer_ids().filter_map(|layer| m.area_rect(layer.id).map(|rect| (layer, rect))).collect());+ ctx.graphics_mut(|graphics| {+ for (layer, rect) in areas {+ if !valid(rect) || layer.order == egui::Order::Background {+ continue;+ }+ let Some(list) = graphics.get_mut(layer) else { continue };+ let replacement = pass.overrides.iter().find(|(id, _)| *id == layer).map(|(_, layers)| layers.as_slice());+ let candidates: Vec<_> = list+ .all_entries()+ .enumerate()+ .filter(|(i, _)| !pass.owned.contains(&(layer, ShapeIdx(*i))))+ .filter_map(|(i, entry)| replacement_shape(&entry.shape, rect, &self.rules, replacement).map(|shape| (ShapeIdx(i), shape)))+ .collect();+ // Ambiguous structure is unsupported: do not guess which entry owns the frame.+ if let [(index, shape)] = candidates.as_slice() {+ list.mutate_shape(*index, |entry| entry.shape = shape.clone());+ pass.results.push((layer, FrameStatus::Applied));+ } else {+ pass.results.push((layer, FrameStatus::Unchanged));+ }+ }+ });+ }+}++fn replacement_shape(shape: &Shape, area: Rect, rules: &[FrameShadow], override_layers: Option<&[Shadow]>) -> Option<Shape> {+ let Shape::Vec(pair) = shape else { return None };+ let [Shape::Rect(shadow), Shape::Rect(frame)] = pair.as_slice() else { return None };+ if !valid(frame.rect) || frame.rect != area || frame.angle != 0.0 || frame.blur_width != 0.0 {+ return None;+ }+ for rule in rules {+ let source_alpha = f32::from(rule.source.color.a());+ if source_alpha == 0.0 {+ continue;+ }+ let opacity = f32::from(shadow.fill.a()) / source_alpha;+ if opacity > 1.0 {+ continue;+ }+ let mut expected = rule.source.as_shape(frame.rect, frame.corner_radius);+ expected.fill = rule.source.color.gamma_multiply(opacity);+ if *shadow != expected {+ continue;+ }+ let layers: Vec<_> =+ override_layers.unwrap_or(&rule.layers).iter().map(|s| Shadow { color: s.color.gamma_multiply(opacity), ..*s }).collect();+ let mut result = shapes(frame.rect, frame.corner_radius, &layers);+ result.push(Shape::Rect(frame.clone()));+ return Some(Shape::Vec(result));+ }+ None+}++#[cfg(test)]+mod tests;new file mode 100644@@ -0,0 +1,369 @@+use super::*;+use egui::{CornerRadius, Pos2, RawInput, Stroke, StrokeKind, pos2, vec2};++fn rect() -> Rect {+ Rect::from_min_size(pos2(30.0, 40.0), vec2(160.0, 80.0))+}++fn source() -> Shadow {+ Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(100) }+}++fn rule() -> FrameShadow {+ FrameShadow::new(source(), Elevation::Medium.layers(source().color))+}++fn original(rect: Rect) -> Shape {+ Frame::NONE.fill(Color32::WHITE).stroke(Stroke::new(1.0, Color32::GRAY)).corner_radius(6).shadow(source()).paint(rect.shrink(1.0))+}++fn rects(shape: &Shape, out: &mut Vec<egui::epaint::RectShape>) {+ match shape {+ Shape::Rect(rect) => out.push(rect.clone()),+ Shape::Vec(shapes) => {+ for shape in shapes {+ rects(shape, out);+ }+ }+ _ => {}+ }+}++#[test]+fn renderer_retains_color_radius_clip_and_painter_opacity_once() {+ let ctx = Context::default();+ let clip = rect().expand(50.0);+ let mut painter = ctx.layer_painter(LayerId::new(egui::Order::Foreground, egui::Id::new("owned"))).with_clip_rect(clip);+ let clip = painter.clip_rect();+ painter.set_opacity(0.5);+ let color = Color32::from_rgba_unmultiplied(40, 100, 160, 100);+ let radius = CornerRadius { nw: 2, ne: 3, sw: 4, se: 5 };+ let layers = [Shadow { color, ..source() }];+ paint(&painter, rect(), radius, &layers);+ ctx.graphics(|graphics| {+ let entry = graphics.get(painter.layer_id()).unwrap().all_entries().next().unwrap();+ assert_eq!(entry.clip_rect, clip);+ let Shape::Rect(shadow) = &entry.shape else { panic!("expected shadow rectangle") };+ assert_eq!(shadow.fill, color.gamma_multiply(0.5));+ assert_eq!(shadow.corner_radius, radius);+ });+ assert_eq!(visual_bounds(rect(), &layers), rect().union(rect() + source().margin()));+}++#[test]+fn invalid_and_empty_inputs_do_not_paint() {+ let layer = [source()];+ for invalid in [+ Rect::NOTHING,+ Rect::from_min_size(Pos2::ZERO, vec2(0.0, 10.0)),+ Rect::from_min_size(Pos2::ZERO, vec2(f32::NAN, 10.0)),+ Rect::from_min_max(pos2(-f32::MAX, 0.0), pos2(f32::MAX, 10.0)),+ ] {+ assert!(shapes(invalid, CornerRadius::ZERO, &layer).is_empty());+ assert_eq!(visual_bounds(invalid, &layer), Rect::NOTHING);+ }+ assert!(shapes(rect(), CornerRadius::ZERO, &[]).is_empty());+ assert!(shapes(rect(), CornerRadius::ZERO, &[Shadow::NONE]).is_empty());+ assert_eq!(visual_bounds(rect(), &[Shadow::NONE]), rect());+}++#[test]+fn adapter_requires_complete_registered_signature_and_exact_caster() {+ let original = original(rect());+ let converted = replacement_shape(&original, rect(), &[rule()], None).unwrap();+ let Shape::Vec(converted) = converted else { panic!("expected frame") };+ assert_eq!(converted.len(), 5);+ assert_eq!(+ converted.last(),+ match &original {+ Shape::Vec(pair) => pair.last(),+ _ => None,+ }+ );+ assert!(replacement_shape(&Shape::rect_filled(rect(), 6, Color32::WHITE), rect(), &[rule()], None).is_none());+ assert!(replacement_shape(&original, rect().expand(1.0), &[rule()], None).is_none());+ assert!(replacement_shape(&original, rect(), &[FrameShadow::new(Shadow { blur: 17, ..source() }, vec![])], None).is_none());+ let Shape::Vec(mut pair) = original else { panic!() };+ pair.insert(0, Shape::Noop);+ assert!(replacement_shape(&Shape::Vec(pair), rect(), &[rule()], None).is_none());+}++#[test]+fn adapter_preserves_fade_and_removes_only_the_owned_shadow() {+ let mut original = original(rect());+ let Shape::Vec(pair) = &mut original else { panic!() };+ for shape in pair {+ if let Shape::Rect(rect) = shape {+ rect.fill = rect.fill.gamma_multiply(0.5);+ rect.stroke.color = rect.stroke.color.gamma_multiply(0.5);+ }+ }+ let converted = replacement_shape(&original, rect(), &[rule()], None).unwrap();+ let Shape::Vec(layers) = converted else { panic!() };+ let Shape::Rect(shadow) = layers.first().unwrap() else { panic!() };+ assert_eq!(shadow.fill, rule().layers[0].color.gamma_multiply(0.5));+ assert!(replacement_shape(&Shape::Vec(layers), rect(), &[rule()], None).is_none(), "must not repeatedly append shadows");+ let removed = replacement_shape(&original, rect(), &[rule()], Some(&[])).unwrap();+ let Shape::Vec(remaining) = removed else { panic!() };+ assert_eq!(remaining.len(), 1);+ let Shape::Vec(original) = original else { panic!() };+ assert_eq!(remaining.last(), original.last());+}++fn render(ctx: &Context, mut content: impl FnMut(&mut Ui)) -> Vec<egui::epaint::RectShape> {+ let mut output = egui::FullOutput::default();+ for frame in 0..4 {+ output = ctx.run_ui(+ RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(640.0, 480.0))), time: Some(frame as f64), ..Default::default() },+ |ui| content(ui),+ );+ output.textures_delta.clear();+ }+ let mut rectangles = Vec::new();+ for entry in &output.shapes {+ rects(&entry.shape, &mut rectangles);+ }+ rectangles+}++#[test]+fn real_window_popup_and_modal_use_the_adapter_without_touching_the_scrim() {+ for kind in ["window", "popup", "modal", "combo"] {+ let ctx = Context::default();+ ctx.all_styles_mut(|s| {+ s.animation_time = 0.0;+ s.visuals.window_shadow = source();+ s.visuals.popup_shadow = source();+ });+ install(&ctx, vec![rule()]);+ let scrim = Color32::from_black_alpha(63);+ let rectangles = render(&ctx, |ui| match kind {+ "window" => {+ egui::Window::new("test").fixed_pos(rect().min).show(ui.ctx(), |ui| ui.label("content"));+ }+ "modal" => {+ egui::Modal::new(egui::Id::new("modal")).backdrop_color(scrim).show(ui.ctx(), |ui| ui.label("content"));+ }+ "combo" => {+ let combo = egui::ComboBox::from_id_salt("combo").show_ui(ui, |ui| ui.label("content"));+ egui::Popup::open_id(ui.ctx(), combo.response.id.with("popup"));+ }+ _ => {+ egui::Popup::new(egui::Id::new("popup"), ui.ctx().clone(), egui::PopupAnchor::Position(rect().min), ui.layer_id())+ .open(true)+ .show(|ui| ui.label("content"));+ }+ });+ let shadows: Vec<_> = rectangles.iter().filter(|r| r.blur_width > 0.0).collect();+ assert_eq!(shadows.len(), 4, "{kind}: {shadows:?}");+ if kind == "modal" {+ assert!(rectangles.iter().any(|r| r.fill == scrim && r.blur_width == 0.0 && r.corner_radius == CornerRadius::ZERO));+ }+ }+}++#[test]+fn owned_frame_preserves_layout_and_allows_no_shadow() {+ let ctx = Context::default();+ let rectangles = render(&ctx, |ui| {+ let frame = Frame::NONE.fill(Color32::GREEN).inner_margin(7).outer_margin(3).stroke(Stroke::new(2.0, Color32::RED)).shadow(source());+ let expected = frame.show(ui, |ui| ui.allocate_exact_size(vec2(60.0, 20.0), egui::Sense::hover())).response.rect.size();+ let response = show_frame(ui, frame, &[], |ui| ui.allocate_exact_size(vec2(60.0, 20.0), egui::Sense::hover()));+ assert_eq!(response.response.rect.size(), expected);+ });+ assert_eq!(rectangles.iter().filter(|r| r.blur_width > 0.0).count(), 1);+ assert_eq!(rectangles.iter().filter(|r| r.fill == Color32::GREEN && r.stroke_kind == StrokeKind::Inside).count(), 2);+}++#[test]+fn nested_popups_are_independent_and_layer_overrides_expire() {+ let ctx = Context::default();+ ctx.all_styles_mut(|s| {+ s.animation_time = 0.0;+ s.visuals.popup_shadow = source();+ });+ install(&ctx, vec![rule()]);+ let first = egui::Id::new("first");+ let second = egui::Id::new("second");+ let nested = |ui: &mut Ui, hide_first| {+ let first = egui::Popup::new(first, ui.ctx().clone(), egui::PopupAnchor::Position(pos2(30.0, 30.0)), ui.layer_id())+ .open(true)+ .show(|ui| {+ ui.label("parent");+ egui::Popup::new(second, ui.ctx().clone(), egui::PopupAnchor::Position(pos2(250.0, 30.0)), ui.layer_id())+ .open(true)+ .show(|ui| ui.label("child"));+ })+ .unwrap();+ if hide_first {+ assert!(set_layer_shadows(ui.ctx(), first.response.layer_id, vec![]));+ }+ };+ let rectangles = render(&ctx, |ui| nested(ui, true));+ assert_eq!(rectangles.iter().filter(|r| r.blur_width > 0.0).count(), 4, "remove only the parent's shadow");+ let rectangles = render(&ctx, |ui| nested(ui, false));+ assert_eq!(rectangles.iter().filter(|r| r.blur_width > 0.0).count(), 8, "the previous pass's override must expire");+ install(&ctx, vec![FrameShadow::new(source(), Vec::new())]);+ let rectangles = render(&ctx, |ui| nested(ui, false));+ assert_eq!(rectangles.iter().filter(|r| r.blur_width > 0.0).count(), 0, "reinstalling replaces the theme recipes");+}++#[test]+fn unsupported_or_ambiguous_area_frames_are_unchanged() {+ let ctx = Context::default();+ install(&ctx, vec![rule()]);+ let id = egui::Id::new("ambiguous");+ let layer = LayerId::new(egui::Order::Foreground, id);+ let area = rect();+ let backdrop = Shape::rect_filled(area.expand(100.0), 0, Color32::from_black_alpha(50));+ let originals = [backdrop, Shape::Noop, original(area), original(area)];+ let expected = originals.clone();+ // Reproduce two competing frame candidates in an actual Area paint list.+ ctx.on_end_pass(+ "ambiguity acceptance",+ std::sync::Arc::new(move |ctx| {+ ctx.graphics(|g| {+ let entries: Vec<_> = g.get(layer).unwrap().all_entries().map(|s| s.shape.clone()).collect();+ assert_eq!(entries, expected);+ });+ }),+ );+ let mut output = ctx.run_ui(Default::default(), |ui| {+ let area = egui::Area::new(id).fixed_pos(area.min).order(layer.order).fade_in(false).show(ui.ctx(), |ui| ui.set_min_size(area.size()));+ ctx.graphics_mut(|g| {+ let list = g.entry(layer);+ for shape in &originals {+ list.add(area.response.rect.expand(100.0), shape.clone());+ }+ });+ });+ output.textures_delta.clear();+ assert_eq!(frame_status(&ctx, layer), Some(FrameStatus::Unchanged));+}++#[test]+fn explicit_owned_frame_is_not_reprocessed_by_the_container_adapter() {+ let ctx = Context::default();+ install(&ctx, vec![rule()]);+ let rectangles = render(&ctx, |ui| {+ let area = egui::Area::new(egui::Id::new("explicit-single")).order(egui::Order::Foreground).fade_in(false).show(ui.ctx(), |ui| {+ show_frame(ui, Frame::NONE.fill(Color32::WHITE), &[source()], |ui| {+ ui.allocate_exact_size(vec2(120.0, 80.0), egui::Sense::hover());+ });+ });+ set_layer_shadows(ui.ctx(), area.response.layer_id, vec![]);+ });+ assert_eq!(rectangles.iter().filter(|r| r.blur_width > 0.0).count(), 1, "explicit painting takes precedence over automatic rules and overrides");+}++#[test]+fn viewport_pass_state_is_isolated_and_closed_viewports_are_released() {+ let ctx = Context::default();+ install(&ctx, vec![rule()]);+ let root = egui::ViewportId::ROOT;+ let child = egui::ViewportId::from_hash_of("shadow-child");+ let layer = LayerId::new(egui::Order::Foreground, egui::Id::new("same-area-id"));+ for viewport_id in [root, child] {+ let mut input = RawInput { viewport_id, ..Default::default() };+ input.viewports.insert(child, egui::ViewportInfo { parent: Some(root), ..Default::default() });+ let mut output = ctx.run_ui(input, |ui| {+ assert!(set_layer_shadows(ui.ctx(), layer, Elevation::Modal.layers(source().color)));+ });+ output.textures_delta.clear();+ }+ ctx.with_plugin(|plugin: &mut EguiFrames| {+ assert_eq!(plugin.passes.get(&root).unwrap().overrides.len(), 1);+ assert_eq!(plugin.passes.get(&child).unwrap().overrides.len(), 1);+ });+ let mut output = ctx.run_ui(Default::default(), |_| {});+ output.textures_delta.clear();+ ctx.with_plugin(|plugin: &mut EguiFrames| {+ assert!(plugin.passes.get(&root).unwrap().overrides.is_empty());+ assert!(!plugin.passes.contains_key(&child));+ });+}++/// A CPU-only comparison, deliberately outside the ordinary unit-test gate. No PNGs or GPU+/// rendering. Run with: cargo test -p craft-ui --lib profile_shadow_adapter -- --ignored --nocapture+#[test]+#[ignore = "explicit CPU profiling; timings depend on the machine and build profile"]+fn profile_shadow_adapter() {+ fn sample(adapt: bool) -> (std::time::Duration, usize) {+ let ctx = Context::default();+ ctx.all_styles_mut(|s| {+ s.animation_time = 0.0;+ s.visuals.popup_shadow = source();+ });+ if adapt {+ install(&ctx, vec![rule()]);+ }+ let mut times = Vec::new();+ let mut vertices = 0;+ for frame in 0..108 {+ let start = std::time::Instant::now();+ let mut output = ctx.run_ui(+ RawInput {+ screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 1080.0))),+ time: Some(frame as f64 / 60.0),+ ..Default::default()+ },+ |ui| {+ for panel in 0..16 {+ egui::Area::new(egui::Id::new(("profile", panel)))+ .order(egui::Order::Middle)+ .fixed_pos(pos2(20.0 + (panel % 4) as f32 * 260.0, 20.0 + (panel / 4) as f32 * 240.0))+ .fade_in(false)+ .show(ui.ctx(), |ui| {+ Frame::popup(ui.style()).show(ui, |ui| {+ ui.set_min_width(200.0);+ for _ in 0..8 {+ ui.label("A representative menu or panel row");+ }+ });+ });+ }+ },+ );+ output.textures_delta.clear();+ let meshes = ctx.tessellate(output.shapes, output.pixels_per_point);+ let elapsed = start.elapsed();+ vertices = meshes+ .iter()+ .map(|mesh| match &mesh.primitive {+ egui::epaint::Primitive::Mesh(mesh) => mesh.vertices.len(),+ _ => 0,+ })+ .sum();+ if frame >= 8 {+ times.push(elapsed);+ }+ }+ if adapt {+ for panel in 0..16 {+ let layer = LayerId::new(egui::Order::Middle, egui::Id::new(("profile", panel)));+ assert_eq!(frame_status(&ctx, layer), Some(FrameStatus::Applied));+ }+ }+ times.sort_unstable();+ (times[times.len() / 2], vertices)+ }+ // Alternate order to make one-time cache/order effects visible in the output.+ for round in 0..2 {+ let (baseline, adapted) = if round == 0 {+ (sample(false), sample(true))+ } else {+ let adapted = sample(true);+ (sample(false), adapted)+ };+ assert!(adapted.1 > baseline.1);+ println!(+ "16 Areas x 8 text rows, 100 warmed samples, round {}: single {:?} / {} vertices; layered {:?} / {} vertices; delta {:+.1}%",+ round + 1,+ baseline.0,+ baseline.1,+ adapted.0,+ adapted.1,+ (adapted.0.as_secs_f64() / baseline.0.as_secs_f64() - 1.0) * 100.0+ );+ }+}new file mode 100644@@ -0,0 +1,137 @@+# UI shadow implementation and manual acceptance++The UI implementation is a local pilot. Visual acceptance is assigned to the maintainer;+no app screenshots or gallery PNGs have been accepted by the implementation agent.+Canvas CPU/GPU shadows are a separate follow-up.++## Shared API++`craft_ui::theme::shadow` accepts slices of `egui::Shadow`. `shapes` returns shadow shapes;+`paint` respects the supplied painter's opacity and clip rectangle. Call it before painting+the surface. `visual_bounds` includes the surface and all visible shadow margins; it does not+expand clipping or input bounds. Units are logical points, and blur is egui penumbra width.++`Elevation::{Low, Medium, High, Modal}.layers(color)` preserves PhotoCraft's four-layer+reference profiles, including hue. `floating_toolbar(color)` supplies a quieter two-layer+candidate. Apps choose which recipe and color a surface uses. Neither chooses z-order,+modal behavior, a theme, fonts or user preferences.++`show_frame(ui, frame, layers, content)` owns a paint slot before its content. It replaces+the frame's ordinary shadow, preserves its layout/margins and paints surface fill/border+after the shadows. An empty layer slice removes its shadow. Use this for custom frames.+Owned paint slots are excluded from the automatic container adapter, even if a deliberately+single-layer recipe matches a registered signature.++For built-in egui 0.36 containers, `install(ctx, rules)` enables an end-of-pass adapter.+Each `FrameShadow` rule registers the complete original single-shadow signature and its+replacement layers. The adapter considers only known non-background Areas, requires a frame+rectangle equal to the Area bounds, and matches exactly `[shadow_rect, frame_rect]` against+the registered shadow geometry, styling and inherited opacity. Ambiguous candidates, plain+rounded rectangles, other vectors and unsupported structures retain their original painting.+Scrims, surface styling, clip rectangles and layer transforms remain intact.++`set_layer_shadows` selects an override for a known container in the current pass. The next+pass clears overrides. Its boolean result reports whether the adapter is installed, not+whether that particular frame structure was supported. Reinstalling replaces theme rules;+it does not stack plugins. The matcher is specific to egui 0.36 and must be retested when+updating egui. Faded shadows retain the inherited opacity, applied once.+After rendering, `frame_status(ctx, layer)` reports `Applied` or `Unchanged` for inspected+Areas; `None` means the Area was not inspected. Use this diagnostic when inventorying fallbacks.+Per-pass state is scoped to the viewport so child windows do not clear a parent's overrides.+Rules are checked in registration order. Signatures differing only in alpha can become+indistinguishable during a fade; use equivalent normalized recipes for such signatures, as+FigCraft does for its light/dark scopes. Use explicit owned painting or a known-layer override+when different roles need different falloff. Alpha reconstruction is limited to 8-bit color+precision; tinted or otherwise unrecognized source shadows keep the single-layer fallback.++## Consumer coverage and limitations++| Consumer | Implementation |+| --- | --- |+| PhotoCraft | Shared four-layer recipes; registered popup/window and custom floating-frame signatures. Existing per-container calls select modal/high overrides. Additional built-in popups use registered defaults. |+| FigCraft | Direct two-layer toolbar; owned frames for menus, Actions palette, comment popovers, canvas banners and prototype editor; direct low-elevation annotation shadows. Built-in containers use registered light/dark frame signatures, including local dark scopes. |+| LightCraft | Registered popup/window signatures for Charcoal, Midnight, Silver and Paper. Nested menus and ComboBoxes use the same adapter. |+| Shared docking / print controls | Their built-in popups inherit the host's registered adapter. Installing the module is opt-in; existing hosts keep their old rendering unless they install it. Floating docking-panel backgrounds remain unchanged. |++Containers whose Area and frame bounds differ (for example, an outer margin or additional+content outside the frame), rotated frame shapes, unregistered local shadow signatures,+and changed egui paint structures use the original single-layer fallback. Tooltips use the+popup recipe when their frame matches. Document drop-shadow effects are never registered.+PhotoCraft canvas geometry, blur and theme/visibility exceptions remain application-owned.++The three application manifests temporarily patch `craft-ui` to `../craft-libs/crates/ui` so+this four-repository workspace can be built and reviewed before publication. Their lockfiles+reflect that path patch. Keep the sibling checkout layout for the pilot. After acceptance,+publish the shared commit, update each immutable `rev`, remove the temporary patch and+regenerate the lockfiles. Do not ship a manifest requiring the local sibling checkout.++## Automated validation (2026-10-13)++- Formatting passes in all four repositories.+- `cargo test --workspace --lib` passes in craft-libs, including 88 passing craft-ui tests and+ the 10 shadow regression tests. The optional CPU profile is ignored by the ordinary gate+ and was run separately. One existing filesystem ACL test is also environment-gated.+- Targeted application tests pass: 49 PhotoCraft theme-related tests, the FigCraft toolbar+ regression (light/dark, 1x/2x), and 11 LightCraft theme/appearance tests.+- Native UI checks, application layer checks and wasm32 UI checks pass for all three apps.+ craft-ui's own wasm check also passes. Existing optional-font and wasm-only unused-code+ warnings are unrelated to this change.+- Strict workspace/all-target Clippy passes in craft-libs. A small existing docking match+ was mechanically rewritten to satisfy the current `collapsible_match` lint.+- Strict application UI Clippy encounters baseline lints: PhotoCraft's analysis/canvas code+ (and raw/cr3 when linting dependencies), FigCraft's branch/comments/dev-compare/style-picker/+ versions code and configured unknown lint, and LightCraft's `panels/detail.rs:782` boolean.+ These application expressions were not changed for the shadow work. Compatibility checks+ use `--no-deps -- -D warnings` with only the relevant baseline categories allowed on the+ command line: `-A clippy::collapsible_match` for PhotoCraft, the same plus+ `-A unknown_lints` for FigCraft, and `-A clippy::nonminimal_bool` for LightCraft. No lint+ suppressions were added to application source. These checks are not a claim that the full+ existing application CI is clean.+- The gallery example is checked for compilation and PNG generation; visual review is left+ to the maintainer. Performance profiling against real application workloads remains a+ release acceptance step; no measured frame-time budget is claimed here.++The CPU-only fixture uses 16 simultaneous Areas with eight text rows each, 1x scale, eight+warm-up frames and 100 measured samples. It includes egui UI construction and tessellation,+without GPU rendering. In the default unoptimized test profile, two rounds (alternating order)+measured median single-shadow times of 3.014/2.953 ms and layered times of 3.831/3.790 ms:+an extra 0.817/0.837 ms (+27.1/+28.4%). Vertices increased from 18,304 to 21,760 (+18.9%).+All 16 Areas were verified as adapted. These are fixture results, not production frame times.+Reproduce with:++```bash+cargo test -p craft-ui --lib profile_shadow_adapter -- --ignored --nocapture+```++## Maintainer visual checks++Run these from the repository named in each step. Use synthetic documents and retain+before/after screenshots locally. The implementation agent does not perform visual acceptance.++1. In craft-libs, generate the comparison gallery:++ ```bash+ cargo run -p craft-ui --example shadows -- /tmp/craft-shadows+ ```++ Inspect its light/dark 1x/2x PNGs. Compare the single-layer, PhotoCraft and toolbar recipes+ over plain and patterned backgrounds. Look for smooth falloff, unwanted rings and clipping.++2. In FigCraft, run `cargo run -p figcraft` and create a blank design document. Check the bottom toolbar on empty and busy+ canvases in both themes, including normal, vector-edit, shape-builder and view-only modes.+ The stepped gray rings should be gone; its outline should remain readable. Check flyouts,+ comment threads/drafts, Actions, canvas banners and the prototype interaction editor.+ Confirm tools, focus and keyboard navigation still work. Inspect the bottom edge at 1x/2x+ and small window sizes for clipped bands or painting over neighboring chrome.+3. In PhotoCraft, run `cargo run -p photocraft`. Compare menus/submenus, ComboBoxes, dialogs,+ floating brush/type/gradient panels and the command palette against the source branch.+ Check light/dark Studio and Pro variants, plus Classic's flat treatment. Inspect modal+ scrims and opening/closing fades; confirm the canvas shadow retains its existing behavior.+4. In LightCraft, run `cargo run -p lightcraft`. Check menus, nested menus, ComboBoxes and+ dialogs in Charcoal, Midnight, Silver and Paper. Inspect text/surface separation, fades,+ edge clipping and dismissal. Check docking/printing popups where available.+5. In all apps, switch themes with overlays open, drag/resize floating panels, repeat open/close operations, and inspect+ 1x/2x scaling. Note any remaining single-layer fallback that needs a dedicated adapter.++Record pass/fail per application, theme, scale and surface, with a screenshot path for each+failure. Visual acceptance and release pinning remain pending until that review is complete.@@ -1,5 +1,9 @@ # Shared theme infrastructure +The layered UI shadow pilot is documented separately in [UI shadows](shadows.md). It adds+explicit painting and optional recipes while retaining application-owned theme policy.+The comparison and migration notes below describe the earlier widget-palette extraction.+ The first shared mechanism is `craft-ui::theme::WidgetPalette`: it applies colors to egui's five widget states, with a supplied foreground stroke and corner radius. VectorCraft, DesignCraft and LightCraft use the same operation. Their complete palettes, theme choices, fonts and preferences