diff --git a/crates/fonts-build/src/tests.rs b/crates/fonts-build/src/tests.rs
--- a/crates/fonts-build/src/tests.rs
+++ b/crates/fonts-build/src/tests.rs
@@ -376,7 +376,10 @@ fn for_ui_embeds_cjk_faces_only_as_cut_interface_faces() {
     assert_eq!(warnings(&r), Vec::<String>::new());
     let r = run(Embed::documents(&["Noto Sans TC UI", "Noto Sans TC"]), Some(&dir), Some("1"));
     assert_eq!(r.result.as_ref().unwrap(), &["Noto Sans TC Regular", "Noto Sans TC UI Regular"]);
-    assert!(r.table.contains("fonts/1.ttf"), "{}", r.table);
+    // Generated Rust escapes native separators (and Windows' canonical path prefix).
+    // Match the selected file's complete include expression on every platform.
+    let interface_face = dir.join("fonts/1.ttf").canonicalize().unwrap();
+    assert!(r.table.contains(&format!("include_bytes!({interface_face:?})")), "{}", r.table);
 }
 
 #[test]
diff --git a/crates/ui/README.md b/crates/ui/README.md
--- a/crates/ui/README.md
+++ b/crates/ui/README.md
@@ -3,12 +3,46 @@
 `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.
+`layout::Section` groups dialog or inspector settings in caller-styled frames with optional headings.
 `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)
 records their scope and acceptance limits. Assets, complete themes, localization and command handling
 remain in each application. No icons, fonts or application engines are bundled.
 
