diff --git a/src/AcDream.App/UI/UiScrollable.cs b/src/AcDream.App/UI/UiScrollable.cs
new file mode 100644
index 00000000..d30e2a0a
--- /dev/null
+++ b/src/AcDream.App/UI/UiScrollable.cs
@@ -0,0 +1,57 @@
+using System;
+
+namespace AcDream.App.UI;
+
+///
+/// Pixel-based vertical scroll model. Port of retail UIElement_Scrollable:
+/// the scroll offset is an integer pixel value (m_iScrollableY) clamped to
+/// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position
+/// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and
+/// shared by the transcript (UiChatView) and the scrollbar (UiChatScrollbar).
+/// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0,
+/// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0.
+///
+public sealed class UiScrollable
+{
+ /// Total wrapped content height in px (m_iScrollableHeight).
+ public int ContentHeight { get; set; }
+ /// Visible viewport height in px.
+ public int ViewHeight { get; set; }
+ /// Pixels per text line (scroll quantum). InqScrollDelta line case.
+ public int LineHeight { get; set; } = 16;
+
+ private int _scrollY;
+ /// Current scroll offset in px from the top of the content.
+ public int ScrollY => _scrollY;
+
+ /// Max scroll = max(0, content - view).
+ public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight);
+
+ /// True when content exceeds the view (a scrollbar is warranted).
+ public bool HasOverflow => ContentHeight > ViewHeight;
+
+ /// True when the offset is at (or past) the bottom — used for bottom-pin.
+ public bool AtEnd => _scrollY >= MaxScroll;
+
+ /// Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp).
+ public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll);
+
+ /// Pin to the bottom (newest content visible).
+ public void ScrollToEnd() => _scrollY = MaxScroll;
+
+ /// Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_).
+ public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight);
+
+ /// Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_).
+ public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll;
+
+ /// Inverse of PositionRatio — used when the user drags the thumb.
+ public void SetPositionRatio(float ratio)
+ => SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll));
+
+ /// Scroll by whole lines (sign: +down/newer, -up/older).
+ public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight);
+
+ /// Scroll by a page = one view height (InqScrollDelta page case).
+ public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight);
+}
diff --git a/tests/AcDream.App.Tests/UI/UiScrollableTests.cs b/tests/AcDream.App.Tests/UI/UiScrollableTests.cs
new file mode 100644
index 00000000..27804b1c
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/UiScrollableTests.cs
@@ -0,0 +1,73 @@
+using AcDream.App.UI;
+using Xunit;
+
+namespace AcDream.App.Tests.UI;
+
+public class UiScrollableTests
+{
+ [Fact]
+ public void Clamp_KeepsScrollWithinContent()
+ {
+ var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 };
+ s.SetScrollY(500);
+ Assert.Equal(200, s.ScrollY);
+ s.SetScrollY(-50);
+ Assert.Equal(0, s.ScrollY);
+ }
+
+ [Fact]
+ public void FitsView_PinsToZero()
+ {
+ var s = new UiScrollable { ContentHeight = 80, ViewHeight = 100 };
+ s.SetScrollY(40);
+ Assert.Equal(0, s.ScrollY);
+ Assert.False(s.HasOverflow);
+ }
+
+ [Fact]
+ public void ThumbRatio_IsViewOverContent_ClampedToOne()
+ {
+ var s = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
+ Assert.Equal(0.25f, s.ThumbRatio, 3);
+ var full = new UiScrollable { ContentHeight = 50, ViewHeight = 100 };
+ Assert.Equal(1f, full.ThumbRatio, 3);
+ }
+
+ [Fact]
+ public void PositionRatio_MapsScrollToZeroOne()
+ {
+ var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 };
+ s.SetScrollY(100);
+ Assert.Equal(0.5f, s.PositionRatio, 3);
+ s.SetScrollY(200);
+ Assert.Equal(1f, s.PositionRatio, 3);
+ }
+
+ [Fact]
+ public void SetPositionRatio_IsInverseOfPositionRatio()
+ {
+ var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 };
+ s.SetPositionRatio(0.5f);
+ Assert.Equal(100, s.ScrollY);
+ }
+
+ [Fact]
+ public void ScrollByLines_AdvancesByLineHeight()
+ {
+ var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 };
+ s.ScrollByLines(-2);
+ Assert.Equal(0, s.ScrollY);
+ s.SetScrollY(50);
+ s.ScrollByLines(2);
+ Assert.Equal(82, s.ScrollY);
+ }
+
+ [Fact]
+ public void ScrollByPage_AdvancesByViewHeight()
+ {
+ var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 };
+ s.SetScrollY(200);
+ s.ScrollByPage(1);
+ Assert.Equal(300, s.ScrollY);
+ }
+}