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/docs/dialog-tabs.md b/crates/ui/docs/dialog-tabs.md
new file mode 100644
--- /dev/null
+++ b/crates/ui/docs/dialog-tabs.md
@@ -0,0 +1,26 @@
+# Dialog tabs that join their page
+
+`Tab::show_dialog_at` adds the page-joining appearance proposed in
+[WordCraft #4029](https://github.com/storytold/wordcraft/pull/4029), using the library's existing
+accessible tab interaction. It suits settings and property dialogs across Craft apps.
+
+Provide a `DialogTabStyle` with the application's colors, border, font and top corner radius.
+Allocate rectangles using the application's layout and optionally `tabs::fit`; draw a baseline
+before the tabs. Selected tabs use `style.page` and erase their bottom border so they join the
+page. Use `style.border` for the baseline centered on the bottom edge; the selected cover extends
+half its width below the interaction rectangle. Other sides and text retain their original clip.
+The page should start at the tabs' bottom edge and use the same fill.
+
+```rust
+let response = craft_ui::tabs::Tab::new(stable_id, localized_label, selected)
+    .show_dialog_at(ui, rect, &style);
+if response.clicked() {
+    // Update the application's selected page.
+}
+```
+
+IDs must remain stable independently of translated labels. Selection, overflow, arrow-key
+navigation and page content remain caller-owned. The underlying `Tab` supplies accessibility,
+keyboard focus/activation, drag response and disabled behavior. Text clips to its rectangle;
+invalid stroke widths become zero and invalid font sizes use 12 points. No app assets or theme
+are bundled, and consumer migration is separate.
diff --git a/crates/ui/src/tabs/dialog.rs b/crates/ui/src/tabs/dialog.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui/src/tabs/dialog.rs
@@ -0,0 +1,143 @@
+//! Page-joining dialog tab painting on the existing accessible tab interaction.
+
+use super::Tab;
+use egui::{Align2, Color32, CornerRadius, FontId, Rect, Response, Stroke, StrokeKind, Ui, pos2};
+
+/// A focused style adapter, not an application theme. The selected fill should match the page
+/// below the strip. Draw the strip's baseline before its tabs: selected tabs erase their bottom
+/// border to visually join that page. Applications own strip layout, overflow and page selection.
+#[derive(Clone, Debug)]
+pub struct DialogTabStyle {
+    pub page: Color32,
+    pub inactive: Color32,
+    pub hovered: Color32,
+    pub text: Color32,
+    pub selected_text: Color32,
+    /// Use this stroke for the strip baseline too. The selected seam covers half its width
+    /// below the tab, matching a baseline centered on the bottom edge.
+    pub border: Stroke,
+    pub font: FontId,
+    pub top_radius: u8,
+}
+
+impl Tab<'_> {
+    /// Paint a raised dialog tab and return the same response as [`Tab::show_at`].
+    ///
+    /// Stable IDs, selected accessibility state, parent disabled state, keyboard activation and
+    /// focus outlines all come from the existing tab. The application supplies localized text,
+    /// geometry and style. Oversized labels clip within their tab instead of covering neighbours.
+    /// The selected tab joins a page beginning at `rect.bottom()`; unselected tabs sit two points
+    /// lower. No page is selected implicitly, and arrow-key navigation remains caller policy.
+    pub fn show_dialog_at(self, ui: &mut Ui, rect: Rect, style: &DialogTabStyle) -> Response {
+        let selected = self.selected;
+        let label = self.label;
+        self.show_at(ui, rect, |ui, response| {
+            let inset = if selected { 0.0 } else { 2.0_f32.min(rect.height() * 0.25) };
+            let shown = Rect::from_min_max(pos2(rect.left(), rect.top() + inset), rect.max);
+            let width = if style.border.width.is_finite() { style.border.width.clamp(0.0, rect.width().min(rect.height()) * 0.5) } else { 0.0 };
+            let stroke = Stroke::new(width, style.border.color);
+            let fill = if selected {
+                style.page
+            } else if response.hovered() {
+                style.hovered
+            } else {
+                style.inactive
+            };
+            let radius = CornerRadius { nw: style.top_radius, ne: style.top_radius, sw: 0, se: 0 };
+            let mut paint_bounds = rect;
+            if selected {
+                paint_bounds.max.y += width * 0.5;
+            }
+            let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(paint_bounds));
+            painter.rect(shown, radius, fill, stroke, StrokeKind::Inside);
+            // Cover only the selected tab's bottom edge, preserving the vertical border corners.
+            if selected && width > 0.0 && rect.width() > 2.0 * width {
+                painter.rect_filled(
+                    Rect::from_min_max(pos2(rect.left() + width, rect.bottom() - width), pos2(rect.right() - width, rect.bottom() + width * 0.5)),
+                    0.0,
+                    style.page,
+                );
+            }
+            let text_rect = rect.shrink2(egui::vec2(2.0_f32.min(rect.width() * 0.25), 0.0));
+            let color = if selected { style.selected_text } else { style.text };
+            let font = FontId::new(if style.font.size.is_finite() { style.font.size.clamp(1.0, 512.0) } else { 12.0 }, style.font.family.clone());
+            painter.with_clip_rect(text_rect.intersect(ui.clip_rect())).text(rect.center(), Align2::CENTER_CENTER, label, font, color);
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use egui::{Id, Vec2};
+    use egui_kittest::kittest::Queryable;
+
+    fn style() -> DialogTabStyle {
+        DialogTabStyle {
+            page: Color32::WHITE,
+            inactive: Color32::LIGHT_GRAY,
+            hovered: Color32::GRAY,
+            text: Color32::BLACK,
+            selected_text: Color32::BLACK,
+            border: Stroke::new(1.0, Color32::DARK_GRAY),
+            font: FontId::proportional(12.0),
+            top_radius: 5,
+        }
+    }
+
+    #[test]
+    fn painting_preserves_accessible_selection_and_click_response_with_duplicate_labels() {
+        let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(400.0, 200.0)).build_ui_state(
+            |ui, selected: &mut usize| {
+                for index in 0..2 {
+                    let (_, rect) = ui.allocate_space(Vec2::new(100.0, 26.0));
+                    let response = Tab::new(Id::new(("dialog", index)), "Options", *selected == index).show_dialog_at(ui, rect, &style());
+                    if response.clicked() {
+                        *selected = index;
+                    }
+                }
+            },
+            0,
+        );
+        h.run_steps(2);
+        let tabs: Vec<_> = h.get_all_by_label("Options").collect();
+        assert_eq!(tabs.len(), 2);
+        tabs[1].click();
+        h.run_steps(2);
+        assert_eq!(*h.state(), 1);
+    }
+
+    #[test]
+    fn malformed_style_and_long_text_tessellate_inside_small_tab() {
+        let ctx = egui::Context::default();
+        let mut style = style();
+        style.border.width = f32::NAN;
+        style.font.size = f32::INFINITY;
+        let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
+            let (_, rect) = ui.allocate_space(Vec2::new(5.0, 3.0));
+            Tab::new(Id::new("small"), "A long localized label", true).show_dialog_at(ui, rect, &style);
+        });
+        out.textures_delta.clear();
+        let _ = ctx.tessellate(out.shapes, out.pixels_per_point);
+    }
+
+    #[test]
+    fn selected_tab_erases_a_baseline_centered_on_its_bottom_edge() {
+        let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(120.0, 80.0)).wgpu().build_ui(|ui| {
+            ui.painter().rect_filled(ui.max_rect(), 0.0, Color32::WHITE);
+            let rect = Rect::from_min_size(egui::pos2(10.0, 10.0), Vec2::new(100.0, 26.0));
+            let mut style = style();
+            style.border = Stroke::new(2.0, Color32::BLACK);
+            ui.painter().line_segment([rect.left_bottom(), rect.right_bottom()], style.border);
+            Tab::new(Id::new("joined"), "Options", true).show_dialog_at(ui, rect, &style);
+        });
+        h.ctx.set_pixels_per_point(1.0);
+        h.run_steps(3);
+        let image = h.render();
+        assert!(image.is_ok());
+        if let Ok(image) = image {
+            let pixel = image.get_pixel(60, 36);
+            assert!(pixel[0] >= 245 && pixel[1] >= 245 && pixel[2] >= 245, "seam remains visible: {pixel:?}");
+        }
+    }
+}
diff --git a/crates/ui/src/tabs/mod.rs b/crates/ui/src/tabs/mod.rs
--- a/crates/ui/src/tabs/mod.rs
+++ b/crates/ui/src/tabs/mod.rs
@@ -6,6 +6,9 @@
 
 use egui::{Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2};
 
+mod dialog;
+pub use dialog::DialogTabStyle;
+
 /// Tabs visible on a strip and tabs that need an overflow menu, all in original order.
 #[derive(Clone, Debug, Default, PartialEq)]
 pub struct TabFit {
