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.rs
new 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 own
diff --git a/crates/ui/src/theme/shadow.rs b/crates/ui/src/theme/shadow.rs
new 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.rs
new 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.md
new 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
