// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3

#pragma once

#include "colors.h"
#include "type.h"
#include "tooltip_style.h"
#include "../layout.h"
#include "../schema/element_styles.h"
#include "../schema/color_var_resolver.h"
#include "../schema/ui_schema.h"
#include "../effects/theme_effects.h"
#include "../effects/low_spec.h"
#include "../effects/imgui_acrylic.h"
#include "../theme.h"
#include "../../util/noise_texture.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
#include "imgui_internal.h"
#include <algorithm>
#include <unordered_map>
#include <cmath>
#include <string>

namespace dragonx {
namespace ui {
namespace material {

// Scale the alpha channel of an ImU32 color by a float factor.
inline ImU32 ScaleAlpha(ImU32 col, float scale) {
    int a = static_cast<int>(((col >> IM_COL32_A_SHIFT) & 0xFF) * scale);
    if (a > 255) a = 255;
    return (col & ~IM_COL32_A_MASK) | (static_cast<ImU32>(a) << IM_COL32_A_SHIFT);
}

// True when the active theme is light (app background luminance is high).
inline bool IsLightTheme() {
    ImU32 bg = Background();
    float r = ((bg >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
    float g = ((bg >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
    float b = ((bg >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
    return (0.299f * r + 0.587f * g + 0.114f * b) > 0.5f;
}

// Theme error color tuned for use as TEXT. The Material --error reds are muted, so on a dark modal
// backdrop they read low-contrast; lift them toward a legible light red on dark themes. Light themes
// keep the theme error (already a dark red readable on a light background).
inline ImU32 ReadableError() {
    ImU32 e = Error();
    if (IsLightTheme()) return e;
    const float m = 0.35f;  // blend toward white
    int r = (int)(((e >> IM_COL32_R_SHIFT) & 0xFF) * (1 - m) + 255 * m);
    int g = (int)(((e >> IM_COL32_G_SHIFT) & 0xFF) * (1 - m) + 255 * m);
    int b = (int)(((e >> IM_COL32_B_SHIFT) & 0xFF) * (1 - m) + 255 * m);
    return IM_COL32(r, g, b, (e >> IM_COL32_A_SHIFT) & 0xFF);
}

// Middle-ellipsis truncation ("front...back", roughly equal halves) so `text` fits within
// maxWidth pixels when drawn with `font` at `fontSize`. Returns `text` unchanged if it already
// fits (or maxWidth is non-positive). Display-only — never mutate the underlying value with this.
inline std::string TruncateToWidth(const std::string& text, ImFont* font, float fontSize, float maxWidth) {
    if (text.empty() || !font || maxWidth <= 0.0f) return text;
    if (font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, text.c_str()).x <= maxWidth) return text;
    const int n = static_cast<int>(text.size());
    for (int f = n / 2; f >= 3; --f) {
        const int b = (f - 2 > 3) ? (f - 2) : 3;             // keep the two halves roughly equal
        std::string t = text.substr(0, f) + "..." + text.substr(n - b);
        if (font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, t.c_str()).x <= maxWidth) return t;
    }
    return n > 6 ? (text.substr(0, 3) + "..." + text.substr(n - 3)) : text;
}

// Animated "loading" ellipsis: "", ".", "..", "..." cycling on a ~3Hz phase.
inline const char* LoadingDots() {
    int n = ((int)(ImGui::GetTime() * 3.0f)) % 4;
    static const char* kDots[4] = { "", ".", "..", "..." };
    return kDots[n];
}

// ── Centered empty state ─────────────────────────────────────────────────
// A big muted icon + title + optional wrapped hint, centered on BOTH axes within
// GetContentRegionAvail(). Mirrors chat_tab's centeredEmptyState so list-empty states
// read the same across tabs. Call at the start of the region you want it centered in
// (e.g. right after a BeginChild / a leading Dummy). Font metrics use the live font
// scale (LegacySize * FontScaleMain) and PushFont draws at that same scale, so this is
// crisp at HiDPI / font_scale 1.5 without any manual dpiScale multiply on the metrics.
inline void DrawEmptyState(const char* iconGlyph, const char* title, const char* hint = nullptr)
{
    auto scaled = [](ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; };
    const ImVec2 avail  = ImGui::GetContentRegionAvail();
    const ImVec2 origin = ImGui::GetCursorPos();
    ImFont* iconF  = Type().iconXL();
    ImFont* titleF = Type().subtitle1();
    ImFont* hintF  = Type().body2();
    const float dp   = Layout::dpiScale();
    const float gap  = 8.0f * dp;
    const float wrap = std::min(avail.x - 40.0f * dp, 360.0f * dp);
    const float iconSz = iconF ? scaled(iconF) : 40.0f;
    const float iconH  = (iconF && iconGlyph) ? iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, iconGlyph).y : 0.0f;
    const float titleH = titleF->CalcTextSizeA(scaled(titleF), FLT_MAX, 0.0f, title).y;
    const float hintH  = hint ? hintF->CalcTextSizeA(scaled(hintF), wrap, wrap, hint).y : 0.0f;
    const float totalH = iconH + (iconH > 0.0f ? gap : 0.0f) + titleH + (hint ? gap + hintH : 0.0f);
    float y = origin.y + std::max(0.0f, (avail.y - totalH) * 0.5f);

    if (iconF && iconGlyph && iconGlyph[0]) {
        const float iw = iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, iconGlyph).x;
        ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - iw) * 0.5f, y));
        ImGui::PushFont(iconF);
        ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(OnSurface(), 70));
        ImGui::TextUnformatted(iconGlyph);
        ImGui::PopStyleColor();
        ImGui::PopFont();
        y += iconH + gap;
    }
    {
        const float tw = titleF->CalcTextSizeA(scaled(titleF), FLT_MAX, 0.0f, title).x;
        ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - tw) * 0.5f, y));
        ImGui::PushFont(titleF);
        ImGui::PushStyleColor(ImGuiCol_Text, OnSurfaceMedium());
        ImGui::TextUnformatted(title);
        ImGui::PopStyleColor();
        ImGui::PopFont();
        y += titleH + gap;
    }
    if (hint) {
        ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - wrap) * 0.5f, y));
        ImGui::PushFont(hintF);
        ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(OnSurface(), 120));
        ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + wrap);
        ImGui::TextUnformatted(hint);
        ImGui::PopTextWrapPos();
        ImGui::PopStyleColor();
        ImGui::PopFont();
    }
}

// ============================================================================
// Text Drop Shadow
// ============================================================================
// Draw text with a subtle dark shadow behind it for readability on
// translucent / glassy surfaces.  The shadow is a 1px offset copy of
// the text in a near-black colour.  When the OS backdrop (DWM Acrylic)
// is inactive, the shadow is skipped since opaque backgrounds already
// provide enough contrast.

inline void DrawTextShadow(ImDrawList* dl, ImFont* font, float fontSize,
                           const ImVec2& pos, ImU32 col, const char* text,
                           float offsetX = 1.0f, float offsetY = 1.0f,
                           ImU32 shadowCol = 0)
{
    if (IsBackdropActive()) {
        if (!shadowCol) {
            static uint32_t s_gen = 0;
            static ImU32 s_shadowCol = 0;
            uint32_t g = schema::UI().generation();
            if (g != s_gen) { s_gen = g; s_shadowCol = schema::UI().resolveColor("var(--text-shadow)", IM_COL32(0, 0, 0, 120)); }
            shadowCol = s_shadowCol;
        }
        dl->AddText(font, fontSize,
                    ImVec2(pos.x + offsetX, pos.y + offsetY),
                    shadowCol, text);
    }
    dl->AddText(font, fontSize, pos, col, text);
}

// Convenience overload that uses the current default font.
inline void DrawTextShadow(ImDrawList* dl, const ImVec2& pos, ImU32 col,
                           const char* text,
                           float offsetX = 1.0f, float offsetY = 1.0f,
                           ImU32 shadowCol = 0)
{
    if (IsBackdropActive()) {
        if (!shadowCol) {
            static uint32_t s_gen = 0;
            static ImU32 s_shadowCol = 0;
            uint32_t g = schema::UI().generation();
            if (g != s_gen) { s_gen = g; s_shadowCol = schema::UI().resolveColor("var(--text-shadow)", IM_COL32(0, 0, 0, 120)); }
            shadowCol = s_shadowCol;
        }
        dl->AddText(ImVec2(pos.x + offsetX, pos.y + offsetY),
                    shadowCol, text);
    }
    dl->AddText(pos, col, text);
}

// ============================================================================
// Modal-Aware Hover Check
// ============================================================================
// Drop-in replacement for ImGui::IsMouseHoveringRect that also respects
// modal popup blocking.  The raw ImGui helper is a pure geometric test
// and will return true even when a modal popup covers the rect, which
// causes background elements to show hover highlights through dialogs.

inline int& OverlayDialogActiveFrame()
{
    static int s_frame = -1;
    return s_frame;
}

inline void MarkOverlayDialogActive()
{
    OverlayDialogActiveFrame() = ImGui::GetFrameCount();
}

inline bool IsCurrentWindowOverlayDialog()
{
    ImGuiWindow* window = ImGui::GetCurrentWindow();
    for (ImGuiWindow* node = window; node; node = node->ParentWindow) {
        // Prefix match so both "##OverlayScrim" and suffixed "##OverlayScrim_<id>" blur overlays
        // (used by nested dialogs and the unified blur-overlay framework) are recognized.
        if (node->Name && strncmp(node->Name, "##OverlayScrim", 14) == 0)
            return true;
    }
    return false;
}

inline bool IsOverlayDialogBlockingInput()
{
    int activeFrame = OverlayDialogActiveFrame();
    int currentFrame = ImGui::GetFrameCount();
    return activeFrame == currentFrame || activeFrame == (currentFrame - 1);
}

inline bool IsRectHovered(const ImVec2& r_min, const ImVec2& r_max, bool clip = true)
{
    if (!ImGui::IsMouseHoveringRect(r_min, r_max, clip))
        return false;
    if (IsOverlayDialogBlockingInput() && !IsCurrentWindowOverlayDialog())
        return false;
    // If a modal popup is open and it is not the current window, treat
    // the content as non-hoverable (same logic ImGui uses internally
    // inside IsWindowContentHoverable for modal blocking).
    //
    // We cannot rely solely on GetTopMostAndVisiblePopupModal() because
    // it checks Active, which is only set when BeginPopupModal() is
    // called in the current frame.  Content tabs render BEFORE their
    // associated dialogs, so the modal's Active flag is still false at
    // this point.  Checking WasActive (set from the previous frame)
    // covers this render-order gap.
    ImGuiContext& g = *ImGui::GetCurrentContext();
    for (int n = g.OpenPopupStack.Size - 1; n >= 0; n--) {
        ImGuiWindow* popup = g.OpenPopupStack.Data[n].Window;
        if (popup && (popup->Flags & ImGuiWindowFlags_Modal)) {
            if ((popup->Active || popup->WasActive) && !popup->Hidden) {
                if (popup != ImGui::GetCurrentWindow())
                    return false;
            }
        }
    }
    return true;
}

// ============================================================================
// Tactile Button Overlay
// ============================================================================
// Adds a subtle top-edge highlight and bottom-edge shadow to a button rect,
// creating the illusion of a raised physical surface.  On active (pressed),
// the highlight/shadow swap to create an inset "pushed" feel.
// Call this AFTER ImGui::Button() using GetItemRectMin()/GetItemRectMax().

