Files
SurvivorCore/plugin/BuildAdminUi.luau
T
Samuel LisonandClaude Opus 4.8 bbfefbec38 fix(builder): chooser cards were unclickable and misrouted picks
Found by an adversarial review of the Build page before it was ever run — all
three reviewers independently flagged the first one.

1. The "what is this object?" cards were dead. chooserCard parented a
   Size=fromScale(1,1) TextButton into Theme.panel() intending an overlay, but
   Theme.panel() contains a UIListLayout, which lays out EVERY GuiObject child —
   there is no opt-out, and ZIndex does not affect layout. So the button became
   another list row: clicking a card's title/summary did nothing, and the
   oversized button spilled past the card and took the click for the card BELOW,
   applying the WRONG component (which also strips the previous component's
   attributes). This was the page's primary interaction.
   Fixed with Theme.panelButton() — the card itself is the button, matching the
   pattern OverviewPage already uses.

2. Clear did nothing on an object whose only tag was unknown to this engine,
   yet reported success. The page renders exactly that branch with a Clear
   button. BuildAdmin.clear now takes the tags to remove explicitly, and the page
   passes the unknown tags it just displayed — never removing an unlisted tag
   speculatively, since it may belong to another plugin. It also reports
   "nothing to clear" instead of a false success.

3. FieldRow's help text set Position inside a UIListLayout parent, so its indent
   was silently dropped and every help line rendered flush left. Uses padding now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:54:21 +10:00

