-- KiraUI-Plugin_Options.lua
-- Native settings window opened with /kiraplugin. No ElvUI or Ace3 code -
-- built purely on Blizzard frame templates. Reads/writes through the shared
-- namespace table populated by KiraUI-Plugin.lua.
--
-- The left nav is a one-level tree: a top item can have child categories that
-- expand below it when selected (EllesmereUI -> Target / Player / Focus / Nameplates).

local addonName, ns = ...

local Msg = ns.Msg
local GetCfg, GetResetCfg, GetCdCfg, GetHistoryCfg, GetBreakCfg =
	ns.GetCfg, ns.GetResetCfg, ns.GetCdCfg, ns.GetHistoryCfg, ns.GetBreakCfg
local ApplyResetSettings, ApplyCdPulseSettings, ApplyHistorySettings =
	ns.ApplyResetSettings, ns.ApplyCdPulseSettings, ns.ApplyHistorySettings
local CdResolveSpellID, CdSpellName, CdSpellTexture, CdSpecLabel =
	ns.CdResolveSpellID, ns.CdSpellName, ns.CdSpellTexture, ns.CdSpecLabel
local GetCdSpells, GetCdDynamic, CdIsDynamic = ns.GetCdSpells, ns.GetCdDynamic, ns.CdIsDynamic

local format, ipairs, pairs, tostring, tonumber = format, ipairs, pairs, tostring, tonumber
local CreateFrame, tinsert = CreateFrame, tinsert