inline void DrawTactileOverlay(ImDrawList* dl, const ImVec2& bMin,
                               const ImVec2& bMax, float rounding,
                               bool active = false)
{
    float h = bMax.y - bMin.y;
    float edgeH = std::min(h * 0.38f, 6.0f);  // highlight/shadow strip height

    // AddRectFilledMultiColor does not support rounding, so clip to the
    // button's rounded rect to prevent sharp corners from poking out.
    if (rounding > 0.0f) {
        float inset = rounding * 0.29f;  // enough to hide corners
        dl->PushClipRect(
            ImVec2(bMin.x + inset, bMin.y + inset),
            ImVec2(bMax.x - inset, bMax.y - inset), true);
    }

    ImU32 tHi = schema::UI().resolveColor("var(--tactile-top)", IM_COL32(255, 255, 255, 18));
    ImU32 tHiT = tHi & ~IM_COL32_A_MASK;  // transparent version

    if (!active) {
        // Raised: bright top edge, dark bottom edge
        // Top highlight
        dl->AddRectFilledMultiColor(
            bMin, ImVec2(bMax.x, bMin.y + edgeH),
            tHi, tHi, tHiT, tHiT);
        // Bottom shadow
        dl->AddRectFilledMultiColor(
            ImVec2(bMin.x, bMax.y - edgeH), bMax,
            IM_COL32(0, 0, 0, 0),          // top-left
            IM_COL32(0, 0, 0, 0),          // top-right
            IM_COL32(0, 0, 0, 22),         // bottom-right
            IM_COL32(0, 0, 0, 22));        // bottom-left
    } else {
        // Pressed: dark top edge, bright bottom edge (inset)
        // Top shadow
        dl->AddRectFilledMultiColor(
            bMin, ImVec2(bMax.x, bMin.y + edgeH),
            IM_COL32(0, 0, 0, 20),
            IM_COL32(0, 0, 0, 20),
            IM_COL32(0, 0, 0, 0),
            IM_COL32(0, 0, 0, 0));
        // Bottom highlight (dimmer when pressed)
        ImU32 pHi = ScaleAlpha(tHi, 0.55f);
        ImU32 pHiT = pHi & ~IM_COL32_A_MASK;
        dl->AddRectFilledMultiColor(
            ImVec2(bMin.x, bMax.y - edgeH), bMax,
            pHiT, pHiT, pHi, pHi);
    }

    if (rounding > 0.0f)
        dl->PopClipRect();
}

// Glass-card button surface: frosted-glass hover/idle fill (skipped while
// pressed) + rim light + tactile depth overlay.  Shared by the tactile
// button wrappers.  Call after computing the button rect / rounding /
// active / hovered state.
inline void DrawButtonGlassOverlay(ImDrawList* dl, const ImVec2& bMin,
                                   const ImVec2& bMax, float rounding,
                                   bool active, bool hovered)
{
    // Light themes: flat buttons (like the Manage-portfolio modal's plain buttons). Skip the glass
    // sheen + tactile 3D — ImGui::Button already draws the fill/hover/active — and add just a subtle
    // defining edge, which reads cleaner on light surfaces than the raised glass look.
    if (IsLightTheme()) {
        ImU32 rim = schema::UI().resolveColor("var(--rim-light)", IM_COL32(0, 0, 0, 50));
        dl->AddRect(bMin, bMax, active ? ScaleAlpha(rim, 0.6f) : rim, rounding, 0, 1.0f);
        return;
    }
    // Frosted glass highlight — subtle fill on top
    if (!active) {
        ImU32 col = hovered
            ? schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 12))
            : schema::UI().resolveColor("var(--glass-fill)", IM_COL32(255, 255, 255, 6));
        dl->AddRectFilled(bMin, bMax, col, rounding);
    }
    // Rim light
    ImU32 rim = schema::UI().resolveColor("var(--rim-light)", IM_COL32(255, 255, 255, 25));
    dl->AddRect(bMin, bMax,
                active ? ScaleAlpha(rim, 0.6f) : rim,
                rounding, 0, 1.0f);
    // Tactile depth
    DrawTactileOverlay(dl, bMin, bMax, rounding, active);
}

// ── Button font tier helper ─────────────────────────────────────────────
// Resolves an int tier (0=sm, 1=md/default, 2=lg) to the matching ImFont*.
// Passing -1 or any out-of-range value returns the default button font.

inline ImFont* resolveButtonFont(int tier)
{
    switch (tier) {
        case 0: return Type().buttonSm();
        case 2: return Type().buttonLg();
        default: return Type().button();   // 1 or any other value
    }
}

// Resolve per-button font: if perButton >= 0 use it, else fall back to sectionDefault
inline ImFont* resolveButtonFont(int perButton, int sectionDefault)
{
    return resolveButtonFont(perButton >= 0 ? perButton : sectionDefault);
}

// ── Progress / fill bar ─────────────────────────────────────────────────
// A rounded track with a fraction (0..1) fill, drawn at the current cursor and advancing the layout
// cursor past it (matches the download-dialog usage). For a placement you control, use the pMin/pMax
// overload. Defaults: theme Primary fill, a subtle white track, 4dp corners.
inline void DrawProgressBar(ImDrawList* dl, const ImVec2& pMin, const ImVec2& pMax, float frac01,
                            ImU32 fill = 0, ImU32 track = IM_COL32(255, 255, 255, 30), float rounding = -1.0f)
{
    if (fill == 0) fill = Primary();
    if (rounding < 0.0f) rounding = 4.0f * Layout::dpiScale();
    frac01 = frac01 < 0.0f ? 0.0f : (frac01 > 1.0f ? 1.0f : frac01);
    dl->AddRectFilled(pMin, pMax, track, rounding);
    if (frac01 > 0.0f)
        dl->AddRectFilled(pMin, ImVec2(pMin.x + (pMax.x - pMin.x) * frac01, pMax.y), fill, rounding);
}

inline void ProgressBar(float width, float height, float frac01,
                        ImU32 fill = 0, ImU32 track = IM_COL32(255, 255, 255, 30))
{
    ImVec2 pMin = ImGui::GetCursorScreenPos();
    DrawProgressBar(ImGui::GetWindowDrawList(), pMin, ImVec2(pMin.x + width, pMin.y + height), frac01, fill, track);
    ImGui::Dummy(ImVec2(0, height));
}

// ── Click-to-copy affordance ────────────────────────────────────────────
// Turns an already-drawn text run into a click-to-copy control. The caller draws the visible text
// (font/color/position of its choosing); this places an invisible hit button (textSize + hitPad) at
// `pos`, shows a hand cursor + tooltip + a hover underline, and copies `copyValue` on click. Returns
// true the frame it was clicked (already on the clipboard) so the caller can show its own toast.
// Caller manages any cursor save/restore around the call.
inline bool CopyableTextOverlay(const char* id, const ImVec2& pos, const ImVec2& textSize, float fontSize,
                                const char* copyValue, const char* tooltip, const ImVec2& hitPad)
{
    const float dp = Layout::dpiScale();
    ImGui::SetCursorScreenPos(pos);
    ImGui::InvisibleButton(id, ImVec2(textSize.x + hitPad.x, fontSize + hitPad.y));
    if (ImGui::IsItemHovered()) {
        ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
        if (tooltip && tooltip[0]) Tooltip("%s", tooltip);
        const float uy = pos.y + fontSize + 1.0f * dp;
        ImGui::GetWindowDrawList()->AddLine(ImVec2(pos.x, uy), ImVec2(pos.x + textSize.x, uy),
                                            WithAlpha(OnSurface(), 60), 1.0f * dp);
    }
    bool copied = false;
    if (ImGui::IsItemClicked()) {
        ImGui::SetClipboardText(copyValue);
        copied = true;
    }
    return copied;
}

// ── Pill / badge ─────────────────────────────────────────────────────────
// A rounded chip: a filled background sized to `text` + `pad`, an optional 1px border, and the text
// inset by `pad`. Use PillSize first when you need to right/center-anchor (compute the width, place
// the pill, then draw).
inline ImVec2 PillSize(const char* text, ImFont* font, const ImVec2& pad)
{
    ImVec2 t = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0.0f, text);
    return ImVec2(t.x + pad.x * 2.0f, font->LegacySize + pad.y * 2.0f);
}

// Draw a pill/badge anchored at top-left `pos`. `border` == 0 draws no outline. rounding < 0 => fully
// rounded (height/2). Returns the pill's total size.
inline ImVec2 DrawPill(ImDrawList* dl, const ImVec2& pos, const char* text, ImFont* font,
                       ImU32 fg, ImU32 bg, ImU32 border = 0,
                       const ImVec2& pad = ImVec2(4.0f, 2.0f), float rounding = -1.0f)
{
    ImVec2 sz = PillSize(text, font, pad);
    if (rounding < 0.0f) rounding = sz.y * 0.5f;
    ImVec2 pMax(pos.x + sz.x, pos.y + sz.y);
    dl->AddRectFilled(pos, pMax, bg, rounding);
    if (border != 0) dl->AddRect(pos, pMax, border, rounding, 0, 1.0f * Layout::dpiScale());
    dl->AddText(font, font->LegacySize, ImVec2(pos.x + pad.x, pos.y + pad.y), fg, text);
    return sz;
}

// ── Labeled form input ───────────────────────────────────────────────────
// The app's common form-field idiom: a plain label above a fixed-width single-line input
// (ImGui::Text label + SetNextItemWidth + InputText / InputTextWithHint). Mirrors the existing
// pattern exactly (no visual change) so input styling can later evolve in ONE place. `width` < 0
// means full width (-1). Pass a non-null `hint` to use InputTextWithHint. Returns the input's
// edited bool. The label is drawn via "%s" so a stray '%' in the text is safe.
inline bool LabeledInput(const char* label, const char* id, char* buf, size_t bufSize,
                         float width = -1.0f, const char* hint = nullptr,
                         ImGuiInputTextFlags flags = 0)
{
    ImGui::Text("%s", label);
    ImGui::SetNextItemWidth(width);
    if (hint && hint[0]) return ImGui::InputTextWithHint(id, hint, buf, bufSize, flags);
    return ImGui::InputText(id, buf, bufSize, flags);
}

// Horizontal variant: label on the left, input to its right on the same line (settings-style rows).
// `labelPos` is the SameLine offset the input starts at (0 = default item spacing after the label).
inline bool LabeledInputRow(const char* label, float labelPos, const char* id, char* buf, size_t bufSize,
                            float width = -1.0f, const char* hint = nullptr, ImGuiInputTextFlags flags = 0)
{
    ImGui::Text("%s", label);
    ImGui::SameLine(labelPos);
    ImGui::SetNextItemWidth(width);
    if (hint && hint[0]) return ImGui::InputTextWithHint(id, hint, buf, bufSize, flags);
    return ImGui::InputText(id, buf, bufSize, flags);
}

// Multiline variant: label above a multiline input of the given `size` (InputTextMultiline governs its
// own width via size, so no SetNextItemWidth). Returns the input's edited bool.
inline bool LabeledInputMultiline(const char* label, const char* id, char* buf, size_t bufSize,
                                  const ImVec2& size, ImGuiInputTextFlags flags = 0)
{
    ImGui::Text("%s", label);
    return ImGui::InputTextMultiline(id, buf, bufSize, size, flags);
}

