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
@@ -11,6 +11,8 @@ remain in each application. No icons, fonts or application engines are bundled.
 
 ## SVG catalogs
 
+For common Insert Shapes choices and previews, see [the shared shape gallery](docs/shape-gallery.md).
+
 ```rust
 use std::sync::OnceLock;
 use craft_ui::icons::{SvgIconSet, SvgStyle};
diff --git a/crates/ui/docs/shape-gallery.md b/crates/ui/docs/shape-gallery.md
new file mode 100644
--- /dev/null
+++ b/crates/ui/docs/shape-gallery.md
@@ -0,0 +1,35 @@
+# Shared Insert Shapes choices and previews
+
+`craft_ui::shapes` shares the shape/connector menu and original procedural previews contributed in
+[WordCraft #2142](https://github.com/storytold/wordcraft/pull/2142). It provides semantic preset
+identities, default ordering, connector/arrow classification, capability filtering and a menu built
+on the existing accessible `IconButton`. This is reusable menu logic as well as painting.
+
+The overlap is concrete: GridCraft's [Insert ribbon](https://github.com/storytold/gridcraft/blob/b8771d51fb4da60bd48c13cf313d6d6014dc5e66/crates/ui-egui/src/ribbon.rs#L1534)
+offers rectangle, rounded rectangle, ellipse, triangle, line, arrow and text box. WordCraft also
+offers diamond, star, heart and connectors. Each host must filter by what its engine supports;
+a preview does not imply import/export or rendering support in that app.
+
+```rust
+let chosen = craft_ui::shapes::menu(ui, 220.0, font, &button_style,
+    |preset| supported_by_this_app(preset),
+    |preset| localize(preset.source_label()),
+);
+if let Some(preset) = chosen {
+    // Adapt the semantic choice to this app's insertion operation.
+}
+```
+
+`ShapePreset::key()` is a stable choice key, not a universal command payload.
+The enum is non-exhaustive: capability adapters should reject unknown future presets by default.
+WordCraft maps `StraightArrowConnector` to `kind: "straightConnector", arrow: "end"`; GridCraft
+must hide it until its own engine supports attachment/routing. An ordinary `Arrow` remains a
+different semantic choice. The library does not silently substitute unsupported objects.
+
+Apps own localization, theme, popup lifetime, current document, insertion coordinates, undo and
+selection. Additional actions such as New Drawing Canvas remain app-specific menu entries.
+`paint_preset` is also available for app-owned gallery layouts. The previews use original path
+geometry and no external assets. They are UI icons, not editable document geometry or a shape
+rendering engine. Star and heart contours honor `fill` across their full area, including with no
+outline; their fixed concave paths use triangle fans around verified interior centers.
+Consumer adoption and command adapters are separate work.
diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs
--- a/crates/ui/src/lib.rs
+++ b/crates/ui/src/lib.rs
@@ -10,5 +10,6 @@ pub mod docking;
 pub mod icons;
 pub mod layout;
 pub mod printing;
+pub mod shapes;
 pub mod tabs;
 pub mod theme;
diff --git a/crates/ui/src/shapes/mod.rs b/crates/ui/src/shapes/mod.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui/src/shapes/mod.rs
@@ -0,0 +1,174 @@
+//! Shared shape-choice semantics and procedural previews, without document insertion commands.
+
+use crate::buttons::{IconButton, IconButtonStyle};
+use egui::{Align2, FontId, Ui, Vec2};
+
+mod paint;
+pub use paint::paint_preset;
+
+/// A menu choice, not a document shape model. Connector routing/attachment and text editing stay
+/// in each app's engine. Stable keys are suitable for IDs and explicit adapters, not localized text.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+#[non_exhaustive]
+pub enum ShapePreset {
+    Rectangle,
+    RoundedRectangle,
+    Ellipse,
+    Triangle,
+    Diamond,
+    Line,
+    Arrow,
+    Star,
+    Heart,
+    TextBox,
+    StraightConnector,
+    StraightArrowConnector,
+    ElbowConnector,
+    ElbowArrowConnector,
+    CurvedConnector,
+    CurvedArrowConnector,
+}
+
+/// Default presentation order. Filter by actual application capabilities before showing choices;
+/// having a preview here never means an application's importer/renderer supports the shape.
+pub const PRESETS: &[ShapePreset] = &[
+    ShapePreset::Rectangle,
+    ShapePreset::RoundedRectangle,
+    ShapePreset::Ellipse,
+    ShapePreset::Triangle,
+    ShapePreset::Diamond,
+    ShapePreset::Line,
+    ShapePreset::Arrow,
+    ShapePreset::Star,
+    ShapePreset::Heart,
+    ShapePreset::TextBox,
+    ShapePreset::StraightConnector,
+    ShapePreset::StraightArrowConnector,
+    ShapePreset::ElbowConnector,
+    ShapePreset::ElbowArrowConnector,
+    ShapePreset::CurvedConnector,
+    ShapePreset::CurvedArrowConnector,
+];
+
+impl ShapePreset {
+    /// Stable semantic choice key, independent of labels and any application's command protocol.
+    pub const fn key(self) -> &'static str {
+        match self {
+            Self::Rectangle => "rectangle",
+            Self::RoundedRectangle => "roundedRectangle",
+            Self::Ellipse => "ellipse",
+            Self::Triangle => "triangle",
+            Self::Diamond => "diamond",
+            Self::Line => "line",
+            Self::Arrow => "arrow",
+            Self::Star => "star",
+            Self::Heart => "heart",
+            Self::TextBox => "textBox",
+            Self::StraightConnector => "straightConnector",
+            Self::StraightArrowConnector => "straightArrowConnector",
+            Self::ElbowConnector => "elbowConnector",
+            Self::ElbowArrowConnector => "elbowArrowConnector",
+            Self::CurvedConnector => "curvedConnector",
+            Self::CurvedArrowConnector => "curvedArrowConnector",
+        }
+    }
+
+    /// English source string for the application's localization catalog. The menu always asks
+    /// the caller for a label; it never installs a translation service or uses a label as identity.
+    pub const fn source_label(self) -> &'static str {
+        match self {
+            Self::Rectangle => "Rectangle",
+            Self::RoundedRectangle => "Rounded Rectangle",
+            Self::Ellipse => "Oval",
+            Self::Triangle => "Triangle",
+            Self::Diamond => "Diamond",
+            Self::Line => "Line",
+            Self::Arrow => "Arrow",
+            Self::Star => "Star",
+            Self::Heart => "Heart",
+            Self::TextBox => "Text Box",
+            Self::StraightConnector => "Straight Connector",
+            Self::StraightArrowConnector => "Straight Arrow Connector",
+            Self::ElbowConnector => "Elbow Connector",
+            Self::ElbowArrowConnector => "Elbow Arrow Connector",
+            Self::CurvedConnector => "Curved Connector",
+            Self::CurvedArrowConnector => "Curved Arrow Connector",
+        }
+    }
+
+    pub const fn is_connector(self) -> bool {
+        matches!(
+            self,
+            Self::StraightConnector
+                | Self::StraightArrowConnector
+                | Self::ElbowConnector
+                | Self::ElbowArrowConnector
+                | Self::CurvedConnector
+                | Self::CurvedArrowConnector
+        )
+    }
+
+    /// Whether this choice has an arrow at its end. An ordinary arrow remains a different
+    /// choice from a connector with an arrow; the application decides its attachment semantics.
+    pub const fn has_end_arrow(self) -> bool {
+        matches!(self, Self::Arrow | Self::StraightArrowConnector | Self::ElbowArrowConnector | Self::CurvedArrowConnector)
+    }
+}
+
+/// Show the default shape list, returning the chosen semantic preset without executing commands.
+///
+/// `supported` hides capabilities absent from the host. `label` supplies localized accessible
+/// text; IDs remain stable even for duplicate translations. The caller owns the popup, theme,
+/// font, optional canvas/gallery actions and command mapping. Wrap repeated menus in `push_id`.
+/// Uses the existing [`IconButton`] for keyboard/pointer/accessibility and disabled-state behavior.
+/// Rows clip long labels; widths must be finite, positive and at most 4096 points or no rows show.
+pub fn menu(
+    ui: &mut Ui,
+    width: f32,
+    font: FontId,
+    style: &IconButtonStyle,
+    mut supported: impl FnMut(ShapePreset) -> bool,
+    mut label: impl FnMut(ShapePreset) -> String,
+) -> Option<ShapePreset> {
+    if !width.is_finite() || width <= 0.0 || width > 4096.0 {
+        return None;
+    }
+    let font = FontId::new(if font.size.is_finite() { font.size.clamp(1.0, 128.0) } else { 12.0 }, font.family);
+    let height = (font.size + 10.0).max(26.0);
+    let mut chosen = None;
+    let mut previous_connector = false;
+    let mut have_item = false;
+    for &preset in PRESETS {
+        if !supported(preset) {
+            continue;
+        }
+        if have_item && preset.is_connector() && !previous_connector {
+            ui.separator();
+        }
+        previous_connector = preset.is_connector();
+        have_item = true;
+        let label = label(preset);
+        ui.push_id(preset.key(), |ui| {
+            let response = IconButton::new(&label, Vec2::new(width, height), style).show(ui, |ui, rect, tint| {
+                let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(rect));
+                let icon = egui::Rect::from_center_size(egui::pos2(rect.left() + 14.0, rect.center().y), Vec2::splat(20.0));
+                paint_preset(&painter, icon, preset, egui::Stroke::new(1.4, tint), tint.gamma_multiply(0.15));
+                let text_rect = egui::Rect::from_min_max(egui::pos2((rect.left() + 30.0).min(rect.right()), rect.top()), rect.max);
+                painter.with_clip_rect(ui.clip_rect().intersect(text_rect)).text(
+                    text_rect.left_center(),
+                    Align2::LEFT_CENTER,
+                    &label,
+                    font.clone(),
+                    tint,
+                );
+            });
+            if response.clicked() {
+                chosen = Some(preset);
+            }
+        });
+    }
+    chosen
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/ui/src/shapes/paint.rs b/crates/ui/src/shapes/paint.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui/src/shapes/paint.rs
@@ -0,0 +1,126 @@
+use super::ShapePreset;
+use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, StrokeKind, vec2};
+
+/// Paint an original procedural preview, adapted from WordCraft's Insert Shapes menu (#2142).
+/// Preview geometry is independent of document geometry: it does not insert/render editable
+/// document objects. No external icon assets, fonts or application dependencies are needed.
+/// Invalid/empty/excessive rectangles produce no paint; stroke widths are bounded to the icon.
+pub fn paint_preset(painter: &Painter, rect: Rect, preset: ShapePreset, stroke: Stroke, fill: Color32) {
+    if !rect.is_finite() || rect.width() <= 0.0 || rect.height() <= 0.0 || rect.size().max_elem() > 4096.0 {
+        return;
+    }
+    let painter = painter.with_clip_rect(painter.clip_rect().intersect(rect));
+    let scale = rect.width().min(rect.height()) / 20.0;
+    let origin = rect.center() - vec2(10.0, 10.0) * scale;
+    let point = |x: f32, y: f32| origin + vec2(x, y) * scale;
+    let width = if stroke.width.is_finite() { stroke.width.clamp(0.0, rect.width().min(rect.height()) * 0.25) } else { 0.0 };
+    let stroke = Stroke::new(width, stroke.color);
+    let polygon = |points: &[(f32, f32)]| {
+        painter.add(Shape::convex_polygon(points.iter().map(|&(x, y)| point(x, y)).collect(), fill, stroke));
+    };
+    let line = |points: &[(f32, f32)]| {
+        painter.add(Shape::line(points.iter().map(|&(x, y)| point(x, y)).collect(), stroke));
+    };
+    let head = |tip: Pos2, previous: Pos2| {
+        let direction = (tip - previous).normalized();
+        let side = vec2(-direction.y, direction.x);
+        painter.add(Shape::line(
+            vec![tip - direction * (4.0 * scale) + side * (2.5 * scale), tip, tip - direction * (4.0 * scale) - side * (2.5 * scale)],
+            stroke,
+        ));
+    };
+    match preset {
+        ShapePreset::Rectangle | ShapePreset::RoundedRectangle | ShapePreset::TextBox => {
+            let shape = Rect::from_min_max(point(3.0, 5.0), point(17.0, 15.0));
+            let radius = if preset == ShapePreset::RoundedRectangle { 3.0 * scale } else { 0.0 };
+            painter.rect(shape, radius, fill, stroke, StrokeKind::Inside);
+            if preset == ShapePreset::TextBox {
+                for y in [8.0, 10.0, 12.0] {
+                    line(&[(6.0, y), (14.0, y)]);
+                }
+            }
+        }
+        ShapePreset::Ellipse => {
+            painter.add(Shape::ellipse_filled(rect.center(), vec2(7.5, 5.5) * scale, fill));
+            painter.add(Shape::ellipse_stroke(rect.center(), vec2(7.5, 5.5) * scale, stroke));
+        }
+        ShapePreset::Triangle => polygon(&[(10.0, 3.5), (17.0, 16.5), (3.0, 16.5)]),
+        ShapePreset::Diamond => polygon(&[(10.0, 3.0), (17.0, 10.0), (10.0, 17.0), (3.0, 10.0)]),
+        ShapePreset::Line | ShapePreset::Arrow => {
+            line(&[(4.0, 16.0), (16.0, 4.0)]);
+            if preset.has_end_arrow() {
+                head(point(16.0, 4.0), point(4.0, 16.0));
+            }
+        }
+        ShapePreset::Star => {
+            let points: Vec<_> = (0..10)
+                .map(|i| {
+                    let angle = -std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::PI / 5.0;
+                    let radius = if i % 2 == 0 { 8.0 } else { 3.3 };
+                    point(10.0 + radius * angle.cos(), 10.8 + radius * angle.sin())
+                })
+                .collect();
+            fill_contour(&painter, point(10.0, 10.8), &points, fill);
+            painter.add(Shape::closed_line(points, stroke));
+        }
+        ShapePreset::Heart => {
+            let points: Vec<_> = (0..32)
+                .map(|i| {
+                    let t = i as f32 / 32.0 * std::f32::consts::TAU;
+                    point(
+                        10.0 + 16.0 * t.sin().powi(3) * 0.44,
+                        9.6 - (13.0 * t.cos() - 5.0 * (2.0 * t).cos() - 2.0 * (3.0 * t).cos() - (4.0 * t).cos()) * 0.44,
+                    )
+                })
+                .collect();
+            fill_contour(&painter, point(10.0, 10.0), &points, fill);
+            painter.add(Shape::closed_line(points, stroke));
+        }
+        _ => {
+            let points: Vec<_> = match preset {
+                ShapePreset::ElbowConnector | ShapePreset::ElbowArrowConnector => {
+                    vec![point(4.0, 15.0), point(10.0, 15.0), point(10.0, 5.0), point(16.0, 5.0)]
+                }
+                ShapePreset::CurvedConnector | ShapePreset::CurvedArrowConnector => (0..=16)
+                    .map(|i| {
+                        let t = i as f32 / 16.0;
+                        let m = 1.0 - t;
+                        let w = [m * m * m, 3.0 * m * m * t, 3.0 * m * t * t, t * t * t];
+                        point(w[0] * 4.0 + w[1] * 12.0 + w[2] * 8.0 + w[3] * 16.0, w[0] * 15.0 + w[1] * 15.0 + w[2] * 5.0 + w[3] * 5.0)
+                    })
+                    .collect(),
+                _ => vec![point(4.0, 15.0), point(16.0, 5.0)],
+            };
+            painter.add(Shape::line(points.clone(), stroke));
+            if let Some(&first) = points.first() {
+                painter.circle_filled(first, 1.6 * scale, stroke.color);
+            }
+            if let [.., previous, tip] = points.as_slice() {
+                if preset.has_end_arrow() {
+                    head(*tip, *previous);
+                } else {
+                    painter.circle_filled(*tip, 1.6 * scale, stroke.color);
+                }
+            }
+        }
+    }
+    // The painter's existing clip handles its host; all preview geometry stays in the icon box.
+}
+
+/// These two fixed contours are star-shaped around the supplied center: it lies in every
+/// edge's interior half-plane. A triangle fan therefore fills their concavities without the
+/// convex-polygon assumption. This is not a triangulator for arbitrary application paths.
+fn fill_contour(painter: &Painter, center: Pos2, points: &[Pos2], fill: Color32) {
+    if points.len() < 3 || fill == Color32::TRANSPARENT {
+        return;
+    }
+    let mut mesh = egui::Mesh::default();
+    mesh.colored_vertex(center, fill);
+    for &point in points {
+        mesh.colored_vertex(point, fill);
+    }
+    for index in 0..points.len() {
+        mesh.add_triangle(0, 1 + index as u32, 1 + ((index + 1) % points.len()) as u32);
+    }
+    painter.add(Shape::mesh(mesh));
+}
diff --git a/crates/ui/src/shapes/tests.rs b/crates/ui/src/shapes/tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui/src/shapes/tests.rs
@@ -0,0 +1,114 @@
+use super::*;
+use crate::buttons::ButtonVisuals;
+use egui::{Color32, CornerRadius, Stroke};
+use egui_kittest::kittest::Queryable;
+
+fn style() -> IconButtonStyle {
+    let normal = ButtonVisuals { fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon: Color32::BLACK };
+    IconButtonStyle {
+        normal,
+        hovered: ButtonVisuals { fill: Color32::LIGHT_BLUE, ..normal },
+        pressed: None,
+        selected: normal,
+        corner_radius: CornerRadius::same(3),
+        focus_stroke: Stroke::new(1.0, Color32::BLUE),
+    }
+}
+
+#[test]
+fn capability_filtered_menu_uses_stable_identity_for_duplicate_localized_labels() {
+    let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(400.0, 300.0)).build_ui_state(
+        |ui, chosen: &mut Option<ShapePreset>| {
+            if let Some(preset) = menu(
+                ui,
+                180.0,
+                FontId::proportional(12.0),
+                &style(),
+                |p| matches!(p, ShapePreset::Rectangle | ShapePreset::Ellipse),
+                |_| "Object".into(),
+            ) {
+                *chosen = Some(preset);
+            }
+        },
+        None,
+    );
+    h.run_steps(2);
+    let items: Vec<_> = h.get_all_by_label("Object").collect();
+    assert_eq!(items.len(), 2);
+    items[1].click();
+    h.run_steps(2);
+    assert_eq!(*h.state(), Some(ShapePreset::Ellipse));
+}
+
+#[test]
+fn catalog_keys_are_unique_and_arrow_connector_semantics_remain_distinct() {
+    let keys: std::collections::HashSet<_> = PRESETS.iter().map(|p| p.key()).collect();
+    assert_eq!(keys.len(), PRESETS.len());
+    assert!(ShapePreset::StraightArrowConnector.is_connector());
+    assert!(ShapePreset::StraightArrowConnector.has_end_arrow());
+    assert!(!ShapePreset::Arrow.is_connector());
+    assert!(ShapePreset::Arrow.has_end_arrow());
+    assert!(!ShapePreset::ElbowConnector.has_end_arrow());
+}
+
+#[test]
+fn every_procedural_preview_tessellates_and_invalid_geometry_is_inert() {
+    let ctx = egui::Context::default();
+    let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
+        for &preset in PRESETS {
+            let rect = egui::Rect::from_min_size(egui::pos2(10.0, 10.0), Vec2::splat(20.0));
+            paint_preset(ui.painter(), rect, preset, Stroke::new(f32::NAN, Color32::BLACK), Color32::LIGHT_BLUE);
+        }
+    });
+    out.textures_delta.clear();
+    for primitive in ctx.tessellate(out.shapes, out.pixels_per_point) {
+        assert!(primitive.clip_rect.min.x >= 10.0 && primitive.clip_rect.min.y >= 10.0);
+        assert!(primitive.clip_rect.max.x <= 30.0 && primitive.clip_rect.max.y <= 30.0);
+        if let egui::epaint::Primitive::Mesh(mesh) = primitive.primitive {
+            assert!(mesh.is_valid());
+            assert!(mesh.vertices.iter().all(|v| v.pos.is_finite()));
+        }
+    }
+    let out = ctx.run_ui(egui::RawInput::default(), |ui| {
+        for &preset in PRESETS {
+            paint_preset(ui.painter(), egui::Rect::NAN, preset, Stroke::NONE, Color32::WHITE);
+        }
+    });
+    assert!(out.shapes.is_empty() || out.shapes.iter().all(|s| matches!(s.shape, egui::Shape::Noop)));
+    out.drop_without_applying_deltas();
+}
+
+#[test]
+fn concave_previews_fill_the_star_tips_and_heart_without_an_outline() {
+    let cross = |a: egui::Pos2, b: egui::Pos2, c: egui::Pos2| {
+        let ab = b - a;
+        let ac = c - a;
+        ab.x * ac.y - ab.y * ac.x
+    };
+    for (preset, sample) in [(ShapePreset::Star, egui::pos2(20.0, 14.0)), (ShapePreset::Heart, egui::pos2(20.0, 20.0))] {
+        let ctx = egui::Context::default();
+        let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
+            paint_preset(ui.painter(), egui::Rect::from_min_size(egui::pos2(10.0, 10.0), Vec2::splat(20.0)), preset, Stroke::NONE, Color32::RED);
+        });
+        out.textures_delta.clear();
+        let mut covered = false;
+        for primitive in ctx.tessellate(out.shapes, out.pixels_per_point) {
+            if let egui::epaint::Primitive::Mesh(mesh) = primitive.primitive {
+                assert!(mesh.is_valid());
+                for triangle in mesh.indices.as_chunks::<3>().0 {
+                    let [a, b, c] = [mesh.vertices[triangle[0] as usize], mesh.vertices[triangle[1] as usize], mesh.vertices[triangle[2] as usize]];
+                    // Consistent winding verifies each fixed contour's center stays in its kernel.
+                    assert!(cross(a.pos, b.pos, c.pos) > 0.0);
+                    covered |= primitive.clip_rect.contains(sample)
+                        && a.color == Color32::RED
+                        && b.color == Color32::RED
+                        && c.color == Color32::RED
+                        && cross(a.pos, b.pos, sample) >= -0.0001
+                        && cross(b.pos, c.pos, sample) >= -0.0001
+                        && cross(c.pos, a.pos, sample) >= -0.0001;
+                }
+            }
+        }
+        assert!(covered, "{preset:?} must honor its fill with no outline");
+    }
+}