500 lines
14 KiB
Luau
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--!nonstrict
--[[
BuildAdminUi the Build page (issue #11). Select a Part or Model in Studio and this asks
"what is this object?", then renders that component's setup form from the engine's own schema.
States: engine missing · nothing selected · ineligible class · untagged (the chooser) · tagged
(identity card + form) · unknown tag · multi-select (apply to all). Every write goes through the
plugin main's record() wrapper, so each edit is one undo step.
Adding a component to the engine gives it a page here for free — there is no per-component UI
code.
]]
local Selection = game:GetService("Selection")
local UserInputService = game:GetService("UserInputService")
local Theme = require(script.Parent.Theme)
local FieldRow = require(script.Parent.FieldRow)
local BuildAdminUi = {}
local FOOTER_H = 36
-- Group the schema's attributes by their `group`, preserving first-appearance order.
local function groupAttributes(specs: { any }, includeAdvanced: boolean): ({ string }, { [string]: { any } })
local order, byGroup = {}, {}
for _, spec in specs do
if spec.advanced and not includeAdvanced then
continue
end
local name = spec.group or "Settings"
if not byGroup[name] then
byGroup[name] = {}
table.insert(order, name)
end
table.insert(byGroup[name], spec)
end
return order, byGroup
end
local function countAdvanced(specs: { any }): number
local n = 0
for _, spec in specs do
if spec.advanced then
n += 1
end
end
return n
end
-- One clickable "what is this?" card. Ineligible components render dim and inert, with the reason.
-- The CARD ITSELF is the button when it's pickable: a full-size button parented INTO a panel would
-- be laid out by the panel's UIListLayout as another row (no opt-out; ZIndex doesn't affect
-- layout), leaving the card body dead and the button spilling onto the next card.
local function chooserCard(schema: any, display: any, eligible: boolean, reason: string?, onPick): GuiObject
local panel: any = if eligible then Theme.panelButton() else Theme.panel()
local title = Theme.label({
Size = UDim2.new(1, 0, 0, 20),
Text = display.title,
Font = Theme.FONT_BOLD,
TextSize = 14,
TextColor3 = if eligible then Theme.COLOR.TEXT else Theme.COLOR.DIM,
LayoutOrder = 0,
})
title.Parent = panel
local summary = if eligible then display.summary else `{display.summary}{reason or "not eligible"}`
Theme.label({
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
Text = summary,
TextColor3 = Theme.COLOR.DIM,
TextWrapped = true,
TextSize = 12,
LayoutOrder = 1,
}).Parent =
panel
if eligible then
(panel :: TextButton).MouseButton1Click:Connect(function()
onPick(schema)
end)
Theme.hover(panel, Theme.COLOR.PANEL, Theme.COLOR.PANEL_HOVER)
end
return panel
end
-- actions = { applyType, clear, setField, resetField } — all record()-wrapped by the plugin main.
function BuildAdminUi.mount(container: Frame, BuildAdmin: any, ContentAdmin: any, actions: any): { refresh: () -> () }
local header = Theme.header(container, "Build")
local scroll = Theme.scroll(32, FOOTER_H, 8)
scroll.Parent = container
local footer = Theme.make("Frame", {
Size = UDim2.new(1, 0, 0, FOOTER_H),
Position = UDim2.new(0, 0, 1, -FOOTER_H),
BackgroundColor3 = Theme.COLOR.PANEL,
BorderSizePixel = 0,
}) :: Frame
footer.Parent = container
local status = Theme.label({
Size = UDim2.new(1, -20, 1, 0),
Position = UDim2.fromOffset(10, 0),
Text = "",
TextColor3 = Theme.COLOR.DIM,
TextTruncate = Enum.TextTruncate.AtEnd,
TextSize = 11,
})
status.Parent = footer
local chooserOpen = false -- "Change type" was clicked on an already-tagged instance
local showAdvanced = false
local refresh -- forward declaration (handlers below call it)
local function say(text: string)
status.Text = text
end
local function clearScroll()
for _, child in scroll:GetChildren() do
if not child:IsA("UIBase") then
child:Destroy()
end
end
end
local function note(text: string, order: number, color: Color3?)
Theme.label({
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
Text = text,
TextColor3 = color or Theme.COLOR.DIM,
TextWrapped = true,
TextSize = 12,
LayoutOrder = order,
}).Parent =
scroll
end
-- ── writes ────────────────────────────────────────────────────────────────
local function applyType(instances: { Instance }, schema: any, all: { any })
local res = actions.applyType(instances, schema, all)
chooserOpen = false
refresh()
if typeof(res) == "table" then
local title = BuildAdmin.displayOf(schema).title
local msg = `✓ {res.applied} object(s) → {title}`
if (res.cleared or 0) > 0 then
msg ..= `, cleared {res.cleared} old component(s)`
end
if (res.skipped or 0) > 0 then
msg ..= `, skipped {res.skipped} ineligible`
end
say(msg)
end
end
local function clearType(instances: { Instance }, all: { any }, extraTags: { string }?)
local res = actions.clear(instances, all, extraTags)
chooserOpen = false
refresh()
if typeof(res) == "table" then
local n = res.cleared or 0
say(if n > 0 then `✓ cleared {n} object(s)` else "nothing to clear on that object")
end
end
local function commitField(instance: Instance, spec: any, raw: any)
local res = actions.setField(instance, spec, raw)
refresh()
if typeof(res) == "table" and res.ok == false and res.error then
say(`✗ {spec.label}: {res.error}`)
end
end
-- ── the form for one tagged instance ──────────────────────────────────────
local function buildForm(instance: Instance, schema: any, startOrder: number): number
local order = startOrder
local specs = schema.attributes or {}
local groups, byGroup = groupAttributes(specs, showAdvanced)
for _, groupName in groups do
order += 1
local panel = Theme.panel()
panel.LayoutOrder = order
panel.Parent = scroll
Theme.label({
Size = UDim2.new(1, 0, 0, 20),
Text = groupName,
Font = Theme.FONT_BOLD,
TextSize = 13,
LayoutOrder = 0,
}).Parent =
panel
for i, spec in byGroup[groupName] do
local state = BuildAdmin.readField(instance, spec)
local choices = nil
if spec.ref and ContentAdmin and ContentAdmin.listRoster then
local ok, roster = pcall(ContentAdmin.listRoster, spec.ref)
if ok and typeof(roster) == "table" then
choices = {}
for _, entry in roster do
table.insert(choices, entry.id)
end
end
end
local row = FieldRow.build({
spec = spec,
state = state,
choices = choices,
onCommit = function(raw)
commitField(instance, spec, raw)
end,
onReset = function()
actions.resetField(instance, spec)
refresh()
end,
})
row.LayoutOrder = i
row.Parent = panel
end
end
local advanced = countAdvanced(specs)
if advanced > 0 then
order += 1
local toggle = Theme.button({
Size = UDim2.new(1, 0, 0, 24),
Text = if showAdvanced then "Hide advanced" else `Show advanced ({advanced})`,
TextSize = 12,
LayoutOrder = order,
})
toggle.Parent = scroll
toggle.MouseButton1Click:Connect(function()
showAdvanced = not showAdvanced
refresh()
end)
end
return order
end
-- ── render ────────────────────────────────────────────────────────────────
refresh = function()
clearScroll()
local schemaRead = BuildAdmin.readSchema()
if not schemaRead.ok then
say("")
note(schemaRead.reason or "Engine not found.", 1)
return
end
local all = schemaRead.components
local sel = BuildAdmin.getSelection()
-- Nothing selected: explain, and list what this engine can build.
if sel.count == 0 then
say("")
note("Select a Part or Model in the viewport or Explorer to set it up.", 1)
local order = 1
for _, schema in all do
order += 1
local display = BuildAdmin.displayOf(schema)
local panel = Theme.panel()
panel.LayoutOrder = order
panel.Parent = scroll
Theme.label({
Size = UDim2.new(1, 0, 0, 18),
Text = display.title,
Font = Theme.FONT_BOLD,
TextSize = 13,
LayoutOrder = 0,
}).Parent =
panel
Theme.label({
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
Text = display.summary,
TextColor3 = Theme.COLOR.DIM,
TextWrapped = true,
TextSize = 12,
LayoutOrder = 1,
}).Parent =
panel
end
note("Tip: Content Gatherables “+ Add to World” drops a ready-made one.", order + 1)
return
end
-- Multi-selection: apply/clear in bulk, no per-attribute form.
if sel.count > 1 then
say(`{#sel.eligible} of {sel.count} selected can take a component`)
note(`{sel.count} objects selected ({#sel.eligible} eligible). Applying affects all of them.`, 1)
local order = 1
for _, schema in all do
order += 1
local display = BuildAdmin.displayOf(schema)
local btn = Theme.button({
Size = UDim2.new(1, 0, 0, 26),
Text = `Apply to all — {display.title}`,
TextSize = 13,
LayoutOrder = order,
})
btn.Parent = scroll
btn.MouseButton1Click:Connect(function()
applyType(sel.eligible, schema, all)
end)
end
order += 1
local clearBtn = Theme.button({
Size = UDim2.new(1, 0, 0, 26),
Text = "Clear all",
TextColor3 = Theme.COLOR.DANGER,
TextSize = 13,
LayoutOrder = order,
})
clearBtn.Parent = scroll
clearBtn.MouseButton1Click:Connect(function()
clearType(sel.eligible, all)
end)
return
end
-- Exactly one selected.
local instance = sel.instances[1]
if BuildAdmin.classify(instance) == "other" then
say("")
note(`Selected a {instance.ClassName} — Build works on Parts and Models.`, 1)
return
end
local id = BuildAdmin.identify(instance, schemaRead)
local order = 0
if id.stale then
order += 1
note(
"⚠ This object carries a leftover runtime bind marker (copied out of a Play session)."
.. " Applying or clearing below removes it.",
order,
Theme.COLOR.DANGER
)
end
if id.schema and not chooserOpen then
local display = BuildAdmin.displayOf(id.schema)
-- Identity card + change/clear.
order += 1
local card = Theme.panel()
card.LayoutOrder = order
card.Parent = scroll
Theme.label({
Size = UDim2.new(1, 0, 0, 20),
Text = `This is a {display.title}`,
Font = Theme.FONT_BOLD,
TextSize = 14,
LayoutOrder = 0,
}).Parent =
card
Theme.label({
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
Text = if id.multi
then `⚠ It also carries another component tag — v1 expects one. {display.summary}`
else display.summary,
TextColor3 = Theme.COLOR.DIM,
TextWrapped = true,
TextSize = 12,
LayoutOrder = 1,
}).Parent =
card
local row = Theme.make("Frame", {
Size = UDim2.new(1, 0, 0, 26),
BackgroundTransparency = 1,
LayoutOrder = 2,
}) :: Frame
row.Parent = card
local change = Theme.button({
Size = UDim2.new(0.5, -4, 1, 0),
Text = "Change type",
TextSize = 13,
})
change.Parent = row
change.MouseButton1Click:Connect(function()
chooserOpen = true
refresh()
end)
local clearBtn = Theme.button({
Size = UDim2.new(0.5, -4, 1, 0),
Position = UDim2.new(0.5, 4, 0, 0),
Text = "Clear",
TextColor3 = Theme.COLOR.DANGER,
TextSize = 13,
})
clearBtn.Parent = row
clearBtn.MouseButton1Click:Connect(function()
clearType({ instance }, all)
end)
if display.hint and display.hint ~= "" then
order += 1
note(display.hint, order)
end
order = buildForm(instance, id.schema, order)
say(`{BuildAdmin.countInPlace(id.schema)} {display.title} in this place`)
return
end
-- Untagged (or "Change type"): the chooser.
if #id.unknownTags > 0 and not id.schema then
order += 1
note(
`Tagged “{table.concat(id.unknownTags, ", ")}” — not a component this engine version knows.`,
order
)
order += 1
local clearBtn = Theme.button({
Size = UDim2.new(1, 0, 0, 26),
Text = "Clear",
TextColor3 = Theme.COLOR.DANGER,
TextSize = 13,
LayoutOrder = order,
})
clearBtn.Parent = scroll
clearBtn.MouseButton1Click:Connect(function()
-- Pass the unknown tags we just listed, so Clear removes exactly what was shown.
clearType({ instance }, all, id.unknownTags)
end)
end
order += 1
note(if chooserOpen then "Change this object to…" else "What is this object?", order, Theme.COLOR.TEXT)
for _, schema in all do
order += 1
local eligible, reason = BuildAdmin.isEligible(instance, schema)
local card = chooserCard(schema, BuildAdmin.displayOf(schema), eligible, reason, function(picked)
applyType({ instance }, picked, all)
end)
card.LayoutOrder = order
card.Parent = scroll
end
if chooserOpen then
order += 1
local cancel = Theme.button({
Size = UDim2.new(1, 0, 0, 24),
Text = "Cancel",
TextSize = 12,
LayoutOrder = order,
})
cancel.Parent = scroll
cancel.MouseButton1Click:Connect(function()
chooserOpen = false
refresh()
end)
end
say(`Selected: {instance.Name}`)
end
-- Re-render on selection change. Two guards: the window is rebuilt when the engine appears
-- (so a stale connection must not touch a destroyed frame), and a refresh while the user is
-- typing in one of our boxes would eat the value.
local selfDirty = false
local connection
connection = Selection.SelectionChanged:Connect(function()
if not container.Parent then
connection:Disconnect()
return
end
local focused = UserInputService:GetFocusedTextBox()
if focused and focused:IsDescendantOf(container) then
return
end
chooserOpen = false
if container.Visible then
refresh()
else
selfDirty = true
end
end)
container:GetPropertyChangedSignal("Visible"):Connect(function()
if container.Visible and selfDirty then
selfDirty = false
refresh()
end
end)
header.refreshBtn.MouseButton1Click:Connect(refresh)
refresh()
return { refresh = refresh }
end
return BuildAdminUi