// ── Frameless icon button ────────────────────────────────────────────────
// Style for IconButton. All colors default to 0 (= unset): color falls back to OnSurfaceMedium();
// hoverColor==0 means "no hover recolor"; hoverBg/restBg==0 mean "no background".
struct IconButtonStyle {
    ImU32 color = 0;          // resting glyph color (0 => OnSurfaceMedium())
    ImU32 hoverColor = 0;     // glyph color while hovered (0 => same as color)
    ImU32 hoverBg = 0;        // fill drawn behind the glyph on hover only (0 => none)
    ImU32 restBg = 0;         // fill drawn every frame, e.g. an "active" pill (0 => none)
    float bgRounding = -1.0f; // <0 => size.y*0.5 (pill/circle for a square); else that radius
    bool  handCursor = true;
    const char* tooltip = nullptr;
};

// A frameless / pill icon button: an InvisibleButton(id, size) at the current cursor with a centered
// `glyph` (in `font`), optional resting + hover backgrounds, hover recolor, hand cursor + tooltip.
// Returns true the frame it is clicked. The caller resolves any state-dependent glyph/colors/tooltip
// before the call and positions the cursor (SetCursorScreenPos / SameLine) as needed.
inline bool IconButton(const char* id, const char* glyph, ImFont* font, const ImVec2& size,
                       const IconButtonStyle& st)
{
    const ImVec2 pos = ImGui::GetCursorScreenPos();
    ImGui::InvisibleButton(id, size);
    const bool hov = ImGui::IsItemHovered();
    const bool clk = ImGui::IsItemClicked();
    ImDrawList* dl = ImGui::GetWindowDrawList();
    const ImVec2 pMax(pos.x + size.x, pos.y + size.y);
    const float round = st.bgRounding < 0.0f ? size.y * 0.5f : st.bgRounding;
    if (st.restBg != 0)         dl->AddRectFilled(pos, pMax, st.restBg, round);
    if (hov && st.hoverBg != 0) dl->AddRectFilled(pos, pMax, st.hoverBg, round);
    if (hov) {
        if (st.handCursor) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
        if (st.tooltip && st.tooltip[0]) Tooltip("%s", st.tooltip);
    }
    const ImU32 gcol = (hov && st.hoverColor != 0) ? st.hoverColor
                     : (st.color != 0 ? st.color : OnSurfaceMedium());
    const ImVec2 gsz = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0, glyph);
    dl->AddText(font, font->LegacySize,
                ImVec2(pos.x + (size.x - gsz.x) * 0.5f, pos.y + (size.y - gsz.y) * 0.5f), gcol, glyph);
    return clk;
}

// ── Tactile wrappers ────────────────────────────────────────────────────
// Drop-in replacements for ImGui::Button / SmallButton that automatically
// add the tactile highlight overlay after rendering.

inline bool TactileButton(const char* label, const ImVec2& size = ImVec2(0, 0), ImFont* font = nullptr)
{
    // Draw button with glass-card styling: translucent fill + border + tactile overlay
    ImDrawList* dl = ImGui::GetWindowDrawList();
    ImFont* useFont = font ? font : Type().button();
    
    // For icon fonts, use InvisibleButton + manual centered text rendering
    // to ensure perfect centering (ImGui::Button alignment can be off for icons)
    bool isIconFont = font && (font == Type().iconSmall() || font == Type().iconMed() || 
                               font == Type().iconLarge() || font == Type().iconXL());
    
    bool pressed;
    if (isIconFont && size.x > 0 && size.y > 0) {
        pressed = ImGui::InvisibleButton(label, size);
    } else {
        ImGui::PushFont(useFont);
        pressed = ImGui::Button(label, size);
        ImGui::PopFont();
    }
    // Glass overlay on the button rect
    ImVec2 bMin = ImGui::GetItemRectMin();
    ImVec2 bMax = ImGui::GetItemRectMax();
    
    // For icon fonts, manually draw centered icon after getting button rect. Measure/draw only the
    // VISIBLE label (up to the "##id" separator): CalcTextSizeA/AddText don't strip "##" the way
    // ImGui's own text render does, so an id suffix like "##pickContact" would inflate textSz and
    // shove the glyph left off-center (and try to draw the notdef id chars).
    if (isIconFont && size.x > 0 && size.y > 0) {
        const char* labelEnd = label;
        while (*labelEnd && !(labelEnd[0] == '#' && labelEnd[1] == '#')) ++labelEnd;
        ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label, labelEnd);
        ImVec2 textPos(bMin.x + (size.x - textSz.x) * 0.5f, bMin.y + (size.y - textSz.y) * 0.5f);
        dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label, labelEnd);
    }
    
    float rounding = ImGui::GetStyle().FrameRounding;
    bool active = ImGui::IsItemActive();
    bool hovered = ImGui::IsItemHovered();
    DrawButtonGlassOverlay(dl, bMin, bMax, rounding, active, hovered);
    return pressed;
}

inline bool TactileSmallButton(const char* label, ImFont* font = nullptr)
{
    ImDrawList* dl = ImGui::GetWindowDrawList();
    ImGui::PushFont(font ? font : Type().button());
    bool pressed = ImGui::SmallButton(label);
    ImGui::PopFont();
    ImVec2 bMin = ImGui::GetItemRectMin();
    ImVec2 bMax = ImGui::GetItemRectMax();
    float rounding = ImGui::GetStyle().FrameRounding;
    bool active = ImGui::IsItemActive();
    bool hovered = ImGui::IsItemHovered();
    DrawButtonGlassOverlay(dl, bMin, bMax, rounding, active, hovered);
    return pressed;
}

// ============================================================================
// Collapsible section header
// ============================================================================
// Renders a full-width transparent clickable header row: `label` drawn on the
// left and an expand/collapse chevron on the right.  Toggles `expanded` when
// clicked and returns the (possibly toggled) state.  The label font governs
// the row height (frame height) and text metrics; the chevron uses the small
// icon font.  `arrowInset` shifts the chevron left of the right edge.
inline bool CollapsibleHeader(ImDrawList* dl, const char* id, const char* label,
                              bool& expanded, float width, ImFont* labelFont,
                              ImU32 labelCol, float arrowInset = 0.0f)
{
    const char* arrow = expanded ? ICON_MD_EXPAND_LESS : ICON_MD_EXPAND_MORE;
    ImGui::PushFont(labelFont);
    ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0,0,0,0));
    ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(1,1,1,0.05f));
    ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1,1,1,0.08f));
    {
        ImVec2 hdrPos = ImGui::GetCursorScreenPos();
        if (ImGui::Button(id, ImVec2(width, ImGui::GetFrameHeight()))) {
            expanded = !expanded;
        }
        float textY = hdrPos.y + (ImGui::GetFrameHeight() - labelFont->LegacySize) * 0.5f;
        dl->AddText(labelFont, labelFont->LegacySize, ImVec2(hdrPos.x, textY), labelCol, label);
        ImFont* iconFont = Type().iconSmall();
        if (!iconFont) iconFont = labelFont;
        float arrowW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, arrow).x;
        dl->AddText(iconFont, iconFont->LegacySize, ImVec2(hdrPos.x + width - arrowW - arrowInset, textY), labelCol, arrow);
    }
    ImGui::PopStyleColor(3);
    ImGui::PopFont();
    return expanded;
}

// ============================================================================
// Glass Panel  (glassmorphism card)
// ============================================================================
// Draws a frosted-glass style panel: a semi-transparent fill with a
// subtle light border.  Used for card/panel containers that sit above
// the blurred background.  When the backdrop is inactive the fill is
// simply the normal surface colour (fully opaque).

struct GlassPanelSpec {
    float rounding      = 6.0f;
    int   fillAlpha     = 18;        // fill brightness (white, 0-255)
    int   borderAlpha   = 30;        // border brightness (white, 0-255)
    float borderWidth   = 1.0f;
};

// When a full-window blur overlay (e.g. the portfolio modal) is active, glass panels use their
// opaque fallback instead of the acrylic blur. They're covered by the overlay's backdrop anyway, and
// this leaves the backdrop as the SOLE applyBlur caller — so it can use its own (stronger) blur
// radius without thrashing the shared, radius-keyed blur cache. Toggled from App::render().
inline bool& FullWindowBlurOverlayActiveRef() { static bool v = false; return v; }
inline void SetFullWindowBlurOverlayActive(bool v) { FullWindowBlurOverlayActiveRef() = v; }
inline bool IsFullWindowBlurOverlayActive() { return FullWindowBlurOverlayActiveRef(); }

// Light-surface themes (Light / Marble) want cards to lift off the pale background with a subtle
// drop shadow. Cached per theme-generation so the string compare runs once per theme load, not per
// panel. (Data-driven later if more themes want it.)
inline bool CardDropShadowWanted()
{
    static uint32_t s_gen = ~0u;
    static bool     s_want = false;
    uint32_t g = schema::UI().generation();
    if (g != s_gen) {
        s_gen = g;
        const std::string& tn = schema::UI().themeName();
        s_want = (tn == "Light" || tn == "Marble");
    }
    return s_want;
}

// Soft, UNIFORM shadow around a rounded rect — no directional offset, equal on all sides. Drawn as
// fading rounded-rect STROKES (not fills), so it never paints across the card body — only the outer
// blur remains once the card fill covers the interior. The "render then mask out the UI area" result
// without an FBO.
inline void DrawCardDropShadow(ImDrawList* dl, const ImVec2& pMin, const ImVec2& pMax, float rounding)
{
    const float dp = Layout::dpiScale();
    // Matches the design mockup's `box-shadow: 0 0 4px rgb(50 53 58 / 26%)`: a tight, darker, colored
    // uniform shadow. Sampled as a stack of rounded-rect strokes stepping OUTWARD from the card edge
    // with a Gaussian alpha falloff (darkest at the edge, ~0 by the tail). ~1px-spaced, near-1px-thick
    // rings keep overlap (and thus alpha accumulation) low, so the peak reads close to the target.
    const float blur  = 4.0f * dp;    // CSS-like blur radius
    const float reach = blur * 1.5f;  // how far the shadow extends beyond the card edge (all sides)
    const float sigma = blur * 0.6f;
    const float peak  = 34.0f;        // edge alpha for rgb(50,53,58) (~13% after the card fill masks the inner half)
    const int   steps = std::max(6, (int)(reach / dp + 0.5f));

    // The shadow rides on the card's OWN draw list (so it keeps the right z-order in EVERY context —
    // main content, modals, foreground popups, notifications; a global background draw list would drop
    // a modal/popup card's shadow behind its scrim). But cards often live inside clipping child windows
    // whose clip rect chops the shadow wherever a card sits flush with the child's top/bottom/side edge.
    // Clip instead to the shadow's own bounding box (card ± reach), so all four sides show in full —
    // bounded on each side to at most `reach` beyond the child clip (and the viewport) so a card scrolled
    // partly out of a scroll area still can't smear its shadow more than the halo width into neighbours.
    ImGuiViewport* vp = ImGui::GetMainViewport();
    const ImVec2 cur0 = dl->GetClipRectMin();
    const ImVec2 cur1 = dl->GetClipRectMax();
    const float x0 = std::max(vp->Pos.x,              std::max(pMin.x - reach, cur0.x - reach));
    const float y0 = std::max(vp->Pos.y,              std::max(pMin.y - reach, cur0.y - reach));
    const float x1 = std::min(vp->Pos.x + vp->Size.x, std::min(pMax.x + reach, cur1.x + reach));
    const float y1 = std::min(vp->Pos.y + vp->Size.y, std::min(pMax.y + reach, cur1.y + reach));
    dl->PushClipRect(ImVec2(x0, y0), ImVec2(x1, y1), false);
    for (int i = 0; i < steps; ++i) {
        float d = reach * (float)i / (float)(steps - 1);   // 0 (card edge) .. reach (outer tail)
        float g = std::exp(-(d * d) / (2.0f * sigma * sigma));
        int   a = (int)(peak * g + 0.5f);
        if (a < 1) continue;
        ImVec2 smn(pMin.x - d, pMin.y - d);
        ImVec2 smx(pMax.x + d, pMax.y + d);
        dl->AddRect(smn, smx, IM_COL32(50, 53, 58, a), rounding + d, 0, 1.3f * dp);
    }
    dl->PopClipRect();
}

