feat(wizard): the chrome speaks the window's language (block A of v1.15.0)

The wizard is the first thing a tester ever sees and it was the last surface
still drawn in ImGui defaults -- stock buttons under a hardcoded forge bronze
that ignored the active theme entirely, since v1.5.2.

Block A is the frame: the settings backdrop at full strength, pagination dots on
the contrast-checked theme accent (current step filled, the rest rings, so the
position reads by shape), step-3 section headers in the tracked-caps-and-fading-
rule language every finished window uses, the primary action as the chamfered
accent pill, and back/skip as ghost links. The selected profile card's border
and the step-4 accents follow the theme now too.

Still open, deliberately: block B gives the profile cards their real shape --
including replacing the emoji icons, which have stood against the global
no-emoji-as-UI-icon rule since the cards were built -- and block C moves the
step-3 checkboxes onto ToggleSwitch rows and the theme list onto PopupRow.
This commit is contained in:
2026-08-19 19:44:39 +02:00
parent 6fadbb1659
commit 5958bceb1c
2 changed files with 166 additions and 42 deletions
+4 -1
View File
@@ -389,7 +389,10 @@ internal static class PluginHostFactory
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<PayloadHandler>()
));
services.AddSingleton(sp => new FirstRunWizard(sp.GetRequiredService<Plugin>()));
services.AddSingleton(sp => new FirstRunWizard(
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<Ui.StyleEngine.SurfaceBackdrop>()
));
// Hosted-service adapters: thin wrappers around the existing init
// methods so the service class bodies stay unchanged. FontManager
+162 -41
View File
@@ -9,6 +9,7 @@ using HellionChat.Code;
using HellionChat.Privacy;
using HellionChat.Resources;
using HellionChat.Themes;
using HellionChat.Ui.StyleEngine;
using HellionChat.Util;
namespace HellionChat.Ui;
@@ -19,28 +20,25 @@ namespace HellionChat.Ui;
// nested WizardState record; every step writes nullable Pending*
// fields, and CommitPending() applies only the non-null ones so
// users who skip a step never get their existing config overwritten.
//
// This is the first thing a tester ever sees, and until v1.15.0 it was the last
// surface still drawn in ImGui defaults -- stock buttons under a hardcoded
// bronze that ignored the theme. It speaks the window's language now: the
// settings backdrop, tracked-caps headings, the chamfered accent pill for the
// one action that matters, and ghosts for the ones that do not.
public sealed class FirstRunWizard : Window
{
// Forge-Bronze (#C2410C). The same constant lives in ThemeRegistry
// and the forge-announce workflow; pinning it locally keeps the
// wizard render path free of registry lookups during draw.
private static readonly Vector4 ForgeBronze = new(0xC2 / 255f, 0x41 / 255f, 0x0C / 255f, 1f);
private static readonly Vector4 ForgeBronzeDim = new(
0xC2 / 255f,
0x41 / 255f,
0x0C / 255f,
0.3f
);
private const int TotalSteps = 4;
private readonly Plugin Plugin;
private readonly StyleEngine.SurfaceBackdrop _backdrop;
private readonly WizardState _state = new();
internal FirstRunWizard(Plugin plugin)
internal FirstRunWizard(Plugin plugin, StyleEngine.SurfaceBackdrop backdrop)
: base($"{HellionStrings.Wizard_Title}###hellion-firstrun")
{
Plugin = plugin;
_backdrop = backdrop;
Flags = ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoDocking;
SizeCondition = ImGuiCond.Appearing;
@@ -63,10 +61,12 @@ public sealed class FirstRunWizard : Window
public override void Draw()
{
// Same floor as the settings window, full strength: the wizard is read
// in glances, not line by line, and it is the first impression.
_backdrop.Draw(accentWashHeight: 46f, moteIntensity: 1f, strength: 1f);
DrawPagination();
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
switch (_state.CurrentStep)
{
@@ -94,26 +94,149 @@ public sealed class FirstRunWizard : Window
var draw = ImGui.GetWindowDrawList();
var avail = ImGui.GetContentRegionAvail();
var cursor = ImGui.GetCursorScreenPos();
const float radius = 5f;
const float spacing = 16f;
var scale = StyleEngine.Metrics.Scale;
var radius = 4f * scale;
var spacing = 18f * scale;
var totalWidth = (TotalSteps - 1) * spacing;
var startX = cursor.X + avail.X - totalWidth - radius;
// Theme accent, contrast-checked, instead of the hardcoded forge bronze
// the wizard carried since v1.5.2 -- it was the one surface that ignored
// the theme entirely. The current dot is filled, the rest are rings, so
// the step reads by shape as well as by brightness.
var c = Plugin.ThemeRegistry.Active.Colors;
var accent = ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(c.Accent),
ColourUtil.RgbaToAbgr(c.WindowBg),
3f
);
var dim = ColourUtil.ApplyAlpha(accent, 0.35f);
for (var i = 0; i < TotalSteps; i++)
{
var color = (i + 1) == _state.CurrentStep ? ForgeBronze : ForgeBronzeDim;
var packed = ImGui.GetColorU32(color);
draw.AddCircleFilled(
new Vector2(startX + i * spacing, cursor.Y + radius),
radius,
packed
);
var centre = new Vector2(startX + i * spacing, cursor.Y + radius);
if ((i + 1) == _state.CurrentStep)
draw.AddCircleFilled(centre, radius, accent);
else
draw.AddCircle(centre, radius - 1f, dim, 0, MathF.Max(1f, scale));
}
// Reserve vertical space the circles consumed so the next widget starts below them.
ImGui.Dummy(new Vector2(0, radius * 2));
}
// The heading language every finished window speaks: tracked small caps with
// a rule that fades out. Shape, not hue, so it reads in every palette.
// Contrast-checked theme accent as a Vector4, for the text pushes that used
// to hardcode forge bronze. The double swap is deliberate: RgbaToAbgr is an
// involution, so it converts ABGR back to the RGBA that RgbaToVector4 reads.
private Vector4 AccentVec4()
{
var c = Plugin.ThemeRegistry.Active.Colors;
var abgr = ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(c.Accent),
ColourUtil.RgbaToAbgr(c.WindowBg),
4.5f
);
return ColourUtil.RgbaToVector4(ColourUtil.RgbaToAbgr(abgr));
}
private void DrawHeading(string text)
{
var scale = StyleEngine.Metrics.Scale;
var c = Plugin.ThemeRegistry.Active.Colors;
var dl = ImGui.GetWindowDrawList();
var pos = ImGui.GetCursorScreenPos();
var tint = ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(c.TextMuted),
ColourUtil.RgbaToAbgr(c.WindowBg),
4.5f
);
var width = dl.DrawTrackedText(pos, text.ToUpperInvariant(), tint, 1.8f * scale);
var ruleX = pos.X + width + 10f * scale;
var availX = ImGui.GetContentRegionAvail().X;
if (pos.X + availX > ruleX)
dl.DrawFadeRule(
new Vector2(ruleX, pos.Y + ImGui.GetTextLineHeight() * 0.5f),
pos.X + availX - ruleX,
ColourUtil.RgbaToAbgr(c.Border),
MathF.Max(1f, scale)
);
ImGui.Dummy(new Vector2(0f, ImGui.GetTextLineHeight() + 6f * scale));
}
// The primary action is the channel pill's shape -- chamfered accent with a
// white depth gradient -- because it is the one thing on the page the user
// is meant to press. Everything secondary is a ghost.
private bool DrawPrimaryPill(string label)
{
var scale = StyleEngine.Metrics.Scale;
var c = Plugin.ThemeRegistry.Active.Colors;
var dl = ImGui.GetWindowDrawList();
var size = new Vector2(ImGui.CalcTextSize(label).X + 36f * scale, ImGui.GetFrameHeight());
var origin = ImGui.GetCursorScreenPos();
var clicked = ImGui.InvisibleButton($"##wiz-primary-{label}", size);
var hovered = ImGui.IsItemHovered();
var amount = StyleEngine.HoverState.Query(ImGui.GetID($"##wiz-primary-{label}"), hovered);
var fill = ColourUtil.RgbaToAbgr(c.Accent);
if (amount > 0f)
fill = ColourUtil.LerpTowardWhite(fill, amount * 0.12f);
dl.DrawSlipPolygon(origin, origin + size, ColourUtil.RgbaToAbgr(fill), 6f * scale);
dl.DrawVerticalGradient(origin, origin + size, 0x28FFFFFFu, 0u);
var ink = ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(c.WindowBg),
ColourUtil.RgbaToAbgr(c.Accent),
4.5f
);
var textSize = ImGui.CalcTextSize(label);
dl.AddText(origin + (size - textSize) * 0.5f, ink, label);
return clicked;
}
private bool DrawGhostLink(string id, string label)
{
var scale = StyleEngine.Metrics.Scale;
var c = Plugin.ThemeRegistry.Active.Colors;
var dl = ImGui.GetWindowDrawList();
var size = new Vector2(ImGui.CalcTextSize(label).X + 16f * scale, ImGui.GetFrameHeight());
var origin = ImGui.GetCursorScreenPos();
var clicked = ImGui.InvisibleButton(id, size);
var hovered = ImGui.IsItemHovered();
var amount = StyleEngine.HoverState.Query(ImGui.GetID(id), hovered);
if (amount > 0f)
dl.AddRectFilled(
origin,
origin + size,
ColourUtil.ApplyAlpha(ColourUtil.RgbaToAbgr(c.SurfaceHover), amount),
3f * scale
);
var tint = ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(c.TextMuted),
ColourUtil.RgbaToAbgr(c.WindowBg),
4.5f
);
if (amount > 0f)
tint = ColourUtil.Lerp(tint, ColourUtil.RgbaToAbgr(c.Accent), amount * 0.6f);
var textSize = ImGui.CalcTextSize(label);
dl.AddText(origin + (size - textSize) * 0.5f, tint, label);
return clicked;
}
private void DrawFooter(bool showBack, bool showSkip, string primaryLabel, Action onPrimary)
{
var spacing = ImGui.GetStyle().ItemSpacing.Y;
@@ -133,14 +256,14 @@ public sealed class FirstRunWizard : Window
if (showBack)
{
if (ImGui.Button(HellionStrings.Wizard_Nav_Back))
if (DrawGhostLink("##wiz-back", HellionStrings.Wizard_Nav_Back))
_state.CurrentStep = Math.Max(1, _state.CurrentStep - 1);
ImGui.SameLine();
}
if (showSkip)
{
if (ImGui.Button(HellionStrings.Wizard_Step1_Skip_Label))
if (DrawGhostLink("##wiz-skip", HellionStrings.Wizard_Step1_Skip_Label))
{
// Skip path = matches today's Cancel path: mark first-run
// complete, save, close. No CommitPending — the user said
@@ -158,13 +281,8 @@ public sealed class FirstRunWizard : Window
if (rightX > ImGui.GetCursorPosX())
ImGui.SameLine(rightX);
using (ImRaii.PushColor(ImGuiCol.Button, ForgeBronze))
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, ForgeBronze))
using (ImRaii.PushColor(ImGuiCol.ButtonActive, ForgeBronze))
{
if (ImGui.Button($"{primaryLabel}##wizard-primary"))
onPrimary();
}
if (DrawPrimaryPill(primaryLabel))
onPrimary();
}
private void DrawStepWelcome()
@@ -306,7 +424,13 @@ public sealed class FirstRunWizard : Window
// packed-colour overload of PushColor for the default branch so we
// can stay in safe code while still matching the current border.
var borderColor = isSelected
? ImGui.GetColorU32(ForgeBronze)
? ColourUtil.RgbaToAbgr(
ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(Plugin.ThemeRegistry.Active.Colors.Accent),
ColourUtil.RgbaToAbgr(Plugin.ThemeRegistry.Active.Colors.ChildBg),
3f
)
)
: ImGui.GetColorU32(ImGuiCol.Border);
using var _border = ImRaii.PushColor(ImGuiCol.Border, borderColor);
@@ -357,8 +481,7 @@ public sealed class FirstRunWizard : Window
ImGui.Spacing();
// History section.
using (ImRaii.PushColor(ImGuiCol.Text, ForgeBronze))
ImGui.TextUnformatted(HellionStrings.Wizard_Step3_Section_History);
DrawHeading(HellionStrings.Wizard_Step3_Section_History);
// One checkbox, not two. LoadPreviousSession was asked for here, shown
// as applied in the summary and written to the config, and no code in
@@ -379,8 +502,7 @@ public sealed class FirstRunWizard : Window
ImGui.Spacing();
// Tell-Tabs section.
using (ImRaii.PushColor(ImGuiCol.Text, ForgeBronze))
ImGui.TextUnformatted(HellionStrings.Wizard_Step3_Section_TellTabs);
DrawHeading(HellionStrings.Wizard_Step3_Section_TellTabs);
var preload =
_state.PendingAutoTellTabsHistoryPreload ?? Plugin.Config.AutoTellTabsHistoryPreload;
@@ -397,8 +519,7 @@ public sealed class FirstRunWizard : Window
ImGui.Spacing();
// Visual section.
using (ImRaii.PushColor(ImGuiCol.Text, ForgeBronze))
ImGui.TextUnformatted(HellionStrings.Wizard_Step3_Section_Visual);
DrawHeading(HellionStrings.Wizard_Step3_Section_Visual);
var compact = _state.PendingUseCompactDensity ?? Plugin.Config.UseCompactDensity;
if (ImGui.Checkbox(HellionStrings.Wizard_Step3_UseCompactDensity_Label, ref compact))
@@ -453,7 +574,7 @@ public sealed class FirstRunWizard : Window
var avail = ImGui.GetContentRegionAvail();
ImGui.Dummy(new Vector2((avail.X - checkSize.X) * 0.5f, 0));
ImGui.SameLine();
using (ImRaii.PushColor(ImGuiCol.Text, ForgeBronze))
using (ImRaii.PushColor(ImGuiCol.Text, AccentVec4()))
ImGui.TextUnformatted(checkmark);
ImGui.Spacing();
@@ -525,7 +646,7 @@ public sealed class FirstRunWizard : Window
// Inline FR-3 hint with placeholder for preload count.
var preloadForHint =
_state.PendingAutoTellTabsHistoryPreload ?? Plugin.Config.AutoTellTabsHistoryPreload;
using (ImRaii.PushColor(ImGuiCol.Text, ForgeBronze))
using (ImRaii.PushColor(ImGuiCol.Text, AccentVec4()))
ImGui.TextWrapped(string.Format(HellionStrings.Wizard_Step4_TestHint, preloadForHint));
ImGui.Spacing();