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/content-entry.md b/crates/ui/docs/content-entry.md
new file mode 100644
--- /dev/null
+++ b/crates/ui/docs/content-entry.md
@@ -0,0 +1,19 @@
+# Tab content entry
+
+`tabs::ContentEntry` shares the directional content transition from
+[WordCraft #3781](https://github.com/storytold/wordcraft/pull/3781). It tracks one region's selected
+tab, stable identity, order and entry clock without retaining or rendering application content.
+
+```rust
+let frame = entry.update(selected_id, tab_index, now, 0.16, animations_enabled);
+let offset = egui::vec2(f32::from(frame.direction) * (1.0 - frame.progress) * 14.0, 0.0);
+let opacity = 0.2 + 0.8 * frame.progress;
+// Apply offset and opacity to the caller's clipped child UI containing the new page.
+if frame.moving { ui.ctx().request_repaint(); }
+```
+
+Initial appearance, same-index page replacement, motion-off, invalid parameters and backwards
+clocks settle immediately. Rapid selection changes restart the new page's entry. Reordering the
+same page does not start another animation. Host-owned layout decides direction for RTL strips.
+The host keeps focus and command policy, disabled state, clipping and page contents, and decides
+whether input should be suppressed during entry. A fading page is not automatically disabled.
diff --git a/crates/ui/src/tabs/content.rs b/crates/ui/src/tabs/content.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui/src/tabs/content.rs
@@ -0,0 +1,105 @@
+use egui::Id;
+
+/// The new page's entry progress. Applications translate/clip their child UI and set its opacity
+/// using this value. It describes only the new content; no outgoing content is retained.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ContentEntryFrame {
+    /// Eased 0..1, with 1 meaning fully settled.
+    pub progress: f32,
+    /// +1 enters from the right, -1 from the left, 0 means no movement. Order is caller-owned.
+    pub direction: i8,
+    /// Request an egui repaint while true.
+    pub moving: bool,
+}
+
+impl ContentEntryFrame {
+    const SETTLED: Self = Self { progress: 1.0, direction: 0, moving: false };
+}
+
+/// Directional entry when switching tabs, reusable for ribbons, inspectors and settings pages.
+/// Keep one instance per content region. IDs must not depend on translated labels.
+/// The first page appears immediately; a new page enters from the side indicated by its order
+/// relative to the previous one. Reordering the same tab alone never starts an animation.
+#[derive(Clone, Debug, Default)]
+pub struct ContentEntry {
+    state: Option<State>,
+}
+
+#[derive(Clone, Debug)]
+struct State {
+    selected: Id,
+    order: usize,
+    start: f64,
+    last_time: f64,
+    direction: i8,
+}
+
+impl ContentEntry {
+    /// `now` is monotonic seconds, `duration` seconds (bounded to ten). Motion-off, invalid time,
+    /// invalid/non-positive durations and a backwards clock settle immediately, without replay
+    /// when motion is re-enabled. A second selection change starts the *new* page's entry again;
+    /// the host owns page replacement, focus, enabled state and any input suppression.
+    pub fn update(&mut self, selected: Id, order: usize, now: f64, duration: f32, motion: bool) -> ContentEntryFrame {
+        let active = motion && now.is_finite() && duration.is_finite() && duration > 0.0;
+        let time = if now.is_finite() { now } else { 0.0 };
+        let mut direction = 0;
+        let mut start = time;
+        if let Some(previous) = self.state.as_ref().filter(|p| active && time >= p.last_time) {
+            if previous.selected == selected {
+                direction = previous.direction;
+                start = previous.start;
+            } else {
+                direction = match order.cmp(&previous.order) {
+                    std::cmp::Ordering::Greater => 1,
+                    std::cmp::Ordering::Less => -1,
+                    std::cmp::Ordering::Equal => 0,
+                };
+            }
+        }
+        let progress = if active { ((time - start) / f64::from(duration.min(10.0))).clamp(0.0, 1.0) as f32 } else { 1.0 };
+        if progress >= 1.0 {
+            direction = 0;
+        }
+        self.state = Some(State { selected, order, start, last_time: time, direction });
+        if direction == 0 {
+            ContentEntryFrame::SETTLED
+        } else {
+            ContentEntryFrame { progress: 1.0 - (1.0 - progress).powi(3), direction, moving: true }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    #[test]
+    fn selection_direction_interruption_and_initial_appearance() {
+        let mut entry = ContentEntry::default();
+        let a = Id::new("a");
+        let b = Id::new("b");
+        let c = Id::new("c");
+        assert_eq!(entry.update(a, 0, 0.0, 1.0, true), ContentEntryFrame::SETTLED);
+        assert_eq!(entry.update(b, 1, 1.0, 1.0, true), ContentEntryFrame { progress: 0.0, direction: 1, moving: true });
+        let mid = entry.update(b, 1, 1.5, 1.0, true);
+        assert!(mid.progress > 0.0 && mid.progress < 1.0);
+        assert_eq!(entry.update(c, 0, 1.5, 1.0, true).direction, -1);
+        assert_eq!(entry.update(c, 0, 3.0, 1.0, true), ContentEntryFrame::SETTLED);
+        assert_eq!(entry.update(c, 20, 4.0, 1.0, true), ContentEntryFrame::SETTLED);
+    }
+    #[test]
+    fn motion_off_and_hostile_clocks_settle_without_replay() {
+        let mut entry = ContentEntry::default();
+        let a = Id::new("a");
+        let b = Id::new("b");
+        entry.update(a, 0, 0.0, 1.0, true);
+        entry.update(b, 1, 1.0, 1.0, true);
+        assert_eq!(entry.update(b, 1, 1.1, 1.0, false), ContentEntryFrame::SETTLED);
+        assert_eq!(entry.update(b, 1, 1.2, 1.0, true), ContentEntryFrame::SETTLED);
+        for duration in [f32::NAN, f32::INFINITY, -1.0, 0.0] {
+            assert_eq!(entry.update(a, 0, 2.0, duration, true), ContentEntryFrame::SETTLED);
+        }
+        assert_eq!(entry.update(b, 1, f64::NAN, 1.0, true), ContentEntryFrame::SETTLED);
+        entry.update(a, 0, 2.0, 1.0, true);
+        assert_eq!(entry.update(b, 1, 1.0, 1.0, true), ContentEntryFrame::SETTLED);
+    }
+}
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 content;
+pub use content::{ContentEntry, ContentEntryFrame};
+
 /// Tabs visible on a strip and tabs that need an overflow menu, all in original order.
 #[derive(Clone, Debug, Default, PartialEq)]
 pub struct TabFit {