inline void DrawGlassPanel(ImDrawList* dl, const ImVec2& pMin,
                           const ImVec2& pMax,
                           const GlassPanelSpec& spec = GlassPanelSpec())
{
    // Subtle drop shadow behind the card (light themes only). Drawn first so the card fill below
    // covers the interior, leaving only the outer soft blur — the card body is never contaminated.
    if (CardDropShadowWanted())
        DrawCardDropShadow(dl, pMin, pMax, spec.rounding);

    if (IsBackdropActive() && !dragonx::ui::effects::isLowSpecMode() && !IsFullWindowBlurOverlayActive()) {
        // --- Cached color lookups (invalidated on theme change) ---
        // These 3 resolveColor() calls do string parsing + map lookup
        // each time.  Cache them per-frame using the schema generation
        // counter so they resolve at most once per theme load.
        static uint32_t s_gen = 0;
        static ImU32 s_glassFill = 0;
        static ImU32 s_glassNoiseTint = 0;
        static ImU32 s_glassBorder = 0;
        uint32_t curGen = schema::UI().generation();
        if (curGen != s_gen) {
            s_gen = curGen;
            s_glassFill      = schema::UI().resolveColor("var(--glass-fill)", IM_COL32(255, 255, 255, 18));
            s_glassNoiseTint = schema::UI().resolveColor("var(--glass-noise-tint)", IM_COL32(255, 255, 255, 10));
            s_glassBorder    = schema::UI().resolveColor("var(--glass-border)", IM_COL32(255, 255, 255, 30));
        }

        float uiOp = effects::ImGuiAcrylic::GetUIOpacity();
        bool useAcrylic = effects::ImGuiAcrylic::IsEnabled()
                       && effects::ImGuiAcrylic::IsAvailable();

        // Glass / acrylic layer — only rendered when not fully opaque
        // (skip the blur pass at 100% for performance)
        if (uiOp < 0.99f) {
            if (useAcrylic) {
                const auto& acrylicTheme = GetCurrentAcrylicTheme();
                effects::ImGuiAcrylic::DrawAcrylicRect(dl, pMin, pMax,
                                                       acrylicTheme.card, spec.rounding);
            } else {
                // Lightweight fake-glass: translucent fill
                ImU32 fill = (spec.fillAlpha == 18) ? s_glassFill : ScaleAlpha(s_glassFill, spec.fillAlpha / 18.0f);
                dl->AddRectFilled(pMin, pMax, fill, spec.rounding);
            }
        }

        // Surface overlay — provides smooth transition from glass to opaque.
        // At uiOp=1.0 this fully covers the panel (opaque card).
        // As uiOp decreases, glass/blur progressively shows through.
        dl->AddRectFilled(pMin, pMax,
            WithAlphaF(GetElevatedSurface(GetCurrentColorTheme(), 1), uiOp),
            spec.rounding);

        // Noise grain overlay — drawn OVER the surface overlay so card
        // opacity doesn't hide it.  Gives cards a tactile paper feel.
        // Noise tint is cached per-generation, opacity checked each panel.
        {
            float noiseMul = dragonx::ui::effects::ImGuiAcrylic::GetNoiseOpacity();
            if (noiseMul > 0.0f) {
                // Tint base color changes only on theme reload; opacity slider may change per-frame
                static uint32_t s_noiseGen = 0;
                static uint8_t  s_baseAlpha = 0;
                if (curGen != s_noiseGen) {
                    s_noiseGen = curGen;
                    s_baseAlpha = (s_glassNoiseTint >> IM_COL32_A_SHIFT) & 0xFF;
                }
                uint8_t scaledAlpha = static_cast<uint8_t>(std::min(255.0f, s_baseAlpha * noiseMul));
                ImU32 noiseTint = (s_glassNoiseTint & ~(0xFFu << IM_COL32_A_SHIFT)) | (scaledAlpha << IM_COL32_A_SHIFT);
                float inset = spec.rounding * 0.3f;
                ImVec2 noiseMin(pMin.x + inset, pMin.y + inset);
                ImVec2 noiseMax(pMax.x - inset, pMax.y - inset);
                // Image rect matches clip bounds exactly — no PushClipRect needed
                dragonx::util::DrawTiledNoiseRect(dl, noiseMin, noiseMax, noiseTint);
            }
        }

        // Border — fades with UI opacity
        ImU32 border = (spec.borderAlpha == 30) ? s_glassBorder : ScaleAlpha(s_glassBorder, spec.borderAlpha / 30.0f);
        if (uiOp < 0.99f) border = ScaleAlpha(border, uiOp);
        dl->AddRect(pMin, pMax, border,
                    spec.rounding, 0, spec.borderWidth);

        // Theme visual effects drawn on ForegroundDrawList so they
        // render above card content (text, values, etc.), not below.
        auto& fx = effects::ThemeEffects::instance();
        if (fx.hasAnyPanelEffect()) {
            ImDrawList* fxDl = ImGui::GetForegroundDrawList();
            if (fx.hasRainbowBorder()) {
                fx.drawRainbowBorder(fxDl, pMin, pMax, spec.rounding, spec.borderWidth);
            }
            if (fx.hasShimmer()) {
                fx.drawShimmer(fxDl, pMin, pMax, spec.rounding);
            }
            if (fx.hasSpecularGlare()) {
                fx.drawSpecularGlare(fxDl, pMin, pMax, spec.rounding);
            }
            // Per-panel theme effects: edge trace + ember rise
            fx.drawPanelEffects(fxDl, pMin, pMax, spec.rounding);
        }
    } else {
        // Low-spec opaque fallback
        dl->AddRectFilled(pMin, pMax,
            GetElevatedSurface(GetCurrentColorTheme(), 1), spec.rounding);
    }
}

// Full-window backdrop for a modal overlay. Lays an OPAQUE base first (so the sheet reads as
// opaque regardless of the UI-opacity slider — the acrylic path multiplies its alpha by
// GetUIOpacity(), so it can't guarantee opacity on its own), then — only when the acrylic backdrop
// is active and we are NOT in low-performance mode — draws a strong blur of the (static) window
// backdrop on top plus a dim. In low-perf mode only the opaque base is drawn (no blur cost),
// mirroring DrawGlassPanel's gating.
// `allowBlur=false` draws only the opaque base (used on the overlay's first frame, before the live
// content has been captured, to avoid a one-frame blur of stale/background pixels).
inline void DrawFullWindowBlurBackdrop(ImDrawList* dl, const ImVec2& pMin, const ImVec2& pMax,
                                       bool allowBlur = true)
{
    // Theme-aware tones: a light theme gets a light frosted backdrop, a dark theme a dark one — the
    // backdrop tints toward the app background rather than a hardcoded dark.
    dl->AddRectFilled(pMin, pMax, WithAlpha(Background(), 255));  // opaque base (fallback / faint-blur show-through)
    if (allowBlur && IsBackdropActive() && !dragonx::ui::effects::isLowSpecMode()
        && effects::ImGuiAcrylic::IsEnabled() && effects::ImGuiAcrylic::IsAvailable()) {
        // Safe to use a strong custom radius here: while a full-window overlay is active every glass
        // panel skips acrylic (IsFullWindowBlurOverlayActive), so the backdrop is the SOLE applyBlur
        // caller — no other radius contends for the shared, radius-keyed blur cache.
        auto params = GetCurrentAcrylicTheme().card;
        params.blurRadius = 120.0f;                            // strong full-window blur (deeper than panels)
        params.absoluteBlurRadius = true;                     // fixed modal blur — independent of the user's acrylic slider
        params.fallbackColor.w = 1.0f;                        // full-strength blur so the live content reads
        effects::ImGuiAcrylic::DrawAcrylicRect(dl, pMin, pMax, params, 0.0f);
        // Veil over the blur so the floating (card-less) modal reads as foreground. On DARK themes a
        // theme-tinted frost darkens the backdrop and the light content pops. On LIGHT themes the modal's
        // own controls carry a dropshadow for figure/ground, so the backdrop stays LIGHT — a theme-tinted
        // light frost — rather than a dark veil (which made a "light" theme read dark and sank the
        // dark-on-light chrome into it).
        if (IsLightTheme())
            dl->AddRectFilled(pMin, pMax, WithAlpha(Background(), 205));  // light frost — dropshadows give shape
        else
            dl->AddRectFilled(pMin, pMax, WithAlpha(Background(), 120));  // theme-tinted darken, stronger
    }
}

// ============================================================================
// Glass Card Scope (RAII) — channel-split glass-card scaffold
// ============================================================================
// Captures the current cursor as the card's top-left, splits the draw list
// into two channels (content on 1, glass on 0), insets the content by `pad`,
// and — on scope exit — appends the bottom pad, sizes the card to its content,
// draws the glass panel behind it on channel 0, merges, and advances the
// cursor past the card. `width` is the full card width, `bottomPad` the
// trailing pad. Reproduces the hand-written prologue/epilogue exactly.
//
//   {
//       material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec);
//       ... card content ...
//   }
struct GlassCardScope {
    ImDrawList*    dl;
    ImVec2         cardMin;
    float          width;
    float          pad;
    float          bottomPad;
    GlassPanelSpec spec;

    GlassCardScope(ImDrawList* dl_, float width_, float pad_, float bottomPad_,
                   const GlassPanelSpec& spec_)
        : dl(dl_), width(width_), pad(pad_), bottomPad(bottomPad_), spec(spec_)
    {
        cardMin = ImGui::GetCursorScreenPos();
        dl->ChannelsSplit(2);
        dl->ChannelsSetCurrent(1);
        ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pad));
        ImGui::Indent(pad);
    }

    ~GlassCardScope()
    {
        ImGui::Dummy(ImVec2(0, bottomPad));
        ImGui::Unindent(pad);

        ImVec2 cardMax(cardMin.x + width, ImGui::GetCursorScreenPos().y);
        dl->ChannelsSetCurrent(0);
        DrawGlassPanel(dl, cardMin, cardMax, spec);
        dl->ChannelsMerge();

        ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
        ImGui::Dummy(ImVec2(width, 0));
    }

    GlassCardScope(const GlassCardScope&) = delete;
    GlassCardScope& operator=(const GlassCardScope&) = delete;
};

// ============================================================================
// Stat Card — reusable card with overline / value / subtitle + accent stripe
// ============================================================================

struct StatCardSpec {
    const char* overline    = nullptr;   // small label at top  (e.g. "LOCAL HASHRATE")
    const char* value       = nullptr;   // main value text     (e.g. "1.23 kH/s")
    const char* subtitle    = nullptr;   // optional small line (e.g. "3 blocks")
    ImU32       valueCol    = 0;         // value text colour (0 = OnSurface)
    ImU32       accentCol   = 0;         // left-stripe colour (0 = no stripe)
    bool        centered    = true;      // centre text horizontally
    bool        hovered     = false;     // draw hover glow border
};

/// Compute a generous stat-card height with breathing room.
inline float StatCardHeight(float vs, float minH = 56.0f) {
    float padV   = Layout::spacingLg() * 2;  // top + bottom
    float content = Type().overline()->LegacySize
                  + Layout::spacingMd()
                  + Type().subtitle1()->LegacySize;
    return std::max(minH, (content + padV) * std::max(vs, 0.85f));
}

