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
No edit summary
Line 1: Line 1:
-- Module:GameInfo/Skills
-- Module:GameInfo/Skills
-- Phase 4.1: Skills renderer (components live here; CSS/JS shared globally).
-- Phase 4.1: Skills renderer (components live here; CSS/JS shared globally).
--
-- Entrypoints (all equivalent):
--  {{#invoke:GameInfo|Skills|data=...|notes=...}}
--  {{#invoke:GameInfo/Skills|render|data=...|notes=...}}
--  {{#invoke:GameInfo/Skills|main|data=...|notes=...}}
--
-- Debug:
--  add |debug=1 to print what Lua actually received for |data=...


local p = {}
local p = {}
Line 7: Line 15:


local _file_exists_cache = {}
local _file_exists_cache = {}
-- -----------------------------------------------------------------------------
-- BASIC HELPERS
-- -----------------------------------------------------------------------------


local function _trim(s)
local function _trim(s)
Line 45: Line 57:
local function _get_arg(frame, key)
local function _get_arg(frame, key)
-- Prefer direct args, but allow parent args (compat with routers/wrappers).
-- Prefer direct args, but allow parent args (compat with routers/wrappers).
local args = frame and frame.args or {}
local args = (frame and frame.args) or {}
local v = _trim(args[key])
local v = _trim(args[key])
if v ~= "" then return v end
if v ~= "" then return v end


local parent = frame and frame.getParent and frame:getParent() or nil
local parent = (frame and frame.getParent) and frame:getParent() or nil
local pargs = parent and parent.args or {}
local pargs = (parent and parent.args) or {}
v = _trim(pargs[key])
v = _trim(pargs[key])
if v ~= "" then return v end
if v ~= "" then return v end
Line 57: Line 69:
end
end


local function _strip_wrappers(raw)
local function _safe_unstrip(raw)
raw = raw == nil and "" or tostring(raw)
raw = raw == nil and "" or tostring(raw)


-- IMPORTANT: undo MediaWiki strip markers created by <nowiki>, <pre>, etc.
-- Some wikis have both of these; some have only one; some none.
if mw.text and mw.text.unstripNoWiki then
if mw.text then
raw = mw.text.unstripNoWiki(raw)
if type(mw.text.unstripNoWiki) == "function" then
raw = mw.text.unstripNoWiki(raw)
end
if type(mw.text.unstrip) == "function" then
raw = mw.text.unstrip(raw)
end
end
end
if mw.text and mw.text.unstrip then
 
raw = mw.text.unstrip(raw)
return raw
end
 
local function _strip_wrappers(frame, raw)
raw = raw == nil and "" or tostring(raw)
 
-- Try to undo strip markers first.
raw = _safe_unstrip(raw)
 
-- If strip markers still exist, preprocess as a fallback.
-- (This is safe because |data= is expected to be literal JSON, usually wrapped in <nowiki>.)
if raw:find("\127", 1, true) and frame and type(frame.preprocess) == "function" then
raw = frame:preprocess(raw)
raw = _safe_unstrip(raw)
end
end


raw = _trim(raw)
raw = _trim(raw)


-- Optional wrapper tags (in case literal tags survive in some paths)
-- Remove literal wrapper tags if they survived some path
raw = raw:gsub("^<nowiki>%s*", ""):gsub("%s*</nowiki>%s*$", "")
raw = raw:gsub("^<nowiki>%s*", ""):gsub("%s*</nowiki>%s*$", "")
raw = raw:gsub("^<pre>%s*", ""):gsub("%s*</pre>%s*$", "")
raw = raw:gsub("^<pre>%s*", ""):gsub("%s*</pre>%s*$", "")
Line 78: Line 108:
end
end


local function _decode_json(raw)
local function _decode_json(frame, raw)
raw = _strip_wrappers(raw)
raw = _strip_wrappers(frame, raw)
if raw == "" then return nil, "missing data" end
if raw == "" then return nil, "missing data", raw end
 
if not (mw.text and type(mw.text.jsonDecode) == "function") then
return nil, "mw.text.jsonDecode missing", raw
end


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


local function _normalize_file_title(page)
local function _normalize_file_title(page)
Line 125: Line 163:


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


-- Phase 4.1 rule: values are display-only:
-- Phase 4.1 rule: values are display-only:
Line 149: Line 193:
local series = val.series
local series = val.series
if type(series) == "table" and #series > 0 then
if type(series) == "table" and #series > 0 then
-- Attach series for Common.js Level Selector.
node:attr("data-series", mw.text.jsonEncode(series))
node:attr("data-series", mw.text.jsonEncode(series))
local v = series[level] or series[#series]
local v = series[level] or series[#series]
Line 169: Line 212:
local wrap = mw.html.create("span"):addClass("sv-meta-lines")
local wrap = mw.html.create("span"):addClass("sv-meta-lines")
local any = false
local any = false
for _, ln in ipairs(lines) do
for _, ln in ipairs(lines) do
local s = _trim(ln)
local s = _trim(ln)
Line 176: Line 220:
end
end
end
end
if not any then
if not any then
wrap:tag("span"):wikitext("—")
wrap:tag("span"):wikitext("—")
end
end
return wrap
return wrap
end
end
-- -----------------------------------------------------------------------------
-- TOP AREA
-- -----------------------------------------------------------------------------


local function _build_notes_tip(notes_wt)
local function _build_notes_tip(notes_wt)
Line 194: Line 244:
:attr("aria-expanded", "false")
:attr("aria-expanded", "false")


-- Info icon (matches locked baseline)
-- Info icon (locked baseline)
summary:tag("span")
summary:tag("span")
:addClass("sv-ico")
:addClass("sv-ico")
Line 211: Line 261:


pop:tag("div"):addClass("sv-tip-pop-body"):wikitext(notes_wt)
pop:tag("div"):addClass("sv-tip-pop-body"):wikitext(notes_wt)
return d
return d
end
end
Line 217: Line 266:
local function _build_identity(identity, notes_wt)
local function _build_identity(identity, notes_wt)
identity = _safe_tbl(identity)
identity = _safe_tbl(identity)
local root = mw.html.create("div"):addClass("sv-skill-head")
local root = mw.html.create("div"):addClass("sv-skill-head")


Line 313: Line 363:


if title ~= "" and #items > 0 then
if title ~= "" and #items > 0 then
ul:tag("li"):addClass("sv-disclose-group-title"):wikitext(mw.text.nowiki(title))
ul:tag("li")
:addClass("sv-disclose-group-title")
:wikitext(mw.text.nowiki(title))
end
end


for _, it in ipairs(items) do
for _, it in ipairs(items) do
it = _safe_tbl(it)
it = _safe_tbl(it)
local li = ul:tag("li")
local li = ul:tag("li")
local label_node = _meta_lines(it.label_lines)
local label_node = _meta_lines(it.label_lines)


local link = _safe_tbl(it.link)
local link = _safe_tbl(it.link)
local page = _safe_str(link.page, "")
local page = _safe_str(link.page, "")
if page ~= "" then
if page ~= "" then
-- HTML in link label is OK for our use (span lines).
li:wikitext(string.format("[[%s|%s]]", page, tostring(label_node)))
li:wikitext(string.format("[[%s|%s]]", page, tostring(label_node)))
else
else
Line 349: Line 403:
if req_details then row:node(req_details) end
if req_details then row:node(req_details) end
if usr_details then row:node(usr_details) end
if usr_details then row:node(usr_details) end
return row
return row
end
end


-- Level selector: this is the "boundary" for Common.js.
-- -----------------------------------------------------------------------------
-- Only fields below this block should carry data-series and update on slider change.
-- BOTTOM AREA
-- -----------------------------------------------------------------------------
 
local function _build_level(level_obj)
local function _build_level(level_obj)
level_obj = _safe_tbl(level_obj)
level_obj = _safe_tbl(level_obj)
Line 386: Line 441:


slider:tag("div"):addClass("sv-level-ticklabels"):attr("aria-hidden", "true")
slider:tag("div"):addClass("sv-level-ticklabels"):attr("aria-hidden", "true")
return root, default, max
return root, default, max
end
end
Line 456: Line 510:
local function _tab_ids(root_id, idx)
local function _tab_ids(root_id, idx)
return root_id .. "-tab-" .. tostring(idx), root_id .. "-panel-" .. tostring(idx)
return root_id .. "-tab-" .. tostring(idx), root_id .. "-panel-" .. tostring(idx)
end
local function _kw_pill_wikitext(s)
s = _safe_str(s, "")
if s == "" then return "" end
-- Compat:
-- - "Domain|Key" -> {{def|Domain|Key}}
-- - "Key" -> plain text pill (no template call)
if s:find("|", 1, true) then
return "{{def|" .. s .. "}}"
end
return mw.text.nowiki(s)
end
end


Line 461: Line 528:
tabs_obj = _safe_tbl(tabs_obj)
tabs_obj = _safe_tbl(tabs_obj)


-- Support either payload.tabs.{...} or top-level {...} keys.
local mechanics = _safe_tbl(tabs_obj.mechanics or tabs_obj.Mechanics or tabs_obj["mechanics"])
local mechanics = _safe_tbl(tabs_obj.mechanics or tabs_obj.Mechanics or tabs_obj["mechanics"])
local keywords  = _safe_tbl(tabs_obj.keywords  or tabs_obj.Keywords  or tabs_obj["keywords"])
local keywords  = _safe_tbl(tabs_obj.keywords  or tabs_obj.Keywords  or tabs_obj["keywords"])
Line 473: Line 539:
local events_count  = _to_int(events.count, #events_cards)
local events_count  = _to_int(events.count, #events_cards)


-- Dynamic 2–4 tab support (Skills defaults to 4).
local tab_specs = {
local tab_specs = {
{ key = "mechanics", title = "Mechanics" },
{ key = "mechanics", title = "Mechanics" },
Line 492: Line 557:
local active_idx = 1
local active_idx = 1


-- Buttons
for i, spec in ipairs(tab_specs) do
for i, spec in ipairs(tab_specs) do
local tab_id, panel_id = _tab_ids(root_id, i)
local tab_id, panel_id = _tab_ids(root_id, i)
Line 545: Line 609:
local pills = panel:tag("div"):addClass("sv-tab-pills")
local pills = panel:tag("div"):addClass("sv-tab-pills")
for _, kw in ipairs(_safe_tbl(keywords.pills)) do
for _, kw in ipairs(_safe_tbl(keywords.pills)) do
local s = _safe_str(kw, "")
local wt = _kw_pill_wikitext(kw)
if s ~= "" then
if wt ~= "" then
-- Definitions already exist; we just wrap them in a pill bubble.
pills:tag("div"):addClass("sv-pill"):wikitext(wt)
pills:tag("div"):addClass("sv-pill"):wikitext("{{def|" .. s .. "}}")
end
end
end
end
end
end


-- Helpers for Effects/Events cards
local function render_ref_icon(parent, icon)
local function render_ref_icon(parent, icon)
icon = _safe_tbl(icon)
icon = _safe_tbl(icon)
Line 575: Line 637:
if page ~= "" then
if page ~= "" then
local t = mw.title.new(page)
local t = mw.title.new(page)
local href = t and t.localUrl and t:localUrl() or nil
local href = (t and t.localUrl) and t:localUrl() or nil
container = parent:tag("a"):addClass("sv-ref-card"):addClass("sv-ref-card--link")
container = parent:tag("a"):addClass("sv-ref-card"):addClass("sv-ref-card--link")
if href then container:attr("href", href) end
if href then container:attr("href", href) end
Line 624: Line 686:
if page ~= "" then
if page ~= "" then
local t = mw.title.new(page)
local t = mw.title.new(page)
local href = t and t.localUrl and t:localUrl() or nil
local href = (t and t.localUrl) and t:localUrl() or nil
container = parent:tag("a"):addClass("sv-ref-card"):addClass("sv-ref-card--link")
container = parent:tag("a"):addClass("sv-ref-card"):addClass("sv-ref-card--link")
if href then container:attr("href", href) end
if href then container:attr("href", href) end
Line 683: Line 745:
end
end


-- Public entrypoint:
-- -----------------------------------------------------------------------------
-- {{#invoke:GameInfo/Skills|render|data=...|notes=...|id=...}}
-- PUBLIC ENTRYPOINT
-- -----------------------------------------------------------------------------
 
function p.render(frame)
function p.render(frame)
frame = frame or mw.getCurrentFrame()
frame = frame or mw.getCurrentFrame()


-- Read args safely (supports parent-args too)
local raw_data = _get_arg(frame, "data")
local raw_data = _get_arg(frame, "data")
local notes_wt = _get_arg(frame, "notes")
local notes_wt = _get_arg(frame, "notes")
local debug = (_get_arg(frame, "debug") == "1")
local debug = (_get_arg(frame, "debug") == "1")


local payload, err = _decode_json(raw_data)
local payload, err, received = _decode_json(frame, raw_data)
if not payload then
if not payload then
local msg = mw.html.create("div")
local msg = mw.html.create("div")
Line 699: Line 762:
:wikitext("GameInfo/Skills error: " .. tostring(err))
:wikitext("GameInfo/Skills error: " .. tostring(err))


-- Optional: show what Lua actually received (turn on with |debug=1)
if debug then
if debug then
local preview = _strip_wrappers(raw_data)
local preview = received or ""
msg:tag("pre"):wikitext(mw.text.nowiki(preview:sub(1, 240)))
local has_strip = preview:find("\127", 1, true) and "1" or "0"
msg:tag("div"):wikitext("len=" .. tostring(#preview))
msg:tag("div"):wikitext("debug: len=" .. tostring(#preview) .. " strip=" .. has_strip)
msg:tag("pre"):wikitext(mw.text.nowiki(preview:sub(1, 800)))
end
end


Line 712: Line 775:
local display_name = _safe_str(identity.display_name, "Unknown Skill")
local display_name = _safe_str(identity.display_name, "Unknown Skill")


-- FIX: use _get_arg (args is not defined in this module)
local idx = _to_int(_get_arg(frame, "index"), 1)
local idx = _to_int(_get_arg(frame, "index"), 1)
if idx < 1 then idx = 1 end
if idx < 1 then idx = 1 end
Line 724: Line 786:
local default_level = _to_int(level_obj.default, 1)
local default_level = _to_int(level_obj.default, 1)
local max_level = _to_int(level_obj.max, _to_int(level_obj.base_max, 1))
local max_level = _to_int(level_obj.max, _to_int(level_obj.base_max, 1))
if max_level < 1 then max_level = 1 end
if max_level < 1 then max_level = 1 end
if default_level < 1 then default_level = 1 end
if default_level < 1 then default_level = 1 end
if default_level > max_level then default_level = max_level end
if default_level > max_level then default_level = max_level end


-- Build base box
local box = GI.new_box({
local box = GI.new_box({
root_id = root_id,
root_id = root_id,
Line 737: Line 797:
})
})


-- Compatibility / Skills classes (match mockup)
-- Skills-specific classes (match your locked baseline selectors)
box.root:addClass("sv-skill-card")
box.root:addClass("sv-skill-card")
box.top:addClass("sv-skill-top")
box.top:addClass("sv-skill-top")
box.bottom:addClass("sv-skill-bottom")
box.bottom:addClass("sv-skill-bottom")


-- TOP: identity + meta + req/users
-- TOP
box.top:node(_build_identity(identity, notes_wt))
box.top:node(_build_identity(identity, notes_wt))
box.top:node(_build_meta_row(payload.meta_row))
box.top:node(_build_meta_row(payload.meta_row))
local reqrow = _build_req_users(payload.requirements, payload.users)
local reqrow = _build_req_users(payload.requirements, payload.users)
if reqrow then box.top:node(reqrow) end
if reqrow then box.top:node(reqrow) end


-- BOTTOM: level + scaling + core + tabs
-- BOTTOM
local level_panel, actual_default = _build_level(payload.level)
local level_panel, actual_default = _build_level(payload.level)
box.bottom:node(level_panel)
box.bottom:node(level_panel)
Line 761: Line 820:
return GI.styles(frame) .. tostring(box.root)
return GI.styles(frame) .. tostring(box.root)
end
end
-- Router-friendly aliases (GameInfo looks for main/render/Skills)
function p.main(frame)  return p.render(frame) end
function p.Skills(frame) return p.render(frame) end
return p

Revision as of 04:39, 19 February 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: Skills renderer (components live here; CSS/JS shared globally).
--
-- Entrypoints (all equivalent):
--   {{#invoke:GameInfo|Skills|data=...|notes=...}}
--   {{#invoke:GameInfo/Skills|render|data=...|notes=...}}
--   {{#invoke:GameInfo/Skills|main|data=...|notes=...}}
--
-- Debug:
--   add |debug=1 to print what Lua actually received for |data=...

local p = {}

local GI = require("Module:GameInfo")

local _file_exists_cache = {}

-- -----------------------------------------------------------------------------
-- BASIC HELPERS
-- -----------------------------------------------------------------------------

local function _trim(s)
	if s == nil then return "" end
	s = tostring(s)
	s = s:gsub("^%s+", ""):gsub("%s+$", "")
	return 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
	n = math.floor(n + 0.0)
	return n
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

-- -----------------------------------------------------------------------------
-- COMPAT / ARG + JSON HELPERS
-- -----------------------------------------------------------------------------

local function _get_arg(frame, key)
	-- Prefer direct args, but allow parent args (compat with routers/wrappers).
	local args = (frame and frame.args) or {}
	local v = _trim(args[key])
	if v ~= "" then return v end

	local parent = (frame and frame.getParent) and frame:getParent() or nil
	local pargs = (parent and parent.args) or {}
	v = _trim(pargs[key])
	if v ~= "" then return v end

	return ""
end

local function _safe_unstrip(raw)
	raw = raw == nil and "" or tostring(raw)

	-- Some wikis have both of these; some have only one; some none.
	if mw.text then
		if type(mw.text.unstripNoWiki) == "function" then
			raw = mw.text.unstripNoWiki(raw)
		end
		if type(mw.text.unstrip) == "function" then
			raw = mw.text.unstrip(raw)
		end
	end

	return raw
end

local function _strip_wrappers(frame, raw)
	raw = raw == nil and "" or tostring(raw)

	-- Try to undo strip markers first.
	raw = _safe_unstrip(raw)

	-- If strip markers still exist, preprocess as a fallback.
	-- (This is safe because |data= is expected to be literal JSON, usually wrapped in <nowiki>.)
	if raw:find("\127", 1, true) and frame and type(frame.preprocess) == "function" then
		raw = frame:preprocess(raw)
		raw = _safe_unstrip(raw)
	end

	raw = _trim(raw)

	-- Remove literal wrapper tags if they survived some path
	raw = raw:gsub("^<nowiki>%s*", ""):gsub("%s*</nowiki>%s*$", "")
	raw = raw:gsub("^<pre>%s*", ""):gsub("%s*</pre>%s*$", "")
	raw = raw:gsub("^<syntaxhighlight[^>]*>%s*", ""):gsub("%s*</syntaxhighlight>%s*$", "")

	return _trim(raw)
end

local function _decode_json(frame, raw)
	raw = _strip_wrappers(frame, raw)
	if raw == "" then return nil, "missing data", raw end

	if not (mw.text and type(mw.text.jsonDecode) == "function") then
		return nil, "mw.text.jsonDecode missing", raw
	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
	return decoded, nil, raw
end

-- -----------------------------------------------------------------------------
-- FILE / ICON HELPERS
-- -----------------------------------------------------------------------------

local function _normalize_file_title(page)
	page = _trim(page)
	if page == "" then return nil end
	if page:match("^[Ff]ile:") then
		return "File:" .. page:sub(6)
	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 t = mw.title.new(file_title)
	local exists = (t and t.exists) == true
	_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

-- -----------------------------------------------------------------------------
-- VALUE RENDERING (display-only)
-- -----------------------------------------------------------------------------

-- Phase 4.1 rule: values are display-only:
-- - { text = "..." } = constant
-- - { series = [...] } = per-level; Lua emits data-series for JS to swap
local function _apply_value(node, val, level)
	if val == nil then
		node:wikitext("—")
		return
	end

	-- Support accidental raw strings/numbers defensively.
	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 _meta_lines(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
			wrap:tag("span"):wikitext(mw.text.nowiki(s))
			any = true
		end
	end

	if not any then
		wrap:tag("span"):wikitext("—")
	end

	return wrap
end

-- -----------------------------------------------------------------------------
-- TOP AREA
-- -----------------------------------------------------------------------------

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

	local d = mw.html.create("details"):addClass("sv-tip")
	local summary = d:tag("summary")
		:addClass("sv-tip-btn")
		:attr("role", "button")
		:attr("tabindex", "0")
		:attr("aria-label", "Notes")
		:attr("aria-expanded", "false")

	-- Info icon (locked baseline)
	summary:tag("span")
		:addClass("sv-ico")
		:addClass("sv-ico--info")
		:attr("aria-hidden", "true")
		:wikitext("i")

	local pop = d:tag("div")
		:addClass("sv-tip-pop")
		:attr("role", "dialog")
		:attr("aria-label", "Notes")

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

	pop:tag("div"):addClass("sv-tip-pop-body"):wikitext(notes_wt)
	return d
end

local function _build_identity(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")
	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(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(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-meta-card")

		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")
		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")
		wrap:tag("div"):addClass("sv-meta-text"):node(_meta_lines(cell.label_lines))
	end

	return meta
end

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

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

	local d = mw.html.create("details"):addClass("sv-disclose")
	local sum = d:tag("summary")
		:attr("role", "button")
		:attr("tabindex", "0")
		:attr("aria-label", label)
		:attr("aria-expanded", "false")

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

	local pop = d:tag("div")
		:addClass("sv-disclose-pop")
		:attr("role", "dialog")
		: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))
	head:tag("div"):addClass("sv-disclose-pop-hint"):wikitext("Click to close")

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

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

		if title ~= "" and #items > 0 then
			ul:tag("li")
				:addClass("sv-disclose-group-title")
				:wikitext(mw.text.nowiki(title))
		end

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

			local li = ul:tag("li")
			local label_node = _meta_lines(it.label_lines)

			local link = _safe_tbl(it.link)
			local page = _safe_str(link.page, "")

			if page ~= "" then
				-- HTML in link label is OK for our use (span lines).
				li:wikitext(string.format("[[%s|%s]]", page, tostring(label_node)))
			else
				li:node(label_node)
			end
		end
	end

	return d, total
end

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

	local req_details, req_n = _build_grouped_disclose("Requirements", _safe_tbl(requirements.groups))
	local usr_details, usr_n = _build_grouped_disclose("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_details then row:node(req_details) end
	if usr_details then row:node(usr_details) end
	return row
end

-- -----------------------------------------------------------------------------
-- BOTTOM AREA
-- -----------------------------------------------------------------------------

local function _build_level(level_obj)
	level_obj = _safe_tbl(level_obj)

	local default = _to_int(level_obj.default, 1)
	local max = _to_int(level_obj.max, _to_int(level_obj.base_max, 1))

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

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

	local ui = root:tag("div"):addClass("sv-level-ui"):addClass("sv-level-ui-centered")
	ui:tag("div")
		:addClass("sv-level-label")
		:wikitext("Level ")
		:tag("span"):addClass("sv-level-num"):wikitext(tostring(default))
	ui:tag("div"):addClass("sv-level-label"):wikitext(" / " .. tostring(max))

	local slider = root:tag("div"):addClass("sv-level-slider")
	slider:tag("input")
		:attr("type", "range")
		:addClass("sv-level-range")
		:attr("min", "1")
		:attr("max", tostring(max))
		:attr("value", tostring(default))
		:attr("step", "1")
		:attr("aria-label", "Skill level select")
		:attr("style", "--sv-steps:" .. tostring(max) .. ";")

	slider:tag("div"):addClass("sv-level-ticklabels"):attr("aria-hidden", "true")
	return root, default, max
end

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

	local root = mw.html.create("div"):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"):addClass("sv-scaling-col--damage")
		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"):addClass("sv-scaling-col--modifier")
		local v = col:tag("div"):addClass("sv-scaling-value")
		_apply_value(v, scaling.modifier, level)
		col:tag("div"):addClass("sv-scaling-label"):wikitext("Modifier")
	end

	do
		local col = grid:tag("div"):addClass("sv-scaling-col"):addClass("sv-scaling-col--scaling")
		local list = col:tag("div"):addClass("sv-scaling-list")
		for _, ln in ipairs(_safe_tbl(scaling.scaling_lines)) do
			local item = list:tag("div"):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-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")

		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 _tab_ids(root_id, idx)
	return root_id .. "-tab-" .. tostring(idx), root_id .. "-panel-" .. tostring(idx)
end

local function _kw_pill_wikitext(s)
	s = _safe_str(s, "")
	if s == "" then return "" end

	-- Compat:
	-- - "Domain|Key" -> {{def|Domain|Key}}
	-- - "Key" -> plain text pill (no template call)
	if s:find("|", 1, true) then
		return "{{def|" .. s .. "}}"
	end
	return mw.text.nowiki(s)
end

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

	local mechanics = _safe_tbl(tabs_obj.mechanics or tabs_obj.Mechanics or tabs_obj["mechanics"])
	local keywords  = _safe_tbl(tabs_obj.keywords  or tabs_obj.Keywords  or tabs_obj["keywords"])
	local effects   = _safe_tbl(tabs_obj.effects   or tabs_obj.Effects   or tabs_obj["effects"])
	local events    = _safe_tbl(tabs_obj.events    or tabs_obj.Events    or tabs_obj["events"])

	local effects_cards = _safe_tbl(effects.cards)
	local events_cards  = _safe_tbl(events.cards)

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

	local tab_specs = {
		{ key = "mechanics", title = "Mechanics" },
		{ key = "keywords",  title = "Keywords"  },
		{ key = "effects",   title = "Effects (" .. tostring(effects_count) .. ")" },
		{ key = "events",    title = "Events (" .. tostring(events_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")

	local active_idx = 1

	for i, spec in ipairs(tab_specs) do
		local tab_id, panel_id = _tab_ids(root_id, i)
		local btn = list:tag("button")
			:addClass("sv-tab")
			:attr("type", "button")
			:attr("role", "tab")
			:attr("id", tab_id)
			:attr("aria-controls", panel_id)

		if i == active_idx then
			btn:attr("aria-selected", "true"):attr("tabindex", "0")
		else
			btn:attr("aria-selected", "false"):attr("tabindex", "-1")
		end

		btn:wikitext(mw.text.nowiki(spec.title))
	end

	-- Panel 1: Mechanics
	do
		local tab_id, panel_id = _tab_ids(root_id, 1)
		local panel = panels:tag("div")
			:addClass("sv-tabpanel")
			:attr("role", "tabpanel")
			:attr("id", panel_id)
			:attr("aria-labelledby", tab_id)
			:attr("data-active", active_idx == 1 and "1" or "0")
		if active_idx ~= 1 then panel:attr("hidden", "hidden") end

		local gridwrap = panel:tag("div"):addClass("sv-kw-root"):tag("div"):addClass("sv-kw-grid")
		for _, item in ipairs(_safe_tbl(mechanics.grid)) do
			item = _safe_tbl(item)
			local cell = gridwrap:tag("div"):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

	-- Panel 2: Keywords
	do
		local tab_id, panel_id = _tab_ids(root_id, 2)
		local panel = panels:tag("div")
			:addClass("sv-tabpanel")
			:attr("role", "tabpanel")
			:attr("id", panel_id)
			:attr("aria-labelledby", tab_id)
			:attr("data-active", active_idx == 2 and "1" or "0")
		if active_idx ~= 2 then panel:attr("hidden", "hidden") end

		local pills = panel:tag("div"):addClass("sv-tab-pills")
		for _, kw in ipairs(_safe_tbl(keywords.pills)) do
			local wt = _kw_pill_wikitext(kw)
			if wt ~= "" then
				pills:tag("div"):addClass("sv-pill"):wikitext(wt)
			end
		end
	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_card(parent, card)
		card = _safe_tbl(card)

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

		local container
		if page ~= "" then
			local t = mw.title.new(page)
			local href = (t and t.localUrl) and t:localUrl() or nil
			container = parent:tag("a"):addClass("sv-ref-card"):addClass("sv-ref-card--link")
			if href then container:attr("href", href) end
		else
			container = parent:tag("div")
				:addClass("sv-ref-card")
				:addClass("sv-ref-card--nolink")
				:attr("tabindex", "0")
				:attr("role", "group")
				:attr("aria-label", title)
		end

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

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

		if card.inline ~= nil then
			local row = text:tag("div"):addClass("sv-ref-title-row")
			row:tag("div"):addClass("sv-ref-title"):wikitext(mw.text.nowiki(title))
			local inline = row:tag("div"):addClass("sv-ref-inline")
			_apply_value(inline, card.inline, level)
			return
		end

		text:tag("div"):addClass("sv-ref-title"):wikitext(mw.text.nowiki(title))

		local stats = card.stats
		if type(stats) == "table" and next(stats) ~= nil then
			local srow = text:tag("div"):addClass("sv-ref-stats")
			local d = srow:tag("span"):addClass("sv-ref-stat")
			_apply_value(d, stats.duration, level)
			local c = srow:tag("span"):addClass("sv-ref-stat")
			_apply_value(c, stats.chance, level)
			local st = srow:tag("span"):addClass("sv-ref-stat")
			_apply_value(st, stats.stacks, level)
		end
	end

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

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

		local container
		if page ~= "" then
			local t = mw.title.new(page)
			local href = (t and t.localUrl) and t:localUrl() or nil
			container = parent:tag("a"):addClass("sv-ref-card"):addClass("sv-ref-card--link")
			if href then container:attr("href", href) end
		else
			container = parent:tag("div")
				:addClass("sv-ref-card")
				:addClass("sv-ref-card--nolink")
				:attr("tabindex", "0")
				:attr("role", "group")
				:attr("aria-label", title)
		end

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

		local text = container:tag("div"):addClass("sv-ref-text")
		local row = text:tag("div"):addClass("sv-ref-title-row"):addClass("sv-ref-title-row--stacked")
		row:tag("div"):addClass("sv-ref-title"):wikitext(mw.text.nowiki(title))
		local sub = row:tag("div"):addClass("sv-ref-sub")
		_apply_value(sub, card.type, level)
	end

	-- Panel 3: Effects
	do
		local tab_id, panel_id = _tab_ids(root_id, 3)
		local panel = panels:tag("div")
			:addClass("sv-tabpanel")
			:attr("role", "tabpanel")
			:attr("id", panel_id)
			:attr("aria-labelledby", tab_id)
			:attr("data-active", active_idx == 3 and "1" or "0")
		if active_idx ~= 3 then panel:attr("hidden", "hidden") end

		local grid = panel:tag("div"):addClass("sv-ref-grid")
		for _, card in ipairs(effects_cards) do
			render_effect_card(grid, card)
		end
	end

	-- Panel 4: Events
	do
		local tab_id, panel_id = _tab_ids(root_id, 4)
		local panel = panels:tag("div")
			:addClass("sv-tabpanel")
			:attr("role", "tabpanel")
			:attr("id", panel_id)
			:attr("aria-labelledby", tab_id)
			:attr("data-active", active_idx == 4 and "1" or "0")
		if active_idx ~= 4 then panel:attr("hidden", "hidden") end

		local grid = panel:tag("div"):addClass("sv-ref-grid")
		for _, card in ipairs(events_cards) do
			render_event_card(grid, card)
		end
	end

	return root
end

-- -----------------------------------------------------------------------------
-- PUBLIC ENTRYPOINT
-- -----------------------------------------------------------------------------

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

	local raw_data = _get_arg(frame, "data")
	local notes_wt = _get_arg(frame, "notes")
	local debug = (_get_arg(frame, "debug") == "1")

	local payload, err, received = _decode_json(frame, raw_data)
	if not payload then
		local msg = mw.html.create("div")
			:addClass("sv-gi-error")
			:wikitext("GameInfo/Skills error: " .. tostring(err))

		if debug then
			local preview = received or ""
			local has_strip = preview:find("\127", 1, true) and "1" or "0"
			msg:tag("div"):wikitext("debug: len=" .. tostring(#preview) .. " strip=" .. has_strip)
			msg:tag("pre"):wikitext(mw.text.nowiki(preview:sub(1, 800)))
		end

		return GI.styles(frame) .. tostring(msg)
	end

	local identity = _safe_tbl(payload.identity)
	local display_name = _safe_str(identity.display_name, "Unknown Skill")

	local idx = _to_int(_get_arg(frame, "index"), 1)
	if idx < 1 then idx = 1 end

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

	local level_obj = _safe_tbl(payload.level)
	local default_level = _to_int(level_obj.default, 1)
	local max_level = _to_int(level_obj.max, _to_int(level_obj.base_max, 1))
	if max_level < 1 then max_level = 1 end
	if default_level < 1 then default_level = 1 end
	if default_level > max_level then default_level = max_level end

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

	-- Skills-specific classes (match your locked baseline selectors)
	box.root:addClass("sv-skill-card")
	box.top:addClass("sv-skill-top")
	box.bottom:addClass("sv-skill-bottom")

	-- TOP
	box.top:node(_build_identity(identity, notes_wt))
	box.top:node(_build_meta_row(payload.meta_row))
	local reqrow = _build_req_users(payload.requirements, payload.users)
	if reqrow then box.top:node(reqrow) end

	-- BOTTOM
	local level_panel, actual_default = _build_level(payload.level)
	box.bottom:node(level_panel)
	box.bottom:node(_build_scaling_top(payload.scaling_top, actual_default))
	box.bottom:node(_build_core_stats(payload.core_stats, actual_default))

	local tabs_obj = _safe_tbl(payload.tabs)
	if next(tabs_obj) == nil then tabs_obj = payload end
	box.bottom:node(_build_tabs(root_id, tabs_obj, actual_default))

	return GI.styles(frame) .. tostring(box.root)
end

-- Router-friendly aliases (GameInfo looks for main/render/Skills)
function p.main(frame)  return p.render(frame) end
function p.Skills(frame) return p.render(frame) end

return p