Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Join the Playtest on Steam Now: SpiritVale

Module:GameInfo/Skills: Difference between revisions

From SpiritVale Wiki
No edit summary
Tags: Mobile edit Mobile web edit
No edit summary
Tags: Mobile edit Mobile web edit
Line 1,597: Line 1,597:
:addClass("sv-skill-meta-block")
:addClass("sv-skill-meta-block")


if meta_node then wrap:node(meta_node) end
local count = 0
if req_node then wrap:node(req_node) end
 
if meta_node then
count = count + 1
local bubble = wrap:tag("div")
:addClass("sv-skill-meta-block__bubble")
:addClass("sv-skill-meta-block__bubble--meta")
bubble:node(meta_node)
end
 
if req_node then
count = count + 1
local bubble = wrap:tag("div")
:addClass("sv-skill-meta-block__bubble")
:addClass("sv-skill-meta-block__bubble--req")
bubble:node(req_node)
end
 
if count == 1 then
wrap:addClass("sv-skill-meta-block--single")
end


return wrap
return wrap

Revision as of 18:37, 7 March 2026

Module:GameInfo/Skills renders the compiled “Skill card” payload used by Module:GameInfo.

Overview

This module is a strict renderer for a compiled JSON payload (schema 1). It does not attempt compatibility fallbacks or schema guessing.

Where it is used

This module is loaded by Module:GameInfo and called through the GameInfo entry points (for example the Skills route).

Styling

This module declares a TemplateStyles source so the wrapper can load the correct CSS:

STYLE_SRC = "Module:GameInfo/styles.css"

Inputs

Arguments are read from frame.args (handled by Module:GameInfo).

Required:

  • data — JSON string payload (must decode to an object and contain schema = 1)

Optional:

  • id — HTML id for the root card. If omitted, one is generated from display name + index.
  • index — Used only for generating a stable id. Default 1.
  • notes — Wikitext content shown in the Notes popup.
  • debug — If set to 1, error output includes a nowiki preview of the first ~480 chars of the raw JSON.

Example usage (shown as text only):