inline void DrawStatCard(ImDrawList* dl,
                         const ImVec2& cMin, const ImVec2& cMax,
                         const StatCardSpec& card,
                         const GlassPanelSpec& glass = GlassPanelSpec())
{
    float cardW = cMax.x - cMin.x;
    float cardH = cMax.y - cMin.y;
    float rnd   = glass.rounding;

    // 1. Glass background
    DrawGlassPanel(dl, cMin, cMax, glass);

    // 2. Accent stripe (left edge, clipped to card rounded corners).
    //    Draw a full-height rounded rect with card rounding (left corners)
    //    and clip to stripe width so the shape follows the corner radius.
    if ((card.accentCol & IM_COL32_A_MASK) != 0) {
        float stripeW = 4.0f * Layout::dpiScale();
        dl->PushClipRect(cMin, ImVec2(cMin.x + stripeW, cMax.y), true);
        dl->AddRectFilled(cMin, cMax, card.accentCol, rnd,
                          ImDrawFlags_RoundCornersLeft);
        dl->PopClipRect();
    }

    // 3. Compute content block height
    ImFont* ovFont  = Type().overline();
    ImFont* valFont = Type().subtitle1();
    ImFont* subFont = Type().caption();

    float blockH = 0;
    if (card.overline) blockH += ovFont->LegacySize;
    if (card.overline && card.value) blockH += Layout::spacingMd();
    if (card.value)   blockH += valFont->LegacySize;
    if (card.subtitle) blockH += Layout::spacingSm() + subFont->LegacySize;

    // 4. Vertically centre
    float topY = cMin.y + (cardH - blockH) * 0.5f;
    float padX = Layout::spacingLg();
    float cy = topY;

    // 5. Overline
    if (card.overline) {
        ImVec2 sz = ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, card.overline);
        float tx = card.centered ? cMin.x + (cardW - sz.x) * 0.5f : cMin.x + padX;
        dl->AddText(ovFont, ovFont->LegacySize, ImVec2(tx, cy), OnSurfaceMedium(), card.overline);
        cy += ovFont->LegacySize + Layout::spacingMd();
    }

    // 6. Value (with text shadow)
    if (card.value) {
        ImU32 col = card.valueCol ? card.valueCol : OnSurface();
        ImVec2 sz = valFont->CalcTextSizeA(valFont->LegacySize, 10000, 0, card.value);
        float tx = card.centered ? cMin.x + (cardW - sz.x) * 0.5f : cMin.x + padX;
        DrawTextShadow(dl, valFont, valFont->LegacySize, ImVec2(tx, cy), col, card.value);
        cy += valFont->LegacySize;
    }

    // 7. Subtitle
    if (card.subtitle) {
        cy += Layout::spacingSm();
        ImVec2 sz = subFont->CalcTextSizeA(subFont->LegacySize, 10000, 0, card.subtitle);
        float tx = card.centered ? cMin.x + (cardW - sz.x) * 0.5f : cMin.x + padX;
        dl->AddText(subFont, subFont->LegacySize, ImVec2(tx, cy), OnSurfaceDisabled(), card.subtitle);
    }

    // 8. Hover glow
    if (card.hovered) {
        dl->AddRect(cMin, cMax,
                    schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 20)),
                    rnd, 0, 1.5f);
    }
}

// ── Styled Button (font-only wrapper) ───────────────────────────────────
// Drop-in replacement for ImGui::Button that pushes the Button font style
// (from JSON config) without adding tactile visual effects.

inline bool StyledButton(const char* label, const ImVec2& size = ImVec2(0, 0), ImFont* font = nullptr)
{
    ImGui::PushFont(font ? font : Type().button());
    bool pressed = ImGui::Button(label, size);
    ImGui::PopFont();
    return pressed;
}

inline bool StyledSmallButton(const char* label, ImFont* font = nullptr)
{
    ImGui::PushFont(font ? font : Type().button());
    bool pressed = ImGui::SmallButton(label);
    ImGui::PopFont();
    return pressed;
}

// ============================================================================
// ButtonStyle-driven overloads (unified UI schema)
// ============================================================================
// These accept a ButtonStyle + ColorVarResolver to push font, colors,
// and size from the schema. Existing call sites are unaffected.

inline bool StyledButton(const char* label, const schema::ButtonStyle& style,
                         const schema::ColorVarResolver& colors)
{
    // Resolve font
    ImFont* font = nullptr;
    if (!style.font.empty()) {
        font = Type().resolveByName(style.font);
    }
    ImGui::PushFont(font ? font : Type().button());

    // Push color overrides
    int colorCount = 0;
    if (!style.colors.background.empty()) {
        ImGui::PushStyleColor(ImGuiCol_Button,
            colors.resolve(style.colors.background));
        colorCount++;
    }
    if (!style.colors.backgroundHover.empty()) {
        ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
            colors.resolve(style.colors.backgroundHover));
        colorCount++;
    }
    if (!style.colors.backgroundActive.empty()) {
        ImGui::PushStyleColor(ImGuiCol_ButtonActive,
            colors.resolve(style.colors.backgroundActive));
        colorCount++;
    }
    if (!style.colors.color.empty()) {
        ImGui::PushStyleColor(ImGuiCol_Text,
            colors.resolve(style.colors.color));
        colorCount++;
    }

    // Push style overrides
    int styleCount = 0;
    if (style.borderRadius >= 0) {
        ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, style.borderRadius);
        styleCount++;
    }

    ImVec2 size(style.width > 0 ? style.width : 0,
                style.height > 0 ? style.height : 0);
    bool pressed = ImGui::Button(label, size);

    ImGui::PopStyleVar(styleCount);
    ImGui::PopStyleColor(colorCount);
    ImGui::PopFont();
    return pressed;
}

inline bool TactileButton(const char* label, const schema::ButtonStyle& style,
                          const schema::ColorVarResolver& colors)
{
    // Resolve font
    ImFont* font = nullptr;
    if (!style.font.empty()) {
        font = Type().resolveByName(style.font);
    }
    ImGui::PushFont(font ? font : Type().button());

    // Push color overrides
    int colorCount = 0;
    if (!style.colors.background.empty()) {
        ImGui::PushStyleColor(ImGuiCol_Button,
            colors.resolve(style.colors.background));
        colorCount++;
    }
    if (!style.colors.backgroundHover.empty()) {
        ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
            colors.resolve(style.colors.backgroundHover));
        colorCount++;
    }
    if (!style.colors.backgroundActive.empty()) {
        ImGui::PushStyleColor(ImGuiCol_ButtonActive,
            colors.resolve(style.colors.backgroundActive));
        colorCount++;
    }
    if (!style.colors.color.empty()) {
        ImGui::PushStyleColor(ImGuiCol_Text,
            colors.resolve(style.colors.color));
        colorCount++;
    }

    // Push style overrides
    int styleCount = 0;
    if (style.borderRadius >= 0) {
        ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, style.borderRadius);
        styleCount++;
    }

    ImDrawList* dl = ImGui::GetWindowDrawList();
    ImVec2 size(style.width > 0 ? style.width : 0,
                style.height > 0 ? style.height : 0);
    bool pressed = ImGui::Button(label, size);

    // Glass overlay on the button rect
    ImVec2 bMin = ImGui::GetItemRectMin();
    ImVec2 bMax = ImGui::GetItemRectMax();
    float rounding = ImGui::GetStyle().FrameRounding;
    bool active = ImGui::IsItemActive();
    bool hovered = ImGui::IsItemHovered();
    DrawButtonGlassOverlay(dl, bMin, bMax, rounding, active, hovered);

    ImGui::PopStyleVar(styleCount);
    ImGui::PopStyleColor(colorCount);
    ImGui::PopFont();
    return pressed;
}

} // namespace material
} // namespace ui
} // namespace dragonx

namespace dragonx {
namespace ui {
namespace material {

// ============================================================================
// Scroll-edge clipping mask — CSS mask-image style vertex alpha fade.
// Call ApplyScrollEdgeMask after EndChild() to fade content at the
// top/bottom edges of a scrollable panel.  Works by walking vertices
// added during rendering and scaling their alpha based on distance
// to the panel edges.  No opaque overlay rectangles — content becomes
// truly transparent, revealing whatever is behind the panel.
//
// Usage pattern:
//   float scrollY = 0, scrollMaxY = 0;
//   int parentVtx = parentDL->VtxBuffer.Size;
//   ImGui::BeginChild(...);
//   ImDrawList* childDL = ImGui::GetWindowDrawList();
//   int childVtx = childDL->VtxBuffer.Size;
//   { ... scrollY = GetScrollY(); scrollMaxY = GetScrollMaxY(); ... }
//   ImGui::EndChild();
//   ApplyScrollEdgeMask(parentDL, parentVtx, childDL, childVtx,
//                       panelMin.y, panelMax.y, fadeZone, scrollY, scrollMaxY);
// ============================================================================
inline void ApplyScrollEdgeMask(ImDrawList* parentDL, int parentVtxStart,
                                ImDrawList* childDL,  int childVtxStart,
                                float topEdge, float bottomEdge,
                                float fadeZone,
                                float scrollY, float scrollMaxY)
{
    auto mask = [&](ImDrawList* dl, int startIdx) {
        for (int vi = startIdx; vi < dl->VtxBuffer.Size; vi++) {
            ImDrawVert& v = dl->VtxBuffer[vi];
            float alpha = 1.0f;

            // Top fade — only when scrolled down
            if (scrollY > 1.0f) {
                float d = v.pos.y - topEdge;
                if (d < fadeZone)
                    alpha = std::min(alpha, std::max(0.0f, d / fadeZone));
            }
            // Bottom fade — only when not at scroll bottom
            if (scrollMaxY > 0 && scrollY < scrollMaxY - 1.0f) {
                float d = bottomEdge - v.pos.y;
                if (d < fadeZone)
                    alpha = std::min(alpha, std::max(0.0f, d / fadeZone));
            }

            if (alpha < 1.0f) {
                int a = (v.col >> IM_COL32_A_SHIFT) & 0xFF;
                a = static_cast<int>(a * alpha);
                v.col = (v.col & ~IM_COL32_A_MASK) | (static_cast<ImU32>(a) << IM_COL32_A_SHIFT);
            }
        }
    };

    if (parentDL) mask(parentDL, parentVtxStart);
    if (childDL)  mask(childDL,  childVtxStart);
}

// ============================================================================
// Smooth scrolling — exponential decay interpolation for mouse wheel.
// Call immediately after BeginChild() on a child that was created with
// ImGuiWindowFlags_NoScrollWithMouse.  Captures wheel input and lerps
// scroll position each frame for a smooth feel.
//
// Usage:
//   ImGui::BeginChild("##List", size, false,
//       ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
//   ApplySmoothScroll();
//   ... render content ...
//   ImGui::EndChild();
// ============================================================================

/// Returns true when any smooth-scroll child is still interpolating toward
/// its target.  Checked by the idle-rendering logic in main.cpp to keep
/// rendering frames until the animation settles.
inline bool& SmoothScrollAnimating() {
    static bool sAnimating = false;
    return sAnimating;
}

inline void ApplySmoothScroll(float speed = 12.0f)
{
    struct ScrollState {
        float target = 0.0f;
        float current = 0.0f;
        bool  init = false;
    };
    static std::unordered_map<ImGuiID, ScrollState> sStates;

    ImGuiWindow* win = ImGui::GetCurrentWindow();
    if (!win) return;

    ScrollState& s = sStates[win->ID];
    float scrollMaxY = ImGui::GetScrollMaxY();

    if (!s.init) {
        s.target  = ImGui::GetScrollY();
        s.current = s.target;
        s.init    = true;
    }

    // If something external set scroll position (e.g. SetScrollHereY for
    // auto-scroll), sync our state so the next wheel-up starts from the
    // actual current position rather than an old remembered target.
    float actualY = ImGui::GetScrollY();
    if (std::abs(s.current - actualY) > 1.0f) {
        s.target  = actualY;
        s.current = actualY;
    }

    // Capture mouse wheel when the cursor is inside this child window.
    // Use a direct position check instead of IsWindowHovered() which can
    // return false when an ActiveId exists (focused input, held button) or
    // when tooltip/popup windows from row hovers interfere with hover
    // routing.  Still skip when a real popup (combo dropdown, context menu)
    // is open so the popup handles its own scrolling.
    {
        bool popupOpen = ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel);
        ImVec2 mpos = ImGui::GetIO().MousePos;
        ImVec2 wmin = win->Pos;
        ImVec2 wmax = ImVec2(wmin.x + win->Size.x, wmin.y + win->Size.y);
        bool inside = (mpos.x >= wmin.x && mpos.x < wmax.x &&
                       mpos.y >= wmin.y && mpos.y < wmax.y);
        if (!popupOpen && inside) {
            float wheel = ImGui::GetIO().MouseWheel;
            if (wheel != 0.0f) {
                float step = ImGui::GetTextLineHeightWithSpacing() * 3.0f;
                s.target -= wheel * step;
                s.target = ImClamp(s.target, 0.0f, scrollMaxY);
            }
        }
    }

