Files
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

223 lines
6.9 KiB
Luau

--!nonstrict
--[[
Theme the plugin's shared visual system. One home for the style constants that used to be
copy-pasted across every UI file, plus the small builders (instance factory, header, scroll,
panel, styled controls) every page composes from. The palette is the dense/dark editor variant
of docs/design-language.md.
]]
local Theme = {}
Theme.COLOR = {
BG = Color3.fromRGB(18, 21, 28),
RAIL = Color3.fromRGB(14, 17, 23), -- sidebar, a step darker than the page
PANEL = Color3.fromRGB(28, 32, 42),
PANEL_HOVER = Color3.fromRGB(34, 39, 52),
FIELD = Color3.fromRGB(38, 43, 56),
TEXT = Color3.fromRGB(235, 238, 245),
DIM = Color3.fromRGB(150, 160, 180),
ACCENT = Color3.fromRGB(120, 170, 255),
DANGER = Color3.fromRGB(210, 90, 90),
STROKE = Color3.fromRGB(210, 220, 245),
}
Theme.FONT = Enum.Font.GothamMedium
Theme.FONT_BOLD = Enum.Font.GothamBold
Theme.ROW_H = 26
-- Generic instance factory: props dict + optional children (reparented in order).
function Theme.make(class: string, props: { [string]: any }, children: { Instance }?): Instance
local inst = Instance.new(class)
for key, value in props do
(inst :: any)[key] = value
end
if children then
for _, child in children do
child.Parent = inst
end
end
return inst
end
function Theme.corner(radius: number): Instance
return Theme.make("UICorner", { CornerRadius = UDim.new(0, radius) })
end
function Theme.stroke(transparency: number?): Instance
return Theme.make("UIStroke", {
Color = Theme.COLOR.STROKE,
Transparency = transparency or 0.9,
ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
})
end
function Theme.pad(left: number, right: number, top: number, bottom: number): Instance
return Theme.make("UIPadding", {
PaddingLeft = UDim.new(0, left),
PaddingRight = UDim.new(0, right),
PaddingTop = UDim.new(0, top),
PaddingBottom = UDim.new(0, bottom),
})
end
-- Styled primitives: sensible defaults, overridable via props.
function Theme.label(props: { [string]: any }): TextLabel
local merged: { [string]: any } = {
BackgroundTransparency = 1,
TextColor3 = Theme.COLOR.TEXT,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 13,
Font = Theme.FONT,
}
for k, v in props do
merged[k] = v
end
return Theme.make("TextLabel", merged) :: TextLabel
end
function Theme.button(props: { [string]: any }): TextButton
local merged: { [string]: any } = {
BackgroundColor3 = Theme.COLOR.FIELD,
AutoButtonColor = true,
TextColor3 = Theme.COLOR.TEXT,
TextSize = 12,
Font = Theme.FONT,
BorderSizePixel = 0,
}
for k, v in props do
merged[k] = v
end
return Theme.make("TextButton", merged, { Theme.corner(4) }) :: TextButton
end
function Theme.textBox(props: { [string]: any }): TextBox
local merged: { [string]: any } = {
BackgroundColor3 = Theme.COLOR.FIELD,
TextColor3 = Theme.COLOR.TEXT,
TextSize = 13,
Font = Theme.FONT,
ClearTextOnFocus = false,
BorderSizePixel = 0,
}
for k, v in props do
merged[k] = v
end
return Theme.make("TextBox", merged, {
Theme.corner(4),
Theme.make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }),
}) :: TextBox
end
-- The standard 32px page header: bold title left, Refresh button right.
function Theme.header(container: Instance, title: string): { frame: Frame, refreshBtn: TextButton }
local frame = Theme.make("Frame", {
Size = UDim2.new(1, 0, 0, 32),
BackgroundTransparency = 1,
}) :: Frame
frame.Parent = container
Theme.label({
Size = UDim2.new(1, -84, 1, 0),
Position = UDim2.fromOffset(12, 0),
Text = title,
TextSize = 15,
Font = Theme.FONT_BOLD,
TextTruncate = Enum.TextTruncate.AtEnd,
}).Parent =
frame
local refreshBtn = Theme.button({
Size = UDim2.fromOffset(72, 22),
Position = UDim2.new(1, -80, 0.5, -11),
BackgroundColor3 = Theme.COLOR.PANEL,
Text = "Refresh",
})
refreshBtn.Parent = frame
return { frame = frame, refreshBtn = refreshBtn }
end
-- The standard page scroll: CanvasSize reset + AutomaticCanvasSize.Y + list layout. Keep this
-- exact recipe — the explicit `CanvasSize = UDim2.new()` matters (the default canvas adds
-- phantom scroll).
function Theme.scroll(offsetTop: number, offsetBottom: number, listPadding: number?): ScrollingFrame
return Theme.make("ScrollingFrame", {
Size = UDim2.new(1, 0, 1, -(offsetTop + offsetBottom)),
Position = UDim2.fromOffset(0, offsetTop),
BackgroundTransparency = 1,
BorderSizePixel = 0,
ScrollBarThickness = 6,
CanvasSize = UDim2.new(),
AutomaticCanvasSize = Enum.AutomaticSize.Y,
}, {
Theme.make("UIListLayout", {
Padding = UDim.new(0, listPadding or 4),
SortOrder = Enum.SortOrder.LayoutOrder,
}),
Theme.pad(10, 10, 6, 12),
}) :: ScrollingFrame
end
-- The standard content panel: PANEL background, rounded, subtle stroke, padded list layout.
-- Grows with content by default (AutomaticSize.Y); override Size/AutomaticSize for fixed panels.
function Theme.panel(): Frame
return Theme.make("Frame", {
BackgroundColor3 = Theme.COLOR.PANEL,
BorderSizePixel = 0,
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
}, {
Theme.corner(8),
Theme.stroke(),
Theme.pad(8, 8, 6, 8),
Theme.make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
}) :: Frame
end
-- A CLICKABLE panel: identical styling to Theme.panel, but the card itself is the button.
-- Use this instead of parenting a full-size button "overlay" into a panel — the panel's own
-- UIListLayout lays out EVERY GuiObject child (there is no opt-out, and ZIndex doesn't change
-- layout), so such an "overlay" becomes another list row: the card body stops being clickable and
-- the oversized button spills onto whatever is rendered next.
function Theme.panelButton(): TextButton
return Theme.make("TextButton", {
BackgroundColor3 = Theme.COLOR.PANEL,
BorderSizePixel = 0,
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
Text = "",
AutoButtonColor = false, -- Theme.hover owns the highlight, matching the other cards
}, {
Theme.corner(8),
Theme.stroke(),
Theme.pad(8, 8, 6, 8),
Theme.make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
}) :: TextButton
end
-- A small count/status pill (rail badges, entry badges).
function Theme.badge(text: string, textColor: Color3?): TextLabel
return Theme.make("TextLabel", {
AutomaticSize = Enum.AutomaticSize.X,
Size = UDim2.fromOffset(0, 14),
BackgroundColor3 = Theme.COLOR.FIELD,
BorderSizePixel = 0,
Text = text,
TextColor3 = textColor or Theme.COLOR.DIM,
TextSize = 10,
Font = Theme.FONT,
}, {
Theme.corner(7),
Theme.make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }),
}) :: TextLabel
end
-- MouseEnter/Leave background tint for custom (non-AutoButtonColor) elements.
function Theme.hover(gui: GuiObject, base: Color3, hoverColor: Color3)
gui.MouseEnter:Connect(function()
gui.BackgroundColor3 = hoverColor
end)
gui.MouseLeave:Connect(function()
gui.BackgroundColor3 = base
end)
end
return Theme