diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs
index 3ecef835..d6569b3c 100644
--- a/src/AcDream.App/UI/UiScrollbar.cs
+++ b/src/AcDream.App/UI/UiScrollbar.cs
@@ -208,8 +208,21 @@ public sealed class UiScrollbar : UiElement
internal bool IsPresentationVisible => !HideWhenDisabled || !IsModelDisabled;
+ ///
+ /// A content-fits (disabled) bar is still hit-testable and still
+ /// hover-highlights — retail's arrows and thumb are real child elements
+ /// whose Normal_rollover hot-tracking keeps running when the scrollbar
+ /// disables; the disabled state only hides the page-click regions
+ /// (UpdateLayout @0x004710d0's children 4-7) and, with attribute 0x79,
+ /// the whole bar. Scrolling input stays inert through geometry: with a
+ /// full-track thumb there is no travel and the line/page steps clamp to
+ /// nothing. The previous IsModelDisabled hit gate made the bar
+ /// hit-TRANSPARENT, which is why hovering it "did nothing" (2026-08-24
+ /// live probe: hovers over the bar reported widget=<none>).
+ /// Only a presentation-hidden bar (0x79 + disabled) ignores the pointer.
+ ///
protected override bool OnHitTest(float localX, float localY)
- => !IsModelDisabled && base.OnHitTest(localX, localY);
+ => IsPresentationVisible && base.OnHitTest(localX, localY);
///
/// Computes the thumb rectangle (local y origin and height) within the track area
@@ -520,7 +533,11 @@ public sealed class UiScrollbar : UiElement
return false; // informational — never consumes
}
- if (IsModelDisabled)
+ // Only a presentation-HIDDEN bar ignores input (see OnHitTest's own
+ // doc): a visible disabled bar keeps hover/pressed visuals exactly
+ // like retail's still-hot-tracking button/thumb children, while its
+ // scroll operations no-op through zero travel.
+ if (!IsPresentationVisible)
{
_draggingThumb = false;
_hoveredThumb = false;
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
index 96e142b2..19ba6170 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
@@ -1953,6 +1953,51 @@ public class CharacterStatControllerTests
Assert.Equal(RetailScrollbarChrome.ThumbMidRollover, scrollbar.ThumbRolloverSprite);
}
+ ///
+ /// 2026-08-24 owner report: "hovering the scrollbar and arrows does
+ /// nothing" — root-level hover through the REAL mounted character
+ /// fixture (production element tree, overlays and z-order included).
+ ///
+ [Fact]
+ public void ProductionFixture_HoveringTheSkillScrollbar_SelectsRolloverMedia()
+ {
+ var layout = FixtureLoader.LoadCharacter();
+ CharacterStatController.Bind(
+ layout,
+ SampleData.SampleCharacter,
+ spriteResolve: id => (id, 16, 16));
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ root.AddChild(layout.Root);
+ ApplyLayoutPass(layout.Root);
+
+ ClickTab(layout, left: 92f);
+ var page = layout.Root.Children.Single(
+ e => e.DatElementId == CharacterStatController.AttributesPageId);
+ var list = Descendants(page).Single(
+ e => e.DatElementId == CharacterStatController.ListBoxId);
+ var scrollbar = list.Parent!.Children.OfType().Single(
+ e => e.DatElementId == CharacterStatController.ListScrollbarId);
+ Assert.True(scrollbar.Visible);
+ // The headless harness never measures row content; give the REAL
+ // bound model an overflowing extent so the bar is enabled the way
+ // a populated skills list is in production.
+ Assert.NotNull(scrollbar.Model);
+ scrollbar.Model!.ContentHeight = 800;
+ scrollbar.Model.ViewHeight = 398;
+ Assert.False(scrollbar.IsModelDisabled);
+
+ var screen = scrollbar.ScreenPosition;
+ // Over the up arrow (5px into the 16px top button).
+ root.OnMouseMove((int)(screen.X + 8f), (int)(screen.Y + 5f));
+ Assert.Equal(
+ RetailScrollbarChrome.UpRollover, scrollbar.ActiveStartSpriteForTest);
+
+ // Over the thumb (just below the up button; thumb starts at track top).
+ root.OnMouseMove((int)(screen.X + 8f), (int)(screen.Y + 24f));
+ Assert.Equal(
+ RetailScrollbarChrome.ThumbMidRollover, scrollbar.ActiveThumbSpriteForTest);
+ }
+
// ── Helpers ──────────────────────────────────────────────────────────────
private static void ClickTab(ImportedLayout layout, float left)
diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
index daa501a3..f9e51e3f 100644
--- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
@@ -573,6 +573,83 @@ public class UiScrollbarTests
Assert.Equal(1u, bar.ActiveThumbSpriteForTest);
}
+ ///
+ /// 2026-08-24 live-probe root cause: a content-fits (disabled) bar was
+ /// hit-TRANSPARENT, so hovering it reported widget=<none> and no
+ /// state ever highlighted. Retail's arrows/thumb are real child
+ /// elements that keep hot-tracking while the scrollbar is disabled —
+ /// only the page regions (and, with 0x79, the whole bar) go away.
+ ///
+ [Fact]
+ public void DisabledVisibleBar_StillHoverHighlights_ButNeverScrolls()
+ {
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ var model = new UiScrollable { ContentHeight = 100, ViewHeight = 150, LineHeight = 10 };
+ var bar = new UiScrollbar
+ {
+ Left = 100, Top = 100, Width = 16f, Height = 200f,
+ Model = model,
+ UpSprite = 1u, UpRolloverSprite = 2u,
+ ThumbSprite = 4u, ThumbRolloverSprite = 5u,
+ };
+ root.AddChild(bar);
+ Assert.True(bar.IsModelDisabled);
+ Assert.True(bar.IsPresentationVisible);
+
+ // Hover the up arrow: highlight, exactly like retail's full-bar state.
+ root.OnMouseMove(108, 105);
+ Assert.Equal(2u, bar.ActiveStartSpriteForTest);
+
+ // Hover the (full-track) thumb: highlight.
+ root.OnMouseMove(108, 130);
+ Assert.Equal(5u, bar.ActiveThumbSpriteForTest);
+
+ // Clicking consumes (the bar is opaque UI) but cannot scroll.
+ Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8, Data2: 5)));
+ Assert.Equal(0, model.ScrollY);
+
+ // A presentation-HIDDEN bar (0x79 + disabled) stays inert.
+ bar.HideWhenDisabled = true;
+ Assert.False(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8, Data2: 5)));
+ }
+
+ ///
+ /// Root-level repro for the 2026-08-24 owner report "hovering the
+ /// scrollbar and arrows does nothing": drives real
+ /// hit-test/hover dispatch instead of
+ /// synthetic direct OnEvent calls.
+ ///
+ [Fact]
+ public void RootMouseMove_OverArrowAndThumb_SelectsRolloverMedia()
+ {
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ var model = new UiScrollable { ContentHeight = 400, ViewHeight = 150, LineHeight = 10 };
+ var bar = new UiScrollbar
+ {
+ Left = 100, Top = 100, Width = 16f, Height = 200f,
+ Model = model,
+ UpSprite = 1u, UpRolloverSprite = 2u, UpPressedSprite = 3u,
+ DownSprite = 7u, DownRolloverSprite = 8u,
+ ThumbSprite = 4u, ThumbRolloverSprite = 5u,
+ };
+ root.AddChild(bar);
+
+ // Over the up arrow (local y = 5, inside the 16px button).
+ root.OnMouseMove(108, 105);
+ Assert.Equal(2u, bar.ActiveStartSpriteForTest);
+
+ // Over the thumb (track 16..184, ratio 150/400=0.375 → thumb 16..79
+ // local, 116..179 screen).
+ root.OnMouseMove(108, 130);
+ Assert.Equal(1u, bar.ActiveStartSpriteForTest);
+ Assert.Equal(5u, bar.ActiveThumbSpriteForTest);
+
+ // Over the down arrow (local y >= 184).
+ root.OnMouseMove(108, 290);
+ Assert.Equal(4u, bar.ActiveThumbSpriteForTest);
+ Assert.Equal(8u, bar.ActiveEndSpriteForTest);
+ }
+
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;