    // Clamp target if scrollMax changed (e.g., content resized)
    if (s.target > scrollMaxY) s.target = scrollMaxY;

    // Exponential decay lerp
    float dt = ImGui::GetIO().DeltaTime;
    s.current += (s.target - s.current) * (1.0f - expf(-speed * dt));

    // Snap when close
    if (std::abs(s.current - s.target) < 0.5f)
        s.current = s.target;
    else
        SmoothScrollAnimating() = true;  // still interpolating — keep rendering

    ImGui::SetScrollY(s.current);
}

// ============================================================================
// Dialog Title Bar with Close Button
// ============================================================================
// Draws a custom title bar for modal popups with a title and close button.
// Returns true if the close button was clicked.

inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col = 0)
{
    bool closeClicked = false;
    ImDrawList* dl = ImGui::GetWindowDrawList();
    ImVec2 winPos = ImGui::GetWindowPos();
    float winWidth = ImGui::GetWindowWidth();
    const float dp = Layout::dpiScale();
    float barHeight = 36.0f * dp;
    
    // Get accent color from theme if not provided
    if (!accent_col) {
        accent_col = schema::UI().resolveColor("var(--primary)", IM_COL32(76, 175, 80, 255));
    }
    
    // Draw title bar background with subtle gradient
    ImVec2 barMin = winPos;
    ImVec2 barMax(winPos.x + winWidth, winPos.y + barHeight);
    
    // Slightly darker top edge
    ImU32 barTop = IM_COL32(0, 0, 0, 40);
    ImU32 barBot = IM_COL32(0, 0, 0, 20);
    dl->AddRectFilledMultiColor(barMin, barMax, barTop, barTop, barBot, barBot);
    
    // Accent line at bottom of title bar
    dl->AddLine(ImVec2(barMin.x, barMax.y), ImVec2(barMax.x, barMax.y), 
                ScaleAlpha(accent_col, 0.6f), 1.0f);
    
    // Title text
    ImFont* titleFont = Type().subtitle1();
    ImGui::PushFont(titleFont);
    ImVec2 titleSize = ImGui::CalcTextSize(title);
    float titleX = barMin.x + 16.0f * dp;
    float titleY = barMin.y + (barHeight - titleSize.y) * 0.5f;
    DrawTextShadow(dl, ImVec2(titleX, titleY), OnSurface(), title);
    ImGui::PopFont();
    
    // Close button (X) on right side
    if (p_open) {
        float btnSize = 24.0f * dp;
        float btnX = barMax.x - btnSize - 12.0f * dp;
        float btnY = barMin.y + (barHeight - btnSize) * 0.5f;
        ImVec2 btnMin(btnX, btnY);
        ImVec2 btnMax(btnX + btnSize, btnY + btnSize);
        
        bool hovered = ImGui::IsMouseHoveringRect(btnMin, btnMax);
        bool held = false;
        if (hovered && ImGui::IsMouseClicked(0)) {
            held = true;
        }
        
        // Button background on hover
        if (hovered) {
            dl->AddRectFilled(btnMin, btnMax, IM_COL32(255, 255, 255, held ? 40 : 25), 4.0f * dp);
        }
        
        // Draw X icon
        ImGui::PushFont(Type().iconSmall());
        ImVec2 iconSize = ImGui::CalcTextSize(ICON_MD_CLOSE);
        float iconX = btnX + (btnSize - iconSize.x) * 0.5f;
        float iconY = btnY + (btnSize - iconSize.y) * 0.5f;
        dl->AddText(ImVec2(iconX, iconY), 
                    hovered ? IM_COL32(255, 255, 255, 255) : OnSurfaceMedium(), 
                    ICON_MD_CLOSE);
        ImGui::PopFont();
        
        // Handle click
        if (hovered && ImGui::IsMouseReleased(0)) {
            *p_open = false;
            closeClicked = true;
        }
    }
    
    // Reserve space for title bar so content starts below it
    ImGui::SetCursorPosY(ImGui::GetCursorPosY() + barHeight + 8.0f * dp);
    
    return closeClicked;
}

// ============================================================================
// Blur-backdrop modal primitives
// ============================================================================
// Shared building blocks for the "Manage Portfolio"-style overlay (live-blur backdrop, floating
// content, segmented section nav, edge-fade scroll). Promoted verbatim from the Market tab so every
// modal can adopt the same look. The overlay begin/end (below) drives these.

// "A blur overlay was drawn this frame" flag + a 1-frame latch. App::render builds its
// effect-suppression guard at the TOP of the frame, before dialogs dispatch, so it reads the
// LAST-frame latch (a stable signal) rather than this frame's in-progress state.
inline bool&  BlurOverlayDrawnThisFrameRef()  { static bool  v = false;   return v; }
inline bool&  BlurOverlayActiveLastFrameRef() { static bool  v = false;   return v; }
inline bool*& BlurOverlayOpenFlagRef()        { static bool* p = nullptr; return p; }
// Record that a blur overlay drew this frame, along with its open flag (so the latch below can tell
// "still open next frame" from "closed this frame" and match the modal's actual open state — no
// extra frame of suppression on close).
inline void  MarkBlurOverlayDrawn(bool* openFlag) { BlurOverlayDrawnThisFrameRef() = true; BlurOverlayOpenFlagRef() = openFlag; }
inline bool  AnyBlurOverlayActiveLastFrame() { return BlurOverlayActiveLastFrameRef(); }
// Latch whether an overlay will still be up next frame (drew this frame AND its open flag is still
// set after this frame's Esc/Close/outside-click handling); on the open->closed transition,
// invalidate the acrylic capture so the other glass panels re-capture the (overlay-free)
// background. Call once at the end of App::render.
inline void  LatchBlurOverlayActive() {
    bool* pf = BlurOverlayOpenFlagRef();
    bool stillOpen = BlurOverlayDrawnThisFrameRef() && (!pf || *pf);
    if (BlurOverlayActiveLastFrameRef() && !stillOpen) effects::ImGuiAcrylic::InvalidateCapture();
    BlurOverlayActiveLastFrameRef() = stillOpen;
    BlurOverlayDrawnThisFrameRef() = false;
    BlurOverlayOpenFlagRef() = nullptr;
}

// True during the frames a blur overlay is (re)capturing the live backdrop — the scroll-fade shader
// is suppressed then, since the acrylic capture rebinds render state on those frames.
inline bool& CapturingBlurBackdropRef() { static bool v = false; return v; }
inline bool  IsCapturingBlurBackdrop() { return CapturingBlurBackdropRef(); }

// Per-overlay capture-arming state (keyed by the scrim window id) so each blur overlay arms its
// capture-once independently on open/resize, detected via frame-count gaps (no close sweep needed).
struct BlurCaptureState { int lastSeenFrame = -2; int captureFrames = 0; float w = 0.0f, h = 0.0f; };
inline BlurCaptureState& BlurCaptureStateFor(const std::string& key) {
    static std::unordered_map<std::string, BlurCaptureState> m; return m[key];
}

// Right-align the next item(s) of total width `w` within the remaining content region (cursor form).
inline void RightAlignX(float w)
{
    float avail = ImGui::GetContentRegionAvail().x;
    if (avail > w) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - w);
}

// Equal-width segmented control (rounded track + N cells, each a centered label + an active pill) at
// screen-space `origin`, spanning `totalW` x `height`. Each cell is a hit-tested InvisibleButton
// (Hand cursor on hover), disambiguated by `idBase`. Does NOT move the layout cursor. Returns the
// clicked cell index, or -1.
inline int SegmentedControl(ImDrawList* dl, ImVec2 origin, float totalW, float height,
                            const char* const* labels, int count, int selected,
                            ImFont* font, const char* idBase, float dp)
{
    int clicked = -1;
    float cellW = totalW / (float)count;
    dl->AddRectFilled(origin, ImVec2(origin.x + totalW, origin.y + height),
                      WithAlpha(OnSurface(), 20), height * 0.5f);
    for (int i = 0; i < count; i++) {
        ImVec2 cMin(origin.x + i * cellW, origin.y), cMax(cMin.x + cellW, origin.y + height);
        bool active = (selected == i);
        bool hov = ImGui::IsMouseHoveringRect(cMin, cMax);
        if (active)
            dl->AddRectFilled(ImVec2(cMin.x + 2.0f * dp, cMin.y + 2.0f * dp),
                              ImVec2(cMax.x - 2.0f * dp, cMax.y - 2.0f * dp),
                              WithAlpha(Primary(), 210), (height - 4.0f * dp) * 0.5f);
        ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0, labels[i]);
        // Center when the label fits; otherwise left-align with a small pad (so a long translation clips
        // on the right, not on BOTH sides). Clip to the cell so no label can bleed into a neighbouring
        // segment or past the rounded track — English fits, but de/es/fr/pt/ru labels can overrun.
        const float lpad = 4.0f * dp;
        const float tx = (ts.x <= cellW - 2.0f * lpad) ? (cellW - ts.x) * 0.5f : lpad;
        dl->PushClipRect(cMin, cMax, true);
        dl->AddText(font, font->LegacySize,
                    ImVec2(cMin.x + tx, cMin.y + (height - ts.y) * 0.5f),
                    active ? IM_COL32(255, 255, 255, 255) : (hov ? OnSurface() : OnSurfaceMedium()),
                    labels[i]);
        dl->PopClipRect();
        ImGui::PushID(i);
        ImGui::SetCursorScreenPos(cMin);
        if (ImGui::InvisibleButton(idBase, ImVec2(cellW, height))) clicked = i;
        if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
        ImGui::PopID();
    }
    return clicked;
}

// The edge-fade scroll child (BeginFadeScrollChild/EndFadeScrollChild) depends on
// effects::ScrollFadeShader, whose GL/DX headers must not be pulled into this widely-included base
// header — it lives in material/overlay_scroll.h, included only by modal implementations.