-- Version read from the .toc (## Version:) so it only needs bumping in one place.
local PLUGIN_VERSION = (C_AddOns and C_AddOns.GetAddOnMetadata and C_AddOns.GetAddOnMetadata(addonName, 'Version'))
	or (GetAddOnMetadata and GetAddOnMetadata(addonName, 'Version'))
	or '?'
local ORANGE = { 1, 0.49, 0.04 }
local CONFIG, scroll, content
local navContent  -- scroll child that holds the left-nav buttons
local navButtons = {}
local navWidgets = {}
-- The nav is a flat list of SECTIONS, each a header plus its pages. Sections
-- do not fold: a header is a label, not a button. Everything stays visible and
-- one click away, and the indent alone carries the grouping.
local navTree = {}
local navFilter = ''    -- lowercase search text filtering the nav list
local pageBuild = {}
local currentPage
local ShowPage
local uid = 0
local function UID(p) uid = uid + 1 return p .. uid end

---------------------------------------------------------------
-- theme + base widget factories (flat dark skin)
---------------------------------------------------------------
local ACCENT    = ORANGE
local C_BG      = { 0.055, 0.055, 0.065 }   -- window background
local C_PANEL   = { 0.115, 0.115, 0.135 }   -- widget background
local C_PANEL_H = { 0.175, 0.175, 0.200 }   -- widget hover
local C_TRACK   = { 0.210, 0.210, 0.240 }   -- slider track / toggle off
local C_INPUT   = { 0.080, 0.080, 0.095 }   -- editbox background
local ARROW_TEX = [[Interface\ChatFrame\ChatFrameExpandArrow]] -- points right; rotate for other directions

local RoundN = ns.Round

local scrollPopup, OpenScrollPopup -- fwd (defined with the shared popup below)

-- 1px black border + flat fill. Exposes __bg / __border for hover styling.
local function SkinBox(f, c)
	f.__border = f:CreateTexture(nil, 'BACKGROUND', nil, 0)
	f.__border:SetColorTexture(0, 0, 0, 1)
	f.__border:SetPoint('TOPLEFT', -1, 1)
	f.__border:SetPoint('BOTTOMRIGHT', 1, -1)
	f.__bg = f:CreateTexture(nil, 'BACKGROUND', nil, 1)
	f.__bg:SetAllPoints()
	c = c or C_PANEL
	f.__bg:SetColorTexture(c[1], c[2], c[3], 1)
	return f
end

local function MakeFlatButton(parent, text, w, h)
	local b = CreateFrame('Button', nil, parent)
	b:SetSize(w or 170, h or 24)
	SkinBox(b, C_PANEL)
	b.__label = b:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
	b.__label:SetPoint('CENTER')
	b.__label:SetText(text or '')
	b.SetText = function(self, t) self.__label:SetText(t) end
	b.GetFontString = function(self) return self.__label end
	b:SetScript('OnEnter', function(self)
		self.__bg:SetColorTexture(C_PANEL_H[1], C_PANEL_H[2], C_PANEL_H[3], 1)
		self.__border:SetColorTexture(ACCENT[1], ACCENT[2], ACCENT[3], 0.85)
	end)
	b:SetScript('OnLeave', function(self)
		self.__bg:SetColorTexture(C_PANEL[1], C_PANEL[2], C_PANEL[3], 1)
		self.__border:SetColorTexture(0, 0, 0, 1)
	end)
	return b
end

local function MakeEditBox(parent, w, h)
	local eb = CreateFrame('EditBox', nil, parent)
	eb:SetAutoFocus(false)
	eb:SetSize(w or 60, h or 20)
	eb:SetFontObject(_G.ChatFontNormal)
	eb:SetTextInsets(5, 5, 0, 0)
	SkinBox(eb, C_INPUT)
	eb:SetScript('OnEscapePressed', function(self) self:ClearFocus() end)
	eb:HookScript('OnEditFocusGained', function(self)
		self.__border:SetColorTexture(ACCENT[1], ACCENT[2], ACCENT[3], 0.9)
	end)
	eb:HookScript('OnEditFocusLost', function(self)
		self.__border:SetColorTexture(0, 0, 0, 1)
	end)
	return eb
end

-- Small on/off switch: orange + knob right when on, grey + knob left when off.
-- Same GetChecked/SetChecked API as a CheckButton, but does NOT auto-toggle.
local function MakeToggle(parent)
	local t = CreateFrame('Button', nil, parent)
	t:SetSize(32, 16)
	SkinBox(t, C_TRACK)
	t.knob = t:CreateTexture(nil, 'ARTWORK')
	t.knob:SetSize(12, 12)
	t.__checked = false
	t.SetChecked = function(self, v)
		self.__checked = v and true or false
		self.knob:ClearAllPoints()
		if self.__checked then
			self.__bg:SetColorTexture(ACCENT[1] * 0.85, ACCENT[2] * 0.85, ACCENT[3] * 0.85, 1)
			self.knob:SetColorTexture(1, 1, 1, 1)
			self.knob:SetPoint('RIGHT', -2, 0)
		else
			self.__bg:SetColorTexture(C_TRACK[1], C_TRACK[2], C_TRACK[3], 1)
			self.knob:SetColorTexture(0.55, 0.55, 0.60, 1)
			self.knob:SetPoint('LEFT', 2, 0)
		end
	end
	t.GetChecked = function(self) return self.__checked end
	t:SetChecked(false)
	return t
end

---------------------------------------------------------------
-- content layout helpers
---------------------------------------------------------------
local function Track(w) content.widgets[#content.widgets + 1] = w return w end

local function ResetContent()
	if content.widgets then
		for _, w in ipairs(content.widgets) do
			w:Hide()
			w:SetParent(nil)
			if w.ClearAllPoints then w:ClearAllPoints() end
		end
	end
	content.widgets = {}
	content.cy = -14
end

-- Sub-tabs inside one page.
--
-- The selected tab lives OUTSIDE the page function, in this table. Pages are
-- torn down and rebuilt from scratch on every ShowPage, so a tab stored in a
-- page-local would be forgotten by the very rebuild the click triggers.
--
-- Returns the active tab name, so the page body is just an if/elseif on it.
local subTab = {}
local function AddTabs(pageKey, names)
	local cur = subTab[pageKey]
	local valid = false
	for _, n in ipairs(names) do if n == cur then valid = true break end end
	if not valid then cur = names[1] end

	local x = 16
	for _, name in ipairs(names) do
		local on = (name == cur)
		local b = MakeFlatButton(content, name, math.max(80, #name * 9 + 24), 24)
		b:SetPoint('TOPLEFT', x, content.cy)
		if on then
			-- the selected tab keeps the hover look permanently, so the two
			-- states are obviously the same control in different modes
			b.__bg:SetColorTexture(C_PANEL_H[1], C_PANEL_H[2], C_PANEL_H[3], 1)
			b.__border:SetColorTexture(ACCENT[1], ACCENT[2], ACCENT[3], 0.85)
			b.__label:SetTextColor(ACCENT[1], ACCENT[2], ACCENT[3])
			b:SetScript('OnLeave', function(self)
				self.__bg:SetColorTexture(C_PANEL_H[1], C_PANEL_H[2], C_PANEL_H[3], 1)
				self.__border:SetColorTexture(ACCENT[1], ACCENT[2], ACCENT[3], 0.85)
			end)
		else
			b:SetScript('OnClick', function()
				subTab[pageKey] = name
				ShowPage(pageKey)
			end)
		end
		Track(b)
		x = x + b:GetWidth() + 6
	end
	content.cy = content.cy - 34
	return cur
end

local function AddHeader(text)
	local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontNormalLarge')
	fs:SetPoint('TOPLEFT', 16, content.cy)
	fs:SetText(text)
	fs:SetTextColor(ORANGE[1], ORANGE[2], ORANGE[3])
	local line = content:CreateTexture(nil, 'ARTWORK')
	line:SetColorTexture(ORANGE[1], ORANGE[2], ORANGE[3], 0.25)
	line:SetHeight(1)
	line:SetPoint('TOPLEFT', 16, content.cy - 20)
	line:SetPoint('TOPRIGHT', -20, content.cy - 20)
	Track(line)
	content.cy = content.cy - 30
	return Track(fs)
end

local function AddDesc(text)
	local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlightSmall')
	fs:SetPoint('TOPLEFT', 16, content.cy)
	fs:SetWidth(470)
	fs:SetJustifyH('LEFT')
	fs:SetText(text)
	fs:SetTextColor(0.7, 0.7, 0.7)
	content.cy = content.cy - (fs:GetStringHeight() or 12) - 10
	return Track(fs)
end

-- HookScript so tooltips stack with hover styling instead of replacing it.
local function Tooltip(frame, text)
	if not text then return end
	frame:HookScript('OnEnter', function(self)
		GameTooltip:SetOwner(self, 'ANCHOR_RIGHT')
		GameTooltip:AddLine(text, 1, 1, 1, true)
		GameTooltip:Show()
	end)
	frame:HookScript('OnLeave', function() GameTooltip:Hide() end)
end

-- Toggle-switch row; clicking the label toggles too.
local function AddCheck(label, getFn, setFn, tooltip)
	local row = CreateFrame('Button', nil, content)
	row:SetPoint('TOPLEFT', 16, content.cy)
	row:SetSize(440, 20)
	local tog = MakeToggle(row)
	tog:SetPoint('LEFT', 0, 0)
	tog:EnableMouse(false) -- the row handles clicks
	local fs = row:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
	fs:SetPoint('LEFT', tog, 'RIGHT', 10, 0)
	fs:SetText(label)
	tog:SetChecked(getFn() and true or false)
	row:SetScript('OnClick', function()
		local v = not (getFn() and true or false)
		tog:SetChecked(v)
		setFn(v)
	end)
	row:SetScript('OnEnter', function() fs:SetTextColor(1, 1, 1) end)
	row:SetScript('OnLeave', function() fs:SetTextColor(0.9, 0.9, 0.9) end)
	fs:SetTextColor(0.9, 0.9, 0.9)
	Tooltip(row, tooltip)
	content.cy = content.cy - 27
	return Track(row)
end

-- Slim flat slider with a draggable thumb AND an always-visible editable value
-- box to the right (click it, type an exact number, press Enter). Mouse wheel
-- over the slider steps by one increment.
local function AddSlider(label, mn, mx, st, getFn, setFn, tooltip)
	local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
	fs:SetPoint('TOPLEFT', 16, content.cy)
	fs:SetText(label)
	fs:SetTextColor(0.9, 0.9, 0.9)
	Track(fs)

	local s = CreateFrame('Slider', nil, content)
	s:SetOrientation('HORIZONTAL')
	s:SetSize(300, 16)
	s:SetPoint('TOPLEFT', 16, content.cy - 18)
	s:SetMinMaxValues(mn, mx)
	s:SetValueStep(st)
	s:SetObeyStepOnDrag(true)
	local trackBorder = s:CreateTexture(nil, 'BACKGROUND', nil, 0)
	trackBorder:SetPoint('LEFT', 0, 0)
	trackBorder:SetPoint('RIGHT', 0, 0)
	trackBorder:SetHeight(6)
	trackBorder:SetColorTexture(0, 0, 0, 1)
	local track = s:CreateTexture(nil, 'BACKGROUND', nil, 1)
	track:SetPoint('LEFT', 1, 0)
	track:SetPoint('RIGHT', -1, 0)
	track:SetHeight(4)
	track:SetColorTexture(C_TRACK[1], C_TRACK[2], C_TRACK[3], 1)
	local thumb = s:CreateTexture(nil, 'ARTWORK')
	thumb:SetColorTexture(ACCENT[1], ACCENT[2], ACCENT[3], 1)
	thumb:SetSize(10, 16)
	s:SetThumbTexture(thumb)

	local function fmt(v) return (st < 1) and format('%.2f', v) or tostring(math.floor(v + 0.5)) end
	local function snap(v)
		if v < mn then v = mn elseif v > mx then v = mx end
		if st < 1 then v = math.floor(v / st + 0.5) * st else v = math.floor(v + 0.5) end
		return v
	end

	local eb = MakeEditBox(content, 56, 18)
	eb:SetJustifyH('CENTER')
	eb:SetPoint('LEFT', s, 'RIGHT', 16, 0)

	local cur = getFn() or mn
	s:SetValue(cur)
	eb:SetText(fmt(cur))
	s:SetScript('OnValueChanged', function(_, val)
		val = snap(val)
		if not eb:HasFocus() then eb:SetText(fmt(val)) end
		setFn(val)
	end)
	-- Plain mouse wheel over a slider scrolls the PAGE (so hovering a slider
	-- never hijacks scrolling and changes values by accident); hold Shift to
	-- fine-adjust the slider by one step per wheel click.
	s:EnableMouseWheel(true)
	s:SetScript('OnMouseWheel', function(self, delta)
		if IsShiftKeyDown() then
			self:SetValue(snap(self:GetValue() + delta * st))
		elseif scroll then
			local maxScroll = scroll:GetVerticalScrollRange()
			local new = scroll:GetVerticalScroll() - delta * 40
			if new < 0 then new = 0 elseif new > maxScroll then new = maxScroll end
			scroll:SetVerticalScroll(new)
		end
	end)
	eb:SetScript('OnEnterPressed', function(self)
		local n = tonumber(self:GetText())
		if n then s:SetValue(snap(n)) end
		self:ClearFocus()
	end)
	eb:SetScript('OnEscapePressed', function(self) self:SetText(fmt(s:GetValue())) self:ClearFocus() end)
	eb:HookScript('OnEditFocusLost', function(self) self:SetText(fmt(s:GetValue())) end)
	Tooltip(s, tooltip)

	Track(eb)
	content.cy = content.cy - 46
	return Track(s)
end

-- Free-text setting. Commits on Enter or on losing focus, so a value typed and
-- then clicked away from is not silently thrown out.
local function AddEditBoxOption(label, getFn, setFn, width)
	local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
	fs:SetPoint('TOPLEFT', 16, content.cy)
	fs:SetText(label)
	fs:SetTextColor(0.9, 0.9, 0.9)
	Track(fs)
	content.cy = content.cy - 18

	local eb = MakeEditBox(content, width or 240, 22)
	eb:SetPoint('TOPLEFT', 16, content.cy)
	eb:SetText(getFn() or '')
	local function commit(self)
		setFn(self:GetText() or '')
		self:ClearFocus()
	end
	eb:SetScript('OnEnterPressed', commit)
	eb:HookScript('OnEditFocusLost', function(self) setFn(self:GetText() or '') end)
	Track(eb)
	content.cy = content.cy - 30
	return eb
end

-- Dropdown: flat button + shared scroll popup. Label on its own line above.
local function AddDropdown(label, options, getFn, setFn)
	local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
	fs:SetPoint('TOPLEFT', 16, content.cy)
	fs:SetText(label)
	fs:SetTextColor(0.9, 0.9, 0.9)
	Track(fs)
	content.cy = content.cy - 18

	local btn = MakeFlatButton(content, '', 200, 22)
	btn:SetPoint('TOPLEFT', 16, content.cy)
	btn.__label:ClearAllPoints()
	btn.__label:SetPoint('LEFT', 8, 0)
	btn.__label:SetPoint('RIGHT', -20, 0)
	btn.__label:SetJustifyH('LEFT')
	local ar = btn:CreateTexture(nil, 'OVERLAY')
	ar:SetTexture(ARROW_TEX)
	ar:SetSize(12, 12)
	ar:SetPoint('RIGHT', -5, 0)
	ar:SetRotation(-math.pi / 2) -- point down

	local function currentText()
		local cur = getFn()
		for _, o in ipairs(options) do if o.value == cur then return o.text end end
		return tostring(cur)
	end
	btn:SetText(currentText())
	btn:SetScript('OnClick', function()
		OpenScrollPopup(btn, options, getFn, setFn, nil, function(t) btn:SetText(t) end)
	end)
	btn:HookScript('OnHide', function()
		if scrollPopup and scrollPopup.__owner == btn then scrollPopup:Hide() end
	end)

	Track(btn)
	content.cy = content.cy - 30
	return btn
end

local function AddButton(label, onClick, width)
	local b = MakeFlatButton(content, label, width or 170, 24)
	b:SetPoint('TOPLEFT', 16, content.cy)
	b:SetScript('OnClick', onClick)
	content.cy = content.cy - 30
	return Track(b)
end

local function AddToggleButton(labelFn, onClick)
	local b = MakeFlatButton(content, labelFn(), 170, 24)
	b:SetPoint('TOPLEFT', 16, content.cy)
	b:SetScript('OnClick', function() onClick() b:SetText(labelFn()) end)
	content.cy = content.cy - 30
	return Track(b)
end

local function AddColor(label, getFn, setFn)
	local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontNormal')
	fs:SetPoint('TOPLEFT', 16, content.cy)
	fs:SetText(label)
	Track(fs)
	local sw = CreateFrame('Button', nil, content)
	sw:SetSize(18, 18)
	sw:SetPoint('LEFT', fs, 'RIGHT', 12, 0)
	local bord = sw:CreateTexture(nil, 'BACKGROUND')
	bord:SetPoint('TOPLEFT', -1, 1)
	bord:SetPoint('BOTTOMRIGHT', 1, -1)
	bord:SetColorTexture(0, 0, 0, 1)
	sw.tex = sw:CreateTexture(nil, 'ARTWORK')
	sw.tex:SetAllPoints()
	local function refresh()
		local c = getFn() or { 1, 1, 1 }
		sw.tex:SetColorTexture(c[1] or 1, c[2] or 1, c[3] or 1, 1)
	end
	refresh()
	sw:SetScript('OnClick', function()
		local c = getFn() or { 1, 1, 1 }
		local r, g, b = c[1] or 1, c[2] or 1, c[3] or 1
		local function applyNow()
			local nr, ng, nb = ColorPickerFrame:GetColorRGB()
			setFn({ nr, ng, nb, 1 })
			refresh()
		end
		if ColorPickerFrame.SetupColorPickerAndShow then
			ColorPickerFrame:SetupColorPickerAndShow({
				r = r, g = g, b = b, hasOpacity = false,
				swatchFunc = applyNow,
				cancelFunc = function() setFn({ r, g, b, 1 }) refresh() end,
			})
		else
			ColorPickerFrame.func = applyNow
			ColorPickerFrame.cancelFunc = function() setFn({ r, g, b, 1 }) refresh() end
			ColorPickerFrame.hasOpacity = false
			ColorPickerFrame.previousValues = { r, g, b }
			ColorPickerFrame:SetColorRGB(r, g, b)
			if ColorPickerFrame.Show then ColorPickerFrame:Show() end
		end
	end)
	Track(sw)
	content.cy = content.cy - 26
	return sw
end

-- Shared scrollable list popup (for all dropdowns; declared local above).
local function GetScrollPopup()
	if scrollPopup then return scrollPopup end
	local pop = CreateFrame('Frame', 'KiraScrollDropdownPopup', UIParent, 'BackdropTemplate')
	pop:SetSize(260, 340)
	pop:SetFrameStrata('FULLSCREEN_DIALOG')
	pop:SetFrameLevel(200)
	if pop.SetBackdrop then
		pop:SetBackdrop({ bgFile = 'Interface\\Buttons\\WHITE8X8', edgeFile = 'Interface\\Buttons\\WHITE8X8', edgeSize = 1 })
		pop:SetBackdropColor(C_BG[1], C_BG[2], C_BG[3], 0.98)
		pop:SetBackdropBorderColor(0, 0, 0, 1)
	end
	pop:Hide()

	local catcher = CreateFrame('Button', nil, UIParent)
	catcher:SetAllPoints(UIParent)
	catcher:SetFrameStrata('FULLSCREEN_DIALOG')
	catcher:SetFrameLevel(150)
	catcher:Hide()
	catcher:SetScript('OnClick', function() pop:Hide() end)
	pop:SetScript('OnShow', function() catcher:Show() end)
	pop:SetScript('OnHide', function() catcher:Hide() end)

	local sf = CreateFrame('ScrollFrame', 'KiraScrollDropdownScroll', pop, 'UIPanelScrollFrameTemplate')
	sf:SetPoint('TOPLEFT', 6, -6)
	sf:SetPoint('BOTTOMRIGHT', -26, 6)
	local child = CreateFrame('Frame', nil, sf)
	child:SetSize(210, 10)
	sf:SetScrollChild(child)
	pop.scroll = sf
	pop.child = child
	pop.rows = {}
	scrollPopup = pop
	return pop
end

function OpenScrollPopup(owner, options, getFn, setFn, onPick, setBtnText, preview)
	local pop = GetScrollPopup()
	if pop:IsShown() and pop.__owner == owner then pop:Hide() return end
	pop.__owner = owner
	pop:ClearAllPoints()
	pop:SetPoint('TOPLEFT', owner, 'BOTTOMLEFT', 0, -2)
	local child = pop.child
	child:SetWidth(pop.scroll:GetWidth())
	local rowH, y = 18, -2
	local cur = getFn()
	for i, o in ipairs(options) do
		local row = pop.rows[i]
		if not row then
			row = CreateFrame('Button', nil, child)
			row:SetHeight(rowH)
			row.text = row:CreateFontString(nil, 'OVERLAY', 'GameFontHighlightSmall')
			row.text:SetJustifyH('LEFT')
			row.hl = row:CreateTexture(nil, 'HIGHLIGHT')
			row.hl:SetAllPoints()
			row.hl:SetColorTexture(1, 1, 1, 0.12)
			row.sel = row:CreateTexture(nil, 'BACKGROUND')
			row.sel:SetAllPoints()
			row.sel:SetColorTexture(0.9, 0.55, 0.1, 0.25)
			-- per-row speaker: audition a sound without selecting / closing
			row.spk = CreateFrame('Button', nil, row)
			row.spk:SetSize(15, 15)
			row.spk:SetPoint('RIGHT', -4, 0)
			row.spk.tex = row.spk:CreateTexture(nil, 'ARTWORK')
			row.spk.tex:SetAllPoints()
			row.spk.tex:SetTexture('Interface\\Common\\VoiceChat-Speaker')
			row.spk:SetScript('OnEnter', function(self) self.tex:SetVertexColor(1, 0.82, 0.2) end)
			row.spk:SetScript('OnLeave', function(self) self.tex:SetVertexColor(1, 1, 1) end)
			pop.rows[i] = row
		end
		row:ClearAllPoints()
		row:SetPoint('TOPLEFT', 0, y)
		row:SetPoint('TOPRIGHT', 0, y)
		row.text:ClearAllPoints()
		row.text:SetPoint('LEFT', 4, 0)
		row.text:SetPoint('RIGHT', preview and -22 or -4, 0)
		row.text:SetText(o.text)
		row.sel:SetShown(o.value == cur)
		if preview then
			row.spk:SetScript('OnClick', function() preview(o.value) end)
			row.spk:Show()
		else
			row.spk:Hide()
		end
		row:SetScript('OnClick', function()
			setFn(o.value)
			if setBtnText then setBtnText(o.text) end
			if onPick then onPick(o.value) end
			pop:Hide()
		end)
		row:Show()
		y = y - rowH
	end
	for i = #options + 1, #pop.rows do pop.rows[i]:Hide() end
	child:SetHeight(math.max(10, #options * rowH + 4))
	-- size the popup to its content so short lists don't open a huge empty box
	pop:SetHeight(math.min(340, #options * rowH + 16))
	pop:Show()
	pop:Raise()
end

local function AddScrollDropdown(label, options, getFn, setFn, onPick, preview)
	local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
	fs:SetPoint('TOPLEFT', 16, content.cy)
	fs:SetText(label)
	fs:SetTextColor(0.9, 0.9, 0.9)
	Track(fs)
	content.cy = content.cy - 18
	local btn = MakeFlatButton(content, '', 220, 22)
	btn:SetPoint('TOPLEFT', 16, content.cy)
	btn.__label:ClearAllPoints()
	btn.__label:SetPoint('LEFT', 8, 0)
	btn.__label:SetPoint('RIGHT', -20, 0)
	btn.__label:SetJustifyH('LEFT')
	local ar = btn:CreateTexture(nil, 'OVERLAY')
	ar:SetTexture(ARROW_TEX)
	ar:SetSize(12, 12)
	ar:SetPoint('RIGHT', -5, 0)
	ar:SetRotation(-math.pi / 2)
	local function currentText()
		local cur = getFn()
		for _, o in ipairs(options) do if o.value == cur then return o.text end end
		return tostring(cur)
	end
	btn:SetText(currentText())
	btn:SetScript('OnClick', function()
		OpenScrollPopup(btn, options, getFn, setFn, onPick, function(t) btn:SetText(t) end, preview)
	end)
	btn:HookScript('OnHide', function()
		if scrollPopup and scrollPopup.__owner == btn then scrollPopup:Hide() end
	end)
	Track(btn)
	content.cy = content.cy - 30
	return btn
end

-- Compact position editor: editable X / Y boxes plus four nudge arrows that
-- move the frame 1 px per click (hold Shift for 10 px). Replaces the old
-- coarse Position X / Position Y sliders.
local function AddPosition(getX, setX, getY, setY)
	local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
	fs:SetPoint('TOPLEFT', 16, content.cy)
	fs:SetText('Position')
	fs:SetTextColor(0.9, 0.9, 0.9)
	Track(fs)
	local hint = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlightSmall')
	hint:SetPoint('LEFT', fs, 'RIGHT', 8, 0)
	hint:SetText('|cff777777arrows nudge 1 px - hold Shift for 10 px|r')
	Track(hint)
	content.cy = content.cy - 20

	local xBox, yBox
	local function refresh()
		if not xBox:HasFocus() then xBox:SetText(tostring(RoundN(getX() or 0))) end
		if not yBox:HasFocus() then yBox:SetText(tostring(RoundN(getY() or 0))) end
	end

	local xl = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
	xl:SetPoint('TOPLEFT', 16, content.cy - 4)
	xl:SetText('X')
	xl:SetTextColor(ORANGE[1], ORANGE[2], ORANGE[3])
	Track(xl)
	xBox = MakeEditBox(content, 54, 20)
	xBox:SetJustifyH('CENTER')
	xBox:SetPoint('LEFT', xl, 'RIGHT', 8, 0)
	Track(xBox)

	local yl = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
	yl:SetPoint('LEFT', xBox, 'RIGHT', 14, 0)
	yl:SetText('Y')
	yl:SetTextColor(ORANGE[1], ORANGE[2], ORANGE[3])
	Track(yl)
	yBox = MakeEditBox(content, 54, 20)
	yBox:SetJustifyH('CENTER')
	yBox:SetPoint('LEFT', yl, 'RIGHT', 8, 0)
	Track(yBox)

	local function commit(box, setFn, getFn)
		local n = tonumber(box:GetText())
		-- only write when the value actually changed, so the focus-lost commit
		-- after a plain click-through doesn't re-apply (or fight a drag).
		if n and RoundN(n) ~= RoundN(getFn() or 0) then setFn(RoundN(n)) end
		refresh()
	end
	xBox:SetScript('OnEnterPressed', function(self) commit(self, setX, getX) self:ClearFocus() end)
	yBox:SetScript('OnEnterPressed', function(self) commit(self, setY, getY) self:ClearFocus() end)
	-- typed/pasted values also apply when clicking away or tabbing out (not just Enter)
	xBox:HookScript('OnEditFocusLost', function(self) commit(self, setX, getX) end)
	yBox:HookScript('OnEditFocusLost', function(self) commit(self, setY, getY) end)
	-- Esc = cancel: restore the saved value before focus loss commits it
	xBox:SetScript('OnEscapePressed', function(self) self:SetText(tostring(RoundN(getX() or 0))) self:ClearFocus() end)
	yBox:SetScript('OnEscapePressed', function(self) self:SetText(tostring(RoundN(getY() or 0))) self:ClearFocus() end)

	-- Live refresh while the page is visible: right-dragging a frame on screen
	-- updates the saved offsets, so poll them into the boxes (skips a box the
	-- user is typing in; runs only while the config page is shown).
	local acc = 0
	xBox:HookScript('OnUpdate', function(_, elapsed)
		acc = acc + elapsed
		if acc >= 0.25 then
			acc = 0
			refresh()
		end
	end)

	-- nudge arrows: left / right / up / down
	local last
	local function ArrowBtn(rotation, dx, dy, tip)
		local b = MakeFlatButton(content, '', 22, 20)
		if last then b:SetPoint('LEFT', last, 'RIGHT', 4, 0)
		else b:SetPoint('LEFT', yBox, 'RIGHT', 18, 0) end
		local ar = b:CreateTexture(nil, 'OVERLAY')
		ar:SetTexture(ARROW_TEX)
		ar:SetSize(11, 11)
		ar:SetPoint('CENTER')
		ar:SetRotation(rotation)
		b:SetScript('OnClick', function()
			local step = IsShiftKeyDown() and 10 or 1
			if dx ~= 0 then setX(RoundN((getX() or 0) + dx * step)) end
			if dy ~= 0 then setY(RoundN((getY() or 0) + dy * step)) end
			refresh()
		end)
		Tooltip(b, tip)
		last = b
		return Track(b)
	end
	ArrowBtn(math.pi,      -1, 0, 'Move left (Shift = 10 px)')
	ArrowBtn(0,             1, 0, 'Move right (Shift = 10 px)')
	ArrowBtn(math.pi / 2,   0, 1, 'Move up (Shift = 10 px)')
	ArrowBtn(-math.pi / 2,  0, -1, 'Move down (Shift = 10 px)')

	refresh()
	content.cy = content.cy - 30
end

local OUTLINE_OPTIONS = {
	{ value = 'NONE', text = 'None' },
	{ value = 'OUTLINE', text = 'Outline' },
	{ value = 'THICK', text = 'Thick Outline' },
	{ value = 'SLUG', text = 'Slug' },
}

---------------------------------------------------------------
-- pages
---------------------------------------------------------------
local function PageTarget()
	AddHeader('Target')
	AddDesc('Target frame tweaks for EllesmereUI. Active only while EllesmereUI is loaded.')
	local function ncfg() return ns.EUIGetNameCfg() end
	AddCheck('Limit target name width',
		function() return ncfg().enable end,
		function(v) ncfg().enable = v ns.EUIRefreshTarget() end,
		'Clamps the target frame name to a maximum width; longer names clip with "...". Width-based so it works even when names are secret (combat, 12.1) - the text is never read, only its rendering area is limited.')
	AddSlider('Max name width (px)', 20, 300, 1,
		function() return ncfg().maxWidth end,
		function(v) ncfg().maxWidth = v ns.EUIRefreshTarget() end)
end

local function PagePlayer()
	AddHeader('Player')
	AddDesc('Player frame aura tweaks. Active only while EllesmereUI is loaded.')
	AddHeader('Debuff type borders')
	AddDesc('Borders the debuffs above your player frame with a clean SQUARE border colored by debuff type: |cff3399ffMagic blue|r, |cff9900ffCurse purple|r, |cff996600Disease brown|r, |cff009900Poison green|r, |cffcc0000Bleed red|r. Non-dispellable debuffs get no colored border - red means Bleed, nothing else. Engine-driven, so it works even when the debuff type is hidden from addons.')
	AddCheck('Enable',
		function() return ns.EUIGetDebuffColorCfg().enable end,
		function(v) ns.EUIGetDebuffColorCfg().enable = v ns.EUIApplyDebuffColors() end)
	AddToggleButton(function() return ns.EUITBIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.EUITBSetPreview(not ns.EUITBIsPreview()) end)
	AddDesc('Preview shows five ? icons - one per debuff type border (Magic, Curse, Disease, Poison, Bleed) - one row above your player frame\'s debuff row, sized like the live debuffs. The Thick border toggle updates it live.')
	AddCheck('Thick border',
		function() return ns.EUIGetDebuffColorCfg().thick end,
		function(v) ns.EUIGetDebuffColorCfg().thick = v ns.EUIApplyDebuffColors() end,
		'Doubles the border line thickness. Off = the original thin border, flush with the icon.')
	AddCheck('Border ignores cooldown swipe',
		function() return ns.EUIGetDebuffColorCfg().aboveSwipe end,
		function(v) ns.EUIGetDebuffColorCfg().aboveSwipe = v ns.EUIApplyDebuffColors() end,
		'Keeps the border fully lit as the duration swipe sweeps across the icon, instead of the swipe dimming the border along with the artwork. Off = the border darkens with the icon (the default look).')
end

local function PageFocus()
	AddHeader('Focus')
	AddDesc('Focus frame tweaks for EllesmereUI. Active only while EllesmereUI is loaded.')
	local function fcfg() return ns.EUIGetFocusMarkCfg() end
	AddCheck('Raid marker on cast bar',
		function() return fcfg().enable end,
		function(v) fcfg().enable = v ns.EUIApplyFocusMark() end,
		'Shows the focus raid target marker (the same icon as on the focus frame) to the left of the focus cast bar while it is casting.')
	AddSlider('Marker size', 8, 40, 1,
		function() return fcfg().size end,
		function(v) fcfg().size = v ns.EUIApplyFocusMark() end)
	AddSlider('Gap from bar', -40, 40, 1,
		function() return fcfg().x end,
		function(v) fcfg().x = v ns.EUIApplyFocusMark() end)
	AddSlider('Vertical offset', -20, 20, 1,
		function() return fcfg().y end,
		function(v) fcfg().y = v ns.EUIApplyFocusMark() end)

	AddHeader('Out-of-range fade')
	local function frcfg() return ns.EUIGetFocusRangeCfg() end
	AddCheck('Fade focus frame when out of range',
		function() return frcfg().enable end,
		function(v) frcfg().enable = v ns.EUIApplyFocusRange() end,
		'Dims the whole focus frame while your focus is out of range. Range is measured with a known spell of your class (harm spell for enemies, baseline heal for friendly focus as a healer). Works in keys: a secret range result is applied through the engine, never read.')
	AddSlider('Out-of-range opacity (%)', 10, 90, 5,
		function() return math.floor(((frcfg().alpha or 0.5) * 100) + 0.5) end,
		function(v) frcfg().alpha = v / 100 ns.EUIApplyFocusRange() end)
end

local function PageInterrupt()
	AddHeader('Interrupt')
	AddDesc('Shows your interrupt spell icon and cooldown on the focus nameplate (or the target nameplate when you have no focus) while it casts. Hidden the moment your interrupt is ready.')
	local function icfg() return ns.EUIIntGetCfg() end
	AddCheck('Enable',
		function() return icfg().enable end,
		function(v) icfg().enable = v ns.EUIIntApply() end)
	AddCheck('Hide icon when interrupt is ready',
		function() return icfg().hideWhenReady end,
		function(v) icfg().hideWhenReady = v ns.EUIIntApply() end)
	AddCheck('Show cooldown text',
		function() return icfg().cooldownText end,
		function(v) icfg().cooldownText = v ns.EUIIntApply() end)
	AddSlider('Icon size', 10, 60, 1,
		function() return icfg().size end,
		function(v) icfg().size = v ns.EUIIntApply() end)
	AddSlider('Text size', 6, 30, 1,
		function() return icfg().textSize end,
		function(v) icfg().textSize = v ns.EUIIntApply() end)
	AddSlider('Offset X', -150, 40, 1,
		function() return icfg().x end,
		function(v) icfg().x = v ns.EUIIntApply() end)
	AddSlider('Offset Y', -40, 40, 1,
		function() return icfg().y end,
		function(v) icfg().y = v ns.EUIIntApply() end)
end

local function PageNP()
	AddHeader('Nameplates')
	AddDesc('Nameplate tweaks for EllesmereUI. Active only while the nameplates module is loaded.')

	local function hcfg() return ns.EUINPGetHealthCfg() end
	AddCheck('Show health as # (%)',
		function() return hcfg().enable end,
		function(v) hcfg().enable = v ns.EUINPRefresh() end,
		'Rewrites the combined "# | %" health text to "# (%)". Set EllesmereUI nameplate health text to "# | %" for this to read as number then percent in brackets.')

	local function acfg() return ns.EUINPGetNameCfg() end
	AddCheck('Enemy name transparency',
		function() return acfg().enable end,
		function(v) acfg().enable = v ns.EUINPApplyNameAlpha() end,
		'Fades the enemy nameplate name text to the chosen opacity.')
	AddSlider('Name opacity %', 10, 100, 5,
		function() return acfg().alpha end,
		function(v) acfg().alpha = v if acfg().enable then ns.EUINPApplyNameAlpha() end end,
		'Lower = more transparent. 50 = half transparent.')

	AddHeader('Target Arrow')
	AddDesc('Custom target arrows using your EthricArrow10 texture (the old Plater "Arrow 10"). Set EllesmereUI\'s own Target Arrows to "None" to avoid two sets of arrows.')
	local function rcfg() return ns.EUINPGetArrowCfg() end
	AddCheck('Custom target arrow (Arrow 10)',
		function() return rcfg().enable end,
		function(v) rcfg().enable = v ns.EUINPApplyArrow() end,
		'Shows your arrow texture on either side of the current target\'s nameplate health bar.')
	AddSlider('Arrow size', 8, 40, 1,
		function() return rcfg().size end,
		function(v) rcfg().size = v ns.EUINPApplyArrow() end)
	AddSlider('Gap from bar', -40, 40, 1,
		function() return rcfg().x end,
		function(v) rcfg().x = v ns.EUINPApplyArrow() end)
	AddSlider('Vertical offset', -20, 20, 1,
		function() return rcfg().y end,
		function(v) rcfg().y = v ns.EUINPApplyArrow() end)
end

local function PageAbsorbs()
	AddHeader('Absorbs (ElvUI)')
	AddDesc('Tweaks for ElvUI unitframe absorb display. Active only while ElvUI is loaded.')
	AddCheck('Hide absorb text when 0',
		function() return GetCfg().hideZeroText end,
		function(v) GetCfg().hideZeroText = v ns.UpdateAbsorbs() end,
		'Absorb tags show nothing instead of "0" when there is no shield.')
	AddCheck('Reversed absorb at full health',
		function() return GetCfg().reversedAbsorb end,
		function(v) GetCfg().reversedAbsorb = v ns.UpdateAbsorbs() end,
		'Player frame: at 100% health the shield grows right-to-left from the end of the health bar. Requires Absorb Style = Overflow.')
end

local function PageUUF()
	AddHeader('UnhaltedUnitFrames')
	AddDesc('Player absorb tweaks for UnhaltedUnitFrames. Active only while UnhaltedUnitFrames is loaded.')
	local function ucfg() return ns.UUFGetCfg() end
	AddCheck('Player absorb texture (attach + reverse)',
		function() return ucfg().enable end,
		function(v) ucfg().enable = v ns.UUFApply() end,
		'On the player frame, the absorb shield attaches to the right of current health, and at full health the overflow reverses back across the bar.')
	AddCheck('Shorten absorb value text',
		function() return ucfg().shorten end,
		function(v) ucfg().shorten = v ns.UUFApply() end,
		'Makes UnhaltedUnitFrames\' own absorb tags render a short number (e.g. 236k) - so the absorb text you already use becomes abbreviated, no extra text. Affects any frame using an [absorbs] / [absorbs:truncate] tag. A /reload is needed to turn this back off.')
end

local function PageExternals()
	AddHeader('Externals')
	if ns.ExtEngineMode then
		-- kept deliberately: without it the Glow type dropdown further down
		-- looks broken, because in engine mode it is ignored on purpose
		AddDesc('|cffffd100Glow type|r is ignored here - the glow is always pixel-style. Lines, frequency, length, thickness and colour all still apply.')
	end
	local function ecfg() return ns.ExtGetCfg() end
	AddCheck('Enable',
		function() return ecfg().enable end,
		function(v) ecfg().enable = v ns.ExtApply() end)
	AddToggleButton(function() return ns.ExtIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.ExtSetPreview(not ns.ExtIsPreview()) end)
	AddCheck('Lock position',
		function() return ecfg().lock end,
		function(v) ecfg().lock = v ns.ExtApply() end)
	AddSlider('Icon size', 16, 80, 1,
		function() return ecfg().iconSize end,
		function(v) ecfg().iconSize = v ns.ExtApply() end)
	AddSlider('Text size', 6, 30, 1,
		function() return ecfg().textSize end,
		function(v) ecfg().textSize = v ns.ExtApply() end)
	AddSlider('Spacing', 0, 20, 1,
		function() return ecfg().spacing end,
		function(v) ecfg().spacing = v ns.ExtApply() end)
	AddDropdown('Grow direction', { { value = 'RIGHT', text = 'Right' }, { value = 'LEFT', text = 'Left' } },
		function() return ecfg().grow end,
		function(v) ecfg().grow = v ns.ExtApply() end)
	AddCheck('Play sound on new external',
		function() return ecfg().soundEnabled end,
		function(v) ecfg().soundEnabled = v ns.ExtRebuildSoundSet() end)
	AddScrollDropdown('Sound', ns.ExtSoundOptions(),
		function() return ecfg().sound end,
		function(v) ecfg().sound = v ns.ExtRebuildSoundSet() end,
		function(v) ns.ExtPlaySound(v) end,
		function(v) ns.ExtPlaySound(v) end)

	AddHeader('Sound spell list')
	AddDesc('|cffffd10012.1:|r the icons come from Blizzard\'s external-defensive category, but the sound is registered with the ENGINE per spell ID (C_UnitAuras.AddAuraAppliedSound), so a buff rings ONLY if its ID is on this list - even in restricted combat where addons go dark. If an icon shows silently: type /kiraext, get that buff again, and the chat prints the exact ID to add here. Note the BUFF\'s ID can differ from the cast\'s ID.')

	local sIdBox = MakeEditBox(content, 90, 22)
	sIdBox:SetPoint('TOPLEFT', 16, content.cy)
	local sAddBtn = MakeFlatButton(content, 'Add', 60, 22)
	sAddBtn:SetPoint('LEFT', sIdBox, 'RIGHT', 8, 0)
	local function doAddSoundSpell()
		local id = tonumber(sIdBox:GetText())
		if not id or id <= 0 then Msg('enter a spell ID first') return end
		id = math.floor(id)
		local list = ns.ExtSoundSpells()
		for _, v in ipairs(list) do
			if v == id then Msg('spell ' .. id .. ' is already on the list') return end
		end
		list[#list + 1] = id
		ns.ExtRebuildSoundSet()
		sIdBox:SetText('')
		ShowPage('externals', true)
	end
	sAddBtn:SetScript('OnClick', doAddSoundSpell)
	sIdBox:SetScript('OnEnterPressed', function(self) doAddSoundSpell() self:ClearFocus() end)
	Track(sIdBox) Track(sAddBtn)
	content.cy = content.cy - 32

	local sList = ns.ExtSoundSpells()
	if #sList == 0 then AddDesc('No spells on the sound list.') end
	for idx, id in ipairs(sList) do
		local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
		fs:SetPoint('TOPLEFT', 16, content.cy)
		fs:SetText(format('|T%s:18|t %s |cff888888(%d)|r', CdSpellTexture(id), CdSpellName(id), id))
		Track(fs)
		local rem = MakeFlatButton(content, 'Remove', 70, 20)
		rem:SetPoint('TOPLEFT', 380, content.cy + 3)
		rem:SetScript('OnClick', function()
			table.remove(ns.ExtSoundSpells(), idx)
			ns.ExtRebuildSoundSet()
			ShowPage('externals', true)
		end)
		Track(rem)
		content.cy = content.cy - 26
	end

	AddHeader('Glow')
	AddCheck('Enable glow',
		function() return ecfg().glow end,
		function(v) ecfg().glow = v ns.ExtApply() end)
	AddColor('Glow color',
		function() return ecfg().glowColor end,
		function(c) ecfg().glowColor = c ns.ExtApply() end)
	AddDropdown('Glow type', { { value = 'pixel', text = 'Pixel Glow' }, { value = 'autocast', text = 'Autocast' }, { value = 'button', text = 'Button' }, { value = 'proc', text = 'Proc' } },
		function() return ecfg().glowType end,
		function(v) ecfg().glowType = v ns.ExtApply() end)
	AddSlider('Glow lines', 1, 20, 1,
		function() return ecfg().glowLines end,
		function(v) ecfg().glowLines = v ns.ExtApply() end)
	AddSlider('Glow frequency', -1, 1, 0.05,
		function() return ecfg().glowFreq end,
		function(v) ecfg().glowFreq = v ns.ExtApply() end)
	AddSlider('Glow length (0=auto)', 0, 20, 1,
		function() return ecfg().glowLength end,
		function(v) ecfg().glowLength = v ns.ExtApply() end)
	AddSlider('Glow thickness', 1, 10, 1,
		function() return ecfg().glowThickness end,
		function(v) ecfg().glowThickness = v ns.ExtApply() end)
	AddPosition(
		function() return ecfg().x end,
		function(v) ecfg().x = v ns.ExtApply() end,
		function() return ecfg().y end,
		function(v) ecfg().y = v ns.ExtApply() end)
end

local function PageCombatText()
	AddHeader('Combat Text')
	AddDesc('Floating combat messages: announces spells you interrupt ("Interrupted [Spell]"), enter/leave combat, and party/focus deaths. Messages stack and fade.')
	local function cfg() return ns.CTGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.CTApply() end)
	AddToggleButton(function() return ns.CTIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.CTSetPreview(not ns.CTIsPreview()) end)
	AddCheck('Lock position',
		function() return cfg().lock end,
		function(v) cfg().lock = v ns.CTApply() end)
	AddDropdown('Font outline', OUTLINE_OPTIONS,
		function() return cfg().outline end,
		function(v) cfg().outline = v ns.CTApply() end)
	AddDropdown('Grow direction', { { value = 'DOWN', text = 'Down' }, { value = 'UP', text = 'Up' } },
		function() return cfg().grow end,
		function(v) cfg().grow = v ns.CTApply() end)
	AddSlider('Spacing', 0, 20, 1,
		function() return cfg().spacing end,
		function(v) cfg().spacing = v ns.CTApply() end)
	AddSlider('Message duration', 0.5, 5, 0.1,
		function() return cfg().duration end,
		function(v) cfg().duration = v end)
	AddPosition(
		function() return cfg().x end,
		function(v) cfg().x = v ns.CTApply() end,
		function() return cfg().y end,
		function(v) cfg().y = v ns.CTApply() end)

	AddHeader('Messages')
	AddCheck('Interrupt announce',
		function() return cfg().interruptEnable end,
		function(v) cfg().interruptEnable = v end,
		'Shows "Interrupted [Spell]" when you or your pet interrupt an enemy cast.')
	AddSlider('Interrupt text size', 10, 40, 1,
		function() return cfg().interruptSize end,
		function(v) cfg().interruptSize = v if ns.CTIsPreview() then ns.CTApply() end end)
	AddCheck('Enter combat (+Combat)',
		function() return cfg().enterEnable end,
		function(v) cfg().enterEnable = v end)
	AddColor('Enter combat color',
		function() return cfg().enterColor end,
		function(c) cfg().enterColor = c if ns.CTIsPreview() then ns.CTApply() end end)
	AddSlider('Enter combat text size', 10, 40, 1,
		function() return cfg().enterSize end,
		function(v) cfg().enterSize = v if ns.CTIsPreview() then ns.CTApply() end end)
	AddCheck('Leave combat (-Combat)',
		function() return cfg().leaveEnable end,
		function(v) cfg().leaveEnable = v end)
	AddColor('Leave combat color',
		function() return cfg().leaveColor end,
		function(c) cfg().leaveColor = c if ns.CTIsPreview() then ns.CTApply() end end)
	AddSlider('Leave combat text size', 10, 40, 1,
		function() return cfg().leaveSize end,
		function(v) cfg().leaveSize = v if ns.CTIsPreview() then ns.CTApply() end end)
	AddCheck('Death alert (party/raid)',
		function() return cfg().deathEnable end,
		function(v) cfg().deathEnable = v end,
		'Announces "[Name] dead" with the class-coloured name when a party/raid member (or you) dies.')
	AddColor('Death color',
		function() return cfg().deathColor end,
		function(c) cfg().deathColor = c if ns.CTIsPreview() then ns.CTApply() end end)
	AddSlider('Death text size', 10, 40, 1,
		function() return cfg().deathSize end,
		function(v) cfg().deathSize = v if ns.CTIsPreview() then ns.CTApply() end end)
	AddCheck('Speak deaths (TTS)',
		function() return cfg().ttsEnable end,
		function(v) cfg().ttsEnable = v end,
		'Says "<Name> dead" out loud for party/raid deaths. Stays silent for: your own death; the 2nd+ death within 5 seconds (one voice line per burst, never x4 wipe spam); and everything after a TANK died this combat - including you - since that usually means a wipe. Resets when combat ends.')
	AddCheck('Shorten spoken names',
		function() return cfg().ttsShortName end,
		function(v) cfg().ttsShortName = v end,
		'Speak only the first 5 letters of the name - "Ramboolisch dead" becomes "Rambo dead". Keeps the voice line short in hectic pulls.')
	AddSlider('TTS volume', 0, 100, 5,
		function() return cfg().ttsVolume end,
		function(v) cfg().ttsVolume = v end)
	do -- voice list comes from the game; built fresh each time the page opens
		local vopts = { { text = 'Use Blizzard TTS setting', value = '' } }
		local vc = _G.C_VoiceChat
		local voices = vc and vc.GetTtsVoices and vc.GetTtsVoices()
		if voices then
			for _, v in ipairs(voices) do
				if v.name then vopts[#vopts + 1] = { text = v.name, value = v.name } end
			end
		end
		AddDropdown('TTS voice', vopts,
			function() return cfg().ttsVoiceName or '' end,
			function(v) cfg().ttsVoiceName = v end)
	end
	AddButton('Preview TTS', function() ns.CTTTSPreview() end, 170)
	AddCheck('Focus death',
		function() return cfg().focusDeathEnable end,
		function(v) cfg().focusDeathEnable = v end)
	AddColor('Focus death color',
		function() return cfg().focusDeathColor end,
		function(c) cfg().focusDeathColor = c if ns.CTIsPreview() then ns.CTApply() end end)
	AddSlider('Focus death text size', 10, 40, 1,
		function() return cfg().focusDeathSize end,
		function(v) cfg().focusDeathSize = v if ns.CTIsPreview() then ns.CTApply() end end)
	AddCheck('Speak focus death (TTS)',
		function() return cfg().focusTtsEnable end,
		function(v) cfg().focusTtsEnable = v end,
		'Says "Focus dead" out loud, using the TTS volume and voice set above. Note: in restricted instanced combat the game hides enemy death state from addons, so the voice may stay silent there - the on-screen text still works.')
	AddButton('Preview focus TTS', function() ns.CTTTSPreviewFocus() end, 170)

	AddHeader('Ready announcements')
	AddCheck('Bloodlust ready',
		function() return cfg().bloodlustAnnounce end,
		function(v) cfg().bloodlustAnnounce = v end,
		'Shows a "Bloodlust Ready" message in this feed when Bloodlust/Heroism is available again (driven by the Bloodlust tab).')
	AddColor('Bloodlust color',
		function() return cfg().bloodlustColor end,
		function(c) cfg().bloodlustColor = c end)
	AddSlider('Bloodlust text size', 10, 40, 1,
		function() return cfg().bloodlustSize end,
		function(v) cfg().bloodlustSize = v end)
	AddCheck('Potion ready',
		function() return cfg().potionAnnounce end,
		function(v) cfg().potionAnnounce = v end,
		'Shows a "Potion Ready" message when your combat potion comes off cooldown (driven by the Potion tab).')
	AddColor('Potion color',
		function() return cfg().potionColor end,
		function(c) cfg().potionColor = c end)
	AddSlider('Potion text size', 10, 40, 1,
		function() return cfg().potionSize end,
		function(v) cfg().potionSize = v end)
end

-- The watched-spell list, shared by the Buffs tab. Pulled out of the page
-- body so the two tabs cannot drift apart.
--
-- CATEGORIES are drawn here and nowhere else. They are not a second layout -
-- the engine never hears about them. All they do is let this list be read in
-- chunks ("movement", "potions") while cfg.spells stays the single ordered
-- list that decides the on-screen row. BWNormalizeCategories keeps each
-- category's spells in one contiguous run, which is the only reason a header
-- can be drawn above a run without the page lying about placement.
local function BuffWatchSpellList(cfg)
	local C = cfg()
	-- Draw from a sorted list or the headers would sit over the wrong rows.
	ns.BWNormalizeCategories(C)
	C.categories = C.categories or {}
	C.spellCat = C.spellCat or {}
	C.catCollapsed = C.catCollapsed or {}

	local cats, of = C.categories, C.spellCat
	local rank = {}
	for i, n in ipairs(cats) do rank[n] = i end
	local function BlockOf(id) return rank[of[id]] or 0 end

	local function Redraw()
		ns.BWApply()
		ShowPage('buffwatch', true)
	end

	----------------------------------------------------------------
	-- Add a spell / add a category
	----------------------------------------------------------------
	local idBox = MakeEditBox(content, 90, 22)
	idBox:SetPoint('TOPLEFT', 16, content.cy)
	local addBtn = MakeFlatButton(content, 'Add', 60, 22)
	addBtn:SetPoint('LEFT', idBox, 'RIGHT', 8, 0)
	local function doAddSpell()
		local id = tonumber(idBox:GetText())
		if not id or id <= 0 then Msg('enter a spell ID first') return end
		id = math.floor(id)
		for _, v in ipairs(C.spells) do
			if v == id then Msg('spell ' .. id .. ' is already on the list') return end
		end
		C.spells[#C.spells + 1] = id
		idBox:SetText('')
		Redraw()
	end
	addBtn:SetScript('OnClick', doAddSpell)
	idBox:SetScript('OnEnterPressed', function(self) doAddSpell() self:ClearFocus() end)
	Tooltip(idBox, 'Spell ID from the buff tooltip or Wowhead. New spells land in Uncategorised.')
	Track(idBox) Track(addBtn)

	local catBox = MakeEditBox(content, 120, 22)
	catBox:SetPoint('TOPLEFT', 196, content.cy)
	local addCat = MakeFlatButton(content, 'Add category', 110, 22)
	addCat:SetPoint('LEFT', catBox, 'RIGHT', 8, 0)
	local function doAddCat()
		local name = (catBox:GetText() or ''):match('^%s*(.-)%s*$')
		if name == '' then Msg('type a category name first') return end
		for _, n in ipairs(cats) do
			if n == name then Msg('there is already a category called "' .. name .. '"') return end
		end
		cats[#cats + 1] = name
		catBox:SetText('')
		Redraw()
	end
	addCat:SetScript('OnClick', doAddCat)
	catBox:SetScript('OnEnterPressed', function(self) doAddCat() self:ClearFocus() end)
	Tooltip(catBox, 'A heading to file buffs under - "Movement", "Potions". Organisation only; it changes nothing in combat beyond keeping its spells together in the row.')
	Track(catBox) Track(addCat)
	content.cy = content.cy - 32

	local list = C.spells or {}
	if #list == 0 and #cats == 0 then AddDesc('No spells watched yet.') end

	----------------------------------------------------------------
	-- Which spells belong to which block. The list is already sorted, so
	-- each block's indices come out contiguous and ascending; an empty
	-- category still gets a header, or there would be no way to file
	-- anything into it or delete it again.
	----------------------------------------------------------------
	local blocks = { [0] = {} }
	for i = 1, #cats do blocks[i] = {} end
	for idx, id in ipairs(list) do
		local b = blocks[BlockOf(id)]
		b[#b + 1] = idx
	end

	-- Small arrow button, shared by the spell rows and the category headers.
	local function MoveBtn(x, rot, enabled, onClick, tip)
		local b = CreateFrame('Button', nil, content)
		b:SetSize(18, 18)
		b:SetPoint('TOPLEFT', x, content.cy + 2)
		local t = b:CreateTexture(nil, 'ARTWORK')
		t:SetAllPoints()
		t:SetTexture(ARROW_TEX)
		t:SetRotation(rot)
		if enabled then
			t:SetVertexColor(0.85, 0.85, 0.85)
			b:SetScript('OnEnter', function() t:SetVertexColor(ACCENT[1], ACCENT[2], ACCENT[3]) end)
			b:SetScript('OnLeave', function() t:SetVertexColor(0.85, 0.85, 0.85) end)
			b:SetScript('OnClick', onClick)
			Tooltip(b, tip)
		else
			t:SetVertexColor(0.3, 0.3, 0.3)
			b:EnableMouse(false)
		end
		Track(b)
	end

	----------------------------------------------------------------
	-- Moving a spell.
	--
	-- Inside its own block the arrows swap two entries, exactly as before.
	-- At the EDGE of a block the same arrow re-files the spell into the
	-- neighbouring category instead - so one control both reorders and
	-- moves between categories, and you can walk a buff the length of the
	-- list without ever opening a menu.
	--
	-- The landing spot falls out of the sort for free: the spell is already
	-- sitting immediately next to the block it is joining, and the sort is
	-- stable, so it enters the block from the side it arrived on.
	----------------------------------------------------------------
	local function MoveSpell(idx, dir)
		local id = list[idx]
		local myR = BlockOf(id)
		local nb = list[idx + dir]
		if nb and BlockOf(nb) == myR then
			list[idx], list[idx + dir] = list[idx + dir], list[idx]
		else
			local newR = myR + dir
			if newR < 0 or newR > #cats then return end
			of[id] = cats[newR]     -- nil at block 0 = uncategorised
			ns.BWNormalizeCategories(C)
		end
		Redraw()
	end

	local function SpellRow(idx, id, indent)
		-- Name and ID are SEPARATE strings. As one string the name was truncated
		-- from the right, which ate the ID - the thing you actually need to read
		-- to match a row against what you typed.
		--
		-- The ID sits PAST the Remove button, in the margin the page was not
		-- using. Squeezed between the name and the text box it left the name too
		-- little room, and a long name truncating into it read as one run-on
		-- string. Out here it never collides with anything and the name gets the
		-- whole column back.
		-- starts where the glow toggle used to, and takes the width the toggle
		-- freed up, so a long buff name has more room before it truncates
		local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
		fs:SetPoint('TOPLEFT', 18 + indent, content.cy - 4)
		fs:SetWidth(196 - indent)
		fs:SetJustifyH('LEFT')
		fs:SetWordWrap(false)
		fs:SetText(format('|T%s:18|t %s', CdSpellTexture(id), CdSpellName(id)))
		Track(fs)

		local idFs = content:CreateFontString(nil, 'OVERLAY', 'GameFontDisableSmall')
		idFs:SetPoint('TOPLEFT', 440, content.cy - 4)
		idFs:SetWidth(56)
		idFs:SetJustifyH('LEFT')
		idFs:SetWordWrap(false)
		idFs:SetText(tostring(id))
		Track(idFs)

		-- Per-spell label. Typing into it is enough; there is no Add button,
		-- because an empty box already means "no label" and a separate confirm
		-- step for one word is friction with nothing behind it.
		C.texts = C.texts or {}
		local entry = C.texts[id]
		local tBox = MakeEditBox(content, 76, 20)
		tBox:SetPoint('TOPLEFT', 220, content.cy + 3)
		tBox:SetText((entry and entry.text) or '')
		tBox:SetScript('OnEnterPressed', function(self) self:ClearFocus() end)
		tBox:SetScript('OnEditFocusLost', function(self)
			local txt = self:GetText() or ''
			local tt = C.texts
			if txt == '' then
				tt[id] = nil
			else
				tt[id] = tt[id] or { size = 14, x = 0, y = 0, color = { 1, 1, 1, 1 } }
				tt[id].text = txt
			end
			ns.BWApply()
		end)
		Tooltip(tBox, 'Text drawn above this buff\'s icon - e.g. "vers" on a trinket proc. Leave empty for none.')
		Track(tBox)

		-- Reorder / re-file. The list order IS the on-screen order, so this is
		-- the control that decides where each buff sits in the row. Text is
		-- keyed by spell ID, not by position, so it travels with the spell
		-- however far it moves.
		local myR = BlockOf(id)
		MoveBtn(302, math.pi / 2, idx > 1 or myR > 0,
			function() MoveSpell(idx, -1) end,
			'Move earlier. From the top of a category this moves it into the one above.')
		MoveBtn(320, -math.pi / 2, idx < #list or myR < #cats,
			function() MoveSpell(idx, 1) end,
			'Move later. From the bottom of a category this moves it into the one below.')

		-- Jump straight to any category, for when the buff is a long way from
		-- where it belongs and nudging it there would take a dozen clicks.
		local catBtn = MakeFlatButton(content, '', 18, 20)
		catBtn:SetPoint('TOPLEFT', 344, content.cy + 3)
		local ct = catBtn:CreateTexture(nil, 'OVERLAY')
		ct:SetTexture(ARROW_TEX)
		ct:SetSize(10, 10)
		ct:SetPoint('CENTER')
		ct:SetRotation(-math.pi / 2)
		catBtn:SetScript('OnClick', function()
			local opts = { { value = '', text = 'Uncategorised' } }
			for _, n in ipairs(cats) do opts[#opts + 1] = { value = n, text = n } end
			OpenScrollPopup(catBtn, opts,
				function() return of[id] or '' end,
				function(v)
					of[id] = (v ~= '' and v) or nil
					ns.BWNormalizeCategories(C)
				end,
				function() Redraw() end)
		end)
		Tooltip(catBtn, 'Category: ' .. (of[id] or 'Uncategorised'))
		Track(catBtn)

		local rem = MakeFlatButton(content, 'Remove', 66, 20)
		rem:SetPoint('TOPLEFT', 368, content.cy + 3)
		rem:SetScript('OnClick', function()
			table.remove(C.spells, idx)
			of[id] = nil
			if C.texts then C.texts[id] = nil end
			Redraw()
		end)
		Track(rem)

		content.cy = content.cy - 28
	end

	----------------------------------------------------------------
	-- Category header. The name is a live edit box rather than a Rename
	-- button: renaming is the only thing you would press it for, and the
	-- box is already the control that shows you the name.
	----------------------------------------------------------------
	local function CategoryHeader(r, name, count)
		local collapsed = C.catCollapsed[name] and true or false

		local tw = CreateFrame('Button', nil, content)
		tw:SetSize(16, 16)
		tw:SetPoint('TOPLEFT', 16, content.cy + 1)
		local tt = tw:CreateTexture(nil, 'ARTWORK')
		tt:SetAllPoints()
		tt:SetTexture(ARROW_TEX)
		tt:SetRotation(collapsed and 0 or -math.pi / 2)  -- right when folded, down when open
		tt:SetVertexColor(ORANGE[1], ORANGE[2], ORANGE[3])
		tw:SetScript('OnClick', function()
			C.catCollapsed[name] = (not collapsed) or nil
			ShowPage('buffwatch', true)   -- nothing changed in combat, so no BWApply
		end)
		Tooltip(tw, collapsed and 'Expand' or 'Collapse')
		Track(tw)

		local nameBox = MakeEditBox(content, 190, 20)
		nameBox:SetPoint('TOPLEFT', 36, content.cy + 2)
		nameBox:SetText(name)
		nameBox:SetScript('OnEnterPressed', function(self) self:ClearFocus() end)
		nameBox:SetScript('OnEditFocusLost', function(self)
			local new = (self:GetText() or ''):match('^%s*(.-)%s*$')
			if new == name then return end
			if new == '' then self:SetText(name) Msg('a category needs a name') return end
			for i, n in ipairs(cats) do
				if n == new and i ~= r then
					self:SetText(name)
					Msg('there is already a category called "' .. new .. '"')
					return
				end
			end
			-- Membership is by name, so every reference has to follow the rename
			-- or the category's spells would fall back to uncategorised.
			cats[r] = new
			for id, cn in pairs(of) do if cn == name then of[id] = new end end
			if C.catCollapsed[name] then C.catCollapsed[name] = nil C.catCollapsed[new] = true end
			Redraw()
		end)
		Tooltip(nameBox, 'Rename this category')
		Track(nameBox)

		local cnt = content:CreateFontString(nil, 'OVERLAY', 'GameFontDisableSmall')
		cnt:SetPoint('TOPLEFT', 234, content.cy - 2)
		cnt:SetWidth(60)
		cnt:SetJustifyH('LEFT')
		cnt:SetText(count == 1 and '1 buff' or (count .. ' buffs'))
		Track(cnt)

		-- Moving a whole category takes its spells with it, because the sort
		-- reads membership rather than positions.
		local function MoveCat(dir)
			local o = r + dir
			if o < 1 or o > #cats then return end
			cats[r], cats[o] = cats[o], cats[r]
			ns.BWNormalizeCategories(C)
			Redraw()
		end
		MoveBtn(302, math.pi / 2, r > 1, function() MoveCat(-1) end,
			'Move this whole category earlier')
		MoveBtn(320, -math.pi / 2, r < #cats, function() MoveCat(1) end,
			'Move this whole category later')

		-- Deleting a category keeps its spells - they drop back to
		-- Uncategorised - so there is nothing here worth a confirmation box.
		local del = MakeFlatButton(content, 'Delete', 64, 20)
		del:SetPoint('TOPLEFT', 344, content.cy + 3)
		del:SetScript('OnClick', function()
			table.remove(cats, r)
			C.catCollapsed[name] = nil
			for id, cn in pairs(of) do if cn == name then of[id] = nil end end
			ns.BWNormalizeCategories(C)
			Msg('category "' .. name .. '" removed; its buffs went back to Uncategorised')
			Redraw()
		end)
		Tooltip(del, 'Remove the category. Its buffs are kept and become Uncategorised.')
		Track(del)

		content.cy = content.cy - 26
	end

	local function PlainHeader(text)
		local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontNormalSmall')
		fs:SetPoint('TOPLEFT', 20, content.cy)
		fs:SetText(text)
		fs:SetTextColor(0.55, 0.55, 0.55)
		Track(fs)
		content.cy = content.cy - 20
	end

	----------------------------------------------------------------
	-- Draw: uncategorised block first, then each category in order.
	----------------------------------------------------------------
	for r = 0, #cats do
		local name = cats[r]
		local rows = blocks[r]
		if r == 0 then
			-- With no categories at all this is just the old flat list, so the
			-- heading would be noise. It only earns its place once there is
			-- something for it to be distinct FROM.
			if #cats > 0 then PlainHeader(#rows > 0 and 'Uncategorised' or 'Uncategorised (empty)') end
		else
			CategoryHeader(r, name, #rows)
		end
		if not (name and C.catCollapsed[name]) then
			local indent = (r > 0) and 12 or 0
			for _, idx in ipairs(rows) do SpellRow(idx, list[idx], indent) end
		end
	end

	AddDesc('The order here is the order on screen, top to bottom. Categories are for reading this list - they never reach the engine - but they do keep their buffs together in the row, so the two always agree.')
	AddDesc('Newly added spells land in |cffffd100Uncategorised|r at the top. Use the arrows to nudge a buff, or the small menu next to Remove to send it straight to a category.')
	AddDesc('|cffffd100Max icons|r on the Settings tab counts from the top of this whole list, and ignores whether a category is folded.')
end

local function PageBuffWatch()
	AddHeader('Buff Watch')
	if ns.BWDisabled then
		AddDesc('|cffffd100Requires game version 12.1.|r Buff Watch is built on the 12.1 buff system and is disabled on this game version - nothing will show in game. Your settings, watched spells and profiles are kept, and the feature turns on automatically once this client is on 12.1 or newer.')
		return
	end
	local function cfg() return ns.BWGetCfg() end
	local tab = AddTabs('buffwatch', { 'Buffs', 'Settings' })

	if tab == 'Buffs' then
		AddDesc('Enter a buff spell ID (from the buff tooltip / Wowhead) and press Add. Different ranks and forms have different IDs - add each one (Stampeding Roar has three). The box beside each spell draws a label above its icon.')
		BuffWatchSpellList(cfg)
		return
	end

	AddDesc('Shows an icon + countdown + stacks for each watched buff while it is on YOU - track things like Stampeding Roar, Zephyr, Wind Rush Totem or Blessing of Freedom by spell ID. |cffffd100Engine-driven (12.1):|r Blizzard reads the auras and fills our widgets, so it works even when aura data is hidden from addons. Right-drag to move in Preview when unlocked.')
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.BWApply() end)
	AddToggleButton(function() return ns.BWIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.BWSetPreview(not ns.BWIsPreview()) end)
	AddCheck('Lock position',
		function() return cfg().lock end,
		function(v) cfg().lock = v ns.BWApply() end)
	AddSlider('Icon width', 16, 120, 1,
		function() return cfg().iconW end,
		function(v) cfg().iconW = v ns.BWApply() end,
		'New size applies to icons as they (re)appear; toggle Preview to see it immediately.')
	AddSlider('Icon height', 16, 120, 1,
		function() return cfg().iconH end,
		function(v) cfg().iconH = v ns.BWApply() end)
	AddSlider('Zoom (% trimmed per edge)', 0, 40, 1,
		function() return cfg().zoom end,
		function(v) cfg().zoom = v ns.BWApply() end,
		'0 shows the whole icon file including its built-in border. 8 matches the rest of the addon. Higher zooms in - useful for filling a non-square icon without the art letterboxing.')
	AddSlider('Text size', 6, 30, 1,
		function() return cfg().textSize end,
		function(v) cfg().textSize = v ns.BWApply() end,
		'Countdown text size. Stack count uses 3/4 of this.')
	AddSlider('Spacing', 0, 20, 1,
		function() return cfg().spacing end,
		function(v) cfg().spacing = v ns.BWApply() end)
	AddSlider('Max icons', 1, 32, 1,
		function() return cfg().maxIcons end,
		function(v) cfg().maxIcons = v ns.BWApply() end)
	AddDropdown('Grow direction', {
		{ value = 'RIGHT', text = 'Right' },
		{ value = 'LEFT', text = 'Left' },
		{ value = 'DOWN', text = 'Down' },
		{ value = 'UP', text = 'Up' },
	}, function() return cfg().grow end,
		function(v) cfg().grow = v ns.BWApply() end)
	AddPosition(
		function() return cfg().x end,
		function(v) cfg().x = v ns.BWApply() end,
		function() return cfg().y end,
		function(v) cfg().y = v ns.BWApply() end)

	AddHeader('Attach to a frame')
	AddDesc('Rides another addon\'s frame instead of sitting at a fixed screen position. Type the frame name exactly - |cffffd200/framestack|r with the mouse over it prints the name.')
	AddDesc('|cffffd100For an on-use trinket:|r point this at the trinket\'s cooldown icon, set Attach point to |cffffd200Centre (overlay)|r and raise the Layer. The proc buff then covers the trinket\'s own cooldown while it is up, and uncovers it the moment the buff drops - there is no hiding logic, the icon simply stops existing. Put the proc first on the Buffs tab and grow Left, and your other buffs line up beside it.')
	AddEditBoxOption('Attach to frame',
		function() return cfg().attachFrame or '' end,
		function(v) cfg().attachFrame = v ns.BWApply() end)
	AddDropdown('Attach point', {
		{ value = 'CENTER', text = 'Centre (overlay)' },
		{ value = 'TOP',    text = 'Above it' },
		{ value = 'BOTTOM', text = 'Below it' },
		{ value = 'LEFT',   text = 'Left of it' },
		{ value = 'RIGHT',  text = 'Right of it' },
	}, function() return cfg().attachPoint end,
		function(v) cfg().attachPoint = v ns.BWApply() end)
	AddSlider('Attach offset X', -200, 200, 1,
		function() return cfg().attachX end,
		function(v) cfg().attachX = v ns.BWApply() end)
	AddSlider('Attach offset Y', -200, 200, 1,
		function() return cfg().attachY end,
		function(v) cfg().attachY = v ns.BWApply() end)
	AddDesc('|cffffd100Attaching to the trinket row:|r point this at |cffffd200KiraUITrinkets|r with Attach point "Left of it". That row resizes to however many trinkets are actually displayed, so with one trinket up your buffs sit beside it.')
	AddDesc('When |cffffd100no|r trinket is displayed the row has nothing left to sit beside, so this container takes its place outright - same anchor frame, same corner, same offsets as the trinket row uses. It lands exactly where the trinkets would have been rather than beside where they are not.')
	AddCheck('Fall back when that frame is hidden',
		function() return cfg().attachFallback end,
		function(v) cfg().attachFallback = v ns.BWApply() end,
		'No trinket equipped, or the icon hidden: the row returns to the free position above instead of following a frame that is not there.')
	AddDropdown('Layer (strata)', {
		{ value = 'LOW',    text = 'Low' },
		{ value = 'MEDIUM', text = 'Medium (default)' },
		{ value = 'HIGH',   text = 'High - above cooldown icons' },
		{ value = 'DIALOG', text = 'Dialog - above almost everything' },
	}, function() return cfg().strata end,
		function(v) cfg().strata = v ns.BWApply() end)

	AddHeader('Custom text')
	AddDesc('How the labels you type on the Buffs tab are drawn. One setting for all of them - three more controls on every row of that list would be a lot of clutter to buy something almost nobody varies per buff.')
	AddSlider('Text size', 6, 40, 1,
		function() return cfg().labelSize end,
		function(v) cfg().labelSize = v ns.BWApply() end)
	AddSlider('Text X offset', -60, 60, 1,
		function() return cfg().labelX end,
		function(v) cfg().labelX = v ns.BWApply() end)
	AddSlider('Text Y offset', -60, 60, 1,
		function() return cfg().labelY end,
		function(v) cfg().labelY = v ns.BWApply() end,
		'0 sits just above the icon. Negative moves it down onto the icon.')

end

local function PageBloodlust()
	AddHeader('Bloodlust ready')
	AddDesc('Plays a sound the instant Bloodlust/Heroism is available again. Right-drag to move when unlocked.')
	local function cfg() return ns.BLGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.BLApply() end)
	AddToggleButton(function() return ns.BLIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.BLSetPreview(not ns.BLIsPreview()) end)
	AddCheck('Lock position',
		function() return cfg().lock end,
		function(v) cfg().lock = v ns.BLApply() end)
	AddSlider('Show icon at (seconds left)', 1, 30, 1,
		function() return cfg().threshold end,
		function(v) cfg().threshold = v ns.BLApply() end,
		'Show the warning icon when the lockout has this many seconds or fewer remaining. Needs the debuff duration to be readable (run /kiralust to check).')
	AddSlider('Icon size', 16, 100, 1,
		function() return cfg().iconSize end,
		function(v) cfg().iconSize = v ns.BLApply() end)
	AddSlider('Text size', 8, 40, 1,
		function() return cfg().textSize end,
		function(v) cfg().textSize = v ns.BLApply() end)
	AddPosition(
		function() return cfg().x end,
		function(v) cfg().x = v ns.BLApply() end,
		function() return cfg().y end,
		function(v) cfg().y = v ns.BLApply() end)

	AddHeader('Sound')
	AddCheck('Play sound when lust is ready',
		function() return cfg().soundEnabled end,
		function(v) cfg().soundEnabled = v end)
	AddScrollDropdown('Ready sound', ns.BLSoundOptions(),
		function() return cfg().sound end,
		function(v) cfg().sound = v end,
		function(v) ns.BLPlaySound(v) end,
		function(v) ns.BLPlaySound(v) end)

	AddHeader('Glow')
	AddCheck('Enable glow',
		function() return cfg().glow end,
		function(v) cfg().glow = v ns.BLApply() end)
	AddColor('Glow color',
		function() return cfg().glowColor end,
		function(c) cfg().glowColor = c ns.BLApply() end)
	AddDropdown('Glow type', { { value = 'pixel', text = 'Pixel Glow' }, { value = 'autocast', text = 'Autocast' }, { value = 'button', text = 'Button' }, { value = 'proc', text = 'Proc' } },
		function() return cfg().glowType end,
		function(v) cfg().glowType = v ns.BLApply() end)
	AddSlider('Glow lines', 1, 20, 1,
		function() return cfg().glowLines end,
		function(v) cfg().glowLines = v ns.BLApply() end)
	AddSlider('Glow frequency', -1, 1, 0.05,
		function() return cfg().glowFreq end,
		function(v) cfg().glowFreq = v ns.BLApply() end)
	AddSlider('Glow length (0=auto)', 0, 20, 1,
		function() return cfg().glowLength end,
		function(v) cfg().glowLength = v ns.BLApply() end)
	AddSlider('Glow thickness', 1, 10, 1,
		function() return cfg().glowThickness end,
		function(v) cfg().glowThickness = v ns.BLApply() end)
end

local function PagePotion()
	AddHeader('Potion ready')
	AddDesc('Plays a sound the instant your combat potion comes off cooldown, and shows the potion icon + countdown for the last few seconds before it. It scans your bags for the potion on cooldown - no spell IDs needed. Right-drag to move when unlocked.')
	local function cfg() return ns.PTGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.PTApply() end)
	AddToggleButton(function() return ns.PTIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.PTSetPreview(not ns.PTIsPreview()) end)
	AddCheck('Lock position',
		function() return cfg().lock end,
		function(v) cfg().lock = v ns.PTApply() end)
	AddSlider('Show icon at (seconds left)', 1, 30, 1,
		function() return cfg().threshold end,
		function(v) cfg().threshold = v ns.PTApply() end)
	AddSlider('Icon size', 16, 100, 1,
		function() return cfg().iconSize end,
		function(v) cfg().iconSize = v ns.PTApply() end)
	AddSlider('Text size', 8, 40, 1,
		function() return cfg().textSize end,
		function(v) cfg().textSize = v ns.PTApply() end)
	AddPosition(
		function() return cfg().x end,
		function(v) cfg().x = v ns.PTApply() end,
		function() return cfg().y end,
		function(v) cfg().y = v ns.PTApply() end)

	AddHeader('Sound')
	AddCheck('Play sound when potion is ready',
		function() return cfg().soundEnabled end,
		function(v) cfg().soundEnabled = v end)
	AddScrollDropdown('Ready sound', ns.PTSoundOptions(),
		function() return cfg().sound end,
		function(v) cfg().sound = v end,
		function(v) ns.PTPlaySound(v) end,
		function(v) ns.PTPlaySound(v) end)

	AddHeader('Glow')
	AddCheck('Enable glow',
		function() return cfg().glow end,
		function(v) cfg().glow = v ns.PTApply() end)
	AddColor('Glow color',
		function() return cfg().glowColor end,
		function(c) cfg().glowColor = c ns.PTApply() end)
	AddDropdown('Glow type', { { value = 'pixel', text = 'Pixel Glow' }, { value = 'autocast', text = 'Autocast' }, { value = 'button', text = 'Button' }, { value = 'proc', text = 'Proc' } },
		function() return cfg().glowType end,
		function(v) cfg().glowType = v ns.PTApply() end)
	AddSlider('Glow lines', 1, 20, 1,
		function() return cfg().glowLines end,
		function(v) cfg().glowLines = v ns.PTApply() end)
	AddSlider('Glow frequency', -1, 1, 0.05,
		function() return cfg().glowFreq end,
		function(v) cfg().glowFreq = v ns.PTApply() end)
	AddSlider('Glow length (0=auto)', 0, 20, 1,
		function() return cfg().glowLength end,
		function(v) cfg().glowLength = v ns.PTApply() end)
	AddSlider('Glow thickness', 1, 10, 1,
		function() return cfg().glowThickness end,
		function(v) cfg().glowThickness = v ns.PTApply() end)
end

local function PageRaidMarkers()
	AddHeader('Raid Markers')
	AddDesc('A movable bar of buttons that set raid target markers (left click) and ground world markers (right click) on your target, plus optional group countdown and ready-check buttons. The marker buttons work in combat. Right-drag the bar to move it when unlocked.')
	local function cfg() return ns.RMGetCfg() end
	AddCheck('Show panel',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.RMApply() end)
	AddCheck('Only show in a group/raid',
		function() return cfg().groupOnly end,
		function(v) cfg().groupOnly = v ns.RMApply() end,
		'Hide the bar while solo; show it only when you are in a party or raid. (Changes that happen during combat apply when you leave combat.)')
	AddToggleButton(function() return ns.RMIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.RMSetPreview(not ns.RMIsPreview()) ShowPage('raidmarkers', true) end)
	AddDesc('Shows the bar even while the feature is off or you are solo, so it can be positioned in either. Right-drag to move it once unlocked.')
	AddCheck('Lock position',
		function() return cfg().lock end,
		function(v) cfg().lock = v end,
		'Prevents moving the bar with right-drag.')
	AddSlider('Scale', 0.5, 2, 0.05,
		function() return cfg().scale end,
		function(v) cfg().scale = v ns.RMApply() end)
	AddSlider('Button spacing', 0, 20, 1,
		function() return cfg().spacing end,
		function(v) cfg().spacing = v ns.RMApply() end)
	AddDropdown('Orientation', {
		{ value = 'HORIZONTAL', text = 'Horizontal' },
		{ value = 'VERTICAL', text = 'Vertical' },
	}, function() return cfg().orientation end,
		function(v) cfg().orientation = v ns.RMApply() end)
	AddPosition(
		function() return cfg().x end,
		function(v) cfg().x = v ns.RMApply() end,
		function() return cfg().y end,
		function(v) cfg().y = v ns.RMApply() end)

	AddHeader('Buttons')
	AddCheck('Show clear button',
		function() return cfg().showClear end,
		function(v) cfg().showClear = v ns.RMApply() end,
		'Left: clear all target markers. Right: clear all world markers.')
	AddCheck('Show countdown button',
		function() return cfg().showCountdown end,
		function(v) cfg().showCountdown = v ns.RMApply() end,
		'Left: start a group countdown. Right: cancel it.')
	AddSlider('Countdown seconds', 1, 30, 1,
		function() return cfg().countdownSeconds end,
		function(v) cfg().countdownSeconds = v end)
	AddCheck('Show ready-check button',
		function() return cfg().showReadyCheck end,
		function(v) cfg().showReadyCheck = v ns.RMApply() end)

end

local function PageRecuperate()
	AddHeader('Recuperate')
	AddDesc('Shows a clickable Recuperate icon when you are OUT OF COMBAT and below full health - click it to cast. It hides automatically in combat and at full health. (Only appears if you know the spell.)')
	local function cfg() return ns.RecupGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.RecupApply() end)
	AddToggleButton(function() return ns.RecupIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.RecupSetPreview(not ns.RecupIsPreview()) end)
	AddCheck('Show spell tooltip on hover',
		function() return cfg().showTooltip end,
		function(v) cfg().showTooltip = v end,
		'When off, hovering the icon shows nothing - just a clean clickable icon.')
	AddSlider('Icon size', 24, 96, 1,
		function() return cfg().size end,
		function(v) cfg().size = v ns.RecupApply() end)
	AddPosition(
		function() return cfg().x end,
		function(v) cfg().x = v ns.RecupApply() end,
		function() return cfg().y end,
		function(v) cfg().y = v ns.RecupApply() end)
end

local function PageElixir()
	AddHeader('Monk')
	local function cfg() return ns.ELGetCfg() end

	AddHeader('Expel Harm orbs')
	AddDesc('Shows your Gift of the Ox orbs as 5 green dots (the spell count on Expel Harm). Right-drag to move when unlocked.')
	AddCheck('Enable',
		function() return cfg().orbEnable end,
		function(v) cfg().orbEnable = v ns.ELApply() end)
	AddToggleButton(function() return ns.ELOrbIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.ELOrbSetPreview(not ns.ELOrbIsPreview()) end)
	AddCheck('Lock position',
		function() return cfg().orbLock end,
		function(v) cfg().orbLock = v end)
	AddSlider('Orb size', 6, 64, 1,
		function() return cfg().orbSize end,
		function(v) cfg().orbSize = v ns.ELApply() end)
	AddSlider('Arc radius', 10, 80, 1,
		function() return cfg().orbRadius end,
		function(v) cfg().orbRadius = v ns.ELApply() end,
		'Distance of the orbs from the arc center - bigger = wider crown, more space between orbs.')
	AddSlider('Arc spread (degrees)', 40, 180, 5,
		function() return cfg().orbSpread end,
		function(v) cfg().orbSpread = v ns.ELApply() end,
		'How much of the circle the 5 orbs cover. They always stay evenly spaced across it.')
	AddColor('Orb color',
		function() return cfg().orbColor end,
		function(c) cfg().orbColor = c ns.ELApply() end)
	AddPosition(
		function() return cfg().orbX end,
		function(v) cfg().orbX = v ns.ELApply() end,
		function() return cfg().orbY end,
		function(v) cfg().orbY = v ns.ELApply() end)

	AddHeader('Dodge')
	AddDesc('Shows your current dodge % as text - Elusive Brawler stacks move it on every hit. Brewmaster only. Right-drag to move when unlocked.')
	AddCheck('Enable',
		function() return cfg().dodgeEnable end,
		function(v) cfg().dodgeEnable = v ns.ELApply() end)
	AddToggleButton(function() return ns.ELDodgeIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.ELDodgeSetPreview(not ns.ELDodgeIsPreview()) end)
	AddCheck('Lock position',
		function() return cfg().dodgeLock end,
		function(v) cfg().dodgeLock = v end)
	AddCheck('Only show in combat',
		function() return cfg().dodgeCombatOnly end,
		function(v) cfg().dodgeCombatOnly = v ns.ELApply() end,
		'Unticked: the dodge text shows all the time (outside instances/combat too).')
	AddSlider('Text size', 8, 48, 1,
		function() return cfg().dodgeSize end,
		function(v) cfg().dodgeSize = v ns.ELApply() end)
	AddColor('Text color',
		function() return cfg().dodgeColor end,
		function(c) cfg().dodgeColor = c ns.ELApply() end)
	AddPosition(
		function() return cfg().dodgeX end,
		function(v) cfg().dodgeX = v ns.ELApply() end,
		function() return cfg().dodgeY end,
		function(v) cfg().dodgeY = v ns.ELApply() end)
end

local function PageCDMStacks()
	AddHeader('CDM Changes')
	AddDesc('Custom changes for Cooldown Manager icons. Works on the Blizzard viewers that EllesmereUI CDM skins, so it composes with your CDM styling.')
	local function cfg() return ns.CDSGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.CDSApply() end)

	AddHeader('Colored on cooldown')
	AddDesc('Keep Essential and Utility cooldown icons full-colour while they are on cooldown - the action-bar look - instead of the grey desaturation. The cooldown sweep and timer keep running. Out-of-range and low-mana tinting are left alone. Works on its own (does not need Enable above).')
	AddCheck('Keep icons colored on cooldown',
		function() return cfg().colorOnCD end,
		function(v) cfg().colorOnCD = v ns.CDSColorApply() end)

	AddHeader('Custom icon texts')
	AddDesc('Attach any text to any Cooldown Manager icon - e.g. "-10" above a defensive. The text shows exactly while that icon is shown. Enter the BUFF\'s spell ID (the one from the buff tooltip) - or the CDM entry\'s spell ID, both work - plus your text, then Add:')

	local idBox = MakeEditBox(content, 80, 22)
	idBox:SetPoint('TOPLEFT', 16, content.cy)
	local txtBox = MakeEditBox(content, 130, 22)
	txtBox:SetPoint('LEFT', idBox, 'RIGHT', 8, 0)
	local addBtn = MakeFlatButton(content, 'Add', 60, 22)
	addBtn:SetPoint('LEFT', txtBox, 'RIGHT', 8, 0)
	local function doAddText()
		local id = tonumber(idBox:GetText())
		if not id or id <= 0 then Msg('enter a spell ID first') return end
		local list = cfg().customTexts
		list[#list + 1] = {
			spellID = math.floor(id),
			text = txtBox:GetText() or '',
			x = 0, y = 30, size = 14,
			color = { 1, 1, 1, 1 },
		}
		ns.CDSApply()
		idBox:SetText('')
		txtBox:SetText('')
		ShowPage('cdmstacks', true)
	end
	addBtn:SetScript('OnClick', doAddText)
	txtBox:SetScript('OnEnterPressed', function(self) doAddText() self:ClearFocus() end)
	Track(idBox) Track(txtBox) Track(addBtn)
	content.cy = content.cy - 32

	local list = cfg().customTexts or {}
	if #list == 0 then AddDesc('No custom texts yet.') end
	for idx, e in ipairs(list) do
		-- row 1: icon + name + id, remove button
		local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
		fs:SetPoint('TOPLEFT', 16, content.cy)
		fs:SetText(format('|T%s:16|t %s |cff888888(%d)|r', ns.CdSpellTexture(e.spellID or 0), ns.CdSpellName(e.spellID or 0), e.spellID or 0))
		Track(fs)
		local rem = MakeFlatButton(content, 'Remove', 70, 20)
		rem:SetPoint('TOPLEFT', 380, content.cy + 3)
		rem:SetScript('OnClick', function()
			table.remove(cfg().customTexts, idx)
			ns.CDSApply()
			ShowPage('cdmstacks', true)
		end)
		Track(rem)
		content.cy = content.cy - 24

		-- row 2: text / size / x / y / colour editors
		local function lbl(x, t)
			local l = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlightSmall')
			l:SetPoint('TOPLEFT', x, content.cy - 5)
			l:SetText(t)
			l:SetTextColor(0.65, 0.65, 0.7)
			Track(l)
		end
		local function numBox(x, w, get, set)
			local b = MakeEditBox(content, w, 20)
			b:SetPoint('TOPLEFT', x, content.cy)
			b:SetJustifyH('CENTER')
			b:SetText(tostring(get()))
			local function commit(self)
				local n = tonumber(self:GetText())
				if n then set(n) ns.CDSApply() end
				self:SetText(tostring(get()))
			end
			b:SetScript('OnEnterPressed', function(self) commit(self) self:ClearFocus() end)
			b:HookScript('OnEditFocusLost', commit)
			Track(b)
		end

		lbl(20, 'Text')
		local tb = MakeEditBox(content, 100, 20)
		tb:SetPoint('TOPLEFT', 48, content.cy)
		tb:SetText(e.text or '')
		local function commitT(self)
			e.text = self:GetText() or ''
			ns.CDSApply()
		end
		tb:SetScript('OnEnterPressed', function(self) commitT(self) self:ClearFocus() end)
		tb:HookScript('OnEditFocusLost', commitT)
		Track(tb)

		lbl(160, 'Size')
		numBox(188, 36, function() return e.size or 14 end, function(v) e.size = math.floor(v) end)
		lbl(236, 'X')
		numBox(250, 44, function() return e.x or 0 end, function(v) e.x = math.floor(v) end)
		lbl(304, 'Y')
		numBox(318, 44, function() return e.y or 30 end, function(v) e.y = math.floor(v) end)

		-- colour swatch
		local sw = CreateFrame('Button', nil, content)
		sw:SetSize(18, 18)
		sw:SetPoint('TOPLEFT', 380, content.cy - 1)
		local bord = sw:CreateTexture(nil, 'BACKGROUND')
		bord:SetPoint('TOPLEFT', -1, 1)
		bord:SetPoint('BOTTOMRIGHT', 1, -1)
		bord:SetColorTexture(0, 0, 0, 1)
		sw.tex = sw:CreateTexture(nil, 'ARTWORK')
		sw.tex:SetAllPoints()
		local function swRefresh()
			local c = e.color or { 1, 1, 1 }
			sw.tex:SetColorTexture(c[1] or 1, c[2] or 1, c[3] or 1, 1)
		end
		swRefresh()
		sw:SetScript('OnClick', function()
			local c = e.color or { 1, 1, 1 }
			local r, g, b = c[1] or 1, c[2] or 1, c[3] or 1
			local function applyNow()
				local nr, ng, nb = ColorPickerFrame:GetColorRGB()
				e.color = { nr, ng, nb, 1 }
				swRefresh()
				ns.CDSApply()
			end
			if ColorPickerFrame.SetupColorPickerAndShow then
				ColorPickerFrame:SetupColorPickerAndShow({
					r = r, g = g, b = b, hasOpacity = false,
					swatchFunc = applyNow,
					cancelFunc = function() e.color = { r, g, b, 1 } swRefresh() ns.CDSApply() end,
				})
			end
		end)
		Tooltip(sw, 'Text colour')
		Track(sw)
		content.cy = content.cy - 30
	end

	AddHeader('Show "1" on buff stacks')
	AddDesc('EllesmereUI shows a buff icon\'s stack count only from 2 upward - at one stack the icon is bare. For the buffs you add here, a "1" rides the CDM buff icon whenever the icon is showing as an active buff; the moment the real stack count appears (2+), this text hides. Works in combat (no aura reads - purely icon-state driven). Enter the BUFF\'s spell ID (needs Enable at the top of this page):')

	local sBox = MakeEditBox(content, 80, 22)
	sBox:SetPoint('TOPLEFT', 16, content.cy)
	local sAdd = MakeFlatButton(content, 'Add', 60, 22)
	sAdd:SetPoint('LEFT', sBox, 'RIGHT', 8, 0)
	local function doAddStack()
		local id = tonumber(sBox:GetText())
		if not id or id <= 0 then Msg('enter a spell ID first') return end
		local list = cfg().stackOnes
		list[#list + 1] = { spellID = math.floor(id) }
		ns.CDSStackOnesApply()
		sBox:SetText('')
		ShowPage('cdmstacks', true)
	end
	sAdd:SetScript('OnClick', doAddStack)
	sBox:SetScript('OnEnterPressed', function(self) doAddStack() self:ClearFocus() end)
	Track(sBox) Track(sAdd)
	content.cy = content.cy - 32

	local slist = cfg().stackOnes or {}
	if #slist == 0 then AddDesc('No buffs added yet.') end
	for idx, e in ipairs(slist) do
		local fs2 = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
		fs2:SetPoint('TOPLEFT', 16, content.cy)
		fs2:SetText(format('|T%s:16|t %s |cff888888(%d)|r',
			ns.CdSpellTexture(e.spellID or 0), ns.CdSpellName(e.spellID or 0), e.spellID or 0))
		Track(fs2)
		local rem2 = MakeFlatButton(content, 'Remove', 70, 20)
		rem2:SetPoint('TOPLEFT', 380, content.cy + 3)
		rem2:SetScript('OnClick', function()
			table.remove(cfg().stackOnes, idx)
			ns.CDSStackOnesApply()
			ShowPage('cdmstacks', true)
		end)
		Track(rem2)
		content.cy = content.cy - 24
	end

	AddSlider('"1" text size', 8, 40, 1,
		function() return cfg().stackOneSize or 14 end,
		function(v) cfg().stackOneSize = v ns.CDSStackOnesApply() end)
	AddSlider('"1" X offset', -60, 60, 1,
		function() return cfg().stackOneX or 0 end,
		function(v) cfg().stackOneX = v ns.CDSStackOnesApply() end,
		'Offset from the icon center.')
	AddSlider('"1" Y offset', -60, 60, 1,
		function() return cfg().stackOneY or 30 end,
		function(v) cfg().stackOneY = v ns.CDSStackOnesApply() end)
end

local function PageTargetedSpells()
	AddHeader('Targeted Spells')
	AddDesc('Tweaks for the standalone "Targeted Spells" addon.')
	if not ns.hasTargetedSpells then
		AddDesc('|cffffd100The Targeted Spells addon is not loaded|r - the setting below is saved but does nothing until the addon is enabled (on PTR you may also need "Load out of date AddOns").')
	end
	AddCheck('Swipe: start colored, fill to black',
		function() return ns.TSGetCfg().reverse end,
		function(v) ns.TSGetCfg().reverse = v ns.TSApply() end,
		'Flips the cast swipe on the icons: the cast starts fully colored and darkens as it completes. Off = the addon\'s default (starts dark, clears to color by the end of the cast). The addon\'s own "Show Swipe" setting must be on for any swipe to render.')
end

local function PageBloodDK()
	AddHeader('Death Knight')
	AddHeader('Death and Decay timer')
	AddDesc('Puts a countdown on the Death and Decay icon in the Cooldown Manager showing how many seconds the patch is still on the floor. Use it to know how long you can keep standing in it, and when you need to step back into it to keep the buff up. Hides itself when the patch expires.')
	local function cfg() return ns.CDSGetCfg() end
	AddCheck('Enable D&D timer',
		function() return cfg().dndEnable end,
		function(v) cfg().dndEnable = v ns.CDSApply() end)
	AddSlider('Timer text size', 8, 40, 1,
		function() return cfg().dndSize end,
		function(v) cfg().dndSize = v ns.CDSApply() end)
	AddSlider('Timer text X offset', -60, 60, 1,
		function() return cfg().dndX end,
		function(v) cfg().dndX = v ns.CDSApply() end,
		'Offset from the icon center.')
	AddSlider('Timer text Y offset', -60, 60, 1,
		function() return cfg().dndY end,
		function(v) cfg().dndY = v ns.CDSApply() end)
end

local function PageShiningLight()
	AddHeader('Paladin')
	AddHeader('WoG Charges')
	if ns.SLDisabled then
		AddDesc('|cffffd100Requires game version 12.1.|r WoG Charges is built on the 12.1 buff system and is disabled on this game version - nothing will show in game. Your settings and profiles are kept, and the feature turns on automatically once this client is on 12.1 or newer.')
		return
	end
	AddDesc("|cffffd100How to read the bar.|r Every third Shield of the Righteous grants a free, instant Word of Glory. The bar shows both halves of that at once: how many free Word of Glory procs you have banked, and how far along you are towards the next one.")
	AddDesc("|cffffd100Why 3 segments per proc.|r One banked free Word of Glory fills |cffffd2003 segments|r in the Free WoG colour - three, because three Shield of the Righteous casts is what earned it. Six filled means two are banked. Anything filled past that is in the Charges colour and counts your progress towards the next proc: 1 segment = 1 Shield of the Righteous cast.")
	AddDesc("|cffffd100Why 8 segments.|r Eight is the most the bar can ever need: 2 banked procs (6 segments) plus 2 casts of progress (2 segments). A third cast completes a proc, so the bar can never hold 9. Reading it at a glance - 3 = one free WoG, 6 = two free WoG, 7 or 8 = two banked and building the next.")
	AddDesc("The corner numbers, if enabled, are the time left on the free proc (left) and on the charge window (right).")
	AddDesc("|cffffd100Works in combat, including keys.|r Charges come from your own Shield of the Righteous casts, which stay readable everywhere, and the free procs are read off the game's own Cooldown Manager buff icon rather than from aura data. One caveat: the addon has to find that icon once while spell IDs are readable, so if it has never found it before you zone into a key it cannot start there. Once found it is remembered for the session.")
	local function cfg() return ns.SLGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.SLApply() end)
	AddToggleButton(function() return ns.SLIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.SLSetPreview(not ns.SLIsPreview()) end)
	AddCheck('Lock position',
		function() return cfg().lock end,
		function(v) cfg().lock = v end)

	AddHeader('Bar')
	AddCheck('Show bar',
		function() return cfg().barEnable end,
		function(v) cfg().barEnable = v ns.SLApply() end,
		'8 boxed segments growing left to right: banked free WoGs recolor 3 segments each, charges continue after them.')
	AddCheck('Always show bar',
		function() return cfg().alwaysShow end,
		function(v) cfg().alwaysShow = v ns.SLApply() end,
		'Keep the empty boxed bar visible with no charges and no free procs, so the layout never shifts.')
	AddDropdown('Bar layer (strata)', {
		{ value = 'LOW',    text = 'Low' },
		{ value = 'MEDIUM', text = 'Medium (default)' },
		{ value = 'HIGH',   text = 'High - above CDM icons' },
		{ value = 'DIALOG', text = 'Dialog - above almost everything' },
	}, function() return cfg().barStrata end,
		function(v) cfg().barStrata = v ns.SLApply() end)
	AddCheck('Show corner timer texts',
		function() return cfg().showTimers end,
		function(v) cfg().showTimers = v end,
		'Remaining time of the free proc (left) and the building charges (right), plain seconds.')
	AddSlider('Width', 100, 800, 1,
		function() return cfg().width end,
		function(v) cfg().width = v ns.SLApply() end)
	AddSlider('Height', 4, 40, 1,
		function() return cfg().height end,
		function(v) cfg().height = v ns.SLApply() end)
	AddSlider('Timer text size', 8, 24, 1,
		function() return cfg().textSize end,
		function(v) cfg().textSize = v ns.SLApply() end)
	AddSlider('Background opacity', 0, 1, 0.05,
		function() return cfg().bgAlpha end,
		function(v) cfg().bgAlpha = v ns.SLApply() end,
		'Empty cells use the Background color at this opacity: 1 = solid, around 0.7 matches the EllesmereUI cast bar background, 0 = invisible.')
	AddColor('Background color',
		function() return cfg().bgColor end,
		function(c) cfg().bgColor = c ns.SLApply() end)
	AddColor('Free WoG color',
		function() return cfg().freeColor end,
		function(c) cfg().freeColor = c ns.SLApply() end)
	AddCheck('True stacks above the bar (engine)',
		function() return ns.SLGetCfg().engineDisplay end,
		function(v) ns.SLGetCfg().engineDisplay = v ns.SLApply() end,
		'Two engine-rendered displays above the bar - the REAL stack count and remaining time of the free WoG (left) and the charge buff (right), written by the game itself. Correct even in M+ where addons cannot read the buffs. Display only: it cannot fill the bar cells. Experimental 12.1 API - if nothing shows, check Diagnostics for the engine slots line.')
	AddButton('Diagnostics (to chat)', function() ns.SLTest() end, 200)
	AddColor('Charges color',
		function() return cfg().chargeColor end,
		function(c) cfg().chargeColor = c ns.SLApply() end)
	AddPosition(
		function() return cfg().barX end,
		function(v) cfg().barX = v ns.SLApply() end,
		function() return cfg().barY end,
		function(v) cfg().barY = v ns.SLApply() end)

end

local function PageMage()
	AddHeader('Mage')
	AddHeader('Alter Time Health')
	AddDesc('After you cast Alter Time (342245), shows the health percentage you had at the moment of the cast - the value Alter Time will return you to. It stays on screen until the Alter Time buff fades, you activate the return, or you die. Works in restricted combat: the health value is rendered by the engine, never read. Right-drag to move while Preview is on and position is unlocked.')
	local function cfg() return ns.MageGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.MageApply() end)
	AddToggleButton(function() return ns.MageIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.MageSetPreview(not ns.MageIsPreview()) end)
	AddCheck('Lock position',
		function() return cfg().lock end,
		function(v) cfg().lock = v ns.MageApply() end,
		'Prevents moving the text with right-drag.')
	AddSlider('Text size', 8, 60, 1,
		function() return cfg().size end,
		function(v) cfg().size = v ns.MageApply() end)
	AddCheck('Anchor to Alter Time CDM buff icon',
		function() return cfg().anchorCDM end,
		function(v) cfg().anchorCDM = v ns.MageApply() end,
		'Rides the Alter Time buff icon in the Cooldown Manager (offsets below). When the icon cannot be found, falls back to the free screen position. Preview always shows at the free position.')
	AddSlider('Icon offset X', -60, 60, 1,
		function() return cfg().anchorX end,
		function(v) cfg().anchorX = v ns.MageApply() end,
		'Offset from the buff icon center, used while anchored.')
	AddSlider('Icon offset Y', -60, 60, 1,
		function() return cfg().anchorY end,
		function(v) cfg().anchorY = v ns.MageApply() end)
	AddPosition(
		function() return cfg().x end,
		function(v) cfg().x = v ns.MageApply() end,
		function() return cfg().y end,
		function(v) cfg().y = v ns.MageApply() end)
end

local function PageControlUndead()
	AddHeader('Control Undead timer')
	local function cfg() return ns.CUGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.CUApply() end)
	AddToggleButton(function() return ns.CUIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.CUSetPreview(not ns.CUIsPreview()) end)
	AddCheck('Lock position',
		function() return cfg().lock end,
		function(v) cfg().lock = v end)

	AddHeader('Timing')
	AddCheck('Hide when the minion is lost',
		function() return cfg().hideOnPetLost end,
		function(v) cfg().hideOnPetLost = v ns.CUApply() end,
		'Clears the icon as soon as the charmed undead dies or breaks free, instead of letting it count down to zero on nothing.')
	AddSlider('Warning threshold (seconds)', 0, 120, 5,
		function() return cfg().warnAt end,
		function(v) cfg().warnAt = v ns.CUApply() end,
		'The countdown switches to the warning colour below this many seconds.')

	AddHeader('Icon')
	AddSlider('Icon size', 16, 128, 1,
		function() return cfg().iconSize end,
		function(v) cfg().iconSize = v ns.CUApply() end)
	AddSlider('Countdown text size (0 = auto)', 0, 40, 1,
		function() return cfg().textSize end,
		function(v) cfg().textSize = v ns.CUApply() end)
	AddCheck('Cooldown swipe',
		function() return cfg().showSwipe end,
		function(v) cfg().showSwipe = v ns.CUApply() end,
		'Darkened sweep across the icon as the control time runs out.')
	AddColor('Countdown colour',
		function() return cfg().textColor end,
		function(c) cfg().textColor = c ns.CUApply() end)
	AddColor('Warning colour',
		function() return cfg().warnColor end,
		function(c) cfg().warnColor = c ns.CUApply() end)
	AddDropdown('Layer (strata)', {
		{ value = 'LOW',    text = 'Low' },
		{ value = 'MEDIUM', text = 'Medium (default)' },
		{ value = 'HIGH',   text = 'High - above CDM icons' },
		{ value = 'DIALOG', text = 'Dialog - above almost everything' },
	}, function() return cfg().strata end,
		function(v) cfg().strata = v ns.CUApply() end)
	AddPosition(
		function() return cfg().x end,
		function(v) cfg().x = v ns.CUApply() end,
		function() return cfg().y end,
		function(v) cfg().y = v ns.CUApply() end)
	AddButton('Diagnostics (to chat)', function() ns.CUTest() end, 200)
end

-- One page per CLASS rather than per spec: Death and Decay is a Blood tool and
-- Control Undead is not, but a player looking for either goes to "Death
-- Knight" and does not want to guess which of two entries it lives under.
local function PageDeathKnight()
	if ns.hasCDMStacks then PageBloodDK() end
	if ns.hasControlUndead then PageControlUndead() end
end

local function PageFieryBrand()
	AddHeader('Demon Hunter')
	AddHeader('Fiery Brand Phases')
	if ns.FBDisabled then
		AddDesc('|cffffd100Requires game version 12.1.|r Fiery Brand Phases is built on the 12.1 buff system and is disabled on this game version - nothing will show in game. Your settings and profiles are kept, and the feature turns on automatically once this client is on 12.1 or newer.')
		return
	end
	AddDesc('One icon, two phases: while the Fiery Brand BUFF is on YOU it shows colored with the buff countdown; the moment your buff ends it turns grey and keeps counting down the DEBUFF left on the target. Both timers are rendered by the game engine from the real auras, so random/talent-modified durations are always correct - this display cannot drift. Right-drag to move while Preview is on and position is unlocked.')
	local function cfg() return ns.FBGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.FBApply() end)
	AddToggleButton(function() return ns.FBIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.FBSetPreview(not ns.FBIsPreview()) end)
	AddCheck('Lock position',
		function() return cfg().lock end,
		function(v) cfg().lock = v end)

	AddHeader('Icon')
	AddSlider('Icon width', 16, 128, 1,
		function() return cfg().iconW end,
		function(v) cfg().iconW = v ns.FBApply() end)
	AddSlider('Icon height', 16, 128, 1,
		function() return cfg().iconH end,
		function(v) cfg().iconH = v ns.FBApply() end)
	AddSlider('Countdown text size (0 = auto)', 0, 40, 1,
		function() return cfg().durSize end,
		function(v) cfg().durSize = v ns.FBApply() end,
		'Size of the countdown number inside the icon.')
	AddDropdown('Layer (strata)', {
		{ value = 'LOW',    text = 'Low' },
		{ value = 'MEDIUM', text = 'Medium (default)' },
		{ value = 'HIGH',   text = 'High - above CDM icons' },
		{ value = 'DIALOG', text = 'Dialog - above almost everything' },
	}, function() return cfg().strata end,
		function(v) cfg().strata = v ns.FBApply() end)
	AddPosition(
		function() return cfg().x end,
		function(v) cfg().x = v ns.FBApply() end,
		function() return cfg().y end,
		function(v) cfg().y = v ns.FBApply() end)

	AddHeader('Phases')
	AddCheck('Desaturate in debuff phase',
		function() return cfg().desatDebuff end,
		function(v) cfg().desatDebuff = v ns.FBApply() end,
		'Grey out the icon once the buff on you has ended and only the target debuff remains.')
	AddColor('Buff phase border',
		function() return cfg().buffBorder end,
		function(c) cfg().buffBorder = c ns.FBApply() end)
	AddColor('Debuff phase border',
		function() return cfg().debuffBorder end,
		function(c) cfg().debuffBorder = c ns.FBApply() end)
	AddColor('Buff phase text',
		function() return cfg().buffText end,
		function(c) cfg().buffText = c ns.FBApply() end)
	AddColor('Debuff phase text',
		function() return cfg().debuffText end,
		function(c) cfg().debuffText = c ns.FBApply() end)
	AddButton('Diagnostics (to chat)', function() ns.FBTest() end, 200)
	AddDesc('If the icon never appears despite an active brand, run Diagnostics OUT of combat with Fiery Brand active on a target and report which spell IDs show FOUND.')
end

local function PageCrosshair()
	AddHeader('Crosshair')
	AddDesc('A crosshair at the centre of the screen - four arms around a centre gap, with an optional centre dot. Tune the size, gap, colour and opacity to taste.')
	local function cfg() return ns.CHGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.CHApply() end)
	AddCheck('Show only in combat',
		function() return cfg().combatOnly end,
		function(v) cfg().combatOnly = v ns.CHApply() end,
		'Only display the crosshair while you are in combat.')
	AddColor('Colour',
		function() return cfg().color end,
		function(c) cfg().color = c ns.CHApply() end)
	AddSlider('Opacity', 0, 1, 0.05,
		function() return cfg().opacity end,
		function(v) cfg().opacity = v ns.CHApply() end)
	AddCheck('Border',
		function() return cfg().border end,
		function(v) cfg().border = v ns.CHApply() end,
		'Draws a dark outline around the crosshair so it stays visible on any background.')
	AddColor('Border colour',
		function() return cfg().borderColor end,
		function(c) cfg().borderColor = c ns.CHApply() end)
	AddSlider('Border size', 1, 5, 1,
		function() return cfg().borderSize end,
		function(v) cfg().borderSize = v ns.CHApply() end)
	AddSlider('Arm length', 0, 60, 1,
		function() return cfg().length end,
		function(v) cfg().length = v ns.CHApply() end)
	AddSlider('Arm thickness', 1, 12, 1,
		function() return cfg().thickness end,
		function(v) cfg().thickness = v ns.CHApply() end)
	AddSlider('Centre gap', 0, 40, 1,
		function() return cfg().gap end,
		function(v) cfg().gap = v ns.CHApply() end,
		'Distance from the centre to where each arm starts.')
	AddCheck('Centre dot',
		function() return cfg().dot end,
		function(v) cfg().dot = v ns.CHApply() end)
	AddSlider('Dot size', 1, 20, 1,
		function() return cfg().dotSize end,
		function(v) cfg().dotSize = v ns.CHApply() end)
	AddPosition(
		function() return cfg().x end,
		function(v) cfg().x = v ns.CHApply() end,
		function() return cfg().y end,
		function(v) cfg().y = v ns.CHApply() end)
end

local function PageSecondaryStats()
	AddHeader('Secondary Stats')
	local function cfg() return ns.SSGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.SSApply() end)
	AddCheck('Lock position',
		function() return cfg().lock end,
		function(v) cfg().lock = v ns.SSApply() end,
		'Prevents moving the list with right-drag (and makes it click-through).')
	AddSlider('Text size', 8, 40, 1,
		function() return cfg().fontSize end,
		function(v) cfg().fontSize = v ns.SSApply() end)
	AddPosition(
		function() return cfg().x end,
		function(v) cfg().x = v ns.SSApply() end,
		function() return cfg().y end,
		function(v) cfg().y = v ns.SSApply() end)
end

local function PageReadyCheck()
	AddHeader('Ready Check Consumables')
	AddDesc('On a ready check, shows a bar of your key consumables. A |cffff5555red X|r means you are missing that buff; the number is how many you are carrying. A 0 next to an X means you cannot fix it yourself.')
	AddDesc('|cffffd100The icons are clickable|r - click one to eat/drink/apply it without opening your bags. Weapon oils apply to the correct weapon automatically.')
	local function cfg() return ns.RCGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.RCApply() end)
	AddToggleButton(function() return ns.RCIsPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.RCSetPreview(not ns.RCIsPreview()) end)
	AddDesc('Preview shows the bar with sample states so you can place it - right-drag to move.')

	AddHeader('Slots')
	AddCheck('Food', function() return cfg().showFood end,
		function(v) cfg().showFood = v ns.RCApply() end)
	AddCheck('Flask', function() return cfg().showFlask end,
		function(v) cfg().showFlask = v ns.RCApply() end)
	AddCheck('Augment Rune', function() return cfg().showRune end,
		function(v) cfg().showRune = v ns.RCApply() end)
	AddCheck('Weapon oil - main hand', function() return cfg().showOilMH end,
		function(v) cfg().showOilMH = v ns.RCApply() end)
	AddCheck('Weapon oil - off hand', function() return cfg().showOilOH end,
		function(v) cfg().showOilOH = v ns.RCApply() end,
		'Only useful if you actually dual wield or carry an off-hand that takes an oil.')

	AddHeader('Appearance')
	AddSlider('Icon size', 16, 80, 1,
		function() return cfg().iconSize end,
		function(v) cfg().iconSize = v ns.RCApply() end)
	AddSlider('Spacing', 0, 24, 1,
		function() return cfg().spacing end,
		function(v) cfg().spacing = v ns.RCApply() end)
	AddSlider('Scale', 0.5, 2, 0.05,
		function() return cfg().scale end,
		function(v) cfg().scale = v ns.RCApply() end)
	AddCheck('Lock position',
		function() return cfg().lock end,
		function(v) cfg().lock = v ns.RCApply() end)
	AddPosition(function() return cfg().x end, function(v) cfg().x = v ns.RCApply() end,
		function() return cfg().y end, function(v) cfg().y = v ns.RCApply() end)

	AddHeader('Behaviour')
	AddCheck('Show each item you carry',
		function() return cfg().showVariants end,
		function(v) cfg().showVariants = v ns.RCApply() end,
		'When you carry more than one kind of the same consumable - two different flasks, say - a row of small icons appears under the slot, one per item, each with its own count. Clicking a small icon uses that specific item. The row is exactly as wide as the icon above it, so the bar does not grow. Nothing appears when there is only one choice.')
	AddCheck('Show tooltips on hover',
		function() return cfg().showTooltip end,
		function(v) cfg().showTooltip = v ns.RCApply() end,
		'The icon, the red X and the count already say what a slot needs, so the tooltip is mostly in the way during a pull. Off = hovering the bar shows nothing.')
	AddCheck('Hide if nothing is missing',
		function() return cfg().hideWhenAllGood end,
		function(v) cfg().hideWhenAllGood = v ns.RCApply() end,
		'Only shows the bar when you actually need to fix something.')
	AddSlider('Maximum time on screen (sec)', 0, 60, 1,
		function() return cfg().duration end,
		function(v) cfg().duration = v ns.RCApply() end,
		'The bar closes as soon as everyone has answered the ready check. This is only the ceiling for a check nobody finishes - it will never sit there longer than this. 0 = no limit, wait for the check.')
end

local function PageInspect()
	AddHeader('Inspect Talents')
	AddDesc('Draws the inspected player\'s talents in a panel beside the inspect sheet - class tree left, hero talents centre, spec tree right, with the picked talents in gold. This is our OWN panel built from the inspect trait data, not Blizzard\'s talent window, so it appears with the sheet and never fights it for screen space.')
	local function cfg() return ns.INSGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.INSApply() end)
	AddCheck('Show untaken talents',
		function() return cfg().showInactive end,
		function(v) cfg().showInactive = v ns.INSApply() end,
		'On = the full tree with unspent nodes greyed out. Off = only the talents they actually took, which makes a much smaller panel.')
	AddDropdown('Dock side',
		{ { value = 'RIGHT', text = 'Right of the sheet' }, { value = 'LEFT', text = 'Left of the sheet' } },
		function() return cfg().side end,
		function(v) cfg().side = v ns.INSApply() end)
	AddSlider('Talent icon size', 14, 44, 1,
		function() return cfg().nodeSize end,
		function(v) cfg().nodeSize = v ns.INSApply() end,
		'Pixel size of each talent icon. The panel sizes itself around the tree, so this is what controls how big the whole thing is.')
	AddSlider('Node spacing', 0, 24, 1,
		function() return cfg().spacing end,
		function(v) cfg().spacing = v ns.INSApply() end,
		'Extra pixels between talent icons. Spreads the tree out without changing the icon size, which makes the connector lines between talents much easier to follow.')
	AddSlider('Background opacity', 0, 1, 0.05,
		function() return cfg().bgAlpha end,
		function(v) cfg().bgAlpha = v ns.INSApply() end)
	AddSlider('Gap', 0, 60, 1,
		function() return cfg().gap end,
		function(v) cfg().gap = v ns.INSApply() end,
		'Pixels between the inspect sheet and the talent panel.')
	AddSlider('Vertical offset', -40, 40, 1,
		function() return cfg().yOffset end,
		function(v) cfg().yOffset = v ns.INSApply() end,
		'Nudges the panel up or down relative to the inspect sheet\'s top edge.')
	AddDesc('Type |cffffd200/kirainspect|r while inspecting someone to print how many nodes were drawn and, if the panel is empty, the exact reason the trait data could not be read.')
end

local function PageAutoBuy()
	AddHeader('Auto Buy')
	AddDesc('Buys a listed item the instant a vendor window opens. Written for dungeon vendors selling a cheap situational item - Sleyin in Murder Row sells the Concealed Pistol - where opening the window, finding the item and closing again costs more time than the item saves.')
	local function cfg() return ns.ABGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v end)
	AddCheck('Allow currency / token purchases',
		function() return cfg().allowCurrency end,
		function(v) cfg().allowCurrency = v end,
		'The Concealed Pistol costs a currency rather than copper, so this is on by default - without it the pistol is never bought. Untick it and nothing bought with a currency or token is ever touched.')
	AddCheck('Answer the confirmation box',
		function() return cfg().autoConfirm end,
		function(v) cfg().autoConfirm = v end,
		'Clicks Yes on the "are you sure you wish to exchange..." box, but only in the two seconds after this addon\'s own purchase - a box you opened yourself is left alone. The delayed box used for capped seasonal currencies is never answered automatically.')
	AddDesc('One at a time, only when you have none, and never above 1 gold. Those three limits are fixed rather than adjustable - they are what stops a mistyped ID from emptying your bags, and there is no version of this feature that is better without them. Every purchase and every skip prints a line in chat.')

	AddHeader('Items to buy')
	AddDesc('Matched by item ID, never by position in the vendor list - a vendor\'s first slot changes with stock and season. Open a vendor and type |cffffd200/kirabuy|r to list every item it sells with its ID.')
	local list = cfg().items
	for i, id in ipairs(list) do
		local nm
		if _G.C_Item and _G.C_Item.GetItemNameByID then
			local ok, v = pcall(_G.C_Item.GetItemNameByID, id)
			if ok then nm = v end
		end
		local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
		fs:SetPoint('TOPLEFT', 18, content.cy)
		fs:SetText(format('%s |cff888888(%d)|r', tostring(nm or 'item'), id))
		Track(fs)
		local rem = MakeFlatButton(content, 'Remove', 70, 22)
		rem:SetPoint('TOPLEFT', 360, content.cy + 2)
		rem:SetScript('OnClick', function()
			table.remove(list, i)
			ShowPage('autogossip', true)
		end)
		Track(rem)
		content.cy = content.cy - 26
	end
	if #list == 0 then AddDesc('|cff888888No items configured - nothing will be bought.|r') end
	content.cy = content.cy - 6

	local eb = MakeEditBox(content, 120, 22)
	eb:SetPoint('TOPLEFT', 18, content.cy)
	eb:SetAutoFocus(false)
	eb:SetNumeric(true)
	Track(eb)
	local add = MakeFlatButton(content, 'Add item ID', 110, 22)
	add:SetPoint('TOPLEFT', 148, content.cy + 2)
	add:SetScript('OnClick', function()
		local id = tonumber(eb:GetText())
		if not id or id <= 0 then return end
		for _, v in ipairs(list) do if v == id then eb:SetText('') return end end
		list[#list + 1] = math.floor(id)
		eb:SetText('')
		ShowPage('autogossip', true)
	end)
	Track(add)
	content.cy = content.cy - 32
	AddDesc('Type |cffffd200/kirabuy|r at any time to print your list and, if a vendor is open, everything it sells.')
end

local function PageAutoGossip()
	AddHeader('Auto Gossip')
	AddDesc('Picks a known dialogue option the instant the gossip window opens. Written for the dungeon NPCs whose menu has exactly one answer you ever give - the disguise NPC, for instance.')
	local function cfg() return ns.AGGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v end)
	AddCheck('Only inside instances',
		function() return cfg().instanceOnly end,
		function(v) cfg().instanceOnly = v end,
		'Leaves ordinary world NPCs alone.')

	AddHeader('Options to auto-select')
	AddDesc('Matched by gossipOptionID, never by the text or by its position in the menu. Text comes back unreadable inside a key and differs on a non-English client; position moves the moment a quest adds an option above it, and auto-clicking a position means auto-clicking whatever moved into it. Talk to the NPC, then type |cffffd200/kiragossip|r to read the IDs off the menu you just saw.')
	AddDesc('The first click is automated. A second "are you sure?" box, if the option raises one, is left for you - that box exists because the thing behind it cannot be undone.')
	local list = cfg().options
	for i, id in ipairs(list) do
		local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
		fs:SetPoint('TOPLEFT', 18, content.cy)
		fs:SetText(format('gossipOptionID |cffffd200%d|r', id))
		Track(fs)
		local rem = MakeFlatButton(content, 'Remove', 70, 22)
		rem:SetPoint('TOPLEFT', 360, content.cy + 2)
		rem:SetScript('OnClick', function()
			table.remove(list, i)
			ShowPage('autogossip', true)
		end)
		Track(rem)
		content.cy = content.cy - 26
	end
	if #list == 0 then AddDesc('|cff888888No options configured - nothing will be selected.|r') end
	content.cy = content.cy - 6

	local eb = MakeEditBox(content, 120, 22)
	eb:SetPoint('TOPLEFT', 18, content.cy)
	eb:SetAutoFocus(false)
	eb:SetNumeric(true)
	Track(eb)
	local add = MakeFlatButton(content, 'Add option ID', 120, 22)
	add:SetPoint('TOPLEFT', 148, content.cy + 2)
	add:SetScript('OnClick', function()
		local id = tonumber(eb:GetText())
		if not id or id <= 0 then return end
		for _, v in ipairs(list) do if v == id then eb:SetText('') return end end
		list[#list + 1] = math.floor(id)
		eb:SetText('')
		ShowPage('autogossip', true)
	end)
	Track(add)
	content.cy = content.cy - 32
end

-- Auto Gossip and Auto Buy are the same job seen twice - answering an NPC so
-- you do not have to - and keeping them on separate pages meant checking two
-- places to find out why a dungeon vendor did nothing.
local function PageNpcAutomation()
	if ns.hasAutoGossip then PageAutoGossip() end
	if ns.hasAutoBuy then PageAutoBuy() end
end

local function PageMDITeleport()
	AddHeader('MDI Teleports')
	AddDesc('Shows each dungeon\'s teleport icon in front of the teleport options in the MDI NPC\'s gossip window. Only windows containing known teleport options are changed - normal NPCs are never touched.')
	AddCheck('Enable', function() return ns.MDIGetCfg().enable end,
		function(v) ns.MDIGetCfg().enable = v end)
	AddSlider('Icon size', 12, 40, 1,
		function() return ns.MDIGetCfg().iconSize end,
		function(v) ns.MDIGetCfg().iconSize = v end)
	AddCheck('Rename greeting to "Dungeons:"', function() return ns.MDIGetCfg().renameGreeting end,
		function(v) ns.MDIGetCfg().renameGreeting = v end)

	if ns.hasKeyVendor then
		AddHeader('Keystone Vendor')
		AddDesc('At the M+ keystone vendor, highlights the useful buys - the Keystone Container (blue), this season\'s "Set Keystone Map" entries (red), "Set Keystone Level" in your range (green) and the affix adders (blue) - renames them short, and dims everything else. Other vendors are never touched.')
		AddDesc('Open the vendor and type |cffffd200/kirakv|r to print its NPC ID, every item name, and gossip option IDs - everything needed to update IDs for a new season.')
		local function kcfg() return ns.KVGetCfg() end
		AddCheck('Enable',
			function() return kcfg().enable end,
			function(v) kcfg().enable = v ns.KVApply() end)
		AddSlider('Highlight key level from', 2, 40, 1,
			function() return kcfg().minLevel end,
			function(v) kcfg().minLevel = v ns.KVApply() end,
			'"Set Keystone Level" entries from this level up are highlighted green.')
		AddSlider('Highlight key level to', 2, 40, 1,
			function() return kcfg().maxLevel end,
			function(v) kcfg().maxLevel = v ns.KVApply() end)
		AddSlider('Highlight text size', 8, 24, 1,
			function() return kcfg().textSize end,
			function(v) kcfg().textSize = v ns.KVApply() end,
			'Keystone / level / affix entries.')
		AddSlider('Dungeon name text size', 8, 24, 1,
			function() return kcfg().mapSize end,
			function(v) kcfg().mapSize = v ns.KVApply() end,
			'The red dungeon entries - smaller than the rest so long names stay inside the button.')

		AddDesc('Vendor NPC ID (0 = detect by the items on sale instead - works even when the ID changes):')
		local eb = MakeEditBox(content, 90, 22)
		eb:SetPoint('TOPLEFT', 16, content.cy)
		eb:SetJustifyH('CENTER')
		eb:SetText(tostring(kcfg().npcID or 0))
		local function commitNpc(self)
			local n = tonumber(self:GetText())
			if n and n >= 0 then
				kcfg().npcID = math.floor(n)
				ns.KVApply()
			end
			self:SetText(tostring(kcfg().npcID or 0))
		end
		eb:SetScript('OnEnterPressed', function(self) commitNpc(self) self:ClearFocus() end)
		eb:HookScript('OnEditFocusLost', commitNpc)
		Track(eb)
		content.cy = content.cy - 32
	end
end

local function PageTrinkets()
	AddHeader('Trinkets')
	AddDesc('Your two equipped trinkets as a fixed row, with cooldown swipes. Separate from the Cooldown Manager because this one you place yourself - and because Buff Watch can anchor to it, so a proc buff can sit on the trinket that caused it.')
	local function cfg() return ns.TRGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.TRApply() end)
	AddCheck('Lock position',
		function() return cfg().lock end,
		function(v) cfg().lock = v end,
		'Only applies when no frame is attached below - an attached row is positioned by its target, not by dragging.')

	AddHeader('What to show')
	AddCheck('On-use trinkets only',
		function() return cfg().onUseOnly end,
		function(v) cfg().onUseOnly = v ns.TRApply() end,
		'Hides passive stat sticks - anything with no "Use:" line. A trinket whose item data has not loaded yet counts as on-use, so nothing you might want to press is hidden by a slow read.')
	AddCheck('Grey out while on cooldown',
		function() return cfg().desatOnCooldown end,
		function(v) cfg().desatOnCooldown = v ns.TRApply() end,
		'Desaturates the icon while recharging and restores colour the moment it is ready - readable from the corner of your eye, where a swipe angle is not. Only does anything while the icon stays visible, so pair it with the option below being off.')
	AddCheck('Hide while on cooldown',
		function() return cfg().hideOnCooldown end,
		function(v) cfg().hideOnCooldown = v ns.TRApply() end,
		'The icon disappears entirely while recharging and returns when ready. Takes precedence over greying out, since there is nothing left to grey.')

	AddHeader('Look')
	AddSlider('Icon width', 16, 120, 1,
		function() return cfg().iconW end,
		function(v) cfg().iconW = v ns.TRApply() end)
	AddSlider('Icon height', 16, 120, 1,
		function() return cfg().iconH end,
		function(v) cfg().iconH = v ns.TRApply() end)
	AddSlider('Spacing', 0, 20, 1,
		function() return cfg().spacing end,
		function(v) cfg().spacing = v ns.TRApply() end)
	AddSlider('Zoom (% trimmed per edge)', 0, 40, 1,
		function() return cfg().zoom end,
		function(v) cfg().zoom = v ns.TRApply() end,
		'0 shows the whole icon file including its built-in border. 8 is the crop the rest of the addon uses. Higher zooms further in - useful for filling a non-square icon without the art letterboxing.')
	AddSlider('Cooldown text size (0 = auto)', 0, 40, 1,
		function() return cfg().durSize end,
		function(v) cfg().durSize = v ns.TRApply() end)
	AddSlider('Cooldown text Y offset', -40, 40, 1,
		function() return cfg().durY end,
		function(v) cfg().durY = v ns.TRApply() end,
		'Moves the recharge timer up or down inside the icon.')
	AddDropdown('Grow direction', {
		{ value = 'RIGHT', text = 'Right' },
		{ value = 'LEFT', text = 'Left' },
	}, function() return cfg().grow end,
		function(v) cfg().grow = v ns.TRApply() end)

	AddHeader('Placement')
	AddDesc('Defaults to your EllesmereUI player frame. Clear the box to place it freely with the position controls instead.')
	AddEditBoxOption('Attach to frame',
		function() return cfg().attachFrame or '' end,
		function(v) cfg().attachFrame = v ns.TRApply() end)
	AddDropdown('Attach to which corner', {
		{ value = 'TOPRIGHT',    text = 'Top-right' },
		{ value = 'TOPLEFT',     text = 'Top-left' },
		{ value = 'BOTTOMRIGHT', text = 'Bottom-right' },
		{ value = 'BOTTOMLEFT',  text = 'Bottom-left' },
		{ value = 'TOP',         text = 'Top edge' },
		{ value = 'BOTTOM',      text = 'Bottom edge' },
	}, function() return cfg().attachPoint end,
		function(v) cfg().attachPoint = v ns.TRApply() end)
	AddDropdown('This row\'s corner', {
		{ value = 'AUTO',        text = 'Auto (follow grow direction)' },
		{ value = 'TOPLEFT',     text = 'Top-left' },
		{ value = 'TOPRIGHT',    text = 'Top-right' },
		{ value = 'BOTTOMLEFT',  text = 'Bottom-left' },
		{ value = 'BOTTOMRIGHT', text = 'Bottom-right' },
		{ value = 'LEFT',        text = 'Left edge' },
		{ value = 'RIGHT',       text = 'Right edge' },
		{ value = 'TOP',         text = 'Top edge' },
		{ value = 'BOTTOM',      text = 'Bottom edge' },
		{ value = 'CENTER',      text = 'Centre' },
	}, function() return cfg().attachSelfPoint end,
		function(v) cfg().attachSelfPoint = v ns.TRApply() end)
	AddDesc('|cffffd100Leave this on Auto unless you have a reason.|r A row collapses away from whatever edge it is pinned by, and Auto picks the edge that keeps it collapsing towards the anchor - growing right pins the left, growing left pins the right. Choosing a corner by hand overrides that, and an emptying row may then leave a gap instead of closing one.')
	AddDesc('|cffffd100Grow direction decides which edge of this row is pinned|r, because a row collapses away from whatever it is anchored by. Growing right pins the left edge, growing left pins the right. Set Grow to |cffffd200Left|r if you want the row to shrink towards the player frame and let a Buff Watch row attached on that side slide in.')
	AddSlider('Attach offset X', -200, 200, 1,
		function() return cfg().attachX end,
		function(v) cfg().attachX = v ns.TRApply() end)
	AddSlider('Attach offset Y', -200, 200, 1,
		function() return cfg().attachY end,
		function(v) cfg().attachY = v ns.TRApply() end)
	AddCheck('Fall back when that frame is hidden',
		function() return cfg().attachFallback end,
		function(v) cfg().attachFallback = v ns.TRApply() end)
	AddDropdown('Layer (strata)', {
		{ value = 'LOW',    text = 'Low' },
		{ value = 'MEDIUM', text = 'Medium (default)' },
		{ value = 'HIGH',   text = 'High' },
		{ value = 'DIALOG', text = 'Dialog' },
	}, function() return cfg().strata end,
		function(v) cfg().strata = v ns.TRApply() end)
	AddPosition(
		function() return cfg().x end,
		function(v) cfg().x = v ns.TRApply() end,
		function() return cfg().y end,
		function(v) cfg().y = v ns.TRApply() end)

	AddHeader('Proc overlay')
	if ns.TRProcDisabled then
		AddDesc('|cffffd100Requires game version 12.1.|r The proc overlay is built on the 12.1 buff system. The trinket icons above still work; your settings are kept and this turns on once the client updates.')
	else
	AddDesc('Covers a trinket with its own proc buff while that buff is up, and uncovers it the moment it falls off. Enter the BUFF the trinket applies - not its on-use spell, which is usually a different ID. Several IDs can be listed, separated by commas, for a trinket that applies more than one aura.')
	AddDesc('Each trinket gets its |cffffd100own|r container. One container holding both would compact - trinket 2\'s proc would slide onto trinket 1 whenever trinket 1\'s was down - and there is no way to reserve a slot in that layout.')
	AddCheck('Enable proc overlay',
		function() return cfg().procEnable end,
		function(v) cfg().procEnable = v ns.TRApply() end)
	cfg().procIDs = cfg().procIDs or { '', '' }
	AddEditBoxOption('Trinket 1 proc buff ID(s)',
		function() return cfg().procIDs[1] or '' end,
		function(v) cfg().procIDs[1] = v ns.TRApply() end)
	AddEditBoxOption('Trinket 2 proc buff ID(s)',
		function() return cfg().procIDs[2] or '' end,
		function(v) cfg().procIDs[2] = v ns.TRApply() end)
	AddSlider('Proc timer size (0 = auto)', 0, 40, 1,
		function() return cfg().procDurSize end,
		function(v) cfg().procDurSize = v ns.TRApply() end)
	AddSlider('Proc timer Y offset', -40, 40, 1,
		function() return cfg().procDurY end,
		function(v) cfg().procDurY = v ns.TRApply() end,
		'Moves the proc timer up or down inside the icon.')
	AddDesc('The proc timer shows the number alone - no trailing "s". The engine appends a unit by default, so a formatter is passed to strip it.')
	end

	AddHeader('Buff Watch')
	AddDesc('Each trinket is also its own frame - |cffffd200KiraUITrinket1|r and |cffffd200KiraUITrinket2|r - so Buff Watch can attach to one of them instead, if you would rather the whole watch row rode a trinket.')
	AddDesc('Type |cffffd200/kiratrinket|r to check what is in each slot, whether it counts as on-use, and whether the attach frame was found.')
end

local function PageReset()
	AddHeader('Instance Reset')
	AddDesc('Shows a button after you leave a season dungeon that has not been reset yet. Left-click resets all instances.')
	AddCheck('Enable', function() return GetResetCfg().enable end,
		function(v) GetResetCfg().enable = v ApplyResetSettings() end)
	AddToggleButton(function() return ns.IsResetPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.ToggleResetPreview() end)
	AddCheck('Lock position', function() return GetResetCfg().lock end,
		function(v) GetResetCfg().lock = v end, 'Prevents moving the button with right-drag.')
	AddCheck('Only show in a group', function() return GetResetCfg().groupOnly end,
		function(v) GetResetCfg().groupOnly = v ns.UpdateResetVisibility() end,
		'Hide the button while solo - no more greeting you after every reload just because a dungeon is still pending. It appears once you are in a party/raid (and disappears in instances as before).')
	AddCheck('Only show as group leader', function() return GetResetCfg().leaderOnly end,
		function(v) GetResetCfg().leaderOnly = v ns.UpdateResetVisibility() end,
		'In a group, only the leader can actually reset instances - hide the button from everyone else. Solo play is unaffected.')
	AddCheck('Only after a started key (M+)', function() return GetResetCfg().keyOnly end,
		function(v) GetResetCfg().keyOnly = v ns.UpdateResetVisibility() end,
		'The button only appears after a keystone was actually STARTED in the dungeon. Just entering on Mythic difficulty and leaving without activating the key no longer counts. Off = any season dungeon visit counts, as before.')
	AddCheck('Announce resets', function() return GetResetCfg().announce end,
		function(v) GetResetCfg().announce = v end, 'Sends a message to party/raid chat when instances are reset.')
	AddSlider('Icon size', 24, 160, 1, function() return GetResetCfg().size end,
		function(v) GetResetCfg().size = v ApplyResetSettings() end)
	AddCheck('Show label', function() return GetResetCfg().showText end,
		function(v) GetResetCfg().showText = v ApplyResetSettings() end)
	AddSlider('Label text size', 8, 32, 1, function() return GetResetCfg().textSize end,
		function(v) GetResetCfg().textSize = v ApplyResetSettings() end)
	AddDropdown('Label position', {
		{ value = 'BELOW', text = 'Below icon' },
		{ value = 'ABOVE', text = 'Above icon' },
		{ value = 'CENTER', text = 'Center of icon' },
	}, function() return GetResetCfg().textAnchor end,
		function(v) GetResetCfg().textAnchor = v ApplyResetSettings() end)
	AddPosition(
		function() return (GetResetCfg().pos or {}).x or 0 end,
		function(v) local c = GetResetCfg() c.pos = c.pos or {} c.pos.x = v ApplyResetSettings() end,
		function() return (GetResetCfg().pos or {}).y or -220 end,
		function(v) local c = GetResetCfg() c.pos = c.pos or {} c.pos.y = v ApplyResetSettings() end)
end

local function PageCd()
	AddHeader('Cooldown Pulse')
	AddDesc('Watched spells pop up mid-screen the moment they come off cooldown, then grow and fade. Spell lists are saved per class and spec.')
	AddCheck('Enable', function() return GetCdCfg().enable end,
		function(v) GetCdCfg().enable = v ApplyCdPulseSettings() end)
	AddToggleButton(function() return ns.IsCdPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.SetCdPreview(not ns.IsCdPreview()) end)
	AddCheck('Lock position', function() return GetCdCfg().lock end,
		function(v) GetCdCfg().lock = v end)
	AddCheck('Pulse while shown', function() return GetCdCfg().pulse end,
		function(v) GetCdCfg().pulse = v ApplyCdPulseSettings() end)
	AddSlider('Icon size', 24, 128, 1, function() return GetCdCfg().iconSize end,
		function(v) GetCdCfg().iconSize = v ApplyCdPulseSettings() end)
	AddSlider('Display time', 0.1, 3, 0.05, function() return GetCdCfg().lead end,
		function(v) GetCdCfg().lead = v end, 'Seconds the icon stays once the spell is ready.')
	AddSlider('Linger time', 0, 3, 0.1, function() return GetCdCfg().linger end,
		function(v) GetCdCfg().linger = v end)
	AddSlider('Fade out time', 0.1, 1, 0.05, function() return GetCdCfg().fadeOut end,
		function(v) GetCdCfg().fadeOut = v ApplyCdPulseSettings() end)
	AddSlider('Fade out scale', 1, 3, 0.1, function() return GetCdCfg().fadeScale end,
		function(v) GetCdCfg().fadeScale = v ApplyCdPulseSettings() end)
	AddSlider('Pulse scale', 1.05, 2, 0.05, function() return GetCdCfg().pulseScale end,
		function(v) GetCdCfg().pulseScale = v ApplyCdPulseSettings() end)
	AddSlider('Pulse speed', 0.1, 1, 0.05, function() return GetCdCfg().pulseSpeed end,
		function(v) GetCdCfg().pulseSpeed = v ApplyCdPulseSettings() end)
	AddPosition(
		function() return (GetCdCfg().pos or {}).x or 0 end,
		function(v) local c = GetCdCfg() c.pos = c.pos or {} c.pos.x = v ApplyCdPulseSettings() end,
		function() return (GetCdCfg().pos or {}).y or 0 end,
		function(v) local c = GetCdCfg() c.pos = c.pos or {} c.pos.y = v ApplyCdPulseSettings() end)

	AddHeader('Watched spells - ' .. CdSpecLabel())
	AddDesc('Add by spell ID (recommended) or exact name. Tick the box for spells whose cooldown changes at runtime (procs, resets).')

	local eb = MakeEditBox(content, 180, 22)
	eb:SetPoint('TOPLEFT', 16, content.cy)
	local add = MakeFlatButton(content, 'Add', 60, 22)
	add:SetPoint('LEFT', eb, 'RIGHT', 10, 0)
	local function doAdd()
		local id = CdResolveSpellID(eb:GetText() and eb:GetText():trim())
		if not id then Msg('spell not found: ' .. tostring(eb:GetText())) return end
		local spells = GetCdSpells()
		for _, v in ipairs(spells) do if v == id then eb:SetText('') return end end
		spells[#spells + 1] = id
		ApplyCdPulseSettings()
		eb:SetText('')
		ShowPage('cd', true)
	end
	add:SetScript('OnClick', doAdd)
	eb:SetScript('OnEnterPressed', function(self) doAdd() self:ClearFocus() end)
	Track(eb) Track(add)
	content.cy = content.cy - 32

	local spells = GetCdSpells()
	if #spells == 0 then AddDesc('No spells watched for this spec yet.') end
	for _, id in ipairs(spells) do
		local dyn = MakeToggle(content)
		dyn:SetPoint('TOPLEFT', 18, content.cy - 3)
		dyn:SetChecked(CdIsDynamic(id))
		dyn:SetScript('OnClick', function(self)
			local v = not self:GetChecked()
			self:SetChecked(v)
			GetCdDynamic()[id] = v or nil
			ns.ClearCdState(id)
			ApplyCdPulseSettings()
		end)
		Tooltip(dyn, 'Dynamic cooldown (changes at runtime)')
		local fs = content:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
		fs:SetPoint('LEFT', dyn, 'RIGHT', 12, 0)
		-- An entry you have not talented stays in the list - swap hero trees
		-- back and it works again - but it is dimmed and labelled so it is
		-- obvious why it never pulses, rather than looking broken.
		local usable = not ns.CdIsUsable or ns.CdIsUsable(id)
		fs:SetText(format('|T%s:18|t %s |cff888888(%d)|r%s',
			CdSpellTexture(id), CdSpellName(id), id,
			usable and '' or '  |cff888888- not talented|r'))
		if not usable then fs:SetAlpha(0.5) end
		Track(fs)
		local rem = MakeFlatButton(content, 'Remove', 70, 22)
		rem:SetPoint('TOPLEFT', 360, content.cy + 2)
		rem:SetScript('OnClick', function()
			for i, v in ipairs(GetCdSpells()) do
				if v == id then table.remove(GetCdSpells(), i) break end
			end
			GetCdDynamic()[id] = nil
			ApplyCdPulseSettings()
			ShowPage('cd', true)
		end)
		Track(dyn) Track(rem)
		content.cy = content.cy - 28
	end
end

local function PageHistory()
	AddHeader('Spell History')
	AddDesc('Shows icons of recently cast spells. The newest appears first; older ones slide along the chosen direction.')
	AddCheck('Enable', function() return GetHistoryCfg().enable end,
		function(v) GetHistoryCfg().enable = v ApplyHistorySettings() end)
	AddToggleButton(function() return ns.IsHistoryPreview() and 'Stop Preview' or 'Preview' end,
		function() ns.SetHistoryPreview(not ns.IsHistoryPreview()) end)
	AddCheck('Lock position', function() return GetHistoryCfg().lock end,
		function(v) GetHistoryCfg().lock = v end)
	AddCheck('Fade older icons', function() return GetHistoryCfg().fade end,
		function(v) GetHistoryCfg().fade = v ApplyHistorySettings() end)
	AddSlider('History size', 1, 15, 1, function() return GetHistoryCfg().count end,
		function(v) GetHistoryCfg().count = v ApplyHistorySettings() end)
	AddSlider('Icon size', 16, 80, 1, function() return GetHistoryCfg().iconSize end,
		function(v) GetHistoryCfg().iconSize = v ApplyHistorySettings() end)
	AddSlider('Spacing', 0, 30, 1, function() return GetHistoryCfg().spacing end,
		function(v) GetHistoryCfg().spacing = v ApplyHistorySettings() end)
	AddDropdown('Grow direction', {
		{ value = 'RIGHT', text = 'Right' },
		{ value = 'LEFT', text = 'Left' },
		{ value = 'UP', text = 'Up' },
		{ value = 'DOWN', text = 'Down' },
	}, function() return GetHistoryCfg().direction end,
		function(v) GetHistoryCfg().direction = v ApplyHistorySettings() end)
	AddPosition(
		function() return (GetHistoryCfg().pos or {}).x or 0 end,
		function(v) local c = GetHistoryCfg() c.pos = c.pos or {} c.pos.x = v ApplyHistorySettings() end,
		function() return (GetHistoryCfg().pos or {}).y or -340 end,
		function(v) local c = GetHistoryCfg() c.pos = c.pos or {} c.pos.y = v ApplyHistorySettings() end)
end

local function PageBreak()
	AddHeader('M+ Break Timer')
	AddDesc('Measures the break between resetting the dungeon and starting the next keystone, announces it in chat, and keeps daily totals per character.')
	AddCheck('Enable', function() return GetBreakCfg().enable end,
		function(v) GetBreakCfg().enable = v end, 'Announce break time in chat when a new key starts.')
	AddButton("Today's summary", function() ns.PrintBreakSummary() end)
	AddButton("Clear today's stats", function()
		local cdb = ns.GetCharDB()
		if cdb then cdb.breaks = nil cdb.breakStart = nil ns.EnsureBreakData() Msg('break stats cleared.') end
	end)
end

-- Shared copy/paste popup for profile export & import.
local strPopup
local function GetStrPopup()
	if strPopup then return strPopup end
	local f = CreateFrame('Frame', 'KiraStrPopup', UIParent, 'BackdropTemplate')
	f:SetSize(460, 300)
	f:SetPoint('CENTER')
	f:SetFrameStrata('FULLSCREEN_DIALOG')
	if f.SetBackdrop then
		f:SetBackdrop({ bgFile = 'Interface\\Buttons\\WHITE8X8', edgeFile = 'Interface\\Buttons\\WHITE8X8', edgeSize = 1 })
		f:SetBackdropColor(C_BG[1], C_BG[2], C_BG[3], 0.98)
		f:SetBackdropBorderColor(0, 0, 0, 1)
	end
	f:SetMovable(true)
	f:EnableMouse(true)
	f:RegisterForDrag('LeftButton')
	f:SetScript('OnDragStart', f.StartMoving)
	f:SetScript('OnDragStop', f.StopMovingOrSizing)
	tinsert(UISpecialFrames, 'KiraStrPopup')

	f.title = f:CreateFontString(nil, 'OVERLAY', 'GameFontNormal')
	f.title:SetPoint('TOP', 0, -10)
	f.title:SetTextColor(ORANGE[1], ORANGE[2], ORANGE[3])

	f.nameLabel = f:CreateFontString(nil, 'OVERLAY', 'GameFontHighlightSmall')
	f.nameLabel:SetPoint('TOPLEFT', 14, -32)
	f.nameLabel:SetText('Profile name:')
	f.nameBox = MakeEditBox(f, 170, 20)
	f.nameBox:SetPoint('LEFT', f.nameLabel, 'RIGHT', 10, 0)

	local sf = CreateFrame('ScrollFrame', 'KiraStrPopupScroll', f, 'UIPanelScrollFrameTemplate')
	sf:SetPoint('TOPLEFT', 14, -58)
	sf:SetPoint('BOTTOMRIGHT', -32, 44)
	local eb = CreateFrame('EditBox', nil, sf)
	eb:SetMultiLine(true)
	eb:SetFontObject('ChatFontNormal')
	eb:SetAutoFocus(false)
	eb:SetWidth(400)
	eb:SetScript('OnEscapePressed', function() f:Hide() end)
	sf:SetScrollChild(eb)
	f.editbox = eb

	f.ok = MakeFlatButton(f, '', 130, 24)
	f.ok:SetPoint('BOTTOMRIGHT', -14, 12)
	f.cancel = MakeFlatButton(f, 'Close', 80, 24)
	f.cancel:SetPoint('BOTTOMLEFT', 14, 12)
	f.cancel:SetScript('OnClick', function() f:Hide() end)

	f:Hide()
	strPopup = f
	return f
end

local function ShowExportPopup()
	local str, err = ns.ExportCurrentProfile()
	local f = GetStrPopup()
	f.title:SetText('Export: ' .. tostring(ns.GetCurrentProfile()))
	f.nameLabel:Hide()
	f.nameBox:Hide()
	f.editbox:SetText(str or ('error: ' .. tostring(err)))
	f.editbox:SetCursorPosition(0)
	f.ok:SetText('Select All')
	f.ok:SetScript('OnClick', function() f.editbox:SetFocus() f.editbox:HighlightText() end)
	f:Show()
	f.editbox:SetFocus()
	f.editbox:HighlightText()
end

local function ShowImportPopup()
	local f = GetStrPopup()
	f.title:SetText('Import profile')
	f.nameLabel:Show()
	f.nameBox:Show()
	f.nameBox:SetText('Imported')
	f.editbox:SetText('')
	f.ok:SetText('Import')
	f.ok:SetScript('OnClick', function()
		local s = f.editbox:GetText()
		local nm = f.nameBox:GetText() and f.nameBox:GetText():trim()
		local ok, res = ns.ImportProfile(s, (nm ~= '' and nm) or 'Imported')
		if ok then
			f:Hide()
			Msg('profile imported and selected: ' .. tostring(res))
			ShowPage('profiles', true)
		else
			Msg('import failed: ' .. tostring(res))
		end
	end)
	f:Show()
	f.editbox:SetFocus()
end

local function PageToS()
	AddHeader('Temple of Sethraliss - Adderis and Aspix')
	AddDesc('One of the pair gains |cffffd200Storm Blessed|r (1289229) and becomes immune to damage; they trade it back and forth as they take damage. This plays a sound the moment it lands.')
	AddDesc('Sound only, deliberately. Naming the boss on screen meant reading unit names off the boss frames, and those go unreadable inside a key - so the line was least trustworthy in the one place it mattered.')
	local function cfg() return ns.ToSGetCfg() end
	AddCheck('Enable',
		function() return cfg().enable end,
		function(v) cfg().enable = v ns.ToSApply() end)
	AddCheck('Only in Temple of Sethraliss',
		function() return cfg().dungeonOnly end,
		function(v) cfg().dungeonOnly = v ns.ToSApply() end,
		'The spell ID is unique to this encounter, so leaving this off is harmless - it just means the check runs everywhere.')

	AddHeader('Sound')
	AddCheck('Play a sound',
		function() return cfg().soundEnabled end,
		function(v) cfg().soundEnabled = v ns.ToSApply() end)
	AddScrollDropdown('Sound', ns.ToSSoundOptions(),
		function() return cfg().sound end,
		function(v) cfg().sound = v end)
	AddButton('Test alert', function() ns.ToSTest() end, 170)
end

local function PageAppearance()
	AddHeader('Font')
	AddDesc('Sets one font for every KiraUI-Plugin display at once - timers, stack counts, combat text, the Ready Check bar and so on. The list comes from LibSharedMedia, so anything registered by your other addons or a media pack shows up here.')
	AddScrollDropdown('Font', ns.FontOptions(),
		function() return (_G.KiraUIChangesDB and _G.KiraUIChangesDB.font) or '' end,
		function(v)
			if _G.KiraUIChangesDB then _G.KiraUIChangesDB.font = v end
			ns.FontApply()
			ShowPage('appearance', true)  -- redraw so the sample below updates
		end)
	local cur = (_G.KiraUIChangesDB and _G.KiraUIChangesDB.font) or ''
	-- live sample in the chosen face, so you can judge it without closing the window
	local sample = content:CreateFontString(nil, 'OVERLAY')
	sample:SetPoint('TOPLEFT', 16, content.cy)
	sample:SetFont(ns.GetFont(), 18, 'OUTLINE')
	sample:SetText('12:34  99  Ready Check  Bloodlust  1234567890')
	sample:SetTextColor(1, 0.82, 0)
	Track(sample)
	content.cy = content.cy - 30
	if cur == '' then
		AddDesc('|cff888888Default (per module)|r keeps every module on the font it already used - some deliberately use Expressway when EllesmereUI or a media pack provides it. Pick a font above to override all of them.')
	elseif ns.GetFont() == _G.STANDARD_TEXT_FONT then
		AddDesc(('|cffff5555"%s" did not resolve|r - LibSharedMedia may not be loaded, or that font is no longer registered. Modules fell back to their own choice.'):format(cur))
	end
	AddDesc('The options window itself deliberately keeps Blizzard\'s interface font, so the settings stay legible whatever you pick here.')
end

local function PageProfiles()
	AddHeader('Profiles')
	AddDesc('Save your settings as named profiles and switch between them - e.g. a 1440p layout and a 1080p layout. Switching replaces ALL plugin settings with that profile and re-applies them. The active profile is remembered per character.')
	AddDesc('Active profile: |cffffd200' .. tostring(ns.GetCurrentProfile()) .. '|r')

	local opts = {}
	for _, n in ipairs(ns.GetProfileNames()) do opts[#opts + 1] = { value = n, text = n } end
	AddScrollDropdown('Profile', opts,
		function() return ns.GetCurrentProfile() end,
		function() end,
		function(v) ns.SwitchProfile(v) ShowPage('profiles', true) end)

	AddDesc('Create a new profile (copies your current settings):')
	local eb = MakeEditBox(content, 180, 22)
	eb:SetPoint('TOPLEFT', 16, content.cy)
	local mk = MakeFlatButton(content, 'Create', 70, 22)
	mk:SetPoint('LEFT', eb, 'RIGHT', 10, 0)
	local function doNew()
		local nm = eb:GetText() and eb:GetText():trim()
		if nm and nm ~= '' then ns.NewProfile(nm) eb:SetText('') ShowPage('profiles', true) end
	end
	mk:SetScript('OnClick', doNew)
	eb:SetScript('OnEnterPressed', function(self) doNew() self:ClearFocus() end)
	Track(eb) Track(mk)
	content.cy = content.cy - 34

	AddButton('Export current profile', ShowExportPopup)
	AddButton('Import profile (paste code)', ShowImportPopup)

	local cur = ns.GetCurrentProfile()
	if cur ~= 'Default' then
		AddButton('Delete profile "' .. cur .. '"', function()
			ns.SwitchProfile('Default')
			ns.DeleteProfile(cur)
			ShowPage('profiles', true)
		end)
	end
end

---------------------------------------------------------------
-- scrollbar
---------------------------------------------------------------
-- A slim bar whose THUMB LENGTH is the fraction of the page you can see and
-- whose POSITION is where you are in it. That pair is what answers "how much
-- is left" at a glance - Blizzard's UIPanelScrollFrameTemplate grip is a fixed
-- size with arrows on the ends, so it tells you neither.
--
-- The whole bar hides itself when the page fits, so short pages stay clean.
local function AttachScrollBar(sf, gap)
	local track = CreateFrame('Frame', nil, sf:GetParent())
	track:SetWidth(4)
	track:SetPoint('TOPLEFT', sf, 'TOPRIGHT', gap or 6, 0)
	track:SetPoint('BOTTOMLEFT', sf, 'BOTTOMRIGHT', gap or 6, 0)
	local bg = track:CreateTexture(nil, 'BACKGROUND')
	bg:SetAllPoints()
	bg:SetColorTexture(1, 1, 1, 0.06)

	local thumb = CreateFrame('Button', nil, track)
	thumb:SetWidth(4)
	thumb:RegisterForDrag('LeftButton')
	local ttex = thumb:CreateTexture(nil, 'ARTWORK')
	ttex:SetAllPoints()
	ttex:SetColorTexture(ACCENT[1], ACCENT[2], ACCENT[3], 0.8)

	local MIN_THUMB = 24
	local function Update()
		local range = sf:GetVerticalScrollRange() or 0
		if range <= 0.5 then track:Hide() return end
		track:Show()
		local h = track:GetHeight()
		local child = sf:GetScrollChild()
		local total = (child and child:GetHeight()) or 0
		local visible = sf:GetHeight()
		-- visible / total is literally "how much of the page is on screen"
		local frac = (total > 0) and math.min(1, visible / total) or 1
		local th = math.max(MIN_THUMB, h * frac)
		if th > h then th = h end
		thumb:SetHeight(th)
		local pos = sf:GetVerticalScroll() / range
		if pos < 0 then pos = 0 elseif pos > 1 then pos = 1 end
		thumb:ClearAllPoints()
		thumb:SetPoint('TOP', track, 'TOP', 0, -pos * (h - th))
	end
	sf.UpdateScrollBar = Update

	thumb:SetScript('OnEnter', function() ttex:SetColorTexture(ACCENT[1], ACCENT[2], ACCENT[3], 1) end)
	thumb:SetScript('OnLeave', function()
		if not thumb.dragging then ttex:SetColorTexture(ACCENT[1], ACCENT[2], ACCENT[3], 0.8) end
	end)
	thumb:SetScript('OnDragStart', function(self)
		self.dragging = true
		local _, cy = GetCursorPosition()
		self.grabY = cy / track:GetEffectiveScale()
		self.grabScroll = sf:GetVerticalScroll()
		self:SetScript('OnUpdate', function(s)
			local travel = track:GetHeight() - s:GetHeight()
			if travel <= 0 then return end
			local _, ny = GetCursorPosition()
			ny = ny / track:GetEffectiveScale()
			local range = sf:GetVerticalScrollRange()
			-- cursor travel maps onto the FREE space of the track, not the
			-- whole track, so the thumb stays under the pointer end to end
			local new = s.grabScroll + (s.grabY - ny) / travel * range
			if new < 0 then new = 0 elseif new > range then new = range end
			sf:SetVerticalScroll(new)
		end)
	end)
	thumb:SetScript('OnDragStop', function(self)
		self.dragging = nil
		self:SetScript('OnUpdate', nil)
		ttex:SetColorTexture(ACCENT[1], ACCENT[2], ACCENT[3], 0.8)
	end)

	sf:HookScript('OnVerticalScroll', Update)
	sf:HookScript('OnScrollRangeChanged', Update)
	sf:HookScript('OnSizeChanged', Update)
	return Update
end

---------------------------------------------------------------
-- nav (flat list of sections)
---------------------------------------------------------------
local function FirstLeafKey()
	for _, sec in ipairs(navTree) do
		for _, it in ipairs(sec.items) do return it.key end
	end
end

local function ClearNav()
	for _, w in ipairs(navWidgets) do w:Hide() w:SetParent(nil) end
	navWidgets = {}
	navButtons = {}
end

-- A section label. Deliberately NOT a button: there is nothing to click, so it
-- gets no highlight, no hit box and a smaller row than a page entry.
local function MakeNavHeader(title, y)
	local f = CreateFrame('Frame', nil, navContent or CONFIG)
	f:SetSize(164, 18)
	f:SetPoint('TOPLEFT', 3, -4 - y)
	local fs = f:CreateFontString(nil, 'OVERLAY')
	fs:SetFont(STANDARD_TEXT_FONT, 10, 'OUTLINE')
	fs:SetPoint('BOTTOMLEFT', 8, 2)
	fs:SetText(title:upper())
	fs:SetTextColor(ACCENT[1], ACCENT[2], ACCENT[3])
	local rule = f:CreateTexture(nil, 'ARTWORK')
	rule:SetHeight(1)
	rule:SetPoint('BOTTOMLEFT', fs, 'BOTTOMRIGHT', 6, 3)
	rule:SetPoint('BOTTOMRIGHT', f, 'BOTTOMRIGHT', -6, 3)
	rule:SetColorTexture(ACCENT[1], ACCENT[2], ACCENT[3], 0.18)
	navWidgets[#navWidgets + 1] = f
	return f
end

local function MakeNavButton(key, title, indent, yTop, onClick)
	local b = CreateFrame('Button', nil, navContent or CONFIG)
	b:SetSize(164 - indent, 24)
	b:SetPoint('TOPLEFT', 3 + indent, -4 - yTop)
	local hl = b:CreateTexture(nil, 'BACKGROUND', nil, 0)
	hl:SetAllPoints()
	hl:SetColorTexture(1, 1, 1, 0.05)
	hl:Hide()
	b.hl = hl
	local sel = b:CreateTexture(nil, 'BACKGROUND', nil, 1)
	sel:SetAllPoints()
	sel:SetColorTexture(ORANGE[1], ORANGE[2], ORANGE[3], 0.12)
	sel:Hide()
	b.sel = sel
	local bar = b:CreateTexture(nil, 'ARTWORK')
	bar:SetWidth(3)
	bar:SetPoint('TOPLEFT')
	bar:SetPoint('BOTTOMLEFT')
	bar:SetColorTexture(ORANGE[1], ORANGE[2], ORANGE[3], 1)
	bar:Hide()
	b.bar = bar
	local fs = b:CreateFontString(nil, 'OVERLAY', 'GameFontHighlight')
	fs:SetPoint('LEFT', 10, 0)
	fs:SetText(title)
	b.fs = fs
	b:SetScript('OnClick', onClick)
	b:SetScript('OnEnter', function() if currentPage ~= key then hl:Show() end end)
	b:SetScript('OnLeave', function() hl:Hide() end)
	navWidgets[#navWidgets + 1] = b
	navButtons[key] = b
	return b
end

local function NavMatches(title)
	if navFilter == '' then return true end
	return title:lower():find(navFilter, 1, true) ~= nil
end

local ROW_H, HEAD_H, HEAD_GAP = 28, 18, 6

local function RebuildNav()
	ClearNav()
	local y, drawnAny = 0, false
	for _, sec in ipairs(navTree) do
		-- A header that matches the search shows its WHOLE section: typing
		-- "raid" should surface everything in Party & Raid, not just the one
		-- tab whose own name happens to contain the word.
		local sectionHit = NavMatches(sec.title)
		local hits = {}
		for _, it in ipairs(sec.items) do
			if sectionHit or NavMatches(it.title) then hits[#hits + 1] = it end
		end
		if #hits > 0 then
			-- gap BETWEEN sections only, so a search that filters out the
			-- first ones does not leave dead space at the top
			if drawnAny then y = y + HEAD_GAP end
			drawnAny = true
			MakeNavHeader(sec.title, y)
			y = y + HEAD_H
			for _, it in ipairs(hits) do
				MakeNavButton(it.key, it.title, 10, y, function() ShowPage(it.key) end)
				y = y + ROW_H
			end
		end
	end
	for key, b in pairs(navButtons) do
		local isCurrent = (key == currentPage)
		b.sel:SetShown(isCurrent)
		b.bar:SetShown(isCurrent)
		b.hl:Hide()
		if isCurrent then
			b.fs:SetTextColor(1, 1, 1)
		else
			b.fs:SetTextColor(0.72, 0.72, 0.76)
		end
	end
	if navContent then
		navContent:SetHeight(math.max(10, y + 10))
		local sf = navContent:GetParent()
		if sf and sf.UpdateScrollBar then sf.UpdateScrollBar() end
	end
end

---------------------------------------------------------------
-- page switching
---------------------------------------------------------------
-- keepScroll: this is a REBUILD of the page you are already on (a list item
-- added, removed or reordered), not a move to a different one. Those rebuilds
-- must not throw you back to the top - you are usually working at the bottom
-- of a long list, and losing your place after every click is miserable.
--
-- Navigating between pages, and searching, both still reset to the top: a new
-- page is a new length, and a scroll position carried onto it means landing
-- somewhere arbitrary.
ShowPage = function(key, keepScroll)
	if not CONFIG or not key then return end
	if not pageBuild[key] then return end
	local prev = 0
	if keepScroll and key == currentPage then
		local ok, v = pcall(scroll.GetVerticalScroll, scroll)
		if ok and type(v) == 'number' then prev = v end
	end
	currentPage = key
	ResetContent()
	pageBuild[key]()
	local h = math.max(10, -content.cy + 20)
	content:SetHeight(h)
	-- Removing a row makes the page shorter, so the old offset can now be past
	-- the end - clamp it or the view scrolls into blank space below the content.
	if prev > 0 then
		local okH, viewH = pcall(scroll.GetHeight, scroll)
		local maxScroll = (okH and type(viewH) == 'number') and math.max(0, h - viewH) or 0
		if prev > maxScroll then prev = maxScroll end
	end
	scroll:SetVerticalScroll(prev)
	if scroll.UpdateScrollBar then scroll.UpdateScrollBar() end
	RebuildNav()
end

---------------------------------------------------------------
-- one-time window construction
---------------------------------------------------------------
local function Build()
	CONFIG = CreateFrame('Frame', 'KiraPluginConfig', UIParent, 'BackdropTemplate')
	CONFIG:SetSize(720, 520)
	CONFIG:SetPoint('CENTER')
	CONFIG:SetFrameStrata('HIGH')
	CONFIG:SetBackdrop({
		bgFile = 'Interface\\Buttons\\WHITE8X8',
		edgeFile = 'Interface\\Buttons\\WHITE8X8',
		edgeSize = 1,
	})
	CONFIG:SetBackdropColor(C_BG[1], C_BG[2], C_BG[3], 0.97)
	CONFIG:SetBackdropBorderColor(0, 0, 0, 1)
	CONFIG:SetMovable(true)
	CONFIG:EnableMouse(true)
	CONFIG:RegisterForDrag('LeftButton')
	CONFIG:SetScript('OnDragStart', CONFIG.StartMoving)
	CONFIG:SetScript('OnDragStop', CONFIG.StopMovingOrSizing)
	CONFIG:SetClampedToScreen(true)
	tinsert(UISpecialFrames, 'KiraPluginConfig')

	local titleBar = CONFIG:CreateTexture(nil, 'ARTWORK')
	titleBar:SetColorTexture(0.09, 0.09, 0.105, 1)
	titleBar:SetPoint('TOPLEFT', 1, -1)
	titleBar:SetPoint('TOPRIGHT', -1, -1)
	titleBar:SetHeight(34)

	-- thin accent line under the title bar
	local accentLine = CONFIG:CreateTexture(nil, 'ARTWORK')
	accentLine:SetColorTexture(ORANGE[1], ORANGE[2], ORANGE[3], 0.9)
	accentLine:SetPoint('TOPLEFT', 1, -35)
	accentLine:SetPoint('TOPRIGHT', -1, -35)
	accentLine:SetHeight(1)

	local title = CONFIG:CreateFontString(nil, 'OVERLAY', 'GameFontNormalLarge')
	title:SetPoint('TOPLEFT', 14, -9)
	title:SetText('|cffff7d0aKiraUI|r Plugin')

	-- The KiraUI installer's shield crest, bundled as Media\logo_shield.png.
	-- Same size and corner-overhang offset as the installer header uses
	-- (SetSize 128x72, TOPLEFT -56/+24). Hidden gracefully if the file is absent.
	local logo = CONFIG:CreateTexture(nil, 'OVERLAY')
	logo:SetSize(128, 72)
	logo:SetPoint('TOPLEFT', CONFIG, 'TOPLEFT', -56, 24)
	logo:SetTexture([[Interface\AddOns\KiraUI-Plugin\Media\logo_shield.png]])
	if logo:GetTexture() then
		title:ClearAllPoints()
		title:SetPoint('TOPLEFT', 40, -9)
	else
		logo:Hide()
	end

	local close = MakeFlatButton(CONFIG, 'X', 22, 22)
	close:SetPoint('TOPRIGHT', -6, -6)
	close:SetScript('OnClick', function() CONFIG:Hide() end)

	-- MOVE MODE: sits immediately left of the close button. Puts every enabled
	-- display on screen at once inside a labelled box you can drag, which is
	-- the thing the per-page Preview buttons cannot do - they only ever show
	-- one element, and only while you are on its page.
	if ns.hasEditMode then
		local unlock = MakeFlatButton(CONFIG, '', 22, 22)
		unlock:SetPoint('TOPRIGHT', close, 'TOPLEFT', -4, 0)
		-- Four arrows pointing out from a centre: the standard "move me" glyph.
		-- Composed from ChatFrameExpandArrow rather than an icon file, because
		-- that texture ships with every client and needs no art of our own.
		local arrows = {}
		for i2 = 1, 4 do
			local t = unlock:CreateTexture(nil, 'ARTWORK')
			t:SetTexture(ARROW_TEX)
			t:SetSize(7, 8)
			arrows[i2] = t
		end
		arrows[1]:SetPoint('CENTER', unlock, 'CENTER', 0, 6)  arrows[1]:SetRotation(math.pi / 2)
		arrows[2]:SetPoint('CENTER', unlock, 'CENTER', 0, -6) arrows[2]:SetRotation(-math.pi / 2)
		arrows[3]:SetPoint('CENTER', unlock, 'CENTER', -6, 0) arrows[3]:SetRotation(math.pi)
		arrows[4]:SetPoint('CENTER', unlock, 'CENTER', 6, 0)  arrows[4]:SetRotation(0)
		local function Paint()
			local on = ns.EditModeActive and ns.EditModeActive()
			for _, t in ipairs(arrows) do
				if on then t:SetVertexColor(ORANGE[1], ORANGE[2], ORANGE[3])
				else t:SetVertexColor(0.85, 0.85, 0.9) end
			end
		end
		Paint()
		unlock:SetScript('OnClick', function()
			if ns.EditModeToggle then ns.EditModeToggle() end
			Paint()
		end)
		unlock:HookScript('OnShow', Paint)
		Tooltip(unlock, 'Move mode|nDrag every enabled KiraUI element at once. Left-click selects, arrow keys nudge, right-click opens that element\'s settings.')
	end

	local navBG = CONFIG:CreateTexture(nil, 'BACKGROUND')
	navBG:SetColorTexture(0, 0, 0, 0.45)
	navBG:SetPoint('TOPLEFT', 1, -36)
	navBG:SetPoint('BOTTOMLEFT', 1, 1)
	navBG:SetWidth(170)

	local navSep = CONFIG:CreateTexture(nil, 'ARTWORK')
	navSep:SetColorTexture(0, 0, 0, 1)
	navSep:SetWidth(1)
	navSep:SetPoint('TOPLEFT', 171, -36)
	navSep:SetPoint('BOTTOMLEFT', 171, 1)

	-- Search box filtering the category list by words.
	local navSearch = MakeEditBox(CONFIG, 150, 20)
	navSearch:SetPoint('TOPLEFT', 11, -44)
	navSearch:SetTextInsets(5, 18, 0, 0)  -- keep typed text clear of the x
	navSearch.placeholder = navSearch:CreateFontString(nil, 'OVERLAY', 'GameFontDisableSmall')
	navSearch.placeholder:SetPoint('LEFT', 5, 0)
	navSearch.placeholder:SetText('Search...')

	-- clear button: appears while there is text, wipes it in one click
	local navClear = CreateFrame('Button', nil, navSearch)
	navClear:SetSize(16, 16)
	navClear:SetPoint('RIGHT', -2, 0)
	navClear.label = navClear:CreateFontString(nil, 'OVERLAY', 'GameFontNormal')
	navClear.label:SetPoint('CENTER', 0, 0)
	navClear.label:SetText('x')
	navClear.label:SetTextColor(0.6, 0.6, 0.65)
	navClear:SetScript('OnEnter', function(self)
		self.label:SetTextColor(ORANGE[1], ORANGE[2], ORANGE[3])
	end)
	navClear:SetScript('OnLeave', function(self)
		self.label:SetTextColor(0.6, 0.6, 0.65)
	end)
	navClear:SetScript('OnClick', function()
		navSearch:SetText('')
		navSearch:ClearFocus()
	end)
	navClear:Hide()

	navSearch:HookScript('OnTextChanged', function(self)
		local t = self:GetText() or ''
		self.placeholder:SetShown(t == '')
		navClear:SetShown(t ~= '')
		navFilter = t:lower():gsub('^%s+', ''):gsub('%s+$', '')
		RebuildNav()
		-- Back to the top on every keystroke. The filtered list is almost always
		-- shorter than what you were looking at, so a scroll position left over
		-- from browsing can sit past the end of the results - you type, matches
		-- appear, and you see an empty pane.
		if navContent then
			local sf = navContent:GetParent()
			if sf and sf.SetVerticalScroll then
				sf:SetVerticalScroll(0)
				if sf.UpdateScrollBar then sf.UpdateScrollBar() end
			end
		end
	end)
	navSearch:SetScript('OnEscapePressed', function(self)
		self:SetText('')
		self:ClearFocus()
	end)

	-- Scrollable left nav (mouse wheel) so a long feature list never overflows.
	local navScroll = CreateFrame('ScrollFrame', nil, CONFIG)
	navScroll:SetPoint('TOPLEFT', 1, -70)
	navScroll:SetPoint('BOTTOMLEFT', 1, 22)
	navScroll:SetWidth(169)
	navScroll:EnableMouseWheel(true)
	navScroll:SetScript('OnMouseWheel', function(self, delta)
		local maxScroll = self:GetVerticalScrollRange()
		local new = self:GetVerticalScroll() - delta * 40
		if new < 0 then new = 0 elseif new > maxScroll then new = maxScroll end
		self:SetVerticalScroll(new)
	end)
	navContent = CreateFrame('Frame', nil, navScroll)
	navContent:SetSize(169, 10)
	navScroll:SetScrollChild(navContent)
	-- the nav is long now that nothing folds, so it gets the same bar
	AttachScrollBar(navScroll, 2)

	-- NAV: sections in the order you tend to reach for them, each holding only
	-- pages that answer the same kind of question. A page appears exactly once.
	navTree = {}
	local function Section(title, items)
		local kept = {}
		for _, it in ipairs(items) do
			if it then kept[#kept + 1] = it end
		end
		-- a section whose every page depends on a missing addon never appears
		if #kept > 0 then navTree[#navTree + 1] = { title = title, items = kept } end
	end
	-- nil and false both mean "that addon is not here", so test truthiness:
	-- an ns.hasX flag that was never set is absent, not present.
	local function Page(cond, key, title, build)
		if not cond then return nil end
		return { key = key, title = title, build = build }
	end

	Section('General', {
		Page(true, 'appearance', 'Appearance', PageAppearance),
		Page(true, 'profiles',   'Profiles',   PageProfiles),
	})
	Section('EllesmereUI', {
		Page(ns.hasEUI,          'eui_player', 'Player',     PagePlayer),
		Page(ns.hasEUI,          'eui_target', 'Target',     PageTarget),
		Page(ns.hasEUI,          'eui_focus',  'Focus',      PageFocus),
		Page(ns.hasEUINP,        'eui_np',     'Nameplates', PageNP),
		Page(ns.hasEUIInterrupt, 'eui_int',    'Interrupt',  PageInterrupt),
	})
	Section('Unit Frames', {
		Page(ns.hasElvUI, 'absorbs', 'Absorbs',            PageAbsorbs),
		Page(ns.hasUUF,   'uuf',     'UnhaltedUnitFrames', PageUUF),
	})
	Section('Party & Raid', {
		Page(ns.hasExternals,   'externals',   'Externals',       PageExternals),
		Page(ns.hasBuffWatch,   'buffwatch',   'Buff Watch',      PageBuffWatch),
		Page(ns.hasReadyCheck,  'readycheck',  'Ready Check',     PageReadyCheck),
		Page(ns.hasRaidMarkers, 'raidmarkers', 'Raid Markers',    PageRaidMarkers),
		Page(ns.hasBloodlust,   'bloodlust',   'Bloodlust ready', PageBloodlust),
		Page(ns.hasPotion,      'potion',      'Potion ready',    PagePotion),
		Page(ns.hasInspect,     'inspect',     'Inspect Talents', PageInspect),
	})
	Section('Cooldowns', {
		Page(true,             'cd',             'Cooldown Pulse',  PageCd),
		Page(true,             'history',        'Spell History',   PageHistory),
		Page(ns.hasCDMStacks,  'cdmstacks',      'CDM Changes',     PageCDMStacks),
		Page(true,             'targetedspells', 'Targeted Spells', PageTargetedSpells),
	})
	-- Class pages keep their class colour; the section header replaces the
	-- old expandable "Class" parent.
	Section('Class', {
		Page(ns.hasCDMStacks or ns.hasControlUndead,
		                         'class_dk',    '|cffC41E3ADeath Knight|r', PageDeathKnight),
		Page(ns.hasFieryBrand,   'class_veng',  '|cffA330C9Demon Hunter|r', PageFieryBrand),
		Page(ns.hasMage,         'class_mage',  '|cff3FC7EBMage|r',         PageMage),
		Page(ns.hasElixir,       'class_brew',  '|cff00FF98Monk|r',         PageElixir),
		Page(ns.hasShiningLight, 'class_prot',  '|cffF48CBAPaladin|r',      PageShiningLight),
	})
	Section('HUD', {
		Page(ns.hasCombatText,     'combattext', 'Combat Text',     PageCombatText),
		Page(ns.hasCrosshair,      'crosshair',  'Crosshair',       PageCrosshair),
		Page(ns.hasTrinkets,       'trinkets',   'Trinkets',        PageTrinkets),
		Page(ns.hasSecondaryStats, 'secstats',   'Secondary Stats', PageSecondaryStats),
		Page(ns.hasRecup,          'recup',      'Recuperate',      PageRecuperate),
	})
	Section('Dungeons', {
		Page(true,               'reset',       'Instance Reset', PageReset),
		Page(true,               'break',       'M+ Break Timer', PageBreak),
		Page(ns.hasMDITeleport,  'mditeleport', 'MDI Teleports',  PageMDITeleport),
		Page(ns.hasAutoGossip or ns.hasAutoBuy,
		                         'autogossip',  'Auto Gossip',    PageNpcAutomation),
		Page(ns.hasToS,          'tos',         'Temple of Sethraliss', PageToS),
	})

	pageBuild = {}
	for _, sec in ipairs(navTree) do
		for _, it in ipairs(sec.items) do pageBuild[it.key] = it.build end
	end

	local hint = CONFIG:CreateFontString(nil, 'OVERLAY', 'GameFontDisableSmall')
	hint:SetPoint('BOTTOMLEFT', 10, 6)
	hint:SetText('Tip: right-drag frames in Preview to move them; position arrows nudge 1 px (Shift = 10). Slash: /kiraplugin')

	-- Version label, bottom-right, matching the KiraUI installer's style.
	local version = CONFIG:CreateFontString(nil, 'OVERLAY')
	version:SetFont(_G.STANDARD_TEXT_FONT, 9, 'OUTLINE')
	version:SetTextColor(0.80, 0.20, 0.20)
	version:SetPoint('BOTTOMRIGHT', CONFIG, 'BOTTOMRIGHT', -6, 5)
	version:SetText('v' .. PLUGIN_VERSION)

	-- Plain ScrollFrame, not UIPanelScrollFrameTemplate: the template's grip is
	-- a fixed size no matter how long the page is, so it cannot show how much
	-- is left. Wheel handling comes with the template, so it is re-added here.
	scroll = CreateFrame('ScrollFrame', 'KiraPluginConfigScroll', CONFIG)
	scroll:SetPoint('TOPLEFT', 180, -42)
	scroll:SetPoint('BOTTOMRIGHT', -22, 24)
	scroll:EnableMouseWheel(true)
	scroll:SetScript('OnMouseWheel', function(self, delta)
		local range = self:GetVerticalScrollRange()
		local new = self:GetVerticalScroll() - delta * 40
		if new < 0 then new = 0 elseif new > range then new = range end
		self:SetVerticalScroll(new)
	end)
	content = CreateFrame('Frame', nil, scroll)
	content:SetSize(500, 10)
	content.widgets = {}
	content.cy = -14
	scroll:SetScrollChild(content)
	AttachScrollBar(scroll, 6)
end

-- Used by edit mode's right-click: open the window ON a given page.
ns.ShowConfigPage = function(key)
	if not CONFIG then Build() end
	CONFIG:Show()
	ShowPage(key or currentPage or FirstLeafKey())
end

-- Show/hide, used by move mode's Settings button so the window can be pushed
-- out of the way without leaving the mode.
ns.ToggleConfig = function()
	if CONFIG and CONFIG:IsShown() then CONFIG:Hide() return false end
	ns.OpenConfig()
	return true
end

ns.OpenConfig = function()
	if not CONFIG then Build() end
	CONFIG:Show()
	ShowPage(currentPage or FirstLeafKey())
end
-- end of KiraUI-Plugin_Options.lua
