collective

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

diff --git a/crates/ui/README.md b/crates/ui/README.md--- a/crates/ui/README.md+++ b/crates/ui/README.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,diff --git a/crates/ui/examples/shadows.rs b/crates/ui/examples/shadows.rsnew file mode 100644--- /dev/null+++ b/crates/ui/examples/shadows.rs@@ -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(())+}diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs--- a/crates/ui/src/docking/surface.rs+++ b/crates/ui/src/docking/surface.rs@@ -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))) {diff --git a/crates/ui/src/theme/mod.rs b/crates/ui/src/theme/mod.rs--- a/crates/ui/src/theme/mod.rs+++ b/crates/ui/src/theme/mod.rs@@ -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 owndiff --git a/crates/ui/src/theme/shadow.rs b/crates/ui/src/theme/shadow.rsnew file mode 100644--- /dev/null+++ b/crates/ui/src/theme/shadow.rs@@ -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;diff --git a/crates/ui/src/theme/shadow/tests.rs b/crates/ui/src/theme/shadow/tests.rsnew file mode 100644--- /dev/null+++ b/crates/ui/src/theme/shadow/tests.rs@@ -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+        );+    }+}diff --git a/docs/shadows.md b/docs/shadows.mdnew file mode 100644--- /dev/null+++ b/docs/shadows.md@@ -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.diff --git a/docs/theme-infrastructure.md b/docs/theme-infrastructure.md--- a/docs/theme-infrastructure.md+++ b/docs/theme-infrastructure.md@@ -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