// ============================================================================
// Fullscreen Overlay Dialog
// ============================================================================
// Creates a fullscreen semi-transparent overlay with a centered card dialog.
// Similar to the shutdown screen pattern but for interactive dialogs.

// Per-dialog content state (keyed by the child's id), measured at the end of each frame so the next
// frame can position/size the (auto-height) card to its content. An auto-resize child reports a
// too-small height on its first render and the true height a frame later, so we keep the card hidden
// until the height holds steady (stableCount) before revealing it centered — otherwise it visibly
// slides for a couple frames as the height converges. g_overlayCurrentKey carries the active dialog's
// key from BeginOverlayDialog to EndOverlayDialog (overlays don't nest).
struct OverlayCardState {
    float height       = 0.0f;   // last measured content-child height
    int   stableCount  = 0;      // consecutive frames the height held steady (within 1px)
    int   appearFrames = 0;      // frames since (re)appearing while still hidden — a safety cap
    bool  shown        = false;  // revealed (centered) at least once this open; don't re-hide after
    bool  overflow     = false;  // content once exceeded the viewport → clamp to viewport + scroll (sticky/open)
};
inline std::unordered_map<std::string, OverlayCardState> g_overlayCardHeights;
inline std::string g_overlayCurrentKey;
// Set when BeginOverlayDialog clips the card on an auto-height dialog's measuring frame (so it never
// flashes off-center); EndOverlayDialog pops the clip. Overlays don't nest, so a single flag is fine.
inline bool g_overlayHideFrameClip = false;

// The dark base color for any full-window overlay backdrop (dialog scrims + the shutdown screen),
// so every overlay shares one backdrop tone. `opacity` is the alpha (dialogs vary it per call).
inline ImVec4 OverlayScrimColor(float opacity) { return ImVec4(0.04f, 0.04f, 0.06f, opacity); }

// Style of an overlay dialog. GlassCard = the classic scrim + glass card + boxed title bar.
// BlurFloat = the "Manage Portfolio" look: live-blur backdrop + floating content + plain heading.
enum class OverlayStyle { GlassCard, BlurFloat };

// Spec-driven overlay. The flags below are orthogonal modifiers on one scaffold; OverlayStyle is a
// convenience preset (GlassCard = all off; BlurFloat = blurBackdrop+floatingContent+plainHeading).
struct OverlayDialogSpec {
    const char*  title  = nullptr;
    bool*        p_open = nullptr;
    OverlayStyle style  = OverlayStyle::GlassCard;
    bool  blurBackdrop    = false;  // live-blur backdrop instead of the opaque scrim
    bool  floatingContent = false;  // transparent card (no glass panel — content floats on the blur)
    bool  plainHeading    = false;  // Type().h6() heading instead of the boxed DrawDialogTitleBar
    float cardWidth  = 460.0f;      // scaled by dpiScale; clamped to viewport
    float cardHeight = 0.0f;        // 0 => auto-height (content-sized); else fixed height (large modals)
    float scrimOpacity = 0.92f;                 // GlassCard scrim only
    float cardBottomViewportRatio = 0.85f;      // auto-height cap
    const char* idSuffix = nullptr;             // distinct window/child ids for nesting + capture keying
};

inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
{
    const bool blur     = spec.blurBackdrop    || spec.style == OverlayStyle::BlurFloat;
    const bool floating = spec.floatingContent || spec.style == OverlayStyle::BlurFloat;
    const bool plainHd  = spec.plainHeading    || spec.style == OverlayStyle::BlurFloat;
    const float dp = Layout::dpiScale();

    MarkOverlayDialogActive();
    ImGuiViewport* vp = ImGui::GetMainViewport();
    ImVec2 vp_pos = vp->Pos, vp_size = vp->Size;

    // Cards are authored in raw px but scale with dpiScale (font-size setting); clamp to viewport.
    float cardWidth = spec.cardWidth * dp;
    cardWidth = std::min(cardWidth, vp_size.x - 32.0f);

    ImGui::SetNextWindowPos(vp_pos);
    ImGui::SetNextWindowSize(vp_size);
    // Focus the scrim so the dialog owns input — but not while a popup (combo/color picker) opened
    // from the dialog is showing, or forcing focus every frame would immediately close that popup.
    const bool overlayPopupOpen =
        ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel);
    if (!overlayPopupOpen) ImGui::SetNextWindowFocus();
    // Blur overlays draw their own backdrop, so the window itself is transparent.
    ImGui::PushStyleColor(ImGuiCol_WindowBg,
        blur ? ImVec4(0, 0, 0, 0) : OverlayScrimColor(spec.scrimOpacity));
    ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f);
    ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
    ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));

    std::string scrimId = "##OverlayScrim";
    std::string childId = "##OverlayDialogContent";
    if (spec.idSuffix && spec.idSuffix[0] != '\0') {
        scrimId += "_"; scrimId += spec.idSuffix;
        childId += "_"; childId += spec.idSuffix;
    }

    bool opened = ImGui::Begin(scrimId.c_str(), nullptr,
        ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
        ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
        ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoNav |
        ImGuiWindowFlags_NoSavedSettings);

    if (!opened) {
        ImGui::End();
        ImGui::PopStyleVar(3);
        ImGui::PopStyleColor();
        return false;
    }

    // True only on the frame the dialog (re)opens — used to re-arm the auto-height settle so each
    // fresh open reveals already-centered (rather than flashing last-open's stale position).
    const bool scrimAppearing = ImGui::IsWindowAppearing();

    ImDrawList* dl = ImGui::GetWindowDrawList();

    // Live-blur backdrop with capture-once: (re)capture the content drawn BELOW this overlay for a
    // few frames on open/resize, then reuse the frozen blurred snapshot. Low-spec / acrylic-off falls
    // back to an opaque base inside DrawFullWindowBlurBackdrop (no blur cost).
    if (blur) {
        MarkBlurOverlayDrawn(spec.p_open);
        BlurCaptureState& st = BlurCaptureStateFor(scrimId);
        int frame = ImGui::GetFrameCount();
        bool justOpened = (st.lastSeenFrame < frame - 1);
        st.lastSeenFrame = frame;
        if (justOpened || st.w != vp_size.x || st.h != vp_size.y) st.captureFrames = 3;
        st.w = vp_size.x; st.h = vp_size.y;
        CapturingBlurBackdropRef() = (st.captureFrames > 0);
        if (st.captureFrames > 0) {
            if (ImDrawCallback liveCap = effects::ImGuiAcrylic::GetLiveCaptureCallback()) {
                dl->AddCallback(liveCap, nullptr);
                dl->AddCallback(ImDrawCallback_ResetRenderState, nullptr);
            }
            st.captureFrames--;
        }
        DrawFullWindowBlurBackdrop(dl, vp_pos, ImVec2(vp_pos.x + vp_size.x, vp_pos.y + vp_size.y),
                                   /*allowBlur=*/!justOpened);   // opaque the first frame (nothing captured yet)
    } else {
        CapturingBlurBackdropRef() = false;
    }

    // Consume pointer input on the scrim so the overlay owns clicks/wheel even outside the card.
    ImGui::SetCursorScreenPos(vp_pos);
    ImGui::InvisibleButton("##OverlayInputBlocker", vp_size,
                           ImGuiButtonFlags_MouseButtonLeft |
                           ImGuiButtonFlags_MouseButtonRight |
                           ImGuiButtonFlags_MouseButtonMiddle);

    // Card geometry: both fixed- and auto-height cards are VERTICALLY CENTERED in the window.
    // Fixed-height centers its known height; auto-height centers last frame's measured content
    // height (cached per dialog id, so a re-opened dialog is centered from frame 1 — only the very
    // first open of a given dialog this session briefly anchors near the top before it re-centers).
    float cardX = vp_pos.x + (vp_size.x - cardWidth) * 0.5f;
    float cardY, cardBottomY;
    bool hideForMeasure = false;   // true on an auto-height dialog's first (unmeasured) frame
    bool autoOverflow   = false;   // auto-height content taller than the viewport → clamp + scroll
    const bool fixedHeight = (spec.cardHeight > 0.0f);
    if (fixedHeight) {
        float cardH = std::min(spec.cardHeight * dp, vp_size.y - 32.0f);
        cardY = vp_pos.y + (vp_size.y - cardH) * 0.5f;
        cardBottomY = cardY + cardH;
        g_overlayCurrentKey.clear();
    } else {
        g_overlayCurrentKey = childId;
        OverlayCardState& cs = g_overlayCardHeights[childId];
        if (scrimAppearing) { cs.shown = false; cs.stableCount = 0; cs.appearFrames = 0; cs.overflow = false; }
        if (!cs.shown) cs.appearFrames++;
        const float measuredH = cs.height;
        const float maxCardH  = vp_size.y - 32.0f;
        // Once the measured content is taller than the viewport, lock the card to the viewport height and
        // let its content child scroll (autoOverflow) so the footer/actions stay reachable. Sticky for this
        // open: clamping makes next frame's measured height the clamped value, so re-deciding from it would
        // oscillate — decide once and hold until the dialog re-opens.
        if (measuredH > maxCardH) cs.overflow = true;
        autoOverflow = cs.overflow;
        // Reveal once the measured height has settled (auto-resize converges in ~2 frames) or it's
        // already been shown this open (don't re-hide on a mid-dialog content change); a frame cap
        // guarantees a pathological ever-changing height can't hide the dialog forever.
        const bool ready = measuredH > 0.0f &&
                           (cs.shown || cs.stableCount >= 1 || cs.appearFrames >= 8);
        if (ready) {
            cs.shown = true;
            if (autoOverflow) {
                // Taller than the screen: top-anchor at the 16px margin, clamp to the viewport; the
                // content child (below) becomes the scroll region so the footer/actions stay reachable.
                cardY = vp_pos.y + 16.0f;
                cardBottomY = cardY + maxCardH;
            } else {
                // Center the measured content; if it's taller than the window, anchor at the top margin.
                cardY = (measuredH < maxCardH)
                            ? vp_pos.y + (vp_size.y - measuredH) * 0.5f
                            : vp_pos.y + 16.0f;
                cardBottomY = cardY + measuredH;
            }
        } else {
            // Still settling: lay the content out (so the auto-height child gets measured) but keep
            // the card hidden (hideForMeasure below) so it never flashes off-center — it appears,
            // already centered and stable, once the height stops changing.
            hideForMeasure = true;
            cardY = vp_pos.y + vp_size.y * 0.15f;
            cardBottomY = vp_pos.y + vp_size.y * spec.cardBottomViewportRatio;
        }
    }
    ImVec2 cardMin(cardX, cardY), cardMax(cardX + cardWidth, cardBottomY);

    // Card background — a glass panel unless the content floats directly on the backdrop. Skipped on
    // the measuring frame (its geometry is a placeholder; the whole card is hidden until centered).
    if (!floating && !hideForMeasure) {
        GlassPanelSpec cardGlass;
        // Fill/border alpha govern every overlay dialog's card boundary — kept well above the default
        // glass panel so the card reads as a distinct surface over busy backdrops (tx lists, mining
        // tiles, chat) while staying translucent rather than opaque.
        cardGlass.rounding = 16.0f * dp; cardGlass.fillAlpha = 60; cardGlass.borderAlpha = 90; cardGlass.borderWidth = 1.0f;
        DrawGlassPanel(dl, cardMin, cardMax, cardGlass);
    }

    // Click outside the card dismisses — but not on the measuring frame (placeholder geometry), the
    // appearing frame (the opening click), or while a popup opened from the dialog is showing.
    if (spec.p_open && !hideForMeasure && !overlayPopupOpen && !ImGui::IsWindowAppearing() &&
        ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
        !ImGui::IsMouseHoveringRect(cardMin, cardMax, false)) {
        *spec.p_open = false;
    }

    // Content child.
    ImGui::SetCursorScreenPos(ImVec2(cardX, cardY));
    ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, floating ? 20.0f * dp : 16.0f * dp);
    ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, floating ? ImVec2(28 * dp, 20 * dp) : ImVec2(28 * dp, 24 * dp));
    ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0));  // transparent (glass/blur behind)
    // A card with a known height is a fixed frame (fixed-height dialogs, and auto-height dialogs whose
    // content overflowed the viewport); otherwise the child auto-resizes to its content.
    const bool clampedCard = fixedHeight || autoOverflow;
    ImGuiChildFlags cflags = ImGuiChildFlags_AlwaysUseWindowPadding | (clampedCard ? 0 : ImGuiChildFlags_AutoResizeY);
    // NoScrollWithMouse (not just NoScrollbar): a modal is a fixed frame — the wheel must never drift
    // the WHOLE card. If content marginally overflows a fixed card, the wheel would otherwise scroll
    // the entire dialog (title + footer and all). Inner scroll regions (lists, notes) still scroll on
    // their own; auto-height cards resize to content so they normally never overflow — EXCEPT when the
    // content is taller than the viewport (autoOverflow), where the card itself IS the scroll region.
    ImGuiWindowFlags childScroll = autoOverflow
        ? ImGuiWindowFlags_None
        : (ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
    bool childVisible = ImGui::BeginChild(childId.c_str(),
        ImVec2(cardWidth, clampedCard ? (cardBottomY - cardY) : 0.0f),
        cflags, childScroll);
    // Floating (portfolio-style) cards: the padding applies to this content child only, so pop it
    // now (nested children mustn't inherit it), and center button labels. Net style-var count stays
    // at 2 (ChildRounding + ButtonTextAlign) so EndOverlayDialog's PopStyleVar(2) is unchanged.
    if (floating) {
        ImGui::PopStyleVar();  // WindowPadding
        ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.5f, 0.5f));
    }

    // Measuring frame: clip the card to nothing. Content (title + the caller's body) still lays out,
    // so the child's auto-height is measured for next frame, but nothing is drawn — no off-center
    // flash. EndOverlayDialog pops this before EndChild. Only entered when childVisible (End runs).
    if (childVisible && hideForMeasure) {
        ImGui::PushClipRect(ImVec2(0, 0), ImVec2(0, 0), false);
        g_overlayHideFrameClip = true;
    }

    if (childVisible && spec.title) {
        if (plainHd) {
            ImGui::PushFont(Type().h6());
            ImGui::TextUnformatted(spec.title);
            ImGui::PopFont();
            ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
        } else {
            DrawDialogTitleBar(spec.title, spec.p_open);
        }
    }

    return childVisible;
}

