diff --git a/AcDream.slnx b/AcDream.slnx
index e7fd39a..760229e 100644
--- a/AcDream.slnx
+++ b/AcDream.slnx
@@ -6,6 +6,7 @@
+
diff --git a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj
new file mode 100644
index 0000000..0fc070c
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj
@@ -0,0 +1,12 @@
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+
+
+
+
+
diff --git a/src/AcDream.UI.Abstractions/ICommandBus.cs b/src/AcDream.UI.Abstractions/ICommandBus.cs
new file mode 100644
index 0000000..7174296
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/ICommandBus.cs
@@ -0,0 +1,24 @@
+namespace AcDream.UI.Abstractions;
+
+///
+/// Publishes user-intent commands from panels to the systems that handle
+/// them (WorldSession, ChatService, Inventory, ...). Panels never touch
+/// those systems directly — they a record
+/// and the bus dispatches.
+///
+///
+/// D.2a scaffolding: is the default wire-up
+/// — commands are accepted but dropped. Real routing lands alongside
+/// chat and inventory (Sprint 2 of the UI plan) when we actually need
+/// commands flowing server-ward.
+///
+///
+public interface ICommandBus
+{
+ ///
+ /// Publish a command record. The bus routes by runtime type via
+ /// registered handlers. Never blocks; handlers run on the publish
+ /// thread today (render thread for panel-triggered commands).
+ ///
+ void Publish(T command) where T : notnull;
+}
diff --git a/src/AcDream.UI.Abstractions/IPanel.cs b/src/AcDream.UI.Abstractions/IPanel.cs
new file mode 100644
index 0000000..5a4a37f
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/IPanel.cs
@@ -0,0 +1,37 @@
+namespace AcDream.UI.Abstractions;
+
+///
+/// A UI panel — chat window, inventory, vitals HUD, character sheet, etc.
+/// Panels are backend-agnostic: they only call into
+/// primitives, never reach through to a specific UI library (Hexa.NET.ImGui
+/// in Phase D.2a, a custom retail-look toolkit in Phase D.2b).
+///
+///
+/// Hard rule: no using Hexa.NET.ImGui inside a panel file. If a
+/// widget needs a feature the abstraction doesn't expose, extend
+/// ; do not import the backend. See
+/// docs/plans/2026-04-24-ui-framework.md.
+///
+///
+public interface IPanel
+{
+ /// Stable, globally-unique identifier. Convention: acdream.{name}.
+ string Id { get; }
+
+ /// Human-readable window title shown in the chrome of the panel.
+ string Title { get; }
+
+ ///
+ /// Whether the panel is currently visible. Backends read this per frame;
+ /// panels may mutate it in response to their own close-button handling.
+ ///
+ bool IsVisible { get; set; }
+
+ ///
+ /// Draw the panel for one frame. Called by
+ /// on the render thread once ImGui's (or the future custom backend's)
+ /// frame has begun. Panels issue drawing calls through
+ /// and publish user-intent actions through ..
+ ///
+ void Render(PanelContext ctx, IPanelRenderer renderer);
+}
diff --git a/src/AcDream.UI.Abstractions/IPanelHost.cs b/src/AcDream.UI.Abstractions/IPanelHost.cs
new file mode 100644
index 0000000..bbd685f
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/IPanelHost.cs
@@ -0,0 +1,36 @@
+namespace AcDream.UI.Abstractions;
+
+///
+/// Owns the set of live s and drives per-frame draw
+/// dispatch. The backend (Hexa.NET.ImGui in D.2a, custom in D.2b) implements
+/// this; GameWindow creates one at startup and registers panels.
+///
+///
+/// Does not call ImGui.NewFrame / ImGui.Render — those
+/// belong to the caller so GL-state ownership is unambiguous. Caller pattern:
+///
+///
+///
+/// // per frame, render thread
+/// inputBridge.BeginFrame(size, dt);
+/// ImGui.NewFrame();
+/// panelHost.RenderAll(ctx);
+/// ImGui.Render();
+/// ImGuiImplOpenGL3.RenderDrawData(ImGui.GetDrawData());
+///
+///
+public interface IPanelHost
+{
+ /// Register a panel for per-frame rendering. Idempotent by .
+ void Register(IPanel panel);
+
+ /// Remove the panel with the matching id. No-op if not present.
+ void Unregister(string panelId);
+
+ ///
+ /// Iterate every visible panel and call . Call
+ /// order within a frame is the registration order; panels with
+ /// set to false are skipped entirely.
+ ///
+ void RenderAll(PanelContext ctx);
+}
diff --git a/src/AcDream.UI.Abstractions/IPanelRenderer.cs b/src/AcDream.UI.Abstractions/IPanelRenderer.cs
new file mode 100644
index 0000000..93e2584
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/IPanelRenderer.cs
@@ -0,0 +1,43 @@
+namespace AcDream.UI.Abstractions;
+
+///
+/// Drawing primitives exposed to panels. The only API panels use to
+/// emit pixels. The ImGui backend maps these straight onto ImGui calls; the
+/// later custom retail-look backend will map the same primitives onto its
+/// own retained-mode toolkit using retail dat-sourced fonts / sprites.
+///
+///
+/// Keep this interface small and retail-friendly. If a widget requires a
+/// feature the custom backend couldn't express with dat assets, don't add
+/// it — find a different widget shape that both backends can satisfy.
+///
+///
+public interface IPanelRenderer
+{
+ ///
+ /// Begin a top-level window. Matches retail's root UiPanel +
+ /// ImGui's Begin. Returns false if the window is collapsed
+ /// — the caller must still call to balance.
+ ///
+ bool Begin(string title);
+
+ /// Close the most recent .
+ void End();
+
+ /// Draw a single line of text. No formatting / markdown.
+ void Text(string text);
+
+ /// Keep the next widget on the same line as the previous one.
+ void SameLine();
+
+ /// Horizontal rule separator.
+ void Separator();
+
+ ///
+ /// A filled progress bar.
+ /// is clamped by the backend to [0, 1].
+ /// is the pixel width of the full bar.
+ /// is optional text (e.g. "54%") rendered on top.
+ ///
+ void ProgressBar(float fraction, float width, string? overlay = null);
+}
diff --git a/src/AcDream.UI.Abstractions/NullCommandBus.cs b/src/AcDream.UI.Abstractions/NullCommandBus.cs
new file mode 100644
index 0000000..2c111ea
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/NullCommandBus.cs
@@ -0,0 +1,21 @@
+namespace AcDream.UI.Abstractions;
+
+///
+/// No-op . Accepts any published command and
+/// discards it. Used as the default in D.2a until chat / inventory panels
+/// need real command routing.
+///
+public sealed class NullCommandBus : ICommandBus
+{
+ /// Shared singleton — the bus is stateless.
+ public static readonly NullCommandBus Instance = new();
+
+ private NullCommandBus() { }
+
+ ///
+ public void Publish(T command) where T : notnull
+ {
+ // Intentionally empty. Panel-emitted commands in D.2a are
+ // read-only diagnostics; nothing routes server-ward yet.
+ }
+}
diff --git a/src/AcDream.UI.Abstractions/PanelContext.cs b/src/AcDream.UI.Abstractions/PanelContext.cs
new file mode 100644
index 0000000..37caba1
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/PanelContext.cs
@@ -0,0 +1,15 @@
+namespace AcDream.UI.Abstractions;
+
+///
+/// Per-frame context passed to each call.
+/// Struct + record for zero-allocation per frame. Add fields here as new
+/// capabilities become panel-facing — e.g. a future IGameState
+/// handle once we need richer data than individual ViewModels can carry.
+///
+///
+/// Carried by value; cheap. Passed per-render; do not cache across frames.
+///
+///
+public readonly record struct PanelContext(
+ float DeltaSeconds,
+ ICommandBus Commands);
diff --git a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs
new file mode 100644
index 0000000..5040c7e
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs
@@ -0,0 +1,69 @@
+namespace AcDream.UI.Abstractions.Panels.Vitals;
+
+///
+/// First real UI panel — shows the local player's vitals as progress bars.
+/// Backend-agnostic; renders exclusively through
+/// so the same file works under Hexa.NET.ImGui (D.2a) and the future custom
+/// retail-look toolkit (D.2b).
+///
+///
+/// D.2a shows only HP (percent). /
+/// return null until a
+/// LocalPlayerState is wired (follow-up issue). When they start
+/// returning non-null, this panel picks them up automatically.
+///
+///
+public sealed class VitalsPanel : IPanel
+{
+ private const float BarWidth = 200f;
+
+ private readonly VitalsVM _vm;
+
+ public VitalsPanel(VitalsVM vm)
+ {
+ _vm = vm ?? throw new ArgumentNullException(nameof(vm));
+ }
+
+ ///
+ public string Id => "acdream.vitals";
+
+ ///
+ public string Title => "Vitals";
+
+ ///
+ public bool IsVisible { get; set; } = true;
+
+ ///
+ public void Render(PanelContext ctx, IPanelRenderer renderer)
+ {
+ if (!renderer.Begin(Title))
+ {
+ renderer.End();
+ return;
+ }
+
+ // HP — always available from CombatState.
+ float hp = _vm.HealthPercent;
+ renderer.Text("HP");
+ renderer.SameLine();
+ renderer.ProgressBar(hp, BarWidth, overlay: $"{hp * 100f:F0}%");
+
+ // Stamina — show only when the VM has a real value.
+ if (_vm.StaminaPercent is float stam)
+ {
+ renderer.Text("Stam");
+ renderer.SameLine();
+ renderer.ProgressBar(stam, BarWidth, overlay: $"{stam * 100f:F0}%");
+ }
+
+ // Mana — show only when the VM has a real value.
+ if (_vm.ManaPercent is float mana)
+ {
+ renderer.Text("Mana");
+ renderer.SameLine();
+ renderer.ProgressBar(mana, BarWidth, overlay: $"{mana * 100f:F0}%");
+ }
+
+ renderer.End();
+ }
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs
new file mode 100644
index 0000000..6d512f2
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs
@@ -0,0 +1,75 @@
+using AcDream.Core.Combat;
+
+namespace AcDream.UI.Abstractions.Panels.Vitals;
+
+///
+/// ViewModel for the vitals HUD panel. Reads live health percentage for the
+/// local player from (which is fed by the server's
+/// UpdateHealth (0x01C0) GameEvent).
+///
+///
+/// D.2a scope limits:
+///
+///
+///
+/// - HP comes from and is percent-only
+/// (0..1). Absolute current/max HP is not wired yet.
+/// - Stamina / Mana are always null — those values live in
+/// AppraiseInfoParser.CreatureProfile (parsed from
+/// PlayerDescription (0x0013)) but the parsed record is
+/// currently discarded. Wiring a LocalPlayerState cache is
+/// a separate follow-up; see docs/ISSUES.md.
+///
+///
+///
+/// GUID timing: the local player's server GUID isn't known at
+/// OnLoad (pre-login). Construct with
+/// left as 0; GameWindow calls the setter when the live session
+/// receives its guid at EnterWorld. Before the GUID is set,
+/// returns 1.0 (via CombatState's safe
+/// default for unknown guids) — the bar reads "full", which is harmless.
+///
+///
+public sealed class VitalsVM
+{
+ private readonly CombatState _combat;
+ private uint _localPlayerGuid;
+
+ ///
+ /// Build a VitalsVM bound to a instance. The
+ /// GUID starts at 0; call once the
+ /// live session assigns it.
+ ///
+ public VitalsVM(CombatState combat)
+ {
+ _combat = combat ?? throw new ArgumentNullException(nameof(combat));
+ _localPlayerGuid = 0;
+ }
+
+ ///
+ /// Push the authoritative local-player GUID from WorldSession.
+ /// One-way setter — only GameWindow should call it, exactly once
+ /// per live session.
+ ///
+ public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid;
+
+ ///
+ /// Current health percent (0..1) for the local player. Returns 1.0
+ /// before login or if the server has never sent an UpdateHealth for
+ /// this GUID.
+ ///
+ public float HealthPercent => _combat.GetHealthPercent(_localPlayerGuid);
+
+ ///
+ /// Stamina percent (0..1) or null when absolute values aren't wired.
+ /// D.2a always returns null; to be populated by a future
+ /// LocalPlayerState that caches PlayerDescription (0x0013).
+ ///
+ public float? StaminaPercent => null;
+
+ ///
+ /// Mana percent (0..1) or null when absolute values aren't wired.
+ /// Same status as .
+ ///
+ public float? ManaPercent => null;
+}