{{#invoke:GameInfo|Skills|id=...|index=...|notes=...|data=...}}

Payload expectations (schema 1)

The payload is expected to contain the parts needed to build the full Skill card, including:

  • identity (name/description/sprite)
  • level (default/max)
  • meta_row (four meta cells)
  • requirements and users (optional grouped lists)
  • scaling_top, core_stats
  • tabs (mechanics, keywords, effects, events)

Values may be either:

  • Plain strings/numbers, or
  • Objects like {"text":"..."}, or
  • Series objects like {"series":[...]} (used for level-based selection and emitted as data-series)

Images and missing files

File pages are checked with a small existence cache. If a file page is missing, the renderer outputs a small “?” badge (sv-miss) instead of a redlink image.

Keyword pills

Keyword pill strings in the form Domain|Key are expanded to the Definitions template:

{{def|Domain|Key}}

Other strings are rendered as plain text.

Interactive UI hooks

The module outputs semantic hooks used by site JS:

  • Popups use data-sv-toggle and sv-hidden
  • Tabs use data-tabs and data-tabs-root
  • The custom level slider uses data-sv-slider and ARIA slider attributes

-- Module:GameInfo/Skills
-- Phase 4.1 — Native format rendering (format 1 + format 2, no adapter glue)
--
-- DEFINITIONS POLICY (strict):
-- - Only accept Definition tokens as JSON strings using \u007C, e.g. "Damage\u007CPiercing".
-- - After jsonDecode, this becomes "Damage|Piercing" and is expanded via {{def|Damage|Piercing}}.
-- - No support for:
--     * <nowiki>...</nowiki> wrapped |data=
--     * double-escaped pipes (\\u007C)
--     * template-shaped strings inside JSON (e.g. "{{def|Damage|Piercing}}")
--
-- Icons:
-- - Meta row has its own icon slot, so meta labels always use noicon=1.
-- - Keyword pills also use noicon=1 (no extra icons in pills).
-- - Native-format scaling stat pills render visible short labels/icons,
--   and use a full-pill invisible Definitions hitbox only when active.
--
-- Popups:
-- - Source nodes (.sv-tip-pop / .sv-disclose-pop) are hidden content only.
-- - All interaction is handled by Universal Popups (Common.js). No legacy popup system.
--
-- Requirements / Users (dynamic headers):
-- - Native format accepts requirements/users as maps (title -> items[]).
-- - Titles are treated as dynamic display text (no hardcoded header logic).
-- - Optional __order = ["Title A","Title B", ...] may be provided to force section order.
--   If omitted, ordering is deterministic: (preferred_order first, then remaining keys sorted).
--
-- SITEWIDE CSS ALIGNMENT:
-- - Shared UI ownership lives in Common.css + Citizen.css.
-- - This module keeps semantic GameInfo/Skills hooks, while also opting into
--   shared sitewide classes where the role already matches:
--     * sv-tip / sv-tip-btn
--     * sv-disclose / sv-disclose-btn
--     * sv-tabs / sv-tab / sv-tabpanel
--     * sv-level-* shared slider anatomy
--     * sv-card / sv-tile / sv-pill / sv-hover-lift where appropriate
-- - The CSS pass can now simplify Module:GameInfo/styles.css by leaning on
--   these shared classes instead of duplicating sitewide surface styling.

local p = {}

local GI = require("Module:GameInfo")

p.STYLE_SRC = "Module:GameInfo/styles.css"

local _file_exists_cache = {}

local _STAT_ORDER = { "str", "vit", "dex", "agi", "int", "luk" }
local _STAT_GRID_ORDER = { "str", "agi", "vit", "int", "dex", "luk" }

local _STAT_LABELS = {
	str = "STR",
	vit = "VIT",
	dex = "DEX",
	agi = "AGI",
	int = "INT",
	luk = "LUK",
}

local _STAT_DEF_TOKENS = {
	str = "Stat|Str",
	vit = "Stat|Vit",
	dex = "Stat|Dex",
	agi = "Stat|Agi",
	int = "Stat|Int",
	luk = "Stat|Luk",
}

local _STAT_TOKEN_TO_KEY = {
	["stat|str"] = "str",
	["stat|vit"] = "vit",
	["stat|dex"] = "dex",
	["stat|agi"] = "agi",
	["stat|int"] = "int",
	["stat|luk"] = "luk",
}

local _STAT_ICON_FILES = {
	str = "File:Strength.png",
	vit = "File:Vitality.png",
	dex = "File:Dexterity.png",
	agi = "File:Agility.png",
	int = "File:Intelligence.png",
	luk = "File:Luck.png",
}

local _CORE_ORDER = {
	{ field = "cost",      label = "Cost"      },
	{ field = "cast_time", label = "Cast Time" },
	{ field = "cooldown",  label = "Cooldown"  },
	{ field = "range",     label = "Range"     },
	{ field = "area",      label = "Area"      },
	{ field = "duration",  label = "Duration"  },
}

local function _trim(s)
	if s == nil then return "" end
	s = tostring(s)
	return (mw.text and mw.text.trim) and mw.text.trim(s)
		or (s:gsub("^%s+", ""):gsub("%s+$", ""))
end

local function _safe_tbl(t) return type(t) == "table" and t or {} end

local function _safe_str(s, fallback)
	s = _trim(s)
	if s == "" then return fallback or "" end
	return s
end

local function _to_int(v, fallback)
	local n = tonumber(v)
	if not n then return fallback end
	return math.floor(n + 0.0)
end

local function _slugify(s)
	s = _trim(s):lower()
	s = s:gsub("[^%w]+", "-"):gsub("%-+", "-"):gsub("^%-", ""):gsub("%-$", "")
	if s == "" then s = "item" end
	return s
end

local function _err(msg, preview)
	local box = mw.html.create("div")
		:addClass("sv-card")
		:addClass("sv-gi-error")

	box:wikitext("GameInfo/Skills error: " .. tostring(msg))
	if preview and preview ~= "" then
		box:tag("pre"):wikitext(mw.text.nowiki(preview:sub(1, 480)))
	end
	return tostring(box)
end

local function _decode_payload(frame)
	local raw = GI.arg(frame, "data", "")
	raw = _trim(raw)
	if raw == "" then return nil, "missing |data=", "" end

	local ok, decoded = pcall(mw.text.jsonDecode, raw)
	if not ok then
		return nil, "invalid json", raw
	end
	if type(decoded) ~= "table" then
		return nil, "json is not object", raw
	end

	local schema = decoded.schema
	if schema ~= 1 and schema ~= 2 then
		return nil, "unsupported schema=" .. tostring(schema), raw
	end

	return decoded, nil, ""
end

local function _normalize_file_title(page)
	page = _trim(page)
	if page == "" then return nil end

	if page:match("^[Ff]ile:") or page:match("^[Ii]mage:") then
		page = page:gsub("^[Ii]mage:", "File:"):gsub("^[Ff]ile:", "File:")
		return page
	end

	return "File:" .. page
end

local function _file_exists(file_title)
	if not file_title then return false end
	local cached = _file_exists_cache[file_title]
	if cached ~= nil then return cached end

	local exists = false
	local t = mw.title.new(file_title)
	if t then
		if t.exists then
			exists = true
		else
			local ok, fileobj = pcall(function() return t.file end)
			if ok and fileobj then exists = true end
		end
	end

	_file_exists_cache[file_title] = exists
	return exists
end

local function _question_badge()
	return mw.html.create("span")
		:addClass("sv-miss")
		:attr("aria-hidden", "true")
		:wikitext("?")
end

local function _render_file_image(file_page, alt, size_px)
	local title = _normalize_file_title(file_page)
	if not title or not _file_exists(title) then
		return _question_badge()
	end

	local size = tostring(size_px or 48) .. "px"
	return mw.html.create("span")
		:addClass("sv-img")
		:wikitext(string.format(
			"[[%s|%s|link=|alt=%s]]",
			title,
			size,
			mw.text.nowiki(_safe_str(alt, ""))
		))
end

local function _apply_value(node, val, level)
	if val == nil then
		node:wikitext("—")
		return
	end

	if type(val) ~= "table" then
		node:wikitext(mw.text.nowiki(tostring(val)))
		return
	end

	local series = val.series
	if type(series) == "table" and #series > 0 then
		node:attr("data-series", mw.text.jsonEncode(series))
		local v = series[level] or series[#series]
		if v == nil then v = "—" end
		node:wikitext(mw.text.nowiki(tostring(v)))
		return
	end

	if val.text ~= nil then
		node:wikitext(mw.text.nowiki(tostring(val.text)))
		return
	end

	node:wikitext("—")
end

local function _has_pipe(s)
	s = _trim(s)
	return s ~= "" and s:find("|", 1, true) ~= nil
end

local function _render_def_token(frame, s, noicon, extra_args)
	s = _trim(s)
	if s == "" then return nil end

	local bar = s:find("|", 1, true)
	if not bar then
		return mw.text.nowiki(s)
	end

	local domain = _trim(s:sub(1, bar - 1))
	local key    = _trim(s:sub(bar + 1))
	if domain == "" or key == "" then
		return mw.text.nowiki(s)
	end

	local args = { domain, key }
	if noicon then
		args.noicon = "1"
	end
	if type(extra_args) == "table" then
		for k, v in pairs(extra_args) do
			if v ~= nil and tostring(v) ~= "" then
				args[k] = tostring(v)
			end
		end
	end

	return frame:expandTemplate{ title = "def", args = args }
end

local function _meta_lines_def(frame, lines)
	lines = _safe_tbl(lines)
	local wrap = mw.html.create("span"):addClass("sv-meta-lines")
	local any = false
	for _, ln in ipairs(lines) do
		local s = _trim(ln)
		if s ~= "" then
			local wt = _render_def_token(frame, s, true) or mw.text.nowiki(s)
			wrap:tag("span"):wikitext(wt)
			any = true
		end
	end
	if not any then
		wrap:tag("span"):wikitext("—")
	end
	return wrap
end

local function _split_def_token(s)
	s = _trim(s)
	if s == "" then return nil, nil end

	local bar = s:find("|", 1, true)
	if not bar then return nil, nil end

	local domain = _trim(s:sub(1, bar - 1))
	local key = _trim(s:sub(bar + 1))
	if domain == "" or key == "" then return nil, nil end

	return domain, key
end

local function _keyword_display_text(pill_text)
	pill_text = _trim(pill_text)
	if pill_text == "" then return "" end

	local domain, key = _split_def_token(pill_text)
	if not domain or not key then
		return pill_text
	end

	return key
end

local function _build_keyword_pill(frame, parent, pill_text)
	pill_text = _trim(pill_text)
	if pill_text == "" then return end

	local visible_text = _keyword_display_text(pill_text)
	local has_def = _has_pipe(pill_text)

	local pill = parent:tag("span")
		:addClass("sv-pill")
		:addClass("sv-pill--value")
		:addClass("sv-mech-keyword-pill")

	pill:tag("span")
		:addClass("sv-mech-keyword-pill__label")
		:wikitext(mw.text.nowiki(visible_text ~= "" and visible_text or pill_text))

	if has_def then
		local wt = _render_def_token(frame, pill_text, true, { pill = "1", fill = "1" })
		if wt then
			pill:tag("span")
				:addClass("sv-mech-keyword-pill__hit")
				:attr("aria-hidden", "true")
				:wikitext(wt)
		end
	end
end

local function _is_blankish(v)
	if v == nil then return true end
	local s = _trim(v)
	return s == "" or s == "—" or s == "-" or s == "--"
end

local function _value_has_content(val)
	if val == nil then return false end

	if type(val) ~= "table" then
		return not _is_blankish(val)
	end

	local series = val.series
	if type(series) == "table" then
		for _, item in ipairs(series) do
			if not _is_blankish(item) then
				return true
			end
		end
		return false
	end

	if val.text ~= nil then
		return not _is_blankish(val.text)
	end

	return false
end

local function _stat_key_from_token(token)
	token = _trim(token):lower()
	if token == "" then return nil end
	return _STAT_TOKEN_TO_KEY[token]
end

local function _normalize_stat_scaling_slots(stat_scaling)
	local slots = {}
	for _, key in ipairs(_STAT_ORDER) do
		slots[key] = nil
	end

	for _, it in ipairs(_safe_tbl(stat_scaling)) do
		it = _safe_tbl(it)

		local legacy_tok = _safe_str(it.stat_wt or it.stat or "", "")
		if legacy_tok ~= "" then
			local key = _stat_key_from_token(legacy_tok)
			if key then
				slots[key] = it.value
			end
		end

		for k, v in pairs(it) do
			local lk = _trim(k):lower()
			if _STAT_LABELS[lk] ~= nil then
				slots[lk] = v
			end
		end
	end

	return slots
end

local function _split_lines(s)
	if s == nil then return {} end
	s = tostring(s)
	s = s:gsub("\r\n", "\n"):gsub("\r", "\n")
	if mw.text and mw.text.split then
		return mw.text.split(s, "\n", true)
	end
	local out = {}
	for line in (s .. "\n"):gmatch("(.-)\n") do
		table.insert(out, line)
	end
	return out
end

local function _render_notes_body(parent, notes_wt)
	notes_wt = _trim(notes_wt)
	if notes_wt == "" then
		parent:wikitext("—")
		return
	end

	notes_wt = notes_wt:gsub("(^|\n)%s+([*#])", "%1%2")

	local lines = _split_lines(notes_wt)
	local i = 1
	local wrote_any = false

	local function is_list_line(ln)
		return type(ln) == "string" and ln:match("^[*#]") ~= nil
	end

	while i <= #lines do
		local ln = lines[i] or ""
		if _trim(ln) == "" then
			i = i + 1
		elseif is_list_line(ln) then
			local mark = ln:sub(1, 1)
			local tagname = (mark == "#") and "ol" or "ul"

			local items = {}
			while i <= #lines do
				local l = lines[i] or ""
				if _trim(l) == "" then
					i = i + 1
					break
				end
				if not l:match("^" .. mark) then break end

				local txt = l:gsub("^" .. mark .. "+%s*", "")
				txt = _trim(txt)
				if txt ~= "" then table.insert(items, txt) end
				i = i + 1
			end

			if #items > 0 then
				local list = parent:tag(tagname)
				for _, txt in ipairs(items) do
					list:tag("li"):wikitext(txt)
				end
				wrote_any = true
			end
		else
			local block = {}
			while i <= #lines do
				local l = lines[i] or ""
				if _trim(l) == "" then
					i = i + 1
					break
				end
				if is_list_line(l) then break end
				table.insert(block, l)
				i = i + 1
			end

			if #block > 0 then
				local joined = table.concat(block, "<br />\n")
				parent:tag("div"):addClass("sv-note-par"):wikitext(joined)
				wrote_any = true
			end
		end
	end

	if not wrote_any then
		parent:wikitext(mw.text.nowiki(notes_wt))
	end
end

local function _build_notes_tip(frame, root_id, notes_wt)
	notes_wt = _trim(notes_wt)
	if notes_wt == "" then return nil end

	local tip_id = root_id .. "-notes"
	local wrap = mw.html.create("div"):addClass("sv-tip")

	local btn = wrap:tag("span")
		:addClass("sv-tip-btn")
		:addClass("sv-tip-btn--icon")
		:addClass("sv-hover-lift")
		:attr("role", "button")
		:attr("tabindex", "0")
		:attr("data-sv-toggle", "1")
		:attr("data-sv-pop", "hover")
		:attr("data-sv-pop-size", "sm")
		:attr("aria-label", "Notes")
		:attr("aria-controls", tip_id)
		:attr("aria-expanded", "false")

	btn:tag("span")
		:addClass("sv-ico")
		:addClass("sv-ico--info")
		:attr("aria-hidden", "true")
		:wikitext("i")

	local pop = wrap:tag("div")
		:addClass("sv-tip-pop")
		:addClass("sv-hidden")
		:attr("hidden", "hidden")
		:attr("id", tip_id)
		:attr("aria-label", "Notes")

	local head = pop:tag("div"):addClass("sv-tip-pop-head")
	head:tag("div"):addClass("sv-tip-pop-title"):wikitext("Notes")

	local body = pop:tag("div"):addClass("sv-tip-pop-body")
	_render_notes_body(body, notes_wt)

	return wrap
end

local function _normalize_overcap_sources(sources, total_bonus)
	sources = _safe_tbl(sources)

	local out = {}
	local missing = 0
	local explicit_sum = 0

	for _, s in ipairs(sources) do
		local page = ""
		local lv = nil

		if type(s) == "string" then
			page = _trim(s)
		elseif type(s) == "table" then
			page = _safe_str(s.page or s[1] or "", "")
			lv = _to_int(s.levels or s.level or s[2], nil)
		end

		if page ~= "" then
			if lv and lv > 0 then
				explicit_sum = explicit_sum + lv
			else
				lv = nil
				missing = missing + 1
			end
			table.insert(out, { page = page, levels = lv })
		end
	end

	local remaining = (_to_int(total_bonus, 0) or 0) - explicit_sum
	if remaining < 0 then remaining = 0 end

	if missing == 1 and remaining > 0 then
		for _, it in ipairs(out) do
			if it.levels == nil then
				it.levels = remaining
				break
			end
		end
	end

	return out
end

local function _build_overcap_tip(root_id, default_cap, max_cap, sources)
	default_cap = _to_int(default_cap, 1) or 1
	max_cap = _to_int(max_cap, default_cap) or default_cap
	if max_cap <= default_cap then return nil end

	local total = max_cap - default_cap
	local tip_id = root_id .. "-overcap"

	local norm = _normalize_overcap_sources(sources, total)
	local show_unknown = (#norm == 0)

	local wrap = mw.html.create("span")
		:addClass("sv-tip")
		:addClass("sv-overcap-tip")

	local btn = wrap:tag("span")
		:addClass("sv-tip-btn")
		:addClass("sv-tip-btn--pill")
		:addClass("sv-overcap-btn")
		:addClass("sv-hover-lift")
		:attr("role", "button")
		:attr("tabindex", "0")
		:attr("data-sv-toggle", "1")
		:attr("data-sv-pop", "click")
		:attr("data-sv-pop-size", "sm")
		:attr("aria-label", "Overcap")
		:attr("aria-controls", tip_id)
		:attr("aria-expanded", "false")
		:wikitext(mw.text.nowiki("Overcap +" .. tostring(total)))

	local pop = wrap:tag("div")
		:addClass("sv-tip-pop")
		:addClass("sv-hidden")
		:attr("hidden", "hidden")
		:attr("id", tip_id)
		:attr("aria-label", "Overcap")

	local head = pop:tag("div"):addClass("sv-tip-pop-head")
	head:tag("div"):addClass("sv-tip-pop-title"):wikitext("Overcap sources")

	local body = pop:tag("div"):addClass("sv-tip-pop-body")
	body:tag("div"):addClass("sv-overcap-summary"):wikitext(mw.text.nowiki(
		"Raises max from " .. tostring(default_cap) .. " to " .. tostring(max_cap) .. " (+" .. tostring(total) .. ")."
	))

	local ul = body:tag("ul"):addClass("sv-disclose-list")

	if show_unknown then
		ul:tag("li"):wikitext("Source unknown")
	else
		for _, it in ipairs(norm) do
			local li = ul:tag("li")
			local page = _safe_str(it.page, "")
			if page ~= "" then
				li:wikitext(string.format("[[%s|%s]]", page, mw.text.nowiki(page)))
			else
				li:wikitext("—")
			end

			if it.levels ~= nil and it.levels > 0 then
				li:wikitext(mw.text.nowiki(" (+" .. tostring(it.levels) .. ")"))
			else
				li:wikitext(mw.text.nowiki(" (+?)"))
			end
		end
	end

	return wrap
end

local function _normalize_disclose_item(it)
	if type(it) == "string" then
		local s = _trim(it)
		if s == "" then return nil end
		return { text = s }
	end

	if type(it) ~= "table" then return nil end

	if it.text ~= nil or it.page ~= nil or it.suffix ~= nil or it.name ~= nil or it.label ~= nil then
		local text = _safe_str(it.text or it.label or it.name or "", "")
		if text == "" then return nil end
		local page = _safe_str(it.page or "", "")
		local suffix = _safe_str(it.suffix or "", "")
		return { text = text, page = (page ~= "" and page or nil), suffix = (suffix ~= "" and suffix or nil) }
	end

	if #it >= 1 then
		local name = _safe_str(it[1], "")
		if name == "" then return nil end
		local suffix = _safe_str(it[2], "")
		return {
			text = name,
			page = name,
			suffix = (suffix ~= "" and suffix or nil)
		}
	end

	local lines = _safe_tbl(it.label_lines)
	local text = ""
	for _, ln in ipairs(lines) do
		local s = _trim(ln)
		if s ~= "" then
			if text ~= "" then text = text .. " " end
			text = text .. s
		end
	end
	if text == "" then return nil end

	local link = _safe_tbl(it.link)
	local page = _safe_str(link.page or it.page or "", "")
	return { text = text, page = (page ~= "" and page or nil) }
end

local function _count_group_items(groups)
	local n = 0
	for _, g in ipairs(_safe_tbl(groups)) do
		for _, it in ipairs(_safe_tbl(g.items)) do
			if _normalize_disclose_item(it) then n = n + 1 end
		end
	end
	return n
end

local function _build_grouped_disclose(root_id, key, label, groups)
	local total = _count_group_items(groups)
	if total == 0 then return nil, 0 end

	local pop_id = root_id .. "-" .. key
	local wrap = mw.html.create("div"):addClass("sv-disclose")

	local btn = wrap:tag("span")
		:addClass("sv-disclose-btn")
		:addClass("sv-disclose-btn--compact")
		:addClass("sv-hover-lift")
		:attr("role", "button")
		:attr("tabindex", "0")
		:attr("data-sv-toggle", "1")
		:attr("data-sv-pop", "click")
		:attr("data-sv-pop-size", "sm")
		:attr("aria-label", label)
		:attr("aria-controls", pop_id)
		:attr("aria-expanded", "false")

	btn:tag("span"):addClass("sv-disclose-label"):wikitext(mw.text.nowiki(label))
	btn:tag("span"):addClass("sv-disclose-count"):wikitext(" (" .. tostring(total) .. ")")

	local pop = wrap:tag("div")
		:addClass("sv-disclose-pop")
		:addClass("sv-hidden")
		:attr("hidden", "hidden")
		:attr("id", pop_id)
		:attr("aria-label", label)

	local head = pop:tag("div"):addClass("sv-disclose-pop-head")
	head:tag("div"):addClass("sv-disclose-pop-title"):wikitext(mw.text.nowiki(label))

	local body = pop:tag("div"):addClass("sv-disclose-pop-body")
	local secs = body:tag("div"):addClass("sv-disclose-sections")

	local function render_item(parent, norm)
		local item = parent:tag("span"):addClass("sv-disclose-item")

		if norm.page then
			item:wikitext(string.format(
				"[[%s|%s]]",
				norm.page,
				mw.text.nowiki(norm.text)
			))
		else
			item:wikitext(mw.text.nowiki(norm.text))
		end

		local sfx = _safe_str(norm.suffix or "", "")
		if sfx ~= "" then
			if not sfx:match("^%s") then sfx = " " .. sfx end
			item:tag("span"):addClass("sv-disclose-sfx"):wikitext(mw.text.nowiki(sfx))
		end
	end

	for _, g in ipairs(_safe_tbl(groups)) do
		local title = _safe_str(g.title, "—")
		local items = _safe_tbl(g.items)

		if #items > 0 then
			local sec = secs:tag("div"):addClass("sv-disclose-sec")
			sec:tag("div"):addClass("sv-disclose-sec-title"):wikitext(mw.text.nowiki(title))

			local line = sec:tag("div"):addClass("sv-disclose-sec-items")

			local norms = {}
			for _, it in ipairs(items) do
				local norm = _normalize_disclose_item(it)
				if norm then table.insert(norms, norm) end
			end

			if #norms == 0 then
				line:tag("span"):addClass("sv-disclose-item"):wikitext("—")
			else
				for i, norm in ipairs(norms) do
					render_item(line, norm)
					if i < #norms then line:wikitext(", ") end
				end
			end
		end
	end

	return wrap, total
end

local function _build_identity(frame, root_id, identity, notes_wt)
	identity = _safe_tbl(identity)
	local root = mw.html.create("div"):addClass("sv-skill-head")

	local icon_div = root:tag("div")
		:addClass("sv-skill-icon")
		:addClass("sv-tile")

	local sprite = _safe_tbl(identity.sprite)
	local sprite_page = _safe_str(sprite.page, "")
	local sprite_alt = _safe_str(sprite.alt, _safe_str(identity.display_name, ""))

	if sprite_page ~= "" then
		icon_div:node(_render_file_image(sprite_page, sprite_alt, 64))
	else
		icon_div:node(_question_badge())
	end

	local headtext = root:tag("div"):addClass("sv-skill-headtext")

	local title_row = headtext:tag("div"):addClass("sv-skill-title-row")
	title_row:tag("div")
		:addClass("sv-skill-title")
		:wikitext(mw.text.nowiki(_safe_str(identity.display_name, "Unknown Skill")))

	local tip = _build_notes_tip(frame, root_id, notes_wt)
	if tip then title_row:node(tip) end

	headtext:tag("div")
		:addClass("sv-skill-desc")
		:wikitext(mw.text.nowiki(_safe_str(identity.description, "")))

	return root
end

local function _build_meta_row(frame, meta_row)
	meta_row = _safe_tbl(meta_row)

	local meta = mw.html.create("div"):addClass("sv-skill-meta")
	for i = 1, 4 do
		local cell = _safe_tbl(meta_row[i])
		local card = meta:tag("div")
			:addClass("sv-tile")
			:addClass("sv-meta-card")
			:addClass("sv-hover-lift")

		local icon = _safe_tbl(cell.icon)
		local icon_page = _safe_str(icon.page, "")
		local icon_alt = _safe_str(icon.alt, "")

		local icon_div = card:tag("div")
			:addClass("sv-meta-icon")
			:addClass("sv-tile")

		if icon_page ~= "" then
			icon_div:node(_render_file_image(icon_page, icon_alt, 24))
		else
			icon_div:node(_question_badge())
		end

		local wrap = card:tag("div"):addClass("sv-meta-textwrap")
		local text = wrap:tag("div"):addClass("sv-meta-text")

		local lines = _safe_tbl(cell.label_lines)
		if #lines > 0 then
			text:node(_meta_lines_def(frame, lines))
		else
			text:wikitext("—")
		end

		local hit_tok = ""
		for _, ln in ipairs(lines) do
			local s = _safe_str(ln, "")
			if _has_pipe(s) then
				hit_tok = s
				break
			end
		end
		if hit_tok ~= "" then
			local wt = _render_def_token(frame, hit_tok, true, { pill = "1", fill = "1" })
			if wt then
				card:tag("span")
					:addClass("sv-meta-hit")
					:attr("aria-hidden", "true")
					:wikitext(wt)
			end
		end
	end

	return meta
end

local function _build_meta_row_native(frame, skill_meta)
	skill_meta = _safe_tbl(skill_meta)

	local meta = mw.html.create("div"):addClass("sv-skill-meta")
	for i = 1, 4 do
		local cell = _safe_tbl(skill_meta[i])
		local card = meta:tag("div")
			:addClass("sv-tile")
			:addClass("sv-meta-card")
			:addClass("sv-hover-lift")

		local icon_div = card:tag("div")
			:addClass("sv-meta-icon")
			:addClass("sv-tile")

		local icon_page = _safe_str(cell.icon, "")
		if icon_page ~= "" then
			icon_div:node(_render_file_image(icon_page, "", 24))
		else
			icon_div:node(_question_badge())
		end

		local wrap = card:tag("div"):addClass("sv-meta-textwrap")
		local text = wrap:tag("div"):addClass("sv-meta-text")

		local display_lines = _safe_tbl(cell.label_lines)
		if #display_lines == 0 then
			local label_wt = _safe_str(cell.label_wt, "")
			if label_wt ~= "" then display_lines = { label_wt } end
		end

		if #display_lines > 0 then
			text:node(_meta_lines_def(frame, display_lines))
		else
			text:wikitext("—")
		end

		local hit_tok = _safe_str(cell.label_wt, "")
		if _has_pipe(hit_tok) then
			local wt = _render_def_token(frame, hit_tok, true, { pill = "1", fill = "1" })
			if wt then
				card:tag("span")
					:addClass("sv-meta-hit")
					:attr("aria-hidden", "true")
					:wikitext(wt)
			end
		end
	end

	return meta
end

local function _normalize_level(level_obj)
	level_obj = _safe_tbl(level_obj)

	local max = _to_int(level_obj.max, nil)
	local base_max = _to_int(level_obj.base_max, nil)

	if not max then max = base_max or 1 end
	if not base_max then base_max = max end

	if max < 1 then max = 1 end
	if base_max < 1 then base_max = 1 end
	if base_max > max then base_max = max end

	local default = base_max
	return default, base_max, max
end

local function _normalize_level_native(level_obj)
	level_obj = _safe_tbl(level_obj)

	local min_level = _to_int(level_obj.base, 1)
	if not min_level or min_level < 1 then min_level = 1 end

	local default = _to_int(level_obj.default, min_level)
	local oc = _safe_tbl(level_obj.overcap)
	local max_level = _to_int(oc.max, default)

	if not max_level or max_level < min_level then max_level = min_level end
	if not default or default < min_level then default = min_level end
	if default > max_level then default = max_level end

	return min_level, default, max_level
end

local function _build_level(root_id, default_level, max_level, min_level, overcap_obj)
	local min = _to_int(min_level, 1)
	local default = _to_int(default_level, 1)
	local max = _to_int(max_level, 1)

	if min < 1 then min = 1 end
	if max < min then max = min end
	if default < min then default = min end
	if default > max then default = max end

	local root = mw.html.create("div")
		:addClass("sv-card")
		:addClass("sv-skill-level")
		:attr("data-sv-level-boundary", "1")
		:attr("data-sv-level-hide-ticks", "1")

	local ui = root:tag("div"):addClass("sv-level-ui")

	local label = ui:tag("div")
		:addClass("sv-level-label")
		:attr("data-sv-helper-text", "Slide to set level")
	label:wikitext("Level ")
	label:tag("span"):addClass("sv-level-num"):wikitext(tostring(default))
	label:wikitext(" / ")
	label:tag("span"):addClass("sv-level-max"):wikitext(tostring(max))

	if type(overcap_obj) == "table" and max > default then
		local oc = _safe_tbl(overcap_obj)
		local tip = _build_overcap_tip(root_id, default, max, oc.sources)
		if tip then ui:node(tip) end
	elseif max > default then
		local tip = _build_overcap_tip(root_id, default, max, nil)
		if tip then ui:node(tip) end
	end

	local slider_wrap = root:tag("div"):addClass("sv-level-slider")

	local slider = slider_wrap:tag("span")
		:addClass("sv-level-range")
		:addClass("sv-level-range--custom")
		:attr("data-sv-slider", "1")
		:attr("role", "slider")
		:attr("tabindex", "0")
		:attr("aria-label", "Skill level select")
		:attr("aria-valuemin", tostring(min))
		:attr("aria-valuemax", tostring(max))
		:attr("aria-valuenow", tostring(default))
		:attr("data-min", tostring(min))
		:attr("data-max", tostring(max))
		:attr("data-value", tostring(default))

	local track = slider:tag("span"):addClass("sv-level-track"):attr("aria-hidden", "true")
	track:tag("span"):addClass("sv-level-fill"):attr("aria-hidden", "true")
	slider:tag("span"):addClass("sv-level-thumb"):attr("aria-hidden", "true")

	slider_wrap:tag("div"):addClass("sv-level-ticklabels"):attr("aria-hidden", "true")

	return root, default, max, min
end

local function _build_scaling_top(frame, scaling, level)
	scaling = _safe_tbl(scaling)

	local root = mw.html.create("div")
		:addClass("sv-card")
		:addClass("sv-skill-scaling")

	local row = root:tag("div"):addClass("sv-scaling-row")
	local grid = row:tag("div"):addClass("sv-scaling-grid")

	do
		local col = grid:tag("div"):addClass("sv-scaling-col")
		local v = col:tag("div"):addClass("sv-scaling-value")
		_apply_value(v, scaling.damage, level)
		col:tag("div"):addClass("sv-scaling-label"):wikitext("Damage")
	end

	do
		local col = grid:tag("div"):addClass("sv-scaling-col sv-scaling-col--scaling")
		local list = col:tag("div"):addClass("sv-scaling-list")

		for _, it in ipairs(_safe_tbl(scaling.stat_scaling)) do
			it = _safe_tbl(it)
			local tok = _safe_str(it.stat_wt or it.stat or "", "")
			local val = _safe_str(it.value or "", "")
			if tok ~= "" and val ~= "" then
				local item = list:tag("div")
					:addClass("sv-tile")
					:addClass("sv-scaling-item")
					:addClass("sv-scaling-item--stat")
				local wt = _render_def_token(frame, tok, false) or mw.text.nowiki(tok)
				item:tag("span"):addClass("sv-scale-stat"):wikitext(wt)
				item:tag("span"):addClass("sv-scale-val"):wikitext(mw.text.nowiki(val))
			end
		end

		for _, ln in ipairs(_safe_tbl(scaling.scaling_lines)) do
			local item = list:tag("div")
				:addClass("sv-tile")
				:addClass("sv-scaling-item")
			_apply_value(item, ln, level)
		end
	end

	return root
end

local function _build_core_stats(core_stats, level)
	core_stats = _safe_tbl(core_stats)

	local root = mw.html.create("div")
		:addClass("sv-card")
		:addClass("sv-skill-core")

	local row = root:tag("div"):addClass("sv-core-row")
	local grid = row:tag("div"):addClass("sv-core-grid")

	for _, cell in ipairs(core_stats) do
		cell = _safe_tbl(cell)

		local c = grid:tag("div")
			:addClass("sv-core-cell")
			:addClass("sv-tile")

		local top = c:tag("div"):addClass("sv-core-top")
		local num = top:tag("span"):addClass("sv-core-num")
		_apply_value(num, cell.value, level)

		local unit = _safe_str(cell.unit, "")
		if unit ~= "" then
			top:tag("span"):addClass("sv-core-unit"):wikitext(mw.text.nowiki(unit))
		end

		local key = _safe_str(cell.key, "")
		local label = c:tag("div"):addClass("sv-core-label"):wikitext(mw.text.nowiki(key))
		if key:len() >= 9 then label:addClass("sv-core-label--tight") end
	end

	return root
end

local function _build_skill_scaling_stat_pill(frame, parent, stat_key, raw_val, level)
	local label = _STAT_LABELS[stat_key] or stat_key:upper()
	local def_tok = _STAT_DEF_TOKENS[stat_key] or label
	local icon_file = _STAT_ICON_FILES[stat_key]
	local active = _value_has_content(raw_val)

	local pill = parent:tag("div")
		:addClass("sv-pill")
		:addClass("sv-pill--compact")
		:addClass("sv_skill_scaling__pill")
		:addClass("sv_skill_scaling__pill--stat")
		:addClass("sv_skill_scaling__stat-pill")
		:addClass("sv_skill_scaling__stat-pill--" .. stat_key)
		:attr("data-stat-key", stat_key)
		:attr("aria-label", label)

	pill:addClass(active and "is-active" or "is-inactive")

	local main = pill:tag("div"):addClass("sv_skill_scaling__stat-main")
	local visual = main:tag("span"):addClass("sv_skill_scaling__stat-visual")

	if active then
		local icon = visual:tag("span"):addClass("sv_skill_scaling__stat-icon")
		if icon_file and icon_file ~= "" then
			icon:node(_render_file_image(icon_file, label, 14))
		else
			icon:node(_question_badge())
		end
	end

	local val = visual:tag("span"):addClass("sv_skill_scaling__stat-value")
	_apply_value(val, raw_val, level)

	if active then
		local wt = _render_def_token(frame, def_tok, false, { pill = "1", fill = "1" })
		if wt then
			pill:tag("span")
				:addClass("sv_skill_scaling__stat-hit")
				:attr("aria-hidden", "true")
				:wikitext(wt)
		end
	end
end

local function _build_skill_scaling_core_pill(parent, spec, fld, level)
	fld = _safe_tbl(fld)
	local active = _value_has_content(fld.value)

	local pill = parent:tag("div")
		:addClass("sv-tile")
		:addClass("sv_skill_scaling__pill")
		:addClass("sv_skill_scaling__pill--core")
		:addClass("sv_skill_scaling__core-pill")
		:addClass("sv_skill_scaling__core-pill--" .. spec.field)
		:attr("data-core-key", spec.field)

	pill:addClass(active and "is-active" or "is-inactive")

	local top = pill:tag("div"):addClass("sv_skill_scaling__core-main")

	local value = top:tag("span"):addClass("sv_skill_scaling__core-value")
	_apply_value(value, fld.value, level)

	local unit = _safe_str(fld.unit, "")
	if unit ~= "" then
		top:tag("span")
			:addClass("sv_skill_scaling__core-unit")
			:wikitext(mw.text.nowiki(unit))
	end

	pill:tag("div")
		:addClass("sv_skill_scaling__core-label")
		:wikitext(mw.text.nowiki(spec.label))
end

local function _build_skill_scaling_native(frame, root_id, skill_scaling, default_level, max_level, min_level, overcap_obj)
	skill_scaling = _safe_tbl(skill_scaling)

	local root = mw.html.create("div")
		:addClass("sv-card")
		:addClass("sv_skill_scaling")

	local level_panel, actual_default = _build_level(
		root_id,
		default_level,
		max_level,
		min_level,
		overcap_obj
	)
	level_panel:addClass("sv_skill_scaling__level")
	root:node(level_panel)

	local body = root:tag("div"):addClass("sv_skill_scaling__body")

	local damage = _safe_tbl(skill_scaling.damage)
	local damage_label = _safe_str(damage.label, "Damage")
	local damage_value = _safe_tbl(damage.value)

	do
		local primary_group = body:tag("div")
			:addClass("sv_skill_scaling__group")
			:addClass("sv_skill_scaling__group--primary-stats")

		local cluster = primary_group:tag("div"):addClass("sv_skill_scaling__cluster")

		do
			local primary = cluster:tag("div")
				:addClass("sv_skill_scaling__column")
				:addClass("sv_skill_scaling__column--primary")
				:addClass("sv_skill_scaling__primary")

			local v = primary:tag("div"):addClass("sv_skill_scaling__primary-value")
			_apply_value(v, damage_value, actual_default)

			primary:tag("div")
				:addClass("sv_skill_scaling__primary-label")
				:wikitext(mw.text.nowiki(damage_label))
		end

		do
			local stats_col = cluster:tag("div")
				:addClass("sv_skill_scaling__column")
				:addClass("sv_skill_scaling__column--stats")
				:addClass("sv_skill_scaling__stats")

			local stats_grid = stats_col:tag("div"):addClass("sv_skill_scaling__stats-grid")
			local stat_slots = _normalize_stat_scaling_slots(skill_scaling.stat_scaling)

			for _, stat_key in ipairs(_STAT_GRID_ORDER) do
				_build_skill_scaling_stat_pill(frame, stats_grid, stat_key, stat_slots[stat_key], actual_default)
			end
		end
	end

	do
		local core_group = body:tag("div")
			:addClass("sv_skill_scaling__group")
			:addClass("sv_skill_scaling__group--core")

		local core_col = core_group:tag("div")
			:addClass("sv_skill_scaling__column")
			:addClass("sv_skill_scaling__column--core")
			:addClass("sv_skill_scaling__core")

		local core_grid = core_col:tag("div"):addClass("sv_skill_scaling__core-grid")

		for _, spec in ipairs(_CORE_ORDER) do
			_build_skill_scaling_core_pill(core_grid, spec, skill_scaling[spec.field], actual_default)
		end
	end

	return root, actual_default
end

local function _build_tabs(frame, root_id, tabs_obj, level)
	tabs_obj = _safe_tbl(tabs_obj)

	local mechanics = _safe_tbl(tabs_obj.mechanics)
	local effects   = _safe_tbl(tabs_obj.effects)

	local mechanics_mods = _safe_tbl(mechanics.mods)
	local mechanics_keywords = _safe_tbl(mechanics.keywords)
	local effects_cards = _safe_tbl(effects.cards)

	if #mechanics_mods == 0 then
		mechanics_mods = _safe_tbl(mechanics.grid)
	end
	if #mechanics_keywords == 0 then
		local old_keywords = _safe_tbl(tabs_obj.keywords)
		mechanics_keywords = _safe_tbl(old_keywords.pills)
	end
	if #effects_cards == 0 then
		local old_events = _safe_tbl(tabs_obj.events)
		local merged = {}

		for _, card in ipairs(_safe_tbl(effects.cards)) do
			table.insert(merged, card)
		end
		for _, card in ipairs(_safe_tbl(old_events.cards)) do
			table.insert(merged, card)
		end

		effects_cards = merged
	end

	local mechanics_count = _to_int(
		mechanics.count,
		#mechanics_mods + #mechanics_keywords
	)

	local effects_count = _to_int(
		effects.count,
		#effects_cards
	)

	local tab_specs = {
		{ key = "mechanics", title = "Mechanics (" .. tostring(mechanics_count) .. ")" },
		{ key = "effects",   title = "Effects (" .. tostring(effects_count) .. ")" },
	}

	local root = mw.html.create("div"):addClass("sv-skill-tabs")

	local tabs = root:tag("div")
		:addClass("sv-tabs")
		:attr("data-tabs", "1")
		:attr("data-tabs-root", root_id)

	local list = tabs:tag("div"):addClass("sv-tabs-list"):attr("role", "tablist")
	local panels = tabs:tag("div"):addClass("sv-tabs-panels")

	for i, spec in ipairs(tab_specs) do
		local active = (i == 1)

		list:tag("span")
			:addClass("sv-tab")
			:attr("role", "tab")
			:attr("tabindex", active and "0" or "-1")
			:attr("data-tab", spec.key)
			:attr("aria-selected", active and "true" or "false")
			:wikitext(mw.text.nowiki(spec.title))
	end

	local function render_ref_icon(parent, icon)
		icon = _safe_tbl(icon)
		local page = _safe_str(icon.page, "")
		local alt = _safe_str(icon.alt, "")
		if page ~= "" then
			parent:node(_render_file_image(page, alt, 52))
		else
			parent:node(_question_badge())
		end
	end

	local function render_effect_stats(parent, stats, kind)
		stats = _safe_tbl(stats)
		kind = _safe_str(kind, ""):lower()

		if kind == "event" then
			local sub = parent:tag("div"):addClass("sv-ref-sub")

			if stats.text ~= nil then
				_apply_value(sub, stats, level)
			else
				_apply_value(sub, { text = "—" }, level)
			end
			return
		end

		if next(stats) ~= nil then
			local srow = parent:tag("div"):addClass("sv-ref-stats")

			local d = srow:tag("span")
				:addClass("sv-pill")
				:addClass("sv-pill--value")
				:addClass("sv-ref-stat")
			_apply_value(d, stats.duration, level)

			local c = srow:tag("span")
				:addClass("sv-pill")
				:addClass("sv-pill--value")
				:addClass("sv-ref-stat")
			_apply_value(c, stats.chance, level)

			local st = srow:tag("span")
				:addClass("sv-pill")
				:addClass("sv-pill--value")
				:addClass("sv-ref-stat")
			_apply_value(st, stats.stacks, level)
		end
	end

	local function render_ref_card(parent, card)
		card = _safe_tbl(card)

		local kind = _safe_str(card.kind, "")
		local title = _safe_str(card.title, "—")
		local page = _safe_str(card.page, "")
		local icon = _safe_tbl(card.icon)

		local container = parent:tag("div")
			:addClass("sv-tile")
			:addClass("sv-hover-lift")
			:addClass("sv-ref-card")
		if kind ~= "" then
			container:addClass("sv-ref-card--" .. _slugify(kind))
		end

		local ico = container:tag("div")
			:addClass("sv-ref-ico")
			:addClass("sv-tile")
		render_ref_icon(ico, icon)

		local text = container:tag("div"):addClass("sv-ref-text")

		local title_div = text:tag("div"):addClass("sv-ref-title")
		if page ~= "" then
			title_div:wikitext(string.format("[[%s|%s]]", page, mw.text.nowiki(title)))
		else
			title_div:wikitext(mw.text.nowiki(title))
		end

		render_effect_stats(text, card.stats, kind)
	end

	do
		local panel = panels:tag("div")
			:addClass("sv-tabpanel")
			:addClass("sv-mech-panel")
			:attr("role", "tabpanel")
			:attr("data-panel", "mechanics")

		local mods_wrap = panel:tag("div")
			:addClass("sv-tab-section")
			:addClass("sv-mech-panel__group")
			:addClass("sv-mech-panel__group--mods")
			:addClass("sv-mech-panel__mods")

		local keys_wrap = panel:tag("div")
			:addClass("sv-tab-section")
			:addClass("sv-mech-panel__group")
			:addClass("sv-mech-panel__group--keywords")
			:addClass("sv-mech-panel__keywords")

		if #mechanics_mods > 0 then
			mods_wrap:tag("div"):addClass("sv-tab-section-title"):wikitext("Modifiers")

			local gridwrap = mods_wrap:tag("div"):addClass("sv-kw-grid")
			for _, item in ipairs(mechanics_mods) do
				item = _safe_tbl(item)

				local cell = gridwrap:tag("div")
					:addClass("sv-tile")
					:addClass("sv-kw-cell")
				cell:tag("div")
					:addClass("sv-kw-label")
					:wikitext(mw.text.nowiki(_safe_str(item.label, "")))

				local v = cell:tag("div"):addClass("sv-kw-value")
				_apply_value(v, item.value, level)
			end
		end

		if #mechanics_keywords > 0 then
			keys_wrap:tag("div"):addClass("sv-tab-section-title"):wikitext("Keywords")

			local pills = keys_wrap:tag("div"):addClass("sv-tab-pills")
			for _, kw in ipairs(mechanics_keywords) do
				_build_keyword_pill(frame, pills, kw)
			end
		end

		if #mechanics_mods == 0 and #mechanics_keywords == 0 then
			panel:tag("div")
				:addClass("sv-tile")
				:addClass("sv-tab-empty")
				:wikitext("—")
		end
	end

	do
		local panel = panels:tag("div")
			:addClass("sv-tabpanel")
			:attr("role", "tabpanel")
			:attr("data-panel", "effects")
			:attr("hidden", "hidden")

		if #effects_cards > 0 then
			local grid = panel:tag("div"):addClass("sv-ref-grid")
			for _, card in ipairs(effects_cards) do
				render_ref_card(grid, card)
			end
		else
			panel:tag("div")
				:addClass("sv-tile")
				:addClass("sv-tab-empty")
				:wikitext("—")
		end
	end

	return root
end

local function _map_to_groups(map_tbl, preferred_order)
	local m = _safe_tbl(map_tbl)

	local function normalize_items(v)
		if type(v) == "table" then
			return v
		end
		if v == nil then
			return {}
		end
		return { v }
	end

	local used = {}
	local group_keys = {}

	local function push_key(k)
		k = tostring(k)
		if k ~= "" and not used[k] and m[k] ~= nil then
			used[k] = true
			table.insert(group_keys, k)
		end
	end

	local explicit = m.__order or m.order
	if type(explicit) == "table" then
		for _, k in ipairs(explicit) do
			push_key(k)
		end
	end

	if type(preferred_order) == "table" then
		for _, k in ipairs(preferred_order) do
			push_key(k)
		end
	end

	local rest = {}
	for k, _ in pairs(m) do
		k = tostring(k)
		if k ~= "__order" and k ~= "order" and not used[k] then
			table.insert(rest, k)
		end
	end
	table.sort(rest)
	for _, k in ipairs(rest) do
		table.insert(group_keys, k)
	end

	local out_groups = {}
	for _, title in ipairs(group_keys) do
		local items = normalize_items(m[title])
		local out_items = {}

		for _, it in ipairs(_safe_tbl(items)) do
			table.insert(out_items, it)
		end

		table.insert(out_groups, { title = title, items = out_items })
	end

	return out_groups
end

local function _build_req_users_native(root_id, requirements_map, users_map)
	local req_groups = _map_to_groups(requirements_map, { "Skills", "Weapon Types", "Stances" })
	local usr_groups = _map_to_groups(users_map,        { "Classes", "Summons" })

	local req_box, req_n = _build_grouped_disclose(root_id, "req", "Requirements", req_groups)
	local usr_box, usr_n = _build_grouped_disclose(root_id, "usr", "Users", usr_groups)

	if req_n == 0 and usr_n == 0 then return nil end

	local row = mw.html.create("div"):addClass("sv-reqrow")
	if req_box then row:node(req_box) end
	if usr_box then row:node(usr_box) end
	return row
end

local function _build_req_users(root_id, requirements, users)
	requirements = _safe_tbl(requirements)
	users = _safe_tbl(users)

	local req_box, req_n = _build_grouped_disclose(
		root_id, "req", "Requirements", _safe_tbl(requirements.groups)
	)
	local usr_box, usr_n = _build_grouped_disclose(
		root_id, "usr", "Users", _safe_tbl(users.groups)
	)

	if req_n == 0 and usr_n == 0 then return nil end

	local row = mw.html.create("div"):addClass("sv-reqrow")
	if req_box then row:node(req_box) end
	if usr_box then row:node(usr_box) end
	return row
end

local function _build_meta_req_block(meta_node, req_node)
	if not meta_node and not req_node then return nil end

	local wrap = mw.html.create("div")
		:addClass("sv-skill-meta-block")

	local count = 0

	if meta_node then
		count = count + 1
		local bubble = wrap:tag("div")
			:addClass("sv-skill-meta-block__bubble")
			:addClass("sv-skill-meta-block__bubble--meta")
		bubble:node(meta_node)
	end

	if req_node then
		count = count + 1
		local bubble = wrap:tag("div")
			:addClass("sv-skill-meta-block__bubble")
			:addClass("sv-skill-meta-block__bubble--req")
		bubble:node(req_node)
	end

	if count == 1 then
		wrap:addClass("sv-skill-meta-block--single")
	end

	return wrap
end

local function _render_legacy(frame, payload, notes_wt)
	local identity = _safe_tbl(payload.identity)
	local display_name = _safe_str(identity.display_name, "Unknown Skill")

	local idx = _to_int(GI.arg(frame, "index", "1"), 1)
	if idx < 1 then idx = 1 end

	local root_id = GI.arg(frame, "id", "")
	if root_id == "" then
		root_id = "sv-skill-" .. _slugify(display_name) .. "-" .. tostring(idx)
	end

	local default_level, _base_max, max_level = _normalize_level(payload.level)

	local box = GI.box({
		root_id = root_id,
		level = default_level,
		max_level = max_level,
		variant = "skills",
	})

	box.root:addClass("sv-skill-card")
	box.top:addClass("sv-skill-top")
	box.bottom:addClass("sv-skill-bottom")

	box.top:node(_build_identity(frame, root_id, identity, notes_wt))

	local meta_block = _build_meta_row(frame, payload.meta_row)
	local reqrow = _build_req_users(root_id, payload.requirements, payload.users)
	local meta_req_block = _build_meta_req_block(meta_block, reqrow)

	if meta_req_block then
		box.top:node(meta_req_block)
	end

	local level_panel, actual_default = _build_level(root_id, default_level, max_level, 1, nil)

	box.bottom:node(level_panel)
	box.bottom:node(_build_scaling_top(frame, payload.scaling_top, actual_default))
	box.bottom:node(_build_core_stats(payload.core_stats, actual_default))
	box.bottom:node(_build_tabs(frame, root_id, payload.tabs, actual_default))

	return tostring(box.root)
end

local function _render_native(frame, payload, notes_wt)
	local identity = _safe_tbl(payload.identity)
	local display_name = _safe_str(identity.display_name, "Unknown Skill")

	local idx = _to_int(GI.arg(frame, "index", "1"), 1)
	if idx < 1 then idx = 1 end

	local root_id = GI.arg(frame, "id", "")
	if root_id == "" then
		root_id = "sv-skill-" .. _slugify(display_name) .. "-" .. tostring(idx)
	end

	local min_level, default_level, max_level = _normalize_level_native(payload.level)

	local box = GI.box({
		root_id = root_id,
		level = default_level,
		max_level = max_level,
		variant = "skills",
	})

	box.root:addClass("sv-skill-card")
	box.top:addClass("sv-skill-top")
	box.bottom:addClass("sv-skill-bottom")

	box.root:attr("data-level-default", tostring(default_level))
	do
		local oc = _safe_tbl(_safe_tbl(payload.level).overcap)
		if next(oc) ~= nil then
			local safe_oc = { max = _to_int(oc.max, max_level), sources = _safe_tbl(oc.sources) }
			box.root:attr("data-level-overcap", mw.text.jsonEncode(safe_oc))
		end
	end

	box.top:node(_build_identity(frame, root_id, identity, notes_wt))

	local meta_block = _build_meta_row_native(frame, payload.skill_meta)
	local reqrow = _build_req_users_native(root_id, payload.requirements, payload.users)
	local meta_req_block = _build_meta_req_block(meta_block, reqrow)

	if meta_req_block then
		box.top:node(meta_req_block)
	end

	local scaling_block, actual_default = _build_skill_scaling_native(
		frame,
		root_id,
		payload.skill_scaling,
		default_level,
		max_level,
		min_level,
		_safe_tbl(payload.level).overcap
	)

	box.bottom:node(scaling_block)
	box.bottom:node(_build_tabs(frame, root_id, payload.tabs, actual_default))

	return tostring(box.root)
end

function p.render(frame)
	frame = frame or mw.getCurrentFrame()

	local notes_wt = GI.arg(frame, "notes", "")
	local payload, err, preview = _decode_payload(frame)
	if not payload then
		local debug = (GI.arg(frame, "debug", "0") == "1")
		return _err(err, debug and preview or nil)
	end

	if payload.schema == 2 then
		return _render_native(frame, payload, notes_wt)
	end

	return _render_legacy(frame, payload, notes_wt)
end

return p