// Positional overload — the default look for the app's dialogs: content floats directly on the
// live-blur backdrop (no glass card) under a plain h6 heading, matching the Manage-Portfolio
// editor. Auto-height keeps short dialogs short. A dialog that needs the old contained card can
// pass an explicit OverlayDialogSpec with style = OverlayStyle::GlassCard.
inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth = 460.0f,
                               float scrimOpacity = 0.92f, float cardBottomViewportRatio = 0.85f,
                               const char* idSuffix = nullptr)
{
    OverlayDialogSpec s;
    s.title = title; s.p_open = p_open; s.cardWidth = cardWidth;
    s.scrimOpacity = scrimOpacity; s.cardBottomViewportRatio = cardBottomViewportRatio; s.idSuffix = idSuffix;
    s.blurBackdrop    = true;
    s.floatingContent = true;   // no glass card — content floats on the blur
    s.plainHeading    = true;   // h6 heading instead of the boxed title bar (drops the ✕)
    return BeginOverlayDialog(s);
}

inline void EndOverlayDialog()
{
    if (g_overlayHideFrameClip) { ImGui::PopClipRect(); g_overlayHideFrameClip = false; }
    ImGui::EndChild();
    // Remember the rendered card height (the child is the last item) + track whether it has stopped
    // changing, so next frame's BeginOverlayDialog can reveal the card only once its height (and thus
    // its centered position) is stable.
    if (!g_overlayCurrentKey.empty()) {
        OverlayCardState& cs = g_overlayCardHeights[g_overlayCurrentKey];
        const float h = ImGui::GetItemRectSize().y;
        const float d = (h >= cs.height) ? (h - cs.height) : (cs.height - h);
        if (h > 0.0f && d <= 1.0f) cs.stableCount++;
        else cs.stableCount = 0;
        cs.height = h;
    }
    ImGui::PopStyleColor();  // ChildBg
    ImGui::PopStyleVar(2);   // ChildRounding, WindowPadding (for child)
    
    ImGui::End();
    ImGui::PopStyleVar(3);   // WindowRounding, WindowBorderSize, WindowPadding (for scrim)
    ImGui::PopStyleColor();  // WindowBg scrim
}

inline void PlaceOverlayDialogActions(float totalWidth)
{
    float rowStartX = ImGui::GetCursorPosX();
    float contentW = ImGui::GetContentRegionAvail().x;
    ImGui::SetCursorPosX(rowStartX + std::max(0.0f, (contentW - totalWidth) * 0.5f));
}

inline void BeginOverlayDialogFooter(float totalActionWidth, bool drawSeparator = true)
{
    ImGui::Spacing();
    if (drawSeparator) {
        ImGui::Separator();
        ImGui::Spacing();
    }
    PlaceOverlayDialogActions(totalActionWidth);
}

// ── Confirmation dialog header / footer ─────────────────────────────────
// Shared scaffolding for the "warning + Cancel/confirm" overlay dialogs.

// Warning icon + "Warning" label row, drawn in `col` (the caller owns the
// exact accent colour; new callers can omit it to use the theme warning).
// Reproduces: PushFont(iconLarge) + ICON_MD_WARNING, PopFont, SameLine,
// TextColored(label).  The label text is passed in (already translated).
inline void DialogWarningHeader(const char* warningLabel, const ImVec4& col = WarningVec4())
{
    ImFont* iconF = Type().iconLarge();
    const float rowTop = ImGui::GetCursorPosY();
    const float iconH  = iconF->LegacySize;      // already dp-scaled
    const float textH  = ImGui::GetFontSize();
    ImGui::PushFont(iconF);
    ImGui::TextColored(col, ICON_MD_WARNING);
    ImGui::PopFont();
    ImGui::SameLine();
    // Vertically center the label with the taller warning glyph (stays within the icon's row height).
    if (iconH > textH) ImGui::SetCursorPosY(rowTop + (iconH - textH) * 0.5f);
    // Wrap so a long warning flows to a second line instead of clipping at the card edge; short
    // warnings (which fit) are unaffected.
    ImGui::PushTextWrapPos(0.0f);
    ImGui::TextColored(col, "%s", warningLabel);
    ImGui::PopTextWrapPos();
}

// 50/50 Cancel / confirm footer for overlay dialogs.  Sets `outCancel` /
// `outConfirm` when the respective button is pressed (the caller runs the
// action bodies).  When `danger`, the confirm button gets the red danger
// style.  Button height comes from ui.toml (falls back to 40).  Reproduces
// the hand-written footer exactly (btnW split + SameLine + optional danger
// PushStyleColor/PopStyleColor(2)).
inline void DialogConfirmFooter(const char* cancelId, const char* confirmLabel,
                                bool danger, bool& outCancel, bool& outConfirm)
{
    float btnH = schema::UI().drawElement("components.overlay-dialog", "confirm-btn-height").sizeOr(40.0f) * Layout::dpiScale();
    float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
    if (ImGui::Button(cancelId, ImVec2(btnW, btnH))) {
        outCancel = true;
    }
    ImGui::SameLine();
    if (danger) {
        ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.8f, 0.2f, 0.2f, 1.0f));
        ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.9f, 0.3f, 0.3f, 1.0f));
    }
    if (ImGui::Button(confirmLabel, ImVec2(btnW, btnH))) {
        outConfirm = true;
    }
    if (danger) {
        ImGui::PopStyleColor(2);
    }
}

// Reference dialog footer: a centered primary + Close (or Cancel) TactileButton pair, fixed width,
// NO divider above (the reference dropped the separator). Sets outPrimary/outClose when the respective
// button is clicked — the caller runs the bodies (so the primary's action can be arbitrary). The
// primary is BeginDisabled'd when !primaryEnabled. btnW<=0 → 130·dp default.
inline void DialogActionFooter(const char* primaryLabel, bool primaryEnabled,
                               const char* closeLabel, bool& outPrimary, bool& outClose,
                               float btnW = 0.0f)
{
    const float dp = Layout::dpiScale();
    if (btnW <= 0.0f) btnW = 130.0f * dp;
    const float total = btnW * 2.0f + ImGui::GetStyle().ItemSpacing.x;
    const float off = (ImGui::GetContentRegionAvail().x - total) * 0.5f;
    if (off > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + off);
    ImGui::BeginDisabled(!primaryEnabled);
    if (TactileButton(primaryLabel, ImVec2(btnW, 0))) outPrimary = true;
    ImGui::EndDisabled();
    ImGui::SameLine();
    if (TactileButton(closeLabel, ImVec2(btnW, 0))) outClose = true;
}

// RAII glass sub-section for grouping an optional/advanced block inside a dialog (the reference's
// scan-height panel). Auto-sizes to its content and paints a subtle glass panel behind it. Uses a
// LOCAL ImDrawListSplitter — safe with BeginCombo popups, unlike the shared ChannelsSplit that
// GlassCardScope uses. padX/padY are the interior insets (in logical px, dp-scaled here). Render the
// section body between construction and destruction; use interiorWidth() for full-width children.
struct GlassSectionScope {
    ImDrawListSplitter splitter;
    ImDrawList* dl;
    ImVec2 cardMin;
    float panelW, padX, padY;
    GlassPanelSpec spec;

    explicit GlassSectionScope(float padXLogical = 12.0f, float padYLogical = 10.0f)
    {
        const float dp = Layout::dpiScale();
        padX = padXLogical * dp;
        padY = padYLogical * dp;
        spec.rounding    = 8.0f * dp;
        spec.fillAlpha   = 20;
        spec.borderAlpha = 30;
        dl = ImGui::GetWindowDrawList();
        panelW  = ImGui::GetContentRegionAvail().x;
        cardMin = ImGui::GetCursorScreenPos();
        splitter.Split(dl, 2);
        splitter.SetCurrentChannel(dl, 1);   // content above the panel background
        ImGui::Dummy(ImVec2(0, padY));
        ImGui::Indent(padX);
    }
    ~GlassSectionScope()
    {
        ImGui::Unindent(padX);
        ImGui::Dummy(ImVec2(0, padY));
        const ImVec2 cardMax(cardMin.x + panelW, ImGui::GetCursorScreenPos().y);
        splitter.SetCurrentChannel(dl, 0);
        DrawGlassPanel(dl, cardMin, cardMax, spec);
        splitter.Merge(dl);
    }
    // Interior content width (panel width minus both side insets) for full-width children.
    float interiorWidth() const { return panelW - 2.0f * padX; }

    GlassSectionScope(const GlassSectionScope&) = delete;
    GlassSectionScope& operator=(const GlassSectionScope&) = delete;
};

} // namespace material
} // namespace ui
} // namespace dragonx