+## Dialog and inspector sections
+
+`layout::Section` groups related settings without owning a window, theme or commands. Pass a
+localized heading and a frame built from the application's tokens. Typography stays in the supplied
+`WidgetText`; contents keep normal egui focus, clipping and parent disabled state.
+
+```rust
+use craft_ui::layout::Section;
+use egui::{CornerRadius, Frame, Margin, RichText};
+
+fn display_settings(ui: &mut egui::Ui, show_grid: &mut bool) {
+    let frame = Frame::new()
+        .fill(ui.visuals().window_fill.lerp_to_gamma(ui.visuals().text_color(), 0.04))
+        .stroke(ui.visuals().window_stroke)
+        .corner_radius(CornerRadius::same(6))
+        .inner_margin(Margin::symmetric(10, 8));
+    Section::new(frame).heading(RichText::new("Display").strong()).show(ui, |ui| {
+        ui.checkbox(show_grid, "Show grid");
+    });
+}
+```
+
+The default fills available width; `.fill_width(false)` wraps the contents. An empty heading adds
+no row or heading gap. The heading remains above the contents even in a horizontal parent.
+The helper neither indents controls nor adds exterior spacing: compose those
+in the caller's layout. Reusing the same frame for nested sections preserves its fill without
+accumulating tint or changing global visuals. Invalid floating-point decoration becomes zero and
+large heading gaps/strokes are capped at 64 logical points; forced widths are capped at one million.
+
+This small framing boundary comes from [WordCraft #4038](https://github.com/storytold/wordcraft/pull/4038)
+and is also suitable for GridCraft/DeckCraft dialogs and creative-app inspectors. Consumer adoption
+is separate; application-specific dialog behavior remains in each app.
+
 ## SVG catalogs
 
 ```rust
diff --git a/crates/ui/src/layout/mod.rs b/crates/ui/src/layout/mod.rs
--- a/crates/ui/src/layout/mod.rs
+++ b/crates/ui/src/layout/mod.rs
@@ -6,10 +6,12 @@
 
 mod geometry;
 mod resize;
+mod section;
 mod toolbar;
 
 pub use geometry::{SplitAxis, SplitLayout, SplitSize, split_rect};
 pub use resize::{ResizeHandle, ResizeHandleStyle, ResizeResponse};
+pub use section::Section;
 pub use toolbar::{ToolbarFit, fit_toolbar};
 
 #[cfg(test)]
diff --git a/crates/ui/src/layout/section.rs b/crates/ui/src/layout/section.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui/src/layout/section.rs
@@ -0,0 +1,185 @@
+//! Group related settings without taking ownership of a dialog or its contents.
+
+use egui::{Frame, InnerResponse, Label, Stroke, Ui, WidgetText};
+
+const MAX_WIDTH: f32 = 1_000_000.0;
+const MAX_DECORATION: f32 = 64.0;
+
+/// A caller-styled frame with an optional heading above its contents.
+///
+/// This extracts the grouping boundary of WordCraft's dialog sections (#4038), not its theme,
+/// fonts, indentation or window policy. Pass localized [`WidgetText`] (including [`egui::RichText`]
+/// for emphasis) and an application-supplied [`Frame`]. The contents retain ordinary egui focus,
+/// clipping, disabled-state and widget-ID behavior. Use `Ui::push_id` around repeated sections
+/// when the application needs identity independent of their position.
+///
+/// Reusing one frame for nested sections keeps their fill consistent: this component neither
+/// adds another tint nor writes temporary global style or nesting state.
+pub struct Section {
+    frame: Frame,
+    heading: Option<WidgetText>,
+    heading_gap: f32,
+    fill_width: bool,
+}
+
+impl Section {
+    pub fn new(frame: Frame) -> Self {
+        Self { frame, heading: None, heading_gap: 2.0, fill_width: true }
+    }
+
+    /// Put a heading above the contents. An empty heading takes no row or gap.
+    ///
+    /// Typography is caller-owned, for example `RichText::new(title).strong()`.
+    pub fn heading(mut self, heading: impl Into<WidgetText>) -> Self {
+        let heading = heading.into();
+        self.heading = (!heading.text().is_empty()).then_some(heading);
+        self
+    }
+
+    /// Space after a nonempty heading, in logical points. Invalid or negative values become zero;
+    /// excessive values are capped at 64 points. This does not add space outside the frame.
+    pub fn heading_gap(mut self, points: f32) -> Self {
+        self.heading_gap = decoration(points);
+        self
+    }
+
+    /// Fill the available width (the default), or wrap the contents when false.
+    /// Unbounded parent widths do not force an unbounded minimum width.
+    pub fn fill_width(mut self, fill: bool) -> Self {
+        self.fill_width = fill;
+        self
+    }
+
+    /// Lay out the heading and caller's controls, returning the caller's value and frame response.
+    ///
+    /// The callback owns its state and actions; the section does not dispatch commands, create
+    /// a modal window, indent controls or change global visuals. egui clamps narrow frame content
+    /// rectangles. This component additionally bounds forced widths and sanitizes the frame's
+    /// floating-point stroke width (invalid values become zero, maximum 64 points).
+    pub fn show<R>(self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
+        let mut frame = self.frame;
+        let width = decoration(frame.stroke.width);
+        frame.stroke = if width == 0.0 { Stroke::NONE } else { Stroke::new(width, frame.stroke.color) };
+        frame.show(ui, |ui| {
+            // A frame inherits its parent's layout. Keep the section's heading above its body
+            // even when the caller places several sections side by side in a horizontal row.
+            ui.vertical(|ui| {
+                if self.fill_width {
+                    let width = ui.available_width();
+                    if width.is_finite() && width > 0.0 {
+                        ui.set_width(width.min(MAX_WIDTH));
+                    }
+                }
+                if let Some(heading) = self.heading {
+                    ui.add(Label::new(heading));
+                    ui.add_space(self.heading_gap);
+                }
+                add_contents(ui)
+            })
+            .inner
+        })
+    }
+}
+
+fn decoration(value: f32) -> f32 {
+    if value.is_finite() { value.clamp(0.0, MAX_DECORATION) } else { 0.0 }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use egui::{Color32, Context, CornerRadius, Event, Margin, Modifiers, PointerButton, RawInput, RichText, Vec2};
+    use egui_kittest::kittest::Queryable;
+
+    fn style() -> Frame {
+        Frame::new()
+            .fill(Color32::from_gray(30))
+            .stroke(Stroke::new(1.0, Color32::GRAY))
+            .corner_radius(CornerRadius::same(6))
+            .inner_margin(Margin::same(8))
+    }
+
+    #[test]
+    fn heading_stays_above_controls_in_horizontal_parents() {
+        let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(500.0, 300.0)).build_ui(|ui| {
+            ui.horizontal(|ui| {
+                Section::new(style()).heading("Display").fill_width(false).show(ui, |ui| {
+                    let _ = ui.button("Apply");
+                });
+                ui.label("Neighbour");
+            });
+        });
+        h.run_steps(2);
+        assert!(h.get_by_label("Apply").rect().top() >= h.get_by_label("Display").rect().bottom());
+    }
+
+    #[test]
+    fn heading_and_content_are_accessible_and_return_the_callers_value() {
+        let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(500.0, 300.0)).build_ui_state(
+            |ui, clicked: &mut bool| {
+                let result = Section::new(style()).heading(RichText::new("Display").strong()).show(ui, |ui| {
+                    if ui.button("Apply").clicked() {
+                        *clicked = true;
+                    }
+                    42
+                });
+                assert_eq!(result.inner, 42);
+            },
+            false,
+        );
+        h.run_steps(2);
+        h.get_by_label("Display");
+        h.get_by_label("Apply").click();
+        h.run_steps(2);
+        assert!(*h.state());
+    }
+
+    #[test]
+    fn parent_disabled_state_prevents_content_activation() {
+        let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(500.0, 300.0)).build_ui_state(
+            |ui, state: &mut (bool, usize)| {
+                ui.add_enabled_ui(state.0, |ui| {
+                    Section::new(style()).heading("Display").show(ui, |ui| {
+                        if ui.button("Apply").clicked() {
+                            state.1 += 1;
+                        }
+                    });
+                });
+            },
+            (true, 0),
+        );
+        h.run_steps(2);
+        let pos = h.get_by_label("Apply").rect().center();
+        h.state_mut().0 = false;
+        h.run_steps(2);
+        h.event(Event::PointerMoved(pos));
+        h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
+        h.step();
+        h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
+        h.run_steps(2);
+        assert_eq!(h.state().1, 0);
+    }
+
+    #[test]
+    fn nested_sections_do_not_change_theme_and_invalid_decoration_stays_finite() {
+        let ctx = Context::default();
+        let original = ctx.global_style().visuals.clone();
+        for value in [f32::NAN, f32::INFINITY, -1.0, f32::MAX] {
+            let mut frame = style();
+            frame.stroke.width = value;
+            let mut out = ctx.run_ui(RawInput::default(), |ui| {
+                Section::new(frame).heading("").heading_gap(value).show(ui, |ui| {
+                    let response = Section::new(frame).heading("Nested").heading_gap(value).show(ui, |ui| {
+                        ui.label("Contents");
+                    });
+                    assert!(response.response.rect.is_finite());
+                });
+            });
+            out.textures_delta.clear();
+            let _ = ctx.tessellate(out.shapes, out.pixels_per_point);
+            assert_eq!(ctx.global_style().visuals.panel_fill, original.panel_fill);
+            assert_eq!(ctx.global_style().visuals.window_fill, original.window_fill);
+            assert_eq!(ctx.global_style().visuals.window_stroke, original.window_stroke);
+        }
+    }
+}
