<roblox version="4">
  <Item class="Folder" referent="0">
    <Properties>
      <string name="Name">BloxUI Installer</string>
    </Properties>
    <Item class="ModuleScript" referent="1">
      <Properties>
        <string name="Name">Builder</string>
        <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	BloxUI UI Builder: the dock widget where you type a prompt, improve it into
	a design brief, generate a full game UI, tweak it, preview it live in the
	viewport and insert it into your game. Generating runs on the BloxUI
	server (Cloud / Remote, docs/API.md); a banner under the tabs says when a
	newer plugin is out (or required) and shows the server's notice.

		local Builder = require(script.Parent.Builder)
		local builder = Builder.start(plugin, { installer = installer, button = toolbarButton })
		builder:toggle()      -- show / hide the widget
		builder:destroy()     -- plugin.Unloading

	The first time it opens after an update, a "What's new" dialog lists the
	release notes (WhatsNew.luau / Changelog.luau); Settings reopens it.

	The widget content is built the first time it is opened, so Studio starts
	fast. The working state (store, preview, undo history) lives in one scope;
	the widget's instances live in an inner scope that is rebuilt when the theme
	changes (it follows Studio's Light / Dark theme unless you pick one in
	Settings). Everything is cleaned up (including the viewport preview) when
	the widget closes or the plugin unloads.

	Keyboard (mouse over the widget, not while typing in a box):
		Ctrl+Z / Ctrl+Y (Ctrl+Shift+Z)   undo / redo edits
		Ctrl+Enter                       generate
		Ctrl+1..4, Ctrl+,                switch tab, settings
		Esc                              close a menu or dialog
		Structure tab: arrows, Enter, Delete, Ctrl+D, H, Alt+arrows (see Tabs/Structure)
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Deps = require(script.Deps)
local Style = require(script.Style)
local Ui = require(script.Ui)
local Overlay = require(script.Overlay)
local Brand = require(script.Brand)
local Store = require(script.Store)
local Preview = require(script.Preview)
local Modules = require(script.Modules)
local Export = require(script.Export)
local Installer = require(script.Installer)
local Catalog = require(script.Catalog)
local History = require(script.History)
local Cloud = require(script.Cloud)
local Version = require(script.Version)
local WhatsNew = require(script.WhatsNew)

local Fusion = Deps.Fusion
local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local C = Style.Color
local S = Style.Space

local HEADER = 52
local TABBAR = 40
local FOOTER = 48
local BANNER_ROW = 38 -- each row of the update / notice banner

local THEME_SETTING = "BloxUIBuilder_Theme"

local TABS = {
	{ id = "Create", label = "Create", icon = "cursor-stars", module = "Create", key = "One" },
	{ id = "Design", label = "Design", icon = "paint-brush", module = "Design", key = "Two" },
	{ id = "Structure", label = "Structure", icon = "three-box-flowchart", module = "Structure", key = "Three" },
	{ id = "Export", label = "Export", icon = "arrow-down-to-line", module = "Export", key = "Four" },
}

local MODIFIERS = {
	LeftControl = "ctrl",
	RightControl = "ctrl",
	LeftMeta = "ctrl",
	RightMeta = "ctrl",
	LeftSuper = "ctrl",
	RightSuper = "ctrl",
	LeftShift = "shift",
	RightShift = "shift",
	LeftAlt = "alt",
	RightAlt = "alt",
}

local Builder = {}
Builder.__index = Builder
Builder.WIDGET_ID = "BloxUIBuilder"
Builder.THEME_SETTING = THEME_SETTING
Builder.VERSION = Version.plugin

--------------------------------------------------------------------------
-- progress (parsed from the store's progress text)
--------------------------------------------------------------------------

--[[
	What the progress panel shows, from the store's progress values:
	Builder.describeProgress("generate", "Designing Shop", 0.45, { index = 3, total = 8 }, review)
	-> { kind, stage, fraction, index, total, step, steps }.
	step: 1 brief, 2 screens, 3 assemble, 4 review. `review` = Claude will run
	its design review (AI mode, quality "best"), which makes it 4 steps.
	The AI's stage names decide the step; the fraction is the fallback.
]]
function Builder.describeProgress(kind: string?, stage: string?, fraction: number?, count: any?, review: boolean?): { [string]: any }
	local text = if typeof(stage) == "string" then stage else ""
	local step = 1
	if kind == "generate" then
		local lower = string.lower(text)
		local function has(word: string): boolean
			return string.find(lower, word, 1, true) ~= nil
		end
		if review and (has("review") or has("applying") or has(" fix")) then
			step = 4
		elseif has("together") or has("check") then
			step = 3
		elseif has("design") or has("warming") or has("generat") or has("rolling") then
			step = 2
		elseif has("improv") or has("brief") or has("studying") then
			step = 1
		elseif typeof(fraction) == "number" then
			if review and fraction >= 0.86 then
				step = 4
			elseif fraction >= (if review then 0.82 else 0.9) then
				step = 3
			elseif fraction >= 0.3 then
				step = 2
			end
		else
			step = 2
		end
	end
	return {
		kind = kind,
		stage = if text ~= "" then text else (if kind == "improve" then "Writing the design brief" else "Designing your UI"),
		fraction = if typeof(fraction) == "number" then fraction else nil,
		index = if typeof(count) == "table" then count.index else nil,
		total = if typeof(count) == "table" then count.total else nil,
		step = step,
		steps = if kind == "generate" and review then 4 else 3,
	}
end

function Builder.formatElapsed(seconds: number): string
	seconds = math.max(0, math.floor(seconds))
	return string.format("%d:%02d", seconds // 60, seconds % 60)
end

--------------------------------------------------------------------------
-- chrome
--------------------------------------------------------------------------

local function header(scope: any, app: any): Frame
	local store = app.store
	local history = app.history
	local modeState, modeEvents = Ui.interact(scope)
	local busy = scope:Computed(function(use)
		return use(store.busy) ~= nil
	end)
	-- "Opus 5", or the ChatGPT model ("GPT-6 Sol") while AI runs on the user's OpenAI key
	local modelLabel = scope:Computed(function(use)
		local provider = store:aiProvider(use)
		local model = store:modelFor(provider, use)
		for _, entry in store:modelOptions(provider) do
			if entry.value == model then
				local label = string.gsub(entry.label, "^Claude ", "")
				label = string.gsub(label, "%s*%(.-%)", "")
				return label
			end
		end
		return model
	end)
	local cloud = app.cloud
	-- what the pill says while idle: the account / server state first, then the mode
	local pillState = scope:Computed(function(use)
		if use(cloud.update).required then
			return "update"
		elseif not use(cloud.signedIn) then
			return "signedout"
		elseif use(cloud.reachable) == false then
			return "offline"
		end
		return if use(store.mode) == "AI" then "ai" else "instant"
	end)
	local isAI = scope:Computed(function(use)
		return use(pillState) == "ai"
	end)
	local alert = scope:Computed(function(use)
		local state = use(pillState)
		return not use(busy) and (state == "update" or state == "offline")
	end)
	local compact = scope:Computed(function(use)
		return not use(app.wide)
	end)

	local pillLabel = scope:Computed(function(use)
		if use(busy) then
			local progress = use(app.progress)
			local pct = if progress.fraction then ` {math.floor(progress.fraction * 100 + 0.5)}%` else ""
			if use(compact) then
				return if pct ~= "" then string.sub(pct, 2) else "Working"
			end
			local label = if progress.kind == "improve" then "Writing brief" elseif progress.step == 4 then "Reviewing" else "Generating"
			return label .. pct
		end
		local state = use(pillState)
		if state == "update" then
			return "Update"
		elseif state == "signedout" then
			return "Sign in"
		elseif state == "offline" then
			return "Offline"
		elseif state == "ai" then
			return if use(compact) then "AI" else "AI · " .. use(modelLabel)
		end
		return "Instant"
	end)

	local undoRedo = Ui.row(scope, {
		Name = "UndoRedo",
		Gap = 2,
		Size = UDim2.fromOffset(58, 28),
		LayoutOrder = 3,
		Visible = store.hasBlueprint,
		[Children] = {
			Ui.iconButton(scope, {
				Name = "UndoButton",
				Icon = "arrow-curl-to-left",
				Size = 28,
				IconSize = 16,
				Tooltip = "Undo  (Ctrl+Z)",
				LayoutOrder = 1,
				Disabled = scope:Computed(function(use)
					return not use(history.canUndo) or use(busy)
				end),
				OnClick = function()
					history:undo()
				end,
			}),
			Ui.iconButton(scope, {
				Name = "RedoButton",
				Icon = "arrow-curl-to-right",
				Size = 28,
				IconSize = 16,
				Tooltip = "Redo  (Ctrl+Y)",
				LayoutOrder = 2,
				Disabled = scope:Computed(function(use)
					return not use(history.canRedo) or use(busy)
				end),
				OnClick = function()
					history:redo()
				end,
			}),
		},
	})

	local pill = scope:New "TextButton" (Ui.merge(modeEvents, {
		Name = "ModePill",
		Text = "",
		AutoButtonColor = false,
		Size = UDim2.fromOffset(0, 26),
		AutomaticSize = Enum.AutomaticSize.X,
		LayoutOrder = 4,
		BackgroundColor3 = scope:Computed(function(use)
			local hovered = use(modeState) == "hover"
			if use(isAI) or use(busy) then
				return if hovered then C.AccentSoftHover else C.AccentSoft
			elseif use(alert) then
				return C.WarningSoft
			end
			return if hovered then C.RaisedHover else C.Raised
		end),
		[OnEvent "Activated"] = function()
			local state = peek(pillState)
			if peek(store.busy) ~= nil then
				store.tab:set("Create")
			elseif state == "update" then
				app.showUpdate()
			elseif state == "signedout" then
				store.tab:set("Settings")
			elseif state == "offline" then
				cloud:refreshStatus()
				store:toast("Checking the connection to the BloxUI server...", "info")
			elseif peek(store.mode) == "AI" then
				store:setMode("Offline")
			else
				store:setMode("AI")
			end
		end,
		[Children] = {
			Ui.corner(scope, Style.Radius.Pill),
			Ui.padding(scope, 0, 10),
			Ui.list(scope, "X", 6, "Center", "Center"),
			scope:New "Frame" {
				Name = "Dot",
				Size = UDim2.fromOffset(6, 6),
				LayoutOrder = 1,
				Visible = scope:Computed(function(use)
					return not use(busy)
				end),
				BackgroundColor3 = scope:Computed(function(use)
					local state = use(pillState)
					if state == "ai" then
						return C.Accent
					elseif state == "instant" then
						return C.Success
					elseif state == "signedout" then
						return C.TextMuted
					end
					return C.Warning
				end),
				[Children] = Ui.corner(scope, Style.Radius.Pill),
			},
			Ui.spinner(scope, {
				Name = "Spinner",
				Size = 13,
				Active = busy,
				Visible = busy,
				LayoutOrder = 1,
			}),
			Ui.text(scope, {
				Name = "Label",
				Text = pillLabel,
				TextSize = Style.Text.Caption,
				Weight = "SemiBold",
				Color = scope:Computed(function(use)
					if use(isAI) or use(busy) then
						return C.AccentText
					end
					return if use(alert) then C.Warning else C.TextSub
				end),
				LayoutOrder = 2,
			}),
		},
	}))
	Ui.tooltip(scope, pill, scope:Computed(function(use)
		if use(busy) then
			return "Show progress"
		end
		local state = use(pillState)
		if state == "update" then
			return "This plugin needs an update to keep generating. Click for how."
		elseif state == "signedout" then
			return "Signed out. Click to sign in."
		elseif state == "offline" then
			return "Can't reach the BloxUI server. Click to check again."
		end
		local ai = Store.aiLabel(store:aiProvider(use))
		return if state == "ai" then `{ai} mode. Click for Instant.` else `Instant mode. Click for {ai}.`
	end))

	return scope:New "Frame" {
		Name = "Header",
		BackgroundColor3 = C.Panel,
		BorderSizePixel = 0,
		Size = UDim2.new(1, 0, 0, HEADER),
		ZIndex = 2,
		[Children] = {
			scope:New "Frame" {
				Name = "Border",
				BackgroundColor3 = C.Border,
				BorderSizePixel = 0,
				AnchorPoint = Vector2.new(0, 1),
				Position = UDim2.fromScale(0, 1),
				Size = UDim2.new(1, 0, 0, 1),
			},
			scope:New "Frame" {
				Name = "Content",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				[Children] = {
					Ui.padding(scope, 0, S.M, 0, S.L),
					Ui.list(scope, "X", 8, "Left", "Center"),
					-- the plugin's icon (Brand.ICON) when it is set, else the drawn mark
					if Brand.ICON ~= "" then scope:New "ImageLabel" {
						Name = "Mark",
						Size = UDim2.fromOffset(28, 28),
						BackgroundTransparency = 1,
						Image = Brand.ICON,
						ScaleType = Enum.ScaleType.Fit,
						LayoutOrder = 1,
					} else scope:New "Frame" {
						Name = "Mark",
						Size = UDim2.fromOffset(28, 28),
						BackgroundColor3 = C.White,
						LayoutOrder = 1,
						[Children] = {
							Ui.corner(scope, 8),
							scope:New "UIGradient" {
								Color = ColorSequence.new(C.Accent, C.Accent2),
								Rotation = 45,
							},
							Ui.stroke(scope, C.White, 1, 0.8),
							Ui.icon(scope, {
								Icon = "cursor-stars",
								Size = 16,
								Bold = true,
								Color = C.White,
								AnchorPoint = Vector2.new(0.5, 0.5),
								Position = UDim2.fromScale(0.5, 0.5),
							}),
						},
					},
					scope:New "Frame" {
						Name = "Titles",
						BackgroundTransparency = 1,
						Size = UDim2.new(0, 0, 0, 34),
						LayoutOrder = 2,
						[Children] = {
							Ui.flex(scope, "Fill"),
							Ui.list(scope, "Y", 0, "Left", "Center"),
							Ui.text(scope, {
								Name = "Title",
								Text = "UI Builder",
								Weight = "Bold",
								TextSize = Style.Text.Title,
								Size = UDim2.new(1, 0, 0, 19),
								Truncate = true,
								LayoutOrder = 1,
							}),
							Ui.text(scope, {
								Name = "Subtitle",
								Text = `BloxUI {Version.plugin}`,
								TextSize = 11,
								Color = C.TextMuted,
								Size = UDim2.new(1, 0, 0, 14),
								Truncate = true,
								LayoutOrder = 2,
							}),
						},
					},
					undoRedo,
					pill,
					Ui.iconButton(scope, {
						Name = "SettingsButton",
						Icon = "gear",
						Size = 30,
						IconSize = 17,
						Tooltip = "Settings  (Ctrl+,)",
						LayoutOrder = 5,
						Active = scope:Computed(function(use)
							return use(store.tab) == "Settings"
						end),
						OnClick = function()
							if peek(store.tab) == "Settings" then
								store.tab:set(app.lastTab or "Create")
							else
								store.tab:set("Settings")
							end
						end,
					}),
				},
			},
		},
	}
end

local function tabBar(scope: any, app: any): Frame
	local store = app.store
	local buttons = {}
	for index, tab in TABS do
		local state, events = Ui.interact(scope)
		local active = scope:Computed(function(use)
			return use(store.tab) == tab.id
		end)
		local color = scope:Computed(function(use)
			if use(active) then
				return C.Text
			end
			return if use(state) == "hover" then C.TextSub else C.TextMuted
		end)
		local button = scope:New "TextButton" (Ui.merge(events, {
			Name = "Tab_" .. tab.id,
			Text = "",
			AutoButtonColor = false,
			BackgroundTransparency = 1,
			Size = UDim2.new(1 / #TABS, 0, 1, 0),
			LayoutOrder = index,
			[OnEvent "Activated"] = function()
				store.tab:set(tab.id)
			end,
			[Children] = {
				scope:New "Frame" {
					Name = "Content",
					BackgroundTransparency = 1,
					Size = UDim2.fromScale(1, 1),
					[Children] = {
						Ui.list(scope, "X", 6, "Center", "Center"),
						Ui.icon(scope, {
							Icon = tab.icon,
							Size = 15,
							LayoutOrder = 1,
							Visible = app.wide,
							Color = scope:Computed(function(use)
								return if use(active) then C.AccentText else use(color)
							end),
						}),
						Ui.text(scope, {
							Text = tab.label,
							TextSize = Style.Text.Small,
							Font = scope:Computed(function(use)
								return if use(active) then Style.Font.SemiBold else Style.Font.Medium
							end),
							Color = color,
							LayoutOrder = 2,
						}),
						-- generation running while you look at another tab
						if tab.id == "Create"
							then scope:New "Frame" {
								Name = "BusyDot",
								Size = UDim2.fromOffset(6, 6),
								BackgroundColor3 = C.Accent,
								LayoutOrder = 3,
								Visible = scope:Computed(function(use)
									return use(store.busy) ~= nil and not use(active)
								end),
								[Children] = Ui.corner(scope, Style.Radius.Pill),
							}
							else nil,
					},
				},
				scope:New "Frame" {
					Name = "Underline",
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.fromScale(0.5, 1),
					Size = UDim2.new(1, -20, 0, 2),
					BackgroundColor3 = C.Accent,
					BorderSizePixel = 0,
					Visible = active,
					[Children] = Ui.corner(scope, 1),
				},
			},
		}))
		Ui.tooltip(scope, button, `{tab.label}  (Ctrl+{index})`)
		table.insert(buttons, button)
	end
	return scope:New "Frame" {
		Name = "TabBar",
		BackgroundColor3 = C.Panel,
		BorderSizePixel = 0,
		Position = UDim2.fromOffset(0, HEADER),
		Size = UDim2.new(1, 0, 0, TABBAR),
		ZIndex = 2,
		[Children] = {
			scope:New "Frame" {
				Name = "Border",
				BackgroundColor3 = C.Border,
				BorderSizePixel = 0,
				AnchorPoint = Vector2.new(0, 1),
				Position = UDim2.fromScale(0, 1),
				Size = UDim2.new(1, 0, 0, 1),
			},
			scope:New "Frame" {
				Name = "Tabs",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				[Children] = {
					Ui.padding(scope, 0, S.S),
					Ui.list(scope, "X", 0, "Left", "Center"),
					buttons,
				},
			},
		},
	}
end

--[[
	The strip under the tabs (0, 1 or 2 rows): "Update available" / "Update
	required" with Open (the update dialog), and the server's notice. Returns
	the frame and a Computed of its height.
]]
local function bannerRow(scope: any, props: { [string]: any }): Frame
	local tone = props.Tone -- state: "accent" | "danger" | "info"
	local function toneColors(use): (Color3, Color3)
		local name = use(tone)
		if name == "danger" then
			return C.DangerSoft, C.Danger
		elseif name == "info" then
			return C.InfoSoft, C.Info
		end
		return C.AccentSoft, C.AccentText
	end
	local row = scope:New "Frame" {
		Name = props.Name,
		BackgroundColor3 = scope:Computed(function(use)
			return (toneColors(use))
		end),
		BorderSizePixel = 0,
		Size = UDim2.new(1, 0, 0, BANNER_ROW),
		LayoutOrder = props.LayoutOrder,
		Visible = props.Visible,
		[Children] = {
			scope:New "Frame" {
				Name = "Border",
				BackgroundColor3 = C.Border,
				BorderSizePixel = 0,
				AnchorPoint = Vector2.new(0, 1),
				Position = UDim2.fromScale(0, 1),
				Size = UDim2.new(1, 0, 0, 1),
			},
			scope:New "Frame" {
				Name = "Content",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				[Children] = {
					Ui.padding(scope, 0, S.S, 0, S.M),
					Ui.list(scope, "X", 8, "Left", "Center"),
					Ui.icon(scope, {
						Icon = props.Icon,
						Size = 15,
						Bold = true,
						LayoutOrder = 1,
						Color = scope:Computed(function(use)
							return select(2, toneColors(use))
						end),
					}),
					Ui.text(scope, {
						Name = "Text",
						Text = props.Text,
						-- a notice may run to two lines (the full text is in the tooltip)
						TextSize = if props.Wrap then Style.Text.Caption else Style.Text.Small,
						Weight = "Medium",
						Color = C.Text,
						Size = UDim2.new(0, 0, 1, if props.Wrap then -6 else 0),
						Wrap = props.Wrap,
						LineHeight = if props.Wrap then 1.05 else 1,
						Truncate = true,
						LayoutOrder = 2,
						[Children] = Ui.flex(scope, "Fill"),
					}),
					props.Button,
					if props.OnDismiss
						then Ui.iconButton(scope, {
							Name = "Dismiss",
							Icon = "x-small",
							Size = 24,
							IconSize = 14,
							Tooltip = "Hide until next time",
							LayoutOrder = 4,
							Visible = props.CanDismiss,
							OnClick = props.OnDismiss,
						})
						else nil,
				},
			},
		},
	}
	if props.Tooltip then
		Ui.tooltip(scope, row, props.Tooltip)
	end
	return row
end

local function banner(scope: any, app: any): (Frame, any)
	local cloud = app.cloud
	local builder = app.builder
	local update = cloud.update
	local showUpdate = scope:Computed(function(use)
		local info = use(update)
		return info.available and (info.required or use(builder.dismissedUpdate) ~= info.latest)
	end)
	local showNotice = scope:Computed(function(use)
		local notice = use(update).notice
		return notice ~= nil and notice ~= "" and use(builder.dismissedNotice) ~= notice
	end)
	local height = scope:Computed(function(use)
		return ((if use(showUpdate) then 1 else 0) + (if use(showNotice) then 1 else 0)) * BANNER_ROW
	end)
	local frame = scope:New "Frame" {
		Name = "Banner",
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(0, HEADER + TABBAR),
		Size = scope:Computed(function(use)
			return UDim2.new(1, 0, 0, use(height))
		end),
		ClipsDescendants = true,
		ZIndex = 2,
		[Children] = {
			Ui.list(scope, "Y", 0),
			bannerRow(scope, {
				Name = "UpdateBanner",
				LayoutOrder = 1,
				Visible = showUpdate,
				Icon = "circle-arrow-up",
				Tone = scope:Computed(function(use)
					return if use(update).required then "danger" else "accent"
				end),
				Text = scope:Computed(function(use)
					local info = use(update)
					if info.required then
						return `Update required: you have {info.current}, need {info.min or "?"}+.`
					end
					return `Update available: BloxUI {info.latest or "?"} (you have {info.current}).`
				end),
				Button = Ui.button(scope, {
					Name = "UpdateOpen",
					Text = "Open",
					Variant = "soft",
					Size = "sm",
					LayoutOrder = 3,
					Tooltip = "How to update (the download link)",
					OnClick = function()
						app.showUpdate()
					end,
				}),
				CanDismiss = scope:Computed(function(use)
					return not use(update).required
				end),
				OnDismiss = function()
					builder.dismissedUpdate:set(peek(update).latest)
				end,
			}),
			bannerRow(scope, {
				Name = "NoticeBanner",
				LayoutOrder = 2,
				Visible = showNotice,
				Icon = "megaphone",
				Tone = "info",
				Wrap = true,
				Text = scope:Computed(function(use)
					return use(update).notice or ""
				end),
				Tooltip = scope:Computed(function(use)
					return use(update).notice or ""
				end),
				CanDismiss = true,
				OnDismiss = function()
					builder.dismissedNotice:set(peek(update).notice)
				end,
			}),
		},
	}
	return frame, height
end

-- The "how to update" dialog: plugins can't open a browser or the clipboard,
-- so the download link sits selected in a read-only box (Ctrl+C copies it).
Builder.DOWNLOAD_URL = "https://ui.bloxmaps.com/download/bloxui"

local function updateDialog(app: any)
	local cloud = app.cloud
	local overlay = app.overlay
	local info = peek(cloud.update)
	local url = info.url or Builder.DOWNLOAD_URL
	local width = math.min(340, overlay:maxWidth())
	local body = if info.required
		then `The BloxUI server needs plugin {info.min or "?"} or newer; you have {info.current}. Generating is paused until you update. Editing, preview, Insert and Export still work.`
		elseif info.available then `BloxUI {info.latest or "?"} is out; you have {info.current}.`
		else `You have the latest BloxUI plugin ({info.current}).`
	local steps = {
		"1. Open the download link in your browser (it's selected below: press Ctrl+C to copy it).",
		"2. Save the new BloxUI.rbxmx into your Studio Plugins folder (Plugins → Plugins Folder), replacing the old file.",
		"3. Restart Studio (or reopen your place). Your BloxUI licence key stays signed in.",
	}
	local charsPerLine = math.max(20, math.floor((width - 32) / 6.2))
	local function lines(text: string): number
		return math.max(1, math.ceil(#text / charsPerLine))
	end
	local bodyHeight = lines(body) * 17 + 2
	local stepHeights = {}
	for i, step in steps do
		stepHeights[i] = lines(step) * 16 + 2
	end
	local height = 16 + 28 + 8 + bodyHeight + 8 + stepHeights[1] + 8 + 30 + 8 + stepHeights[2] + 8 + stepHeights[3] + 12 + 32 + 16
	local entry = overlay:open({
		name = "UpdateDialog",
		modal = true,
		width = width,
		height = height,
		build = function(scope, close)
			local function stepText(index: number, order: number)
				return Ui.text(scope, {
					Name = "Step" .. index,
					Text = steps[index],
					TextSize = Style.Text.Small,
					Color = C.TextSub,
					Wrap = true,
					LineHeight = 1.1,
					Size = UDim2.new(1, 0, 0, stepHeights[index]),
					YAlign = Enum.TextYAlignment.Top,
					LayoutOrder = order,
				})
			end
			return {
				Ui.padding(scope, 16),
				Ui.list(scope, "Y", 8),
				Ui.row(scope, {
					Gap = 10,
					Height = 28,
					LayoutOrder = 1,
					[Children] = {
						Ui.iconBubble(scope, {
							Icon = "circle-arrow-up",
							Background = if info.required then C.DangerSoft else C.AccentSoft,
							Color = if info.required then C.Danger else C.AccentText,
							Size = 28,
							LayoutOrder = 1,
						}),
						Ui.text(scope, {
							Name = "Title",
							Text = if info.required then "Update required" else "Update BloxUI",
							Weight = "SemiBold",
							TextSize = Style.Text.Title,
							Truncate = true,
							Size = UDim2.new(1, -38, 0, 28),
							LayoutOrder = 2,
						}),
					},
				}),
				Ui.text(scope, {
					Name = "Body",
					Text = body,
					TextSize = Style.Text.Small,
					Color = C.Text,
					Wrap = true,
					LineHeight = 1.15,
					Size = UDim2.new(1, 0, 0, bodyHeight),
					YAlign = Enum.TextYAlignment.Top,
					LayoutOrder = 2,
				}),
				stepText(1, 3),
				Ui.input(scope, {
					Name = "UpdateUrl",
					Value = url,
					Icon = "arrow-up-right-from-square",
					Height = 30,
					TextSize = Style.Text.Caption,
					Disabled = true,
					LayoutOrder = 4,
				}),
				stepText(2, 5),
				stepText(3, 6),
				Ui.row(scope, {
					Gap = 8,
					Align = "Right",
					LayoutOrder = 7,
					Height = 32,
					[Children] = {
						Ui.button(scope, {
							Name = "CheckAgain",
							Text = "Check again",
							Variant = "ghost",
							LayoutOrder = 1,
							OnClick = function()
								cloud:refreshStatus()
								close()
							end,
						}),
						Ui.button(scope, {
							Name = "Done",
							Text = "Done",
							Variant = "primary",
							LayoutOrder = 2,
							OnClick = close,
						}),
					},
				}),
			}
		end,
	})
	-- select the link so Ctrl+C copies it
	task.defer(function()
		local panel = entry and entry.panel
		local box = panel and panel:FindFirstChild("UpdateUrl", true)
		box = box and box:FindFirstChildWhichIsA("TextBox", true)
		if box then
			pcall(function()
				box:CaptureFocus()
				box.SelectionStart = 1
				box.CursorPosition = #box.Text + 1
			end)
		end
	end)
end
Builder.updateDialog = updateDialog

local function previewBar(scope: any, app: any): Frame
	local store = app.store
	local noBlueprint = scope:Computed(function(use)
		return not use(store.hasBlueprint)
	end)
	local switch = Ui.switch(scope, {
		Name = "PreviewSwitch",
		Value = store.previewOn,
		Disabled = noBlueprint,
		LayoutOrder = 1,
		OnChange = function(on)
			store.previewOn:set(on)
		end,
	})
	Ui.tooltip(scope, switch, scope:Computed(function(use)
		if use(noBlueprint) then
			return "Generate a UI to preview it"
		end
		return if use(store.previewOn) then "Hide the viewport preview" else "Show it live in the viewport"
	end))
	return scope:New "Frame" {
		Name = "PreviewBar",
		BackgroundColor3 = C.Panel,
		BorderSizePixel = 0,
		AnchorPoint = Vector2.new(0, 1),
		Position = UDim2.fromScale(0, 1),
		Size = UDim2.new(1, 0, 0, FOOTER),
		ZIndex = 2,
		[Children] = {
			scope:New "Frame" {
				Name = "Border",
				BackgroundColor3 = C.Border,
				BorderSizePixel = 0,
				Size = UDim2.new(1, 0, 0, 1),
			},
			scope:New "Frame" {
				Name = "Content",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				[Children] = {
					Ui.padding(scope, 0, S.M, 0, S.M),
					Ui.list(scope, "X", 8, "Left", "Center"),
					switch,
					Ui.text(scope, {
						Name = "PreviewLabel",
						Text = "Preview",
						TextSize = Style.Text.Small,
						Weight = "SemiBold",
						Color = scope:Computed(function(use)
							return if use(noBlueprint) then C.TextDisabled else C.Text
						end),
						LayoutOrder = 2,
					}),
					Ui.dropdown(scope, {
						Name = "PreviewScreen",
						Options = scope:Computed(function(use)
							use(store.structure)
							use(store.hasBlueprint)
							return store:screenOptions(true)
						end),
						Value = store.previewScreen,
						Disabled = noBlueprint,
						Height = 30,
						TextSize = Style.Text.Small,
						MenuWidth = 220,
						MenuTitle = "Show in the viewport",
						Size = UDim2.new(0, 0, 0, 30),
						Flex = true,
						LayoutOrder = 3,
						OnChange = function(id)
							store.previewScreen:set(id)
							if not peek(store.previewOn) then
								store.previewOn:set(true)
							end
						end,
					}),
					Ui.segmented(scope, {
						Name = "Device",
						Value = store.device,
						Size = UDim2.fromOffset(68, 30),
						LayoutOrder = 4,
						Options = {
							{ value = "Desktop", label = "", icon = "computer-monitor", tooltip = "Desktop" },
							{ value = "Phone", label = "", icon = "smartphone-portrait", tooltip = "Phone (844×390)" },
						},
						OnChange = function(device)
							store.device:set(device)
						end,
					}),
				},
			},
		},
	}
end

--------------------------------------------------------------------------
-- lifecycle
--------------------------------------------------------------------------

function Builder.start(plugin: any, options: { [string]: any }?): any
	options = options or {}
	local self = setmetatable({}, Builder)
	self.plugin = plugin
	self.button = options.button
	self.installer = options.installer or Installer.new(Deps.BloxUI)
	self.mounted = false
	self.destroyed = false
	self.connections = {}

	local info = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 420, 780, 320, 420)
	-- CreateDockWidgetPluginGuiAsync supersedes the old call (it may yield;
	-- Main starts the builder inside a pcall). Older Studios only have the old one.
	local ok, created = pcall(function()
		return plugin:CreateDockWidgetPluginGuiAsync(Builder.WIDGET_ID, info)
	end)
	local widget = if ok and typeof(created) == "Instance" then created else plugin:CreateDockWidgetPluginGui(Builder.WIDGET_ID, info)
	widget.Name = Builder.WIDGET_ID
	widget.Title = "BloxUI · UI Builder"
	widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
	self.widget = widget

	table.insert(
		self.connections,
		widget:GetPropertyChangedSignal("Enabled"):Connect(function()
			self:onEnabledChanged()
		end)
	)
	if widget.Enabled then
		self:onEnabledChanged()
	end
	Builder.last = self -- handy from the command bar / tests
	return self
end

function Builder:setButtonActive(active: boolean)
	if self.button then
		pcall(function()
			self.button:SetActive(active)
		end)
	end
end

function Builder:onEnabledChanged()
	if self.destroyed then
		return
	end
	local enabled = self.widget.Enabled
	self:setButtonActive(enabled)
	if enabled then
		if not self.mounted then
			local ok, err = xpcall(function()
				self:mount()
			end, debug.traceback)
			if not ok then
				warn("[BloxUI Builder] could not open: " .. tostring(err))
			end
		elseif self.cloud then
			-- reopening checks the server again (update banner, reachability)
			self.cloud:refreshStatus()
			self:showWhatsNewSoon() -- closed again before "What's new" showed? now it does
		end
	elseif self.app then
		-- closing the widget removes the viewport preview
		self.app.store.previewOn:set(false)
		self.app.overlay:close()
	end
end

function Builder:toggle()
	self.widget.Enabled = not self.widget.Enabled
end

function Builder:open()
	self.widget.Enabled = true
end

-- true when this Studio is signed in (a licence key, or an early tester's Roblox account)
function Builder:signedIn(): boolean
	return self.cloud == nil or peek(self.cloud.signedIn) == true
end

-- Opens the builder on its Account card (the toolbar's gated buttons use it).
function Builder:showSignIn()
	self.widget.Enabled = true
	if self.store then
		self.store.tab:set("Settings")
	end
end

function Builder:close()
	self.widget.Enabled = false
end

-- "Auto" (follow Studio) | "Dark" | "Light"
function Builder:themePreference(): string
	local ok, value = pcall(function()
		return self.plugin:GetSetting(THEME_SETTING)
	end)
	if ok and (value == "Dark" or value == "Light") then
		return value
	end
	return "Auto"
end

function Builder:setThemePreference(preference: string)
	pcall(function()
		self.plugin:SetSetting(THEME_SETTING, if preference == "Auto" then nil else preference)
	end)
	if self.themeChoice then
		self.themeChoice:set(preference)
	end
	self:applyTheme()
end

-- Applies the preferred / Studio theme; rebuilds the widget if it changed.
function Builder:applyTheme()
	if self.destroyed then
		return
	end
	if Style.apply(Style.resolve(self:themePreference())) and self.mounted and self.uiScope then
		self:rebuildUi()
	end
end

function Builder:mount()
	if self.mounted then
		return
	end
	self.mounted = true
	Style.apply(Style.resolve(self:themePreference()))

	local scope = Fusion.scoped(Fusion)
	self.scope = scope

	local modules = Modules.load(Deps.BloxUI)
	if modules.Blueprint then
		pcall(Catalog.sync, modules.Blueprint.Schema)
	end
	self.modules = modules
	-- the BloxUI server: sign-in, status (update banner), jobs
	local cloud = Cloud.new(scope, { plugin = self.plugin, version = Version.plugin })
	self.cloud = cloud
	table.insert(scope, function()
		cloud:destroy()
	end)
	self.dismissedUpdate = scope:Value(nil :: string?)
	self.dismissedNotice = scope:Value(nil :: string?)
	local store = Store.new(scope, { plugin = self.plugin, modules = modules, cloud = cloud })
	self.store = store
	self.preview = Preview.new(scope, store, modules)
	self.history = History.new(scope, store)
	self.themeChoice = scope:Value(self:themePreference())

	-- progress: parsed stage + a ticking clock while a job runs
	self.progress = scope:Computed(function(use)
		-- Claude's design review (AI mode, quality "best"; the job's polls confirm it)
		local review = use(store.progressReview) == true
		return Builder.describeProgress(use(store.busy), use(store.progressStage), use(store.progressFraction), use(store.progressCount), review)
	end)
	self.elapsed = scope:Value(0)
	local clockJob = 0
	table.insert(
		scope,
		scope:Observer(store.busy):onChange(function()
			clockJob += 1
			local job = clockJob
			if peek(store.busy) == nil then
				return
			end
			local started = os.clock()
			self.elapsed:set(0)
			task.spawn(function()
				while job == clockJob and not self.destroyed and peek(store.busy) ~= nil do
					self.elapsed:set(math.floor(os.clock() - started))
					task.wait(0.5)
				end
			end)
		end)
	)
	table.insert(scope, function()
		clockJob += 1
	end)

	-- preview errors surface as a toast
	table.insert(
		scope,
		scope:Observer(store.previewError):onChange(function()
			local message = peek(store.previewError)
			if message then
				store:toast("Preview: " .. message, "warning")
			end
		end)
	)

	-- autosave the working session (plugin settings, this machine)
	table.insert(
		scope,
		store:onChanged(function()
			store:scheduleSave()
		end)
	)

	-- keep the saved-blueprints list fresh when the place changes
	local folderConnections = {}
	local function refreshSoon()
		task.defer(function()
			if self.app and self.app.refreshSaved and not self.destroyed then
				self.app.refreshSaved()
			end
		end)
	end
	local function watchFolder()
		for _, connection in folderConnections do
			connection:Disconnect()
		end
		table.clear(folderConnections)
		local folder = Export.folder()
		if folder then
			table.insert(folderConnections, folder.ChildAdded:Connect(refreshSoon))
			table.insert(folderConnections, folder.ChildRemoved:Connect(refreshSoon))
		end
		refreshSoon()
	end
	table.insert(
		scope,
		ReplicatedStorage.ChildAdded:Connect(function(child)
			if child.Name == Export.FOLDER then
				watchFolder()
			end
		end)
	)
	table.insert(
		scope,
		ReplicatedStorage.ChildRemoved:Connect(function(child)
			if child.Name == Export.FOLDER then
				watchFolder()
			end
		end)
	)
	table.insert(scope, function()
		for _, connection in folderConnections do
			connection:Disconnect()
		end
	end)

	-- follow Studio's theme (unless a theme is picked in Settings)
	local themeConnection = Style.onStudioThemeChanged(function()
		if self:themePreference() == "Auto" then
			self:applyTheme()
		end
	end)
	if themeConnection then
		table.insert(scope, themeConnection)
	end

	self:buildUi()
	watchFolder()

	-- restore the last session (prompt + working blueprint) from plugin settings
	pcall(function()
		store:restoreSession()
	end)
	self.history:clear()

	-- /v1/status now (update banner, reachability) and every 30 min; /v1/me when signed in
	cloud:start()

	-- the release notes, once after an update (a fresh install just remembers this version)
	local entries, since = WhatsNew.pending(self.plugin, Version.plugin)
	if entries then
		self.whatsNewPending = { entries = entries, since = since }
		self:showWhatsNewSoon()
	end
end

-- Shows the pending "What's new" once the widget has settled on its first tab.
function Builder:showWhatsNewSoon()
	if self.whatsNewPending == nil or self.whatsNewScheduled then
		return
	end
	self.whatsNewScheduled = true
	task.delay(WhatsNew.DELAY, function()
		self.whatsNewScheduled = false
		local pending = self.whatsNewPending
		if pending == nil or self.destroyed or self.app == nil or not self.widget.Enabled then
			return
		end
		self.whatsNewPending = nil
		self.app.showWhatsNew(pending)
	end)
end

-- Builds every instance of the widget in an inner scope (see rebuildUi).
function Builder:buildUi()
	local scope, life = Ui.innerScope(self.scope)
	self.uiScope, self.uiLife = scope, life
	local store = self.store

	local root = scope:New "Frame" {
		Name = "BuilderRoot",
		BackgroundColor3 = C.Bg,
		BorderSizePixel = 0,
		Size = UDim2.fromScale(1, 1),
		ClipsDescendants = true,
	}
	self.root = root

	local overlay = Overlay.new(scope, root)
	overlay:setBottomInset(FOOTER)
	Ui.overlay = overlay
	store.toastFn = function(text, tone, opts)
		overlay:toast(text, tone, opts)
	end

	local width = scope:Value(self.lastWidth or 420)
	local function measure()
		local size = root.AbsoluteSize
		if size.X > 0 then
			self.lastWidth = size.X
			width:set(size.X)
		end
	end
	table.insert(scope, root:GetPropertyChangedSignal("AbsoluteSize"):Connect(measure))

	local app = {
		store = store,
		cloud = self.cloud,
		overlay = overlay,
		modules = self.modules,
		installer = self.installer,
		plugin = self.plugin,
		history = self.history,
		progress = self.progress,
		elapsed = self.elapsed,
		themeChoice = self.themeChoice,
		builder = self,
		root = root,
		width = width,
		wide = scope:Computed(function(use)
			return use(width) >= 380
		end),
		lastTab = self.lastTab or "Create",
		keyHandlers = {},
		held = {},
	}
	function app.compactMode(): boolean
		return not peek(app.wide)
	end
	function app.setTheme(preference: string)
		-- deferred: the Settings control that asked is rebuilt by the switch
		task.defer(function()
			self:setThemePreference(preference)
		end)
	end
	-- the "how to update" dialog (banner Open, header pill, toasts)
	function app.showUpdate()
		updateDialog(app)
	end
	store.showUpdate = app.showUpdate
	-- "What's new" (after an update; Settings > This plugin build > What's new): the latest notes by default
	function app.showWhatsNew(opts: { [string]: any }?)
		return WhatsNew.show(app, opts)
	end
	function app.modifiers(input: any?): { ctrl: boolean, shift: boolean, alt: boolean }
		local mods = { ctrl = app.held.ctrl == true, shift = app.held.shift == true, alt = app.held.alt == true }
		if input ~= nil then
			pcall(function()
				local ctrl = input:IsModifierKeyDown(Enum.ModifierKey.Ctrl) or input:IsModifierKeyDown(Enum.ModifierKey.Meta)
				local shift = input:IsModifierKeyDown(Enum.ModifierKey.Shift)
				local alt = input:IsModifierKeyDown(Enum.ModifierKey.Alt)
				mods.ctrl, mods.shift, mods.alt = ctrl, shift, alt
			end)
		end
		return mods
	end
	self.app = app

	-- pages
	local pages = {}
	local pageFrames = {}
	local tabModules = script:FindFirstChild("Tabs")
	local allTabs = table.clone(TABS)
	table.insert(allTabs, { id = "Settings", module = "Settings" })
	for _, tab in allTabs do
		local module = tabModules and tabModules:FindFirstChild(tab.module)
		local page
		if module then
			local ok, result = xpcall(function()
				return require(module)(scope, app)
			end, debug.traceback)
			if ok then
				page = result
			else
				warn(`[BloxUI Builder] the {tab.id} tab failed to build: {result}`)
			end
		end
		if page == nil then
			page = Ui.empty(scope, {
				Icon = "triangle-exclamation",
				Tone = "warning",
				Title = `{tab.id} is unavailable`,
				Body = "This part of the builder failed to load. Check the Output window for details.",
			})
		end
		local frame = scope:New "Frame" {
			Name = tab.id .. "Tab",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5, 0),
			Size = UDim2.fromScale(1, 1),
			Visible = scope:Computed(function(use)
				return use(store.tab) == tab.id
			end),
			[Children] = {
				scope:New "UISizeConstraint" { MaxSize = Vector2.new(Style.MaxContentWidth, math.huge) },
				page,
			},
		}
		pages[tab.id] = page
		pageFrames[tab.id] = frame
	end
	self.pages = pages

	-- Licence gate: signed out (no licence key, no early-tester Roblox sign-in) only Settings, with its
	-- Account card, is usable. Every other tab, shortcut and action waits for the sign-in. The tab is
	-- moved on the next frame (never from inside the tab's own change).
	local cloud = app.cloud
	local function gate(explain: boolean)
		if cloud == nil or peek(cloud.signedIn) or peek(store.tab) == "Settings" then
			return
		end
		task.defer(function()
			if peek(cloud.signedIn) or peek(store.tab) == "Settings" then
				return
			end
			store.tab:set("Settings")
			if explain and store.toast then
				store:toast("Sign in first: paste your BloxUI licence key in Settings > Account.")
			end
		end)
	end
	table.insert(scope, scope:Observer(store.tab):onChange(function()
		gate(true)
	end))
	if cloud then
		table.insert(scope, scope:Observer(cloud.signedIn):onChange(function()
			gate(false)
		end))
	end
	gate(false)

	local bannerFrame, bannerHeight = banner(scope, app)
	local body = scope:New "Frame" {
		Name = "Pages",
		BackgroundTransparency = 1,
		Position = scope:Computed(function(use)
			return UDim2.fromOffset(0, HEADER + TABBAR + use(bannerHeight))
		end),
		Size = scope:Computed(function(use)
			return UDim2.new(1, 0, 1, -(HEADER + TABBAR + FOOTER + use(bannerHeight)))
		end),
		ClipsDescendants = true,
		[Children] = pageFrames,
	}
	header(scope, app).Parent = root
	tabBar(scope, app).Parent = root
	bannerFrame.Parent = root
	body.Parent = root
	previewBar(scope, app).Parent = root
	overlay.layer.Parent = nil
	overlay.layer.Parent = root -- keep the overlay last (on top)

	-- tab switching: remember the last main tab, refresh lists, subtle slide
	table.insert(
		scope,
		scope:Observer(store.tab):onChange(function()
			local tab = peek(store.tab)
			overlay:close()
			if tab ~= "Settings" then
				app.lastTab = tab
				self.lastTab = tab
			end
			if tab == "Export" and app.refreshSaved then
				app.refreshSaved()
			end
			local frame = pageFrames[tab]
			if frame then
				frame.Position = UDim2.new(0.5, 0, 0, 8)
				Ui.tween(frame, { Position = UDim2.fromScale(0.5, 0) }, 0.18)
			end
		end)
	)

	-- keyboard shortcuts (the mouse has to be over the widget)
	table.insert(
		scope,
		root.InputBegan:Connect(function(input)
			self:onKey(input)
		end)
	)
	table.insert(
		scope,
		root.InputEnded:Connect(function(input)
			if input.UserInputType == Enum.UserInputType.Keyboard then
				local modifier = MODIFIERS[input.KeyCode.Name]
				if modifier then
					app.held[modifier] = nil
				end
			end
		end)
	)
	table.insert(
		scope,
		root.MouseLeave:Connect(function()
			table.clear(app.held)
		end)
	)
	table.insert(
		scope,
		self.widget.WindowFocusReleased:Connect(function()
			table.clear(app.held)
		end)
	)

	root.Parent = self.widget
	measure()
end

-- Rebuilds the widget's instances (after a theme switch), keeping the work.
function Builder:rebuildUi()
	if not self.mounted or self.destroyed or self.uiScope == nil then
		return
	end
	local scrolls = {}
	for id, page in self.pages or {} do
		if typeof(page) == "Instance" and page:IsA("ScrollingFrame") then
			scrolls[id] = page.CanvasPosition
		end
	end
	if self.app then
		self.app.overlay:close()
	end
	Ui.cleanup(self.uiScope, self.uiLife)
	self.uiScope = nil
	self:buildUi()
	for id, position in scrolls do
		local page = self.pages[id]
		if typeof(page) == "Instance" and page:IsA("ScrollingFrame") then
			page.CanvasPosition = position
		end
	end
end

-- One key press over the widget. Returns true when it was handled.
function Builder:onKey(input: any): boolean
	local app = self.app
	if app == nil or input.UserInputType ~= Enum.UserInputType.Keyboard then
		return false
	end
	local key = input.KeyCode.Name
	local modifier = MODIFIERS[key]
	if modifier then
		app.held[modifier] = true
		return false
	end
	local mods = app.modifiers(input)
	local store = app.store
	local overlay = app.overlay

	if key == "Escape" and overlay:isOpen() then
		overlay:close()
		return true
	end
	if Ui.focusedBox ~= nil then
		return false -- typing: leave every key to the text box
	end
	if mods.ctrl then
		if key == "Z" then
			if mods.shift then
				app.history:redo()
			else
				app.history:undo()
			end
			return true
		elseif key == "Y" then
			app.history:redo()
			return true
		elseif key == "Return" then
			if peek(store.busy) == nil then
				store:generate(false)
			end
			return true
		elseif key == "Comma" then
			store.tab:set(if peek(store.tab) == "Settings" then app.lastTab else "Settings")
			return true
		end
		for _, tab in TABS do
			if key == tab.key then
				store.tab:set(tab.id)
				return true
			end
		end
	end
	if overlay:isOpen() then
		return false
	end
	local handler = app.keyHandlers[peek(store.tab)]
	if handler then
		return handler(key, mods) == true
	end
	return false
end

function Builder:destroy()
	if self.destroyed then
		return
	end
	self.destroyed = true
	for _, connection in self.connections do
		connection:Disconnect()
	end
	table.clear(self.connections)
	if self.store then
		pcall(function()
			self.store:saveSession()
		end)
		self.store:destroy()
	end
	if self.preview then
		self.preview:destroy()
	end
	if self.uiScope then
		Ui.cleanup(self.uiScope, self.uiLife)
		self.uiScope = nil
	end
	if self.scope then
		Fusion.doCleanup(self.scope)
		self.scope = nil
	end
	if Ui.overlay and self.app and Ui.overlay == self.app.overlay then
		Ui.overlay = nil
	end
	self:setButtonActive(false)
end

return Builder
]]></string>
      </Properties>
      <Item class="ModuleScript" referent="2">
        <Properties>
          <string name="Name">Base64</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Base64: standard base64 (RFC 4648, "+/" alphabet, "=" padding, no line
	breaks). The plugin sends reference screenshots to the BloxUI server as
	base64 (docs/API.md, options.references). Works on buffers, so a 600 KB
	screenshot encodes in a few milliseconds.

		Base64.encode("foobar")        --> "Zm9vYmFy"
		Base64.decode("Zm9vYmFy")      --> "foobar"   (nil when the text isn't base64)
		Base64.encodedLength(600000)   --> 800000
]]

local Base64 = {}

local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local PAD = string.byte("=")

-- encode: 6-bit value -> character; decode: character -> 6-bit value (255 = invalid)
local encodeTable = buffer.fromstring(ALPHABET)
local decodeTable = buffer.create(256)
buffer.fill(decodeTable, 0, 255)
for i = 1, #ALPHABET do
	buffer.writeu8(decodeTable, string.byte(ALPHABET, i), i - 1)
end

-- Characters the encoding of `bytes` input bytes takes.
function Base64.encodedLength(bytes: number): number
	return 4 * math.ceil(math.max(0, bytes) / 3)
end

function Base64.encode(input: string): string
	local length = #input
	if length == 0 then
		return ""
	end
	local source = buffer.fromstring(input)
	local out = buffer.create(Base64.encodedLength(length))
	local readu8, writeu8 = buffer.readu8, buffer.writeu8
	local whole = length - length % 3
	local o = 0
	for i = 0, whole - 1, 3 do
		local n = readu8(source, i) * 65536 + readu8(source, i + 1) * 256 + readu8(source, i + 2)
		writeu8(out, o, readu8(encodeTable, n // 262144))
		writeu8(out, o + 1, readu8(encodeTable, n // 4096 % 64))
		writeu8(out, o + 2, readu8(encodeTable, n // 64 % 64))
		writeu8(out, o + 3, readu8(encodeTable, n % 64))
		o += 4
	end
	local rest = length - whole
	if rest > 0 then
		local a = readu8(source, whole)
		local b = if rest == 2 then readu8(source, whole + 1) else 0
		local n = a * 65536 + b * 256
		writeu8(out, o, readu8(encodeTable, n // 262144))
		writeu8(out, o + 1, readu8(encodeTable, n // 4096 % 64))
		writeu8(out, o + 2, if rest == 2 then readu8(encodeTable, n // 64 % 64) else PAD)
		writeu8(out, o + 3, PAD)
	end
	return buffer.tostring(out)
end

-- The bytes `text` encodes, or nil when it isn't valid padded base64.
function Base64.decode(text: string): string?
	local length = #text
	if length % 4 ~= 0 then
		return nil
	end
	if length == 0 then
		return ""
	end
	local source = buffer.fromstring(text)
	local readu8 = buffer.readu8
	local padding = 0
	if readu8(source, length - 1) == PAD then
		padding = if readu8(source, length - 2) == PAD then 2 else 1
	end
	local out = buffer.create(length // 4 * 3 - padding)
	local o = 0
	for i = 0, length - 1, 4 do
		local last = i + 4 == length
		local values = table.create(4, 0)
		for k = 0, 3 do
			local c = readu8(source, i + k)
			if c == PAD and last and k >= 4 - padding then
				values[k + 1] = 0
			else
				local v = readu8(decodeTable, c)
				if v == 255 then
					return nil
				end
				values[k + 1] = v
			end
		end
		local n = values[1] * 262144 + values[2] * 4096 + values[3] * 64 + values[4]
		local count = if last then 3 - padding else 3
		buffer.writeu8(out, o, n // 65536)
		if count > 1 then
			buffer.writeu8(out, o + 1, n // 256 % 256)
		end
		if count > 2 then
			buffer.writeu8(out, o + 2, n % 256)
		end
		o += count
	end
	return buffer.tostring(out)
end

return Base64
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="3">
        <Properties>
          <string name="Name">Brand</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Brand: the plugin's icon, uploaded to Roblox (Studio: View > Asset Manager > Images > Bulk Import,
	then right-click the image > Copy ID). Used by every toolbar button and the builder's header.
	Empty: toolbar buttons show Studio's default icon and the header keeps its drawn mark.
]]
return {
	ICON = "rbxassetid://15676588786",
}
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="4">
        <Properties>
          <string name="Name">Catalog</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Catalog: everything the UI Builder offers in its pickers.

	  Catalog.style            Blueprint Style enums (docs/BLUEPRINT.md)
	  Catalog.fonts            Roblox built-in font families + the weights each ships with
	  Catalog.palettes         curated palette presets
	  Catalog.tones            BloxUI tone names with a preview colour
	  Catalog.soundSlots       style.sounds slots with Creator Store search terms
	  Catalog.musicSlots       blueprint.music tracks
	  Catalog.hudLayouts       HUD layouts; layoutPresets / layoutDiagrams feed the layout cards
	  Catalog.hud / sections / screens
	                           one schema per HudElement type, Section kind and Screen kind:
	                           label, icon, blurb, editable fields and a `new()` template

	Field kinds used by the Structure tab editors:
	  text, multiline, number, bool, enum, icon, tone, rarity, action, key, color,
	  currency, strings (list of text), object (nested fields), list (array of items)
]]

local Catalog = {}

--------------------------------------------------------------------------
-- Example prompts (Create tab chips)
--------------------------------------------------------------------------

Catalog.examples = {
	{ label = "FPS", icon = "crosshairs", prompt = "A fast competitive FPS arena like Rivals: sleek dark HUD, loadouts, weapon shop with skins and cases, battle pass and a kill feed." },
	{ label = "Simulator", icon = "circle-dollar-sign", prompt = "A bright pet simulator: big click button, coins and gems, egg shop, pet inventory, upgrades, rebirths and daily rewards." },
	{ label = "Horror", icon = "moon", prompt = "A tense horror escape game like Doors: minimal typewriter HUD, flashlight battery, stamina, objective text, a fear vignette and a creepy main menu." },
	{ label = "Tycoon", icon = "building-bank", prompt = "A cheerful factory tycoon: cash counter, upgrade droppers and conveyors, rebirth shop, gamepasses for 2x cash and auto-collect." },
	{ label = "RPG", icon = "sword", prompt = "A fantasy adventure RPG like Treasure Quest: gold trim panels, quests, inventory with rarities, equipment loadout, health and XP bars, world map." },
	{ label = "Obby", icon = "person-jumping", prompt = "A colourful obby: stage counter, skip-stage purchase, checkpoints, trails shop, timer and a leaderboard." },
	{ label = "Racing", icon = "lightning-bolt", prompt = "An arcade racing game: speedometer-style HUD, lap timer, car garage with upgrades, nitro boosts and race results screen." },
	{ label = "Tower Defense", icon = "shield-check", prompt = "A tower defense game: wave counter, cash, unit hotbar, unit summon cases, upgrades and a lobby main menu." },
	{ label = "Battle Royale", icon = "target", prompt = "A cartoony battle royale: storm timer, players alive, inventory hotbar, minimap, emote wheel and a victory results screen." },
	{ label = "Roleplay", icon = "house", prompt = "A cozy roleplay town like Adopt Me: soft bubbly panels, money, house and vehicle shop, pets, trading and an emote wheel." },
}

-- Every genre (the same order as Blueprint.Schema.GENRES), for "Start from a sample".
Catalog.genres = {
	{ value = "fps", label = "FPS", icon = "crosshairs" },
	{ value = "horror", label = "Horror", icon = "moon" },
	{ value = "simulator", label = "Simulator", icon = "circle-dollar-sign" },
	{ value = "tycoon", label = "Tycoon", icon = "building-bank" },
	{ value = "obby", label = "Obby", icon = "person-jumping" },
	{ value = "rpg", label = "RPG", icon = "sword" },
	{ value = "racing", label = "Racing", icon = "lightning-bolt" },
	{ value = "battleroyale", label = "Battle Royale", icon = "target" },
	{ value = "towerdefense", label = "Tower Defense", icon = "shield-check" },
	{ value = "fighting", label = "Fighting", icon = "sword" },
	{ value = "survival", label = "Survival", icon = "flame" },
	{ value = "roleplay", label = "Roleplay", icon = "house" },
	{ value = "sports", label = "Sports", icon = "trophy" },
	{ value = "casual", label = "Casual", icon = "star" },
	{ value = "scifi", label = "Sci-Fi", icon = "globe-simplified" },
}

Catalog.genreIcons = {
	fps = "crosshairs",
	horror = "moon",
	simulator = "circle-dollar-sign",
	tycoon = "building-bank",
	obby = "person-jumping",
	rpg = "sword",
	racing = "lightning-bolt",
	battleroyale = "target",
	towerdefense = "shield-check",
	fighting = "sword",
	survival = "flame",
	roleplay = "house",
	sports = "trophy",
	casual = "star",
	scifi = "globe-simplified",
}

--------------------------------------------------------------------------
-- Style enums (Design tab)
--------------------------------------------------------------------------

Catalog.style = {
	archetype = {
		{ value = "Arena", label = "Arena", hint = "Rivals: dark glass, one hot accent" },
		{ value = "Bubbly", label = "Bubbly", hint = "Adopt Me: cream panels, chunky type" },
		{ value = "Adventure", label = "Adventure", hint = "Treasure Quest: gold trim, gems" },
		{ value = "Sleek", label = "Sleek", hint = "Modern app: flat, soft shadows" },
		{ value = "Candy", label = "Candy", hint = "Simulator: pastel gradients, sparkles" },
		{ value = "Tactical", label = "Tactical", hint = "Military: slate glass, amber, sharp" },
		{ value = "Horror", label = "Horror", hint = "Doors: minimal, typewriter, blood" },
	},
	buttonStyle = { "Glossy3D", "Flat", "Pill", "Outline", "Soft" },
	panelStyle = { "Glass", "Solid", "Gradient", "Outlined", "Ribbon", "Fade" },
	corner = { "Sharp", "Soft", "Round", "Bubbly" },
	clickFx = { "Squash", "Ripple", "Sparkle", "Glow", "Shake", "Glitch", "None" },
	transition = { "Pop", "Rise", "Slide", "Fade", "Drop", "Zoom", "Swap" },
	iconStyle = { "Filled", "Outline" },
	textCase = { "Normal", "Upper" },
	decoration = { "None", "Sparkles", "Stripes", "Dots", "Grain" },
	shadow = { "Soft", "Hard", "None" },
	density = { "Compact", "Comfy" },
}

-- Labels + order of the "look" controls in the Design tab.
Catalog.styleControls = {
	{ key = "buttonStyle", label = "Buttons" },
	{ key = "panelStyle", label = "Panels" },
	{ key = "corner", label = "Corners" },
	{ key = "clickFx", label = "Click FX" },
	{ key = "transition", label = "Transition" },
	{ key = "iconStyle", label = "Icons" },
	{ key = "textCase", label = "Text case" },
	{ key = "decoration", label = "Decoration" },
	{ key = "shadow", label = "Shadow" },
	{ key = "density", label = "Density" },
}

Catalog.logoTypes = { "Wordmark", "Emblem", "Badge", "Stacked", "Neon" }
Catalog.anchors = { "TopLeft", "TopCenter", "TopRight", "Left", "Center", "Right", "BottomLeft", "BottomCenter", "BottomRight" }
-- HUD layouts (blueprint.hud.layout), in picker order: the Create tab's
-- presets first. "Simulator" is built by the BloxUI server; payloads that
-- don't know it yet draw it as Classic.
Catalog.hudLayouts = {
	{ value = "Simulator", label = "Simulator / Tycoon", hint = "Cash bottom, packs and boosts right, teleports top, menu left" },
	{ value = "Corners", label = "Shooter / Battlegrounds", hint = "Spread to the corners, the centre clear for aiming" },
	{ value = "Minimal", label = "Minimal", hint = "Only the essentials, kept small" },
	{ value = "Dock", label = "Dock", hint = "Menu as a dock at the bottom" },
	{ value = "Classic", label = "Classic", hint = "Currencies top-left, menu left" },
	{ value = "Rail", label = "Rail", hint = "Currencies top-left, menu right" },
	{ value = "TopBar", label = "Top bar", hint = "Currencies top-centre, menu left" },
}
Catalog.hudLayoutByValue = {}
for _, entry in Catalog.hudLayouts do
	Catalog.hudLayoutByValue[entry.value] = entry
end
-- The Create tab's cards: Auto, these presets, then "More layouts" (the rest).
Catalog.layoutPresets = { "Simulator", "Corners", "Minimal", "Dock" }
Catalog.hudLayoutAuto = { value = "Auto", label = "Auto", hint = "BloxUI picks the layout that suits your game" }

--[[
	Mini diagrams of where each layout puts things on a 16:9 screen (the
	layout cards). Blocks are { kind, x, y, w, h } on a 64 x 36 grid;
	pill = true rounds the ends. Kinds pick the colour (layoutColors).
]]
Catalog.layoutColors = {
	cash = "#FFC83D",
	menu = "#8B7CFF",
	packs = "#FF6FB5",
	tp = "#3CC8F0",
	health = "#FF5F6D",
	item = "#C9CFDB",
	feed = "#C9CFDB",
	cross = "#FFFFFF",
	dock = "#FFFFFF",
}
local function column(kind: string, x: number, ys: { number }, w: number, h: number): { any }
	local out = {}
	for _, y in ys do
		table.insert(out, { kind = kind, x = x, y = y, w = w, h = h })
	end
	return out
end
local function row(kind: string, xs: { number }, y: number, w: number, h: number): { any }
	local out = {}
	for _, x in xs do
		table.insert(out, { kind = kind, x = x, y = y, w = w, h = h })
	end
	return out
end
local function join(...: { any }): { any }
	local out = {}
	for i = 1, select("#", ...) do
		for _, block in (select(i, ...)) do
			table.insert(out, block)
		end
	end
	return out
end
Catalog.layoutDiagrams = {
	Simulator = join(
		row("tp", { 17, 25, 33, 41 }, 3, 6, 5),
		column("menu", 3, { 9, 16, 23 }, 6, 6),
		column("packs", 55, { 9, 16, 23 }, 6, 6),
		{ { kind = "cash", x = 22, y = 29, w = 20, h = 5, pill = true } }
	),
	Corners = join(
		{ { kind = "cash", x = 48, y = 3, w = 13, h = 4, pill = true } },
		{ { kind = "feed", x = 46, y = 10, w = 15, h = 1.5, alpha = 0.35 }, { kind = "feed", x = 50, y = 13, w = 11, h = 1.5, alpha = 0.35 } },
		column("menu", 3, { 11, 17, 23 }, 5, 5),
		{ { kind = "health", x = 3, y = 31, w = 15, h = 2.5, pill = true } },
		row("item", { 20.5, 26.5, 32.5, 38.5 }, 29.5, 5, 5),
		{ { kind = "cross", x = 31.5, y = 15.5, w = 1, h = 5 }, { kind = "cross", x = 29.5, y = 17.5, w = 5, h = 1 } }
	),
	Minimal = join(
		{ { kind = "cash", x = 51, y = 3, w = 10, h = 3, pill = true } },
		column("menu", 57, { 14, 19, 24 }, 4, 4),
		{ { kind = "health", x = 3, y = 32, w = 11, h = 2, pill = true, alpha = 0.2 } }
	),
	Dock = join(
		{ { kind = "cash", x = 48, y = 3, w = 13, h = 4, pill = true } },
		{ { kind = "dock", x = 17, y = 27.5, w = 30, h = 6.5, alpha = 0.82 } },
		row("menu", { 19, 24.5, 30, 35.5, 41 }, 28.75, 4, 4)
	),
	Classic = join(
		{ { kind = "cash", x = 3, y = 3, w = 16, h = 4, pill = true } },
		row("menu", { 3, 10 }, 10, 6, 6),
		row("menu", { 3, 10 }, 17, 6, 6),
		{ { kind = "health", x = 3, y = 31, w = 15, h = 2.5, pill = true } }
	),
	Rail = join(
		{ { kind = "cash", x = 3, y = 3, w = 16, h = 4, pill = true } },
		column("menu", 55, { 7, 14, 21, 28 }, 6, 6)
	),
	TopBar = join(
		{ { kind = "cash", x = 21, y = 3, w = 22, h = 4, pill = true } },
		column("menu", 3, { 10, 17, 24 }, 6, 6)
	),
}

-- One layout's label / hint ("Auto" included; unknown values keep their name).
function Catalog.hudLayoutInfo(value: string?): { value: string, label: string, hint: string? }
	if value == Catalog.hudLayoutAuto.value then
		return Catalog.hudLayoutAuto
	end
	local entry = value and Catalog.hudLayoutByValue[value]
	if entry then
		return entry
	end
	return { value = tostring(value or "Classic"), label = tostring(value or "Classic") }
end

--------------------------------------------------------------------------
-- HUD kits (docs/BLUEPRINT.md v1.8, Components/HudKits): blueprint.hud.kit
-- and each HUD element's `look`
--------------------------------------------------------------------------

--[[
	The Design tab's HUD kit picker. `swatch` draws the little plate sample on
	each kit's tile:
	  face, stroke, ink   hex, or a palette key ("primary", "accent", "surface",
	                      "text") so samples follow your palette like the kits do
	  faceAlpha, strokeWidth, strokeAlpha, corner (px), tilt (degrees),
	  inkStroke (+ inkStrokeWidth, inkStrokeAlpha)
	  font                "display" (your display font) or "Family:Weight"
	  detail              "bar" | "shadow" | "shine" | "lip" | "hairline" | "glow"
	                      | "brackets" | "rule" | "notch" | "gloss"
]]
Catalog.hudKits = {
	{
		value = "Competitive",
		label = "Competitive",
		hint = "RIVALS / Valorant: flat dark plates, thin accent bars, big clean numbers, no bounce",
		swatch = { face = "#141922", faceAlpha = 0.08, corner = 2, ink = "#FFFFFF", font = "display", detail = "bar" },
	},
	{
		value = "Arcade",
		label = "Arcade",
		hint = "Arsenal / party games: deep coloured plates, bold outlines, hard shadows, bouncy pops",
		swatch = { face = "primary", corner = 8, stroke = "#16162A", strokeWidth = 3, ink = "#FFFFFF", inkStroke = "#16162A", inkStrokeWidth = 2, font = "LuckiestGuy:Regular", detail = "shadow" },
	},
	{
		value = "Glass",
		label = "Glass",
		hint = "Frosted translucent panels, soft light rims, rings instead of bars, a slow shine",
		swatch = { face = "surface", faceAlpha = 0.5, corner = 13, stroke = "#FFFFFF", strokeWidth = 1.5, strokeAlpha = 0.3, ink = "#FFFFFF", font = "display", detail = "shine" },
	},
	{
		value = "Chunky Toy",
		label = "Chunky Toy",
		hint = "Pet Simulator 99 / Adopt Me: cream plates, thick outlines, big overlapping icons, rounded type",
		swatch = { face = "#FFF3DC", corner = 13, stroke = "#2B2240", strokeWidth = 3, ink = "#3A2A1E", font = "FredokaOne:Regular", detail = "lip" },
	},
	{
		value = "Minimal",
		label = "Minimal",
		hint = "Almost no plates: outlined text, hairline bars, small icons, quiet fades",
		swatch = { faceAlpha = 1, ink = "#FFFFFF", inkStroke = "#000000", inkStrokeWidth = 1.5, font = "display", detail = "hairline" },
	},
	{
		value = "Neon",
		label = "Neon",
		hint = "Synthwave: near-black plates, glowing rims in the theme accent, breathing light",
		swatch = { face = "#0B0B14", faceAlpha = 0.1, corner = 6, stroke = "accent", strokeWidth = 2, ink = "#FFFFFF", inkStroke = "accent", inkStrokeWidth = 1.5, inkStrokeAlpha = 0.55, font = "Sarpanch:ExtraBold", detail = "glow" },
	},
	{
		value = "Tactical",
		label = "Tactical",
		hint = "Military sim: dark plates with corner brackets, mono numbers, scan lines, a heart monitor",
		swatch = { face = "#1B2026", faceAlpha = 0.3, corner = 0, stroke = "#FFFFFF", strokeWidth = 1, strokeAlpha = 0.86, ink = "#FFFFFF", font = "RobotoMono:Bold", detail = "brackets" },
	},
	{
		value = "Storybook",
		label = "Storybook",
		hint = "RPG parchment: paper plates with a double rule, serif numbers, a health arc round the portrait",
		swatch = { face = "#F1E2C0", corner = 5, stroke = "#5B3E24", strokeWidth = 2.5, ink = "#4A3220", font = "Merriweather:Heavy", detail = "rule" },
	},
	{
		value = "Retro",
		label = "Retro",
		hint = "8-bit: notched pixel frames, pixel type, scan lines, hard shadows, heart pips",
		swatch = { face = "surface", faceAlpha = 0.08, corner = 0, stroke = "#FFFFFF", strokeWidth = 3, ink = "#FFFFFF", inkStroke = "#000000", inkStrokeWidth = 2, font = "PressStart2P:Regular", detail = "notch" },
	},
}
--------------------------------------------------------------------------
-- v2.4 style families (Blueprint.Schema.STYLE_FAMILIES, Theme/Families):
-- blueprint.style.family. The Design tab's picker draws `preview` as a tiny
-- window in the family's default colours:
--   panel, ink        body colour and text colour
--   band              header band colour (nil: a hairline in `line`)
--   stroke, strokeWidth, strokeAlpha   the outline
--   button, pill      the button colour; pill = fully round
--   corner            window corner (px at preview size)
--   shadow            { x, y, alpha, blur? } drop shadow (blur = soft)
--   glow              coloured glow round the window
--   font              "Family:Weight" for the "Aa"
--   detail            "lines" (paper) | "brackets" (holo) | "grid" | "dots" | "bar" (accent bar)
--------------------------------------------------------------------------
Catalog.styleFamilies = {
	{ value = "Toybox", label = "Toybox Gloss", hint = "Glossy cartoon: cream panels, chunky tinted outlines, bouncy (simulators, tycoons, obbies)",
		preview = { panel = "#FFF7E8", ink = "#23324D", band = "#2F9BFF", stroke = "#1B3A7A", strokeWidth = 2, button = "#5BD62A", corner = 6, shadow = { 0, 3, 0.1 }, font = "FredokaOne:Regular", detail = "dots" } },
	{ value = "Studio", label = "Studio Minimal", hint = "Clean app UI: hairlines, soft layered shadows, crisp motion (roleplay, sports management)",
		preview = { panel = "#16161C", ink = "#EDEDF0", line = "#FFFFFF", stroke = "#FFFFFF", strokeWidth = 1, strokeAlpha = 0.85, button = "#6E7BFF", corner = 4, shadow = { 0, 4, 0.55, true }, font = "BuilderSans:Bold" } },
	{ value = "Neon", label = "Neon Synth", hint = "Cyber neon: glowing edges and grids on near-black, snappy (racing, arcade shooters)",
		preview = { panel = "#120F24", ink = "#F4F0FF", line = "#FF2BD6", stroke = "#FF2BD6", strokeWidth = 1.5, button = "#19E6FF", corner = 3, glow = "#FF2BD6", font = "Sarpanch:ExtraBold", detail = "grid" } },
	{ value = "Clay", label = "Soft Clay", hint = "Soft 3D pastels: puffy fills, big tinted shadows, squishy (cozy sims, kids, pets)",
		preview = { panel = "#F7F9FF", ink = "#2E3350", button = "#7C8CFF", pill = true, corner = 10, shadow = { 0, 5, 0.6, true }, font = "Nunito:Black" } },
	{ value = "Sketch", label = "Sketchbook", hint = "Paper and ink: ruled paper, hand fonts, hard offset shadows (story, school, puzzle)",
		preview = { panel = "#FFFBF0", ink = "#2B2A33", stroke = "#2B2A33", strokeWidth = 1.5, button = "#3C6FD6", corner = 2, shadow = { 3, 3, 0.7 }, font = "PatrickHand:Regular", detail = "lines" } },
	{ value = "Royal", label = "Royal Gold", hint = "Luxury: dark violet, double gold rules, stately motion (kingdoms, VIP, casinos)",
		preview = { panel = "#1D1330", ink = "#FFF6E0", line = "#F5C84C", stroke = "#F5C84C", strokeWidth = 1, button = "#F5C84C", corner = 2, shadow = { 0, 5, 0.5, true }, font = "Merriweather:Heavy" } },
	{ value = "Arcade", label = "Arcade Pixel", hint = "8-bit retro: square pixel frames, pixel type, stepped motion (retro obbies, minigames)",
		preview = { panel = "#1D2B53", ink = "#FFF1E8", band = "#FF004D", stroke = "#000000", strokeWidth = 2, button = "#00E436", corner = 0, shadow = { 2, 2, 0 }, font = "PressStart2P:Regular" } },
	{ value = "Comic", label = "Comic Pop", hint = "Comic book: thick ink outlines, halftone, punchy slams (fighting, heroes, party)",
		preview = { panel = "#FFFFFF", ink = "#111111", band = "#FFD400", stroke = "#111111", strokeWidth = 2, button = "#FF2E4D", corner = 2, shadow = { 3, 3, 0 }, font = "Bangers:Regular", detail = "dots" } },
	{ value = "Dread", label = "Dread", hint = "Horror grunge: bone on black, grain, slow uneasy motion (horror, survival nights)",
		preview = { panel = "#121216", ink = "#D9D4C7", line = "#B3121B", stroke = "#D9D4C7", strokeWidth = 1, strokeAlpha = 0.75, button = "#B3121B", corner = 0, font = "SpecialElite:Regular" } },
	{ value = "Holo", label = "Holo HUD", hint = "Sci-fi hologram: cyan hairlines, corner brackets, scanlines (space, mechs, future FPS)",
		preview = { panel = "#0A1A2A", ink = "#D8F6FF", line = "#3FE0FF", stroke = "#3FE0FF", strokeWidth = 1, strokeAlpha = 0.45, button = "#3FE0FF", corner = 0, glow = "#3FE0FF", font = "Sarpanch:SemiBold", detail = "brackets" } },
	{ value = "Gacha", label = "Gacha Anime", hint = "Anime gacha: night gradients, white rims, star glows (anime TD, battlegrounds, gacha RPGs)",
		preview = { panel = "#23265C", ink = "#FFFFFF", band = "#4FA3FF", stroke = "#FFFFFF", strokeWidth = 1, strokeAlpha = 0.5, button = "#FFCB5C", pill = true, corner = 6, shadow = { 0, 5, 0.55, true }, font = "Montserrat:Black" } },
	{ value = "Broadcast", label = "Broadcast", hint = "Esports TV: flat slabs, accent bars, wide type, no bounce (sports, shooters, battle royale)",
		preview = { panel = "#111A2E", ink = "#FFFFFF", band = "#0A0F1C", button = "#1F7BFF", corner = 1, shadow = { 0, 3, 0.5 }, font = "BuilderExtended:ExtraBold", detail = "bar", accent = "#D7FF1F" } },
	{ value = "Tavern", label = "Tavern Fantasy", hint = "Medieval: parchment, dark wood, gold trim, weighty motion (RPGs, fantasy TD, survival)",
		preview = { panel = "#F1E2BE", ink = "#2A1D12", band = "#6B4226", stroke = "#2A170C", strokeWidth = 2, button = "#3F7D3A", corner = 3, shadow = { 0, 4, 0.45, true }, font = "Merriweather:Heavy" } },
	{ value = "Mochi", label = "Mochi Kawaii", hint = "Cute pastels: pink, pill buttons, tinted outlines, polka dots (roleplay, dress-up, pets)",
		preview = { panel = "#FFFFFF", ink = "#6B3A58", band = "#FF8FC0", stroke = "#C2507F", strokeWidth = 2, button = "#FF8FC0", pill = true, corner = 8, shadow = { 0, 3, 0.35 }, font = "FredokaOne:Regular", detail = "dots" } },
}
-- No family: the archetype's classic look (every blueprint before v2.4).
Catalog.styleFamilyNone = {
	value = "None",
	label = "Classic",
	hint = "No family: the archetype alone decides the look (the pre-2.4 style)",
	preview = { panel = "#1B1827", ink = "#FFFFFF", line = "#FFD21F", stroke = "#FFFFFF", strokeWidth = 1, strokeAlpha = 0.8, button = "#5ED422", corner = 4, font = "BuilderSans:ExtraBold" },
}
Catalog.styleFamilyByValue = {}
for _, entry in Catalog.styleFamilies do
	Catalog.styleFamilyByValue[entry.value] = entry
end

-- No kit: every element keeps its original look (the pre-v1.8 HUD).
Catalog.hudKitOriginal = {
	value = "None",
	label = "Original",
	hint = "No kit: every element keeps its original look (glass pills, theme fonts)",
	swatch = { face = "surface", faceAlpha = 0.35, corner = 12, ink = "#FFFFFF", font = "display", detail = "gloss" },
}

Catalog.hudKitByValue = {}
for _, kit in Catalog.hudKits do
	Catalog.hudKitByValue[kit.value] = kit
end

local KIT_ALIASES = { toy = "Chunky Toy", chunky = "Chunky Toy", chunkytoy = "Chunky Toy" }

-- A kit value as the picker knows it ("toy" / "chunky toy" -> "Chunky Toy"),
-- or nil for no kit ("None", "Classic", "Auto", nil, unknown names).
function Catalog.hudKitName(value: any): string?
	if typeof(value) ~= "string" then
		return nil
	end
	local key = string.lower((string.gsub(value, "[^%w]", "")))
	for _, kit in Catalog.hudKits do
		local name = if typeof(kit) == "table" then kit.value else kit
		if string.lower((string.gsub(name, "[^%w]", ""))) == key then
			return name
		end
	end
	return KIT_ALIASES[key]
end

-- Each element's looks (Components/HudKits.styles); the first is its
-- original look. The Structure tab's "Look" dropdown lists them.
Catalog.hudLooks = {
	CurrencyBar = {
		{ value = "Pill", label = "Pill", hint = "A glass pill per currency, big overlapping icon (the original)" },
		{ value = "Chips", label = "Chips", hint = "Slim stacked chips" },
		{ value = "Banner", label = "Banner", hint = "Every currency on one top strip" },
		{ value = "Wallet", label = "Wallet", hint = "A corner card listing each amount" },
		{ value = "Tiles", label = "Tiles", hint = "Square tiles, icon above the number" },
		{ value = "Readout", label = "Readout", hint = "Just text and a tiny icon" },
	},
	HealthBar = {
		{ value = "Bar", label = "Bar", hint = "A flat bar with the number inside (the original)" },
		{ value = "Segments", label = "Segments", hint = "Chunky cells that drop out one by one" },
		{ value = "Arc", label = "Arc", hint = "An arc round a portrait" },
		{ value = "Big", label = "Big", hint = "A big number over a thin bar (Valorant)" },
		{ value = "Hearts", label = "Hearts", hint = "A row of heart pips" },
		{ value = "Pulse", label = "Pulse", hint = "A heart monitor line that flattens as you weaken" },
	},
	XPBar = {
		{ value = "Bar", label = "Bar", hint = "A bar with a round level badge (the original)" },
		{ value = "Thin", label = "Thin", hint = "A hairline bar, the level above it" },
		{ value = "Ring", label = "Ring", hint = "The level inside a progress ring" },
		{ value = "Chunky", label = "Chunky", hint = "A segmented bar with a level shield" },
	},
	Hotbar = {
		{ value = "Slots", label = "Slots", hint = "Square slots with ammo (the original)" },
		{ value = "Cards", label = "Cards", hint = "Fanned cards; the equipped one rises" },
		{ value = "List", label = "List", hint = "A numbered list with a springing selection bar" },
		{ value = "Stack", label = "Stack", hint = "Slots up the screen edge; the equipped one slides out" },
		{ value = "Round", label = "Round", hint = "Round slots with a springing selection ring" },
		{ value = "Tactical", label = "Tactical", hint = "The weapon name, big ammo and slot ticks only" },
	},
	Timer = {
		{ value = "Pill", label = "Pill", hint = "A pill with the time (the original)" },
		{ value = "Big", label = "Big", hint = "Big centre digits under a small caption" },
		{ value = "Ring", label = "Ring", hint = "A ring that drains round the digits" },
		{ value = "Flip", label = "Flip", hint = "Flip-clock digit tiles" },
		{ value = "Stopwatch", label = "Stopwatch", hint = "A small stopwatch with a ticking hand" },
	},
	Objective = {
		{ value = "Panel", label = "Panel", hint = "A faded panel with typewriter text (the original)" },
		{ value = "Card", label = "Card", hint = "A card with a progress ring" },
		{ value = "Ticker", label = "Ticker", hint = "One line of text" },
		{ value = "Checklist", label = "Checklist", hint = "A checkbox that strikes through when done" },
		{ value = "Waypoint", label = "Waypoint", hint = "A banner with a marker and moving chevrons" },
	},
	QuestTracker = {
		{ value = "Panel", label = "Panel", hint = "The quest panel (the original)" },
		{ value = "Card", label = "Card", hint = "A card; each task has a small progress ring" },
		{ value = "Ticker", label = "Ticker", hint = "One line: the current task, its count and a thin bar" },
		{ value = "Checklist", label = "Checklist", hint = "Checkboxes; finished tasks are struck through" },
		{ value = "Waypoint", label = "Waypoint", hint = "A banner with a marker for the current task" },
	},
	Boosts = {
		{ value = "Pills", label = "Pills", hint = "A pill per boost with its time left (the original)" },
		{ value = "Radial", label = "Radial", hint = "Round icons with a draining ring" },
		{ value = "Badges", label = "Badges", hint = "A stacked badge column" },
	},
	KillFeed = {
		{ value = "Rows", label = "Rows", hint = "Killer, weapon, victim rows (the original)" },
		{ value = "Compact", label = "Compact", hint = "Slim icon rows" },
		{ value = "Callout", label = "Callout", hint = "Compact rows plus a big ELIMINATED for your own kills" },
	},
	MenuButtons = {
		{ value = "Column", label = "Column", hint = "A column of buttons (an original)" },
		{ value = "Grid", label = "Grid", hint = "A two-wide grid (an original)" },
		{ value = "Dock", label = "Dock", hint = "A dock of buttons (an original)" },
		{ value = "Bubbles", label = "Bubbles", hint = "Round floating buttons" },
		{ value = "TabBar", label = "Tab bar", hint = "A mobile-game tab bar with a raised centre button" },
		{ value = "Drawer", label = "Drawer", hint = "A side drawer that folds away" },
		{ value = "Tiles", label = "Tiles", hint = "Labelled square tiles (PS99)" },
	},
	Minimap = {
		{ value = "Square", label = "Square", hint = "A framed map (the original; Shape rounds it)" },
		{ value = "Compass", label = "Compass", hint = "A round map in a compass ring" },
		{ value = "Card", label = "Card", hint = "A rounded card with the zone name and coordinates" },
	},
	TeamScore = {
		{ value = "Blocks", label = "Blocks", hint = "A score block per team (the original)" },
		{ value = "TugOfWar", label = "Tug of war", hint = "One bar split between two teams" },
		{ value = "Line", label = "Line", hint = "A minimal score line, the clock underneath" },
	},
	KeyHints = {
		{ value = "Strip", label = "Strip", hint = "A strip of key pills (the original)" },
		{ value = "Prompts", label = "Prompts", hint = "One floating prompt per hint: a big key and a label" },
		{ value = "Sheet", label = "Sheet", hint = "A corner cheat-sheet card" },
	},
	Clicker = {
		{ value = "Button", label = "Button", hint = "The big glossy button (the original)" },
		{ value = "Blob", label = "Blob", hint = "A squishy blob: squash and stretch" },
		{ value = "Orb", label = "Orb", hint = "A glowing orb that rings out on every tap" },
		{ value = "Capsule", label = "Capsule", hint = "A tall capsule with a combo meter" },
	},
	Crosshair = {
		{ value = "Cross", label = "Cross", hint = "Four bars that spread (the original)" },
		{ value = "Dot", label = "Dot", hint = "Just the dot" },
		{ value = "Circle", label = "Circle", hint = "A ring that widens with spread" },
		{ value = "T", label = "T", hint = "No top bar" },
		{ value = "Chevron", label = "Chevron", hint = "A small ^ under the dot" },
		{ value = "Brackets", label = "Brackets", hint = "[ ] that open with spread" },
	},
}

-- The Structure tab's per-element "Look" field (only for types with looks).
local function lookField(looks: { any }): any
	return {
		kind = "enum",
		key = "look",
		label = "Look",
		options = looks,
		default = "Kit's choice",
		defaultHint = "The HUD kit's look for this element (Design → HUD kit); with no kit, its original look",
	}
end

function Catalog.addLookFields()
	for _, entry in Catalog.hud or {} do
		local looks = Catalog.hudLooks[entry.type]
		if looks and #looks > 0 then
			local has = false
			for _, field in entry.fields do
				if field.key == "look" then
					has = true
				end
			end
			if not has then
				table.insert(entry.fields, 1, lookField(looks))
			end
		end
	end
end

Catalog.rarities ={ "Common", "Uncommon", "Rare", "Epic", "Legendary", "Mythic", "Secret" }
Catalog.rarityColors = {
	Common = "#B0B7C3",
	Uncommon = "#5BE22B",
	Rare = "#3AA8FF",
	Epic = "#B45CFF",
	Legendary = "#FFB300",
	Mythic = "#FF3B6B",
	Secret = "#FFFFFF",
}

--------------------------------------------------------------------------
-- Fonts: Roblox built-in families (content/fonts/families) and their weights
--------------------------------------------------------------------------

-- weight name -> Enum.FontWeight value
-- (names match what Blueprint.normalize writes, e.g. 900 = "Black")
Catalog.weights = {
	{ name = "Thin", value = 100 },
	{ name = "ExtraLight", value = 200 },
	{ name = "Light", value = 300 },
	{ name = "Regular", value = 400 },
	{ name = "Medium", value = 500 },
	{ name = "SemiBold", value = 600 },
	{ name = "Bold", value = 700 },
	{ name = "ExtraBold", value = 800 },
	{ name = "Black", value = 900 },
}

-- Enum.FontWeight item names by weight value.
Catalog.weightEnumNames = {
	[100] = "Thin",
	[200] = "ExtraLight",
	[300] = "Light",
	[400] = "Regular",
	[500] = "Medium",
	[600] = "SemiBold",
	[700] = "Bold",
	[800] = "ExtraBold",
	[900] = "Heavy",
}

Catalog.fonts = {
	-- Rubik: the RIVALS typeface, a free Roblox-published family loaded by asset id
	{ family = "Rubik", label = "Rubik", group = "Clean", asset = "rbxassetid://12187365977", weights = { 300, 400, 500, 600, 700, 800, 900 } },
	{ family = "BuilderSans", label = "Builder Sans", group = "Clean", weights = { 100, 300, 400, 500, 600, 700, 800 } },
	{ family = "BuilderExtended", label = "Builder Extended", group = "Clean", weights = { 300, 400, 600, 700, 800 } },
	{ family = "Montserrat", label = "Montserrat", group = "Clean", weights = { 100, 300, 400, 500, 600, 700, 800, 900 } },
	{ family = "Nunito", label = "Nunito", group = "Rounded", weights = { 300, 400, 600, 700, 800, 900 } },
	{ family = "FredokaOne", label = "Fredoka One", group = "Rounded", weights = { 400 } },
	{ family = "LuckiestGuy", label = "Luckiest Guy", group = "Cartoon", weights = { 400 } },
	{ family = "Bangers", label = "Bangers", group = "Cartoon", weights = { 400 } },
	{ family = "DenkOne", label = "Denk One", group = "Cartoon", weights = { 400 } },
	{ family = "PermanentMarker", label = "Permanent Marker", group = "Hand", weights = { 400 } },
	{ family = "PatrickHand", label = "Patrick Hand", group = "Hand", weights = { 400 } },
	{ family = "IndieFlower", label = "Indie Flower", group = "Hand", weights = { 400 } },
	{ family = "Kalam", label = "Kalam", group = "Hand", weights = { 300, 400, 700 } },
	{ family = "Oswald", label = "Oswald", group = "Condensed", weights = { 300, 400, 500, 600, 700 } },
	{ family = "RobotoCondensed", label = "Roboto Condensed", group = "Condensed", weights = { 300, 400, 700 } },
	{ family = "Sarpanch", label = "Sarpanch", group = "Tech", weights = { 400, 500, 600, 700, 800, 900 } },
	{ family = "Michroma", label = "Michroma", group = "Tech", weights = { 400 } },
	{ family = "TitilliumWeb", label = "Titillium Web", group = "Tech", weights = { 300, 400, 600, 700, 900 } },
	{ family = "Jura", label = "Jura", group = "Tech", weights = { 300, 400, 500, 600, 700 } },
	{ family = "PressStart2P", label = "Press Start 2P", group = "Retro", weights = { 400 } },
	{ family = "Roboto", label = "Roboto", group = "Clean", weights = { 100, 300, 400, 500, 700, 900 } },
	{ family = "Ubuntu", label = "Ubuntu", group = "Clean", weights = { 300, 400, 500, 700 } },
	{ family = "JosefinSans", label = "Josefin Sans", group = "Clean", weights = { 100, 300, 400, 500, 600, 700 } },
	{ family = "SourceSansPro", label = "Source Sans Pro", group = "Clean", weights = { 300, 400, 600, 700, 900 } },
	{ family = "Arimo", label = "Arimo", group = "Clean", weights = { 400, 500, 600, 700 } },
	{ family = "Merriweather", label = "Merriweather", group = "Serif", weights = { 300, 400, 700, 900 } },
	{ family = "Fondamento", label = "Fondamento", group = "Fantasy", weights = { 400 } },
	{ family = "GrenzeGotisch", label = "Grenze Gotisch", group = "Fantasy", weights = { 100, 300, 400, 500, 600, 700, 800, 900 } },
	{ family = "AmaticSC", label = "Amatic SC", group = "Fantasy", weights = { 400, 700 } },
	{ family = "Creepster", label = "Creepster", group = "Horror", weights = { 400 } },
	{ family = "SpecialElite", label = "Special Elite", group = "Horror", weights = { 400 } },
	{ family = "RobotoMono", label = "Roboto Mono", group = "Mono", weights = { 100, 300, 400, 500, 600, 700 } },
	{ family = "Inconsolata", label = "Inconsolata", group = "Mono", weights = { 300, 400, 500, 600, 700, 800, 900 } },
}

Catalog.fontByFamily = {}
for _, font in Catalog.fonts do
	Catalog.fontByFamily[font.family] = font
end

function Catalog.weightName(value: number): string
	for _, w in Catalog.weights do
		if w.value == value then
			return w.name
		end
	end
	return "Regular"
end

local WEIGHT_ALIASES = {
	black = 900, heavy = 900, extrabold = 800, ultrabold = 800, bold = 700, semibold = 600, demibold = 600,
	medium = 500, regular = 400, normal = 400, book = 400, light = 300, extralight = 200, ultralight = 200,
	thin = 100, hairline = 100,
}

function Catalog.weightValue(name: string?): number?
	if typeof(name) ~= "string" then
		return nil
	end
	local number = tonumber(name)
	if number then
		return number
	end
	return WEIGHT_ALIASES[string.lower((string.gsub(name, "[%s_%-]", "")))]
end

-- Brings the pickers in line with the Blueprint module actually shipped in the
-- payload (Blueprint.Schema): extra enum values, style fields and font
-- families are appended; nothing is removed. Safe to call more than once.
function Catalog.sync(Schema: any)
	if typeof(Schema) ~= "table" then
		return
	end
	local function merge(list: { any }, extra: any)
		if typeof(extra) ~= "table" then
			return
		end
		local seen = {}
		for _, v in list do
			seen[if typeof(v) == "table" then v.value else v] = true
		end
		for _, v in extra do
			if typeof(v) == "string" and not seen[v] then
				seen[v] = true
				table.insert(list, v)
			end
		end
	end
	local enums = Schema.STYLE_ENUMS
	if typeof(enums) == "table" then
		for key, values in enums do
			if Catalog.style[key] == nil then
				Catalog.style[key] = {}
				local label = if key == "rarityScheme" then "Rarity colours" else key
				table.insert(Catalog.styleControls, { key = key, label = label })
			end
			merge(Catalog.style[key], values)
		end
	end
	merge(Catalog.style.archetype, Schema.ARCHETYPES)
	-- v2.4 style families the payload knows but this catalog doesn't yet
	if typeof(Schema.STYLE_FAMILIES) == "table" then
		for _, id in Schema.STYLE_FAMILIES do
			if typeof(id) == "string" and Catalog.styleFamilyByValue[id] == nil then
				local info = typeof(Schema.FAMILY_INFO) == "table" and Schema.FAMILY_INFO[id] or nil
				local entry = { value = id, label = (info and info.label) or id, hint = (info and info.blurb) or id, preview = {} }
				table.insert(Catalog.styleFamilies, entry)
				Catalog.styleFamilyByValue[id] = entry
			end
		end
	end
	-- genres the payload knows but this catalog doesn't yet ("Start from a sample")
	if typeof(Schema.GENRES) == "table" then
		for _, genre in Schema.GENRES do
			local known = false
			for _, entry in Catalog.genres do
				if entry.value == genre then
					known = true
				end
			end
			if typeof(genre) == "string" and not known then
				table.insert(Catalog.genres, { value = genre, label = genre, icon = Catalog.genreIcons[genre] or "star" })
			end
		end
	end
	merge(Catalog.logoTypes, Schema.LOGO_TYPES)
	merge(Catalog.anchors, Schema.ANCHORS)
	merge(Catalog.rarities, Schema.RARITIES)
	-- v1.4 layout variants: values the payload knows but this catalog doesn't
	-- yet still show up (without a hint)
	merge(Catalog.hudLayouts, Schema.HUD_LAYOUTS)
	for _, layout in Catalog.hudLayouts do
		if typeof(layout) == "string" then
			Catalog.hudLayoutByValue[layout] = Catalog.hudLayoutByValue[layout] or { value = layout, label = layout }
		end
	end
	merge(Catalog.productLayouts, Schema.PRODUCT_LAYOUTS)
	merge(Catalog.inventoryLayouts, Schema.INVENTORY_LAYOUTS)
	merge(Catalog.transitions, Schema.SCREEN_TRANSITIONS)
	merge(Catalog.panelStyles, Schema.SCREEN_PANELS)
	-- v1.8 HUD kits and element looks (a payload may know more than this catalog)
	merge(Catalog.hudKits, Schema.HUD_KITS)
	for _, kit in Catalog.hudKits do
		if typeof(kit) == "string" then
			Catalog.hudKitByValue[kit] = Catalog.hudKitByValue[kit] or { value = kit, label = kit }
		end
	end
	if typeof(Schema.HUD_LOOKS) == "table" then
		for kind, looks in Schema.HUD_LOOKS do
			if typeof(kind) == "string" and typeof(looks) == "table" then
				Catalog.hudLooks[kind] = Catalog.hudLooks[kind] or {}
				merge(Catalog.hudLooks[kind], looks)
			end
		end
		Catalog.addLookFields()
	end
	if typeof(Schema.MENU_BANNER) == "table" then
		for layout, positions in Schema.MENU_BANNER do
			if typeof(layout) == "string" and typeof(positions) == "table" then
				Catalog.bannerPositions[layout] = Catalog.bannerPositions[layout] or {}
				merge(Catalog.bannerPositions[layout], positions)
			end
		end
	end
	if typeof(Schema.FONTS) == "table" then
		local names = {}
		for family in Schema.FONTS do
			table.insert(names, family)
		end
		table.sort(names)
		for _, family in names do
			local weights = Schema.FONTS[family]
			local existing = Catalog.fontByFamily[family]
			if existing and typeof(weights) == "table" then
				-- union of the weights we know and the ones the schema knows
				local set = {}
				for _, w in existing.weights do
					set[w] = true
				end
				for _, w in weights do
					if typeof(w) == "number" and not set[w] then
						set[w] = true
						table.insert(existing.weights, w)
					end
				end
				table.sort(existing.weights)
			elseif existing == nil and typeof(weights) == "table" and not string.find(family, "Fallback") then
				local entry = { family = family, label = family, group = "More", weights = table.clone(weights) }
				table.insert(Catalog.fonts, entry)
				Catalog.fontByFamily[family] = entry
			end
		end
	end
end

-- Font asset path for a family ("rbxasset://fonts/families/X.json" or an asset id).
function Catalog.fontPath(family: string): string
	local info = Catalog.fontByFamily[family]
	if info and info.asset then
		return info.asset
	end
	return `rbxasset://fonts/families/{family}.json`
end

-- "LuckiestGuy:Bold" -> "LuckiestGuy", "Bold"
function Catalog.splitFontRef(ref: string?): (string, string?)
	if typeof(ref) ~= "string" or ref == "" then
		return "BuilderSans", nil
	end
	local family, weight = string.match(ref, "^([^:]+):(.+)$")
	if family then
		return family, weight
	end
	return ref, nil
end

-- The heaviest available weight <= wanted (or the lightest available one).
function Catalog.closestWeight(family: string, wanted: string?): string
	local info = Catalog.fontByFamily[family]
	local target = Catalog.weightValue(wanted) or 400
	if info == nil then
		return wanted or "Regular"
	end
	local best = info.weights[1]
	for _, w in info.weights do
		if w <= target then
			best = w
		end
	end
	return Catalog.weightName(best)
end

--------------------------------------------------------------------------
-- Palettes
--------------------------------------------------------------------------

Catalog.paletteKeys = { "primary", "secondary", "accent", "surface", "text" }
Catalog.paletteExtraKeys = { "good", "bad", "warn" }
Catalog.paletteLabels = {
	primary = "Primary",
	secondary = "Secondary",
	accent = "Accent",
	surface = "Surface",
	text = "Text",
	good = "Good",
	bad = "Bad",
	warn = "Warn",
}

local function palette(name, primary, secondary, accent, surface, text, good, bad, warn)
	return {
		name = name,
		colors = {
			primary = primary,
			secondary = secondary,
			accent = accent,
			surface = surface,
			text = text,
			good = good or "#3DDC84",
			bad = bad or "#FF4D5E",
			warn = warn or "#FFB020",
		},
	}
end

Catalog.palettes = {
	palette("Arena Night", "#FFB020", "#2B3A55", "#FF5A36", "#141A26", "#FFFFFF"),
	palette("Neon Pulse", "#00E5FF", "#7C3AED", "#FF2BD6", "#0D0B1F", "#F5F7FF"),
	palette("Candy Pop", "#FF6FB5", "#8B5CF6", "#FFD23F", "#FFF4FB", "#3B1D4A", "#4ADE80", "#FF4D6D", "#FFB020"),
	palette("Meadow", "#5BD15B", "#FFB84D", "#4FC3F7", "#FFF8E7", "#3A2A1A", "#2FBF4A", "#F25C54", "#F4A62A"),
	palette("Treasure", "#FFC83D", "#1E3A8A", "#2DD4BF", "#1B2447", "#FFFFFF"),
	palette("Blood Moon", "#C1121F", "#3D3D3D", "#E8E2D9", "#0B0B0C", "#E8E2D9", "#7FB069", "#E63946", "#D4A017"),
	palette("Tactical", "#F59E0B", "#334155", "#22D3EE", "#111827", "#E5E7EB"),
	palette("Ocean Breeze", "#38BDF8", "#0EA5E9", "#FDE047", "#F0F9FF", "#0C4A6E", "#22C55E", "#EF4444", "#F59E0B"),
	palette("Sunset Tycoon", "#FF7A1A", "#FFCB45", "#3DDC97", "#2A1B3D", "#FFFFFF"),
	palette("Mono Sleek", "#FFFFFF", "#3F3F46", "#6366F1", "#18181B", "#FAFAFA"),
	palette("Jungle Quest", "#84CC16", "#065F46", "#FACC15", "#10231C", "#F7FEE7"),
	palette("Bubblegum", "#FF8FAB", "#6EC6FF", "#FFE066", "#FFFFFF", "#46315C", "#52D273", "#FF5C7A", "#FFB347"),
}

--------------------------------------------------------------------------
-- Tones (BloxUI tone names) with preview colours for pickers
--------------------------------------------------------------------------

Catalog.tones = {
	{ value = "Green", color = "#49D25A" },
	{ value = "Red", color = "#FF4B4B" },
	{ value = "Yellow", color = "#FFD21F" },
	{ value = "Blue", color = "#3AA8FF" },
	{ value = "Purple", color = "#A55CFF" },
	{ value = "Orange", color = "#FF9A1F" },
	{ value = "Pink", color = "#FF6FB5" },
	{ value = "Cyan", color = "#2FD6E6" },
	{ value = "Gray", color = "#9AA3B2" },
	{ value = "White", color = "#F4F6FA" },
	{ value = "Dark", color = "#2A2F3A" },
}
Catalog.toneColor = {}
for _, tone in Catalog.tones do
	Catalog.toneColor[tone.value] = tone.color
end

--------------------------------------------------------------------------
-- Actions
--------------------------------------------------------------------------

Catalog.actionKinds = {
	{ value = "None", label = "None", icon = "circle-slash" },
	{ value = "Open", label = "Open screen", icon = "arrow-up-right-from-square" },
	{ value = "Toggle", label = "Toggle screen", icon = "toggle-right" },
	{ value = "Close", label = "Close screen", icon = "x-small" },
	{ value = "Purchase", label = "Purchase product", icon = "robux" },
	{ value = "GamePass", label = "Game pass", icon = "premium" },
	{ value = "Remote", label = "Fire remote", icon = "paper-airplane" },
	{ value = "Event", label = "Local event", icon = "lightning-bolt" },
	{ value = "Notify", label = "Show notice", icon = "bell" },
}

-- Human summary of an action, e.g. "Open shop", "Purchase 123".
function Catalog.describeAction(action: any): string
	if typeof(action) ~= "table" or action.kind == nil or action.kind == "None" then
		return "No action"
	end
	local kind = action.kind
	if kind == "Open" or kind == "Close" or kind == "Toggle" then
		return `{kind} {action.target or "?"}`
	elseif kind == "Purchase" then
		return `Purchase {action.productId or "?"}`
	elseif kind == "GamePass" then
		return `Game pass {action.gamePassId or "?"}`
	elseif kind == "Remote" then
		return `Remote {action.name or "?"}`
	elseif kind == "Event" then
		return `Event {action.name or "?"}`
	elseif kind == "Notify" then
		return `Notify "{action.text or ""}"`
	end
	return kind
end

-- Common key names for key pickers (KeyCode names)
Catalog.keys = {
	"E", "F", "G", "Q", "R", "T", "B", "C", "V", "X", "Z", "M", "N", "H", "I", "J", "K", "L", "P", "Tab",
	"One", "Two", "Three", "Four", "Five", "LeftShift", "LeftControl", "Space", "Return", "Escape",
	"ButtonA", "ButtonB", "ButtonX", "ButtonY", "ButtonL1", "ButtonR1", "ButtonL2", "ButtonR2", "DPadUp", "DPadDown", "DPadLeft", "DPadRight",
}

--------------------------------------------------------------------------
-- Sounds & music (Design tab; docs/BLUEPRINT.md v1.3)
--------------------------------------------------------------------------

-- style.sounds slots. `search` = Creator Store search terms from
-- docs/RESEARCH.md 8.6; `core` slots are shown first, the rest behind "More".
Catalog.soundSlots = {
	{ key = "click", label = "Click", search = { "ui click", "button click short" }, core = true },
	{ key = "hover", label = "Hover", search = { "soft tick", "ui hover" }, core = true },
	{ key = "open", label = "Open", search = { "menu open", "pop up" }, core = true },
	{ key = "close", label = "Close", search = { "menu close", "swoosh down" }, core = true },
	{ key = "purchase", label = "Purchase", search = { "purchase", "cash register" }, core = true },
	{ key = "reward", label = "Reward", search = { "reward", "win jingle" }, core = true },
	{ key = "levelUp", label = "Level up", search = { "level up", "achievement" } },
	{ key = "error", label = "Error", search = { "error", "wrong buzzer" } },
	{ key = "notify", label = "Notify", search = { "notification", "bell ding" } },
	{ key = "swoosh", label = "Swoosh", search = { "whoosh short", "ui swish" } },
	{ key = "pop", label = "Pop", search = { "pop", "bubble pop" } },
	{ key = "tick", label = "Tick", search = { "counter tick", "blip" } },
	{ key = "coin", label = "Coin", search = { "coin pickup", "coin collect" } },
	{ key = "hit", label = "Hit", search = { "hitmarker", "impact tick" } },
	{ key = "kill", label = "Kill", search = { "kill confirm", "ding" } },
}

Catalog.musicSlots = {
	{ key = "lobby", label = "Lobby", hint = 'Main menu · Search: "lobby music"', search = { "lobby music", "menu theme" } },
	{ key = "gameplay", label = "Gameplay", hint = 'In game, optional · "background music"', search = { "background music", "adventure loop" } },
}

-- 'Search: "ui click" · "button click short"'
function Catalog.searchHint(terms: { string }): string
	local quoted = {}
	for _, term in terms do
		table.insert(quoted, `"{term}"`)
	end
	return "Search: " .. table.concat(quoted, " · ")
end

--------------------------------------------------------------------------
-- Field schema helpers
--------------------------------------------------------------------------

local F = {}
Catalog.F = F

-- `extra.hint` (any field): a short line under the control.
function F.text(key, label, extra)
	return { kind = "text", key = key, label = label, placeholder = extra and extra.placeholder, hint = extra and extra.hint }
end
function F.multiline(key, label)
	return { kind = "multiline", key = key, label = label }
end
function F.number(key, label, extra)
	extra = extra or {}
	return { kind = "number", key = key, label = label, min = extra.min, max = extra.max, step = extra.step, integer = extra.integer, hint = extra.hint }
end
function F.fraction(key, label, extra)
	return { kind = "number", key = key, label = label, min = 0, max = 1, step = 0.05, hint = extra and extra.hint }
end
function F.bool(key, label)
	return { kind = "bool", key = key, label = label }
end
--[[
	options: strings or { value, label, hint }, or function(item) -> options.
	extra = { default = "Theme default", defaultHint = "...", hint = "...",
	          after = function(item) ... end } (see Fields: default clears the key)
]]
function F.enum(key, label, options, extra)
	extra = extra or {}
	return {
		kind = "enum",
		key = key,
		label = label,
		options = options,
		default = extra.default,
		defaultHint = extra.defaultHint,
		hint = extra.hint,
		after = extra.after,
	}
end
function F.icon(key, label)
	return { kind = "icon", key = key or "icon", label = label or "Icon" }
end
function F.tone(key, label)
	return { kind = "tone", key = key or "tone", label = label or "Tone" }
end
function F.rarity(key, label)
	return { kind = "rarity", key = key or "rarity", label = label or "Rarity" }
end
function F.action(key, label)
	return { kind = "action", key = key or "action", label = label or "On click" }
end
function F.key(key, label)
	return { kind = "key", key = key, label = label }
end
function F.color(key, label)
	return { kind = "color", key = key or "color", label = label or "Colour" }
end
function F.currency(key, label, allowRobux)
	return { kind = "currency", key = key or "currency", label = label or "Currency", robux = allowRobux }
end
function F.strings(key, label, itemLabel)
	return { kind = "strings", key = key, label = label, itemLabel = itemLabel or "Item" }
end
function F.object(key, label, fields, default)
	return { kind = "object", key = key, label = label, fields = fields, default = default }
end
-- item.title: which key names an item in rows (first that exists wins)
function F.list(key, label, item)
	return { kind = "list", key = key, label = label, item = item }
end

--------------------------------------------------------------------------
-- Built-in settings (docs/BLUEPRINT.md v1.3 / v1.5): ids the builder applies
-- for real and saves per player (the same definitions as
-- Blueprint.Schema.BUILTIN_SETTING_DEFS). The Structure tab's settings
-- editor offers them with a hint; choosing one fills in its label, icon,
-- control and range. Any other id only fires ui:on(id, fn).
--------------------------------------------------------------------------

local COLORBLIND_MODES = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" }

Catalog.settingBuiltins = {
	-- General
	{ value = "hitMarkers", label = "Hit markers", hint = "Crosshair hit and kill markers on / off", setting = { label = "Hit Markers", icon = "crosshairs", type = "Toggle", default = true } },
	{ value = "damageNumbers", label = "Damage numbers", hint = "Floating damage numbers on / off", setting = { label = "Damage Numbers", icon = "hashtag", type = "Toggle", default = true } },
	{ value = "killFeed", label = "Kill feed", hint = "Shows or hides the HUD kill feed", setting = { label = "Kill Feed", icon = "list-bulleted", type = "Toggle", default = true } },
	{ value = "notifications", label = "Notifications", hint = "Off hides non-essential toasts; errors and purchases still show", setting = { label = "Notifications", icon = "bell", type = "Toggle", default = true } },
	{ value = "skipHatch", label = "Skip hatch animation", hint = "Egg hatches and case openings play their fast path", setting = { label = "Skip Hatch Animation", icon = "skip-next-large", type = "Toggle", default = false } },
	{ value = "showHud", label = "Show HUD", hint = "Hides the whole HUD for screenshots; F1 or the eye button brings it back", setting = { label = "Show HUD", icon = "frame-expanded", type = "Toggle", default = true } },
	-- Graphics
	{ value = "fov", label = "Field of view", hint = "Camera field of view, 70-120 (Slider)", setting = { label = "Field of View", icon = "binoculars", type = "Slider", min = 70, max = 120, default = 70 } },
	{ value = "shadows", label = "Shadows", hint = "Lighting.GlobalShadows on this client", setting = { label = "Shadows", icon = "sun", type = "Toggle", default = true } },
	{ value = "lowDetail", label = "Low detail mode", hint = "Lighter UI (no gloss layers) and fewer particles; a quality Choice works too", setting = { label = "Low Detail Mode", icon = "speedometer", type = "Toggle", default = false } },
	-- Audio
	{ value = "music", label = "Music", hint = "Music volume (Slider) or on / off (Toggle)", setting = { label = "Music", icon = "music", type = "Slider", min = 0, max = 100, default = 50 } },
	{ value = "sfx", label = "Sound effects", hint = "UI sound volume (Slider) or on / off (Toggle)", setting = { label = "Sound Effects", icon = "sound", type = "Slider", min = 0, max = 100, default = 50 } },
	-- Controls
	{ value = "sensitivity", label = "Sensitivity", hint = "Mouse sensitivity; 50 = 1x (Slider)", setting = { label = "Mouse Sensitivity", icon = "cursor", type = "Slider", min = 5, max = 100, default = 50 } },
	-- Accessibility
	{ value = "colorblind", label = "Colour-blind mode", hint = "Colourblind-safe palettes for good / bad / warning and rarity colours", setting = { label = "Colour-blind Mode", icon = "eye", type = "Choice", options = COLORBLIND_MODES, default = "Off" } },
	{ value = "reducedMotion", label = "Reduce motion", hint = "No camera shake, flashes or idle animation; fewer particles", setting = { label = "Reduce Motion", icon = "pause-large", type = "Toggle", default = false } },
	{ value = "uiScale", label = "UI scale", hint = "UI size in percent, 80-120; stays on screen on phones", setting = { label = "UI Scale", icon = "dual-arrows-to-corners", type = "Slider", min = 80, max = 120, default = 100 } },
}

Catalog.settingBuiltinById = {}
for _, entry in Catalog.settingBuiltins do
	Catalog.settingBuiltinById[entry.value] = entry
end

-- The "Setting" picker's options for one settings item: every built-in, plus
-- the item's own custom id.
function Catalog.settingOptions(item: any): { any }
	local out = {}
	for _, entry in Catalog.settingBuiltins do
		table.insert(out, { value = entry.value, label = entry.label, hint = entry.hint })
	end
	local id = typeof(item) == "table" and item.id or nil
	if typeof(id) == "string" and id ~= "" and Catalog.settingBuiltinById[id] == nil then
		table.insert(out, { value = id, label = `Custom: {id}`, hint = `Only fires ui:on("{id}", fn); your scripts apply it` })
	end
	return out
end

-- After a built-in is picked: its label, icon, control and range.
function Catalog.applySettingBuiltin(item: any)
	local entry = Catalog.settingBuiltinById[item.id]
	if entry == nil then
		return
	end
	local setting = entry.setting
	item.label = setting.label
	item.icon = setting.icon
	item.type = setting.type
	item.min = setting.min
	item.max = setting.max
	item.options = if setting.options then table.clone(setting.options) else nil
	item.default = setting.default
end

--------------------------------------------------------------------------
-- v1.4 layout variants (docs/BLUEPRINT.md "v1.4 additions")
--------------------------------------------------------------------------

Catalog.productLayouts = {
	{ value = "Cards", label = "Cards", hint = "A grid of product cards" },
	{ value = "Compact", label = "Compact", hint = "Short rows, two columns when there's room" },
	{ value = "Featured", label = "Featured", hint = "A big offer beside two regular ones, then a grid" },
	{ value = "Hero", label = "Hero", hint = "The first item as a full-width showcase, the rest in a grid" },
	{ value = "Carousel", label = "Carousel", hint = "One row that scrolls sideways, with arrows and dots" },
	{ value = "Rows", label = "Rows", hint = "A list with descriptions and prices; good for passes" },
	{ value = "Bundles", label = "Bundles", hint = "Tall value cards with a SAVE or BONUS ribbon" },
}

Catalog.inventoryLayouts = {
	{ value = "Side", label = "Side", hint = "Grid with the item detail panel on the right" },
	{ value = "Bottom", label = "Bottom", hint = "Grid with a detail bar underneath (Side under 560 px)" },
	{ value = "Showcase", label = "Showcase", hint = "Three big on-show slots above the grid (under 620 px: grid)" },
}

Catalog.dailyLayouts = {
	{ value = "Row", label = "Row", hint = "A row of day tiles" },
	{ value = "Calendar", label = "Calendar", hint = "A month grid, 7 across; up to 28 days" },
	{ value = "Ladder", label = "Ladder", hint = "Tiles that rise day by day on a streak rail" },
}

Catalog.menuLayouts = {
	{ value = "Left", label = "Left", hint = "Logo and buttons on the left, banner on the right" },
	{ value = "Center", label = "Center", hint = "Logo on top, a big PLAY in the middle, buttons under it" },
	{ value = "Split", label = "Split", hint = "A key-art panel with PLAY beside a rail of buttons" },
	{ value = "Corner", label = "Corner", hint = "Lobby look: buttons in the bottom-left, the middle kept clear" },
}

-- Where the Banner section can go for each menu layout (first = default).
Catalog.bannerPositions = {
	Left = {
		{ value = "Side", label = "Side", hint = "On the right, beside the buttons" },
	},
	Center = {
		{ value = "Bottom", label = "Bottom", hint = "Along the bottom (moves to the corner if it won't fit)" },
		{ value = "Corner", label = "Corner", hint = "A card in the top-right corner" },
	},
	Split = {
		{ value = "Side", label = "Top of rail", hint = "At the top of the button rail" },
		{ value = "Bottom", label = "Bottom of rail", hint = "At the bottom of the button rail" },
	},
	Corner = {
		{ value = "Side", label = "Side", hint = "On the right edge" },
		{ value = "Corner", label = "Corner", hint = "A card in the top-right corner" },
	},
}

function Catalog.bannerOptions(menuLayout: string?): { any }
	return Catalog.bannerPositions[menuLayout or "Left"] or Catalog.bannerPositions.Left
end

Catalog.transitions = {
	{ value = "Pop", label = "Pop", hint = "Springs up from small (bouncy)" },
	{ value = "Rise", label = "Rise", hint = "Fades in while rising a little (calm)" },
	{ value = "Slide", label = "Slide", hint = "Slides up from below" },
	{ value = "Fade", label = "Fade", hint = "Fades in place" },
	{ value = "Drop", label = "Drop", hint = "Drops in from above with a bounce" },
	{ value = "Zoom", label = "Zoom", hint = "Zooms in from larger" },
	{ value = "Swap", label = "Swap", hint = "A quick cross-fade; good between sibling screens" },
}

Catalog.panelStyles = {
	{ value = "Glass", label = "Glass", hint = "Frosted and see-through" },
	{ value = "Solid", label = "Solid", hint = "A flat, opaque panel" },
	{ value = "Gradient", label = "Gradient", hint = "A soft colour gradient" },
	{ value = "Outlined", label = "Outlined", hint = "Dark fill with a coloured border" },
	{ value = "Ribbon", label = "Ribbon", hint = "The title on a coloured ribbon" },
	{ value = "Fade", label = "Fade", hint = "Fades into the scene at the edges" },
}

-- Window / Fullscreen / Popup: per-screen overrides of the style defaults.
local WINDOW_CHROME = {
	F.enum("transition", "Transition", Catalog.transitions, {
		default = "Theme default",
		defaultHint = "Use the style's transition",
		hint = "How this screen opens and closes.",
	}),
	F.enum("panelStyle", "Panel", Catalog.panelStyles, {
		default = "Theme default",
		defaultHint = "Use the style's panel look",
	}),
	F.fraction("scrim", "Backdrop dim", { hint = "0 = none, 1 = black. Empty = the theme's." }),
}

local function reward()
	return F.object("reward", "Reward", { F.icon(), F.number("amount", "Amount", { min = 0 }) }, function()
		return { icon = "coin", amount = 100 }
	end)
end

-- v1.6: currency UI.Server pays itself; left empty, the game's scripts give
-- the prize (Server.onSpinPrize / Server.onReward).
local function grant()
	return F.object("grant", "Server pays", {
		F.currency("currency", "Currency"),
		F.number("amount", "Amount", { min = 0, integer = true, hint = "UI.Server grants this itself. Empty = your Server.onReward / onSpinPrize handler gives the prize." }),
	})
end

-- v1.6: a prize (icon, display amount, optional server grant).
local function prize(key, label)
	return F.object(key or "reward", label or "Reward", {
		F.icon(),
		F.text("amount", "Amount", { placeholder = "500", hint = "What players read, e.g. 500, 5K or 2x Luck" }),
		grant(),
	}, function()
		return { icon = "coin", amount = "100" }
	end)
end

local BUTTON_ITEM = {
	titleKeys = { "label" },
	fields = { F.text("label", "Label"), F.icon(), F.tone(), F.text("badge", "Badge"), F.action() },
	new = function(n)
		return { id = `button{n}`, label = "Button", icon = "star", tone = "Blue", action = { kind = "None" } }
	end,
}

--------------------------------------------------------------------------
-- HUD elements
--------------------------------------------------------------------------

Catalog.hud = {
	{
		type = "CurrencyBar",
		label = "Currency bar",
		icon = "circle-dollar-sign",
		blurb = "Coins, gems and other counters",
		fields = { F.strings("currencies", "Currencies (ids)", "Currency id"), F.bool("showAdd", "Show + button"), F.action("addAction", "+ button action") },
		new = function(ctx)
			local ids = {}
			for _, currency in ctx.currencies or {} do
				table.insert(ids, currency.id)
			end
			return { type = "CurrencyBar", anchor = "TopLeft", currencies = ids, showAdd = true, addAction = { kind = "None" } }
		end,
	},
	{
		type = "MenuButtons",
		label = "Menu buttons",
		icon = "four-squares-grid",
		blurb = "Shop / inventory / settings buttons",
		fields = {
			F.enum("style", "Style", { "Grid", "Column", "Row", "Dock" }),
			F.list("buttons", "Buttons", BUTTON_ITEM),
		},
		new = function(ctx)
			local buttons = {}
			for i, screen in ctx.screens or {} do
				if i > 4 then
					break
				end
				if screen.kind ~= "MainMenu" and screen.kind ~= "Loading" then
					table.insert(buttons, {
						id = `open_{screen.id}`,
						label = screen.title or screen.id,
						icon = screen.icon or "star",
						tone = "Blue",
						action = { kind = "Toggle", target = screen.id },
					})
				end
			end
			if #buttons == 0 then
				buttons = { BUTTON_ITEM.new(1) }
			end
			return { type = "MenuButtons", anchor = "Left", style = "Column", buttons = buttons }
		end,
	},
	{
		type = "HealthBar",
		label = "Health bar",
		icon = "heart",
		blurb = "Follows the Humanoid",
		fields = { F.number("max", "Max", { min = 1 }), F.object("source", "Source", { F.enum("kind", "Kind", { "humanoid", "none" }) }, function()
			return { kind = "humanoid" }
		end) },
		new = function()
			return { type = "HealthBar", anchor = "BottomLeft", max = 100, source = { kind = "humanoid" } }
		end,
	},
	{
		type = "XPBar",
		label = "XP bar",
		icon = "arrow-progress-up",
		blurb = "Level + progress",
		fields = { F.number("level", "Level", { min = 1, integer = true }), F.fraction("progress", "Progress") },
		new = function()
			return { type = "XPBar", anchor = "BottomCenter", level = 1, progress = 0.35 }
		end,
	},
	{
		type = "QuestTracker",
		label = "Quest tracker",
		icon = "clipboard-pencil",
		blurb = "Task list with rewards",
		fields = {
			F.text("title", "Title"),
			F.list("tasks", "Tasks", {
				titleKeys = { "text" },
				fields = { F.text("text", "Task"), F.icon(), F.number("goal", "Goal", { min = 1, integer = true }), reward() },
				new = function()
					return { text = "Collect 10 coins", icon = "coin", goal = 10, reward = { icon = "coin", amount = 50 } }
				end,
			}),
		},
		new = function()
			return { type = "QuestTracker", anchor = "Right", title = "Quests", tasks = { { text = "Collect 10 coins", icon = "coin", goal = 10, reward = { icon = "coin", amount = 50 } } } }
		end,
	},
	{
		type = "Hotbar",
		label = "Hotbar",
		icon = "rectangle-list",
		blurb = "Weapon / tool slots with ammo",
		fields = {
			F.list("slots", "Slots", {
				titleKeys = { "name" },
				fields = { F.text("name", "Name"), F.icon(), F.number("ammo", "Ammo", { min = 0, integer = true }), F.number("reserve", "Reserve", { min = 0, integer = true }) },
				new = function()
					return { name = "Slot", icon = "sword" }
				end,
			}),
		},
		new = function()
			return { type = "Hotbar", anchor = "BottomCenter", slots = { { name = "Rifle", icon = "crosshairs", ammo = 30, reserve = 90 }, { name = "Knife", icon = "sword" } } }
		end,
	},
	{
		type = "Crosshair",
		label = "Crosshair",
		icon = "crosshairs",
		blurb = "Centre reticle with hitmarkers",
		fields = { F.enum("style", "Style", { "Cross", "Dot" }) },
		new = function()
			return { type = "Crosshair", anchor = "Center", style = "Cross" }
		end,
	},
	{
		type = "KeyHints",
		label = "Key hints",
		icon = "keyboard",
		blurb = "Keyboard / gamepad prompts",
		fields = {
			F.list("hints", "Hints", {
				titleKeys = { "text" },
				fields = { F.text("text", "Text"), F.key("key", "Key"), F.key("gamepad", "Gamepad") },
				new = function()
					return { text = "Interact", key = "E", gamepad = "ButtonX" }
				end,
			}),
		},
		new = function()
			return { type = "KeyHints", anchor = "BottomRight", hints = { { text = "Interact", key = "E", gamepad = "ButtonX" } } }
		end,
	},
	{
		type = "Objective",
		label = "Objective",
		icon = "flag",
		blurb = "Typed objective text",
		fields = { F.text("text", "Text") },
		new = function()
			return { type = "Objective", anchor = "TopCenter", text = "Find the exit" }
		end,
	},
	{
		type = "Stamina",
		label = "Stamina",
		icon = "person-running",
		blurb = "Sprint meter",
		fields = {},
		new = function()
			return { type = "Stamina", anchor = "BottomCenter" }
		end,
	},
	{
		type = "Battery",
		label = "Battery",
		icon = "lightning-bolt",
		blurb = "Flashlight battery",
		fields = {},
		new = function()
			return { type = "Battery", anchor = "BottomRight" }
		end,
	},
	{
		type = "Fear",
		label = "Fear meter",
		icon = "eye",
		blurb = "Vignette + heartbeat",
		fields = { F.fraction("intensity", "Intensity") },
		new = function()
			return { type = "Fear", anchor = "Center", intensity = 0.3 }
		end,
	},
	{
		type = "Vignette",
		label = "Vignette",
		icon = "circle-dashed",
		blurb = "Darkened screen edges",
		fields = {
			F.fraction("intensity", "Intensity"),
			F.enum("style", "Style", {
				{ value = "Soft", label = "Soft", hint = "Gentle dark edges" },
				{ value = "Tunnel", label = "Tunnel", hint = "A hard circle: low health, a flashlight" },
				{ value = "Static", label = "Static", hint = "TV snow crawling in from the edges (Slender)" },
			}),
		},
		new = function()
			return { type = "Vignette", anchor = "Center", intensity = 0.4, style = "Soft" }
		end,
	},
	{
		type = "Boosts",
		label = "Boosts",
		icon = "double-chevron-large-up",
		blurb = "Active boost timers",
		fields = {
			F.list("boosts", "Boosts", {
				titleKeys = { "text" },
				fields = { F.icon(), F.text("text", "Text"), F.number("seconds", "Seconds", { min = 1, integer = true }) },
				new = function()
					return { icon = "lightning-bolt", text = "2x Coins", seconds = 600 }
				end,
			}),
		},
		new = function()
			return { type = "Boosts", anchor = "Right", boosts = { { icon = "lightning-bolt", text = "2x Coins", seconds = 600 } } }
		end,
	},
	{
		type = "Clicker",
		label = "Clicker",
		icon = "cursor-stars",
		blurb = "Giant click button",
		fields = { F.text("text", "Text"), F.icon(), F.currency("currency", "Currency"), F.number("amount", "Amount", { min = 1 }) },
		new = function(ctx)
			local first = ctx.currencies and ctx.currencies[1]
			return { type = "Clicker", anchor = "BottomCenter", text = "Click!", icon = "coin", currency = first and first.id or "coins", amount = 1 }
		end,
	},
	{
		type = "KillFeed",
		label = "Kill feed",
		icon = "rectangle-list",
		blurb = "Recent eliminations",
		fields = {},
		new = function()
			return { type = "KillFeed", anchor = "TopRight" }
		end,
	},
	{
		type = "Timer",
		label = "Timer",
		icon = "clock",
		blurb = "Round / event countdown",
		fields = { F.text("label", "Label"), F.number("seconds", "Seconds", { min = 1, integer = true }) },
		new = function()
			return { type = "Timer", anchor = "TopCenter", label = "Round ends", seconds = 180 }
		end,
	},
	{
		type = "TeamScore",
		label = "Team score",
		icon = "trophy",
		blurb = "Top-centre match header",
		fields = {
			F.number("goal", "Goal", { min = 1, integer = true }),
			F.list("teams", "Teams", {
				titleKeys = { "name" },
				fields = { F.text("name", "Name"), F.color("color", "Colour"), F.number("score", "Score", { min = 0, integer = true }) },
				new = function()
					return { name = "Team", color = "#3AA8FF", score = 0 }
				end,
			}),
		},
		new = function()
			return { type = "TeamScore", anchor = "TopCenter", goal = 50, teams = { { name = "Blue", color = "#3AA8FF", score = 0 }, { name = "Red", color = "#FF4B4B", score = 0 } } }
		end,
	},
	{
		type = "Button",
		label = "Button",
		icon = "square",
		blurb = "A single HUD button",
		fields = { F.text("label", "Label"), F.icon(), F.tone(), F.action() },
		new = function()
			return { type = "Button", anchor = "BottomRight", label = "Play", icon = "play", tone = "Green", action = { kind = "None" } }
		end,
	},
	{
		type = "Minimap",
		label = "Minimap",
		icon = "location-pin-map",
		blurb = "Decorative map frame",
		fields = { F.enum("shape", "Shape", { "Round", "Square" }) },
		new = function()
			return { type = "Minimap", anchor = "TopRight", shape = "Round" }
		end,
	},
	{
		type = "BossBar",
		label = "Boss bar",
		icon = "crown",
		blurb = "Big top-centre boss health",
		fields = { F.text("name", "Boss name"), F.number("max", "Max health", { min = 1 }), F.tone() },
		new = function()
			return { type = "BossBar", anchor = "TopCenter", name = "The Warden", max = 5000, tone = "Red" }
		end,
	},
	{
		type = "MobileControls",
		label = "Mobile controls",
		icon = "smartphone-portrait",
		blurb = "Touch-only action buttons",
		fields = {
			F.list("buttons", "Buttons", {
				titleKeys = { "label" },
				fields = { F.text("label", "Label"), F.icon(), F.action() },
				new = function(n)
					return { id = `touch{n}`, label = "Action", icon = "hand", action = { kind = "None" } }
				end,
			}),
		},
		new = function()
			return { type = "MobileControls", anchor = "BottomRight", buttons = { { id = "sprint", label = "Sprint", icon = "run", action = { kind = "Event", name = "Sprint" } } } }
		end,
	},
	{
		type = "EmoteWheel",
		label = "Emote wheel",
		icon = "square-face-waving-hand",
		blurb = "Radial menu on a key",
		fields = {
			F.key("key", "Key"),
			F.list("emotes", "Emotes", {
				titleKeys = { "label" },
				fields = { F.text("label", "Label"), F.icon() },
				new = function(n)
					return { id = `emote{n}`, label = "Wave", icon = "hand" }
				end,
			}),
		},
		new = function()
			return {
				type = "EmoteWheel",
				anchor = "Center",
				key = "G",
				emotes = { { id = "wave", label = "Wave", icon = "hand" }, { id = "dance", label = "Dance", icon = "music" }, { id = "cheer", label = "Cheer", icon = "star" } },
			}
		end,
	},
	-- v1.7: sub-genre readouts (horror flavours and more)
	{
		type = "Counter",
		label = "Counter",
		icon = "hashtag",
		blurb = "DOOR 0042, PAGES 3/8, NIGHT 3 · 4 AM",
		fields = {
			F.text("label", "Label", { placeholder = "DOOR", hint = "The small caption: DOOR, PAGES, LEVEL, NIGHT, GENERATORS." }),
			F.number("value", "Value", { integer = true, hint = "Set it from your game with the element's handle: h.set(43)." }),
			F.number("goal", "Goal", { min = 1, integer = true, hint = "Adds /8 (PAGES 3/8); Stamp and Scrawl show a pip per point up to 10." }),
			F.number("digits", "Digits", { min = 1, max = 8, integer = true, hint = "Zero-pads the number: 4 shows DOOR 0042." }),
			F.text("suffix", "Suffix", { placeholder = "4 AM", hint = "A second reading after a dot: NIGHT 3 · 4 AM." }),
			F.icon(),
			F.enum("style", "Style", {
				{ value = "Plaque", label = "Plaque", hint = "A brass door plate with engraved numerals (hotel runs)" },
				{ value = "Digital", label = "Digital", hint = "An LCD readout (night shifts, the backrooms)" },
				{ value = "Stamp", label = "Stamp", hint = "Poster type on a torn tape strip (slashers, toy factories)" },
				{ value = "Scrawl", label = "Scrawl", hint = "Handwritten and underlined (forest pages)" },
			}),
		},
		new = function()
			return { type = "Counter", anchor = "TopCenter", label = "DOOR", value = 42, digits = 4, style = "Plaque" }
		end,
	},
	{
		type = "Meter",
		label = "Meter",
		icon = "speedometer",
		blurb = "Sanity, oxygen, power, EMF, lantern oil",
		fields = {
			F.text("label", "Label", { placeholder = "SANITY" }),
			F.icon(),
			F.fraction("value", "Value", { hint = "0 to 1. Set it from your game: h.set(0.4)." }),
			F.enum("style", "Style", {
				{ value = "Bar", label = "Bar", hint = "A labelled progress bar" },
				{ value = "Segments", label = "Segments", hint = "Chunky cells plus optional usage bars (FNAF power)" },
				{ value = "Dial", label = "Dial", hint = "An arc of numbered LEDs (an EMF reader)" },
				{ value = "ECG", label = "ECG", hint = "A heartbeat monitor that says FINE, CAUTION or DANGER" },
			}),
			F.tone(),
			F.number("segments", "Segments", { min = 2, max = 12, integer = true, hint = "Cells (Segments) or LEDs (Dial)." }),
			F.number("usage", "Usage bars", { min = 0, max = 5, integer = true, hint = "Segments only: how much is drawing power right now." }),
			F.text("readout", "Readout", { placeholder = "72%", hint = "Text shown instead of the percentage." }),
		},
		new = function()
			return { type = "Meter", anchor = "BottomLeft", label = "SANITY", icon = "eye", value = 0.7, style = "Bar" }
		end,
	},
	{
		type = "TeamStatus",
		label = "Team status",
		icon = "three-people",
		blurb = "Survivors alive / downed / escaped vs the killer",
		fields = {
			F.text("title", "Title", { placeholder = "SURVIVORS" }),
			F.list("members", "Members", {
				titleKeys = { "name" },
				fields = {
					F.text("name", "Name"),
					F.icon(),
					F.enum("state", "State", {
						{ value = "Alive", label = "Alive", hint = "A clean ring" },
						{ value = "Downed", label = "Downed", hint = "Amber, a falling figure; it pulses" },
						{ value = "Escaped", label = "Escaped", hint = "Green, a door" },
						{ value = "Dead", label = "Dead", hint = "Greyed out with a cross" },
						{ value = "Caught", label = "Caught", hint = "Amber with a lock" },
					}),
				},
				new = function(n)
					return { name = `Survivor {n}`, icon = "head-blank", state = "Alive" }
				end,
			}),
			F.object("killer", "Killer", { F.text("name", "Name"), F.icon() }, function()
				return { name = "The Killer", icon = "hand" }
			end),
		},
		new = function()
			return {
				type = "TeamStatus",
				anchor = "TopCenter",
				title = "SURVIVORS",
				members = { { name = "Mia", icon = "head-female", state = "Alive" }, { name = "Theo", icon = "head-male", state = "Downed" }, { name = "Jess", icon = "head-freckles", state = "Escaped" } },
				killer = { name = "The Butcher", icon = "hand" },
			}
		end,
	},
	{
		type = "CameraPanel",
		label = "Camera panel",
		icon = "video-camera",
		blurb = "Security-camera switcher (fires its id with the camera)",
		fields = {
			F.text("title", "Title", { placeholder = "CAMERAS" }),
			F.number("columns", "Columns", { min = 1, max = 6, integer = true }),
			F.list("cameras", "Cameras", {
				titleKeys = { "label", "name" },
				fields = { F.text("label", "Code", { placeholder = "1A", hint = "Up to 4 characters; shown as CAM 1A." }), F.text("name", "Room", { placeholder = "Show Stage" }) },
				new = function(n)
					return { id = `cam{n}`, label = tostring(n), name = `Camera {n}` }
				end,
			}),
		},
		new = function()
			return {
				type = "CameraPanel",
				anchor = "BottomRight",
				title = "CAMERAS",
				columns = 3,
				cameras = {
					{ id = "cam1a", label = "1A", name = "Show Stage" }, { id = "cam1b", label = "1B", name = "Dining Area" }, { id = "cam2a", label = "2A", name = "West Hall" },
					{ id = "cam2b", label = "2B", name = "West Corner" }, { id = "cam4a", label = "4A", name = "East Hall" }, { id = "cam5", label = "5", name = "Backstage" },
				},
			}
		end,
	},
	{
		type = "Camcorder",
		label = "Camcorder",
		icon = "frame-record",
		blurb = "Found-footage REC, timecode and brackets",
		fields = {
			F.enum("mode", "Mode", {
				{ value = "VHS", label = "VHS", hint = "White on-screen display over scanlines" },
				{ value = "NightVision", label = "Night vision", hint = "Phosphor green with a green tint" },
				{ value = "Digital", label = "Digital", hint = "A clean modern camera, no scanlines" },
			}),
			F.text("label", "Label", { placeholder = "REC" }),
			F.text("date", "Date stamp", { placeholder = "OCT 31 1998" }),
			F.fraction("battery", "Battery", { hint = "0 to 1; the last cell blinks when it's low." }),
			F.bool("brackets", "Corner brackets"),
			F.bool("scanlines", "Scanlines"),
		},
		new = function()
			return { type = "Camcorder", anchor = "TopLeft", mode = "VHS", label = "REC", date = "OCT 31 1998", battery = 0.7, brackets = true, scanlines = true }
		end,
	},
	{
		type = "Compass",
		label = "Compass",
		icon = "compass",
		blurb = "Heading strip that follows the camera",
		fields = {
			F.number("heading", "Heading", { min = 0, max = 359, integer = true, hint = "The preview heading; in game it follows the camera." }),
			F.number("marker", "Marker", { min = 0, max = 359, integer = true, hint = "Heading of a goal, shown as a dot on the strip." }),
			F.bool("follow", "Follow the camera"),
		},
		new = function()
			return { type = "Compass", anchor = "TopCenter", heading = 0, follow = true }
		end,
	},
}

--------------------------------------------------------------------------
-- Sections
--------------------------------------------------------------------------

local PRODUCT_ITEM = {
	titleKeys = { "title" },
	fields = {
		F.text("title", "Title"),
		F.number("amount", "Amount", { min = 0 }),
		F.icon(),
		F.tone(),
		F.number("price", "Price", { min = 0 }),
		F.currency("currency", "Paid with", true),
		F.number("productId", "Product id", { min = 0, integer = true }),
		F.number("gamePassId", "Game pass id", { min = 0, integer = true }),
		F.text("tag", "Tag"),
		F.text("description", "Description", { placeholder = "Short selling line", hint = "Up to 48 characters. Rows and Bundles show it." }),
		F.number("bonus", "Bonus %", { min = 0, max = 1000, integer = true, hint = "Extra currency, as a percent. 0 = none." }),
		F.number("was", "Was", { min = 0, hint = "The old price, struck through. Must be above the price." }),
	},
	new = function(n)
		return { id = `product{n}`, title = "Bundle", amount = 100, icon = "coin", tone = "Yellow", price = 49, currency = "robux" }
	end,
}

Catalog.sections = {
	{
		kind = "Products",
		label = "Products",
		icon = "shopping-basket",
		blurb = "Shop cards: Robux or currency",
		fields = { F.enum("layout", "Layout", Catalog.productLayouts), F.list("items", "Items", PRODUCT_ITEM) },
		new = function()
			return { kind = "Products", title = "Shop", layout = "Cards", items = { PRODUCT_ITEM.new(1), { id = "product2", title = "Big Bundle", amount = 500, icon = "coin", tone = "Orange", price = 199, currency = "robux", tag = "Best value" } } }
		end,
	},
	{
		kind = "Inventory",
		label = "Inventory",
		icon = "backpack",
		blurb = "Item grid with rarities",
		fields = {
			F.enum("layout", "Layout", Catalog.inventoryLayouts, {
				default = "Classic",
				defaultHint = "The v1 grid (detail panel if Detail is on)",
			}),
			F.number("columns", "Columns", { min = 2, max = 8, integer = true }),
			F.bool("detail", "Detail panel"),
			F.list("items", "Items", {
				titleKeys = { "name" },
				fields = { F.text("name", "Name"), F.icon(), F.rarity(), F.number("count", "Count", { min = 0, integer = true }), F.bool("equipped", "Equipped"), F.bool("locked", "Locked") },
				new = function(n)
					return { id = `item{n}`, name = "Item", icon = "star", rarity = "Common" }
				end,
			}),
		},
		new = function()
			return {
				kind = "Inventory",
				title = "Inventory",
				columns = 5,
				detail = true,
				items = { { id = "item1", name = "Sword", icon = "sword", rarity = "Rare" }, { id = "item2", name = "Shield", icon = "shield", rarity = "Common" } },
			}
		end,
	},
	{
		kind = "Upgrades",
		label = "Upgrades",
		icon = "arrow-large-up",
		blurb = "Levelled upgrades with costs",
		fields = {
			F.list("upgrades", "Upgrades", {
				titleKeys = { "title" },
				fields = { F.text("title", "Title"), F.icon(), F.number("level", "Level", { min = 0, integer = true }), F.number("maxLevel", "Max level", { min = 1, integer = true }), F.number("cost", "Cost", { min = 0 }), F.currency("currency", "Currency"), F.text("effect", "Effect") },
				new = function(n)
					return { id = `upgrade{n}`, title = "Upgrade", icon = "arrow-large-up", level = 0, maxLevel = 10, cost = 100, currency = "coins", effect = "+10%" }
				end,
			}),
		},
		new = function()
			return { kind = "Upgrades", title = "Upgrades", upgrades = { { id = "speed", title = "Speed", icon = "run", level = 1, maxLevel = 10, cost = 100, currency = "coins", effect = "+10% speed" } } }
		end,
	},
	{
		kind = "Daily",
		label = "Daily rewards",
		icon = "calendar-star",
		blurb = "7-day login calendar",
		fields = {
			F.enum("layout", "Layout", { Catalog.dailyLayouts[2], Catalog.dailyLayouts[3] }, {
				default = "Row",
				defaultHint = Catalog.dailyLayouts[1].hint,
			}),
			F.number("streak", "Streak", { min = 0, max = 27, integer = true, hint = "Days already claimed when it opens, so the next one is today. 0 = none." }),
			F.list("days", "Days", {
				titleKeys = { "label", "day" },
				fields = { F.number("day", "Day", { min = 1, integer = true }), F.icon(), F.number("amount", "Amount", { min = 0 }), F.text("label", "Label") },
				new = function(n)
					return { day = n, icon = "coin", amount = 100 * n, label = `Day {n}` }
				end,
			}),
		},
		new = function()
			local days = {}
			for day = 1, 7 do
				table.insert(days, { day = day, icon = if day == 7 then "gem" else "coin", amount = if day == 7 then 50 else day * 100, label = `Day {day}` })
			end
			return { kind = "Daily", title = "Daily Rewards", days = days }
		end,
	},
	{
		kind = "Quests",
		label = "Quests",
		icon = "clipboard-pencil",
		blurb = "Quest cards with progress",
		fields = {
			F.list("quests", "Quests", {
				titleKeys = { "title" },
				fields = { F.text("title", "Title"), F.text("description", "Description"), F.icon(), F.number("goal", "Goal", { min = 1, integer = true }), F.number("progress", "Progress", { min = 0, integer = true }), reward() },
				new = function(n)
					return { id = `quest{n}`, title = "Quest", description = "Do the thing", icon = "flag", goal = 10, progress = 0, reward = { icon = "coin", amount = 100 } }
				end,
			}),
		},
		new = function()
			return { kind = "Quests", title = "Quests", quests = { { id = "quest1", title = "First Steps", description = "Collect 100 coins", icon = "coin", goal = 100, progress = 20, reward = { icon = "gem", amount = 5 } } } }
		end,
	},
	{
		kind = "Pass",
		label = "Battle pass",
		icon = "premium",
		blurb = "Free + premium reward track",
		fields = {
			F.number("premiumProductId", "Premium product id", { min = 0, integer = true }),
			F.list("tiers", "Tiers", {
				titleKeys = { "tier" },
				fields = {
					F.number("tier", "Tier", { min = 1, integer = true }),
					F.object("free", "Free reward", { F.icon(), F.number("amount", "Amount", { min = 0 }) }, function()
						return { icon = "coin", amount = 100 }
					end),
					F.object("premium", "Premium reward", { F.icon(), F.number("amount", "Amount", { min = 0 }) }, function()
						return { icon = "gem", amount = 10 }
					end),
				},
				new = function(n)
					return { tier = n, free = { icon = "coin", amount = 100 }, premium = { icon = "gem", amount = 10 } }
				end,
			}),
		},
		new = function()
			local tiers = {}
			for tier = 1, 6 do
				table.insert(tiers, { tier = tier, free = { icon = "coin", amount = tier * 100 }, premium = { icon = "gem", amount = tier * 5 } })
			end
			return { kind = "Pass", title = "Season Pass", tiers = tiers }
		end,
	},
	{
		kind = "Leaderboard",
		label = "Leaderboard",
		icon = "trophy",
		blurb = "Top players by a stat",
		fields = {
			F.text("stat", "Stat"),
			F.list("rows", "Rows", {
				titleKeys = { "name" },
				fields = { F.number("rank", "Rank", { min = 1, integer = true }), F.text("name", "Name"), F.number("value", "Value", { min = 0 }) },
				new = function(n)
					return { rank = n, name = "Player", value = 1000 }
				end,
			}),
		},
		new = function()
			return { kind = "Leaderboard", title = "Top Players", stat = "Coins", rows = { { rank = 1, name = "Builderman", value = 125000 }, { rank = 2, name = "Noob", value = 98000 }, { rank = 3, name = "Guest", value = 45000 } } }
		end,
	},
	{
		kind = "Scoreboard",
		label = "Scoreboard",
		icon = "chart-three-horizontal-bars",
		blurb = "Team K/D table",
		fields = {
			F.strings("columns", "Columns", "Column"),
			F.list("teams", "Teams", {
				titleKeys = { "name" },
				fields = { F.text("name", "Name"), F.color("color", "Colour") },
				new = function()
					return { name = "Team", color = "#3AA8FF", rows = {} }
				end,
			}),
		},
		new = function()
			return {
				kind = "Scoreboard",
				title = "Scoreboard",
				columns = { "K", "D", "Score" },
				teams = {
					{ name = "Blue", color = "#3AA8FF", rows = { { name = "Player1", values = { 12, 3, 1450 } } } },
					{ name = "Red", color = "#FF4B4B", rows = { { name = "Player2", values = { 9, 5, 1100 } } } },
				},
			}
		end,
	},
	{
		kind = "Loadout",
		label = "Loadout",
		icon = "tshirt-2d-text",
		blurb = "Equipment slots with stats",
		fields = {
			F.list("slots", "Slots", {
				titleKeys = { "name" },
				fields = { F.text("name", "Name"), F.icon(), F.rarity() },
				new = function()
					return { name = "Slot", icon = "sword", rarity = "Common", stats = { { label = "Damage", value = 0.5 } } }
				end,
			}),
		},
		new = function()
			return { kind = "Loadout", title = "Loadout", slots = { { name = "Primary", icon = "crosshairs", rarity = "Epic", stats = { { label = "Damage", value = 0.7 }, { label = "Range", value = 0.5 } } } } }
		end,
	},
	{
		kind = "Stats",
		label = "Stats",
		icon = "chart-four-vertical-bars",
		blurb = "Label / value rows",
		fields = {
			F.list("stats", "Stats", {
				titleKeys = { "label" },
				fields = { F.text("label", "Label"), F.icon(), F.text("value", "Value"), F.fraction("bar", "Bar") },
				new = function()
					return { label = "Stat", icon = "star", value = "0" }
				end,
			}),
		},
		new = function()
			return { kind = "Stats", title = "Stats", stats = { { label = "Wins", icon = "trophy", value = "12" }, { label = "Playtime", icon = "clock", value = "3h" } } }
		end,
	},
	{
		kind = "Settings",
		label = "Settings",
		icon = "gear",
		blurb = "Toggles, sliders, choices",
		fields = {
			F.list("settings", "Settings", {
				titleKeys = { "label" },
				-- built-in ids (Catalog.settingBuiltins) change the game for real;
				-- picking one fills in its label, icon, control and range
				fields = {
					F.enum("id", "Setting", Catalog.settingOptions, {
						hint = "Built-in settings really work and are saved per player. A custom id only fires ui:on(id, fn).",
						after = Catalog.applySettingBuiltin,
					}),
					F.text("label", "Label"),
					F.text("id", "Id", { placeholder = "a custom id (fires ui:on)" }),
					F.icon(),
					F.enum("type", "Type", { "Toggle", "Slider", "Choice" }),
					F.number("min", "Min"),
					F.number("max", "Max"),
					F.strings("options", "Options", "Option"),
				},
				new = function(n)
					return { id = `setting{n}`, label = "Setting", icon = "gear", type = "Toggle", default = true }
				end,
			}),
		},
		new = function()
			return {
				kind = "Settings",
				title = "Settings",
				settings = {
					{ id = "music", label = "Music", icon = "music", type = "Slider", default = 50, min = 0, max = 100 },
					{ id = "sfx", label = "Sound Effects", icon = "sound", type = "Slider", default = 50, min = 0, max = 100 },
				},
			}
		end,
	},
	{
		kind = "Banner",
		label = "Banner",
		icon = "tag-sparkle",
		blurb = "Featured offer / hero card",
		fields = {
			F.text("title", "Title"),
			F.text("subtitle", "Subtitle"),
			F.icon(),
			F.tone(),
			F.number("timer", "Timer (seconds)", { min = 0, integer = true }),
			F.object("button", "Button", { F.text("label", "Label"), F.action() }, function()
				return { label = "Get", action = { kind = "None" } }
			end),
		},
		new = function()
			return { kind = "Banner", title = "Limited Offer", subtitle = "Only this weekend!", icon = "gift", tone = "Pink", button = { label = "Get it", action = { kind = "None" } }, timer = 86400 }
		end,
	},
	{
		kind = "Buttons",
		label = "Buttons",
		icon = "four-squares-grid",
		blurb = "Row / column of buttons",
		fields = { F.enum("layout", "Layout", { "Row", "Column", "Grid" }), F.list("buttons", "Buttons", BUTTON_ITEM) },
		new = function()
			return { kind = "Buttons", layout = "Column", buttons = { { id = "play", label = "Play", icon = "play", tone = "Green", action = { kind = "None" } } } }
		end,
	},
	{
		kind = "Codes",
		label = "Codes",
		icon = "key",
		blurb = "Redeem-code input",
		fields = { F.text("placeholder", "Placeholder"), F.text("button", "Button text"), F.action() },
		new = function()
			return { kind = "Codes", title = "Codes", placeholder = "Enter code...", button = "Redeem", action = { kind = "Remote", name = "RedeemCode" } }
		end,
	},
	{
		kind = "Text",
		label = "Text",
		icon = "text-t-serif",
		blurb = "A paragraph of text",
		fields = { F.multiline("body", "Body") },
		new = function()
			return { kind = "Text", title = "About", body = "Write something here." }
		end,
	},
	{
		kind = "Cases",
		label = "Cases",
		icon = "gift-box",
		blurb = "Crates that spin a case opener",
		fields = {
			F.list("cases", "Cases", {
				titleKeys = { "title" },
				fields = { F.text("title", "Title"), F.icon(), F.tone(), F.number("price", "Price", { min = 0 }), F.currency("currency", "Paid with", true) },
				new = function(n)
					return {
						id = `case{n}`,
						title = "Case",
						icon = "gift",
						tone = "Purple",
						price = 100,
						currency = "coins",
						pool = { { name = "Common Skin", icon = "star", rarity = "Common", chance = 0.7 }, { name = "Epic Skin", icon = "star", rarity = "Epic", chance = 0.25 }, { name = "Legendary Skin", icon = "crown", rarity = "Legendary", chance = 0.05 } },
					}
				end,
			}),
		},
		new = function()
			return {
				kind = "Cases",
				title = "Cases",
				cases = {
					{
						id = "starter",
						title = "Starter Case",
						icon = "gift",
						tone = "Blue",
						price = 250,
						currency = "coins",
						pool = { { name = "Common Skin", icon = "star", rarity = "Common", chance = 0.7 }, { name = "Epic Skin", icon = "star", rarity = "Epic", chance = 0.25 }, { name = "Legendary Skin", icon = "crown", rarity = "Legendary", chance = 0.05 } },
					},
				},
			}
		end,
	},
	{
		kind = "Worlds",
		label = "Worlds",
		icon = "globe-simplified",
		blurb = "World / teleport select",
		fields = {
			F.list("worlds", "Worlds", {
				titleKeys = { "name" },
				fields = { F.text("name", "Name"), F.icon(), F.tone(), F.text("requirement", "Requirement"), F.bool("locked", "Locked") },
				new = function(n)
					return { id = `world{n}`, name = "World", icon = "globe-simplified", tone = "Green", requirement = "", locked = n > 1 }
				end,
			}),
		},
		new = function()
			return {
				kind = "Worlds",
				title = "Worlds",
				worlds = { { id = "spawn", name = "Spawn Island", icon = "house", tone = "Green", requirement = "", locked = false }, { id = "desert", name = "Desert", icon = "sun", tone = "Orange", requirement = "10K coins", locked = true } },
			}
		end,
	},
	{
		kind = "Profile",
		label = "Profile",
		icon = "circle-person",
		blurb = "Player card with badges",
		fields = {
			F.list("stats", "Stats", {
				titleKeys = { "label" },
				fields = { F.text("label", "Label"), F.icon(), F.text("value", "Value") },
				new = function()
					return { label = "Stat", icon = "star", value = "0" }
				end,
			}),
			F.list("badges", "Badges", {
				titleKeys = { "label" },
				fields = { F.icon(), F.text("label", "Label") },
				new = function()
					return { icon = "medal", label = "Badge" }
				end,
			}),
		},
		new = function()
			return { kind = "Profile", title = "Profile", stats = { { label = "Level", icon = "level", value = "12" } }, badges = { { icon = "medal", label = "Veteran" } } }
		end,
	},
	{
		kind = "Party",
		label = "Party",
		icon = "two-people",
		blurb = "Party / friend invites",
		fields = { F.number("slots", "Slots", { min = 2, max = 8, integer = true }) },
		new = function()
			return { kind = "Party", title = "Party", slots = 4 }
		end,
	},
	{
		kind = "Keybinds",
		label = "Keybinds",
		icon = "keyboard",
		blurb = "Rebindable controls list",
		fields = {
			F.list("binds", "Binds", {
				titleKeys = { "action" },
				fields = { F.text("action", "Action"), F.key("key", "Key"), F.key("gamepad", "Gamepad") },
				new = function()
					return { action = "Action", key = "E", gamepad = "ButtonX" }
				end,
			}),
		},
		new = function()
			return { kind = "Keybinds", title = "Controls", binds = { { action = "Sprint", key = "LeftShift", gamepad = "ButtonL3" }, { action = "Interact", key = "E", gamepad = "ButtonX" } } }
		end,
	},
	-- v1.6: retention and monetisation (UI.Server rolls, times and pays these)
	{
		kind = "Spin",
		label = "Prize wheel",
		icon = "chart-pie",
		blurb = "Spin wheel: a free spin on a timer, paid spins; the server rolls",
		fields = {
			F.number("freeHours", "Free spin every", { min = 0, max = 168, hint = "Hours between free spins (0 = none). The server keeps the timer in the saved profile." }),
			F.number("price", "Extra spin price", { min = 0, hint = "0 = free spins only. Wedges paying this same currency must return under 80% of it on average (x2 luck counted), or paid spins are turned off." }),
			F.currency("currency", "Paid with", true),
			F.number("productId", "Product id", { min = 0, integer = true, hint = "A Robux spin needs a developer product; the server rolls it once per receipt." }),
			F.number("luckGamePassId", "2x Luck pass id", { min = 0, integer = true, hint = "A game pass that doubles the odds of Epic and better." }),
			F.number("luckPrice", "2x Luck price", { min = 0, integer = true, hint = "Robux shown on the Luck button until the pass id is set." }),
			F.list("wedges", "Wedges", {
				titleKeys = { "label", "amount" },
				fields = {
					F.text("label", "Label"),
					F.icon(),
					F.text("amount", "Amount", { hint = "Text on the wedge, e.g. 500 or x2" }),
					F.rarity(),
					F.tone(),
					F.number("chance", "Chance %", { min = 0, max = 100, hint = "The wheel is scaled to add up to 100. Keep one jackpot at 0.5-3%." }),
					grant(),
				},
				new = function(n)
					return { id = `wedge{n}`, icon = "coin", amount = "100", rarity = "Common", chance = 10, grant = { currency = "coins", amount = 100 } }
				end,
			}),
		},
		new = function()
			local plan = { { 100, "Common", 28 }, { 50, "Uncommon", 9, true }, { 250, "Common", 24 }, { 1000, "Rare", 12 }, { 500, "Uncommon", 18 }, { 250, "Epic", 6.5, true }, { 2500, "Rare", 2 }, { 1000, "Legendary", 0.5, true } }
			local wedges = {}
			for i, p in plan do
				local currency = if p[4] then "gems" else "coins"
				table.insert(wedges, { id = `wedge{i}`, icon = if p[4] then "gem" else "coin", amount = tostring(p[1]), rarity = p[2], chance = p[3], grant = { currency = currency, amount = p[1] } })
			end
			-- (75 gems: its gem wedges pay back well under the price, even with x2 luck)
			return { kind = "Spin", title = "Lucky Wheel", freeHours = 12, price = 75, currency = "gems", wedges = wedges }
		end,
	},
	{
		kind = "Gifts",
		label = "Playtime gifts",
		icon = "gift-box",
		blurb = "Gift boxes that open after minutes of play",
		fields = {
			F.list("gifts", "Gifts", {
				titleKeys = { "label", "amount" },
				fields = {
					F.number("minutes", "After (min)", { min = 0.5, max = 720, hint = "Minutes played this session. The server checks the time before paying." }),
					F.icon(),
					F.text("amount", "Amount"),
					F.text("label", "Label"),
					F.rarity(),
					grant(),
				},
				new = function(n)
					return { id = `gift{n}`, minutes = n * 5, icon = "coin", amount = tostring(n * 250), rarity = "Common", grant = { currency = "coins", amount = n * 250 } }
				end,
			}),
		},
		new = function()
			local gifts = {}
			for i, minutes in { 1, 3, 5, 10, 15, 20, 30, 45 } do
				local gems = minutes >= 30
				local amount = if gems then minutes else minutes * 60
				table.insert(gifts, {
					id = `gift{i}`,
					minutes = minutes,
					icon = if gems then "gem" else "coin",
					amount = tostring(amount),
					rarity = if minutes >= 30 then "Epic" elseif minutes >= 10 then "Rare" else "Common",
					grant = { currency = if gems then "gems" else "coins", amount = amount },
				})
			end
			return { kind = "Gifts", title = "Free Gifts", gifts = gifts }
		end,
	},
	{
		kind = "Index",
		label = "Index",
		icon = "book-open",
		blurb = "Collection book: found items, ??? silhouettes, milestones",
		fields = {
			F.text("groupBy", "Grouped by", { placeholder = "Rarity" }),
			F.list("groups", "Groups", {
				titleKeys = { "name" },
				fields = {
					F.text("name", "Name"),
					F.icon(),
					F.tone(),
					F.list("items", "Items", {
						titleKeys = { "name" },
						fields = { F.text("name", "Name"), F.icon(), F.rarity(), F.bool("owned", "Found (preview)") },
						new = function(n)
							return { id = `item{n}`, name = "Item", icon = "star", rarity = "Common" }
						end,
					}),
				},
				new = function(n)
					return { id = `group{n}`, name = "Group", icon = "star", items = { { id = `group{n}_item1`, name = "Item", icon = "star", rarity = "Common" } } }
				end,
			}),
			F.list("milestones", "Milestones", {
				titleKeys = { "label", "percent" },
				fields = {
					F.number("percent", "At %", { min = 1, max = 100, integer = true, hint = "Claimable once this share is found; the server counts (Server.setIndexOwned)." }),
					F.icon(),
					F.text("amount", "Amount"),
					F.text("label", "Label"),
					grant(),
				},
				new = function()
					return { percent = 100, icon = "gem", amount = "500", grant = { currency = "gems", amount = 500 } }
				end,
			}),
		},
		new = function()
			return {
				kind = "Index",
				title = "Pet Index",
				groupBy = "Rarity",
				groups = {
					{ id = "common", name = "Common", icon = "star", items = { { id = "doggy", name = "Doggy", icon = "pet", rarity = "Common", owned = true }, { id = "kitty", name = "Kitty", icon = "pet", rarity = "Common", owned = true }, { id = "bunny", name = "Bunny", icon = "pet", rarity = "Common" } } },
					{ id = "legendary", name = "Legendary", icon = "crown", items = { { id = "dragon", name = "Dragon", icon = "fire", rarity = "Legendary" } } },
				},
			}
		end,
	},
	{
		kind = "Achievements",
		label = "Achievements",
		icon = "trophy",
		blurb = "Badge tiles with progress, points and rewards",
		fields = {
			F.list("achievements", "Achievements", {
				titleKeys = { "title" },
				fields = {
					F.text("id", "Id", { hint = "Progress comes from the player attribute BloxUI_Ach_<id>, set by your server scripts." }),
					F.text("title", "Title"),
					F.text("description", "Description"),
					F.icon(),
					F.number("goal", "Goal", { min = 1, integer = true }),
					F.number("progress", "Preview progress", { min = 0, integer = true }),
					F.number("points", "Points", { min = 0, integer = true }),
					prize("reward", "Reward"),
				},
				new = function(n)
					return { id = `ach{n}`, title = "Achievement", description = "Do the thing 10 times", icon = "trophy", goal = 10, progress = 0, points = 10, reward = { icon = "gem", amount = "10", grant = { currency = "gems", amount = 10 } } }
				end,
			}),
		},
		new = function()
			return {
				kind = "Achievements",
				title = "Achievements",
				achievements = {
					{ id = "first_steps", title = "First Steps", description = "Collect 1,000 coins", icon = "coin", goal = 1000, progress = 1000, points = 10, reward = { icon = "gem", amount = "5", grant = { currency = "gems", amount = 5 } } },
					{ id = "collector", title = "Collector", description = "Find 25 pets", icon = "pet", goal = 25, progress = 9, points = 25, reward = { icon = "gem", amount = "25", grant = { currency = "gems", amount = 25 } } },
					{ id = "legend", title = "Legend", description = "Hatch a Legendary", icon = "crown", goal = 1, progress = 0, points = 100, reward = { icon = "gem", amount = "100", grant = { currency = "gems", amount = 100 } } },
				},
			}
		end,
	},
	{
		kind = "Social",
		label = "Social rewards",
		icon = "three-people",
		blurb = "Group reward, like thank-you, invite a friend",
		fields = {
			F.number("groupId", "Group id", { min = 0, integer = true, hint = "Your Roblox group. The server checks IsInGroup before paying." }),
			F.list("rewards", "Rewards", {
				titleKeys = { "label" },
				fields = {
					F.enum("kind", "Kind", {
						{ value = "Group", label = "Group", hint = "Checked on the server with IsInGroup" },
						{ value = "Like", label = "Like", hint = "Can't be checked: a small one-time thank-you after the wait" },
						{ value = "Invite", label = "Invite", hint = "Paid when a friend joins through the player's invite" },
					}),
					F.text("label", "Label"),
					F.icon(),
					F.number("wait", "Like wait (s)", { min = 0, max = 3600, integer = true, hint = "Like only: seconds of play before the thank-you unlocks." }),
					F.number("limit", "Invite limit", { min = 1, max = 100, integer = true, hint = "Invite only: friends paid for at most." }),
					prize("reward", "Reward"),
				},
				new = function()
					return { kind = "Like", label = "Like the Game", icon = "thumb-up", wait = 60, reward = { icon = "gem", amount = "10", grant = { currency = "gems", amount = 10 } } }
				end,
			}),
		},
		new = function()
			return {
				kind = "Social",
				title = "Social",
				rewards = {
					{ id = "group", kind = "Group", label = "Join the Group", icon = "three-people", reward = { icon = "coin", amount = "1,000", grant = { currency = "coins", amount = 1000 } } },
					{ id = "like", kind = "Like", label = "Like the Game", icon = "thumb-up", wait = 60, reward = { icon = "gem", amount = "10", grant = { currency = "gems", amount = 10 } } },
					{ id = "invite", kind = "Invite", label = "Invite a Friend", icon = "person-plus", limit = 10, reward = { icon = "gem", amount = "25", grant = { currency = "gems", amount = 25 } } },
				},
			}
		end,
	},
}

--------------------------------------------------------------------------
-- Screens
--------------------------------------------------------------------------

Catalog.screenKinds = {
	{ value = "Window", label = "Window", icon = "windows-app-restore", blurb = "Centered window (shop, inventory...)" },
	{ value = "Fullscreen", label = "Fullscreen", icon = "windows-app-fullscreen", blurb = "Covers the whole screen" },
	{ value = "Popup", label = "Popup", icon = "square-small", blurb = "Small dialog" },
	{ value = "MainMenu", label = "Main menu", icon = "house", blurb = "Logo + big buttons" },
	{ value = "Loading", label = "Loading", icon = "clock-dashed", blurb = "Logo, progress bar and tips" },
	{ value = "Results", label = "Results", icon = "trophy", blurb = "End-of-match victory / defeat" },
	{ value = "Trade", label = "Trade", icon = "two-arrows-left-right", blurb = "Two-sided trade window" },
	-- v1.6
	{ value = "Offer", label = "Offer", icon = "tag-sparkle", blurb = "Limited offer / starter pack that pops up" },
	{ value = "OfflineEarnings", label = "Offline earnings", icon = "moon-with-stars", blurb = "Welcome back: earned while away" },
}

Catalog.screenFields = {
	F.text("title", "Title"),
	F.icon(),
	F.enum("kind", "Kind", { "Window", "Fullscreen", "Popup", "MainMenu", "Loading", "Results", "Trade", "Offer", "OfflineEarnings" }),
	F.enum("size", "Size", { "Small", "Medium", "Large" }),
	F.enum("nav", "Navigation", { "None", "Tabs", "SideTabs" }),
	F.key("openKey", "Open key"),
	F.text("description", "Description"),
}

-- A banner position the menu layout can use (else nil = the layout's first).
local function validBanner(layout: string?, position: any): string?
	for _, option in Catalog.bannerOptions(layout) do
		if option.value == position then
			return position
		end
	end
	return nil
end
Catalog.validBanner = validBanner

-- Extra fields that only some screen kinds use.
Catalog.screenKindFields = {
	MainMenu = {
		F.enum("layout", "Menu layout", { Catalog.menuLayouts[2], Catalog.menuLayouts[3], Catalog.menuLayouts[4] }, {
			default = "Left",
			defaultHint = Catalog.menuLayouts[1].hint,
			hint = "The first Buttons button is always the big PLAY button.",
			after = function(screen)
				-- keep the banner somewhere this layout can put it
				local position = screen.bannerPosition or screen.banner
				screen.bannerPosition = validBanner(screen.layout, position)
				screen.banner = nil
			end,
		}),
		F.enum("bannerPosition", "Banner", function(screen)
			return Catalog.bannerOptions(screen.layout)
		end, {
			default = "Auto",
			defaultHint = "The layout's first choice",
			after = function(screen)
				screen.banner = nil -- the alias would fight the new value
			end,
		}),
	},
	Window = WINDOW_CHROME,
	Fullscreen = WINDOW_CHROME,
	Popup = WINDOW_CHROME,
	Loading = { F.strings("tips", "Tips", "Tip") },
	Results = {
		F.enum("result", "Result", { "Victory", "Defeat", "Draw" }),
		F.list("stats", "Stats", {
			titleKeys = { "label" },
			fields = { F.text("label", "Label"), F.text("value", "Value") },
			new = function()
				return { label = "Kills", value = "12" }
			end,
		}),
		F.list("rewards", "Rewards", {
			titleKeys = { "amount" },
			fields = { F.icon(), F.number("amount", "Amount", { min = 0 }) },
			new = function()
				return { icon = "coin", amount = 100 }
			end,
		}),
		F.list("buttons", "Buttons", BUTTON_ITEM),
	},
	Trade = { F.currency("currency", "Currency") },
	-- v1.6: sold through the usual Purchase / GamePass prompts and UI.Server's receipts
	Offer = {
		F.text("subtitle", "Subtitle"),
		F.tone(),
		F.text("tag", "Ribbon", { placeholder = "STARTER PACK" }),
		F.number("productId", "Product id", { min = 0, integer = true, hint = "A developer product: UI.Server grants the contents after the receipt." }),
		F.number("gamePassId", "Game pass id", { min = 0, integer = true, hint = "Or a game pass (contents can't grant currency then)." }),
		F.number("price", "Price (R$)", { min = 0, integer = true }),
		F.number("was", "Worth (R$)", { min = 0, integer = true, hint = "The honest value, struck through. Must be above the price." }),
		F.number("timer", "Timer (s)", { min = 0, integer = true, hint = "Counts down from the first time a player sees it. 0 = none." }),
		F.enum("show", "Pops up", {
			{ value = "Session", label = "Every session", hint = "Once per session, a few seconds after joining" },
			{ value = "Once", label = "Once ever", hint = "Only on the first join (the server remembers)" },
			{ value = "Joins", label = "Every few joins", hint = "Every N joins (set below)" },
			{ value = "Manual", label = "Never by itself", hint = "Only when a button opens it" },
		}, { hint = "Never shown to players who own it." }),
		F.number("every", "Every N joins", { min = 2, max = 100, integer = true }),
		F.number("delay", "Delay (s)", { min = 0, max = 120 }),
		F.list("items", "Contents", {
			titleKeys = { "label", "amount" },
			fields = { F.text("label", "Label"), F.icon(), F.text("amount", "Amount"), grant() },
			new = function()
				return { label = "Gems", icon = "gem", amount = "500", grant = { currency = "gems", amount = 500 } }
			end,
		}),
	},
	OfflineEarnings = {
		F.currency("currency", "Currency"),
		F.number("rate", "Per minute", { min = 0, hint = "Earned per minute away; the server works it out from the last visit." }),
		F.number("capHours", "Cap (hours)", { min = 0.25, max = 72, hint = "Counted up to this many hours, so playing always pays more." }),
		F.number("minMinutes", "Min away (min)", { min = 1, max = 1440 }),
		F.number("doubleProductId", "x2 product id", { min = 0, integer = true, hint = "A developer product that doubles the amount." }),
		F.number("doublePrice", "x2 price (R$)", { min = 0, integer = true }),
	},
}

Catalog.tabFields = {
	F.text("label", "Label"),
	F.icon(),
	F.text("badge", "Badge"),
	F.text("callout", "Callout"),
}

local function uniqueId(base: string, taken: { [string]: boolean }): string
	local id = base
	local n = 2
	while taken[id] do
		id = `{base}{n}`
		n += 1
	end
	return id
end
Catalog.uniqueId = uniqueId

function Catalog.newScreen(kind: string, blueprint: any): any
	local taken = {}
	for _, screen in (blueprint and blueprint.screens) or {} do
		taken[screen.id] = true
	end
	local base = string.lower(kind)
	if kind == "Window" then
		base = "window"
	elseif kind == "MainMenu" then
		base = "menu"
	end
	local id = uniqueId(base, taken)
	local screen: any = { id = id, title = kind, icon = "star", kind = kind, size = "Medium", nav = "None", sections = {} }
	if kind == "Window" then
		screen.title = "Shop"
		screen.icon = "shop"
		screen.nav = "Tabs"
		screen.tabs = {
			{ id = "featured", label = "Featured", icon = "star", sections = { (Catalog.sectionByKind.Products :: any).new() } },
		}
	elseif kind == "Fullscreen" then
		screen.title = "Inventory"
		screen.icon = "backpack"
		screen.size = "Large"
		screen.sections = { (Catalog.sectionByKind.Inventory :: any).new() }
	elseif kind == "Popup" then
		screen.title = "Daily Rewards"
		screen.icon = "daily"
		screen.size = "Small"
		screen.sections = { (Catalog.sectionByKind.Daily :: any).new() }
	elseif kind == "MainMenu" then
		screen.title = "Main Menu"
		screen.icon = "house"
		screen.size = "Large"
		screen.sections = {
			{
				kind = "Buttons",
				layout = "Column",
				buttons = {
					{ id = "play", label = "Play", icon = "play", tone = "Green", action = { kind = "Close", target = id } },
					{ id = "settings", label = "Settings", icon = "settings", tone = "Gray", action = { kind = "None" } },
				},
			},
		}
	elseif kind == "Loading" then
		screen.title = "Loading"
		screen.icon = "clock-dashed"
		screen.tips = { "Tip: press B to open the shop!", "Invite friends for a bonus!" }
	elseif kind == "Results" then
		screen.title = "Results"
		screen.icon = "trophy"
		screen.result = "Victory"
		screen.stats = { { label = "Kills", value = "12" }, { label = "Damage", value = "2,480" } }
		screen.rewards = { { icon = "coin", amount = 250 } }
		screen.buttons = { { id = "again", label = "Play again", icon = "play", tone = "Green", action = { kind = "Close", target = id } } }
	elseif kind == "Trade" then
		screen.title = "Trade"
		screen.icon = "two-arrows-left-right"
		screen.size = "Large"
	elseif kind == "Offer" then
		screen.title = "Starter Pack"
		screen.icon = "tag-sparkle"
		screen.subtitle = "A head start, once per player"
		screen.tag = "STARTER PACK"
		screen.items = {
			{ label = "Gems", icon = "gem", amount = "500" },
			{ label = "Coins", icon = "coin", amount = "25,000" },
			{ label = "2x Luck (30m)", icon = "sparkle-arrow-up", amount = "1" },
		}
		screen.price = 99
		screen.was = 299
		screen.timer = 86400
		screen.show = "Once"
	elseif kind == "OfflineEarnings" then
		local currency = blueprint and blueprint.currencies and blueprint.currencies[1]
		screen.title = "Welcome Back!"
		screen.icon = "moon-with-stars"
		screen.currency = if typeof(currency) == "table" then currency.id else nil
		screen.rate = 10
		screen.capHours = 8
	end
	return screen
end

function Catalog.newTab(screen: any): any
	local taken = {}
	for _, tab in screen.tabs or {} do
		taken[tab.id] = true
	end
	local id = uniqueId("tab", taken)
	return { id = id, label = "New Tab", icon = "star", sections = {} }
end

-- v1.8: every element with looks gets a "Look" dropdown (first field)
Catalog.addLookFields()

-- lookups
Catalog.hudByType = {}
for _, entry in Catalog.hud do
	Catalog.hudByType[entry.type] = entry
end
Catalog.sectionByKind = {}
for _, entry in Catalog.sections do
	Catalog.sectionByKind[entry.kind] = entry
end
Catalog.screenKindByValue = {}
for _, entry in Catalog.screenKinds do
	Catalog.screenKindByValue[entry.value] = entry
end

-- A readable title for any item table, using the schema's titleKeys.
function Catalog.itemTitle(item: any, titleKeys: { string }?, fallback: string?): string
	if typeof(item) ~= "table" then
		return tostring(item)
	end
	for _, key in titleKeys or { "label", "title", "name", "text" } do
		local value = item[key]
		if value ~= nil and value ~= "" then
			if key == "tier" or key == "day" or key == "amount" then
				return `{fallback or key} {value}`
			end
			return tostring(value)
		end
	end
	return fallback or "Item"
end

return Catalog
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="5">
        <Properties>
          <string name="Name">Changelog</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Changelog: what changed in each plugin release, newest first. The "What's
	new" dialog (WhatsNew.luau) shows these after an update.

	Before shipping a version, add its entry at the top: a short title and a
	few plain-words bullets a game developer understands (no internal names).
	The plugin tests fail when the newest entry isn't version.json's plugin
	version, so a release can't go out without notes.

		Changelog.entries                  { { version, title, notes = { "..." } }, ... } newest first
		Changelog.entry("2.3.0")           -- one release (nil when it has no notes)
		Changelog.between("2.2.2", "2.3.0")  -- releases after 2.2.2, up to 2.3.0 (newest first)
		Changelog.latest(3)                -- the newest three
		Changelog.compare(a, b)            -- -1 / 0 / 1 ("2.10.0" is newer than "2.9.0")
]]

local Changelog = {}

Changelog.entries = {
	{
		version = "2.4.0",
		title = "ChatGPT support and a big design upgrade",
		notes = {
			"AI mode runs on your own OpenAI (ChatGPT) or Claude key. Pick it in Settings > Your AI key.",
			"14 design styles with their own fonts, shadows and outlines. Pick one on the Design tab.",
			"New shop and reward layouts, side-docked shops, and items that match your prompt.",
			"Clearer text, and Save & test now works when you click it.",
		},
	},
	{
		version = "2.3.0",
		title = "Layouts and a cleaner Create tab",
		notes = {
			"Pick a HUD layout before you generate. New: Simulator / Tycoon, with the cash at the bottom, packs and boosts on the right, teleport buttons on top and the menu on the left. Also Shooter / Battlegrounds, Minimal, Dock and more.",
			"Switch the layout of a finished design from Structure or the result card, and everything moves into place.",
			"A simpler Create tab: your prompt, a layout and Generate. Mode, quality, reference screenshots and the design brief are under More options.",
			"Teleport buttons fire a Teleport event your game script can handle, and Export as code writes the stub for it.",
		},
	},
	{
		version = "2.2.2",
		title = "A roomier prompt box",
		notes = {
			"The prompt box grows as you type, up to 2,000 characters.",
		},
	},
	{
		version = "2.2.1",
		title = "Easier sign-in",
		notes = {
			"Activate works when you click the button, not just when you press Enter.",
		},
	},
	{
		version = "2.2.0",
		title = "Paid Trial and Premium keys",
		notes = {
			"Sign in with a Paid Trial or Premium licence key.",
			"Settings > Account shows your trial status and an upgrade link.",
		},
	},
}

-- "2.10.3" -> { 2, 10, 3 }; nil unless it's three numbers.
local function parse(text: any): { number }?
	if typeof(text) ~= "string" then
		return nil
	end
	local a, b, c = string.match(text, "^(%d+)%.(%d+)%.(%d+)$")
	if a == nil then
		return nil
	end
	return { tonumber(a) :: number, tonumber(b) :: number, tonumber(c) :: number }
end

function Changelog.isVersion(text: any): boolean
	return parse(text) ~= nil
end

-- -1 when a is older than b, 1 when newer, 0 when equal (or either isn't a version).
function Changelog.compare(a: any, b: any): number
	local pa, pb = parse(a), parse(b)
	if pa == nil or pb == nil then
		return 0
	end
	for i = 1, 3 do
		if pa[i] ~= pb[i] then
			return if pa[i] < pb[i] then -1 else 1
		end
	end
	return 0
end

function Changelog.entry(version: string?): any
	for _, entry in Changelog.entries do
		if entry.version == version then
			return entry
		end
	end
	return nil
end

-- Releases newer than `after`, up to and including `upTo` (newest first).
function Changelog.between(after: string?, upTo: string?): { any }
	local out = {}
	for _, entry in Changelog.entries do
		local newer = after == nil or Changelog.compare(entry.version, after) > 0
		local released = upTo == nil or Changelog.compare(entry.version, upTo) <= 0
		if newer and released then
			table.insert(out, entry)
		end
	end
	return out
end

function Changelog.latest(count: number?): { any }
	local out = {}
	for index, entry in Changelog.entries do
		if index > (count or 3) then
			break
		end
		table.insert(out, entry)
	end
	return out
end

return Changelog
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="6">
        <Properties>
          <string name="Name">Cloud</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Cloud: the UI Builder's link to the BloxUI server, as Fusion state (the
	HTTP side is Remote.luau).

		local cloud = Cloud.new(scope, { plugin = plugin, version = Version.plugin })
		cloud:start()               -- GET /v1/status now and every 30 min; GET /v1/me when signed in

		cloud.signedIn              -- true while a token is saved
		cloud.account               -- the Account (name, plan, quota, devices, trial), nil until /v1/me answers
		cloud.premium               -- a Premium key: everything unlocked
		cloud.trial                 -- a Paid Trial key (cloud.trialEnded once its days are over); cloud:upgradeUrl()
		cloud.free                  -- a legacy free-plan session (the server no longer hands these out)
		cloud.status                -- the last /v1/status, nil until it answers
		cloud.reachable             -- true / false / nil (not checked yet)
		cloud.update                -- { available, required, latest, min, url, notice, current }
		cloud.device                -- "Sign in with Roblox" in progress:
		                            -- { state = "starting" | "waiting" | "denied" | "expired" | "error", userCode, verifyUrl, message }
		cloud.busy                  -- nil | "key" | "device" | "signout" | "ownkey"
		cloud.message               -- { tone, text } for the Account card (nil = nothing to say)
		cloud.provider              -- whose own key AI mode uses: "anthropic" | "openai" (remembered)
		cloud.ownKeys               -- { anthropic, openai }: each saved key masked ("sk-ant-…WXYZ"), "" = none
		cloud.ownKey                -- the chosen provider's masked key ("" = none)
		cloud.aiProvider            -- who runs AI mode now: "openai" (ChatGPT, the user's key) | "anthropic"

		cloud:activateKey(key)      cloud:startDeviceLogin()   cloud:cancelDeviceLogin()
		cloud:signOut()             cloud:refreshStatus()      cloud:refreshAccount()
		cloud:setProvider(p)        cloud:saveOwnKey(key, model, done?, provider?)   cloud:removeOwnKey(provider?)
		cloud:readiness("AI")       -- true | false, reason: "update_required" | "signed_out" | "offline" | "ai_unavailable" | "offline_unavailable"

	The account (name, plan, quota) is cached in plugin settings so the card
	shows it at once; the token is Remote's (plugin settings only, never the
	place, never printed). A licence key is only held for the one request.
	Own AI keys (Claude or ChatGPT) live in plugin settings too, one per
	provider; only their masked form is ever state or text.
]]

local HttpService = game:GetService("HttpService")

local Deps = require(script.Parent.Deps)
local Remote = require(script.Parent.Remote)

local Fusion = Deps.Fusion
local peek = Fusion.peek

local Cloud = {}
Cloud.__index = Cloud

Cloud.STATUS_EVERY = 30 * 60 -- seconds between /v1/status checks
Cloud.ACCOUNT_SETTING = "BloxUI_Account"

function Cloud.new(scope: any, options: { [string]: any }): any
	local self = setmetatable({}, Cloud)
	self.scope = scope
	self.plugin = options.plugin
	self.version = tostring(options.version or "0.0.0")
	self.alive = true
	self.remote = options.remote
		or Remote.new({ plugin = options.plugin, version = self.version, http = options.http })
	self.remote.onSignedOut = function()
		self:signedOutByServer()
	end
	self.remote.onUpdateRequired = function()
		self.updateRequired:set(true)
	end

	self.hasToken = scope:Value(self.remote:hasToken())
	self.signedIn = self.hasToken
	self.account = scope:Value(if peek(self.hasToken) then self:cachedAccount() else nil)
	self.status = scope:Value(nil :: any)
	self.reachable = scope:Value(nil :: boolean?)
	self.checking = scope:Value(false)
	self.updateRequired = scope:Value(false)
	self.busy = scope:Value(nil :: string?)
	self.message = scope:Value(nil :: any)
	self.device = scope:Value(nil :: any)
	self.serverUrl = scope:Value(self.remote:baseUrl())
	self.deviceSerial = 0
	-- the free plan (a session per install, no licence; being retired): generations and AI counted per day
	self.free = scope:Computed(function(use)
		local info = use(self.account)
		return use(self.hasToken) and info ~= nil and info.free == true
	end)
	-- a Paid Trial key: a few generations a day for 7 days, no Premium features; upgradable any time
	self.trial = scope:Computed(function(use)
		local info = use(self.account)
		return use(self.hasToken) and info ~= nil and (info.trial ~= nil or info.plan == "trial")
	end)
	self.trialEnded = scope:Computed(function(use)
		local info = use(self.account)
		return use(self.trial) and info.trial ~= nil and info.trial.ended == true
	end)
	-- a Premium licence key (or an allowlisted Roblox account): everything unlocked
	self.premium = scope:Computed(function(use)
		return use(self.hasToken) and not use(self.free) and not use(self.trial)
	end)
	-- the user's own AI keys (Settings > Generation), one per provider, masked ("sk-ant-…WXYZ",
	-- "sk-proj-…WXYZ"); cloud.provider is whose key AI mode uses (remembered), cloud.ownKey the
	-- masked key of that provider ("" when none is saved)
	self.ownKeys = scope:Value({
		anthropic = Remote.maskKey(self.remote:ownKey("anthropic")),
		openai = Remote.maskKey(self.remote:ownKey("openai")),
	})
	self.provider = scope:Value(self.remote:provider())
	self.ownKey = scope:Computed(function(use)
		return use(self.ownKeys)[use(self.provider)] or ""
	end)
	self.ownKeyMessage = scope:Value(nil :: any)
	-- whose own key AI jobs carry (as Remote:activeKey: the chosen provider's, else the other saved
	-- one); nil = none, AI runs on BloxUI's own Claude
	self.keyProvider = scope:Computed(function(use)
		local keys = use(self.ownKeys)
		local chosen = use(self.provider)
		for _, provider in { chosen, if chosen == "openai" then "anthropic" else "openai" } do
			if (keys[provider] or "") ~= "" then
				return provider
			end
		end
		return nil
	end)
	-- ...and whether the server takes it right now (own keys on, that provider accepted)
	self.ownKeyInUse = scope:Computed(function(use)
		local provider = use(self.keyProvider)
		return if provider and self:acceptsOwnKey(provider, use(self.status)) then provider else nil
	end)
	-- who runs AI mode: "openai" (ChatGPT on the user's OpenAI key) or "anthropic" (Claude, on the
	-- user's key or BloxUI's own)
	self.aiProvider = scope:Computed(function(use)
		return if use(self.keyProvider) == "openai" then "openai" else "anthropic"
	end)

	self.update = scope:Computed(function(use)
		local status = use(self.status)
		local latest = status and status.latestPlugin
		local minimum = status and status.minPlugin
		local required = use(self.updateRequired)
			or (minimum ~= nil and Remote.compareVersions(self.version, minimum) < 0)
		return {
			current = self.version,
			latest = latest,
			min = minimum,
			required = required,
			available = required or (latest ~= nil and Remote.compareVersions(self.version, latest) < 0),
			url = status and status.updateUrl,
			notice = status and status.notice,
		}
	end)
	-- "AI today 3/40" ("Your key today 3/100" when AI runs on the user's own key, "Trial today 1/3",
	-- "Free today 1/3")
	self.quotaText = scope:Computed(function(use)
		local account = use(self.account)
		local quota = account and account.quota
		if use(self.trial) and quota and quota.generationsToday and quota.generationsPerDay then
			return `Trial today {quota.generationsToday}/{quota.generationsPerDay}`
		end
		if account and account.free and quota and quota.generationsToday and quota.generationsPerDay then
			return `Free today {quota.generationsToday}/{quota.generationsPerDay}`
		end
		if use(self.ownKeyInUse) ~= nil and quota and quota.ownKeyToday and quota.ownKeyPerDay then
			return `Your key today {quota.ownKeyToday}/{quota.ownKeyPerDay}`
		end
		if quota and quota.aiToday and quota.aiPerDay then
			return `AI today {quota.aiToday}/{quota.aiPerDay}`
		end
		return ""
	end)
	return self
end

--------------------------------------------------------------------------
-- settings (the account cache; the token itself is Remote's)
--------------------------------------------------------------------------

function Cloud:cachedAccount(): any
	local ok, raw = pcall(function()
		return self.plugin and self.plugin:GetSetting(Cloud.ACCOUNT_SETTING)
	end)
	if not ok or typeof(raw) ~= "string" or raw == "" then
		return nil
	end
	local decoded, data = pcall(function()
		return HttpService:JSONDecode(raw)
	end)
	return if decoded then Remote.account(data) else nil
end

function Cloud:saveAccount(account: any)
	if self.plugin == nil then
		return
	end
	local raw = nil
	if account ~= nil then
		local ok, json = pcall(function()
			return HttpService:JSONEncode(account)
		end)
		raw = if ok then json else nil
	end
	pcall(function()
		self.plugin:SetSetting(Cloud.ACCOUNT_SETTING, raw)
	end)
end

function Cloud:setAccount(account: any)
	self.account:set(account)
	self:saveAccount(account)
end

-- Where a Paid Trial is upgraded to Premium (the server's link, else BloxUI's own).
function Cloud:upgradeUrl(use: any?): string
	local info = if use then use(self.account) else peek(self.account)
	return (info and info.upgradeUrl) or Remote.UPGRADE_URL
end

--------------------------------------------------------------------------
-- status (update banner, reachability)
--------------------------------------------------------------------------

function Cloud:start()
	if self.started then
		return
	end
	self.started = true
	self:refreshStatus()
	if peek(self.hasToken) then
		self:refreshAccount()
	else
		self:startFree()
	end
	local function tick()
		if not self.alive then
			return
		end
		self:refreshStatus()
		self.statusThread = task.delay(Cloud.STATUS_EVERY, tick)
	end
	self.statusThread = task.delay(Cloud.STATUS_EVERY, tick)
end

-- GET /v1/status in the background. `done(ok)` is for tests.
function Cloud:refreshStatus(done: ((boolean) -> ())?)
	if peek(self.checking) then
		if done then
			done(false)
		end
		return
	end
	self.checking:set(true)
	task.spawn(function()
		local ok, status = self.remote:status()
		if not self.alive then
			return
		end
		self.checking:set(false)
		if ok then
			self.status:set(status)
			self.reachable:set(true)
			-- the server decides: a lower minPlugin lifts an earlier 426
			if status.minPlugin == nil or Remote.compareVersions(self.version, status.minPlugin) >= 0 then
				self.updateRequired:set(false)
			end
		else
			-- http_blocked, network, timeout, 5xx: generation can't work right now
			self.reachable:set(false)
			self.lastError = status
		end
		if done then
			done(ok)
		end
	end)
end

-- GET /v1/me in the background (keeps the cached account when offline).
function Cloud:refreshAccount(done: ((boolean) -> ())?)
	if not self.remote:hasToken() then
		self.hasToken:set(false)
		self:setAccount(nil)
		if done then
			done(false)
		end
		return
	end
	task.spawn(function()
		local ok, account = self.remote:me()
		if not self.alive then
			return
		end
		if ok then
			self:setAccount(account)
			self.reachable:set(true)
		elseif account.code ~= "unauthorized" and (account.code == "network" or account.code == "timeout" or account.code == "http_blocked") then
			self.reachable:set(false)
		end
		if done then
			done(ok)
		end
	end)
end

--[[
	Can the builder generate right now? `mode` is the builder's "AI" /
	"Offline". Unknown reachability (not checked yet) counts as fine: the
	job itself will say.
]]
function Cloud:readiness(mode: string?): (boolean, string?)
	if peek(self.update).required then
		return false, "update_required"
	end
	if not peek(self.hasToken) then
		return false, "signed_out"
	end
	if peek(self.reachable) == false then
		return false, "offline"
	end
	local status = peek(self.status)
	if status and status.modes then
		if mode == "AI" and status.modes.ai == false and not self:ownKeyReady() then
			return false, "ai_unavailable"
		elseif mode ~= "AI" and status.modes.offline == false then
			return false, "offline_unavailable"
		end
	end
	return true, nil
end

-- Sentences for readiness reasons.
Cloud.REASONS = {
	update_required = "Update the BloxUI plugin to keep generating.",
	signed_out = "Paste your BloxUI licence key in Settings > Account to generate. Editing, preview, Insert and Export work without one.",
	offline = "Can't reach the BloxUI server right now. You can still edit, preview, insert and export.",
	ai_unavailable = "AI mode is off on the server right now. Add your own Claude (Anthropic) or ChatGPT (OpenAI) key in Settings to use it, or use Instant mode.",
	offline_unavailable = "Instant mode is paused on the server right now. Try Claude mode.",
}

--------------------------------------------------------------------------
-- sign in / out
--------------------------------------------------------------------------

function Cloud:onSignedIn(account: any, how: string)
	self.hasToken:set(true)
	self:setAccount(account)
	self.reachable:set(true)
	local name = account and account.name or "your account"
	self.message:set({ tone = "success", text = `Signed in as {name}{how}.` })
end

-- Does the server (its last /v1/status) take own keys of `provider`? Unknown counts as yes: the job
-- itself will say. A server from before OpenAI keys lists no providers: Anthropic only.
function Cloud:acceptsOwnKey(provider: string, status: any): boolean
	if status == nil or status.modes == nil then
		return true
	end
	if status.modes.ownKey ~= true then
		return false
	end
	local providers = status.modes.ownKeyProviders
	if providers == nil then
		return provider == "anthropic"
	end
	return table.find(providers, provider) ~= nil
end

-- AI can run on the user's own key: one is saved and the server accepts it.
function Cloud:ownKeyReady(): boolean
	return peek(self.ownKeyInUse) ~= nil
end

-- Whose key AI mode uses ("anthropic" | "openai"), remembered on this computer.
function Cloud:setProvider(provider: string?)
	provider = Remote.providerOf(provider)
	if peek(self.provider) == provider then
		return
	end
	self.remote:setProvider(provider)
	self.provider:set(provider)
	self.ownKeyMessage:set(nil)
end

-- Checks the key with the server (free: no generation) and saves it on this computer only when it
-- works; its provider (default: the chosen one) becomes the one AI mode uses. The key never goes
-- into the place, the Output or a message.
function Cloud:saveOwnKey(key: string?, model: string?, done: ((boolean) -> ())?, provider: string?)
	provider = Remote.providerOf(provider or peek(self.provider))
	local info = Remote.PROVIDERS[provider :: string]
	key = Remote.cleanKey(key)
	if not Remote.isKey(key, provider) then
		local text = if provider == "openai"
			then (if string.match(key :: string, "^sk%-ant%-")
				then "That's an Anthropic key. Choose Claude (Anthropic) above to use it."
				else "That doesn't look like an OpenAI API key (they start with sk-, from platform.openai.com). A ChatGPT login can't be used here.")
			else "That doesn't look like an Anthropic API key (they start with sk-ant-)."
		self.ownKeyMessage:set({ tone = "warning", text = text })
		if done then
			done(false)
		end
		return
	end
	if provider == "openai" and not self:acceptsOwnKey("openai", peek(self.status)) then
		self.ownKeyMessage:set({ tone = "warning", text = "This BloxUI server doesn't take OpenAI keys yet. Use a Claude (Anthropic) key, or try again after the server updates." })
		if done then
			done(false)
		end
		return
	end
	if not peek(self.hasToken) then
		self.ownKeyMessage:set({ tone = "warning", text = "Sign in first, then add your key." })
		if done then
			done(false)
		end
		return
	end
	if peek(self.busy) ~= nil then
		if done then
			done(false)
		end
		return
	end
	self.busy:set("ownkey")
	self.ownKeyMessage:set({ tone = "busy", text = `Checking your key with {info.name}...` })
	task.spawn(function()
		local ok, result = self.remote:checkOwnKey(key :: string, model, provider)
		if not self.alive then
			return
		end
		self.busy:set(nil)
		if ok then
			self.remote:setOwnKey(key, provider)
			local masked = table.clone(peek(self.ownKeys))
			masked[provider :: string] = Remote.maskKey(key)
			self.ownKeys:set(masked)
			self.remote:setProvider(provider)
			self.provider:set(provider)
			self.ownKeyMessage:set({
				tone = "success",
				text = if provider == "openai"
					then "Key works. AI mode now runs on ChatGPT (OpenAI's GPT models) with your key (your OpenAI bill)."
					else "Key works. Claude mode now runs on your key (your Anthropic bill).",
			})
			self:refreshAccount()
		else
			local detail = if typeof(result) == "table" and typeof(result.detail) == "string" then result.detail else nil
			local fallback = if provider == "openai" and typeof(result) == "table" and result.code == "key_rejected"
				then "OpenAI rejected that key. Check it (and its billing) at platform.openai.com."
				else Remote.messageOf(result)
			self.ownKeyMessage:set({ tone = "error", text = detail or fallback })
		end
		key = nil
		if done then
			done(ok)
		end
	end)
end

-- Forgets the key of `provider` (default: the chosen one) on this computer.
function Cloud:removeOwnKey(provider: string?)
	provider = Remote.providerOf(provider or peek(self.provider))
	self.remote:setOwnKey(nil, provider)
	local masked = table.clone(peek(self.ownKeys))
	masked[provider :: string] = ""
	self.ownKeys:set(masked)
	self.ownKeyMessage:set({
		tone = "info",
		text = if provider == "openai"
			then "Your OpenAI key was removed from this computer. AI mode uses BloxUI's AI (Claude) again."
			else "Your key was removed from this computer. Claude mode uses BloxUI's AI again.",
	})
end

-- POST /v1/auth/key. The key is only held for this request.
function Cloud:activateKey(key: string?, done: ((boolean) -> ())?)
	if peek(self.busy) == "device" then
		self:cancelDeviceLogin() -- a key while a Roblox code is showing: the key wins
	end
	if peek(self.busy) ~= nil then
		if done then
			done(false)
		end
		return
	end
	self.busy:set("key")
	self.message:set({ tone = "busy", text = "Checking your licence key..." })
	task.spawn(function()
		local ok, result = self.remote:loginKey(key)
		key = nil
		if not self.alive then
			return
		end
		self.busy:set(nil)
		if ok then
			self:onSignedIn(result, " with your licence key")
		else
			self.message:set({ tone = "error", text = Remote.messageOf(result) })
			if result.code == "network" or result.code == "timeout" then
				self.reachable:set(false)
			end
		end
		if done then
			done(ok)
		end
	end)
end

-- "Sign in with Roblox": gets a code, shows it, polls until approved.
function Cloud:startDeviceLogin(done: ((boolean) -> ())?)
	if peek(self.busy) ~= nil and peek(self.busy) ~= "device" then
		return
	end
	self.deviceSerial += 1
	local serial = self.deviceSerial
	local function current(): boolean
		return self.alive and self.deviceSerial == serial
	end
	self.busy:set("device")
	self.message:set(nil)
	self.device:set({ state = "starting" })
	task.spawn(function()
		local ok, flow = self.remote:startDeviceLogin()
		if not current() then
			return
		end
		if not ok then
			self.busy:set(nil)
			self.device:set({ state = "error", message = Remote.messageOf(flow) })
			if flow.code == "network" or flow.code == "timeout" then
				self.reachable:set(false)
			end
			if done then
				done(false)
			end
			return
		end
		self.reachable:set(true)
		self.device:set({
			state = "waiting",
			userCode = flow.userCode,
			verifyUrl = flow.verifyUrl,
			expiresIn = flow.expiresIn,
			expiresAt = flow.expiresAt,
		})
		local signed, result = self.remote:waitForDevice(flow, {
			cancelled = function()
				return not current()
			end,
		})
		if not current() then
			return
		end
		self.busy:set(nil)
		if signed then
			self.device:set(nil)
			self:onSignedIn(result, " (Roblox)")
		elseif result.code ~= "cancelled" then
			self.device:set({
				state = if result.state == "expired" or result.code == "expired" then "expired" elseif result.state == "denied" then "denied" else "error",
				userCode = flow.userCode,
				verifyUrl = flow.verifyUrl,
				message = Remote.messageOf(result),
			})
		end
		if done then
			done(signed)
		end
	end)
end

function Cloud:cancelDeviceLogin()
	self.deviceSerial += 1
	self.device:set(nil)
	if peek(self.busy) == "device" then
		self.busy:set(nil)
	end
end

-- Revokes the token on the server (best effort) and forgets it here.
function Cloud:signOut(done: (() -> ())?)
	self:cancelDeviceLogin()
	local remote = self.remote
	self.hasToken:set(false)
	self:setAccount(nil)
	self.message:set({ tone = "info", text = "Signed out. Your designs, history and inserted UIs stay." })
	task.spawn(function()
		remote:logout()
		-- back to the free plan on this install
		self:startFree()
		if done then
			done()
		end
	end)
end

-- The free plan: a session for this install with no sign-in (POST /v1/auth/free). Quiet: when the
-- server has no free plan or can't be reached, the plugin simply stays signed out.
function Cloud:startFree(done: ((boolean) -> ())?)
	-- at most one attempt every 30 s (a server that keeps refusing never makes this spin)
	if self.freeStarting or peek(self.hasToken) or not self.alive or os.clock() - (self.freeTried or -math.huge) < 30 then
		if done then
			done(false)
		end
		return
	end
	self.freeStarting = true
	self.freeTried = os.clock()
	task.spawn(function()
		local ok, result = self.remote:loginFree()
		self.freeStarting = false
		if not self.alive then
			return
		end
		if ok and not peek(self.hasToken) then
			self.hasToken:set(true)
			self:setAccount(result)
		elseif not ok and typeof(result) == "table" and (result.code == "network" or result.code == "timeout") then
			self.reachable:set(false)
		end
		if done then
			done(ok)
		end
	end)
end

-- The server stopped accepting the token (expired / revoked elsewhere).
function Cloud:signedOutByServer()
	if not self.alive then
		return
	end
	local wasFree = peek(self.free)
	self.hasToken:set(false)
	self:setAccount(nil)
	if not wasFree then
		self.message:set({ tone = "warning", text = "Your session ended (signed out on the server). Paste your licence key again to keep generating." })
	end
	self:startFree()
end

-- Advanced: another server address ("" = the default). Changing it signs out.
function Cloud:setServerUrl(text: string?): (boolean, string?)
	local before = self.remote:baseUrl()
	local ok, problem = self.remote:setBaseUrl(text)
	if not ok then
		return false, problem
	end
	local after = self.remote:baseUrl()
	self.serverUrl:set(after)
	if after ~= before then
		if self.remote:hasToken() then
			self.remote:setToken(nil)
			self.hasToken:set(false)
			self:setAccount(nil)
		end
		self.status:set(nil)
		self.reachable:set(nil)
		self.updateRequired:set(false)
		self:refreshStatus()
	end
	return true, nil
end

function Cloud:destroy()
	self.alive = false
	self.deviceSerial += 1
	if self.statusThread then
		pcall(task.cancel, self.statusThread)
		self.statusThread = nil
	end
end

return Cloud
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="7">
        <Properties>
          <string name="Name">Deps</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Deps: shared references for the UI Builder modules.

	The plugin model looks like:
		BloxUI Installer (Folder)
			Main      (Script)       toolbar + installer
			Builder   (ModuleScript) this UI Builder
			Payload
				BloxUI  (ModuleScript) the public BloxUI runtime (Fusion, components, Blueprint,
				                      samples...); the generator lives on the BloxUI server

		local Deps = require(script.Parent.Deps)
		local scope = Deps.Fusion.scoped(Deps.Fusion)
]]

local root = script.Parent.Parent
local payload = root:FindFirstChild("Payload")
local bloxui = payload and payload:FindFirstChild("BloxUI")
assert(bloxui, "[BloxUI Builder] the plugin payload (Payload.BloxUI) is missing")

local packages = bloxui:FindFirstChild("Packages")
local fusionModule = packages and packages:FindFirstChild("Fusion")
assert(fusionModule, "[BloxUI Builder] Payload.BloxUI.Packages.Fusion is missing")

local Fusion = require(fusionModule)

-- Icons only depends on Assets, so it loads even when other parts of the
-- payload are mid-update. Fall back to a tiny stand-in if it can't load.
local Icons
do
	local iconsModule = bloxui:FindFirstChild("Icons")
	local ok, result = false, nil
	if iconsModule then
		ok, result = pcall(require, iconsModule)
	end
	if ok and typeof(result) == "table" then
		Icons = result
	else
		Icons = { alias = {}, names = {}, list = {} }
	end
end

return {
	root = root,
	payload = payload,
	BloxUI = bloxui,
	Fusion = Fusion,
	Icons = Icons,
}
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="8">
        <Properties>
          <string name="Name">Export</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Export: puts a blueprint into the place and manages saved blueprints.

		local ok, message, value, details = Export.insert(installer, store, { name = "NeonStrike", server = true })
		-- details = { value, bloxui, loader, server, added = { "loader", ... } } (select them in Explorer)
		Export.plan("NeonStrike", true) -- what Insert will do: { { key, label, path, status, tone, instance } }
		Export.list()                 -- { { instance, name, enabled, title, genre, updated } }
		Export.load(store, instance)  -- editor blueprint (keeps hidden items when possible)
		Export.setEnabled(installer, instance, false)
		Export.delete(installer, instance)

	Insert (one undo step):
	  * installs / updates ReplicatedStorage.BloxUI (same as the Install button)
	  * writes the blueprint JSON to ReplicatedStorage.BloxUI_Blueprints.<Name>
	    (StringValue, attribute Enabled = true). Hidden elements are left out;
	    the editor copy (with them) is kept in a child "EditorSource" so Load
	    can restore them.
	  * adds the BlueprintLoader LocalScript to StarterPlayerScripts once
	  * optionally adds the BlueprintServer Script to ServerScriptService once

	Export as code (Blueprint.Integration):

		local out = Export.generateHooks(store, "NeonStrike")   -- nil, reason when it can't
		Export.hooksPlan(store, "NeonStrike")                    -- rows like Export.plan
		local ok, message, details = Export.insertHooks(installer, store, { name = "NeonStrike" })
		-- details = { client, server, generated, replaced } (select / open them)
		Export.editedHooks(store, "NeonStrike")                  -- inserted scripts someone edited since

	Insert scripts (one undo step) adds <Name>_UIHooks (LocalScript,
	StarterPlayerScripts) and <Name>_ServerHooks (Script, ServerScriptService),
	both enabled like the loader, and installs BloxUI if it's missing. The
	scripts are tagged (Integration.TAG = the name, Integration.SIDE, and a
	hash of the source): inserting again replaces only scripts with that tag,
	never a script of the user's, even one with the same name.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Selection = game:GetService("Selection")

local Deps = require(script.Parent.Deps)
local Store = require(script.Parent.Store)

local Export = {}

Export.FOLDER = "BloxUI_Blueprints"
Export.LOADER = "BloxUI_BlueprintLoader"
Export.SERVER = "BloxUI_BlueprintServer"
Export.EDITOR = "EditorSource"
Export.MAX_LENGTH = 199999

function Export.templates(): Instance?
	return Deps.BloxUI:FindFirstChild("Templates")
end

function Export.hasLoaderTemplate(): boolean
	local templates = Export.templates()
	return templates ~= nil and templates:FindFirstChild("BlueprintLoader") ~= nil
end

function Export.hasServerTemplate(): boolean
	local templates = Export.templates()
	return templates ~= nil and templates:FindFirstChild("BlueprintServer") ~= nil
end

function Export.folder(): Instance?
	return ReplicatedStorage:FindFirstChild(Export.FOLDER)
end

local function findTagged(parent: Instance, name: string, attribute: string): Instance?
	for _, child in parent:GetChildren() do
		if child.Name == name or child:GetAttribute(attribute) == true then
			return child
		end
	end
	return nil
end

local function starterScripts(): Instance?
	local StarterPlayer = game:GetService("StarterPlayer")
	return StarterPlayer:FindFirstChildOfClass("StarterPlayerScripts")
end

-- The loader / server scripts Insert added earlier (nil when not there).
function Export.loader(): Instance?
	local scripts = starterScripts()
	return scripts and findTagged(scripts, Export.LOADER, "BloxUIBlueprintLoader")
end

function Export.server(): Instance?
	return findTagged(ServerScriptService, Export.SERVER, "BloxUIBlueprintServer")
end

--[[
	What Insert will do, as a checklist for the Export tab. Never changes the
	place. status is a short verb ("Install", "Update", "New", "Replace", "Add",
	"In place", "Missing"); tone is a badge tone.
]]
function Export.plan(name: string, server: boolean?): { { [string]: any } }
	local out = {}
	local library = ReplicatedStorage:FindFirstChild("BloxUI")
	table.insert(out, {
		key = "library",
		icon = "square-books",
		label = "BloxUI library",
		path = "ReplicatedStorage.BloxUI",
		status = if library then "Update" else "Install",
		tone = if library then "neutral" else "success",
		instance = library,
	})
	local folder = Export.folder()
	local existing = folder and folder:FindFirstChild(name)
	table.insert(out, {
		key = "blueprint",
		icon = "file-box",
		label = "Your UI (blueprint JSON)",
		path = `ReplicatedStorage.{Export.FOLDER}.{name}`,
		status = if existing then "Replace" else "New",
		tone = if existing then "warning" else "success",
		instance = existing,
	})
	local loader = Export.loader()
	table.insert(out, {
		key = "loader",
		icon = "square-code",
		label = "Loader script",
		path = `StarterPlayerScripts.{Export.LOADER}`,
		status = if loader then "In place" elseif Export.hasLoaderTemplate() then "Add" else "Missing",
		tone = if loader then "neutral" elseif Export.hasLoaderTemplate() then "success" else "danger",
		instance = loader,
	})
	if server then
		local script = Export.server()
		table.insert(out, {
			key = "server",
			icon = "square-code",
			label = "Server script",
			path = `ServerScriptService.{Export.SERVER}`,
			status = if script then "In place" elseif Export.hasServerTemplate() then "Add" else "Missing",
			tone = if script then "neutral" elseif Export.hasServerTemplate() then "success" else "danger",
			instance = script,
		})
	end
	return out
end

--[[
	Returns ok, message, stringValue, details. `opts.name` is sanitised to
	letters, digits and underscores. details = { value, bloxui, loader, server,
	added = { "loader", "server" } } so the caller can select them in Explorer.
]]
function Export.insert(installer: any, store: any, opts: { [string]: any }): (boolean, string, Instance?, { [string]: any }?)
	local bp = store.blueprint
	if bp == nil then
		return false, "Generate or load a UI first.", nil
	end
	local name = Store.safeName(opts.name)
	local compiled = store:normalize(Store.compile(bp))
	local json = store:encode(compiled)
	local editorJson = store:encode(bp)
	if json == nil then
		return false, "Could not encode the blueprint as JSON.", nil
	end
	if #json > Export.MAX_LENGTH then
		return false, `The blueprint is too big for a StringValue ({#json} characters).`, nil
	end

	local notes = {}
	local added = {}
	local value: StringValue? = nil
	local ok, err = installer:record(`Insert BloxUI UI {name}`, function()
		installer:install()

		local folder = Export.folder()
		if folder == nil then
			folder = Instance.new("Folder")
			folder.Name = Export.FOLDER
			folder.Parent = ReplicatedStorage
		end

		local existing = folder:FindFirstChild(name)
		local sv: StringValue
		if existing and existing:IsA("StringValue") then
			sv = existing :: StringValue
		else
			if existing then
				existing.Parent = nil
			end
			sv = Instance.new("StringValue")
			sv.Name = name
		end
		sv.Value = json
		sv:SetAttribute("Enabled", true)
		sv:SetAttribute("Title", Store.titleOf(bp))
		sv:SetAttribute("Genre", (bp.meta and bp.meta.genre) or "")
		sv:SetAttribute("Source", (bp.meta and bp.meta.source) or "offline")
		sv:SetAttribute("BloxUIVersion", installer.version)
		sv:SetAttribute("UpdatedAt", os.time())
		sv:SetAttribute("ExportHash", Store.hash(json))

		local editor = sv:FindFirstChild(Export.EDITOR)
		if editorJson and editorJson ~= json and #editorJson <= Export.MAX_LENGTH then
			if editor == nil or not editor:IsA("StringValue") then
				if editor then
					editor.Parent = nil
				end
				editor = Instance.new("StringValue")
				editor.Name = Export.EDITOR
			end
			(editor :: StringValue).Value = editorJson;
			(editor :: StringValue).Parent = sv
		elseif editor then
			editor.Parent = nil
		end
		sv.Parent = folder
		value = sv

		local templates = Export.templates()
		local scripts = installer:starterScripts()
		if findTagged(scripts, Export.LOADER, "BloxUIBlueprintLoader") == nil then
			local template = templates and templates:FindFirstChild("BlueprintLoader")
			if template then
				local loader = template:Clone()
				loader.Name = Export.LOADER
				loader:SetAttribute("BloxUIBlueprintLoader", true);
				(loader :: any).Enabled = true
				loader.Parent = scripts
				table.insert(notes, "loader added")
				table.insert(added, "loader")
			else
				table.insert(notes, "loader template missing")
			end
		end

		if opts.server then
			if findTagged(ServerScriptService, Export.SERVER, "BloxUIBlueprintServer") == nil then
				local template = templates and templates:FindFirstChild("BlueprintServer")
				if template then
					local server = template:Clone()
					server.Name = Export.SERVER
					server:SetAttribute("BloxUIBlueprintServer", true);
					(server :: any).Enabled = true
					server.Parent = ServerScriptService
					table.insert(notes, "server script added")
					table.insert(added, "server")
				else
					table.insert(notes, "server template missing")
				end
			end
		end

		Selection:Set({ sv })
	end)
	if not ok then
		return false, "Insert failed: " .. tostring(err), nil
	end
	local message = `Inserted {name}.`
	if table.find(notes, "loader template missing") then
		message ..= " The BlueprintLoader template is missing from this plugin build, so nothing will load it yet."
	else
		message ..= " Press Play to try it."
	end
	local details = {
		value = value,
		bloxui = ReplicatedStorage:FindFirstChild("BloxUI"),
		loader = Export.loader(),
		server = if opts.server then Export.server() else nil,
		added = added,
	}
	return true, message, value, details
end

function Export.list(): { any }
	local folder = Export.folder()
	local out = {}
	if folder == nil then
		return out
	end
	for _, child in folder:GetChildren() do
		if child:IsA("StringValue") then
			local enabled = child:GetAttribute("Enabled")
			table.insert(out, {
				instance = child,
				name = child.Name,
				enabled = enabled ~= false,
				title = child:GetAttribute("Title") or child.Name,
				genre = child:GetAttribute("Genre") or "",
				source = child:GetAttribute("Source") or "",
				updated = child:GetAttribute("UpdatedAt"),
				size = #(child :: StringValue).Value,
			})
		end
	end
	table.sort(out, function(a, b)
		return string.lower(a.name) < string.lower(b.name)
	end)
	return out
end

-- Decodes a saved blueprint for editing. Prefers the editor copy (with hidden
-- items) unless the exported JSON was edited by hand since.
function Export.load(store: any, instance: Instance): any
	local sv = instance :: StringValue
	local editor = sv:FindFirstChild(Export.EDITOR)
	if editor and editor:IsA("StringValue") and sv:GetAttribute("ExportHash") == Store.hash(sv.Value) then
		local bp = store:decode((editor :: StringValue).Value, true)
		if bp then
			return bp
		end
	end
	return store:decode(sv.Value)
end

function Export.setEnabled(installer: any, instance: Instance, enabled: boolean): boolean
	local ok = installer:record(`{if enabled then "Enable" else "Disable"} BloxUI UI {instance.Name}`, function()
		instance:SetAttribute("Enabled", enabled)
	end)
	return ok
end

function Export.delete(installer: any, instance: Instance): boolean
	local ok = installer:record(`Delete BloxUI UI {instance.Name}`, function()
		instance.Parent = nil -- undoable
	end)
	return ok
end

--------------------------------------------------------------------------
-- Export as code: <Name>_UIHooks + <Name>_ServerHooks
--------------------------------------------------------------------------

-- The generator (Blueprint.Integration), or nil in a build without it.
function Export.integration(store: any): any
	local Blueprint = store and store.modules and store.modules.Blueprint
	if Blueprint == nil then
		return nil
	end
	local ok, module = pcall(function()
		return Blueprint.Integration
	end)
	if ok and typeof(module) == "table" and typeof(module.generate) == "function" then
		return module
	end
	return nil
end

-- The scripts and checklist for the current blueprint, or nil and why not.
function Export.generateHooks(store: any, name: string): (any, string?)
	local Integration = Export.integration(store)
	if Integration == nil then
		return nil, "Export as code isn't in this plugin build."
	end
	if store.blueprint == nil then
		return nil, "Generate or load a UI first."
	end
	local ok, out = pcall(Integration.generate, Store.compile(store.blueprint), { name = Store.safeName(name) })
	if not ok or typeof(out) ~= "table" then
		return nil, `Couldn't write the scripts: {store:redact(tostring(out))}`
	end
	return out, nil
end

local function isScript(instance: Instance): boolean
	return instance:IsA("Script") or instance:IsA("LocalScript")
end

-- Hook scripts this plugin inserted for `name`: { client = {...}, server = {...} }.
-- Only tagged scripts count, whatever they're called.
function Export.hookScripts(store: any, name: string): { client: { Instance }, server: { Instance } }
	local out = { client = {}, server = {} }
	local Integration = Export.integration(store)
	if Integration == nil then
		return out
	end
	local function scan(parent: Instance?, side: string, list: { Instance })
		if parent == nil then
			return
		end
		for _, child in parent:GetChildren() do
			if isScript(child) and child:GetAttribute(Integration.TAG) == name and child:GetAttribute(Integration.SIDE) == side then
				table.insert(list, child)
			end
		end
	end
	scan(starterScripts(), "Client", out.client)
	scan(ServerScriptService, "Server", out.server)
	return out
end

-- Tagged hook scripts whose source changed since they were inserted.
function Export.editedHooks(store: any, name: string): { Instance }
	local Integration = Export.integration(store)
	local edited = {}
	if Integration == nil then
		return edited
	end
	local found = Export.hookScripts(store, name)
	for _, list in { found.client, found.server } do
		for _, script in list do
			local ok, source = pcall(function()
				return (script :: any).Source
			end)
			if ok and typeof(source) == "string" and Integration.hash(source) ~= script:GetAttribute(Integration.HASH) then
				table.insert(edited, script)
			end
		end
	end
	return edited
end

-- What Insert scripts will do (rows like Export.plan).
function Export.hooksPlan(store: any, name: string): { { [string]: any } }
	local Integration = Export.integration(store)
	if Integration == nil then
		return {}
	end
	local found = Export.hookScripts(store, name)
	local edited = Export.editedHooks(store, name)
	local rows = {}
	for _, spec in {
		{ key = "client", label = "UI hooks", path = `StarterPlayerScripts.{name}{Integration.CLIENT_SUFFIX}`, list = found.client },
		{ key = "server", label = "Server hooks", path = `ServerScriptService.{name}{Integration.SERVER_SUFFIX}`, list = found.server },
	} do
		local existing = spec.list[1]
		local isEdited = false
		for _, script in spec.list do
			if table.find(edited, script) then
				isEdited = true
			end
		end
		table.insert(rows, {
			key = spec.key,
			icon = "square-code",
			label = spec.label,
			path = if existing and existing.Parent then `{existing.Parent.Name}.{existing.Name}` else spec.path,
			status = if isEdited then "Edited" elseif existing then "Update" else "New",
			tone = if isEdited then "warning" elseif existing then "neutral" else "success",
			instance = existing,
		})
	end
	return rows
end

local function freeName(parent: Instance, base: string): string
	local name = base
	local n = 2
	while parent:FindFirstChild(name) ~= nil do
		name = base .. n
		n += 1
	end
	return name
end

--[[
	Inserts <Name>_UIHooks and <Name>_ServerHooks (one undo step), replacing
	the ones inserted for this name before. Returns ok, message, details =
	{ client, server, generated, replaced }. Callers ask before replacing
	scripts someone edited (Export.editedHooks).
]]
function Export.insertHooks(installer: any, store: any, opts: { [string]: any }): (boolean, string, { [string]: any }?)
	local name = Store.safeName(opts.name)
	local out, reason = Export.generateHooks(store, name)
	if out == nil then
		return false, reason or "Couldn't write the scripts.", nil
	end
	local Integration = Export.integration(store)
	local client: Instance? = nil
	local server: Instance? = nil
	local replaced = 0
	local ok, err = installer:record(`Insert BloxUI hooks {name}`, function()
		if ReplicatedStorage:FindFirstChild("BloxUI") == nil then
			installer:install()
		end
		local scripts = installer:starterScripts()
		local found = Export.hookScripts(store, name)
		for _, list in { found.client, found.server } do
			for _, old in list do
				old.Parent = nil -- undoable, unlike Destroy()
				replaced += 1
			end
		end
		local function make(className: string, scriptName: string, source: string, side: string, parent: Instance): Instance
			local script: any = Instance.new(className)
			script.Name = freeName(parent, scriptName)
			script.Source = source
			script:SetAttribute(Integration.TAG, name)
			script:SetAttribute(Integration.SIDE, side)
			script:SetAttribute(Integration.HASH, Integration.hash(source))
			script.Parent = parent
			return script
		end
		client = make("LocalScript", out.clientName, out.client, "Client", scripts)
		server = make("Script", out.serverName, out.server, "Server", ServerScriptService)
		Selection:Set({ client, server })
	end)
	if not ok then
		return false, "Insert scripts failed: " .. store:redact(tostring(err)), nil
	end
	local message = if replaced > 0
		then `Replaced {out.clientName} and {out.serverName}.`
		else `Inserted {out.clientName} and {out.serverName}.`
	return true, message, { client = client, server = server, generated = out, replaced = replaced > 0 }
end

-- Opens a script in Studio's editor (plugin:OpenScript). False when it can't.
function Export.openScript(plugin: any, script: Instance?): boolean
	if plugin == nil or script == nil or script.Parent == nil then
		return false
	end
	local ok = pcall(function()
		plugin:OpenScript(script)
	end)
	return ok
end

return Export
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="9">
        <Properties>
          <string name="Name">Fields</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Fields: editors for one field of a blueprint table, driven by the schemas
	in Catalog (Catalog.F.*).

		Fields.render(scope, app, item, field, order) -> Instance

	Edits write straight into `item` and call app.store:changed(structural).
	Text-like fields commit when focus is lost, so typing never rebuilds the
	tree under the cursor.
]]

local Deps = require(script.Parent.Deps)
local Style = require(script.Parent.Style)
local Ui = require(script.Parent.Ui)
local Catalog = require(script.Parent.Catalog)

local Fusion = Deps.Fusion
local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local C = Style.Color

local Fields = {}

local LABEL_WIDTH = 92
Fields.LABEL_WIDTH = LABEL_WIDTH

-- Reactive read of item[key] that refreshes after every edit. `revision`
-- (optional) is a tab-gated counter from store:tabCounters.
function Fields.watch(scope: any, store: any, item: any, key: string, revision: any?): any
	local counter = revision or store.revision
	return scope:Computed(function(use)
		use(counter)
		return item[key]
	end)
end

-- label on the left, control filling the rest.
function Fields.inline(scope: any, label: string, control: Instance, order: number?, height: number?): Frame
	return scope:New "Frame" {
		Name = "Field_" .. label,
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, height or Style.Height.Control),
		LayoutOrder = order or 0,
		[Children] = {
			Ui.text(scope, {
				Name = "Label",
				Text = label,
				TextSize = Style.Text.Small,
				Color = C.TextSub,
				Size = UDim2.new(0, LABEL_WIDTH - 8, 1, 0),
				Truncate = true,
			}),
			scope:New "Frame" {
				Name = "Control",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(LABEL_WIDTH, 0),
				Size = UDim2.new(1, -LABEL_WIDTH, 1, 0),
				[Children] = control,
			},
		},
	}
end

-- label above, control below (multi-line / lists).
function Fields.stacked(scope: any, label: string, children: any, order: number?, accessory: any?): Frame
	if typeof(accessory) == "Instance" and accessory:IsA("GuiObject") then
		accessory.LayoutOrder = 3 -- after the label and the spacer
	end
	return scope:New "Frame" {
		Name = "Field_" .. label,
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		LayoutOrder = order or 0,
		[Children] = {
			Ui.list(scope, "Y", 6),
			scope:New "Frame" {
				Name = "LabelRow",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 20),
				LayoutOrder = 0,
				[Children] = {
					Ui.list(scope, "X", 6, "Left", "Center"),
					Ui.text(scope, { Text = label, TextSize = Style.Text.Small, Color = C.TextSub, LayoutOrder = 1 }),
					Ui.spacer(scope, 2),
					accessory,
				},
			},
			children,
		},
	}
end

local function set(app: any, item: any, key: string, value: any, structural: boolean?)
	if item[key] == value then
		return
	end
	item[key] = value
	app.store:changed(structural)
end

--------------------------------------------------------------------------
-- option lists
--------------------------------------------------------------------------

function Fields.toneOptions(): { any }
	local out = {}
	for _, tone in Catalog.tones do
		table.insert(out, { value = tone.value, label = tone.value, color = tone.color })
	end
	return out
end

function Fields.rarityOptions(): { any }
	local out = {}
	for _, rarity in Catalog.rarities do
		table.insert(out, { value = rarity, label = rarity, color = Catalog.rarityColors[rarity] })
	end
	return out
end

function Fields.keyOptions(allowNone: boolean?): { any }
	local out = {}
	if allowNone then
		table.insert(out, { value = "", label = "None" })
	end
	for _, key in Catalog.keys do
		table.insert(out, { value = key, label = key })
	end
	return out
end

function Fields.currencyOptions(store: any, robux: boolean?): { any }
	local out = {}
	if robux then
		table.insert(out, { value = "robux", label = "Robux", icon = "robux" })
	end
	for _, currency in (store.blueprint and store.blueprint.currencies) or {} do
		if currency.id then
			table.insert(out, { value = currency.id, label = currency.name or currency.id, icon = currency.icon, color = currency.color })
		end
	end
	return out
end

function Fields.paletteColors(store: any): { string }
	local out = {}
	local palette = store.blueprint and store.blueprint.style and store.blueprint.style.palette or {}
	for _, key in Catalog.paletteKeys do
		if palette[key] then
			table.insert(out, palette[key])
		end
	end
	for _, key in Catalog.paletteExtraKeys do
		if palette[key] then
			table.insert(out, palette[key])
		end
	end
	return out
end

--------------------------------------------------------------------------
-- action editor
--------------------------------------------------------------------------

function Fields.action(scope: any, app: any, owner: any, key: string, order: number?, label: string?): Frame
	local store = app.store
	local current = Fields.watch(scope, store, owner, key, app.revision)
	local kind = scope:Computed(function(use)
		local action = use(current)
		return if typeof(action) == "table" and action.kind then action.kind else "None"
	end)
	local function action(): any
		local value = owner[key]
		if typeof(value) ~= "table" then
			value = { kind = "None" }
			owner[key] = value
		end
		return value
	end
	local kindOptions = {}
	for _, entry in Catalog.actionKinds do
		table.insert(kindOptions, { value = entry.value, label = entry.label, icon = entry.icon })
	end

	local detail = scope:Computed(function(use, inner)
		local k = use(kind)
		if k == "Open" or k == "Close" or k == "Toggle" then
			return Fields.inline(inner, "Screen", Ui.dropdown(inner, {
				Options = store:screenOptions(false),
				Value = inner:Computed(function(use2)
					local a = use2(current)
					return if typeof(a) == "table" then a.target else nil
				end),
				Placeholder = "Choose a screen",
				OnChange = function(value)
					action().target = value
					store:changed(false)
				end,
			}), 2)
		elseif k == "Purchase" or k == "GamePass" then
			local idKey = if k == "Purchase" then "productId" else "gamePassId"
			return Fields.inline(inner, if k == "Purchase" then "Product id" else "Game pass id", Ui.numberInput(inner, {
				Value = inner:Computed(function(use2)
					local a = use2(current)
					return if typeof(a) == "table" then a[idKey] else nil
				end),
				Placeholder = "123456789",
				Integer = true,
				Min = 0,
				OnCommit = function(number)
					action()[idKey] = number
					store:changed(false)
				end,
			}), 2)
		elseif k == "Remote" or k == "Event" or k == "Notify" then
			local textKey = if k == "Notify" then "text" else "name"
			return Fields.inline(inner, if k == "Notify" then "Message" elseif k == "Remote" then "Remote" else "Event", Ui.input(inner, {
				Value = inner:Computed(function(use2)
					local a = use2(current)
					return if typeof(a) == "table" then a[textKey] else nil
				end),
				Placeholder = if k == "Notify" then "Coming soon!" elseif k == "Remote" then "RemoteEventName" else "Rebirth",
				OnCommit = function(text)
					action()[textKey] = text
					store:changed(false)
				end,
			}), 2)
		end
		return nil
	end)

	return scope:New "Frame" {
		Name = "Action",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		LayoutOrder = order or 0,
		[Children] = {
			Ui.list(scope, "Y", 6),
			Fields.inline(scope, label or "On click", Ui.dropdown(scope, {
				Options = kindOptions,
				Value = kind,
				OnChange = function(value)
					local previous = action()
					if previous.kind == value then
						return
					end
					local fresh: any = { kind = value }
					if value == "Open" or value == "Close" or value == "Toggle" then
						fresh.target = previous.target
						if fresh.target == nil then
							local first = store.blueprint and store.blueprint.screens and store.blueprint.screens[1]
							fresh.target = first and first.id
						end
					elseif value == "Notify" then
						fresh.text = "Coming soon!"
					elseif value == "Remote" then
						fresh.name = "UIAction"
					elseif value == "Event" then
						fresh.name = "Action"
					end
					owner[key] = fresh
					store:changed(false)
				end,
			}), 1),
			detail,
		},
	}
end

--------------------------------------------------------------------------
-- strings list
--------------------------------------------------------------------------

function Fields.strings(scope: any, app: any, owner: any, field: any, order: number?): Frame
	local store = app.store
	local version = scope:Value(0)
	local function list(): { string }
		local value = owner[field.key]
		if typeof(value) ~= "table" then
			value = {}
			owner[field.key] = value
		end
		return value
	end
	local rows = scope:Computed(function(use, inner)
		use(version)
		local out = {}
		for index, text in list() do
			table.insert(out, Ui.row(inner, {
				Gap = 4,
				Height = Style.Height.Control,
				LayoutOrder = index,
				[Children] = {
					Ui.input(inner, {
						Value = tostring(text),
						Size = UDim2.new(1, -32, 1, 0),
						LayoutOrder = 1,
						OnCommit = function(newText)
							local l = list()
							if l[index] ~= nil and l[index] ~= newText then
								l[index] = newText
								store:changed(false)
							end
						end,
					}),
					Ui.iconButton(inner, {
						Icon = "x-small",
						Tooltip = "Remove",
						Tone = "danger",
						LayoutOrder = 2,
						OnClick = function()
							table.remove(list(), index)
							version:set(peek(version) + 1)
							store:changed(false)
						end,
					}),
				},
			}))
		end
		return out
	end)
	return Fields.stacked(scope, field.label, Ui.column(scope, {
		Gap = 4,
		LayoutOrder = 1,
		[Children] = {
			rows,
			Ui.button(scope, {
				Text = "Add " .. string.lower(field.itemLabel or "item"),
				Icon = "plus-small",
				Variant = "ghost",
				Size = "sm",
				LayoutOrder = 10000,
				OnClick = function()
					table.insert(list(), if field.itemLabel == "Currency id" then "coins" else "")
					version:set(peek(version) + 1)
					store:changed(false)
				end,
			}),
		},
	}), order)
end

--------------------------------------------------------------------------
-- render one field
--------------------------------------------------------------------------

local DEFAULT_OPTION = "__default"

-- A field row with a short explanation under the control (field.hint).
local function withHint(scope: any, row: Instance?, hint: string?): Instance?
	if row == nil or hint == nil or hint == "" then
		return row
	end
	local order = (row :: any).LayoutOrder
	local name = row.Name;
	(row :: any).LayoutOrder = 1
	return scope:New "Frame" {
		Name = name,
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		LayoutOrder = order,
		[Children] = {
			Ui.list(scope, "Y", 3),
			row,
			scope:New "Frame" {
				Name = "HintRow",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 2,
				[Children] = {
					Ui.padding(scope, 0, 0, 2, LABEL_WIDTH),
					Ui.text(scope, {
						Name = "Hint",
						Text = hint,
						TextSize = Style.Text.Caption,
						Color = C.TextMuted,
						Fill = true,
						Wrap = true,
						LineHeight = 1.1,
					}),
				},
			},
		},
	}
end

--[[
	Enum fields take, besides a plain list of options (strings or
	{ value, label, hint }):
	  field.options = function(item) -> list    options that depend on the item
	  field.default = "Theme default"           an option that clears the key (nil)
	  field.defaultHint = "..."                 its hint in the menu
	  field.after = function(item)              runs after a change (e.g. fix a
	                                            dependent field) before the redraw
]]
local function enumControl(scope: any, app: any, item: any, field: any, value: any, revision: any): Instance
	local key = field.key
	local function optionsFor(): { any }
		local raw = if typeof(field.options) == "function" then field.options(item) else field.options
		if field.default == nil then
			return raw or {}
		end
		local out = { { value = DEFAULT_OPTION, label = field.default, hint = field.defaultHint } }
		for _, option in raw or {} do
			table.insert(out, option)
		end
		return out
	end
	local options = if typeof(field.options) == "function"
		then scope:Computed(function(use)
			use(revision)
			return optionsFor()
		end)
		else optionsFor()
	local shown = if field.default ~= nil
		then scope:Computed(function(use)
			local current = use(value)
			return if current == nil then DEFAULT_OPTION else current
		end)
		else value
	return Ui.dropdown(scope, {
		Options = options,
		Value = shown,
		MenuTitle = if field.hint or field.default then field.label else nil,
		OnChange = function(option)
			if field.onChange then
				field.onChange(item, option)
				return
			end
			local chosen = if option == DEFAULT_OPTION then nil else option
			if item[key] == chosen then
				return
			end
			item[key] = chosen
			if field.after then
				field.after(item)
			end
			app.store:changed(field.structural)
		end,
	})
end

function Fields.render(scope: any, app: any, item: any, field: any, order: number?): Instance?
	return withHint(scope, Fields.renderControl(scope, app, item, field, order), field.hint)
end

function Fields.renderControl(scope: any, app: any, item: any, field: any, order: number?): Instance?
	local store = app.store
	local key = field.key
	local kind = field.kind
	local revision = app.revision or store.revision
	local value = Fields.watch(scope, store, item, key, revision)

	if kind == "text" then
		return Fields.inline(scope, field.label, Ui.input(scope, {
			Value = value,
			Placeholder = field.placeholder or "",
			OnCommit = function(text)
				local previous = item[key]
				if text == "" and (previous == nil) then
					return
				end
				-- keep numbers numeric when the schema field was a number before (e.g. badge = 3)
				if typeof(previous) == "number" and tonumber(text) then
					set(app, item, key, tonumber(text))
				else
					set(app, item, key, if text == "" then nil else text)
				end
			end,
		}), order)
	elseif kind == "multiline" then
		return Fields.stacked(scope, field.label, Ui.input(scope, {
			Value = value,
			MultiLine = true,
			Height = 96,
			LayoutOrder = 1,
			OnCommit = function(text)
				set(app, item, key, text)
			end,
		}), order)
	elseif kind == "number" then
		return Fields.inline(scope, field.label, Ui.numberInput(scope, {
			Value = value,
			Min = field.min,
			Max = field.max,
			Integer = field.integer,
			AllowEmpty = true,
			Placeholder = if field.min == 0 and field.max == 1 then "0 – 1" else "",
			OnCommit = function(number)
				set(app, item, key, number)
			end,
		}), order)
	elseif kind == "bool" then
		return Fields.inline(scope, field.label, scope:New "Frame" {
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			[Children] = {
				Ui.list(scope, "X", 0, "Left", "Center"),
				Ui.switch(scope, {
					Value = scope:Computed(function(use)
						return use(value) == true
					end),
					OnChange = function(on)
						set(app, item, key, on)
					end,
				}),
			},
		}, order)
	elseif kind == "enum" then
		return Fields.inline(scope, field.label, enumControl(scope, app, item, field, value, revision), order)
	elseif kind == "icon" then
		return Fields.inline(scope, field.label, Ui.iconField(scope, {
			Value = value,
			OnChange = function(name)
				set(app, item, key, name)
			end,
		}), order)
	elseif kind == "tone" then
		return Fields.inline(scope, field.label, Ui.dropdown(scope, {
			Options = Fields.toneOptions(),
			Value = value,
			Placeholder = "Default",
			OnChange = function(tone)
				set(app, item, key, tone)
			end,
		}), order)
	elseif kind == "rarity" then
		return Fields.inline(scope, field.label, Ui.dropdown(scope, {
			Options = Fields.rarityOptions(),
			Value = value,
			OnChange = function(rarity)
				set(app, item, key, rarity)
			end,
		}), order)
	elseif kind == "key" then
		return Fields.inline(scope, field.label, Ui.dropdown(scope, {
			Options = Fields.keyOptions(true),
			Value = scope:Computed(function(use)
				return use(value) or ""
			end),
			Searchable = true,
			OnChange = function(keyName)
				set(app, item, key, if keyName == "" then nil else keyName)
			end,
		}), order)
	elseif kind == "color" then
		return Fields.inline(scope, field.label, Ui.colorField(scope, {
			Value = value,
			Palette = scope:Computed(function(use)
				use(revision)
				return Fields.paletteColors(store)
			end),
			OnChange = function(hex)
				set(app, item, key, hex)
			end,
		}), order)
	elseif kind == "currency" then
		return Fields.inline(scope, field.label, Ui.dropdown(scope, {
			Options = scope:Computed(function(use)
				use(store.structure)
				return Fields.currencyOptions(store, field.robux)
			end),
			Value = value,
			Placeholder = "Choose...",
			OnChange = function(id)
				set(app, item, key, id)
			end,
		}), order)
	elseif kind == "action" then
		return Fields.action(scope, app, item, key, order, field.label)
	elseif kind == "strings" then
		return Fields.strings(scope, app, item, field, order)
	elseif kind == "object" then
		local object = item[key]
		if typeof(object) ~= "table" then
			object = if field.default then field.default() else {}
			item[key] = object
		end
		local children = {}
		for index, sub in field.fields do
			table.insert(children, Fields.render(scope, app, object, sub, index))
		end
		return Fields.stacked(scope, field.label, scope:New "Frame" {
			Name = "Object",
			BackgroundTransparency = 1,
			Size = UDim2.new(1, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			LayoutOrder = 1,
			[Children] = {
				scope:New "Frame" {
					Name = "Rule",
					BackgroundColor3 = C.Border,
					BorderSizePixel = 0,
					Size = UDim2.new(0, 2, 1, 0),
				},
				scope:New "Frame" {
					Name = "Inner",
					BackgroundTransparency = 1,
					Position = UDim2.fromOffset(12, 0),
					Size = UDim2.new(1, -12, 0, 0),
					AutomaticSize = Enum.AutomaticSize.Y,
					[Children] = {
						Ui.list(scope, "Y", 6),
						children,
					},
				},
			},
		}, order)
	elseif kind == "list" then
		-- nested lists are rendered by the Structure tree (rows with actions)
		if app.renderList then
			return app.renderList(scope, item, field, order)
		end
		return nil
	end
	return nil
end

--------------------------------------------------------------------------
-- slider
--------------------------------------------------------------------------

--[[
	Horizontal slider with a value readout.
	Fields.slider(scope, {
		Value = state<number>, Min = 0, Max = 1, Step = 0.05,
		Format = fn(number) -> string,     -- readout, e.g. "50%"
		OnCommit = fn(number),             -- on release (store edits go here)
	})
]]
function Fields.slider(scope: any, props: { [any]: any }): Frame
	local min, max = props.Min or 0, props.Max or 1
	local step = props.Step
	local dragging = false
	local shown = scope:Value(peek(props.Value) or min)
	local hovered = scope:Value(false)
	if typeof(props.Value) == "table" then
		table.insert(scope, scope:Observer(props.Value):onChange(function()
			if not dragging then
				shown:set(peek(props.Value) or min)
			end
		end))
	end
	local fraction = scope:Computed(function(use)
		local span = max - min
		return if span > 0 then math.clamp(((use(shown) or min) - min) / span, 0, 1) else 0
	end)

	local track: GuiObject
	local function fromX(x: number): number
		local t = math.clamp((x - track.AbsolutePosition.X) / math.max(1, track.AbsoluteSize.X), 0, 1)
		local value = min + (max - min) * t
		if step then
			value = math.clamp(math.floor(value / step + 0.5) * step, min, max)
		end
		return value
	end
	local function commit()
		dragging = false
		if props.OnCommit then
			props.OnCommit(peek(shown))
		end
	end

	track = scope:New "TextButton" {
		Name = "Track",
		Text = "",
		AutoButtonColor = false,
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0, 0.5),
		Position = UDim2.fromScale(0, 0.5),
		Size = UDim2.new(1, -56, 0, 22),
		[OnEvent "MouseEnter"] = function()
			hovered:set(true)
		end,
		[OnEvent "MouseLeave"] = function()
			hovered:set(false)
			if dragging then
				commit()
			end
		end,
		[OnEvent "InputBegan"] = function(input: InputObject)
			if input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch then
				dragging = true
				shown:set(fromX(input.Position.X))
			end
		end,
		[OnEvent "InputChanged"] = function(input: InputObject)
			if dragging and (input.UserInputType == Enum.UserInputType.MouseMovement or input.UserInputType == Enum.UserInputType.Touch) then
				shown:set(fromX(input.Position.X))
			end
		end,
		[OnEvent "InputEnded"] = function(input: InputObject)
			if dragging and (input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch) then
				commit()
			end
		end,
		[Children] = {
			scope:New "Frame" {
				Name = "Rail",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.fromScale(0, 0.5),
				Size = UDim2.new(1, 0, 0, 4),
				BackgroundColor3 = C.BorderStrong,
				BorderSizePixel = 0,
				[Children] = {
					Ui.corner(scope, Style.Radius.Pill),
					scope:New "Frame" {
						Name = "Fill",
						Size = scope:Computed(function(use)
							return UDim2.fromScale(use(fraction), 1)
						end),
						BackgroundColor3 = C.Accent,
						BorderSizePixel = 0,
						[Children] = Ui.corner(scope, Style.Radius.Pill),
					},
				},
			},
			scope:New "Frame" {
				Name = "Knob",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = scope:Computed(function(use)
					return UDim2.fromScale(use(fraction), 0.5)
				end),
				Size = scope:Computed(function(use)
					return if use(hovered) then UDim2.fromOffset(16, 16) else UDim2.fromOffset(14, 14)
				end),
				BackgroundColor3 = C.White,
				ZIndex = 2,
				[Children] = {
					Ui.corner(scope, Style.Radius.Pill),
					Ui.stroke(scope, C.Accent, 2),
				},
			},
		},
	}

	return scope:New "Frame" (Ui.merge(Ui.layout(props, { Name = "Slider" }), {
		BackgroundTransparency = 1,
		Size = props.Size or UDim2.new(1, 0, 1, 0),
		[Children] = {
			track,
			Ui.text(scope, {
				Name = "Readout",
				Text = scope:Computed(function(use)
					local value = use(shown) or min
					return if props.Format then props.Format(value) else tostring(value)
				end),
				Font = Style.Font.Mono,
				TextSize = Style.Text.Small,
				Color = C.TextSub,
				XAlign = Enum.TextXAlignment.Right,
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.fromScale(1, 0.5),
				Size = UDim2.new(0, 46, 1, 0),
			}),
		},
	}))
end

return Fields
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="10">
        <Properties>
          <string name="Name">History</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	History: editor undo / redo for the working blueprint (Ctrl+Z / Ctrl+Y).

		local history = History.new(scope, store)
		history:undo()           -- true if something was undone
		history:redo()
		history.canUndo          -- Fusion states (bool)
		history.canRedo
		history.topKind          -- state: "edit" | "replace" | nil (what Undo would undo)
		history:lastId()         -- id of the newest undo step (Structure's "Undo" toast)

	It listens to store:onChanged, so every edit the tabs make through
	store:changed() / store:edit() becomes one step. A new document (Generate,
	Variation, a history restore, Load) is a "replace" step, so Undo takes you
	back to the design you had before. Quick successive small edits (under
	History.MERGE seconds apart) merge into one step. Everything lives in
	memory only; nothing is written anywhere.
]]

local Deps = require(script.Parent.Deps)

local Fusion = Deps.Fusion
local peek = Fusion.peek

local History = {}
History.__index = History

History.LIMIT = 60
History.MERGE = 0.4

local function deepCopy(value: any): any
	if typeof(value) ~= "table" then
		return value
	end
	local out = {}
	for k, v in value do
		out[k] = deepCopy(v)
	end
	return out
end
History.deepCopy = deepCopy

-- Makes `target` equal to `source` while keeping the identity of every table
-- that exists in both (so expanded rows in the Structure tree stay open).
local function patch(target: { [any]: any }, source: { [any]: any })
	for key in table.clone(target) do
		if source[key] == nil then
			target[key] = nil
		end
	end
	for key, value in source do
		local existing = target[key]
		if typeof(value) == "table" and typeof(existing) == "table" then
			patch(existing, value)
		else
			target[key] = deepCopy(value)
		end
	end
end
History.patch = patch

function History.new(scope: any, store: any): any
	local self = setmetatable({}, History)
	self.store = store
	self.undoStack = {}
	self.redoStack = {}
	self.nextId = 0
	self.ignore = 0
	self.tracked = nil
	self.base = nil
	self.lastEdit = -math.huge
	self.canUndo = scope:Value(false)
	self.canRedo = scope:Value(false)
	self.topKind = scope:Value(nil :: string?)
	self.undoCount = scope:Value(0)
	table.insert(
		scope,
		store:onChanged(function(structural)
			self:onChanged(structural)
		end)
	)
	-- a blueprint restored before we started listening
	if store.blueprint ~= nil then
		self.tracked = store.blueprint
		self.base = deepCopy(store.blueprint)
	end
	return self
end

function History:refresh()
	local top = self.undoStack[#self.undoStack]
	self.canUndo:set(top ~= nil)
	self.canRedo:set(#self.redoStack > 0)
	self.topKind:set(if top then top.kind else nil)
	self.undoCount:set(#self.undoStack)
end

function History:push(entry: { [string]: any })
	self.nextId += 1
	entry.id = self.nextId
	table.insert(self.undoStack, entry)
	while #self.undoStack > History.LIMIT do
		table.remove(self.undoStack, 1)
	end
end

function History:lastId(): number?
	local top = self.undoStack[#self.undoStack]
	return if top then top.id else nil
end

function History:clear()
	table.clear(self.undoStack)
	table.clear(self.redoStack)
	self.tracked = self.store.blueprint
	self.base = if self.store.blueprint then deepCopy(self.store.blueprint) else nil
	self:refresh()
end

function History:onChanged(structural: boolean?)
	if self.ignore > 0 then
		self.ignore -= 1
		return
	end
	local bp = self.store.blueprint
	if bp == nil then
		return
	end
	if bp ~= self.tracked then
		-- a new document replaced the old one
		if self.tracked ~= nil and self.base ~= nil then
			self:push({ kind = "replace", snapshot = self.base })
		end
		self.tracked = bp
		self.base = deepCopy(bp)
		table.clear(self.redoStack)
		self.lastEdit = -math.huge
		self:refresh()
		return
	end
	local now = os.clock()
	local top = self.undoStack[#self.undoStack]
	local merge = not structural
		and not self.lastStructural
		and top ~= nil
		and top.kind == "edit"
		and top.id == self.nextId
		and now - self.lastEdit < History.MERGE
	if not merge then
		self:push({ kind = "edit", snapshot = self.base })
	end
	self.lastEdit = now
	self.lastStructural = structural == true
	self.base = deepCopy(bp)
	table.clear(self.redoStack)
	self:refresh()
end

-- Restores `entry` and returns the step that would reverse it.
function History:apply(entry: { [string]: any }): { [string]: any }
	local store = self.store
	local current = deepCopy(store.blueprint)
	local reverse = { kind = entry.kind, snapshot = current }
	if entry.kind == "replace" or store.blueprint == nil then
		local copy = deepCopy(entry.snapshot)
		self.ignore += 1
		store:setBlueprint(copy, { history = false })
		self.tracked = copy
	else
		patch(store.blueprint, entry.snapshot)
		self.ignore += 1
		store:changed(true)
	end
	-- listeners may run deferred: make sure the flag never leaks into a real edit
	task.defer(function()
		self.ignore = 0
	end)
	self.base = deepCopy(store.blueprint)
	self.lastEdit = -math.huge
	return reverse
end

function History:undo(): boolean
	if peek(self.store.busy) ~= nil then
		return false
	end
	local entry = table.remove(self.undoStack)
	if entry == nil then
		return false
	end
	local reverse = self:apply(entry)
	reverse.id = entry.id
	table.insert(self.redoStack, reverse)
	self:refresh()
	return true
end

function History:redo(): boolean
	if peek(self.store.busy) ~= nil then
		return false
	end
	local entry = table.remove(self.redoStack)
	if entry == nil then
		return false
	end
	local reverse = self:apply(entry)
	self:push(reverse)
	self:refresh()
	return true
end

return History
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="11">
        <Properties>
          <string name="Name">Image</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Image: checks the reference screenshots a user attaches in the Create tab
	before they are sent to the BloxUI server (docs/API.md: options.references,
	at most 2 and 600 KB of file together, whole request at most 1 MB).

		local ok, info = Image.inspect(bytes, "shop.png")
		-- ok: { mediaType = "image/png", width = 1280, height = 720, size = 412345 }
		-- not ok: info is a message to show ("... Crop or resize the screenshot (under 600 KB) and try again.")

	Nothing here decodes or resizes pixels: the format comes from the file's
	magic bytes and the size from its header (PNG IHDR, JPEG SOF, GIF, WebP).
	Base64 grows data by a third, so 600 KB of screenshots is about 800 KB in
	the request, which leaves room for the prompt inside the 1 MB limit.
]]

local Image = {}

Image.LIMITS = {
	count = 2, -- references per request
	bytes = 600 * 1024, -- file bytes: each image, and all of them together
	maxSide = 8000, -- px: the model rejects anything larger
}

-- What the plugin's file picker offers (StudioService:PromptImportFileAsync).
Image.FILE_TYPES = { "png", "jpg", "jpeg" }

local LABELS = { ["image/png"] = "PNG", ["image/jpeg"] = "JPEG", ["image/webp"] = "WebP", ["image/gif"] = "GIF" }

-- "412 KB", "1.2 MB", "900 B"
function Image.formatBytes(size: number): string
	size = math.max(0, math.floor(tonumber(size) or 0))
	if size < 1024 then
		return `{size} B`
	elseif size < 1024 * 1024 then
		return `{math.max(1, math.floor(size / 1024 + 0.5))} KB`
	end
	return string.format("%.1f MB", size / (1024 * 1024))
end

-- "PNG" for "image/png".
function Image.label(mediaType: string?): string
	return LABELS[mediaType or ""] or "image"
end

-- The media type from the file's magic bytes, or nil for anything else.
function Image.sniff(bytes: any): string?
	if typeof(bytes) ~= "string" or #bytes < 12 then
		return nil
	end
	if string.sub(bytes, 1, 8) == "\137PNG\r\n\26\n" then
		return "image/png"
	elseif string.sub(bytes, 1, 3) == "\255\216\255" then
		return "image/jpeg"
	elseif string.sub(bytes, 1, 6) == "GIF87a" or string.sub(bytes, 1, 6) == "GIF89a" then
		return "image/gif"
	elseif string.sub(bytes, 1, 4) == "RIFF" and string.sub(bytes, 9, 12) == "WEBP" then
		return "image/webp"
	end
	return nil
end

local function u16be(bytes: string, at: number): number
	local a, b = string.byte(bytes, at, at + 1)
	return (a or 0) * 256 + (b or 0)
end

local function u16le(bytes: string, at: number): number
	local a, b = string.byte(bytes, at, at + 1)
	return (a or 0) + (b or 0) * 256
end

local function u24le(bytes: string, at: number): number
	local a, b, c = string.byte(bytes, at, at + 2)
	return (a or 0) + (b or 0) * 256 + (c or 0) * 65536
end

local function u32be(bytes: string, at: number): number
	local a, b, c, d = string.byte(bytes, at, at + 3)
	return (a or 0) * 16777216 + (b or 0) * 65536 + (c or 0) * 256 + (d or 0)
end

-- JPEG: walk the segments to the first start-of-frame marker.
local SOF = {
	[0xC0] = true, [0xC1] = true, [0xC2] = true, [0xC3] = true, [0xC5] = true, [0xC6] = true, [0xC7] = true,
	[0xC9] = true, [0xCA] = true, [0xCB] = true, [0xCD] = true, [0xCE] = true, [0xCF] = true,
}
local function jpegSize(bytes: string): (number?, number?)
	local n = #bytes
	local at = 3 -- after FF D8
	local steps = 0
	while at + 3 <= n and steps < 4096 do
		steps += 1
		if string.byte(bytes, at) ~= 0xFF then
			return nil, nil
		end
		-- fill bytes
		while at <= n and string.byte(bytes, at + 1) == 0xFF do
			at += 1
		end
		local marker = string.byte(bytes, at + 1)
		if marker == nil then
			return nil, nil
		end
		if SOF[marker] then
			if at + 8 > n then
				return nil, nil
			end
			return u16be(bytes, at + 7), u16be(bytes, at + 5)
		elseif marker == 0xD8 or marker == 0x01 or (marker >= 0xD0 and marker <= 0xD7) then
			at += 2
		elseif marker == 0xD9 or marker == 0xDA then
			return nil, nil -- end of image / scan data before any frame header
		else
			at += 2 + u16be(bytes, at + 2)
		end
	end
	return nil, nil
end

local function webpSize(bytes: string): (number?, number?)
	local chunk = string.sub(bytes, 13, 16)
	if chunk == "VP8X" and #bytes >= 30 then
		return u24le(bytes, 25) + 1, u24le(bytes, 28) + 1
	elseif chunk == "VP8 " and #bytes >= 30 then
		return u16le(bytes, 27) % 16384, u16le(bytes, 29) % 16384
	elseif chunk == "VP8L" and #bytes >= 25 then
		local b0, b1, b2, b3 = string.byte(bytes, 22, 25)
		local width = 1 + (b1 % 64) * 256 + b0
		local height = 1 + (b3 % 16) * 1024 + b2 * 4 + b1 // 64
		return width, height
	end
	return nil, nil
end

-- Width and height in px from the file header, or nil when it can't be read.
function Image.dimensions(bytes: any, mediaType: string?): (number?, number?)
	if typeof(bytes) ~= "string" then
		return nil, nil
	end
	mediaType = mediaType or Image.sniff(bytes)
	local width, height = nil, nil
	if mediaType == "image/png" then
		if #bytes >= 24 and string.sub(bytes, 13, 16) == "IHDR" then
			width, height = u32be(bytes, 17), u32be(bytes, 21)
		end
	elseif mediaType == "image/jpeg" then
		width, height = jpegSize(bytes)
	elseif mediaType == "image/gif" then
		if #bytes >= 10 then
			width, height = u16le(bytes, 7), u16le(bytes, 9)
		end
	elseif mediaType == "image/webp" then
		width, height = webpSize(bytes)
	end
	if width == nil or height == nil or width <= 0 or height <= 0 then
		return nil, nil
	end
	return width, height
end

local function named(name: any): string
	if typeof(name) == "string" and name ~= "" then
		return `"{name}"`
	end
	return "That image"
end

function Image.tooBigMessage(name: any, size: number, limit: number?): string
	limit = limit or Image.LIMITS.bytes
	return `{named(name)} is {Image.formatBytes(size)}. Crop or resize the screenshot (under {Image.formatBytes(limit :: number)}) and try again.`
end

--[[
	Checks one picked file. Returns true, { mediaType, width?, height?, size }
	or false, a message for the user. `limit` overrides LIMITS.bytes (the
	space left when other references are attached).
]]
function Image.inspect(bytes: any, name: any, limit: number?): (boolean, any)
	if typeof(bytes) ~= "string" or #bytes == 0 then
		return false, `{named(name)} is empty.`
	end
	local mediaType = Image.sniff(bytes)
	if mediaType == nil then
		return false, `{named(name)} isn't a PNG or JPEG image.`
	end
	local total = Image.LIMITS.bytes
	local cap = math.min(limit or total, total)
	if #bytes > total then
		return false, Image.tooBigMessage(name, #bytes, total)
	elseif #bytes > cap then
		-- fits on its own, not next to the references already attached
		return false, `{named(name)} is {Image.formatBytes(#bytes)}, and all references together must stay under {Image.formatBytes(total)}. Crop or resize the screenshot (under {Image.formatBytes(math.max(cap, 0))}) and try again.`
	end
	local width, height = Image.dimensions(bytes, mediaType)
	local side = Image.LIMITS.maxSide
	if width and height and (width > side or height > side) then
		return false, `{named(name)} is {width}×{height} px; the limit is {side}×{side}. Crop or resize the screenshot and try again.`
	end
	return true, { mediaType = mediaType, width = width, height = height, size = #bytes }
end

return Image
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="12">
        <Properties>
          <string name="Name">Installer</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Installer: copies BloxUI into the place and records undo steps.
	Shared by the toolbar buttons (Main.server.luau) and the UI Builder's
	"Insert into game".

		local installer = Installer.new(payload.BloxUI)
		installer:record("Install BloxUI", function()
			installer:install()                      -- the runtime, without the sample blueprints
			installer:install({ samples = true })    -- with them (the Self-Test builds them)
		end)
		installer.version                            -- the runtime version (BloxUI.Version, from version.json)

	The copy in your place is only the public runtime (the payload never
	contains the generator). The ready-made samples (Blueprint.Samples, about
	30 blueprints) are left out by default: a game doesn't need them, and
	everything in ReplicatedStorage is sent to every player.
]]

local ChangeHistoryService = game:GetService("ChangeHistoryService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local StarterPlayer = game:GetService("StarterPlayer")

local Installer = {}
Installer.__index = Installer

-- The runtime version from BloxUI.Version (stamped by build.ps1); "?" when unknown.
local function versionOf(source: Instance): string
	local module = source:FindFirstChild("Version")
	if module and module:IsA("ModuleScript") then
		local ok, data = pcall(require, module)
		if ok and typeof(data) == "table" and typeof(data.runtime) == "string" then
			return data.runtime
		end
	end
	local ok, text = pcall(function()
		return (source :: any).Source
	end)
	return (ok and typeof(text) == "string" and string.match(text, 'version%s*=%s*"([^"]+)"')) or "?"
end
Installer.versionOf = versionOf

function Installer.new(source: Instance): any
	local self = setmetatable({}, Installer)
	self.source = source
	self.version = versionOf(source)
	return self
end

-- Runs `action` inside one ChangeHistoryService recording (one Ctrl+Z).
-- Returns ok, err. Never throws.
function Installer:record(name: string, action: () -> ()): (boolean, string?)
	local began, recording = pcall(ChangeHistoryService.TryBeginRecording, ChangeHistoryService, name)
	local ok, err = pcall(action)
	if began and recording then
		ChangeHistoryService:FinishRecording(
			recording,
			if ok then Enum.FinishRecordingOperation.Commit else Enum.FinishRecordingOperation.Cancel
		)
	elseif ok then
		ChangeHistoryService:SetWaypoint(name)
	end
	if not ok then
		warn(`[BloxUI] {name} failed: {err}`)
	end
	return ok, if ok then nil else tostring(err)
end

-- The sample blueprint modules inside a BloxUI copy (Blueprint.Samples.<genre>_<seed>).
function Installer.sampleModules(copy: Instance): { Instance }
	local out = {}
	local blueprint = copy:FindFirstChild("Blueprint")
	local samples = blueprint and blueprint:FindFirstChild("Samples")
	if samples then
		for _, child in samples:GetChildren() do
			if child:IsA("ModuleScript") and string.match(child.Name, "^%a+_%d+$") then
				table.insert(out, child)
			end
		end
	end
	return out
end

-- Does this BloxUI copy carry the sample blueprints?
function Installer.hasSamples(copy: Instance?): boolean
	return copy ~= nil and #Installer.sampleModules(copy) > 0
end

--[[
	Copies BloxUI into ReplicatedStorage, replacing an older copy (undoably).
	options.samples = true keeps Blueprint.Samples' blueprints in the copy.
]]
function Installer:install(options: { samples: boolean? }?): Instance
	local existing = ReplicatedStorage:FindFirstChild("BloxUI")
	local copy = self.source:Clone()
	if not (options and options.samples) then
		for _, module in Installer.sampleModules(copy) do
			module:Destroy()
		end
	end
	copy:SetAttribute("BloxUIVersion", self.version)
	if existing then
		existing.Parent = nil -- undoable, unlike Destroy()
	end
	copy.Parent = ReplicatedStorage
	return copy
end

-- StarterPlayer.StarterPlayerScripts (it can't be created by scripts, so a
-- place without it gets a clear error instead).
function Installer:starterScripts(): Instance
	local scripts = StarterPlayer:FindFirstChildOfClass("StarterPlayerScripts")
	if scripts == nil then
		local ok, found = pcall(function()
			return StarterPlayer:WaitForChild("StarterPlayerScripts", 2)
		end)
		if ok and typeof(found) == "Instance" then
			scripts = found
		end
	end
	if scripts == nil then
		error("StarterPlayer has no StarterPlayerScripts folder. Save and reopen the place (Studio adds it back), then try again.", 0)
	end
	return scripts :: Instance
end

return Installer
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="13">
        <Properties>
          <string name="Name">LayoutCards</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	LayoutCards: HUD layouts as picture cards. Each card shows a mini 16:9
	screen with coloured blocks where the layout puts the cash, menu, packs,
	teleports and health (Catalog.layoutDiagrams).

		LayoutCards.diagram(scope, "Simulator", { Width = 48 })     -- just the mini screen
		LayoutCards.card(scope, { Name, Value = "Dock" | state, Label, Selected = state, OnClick, OnHover })
		LayoutCards.grid(scope, { Cards = {...}, Columns = 3 | state })   -- rows of equal cards
		LayoutCards.pick(app, anchorButton, { values = {...}, value = "Classic", title = "HUD layout", onSelect = fn })
		LayoutCards.values()          -- every layout the pickers offer, in order
		LayoutCards.describe("Dock")  -- "<b>Dock</b>: Menu as a dock at the bottom centre." (RichText)

	"Auto" draws a sparkle (BloxUI picks for the genre), "More" three dots and
	a layout without a diagram a generic grid.
]]

local Deps = require(script.Parent.Deps)
local Style = require(script.Parent.Style)
local Ui = require(script.Parent.Ui)
local Catalog = require(script.Parent.Catalog)

local Fusion = Deps.Fusion
local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local C = Style.Color
local R = Style.Radius

local LayoutCards = {}

LayoutCards.CARD_HEIGHT = 76 -- 5 + 36 (screen) + 4 + 26 (two label lines) + 5
LayoutCards.GAP = 6
LayoutCards.MIN_CARD = 84 -- narrowest card that still fits "Battlegrounds" on a line

-- The mini screen is a game view whatever the widget's theme.
local SCREEN_TOP = Color3.fromRGB(46, 52, 69)
local SCREEN_BOTTOM = Color3.fromRGB(28, 32, 43)
local ICONS = {
	Auto = { icon = "cursor-stars", color = Color3.fromRGB(196, 184, 255) },
	More = { icon = "three-dots-horizontal", color = Color3.fromRGB(167, 170, 179) },
}

--[[
	The mini screen for a layout ("Auto", "More" or a HudLayout).
	props: Width (px, default 64; the height follows 16:9), Selected (state:
	a check in the corner), Name, LayoutOrder.
]]
function LayoutCards.diagram(scope: any, value: string?, props: { [string]: any }?): Frame
	props = props or {}
	local width = props.Width or 64
	local height = math.floor(width * 9 / 16 + 0.5)
	local small = width < 56
	local children = {}
	local blocks = if value then Catalog.layoutDiagrams[value] else nil
	if blocks then
		for _, block in blocks do
			table.insert(children, scope:New "Frame" {
				Name = "Block_" .. block.kind,
				BackgroundColor3 = Color3.fromHex(Catalog.layoutColors[block.kind] or "#FFFFFF"),
				BackgroundTransparency = block.alpha or 0,
				BorderSizePixel = 0,
				Position = UDim2.fromScale(block.x / 64, block.y / 36),
				Size = UDim2.fromScale(block.w / 64, block.h / 36),
				ZIndex = 2,
				[Children] = scope:New "UICorner" {
					CornerRadius = if block.pill then UDim.new(0.5, 0) else UDim.new(0, if small then 1 else 2),
				},
			})
		end
	else
		local info = ICONS[value or ""] or { icon = "four-rectangles-mason-grid", color = Color3.fromRGB(167, 170, 179) }
		table.insert(children, Ui.icon(scope, {
			Name = "Glyph",
			Icon = info.icon,
			Size = math.max(10, math.floor(height * 0.5)),
			Bold = true,
			Color = info.color,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			ZIndex = 2,
		}))
	end
	if props.Selected ~= nil then
		table.insert(children, scope:New "Frame" {
			Name = "Check",
			AnchorPoint = Vector2.new(1, 0),
			Position = UDim2.new(1, -2, 0, 2),
			Size = UDim2.fromOffset(12, 12),
			BackgroundColor3 = C.Accent,
			BorderSizePixel = 0,
			ZIndex = 4,
			Visible = props.Selected,
			[Children] = {
				Ui.corner(scope, R.Pill),
				Ui.icon(scope, {
					Icon = "check-small",
					Size = 11,
					Bold = true,
					Color = C.White,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					ZIndex = 5,
				}),
			},
		})
	end
	return scope:New "Frame" {
		Name = props.Name or "Diagram",
		BackgroundColor3 = C.White,
		BorderSizePixel = 0,
		Size = UDim2.fromOffset(width, height),
		LayoutOrder = props.LayoutOrder or 1,
		ClipsDescendants = true,
		[Children] = {
			Ui.corner(scope, if small then 3 else 4),
			scope:New "UIGradient" { Rotation = 90, Color = ColorSequence.new(SCREEN_TOP, SCREEN_BOTTOM) },
			children,
		},
	}
end

--[[
	One picture card: the mini screen over a (two-line) label. Its size comes
	from the grid it sits in.
	props: Name, Value (a layout or a state of one), Label (text or state),
	Selected (state), OnClick, OnHover(bool), Tooltip (text or state),
	LayoutOrder, Disabled.
]]
function LayoutCards.card(scope: any, props: { [string]: any }): TextButton
	local selected = props.Selected
	local disabled = props.Disabled
	local state, events = Ui.interact(scope, disabled)
	local isSelected = scope:Computed(function(use)
		return selected ~= nil and use(selected) == true
	end)
	local picture
	if typeof(props.Value) == "table" then
		picture = scope:Computed(function(use, inner)
			return LayoutCards.diagram(inner, use(props.Value), { Selected = isSelected })
		end)
	else
		picture = LayoutCards.diagram(scope, props.Value, { Selected = isSelected })
	end
	local button = scope:New "TextButton" (Ui.merge(events, {
		Name = props.Name or "LayoutCard",
		Text = "",
		AutoButtonColor = false,
		BorderSizePixel = 0,
		Size = UDim2.fromOffset(LayoutCards.MIN_CARD, LayoutCards.CARD_HEIGHT),
		LayoutOrder = props.LayoutOrder or 0,
		Selectable = false,
		Active = scope:Computed(function(use)
			return not (disabled ~= nil and use(disabled))
		end),
		BackgroundColor3 = scope:Computed(function(use)
			if use(isSelected) then
				return C.AccentSoft
			end
			local s = use(state)
			return if s == "hover" or s == "press" then C.RaisedHover else C.Raised
		end),
		[OnEvent "Activated"] = function()
			if disabled ~= nil and peek(disabled) then
				return
			end
			if props.OnClick then
				props.OnClick()
			end
		end,
		[Children] = {
			Ui.corner(scope, R.M),
			Ui.stroke(scope, scope:Computed(function(use)
				if use(isSelected) then
					return C.Accent
				end
				return if use(state) == "hover" then C.BorderStrong else C.Border
			end), scope:Computed(function(use)
				return if use(isSelected) then 1.5 else 1
			end)),
			Ui.padding(scope, 5, 3, 5, 3),
			Ui.list(scope, "Y", 4, "Center", "Top"),
			picture,
			Ui.text(scope, {
				Name = "Label",
				Text = props.Label,
				TextSize = 11,
				Font = scope:Computed(function(use)
					return if use(isSelected) then Style.Font.SemiBold else Style.Font.Medium
				end),
				Color = scope:Computed(function(use)
					if use(isSelected) then
						return C.AccentText
					end
					return if use(state) == "hover" then C.Text else C.TextSub
				end),
				XAlign = Enum.TextXAlignment.Center,
				Wrap = true,
				Truncate = true,
				LineHeight = 1.05,
				Size = UDim2.new(1, 0, 0, 26),
				LayoutOrder = 2,
			}),
		},
	}))
	if props.Tooltip then
		Ui.tooltip(scope, button, props.Tooltip)
	end
	if props.OnHover then
		table.insert(scope, button.MouseEnter:Connect(function()
			props.OnHover(true)
		end))
		table.insert(scope, button.MouseLeave:Connect(function()
			props.OnHover(false)
		end))
	end
	return button
end

-- Columns that fit `width` px (at most `count`, at least `least`).
function LayoutCards.columnsFor(width: number, count: number, least: number?): number
	local fit = math.floor((width + LayoutCards.GAP) / (LayoutCards.MIN_CARD + LayoutCards.GAP))
	return math.clamp(fit, least or 1, math.max(least or 1, count))
end

--[[
	Rows of equal cards. props: Cards (the card instances), Columns (a number
	or a state), Name, LayoutOrder, Visible.
]]
function LayoutCards.grid(scope: any, props: { [string]: any }): Frame
	local count = #props.Cards
	local columns = props.Columns or 3
	local gap = LayoutCards.GAP
	local height = LayoutCards.CARD_HEIGHT
	return scope:New "Frame" {
		Name = props.Name or "LayoutCards",
		BackgroundTransparency = 1,
		LayoutOrder = props.LayoutOrder or 0,
		Visible = if props.Visible ~= nil then props.Visible else true,
		Size = scope:Computed(function(use)
			local cols = math.max(1, use(columns))
			local rows = math.max(1, math.ceil(count / cols))
			return UDim2.new(1, 0, 0, rows * height + (rows - 1) * gap)
		end),
		[Children] = {
			scope:New "UIGridLayout" {
				CellSize = scope:Computed(function(use)
					local cols = math.max(1, use(columns))
					return UDim2.new(1 / cols, -math.ceil(gap * (cols - 1) / cols), 0, height)
				end),
				CellPadding = UDim2.fromOffset(gap, gap),
				FillDirection = Enum.FillDirection.Horizontal,
				HorizontalAlignment = Enum.HorizontalAlignment.Left,
				VerticalAlignment = Enum.VerticalAlignment.Top,
				SortOrder = Enum.SortOrder.LayoutOrder,
			},
			props.Cards,
		},
	}
end

-- Every layout the pickers offer (Catalog.hudLayouts, plus any the payload's schema added).
function LayoutCards.values(): { string }
	local out = {}
	for _, raw in Catalog.hudLayouts do
		table.insert(out, Ui.normalizeOption(raw).value)
	end
	return out
end

-- "<b>Label</b>: hint." for a hint line (RichText).
function LayoutCards.describe(value: string?): string
	local info = Catalog.hudLayoutInfo(value)
	if info.hint and info.hint ~= "" then
		return `<b>{info.label}</b>: {info.hint}.`
	end
	return `<b>{info.label}</b>`
end

--[[
	A popover of layout cards under `anchor` (the visual version of a
	dropdown). Its header names the card under the mouse. opts: values
	(layouts, in order), value (the current one), title, onSelect(value),
	width (px, default 384).
]]
function LayoutCards.pick(app: any, anchor: GuiObject?, opts: { [string]: any })
	local overlay = app.overlay
	local values = opts.values or {}
	local width = math.min(opts.width or 384, overlay:maxWidth())
	local pad = 8
	local columns = LayoutCards.columnsFor(width - pad * 2, #values, 3)
	local rows = math.max(1, math.ceil(#values / columns))
	local gridHeight = rows * LayoutCards.CARD_HEIGHT + (rows - 1) * LayoutCards.GAP
	local titleHeight = 40
	overlay:open({
		name = "LayoutPicker",
		anchor = anchor,
		width = width,
		height = titleHeight + gridHeight + pad,
		onClose = opts.onClose,
		build = function(scope, close)
			local hovered = scope:Value(nil :: string?)
			local cards = {}
			for index, value in values do
				table.insert(cards, LayoutCards.card(scope, {
					Name = "Layout_" .. value,
					Value = value,
					Label = Catalog.hudLayoutInfo(value).label,
					Selected = value == opts.value,
					LayoutOrder = index,
					OnHover = function(over)
						if over then
							hovered:set(value)
						elseif peek(hovered) == value then
							hovered:set(nil)
						end
					end,
					OnClick = function()
						close()
						if opts.onSelect then
							opts.onSelect(value)
						end
					end,
				}))
			end
			return {
				Ui.padding(scope, 0, pad, pad, pad),
				Ui.list(scope, "Y", 0),
				scope:New "Frame" {
					Name = "Title",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, 0, 0, titleHeight),
					LayoutOrder = 1,
					[Children] = {
						Ui.padding(scope, 0, 4, 0, 4),
						Ui.text(scope, {
							Name = "Hint",
							Text = scope:Computed(function(use)
								local value = use(hovered)
								if value then
									return LayoutCards.describe(value)
								end
								return `<b>{opts.title or "HUD layout"}</b>: where the cash, menu and buttons go.`
							end),
							RichText = true,
							TextSize = Style.Text.Caption,
							Color = C.TextSub,
							Wrap = true,
							Truncate = true,
							LineHeight = 1.1,
							Size = UDim2.fromScale(1, 1),
						}),
					},
				},
				LayoutCards.grid(scope, { Name = "Cards", Cards = cards, Columns = columns, LayoutOrder = 2 }),
			}
		end,
	})
end

return LayoutCards
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="14">
        <Properties>
          <string name="Name">Modules</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Modules: loads the parts of the BloxUI payload the builder drives, defensively.

		local modules = Modules.load(Deps.BloxUI)
		modules.UI          -- the BloxUI library (nil if it failed to load)
		modules.Blueprint   -- the blueprint builder (validate / build / encode / decode)
		modules.Samples     -- Blueprint.Samples: ready-made blueprints per genre (may be nil)
		modules.missing     -- { "Blueprint", ... } names that could not be loaded
		modules.errors      -- { Blueprint = "reason" }
		modules.version     -- the runtime version ("2.0.0", "?" when unknown)

	Generating (the prompt improver, the generator, Claude) happens on the
	BloxUI server (Remote / Cloud), so none of that is looked for here: the
	payload is only the public runtime. Nothing here ever throws.
]]

local Modules = {}

local REQUIRED = {
	Blueprint = { "validate", "build" },
}

local function hasFunctions(module: any, names: { string }): boolean
	if typeof(module) ~= "table" then
		return false
	end
	for _, name in names do
		if typeof(module[name]) ~= "function" then
			return false
		end
	end
	return true
end

function Modules.load(bloxui: Instance): { [string]: any }
	local result: { [string]: any } = { missing = {}, errors = {} }

	local okUI, UI = pcall(require, bloxui)
	if okUI and typeof(UI) == "table" then
		result.UI = UI
	else
		result.errors.BloxUI = tostring(UI)
	end

	for _, name in { "Blueprint" } do
		local module = nil
		if result.UI then
			local ok, value = pcall(function()
				return result.UI[name]
			end)
			if ok then
				module = value
			else
				result.errors[name] = tostring(value)
			end
		end
		if module == nil and result.errors[name] == nil then
			local child = bloxui:FindFirstChild(name)
			if child and child:IsA("ModuleScript") then
				local ok, value = pcall(require, child)
				if ok then
					module = value
				else
					result.errors[name] = tostring(value)
				end
			end
		end
		if module ~= nil and not hasFunctions(module, REQUIRED[name]) then
			result.errors[name] = `{name} is missing {table.concat(REQUIRED[name], "/")}`
			module = nil
		end
		result[name] = module
		if module == nil then
			table.insert(result.missing, name)
		end
	end

	-- the samples ("Start from a sample"): Blueprint.Samples, loaded lazily
	if result.Blueprint then
		local ok, samples = pcall(function()
			return result.Blueprint.Samples
		end)
		if ok and typeof(samples) == "table" and typeof(samples.list) == "function" and typeof(samples.get) == "function" then
			result.Samples = samples
		end
	end

	result.version = if result.UI and result.UI.version then tostring(result.UI.version) else "?"
	return result
end

return Modules
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="15">
        <Properties>
          <string name="Name">Overlay</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Overlay: everything that floats above the widget content.

		local overlay = Overlay.new(scope, rootFrame)
		overlay:menu(anchorButton, { options = {...}, value = "Arena", onSelect = fn, searchable = true })
		overlay:iconPicker(anchor, { value = "coin", onSelect = fn })
		overlay:colorPicker(anchor, { value = "#FFB020", palette = {...}, onSelect = fn })
		overlay:toast("Inserted NeonStrike", "success")
		overlay:confirm({ title = "Delete?", body = "...", confirm = "Delete", danger = true, onConfirm = fn })
		overlay:showTooltip(gui, "Duplicate")  overlay:hideTooltip(gui)
		local entry = overlay:open({ name, modal, width, height, build = fn(scope, close), onClose })
		overlay:resize(entry, newHeight)   -- e.g. a dialog whose content grew
		overlay:close()

	Only one menu / popover / dialog is open at a time. Clicking outside closes it.
]]

local Deps = require(script.Parent.Deps)
local Style = require(script.Parent.Style)
local Ui = require(script.Parent.Ui)

local Fusion = Deps.Fusion
local Icons = Deps.Icons
local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local C = Style.Color
local R = Style.Radius

local Overlay = {}
Overlay.__index = Overlay

local ROW = 30
local ROW_HINT = 42
local GROUP_ROW = 24

-- Curated colours for the colour popover (6 rows x 8).
local SWATCHES = {
	"#FFFFFF", "#E5E7EB", "#9CA3AF", "#6B7280", "#374151", "#1F2937", "#111827", "#000000",
	"#FF4D5E", "#FF7A1A", "#FFB020", "#FFD23F", "#A3E635", "#3DDC84", "#2DD4BF", "#22D3EE",
	"#3AA8FF", "#4C6FFF", "#7C5CFF", "#A55CFF", "#FF6FB5", "#FF2BD6", "#C1121F", "#8B5A2B",
	"#FFE4E6", "#FFEDD5", "#FEF9C3", "#DCFCE7", "#CFFAFE", "#DBEAFE", "#EDE9FE", "#FCE7F3",
	"#7F1D1D", "#7C2D12", "#713F12", "#14532D", "#134E4A", "#1E3A8A", "#3B0764", "#831843",
	"#0D0B1F", "#141A26", "#1B2447", "#2A1B3D", "#10231C", "#FFF4FB", "#FFF8E7", "#F0F9FF",
}

function Overlay.new(scope: any, root: GuiObject): any
	local self = setmetatable({}, Overlay)
	self.scope = scope
	self.root = root
	self.bottomInset = 56
	self.current = nil -- { scope, onClose }
	self.tooltipOwner = nil

	self.layer = scope:New "Frame" {
		Name = "Overlay",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		ZIndex = 100,
		Parent = root,
	}

	self.tooltipText = scope:Value("")
	self.tooltipVisible = scope:Value(false)
	self.tooltipPosition = scope:Value(UDim2.new())
	self.tooltip = scope:New "Frame" {
		Name = "Tooltip",
		BackgroundColor3 = C.Tooltip,
		AutomaticSize = Enum.AutomaticSize.XY,
		Size = UDim2.new(),
		Position = self.tooltipPosition,
		AnchorPoint = Vector2.new(0.5, 0),
		Visible = self.tooltipVisible,
		ZIndex = 30,
		Parent = self.layer,
		[Children] = {
			Ui.corner(scope, R.S),
			Ui.stroke(scope, C.BorderStrong, 1, if Style.isDark() then 0 else 1),
			Ui.padding(scope, 5, 8),
			Ui.text(scope, {
				Text = self.tooltipText,
				TextSize = Style.Text.Caption,
				Weight = "Medium",
				Color = C.TooltipText,
			}),
		},
	}

	self.toasts = scope:New "Frame" {
		Name = "Toasts",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -(self.bottomInset + 8)),
		Size = UDim2.new(1, -24, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		ZIndex = 40,
		Parent = self.layer,
		[Children] = {
			Ui.list(scope, "Y", 6, "Center", "Bottom"),
		},
	}
	self.toastCount = 0
	return self
end

function Overlay:setBottomInset(inset: number)
	self.bottomInset = inset
	self.toasts.Position = UDim2.new(0.5, 0, 1, -(inset + 8))
end

--------------------------------------------------------------------------
-- positioning
--------------------------------------------------------------------------

function Overlay:place(anchor: GuiObject?, width: number, height: number, gap: number?): UDim2
	local rootPos = self.root.AbsolutePosition
	local rootSize = self.root.AbsoluteSize
	gap = gap or 4
	if anchor == nil or anchor.Parent == nil then
		return UDim2.fromOffset(math.max(8, (rootSize.X - width) / 2), math.max(8, (rootSize.Y - height) / 2))
	end
	local aPos = anchor.AbsolutePosition - rootPos
	local aSize = anchor.AbsoluteSize
	local maxX = math.max(8, rootSize.X - width - 8)
	local x = math.clamp(aPos.X, 8, maxX)
	local below = aPos.Y + aSize.Y + gap
	local y
	if below + height <= rootSize.Y - 8 then
		y = below
	elseif aPos.Y - gap - height >= 8 then
		y = aPos.Y - gap - height
	else
		y = math.clamp(below, 8, math.max(8, rootSize.Y - height - 8))
	end
	return UDim2.fromOffset(math.floor(x), math.floor(y))
end

function Overlay:maxWidth(): number
	return math.max(160, self.root.AbsoluteSize.X - 16)
end

--------------------------------------------------------------------------
-- open / close
--------------------------------------------------------------------------

function Overlay:close()
	local current = self.current
	if current == nil then
		return
	end
	self.current = nil
	if current.onClose then
		task.spawn(current.onClose)
	end
	Ui.cleanup(current.scope, current.life)
end

function Overlay:isOpen(): boolean
	return self.current ~= nil
end

-- Opens a floating panel. build(innerScope, close) returns the panel's children.
function Overlay:open(opts: { [string]: any }): any
	self:close()
	self:hideTooltip()
	local scope, life = Ui.innerScope(self.scope)
	local entry = { scope = scope, life = life, onClose = opts.onClose }
	self.current = entry
	local function close()
		if self.current == entry then
			self:close()
		end
	end
	local width = math.min(opts.width or 240, self:maxWidth())
	local height = opts.height or 200
	local position = if opts.modal
		then UDim2.fromScale(0.5, 0.5)
		else self:place(opts.anchor, width, height, opts.gap)

	scope:New "TextButton" {
		Name = if opts.modal then "Backdrop" else "Dismiss",
		Text = "",
		AutoButtonColor = false,
		BackgroundColor3 = C.Black,
		BackgroundTransparency = if opts.modal then Style.Tone.backdrop else 1,
		Size = UDim2.fromScale(1, 1),
		ZIndex = 10,
		Parent = self.layer,
		[OnEvent "Activated"] = close,
	}
	local panel = scope:New "Frame" {
		Name = opts.name or "Popover",
		BackgroundColor3 = C.Popover,
		Size = UDim2.fromOffset(width, height),
		Position = position,
		AnchorPoint = if opts.modal then Vector2.new(0.5, 0.5) else Vector2.zero,
		ZIndex = 20,
		Active = true,
		Parent = self.layer,
		[Children] = {
			Ui.corner(scope, R.L),
			Ui.stroke(scope, C.BorderStrong),
			opts.build(scope, close, width, height),
		},
	}
	-- soft drop shadow
	local shadow = scope:New "Frame" {
		Name = "Shadow",
		BackgroundColor3 = C.Black,
		BackgroundTransparency = Style.Tone.shadow,
		Size = UDim2.fromOffset(width + 4, height + 4),
		Position = panel.Position + UDim2.fromOffset(-2, 4),
		AnchorPoint = panel.AnchorPoint,
		ZIndex = 19,
		Parent = self.layer,
		[Children] = Ui.corner(scope, R.XL),
	}
	entry.panel = panel
	entry.shadow = shadow
	return entry
end

-- Changes the height of the open panel `entry` (and its shadow). A modal
-- panel stays centred; an anchored one keeps its top edge.
function Overlay:resize(entry: any, height: number)
	if entry == nil or self.current ~= entry or entry.panel == nil then
		return
	end
	local width = entry.panel.Size.X.Offset
	entry.panel.Size = UDim2.fromOffset(width, height)
	if entry.shadow then
		entry.shadow.Size = UDim2.fromOffset(width + 4, height + 4)
	end
end

--------------------------------------------------------------------------
-- menus
--------------------------------------------------------------------------

local function matches(option: any, query: string): boolean
	if query == "" then
		return true
	end
	local haystack = string.lower(tostring(option.label or "") .. " " .. tostring(option.value or "") .. " " .. tostring(option.hint or "") .. " " .. tostring(option.group or ""))
	return string.find(haystack, query, 1, true) ~= nil
end

--[[
	overlay:menu(anchor, {
		options = { "A", "B" } | {{ value, label, hint, icon, color, font, group }},
		value = current, onSelect = fn(value), searchable = bool?, width = px?, title = "..."?
	})
]]
function Overlay:menu(anchor: GuiObject?, opts: { [string]: any })
	local options = {}
	for _, raw in opts.options or {} do
		table.insert(options, Ui.normalizeOption(raw))
	end
	local hasHints = false
	local groups = {}
	for _, option in options do
		if option.hint then
			hasHints = true
		end
		if option.group then
			groups[option.group] = true
		end
	end
	local rowHeight = if hasHints then ROW_HINT else ROW
	local searchable = opts.searchable
	if searchable == nil then
		searchable = #options > 10
	end
	local contentHeight = 0
	local lastGroup = nil
	for _, option in options do
		if option.group and option.group ~= lastGroup then
			contentHeight += GROUP_ROW
			lastGroup = option.group
		end
		contentHeight += rowHeight
	end
	local searchHeight = if searchable then 44 else 0
	local titleHeight = if opts.title then 28 else 0
	local listHeight = math.min(contentHeight + 8, opts.maxHeight or 300)
	local height = listHeight + searchHeight + titleHeight
	local anchorWidth = if anchor then anchor.AbsoluteSize.X else 0
	local width = math.max(opts.width or 0, anchorWidth, 180)

	self:open({
		name = "Menu",
		anchor = anchor,
		width = width,
		height = height,
		onClose = opts.onClose,
		build = function(scope, close, _, _)
			local query = scope:Value("")
			local rows = scope:Computed(function(use, inner)
				local q = string.lower(use(query))
				local out = {}
				local order = 0
				local currentGroup = nil
				for _, option in options do
					if matches(option, q) then
						if option.group and option.group ~= currentGroup then
							currentGroup = option.group
							order += 1
							table.insert(out, inner:New "Frame" {
								Name = "Group",
								BackgroundTransparency = 1,
								Size = UDim2.new(1, 0, 0, GROUP_ROW),
								LayoutOrder = order,
								[Children] = {
									Ui.padding(inner, 6, 10, 0, 10),
									Ui.text(inner, {
										Text = string.upper(option.group),
										TextSize = 11,
										Weight = "SemiBold",
										Color = C.TextMuted,
									}),
								},
							})
						end
						order += 1
						local selected = option.value == opts.value
						local state, events = Ui.interact(inner, option.disabled)
						table.insert(out, inner:New "TextButton" (Ui.merge(events, {
							Name = "Option_" .. tostring(option.value),
							Text = "",
							AutoButtonColor = false,
							BorderSizePixel = 0,
							Size = UDim2.new(1, 0, 0, rowHeight),
							LayoutOrder = order,
							BackgroundColor3 = inner:Computed(function(use2)
								local s = use2(state)
								return if s == "hover" or s == "press" then C.RaisedHover else C.Raised
							end),
							BackgroundTransparency = inner:Computed(function(use2)
								local s = use2(state)
								return if s == "hover" or s == "press" then 0 else (if selected then 0.4 else 1)
							end),
							[OnEvent "Activated"] = function()
								if option.disabled and peek(option.disabled) then
									return
								end
								close()
								if opts.onSelect then
									opts.onSelect(option.value)
								end
							end,
							[Children] = {
								Ui.corner(inner, R.S),
								Ui.padding(inner, 0, 8, 0, 8),
								Ui.list(inner, "X", 8, "Left", "Center"),
								if option.color
									then Ui.swatch(inner, { Color = option.color, Size = 14, LayoutOrder = 1 })
									elseif option.icon then Ui.icon(inner, { Icon = option.icon, Size = 16, Color = C.TextSub, LayoutOrder = 1 })
									else nil,
								Ui.column(inner, {
									Gap = 2,
									LayoutOrder = 2,
									Size = UDim2.new(0, 0, 0, 0),
									Flex = "Fill",
									[Children] = {
										Ui.text(inner, {
											Text = option.label or tostring(option.value),
											Font = option.font or (if selected then Style.Font.SemiBold else Style.Font.Regular),
											TextSize = Style.Text.Body,
											Color = if option.disabled and peek(option.disabled) then C.TextDisabled else C.Text,
											Fill = true,
											Truncate = true,
											AutomaticSize = Enum.AutomaticSize.Y,
											LayoutOrder = 1,
										}),
										if option.hint
											then Ui.text(inner, {
												Text = option.hint,
												TextSize = Style.Text.Caption,
												Color = C.TextMuted,
												Fill = true,
												Truncate = true,
												AutomaticSize = Enum.AutomaticSize.Y,
												LayoutOrder = 2,
											})
											else nil,
									},
								}),
								if selected
									then Ui.icon(inner, { Icon = "check-small", Size = 16, Bold = true, Color = C.Accent, LayoutOrder = 3 })
									else nil,
							},
						})))
					end
				end
				if #out == 0 then
					table.insert(out, Ui.text(inner, {
						Text = "No matches",
						TextSize = Style.Text.Small,
						Color = C.TextMuted,
						Size = UDim2.new(1, 0, 0, ROW),
						XAlign = Enum.TextXAlignment.Center,
					}))
				end
				return out
			end)

			return {
				Ui.list(scope, "Y", 0),
				if opts.title
					then scope:New "Frame" {
						Name = "Title",
						BackgroundTransparency = 1,
						Size = UDim2.new(1, 0, 0, titleHeight),
						LayoutOrder = 0,
						[Children] = {
							Ui.padding(scope, 10, 12, 0, 12),
							Ui.text(scope, { Text = opts.title, TextSize = Style.Text.Caption, Weight = "SemiBold", Color = C.TextMuted }),
						},
					}
					else nil,
				if searchable
					then scope:New "Frame" {
						Name = "Search",
						BackgroundTransparency = 1,
						Size = UDim2.new(1, 0, 0, searchHeight),
						LayoutOrder = 1,
						[Children] = {
							Ui.padding(scope, 8, 8, 4, 8),
							Ui.input(scope, {
								Name = "SearchBox",
								Value = query,
								Placeholder = "Search...",
								Icon = "magnifying-glass",
								Height = 30,
								OnInput = function(text)
									query:set(text)
								end,
								OnCommit = function(text, enter)
									if not enter then
										return
									end
									-- Enter picks the first match
									local q = string.lower(text)
									for _, option in options do
										if matches(option, q) and not (option.disabled and peek(option.disabled)) then
											close()
											if opts.onSelect then
												opts.onSelect(option.value)
											end
											return
										end
									end
								end,
								BoxRef = function(box)
									task.defer(function()
										if box.Parent ~= nil then
											pcall(box.CaptureFocus, box)
										end
									end)
								end,
							}),
						},
					}
					else nil,
				scope:New "ScrollingFrame" {
					Name = "List",
					BackgroundTransparency = 1,
					BorderSizePixel = 0,
					Size = UDim2.new(1, 0, 0, listHeight),
					LayoutOrder = 2,
					CanvasSize = UDim2.new(),
					AutomaticCanvasSize = Enum.AutomaticSize.Y,
					ScrollingDirection = Enum.ScrollingDirection.Y,
					ScrollBarThickness = 4,
					ScrollBarImageColor3 = C.BorderStrong,
					VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
					TopImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
					MidImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
					BottomImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
					[Children] = {
						Ui.padding(scope, 4),
						Ui.list(scope, "Y", 0),
						rows,
					},
				},
			}
		end,
	})
end

--------------------------------------------------------------------------
-- icon picker
--------------------------------------------------------------------------

local MAX_ICON_RESULTS = 160

function Overlay.searchIcons(query: string): { string }
	query = string.lower(query or "")
	local out = {}
	local seen = {}
	local function add(name)
		if not seen[name] and #out < MAX_ICON_RESULTS then
			seen[name] = true
			table.insert(out, name)
		end
	end
	if query == "" then
		-- friendly aliases first (they are the most useful for games)
		local aliases = {}
		for alias in Icons.alias do
			table.insert(aliases, alias)
		end
		table.sort(aliases)
		for _, alias in aliases do
			add(alias)
		end
		return out
	end
	local aliases = {}
	for alias, target in Icons.alias do
		if string.find(alias, query, 1, true) or string.find(target, query, 1, true) then
			table.insert(aliases, alias)
		end
	end
	table.sort(aliases)
	for _, alias in aliases do
		add(alias)
	end
	for _, name in Icons.list do
		if string.find(name, query, 1, true) then
			add(name)
		end
	end
	return out
end

function Overlay:iconPicker(anchor: GuiObject?, opts: { [string]: any })
	local width = math.min(304, self:maxWidth())
	local height = 360
	self:open({
		name = "IconPicker",
		anchor = anchor,
		width = width,
		height = height,
		onClose = opts.onClose,
		build = function(scope, close, w, _)
			local query = scope:Value("")
			local hoveredName = scope:Value(opts.value or "")
			local results = scope:Computed(function(use)
				return Overlay.searchIcons(use(query))
			end)
			local cell = 34
			local columns = math.max(4, math.floor((w - 24 + 4) / (cell + 4)))
			local grid = scope:Computed(function(use, inner)
				local out = {}
				for index, name in use(results) do
					local state, events = Ui.interact(inner)
					local selected = name == opts.value
					local button = inner:New "TextButton" (Ui.merge(events, {
						Name = name,
						Text = "",
						AutoButtonColor = false,
						LayoutOrder = index,
						BackgroundColor3 = inner:Computed(function(use2)
							if selected then
								return C.AccentSoft
							end
							local s = use2(state)
							return if s == "hover" or s == "press" then C.RaisedHover else C.Raised
						end),
						BackgroundTransparency = inner:Computed(function(use2)
							if selected then
								return 0
							end
							local s = use2(state)
							return if s == "hover" or s == "press" then 0 else 1
						end),
						[OnEvent "MouseEnter"] = function()
							hoveredName:set(name)
						end,
						[OnEvent "Activated"] = function()
							close()
							if opts.onSelect then
								opts.onSelect(name)
							end
						end,
						[Children] = {
							Ui.corner(inner, R.S),
							Ui.icon(inner, {
								Icon = name,
								Size = 18,
								Color = if selected then C.AccentText else C.Text,
								AnchorPoint = Vector2.new(0.5, 0.5),
								Position = UDim2.fromScale(0.5, 0.5),
							}),
						},
					}))
					table.insert(out, button)
				end
				return out
			end)
			return {
				Ui.padding(scope, 10),
				Ui.list(scope, "Y", 8),
				scope:New "Frame" {
					Name = "Head",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, 0, 0, 30),
					LayoutOrder = 1,
					[Children] = {
						Ui.input(scope, {
							Value = query,
							Placeholder = `Search {#Icons.list} icons...`,
							Icon = "magnifying-glass",
							Height = 30,
							OnInput = function(text)
								query:set(text)
							end,
						}),
					},
				},
				scope:New "ScrollingFrame" {
					Name = "Grid",
					BackgroundTransparency = 1,
					BorderSizePixel = 0,
					Size = UDim2.new(1, 0, 1, -30 - 8 - 22 - 8),
					LayoutOrder = 2,
					CanvasSize = UDim2.new(),
					AutomaticCanvasSize = Enum.AutomaticSize.Y,
					ScrollingDirection = Enum.ScrollingDirection.Y,
					ScrollBarThickness = 4,
					ScrollBarImageColor3 = C.BorderStrong,
					VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
					TopImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
					MidImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
					BottomImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
					[Children] = {
						scope:New "UIGridLayout" {
							CellSize = UDim2.fromOffset(cell, cell),
							CellPadding = UDim2.fromOffset(4, 4),
							FillDirection = Enum.FillDirection.Horizontal,
							FillDirectionMaxCells = columns,
							HorizontalAlignment = Enum.HorizontalAlignment.Left,
							VerticalAlignment = Enum.VerticalAlignment.Top,
							SortOrder = Enum.SortOrder.LayoutOrder,
						},
						grid,
					},
				},
				scope:New "Frame" {
					Name = "Footer",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, 0, 0, 22),
					LayoutOrder = 3,
					[Children] = {
						Ui.list(scope, "X", 6, "Left", "Center"),
						Ui.icon(scope, { Icon = hoveredName, Size = 16, Color = C.AccentText, LayoutOrder = 1 }),
						Ui.text(scope, {
							Text = scope:Computed(function(use)
								local name = use(hoveredName)
								local count = #use(results)
								local suffix = if count >= MAX_ICON_RESULTS then ` · first {MAX_ICON_RESULTS}` else ` · {count}`
								return (if name == "" then "Hover an icon" else name) .. suffix
							end),
							Font = Style.Font.Mono,
							TextSize = Style.Text.Caption,
							Color = C.TextMuted,
							Truncate = true,
							Size = UDim2.new(1, -24, 1, 0),
							LayoutOrder = 2,
						}),
					},
				},
			}
		end,
	})
end

--------------------------------------------------------------------------
-- colour picker
--------------------------------------------------------------------------

function Overlay:colorPicker(anchor: GuiObject?, opts: { [string]: any })
	local columns = 8
	local cell = 22
	local gap = 5
	local gridWidth = columns * cell + (columns - 1) * gap
	local width = gridWidth + 24
	local rows = math.ceil(#SWATCHES / columns)
	local paletteColors = {}
	for _, hex in opts.palette or {} do
		if Style.parseHex(hex) then
			table.insert(paletteColors, hex)
		end
	end
	local paletteHeight = if #paletteColors > 0 then 16 + 6 + cell + 10 else 0
	local height = 12 + paletteHeight + 16 + 6 + rows * cell + (rows - 1) * gap + 12 + 32 + 12

	local function swatchButton(scope, hex, order, close)
		local state, events = Ui.interact(scope)
		local selected = Style.normalizeHex(opts.value) == Style.normalizeHex(hex)
		return scope:New "TextButton" (Ui.merge(events, {
			Name = hex,
			Text = "",
			AutoButtonColor = false,
			Size = UDim2.fromOffset(cell, cell),
			LayoutOrder = order,
			BackgroundColor3 = Style.parseHex(hex) or C.Raised,
			[OnEvent "Activated"] = function()
				close()
				if opts.onSelect then
					opts.onSelect(Style.normalizeHex(hex))
				end
			end,
			[Children] = {
				Ui.corner(scope, 6),
				Ui.stroke(scope, scope:Computed(function(use)
					if selected then
						return C.Text
					end
					return if use(state) == "hover" then C.Text else C.Black
				end), if selected then 2 else 1, scope:Computed(function(use)
					if selected then
						return 0
					end
					return if use(state) == "hover" then 0.2 else 0.7
				end)),
			},
		}))
	end

	self:open({
		name = "ColorPicker",
		anchor = anchor,
		width = width,
		height = height,
		onClose = opts.onClose,
		build = function(scope, close)
			local hexText = scope:Value(opts.value or "")
			local invalid = scope:Value(false)
			local swatches = {}
			for index, hex in SWATCHES do
				table.insert(swatches, swatchButton(scope, hex, index, close))
			end
			local paletteButtons = {}
			for index, hex in paletteColors do
				table.insert(paletteButtons, swatchButton(scope, hex, index, close))
			end
			local function apply(text)
				local hex = Style.normalizeHex(text)
				if hex == nil then
					invalid:set(true)
					return
				end
				close()
				if opts.onSelect then
					opts.onSelect(hex)
				end
			end
			return {
				Ui.padding(scope, 12),
				Ui.list(scope, "Y", 6),
				if #paletteColors > 0
					then Ui.text(scope, { Text = "PALETTE", TextSize = 11, Weight = "SemiBold", Color = C.TextMuted, LayoutOrder = 1, Size = UDim2.new(1, 0, 0, 16) })
					else nil,
				if #paletteColors > 0
					then scope:New "Frame" {
						Name = "Palette",
						BackgroundTransparency = 1,
						Size = UDim2.new(1, 0, 0, cell + 10),
						LayoutOrder = 2,
						[Children] = {
							Ui.padding(scope, 0, 0, 10, 0),
							Ui.list(scope, "X", gap, "Left", "Top"),
							paletteButtons,
						},
					}
					else nil,
				Ui.text(scope, { Text = "COLOURS", TextSize = 11, Weight = "SemiBold", Color = C.TextMuted, LayoutOrder = 3, Size = UDim2.new(1, 0, 0, 16) }),
				scope:New "Frame" {
					Name = "Swatches",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, 0, 0, rows * cell + (rows - 1) * gap + 12),
					LayoutOrder = 4,
					[Children] = {
						Ui.padding(scope, 0, 0, 12, 0),
						scope:New "UIGridLayout" {
							CellSize = UDim2.fromOffset(cell, cell),
							CellPadding = UDim2.fromOffset(gap, gap),
							FillDirection = Enum.FillDirection.Horizontal,
							FillDirectionMaxCells = columns,
							HorizontalAlignment = Enum.HorizontalAlignment.Left,
							VerticalAlignment = Enum.VerticalAlignment.Top,
							SortOrder = Enum.SortOrder.LayoutOrder,
						},
						swatches,
					},
				},
				Ui.row(scope, {
					Gap = 6,
					LayoutOrder = 5,
					Height = 32,
					[Children] = {
						Ui.input(scope, {
							Value = hexText,
							Mono = true,
							Placeholder = "#RRGGBB",
							Invalid = invalid,
							Size = UDim2.new(1, -64, 0, 32),
							LayoutOrder = 1,
							OnInput = function(text)
								hexText:set(text)
								invalid:set(false)
							end,
							OnCommit = function(text, enter)
								if enter then
									apply(text)
								end
							end,
						}),
						Ui.button(scope, {
							Text = "Apply",
							Variant = "soft",
							Size = "md",
							Width = 58,
							LayoutOrder = 2,
							OnClick = function()
								apply(peek(hexText))
							end,
						}),
					},
				}),
			}
		end,
	})
end

--------------------------------------------------------------------------
-- dialogs
--------------------------------------------------------------------------

function Overlay:confirm(opts: { [string]: any })
	local width = math.min(320, self:maxWidth())
	-- rough wrap estimate (Builder Sans 13px ~ 6.4px per character)
	local charsPerLine = math.max(20, math.floor((width - 32) / 6.4))
	local lines = math.max(1, math.ceil(#tostring(opts.body or "") / charsPerLine))
	local bodyHeight = lines * 16 + 4
	local height = 16 + 28 + 8 + bodyHeight + 8 + 4 + 8 + 32 + 16
	self:open({
		name = "Dialog",
		modal = true,
		width = width,
		height = height,
		onClose = opts.onClose,
		build = function(scope, close)
			return {
				Ui.padding(scope, 16),
				Ui.list(scope, "Y", 8),
				Ui.row(scope, {
					Gap = 10,
					Height = 28,
					LayoutOrder = 1,
					[Children] = {
						Ui.iconBubble(scope, {
							Icon = opts.icon or (if opts.danger then "triangle-exclamation" else "circle-i"),
							Background = if opts.danger then C.DangerSoft else C.AccentSoft,
							Color = if opts.danger then C.Danger else C.AccentText,
							Size = 28,
							LayoutOrder = 1,
						}),
						Ui.text(scope, {
							Text = opts.title or "Are you sure?",
							Weight = "SemiBold",
							TextSize = Style.Text.Title,
							Truncate = true,
							Size = UDim2.new(1, -38, 0, 28),
							LayoutOrder = 2,
						}),
					},
				}),
				Ui.text(scope, {
					Text = opts.body or "",
					TextSize = Style.Text.Small,
					Color = C.TextSub,
					Wrap = true,
					LineHeight = 1.1,
					Size = UDim2.new(1, 0, 0, bodyHeight),
					YAlign = Enum.TextYAlignment.Top,
					LayoutOrder = 2,
				}),
				scope:New "Frame" { Name = "Gap", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 4), LayoutOrder = 3 },
				Ui.row(scope, {
					Gap = 8,
					Align = "Right",
					LayoutOrder = 4,
					Height = 32,
					[Children] = {
						Ui.button(scope, {
							Name = "Cancel",
							Text = opts.cancel or "Cancel",
							Variant = "ghost",
							LayoutOrder = 1,
							OnClick = close,
						}),
						Ui.button(scope, {
							Name = "Confirm",
							Text = opts.confirm or "OK",
							Variant = if opts.danger then "solidDanger" else "primary",
							LayoutOrder = 2,
							OnClick = function()
								close()
								if opts.onConfirm then
									opts.onConfirm()
								end
							end,
						}),
					},
				}),
			}
		end,
	})
end

--------------------------------------------------------------------------
-- tooltips
--------------------------------------------------------------------------

function Overlay:showTooltip(gui: GuiObject, text: string)
	if self.current ~= nil or gui.Parent == nil then
		return
	end
	self.tooltipOwner = gui
	self.tooltipText:set(text)
	local rootPos = self.root.AbsolutePosition
	local rootSize = self.root.AbsoluteSize
	local pos = gui.AbsolutePosition - rootPos
	local size = gui.AbsoluteSize
	local estimate = #text * 6.5 + 16
	local x = math.clamp(pos.X + size.X / 2, estimate / 2 + 6, math.max(estimate / 2 + 6, rootSize.X - estimate / 2 - 6))
	local y = pos.Y + size.Y + 6
	if y + 26 > rootSize.Y then
		y = pos.Y - 30
	end
	self.tooltipPosition:set(UDim2.fromOffset(math.floor(x), math.floor(y)))
	self.tooltipVisible:set(true)
end

function Overlay:hideTooltip(gui: GuiObject?)
	if gui == nil or self.tooltipOwner == gui then
		self.tooltipOwner = nil
		self.tooltipVisible:set(false)
	end
end

--------------------------------------------------------------------------
-- toasts
--------------------------------------------------------------------------

local function toastTone(tone: string?): { icon: string, color: Color3 }
	if tone == "success" then
		return { icon = "circle-check", color = C.Success }
	elseif tone == "warning" then
		return { icon = "triangle-exclamation", color = C.Warning }
	elseif tone == "error" then
		return { icon = "circle-x", color = C.Danger }
	elseif tone == "ai" then
		return { icon = "cursor-stars", color = C.AccentText }
	end
	return { icon = "circle-i", color = C.Info }
end

--[[
	overlay:toast(text, tone?, { duration = 3.5, action = { label = "Undo", run = fn } }?)
	tone: "info" | "success" | "warning" | "error" | "ai". Errors and toasts with
	an action stay longer; hovering a toast keeps it open; x closes it.
]]
function Overlay:toast(text: string, tone: string?, opts: any?)
	local info = toastTone(tone)
	local options = if typeof(opts) == "table" then opts else { duration = opts }
	local action = options.action
	local scope, life = Ui.innerScope(self.scope)
	local hovered = false
	self.toastCount += 1
	local order = self.toastCount
	local function dismiss()
		Ui.cleanup(scope, life)
	end
	local reserved = 24 + 26 + (if action then 64 else 0)
	local toast = scope:New "Frame" {
		Name = "Toast",
		BackgroundColor3 = C.Toast,
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		LayoutOrder = order,
		ClipsDescendants = true,
		Parent = self.toasts,
		[OnEvent "MouseEnter"] = function()
			hovered = true
		end,
		[OnEvent "MouseLeave"] = function()
			hovered = false
		end,
		[Children] = {
			Ui.corner(scope, R.M),
			Ui.stroke(scope, C.BorderStrong),
			scope:New "Frame" {
				Name = "ToneBar",
				BackgroundColor3 = info.color,
				BorderSizePixel = 0,
				Size = UDim2.new(0, 3, 1, 0),
			},
			scope:New "Frame" {
				Name = "Body",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				[Children] = {
					Ui.padding(scope, 9, 6, 9, 13),
					Ui.list(scope, "X", 8, "Left", "Center"),
					Ui.icon(scope, { Icon = info.icon, Size = 16, Color = info.color, Bold = true, LayoutOrder = 1 }),
					Ui.text(scope, {
						Name = "Message",
						Text = text,
						TextSize = Style.Text.Small,
						Weight = "Medium",
						Color = C.Text,
						Wrap = true,
						LineHeight = 1.1,
						AutomaticSize = Enum.AutomaticSize.Y,
						Size = UDim2.new(1, -reserved, 0, 0),
						LayoutOrder = 2,
					}),
					if action
						then Ui.button(scope, {
							Name = "Action",
							Text = action.label or "Undo",
							Variant = "soft",
							Size = "sm",
							LayoutOrder = 3,
							OnClick = function()
								dismiss()
								action.run()
							end,
						})
						else nil,
					Ui.iconButton(scope, {
						Name = "Close",
						Icon = "x-small",
						Size = 22,
						IconSize = 14,
						LayoutOrder = 4,
						OnClick = dismiss,
					}),
				},
			},
		},
	}
	-- a small pop-in (cosmetic)
	local pop = scope:New "UIScale" { Scale = 0.96, Parent = toast }
	Ui.tween(pop, { Scale = 1 }, 0.14)

	-- keep at most 3
	local all = {}
	for _, child in self.toasts:GetChildren() do
		if child.Name == "Toast" then
			table.insert(all, child)
		end
	end
	table.sort(all, function(a, b)
		return a.LayoutOrder < b.LayoutOrder
	end)
	while #all > 3 do
		local oldest = table.remove(all, 1)
		oldest.Visible = false
		oldest.Parent = nil
	end
	local duration = options.duration or (if action or tone == "error" then 6 else 3.5)
	local function expire()
		if not life.alive then
			return
		end
		if hovered then
			task.delay(1, expire)
			return
		end
		dismiss()
	end
	task.delay(duration, expire)
	return toast
end

function Overlay:clearToasts()
	for _, child in self.toasts:GetChildren() do
		if child.Name == "Toast" then
			child.Parent = nil
		end
	end
end

return Overlay
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="16">
        <Properties>
          <string name="Name">Preview</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Preview: shows the current blueprint live in the Studio viewport.

		local preview = Preview.new(scope, store, modules)
		store.previewOn:set(true)       -- builds into CoreGui
		store.previewScreen:set("shop") -- opens a screen ("__hud" = HUD only)
		store.device:set("Phone")       -- phone-sized frame in the middle of the viewport
		preview:destroy()               -- removes everything (widget closed / plugin unloading)

	Edits rebuild the preview after a short debounce (0.15 s). The preview is
	built with Blueprint.build(scope, blueprint, { Parent = CoreGui, Preview = true }).
	Every ScreenGui the build adds to CoreGui is tagged (attribute
	BloxUIBuilderPreview) and removed on teardown. Nothing is ever written
	into the place.
]]

local CoreGui = game:GetService("CoreGui")
local Workspace = game:GetService("Workspace")

local Deps = require(script.Parent.Deps)
local Store = require(script.Parent.Store)

local Fusion = Deps.Fusion
local peek = Fusion.peek

local Preview = {}
Preview.__index = Preview

Preview.DEBOUNCE = 0.15
Preview.CONTAINER = "BloxUIBuilderPreview"
Preview.TAG = "BloxUIBuilderPreview"
Preview.DEVICES = {
	Desktop = nil,
	Phone = Vector2.new(844, 390), -- iPhone 14 landscape, in points
}

function Preview.new(scope: any, store: any, modules: any): any
	local self = setmetatable({}, Preview)
	self.store = store
	self.modules = modules
	self.token = 0
	self.alive = true
	self.built = nil -- { scope, ui, guis, device }
	self.builds = 0

	table.insert(
		scope,
		scope:Observer(store.previewOn):onChange(function()
			if peek(store.previewOn) then
				self:rebuild()
			else
				self:teardown()
			end
		end)
	)
	table.insert(
		scope,
		scope:Observer(store.previewScreen):onChange(function()
			self:showScreen()
		end)
	)
	table.insert(
		scope,
		scope:Observer(store.device):onChange(function()
			self:schedule()
		end)
	)
	table.insert(
		scope,
		store:onChanged(function()
			self:schedule()
		end)
	)
	table.insert(scope, function()
		self:destroy()
	end)
	return self
end

function Preview:isOn(): boolean
	return self.alive and peek(self.store.previewOn) == true
end

-- Debounced rebuild.
function Preview:schedule()
	if not self:isOn() then
		return
	end
	self.token += 1
	local token = self.token
	task.delay(Preview.DEBOUNCE, function()
		if token == self.token and self:isOn() then
			self:rebuild()
		end
	end)
end

function Preview:teardown()
	self.token += 1
	local built = self.built
	self.built = nil
	if built == nil then
		return
	end
	if built.ui and typeof(built.ui.destroy) == "function" then
		pcall(built.ui.destroy, built.ui)
	end
	if built.scope then
		pcall(Fusion.doCleanup, built.scope)
	end
	if built.device then
		pcall(function()
			built.device:Destroy()
		end)
	end
	for _, gui in built.guis or {} do
		pcall(function()
			gui:Destroy()
		end)
	end
	if built.viewport and self.modules.UI and self.modules.UI.Viewport then
		-- give the payload's Viewport its real size back
		pcall(function()
			local camera = Workspace.CurrentCamera
			if camera then
				self.modules.UI.Viewport.size:set(camera.ViewportSize)
			end
		end)
	end
end

-- Wraps each built ScreenGui's scaled canvas in a phone-sized frame.
function Preview:frameDevice(guis: { Instance }, size: Vector2): Instance?
	local UI = self.modules.UI
	local camera = Workspace.CurrentCamera
	local viewport = if camera then camera.ViewportSize else Vector2.new(1920, 1080)
	local fit = math.min(1, (viewport.X - 80) / size.X, (viewport.Y - 120) / size.Y)
	local shown = Vector2.new(math.floor(size.X * fit), math.floor(size.Y * fit))

	if UI and UI.Viewport and UI.Viewport.size then
		pcall(function()
			UI.Viewport.size:set(shown)
		end)
	end

	for _, gui in guis do
		if gui:IsA("ScreenGui") then
			local canvas = gui:FindFirstChild("Canvas")
			if canvas and canvas:IsA("GuiObject") then
				local frame = Instance.new("Frame")
				frame.Name = "DeviceFrame"
				frame.AnchorPoint = Vector2.new(0.5, 0.5)
				frame.Position = UDim2.fromScale(0.5, 0.5)
				frame.Size = UDim2.fromOffset(shown.X, shown.Y)
				frame.BackgroundTransparency = 1
				frame.ClipsDescendants = true
				frame.Parent = gui
				canvas.Parent = frame
			end
		end
	end

	-- dim the rest of the viewport and draw a bezel
	local bezel = Instance.new("ScreenGui")
	bezel.Name = Preview.CONTAINER .. "Device"
	bezel.Archivable = false
	bezel:SetAttribute(Preview.TAG, true)
	bezel.IgnoreGuiInset = true
	bezel.DisplayOrder = -1
	bezel.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
	local dim = Instance.new("Frame")
	dim.Name = "Dim"
	dim.BackgroundColor3 = Color3.fromRGB(10, 10, 14)
	dim.BackgroundTransparency = 0.25
	dim.Size = UDim2.fromScale(1, 1)
	dim.Parent = bezel
	local screen = Instance.new("Frame")
	screen.Name = "Screen"
	screen.AnchorPoint = Vector2.new(0.5, 0.5)
	screen.Position = UDim2.fromScale(0.5, 0.5)
	screen.Size = UDim2.fromOffset(shown.X, shown.Y)
	screen.BackgroundColor3 = Color3.fromRGB(0, 0, 0)
	screen.BackgroundTransparency = 1
	screen.Parent = dim
	local stroke = Instance.new("UIStroke")
	stroke.Color = Color3.fromRGB(30, 31, 38)
	stroke.Thickness = 10
	stroke.Parent = screen
	local corner = Instance.new("UICorner")
	corner.CornerRadius = UDim.new(0, 18)
	corner.Parent = screen
	local label = Instance.new("TextLabel")
	label.Name = "Label"
	label.BackgroundTransparency = 1
	label.AnchorPoint = Vector2.new(0.5, 0)
	label.Position = UDim2.new(0.5, 0, 0.5, shown.Y / 2 + 16)
	label.Size = UDim2.fromOffset(300, 20)
	label.Text = `Phone preview · {size.X}×{size.Y}`
	label.TextColor3 = Color3.fromRGB(160, 163, 172)
	label.TextSize = 14
	label.FontFace = Font.new("rbxasset://fonts/families/BuilderSans.json", Enum.FontWeight.Medium)
	label.Parent = dim
	bezel.Parent = CoreGui
	return bezel
end

function Preview:rebuild()
	if not self:isOn() then
		return
	end
	self:teardown()
	local store = self.store
	local Blueprint = self.modules.Blueprint
	if Blueprint == nil then
		store.previewError:set("The Blueprint module is missing, so the preview can't be built.")
		return
	end
	if store.blueprint == nil then
		store.previewError:set(nil)
		return
	end
	local compiled = store:normalize(Store.compile(store.blueprint))

	local UI = self.modules.UI
	local scope = if UI and typeof(UI.scope) == "function" then UI.scope() else Fusion.scoped(Fusion)
	local built = { scope = scope, guis = {} }
	self.built = built
	self.builds += 1

	-- remember what CoreGui had, so we know exactly which ScreenGuis are ours
	local before = {}
	pcall(function()
		for _, child in CoreGui:GetChildren() do
			before[child] = true
		end
	end)
	local ok, result = xpcall(Blueprint.build, debug.traceback, scope, compiled, {
		Parent = CoreGui,
		Preview = true,
	})
	pcall(function()
		for _, child in CoreGui:GetChildren() do
			if not before[child] and child:IsA("LayerCollector") then
				child.Archivable = false
				child:SetAttribute(Preview.TAG, true)
				table.insert(built.guis, child)
			end
		end
	end)
	if self.built ~= built then
		-- torn down while building
		for _, gui in built.guis do
			pcall(function()
				gui:Destroy()
			end)
		end
		return
	end
	if not ok then
		local message = tostring(result)
		store.previewError:set(string.match(message, "^[^\n]*") or message)
		warn("[BloxUI Builder] preview failed: " .. message)
		self:teardown()
		return
	end
	built.ui = result
	store.previewError:set(nil)

	local device = Preview.DEVICES[peek(store.device)]
	if device then
		local okDevice, bezel = pcall(self.frameDevice, self, built.guis, device)
		if okDevice then
			built.device = bezel
			built.viewport = true
		end
	end

	self:showScreen()
end

-- Opens the selected screen (or closes all for "HUD only") without a rebuild.
function Preview:showScreen()
	local built = self.built
	if built == nil or built.ui == nil then
		return
	end
	local ui = built.ui
	local selected = peek(self.store.previewScreen)
	local bp = self.store.blueprint
	for _, screen in (bp and bp.screens) or {} do
		if screen.id ~= selected and typeof(ui.close) == "function" then
			pcall(ui.close, ui, screen.id)
		end
	end
	if selected ~= Store.HUD and typeof(ui.open) == "function" then
		pcall(ui.open, ui, selected)
	end
end

function Preview:destroy()
	if not self.alive then
		return
	end
	self:teardown()
	self.alive = false
	-- belt and braces: remove any stray preview left in CoreGui
	pcall(function()
		for _, child in CoreGui:GetChildren() do
			if child:GetAttribute(Preview.TAG) == true then
				child:Destroy()
			end
		end
	end)
end

return Preview
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="17">
        <Properties>
          <string name="Name">References</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	References: the screenshots a user attaches in the Create tab so Claude
	matches their style ("match a reference").

		local ok, result = References.pick(used)   -- opens Studio's file picker (yields)
		-- ok + record  : { name, size, mediaType, width?, height?, bytes, thumb? }
		-- ok + nil     : the picker was cancelled
		-- not ok       : result is a message to show
		References.payload(records)  -- { { mediaType, data = base64 } } for options.references

	Studio APIs (create.roblox.com/docs, PluginSecurity, plugins only):
	- StudioService:PromptImportFileAsync(fileTypeFilter) -> File?, nil when
	  cancelled or the file is over 100 MB. The older PromptImportFile (now
	  deprecated) is the fallback on Studio builds without the Async one.
	- File.Size (bytes), File:GetBinaryContents() -> the raw bytes,
	  File:GetTemporaryId() -> an rbxtemp:// id an ImageLabel can show (the
	  chip's thumbnail). Every call is wrapped: a failure is a message, never
	  a crash.

	Records live in the store's memory for this Studio session only: they
	are never written into the place or the plugin settings, and their bytes
	only ever go to the BloxUI server with the brief request (Claude mode).
	The format and size checks are Image's (Image.luau).
]]

local Image = require(script.Parent.Image)
local Base64 = require(script.Parent.Base64)

local References = {}

References.MAX = Image.LIMITS.count
References.MAX_BYTES = Image.LIMITS.bytes
References.FILE_TYPES = Image.FILE_TYPES

-- { count, bytes } the references must stay within.
function References.limits(): { count: number, bytes: number }
	return { count = Image.LIMITS.count, bytes = Image.LIMITS.bytes }
end

function References.formatBytes(size: number): string
	return Image.formatBytes(size)
end

-- "412 KB · 1280×720"
function References.caption(record: any): string
	local parts = { References.formatBytes(record.size or 0) }
	if record.width and record.height then
		table.insert(parts, `{record.width}×{record.height}`)
	end
	return table.concat(parts, "  ·  ")
end

-- Opens Studio's file picker. Returns the File, nil (cancelled), or nil + a message.
function References.prompt(studioService: any?): (Instance?, string?)
	local service = studioService
	if service == nil then
		local ok, result = pcall(function()
			return game:GetService("StudioService")
		end)
		service = if ok then result else nil
	end
	if service == nil then
		return nil, "Studio's file picker isn't available here."
	end
	local function method(name: string): any
		local ok, fn = pcall(function()
			return (service :: any)[name]
		end)
		return if ok and typeof(fn) == "function" then fn else nil
	end
	local open = method("PromptImportFileAsync") or method("PromptImportFile")
	if open == nil then
		return nil, "This version of Studio can't import files into plugins."
	end
	local ok, file = pcall(open, service, References.FILE_TYPES)
	if not ok then
		return nil, "Couldn't open the file: " .. tostring(file)
	end
	if typeof(file) ~= "Instance" then
		return nil, nil -- cancelled (or over Studio's 100 MB import limit)
	end
	return file, nil
end

-- A picked File -> record, or nil + a message. `limit` = bytes still free.
function References.fromFile(file: any, limit: number?): (any, string?)
	local okName, name = pcall(function()
		return file.Name
	end)
	name = if okName and typeof(name) == "string" and name ~= "" then name else "screenshot"
	local label = `"{name}"`
	local total = Image.LIMITS.bytes
	local cap = limit or total
	-- the size first: an oversized file is refused before it is read
	local okSize, size = pcall(function()
		return file.Size
	end)
	if okSize and typeof(size) == "number" and size > cap then
		if size <= total then
			-- fits on its own, not next to the references already attached
			return nil, `{label} is {References.formatBytes(size)}, and all references together must stay under {References.formatBytes(total)}. Crop or resize the screenshot (under {References.formatBytes(math.max(cap, 0))}) and try again.`
		end
		return nil, `{label} is {References.formatBytes(size)}. Crop or resize the screenshot (under {References.formatBytes(total)}) and try again.`
	end
	local okRead, bytes = pcall(function()
		return file:GetBinaryContents()
	end)
	if not okRead or typeof(bytes) ~= "string" then
		return nil, `Couldn't read {label}. Try saving the screenshot again.`
	end
	local ok, info = Image.inspect(bytes, name, cap)
	if not ok then
		return nil, tostring(info)
	end
	local okThumb, thumb = pcall(function()
		return file:GetTemporaryId()
	end)
	return {
		name = name,
		size = #bytes,
		mediaType = info.mediaType,
		width = info.width,
		height = info.height,
		bytes = bytes,
		thumb = if okThumb and typeof(thumb) == "string" and thumb ~= "" then thumb else nil,
		file = file, -- keeps the temporary id alive while the chip shows it
	}, nil
end

--[[
	The whole attach flow: picker, checks, record. `used` = bytes the other
	references already take. Returns ok, record? (nil = cancelled) or
	false, message.
]]
function References.pick(used: number?, studioService: any?): (boolean, any)
	local file, problem = References.prompt(studioService)
	if file == nil then
		if problem then
			return false, problem
		end
		return true, nil
	end
	local record, message = References.fromFile(file, Image.LIMITS.bytes - (used or 0))
	if record == nil then
		return false, message
	end
	return true, record
end

--[[
	What the server takes (docs/API.md options.references): at most 2, each
	{ mediaType, data = base64 }. Records that break the limits (which the
	picker already refuses) are left out. File names never leave Studio.
]]
function References.payload(records: { any }?): { { mediaType: string, data: string } }?
	local out = {}
	local used = 0
	for _, record in records or {} do
		if #out >= Image.LIMITS.count then
			break
		end
		local bytes = record.bytes
		if typeof(bytes) == "string" and #bytes > 0 and used + #bytes <= Image.LIMITS.bytes then
			local mediaType = record.mediaType or Image.sniff(bytes)
			if mediaType then
				used += #bytes
				table.insert(out, { mediaType = mediaType, data = Base64.encode(bytes) })
			end
		end
	end
	return if #out > 0 then out else nil
end

return References
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="18">
        <Properties>
          <string name="Name">Remote</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Remote: the plugin's client for the BloxUI server (docs/API.md, v1). The
	generator and the Claude prompts live on the server; the plugin sends
	prompts and gets blueprints back.

		local remote = Remote.new({ plugin = plugin, version = "2.0.0" })
		local ok, status = remote:status()                 -- GET /v1/status (no auth)
		local ok, account = remote:loginKey("BLOX-....")   -- license key -> token (saved)
		local ok, flow = remote:startDeviceLogin()         -- Sign in with Roblox: show flow.userCode + flow.verifyUrl
		local ok, account = remote:waitForDevice(flow, { cancelled = fn })   -- polls until approved / denied / expired
		local ok, account = remote:me()
		remote:logout()                                    -- revokes the token, forgets it
		local handle = {}
		local ok, job = remote:runJob(body, { handle = handle, onProgress = fn(job) })   -- POST + poll (1 s -> 2 s)
		remote:cancel(handle)                              -- DELETE /v1/jobs/{id}

	Every call returns ok, value and never throws. When ok is false, value is
	an error { code, message, status?, retryAfter?, detail?, upgradeUrl? }:
	`code` is one of the API's codes (bad_request, unauthorized,
	license_invalid, not_owner, device_limit, quota_exceeded, rate_limited,
	too_large, not_found, update_required, server_error, unavailable) or a
	client one (network, timeout, http_blocked, bad_response, cancelled,
	expired), and `message` is a clean sentence for the user
	(Remote.MESSAGES). The plan refusals (trial_limit, trial_ended,
	free_limit, premium_required) show the server's own sentence, and
	upgradeUrl is set when the server says where to upgrade.

	Requests time out (Remote.TIMEOUT), retry 429 / 5xx (Retry-After is
	honoured) and, when it's safe, dropped connections. Every request sends
	X-BloxUI-Plugin: <version>; signed-in ones send Authorization: Bearer
	<token>. The token lives only in plugin:SetSetting("BloxUI_Token"): never
	in the place, never in a message. This module never prints anything, so
	tokens, prompts and images can't end up in the Output.
]]

local HttpService = game:GetService("HttpService")

local Remote = {}
Remote.__index = Remote

Remote.DEFAULT_URL = "https://ui.bloxmaps.com" -- the owner's server (docs/SERVER.md); Settings → Advanced can change it
Remote.API = 1
Remote.SETTING = {
	url = "BloxUI_ServerUrl",
	token = "BloxUI_Token",
	device = "BloxUI_DeviceId",
	-- the user's own AI keys (optional), one per provider: sent only with AI jobs and key checks, to
	-- the BloxUI server over https, which uses it for that job and never stores it
	ownKey = "BloxUI_AnthropicKey",
	openaiKey = "BloxUI_OpenAIKey",
	-- whose key AI mode uses: "anthropic" (default) | "openai"
	provider = "BloxUI_AIProvider",
	-- this install's free-plan id (32 hex): the free session and its daily generations belong to it
	install = "BloxUI_InstallId",
}

-- Own-key providers: the header the key travels in, the setting it is kept in, what it looks like.
-- OpenAI takes API keys only (platform.openai.com): a ChatGPT or Codex subscription can't be used by
-- other apps.
Remote.PROVIDERS = {
	anthropic = {
		id = "anthropic",
		header = "X-Anthropic-Key",
		setting = Remote.SETTING.ownKey,
		name = "Anthropic",
		label = "Claude (Anthropic)",
		short = "Claude",
		placeholder = "sk-ant-...",
		console = "console.anthropic.com",
	},
	openai = {
		id = "openai",
		header = "X-OpenAI-Key",
		setting = Remote.SETTING.openaiKey,
		name = "OpenAI",
		label = "ChatGPT (OpenAI)",
		short = "ChatGPT",
		placeholder = "sk-...",
		console = "platform.openai.com",
	},
}
Remote.PROVIDER_ORDER = { "anthropic", "openai" }

-- "anthropic" or "openai" (anything else is Anthropic, the default).
function Remote.providerOf(value: any): string
	return if value == "openai" then "openai" else "anthropic"
end

-- Does `key` look like an API key of `provider`? (Anthropic: sk-ant-...; OpenAI: sk-proj-... or a
-- legacy sk-..., never an Anthropic one.)
--[[
	A key as pasted -> the key itself. Text copied from a web page, an email, Discord or a notes
	app can carry invisible characters (non-breaking and zero-width spaces, a byte order mark),
	typographic dashes, line breaks or quotes, none of which a key ever contains.
]]
local INVISIBLE = {
	"\u{00A0}", "\u{00AD}", "\u{200B}", "\u{200C}", "\u{200D}", "\u{200E}", "\u{200F}", "\u{2028}", "\u{2029}",
	"\u{202F}", "\u{2060}", "\u{FEFF}",
}
local DASHES = { "\u{2010}", "\u{2011}", "\u{2012}", "\u{2013}", "\u{2014}", "\u{2212}" }

function Remote.cleanKey(text: any): string
	if typeof(text) ~= "string" then
		return ""
	end
	for _, ch in INVISIBLE do
		text = string.gsub(text, ch, "")
	end
	for _, ch in DASHES do
		text = string.gsub(text, ch, "-")
	end
	text = string.gsub(text, "%s+", "")
	text = string.gsub(text, "^[\"'`]+", "")
	text = string.gsub(text, "[\"'`]+$", "")
	return text
end

function Remote.isKey(key: any, provider: string?): boolean
	if typeof(key) ~= "string" or #key < 20 or #key > 512 then
		return false
	end
	if Remote.providerOf(provider) == "openai" then
		return string.match(key, "^sk%-[%w%-_]+$") ~= nil and string.match(key, "^sk%-ant%-") == nil
	end
	return string.match(key, "^sk%-ant%-[%w%-_]+$") ~= nil
end

-- where a licence key is bought (the Paid Trial or Premium), and where a trial is upgraded
Remote.PREMIUM_URL = "https://ui.bloxmaps.com"
Remote.UPGRADE_URL = "https://ui.bloxmaps.com/upgrade"
Remote.TIMEOUT = 20 -- seconds per request
Remote.RETRIES = 3 -- extra attempts for 429 / 5xx / dropped connections
Remote.MAX_RETRY_WAIT = 30 -- a longer Retry-After is reported, not waited out
Remote.JOB_TIMEOUT = 15 * 60 -- stop polling a job after this long
Remote.POLL_FIRST = 1 -- job polling: every ~1 s...
Remote.POLL_MAX = 2 -- ...backing off to 2 s
Remote.POLL_STEP = 0.25
Remote.MAX_BODY = 1024 * 1024 -- the server's limit for a whole request
Remote.MAX_PROMPT = 2000 -- characters

-- One clean sentence per error code (what the user reads).
Remote.MESSAGES = {
	bad_request = "The BloxUI server couldn't accept that request.",
	unauthorized = "You're signed out. Sign in again in Settings.",
	license_invalid = "That licence key isn't valid. Check it for typos and try again.",
	not_owner = "This Roblox account isn't on the BloxUI early-tester list. Get a licence key at ui.bloxmaps.com, then paste it in Settings > Account.",
	device_limit = "This licence is already signed in on the most devices it allows. Sign out on another computer, then try again.",
	quota_exceeded = "You've used all of today's AI generations. Instant mode still works, and the quota resets tomorrow.",
	rate_limited = "Too many requests right now. Try again in a moment.",
	too_large = "That request is too big. Use smaller reference screenshots (under 600 KB together) or a shorter prompt.",
	not_found = "The server no longer has that job (jobs are kept for 10 minutes). Generate again.",
	update_required = "This version of the BloxUI plugin is too old for the server. Update the plugin to keep generating.",
	server_error = "The BloxUI server hit an error. Try again in a moment.",
	unavailable = "The BloxUI server is down for maintenance. Try again soon.",
	network = "Couldn't reach the BloxUI server. Check your internet connection and try again.",
	timeout = "The BloxUI server took too long to answer. Try again.",
	http_blocked = "Studio blocked the request. Allow HTTP requests for the BloxUI plugin (Plugins tab, Manage Plugins), then try again.",
	bad_response = "The BloxUI server sent an answer this plugin can't read. Try again, or update the plugin.",
	cancelled = "Cancelled.",
	key_rejected = "Anthropic rejected that key. Check it (and its credit) in the Anthropic Console.",
	expired = "The sign-in code expired. Start again to get a new one.",
}

local STATUS_CODES = {
	[400] = "bad_request",
	[401] = "unauthorized",
	[403] = "not_owner",
	[404] = "not_found",
	[413] = "too_large",
	[426] = "update_required",
	[429] = "rate_limited",
	[503] = "unavailable",
}

-- Errors worth another try (the server or the connection had a hiccup).
local TRANSIENT = { rate_limited = true, server_error = true, unavailable = true, network = true, timeout = true }

--------------------------------------------------------------------------
-- small helpers
--------------------------------------------------------------------------

local function trim(text: any): string
	if typeof(text) ~= "string" then
		return ""
	end
	return (string.gsub(string.gsub(text, "^%s+", ""), "%s+$", ""))
end

-- Server text shown to people: one line, no control characters, capped.
local function clean(text: any, limit: number?): string?
	if typeof(text) ~= "string" then
		return nil
	end
	local out = string.gsub(text, "%c", " ")
	out = trim(string.gsub(out, "%s+", " "))
	if out == "" then
		return nil
	end
	local cap = limit or 160
	if #out > cap then
		out = string.sub(out, 1, cap - 3) .. "..."
	end
	return out
end
Remote.clean = clean

local function jsonDecode(text: any): any
	if typeof(text) ~= "string" or text == "" then
		return nil
	end
	local ok, value = pcall(function()
		return HttpService:JSONDecode(text)
	end)
	return if ok then value else nil
end

local function header(headers: any, name: string): string?
	if typeof(headers) ~= "table" then
		return nil
	end
	local want = string.lower(name)
	for key, value in headers do
		if typeof(key) == "string" and string.lower(key) == want then
			return tostring(value)
		end
	end
	return nil
end

-- A link the plugin may show for buying or upgrading: BloxUI's own site only (a custom server
-- can't point people anywhere else), no spaces, nil otherwise.
function Remote.shopUrl(text: any): string?
	if typeof(text) ~= "string" or #text > 200 or string.find(text, "[%s%c]") then
		return nil
	end
	if text == Remote.PREMIUM_URL or string.find(text, "^https://ui%.bloxmaps%.com/") then
		return text
	end
	return nil
end

--[[
	Builds an error. `code` is an API or client code; extra = { status,
	retryAfter, detail (server text), upgradeUrl }. The message comes from
	MESSAGES.
]]
function Remote.error(code: string, extra: { [string]: any }?): { [string]: any }
	extra = extra or {}
	local known = Remote.MESSAGES[code] ~= nil
	local message = Remote.MESSAGES[code] or clean(extra.detail) or "Something went wrong talking to the BloxUI server."
	local retryAfter = tonumber(extra.retryAfter)
	if retryAfter then
		retryAfter = math.max(0, math.ceil(retryAfter))
	end
	if code == "rate_limited" and retryAfter and retryAfter > 0 then
		message = `Too many requests right now. Try again in {retryAfter} s.`
	elseif code == "bad_request" and clean(extra.detail, 120) then
		message = `The BloxUI server couldn't accept that request: {clean(extra.detail, 120)}`
	end
	return {
		code = if known then code else (if typeof(code) == "string" and code ~= "" then code else "server_error"),
		message = message,
		status = extra.status,
		retryAfter = retryAfter,
		detail = clean(extra.detail),
		upgradeUrl = Remote.shopUrl(extra.upgradeUrl),
	}
end

-- The user-facing sentence for an error (or anything else).
function Remote.messageOf(err: any): string
	if typeof(err) == "table" and typeof(err.message) == "string" then
		return err.message
	end
	if typeof(err) == "string" and Remote.MESSAGES[err] then
		return Remote.MESSAGES[err]
	end
	return "Something went wrong talking to the BloxUI server."
end

-- What a RequestAsync failure (it throws) means.
function Remote.transportError(message: any): { [string]: any }
	local lower = string.lower(tostring(message or ""))
	local function has(word: string): boolean
		return string.find(lower, word, 1, true) ~= nil
	end
	if has("not enabled") or has("permission") or has("denied") or has("not allowed") or has("blocked") then
		return Remote.error("http_blocked")
	elseif has("timedout") or has("timed out") or has("timeout") then
		return Remote.error("timeout")
	elseif has("number of requests exceeded") or has("too many requests") then
		return Remote.error("rate_limited", { retryAfter = 5 })
	end
	return Remote.error("network")
end

-- "2.10.3" -> { 2, 10, 3 } (missing parts are 0), nil when it isn't a version.
function Remote.parseVersion(text: any): { number }?
	if typeof(text) ~= "string" then
		return nil
	end
	local parts = {}
	for piece in string.gmatch(trim(text), "[^%.]+") do
		local number = tonumber(string.match(piece, "^(%d+)"))
		if number == nil then
			return nil
		end
		table.insert(parts, number)
	end
	if #parts == 0 then
		return nil
	end
	while #parts < 3 do
		table.insert(parts, 0)
	end
	return parts
end

-- -1 when a < b, 0 when equal (or either isn't a version), 1 when a > b.
function Remote.compareVersions(a: any, b: any): number
	local pa, pb = Remote.parseVersion(a), Remote.parseVersion(b)
	if pa == nil or pb == nil then
		return 0
	end
	for i = 1, math.max(#pa, #pb) do
		local x, y = pa[i] or 0, pb[i] or 0
		if x ~= y then
			return if x < y then -1 else 1
		end
	end
	return 0
end

--[[
	A server address from what someone typed: https only (http is allowed
	for localhost while developing), no trailing slash. Returns the url or
	nil + a message.
]]
function Remote.normalizeUrl(text: any): (string?, string?)
	local url = trim(text)
	url = string.gsub(url, "/+$", "")
	if url == "" then
		return nil, "Type the server address, e.g. " .. Remote.DEFAULT_URL
	end
	if string.find(url, "%s") then
		return nil, "The address can't contain spaces."
	end
	local host = string.match(url, "^https://([%w%.%-]+)")
	if host == nil then
		local localHost = string.match(url, "^http://([%w%.%-]+)")
		if localHost == "localhost" or localHost == "127.0.0.1" then
			return url, nil
		end
		return nil, "The address must start with https://"
	end
	if not string.find(host, "%.") and host ~= "localhost" then
		return nil, "That doesn't look like a server address."
	end
	return url, nil
end

--------------------------------------------------------------------------
-- the client
--------------------------------------------------------------------------

--[[
	Remote.new({
		plugin = plugin,            -- settings: server url, token, device id
		version = "2.0.0",          -- sent as X-BloxUI-Plugin
		http = HttpService,         -- anything with RequestAsync (tests)
		timeout = 20, retries = 3,  -- per request
	})
]]
function Remote.new(options: { [string]: any }?): any
	options = options or {}
	local self = setmetatable({}, Remote)
	self.plugin = options.plugin
	self.version = tostring(options.version or "0.0.0")
	self.http = options.http or HttpService
	self.timeout = tonumber(options.timeout) or Remote.TIMEOUT
	self.retries = tonumber(options.retries) or Remote.RETRIES
	self.onSignedOut = options.onSignedOut -- fn(): the server said the token is no good
	self.onUpdateRequired = options.onUpdateRequired -- fn(): the server wants a newer plugin (426)
	return self
end

function Remote:getSetting(key: string): any
	if self.plugin == nil then
		return nil
	end
	local ok, value = pcall(function()
		return self.plugin:GetSetting(key)
	end)
	return if ok then value else nil
end

function Remote:setSetting(key: string, value: any)
	if self.plugin == nil then
		return
	end
	pcall(function()
		self.plugin:SetSetting(key, value)
	end)
end

-- The server address (the Advanced setting, else the default).
function Remote:baseUrl(): string
	local saved = self:getSetting(Remote.SETTING.url)
	local url = if typeof(saved) == "string" then Remote.normalizeUrl(saved) else nil
	return url or Remote.DEFAULT_URL
end

-- Saves a custom server address; "" / nil goes back to the default.
function Remote:setBaseUrl(text: string?): (boolean, string?)
	if text == nil or trim(text) == "" or trim(text) == Remote.DEFAULT_URL then
		self:setSetting(Remote.SETTING.url, nil)
		return true, nil
	end
	local url, problem = Remote.normalizeUrl(text)
	if url == nil then
		return false, problem
	end
	self:setSetting(Remote.SETTING.url, url)
	return true, nil
end

function Remote:isCustomUrl(): boolean
	return self:baseUrl() ~= Remote.DEFAULT_URL
end

function Remote:token(): string?
	local token = self:getSetting(Remote.SETTING.token)
	if typeof(token) == "string" and token ~= "" then
		return token
	end
	return nil
end

function Remote:setToken(token: string?)
	self:setSetting(Remote.SETTING.token, if typeof(token) == "string" and token ~= "" then token else nil)
end

-- The user's own key for `provider` (default Anthropic), or nil.
function Remote:ownKey(provider: string?): string?
	local info = Remote.PROVIDERS[Remote.providerOf(provider)]
	local key = self:getSetting(info.setting)
	return if Remote.isKey(key, info.id) then key else nil
end

function Remote:setOwnKey(key: string?, provider: string?)
	local info = Remote.PROVIDERS[Remote.providerOf(provider)]
	self:setSetting(info.setting, if key and key ~= "" then key else nil)
end

-- Whose key AI mode uses ("anthropic" | "openai"), remembered on this computer.
function Remote:provider(): string
	return Remote.providerOf(self:getSetting(Remote.SETTING.provider))
end

function Remote:setProvider(provider: string?)
	provider = Remote.providerOf(provider)
	self:setSetting(Remote.SETTING.provider, if provider == "anthropic" then nil else provider)
end

-- The key AI jobs carry: the chosen provider's when one is saved, else the other saved one (choosing
-- ChatGPT before pasting its key doesn't stop a working Claude key). -> key, provider | nil
function Remote:activeKey(): (string?, string?)
	local chosen = self:provider()
	for _, provider in { chosen, if chosen == "openai" then "anthropic" else "openai" } do
		local key = self:ownKey(provider)
		if key then
			return key, provider
		end
	end
	return nil, nil
end

-- "sk-ant-…WXYZ", "sk-proj-…WXYZ" or "sk-…WXYZ": enough to recognise a key, never the whole key
function Remote.maskKey(key: string?): string
	if typeof(key) ~= "string" or #key < 12 then
		return ""
	end
	local prefix = string.match(key, "^(sk%-ant%-)") or string.match(key, "^(sk%-proj%-)") or string.match(key, "^(sk%-svcacct%-)") or "sk-"
	return prefix .. "…" .. string.sub(key, -4)
end

function Remote:hasToken(): boolean
	return self:token() ~= nil
end

-- "Roblox Studio (1a2b3c4d)": a stable, anonymous label for this computer.
function Remote:deviceLabel(): string
	local id = self:getSetting(Remote.SETTING.device)
	if typeof(id) ~= "string" or not string.match(id, "^%x%x%x%x%x%x%x%x$") then
		local ok, guid = pcall(function()
			return HttpService:GenerateGUID(false)
		end)
		local hex = if ok and typeof(guid) == "string" then string.gsub(guid, "[^%x]", "") else ""
		id = string.lower(string.sub(hex .. "00000000", 1, 8))
		self:setSetting(Remote.SETTING.device, id)
	end
	return `Roblox Studio ({id})`
end

-- This install's free-plan id (made once, kept in plugin settings).
function Remote:installId(): string
	local id = self:getSetting(Remote.SETTING.install)
	if typeof(id) ~= "string" or not string.match(id, "^%x+$") or #id ~= 32 then
		local ok, guid = pcall(function()
			return HttpService:GenerateGUID(false)
		end)
		id = string.lower(string.sub((if ok and typeof(guid) == "string" then string.gsub(guid, "[^%x]", "") else "") .. string.rep("0", 32), 1, 32))
		self:setSetting(Remote.SETTING.install, id)
	end
	return id
end

-- Waits `seconds`, in small steps, unless cancelled(). Returns false when cancelled.
local function sleep(seconds: number, cancelled: (() -> boolean)?): boolean
	local left = math.max(0, seconds)
	repeat
		if cancelled and cancelled() then
			return false
		end
		local step = math.min(0.25, left)
		task.wait(step)
		left -= step
	until left <= 0
	return not (cancelled and cancelled())
end
Remote.sleep = sleep

-- One HTTP call with a timeout. Returns the response, or nil + an error.
function Remote:send(request: { [string]: any }, timeout: number?): (any, any)
	local http = self.http
	local result = nil
	task.spawn(function()
		local ok, response = pcall(function()
			return http:RequestAsync(request)
		end)
		result = { ok = ok, response = response }
	end)
	if result == nil then
		local deadline = os.clock() + (timeout or self.timeout)
		while result == nil do
			if os.clock() >= deadline then
				return nil, Remote.error("timeout")
			end
			task.wait(0.05)
		end
	end
	if not result.ok then
		return nil, Remote.transportError(result.response)
	end
	if typeof(result.response) ~= "table" then
		return nil, Remote.error("bad_response")
	end
	return result.response, nil
end

-- A response -> true, data | false, error.
function Remote:parse(response: any): (boolean, any)
	local status = tonumber(response.StatusCode) or 0
	local data = jsonDecode(response.Body)
	if status >= 200 and status < 300 then
		if typeof(data) ~= "table" then
			return false, Remote.error("bad_response", { status = status })
		end
		return true, data
	end
	local body = if typeof(data) == "table" and typeof(data.error) == "table" then data.error else {}
	local code = if typeof(body.code) == "string" and body.code ~= "" then body.code else nil
	if code == nil then
		code = STATUS_CODES[status] or (if status >= 500 then "server_error" else "bad_request")
	end
	local retryAfter = tonumber(body.retryAfter) or tonumber(header(response.Headers, "Retry-After"))
	return false, Remote.error(code, { status = status, retryAfter = retryAfter, detail = body.message, upgradeUrl = body.upgradeUrl })
end

--[[
	remote:request("POST", "/v1/jobs", body, {
		auth = true | false | nil,  -- true: needs the token; false: never send it; nil: send it if there is one
		idempotent = bool,          -- may a dropped connection / timeout be retried? (default: GET / DELETE)
		retries = n, timeout = s, cancelled = fn,
	}) -> ok, data | false, error
]]
function Remote:request(method: string, path: string, body: any?, opts: { [string]: any }?): (boolean, any)
	opts = opts or {}
	local headers = {
		["X-BloxUI-Plugin"] = self.version,
		["Accept"] = "application/json",
	}
	local encoded = nil
	if body ~= nil then
		local ok, json = pcall(function()
			return HttpService:JSONEncode(body)
		end)
		if not ok or typeof(json) ~= "string" then
			return false, Remote.error("bad_request")
		end
		if #json > Remote.MAX_BODY then
			return false, Remote.error("too_large")
		end
		encoded = json
		headers["Content-Type"] = "application/json"
	end
	local token = if opts.auth ~= false then self:token() else nil
	if opts.auth == true and token == nil then
		return false, Remote.error("unauthorized")
	end
	if token then
		headers["Authorization"] = "Bearer " .. token
	end
	-- the user's own key only goes to an https server (never plain http), and only when the caller
	-- asks for it (AI jobs, key checks): X-Anthropic-Key or X-OpenAI-Key (opts.ownKeyProvider)
	if opts.ownKey and string.match(self:baseUrl(), "^https://") then
		headers[Remote.PROVIDERS[Remote.providerOf(opts.ownKeyProvider)].header] = opts.ownKey
	end
	local request = { Url = self:baseUrl() .. path, Method = method, Headers = headers, Body = encoded }
	local idempotent = if opts.idempotent ~= nil then opts.idempotent else (method == "GET" or method == "DELETE")
	local retries = if opts.retries ~= nil then opts.retries else self.retries

	local attempt = 0
	while true do
		attempt += 1
		local response, failure = self:send(request, opts.timeout)
		local ok, value
		if response then
			ok, value = self:parse(response)
		else
			ok, value = false, failure
		end
		if ok then
			return true, value
		end
		if value.code == "unauthorized" and token ~= nil and self:token() == token then
			-- the server no longer knows this token: forget it
			self:setToken(nil)
			if self.onSignedOut then
				task.spawn(self.onSignedOut)
			end
		elseif value.code == "update_required" and self.onUpdateRequired then
			task.spawn(self.onUpdateRequired)
		end
		local retryable = TRANSIENT[value.code] == true
		if (value.code == "network" or value.code == "timeout") and not idempotent then
			retryable = false
		end
		if not retryable or attempt > retries then
			return false, value
		end
		local delay = value.retryAfter or (2 ^ (attempt - 1))
		if delay > Remote.MAX_RETRY_WAIT then
			return false, value
		end
		if not sleep(math.max(delay, 0.1), opts.cancelled) then
			return false, Remote.error("cancelled")
		end
	end
end

--------------------------------------------------------------------------
-- endpoints
--------------------------------------------------------------------------

-- GET /v1/status: { api, server, latestPlugin, minPlugin, updateUrl, notice, modes }
function Remote:status(): (boolean, any)
	local ok, data = self:request("GET", "/v1/status", nil, { auth = false, retries = 1 })
	if not ok then
		return false, data
	end
	local modes = if typeof(data.modes) == "table" then data.modes else {}
	return true, {
		api = tonumber(data.api),
		server = if typeof(data.server) == "string" then data.server else nil,
		latestPlugin = if typeof(data.latestPlugin) == "string" then data.latestPlugin else nil,
		minPlugin = if typeof(data.minPlugin) == "string" then data.minPlugin else nil,
		updateUrl = if typeof(data.updateUrl) == "string" and string.match(data.updateUrl, "^https://") then data.updateUrl else nil,
		notice = clean(data.notice, 300),
		modes = {
			offline = modes.offline ~= false,
			ai = modes.ai ~= false,
			ownKey = modes.ownKey == true,
			-- whose own keys the server takes (nil: a server from before OpenAI keys, Anthropic only)
			ownKeyProviders = (function()
				if typeof(modes.ownKeyProviders) ~= "table" then
					return nil
				end
				local list = {}
				for _, provider in modes.ownKeyProviders do
					if typeof(provider) == "string" and Remote.PROVIDERS[provider] and not table.find(list, provider) then
						table.insert(list, provider)
					end
				end
				return list
			end)(),
		},
	}
end

-- A finite number, else nil.
local function number(value: any): number?
	if typeof(value) == "number" and value == value and value ~= math.huge and value ~= -math.huge then
		return value
	end
	return nil
end

-- A server Account -> the fields the plugin shows (unknown ones dropped).
function Remote.account(data: any): { [string]: any }?
	if typeof(data) ~= "table" then
		return nil
	end
	local quota = if typeof(data.quota) == "table" then data.quota else {}
	-- a Paid Trial key: { endsAt = unix time, daysLeft, ended }
	local trial = nil
	if typeof(data.trial) == "table" then
		local daysLeft = number(data.trial.daysLeft)
		trial = {
			endsAt = number(data.trial.endsAt),
			daysLeft = if daysLeft then math.max(0, math.floor(daysLeft)) else nil,
			ended = data.trial.ended == true,
		}
	end
	return {
		id = if typeof(data.id) == "string" then data.id else nil,
		name = clean(data.name, 60) or "your account",
		plan = clean(data.plan, 30),
		quota = {
			aiToday = tonumber(quota.aiToday),
			aiPerDay = tonumber(quota.aiPerDay),
			ownKeyToday = tonumber(quota.ownKeyToday),
			ownKeyPerDay = tonumber(quota.ownKeyPerDay),
			generationsToday = tonumber(quota.generationsToday),
			generationsPerDay = tonumber(quota.generationsPerDay),
		},
		-- the free plan (no licence yet): generations and AI are counted per day
		free = data.free == true,
		trial = trial,
		-- where this trial is upgraded to Premium (BloxUI's own site only)
		upgradeUrl = Remote.shopUrl(data.upgradeUrl),
		devices = tonumber(data.devices),
		maxDevices = tonumber(data.maxDevices),
	}
end

local function signedIn(self: any, data: any): (boolean, any)
	if typeof(data) ~= "table" or typeof(data.token) ~= "string" or data.token == "" then
		return false, Remote.error("bad_response")
	end
	self:setToken(data.token)
	return true, Remote.account(data.account) or { name = "your account", quota = {} }
end

-- POST /v1/auth/key: a license key -> signed in (the key itself is never stored).
function Remote:loginKey(key: string?): (boolean, any)
	local normalized = string.upper(Remote.cleanKey(key))
	if normalized == "" then
		return false, { code = "license_invalid", message = "Paste your licence key first." }
	end
	if #normalized > 64 then
		return false, Remote.error("license_invalid")
	end
	local ok, data = self:request("POST", "/v1/auth/key", { key = normalized, device = self:deviceLabel() }, { auth = false, idempotent = true })
	if not ok then
		return false, data
	end
	return signedIn(self, data)
end

-- POST /v1/auth/device: starts "Sign in with Roblox".
-- -> { deviceCode, userCode, verifyUrl, interval, expiresIn, expiresAt }
function Remote:startDeviceLogin(): (boolean, any)
	local ok, data = self:request("POST", "/v1/auth/device", { device = self:deviceLabel() }, { auth = false, idempotent = true })
	if not ok then
		return false, data
	end
	if typeof(data.deviceCode) ~= "string" or typeof(data.userCode) ~= "string" or typeof(data.verifyUrl) ~= "string" then
		return false, Remote.error("bad_response")
	end
	local interval = math.clamp(tonumber(data.interval) or 3, 1, 30)
	local expiresIn = math.clamp(tonumber(data.expiresIn) or 600, 10, 3600)
	return true, {
		deviceCode = data.deviceCode,
		userCode = clean(data.userCode, 32) or data.userCode,
		verifyUrl = clean(data.verifyUrl, 200) or data.verifyUrl,
		interval = interval,
		expiresIn = expiresIn,
		expiresAt = os.clock() + expiresIn,
	}
end

-- POST /v1/auth/token once. -> true, { state = "pending" } | true, { state = "ok", account } | false, error
function Remote:pollDevice(flow: any): (boolean, any)
	local ok, data = self:request("POST", "/v1/auth/token", { deviceCode = flow.deviceCode }, { auth = false, idempotent = true, retries = 0 })
	if not ok then
		return false, data
	end
	local state = data.state
	if state == "pending" then
		return true, { state = "pending" }
	elseif state == "ok" then
		local done, account = signedIn(self, data)
		if not done then
			return false, account
		end
		return true, { state = "ok", account = account }
	elseif state == "denied" then
		local code = if typeof(data.error) == "table" and typeof(data.error.code) == "string" then data.error.code else "not_owner"
		local err = Remote.error(code, { detail = if typeof(data.error) == "table" then data.error.message else nil })
		err.state = "denied"
		return false, err
	elseif state == "expired" then
		local err = Remote.error("expired")
		err.state = "expired"
		return false, err
	end
	return false, Remote.error("bad_response")
end

--[[
	Polls every flow.interval s until the code is approved (true, account),
	denied / expired / cancelled (false, error). Connection hiccups keep it
	polling until the code expires. opts = { cancelled = fn, onPoll = fn }.
]]
function Remote:waitForDevice(flow: any, opts: { [string]: any }?): (boolean, any)
	opts = opts or {}
	local interval = flow.interval or 3
	while true do
		if not sleep(interval, opts.cancelled) then
			return false, Remote.error("cancelled")
		end
		if os.clock() >= (flow.expiresAt or math.huge) then
			local err = Remote.error("expired")
			err.state = "expired"
			return false, err
		end
		local ok, result = self:pollDevice(flow)
		if opts.cancelled and opts.cancelled() then
			return false, Remote.error("cancelled")
		end
		if opts.onPoll then
			opts.onPoll(ok, result)
		end
		if ok and result.state == "ok" then
			return true, result.account
		elseif not ok then
			if result.code == "rate_limited" then
				interval = math.min(interval + 2, 30) -- polling too fast: slow down
			elseif not TRANSIENT[result.code] then
				return false, result
			end
		end
	end
end

-- GET /v1/me -> Account
function Remote:me(): (boolean, any)
	local ok, data = self:request("GET", "/v1/me", nil, { auth = true })
	if not ok then
		return false, data
	end
	return true, Remote.account(data)
end

-- POST /v1/auth/logout, then forgets the token (even when offline).
function Remote:logout(): (boolean, any)
	local token = self:token()
	if token == nil then
		return true, nil
	end
	local ok, data = self:request("POST", "/v1/auth/logout", nil, { auth = true, idempotent = true, retries = 1, timeout = 8 })
	-- forget it (unless someone signed in again while this was on its way)
	if self:token() == token then
		self:setToken(nil)
	end
	return ok, data
end

-- POST /v1/jobs -> jobId
function Remote:createJob(body: { [string]: any }, opts: { [string]: any }?): (boolean, any)
	-- AI jobs run on the user's own key when one is saved for the chosen provider (their bill, their model)
	local ownKey, provider = nil, nil
	if body.mode == "ai" then
		ownKey, provider = self:activeKey()
	end
	local ok, data = self:request("POST", "/v1/jobs", body, {
		auth = true,
		idempotent = false,
		cancelled = opts and opts.cancelled,
		ownKey = ownKey,
		ownKeyProvider = provider,
	})
	if not ok then
		return false, data
	end
	if typeof(data.jobId) ~= "string" or data.jobId == "" then
		return false, Remote.error("bad_response")
	end
	return true, data.jobId
end

-- POST /v1/ai/key-check: does this key (of `provider`, default Anthropic) work?
-- -> true, { model, message } | false, error
function Remote:checkOwnKey(key: string, model: string?, provider: string?): (boolean, any)
	return self:request("POST", "/v1/ai/key-check", { model = model }, {
		auth = true,
		retries = 0,
		timeout = 25,
		ownKey = key,
		ownKeyProvider = Remote.providerOf(provider),
	})
end

-- POST /v1/auth/free: the free plan's session for this install (no sign-in). -> true, account | false, error
function Remote:loginFree(): (boolean, any)
	local ok, data = self:request("POST", "/v1/auth/free", { device = self:installId(), name = self:deviceLabel() }, { auth = false, retries = 1 })
	if not ok then
		return false, data
	end
	if typeof(data.token) ~= "string" or data.token == "" then
		return false, Remote.error("bad_response")
	end
	self:setToken(data.token)
	return true, Remote.account(data.account)
end

function Remote:getJob(jobId: string): (boolean, any)
	return self:request("GET", "/v1/jobs/" .. HttpService:UrlEncode(jobId), nil, { auth = true, retries = 1 })
end

function Remote:cancelJob(jobId: string): (boolean, any)
	return self:request("DELETE", "/v1/jobs/" .. HttpService:UrlEncode(jobId), nil, { auth = true, retries = 1, timeout = 8 })
end

-- Cancels a runJob handle: marks it and sends DELETE once its job exists.
function Remote:cancel(handle: any)
	if typeof(handle) ~= "table" then
		return
	end
	handle.cancelled = true
	if handle.id and not handle.cancelSent then
		handle.cancelSent = true
		task.spawn(function()
			self:cancelJob(handle.id)
		end)
	end
end

--[[
	Creates a job and polls it (every ~1 s, backing off to 2 s) until it is
	done. opts = { handle = {} (remote:cancel(handle) stops it), onProgress =
	fn(job) after every poll }. -> true, job (state "done", job.result) |
	false, error (the job's own error when it failed).
]]
function Remote:runJob(body: { [string]: any }, opts: { [string]: any }?): (boolean, any)
	opts = opts or {}
	local handle = opts.handle or {}
	local function cancelled(): boolean
		return handle.cancelled == true
	end
	local ok, jobId = self:createJob(body, { cancelled = cancelled })
	if not ok then
		return false, jobId
	end
	handle.id = jobId
	if cancelled() then
		self:cancel(handle)
		return false, Remote.error("cancelled")
	end
	local interval = opts.pollFirst or Remote.POLL_FIRST
	local started = os.clock()
	local misses = 0
	while true do
		if not sleep(interval, cancelled) then
			self:cancel(handle)
			return false, Remote.error("cancelled")
		end
		local polled, job = self:getJob(jobId)
		if cancelled() then
			self:cancel(handle)
			return false, Remote.error("cancelled")
		end
		if not polled then
			misses += 1
			if not TRANSIENT[job.code] or misses >= 5 then
				return false, job
			end
		else
			misses = 0
			if opts.onProgress then
				pcall(opts.onProgress, job)
			end
			if job.state == "done" then
				return true, job
			elseif job.state == "failed" then
				local failure = if typeof(job.error) == "table" then job.error else {}
				return false, Remote.error(if typeof(failure.code) == "string" then failure.code else "server_error", { detail = failure.message, upgradeUrl = failure.upgradeUrl })
			elseif job.state == "cancelled" then
				return false, Remote.error("cancelled")
			end
		end
		if os.clock() - started > Remote.JOB_TIMEOUT then
			self:cancel(handle)
			return false, Remote.error("timeout")
		end
		interval = math.min(interval + Remote.POLL_STEP, opts.pollMax or Remote.POLL_MAX)
	end
end

return Remote
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="19">
        <Properties>
          <string name="Name">Store</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Store: all UI Builder state plus the actions that change it.

		local store = Store.new(scope, { plugin = plugin, modules = modules, cloud = cloud })
		store.prompt:set("A fps game UI")
		store:improve()            -- prompt -> Brief (a job on the BloxUI server)
		store:generate()           -- Brief / prompt -> Blueprint (a job on the server)
		store:generate(true)       -- variation: same brief, new seed
		store:setHudLayout("Simulator") -- the layout preset ("Auto" = the server picks)
		store:loadSample("fps")    -- a ready-made blueprint (works signed out / offline)
		store:edit(function(bp) bp.style.corner = "Round" end)
		store:onChanged(fn)        -- fn(structural: boolean) after every edit
		store:removeHistory(entry) -- -> index; insertHistory(entry, index) puts it back
		local revision, structure = store:tabCounters(scope, "Design") -- edit counters gated on a tab

	Generating runs on the BloxUI server (docs/API.md): improve / generate /
	variation are jobs (store.cloud.remote:runJob) the progress panel follows.
	Mode "AI" sends mode = "ai" (Claude, counts toward the daily quota; on the
	user's own OpenAI key ChatGPT, with store.openaiModel) and "Offline" sends
	mode = "offline" (the instant built-in generator); Quality is
	options.quality. The HUD layout preset (store.hudLayout: "Auto" or one
	of Store.HUD_LAYOUTS) goes out as options.hudLayout on generate and
	variation jobs; "Auto" sends nothing (the server picks for the genre).
	Signed out or offline, editing, preview, insert and export still work, and
	"Start from a sample" loads Blueprint.Examples and Blueprint.Samples.

	While a job runs: store.busy ("improve" | "generate"), store.progressStage
	("Designing Shop"), store.progressFraction (0..1, nil = unknown),
	store.progressCount ({ index, total } or nil), store.progressReview (the
	job includes Claude's design review) and store.progress (readable text).

	`store.blueprint` is the plain editable table (the "editor blueprint").
	Hidden things keep `visible = false` in it; Store.compile() strips them
	for the preview and the export.

	Reference screenshots (store.references, up to 2) live in memory for this
	Studio session only: never in the place, the session or the history.
	Their bytes go (base64) to the BloxUI server with the brief request, in
	Claude mode, and nowhere else.

		store:addReference()          -- Studio's file picker -> a chip (yields in its own thread)
		store:removeReference(record)
]]

local HttpService = game:GetService("HttpService")

local Deps = require(script.Parent.Deps)
local Catalog = require(script.Parent.Catalog)
local References = require(script.Parent.References)
local Remote = require(script.Parent.Remote)
local Cloud = require(script.Parent.Cloud)

local Fusion = Deps.Fusion
local peek = Fusion.peek

local Store = {}
Store.__index = Store

-- Model / Quality keep the setting names older builds used.
local SETTING = {
	LegacyApiKey = "BloxUI_ClaudeApiKey", -- older builds kept a Claude key here: removed on start
	Model = "BloxUI_ClaudeModel",
	OpenAIModel = "BloxUI_OpenAIModel", -- the ChatGPT model, when AI runs on the user's OpenAI key
	Quality = "BloxUI_ClaudeQuality",
	Mode = "BloxUIBuilder_Mode",
	History = "BloxUIBuilder_History",
	Session = "BloxUIBuilder_Session",
	AutoPreview = "BloxUIBuilder_AutoPreview",
	MoreOptions = "BloxUIBuilder_MoreOptions", -- the Create tab's "More options" is open
}
Store.SETTING = SETTING

-- options.hudLayout values the server takes (docs/API.md); "Auto" sends none.
Store.HUD_LAYOUTS = { "Simulator", "Classic", "Dock", "Corners", "Minimal", "Rail", "TopBar" }
Store.AUTO_LAYOUT = "Auto"

function Store.isHudLayout(value: any): boolean
	return typeof(value) == "string" and table.find(Store.HUD_LAYOUTS, value) ~= nil
end

Store.MODELS = {
	{ value = "claude-opus-5", label = "Claude Opus 5", hint = "Best quality (default)" },
	{ value = "claude-sonnet-5", label = "Claude Sonnet 5", hint = "Faster and cheaper" },
}
Store.DEFAULT_MODEL = "claude-opus-5"

-- ChatGPT models, when AI runs on the user's own OpenAI key (the server's BloxUI.AI.OPENAI_MODELS).
Store.OPENAI_MODELS = {
	{ value = "gpt-6-sol", label = "GPT-6 Sol", hint = "Best quality for the price (default)" },
	{ value = "gpt-6-luna", label = "GPT-6 Luna", hint = "Faster and much cheaper, simpler designs" },
	{ value = "gpt-6-astra", label = "GPT-6 Astra", hint = "OpenAI's most capable; slower and about 5x Sol's price" },
}
Store.DEFAULT_OPENAI_MODEL = "gpt-6-sol"

-- What AI mode is called for who runs it: "Claude AI", or "AI · ChatGPT" on an OpenAI key.
function Store.aiLabel(provider: string?): string
	return if provider == "openai" then "AI · ChatGPT" else "Claude AI"
end

-- AI quality: "best" adds Claude's design review (one more call); "fast" skips it.
Store.QUALITY = {
	{ value = "best", label = "Best", hint = "Adds a design review that fixes colour, copy, prices and gaps (default)" },
	{ value = "fast", label = "Fast", hint = "Skips the review" },
}
Store.DEFAULT_QUALITY = "best"
Store.HISTORY_LIMIT = 10
Store.HUD = "__hud"

local rng = Random.new()

local function trim(text: any): string
	if typeof(text) ~= "string" then
		return ""
	end
	return (string.gsub(string.gsub(text, "^%s+", ""), "%s+$", ""))
end
Store.trim = trim

local function deepCopy(value: any): any
	if typeof(value) ~= "table" then
		return value
	end
	local out = {}
	for k, v in value do
		out[k] = deepCopy(v)
	end
	return out
end
Store.deepCopy = deepCopy

local function isArray(value: any): boolean
	if typeof(value) ~= "table" then
		return false
	end
	if value[1] ~= nil then
		return true
	end
	return next(value) == nil
end

-- Copy of the editor blueprint without hidden things (visible = false) and
-- without `visible = true` noise. Used for the preview and the export.
function Store.compile(blueprint: any): any
	local hiddenScreens = {}
	for _, screen in (blueprint and blueprint.screens) or {} do
		if typeof(screen) == "table" and screen.visible == false and screen.id then
			hiddenScreens[screen.id] = true
		end
	end
	local function strip(value: any): any
		if typeof(value) ~= "table" then
			return value
		end
		local out = {}
		if isArray(value) then
			for _, item in value do
				if not (typeof(item) == "table" and item.visible == false) then
					table.insert(out, strip(item))
				end
			end
		else
			for k, v in value do
				if k ~= "visible" then
					out[k] = strip(v)
				end
			end
		end
		return out
	end
	local compiled = strip(blueprint)
	if compiled and compiled.startScreen and hiddenScreens[compiled.startScreen] then
		compiled.startScreen = nil
	end
	return compiled
end

-- Short stable hash of a string (djb2), used to spot hand-edited exports.
function Store.hash(text: string): string
	local h = 5381
	for i = 1, #text do
		h = (h * 33 + string.byte(text, i)) % 4294967296
	end
	return string.format("%08x", h)
end

function Store.new(scope: any, ctx: { [string]: any }): any
	local self = setmetatable({}, Store)
	self.scope = scope
	self.plugin = ctx.plugin
	self.modules = ctx.modules or { missing = {}, errors = {} }
	self.listeners = {}
	self.toastFn = nil
	self.expanded = setmetatable({}, { __mode = "k" })

	-- document
	self.blueprint = nil :: any
	self.revision = scope:Value(0) -- every edit
	self.structure = scope:Value(0) -- structural edits (tree rebuild)
	self.hasBlueprint = scope:Value(false)

	-- create
	self.prompt = scope:Value("")
	self.hudLayout = scope:Value(Store.AUTO_LAYOUT) -- the layout preset for the next generation
	self.brief = scope:Value(nil :: any)
	self.briefText = scope:Value("")
	self.briefOriginal = scope:Value("")
	self.briefSource = scope:Value("offline")
	self.briefEdited = scope:Computed(function(use)
		return use(self.brief) ~= nil and trim(use(self.briefText)) ~= trim(use(self.briefOriginal))
	end)
	self.busy = scope:Value(nil :: string?) -- "improve" | "generate" | nil
	self.progress = scope:Value("") -- readable: "Designing Shop (3/8) (45%)"
	self.progressStage = scope:Value("") -- "Designing Shop"
	self.progressFraction = scope:Value(nil :: number?) -- 0..1, nil while unknown
	self.progressCount = scope:Value(nil :: any) -- { index = 3, total = 8 } or nil
	self.progressReview = scope:Value(nil :: boolean?) -- the job includes Claude's design review
	self.lastResult = scope:Value(nil :: any)
	self.lastInput = nil :: any -- what the last Generate used (prompt text, brief text or a brief)
	self.lastBrief = nil :: any -- the brief the server designed the last UI from (for variations)
	self.remoteHandle = nil :: any -- the running server job (Cancel sends DELETE)
	self.samplePicks = {} -- [genre] = the sample shown last (they take turns)
	self.history = scope:Value({})
	self.job = 0
	-- reference screenshots (memory only) and which ones the brief was written with
	self.references = scope:Value({})
	self.referencesKey = scope:Computed(function(use)
		return Store.referenceKey(use(self.references))
	end)
	self.briefReferencesKey = scope:Value("")
	self.picking = scope:Value(false) -- the file picker is open
	self.referenceSerial = 0

	-- the BloxUI server (sign-in, status, jobs)
	self.cloud = ctx.cloud

	-- settings
	if self:getSetting(SETTING.LegacyApiKey) ~= nil then
		-- the server pays for Claude now: an old personal key isn't needed, so it isn't kept
		self:setSetting(SETTING.LegacyApiKey, nil)
	end
	local mode = self:getSetting(SETTING.Mode)
	local model = self:getSetting(SETTING.Model)
	local openaiModel = self:getSetting(SETTING.OpenAIModel)
	local quality = self:getSetting(SETTING.Quality)
	self.mode = scope:Value(if mode == "AI" then "AI" else "Offline")
	self.model = scope:Value(if typeof(model) == "string" and model ~= "" then model else Store.DEFAULT_MODEL)
	self.openaiModel = scope:Value(if typeof(openaiModel) == "string" and openaiModel ~= "" then openaiModel else Store.DEFAULT_OPENAI_MODEL)
	self.quality = scope:Value(if quality == "fast" or quality == "best" then quality else Store.DEFAULT_QUALITY)
	local auto = self:getSetting(SETTING.AutoPreview)
	self.autoPreview = scope:Value(auto ~= false)
	self.moreOptions = scope:Value(self:getSetting(SETTING.MoreOptions) == true)

	-- navigation / preview
	self.tab = scope:Value("Create")
	self.previewOn = scope:Value(false)
	self.previewScreen = scope:Value(Store.HUD)
	self.device = scope:Value("Desktop")
	self.previewError = scope:Value(nil :: string?)

	-- export
	self.exportName = scope:Value("")
	self.exportNameAuto = true
	self.addServer = scope:Value(false)
	self.saved = scope:Value({})

	self:loadHistory()
	return self
end

--------------------------------------------------------------------------
-- plumbing
--------------------------------------------------------------------------

function Store:getSetting(key: string): any
	if self.plugin == nil then
		return nil
	end
	local ok, value = pcall(function()
		return self.plugin:GetSetting(key)
	end)
	return if ok then value else nil
end

function Store:setSetting(key: string, value: any)
	if self.plugin == nil then
		return
	end
	pcall(function()
		self.plugin:SetSetting(key, value)
	end)
end

function Store:toast(text: string, tone: string?, opts: any?)
	if self.toastFn then
		self.toastFn(text, tone, opts)
	end
end

function Store:onChanged(fn: (boolean) -> ()): () -> ()
	table.insert(self.listeners, fn)
	return function()
		local index = table.find(self.listeners, fn)
		if index then
			table.remove(self.listeners, index)
		end
	end
end

--[[
	Copies of `revision` and `structure` that only follow edits while `tab` is
	the open tab, and catch up once when it opens again. UI that depends on
	them instead of the store's counters stays out of Fusion's change graph
	while hidden (Fusion invalidates every dependent eagerly), so editing in
	one tab stays cheap however big the others are. `scope` owns the watchers.
]]
function Store:tabCounters(scope: any, tab: string): (any, any)
	local revision = scope:Value(peek(self.revision))
	local structure = scope:Value(peek(self.structure))
	local function sync()
		if peek(self.tab) ~= tab then
			return
		end
		if peek(revision) ~= peek(self.revision) then
			revision:set(peek(self.revision))
		end
		if peek(structure) ~= peek(self.structure) then
			structure:set(peek(self.structure))
		end
	end
	table.insert(scope, scope:Observer(self.revision):onChange(sync))
	table.insert(scope, scope:Observer(self.structure):onChange(sync))
	table.insert(scope, scope:Observer(self.tab):onChange(sync))
	return revision, structure
end

-- Call after any edit to `store.blueprint`.
function Store:changed(structural: boolean?)
	self.revision:set(peek(self.revision) + 1)
	if structural then
		self.structure:set(peek(self.structure) + 1)
	end
	for _, fn in table.clone(self.listeners) do
		task.spawn(fn, structural == true)
	end
end

function Store:edit(fn: (any) -> (), structural: boolean?)
	local bp = self.blueprint
	if bp == nil then
		return
	end
	fn(bp)
	self:changed(structural)
end

function Store:encode(value: any): string?
	local Blueprint = self.modules.Blueprint
	if Blueprint and typeof(Blueprint.encode) == "function" then
		local ok, json = pcall(Blueprint.encode, value)
		if ok and typeof(json) == "string" then
			return json
		end
	end
	local ok, json = pcall(function()
		return HttpService:JSONEncode(value)
	end)
	return if ok then json else nil
end

-- raw = true keeps editor-only data (e.g. `visible = false` on sections and
-- items); use it for the builder's own copies (history, session, EditorSource).
function Store:decode(json: string, raw: boolean?): any
	local Blueprint = self.modules.Blueprint
	if not raw and Blueprint and typeof(Blueprint.decode) == "function" then
		local ok, value = pcall(Blueprint.decode, json)
		if ok and typeof(value) == "table" then
			return value
		end
	end
	local ok, value = pcall(function()
		return HttpService:JSONDecode(json)
	end)
	return if ok and typeof(value) == "table" then value else nil
end

-- Runs Blueprint.validate when available; returns the normalized blueprint.
function Store:normalize(blueprint: any): any
	local Blueprint = self.modules.Blueprint
	if Blueprint == nil then
		return blueprint
	end
	-- validate(anything) -> ok, warnings, normalizedBlueprint (never throws)
	local called, _ok, _warnings, normalized = pcall(Blueprint.validate, blueprint)
	if called and typeof(normalized) == "table" then
		return normalized
	end
	return blueprint
end

function Store:compiled(): any
	if self.blueprint == nil then
		return nil
	end
	return Store.compile(self.blueprint)
end

--------------------------------------------------------------------------
-- mode, model, quality
--------------------------------------------------------------------------

-- Removes anything secret-looking from text we show or warn: the sign-in
-- token, bearer headers and long base64 runs (screenshot data).
function Store:redact(text: any): string
	local message = tostring(text or "")
	local token = self.cloud and self.cloud.remote and self.cloud.remote:token()
	if token and #token >= 8 then
		local start = 1
		while true do
			local i, j = string.find(message, token, start, true)
			if i == nil then
				break
			end
			message = string.sub(message, 1, i - 1) .. "[token]" .. string.sub(message, j + 1)
			start = i + 7
		end
	end
	message = string.gsub(message, "[Bb]earer%s+[%w%-%._~%+/=]+", "Bearer [token]")
	-- image data (a reference screenshot as base64) never reaches a toast or the output
	message = string.gsub(message, "[%w%+/]+=*", function(run: string): string?
		return if #run >= 200 then "[image data]" else nil
	end)
	return message
end

-- "AI" (Claude or, on the user's OpenAI key, ChatGPT: mode = "ai") or "Offline" (the instant
-- generator, mode = "offline").
function Store:setMode(mode: string)
	mode = if mode == "AI" then "AI" else "Offline"
	self.mode:set(mode)
	self:setSetting(SETTING.Mode, mode)
	local status = self.cloud and peek(self.cloud.status)
	local ownKey = self.cloud and self.cloud.ownKeyReady ~= nil and self.cloud:ownKeyReady()
	if mode == "AI" and status and status.modes and status.modes.ai == false and not ownKey then
		self:toast(Cloud.REASONS.ai_unavailable, "warning")
	end
end

-- The Claude model, or (provider "openai") the ChatGPT model; each is remembered on its own.
function Store:setModel(model: string, provider: string?)
	if provider == "openai" then
		self.openaiModel:set(model)
		self:setSetting(SETTING.OpenAIModel, model)
		return
	end
	self.model:set(model)
	self:setSetting(SETTING.Model, model)
end

-- Who runs AI mode now ("openai" on the user's OpenAI key, else "anthropic"): see Cloud.aiProvider.
function Store:aiProvider(use: any?): string
	local cloud = self.cloud
	if cloud == nil or cloud.aiProvider == nil then
		return "anthropic"
	end
	return if use then use(cloud.aiProvider) else peek(cloud.aiProvider)
end

-- The model AI jobs name for `provider` (default: who runs AI now).
function Store:modelFor(provider: string?, use: any?): string
	local read = use or peek
	provider = provider or self:aiProvider(use)
	return if provider == "openai" then read(self.openaiModel) else read(self.model)
end

function Store:setQuality(quality: string)
	if quality ~= "fast" and quality ~= "best" then
		quality = Store.DEFAULT_QUALITY
	end
	self.quality:set(quality)
	self:setSetting(SETTING.Quality, quality)
end

-- Opus 5 (default) + Sonnet 5 (options.model; the server has the key); provider "openai": the
-- ChatGPT models for the user's OpenAI key.
function Store:modelOptions(provider: string?): { any }
	return table.clone(if provider == "openai" then Store.OPENAI_MODELS else Store.MODELS)
end

function Store:setAutoPreview(on: boolean)
	self.autoPreview:set(on)
	self:setSetting(SETTING.AutoPreview, on)
end

-- The Create tab's layout preset: "Auto" or one of Store.HUD_LAYOUTS (anything
-- else counts as Auto). Saved with the session.
function Store:setHudLayout(layout: string?)
	local value = if Store.isHudLayout(layout) then layout :: string else Store.AUTO_LAYOUT
	if peek(self.hudLayout) == value then
		return
	end
	self.hudLayout:set(value)
	self:saveSession()
end

-- "More options" on the Create tab (remembered on this computer).
function Store:setMoreOptions(open: boolean)
	self.moreOptions:set(open == true)
	self:setSetting(SETTING.MoreOptions, open == true)
end

-- blueprint.hud.layout of the design being edited (Structure, the result card).
function Store:setBlueprintLayout(layout: string)
	local Blueprint = self.modules.Blueprint
	self:edit(function(bp)
		bp.hud = bp.hud or { layout = "Classic", elements = {} }
		-- a 2.3 runtime moves the currencies and the menu to the new layout's places in the design
		-- itself (so the Structure anchors match); an older payload only knows the field
		if Blueprint and typeof(Blueprint.setLayout) == "function" then
			local ok, moved = pcall(Blueprint.setLayout, bp, layout)
			if ok and moved then
				return
			end
		end
		bp.hud.layout = layout
	end)
end

-- Claude mode (mode = "ai" jobs).
function Store:useAI(): boolean
	return peek(self.mode) == "AI"
end

--------------------------------------------------------------------------
-- reference screenshots (memory only, this session)
--------------------------------------------------------------------------

-- "3,5": which references are attached (the brief goes stale when it changes).
function Store.referenceKey(list: any): string
	local ids = {}
	for _, record in (if typeof(list) == "table" then list else {}) do
		table.insert(ids, tostring(record.id))
	end
	return table.concat(ids, ",")
end

function Store:referenceLimits(): { count: number, bytes: number }
	return References.limits()
end

-- Opens Studio's file picker and attaches the picked screenshot. Runs on its
-- own thread (the picker yields); `done(ok, message?)` is for tests.
function Store:addReference(done: ((boolean, string?) -> ())?)
	local limits = self:referenceLimits()
	local finish = function(ok: boolean, message: string?)
		if done then
			done(ok, message)
		end
	end
	if #peek(self.references) >= limits.count then
		self:toast(`Up to {limits.count} references. Remove one to add another.`, "warning")
		finish(false, "limit")
		return
	end
	if peek(self.picking) then
		finish(false, "busy")
		return
	end
	self.picking:set(true)
	task.spawn(function()
		local used = 0
		for _, record in peek(self.references) do
			used += record.size or 0
		end
		local ok, result = References.pick(used)
		self.picking:set(false)
		if self.destroyed then
			return
		end
		if not ok then
			self:toast(self:redact(result), "warning")
			finish(false, result)
			return
		end
		if result == nil then
			finish(false, nil) -- cancelled: nothing to say
			return
		end
		local list = table.clone(peek(self.references))
		if #list >= limits.count then
			self:toast(`Up to {limits.count} references. Remove one to add another.`, "warning")
			finish(false, "limit")
			return
		end
		self.referenceSerial += 1
		result.id = self.referenceSerial
		table.insert(list, result)
		self.references:set(list)
		finish(true, nil)
	end)
end

function Store:removeReference(record: any)
	local list = table.clone(peek(self.references))
	local index = table.find(list, record)
	if index then
		table.remove(list, index)
		self.references:set(list)
	end
end

-- What the server takes (options.references): { { mediaType, data = base64 } }.
function Store:referencePayload(): { any }?
	return References.payload(peek(self.references))
end

--------------------------------------------------------------------------
-- jobs (improve / generate)
--------------------------------------------------------------------------

--[[
	Records one progress report: (stage, fraction), (fraction), or a table
	{ message|text|stage, fraction } (job polls go through applyJobProgress).
	A stage like "Designing Shop (3/8)…" becomes progressStage "Designing Shop"
	and progressCount { index = 3, total = 8 }. A report without a fraction
	keeps the last one.
]]
function Store:setProgress(a: any, b: any?)
	local stage, fraction = nil, nil
	if typeof(a) == "table" then
		stage = a.message or a.text or a.stage or a.status
		fraction = a.fraction or a.progress
	elseif typeof(a) == "number" and b == nil then
		fraction = a
	else
		stage, fraction = a, b
	end
	if typeof(stage) == "string" and stage ~= "" then
		stage = self:redact(stage)
		local index, total = string.match(stage, "%((%d+)/(%d+)%)")
		local clean = string.gsub(stage, "%s*%(%d+/%d+%)", "")
		clean = trim(clean)
		clean = string.gsub(clean, "…$", "")
		clean = string.gsub(clean, "%.+$", "")
		self.progressStage:set(trim(clean))
		self.progressCount:set(if index then { index = tonumber(index), total = tonumber(total) } else nil)
	end
	if typeof(fraction) == "number" and fraction == fraction then
		self.progressFraction:set(math.clamp(fraction, 0, 1))
	end
	local text = peek(self.progressStage)
	local count = peek(self.progressCount)
	if count then
		text ..= ` ({count.index}/{count.total})`
	end
	local current = peek(self.progressFraction)
	if current then
		text ..= ` ({math.floor(current * 100 + 0.5)}%)`
	end
	self.progress:set(text)
end

function Store:clearProgress()
	self.progressStage:set("")
	self.progressFraction:set(nil)
	self.progressCount:set(nil)
	self.progressReview:set(nil)
	self.progress:set("")
end

-- One job poll (docs/API.md GET /v1/jobs/{id}) -> the progress panel.
function Store:applyJobProgress(state: any)
	if typeof(state) ~= "table" then
		return
	end
	self:setProgress(if typeof(state.stage) == "string" then state.stage else nil, tonumber(state.fraction))
	local screens = state.screens
	if typeof(screens) == "table" and tonumber(screens.total) and tonumber(screens.total) > 0 then
		self.progressCount:set({ index = math.max(0, tonumber(screens.done) or 0), total = tonumber(screens.total) })
		self:setProgress(nil, nil) -- refresh the readable text
	end
	if state.review == true then
		self.progressReview:set(true)
	end
end

-- Why nothing can be generated right now (nil = it can): see Cloud:readiness.
function Store:blocker(): string?
	if self.cloud == nil then
		return "offline"
	end
	local ok, reason = self.cloud:readiness(peek(self.mode))
	return if ok then nil else reason
end

-- Says why generating can't happen and offers the way out.
function Store:explainBlocker(reason: string)
	local text = Cloud.REASONS[reason] or Remote.MESSAGES.network
	local action = nil
	if reason == "signed_out" then
		action = {
			label = "Sign in",
			run = function()
				self.tab:set("Settings")
			end,
		}
	elseif reason == "offline" and self.cloud then
		action = {
			label = "Retry",
			run = function()
				self.cloud:refreshStatus()
			end,
		}
	elseif reason == "ai_unavailable" then
		action = {
			label = "Use Instant",
			run = function()
				self:setMode("Offline")
			end,
		}
	elseif reason == "update_required" and self.showUpdate then
		action = { label = "Update", run = self.showUpdate }
	end
	self:toast(text, "warning", { action = action })
end

-- The plan said no (the Paid Trial's day or days are used up, a Premium feature, a free session):
-- the server's own sentence says what to do.
local PLAN_REFUSALS = { trial_limit = true, trial_ended = true, free_limit = true, premium_required = true }

-- The account's counters moved: Claude designs (AI jobs) and, on the Paid Trial or a free session,
-- every generation ("2 of 3 left today").
function Store:refreshQuota(useAI: boolean?)
	local cloud = self.cloud
	if cloud and (useAI or peek(cloud.trial) or peek(cloud.free)) then
		cloud:refreshAccount()
	end
end

-- A failed job -> one clear toast (and the fix, when there is one).
function Store:jobFailed(err: any)
	local code = if typeof(err) == "table" then err.code else nil
	if code == "cancelled" then
		return
	end
	local message = self:redact(Remote.messageOf(err))
	local action = nil
	if PLAN_REFUSALS[code] then
		-- the badge, "x of 3 left" and the plan catch up; Settings > Account has the upgrade link
		if self.cloud then
			self.cloud:refreshAccount()
		end
		action = {
			label = if err.upgradeUrl then "Upgrade" else "Get a key",
			run = function()
				self.tab:set("Settings")
			end,
		}
	elseif code == "unauthorized" then
		action = {
			label = "Sign in",
			run = function()
				self.tab:set("Settings")
			end,
		}
	elseif code == "update_required" and self.showUpdate then
		action = { label = "Update", run = self.showUpdate }
	elseif code == "quota_exceeded" then
		action = {
			label = "Use Instant",
			run = function()
				self:setMode("Offline")
			end,
		}
		if self.cloud then
			self.cloud:refreshAccount()
		end
	elseif (code == "network" or code == "timeout" or code == "unavailable") and self.cloud then
		self.cloud.reachable:set(false)
		action = {
			label = "Retry",
			run = function()
				self.cloud:refreshStatus()
			end,
		}
	end
	local limit = code == "quota_exceeded" or code == "rate_limited" or PLAN_REFUSALS[code] == true
	self:toast(message, if limit then "warning" else "error", { action = action })
end

--[[
	Runs one server job (body = docs/API.md POST /v1/jobs) on its own thread:
	the progress panel follows its polls, Cancel sends DELETE, and
	finish(result, info) runs when it's done (unless cancelled).
]]
function Store:startJob(kind: string, body: { [string]: any }, finish: (any, any) -> ())
	if peek(self.busy) ~= nil then
		return
	end
	self.job += 1
	local job = self.job
	local handle = {}
	self.remoteHandle = handle
	self:clearProgress()
	self:setProgress(if kind == "improve" then "Writing the design brief" else "Designing your UI")
	-- Claude's design review runs in AI mode at quality "best" (the job says so too)
	self.progressReview:set(kind ~= "improve" and body.mode == "ai" and body.options and body.options.quality ~= "fast")
	self.busy:set(kind)
	task.spawn(function()
		task.wait() -- let the busy state render first
		if job ~= self.job then
			return
		end
		local ok, result = self.cloud.remote:runJob(body, {
			handle = handle,
			onProgress = function(state)
				if job == self.job then
					self:applyJobProgress(state)
				end
			end,
		})
		if job ~= self.job then
			return -- cancelled
		end
		self.remoteHandle = nil
		self.busy:set(nil)
		self:clearProgress()
		if not ok then
			self:jobFailed(result)
			return
		end
		local output = if typeof(result.result) == "table" then result.result else {}
		local info = if typeof(result.info) == "table" then result.info else {}
		local done, err = xpcall(finish, debug.traceback, output, info)
		if not done then
			local message = self:redact(err)
			self:toast("Something went wrong: " .. (string.match(message, "^[^\n]*") or message), "error")
			warn("[BloxUI Builder] " .. message)
		end
	end)
end

function Store:cancel()
	if peek(self.busy) == nil then
		return
	end
	self.job += 1
	if self.remoteHandle and self.cloud then
		self.cloud.remote:cancel(self.remoteHandle) -- DELETE /v1/jobs/{id}
	end
	self.remoteHandle = nil
	self.busy:set(nil)
	self:clearProgress()
	self:toast("Cancelled.", "info")
end

-- options for POST /v1/jobs (seed, quality, model in Claude mode, references,
-- and on generate / variation jobs the HUD layout preset unless it's Auto).
function Store:jobOptions(seed: number?, references: { any }?, kind: string?): { [string]: any }
	local options: { [string]: any } = {
		seed = seed or rng:NextInteger(1, 2147483646),
		quality = peek(self.quality),
	}
	if self:useAI() then
		-- the Claude model, or the ChatGPT one when the job carries the user's OpenAI key
		options.model = self:modelFor()
		options.references = references
	end
	local layout = peek(self.hudLayout)
	if (kind == "generate" or kind == "variation") and Store.isHudLayout(layout) then
		options.hudLayout = layout
	end
	return options
end

-- Instant mode can't read screenshots: say so plainly when some are attached.
function Store:warnReferencesOffline()
	if #peek(self.references) > 0 then
		local mode = if self:aiProvider() == "openai" then "AI mode" else "Claude mode"
		self:toast(`References need {mode}. The instant generator ignored them.`, "warning")
	end
end

-- Checks shared by Improve and Generate. Returns true when a job may start.
function Store:canStart(prompt: string?): boolean
	local reason = self:blocker()
	if reason then
		self:explainBlocker(reason)
		return false
	end
	if prompt and #prompt > Remote.MAX_PROMPT then
		self:toast(`Your prompt is {#prompt} characters; the limit is {Remote.MAX_PROMPT}. Shorten it and try again.`, "warning")
		return false
	end
	return true
end

-- The server fell back to the instant generator (info.source ~= "ai").
local function fellBack(useAI: boolean, info: any): boolean
	return useAI and typeof(info) == "table" and info.source ~= nil and info.source ~= "ai"
end

function Store:improve()
	local prompt = trim(peek(self.prompt))
	if prompt == "" then
		self:toast("Describe your game first (or pick an example).", "warning")
		return
	end
	if not self:canStart(prompt) then
		return
	end
	local useAI = self:useAI()
	local references = if useAI then self:referencePayload() else nil
	local referencesKey = if useAI then peek(self.referencesKey) else ""
	local body = {
		kind = "improve",
		mode = if useAI then "ai" else "offline",
		input = { prompt = prompt },
		options = self:jobOptions(nil, references),
	}
	self:startJob("improve", body, function(result, info)
		local brief = result.brief
		if typeof(brief) ~= "table" then
			self:toast("The server sent no brief back. Try again.", "error")
			return
		end
		local text = if typeof(brief.text) == "string" then brief.text else ""
		local source = if info.source == "ai" or info.source == "offline" then info.source elseif useAI then "ai" else "offline"
		if typeof(brief.prompt) ~= "string" or brief.prompt == "" then
			brief.prompt = prompt
		end
		self.brief:set(brief)
		self.briefOriginal:set(text)
		self.briefText:set(text)
		self.briefSource:set(source)
		self.briefReferencesKey:set(if source == "ai" then referencesKey else "")
		if fellBack(useAI, info) then
			self:toast("Claude wasn't available, so the instant generator wrote this brief.", "warning")
		else
			self:toast(`Brief ready: {brief.title or "your game"}. Edit it or hit Generate.`, if source == "ai" then "ai" else "success")
		end
		if not useAI then
			self:warnReferencesOffline()
		end
		self:referenceWarnings(info)
		self:refreshQuota(useAI) -- the quota moved
		self:saveSession()
	end)
end

-- A reference Claude couldn't use (too big, not an image) is worth a toast.
function Store:referenceWarnings(info: any)
	if typeof(info) ~= "table" or typeof(info.warnings) ~= "table" then
		return
	end
	for _, warning in info.warnings do
		if typeof(warning) == "string" and string.find(warning, "^Reference skipped: ") then
			self:toast(self:redact(string.sub(warning, 20)), "warning")
		end
	end
end

-- The brief text if the user edited it, else the Brief table, else the prompt.
function Store:currentInput(): any
	local brief = peek(self.brief)
	if brief ~= nil then
		if peek(self.briefEdited) then
			local text = trim(peek(self.briefText))
			if text ~= "" then
				return text
			end
		end
		return brief
	end
	local prompt = trim(peek(self.prompt))
	if prompt == "" then
		return nil
	end
	return prompt
end

-- An input from currentInput() as the API's job input:
-- { prompt = ... } | { briefText = ... } (your edited brief) | { brief = {...} }.
function Store:jobInput(input: any): { [string]: any }?
	if typeof(input) == "table" then
		return { brief = input }
	elseif typeof(input) == "string" then
		if peek(self.brief) ~= nil and peek(self.briefEdited) and input == trim(peek(self.briefText)) then
			return { briefText = input }
		end
		return { prompt = input }
	end
	return nil
end

function Store:generate(variation: boolean?)
	local input = if variation and self.lastInput ~= nil then self.lastInput else self:currentInput()
	-- a variation varies the brief the server designed from, else the current design
	local base = nil
	if variation then
		if typeof(self.lastBrief) == "table" then
			base = { brief = self.lastBrief }
		elseif typeof(input) == "table" then
			base = { brief = input }
		elseif self.blueprint ~= nil then
			base = { blueprint = Store.compile(self.blueprint) }
		end
	end
	if input == nil and base == nil then
		self:toast("Describe your game first (or pick an example).", "warning")
		return
	end
	local jobInput = base or self:jobInput(input)
	-- the 2,000-character limit is the prompt's (an edited brief can be longer)
	if not self:canStart(jobInput and jobInput.prompt) then
		return
	end
	local useAI = self:useAI()
	if input ~= nil then
		self.lastInput = input
	end
	local seed = rng:NextInteger(1, 2147483646)
	local promptText = if typeof(input) == "string" then input elseif typeof(input) == "table" then (input.prompt or peek(self.prompt)) else peek(self.prompt)
	-- screenshots only matter when Claude writes the brief first (a prompt or
	-- an edited brief text); a written brief already carries their style
	local references = if useAI and base == nil and typeof(input) == "string" then self:referencePayload() else nil
	local kind = if base then "variation" else "generate"
	local body = {
		kind = kind,
		mode = if useAI then "ai" else "offline",
		input = jobInput,
		options = self:jobOptions(seed, references, kind),
	}
	self:startJob("generate", body, function(result, info)
		local blueprint = result.blueprint
		if typeof(blueprint) ~= "table" then
			self:toast("The server sent no UI back. Try again.", "error")
			return
		end
		local nextBrief = if typeof(result.brief) == "table" then result.brief elseif base and base.brief then base.brief else nil
		local normalized = self:normalize(blueprint)
		normalized.meta = normalized.meta or {}
		normalized.meta.seed = normalized.meta.seed or seed
		normalized.meta.source = if info.source == "ai" or info.source == "offline"
			then info.source
			else (normalized.meta.source or (if useAI then "ai" else "offline"))
		if normalized.meta.prompt == nil and typeof(promptText) == "string" and promptText ~= "" then
			normalized.meta.prompt = string.sub(promptText, 1, 400)
		end
		self:setBlueprint(normalized, { history = true, source = normalized.meta.source })
		self.lastBrief = nextBrief
		if fellBack(useAI, info) then
			self:toast("Claude wasn't available, so the instant generator made this UI.", "warning")
		else
			local title = normalized.meta.title or (normalized.logo and normalized.logo.text) or "your UI"
			self:toast(`{if variation then "Variation" else "Generated"}: {title}`, if normalized.meta.source == "ai" then "ai" else "success")
		end
		if not useAI and base == nil and typeof(input) == "string" then
			self:warnReferencesOffline()
		end
		self:referenceWarnings(info)
		self:refreshQuota(useAI) -- the quota moved
		if peek(self.autoPreview) and not peek(self.previewOn) then
			self.previewOn:set(true)
		end
	end)
end

--------------------------------------------------------------------------
-- samples (work signed out and offline)
--------------------------------------------------------------------------

-- Genres with a ready-made blueprint (Blueprint.Examples + Blueprint.Samples):
-- { { value = "fps", label = "FPS", icon, count } } in genre order.
function Store:sampleGenres(): { any }
	local out = {}
	local Samples = self.modules.Samples
	local Examples = self.modules.Blueprint and self.modules.Blueprint.Examples
	for _, genre in Catalog.genres do
		local count = if Samples then #Samples.forGenre(genre.value) else 0
		if typeof(Examples) == "table" and typeof(Examples[genre.value]) == "table" then
			count += 1
		end
		if count > 0 then
			table.insert(out, { value = genre.value, label = genre.label, icon = genre.icon, count = count })
		end
	end
	return out
end

-- Loads the next ready-made blueprint for a genre (the hand-made example
-- first, then the pre-generated samples; each click shows the next one).
function Store:loadSample(genre: string): boolean
	local candidates = {}
	local Samples = self.modules.Samples
	local Examples = self.modules.Blueprint and self.modules.Blueprint.Examples
	if typeof(Examples) == "table" and typeof(Examples[genre]) == "table" then
		table.insert(candidates, { example = genre })
	end
	if Samples then
		for _, name in Samples.forGenre(genre) do
			table.insert(candidates, { sample = name })
		end
	end
	if #candidates == 0 then
		self:toast("There's no sample for that genre in this plugin build.", "warning")
		return false
	end
	local index = (self.samplePicks[genre] or 0) % #candidates + 1
	self.samplePicks[genre] = index
	local pick = candidates[index]
	local blueprint = if pick.example then deepCopy(Examples[pick.example]) else Samples.get(pick.sample)
	if typeof(blueprint) ~= "table" then
		self:toast("That sample couldn't be loaded.", "error")
		return false
	end
	local normalized = self:normalize(blueprint)
	normalized.meta = normalized.meta or {}
	self.lastInput = nil
	self:setBlueprint(normalized, { history = true, source = "sample" })
	local label = genre
	for _, entry in Catalog.genres do
		if entry.value == genre then
			label = entry.label
		end
	end
	self:toast(`Sample loaded: {Store.titleOf(normalized)} ({label}). Customize, preview or insert it.`, "success")
	if peek(self.autoPreview) and not peek(self.previewOn) then
		self.previewOn:set(true)
	end
	self:saveSession()
	return true
end

--------------------------------------------------------------------------
-- document
--------------------------------------------------------------------------

local function counts(bp: any): (number, number)
	local hud = 0
	for _, element in (bp.hud and bp.hud.elements) or {} do
		if element.visible ~= false then
			hud += 1
		end
	end
	local screens = 0
	for _, screen in bp.screens or {} do
		if screen.visible ~= false then
			screens += 1
		end
	end
	return hud, screens
end
Store.counts = counts

function Store.titleOf(bp: any): string
	if typeof(bp) ~= "table" then
		return "Untitled"
	end
	local meta = bp.meta or {}
	return meta.title or (bp.logo and bp.logo.text) or "Untitled"
end

-- "Neon Strike!" -> "NeonStrike"
function Store.safeName(text: string?): string
	local name = string.gsub(tostring(text or ""), "[^%w]+", " ")
	local out = {}
	for word in string.gmatch(name, "%S+") do
		table.insert(out, string.upper(string.sub(word, 1, 1)) .. string.sub(word, 2))
	end
	local joined = table.concat(out)
	if joined == "" then
		joined = "MyGameUI"
	end
	if string.match(joined, "^%d") then
		joined = "UI" .. joined
	end
	return string.sub(joined, 1, 40)
end

function Store:setBlueprint(bp: any, opts: { [string]: any }?)
	opts = opts or {}
	bp.meta = bp.meta or {}
	bp.style = bp.style or {}
	bp.style.palette = bp.style.palette or {}
	bp.style.fonts = bp.style.fonts or {}
	bp.logo = bp.logo or {}
	bp.hud = bp.hud or { layout = "Classic", elements = {} }
	bp.hud.elements = bp.hud.elements or {}
	bp.screens = bp.screens or {}
	bp.currencies = bp.currencies or {}
	-- v1.8: remember the generator's HUD kit (the Design tab's "Auto")
	if bp.meta.kit == nil and Catalog.hudKitName(bp.hud.kit) ~= nil then
		bp.meta.kit = Catalog.hudKitName(bp.hud.kit)
	end
	self.blueprint = bp
	self.expanded = setmetatable({}, { __mode = "k" })
	self.hasBlueprint:set(true)
	self.lastBrief = nil -- variations follow the design on screen (generate sets it again)

	local hud, screens = counts(bp)
	self.lastResult:set({
		title = Store.titleOf(bp),
		genre = bp.meta.genre or "",
		source = if opts.source == "sample" then "sample" else (bp.meta.source or opts.source or "offline"),
		seed = bp.meta.seed,
		hud = hud,
		screens = screens,
	})

	if self.exportNameAuto or trim(peek(self.exportName)) == "" then
		self.exportName:set(opts.exportName or Store.safeName(Store.titleOf(bp)))
		self.exportNameAuto = opts.exportName == nil
	end

	local selected = peek(self.previewScreen)
	if selected ~= Store.HUD then
		local found = false
		for _, screen in bp.screens do
			if screen.id == selected then
				found = true
			end
		end
		if not found then
			self.previewScreen:set(Store.HUD)
		end
	end

	if opts.history then
		self:pushHistory(bp, if opts.source == "sample" then "sample" else nil)
	end
	self:changed(true)
end

function Store:setExportName(name: string)
	self.exportName:set(name)
	self.exportNameAuto = false
end

--------------------------------------------------------------------------
-- history + session (plugin settings, this machine only)
--------------------------------------------------------------------------

function Store:loadHistory()
	local raw = self:getSetting(SETTING.History)
	if typeof(raw) ~= "string" or raw == "" then
		return
	end
	local ok, list = pcall(function()
		return HttpService:JSONDecode(raw)
	end)
	if ok and typeof(list) == "table" then
		local clean = {}
		for _, entry in list do
			if typeof(entry) == "table" and typeof(entry.json) == "string" then
				table.insert(clean, entry)
			end
		end
		self.history:set(clean)
	end
end

function Store:saveHistory()
	local ok, raw = pcall(function()
		return HttpService:JSONEncode(peek(self.history))
	end)
	if ok then
		self:setSetting(SETTING.History, raw)
	end
end

function Store:pushHistory(bp: any, source: string?)
	local json = self:encode(bp)
	if json == nil then
		return
	end
	local hud, screens = counts(bp)
	local entry = {
		id = HttpService:GenerateGUID(false),
		title = Store.titleOf(bp),
		genre = (bp.meta and bp.meta.genre) or "",
		source = source or (bp.meta and bp.meta.source) or "offline",
		seed = bp.meta and bp.meta.seed,
		prompt = trim(peek(self.prompt)),
		time = os.time(),
		hud = hud,
		screens = screens,
		json = json,
	}
	local list = table.clone(peek(self.history))
	table.insert(list, 1, entry)
	while #list > Store.HISTORY_LIMIT do
		table.remove(list)
	end
	self.history:set(list)
	self:saveHistory()
end

function Store:restoreHistory(entry: any)
	local bp = self:decode(entry.json, true)
	if bp == nil then
		self:toast("That history entry could not be read.", "error")
		return
	end
	if entry.prompt and entry.prompt ~= "" then
		self.prompt:set(entry.prompt)
	end
	self:setBlueprint(bp, { history = false, source = if entry.source == "sample" then "sample" else nil })
	self:toast(`Restored {entry.title or "generation"}.`, "info")
end

function Store:clearHistory()
	self.history:set({})
	self:saveHistory()
end

-- Removes one history entry; returns where it was (for insertHistory / Undo).
function Store:removeHistory(entry: any): number?
	local list = table.clone(peek(self.history))
	local index = table.find(list, entry)
	if index == nil then
		return nil
	end
	table.remove(list, index)
	self.history:set(list)
	self:saveHistory()
	return index
end

-- Puts an entry back (e.g. after removeHistory); no-op if it's already there.
function Store:insertHistory(entry: any, index: number?)
	local list = table.clone(peek(self.history))
	if table.find(list, entry) then
		return
	end
	table.insert(list, math.clamp(index or 1, 1, #list + 1), entry)
	while #list > Store.HISTORY_LIMIT do
		table.remove(list)
	end
	self.history:set(list)
	self:saveHistory()
end

function Store:saveSession()
	local brief = peek(self.brief)
	local session = {
		prompt = peek(self.prompt),
		hudLayout = peek(self.hudLayout),
		brief = brief,
		briefText = if brief ~= nil then peek(self.briefText) else nil,
		briefOriginal = if brief ~= nil then peek(self.briefOriginal) else nil,
		briefSource = if brief ~= nil then peek(self.briefSource) else nil,
		exportName = peek(self.exportName),
		json = if self.blueprint then self:encode(self.blueprint) else nil,
	}
	local ok, raw = pcall(function()
		return HttpService:JSONEncode(session)
	end)
	if not ok and brief ~= nil then
		-- a brief that can't be encoded shouldn't cost the rest of the session
		session.brief = nil
		ok, raw = pcall(function()
			return HttpService:JSONEncode(session)
		end)
	end
	if ok then
		self:setSetting(SETTING.Session, raw)
	end
end

function Store:restoreSession(): boolean
	local raw = self:getSetting(SETTING.Session)
	if typeof(raw) ~= "string" or raw == "" then
		return false
	end
	local ok, session = pcall(function()
		return HttpService:JSONDecode(raw)
	end)
	if not ok or typeof(session) ~= "table" then
		return false
	end
	if typeof(session.prompt) == "string" then
		self.prompt:set(session.prompt)
	end
	if Store.isHudLayout(session.hudLayout) or session.hudLayout == Store.AUTO_LAYOUT then
		self.hudLayout:set(session.hudLayout)
	end
	-- the brief (and your edits to its text)
	if typeof(session.brief) == "table" then
		local brief = session.brief
		local original = if typeof(session.briefOriginal) == "string" then session.briefOriginal elseif typeof(brief.text) == "string" then brief.text else ""
		self.brief:set(brief)
		self.briefOriginal:set(original)
		self.briefText:set(if typeof(session.briefText) == "string" then session.briefText else original)
		self.briefSource:set(if session.briefSource == "ai" then "ai" else "offline")
	end
	if typeof(session.json) == "string" then
		local bp = self:decode(session.json, true)
		if bp then
			local name = if typeof(session.exportName) == "string" and session.exportName ~= "" then session.exportName else nil
			self:setBlueprint(bp, { history = false, exportName = name })
			return true
		end
	end
	return false
end

-- Debounced session save after edits.
function Store:scheduleSave()
	self.saveToken = (self.saveToken or 0) + 1
	local token = self.saveToken
	task.delay(1.5, function()
		if token == self.saveToken and not self.destroyed then
			self:saveSession()
		end
	end)
end

--------------------------------------------------------------------------
-- editing helpers used by the Design and Structure tabs
--------------------------------------------------------------------------

function Store:setStyle(key: string, value: any)
	self:edit(function(bp)
		bp.style[key] = value
	end)
end

function Store:setPalette(key: string, hex: string)
	self:edit(function(bp)
		bp.style.palette = bp.style.palette or {}
		bp.style.palette[key] = hex
	end)
end

function Store:applyPalette(colors: { [string]: string })
	self:edit(function(bp)
		bp.style.palette = bp.style.palette or {}
		for key, hex in colors do
			bp.style.palette[key] = hex
		end
	end)
end

function Store:setFont(slot: string, family: string, weight: string?)
	self:edit(function(bp)
		bp.style.fonts = bp.style.fonts or {}
		local w = Catalog.closestWeight(family, weight)
		bp.style.fonts[slot] = if w == "Regular" and weight == nil then family else `{family}:{w}`
	end)
end

function Store:setLogo(key: string, value: any)
	self:edit(function(bp)
		bp.logo = bp.logo or {}
		bp.logo[key] = value
	end)
end

-- "123", "rbxassetid://123", or a Creator Store / library link
-- ("https://create.roblox.com/store/asset/123/Name") -> "rbxassetid://123".
-- nil for empty text, false when there is no asset id in it.
function Store.parseAssetId(text: any): any
	if typeof(text) == "number" then
		return if text >= 1 and text == math.floor(text) and text < 1e15 then string.format("rbxassetid://%d", text) else false
	end
	local s = trim(text)
	if s == "" then
		return nil
	end
	if string.match(s, "^rbxasset://[%w_%-%./]+$") then
		return s
	end
	local digits = string.match(s, "^rbxassetid://(%d+)$")
		or string.match(s, "^(%d+)$")
		or string.match(s, "^https?://[%w%.]*roblox%.com/.*[?&]id=(%d+)")
		or string.match(s, "^https?://[%w%.]*roblox%.com/[%w/]-/(%d+)")
		or string.match(s, "^[%w%.]*roblox%.com/[%w/]-/(%d+)")
	local n = digits and #digits <= 15 and tonumber(digits)
	if not n or n < 1 then
		return false
	end
	return string.format("rbxassetid://%d", n)
end

-- style.sounds[slot] = id (nil = the theme default).
function Store:setSound(slot: string, id: string?)
	self:edit(function(bp)
		bp.style = bp.style or {}
		local sounds = if typeof(bp.style.sounds) == "table" then bp.style.sounds else {}
		for key in table.clone(sounds) do
			if typeof(key) == "string" and string.lower(key) == string.lower(slot) then
				sounds[key] = nil -- "Click" and "click" are the same slot
			end
		end
		sounds[slot] = id
		bp.style.sounds = if next(sounds) ~= nil then sounds else nil
	end)
end

-- blueprint.music[key] = value (lobby / gameplay ids, volume 0..1, muffle).
function Store:setMusic(key: string, value: any)
	self:edit(function(bp)
		local music = if typeof(bp.music) == "table" then bp.music else {}
		music[key] = value
		bp.music = if next(music) ~= nil then music else nil
	end)
end

--------------------------------------------------------------------------
-- HUD kit (docs/BLUEPRINT.md v1.8): blueprint.hud.kit restyles every HUD
-- element; blueprint.meta.kit remembers the one the generator chose, so
-- "Auto" can go back to it.
--------------------------------------------------------------------------

-- The kit the HUD wears now (nil = none: the original looks).
function Store:hudKit(): string?
	local bp = self.blueprint
	return Catalog.hudKitName(bp and bp.hud and bp.hud.kit)
end

-- The generator's choice (nil = it chose none). Until something changes the
-- kit, that's simply the kit the blueprint arrived with.
function Store:autoKit(): string?
	local bp = self.blueprint
	local chosen = bp and bp.meta and bp.meta.kit
	if chosen ~= nil then
		return Catalog.hudKitName(chosen)
	end
	return self:hudKit()
end

-- choice: a kit name, "None" (the original looks) or "Auto" (the generator's).
function Store:setHudKit(choice: string?)
	self:edit(function(bp)
		bp.meta = bp.meta or {}
		bp.hud = bp.hud or { layout = "Classic", elements = {} }
		if bp.meta.kit == nil then
			bp.meta.kit = Catalog.hudKitName(bp.hud.kit) or "None"
		end
		bp.hud.kit = if choice == "Auto" then Catalog.hudKitName(bp.meta.kit) else Catalog.hudKitName(choice)
	end)
end

-- How many HUD elements override the kit with their own `look`.
function Store:lookOverrides(): number
	local n = 0
	for _, element in (self.blueprint and self.blueprint.hud and self.blueprint.hud.elements) or {} do
		if typeof(element) == "table" and element.look ~= nil then
			n += 1
		end
	end
	return n
end

function Store:clearLooks()
	self:edit(function(bp)
		for _, element in (bp.hud and bp.hud.elements) or {} do
			if typeof(element) == "table" then
				element.look = nil
			end
		end
	end)
end

-- Screens the preview / action pickers can target.
function Store:screenOptions(includeHud: boolean?): { any }
	local out = {}
	if includeHud then
		table.insert(out, { value = Store.HUD, label = "HUD only", icon = "squares-grid-plus" })
	end
	for _, screen in (self.blueprint and self.blueprint.screens) or {} do
		local kind = Catalog.screenKindByValue[screen.kind]
		table.insert(out, {
			value = screen.id,
			label = screen.title or screen.id,
			icon = screen.icon or (kind and kind.icon) or "square",
			hint = if screen.visible == false then "hidden" else nil,
		})
	end
	return out
end

function Store:destroy()
	self.destroyed = true
	if #peek(self.references) > 0 then
		self.references:set({}) -- the screenshots' bytes go with the session
	end
	self.job += 1
	if self.remoteHandle and self.cloud then
		self.cloud.remote:cancel(self.remoteHandle) -- nobody will read the result
	end
	self.remoteHandle = nil
	table.clear(self.listeners)
end

return Store
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="20">
        <Properties>
          <string name="Name">Style</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Style: design tokens for the UI Builder widget itself.

	The widget has its own tool-like look (think Figma / Linear) in a Dark and
	a Light theme, and follows Roblox Studio's theme by default. It never reads
	the global BloxUI Theme, which the live preview changes all the time.

		local Style = require(script.Parent.Style)
		frame.BackgroundColor3 = Style.Color.Card
		label.FontFace = Style.Font.SemiBold

		Style.apply("Light")               -- recolours Style.Color in place
		Style.resolve("Auto")              -- "Dark" | "Light" (Studio's theme)
		Style.onStudioThemeChanged(fn)     -- Studio switched theme (nil outside Studio)

	`Style.Color` is one table that is recoloured in place, so modules can keep
	`local C = Style.Color`; read colours when you build, and the builder
	rebuilds the widget after a theme switch.
]]

local ICON_FONT = "rbxasset://LuaPackages/Packages/_Index/BuilderIcons/BuilderIcons/BuilderIcons.json"
local SANS = "rbxasset://fonts/families/BuilderSans.json"
local MONO = "rbxasset://fonts/families/RobotoMono.json"

local function hex(value: string): Color3
	return Color3.fromHex(value)
end

local Style = {}

local function palette(values: { [string]: string }): { [string]: Color3 }
	local out = {}
	for key, value in values do
		out[key] = hex(value)
	end
	return out
end

Style.THEMES = {
	Dark = palette({
		-- surfaces, darkest to lightest
		Bg = "0F1013",
		Panel = "15161A",
		Card = "1A1B20",
		Raised = "24252C",
		RaisedHover = "2C2D35",
		RaisedPress = "202127",
		Field = "111216",
		FieldHover = "14151A",
		Row = "1A1B20",
		RowHover = "202128",
		Overlay = "000000",
		Popover = "1C1D23",
		Tooltip = "2A2B33",
		TooltipText = "ECEDEF",
		Toast = "22232A",

		-- lines
		Border = "26272E",
		BorderStrong = "34353E",
		Divider = "202127",
		Hairline = "FFFFFF", -- 1px edge on swatches (used at ~85% transparency)

		-- text
		Text = "ECEDEF",
		TextSub = "A7AAB3",
		TextMuted = "737680",
		TextDisabled = "4C4E56",

		-- brand accent (violet -> blue gradient on primary actions)
		Accent = "7C5CFF",
		AccentHover = "8C70FF",
		AccentPress = "6B4CF0",
		Accent2 = "4C8DFF",
		Accent2Hover = "5E9AFF",
		Accent2Press = "3F7BEF",
		AccentSoft = "262045",
		AccentSoftHover = "2E2754",
		AccentText = "C4B8FF",
		OnAccent = "FFFFFF",

		-- status
		Success = "34D399",
		SuccessSoft = "15302A",
		SuccessStroke = "1F4A3C",
		Warning = "FBBF24",
		WarningSoft = "33290F",
		WarningStroke = "4A3B12",
		Danger = "F87171",
		DangerHover = "FF8A8A",
		DangerSoft = "3A1C1F",
		DangerSoftHover = "4A2328",
		DangerSoftPress = "33181B",
		DangerSolid = "D9444F",
		DangerSolidHover = "E85560",
		DangerSolidPress = "C23A45",
		Info = "60A5FA",
		InfoSoft = "152338",

		SwitchOff = "34353E",
		SwitchOffHover = "40414B",
		SegmentTrack = "111216",
		SegmentThumb = "2A2B33",

		White = "FFFFFF",
		Black = "000000",
	}),

	Light = palette({
		Bg = "F4F5F7",
		Panel = "FFFFFF",
		Card = "FFFFFF",
		Raised = "EEEFF3",
		RaisedHover = "E6E7EC",
		RaisedPress = "DFE0E6",
		Field = "FFFFFF",
		FieldHover = "F8F9FA",
		Row = "FFFFFF",
		RowHover = "F0F1F4",
		Overlay = "000000",
		Popover = "FFFFFF",
		Tooltip = "1F2127",
		TooltipText = "F2F3F5",
		Toast = "FFFFFF",

		Border = "E1E3E8",
		BorderStrong = "C9CDD5",
		Divider = "ECEEF2",
		Hairline = "000000",

		Text = "16181D",
		TextSub = "4A4F5B",
		TextMuted = "7B808C",
		TextDisabled = "B3B7C0",

		Accent = "6E4BFF",
		AccentHover = "7D5EFF",
		AccentPress = "5A38EA",
		Accent2 = "3F7CFF",
		Accent2Hover = "5A92FF",
		Accent2Press = "3068E8",
		AccentSoft = "EFEBFF",
		AccentSoftHover = "E4DDFF",
		AccentText = "5634E0",
		OnAccent = "FFFFFF",

		Success = "0F9B63",
		SuccessSoft = "E2F5EC",
		SuccessStroke = "B9E6D0",
		Warning = "A86A00",
		WarningSoft = "FDF1D8",
		WarningStroke = "F1D9A6",
		Danger = "D93843",
		DangerHover = "E5484D",
		DangerSoft = "FCE9EA",
		DangerSoftHover = "F9DCDE",
		DangerSoftPress = "F4CFD2",
		DangerSolid = "D93843",
		DangerSolidHover = "E5484D",
		DangerSolidPress = "C22F3A",
		Info = "2A6BD8",
		InfoSoft = "E5EEFC",

		SwitchOff = "C9CDD5",
		SwitchOffHover = "B5BAC4",
		SegmentTrack = "EEEFF3",
		SegmentThumb = "FFFFFF",

		White = "FFFFFF",
		Black = "000000",
	}),
}

-- Non-colour values that differ per theme.
Style.TONES = {
	Dark = { shadow = 0.55, backdrop = 0.45, hairline = 0.84, glow = 0.8 },
	Light = { shadow = 0.88, backdrop = 0.62, hairline = 0.86, glow = 0.9 },
}

Style.theme = "Dark"
Style.Color = table.clone(Style.THEMES.Dark)
Style.Tone = table.clone(Style.TONES.Dark)

-- Recolours Style.Color (in place) for "Dark" or "Light". Returns true when
-- the theme actually changed.
function Style.apply(name: string?): boolean
	local theme = if name == "Light" then "Light" else "Dark"
	local changed = theme ~= Style.theme
	Style.theme = theme
	for key, value in Style.THEMES[theme] do
		Style.Color[key] = value
	end
	for key, value in Style.TONES[theme] do
		Style.Tone[key] = value
	end
	return changed
end

function Style.isDark(): boolean
	return Style.theme ~= "Light"
end

-- Studio's current theme ("Dark" | "Light"), or nil outside Studio.
function Style.studioTheme(): string?
	local ok, name = pcall(function()
		return (settings() :: any).Studio.Theme.Name
	end)
	if ok and typeof(name) == "string" then
		return if string.find(string.lower(name), "light", 1, true) then "Light" else "Dark"
	end
	return nil
end

-- "Auto" (follow Studio), "Dark" or "Light" -> the theme to use.
function Style.resolve(preference: string?): string
	if preference == "Dark" or preference == "Light" then
		return preference
	end
	return Style.studioTheme() or "Dark"
end

-- Connects to Studio's ThemeChanged; returns the connection (or nil).
function Style.onStudioThemeChanged(fn: () -> ()): any
	local ok, connection = pcall(function()
		return (settings() :: any).Studio.ThemeChanged:Connect(fn)
	end)
	return if ok then connection else nil
end

Style.Font = {
	Regular = Font.new(SANS, Enum.FontWeight.Regular),
	Medium = Font.new(SANS, Enum.FontWeight.Medium),
	SemiBold = Font.new(SANS, Enum.FontWeight.SemiBold),
	Bold = Font.new(SANS, Enum.FontWeight.Bold),
	ExtraBold = Font.new(SANS, Enum.FontWeight.ExtraBold),
	Mono = Font.new(MONO, Enum.FontWeight.Regular),
}

Style.IconFont = {
	Regular = Font.new(ICON_FONT, Enum.FontWeight.Regular),
	Bold = Font.new(ICON_FONT, Enum.FontWeight.Bold),
}
Style.IconFontPath = ICON_FONT

-- type scale (px)
Style.Text = {
	Caption = 12,
	Small = 13,
	Body = 14,
	Title = 15,
	Header = 17,
	Hero = 20,
}

-- 8-pt spacing (with a 4 half-step)
Style.Space = {
	XS = 4,
	S = 8,
	M = 12,
	L = 16,
	XL = 24,
	XXL = 32,
}

Style.Radius = {
	S = 6,
	M = 8,
	L = 10,
	XL = 14,
	Pill = 999,
}

-- control heights
Style.Height = {
	Small = 26,
	Control = 32,
	Large = 38,
	Row = 34,
}

-- Widest the page content grows in a wide dock (it stays centred).
Style.MaxContentWidth = 760

Style.hex = hex

function Style.toHex(color: Color3): string
	return "#" .. string.upper(color:ToHex())
end

-- Parses "#RRGGBB" / "RRGGBB" / "#RGB"; returns nil when invalid.
function Style.parseHex(text: string?): Color3?
	if typeof(text) ~= "string" then
		return nil
	end
	local clean = string.gsub(text, "^%s*#?", "")
	clean = string.gsub(clean, "%s+$", "")
	if string.match(clean, "^%x%x%x$") then
		clean = string.gsub(clean, "%x", "%0%0")
	end
	if not string.match(clean, "^%x%x%x%x%x%x$") then
		return nil
	end
	local ok, color = pcall(Color3.fromHex, clean)
	return if ok then color else nil
end

function Style.normalizeHex(text: string?): string?
	local color = Style.parseHex(text)
	return if color then Style.toHex(color) else nil
end

return Style
]]></string>
        </Properties>
      </Item>
      <Item class="Folder" referent="21">
        <Properties>
          <string name="Name">Tabs</string>
        </Properties>
        <Item class="ModuleScript" referent="22">
          <Properties>
            <string name="Name">Create</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Create tab: prompt + HUD layout -> generate -> result + history.
	Improve / Generate / Variation / Regenerate are jobs on the BloxUI server.

	  * By default only the prompt, the HUD layout cards (Auto, Simulator /
	    Tycoon, Shooter / Battlegrounds, Minimal, Dock, More layouts) and one
	    big Generate button show. The preset goes out as options.hudLayout
	    (Auto sends nothing). "More options" (remembered) holds the mode,
	    quality, reference screenshots, the design brief step and examples.
	  * Enter in the prompt box does the next visible step: in the simple view
	    it generates; with More options open (where the brief step lives) or a
	    brief on screen it writes / rewrites the brief, then generates from an
	    up-to-date one. Ctrl+Enter always generates.
	  * Signed out, offline or on a plugin the server no longer accepts, a
	    card says so with the fix (Sign in / Retry / How to update), and
	    "Start from a sample" loads a ready-made UI per genre instead.
	  * "Add reference" attaches up to 2 screenshots of UIs the user likes
	    (chips with a thumbnail, size and a remove button). In Claude mode the
	    brief matches their style; Instant mode says plainly it can't.
	  * While a job runs, a staged progress panel (brief -> screens -> assemble
	    -> review) follows the job's polls, with a ticking clock and Cancel.
	  * The brief reads as foldable sections, or switch to Edit to change the
	    text (Generate then uses your text).
	  * The result lists every frame (click one to preview it) and its HUD
	    layout (Change swaps it), with Variation, Regenerate and Undo; Recent
	    keeps the last generations.
]]

local Deps = require(script.Parent.Parent.Deps)
local Style = require(script.Parent.Parent.Style)
local Ui = require(script.Parent.Parent.Ui)
local Catalog = require(script.Parent.Parent.Catalog)
local Store = require(script.Parent.Parent.Store)
local References = require(script.Parent.Parent.References)
local LayoutCards = require(script.Parent.Parent.LayoutCards)

local Fusion = Deps.Fusion
local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local C = Style.Color
local S = Style.Space

local function ago(time: number?): string
	if typeof(time) ~= "number" then
		return ""
	end
	local seconds = math.max(0, os.time() - time)
	if seconds < 45 then
		return "just now"
	elseif seconds < 3600 then
		return `{math.max(1, math.floor(seconds / 60 + 0.5))}m ago`
	elseif seconds < 86400 then
		return `{math.floor(seconds / 3600 + 0.5)}h ago`
	end
	return `{math.floor(seconds / 86400 + 0.5)}d ago`
end

local function elapsedLabel(seconds: number): string
	seconds = math.max(0, math.floor(seconds))
	return string.format("%d:%02d", seconds // 60, seconds % 60)
end

-- Where a design came from, as a badge: Claude, the instant generator, a sample, or by hand.
local function sourceLabel(source: string?): string
	if source == "ai" then
		return "AI"
	elseif source == "sample" then
		return "Sample"
	elseif source == "manual" then
		return "Custom"
	end
	return "Instant"
end

local function sourceTone(source: string?): string
	if source == "ai" then
		return "accent"
	elseif source == "sample" then
		return "info"
	end
	return "neutral"
end

--------------------------------------------------------------------------
-- brief text -> sections
--------------------------------------------------------------------------

-- Claude's design document (AI.briefToText) uses plain capital headings:
-- "PALETTE", "REFERENCE STYLE", "HUD (layout Corners)", "DESIGN LANGUAGE (structure: one per UI)".
-- A capital label followed by ": lowercase text" ("START SCREEN: menu") is a line, not a heading.
local function plainHeading(clean: string): boolean
	if string.find(clean, "^[%-%d]") then
		return false
	end
	local paren = string.find(clean, "(", 1, true)
	local colon = string.find(clean, ":", 1, true)
	local cut = math.min(paren or #clean + 1, colon or #clean + 1)
	local label = Store.trim(string.sub(clean, 1, cut - 1))
	if #label < 3 or string.find(label, "%l") or not string.find(label, "^%u") then
		return false
	end
	if colon and (paren == nil or colon < paren) and string.find(string.sub(clean, colon + 1), "%l") then
		return false
	end
	return true
end

-- "3. COLOUR PALETTE: MATCH POINT", "AT A GLANCE", "## Buttons" or (in
-- Claude's document) "PALETTE" -> heading
local function headingOf(line: string, plain: boolean?): string?
	local clean = Store.trim(line)
	if clean == "" or #clean > 90 then
		return nil
	end
	local markdown = string.match(clean, "^#+%s+(.+)$")
	if markdown then
		return markdown
	end
	if clean == "AT A GLANCE" then
		return clean
	end
	if string.match(clean, "^%d+[%.%)]%s+%S") and not string.find(clean, "%l") then
		return clean
	end
	if plain and plainHeading(clean) then
		return clean
	end
	return nil
end

local function parseBrief(text: string?): { { title: string, number: string?, body: string } }
	local sections = {}
	local current = nil
	-- Claude's document starts "NEON STRIKE: UI DESIGN BRIEF" (a title, not a section)
	local title = Store.trim(string.match(text or "", "^%s*([^\n]*)") or "")
	local plain = string.find(title, ": UI DESIGN BRIEF$") ~= nil
	for line in string.gmatch((text or "") .. "\n", "([^\n]*)\n") do
		local heading = if plain and Store.trim(line) == title then nil else headingOf(line, plain)
		if heading then
			local number, rest = string.match(heading, "^(%d+)[%.%)]%s+(.+)$")
			current = { title = rest or heading, number = number, lines = {} }
			table.insert(sections, current)
		elseif current then
			table.insert(current.lines, line)
		end
	end
	local out = {}
	for _, section in sections do
		local body = Store.trim(table.concat(section.lines, "\n"))
		body = string.gsub(body, "\n%s*\n%s*\n+", "\n\n")
		-- "- item" lists read better with real bullets
		body = string.gsub(body, "^%- ", "•  ")
		body = string.gsub(body, "\n(%s*)%- ", "\n%1•  ")
		table.insert(out, { title = section.title, number = section.number, body = body })
	end
	if #out == 0 and Store.trim(text) ~= "" then
		table.insert(out, { title = "BRIEF", body = Store.trim(text) })
	end
	return out
end

--------------------------------------------------------------------------
-- small pieces
--------------------------------------------------------------------------

local function summaryRow(scope: any, label: string, content: any, order: number): Frame
	return scope:New "Frame" {
		Name = "Summary_" .. label,
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		LayoutOrder = order,
		[Children] = {
			Ui.text(scope, {
				Text = label,
				TextSize = Style.Text.Caption,
				Color = C.TextMuted,
				Size = UDim2.fromOffset(64, 22),
			}),
			scope:New "Frame" {
				Name = "Value",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(68, 0),
				Size = UDim2.new(1, -68, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				[Children] = {
					Ui.padding(scope, 1, 0, 1, 0),
					Ui.list(scope, "X", 6, "Left", "Center", true),
					content,
				},
			},
		},
	}
end

local function briefSummary(scope: any, store: any): any
	return scope:Computed(function(use, inner)
		local brief = use(store.brief)
		if brief == nil then
			return nil
		end
		local style = brief.style or {}
		local palette = style.palette or {}
		local fonts = style.fonts or {}
		local logo = brief.logo or {}
		local swatches = {}
		for index, key in Catalog.paletteKeys do
			if palette[key] then
				table.insert(swatches, Ui.swatch(inner, { Color = palette[key], Size = 18, LayoutOrder = index }))
			end
		end
		if typeof(brief.paletteName) == "string" and brief.paletteName ~= "" then
			table.insert(swatches, Ui.text(inner, {
				Text = brief.paletteName,
				TextSize = Style.Text.Caption,
				Color = C.TextMuted,
				LayoutOrder = 20,
			}))
		end
		local function fontLabel(ref)
			local family, weight = Catalog.splitFontRef(ref)
			local info = Catalog.fontByFamily[family]
			return (if info then info.label else family) .. (if weight then " " .. weight else "")
		end
		local frames = {}
		for index, frame in (typeof(brief.frames) == "table" and brief.frames) or {} do
			if typeof(frame) == "table" and index <= 12 then
				local kind = Catalog.screenKindByValue[frame.kind]
				table.insert(frames, Ui.badge(inner, {
					Text = tostring(frame.title or frame.id or "Frame"),
					Icon = frame.icon or (kind and kind.icon) or nil,
					LayoutOrder = index,
				}))
			end
		end
		-- what Claude took from the reference screenshots
		local reference = if typeof(brief.referenceStyle) == "table" then brief.referenceStyle else nil
		local referenceRow = nil
		if reference then
			local items = {}
			for index, entry in (typeof(reference.palette) == "table" and reference.palette) or {} do
				if index <= 6 and typeof(entry) == "table" and entry.hex then
					table.insert(items, Ui.swatch(inner, { Color = entry.hex, Size = 14, LayoutOrder = index }))
				end
			end
			local count = tonumber(reference.count)
			table.insert(items, Ui.text(inner, {
				Name = "ReferenceNote",
				Text = if count then `Matched from {count} screenshot{if count == 1 then "" else "s"}` else "Matched from your screenshots",
				TextSize = Style.Text.Caption,
				Color = C.TextMuted,
				LayoutOrder = 20,
			}))
			referenceRow = summaryRow(inner, "Reference", items, 2)
		end
		-- the HUD layout: your preset when you picked one, else the brief's own
		local chosen = use(store.hudLayout)
		local written = if typeof(brief.hud) == "table" and typeof(brief.hud.layout) == "string" then brief.hud.layout else nil
		local layout = if Store.isHudLayout(chosen) then chosen else written
		local layoutRow = nil
		if layout then
			layoutRow = summaryRow(inner, "Layout", {
				LayoutCards.diagram(inner, layout, { Width = 32, LayoutOrder = 1 }),
				Ui.text(inner, {
					Name = "LayoutName",
					Text = Catalog.hudLayoutInfo(layout).label,
					TextSize = Style.Text.Small,
					Weight = "SemiBold",
					Color = C.Text,
					LayoutOrder = 2,
				}),
				Ui.text(inner, {
					Name = "LayoutSource",
					Text = if Store.isHudLayout(chosen) then "your pick" else "the brief's pick",
					TextSize = Style.Text.Caption,
					Color = C.TextMuted,
					LayoutOrder = 3,
				}),
			}, 7)
		end
		return {
			summaryRow(inner, "Look", {
				Ui.badge(inner, { Text = style.archetype or "Custom", Tone = "accent", LayoutOrder = 1 }),
				if brief.genre then Ui.badge(inner, { Text = string.upper(brief.genre), Icon = Catalog.genreIcons[brief.genre], LayoutOrder = 2 }) else nil,
				if style.buttonStyle then Ui.badge(inner, { Text = style.buttonStyle, LayoutOrder = 3 }) else nil,
			}, 1),
			referenceRow,
			summaryRow(inner, "Palette", swatches, 3),
			summaryRow(inner, "Fonts", {
				Ui.text(inner, {
					Text = `{fontLabel(fonts.display)} / {fontLabel(fonts.body)}`,
					TextSize = Style.Text.Small,
					Color = C.Text,
					Fill = true,
					Wrap = true,
					LineHeight = 1.15,
				}),
			}, 4),
			summaryRow(inner, "Logo", {
				Ui.icon(inner, { Icon = logo.icon, Size = 14, Color = C.AccentText, LayoutOrder = 1, Visible = logo.icon ~= nil }),
				Ui.text(inner, {
					Text = `{logo.text or brief.title or "?"}` .. (if logo.type then `  ·  {logo.type}` else ""),
					TextSize = Style.Text.Small,
					Weight = "SemiBold",
					Color = C.Text,
					Truncate = true,
					Size = UDim2.new(1, -20, 0, 20),
					LayoutOrder = 2,
				}),
			}, 5),
			summaryRow(inner, "Feel", {
				Ui.text(inner, {
					Text = `{style.clickFx or "Squash"} clicks  ·  {style.transition or "Pop"} transitions  ·  {style.corner or "Soft"} corners`,
					TextSize = Style.Text.Small,
					Color = C.TextSub,
					Fill = true,
					Wrap = true,
					LineHeight = 1.15,
				}),
			}, 6),
			layoutRow,
			if #frames > 0 then summaryRow(inner, "Frames", frames, 8) else nil,
		}
	end)
end

-- One step of the flow / progress stepper: "done", "active" or "pending".
-- Compact (a state): only the active step keeps its label (narrow docks).
local function stepDot(scope: any, props: { [string]: any }): Frame
	local status = props.Status
	local compact = props.Compact
	return scope:New "Frame" {
		Name = props.Name,
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(0, 22),
		AutomaticSize = Enum.AutomaticSize.X,
		LayoutOrder = props.LayoutOrder,
		Visible = if props.Visible ~= nil then props.Visible else true,
		[Children] = {
			Ui.list(scope, "X", 6, "Left", "Center"),
			scope:New "Frame" {
				Name = "Dot",
				Size = UDim2.fromOffset(18, 18),
				LayoutOrder = 1,
				BackgroundColor3 = scope:Computed(function(use)
					local s = use(status)
					return if s == "done" then C.SuccessSoft elseif s == "active" then C.AccentSoft else C.Raised
				end),
				[Children] = {
					Ui.corner(scope, Style.Radius.Pill),
					Ui.stroke(scope, scope:Computed(function(use)
						return if use(status) == "active" then C.Accent else C.Border
					end), 1, scope:Computed(function(use)
						return if use(status) == "done" then 1 else 0
					end)),
					Ui.icon(scope, {
						Icon = "check-small",
						Size = 14,
						Bold = true,
						Color = C.Success,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Visible = scope:Computed(function(use)
							return use(status) == "done"
						end),
					}),
					Ui.text(scope, {
						Text = props.Number,
						TextSize = 11,
						Weight = "Bold",
						Color = scope:Computed(function(use)
							return if use(status) == "active" then C.AccentText else C.TextMuted
						end),
						XAlign = Enum.TextXAlignment.Center,
						Size = UDim2.fromScale(1, 1),
						Visible = scope:Computed(function(use)
							return use(status) ~= "done"
						end),
					}),
				},
			},
			Ui.text(scope, {
				Name = "Label",
				Text = props.Text,
				TextSize = Style.Text.Caption,
				Weight = scope:Computed(function(use)
					return if use(status) == "active" then "SemiBold" else "Medium"
				end),
				Font = scope:Computed(function(use)
					return if use(status) == "active" then Style.Font.SemiBold else Style.Font.Medium
				end),
				Color = scope:Computed(function(use)
					local s = use(status)
					return if s == "active" then C.Text elseif s == "done" then C.TextSub else C.TextMuted
				end),
				Visible = if compact ~= nil
					then scope:Computed(function(use)
						return not use(compact) or use(status) == "active"
					end)
					else true,
				LayoutOrder = 2,
			}),
		},
	}
end

-- One attached screenshot: thumbnail (or an image glyph), name, size, remove.
local function referenceChip(scope: any, store: any, record: any, index: number): Frame
	local thumb
	if record.thumb then
		thumb = scope:New "ImageLabel" {
			Name = "Thumb",
			BackgroundColor3 = C.Field,
			Size = UDim2.fromOffset(30, 30),
			Image = record.thumb,
			ScaleType = Enum.ScaleType.Crop,
			LayoutOrder = 1,
			[Children] = {
				Ui.corner(scope, Style.Radius.S),
				Ui.stroke(scope, C.Border),
			},
		}
	else
		thumb = Ui.iconBubble(scope, { Name = "Thumb", Icon = "image", Size = 30, Radius = Style.Radius.S, LayoutOrder = 1 })
	end
	local chip = scope:New "Frame" {
		Name = "Reference_" .. index,
		BackgroundColor3 = C.Raised,
		Size = UDim2.new(0.5, -3, 0, 42),
		LayoutOrder = index,
		[Children] = {
			Ui.corner(scope, Style.Radius.M),
			Ui.stroke(scope, C.Border),
			Ui.padding(scope, 0, 4, 0, 6),
			Ui.list(scope, "X", 8, "Left", "Center"),
			thumb,
			scope:New "Frame" {
				Name = "Info",
				BackgroundTransparency = 1,
				Size = UDim2.new(0, 0, 0, 32),
				LayoutOrder = 2,
				[Children] = {
					Ui.flex(scope, "Fill"),
					Ui.list(scope, "Y", 1, "Left", "Center"),
					Ui.text(scope, {
						Name = "FileName",
						Text = record.name or "screenshot",
						TextSize = Style.Text.Small,
						Weight = "Medium",
						Color = C.Text,
						Size = UDim2.new(1, 0, 0, 15),
						Truncate = true,
						LayoutOrder = 1,
					}),
					Ui.text(scope, {
						Name = "Meta",
						Text = References.caption(record),
						TextSize = Style.Text.Caption,
						Color = C.TextMuted,
						Size = UDim2.new(1, 0, 0, 14),
						Truncate = true,
						LayoutOrder = 2,
					}),
				},
			},
			Ui.iconButton(scope, {
				Name = "RemoveReference",
				Icon = "x-small",
				Size = 24,
				IconSize = 14,
				Tone = "danger",
				Tooltip = "Remove this reference",
				LayoutOrder = 3,
				OnClick = function()
					store:removeReference(record)
				end,
			}),
		},
	}
	Ui.tooltip(scope, chip, record.name or "")
	return chip
end

local function stepLine(scope: any, order: number): Frame
	return scope:New "Frame" {
		Name = "Line",
		BackgroundColor3 = C.Border,
		BorderSizePixel = 0,
		Size = UDim2.new(0, 0, 0, 1),
		LayoutOrder = order,
		[Children] = scope:New "UIFlexItem" { FlexMode = Enum.UIFlexMode.Fill },
	}
end

return function(scope: any, app: any): Instance
	local store = app.store
	local cloud = app.cloud
	local history = app.history

	local busy = store.busy
	local isBusy = scope:Computed(function(use)
		return use(busy) ~= nil
	end)
	local compact = scope:Computed(function(use)
		return not use(app.wide)
	end)
	local usingAI = scope:Computed(function(use)
		return use(store.mode) == "AI"
	end)
	-- why generating can't happen right now (Cloud:readiness), nil = it can
	local blocker = scope:Computed(function(use)
		use(cloud.update)
		use(cloud.signedIn)
		use(cloud.reachable)
		use(cloud.status)
		use(store.mode)
		local ok, reason = cloud:readiness(peek(store.mode))
		return if ok then nil else reason
	end)
	local canGenerate = scope:Computed(function(use)
		return not use(isBusy) and use(blocker) == nil
	end)
	local hasPrompt = scope:Computed(function(use)
		return Store.trim(use(store.prompt)) ~= ""
	end)
	-- the brief was written for the prompt that's in the box now
	local promptCurrent = scope:Computed(function(use)
		local brief = use(store.brief)
		if brief == nil then
			return false
		end
		local written = if typeof(brief.prompt) == "string" then Store.trim(brief.prompt) else nil
		return written == nil or written == "" or written == Store.trim(use(store.prompt))
	end)
	-- ...and, in Claude mode, for the reference screenshots attached now
	local referencesCurrent = scope:Computed(function(use)
		return not use(usingAI) or use(store.referencesKey) == use(store.briefReferencesKey)
	end)
	local briefCurrent = scope:Computed(function(use)
		return use(promptCurrent) and use(referencesCurrent)
	end)
	local moreOpen = store.moreOptions
	-- what Enter in the prompt does (the key hint under the box says it)
	local enterAction = scope:Computed(function(use)
		if use(briefCurrent) then
			return "generate"
		end
		if use(store.brief) ~= nil then
			return "improve" -- the brief on screen no longer matches: rewrite it
		end
		-- no brief yet: the simple view generates straight away; with More
		-- options open (where the brief step lives) Enter writes the brief first
		return if use(moreOpen) then "improve" else "generate"
	end)

	local promptBox: TextBox? = nil

	local function improve()
		if peek(canGenerate) then
			store:improve()
		end
	end
	local function generate()
		if peek(canGenerate) then
			store:generate(false)
		end
	end

	app.keyHandlers.Create = function(key)
		if key == "Slash" and promptBox then
			pcall(promptBox.CaptureFocus, promptBox)
			return true
		end
		return false
	end

	----------------------------------------------------------------------
	-- example prompts (under More options)
	----------------------------------------------------------------------
	local chips = {}
	for index, example in Catalog.examples do
		table.insert(chips, Ui.chip(scope, {
			Name = "Example_" .. example.label,
			Text = example.label,
			Icon = example.icon,
			Height = 26,
			LayoutOrder = index,
			Selected = scope:Computed(function(use)
				return use(store.prompt) == example.prompt
			end),
			OnClick = function()
				store.prompt:set(example.prompt)
				store.brief:set(nil)
				store:saveSession()
			end,
		}))
	end

	local modeHint = scope:Computed(function(use)
		local quota = use(cloud.quotaText)
		if use(store.mode) == "AI" then
			-- the Claude model, or the ChatGPT one on the user's OpenAI key
			local provider = store:aiProvider(use)
			local model = store:modelFor(provider, use)
			local label = model
			for _, entry in store:modelOptions(provider) do
				if entry.value == model then
					label = entry.label
				end
			end
			local review = store.quality == nil or use(store.quality) == "best"
			return `{label} writes the brief and designs every screen{if review then ", then reviews it" else ""}. Usually 1–2 min.`
				.. (if quota ~= "" then ` {quota}.` else "")
		end
		-- the Paid Trial counts every generation, Instant ones too
		if use(cloud.trial) and quota ~= "" then
			return `BloxUI's built-in generator: done in seconds. {quota}.`
		end
		return "BloxUI's built-in generator: done in seconds, and it doesn't use your AI quota."
	end)

	----------------------------------------------------------------------
	-- can't generate right now: why, and the way out
	----------------------------------------------------------------------
	local GATES = {
		signed_out = {
			title = "Sign in to generate",
			icon = "circle-person",
			tone = "accent",
			body = "Generating runs on the BloxUI server. Paste your BloxUI licence key (Paid Trial or Premium, from ui.bloxmaps.com) in Settings > Account. Until then, start from a sample below: editing, preview, Insert and Export all work.",
			action = "Sign in",
			actionIcon = "person-check",
		},
		offline = {
			title = "Can't reach the BloxUI server",
			icon = "signal-exclamation",
			tone = "warning",
			body = "Check your internet connection (and that Studio lets BloxUI make HTTP requests). You can still edit, preview, insert and export, or start from a sample below.",
			action = "Retry",
			actionIcon = "arrow-rotate-right",
		},
		update_required = {
			title = "Update to keep generating",
			icon = "circle-arrow-up",
			tone = "danger",
			body = "The BloxUI server needs a newer plugin. Download the new version from ui.bloxmaps.com (it takes a minute). Editing, preview, Insert and Export still work.",
			action = "How to update",
			actionIcon = "circle-arrow-up",
		},
		ai_unavailable = {
			title = "Claude mode is paused",
			icon = "cursor-stars",
			tone = "warning",
			body = "The server has paused Claude generations for a moment. Instant mode still works.",
			action = "Use Instant",
			actionIcon = "lightning-bolt",
		},
		offline_unavailable = {
			title = "Instant mode is paused",
			icon = "lightning-bolt",
			tone = "warning",
			body = "The server has paused the instant generator for a moment. Claude mode still works.",
			action = "Use Claude",
			actionIcon = "cursor-stars",
		},
	}
	local function gateRun(reason: string?)
		if reason == "signed_out" then
			store.tab:set("Settings")
		elseif reason == "offline" then
			cloud:refreshStatus()
			store:toast("Checking the connection to the BloxUI server...", "info")
		elseif reason == "update_required" then
			app.showUpdate()
		elseif reason == "ai_unavailable" then
			store:setMode("Offline")
		elseif reason == "offline_unavailable" then
			store:setMode("AI")
		end
	end
	local gate = scope:Computed(function(use)
		local reason = use(blocker)
		return if reason then GATES[reason] else nil
	end)
	local gateCard = scope:New "Frame" {
		Name = "GateCard",
		BackgroundColor3 = scope:Computed(function(use)
			local info = use(gate)
			local tone = info and info.tone
			return if tone == "danger" then C.DangerSoft elseif tone == "warning" then C.WarningSoft else C.AccentSoft
		end),
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		LayoutOrder = 1,
		Visible = scope:Computed(function(use)
			return use(gate) ~= nil
		end),
		[Children] = {
			Ui.corner(scope, Style.Radius.L),
			Ui.stroke(scope, scope:Computed(function(use)
				local info = use(gate)
				local tone = info and info.tone
				return if tone == "danger" then C.Danger elseif tone == "warning" then C.WarningStroke else C.Accent
			end), 1, 0.35),
			Ui.padding(scope, S.M, S.M, S.M, S.M),
			Ui.list(scope, "Y", 8),
			Ui.row(scope, {
				Name = "GateHead",
				Gap = 10,
				Height = 30,
				LayoutOrder = 1,
				[Children] = {
					Ui.iconBubble(scope, {
						Icon = scope:Computed(function(use)
							local info = use(gate)
							return if info then info.icon else "circle-i"
						end),
						Size = 30,
						Background = C.Card,
						Color = scope:Computed(function(use)
							local info = use(gate)
							local tone = info and info.tone
							return if tone == "danger" then C.Danger elseif tone == "warning" then C.Warning else C.AccentText
						end),
						LayoutOrder = 1,
					}),
					Ui.text(scope, {
						Name = "GateTitle",
						Text = scope:Computed(function(use)
							local info = use(gate)
							return if info then info.title else ""
						end),
						TextSize = Style.Text.Title,
						Weight = "SemiBold",
						Size = UDim2.new(0, 0, 1, 0),
						Truncate = true,
						LayoutOrder = 2,
						[Children] = Ui.flex(scope, "Fill"),
					}),
				},
			}),
			Ui.text(scope, {
				Name = "GateBody",
				Text = scope:Computed(function(use)
					local info = use(gate)
					return if info then info.body else ""
				end),
				TextSize = Style.Text.Small,
				Color = C.TextSub,
				Fill = true,
				Wrap = true,
				LineHeight = 1.1,
				LayoutOrder = 2,
			}),
			Ui.row(scope, {
				Name = "GateActions",
				Gap = S.S,
				LayoutOrder = 3,
				[Children] = {
					Ui.button(scope, {
						Name = "GateAction",
						Text = scope:Computed(function(use)
							local info = use(gate)
							return if info then info.action else ""
						end),
						Icon = scope:Computed(function(use)
							local info = use(gate)
							return if info then info.actionIcon else nil
						end),
						Variant = "primary",
						LayoutOrder = 1,
						Busy = scope:Computed(function(use)
							return use(blocker) == "offline" and use(cloud.checking)
						end),
						OnClick = function()
							gateRun(peek(blocker))
						end,
					}),
				},
			}),
		},
	}

	----------------------------------------------------------------------
	-- start from a sample (no sign-in needed)
	----------------------------------------------------------------------
	local sampleChips = {}
	for index, genre in store:sampleGenres() do
		table.insert(sampleChips, Ui.chip(scope, {
			Name = "Sample_" .. genre.value,
			Text = genre.label,
			Icon = genre.icon,
			Height = 26,
			LayoutOrder = index,
			OnClick = function()
				store:loadSample(genre.value)
			end,
		}))
	end
	local samplesCard = Ui.card(scope, {
		Name = "SamplesCard",
		Title = "Start from a sample",
		Icon = "squares-grid-plus",
		LayoutOrder = 2,
		Visible = scope:Computed(function(use)
			return use(blocker) ~= nil and #sampleChips > 0
		end),
		[Children] = {
			scope:New "Frame" {
				Name = "SampleGenres",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 1,
				[Children] = {
					Ui.list(scope, "X", 6, "Left", "Center", true),
					sampleChips,
				},
			},
			Ui.text(scope, {
				Name = "SamplesHint",
				Text = "Pick a genre to load its UI, then customize, preview or insert it. Click again for another one.",
				TextSize = Style.Text.Caption,
				Color = C.TextMuted,
				Fill = true,
				Wrap = true,
				LineHeight = 1.1,
				LayoutOrder = 2,
			}),
		},
	})

	-- staged progress (replaces the buttons while a job runs)
	local progress = app.progress
	local hasReview = scope:Computed(function(use)
		return use(progress).steps == 4
	end)
	-- four labelled steps don't fit a narrow dock: keep only the active label
	local stepperCompact = scope:Computed(function(use)
		return use(hasReview) and use(app.width) < 400
	end)
	local function stepStatus(step: number): any
		return scope:Computed(function(use)
			local p = use(progress)
			if p.step > step then
				return "done"
			end
			return if p.step == step then "active" else "pending"
		end)
	end
	local progressPanel = scope:New "Frame" {
		Name = "Progress",
		BackgroundColor3 = C.Field,
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		LayoutOrder = 8,
		Visible = isBusy,
		[Children] = {
			Ui.corner(scope, Style.Radius.M),
			Ui.stroke(scope, C.Border),
			Ui.padding(scope, 12),
			Ui.list(scope, "Y", 10),
			Ui.row(scope, {
				Name = "Stage",
				Gap = 8,
				Height = 20,
				LayoutOrder = 1,
				[Children] = {
					Ui.spinner(scope, { Name = "Spinner", Size = 16, Active = isBusy, LayoutOrder = 1 }),
					Ui.text(scope, {
						Name = "StageText",
						Text = scope:Computed(function(use)
							return use(progress).stage
						end),
						TextSize = Style.Text.Body,
						Weight = "SemiBold",
						Size = UDim2.new(0, 0, 1, 0),
						Truncate = true,
						LayoutOrder = 2,
						[Children] = Ui.flex(scope, "Fill"),
					}),
					Ui.text(scope, {
						Name = "Elapsed",
						Text = scope:Computed(function(use)
							return elapsedLabel(use(app.elapsed))
						end),
						Font = Style.Font.Mono,
						TextSize = Style.Text.Caption,
						Color = C.TextMuted,
						LayoutOrder = 3,
					}),
				},
			}),
			Ui.progress(scope, {
				Name = "Bar",
				Height = 4,
				LayoutOrder = 2,
				Active = isBusy,
				Value = scope:Computed(function(use)
					return use(progress).fraction
				end),
			}),
			Ui.row(scope, {
				Name = "Stepper",
				Gap = 6,
				Height = 22,
				LayoutOrder = 3,
				Visible = scope:Computed(function(use)
					return use(busy) == "generate"
				end),
				[Children] = {
					stepDot(scope, { Name = "Stage_Brief", Number = "1", Text = "Brief", Status = stepStatus(1), Compact = stepperCompact, LayoutOrder = 1 }),
					stepLine(scope, 2),
					stepDot(scope, {
						Name = "Stage_Screens",
						Number = "2",
						Text = scope:Computed(function(use)
							local p = use(progress)
							if p.step == 2 and p.index and p.total then
								return `Screens {math.min(p.index, p.total)}/{p.total}`
							end
							return "Screens"
						end),
						Status = stepStatus(2),
						Compact = stepperCompact,
						LayoutOrder = 3,
					}),
					stepLine(scope, 4),
					stepDot(scope, { Name = "Stage_Assemble", Number = "3", Text = "Assemble", Status = stepStatus(3), Compact = stepperCompact, LayoutOrder = 5 }),
					-- Claude's design review (AI mode, quality "best")
					scope:New "Frame" {
						Name = "ReviewLine",
						BackgroundColor3 = C.Border,
						BorderSizePixel = 0,
						Size = UDim2.new(0, 0, 0, 1),
						LayoutOrder = 6,
						Visible = hasReview,
						[Children] = scope:New "UIFlexItem" { FlexMode = Enum.UIFlexMode.Fill },
					},
					stepDot(scope, {
						Name = "Stage_Review",
						Number = "4",
						Text = "Review",
						Status = stepStatus(4),
						Compact = stepperCompact,
						Visible = hasReview,
						LayoutOrder = 7,
					}),
				},
			}),
			Ui.row(scope, {
				Name = "Footer",
				Gap = 8,
				LayoutOrder = 4,
				VAlign = "Center",
				[Children] = {
					Ui.text(scope, {
						Name = "Hint",
						Text = scope:Computed(function(use)
							local kind = use(busy)
							if not use(usingAI) then
								return if kind == "improve" then "The instant generator is writing the brief." else "The instant generator is building your UI. This only takes a moment."
							end
							local p = use(progress)
							if p.step == 1 and string.find(string.lower(p.stage or ""), "studying", 1, true) then
								return "Claude is studying your screenshots, then writing the brief in their style. Usually 20–60 s."
							end
							if kind == "improve" then
								return "Claude is reading your prompt and writing the brief. Usually 15–40 s."
							end
							if p.step == 4 then
								return "Claude is reviewing colour, copy, prices and gaps, then applying its fixes. Almost there."
							end
							if p.steps == 4 then
								return "Claude designs the HUD and every screen in parallel, then reviews the whole design. Usually 1–2 min. Keep working in other tabs meanwhile."
							end
							return "Claude designs the HUD and every screen in parallel. Usually 1–2 min. Keep working in other tabs meanwhile."
						end),
						TextSize = Style.Text.Caption,
						Color = C.TextMuted,
						Wrap = true,
						LineHeight = 1.1,
						Size = UDim2.new(1, -86, 0, 0),
						AutomaticSize = Enum.AutomaticSize.Y,
						LayoutOrder = 1,
					}),
					Ui.button(scope, {
						Name = "Cancel",
						Text = "Cancel",
						Icon = "x-small",
						Variant = "secondary",
						Size = "sm",
						Width = 78,
						LayoutOrder = 2,
						Tooltip = "Stop and keep what you had",
						OnClick = function()
							store:cancel() -- DELETE /v1/jobs/{id}
						end,
					}),
				},
			}),
		},
	}

	----------------------------------------------------------------------
	-- reference screenshots (Claude matches their style)
	----------------------------------------------------------------------
	local referenceLimits = store:referenceLimits()
	local referenceCount = scope:Computed(function(use)
		return #use(store.references)
	end)
	local hasReferences = scope:Computed(function(use)
		return use(referenceCount) > 0
	end)
	local referencesFull = scope:Computed(function(use)
		return use(referenceCount) >= referenceLimits.count
	end)
	-- Premium only: the Paid Trial and a free session can't add screenshots
	local referencesLocked = scope:Computed(function(use)
		return use(cloud.trial) or use(cloud.free)
	end)
	local referenceChips = scope:Computed(function(use, inner)
		local out = {}
		for index, record in use(store.references) do
			table.insert(out, referenceChip(inner, store, record, index))
		end
		return out
	end)
	local function noteLine(props: { [string]: any }): Frame
		return scope:New "Frame" {
			Name = props.Name,
			BackgroundTransparency = 1,
			Size = UDim2.new(1, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			LayoutOrder = props.LayoutOrder,
			Visible = props.Visible,
			[Children] = {
				Ui.list(scope, "X", 6, "Left", "Top"),
				Ui.icon(scope, { Icon = props.Icon, Size = 13, Color = props.IconColor, Bold = true, LayoutOrder = 1 }),
				Ui.text(scope, {
					Name = "Text",
					Text = props.Text,
					TextSize = Style.Text.Caption,
					Color = props.Color or C.TextMuted,
					Wrap = true,
					LineHeight = 1.1,
					Size = UDim2.new(1, -19, 0, 0),
					AutomaticSize = Enum.AutomaticSize.Y,
					LayoutOrder = 2,
				}),
			},
		}
	end
	local referencesBlock = Ui.column(scope, {
		Name = "References",
		Gap = 6,
		LayoutOrder = 3,
		[Children] = {
			Ui.row(scope, {
				Name = "ReferenceBar",
				Gap = 8,
				Height = 26,
				LayoutOrder = 1,
				[Children] = {
					Ui.button(scope, {
						Name = "AddReference",
						-- reference screenshots are a Premium feature (the server refuses them on the Paid Trial
						-- and the free plan)
						Text = scope:Computed(function(use)
							return if use(referencesLocked) then "Add reference · Premium" else "Add reference"
						end),
						Icon = scope:Computed(function(use)
							return if use(referencesLocked) then "crown" else "image"
						end),
						Variant = "secondary",
						Size = "sm",
						LayoutOrder = 1,
						Busy = store.picking,
						Disabled = referencesFull,
						Tooltip = scope:Computed(function(use)
							if use(referencesLocked) then
								return "BloxUI Premium: match the style of your own screenshots"
							end
							if use(referencesFull) then
								return `Up to {referenceLimits.count} references`
							end
							return `A PNG or JPEG screenshot, under {References.formatBytes(referenceLimits.bytes)}`
						end),
						OnClick = function()
							if peek(cloud.trial) then
								store:toast("Reference screenshots are a BloxUI Premium feature. Upgrade at ui.bloxmaps.com/upgrade, then press Refresh in Settings > Account.")
								return
							elseif peek(cloud.free) then
								store:toast("Reference screenshots are a BloxUI Premium feature. Get a key at ui.bloxmaps.com, then paste it in Settings > Account.")
								return
							end
							store:addReference()
						end,
					}),
				},
			}),
			Ui.text(scope, {
				Name = "ReferenceHint",
				Text = scope:Computed(function(use)
					local who = if store:aiProvider(use) == "openai" then "ChatGPT" else "Claude"
					return `Screenshots of UIs you like. {who} matches their style (never their name or logo).`
				end),
				TextSize = Style.Text.Caption,
				Color = C.TextMuted,
				Fill = true,
				Wrap = true,
				LineHeight = 1.1,
				LayoutOrder = 2,
			}),
			scope:New "Frame" {
				Name = "ReferenceChips",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 3,
				Visible = hasReferences,
				[Children] = {
					Ui.list(scope, "X", 6, "Left", "Center", true),
					referenceChips,
				},
			},
			noteLine({
				Name = "ReferencePrivacy",
				Icon = "shield-lock",
				IconColor = C.TextMuted,
				-- where the screenshots go: Claude, or OpenAI on the user's own OpenAI key
				Text = scope:Computed(function(use)
					if store:aiProvider(use) == "openai" then
						return "Sent only to the BloxUI server and OpenAI (your key), in AI mode. Never saved to your place."
					end
					return "Sent only to the BloxUI server for Claude, in Claude mode. Never saved to your place."
				end),
				LayoutOrder = 4,
				Visible = hasReferences,
			}),
			noteLine({
				Name = "ReferenceOffline",
				Icon = "triangle-exclamation",
				IconColor = C.Warning,
				Color = C.Text,
				Text = scope:Computed(function(use)
					return if store:aiProvider(use) == "openai"
						then "References need AI mode. Instant mode ignores them."
						else "References need Claude mode. Instant mode ignores them."
				end),
				LayoutOrder = 5,
				Visible = scope:Computed(function(use)
					return use(hasReferences) and not use(usingAI)
				end),
			}),
		},
	})

	----------------------------------------------------------------------
	-- HUD layout presets: Auto, the presets, More layouts (picture cards)
	----------------------------------------------------------------------
	-- the server's other layouts sit behind "More layouts" (Classic, Rail, Top bar)
	local moreLayouts = {}
	local moreLabels = {}
	for _, value in LayoutCards.values() do
		if Store.isHudLayout(value) and not table.find(Catalog.layoutPresets, value) then
			table.insert(moreLayouts, value)
			table.insert(moreLabels, Catalog.hudLayoutInfo(value).label)
		end
	end
	local moreNames = if #moreLabels > 1
		then table.concat(moreLabels, ", ", 1, #moreLabels - 1) .. " and " .. moreLabels[#moreLabels]
		else (moreLabels[1] or "")
	local extraLayout = scope:Computed(function(use)
		local value = use(store.hudLayout)
		return if table.find(moreLayouts, value) then value else nil
	end)
	-- the hint line follows the card under the mouse, else the chosen one
	local hoveredLayout = scope:Value(nil :: string?)
	local function hoverHint(value: string): (boolean) -> ()
		return function(over: boolean)
			if over then
				hoveredLayout:set(value)
			elseif peek(hoveredLayout) == value then
				hoveredLayout:set(nil)
			end
		end
	end
	local layoutCards = {}
	local function presetCard(value: string, order: number)
		return LayoutCards.card(scope, {
			Name = "Layout_" .. value,
			Value = value,
			Label = Catalog.hudLayoutInfo(value).label,
			LayoutOrder = order,
			Selected = scope:Computed(function(use)
				return use(store.hudLayout) == value
			end),
			OnHover = hoverHint(value),
			OnClick = function()
				store:setHudLayout(value)
			end,
		})
	end
	table.insert(layoutCards, presetCard(Store.AUTO_LAYOUT, 1))
	for index, value in Catalog.layoutPresets do
		table.insert(layoutCards, presetCard(value, index + 1))
	end
	local moreCard
	moreCard = LayoutCards.card(scope, {
		Name = "Layout_More",
		Value = scope:Computed(function(use)
			return use(extraLayout) or "More"
		end),
		Label = scope:Computed(function(use)
			local extra = use(extraLayout)
			return if extra then Catalog.hudLayoutInfo(extra).label else "More layouts"
		end),
		LayoutOrder = #Catalog.layoutPresets + 2,
		Selected = scope:Computed(function(use)
			return use(extraLayout) ~= nil
		end),
		OnHover = hoverHint("More"),
		OnClick = function()
			LayoutCards.pick(app, moreCard, {
				values = moreLayouts,
				value = peek(store.hudLayout),
				title = "More layouts",
				onSelect = function(value)
					store:setHudLayout(value)
				end,
			})
		end,
	})
	table.insert(layoutCards, moreCard)
	-- one row when the dock is wide enough, else rows of three
	local layoutColumns = scope:Computed(function(use)
		local inside = math.min(use(app.width), Style.MaxContentWidth) - 4 * S.L - 6 -- page + card padding, scroll bar
		local all = #layoutCards
		return if inside >= all * LayoutCards.MIN_CARD + (all - 1) * LayoutCards.GAP then all else 3
	end)
	local layoutBlock = Ui.column(scope, {
		Name = "LayoutBlock",
		Gap = 8,
		LayoutOrder = 3,
		[Children] = {
			Ui.overline(scope, {
				Text = "HUD layout",
				LayoutOrder = 1,
				-- once there's a design: these cards shape the next one (the result's Change edits this one)
				Accessory = Ui.text(scope, {
					Name = "LayoutWhen",
					Text = "Used when you generate",
					TextSize = Style.Text.Caption,
					Color = C.TextMuted,
					Visible = store.hasBlueprint,
				}),
			}),
			LayoutCards.grid(scope, { Name = "LayoutCards", Cards = layoutCards, Columns = layoutColumns, LayoutOrder = 2 }),
			Ui.text(scope, {
				Name = "LayoutHint",
				Text = scope:Computed(function(use)
					local value = use(hoveredLayout) or use(store.hudLayout)
					if value == "More" then
						local extra = use(extraLayout)
						return if extra then LayoutCards.describe(extra) else `<b>More layouts</b>: {moreNames}.`
					end
					return LayoutCards.describe(value)
				end),
				RichText = true,
				TextSize = Style.Text.Caption,
				Color = C.TextSub,
				Wrap = true,
				Truncate = true,
				LineHeight = 1.1,
				-- a fixed two-line height: hovering the cards never moves what's below
				Size = UDim2.new(1, 0, 0, 27),
				YAlign = Enum.TextYAlignment.Top,
				LayoutOrder = 3,
			}),
		},
	})

	----------------------------------------------------------------------
	-- More options: mode, quality, references, the brief step, examples
	----------------------------------------------------------------------
	local moreState, moreEvents = Ui.interact(scope)
	local moreToggle = scope:New "TextButton" (Ui.merge(moreEvents, {
		Name = "MoreOptions",
		Text = "",
		AutoButtonColor = false,
		BorderSizePixel = 0,
		Size = UDim2.new(1, 0, 0, 30),
		LayoutOrder = 10,
		BackgroundColor3 = C.RowHover,
		BackgroundTransparency = scope:Computed(function(use)
			local s = use(moreState)
			return if s == "hover" or s == "press" then 0 else 1
		end),
		[OnEvent "Activated"] = function()
			store:setMoreOptions(not peek(moreOpen))
		end,
		[Children] = {
			Ui.corner(scope, Style.Radius.S),
			Ui.padding(scope, 0, 6, 0, 6),
			Ui.list(scope, "X", 6, "Left", "Center"),
			Ui.icon(scope, { Icon = "two-sliders-horizontal", Size = 14, Color = C.TextMuted, LayoutOrder = 1 }),
			Ui.text(scope, {
				Name = "Label",
				Text = "More options",
				TextSize = Style.Text.Small,
				Weight = "Medium",
				Color = scope:Computed(function(use)
					return if use(moreState) == "hover" or use(moreOpen) then C.Text else C.TextSub
				end),
				LayoutOrder = 2,
			}),
			-- what's set in there, so nothing hides behind the fold
			Ui.text(scope, {
				Name = "Summary",
				Text = scope:Computed(function(use)
					local parts = { if use(usingAI) then Store.aiLabel(store:aiProvider(use)) else "Instant" }
					if use(usingAI) and use(store.quality) == "fast" then
						table.insert(parts, "Fast")
					end
					local refs = #use(store.references)
					if refs > 0 then
						table.insert(parts, `{refs} reference{if refs == 1 then "" else "s"}`)
					end
					return table.concat(parts, " · ")
				end),
				TextSize = Style.Text.Caption,
				Color = C.TextMuted,
				XAlign = Enum.TextXAlignment.Right,
				Size = UDim2.new(0, 0, 1, 0),
				AutomaticSize = Enum.AutomaticSize.None,
				Truncate = true,
				LayoutOrder = 3,
				[Children] = Ui.flex(scope, "Fill"),
			}),
			Ui.icon(scope, {
				Name = "Chevron",
				Icon = scope:Computed(function(use)
					return if use(moreOpen) then "chevron-small-up" else "chevron-small-down"
				end),
				Size = 16,
				Color = C.TextMuted,
				LayoutOrder = 4,
			}),
		},
	}))
	Ui.tooltip(scope, moreToggle, scope:Computed(function(use)
		return if use(moreOpen) then "Hide the extra options" else "Mode, references, design brief, examples"
	end))

	local moreBody = Ui.column(scope, {
		Name = "MoreOptionsBody",
		Gap = S.L,
		LayoutOrder = 11,
		Visible = moreOpen,
		Padding = { 2, 0, 0, 0 },
		[Children] = {
			Ui.field(scope, {
				Name = "ModeField",
				Label = "Mode",
				Hint = modeHint,
				LayoutOrder = 1,
				[Children] = Ui.segmented(scope, {
					Name = "Mode",
					Value = store.mode,
					Options = {
						{
							value = "AI",
							label = scope:Computed(function(use)
								return Store.aiLabel(store:aiProvider(use))
							end),
							icon = "cursor-stars",
							tooltip = scope:Computed(function(use)
								if store:aiProvider(use) == "openai" then
									return "ChatGPT (OpenAI's GPT models) writes the brief and the UI, on your OpenAI key"
								end
								return "Claude writes the brief and the UI (counts toward today's AI quota)"
							end),
							-- off on the server, unless an own key of the user's runs it
							locked = scope:Computed(function(use)
								local status = use(cloud.status)
								return status ~= nil and status.modes ~= nil and status.modes.ai == false
									and (cloud.ownKeyInUse == nil or use(cloud.ownKeyInUse) == nil)
							end),
						},
						{ value = "Offline", label = "Instant", icon = "lightning-bolt", tooltip = "BloxUI's built-in generator: done in seconds, no AI quota" },
					},
					OnChange = function(mode)
						store:setMode(mode)
					end,
				}),
			}),
			-- Best adds Claude's design review; only Claude mode has one
			Ui.field(scope, {
				Name = "CreateQualityField",
				Label = "Quality",
				Hint = scope:Computed(function(use)
					local value = use(store.quality)
					for _, entry in Store.QUALITY do
						if entry.value == value and entry.hint then
							return entry.hint .. "."
						end
					end
					return ""
				end),
				LayoutOrder = 2,
				Visible = usingAI,
				[Children] = Ui.segmented(scope, {
					Name = "CreateQuality",
					Value = store.quality,
					Options = (function()
						local options = {}
						for _, entry in Store.QUALITY do
							table.insert(options, {
								value = entry.value,
								label = entry.label,
								icon = if entry.value == "best" then "circle-star" else "lightning-bolt",
							})
						end
						return options
					end)(),
					OnChange = function(quality)
						store:setQuality(quality)
					end,
				}),
			}),
			Ui.field(scope, {
				Name = "ReferencesField",
				Label = "Reference screenshots",
				Accessory = Ui.text(scope, {
					Name = "ReferenceCount",
					Text = scope:Computed(function(use)
						local count = use(referenceCount)
						return if count == 0 then "Optional" else `{count} of {referenceLimits.count}`
					end),
					TextSize = Style.Text.Caption,
					Color = C.TextMuted,
					LayoutOrder = 3,
				}),
				LayoutOrder = 3,
				[Children] = referencesBlock,
			}),
			Ui.field(scope, {
				Name = "BriefStep",
				Label = "Design brief",
				Hint = scope:Computed(function(use)
					if use(store.brief) ~= nil then
						return "Your brief is below: read or edit it, then generate from it."
					end
					return `{if use(usingAI) then "Claude writes" else "Get"} a full design brief first, to read and edit before generating.`
				end),
				LayoutOrder = 4,
				[Children] = Ui.row(scope, {
					Name = "BriefActions",
					Gap = S.S,
					[Children] = {
						Ui.button(scope, {
							Name = "Improve",
							Text = scope:Computed(function(use)
								if use(compact) then
									return "Improve"
								end
								return if use(store.brief) ~= nil then "Rewrite brief" else "Improve prompt"
							end),
							Icon = "clipboard-pencil",
							Variant = "secondary",
							LayoutOrder = 1,
							Tooltip = scope:Computed(function(use)
								return if use(enterAction) == "improve" then "Write a full design brief first (Enter)" else "Write a full design brief first"
							end),
							Busy = scope:Computed(function(use)
								return use(busy) == "improve"
							end),
							Disabled = scope:Computed(function(use)
								return not use(canGenerate) and use(busy) ~= "improve"
							end),
							OnClick = improve,
						}),
					},
				}),
			}),
			Ui.field(scope, {
				Name = "ExamplesBlock",
				Label = "Try an example",
				LayoutOrder = 5,
				[Children] = scope:New "Frame" {
					Name = "Examples",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, 0, 0, 0),
					AutomaticSize = Enum.AutomaticSize.Y,
					[Children] = {
						Ui.list(scope, "X", 6, "Left", "Center", true),
						chips,
					},
				},
			}),
		},
	})

	local promptCard = Ui.card(scope, {
		Name = "PromptCard",
		Title = "Describe your game",
		Icon = "cursor-stars",
		LayoutOrder = 3,
		[Children] = {
			Ui.input(scope, {
				Name = "Prompt",
				Value = store.prompt,
				SubmitOnEnter = true,
				Height = 92,
				MaxHeight = 220, -- grows with a longer description, then scrolls (up to Remote.MAX_PROMPT characters)
				Placeholder = "e.g. A neon arena shooter with a battle pass, weapon cases and a kill feed",
				LayoutOrder = 1,
				BoxRef = function(box)
					promptBox = box
				end,
				OnInput = function(text)
					store.prompt:set(text)
				end,
				OnCommit = function(text, enter)
					store:saveSession()
					if not enter or peek(isBusy) or Store.trim(text) == "" then
						return
					end
					if app.modifiers().ctrl or peek(enterAction) == "generate" then
						generate()
					else
						improve()
					end
				end,
			}),
			Ui.row(scope, {
				Name = "KeyHint",
				Gap = 6,
				Height = 18,
				LayoutOrder = 2,
				Visible = scope:Computed(function(use)
					return use(hasPrompt) and not use(isBusy)
				end),
				[Children] = {
					Ui.kbd(scope, { Text = "Enter", LayoutOrder = 1 }),
					Ui.text(scope, {
						Name = "EnterAction",
						Text = scope:Computed(function(use)
							if use(enterAction) == "generate" then
								return "generate the UI"
							end
							return if use(store.brief) ~= nil then "rewrite the brief" else "write the design brief"
						end),
						TextSize = Style.Text.Caption,
						Color = C.TextMuted,
						LayoutOrder = 2,
					}),
					Ui.spacer(scope, 3),
					Ui.kbd(scope, {
						Text = "Ctrl+Enter",
						LayoutOrder = 4,
						Visible = scope:Computed(function(use)
							return use(app.wide) and use(enterAction) == "improve"
						end),
					}),
					Ui.text(scope, {
						Text = "skip to UI",
						TextSize = Style.Text.Caption,
						Color = C.TextMuted,
						LayoutOrder = 5,
						Visible = scope:Computed(function(use)
							return use(app.wide) and use(enterAction) == "improve"
						end),
					}),
				},
			}),
			layoutBlock,
			Ui.row(scope, {
				Name = "Actions",
				Gap = S.S,
				LayoutOrder = 4,
				Visible = scope:Computed(function(use)
					return not use(isBusy)
				end),
				[Children] = {
					Ui.button(scope, {
						Name = "Generate",
						Text = scope:Computed(function(use)
							if use(busy) == "generate" then
								return "Generating..."
							end
							if use(compact) then
								return "Generate"
							end
							return if use(store.hasBlueprint) then "Generate new UI" else "Generate UI"
						end),
						Icon = "play",
						Variant = "primary",
						Size = "lg",
						Flex = true,
						LayoutOrder = 2,
						Tooltip = "Build the whole UI (Ctrl+Enter)",
						Busy = scope:Computed(function(use)
							return use(busy) == "generate"
						end),
						Disabled = scope:Computed(function(use)
							return not use(canGenerate) and use(busy) ~= "generate"
						end),
						OnClick = generate,
					}),
				},
			}),
			progressPanel,
			Ui.divider(scope, { Name = "MoreDivider", LayoutOrder = 9 }),
			moreToggle,
			moreBody,
		},
	})

	----------------------------------------------------------------------
	-- brief card
	----------------------------------------------------------------------
	local briefView = scope:Value("Read")
	local openSections = {} -- [title] = true (kept across re-parses)
	local sectionsRevision = scope:Value(0)
	local function bumpSections()
		sectionsRevision:set(peek(sectionsRevision) + 1)
	end

	local parsed = scope:Computed(function(use)
		if use(briefView) ~= "Read" or use(store.brief) == nil then
			return {}
		end
		local all = parseBrief(use(store.briefText))
		local out = {}
		for _, section in all do
			if section.title ~= "AT A GLANCE" then -- shown as chips above
				table.insert(out, section)
			end
		end
		return out
	end)
	local allOpen = scope:Computed(function(use)
		use(sectionsRevision)
		local list = use(parsed)
		if #list == 0 then
			return false
		end
		for _, section in list do
			if not openSections[section.title] then
				return false
			end
		end
		return true
	end)

	local readView = scope:Computed(function(use, inner)
		use(sectionsRevision)
		local out = {}
		for index, section in use(parsed) do
			local open = openSections[section.title] == true
			local state, events = Ui.interact(inner)
			table.insert(out, inner:New "Frame" {
				Name = "BriefSection_" .. index,
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = index,
				[Children] = {
					Ui.list(inner, "Y", 0),
					inner:New "TextButton" (Ui.merge(events, {
						Name = "Header",
						Text = "",
						AutoButtonColor = false,
						Size = UDim2.new(1, 0, 0, 30),
						LayoutOrder = 1,
						BackgroundColor3 = C.RowHover,
						BackgroundTransparency = inner:Computed(function(use2)
							local s = use2(state)
							return if s == "hover" or s == "press" then 0 else 1
						end),
						[OnEvent "Activated"] = function()
							openSections[section.title] = if open then nil else true
							bumpSections()
						end,
						[Children] = {
							Ui.corner(inner, Style.Radius.S),
							Ui.padding(inner, 0, 6, 0, 4),
							Ui.list(inner, "X", 6, "Left", "Center"),
							Ui.icon(inner, {
								Icon = if open then "chevron-small-down" else "chevron-small-right",
								Size = 14,
								Color = C.TextMuted,
								LayoutOrder = 1,
							}),
							if section.number
								then Ui.text(inner, {
									Name = "Number",
									Text = string.format("%02d", tonumber(section.number) or 0),
									Font = Style.Font.Mono,
									TextSize = 11,
									Color = C.TextMuted,
									LayoutOrder = 2,
								})
								else nil,
							Ui.text(inner, {
								Name = "Title",
								Text = section.title,
								TextSize = Style.Text.Caption,
								Weight = "SemiBold",
								Color = if open then C.Text else C.TextSub,
								Size = UDim2.new(0, 0, 1, 0),
								Truncate = true,
								LayoutOrder = 3,
								[Children] = Ui.flex(inner, "Fill"),
							}),
						},
					})),
					if open
						then inner:New "Frame" {
							Name = "Body",
							BackgroundTransparency = 1,
							Size = UDim2.new(1, 0, 0, 0),
							AutomaticSize = Enum.AutomaticSize.Y,
							LayoutOrder = 2,
							[Children] = {
								Ui.padding(inner, 2, 6, 10, 24),
								Ui.text(inner, {
									Name = "Text",
									Text = section.body,
									TextSize = Style.Text.Small,
									Color = C.TextSub,
									LineHeight = 1.25,
									Fill = true,
									Wrap = true,
								}),
							},
						}
						else nil,
				},
			})
		end
		return out
	end)

	local briefCard = Ui.card(scope, {
		Name = "BriefCard",
		Title = "Design brief",
		Subtitle = scope:Computed(function(use)
			local count = #use(parsed)
			local source = if use(store.briefSource) == "ai" then "Written by Claude" else "Written instantly"
			if use(store.briefEdited) then
				return "Edited by you"
			end
			return if count > 0 then `{source} · {count} section{if count == 1 then "" else "s"}` else `{source}. Edit anything.`
		end),
		Icon = "clipboard-pencil",
		LayoutOrder = 4,
		Visible = scope:Computed(function(use)
			return use(store.brief) ~= nil
		end),
		Accessory = {
			Ui.badge(scope, {
				Name = "EditedBadge",
				Text = "Edited",
				Tone = "warning",
				Dot = true,
				LayoutOrder = 1,
				Visible = store.briefEdited,
			}),
			Ui.button(scope, {
				Name = "Reset",
				Text = "Reset",
				Icon = "arrow-rotate-right",
				Variant = "ghost",
				Size = "sm",
				LayoutOrder = 2,
				Tooltip = "Back to the original brief",
				Visible = scope:Computed(function(use)
					return use(store.briefEdited) and use(app.wide)
				end),
				OnClick = function()
					store.briefText:set(peek(store.briefOriginal))
				end,
			}),
			Ui.iconButton(scope, {
				Name = "Discard",
				Icon = "x-small",
				Tooltip = "Discard brief",
				LayoutOrder = 3,
				OnClick = function()
					store.brief:set(nil)
					store.briefText:set("")
					store.briefOriginal:set("")
				end,
			}),
		},
		[Children] = {
			scope:New "Frame" {
				Name = "BriefStale",
				BackgroundColor3 = C.WarningSoft,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 0,
				Visible = scope:Computed(function(use)
					return use(store.brief) ~= nil and not use(briefCurrent) and use(hasPrompt)
				end),
				[Children] = {
					Ui.corner(scope, Style.Radius.M),
					Ui.padding(scope, 8, 8, 8, 10),
					Ui.list(scope, "X", 8, "Left", "Center"),
					Ui.icon(scope, { Icon = "triangle-exclamation", Size = 14, Color = C.Warning, Bold = true, LayoutOrder = 1 }),
					Ui.text(scope, {
						Name = "StaleText",
						Text = scope:Computed(function(use)
							if not use(promptCurrent) then
								return "Your prompt changed since this brief was written."
							end
							return "Your references changed since this brief was written."
						end),
						TextSize = Style.Text.Caption,
						Color = C.Text,
						Wrap = true,
						Size = UDim2.new(1, -110, 0, 0),
						AutomaticSize = Enum.AutomaticSize.Y,
						LayoutOrder = 2,
					}),
					Ui.button(scope, {
						Name = "Rewrite",
						Text = "Rewrite",
						Variant = "soft",
						Size = "sm",
						LayoutOrder = 3,
						Disabled = scope:Computed(function(use)
							return not use(canGenerate)
						end),
						OnClick = improve,
					}),
				},
			},
			Ui.overline(scope, { Text = "At a glance", LayoutOrder = 1 }),
			scope:New "Frame" {
				Name = "Summary",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 2,
				[Children] = {
					Ui.list(scope, "Y", 4),
					briefSummary(scope, store),
				},
			},
			Ui.divider(scope, { LayoutOrder = 3 }),
			Ui.row(scope, {
				Name = "BriefTools",
				Gap = 8,
				Height = 30,
				LayoutOrder = 4,
				[Children] = {
					Ui.segmented(scope, {
						Name = "BriefView",
						Value = briefView,
						Size = UDim2.fromOffset(150, 30),
						LayoutOrder = 1,
						Options = {
							{ value = "Read", label = "Read", icon = "book-closed", tooltip = "Read the brief by section" },
							{ value = "Edit", label = "Edit", icon = "pencil", tooltip = "Change the brief text" },
						},
						OnChange = function(view)
							briefView:set(view)
						end,
					}),
					Ui.spacer(scope, 2),
					Ui.button(scope, {
						Name = "ToggleSections",
						Text = scope:Computed(function(use)
							return if use(allOpen) then "Collapse all" else "Expand all"
						end),
						Variant = "ghost",
						Size = "sm",
						LayoutOrder = 3,
						Visible = scope:Computed(function(use)
							return use(briefView) == "Read"
						end),
						OnClick = function()
							local open = not peek(allOpen)
							for _, section in peek(parsed) do
								openSections[section.title] = if open then true else nil
							end
							bumpSections()
						end,
					}),
				},
			}),
			Ui.column(scope, {
				Name = "BriefRead",
				Gap = 0,
				LayoutOrder = 5,
				Visible = scope:Computed(function(use)
					return use(briefView) == "Read"
				end),
				[Children] = readView,
			}),
			Ui.column(scope, {
				Name = "BriefEditor",
				Gap = 6,
				LayoutOrder = 6,
				Visible = scope:Computed(function(use)
					return use(briefView) == "Edit"
				end),
				[Children] = {
					Ui.input(scope, {
						Name = "BriefText",
						Value = store.briefText,
						MultiLine = true,
						Height = 300,
						TextSize = Style.Text.Small,
						LayoutOrder = 1,
						OnInput = function(text)
							store.briefText:set(text)
						end,
					}),
					Ui.text(scope, {
						Text = "Generate uses your text as written. Reset brings back the original.",
						TextSize = Style.Text.Caption,
						Color = C.TextMuted,
						Fill = true,
						Wrap = true,
						LayoutOrder = 2,
					}),
				},
			}),
			Ui.button(scope, {
				Name = "GenerateFromBrief",
				Text = scope:Computed(function(use)
					return if use(store.briefEdited) then "Generate from your edited brief" else "Generate UI from this brief"
				end),
				Icon = "play",
				Variant = "primary",
				Size = "lg",
				Fill = true,
				LayoutOrder = 7,
				Visible = scope:Computed(function(use)
					return not use(isBusy)
				end),
				Disabled = scope:Computed(function(use)
					return not use(canGenerate)
				end),
				OnClick = generate,
			}),
		},
	})

	----------------------------------------------------------------------
	-- result card
	----------------------------------------------------------------------
	local frameChips = scope:Computed(function(use, inner)
		use(store.structure)
		use(store.hasBlueprint)
		local out = {}
		for index, option in store:screenOptions(true) do
			local id = option.value
			table.insert(out, Ui.chip(inner, {
				Name = "Frame_" .. tostring(id),
				Text = if id == Store.HUD then "HUD" else option.label,
				Icon = option.icon,
				Height = 26,
				LayoutOrder = index,
				Selected = inner:Computed(function(use2)
					return use2(store.previewOn) and use2(store.previewScreen) == id
				end),
				OnClick = function()
					store.previewScreen:set(id)
					if not peek(store.previewOn) then
						store.previewOn:set(true)
					end
				end,
			}))
		end
		return out
	end)

	-- the design's HUD layout (Change picks another, like the Structure tab)
	local resultLayout = scope:Computed(function(use)
		use(store.revision)
		use(store.hasBlueprint)
		local bp = store.blueprint
		local hud = bp and bp.hud
		return if typeof(hud) == "table" and typeof(hud.layout) == "string" then hud.layout else "Classic"
	end)
	local changeLayout
	changeLayout = Ui.button(scope, {
		Name = "ChangeLayout",
		Text = "Change",
		Variant = "ghost",
		Size = "sm",
		LayoutOrder = 4,
		Tooltip = "Pick another HUD layout for this design",
		OnClick = function()
			LayoutCards.pick(app, changeLayout, {
				values = LayoutCards.values(),
				value = peek(resultLayout),
				title = "HUD layout",
				onSelect = function(value)
					store:setBlueprintLayout(value)
				end,
			})
		end,
	})
	local resultLayoutRow = Ui.row(scope, {
		Name = "ResultLayout",
		Gap = 8,
		Height = 26,
		LayoutOrder = 3,
		[Children] = {
			Ui.text(scope, {
				Text = "HUD layout",
				TextSize = Style.Text.Caption,
				Color = C.TextMuted,
				Size = UDim2.fromOffset(64, 20),
				LayoutOrder = 1,
			}),
			scope:Computed(function(use, inner)
				return LayoutCards.diagram(inner, use(resultLayout), { Name = "LayoutDiagram", Width = 32, LayoutOrder = 2 })
			end),
			Ui.text(scope, {
				Name = "LayoutName",
				Text = scope:Computed(function(use)
					return Catalog.hudLayoutInfo(use(resultLayout)).label
				end),
				TextSize = Style.Text.Small,
				Weight = "SemiBold",
				Size = UDim2.new(0, 0, 1, 0),
				Truncate = true,
				LayoutOrder = 3,
				[Children] = Ui.flex(scope, "Fill"),
			}),
			changeLayout,
		},
	})

	local resultCard = Ui.card(scope, {
		Name = "ResultCard",
		Title = scope:Computed(function(use)
			local result = use(store.lastResult)
			return if result then result.title else ""
		end),
		Subtitle = scope:Computed(function(use)
			use(store.revision)
			local result = use(store.lastResult)
			if result == nil then
				return ""
			end
			local bp = store.blueprint
			local hud, screens = result.hud, result.screens
			if bp then
				hud, screens = Store.counts(bp)
			end
			local parts = {}
			if not use(app.wide) then
				return `{hud} HUD  ·  {screens} screen{if screens == 1 then "" else "s"}`
			end
			if result.genre and result.genre ~= "" then
				table.insert(parts, result.genre)
			end
			table.insert(parts, `{hud} HUD element{if hud == 1 then "" else "s"}`)
			table.insert(parts, `{screens} screen{if screens == 1 then "" else "s"}`)
			if result.seed and use(app.wide) then
				table.insert(parts, `seed {result.seed}`)
			end
			return table.concat(parts, "  ·  ")
		end),
		Icon = "circle-check",
		LayoutOrder = 5,
		Visible = scope:Computed(function(use)
			return use(store.lastResult) ~= nil
		end),
		Accessory = Ui.badge(scope, {
			Name = "SourceBadge",
			Text = scope:Computed(function(use)
				local result = use(store.lastResult)
				return sourceLabel(result and result.source)
			end),
			Tone = scope:Computed(function(use)
				local result = use(store.lastResult)
				return sourceTone(result and result.source)
			end),
			Dot = true,
		}),
		[Children] = {
			Ui.overline(scope, {
				Name = "FramesHeader",
				Text = "Frames",
				LayoutOrder = 1,
				Accessory = Ui.text(scope, {
					Text = "Click to preview",
					TextSize = Style.Text.Caption,
					Color = C.TextMuted,
				}),
			}),
			scope:New "Frame" {
				Name = "Frames",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 2,
				[Children] = {
					Ui.list(scope, "X", 6, "Left", "Center", true),
					frameChips,
				},
			},
			resultLayoutRow,
			Ui.divider(scope, { LayoutOrder = 4 }),
			Ui.row(scope, {
				Name = "ResultActions",
				Gap = S.S,
				Wrap = true,
				LayoutOrder = 5,
				[Children] = {
					Ui.button(scope, {
						Name = "Variation",
						Text = "Variation",
						Icon = "two-arrows-spin-clockwise",
						Variant = "secondary",
						LayoutOrder = 1,
						Disabled = scope:Computed(function(use)
							return not use(canGenerate)
						end),
						Tooltip = "Same input, new seed: fresh content, same style",
						OnClick = function()
							store:generate(true)
						end,
					}),
					Ui.button(scope, {
						Name = "Regenerate",
						Text = "Regenerate",
						Icon = "arrow-rotate-right",
						Variant = "secondary",
						LayoutOrder = 2,
						Disabled = scope:Computed(function(use)
							return not use(canGenerate)
						end),
						Tooltip = "Generate again from your current prompt / brief",
						OnClick = generate,
					}),
					Ui.button(scope, {
						Name = "UndoGenerate",
						Text = "Undo",
						Icon = "arrow-curl-to-left",
						Variant = "ghost",
						LayoutOrder = 3,
						Visible = scope:Computed(function(use)
							return use(history.topKind) == "replace"
						end),
						Disabled = isBusy,
						Tooltip = "Back to the design you had before (Ctrl+Z)",
						OnClick = function()
							history:undo()
						end,
					}),
				},
			}),
			Ui.row(scope, {
				Name = "NextSteps",
				Gap = S.S,
				LayoutOrder = 6,
				[Children] = {
					Ui.button(scope, {
						Name = "Customize",
						Text = "Customize",
						Icon = "paint-brush",
						Variant = "soft",
						Flex = true,
						LayoutOrder = 1,
						OnClick = function()
							store.tab:set("Design")
						end,
					}),
					Ui.button(scope, {
						Name = "GoInsert",
						Text = "Insert",
						Icon = "arrow-down-to-line",
						Variant = "soft",
						Flex = true,
						LayoutOrder = 2,
						OnClick = function()
							store.tab:set("Export")
						end,
					}),
				},
			}),
		},
	})

	----------------------------------------------------------------------
	-- history
	----------------------------------------------------------------------
	local function isCurrent(entry: any): boolean
		local bp = store.blueprint
		if bp == nil or typeof(bp.meta) ~= "table" then
			return false
		end
		return entry.seed ~= nil and entry.seed == bp.meta.seed and entry.title == Store.titleOf(bp)
	end

	local function deleteEntry(entry: any)
		local index = store:removeHistory(entry)
		if index == nil then
			return
		end
		store:toast(`Removed {entry.title or "that generation"} from history.`, "info", {
			action = {
				label = "Undo",
				run = function()
					store:insertHistory(entry, index)
				end,
			},
		})
	end

	local historyRows = scope:Computed(function(use, inner)
		use(store.lastResult)
		local out = {}
		for index, entry in use(store.history) do
			local state, events = Ui.interact(inner)
			local current = isCurrent(entry)
			local rowHover = inner:Value(false)
			local reveal = inner:Computed(function(use2)
				return use2(rowHover) or use2(compact)
			end)
			local row = inner:New "TextButton" (Ui.merge(events, {
				Name = "History_" .. index,
				Text = "",
				AutoButtonColor = false,
				Size = UDim2.new(1, 0, 0, 46),
				LayoutOrder = index,
				BackgroundColor3 = inner:Computed(function(use2)
					local s = use2(state)
					if s == "hover" or s == "press" then
						return C.RowHover
					end
					return if current then C.AccentSoft else C.Card
				end),
				BackgroundTransparency = inner:Computed(function(use2)
					local s = use2(state)
					return if s == "hover" or s == "press" or current then 0 else 1
				end),
				[OnEvent "Activated"] = function()
					if not current then
						store:restoreHistory(entry)
					end
				end,
				[Children] = {
					Ui.corner(inner, Style.Radius.M),
					Ui.padding(inner, 0, 6, 0, 8),
					Ui.list(inner, "X", 10, "Left", "Center"),
					Ui.iconBubble(inner, {
						Icon = Catalog.genreIcons[entry.genre or ""] or "cursor-stars",
						Size = 30,
						Background = if current then C.Accent else C.Raised,
						Color = if current then C.White else C.TextSub,
						LayoutOrder = 1,
					}),
					Ui.column(inner, {
						Gap = 1,
						LayoutOrder = 2,
						Size = UDim2.new(0, 0, 0, 0),
						Flex = "Fill",
						[Children] = {
							Ui.text(inner, {
								Text = entry.title or "Untitled",
								Weight = "SemiBold",
								TextSize = Style.Text.Small,
								Fill = true,
								Truncate = true,
								AutomaticSize = Enum.AutomaticSize.Y,
								LayoutOrder = 1,
							}),
							Ui.text(inner, {
								Text = `{entry.genre ~= "" and entry.genre or "ui"}  ·  {entry.screens or 0} screens  ·  {ago(entry.time)}`,
								TextSize = Style.Text.Caption,
								Color = C.TextMuted,
								Fill = true,
								Truncate = true,
								AutomaticSize = Enum.AutomaticSize.Y,
								LayoutOrder = 2,
							}),
						},
					}),
					if current
						then Ui.badge(inner, { Text = "Current", Tone = "accent", LayoutOrder = 3 })
						else Ui.badge(inner, {
							Text = sourceLabel(entry.source),
							Tone = sourceTone(entry.source),
							LayoutOrder = 3,
						}),
					Ui.iconButton(inner, {
						Name = "DeleteEntry",
						Icon = "x-small",
						Size = 24,
						IconSize = 14,
						Tone = "danger",
						Tooltip = "Remove from history",
						LayoutOrder = 4,
						Visible = reveal,
						OnClick = function()
							deleteEntry(entry)
						end,
					}),
				},
			}))
			table.insert(inner, row.MouseEnter:Connect(function()
				rowHover:set(true)
			end))
			table.insert(inner, row.MouseLeave:Connect(function()
				rowHover:set(false)
			end))
			if entry.prompt and entry.prompt ~= "" then
				Ui.tooltip(inner, row, if #entry.prompt > 90 then string.sub(entry.prompt, 1, 87) .. "..." else entry.prompt)
			end
			table.insert(out, row)
		end
		return out
	end)

	local historyCard = Ui.card(scope, {
		Name = "HistoryCard",
		Title = "Recent",
		Subtitle = "Click one to restore it.",
		Icon = "clock-spin-reverse",
		LayoutOrder = 6,
		Gap = S.S,
		Visible = scope:Computed(function(use)
			return #use(store.history) > 0
		end),
		Accessory = Ui.button(scope, {
			Name = "ClearHistory",
			Text = "Clear",
			Variant = "ghost",
			Size = "sm",
			OnClick = function()
				local n = #peek(store.history)
				app.overlay:confirm({
					title = "Clear history?",
					body = `Removes {n} saved generation{if n == 1 then "" else "s"} from this computer. The design you're working on stays.`,
					confirm = "Clear",
					danger = true,
					onConfirm = function()
						store:clearHistory()
						store:toast("History cleared.", "info")
					end,
				})
			end,
		}),
		[Children] = {
			Ui.column(scope, {
				Name = "HistoryList",
				Gap = 2,
				LayoutOrder = 1,
				[Children] = historyRows,
			}),
		},
	})

	return Ui.scroll(scope, {
		Name = "CreatePage",
		Gap = S.M,
		[Children] = {
			gateCard,
			samplesCard,
			promptCard,
			briefCard,
			resultCard,
			historyCard,
		},
	})
end
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="23">
          <Properties>
            <string name="Name">Design</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Design tab: live edits of blueprint.style, blueprint.logo, the HUD kit
	(blueprint.hud.kit, v1.8) and the sounds and music (style.sounds,
	blueprint.music).
]]

local SoundService = game:GetService("SoundService")

local Deps = require(script.Parent.Parent.Deps)
local Style = require(script.Parent.Parent.Style)
local Ui = require(script.Parent.Parent.Ui)
local Catalog = require(script.Parent.Parent.Catalog)
local Fields = require(script.Parent.Parent.Fields)
local Store = require(script.Parent.Parent.Store)

local Fusion = Deps.Fusion
local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local C = Style.Color
local S = Style.Space

local FONT_SLOTS = {
	{ key = "display", label = "Display", hint = "Titles, numbers, logo" },
	{ key = "body", label = "Body", hint = "Buttons, labels, text" },
	{ key = "title", label = "Title", hint = "Window headers" },
}

local function weightEnum(value: number): Enum.FontWeight
	local ok, item = pcall(function()
		return (Enum.FontWeight :: any)[Catalog.weightEnumNames[value] or "Regular"]
	end)
	return if ok and item then item else Enum.FontWeight.Regular
end

local function fontFor(ref: string?): Font
	local family, weight = Catalog.splitFontRef(ref)
	local info = Catalog.fontByFamily[family]
	if info == nil then
		return Style.Font.Bold
	end
	local value = Catalog.weightValue(Catalog.closestWeight(family, weight or "Regular")) or 400
	local ok, font = pcall(Font.new, Catalog.fontPath(family), weightEnum(value))
	return if ok then font else Style.Font.Bold
end

local fontOptions = nil
local function getFontOptions(): { any }
	if fontOptions == nil then
		fontOptions = {}
		for _, info in Catalog.fonts do
			local ok, font = pcall(Font.new, Catalog.fontPath(info.family), Enum.FontWeight.Regular)
			table.insert(fontOptions, {
				value = info.family,
				label = info.label,
				group = info.group,
				font = if ok then font else nil,
			})
		end
	end
	return fontOptions
end

return function(scope: any, app: any): Instance
	local store = app.store
	-- Controls follow edits only while Design is showing (they catch up once
	-- when it opens again), so edits elsewhere don't re-evaluate every control.
	local revision = store:tabCounters(scope, "Design")

	local function styleValue(key: string): any
		return scope:Computed(function(use)
			use(revision)
			local bp = store.blueprint
			return bp and bp.style and bp.style[key]
		end)
	end
	local function paletteValue(key: string): any
		return scope:Computed(function(use)
			use(revision)
			local bp = store.blueprint
			return bp and bp.style and bp.style.palette and bp.style.palette[key]
		end)
	end
	local function logoValue(key: string): any
		return scope:Computed(function(use)
			use(revision)
			local bp = store.blueprint
			return bp and bp.logo and bp.logo[key]
		end)
	end
	local function fontRef(slot: string): any
		return scope:Computed(function(use)
			use(revision)
			local bp = store.blueprint
			return bp and bp.style and bp.style.fonts and bp.style.fonts[slot]
		end)
	end
	local paletteList = scope:Computed(function(use)
		use(revision)
		-- (v2.4: palette.surface may be the word "light" / "dark", not a colour)
		local out = {}
		for _, value in Fields.paletteColors(store) do
			if typeof(value) == "string" and Style.parseHex(value) ~= nil then
				table.insert(out, value)
			end
		end
		return out
	end)

	----------------------------------------------------------------------
	-- style archetype
	----------------------------------------------------------------------
	local archetype = styleValue("archetype")
	local archetypeChips = {}
	for index, raw in Catalog.style.archetype do
		local option = Ui.normalizeOption(raw)
		table.insert(archetypeChips, Ui.chip(scope, {
			Name = "Archetype_" .. option.value,
			Text = option.label,
			LayoutOrder = index,
			Selected = scope:Computed(function(use)
				return use(archetype) == option.value
			end),
			OnClick = function()
				store:setStyle("archetype", option.value)
			end,
		}))
	end
	local archetypeHint = scope:Computed(function(use)
		local value = use(archetype)
		for _, raw in Catalog.style.archetype do
			local option = Ui.normalizeOption(raw)
			if option.value == value and option.hint then
				return option.hint
			end
		end
		return "Pick the overall look."
	end)

	local styleCard = Ui.card(scope, {
		Name = "StyleCard",
		Title = "Style",
		Subtitle = archetypeHint,
		Icon = "paint-brush",
		LayoutOrder = 1,
		[Children] = {
			scope:New "Frame" {
				Name = "Archetypes",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 1,
				[Children] = {
					Ui.list(scope, "X", 6, "Left", "Center", true),
					archetypeChips,
				},
			},
		},
	})

	----------------------------------------------------------------------
	-- style family (v2.4: blueprint.style.family, Theme/Families)
	----------------------------------------------------------------------
	local currentFamily = styleValue("family")
	local surfaceValue = paletteValue("surface")
	local surfaceMode = scope:Computed(function(use)
		local value = use(surfaceValue)
		if value == "light" or value == "dark" then
			return value
		end
		return if typeof(value) == "string" and string.sub(value, 1, 1) == "#" then "custom" else "auto"
	end)

	-- With a family on, the family decides fonts and the look & feel switches;
	-- a key the user sets by hand is listed in style.custom and keeps its value.
	local CUSTOM_KEYS = {
		fonts = true, corner = true, buttonStyle = true, panelStyle = true, clickFx = true, transition = true,
		textCase = true, decoration = true, shadow = true, iconStyle = true, rarityScheme = true, density = true,
	}
	local function markCustom(key: string)
		local bp = store.blueprint
		if not (bp and bp.style and bp.style.family and CUSTOM_KEYS[key]) then
			return
		end
		local list = bp.style.custom
		if typeof(list) == "table" and table.find(list, key) then
			return
		end
		store:edit(function(b)
			b.style.custom = if typeof(b.style.custom) == "table" then b.style.custom else {}
			table.insert(b.style.custom, key)
		end)
	end

	local function setFamily(value: string)
		store:edit(function(bp)
			bp.style = bp.style or {}
			if value == "None" then
				bp.style.family = nil
				bp.style.custom = nil
			else
				bp.style.family = value
				-- a surface colour made for the classic dark glass would force the
				-- family dark: let the family decide (Light / Dark below)
				local palette = bp.style.palette
				if typeof(palette) == "table" and typeof(palette.surface) == "string" and string.sub(palette.surface, 1, 1) == "#" then
					palette.surface = nil
				end
			end
		end)
	end

	-- A tiny window in the family's own colours: panel, header, outline,
	-- shadow or glow, a button and "Aa" in its display face.
	local function familyPreview(inner: any, spec: any): Frame
		spec = spec or {}
		local function hex(value: any, fallback: Color3): Color3
			return (typeof(value) == "string" and Style.parseHex(value)) or fallback
		end
		local panel = hex(spec.panel, Color3.fromRGB(30, 30, 40))
		local ink = hex(spec.ink, Color3.new(1, 1, 1))
		local corner = spec.corner or 3
		local W, H = 60, 34
		local layers = {}
		if spec.shadow then
			local s = spec.shadow
			table.insert(layers, inner:New "Frame" {
				Name = "Shadow",
				BackgroundColor3 = Color3.new(0, 0, 0),
				BackgroundTransparency = if s[4] then math.min(0.9, (s[3] or 0.5) + 0.25) else (s[3] or 0.3),
				BorderSizePixel = 0,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, s[1] or 0, 0.5, s[2] or 2),
				Size = UDim2.fromOffset(W + (if s[4] then 4 else 0), H + (if s[4] then 2 else 0)),
				[Children] = inner:New "UICorner" { CornerRadius = UDim.new(0, corner + 2) },
			})
		end
		if spec.glow then
			table.insert(layers, inner:New "Frame" {
				Name = "Glow",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.fromOffset(W + 4, H + 4),
				[Children] = {
					inner:New "UICorner" { CornerRadius = UDim.new(0, corner + 2) },
					inner:New "UIStroke" { Color = hex(spec.glow, Color3.new(1, 1, 1)), Thickness = 3, Transparency = 0.55 },
				},
			})
		end
		local body = {
			inner:New "UICorner" { CornerRadius = UDim.new(0, corner) },
			if (spec.strokeWidth or 0) > 0
				then inner:New "UIStroke" {
					Color = hex(spec.stroke, Color3.new(0, 0, 0)),
					Thickness = spec.strokeWidth,
					Transparency = spec.strokeAlpha or 0,
					ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				}
				else nil,
		}
		if spec.band then
			local band = hex(spec.band, ink)
			table.insert(body, inner:New "Frame" {
				Name = "Band",
				BackgroundColor3 = band,
				BorderSizePixel = 0,
				Size = UDim2.new(1, 0, 0, 10),
				[Children] = inner:New "UICorner" { CornerRadius = UDim.new(0, corner) },
			})
			table.insert(body, inner:New "Frame" {
				Name = "BandBase",
				BackgroundColor3 = band,
				BorderSizePixel = 0,
				Position = UDim2.fromOffset(0, 5),
				Size = UDim2.new(1, 0, 0, 5),
			})
			if spec.detail == "bar" then
				table.insert(body, inner:New "Frame" {
					Name = "AccentBar",
					BackgroundColor3 = hex(spec.accent, Color3.new(1, 1, 0)),
					BorderSizePixel = 0,
					Position = UDim2.fromOffset(0, 2),
					Size = UDim2.fromOffset(2, 6),
					ZIndex = 2,
				})
			end
		elseif spec.line then
			table.insert(body, inner:New "Frame" {
				Name = "Line",
				BackgroundColor3 = hex(spec.line, ink),
				BackgroundTransparency = 0.2,
				BorderSizePixel = 0,
				Position = UDim2.fromOffset(4, 10),
				Size = UDim2.new(1, -8, 0, 1),
			})
		end
		if spec.detail == "lines" then
			for i = 0, 3 do
				table.insert(body, inner:New "Frame" {
					Name = "Ruled",
					BackgroundColor3 = Color3.fromRGB(111, 154, 216),
					BackgroundTransparency = 0.5,
					BorderSizePixel = 0,
					Position = UDim2.fromOffset(2, 9 + i * 6),
					Size = UDim2.new(1, -4, 0, 1),
				})
			end
		elseif spec.detail == "brackets" then
			for _, c in { { 0, 0 }, { 1, 1 } } do
				local anchor = Vector2.new(c[1], c[2])
				local at = UDim2.fromScale(c[1], c[2])
				for _, size in { Vector2.new(7, 2), Vector2.new(2, 7) } do
					table.insert(body, inner:New "Frame" {
						Name = "Bracket",
						BackgroundColor3 = hex(spec.stroke, ink),
						BorderSizePixel = 0,
						AnchorPoint = anchor,
						Position = at,
						Size = UDim2.fromOffset(size.X, size.Y),
						ZIndex = 2,
					})
				end
			end
		elseif spec.detail == "grid" then
			for i = 1, 5 do
				table.insert(body, inner:New "Frame" {
					Name = "GridV",
					BackgroundColor3 = hex(spec.stroke, ink),
					BackgroundTransparency = 0.8,
					BorderSizePixel = 0,
					Position = UDim2.new(i / 6, 0, 0, 11),
					Size = UDim2.new(0, 1, 1, -12),
				})
			end
		elseif spec.detail == "dots" then
			for i = 0, 5 do
				table.insert(body, inner:New "Frame" {
					Name = "Dot",
					BackgroundColor3 = ink,
					BackgroundTransparency = 0.8,
					BorderSizePixel = 0,
					Position = UDim2.new(1, -6 - (i % 3) * 4, 1, -5 - math.floor(i / 3) * 4),
					Size = UDim2.fromOffset(2, 2),
					[Children] = inner:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
				})
			end
		end
		table.insert(body, inner:New "TextLabel" {
			Name = "Sample",
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(5, 12),
			Size = UDim2.new(0, 30, 1, -14),
			Text = "Aa",
			FontFace = fontFor(spec.font),
			TextSize = if string.find(spec.font or "", "PressStart2P", 1, true) then 9 else 15,
			TextColor3 = ink,
			TextXAlignment = Enum.TextXAlignment.Left,
			ZIndex = 3,
		})
		table.insert(body, inner:New "Frame" {
			Name = "Button",
			BackgroundColor3 = hex(spec.button, Color3.fromRGB(94, 212, 34)),
			BorderSizePixel = 0,
			AnchorPoint = Vector2.new(1, 1),
			Position = UDim2.new(1, -5, 1, -5),
			Size = UDim2.fromOffset(18, 8),
			ZIndex = 3,
			[Children] = inner:New "UICorner" { CornerRadius = if spec.pill then UDim.new(0.5, 0) else UDim.new(0, math.min(corner, 3)) },
		})
		return inner:New "Frame" {
			Name = "Scene",
			BackgroundColor3 = Color3.new(1, 1, 1),
			BorderSizePixel = 0,
			Size = UDim2.new(1, 0, 0, 46),
			ClipsDescendants = true,
			LayoutOrder = 1,
			[Children] = {
				Ui.corner(inner, Style.Radius.S),
				inner:New "UIGradient" {
					Rotation = 90,
					Color = ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color3.fromRGB(96, 156, 226)),
						ColorSequenceKeypoint.new(0.62, Color3.fromRGB(150, 196, 240)),
						ColorSequenceKeypoint.new(0.64, Color3.fromRGB(98, 176, 78)),
						ColorSequenceKeypoint.new(1, Color3.fromRGB(70, 138, 58)),
					}),
				},
				layers,
				inner:New "Frame" {
					Name = "Window",
					BackgroundColor3 = panel,
					BorderSizePixel = 0,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Size = UDim2.fromOffset(W, H),
					ZIndex = 2,
					[Children] = body,
				},
			},
		}
	end

	local function familyTile(entry: any, index: number): TextButton
		local state, events = Ui.interact(scope)
		local selected = scope:Computed(function(use)
			local current = use(currentFamily)
			if entry.value == "None" then
				return current == nil or current == "" or current == "None"
			end
			return current == entry.value
		end)
		return scope:New "TextButton" (Ui.merge(events, {
			Name = "Family_" .. entry.value,
			Text = "",
			AutoButtonColor = false,
			Size = UDim2.fromOffset(88, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			LayoutOrder = index,
			BackgroundColor3 = scope:Computed(function(use)
				if use(selected) then
					return C.AccentSoft
				end
				local s = use(state)
				return if s == "hover" or s == "press" then C.RaisedHover else C.Raised
			end),
			[OnEvent "Activated"] = function()
				setFamily(entry.value)
			end,
			[Children] = {
				Ui.corner(scope, Style.Radius.M),
				Ui.stroke(scope, scope:Computed(function(use)
					if use(selected) then
						return C.Accent
					end
					return if use(state) == "hover" then C.BorderStrong else C.Border
				end)),
				Ui.padding(scope, 4, 4, 5, 4),
				Ui.list(scope, "Y", 4, "Center"),
				familyPreview(scope, entry.preview),
				Ui.text(scope, {
					Name = "Label",
					Text = entry.label,
					TextSize = Style.Text.Caption,
					Weight = "Medium",
					Fill = true,
					Truncate = true,
					XAlign = Enum.TextXAlignment.Center,
					Color = scope:Computed(function(use)
						return if use(selected) then C.AccentText else C.TextSub
					end),
					LayoutOrder = 2,
				}),
			},
		}))
	end

	local familyTiles = { familyTile(Catalog.styleFamilyNone, 1) }
	for index, entry in Catalog.styleFamilies do
		table.insert(familyTiles, familyTile(entry, index + 1))
	end

	local familyHint = scope:Computed(function(use)
		local current = use(currentFamily)
		local entry = if current then Catalog.styleFamilyByValue[current] else nil
		entry = entry or Catalog.styleFamilyNone
		return `<b>{entry.label}</b>: {entry.hint}.`
	end)

	local surfaceChips = {}
	for index, option in { { "auto", "Family default" }, { "light", "Light panels" }, { "dark", "Dark panels" } } do
		table.insert(surfaceChips, Ui.chip(scope, {
			Name = "Surface_" .. option[1],
			Text = option[2],
			LayoutOrder = index,
			Selected = scope:Computed(function(use)
				return use(surfaceMode) == option[1]
			end),
			OnClick = function()
				store:edit(function(bp)
					bp.style.palette = bp.style.palette or {}
					bp.style.palette.surface = if option[1] == "auto" then nil else option[1]
				end)
			end,
		}))
	end
	local customCount = scope:Computed(function(use)
		use(revision)
		local bp = store.blueprint
		local list = bp and bp.style and bp.style.custom
		return if typeof(list) == "table" then #list else 0
	end)

	local familyCard = Ui.card(scope, {
		Name = "FamilyCard",
		Title = "Style family",
		Subtitle = "A complete look: colours, panels, shadows, fonts, motion and sounds.",
		Icon = "sparkle",
		LayoutOrder = 0,
		[Children] = {
			scope:New "Frame" {
				Name = "Families",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 1,
				[Children] = {
					Ui.list(scope, "X", 6, "Left", "Top", true),
					familyTiles,
				},
			},
			Ui.text(scope, {
				Name = "FamilyHint",
				Text = familyHint,
				RichText = true,
				TextSize = Style.Text.Caption,
				Color = C.TextSub,
				Fill = true,
				Wrap = true,
				LineHeight = 1.15,
				LayoutOrder = 2,
			}),
			scope:New "Frame" {
				Name = "SurfaceRow",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 3,
				Visible = scope:Computed(function(use)
					local current = use(currentFamily)
					return current ~= nil and current ~= "" and current ~= "None"
				end),
				[Children] = {
					Ui.list(scope, "X", 6, "Left", "Center", true),
					surfaceChips,
				},
			},
			scope:New "Frame" {
				Name = "CustomRow",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 4,
				Visible = scope:Computed(function(use)
					return use(customCount) > 0 and use(currentFamily) ~= nil
				end),
				[Children] = {
					Ui.list(scope, "X", 8, "Left", "Center"),
					Ui.text(scope, {
						Name = "CustomNote",
						Text = scope:Computed(function(use)
							local n = use(customCount)
							return `{n} setting{if n == 1 then "" else "s"} changed by hand keep{if n == 1 then "s" else ""} {if n == 1 then "its" else "their"} value over the family.`
						end),
						TextSize = Style.Text.Caption,
						Color = C.TextMuted,
						LayoutOrder = 1,
					}),
					Ui.button(scope, {
						Name = "ClearCustom",
						Text = "Use the family's",
						Icon = "arrow-spin-counter-clockwise",
						Variant = "ghost",
						Size = "sm",
						LayoutOrder = 2,
						OnClick = function()
							store:edit(function(bp)
								bp.style.custom = nil
							end)
						end,
					}),
				},
			},
		},
	})

	----------------------------------------------------------------------
	-- palette
	----------------------------------------------------------------------
	local presetButtons = {}
	for index, preset in Catalog.palettes do
		local state, events = Ui.interact(scope)
		local dots = {}
		for i, key in Catalog.paletteKeys do
			table.insert(dots, Ui.swatch(scope, { Name = key, Color = preset.colors[key], Size = 16, LayoutOrder = i }))
		end
		table.insert(presetButtons, scope:New "TextButton" (Ui.merge(events, {
			Name = "Preset_" .. preset.name,
			Text = "",
			AutoButtonColor = false,
			Size = UDim2.fromOffset(0, 34),
			AutomaticSize = Enum.AutomaticSize.X,
			LayoutOrder = index,
			BackgroundColor3 = scope:Computed(function(use)
				local s = use(state)
				return if s == "hover" or s == "press" then C.RaisedHover else C.Raised
			end),
			[OnEvent "Activated"] = function()
				store:applyPalette(preset.colors)
				store:toast(`Applied the {preset.name} palette.`, "success")
			end,
			[Children] = {
				Ui.corner(scope, Style.Radius.M),
				Ui.stroke(scope, scope:Computed(function(use)
					return if use(state) == "hover" then C.BorderStrong else C.Border
				end)),
				Ui.padding(scope, 0, 10, 0, 8),
				Ui.list(scope, "X", 8, "Left", "Center"),
				scope:New "Frame" {
					Name = "Strip",
					Size = UDim2.fromOffset(0, 16),
					AutomaticSize = Enum.AutomaticSize.X,
					BackgroundTransparency = 1,
					LayoutOrder = 1,
					[Children] = {
						Ui.list(scope, "X", -5, "Left", "Center"),
						dots,
					},
				},
				Ui.text(scope, {
					Text = preset.name,
					TextSize = Style.Text.Small,
					Weight = "Medium",
					Color = C.TextSub,
					LayoutOrder = 2,
				}),
			},
		})))
	end

	local colorRows = {}
	for index, key in Catalog.paletteKeys do
		table.insert(colorRows, Fields.inline(scope, Catalog.paletteLabels[key], Ui.colorField(scope, {
			Name = "Color_" .. key,
			Value = paletteValue(key),
			Palette = paletteList,
			OnChange = function(hex)
				store:setPalette(key, hex)
			end,
		}), index))
	end
	local showExtra = scope:Value(false)
	local extraRows = {}
	for index, key in Catalog.paletteExtraKeys do
		table.insert(extraRows, Fields.inline(scope, Catalog.paletteLabels[key], Ui.colorField(scope, {
			Name = "Color_" .. key,
			Value = paletteValue(key),
			Palette = paletteList,
			OnChange = function(hex)
				store:setPalette(key, hex)
			end,
		}), index))
	end

	local paletteCard = Ui.card(scope, {
		Name = "PaletteCard",
		Title = "Palette",
		Subtitle = "Presets, or set each colour.",
		Icon = "paint-bucket",
		LayoutOrder = 3,
		Accessory = scope:New "Frame" {
			Name = "Current",
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(0, 18),
			AutomaticSize = Enum.AutomaticSize.X,
			[Children] = {
				Ui.list(scope, "X", -4, "Right", "Center"),
				(function()
					local out = {}
					for i, key in Catalog.paletteKeys do
						table.insert(out, Ui.swatch(scope, { Color = paletteValue(key), Size = 16, LayoutOrder = i }))
					end
					return out
				end)(),
			},
		},
		[Children] = {
			scope:New "Frame" {
				Name = "Presets",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 1,
				[Children] = {
					Ui.list(scope, "X", 6, "Left", "Center", true),
					presetButtons,
				},
			},
			Ui.divider(scope, { LayoutOrder = 2 }),
			Ui.column(scope, { Name = "Colors", Gap = 6, LayoutOrder = 3, [Children] = colorRows }),
			Ui.button(scope, {
				Name = "MoreColors",
				Text = scope:Computed(function(use)
					return if use(showExtra) then "Hide status colours" else "Status colours (good / bad / warn)"
				end),
				Icon = scope:Computed(function(use)
					return if use(showExtra) then "chevron-small-up" else "chevron-small-down"
				end),
				Variant = "ghost",
				Size = "sm",
				LayoutOrder = 4,
				OnClick = function()
					showExtra:set(not Fusion.peek(showExtra))
				end,
			}),
			Ui.column(scope, { Name = "ExtraColors", Gap = 6, LayoutOrder = 5, Visible = showExtra, [Children] = extraRows }),
		},
	})

	----------------------------------------------------------------------
	-- typography
	----------------------------------------------------------------------
	local fontRows = {}
	for index, slot in FONT_SLOTS do
		local ref = fontRef(slot.key)
		local family = scope:Computed(function(use)
			local f = Catalog.splitFontRef(use(ref))
			return f
		end)
		local weight = scope:Computed(function(use)
			local f, w = Catalog.splitFontRef(use(ref))
			-- "Black" / "900" / nil -> the name the weight menu uses ("Heavy")
			return Catalog.closestWeight(f, Catalog.weightName(Catalog.weightValue(w) or 400))
		end)
		local weightOptions = scope:Computed(function(use)
			local info = Catalog.fontByFamily[use(family)]
			local out = {}
			for _, value in (info and info.weights) or { 400 } do
				table.insert(out, { value = Catalog.weightName(value), label = Catalog.weightName(value) })
			end
			return out
		end)
		table.insert(fontRows, Ui.field(scope, {
			Name = "Font_" .. slot.key,
			Label = slot.label,
			Accessory = Ui.text(scope, { Text = slot.hint, TextSize = Style.Text.Caption, Color = C.TextMuted, LayoutOrder = 3 }),
			LayoutOrder = index,
			[Children] = Ui.row(scope, {
				Gap = 6,
				Height = Style.Height.Control,
				[Children] = {
					Ui.dropdown(scope, {
						Name = "Family",
						Options = getFontOptions(),
						Value = family,
						Searchable = true,
						MenuWidth = 240,
						Size = UDim2.new(1, -118, 1, 0),
						LayoutOrder = 1,
						OnChange = function(newFamily)
							store:setFont(slot.key, newFamily, Fusion.peek(weight))
							markCustom("fonts")
						end,
					}),
					Ui.dropdown(scope, {
						Name = "Weight",
						Options = weightOptions,
						Value = weight,
						Size = UDim2.new(0, 112, 1, 0),
						LayoutOrder = 2,
						OnChange = function(newWeight)
							store:setFont(slot.key, Fusion.peek(family), newWeight)
							markCustom("fonts")
						end,
					}),
				},
			}),
		}))
	end

	local surfaceHex = paletteValue("surface")
	local primaryHex = paletteValue("primary")
	local textHex = paletteValue("text")
	local displayRef = fontRef("display")
	local bodyRef = fontRef("body")
	local specimen = scope:New "Frame" {
		Name = "Specimen",
		BackgroundColor3 = scope:Computed(function(use)
			return Style.parseHex(use(surfaceHex)) or C.Field
		end),
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		LayoutOrder = 10,
		[Children] = {
			Ui.corner(scope, Style.Radius.M),
			Ui.stroke(scope, C.Border),
			Ui.padding(scope, 12, 14),
			Ui.list(scope, "Y", 4),
			Ui.text(scope, {
				Name = "DisplaySample",
				Text = scope:Computed(function(use)
					use(revision)
					local bp = store.blueprint
					local text = (bp and bp.logo and bp.logo.text) or "NEON STRIKE"
					return text
				end),
				Font = scope:Computed(function(use)
					return fontFor(use(displayRef))
				end),
				TextSize = 26,
				Color = scope:Computed(function(use)
					return Style.parseHex(use(primaryHex)) or C.Text
				end),
				Fill = true,
				Truncate = true,
				LayoutOrder = 1,
			}),
			Ui.text(scope, {
				Name = "BodySample",
				Text = "Buy 40 keys  ·  Daily reward ready  ·  1,250 coins",
				Font = scope:Computed(function(use)
					return fontFor(use(bodyRef))
				end),
				TextSize = 15,
				Color = scope:Computed(function(use)
					return Style.parseHex(use(textHex)) or C.TextSub
				end),
				Fill = true,
				Truncate = true,
				LayoutOrder = 2,
			}),
		},
	}

	local typeCard = Ui.card(scope, {
		Name = "TypeCard",
		Title = "Typography",
		Subtitle = "Roblox built-in fonts. Max two families look best.",
		Icon = "text-t-serif",
		LayoutOrder = 4,
		[Children] = { fontRows, specimen },
	})

	----------------------------------------------------------------------
	-- look & feel grid
	----------------------------------------------------------------------
	local lookControls = {}
	for index, control in Catalog.styleControls do
		table.insert(lookControls, Ui.field(scope, {
			Name = "Look_" .. control.key,
			Label = control.label,
			LayoutOrder = index,
			Size = UDim2.new(0.5, -4, 0, 0),
			[Children] = Ui.dropdown(scope, {
				Name = control.key,
				Options = Catalog.style[control.key],
				Value = styleValue(control.key),
				OnChange = function(value)
					store:setStyle(control.key, value)
					markCustom(control.key)
				end,
			}),
		}))
	end
	local lookCard = Ui.card(scope, {
		Name = "LookCard",
		Title = "Look & feel",
		Subtitle = "Buttons, panels, motion and density.",
		Icon = "two-sliders-horizontal",
		LayoutOrder = 5,
		[Children] = {
			scope:New "Frame" {
				Name = "Grid",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 1,
				[Children] = {
					scope:New "UIListLayout" {
						FillDirection = Enum.FillDirection.Horizontal,
						Wraps = true,
						Padding = UDim.new(0, 8),
						SortOrder = Enum.SortOrder.LayoutOrder,
						HorizontalAlignment = Enum.HorizontalAlignment.Left,
						VerticalAlignment = Enum.VerticalAlignment.Top,
					},
					lookControls,
				},
			},
		},
	})

	----------------------------------------------------------------------
	-- HUD kit (v1.8: blueprint.hud.kit; the generator's choice is meta.kit)
	----------------------------------------------------------------------
	local currentKit = scope:Computed(function(use)
		use(revision)
		return store:hudKit()
	end)
	local autoKit = scope:Computed(function(use)
		use(revision)
		return store:autoKit()
	end)
	local overrides = scope:Computed(function(use)
		use(revision)
		return store:lookOverrides()
	end)
	local kitPalette = {}
	for _, key in Catalog.paletteKeys do
		kitPalette[key] = paletteValue(key)
	end

	-- A tiny game scene with the kit's plate on it: the plate, outline,
	-- corners, number type and signature detail, in your palette's colours.
	local SKY_GRASS = ColorSequence.new({
		ColorSequenceKeypoint.new(0, Color3.fromRGB(96, 156, 226)),
		ColorSequenceKeypoint.new(0.62, Color3.fromRGB(150, 196, 240)),
		ColorSequenceKeypoint.new(0.64, Color3.fromRGB(98, 176, 78)),
		ColorSequenceKeypoint.new(1, Color3.fromRGB(70, 138, 58)),
	})
	local function kitSwatch(inner: any, spec: any): Frame
		spec = spec or {}
		local function color(value: any, fallback: Color3): any
			if typeof(value) ~= "string" then
				return fallback
			end
			local state = kitPalette[value]
			if state == nil then
				return Style.parseHex(value) or fallback
			end
			return inner:Computed(function(use)
				return Style.parseHex(use(state)) or fallback
			end)
		end
		local face = color(spec.face, Color3.fromRGB(28, 32, 44))
		local ink = color(spec.ink, Color3.new(1, 1, 1))
		local accent = color("accent", Color3.fromRGB(255, 196, 0))
		local corner = spec.corner or 0
		local W, H = 54, 24
		local function cornerOf(target: number): any
			return inner:New "UICorner" { CornerRadius = UDim.new(0, target) }
		end
		local details = {}
		local behind = {}
		local detail = spec.detail
		if detail == "bar" then
			table.insert(details, inner:New "Frame" {
				Name = "Bar",
				BackgroundColor3 = accent,
				BorderSizePixel = 0,
				Position = UDim2.fromOffset(3, 4),
				Size = UDim2.new(0, 3, 1, -8),
			})
		elseif detail == "shine" or detail == "gloss" then
			table.insert(details, inner:New "Frame" {
				Name = "Shine",
				BackgroundColor3 = Color3.new(1, 1, 1),
				BackgroundTransparency = if detail == "shine" then 0.45 else 0.7,
				BorderSizePixel = 0,
				Position = UDim2.fromOffset(4, 2),
				Size = UDim2.new(1, -8, 0.38, 0),
				[Children] = cornerOf(math.max(corner - 3, 0)),
			})
		elseif detail == "hairline" then
			table.insert(details, inner:New "Frame" {
				Name = "Hairline",
				BackgroundColor3 = Color3.new(1, 1, 1),
				BackgroundTransparency = 0.25,
				BorderSizePixel = 0,
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(0.5, 0, 1, 0),
				Size = UDim2.new(0, 42, 0, 1),
			})
		elseif detail == "rule" then
			table.insert(details, inner:New "Frame" {
				Name = "Rule",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(3, 3),
				Size = UDim2.new(1, -6, 1, -6),
				[Children] = {
					cornerOf(math.max(corner - 2, 0)),
					inner:New "UIStroke" { Color = color(spec.stroke, Color3.new()), Thickness = 1, Transparency = 0.35 },
				},
			})
		elseif detail == "brackets" then
			for _, c in { { 0, 0 }, { 1, 0 }, { 0, 1 }, { 1, 1 } } do
				local anchor = Vector2.new(c[1], c[2])
				local at = UDim2.new(c[1], if c[1] == 0 then -3 else 3, c[2], if c[2] == 0 then -3 else 3)
				table.insert(details, inner:New "Frame" {
					Name = "BracketH",
					BackgroundColor3 = accent,
					BorderSizePixel = 0,
					AnchorPoint = anchor,
					Position = at,
					Size = UDim2.fromOffset(8, 2),
				})
				table.insert(details, inner:New "Frame" {
					Name = "BracketV",
					BackgroundColor3 = accent,
					BorderSizePixel = 0,
					AnchorPoint = anchor,
					Position = at,
					Size = UDim2.fromOffset(2, 8),
				})
			end
		elseif detail == "notch" then
			-- bites out of the frame's corners, in the scene's sky / grass colour
			for _, c in { { 0, 0 }, { 1, 0 }, { 0, 1 }, { 1, 1 } } do
				local out = spec.strokeWidth or 3
				table.insert(details, inner:New "Frame" {
					Name = "Notch",
					BackgroundColor3 = if c[2] == 0 then Color3.fromRGB(108, 165, 229) else Color3.fromRGB(81, 153, 66),
					BorderSizePixel = 0,
					AnchorPoint = Vector2.new(c[1], c[2]),
					Position = UDim2.new(c[1], if c[1] == 0 then -out else out, c[2], if c[2] == 0 then -out else out),
					Size = UDim2.fromOffset(out, out),
					ZIndex = 4,
				})
			end
			table.insert(behind, inner:New "Frame" {
				Name = "Shadow",
				BackgroundColor3 = Color3.new(),
				BackgroundTransparency = 0.25,
				BorderSizePixel = 0,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 3, 0.5, 3),
				Size = UDim2.fromOffset(W, H),
			})
		elseif detail == "shadow" or detail == "lip" then
			table.insert(behind, inner:New "Frame" {
				Name = "Shadow",
				BackgroundColor3 = if detail == "lip" then color(spec.stroke, Color3.new()) else Color3.new(),
				BackgroundTransparency = if detail == "lip" then 0 else 0.35,
				BorderSizePixel = 0,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, if detail == "lip" then 0 else 3, 0.5, if detail == "lip" then 3 else 4),
				Size = UDim2.fromOffset(W, H),
				Rotation = spec.tilt or 0,
				[Children] = cornerOf(corner),
			})
		elseif detail == "glow" then
			table.insert(behind, inner:New "Frame" {
				Name = "Glow",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.fromOffset(W + 4, H + 4),
				[Children] = {
					cornerOf(corner + 2),
					inner:New "UIStroke" { Color = color(spec.stroke, Color3.new(1, 1, 1)), Thickness = 3, Transparency = 0.6 },
				},
			})
		end
		local isPixel = string.find(spec.font or "", "PressStart2P", 1, true) ~= nil
		return inner:New "Frame" {
			Name = "Scene",
			BackgroundColor3 = Color3.new(1, 1, 1),
			BorderSizePixel = 0,
			Size = UDim2.new(1, 0, 0, 42),
			ClipsDescendants = true,
			LayoutOrder = 1,
			[Children] = {
				Ui.corner(inner, Style.Radius.S),
				inner:New "UIGradient" { Rotation = 90, Color = SKY_GRASS },
				behind,
				inner:New "Frame" {
					Name = "Plate",
					BackgroundColor3 = face,
					BackgroundTransparency = spec.faceAlpha or 0,
					BorderSizePixel = 0,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Size = UDim2.fromOffset(W, H),
					Rotation = spec.tilt or 0,
					ZIndex = 2,
					[Children] = {
						cornerOf(corner),
						if (spec.strokeWidth or 0) > 0
							then inner:New "UIStroke" {
								Color = color(spec.stroke, Color3.new(1, 1, 1)),
								Thickness = spec.strokeWidth,
								Transparency = spec.strokeAlpha or 0,
								ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
							}
							else nil,
						details,
						inner:New "TextLabel" {
							Name = "Sample",
							BackgroundTransparency = 1,
							Size = UDim2.fromScale(1, 1),
							Text = "250",
							FontFace = if spec.font == "display" or spec.font == nil
								then inner:Computed(function(use)
									return fontFor(use(displayRef))
								end)
								else fontFor(spec.font),
							TextSize = if isPixel then 11 else 16,
							TextColor3 = ink,
							ZIndex = 3,
							[Children] = if (spec.inkStrokeWidth or 0) > 0
								then inner:New "UIStroke" {
									Color = color(spec.inkStroke, Color3.new()),
									Thickness = spec.inkStrokeWidth,
									Transparency = spec.inkStrokeAlpha or 0,
								}
								else nil,
						},
					},
				},
			},
		}
	end

	-- spec: the swatch table, or (the Auto tile) a state holding a kit name
	local function kitTile(value: string, label: string, index: number, spec: any, selected: any, follows: boolean?): TextButton
		local state, events = Ui.interact(scope)
		return scope:New "TextButton" (Ui.merge(events, {
			Name = "HudKit_" .. value,
			Text = "",
			AutoButtonColor = false,
			Size = UDim2.fromOffset(82, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			LayoutOrder = index,
			BackgroundColor3 = scope:Computed(function(use)
				if use(selected) then
					return C.AccentSoft
				end
				local s = use(state)
				return if s == "hover" or s == "press" then C.RaisedHover else C.Raised
			end),
			[OnEvent "Activated"] = function()
				store:setHudKit(value)
			end,
			[Children] = {
				Ui.corner(scope, Style.Radius.M),
				Ui.stroke(scope, scope:Computed(function(use)
					if use(selected) then
						return C.Accent
					end
					return if use(state) == "hover" then C.BorderStrong else C.Border
				end)),
				Ui.padding(scope, 4, 4, 5, 4),
				Ui.list(scope, "Y", 4, "Center"),
				-- the Auto tile's scene follows the generator's kit
				if not follows
					then kitSwatch(scope, spec)
					else scope:Computed(function(use, inner)
						local kit = use(spec)
						local info = if kit then Catalog.hudKitByValue[kit] else Catalog.hudKitOriginal
						return kitSwatch(inner, info and info.swatch)
					end),
				Ui.text(scope, {
					Name = "Label",
					Text = label,
					TextSize = Style.Text.Caption,
					Weight = "Medium",
					Fill = true,
					Truncate = true,
					XAlign = Enum.TextXAlignment.Center,
					Color = scope:Computed(function(use)
						return if use(selected) then C.AccentText else C.TextSub
					end),
					LayoutOrder = 2,
				}),
			},
		}))
	end

	local kitTiles = {
		kitTile("Auto", "Auto", 1, autoKit, scope:Computed(function(use)
			return use(currentKit) == use(autoKit)
		end), true),
		kitTile("None", Catalog.hudKitOriginal.label, 2, Catalog.hudKitOriginal.swatch, scope:Computed(function(use)
			return use(currentKit) == nil and use(autoKit) ~= nil
		end)),
	}
	for index, raw in Catalog.hudKits do
		local option = Ui.normalizeOption(raw)
		local info = Catalog.hudKitByValue[option.value]
		table.insert(kitTiles, kitTile(option.value, option.label, index + 2, (info and info.swatch) or {}, scope:Computed(function(use)
			local current = use(currentKit)
			return current == option.value and current ~= use(autoKit)
		end)))
	end

	local kitHint = scope:Computed(function(use)
		local current, auto = use(currentKit), use(autoKit)
		local info = if current then Catalog.hudKitByValue[current] else Catalog.hudKitOriginal
		local name = (info and info.label) or current or "Original"
		local hint = (info and info.hint) or ""
		if current == auto then
			return `<b>Auto: {name}</b>, the generator's pick. {hint}.`
		end
		return `<b>{name}</b>: {hint}.`
	end)

	local hudKitCard = Ui.card(scope, {
		Name = "HudKitCard",
		Title = "HUD kit",
		Subtitle = "Restyles every HUD element at once, in your palette.",
		Icon = "squares-grid-plus",
		LayoutOrder = 2,
		[Children] = {
			scope:New "Frame" {
				Name = "Kits",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 1,
				[Children] = {
					Ui.list(scope, "X", 6, "Left", "Top", true),
					kitTiles,
				},
			},
			Ui.text(scope, {
				Name = "KitHint",
				Text = kitHint,
				RichText = true,
				TextSize = Style.Text.Caption,
				Color = C.TextSub,
				Fill = true,
				Wrap = true,
				LineHeight = 1.15,
				LayoutOrder = 2,
			}),
			scope:New "Frame" {
				Name = "LooksRow",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 3,
				Visible = scope:Computed(function(use)
					return use(overrides) > 0
				end),
				[Children] = {
					Ui.list(scope, "X", 8, "Left", "Center"),
					Ui.text(scope, {
						Name = "LooksNote",
						Text = scope:Computed(function(use)
							local n = use(overrides)
							return `{n} element{if n == 1 then "" else "s"} keep{if n == 1 then "s" else ""} {if n == 1 then "its" else "their"} own look (Structure → Look).`
						end),
						TextSize = Style.Text.Caption,
						Color = C.TextMuted,
						LayoutOrder = 1,
					}),
					Ui.button(scope, {
						Name = "ClearLooks",
						Text = "Use the kit's",
						Icon = "arrow-spin-counter-clockwise",
						Variant = "ghost",
						Size = "sm",
						LayoutOrder = 2,
						OnClick = function()
							store:clearLooks()
						end,
					}),
				},
			},
		},
	})

	----------------------------------------------------------------------
	-- logo
	----------------------------------------------------------------------
	local logoCard = Ui.card(scope, {
		Name = "LogoCard",
		Title = "Logo",
		Subtitle = "Shown on the main menu and loading screen.",
		Icon = "star",
		LayoutOrder = 6,
		[Children] = {
			Fields.inline(scope, "Text", Ui.input(scope, {
				Name = "LogoText",
				Value = logoValue("text"),
				Placeholder = "GAME NAME",
				OnCommit = function(text)
					store:setLogo("text", text)
				end,
			}), 1),
			Fields.inline(scope, "Subtitle", Ui.input(scope, {
				Name = "LogoSubtitle",
				Value = logoValue("subtitle"),
				Placeholder = "SEASON 1",
				OnCommit = function(text)
					store:setLogo("subtitle", if text == "" then nil else text)
				end,
			}), 2),
			Fields.inline(scope, "Type", Ui.dropdown(scope, {
				Name = "LogoType",
				Options = Catalog.logoTypes,
				Value = logoValue("type"),
				OnChange = function(value)
					store:setLogo("type", value)
				end,
			}), 3),
			Fields.inline(scope, "Icon", Ui.iconField(scope, {
				Name = "LogoIcon",
				Value = logoValue("icon"),
				OnChange = function(name)
					store:setLogo("icon", name)
				end,
			}), 4),
			Fields.inline(scope, "Tone", Ui.dropdown(scope, {
				Name = "LogoTone",
				Options = Fields.toneOptions(),
				Value = logoValue("tone"),
				Placeholder = "Default",
				OnChange = function(value)
					store:setLogo("tone", value)
				end,
			}), 5),
			Fields.inline(scope, "Tilt", Ui.numberInput(scope, {
				Name = "LogoTilt",
				Value = logoValue("tilt"),
				Min = -15,
				Max = 15,
				Placeholder = "-4",
				OnCommit = function(number)
					store:setLogo("tilt", number)
				end,
			}), 6),
		},
	})

	----------------------------------------------------------------------
	-- sounds & music (style.sounds, blueprint.music)
	----------------------------------------------------------------------
	local alive = true
	-- Test playback in Studio edit mode: a Sound that never goes into the
	-- place, played with SoundService:PlayLocalSound and destroyed after.
	local testing: { [Sound]: boolean } = {}
	local musicSound: Sound? = nil
	local musicKey = scope:Value(nil :: string?)
	local function dispose(sound: Sound?)
		if sound and testing[sound] then
			testing[sound] = nil
			pcall(function()
				sound:Stop()
				sound:Destroy()
			end)
		end
	end
	local function playTest(id: string, looped: boolean, volume: number): Sound?
		local ok, sound = pcall(function()
			local s = Instance.new("Sound")
			s.Name = "BloxUIBuilderSoundTest"
			s.SoundId = id
			s.Volume = volume
			s.Looped = looped
			return s
		end)
		if not ok then
			return nil
		end
		testing[sound] = true
		if not pcall(function()
			SoundService:PlayLocalSound(sound)
		end) then
			pcall(function()
				sound:Play()
			end)
		end
		return sound
	end
	local function stopMusic()
		dispose(musicSound)
		musicSound = nil
		if alive then
			musicKey:set(nil)
		end
	end
	table.insert(scope, function()
		alive = false
		for sound in table.clone(testing) do
			dispose(sound)
		end
		musicSound = nil
	end)
	-- music never keeps playing once you leave the tab
	table.insert(scope, scope:Observer(store.tab):onChange(function()
		if peek(store.tab) ~= "Design" then
			stopMusic()
		end
	end))

	local function soundValue(slot: string): any
		return scope:Computed(function(use)
			use(revision)
			local sounds = store.blueprint and store.blueprint.style and store.blueprint.style.sounds
			if typeof(sounds) ~= "table" then
				return nil
			end
			for key, value in sounds do
				if typeof(key) == "string" and string.lower(key) == string.lower(slot) then
					return value
				end
			end
			return nil
		end)
	end
	local function musicValue(key: string): any
		return scope:Computed(function(use)
			use(revision)
			local music = store.blueprint and store.blueprint.music
			return if typeof(music) == "table" then music[key] else nil
		end)
	end
	-- The theme's own sound for a slot (what an empty field plays).
	local function themeSound(slot: string): string?
		local ok, value = pcall(function()
			for name, id in app.modules.UI.Theme.get().Sound do
				if string.lower(name) == string.lower(slot) then
					return id
				end
			end
			return nil
		end)
		local id = if ok then Store.parseAssetId(value) else nil
		return if typeof(id) == "string" then id else nil
	end

	-- Label, asset id box, a test button, and a hint under the box.
	local function assetRow(props: { [string]: any }): Frame
		local invalid = scope:Value(false)
		return scope:New "Frame" {
			Name = props.Name,
			BackgroundTransparency = 1,
			Size = UDim2.new(1, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			LayoutOrder = props.Order,
			[Children] = {
				Ui.list(scope, "Y", 4),
				Fields.inline(scope, props.Label, Ui.row(scope, {
					Name = "Controls",
					Gap = 6,
					Size = UDim2.fromScale(1, 1),
					[Children] = {
						Ui.input(scope, {
							Name = "AssetId",
							Value = props.Value,
							Placeholder = props.Placeholder,
							Mono = true,
							Invalid = invalid,
							Size = UDim2.new(1, -38, 1, 0),
							LayoutOrder = 1,
							OnCommit = function(text)
								local id = Store.parseAssetId(text)
								if id == false then
									invalid:set(true)
									store:toast(`No asset id in "{string.sub(Store.trim(text), 1, 40)}". Paste a number, rbxassetid://… or a Creator Store link.`, "warning")
									return
								end
								invalid:set(false)
								if id ~= peek(props.Value) then
									props.OnChange(id)
								end
							end,
						}),
						Ui.iconButton(scope, {
							Name = "Test",
							Icon = props.TestIcon,
							Tooltip = props.TestTooltip,
							Size = 32,
							IconSize = 16,
							Filled = true,
							Active = props.Active,
							LayoutOrder = 2,
							OnClick = props.OnTest,
						}),
					},
				}), 1),
				scope:New "Frame" {
					Name = "HintRow",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, 0, 0, 14),
					LayoutOrder = 2,
					[Children] = Ui.text(scope, {
						Name = "Hint",
						Text = props.Hint,
						TextSize = Style.Text.Caption,
						Color = C.TextMuted,
						Position = UDim2.fromOffset(Fields.LABEL_WIDTH, 0),
						Size = UDim2.new(1, -Fields.LABEL_WIDTH, 1, 0),
						Truncate = true,
					}),
				},
			},
		}
	end

	local showMoreSounds = scope:Value(false)
	local coreRows, moreRows = {}, {}
	for index, slot in Catalog.soundSlots do
		local value = soundValue(slot.key)
		local row = assetRow({
			Name = "Sound_" .. slot.key,
			Label = slot.label,
			Value = value,
			Placeholder = "Theme default",
			Hint = Catalog.searchHint(slot.search),
			Order = index,
			TestIcon = "play-small",
			TestTooltip = `Play {string.lower(slot.label)}`,
			OnChange = function(id)
				store:setSound(slot.key, id)
			end,
			OnTest = function()
				local id = Store.parseAssetId(peek(value))
				if typeof(id) ~= "string" then
					id = themeSound(slot.key)
				end
				if id == nil then
					store:toast(`No {string.lower(slot.label)} sound yet: the theme's is silent. Paste an asset id to try one.`, "info")
					return
				end
				local sound = playTest(id, false, 0.8)
				if sound then
					task.delay(8, function()
						dispose(sound)
					end)
				end
			end,
		})
		table.insert(if slot.core then coreRows else moreRows, row)
	end

	local musicRows = {}
	for index, track in Catalog.musicSlots do
		local value = musicValue(track.key)
		table.insert(musicRows, assetRow({
			Name = "Music_" .. track.key,
			Label = track.label,
			Value = value,
			Placeholder = "No track",
			Hint = track.hint,
			Order = index,
			TestIcon = scope:Computed(function(use)
				return if use(musicKey) == track.key then "stop-small" else "play-small"
			end),
			TestTooltip = `Play / stop the {string.lower(track.label)} track`,
			Active = scope:Computed(function(use)
				return use(musicKey) == track.key
			end),
			OnChange = function(id)
				store:setMusic(track.key, id)
				if peek(musicKey) == track.key then
					stopMusic()
				end
			end,
			OnTest = function()
				if peek(musicKey) == track.key then
					stopMusic()
					return
				end
				stopMusic()
				local id = Store.parseAssetId(peek(value))
				if typeof(id) ~= "string" then
					store:toast(`Paste a {string.lower(track.label)} track id first.`, "info")
					return
				end
				local music = store.blueprint and store.blueprint.music
				local volume = if typeof(music) == "table" and typeof(music.volume) == "number" then music.volume else 0.5
				musicSound = playTest(id, true, math.clamp(volume, 0, 1))
				if musicSound then
					musicKey:set(track.key)
				end
			end,
		}))
	end

	local rawVolume = musicValue("volume")
	local musicVolume = scope:Computed(function(use)
		local volume = use(rawVolume)
		return if typeof(volume) == "number" then math.clamp(volume, 0, 1) else 0.5
	end)
	local rawMuffle = musicValue("muffle")
	local muffle = scope:Computed(function(use)
		return use(rawMuffle) ~= false
	end)
	local customCount = scope:Computed(function(use)
		use(revision)
		local n = 0
		local bp = store.blueprint
		for _ in (bp and bp.style and typeof(bp.style.sounds) == "table" and bp.style.sounds) or {} do
			n += 1
		end
		local music = bp and bp.music
		if typeof(music) == "table" then
			n += (if music.lobby then 1 else 0) + (if music.gameplay then 1 else 0)
		end
		return n
	end)

	local soundsCard = Ui.card(scope, {
		Name = "SoundsCard",
		Title = "Sounds & music",
		Subtitle = "Click, reward and purchase sounds; lobby music.",
		Icon = "speaker",
		LayoutOrder = 7,
		Accessory = Ui.badge(scope, {
			Name = "CustomCount",
			Text = scope:Computed(function(use)
				return `{use(customCount)} set`
			end),
			Tone = "accent",
			Visible = scope:Computed(function(use)
				return use(customCount) > 0
			end),
		}),
		[Children] = {
			scope:New "Frame" {
				Name = "SoundNote",
				BackgroundColor3 = C.InfoSoft,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 1,
				[Children] = {
					Ui.corner(scope, Style.Radius.M),
					Ui.padding(scope, 10, 12),
					Ui.list(scope, "X", 10, "Left", "Top"),
					Ui.icon(scope, { Icon = "circle-i", Size = 16, Color = C.Info, Bold = true, LayoutOrder = 1 }),
					Ui.text(scope, {
						Name = "Text",
						Text = "Use sounds from the Creator Store (<b>Toolbox → Audio</b>). Right-click → <b>Copy Asset ID</b>. Empty fields use the theme's sound.",
						RichText = true,
						TextSize = Style.Text.Caption,
						Color = C.TextSub,
						Wrap = true,
						LineHeight = 1.15,
						Size = UDim2.new(1, -26, 0, 0),
						AutomaticSize = Enum.AutomaticSize.Y,
						LayoutOrder = 2,
					}),
				},
			},
			Ui.overline(scope, { Name = "SoundsLabel", Text = "UI sounds", LayoutOrder = 2 }),
			Ui.column(scope, { Name = "CoreSounds", Gap = 10, LayoutOrder = 3, [Children] = coreRows }),
			Ui.column(scope, { Name = "MoreSounds", Gap = 10, LayoutOrder = 4, Visible = showMoreSounds, [Children] = moreRows }),
			Ui.button(scope, {
				Name = "MoreSoundsButton",
				Text = scope:Computed(function(use)
					return if use(showMoreSounds) then "Fewer sounds" else `More sounds ({#moreRows}): level up, error, coin, hit…`
				end),
				Icon = scope:Computed(function(use)
					return if use(showMoreSounds) then "chevron-small-up" else "chevron-small-down"
				end),
				Variant = "ghost",
				Size = "sm",
				LayoutOrder = 5,
				OnClick = function()
					showMoreSounds:set(not peek(showMoreSounds))
				end,
			}),
			Ui.divider(scope, { LayoutOrder = 6 }),
			Ui.overline(scope, { Name = "MusicLabel", Text = "Music", LayoutOrder = 7 }),
			Ui.column(scope, { Name = "MusicTracks", Gap = 10, LayoutOrder = 8, [Children] = musicRows }),
			Fields.inline(scope, "Volume", Fields.slider(scope, {
				Name = "MusicVolume",
				Value = musicVolume,
				Min = 0,
				Max = 1,
				Step = 0.05,
				Format = function(v)
					return `{math.floor(v * 100 + 0.5)}%`
				end,
				OnCommit = function(v)
					if math.abs(v - peek(musicVolume)) > 1e-3 then
						store:setMusic("volume", v)
					end
					if musicSound then
						pcall(function()
							(musicSound :: Sound).Volume = v
						end)
					end
				end,
			}), 9),
			Fields.inline(scope, "Muffle", scope:New "Frame" {
				Name = "MuffleRow",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				[Children] = {
					Ui.list(scope, "X", 10, "Left", "Center"),
					Ui.switch(scope, {
						Name = "MuffleSwitch",
						Value = muffle,
						LayoutOrder = 1,
						OnChange = function(on)
							store:setMusic("muffle", on)
						end,
					}),
					Ui.text(scope, {
						Text = "Softer music while a window is open",
						TextSize = Style.Text.Small,
						Color = C.TextSub,
						LayoutOrder = 2,
					}),
				},
			}, 10),
		},
	})

	-- stop the music test when the widget closes
	task.defer(function()
		if not alive then
			return
		end
		pcall(function()
			local widget = soundsCard:FindFirstAncestorWhichIsA("PluginGui")
			if widget then
				table.insert(scope, widget:GetPropertyChangedSignal("Enabled"):Connect(function()
					if not widget.Enabled then
						stopMusic()
					end
				end))
			end
		end)
	end)

	local content = Ui.column(scope, {
		Name = "DesignContent",
		Gap = S.L,
		Visible = store.hasBlueprint,
		[Children] = { familyCard, styleCard, hudKitCard, paletteCard, typeCard, lookCard, logoCard, soundsCard },
	})

	local empty = Ui.empty(scope, {
		Name = "DesignEmpty",
		Icon = "paint-brush",
		Title = "Nothing to style yet",
		Body = "Generate a UI in the Create tab (or load a saved one from Export), then fine-tune every colour, font and effect here.",
		Visible = scope:Computed(function(use)
			return not use(store.hasBlueprint)
		end),
		Action = Ui.button(scope, {
			Text = "Go to Create",
			Icon = "cursor-stars",
			Variant = "primary",
			LayoutOrder = 6,
			OnClick = function()
				store.tab:set("Create")
			end,
		}),
	})

	return Ui.scroll(scope, {
		Name = "DesignPage",
		[Children] = { empty, content },
	})
end
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="24">
          <Properties>
            <string name="Name">Export</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Export tab: "Insert into game", "Export as code" + the blueprints saved in
	this place.

	The insert card lists exactly what Insert will add or change (and whether
	each part is new, replaced or already there). After an insert it shows a
	success panel with links that select each inserted instance in the
	Explorer, and warns when you've edited the design since.

	The code card (Blueprint.Integration) previews the checklist of what the
	game still needs (counts, click a group for its items) and inserts the
	<Name>_UIHooks / <Name>_ServerHooks scripts in one undo step. Plugins
	can't copy to the clipboard, so the scripts are selected in the Explorer
	and the UI hooks open in the script editor instead. Inserting again only
	replaces scripts it inserted itself, and asks first when one was edited.
]]

local Selection = game:GetService("Selection")

local Deps = require(script.Parent.Parent.Deps)
local Style = require(script.Parent.Parent.Style)
local Ui = require(script.Parent.Parent.Ui)
local Store = require(script.Parent.Parent.Store)
local Export = require(script.Parent.Parent.Export)

local Fusion = Deps.Fusion
local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local C = Style.Color
local S = Style.Space

local function sizeLabel(bytes: number?): string
	if typeof(bytes) ~= "number" then
		return ""
	end
	if bytes < 1024 then
		return `{bytes} B`
	end
	return string.format("%.1f KB", bytes / 1024)
end

local function ago(time: number?): string
	if typeof(time) ~= "number" then
		return ""
	end
	local seconds = math.max(0, os.time() - time)
	if seconds < 45 then
		return "just now"
	elseif seconds < 3600 then
		return `{math.max(1, math.floor(seconds / 60 + 0.5))} min ago`
	elseif seconds < 86400 then
		return `{math.floor(seconds / 3600 + 0.5)} h ago`
	end
	return `{math.floor(seconds / 86400 + 0.5)} d ago`
end

local function select(instances: { Instance? }): boolean
	local list = {}
	for _, instance in instances do
		if instance and instance.Parent ~= nil then
			table.insert(list, instance)
		end
	end
	if #list == 0 then
		return false
	end
	local ok = pcall(function()
		Selection:Set(list)
	end)
	return ok
end

return function(scope: any, app: any): Instance
	local store = app.store
	local installer = app.installer
	local overlay = app.overlay
	local builder = app.builder

	-- place state isn't reactive: bump this after anything that changes it
	local placeVersion = scope:Value(0)
	local function refresh()
		store.saved:set(Export.list())
		placeVersion:set(peek(placeVersion) + 1)
	end
	app.refreshSaved = refresh
	refresh()

	-- the last insert survives a theme rebuild (kept on the builder)
	local keep = if builder then builder else {}
	local lastInsert = scope:Value(keep.lastInsert)
	table.insert(scope, scope:Observer(lastInsert):onChange(function()
		keep.lastInsert = peek(lastInsert)
	end))

	local nameValue = store.exportName
	local safeName = scope:Computed(function(use)
		return Store.safeName(use(nameValue))
	end)
	-- the saved blueprint with this name is the one this session inserted
	local ours = scope:Computed(function(use)
		local last = use(lastInsert)
		return last ~= nil and last.name == use(safeName)
	end)
	local plan = scope:Computed(function(use)
		use(placeVersion)
		local rows = Export.plan(use(safeName), use(store.addServer))
		for _, row in rows do
			if row.key == "blueprint" and row.status == "Replace" and use(ours) then
				row.status, row.tone = "Update", "neutral"
			end
		end
		return rows
	end)
	local replacing = scope:Computed(function(use)
		for _, row in use(plan) do
			if row.key == "blueprint" then
				return row.status == "Replace" or row.status == "Update"
			end
		end
		return false
	end)

	local function insert()
		if store.blueprint == nil then
			store:toast("Generate or load a UI first.", "warning")
			return
		end
		local name = Store.safeName(peek(store.exportName))
		store:setExportName(name)
		local ok, message, value, details = Export.insert(installer, store, { name = name, server = peek(store.addServer) })
		refresh()
		if ok then
			lastInsert:set({
				name = name,
				time = os.time(),
				revision = peek(store.revision),
				blueprint = store.blueprint,
				value = value,
				details = details or {},
			})
			store:toast(message, "success", {
				action = {
					label = "Select",
					run = function()
						select({ value })
					end,
				},
			})
		else
			store:toast(message, "error")
		end
	end

	----------------------------------------------------------------------
	-- what gets inserted
	----------------------------------------------------------------------
	local planRows = scope:Computed(function(use, inner)
		local out = {}
		for index, row in use(plan) do
			local tone = Ui.tone(row.tone)
			local state, events = Ui.interact(inner)
			local canSelect = row.instance ~= nil
			local rowButton = inner:New "TextButton" (Ui.merge(events, {
				Name = "Plan_" .. row.key,
				Text = "",
				AutoButtonColor = false,
				Size = UDim2.new(1, 0, 0, 40),
				LayoutOrder = index,
				BackgroundColor3 = C.RowHover,
				BackgroundTransparency = inner:Computed(function(use2)
					local s = use2(state)
					return if canSelect and (s == "hover" or s == "press") then 0 else 1
				end),
				[OnEvent "Activated"] = function()
					if canSelect then
						select({ row.instance })
					end
				end,
				[Children] = {
					Ui.corner(inner, Style.Radius.S),
					Ui.padding(inner, 0, 8, 0, 8),
					Ui.list(inner, "X", 10, "Left", "Center"),
					Ui.iconBubble(inner, {
						Icon = row.icon,
						Size = 26,
						Background = C.Raised,
						Color = C.TextSub,
						LayoutOrder = 1,
					}),
					Ui.column(inner, {
						Gap = 0,
						LayoutOrder = 2,
						Size = UDim2.new(0, 0, 0, 0),
						Flex = "Fill",
						[Children] = {
							Ui.text(inner, {
								Text = row.label,
								TextSize = Style.Text.Small,
								Weight = "SemiBold",
								Fill = true,
								Truncate = true,
								AutomaticSize = Enum.AutomaticSize.Y,
								LayoutOrder = 1,
							}),
							Ui.text(inner, {
								Text = row.path,
								Font = Style.Font.Mono,
								TextSize = 11,
								Color = C.TextMuted,
								Fill = true,
								Truncate = true,
								AutomaticSize = Enum.AutomaticSize.Y,
								LayoutOrder = 2,
							}),
						},
					}),
					inner:New "Frame" {
						Name = "Status",
						BackgroundColor3 = tone.bg,
						Size = UDim2.fromOffset(0, 20),
						AutomaticSize = Enum.AutomaticSize.X,
						LayoutOrder = 3,
						[Children] = {
							Ui.corner(inner, Style.Radius.Pill),
							Ui.padding(inner, 0, 8),
							Ui.text(inner, {
								Text = row.status,
								TextSize = Style.Text.Caption,
								Weight = "SemiBold",
								Color = tone.text,
								Size = UDim2.fromScale(0, 1),
								AutomaticSize = Enum.AutomaticSize.X,
							}),
						},
					},
				},
			}))
			if canSelect then
				Ui.tooltip(inner, rowButton, "Select in Explorer")
			end
			table.insert(out, rowButton)
		end
		return out
	end)

	----------------------------------------------------------------------
	-- success panel
	----------------------------------------------------------------------
	local editedSince = scope:Computed(function(use)
		local last = use(lastInsert)
		use(store.revision)
		if last == nil then
			return false
		end
		return store.blueprint ~= last.blueprint or peek(store.revision) ~= last.revision
	end)

	local successLinks = scope:Computed(function(use, inner)
		local last = use(lastInsert)
		use(placeVersion)
		if last == nil then
			return nil
		end
		local details = last.details or {}
		local links = {
			{ key = "value", label = `{Export.FOLDER}.{last.name}`, icon = "file-box", instance = last.value },
			{ key = "loader", label = "BlueprintLoader", icon = "square-code", instance = details.loader },
			{ key = "server", label = "BlueprintServer", icon = "square-code", instance = details.server },
			{ key = "bloxui", label = "BloxUI", icon = "square-books", instance = details.bloxui },
		}
		local out = {}
		for index, link in links do
			if link.instance and link.instance.Parent ~= nil then
				table.insert(out, Ui.chip(inner, {
					Name = "Select_" .. link.key,
					Text = link.label,
					Icon = link.icon,
					Height = 26,
					LayoutOrder = index,
					OnClick = function()
						select({ link.instance })
					end,
				}))
			end
		end
		return out
	end)

	local successPanel = scope:New "Frame" {
		Name = "InsertDone",
		BackgroundColor3 = scope:Computed(function(use)
			return if use(editedSince) then C.WarningSoft else C.SuccessSoft
		end),
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		LayoutOrder = 6,
		Visible = scope:Computed(function(use)
			local last = use(lastInsert)
			return last ~= nil and last.value ~= nil and last.value.Parent ~= nil and use(placeVersion) >= 0
		end),
		[Children] = {
			Ui.corner(scope, Style.Radius.M),
			Ui.stroke(scope, scope:Computed(function(use)
				return if use(editedSince) then C.WarningStroke else C.SuccessStroke
			end)),
			Ui.padding(scope, 12),
			Ui.list(scope, "Y", 8),
			Ui.row(scope, {
				Name = "Head",
				Gap = 8,
				Height = 20,
				LayoutOrder = 1,
				[Children] = {
					Ui.icon(scope, {
						Icon = scope:Computed(function(use)
							return if use(editedSince) then "triangle-exclamation" else "circle-check"
						end),
						Size = 16,
						Bold = true,
						Color = scope:Computed(function(use)
							return if use(editedSince) then C.Warning else C.Success
						end),
						LayoutOrder = 1,
					}),
					Ui.text(scope, {
						Name = "Title",
						Text = scope:Computed(function(use)
							local last = use(lastInsert)
							if last == nil then
								return ""
							end
							if use(editedSince) then
								return `Edited since you inserted {last.name}`
							end
							return `Inserted {last.name}`
						end),
						TextSize = Style.Text.Body,
						Weight = "SemiBold",
						Size = UDim2.new(0, 0, 1, 0),
						Truncate = true,
						LayoutOrder = 2,
						[Children] = Ui.flex(scope, "Fill"),
					}),
					Ui.text(scope, {
						Text = scope:Computed(function(use)
							local last = use(lastInsert)
							use(app.elapsed)
							return if last then ago(last.time) else ""
						end),
						TextSize = Style.Text.Caption,
						Color = C.TextMuted,
						LayoutOrder = 3,
					}),
				},
			}),
			Ui.text(scope, {
				Name = "Next",
				Text = scope:Computed(function(use)
					if use(editedSince) then
						return "Insert again to save your changes into the place (one undo step)."
					end
					return "Press Play (F5) to try it. Click a part to select it in the Explorer."
				end),
				TextSize = Style.Text.Small,
				Color = C.TextSub,
				Fill = true,
				Wrap = true,
				LayoutOrder = 2,
			}),
			scope:New "Frame" {
				Name = "Links",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 3,
				[Children] = {
					Ui.list(scope, "X", 6, "Left", "Center", true),
					successLinks,
				},
			},
		},
	}

	----------------------------------------------------------------------
	-- insert card
	----------------------------------------------------------------------
	local insertCard = Ui.card(scope, {
		Name = "InsertCard",
		Title = "Insert into game",
		Subtitle = "One click, one undo step (Ctrl+Z in Studio).",
		Icon = "arrow-down-to-line",
		LayoutOrder = 1,
		[Children] = {
			Ui.field(scope, {
				Label = "Name",
				Hint = scope:Computed(function(use)
					if use(replacing) and use(ours) then
						return `Updates the {use(safeName)} you inserted.`
					elseif use(replacing) then
						return `Replaces the {use(safeName)} already in this place.`
					end
					return "Letters and digits. Scripts find it by this name."
				end),
				HintColor = scope:Computed(function(use)
					return if use(replacing) and not use(ours) then C.Warning else C.TextMuted
				end),
				LayoutOrder = 1,
				[Children] = Ui.input(scope, {
					Name = "ExportName",
					Value = nameValue,
					Placeholder = "MyGameUI",
					Icon = "pencil",
					OnInput = function()
						placeVersion:set(peek(placeVersion) + 1)
					end,
					OnCommit = function(text)
						store:setExportName(Store.safeName(text))
					end,
				}),
			}),
			Ui.checkbox(scope, {
				Name = "AddServer",
				Label = "Add server script",
				Hint = if Export.hasServerTemplate()
					then "Leaderstats for your currencies, action remotes and purchase receipts (ServerScriptService)."
					else "The BlueprintServer template isn't in this plugin build yet.",
				Value = store.addServer,
				LayoutOrder = 2,
				OnChange = function(on)
					store.addServer:set(on)
				end,
			}),
			Ui.overline(scope, {
				Name = "PlanHeader",
				Text = "What gets inserted",
				LayoutOrder = 3,
			}),
			scope:New "Frame" {
				Name = "Plan",
				BackgroundColor3 = C.Field,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 4,
				[Children] = {
					Ui.corner(scope, Style.Radius.M),
					Ui.stroke(scope, C.Border),
					Ui.padding(scope, 4),
					Ui.list(scope, "Y", 0),
					planRows,
				},
			},
			Ui.button(scope, {
				Name = "Insert",
				Text = scope:Computed(function(use)
					if use(replacing) then
						return `Update {use(safeName)}`
					end
					return "Insert into game"
				end),
				Icon = "arrow-down-to-line",
				Variant = "primary",
				Size = "lg",
				Fill = true,
				LayoutOrder = 5,
				Disabled = scope:Computed(function(use)
					return not use(store.hasBlueprint)
				end),
				OnClick = insert,
			}),
			successPanel,
			Ui.text(scope, {
				Name = "NoBlueprint",
				Text = "Generate a UI in Create (or load one below) to insert it.",
				TextSize = Style.Text.Caption,
				Color = C.TextMuted,
				Fill = true,
				Wrap = true,
				LayoutOrder = 7,
				Visible = scope:Computed(function(use)
					return not use(store.hasBlueprint)
				end),
			}),
		},
	})

	----------------------------------------------------------------------
	-- export as code
	----------------------------------------------------------------------
	local codeAvailable = Export.integration(store) ~= nil
	-- blueprint edits only reach this card while the Export tab is open
	local codeRevision = store:tabCounters(scope, "Export")
	local lastCode: { value: any } = { value = nil }
	local generated = scope:Computed(function(use)
		use(codeRevision)
		local has = use(store.hasBlueprint)
		local name = use(safeName)
		if use(store.tab) ~= "Export" and lastCode.value ~= nil then
			return lastCode.value
		end
		local result
		if not codeAvailable then
			result = { reason = "Export as code isn't in this plugin build." }
		elseif not has or store.blueprint == nil then
			result = { reason = "Generate a UI in Create (or load one below) to export its code." }
		else
			local out, reason = Export.generateHooks(store, name)
			result = { out = out, reason = reason }
		end
		lastCode.value = result
		return result
	end)
	local hasCode = scope:Computed(function(use)
		return use(generated).out ~= nil
	end)

	-- the last Insert scripts (kept on the builder across theme rebuilds)
	local lastHooks = scope:Value(keep.lastHooks)
	table.insert(scope, scope:Observer(lastHooks):onChange(function()
		keep.lastHooks = peek(lastHooks)
	end))

	local hooksPlan = scope:Computed(function(use)
		use(placeVersion)
		use(generated)
		return Export.hooksPlan(store, use(safeName))
	end)

	local function hooksInsert()
		local name = Store.safeName(peek(store.exportName))
		store:setExportName(name)
		local ok, message, details = Export.insertHooks(installer, store, { name = name })
		refresh()
		if not ok or details == nil then
			store:toast(message, "error")
			return
		end
		lastHooks:set({
			name = name,
			time = os.time(),
			revision = peek(store.revision),
			blueprint = store.blueprint,
			client = details.client,
			server = details.server,
		})
		local opened = Export.openScript(app.plugin, details.client)
		store:toast(message .. (if opened then " The UI hooks are open in the editor." else " They're selected in the Explorer."), "success", {
			action = {
				label = "Open server",
				run = function()
					if not Export.openScript(app.plugin, details.server) then
						select({ details.server })
					end
				end,
			},
		})
	end

	local function insertScripts()
		if store.blueprint == nil then
			store:toast("Generate or load a UI first.", "warning")
			return
		end
		local name = Store.safeName(peek(store.exportName))
		local edited = Export.editedHooks(store, name)
		if #edited == 0 then
			hooksInsert()
			return
		end
		refresh() -- the plan shows which one was edited
		local names = {}
		for _, script in edited do
			table.insert(names, script.Name)
		end
		overlay:confirm({
			title = "Replace your edited scripts?",
			body = `You changed {table.concat(names, " and ")} since it was inserted. Inserting again replaces it with fresh stubs (Ctrl+Z brings yours back). To keep yours, delete its BloxUIHooks attribute first.`,
			confirm = "Replace",
			danger = true,
			onConfirm = hooksInsert,
		})
	end

	local expandedGroups = scope:Value({})
	local SEVERITY_TONE = { error = "danger", warning = "warning", info = "info" }

	local function checkGroup(inner: any, group: any, index: number, isOpen: boolean): Instance
		local tone = SEVERITY_TONE[group.severity] or "neutral"
		local colors = Ui.tone(tone)
		local state, events = Ui.interact(inner)
		local header = inner:New "TextButton" (Ui.merge(events, {
			Name = "Header",
			Text = "",
			AutoButtonColor = false,
			Size = UDim2.new(1, 0, 0, 34),
			BackgroundColor3 = C.RowHover,
			BackgroundTransparency = inner:Computed(function(use)
				local s = use(state)
				return if s == "hover" or s == "press" then 0 else 1
			end),
			LayoutOrder = 1,
			[OnEvent "Activated"] = function()
				local now = table.clone(peek(expandedGroups))
				now[group.id] = if now[group.id] then nil else true
				expandedGroups:set(now)
			end,
			[Children] = {
				Ui.corner(inner, Style.Radius.S),
				Ui.padding(inner, 0, 8, 0, 8),
				Ui.list(inner, "X", 8, "Left", "Center"),
				Ui.icon(inner, {
					Icon = if group.severity == "info" then "circle-i" else "triangle-exclamation",
					Size = 14,
					Bold = true,
					Color = colors.text,
					LayoutOrder = 1,
				}),
				Ui.text(inner, {
					Name = "Title",
					Text = group.title,
					TextSize = Style.Text.Small,
					Weight = "SemiBold",
					Size = UDim2.new(0, 0, 1, 0),
					Truncate = true,
					LayoutOrder = 2,
					[Children] = Ui.flex(inner, "Fill"),
				}),
				Ui.badge(inner, {
					Name = "Count",
					Text = tostring(#group.items),
					Tone = tone,
					Height = 18,
					LayoutOrder = 3,
				}),
				Ui.icon(inner, {
					Icon = if isOpen then "chevron-small-down" else "chevron-small-right",
					Size = 14,
					Color = C.TextMuted,
					LayoutOrder = 4,
				}),
			},
		}))
		local children: { any } = { Ui.list(inner, "Y", 0), header }
		if isOpen then
			local lines = {}
			if group.hint then
				table.insert(lines, Ui.text(inner, {
					Name = "Hint",
					Text = group.hint,
					TextSize = Style.Text.Caption,
					Color = C.TextMuted,
					Fill = true,
					Wrap = true,
					LayoutOrder = 1,
				}))
			end
			local MAX = 20
			if group.compact then
				local texts = {}
				for _, item in group.items do
					table.insert(texts, item.text)
				end
				table.insert(lines, Ui.text(inner, {
					Name = "Item_1",
					Text = table.concat(texts, ", "),
					TextSize = Style.Text.Caption,
					Color = C.TextSub,
					Fill = true,
					Wrap = true,
					LayoutOrder = 2,
				}))
			else
				for i, item in group.items do
					if i > MAX then
						table.insert(lines, Ui.text(inner, {
							Name = "More",
							Text = `... and {#group.items - MAX} more (the scripts' headers list them all)`,
							TextSize = Style.Text.Caption,
							Color = C.TextMuted,
							Fill = true,
							Wrap = true,
							LayoutOrder = 1 + i,
						}))
						break
					end
					table.insert(lines, Ui.text(inner, {
						Name = "Item_" .. i,
						Text = "•  " .. item.text,
						TextSize = Style.Text.Caption,
						Color = C.TextSub,
						Fill = true,
						Wrap = true,
						LineHeight = 1.1,
						LayoutOrder = 1 + i,
					}))
				end
			end
			table.insert(children, inner:New "Frame" {
				Name = "Items",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 2,
				[Children] = {
					Ui.padding(inner, 2, 8, 8, 30),
					Ui.list(inner, "Y", 5),
					lines,
				},
			})
		end
		return inner:New "Frame" {
			Name = "Check_" .. group.id,
			BackgroundTransparency = 1,
			Size = UDim2.new(1, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			LayoutOrder = index,
			[Children] = children,
		}
	end

	local checklistRows = scope:Computed(function(use, inner)
		local out = use(generated).out
		local open = use(expandedGroups)
		local rows = {}
		if out == nil then
			return rows
		end
		if #out.checklist.groups == 0 then
			table.insert(rows, Ui.text(inner, {
				Name = "ChecklistClear",
				Text = "Nothing left to do: every id, grant and setting is in place.",
				TextSize = Style.Text.Small,
				Color = C.TextSub,
				Fill = true,
				Wrap = true,
			}))
			return rows
		end
		for index, group in out.checklist.groups do
			table.insert(rows, checkGroup(inner, group, index, open[group.id] == true))
		end
		return rows
	end)

	local checklistBadges = scope:Computed(function(use, inner)
		local out = use(generated).out
		if out == nil then
			return nil
		end
		local checklist = out.checklist
		if checklist.todo == 0 then
			return Ui.badge(inner, { Name = "Todo", Text = "Nothing to do", Tone = "success", Height = 18, LayoutOrder = 1 })
		end
		return {
			Ui.badge(inner, { Name = "Todo", Text = `{checklist.todo} to do`, Tone = "warning", Height = 18, LayoutOrder = 1 }),
			if checklist.notes > 0
				then Ui.badge(inner, { Name = "Notes", Text = `{checklist.notes} note{if checklist.notes == 1 then "" else "s"}`, Height = 18, LayoutOrder = 2 })
				else nil,
		}
	end)

	local hooksPlanRows = scope:Computed(function(use, inner)
		local out = use(generated).out
		local rows = {}
		for index, row in use(hooksPlan) do
			local tone = Ui.tone(row.tone)
			local count = if out then (if row.key == "client" then out.stats.client else out.stats.server) else 0
			local state, events = Ui.interact(inner)
			local canSelect = row.instance ~= nil
			local rowButton = inner:New "TextButton" (Ui.merge(events, {
				Name = "HooksPlan_" .. row.key,
				Text = "",
				AutoButtonColor = false,
				Size = UDim2.new(1, 0, 0, 40),
				LayoutOrder = index,
				BackgroundColor3 = C.RowHover,
				BackgroundTransparency = inner:Computed(function(use2)
					local s = use2(state)
					return if canSelect and (s == "hover" or s == "press") then 0 else 1
				end),
				[OnEvent "Activated"] = function()
					if canSelect then
						select({ row.instance })
					end
				end,
				[Children] = {
					Ui.corner(inner, Style.Radius.S),
					Ui.padding(inner, 0, 8, 0, 8),
					Ui.list(inner, "X", 10, "Left", "Center"),
					Ui.iconBubble(inner, {
						Icon = row.icon,
						Size = 26,
						Background = C.Raised,
						Color = C.TextSub,
						LayoutOrder = 1,
					}),
					Ui.column(inner, {
						Gap = 0,
						LayoutOrder = 2,
						Size = UDim2.new(0, 0, 0, 0),
						Flex = "Fill",
						[Children] = {
							Ui.text(inner, {
								Text = `{row.label}  ·  {count} handler{if count == 1 then "" else "s"}`,
								TextSize = Style.Text.Small,
								Weight = "SemiBold",
								Fill = true,
								Truncate = true,
								AutomaticSize = Enum.AutomaticSize.Y,
								LayoutOrder = 1,
							}),
							Ui.text(inner, {
								Text = row.path,
								Font = Style.Font.Mono,
								TextSize = 11,
								Color = C.TextMuted,
								Fill = true,
								Truncate = true,
								AutomaticSize = Enum.AutomaticSize.Y,
								LayoutOrder = 2,
							}),
						},
					}),
					inner:New "Frame" {
						Name = "Status",
						BackgroundColor3 = tone.bg,
						Size = UDim2.fromOffset(0, 20),
						AutomaticSize = Enum.AutomaticSize.X,
						LayoutOrder = 3,
						[Children] = {
							Ui.corner(inner, Style.Radius.Pill),
							Ui.padding(inner, 0, 8),
							Ui.text(inner, {
								Text = row.status,
								TextSize = Style.Text.Caption,
								Weight = "SemiBold",
								Color = tone.text,
								Size = UDim2.fromScale(0, 1),
								AutomaticSize = Enum.AutomaticSize.X,
							}),
						},
					},
				},
			}))
			if canSelect then
				Ui.tooltip(inner, rowButton, "Select in Explorer")
			end
			table.insert(rows, rowButton)
		end
		return rows
	end)

	local hooksStale = scope:Computed(function(use)
		local last = use(lastHooks)
		use(store.revision)
		if last == nil then
			return false
		end
		return store.blueprint ~= last.blueprint or peek(store.revision) ~= last.revision
	end)

	local hooksDone = scope:New "Frame" {
		Name = "HooksDone",
		BackgroundColor3 = scope:Computed(function(use)
			return if use(hooksStale) then C.WarningSoft else C.SuccessSoft
		end),
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		LayoutOrder = 7,
		Visible = scope:Computed(function(use)
			local last = use(lastHooks)
			use(placeVersion)
			return last ~= nil and last.client ~= nil and last.client.Parent ~= nil
		end),
		[Children] = {
			Ui.corner(scope, Style.Radius.M),
			Ui.stroke(scope, scope:Computed(function(use)
				return if use(hooksStale) then C.WarningStroke else C.SuccessStroke
			end)),
			Ui.padding(scope, 12),
			Ui.list(scope, "Y", 8),
			Ui.row(scope, {
				Name = "Head",
				Gap = 8,
				Height = 20,
				LayoutOrder = 1,
				[Children] = {
					Ui.icon(scope, {
						Icon = scope:Computed(function(use)
							return if use(hooksStale) then "triangle-exclamation" else "circle-check"
						end),
						Size = 16,
						Bold = true,
						Color = scope:Computed(function(use)
							return if use(hooksStale) then C.Warning else C.Success
						end),
						LayoutOrder = 1,
					}),
					Ui.text(scope, {
						Name = "Title",
						Text = scope:Computed(function(use)
							local last = use(lastHooks)
							if last == nil then
								return ""
							end
							return if use(hooksStale) then "Edited since the scripts went in" else "Scripts inserted"
						end),
						TextSize = Style.Text.Body,
						Weight = "SemiBold",
						Size = UDim2.new(0, 0, 1, 0),
						Truncate = true,
						LayoutOrder = 2,
						[Children] = Ui.flex(scope, "Fill"),
					}),
					Ui.text(scope, {
						Text = scope:Computed(function(use)
							local last = use(lastHooks)
							use(app.elapsed)
							return if last then ago(last.time) else ""
						end),
						TextSize = Style.Text.Caption,
						Color = C.TextMuted,
						LayoutOrder = 3,
					}),
				},
			}),
			Ui.text(scope, {
				Name = "Next",
				Text = scope:Computed(function(use)
					if use(hooksStale) then
						return "Update the scripts for stubs that match the new design (it asks before replacing one you edited)."
					end
					return "Fill in the TODOs and the handlers you need. Every script change can be undone with Ctrl+Z."
				end),
				TextSize = Style.Text.Small,
				Color = C.TextSub,
				Fill = true,
				Wrap = true,
				LayoutOrder = 2,
			}),
			scope:New "Frame" {
				Name = "Links",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 3,
				[Children] = {
					Ui.list(scope, "X", 6, "Left", "Center", true),
					Ui.chip(scope, {
						Name = "HooksSelect",
						Text = "Select both",
						Icon = "cursor",
						Height = 26,
						LayoutOrder = 1,
						OnClick = function()
							local last = peek(lastHooks)
							if last then
								select({ last.client, last.server })
							end
						end,
					}),
					Ui.chip(scope, {
						Name = "HooksOpen_client",
						Text = "Open UI hooks",
						Icon = "square-code",
						Height = 26,
						LayoutOrder = 2,
						OnClick = function()
							local last = peek(lastHooks)
							if last and not Export.openScript(app.plugin, last.client) then
								select({ last.client })
							end
						end,
					}),
					Ui.chip(scope, {
						Name = "HooksOpen_server",
						Text = "Open server hooks",
						Icon = "square-code",
						Height = 26,
						LayoutOrder = 3,
						OnClick = function()
							local last = peek(lastHooks)
							if last and not Export.openScript(app.plugin, last.server) then
								select({ last.server })
							end
						end,
					}),
				},
			},
		},
	}

	local codeCard = Ui.card(scope, {
		Name = "CodeCard",
		Title = "Export as code",
		Subtitle = "Hook scripts with a handler for everything this UI needs.",
		Icon = "square-code",
		LayoutOrder = 2,
		[Children] = {
			Ui.text(scope, {
				Name = "CodeReason",
				Text = scope:Computed(function(use)
					return use(generated).reason or ""
				end),
				TextSize = Style.Text.Caption,
				Color = C.TextMuted,
				Fill = true,
				Wrap = true,
				LayoutOrder = 1,
				Visible = scope:Computed(function(use)
					return not use(hasCode)
				end),
			}),
			Ui.overline(scope, {
				Name = "ChecklistHeader",
				Text = "Checklist",
				LayoutOrder = 2,
				Accessory = checklistBadges,
				Visible = hasCode,
			}),
			scope:New "Frame" {
				Name = "Checklist",
				BackgroundColor3 = C.Field,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 3,
				Visible = hasCode,
				[Children] = {
					Ui.corner(scope, Style.Radius.M),
					Ui.stroke(scope, C.Border),
					Ui.padding(scope, 4),
					Ui.list(scope, "Y", 0),
					checklistRows,
				},
			},
			Ui.overline(scope, {
				Name = "HooksHeader",
				Text = "Scripts",
				LayoutOrder = 4,
				Visible = hasCode,
			}),
			scope:New "Frame" {
				Name = "HooksPlan",
				BackgroundColor3 = C.Field,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 5,
				Visible = hasCode,
				[Children] = {
					Ui.corner(scope, Style.Radius.M),
					Ui.stroke(scope, C.Border),
					Ui.padding(scope, 4),
					Ui.list(scope, "Y", 0),
					hooksPlanRows,
				},
			},
			Ui.button(scope, {
				Name = "InsertHooks",
				Text = scope:Computed(function(use)
					for _, row in use(hooksPlan) do
						if row.status ~= "New" then
							return "Update scripts"
						end
					end
					return "Insert scripts"
				end),
				Icon = "square-code",
				Variant = "secondary",
				Size = "lg",
				Fill = true,
				LayoutOrder = 6,
				Disabled = scope:Computed(function(use)
					return not use(hasCode)
				end),
				OnClick = insertScripts,
			}),
			hooksDone,
			Ui.text(scope, {
				Name = "CodeHint",
				Text = "Named after the UI above, one undo step. The loader and server scripts stay as they are; these only add handlers, and inserting again never touches a script of yours.",
				TextSize = Style.Text.Caption,
				Color = C.TextMuted,
				Fill = true,
				Wrap = true,
				LayoutOrder = 8,
				Visible = hasCode,
			}),
		},
	})

	----------------------------------------------------------------------
	-- saved in this place
	----------------------------------------------------------------------
	local savedRows = scope:Computed(function(use, inner)
		local out = {}
		local current = use(safeName)
		for index, entry in use(store.saved) do
			local instance = entry.instance
			local isCurrent = entry.name == current and use(store.hasBlueprint)
			table.insert(out, inner:New "Frame" {
				Name = "Saved_" .. entry.name,
				BackgroundColor3 = C.Field,
				Size = UDim2.new(1, 0, 0, 52),
				LayoutOrder = index,
				[Children] = {
					Ui.corner(inner, Style.Radius.M),
					Ui.stroke(inner, if isCurrent then C.Accent else C.Border, 1, if isCurrent then 0.4 else 0),
					Ui.padding(inner, 0, 6, 0, 12),
					Ui.list(inner, "X", 8, "Left", "Center"),
					Ui.switch(inner, {
						Name = "Enabled",
						Value = entry.enabled,
						LayoutOrder = 1,
						OnChange = function(on)
							Export.setEnabled(installer, instance, on)
							refresh()
							store:toast(`{entry.name} {if on then "enabled: it loads in game" else "disabled: it won't load in game"}.`, "info")
						end,
					}),
					Ui.column(inner, {
						Gap = 1,
						LayoutOrder = 2,
						Size = UDim2.new(0, 0, 0, 0),
						Flex = "Fill",
						[Children] = {
							Ui.row(inner, {
								Gap = 6,
								LayoutOrder = 1,
								[Children] = {
									Ui.text(inner, {
										Text = entry.name,
										Weight = "SemiBold",
										TextSize = Style.Text.Small,
										Color = if entry.enabled then C.Text else C.TextMuted,
										Truncate = true,
										LayoutOrder = 1,
										Size = UDim2.new(0, 0, 0, 18),
										AutomaticSize = Enum.AutomaticSize.X,
										[Children] = inner:New "UISizeConstraint" { MaxSize = Vector2.new(150, math.huge) },
									}),
									if isCurrent and not app.compactMode()
										then Ui.badge(inner, { Text = "Editing", Tone = "accent", Height = 18, LayoutOrder = 2 })
										else nil,
								},
							}),
							Ui.text(inner, {
								Text = `{entry.title}{if entry.genre ~= "" then "  ·  " .. entry.genre else ""}  ·  {sizeLabel(entry.size)}{if entry.updated then "  ·  " .. ago(entry.updated) else ""}`,
								TextSize = Style.Text.Caption,
								Color = C.TextMuted,
								Fill = true,
								Truncate = true,
								AutomaticSize = Enum.AutomaticSize.Y,
								LayoutOrder = 2,
							}),
						},
					}),
					Ui.iconButton(inner, {
						Name = "Select",
						Icon = "cursor",
						Tooltip = "Select in Explorer",
						LayoutOrder = 3,
						OnClick = function()
							select({ instance })
						end,
					}),
					Ui.button(inner, {
						Name = "Load",
						Text = if app.compactMode() then "" else "Load",
						Icon = "pencil",
						Variant = "soft",
						Size = "sm",
						LayoutOrder = 4,
						Tooltip = "Edit this blueprint in the builder",
						OnClick = function()
							local bp = Export.load(store, instance)
							if bp == nil then
								store:toast(`{entry.name} isn't valid JSON.`, "error")
								return
							end
							store:setBlueprint(bp, { history = false, exportName = entry.name })
							store.tab:set("Design")
							store:toast(`Loaded {entry.name}. Insert again to save your changes.`, "success")
						end,
					}),
					Ui.iconButton(inner, {
						Name = "Delete",
						Icon = "trash-can",
						Tooltip = "Delete from this place",
						Tone = "danger",
						LayoutOrder = 5,
						OnClick = function()
							overlay:confirm({
								title = `Delete {entry.name}?`,
								body = "Removes the blueprint from ReplicatedStorage. You can undo with Ctrl+Z.",
								confirm = "Delete",
								danger = true,
								onConfirm = function()
									Export.delete(installer, instance)
									refresh()
									store:toast(`Deleted {entry.name}.`, "info")
								end,
							})
						end,
					}),
				},
			})
		end
		if #out == 0 then
			table.insert(out, Ui.text(inner, {
				Name = "NothingSaved",
				Text = "Nothing saved in this place yet. Insert puts your UI here, and you can load it back to keep editing.",
				TextSize = Style.Text.Small,
				Color = C.TextMuted,
				Fill = true,
				Wrap = true,
			}))
		end
		return out
	end)

	local savedCard = Ui.card(scope, {
		Name = "SavedCard",
		Title = "Saved in this place",
		Subtitle = scope:Computed(function(use)
			local n = #use(store.saved)
			if n == 0 then
				return `ReplicatedStorage.{Export.FOLDER}`
			end
			local enabled = 0
			for _, entry in use(store.saved) do
				if entry.enabled then
					enabled += 1
				end
			end
			return `{n} blueprint{if n == 1 then "" else "s"} · {enabled} load in game`
		end),
		Icon = "folder",
		LayoutOrder = 3,
		Accessory = Ui.iconButton(scope, {
			Name = "Refresh",
			Icon = "arrow-rotate-right",
			Tooltip = "Refresh",
			OnClick = refresh,
		}),
		[Children] = {
			Ui.column(scope, {
				Name = "SavedList",
				Gap = 6,
				LayoutOrder = 1,
				[Children] = savedRows,
			}),
		},
	})

	local tipCard = Ui.card(scope, {
		Name = "TipCard",
		Title = "Using it in your game",
		Subtitle = "Press Play: the loader builds it.",
		Icon = "code",
		LayoutOrder = 4,
		[Children] = {
			scope:New "Frame" {
				Name = "Code",
				BackgroundColor3 = C.Field,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 1,
				[Children] = {
					Ui.corner(scope, Style.Radius.M),
					Ui.stroke(scope, C.Border),
					Ui.padding(scope, 10, 12),
					Ui.text(scope, {
						Text = scope:Computed(function(use)
							local name = use(safeName)
							return `local ui = UI.Blueprint.waitFor("{name}")\nui:on("Rebirth", function() ... end)\nui:open("shop")`
						end),
						Font = Style.Font.Mono,
						TextSize = Style.Text.Caption,
						Color = C.TextSub,
						LineHeight = 1.25,
						Fill = true,
						Wrap = true,
					}),
				},
			},
			Ui.text(scope, {
				Text = "Buttons with Event actions fire ui:on handlers in a LocalScript; Remote actions go to the server script. Export as code writes both for this UI.",
				TextSize = Style.Text.Caption,
				Color = C.TextMuted,
				Fill = true,
				Wrap = true,
				LayoutOrder = 2,
			}),
		},
	})

	return Ui.scroll(scope, {
		Name = "ExportPage",
		Gap = S.M,
		[Children] = { insertCard, codeCard, savedCard, tipCard },
	})
end
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="25">
          <Properties>
            <string name="Name">Settings</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Settings tab: the BloxUI account (a licence key, Paid Trial or Premium;
	early testers can sign in with Roblox; plan, today's quota, devices, the
	trial's upgrade link, sign out), generation mode / model / quality,
	appearance (follow Studio / Dark / Light) and preview options, a keyboard
	shortcut reference, Advanced (the server address) and a status panel for
	this plugin build (with "What's new": the release notes).

	Signing in never touches the place: the token lives in plugin settings on
	this computer (Remote.luau), and a licence key is only held by its text
	box until Activate sends it.
]]

local Deps = require(script.Parent.Parent.Deps)
local Style = require(script.Parent.Parent.Style)
local Ui = require(script.Parent.Parent.Ui)
local Store = require(script.Parent.Parent.Store)
local Export = require(script.Parent.Parent.Export)
local Remote = require(script.Parent.Parent.Remote)

local Fusion = Deps.Fusion
local Children = Fusion.Children
local peek = Fusion.peek

local C = Style.Color
local S = Style.Space

local SHORTCUTS = {
	{ keys = "Enter", text = "Generate (More options open: the brief first)" },
	{ keys = "Ctrl+Enter", text = "Generate" },
	{ keys = "Ctrl+Z  ·  Ctrl+Y", text = "Undo / redo your edits" },
	{ keys = "Ctrl+1–4  ·  Ctrl+,", text = "Switch tab  ·  Settings" },
	{ keys = "/", text = "Focus the prompt or the Structure filter" },
	{ keys = "↑ ↓  ← →", text = "Select and open Structure rows" },
	{ keys = "H  ·  Ctrl+D  ·  Del", text = "Hide, duplicate, delete a row" },
	{ keys = "Alt+↑ ↓", text = "Move a row" },
	{ keys = "Esc", text = "Close a menu or dialog" },
}

local function statusTone(tone: string?): { icon: string, color: Color3, bg: Color3 }
	if tone == "success" then
		return { icon = "circle-check", color = C.Success, bg = C.SuccessSoft }
	elseif tone == "error" then
		return { icon = "circle-x", color = C.Danger, bg = C.DangerSoft }
	elseif tone == "warning" then
		return { icon = "triangle-exclamation", color = C.Warning, bg = C.WarningSoft }
	elseif tone == "busy" then
		return { icon = "arrow-spin-clockwise", color = C.AccentText, bg = C.AccentSoft }
	end
	return { icon = "circle-i", color = C.Info, bg = C.InfoSoft }
end

local function statusRow(scope: any, label: string, ok: boolean, detail: string?, order: number, accessory: Instance?): Frame
	return Ui.row(scope, {
		Name = "Status_" .. label,
		Gap = 8,
		Height = if accessory then 26 else 22,
		LayoutOrder = order,
		[Children] = {
			Ui.icon(scope, {
				Icon = if ok then "circle-check" else "triangle-exclamation",
				Size = 14,
				Bold = true,
				Color = if ok then C.Success else C.Warning,
				LayoutOrder = 1,
			}),
			Ui.text(scope, { Text = label, TextSize = Style.Text.Small, Color = C.Text, LayoutOrder = 2 }),
			Ui.spacer(scope, 3),
			Ui.text(scope, {
				Name = "Detail",
				Text = detail or (if ok then "Ready" else "Missing"),
				TextSize = Style.Text.Caption,
				Color = if ok then C.TextMuted else C.Warning,
				LayoutOrder = 4,
			}),
			accessory,
		},
	})
end

-- Label + description on the left, a control on the right.
local function settingRow(scope: any, props: { [string]: any }): Frame
	return Ui.row(scope, {
		Name = props.Name,
		Gap = S.M,
		LayoutOrder = props.LayoutOrder,
		[Children] = {
			Ui.column(scope, {
				Gap = 2,
				Size = UDim2.new(1, -(props.ControlWidth + S.M), 0, 0),
				LayoutOrder = 1,
				[Children] = {
					Ui.text(scope, { Text = props.Title, TextSize = Style.Text.Body, Weight = "Medium", Fill = true, Wrap = true, LayoutOrder = 1 }),
					if props.Body
						then Ui.text(scope, {
							Text = props.Body,
							TextSize = Style.Text.Caption,
							Color = C.TextMuted,
							Fill = true,
							Wrap = true,
							LayoutOrder = 2,
						})
						else nil,
				},
			}),
			props.Control,
		},
	})
end

-- A coloured message panel (icon + text) for a { tone, text } state.
local function messagePanel(scope: any, props: { [string]: any }): any
	return scope:Computed(function(use, inner)
		local status = use(props.Value)
		if status == nil or status.text == nil or status.text == "" then
			return nil
		end
		local tone = statusTone(status.tone)
		return inner:New "Frame" {
			Name = props.Name,
			BackgroundColor3 = tone.bg,
			Size = UDim2.new(1, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			LayoutOrder = props.LayoutOrder,
			[Children] = {
				Ui.corner(inner, Style.Radius.M),
				Ui.padding(inner, 9, 12),
				Ui.list(inner, "X", 8, "Left", "Top"),
				if status.tone == "busy"
					then Ui.spinner(inner, { Size = 15, Color = tone.color, LayoutOrder = 1 })
					else Ui.icon(inner, { Icon = tone.icon, Size = 15, Color = tone.color, Bold = true, LayoutOrder = 1 }),
				Ui.text(inner, {
					Name = "Message",
					Text = status.text,
					TextSize = Style.Text.Small,
					Weight = "Medium",
					Color = if status.tone == "info" or status.tone == "busy" then C.Text else tone.color,
					Wrap = true,
					LineHeight = 1.1,
					Size = UDim2.new(1, -24, 0, 0),
					AutomaticSize = Enum.AutomaticSize.Y,
					LayoutOrder = 2,
				}),
			},
		}
	end)
end

-- One small stat (Plan / AI today / Devices) in the signed-in summary.
local function statTile(scope: any, props: { [string]: any }): Frame
	return scope:New "Frame" {
		Name = props.Name,
		BackgroundColor3 = C.Raised,
		Size = UDim2.new(0, 0, 0, 54),
		LayoutOrder = props.LayoutOrder,
		[Children] = {
			Ui.flex(scope, "Fill"),
			Ui.corner(scope, Style.Radius.M),
			Ui.padding(scope, 8, 10),
			Ui.list(scope, "Y", 2, "Left", "Center"),
			Ui.row(scope, {
				Name = "Top",
				Gap = 5,
				Height = 14,
				LayoutOrder = 1,
				[Children] = {
					Ui.icon(scope, { Icon = props.Icon, Size = 12, Color = C.TextMuted, LayoutOrder = 1 }),
					Ui.text(scope, { Name = "Label", Text = props.Label, TextSize = 11, Weight = "Medium", Color = C.TextMuted, LayoutOrder = 2 }),
				},
			}),
			Ui.text(scope, {
				Name = "Value",
				Text = props.Value,
				TextSize = Style.Text.Title,
				Weight = "SemiBold",
				Color = props.Color or C.Text,
				Size = UDim2.new(1, 0, 0, 20),
				Truncate = true,
				LayoutOrder = 2,
			}),
		},
	}
end

return function(scope: any, app: any): Instance
	local store = app.store
	local modules = app.modules
	local cloud = app.cloud

	local signedIn = cloud.signedIn
	local signedOut = scope:Computed(function(use)
		return not use(signedIn)
	end)
	-- a legacy free session counts as a session, but the card asks for a licence key like signed out
	local free = cloud.free
	local trial = cloud.trial
	local trialEnded = cloud.trialEnded
	local premium = cloud.premium
	-- a licence key (Premium or the Paid Trial): the plan / today / devices summary
	local keyed = scope:Computed(function(use)
		return use(premium) or use(trial)
	end)
	local notKeyed = scope:Computed(function(use)
		return not use(keyed)
	end)
	-- "2 of 3": the day's generations left (the Paid Trial, a free session)
	local function freeLeftText(use): string
		local info = use(cloud.account)
		local quota = info and info.quota
		if quota and quota.generationsToday and quota.generationsPerDay then
			return `{math.max(0, quota.generationsPerDay - quota.generationsToday)} of {quota.generationsPerDay}`
		end
		return "3 of 3"
	end
	-- ", 6 days left" on a Paid Trial ("" when the server didn't say)
	local function trialDaysText(use): string
		local info = use(cloud.account)
		local days = info and info.trial and info.trial.daysLeft
		if days == nil then
			return ""
		end
		return `, {days} day{if days == 1 then "" else "s"} left`
	end
	local account = cloud.account
	local device = cloud.device
	local keyBox: TextBox? = nil
	local keyTyped = scope:Value(false)
	-- In Studio a click on Activate takes the focus off the key box before the click lands, and the
	-- box clears itself right then (ClearOnCommit): what it held is kept for a moment so the button
	-- still gets the key. Never saved; dropped on use, on refocus, or when stale.
	local leftKey, leftAt = "", 0
	local LEFT_KEY_SECONDS = 3

	local function activate(text: string?)
		local key = Store.trim(text)
		if key == "" then
			cloud.message:set({ tone = "warning", text = "Paste your licence key first (BLOX-XXXX-XXXX-XXXX-XXXX)." })
			return
		end
		keyTyped:set(false)
		cloud:activateKey(key)
		key = nil
	end

	----------------------------------------------------------------------
	-- account
	----------------------------------------------------------------------
	local function verifyUrlText(use): string
		local flow = use(device)
		return if flow and flow.verifyUrl then flow.verifyUrl else ""
	end
	local expiresText = scope:Value("")
	-- the countdown on the code (ticks while a code is shown)
	local ticking = false
	local function tick()
		if ticking then
			return
		end
		ticking = true
		task.spawn(function()
			while ticking do
				local flow = peek(device)
				if flow == nil or flow.state ~= "waiting" or flow.expiresAt == nil then
					break
				end
				local left = math.max(0, math.floor(flow.expiresAt - os.clock()))
				expiresText:set(string.format("Code expires in %d:%02d", left // 60, left % 60))
				task.wait(1)
			end
			ticking = false
		end)
	end
	table.insert(scope, scope:Observer(device):onChange(function()
		local flow = peek(device)
		if flow and flow.state == "waiting" then
			tick()
		end
	end))
	table.insert(scope, function()
		ticking = false
	end)

	local devicePanel = scope:Computed(function(use, inner)
		local flow = use(device)
		if flow == nil then
			return nil
		end
		local state = flow.state
		local children = {}
		if state == "starting" then
			table.insert(children, Ui.row(inner, {
				Name = "Starting",
				Gap = 8,
				Height = 22,
				LayoutOrder = 1,
				[Children] = {
					Ui.spinner(inner, { Size = 15, LayoutOrder = 1 }),
					Ui.text(inner, { Text = "Getting a sign-in code...", TextSize = Style.Text.Small, Color = C.TextSub, LayoutOrder = 2 }),
				},
			}))
		elseif state == "waiting" then
			table.insert(children, Ui.text(inner, {
				Name = "Step1",
				Text = "<b>1.</b> In your browser, go to",
				RichText = true,
				TextSize = Style.Text.Small,
				Color = C.TextSub,
				LayoutOrder = 1,
			}))
			-- read-only boxes: plugins can't open a browser, so the address and code can be selected and copied
			table.insert(children, Ui.input(inner, {
				Name = "VerifyUrl",
				Value = verifyUrlText(use),
				Icon = "arrow-up-right-from-square",
				Height = 30,
				TextSize = Style.Text.Small,
				Disabled = true,
				LayoutOrder = 2,
			}))
			table.insert(children, Ui.text(inner, {
				Name = "Step2",
				Text = "<b>2.</b> Enter this code, then approve with Roblox",
				RichText = true,
				TextSize = Style.Text.Small,
				Color = C.TextSub,
				LayoutOrder = 3,
			}))
			table.insert(children, inner:New "Frame" {
				Name = "UserCode",
				BackgroundColor3 = C.Field,
				Size = UDim2.new(1, 0, 0, 58),
				LayoutOrder = 4,
				[Children] = {
					Ui.corner(inner, Style.Radius.M),
					Ui.stroke(inner, C.Accent, 1.5),
					inner:New "TextBox" {
						Name = "Code",
						BackgroundTransparency = 1,
						Size = UDim2.fromScale(1, 1),
						ClearTextOnFocus = false,
						TextEditable = false,
						Text = tostring(flow.userCode or ""),
						FontFace = Font.new("rbxasset://fonts/families/RobotoMono.json", Enum.FontWeight.Bold),
						TextSize = 30,
						TextColor3 = C.Text,
						TextXAlignment = Enum.TextXAlignment.Center,
					},
				},
			})
			table.insert(children, Ui.row(inner, {
				Name = "Waiting",
				Gap = 8,
				Height = 22,
				LayoutOrder = 5,
				[Children] = {
					Ui.spinner(inner, { Size = 14, LayoutOrder = 1 }),
					Ui.text(inner, {
						Name = "WaitingText",
						Text = "Waiting for approval...",
						TextSize = Style.Text.Caption,
						Color = C.TextSub,
						LayoutOrder = 2,
					}),
					Ui.spacer(inner, 3),
					Ui.text(inner, {
						Name = "Expires",
						Text = expiresText,
						TextSize = Style.Text.Caption,
						Font = Style.Font.Mono,
						Color = C.TextMuted,
						LayoutOrder = 4,
					}),
				},
			}))
		else
			local tone = if state == "expired" then "warning" else "error"
			local look = statusTone(tone)
			table.insert(children, inner:New "Frame" {
				Name = "DeviceProblem",
				BackgroundColor3 = look.bg,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 1,
				[Children] = {
					Ui.corner(inner, Style.Radius.M),
					Ui.padding(inner, 9, 12),
					Ui.list(inner, "X", 8, "Left", "Top"),
					Ui.icon(inner, { Icon = look.icon, Size = 15, Color = look.color, Bold = true, LayoutOrder = 1 }),
					Ui.text(inner, {
						Name = "Message",
						Text = tostring(flow.message or "Sign-in didn't finish."),
						TextSize = Style.Text.Small,
						Weight = "Medium",
						Color = look.color,
						Wrap = true,
						LineHeight = 1.1,
						Size = UDim2.new(1, -24, 0, 0),
						AutomaticSize = Enum.AutomaticSize.Y,
						LayoutOrder = 2,
					}),
				},
			})
		end
		table.insert(children, Ui.row(inner, {
			Name = "DeviceActions",
			Gap = S.S,
			LayoutOrder = 10,
			[Children] = {
				if state == "denied" or state == "expired" or state == "error"
					then Ui.button(inner, {
						Name = "DeviceRetry",
						Text = if state == "expired" then "Get a new code" else "Try again",
						Icon = "arrow-rotate-right",
						Variant = "secondary",
						Size = "sm",
						LayoutOrder = 1,
						OnClick = function()
							cloud:startDeviceLogin()
						end,
					})
					else nil,
				Ui.button(inner, {
					Name = "DeviceCancel",
					Text = if state == "waiting" or state == "starting" then "Cancel" else "Close",
					Variant = "ghost",
					Size = "sm",
					LayoutOrder = 2,
					OnClick = function()
						cloud:cancelDeviceLogin()
					end,
				}),
			},
		}))
		return inner:New "Frame" {
			Name = "DeviceCode",
			BackgroundColor3 = C.Raised,
			Size = UDim2.new(1, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			LayoutOrder = 7,
			[Children] = {
				Ui.corner(inner, Style.Radius.M),
				Ui.stroke(inner, C.Border),
				Ui.padding(inner, 12),
				Ui.list(inner, "Y", 8),
				children,
			},
		}
	end)

	local accountCard = Ui.card(scope, {
		Name = "AccountCard",
		Title = "Account",
		Subtitle = scope:Computed(function(use)
			if use(trialEnded) then
				return "Your Paid Trial has ended. Upgrade to keep generating."
			elseif use(trial) then
				return `Paid Trial: {freeLeftText(use)} generations left today{trialDaysText(use)}`
			elseif use(free) then
				return "Free sessions are ending: paste a licence key below"
			end
			if use(signedIn) then
				local info = use(account)
				return if info and info.name then `Signed in as {info.name}` else "Signed in"
			end
			local flow = use(device)
			if flow and (flow.state == "waiting" or flow.state == "starting") then
				return "Signing in with Roblox..."
			end
			return "Signed out. Paste a licence key to generate."
		end),
		Icon = "circle-person",
		LayoutOrder = 1,
		Accessory = Ui.badge(scope, {
			Name = "AccountBadge",
			Text = scope:Computed(function(use)
				if not use(signedIn) then
					return "Signed out"
				end
				if use(cloud.reachable) == false then
					return "Offline"
				elseif use(trialEnded) then
					return "Trial ended"
				elseif use(trial) then
					return "Paid Trial"
				end
				return if use(free) then "Free plan" else "Premium"
			end),
			Tone = scope:Computed(function(use)
				if not use(signedIn) then
					return "neutral"
				elseif use(cloud.reachable) == false or use(trialEnded) then
					return "warning"
				end
				return if use(trial) then "accent" else "success"
			end),
			Dot = true,
		}),
		[Children] = {
			-- signed in with a key (Premium or the Paid Trial): plan, today's quota, devices
			Ui.column(scope, {
				Name = "SignedIn",
				Gap = S.S,
				LayoutOrder = 1,
				Visible = keyed,
				[Children] = {
					Ui.row(scope, {
						Name = "Stats",
						Gap = 6,
						Height = 54,
						LayoutOrder = 1,
						[Children] = {
							statTile(scope, {
								Name = "Stat_Plan",
								Icon = "crown",
								Label = "PLAN",
								LayoutOrder = 1,
								Value = scope:Computed(function(use)
									local info = use(account)
									local plan = info and info.plan
									if use(trial) then
										return "Paid Trial"
									end
									return if plan then (string.upper(string.sub(plan, 1, 1)) .. string.sub(plan, 2)) else "–"
								end),
							}),
							-- the Paid Trial counts generations (3 a day); Premium counts Claude designs
							statTile(scope, {
								Name = "Stat_Quota",
								Icon = "cursor-stars",
								Label = scope:Computed(function(use)
									return if use(trial) then "TODAY" else "AI TODAY"
								end),
								LayoutOrder = 2,
								Value = scope:Computed(function(use)
									local info = use(account)
									local quota = info and info.quota
									if use(trial) then
										if quota and quota.generationsToday and quota.generationsPerDay then
											return `{quota.generationsToday}/{quota.generationsPerDay}`
										end
									elseif quota and quota.aiToday and quota.aiPerDay then
										return `{quota.aiToday}/{quota.aiPerDay}`
									end
									return "–"
								end),
								Color = scope:Computed(function(use)
									local info = use(account)
									local quota = info and info.quota
									local used, limit = quota and quota.aiToday, quota and quota.aiPerDay
									if use(trial) then
										used, limit = quota and quota.generationsToday, quota and quota.generationsPerDay
									end
									if used and limit and used >= limit then
										return C.Warning
									end
									return C.Text
								end),
							}),
							statTile(scope, {
								Name = "Stat_Devices",
								Icon = "computer-monitor",
								Label = "DEVICES",
								LayoutOrder = 3,
								Value = scope:Computed(function(use)
									local info = use(account)
									if info and info.devices and info.maxDevices then
										return `{info.devices} of {info.maxDevices}`
									elseif info and info.devices then
										return tostring(info.devices)
									end
									return "–"
								end),
							}),
						},
					}),
					Ui.text(scope, {
						Name = "QuotaHint",
						Text = scope:Computed(function(use)
							local info = use(account)
							local quota = info and info.quota
							if use(trialEnded) then
								return "Your Paid Trial is over. Upgrade to Premium below to keep generating."
							elseif use(trial) then
								local perDay = (quota and quota.generationsPerDay) or 3
								if quota and quota.generationsToday and quota.generationsToday >= perDay then
									return `Today's {perDay} trial generations are used up. They reset tomorrow, or upgrade for unlimited.`
								end
								return `The Paid Trial includes {perDay} generations a day (AI or Instant). Reference screenshots need Premium.`
							end
							if quota and quota.aiToday and quota.aiPerDay and quota.aiToday >= quota.aiPerDay then
								return "Today's AI generations are used up. Instant mode is unlimited, and the quota resets tomorrow."
							end
							return "AI mode counts toward the daily AI quota. Instant mode doesn't."
						end),
						TextSize = Style.Text.Caption,
						Color = C.TextMuted,
						Fill = true,
						Wrap = true,
						LayoutOrder = 2,
					}),
					-- the Paid Trial: upgrade to Premium on the site (the same key becomes Premium), then Refresh
					Ui.column(scope, {
						Name = "Upgrade",
						Gap = 4,
						LayoutOrder = 3,
						Visible = trial,
						[Children] = {
							Ui.text(scope, {
								Name = "UpgradeText",
								Text = "<b>Upgrade to Premium</b>: unlimited generations and every feature. What you paid for the trial counts toward it. Open this link, pay, then press Refresh:",
								RichText = true,
								TextSize = Style.Text.Small,
								Color = C.TextSub,
								Fill = true,
								Wrap = true,
								LineHeight = 1.1,
								LayoutOrder = 1,
							}),
							-- read-only: select it and copy (Ctrl+C) into your browser
							Ui.input(scope, {
								Name = "UpgradeUrl",
								Value = scope:Computed(function(use)
									return cloud:upgradeUrl(use)
								end),
								Icon = "arrow-up-right-from-square",
								Height = 30,
								TextSize = Style.Text.Small,
								Disabled = true,
								LayoutOrder = 2,
							}),
						},
					}),
					Ui.row(scope, {
						Name = "AccountActions",
						Gap = S.S,
						LayoutOrder = 4,
						[Children] = {
							Ui.button(scope, {
								Name = "RefreshAccount",
								Text = "Refresh",
								Icon = "arrow-rotate-right",
								Variant = "secondary",
								Size = "sm",
								LayoutOrder = 1,
								Tooltip = "Check your plan and today's quota",
								OnClick = function()
									cloud:refreshAccount()
									cloud:refreshStatus()
								end,
							}),
							Ui.spacer(scope, 2),
							Ui.button(scope, {
								Name = "SignOut",
								Text = "Sign out",
								Icon = "door-open-arrow-to-bottom-right",
								Variant = "danger",
								Size = "sm",
								LayoutOrder = 3,
								Tooltip = "Signs this computer out (your designs stay)",
								OnClick = function()
									cloud:signOut()
								end,
							}),
						},
					}),
				},
			}),
			-- signed out (or a legacy free session): a licence key, or Sign in with Roblox for early testers
			Ui.column(scope, {
				Name = "SignedOut",
				Gap = S.S,
				LayoutOrder = 2,
				Visible = notKeyed,
				[Children] = {
					Ui.text(scope, {
						Name = "SignInIntro",
						Text = scope:Computed(function(use)
							if use(free) then
								return "Free sessions are ending: get a Paid Trial or Premium key at ui.bloxmaps.com, then paste it below to keep generating."
							end
							return "BloxUI runs on a licence key. Get the Paid Trial or Premium at ui.bloxmaps.com, then paste your key below."
						end),
						TextSize = Style.Text.Small,
						Color = C.TextSub,
						Fill = true,
						Wrap = true,
						LineHeight = 1.1,
						LayoutOrder = 1,
					}),
					Ui.column(scope, {
						Name = "GetPremium",
						Gap = 4,
						LayoutOrder = 2,
						[Children] = {
							Ui.text(scope, {
								Name = "GetPremiumText",
								Text = "<b>Get a BloxUI licence key</b> (Paid Trial or Premium), then paste it below:",
								RichText = true,
								TextSize = Style.Text.Small,
								Color = C.TextSub,
								Fill = true,
								Wrap = true,
								LineHeight = 1.1,
								LayoutOrder = 1,
							}),
							-- read-only: select it and copy (Ctrl+C) into your browser
							Ui.input(scope, {
								Name = "PremiumUrl",
								Value = Remote.PREMIUM_URL,
								Icon = "arrow-up-right-from-square",
								Height = 30,
								TextSize = Style.Text.Small,
								Disabled = true,
								LayoutOrder = 2,
							}),
						},
					}),
					Ui.button(scope, {
						Name = "SignInRoblox",
						Text = "Early tester? Sign in with Roblox",
						Icon = "person-check",
						Variant = "secondary",
						Size = "sm",
						LayoutOrder = 6,
						Visible = scope:Computed(function(use)
							return use(device) == nil
						end),
						Disabled = scope:Computed(function(use)
							return use(cloud.busy) == "key"
						end),
						Tooltip = "Uses the Roblox account that owns BloxUI",
						OnClick = function()
							cloud:startDeviceLogin()
						end,
					}),
					devicePanel,
					Ui.row(scope, {
						Name = "Or",
						Gap = 8,
						Height = 16,
						LayoutOrder = 4,
						[Children] = {
							scope:New "Frame" {
								Name = "Line1",
								BackgroundColor3 = C.Divider,
								BorderSizePixel = 0,
								Size = UDim2.new(0, 0, 0, 1),
								LayoutOrder = 1,
								[Children] = Ui.flex(scope, "Fill"),
							},
							Ui.text(scope, { Text = "OR", TextSize = 11, Weight = "SemiBold", Color = C.TextMuted, LayoutOrder = 2 }),
							scope:New "Frame" {
								Name = "Line2",
								BackgroundColor3 = C.Divider,
								BorderSizePixel = 0,
								Size = UDim2.new(0, 0, 0, 1),
								LayoutOrder = 3,
								[Children] = Ui.flex(scope, "Fill"),
							},
						},
					}),
					Ui.field(scope, {
						Name = "LicenseField",
						Label = "Your licence key",
						Hint = "Stored nowhere: it's exchanged for a sign-in on this computer, never saved into your place or printed.",
						LayoutOrder = 3,
						[Children] = Ui.row(scope, {
							Name = "KeyRow",
							Gap = S.S,
							Height = Style.Height.Control,
							[Children] = {
								Ui.input(scope, {
									Name = "LicenseKey",
									Value = "",
									ClearOnCommit = true,
									Mono = true,
									Icon = "key",
									Placeholder = "BLOX-XXXX-XXXX-XXXX-XXXX",
									Size = UDim2.new(1, -(96 + S.S), 0, Style.Height.Control),
									LayoutOrder = 1,
									BoxRef = function(box)
										keyBox = box
									end,
									OnInput = function(text)
										keyTyped:set(text ~= "")
									end,
									OnFocus = function()
										leftKey = ""
									end,
									OnCommit = function(text, enter)
										if enter then
											leftKey = ""
											activate(text)
										else
											leftKey, leftAt = text, os.clock()
										end
									end,
								}),
								Ui.button(scope, {
									Name = "ActivateKey",
									Text = "Activate",
									Variant = "secondary",
									Width = 96,
									LayoutOrder = 2,
									Busy = scope:Computed(function(use)
										return use(cloud.busy) == "key"
									end),
									OnClick = function()
										local box = keyBox
										if box == nil then
											return
										end
										local text = box.Text
										box.Text = ""
										if Ui.focusedBox == box then
											box:ReleaseFocus(false)
										end
										if Store.trim(text) == "" and os.clock() - leftAt < LEFT_KEY_SECONDS then
											text = leftKey -- the box already cleared itself when the click took its focus
										end
										leftKey = ""
										activate(text)
									end,
								}),
							},
						}),
					}),
				},
			}),
			messagePanel(scope, { Name = "AccountStatus", Value = cloud.message, LayoutOrder = 3 }),
			Ui.divider(scope, { LayoutOrder = 4 }),
			scope:New "Frame" {
				Name = "HttpNote",
				BackgroundColor3 = C.InfoSoft,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 5,
				[Children] = {
					Ui.corner(scope, Style.Radius.M),
					Ui.padding(scope, 10, 12),
					Ui.list(scope, "X", 10, "Left", "Top"),
					Ui.icon(scope, { Icon = "circle-i", Size = 16, Color = C.Info, Bold = true, LayoutOrder = 1 }),
					Ui.text(scope, {
						Name = "Text",
						Text = scope:Computed(function(use)
							local host = string.match(use(cloud.serverUrl), "^https?://([^/]+)") or "the BloxUI server"
							return `The first request makes Studio ask whether BloxUI may reach <b>{host}</b>. Click <b>Allow</b>.`
						end),
						RichText = true,
						TextSize = Style.Text.Caption,
						Color = C.TextSub,
						Wrap = true,
						LineHeight = 1.15,
						Size = UDim2.new(1, -26, 0, 0),
						AutomaticSize = Enum.AutomaticSize.Y,
						LayoutOrder = 2,
					}),
				},
			},
		},
	})

	----------------------------------------------------------------------
	-- generation: mode + model + quality
	----------------------------------------------------------------------
	local modelHints = {}
	for _, entry in Store.MODELS do
		modelHints[entry.value] = entry.hint
	end
	for _, entry in Store.OPENAI_MODELS do
		modelHints[entry.value] = entry.hint
	end
	-- who runs AI mode: Claude (the user's key or BloxUI's), or ChatGPT on the user's OpenAI key
	local aiProvider = scope:Computed(function(use)
		return store:aiProvider(use)
	end)

	local generationCard = Ui.card(scope, {
		Name = "GenerationCard",
		Title = "Generation",
		Subtitle = "How the brief and the UI are made (on the BloxUI server).",
		Icon = "cursor-stars",
		LayoutOrder = 2,
		[Children] = {
			Ui.field(scope, {
				Label = "Mode",
				Hint = scope:Computed(function(use)
					local quota = use(cloud.quotaText)
					if use(store.mode) == "AI" then
						local who = if use(aiProvider) == "openai" then "ChatGPT (OpenAI, your key)" else "Claude"
						return `{who} writes the brief and designs every screen.` .. (if quota ~= "" then ` {quota}.` else "")
					elseif use(trial) and quota ~= "" then
						-- the Paid Trial counts every generation, Instant ones too
						return `Instant: BloxUI's built-in generator. Fast; on the Paid Trial it counts like AI mode. {quota}.`
					end
					return "Instant: BloxUI's built-in generator. Fast, and it doesn't use your AI quota."
				end),
				LayoutOrder = 1,
				[Children] = Ui.segmented(scope, {
					Name = "SettingsMode",
					Value = store.mode,
					Options = {
						{
							value = "AI",
							label = scope:Computed(function(use)
								return Store.aiLabel(use(aiProvider))
							end),
							icon = "cursor-stars",
							locked = scope:Computed(function(use)
								local status = use(cloud.status)
								return status ~= nil and status.modes ~= nil and status.modes.ai == false and use(cloud.ownKeyInUse) == nil
							end),
						},
						{ value = "Offline", label = "Instant", icon = "lightning-bolt" },
					},
					OnChange = function(mode)
						store:setMode(mode)
					end,
				}),
			}),
			-- the Claude models, or the ChatGPT ones while AI runs on the user's OpenAI key
			Ui.field(scope, {
				Label = "Model",
				Hint = scope:Computed(function(use)
					local model = store:modelFor(use(aiProvider), use)
					return modelHints[model] or (if use(aiProvider) == "openai" then "Used in AI mode on your OpenAI key." else "Used in Claude AI mode.")
				end),
				LayoutOrder = 2,
				[Children] = Ui.dropdown(scope, {
					Name = "Model",
					Options = scope:Computed(function(use)
						return store:modelOptions(use(aiProvider))
					end),
					Value = scope:Computed(function(use)
						return store:modelFor(use(aiProvider), use)
					end),
					MenuTitle = scope:Computed(function(use)
						return if use(aiProvider) == "openai" then "ChatGPT model" else "Claude model"
					end),
					OnChange = function(model)
						store:setModel(model, peek(aiProvider))
					end,
				}),
			}),
			-- Best adds the AI's design review pass (Claude, or ChatGPT on the user's key); Fast skips it
			Ui.field(scope, {
				Name = "QualityField",
				Label = "Quality",
				Hint = scope:Computed(function(use)
					local value = use(store.quality)
					local hint = ""
					for _, entry in Store.QUALITY do
						if entry.value == value then
							hint = tostring(entry.hint or "")
						end
					end
					if hint ~= "" and not string.find(hint, "[%.!]$") then
						hint ..= "."
					end
					local who = if use(aiProvider) == "openai" then "ChatGPT" else "Claude"
					local cost = if value == "best"
						then ` One more {who} step, so it's a bit slower.`
						else " Quicker; the design isn't double-checked."
					return hint .. cost
				end),
				LayoutOrder = 3,
				[Children] = Ui.segmented(scope, {
					Name = "Quality",
					Value = store.quality,
					Options = (function()
						local options = {}
						for _, entry in Store.QUALITY do
							table.insert(options, {
								value = entry.value,
								label = entry.label,
								icon = if entry.value == "best" then "circle-star" else "lightning-bolt",
								tooltip = entry.hint,
							})
						end
						return options
					end)(),
					OnChange = function(quality)
						store:setQuality(quality)
					end,
				}),
			}),
			-- the user's own AI key: Claude (Anthropic) or ChatGPT (OpenAI) on their bill, one key per
			-- provider. Checked with the server (free), then saved on this computer only (plugin
			-- settings); sent only with AI jobs, over https. The chosen provider is remembered.
			(function()
				local ownKeyBox: TextBox? = nil
				-- the licence box's problem again: a click on "Save & test" takes the focus off the box
				-- before the click lands and the box clears itself (ClearOnCommit), so what it held is
				-- kept for a moment for the button. Never saved; dropped on use, on refocus, or when stale.
				local leftOwnKey, leftOwnAt = "", 0
				local function saveOwnKey()
					local box = ownKeyBox
					if box == nil then
						return
					end
					local text = box.Text
					box.Text = ""
					if Ui.focusedBox == box then
						box:ReleaseFocus(false)
					end
					if Store.trim(text) == "" and os.clock() - leftOwnAt < LEFT_KEY_SECONDS then
						text = leftOwnKey
					end
					leftOwnKey = ""
					local provider = peek(cloud.provider)
					cloud:saveOwnKey(text, store:modelFor(provider), nil, provider)
				end
				local saved = scope:Computed(function(use)
					return use(cloud.ownKey) ~= ""
				end)
				local providerOptions = {}
				for _, id in Remote.PROVIDER_ORDER do
					local info = Remote.PROVIDERS[id]
					table.insert(providerOptions, {
						value = id,
						label = info.label,
						tooltip = if id == "openai"
							then "An OpenAI API key (platform.openai.com). A ChatGPT or Codex subscription can't be used by other apps."
							else "An Anthropic API key (console.anthropic.com).",
					})
				end
				return Ui.field(scope, {
					Name = "OwnKeyField",
					Label = "Your AI key (optional)",
					Hint = scope:Computed(function(use)
						local provider = use(cloud.provider)
						local masked = use(cloud.ownKey)
						local stored = "It's saved on this computer only; the BloxUI server uses it for each job and never stores it."
						if provider == "openai" then
							local status = use(cloud.status)
							if not cloud:acceptsOwnKey("openai", status) and status ~= nil and status.modes ~= nil and status.modes.ownKey == true then
								return "This BloxUI server doesn't take OpenAI keys yet. Use a Claude (Anthropic) key for now."
							end
							if masked ~= "" then
								return `AI mode runs on ChatGPT (OpenAI's GPT models) with your key ({masked}) and your OpenAI bill. {stored}`
							end
							local fallback = if (use(cloud.ownKeys).anthropic or "") ~= "" then " Until then AI mode keeps using your Claude key." else ""
							return "Have an API key from platform.openai.com (sk-...)? AI mode then runs on OpenAI's GPT models on your own OpenAI bill. A ChatGPT Plus or Codex subscription can't be used here: OpenAI only lets other apps use API keys. Saved on this computer only, never in your place."
								.. fallback
						end
						if masked ~= "" then
							return `Claude mode runs on your key ({masked}) and your Anthropic bill. {stored}`
						end
						local fallback = if (use(cloud.ownKeys).openai or "") ~= "" then " Until then AI mode keeps using your ChatGPT key." else ""
						return "Have an API key from console.anthropic.com? Claude mode then runs on your own Anthropic bill. It's saved on this computer only, never in your place."
							.. fallback
					end),
					LayoutOrder = 4,
					[Children] = {
						Ui.segmented(scope, {
							Name = "OwnKeyProvider",
							Value = cloud.provider,
							Options = providerOptions,
							LayoutOrder = 0,
							OnChange = function(provider)
								cloud:setProvider(provider)
							end,
						}),
						Ui.row(scope, {
							Name = "OwnKeyInputRow",
							Gap = S.S,
							Height = Style.Height.Control,
							LayoutOrder = 1,
							Visible = scope:Computed(function(use)
								return not use(saved)
							end),
							[Children] = {
								Ui.input(scope, {
									Name = "OwnKey",
									Value = "",
									ClearOnCommit = true,
									Masked = true,
									Mono = true,
									Icon = "key",
									Placeholder = scope:Computed(function(use)
										return Remote.PROVIDERS[use(cloud.provider)].placeholder
									end),
									Size = UDim2.new(1, -(116 + S.S), 0, Style.Height.Control),
									LayoutOrder = 1,
									BoxRef = function(box)
										ownKeyBox = box
									end,
									OnFocus = function()
										leftOwnKey = ""
									end,
									OnCommit = function(text, enter)
										if enter then
											leftOwnKey = ""
											saveOwnKey()
										else
											leftOwnKey, leftOwnAt = text, os.clock()
										end
									end,
								}),
								Ui.button(scope, {
									Name = "SaveOwnKey",
									Text = "Save & test",
									Variant = "secondary",
									Width = 116,
									LayoutOrder = 2,
									Busy = scope:Computed(function(use)
										return use(cloud.busy) == "ownkey"
									end),
									OnClick = saveOwnKey,
								}),
							},
						}),
						Ui.row(scope, {
							Name = "OwnKeySavedRow",
							Gap = S.S,
							Height = Style.Height.Control,
							LayoutOrder = 2,
							Visible = saved,
							[Children] = {
								Ui.icon(scope, { Icon = "key", Size = 15, Color = C.TextSub, LayoutOrder = 1 }),
								Ui.text(scope, {
									Name = "Masked",
									Text = cloud.ownKey,
									TextSize = Style.Text.Small,
									Color = C.Text,
									LayoutOrder = 2,
								}),
								Ui.spacer(scope, 3),
								Ui.button(scope, {
									Name = "RemoveOwnKey",
									Text = "Remove",
									Variant = "secondary",
									Width = 90,
									LayoutOrder = 4,
									OnClick = function()
										cloud:removeOwnKey()
									end,
								}),
							},
						}),
						messagePanel(scope, { Name = "OwnKeyStatus", Value = cloud.ownKeyMessage, LayoutOrder = 3 }),
					},
				})
			end)(),
		},
	})

	----------------------------------------------------------------------
	-- appearance + preview
	----------------------------------------------------------------------
	local previewCard = Ui.card(scope, {
		Name = "PreviewCard",
		Title = "Appearance & preview",
		Icon = "eye",
		LayoutOrder = 3,
		[Children] = {
			Ui.field(scope, {
				Label = "Theme",
				Hint = scope:Computed(function(use)
					if use(app.themeChoice) == "Auto" then
						return `Follows Studio (now {Style.theme}). Change it in File → Studio Settings → Theme.`
					end
					return "The builder's own theme; your generated UI isn't affected."
				end),
				LayoutOrder = 1,
				[Children] = Ui.segmented(scope, {
					Name = "ThemeChoice",
					Value = app.themeChoice,
					Options = {
						{ value = "Auto", label = "Studio", icon = "computer-monitor", tooltip = "Follow Studio's theme" },
						{ value = "Dark", label = "Dark", icon = "moon" },
						{ value = "Light", label = "Light", icon = "sun" },
					},
					OnChange = function(choice)
						app.setTheme(choice)
					end,
				}),
			}),
			Ui.divider(scope, { LayoutOrder = 2 }),
			settingRow(scope, {
				Name = "AutoPreview",
				Title = "Show the preview after generating",
				Body = "Builds the UI into the viewport (CoreGui). Nothing is added to your place until you insert it.",
				ControlWidth = 34,
				LayoutOrder = 3,
				Control = Ui.switch(scope, {
					Name = "AutoPreviewSwitch",
					Value = store.autoPreview,
					LayoutOrder = 2,
					OnChange = function(on)
						store:setAutoPreview(on)
					end,
				}),
			}),
		},
	})

	----------------------------------------------------------------------
	-- keyboard shortcuts
	----------------------------------------------------------------------
	local shortcutRows = {}
	for index, shortcut in SHORTCUTS do
		table.insert(shortcutRows, Ui.row(scope, {
			Name = "Shortcut_" .. index,
			Gap = S.S,
			VAlign = "Top",
			LayoutOrder = index,
			[Children] = {
				scope:New "Frame" {
					Name = "Keys",
					BackgroundTransparency = 1,
					Size = UDim2.fromOffset(120, 22),
					LayoutOrder = 1,
					[Children] = Ui.kbd(scope, { Text = shortcut.keys, Height = 20 }),
				},
				Ui.text(scope, {
					Text = shortcut.text,
					TextSize = Style.Text.Small,
					Color = C.TextSub,
					Wrap = true,
					LineHeight = 1.1,
					Size = UDim2.new(1, -128, 0, 0),
					AutomaticSize = Enum.AutomaticSize.Y,
					LayoutOrder = 2,
					[Children] = Ui.padding(scope, 2, 0, 2, 0),
				}),
			},
		}))
	end
	local shortcutsCard = Ui.card(scope, {
		Name = "ShortcutsCard",
		Title = "Keyboard shortcuts",
		Subtitle = "Mouse over the builder, not typing.",
		Icon = "keyboard",
		LayoutOrder = 4,
		Gap = S.S,
		[Children] = {
			Ui.column(scope, { Name = "Shortcuts", Gap = 2, LayoutOrder = 1, [Children] = shortcutRows }),
		},
	})

	----------------------------------------------------------------------
	-- advanced: the server address
	----------------------------------------------------------------------
	local urlProblem = scope:Value(nil :: string?)
	local advancedCard = Ui.card(scope, {
		Name = "AdvancedCard",
		Title = "Advanced",
		Subtitle = "Only change these if BloxUI support asks you to.",
		Icon = "gear",
		LayoutOrder = 5,
		[Children] = {
			Ui.field(scope, {
				Name = "ServerField",
				Label = "Server",
				Hint = scope:Computed(function(use)
					local problem = use(urlProblem)
					if problem then
						return problem
					end
					if use(cloud.serverUrl) ~= Remote.DEFAULT_URL then
						return "A custom server. Changing the server signs this computer out."
					end
					return "Where the plugin sends prompts (HTTPS). Changing it signs this computer out."
				end),
				HintColor = scope:Computed(function(use)
					return if use(urlProblem) then C.Danger else C.TextMuted
				end),
				Accessory = Ui.button(scope, {
					Name = "ResetServer",
					Text = "Default",
					Variant = "ghost",
					Size = "sm",
					Visible = scope:Computed(function(use)
						return use(cloud.serverUrl) ~= Remote.DEFAULT_URL
					end),
					OnClick = function()
						urlProblem:set(nil)
						cloud:setServerUrl(nil)
					end,
				}),
				LayoutOrder = 1,
				[Children] = Ui.input(scope, {
					Name = "ServerUrl",
					Value = cloud.serverUrl,
					Mono = true,
					Icon = "chain-link",
					Placeholder = Remote.DEFAULT_URL,
					OnCommit = function(text)
						if Store.trim(text) == peek(cloud.serverUrl) then
							urlProblem:set(nil)
							return
						end
						local ok, problem = cloud:setServerUrl(text)
						urlProblem:set(if ok then nil else problem)
					end,
				}),
			}),
			Ui.row(scope, {
				Name = "Connection",
				Gap = 8,
				Height = 26,
				LayoutOrder = 2,
				[Children] = {
					Ui.icon(scope, {
						Icon = scope:Computed(function(use)
							local reachable = use(cloud.reachable)
							return if reachable == false then "signal-exclamation" elseif reachable then "circle-check" else "circle-i"
						end),
						Size = 14,
						Bold = true,
						Color = scope:Computed(function(use)
							local reachable = use(cloud.reachable)
							return if reachable == false then C.Warning elseif reachable then C.Success else C.TextMuted
						end),
						LayoutOrder = 1,
					}),
					Ui.text(scope, {
						Name = "ConnectionText",
						Text = scope:Computed(function(use)
							if use(cloud.checking) then
								return "Checking the server..."
							end
							local reachable = use(cloud.reachable)
							local status = use(cloud.status)
							if reachable == false then
								return "Can't reach the server"
							elseif reachable and status then
								return `Connected · server {status.server or "?"} · API {status.api or "?"}`
							end
							return "Not checked yet"
						end),
						TextSize = Style.Text.Small,
						Color = C.TextSub,
						Size = UDim2.new(0, 0, 1, 0),
						Truncate = true,
						LayoutOrder = 2,
						[Children] = Ui.flex(scope, "Fill"),
					}),
					Ui.button(scope, {
						Name = "CheckServer",
						Text = "Check now",
						Variant = "ghost",
						Size = "sm",
						LayoutOrder = 3,
						Busy = cloud.checking,
						OnClick = function()
							cloud:refreshStatus()
						end,
					}),
				},
			}),
		},
	})

	----------------------------------------------------------------------
	-- this plugin build
	----------------------------------------------------------------------
	local sampleCount = 0
	if modules.Samples then
		local ok, list = pcall(modules.Samples.list)
		sampleCount = if ok and typeof(list) == "table" then #list else 0
	end
	-- this version's release notes (the dialog that opens once after an update)
	local whatsNewButton = Ui.button(scope, {
		Name = "WhatsNew",
		Text = "What's new",
		Icon = "gift",
		Variant = "ghost",
		Size = "sm",
		LayoutOrder = 5,
		Tooltip = "What changed in the latest updates",
		OnClick = function()
			app.showWhatsNew()
		end,
	})
	local statusChildren = {
		statusRow(scope, "BloxUI runtime", modules.UI ~= nil, if modules.UI then "v" .. tostring(modules.version) else "Failed to load", 1),
		statusRow(scope, "Plugin", true, "v" .. tostring(cloud.version), 2, whatsNewButton),
		statusRow(scope, "Blueprint builder", modules.Blueprint ~= nil, nil, 3),
		statusRow(scope, "Samples", sampleCount > 0, if sampleCount > 0 then `{sampleCount} ready-made UIs` else nil, 4),
		statusRow(scope, "Loader template", Export.hasLoaderTemplate(), nil, 5),
		statusRow(scope, "Server template", Export.hasServerTemplate(), nil, 6),
	}
	local aboutCard = Ui.card(scope, {
		Name = "AboutCard",
		Title = "This plugin build",
		Subtitle = "Missing pieces? Reinstall the plugin, restart Studio.",
		Icon = "circle-i",
		LayoutOrder = 6,
		Gap = S.S,
		[Children] = {
			Ui.column(scope, { Name = "Modules", Gap = 4, LayoutOrder = 1, [Children] = statusChildren }),
			Ui.divider(scope, { LayoutOrder = 2 }),
			Ui.row(scope, {
				Name = "HistoryRow",
				Gap = S.S,
				Height = 26,
				LayoutOrder = 3,
				[Children] = {
					Ui.text(scope, {
						Text = scope:Computed(function(use)
							local n = #use(store.history)
							return `{n} generation{if n == 1 then "" else "s"} in Recent (this computer)`
						end),
						TextSize = Style.Text.Small,
						Color = C.TextSub,
						Size = UDim2.new(0, 0, 1, 0),
						Truncate = true,
						LayoutOrder = 1,
						[Children] = Ui.flex(scope, "Fill"),
					}),
					Ui.button(scope, {
						Name = "ManageHistory",
						Text = "Manage",
						Variant = "ghost",
						Size = "sm",
						LayoutOrder = 3,
						OnClick = function()
							store.tab:set("Create")
						end,
					}),
				},
			}),
		},
	})

	return Ui.scroll(scope, {
		Name = "SettingsPage",
		Gap = S.M,
		[Children] = { accountCard, generationCard, previewCard, shortcutsCard, advancedCard, aboutCard },
	})
end
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="26">
          <Properties>
            <string name="Name">Structure</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Structure tab: a tree of HUD elements, screens (tabs -> sections -> items)
	and currencies. Every node can be shown / hidden (eye), renamed, re-iconed,
	reordered (drag the grip, or the arrows), duplicated, deleted (with Undo)
	and expanded to edit its fields. New nodes come from the Catalog (every
	HudElement type, Section kind and Screen kind in docs/BLUEPRINT.md,
	including the v1.1 additions).

	The filter box narrows the top-level list (a screen also matches through
	its tabs and sections). Row actions show on hover, so the tree stays calm.
	Under the HUD heading, the HUD layout shows as a picture; clicking it
	offers every layout as picture cards (blueprint.hud.layout).

	Keyboard (mouse over the widget): Up / Down select a row, Right / Left
	expand / collapse, Enter toggles, H hides, Ctrl+D duplicates, Delete
	deletes, Alt+Up / Alt+Down move, Esc clears the selection, / or Ctrl+F
	focuses the filter.
]]

local Deps = require(script.Parent.Parent.Deps)
local Style = require(script.Parent.Parent.Style)
local Ui = require(script.Parent.Parent.Ui)
local Catalog = require(script.Parent.Parent.Catalog)
local Fields = require(script.Parent.Parent.Fields)
local Store = require(script.Parent.Parent.Store)
local LayoutCards = require(script.Parent.Parent.LayoutCards)

local Fusion = Deps.Fusion
local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local C = Style.Color
local S = Style.Space

local INDENT = 14
local ROW_HEIGHT = 32
local TOOLBAR = 48

local LIST_ITEM_LABELS = {
	buttons = "button",
	tasks = "task",
	slots = "slot",
	hints = "hint",
	boosts = "boost",
	teams = "team",
	emotes = "emote",
	items = "item",
	upgrades = "upgrade",
	days = "day",
	quests = "quest",
	tiers = "tier",
	rows = "row",
	stats = "stat",
	settings = "setting",
	cases = "case",
	worlds = "world",
	badges = "badge",
	binds = "bind",
	rewards = "reward",
	-- v1.6
	wedges = "wedge",
	gifts = "gift",
	groups = "group",
	milestones = "milestone",
	achievements = "achievement",
}

local CURRENCY_FIELDS = {
	Catalog.F.text("name", "Name"),
	Catalog.F.text("id", "Id"),
	Catalog.F.icon(),
	Catalog.F.color("color", "Colour"),
	Catalog.F.number("start", "Start amount", { min = 0 }),
	Catalog.F.enum("format", "Format", { "abbrev", "comma" }),
	Catalog.F.object("source", "Source", {
		Catalog.F.enum("kind", "Kind", { "none", "leaderstat", "attribute" }),
		Catalog.F.text("name", "Name"),
	}, function()
		return { kind = "leaderstat", name = "Coins" }
	end),
}

local function takenIds(list: { any }?): { [string]: boolean }
	local taken = {}
	for _, entry in list or {} do
		if typeof(entry) == "table" and entry.id then
			taken[tostring(entry.id)] = true
		end
	end
	return taken
end

local function countLabel(n: number, word: string): string
	return `{n} {word}{if n == 1 then "" else "s"}`
end

local function firstList(item: any, schemaFields: { any }?): (number, string?)
	for _, field in schemaFields or {} do
		if field.kind == "list" and typeof(item[field.key]) == "table" then
			return #item[field.key], LIST_ITEM_LABELS[field.key] or "item"
		end
	end
	return 0, nil
end

-- Case-insensitive "does any of these strings contain the query".
local function matches(query: string, ...: any): boolean
	for i = 1, select("#", ...) do
		local value = select(i, ...)
		if typeof(value) == "string" and string.find(string.lower(value), query, 1, true) then
			return true
		end
	end
	return false
end

--[[
	Where a dragged row lands. `midpoints` are the Y centres of the rows of
	one list (in list order), `from` the dragged row's index, `y` the mouse.
	Returns the new index for the dragged row (1..#midpoints).
]]
local function dropIndex(midpoints: { number }, from: number, y: number): number
	local target = 1
	for index, mid in midpoints do
		if index ~= from and y > mid then
			target += 1
		end
	end
	return math.clamp(target, 1, math.max(1, #midpoints))
end

return function(scope: any, app: any): Instance
	local store = app.store
	local overlay = app.overlay
	local history = app.history

	local renderList -- forward

	-- tree state that isn't part of the document
	local query = scope:Value("") -- filter (lower case, trimmed)
	local filterText = scope:Value("") -- what's in the filter box
	local treeVersion = scope:Value(0) -- bump to rebuild without an edit (collapse all)
	local selected = scope:Value(nil :: any) -- the selected item table
	-- Rows and the tree follow edits only while this tab is showing (they
	-- catch up once when you come back), so editing in other tabs stays cheap
	-- however big the tree is.
	local rowRevision, treeStructure = store:tabCounters(scope, "Structure")
	local rows = setmetatable({}, { __mode = "k" }) -- header -> row entry
	local content: Frame
	local page: ScrollingFrame
	local filterBox: TextBox? = nil

	-- A Fields context that knows how deep we are (for nested list rows).
	local function ctx(depth: number): any
		return setmetatable({
			revision = rowRevision,
			renderList = function(s, owner, field, order)
				return renderList(s, owner, field, order, depth)
			end,
		}, { __index = app })
	end

	local function changedStructure()
		store:changed(true)
	end

	local function removeAt(list: { any }, item: any, label: string)
		local index = table.find(list, item)
		if index == nil then
			return
		end
		table.remove(list, index)
		changedStructure()
		local step = history:lastId()
		store:toast(`Deleted {label}.`, "info", {
			action = {
				label = "Undo",
				run = function()
					if step ~= nil and history:lastId() == step then
						history:undo()
					elseif table.find(list, item) == nil then
						table.insert(list, math.min(index, #list + 1), item)
						changedStructure()
					end
				end,
			},
		})
	end

	local function move(list: { any }, item: any, delta: number)
		local index = table.find(list, item)
		if index == nil then
			return
		end
		local target = index + delta
		if target < 1 or target > #list then
			return
		end
		list[index], list[target] = list[target], list[index]
		changedStructure()
	end

	local function moveTo(list: { any }, item: any, target: number)
		local index = table.find(list, item)
		if index == nil or index == target then
			return
		end
		table.remove(list, index)
		table.insert(list, math.clamp(target, 1, #list + 1), item)
		changedStructure()
	end

	local function duplicate(list: { any }, item: any)
		local index = table.find(list, item)
		if index == nil then
			return
		end
		local copy = Store.deepCopy(item)
		if typeof(copy.id) == "string" then
			copy.id = Catalog.uniqueId(copy.id, takenIds(list))
		end
		if typeof(copy.title) == "string" and list == (store.blueprint and store.blueprint.screens) then
			copy.title = copy.title .. " copy"
		end
		table.insert(list, index + 1, copy)
		store.expanded[copy] = store.expanded[item]
		changedStructure()
		selected:set(copy)
	end

	----------------------------------------------------------------------
	-- drag to reorder (within the row's own list)
	----------------------------------------------------------------------
	local drag = nil -- { entry, from, target, connections, indicator }

	local function listRows(list: { any }): { any }
		local out = {}
		for header, entry in rows do
			if entry.list == list and header.Parent ~= nil then
				table.insert(out, entry)
			end
		end
		table.sort(out, function(a, b)
			return (table.find(list, a.item) or 0) < (table.find(list, b.item) or 0)
		end)
		return out
	end

	local function endDrag(commit: boolean)
		local current = drag
		drag = nil
		if current == nil then
			return
		end
		for _, connection in current.connections do
			connection:Disconnect()
		end
		if current.indicator then
			current.indicator:Destroy()
		end
		if current.entry.dragging then
			current.entry.dragging:set(false)
		end
		if commit and current.target and current.target ~= current.from then
			moveTo(current.entry.list, current.entry.item, current.target)
		end
	end
	table.insert(scope, function()
		endDrag(false)
	end)

	local function updateDrag(y: number)
		local current = drag
		if current == nil then
			return
		end
		local siblings = listRows(current.entry.list)
		local midpoints = {}
		local from = 1
		for index, entry in siblings do
			local header = entry.header
			midpoints[index] = header.AbsolutePosition.Y + header.AbsoluteSize.Y / 2
			if entry == current.entry then
				from = index
			end
		end
		local target = dropIndex(midpoints, from, y)
		current.from = from
		current.target = target
		-- insertion line: below the target row when moving down, above it when moving up
		local rootPos = app.root.AbsolutePosition
		local anchor = siblings[target]
		if anchor then
			local header = anchor.header
			local lineY = if target > from then header.AbsolutePosition.Y + header.AbsoluteSize.Y else header.AbsolutePosition.Y
			current.indicator.Position = UDim2.fromOffset(header.AbsolutePosition.X - rootPos.X, lineY - rootPos.Y - 1)
			current.indicator.Size = UDim2.fromOffset(header.AbsoluteSize.X, 2)
			current.indicator.Visible = target ~= from
		end
	end

	local function startDrag(entry: any)
		endDrag(false)
		if entry == nil or entry.item == nil then
			return
		end
		local root = app.root
		local indicator = Instance.new("Frame")
		indicator.Name = "DropIndicator"
		indicator.BackgroundColor3 = C.Accent
		indicator.BorderSizePixel = 0
		indicator.ZIndex = 50
		indicator.Visible = false
		indicator.Parent = root
		drag = { entry = entry, from = table.find(entry.list, entry.item), target = nil, connections = {}, indicator = indicator }
		entry.dragging:set(true)
		table.insert(drag.connections, root.InputChanged:Connect(function(input)
			if input.UserInputType == Enum.UserInputType.MouseMovement then
				updateDrag(input.Position.Y)
			end
		end))
		table.insert(drag.connections, root.InputEnded:Connect(function(input)
			if input.UserInputType == Enum.UserInputType.MouseButton1 then
				endDrag(true)
			end
		end))
		table.insert(drag.connections, root.MouseLeave:Connect(function()
			endDrag(false)
		end))
	end

	----------------------------------------------------------------------
	-- one tree row (header + expandable body)
	----------------------------------------------------------------------
	--[[
		opts = {
			item, list, depth, name, icon (fn), title (fn), subtitle (fn),
			canHide = true, body = fn(scope) -> children, extraMenu = {{label, icon, run}},
			deleteLabel = "Shop", highlight = "matches: Codes" (filter hint),
		}
	]]
	local function nodeRow(rowScope: any, opts: { [string]: any }): Frame
		local item = opts.item
		local list = opts.list
		local depth = opts.depth or 0
		local expandable = opts.body ~= nil
		local expanded = rowScope:Value(store.expanded[item] == true)
		local hovered = rowScope:Value(false)
		local dragging = rowScope:Value(false)
		local isSelected = rowScope:Computed(function(use)
			return use(selected) == item
		end)
		local reveal = rowScope:Computed(function(use)
			return use(hovered) or use(isSelected)
		end)
		local visible = rowScope:Computed(function(use)
			use(rowRevision)
			return item.visible ~= false
		end)
		local title = rowScope:Computed(function(use)
			use(rowRevision)
			return opts.title()
		end)
		local subtitle = rowScope:Computed(function(use)
			use(rowRevision)
			if opts.highlight then
				return opts.highlight
			end
			return if opts.subtitle then opts.subtitle() else ""
		end)
		local icon = rowScope:Computed(function(use)
			use(rowRevision)
			return if opts.icon then opts.icon() else nil
		end)
		local state, events = Ui.interact(rowScope)
		local header: TextButton

		local function setExpanded(now: boolean)
			if not expandable or peek(expanded) == now then
				return
			end
			expanded:set(now)
			store.expanded[item] = now or nil
		end
		local function toggle()
			setExpanded(not peek(expanded))
		end
		local function hide()
			if opts.canHide == false then
				return
			end
			item.visible = if item.visible == false then nil else false
			store:changed(false)
		end

		local menuButton
		local function openMenu()
			local options = {}
			local actions = {}
			local function add(value, label, iconName, run, disabled, hint)
				table.insert(options, { value = value, label = label, icon = iconName, disabled = disabled, hint = hint })
				actions[value] = run
			end
			add("up", "Move up", "arrow-small-up", function()
				move(list, item, -1)
			end)
			add("down", "Move down", "arrow-small-down", function()
				move(list, item, 1)
			end)
			for i, extra in opts.extraMenu or {} do
				add("extra" .. i, extra.label, extra.icon, extra.run)
			end
			add("duplicate", "Duplicate", "two-stacked-squares", function()
				duplicate(list, item)
			end)
			add("delete", "Delete", "trash-can", function()
				removeAt(list, item, opts.deleteLabel or peek(title))
			end)
			selected:set(item)
			overlay:menu(menuButton, {
				options = options,
				width = 190,
				onSelect = function(value)
					local run = actions[value]
					if run then
						run()
					end
				end,
			})
		end

		local function action(props: { [string]: any }): TextButton
			local button = Ui.iconButton(rowScope, Ui.merge({
				Size = 24,
				Visible = props.Visible or reveal,
			}, props))
			-- keep the row "hovered" while the pointer is on its buttons
			table.insert(rowScope, button.MouseEnter:Connect(function()
				hovered:set(true)
			end))
			return button
		end

		menuButton = action({
			Name = "More",
			Icon = "three-dots-horizontal",
			Tooltip = "More",
			LayoutOrder = 14,
			OnClick = openMenu,
		})

		local grip = rowScope:New "TextButton" {
			Name = "Grip",
			Text = "",
			AutoButtonColor = false,
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(12, ROW_HEIGHT),
			LayoutOrder = 0,
			[OnEvent "MouseEnter"] = function()
				hovered:set(true)
			end,
			[OnEvent "InputBegan"] = function(input)
				if input.UserInputType == Enum.UserInputType.MouseButton1 and rows[header] then
					selected:set(item)
					startDrag(rows[header])
				end
			end,
			[Children] = Ui.icon(rowScope, {
				Icon = "six-dots-two-column-grid",
				Size = 12,
				Color = C.TextMuted,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Transparency = rowScope:Computed(function(use)
					return if use(hovered) or use(dragging) then 0 else 1
				end),
			}),
		}
		Ui.tooltip(rowScope, grip, "Drag to reorder")

		header = rowScope:New "TextButton" (Ui.merge(events, {
			Name = "Header",
			Text = "",
			AutoButtonColor = false,
			Size = UDim2.new(1, 0, 0, ROW_HEIGHT),
			LayoutOrder = 1,
			BackgroundColor3 = rowScope:Computed(function(use)
				if use(dragging) then
					return C.AccentSoft
				end
				local s = use(state)
				if use(isSelected) then
					return if s == "hover" or s == "press" then C.AccentSoftHover else C.AccentSoft
				end
				if s == "hover" or s == "press" or use(hovered) then
					return C.RowHover
				end
				return if use(expanded) then C.Card else C.Bg
			end),
			BackgroundTransparency = rowScope:Computed(function(use)
				local s = use(state)
				if s == "hover" or s == "press" or use(hovered) or use(expanded) or use(isSelected) or use(dragging) then
					return 0
				end
				return 1
			end),
			[OnEvent "Activated"] = function()
				selected:set(item)
				toggle()
			end,
			[Children] = {
				Ui.corner(rowScope, Style.Radius.S),
				Ui.padding(rowScope, 0, 2, 0, depth * INDENT),
				Ui.list(rowScope, "X", 3, "Left", "Center"),
				grip,
				Ui.icon(rowScope, {
					Name = "Chevron",
					Icon = rowScope:Computed(function(use)
						return if use(expanded) then "chevron-small-down" else "chevron-small-right"
					end),
					Size = 14,
					Color = C.TextMuted,
					Transparency = if expandable then 0 else 1,
					LayoutOrder = 1,
				}),
				Ui.icon(rowScope, {
					Name = "Glyph",
					Icon = icon,
					Size = 16,
					Visible = rowScope:Computed(function(use)
						return use(icon) ~= nil
					end),
					Color = rowScope:Computed(function(use)
						return if use(visible) then C.AccentText else C.TextDisabled
					end),
					LayoutOrder = 3,
				}),
				if opts.swatch
					then Ui.swatch(rowScope, {
						Name = "Swatch",
						Size = 12,
						Color = rowScope:Computed(function(use)
							use(rowRevision)
							return opts.swatch() or "#000000"
						end),
						Visible = rowScope:Computed(function(use)
							return use(icon) == nil
						end),
						LayoutOrder = 3,
					})
					else nil,
				rowScope:New "Frame" {
					Name = "Titles",
					BackgroundTransparency = 1,
					Size = UDim2.new(0, 0, 1, 0),
					LayoutOrder = 4,
					[Children] = {
						Ui.flex(rowScope, "Fill"),
						Ui.padding(rowScope, 0, 0, 0, 3),
						Ui.list(rowScope, "X", 6, "Left", "Center"),
						Ui.text(rowScope, {
							Name = "Title",
							Text = title,
							Weight = "SemiBold",
							TextSize = Style.Text.Small,
							Color = rowScope:Computed(function(use)
								return if use(visible) then C.Text else C.TextMuted
							end),
							AutomaticSize = Enum.AutomaticSize.X,
							Size = UDim2.new(0, 0, 1, 0),
							LayoutOrder = 1,
							[Children] = rowScope:New "UISizeConstraint" { MaxSize = Vector2.new(170, math.huge) },
							Truncate = true,
						}),
						Ui.text(rowScope, {
							Name = "Subtitle",
							Text = subtitle,
							TextSize = Style.Text.Caption,
							Color = if opts.highlight then C.AccentText else C.TextMuted,
							Size = UDim2.new(0, 0, 1, 0),
							Truncate = true,
							LayoutOrder = 2,
							[Children] = Ui.flex(rowScope, "Fill"),
						}),
					},
				},
				if opts.canHide ~= false
					then action({
						Name = "Eye",
						Icon = rowScope:Computed(function(use)
							return if use(visible) then "eye" else "eye-slash"
						end),
						IconSize = 15,
						Tooltip = rowScope:Computed(function(use)
							return if use(visible) then "Hide  (H)" else "Show  (H)"
						end),
						Visible = rowScope:Computed(function(use)
							return use(reveal) or not use(visible)
						end),
						Color = rowScope:Computed(function(use)
							return if use(visible) then C.TextSub else C.TextMuted
						end),
						LayoutOrder = 10,
						OnClick = hide,
					})
					else nil,
				action({
					Name = "Up",
					Icon = "chevron-small-up",
					Tooltip = "Move up  (Alt+Up)",
					LayoutOrder = 11,
					Visible = rowScope:Computed(function(use)
						return use(reveal) and use(app.wide)
					end),
					OnClick = function()
						move(list, item, -1)
					end,
				}),
				action({
					Name = "Down",
					Icon = "chevron-small-down",
					Tooltip = "Move down  (Alt+Down)",
					LayoutOrder = 12,
					Visible = rowScope:Computed(function(use)
						return use(reveal) and use(app.wide)
					end),
					OnClick = function()
						move(list, item, 1)
					end,
				}),
				menuButton,
			},
		}))

		rows[header] = {
			item = item,
			list = list,
			header = header,
			depth = depth,
			expandable = expandable,
			expanded = expanded,
			dragging = dragging,
			setExpanded = setExpanded,
			toggle = toggle,
			hide = hide,
			remove = function()
				removeAt(list, item, opts.deleteLabel or peek(title))
			end,
			duplicate = function()
				duplicate(list, item)
			end,
			move = function(delta)
				move(list, item, delta)
			end,
		}
		table.insert(rowScope, header.MouseEnter:Connect(function()
			hovered:set(true)
		end))
		table.insert(rowScope, header.MouseLeave:Connect(function()
			hovered:set(false)
		end))

		local body = if expandable
			then rowScope:Computed(function(use, inner)
				if not use(expanded) then
					return nil
				end
				return inner:New "Frame" {
					Name = "Body",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, 0, 0, 0),
					AutomaticSize = Enum.AutomaticSize.Y,
					LayoutOrder = 2,
					[Children] = {
						inner:New "Frame" {
							Name = "Rule",
							BackgroundColor3 = C.Border,
							BorderSizePixel = 0,
							Position = UDim2.fromOffset(20 + depth * INDENT, 0),
							Size = UDim2.new(0, 1, 1, -6),
						},
						inner:New "Frame" {
							Name = "Inner",
							BackgroundTransparency = 1,
							Position = UDim2.fromOffset(30 + depth * INDENT, 0),
							Size = UDim2.new(1, -(34 + depth * INDENT), 0, 0),
							AutomaticSize = Enum.AutomaticSize.Y,
							[Children] = {
								Ui.padding(inner, 6, 0, 10, 0),
								Ui.list(inner, "Y", 6),
								opts.body(inner),
							},
						},
					},
				}
			end)
			else nil

		return rowScope:New "Frame" {
			Name = opts.name or "Node",
			BackgroundTransparency = 1,
			Size = UDim2.new(1, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			LayoutOrder = opts.order or 0,
			[Children] = {
				Ui.list(rowScope, "Y", 0),
				header,
				body,
			},
		}
	end

	-- small "+ Add" button that opens a catalog menu
	local function addButton(s: any, label: string, order: number, options: { any }, onSelect: (any) -> (), menuTitle: string?)
		local button
		button = Ui.button(s, {
			Name = "Add",
			Text = label,
			Icon = "plus-small",
			Variant = "ghost",
			Size = "sm",
			LayoutOrder = order,
			OnClick = function()
				if #options == 1 and options[1].direct then
					onSelect(options[1].value)
					return
				end
				overlay:menu(button, {
					options = options,
					width = 260,
					title = menuTitle,
					searchable = #options > 8,
					onSelect = onSelect,
				})
			end,
		})
		return button
	end

	local function groupHeader(s: any, text: string, order: number, count: any, accessory: any?): Frame
		return Ui.overline(s, {
			Name = "Header_" .. text,
			Text = text,
			Count = if count ~= nil then tostring(count) else nil,
			LayoutOrder = order,
			Accessory = accessory,
		})
	end

	-- The HUD layout as a picture (+ name and hint); click for every layout as cards.
	local function hudLayoutRow(s: any, bp: any): TextButton
		local state, events = Ui.interact(s)
		local current = s:Computed(function(use)
			use(rowRevision)
			local hud = bp.hud
			return if typeof(hud) == "table" and typeof(hud.layout) == "string" then hud.layout else "Classic"
		end)
		local button
		button = s:New "TextButton" (Ui.merge(events, {
			Name = "HudLayout",
			Text = "",
			AutoButtonColor = false,
			BorderSizePixel = 0,
			Size = UDim2.new(1, 0, 0, 42),
			BackgroundColor3 = s:Computed(function(use)
				local st = use(state)
				return if st == "hover" or st == "press" then C.FieldHover else C.Field
			end),
			[OnEvent "Activated"] = function()
				LayoutCards.pick(app, button, {
					values = LayoutCards.values(),
					value = peek(current),
					title = "HUD layout",
					onSelect = function(layout)
						store:setBlueprintLayout(layout)
					end,
				})
			end,
			[Children] = {
				Ui.corner(s, Style.Radius.M),
				Ui.stroke(s, s:Computed(function(use)
					return if use(state) == "hover" then C.BorderStrong else C.Border
				end)),
				Ui.padding(s, 0, 8, 0, 6),
				Ui.list(s, "X", 10, "Left", "Center"),
				s:Computed(function(use, inner)
					return LayoutCards.diagram(inner, use(current), { Name = "LayoutDiagram", Width = 48, LayoutOrder = 1 })
				end),
				Ui.column(s, {
					Name = "Info",
					Gap = 1,
					LayoutOrder = 2,
					Size = UDim2.new(0, 0, 0, 0),
					Flex = "Fill",
					[Children] = {
						-- the picture shows where things go; the picker's header has the full hint
						Ui.text(s, {
							Name = "LayoutCaption",
							Text = "Layout",
							TextSize = Style.Text.Caption,
							Color = C.TextMuted,
							Fill = true,
							AutomaticSize = Enum.AutomaticSize.Y,
							LayoutOrder = 1,
						}),
						Ui.text(s, {
							Name = "LayoutName",
							Text = s:Computed(function(use)
								return Catalog.hudLayoutInfo(use(current)).label
							end),
							TextSize = Style.Text.Small,
							Weight = "SemiBold",
							Fill = true,
							Truncate = true,
							AutomaticSize = Enum.AutomaticSize.Y,
							LayoutOrder = 2,
						}),
					},
				}),
				Ui.text(s, {
					Name = "Change",
					Text = "Change",
					TextSize = Style.Text.Caption,
					Weight = "Medium",
					Color = C.AccentText,
					LayoutOrder = 3,
					Visible = app.wide, -- a narrow dock keeps the room for the hint (the chevron stays)
				}),
				Ui.icon(s, { Icon = "chevron-small-down", Size = 14, Color = C.TextMuted, LayoutOrder = 4 }),
			},
		}))
		return button
	end

	----------------------------------------------------------------------
	-- nested lists (items / buttons / tiers ...)
	----------------------------------------------------------------------
	renderList = function(s: any, owner: any, field: any, order: number?, depth: number): Frame
		local list = owner[field.key]
		if typeof(list) ~= "table" then
			list = {}
			owner[field.key] = list
		end
		local schema = field.item or {}
		local singular = LIST_ITEM_LABELS[field.key] or "item"
		local listRowsOut = {}
		for index, entry in list do
			if typeof(entry) == "table" then
				table.insert(listRowsOut, nodeRow(s, {
					name = "Item_" .. index,
					item = entry,
					list = list,
					depth = 0,
					order = index,
					icon = function()
						local glyph = entry.icon or (entry.free and entry.free.icon)
						if glyph == nil and entry.color == nil then
							return "square-small"
						end
						return glyph
					end,
					swatch = function()
						return entry.color
					end,
					title = function()
						return Catalog.itemTitle(entry, schema.titleKeys, string.upper(string.sub(singular, 1, 1)) .. string.sub(singular, 2))
					end,
					subtitle = function()
						if field.key == "settings" then
							-- built-in settings really work; others only fire ui:on(id, fn)
							local kind = if Catalog.settingBuiltinById[entry.id] then "Built-in" else "Event only"
							return `{kind} · {tostring(entry.type or "Toggle")}`
						elseif entry.action then
							return Catalog.describeAction(entry.action)
						elseif entry.rarity then
							return entry.rarity
						elseif entry.price then
							return `{entry.price} {if entry.currency == "robux" then "R$" else (entry.currency or "")}`
						elseif entry.key then
							return entry.key
						end
						return ""
					end,
					body = function(inner)
						local out = {}
						for i, sub in schema.fields or {} do
							table.insert(out, Fields.render(inner, ctx(depth + 1), entry, sub, i))
						end
						return out
					end,
				}))
			end
		end
		return Fields.stacked(s, `{field.label} ({#list})`, s:New "Frame" {
			Name = "List_" .. field.key,
			BackgroundTransparency = 1,
			Size = UDim2.new(1, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			LayoutOrder = 1,
			[Children] = {
				Ui.list(s, "Y", 2),
				listRowsOut,
			},
		}, order, Ui.button(s, {
			Name = "AddItem",
			Text = "Add " .. singular,
			Icon = "plus-small",
			Variant = "ghost",
			Size = "sm",
			OnClick = function()
				local fresh = if schema.new then schema.new(#list + 1) else {}
				if typeof(fresh.id) == "string" then
					fresh.id = Catalog.uniqueId(fresh.id, takenIds(list))
				end
				table.insert(list, fresh)
				store.expanded[fresh] = true
				changedStructure()
				selected:set(fresh)
			end,
		}))
	end

	----------------------------------------------------------------------
	-- sections
	----------------------------------------------------------------------
	local sectionOptions = {}
	for _, entry in Catalog.sections do
		table.insert(sectionOptions, { value = entry.kind, label = entry.label, icon = entry.icon, hint = entry.blurb })
	end

	local function sectionRows(s: any, sections: { any }, depth: number, orderBase: number): { Instance }
		local out = {}
		for index, section in sections do
			local entry = Catalog.sectionByKind[section.kind]
			table.insert(out, nodeRow(s, {
				name = "Section_" .. index,
				item = section,
				list = sections,
				depth = depth,
				order = orderBase + index,
				deleteLabel = section.title or (entry and entry.label) or "section",
				icon = function()
					return if entry then entry.icon else "square"
				end,
				title = function()
					return section.title or (entry and entry.label) or section.kind or "Section"
				end,
				subtitle = function()
					local n, word = firstList(section, entry and entry.fields)
					local kindLabel = if entry then entry.label else tostring(section.kind)
					if word then
						return `{kindLabel} · {countLabel(n, word)}`
					end
					return kindLabel
				end,
				body = function(inner)
					local children = {
						Fields.render(inner, ctx(depth + 1), section, Catalog.F.text("title", "Title"), 1),
					}
					if entry then
						for i, field in entry.fields do
							table.insert(children, Fields.render(inner, ctx(depth + 1), section, field, i + 1))
						end
					else
						table.insert(children, Ui.text(inner, {
							Text = `Unknown section kind "{tostring(section.kind)}". It is kept as-is.`,
							TextSize = Style.Text.Small,
							Color = C.Warning,
							Fill = true,
							Wrap = true,
							LayoutOrder = 2,
						}))
					end
					return children
				end,
			}))
		end
		return out
	end

	local function addSectionButton(s: any, sections: { any }, order: number): Instance
		return addButton(s, "Add section", order, sectionOptions, function(kind)
			local entry = Catalog.sectionByKind[kind]
			if entry == nil then
				return
			end
			local section = entry.new()
			table.insert(sections, section)
			store.expanded[section] = true
			changedStructure()
			selected:set(section)
			store:toast(`Added a {entry.label} section.`, "success")
		end, "Section kinds")
	end

	----------------------------------------------------------------------
	-- screens
	----------------------------------------------------------------------
	local function screenFieldList(screen: any): { any }
		local fields = {}
		for _, field in Catalog.screenFields do
			if field.key == "nav" then
				local navField = table.clone(field)
				navField.onChange = function(item, nav)
					if item.nav == nav then
						return
					end
					if nav == "None" then
						-- flatten tabs into sections
						local sections = item.sections or {}
						for _, tab in item.tabs or {} do
							for _, section in tab.sections or {} do
								table.insert(sections, section)
							end
						end
						item.sections = sections
						item.tabs = nil
					elseif (item.tabs == nil or #item.tabs == 0) then
						item.tabs = {
							{ id = "main", label = item.title or "Main", icon = item.icon or "star", sections = item.sections or {} },
						}
						item.sections = {}
					end
					item.nav = nav
					changedStructure()
				end
				table.insert(fields, navField)
			elseif field.key == "kind" then
				local kindField = table.clone(field)
				kindField.onChange = function(item, kind)
					item.kind = kind
					changedStructure()
				end
				table.insert(fields, kindField)
			else
				table.insert(fields, field)
			end
		end
		for _, field in Catalog.screenKindFields[screen.kind] or {} do
			table.insert(fields, field)
		end
		return fields
	end

	local function screenBody(inner: any, screen: any, depth: number): { Instance }
		local children = {}
		local order = 0
		for _, field in screenFieldList(screen) do
			order += 1
			table.insert(children, Fields.render(inner, ctx(depth + 1), screen, field, order))
		end
		order += 1
		table.insert(children, Fields.inline(inner, "Start screen", inner:New "Frame" {
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			[Children] = {
				Ui.list(inner, "X", 8, "Left", "Center"),
				Ui.switch(inner, {
					Value = inner:Computed(function(use)
						use(rowRevision)
						return store.blueprint ~= nil and store.blueprint.startScreen == screen.id
					end),
					OnChange = function(on)
						store:edit(function(bp)
							bp.startScreen = if on then screen.id else nil
						end)
					end,
				}),
				Ui.text(inner, { Text = "Open when the game starts", TextSize = Style.Text.Caption, Color = C.TextMuted }),
			},
		}, order))

		local hasTabs = screen.nav ~= nil and screen.nav ~= "None" and typeof(screen.tabs) == "table"
		if hasTabs then
			order += 1
			table.insert(children, groupHeader(inner, "Tabs", order, #screen.tabs, addButton(inner, "Add tab", 1, { { value = "tab", direct = true } }, function()
				local tab = Catalog.newTab(screen)
				table.insert(screen.tabs, tab)
				store.expanded[tab] = true
				changedStructure()
				selected:set(tab)
			end)))
			for index, tab in screen.tabs do
				order += 1
				tab.sections = tab.sections or {}
				table.insert(children, nodeRow(inner, {
					name = "Tab_" .. index,
					item = tab,
					list = screen.tabs,
					depth = 0,
					order = order,
					deleteLabel = tab.label or "tab",
					icon = function()
						return tab.icon or "folder-tabs"
					end,
					title = function()
						return tab.label or tab.id or "Tab"
					end,
					subtitle = function()
						return countLabel(#(tab.sections or {}), "section")
					end,
					body = function(tabScope)
						local tabChildren = {}
						for i, field in Catalog.tabFields do
							table.insert(tabChildren, Fields.render(tabScope, ctx(1), tab, field, i))
						end
						table.insert(tabChildren, groupHeader(tabScope, "Sections", 50, #tab.sections, addSectionButton(tabScope, tab.sections, 1)))
						for _, row in sectionRows(tabScope, tab.sections, 0, 50) do
							table.insert(tabChildren, row)
						end
						return tabChildren
					end,
				}))
			end
		else
			screen.sections = screen.sections or {}
			order += 1
			table.insert(children, groupHeader(inner, "Sections", order, #screen.sections, addSectionButton(inner, screen.sections, 1)))
			for _, row in sectionRows(inner, screen.sections, 0, order) do
				table.insert(children, row)
			end
		end
		return children
	end

	-- For the filter: tab / section names inside a screen that match.
	local function screenInnerMatches(screen: any, q: string): { string }
		local found = {}
		local function checkSection(section)
			if typeof(section) ~= "table" then
				return
			end
			local entry = Catalog.sectionByKind[section.kind]
			local label = section.title or (entry and entry.label) or section.kind
			if matches(q, section.title, section.kind, entry and entry.label) and #found < 3 then
				table.insert(found, tostring(label))
			end
		end
		for _, tab in (typeof(screen.tabs) == "table" and screen.tabs) or {} do
			if typeof(tab) == "table" then
				if matches(q, tab.label, tab.id) and #found < 3 then
					table.insert(found, tostring(tab.label or tab.id))
				end
				for _, section in tab.sections or {} do
					checkSection(section)
				end
			end
		end
		for _, section in (typeof(screen.sections) == "table" and screen.sections) or {} do
			checkSection(section)
		end
		return found
	end

	local screenKindOptions = {}
	for _, entry in Catalog.screenKinds do
		table.insert(screenKindOptions, { value = entry.value, label = entry.label, icon = entry.icon, hint = entry.blurb })
	end

	----------------------------------------------------------------------
	-- HUD
	----------------------------------------------------------------------
	local hudOptions = {}
	for _, entry in Catalog.hud do
		table.insert(hudOptions, { value = entry.type, label = entry.label, icon = entry.icon, hint = entry.blurb })
	end

	local function hudTitle(element: any): string
		local entry = Catalog.hudByType[element.type]
		local label = if entry then entry.label else tostring(element.type)
		local custom = element.label or element.title or element.text or element.name
		if typeof(custom) == "string" and custom ~= "" and #custom <= 24 then
			return `{label}: {custom}`
		end
		return label
	end

	local function hudRow(s: any, element: any, index: number, elements: { any }, order: number): Frame
		local entry = Catalog.hudByType[element.type]
		return nodeRow(s, {
			name = "Hud_" .. index,
			item = element,
			list = elements,
			depth = 0,
			order = order,
			deleteLabel = if entry then entry.label else tostring(element.type),
			icon = function()
				return if entry then entry.icon else "square"
			end,
			title = function()
				return hudTitle(element)
			end,
			subtitle = function()
				local n, word = firstList(element, entry and entry.fields)
				local anchor = element.anchor or "Auto"
				if word then
					return `{anchor} · {countLabel(n, word)}`
				end
				return anchor
			end,
			body = function(inner)
				local children = {
					Fields.render(inner, ctx(1), element, Catalog.F.enum("anchor", "Anchor", Catalog.anchors), 1),
					Fields.render(inner, ctx(1), element, Catalog.F.text("id", "Id"), 2),
				}
				if entry then
					for i, field in entry.fields do
						table.insert(children, Fields.render(inner, ctx(1), element, field, i + 2))
					end
					if #entry.fields == 0 then
						table.insert(children, Ui.text(inner, {
							Text = "No settings. It follows the game automatically.",
							TextSize = Style.Text.Caption,
							Color = C.TextMuted,
							Fill = true,
							LayoutOrder = 99,
						}))
					end
				else
					table.insert(children, Ui.text(inner, {
						Text = `Unknown element type "{tostring(element.type)}". It is kept as-is.`,
						TextSize = Style.Text.Small,
						Color = C.Warning,
						Fill = true,
						Wrap = true,
						LayoutOrder = 99,
					}))
				end
				return children
			end,
		})
	end

	----------------------------------------------------------------------
	-- the tree
	----------------------------------------------------------------------
	local tree = scope:Computed(function(use, inner)
		use(treeStructure)
		use(treeVersion)
		local q = use(query)
		local bp = store.blueprint
		if bp == nil then
			return nil
		end
		bp.hud = bp.hud or { layout = "Classic", elements = {} }
		bp.hud.elements = bp.hud.elements or {}
		bp.screens = bp.screens or {}
		bp.currencies = bp.currencies or {}
		local filtering = q ~= ""

		local out = {}
		local order = 0
		local function push(instance)
			order += 1
			if instance then
				instance.LayoutOrder = order
				table.insert(out, instance)
			end
		end
		local shown = 0

		-- HUD
		local hudMatches = {}
		for index, element in bp.hud.elements do
			local entry = Catalog.hudByType[element.type]
			if not filtering or matches(q, hudTitle(element), element.type, element.id, element.anchor, entry and entry.label) then
				table.insert(hudMatches, index)
			end
		end
		shown += #hudMatches
		if not filtering or #hudMatches > 0 then
			push(groupHeader(inner, "HUD", 0, if filtering then `{#hudMatches} of {#bp.hud.elements}` else #bp.hud.elements, {
				addButton(inner, "Add", 2, hudOptions, function(kind)
					local entry = Catalog.hudByType[kind]
					if entry == nil then
						return
					end
					local element = entry.new({ currencies = bp.currencies, screens = bp.screens })
					element.id = element.id or Catalog.uniqueId(string.lower(string.sub(kind, 1, 1)) .. string.sub(kind, 2), takenIds(bp.hud.elements))
					table.insert(bp.hud.elements, element)
					store.expanded[element] = true
					changedStructure()
					selected:set(element)
					store:toast(`Added a {entry.label}.`, "success")
				end, "HUD elements"),
			}))
			if not filtering then
				push(hudLayoutRow(inner, bp))
			end
			if #bp.hud.elements == 0 then
				push(Ui.text(inner, { Text = "No HUD elements. Add one from the catalog.", TextSize = Style.Text.Small, Color = C.TextMuted, Fill = true }))
			end
			for _, index in hudMatches do
				push(hudRow(inner, bp.hud.elements[index], index, bp.hud.elements, 0))
			end
		end

		-- Screens
		local screenMatches = {}
		for index, screen in bp.screens do
			local kindEntry = Catalog.screenKindByValue[screen.kind]
			if not filtering then
				table.insert(screenMatches, { index = index })
			elseif matches(q, screen.title, screen.id, screen.kind, kindEntry and kindEntry.label) then
				table.insert(screenMatches, { index = index })
			else
				local inside = screenInnerMatches(screen, q)
				if #inside > 0 then
					table.insert(screenMatches, { index = index, highlight = "has " .. table.concat(inside, ", ") })
				end
			end
		end
		shown += #screenMatches
		if not filtering or #screenMatches > 0 then
			push(inner:New "Frame" { Name = "Gap1", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 6) })
			push(groupHeader(inner, "Screens", 0, if filtering then `{#screenMatches} of {#bp.screens}` else #bp.screens, addButton(inner, "Add screen", 1, screenKindOptions, function(kind)
				local screen = Catalog.newScreen(kind, bp)
				table.insert(bp.screens, screen)
				store.expanded[screen] = true
				changedStructure()
				selected:set(screen)
				store:toast(`Added the {screen.title} screen.`, "success")
			end, "Screen kinds")))
			if #bp.screens == 0 then
				push(Ui.text(inner, { Text = "No screens yet.", TextSize = Style.Text.Small, Color = C.TextMuted, Fill = true }))
			end
			for _, match in screenMatches do
				local index = match.index
				local screen = bp.screens[index]
				local kindEntry = Catalog.screenKindByValue[screen.kind]
				push(nodeRow(inner, {
					name = "Screen_" .. tostring(screen.id or index),
					item = screen,
					list = bp.screens,
					depth = 0,
					deleteLabel = screen.title or screen.id,
					highlight = match.highlight,
					icon = function()
						return screen.icon or (kindEntry and kindEntry.icon) or "square"
					end,
					title = function()
						return screen.title or screen.id or "Screen"
					end,
					subtitle = function()
						local kindLabel = if Catalog.screenKindByValue[screen.kind] then Catalog.screenKindByValue[screen.kind].label else tostring(screen.kind)
						local start = if bp.startScreen == screen.id then " · start" else ""
						if screen.nav and screen.nav ~= "None" and screen.tabs then
							return `{kindLabel} · {countLabel(#screen.tabs, "tab")}{start}`
						end
						return `{kindLabel} · {countLabel(#(screen.sections or {}), "section")}{start}`
					end,
					extraMenu = {
						{
							label = "Show in preview",
							icon = "eye",
							run = function()
								store.previewScreen:set(screen.id)
								if not peek(store.previewOn) then
									store.previewOn:set(true)
								end
							end,
						},
					},
					body = function(bodyScope)
						return screenBody(bodyScope, screen, 0)
					end,
				}))
			end
		end

		-- Currencies
		local currencyMatches = {}
		for index, currency in bp.currencies do
			if not filtering or matches(q, currency.name, currency.id) then
				table.insert(currencyMatches, index)
			end
		end
		shown += #currencyMatches
		if not filtering or #currencyMatches > 0 then
			push(inner:New "Frame" { Name = "Gap2", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 6) })
			push(groupHeader(inner, "Currencies", 0, if filtering then `{#currencyMatches} of {#bp.currencies}` else #bp.currencies, addButton(inner, "Add currency", 1, { { value = "currency", direct = true } }, function()
				local id = Catalog.uniqueId("coins", takenIds(bp.currencies))
				local currency = { id = id, name = "Coins", icon = "coin", color = "#FFD21F", start = 0, format = "abbrev", source = { kind = "leaderstat", name = "Coins" } }
				table.insert(bp.currencies, currency)
				store.expanded[currency] = true
				changedStructure()
				selected:set(currency)
			end)))
			for _, index in currencyMatches do
				local currency = bp.currencies[index]
				push(nodeRow(inner, {
					name = "Currency_" .. tostring(currency.id or index),
					item = currency,
					list = bp.currencies,
					depth = 0,
					canHide = false,
					deleteLabel = currency.name or currency.id,
					icon = function()
						return currency.icon or "coin"
					end,
					title = function()
						return currency.name or currency.id or "Currency"
					end,
					subtitle = function()
						local source = currency.source
						if typeof(source) == "table" and source.kind and source.kind ~= "none" then
							return `{currency.id} · {source.kind} {source.name or ""}`
						end
						return tostring(currency.id or "")
					end,
					body = function(bodyScope)
						local children = {}
						for i, field in CURRENCY_FIELDS do
							table.insert(children, Fields.render(bodyScope, ctx(1), currency, field, i))
						end
						return children
					end,
				}))
			end
		end

		if filtering and shown == 0 then
			push(Ui.empty(inner, {
				Name = "NoMatches",
				Icon = "magnifying-glass",
				Title = "Nothing matches",
				Body = `No element, screen, section or currency contains “{q}”.`,
				Padding = S.L,
				Action = Ui.button(inner, {
					Text = "Clear filter",
					Variant = "secondary",
					Size = "sm",
					LayoutOrder = 6,
					OnClick = function()
						query:set("")
						filterText:set("")
					end,
				}),
			}))
		end
		return out
	end)

	content = Ui.column(scope, {
		Name = "StructureContent",
		Gap = 2,
		Visible = store.hasBlueprint,
		[Children] = tree,
	})

	local empty = Ui.empty(scope, {
		Name = "StructureEmpty",
		Icon = "three-box-flowchart",
		Title = "No structure yet",
		Body = "Generate a UI first. Then show, hide, rename and reorder every HUD element, screen, section and button here.",
		Visible = scope:Computed(function(use)
			return not use(store.hasBlueprint)
		end),
		Action = Ui.button(scope, {
			Text = "Go to Create",
			Icon = "cursor-stars",
			Variant = "primary",
			LayoutOrder = 6,
			OnClick = function()
				store.tab:set("Create")
			end,
		}),
	})

	----------------------------------------------------------------------
	-- toolbar (filter + collapse), pinned above the tree
	----------------------------------------------------------------------
	local filterToken = 0
	local toolbar = scope:New "Frame" {
		Name = "Toolbar",
		BackgroundColor3 = C.Bg,
		BorderSizePixel = 0,
		Size = UDim2.new(1, 0, 0, TOOLBAR),
		Visible = store.hasBlueprint,
		ZIndex = 2,
		[Children] = {
			Ui.padding(scope, S.S, S.M, S.XS, S.M),
			Ui.list(scope, "X", 6, "Left", "Center"),
			Ui.input(scope, {
				Name = "StructureFilter",
				Value = filterText,
				Placeholder = "Filter elements, screens, sections…",
				Icon = "magnifying-glass",
				Height = 32,
				Size = UDim2.new(0, 0, 0, 32),
				LayoutOrder = 1,
				BoxRef = function(box)
					filterBox = box
				end,
				OnInput = function(text)
					filterText:set(text)
					-- debounce: rebuilding a big tree on every keystroke is wasteful
					filterToken += 1
					local token = filterToken
					task.delay(0.15, function()
						if token == filterToken then
							query:set(string.lower(Store.trim(text)))
						end
					end)
				end,
				OnCommit = function(text)
					filterToken += 1
					query:set(string.lower(Store.trim(text)))
				end,
				TrailingWidth = 26,
				Trailing = Ui.iconButton(scope, {
					Name = "ClearFilter",
					Icon = "x-small",
					Size = 24,
					IconSize = 14,
					Tooltip = "Clear filter",
					Visible = scope:Computed(function(use)
						return use(filterText) ~= ""
					end),
					OnClick = function()
						filterToken += 1
						filterText:set("")
						query:set("")
					end,
				}),
			}),
			Ui.iconButton(scope, {
				Name = "CollapseAll",
				Icon = "double-chevron-large-condensed-up",
				Size = 32,
				IconSize = 16,
				Filled = false,
				Tooltip = "Collapse everything",
				LayoutOrder = 2,
				OnClick = function()
					store.expanded = setmetatable({}, { __mode = "k" })
					treeVersion:set(peek(treeVersion) + 1)
				end,
			}),
		},
	}
	-- the filter box fills the row
	local filterInput = toolbar:FindFirstChild("StructureFilter")
	if filterInput then
		Ui.flex(scope, "Fill").Parent = filterInput
	end

	page = Ui.scroll(scope, {
		Name = "StructurePage",
		Gap = S.S,
		Padding = { S.XS, S.M, S.XL, S.M },
		Position = UDim2.fromOffset(0, 0),
		Size = UDim2.fromScale(1, 1),
		[Children] = { empty, content },
	})
	-- leave room for the toolbar when there is a tree to filter
	local function placePage()
		local hasTree = peek(store.hasBlueprint)
		page.Position = UDim2.fromOffset(0, if hasTree then TOOLBAR else 0)
		page.Size = UDim2.new(1, 0, 1, if hasTree then -TOOLBAR else 0)
	end
	table.insert(scope, scope:Observer(store.hasBlueprint):onBind(placePage))

	----------------------------------------------------------------------
	-- keyboard
	----------------------------------------------------------------------
	-- Row entries in on-screen order (walks the tree by LayoutOrder).
	local function visibleRows(): { any }
		local out = {}
		local function walk(inst: Instance)
			local kids = {}
			for index, child in inst:GetChildren() do
				if child:IsA("GuiObject") and child.Visible then
					table.insert(kids, { child = child, order = child.LayoutOrder, index = index })
				end
			end
			table.sort(kids, function(a, b)
				if a.order ~= b.order then
					return a.order < b.order
				end
				return a.index < b.index
			end)
			for _, kid in kids do
				local entry = rows[kid.child]
				if entry then
					table.insert(out, entry)
				end
				walk(kid.child)
			end
		end
		walk(content)
		return out
	end

	local function scrollTo(entry: any)
		local header = entry.header
		local top = header.AbsolutePosition.Y - page.AbsolutePosition.Y
		local bottom = top + header.AbsoluteSize.Y
		local view = page.AbsoluteSize.Y
		if top < 0 then
			page.CanvasPosition = Vector2.new(0, math.max(0, page.CanvasPosition.Y + top - 8))
		elseif bottom > view then
			page.CanvasPosition = Vector2.new(0, page.CanvasPosition.Y + (bottom - view) + 8)
		end
	end

	local function selectedEntry(list: { any }): (any, number?)
		local item = peek(selected)
		for index, entry in list do
			if entry.item == item then
				return entry, index
			end
		end
		return nil, nil
	end

	app.structure = {
		visibleRows = visibleRows,
		selected = selected,
		query = query,
		dropIndex = dropIndex,
		startDrag = function(item)
			for _, entry in visibleRows() do
				if entry.item == item then
					startDrag(entry)
				end
			end
		end,
		updateDrag = updateDrag,
		endDrag = endDrag,
	}

	app.keyHandlers.Structure = function(key: string, mods: any): boolean
		if not peek(store.hasBlueprint) then
			return false
		end
		if key == "Slash" or (mods.ctrl and key == "F") then
			if filterBox then
				pcall(filterBox.CaptureFocus, filterBox)
			end
			return true
		end
		local list = visibleRows()
		if #list == 0 then
			return false
		end
		local entry, index = selectedEntry(list)
		if key == "Down" or key == "Up" then
			if mods.alt and entry then
				entry.move(if key == "Up" then -1 else 1)
				return true
			end
			local nextIndex
			if index == nil then
				nextIndex = if key == "Down" then 1 else #list
			else
				nextIndex = math.clamp(index + (if key == "Down" then 1 else -1), 1, #list)
			end
			selected:set(list[nextIndex].item)
			scrollTo(list[nextIndex])
			return true
		elseif key == "Escape" then
			selected:set(nil)
			return true
		end
		if entry == nil then
			return false
		end
		if key == "Right" then
			entry.setExpanded(true)
		elseif key == "Left" then
			entry.setExpanded(false)
		elseif key == "Return" or key == "Space" then
			entry.toggle()
		elseif key == "H" and not mods.ctrl then
			entry.hide()
		elseif key == "D" and mods.ctrl then
			entry.duplicate()
		elseif key == "Delete" or key == "Backspace" then
			local after = list[(index or 1) + 1] or list[(index or 1) - 1]
			entry.remove()
			selected:set(if after then after.item else nil)
		else
			return false
		end
		return true
	end

	return scope:New "Frame" {
		Name = "StructureView",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		[Children] = { toolbar, page },
	}
end
]]></string>
          </Properties>
        </Item>
      </Item>
      <Item class="ModuleScript" referent="27">
        <Properties>
          <string name="Name">Ui</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Ui: the small component kit the UI Builder widget is made of.

	Every function is `Ui.name(scope, props) -> Instance` (Fusion 0.3 scopes).
	Any prop may be a plain value or a Fusion state. Colours, fonts and
	spacing come from Style, never from the global BloxUI Theme.

		local card = Ui.card(scope, {
			Title = "Describe your game",
			[Children] = {
				Ui.input(scope, { Value = prompt, MultiLine = true, Height = 96, OnInput = function(t) prompt:set(t) end }),
				Ui.button(scope, { Text = "Generate UI", Icon = "cursor-stars", Variant = "primary", OnClick = generate }),
			},
		})

	Hover / press states switch instantly (so they are always correct even if
	Studio's edit mode doesn't run animations); motion is only ever cosmetic.
]]

local TweenService = game:GetService("TweenService")
local TextService = game:GetService("TextService")

local Deps = require(script.Parent.Deps)
local Style = require(script.Parent.Style)

local Fusion = Deps.Fusion
local Icons = Deps.Icons

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local OnChange = Fusion.OnChange
local peek = Fusion.peek

local C = Style.Color
local S = Style.Space
local R = Style.Radius

local Ui = {}

-- Set by the builder once the overlay layer exists (menus, tooltips, pickers).
Ui.overlay = nil :: any

Ui.Children = Children
Ui.OnEvent = OnEvent
Ui.OnChange = OnChange
Ui.peek = peek

--------------------------------------------------------------------------
-- helpers
--------------------------------------------------------------------------

local function merge(...: any): { [any]: any }
	local out = {}
	for i = 1, select("#", ...) do
		local t = select(i, ...)
		if t then
			for k, v in t do
				out[k] = v
			end
		end
	end
	return out
end
Ui.merge = merge

-- Common layout props copied onto a root instance when given.
local LAYOUT_KEYS = { "Name", "Position", "AnchorPoint", "LayoutOrder", "Visible", "ZIndex", "Rotation" }
local function layout(props: { [any]: any }, defaults: { [any]: any }?): { [any]: any }
	local out = {}
	if defaults then
		for k, v in defaults do
			out[k] = v
		end
	end
	for _, key in LAYOUT_KEYS do
		if props[key] ~= nil then
			out[key] = props[key]
		end
	end
	return out
end
Ui.layout = layout

local tweenTokens = setmetatable({}, { __mode = "k" })

-- Cosmetic tween with a guaranteed end state: if tweens don't run (e.g. an
-- edit-mode quirk), the goal values are applied directly shortly after.
function Ui.tween(inst: Instance, goals: { [string]: any }, time: number?, easing: Enum.EasingStyle?)
	local duration = time or 0.16
	local token = {}
	tweenTokens[inst] = token
	local ok = pcall(function()
		TweenService:Create(inst, TweenInfo.new(duration, easing or Enum.EasingStyle.Quad, Enum.EasingDirection.Out), goals):Play()
	end)
	if not ok then
		for key, value in goals do
			(inst :: any)[key] = value
		end
		return
	end
	task.delay(duration + 0.05, function()
		if tweenTokens[inst] == token and inst.Parent ~= nil then
			for key, value in goals do
				pcall(function()
					(inst :: any)[key] = value
				end)
			end
		end
	end)
end

-- An inner scope that can be cleaned up early, safely: cleaning it twice (or
-- after its parent scope was cleaned) is a no-op instead of a Fusion error.
function Ui.innerScope(scope: any): (any, { alive: boolean })
	local inner = scope:innerScope()
	local life = { alive = true }
	table.insert(inner, function()
		life.alive = false
	end)
	return inner, life
end

function Ui.cleanup(inner: any, life: { alive: boolean })
	if life.alive then
		life.alive = false
		Fusion.doCleanup(inner)
	end
end

function Ui.corner(scope: any, radius: number?): UICorner
	return scope:New "UICorner" { CornerRadius = UDim.new(0, radius or R.M) }
end

function Ui.stroke(scope: any, color: any, thickness: number?, transparency: any?): UIStroke
	return scope:New "UIStroke" {
		Color = color or C.Border,
		Thickness = thickness or 1,
		Transparency = transparency or 0,
		ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
	}
end

function Ui.padding(scope: any, top: number, right: number?, bottom: number?, left: number?): UIPadding
	local r = right or top
	return scope:New "UIPadding" {
		PaddingTop = UDim.new(0, top),
		PaddingRight = UDim.new(0, r),
		PaddingBottom = UDim.new(0, bottom or top),
		PaddingLeft = UDim.new(0, left or r),
	}
end

function Ui.list(scope: any, direction: string?, gap: number?, halign: string?, valign: string?, wraps: boolean?): UIListLayout
	return scope:New "UIListLayout" {
		FillDirection = if direction == "X" then Enum.FillDirection.Horizontal else Enum.FillDirection.Vertical,
		Padding = UDim.new(0, gap or 0),
		SortOrder = Enum.SortOrder.LayoutOrder,
		HorizontalAlignment = Enum.HorizontalAlignment[halign or "Left"],
		VerticalAlignment = Enum.VerticalAlignment[valign or (if direction == "X" then "Center" else "Top")],
		Wraps = wraps or false,
	}
end

function Ui.flex(scope: any, mode: string?): UIFlexItem
	return scope:New "UIFlexItem" { FlexMode = Enum.UIFlexMode[mode or "Fill"] }
end

--------------------------------------------------------------------------
-- icons
--------------------------------------------------------------------------

-- "glyph" (Builder Icons name), "image", "text" (emoji), "unknown" or "none".
function Ui.resolveIcon(icon: any): (string, string)
	if icon == nil or icon == "" or icon == false then
		return "none", ""
	end
	if typeof(icon) == "number" then
		return "image", "rbxassetid://" .. tostring(icon)
	end
	if typeof(icon) ~= "string" then
		return "none", ""
	end
	if string.find(icon, "^rbx") or string.find(icon, "^https?://") then
		return "image", icon
	end
	local name = Icons.alias[icon] or icon
	if Icons.names[name] then
		return "glyph", name
	end
	if string.find(name, "[\128-\255]") then
		return "text", name
	end
	return "unknown", name
end

function Ui.icon(scope: any, props: { [any]: any }): TextLabel
	local size = props.Size or 16
	local info = scope:Computed(function(use)
		local kind, value = Ui.resolveIcon(use(props.Icon))
		return { kind = kind, value = value }
	end)
	return scope:New "TextLabel" (merge(layout(props, { Name = "Icon" }), {
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(size, size),
		Text = scope:Computed(function(use)
			local resolved = use(info)
			if resolved.kind == "glyph" or resolved.kind == "text" then
				return resolved.value
			elseif resolved.kind == "image" then
				return "image"
			elseif resolved.kind == "unknown" then
				return "circle-question"
			end
			return ""
		end),
		FontFace = scope:Computed(function(use)
			if use(info).kind == "text" then
				return Style.Font.Regular
			end
			return if use(props.Bold) then Style.IconFont.Bold else Style.IconFont.Regular
		end),
		TextSize = size,
		TextColor3 = props.Color or C.TextSub,
		TextTransparency = props.Transparency or 0,
		TextXAlignment = Enum.TextXAlignment.Center,
		TextYAlignment = Enum.TextYAlignment.Center,
	}))
end

--------------------------------------------------------------------------
-- text
--------------------------------------------------------------------------

function Ui.text(scope: any, props: { [any]: any }): TextLabel
	local fill = props.Fill
	local size = props.Size
	local automatic = props.AutomaticSize
	if size == nil then
		if fill then
			size = UDim2.new(1, 0, 0, 0)
			automatic = automatic or Enum.AutomaticSize.Y
		else
			size = UDim2.new()
			automatic = automatic or Enum.AutomaticSize.XY
		end
	end
	return scope:New "TextLabel" (merge(layout(props, { Name = "Text" }), {
		BackgroundTransparency = 1,
		Size = size,
		AutomaticSize = automatic or Enum.AutomaticSize.None,
		Text = props.Text or "",
		RichText = props.RichText or false,
		FontFace = props.Font or Style.Font[props.Weight or "Regular"],
		TextSize = props.TextSize or Style.Text.Body,
		TextColor3 = props.Color or C.Text,
		TextTransparency = props.Transparency or 0,
		TextXAlignment = props.XAlign or Enum.TextXAlignment.Left,
		TextYAlignment = props.YAlign or Enum.TextYAlignment.Center,
		TextWrapped = props.Wrap or false,
		TextTruncate = if props.Truncate then Enum.TextTruncate.AtEnd else Enum.TextTruncate.None,
		LineHeight = props.LineHeight or 1,
		[Children] = props[Children],
	}))
end

-- Small uppercase label above a group ("STYLE", "HUD").
function Ui.overline(scope: any, props: { [any]: any }): Frame
	return scope:New "Frame" (merge(layout(props, { Name = "Overline" }), {
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, 20),
		[Children] = {
			Ui.list(scope, "X", S.S, "Left", "Center"),
			Ui.text(scope, {
				Text = scope:Computed(function(use)
					return string.upper(tostring(use(props.Text) or ""))
				end),
				Weight = "SemiBold",
				TextSize = Style.Text.Caption,
				Color = C.TextMuted,
				LayoutOrder = 1,
			}),
			if props.Count
				then Ui.text(scope, {
					Text = props.Count,
					TextSize = Style.Text.Caption,
					Color = C.TextDisabled,
					Weight = "Medium",
					LayoutOrder = 2,
				})
				else nil,
			scope:New "Frame" {
				Name = "Spacer",
				BackgroundTransparency = 1,
				Size = UDim2.new(0, 0, 0, 1),
				LayoutOrder = 3,
				[Children] = Ui.flex(scope, "Fill"),
			},
			if props.Accessory
				then scope:New "Frame" {
					Name = "Accessory",
					BackgroundTransparency = 1,
					AutomaticSize = Enum.AutomaticSize.XY,
					Size = UDim2.new(),
					LayoutOrder = 4,
					[Children] = { Ui.list(scope, "X", S.XS, "Right", "Center"), props.Accessory },
				}
				else nil,
		},
	}))
end

--------------------------------------------------------------------------
-- interaction
--------------------------------------------------------------------------

-- Returns a Computed state ("idle" | "hover" | "press" | "disabled") and the
-- event handlers to spread onto a GuiButton.
function Ui.interact(scope: any, disabled: any?): (any, { [any]: any })
	local hovered = scope:Value(false)
	local pressed = scope:Value(false)
	local events = {
		[OnEvent "MouseEnter"] = function()
			hovered:set(true)
		end,
		[OnEvent "MouseLeave"] = function()
			hovered:set(false)
			pressed:set(false)
		end,
		[OnEvent "MouseButton1Down"] = function()
			pressed:set(true)
		end,
		[OnEvent "MouseButton1Up"] = function()
			pressed:set(false)
		end,
	}
	local state = scope:Computed(function(use)
		if disabled ~= nil and use(disabled) then
			return "disabled"
		elseif use(pressed) then
			return "press"
		elseif use(hovered) then
			return "hover"
		end
		return "idle"
	end)
	return state, events
end

-- Shows `text` in a small tooltip after hovering `gui` for a moment.
function Ui.tooltip(scope: any, gui: GuiObject, text: any)
	local token = 0
	local function hide()
		token += 1
		if Ui.overlay then
			Ui.overlay:hideTooltip(gui)
		end
	end
	table.insert(
		scope,
		gui.MouseEnter:Connect(function()
			token += 1
			local mine = token
			task.delay(0.45, function()
				if mine == token and Ui.overlay and gui.Parent ~= nil then
					local value = peek(text)
					if value and value ~= "" then
						Ui.overlay:showTooltip(gui, value)
					end
				end
			end)
		end)
	)
	table.insert(scope, gui.MouseLeave:Connect(hide))
	if gui:IsA("GuiButton") then
		table.insert(scope, (gui :: GuiButton).Activated:Connect(hide))
	end
	table.insert(scope, hide)
end

--------------------------------------------------------------------------
-- buttons
--------------------------------------------------------------------------

-- Built on demand so they always use the current theme's colours.
local function variantOf(name: string?): { [string]: any }
	if name == "primary" then
		return {
			idle = C.White,
			text = C.OnAccent,
			gradient = { { C.Accent, C.Accent2 }, { C.AccentHover, C.Accent2Hover }, { C.AccentPress, C.Accent2Press } },
		}
	elseif name == "ghost" then
		return {
			idle = C.RowHover,
			hover = C.RowHover,
			press = C.RaisedPress,
			text = C.TextSub,
			textHover = C.Text,
			ghost = true,
		}
	elseif name == "soft" then
		return {
			idle = C.AccentSoft,
			hover = C.AccentSoftHover,
			press = C.AccentSoft,
			text = C.AccentText,
		}
	elseif name == "danger" then
		return {
			idle = C.DangerSoft,
			hover = C.DangerSoftHover,
			press = C.DangerSoftPress,
			text = C.Danger,
		}
	elseif name == "solidDanger" then
		return {
			idle = C.DangerSolid,
			hover = C.DangerSolidHover,
			press = C.DangerSolidPress,
			text = C.White,
		}
	end
	return {
		idle = C.Raised,
		hover = C.RaisedHover,
		press = C.RaisedPress,
		text = C.Text,
		stroke = C.BorderStrong,
	}
end

local SIZES = {
	sm = { height = 26, text = Style.Text.Small, pad = 10, icon = 14, gap = 5 },
	md = { height = 32, text = Style.Text.Body, pad = 12, icon = 16, gap = 6 },
	lg = { height = 38, text = Style.Text.Title, pad = 16, icon = 18, gap = 8 },
}

--[[
	Ui.button(scope, {
		Text = "Generate UI", Icon = "cursor-stars",
		Variant = "primary" | "secondary" | "ghost" | "soft" | "danger" | "solidDanger",
		Size = "sm" | "md" | "lg",
		OnClick = fn, Disabled = state, Busy = state (shows a spinner),
		Fill = true (full width) | Flex = true (fill a row) | Width = px,
		Tooltip = "...",
	})
]]
function Ui.button(scope: any, props: { [any]: any }): TextButton
	local variant = variantOf(props.Variant)
	local size = SIZES[props.Size or "md"] or SIZES.md
	local busy = props.Busy
	local disabled = scope:Computed(function(use)
		return use(props.Disabled) == true or (busy ~= nil and use(busy) == true)
	end)
	local state, events = Ui.interact(scope, disabled)
	local isBusy = scope:Computed(function(use)
		return busy ~= nil and use(busy) == true
	end)

	local background = scope:Computed(function(use)
		local s = use(state)
		if s == "disabled" then
			return if variant.gradient then C.Raised else (variant.idle or C.Raised)
		end
		if variant.gradient then
			return C.White
		end
		return variant[s] or variant.idle
	end)
	local transparency = scope:Computed(function(use)
		local s = use(state)
		if variant.ghost then
			return if s == "hover" or s == "press" then 0 else 1
		end
		if s == "disabled" and variant.gradient then
			return 0
		end
		return 0
	end)
	local textColor = scope:Computed(function(use)
		local s = use(state)
		if s == "disabled" then
			if use(isBusy) and variant.gradient then
				return C.TextSub
			end
			return C.TextDisabled
		end
		if (s == "hover" or s == "press") and variant.textHover then
			return variant.textHover
		end
		return variant.text
	end)

	local iconName = scope:Computed(function(use)
		if use(isBusy) then
			return "arrow-spin-clockwise"
		end
		return use(props.Icon)
	end)
	local hasIcon = scope:Computed(function(use)
		local value = use(iconName)
		return value ~= nil and value ~= ""
	end)
	local hasText = scope:Computed(function(use)
		local value = use(props.Text)
		return value ~= nil and value ~= ""
	end)

	local iconLabel = Ui.icon(scope, {
		Icon = iconName,
		Size = size.icon,
		Color = textColor,
		Visible = hasIcon,
		LayoutOrder = 1,
	})

	local rootSize
	local automatic = Enum.AutomaticSize.X
	if props.Fill then
		rootSize = UDim2.new(1, 0, 0, size.height)
		automatic = Enum.AutomaticSize.None
	elseif props.Width then
		rootSize = UDim2.fromOffset(props.Width, size.height)
		automatic = Enum.AutomaticSize.None
	else
		rootSize = UDim2.fromOffset(0, size.height)
	end

	local button = scope:New "TextButton" (merge(layout(props, { Name = "Button" }), events, {
		Text = "",
		AutoButtonColor = false,
		BackgroundColor3 = background,
		BackgroundTransparency = transparency,
		BorderSizePixel = 0,
		Size = rootSize,
		AutomaticSize = automatic,
		Active = scope:Computed(function(use)
			return not use(disabled)
		end),
		Selectable = false,
		[OnEvent "Activated"] = function()
			if not peek(disabled) and props.OnClick then
				props.OnClick()
			end
		end,
		[Children] = {
			Ui.corner(scope, props.Radius or R.M),
			if variant.stroke
				then Ui.stroke(scope, scope:Computed(function(use)
					return if use(state) == "hover" then C.BorderStrong else C.Border
				end))
				else nil,
			if variant.gradient
				then scope:New "UIGradient" {
					Enabled = scope:Computed(function(use)
						return use(state) ~= "disabled"
					end),
					Color = scope:Computed(function(use)
						local s = use(state)
						local pair = variant.gradient[if s == "hover" then 2 elseif s == "press" then 3 else 1]
						return ColorSequence.new(pair[1], pair[2])
					end),
				}
				else nil,
			if variant.gradient
				then Ui.stroke(scope, C.White, 1, scope:Computed(function(use)
					return if use(state) == "disabled" then 1 else 0.86
				end))
				else nil,
			Ui.padding(scope, 0, size.pad),
			Ui.list(scope, "X", size.gap, props.Align or "Center", "Center"),
			iconLabel,
			Ui.text(scope, {
				Name = "Label",
				Text = scope:Computed(function(use)
					return tostring(use(props.Text) or "")
				end),
				Weight = props.Weight or "SemiBold",
				TextSize = size.text,
				Color = textColor,
				Visible = hasText,
				LayoutOrder = 2,
				Truncate = props.Fill or props.Flex,
			}),
			if props.Flex then Ui.flex(scope, "Fill") else nil,
			props[Children],
		},
	}))

	if busy ~= nil then
		Ui.spin(scope, iconLabel, isBusy)
	end

	if props.Tooltip then
		Ui.tooltip(scope, button, props.Tooltip)
	end
	return button
end

-- Rotates `gui` while `active` (a state) is true; stops with the scope.
function Ui.spin(scope: any, gui: GuiObject, active: any, speed: number?)
	local alive = true
	local spinning = false
	local step = speed or 14
	table.insert(scope, function()
		alive = false
	end)
	local function run()
		if spinning then
			return
		end
		spinning = true
		task.spawn(function()
			while alive and peek(active) do
				gui.Rotation = (gui.Rotation + step) % 360
				task.wait(1 / 30)
			end
			spinning = false
			if alive then
				gui.Rotation = 0
			end
		end)
	end
	table.insert(
		scope,
		scope:Observer(active):onBind(function()
			if peek(active) then
				run()
			end
		end)
	)
end

-- A spinning "busy" glyph. Ui.spinner(scope, { Size = 16, Color = C.AccentText, Active = state })
function Ui.spinner(scope: any, props: { [any]: any }): TextLabel
	local active = props.Active
	if active == nil then
		active = scope:Value(true)
	end
	local label = Ui.icon(scope, Ui.merge(props, {
		Icon = props.Icon or "arrow-spin-clockwise",
		Size = props.Size or 16,
		Color = props.Color or C.AccentText,
		Bold = true,
	}))
	Ui.spin(scope, label, active, props.Speed)
	return label
end

--[[
	Thin progress bar. Value is a state of 0..1, or nil for "indeterminate"
	(a short segment slides back and forth).
	Ui.progress(scope, { Value = state<number?>, Height = 4, Active = state<bool> })
]]
function Ui.progress(scope: any, props: { [any]: any }): Frame
	local height = props.Height or 4
	local value = props.Value
	local indeterminate = scope:Computed(function(use)
		return typeof(use(value)) ~= "number"
	end)
	local fill = scope:New "Frame" {
		Name = "Fill",
		BackgroundColor3 = C.White,
		BorderSizePixel = 0,
		Size = scope:Computed(function(use)
			local v = use(value)
			if typeof(v) == "number" then
				return UDim2.new(math.clamp(v, 0, 1), 0, 1, 0)
			end
			return UDim2.new(0.28, 0, 1, 0)
		end),
		[Children] = {
			Ui.corner(scope, R.Pill),
			scope:New "UIGradient" { Color = ColorSequence.new(C.Accent, C.Accent2) },
		},
	}
	-- slide the indeterminate segment (cosmetic)
	local alive = true
	table.insert(scope, function()
		alive = false
	end)
	local sliding = false
	local active = props.Active
	local function slide()
		if sliding then
			return
		end
		sliding = true
		task.spawn(function()
			local t = 0
			while alive and peek(indeterminate) and (active == nil or peek(active)) do
				t += 1 / 30
				local phase = (math.sin(t * 2.4) + 1) / 2
				fill.Position = UDim2.fromScale(phase * 0.72, 0)
				task.wait(1 / 30)
			end
			sliding = false
			if alive then
				fill.Position = UDim2.new()
			end
		end)
	end
	table.insert(
		scope,
		scope:Observer(indeterminate):onBind(function()
			if peek(indeterminate) then
				slide()
			else
				fill.Position = UDim2.new()
			end
		end)
	)
	if active ~= nil then
		table.insert(
			scope,
			scope:Observer(active):onChange(function()
				if peek(active) and peek(indeterminate) then
					slide()
				end
			end)
		)
	end
	return scope:New "Frame" (merge(layout(props, { Name = "Progress" }), {
		BackgroundColor3 = C.Raised,
		BorderSizePixel = 0,
		Size = props.Size or UDim2.new(1, 0, 0, height),
		ClipsDescendants = true,
		[Children] = {
			Ui.corner(scope, R.Pill),
			fill,
		},
	}))
end

-- A small key cap ("Ctrl", "Enter") for shortcut hints.
function Ui.kbd(scope: any, props: { [any]: any }): Frame
	return scope:New "Frame" (merge(layout(props, { Name = "Kbd" }), {
		BackgroundColor3 = C.Raised,
		Size = UDim2.fromOffset(0, props.Height or 18),
		AutomaticSize = Enum.AutomaticSize.X,
		[Children] = {
			Ui.corner(scope, 4),
			Ui.stroke(scope, C.Border),
			Ui.padding(scope, 0, 5),
			Ui.text(scope, {
				Text = props.Text,
				TextSize = 11,
				Weight = "SemiBold",
				Color = C.TextSub,
				Size = UDim2.fromScale(0, 1),
				AutomaticSize = Enum.AutomaticSize.X,
			}),
		},
	}))
end

--[[
	Square icon-only button.
	Ui.iconButton(scope, { Icon = "trash-can", OnClick = fn, Tooltip = "Delete", Active = state, Tone = "danger", Size = 28 })
]]
function Ui.iconButton(scope: any, props: { [any]: any }): TextButton
	local dimension = props.Size or 28
	local disabled = props.Disabled
	local state, events = Ui.interact(scope, disabled)
	local tone = props.Tone
	local active = props.Active
	local button = scope:New "TextButton" (merge(layout(props, { Name = "IconButton" }), events, {
		Text = "",
		AutoButtonColor = false,
		BorderSizePixel = 0,
		Size = UDim2.fromOffset(dimension, dimension),
		BackgroundColor3 = scope:Computed(function(use)
			if active ~= nil and use(active) then
				return C.AccentSoft
			end
			local s = use(state)
			if tone == "danger" and (s == "hover" or s == "press") then
				return C.DangerSoft
			end
			return if s == "press" then C.RaisedPress else C.RowHover
		end),
		BackgroundTransparency = scope:Computed(function(use)
			if active ~= nil and use(active) then
				return 0
			end
			local s = use(state)
			return if s == "hover" or s == "press" then 0 else (if props.Filled then 0 else 1)
		end),
		Active = scope:Computed(function(use)
			return not (disabled ~= nil and use(disabled))
		end),
		[OnEvent "Activated"] = function()
			if not (disabled ~= nil and peek(disabled)) and props.OnClick then
				props.OnClick()
			end
		end,
		[Children] = {
			Ui.corner(scope, props.Radius or R.S),
			Ui.icon(scope, {
				Icon = props.Icon,
				Size = props.IconSize or math.floor(dimension * 0.57),
				Bold = props.Bold,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Color = scope:Computed(function(use)
					local s = use(state)
					if s == "disabled" then
						return C.TextDisabled
					end
					if active ~= nil and use(active) then
						return C.AccentText
					end
					if tone == "danger" and (s == "hover" or s == "press") then
						return C.Danger
					end
					if props.Color then
						return use(props.Color)
					end
					return if s == "hover" or s == "press" then C.Text else C.TextMuted
				end),
			}),
			props[Children],
		},
	}))
	if props.Tooltip then
		Ui.tooltip(scope, button, props.Tooltip)
	end
	return button
end

-- Pill chip (example prompts, filters).
function Ui.chip(scope: any, props: { [any]: any }): TextButton
	local state, events = Ui.interact(scope, props.Disabled)
	local selected = props.Selected
	return scope:New "TextButton" (merge(layout(props, { Name = "Chip" }), events, {
		Text = "",
		AutoButtonColor = false,
		BorderSizePixel = 0,
		Size = UDim2.fromOffset(0, props.Height or 26),
		AutomaticSize = Enum.AutomaticSize.X,
		BackgroundColor3 = scope:Computed(function(use)
			if selected ~= nil and use(selected) then
				return C.AccentSoft
			end
			local s = use(state)
			return if s == "hover" then C.RaisedHover elseif s == "press" then C.RaisedPress else C.Raised
		end),
		[OnEvent "Activated"] = function()
			if props.OnClick and not (props.Disabled and peek(props.Disabled)) then
				props.OnClick()
			end
		end,
		[Children] = {
			Ui.corner(scope, R.Pill),
			Ui.stroke(scope, scope:Computed(function(use)
				if selected ~= nil and use(selected) then
					return C.Accent
				end
				return if use(state) == "hover" then C.BorderStrong else C.Border
			end), 1, scope:Computed(function(use)
				return if selected ~= nil and use(selected) then 0.4 else 0
			end)),
			Ui.padding(scope, 0, 10),
			Ui.list(scope, "X", 5, "Center", "Center"),
			if props.Icon
				then Ui.icon(scope, {
					Icon = props.Icon,
					Size = 13,
					LayoutOrder = 1,
					Color = scope:Computed(function(use)
						if selected ~= nil and use(selected) then
							return C.AccentText
						end
						return if use(state) == "hover" then C.Text else C.TextMuted
					end),
				})
				else nil,
			if props.Swatch
				then scope:New "Frame" {
					Name = "Swatch",
					Size = UDim2.fromOffset(10, 10),
					BackgroundColor3 = props.Swatch,
					LayoutOrder = 1,
					[Children] = Ui.corner(scope, R.Pill),
				}
				else nil,
			Ui.text(scope, {
				Text = props.Text,
				TextSize = props.TextSize or Style.Text.Small,
				Weight = "Medium",
				LayoutOrder = 2,
				Color = scope:Computed(function(use)
					if selected ~= nil and use(selected) then
						return C.AccentText
					end
					return if use(state) == "hover" then C.Text else C.TextSub
				end),
			}),
			props[Children],
		},
	}))
end

--------------------------------------------------------------------------
-- containers
--------------------------------------------------------------------------

function Ui.row(scope: any, props: { [any]: any }): Frame
	return scope:New "Frame" (merge(layout(props, { Name = "Row" }), {
		BackgroundTransparency = 1,
		Size = props.Size or UDim2.new(1, 0, 0, props.Height or 0),
		AutomaticSize = if props.Height or props.Size then Enum.AutomaticSize.None else Enum.AutomaticSize.Y,
		[Children] = {
			Ui.list(scope, "X", props.Gap or S.S, props.Align or "Left", props.VAlign or "Center", props.Wrap),
			props[Children],
		},
	}))
end

function Ui.column(scope: any, props: { [any]: any }): Frame
	return scope:New "Frame" (merge(layout(props, { Name = "Column" }), {
		BackgroundTransparency = 1,
		Size = props.Size or UDim2.new(1, 0, 0, 0),
		AutomaticSize = props.AutomaticSize or Enum.AutomaticSize.Y,
		[Children] = {
			Ui.list(scope, "Y", props.Gap or S.S, props.Align or "Left", "Top"),
			if props.Padding then Ui.padding(scope, table.unpack(props.Padding)) else nil,
			if props.Flex then Ui.flex(scope, props.Flex) else nil,
			props[Children],
		},
	}))
end

-- A spacer that grows to fill a row.
function Ui.spacer(scope: any, order: number?): Frame
	return scope:New "Frame" {
		Name = "Spacer",
		BackgroundTransparency = 1,
		Size = UDim2.new(0, 0, 0, 1),
		LayoutOrder = order or 0,
		[Children] = Ui.flex(scope, "Fill"),
	}
end

function Ui.divider(scope: any, props: { [any]: any }?): Frame
	props = props or {}
	return scope:New "Frame" (merge(layout(props :: any, { Name = "Divider" }), {
		BackgroundColor3 = C.Divider,
		BorderSizePixel = 0,
		Size = UDim2.new(1, 0, 0, 1),
	}))
end

-- Rounded square with an icon (card headers, empty states).
function Ui.iconBubble(scope: any, props: { [any]: any }): Frame
	local size = props.Size or 28
	return scope:New "Frame" (merge(layout(props, { Name = "IconBubble" }), {
		BackgroundColor3 = props.Background or C.AccentSoft,
		Size = UDim2.fromOffset(size, size),
		[Children] = {
			Ui.corner(scope, props.Radius or R.M),
			Ui.icon(scope, {
				Icon = props.Icon,
				Size = props.IconSize or math.floor(size * 0.55),
				Color = props.Color or C.AccentText,
				Bold = props.Bold,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
			}),
		},
	}))
end

--[[
	Card with an optional header.
	Ui.card(scope, { Title, Subtitle, Icon, Accessory = instance(s), Gap, [Children] = {...} })
]]
function Ui.card(scope: any, props: { [any]: any }): Frame
	local header = nil
	if props.Title then
		-- Fixed height and single-line titles: nothing here depends on the
		-- width the flex layout ends up giving the titles column.
		local hasSubtitle = props.Subtitle ~= nil
		header = scope:New "Frame" {
			Name = "Header",
			BackgroundTransparency = 1,
			Size = UDim2.new(1, 0, 0, if hasSubtitle then 36 else 28),
			LayoutOrder = -1000,
			[Children] = {
				Ui.list(scope, "X", 10, "Left", "Center"),
				if props.Icon then Ui.iconBubble(scope, { Icon = props.Icon, Size = 30, LayoutOrder = 1 }) else nil,
				scope:New "Frame" {
					Name = "Titles",
					BackgroundTransparency = 1,
					Size = UDim2.new(0, 0, 1, 0),
					LayoutOrder = 2,
					[Children] = {
						Ui.flex(scope, "Fill"),
						Ui.list(scope, "Y", 1, "Left", "Center"),
						Ui.text(scope, {
							Name = "Title",
							Text = props.Title,
							Weight = "SemiBold",
							TextSize = Style.Text.Title,
							Size = UDim2.new(1, 0, 0, 19),
							Truncate = true,
							LayoutOrder = 1,
						}),
						if hasSubtitle
							then Ui.text(scope, {
								Name = "Subtitle",
								Text = props.Subtitle,
								TextSize = Style.Text.Caption,
								Color = C.TextMuted,
								Size = UDim2.new(1, 0, 0, 15),
								Truncate = true,
								LayoutOrder = 2,
							})
							else nil,
					},
				},
				if props.Accessory
					then scope:New "Frame" {
						Name = "Accessory",
						BackgroundTransparency = 1,
						Size = UDim2.new(),
						AutomaticSize = Enum.AutomaticSize.XY,
						LayoutOrder = 3,
						[Children] = { Ui.list(scope, "X", S.XS, "Right", "Center"), props.Accessory },
					}
					else nil,
			},
		}
	end
	return scope:New "Frame" (merge(layout(props, { Name = "Card" }), {
		BackgroundColor3 = props.Background or C.Card,
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		[Children] = {
			Ui.corner(scope, R.L),
			Ui.stroke(scope, props.Stroke or C.Border),
			Ui.padding(scope, props.Padding or S.L),
			Ui.list(scope, "Y", props.Gap or S.M),
			header,
			props[Children],
		},
	}))
end

-- Label + control + hint.
function Ui.field(scope: any, props: { [any]: any }): Frame
	return scope:New "Frame" (merge(layout(props, { Name = "Field" }), {
		BackgroundTransparency = 1,
		Size = props.Size or UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		[Children] = {
			Ui.list(scope, "Y", 6),
			if props.Label or props.Accessory
				then scope:New "Frame" {
					Name = "LabelRow",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, 0, 0, 16),
					LayoutOrder = 1,
					[Children] = {
						Ui.list(scope, "X", S.S, "Left", "Center"),
						Ui.text(scope, {
							Name = "Label",
							Text = props.Label or "",
							TextSize = Style.Text.Small,
							Weight = "Medium",
							Color = C.TextSub,
							LayoutOrder = 1,
						}),
						Ui.spacer(scope, 2),
						props.Accessory,
					},
				}
				else nil,
			scope:New "Frame" {
				Name = "Control",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				LayoutOrder = 2,
				[Children] = { Ui.list(scope, "Y", 6), props[Children] },
			},
			if props.Hint
				then Ui.text(scope, {
					Name = "Hint",
					Text = props.Hint,
					TextSize = Style.Text.Caption,
					Color = props.HintColor or C.TextMuted,
					Fill = true,
					Wrap = true,
					LayoutOrder = 3,
					RichText = props.RichHint,
				})
				else nil,
		},
	}))
end

-- Vertically scrolling page.
function Ui.scroll(scope: any, props: { [any]: any }): ScrollingFrame
	return scope:New "ScrollingFrame" (merge(layout(props, { Name = "Scroll" }), {
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		Size = props.Size or UDim2.fromScale(1, 1),
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		ScrollingDirection = Enum.ScrollingDirection.Y,
		ScrollBarThickness = props.ScrollBarThickness or 6,
		ScrollBarImageColor3 = C.BorderStrong,
		ScrollBarImageTransparency = 0.1,
		VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
		ElasticBehavior = Enum.ElasticBehavior.Never,
		TopImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
		MidImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
		BottomImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
		[Children] = {
			if props.Padding ~= false then Ui.padding(scope, table.unpack(props.Padding or { S.L, S.L, S.XL, S.L })) else nil,
			Ui.list(scope, "Y", props.Gap or S.L),
			props[Children],
		},
	}))
end

function Ui.empty(scope: any, props: { [any]: any }): Frame
	return scope:New "Frame" (merge(layout(props, { Name = "Empty" }), {
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		[Children] = {
			Ui.padding(scope, props.Padding or S.XL, S.L),
			Ui.list(scope, "Y", S.S, "Center", "Top"),
			Ui.iconBubble(scope, {
				Icon = props.Icon or "cursor-stars",
				Size = 44,
				Radius = R.XL,
				Background = props.Tone == "danger" and C.DangerSoft or props.Tone == "warning" and C.WarningSoft or C.AccentSoft,
				Color = props.Tone == "danger" and C.Danger or props.Tone == "warning" and C.Warning or C.AccentText,
				LayoutOrder = 1,
			}),
			scope:New "Frame" { Name = "Gap", BackgroundTransparency = 1, Size = UDim2.fromOffset(1, 2), LayoutOrder = 2 },
			Ui.text(scope, {
				Text = props.Title,
				Weight = "SemiBold",
				TextSize = Style.Text.Title,
				XAlign = Enum.TextXAlignment.Center,
				Fill = true,
				Wrap = true,
				LayoutOrder = 3,
			}),
			if props.Body
				then Ui.text(scope, {
					Text = props.Body,
					TextSize = Style.Text.Small,
					Color = C.TextSub,
					XAlign = Enum.TextXAlignment.Center,
					Fill = true,
					Wrap = true,
					LineHeight = 1.1,
					LayoutOrder = 4,
				})
				else nil,
			if props.Action then scope:New "Frame" { Name = "Gap2", BackgroundTransparency = 1, Size = UDim2.fromOffset(1, 4), LayoutOrder = 5 } else nil,
			props.Action,
		},
	}))
end

--------------------------------------------------------------------------
-- small displays
--------------------------------------------------------------------------

-- { bg, text } for a tone name, in the current theme.
function Ui.tone(name: string?): { bg: Color3, text: Color3 }
	if name == "accent" then
		return { bg = C.AccentSoft, text = C.AccentText }
	elseif name == "success" then
		return { bg = C.SuccessSoft, text = C.Success }
	elseif name == "warning" then
		return { bg = C.WarningSoft, text = C.Warning }
	elseif name == "danger" or name == "error" then
		return { bg = C.DangerSoft, text = C.Danger }
	elseif name == "info" then
		return { bg = C.InfoSoft, text = C.Info }
	end
	return { bg = C.Raised, text = C.TextSub }
end

function Ui.badge(scope: any, props: { [any]: any }): Frame
	local tones = {}
	local toneOf = function(use)
		local name = use(props.Tone) or "neutral"
		tones[name] = tones[name] or Ui.tone(name)
		return tones[name]
	end
	return scope:New "Frame" (merge(layout(props, { Name = "Badge" }), {
		Size = UDim2.fromOffset(0, props.Height or 20),
		AutomaticSize = Enum.AutomaticSize.X,
		BackgroundColor3 = scope:Computed(function(use)
			return toneOf(use).bg
		end),
		[Children] = {
			Ui.corner(scope, R.Pill),
			Ui.padding(scope, 0, 8),
			Ui.list(scope, "X", 4, "Center", "Center"),
			if props.Icon
				then Ui.icon(scope, {
					Icon = props.Icon,
					Size = 12,
					Bold = true,
					LayoutOrder = 1,
					Color = scope:Computed(function(use)
						return toneOf(use).text
					end),
				})
				else nil,
			if props.Dot
				then scope:New "Frame" {
					Name = "Dot",
					Size = UDim2.fromOffset(6, 6),
					LayoutOrder = 1,
					BackgroundColor3 = scope:Computed(function(use)
						return toneOf(use).text
					end),
					[Children] = Ui.corner(scope, R.Pill),
				}
				else nil,
			Ui.text(scope, {
				Text = props.Text,
				TextSize = Style.Text.Caption,
				Weight = "SemiBold",
				LayoutOrder = 2,
				Color = scope:Computed(function(use)
					return toneOf(use).text
				end),
			}),
		},
	}))
end

-- Colour dot / square. `Color` is a Color3, "#hex" string or a state of either.
function Ui.swatch(scope: any, props: { [any]: any }): Frame
	local size = props.Size or 16
	return scope:New "Frame" (merge(layout(props, { Name = "Swatch" }), {
		Size = UDim2.fromOffset(size, size),
		BackgroundColor3 = scope:Computed(function(use)
			local value = use(props.Color)
			if typeof(value) == "Color3" then
				return value
			end
			return Style.parseHex(value) or C.Raised
		end),
		[Children] = {
			Ui.corner(scope, props.Radius or R.Pill),
			Ui.stroke(scope, C.Hairline, 1, Style.Tone.hairline),
		},
	}))
end

--------------------------------------------------------------------------
-- inputs
--------------------------------------------------------------------------

--[[
	Text input (single or multi-line).
	Ui.input(scope, {
		Value = state<string>,          -- shown whenever the box isn't focused
		OnCommit = fn(text, enter),     -- focus lost / Enter
		OnInput = fn(text),             -- every keystroke
		Placeholder, MultiLine, Height, Mono, Masked, Icon, ClearOnCommit, Disabled, TextSize,
		SubmitOnEnter = true,           -- wraps like a multi-line box, but Enter commits (enter = true)
		MaxHeight = px,                 -- multi-line: grows with its text from Height up to this, then scrolls
		Revealed = state<bool>,         -- Masked boxes: show what is being typed
		Trailing = instance(s), TrailingWidth = px,   -- controls inside the right edge
		BoxRef = fn(textBox),
	})
]]
Ui.focusedBox = nil :: TextBox?

function Ui.input(scope: any, props: { [any]: any }): Frame
	local focused = scope:Value(false)
	local hovered = scope:Value(false)
	local submit = props.SubmitOnEnter == true
	local multi = props.MultiLine == true or submit
	local height = props.Height or Style.Height.Control
	local padX = 10
	local padY = if multi then 8 else 0
	local masked = props.Masked == true
	local revealed = props.Revealed
	local typed = scope:Value("")
	local trailingWidth = if props.Trailing then (props.TrailingWidth or 30) else 0
	local maxHeight = if multi and type(props.MaxHeight) == "number" and props.MaxHeight > height then props.MaxHeight else nil
	local grown = scope:Value(height)
	local box: TextBox
	local content: any

	local hidden = scope:Computed(function(use)
		return masked and use(typed) ~= "" and not (revealed ~= nil and use(revealed))
	end)

	local function current(): string
		local value = peek(props.Value)
		return if value == nil then "" else tostring(value)
	end

	local function sync()
		if box and not peek(focused) then
			box.Text = if props.ClearOnCommit then "" else current()
		end
	end

	-- MaxHeight: the box grows with its text (a TextBox never scrolls itself, so text past the
	-- visible lines looked like a length limit); measured with the wrap width the text really has
	local measuring = 0
	local function measure()
		if maxHeight == nil or box == nil or content == nil then
			return
		end
		measuring += 1
		local ticket = measuring
		task.spawn(function()
			local width = content.AbsoluteSize.X - padX * 2 - 6 - trailingWidth
			if width <= 0 then
				return
			end
			local factory = GetTextBoundsParams :: any -- Roblox: Instance.new; the test engine: a .new() global
			local params = if factory and factory.new then factory.new() else Instance.new("GetTextBoundsParams")
			params.Text = if box.Text == "" then " " else box.Text
			params.Font = box.FontFace
			params.Size = box.TextSize
			params.Width = width
			local ok, bounds = pcall(function()
				return TextService:GetTextBoundsAsync(params)
			end)
			if not ok or ticket ~= measuring or typeof(bounds) ~= "Vector2" then
				return
			end
			local want = math.ceil(bounds.Y * (box.LineHeight or 1)) + padY * 2 + 4
			grown:set(math.clamp(want, height, maxHeight :: number))
		end)
	end

	-- past MaxHeight the box scrolls: keep the line with the caret visible (typing, pasting, arrows)
	local function followCaret()
		if content == nil or box == nil or not content:IsA("ScrollingFrame") or not peek(focused) then
			return
		end
		local len = #box.Text
		local ok, cursor, canvas, window = pcall(function()
			return box.CursorPosition, content.AbsoluteCanvasSize.Y, content.AbsoluteWindowSize.Y
		end)
		if not ok or len == 0 or cursor < 1 or canvas <= window then
			return
		end
		local line = box.TextSize * (box.LineHeight or 1)
		local caretY = math.clamp((cursor - 1) / len, 0, 1) * (canvas - padY * 2) + padY
		local top = content.CanvasPosition.Y
		if caretY + line > top + window then
			content.CanvasPosition = Vector2.new(0, math.min(canvas - window, caretY + line * 1.5 - window))
		elseif caretY < top then
			content.CanvasPosition = Vector2.new(0, math.max(0, caretY - line * 0.5))
		end
	end

	box = scope:New "TextBox" {
		Name = "Box",
		BackgroundTransparency = 1,
		ClearTextOnFocus = false,
		MultiLine = multi and not submit,
		TextWrapped = multi,
		Text = if props.ClearOnCommit then "" else current(),
		PlaceholderText = props.Placeholder or "",
		PlaceholderColor3 = C.TextMuted,
		FontFace = if props.Mono then Style.Font.Mono else Style.Font.Regular,
		TextSize = props.TextSize or (if props.Mono then Style.Text.Small else Style.Text.Body),
		TextColor3 = C.Text,
		TextTransparency = if masked
			then scope:Computed(function(use)
				return if use(hidden) then 1 else 0
			end)
			else 0,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextYAlignment = if multi then Enum.TextYAlignment.Top else Enum.TextYAlignment.Center,
		TextEditable = scope:Computed(function(use)
			return not (props.Disabled ~= nil and use(props.Disabled))
		end),
		LineHeight = if multi then 1.15 else 1,
		ClipsDescendants = not multi,
		Size = if multi then UDim2.new(1, 0, 0, height - padY * 2) else UDim2.fromScale(1, 1),
		AutomaticSize = if multi then Enum.AutomaticSize.Y else Enum.AutomaticSize.None,
		Selectable = true,
		[OnEvent "Focused"] = function()
			focused:set(true)
			Ui.focusedBox = box
			if props.OnFocus then
				props.OnFocus()
			end
		end,
		[OnEvent "FocusLost"] = function(enterPressed)
			focused:set(false)
			if Ui.focusedBox == box then
				Ui.focusedBox = nil
			end
			if props.OnCommit then
				props.OnCommit(box.Text, enterPressed == true)
			end
			if props.ClearOnCommit then
				box.Text = ""
			end
			sync()
		end,
		[OnChange "Text"] = function(text)
			typed:set(text)
			if props.OnInput then
				props.OnInput(text)
			end
			if multi then
				-- after the layout catches up: grow to fit, and keep the caret in view once it scrolls
				task.defer(function()
					measure()
					followCaret()
				end)
			end
		end,
		[Children] = if masked
			then Ui.text(scope, {
				Name = "Mask",
				Size = UDim2.fromScale(1, 1),
				Text = scope:Computed(function(use)
					return string.rep("•", math.min(#use(typed), 48))
				end),
				TextSize = Style.Text.Body,
				Color = C.Text,
				Visible = hidden,
			})
			else nil,
	}
	table.insert(scope, function()
		if Ui.focusedBox == box then
			Ui.focusedBox = nil
		end
	end)
	if props.BoxRef then
		props.BoxRef(box)
	end

	if props.Value ~= nil and typeof(props.Value) == "table" then
		local ok, observer = pcall(function()
			return scope:Observer(props.Value)
		end)
		if ok and observer then
			table.insert(scope, observer:onChange(sync))
		end
	end

	if multi then
		content = scope:New "ScrollingFrame" {
			Name = "Scroller",
			BackgroundTransparency = 1,
			BorderSizePixel = 0,
			Size = UDim2.fromScale(1, 1),
			CanvasSize = UDim2.new(),
			AutomaticCanvasSize = Enum.AutomaticSize.Y,
			ScrollingDirection = Enum.ScrollingDirection.Y,
			ScrollBarThickness = 4,
			ScrollBarImageColor3 = C.BorderStrong,
			VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
			TopImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
			MidImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
			BottomImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
			[Children] = {
				Ui.padding(scope, padY, padX - 2 + trailingWidth, padY, padX),
				box,
			},
		}
	else
		content = scope:New "Frame" {
			Name = "Content",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			[Children] = {
				Ui.padding(scope, 0, padX + trailingWidth, 0, if props.Icon then 30 else padX),
				box,
			},
		}
	end

	if maxHeight then
		pcall(function()
			table.insert(scope, content:GetPropertyChangedSignal("AbsoluteSize"):Connect(measure))
		end)
		task.defer(measure)
	end
	if multi then
		pcall(function()
			table.insert(scope, box:GetPropertyChangedSignal("CursorPosition"):Connect(followCaret))
		end)
	end

	return scope:New "Frame" (merge(layout(props, { Name = "Input" }), {
		BackgroundColor3 = scope:Computed(function(use)
			if props.Disabled ~= nil and use(props.Disabled) then
				return C.Raised
			end
			return if use(hovered) and not use(focused) then C.FieldHover else C.Field
		end),
		Size = props.Size or (if maxHeight
			then scope:Computed(function(use)
				return UDim2.new(1, 0, 0, use(grown))
			end)
			else UDim2.new(1, 0, 0, height)),
		ClipsDescendants = true,
		[OnEvent "MouseEnter"] = function()
			hovered:set(true)
		end,
		[OnEvent "MouseLeave"] = function()
			hovered:set(false)
		end,
		[Children] = {
			Ui.corner(scope, R.M),
			scope:New "UIStroke" {
				ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				Color = scope:Computed(function(use)
					if use(focused) then
						return C.Accent
					elseif props.Invalid ~= nil and use(props.Invalid) then
						return C.Danger
					elseif use(hovered) then
						return C.BorderStrong
					end
					return C.Border
				end),
				Thickness = scope:Computed(function(use)
					return if use(focused) then 1.5 else 1
				end),
			},
			if props.Icon
				then Ui.icon(scope, {
					Icon = props.Icon,
					Size = 15,
					Color = scope:Computed(function(use)
						return if use(focused) then C.AccentText else C.TextMuted
					end),
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 10, 0.5, 0),
				})
				else nil,
			content,
			if props.Trailing
				then scope:New "Frame" {
					Name = "Trailing",
					BackgroundTransparency = 1,
					AnchorPoint = Vector2.new(1, 0),
					Position = UDim2.new(1, -4, 0, 0),
					Size = UDim2.new(0, trailingWidth, 0, if multi then 32 else height),
					ZIndex = 2,
					[Children] = {
						Ui.list(scope, "X", 2, "Right", "Center"),
						props.Trailing,
					},
				}
				else nil,
		},
	}))
end

-- Number input with clamping. OnCommit(number) only fires for valid numbers.
function Ui.numberInput(scope: any, props: { [any]: any }): Frame
	local text = scope:Computed(function(use)
		local value = use(props.Value)
		if value == nil then
			return ""
		end
		if typeof(value) == "number" and value ~= math.floor(value) then
			local formatted = string.format("%.2f", value)
			formatted = string.gsub(formatted, "0+$", "")
			formatted = string.gsub(formatted, "%.$", "")
			return formatted
		end
		return tostring(value)
	end)
	return Ui.input(scope, merge(props, {
		Value = text,
		Mono = true,
		OnCommit = function(raw)
			local trimmed = string.gsub(raw, "[%s,_]", "")
			if trimmed == "" then
				if props.AllowEmpty and props.OnCommit then
					props.OnCommit(nil)
				end
				return
			end
			local number = tonumber(trimmed)
			if number == nil then
				return
			end
			if props.Integer then
				number = math.floor(number + 0.5)
			end
			if props.Min then
				number = math.max(props.Min, number)
			end
			if props.Max then
				number = math.min(props.Max, number)
			end
			if props.OnCommit then
				props.OnCommit(number)
			end
		end,
	}))
end

--[[
	Dropdown. Options are strings or { value, label, hint, icon, color = "#hex", font = Font }.
	Ui.dropdown(scope, { Options = {...} | state, Value = state, OnChange = fn(value), Placeholder, Searchable, MenuWidth, Height })
]]
local function normalizeOption(option: any): any
	if typeof(option) == "table" then
		return option
	end
	return { value = option, label = tostring(option) }
end
Ui.normalizeOption = normalizeOption

function Ui.dropdown(scope: any, props: { [any]: any }): TextButton
	local open = scope:Value(false)
	local state, events = Ui.interact(scope, props.Disabled)
	local height = props.Height or Style.Height.Control
	local selected = scope:Computed(function(use)
		local value = use(props.Value)
		for _, option in use(props.Options) or {} do
			local o = normalizeOption(option)
			if o.value == value then
				return o
			end
		end
		if value == nil or value == "" then
			return { label = props.Placeholder or "Choose...", placeholder = true }
		end
		return { value = value, label = tostring(value) }
	end)
	local button
	button = scope:New "TextButton" (merge(layout(props, { Name = "Dropdown" }), events, {
		Text = "",
		AutoButtonColor = false,
		BorderSizePixel = 0,
		Size = props.Size or UDim2.new(1, 0, 0, height),
		BackgroundColor3 = scope:Computed(function(use)
			local s = use(state)
			return if s == "hover" or s == "press" then C.FieldHover else C.Field
		end),
		Active = scope:Computed(function(use)
			return not (props.Disabled ~= nil and use(props.Disabled))
		end),
		[OnEvent "Activated"] = function()
			if (props.Disabled and peek(props.Disabled)) or Ui.overlay == nil then
				return
			end
			open:set(true)
			Ui.overlay:menu(button, {
				options = peek(props.Options) or {},
				value = peek(props.Value),
				searchable = props.Searchable,
				width = props.MenuWidth,
				title = props.MenuTitle,
				onSelect = function(value)
					if props.OnChange then
						props.OnChange(value)
					end
				end,
				onClose = function()
					open:set(false)
				end,
			})
		end,
		[Children] = {
			Ui.corner(scope, R.M),
			Ui.stroke(scope, scope:Computed(function(use)
				if use(open) then
					return C.Accent
				end
				local s = use(state)
				return if s == "hover" or s == "press" then C.BorderStrong else C.Border
			end)),
			Ui.padding(scope, 0, 8, 0, 10),
			Ui.list(scope, "X", 8, "Left", "Center"),
			Ui.swatch(scope, {
				Color = scope:Computed(function(use)
					return use(selected).color or "#000000"
				end),
				Size = 12,
				Visible = scope:Computed(function(use)
					return use(selected).color ~= nil
				end),
				LayoutOrder = 1,
			}),
			Ui.icon(scope, {
				Icon = scope:Computed(function(use)
					return use(selected).icon
				end),
				Size = 15,
				Color = C.TextSub,
				Visible = scope:Computed(function(use)
					return use(selected).icon ~= nil and use(selected).color == nil
				end),
				LayoutOrder = 2,
			}),
			Ui.text(scope, {
				Name = "Value",
				Text = scope:Computed(function(use)
					return use(selected).label or ""
				end),
				Font = scope:Computed(function(use)
					return use(selected).font or Style.Font.Regular
				end),
				TextSize = props.TextSize or Style.Text.Body,
				Color = scope:Computed(function(use)
					if props.Disabled ~= nil and use(props.Disabled) then
						return C.TextDisabled
					end
					return if use(selected).placeholder then C.TextMuted else C.Text
				end),
				Size = UDim2.new(0, 0, 1, 0),
				AutomaticSize = Enum.AutomaticSize.None,
				Truncate = true,
				LayoutOrder = 3,
				[Children] = Ui.flex(scope, "Fill"),
			}),
			Ui.icon(scope, {
				Icon = "chevron-small-down",
				Size = 16,
				Color = C.TextMuted,
				LayoutOrder = 4,
			}),
			if props.Flex then Ui.flex(scope, "Fill") else nil,
		},
	}))
	return button
end

--[[
	Segmented control.
	Ui.segmented(scope, { Options = {{ value, label, icon, disabled = state? }}, Value = state, OnChange = fn })
]]
function Ui.segmented(scope: any, props: { [any]: any }): Frame
	local options = props.Options
	local count = #options
	local gap = 2
	local height = props.Height or 30
	local items = {}
	for index, raw in options do
		local option = normalizeOption(raw)
		local optionDisabled = option.disabled
		-- locked: shown dimmed with a lock, but still clickable (e.g. AI without a key)
		local locked = scope:Computed(function(use)
			return option.locked ~= nil and use(option.locked) == true
		end)
		local state, events = Ui.interact(scope, optionDisabled)
		local isSelected = scope:Computed(function(use)
			return use(props.Value) == option.value
		end)
		local item = scope:New "TextButton" (merge(events, {
			Name = "Option_" .. tostring(option.value),
			Text = "",
			AutoButtonColor = false,
			BorderSizePixel = 0,
			LayoutOrder = index,
			Size = UDim2.new(1 / count, -gap * (count - 1) / count, 1, 0),
			BackgroundColor3 = scope:Computed(function(use)
				return if use(isSelected) then C.SegmentThumb else C.RaisedHover
			end),
			BackgroundTransparency = scope:Computed(function(use)
				if use(isSelected) then
					return 0
				end
				return if use(state) == "hover" then 0.5 else 1
			end),
			[OnEvent "Activated"] = function()
				if optionDisabled and peek(optionDisabled) then
					return
				end
				if props.OnChange then
					props.OnChange(option.value)
				end
			end,
			[Children] = {
				Ui.corner(scope, R.S),
				Ui.stroke(scope, C.Border, 1, scope:Computed(function(use)
					return if use(isSelected) then 0 else 1
				end)),
				Ui.list(scope, "X", 5, "Center", "Center"),
				if option.icon
					then Ui.icon(scope, {
						Icon = scope:Computed(function(use)
							return if use(locked) then "lock-closed" else option.icon
						end),
						Size = 14,
						LayoutOrder = 1,
						Color = scope:Computed(function(use)
							if use(state) == "disabled" or use(locked) then
								return C.TextDisabled
							end
							return if use(isSelected) then C.AccentText else C.TextMuted
						end),
					})
					else nil,
				if option.label and option.label ~= ""
					then Ui.text(scope, {
						Text = option.label,
						TextSize = Style.Text.Small,
						Weight = "Medium",
						LayoutOrder = 2,
						Color = scope:Computed(function(use)
							local s = use(state)
							if s == "disabled" then
								return C.TextDisabled
							end
							if use(locked) then
								return if s == "hover" then C.TextSub else C.TextMuted
							end
							if use(isSelected) then
								return C.Text
							end
							return if s == "hover" then C.Text else C.TextSub
						end),
					})
					else nil,
			},
		}))
		if option.tooltip then
			Ui.tooltip(scope, item, option.tooltip)
		end
		table.insert(items, item)
	end
	return scope:New "Frame" (merge(layout(props, { Name = "Segmented" }), {
		BackgroundColor3 = C.SegmentTrack,
		Size = props.Size or UDim2.new(1, 0, 0, height),
		[Children] = {
			Ui.corner(scope, R.M),
			Ui.stroke(scope, C.Border),
			Ui.padding(scope, 2),
			Ui.list(scope, "X", gap, "Left", "Center"),
			items,
		},
	}))
end

-- iOS-style switch. Ui.switch(scope, { Value = state<bool>, OnChange = fn(bool), Disabled })
function Ui.switch(scope: any, props: { [any]: any }): TextButton
	local state, events = Ui.interact(scope, props.Disabled)
	return scope:New "TextButton" (merge(layout(props, { Name = "Switch" }), events, {
		Text = "",
		AutoButtonColor = false,
		BorderSizePixel = 0,
		Size = UDim2.fromOffset(34, 20),
		BackgroundColor3 = scope:Computed(function(use)
			if use(state) == "disabled" then
				return C.Raised
			end
			if use(props.Value) then
				return if use(state) == "hover" then C.AccentHover else C.Accent
			end
			return if use(state) == "hover" then C.SwitchOffHover else C.SwitchOff
		end),
		[OnEvent "Activated"] = function()
			if props.Disabled and peek(props.Disabled) then
				return
			end
			if props.OnChange then
				props.OnChange(not peek(props.Value))
			end
		end,
		[Children] = {
			Ui.corner(scope, R.Pill),
			scope:New "Frame" {
				Name = "Knob",
				AnchorPoint = Vector2.new(0, 0.5),
				Size = UDim2.fromOffset(14, 14),
				Position = scope:Computed(function(use)
					return if use(props.Value) then UDim2.new(1, -17, 0.5, 0) else UDim2.new(0, 3, 0.5, 0)
				end),
				BackgroundColor3 = scope:Computed(function(use)
					return if use(state) == "disabled" then C.TextDisabled else C.White
				end),
				[Children] = Ui.corner(scope, R.Pill),
			},
		},
	}))
end

-- Checkbox with a label. Ui.checkbox(scope, { Value = state<bool>, OnChange = fn(bool), Label, Hint })
function Ui.checkbox(scope: any, props: { [any]: any }): TextButton
	local state, events = Ui.interact(scope, props.Disabled)
	return scope:New "TextButton" (merge(layout(props, { Name = "Checkbox" }), events, {
		Text = "",
		AutoButtonColor = false,
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		[OnEvent "Activated"] = function()
			if props.Disabled and peek(props.Disabled) then
				return
			end
			if props.OnChange then
				props.OnChange(not peek(props.Value))
			end
		end,
		[Children] = {
			Ui.list(scope, "X", 10, "Left", "Top"),
			scope:New "Frame" {
				Name = "Box",
				Size = UDim2.fromOffset(18, 18),
				LayoutOrder = 1,
				BackgroundColor3 = scope:Computed(function(use)
					if use(props.Value) then
						return if use(state) == "hover" then C.AccentHover else C.Accent
					end
					return C.Field
				end),
				[Children] = {
					Ui.corner(scope, 5),
					Ui.stroke(scope, scope:Computed(function(use)
						if use(props.Value) then
							return C.Accent
						end
						return if use(state) == "hover" then C.TextMuted else C.BorderStrong
					end)),
					Ui.icon(scope, {
						Icon = "check-small",
						Size = 16,
						Bold = true,
						Color = C.White,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Visible = scope:Computed(function(use)
							return use(props.Value) == true
						end),
					}),
				},
			},
			Ui.column(scope, {
				Gap = 2,
				LayoutOrder = 2,
				Size = UDim2.new(1, -28, 0, 0),
				[Children] = {
					Ui.text(scope, {
						Text = props.Label,
						TextSize = Style.Text.Body,
						Weight = "Medium",
						Fill = true,
						Wrap = true,
						LayoutOrder = 1,
						Color = C.Text,
					}),
					if props.Hint
						then Ui.text(scope, {
							Text = props.Hint,
							TextSize = Style.Text.Caption,
							Color = C.TextMuted,
							Fill = true,
							Wrap = true,
							LayoutOrder = 2,
						})
						else nil,
				},
			}),
		},
	}))
end

--[[
	Colour field: swatch (opens the colour popover) + hex input.
	Ui.colorField(scope, { Value = state<"#hex">, OnChange = fn("#hex"), Palette = state<{hex}>? })
]]
function Ui.colorField(scope: any, props: { [any]: any }): Frame
	local invalid = scope:Value(false)
	local swatchButton
	local state, events = Ui.interact(scope)
	swatchButton = scope:New "TextButton" (merge(events, {
		Name = "SwatchButton",
		Text = "",
		AutoButtonColor = false,
		Size = UDim2.fromOffset(32, 32),
		BackgroundColor3 = C.Field,
		LayoutOrder = 1,
		[OnEvent "Activated"] = function()
			if Ui.overlay then
				Ui.overlay:colorPicker(swatchButton, {
					value = peek(props.Value),
					palette = peek(props.Palette),
					onSelect = function(hex)
						invalid:set(false)
						if props.OnChange then
							props.OnChange(hex)
						end
					end,
				})
			end
		end,
		[Children] = {
			Ui.corner(scope, R.M),
			Ui.stroke(scope, scope:Computed(function(use)
				return if use(state) == "hover" then C.BorderStrong else C.Border
			end)),
			scope:New "Frame" {
				Name = "Fill",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.new(1, -8, 1, -8),
				BackgroundColor3 = scope:Computed(function(use)
					return Style.parseHex(use(props.Value)) or C.Raised
				end),
				[Children] = {
					Ui.corner(scope, 5),
					Ui.stroke(scope, C.Hairline, 1, Style.Tone.hairline),
				},
			},
		},
	}))
	return scope:New "Frame" (merge(layout(props, { Name = "ColorField" }), {
		BackgroundTransparency = 1,
		Size = props.Size or UDim2.new(1, 0, 0, 32),
		[Children] = {
			Ui.list(scope, "X", 6, "Left", "Center"),
			swatchButton,
			Ui.input(scope, {
				Name = "Hex",
				Value = props.Value,
				Mono = true,
				Placeholder = "#RRGGBB",
				Invalid = invalid,
				LayoutOrder = 2,
				Size = UDim2.new(1, -38, 0, 32),
				OnCommit = function(text)
					local hex = Style.normalizeHex(text)
					if hex == nil then
						invalid:set(text ~= "")
						return
					end
					invalid:set(false)
					if props.OnChange and hex ~= peek(props.Value) then
						props.OnChange(hex)
					end
				end,
			}),
		},
	}))
end

--[[
	Icon field: shows the glyph + name, opens the searchable icon picker.
	Ui.iconField(scope, { Value = state<string>, OnChange = fn(name) })
]]
function Ui.iconField(scope: any, props: { [any]: any }): TextButton
	local state, events = Ui.interact(scope)
	local button
	button = scope:New "TextButton" (merge(layout(props, { Name = "IconField" }), events, {
		Text = "",
		AutoButtonColor = false,
		Size = props.Size or UDim2.new(1, 0, 0, Style.Height.Control),
		BackgroundColor3 = scope:Computed(function(use)
			return if use(state) == "hover" then C.FieldHover else C.Field
		end),
		[OnEvent "Activated"] = function()
			if Ui.overlay then
				Ui.overlay:iconPicker(button, {
					value = peek(props.Value),
					onSelect = function(name)
						if props.OnChange then
							props.OnChange(name)
						end
					end,
				})
			end
		end,
		[Children] = {
			Ui.corner(scope, R.M),
			Ui.stroke(scope, scope:Computed(function(use)
				return if use(state) == "hover" then C.BorderStrong else C.Border
			end)),
			Ui.padding(scope, 0, 8, 0, 8),
			Ui.list(scope, "X", 8, "Left", "Center"),
			scope:New "Frame" {
				Name = "Glyph",
				Size = UDim2.fromOffset(22, 22),
				BackgroundColor3 = C.Raised,
				LayoutOrder = 1,
				[Children] = {
					Ui.corner(scope, R.S),
					Ui.icon(scope, {
						Icon = props.Value,
						Size = 15,
						Color = C.Text,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
					}),
				},
			},
			Ui.text(scope, {
				Text = scope:Computed(function(use)
					local value = use(props.Value)
					return if value == nil or value == "" then "None" else tostring(value)
				end),
				TextSize = Style.Text.Small,
				Color = C.TextSub,
				Font = Style.Font.Mono,
				Size = UDim2.new(0, 0, 1, 0),
				Truncate = true,
				LayoutOrder = 2,
				[Children] = Ui.flex(scope, "Fill"),
			}),
			Ui.icon(scope, { Icon = "magnifying-glass", Size = 14, Color = C.TextMuted, LayoutOrder = 3 }),
		},
	}))
	return button
end

return Ui
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="28">
        <Properties>
          <string name="Name">Version</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Generated by build.ps1 from version.json. Do not edit: change version.json and build.
return {
	plugin = "2.4.0",
	runtime = "2.2.0",
	api = 1,
}
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="29">
        <Properties>
          <string name="Name">WhatsNew</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	WhatsNew: the "What's new in BloxUI <version>" dialog, with the release
	notes from Changelog.luau.

		local entries, since = WhatsNew.pending(plugin)   -- what to show when the builder opens (nil: nothing)
		WhatsNew.show(app, { entries = entries, since = since })
		WhatsNew.show(app)                                -- the latest notes (Settings > What's new)

	It opens once after an update. The plugin setting BloxUI_SeenVersion
	remembers the last version this computer was shown; closing the dialog
	(Got it, the X, Esc or a click outside) stores the running version. A
	fresh install shows nothing and just stores it. A computer that used a
	build from before these notes existed (it has a saved session or history,
	but no seen version) gets this version's notes. When more than two
	versions are new, the newest shows in full and the older ones fold into
	one line each (click to open), so the dialog never gets long.
]]

local Deps = require(script.Parent.Deps)
local Style = require(script.Parent.Style)
local Ui = require(script.Parent.Ui)
local Store = require(script.Parent.Store)
local Changelog = require(script.Parent.Changelog)
local Version = require(script.Parent.Version)

local Fusion = Deps.Fusion
local Children = Fusion.Children
local OnEvent = Fusion.OnEvent

local C = Style.Color

local WhatsNew = {}

WhatsNew.SETTING = "BloxUI_SeenVersion"
WhatsNew.FULL = 2 -- this many new versions or fewer: every one in full
WhatsNew.LIMIT = 5 -- versions listed at most (then "And N earlier updates.")
WhatsNew.LATEST = 3 -- versions the Settings button shows
WhatsNew.DELAY = 0.4 -- seconds after the builder opens, once its first tab has settled

-- what an older build leaves in plugin settings: this computer used BloxUI before
local EARLIER_USE = { Store.SETTING.Session, Store.SETTING.History }

-- sizes (the panel needs a height up front; the notes scroll if it runs short)
local PAD = 16
local GAP = 12
local FOOTER = 32
local ROW = 24 -- a version's heading
local ENTRY_GAP = 14
local NOTE_GAP = 5
local NOTE_SIZE = 13 -- Style.Text.Small
local LINE = 15 -- a 13 px line at LineHeight 1.15
local BULLET = 14 -- the bullet column + its gap
local FOLD_INDENT = 22 -- folded versions' notes line up with their title
local SCROLL_BAR = 4

-- Builder Sans Regular advance widths in thousandths of an em, " " (32) to "~" (126),
-- for measuring how the notes wrap before the panel is built.
local ADVANCE = {
	270, 226, 390, 703, 554, 831, 679, 220, 331, 331, 406, 494, 226, 326, 226, 396,
	596, 376, 562, 564, 572, 562, 574, 514, 590, 574, 254, 254, 494, 494, 494, 512,
	889, 629, 628, 671, 672, 536, 536, 678, 699, 250, 466, 593, 530, 866, 700, 734,
	591, 734, 618, 550, 516, 659, 654, 854, 588, 592, 544, 336, 396, 336, 438, 392,
	234, 536, 576, 522, 582, 560, 368, 577, 587, 224, 235, 482, 224, 866, 587, 572,
	576, 582, 381, 465, 358, 577, 516, 722, 479, 515, 451, 416, 266, 416, 572,
}
-- Roblox sizes text by its line: a 13 px TextSize is a 13 / 1.26 px em in Builder Sans
local EM_PER_TEXT_SIZE = 1000 / 1260
local SEMIBOLD = 1.08 -- semibold runs about this much wider than regular

local function getSetting(plugin: any, key: string): any
	if plugin == nil then
		return nil
	end
	local ok, value = pcall(function()
		return plugin:GetSetting(key)
	end)
	return if ok then value else nil
end

local function setSetting(plugin: any, key: string, value: any)
	if plugin == nil then
		return
	end
	pcall(function()
		plugin:SetSetting(key, value)
	end)
end

-- Remembers that this computer has seen `current`'s notes (never steps back to an older version).
function WhatsNew.markSeen(plugin: any, current: string?)
	current = current or Version.plugin
	local seen = getSetting(plugin, WhatsNew.SETTING)
	if Changelog.isVersion(seen) and Changelog.compare(seen, current) >= 0 then
		return
	end
	setSetting(plugin, WhatsNew.SETTING, current)
end

--[[
	What to show when the builder opens: the entries newer than the version
	this computer saw last (newest first) and that version, or nil when
	there's nothing new. A fresh install stores the running version here.
]]
function WhatsNew.pending(plugin: any, current: string?): ({ any }?, string?)
	current = current or Version.plugin
	local seen = getSetting(plugin, WhatsNew.SETTING)
	if not Changelog.isVersion(seen) then
		local usedBefore = false
		for _, key in EARLIER_USE do
			if getSetting(plugin, key) ~= nil then
				usedBefore = true
			end
		end
		local entry = if usedBefore then Changelog.entry(current) else nil
		if entry == nil then
			setSetting(plugin, WhatsNew.SETTING, current) -- a fresh install: nothing to catch up on
			return nil, nil
		end
		return { entry }, nil -- an update from a build before these notes existed
	end
	local entries = Changelog.between(seen, current)
	if #entries == 0 then
		WhatsNew.markSeen(plugin, current) -- a version without notes: don't ask again
		return nil, seen
	end
	return entries, seen
end

-- Width in px of one line of Builder Sans Regular text at `textSize`.
local function textWidth(text: string, textSize: number): number
	local total = 0
	if utf8.len(text) then
		for _, code in utf8.codes(text) do
			total += if code >= 32 and code <= 126 then ADVANCE[code - 31] else 560
		end
	else
		total = #text * 560
	end
	return total / 1000 * textSize * EM_PER_TEXT_SIZE
end

-- Lines `text` wraps to in `width` px (greedy at spaces, like TextWrapped).
local function lineCount(text: string, width: number, textSize: number, scale: number?): number
	local lines, current = 1, ""
	for word in string.gmatch(text, "%S+") do
		local candidate = if current == "" then word else current .. " " .. word
		if current == "" or textWidth(candidate, textSize) * (scale or 1) <= width then
			current = candidate
		else
			lines += 1
			current = word
		end
	end
	return lines
end
WhatsNew.lineCount = lineCount

-- One version: its number and title, then its notes (folded ones open on click).
local function entryView(scope: any, entry: any, index: number, isOpen: any, canFold: boolean, toggle: (string) -> ()): Frame
	local badge = Ui.badge(scope, {
		Name = "Version",
		Text = entry.version,
		Tone = if index == 1 then "accent" else "neutral",
		LayoutOrder = 2,
	})
	local title = Ui.text(scope, {
		Name = "Title",
		Text = entry.title,
		TextSize = Style.Text.Small,
		Weight = "SemiBold",
		Color = if index == 1 then C.Text else C.TextSub,
		Size = UDim2.new(0, 0, 1, 0),
		Truncate = true,
		LayoutOrder = 3,
		[Children] = Ui.flex(scope, "Fill"),
	})
	local heading
	if canFold then
		local state, events = Ui.interact(scope)
		heading = scope:New "TextButton" (Ui.merge(events, {
			Name = "Toggle",
			Text = "",
			AutoButtonColor = false,
			BorderSizePixel = 0,
			Size = UDim2.new(1, 0, 0, ROW),
			LayoutOrder = 1,
			BackgroundColor3 = C.RowHover,
			BackgroundTransparency = scope:Computed(function(use)
				local s = use(state)
				return if s == "hover" or s == "press" then 0 else 1
			end),
			[OnEvent "Activated"] = function()
				toggle(entry.version)
			end,
			[Children] = {
				Ui.corner(scope, Style.Radius.S),
				Ui.padding(scope, 0, 4, 0, 2),
				Ui.list(scope, "X", 6, "Left", "Center"),
				Ui.icon(scope, {
					Name = "Chevron",
					Icon = scope:Computed(function(use)
						return if use(isOpen) then "chevron-small-down" else "chevron-small-right"
					end),
					Size = 14,
					Color = C.TextMuted,
					LayoutOrder = 1,
				}),
				badge,
				title,
			},
		}))
	else
		heading = Ui.row(scope, {
			Name = "Heading",
			Gap = 8,
			Height = ROW,
			LayoutOrder = 1,
			[Children] = { badge, title },
		})
	end
	local notes = {}
	for i, note in entry.notes do
		table.insert(notes, Ui.row(scope, {
			Name = "Note_" .. i,
			Gap = BULLET - 8,
			VAlign = "Top",
			LayoutOrder = i,
			[Children] = {
				Ui.text(scope, {
					Name = "Bullet",
					Text = "•",
					TextSize = Style.Text.Small,
					Weight = "Bold",
					Color = C.AccentText,
					Size = UDim2.fromOffset(8, LINE),
					LayoutOrder = 1,
				}),
				Ui.text(scope, {
					Name = "Text",
					Text = note,
					TextSize = Style.Text.Small,
					Color = C.TextSub,
					Wrap = true,
					LineHeight = 1.15,
					Size = UDim2.new(1, -BULLET, 0, 0),
					AutomaticSize = Enum.AutomaticSize.Y,
					LayoutOrder = 2,
				}),
			},
		}))
	end
	return scope:New "Frame" {
		Name = "Entry_" .. entry.version,
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		LayoutOrder = index,
		[Children] = {
			Ui.list(scope, "Y", 6),
			heading,
			Ui.column(scope, {
				Name = "Notes",
				Gap = NOTE_GAP,
				LayoutOrder = 2,
				Visible = isOpen,
				Padding = { 0, 0, 0, if canFold then FOLD_INDENT else 0 },
				[Children] = notes,
			}),
		},
	}
end

--[[
	Opens the dialog. opts: entries (default: the latest few), since (the
	version this computer had; shown under the title), subtitle, version
	(default: this plugin's). Returns the overlay entry.
]]
function WhatsNew.show(app: any, opts: { [string]: any }?): any
	opts = opts or {}
	local overlay = app.overlay
	local current = opts.version or Version.plugin
	local all = opts.entries or Changelog.latest(WhatsNew.LATEST)
	if #all == 0 then
		return nil
	end
	local list = {}
	for index = 1, math.min(#all, WhatsNew.LIMIT) do
		list[index] = all[index]
	end
	local more = #all - #list
	local foldable = #all > WhatsNew.FULL
	local open = {}
	for index, entry in list do
		open[entry.version] = not foldable or index == 1
	end

	local subtitle = opts.subtitle
		or (if opts.since then `You updated from {opts.since}.` elseif opts.entries then "Here's what changed in this update." else "The latest updates.")
	local width = math.min(360, overlay:maxWidth())
	local title = `What's new in BloxUI {current}`
	local titleLines = math.min(2, lineCount(title, width - PAD * 2 - 74, Style.Text.Title, SEMIBOLD))
	local headerHeight = math.max(34, titleLines * 18 + 17)
	local rootHeight = if app.root then app.root.AbsoluteSize.Y else 600
	local maxHeight = math.max(260, math.min(560, rootHeight - 48))
	local chrome = PAD * 2 + headerHeight + GAP * 2 + FOOTER

	-- the notes' height for what's open now (the panel shrinks to fit, up to maxHeight)
	local function bodyHeight(): number
		local total = 0
		for index, entry in list do
			if index > 1 then
				total += ENTRY_GAP
			end
			total += ROW
			if open[entry.version] then
				local indent = if foldable and index > 1 then FOLD_INDENT else 0
				local wrapWidth = width - PAD * 2 - BULLET - indent
				total += 6
				for i, note in entry.notes do
					if i > 1 then
						total += NOTE_GAP
					end
					total += lineCount(note, wrapWidth, NOTE_SIZE) * LINE + 1
				end
			end
		end
		if more > 0 then
			total += ENTRY_GAP + 16
		end
		return math.min(total, maxHeight - chrome)
	end

	local entryRef
	entryRef = overlay:open({
		name = "WhatsNewDialog",
		modal = true,
		width = width,
		height = chrome + bodyHeight(),
		onClose = function()
			WhatsNew.markSeen(app.plugin, current)
			if opts.onClose then
				opts.onClose()
			end
		end,
		build = function(scope, close)
			local openState = scope:Value(table.clone(open))
			local bodySize = scope:Value(bodyHeight())
			local function toggle(version: string)
				open[version] = not open[version]
				openState:set(table.clone(open))
				local body = bodyHeight()
				bodySize:set(body)
				overlay:resize(entryRef, chrome + body)
			end
			local rows = {}
			for index, entry in list do
				local isOpen = scope:Computed(function(use)
					return use(openState)[entry.version] == true
				end)
				table.insert(rows, entryView(scope, entry, index, isOpen, foldable and index > 1, toggle))
			end
			if more > 0 then
				table.insert(rows, Ui.text(scope, {
					Name = "Earlier",
					Text = `And {more} earlier update{if more == 1 then "" else "s"}.`,
					TextSize = Style.Text.Caption,
					Color = C.TextMuted,
					Size = UDim2.new(1, 0, 0, 16),
					LayoutOrder = #list + 1,
				}))
			end
			return {
				Ui.padding(scope, PAD),
				Ui.list(scope, "Y", GAP),
				scope:New "Frame" {
					Name = "Header",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, 0, 0, headerHeight),
					LayoutOrder = 1,
					[Children] = {
						Ui.list(scope, "X", 10, "Left", "Top"),
						Ui.iconBubble(scope, { Icon = "gift", Size = 30, LayoutOrder = 1 }),
						Ui.column(scope, {
							Name = "Titles",
							Gap = 2,
							Size = UDim2.new(1, -74, 0, 0),
							LayoutOrder = 2,
							[Children] = {
								Ui.text(scope, {
									Name = "Title",
									Text = title,
									Weight = "SemiBold",
									TextSize = Style.Text.Title,
									Fill = true,
									Wrap = true,
									LineHeight = 1.15,
									LayoutOrder = 1,
								}),
								Ui.text(scope, {
									Name = "Subtitle",
									Text = subtitle,
									TextSize = Style.Text.Caption,
									Color = C.TextMuted,
									Fill = true,
									Truncate = true,
									LayoutOrder = 2,
								}),
							},
						}),
						Ui.iconButton(scope, {
							Name = "WhatsNewClose",
							Icon = "x-small",
							Size = 24,
							IconSize = 16,
							Tooltip = "Close",
							LayoutOrder = 3,
							OnClick = close,
						}),
					},
				},
				scope:New "ScrollingFrame" {
					Name = "Body",
					BackgroundTransparency = 1,
					BorderSizePixel = 0,
					Size = scope:Computed(function(use)
						return UDim2.new(1, 0, 0, use(bodySize))
					end),
					CanvasSize = UDim2.new(),
					AutomaticCanvasSize = Enum.AutomaticSize.Y,
					ScrollingDirection = Enum.ScrollingDirection.Y,
					ScrollBarThickness = SCROLL_BAR,
					ScrollBarImageColor3 = C.BorderStrong,
					VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
					TopImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
					MidImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
					BottomImage = "rbxasset://textures/ui/Scroll/scroll-middle.png",
					LayoutOrder = 2,
					[Children] = {
						Ui.list(scope, "Y", ENTRY_GAP),
						rows,
					},
				},
				Ui.row(scope, {
					Name = "Footer",
					Gap = 8,
					Align = "Right",
					Height = FOOTER,
					LayoutOrder = 3,
					[Children] = Ui.button(scope, {
						Name = "WhatsNewDone",
						Text = "Got it",
						Variant = "primary",
						Width = 96,
						LayoutOrder = 1,
						OnClick = close,
					}),
				}),
			}
		end,
	})
	return entryRef
end

return WhatsNew
]]></string>
        </Properties>
      </Item>
    </Item>
    <Item class="Script" referent="30">
      <Properties>
        <string name="Name">Main</string>
        <token name="RunContext">0</token>
        <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	BloxUI (Studio plugin)

	Adds a "BloxUI" toolbar to the Plugins tab:
	  UI Builder       opens the UI Builder: type a prompt, improve it into a
	                   design brief, generate a full game UI (on the BloxUI
	                   server), tweak it, preview it in the viewport and
	                   insert it into your game
	  Install BloxUI   copies BloxUI into ReplicatedStorage (click again to update)
	  Gallery / Shop / FPS / Horror / Simulator
	                   adds that demo LocalScript to StarterPlayerScripts
	                   (installs BloxUI first if needed), then press Play
	  Self-Test        adds a LocalScript that checks BloxUI in the real engine
	                   (every example + ready-made sample, fonts, Output errors,
	                   text overflow); press Play and read the report card
	  Remove Demos     removes the demo / self-test scripts this plugin added

	Every change to the place can be undone with Ctrl+Z.
]]

local Selection = game:GetService("Selection")

local root = script.Parent
local payload = root:WaitForChild("Payload")
local source = payload:WaitForChild("BloxUI")
local builderModule = root:WaitForChild("Builder")

local Installer = require(builderModule:WaitForChild("Installer"))
local installer = Installer.new(source)
local version = installer.version

local DEMO_ATTRIBUTE = "BloxUIDemo"

local function record(name: string, action: () -> ())
	installer:record(name, action)
end

local function install(options: { samples: boolean? }?): Instance
	return installer:install(options)
end

local function starterScripts(): Instance
	return installer:starterScripts()
end

local function removeDemos(scripts: Instance)
	for _, child in scripts:GetChildren() do
		if child:GetAttribute(DEMO_ATTRIBUTE) then
			child.Parent = nil
		end
	end
end

-- needsSamples: the Self-Test builds the ready-made samples, so its copy of BloxUI carries them
local function addDemo(templateName: string, message: string?, needsSamples: boolean?)
	record(`Add BloxUI {templateName}`, function()
		local ReplicatedStorage = game:GetService("ReplicatedStorage")
		local installed = ReplicatedStorage:FindFirstChild("BloxUI")
		if installed == nil or (needsSamples and not Installer.hasSamples(installed)) then
			installed = install({ samples = needsSamples == true })
			print(`[BloxUI] Installed BloxUI {version} into ReplicatedStorage.`)
		end
		local template = source.Templates:FindFirstChild(templateName)
		assert(template, `no template called {templateName}`)
		local scripts = starterScripts()
		removeDemos(scripts)
		local demo = template:Clone()
		demo.Name = `BloxUI_{templateName}`
		demo:SetAttribute(DEMO_ATTRIBUTE, true)
		demo.Enabled = true
		demo.Parent = scripts
		Selection:Set({ demo })
		print(message or `[BloxUI] Added {templateName} to StarterPlayerScripts. Press Play (F5) to try it.`)
	end)
end

local toolbar = plugin:CreateToolbar("BloxUI")

-- the plugin's icon (Builder/Brand.luau), on every toolbar button
local ICON = ""
do
	local brand = builderModule and builderModule:FindFirstChild("Brand")
	local ok, value = pcall(function()
		return if brand then require(brand).ICON else ""
	end)
	if ok and type(value) == "string" then
		ICON = value
	end
end

local function button(id: string, text: string, tooltip: string, onClick: () -> ())
	local b = toolbar:CreateButton(id, tooltip, ICON, text)
	b.ClickableWhenViewportHidden = true
	b.Click:Connect(function()
		onClick()
		b:SetActive(false)
	end)
	return b
end

-- UI Builder (dock widget). Loaded defensively so the installer buttons keep
-- working even if the builder can't start.
local builderButton = toolbar:CreateButton(
	"BloxUIBuilder",
	"Open the BloxUI UI Builder: prompt -> full game UI, live preview, one-click insert",
	ICON,
	"UI Builder"
)
builderButton.ClickableWhenViewportHidden = true

local builder = nil
do
	local ok, result = pcall(function()
		local Builder = require(builderModule)
		return Builder.start(plugin, { installer = installer, button = builderButton })
	end)
	if ok then
		builder = result
	else
		warn(`[BloxUI] The UI Builder could not start: {result}`)
	end
end

-- Licence gate for the toolbar: installing BloxUI and adding demos need a sign-in too (a licence
-- key, or an early tester's Roblox account), like the builder. Remove Demos always works (clean-up).
local function signedIn(): boolean
	if builder then
		return builder:signedIn()
	end
	local ok, token = pcall(function()
		return plugin:GetSetting("BloxUI_Token")
	end)
	return ok and type(token) == "string" and token ~= ""
end

local function gated(fn: () -> ()): () -> ()
	return function()
		if signedIn() then
			fn()
			return
		end
		warn("[BloxUI] Sign in first: paste your BloxUI licence key in UI Builder > Settings > Account (get one at ui.bloxmaps.com).")
		if builder then
			builder:showSignIn()
		end
	end
end

builderButton.Click:Connect(function()
	if builder then
		builder:toggle()
	else
		builderButton:SetActive(false)
		warn("[BloxUI] The UI Builder isn't available in this build. The Install and demo buttons still work.")
	end
end)

button("BloxUIInstall", "Install BloxUI", "Copy BloxUI into ReplicatedStorage (click again to update)", gated(function()
	record("Install BloxUI", function()
		local copy = install()
		Selection:Set({ copy })
		print(`[BloxUI] Installed BloxUI {version} into ReplicatedStorage.`)
		print(`[BloxUI] require(game.ReplicatedStorage.BloxUI) from a LocalScript, or add a demo from the toolbar.`)
	end)
end))

button("BloxUIGallery", "Gallery", "Add the component gallery demo", gated(function()
	addDemo("Gallery")
end))
button("BloxUIShop", "Shop", "Add the key shop demo", gated(function()
	addDemo("ShopDemo")
end))
button("BloxUIFPS", "FPS", "Add the FPS HUD demo", gated(function()
	addDemo("FPSDemo")
end))
button("BloxUIHorror", "Horror", "Add the horror HUD demo", gated(function()
	addDemo("HorrorDemo")
end))
button("BloxUISimulator", "Simulator", "Add the simulator HUD demo", gated(function()
	addDemo("SimulatorDemo")
end))
button("BloxUISelfTest", "Self-Test", "Adds a script that checks BloxUI in real Roblox: press Play, read the report", gated(function()
	addDemo(
		"SelfTest",
		"[BloxUI] Added the BloxUI self-test to StarterPlayerScripts. Press Play (F5): the report card shows in about two minutes. Click Remove Demos (and Install BloxUI, which drops the samples) when you're done.",
		true
	)
end))
button("BloxUIRemoveDemos", "Remove Demos", "Remove the demo and self-test scripts added by BloxUI", function()
	record("Remove BloxUI demos", function()
		removeDemos(starterScripts())
		print("[BloxUI] Removed demo scripts.")
	end)
end)

plugin.Unloading:Connect(function()
	if builder then
		builder:destroy()
		builder = nil
	end
end)
]]></string>
      </Properties>
    </Item>
    <Item class="Folder" referent="31">
      <Properties>
        <string name="Name">Payload</string>
      </Properties>
      <Item class="ModuleScript" referent="32">
        <Properties>
          <string name="Name">BloxUI</string>
          <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	BloxUI: a cartoony game UI kit for Roblox, built on Fusion 0.3.

	Quick start (LocalScript):

		local UI = require(game:GetService("ReplicatedStorage"):WaitForChild("BloxUI"))
		local Children = UI.Children

		local scope = UI.scope()
		local coins = scope:Value(0)

		scope:Screen {
			Name = "HUD",
			[Children] = {
				scope:Currency { Icon = "coin", Amount = coins, Position = UDim2.fromOffset(40, 40) },
				scope:Button {
					Text = "Collect", Icon = "gift", Tone = "Green",
					AnchorPoint = Vector2.new(0.5, 1), Position = UDim2.new(0.5, 0, 1, -40),
					OnClick = function() coins:set(UI.peek(coins) + 10) end,
				},
			},
		}

	Everything you need hangs off this module:
		UI.scope()        Fusion scope with every component as a method
		UI.Theme          Toon / Arena / Bubbly / Adventure / Sleek / Candy /
		                  Tactical / Horror, or make your own
		UI.FX             pops, poofs, shakes, particles, flashes, announcements
		UI.Prefabs        Shop, FPSHud, HorrorHud, SimulatorHud, LoadingScreen,
		                  MainMenu, Results, Trade
		UI.notify / UI.confirm / UI.reward / UI.dialogue / UI.hatch / UI.openCase
		UI.Blueprint      build a whole game UI from Blueprint data (docs/BLUEPRINT.md);
		                  UI.Blueprint.Samples: ready-made blueprints per genre
		UI.Server         server glue: leaderstats, purchases, receipts, saving
		UI.Prompts        themed ProximityPrompts
		UI.HudKits        nine named HUD looks (Competitive, Arcade, Glass, Chunky Toy...)
		UI.Icons          771 built-in vector icons
		UI.Fusion         the Fusion library itself (patched: Packages/Fusion/PATCHES.md)
]]

local Fusion = require(script.Packages.Fusion)
local Theme = require(script.Theme)
local Icons = require(script.Icons)
local Assets = require(script.Assets)
local Kit = require(script.Kit)
local FX = require(script.FX)
local Components = require(script.Components)
local Prefabs = require(script.Prefabs)
local Popups = require(script.Popups)
local Prompts = require(script.Prompts)
local Bind = require(script.Util.Bind)
local Color = require(script.Util.Color)
local Format = require(script.Util.Format)
local Input = require(script.Util.Input)
local Sound = require(script.Util.Sound)
local Viewport = require(script.Util.Viewport)
local Tooltip = require(script.Components.Tooltip)
local HatchReveal = require(script.Components.HatchReveal)
local CaseSpinner = require(script.Components.CaseSpinner)
local SpinWheel = require(script.Components.SpinWheel)
local Countdown = require(script.Components.Countdown)
local Spotlight = require(script.Components.Spotlight)
local HudKits = require(script.Components.HudKits)
local Gamepad = require(script.Util.Gamepad)
local Version = require(script.Version)

local BloxUI = {
	version = Version.runtime, -- stamped from version.json by build.ps1

	Fusion = Fusion,
	Theme = Theme,
	Icons = Icons,
	Assets = Assets,
	Kit = Kit,
	FX = FX,
	Motion = FX.Motion, -- UI.Motion.reduced: the reduced-motion switch
	Components = Components,
	Prefabs = Prefabs,
	Prompts = Prompts,

	Color = Color,
	Format = Format,
	Input = Input,
	Sound = Sound,
	Viewport = Viewport,

	-- Fusion shortcuts
	Children = Fusion.Children,
	OnEvent = Fusion.OnEvent,
	OnChange = Fusion.OnChange,
	Out = Fusion.Out,
	peek = Fusion.peek,
	doCleanup = Fusion.doCleanup,

	-- popups (UI.Popups.configure({ Toasts = "Top" }) moves the toast stack)
	Popups = Popups,
	notify = Popups.notify,
	confirm = Popups.confirm,
	reward = Popups.reward,
	dialogue = Popups.dialogue,
	hatch = HatchReveal.reveal, -- UI.hatch({ Egg = "egg", Result = { name, icon, rarity } })
	HatchReveal = HatchReveal, -- UI.HatchReveal.skip = true: every hatch plays its fast path

	-- hover help
	Tooltip = Tooltip,
	tooltip = Tooltip.attach, -- UI.tooltip(scope, guiObject, "text")

	-- match flow & guidance (these yield until finished)
	CaseSpinner = CaseSpinner,
	openCase = CaseSpinner.open, -- UI.openCase({ Case = {...}, Pool = {...} }) -> won item
	SpinWheel = SpinWheel, -- UI.SpinWheel.skip = true: every prize wheel plays its fast path
	Countdown = Countdown,
	countdown = Countdown.run, -- UI.countdown({ From = 3, Text = "GO!" })
	Spotlight = Spotlight,
	spotlight = Spotlight.highlight, -- UI.spotlight(button, { Text = "Tap here!" })

	-- HUD kits (v1.8): named HUD looks. UI.HudKits.names, .get(name), .styles,
	-- .apply(props, kit, "HealthBar"), .pick(genre, seed), .enter(scope, gui, opts)
	HudKits = HudKits,
	Gamepad = Gamepad,

	-- data binding
	fromProperty = Bind.fromProperty,
	fromAttribute = Bind.fromAttribute,
	fromValueObject = Bind.fromValueObject,
	leaderstat = Bind.leaderstat,
	characterHealth = Bind.characterHealth,
}

-- Big optional systems load the first time they're used, so games that only
-- use components don't pay for them:
--   UI.Blueprint   build a whole game UI from Blueprint data
--   UI.Server      server glue for blueprints (leaderstats, remotes, receipts)
--   UI.Generator / UI.AI
--                  the prompt improver and generators. They run on the BloxUI
--                  server (the plugin talks to it) and are NOT part of this
--                  library, so both are nil here; they only exist in the
--                  private source tree the server loads.
local LAZY = { "Blueprint", "Server", "Generator", "AI" }
setmetatable(BloxUI, {
	__index = function(self, key)
		if table.find(LAZY, key) then
			local module = script:FindFirstChild(key)
			if module and module:IsA("ModuleScript") then
				local loaded = require(module)
				rawset(self, key, loaded)
				return loaded
			end
		end
		-- absent (UI.Generator / UI.AI in every shipped copy): nil, never an error
		return nil
	end,
})

-- A Fusion scope with every BloxUI component available as a method:
--   local scope = UI.scope()
--   scope:Button { ... }   scope:Value(0)   scope:Computed(...)
-- Pass extra tables of your own components to add them too.
-- Clean everything up with UI.doCleanup(scope).
function BloxUI.scope(...: { [string]: any }): any
	return Fusion.scoped(Fusion, Components, ...)
end

return BloxUI
]]></string>
        </Properties>
        <Item class="ModuleScript" referent="33">
          <Properties>
            <string name="Name">Assets</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Assets

	Every image, font and sound BloxUI uses. They all ship inside the Roblox
	client (rbxasset://), so nothing needs uploading and nothing can be
	moderated away.

	If you'd rather use your own art, swap a value for an uploaded id:
		require(ReplicatedStorage.BloxUI).Assets.Glow = "rbxassetid://123"
	Do this before building any UI.
]]

return {
	-- Vector icon font with 771 icons (see Icons.luau).
	IconFont = "rbxasset://LuaPackages/Packages/_Index/BuilderIcons/BuilderIcons/BuilderIcons.json",
	-- Roblox's published copy of the same font (BuilderIconsFallback), used
	-- automatically when the client path above doesn't load on a device.
	IconFontFallback = "rbxassetid://97149316496910",

	-- Soft rounded glow, used for drop shadows (9-slice).
	SoftShadow = "rbxasset://textures/ui/Lobby/Buttons/glow_nine_slice.png",
	SoftShadowSlice = Rect.new(66, 66, 90, 90),
	SoftShadowFalloff = 66, -- pixels of fade in the source image

	-- Dark edges, clear centre. Gentle version and "tunnel vision" version.
	Vignette = "rbxasset://textures/loading/loadingvignette.png",
	Tunnel = "rbxasset://textures/ui/VR/edgeBlur.png",

	-- Soft round white glow (halos behind icons, button glow, reward reveals).
	Glow = "rbxasset://textures/ui/AvatarExperience/glowLight.png",
	-- The same white glow sliced into a rounded rectangle (Kit.softGlow): unlike
	-- SoftShadow (baked blue) it tints to any colour, warm glows included.
	SoftGlowSlice = Rect.new(255, 255, 257, 257),
	SoftGlowFalloff = 205, -- pixels of fade in the source image

	-- Small crisp white dot (decoration patterns, pips).
	Dot = "rbxasset://textures/smallWhiteCircle.png",

	-- Radiant backdrop for reward reveals. The engine's old glow.png starburst
	-- has a dark grey body that reads as a dirty square, so this is a clean
	-- white radial glow; for real light beams use Kit.rays(scope, {...}).
	Rays = "rbxasset://textures/ui/AvatarExperience/glowLight.png",

	-- Four point twinkle. Has dark fringes; BloxUI draws its own twinkles
	-- (FX.Particles.newTwinkle) and only keeps this for compatibility.
	Sparkle = "rbxasset://textures/sparkle.png",

	-- Plain white circle for dots, ripples and round particles.
	Circle = "rbxasset://textures/whiteCircle.png",

	-- Rounded scroll bar thumb (Roblox's own; white at half alpha, so tint it).
	ScrollTop = "rbxasset://textures/ui/Scroll/scroll-top.png",
	ScrollMid = "rbxasset://textures/ui/Scroll/scroll-middle.png",
	ScrollBottom = "rbxasset://textures/ui/Scroll/scroll-bottom.png",

	-- Short UI tick sound from the Roblox settings menu.
	Tick = "rbxasset://sounds/volume_slider.ogg",
}
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="34">
          <Properties>
            <string name="Name">Blueprint</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Blueprint: turns a Blueprint (plain JSON data, docs/BLUEPRINT.md) into a
	complete, polished UI. This is the only place layout decisions are made.

		local Blueprint = UI.Blueprint
		local blueprint, warnings = Blueprint.normalize(anything)     -- never throws
		local ok, problems, blueprint = Blueprint.validate(anything)  -- ok = no warnings

		local ui = Blueprint.build(scope, blueprint, {
			Parent = playerGui,     -- default: LocalPlayer.PlayerGui (plugin preview: CoreGui)
			Preview = false,        -- true: no hotkeys, sounds, remotes or purchases
			Handlers = { Rebirth = function(context) end },
			Theme = true,           -- false: keep the current theme
			Touch = nil,            -- true/false: lay the HUD out for phones (default: the device)
		})
		ui:open("shop")  ui:close("shop")  ui:toggle("shop")  ui:isOpen("shop")
		ui:on("Rebirth", function(context) end)     -- Event actions and element ids
		ui.currencies.coins                         -- Fusion Value
		ui:give("coins", 250, fromPartOrGui)        -- coins fly into the counter
		ui.hud.health.Health                        -- HUD element handles by id
		ui:setSetting("fov", 90)  ui:getSetting("fov")   -- Settings (Blueprint.Settings: real effects, saved)
		ui.display.hud                              -- Value: the HUD is shown (the showHud setting)
		ui:setOwned({ "dragon", "cat" })            -- v1.6: owned items shown in colour in Index sections
		ui:spin("wheel", 3)                         -- v1.6: play a spin the server rolled onto wedge 3
		ui.rewards                                  -- v1.6: reward state (Blueprint.Rewards)
		ui:destroy()

		Blueprint.encode(blueprint) -> json      Blueprint.decode(json) -> blueprint, warnings
		Blueprint.setLayout(blueprint, "Simulator")   -- v2.3: re-arrange an editable blueprint's HUD
		Blueprint.fromInstance(stringValue)      Blueprint.get("Main") (built by the loader)
		Blueprint.Examples.fps                   five hand-made blueprints
		Blueprint.Samples.get("fps_7")           ready-made blueprints, two per genre (Samples/init.luau)
		Blueprint.Integration.generate(bp, { name = "Main" })   "Export as code": the
		                                         UIHooks / ServerHooks scripts + a checklist
]]

local HttpService = game:GetService("HttpService")
local Players = game:GetService("Players")

local Fusion = require(script.Parent.Packages.Fusion)
local Theme = require(script.Parent.Theme)
local Kit = require(script.Parent.Kit)
local Components = require(script.Parent.Components)
local Popups = require(script.Parent.Popups)
local Bind = require(script.Parent.Util.Bind)
local Format = require(script.Parent.Util.Format)
local Input = require(script.Parent.Util.Input)
local Sound = require(script.Parent.Util.Sound)
local Viewport = require(script.Parent.Util.Viewport)
local Motion = require(script.Parent.FX.Motion)
local Particles = require(script.Parent.FX.Particles)

local Schema = require(script.Schema)
local Normalize = require(script.Normalize)
local Common = require(script.Common)
local Actions = require(script.Actions)
local Hud = require(script.Hud)
local Screens = require(script.Screens)
local Sections = require(script.Sections)
local Settings = require(script.Settings)
local Rewards = require(script.Rewards)

local Children = Fusion.Children
local peek = Fusion.peek

local Blueprint = {}

Blueprint.Schema = Schema
Blueprint.Normalize = Normalize
Blueprint.Actions = Actions
Blueprint.Common = Common
Blueprint.Hud = Hud
Blueprint.Screens = Screens
Blueprint.Sections = Sections
Blueprint.Settings = Settings
Blueprint.Rewards = Rewards
Blueprint.Examples = require(script.Examples)
Blueprint.MISSING = Common.MISSING

-- Blueprint.Integration ("Export as code": hook scripts and a checklist for a
-- blueprint) and Blueprint.Samples (ready-made blueprints per genre) load the
-- first time they're used. Only the plugin and tools need them, never a
-- running game.
setmetatable(Blueprint, {
	__index = function(self, key)
		if key == "Integration" or key == "Samples" then
			local module = script:FindFirstChild(key)
			if module then
				local loaded = require(module)
				rawset(self, key, loaded)
				return loaded
			end
		end
		return nil
	end,
})

--------------------------------------------------------------------------
-- Data
--------------------------------------------------------------------------

-- A complete, valid blueprint from anything, plus warnings. Never throws.
function Blueprint.normalize(input: any): ({ [string]: any }, { string })
	return Normalize.run(input)
end

-- ok is true when the input needed no repairs. Never throws.
function Blueprint.validate(input: any): (boolean, { string }, { [string]: any })
	local bp, warnings = Normalize.run(input)
	return #warnings == 0, warnings, bp
end

-- v2.3: switches an editable blueprint's HUD layout in place: the currencies
-- and the menu move from where the old layout put them to the new layout's
-- spots (anything moved by hand, and teleport rows, stay). False for an
-- unknown layout. Setting hud.layout alone does the same at the next
-- normalize, without touching the table you hold.
Blueprint.setLayout = Normalize.setLayout

function Blueprint.encode(blueprint: any): string
	local ok, json = pcall(function()
		return HttpService:JSONEncode(blueprint)
	end)
	if ok then
		return json
	end
	return HttpService:JSONEncode((Normalize.run(blueprint)))
end

-- JSON -> normalized blueprint + warnings. Bad JSON gives the default blueprint.
function Blueprint.decode(json: any): ({ [string]: any }, { string })
	if typeof(json) ~= "string" then
		local bp, warnings = Normalize.run(nil)
		table.insert(warnings, 1, "decode: expected a JSON string")
		return bp, warnings
	end
	local ok, data = pcall(function()
		return HttpService:JSONDecode(json)
	end)
	if not ok then
		local bp, warnings = Normalize.run(nil)
		table.insert(warnings, 1, `decode: invalid JSON ({data})`)
		return bp, warnings
	end
	return Normalize.run(data)
end

Blueprint.FOLDER = "BloxUI_Blueprints"

-- Reads a StringValue holding blueprint JSON (or a name inside
-- ReplicatedStorage.BloxUI_Blueprints).
function Blueprint.fromInstance(value: any): ({ [string]: any }, { string })
	if typeof(value) == "string" then
		local folder = game:GetService("ReplicatedStorage"):FindFirstChild(Blueprint.FOLDER)
		value = folder and folder:FindFirstChild(value)
	end
	if typeof(value) ~= "Instance" or not value:IsA("StringValue") then
		local bp, warnings = Normalize.run(nil)
		table.insert(warnings, 1, "fromInstance: expected a StringValue")
		return bp, warnings
	end
	return Blueprint.decode(value.Value)
end

--------------------------------------------------------------------------
-- Style -> theme
--------------------------------------------------------------------------

local ARCHETYPE_PRESET = {
	Arena = "Toon", Bubbly = "Toon", Adventure = "Toon", Sleek = "Toon",
	Candy = "Candy", Tactical = "Tactical", Horror = "Horror",
}

-- Blackletter, calligraphic, dripping and scratchy hand faces only read at
-- logo size (docs/QUALITY.md: never on banners, buttons, numbers or body
-- text). Blueprint.readableFonts gives the Title / Display slots a readable
-- face and keeps the ornate one in theme.Font.Ornate for the logo (and for
-- window titles, once Components/Window asks for it).
local ORNATE_FACES = { GrenzeGotisch = true, Fondamento = true, Creepster = true, AmaticSC = true }

local function fontFamily(font: any): string
	if typeof(font) ~= "Font" then
		return ""
	end
	return string.match(font.Family, "families/([%w]+)%.json$") or ""
end

function Blueprint.readableFonts(theme: { [string]: any }): { [string]: any }
	local fonts = theme.Font
	if typeof(fonts) ~= "table" then
		return theme
	end
	if ORNATE_FACES[fontFamily(fonts.Body)] then
		fonts.Body = Theme.fontFromRef("BuilderSans:Bold")
		fonts.Heavy = Theme.fontFromRef("BuilderSans:ExtraBold")
		fonts.Small = Theme.fontFromRef("BuilderSans:Medium")
	end
	local ornate = nil
	if ORNATE_FACES[fontFamily(fonts.Title)] then
		ornate = fonts.Title
	end
	if ORNATE_FACES[fontFamily(fonts.Display)] then
		ornate = ornate or fonts.Display
		fonts.Display = fonts.Heavy or fonts.Body
	end
	if ornate then
		fonts.Ornate = ornate
		if ORNATE_FACES[fontFamily(fonts.Title)] then
			fonts.Title = fonts.Display
		end
	end
	return theme
end

-- Builds and applies the theme for a blueprint style. Uses Theme.fromStyle
-- when the theme system has it, otherwise the closest preset tinted with the
-- palette's accent colours.
function Blueprint.applyStyle(style: { [string]: any }): { [string]: any }?
	local fromStyle = (Theme :: any).fromStyle
	if typeof(fromStyle) == "function" then
		local ok, theme = pcall(fromStyle, style)
		if ok and typeof(theme) == "table" then
			Blueprint.readableFonts(theme)
			Theme.set(theme)
			return theme
		end
		warn(`[BloxUI] Blueprint: Theme.fromStyle failed ({theme}), using a preset`)
	end
	local presetName = if Theme.presets[style.archetype] then style.archetype else ARCHETYPE_PRESET[style.archetype] or "Toon"
	local base = Theme.presets[presetName] or Theme.presets.Toon
	local palette = style.palette or {}
	local ok, theme = pcall(Theme.extend, base, {
		Name = `Blueprint{style.archetype or ""}`,
		Color = {
			Accent = Common.color(palette.accent, base.Color.Accent),
			Good = Common.color(palette.good, base.Color.Good),
			Bad = Common.color(palette.bad, base.Color.Bad),
			Warn = Common.color(palette.warn, base.Color.Warn),
		},
	})
	if ok then
		Theme.set(theme)
		return theme
	end
	Theme.set(base)
	return base
end

--------------------------------------------------------------------------
-- Registry (filled by Templates/BlueprintLoader)
--------------------------------------------------------------------------

local registry: { [string]: any } = {}

function Blueprint.register(name: string, ui: any)
	registry[name] = ui
	ui._registeredAs = name
end

function Blueprint.get(name: string?): any
	if name == nil then
		local _, first = next(registry)
		return first
	end
	return registry[name]
end

-- Waits (up to `timeout` seconds, default 10) for the loader to build `name`.
function Blueprint.waitFor(name: string, timeout: number?): any
	local deadline = os.clock() + (timeout or 10)
	while registry[name] == nil and os.clock() < deadline do
		task.wait(0.1)
	end
	return registry[name]
end

function Blueprint.all(): { [string]: any }
	return table.clone(registry)
end

-- Components the builder looked for but couldn't find (placeholders drawn).
function Blueprint.missingComponents(): { string }
	return table.clone(Common.missing)
end

--------------------------------------------------------------------------
-- Build
--------------------------------------------------------------------------

local previewCount = 0
local soundOwner: any = nil -- the ui whose style.sounds are playing
local soundWasEnabled = true

local TOAST_TONES = { error = "Red", success = "Green", warning = "Yellow", reward = "Yellow", info = "Blue" }

local function weightedPick(pool: { any }): any
	local total = 0
	for _, drop in pool do
		total += math.max(0, drop.chance or 1)
	end
	local roll = math.random() * math.max(total, 1e-6)
	for _, drop in pool do
		roll -= math.max(0, drop.chance or 1)
		if roll <= 0 then
			return drop
		end
	end
	return pool[#pool]
end

export type BuildOptions = {
	Parent: Instance?,
	Preview: boolean?,
	Handlers: { [string]: (any) -> () }?,
	Theme: boolean?,
	Touch: boolean?,
}

function Blueprint.build(scope: any, input: any, options: BuildOptions?): any
	local opts = options or {}
	local bp, warnings = Normalize.run(input)
	local preview = opts.Preview == true
	local player = Players.LocalPlayer

	local parent = opts.Parent
	if parent == nil and player then
		if preview then
			parent = player:FindFirstChildOfClass("PlayerGui")
		else
			parent = player:WaitForChild("PlayerGui")
		end
	end

	local root = Fusion.scoped(Fusion, Components)
	local destroyed = false

	if opts.Theme ~= false then
		local style = bp.style
		if preview and player == nil then
			-- plugin preview: there is no FX layer without a LocalPlayer
			style = table.clone(style)
			style.clickFx = "None"
		end
		Blueprint.applyStyle(style)
	end
	if preview then
		if previewCount == 0 then
			soundWasEnabled = Sound.enabled
		end
		previewCount += 1
		Sound.enabled = false
	end

	local ui: { [string]: any } = {
		blueprint = bp,
		warnings = warnings,
		preview = preview,
		currencies = {},
		currencyDefs = {},
		currencyFrames = {},
		hud = {},
		screens = {},
		settings = {},
		Guis = {},
		_state = {},
		_handlers = {},
	}
	local handlers: { [string]: { (any) -> () } } = ui._handlers
	for name, fn in opts.Handlers or {} do
		if typeof(fn) == "function" then
			handlers[name] = { fn }
		end
	end

	-- style.sounds replace the theme's UI sounds while this UI lives
	if opts.Theme ~= false and typeof(Sound.override) == "function" then
		Sound.override(bp.style.sounds)
		soundOwner = ui
	end
	-- settings: saved values, built-in effects and saving (Blueprint.Settings)
	local settingIndex = Settings.attach(root, ui, { preview = preview, player = player })

	-- clock for countdowns
	local clock = root:Value(os.time())
	do
		local alive = true
		table.insert(root, function()
			alive = false
		end)
		task.spawn(function()
			while alive do
				task.wait(1)
				if alive then
					clock:set(os.time())
				end
			end
		end)
	end

	-- v1.6 rewards: one shared state per reward section (badges, claims, popups)
	local rewards = Rewards.attach(root, ui, { preview = preview, player = player, clock = clock })

	-- currencies
	for _, def in bp.currencies do
		ui.currencyDefs[def.id] = def
		local value
		if not preview and player and def.source.kind == "leaderstat" then
			value = Bind.leaderstat(root, def.source.name, player, def.start)
		elseif not preview and player and def.source.kind == "attribute" then
			value = Bind.fromAttribute(root, player, def.source.name, def.start)
		else
			value = root:Value(def.start)
		end
		ui.currencies[def.id] = value
	end

	local screensById: { [string]: any } = {}
	for _, def in bp.screens do
		screensById[def.id] = def
	end

	-- layers
	local menusOpen = root:Value(0)
	Settings.music(root, ui, menusOpen, { preview = preview, player = player })
	local function layer(name: string, order: number): Instance
		local gui = root:Screen { Name = name, DisplayOrder = order, Parent = parent }
		ui.Guis[name] = gui
		return gui:FindFirstChild("Canvas") :: Instance
	end
	local ctx: { [string]: any } = {
		bp = bp,
		ui = ui,
		preview = preview,
		parent = parent,
		compact = bp.style.density == "Compact",
		touch = opts.Touch == true or (opts.Touch == nil and Viewport.isTouch),
		layout = bp.hud.layout,
		clock = clock,
		currencyDefs = ui.currencyDefs,
	}
	ctx.layers = {
		windows = layer("BloxUI_Screens", 10),
		popups = layer("BloxUI_Popups", 20),
		menus = layer("BloxUI_Menus", 30),
	}
	-- { open, tuck, width }: a window is open / a big one wants the HUD tucked
	-- away / the widest open window (design px). Blueprint.Hud reads it; each
	-- screen's Open observer refreshes it.
	local modalState = root:Value({ open = false, tuck = false, width = 0, height = 0 })
	ctx.modal = modalState
	local function refreshModal()
		if destroyed then
			return
		end
		local state = { open = false, tuck = false, width = 0, height = 0 }
		for _, handle in ui.screens do
			local modal = handle.Modal
			if modal and peek(handle.Open) then
				state.open = true
				state.tuck = state.tuck or modal.tuck == true
				state.width = math.max(state.width, modal.width or 0)
				state.height = math.max(state.height, modal.height or 0)
				-- v2.4 (style families): a docked window's edge, so the HUD
				-- knows which stacks it covers (Blueprint.Hud windowRect)
				state.dock = state.dock or modal.dock
			end
		end
		local now = peek(modalState)
		if now.open ~= state.open or now.tuck ~= state.tuck or now.width ~= state.width or now.height ~= state.height or now.dock ~= state.dock then
			modalState:set(state)
		end
	end
	ctx.run = function(action: any, context: { [string]: any }?)
		Actions.run(ui, action, context and table.clone(context) or {})
	end
	ctx.buy = function(item: any, context: { [string]: any })
		return Actions.buy(ui, item, context)
	end
	-- A themed Button wired to an action. `onClick` runs first (optional).
	ctx.button = function(buttonScope: any, props: { [any]: any }, action: any?, context: { [string]: any }?, onClick: (() -> ())?)
		props.OnClick = function()
			if onClick then
				onClick()
			end
			if action and (action.kind ~= "None" or (context and context.id)) then
				ctx.run(action, context)
			end
		end
		if preview then
			props.Sound = ""
			props.Hotkey = nil
			props.ClickFx = "None"
		end
		local button = buttonScope:Button(props)
		button:SetAttribute("BloxUIAction", true)
		return button
	end
	ui._ctx = ctx

	----------------------------------------------------------------------
	-- ui methods
	----------------------------------------------------------------------

	function ui.on(_self, name: string, fn: (any) -> ()): () -> ()
		local list = handlers[name]
		if list == nil then
			list = {}
			handlers[name] = list
		end
		table.insert(list, fn)
		return function()
			local index = table.find(list, fn)
			if index then
				table.remove(list, index)
			end
		end
	end

	function ui.emit(_self, name: string, context: { [string]: any }?)
		local list = handlers[name]
		if list == nil or destroyed then
			return
		end
		local c = context or {}
		c.ui = ui
		for _, fn in table.clone(list) do
			task.spawn(fn, c)
		end
	end

	-- Persistent per-section state (kept while screens are rebuilt).
	function ui.state(_self, section: string, key: string, default: any): any
		local sectionState = ui._state[section]
		if sectionState == nil then
			sectionState = {}
			ui._state[section] = sectionState
		end
		local value = sectionState[key]
		if value == nil then
			value = root:Value(default)
			sectionState[key] = value
		end
		return value
	end

	local toastColumn = nil
	local toastCount = 0
	-- Small in-UI message (used in Preview mode instead of real prompts).
	function ui.toast(_self, text: string, icon: string?, tone: string?)
		if destroyed then
			return
		end
		if toastColumn == nil then
			toastColumn = root:Column {
				Name = "Toasts",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, 24),
				Size = UDim2.fromOffset(800, 0),
				AutoSize = "Y",
				Gap = 8,
				ZIndex = 100,
				Parent = ctx.layers.popups,
			}
		end
		toastCount += 1
		local toastScope = root:innerScope()
		Common.pill(toastScope, {
			Name = "Toast",
			Text = text,
			Icon = icon or "info",
			Tone = tone or "Dark",
			Height = 48,
			LayoutOrder = -toastCount,
			ZIndex = 101,
		}).Parent = toastColumn
		task.delay(2.4, function()
			if not destroyed then
				Fusion.doCleanup(toastScope)
			end
		end)
	end

	-- `essential`: shows even with notifications turned off (Popups.quiet),
	-- like purchase confirmations. Errors and warnings always show.
	function ui.notify(_self, text: string, kind: string?, essential: boolean?)
		if preview or player == nil then
			ui:toast(text, if kind == "error" then "error" elseif kind == "success" then "success" else "info", TOAST_TONES[kind or "info"])
			return
		end
		task.spawn(Popups.notify, text, { Type = kind or "info", Essential = essential == true })
	end

	function ui.shakeCurrency(_self, id: string)
		local frame = ui.currencyFrames[id]
		if frame and not preview then
			pcall(Motion.shake, frame, 10, 0.4)
		end
	end

	-- Adds `amount` to a currency; coins fly from `from` into its counter.
	-- Server-backed currencies (leaderstat / attribute) only play the effect.
	function ui.give(_self, id: string, amount: number, from: any?)
		local value = ui.currencies[id]
		local def = ui.currencyDefs[id]
		if value == nil or def == nil then
			warn(`[BloxUI] Blueprint: no currency "{id}"`)
			return
		end
		local serverOwned = def.source.kind ~= "none" and not preview
		local function add(n: number)
			if not serverOwned and not destroyed then
				value:set(peek(value) + n)
			end
		end
		local frame = ui.currencyFrames[id]
		if preview or frame == nil or player == nil then
			add(amount)
			return
		end
		local origin = from or UDim2.fromScale(0.5, 0.6)
		local count = math.clamp(math.ceil(amount / math.max(1, amount / 8)), 1, 10)
		local share = amount / count
		local landed = 0
		local ok = pcall(function()
			Particles.popText(origin, `+{Format.abbrev(amount)}`, { Color = Common.color(def.color) })
			Particles.flyTo(origin, frame, {
				Icon = def.icon,
				Color = Common.color(def.color),
				Count = count,
				OnArrive = function()
					landed += 1
					add(if landed == count then amount - share * (count - 1) else share)
				end,
			})
		end)
		if not ok then
			add(amount - share * landed)
		end
	end

	local function build(id: string): any
		local handle = ui.screens[id]
		if handle then
			return handle
		end
		local def = screensById[id]
		if def == nil then
			return nil
		end
		local screenScope = root:innerScope()
		local ok, result = pcall(Screens.build, ctx, screenScope, def)
		if not ok or typeof(result) ~= "table" then
			warn(`[BloxUI] Blueprint: screen "{id}" failed to build: {result}`)
			Fusion.doCleanup(screenScope)
			return nil
		end
		result.def = def
		result.scope = screenScope
		if result.Modal == nil and (def.kind == "Results" or def.kind == "Trade") then
			-- end-of-match cards and trades take the whole screen's attention
			result.Modal = { tuck = true, width = if def.kind == "Trade" then 1400 else 1920, height = 1080 }
		end
		ui.screens[id] = result
		if result.Modal and typeof(result.Open) == "table" then
			table.insert(screenScope, screenScope:Observer(result.Open):onChange(refreshModal))
		end
		return result
	end

	local warnedMissing: { [string]: boolean } = {}
	function ui.open(_self, id: string): boolean
		if destroyed then
			return false
		end
		local def = screensById[id]
		if def == nil then
			if not warnedMissing[id] then
				warnedMissing[id] = true
				warn(`[BloxUI] Blueprint: no screen "{id}"`)
			end
			return false
		end
		if Schema.WINDOW_KINDS[def.kind] then
			for otherId, other in ui.screens do
				if otherId ~= id and Schema.WINDOW_KINDS[other.def.kind] and peek(other.Open) then
					other.Open:set(false)
				end
			end
		end
		local handle = build(id)
		if handle == nil then
			return false
		end
		if not peek(handle.Open) then
			handle.Open:set(true)
			if def.kind == "MainMenu" or def.kind == "Loading" then
				menusOpen:set(peek(menusOpen) + 1)
			end
			ui:emit("ScreenOpened", { screen = id, payload = id })
		end
		return true
	end

	function ui.close(_self, id: string): boolean
		local handle = ui.screens[id]
		if handle == nil or destroyed then
			return false
		end
		if peek(handle.Open) then
			handle.Open:set(false)
			if handle.def.kind == "MainMenu" or handle.def.kind == "Loading" then
				menusOpen:set(math.max(0, peek(menusOpen) - 1))
			end
			ui:emit("ScreenClosed", { screen = id, payload = id })
		end
		return true
	end

	function ui.toggle(_self, id: string): boolean
		if ui:isOpen(id) then
			return ui:close(id)
		end
		return ui:open(id)
	end

	function ui.isOpen(_self, id: string): boolean
		local handle = ui.screens[id]
		return handle ~= nil and peek(handle.Open) == true
	end

	function ui.closeAll(_self)
		for id in ui.screens do
			ui:close(id)
		end
	end

	-- Builds every screen now instead of on first open.
	function ui.preload(_self)
		for _, def in bp.screens do
			build(def.id)
		end
	end

	-- Plays the case opener (CaseSpinner when it exists) and returns the drop.
	-- `wonName` is the drop UI.Server rolled; without it (client-side
	-- currencies, Preview) the roll happens here.
	function ui.openCase(_self, caseDef: { [string]: any }, wonName: string?): any
		local winner = nil
		if typeof(wonName) == "string" then
			for _, drop in caseDef.pool do
				if drop.name == wonName then
					winner = drop
					break
				end
			end
			winner = winner or { name = wonName, icon = caseDef.icon, rarity = "Common", chance = 0 }
		end
		local rolledByServer = winner ~= nil
		winner = winner or weightedPick(caseDef.pool)
		local context = { id = caseDef.id, payload = winner }
		if preview then
			ui:toast(`{caseDef.title}: {winner.name} ({winner.rarity})`, winner.icon, "Purple")
			ui:emit("CaseOpened", context)
			return winner
		end
		local CaseSpinner = Common.component("CaseSpinner")
		task.spawn(function()
			local won = winner
			if CaseSpinner and typeof(CaseSpinner.open) == "function" then
				local ok, result = pcall(CaseSpinner.open, {
					Case = { title = caseDef.title, icon = caseDef.icon, tone = caseDef.tone },
					Pool = caseDef.pool,
					Winner = winner,
				})
				if ok and typeof(result) == "table" and not rolledByServer then
					won = result
				end
			else
				-- what a purchase gave: shown even with notifications off
				ui:notify(`You got {winner.name}!`, "reward", true)
			end
			context.payload = won
			ui:emit("CaseOpened", context)
		end)
		return winner
	end

	function ui.destroy(_self)
		if destroyed then
			return
		end
		destroyed = true
		if ui._registeredAs and registry[ui._registeredAs] == ui then
			registry[ui._registeredAs] = nil
		end
		Fusion.doCleanup(root)
		if soundOwner == ui then
			soundOwner = nil
			Sound.override(nil)
		end
		if preview then
			previewCount = math.max(0, previewCount - 1)
			if previewCount == 0 then
				Sound.enabled = soundWasEnabled
			end
		end
	end

	----------------------------------------------------------------------
	-- HUD + hotkeys
	----------------------------------------------------------------------

	local hudOk, hudResult = pcall(Hud.build, ctx, root:innerScope(), bp.hud)
	if hudOk then
		ui.hud = hudResult.handles
		ui.Gui = hudResult.Gui
		ui.Guis.BloxUI_Hud = hudResult.Gui
		-- the HUD hides while a main menu or loading screen covers it, and
		-- while the player has turned it off (the "showHud" setting)
		local hudGui = hudResult.Gui
		local overlay = hudResult.Overlay
		local top = hudResult.Top
		local function refreshHud()
			if destroyed then
				return
			end
			local shown = peek(menusOpen) == 0 and peek(ui.display.hud) == true
			hudGui.Enabled = shown
			if overlay then
				overlay.Enabled = shown
			end
			if top then
				top.Enabled = shown
			end
		end
		table.insert(root, root:Observer(menusOpen):onChange(refreshHud))
		table.insert(root, root:Observer(ui.display.hud):onChange(refreshHud))
		refreshHud()
	else
		warn(`[BloxUI] Blueprint: HUD failed to build: {hudResult}`)
	end

	-- showHud: a hidden HUD always has a way back. Settings.HUD_KEY (F1)
	-- toggles it, a small eye button in the top-right corner shows it again,
	-- and a toast says so when it goes.
	local hudSetting = settingIndex.showHud
	if not preview and player and hudSetting and hudSetting[1].type == "Toggle" then
		Input.bind(root, Settings.HUD_KEY, function()
			ui:setSetting("showHud", not peek(ui.display.hud))
		end)
		local safeTop = Kit.safeTop(root)
		local eye = ctx.button(root, {
			Name = "ShowHud",
			Icon = "eye",
			Tone = "Dark",
			Size = UDim2.fromOffset(60, 60),
			AnchorPoint = Vector2.new(1, 0),
			Position = root:Computed(function(use)
				return UDim2.new(1, -24, 0, use(safeTop))
			end),
			Visible = root:Computed(function(use)
				return not use(ui.display.hud) and use(menusOpen) == 0 and not use(modalState).open
			end),
		}, { kind = "None" }, nil, function()
			ui:setSetting("showHud", true)
		end)
		eye.Parent = ctx.layers.popups
		local function hint()
			if not destroyed and not peek(ui.display.hud) then
				local touch = peek(Input.device) == "Touch"
				ui:notify(if touch
					then "HUD hidden. Tap the eye to bring it back."
					else `HUD hidden. Press {Settings.HUD_KEY.Name} or click the eye to bring it back.`, "info", true)
			end
		end
		table.insert(root, root:Observer(ui.display.hud):onChange(hint))
		task.defer(hint)
	end

	-- Robux cases: the server rolls the drop and tells this client
	if not preview and player then
		table.insert(root, Actions.onServer(Actions.CASE_RESULT, function(caseId, wonName)
			if destroyed then
				return
			end
			for _, screen in bp.screens do
				local lists = { screen.sections }
				for _, tab in screen.tabs or {} do
					table.insert(lists, tab.sections)
				end
				for _, list in lists do
					for _, section in list or {} do
						for _, c in (section.kind == "Cases" and section.cases) or {} do
							if c.id == caseId then
								ui:openCase(c, wonName)
								return
							end
						end
					end
				end
			end
		end))
	end

	-- v1.6 Robux spins: the server rolls the wedge and tells this client
	if not preview and player then
		table.insert(root, Actions.onServer(Actions.SPIN_RESULT, function(sectionId, index)
			if not destroyed and typeof(sectionId) == "string" and typeof(index) == "number" then
				ui:spin(sectionId, index)
			end
		end))
	end

	if not preview then
		for _, def in bp.screens do
			local key = if def.openKey then Common.inputEnum(def.openKey) else nil
			if key then
				Input.bind(root, key, function()
					ui:toggle(def.id)
				end)
			end
		end
	end

	-- v1.6: offline earnings and offers pop up by themselves (never in previews)
	rewards:start()

	if scope ~= nil and typeof(scope) == "table" then
		table.insert(scope, function()
			ui:destroy()
		end)
	end
	return ui
end

return Blueprint
]]></string>
          </Properties>
          <Item class="ModuleScript" referent="35">
            <Properties>
              <string name="Name">Actions</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Blueprint.Actions: runs blueprint actions for a built UI.

		Actions.run(ui, { kind = "Open", target = "shop" }, { id = "shopButton", screen = nil })

	Kinds: None, Open, Close, Toggle, Purchase, GamePass, Remote, Event, Notify.

	- Remote fires ReplicatedStorage.<name> when a RemoteEvent with that name
	  exists (FireServer(elementId, payload)); otherwise the shared
	  ReplicatedStorage.BloxUI_Action remote (FireServer(name, elementId, payload)),
	  which UI.Server dispatches to UI.Server.on(name, fn).
	- Event calls local handlers: build options Handlers[name] and ui:on(name, fn).
	  An Event's `payload` (v2.3) arrives as context.payload.
	- Every click also calls handlers registered for the clicked element's id.
	- In Preview mode nothing leaves the UI: purchases, remotes and server
	  buys only show a small preview toast; navigation still works.
	- Buying with a server currency waits for UI.Server's answer
	  ("BloxUI.BuyResult") before any effect plays; the server also rolls
	  cases. See Actions.buy / Actions.request.
]]

local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Format = require(script.Parent.Parent.Util.Format)

local peek = Fusion.peek

local Actions = {}

Actions.REMOTE_NAME = "BloxUI_Action"

-- Fires a server remote without ever yielding the caller.
function Actions.fireRemote(name: string, id: string?, payload: any?)
	local direct = ReplicatedStorage:FindFirstChild(name)
	if direct and direct:IsA("RemoteEvent") then
		direct:FireServer(id, payload)
		return
	end
	local shared = ReplicatedStorage:FindFirstChild(Actions.REMOTE_NAME)
	if shared and shared:IsA("RemoteEvent") then
		shared:FireServer(name, id, payload)
		return
	end
	task.spawn(function()
		local remote = ReplicatedStorage:WaitForChild(Actions.REMOTE_NAME, 5)
		if remote and remote:IsA("RemoteEvent") then
			remote:FireServer(name, id, payload)
		else
			warn(`[BloxUI] Blueprint: no RemoteEvent "{name}" or "{Actions.REMOTE_NAME}" in ReplicatedStorage. Add UI.Server.start() to a server Script.`)
		end
	end)
end

local function prompt(ui: any, kind: string, id: number)
	if ui.preview then
		ui:toast(if kind == "product" then `Purchase prompt (product {id})` else `Game pass prompt ({id})`, "robux")
		return
	end
	local player = Players.LocalPlayer
	if player == nil then
		return
	end
	local ok, err = pcall(function()
		if kind == "product" then
			MarketplaceService:PromptProductPurchase(player, id)
		else
			MarketplaceService:PromptGamePassPurchase(player, id)
		end
	end)
	if not ok then
		warn(`[BloxUI] Blueprint: purchase prompt failed: {err}`)
	end
end

-- Runs one action. `context` = { id?, screen?, payload?, source? }.
function Actions.run(ui: any, action: any, context: { [string]: any }?)
	local ctx = context or {}
	local kind = if typeof(action) == "table" then action.kind else "None"
	ctx.action = action
	ctx.ui = ui
	local firedName = nil

	if kind == "Open" then
		ui:open(action.target)
	elseif kind == "Close" then
		local target = action.target or ctx.screen
		if target then
			ui:close(target)
		else
			ui:closeAll()
		end
	elseif kind == "Toggle" then
		ui:toggle(action.target)
	elseif kind == "Purchase" then
		prompt(ui, "product", action.productId)
	elseif kind == "GamePass" then
		prompt(ui, "gamepass", action.gamePassId)
	elseif kind == "Remote" then
		if ui.preview then
			ui:toast(`Sends "{action.name}" to the server`, "controller")
		else
			Actions.fireRemote(action.name, ctx.id, ctx.payload)
		end
	elseif kind == "Event" then
		firedName = action.name
		-- v2.3: the action's own payload (a teleport button's place) when the
		-- caller sent none
		if ctx.payload == nil and action.payload ~= nil then
			ctx.payload = action.payload
		end
		ui:emit(action.name, ctx)
	elseif kind == "Notify" then
		ui:notify(action.text or "")
	end

	if ctx.id and ctx.id ~= firedName then
		ui:emit(ctx.id, ctx)
	end
end

--------------------------------------------------------------------------
-- Buying with in-game currency / Robux
--------------------------------------------------------------------------

-- Tries to spend `price` of a client-side currency. Server-backed currencies
-- (leaderstat / attribute) are never changed here; the server decides.
local function afford(ui: any, currency: string, price: number): boolean
	local value = ui.currencies[currency]
	if value == nil then
		return false
	end
	if (peek(value) or 0) < price then
		local def = ui.currencyDefs[currency]
		ui:notify(`Not enough {if def then def.name else currency}!`, "error")
		ui:shakeCurrency(currency)
		return false
	end
	return true
end

--------------------------------------------------------------------------
-- Server requests with an answer (UI.Server replies on the same remote)
--------------------------------------------------------------------------

Actions.TIMEOUT = 5 -- seconds to wait for the server before giving up
Actions.BUY = "BloxUI.Buy"
Actions.BUY_RESULT = "BloxUI.BuyResult"
Actions.CASE_RESULT = "BloxUI.CaseResult"
-- v1.6 rewards: claims are answered like buys; Robux spins come back as a message
Actions.CLAIM = "BloxUI.Claim"
Actions.SPIN_RESULT = "BloxUI.SpinResult"
-- What a refused claim tells the player (UI.Server's reasons).
Actions.CLAIM_FAILED = {
	loading = "Your data is still loading. Try again in a moment.",
	unavailable = "Rewards aren't available right now.",
	busy = "One moment...",
	cooldown = "Not ready yet!",
	early = "Keep playing to open this one!",
	claimed = "Already claimed!",
	locked = "Not unlocked yet!",
	notInGroup = "Join the group first! (Just joined? Rejoin the game so it can see it.)",
	funds = "Not enough %s!",
	blocked = "You can't do that right now.",
	none = "Nothing to collect.",
	timeout = "The server didn't answer. Try again.",
	offline = "Rewards need the server (UI.Server.start()).",
}

local waiting: { [number]: (...any) -> () } = {}
local serverListeners: { [string]: { (...any) -> () } } = {}
local listeningTo: RemoteEvent? = nil
local listenConnection: RBXScriptConnection? = nil
local nonce = 0

-- Hooks up to BloxUI_Action.OnClientEvent (once per remote). Returns the
-- remote, or nil when there is no server glue in this game.
function Actions.listen(): RemoteEvent?
	local remote = ReplicatedStorage:FindFirstChild(Actions.REMOTE_NAME)
	if remote == nil or not remote:IsA("RemoteEvent") then
		return nil
	end
	if listeningTo ~= remote then
		if listenConnection then
			listenConnection:Disconnect()
		end
		listeningTo = remote :: RemoteEvent
		listenConnection = (remote :: RemoteEvent).OnClientEvent:Connect(function(kind: any, id: any, ...)
			if kind == Actions.BUY_RESULT then
				local callback = waiting[id]
				if callback then
					waiting[id] = nil
					callback(...)
				end
			elseif typeof(kind) == "string" and serverListeners[kind] then
				for _, fn in table.clone(serverListeners[kind]) do
					task.spawn(fn, id, ...)
				end
			end
		end)
	end
	return remote :: RemoteEvent
end

-- Calls fn(...) for server messages of `kind` (e.g. "BloxUI.CaseResult").
-- Returns a disconnect function.
function Actions.onServer(kind: string, fn: (...any) -> ()): () -> ()
	local list = serverListeners[kind]
	if list == nil then
		list = {}
		serverListeners[kind] = list
	end
	table.insert(list, fn)
	Actions.listen()
	return function()
		local index = table.find(list, fn)
		if index then
			table.remove(list, index)
		end
	end
end

-- Sends `name` to UI.Server and calls callback(ok, reason, data) with its
-- answer, or (false, "timeout") / (false, "offline").
function Actions.request(name: string, id: string?, payload: { [string]: any }, callback: (boolean, string?, any) -> (), timeout: number?)
	local remote = Actions.listen()
	if remote == nil then
		callback(false, "offline")
		return
	end
	nonce += 1
	local mine = nonce
	payload.nonce = mine
	waiting[mine] = callback
	task.delay(timeout or Actions.TIMEOUT, function()
		local pending = waiting[mine]
		if pending then
			waiting[mine] = nil
			pending(false, "timeout")
		end
	end)
	local ok = pcall(function()
		remote:FireServer(name, id, payload)
	end)
	if not ok and waiting[mine] then
		waiting[mine] = nil
		callback(false, "offline")
	end
end

local FAILED = {
	funds = "Not enough %s!",
	maxed = "That's maxed out already.",
	loading = "Your data is still loading. Try again in a moment.",
	unavailable = "Buying isn't available right now.",
	blocked = "You can't buy that right now.",
	timeout = "The server didn't answer. Check your balance before trying again.",
	offline = "Buying needs the server (UI.Server.start()).",
}

-- One request per item at a time (double clicks, impatient taps).
local buying: { [string]: boolean } = {}

-- Buys a Products item, an upgrade or a case. Robux items prompt a
-- purchase. In-game currency items are paid locally for client-side
-- currencies (and in Preview); leaderstat / attribute currencies ask the
-- server ("BloxUI.Buy"), which checks the price, takes it and answers.
-- onDone(ok, data) runs once it's settled (data.won = the case drop the
-- server rolled). Returns true only when it was bought right away.
function Actions.buy(ui: any, item: { [string]: any }, context: { [string]: any }, onDone: ((boolean, any) -> ())?): boolean
	context.payload = context.payload or { section = context.section, item = item.id }
	if item.currency == "robux" or item.currency == nil then
		if not ui.preview then
			Actions.listen() -- Robux cases: the server's roll comes back as a message
		end
		if item.productId then
			Actions.run(ui, { kind = "Purchase", productId = item.productId }, context)
		elseif item.gamePassId then
			Actions.run(ui, { kind = "GamePass", gamePassId = item.gamePassId }, context)
		else
			Actions.run(ui, { kind = "Notify", text = "Not for sale yet!" }, context)
		end
		return false
	end
	local price = item.price or item.cost or 0
	if not afford(ui, item.currency, price) then
		if onDone then
			onDone(false, nil)
		end
		return false
	end
	local def = ui.currencyDefs[item.currency]
	local serverOwned = def and def.source and def.source.kind ~= "none"
	local function bought(data: any)
		ui:emit("Purchased", context)
		if item.id then
			ui:emit(item.id, context)
		end
		if onDone then
			onDone(true, data)
		end
	end
	if serverOwned and not ui.preview then
		local payload = table.clone(context.payload)
		local key = `{tostring(payload.section)}/{tostring(item.id)}`
		if buying[key] then
			return false -- the first click is still waiting for the server
		end
		buying[key] = true
		Actions.request(Actions.BUY, item.id, payload, function(ok, reason, data)
			buying[key] = nil
			if ok then
				bought(data)
				return
			end
			local message = FAILED[reason or ""] or "That purchase didn't go through."
			ui:notify(string.format(message, if def then def.name else item.currency), "error")
			if reason == "funds" then
				ui:shakeCurrency(item.currency)
			end
			if onDone then
				onDone(false, nil)
			end
		end)
		return false
	end
	local value = ui.currencies[item.currency]
	value:set(math.max(0, peek(value) - price))
	if item.grant then
		ui:give(item.grant.currency, item.grant.amount, context.source)
	end
	-- a purchase confirmation: shown even with notifications turned off
	ui:notify(`Bought {item.title or item.name or "item"} for {Format.abbrev(price)} {if def then def.name else ""}`, "success", true)
	bought(nil)
	return true
end

return Actions
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="36">
            <Properties>
              <string name="Name">Common</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Blueprint.Common: helpers shared by the HUD, screen and section builders.

	- Common.component("ItemGrid") finds a component (or prefab) by name at
	  runtime, so the builder works before every component exists. A missing
	  one returns nil and warns ONCE with a message that starts with
	  Common.MISSING ("[BloxUI] Blueprint: component missing") so tests can
	  filter it; the caller draws a placeholder instead.
	- Common.card(...) is the layered surface every placeholder uses
	  (base gradient + inner highlight + tinted edge; docs/QUALITY.md rule 2).
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local Schema = require(script.Parent.Schema)

local Children = Fusion.Children
local peek = Fusion.peek

local Common = {}

Common.MISSING = "[BloxUI] Blueprint: component missing"

local BloxUI = script.Parent.Parent
local cache: { [string]: any } = {}
local warned: { [string]: boolean } = {}
Common.missing = {} :: { string }

-- "ItemGrid" -> Components.ItemGrid, "Prefabs/MainMenu" -> Prefabs.MainMenu.
-- Returns the required module, or nil (warning once) when it doesn't exist
-- or fails to load.
function Common.component(path: string): any
	local cached = cache[path]
	if cached ~= nil then
		return if cached == false then nil else cached
	end
	local folderName, name = string.match(path, "^(%w+)/(%w+)$")
	if folderName == nil then
		folderName, name = "Components", path
	end
	local folder = BloxUI:FindFirstChild(folderName)
	local module = folder and folder:FindFirstChild(name)
	local result = nil
	if module and module:IsA("ModuleScript") then
		local ok, loaded = pcall(require, module)
		if ok then
			result = loaded
		elseif not warned[path] then
			warned[path] = true
			warn(`[BloxUI] Blueprint: component {path} failed to load, using a placeholder: {loaded}`)
		end
	end
	if result == nil and not warned[path] then
		warned[path] = true
		table.insert(Common.missing, path)
		warn(`{Common.MISSING}: {path} (using a placeholder until it exists)`)
	end
	cache[path] = if result == nil then false else result
	return result
end

-- Forget cached lookups (tests use this after adding a component).
function Common.resetCache()
	table.clear(cache)
	table.clear(warned)
	table.clear(Common.missing)
end

-- Calls a component function in a protected way. Returns the instance, or nil
-- plus the error (the caller falls back to a placeholder).
function Common.try(fn: any, ...: any): (any, string?)
	if typeof(fn) ~= "function" then
		if typeof(fn) == "table" and typeof(fn.new) == "function" then
			fn = fn.new
		else
			return nil, "not a function"
		end
	end
	local ok, result = pcall(fn, ...)
	if ok then
		return result, nil
	end
	return nil, tostring(result)
end

--------------------------------------------------------------------------
-- Colours
--------------------------------------------------------------------------

function Common.color(hex: string?, default: Color3?): Color3
	if typeof(hex) == "string" then
		local ok, color = pcall(Color3.fromHex, (string.gsub(hex, "^#", "")))
		if ok then
			return color
		end
	end
	return default or Color3.new(1, 1, 1)
end

-- A full tone table from one colour (Theme.makeTone when the theme has it).
function Common.makeTone(color: Color3): { [string]: any }
	local makeTone = (Theme :: any).makeTone
	if typeof(makeTone) == "function" then
		local ok, tone = pcall(makeTone, color)
		if ok and typeof(tone) == "table" and tone.Top then
			return tone
		end
	end
	local light = Color.luminance(color) > 0.72
	return {
		Top = Color.lighten(color, 0.22),
		Bottom = color,
		Edge = Color.darken(color, 0.38),
		Deep = Color.darken(color, 0.62),
		Text = if light then Color3.fromRGB(30, 30, 38) else Color3.new(1, 1, 1),
		Stroke = if light then nil else Color.darken(color, 0.55),
	}
end

local rarityCache: { [string]: any } = {}

-- Tone for a rarity name, from theme.Rarity when the theme defines it.
function Common.rarityTone(theme: any, rarity: string?): { [string]: any }
	local name = rarity or "Common"
	if typeof(theme) == "table" and typeof(theme.Rarity) == "table" and theme.Rarity[name] then
		return theme.Rarity[name]
	end
	local cached = rarityCache[name]
	if cached == nil then
		cached = Common.makeTone(Common.color(Schema.RARITY_HEX[name] or Schema.RARITY_HEX.Common))
		if name == "Secret" then
			cached.Text = Color3.fromRGB(30, 30, 38)
			cached.Stroke = nil
		end
		rarityCache[name] = cached
	end
	return cached
end

--------------------------------------------------------------------------
-- Text helpers
--------------------------------------------------------------------------

-- 1320 -> "1,320", "40" -> "40", nil -> "".
function Common.amountText(amount: any): string
	if typeof(amount) == "number" then
		return Format.comma(amount)
	end
	return if amount == nil then "" else tostring(amount)
end

-- Rough width of a label in design px (for layout decisions only).
function Common.textWidth(text: string?, size: number): number
	local length = if text then (utf8.len(text) or #text) else 0
	return math.ceil(length * size * 0.56)
end

--------------------------------------------------------------------------
-- Surfaces
--------------------------------------------------------------------------

export type CardProps = {
	Name: string?,
	Size: UDim2?,
	Position: UDim2?,
	AnchorPoint: Vector2?,
	LayoutOrder: number?,
	ZIndex: number?,
	Visible: any?,
	Tone: any?, -- tone name / tone table / state: coloured card. nil = glass
	Radius: any?,
	Stroke: number?,
	Highlight: boolean?,
	Clip: boolean?,
	Children: any?,
}

-- A layered card: gradient fill, soft inner highlight on the top 40% and a
-- tinted edge. Glass when no Tone is given.
function Common.card(scope: any, props: CardProps): Frame
	local toned = props.Tone ~= nil
	local tone = Kit.tone(scope, props.Tone, "Dark")
	local z = props.ZIndex or 1
	return scope:New "Frame" {
		Name = props.Name or "Card",
		Size = props.Size or UDim2.fromScale(1, 1),
		Position = props.Position or UDim2.new(),
		AnchorPoint = props.AnchorPoint or Vector2.zero,
		LayoutOrder = props.LayoutOrder or 0,
		ZIndex = z,
		Visible = if props.Visible == nil then true else props.Visible,
		ClipsDescendants = props.Clip or false,
		-- (v2.4 style families: an opaque card one step off the window panel,
		-- light or dark, instead of the dark glass plate)
		BackgroundColor3 = if toned
			then Color3.new(1, 1, 1)
			else Kit.themed(scope, function(theme)
				return if theme.Family then (theme.Color.Card or theme.Color.Surface) else theme.Color.Surface
			end),
		BackgroundTransparency = if toned
			then 0
			else Kit.themed(scope, function(theme)
				if theme.Family then
					return (theme.Family.Fill and theme.Family.Fill.Translucent) or 0
				end
				return math.clamp(theme.Alpha.Surface - 0.08, 0, 1)
			end),
		[Children] = {
			Kit.corner(scope, props.Radius or "M"),
			if toned
				then Kit.gradient(scope, {
					Rotation = 90,
					Color = scope:Computed(function(use)
						local t = use(tone)
						-- v2.4 style families: the family's card fill
						local fill = Kit.cardFill(use(Theme.current), t)
						if fill then
							return fill
						end
						return ColorSequence.new({
							ColorSequenceKeypoint.new(0, t.Top),
							ColorSequenceKeypoint.new(0.55, t.Bottom),
							ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.12)),
						})
					end),
				})
				else Kit.gradient(scope, {
					Rotation = 90,
					Color = ColorSequence.new(Color3.new(1, 1, 1), Color3.fromRGB(200, 205, 220)),
				}),
			-- glass plates stay borderless (quiet plates, loud content); coloured
			-- cards get a thin edge in their own darkest shade (style families:
			-- the family outline colour; plain cards a hairline)
			if toned or props.Stroke
				then Kit.stroke(scope, {
					Color = if toned
						then scope:Computed(function(use)
							local theme = use(Theme.current)
							if theme.Family then
								return Kit.outlineColor(theme, use(tone))
							end
							return use(tone).Edge
						end)
						else Color3.new(1, 1, 1),
					Thickness = if toned and props.Stroke == nil
						then Kit.themed(scope, function(theme)
							local fam = theme.Family
							if fam and fam.Outline then
								return math.clamp((fam.Outline.Width or 2) * 0.75, 1, 3)
							end
							return 2
						end)
						else (props.Stroke or 2),
					Transparency = if toned then 0 else 0.86,
				})
				else Kit.stroke(scope, {
					Enabled = Kit.themed(scope, function(theme)
						return theme.Family ~= nil
					end),
					Color = Kit.themed(scope, function(theme)
						return theme.Color.CardEdge or Color3.new(1, 1, 1)
					end),
					Thickness = 1.5,
					Transparency = 0.88,
				}),
			Kit.rim(scope, {}),
			if props.Highlight ~= false
				then scope:New "Frame" {
					Name = "Highlight",
					Visible = Kit.themed(scope, function(theme)
						return theme.Family == nil
					end),
					BackgroundColor3 = Color3.new(1, 1, 1),
					BackgroundTransparency = if toned then 0.72 else 0.9,
					Position = UDim2.fromOffset(4, 3),
					Size = UDim2.new(1, -8, 0.4, 0),
					ZIndex = z,
					[Children] = {
						Kit.corner(scope, props.Radius or "M"),
						Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0, 1) }),
					},
				}
				else nil,
			props.Children,
		},
	} :: Frame
end

-- A coloured tile with a big shaded icon (used by placeholders for item art).
function Common.iconTile(scope: any, props: { [string]: any }): Frame
	local size = props.Size or 96
	local z = props.ZIndex or 2
	return Common.card(scope, {
		Name = props.Name or "Tile",
		Size = UDim2.fromOffset(size, size),
		Position = props.Position,
		AnchorPoint = props.AnchorPoint,
		LayoutOrder = props.LayoutOrder,
		ZIndex = z,
		Tone = props.Tone or "Blue",
		Radius = props.Radius or "M",
		Children = {
			scope:Icon {
				Icon = props.Icon or "star",
				Size = math.floor(size * (props.IconScale or 0.62)),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Color = props.IconColor or Color3.new(1, 1, 1),
				Gradient = true,
				Shadow = true,
				StrokeColor = scope:Computed(function(use)
					return Theme.tone(use(Theme.current), use(props.Tone) or "Blue").Edge
				end),
				ZIndex = z + 2,
			},
			props.Children,
		},
	})
end

-- True when the theme's window panels are light (cream / white), so text on
-- them must be dark.
function Common.panelIsLight(theme: any): boolean
	local ok, look = pcall(Kit.surfaceLook, theme, Theme.style(theme, "PanelStyle"))
	return ok and typeof(look) == "table" and look.light == true
end

--[[
	Colours for text drawn straight on a window panel (not inside a card):
	  light panels (Bubbly's cream)      dark panel text, a softer muted, no outline
	  vivid panels (Candy's pink gradient) white text, a near-white muted with a
	                                     thin outline in the panel's own edge colour
	  dark panels                        the theme's text and muted text
	`panelStyle` overrides the theme's (a screen's `panelStyle`). Returns
	{ text, muted, stroke (Color3?, outline muted text with it), light }.
]]
function Common.panelInk(theme: any, panelStyle: string?): { [string]: any }
	local c = theme.Color
	local style = panelStyle or Theme.style(theme, "PanelStyle")
	local ok, look = pcall(Kit.surfaceLook, theme, style)
	if ok and typeof(look) == "table" and look.light then
		local ink = c.PanelText or c.TextDark or Color3.new(0.1, 0.1, 0.12)
		if Color.luminance(ink) > 0.5 then
			ink = c.TextDark or Color3.new(0.1, 0.1, 0.12)
		end
		return { text = ink, muted = ink:Lerp(c.Panel or Color3.new(1, 1, 1), 0.3), stroke = nil, light = true }
	end
	if ok and typeof(look) == "table" and (style == "Gradient" or style == "Tone") then
		local keypoints = look.gradient.Keypoints
		local middle = keypoints[math.max(1, math.ceil(#keypoints / 2))].Value
		if Color.luminance(middle) > 0.45 then
			local white = Color3.new(1, 1, 1)
			return { text = white, muted = white:Lerp(middle, 0.12), stroke = look.stroke, light = false }
		end
	end
	return { text = c.Text, muted = c.TextMuted, stroke = nil, light = false }
end

-- Section title row: accent icon + big title (+ optional right-hand label).
function Common.header(scope: any, props: { [string]: any }): Frame
	local z = props.ZIndex or 2
	return scope:New "Frame" {
		Name = "Header",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, props.Height or 44),
		LayoutOrder = props.LayoutOrder or 0,
		ZIndex = z,
		[Children] = {
			scope:Row {
				Name = "Title",
				Size = UDim2.fromScale(1, 1),
				Gap = 12,
				VAlign = "Center",
				ZIndex = z,
				[Children] = {
					if props.Icon
						then scope:Icon {
							Icon = props.Icon,
							Size = 36,
							Color = Kit.themed(scope, function(theme)
								return theme.Color.Accent
							end),
							Gradient = true,
							Shadow = true,
							LayoutOrder = 1,
							ZIndex = z + 1,
						}
						else nil,
					-- section titles sit on the window panel: dark panel text (and no
					-- outline) only when the theme's panel really is light, like
					-- Components/Panel decides it
					scope:Text {
						Text = props.Text,
						TextSize = props.TextSize or 30,
						Font = "Title",
						Color = Kit.themed(scope, function(theme)
							return if Common.panelIsLight(theme) then theme.Color.PanelText or theme.Color.Text else theme.Color.Text
						end),
						Stroke = Kit.themed(scope, function(theme)
							local panelText = theme.Color.PanelText
							if Common.panelIsLight(theme) and panelText and Color.luminance(panelText) < 0.5 then
								return false
							end
							return theme.Stroke.Text
						end),
						LayoutOrder = 2,
						ZIndex = z + 1,
					},
				},
			},
			if props.Right
				then scope:Text {
					Name = "Right",
					Text = props.Right,
					TextSize = 22,
					Font = "Body",
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -4, 0.5, 0),
					ZIndex = z + 1,
				}
				else nil,
		},
	} :: Frame
end

-- A small rounded pill label (tags, rarity names, timers).
function Common.pill(scope: any, props: { [string]: any }): Frame
	local z = props.ZIndex or 3
	local height = props.Height or 30
	return scope:New "Frame" {
		Name = props.Name or "Pill",
		Size = UDim2.fromOffset(0, height),
		AutomaticSize = Enum.AutomaticSize.X,
		Position = props.Position or UDim2.new(),
		AnchorPoint = props.AnchorPoint or Vector2.zero,
		LayoutOrder = props.LayoutOrder or 0,
		BackgroundColor3 = scope:Computed(function(use)
			local color = use(props.Color)
			if color then
				return color
			end
			return Theme.tone(use(Theme.current), use(props.Tone) or "Dark").Bottom
		end),
		BackgroundTransparency = props.Transparency or 0,
		ZIndex = z,
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.padding(scope, { 0, math.floor(height * 0.45) }),
			Kit.list(scope, { Direction = "X", Padding = 6, VAlign = "Center" }),
			if props.Icon
				then scope:Icon {
					Icon = props.Icon,
					Size = math.floor(height * 0.72),
					LayoutOrder = 1,
					ZIndex = z + 1,
				}
				else nil,
			scope:Text {
				Text = props.Text or "",
				TextSize = props.TextSize or math.floor(height * 0.62),
				Font = props.Font or "Display",
				Color = props.TextColor,
				Stroke = props.TextStroke,
				LayoutOrder = 2,
				ZIndex = z + 1,
			},
		},
	} :: Frame
end

-- Countdown text for "ends in" timers, updated by a shared clock Value.
function Common.countdown(scope: any, clock: any, endsAt: number): any
	return scope:Computed(function(use)
		return Format.time(math.max(0, endsAt - use(clock)))
	end)
end

-- A key name from a blueprint -> the EnumItem the input components draw:
-- MouseButton1/2/3 and MouseWheel are Enum.UserInputType items, everything
-- else a KeyCode. Real Roblox throws on an unknown Enum name, so every
-- lookup is guarded; unknown names come back as nil.
local MOUSE_INPUTS = { MouseButton1 = true, MouseButton2 = true, MouseButton3 = true, MouseWheel = true }

function Common.inputEnum(name: any): EnumItem?
	if typeof(name) == "EnumItem" then
		return name
	end
	if typeof(name) ~= "string" or name == "" then
		return nil
	end
	local ok, item = pcall(function()
		if MOUSE_INPUTS[name] then
			return (Enum.UserInputType :: any)[name]
		end
		return (Enum.KeyCode :: any)[name]
	end)
	return if ok and typeof(item) == "EnumItem" then item else nil
end

-- Picks the ProductCard price icon for a currency id.
function Common.priceIcon(currencyDefs: { [string]: any }, currency: string?): string?
	if currency == nil or currency == "robux" then
		return "robux"
	end
	local def = currencyDefs[currency]
	return if def then def.icon else "coin"
end

-- The number part of an amount label ("+ 10" -> 10), for grants.
function Common.peekNumber(value: any): number?
	if typeof(value) == "number" then
		return value
	end
	return tonumber((string.gsub(tostring(value or ""), "[^%d%.]", "")))
end

Common.peek = peek

return Common
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="37">
            <Properties>
              <string name="Name">Examples</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Blueprint.Examples: five hand-made gold-standard blueprints. They are test
	fixtures, few-shot examples for the AI generator, and a good starting point:

		local ui = UI.Blueprint.build(scope, UI.Blueprint.Examples.fps)

	Each value is plain JSON-compatible data (see docs/BLUEPRINT.md). Treat them
	as read-only; Blueprint.build normalizes a copy.
]]

return {
	fps = require(script.fps),
	simulator = require(script.simulator),
	horror = require(script.horror),
	rpg = require(script.rpg),
	tycoon = require(script.tycoon),
}
]]></string>
            </Properties>
            <Item class="ModuleScript" referent="38">
              <Properties>
                <string name="Name">fps</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Example blueprint: a Rivals-style arena shooter. Hand-made gold standard
	(also a test fixture and a few-shot example for the AI generator).
]]

return {
	version = 1,
	meta = { title = "Neon Strike", genre = "fps", prompt = "A competitive arena shooter like Rivals", seed = 1001, source = "manual" },
	style = {
		archetype = "Arena",
		palette = {
			primary = "#FFB400", secondary = "#2E7BFF", accent = "#FFD21F", surface = "#12141C",
			text = "#FFFFFF", good = "#5BE22B", bad = "#FF3B3B", warn = "#FFB300",
		},
		fonts = { display = "Rubik:Black", body = "Rubik:Bold", title = "Rubik:Black" },
		buttonStyle = "Flat", panelStyle = "Glass", corner = "Soft", clickFx = "Punch", transition = "Pop",
		iconStyle = "Filled", textCase = "Normal", decoration = "None", shadow = "Soft", density = "Comfy",
		rarityScheme = "Arena",
	},
	logo = { text = "NEON STRIKE", subtitle = "SEASON 3", type = "Neon", icon = "crosshair", tilt = -4, tone = "Yellow" },
	currencies = {
		{ id = "coins", name = "Coins", icon = "coin", color = "#FFD21F", start = 1250, format = "comma", source = { kind = "leaderstat", name = "Coins" } },
		{ id = "keys", name = "Keys", icon = "key", color = "#FFC300", start = 13, format = "comma", source = { kind = "attribute", name = "Keys" } },
	},
	hud = {
		layout = "Corners",
		elements = {
			{ id = "score", type = "TeamScore", anchor = "TopCenter", goal = 30, teams = {
				{ name = "Blue", color = "#3AA8FF", score = 12 },
				{ name = "Red", color = "#FF4B4B", score = 9 },
			} },
			{ id = "round", type = "Timer", anchor = "TopCenter", label = "Round ends", seconds = 165 },
			{ id = "wallet", type = "CurrencyBar", anchor = "TopLeft", currencies = { "coins", "keys" }, showAdd = true, addAction = { kind = "Open", target = "shop" } },
			{ id = "feed", type = "KillFeed", anchor = "TopRight" },
			{ id = "tasks", type = "QuestTracker", anchor = "Left", title = "Beginner Tasks", tasks = {
				{ text = "Go to Shooting Range", icon = "flag", goal = 1, progress = 1 },
				{ text = "Break targets", icon = "target", goal = 5, progress = 2, reward = { icon = "key", amount = 1 } },
				{ text = "Run + crouch to Slide", icon = "run", goal = 1, progress = 0 },
			} },
			{ id = "menu", type = "MenuButtons", anchor = "Right", style = "Column", buttons = {
				{ id = "shopButton", label = "Shop", icon = "shop", tone = "Yellow", badge = "!", action = { kind = "Open", target = "shop" } },
				{ id = "loadoutButton", label = "Loadout", icon = "melee", tone = "Blue", action = { kind = "Open", target = "loadout" } },
				{ id = "passButton", label = "Pass", icon = "premium", tone = "Purple", action = { kind = "Open", target = "pass" } },
				{ id = "scoresButton", label = "Scores", icon = "trophy", tone = "Orange", action = { kind = "Open", target = "scoreboard" } },
			} },
			{ id = "health", type = "HealthBar", anchor = "BottomLeft", max = 150, source = { kind = "humanoid" } },
			{ id = "weapons", type = "Hotbar", anchor = "BottomRight", slots = {
				{ name = "Assault Rifle", icon = "ammo", ammo = 30, reserve = 120 },
				{ name = "Pistol", icon = "crosshair", ammo = 12, reserve = 48 },
				{ name = "Knife", icon = "melee" },
				{ name = "Grenade", icon = "grenade" },
			} },
			{ id = "hints", type = "KeyHints", anchor = "BottomRight", hints = {
				{ text = "Reload", key = "R", gamepad = "ButtonX" },
				{ text = "Quick Melee", key = "F", gamepad = "ButtonB" },
				{ text = "Scoreboard", key = "Tab" },
			} },
			{ id = "crosshair", type = "Crosshair", style = "Cross" },
			{ id = "emotes", type = "EmoteWheel", key = "G", emotes = {
				{ id = "wave", label = "Wave", icon = "hand" },
				{ id = "gg", label = "GG", icon = "thumb-up" },
				{ id = "dance", label = "Dance", icon = "music" },
				{ id = "taunt", label = "Taunt", icon = "face-winking" },
			} },
			{ id = "touch", type = "MobileControls", buttons = {
				{ id = "fire", label = "Fire", icon = "crosshair", action = { kind = "Event", name = "Fire" } },
				{ id = "aim", label = "Aim", icon = "look", action = { kind = "Event", name = "Aim" } },
				{ id = "reload", label = "Reload", icon = "rebirth", action = { kind = "Event", name = "Reload" } },
			} },
		},
	},
	screens = {
		{
			id = "loading", title = "Loading", kind = "Loading", duration = 3,
			tips = {
				"Slide into cover to dodge headshots.",
				"Keys open cases. Legendary skins drop 1% of the time.",
				"Finish Beginner Tasks for free keys!",
			},
		},
		{
			id = "menu", title = "Main Menu", kind = "MainMenu",
			sections = {
				{ kind = "Buttons", layout = "Column", buttons = {
					{ id = "play", label = "Play", icon = "play", tone = "Green", action = { kind = "Close" } },
					{ id = "menuLoadout", label = "Loadout", icon = "melee", tone = "Blue", action = { kind = "Open", target = "loadout" } },
					{ id = "menuShop", label = "Shop", icon = "shop", tone = "Yellow", action = { kind = "Open", target = "shop" } },
					{ id = "menuCodes", label = "Codes", icon = "tag", tone = "Purple", action = { kind = "Open", target = "codes" } },
					{ id = "menuSettings", label = "Settings", icon = "settings", tone = "Gray", action = { kind = "Open", target = "settings" } },
				} },
				{ kind = "Banner", title = "Season 3 is live", subtitle = "New map Neon Docks and 50 new pass rewards.", icon = "premium", tone = "Purple", timer = 259200,
					button = { label = "View Pass", action = { kind = "Open", target = "pass" } } },
			},
		},
		{
			id = "shop", title = "Shop", icon = "shop", kind = "Window", size = "Large", nav = "Tabs", openKey = "B",
			description = "Buy keys, open cases and claim the daily reward.",
			tabs = {
				{ id = "featured", label = "Home", icon = "shop", sections = {
					{ kind = "Banner", title = "Neon Bundle", subtitle = "Skin, knife and 500 keys. 60% off this week!", icon = "bundle", tone = "Pink", timer = 86400,
						button = { label = "Get Bundle", action = { kind = "Purchase", productId = 1720031 } } },
					{ kind = "Products", title = "Hot Deals", layout = "Cards", items = {
						{ id = "starter", title = "Starter Pack", amount = "+ 25 Keys", icon = "gift", tone = "Green", price = 99, currency = "robux", productId = 1720032, tag = "NEW" },
						{ id = "vip", title = "VIP Pass", amount = "2x XP", icon = "crown", tone = "Purple", price = 399, currency = "robux", gamePassId = 880101 },
					} },
				} },
				{ id = "keys", label = "Keys", icon = "key", sections = {
					{ kind = "Products", layout = "Cards", items = {
						{ id = "keys10", title = "Key Bundle", amount = "+ 10", icon = "key", tone = "Yellow", price = 48, currency = "robux", productId = 1720001, grant = { currency = "keys", amount = 10 } },
						{ id = "keys40", title = "Mega Key Bundle", amount = "+ 40", icon = "key", tone = "Yellow", price = 160, currency = "robux", productId = 1720002, grant = { currency = "keys", amount = 40 } },
						{ id = "keys90", title = "Super Key Bundle", amount = "+ 90", icon = "key", tone = "Yellow", price = 308, currency = "robux", productId = 1720003, grant = { currency = "keys", amount = 90 } },
						{ id = "keys450", title = "Ultra Key Bundle", amount = "+ 450", icon = "key", tone = "Yellow", price = 1320, currency = "robux", productId = 1720004, tag = "BEST VALUE", grant = { currency = "keys", amount = 450 } },
						{ id = "keys1100", title = "Legendary Key Bundle", amount = "+ 1,100", icon = "key", tone = "Red", price = 3000, currency = "robux", productId = 1720005, grant = { currency = "keys", amount = 1100 } },
					} },
				} },
				{ id = "cases", label = "Cases", icon = "cube", badge = 2, sections = {
					{ kind = "Cases", title = "Weapon Cases", cases = {
						{ id = "basic", title = "Basic Case", icon = "cube", tone = "Blue", price = 1, currency = "keys", pool = {
							{ name = "Urban Camo", icon = "tshirt", rarity = "Common", chance = 55 },
							{ name = "Tiger Stripe", icon = "tshirt", rarity = "Uncommon", chance = 30 },
							{ name = "Ice Blade", icon = "melee", rarity = "Rare", chance = 12 },
							{ name = "Golden Rifle", icon = "ammo", rarity = "Legendary", chance = 3 },
						} },
						{ id = "neon", title = "Neon Case", icon = "cube", tone = "Pink", price = 5, currency = "keys", pool = {
							{ name = "Neon Wrap", icon = "paint-brush", rarity = "Rare", chance = 50 },
							{ name = "Pulse Pistol", icon = "crosshair", rarity = "Epic", chance = 35 },
							{ name = "Synthwave Knife", icon = "melee", rarity = "Legendary", chance = 12 },
							{ name = "Glitch Rifle", icon = "ammo", rarity = "Mythic", chance = 3 },
						} },
						{ id = "royal", title = "Royal Case", icon = "crown", tone = "Yellow", price = 149, currency = "robux", productId = 1720010, pool = {
							{ name = "Royal Guard", icon = "shield", rarity = "Epic", chance = 60 },
							{ name = "Crown Jewel Knife", icon = "melee", rarity = "Legendary", chance = 35 },
							{ name = "Secret Sovereign", icon = "crown", rarity = "Secret", chance = 5 },
						} },
					} },
				} },
				{ id = "daily", label = "Daily", icon = "daily", callout = "Claim your FREE reward now!", sections = {
					{ kind = "Daily", title = "Daily Rewards", days = {
						{ day = 1, icon = "coin", amount = "100" }, { day = 2, icon = "key", amount = "1" },
						{ day = 3, icon = "coin", amount = "250" }, { day = 4, icon = "key", amount = "2" },
						{ day = 5, icon = "coin", amount = "500" }, { day = 6, icon = "key", amount = "3" },
						{ day = 7, icon = "cube", amount = "1", label = "Neon Case" },
					} },
				} },
			},
		},
		{
			id = "loadout", title = "Loadout", icon = "melee", kind = "Window", size = "Large", openKey = "L",
			sections = {
				{ kind = "Loadout", title = "Your Loadout", slots = {
					{ name = "Assault Rifle", icon = "ammo", rarity = "Epic", stats = { { label = "Damage", value = 0.62 }, { label = "Fire rate", value = 0.8 }, { label = "Range", value = 0.7 } } },
					{ name = "Pistol", icon = "crosshair", rarity = "Rare", stats = { { label = "Damage", value = 0.45 }, { label = "Fire rate", value = 0.55 }, { label = "Range", value = 0.4 } } },
					{ name = "Knife", icon = "melee", rarity = "Legendary", stats = { { label = "Damage", value = 0.9 }, { label = "Speed", value = 0.95 }, { label = "Range", value = 0.1 } } },
					{ name = "Grenade", icon = "grenade", rarity = "Uncommon", stats = { { label = "Damage", value = 0.85 }, { label = "Radius", value = 0.6 }, { label = "Fuse", value = 0.3 } } },
				} },
				{ kind = "Stats", title = "Career", stats = {
					{ label = "Kills", icon = "crosshair", value = "4,812" },
					{ label = "Win rate", icon = "trophy", value = "58%", bar = 0.58 },
					{ label = "Headshots", icon = "target", value = "31%", bar = 0.31 },
					{ label = "Level", icon = "level", value = "42", bar = 0.66 },
				} },
			},
		},
		{
			id = "pass", title = "Season Pass", icon = "premium", kind = "Window", size = "Large",
			sections = {
				{ kind = "Pass", title = "Season 3 Rewards", premiumProductId = 1720020, tiers = {
					{ tier = 1, free = { icon = "coin", amount = "100" }, premium = { icon = "key", amount = "5" } },
					{ tier = 2, free = { icon = "key", amount = "1" }, premium = { icon = "tshirt", amount = "1" } },
					{ tier = 3, free = { icon = "coin", amount = "250" }, premium = { icon = "cube", amount = "1" } },
					{ tier = 4, free = { icon = "key", amount = "2" }, premium = { icon = "paint-brush", amount = "1" } },
					{ tier = 5, free = { icon = "coin", amount = "500" }, premium = { icon = "melee", amount = "1" } },
					{ tier = 6, free = { icon = "key", amount = "3" }, premium = { icon = "crown", amount = "1" } },
					{ tier = 7, free = { icon = "coin", amount = "750" }, premium = { icon = "key", amount = "15" } },
					{ tier = 8, free = { icon = "cube", amount = "1" }, premium = { icon = "ammo", amount = "1" } },
				} },
				{ kind = "Quests", title = "Weekly Challenges", quests = {
					{ id = "w1", title = "Sharpshooter", description = "Get 50 headshots", icon = "target", goal = 50, progress = 34, reward = { icon = "key", amount = "3" } },
					{ id = "w2", title = "Close Quarters", description = "Win 10 knife fights", icon = "melee", goal = 10, progress = 10, reward = { icon = "coin", amount = "500" } },
				} },
			},
		},
		{
			id = "scoreboard", title = "Scoreboard", icon = "trophy", kind = "Window", size = "Large", openKey = "Tab",
			sections = {
				{ kind = "Scoreboard", columns = { "K", "D", "A", "Score" }, teams = {
					{ name = "Blue Team", color = "#3AA8FF", rows = {
						{ name = "NeonNinja", values = { 18, 6, 4, 2140 } },
						{ name = "TestPlayer", values = { 14, 7, 9, 1985 } },
						{ name = "Kairo_x", values = { 9, 10, 3, 1320 } },
					} },
					{ name = "Red Team", color = "#FF4B4B", rows = {
						{ name = "Blazey", values = { 16, 9, 2, 1870 } },
						{ name = "xXSniperXx", values = { 11, 12, 5, 1510 } },
						{ name = "PixelPaw", values = { 7, 13, 8, 1105 } },
					} },
				} },
			},
		},
		{
			id = "settings", title = "Settings", icon = "settings", kind = "Window", size = "Medium", nav = "SideTabs",
			tabs = {
				{ id = "game", label = "General", icon = "controller", sections = {
					{ kind = "Settings", settings = {
						{ id = "hitMarkers", label = "Hit Markers", icon = "crosshairs", type = "Toggle", default = true },
						{ id = "damageNumbers", label = "Damage Numbers", icon = "chart-four-vertical-bars", type = "Toggle", default = true },
						{ id = "killFeed", label = "Kill Feed", icon = "list-bulleted", type = "Toggle", default = true },
						-- game-specific: the game's scripts handle ui:on("crosshairStyle")
						{ id = "crosshairStyle", label = "Crosshair", icon = "target", type = "Choice", options = { "Cross", "Dot", "Circle" }, default = "Cross" },
					} },
				} },
				{ id = "graphics", label = "Graphics", icon = "image", sections = {
					{ kind = "Settings", settings = {
						{ id = "fov", label = "Field of View", icon = "look", type = "Slider", min = 70, max = 120, default = 90 },
						{ id = "shadows", label = "Shadows", icon = "sun", type = "Toggle", default = true },
						{ id = "lowDetail", label = "Performance Mode", icon = "speedometer", type = "Toggle", default = false },
					} },
				} },
				{ id = "audio", label = "Audio", icon = "sound", sections = {
					{ kind = "Settings", settings = {
						{ id = "music", label = "Music", icon = "music", type = "Toggle", default = true },
						{ id = "sfx", label = "Effects Volume", icon = "sound", type = "Slider", min = 0, max = 100, default = 80 },
					} },
				} },
				{ id = "controls", label = "Controls", icon = "keyboard", sections = {
					{ kind = "Settings", settings = {
						{ id = "sensitivity", label = "Sensitivity", icon = "crosshair", type = "Slider", min = 1, max = 100, default = 45 },
					} },
					{ kind = "Keybinds", binds = {
						{ action = "Reload", key = "R", gamepad = "ButtonX" },
						{ action = "Quick Melee", key = "F", gamepad = "ButtonB" },
						{ action = "Emotes", key = "G" },
						{ action = "Scoreboard", key = "Tab" },
					} },
				} },
				{ id = "access", label = "Accessibility", icon = "circle-person", sections = {
					{ kind = "Settings", settings = {
						{ id = "colorblind", label = "Colour-blind Mode", icon = "eye", type = "Choice", options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" }, default = "Off" },
						{ id = "reducedMotion", label = "Reduce Motion", icon = "pause-large", type = "Toggle", default = false },
						{ id = "uiScale", label = "UI Scale", icon = "dual-arrows-to-corners", type = "Slider", min = 80, max = 120, default = 100 },
					} },
				} },
			},
		},
		{
			id = "codes", title = "Codes", icon = "tag", kind = "Popup",
			sections = {
				{ kind = "Text", body = "Follow the group for new codes every update!" },
				{ kind = "Codes", placeholder = "Enter code...", button = "Redeem", action = { kind = "Remote", name = "RedeemCode" } },
			},
		},
		{
			id = "results", title = "Match Results", kind = "Results", result = "Victory",
			stats = {
				{ label = "Kills", value = "24" }, { label = "Deaths", value = "7" },
				{ label = "Assists", value = "11" }, { label = "Score", value = "3,450" },
			},
			rewards = { { icon = "coin", amount = "250" }, { icon = "key", amount = "3" }, { icon = "level", amount = "1,200 XP" } },
			buttons = {
				{ id = "again", label = "Play Again", icon = "play", tone = "Green", action = { kind = "Event", name = "PlayAgain" } },
				{ id = "lobby", label = "Lobby", icon = "home", tone = "Gray", action = { kind = "Open", target = "menu" } },
			},
		},
	},
	startScreen = "loading",
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="39">
              <Properties>
                <string name="Name">horror</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Example blueprint: a Doors-style horror game. Minimal HUD, typewriter
	objective, fear vignette, dark menus. Hand-made gold standard.
]]

return {
	version = 1,
	meta = { title = "The Hollow", genre = "horror", prompt = "A scary hotel escape game like Doors", seed = 3003, source = "manual" },
	style = {
		archetype = "Horror",
		palette = {
			primary = "#B3122E", secondary = "#3A3A40", accent = "#D6263D", surface = "#0B0B0D",
			text = "#E6E0D8", good = "#8FBF6A", bad = "#D6263D", warn = "#C9A227",
		},
		fonts = { display = "SpecialElite", body = "SpecialElite", title = "SpecialElite" },
		buttonStyle = "Outline", panelStyle = "Solid", corner = "Sharp", clickFx = "Glitch", transition = "Fade",
		iconStyle = "Outline", textCase = "Upper", decoration = "Grain", shadow = "None", density = "Compact",
	},
	logo = { text = "THE HOLLOW", subtitle = "CHECK IN. NEVER CHECK OUT.", type = "Wordmark", icon = "door", tilt = 0, tone = "Red" },
	currencies = {
		{ id = "embers", name = "Embers", icon = "fire", color = "#FF8A3D", start = 88, format = "comma", source = { kind = "leaderstat", name = "Embers" } },
	},
	hud = {
		layout = "Minimal",
		elements = {
			{ id = "objective", type = "Objective", anchor = "TopLeft", label = "OBJECTIVE", text = "Find the basement key" },
			{ id = "wallet", type = "CurrencyBar", anchor = "TopRight", currencies = { "embers" } },
			{ id = "menu", type = "MenuButtons", anchor = "Right", style = "Column", buttons = {
				{ id = "journalButton", label = "Journal", icon = "book-open", tone = "Dark", action = { kind = "Open", target = "journal" } },
				{ id = "shopButton", label = "Shop", icon = "shop", tone = "Dark", action = { kind = "Open", target = "shop" } },
				{ id = "settingsButton", label = "Settings", icon = "settings", tone = "Dark", action = { kind = "Open", target = "settings" } },
			} },
			{ id = "stamina", type = "Stamina", anchor = "BottomCenter" },
			{ id = "battery", type = "Battery", anchor = "BottomLeft" },
			{ id = "hints", type = "KeyHints", anchor = "BottomRight", hints = {
				{ text = "Flashlight", key = "F", gamepad = "ButtonY" },
				{ text = "Hide", key = "E", gamepad = "ButtonX" },
				{ text = "Crouch", key = "C", gamepad = "ButtonB" },
			} },
			{ id = "fear", type = "Fear", intensity = 0.25 },
			{ id = "dark", type = "Vignette", intensity = 0.55, style = "Soft" },
		},
	},
	screens = {
		{
			id = "loading", title = "Loading", kind = "Loading", duration = 4,
			tips = {
				"If the lights flicker, hide.",
				"Don't look at it. Don't ever look at it.",
				"Closets are safe. Mostly.",
			},
		},
		{
			id = "menu", title = "Main Menu", kind = "MainMenu",
			sections = {
				{ kind = "Buttons", layout = "Column", buttons = {
					{ id = "play", label = "Enter", icon = "door", tone = "Red", action = { kind = "Close" } },
					{ id = "menuShop", label = "Pre-Run Shop", icon = "shop", tone = "Dark", action = { kind = "Open", target = "shop" } },
					{ id = "menuSettings", label = "Settings", icon = "settings", tone = "Dark", action = { kind = "Open", target = "settings" } },
				} },
				{ kind = "Banner", title = "New Floor: The Cellar", subtitle = "50 new rooms. Something is living down there.", icon = "night", tone = "Red",
					button = { label = "Descend", action = { kind = "Close" } } },
			},
		},
		{
			id = "journal", title = "Journal", icon = "book-open", kind = "Window", size = "Medium", openKey = "J",
			sections = {
				{ kind = "Quests", title = "Clues", quests = {
					{ id = "c1", title = "The Guest Book", description = "Read the last entry at the front desk", icon = "book-open", goal = 1, progress = 1 },
					{ id = "c2", title = "Room 206", description = "Find what the maid left behind", icon = "key", goal = 3, progress = 1 },
					{ id = "c3", title = "The Cellar Door", description = "It only opens at 3:00 AM", icon = "door", goal = 1, progress = 0 },
				} },
				{ kind = "Text", title = "Note, Day 3", body = "They told us the fourth floor was closed for repairs. There is no fourth floor. The elevator stopped there anyway, and the doors opened onto a hallway that smelled like wet paper.\n\nIf you find this, do not follow the knocking." },
			},
		},
		{
			id = "shop", title = "Pre-Run Shop", icon = "shop", kind = "Window", size = "Medium",
			sections = {
				{ kind = "Products", title = "Supplies", layout = "Compact", items = {
					{ id = "lighter", title = "Lighter", amount = "Lasts 2 floors", icon = "fire", tone = "Orange", price = 40, currency = "embers" },
					{ id = "vitamins", title = "Vitamins", amount = "+ Speed burst", icon = "lab-beaker", tone = "Green", price = 60, currency = "embers" },
					{ id = "crucifix", title = "Crucifix", amount = "Stops one attack", icon = "shield", tone = "Gray", price = 120, currency = "embers" },
				} },
				{ kind = "Products", title = "Revives", layout = "Compact", items = {
					{ id = "revive1", title = "Revive", amount = "Continue your run", icon = "heart", tone = "Red", price = 25, currency = "robux", productId = 3720001 },
					{ id = "revive5", title = "5 Revives", amount = "Save 20%", icon = "heart", tone = "Red", price = 99, currency = "robux", productId = 3720002, tag = "SAVE 20%" },
				} },
			},
		},
		{
			id = "settings", title = "Settings", icon = "settings", kind = "Window", size = "Medium", nav = "SideTabs",
			tabs = {
				{ id = "general", label = "General", icon = "settings", sections = {
					{ kind = "Settings", settings = {
						-- flashes and camera shake come first in a horror game
						{ id = "reducedMotion", label = "Reduce Flashes & Shake", icon = "pause-large", type = "Toggle", default = false },
						-- game-specific: the game's scripts handle ui:on("brightness") / ui:on("subtitles")
						{ id = "brightness", label = "Brightness", icon = "sun", type = "Slider", min = 0, max = 100, default = 35 },
						{ id = "subtitles", label = "Subtitles", icon = "chat", type = "Toggle", default = true },
					} },
				} },
				{ id = "graphics", label = "Graphics", icon = "image", sections = {
					{ kind = "Settings", settings = {
						{ id = "fov", label = "Field of View", icon = "look", type = "Slider", min = 70, max = 100, default = 70 },
						-- lowDetail as a quality Choice: Low turns low detail on
						{ id = "lowDetail", label = "Quality", icon = "eye", type = "Choice", options = { "Low", "High" }, default = "High" },
					} },
				} },
				{ id = "audio", label = "Audio", icon = "sound", sections = {
					{ kind = "Settings", settings = {
						{ id = "music", label = "Music", icon = "music", type = "Slider", min = 0, max = 100, default = 40 },
						{ id = "sfx", label = "Sound Effects", icon = "sound", type = "Slider", min = 0, max = 100, default = 60 },
					} },
				} },
				{ id = "controls", label = "Controls", icon = "controller", sections = {
					{ kind = "Settings", settings = {
						{ id = "sensitivity", label = "Look Sensitivity", icon = "cursor", type = "Slider", min = 5, max = 100, default = 50 },
					} },
					{ kind = "Keybinds", binds = {
						{ action = "Interact", key = "E", gamepad = "ButtonX" },
						{ action = "Lantern", key = "F", gamepad = "ButtonY" },
						{ action = "Sprint", key = "LeftShift", gamepad = "ButtonL3" },
						{ action = "Hide", key = "C", gamepad = "ButtonB" },
					} },
				} },
				{ id = "access", label = "Accessibility", icon = "circle-person", sections = {
					{ kind = "Settings", settings = {
						{ id = "colorblind", label = "Colour-blind Mode", icon = "eye", type = "Choice", options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" }, default = "Off" },
						{ id = "uiScale", label = "UI Scale", icon = "dual-arrows-to-corners", type = "Slider", min = 80, max = 120, default = 100 },
					} },
				} },
			},
		},
		{
			id = "results", title = "Run Over", kind = "Results", result = "Defeat",
			stats = {
				{ label = "Rooms", value = "42" }, { label = "Time", value = "12:31" }, { label = "Embers", value = "88" },
			},
			rewards = { { icon = "fire", amount = "88" } },
			buttons = {
				{ id = "retry", label = "Try Again", icon = "rebirth", tone = "Red", action = { kind = "Event", name = "Retry" } },
				{ id = "lobby", label = "Lobby", icon = "home", tone = "Dark", action = { kind = "Open", target = "menu" } },
			},
		},
	},
	startScreen = "loading",
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="40">
              <Properties>
                <string name="Name">rpg</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Example blueprint: a Treasure Quest-style fantasy RPG with a boss fight,
	skills, inventory, guild and world map. Hand-made gold standard.
]]

return {
	version = 1,
	meta = { title = "Mythic Realms", genre = "rpg", prompt = "A fantasy dungeon RPG with bosses and loot", seed = 4004, source = "manual" },
	style = {
		archetype = "Adventure",
		palette = {
			primary = "#F2B233", secondary = "#1E9C9C", accent = "#FFD34D", surface = "#1B2440",
			text = "#FFFFFF", good = "#5BE22B", bad = "#FF4B4B", warn = "#FFB300",
		},
		fonts = { display = "BuilderSans:ExtraBold", body = "BuilderSans:Bold", title = "BuilderSans:ExtraBold" },
		buttonStyle = "Glossy3D", panelStyle = "Ribbon", corner = "Round", clickFx = "Sparkle", transition = "Drop",
		iconStyle = "Filled", textCase = "Normal", decoration = "Sparkles", shadow = "Soft", density = "Comfy",
	},
	logo = { text = "MYTHIC REALMS", subtitle = "DUNGEONS & DRAGONS", type = "Emblem", icon = "crown", tilt = -2, tone = "Yellow" },
	currencies = {
		{ id = "gold", name = "Gold", icon = "coin", color = "#FFD21F", start = 18400, format = "abbrev", source = { kind = "leaderstat", name = "Gold" } },
		{ id = "gems", name = "Gems", icon = "gem", color = "#B45CFF", start = 45, format = "comma", source = { kind = "attribute", name = "Gems" } },
	},
	hud = {
		layout = "Corners",
		elements = {
			{ id = "boss", type = "BossBar", anchor = "TopCenter", name = "Ignis, the Ember Wyrm", max = 50000, tone = "Red", icon = "fire" },
			{ id = "wallet", type = "CurrencyBar", anchor = "TopLeft", currencies = { "gold", "gems" }, showAdd = true, addAction = { kind = "Open", target = "shop" } },
			{ id = "map", type = "Minimap", anchor = "TopRight", shape = "Round" },
			{ id = "menu", type = "MenuButtons", anchor = "Left", style = "Grid", buttons = {
				{ id = "bagButton", label = "Bag", icon = "backpack", tone = "Orange", action = { kind = "Open", target = "inventory" } },
				{ id = "questsButton", label = "Quests", icon = "quest", tone = "Yellow", badge = 2, action = { kind = "Open", target = "quests" } },
				{ id = "skillsButton", label = "Skills", icon = "upgrade", tone = "Blue", action = { kind = "Open", target = "skills" } },
				{ id = "mapButton", label = "Map", icon = "map", tone = "Cyan", action = { kind = "Open", target = "map" } },
				{ id = "guildButton", label = "Guild", icon = "three-people", tone = "Green", action = { kind = "Open", target = "guild" } },
				{ id = "settingsButton", label = "Settings", icon = "settings", tone = "Gray", action = { kind = "Open", target = "settings" } },
			} },
			{ id = "quest", type = "QuestTracker", anchor = "Right", title = "The Ember Wyrm", tasks = {
				{ text = "Reach the Ashen Gate", icon = "map", goal = 1, progress = 1 },
				{ text = "Defeat fire imps", icon = "melee", goal = 8, progress = 5, reward = { icon = "coin", amount = 300 } },
				{ text = "Slay Ignis", icon = "crown", goal = 1, progress = 0, reward = { icon = "gem", amount = 10 } },
			} },
			{ id = "health", type = "HealthBar", anchor = "BottomLeft", max = 250, source = { kind = "humanoid" } },
			{ id = "skills", type = "Hotbar", anchor = "BottomCenter", slots = {
				{ name = "Flame Sword", icon = "melee" },
				{ name = "Shield Bash", icon = "shield" },
				{ name = "Healing Potion", icon = "lab-beaker" },
				{ name = "Arcane Bolt", icon = "energy" },
				{ name = "Dash", icon = "run" },
			} },
			{ id = "xp", type = "XPBar", anchor = "BottomCenter", level = 17, progress = 0.42 },
			{ id = "hints", type = "KeyHints", anchor = "BottomRight", hints = {
				{ text = "Interact", key = "E", gamepad = "ButtonX" },
				{ text = "Dodge", key = "Space", gamepad = "ButtonB" },
				{ text = "Map", key = "M" },
			} },
			{ id = "emotes", type = "EmoteWheel", key = "T", emotes = {
				{ id = "bow", label = "Bow", icon = "hand" },
				{ id = "cheer", label = "Cheer", icon = "megaphone-cheer" },
				{ id = "laugh", label = "Laugh", icon = "face-winking" },
			} },
		},
	},
	screens = {
		{
			id = "inventory", title = "Inventory", icon = "backpack", kind = "Window", size = "Large", nav = "Tabs", openKey = "I",
			tabs = {
				{ id = "bag", label = "Bag", icon = "backpack", sections = {
					{ kind = "Inventory", columns = 6, detail = true, items = {
						{ id = "i1", name = "Ember Blade", icon = "melee", rarity = "Legendary", equipped = true },
						{ id = "i2", name = "Dragon Scale", icon = "shield", rarity = "Epic", equipped = true },
						{ id = "i3", name = "Crown of Ash", icon = "crown", rarity = "Mythic" },
						{ id = "i4", name = "Health Potion", icon = "lab-beaker", rarity = "Common", count = 12 },
						{ id = "i5", name = "Mana Potion", icon = "lab-beaker", rarity = "Uncommon", count = 7 },
						{ id = "i6", name = "Old Map", icon = "map", rarity = "Rare" },
						{ id = "i7", name = "Iron Key", icon = "key", rarity = "Common", count = 3 },
						{ id = "i8", name = "Arcane Gem", icon = "gem", rarity = "Epic", count = 2 },
						{ id = "i9", name = "Lucky Clover", icon = "sparkle", rarity = "Rare" },
						{ id = "i10", name = "Leather Boots", icon = "shoe", rarity = "Uncommon" },
						{ id = "i11", name = "Traveler Hat", icon = "hat-fedora", rarity = "Common" },
						{ id = "i12", name = "Wyrm Egg", icon = "egg", rarity = "Secret", locked = true },
					} },
				} },
				{ id = "gear", label = "Gear", icon = "shield", sections = {
					{ kind = "Loadout", slots = {
						{ name = "Ember Blade", icon = "melee", rarity = "Legendary", stats = { { label = "Attack", value = 0.86 }, { label = "Speed", value = 0.6 } } },
						{ name = "Dragon Scale", icon = "shield", rarity = "Epic", stats = { { label = "Defense", value = 0.78 }, { label = "Weight", value = 0.5 } } },
						{ name = "Arcane Staff", icon = "energy", rarity = "Rare", stats = { { label = "Magic", value = 0.7 }, { label = "Mana", value = 0.55 } } },
					} },
				} },
			},
		},
		{
			id = "quests", title = "Quests", icon = "quest", kind = "Window", size = "Large", nav = "Tabs",
			tabs = {
				{ id = "main", label = "Story", icon = "book-open", sections = {
					{ kind = "Quests", quests = {
						{ id = "m1", title = "The Ember Wyrm", description = "Slay Ignis in the Ashen Caldera", icon = "fire", goal = 1, progress = 0, reward = { icon = "gem", amount = "10" } },
						{ id = "m2", title = "Imp Infestation", description = "Defeat 8 fire imps", icon = "melee", goal = 8, progress = 5, reward = { icon = "coin", amount = "300" } },
					} },
				} },
				{ id = "side", label = "Side", icon = "flag", sections = {
					{ kind = "Quests", quests = {
						{ id = "s1", title = "Lost Satchel", description = "Return the merchant's bag", icon = "backpack", goal = 1, progress = 1, reward = { icon = "coin", amount = "150" } },
						{ id = "s2", title = "Fisher's Tale", description = "Catch 5 lava eels", icon = "fish", goal = 5, progress = 2, reward = { icon = "gem", amount = "3" } },
						{ id = "s3", title = "Herbalist", description = "Gather 10 fire lilies", icon = "farm", goal = 10, progress = 10, reward = { icon = "lab-beaker", amount = "2" } },
					} },
				} },
			},
		},
		{
			id = "skills", title = "Skills", icon = "upgrade", kind = "Window", size = "Large",
			sections = {
				{ kind = "Stats", title = "Hero", stats = {
					{ label = "Level", icon = "level", value = "17", bar = 0.42 },
					{ label = "Skill points", icon = "star", value = "3" },
				} },
				{ kind = "Upgrades", title = "Skill Tree", upgrades = {
					{ id = "strength", title = "Strength", icon = "melee", level = 6, maxLevel = 20, cost = 1200, currency = "gold", effect = "+4% melee damage" },
					{ id = "vitality", title = "Vitality", icon = "heart", level = 4, maxLevel = 20, cost = 900, currency = "gold", effect = "+25 max health" },
					{ id = "arcane", title = "Arcane", icon = "energy", level = 2, maxLevel = 20, cost = 1500, currency = "gold", effect = "+6% spell power" },
					{ id = "fortune", title = "Fortune", icon = "sparkle", level = 1, maxLevel = 10, cost = 5, currency = "gems", effect = "+3% loot luck" },
				} },
			},
		},
		{
			id = "map", title = "World Map", icon = "map", kind = "Window", size = "Large", openKey = "M",
			sections = {
				{ kind = "Worlds", worlds = {
					{ id = "vale", name = "Greenvale", icon = "farm", tone = "Green", requirement = "Lv 1+" },
					{ id = "coast", name = "Saltwind Coast", icon = "fish", tone = "Cyan", requirement = "Lv 8+" },
					{ id = "caldera", name = "Ashen Caldera", icon = "fire", tone = "Red", requirement = "Lv 15+" },
					{ id = "peaks", name = "Frostfang Peaks", icon = "diamond", tone = "Blue", requirement = "Lv 25+", locked = true },
					{ id = "void", name = "The Starless Void", icon = "nebula", tone = "Purple", requirement = "Lv 40+", locked = true },
				} },
			},
		},
		{
			id = "guild", title = "Guild", icon = "three-people", kind = "Window", size = "Large", nav = "SideTabs",
			tabs = {
				{ id = "party", label = "Party", icon = "friends", sections = {
					{ kind = "Party", title = "Your Party", slots = 4 },
				} },
				{ id = "ranking", label = "Ranking", icon = "trophy", sections = {
					{ kind = "Leaderboard", title = "Top Guilds", stat = "Glory", rows = {
						{ rank = 1, name = "Dragonborn", value = "98,410" },
						{ rank = 2, name = "Night Owls", value = "91,002" },
						{ rank = 3, name = "Iron Oath", value = "84,377" },
						{ rank = 4, name = "Sunforged", value = "70,115" },
						{ rank = 5, name = "Mistwalkers", value = "66,980" },
					} },
				} },
				{ id = "profile", label = "Profile", icon = "player", sections = {
					{ kind = "Profile", name = "Aria Emberheart", level = 17, stats = {
						{ label = "Bosses slain", icon = "crown", value = "12" },
						{ label = "Quests done", icon = "quest", value = "58" },
						{ label = "Gold earned", icon = "coin", value = "1.2M" },
						{ label = "Deaths", icon = "heart", value = "31" },
					}, badges = {
						{ icon = "fire", label = "Wyrmslayer" },
						{ icon = "compass", label = "Explorer" },
						{ icon = "trophy", label = "Champion" },
					} },
				} },
			},
		},
		{
			id = "shop", title = "Merchant", icon = "building-store", kind = "Window", size = "Large",
			sections = {
				{ kind = "Products", title = "Gem Chests", layout = "Featured", items = {
					{ id = "hoard", title = "Dragon's Hoard", amount = "+ 5,000 Gems", icon = "gem", tone = "Purple", price = 1499, currency = "robux", productId = 4720004, tag = "BEST VALUE", grant = { currency = "gems", amount = 5000 } },
					{ id = "pouch", title = "Gem Pouch", amount = "+ 120", icon = "gem", tone = "Blue", price = 49, currency = "robux", productId = 4720001, grant = { currency = "gems", amount = 120 } },
					{ id = "chest", title = "Gem Chest", amount = "+ 700", icon = "gem", tone = "Blue", price = 249, currency = "robux", productId = 4720002, grant = { currency = "gems", amount = 700 } },
					{ id = "vault", title = "Gem Vault", amount = "+ 1,600", icon = "gem", tone = "Purple", price = 499, currency = "robux", productId = 4720003, grant = { currency = "gems", amount = 1600 } },
				} },
				{ kind = "Products", title = "Supplies", layout = "Compact", items = {
					{ id = "potions", title = "Potion Bundle", amount = "x10 Health", icon = "lab-beaker", tone = "Red", price = 500, currency = "gold" },
					{ id = "keys", title = "Dungeon Keys", amount = "x3", icon = "key", tone = "Yellow", price = 15, currency = "gems" },
				} },
			},
		},
		{
			id = "settings", title = "Settings", icon = "settings", kind = "Window", size = "Medium", openKey = "P",
			sections = {
				{ kind = "Settings", title = "General", settings = {
					{ id = "music", label = "Music", icon = "music", type = "Toggle", default = true },
					{ id = "sfx", label = "Sound Effects", icon = "sound", type = "Slider", min = 0, max = 100, default = 60 },
					{ id = "damageNumbers", label = "Damage Numbers", icon = "hashtag", type = "Toggle", default = true },
					{ id = "lowDetail", label = "Graphics", icon = "eye", type = "Choice", options = { "Low", "Medium", "High" }, default = "High" },
					{ id = "showHud", label = "Show HUD", icon = "frame-expanded", type = "Toggle", default = true },
				} },
				{ kind = "Settings", title = "Accessibility", settings = {
					{ id = "colorblind", label = "Colour-blind Mode", icon = "eye", type = "Choice", options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" }, default = "Off" },
					{ id = "reducedMotion", label = "Reduce Motion", icon = "pause-large", type = "Toggle", default = false },
					{ id = "uiScale", label = "UI Scale", icon = "dual-arrows-to-corners", type = "Slider", min = 80, max = 120, default = 100 },
				} },
				{ kind = "Keybinds", title = "Controls", binds = {
					{ action = "Interact", key = "E", gamepad = "ButtonX" },
					{ action = "Dodge", key = "Space", gamepad = "ButtonB" },
					{ action = "Inventory", key = "I" },
					{ action = "Map", key = "M" },
				} },
			},
		},
	},
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="41">
              <Properties>
                <string name="Name">simulator</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Example blueprint: a Pet Simulator-style clicker with pets, worlds, trading
	and rebirths. Hand-made gold standard.
]]

return {
	version = 1,
	meta = { title = "Pet Pals Simulator", genre = "simulator", prompt = "A pet simulator with eggs, worlds and trading", seed = 2002, source = "manual" },
	style = {
		archetype = "Candy",
		palette = {
			primary = "#FF6FB5", secondary = "#9B6BFF", accent = "#FFE066", surface = "#2A1840",
			text = "#FFFFFF", good = "#6BEA3A", bad = "#FF4B6B", warn = "#FFB84D",
		},
		fonts = { display = "FredokaOne", body = "BuilderSans:Bold", title = "FredokaOne" },
		buttonStyle = "Glossy3D", panelStyle = "Gradient", corner = "Bubbly", clickFx = "Sparkle", transition = "Pop",
		iconStyle = "Filled", textCase = "Normal", decoration = "Sparkles", shadow = "Hard", density = "Comfy",
	},
	logo = { text = "PET PALS", subtitle = "SIMULATOR", type = "Stacked", icon = "pet", tilt = -3, tone = "Pink" },
	currencies = {
		{ id = "coins", name = "Coins", icon = "coin", color = "#FFD21F", start = 15320, format = "abbrev", source = { kind = "leaderstat", name = "Coins" } },
		{ id = "gems", name = "Gems", icon = "gem", color = "#5CC8FF", start = 120, format = "comma", source = { kind = "attribute", name = "Gems" } },
	},
	hud = {
		layout = "Classic",
		elements = {
			{ id = "wallet", type = "CurrencyBar", anchor = "TopLeft", currencies = { "coins", "gems" }, showAdd = true, addAction = { kind = "Open", target = "shop" } },
			{ id = "menu", type = "MenuButtons", anchor = "Left", style = "Grid", buttons = {
				{ id = "shopButton", label = "Shop", icon = "shop", tone = "Yellow", action = { kind = "Open", target = "shop" } },
				{ id = "petsButton", label = "Pets", icon = "pet", tone = "Pink", badge = 3, action = { kind = "Open", target = "pets" } },
				{ id = "worldsButton", label = "Worlds", icon = "world", tone = "Blue", action = { kind = "Open", target = "worlds" } },
				{ id = "tradeButton", label = "Trade", icon = "two-arrows-left-right", tone = "Green", action = { kind = "Open", target = "trade" } },
				{ id = "rebirthButton", label = "Rebirth", icon = "rebirth", tone = "Purple", action = { kind = "Open", target = "rebirth" } },
				{ id = "questsButton", label = "Quests", icon = "quest", tone = "Orange", action = { kind = "Open", target = "quests" } },
			} },
			{ id = "quest", type = "QuestTracker", anchor = "TopRight", title = "Daily Quests", tasks = {
				{ text = "Hatch 10 eggs", icon = "egg", goal = 10, progress = 6, reward = { icon = "gem", amount = 25 } },
				{ text = "Collect 50K coins", icon = "coin", goal = 50000, progress = 15320 },
			} },
			{ id = "boosts", type = "Boosts", anchor = "Right", boosts = {
				{ icon = "boost", text = "2x Coins", seconds = 300 },
				{ icon = "sparkle", text = "Lucky Eggs", seconds = 900 },
			} },
			{ id = "gift", type = "Button", anchor = "Right", label = "Free Gift", icon = "gift", tone = "Green", action = { kind = "Open", target = "daily" } },
			{ id = "xp", type = "XPBar", anchor = "BottomCenter", level = 12, progress = 0.64 },
			{ id = "clicker", type = "Clicker", text = "TAP!", icon = "coin", currency = "coins", amount = 25 },
		},
	},
	screens = {
		{
			id = "shop", title = "Shop", icon = "shop", kind = "Window", size = "Large", nav = "Tabs", openKey = "B",
			tabs = {
				{ id = "gems", label = "Gems", icon = "gem", sections = {
					{ kind = "Products", layout = "Cards", items = {
						{ id = "gems100", title = "Pouch of Gems", amount = "+ 100", icon = "gem", tone = "Blue", price = 49, currency = "robux", productId = 2720001, grant = { currency = "gems", amount = 100 } },
						{ id = "gems550", title = "Sack of Gems", amount = "+ 550", icon = "gem", tone = "Blue", price = 229, currency = "robux", productId = 2720002, grant = { currency = "gems", amount = 550 } },
						{ id = "gems1200", title = "Chest of Gems", amount = "+ 1,200", icon = "gem", tone = "Purple", price = 449, currency = "robux", productId = 2720003, tag = "POPULAR", grant = { currency = "gems", amount = 1200 } },
						{ id = "gems3000", title = "Vault of Gems", amount = "+ 3,000", icon = "gem", tone = "Pink", price = 999, currency = "robux", productId = 2720004, tag = "BEST VALUE", grant = { currency = "gems", amount = 3000 } },
					} },
				} },
				{ id = "boosts", label = "Boosts", icon = "boost", sections = {
					{ kind = "Products", title = "Boosts", layout = "Compact", items = {
						{ id = "x2coins", title = "2x Coins (30 min)", amount = "Double every tap", icon = "coin", tone = "Yellow", price = 150, currency = "gems" },
						{ id = "x2luck", title = "Lucky Eggs (30 min)", amount = "+ 50% rare pets", icon = "sparkle", tone = "Green", price = 250, currency = "gems" },
						{ id = "autoclick", title = "Auto Clicker", amount = "Taps for you", icon = "cursor", tone = "Blue", price = 299, currency = "robux", gamePassId = 2880101 },
						{ id = "petslots", title = "+3 Pet Slots", amount = "Equip more pets", icon = "pet", tone = "Pink", price = 199, currency = "robux", gamePassId = 2880102 },
					} },
				} },
				{ id = "upgrades", label = "Upgrades", icon = "upgrade", sections = {
					{ kind = "Upgrades", upgrades = {
						{ id = "tap", title = "Tap Power", icon = "coin", level = 4, maxLevel = 25, cost = 1200, currency = "coins", effect = "+5 coins per tap" },
						{ id = "walk", title = "Walk Speed", icon = "run", level = 2, maxLevel = 10, cost = 800, currency = "coins", effect = "+10% speed" },
						{ id = "luck", title = "Hatch Luck", icon = "sparkle", level = 1, maxLevel = 10, cost = 40, currency = "gems", effect = "+5% rare chance" },
						{ id = "storage", title = "Pet Storage", icon = "backpack", level = 3, maxLevel = 20, cost = 2500, currency = "coins", effect = "+10 slots" },
					} },
				} },
				{ id = "codes", label = "Codes", icon = "tag", sections = {
					{ kind = "Codes", title = "Redeem a Code", placeholder = "Try PETPALS...", button = "Redeem", action = { kind = "Remote", name = "RedeemCode" } },
					{ kind = "Text", body = "New codes drop at every like milestone. Join the group for 10% more gems!" },
				} },
			},
		},
		{
			id = "pets", title = "My Pets", icon = "pet", kind = "Window", size = "Large",
			sections = {
				{ kind = "Inventory", columns = 6, detail = true, items = {
					{ id = "p1", name = "Rainbow Dragon", icon = "pet", rarity = "Mythic", equipped = true },
					{ id = "p2", name = "Golden Parrot", icon = "parrot", rarity = "Legendary", equipped = true },
					{ id = "p3", name = "Space Cat", icon = "nebula", rarity = "Epic", equipped = true },
					{ id = "p4", name = "Lava Pup", icon = "fire", rarity = "Epic" },
					{ id = "p5", name = "Ice Bunny", icon = "egg", rarity = "Rare", count = 2 },
					{ id = "p6", name = "Piggy Bank", icon = "pig", rarity = "Rare" },
					{ id = "p7", name = "Bumble Bee", icon = "bug", rarity = "Uncommon", count = 3 },
					{ id = "p8", name = "Butterfly", icon = "butterfly-wings", rarity = "Uncommon" },
					{ id = "p9", name = "Goldfish", icon = "fish", rarity = "Common", count = 5 },
					{ id = "p10", name = "Shrimpy", icon = "shrimp", rarity = "Common", count = 2 },
					{ id = "p11", name = "Bunny", icon = "egg", rarity = "Common" },
					{ id = "p12", name = "Secret Void", icon = "nebula", rarity = "Secret", locked = true },
				} },
			},
		},
		{
			id = "worlds", title = "Worlds", icon = "world", kind = "Window", size = "Large",
			sections = {
				{ kind = "Worlds", worlds = {
					{ id = "spawn", name = "Spawn Meadow", icon = "farm", tone = "Green", requirement = "Unlocked" },
					{ id = "beach", name = "Sunny Beach", icon = "sun", tone = "Yellow", requirement = "Unlocked" },
					{ id = "candy", name = "Candy Land", icon = "lollipop", tone = "Pink", requirement = "Unlocked" },
					{ id = "volcano", name = "Volcano", icon = "fire", tone = "Orange", requirement = "Rebirth 3", locked = true },
					{ id = "ice", name = "Frost Peak", icon = "diamond", tone = "Cyan", requirement = "Rebirth 5", locked = true },
					{ id = "space", name = "Outer Space", icon = "nebula", tone = "Purple", requirement = "Rebirth 10", locked = true },
				} },
			},
		},
		{ id = "trade", title = "Trade", icon = "two-arrows-left-right", kind = "Trade", currency = "gems" },
		{
			id = "rebirth", title = "Rebirth", icon = "rebirth", kind = "Popup",
			sections = {
				{ kind = "Stats", stats = {
					{ label = "Rebirths", icon = "rebirth", value = "2" },
					{ label = "Coin boost", icon = "coin", value = "x3 -> x4" },
				} },
				{ kind = "Buttons", layout = "Row", buttons = {
					{ id = "doRebirth", label = "Rebirth", icon = "rebirth", tone = "Purple", action = { kind = "Event", name = "Rebirth" } },
					{ id = "cancelRebirth", label = "Not yet", tone = "Gray", action = { kind = "Close" } },
				} },
			},
		},
		{
			id = "quests", title = "Quests", icon = "quest", kind = "Window", size = "Medium",
			sections = {
				{ kind = "Quests", quests = {
					{ id = "q1", title = "Egg Hunter", description = "Hatch 10 eggs", icon = "egg", goal = 10, progress = 6, reward = { icon = "gem", amount = "25" } },
					{ id = "q2", title = "Coin Collector", description = "Collect 50K coins", icon = "coin", goal = 50000, progress = 50000, reward = { icon = "gem", amount = "40" } },
					{ id = "q3", title = "Explorer", description = "Visit Candy Land", icon = "world", goal = 1, progress = 0, reward = { icon = "coin", amount = "5K" } },
				} },
			},
		},
		{
			id = "daily", title = "Daily Gift", icon = "gift", kind = "Popup", size = "Medium",
			sections = {
				{ kind = "Daily", days = {
					{ day = 1, icon = "coin", amount = "1K" }, { day = 2, icon = "gem", amount = "10" },
					{ day = 3, icon = "coin", amount = "5K" }, { day = 4, icon = "gem", amount = "25" },
					{ day = 5, icon = "boost", amount = "1", label = "2x Coins" }, { day = 6, icon = "gem", amount = "50" },
					{ day = 7, icon = "egg", amount = "1", label = "Mythic Egg" },
				} },
			},
		},
	},
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="42">
              <Properties>
                <string name="Name">tycoon</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Example blueprint: a bubbly restaurant tycoon with a bottom dock, cash
	upgrades, rebirths, gamepasses and a leaderboard. Hand-made gold standard.
]]

return {
	version = 1,
	meta = { title = "Burger Empire Tycoon", genre = "tycoon", prompt = "A cute burger restaurant tycoon", seed = 5005, source = "manual" },
	style = {
		archetype = "Bubbly",
		palette = {
			primary = "#FF7AB6", secondary = "#5CC8FF", accent = "#FFD84D", surface = "#2B2470",
			text = "#FFFFFF", good = "#63E03A", bad = "#FF4B5C", warn = "#FFB300",
		},
		fonts = { display = "FredokaOne", body = "BuilderSans:Bold", title = "FredokaOne" },
		buttonStyle = "Glossy3D", panelStyle = "Outlined", corner = "Bubbly", clickFx = "Squash", transition = "Pop",
		iconStyle = "Filled", textCase = "Normal", decoration = "Sparkles", shadow = "Hard", density = "Comfy",
	},
	logo = { text = "BURGER EMPIRE", subtitle = "TYCOON", type = "Badge", icon = "building-store", tilt = -5, tone = "Orange" },
	currencies = {
		{ id = "cash", name = "Cash", icon = "cash", color = "#5BE22B", start = 24750, format = "abbrev", source = { kind = "leaderstat", name = "Cash" } },
		{ id = "rubies", name = "Rubies", icon = "gem", color = "#FF3B6B", start = 30, format = "comma", source = { kind = "attribute", name = "Rubies" } },
	},
	hud = {
		layout = "Dock",
		elements = {
			{ id = "wallet", type = "CurrencyBar", anchor = "TopRight", currencies = { "cash", "rubies" }, showAdd = true, addAction = { kind = "Open", target = "shop" } },
			{ id = "goal", type = "Objective", anchor = "TopLeft", label = "NEXT GOAL", text = "Buy the Drive-Thru ($25K)" },
			{ id = "dock", type = "MenuButtons", anchor = "BottomCenter", style = "Dock", buttons = {
				{ id = "shopButton", label = "Shop", icon = "shop", tone = "Yellow", action = { kind = "Open", target = "shop" } },
				{ id = "upgradesButton", label = "Upgrades", icon = "upgrade", tone = "Blue", action = { kind = "Open", target = "upgrades" } },
				{ id = "rebirthButton", label = "Rebirth", icon = "rebirth", tone = "Purple", action = { kind = "Open", target = "rebirth" } },
				{ id = "dailyButton", label = "Daily", icon = "daily", tone = "Pink", badge = 1, action = { kind = "Open", target = "daily" } },
				{ id = "statsButton", label = "Stats", icon = "trophy", tone = "Orange", action = { kind = "Open", target = "stats" } },
				{ id = "settingsButton", label = "Settings", icon = "settings", tone = "Gray", action = { kind = "Open", target = "settings" } },
			} },
			{ id = "boosts", type = "Boosts", anchor = "Right", boosts = {
				{ icon = "boost", text = "2x Cash", seconds = 600 },
			} },
			{ id = "collect", type = "Button", anchor = "Right", label = "Collect", icon = "cash", tone = "Green", action = { kind = "Event", name = "CollectCash" } },
		},
	},
	screens = {
		{
			id = "shop", title = "Shop", icon = "shop", kind = "Window", size = "Large", nav = "Tabs", openKey = "B",
			tabs = {
				{ id = "cash", label = "Cash", icon = "cash", sections = {
					{ kind = "Products", layout = "Cards", items = {
						{ id = "cash10k", title = "Tip Jar", amount = "+ $10K", icon = "cash", tone = "Green", price = 25, currency = "robux", productId = 5720001, grant = { currency = "cash", amount = 10000 } },
						{ id = "cash75k", title = "Cash Register", amount = "+ $75K", icon = "cash", tone = "Green", price = 129, currency = "robux", productId = 5720002, grant = { currency = "cash", amount = 75000 } },
						{ id = "cash400k", title = "Bank Vault", amount = "+ $400K", icon = "building-bank", tone = "Yellow", price = 499, currency = "robux", productId = 5720003, tag = "BEST VALUE", grant = { currency = "cash", amount = 400000 } },
					} },
					{ kind = "Products", title = "Rubies", layout = "Cards", items = {
						{ id = "rubies50", title = "Ruby Handful", amount = "+ 50", icon = "gem", tone = "Pink", price = 49, currency = "robux", productId = 5720011, grant = { currency = "rubies", amount = 50 } },
						{ id = "rubies300", title = "Ruby Crate", amount = "+ 300", icon = "gem", tone = "Red", price = 249, currency = "robux", productId = 5720012, grant = { currency = "rubies", amount = 300 } },
					} },
				} },
				{ id = "passes", label = "Passes", icon = "premium", sections = {
					{ kind = "Products", layout = "Compact", items = {
						{ id = "x2cash", title = "2x Cash Forever", amount = "Double all income", icon = "cash", tone = "Green", price = 399, currency = "robux", gamePassId = 5880101, tag = "HOT" },
						{ id = "autocollect", title = "Auto Collect", amount = "Never tap the till", icon = "timer", tone = "Blue", price = 249, currency = "robux", gamePassId = 5880102 },
						{ id = "vip", title = "VIP Lounge", amount = "Golden decor + chat tag", icon = "crown", tone = "Yellow", price = 199, currency = "robux", gamePassId = 5880103 },
						{ id = "fastchef", title = "Speedy Chefs", amount = "Cook 50% faster", icon = "speedometer", tone = "Orange", price = 149, currency = "robux", gamePassId = 5880104 },
					} },
				} },
				{ id = "codes", label = "Codes", icon = "tag", sections = {
					{ kind = "Codes", title = "Redeem a Code", placeholder = "Enter code...", button = "Redeem", action = { kind = "Remote", name = "RedeemCode" } },
				} },
			},
		},
		{
			id = "upgrades", title = "Upgrades", icon = "upgrade", kind = "Window", size = "Large",
			sections = {
				{ kind = "Upgrades", upgrades = {
					{ id = "grill", title = "Better Grill", icon = "fire", level = 3, maxLevel = 10, cost = 12000, currency = "cash", effect = "+15% burger value" },
					{ id = "tables", title = "More Tables", icon = "house", level = 2, maxLevel = 8, cost = 8000, currency = "cash", effect = "+2 customers" },
					{ id = "chef", title = "Hire Chef", icon = "player", level = 1, maxLevel = 5, cost = 20000, currency = "cash", effect = "Cooks automatically" },
					{ id = "sign", title = "Neon Sign", icon = "star", level = 0, maxLevel = 3, cost = 45000, currency = "cash", effect = "+25% customers" },
					{ id = "milkshake", title = "Milkshake Bar", icon = "milk", level = 0, maxLevel = 5, cost = 15, currency = "rubies", effect = "New menu item" },
					{ id = "drive", title = "Drive-Thru", icon = "speedometer", level = 0, maxLevel = 1, cost = 25000, currency = "cash", effect = "Serve cars too" },
				} },
			},
		},
		{
			id = "rebirth", title = "Rebirth", icon = "rebirth", kind = "Popup",
			sections = {
				{ kind = "Text", body = "Start fresh with a permanent 1.5x cash boost. Your rubies and gamepasses stay." },
				{ kind = "Buttons", layout = "Row", buttons = {
					{ id = "doRebirth", label = "Rebirth ($1M)", icon = "rebirth", tone = "Purple", action = { kind = "Remote", name = "Rebirth" } },
					{ id = "cancelRebirth", label = "Later", tone = "Gray", action = { kind = "Close" } },
				} },
			},
		},
		{
			id = "daily", title = "Daily Rewards", icon = "daily", kind = "Popup", size = "Medium",
			sections = {
				{ kind = "Daily", days = {
					{ day = 1, icon = "cash", amount = "$5K" }, { day = 2, icon = "cash", amount = "$10K" },
					{ day = 3, icon = "gem", amount = "5" }, { day = 4, icon = "cash", amount = "$25K" },
					{ day = 5, icon = "gem", amount = "10" }, { day = 6, icon = "cash", amount = "$50K" },
					{ day = 7, icon = "crown", amount = "1", label = "Golden Grill" },
				} },
			},
		},
		{
			id = "stats", title = "Stats", icon = "trophy", kind = "Window", size = "Large", nav = "Tabs",
			tabs = {
				{ id = "me", label = "Profile", icon = "player", sections = {
					{ kind = "Profile", level = 9, stats = {
						{ label = "Burgers sold", icon = "cash", value = "48,210" },
						{ label = "Rebirths", icon = "rebirth", value = "2" },
						{ label = "Best day", icon = "sun", value = "$1.8M" },
						{ label = "Customers", icon = "friends", value = "9,904" },
					}, badges = {
						{ icon = "star", label = "5 Stars" },
						{ icon = "fire", label = "Grill Master" },
					} },
				} },
				{ id = "top", label = "Top", icon = "trophy", sections = {
					{ kind = "Leaderboard", title = "Richest Owners", stat = "Cash", rows = {
						{ rank = 1, name = "BurgerBoss", value = "$48.2B" },
						{ rank = 2, name = "FryQueen", value = "$31.9B" },
						{ rank = 3, name = "SirSizzle", value = "$27.0B" },
						{ rank = 4, name = "Patty_Pro", value = "$9.4B" },
						{ rank = 5, name = "TestPlayer", value = "$24.7K" },
					} },
				} },
			},
		},
		{
			id = "settings", title = "Settings", icon = "settings", kind = "Window", size = "Medium",
			tabs = {
				{ id = "general", label = "General", icon = "settings", sections = {
					{ kind = "Settings", settings = {
						{ id = "music", label = "Music", icon = "music", type = "Toggle", default = true },
						{ id = "sfx", label = "Sounds", icon = "sound", type = "Slider", min = 0, max = 100, default = 70 },
						{ id = "notifications", label = "Notifications", icon = "bell", type = "Toggle", default = true },
						-- game-specific: the game's scripts handle ui:on("hideOthers")
						{ id = "hideOthers", label = "Hide Other Tycoons", icon = "hide", type = "Toggle", default = false },
					} },
				} },
				{ id = "access", label = "Accessibility", icon = "circle-person", sections = {
					{ kind = "Settings", settings = {
						{ id = "colorblind", label = "Colour-blind Mode", icon = "eye", type = "Choice", options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" }, default = "Off" },
						{ id = "reducedMotion", label = "Reduce Motion", icon = "pause-large", type = "Toggle", default = false },
						{ id = "uiScale", label = "UI Scale", icon = "dual-arrows-to-corners", type = "Slider", min = 80, max = 120, default = 100 },
					} },
				} },
			},
		},
	},
}
]]></string>
              </Properties>
            </Item>
          </Item>
          <Item class="ModuleScript" referent="43">
            <Properties>
              <string name="Name">Hud</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Blueprint.Hud: builds the HUD of a blueprint with an anchor layout engine.

	Every element reports its size in design px. Elements that share an anchor
	are stacked (first element hugs the screen edge; middle anchors read top to
	bottom) with the theme's density gap, so they never overlap. Stacks in the
	same screen column (TopLeft / Left / BottomLeft ...) are clamped against each
	other, and centre stacks shrink or lift when a side stack would touch them.

	Safe zones: 24 px from every edge, below the Roblox top bar in the top
	corners, and on touch screens the bottom-right corner stays clear for the
	jump button.

		local hud = Hud.build(ctx, scope, blueprint.hud)
		hud.Gui          ScreenGui
		hud.handles.xp   { Gui, Level, Progress }  (one handle per element id)

	v1.8 HUD kits: hud.kit (Components/HudKits) gives every element its kit
	style and shared details (plates, type, corners, idle motion) and brings
	the HUD in from the screen edges; an element's `look` picks one of its
	type's styles. Types without looks take what fits: Stamina and Battery sit
	on the kit's plate with its bar, the v1.7 readouts and the BossBar take its
	type, Buttons its button style. Without a kit or look, an element builds
	exactly as before.

	v2.3: a teleport row (MenuButtons role "teleports") builds small and stays
	a row; no menu is a side drawer at the top or bottom centre; in the
	Simulator layout the currencies sit lowest in a bottom stack.
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Bind = require(script.Parent.Parent.Util.Bind)
local Format = require(script.Parent.Parent.Util.Format)
local Input = require(script.Parent.Parent.Util.Input)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Motion = require(script.Parent.Parent.FX.Motion)
local Schema = require(script.Parent.Schema)
local Common = require(script.Parent.Common)
local HudKits = require(script.Parent.Parent.Components.HudKits)
local HudParts = require(script.Parent.Parent.Components.HudParts)

local Children = Fusion.Children
local peek = Fusion.peek

local Hud = {}

local EDGE = Schema.SAFE.Edge

type Built = { gui: Instance, size: Vector2, handle: { [string]: any }? }
type Place = { anchor: string, side: string, row: string, horizontal: boolean }

local function placeOf(anchor: string): Place
	local row = if string.find(anchor, "^Top") then "Top" elseif string.find(anchor, "^Bottom") then "Bottom" else "Middle"
	local side = if string.find(anchor, "Left$") then "Left" elseif string.find(anchor, "Right$") then "Right" else "Center"
	return {
		anchor = anchor,
		side = side,
		row = row,
		-- centre-top / centre-bottom stacks lay lists out horizontally
		horizontal = side == "Center" and row ~= "Middle",
	}
end

local function sideScale(side: string): number
	return if side == "Left" then 0 elseif side == "Right" then 1 else 0.5
end

-- A transparent frame of a known size that holds an auto-sized component,
-- pinned to the corner that faces the screen edge.
local function holder(scope: any, name: string, size: Vector2, place: Place, child: Instance): Frame
	local x = sideScale(place.side)
	local y = if place.row == "Bottom" then 1 else 0
	child.AnchorPoint = Vector2.new(x, y)
	child.Position = UDim2.fromScale(x, y)
	return scope:New "Frame" {
		Name = name,
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(size.X, size.Y),
		[Children] = child,
	}
end

local function hudButtonTone(tone: string?): string
	return tone or "Blue"
end

-- HUD buttons float over the busy 3D world: an outline or soft button there
-- (dark text on a see-through plate) is unreadable, so they stay solid
-- v1.6: a menu button that opens a screen with rewards waiting shows how
-- many (gifts ready, a free spin, a milestone...), else its own badge.
local function liveBadge(ctx, scope: any, b: { [string]: any }): any
	local action = b.action
	local target = if typeof(action) == "table" and (action.kind == "Open" or action.kind == "Toggle") then action.target else nil
	local rewards = ctx.ui.rewards
	local count = if target and rewards then rewards:claimable(target) else nil
	if count == nil then
		return b.badge
	end
	return scope:Computed(function(use)
		local n = use(count)
		if n > 0 then
			return n
		end
		return b.badge
	end)
end

local function solidStyle(scope: any): any
	return Kit.themed(scope, function(theme)
		local style = Theme.style(theme, "ButtonStyle")
		return if style == "Outline" or style == "Soft" then "Flat" else nil
	end)
end

--------------------------------------------------------------------------
-- v1.8 HUD kits (Components/HudKits). An element goes through its kit
-- component when the HUD has a kit (hud.kit) or the element has a look of
-- its own (e.look); otherwise it builds exactly as it did before v1.8.
--------------------------------------------------------------------------

local function kitted(ctx: any, e: { [string]: any }): boolean
	return ctx.kit ~= nil or e.look ~= nil
end

-- Props for a kit component: the element's own look (when set), else the
-- kit's style for the type, the kit's tokens, and the HUD's density.
local function kitProps(ctx: any, e: { [string]: any }, kind: string, props: { [any]: any }): { [any]: any }
	if e.look ~= nil and HudKits.valid(kind, e.look) then
		props.Style = e.look
	end
	props.Compact = ctx.compact
	props.Touch = ctx.touch
	return HudKits.apply(props, ctx.kit, kind)
end

-- The tokens handed to components that have no looks of their own (the
-- v1.7 readouts, buttons): the kit's type, corners and button style.
local function kitTokens(ctx: any): { [string]: any }?
	return if ctx.kit then HudKits.tokens(ctx.kit) else nil
end

--------------------------------------------------------------------------
-- Element builders: (ctx, scope, element, place) -> Built
--------------------------------------------------------------------------

local BUILD: { [string]: (any, any, { [string]: any }, Place) -> Built? } = {}

-- The kit builders (same handles as the originals below).
local KIT: { [string]: (any, any, { [string]: any }, Place) -> Built? } = {}

KIT.CurrencyBar = function(ctx, scope, e, place)
	local list = {}
	for _, id in e.currencies do
		local def = ctx.currencyDefs[id]
		if def == nil then
			continue
		end
		table.insert(list, {
			Id = id,
			Icon = def.icon,
			Color = Common.color(def.color),
			Amount = ctx.ui.currencies[id],
			Format = def.format,
			OnAdd = if e.showAdd
				then function()
					ctx.run(e.addAction, { id = e.id, payload = id })
				end
				else nil,
		})
	end
	local frames = {}
	local gui = scope:HudCurrencies(kitProps(ctx, e, "CurrencyBar", {
		Name = "CurrencyBar",
		Currencies = list,
		Horizontal = place.horizontal,
		Align = place.side,
		Frames = frames,
	}))
	for id, frame in frames do
		ctx.ui.currencyFrames[id] = frame
	end
	return { gui = gui, size = HudKits.sizeOf(gui), handle = { Gui = gui, Frames = frames } }
end

KIT.MenuButtons = function(ctx, scope, e, place)
	local buttons = {}
	for _, b in e.buttons do
		table.insert(buttons, {
			Id = b.id,
			Label = b.label,
			Icon = b.icon,
			Tone = hudButtonTone(b.tone),
			Badge = liveBadge(ctx, scope, b),
			Action = b.action,
			OnClick = function()
				ctx.run(b.action, { id = b.id })
			end,
		})
	end
	local handles = {}
	local teleports = e.role == "teleports"
	local props = kitProps(ctx, e, "MenuButtons", {
		Name = "Menu",
		Buttons = buttons,
		Align = place.side,
		Row = place.row,
		Horizontal = place.horizontal,
		-- v2.3: a teleport row is small, like the Minimal layout's menu
		Minimal = ctx.layout == "Minimal" or teleports,
		Handles = handles,
		Sound = if ctx.preview then "" else nil,
		MakeButton = function(s, p, b)
			return ctx.button(s, p, b.Action, { id = b.Id })
		end,
	})
	-- the HUD layout picks the menu's placement style (Column / Grid / Dock /
	-- Row); a kit whose menu is one of those originals keeps the layout's
	if e.look == nil and (props.Style == "Column" or props.Style == "Grid" or props.Style == "Dock") then
		props.Style = e.style
	end
	-- v2.3: a side drawer can't sit in a row at the top or bottom centre, and
	-- a teleport row stays a row (no raised tab-bar button): both keep their
	-- own style there
	if (place.horizontal and props.Style == "Drawer") or (teleports and props.Style == "TabBar") then
		props.Style = e.style
	end
	if ctx.kit == nil then
		-- a look without a kit: buttons stay solid over the world, as before
		props.ButtonStyle = solidStyle(scope)
	end
	local gui = scope:HudMenu(props)
	return { gui = gui, size = HudKits.sizeOf(gui), handle = { Gui = gui, Buttons = handles } }
end

KIT.HealthBar = function(ctx, scope, e, place)
	local health, maxHealth
	if e.source.kind == "humanoid" and not ctx.preview and Players.LocalPlayer then
		health, maxHealth = Bind.characterHealth(scope)
	else
		health = scope:Value(e.max)
		maxHealth = scope:Value(e.max)
	end
	local gui = scope:HudHealth(kitProps(ctx, e, "HealthBar", {
		Name = "Health",
		Value = health,
		Max = maxHealth,
		Align = place.side,
		Low = 0.3,
	}))
	return { gui = gui, size = HudKits.sizeOf(gui), handle = { Gui = gui, Health = health, MaxHealth = maxHealth } }
end

KIT.XPBar = function(ctx, scope, e, place)
	local level = scope:Value(e.level)
	local progress = scope:Value(e.progress)
	local gui = scope:HudXP(kitProps(ctx, e, "XPBar", { Name = "XPBar", Level = level, Progress = progress }))
	return { gui = gui, size = HudKits.sizeOf(gui), handle = { Gui = gui, Level = level, Progress = progress } }
end

KIT.QuestTracker = function(ctx, scope, e, place)
	local tasks = {}
	local values = {}
	local biggest = 0
	for index, t in e.tasks do
		local progress = scope:Value(t.progress)
		values[index] = progress
		biggest = math.max(biggest, t.goal)
		table.insert(tasks, {
			Text = t.text,
			Icon = t.icon,
			Progress = progress,
			Goal = t.goal,
			Reward = if t.reward then { Icon = t.reward.icon, Amount = t.reward.amount } else nil,
		})
	end
	local width = if biggest >= 10000 then 420 elseif biggest >= 1000 then 390 else 360
	local props = kitProps(ctx, e, "QuestTracker", { Title = e.title, Icon = "quest", Tasks = tasks, Width = width })
	-- the panel's rows keep their original size
	props.Compact = nil
	local gui = scope:QuestTracker(props)
	local size = HudKits.sizeOf(gui)
	return { gui = holder(scope, "QuestTracker", size, place, gui), size = size, handle = { Gui = gui, Progress = values } }
end

KIT.Hotbar = function(ctx, scope, e, place)
	local slots = {}
	local ammo = {}
	for index, s in e.slots do
		local slot = { Name = s.name, Icon = s.icon }
		if s.ammo ~= nil then
			slot.Ammo = scope:Value(s.ammo)
			ammo[index] = slot.Ammo
		end
		if s.reserve ~= nil then
			slot.Reserve = scope:Value(s.reserve)
		end
		table.insert(slots, slot)
	end
	local equipped = scope:Value(1)
	local gui = scope:Hotbar(kitProps(ctx, e, "Hotbar", {
		Slots = slots,
		Selected = equipped,
		BindKeys = not ctx.preview,
		Align = place.side,
		OnSelect = function(index)
			ctx.ui:emit(e.id, { id = e.id, payload = index })
		end,
	}))
	local size = HudKits.sizeOf(gui)
	return { gui = holder(scope, "Hotbar", size, place, gui), size = size, handle = { Gui = gui, Equipped = equipped, Ammo = ammo } }
end

KIT.KeyHints = function(ctx, scope, e, place)
	local hints = {}
	for _, h in e.hints do
		local key = Common.inputEnum(h.key) or h.key
		local pad = Common.inputEnum(h.gamepad)
		table.insert(hints, { Text = h.text, Key = key, Gamepad = pad })
	end
	local gui = scope:KeyHints(kitProps(ctx, e, "KeyHints", { Hints = hints, Align = place.side }))
	local size = HudKits.sizeOf(gui)
	return { gui = holder(scope, "KeyHints", size, place, gui), size = size, handle = { Gui = gui } }
end

KIT.Objective = function(ctx, scope, e, place)
	local objective = scope:Value(e.text)
	local gui = scope:HudObjective(kitProps(ctx, e, "Objective", {
		Name = "Objective",
		Text = objective,
		Label = e.label,
		Align = place.side,
		Animate = not ctx.preview,
	}))
	return {
		gui = gui,
		size = HudKits.sizeOf(gui),
		handle = {
			Gui = gui,
			Text = objective,
			set = function(textValue: string)
				objective:set(textValue)
			end,
		},
	}
end

KIT.Boosts = function(ctx, scope, e, place)
	local list = {}
	for _, b in e.boosts do
		table.insert(list, { icon = b.icon, text = b.text, endsAt = os.time() + b.seconds, seconds = b.seconds })
	end
	local boosts = scope:Value(list)
	local gui = scope:HudBoosts(kitProps(ctx, e, "Boosts", {
		Name = "Boosts",
		Boosts = boosts,
		Clock = ctx.clock,
		Align = place.side,
		Horizontal = place.horizontal,
		Slots = #list,
	}))
	return {
		gui = gui,
		size = HudKits.sizeOf(gui),
		handle = {
			Gui = gui,
			add = function(boost: { [string]: any })
				local now = table.clone(peek(boosts))
				local seconds = boost.seconds or boost.Seconds or 60
				table.insert(now, {
					icon = boost.icon or boost.Icon or "boost",
					text = boost.text or boost.Text or "Boost",
					endsAt = os.time() + seconds,
					seconds = seconds,
				})
				boosts:set(now)
			end,
		},
	}
end

KIT.Clicker = function(ctx, scope, e, place)
	local gui
	local function give()
		if e.currency and e.amount > 0 then
			ctx.ui:give(e.currency, e.amount, gui)
		end
	end
	gui = scope:Clicker(kitProps(ctx, e, "Clicker", {
		Name = "Clicker",
		Text = e.text,
		Icon = e.icon,
		Tone = "Yellow",
		Sound = if ctx.preview then "" else nil,
		OnClick = function()
			give()
			ctx.run({ kind = "None" }, { id = e.id })
		end,
		MakeButton = function(s, p)
			return ctx.button(s, p, { kind = "None" }, { id = e.id }, give)
		end,
	}))
	return { gui = gui, size = HudKits.sizeOf(gui), handle = { Gui = gui } }
end

KIT.KillFeed = function(ctx, scope, e, place)
	local feed = scope:Value({})
	local lines = if ctx.touch then 2 else 3
	local layer = ctx.hudLayer
	local gui = scope:KillFeed(kitProps(ctx, e, "KillFeed", {
		Name = "KillFeed",
		Entries = feed,
		Lines = lines,
		Align = place.side,
		Row = place.row,
		CalloutParent = if layer then layer.hud else nil,
	}))
	local order = 0
	return {
		gui = gui,
		size = HudKits.sizeOf(gui),
		handle = {
			Gui = gui,
			feed = function(killer: string, victim: string, iconName: string?)
				order += 1
				local player = Players.LocalPlayer
				local entry = {
					killer = killer,
					victim = victim,
					icon = iconName or "target",
					order = order,
					mine = player ~= nil and killer == player.DisplayName,
				}
				local list = table.clone(peek(feed))
				table.insert(list, entry)
				while #list > lines do
					table.remove(list, 1)
				end
				feed:set(list)
				task.delay(6, function()
					local now = table.clone(peek(feed))
					local index = table.find(now, entry)
					if index then
						table.remove(now, index)
						feed:set(now)
					end
				end)
			end,
		},
	}
end

KIT.Timer = function(ctx, scope, e, place)
	local endsAt = scope:Value(os.time() + e.seconds)
	local remaining = scope:Computed(function(use)
		return math.max(0, use(endsAt) - use(ctx.clock))
	end)
	local gui = scope:Timer(kitProps(ctx, e, "Timer", { EndsAt = endsAt, Label = e.label, Icon = "timer" }))
	local size = HudKits.sizeOf(gui)
	return {
		gui = holder(scope, "Timer", size, place, gui),
		size = size,
		handle = {
			Gui = gui,
			EndsAt = endsAt,
			Remaining = remaining,
			set = function(seconds: number)
				endsAt:set(os.time() + seconds)
			end,
		},
	}
end

KIT.TeamScore = function(ctx, scope, e, place)
	local scores = {}
	local teams = {}
	for index, t in e.teams do
		scores[index] = scope:Value(t.score)
		table.insert(teams, { name = t.name, color = Common.color(t.color), score = scores[index] })
	end
	local gui = scope:TeamScore(kitProps(ctx, e, "TeamScore", { Teams = teams, Goal = e.goal }))
	local size = HudKits.sizeOf(gui)
	return { gui = holder(scope, "TeamScore", size, place, gui), size = size, handle = { Gui = gui, Scores = scores } }
end

KIT.Minimap = function(ctx, scope, e, place)
	local gui = scope:Minimap(kitProps(ctx, e, "Minimap", { Name = "Minimap", Shape = e.shape, Size = 220 }))
	return { gui = gui, size = HudKits.sizeOf(gui), handle = { Gui = gui, Map = gui:FindFirstChild("Map", true) } }
end

-- Stamina and Battery (horror) have no looks of their own; in a kit they sit
-- on its plate with its bar, icon treatment and number type.
local function kitFill(scope: any, look: any, value: any, low: number, bright: string): any
	return scope:Computed(function(use)
		local theme = use(Theme.current)
		local l = use(look)
		if use(value) < low then
			return theme.Color.Bad
		end
		if bright == "Yellow" then
			return Theme.tone(theme, "Yellow").Top
		end
		-- white reads on dark plates; light (paper, toy) plates get the good colour
		return if l.light then theme.Color.Good else Color3.new(1, 1, 1)
	end)
end

KIT.Stamina = function(ctx, scope, e, place)
	local value = scope:Value(1)
	local T = HudKits.tokens(ctx.kit)
	local look = HudParts.look(scope, T)
	local size = Vector2.new(480, 36)
	local gui = scope:New "Frame" {
		Name = "Stamina",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(size.X, size.Y),
		[Children] = {
			HudParts.plate(scope, T, { Size = UDim2.fromScale(1, 1), ZIndex = 1 }),
			HudParts.icon(scope, T, {
				Icon = "run",
				Size = 24,
				Color = scope:Computed(function(use)
					return use(look).ink
				end),
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 10, 0.5, 0),
				ZIndex = 3,
			}),
			HudParts.bar(scope, T, {
				Name = "Bar",
				Fraction = value,
				Color = kitFill(scope, look, value, 0.25, "White"),
				Low = 0.25,
				Ghost = false,
				Heal = false,
				Size = UDim2.new(1, -58, 0, 12),
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 44, 0.5, 0),
				ZIndex = 3,
			}),
		},
	}
	HudParts.footprint(gui, size)
	return { gui = gui, size = size, handle = { Gui = gui, Value = value } }
end

KIT.Battery = function(ctx, scope, e, place)
	local value = scope:Value(0.8)
	local T = HudKits.tokens(ctx.kit)
	local look = HudParts.look(scope, T)
	local size = Vector2.new(300, 44)
	local gui = scope:New "Frame" {
		Name = "Battery",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(size.X, size.Y),
		[Children] = {
			HudParts.plate(scope, T, { Size = UDim2.fromScale(1, 1), ZIndex = 1 }),
			scope:Row {
				Name = "Content",
				Size = UDim2.new(1, -24, 1, 0),
				Position = UDim2.fromOffset(12, 0),
				Gap = 10,
				Align = "Center",
				ZIndex = 3,
				[Children] = {
					HudParts.icon(scope, T, {
						Icon = "bolt",
						Size = 28,
						Color = scope:Computed(function(use)
							local theme = use(Theme.current)
							return if use(value) < 0.25 then theme.Color.Bad else use(look).ink
						end),
						LayoutOrder = 1,
						ZIndex = 3,
					}),
					HudParts.bar(scope, T, {
						Name = "Cells",
						Fraction = value,
						Color = kitFill(scope, look, value, 0.25, "Yellow"),
						Segments = 4,
						Low = 0.25,
						Ghost = false,
						Heal = false,
						Size = UDim2.fromOffset(150, 20),
						LayoutOrder = 2,
						ZIndex = 3,
					}),
					HudParts.text(scope, T, look, {
						Role = "Number",
						Text = scope:Computed(function(use)
							return Format.percent(math.clamp(use(value), 0, 1))
						end),
						TextSize = 20,
						Size = UDim2.fromOffset(62, 28),
						LayoutOrder = 3,
						ZIndex = 3,
					}),
				},
			},
		},
	}
	HudParts.footprint(gui, size)
	return { gui = gui, size = size, handle = { Gui = gui, Value = value } }
end

BUILD.CurrencyBar = function(ctx, scope, e, place)
	if kitted(ctx, e) then
		return KIT.CurrencyBar(ctx, scope, e, place)
	end
	local compact = ctx.compact
	local w, h = if compact then 230 else 260, if compact then 54 else 60
	local gap = if place.horizontal then 16 else 12
	local frames = {}
	local list = {}
	for index, id in e.currencies do
		local def = ctx.currencyDefs[id]
		if def == nil then
			continue
		end
		local frame = scope:Currency {
			Name = id,
			Icon = def.icon,
			IconColor = Common.color(def.color),
			Amount = ctx.ui.currencies[id],
			Format = def.format,
			OnAdd = if e.showAdd
				then function()
					ctx.run(e.addAction, { id = e.id, payload = id })
				end
				else nil,
			Size = UDim2.fromOffset(w, h),
			LayoutOrder = index,
		}
		frames[id] = frame
		ctx.ui.currencyFrames[id] = frame
		table.insert(list, frame)
	end
	local n = math.max(1, #list)
	local size = if place.horizontal
		then Vector2.new(n * w + (n - 1) * gap, h)
		else Vector2.new(w, n * h + (n - 1) * gap)
	local gui = scope:New "Frame" {
		Name = "CurrencyBar",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(size.X, size.Y),
		[Children] = {
			Kit.list(scope, {
				Direction = if place.horizontal then "X" else "Y",
				Padding = gap,
				HAlign = if place.horizontal then "Center" else place.side,
				VAlign = "Center",
			}),
			list,
		},
	}
	return { gui = gui, size = size, handle = { Gui = gui, Frames = frames } }
end

BUILD.MenuButtons = function(ctx, scope, e, place)
	if kitted(ctx, e) then
		return KIT.MenuButtons(ctx, scope, e, place)
	end
	local style = e.style
	if ctx.touch and style == "Column" and place.row == "Middle" and #e.buttons > 3 then
		-- phones are short: a tall column becomes a two-wide grid
		style = "Grid"
	end
	if style == "Grid" and place.horizontal then
		style = "Row"
	elseif (style == "Row" or style == "Dock") and place.row == "Middle" then
		style = "Column"
	end
	-- Minimal (and a v2.3 teleport row) shrinks the buttons, except on touch
	-- screens where they are already at the smallest comfortable thumb size
	local minimal = (ctx.layout == "Minimal" or e.role == "teleports") and not ctx.touch
	local dock = style == "Dock"
	local buttonW = if minimal then 72 elseif dock then 84 else 96
	local buttonH = if minimal then 66 elseif dock then 76 else 88
	local labelSize = if minimal then 18 elseif dock then 20 else 22
	-- cells widen (a little) for long labels like LEADERBOARD; anything longer
	-- still shrinks to its cell instead of running into its neighbour
	local widest = 0
	for _, b in e.buttons do
		-- capitals in a display face run ~15% wider than textWidth's average
		widest = math.max(widest, Common.textWidth(string.upper(b.label or ""), labelSize * 1.15))
	end
	local cellW = math.clamp(widest + 8, buttonW + 8, buttonW + 60)
	local cellH = buttonH + labelSize + 10
	local handles = {}
	local cells = {}
	for index, b in e.buttons do
		local button = ctx.button(scope, {
			Name = "Button",
			Icon = b.icon,
			Tone = hudButtonTone(b.tone),
			Style = solidStyle(scope),
			Size = UDim2.fromOffset(buttonW, buttonH),
			IconSize = math.floor(buttonH * 0.62),
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5, 0),
			Badge = liveBadge(ctx, scope, b),
		}, b.action, { id = b.id })
		handles[b.id] = button
		table.insert(cells, scope:New "Frame" {
			Name = b.id,
			LayoutOrder = index,
			-- earlier cells draw on top, so a button's drop shadow never covers
			-- the label of the cell above it
			ZIndex = #e.buttons - index + 2,
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(cellW, cellH),
			[Children] = {
				button,
				scope:Text {
					Name = "Label",
					Text = b.label,
					TextSize = labelSize,
					Font = "Display",
					-- labels float over the game world: always outlined, even in
					-- themes whose text has no stroke (docs/RESEARCH.md 7.4)
					Stroke = if dock then nil else 2,
					Size = UDim2.new(1, 2, 0, labelSize + 4),
					Scaled = true,
					Wrapped = false, -- single line: shrink, never break a word
					MaxTextSize = labelSize,
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, 0),
					ZIndex = 3,
				},
			},
		})
	end
	local n = math.max(1, #cells)
	-- phones are short: five or more grid buttons go three across (two rows)
	-- so the menu keeps full-size touch targets instead of shrinking
	local columns = if style == "Grid" then (if ctx.touch and n >= 5 then 3 else math.min(2, n))
		elseif style == "Column" then 1
		else n
	local rows = math.ceil(n / columns)
	local gapX, gapY = if dock then 8 else 12, 8
	local inner = Vector2.new(columns * cellW + (columns - 1) * gapX, rows * cellH + (rows - 1) * gapY)
	local pad = if dock then 12 else 0
	local size = inner + Vector2.new(pad * 2, pad * 2)
	local grid = scope:Grid {
		Name = "Buttons",
		Size = UDim2.fromOffset(inner.X, inner.Y),
		Position = UDim2.fromOffset(pad, pad),
		CellSize = Vector2.new(cellW, cellH),
		Gap = Vector2.new(gapX, gapY),
		Columns = columns,
		Align = if place.side == "Right" then "Right" elseif place.side == "Left" then "Left" else "Center",
		[Children] = cells,
	}
	local gui
	if dock then
		gui = Common.card(scope, {
			Name = "MenuDock",
			Size = UDim2.fromOffset(size.X, size.Y),
			Radius = "L",
			Children = grid,
		})
	else
		gui = scope:New "Frame" {
			Name = "Menu",
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(size.X, size.Y),
			[Children] = grid,
		}
	end
	return { gui = gui, size = size, handle = { Gui = gui, Buttons = handles } }
end

BUILD.HealthBar = function(ctx, scope, e, place)
	if kitted(ctx, e) then
		return KIT.HealthBar(ctx, scope, e, place)
	end
	local health, maxHealth
	if e.source.kind == "humanoid" and not ctx.preview and Players.LocalPlayer then
		health, maxHealth = Bind.characterHealth(scope)
	else
		health = scope:Value(e.max)
		maxHealth = scope:Value(e.max)
	end
	local size = Vector2.new(if ctx.compact then 360 else 400, 44)
	local gui = scope:ProgressBar {
		Name = "Health",
		Value = health,
		Max = maxHealth,
		Tone = scope:Computed(function(use)
			local max = use(maxHealth)
			local fraction = if max > 0 then use(health) / max else 0
			return if fraction > 0.5 then "Green" elseif fraction > 0.25 then "Yellow" else "Red"
		end),
		Size = UDim2.fromOffset(size.X, size.Y),
		Label = true,
		LabelIcon = "heart",
		Low = 0.3,
		-- low health shows a warning sign too, not just a colour
		LowIcon = "warning",
		Shine = true,
	}
	return { gui = gui, size = size, handle = { Gui = gui, Health = health, MaxHealth = maxHealth } }
end

BUILD.XPBar = function(ctx, scope, e, place)
	if kitted(ctx, e) then
		return KIT.XPBar(ctx, scope, e, place)
	end
	local level = scope:Value(e.level)
	local progress = scope:Value(e.progress)
	local barW = if ctx.compact then 420 else 480
	local badge = 60
	local size = Vector2.new(barW + badge - 12, badge)
	local gui = scope:New "Frame" {
		Name = "XPBar",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(size.X, size.Y),
		[Children] = {
			scope:ProgressBar {
				Name = "Bar",
				Value = progress,
				Max = 1,
				Tone = "Blue",
				Ghost = false,
				Shine = true,
				Size = UDim2.fromOffset(barW, 30),
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.fromScale(1, 0.5),
				Label = scope:Computed(function(use)
					return Format.percent(math.clamp(use(progress), 0, 1))
				end),
			},
			Common.card(scope, {
				Name = "Level",
				Size = UDim2.fromOffset(badge, badge),
				Tone = "Yellow",
				Radius = "Full",
				ZIndex = 4,
				Children = scope:Text {
					Text = scope:Computed(function(use)
						return tostring(use(level))
					end),
					TextSize = 30,
					Font = "Display",
					Size = UDim2.new(1, -10, 0, 34),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Scaled = true,
					Wrapped = false, -- single line: shrink, never break a word
					MaxTextSize = 30,
					StrokeColor = Kit.themed(scope, function(theme)
						return theme.Tone.Yellow.Stroke or theme.Color.Outline
					end),
					ZIndex = 6,
				},
			}),
		},
	}
	return { gui = gui, size = size, handle = { Gui = gui, Level = level, Progress = progress } }
end

BUILD.QuestTracker = function(ctx, scope, e, place)
	if kitted(ctx, e) then
		return KIT.QuestTracker(ctx, scope, e, place)
	end
	local tasks = {}
	local values = {}
	for index, t in e.tasks do
		local progress = scope:Value(t.progress)
		values[index] = progress
		table.insert(tasks, {
			Text = t.text,
			Icon = t.icon,
			Progress = progress,
			Goal = t.goal,
			Reward = if t.reward then { Icon = t.reward.icon, Amount = t.reward.amount } else nil,
		})
	end
	-- big goals print long counters ("15320 / 50000"): give them room
	local biggest = 0
	for _, t in e.tasks do
		biggest = math.max(biggest, t.goal)
	end
	local width = if biggest >= 10000 then 420 elseif biggest >= 1000 then 390 else 360
	local size = Vector2.new(width, 10 + 40 + 2 + #tasks * 58 + 12)
	local gui = scope:QuestTracker {
		Title = e.title,
		Icon = "quest",
		Tasks = tasks,
		Width = width,
	}
	return { gui = holder(scope, "QuestTracker", size, place, gui), size = size, handle = { Gui = gui, Progress = values } }
end

BUILD.Hotbar = function(ctx, scope, e, place)
	if kitted(ctx, e) then
		return KIT.Hotbar(ctx, scope, e, place)
	end
	local slots = {}
	local ammo = {}
	for index, s in e.slots do
		local slot = { Name = s.name, Icon = s.icon }
		if s.ammo ~= nil then
			slot.Ammo = scope:Value(s.ammo)
			ammo[index] = slot.Ammo
		end
		if s.reserve ~= nil then
			slot.Reserve = scope:Value(s.reserve)
		end
		table.insert(slots, slot)
	end
	local equipped = scope:Value(1)
	local n = math.max(1, #slots)
	local size = Vector2.new(114 + (n - 1) * 96, 46 + 6 + 97)
	local gui = scope:Hotbar {
		Slots = slots,
		Selected = equipped,
		BindKeys = not ctx.preview,
		Align = place.side,
		OnSelect = function(index)
			ctx.ui:emit(e.id, { id = e.id, payload = index })
		end,
	}
	return { gui = holder(scope, "Hotbar", size, place, gui), size = size, handle = { Gui = gui, Equipped = equipped, Ammo = ammo } }
end

BUILD.KeyHints = function(ctx, scope, e, place)
	if kitted(ctx, e) then
		return KIT.KeyHints(ctx, scope, e, place)
	end
	local hints = {}
	local width = 28
	for index, h in e.hints do
		-- mouse buttons are UserInputTypes; unknown names show as text
		local key = Common.inputEnum(h.key) or h.key
		local pad = Common.inputEnum(h.gamepad)
		table.insert(hints, { Text = h.text, Key = key, Gamepad = pad })
		width += Common.textWidth(h.text, 17) + 8 + 30 + (if index > 1 then 20 else 0)
	end
	local size = Vector2.new(width, 40)
	local gui = scope:KeyHints { Hints = hints }
	return { gui = holder(scope, "KeyHints", size, place, gui), size = size, handle = { Gui = gui } }
end

BUILD.Objective = function(ctx, scope, e, place)
	if kitted(ctx, e) then
		return KIT.Objective(ctx, scope, e, place)
	end
	local objective = scope:Value(e.text)
	local size = Vector2.new(560, 88)
	-- on the right edge the whole plate mirrors: accent bar, caption and text
	-- all hug the right, so nothing is split across the plate
	local right = place.side == "Right"
	local inset = if right then 4 else 20
	local label = scope:Text {
		Name = "ObjectiveText",
		Text = e.text,
		TextSize = 30,
		Font = "Display",
		XAlign = if right then "Right" else "Left",
		Size = UDim2.new(1, -24, 0, 36),
		Position = UDim2.fromOffset(inset, 40),
		Truncate = true,
		ZIndex = 3,
	}
	local gui = scope:New "Frame" {
		Name = "Objective",
		Size = UDim2.fromOffset(size.X, size.Y),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Surface
		end),
		BackgroundTransparency = 0.35,
		[Children] = {
			Kit.corner(scope, "M"),
			Kit.gradient(scope, {
				Rotation = if place.side == "Right" then 180 else 0,
				Transparency = NumberSequence.new({
					NumberSequenceKeypoint.new(0, 0),
					NumberSequenceKeypoint.new(0.6, 0.35),
					NumberSequenceKeypoint.new(1, 1),
				}),
			}),
			scope:New "Frame" {
				Name = "Accent",
				Size = UDim2.new(0, 5, 1, -24),
				Position = if right then UDim2.new(1, -5, 0, 12) else UDim2.fromOffset(0, 12),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Accent
				end),
				ZIndex = 2,
				[Children] = Kit.corner(scope, "Full"),
			},
			scope:Row {
				Name = "Caption",
				Size = UDim2.new(1, -24, 0, 26),
				Position = UDim2.fromOffset(inset, 12),
				Gap = 8,
				Align = if right then "Right" else "Left",
				ZIndex = 3,
				[Children] = {
					scope:Icon {
						Icon = "objective",
						Size = 22,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Accent
						end),
						LayoutOrder = 1,
						ZIndex = 3,
					},
					scope:Text {
						Text = e.label,
						TextSize = 20,
						Font = "Heavy",
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Accent
						end),
						LayoutOrder = 2,
						ZIndex = 3,
					},
				},
			},
			label,
		},
	}
	local skip: (() -> ())? = nil
	local function show(textValue: string)
		if skip then
			skip()
			skip = nil
		end
		if ctx.preview or textValue == "" then
			label.Text = textValue
			return
		end
		skip = Motion.typewrite(label, textValue, { Speed = 32, Sound = "" })
	end
	if not ctx.preview then
		show(e.text)
	end
	Kit.watch(scope, objective, show)
	return {
		gui = gui,
		size = size,
		handle = {
			Gui = gui,
			Text = objective,
			set = function(textValue: string)
				objective:set(textValue)
			end,
		},
	}
end

BUILD.Stamina = function(ctx, scope, e, place)
	if ctx.kit ~= nil then
		return KIT.Stamina(ctx, scope, e, place)
	end
	local value = scope:Value(1)
	local size = Vector2.new(480, 30)
	local gui = scope:Row {
		Name = "Stamina",
		Size = UDim2.fromOffset(size.X, size.Y),
		Gap = 10,
		Align = "Center",
		[Children] = {
			scope:Icon { Icon = "run", Size = 28, LayoutOrder = 1 },
			scope:ProgressBar {
				Value = value,
				Max = 1,
				Ghost = false,
				Tone = scope:Computed(function(use)
					return if use(value) < 0.25 then "Red" else "White"
				end),
				Size = UDim2.fromOffset(size.X - 40, 16),
				LayoutOrder = 2,
			},
		},
	}
	return { gui = gui, size = size, handle = { Gui = gui, Value = value } }
end

BUILD.Battery = function(ctx, scope, e, place)
	if ctx.kit ~= nil then
		return KIT.Battery(ctx, scope, e, place)
	end
	local value = scope:Value(0.8)
	local size = Vector2.new(300, 40)
	local gui = scope:Row {
		Name = "Battery",
		Size = UDim2.fromOffset(size.X, size.Y),
		Gap = 10,
		Align = place.side,
		[Children] = {
			scope:Icon {
				Icon = "bolt",
				Size = 34,
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					return if use(value) < 0.25 then theme.Color.Bad else theme.Color.Text
				end),
				LayoutOrder = 1,
			},
			scope:ProgressBar {
				Value = value,
				Max = 1,
				Segments = 4,
				Ghost = false,
				Radius = "S",
				Tone = scope:Computed(function(use)
					return if use(value) < 0.25 then "Red" else "Yellow"
				end),
				Size = UDim2.fromOffset(160, 24),
				LayoutOrder = 2,
			},
			scope:Text {
				Text = scope:Computed(function(use)
					return Format.percent(math.clamp(use(value), 0, 1))
				end),
				TextSize = 22,
				Font = "Display",
				LayoutOrder = 3,
			},
		},
	}
	return { gui = gui, size = size, handle = { Gui = gui, Value = value } }
end

BUILD.Boosts = function(ctx, scope, e, place)
	if kitted(ctx, e) then
		return KIT.Boosts(ctx, scope, e, place)
	end
	local list = {}
	for _, b in e.boosts do
		table.insert(list, { icon = b.icon, text = b.text, endsAt = os.time() + b.seconds })
	end
	local boosts = scope:Value(list)
	local rows = scope:ForValues(boosts, function(_, inner, boost)
		local remaining = inner:Computed(function(use)
			return math.max(0, boost.endsAt - use(ctx.clock))
		end)
		return inner:New "Frame" {
			Name = "Boost",
			Size = UDim2.fromOffset(0, 50),
			AutomaticSize = Enum.AutomaticSize.X,
			Visible = inner:Computed(function(use)
				return use(remaining) > 0
			end),
			BackgroundColor3 = Kit.themed(inner, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = Kit.themed(inner, function(theme)
				return theme.Alpha.Surface
			end),
			[Children] = {
				Kit.corner(inner, "Full"),
				Kit.stroke(inner, { Color = Color3.new(1, 1, 1), Thickness = 1.5, Transparency = 0.85 }),
				Kit.padding(inner, { 0, 18, 0, 8 }),
				Kit.list(inner, { Direction = "X", Padding = 8, VAlign = "Center" }),
				inner:Icon {
					Icon = boost.icon,
					Size = 38,
					Color = Kit.themed(inner, function(theme)
						return theme.Tone.Green.Top
					end),
					Gradient = true,
					LayoutOrder = 1,
				},
				inner:Text { Text = boost.text, TextSize = 22, Font = "Display", LayoutOrder = 2 },
				inner:Text {
					Text = inner:Computed(function(use)
						return Format.time(use(remaining))
					end),
					TextSize = 22,
					Font = "Display",
					Color = Kit.themed(inner, function(theme)
						return theme.Color.Accent
					end),
					LayoutOrder = 3,
				},
			},
		}
	end)
	local n = math.max(1, #list)
	local size = Vector2.new(320, n * 50 + (n - 1) * 10)
	local gui = scope:Column {
		Name = "Boosts",
		Size = UDim2.fromOffset(size.X, size.Y),
		Gap = 10,
		Align = place.side,
		[Children] = rows,
	}
	return {
		gui = gui,
		size = size,
		handle = {
			Gui = gui,
			add = function(boost: { [string]: any })
				local now = table.clone(peek(boosts))
				table.insert(now, {
					icon = boost.icon or boost.Icon or "boost",
					text = boost.text or boost.Text or "Boost",
					endsAt = os.time() + (boost.seconds or boost.Seconds or 60),
				})
				boosts:set(now)
			end,
		},
	}
end

BUILD.Clicker = function(ctx, scope, e, place)
	if kitted(ctx, e) then
		return KIT.Clicker(ctx, scope, e, place)
	end
	local size = Vector2.new(if ctx.compact then 280 else 320, if ctx.compact then 100 else 112)
	local gui
	gui = ctx.button(scope, {
		Name = "Clicker",
		Text = e.text,
		Icon = e.icon,
		Tone = "Yellow",
		Size = UDim2.fromOffset(size.X, size.Y),
		TextSize = 50,
		IconSize = 64,
		Shine = true,
	}, { kind = "None" }, { id = e.id }, function()
		if e.currency and e.amount > 0 then
			ctx.ui:give(e.currency, e.amount, gui)
		end
	end)
	return { gui = gui, size = size, handle = { Gui = gui } }
end

BUILD.KillFeed = function(ctx, scope, e, place)
	if kitted(ctx, e) then
		return KIT.KillFeed(ctx, scope, e, place)
	end
	local feed = scope:Value({})
	local rows = scope:ForValues(feed, function(_, inner, entry)
		return inner:New "Frame" {
			Name = "FeedEntry",
			LayoutOrder = -entry.order,
			Size = UDim2.fromOffset(0, 40),
			AutomaticSize = Enum.AutomaticSize.X,
			BackgroundColor3 = Kit.themed(inner, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = Kit.themed(inner, function(theme)
				return theme.Alpha.Surface
			end),
			[Children] = {
				Kit.corner(inner, "S"),
				Kit.padding(inner, { 0, 12 }),
				Kit.list(inner, { Direction = "X", Padding = 10, VAlign = "Center" }),
				inner:Text {
					Text = entry.killer,
					TextSize = 20,
					Font = "Heavy",
					Color = if entry.mine
						then Kit.themed(inner, function(theme)
							return theme.Color.Accent
						end)
						else nil,
					LayoutOrder = 1,
				},
				inner:Icon { Icon = entry.icon, Size = 24, LayoutOrder = 2 },
				inner:Text { Text = entry.victim, TextSize = 20, Font = "Heavy", LayoutOrder = 3 },
			},
		}
	end)
	local lines = if ctx.touch then 2 else 3
	local size = Vector2.new(460, lines * 40 + (lines - 1) * 6)
	local gui = scope:Column {
		Name = "KillFeed",
		Size = UDim2.fromOffset(size.X, size.Y),
		Gap = 6,
		Align = place.side,
		VAlign = if place.row == "Bottom" then "Bottom" else "Top",
		[Children] = rows,
	}
	local order = 0
	return {
		gui = gui,
		size = size,
		handle = {
			Gui = gui,
			feed = function(killer: string, victim: string, iconName: string?)
				order += 1
				local player = Players.LocalPlayer
				local entry = {
					killer = killer,
					victim = victim,
					icon = iconName or "target",
					order = order,
					mine = player ~= nil and killer == player.DisplayName,
				}
				local list = table.clone(peek(feed))
				table.insert(list, entry)
				while #list > lines do
					table.remove(list, 1)
				end
				feed:set(list)
				task.delay(6, function()
					local now = table.clone(peek(feed))
					local index = table.find(now, entry)
					if index then
						table.remove(now, index)
						feed:set(now)
					end
				end)
			end,
		},
	}
end

-- Pill with an icon, a label and a big countdown.
local function timerPill(ctx, scope, label: string, remaining: any, width: number): Frame
	return Common.card(scope, {
		Name = "Timer",
		Size = UDim2.fromOffset(width, 60),
		Radius = "Full",
		Children = {
			scope:Row {
				Size = UDim2.fromScale(1, 1),
				Gap = 10,
				Align = "Center",
				ZIndex = 3,
				[Children] = {
					scope:Icon {
						Icon = "timer",
						Size = 34,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Accent
						end),
						Gradient = true,
						LayoutOrder = 1,
						ZIndex = 3,
					},
					scope:Text {
						Text = label,
						TextSize = 20,
						Font = "Body",
						Color = Kit.themed(scope, function(theme)
							return theme.Color.TextMuted
						end),
						LayoutOrder = 2,
						ZIndex = 3,
					},
					scope:Text {
						Text = scope:Computed(function(use)
							return Format.time(use(remaining))
						end),
						TextSize = 34,
						Font = "Display",
						LayoutOrder = 3,
						ZIndex = 3,
					},
				},
			},
		},
	})
end

BUILD.Timer = function(ctx, scope, e, place)
	if kitted(ctx, e) then
		return KIT.Timer(ctx, scope, e, place)
	end
	local endsAt = scope:Value(os.time() + e.seconds)
	local remaining = scope:Computed(function(use)
		return math.max(0, use(endsAt) - use(ctx.clock))
	end)
	local width = math.max(240, 150 + Common.textWidth(e.label, 20))
	local size = Vector2.new(width, 60)
	local Timer = Common.component("Timer")
	local gui = if Timer
		then Common.try(Timer, scope, { EndsAt = endsAt, Label = e.label, Icon = "timer", Size = UDim2.fromOffset(size.X, size.Y) })
		else nil
	if gui == nil then
		gui = timerPill(ctx, scope, e.label, remaining, width)
	end
	return {
		gui = holder(scope, "Timer", size, place, gui),
		size = size,
		handle = {
			Gui = gui,
			EndsAt = endsAt,
			Remaining = remaining,
			set = function(seconds: number)
				endsAt:set(os.time() + seconds)
			end,
		},
	}
end

BUILD.TeamScore = function(ctx, scope, e, place)
	if kitted(ctx, e) then
		return KIT.TeamScore(ctx, scope, e, place)
	end
	local scores = {}
	local teams = {}
	for index, t in e.teams do
		scores[index] = scope:Value(t.score)
		table.insert(teams, { name = t.name, color = Common.color(t.color), score = scores[index] })
	end
	local size = Vector2.new(560, 84)
	local TeamScore = Common.component("TeamScore")
	local gui = if TeamScore then Common.try(TeamScore, scope, { Teams = teams, Goal = e.goal }) else nil
	if gui == nil then
		local function side(index: number): Frame
			local t = teams[index]
			local tone = Common.makeTone(t.color)
			return Common.card(scope, {
				Name = `Team{index}`,
				Size = UDim2.fromOffset(200, 72),
				AnchorPoint = Vector2.new(if index == 1 then 0 else 1, 0.5),
				Position = UDim2.fromScale(if index == 1 then 0 else 1, 0.5),
				Tone = tone,
				Radius = "M",
				Children = {
					scope:Text {
						Text = t.name,
						TextSize = 20,
						Font = "Heavy",
						AnchorPoint = Vector2.new(0.5, 0),
						Position = UDim2.new(0.5, 0, 0, 6),
						StrokeColor = tone.Edge,
						ZIndex = 3,
					},
					scope:Text {
						Text = scope:Computed(function(use)
							return tostring(use(t.score))
						end),
						TextSize = 40,
						Font = "Display",
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.new(0.5, 0, 1, -2),
						StrokeColor = tone.Edge,
						ZIndex = 3,
					},
				},
			})
		end
		gui = scope:New "Frame" {
			Name = "TeamScore",
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(size.X, size.Y),
			[Children] = {
				side(1),
				side(2),
				Common.card(scope, {
					Name = "Goal",
					Size = UDim2.fromOffset(120, 56),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Radius = "M",
					Children = scope:Text {
						Text = if e.goal then `FIRST TO {e.goal}` else "VS",
						TextSize = if e.goal then 16 else 30,
						Font = "Display",
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Size = UDim2.new(1, -12, 1, -8),
						Wrapped = true,
						ZIndex = 3,
					},
				}),
			},
		}
	end
	return { gui = holder(scope, "TeamScore", size, place, gui), size = size, handle = { Gui = gui, Scores = scores } }
end

BUILD.Button = function(ctx, scope, e, place)
	local size = Vector2.new(math.clamp(110 + Common.textWidth(e.label, 28), 180, 320), 68)
	local tokens = kitTokens(ctx)
	local gui = ctx.button(scope, {
		Name = e.id,
		Text = e.label,
		Icon = e.icon,
		Tone = e.tone,
		-- a kit gives its button style, corners and type
		Style = if tokens then HudParts.buttonStyle(tokens) else solidStyle(scope),
		Radius = if tokens then HudParts.buttonRadius(tokens) else nil,
		Font = if tokens then tokens.labelFont else nil,
		Size = UDim2.fromOffset(size.X, size.Y),
		Shine = true,
		Badge = liveBadge(ctx, scope, e),
	}, e.action, { id = e.id })
	return { gui = gui, size = size, handle = { Gui = gui } }
end

BUILD.Minimap = function(ctx, scope, e, place)
	if kitted(ctx, e) then
		return KIT.Minimap(ctx, scope, e, place)
	end
	local size = Vector2.new(220, 220)
	local radius = if e.shape == "Round" then "Full" else "L"
	local lines = {}
	for i = 1, 3 do
		table.insert(lines, scope:New "Frame" {
			Name = "GridX",
			Size = UDim2.new(1, 0, 0, 1),
			Position = UDim2.fromScale(0, i / 4),
			BackgroundColor3 = Color3.new(1, 1, 1),
			BackgroundTransparency = 0.9,
			ZIndex = 2,
		})
		table.insert(lines, scope:New "Frame" {
			Name = "GridY",
			Size = UDim2.new(0, 1, 1, 0),
			Position = UDim2.fromScale(i / 4, 0),
			BackgroundColor3 = Color3.new(1, 1, 1),
			BackgroundTransparency = 0.9,
			ZIndex = 2,
		})
	end
	local map = scope:New "CanvasGroup" {
		Name = "Map",
		Size = UDim2.new(1, -12, 1, -12),
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Surface
		end),
		BackgroundTransparency = 0.25,
		ZIndex = 2,
		[Children] = { Kit.corner(scope, radius), lines },
	}
	local gui = scope:New "Frame" {
		Name = "Minimap",
		Size = UDim2.fromOffset(size.X, size.Y),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Surface
		end),
		BackgroundTransparency = Kit.themed(scope, function(theme)
			return theme.Alpha.Surface
		end),
		[Children] = {
			Kit.corner(scope, radius),
			Kit.stroke(scope, { Color = Color3.new(1, 1, 1), Thickness = 3, Transparency = 0.6 }),
			map,
			scope:Icon {
				Name = "Player",
				Icon = "location-pin",
				Size = 30,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Color = Kit.themed(scope, function(theme)
					return theme.Color.Accent
				end),
				ZIndex = 4,
			},
			scope:Text {
				Name = "North",
				Text = "N",
				TextSize = 22,
				Font = "Display",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 0, 0, 14),
				ZIndex = 4,
			},
		},
	}
	return { gui = gui, size = size, handle = { Gui = gui, Map = map } }
end

BUILD.BossBar = function(ctx, scope, e, place)
	local health = scope:Value(e.max)
	local max = scope:Value(e.max)
	local size = Vector2.new(760, 92)
	local BossBar = Common.component("BossBar")
	local gui = if BossBar
		then Common.try(BossBar, scope, { Name = e.name, Value = health, Max = max, Tone = e.tone, Icon = e.icon, Size = UDim2.fromOffset(size.X, size.Y), Kit = kitTokens(ctx) })
		else nil
	if gui == nil then
		gui = scope:New "Frame" {
			Name = "BossBar",
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(size.X, size.Y),
			[Children] = {
				scope:Row {
					Name = "Plate",
					Size = UDim2.new(1, 0, 0, 44),
					Gap = 10,
					Align = "Center",
					[Children] = {
						scope:Icon {
							Icon = e.icon or "crown",
							Size = 34,
							Color = Kit.themed(scope, function(theme)
								return Theme.tone(theme, e.tone).Top
							end),
							Gradient = true,
							Shadow = true,
							LayoutOrder = 1,
						},
						scope:Text { Text = e.name, TextSize = 34, Font = "Title", LayoutOrder = 2 },
					},
				},
				scope:ProgressBar {
					Name = "Health",
					Value = health,
					Max = max,
					Tone = e.tone,
					Segments = 4,
					Shine = true,
					Label = function(value, total)
						return `{Format.abbrev(value)} / {Format.abbrev(total)}`
					end,
					Size = UDim2.new(1, 0, 0, 40),
					Position = UDim2.fromOffset(0, 50),
				},
			},
		}
	end
	return {
		gui = holder(scope, "BossBar", size, place, gui),
		size = size,
		handle = { Gui = gui, Health = health, Max = max },
	}
end

--------------------------------------------------------------------------
-- v1.7: sub-genre readouts (Components/Counter, Meter, TeamStatus,
-- CameraPanel, Camcorder, Compass)
--------------------------------------------------------------------------

local function counterText(e: { [string]: any }): string
	local text = tostring(math.floor(e.value or 0))
	if e.digits and e.digits > 1 and (e.value or 0) >= 0 then
		text = string.rep("0", math.max(0, e.digits - #text)) .. text
	end
	if e.goal then
		text ..= "/" .. tostring(e.goal)
	end
	if e.suffix then
		text ..= " · " .. e.suffix
	end
	return text
end

BUILD.Counter = function(ctx, scope, e, place)
	local Counter = Common.component("Counter")
	local reading = counterText(e)
	local k = if ctx.compact then 0.92 else 1
	local w, h
	if e.style == "Digital" then
		w = math.clamp(Common.textWidth(e.label, 20) + Common.textWidth(reading, 34) + 70, 220, 400)
		h = 66
	elseif e.style == "Stamp" then
		w = math.clamp(math.max(Common.textWidth(e.label, 24) + 50, Common.textWidth(reading, 58) + (if e.icon then 76 else 14)), 240, 420)
		h = 104
	elseif e.style == "Scrawl" then
		w = math.clamp(Common.textWidth(e.label .. "  " .. reading, 44) + 24, 220, 420)
		h = 76
	else
		w = math.clamp(Common.textWidth(reading, 44) + 80, 200, 340)
		h = 90
	end
	local pips = e.goal and e.goal >= 2 and e.goal <= 10 and (e.style == "Stamp" or e.style == "Scrawl")
	if pips then
		h += 22
	end
	local size = Vector2.new(math.floor(w * k), math.floor(h * k))
	local value = scope:Value(e.value or 0)
	local goal = scope:Value(e.goal)
	local suffix = scope:Value(e.suffix)
	local gui = if Counter
		then Common.try(Counter, scope, {
			Label = e.label,
			Value = value,
			Goal = goal,
			Digits = e.digits,
			Suffix = suffix,
			Icon = e.icon,
			Style = e.style,
			Align = place.side,
			Kit = kitTokens(ctx),
			Size = UDim2.fromOffset(size.X, size.Y),
		})
		else nil
	if gui == nil then
		gui = scope:Text { Text = `{e.label} {reading}`, TextSize = 34, Font = "Display", Size = UDim2.fromOffset(size.X, size.Y) }
	end
	return {
		gui = gui,
		size = size,
		handle = {
			Gui = gui,
			Value = value,
			Goal = goal,
			Suffix = suffix,
			set = function(n: number, newSuffix: string?)
				value:set(n)
				if newSuffix ~= nil then
					suffix:set(newSuffix)
				end
			end,
		},
	}
end

BUILD.Meter = function(ctx, scope, e, place)
	local Meter = Common.component("Meter")
	local size = if e.style == "Dial" then Vector2.new(240, 170)
		elseif e.style == "ECG" then Vector2.new(340, 112)
		elseif e.style == "Segments" then Vector2.new(if e.usage ~= nil then 400 else 360, 82)
		else Vector2.new(340, 76)
	if ctx.compact then
		size = Vector2.new(math.floor(size.X * 0.92), math.floor(size.Y * 0.92))
	end
	local value = scope:Value(e.value)
	local usage = if e.usage ~= nil then scope:Value(e.usage) else nil
	local readout = if e.readout then scope:Value(e.readout) else nil
	local gui = if Meter
		then Common.try(Meter, scope, {
			Label = e.label,
			Icon = e.icon,
			Value = value,
			Style = e.style,
			Tone = e.tone or "Primary",
			Segments = e.segments,
			Usage = usage,
			Readout = readout,
			Low = e.low,
			Kit = kitTokens(ctx),
			Size = UDim2.fromOffset(size.X, size.Y),
		})
		else nil
	if gui == nil then
		gui = scope:ProgressBar { Value = value, Max = 1, Size = UDim2.fromOffset(size.X, 24) }
	end
	return {
		gui = gui,
		size = size,
		handle = {
			Gui = gui,
			Value = value,
			Usage = usage,
			Readout = readout,
			set = function(fraction: number)
				value:set(math.clamp(fraction, 0, 1))
			end,
		},
	}
end

BUILD.TeamStatus = function(ctx, scope, e, place)
	local TeamStatus = Common.component("TeamStatus")
	local count = #e.members
	local style = e.style
	if place.row == "Middle" and place.side ~= "Center" then
		-- a side edge has room for rows, not a row of rings
		style = "List"
	end
	local size
	if style == "List" then
		size = Vector2.new(260, 26 + (count + (if e.killer then 1 else 0)) * 44)
	else
		local h = if count > 5 then 104 else 124
		local ring = math.clamp(h - 18 - 36, 40, 76)
		size = Vector2.new(count * (ring + 20) + (if e.killer then 34 + ring + 30 else 0) + 8, h)
	end
	local states = {}
	local members = {}
	for index, m in e.members do
		states[index] = scope:Value(m.state)
		table.insert(members, { Name = m.name, Icon = m.icon, State = states[index] })
	end
	local gui = if TeamStatus
		then Common.try(TeamStatus, scope, {
			Title = e.title,
			Members = members,
			Killer = if e.killer then { Name = e.killer.name, Icon = e.killer.icon } else nil,
			Style = style,
			Kit = kitTokens(ctx),
			Size = UDim2.fromOffset(size.X, size.Y),
		})
		else nil
	if gui == nil then
		gui = scope:Text { Text = e.title, TextSize = 24, Size = UDim2.fromOffset(size.X, size.Y) }
	end
	return {
		gui = gui,
		size = size,
		handle = {
			Gui = gui,
			States = states,
			-- setState("Mia", "Downed") or setState(2, "Escaped")
			setState = function(who: any, state: string)
				for index, m in e.members do
					if index == who or m.name == who then
						states[index]:set(state)
					end
				end
			end,
		},
	}
end

BUILD.CameraPanel = function(ctx, scope, e, place)
	local CameraPanel = Common.component("CameraPanel")
	local columns = math.min(e.columns, math.max(1, #e.cameras))
	local rows = math.ceil(#e.cameras / columns)
	local cellW, cellH, gap = 78, 46, 7
	local size = Vector2.new(columns * cellW + (columns - 1) * gap + 24, 58 + 24 + rows * cellH + (rows - 1) * gap)
	local selected = scope:Value(e.selected)
	local cameras = {}
	for _, cam in e.cameras do
		table.insert(cameras, { Id = cam.id, Label = cam.label, Name = cam.name })
	end
	local gui = if CameraPanel
		then Common.try(CameraPanel, scope, {
			Title = e.title,
			Cameras = cameras,
			Selected = selected,
			Columns = columns,
			OnSelect = function(id: string)
				ctx.ui:emit(e.id, { id = e.id, payload = id })
			end,
			Kit = kitTokens(ctx),
			Size = UDim2.fromOffset(size.X, size.Y),
		})
		else nil
	if gui == nil then
		gui = scope:Text { Text = e.title, TextSize = 24, Size = UDim2.fromOffset(size.X, size.Y) }
	end
	return {
		gui = gui,
		size = size,
		handle = {
			Gui = gui,
			Selected = selected,
			select = function(id: string)
				selected:set(id)
			end,
		},
	}
end

BUILD.Camcorder = function(ctx, scope, e, place)
	local Camcorder = Common.component("Camcorder")
	local size = Vector2.new(340, 80)
	local battery = scope:Value(e.battery)
	local gui = if Camcorder
		then Common.try(Camcorder, scope, {
			Mode = e.mode,
			Label = e.label,
			Date = e.date,
			Battery = battery,
			Size = UDim2.fromOffset(size.X, size.Y),
		})
		else nil
	if gui == nil then
		gui = scope:Text { Text = "● " .. e.label, TextSize = 34, Font = "Display", Size = UDim2.fromOffset(size.X, size.Y) }
	end
	-- the viewfinder itself: brackets, focus mark, scanlines and tint
	local overlay = nil
	local layer = ctx.hudLayer
	if Camcorder and layer and typeof(Camcorder.overlay) == "function" then
		overlay = Common.try(Camcorder.overlay, scope, {
			Mode = e.mode,
			Brackets = e.brackets,
			Scanlines = e.scanlines,
			Top = Kit.safeTop(scope, 60),
			Parent = layer.hud,
		})
	end
	return { gui = gui, size = size, handle = { Gui = gui, Overlay = overlay, Battery = battery } }
end

BUILD.Compass = function(ctx, scope, e, place)
	local Compass = Common.component("Compass")
	local size = Vector2.new(if ctx.compact then 440 else 480, 74)
	local heading = scope:Value(e.heading)
	local gui = if Compass
		then Common.try(Compass, scope, {
			Heading = heading,
			Marker = e.marker,
			Follow = e.follow and not ctx.preview,
			Preview = ctx.preview,
			Kit = kitTokens(ctx),
			Size = UDim2.fromOffset(size.X, size.Y),
		})
		else nil
	if gui == nil then
		gui = scope:Text { Text = "N", TextSize = 30, Size = UDim2.fromOffset(size.X, size.Y) }
	end
	return {
		gui = holder(scope, "Compass", size, place, gui),
		size = size,
		handle = {
			Gui = gui,
			Heading = heading,
			set = function(degrees: number)
				heading:set(math.floor(degrees) % 360)
			end,
		},
	}
end

-- One entry per HUD element type that takes no anchor space.
local OVERLAY: { [string]: (any, any, { [string]: any }, any) -> { [string]: any }? } = {}

OVERLAY.Crosshair = function(ctx, scope, e, layer)
	local hits = scope:Value(0)
	local kills = scope:Value(0)
	-- v1.8: a look, else the kit's crosshair (no kit: the element's style)
	local style = if kitted(ctx, e) then (e.look or HudKits.style(ctx.kit, "Crosshair")) else e.style
	local spread = scope:Value(if style == "Dot" then 0 else 6)
	local gui = scope:Crosshair {
		Spread = spread,
		Hits = hits,
		Kills = kills,
		Length = if style == "Dot" then 0 else 10,
		Dot = true,
		Style = if kitted(ctx, e) then style else nil,
		-- the "hitMarkers" setting (Blueprint.Settings)
		Markers = if ctx.ui.display then ctx.ui.display.hitMarkers else nil,
		Parent = layer.hud,
	}
	return {
		Gui = gui,
		Spread = spread,
		hit = function()
			hits:set(peek(hits) + 1)
		end,
		kill = function()
			kills:set(peek(kills) + 1)
		end,
	}
end

OVERLAY.Fear = function(ctx, scope, e, layer)
	local fear = scope:Value(e.intensity)
	scope:Vignette {
		Name = "Tunnel",
		Style = "Tunnel",
		Intensity = scope:Computed(function(use)
			return math.clamp(use(fear), 0, 1) * 0.95
		end),
		Spread = scope:Computed(function(use)
			return 2.2 - math.clamp(use(fear), 0, 1) * 0.9
		end),
		Parent = layer.overlay(),
	}
	scope:Heartbeat {
		BPM = scope:Computed(function(use)
			return 60 + math.clamp(use(fear), 0, 1) * 110
		end),
		Intensity = scope:Computed(function(use)
			local f = use(fear)
			return if f > 0.35 then (f - 0.35) / 0.65 * 0.8 else 0
		end),
		Parent = layer.overlay(),
	}
	return { Value = fear }
end

OVERLAY.Vignette = function(ctx, scope, e, layer)
	local intensity = scope:Value(e.intensity)
	local gui = scope:Vignette {
		Name = "Vignette",
		Style = if e.style == "Static" then "Soft" else e.style,
		Intensity = intensity,
		Parent = layer.overlay(),
	}
	-- v1.7 Static: TV snow crawling in from the edges on top of the soft vignette
	local static = nil
	if e.style == "Static" then
		local Static = Common.component("Static")
		if Static then
			static = Common.try(Static, scope, {
				Name = "Static",
				Intensity = scope:Computed(function(use)
					return math.clamp(use(intensity) * 0.9, 0, 1)
				end),
				Parent = layer.overlay(),
			})
		end
	end
	return { Gui = gui, Static = static, Value = intensity }
end

OVERLAY.MobileControls = function(ctx, scope, e, layer)
	local buttons = {}
	for _, b in e.buttons do
		table.insert(buttons, {
			id = b.id,
			label = b.label,
			icon = b.icon,
			OnPress = function()
				ctx.run(b.action, { id = b.id })
			end,
		})
	end
	local MobileControls = Common.component("MobileControls")
	local gui = if MobileControls then Common.try(MobileControls, scope, { Buttons = buttons, Parent = layer.hud }) else nil
	if gui == nil then
		-- placeholder: an arc of round buttons above the jump button, touch only
		local cells = {}
		for index, b in e.buttons do
			local angle = math.rad(180 + (index - 1) * (90 / math.max(1, #e.buttons - 1)))
			table.insert(cells, ctx.button(scope, {
				Name = b.id,
				Icon = b.icon,
				Tone = "Dark",
				Radius = "Full",
				Size = UDim2.fromOffset(104, 104),
				IconSize = 52,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(1, math.floor(-150 + math.cos(angle) * 190), 1, math.floor(-150 + math.sin(angle) * 190)),
			}, b.action, { id = b.id }))
		end
		gui = scope:New "Frame" {
			Name = "MobileControls",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			Visible = scope:Computed(function(use)
				return use(Input.device) == "Touch"
			end),
			Parent = layer.hud,
			[Children] = cells,
		}
	end
	return { Gui = gui }
end

OVERLAY.EmoteWheel = function(ctx, scope, e, layer)
	local open = scope:Value(false)
	local items = {}
	for _, emote in e.emotes do
		table.insert(items, { id = emote.id, label = emote.label, icon = emote.icon })
	end
	local function select(id: string)
		open:set(false)
		ctx.ui:emit("Emote", { id = id, payload = id })
		ctx.ui:emit(id, { id = id })
	end
	local RadialMenu = Common.component("RadialMenu")
	local gui = if RadialMenu then Common.try(RadialMenu, scope, { Items = items, Open = open, OnSelect = select, Parent = layer.hud }) else nil
	if gui == nil then
		local cells = {}
		for index, emote in e.emotes do
			local angle = math.rad(-90 + (index - 1) * 360 / #e.emotes)
			table.insert(cells, scope:New "Frame" {
				Name = emote.id,
				BackgroundTransparency = 1,
				Size = UDim2.fromOffset(120, 132),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, math.floor(math.cos(angle) * 190), 0.5, math.floor(math.sin(angle) * 190)),
				[Children] = {
					ctx.button(scope, {
						Name = "Button",
						Icon = emote.icon,
						Tone = "Dark",
						Size = UDim2.fromOffset(100, 96),
						IconSize = 56,
						AnchorPoint = Vector2.new(0.5, 0),
						Position = UDim2.fromScale(0.5, 0),
					}, { kind = "None" }, { id = emote.id }, function()
						select(emote.id)
					end),
					scope:Text {
						Text = emote.label,
						TextSize = 22,
						Font = "Display",
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.fromScale(0.5, 1),
					},
				},
			})
		end
		gui = scope:New "Frame" {
			Name = "EmoteWheel",
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(560, 560),
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Visible = open,
			Parent = layer.hud,
			[Children] = {
				scope:New "Frame" {
					Name = "Ring",
					Size = UDim2.fromOffset(500, 500),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Surface
					end),
					BackgroundTransparency = 0.45,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.stroke(scope, { Color = Color3.new(1, 1, 1), Thickness = 2, Transparency = 0.8 }),
					},
				},
				scope:Text {
					Text = "EMOTES",
					TextSize = 30,
					Font = "Title",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
				},
				cells,
			},
		}
	end
	local key = Common.inputEnum(e.key)
	if not ctx.preview and key then
		Input.bind(scope, key, function()
			open:set(not peek(open))
		end)
	end
	return {
		Gui = gui,
		Open = open,
		open = function()
			open:set(true)
		end,
		close = function()
			open:set(false)
		end,
	}
end

--------------------------------------------------------------------------
-- Layout engine
--------------------------------------------------------------------------

-- Which elements hug the screen edge first when they share an anchor.
local EDGE_WEIGHT = { KeyHints = 0, MenuButtons = 1, Hotbar = 2, HealthBar = 3, XPBar = 4 }

-- v2.3: the Simulator layout keeps the cash lowest ("cash at the bottom"),
-- under the rebirth bar and the clicker.
local function edgeWeight(e: { [string]: any }, layout: string?, place: Place): number
	if layout == "Simulator" and e.type == "CurrencyBar" and place.row == "Bottom" then
		return 1.5
	end
	return EDGE_WEIGHT[e.type] or 5
end

local function stackOrder(list: { any }, layout: string?, place: Place): { any }
	local sorted = table.clone(list)
	table.sort(sorted, function(a, b)
		local wa = edgeWeight(a.e, layout, place)
		local wb = edgeWeight(b.e, layout, place)
		if wa ~= wb then
			return wa < wb
		end
		return a.index < b.index
	end)
	return sorted
end

function Hud.build(ctx: any, scope: any, hud: { [string]: any }): { [string]: any }
	local gap = if ctx.compact then 12 else 16
	local handles = {}
	local stacks: { [string]: { any } } = {}
	local overlayGui: ScreenGui? = nil
	local topGui: ScreenGui? = nil

	local hudGui = scope:Screen {
		Name = "BloxUI_Hud",
		DisplayOrder = 5,
		Parent = ctx.parent,
	}
	local canvas = hudGui:FindFirstChild("Canvas")

	-- Big windows tuck the HUD away: side stacks slide off their edge and the
	-- overlay elements hide, while the currency bar stays (lifted above the
	-- window scrim when there is room beside the window) so the shop still
	-- shows what the player can spend. ctx.modal comes from Blueprint.build.
	local modal = ctx.modal
	local tuckGoal = scope:Computed(function(use)
		local state = if modal then use(modal) else nil
		return if state and state.tuck then 1 else 0
	end)
	local overlays = scope:New "Frame" {
		Name = "Overlays",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		Visible = scope:Computed(function(use)
			return use(tuckGoal) == 0
		end),
		Parent = canvas,
	}

	local layer = {
		hud = overlays,
		overlay = function()
			if overlayGui == nil then
				overlayGui = scope:Screen {
					Name = "BloxUI_HudOverlay",
					DisplayOrder = 4,
					SafeArea = "None",
					Parent = ctx.parent,
				}
			end
			return (overlayGui :: any):FindFirstChild("Canvas")
		end,
	}
	-- v1.7: anchored elements with a full-screen part (the Camcorder's
	-- viewfinder) draw it here
	ctx.hudLayer = layer
	-- v1.8: the HUD kit (nil: every element's original look)
	ctx.kit = HudKits.canonical(hud.kit)
	local entranceDelay = if ctx.kit then HudKits.tokens(ctx.kit).stagger else 0
	local entered = 0

	-- touch: the right edge belongs to the jump button and the MobileControls
	-- arc, so a right-middle stack can't fit on a phone. It moves to the left
	-- edge when that's free; otherwise its extras (quest tracker, boosts, kill
	-- feed) are left out and the rest joins the left-middle stack.
	local hasMobileControls = false
	local mobileCount = 0
	local leftUsed = false
	local topLeftUsed = false
	for _, e in hud.elements do
		if e.type == "MobileControls" and e.visible then
			hasMobileControls = true
			mobileCount = #(e.buttons or {})
		elseif e.anchor == "Left" and e.visible and not OVERLAY[e.type] then
			leftUsed = true
		end
		if e.anchor == "TopLeft" and e.visible and not OVERLAY[e.type] then
			topLeftUsed = true
		end
	end
	local OPTIONAL_ON_PHONE = { QuestTracker = true, Boosts = true, KillFeed = true }

	for index, e in hud.elements do
		local elementScope = scope:innerScope()
		if OVERLAY[e.type] then
			local ok, handle = pcall(OVERLAY[e.type], ctx, elementScope, e, layer)
			if ok then
				handles[e.id] = handle or {}
			else
				warn(`[BloxUI] Blueprint: HUD element "{e.id}" ({e.type}) failed: {handle}`)
				Fusion.doCleanup(elementScope)
			end
			continue
		end
		local builder = BUILD[e.type]
		if builder == nil then
			Fusion.doCleanup(elementScope)
			continue
		end
		-- touch screens: key hints are hidden (no space reserved) and the
		-- bottom-right corner belongs to the jump button and MobileControls
		local phoneExtra = ctx.touch and hasMobileControls and e.anchor == "Right" and leftUsed and OPTIONAL_ON_PHONE[e.type]
		if ctx.touch and (e.type == "KeyHints" or phoneExtra) then
			Fusion.doCleanup(elementScope)
			handles[e.id] = {}
			continue
		end
		local anchor = e.anchor
		if ctx.touch and anchor == "BottomRight" then
			anchor = "BottomCenter"
		elseif ctx.touch and hasMobileControls and anchor == "Right" then
			anchor = "Left"
		elseif ctx.touch and hasMobileControls and anchor == "TopRight" and e.type == "CurrencyBar" and not topLeftUsed then
			-- phones: the top-right stack shrinks to clear the MobileControls
			-- arc; the wallet (and its + buttons) keeps full size top-left
			anchor = "TopLeft"
			topLeftUsed = true
		end
		local place = placeOf(anchor)
		local ok, built = pcall(builder, ctx, elementScope, e, place)
		if not ok or built == nil then
			warn(`[BloxUI] Blueprint: HUD element "{e.id}" ({e.type}) failed: {built}`)
			Fusion.doCleanup(elementScope)
			continue
		end
		if ctx.kit and not ctx.preview then
			-- v1.8: kits bring the HUD in from the screen edges, one after another
			local from = if place.side == "Left" then "Left"
				elseif place.side == "Right" then "Right"
				elseif place.row == "Bottom" then "Bottom"
				else "Top"
			built.gui = HudKits.enter(elementScope, built.gui, {
				From = from,
				Delay = 0.1 + entered * entranceDelay,
				Kit = ctx.kit,
				Size = built.size,
			})
			entered += 1
		end
		built.gui.Name = e.id
		built.gui.Visible = e.visible
		local display = ctx.ui.display
		if e.type == "KillFeed" and e.visible and display then
			-- the "killFeed" setting hides it (the stack closes up)
			local gui = built.gui
			gui.Visible = peek(display.killFeed)
			Kit.watch(elementScope, display.killFeed, function(on)
				gui.Visible = on
			end)
		end
		handles[e.id] = built.handle or { Gui = built.gui }
		handles[e.id].Visible = e.visible
		stacks[anchor] = stacks[anchor] or {}
		table.insert(stacks[anchor], { e = e, index = index, built = built })
	end

	-- measure every stack
	local measured: { [string]: { size: Vector2, place: Place, entries: { any } } } = {}
	for anchor, list in stacks do
		local place = placeOf(anchor)
		local entries = stackOrder(list, ctx.layout, place)
		local w, h, count = 0, 0, 0
		for _, entry in entries do
			if entry.e.visible then
				w = math.max(w, entry.built.size.X)
				h += entry.built.size.Y
				count += 1
			end
		end
		h += math.max(0, count - 1) * gap
		measured[anchor] = { size = Vector2.new(w, h), place = place, entries = entries }
	end

	local function stackSize(anchor: string): Vector2
		local m = measured[anchor]
		return if m then m.size else Vector2.zero
	end

	local topBar = scope:Computed(function(use)
		return math.max(EDGE, math.ceil(Schema.SAFE.TopBar / math.max(0.1, use(Viewport.scale))) + 12)
	end)
	-- touch: keep the jump button clear, and the MobileControls arc above it.
	-- The arc's reach follows Components/MobileControls: a ring of buttons
	-- around Roblox's jump button (compact on phones), a second ring past 3.
	local controlsReach = scope:Computed(function(use)
		if not hasMobileControls then
			return Schema.SAFE.TouchJump
		end
		local screen = use(Viewport.size)
		local small = math.min(screen.X, screen.Y) <= 500
		local centreY = if small then 55 else 150
		local jump = if small then 70 else 120
		local button, spacing = jump * 0.84, jump * 0.16
		local ring = math.max(jump / 2 + spacing + button / 2, (button + spacing) / (2 * math.sin(math.rad(22.5))))
		local rings = if mobileCount <= 3 then 1 else 1 + math.ceil((mobileCount - 3) / 4)
		local reach = (centreY + ring + (rings - 1) * (button + spacing) + button / 2 + 12) / math.max(0.05, use(Viewport.scale))
		return math.max(Schema.SAFE.TouchControls, reach)
	end)

	local function topInset(side: string, use: any): number
		return if side == "Center" then EDGE else use(topBar)
	end
	-- Where Components/Window puts an open window (design px): centred a
	-- little low, clamped below the Roblox top bar. `state` is ctx.modal's.
	local function windowRect(screen: Vector2, viewScale: number, topbar: number, state: { [string]: any }): (number, number, number, number)
		local inset = topbar / math.max(0.05, viewScale) + 24
		local w = math.min(state.width or 0, screen.X - 48)
		-- v2.4 docked windows (Components/Window Dock): side panels on their
		-- edge, centred in the band below the top bar; a sheet on the bottom edge
		if state.dock == "Right" or state.dock == "Left" then
			local band = screen.Y - inset - 16
			local h = math.min(state.height or 0, band)
			return if state.dock == "Right" then screen.X - 16 - w else 16, inset + (band - h) / 2, w, h
		elseif state.dock == "Bottom" then
			local h = math.min(state.height or 0, screen.Y - inset)
			w = math.min(state.width or 0, screen.X - 32)
			return (screen.X - w) / 2, screen.Y - h, w, h
		end
		local h = math.min(state.height or 0, screen.Y - inset - 24)
		local free = screen.Y - h
		local y = if free < inset * 2 then inset - free / 2 else 16
		return (screen.X - w) / 2, screen.Y / 2 + y - h / 2, w, h
	end

	local function bottomInset(side: string, use: any): number
		if side ~= "Right" or not ctx.touch then
			return EDGE
		end
		return EDGE + use(controlsReach)
	end

	for anchor, m in measured do
		local place, size = m.place, m.size
		local x = sideScale(place.side)
		local xOffset = if place.side == "Left" then EDGE elseif place.side == "Right" then -EDGE else 0

		local children = {}
		for order, entry in m.entries do
			-- bottom stacks: the first entry sits lowest
			entry.built.gui.LayoutOrder = if place.row == "Bottom" then 1000 - order else order
			table.insert(children, entry.built.gui)
		end

		local position, scale
		if place.row == "Middle" then
			local column = place.side
			local topAnchor = if column == "Center" then "TopCenter" else "Top" .. column
			local bottomAnchor = if column == "Center" then "BottomCenter" else "Bottom" .. column
			local extra = if column == "Center" then 96 else 0 -- centre stacks sit under the crosshair
			-- fits between the corner stacks of its column; shrinks (to 55%) on short screens
			local fit = scope:Computed(function(use)
				local screen = use(Viewport.designSize)
				local top = topInset(column, use) + stackSize(topAnchor).Y + (if stackSize(topAnchor).Y > 0 then gap else 0)
				local bottom = screen.Y - bottomInset(column, use) - stackSize(bottomAnchor).Y - (if stackSize(bottomAnchor).Y > 0 then gap else 0)
				local s = math.clamp((bottom - top) / math.max(1, size.Y), 0.55, 1)
				local h = size.Y * s
				local y = math.floor(screen.Y / 2 - h / 2 + extra)
				y = math.min(y, bottom - h)
				y = math.max(y, top)
				return { y = y, scale = s }
			end)
			scale = scope:Computed(function(use)
				return use(fit).scale
			end)
			position = scope:Computed(function(use)
				return UDim2.new(x, xOffset, 0, use(fit).y)
			end)
		elseif place.side == "Center" then
			-- top/bottom centre: shrink (down to 70%) or lift clear of the corner stacks
			local leftAnchor = place.row .. "Left"
			local rightAnchor = place.row .. "Right"
			local fit = scope:Computed(function(use)
				local screen = use(Viewport.designSize)
				local sideWidth = math.max(stackSize(leftAnchor).X, stackSize(rightAnchor).X)
				if sideWidth <= 0 then
					return { scale = 1, lift = 0 }
				end
				local free = screen.X - 2 * (EDGE + sideWidth + gap)
				local s = math.clamp(free / math.max(1, size.X), 0.7, 1)
				local lift = 0
				if size.X * s > free then
					lift = math.max(stackSize(leftAnchor).Y, stackSize(rightAnchor).Y) + gap
				end
				return { scale = s, lift = lift }
			end)
			scale = scope:Computed(function(use)
				return use(fit).scale
			end)
			position = scope:Computed(function(use)
				local lift = use(fit).lift
				if place.row == "Top" then
					return UDim2.new(0.5, 0, 0, topInset("Center", use) + lift)
				end
				return UDim2.new(0.5, 0, 1, -(EDGE + lift))
			end)
		elseif place.row == "Top" then
			position = scope:Computed(function(use)
				return UDim2.new(x, xOffset, 0, use(topBar))
			end)
			if ctx.touch and hasMobileControls and place.side == "Right" then
				-- phones: the top-right stack shrinks (to 60%) to end above the
				-- MobileControls arc instead of running into it
				scale = scope:Computed(function(use)
					local screen = use(Viewport.designSize)
					local room = screen.Y - bottomInset("Right", use) - gap - use(topBar)
					return math.clamp(room / math.max(1, size.Y), 0.6, 1)
				end)
			end
		else
			position = scope:Computed(function(use)
				return UDim2.new(x, xOffset, 1, -bottomInset(place.side, use))
			end)
		end

		-- While a window is open, a stack it would cover (or show through a
		-- glass panel) slides off its nearest edge; big windows tuck the whole
		-- HUD. The currency bar is the exception: it stays wherever the window
		-- leaves it room, lifted above the scrim so the shop still shows what
		-- the player can spend.
		local keeps = false
		for _, entry in m.entries do
			keeps = keeps or (entry.e.type == "CurrencyBar" and entry.e.visible)
		end
		local out = if place.side == "Left"
			then Vector2.new(-(size.X + EDGE + 48), 0)
			elseif place.side == "Right" then Vector2.new(size.X + EDGE + 48, 0)
			elseif place.row == "Top" then Vector2.new(0, -(size.Y + 160))
			else Vector2.new(0, size.Y + EDGE + 48)
		local anchorPoint = Vector2.new(x, if place.row == "Bottom" then 1 else 0)
		local basePosition = position
		local baseScale = scale
		local covered = scope:Computed(function(use)
			local state = if modal then use(modal) else nil
			if state == nil or not state.open then
				return false
			end
			local screen = use(Viewport.designSize)
			local p = if typeof(basePosition) == "UDim2" then basePosition else use(basePosition)
			local k = if baseScale then use(baseScale) else 1
			local w, h = size.X * k, size.Y * k
			local x0 = p.X.Scale * screen.X + p.X.Offset - anchorPoint.X * w
			local y0 = p.Y.Scale * screen.Y + p.Y.Offset - anchorPoint.Y * h
			local wx, wy, ww, wh = windowRect(screen, use(Viewport.scale), use(Kit.topbarHeight), state)
			return x0 < wx + ww + 8 and wx < x0 + w + 8 and y0 < wy + wh + 8 and wy < y0 + h + 8
		end)
		local stackTuck = scope:Spring(scope:Computed(function(use)
			if use(covered) then
				return 1
			end
			return if keeps then 0 else use(tuckGoal)
		end), 26, 1)
		local finalPosition = scope:Computed(function(use)
			local base = if typeof(basePosition) == "UDim2" then basePosition else use(basePosition)
			local t = use(stackTuck)
			if t < 0.001 then
				return base
			end
			return base + UDim2.fromOffset(math.floor(out.X * t), math.floor(out.Y * t))
		end)
		local parentGui: any = canvas
		if keeps and modal then
			-- above the window and popup scrims (screens 10, popups 20)
			if topGui == nil then
				topGui = scope:Screen {
					Name = "BloxUI_HudTop",
					DisplayOrder = 21,
					Parent = ctx.parent,
				}
			end
			local topCanvas = (topGui :: any):FindFirstChild("Canvas")
			parentGui = scope:Computed(function(use)
				local state = use(modal)
				return if state.open and not use(covered) then topCanvas else canvas
			end)
		end

		scope:New "Frame" {
			Name = anchor,
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(x, if place.row == "Bottom" then 1 else 0),
			Position = finalPosition,
			Size = UDim2.fromOffset(size.X, size.Y),
			Visible = scope:Computed(function(use)
				return use(stackTuck) < 0.98
			end),
			Parent = parentGui,
			[Children] = {
				scope:New "UIListLayout" {
					FillDirection = Enum.FillDirection.Vertical,
					SortOrder = Enum.SortOrder.LayoutOrder,
					Padding = UDim.new(0, gap),
					HorizontalAlignment = Enum.HorizontalAlignment[place.side],
					VerticalAlignment = if place.row == "Bottom" then Enum.VerticalAlignment.Bottom else Enum.VerticalAlignment.Top,
				},
				if scale
					then scope:New "UIScale" { Scale = scale }
					else nil,
				children,
			},
		}
	end

	return {
		Gui = hudGui,
		Overlay = overlayGui,
		Top = topGui,
		handles = handles,
		stacks = measured,
	}
end

return Hud
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="44">
            <Properties>
              <string name="Name">Integration</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Blueprint.Integration: "Export as code". Turns a blueprint into the two
	scripts a game needs to use it, plus a checklist of what's left to do.

		local Integration = UI.Blueprint.Integration
		local out = Integration.generate(blueprint, { name = "PetPals" })
		out.client       -- LocalScript source: <Name>_UIHooks (StarterPlayerScripts)
		out.server       -- Script source: <Name>_ServerHooks (ServerScriptService)
		out.checklist    -- { groups = { { id, title, severity, hint, items = { { text } } } }, todo, notes }
		out.events       -- event names the client script stubs (ui:on)
		out.remotes      -- Remote action names the server script stubs (UI.Server.on)
		out.stats        -- { client = handlers, server = handlers }
		Integration.checklist(blueprint)          -- only the checklist
		Integration.checklistLines(checklist)     -- the checklist as plain text lines

	The input can be raw or normalized: it is normalized here, and whatever
	the validator repaired is listed in the checklist. Both scripts are plain
	Luau that parses and runs as generated: every handler is an empty stub
	(one that could mark something as paid returns false until your code
	gives it), the examples live in an Examples table nothing calls, and the
	server stubs check every value a client sends. Blueprint text only ever
	appears inside quoted strings and comments, and anything shaped like an
	API key is removed.

	Nothing here touches the place: the plugin inserts the scripts (Export
	tab), tagged with Integration.TAG so inserting again only ever replaces
	its own.
]]

local Normalize = require(script.Parent.Normalize)
local Schema = require(script.Parent.Schema)

local Integration = {}

Integration.CLIENT_SUFFIX = "_UIHooks"
Integration.SERVER_SUFFIX = "_ServerHooks"
-- Attributes on the scripts the plugin inserts: the blueprint name, the side
-- ("Client" / "Server") and a hash of the source as inserted (a different
-- hash means someone edited the script since).
Integration.TAG = "BloxUIHooks"
Integration.SIDE = "BloxUIHooksSide"
Integration.HASH = "BloxUIHooksHash"
Integration.FOLDER = "BloxUI_Blueprints"
-- Checklist items listed per group in the scripts' headers.
Integration.MAX_ITEMS = 30
Integration.CREATE_URL = "create.roblox.com/dashboard/creations"

local WIDTH = 90 -- comments wrap here
local RULE = string.rep("-", 74)

local BUILTIN_SETTINGS = Schema.set(Schema.BUILTIN_SETTINGS)
local KEYWORDS = Schema.set({
	"and", "break", "continue", "do", "else", "elseif", "end", "export", "false", "for", "function",
	"if", "in", "local", "nil", "not", "or", "repeat", "return", "then", "true", "type", "typeof",
	"until", "while",
})
-- Locals the scripts declare themselves (examples never shadow them).
local RESERVED_LOCALS = Schema.set({
	"UI", "ui", "Examples", "Players", "ReplicatedStorage", "MarketplaceService", "game", "script",
	"workspace", "player", "task", "string", "table", "math",
})

--------------------------------------------------------------------------
-- Text
--------------------------------------------------------------------------

local function trim(s: string): string
	s = string.gsub(s, "^%s+", "")
	s = string.gsub(s, "%s+$", "")
	return s
end

-- Anything shaped like an API key ("sk-ant-api03-...") never reaches the output.
local function redact(s: string): string
	return (string.gsub(s, "%f[%w]sk%-[%w_%-]+", function(token: string): string
		return if #token >= 16 then "[removed]" else token
	end))
end
Integration.redact = redact

local function text(value: any): string
	if typeof(value) == "number" then
		if value == value and value == math.floor(value) and math.abs(value) < 1e15 then
			return string.format("%d", value)
		end
		return tostring(value)
	end
	if typeof(value) == "string" then
		return value
	end
	return if value == nil then "" else tostring(value)
end

-- One line of plain text for a comment: no control characters, no "]]"
-- (that would close a --[[ ]] block) and no secrets.
local function clean(value: any, maxChars: number?): string
	local s = text(value)
	s = string.gsub(s, "%c", " ")
	s = string.gsub(s, "%s+", " ")
	s = redact(s)
	while string.find(s, "]]", 1, true) do
		s = string.gsub(s, "%]%]", "] ]")
	end
	s = trim(s)
	if maxChars then
		local length = utf8.len(s)
		if length and length > maxChars then
			local cut = utf8.offset(s, maxChars)
			if cut then
				s = trim(string.sub(s, 1, cut - 1)) .. "..."
			end
		elseif length == nil and #s > maxChars then
			s = string.sub(s, 1, maxChars) .. "..."
		end
	end
	return s
end
Integration.clean = clean

local ESCAPES = { ["\n"] = "\\n", ["\r"] = "\\r", ["\t"] = "\\t", ['"'] = '\\"', ["\\"] = "\\\\" }

-- A Luau string literal for any text.
local function quote(value: any): string
	local s = redact(text(value))
	s = string.gsub(s, '[%c"\\]', function(c: string): string
		return ESCAPES[c] or string.format("\\%03d", string.byte(c))
	end)
	return '"' .. s .. '"'
end
Integration.quote = quote

local function isName(s: string): boolean
	return string.match(s, "^[%a_][%w_]*$") ~= nil and not KEYWORDS[s]
end

-- base.key, or base["key"] when key isn't a plain name.
local function field(base: string, key: string): string
	if isName(key) then
		return base .. "." .. key
	end
	return base .. "[" .. quote(key) .. "]"
end

-- A table constructor key: key = or ["key"] =
local function tableKey(key: string): string
	if isName(key) then
		return key
	end
	return "[" .. quote(key) .. "]"
end

-- "a", "a and b", "a, b and c", "a, b, c and 4 more"
local function join(list: { string }, max: number?): string
	local limit = max or #list
	local shown = {}
	for i = 1, math.min(#list, limit) do
		table.insert(shown, list[i])
	end
	local more = #list - #shown
	if more > 0 then
		return table.concat(shown, ", ") .. ` and {more} more`
	end
	if #shown <= 1 then
		return shown[1] or ""
	end
	return table.concat(shown, ", ", 1, #shown - 1) .. " and " .. shown[#shown]
end

local function plural(n: number, word: string, many: string?): string
	return `{n} {if n == 1 then word else many or word .. "s"}`
end

local function wrap(s: string, width: number): { string }
	local lines = {}
	local line = ""
	for word in string.gmatch(s, "%S+") do
		if line == "" then
			line = word
		elseif #line + 1 + #word <= width then
			line ..= " " .. word
		else
			table.insert(lines, line)
			line = word
		end
	end
	if line ~= "" then
		table.insert(lines, line)
	end
	return lines
end

-- "main_menu" -> "MainMenu" (a name part: letters, digits and _ only)
local function pascal(id: string): string
	local out = {}
	for part in string.gmatch(id, "[%w]+") do
		table.insert(out, string.upper(string.sub(part, 1, 1)) .. string.sub(part, 2))
	end
	local joined = table.concat(out)
	return if joined == "" then "Screen" else joined
end

-- A local variable name for an id, unique within `used`.
local function localName(id: string, used: { [string]: boolean }, suffix: string?): string
	local base = string.gsub(id, "[^%w_]", "_")
	if string.match(base, "^%d") or base == "" then
		base = "v" .. base
	end
	if KEYWORDS[base] or RESERVED_LOCALS[base] then
		base ..= suffix or "Value"
	end
	local name = base
	local n = 2
	while used[name] do
		name = base .. n
		n += 1
	end
	used[name] = true
	return name
end

-- "Neon Strike!" -> "NeonStrike" (the plugin's export name rule)
function Integration.safeName(value: any): string
	local name = string.gsub(text(value), "[^%w]+", " ")
	local out = {}
	for word in string.gmatch(name, "%S+") do
		table.insert(out, string.upper(string.sub(word, 1, 1)) .. string.sub(word, 2))
	end
	local joined = table.concat(out)
	if joined == "" then
		joined = "MyGameUI"
	end
	if string.match(joined, "^%d") then
		joined = "UI" .. joined
	end
	return string.sub(joined, 1, 40)
end

-- Short stable hash of a string (djb2), used to spot edited scripts.
function Integration.hash(value: string): string
	local h = 5381
	for i = 1, #value do
		h = (h * 33 + string.byte(value, i)) % 4294967296
	end
	return string.format("%08x", h)
end

--------------------------------------------------------------------------
-- Code writer
--------------------------------------------------------------------------

local Writer = {}
Writer.__index = Writer

local function newWriter(): any
	return setmetatable({ lines = {}, depth = 0 }, Writer)
end

function Writer:line(s: string)
	if s == "" then
		table.insert(self.lines, "")
	else
		table.insert(self.lines, string.rep("\t", self.depth) .. s)
	end
end

function Writer:blank()
	local last = self.lines[#self.lines]
	if last ~= nil and last ~= "" then
		table.insert(self.lines, "")
	end
end

-- A wrapped "-- " comment.
function Writer:comment(s: string)
	for _, l in wrap(clean(s), WIDTH - 3 - self.depth * 4) do
		self:line("-- " .. l)
	end
end

-- One "-- " line however long (the line above a stub).
function Writer:note(s: string)
	self:line("-- " .. clean(s))
end

function Writer:rule(title: string, subtitle: string?)
	self:blank()
	self:line(RULE)
	self:comment(title)
	if subtitle then
		self:comment(subtitle)
	end
	self:line(RULE)
	self:blank()
end

function Writer:sub(title: string)
	self:blank()
	local head = "-- " .. clean(title, 60) .. " "
	self:line(head .. string.rep("-", math.max(4, 74 - #head)))
	self:blank()
end

function Writer:push()
	self.depth += 1
end

function Writer:pop()
	self.depth -= 1
end

-- A --[[ ]] block (every line cleaned, so nothing inside can close it;
-- leading spaces are kept for indentation).
function Writer:block(lines: { string })
	self:line("--[[")
	for _, l in lines do
		local lead, rest = string.match(l, "^( *)(.*)$")
		local body = clean(rest)
		if body == "" then
			self:line("")
		else
			self:line("\t" .. (lead or "") .. body)
		end
	end
	self:line("]]")
end

function Writer:text(): string
	while #self.lines > 0 and self.lines[#self.lines] == "" do
		table.remove(self.lines)
	end
	return table.concat(self.lines, "\n") .. "\n"
end

--------------------------------------------------------------------------
-- Reading the blueprint
--------------------------------------------------------------------------

local function eachSection(bp: any, fn: (any, any, any) -> ())
	for _, screen in bp.screens do
		for _, s in screen.sections or {} do
			fn(s, screen, nil)
		end
		for _, tab in screen.tabs or {} do
			for _, s in tab.sections or {} do
				fn(s, screen, tab)
			end
		end
	end
end

-- "Shop > Gems" / "Shop" / "HUD"
local function whereOf(screen: any, tab: any): string
	if screen == nil then
		return "HUD"
	end
	local title = clean(screen.title or screen.id, 40)
	if tab then
		local label = clean(tab.label or tab.id, 30)
		if label ~= "" and label ~= title then
			return title .. " > " .. label
		end
	end
	return title
end

local function number(n: any): string
	return text(if typeof(n) == "number" then n else 0)
end

local function samples(list: { any }, key: string, max: number?): string
	local out = {}
	for i, item in list do
		if i > (max or 3) then
			break
		end
		if typeof(item) == "table" and item[key] ~= nil then
			table.insert(out, quote(item[key]))
		end
	end
	if #list > (max or 3) then
		table.insert(out, "...")
	end
	return table.concat(out, ", ")
end

-- Known placeholder shapes for product / game pass / group ids. Returns why
-- the id looks made up, or nil when it looks real.
local function madeUp(id: number, neighbours: { number }?): string?
	if id < 1000 then
		return "far too small: 0 or a placeholder"
	end
	local digits = string.format("%d", id)
	if #digits >= 4 then
		if (string.gsub(digits, string.sub(digits, 1, 1), "")) == "" then
			return "one digit repeated"
		end
		if string.find("12345678901234567890", digits, 1, true) or string.find("98765432109876543210", digits, 1, true) then
			return "a counting sequence"
		end
		if #digits >= 5 and string.match(digits, "^[1-9]0+$") then
			return "a round number"
		end
	end
	local closest = nil
	for _, other in neighbours or {} do
		local gap = math.abs(other - id)
		if other ~= id and gap <= 9 and (closest == nil or gap < math.abs(closest - id)) then
			closest = other
		end
	end
	if closest then
		local gap = math.abs(closest - id)
		return if gap == 1 then `next to {string.format("%d", closest)}` else `only {gap} from {string.format("%d", closest)}`
	end
	return nil
end
Integration.madeUp = madeUp

local function currencyLabel(def: any): string
	local name = clean(def.name, 24)
	if def.source.kind == "leaderstat" then
		return `{name} (leaderstat "{clean(def.source.name, 32)}")`
	elseif def.source.kind == "attribute" then
		return `{name} (player attribute "{clean(def.source.name, 32)}")`
	end
	return `{name} (client-side)`
end

--[[
	Everything the scripts and the checklist need, read once.
]]
local function analyze(bp: any, name: string): { [string]: any }
	local info: { [string]: any } = {
		bp = bp,
		name = name,
		title = clean(bp.meta and bp.meta.title or name, 40),
		clientName = name .. Integration.CLIENT_SUFFIX,
		serverName = name .. Integration.SERVER_SUFFIX,
		currencyDefs = {},
		serverCurrencies = {},
		clientCurrencies = {},
		events = {}, -- client stubs, in order
		eventIndex = {},
		remotes = {}, -- server stubs, in order
		remoteIndex = {},
		reserved = {}, -- Remote names UI.Server keeps for itself
		ids = {}, -- every product / game pass id: { kind, id, label, where, item }
		missingIds = {}, -- checklist lines: priced in Robux, no id
		receipts = {}, -- Robux products without a grant
		passes = {}, -- game passes whose perk the game gives
		bought = {}, -- in-game items without a grant (server currencies)
		boughtIndex = {},
		clientBought = {}, -- items priced in a client-side currency
		upgrades = {},
		cases = {},
		spins = {},
		rewards = {}, -- onReward: { where, kind, missing = { labels } }
		index = {},
		achievements = {},
		passSections = {},
		offers = {},
		offline = {},
		social = {},
		groupIds = {}, -- checklist lines: Social group ids
		displayOnly = {}, -- Daily / Quests / Pass
		quests = {},
		daily = {},
		clickers = {}, -- checklist lines
		settings = {}, -- event-only settings
		settingIndex = {},
		builtinSettings = {},
		inGameSections = {}, -- { sectionId = { itemIds } } (canBuy)
		productIds = {}, -- distinct developer product ids
	}
	for _, def in bp.currencies do
		info.currencyDefs[def.id] = def
		table.insert(if def.source.kind == "none" then info.clientCurrencies else info.serverCurrencies, def)
	end

	local function currencyName(id: any): string
		if id == "robux" or id == nil then
			return "Robux"
		end
		local def = info.currencyDefs[id]
		return if def then clean(def.name, 24) else clean(id, 24)
	end
	local function price(amount: any, currency: any): string
		return `{number(amount)} {currencyName(currency)}`
	end

	-- client events --------------------------------------------------------
	-- `what` happened `where`; one stub per name (more places join its comment)
	local function addEvent(eventName: any, group: string, what: string, where: string, context: string?, hint: string?)
		if typeof(eventName) ~= "string" or eventName == "" then
			return
		end
		local existing = info.eventIndex[eventName]
		if existing then
			if existing.what == what then
				if not table.find(existing.wheres, where) then
					table.insert(existing.wheres, where)
				end
			else
				local label = what .. " in " .. where
				if not table.find(existing.also, label) then
					table.insert(existing.also, label)
				end
			end
			return
		end
		local event = { name = eventName, group = group, what = what, wheres = { where }, context = context, hint = hint, also = {} }
		info.eventIndex[eventName] = event
		table.insert(info.events, event)
	end

	local function context(id: any, screenId: any, payload: string?): string?
		local parts = {}
		if id ~= nil then
			table.insert(parts, "context.id = " .. quote(id))
		end
		if screenId ~= nil then
			table.insert(parts, "context.screen = " .. quote(screenId))
		end
		if payload then
			table.insert(parts, "context.payload = " .. payload)
		end
		return if #parts > 0 then table.concat(parts, ", ") else nil
	end

	local function addId(kind: string, id: any, label: string, where: string, item: boolean)
		if typeof(id) ~= "number" then
			return
		end
		table.insert(info.ids, { kind = kind, id = id, label = label, where = where, item = item })
		if kind == "product" and not table.find(info.productIds, id) then
			table.insert(info.productIds, id)
		end
	end

	-- server remotes -------------------------------------------------------
	local function addRemote(remoteName: string, source: { [string]: any })
		local reserved = remoteName == "Purchase"
			or remoteName == "CaseOpened"
			or string.sub(remoteName, 1, 7) == "BloxUI."
			or string.find(remoteName, ":", 1, true) ~= nil
		if reserved then
			table.insert(info.reserved, `{source.where}: {source.label} sends the Remote action "{clean(remoteName)}", a name UI.Server keeps for itself, so no handler ever hears it: rename it`)
			return
		end
		local remote = info.remoteIndex[remoteName]
		if remote == nil then
			remote = { name = remoteName, sources = {} }
			info.remoteIndex[remoteName] = remote
			table.insert(info.remotes, remote)
		end
		table.insert(remote.sources, source)
	end

	-- one action anywhere in the UI
	local function onAction(act: any, source: { [string]: any })
		if typeof(act) ~= "table" then
			return
		end
		local kind = act.kind
		if kind == "Event" then
			addEvent(act.name, source.group, source.label, source.where, context(source.id, source.screen, source.payloadText))
		elseif kind == "None" and source.id ~= nil then
			addEvent(source.id, source.group, source.label, source.where, context(nil, source.screen, source.payloadText), "It has no action: pressing it only runs this handler.")
		elseif kind == "Remote" then
			addRemote(act.name, source)
		elseif kind == "Purchase" then
			addId("product", act.productId, source.label, source.where, false)
		elseif kind == "GamePass" then
			addId("pass", act.gamePassId, source.label, source.where, false)
		elseif kind == "Notify" and act.text == "Not for sale yet!" then
			table.insert(info.missingIds, `{source.where}: {source.label}: its Purchase / GamePass action has no id`)
		end
	end

	-- HUD ------------------------------------------------------------------
	for _, e in bp.hud.elements do
		local where, group = "HUD", "hud"
		if e.type == "CurrencyBar" and e.showAdd then
			local names, ids = {}, {}
			for _, id in e.currencies do
				table.insert(names, currencyName(id))
				table.insert(ids, quote(id))
			end
			onAction(e.addAction, {
				label = `the "+" next to {join(names)}`,
				where = where,
				group = group,
				id = e.id,
				payload = "currency",
				payloadText = `the currency id ({table.concat(ids, ", ")})`,
			})
		elseif e.type == "MenuButtons" and e.role == "teleports" then
			-- v2.3: a teleport row: one handler per event (usually "Teleport"),
			-- the place each button goes to in context.payload
			local names, places, ids = {}, {}, {}
			for _, b in e.buttons do
				local act = b.action
				if typeof(act) == "table" and act.kind == "Event" and act.payload ~= nil then
					if places[act.name] == nil then
						table.insert(names, act.name)
						places[act.name], ids[act.name] = {}, {}
					end
					table.insert(places[act.name], quote(act.payload))
					table.insert(ids[act.name], quote(b.id))
				else
					onAction(act, { label = `"{clean(b.label, 24)}" teleport button`, where = where, group = group, id = b.id })
				end
			end
			for _, eventName in names do
				addEvent(eventName, group, "a teleport button pressed (the row along the top)", where,
					`context.payload = the place ({join(places[eventName], 6)}), context.id = the button ({join(ids[eventName], 3)})`,
					"Move the player on the server: send context.payload through a RemoteEvent and teleport there after checking it's a place they may go.")
			end
		elseif e.type == "MenuButtons" then
			for _, b in e.buttons do
				onAction(b.action, { label = `"{clean(b.label, 24)}" menu button`, where = where, group = group, id = b.id })
			end
		elseif e.type == "Button" then
			onAction(e.action, { label = `"{clean(e.label, 24)}" button`, where = where, group = group, id = e.id })
		elseif e.type == "MobileControls" then
			for _, b in e.buttons do
				local label = if b.label ~= "" then b.label else b.id
				onAction(b.action, { label = `"{clean(label, 24)}" touch button`, where = where, group = group, id = b.id })
			end
		elseif e.type == "Clicker" then
			local def = e.currency and info.currencyDefs[e.currency]
			local hint = nil
			if def and def.source.kind ~= "none" then
				hint = `{clean(def.name, 24)} is server-owned, so a tap only plays the effect: ask your server to add it (and rate-limit taps there).`
				table.insert(info.clickers, `HUD: the "{clean(e.text, 16)}" clicker pays {clean(def.name, 24)} (server-owned): taps only play the effect until your server grants it`)
			end
			addEvent(e.id, group, `the "{clean(e.text, 16)}" clicker tapped`, "HUD", context(e.id, nil, nil), hint)
		elseif e.type == "Hotbar" then
			local first = e.slots[1]
			addEvent(e.id, group, "a hotbar slot picked", "HUD", context(e.id, nil, `the slot number{if first then ` (1 = {quote(first.name)})` else ""}`))
		elseif e.type == "EmoteWheel" then
			addEvent("Emote", group, "an emote picked on the emote wheel", "HUD", context(nil, nil, `the emote id ({samples(e.emotes, "id")})`))
		end
	end

	-- sections -------------------------------------------------------------
	local inGame = {}
	local function markInGame(s: any, itemId: string, where: string)
		local list = info.inGameSections[s.id]
		if list == nil then
			list = { where = where, items = {} }
			info.inGameSections[s.id] = list
			table.insert(inGame, s.id)
		end
		table.insert(list.items, itemId)
	end
	info.inGameOrder = inGame

	local function ungranted(list: { any }, labelOf: (any) -> string): { string }
		local out = {}
		for _, prize in list do
			if typeof(prize) == "table" and prize.grant == nil then
				table.insert(out, labelOf(prize))
			end
		end
		return out
	end
	local function prizeLabel(prize: any): string
		local label = clean(prize.label or prize.amount or "prize", 20)
		return `"{label}"`
	end

	eachSection(bp, function(s, screen, tab)
		local where = whereOf(screen, tab)
		local group = screen.id
		local kind = s.kind
		local titled = if s.title then `"{clean(s.title, 30)}" ` else ""
		if kind == "Buttons" then
			for _, b in s.buttons do
				onAction(b.action, { label = `"{clean(b.label, 24)}" button`, where = where, group = group, id = b.id, screen = screen.id })
			end
		elseif kind == "Banner" then
			if s.button then
				onAction(s.button.action, { label = `"{clean(s.button.label, 24)}" on the {titled}banner`, where = where, group = group, id = s.id, screen = screen.id })
			end
		elseif kind == "Codes" then
			onAction(s.action, {
				label = `the {titled}code box`,
				where = where,
				group = group,
				id = s.id,
				screen = screen.id,
				payload = "code",
				payloadText = "the code the player typed (a string)",
				section = s,
			})
		elseif kind == "Products" then
			local anyInGame = false
			for _, item in s.items do
				local label = `"{clean(item.title, 32)}" ({price(item.price, item.currency)})`
				if item.currency == "robux" then
					if item.productId then
						addId("product", item.productId, label, where, true)
						if item.grant == nil then
							table.insert(info.receipts, { id = item.productId, label = label, where = where })
						end
					elseif item.gamePassId then
						addId("pass", item.gamePassId, label, where, true)
						table.insert(info.passes, { id = item.gamePassId, key = item.id, label = label, where = where })
					else
						table.insert(info.missingIds, `{where}: {label}: no product or game-pass id`)
					end
				else
					anyInGame = true
					local def = info.currencyDefs[item.currency]
					if def and def.source.kind ~= "none" then
						markInGame(s, item.id, where)
						if item.grant == nil then
							local key = "Bought:" .. item.id
							local existing = info.boughtIndex[key]
							if existing then
								table.insert(existing.also, `{label} in {where}`)
							else
								local entry = { event = key, id = item.id, label = label, where = where, also = {} }
								info.boughtIndex[key] = entry
								table.insert(info.bought, entry)
							end
						end
					else
						table.insert(info.clientBought, { label = label, where = where, currency = currencyName(item.currency) })
					end
				end
			end
			if anyInGame then
				addEvent("Purchased", group, `anything bought with an in-game currency`, where, "context.id = the item id, context.payload = { section, item }")
			end
		elseif kind == "Inventory" then
			local items = samples(s.items, "id")
			addEvent("Equip", group, `Equip pressed`, where, `context.payload = the item id ({items})`)
			if s.layout == "Bottom" then
				addEvent("Favorite", group, `Favorite pressed`, where, `context.payload = the item id ({items})`)
			elseif s.layout == "Showcase" then
				addEvent("Unequip", group, `a showcase slot emptied`, where, "context.payload = the item id")
			end
			addEvent(s.id, group, `an item selected`, where, context(s.id, screen.id, `its id ({items})`))
		elseif kind == "Upgrades" then
			local list = {}
			for _, u in s.upgrades do
				if u.currency == "robux" then
					table.insert(info.missingIds, `{where}: upgrade "{clean(u.title, 24)}" costs Robux, but upgrades only sell for in-game currency: sell it as a Products item with a product id`)
				else
					table.insert(list, u)
					table.insert(info.upgrades, { u = u, where = where })
					local def = info.currencyDefs[u.currency]
					if def and def.source.kind ~= "none" then
						markInGame(s, u.id, where)
					end
				end
			end
			if #list > 0 then
				addEvent("Upgrade", group, `an upgrade bought`, where, `context.id = the upgrade id ({samples(list, "id")}), context.payload = \{ id, level }`, "The server already took the price and saved the level: play effects here, apply the upgrade on the server (onUpgrade in the server hooks).")
				addEvent("Purchased", group, `anything bought with an in-game currency`, where, "context.id = the item id, context.payload = { section, item }")
			end
		elseif kind == "Daily" then
			table.insert(info.daily, { s = s, where = where })
			table.insert(info.displayOnly, { kind = "Daily", where = where })
			addEvent("DailyClaim", group, `a day claimed`, where, context(s.id, screen.id, `the day number (1-{#s.days})`), "Display only: the window marks the day, nothing is given. Give the reward on your server, which checks the streak itself.")
		elseif kind == "Quests" then
			table.insert(info.quests, { s = s, where = where })
			table.insert(info.displayOnly, { kind = "Quests", where = where })
			addEvent("QuestClaim", group, `a quest claimed`, where, `context.payload = the quest id ({samples(s.quests, "id")})`, "Display only: give the quest reward on your server, which checks the progress itself.")
		elseif kind == "Pass" then
			table.insert(info.passSections, { s = s, where = where })
			table.insert(info.displayOnly, { kind = "Pass", where = where })
			local premium = false
			for _, tier in s.tiers do
				if tier.premium ~= nil then
					premium = true
				end
			end
			if s.premiumProductId then
				addId("product", s.premiumProductId, "the premium pass", where, true)
			end
			if s.premiumGamePassId then
				addId("pass", s.premiumGamePassId, "the premium pass", where, true)
			end
			if premium and s.premiumProductId == nil and s.premiumGamePassId == nil then
				table.insert(info.missingIds, `{where}: the premium track: no premiumGamePassId or premiumProductId`)
			end
			addEvent("PassClaim", group, `a pass tier claimed`, where, context(s.id, screen.id, '{ tier, track = "free" | "premium" }'), "Display only: give the tier's reward on your server, from your own pass progress. Premium owners have the player attribute BloxUI_Premium_" .. clean(s.id) .. ".")
		elseif kind == "Loadout" then
			addEvent(s.id, group, `a loadout slot picked`, where, context(s.id, screen.id, `the slot name ({samples(s.slots, "name")})`))
		elseif kind == "Cases" then
			local titles = {}
			local inGameCase = false
			for _, c in s.cases do
				table.insert(titles, `"{clean(c.title, 24)}"`)
				local label = `case "{clean(c.title, 24)}" ({price(c.price, c.currency)})`
				if c.currency == "robux" then
					if c.productId then
						addId("product", c.productId, label, where, true)
					else
						table.insert(info.missingIds, `{where}: {label}: no product id`)
					end
				else
					inGameCase = true
					local def = info.currencyDefs[c.currency]
					if def and def.source.kind ~= "none" then
						markInGame(s, c.id, where)
					end
				end
			end
			table.insert(info.cases, { where = where, titles = titles })
			addEvent("CaseOpened", group, `a case opened`, where, `context.id = the case id ({samples(s.cases, "id")}), context.payload = the drop \{ name, icon, rarity }`, "The server rolled the drop and gave it (onCaseDrop in the server hooks): this is the reveal.")
			if inGameCase then
				addEvent("Purchased", group, `anything bought with an in-game currency`, where, "context.id = the item id, context.payload = { section, item }")
			end
		elseif kind == "Worlds" then
			addEvent("Teleport", group, `a world picked`, where, `context.payload = the world id ({samples(s.worlds, "id")})`, "Teleport on the server, after checking there that the world is unlocked (a client can pick a locked one).")
		elseif kind == "Party" then
			addEvent("PartyInvite", group, `Invite pressed`, where, context(s.id, screen.id, nil))
		elseif kind == "Keybinds" then
			addEvent("Rebind", group, `a key rebound`, where, context(s.id, screen.id, "{ action, key }"))
		elseif kind == "Spin" then
			local missing = ungranted(s.wedges, prizeLabel)
			table.insert(info.spins, { s = s, where = where, missing = missing })
			if s.price and s.currency == "robux" then
				if s.productId then
					addId("product", s.productId, `the Robux spin {titled}({number(s.price)} Robux)`, where, true)
				else
					table.insert(info.missingIds, `{where}: the Robux spin ({number(s.price)} Robux): no productId`)
				end
			elseif s.price then
				local def = info.currencyDefs[s.currency]
				if def and def.source.kind ~= "none" then
					markInGame(s, "spin", where)
				end
			end
			if s.luckGamePassId then
				addId("pass", s.luckGamePassId, "the x2 Luck pass", where, true)
			elseif s.luckPrice then
				table.insert(info.missingIds, `{where}: the x2 Luck button ({number(s.luckPrice)} Robux): no luckGamePassId`)
			end
			addEvent("SpinWon", group, `the wheel stopped`, where, context(s.id, screen.id, "{ index, wedge }"), "The server rolled it and already gave it: play effects here.")
		elseif kind == "Gifts" or kind == "Index" or kind == "Achievements" or kind == "Social" then
			local missing = {}
			if kind == "Gifts" then
				missing = ungranted(s.gifts, prizeLabel)
			elseif kind == "Index" then
				table.insert(info.index, { s = s, where = where })
				missing = ungranted(s.milestones, function(m)
					return `{number(m.percent)}% milestone`
				end)
			elseif kind == "Achievements" then
				for _, a in s.achievements do
					table.insert(info.achievements, { a = a, where = where })
					if a.reward and a.reward.grant == nil then
						table.insert(missing, `"{clean(a.title, 24)}"`)
					end
				end
			else
				table.insert(info.social, { s = s, where = where })
				local hasInvite = false
				for _, r in s.rewards do
					if r.reward and r.reward.grant == nil then
						table.insert(missing, `"{clean(r.label, 24)}"`)
					end
					if r.kind == "Invite" then
						hasInvite = true
					end
					if r.kind == "Group" then
						if s.groupId == nil then
							table.insert(info.groupIds, `{where}: "{clean(r.label, 24)}": no groupId, so nobody can claim it`)
						else
							local reason = madeUp(s.groupId)
							if reason then
								table.insert(info.groupIds, `{where}: groupId {string.format("%d", s.groupId)} looks made up ({reason})`)
							end
						end
					end
				end
				if hasInvite then
					addEvent("SocialInvite", group, `Invite a Friend pressed`, where, context(nil, screen.id, nil))
				end
			end
			table.insert(info.rewards, { kind = kind, where = where, missing = missing })
			addEvent("RewardClaimed", group, `a reward claimed`, where, "context.id = the section id, context.payload = { kind, item, data }", "The server checked the claim and already gave it: play effects here.")
		elseif kind == "Settings" then
			for _, def in s.settings do
				if BUILTIN_SETTINGS[def.id] then
					if not table.find(info.builtinSettings, def.id) then
						table.insert(info.builtinSettings, def.id)
					end
				elseif info.settingIndex[def.id] == nil then
					local entry = { id = def.id, def = def, where = where }
					info.settingIndex[def.id] = entry
					table.insert(info.settings, entry)
				end
			end
		end
	end)

	-- screens --------------------------------------------------------------
	for _, screen in bp.screens do
		local group = screen.id
		local where = whereOf(screen, nil)
		if screen.kind == "Results" then
			for _, b in screen.buttons or {} do
				onAction(b.action, { label = `"{clean(b.label, 24)}" button`, where = where, group = group, id = b.id, screen = screen.id })
			end
		elseif screen.kind == "Trade" then
			addEvent("TradeAccept", group, `Accept pressed`, where, context(screen.id, screen.id, nil), "Trading is yours to run: check both offers on the server, then swap the items there.")
			addEvent("TradeDecline", group, `Decline pressed`, where, context(screen.id, screen.id, nil))
		elseif screen.kind == "Loading" then
			addEvent("Loaded", group, `the loading screen finished`, where, context(screen.id, screen.id, nil))
		elseif screen.kind == "Offer" then
			local label = `offer "{clean(screen.title, 30)}" ({number(screen.price)} Robux)`
			table.insert(info.offers, { screen = screen, where = where })
			local missing = ungranted(screen.items, prizeLabel)
			if screen.productId then
				addId("product", screen.productId, label, where, true)
				table.insert(info.rewards, { kind = "Offer", where = where, missing = missing })
			elseif screen.gamePassId then
				addId("pass", screen.gamePassId, label, where, true)
				if #missing > 0 then
					table.insert(info.passes, { id = screen.gamePassId, key = screen.id, label = label, where = where, offer = true, missing = missing })
				end
			else
				table.insert(info.missingIds, `{where}: the {label}: no productId or gamePassId`)
			end
		elseif screen.kind == "OfflineEarnings" then
			table.insert(info.offline, { screen = screen, where = where })
			if screen.doubleProductId then
				addId("product", screen.doubleProductId, "the x2 offline earnings", where, true)
			elseif screen.doublePrice then
				table.insert(info.missingIds, `{where}: the x2 button ({number(screen.doublePrice)} Robux): no doubleProductId`)
			end
			addEvent("RewardClaimed", group, `offline earnings claimed`, where, "context.id = the section id, context.payload = { kind, item, data }", "The server checked the claim and already gave it: play effects here.")
		end
	end

	-- a Purchase / GamePass action for something no item sells: UI.Server
	-- doesn't know it, so the game's code gives it
	local sold = {}
	for _, entry in info.ids do
		if entry.item then
			sold[entry.kind .. ":" .. string.format("%d", entry.id)] = true
		end
	end
	for _, entry in info.ids do
		local key = entry.kind .. ":" .. string.format("%d", entry.id)
		if not entry.item and not sold[key] then
			sold[key] = true
			if entry.kind == "product" then
				table.insert(info.receipts, { id = entry.id, label = entry.label, where = entry.where })
			else
				table.insert(info.passes, { id = entry.id, key = "pass" .. string.format("%d", entry.id), label = entry.label, where = entry.where })
			end
		end
	end

	-- ids that look made up, and ids used twice -----------------------------
	local all = {}
	for _, entry in info.ids do
		table.insert(all, entry.id)
	end
	local seen = {}
	info.badIds = {}
	for _, entry in info.ids do
		local key = entry.kind .. ":" .. string.format("%d", entry.id)
		local what = if entry.kind == "pass" then "gamePassId" else "productId"
		local reason = madeUp(entry.id, all)
		if reason then
			table.insert(info.badIds, `{entry.where}: {entry.label}: {what} {string.format("%d", entry.id)} looks made up ({reason})`)
		elseif entry.item and seen[key] then
			table.insert(info.badIds, `{entry.where}: {entry.label}: {what} {string.format("%d", entry.id)} is also used by {seen[key]} (the server gives the first one)`)
		end
		if entry.item and seen[key] == nil then
			seen[key] = `{entry.label} in {entry.where}`
		end
	end
	return info
end

--------------------------------------------------------------------------
-- Checklist
--------------------------------------------------------------------------

local function buildChecklist(info: any, warnings: { string }): { [string]: any }
	local groups = {}
	local function group(id: string, title: string, severity: string, hint: string?, lines: { string }, compact: boolean?)
		if #lines == 0 then
			return
		end
		local items = {}
		for _, line in lines do
			table.insert(items, { text = clean(line) })
		end
		table.insert(groups, { id = id, title = title, severity = severity, hint = hint, items = items, compact = compact })
	end

	local ids = {}
	for _, line in info.missingIds do
		table.insert(ids, line)
	end
	for _, line in info.badIds do
		table.insert(ids, line)
	end
	group(
		"ids",
		"Product and game-pass ids",
		"error",
		`Until they have real ids these say "Not for sale yet!" (real ids are long and never follow each other). Create them at {Integration.CREATE_URL} > your experience > Monetization: Developer Products for things bought again and again, Passes for things bought once. Paste each id in the plugin (Structure tab), then Insert again.`,
		ids
	)

	group(
		"group",
		"Group id for Social rewards",
		"error",
		"Put your group's id in the Social section's groupId: it's the number in your group's address (roblox.com/communities/<id>/...).",
		info.groupIds
	)

	local grants = {}
	for _, r in info.receipts do
		table.insert(grants, `{r.where}: {r.label}: UI.Server.onReceipt({string.format("%d", r.id)})`)
	end
	for _, p in info.passes do
		if p.offer then
			table.insert(grants, `{p.where}: the {p.label}, a game pass: givePass gives its {join(p.missing, 4)}`)
		else
			table.insert(grants, `{p.where}: {p.label}, a game pass: givePass`)
		end
	end
	for _, b in info.bought do
		table.insert(grants, `{b.where}: {b.label}: UI.Server.on("Bought:{clean(b.id)}")`)
	end
	for _, c in info.cases do
		table.insert(grants, `{c.where}: the drops of {join(c.titles, 4)}: UI.Server.onCaseDrop`)
	end
	for _, sp in info.spins do
		if #sp.missing > 0 then
			table.insert(grants, `{sp.where}: {plural(#sp.missing, "wedge")} ({join(sp.missing, 4)}): UI.Server.onSpinPrize`)
		end
	end
	for _, rw in info.rewards do
		if #rw.missing > 0 then
			local what = if rw.kind == "Offer" then "offer item" elseif rw.kind == "Index" then "Index milestone" elseif rw.kind == "Achievements" then "achievement reward" elseif rw.kind == "Gifts" then "gift" else "social reward"
			table.insert(grants, `{rw.where}: {plural(#rw.missing, what)} ({join(rw.missing, 4)}): UI.Server.onReward`)
		end
	end
	if #info.upgrades > 0 then
		local titles = {}
		for _, entry in info.upgrades do
			table.insert(titles, `"{clean(entry.u.title, 20)}"`)
		end
		table.insert(grants, `{plural(#info.upgrades, "upgrade")} ({join(titles, 4)}): BloxUI sells the levels, onUpgrade applies them`)
	end
	for _, d in info.displayOnly do
		local what = if d.kind == "Daily" then "daily rewards" elseif d.kind == "Quests" then "quest rewards" else "pass tier rewards"
		table.insert(grants, `{d.where}: {what} are display only: give them from your server data`)
	end
	for _, line in info.clickers do
		table.insert(grants, line)
	end
	for _, cb in info.clientBought do
		table.insert(grants, `{cb.where}: {cb.label} is paid in {cb.currency}, a client-side currency, so it only happens on the player's screen: make {cb.currency} a leaderstat or attribute currency`)
	end
	group("grants", "Items without a grant", "warning", `BloxUI can't give these by itself. Each has a handler in {info.serverName} for your code:`, grants)

	local settings = {}
	for _, entry in info.settings do
		local def = entry.def
		table.insert(settings, `"{clean(def.label, 24)}" ({clean(def.id)}, {def.type}) in {entry.where}`)
	end
	group("settings", "Event-only settings", "warning", `BloxUI saves them, but only your code can apply them: fill in their ui:on handlers in {info.clientName}.`, settings)

	local sounds = {}
	local set = (info.bp.style and info.bp.style.sounds) or {}
	for _, slot in Schema.SOUND_SLOTS do
		if set[slot] == nil then
			table.insert(sounds, slot)
		end
	end
	if info.bp.music == nil then
		table.insert(sounds, "music (lobby / gameplay)")
	end
	group("sounds", "Empty sound slots", "info", "They play the theme's default sounds. Add your own in the plugin (Design > Sounds & music) with Creator Store audio ids.", sounds, true)

	local warned = {}
	for _, line in info.reserved do
		table.insert(warned, line)
	end
	for _, w in warnings do
		table.insert(warned, w)
	end
	group("warnings", "Validator warnings", "warning", "What UI.Blueprint.validate had to repair. Fix them in the plugin so the UI is exactly what you designed.", warned)

	local todo, notes = 0, 0
	for _, g in groups do
		if g.severity == "info" then
			notes += #g.items
		else
			todo += #g.items
		end
	end
	return { groups = groups, todo = todo, notes = notes }
end

-- The checklist as plain text lines (the scripts' headers).
function Integration.checklistLines(checklist: any, width: number?): { string }
	local out = {}
	local room = width or (WIDTH - 8)
	local function add(prefix: string, s: string, continuation: string)
		for i, line in wrap(clean(s), room - #prefix) do
			table.insert(out, (if i == 1 then prefix else continuation) .. line)
		end
	end
	if #checklist.groups == 0 then
		table.insert(out, "CHECKLIST: nothing left to do.")
		return out
	end
	add("", `CHECKLIST: {checklist.todo} to do, {plural(checklist.notes, "note")} (the plugin's Export tab shows it too)`, "  ")
	for _, g in checklist.groups do
		table.insert(out, "")
		add(if g.severity == "info" then "(i) " else "[ ] ", `{g.title} ({#g.items})`, "    ")
		if g.hint then
			add("    ", g.hint, "    ")
		end
		if g.compact then
			local list = {}
			for _, item in g.items do
				table.insert(list, item.text)
			end
			add("    ", table.concat(list, ", "), "    ")
		else
			for i, item in g.items do
				if i > Integration.MAX_ITEMS then
					add("    ", `... and {#g.items - Integration.MAX_ITEMS} more`, "    ")
					break
				end
				add("    - ", item.text, "      ")
			end
		end
	end
	return out
end

--------------------------------------------------------------------------
-- Client script (LocalScript, StarterPlayerScripts)
--------------------------------------------------------------------------

local function settingValue(def: any): string
	if def.type == "Toggle" then
		return "true / false"
	elseif def.type == "Slider" then
		return `a number from {number(def.min)} to {number(def.max)}`
	end
	local options = {}
	for _, o in def.options or {} do
		table.insert(options, quote(o))
	end
	return "one of " .. table.concat(options, ", ")
end

local HUD_EXAMPLES: { [string]: (any) -> ({ string }?, string?) } = {
	HealthBar = function(e)
		local note = if e.source and e.source.kind == "humanoid"
			then "follows the character's Humanoid by itself; set it for custom health"
			else "set it from your game"
		return { "if h.Health then", "\th.MaxHealth:set(" .. number(e.max) .. ")", "\th.Health:set(" .. number(math.floor(e.max * 0.75)) .. ")", "end" }, note
	end,
	XPBar = function(e)
		return { "if h.Level then", "\th.Level:set(" .. number(e.level + 1) .. ")", "\th.Progress:set(0.25) -- 0..1", "end" }, "level and 0..1 progress"
	end,
	QuestTracker = function(e)
		local first = e.tasks[1]
		if first == nil then
			return nil
		end
		return { "if h.Progress and h.Progress[1] then", "\th.Progress[1]:set(" .. number(first.goal) .. ") -- " .. clean(first.text, 40), "end" }, "task progress, by task number"
	end,
	Hotbar = function(e)
		local lines = { "if h.Equipped then", "\th.Equipped:set(1) -- slot 1: " .. clean(e.slots[1] and e.slots[1].name or "", 24) }
		for index, slot in e.slots do
			if slot.ammo ~= nil then
				table.insert(lines, `\tif h.Ammo[{index}] then`)
				table.insert(lines, `\t\th.Ammo[{index}]:set({number(math.max(0, slot.ammo - 1))}) -- {clean(slot.name, 24)} ammo`)
				table.insert(lines, "\tend")
				break
			end
		end
		table.insert(lines, "end")
		return lines, "the equipped slot (and ammo)"
	end,
	Objective = function(e)
		return { "if h.set then", "\th.set(" .. quote(e.text) .. ")", "end" }, "the objective text"
	end,
	Stamina = function()
		return { "if h.Value then", "\th.Value:set(0.5) -- 0..1", "end" }, "stamina"
	end,
	Battery = function()
		return { "if h.Value then", "\th.Value:set(0.5) -- 0..1", "end" }, "battery charge"
	end,
	Fear = function()
		return { "if h.Value then", "\th.Value:set(0.8) -- 0..1: vignette and heartbeat", "end" }, "fear"
	end,
	Vignette = function()
		return { "if h.Value then", "\th.Value:set(0.6) -- 0..1", "end" }, "vignette strength"
	end,
	Boosts = function(e)
		local first = e.boosts[1] or { icon = "boost", text = "2x Boost", seconds = 300 }
		return { "if h.add then", `\th.add(\{ icon = {quote(first.icon)}, text = {quote(first.text)}, seconds = {number(first.seconds)} \})`, "end" }, "add a boost with its timer"
	end,
	KillFeed = function()
		return { "if h.feed then", '\th.feed("Player1", "Player2") -- killer, victim (and an optional icon)', "end" }, "add a kill"
	end,
	Timer = function(e)
		return { "if h.set then", "\th.set(" .. number(e.seconds) .. ") -- seconds left", "end" }, "restart the countdown"
	end,
	TeamScore = function(e)
		return { "if h.Scores and h.Scores[1] then", "\th.Scores[1]:set(1) -- " .. clean(e.teams[1] and e.teams[1].name or "team 1", 20), "end" }, "team scores, by team number"
	end,
	BossBar = function(e)
		return { "if h.Health then", "\th.Max:set(" .. number(e.max) .. ")", "\th.Health:set(" .. number(math.floor(e.max * 0.6)) .. ")", "end" }, "boss health"
	end,
	Crosshair = function()
		return { "if h.hit then", "\th.hit() -- a hit marker", "\th.kill() -- a kill marker", "end" }, "hit and kill markers"
	end,
	EmoteWheel = function()
		return { "if h.open then", "\th.open() -- and h.close()", "end" }, "open the wheel from code"
	end,
}

local function clientScript(info: any, checklist: any): string
	local w = newWriter()
	local bp = info.bp
	local header = {
		`{info.title}: UI hooks`,
		"",
		`The client side of the UI "{info.name}" (ReplicatedStorage.{Integration.FOLDER}.{info.name}),`,
		"generated by the BloxUI plugin (Export > Export as code). BloxUI_BlueprintLoader",
		"builds the UI; this script listens to it: every event its buttons, sections and",
		"settings fire, as empty handlers, plus examples for its currencies, HUD and screens.",
		"",
		"HOW TO USE",
		"  Fill in the handlers your game needs and delete the rest. Anything that gives the",
		"  player something (currency, items, rewards) belongs on the server, in",
		`  {info.serverName}: a client can be tampered with, so it only asks.`,
		"  Inserting the scripts again replaces this one (the plugin asks first if you edited",
		"  it, and Ctrl+Z brings it back).",
		"",
	}
	for _, line in Integration.checklistLines(checklist) do
		table.insert(header, line)
	end
	w:block(header)
	w:blank()
	w:line('local ReplicatedStorage = game:GetService("ReplicatedStorage")')
	w:blank()
	w:line('local UI = require(ReplicatedStorage:WaitForChild("BloxUI"))')
	w:blank()
	w:comment("BloxUI_BlueprintLoader builds the UI and registers it by name.")
	w:line(`local ui = UI.Blueprint.waitFor({quote(info.name)}, 30)`)
	w:line("if ui == nil then")
	w:push()
	w:line(`warn({quote(`[{info.clientName}] the UI "{info.name}" wasn't built in 30 s: is BloxUI_BlueprintLoader in StarterPlayerScripts and {info.name} enabled in ReplicatedStorage.{Integration.FOLDER}?`)})`)
	w:line("return")
	w:pop()
	w:line("end")

	-- events, by screen
	w:rule("Events: what the UI's buttons and sections fire, by screen", "context.id = the element, context.screen = its screen, context.payload = what it sent")
	local order = { "hud" }
	local titles = { hud = "HUD" }
	for _, screen in bp.screens do
		table.insert(order, screen.id)
		local key = if screen.openKey then `, key {screen.openKey}` else ""
		titles[screen.id] = `{clean(screen.title, 40)} (screen "{clean(screen.id)}", {screen.kind}{key})`
	end
	local count = 0
	for _, groupId in order do
		local list = {}
		for _, event in info.events do
			if event.group == groupId then
				table.insert(list, event)
			end
		end
		if #list > 0 then
			w:sub(titles[groupId])
			for _, event in list do
				local label = event.what .. " in " .. join(event.wheres, 3)
				if #event.also > 0 then
					label ..= "; also " .. join(event.also, 2)
				end
				w:note(if event.context then label .. ": " .. event.context else label)
				w:line(`ui:on({quote(event.name)}, function(context)`)
				if event.hint then
					w:push()
					w:comment(event.hint)
					w:pop()
				end
				w:line("end)")
				w:blank()
				count += 1
			end
		end
	end
	if count == 0 then
		w:comment("This UI has no Event actions, action-less buttons or section events to listen to.")
	end
	if #info.remotes > 0 then
		local names = {}
		for _, remote in info.remotes do
			table.insert(names, quote(remote.name))
		end
		w:blank()
		w:comment(`Remote actions go straight to the server: {join(names)} (see {info.serverName}).`)
	end

	-- settings
	if #info.settings > 0 then
		w:rule("Settings that only fire an event", "BloxUI shows and saves them per player; your code applies them. context.payload = the value")
		local ids = {}
		for _, entry in info.settings do
			local def = entry.def
			w:note(`"{clean(def.label, 24)}" ({def.type}) in {entry.where}: context.payload = {settingValue(def)}`)
			w:line(`ui:on({quote(entry.id)}, function(context)`)
			w:line("end)")
			w:blank()
			table.insert(ids, quote(entry.id))
		end
		w:comment("Saved values load before this script starts: hand each handler the value it has now (context.source = \"start\").")
		w:line(`for _, id in \{ {table.concat(ids, ", ")} \} do`)
		w:line('\tui:emit(id, { id = id, payload = ui:getSetting(id), source = "start" })')
		w:line("end")
	end
	if #info.builtinSettings > 0 then
		w:blank()
		w:comment(`Built-in settings BloxUI applies by itself (ui:on(id) still hears them): {join(info.builtinSettings)}.`)
	end

	-- examples
	w:rule("Examples: nothing below runs by itself", "Call these from your own code, or copy the lines you need.")
	w:line("local Examples = {}")

	if #bp.currencies > 0 then
		w:blank()
		w:comment("Currencies: ui.currencies.<id> is a Fusion value (UI.peek reads it). ui:give plays the \"+N\" fly-in; for a server-owned currency the server changes the balance (UI.Server.grant), so call ui:give after it did.")
		w:line("function Examples.currencies()")
		w:push()
		local used = {}
		local names = {}
		for _, def in bp.currencies do
			local var = localName(def.id, used, "Amount")
			table.insert(names, var)
			w:line(`local {var} = UI.peek({field("ui.currencies", def.id)}) -- {currencyLabel(def)}`)
		end
		local first = bp.currencies[1]
		w:line(`ui:give({quote(first.id)}, 100) -- 100 {clean(first.name, 24)} fly into the counter`)
		w:line("return " .. table.concat(names, ", "))
		w:pop()
		w:line("end")
	end

	local hudLines = {}
	for _, e in bp.hud.elements do
		local make = HUD_EXAMPLES[e.type]
		if make then
			local lines, note = make(e)
			if lines then
				table.insert(hudLines, { e = e, lines = lines, note = note })
			end
		end
	end
	if #hudLines > 0 then
		w:blank()
		w:comment("HUD: ui.hud.<element id> holds each element's handle. Some are left out on phones (key hints, and side panels next to the touch controls), so check before using one.")
		w:line("function Examples.hud()")
		w:push()
		for index, entry in hudLines do
			if index > 1 then
				w:blank()
			end
			w:note(`{entry.e.type} "{clean(entry.e.id)}": {entry.note}`)
			w:line("do")
			w:push()
			w:line("local h = " .. field("ui.hud", entry.e.id) .. " or {}")
			for _, line in entry.lines do
				w:line(line)
			end
			w:pop()
			w:line("end")
		end
		w:pop()
		w:line("end")
	end

	if #bp.screens > 0 then
		w:blank()
		w:comment("Screens: ui:open / ui:close / ui:toggle / ui:isOpen. Open one from a ProximityPrompt, a part or a tutorial step, e.g. workspace.ShopStand.ProximityPrompt.Triggered:Connect(Examples.open" .. pascal(bp.screens[1].id) .. "). ui:on(\"ScreenOpened\", fn) hears every screen that opens (context.screen).")
		local used = {}
		for _, screen in bp.screens do
			local fnName = "open" .. pascal(screen.id)
			local n = 2
			local base = fnName
			while used[fnName] do
				fnName = base .. n
				n += 1
			end
			used[fnName] = true
			local key = if screen.openKey then `, key {screen.openKey}` else ""
			w:line(`function Examples.{fnName}()`)
			w:line(`\tui:open({quote(screen.id)}) -- {clean(screen.title, 40)} ({screen.kind}{key})`)
			w:line("end")
		end
	end

	if #info.index > 0 then
		local entry = info.index[1]
		local items = {}
		for _, g in entry.s.groups do
			for _, item in g.items do
				if #items < 3 then
					table.insert(items, item)
				end
			end
		end
		local ids, names = {}, {}
		for _, item in items do
			table.insert(ids, quote(item.id))
			table.insert(names, `{quote(item.id)} {clean(item.name, 20)}`)
		end
		w:blank()
		w:comment(`Index in {entry.where}: ui:setOwned shows what the player owns in colour (display only). With UI.Server, call UI.Server.setIndexOwned on the server instead: the client follows it and the server pays the milestones.`)
		w:line("function Examples.showOwned()")
		w:line(`\tui:setOwned(\{ {table.concat(ids, ", ")} \}) -- ids from the Index: {table.concat(names, ", ")}`)
		w:line("end")
	end

	if #info.quests > 0 then
		local entry = info.quests[1]
		local q = entry.s.quests[1]
		if q then
			w:blank()
			w:comment(`Quests in {entry.where}: a quest shows the progress in ui:state(<section id>, "<quest id>.progress"); set it from your game data and Claim lights up at the goal.`)
			w:line("function Examples.questProgress()")
			w:line(`\tui:state({quote(entry.s.id)}, {quote(q.id .. ".progress")}, 0):set({number(q.goal)}) -- "{clean(q.title, 30)}" (goal {number(q.goal)})`)
			w:line("end")
		end
	end
	if #info.daily > 0 then
		local entry = info.daily[1]
		w:blank()
		w:comment(`Daily rewards in {entry.where}: the days already claimed are ui:state(<section id>, "claimed"); set it from the streak your server saved.`)
		w:line("function Examples.dailyStreak()")
		w:line(`\tui:state({quote(entry.s.id)}, "claimed", 0):set(1) -- day 1 claimed, day 2 is next`)
		w:line("end")
	end
	if #info.passSections > 0 then
		local entry = info.passSections[1]
		w:blank()
		w:comment(`Pass in {entry.where}: the tier reached is ui:state(<section id>, "progress"); set it from your server's pass XP.`)
		w:line("function Examples.passProgress()")
		w:line(`\tui:state({quote(entry.s.id)}, "progress", 0):set({number(math.min(5, #entry.s.tiers))}) -- tier reached`)
		w:line("end")
	end
	return w:text()
end

--------------------------------------------------------------------------
-- Server script (Script, ServerScriptService)
--------------------------------------------------------------------------

-- The delivery pattern: the claim id first (a retry gives nothing twice),
-- then give, remember the id with it, and return true only once given.
local function writeDelivery(w: any, claimLine: string, todo: string, falseMeans: string)
	w:line(claimLine)
	w:line("if claimId and alreadyGiven(player, claimId) then")
	w:line("\treturn true -- given before (a retry): never give twice")
	w:line("end")
	w:line("local given = false")
	w:comment("TODO: " .. todo .. ", then set given = true")
	w:line("if given and claimId then")
	w:line("\tremember(player, claimId) -- keep it with what you gave")
	w:line("end")
	w:line(`return given -- false: {falseMeans}`)
end

local function serverScript(info: any, checklist: any): string
	local w = newWriter()
	local bp = info.bp
	local hasOffline = #info.offline > 0
	local rewardSections = 0
	for _ in info.rewards do
		rewardSections += 1
	end
	local needsGiven = #info.receipts > 0 or #info.cases > 0 or #info.spins > 0 or rewardSections > 0
	local needsPlayers = #info.passes > 0 or #info.upgrades > 0
	local needsMarket = #info.passes > 0

	local header = {
		`{info.title}: server hooks`,
		"",
		`The server side of the UI "{info.name}", generated by the BloxUI plugin`,
		"(Export > Export as code). It starts UI.Server with options picked for this UI and",
		"has a handler for everything the UI sends the server or can't pay by itself:",
		"Remote actions, products and rewards without a grant, and the hooks your game",
		"data needs.",
		"",
		"HOW TO USE",
		"  Fill in the TODOs. Every value a client sends is checked before it's used; keep",
		"  it that way and decide from the player's data here, never from what a client",
		"  says. A delivery hook returns true only once it gave something, so nothing is",
		"  marked as paid while it gives nothing.",
		"  UI.Server starts once per game: BloxUI_BlueprintServer (if it's here too) steps",
		"  aside when this script started it first, and this start replaces its defaults",
		"  when it ran first.",
		"  Inserting the scripts again replaces this one (the plugin asks first if you",
		"  edited it, and Ctrl+Z brings it back).",
		"",
	}
	for _, line in Integration.checklistLines(checklist) do
		table.insert(header, line)
	end
	w:block(header)
	w:blank()
	if needsMarket then
		w:line('local MarketplaceService = game:GetService("MarketplaceService")')
	end
	if needsPlayers then
		w:line('local Players = game:GetService("Players")')
	end
	w:line('local ReplicatedStorage = game:GetService("ReplicatedStorage")')
	w:blank()
	w:line('local UI = require(ReplicatedStorage:WaitForChild("BloxUI"))')

	if needsPlayers then
		w:blank()
		w:comment("Runs fn for every player, now and when they join.")
		w:line("local function onEveryPlayer(fn: (Player) -> ())")
		w:line("\tPlayers.PlayerAdded:Connect(fn)")
		w:line("\tfor _, player in Players:GetPlayers() do")
		w:line("\t\ttask.spawn(fn, player)")
		w:line("\tend")
		w:line("end")
	end

	if needsGiven then
		w:rule("What each player was given", "Every delivery hook below gets an id that stays the same each time the same thing is paid out (a retry after a crash or a lost save). Record it with what you give, and give nothing when you've seen it.")
		w:comment("TODO: keep these in YOUR saved player data, next to what they unlocked: this table only lives as long as this server.")
		w:line("local givenIds: { [number]: { [string]: boolean } } = {}")
		w:blank()
		w:line("local function alreadyGiven(player: Player, claimId: string): boolean")
		w:line("\tlocal ids = givenIds[player.UserId]")
		w:line("\treturn ids ~= nil and ids[claimId] == true")
		w:line("end")
		w:blank()
		w:line("local function remember(player: Player, claimId: string)")
		w:line("\tlocal ids = givenIds[player.UserId] or {}")
		w:line("\tids[claimId] = true")
		w:line("\tgivenIds[player.UserId] = ids")
		w:line("end")
	end

	-- Remote actions
	if #info.remotes > 0 then
		w:rule("Remote actions", "A client can send anything, so every handler checks what arrived. UI.Server already rate-limits the remote and checks the shapes (an element id of at most 100 characters, plain values). Decide everything else from the player's data here.")
		local grantCurrency = info.serverCurrencies[2] or info.serverCurrencies[1]
		for _, remote in info.remotes do
			local kinds = {}
			local labels = {}
			for _, source in remote.sources do
				kinds[source.payload or "none"] = true
				table.insert(labels, `{source.label} in {source.where}`)
			end
			local onlyCodes = kinds.code and not kinds.none and not kinds.currency
			local onlyButtons = kinds.none and not kinds.code and not kinds.currency
			local onlyCurrency = kinds.currency and not kinds.none and not kinds.code
			local sent = if onlyCodes then "payload = the code typed" elseif onlyButtons then "sends no payload" elseif onlyCurrency then "payload = a currency id" else "payload = a code, a currency id or nothing"
			w:note(`{join(labels, 3)}: {sent}`)
			if onlyCodes then
				w:line("do")
				w:push()
				w:comment("The codes and what each one gives, here on the server only (a client never sees them).")
				w:line("local CODES: { [string]: number } = {")
				w:line("\t-- RELEASE = 50,")
				w:line("}")
				w:comment("Codes each player used. TODO: save this with the player's data, or a code works once per server instead of once per player.")
				w:line("local used: { [number]: { [string]: boolean } } = {}")
				w:blank()
				w:line(`UI.Server.on({quote(remote.name)}, function(player: Player, elementId: any, payload: any)`)
				w:push()
				w:comment("Accept a short string only, then look it up in CODES.")
				w:line('if typeof(payload) ~= "string" or #payload > 40 then')
				w:line("\treturn")
				w:line("end")
				w:line('local code = string.upper((string.gsub(payload, "%s+", "")))')
				w:line("local amount = CODES[code]")
				w:line("local mine = used[player.UserId] or {}")
				w:line("if amount == nil or mine[code] then")
				w:line("\treturn -- unknown, or used already")
				w:line("end")
				w:line("mine[code] = true")
				w:line("used[player.UserId] = mine")
				if grantCurrency then
					w:line(`UI.Server.grant(player, {quote(grantCurrency.id)}, amount) -- {clean(grantCurrency.name, 24)} (saved)`)
				else
					w:line("print(player.Name, \"redeemed\", code, amount) -- TODO: give the reward (this UI has no server-owned currency)")
				end
				w:pop()
				w:line("end)")
				w:pop()
				w:line("end")
			else
				w:line(`UI.Server.on({quote(remote.name)}, function(player: Player, elementId: any, payload: any)`)
				w:push()
				if onlyButtons then
					w:comment("A button sends nothing: anything else didn't come from this UI.")
					w:line("if payload ~= nil then")
					w:line("\treturn")
					w:line("end")
				elseif onlyCurrency then
					local checks = {}
					for _, def in bp.currencies do
						table.insert(checks, `payload ~= {quote(def.id)}`)
					end
					w:comment("The \"+\" sends the currency id: accept only this UI's own.")
					w:line(`if typeof(payload) ~= "string" or ({if #checks > 0 then table.concat(checks, " and ") else "true"}) then`)
					w:line("\treturn")
					w:line("end")
				else
					w:comment("Buttons send nothing, a code box a string, the \"+\" a currency id: accept those shapes only.")
					w:line('if payload ~= nil and (typeof(payload) ~= "string" or #payload > 40) then')
					w:line("\treturn")
					w:line("end")
				end
				w:comment("TODO: do it, deciding from the player's data here (their balance, level, cooldown), e.g. local coins = UI.Server.getCurrency(player, \"" .. clean(info.serverCurrencies[1] and info.serverCurrencies[1].id or "coins") .. "\") or 0")
				w:pop()
				w:line("end)")
			end
			w:blank()
		end
	end

	-- developer products without a grant
	if #info.receipts > 0 then
		w:rule("Developer products without a grant", "BloxUI can't know what these give. A handler runs once per purchase in a server, but Roblox may retry a purchase in another server: the claim id stops a retry from giving twice.")
		local done = {}
		for _, r in info.receipts do
			if not done[r.id] then
				done[r.id] = true
				w:note(`{r.label} in {r.where}`)
				w:line(`UI.Server.onReceipt({string.format("%d", r.id)}, function(player: Player, receiptInfo: any): boolean`)
				w:push()
				writeDelivery(w, 'local claimId = "product:" .. tostring(receiptInfo.PurchaseId)', "give what it contains", "not granted yet, Roblox tries again later")
				w:pop()
				w:line("end)")
				w:blank()
			end
		end
	end

	-- game passes
	if #info.passes > 0 then
		w:rule("Game passes", "BloxUI shows the purchase prompt; owners get the player attribute GamePass_<id> here (on join and after buying), which your code reads.")
		w:line("local GAME_PASSES: { [number]: string } = {")
		local done = {}
		for _, p in info.passes do
			if not done[p.id] then
				done[p.id] = true
				w:line(`\t[{string.format("%d", p.id)}] = {quote(p.key)}, -- {clean(p.label)} in {clean(p.where)}`)
			end
		end
		w:line("}")
		w:blank()
		w:line("local function ownsPass(player: Player, gamePassId: number): boolean")
		w:line("\tlocal ok, owns = pcall(function()")
		w:line("\t\treturn MarketplaceService:UserOwnsGamePassAsync(player.UserId, gamePassId)")
		w:line("\tend)")
		w:line("\treturn ok and owns == true")
		w:line("end")
		w:blank()
		w:line("local function givePass(player: Player, gamePassId: number)")
		w:line('\tplayer:SetAttribute("GamePass_" .. GAME_PASSES[gamePassId], true)')
		w:push()
		w:comment("TODO: turn the perk on here (or read the GamePass_<id> attribute where it applies)")
		w:pop()
		w:line("end")
		w:blank()
		w:line("onEveryPlayer(function(player: Player)")
		w:line("\tfor gamePassId in GAME_PASSES do")
		w:line("\t\tif ownsPass(player, gamePassId) then")
		w:line("\t\t\tgivePass(player, gamePassId)")
		w:line("\t\tend")
		w:line("\tend")
		w:line("end)")
		w:blank()
		w:comment("The prompt's \"purchased\" flag can be faked by a client: ask Roblox again.")
		w:line("MarketplaceService.PromptGamePassPurchaseFinished:Connect(function(player: Player, gamePassId: number, purchased: boolean)")
		w:line("\tif purchased and GAME_PASSES[gamePassId] and ownsPass(player, gamePassId) then")
		w:line("\t\tgivePass(player, gamePassId)")
		w:line("\tend")
		w:line("end)")
	end

	-- in-game items without a grant
	if #info.bought > 0 then
		w:rule("Items bought with in-game currency", "BloxUI already took the price (its own price, never the client's). These have no grant, so give them here. Only the server raises Bought:<id>: a client can't fire it.")
		for _, b in info.bought do
			local label = `{b.label} in {b.where}`
			if #b.also > 0 then
				label ..= "; also " .. join(b.also, 2)
			end
			w:note(label)
			w:line(`UI.Server.on({quote(b.event)}, function(player: Player, itemId: string, event: any)`)
			w:push()
			w:comment("TODO: give it (event = { section, item, price })")
			w:pop()
			w:line("end)")
			w:blank()
		end
	end

	-- upgrades
	if #info.upgrades > 0 then
		w:rule("Upgrades", "BloxUI takes the price on the server and raises the player attribute BloxUI_Upgrade_<id> (saved). Apply the levels here.")
		w:comment("Each upgrade's id and the level a player starts at.")
		w:line("local UPGRADES: { [string]: number } = {")
		local done = {}
		for _, entry in info.upgrades do
			local u = entry.u
			if not done[u.id] then
				done[u.id] = true
				local effect = if u.effect then ": " .. clean(u.effect, 40) else ""
				w:line(`\t{tableKey(u.id)} = {number(u.level)}, -- {clean(u.title, 24)}{effect} (max {number(u.maxLevel)}, {clean(entry.where)})`)
			end
		end
		w:line("}")
		w:blank()
		w:comment("Called on join and after every level bought.")
		w:line("local function onUpgrade(player: Player, id: string, level: number)")
		w:push()
		w:comment(`TODO: apply it, e.g. if id == {quote(info.upgrades[1].u.id)} then (use level) end`)
		w:pop()
		w:line("end")
		w:blank()
		w:line("onEveryPlayer(function(player: Player)")
		w:line("\tfor id, start in UPGRADES do")
		w:line('\t\tlocal attribute = "BloxUI_Upgrade_" .. id')
		w:line("\t\tlocal function apply()")
		w:line("\t\t\tlocal level = player:GetAttribute(attribute)")
		w:line('\t\t\tonUpgrade(player, id, if typeof(level) == "number" then level else start)')
		w:line("\t\tend")
		w:line("\t\tplayer:GetAttributeChangedSignal(attribute):Connect(apply)")
		w:line("\t\tapply()")
		w:line("\tend")
		w:line("end)")
	end

	-- cases
	if #info.cases > 0 then
		local where = {}
		for _, c in info.cases do
			table.insert(where, `{join(c.titles, 3)} in {c.where}`)
		end
		w:rule("Case drops", `Cases: {join(where, 3)}. The server rolls the drop with its own Random and hands it here.`)
		w:comment("drop = { name, icon, rarity, chance }; info = { case, section, purchaseId (Robux cases) }")
		w:line("UI.Server.onCaseDrop(function(player: Player, drop: any, info: any): boolean")
		w:push()
		writeDelivery(w, 'local claimId = if info.purchaseId then "case:" .. tostring(info.purchaseId) else nil', "give drop.name to the player (your inventory)", "an in-game buy is refunded, a Robux one retried later")
		w:pop()
		w:line("end)")
	end

	-- spin wheels
	if #info.spins > 0 then
		local lines = {}
		for _, sp in info.spins do
			if #sp.missing > 0 then
				table.insert(lines, `{sp.where}: {join(sp.missing, 4)}`)
			else
				table.insert(lines, `{sp.where}: every wedge has a grant (BloxUI pays them)`)
			end
		end
		w:rule("Spin wheel prizes", "The server rolls the wheel and pays wedges with a grant itself; the others come here. Wedges without a grant: " .. table.concat(lines, "; "))
		w:comment("wedge = { id, label, icon, amount, rarity }; info = { section, index, spinId, mode, purchaseId? }")
		w:line("UI.Server.onSpinPrize(function(player: Player, wedge: any, info: any): boolean")
		w:push()
		writeDelivery(w, 'local claimId = "spin:" .. tostring(info.spinId)', "give the wedge's prize", "the spin is undone (cooldown or price given back)")
		w:pop()
		w:line("end)")
	end

	-- other rewards
	if rewardSections > 0 then
		local lines = {}
		for _, rw in info.rewards do
			local what = if rw.kind == "Offer" then "offer" else rw.kind
			if #rw.missing > 0 then
				table.insert(lines, `{rw.where} ({what}): {join(rw.missing, 4)}`)
			end
		end
		w:rule("Rewards", "Gifts, Index milestones, achievements, social rewards and offers: the server checks every claim, pays anything with a grant itself and hands the rest here. " .. (if #lines > 0 then "Without a grant: " .. table.concat(lines, "; ") .. "." else "Every one has a grant right now, so this only runs if you add one without."))
		w:comment("info = { kind = \"Gift\" | \"Index\" | \"Achievement\" | \"Social\" | \"Offer\", section, id, claimId, purchaseId? }")
		w:line("UI.Server.onReward(function(player: Player, reward: any, info: any): boolean")
		w:push()
		writeDelivery(w, "local claimId = info.claimId", "give the reward (reward.label, reward.icon, reward.amount)", "the claim fails and can be tried again (an offer purchase waits)")
		w:pop()
		w:line("end)")
	end

	-- offline earnings
	if hasOffline then
		local entry = info.offline[1]
		local screen = entry.screen
		local def = screen.currency and info.currencyDefs[screen.currency]
		w:rule("Offline earnings", `{clean(screen.title, 30)}: {number(screen.rate)} {if def then clean(def.name, 24) else "currency"} a minute away, up to {number(screen.capHours)} h. BloxUI works it out from when the player was last saved.`)
		w:line("UI.Server.offlineRate = function(player: Player, screen: any): number")
		w:push()
		w:comment("Per minute away, from the player's own data here (never a value a client sent).")
		w:line("return screen.rate -- TODO: e.g. the player's income per minute")
		w:pop()
		w:line("end")
	end

	-- canBuy
	w:rule("canBuy: the last word on in-game-currency buys", "Runs after BloxUI matched the item to its own price and checked the balance on the server, so sectionId and itemId already name a real item. Return false to refuse, e.g. something the player hasn't unlocked.")
	if #info.inGameOrder > 0 then
		for _, sectionId in info.inGameOrder do
			local entry = info.inGameSections[sectionId]
			local items = {}
			for _, id in entry.items do
				table.insert(items, quote(id))
			end
			w:note(`{quote(sectionId)} ({entry.where}): {join(items, 8)}`)
		end
	else
		w:comment("Nothing in this UI is bought with a server-owned currency yet, so this never runs.")
	end
	w:line("UI.Server.canBuy = function(player: Player, sectionId: string, itemId: string): boolean")
	w:line("\treturn true")
	w:line("end")

	-- display-only rewards
	if #info.displayOnly > 0 then
		local list, events, checks = {}, {}, {}
		local EVENT = { Daily = "DailyClaim", Quests = "QuestClaim", Pass = "PassClaim" }
		local CHECK = { Daily = "the streak", Quests = "the quest progress", Pass = "the pass XP" }
		for _, d in info.displayOnly do
			table.insert(list, `{d.kind} in {d.where}`)
			local handler = `ui:on("{EVENT[d.kind]}")`
			if not table.find(events, handler) then
				table.insert(events, handler)
				table.insert(checks, CHECK[d.kind])
			end
		end
		w:rule("Rewards the UI only shows", `{join(list, 4)}: claiming only changes the window ({join(events)} in {info.clientName}). Give these rewards from your own server data, e.g. through a Remote action whose handler here checks {join(checks)} itself.`)
	end

	-- examples
	w:rule("Examples: nothing below runs by itself", "Call these from your own server code, or copy the lines you need.")
	w:line("local Examples = {}")
	if #info.serverCurrencies > 0 then
		local first = info.serverCurrencies[1]
		local last = info.serverCurrencies[#info.serverCurrencies]
		w:blank()
		w:comment("Currencies the server owns: " .. (function()
			local list = {}
			for _, def in info.serverCurrencies do
				table.insert(list, currencyLabel(def))
			end
			return join(list)
		end)() .. ".")
		w:line("function Examples.currencies(player: Player): number?")
		w:line(`\tUI.Server.grant(player, {quote(first.id)}, 100) -- +100 {clean(first.name, 24)} (saved; waits while the player's data loads)`)
		w:line(`\treturn UI.Server.getCurrency(player, {quote(last.id)}) -- {clean(last.name, 24)} right now`)
		w:line("end")
	end
	if #info.index > 0 then
		local entry = info.index[1]
		local ids = {}
		for _, g in entry.s.groups do
			for _, item in g.items do
				if #ids < 3 then
					table.insert(ids, quote(item.id))
				end
			end
		end
		w:blank()
		w:comment(`Index in {entry.where}: what the player has found. Call it on join and whenever they find something: the Index shows it and the milestones pay from it.`)
		w:line("function Examples.indexFound(player: Player)")
		w:line(`\tUI.Server.setIndexOwned(player, \{ {table.concat(ids, ", ")} \}) -- ids from the Index (TODO: from YOUR saved data)`)
		w:line("end")
	end
	if #info.achievements > 0 then
		local list = {}
		for _, entry in info.achievements do
			table.insert(list, `{quote(entry.a.id)} {clean(entry.a.title, 24)} (goal {number(entry.a.goal)})`)
		end
		local first = info.achievements[1].a
		w:blank()
		w:comment("Achievements: progress is the player attribute BloxUI_Ach_<id>, set by your server code. The player can claim the reward once it reaches the goal. " .. join(list, 12) .. ".")
		w:line("function Examples.achievementProgress(player: Player)")
		w:line(`\tlocal attribute = {quote("BloxUI_Ach_" .. first.id)}`)
		w:line("\tlocal now = player:GetAttribute(attribute)")
		w:line('\tplayer:SetAttribute(attribute, (if typeof(now) == "number" then now else 0) + 1)')
		w:line("end")
	end
	if #info.passSections > 0 then
		local entry = info.passSections[1]
		w:blank()
		w:comment(`Premium pass in {entry.where}: owners have the player attribute BloxUI_Premium_<section id> (BloxUI sets it from the game pass or product).`)
		w:line("function Examples.hasPremium(player: Player): boolean")
		w:line(`\treturn player:GetAttribute({quote("BloxUI_Premium_" .. entry.s.id)}) == true`)
		w:line("end")
	end
	if #info.offers > 0 then
		local entry = info.offers[1]
		w:blank()
		w:comment(`Offers: BloxUI marks buyers with the player attribute BloxUI_OfferOwned_<id> ("{clean(entry.screen.title, 30)}" here).`)
		w:line("function Examples.ownsOffer(player: Player): boolean")
		w:line(`\treturn player:GetAttribute({quote("BloxUI_OfferOwned_" .. entry.screen.id)}) == true`)
		w:line("end")
	end
	if #info.settings + #info.builtinSettings > 0 then
		w:blank()
		w:comment("Settings: each player's choices, defaults filled in (read-only here: players change them).")
		w:line("function Examples.settings(player: Player): { [string]: any }")
		w:line("\treturn UI.Server.getSettings(player)")
		w:line("end")
	end

	-- start
	w:rule("Start", "Last, so every handler above is in place before the first player arrives.")
	local data = {}
	for _, def in info.serverCurrencies do
		table.insert(data, currencyLabel(def))
	end
	if #info.upgrades > 0 then
		table.insert(data, "upgrade levels")
	end
	if #info.passSections > 0 then
		table.insert(data, "premium passes")
	end
	if #info.spins + rewardSections > 0 or hasOffline then
		table.insert(data, "reward claims and cooldowns")
	end
	if #info.productIds > 0 then
		table.insert(data, "purchase receipts")
	end
	local numberValues = false
	for _, def in info.serverCurrencies do
		if def.source.kind == "leaderstat" and (def.start ~= math.floor(def.start) or def.start >= 2 ^ 53) then
			numberValues = true
		end
	end
	w:line("UI.Server.start({")
	w:push()
	if #data > 0 then
		w:comment(`SaveData = true: this UI keeps {join(data)} per player, so BloxUI saves them (DataStore "BloxUI_Data", one server at a time), even a leaderstat another script made first. Your own data code saves these already? Use SaveData = false with saveHook = function(player) return savedOk end, so purchases wait for your save.`)
		w:line("SaveData = true,")
	else
		w:comment("SaveData = false: nothing in this UI is kept per player (no leaderstat or attribute currency, upgrades, products or reward claims), so BloxUI leaves player data alone. Settings are still saved.")
		w:line("SaveData = false,")
	end
	if numberValues then
		w:comment("NumberValues = true: a leaderstat starts at a fraction or a huge number, which IntValues can't hold.")
		w:line("NumberValues = true,")
	end
	if #info.productIds > 0 then
		w:comment(`{plural(#info.productIds, "developer product")}: BloxUI answers MarketplaceService.ProcessReceipt for them. Your game already has a ProcessReceipt? Use ProcessReceipt = false and call UI.Server.handleReceipt(receiptInfo) from yours.`)
		w:line("-- ProcessReceipt = false,")
	end
	if #info.settings + #info.builtinSettings > 0 then
		w:comment("Settings are saved per player (DataStore \"BloxUI_Settings\"); SaveSettings = false keeps them for the session only.")
	end
	w:pop()
	w:line("})")
	return w:text()
end

--------------------------------------------------------------------------
-- Public
--------------------------------------------------------------------------

export type Options = {
	name: string?, -- the StringValue name the loader registers (default: from the title)
	warnings: { string }?, -- more validator warnings to list (the plugin's)
}

local function prepare(blueprint: any, opts: Options?): (any, { string }, string)
	local options = opts or {}
	local bp, warnings = Normalize.run(blueprint)
	local all = table.clone(warnings)
	for _, w in options.warnings or {} do
		if typeof(w) == "string" and not table.find(all, w) then
			table.insert(all, w)
		end
	end
	local name = Integration.safeName(options.name or (bp.meta and bp.meta.title))
	return bp, all, name
end

-- The checklist alone: { groups = { { id, title, severity, hint, items, compact } }, todo, notes }.
function Integration.checklist(blueprint: any, opts: Options?): { [string]: any }
	local bp, warnings, name = prepare(blueprint, opts)
	return buildChecklist(analyze(bp, name), warnings)
end

function Integration.generate(blueprint: any, opts: Options?): { [string]: any }
	local bp, warnings, name = prepare(blueprint, opts)
	local info = analyze(bp, name)
	local checklist = buildChecklist(info, warnings)
	local events, remotes = {}, {}
	for _, event in info.events do
		table.insert(events, event.name)
	end
	for _, entry in info.settings do
		table.insert(events, entry.id)
	end
	for _, remote in info.remotes do
		table.insert(remotes, remote.name)
	end
	local serverHandlers = #info.remotes + #info.bought
	local receiptIds = {}
	for _, r in info.receipts do
		receiptIds[r.id] = true
	end
	for _ in receiptIds do
		serverHandlers += 1
	end
	for _, flag in { #info.passes > 0, #info.upgrades > 0, #info.cases > 0, #info.spins > 0, #info.rewards > 0, #info.offline > 0 } do
		if flag then
			serverHandlers += 1
		end
	end
	serverHandlers += 1 -- canBuy
	return {
		name = name,
		title = info.title,
		clientName = info.clientName,
		serverName = info.serverName,
		client = clientScript(info, checklist),
		server = serverScript(info, checklist),
		checklist = checklist,
		events = events,
		remotes = remotes,
		stats = { client = #events, server = serverHandlers },
		blueprint = bp,
	}
end

return Integration
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="45">
            <Properties>
              <string name="Name">Normalize</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Blueprint.Normalize: turns ANY input (a generator's output, hand-written
	JSON, garbage) into a complete, valid blueprint. It never throws.

		local blueprint, warnings = Normalize.run(anything)

	What it does:
	- fills every missing field with a default (per archetype / genre)
	- coerces types ("12" -> 12, "b" -> "B", "gold coin" -> "coin")
	- drops unknown HUD types / section kinds / screen kinds (with a warning)
	- dedupes and generates ids, clamps every list (Schema.LIMITS)
	- replaces unknown icons, colours, fonts and key names with safe ones
	- repairs actions (unknown screen targets are fuzzy-matched or become a Notify)
	- guarantees a usable HUD: currencies are shown and every window screen
	  can be opened from a menu button (never a v2.3 teleport row's)
	- v2.3: records where the HUD layout put the currencies and the menu
	  (hud.arranged) and moves them when hud.layout changes (Normalize.setLayout
	  does the same in place)

	Reads only with rawget, so tables with hostile metatables can't hurt it.
]]

local Schema = require(script.Parent.Schema)
local Icons = require(script.Parent.Parent.Icons)
local HudKits = require(script.Parent.Parent.Components.HudKits)

local Normalize = {}

local LIMITS = Schema.LIMITS
local MAX_SCAN = 4096 -- never walk more than this many array slots

--------------------------------------------------------------------------
-- Readers (all total: they return a default instead of failing)
--------------------------------------------------------------------------

local function isTable(value: any): boolean
	return typeof(value) == "table"
end

local function get(t: any, key: any): any
	if isTable(t) then
		return rawget(t, key)
	end
	return nil
end

-- First non-nil of several keys (tolerates generator spelling variants).
local function pick(t: any, ...: string): any
	if not isTable(t) then
		return nil
	end
	for i = 1, select("#", ...) do
		local value = rawget(t, (select(i, ...)))
		if value ~= nil then
			return value
		end
	end
	return nil
end

local function trim(s: string): string
	return (string.gsub(s, "^%s+", ""):gsub("%s+$", ""))
end

local function utf8Clean(s: string): string
	if utf8.len(s) == nil then
		s = string.gsub(s, "[\128-\255]", "")
	end
	return s
end

local function truncate(s: string, maxChars: number): string
	local length = utf8.len(s)
	if length and length > maxChars then
		local cut = utf8.offset(s, maxChars + 1)
		if cut then
			s = trim(string.sub(s, 1, cut - 1))
		end
	end
	return s
end

-- A display string: numbers become text, control characters become spaces.
local function text(value: any, default: string?, maxChars: number?): string?
	if typeof(value) == "number" then
		if value ~= value or math.abs(value) == math.huge then
			return default
		end
		value = if value == math.floor(value) and math.abs(value) < 1e15 then string.format("%d", value) else tostring(value)
	end
	if typeof(value) ~= "string" then
		return default
	end
	value = utf8Clean(value)
	value = string.gsub(value, "%c", function(c)
		return if c == "\n" then "\n" else " "
	end)
	value = trim(value)
	if value == "" then
		return default
	end
	return truncate(value, maxChars or 80)
end

local function number(value: any, default: number?, min: number?, max: number?): number?
	if typeof(value) == "string" then
		value = tonumber((string.gsub(value, "[,%s_]", "")))
	end
	if typeof(value) ~= "number" or value ~= value then
		return default
	end
	if value == math.huge then
		value = max or 1e15
	elseif value == -math.huge then
		value = min or -1e15
	end
	if min and value < min then
		value = min
	end
	if max and value > max then
		value = max
	end
	return value
end

local function integer(value: any, default: number?, min: number?, max: number?): number?
	local n = number(value, nil, min, max)
	if n == nil then
		return default
	end
	return math.floor(n + 0.5)
end

local function bool(value: any, default: boolean): boolean
	if typeof(value) == "boolean" then
		return value
	elseif value == "true" or value == 1 then
		return true
	elseif value == "false" or value == 0 then
		return false
	end
	return default
end

local function squash(s: string): string
	return string.lower((string.gsub(s, "[%s_%-]", "")))
end

-- Case/spacing-insensitive match against a list of canonical names.
local function enum(value: any, list: { string }, default: string?): string?
	if typeof(value) ~= "string" then
		return default
	end
	local wanted = squash(value)
	for _, name in list do
		if squash(name) == wanted then
			return name
		end
	end
	return default
end

-- The items of an array (or the values of a map, sorted by key, with the key
-- remembered as `_key`), skipping anything that isn't allowed by `keep`.
local function items(value: any): { any }
	local out = {}
	if not isTable(value) then
		return out
	end
	local count = 0
	for i = 1, MAX_SCAN do
		local item = rawget(value, i)
		if item == nil then
			break
		end
		count += 1
		table.insert(out, item)
	end
	if count > 0 then
		return out
	end
	-- map form: { coins = {...}, gems = {...} }
	local keys = {}
	local ok = pcall(function()
		for key, item in next, value do
			if typeof(key) == "string" and isTable(item) then
				table.insert(keys, key)
			end
			if #keys >= MAX_SCAN then
				break
			end
		end
	end)
	if not ok then
		return out
	end
	table.sort(keys)
	for _, key in keys do
		local item = rawget(value, key)
		table.insert(out, { _key = key, _item = item })
	end
	return out
end

-- Unwraps the map form produced by items().
local function unwrap(entry: any): (any, string?)
	if isTable(entry) and rawget(entry, "_item") ~= nil and rawget(entry, "_key") ~= nil then
		return rawget(entry, "_item"), rawget(entry, "_key")
	end
	return entry, nil
end

-- An id from a label: lower_snake_case ("Item Shop" -> "item_shop").
local function slug(value: any, default: string?): string?
	local s = text(value, nil, 64)
	if s == nil then
		return default
	end
	s = string.lower(s)
	s = string.gsub(s, "[^%w]+", "_")
	s = string.gsub(s, "^_+", "")
	s = string.gsub(s, "_+$", "")
	if s == "" then
		return default
	end
	return string.sub(s, 1, 32)
end

-- An id given explicitly: keeps its case ("shopButton"), only unsafe
-- characters become "_".
local function idOf(value: any): string?
	local s = text(value, nil, 64)
	if s == nil then
		return nil
	end
	s = string.gsub(s, "[^%w_]+", "_")
	s = string.gsub(s, "^_+", "")
	s = string.gsub(s, "_+$", "")
	if s == "" then
		return nil
	end
	return string.sub(s, 1, 32)
end

-- Hands out unique ids inside one namespace.
local function idMaker()
	local used: { [string]: boolean } = {}
	return function(wanted: string?, fallback: string): string
		local base = wanted or fallback
		local id = base
		local n = 2
		while used[string.lower(id)] do
			id = `{base}_{n}`
			n += 1
		end
		used[string.lower(id)] = true
		return id
	end
end

--------------------------------------------------------------------------
-- Colours, fonts, icons, keys
--------------------------------------------------------------------------

local function hex(value: any): string?
	if typeof(value) == "Color3" then
		return "#" .. string.upper(value:ToHex())
	end
	if typeof(value) ~= "string" then
		return nil
	end
	local s = string.gsub(trim(value), "^#", "")
	s = string.gsub(s, "^0[xX]", "")
	if string.match(s, "^%x%x%x$") then
		local r, g, b = string.sub(s, 1, 1), string.sub(s, 2, 2), string.sub(s, 3, 3)
		s = r .. r .. g .. g .. b .. b
	end
	if string.match(s, "^%x%x%x%x%x%x$") then
		return "#" .. string.upper(s)
	end
	return nil
end
Normalize.hex = hex

-- Perceived brightness 0..1 of a "#RRGGBB" colour.
local function luminance(color: string): number
	local r = tonumber(string.sub(color, 2, 3), 16) or 0
	local g = tonumber(string.sub(color, 4, 5), 16) or 0
	local b = tonumber(string.sub(color, 6, 7), 16) or 0
	return (r * 0.299 + g * 0.587 + b * 0.114) / 255
end
Normalize.luminance = luminance

-- "Family" or "Family:Weight" -> a valid FontRef (family must ship with Roblox).
local function fontRef(value: any): string?
	if typeof(value) ~= "string" then
		return nil
	end
	local family, weight = string.match(value, "^%s*([%w]+)%s*:?%s*([%w]*)%s*$")
	if family == nil then
		return nil
	end
	local canonical = nil
	for name in Schema.FONTS do
		if string.lower(name) == string.lower(family) then
			canonical = name
			break
		end
	end
	if canonical == nil then
		return nil
	end
	local weights = Schema.FONTS[canonical]
	if weight == nil or weight == "" then
		return canonical
	end
	local wantedWeight = nil
	for name, value2 in Schema.WEIGHTS do
		if string.lower(name) == string.lower(weight) then
			wantedWeight = { name, value2 }
			break
		end
	end
	if wantedWeight == nil then
		return canonical
	end
	-- nearest weight the family actually has
	local best, bestDistance = nil, math.huge
	for _, available in weights do
		local distance = math.abs(available - wantedWeight[2])
		if distance < bestDistance then
			best, bestDistance = available, distance
		end
	end
	if best == 400 or Schema.WEIGHT_NAMES[best] == nil then
		return canonical
	end
	return `{canonical}:{Schema.WEIGHT_NAMES[best]}`
end
Normalize.fontRef = fontRef

-- Words generators like to use that aren't Builder Icons names.
local ICON_SYNONYMS = {
	money = "coin", dollar = "coin", dollars = "coin", gold = "coin", token = "coin", tokens = "coin", cash = "cash",
	crystal = "gem", crystals = "gem", jewel = "gem", ruby = "gem", emerald = "gem", sapphire = "gem", gemstone = "gem",
	potion = "lab-beaker", flask = "lab-beaker", elixir = "lab-beaker", science = "lab-beaker",
	bag = "backpack", inventory = "backpack", items = "backpack", storage = "backpack",
	chest = "gift", box = "cube", crate = "cube", case = "cube", cases = "cube", lootbox = "cube", mystery = "cube-question-mark",
	store = "shop", market = "building-store", merchant = "building-store",
	vip = "crown", king = "crown", royal = "crown", gamepass = "premium", pass = "premium", battlepass = "premium", season = "premium",
	rank = "trophy", ranks = "trophy", ranked = "trophy", leaderboard = "trophy", leaderboards = "trophy", ranking = "trophy",
	achievement = "trophy", achievements = "trophy", win = "trophy", wins = "trophy", victory = "trophy", champion = "trophy",
	badge = "medal", badges = "medal", award = "medal",
	mission = "quest", missions = "quest", challenge = "quest", challenges = "quest", task = "quest", todo = "quest",
	hourglass = "timer", stopwatch = "timer", countdown = "timer", clock = "clock",
	speed = "speedometer", fast = "speedometer", velocity = "speedometer", car = "speedometer", vehicle = "speedometer", vehicles = "speedometer",
	power = "energy", lightning = "energy", electric = "energy", flashlight = "bolt", battery = "bolt", charge = "bolt",
	flames = "fire", lava = "fire", heat = "fire", burn = "fire",
	gun = "ammo", guns = "ammo", pistol = "ammo", rifle = "ammo", bullet = "ammo", bullets = "ammo", shoot = "ammo",
	weapon = "melee", weapons = "melee", knife = "melee", blade = "melee", dagger = "melee", katana = "melee", attack = "melee",
	bomb = "grenade", explosive = "grenade", dynamite = "grenade",
	worlds = "world", planet = "world", globe = "world", earth = "world", zone = "world", zones = "world", realm = "world",
	portal = "nebula", space = "nebula", galaxy = "nebula", teleport = "person-teleport",
	house = "home", base = "home", lobby = "home",
	party = "three-people", team = "three-people", teams = "three-people", squad = "three-people", group = "three-people", clan = "three-people",
	trade = "two-arrows-left-right", trading = "two-arrows-left-right", swap = "two-arrows-left-right", exchange = "two-arrows-left-right",
	profile = "player", user = "player", avatar = "player", character = "player", account = "player", me = "player",
	options = "settings", cog = "settings", config = "settings", preferences = "settings", controls = "controller",
	keybinds = "keyboard", keys = "key", hotkeys = "keyboard",
	stats = "chart-four-vertical-bars", statistics = "chart-four-vertical-bars", chart = "chart-four-vertical-bars", graph = "chart-line",
	xp = "level", experience = "level", levelup = "level", levels = "level",
	animal = "pet", animals = "pet", paw = "pet", dog = "pet", cat = "pet", bird = "pet", dragon = "pet", companion = "pet",
	plant = "farm", crop = "farm", crops = "farm", seed = "farm", seeds = "farm", garden = "farm", harvest = "farm",
	prestige = "rebirth", ascend = "rebirth", ascension = "rebirth", reset = "rebirth",
	upgrades = "upgrade", improve = "upgrade", enhance = "upgrade",
	boosts = "boost", multiplier = "boost", booster = "boost", luck = "cursor-stars",
	codes = "tag", code = "tag", promo = "tag", redeem = "tag", coupon = "tag", ticket = "tag", tickets = "tag",
	free = "gift", reward = "gift", rewards = "gift", present = "gift", prize = "gift",
	emote = "face-winking", emotes = "face-winking", dance = "face-winking", emoji = "face-winking",
	minimap = "map", location = "map", waypoint = "map",
	locked = "lock", padlock = "lock",
	start = "play", go = "play", resume = "play",
	audio = "sound", volume = "sound", sfx = "sound", songs = "music",
	message = "chat", messages = "chat", inbox = "mail",
	news = "megaphone", announcement = "megaphone", announcements = "megaphone", events = "calendar-star", event = "calendar-star",
	build = "tools", hammer = "tools", pickaxe = "tools", axe = "tools", wrench = "tools", mine = "tools", mining = "tools", craft = "tools", crafting = "tools",
	race = "flag", racing = "flag", finish = "flag", checkpoint = "flag",
	tower = "shield", defense = "shield", defence = "shield", armor = "shield", armour = "shield", protection = "shield", block = "shield",
	hp = "health", life = "heart", lives = "heart", love = "heart", like = "thumb-up",
	stars = "star", favorite = "star", favourite = "star", rating = "star",
	vision = "eye", watch = "eye", spectate = "eye", ghost = "eye", skull = "ninja", death = "ninja", kill = "crosshair", kills = "crosshair",
	candy = "lollipop", sweets = "lollipop", sweet = "lollipop",
	skin = "skins", outfit = "skins", outfits = "skins", clothes = "skins", cosmetics = "skins", cosmetic = "skins", hat = "hat-fedora",
	paint = "paint-brush", color = "paint-brush", colour = "paint-brush", customize = "paint-brush",
	stealth = "ninja", halloween = "pumpkin", piggy = "pig", bank = "building-bank", savings = "pig",
	fishing = "fish", ocean = "fish",
	question = "help", tutorial = "help", guide = "book-open", book = "book-open", journal = "book-open", lore = "book-open",
	exit = "door", leave = "door", quit = "door",
	aim = "crosshair", sniper = "crosshair", scope = "binoculars", explore = "compass", adventure = "compass", navigate = "compass",
	night = "night", dark = "night", day = "sun", sunny = "sun", weather = "cloud",
	magic = "cursor-stars", spell = "cursor-stars", spells = "cursor-stars", sparkles = "sparkle", wand = "cursor-stars",
	gifts = "gift", notifications = "bell", alert = "warning", danger = "warning",
}

local function iconOk(name: string): boolean
	local ok, result = pcall(Icons.has, name)
	return ok and result == true
end

-- A valid icon value, or nil. Accepts names, aliases, synonyms, asset ids and emoji.
local function icon(value: any): string?
	if typeof(value) == "number" then
		if value > 0 and value == math.floor(value) and value < 1e15 then
			return "rbxassetid://" .. string.format("%d", value)
		end
		return nil
	end
	if typeof(value) ~= "string" then
		return nil
	end
	local s = trim(utf8Clean(value))
	if s == "" or #s > 120 then
		return nil
	end
	if string.match(s, "^rbxassetid://%d+$") or string.match(s, "^rbxasset://[%w_%./@%-]+$") then
		return s
	end
	-- a single emoji / symbol
	if string.find(s, "[\128-\255]") and utf8.len(s) and utf8.len(s) <= 2 then
		return s
	end
	if iconOk(s) then
		return s
	end
	local lower = string.lower(s)
	local dashed = string.gsub(string.gsub(lower, "[%s_]+", "-"), "[^%w%-]", "")
	for _, candidate in { lower, dashed } do
		if iconOk(candidate) then
			return candidate
		end
		local synonym = ICON_SYNONYMS[candidate]
		if synonym and iconOk(synonym) then
			return synonym
		end
		local singular = string.gsub(candidate, "s$", "")
		if singular ~= candidate and iconOk(singular) then
			return singular
		end
	end
	-- word by word: "gold-coin-pile" -> "coin"
	for word in string.gmatch(dashed, "[%w]+") do
		if iconOk(word) then
			return word
		end
		local synonym = ICON_SYNONYMS[word]
		if synonym and iconOk(synonym) then
			return synonym
		end
	end
	return nil
end
Normalize.icon = icon

local KEY_ALIASES = {
	["1"] = "One", ["2"] = "Two", ["3"] = "Three", ["4"] = "Four", ["5"] = "Five",
	["6"] = "Six", ["7"] = "Seven", ["8"] = "Eight", ["9"] = "Nine", ["0"] = "Zero",
	shift = "LeftShift", lshift = "LeftShift", ctrl = "LeftControl", control = "LeftControl",
	alt = "LeftAlt", esc = "Escape", enter = "Return", spacebar = "Space", caps = "CapsLock",
	up = "Up", down = "Down", left = "Left", right = "Right", tab = "Tab", space = "Space",
	backspace = "Backspace", escape = "Escape",
	lmb = "MouseButton1", rmb = "MouseButton2", mouse1 = "MouseButton1", mouse2 = "MouseButton2",
}

-- A KeyCode (or mouse UserInputType) name that exists, or nil.
local function keyName(value: any): string?
	local s = text(value, nil, 24)
	if s == nil then
		return nil
	end
	s = string.gsub(s, "^Enum%.KeyCode%.", "")
	if #s == 1 and string.match(s, "%a") then
		s = string.upper(s)
	end
	s = KEY_ALIASES[string.lower(s)] or s
	if s == "MouseButton1" or s == "MouseButton2" then
		return s
	end
	local ok, item = pcall(function()
		return (Enum.KeyCode :: any)[s]
	end)
	if ok and item ~= nil then
		return s
	end
	return nil
end
Normalize.keyName = keyName

--------------------------------------------------------------------------
-- Context: warnings + lookups shared by the passes below
--------------------------------------------------------------------------

type Ctx = {
	warnings: { string },
	screenIds: { string },
	screenSet: { [string]: boolean },
	screenTitles: { [string]: string },
	currencySet: { [string]: boolean },
	currencyIds: { string },
	currencyIcons: { [string]: string },
	elementIds: (string?, string) -> string,
	settingTypes: { [string]: string }, -- setting id -> control type, blueprint-wide
	sectionIds: (string?, string) -> string, -- section ids, blueprint-wide
	achievementIds: (string?, string) -> string, -- v1.6: achievement ids, blueprint-wide (BloxUI_Ach_<id>)
}

local function warnf(ctx: Ctx, path: string, message: string)
	if #ctx.warnings < 200 then
		table.insert(ctx.warnings, `{path}: {message}`)
	end
end

-- Clamps a list, warning once when it was too long.
local function limited(ctx: Ctx, value: any, limit: number, path: string): { any }
	local list = items(value)
	if #list > limit then
		warnf(ctx, path, `{#list} entries, kept the first {limit}`)
		local kept = {}
		for i = 1, limit do
			kept[i] = list[i]
		end
		return kept
	end
	return list
end

local function toneName(value: any, default: string?): string?
	return enum(value, Schema.TONES, default)
end

local function rarity(value: any, default: string?): string?
	return enum(value, Schema.RARITIES, default)
end

local function iconOr(ctx: Ctx, value: any, fallback: string, path: string): string
	if value == nil or value == "" then
		return fallback
	end
	local resolved = icon(value)
	if resolved == nil then
		warnf(ctx, path, `unknown icon {text(value, "?", 30)}, using "{fallback}"`)
		return fallback
	end
	return resolved
end

--------------------------------------------------------------------------
-- Actions
--------------------------------------------------------------------------

local function matchScreen(ctx: Ctx, target: any): string?
	local s = text(target, nil, 64)
	if s == nil then
		return nil
	end
	if ctx.screenSet[s] then
		return s
	end
	local wanted = slug(s)
	if wanted == nil then
		return nil
	end
	for _, id in ctx.screenIds do
		if slug(id) == wanted then
			return id
		end
	end
	for _, id in ctx.screenIds do
		if slug(ctx.screenTitles[id]) == wanted then
			return id
		end
	end
	-- "open_shop", "shopMenu", "the shop" -> "shop"
	for _, id in ctx.screenIds do
		local lowered = slug(id) or id
		if #lowered >= 3 and (string.find(wanted, lowered, 1, true) or string.find(lowered, wanted, 1, true)) then
			return id
		end
	end
	return nil
end

local function action(ctx: Ctx, value: any, path: string): { [string]: any }
	if value == nil then
		return { kind = "None" }
	end
	-- shorthand: "shop" means open the shop screen
	if typeof(value) == "string" then
		local lowered = string.lower(value)
		if lowered == "close" then
			return { kind = "Close" }
		end
		local target = matchScreen(ctx, value)
		if target then
			return { kind = "Open", target = target }
		end
		return { kind = "Event", name = text(value, "Action", 64) :: string }
	end
	if not isTable(value) then
		warnf(ctx, path, "action is not a table, ignored")
		return { kind = "None" }
	end
	local kind = enum(pick(value, "kind", "type"), Schema.ACTION_KINDS, nil)
	if kind == nil then
		if pick(value, "target", "screen") ~= nil then
			kind = "Open"
		elseif pick(value, "productId") ~= nil then
			kind = "Purchase"
		elseif pick(value, "gamePassId") ~= nil then
			kind = "GamePass"
		else
			if pick(value, "kind", "type") ~= nil then
				warnf(ctx, path, `unknown action kind {text(pick(value, "kind", "type"), "?", 20)}`)
			end
			return { kind = "None" }
		end
	end
	if kind == "Open" or kind == "Toggle" then
		local target = matchScreen(ctx, pick(value, "target", "screen", "id"))
		if target == nil then
			warnf(ctx, path, `no screen {text(pick(value, "target", "screen"), "(none)", 30)}, action now shows "Coming soon!"`)
			return { kind = "Notify", text = "Coming soon!" }
		end
		return { kind = kind, target = target }
	elseif kind == "Close" then
		local raw = pick(value, "target", "screen")
		local target = matchScreen(ctx, raw)
		if raw ~= nil and target == nil then
			warnf(ctx, path, "close target not found, closes its own screen")
		end
		return { kind = "Close", target = target }
	elseif kind == "Purchase" then
		local id = integer(pick(value, "productId", "id"), nil, 1, 1e15)
		if id == nil then
			warnf(ctx, path, "purchase without a valid productId")
			return { kind = "Notify", text = "Not for sale yet!" }
		end
		return { kind = "Purchase", productId = id }
	elseif kind == "GamePass" then
		local id = integer(pick(value, "gamePassId", "id"), nil, 1, 1e15)
		if id == nil then
			warnf(ctx, path, "gamepass without a valid gamePassId")
			return { kind = "Notify", text = "Not for sale yet!" }
		end
		return { kind = "GamePass", gamePassId = id }
	elseif kind == "Remote" or kind == "Event" then
		local name = text(pick(value, "name", "event", "remote"), nil, 64)
		if name then
			name = string.gsub(name, "[^%w_%.%-]", "")
		end
		if name == nil or name == "" then
			warnf(ctx, path, `{kind} action without a name`)
			return { kind = "None" }
		end
		-- v2.3: an Event may carry a payload (a teleport button's place), which
		-- its handlers get as context.payload
		local payload = if kind == "Event" then text(get(value, "payload"), nil, 64) else nil
		return { kind = kind, name = name, payload = payload }
	elseif kind == "Notify" then
		return { kind = "Notify", text = text(pick(value, "text", "message"), "Coming soon!", 120) :: string }
	end
	return { kind = "None" }
end

--------------------------------------------------------------------------
-- Style, logo, currencies
--------------------------------------------------------------------------

-- An audio asset id from anything a person might paste: 123, "123",
-- "rbxassetid://123", a Creator Store / library URL or an rbxasset:// path.
-- nil when there is no id in it.
local function soundId(value: any): string?
	if typeof(value) == "number" then
		if value ~= value or value < 1 or value >= 1e15 or value ~= math.floor(value) then
			return nil
		end
		return string.format("rbxassetid://%d", value)
	end
	if typeof(value) ~= "string" then
		return nil
	end
	local s = trim(value)
	if s == "" or #s > 300 then
		return nil
	end
	if string.match(s, "^rbxasset://[%w_%-%./]+$") then
		return s
	end
	local digits = string.match(s, "^rbxassetid://(%d+)$")
		or string.match(s, "^(%d+)$")
		or string.match(s, "^https?://[%w%.]*roblox%.com/.*[?&]id=(%d+)")
		or string.match(s, "^https?://[%w%.]*roblox%.com/[%w/]-/(%d+)")
	if digits == nil or #digits > 15 then
		return nil
	end
	local n = tonumber(digits)
	if n == nil or n < 1 then
		return nil
	end
	return string.format("rbxassetid://%d", n)
end
Normalize.soundId = soundId

-- "Click" / "LEVEL_UP" / "denied" -> the canonical slot name, or nil.
local function soundSlot(value: any): string?
	if typeof(value) ~= "string" then
		return nil
	end
	local key = squash(value)
	for _, slot in Schema.SOUND_SLOTS do
		if string.lower(slot) == key then
			return slot
		end
	end
	return Schema.SOUND_ALIASES[key]
end

-- style.sounds: { slot = asset id }. "" or a missing slot means the theme
-- default; unknown slots and values that aren't asset ids are dropped.
local function sounds(ctx: Ctx, raw: any): { [string]: string }?
	if raw == nil or raw == "" then
		return nil
	end
	if not isTable(raw) then
		warnf(ctx, "style.sounds", "expected { slot = asset id }, ignored")
		return nil
	end
	local keys = {}
	pcall(function()
		for key in next, raw do
			if #keys >= 64 then
				break
			end
			table.insert(keys, key)
		end
	end)
	table.sort(keys, function(a, b)
		return tostring(a) < tostring(b)
	end)
	local out, count = {}, 0
	for _, key in keys do
		local value = rawget(raw, key)
		local slot = soundSlot(key)
		if slot == nil then
			warnf(ctx, `style.sounds.{text(key, "?", 20)}`, "unknown sound slot, dropped")
		elseif value ~= "" and value ~= nil and out[slot] == nil then
			local id = soundId(value)
			if id then
				out[slot] = id
				count += 1
			else
				warnf(ctx, `style.sounds.{slot}`, "not an asset id, using the theme default")
			end
		end
	end
	return if count > 0 then out else nil
end

-- Case/spacing-insensitive field read ("Lobby", "lobby_music").
local function field(t: any, names: { string }): any
	if not isTable(t) then
		return nil
	end
	for _, name in names do
		local value = rawget(t, name)
		if value ~= nil then
			return value
		end
	end
	local found = nil
	pcall(function()
		local count = 0
		for key, value in next, t do
			count += 1
			if count > 64 then
				break
			end
			if typeof(key) == "string" and table.find(names, squash(key)) then
				found = value
				break
			end
		end
	end)
	return found
end

-- Top-level music: { lobby, gameplay?, volume = 0..1, muffle }. Dropped when
-- it has no track to play.
local function music(ctx: Ctx, raw: any): { [string]: any }?
	if raw == nil or raw == "" then
		return nil
	end
	if typeof(raw) == "string" or typeof(raw) == "number" then
		raw = { lobby = raw }
	end
	if not isTable(raw) then
		warnf(ctx, "music", "expected { lobby, gameplay, volume, muffle }, ignored")
		return nil
	end
	local out: { [string]: any } = {}
	for key, names in { lobby = { "lobby", "menu", "lobbymusic", "menumusic" }, gameplay = { "gameplay", "game", "ingame", "gameplaymusic" } } do
		local given = field(raw, names)
		if given ~= nil and given ~= "" then
			local id = soundId(given)
			if id then
				out[key] = id
			else
				warnf(ctx, `music.{key}`, "not an asset id, ignored")
			end
		end
	end
	if out.lobby == nil and out.gameplay == nil then
		return nil
	end
	local rawVolume = field(raw, { "volume" })
	local volume = number(rawVolume, nil, 0, 100)
	if rawVolume ~= nil and volume == nil then
		warnf(ctx, "music.volume", "not a number, using 0.5")
	end
	if volume and volume > 1 then
		volume /= 100 -- "50" means 50 %
	end
	out.volume = volume or Schema.MUSIC_VOLUME
	out.muffle = bool(field(raw, { "muffle" }), true)
	return out
end

local function style(ctx: Ctx, raw: any, genre: string): { [string]: any }
	-- v2.4: a style family (Schema.STYLE_FAMILIES) decides the whole look; the
	-- archetype underneath defaults to the family's base
	local family = nil
	local rawFamily = get(raw, "family")
	if rawFamily ~= nil and rawFamily ~= "" and rawFamily ~= "None" then
		family = enum(rawFamily, Schema.STYLE_FAMILIES, nil)
		if family == nil and typeof(rawFamily) == "string" then
			-- labels work too ("Toybox Gloss", "holo hud")
			local wanted = squash(rawFamily)
			for id, info in Schema.FAMILY_INFO do
				if squash(info.label) == wanted then
					family = id
				end
			end
		end
		if family == nil then
			warnf(ctx, "style.family", `unknown family {text(rawFamily, "?", 20)}, ignored`)
		end
	end
	local archetype = enum(get(raw, "archetype"), Schema.ARCHETYPES, nil)
	if archetype == nil then
		if get(raw, "archetype") ~= nil then
			warnf(ctx, "style.archetype", "unknown archetype, using the genre default")
		end
		archetype = if family then Schema.FAMILY_INFO[family].base else (Schema.GENRE_ARCHETYPE[genre] or "Arena")
	end
	local base = Schema.ARCHETYPE_STYLE[archetype]
	local out: { [string]: any } = { archetype = archetype }
	if family then
		out.family = family
	end

	local palette = {}
	local rawPalette = get(raw, "palette")
	local familyPalette = if family then Schema.FAMILY_PALETTES[family] else nil
	for _, key in Schema.PALETTE_KEYS do
		local given = get(rawPalette, key)
		if key == "surface" and family then
			-- a family blueprint's surface is a mode ("light" | "dark") or a colour
			-- of any lightness; missing = the family's default mode
			local mode = if typeof(given) == "string" then string.lower(trim(given)) else nil
			if mode == "light" or mode == "dark" then
				palette.surface = mode
				continue
			end
			local color = hex(given)
			if given ~= nil and color == nil then
				warnf(ctx, "style.palette.surface", `not "light", "dark" or a #RRGGBB colour, using the family's`)
			end
			palette.surface = color or Schema.FAMILY_INFO[family].surface
			continue
		end
		local color = hex(given)
		if given ~= nil and color == nil then
			warnf(ctx, `style.palette.{key}`, "not a #RRGGBB colour")
		end
		palette[key] = color or (familyPalette and familyPalette[key]) or base.palette[key]
	end
	-- Theme contract: `text` is the HUD text (drawn over the game world, so it
	-- must be light) and `surface` is the glass behind HUD pills and windows
	-- (so it must be dark). Anything else gives dark-on-dark text. A family
	-- decides light or dark panels itself (palette.surface above).
	if luminance(palette.text) < 0.6 then
		warnf(ctx, "style.palette.text", "HUD text must be light, using the archetype's")
		palette.text = base.palette.text
	end
	if family == nil and luminance(palette.surface) > 0.4 then
		warnf(ctx, "style.palette.surface", "the glass surface must be dark, using the archetype's")
		palette.surface = base.palette.surface
	end
	out.palette = palette

	-- v2.4: the style keys the user set by hand while a family is on
	local rawCustom = get(raw, "custom")
	if family and isTable(rawCustom) then
		local custom = {}
		for _, key in items(rawCustom) do
			local name = enum(key, Schema.FAMILY_CUSTOM_KEYS, nil)
			if name and not table.find(custom, name) then
				table.insert(custom, name)
			end
		end
		if #custom > 0 then
			out.custom = custom
		end
	end

	local fonts = {}
	local rawFonts = get(raw, "fonts")
	for _, key in { "display", "body", "title" } do
		local given = get(rawFonts, key)
		local ref = fontRef(given)
		if given ~= nil and ref == nil then
			warnf(ctx, `style.fonts.{key}`, `unknown font {text(given, "?", 30)}`)
		end
		fonts[key] = ref or base.fonts[key]
	end
	out.fonts = fonts

	for key, list in Schema.STYLE_ENUMS do
		local given = get(raw, key)
		local value = enum(given, list, nil)
		if given ~= nil and value == nil then
			warnf(ctx, `style.{key}`, `unknown value {text(given, "?", 20)}`)
		end
		out[key] = value or base[key]
	end
	out.sounds = sounds(ctx, get(raw, "sounds"))
	return out
end

local function logo(ctx: Ctx, raw: any, title: string): { [string]: any }
	return {
		text = text(pick(raw, "text", "title"), string.upper(title), 32),
		subtitle = text(get(raw, "subtitle"), nil, 32),
		type = enum(get(raw, "type"), Schema.LOGO_TYPES, "Wordmark"),
		icon = if get(raw, "icon") ~= nil then iconOr(ctx, get(raw, "icon"), "star", "logo.icon") else nil,
		tilt = number(get(raw, "tilt"), -3, -12, 12),
		tone = toneName(get(raw, "tone"), "Yellow"),
	}
end

local function currencies(ctx: Ctx, raw: any): { any }
	local out = {}
	local ids = idMaker()
	for index, entry in limited(ctx, raw, LIMITS.currencies, "currencies") do
		local item, key = unwrap(entry)
		local path = `currencies[{index}]`
		if not isTable(item) then
			warnf(ctx, path, "not a table, dropped")
			continue
		end
		local name = text(get(item, "name"), nil, 24)
		local id = ids(idOf(get(item, "id")) or idOf(key) or slug(name), `currency{index}`)
		name = name or (string.upper(string.sub(id, 1, 1)) .. string.sub(id, 2))
		local rawSource = get(item, "source")
		local sourceKind = enum(if typeof(rawSource) == "string" then rawSource else get(rawSource, "kind"), { "none", "leaderstat", "attribute" }, "none")
		local sourceName = text(get(rawSource, "name"), name, 32)
		if sourceName then
			sourceName = string.gsub(sourceName, "[^%w_ ]", "")
			if sourceName == "" then
				sourceName = name
			end
		end
		if sourceKind == "attribute" then
			-- attribute names: letters, digits and _ only, and never the
			-- reserved RBX prefix (SetAttribute throws otherwise)
			local given = sourceName
			sourceName = string.gsub(sourceName or "", "[^%w_]", "")
			if sourceName == "" or string.upper(string.sub(sourceName, 1, 3)) == "RBX" then
				sourceName = "Currency_" .. string.gsub(id, "[^%w_]", "_")
			end
			if given ~= sourceName then
				warnf(ctx, path .. ".source.name", `"{given}" isn't a valid attribute name, using "{sourceName}"`)
			end
		end
		table.insert(out, {
			id = id,
			name = name,
			icon = iconOr(ctx, get(item, "icon"), if index == 1 then "coin" else "gem", path .. ".icon"),
			color = hex(get(item, "color")) or (if index == 1 then "#FFD21F" elseif index == 2 then "#5CC8FF" else "#B45CFF"),
			start = number(get(item, "start"), 0, 0, 1e15),
			format = enum(get(item, "format"), { "abbrev", "comma" }, "abbrev"),
			source = { kind = sourceKind, name = sourceName },
		})
		ctx.currencySet[id] = true
		ctx.currencyIcons[id] = out[#out].icon
		table.insert(ctx.currencyIds, id)
	end
	return out
end

-- A known currency id for a reference (exact, then case-insensitive), or nil.
local function resolveCurrency(ctx: Ctx, value: any): string?
	local exact = idOf(value)
	if exact and ctx.currencySet[exact] then
		return exact
	end
	local s = slug(value)
	if s == nil then
		return nil
	end
	for _, id in ctx.currencyIds do
		if slug(id) == s then
			return id
		end
	end
	return nil
end

-- "robux" or a known currency id.
local function priceCurrency(ctx: Ctx, value: any): string
	local s = slug(value)
	if s == nil or s == "robux" or s == "r" then
		return "robux"
	end
	local known = resolveCurrency(ctx, value)
	if known then
		return known
	end
	for _, id in ctx.currencyIds do
		local lowered = slug(id) or id
		if string.find(s, lowered, 1, true) or string.find(lowered, s, 1, true) then
			return id
		end
	end
	return ctx.currencyIds[1] or "robux"
end

local function currencyId(ctx: Ctx, value: any): string?
	return resolveCurrency(ctx, value) or ctx.currencyIds[1]
end

local function rewardSpec(ctx: Ctx, raw: any, path: string): { [string]: any }?
	if raw == nil then
		return nil
	end
	if typeof(raw) == "number" or typeof(raw) == "string" then
		return { icon = "gift", amount = text(raw, "1", 16) }
	end
	if not isTable(raw) then
		return nil
	end
	return {
		icon = iconOr(ctx, get(raw, "icon"), "gift", path .. ".icon"),
		amount = text(get(raw, "amount"), nil, 16),
	}
end

-- 1320 -> "1,320" (display text for amounts made from a grant)
local function commaText(n: number): string
	local whole = tostring(math.floor(math.abs(n)))
	local formatted = string.reverse((string.gsub(string.reverse(whole), "(%d%d%d)", "%1,")))
	return (string.gsub(formatted, "^,", ""))
end

-- v1.6: currency UI.Server hands out itself: { currency, amount } (a known
-- currency, a whole amount above 0), or nil with a warning.
local function grantSpec(ctx: Ctx, raw: any, path: string): { [string]: any }?
	if raw == nil then
		return nil
	end
	if not isTable(raw) then
		warnf(ctx, path, "grant must be { currency, amount }, dropped")
		return nil
	end
	-- an empty grant ({}, or "" and 0 as the AI and the plugin leave it) is no grant
	local rawCurrency, rawAmount = get(raw, "currency"), get(raw, "amount")
	if (rawCurrency == nil or rawCurrency == "") and (rawAmount == nil or rawAmount == 0) then
		return nil
	end
	local currency = resolveCurrency(ctx, get(raw, "currency"))
	local amount = number(get(raw, "amount"), nil, 0, 1e15)
	if currency == nil or amount == nil or math.floor(amount) <= 0 then
		warnf(ctx, path, "unknown currency or amount, dropped")
		return nil
	end
	return { currency = currency, amount = math.floor(amount) }
end

-- v1.6: a reward with an optional grant ({ icon, amount, grant? }). The
-- amount text and the icon follow the grant when they're left out.
local function prizeSpec(ctx: Ctx, raw: any, path: string, fallbackIcon: string): { [string]: any }?
	if raw == nil then
		return nil
	end
	if typeof(raw) == "number" or typeof(raw) == "string" then
		return { icon = fallbackIcon, amount = text(raw, "1", 16) }
	end
	if not isTable(raw) then
		return nil
	end
	local grant = grantSpec(ctx, get(raw, "grant"), path .. ".grant")
	local amount = text(get(raw, "amount"), nil, 16)
	if amount == nil and grant then
		amount = commaText(grant.amount)
	end
	local defaultIcon = if grant then ctx.currencyIcons[grant.currency] or fallbackIcon else fallbackIcon
	return {
		icon = iconOr(ctx, get(raw, "icon"), defaultIcon, path .. ".icon"),
		amount = amount,
		grant = grant,
	}
end

--------------------------------------------------------------------------
-- HUD
--------------------------------------------------------------------------

local DEFAULT_ANCHOR = {
	CurrencyBar = "TopLeft", MenuButtons = "Left", HealthBar = "BottomLeft", XPBar = "BottomCenter",
	QuestTracker = "Left", Hotbar = "BottomCenter", Crosshair = "Center", KeyHints = "BottomRight",
	Objective = "TopLeft", Stamina = "BottomCenter", Battery = "BottomLeft", Fear = "Center",
	Vignette = "Center", Boosts = "Right", Clicker = "BottomCenter", KillFeed = "TopRight",
	Timer = "TopCenter", TeamScore = "TopCenter", Button = "Right", Minimap = "TopRight",
	BossBar = "TopCenter", MobileControls = "BottomRight", EmoteWheel = "Center",
	-- v1.7
	Counter = "TopRight", Meter = "BottomLeft", TeamStatus = "TopCenter", CameraPanel = "BottomRight",
	Camcorder = "TopLeft", Compass = "TopCenter",
}

-- Where the HUD layout wants its currencies and menu.
local LAYOUT_ANCHORS = Schema.LAYOUT_ANCHORS

local function menuButtons(ctx: Ctx, raw: any, path: string): { any }
	local out = {}
	local ids = idMaker()
	for index, entry in limited(ctx, raw, LIMITS.menuButtons, path) do
		local item = unwrap(entry)
		local bpath = `{path}[{index}]`
		if not isTable(item) then
			continue
		end
		local label = text(pick(item, "label", "text", "title"), nil, 16)
		local act = action(ctx, pick(item, "action", "target", "screen"), bpath .. ".action")
		if label == nil and act.target then
			label = ctx.screenTitles[act.target]
		end
		label = label or "Menu"
		local fallbackIcon = icon(label) or (if act.target then icon(act.target) else nil) or "star"
		table.insert(out, {
			id = ids(idOf(get(item, "id")) or slug(label), `button{index}`),
			label = label,
			icon = iconOr(ctx, get(item, "icon"), fallbackIcon, bpath .. ".icon"),
			tone = toneName(get(item, "tone"), Schema.MENU_TONES[((index - 1) % #Schema.MENU_TONES) + 1]),
			badge = if typeof(get(item, "badge")) == "number" then integer(get(item, "badge"), nil, 0, 99) else text(get(item, "badge"), nil, 4),
			action = act,
		})
	end
	return out
end

-- v1.7 TeamStatus: default portraits, one face each
local PORTRAITS = { "head-male", "head-female", "head-freckles", "beard", "head-blush", "head-blank", "head-lips", "eyebrows" }

local function hudElement(ctx: Ctx, raw: any, index: number, layout: string): { [string]: any }?
	local path = `hud.elements[{index}]`
	if not isTable(raw) then
		warnf(ctx, path, "not a table, dropped")
		return nil
	end
	local kind = enum(pick(raw, "type", "kind"), Schema.HUD_TYPE_LIST, nil)
	if kind == nil then
		warnf(ctx, path, `unknown HUD type {text(pick(raw, "type", "kind"), "(none)", 30)}, dropped`)
		return nil
	end
	local layoutInfo = LAYOUT_ANCHORS[layout]
	local defaultAnchor = layoutInfo[kind] or DEFAULT_ANCHOR[kind]
	local e: { [string]: any } = {
		type = kind,
		anchor = enum(get(raw, "anchor"), Schema.ANCHORS, defaultAnchor),
		visible = bool(get(raw, "visible"), true),
	}
	e.id = ctx.elementIds(idOf(get(raw, "id")), slug(kind) :: string)

	if kind == "CurrencyBar" then
		local list = {}
		for _, id in items(get(raw, "currencies")) do
			local known = resolveCurrency(ctx, id)
			if known and not table.find(list, known) then
				table.insert(list, known)
			end
		end
		if #list == 0 then
			list = table.clone(ctx.currencyIds)
		end
		e.currencies = list
		e.showAdd = bool(get(raw, "showAdd"), false)
		e.addAction = action(ctx, get(raw, "addAction"), path .. ".addAction")
		if e.showAdd and e.addAction.kind == "None" then
			-- a "+" should do something: open the first shop-like screen
			for _, id in ctx.screenIds do
				if string.find(id, "shop", 1, true) or string.find(id, "store", 1, true) then
					e.addAction = { kind = "Open", target = id }
					break
				end
			end
		end
	elseif kind == "MenuButtons" then
		-- v2.3: a teleport row (role "teleports") is a row at the top centre
		-- unless it says otherwise, whatever the layout
		local rawRole = get(raw, "role")
		if rawRole ~= nil then
			e.role = enum(rawRole, Schema.MENU_ROLES, nil)
			if e.role == nil then
				warnf(ctx, path .. ".role", `unknown MenuButtons role {text(rawRole, "(none)", 20)}, ignored (one of {table.concat(Schema.MENU_ROLES, ", ")})`)
			end
		end
		local teleports = e.role == "teleports"
		if teleports and enum(get(raw, "anchor"), Schema.ANCHORS, nil) == nil then
			e.anchor = Schema.TELEPORT_ANCHOR
		end
		e.style = enum(get(raw, "style"), Schema.MENU_STYLES, if teleports then "Row" else layoutInfo.menuStyle)
		e.buttons = menuButtons(ctx, get(raw, "buttons"), path .. ".buttons")
	elseif kind == "HealthBar" then
		e.max = number(get(raw, "max"), 100, 1, 1e9)
		e.source = { kind = enum(get(get(raw, "source"), "kind") or get(raw, "source"), { "humanoid", "none" }, "humanoid") }
	elseif kind == "XPBar" then
		e.level = integer(get(raw, "level"), 1, 0, 1e6)
		e.progress = number(get(raw, "progress"), 0.35, 0, 1)
	elseif kind == "QuestTracker" then
		e.title = text(get(raw, "title"), "Quests", 28)
		local tasks = {}
		for t, rawTask in limited(ctx, get(raw, "tasks"), LIMITS.tasks, path .. ".tasks") do
			local item = unwrap(rawTask)
			if typeof(item) == "string" then
				item = { text = item }
			end
			if isTable(item) then
				table.insert(tasks, {
					text = text(pick(item, "text", "title", "name"), `Task {t}`, 40),
					icon = iconOr(ctx, get(item, "icon"), "check", `{path}.tasks[{t}].icon`),
					goal = integer(get(item, "goal"), 1, 1, 1e9),
					progress = integer(get(item, "progress"), 0, 0, 1e9),
					reward = rewardSpec(ctx, get(item, "reward"), `{path}.tasks[{t}].reward`),
				})
			end
		end
		e.tasks = tasks
	elseif kind == "Hotbar" then
		local slots = {}
		for s, rawSlot in limited(ctx, get(raw, "slots"), LIMITS.slots, path .. ".slots") do
			local item = unwrap(rawSlot)
			if typeof(item) == "string" then
				item = { name = item }
			end
			if isTable(item) then
				table.insert(slots, {
					name = text(pick(item, "name", "label"), `Slot {s}`, 24),
					icon = iconOr(ctx, get(item, "icon"), "melee", `{path}.slots[{s}].icon`),
					ammo = integer(get(item, "ammo"), nil, 0, 9999),
					reserve = integer(get(item, "reserve"), nil, 0, 99999),
				})
			end
		end
		e.slots = slots
	elseif kind == "Crosshair" then
		e.style = enum(get(raw, "style"), { "Cross", "Dot" }, "Cross")
	elseif kind == "KeyHints" then
		local hints = {}
		for h, rawHint in limited(ctx, get(raw, "hints"), LIMITS.hints, path .. ".hints") do
			local item = unwrap(rawHint)
			local key = keyName(get(item, "key"))
			if isTable(item) and key then
				table.insert(hints, {
					text = text(pick(item, "text", "label"), "Action", 24),
					key = key,
					gamepad = keyName(get(item, "gamepad")),
				})
			elseif isTable(item) then
				warnf(ctx, `{path}.hints[{h}]`, "unknown key, dropped")
			end
		end
		e.hints = hints
	elseif kind == "Objective" then
		e.text = text(get(raw, "text"), "Find a way out", 80)
		e.label = text(get(raw, "label"), "OBJECTIVE", 20)
	elseif kind == "Fear" then
		e.intensity = number(get(raw, "intensity"), 0.3, 0, 1)
	elseif kind == "Vignette" then
		e.intensity = number(get(raw, "intensity"), 0.5, 0, 1)
		e.style = enum(get(raw, "style"), Schema.VIGNETTE_STYLES, "Soft")
	elseif kind == "Boosts" then
		local boosts = {}
		for b, rawBoost in limited(ctx, get(raw, "boosts"), LIMITS.boosts, path .. ".boosts") do
			local item = unwrap(rawBoost)
			if isTable(item) then
				table.insert(boosts, {
					icon = iconOr(ctx, get(item, "icon"), "boost", `{path}.boosts[{b}].icon`),
					text = text(pick(item, "text", "label", "name"), "2x Boost", 20),
					seconds = integer(get(item, "seconds"), 300, 0, 86400 * 7),
				})
			end
		end
		e.boosts = boosts
	elseif kind == "Clicker" then
		e.text = text(get(raw, "text"), "CLICK!", 12)
		e.currency = currencyId(ctx, get(raw, "currency"))
		e.amount = number(get(raw, "amount"), 1, 0, 1e12)
		e.icon = iconOr(ctx, get(raw, "icon"), (e.currency and ctx.currencyIcons[e.currency]) or "coin", path .. ".icon")
	elseif kind == "Timer" then
		e.label = text(get(raw, "label"), "Round ends", 20)
		e.seconds = integer(get(raw, "seconds"), 180, 0, 86400)
	elseif kind == "TeamScore" then
		local teams = {}
		for t, rawTeam in limited(ctx, get(raw, "teams"), LIMITS.teamsHud, path .. ".teams") do
			local item = unwrap(rawTeam)
			if isTable(item) then
				table.insert(teams, {
					name = text(get(item, "name"), if t == 1 then "Blue" else "Red", 16),
					color = hex(get(item, "color")) or (if t == 1 then "#3AA8FF" else "#FF4B4B"),
					score = integer(get(item, "score"), 0, 0, 1e6),
				})
			end
		end
		while #teams < 2 do
			local t = #teams + 1
			table.insert(teams, { name = if t == 1 then "Blue" else "Red", color = if t == 1 then "#3AA8FF" else "#FF4B4B", score = 0 })
		end
		e.teams = teams
		e.goal = integer(get(raw, "goal"), nil, 1, 1e6)
	elseif kind == "Button" then
		e.label = text(pick(raw, "label", "text"), "Play", 16)
		e.icon = iconOr(ctx, get(raw, "icon"), "play", path .. ".icon")
		e.tone = toneName(get(raw, "tone"), "Green")
		e.action = action(ctx, get(raw, "action"), path .. ".action")
	elseif kind == "Minimap" then
		e.shape = enum(get(raw, "shape"), { "Round", "Square" }, "Round")
	elseif kind == "BossBar" then
		e.name = text(get(raw, "name"), "BOSS", 32)
		e.max = number(get(raw, "max"), 5000, 1, 1e12)
		e.tone = toneName(get(raw, "tone"), "Red")
		e.icon = if get(raw, "icon") ~= nil then iconOr(ctx, get(raw, "icon"), "crown", path .. ".icon") else nil
	elseif kind == "MobileControls" then
		local buttons = {}
		local ids = idMaker()
		for b, rawButton in limited(ctx, get(raw, "buttons"), LIMITS.mobileButtons, path .. ".buttons") do
			local item = unwrap(rawButton)
			if isTable(item) then
				local label = text(pick(item, "label", "text"), nil, 12)
				table.insert(buttons, {
					id = ids(idOf(get(item, "id")) or slug(label), `mobile{b}`),
					label = label or "",
					icon = iconOr(ctx, get(item, "icon"), "bolt", `{path}.buttons[{b}].icon`),
					action = action(ctx, get(item, "action"), `{path}.buttons[{b}].action`),
				})
			end
		end
		e.buttons = buttons
	elseif kind == "EmoteWheel" then
		e.key = keyName(get(raw, "key")) or "G"
		local emotes = {}
		local ids = idMaker()
		for m, rawEmote in limited(ctx, get(raw, "emotes"), LIMITS.emotes, path .. ".emotes") do
			local item = unwrap(rawEmote)
			if typeof(item) == "string" then
				item = { label = item }
			end
			if isTable(item) then
				local label = text(pick(item, "label", "name"), `Emote {m}`, 14)
				table.insert(emotes, {
					id = ids(idOf(get(item, "id")) or slug(label), `emote{m}`),
					label = label,
					icon = iconOr(ctx, get(item, "icon"), "face-winking", `{path}.emotes[{m}].icon`),
				})
			end
		end
		if #emotes == 0 then
			emotes = {
				{ id = "wave", label = "Wave", icon = "hand" },
				{ id = "dance", label = "Dance", icon = "music" },
				{ id = "laugh", label = "Laugh", icon = "face-winking" },
				{ id = "cheer", label = "Cheer", icon = "megaphone-cheer" },
			}
		end
		e.emotes = emotes
	-- v1.7: sub-genre readouts
	elseif kind == "Counter" then
		e.label = text(pick(raw, "label", "title"), "LEVEL", 16)
		e.value = integer(get(raw, "value"), 0, -1e6, 1e9)
		e.goal = integer(get(raw, "goal"), nil, 1, 1e6)
		e.digits = integer(get(raw, "digits"), nil, 1, 8)
		e.suffix = text(get(raw, "suffix"), nil, 16)
		e.icon = if get(raw, "icon") ~= nil then iconOr(ctx, get(raw, "icon"), "flag", path .. ".icon") else nil
		e.style = enum(get(raw, "style"), Schema.COUNTER_STYLES, Schema.COUNTER_STYLES[1])
	elseif kind == "Meter" then
		e.label = text(pick(raw, "label", "title"), "SANITY", 16)
		e.icon = iconOr(ctx, get(raw, "icon"), "bolt", path .. ".icon")
		e.value = number(get(raw, "value"), 1, 0, 1)
		e.style = enum(get(raw, "style"), Schema.METER_STYLES, Schema.METER_STYLES[1])
		e.tone = toneName(get(raw, "tone"), nil)
		e.segments = integer(get(raw, "segments"), if e.style == "Dial" then 5 else 6, 2, 12)
		e.usage = if e.style == "Segments" then integer(get(raw, "usage"), nil, 0, 5) else nil
		e.readout = text(get(raw, "readout"), nil, 14)
		e.low = number(get(raw, "low"), 0.25, 0, 1)
	elseif kind == "TeamStatus" then
		e.title = text(get(raw, "title"), "SURVIVORS", 20)
		e.style = enum(get(raw, "style"), Schema.TEAM_STYLES, Schema.TEAM_STYLES[1])
		local members = {}
		for m, rawMember in limited(ctx, pick(raw, "members", "players", "survivors"), LIMITS.members, path .. ".members") do
			local item = unwrap(rawMember)
			if typeof(item) == "string" then
				item = { name = item }
			end
			if isTable(item) then
				table.insert(members, {
					name = text(pick(item, "name", "label"), `Player {m}`, 16),
					icon = iconOr(ctx, get(item, "icon"), PORTRAITS[((m - 1) % #PORTRAITS) + 1], `{path}.members[{m}].icon`),
					state = enum(get(item, "state"), Schema.TEAM_STATES, "Alive"),
				})
			end
		end
		if #members == 0 then
			for m, state in { "Alive", "Alive", "Downed", "Escaped" } do
				table.insert(members, { name = `Survivor {m}`, icon = PORTRAITS[m], state = state })
			end
		end
		e.members = members
		local rawKiller = get(raw, "killer")
		if isTable(rawKiller) then
			e.killer = {
				name = text(pick(rawKiller, "name", "label"), "The Killer", 20),
				icon = iconOr(ctx, get(rawKiller, "icon"), "hand", path .. ".killer.icon"),
			}
		elseif typeof(rawKiller) == "string" then
			e.killer = { name = text(rawKiller, "The Killer", 20), icon = "hand" }
		end
	elseif kind == "CameraPanel" then
		e.title = text(get(raw, "title"), "CAMERAS", 20)
		e.columns = integer(get(raw, "columns"), 3, 1, 6)
		local cameras = {}
		local ids = idMaker()
		for c, rawCamera in limited(ctx, get(raw, "cameras"), LIMITS.cameras, path .. ".cameras") do
			local item = unwrap(rawCamera)
			if typeof(item) == "string" then
				item = { name = item }
			end
			if isTable(item) then
				local label = text(get(item, "label"), tostring(c), 4)
				table.insert(cameras, {
					id = ids(idOf(get(item, "id")) or slug("cam" .. (label :: string)), `cam{c}`),
					label = label,
					name = text(pick(item, "name", "room"), `Camera {label}`, 20),
				})
			end
		end
		if #cameras == 0 then
			for _, spec in { { "1A", "Stage" }, { "1B", "Dining Hall" }, { "2A", "West Hall" }, { "2B", "West Corner" }, { "4A", "East Hall" }, { "5", "Backstage" } } do
				table.insert(cameras, { id = "cam" .. string.lower(spec[1]), label = spec[1], name = spec[2] })
			end
		end
		e.cameras = cameras
		local selected = idOf(get(raw, "selected"))
		local found = false
		for _, cam in cameras do
			found = found or cam.id == selected
		end
		e.selected = if found then selected else cameras[1].id
	elseif kind == "Camcorder" then
		e.mode = enum(get(raw, "mode"), Schema.CAMCORDER_MODES, Schema.CAMCORDER_MODES[1])
		e.label = text(get(raw, "label"), "REC", 8)
		e.date = text(get(raw, "date"), nil, 16)
		e.battery = number(get(raw, "battery"), 0.7, 0, 1)
		e.brackets = bool(get(raw, "brackets"), true)
		e.scanlines = bool(get(raw, "scanlines"), e.mode ~= "Digital")
	elseif kind == "Compass" then
		e.heading = integer(get(raw, "heading"), 0, -1e6, 1e6) % 360
		e.marker = integer(get(raw, "marker"), nil, -1e6, 1e6)
		if e.marker then
			e.marker %= 360
		end
		e.follow = bool(get(raw, "follow"), true)
	end
	-- These types always sit in fixed places.
	if kind == "Clicker" then
		e.anchor = "BottomCenter"
	elseif kind == "Crosshair" or kind == "Fear" or kind == "Vignette" or kind == "EmoteWheel" then
		e.anchor = "Center"
	elseif kind == "MobileControls" then
		e.anchor = "BottomRight"
	end
	-- v1.8: a look (one of this type's HUD kit styles) overrides the kit's;
	-- "Auto" (or "Kit", "Default", "") means the kit's own, so no look
	local rawLook = get(raw, "look")
	if typeof(rawLook) == "string" then
		local plain = string.lower((string.gsub(rawLook, "%s", "")))
		if plain == "" or plain == "auto" or plain == "kit" or plain == "default" then
			rawLook = nil
		end
	end
	if rawLook ~= nil then
		local looks = Schema.HUD_LOOKS[kind]
		local look = if looks then enum(rawLook, looks, nil) else nil
		if look then
			e.look = look
			if kind == "MenuButtons" and (look == "Column" or look == "Grid" or look == "Dock") then
				-- the original looks are the menu's style
				e.style = look
			elseif kind == "Crosshair" and (look == "Cross" or look == "Dot") then
				e.style = look
			end
		elseif looks then
			warnf(ctx, path .. ".look", `unknown {kind} look {text(rawLook, "(none)", 20)}, dropped (one of {table.concat(looks, ", ")})`)
		else
			warnf(ctx, path .. ".look", `{kind} has no looks, dropped`)
		end
	end
	return e
end

--------------------------------------------------------------------------
-- Sections
--------------------------------------------------------------------------

-- A setting's id. Built-in aliases become the built-in id ("Volume" ->
-- "sfx", "fieldOfView" -> "fov") when the built-in works as that control,
-- and one id never names two control types anywhere in the blueprint (they
-- share one saved value). The second result is true for a Toggle whose name
-- means the opposite of the built-in ("Screen Shake" -> reducedMotion).
local function settingId(ctx: Ctx, ids: (string?, string) -> string, wanted: string?, fallback: string, settingType: string, path: string): (string, boolean)
	local types = ctx.settingTypes
	local builtin, inverted = Schema.builtinSetting(wanted)
	local def = builtin and Schema.BUILTIN_SETTING_DEFS[builtin]
	local fits = builtin ~= nil
		and (wanted == builtin or (def ~= nil and table.find(def.types, settingType) ~= nil and (not inverted or settingType == "Toggle")))
	local mapped = false
	if fits and (types[builtin] == nil or types[builtin] == settingType) then
		wanted = builtin
		mapped = true
	end
	local id = ids(wanted, fallback)
	if types[id] ~= nil and types[id] ~= settingType then
		local renamed = ids(`{id}_{string.lower(settingType)}`, fallback)
		warnf(ctx, path .. ".id", `"{id}" is already a {types[id]}, renamed to "{renamed}"`)
		id = renamed
	end
	types[id] = settingType
	return id, mapped and inverted and id == builtin
end

local function sectionItems(ctx: Ctx, raw: any, key: string, limit: number, path: string): { any }
	return limited(ctx, raw, limit, `{path}.{key}`)
end

local function section(ctx: Ctx, raw: any, path: string, ids: (string?, string) -> string, fallbackIcon: string): { [string]: any }?
	if not isTable(raw) then
		warnf(ctx, path, "not a table, dropped")
		return nil
	end
	local kind = enum(pick(raw, "kind", "type"), Schema.SECTION_KIND_LIST, nil)
	if kind == nil then
		warnf(ctx, path, `unknown section kind {text(pick(raw, "kind", "type"), "(none)", 30)}, dropped`)
		return nil
	end
	local s: { [string]: any } = {
		kind = kind,
		title = text(get(raw, "title"), nil, 40),
	}
	s.id = ids(idOf(get(raw, "id")) or slug(s.title), slug(kind) :: string)

	if kind == "Products" then
		s.layout = enum(get(raw, "layout"), Schema.PRODUCT_LAYOUTS, nil) or enum(get(raw, "layout"), Schema.SECTION_LAYOUTS_V24.Products, nil)
		if s.layout == nil then
			if get(raw, "layout") ~= nil then
				warnf(ctx, path .. ".layout", `unknown layout {text(get(raw, "layout"), "?", 20)}, using Cards`)
			end
			s.layout = "Cards"
		end
		local list = {}
		local itemIds = idMaker()
		for i, entry in sectionItems(ctx, get(raw, "items"), "items", LIMITS.products, path) do
			local item = unwrap(entry)
			local ipath = `{path}.items[{i}]`
			if not isTable(item) then
				continue
			end
			local title = text(pick(item, "title", "name"), `Offer {i}`, 28)
			local currency = priceCurrency(ctx, get(item, "currency"))
			local grant = get(item, "grant")
			local product = {
				id = itemIds(idOf(get(item, "id")) or slug(title), `item{i}`),
				title = title,
				amount = text(get(item, "amount"), nil, 16),
				icon = iconOr(ctx, get(item, "icon"), fallbackIcon, ipath .. ".icon"),
				tone = toneName(get(item, "tone"), "Yellow"),
				price = number(get(item, "price"), nil, 0, 1e12) or 0,
				currency = currency,
				productId = integer(get(item, "productId"), nil, 1, 1e15),
				gamePassId = integer(get(item, "gamePassId"), nil, 1, 1e15),
				tag = text(get(item, "tag"), nil, 14),
				grant = nil :: any,
				-- v1.4: a short selling line, extra currency in percent, and the
				-- struck-through price before the discount
				description = text(pick(item, "description", "subtitle"), nil, 48),
				bonus = integer(get(item, "bonus"), nil, 0, 1000),
				was = number(get(item, "was"), nil, 0, 1e12),
				-- v2.4: the item's rarity (Grid / Showcase / Mosaic draw it as a
				-- chip and a frame) and its stat bars (Showcase)
				rarity = rarity(get(item, "rarity"), nil),
				stats = nil :: any,
			}
			local rawStats = get(item, "stats")
			if isTable(rawStats) then
				local stats = {}
				for _, rawStat in limited(ctx, rawStats, 4, ipath .. ".stats") do
					local stat = unwrap(rawStat)
					if isTable(stat) then
						table.insert(stats, { label = text(get(stat, "label"), "Stat", 12), value = number(get(stat, "value"), 0.5, 0, 1) })
					end
				end
				product.stats = if #stats > 0 then stats else nil
			end
			if product.bonus == 0 then
				product.bonus = nil
			end
			if product.was and product.was <= product.price then
				warnf(ctx, ipath .. ".was", "not above the price, dropped")
				product.was = nil
			end
			if isTable(grant) then
				local grantCurrency = resolveCurrency(ctx, get(grant, "currency"))
				local grantAmount = number(get(grant, "amount"), nil, 0, 1e15)
				if grantCurrency and grantAmount then
					product.grant = { currency = grantCurrency, amount = grantAmount }
				else
					warnf(ctx, ipath .. ".grant", "unknown currency or amount, dropped")
				end
			end
			table.insert(list, product)
		end
		s.items = list
	elseif kind == "Inventory" then
		s.columns = integer(get(raw, "columns"), 5, 2, 8)
		s.detail = bool(get(raw, "detail"), true)
		-- v1.4: nil keeps the v1 look (Side when there is a detail panel);
		-- v2.4 adds Filters and Team
		s.layout = enum(get(raw, "layout"), Schema.INVENTORY_LAYOUTS, nil) or enum(get(raw, "layout"), Schema.SECTION_LAYOUTS_V24.Inventory, nil)
		if s.layout == nil and get(raw, "layout") ~= nil then
			warnf(ctx, path .. ".layout", `unknown layout {text(get(raw, "layout"), "?", 20)}, ignored`)
		end
		-- v2.4 Team: how many can be equipped at once
		s.slots = if s.layout == "Team" then integer(get(raw, "slots"), 4, 1, LIMITS.slots) else nil
		local list = {}
		local itemIds = idMaker()
		for i, entry in sectionItems(ctx, get(raw, "items"), "items", LIMITS.items, path) do
			local item = unwrap(entry)
			if typeof(item) == "string" then
				item = { name = item }
			end
			if not isTable(item) then
				continue
			end
			local name = text(pick(item, "name", "title"), `Item {i}`, 24)
			table.insert(list, {
				id = itemIds(idOf(get(item, "id")) or slug(name), `item{i}`),
				name = name,
				icon = iconOr(ctx, get(item, "icon"), fallbackIcon, `{path}.items[{i}].icon`),
				rarity = rarity(get(item, "rarity"), "Common"),
				count = integer(get(item, "count"), nil, 0, 1e9),
				equipped = bool(get(item, "equipped"), false),
				locked = bool(get(item, "locked"), false),
				-- v2.4 Team: what it adds ("x12")
				power = text(get(item, "power"), nil, 8),
			})
		end
		s.items = list
	elseif kind == "Upgrades" then
		local list = {}
		local itemIds = idMaker()
		for i, entry in sectionItems(ctx, get(raw, "upgrades"), "upgrades", LIMITS.upgrades, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			local title = text(pick(item, "title", "name"), `Upgrade {i}`, 24)
			local maxLevel = integer(get(item, "maxLevel"), 10, 1, 1000)
			table.insert(list, {
				id = itemIds(idOf(get(item, "id")) or slug(title), `upgrade{i}`),
				title = title,
				icon = iconOr(ctx, get(item, "icon"), "upgrade", `{path}.upgrades[{i}].icon`),
				level = integer(get(item, "level"), 0, 0, maxLevel),
				maxLevel = maxLevel,
				cost = number(get(item, "cost"), 100, 0, 1e15),
				currency = priceCurrency(ctx, get(item, "currency")),
				effect = text(get(item, "effect"), nil, 40),
			})
		end
		s.upgrades = list
	elseif kind == "Daily" then
		-- v1.4: Row (nil = the v1 row) | Calendar (up to 28 days) | Ladder;
		-- v2.4 Path
		s.layout = enum(get(raw, "layout"), Schema.DAILY_LAYOUTS, nil) or enum(get(raw, "layout"), Schema.SECTION_LAYOUTS_V24.Daily, nil)
		if s.layout == nil and get(raw, "layout") ~= nil then
			warnf(ctx, path .. ".layout", `unknown layout {text(get(raw, "layout"), "?", 20)}, ignored`)
		end
		local list = {}
		local dayLimit = if s.layout == "Calendar" then LIMITS.days else LIMITS.daysRow
		for i, entry in sectionItems(ctx, get(raw, "days"), "days", dayLimit, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			table.insert(list, {
				day = integer(get(item, "day"), i, 1, 365),
				icon = iconOr(ctx, get(item, "icon"), "gift", `{path}.days[{i}].icon`),
				amount = text(get(item, "amount"), nil, 12),
				label = text(get(item, "label"), nil, 16),
			})
		end
		if #list == 0 then
			for i = 1, 7 do
				table.insert(list, { day = i, icon = if i == 7 then "gem" else "coin", amount = tostring(i * 100), label = nil })
			end
		end
		s.days = list
		-- v1.4: days already claimed when the UI opens (a streak in progress)
		s.streak = integer(get(raw, "streak"), nil, 0, math.max(0, #list - 1))
		if s.streak == 0 then
			s.streak = nil
		end
	elseif kind == "Quests" then
		local list = {}
		local itemIds = idMaker()
		for i, entry in sectionItems(ctx, get(raw, "quests"), "quests", LIMITS.quests, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			local title = text(pick(item, "title", "name"), `Quest {i}`, 32)
			local goal = integer(get(item, "goal"), 1, 1, 1e12)
			table.insert(list, {
				id = itemIds(idOf(get(item, "id")) or slug(title), `quest{i}`),
				title = title,
				description = text(get(item, "description"), nil, 80),
				icon = iconOr(ctx, get(item, "icon"), "quest", `{path}.quests[{i}].icon`),
				goal = goal,
				progress = integer(get(item, "progress"), 0, 0, goal),
				reward = rewardSpec(ctx, get(item, "reward"), `{path}.quests[{i}].reward`),
			})
		end
		s.quests = list
	elseif kind == "Pass" then
		local list = {}
		for i, entry in sectionItems(ctx, get(raw, "tiers"), "tiers", LIMITS.tiers, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			table.insert(list, {
				tier = integer(get(item, "tier"), i, 1, 1000),
				free = rewardSpec(ctx, get(item, "free"), `{path}.tiers[{i}].free`),
				premium = rewardSpec(ctx, get(item, "premium"), `{path}.tiers[{i}].premium`),
			})
		end
		s.tiers = list
		s.premiumProductId = integer(get(raw, "premiumProductId"), nil, 1, 1e15)
		s.premiumGamePassId = integer(get(raw, "premiumGamePassId"), nil, 1, 1e15)
	elseif kind == "Leaderboard" then
		s.stat = text(get(raw, "stat"), "Wins", 20)
		local list = {}
		for i, entry in sectionItems(ctx, get(raw, "rows"), "rows", LIMITS.rows, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			table.insert(list, {
				rank = integer(get(item, "rank"), i, 1, 1e6),
				name = text(get(item, "name"), `Player{i}`, 20),
				value = text(get(item, "value"), "0", 14),
			})
		end
		s.rows = list
	elseif kind == "Scoreboard" then
		local columns = {}
		for _, c in limited(ctx, get(raw, "columns"), LIMITS.columns, path .. ".columns") do
			local label = text(c, nil, 8)
			if label then
				table.insert(columns, label)
			end
		end
		if #columns == 0 then
			columns = { "K", "D", "Score" }
		end
		s.columns = columns
		local teams = {}
		for t, entry in sectionItems(ctx, get(raw, "teams"), "teams", LIMITS.teams, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			local rows = {}
			for r, rawRow in limited(ctx, get(item, "rows"), LIMITS.teamRows, `{path}.teams[{t}].rows`) do
				local row = unwrap(rawRow)
				if isTable(row) then
					local values = {}
					for c = 1, #columns do
						values[c] = number(get(get(row, "values"), c), 0, -1e9, 1e12)
					end
					table.insert(rows, { name = text(get(row, "name"), `Player{r}`, 20), values = values })
				end
			end
			table.insert(teams, {
				name = text(get(item, "name"), `Team {t}`, 16),
				color = hex(get(item, "color")) or (if t == 1 then "#3AA8FF" else "#FF4B4B"),
				rows = rows,
			})
		end
		s.teams = teams
	elseif kind == "Loadout" then
		local list = {}
		for i, entry in sectionItems(ctx, get(raw, "slots"), "slots", LIMITS.loadout, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			local stats = {}
			for _, rawStat in limited(ctx, get(item, "stats"), 4, `{path}.slots[{i}].stats`) do
				local stat = unwrap(rawStat)
				if isTable(stat) then
					table.insert(stats, { label = text(get(stat, "label"), "Stat", 12), value = number(get(stat, "value"), 0.5, 0, 1) })
				end
			end
			table.insert(list, {
				name = text(pick(item, "name", "title"), `Slot {i}`, 20),
				icon = iconOr(ctx, get(item, "icon"), fallbackIcon, `{path}.slots[{i}].icon`),
				rarity = rarity(get(item, "rarity"), "Common"),
				stats = stats,
			})
		end
		s.slots = list
	elseif kind == "Stats" or kind == "Profile" then
		local list = {}
		for i, entry in sectionItems(ctx, get(raw, "stats"), "stats", LIMITS.stats, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			table.insert(list, {
				label = text(get(item, "label"), "Stat", 20),
				icon = iconOr(ctx, get(item, "icon"), "star", `{path}.stats[{i}].icon`),
				value = text(get(item, "value"), "0", 16),
				bar = number(get(item, "bar"), nil, 0, 1),
			})
		end
		s.stats = list
		if kind == "Profile" then
			local badges = {}
			for b, entry in limited(ctx, get(raw, "badges"), LIMITS.badges, path .. ".badges") do
				local item = unwrap(entry)
				if isTable(item) then
					table.insert(badges, {
						icon = iconOr(ctx, get(item, "icon"), "medal", `{path}.badges[{b}].icon`),
						label = text(get(item, "label"), "Badge", 16),
					})
				end
			end
			s.badges = badges
			s.name = text(get(raw, "name"), nil, 20)
			s.level = integer(get(raw, "level"), nil, 0, 1e6)
		end
	elseif kind == "Settings" then
		local list = {}
		local itemIds = idMaker()
		for i, entry in sectionItems(ctx, get(raw, "settings"), "settings", LIMITS.settings, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			local givenLabel = text(get(item, "label"), nil, 24)
			local settingType = enum(get(item, "type"), { "Toggle", "Slider", "Choice" }, "Toggle")
			local spath = `{path}.settings[{i}]`
			local id, inverted = settingId(ctx, itemIds, idOf(get(item, "id")) or slug(givenLabel or `Setting {i}`), `setting{i}`, settingType, spath)
			-- a built-in fills what the entry leaves out (v1.5)
			local builtin = Schema.BUILTIN_SETTING_DEFS[id]
			local label = givenLabel or (builtin and builtin.label) or `Setting {i}`
			if inverted and builtin then
				-- "Screen Shake" (on = shake) becomes "Reduce Motion" (on = calm)
				if givenLabel ~= builtin.label then
					warnf(ctx, spath .. ".label", `"{givenLabel or id}" is the {id} setting turned around; now "{builtin.label}"`)
				end
				label = builtin.label
			end
			local setting: { [string]: any } = {
				id = id,
				label = label,
				icon = iconOr(ctx, get(item, "icon"), if builtin then builtin.icon else "settings", spath .. ".icon"),
				type = settingType,
			}
			if settingType == "Toggle" then
				local fallback = true
				if builtin then
					fallback = if typeof(builtin.default) == "boolean" then builtin.default else builtin.toggle ~= false
				end
				if inverted then
					setting.default = not bool(get(item, "default"), not fallback)
				else
					setting.default = bool(get(item, "default"), fallback)
				end
			elseif settingType == "Slider" then
				local low = if builtin and typeof(builtin.min) == "number" then builtin.min else 0
				local high = if builtin and typeof(builtin.max) == "number" then builtin.max else 100
				setting.min = number(get(item, "min"), low, -1e6, 1e6)
				setting.max = number(get(item, "max"), high, -1e6, 1e6)
				if setting.max <= setting.min then
					setting.max = setting.min + 1
				end
				local fallback = setting.max
				if builtin and typeof(builtin.default) == "number" and builtin.default >= setting.min and builtin.default <= setting.max then
					fallback = builtin.default
				end
				setting.default = number(get(item, "default"), fallback, setting.min, setting.max)
			else
				local options = {}
				local colorblind = id == "colorblind"
				for _, o in limited(ctx, get(item, "options"), LIMITS.options, spath .. ".options") do
					local label2 = text(o, nil, 12)
					if colorblind then
						-- colourblind modes are the fixed palettes ("Red-Green" -> Deuteranopia)
						label2 = Schema.colorblindMode(label2)
					end
					if label2 and not table.find(options, label2) then
						table.insert(options, label2)
					end
				end
				if colorblind then
					table.sort(options, function(a, b)
						return (table.find(Schema.COLORBLIND_MODES, a) or 0) < (table.find(Schema.COLORBLIND_MODES, b) or 0)
					end)
					if options[1] ~= "Off" then
						table.insert(options, 1, "Off")
					end
				end
				if #options < 2 then
					options = if colorblind then table.clone(Schema.COLORBLIND_MODES) else { "Low", "Medium", "High" }
				end
				setting.options = options
				local wanted = text(get(item, "default"), nil, 12)
				if colorblind then
					wanted = Schema.colorblindMode(wanted)
				end
				-- a quality choice left without a default starts on its best option
				local first = if id == "lowDetail" then options[#options] else options[1]
				setting.default = if wanted and table.find(options, wanted) then wanted else first
			end
			table.insert(list, setting)
		end
		s.settings = list
	elseif kind == "Banner" then
		s.title = text(get(raw, "title"), "Limited Offer", 32)
		s.subtitle = text(get(raw, "subtitle"), nil, 80)
		s.icon = iconOr(ctx, get(raw, "icon"), fallbackIcon, path .. ".icon")
		s.tone = toneName(get(raw, "tone"), "Purple")
		local button = get(raw, "button")
		if isTable(button) or typeof(button) == "string" then
			s.button = {
				label = text(if typeof(button) == "string" then button else pick(button, "label", "text"), "Open", 16),
				action = action(ctx, get(button, "action"), path .. ".button.action"),
			}
		end
		s.timer = integer(get(raw, "timer"), nil, 1, 86400 * 30)
	elseif kind == "Buttons" then
		s.layout = enum(get(raw, "layout"), { "Row", "Column", "Grid" }, "Row")
		local list = {}
		local itemIds = idMaker()
		for i, entry in sectionItems(ctx, get(raw, "buttons"), "buttons", LIMITS.buttons, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			local label = text(pick(item, "label", "text"), `Button {i}`, 18)
			table.insert(list, {
				id = itemIds(idOf(get(item, "id")) or slug(label), `button{i}`),
				label = label,
				icon = if get(item, "icon") ~= nil then iconOr(ctx, get(item, "icon"), "play", `{path}.buttons[{i}].icon`) else nil,
				tone = toneName(get(item, "tone"), if i == 1 then "Green" else "Blue"),
				action = action(ctx, get(item, "action"), `{path}.buttons[{i}].action`),
			})
		end
		s.buttons = list
	elseif kind == "Codes" then
		s.placeholder = text(get(raw, "placeholder"), "Enter code...", 32)
		s.button = text(if typeof(get(raw, "button")) == "table" then get(get(raw, "button"), "label") else get(raw, "button"), "Redeem", 12)
		local act = action(ctx, get(raw, "action"), path .. ".action")
		if act.kind ~= "Remote" and act.kind ~= "Event" then
			act = { kind = "Remote", name = "RedeemCode" }
		end
		s.action = act
	elseif kind == "Text" then
		s.body = text(pick(raw, "body", "text"), "", LIMITS.text)
	elseif kind == "Cases" then
		local list = {}
		local itemIds = idMaker()
		for i, entry in sectionItems(ctx, get(raw, "cases"), "cases", LIMITS.cases, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			local title = text(pick(item, "title", "name"), `Case {i}`, 24)
			local pool = {}
			for p, rawPool in limited(ctx, get(item, "pool"), LIMITS.pool, `{path}.cases[{i}].pool`) do
				local drop = unwrap(rawPool)
				if isTable(drop) then
					table.insert(pool, {
						name = text(pick(drop, "name", "title"), `Drop {p}`, 24),
						icon = iconOr(ctx, get(drop, "icon"), "star", `{path}.cases[{i}].pool[{p}].icon`),
						rarity = rarity(get(drop, "rarity"), "Common"),
						chance = number(get(drop, "chance"), 1, 0, 100),
					})
				end
			end
			if #pool == 0 then
				pool = {
					{ name = "Common Drop", icon = "star", rarity = "Common", chance = 60 },
					{ name = "Rare Drop", icon = "gem", rarity = "Rare", chance = 30 },
					{ name = "Legendary Drop", icon = "crown", rarity = "Legendary", chance = 10 },
				}
			end
			table.insert(list, {
				id = itemIds(idOf(get(item, "id")) or slug(title), `case{i}`),
				title = title,
				icon = iconOr(ctx, get(item, "icon"), "cube", `{path}.cases[{i}].icon`),
				tone = toneName(get(item, "tone"), "Blue"),
				price = number(get(item, "price"), 100, 0, 1e12),
				currency = priceCurrency(ctx, get(item, "currency")),
				productId = integer(get(item, "productId"), nil, 1, 1e15),
				pool = pool,
			})
		end
		s.cases = list
		-- v2.4: Tiers | Banner (nil = the v1 card grid); a Banner's countdown
		s.layout = enum(get(raw, "layout"), Schema.SECTION_LAYOUTS_V24.Cases, nil)
		if s.layout == nil and get(raw, "layout") ~= nil then
			warnf(ctx, path .. ".layout", `unknown layout {text(get(raw, "layout"), "?", 20)}, ignored`)
		end
		s.timer = integer(get(raw, "timer"), nil, 1, 86400 * 30)
	elseif kind == "Worlds" then
		local list = {}
		local itemIds = idMaker()
		for i, entry in sectionItems(ctx, get(raw, "worlds"), "worlds", LIMITS.worlds, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			local name = text(pick(item, "name", "title"), `World {i}`, 20)
			table.insert(list, {
				id = itemIds(idOf(get(item, "id")) or slug(name), `world{i}`),
				name = name,
				icon = iconOr(ctx, get(item, "icon"), "world", `{path}.worlds[{i}].icon`),
				tone = toneName(get(item, "tone"), Schema.MENU_TONES[((i - 1) % #Schema.MENU_TONES) + 1]),
				requirement = text(get(item, "requirement"), nil, 28),
				locked = bool(get(item, "locked"), false),
			})
		end
		s.worlds = list
	elseif kind == "Party" then
		s.slots = integer(get(raw, "slots"), 4, 1, LIMITS.partySlots)
	elseif kind == "Keybinds" then
		local list = {}
		for i, entry in sectionItems(ctx, get(raw, "binds"), "binds", LIMITS.binds, path) do
			local item = unwrap(entry)
			if isTable(item) then
				local key = keyName(get(item, "key"))
				if key then
					table.insert(list, {
						action = text(pick(item, "action", "label"), `Action {i}`, 24),
						key = key,
						gamepad = keyName(get(item, "gamepad")),
					})
				else
					warnf(ctx, `{path}.binds[{i}]`, "unknown key, dropped")
				end
			end
		end
		s.binds = list
	elseif kind == "Spin" then
		-- v1.6: a prize wheel. UI.Server rolls it; chances are percentages
		-- that are made to add up to 100.
		local list = {}
		local itemIds = idMaker()
		for i, entry in sectionItems(ctx, get(raw, "wedges"), "wedges", LIMITS.wedges, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			local wpath = `{path}.wedges[{i}]`
			local prize = prizeSpec(ctx, item, wpath, "gift") :: { [string]: any }
			local label = text(pick(item, "label", "name", "title"), nil, 20)
			table.insert(list, {
				id = itemIds(idOf(get(item, "id")) or slug(label or prize.amount), `wedge{i}`),
				label = label,
				icon = prize.icon,
				amount = prize.amount or "1",
				rarity = rarity(get(item, "rarity"), "Common"),
				tone = toneName(get(item, "tone"), nil),
				chance = number(get(item, "chance"), 1, 0, 100),
				grant = prize.grant,
			})
		end
		if #list > 0 and #list < Schema.WEDGES_MIN then
			warnf(ctx, path .. ".wedges", `{#list} wedges, repeated to make {Schema.WEDGES_MIN}`)
			local count = #list
			local i = 1
			while #list < Schema.WEDGES_MIN do
				local copy = table.clone(list[((i - 1) % count) + 1])
				copy.id = itemIds(copy.id, `wedge{#list + 1}`)
				table.insert(list, copy)
				i += 1
			end
		end
		if #list == 0 then
			local coin = ctx.currencyIds[1]
			local gem = ctx.currencyIds[2] or coin
			local plan = {
				{ 100, "Common", 28 }, { 250, "Common", 24 }, { 500, "Uncommon", 18 }, { 1000, "Rare", 12 },
				{ 10, "Epic", 9, true }, { 2500, "Rare", 6 }, { 50, "Legendary", 2.5, true }, { 100, "Mythic", 0.5, true },
			}
			for i, p in plan do
				local currency = if p[4] then gem else coin
				table.insert(list, {
					id = `wedge{i}`,
					icon = if currency then ctx.currencyIcons[currency] else "gift",
					amount = commaText(p[1]),
					rarity = p[2],
					chance = p[3],
					grant = if currency then { currency = currency, amount = p[1] } else nil,
				})
			end
		end
		local total = 0
		for _, w in list do
			total += w.chance
		end
		if total <= 0 then
			for _, w in list do
				w.chance = 1
			end
			total = #list
		end
		if math.abs(total - 100) > 0.05 then
			if total ~= #list then
				warnf(ctx, path .. ".wedges", `chances add up to {math.floor(total * 100 + 0.5) / 100}, scaled to 100`)
			end
			local sum, biggest = 0, list[1]
			for _, w in list do
				w.chance = math.floor(w.chance / total * 10000 + 0.5) / 100
				sum += w.chance
				if w.chance > biggest.chance then
					biggest = w
				end
			end
			-- rounding error goes to the most likely wedge, so it adds up exactly
			biggest.chance = math.floor((biggest.chance + 100 - sum) * 100 + 0.5) / 100
		end
		s.wedges = list
		s.freeHours = number(get(raw, "freeHours"), Schema.SPIN_FREE_HOURS, 0, 168)
		s.price = number(get(raw, "price"), nil, 0, 1e12)
		if s.price == 0 then
			s.price = nil
		end
		s.currency = if s.price then priceCurrency(ctx, get(raw, "currency")) else nil
		s.productId = integer(get(raw, "productId"), nil, 1, 1e15)
		if s.price and s.currency == "robux" and s.productId == nil then
			warnf(ctx, path .. ".productId", "a Robux spin needs a developer product id; the button says Not for sale yet")
		end
		s.luckGamePassId = integer(get(raw, "luckGamePassId"), nil, 1, 1e15)
		s.luckPrice = number(get(raw, "luckPrice"), nil, 0, 1e9)
		if s.freeHours == 0 and s.price == nil then
			warnf(ctx, path, `no free spin and no price: the wheel can't be spun, so it gets a free spin every {Schema.SPIN_FREE_HOURS} hours`)
			s.freeHours = Schema.SPIN_FREE_HOURS
		end
		-- a paid spin that pays back more than it costs could be spun for profit
		local payback = Schema.spinReturn(s)
		if payback and payback > Schema.SPIN_MAX_RETURN then
			warnf(ctx, path .. ".price", `a paid spin pays back {math.floor(payback * 100 + 0.5)}% of its price in {s.currency} on average (at most {math.floor(Schema.SPIN_MAX_RETURN * 100)}%): UI.Server turns paid spins off until the price goes up or the {s.currency} wedges go down`)
		end
	elseif kind == "Gifts" then
		-- v1.6: playtime gifts, opened after `minutes` of this session
		local list = {}
		local itemIds = idMaker()
		local seen: { [number]: boolean } = {}
		for i, entry in sectionItems(ctx, get(raw, "gifts"), "gifts", LIMITS.gifts, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			local gpath = `{path}.gifts[{i}]`
			local minutes = number(pick(item, "minutes", "minute", "at"), nil, 0.5, 720)
			if minutes == nil then
				warnf(ctx, gpath .. ".minutes", "missing, dropped")
				continue
			end
			if seen[minutes] then
				warnf(ctx, gpath .. ".minutes", `two gifts at {minutes} min, kept the first`)
				continue
			end
			seen[minutes] = true
			local prize = prizeSpec(ctx, item, gpath, "gift") :: { [string]: any }
			table.insert(list, {
				id = itemIds(idOf(get(item, "id")) or `gift{i}`, `gift{i}`),
				minutes = minutes,
				icon = prize.icon,
				amount = prize.amount or "1",
				label = text(get(item, "label"), nil, 16),
				rarity = rarity(get(item, "rarity"), "Common"),
				grant = prize.grant,
			})
		end
		table.sort(list, function(a, b)
			return a.minutes < b.minutes
		end)
		if #list == 0 then
			local coin = ctx.currencyIds[1]
			for i, minutes in { 1, 3, 5, 10, 15, 20, 30, 45 } do
				local amount = minutes * 50
				table.insert(list, {
					id = `gift{i}`,
					minutes = minutes,
					icon = if coin then ctx.currencyIcons[coin] else "gift",
					amount = commaText(amount),
					rarity = if minutes >= 30 then "Epic" elseif minutes >= 10 then "Rare" else "Common",
					grant = if coin then { currency = coin, amount = amount } else nil,
				})
			end
		end
		s.gifts = list
		-- v2.4: Chests (nil = the v1.6 gift grid)
		s.layout = enum(get(raw, "layout"), Schema.SECTION_LAYOUTS_V24.Gifts, nil)
		if s.layout == nil and get(raw, "layout") ~= nil then
			warnf(ctx, path .. ".layout", `unknown layout {text(get(raw, "layout"), "?", 20)}, ignored`)
		end
	elseif kind == "Index" then
		-- v1.6: the collection book. Items are grouped (by rarity, world...);
		-- `owned` only seeds the preview until the game says what's owned.
		local groups = {}
		local groupIds = idMaker()
		local itemIds = idMaker()
		local itemCount = 0
		for g, entry in limited(ctx, get(raw, "groups"), LIMITS.indexGroups, path .. ".groups") do
			local item, key = unwrap(entry)
			if not isTable(item) then
				continue
			end
			local gpath = `{path}.groups[{g}]`
			local name = text(pick(item, "name", "title", "label"), key or `Group {g}`, 20) :: string
			local items = {}
			for i, rawItem in limited(ctx, get(item, "items"), LIMITS.indexItems, gpath .. ".items") do
				local it = unwrap(rawItem)
				if typeof(it) == "string" then
					it = { name = it }
				end
				if not isTable(it) or itemCount >= LIMITS.indexItems then
					continue
				end
				local itemName = text(pick(it, "name", "title"), `Item {i}`, 24) :: string
				itemCount += 1
				table.insert(items, {
					id = itemIds(idOf(get(it, "id")) or slug(itemName), `item{itemCount}`),
					name = itemName,
					icon = iconOr(ctx, get(it, "icon"), fallbackIcon, `{gpath}.items[{i}].icon`),
					rarity = rarity(get(it, "rarity"), "Common"),
					owned = bool(get(it, "owned"), false),
				})
			end
			if #items > 0 then
				table.insert(groups, {
					id = groupIds(idOf(get(item, "id")) or slug(name), `group{g}`),
					name = name,
					icon = iconOr(ctx, get(item, "icon"), "book-open", gpath .. ".icon"),
					tone = toneName(get(item, "tone"), nil),
					items = items,
				})
			end
		end
		if #groups == 0 then
			warnf(ctx, path .. ".groups", "no items, added a placeholder group")
			table.insert(groups, { id = "all", name = "Collection", icon = "book-open", items = {
				{ id = "item1", name = "Item", icon = fallbackIcon, rarity = "Common", owned = true },
			} })
		end
		s.groups = groups
		s.groupBy = text(get(raw, "groupBy"), nil, 12)
		local milestones = {}
		local seenPercent: { [number]: boolean } = {}
		for m, entry in limited(ctx, get(raw, "milestones"), LIMITS.milestones, path .. ".milestones") do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			local percent = integer(get(item, "percent"), nil, 1, 100)
			if percent == nil or seenPercent[percent] then
				warnf(ctx, `{path}.milestones[{m}]`, "needs a percent 1..100 of its own, dropped")
				continue
			end
			seenPercent[percent] = true
			local prize = prizeSpec(ctx, item, `{path}.milestones[{m}]`, "gift") :: { [string]: any }
			table.insert(milestones, { percent = percent, icon = prize.icon, amount = prize.amount or "1", label = text(get(item, "label"), nil, 16), grant = prize.grant })
		end
		if #milestones == 0 then
			local gem = ctx.currencyIds[2] or ctx.currencyIds[1]
			for m, percent in Schema.INDEX_MILESTONES do
				local amount = ({ 25, 75, 200, 500 })[m]
				table.insert(milestones, {
					percent = percent,
					icon = if gem then ctx.currencyIcons[gem] else "gift",
					amount = commaText(amount),
					grant = if gem then { currency = gem, amount = amount } else nil,
				})
			end
		end
		table.sort(milestones, function(a, b)
			return a.percent < b.percent
		end)
		s.milestones = milestones
	elseif kind == "Achievements" then
		-- v1.6: progress comes from the player attribute BloxUI_Ach_<id>,
		-- which the game sets on the server; `progress` is the preview value
		local list = {}
		local itemIds = idMaker()
		for i, entry in sectionItems(ctx, get(raw, "achievements"), "achievements", LIMITS.achievements, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			local apath = `{path}.achievements[{i}]`
			local title = text(pick(item, "title", "name"), `Achievement {i}`, 28) :: string
			local goal = integer(get(item, "goal"), 1, 1, 1e12) :: number
			if idOf(get(item, "id")) == nil then
				-- claims are saved by id: a renamed title would open it again
				warnf(ctx, apath .. ".id", `no id: "{title}" gets one from its title, so renaming it lets players claim it again; give it a fixed id`)
			end
			local id = ctx.achievementIds(idOf(get(item, "id")) or slug(title), `ach{i}`)
			table.insert(list, {
				id = id,
				title = title,
				description = text(get(item, "description"), nil, 60),
				icon = iconOr(ctx, get(item, "icon"), "trophy", apath .. ".icon"),
				goal = goal,
				progress = integer(get(item, "progress"), 0, 0, goal),
				points = integer(get(item, "points"), 10, 0, 1e6),
				reward = prizeSpec(ctx, get(item, "reward"), apath .. ".reward", "gift"),
			})
		end
		s.achievements = list
	elseif kind == "Social" then
		-- v1.6: Group rewards are checked with IsInGroup on the server; a Like
		-- can't be checked (a one-time thank-you); Invite pays when a friend joins
		s.groupId = integer(get(raw, "groupId"), nil, 1, 1e15)
		local list = {}
		local itemIds = idMaker()
		local kinds: { [string]: boolean } = {}
		for i, entry in sectionItems(ctx, get(raw, "rewards"), "rewards", LIMITS.socialRewards, path) do
			local item = unwrap(entry)
			if not isTable(item) then
				continue
			end
			local rpath = `{path}.rewards[{i}]`
			local socialKind = enum(pick(item, "kind", "type"), Schema.SOCIAL_KINDS, nil)
			if socialKind == nil then
				warnf(ctx, rpath .. ".kind", "must be Group, Like or Invite, dropped")
				continue
			end
			if kinds[socialKind] then
				warnf(ctx, rpath .. ".kind", `a second {socialKind} reward, dropped`)
				continue
			end
			-- (a Group reward without the section's groupId shows, but can't be
			-- claimed until the developer sets it, like a product without its id)
			kinds[socialKind] = true
			local defaults = { Group = { "Join the Group", "three-people" }, Like = { "Like the Game", "thumb-up" }, Invite = { "Invite a Friend", "person-plus" } }
			table.insert(list, {
				id = itemIds(idOf(get(item, "id")) or string.lower(socialKind), string.lower(socialKind)),
				kind = socialKind,
				label = text(pick(item, "label", "title"), defaults[socialKind][1], 24),
				icon = iconOr(ctx, get(item, "icon"), defaults[socialKind][2], rpath .. ".icon"),
				reward = prizeSpec(ctx, get(item, "reward"), rpath .. ".reward", "gift"),
				wait = if socialKind == "Like" then integer(get(item, "wait"), 60, 0, 3600) else nil,
				limit = if socialKind == "Invite" then integer(get(item, "limit"), 10, 1, 100) else nil,
			})
		end
		s.rewards = list
	end
	return s
end

local function sections(ctx: Ctx, raw: any, path: string, fallbackIcon: string): { any }
	local out = {}
	-- one id space for the whole blueprint: UI.Server finds items by
	-- "<section id>/<item id>", so two sections must never share an id
	local ids = ctx.sectionIds
	for index, entry in limited(ctx, raw, LIMITS.sections, path) do
		local s = section(ctx, (unwrap(entry)), `{path}[{index}]`, ids, fallbackIcon)
		if s then
			table.insert(out, s)
		end
	end
	return out
end

--------------------------------------------------------------------------
-- Screens
--------------------------------------------------------------------------

-- First pass: ids and titles only, so actions anywhere can target any screen.
local function screenHeaders(ctx: Ctx, raw: any)
	local ids = idMaker()
	local list = limited(ctx, raw, LIMITS.screens, "screens")
	local headers = {}
	for index, entry in list do
		local item, key = unwrap(entry)
		if not isTable(item) then
			headers[index] = false
			continue
		end
		local title = text(pick(item, "title", "name"), nil, 28)
		local id = ids(idOf(get(item, "id")) or idOf(key) or slug(title), `screen{index}`)
		headers[index] = { id = id, title = title or (string.upper(string.sub(id, 1, 1)) .. string.gsub(string.sub(id, 2), "_", " ")) }
		table.insert(ctx.screenIds, id)
		ctx.screenSet[id] = true
		ctx.screenTitles[id] = headers[index].title
	end
	return list, headers
end

local function screen(ctx: Ctx, raw: any, header: { [string]: any }, index: number): { [string]: any }
	local path = `screens[{index}]`
	local kind = enum(pick(raw, "kind", "type"), Schema.SCREEN_KIND_LIST, nil)
	if kind == nil then
		if pick(raw, "kind", "type") ~= nil then
			warnf(ctx, path, `unknown screen kind {text(pick(raw, "kind", "type"), "?", 20)}, using Window`)
		end
		kind = "Window"
	end
	local screenIcon = iconOr(ctx, get(raw, "icon"), icon(header.title) or icon(header.id) or "star", path .. ".icon")
	local s: { [string]: any } = {
		id = header.id,
		title = header.title,
		icon = screenIcon,
		kind = kind,
		size = enum(get(raw, "size"), Schema.SCREEN_SIZES, nil), -- nil: the builder picks from the content
		nav = enum(get(raw, "nav"), Schema.NAVS, nil),
		openKey = keyName(get(raw, "openKey")),
		description = text(get(raw, "description"), nil, 200),
	}
	-- tabs
	local tabs = {}
	local tabIds = idMaker()
	for t, entry in limited(ctx, get(raw, "tabs"), LIMITS.tabs, path .. ".tabs") do
		local item, key = unwrap(entry)
		local tpath = `{path}.tabs[{t}]`
		if not isTable(item) then
			continue
		end
		local label = text(pick(item, "label", "title", "name"), nil, 14)
		local id = tabIds(idOf(get(item, "id")) or idOf(key) or slug(label), `tab{t}`)
		local tabIcon = iconOr(ctx, get(item, "icon"), icon(label) or screenIcon, tpath .. ".icon")
		table.insert(tabs, {
			id = id,
			label = label or header.title,
			icon = tabIcon,
			badge = if typeof(get(item, "badge")) == "number" then integer(get(item, "badge"), nil, 0, 99) else text(get(item, "badge"), nil, 4),
			callout = text(get(item, "callout"), nil, 40),
			sections = sections(ctx, get(item, "sections"), tpath .. ".sections", tabIcon),
		})
	end
	-- one bobbing callout per screen (docs/RESEARCH.md): the first one wins
	local calloutSeen = false
	for _, tab in tabs do
		if tab.callout then
			if calloutSeen then
				tab.callout = nil
			end
			calloutSeen = true
		end
	end
	s.tabs = tabs
	s.sections = sections(ctx, get(raw, "sections"), path .. ".sections", screenIcon)
	if #tabs > 0 and #s.sections > 0 then
		-- loose sections next to tabs become one more tab
		table.insert(tabs, 1, { id = tabIds("main", "main"), label = header.title, icon = screenIcon, sections = s.sections })
		s.sections = {}
		if #tabs > LIMITS.tabs then
			table.remove(tabs)
		end
	end
	if s.nav == nil then
		s.nav = if #tabs > 1 then (if #tabs > 4 then "SideTabs" else "Tabs") else "None"
	end
	if s.nav == "None" and #tabs > 0 then
		-- no tab bar: flatten every tab's sections into the page
		for _, tab in tabs do
			for _, sec in tab.sections do
				if #s.sections < LIMITS.sections then
					table.insert(s.sections, sec)
				end
			end
		end
		s.tabs = {}
	elseif s.nav ~= "None" and #tabs == 0 then
		s.nav = "None"
	end

	-- v1.4 structure and chrome (all optional: nil builds the v1.3 look)
	if kind == "MainMenu" then
		local rawLayout = get(raw, "layout")
		s.layout = enum(rawLayout, Schema.MENU_LAYOUTS, nil)
		if s.layout == nil and rawLayout ~= nil then
			warnf(ctx, path .. ".layout", `unknown menu layout {text(rawLayout, "?", 20)}, using Left`)
		end
		local rawBanner = pick(raw, "bannerPosition", "banner")
		if typeof(rawBanner) == "string" then
			local allowed = Schema.MENU_BANNER[s.layout or "Left"]
			local position = enum(rawBanner, Schema.BANNER_POSITIONS, nil)
			if position and table.find(allowed, position) then
				s.bannerPosition = position
			else
				warnf(ctx, path .. ".bannerPosition", `{text(rawBanner, "?", 20)} doesn't suit the {s.layout or "Left"} layout, using {allowed[1]}`)
			end
		end
	elseif kind == "Window" or kind == "Fullscreen" or kind == "Popup" then
		for _, key in { "transition", "panelStyle" } do
			local list = if key == "transition" then Schema.SCREEN_TRANSITIONS else Schema.SCREEN_PANELS
			local given = get(raw, key)
			s[key] = enum(given, list, nil)
			if s[key] == nil and given ~= nil then
				warnf(ctx, `{path}.{key}`, `unknown value {text(given, "?", 20)}, using the theme's`)
			end
		end
		local scrim = get(raw, "scrim")
		if scrim == false then
			s.scrim = 0
		elseif scrim ~= nil and scrim ~= true then
			s.scrim = number(scrim, nil, 0, 100)
			if s.scrim and s.scrim > 1 then
				s.scrim /= 100 -- "40" means 40 %
			end
			if s.scrim == nil then
				warnf(ctx, path .. ".scrim", "not a number 0..1, using the theme's")
			end
		end
		-- v2.4: where a Window sits (Schema.SCREEN_DOCKS; style families only)
		local rawDock = get(raw, "dock")
		if kind == "Window" and rawDock ~= nil then
			s.dock = enum(rawDock, Schema.SCREEN_DOCKS, nil)
			if s.dock == nil then
				warnf(ctx, path .. ".dock", `unknown dock {text(rawDock, "?", 20)}, using Center`)
			elseif s.dock == "Center" then
				s.dock = nil
			end
		end
	end

	if kind == "Loading" then
		local tips = {}
		for _, tip in limited(ctx, get(raw, "tips"), LIMITS.tips, path .. ".tips") do
			local line = text(tip, nil, 90)
			if line then
				table.insert(tips, line)
			end
		end
		if #tips == 0 then
			tips = { "Tip: open the shop to see today's deals!", "Tip: invite friends for bonus rewards." }
		end
		s.tips = tips
		s.duration = number(get(raw, "duration"), 3, 0.5, 30)
	elseif kind == "Results" then
		s.result = enum(get(raw, "result"), Schema.RESULTS, "Victory")
		local stats = {}
		for _, entry in limited(ctx, get(raw, "stats"), LIMITS.resultStats, path .. ".stats") do
			local item = unwrap(entry)
			if isTable(item) then
				table.insert(stats, { label = text(get(item, "label"), "Stat", 16), value = text(get(item, "value"), "0", 12) })
			end
		end
		s.stats = stats
		local rewards = {}
		for r, entry in limited(ctx, get(raw, "rewards"), LIMITS.rewards, path .. ".rewards") do
			local reward = rewardSpec(ctx, unwrap(entry), `{path}.rewards[{r}]`)
			if reward then
				table.insert(rewards, reward)
			end
		end
		s.rewards = rewards
		local buttons = {}
		local buttonIds = idMaker()
		for b, entry in limited(ctx, get(raw, "buttons"), 3, path .. ".buttons") do
			local item = unwrap(entry)
			if isTable(item) then
				local label = text(pick(item, "label", "text"), "Continue", 16)
				table.insert(buttons, {
					id = buttonIds(idOf(get(item, "id")) or slug(label), `button{b}`),
					label = label,
					icon = if get(item, "icon") ~= nil then iconOr(ctx, get(item, "icon"), "play", `{path}.buttons[{b}].icon`) else nil,
					tone = toneName(get(item, "tone"), if b == 1 then "Green" else "Gray"),
					action = action(ctx, get(item, "action"), `{path}.buttons[{b}].action`),
				})
			end
		end
		if #buttons == 0 then
			buttons = { { id = "continue", label = "Continue", icon = "play", tone = "Green", action = { kind = "Close" } } }
		end
		s.buttons = buttons
	elseif kind == "Trade" then
		s.currency = if get(raw, "currency") ~= nil then currencyId(ctx, get(raw, "currency")) else nil
	elseif kind == "Offer" then
		-- v1.6: a limited-time offer / starter pack, bought with Robux through
		-- the usual Purchase / GamePass prompts and UI.Server's receipts
		s.subtitle = text(get(raw, "subtitle"), nil, 60)
		s.tone = toneName(get(raw, "tone"), "Purple")
		s.tag = text(get(raw, "tag"), nil, 16)
		s.productId = integer(get(raw, "productId"), nil, 1, 1e15)
		s.gamePassId = integer(get(raw, "gamePassId"), nil, 1, 1e15)
		if s.productId and s.gamePassId then
			warnf(ctx, path, "an offer sells a developer product or a game pass, not both; kept the product")
			s.gamePassId = nil
		end
		local items = {}
		for i, entry in limited(ctx, get(raw, "items"), LIMITS.offerItems, path .. ".items") do
			local item = unwrap(entry)
			if typeof(item) == "string" then
				item = { label = item }
			end
			if not isTable(item) then
				continue
			end
			local prize = prizeSpec(ctx, item, `{path}.items[{i}]`, "gift") :: { [string]: any }
			if prize.grant and s.gamePassId then
				warnf(ctx, `{path}.items[{i}].grant`, "a game pass is never processed as a purchase, so it can't grant currency; dropped")
				prize.grant = nil
			end
			table.insert(items, {
				icon = prize.icon,
				amount = prize.amount,
				label = text(pick(item, "label", "name", "title"), nil, 24),
				grant = prize.grant,
			})
		end
		if #items == 0 then
			-- (shown as a placeholder, never sold: nothing would be delivered)
			warnf(ctx, path .. ".items", if s.productId
				then "an offer without contents can't be sold: added a placeholder and dropped its productId"
				else "an offer without contents, added a placeholder")
			table.insert(items, { icon = "gift", amount = "1", label = "Mystery Gift" })
			s.productId = nil
		end
		s.items = items
		s.price = number(get(raw, "price"), 0, 0, 1e9)
		s.was = number(get(raw, "was"), nil, 0, 1e9)
		if s.was and s.was <= s.price then
			warnf(ctx, path .. ".was", "not above the price, dropped")
			s.was = nil
		end
		s.timer = integer(get(raw, "timer"), nil, 0, 86400 * 30)
		if s.timer == 0 then
			s.timer = nil
		end
		s.show = enum(get(raw, "show"), Schema.OFFER_SHOW, nil)
		if s.show == nil then
			if get(raw, "show") ~= nil then
				warnf(ctx, path .. ".show", "must be Session, Once, Joins or Manual, using Session")
			end
			s.show = "Session"
		end
		s.every = if s.show == "Joins" then integer(get(raw, "every"), 3, 2, 100) else nil
		s.delay = number(get(raw, "delay"), 4, 0, 120)
		if s.productId and (s.show == "Once" or s.timer) then
			-- a developer product can be prompted again by any script
			warnf(ctx, path .. ".productId", "a one-time or timed offer sold as a developer product can be bought again by a script; UI.Server pays a re-purchase at its honest value (price / was). A game pass can only be bought once")
		end
		s.sections, s.tabs, s.nav = {}, {}, "None"
	elseif kind == "OfflineEarnings" then
		-- v1.6: UI.Server works this out from the saved last-seen time
		s.currency = resolveCurrency(ctx, get(raw, "currency"))
		if s.currency == nil then
			if get(raw, "currency") ~= nil then
				warnf(ctx, path .. ".currency", "unknown currency, using the first one")
			end
			s.currency = ctx.currencyIds[1]
		end
		s.rate = number(get(raw, "rate"), 10, 0, 1e12)
		s.capHours = number(get(raw, "capHours"), Schema.OFFLINE_CAP_HOURS, 0.25, 72)
		s.minMinutes = number(get(raw, "minMinutes"), Schema.OFFLINE_MIN_MINUTES, 1, 1440)
		s.doubleProductId = integer(get(raw, "doubleProductId"), nil, 1, 1e15)
		s.doublePrice = number(get(raw, "doublePrice"), nil, 0, 1e9)
		s.sections, s.tabs, s.nav = {}, {}, "None"
	end
	return s
end

--------------------------------------------------------------------------
-- Guarantees
--------------------------------------------------------------------------

local function collectTargets(value: any, out: { [string]: boolean }, depth: number)
	if depth > 12 or typeof(value) ~= "table" then
		return
	end
	if (value.kind == "Open" or value.kind == "Toggle") and typeof(value.target) == "string" then
		out[value.target] = true
	end
	for _, inner in value do
		if typeof(inner) == "table" then
			collectTargets(inner, out, depth + 1)
		end
	end
end

local function guaranteeHud(ctx: Ctx, bp: { [string]: any })
	local hud = bp.hud
	local layoutInfo = LAYOUT_ANCHORS[hud.layout]
	-- makes room for an essential element when the HUD is full
	local function makeRoom()
		while #hud.elements >= LIMITS.hudElements do
			local removed = false
			for i = #hud.elements, 1, -1 do
				local kind = hud.elements[i].type
				if kind ~= "CurrencyBar" and kind ~= "MenuButtons" then
					table.remove(hud.elements, i)
					removed = true
					break
				end
			end
			if not removed then
				break
			end
		end
	end

	-- currencies are always visible
	local hasBar = false
	for _, e in hud.elements do
		if e.type == "CurrencyBar" then
			hasBar = true
		end
	end
	if not hasBar and #bp.currencies > 0 then
		makeRoom()
		table.insert(hud.elements, 1, {
			type = "CurrencyBar",
			id = ctx.elementIds("currencies", "currencies"),
			anchor = layoutInfo.CurrencyBar,
			visible = true,
			currencies = table.clone(ctx.currencyIds),
			showAdd = false,
			addAction = { kind = "None" },
		})
		warnf(ctx, "hud", "added a CurrencyBar for the blueprint's currencies")
	end

	-- every window-like screen can be reached
	local reachable: { [string]: boolean } = {}
	collectTargets(hud, reachable, 0)
	collectTargets(bp.screens, reachable, 0)
	if bp.startScreen then
		reachable[bp.startScreen] = true
	end
	local missing = {}
	for _, s in bp.screens do
		if Schema.WINDOW_KINDS[s.kind] and not reachable[s.id] then
			table.insert(missing, s)
		end
	end
	if #missing == 0 then
		return
	end
	local menu = nil
	for _, e in hud.elements do
		-- (a teleport row takes no menu buttons)
		if e.type == "MenuButtons" and e.role ~= "teleports" then
			menu = e
			break
		end
	end
	if menu == nil then
		makeRoom()
		menu = {
			type = "MenuButtons",
			id = ctx.elementIds("menu", "menu"),
			anchor = layoutInfo.MenuButtons,
			visible = true,
			style = layoutInfo.menuStyle,
			buttons = {},
		}
		table.insert(hud.elements, menu)
	end
	local ids = {}
	for _, b in menu.buttons do
		ids[b.id] = true
	end
	local function uniqueButtonId(base: string): string
		local id = base
		while ids[id] do
			id ..= "_btn"
		end
		ids[id] = true
		return id
	end
	local entries = {}
	for _, s in missing do
		table.insert(entries, {
			id = uniqueButtonId(s.id),
			label = truncate(s.title, 16),
			icon = s.icon,
			action = { kind = "Open", target = s.id },
		})
		warnf(ctx, "hud", `added a menu button for screen "{s.id}"`)
	end
	if #menu.buttons + #entries > LIMITS.menuButtons then
		-- too many for the menu: the last slot opens a "More" screen with the rest
		local keep = LIMITS.menuButtons - 1
		while #menu.buttons > keep do
			table.insert(entries, 1, table.remove(menu.buttons) :: any)
		end
		while #menu.buttons < keep and #entries > 0 do
			local entry = table.remove(entries, 1) :: any
			entry.tone = entry.tone or Schema.MENU_TONES[(#menu.buttons % #Schema.MENU_TONES) + 1]
			table.insert(menu.buttons, entry)
		end
		local hubId = "more"
		local n = 2
		while ctx.screenSet[hubId] do
			hubId = `more_{n}`
			n += 1
		end
		local sectionsList = {}
		for i = 1, #entries, LIMITS.buttons do
			local chunk = {}
			for j = i, math.min(#entries, i + LIMITS.buttons - 1) do
				local entry = entries[j]
				table.insert(chunk, {
					id = entry.id,
					label = entry.label,
					icon = entry.icon,
					tone = Schema.MENU_TONES[((j - 1) % #Schema.MENU_TONES) + 1],
					action = entry.action,
				})
			end
			if #sectionsList < LIMITS.sections then
				table.insert(sectionsList, { kind = "Buttons", id = `more{#sectionsList + 1}`, layout = "Grid", buttons = chunk })
			end
		end
		table.insert(bp.screens, {
			id = hubId,
			title = "More",
			icon = "more",
			kind = "Window",
			size = "Medium",
			nav = "None",
			tabs = {},
			sections = sectionsList,
		})
		ctx.screenSet[hubId] = true
		table.insert(ctx.screenIds, hubId)
		ctx.screenTitles[hubId] = "More"
		table.insert(menu.buttons, {
			id = uniqueButtonId(hubId),
			label = "More",
			icon = "more",
			tone = "Gray",
			action = { kind = "Open", target = hubId },
		})
		warnf(ctx, "hud", `menu is full, added a "More" screen for {#entries} screen(s)`)
		return
	end
	for _, entry in entries do
		entry.tone = Schema.MENU_TONES[(#menu.buttons % #Schema.MENU_TONES) + 1]
		table.insert(menu.buttons, entry)
	end
end

--------------------------------------------------------------------------
-- v2.3: switching HUD layouts
--------------------------------------------------------------------------

-- The elements a HUD layout arranges: the first CurrencyBar (the wallet)
-- and the first MenuButtons that isn't a teleport row (the menu). Reads raw
-- (editor) elements too.
local function arrangedElements(elements: any): (any, any)
	local wallet, menu = nil, nil
	for _, entry in items(elements) do
		local e = unwrap(entry)
		local kind = enum(pick(e, "type", "kind"), Schema.HUD_TYPE_LIST, nil)
		if kind == "CurrencyBar" and wallet == nil then
			wallet = e
		elseif kind == "MenuButtons" and menu == nil and enum(get(e, "role"), Schema.MENU_ROLES, nil) == nil then
			menu = e
		end
	end
	return wallet, menu
end

-- hud.arranged: the layout the HUD was last arranged for, and where that
-- left the wallet and the menu (and the menu's style).
local function arrangement(layout: string, elements: any): { [string]: any }
	local wallet, menu = arrangedElements(elements)
	return {
		layout = layout,
		CurrencyBar = enum(get(wallet, "anchor"), Schema.ANCHORS, nil),
		MenuButtons = enum(get(menu, "anchor"), Schema.ANCHORS, nil),
		menuStyle = enum(get(menu, "style"), Schema.MENU_STYLES, nil),
	}
end

local function readArrangement(raw: any): { [string]: any }?
	local layout = enum(get(raw, "layout"), Schema.HUD_LAYOUTS, nil)
	if layout == nil then
		return nil
	end
	return {
		layout = layout,
		CurrencyBar = enum(get(raw, "CurrencyBar"), Schema.ANCHORS, nil),
		MenuButtons = enum(get(raw, "MenuButtons"), Schema.ANCHORS, nil),
		menuStyle = enum(get(raw, "menuStyle"), Schema.MENU_STYLES, nil),
	}
end

-- Moves the wallet and the menu from where the `from` arrangement left them
-- to where `layout` puts them; the menu's style follows while it's still the
-- old layout's, and an original look picked for the old spot (a Dock look on
-- a bottom menu) goes with it. Anything moved since stays put, and teleport
-- rows never move. Returns true when something moved.
local function relayout(elements: any, from: { [string]: any }, layout: string): boolean
	local spots = LAYOUT_ANCHORS[layout]
	local wallet, menu = arrangedElements(elements)
	local moved = false
	if wallet and from.CurrencyBar and get(wallet, "anchor") == from.CurrencyBar then
		wallet.anchor = spots.CurrencyBar
		moved = true
	end
	if menu and from.MenuButtons and get(menu, "anchor") == from.MenuButtons then
		menu.anchor = spots.MenuButtons
		if from.menuStyle and get(menu, "style") == from.menuStyle then
			menu.style = spots.menuStyle
			if get(menu, "look") == from.menuStyle then
				menu.look = nil
			end
		end
		moved = true
	end
	return moved
end

--[[
	Switches an editable blueprint's HUD to another layout, in place: the
	wallet and the menu move from where the HUD was arranged (hud.arranged,
	else where they are now) to where `layout` puts them, and hud.layout /
	hud.arranged follow. Teleport rows and everything else keep their
	anchors. Returns false for an unknown layout. (Blueprint.setLayout)
]]
function Normalize.setLayout(blueprint: any, layout: any): boolean
	local target = enum(layout, Schema.HUD_LAYOUTS, nil)
	if target == nil or not isTable(blueprint) then
		return false
	end
	local hud = get(blueprint, "hud")
	if not isTable(hud) then
		hud = { elements = {} }
		blueprint.hud = hud
	end
	local elements = get(hud, "elements")
	local from = readArrangement(get(hud, "arranged"))
		or arrangement(enum(get(hud, "layout"), Schema.HUD_LAYOUTS, "Classic") :: string, elements)
	if from.layout ~= target then
		relayout(elements, from, target)
	end
	hud.layout = target
	hud.arranged = arrangement(target, elements)
	return true
end

-- v1.8 meta.kit: the HUD kit the generator chose, kept so the UI Builder's
-- "Auto" can go back to it after you try others. "None" = it chose no kit.
-- Informational only (hud.kit is what builds); anything else is dropped.
local function metaKit(raw: any): string?
	if typeof(raw) ~= "string" then
		return nil
	end
	local kit = HudKits.canonical(raw)
	if kit then
		return kit
	end
	local plain = string.lower(raw)
	return if plain == "none" or plain == "classic" or plain == "original" then "None" else nil
end

--------------------------------------------------------------------------
-- Entry points
--------------------------------------------------------------------------

local function run(input: any): ({ [string]: any }, { string })
	local ctx: Ctx = {
		warnings = {},
		screenIds = {},
		screenSet = {},
		screenTitles = {},
		currencySet = {},
		currencyIds = {},
		currencyIcons = {},
		elementIds = idMaker(),
		sectionIds = idMaker(),
		achievementIds = idMaker(),
		settingTypes = {},
	}
	if not isTable(input) then
		warnf(ctx, "blueprint", `expected a table, got {typeof(input)}`)
		input = {}
	end

	local rawMeta = get(input, "meta")
	local title = text(pick(rawMeta, "title") or get(input, "title"), "My Game", 40) :: string
	local genre = enum(pick(rawMeta, "genre") or get(input, "genre"), Schema.GENRES, "casual") :: string
	local bp: { [string]: any } = {
		version = Schema.VERSION,
		meta = {
			title = title,
			genre = genre,
			prompt = text(get(rawMeta, "prompt"), nil, 2000),
			seed = integer(get(rawMeta, "seed"), nil, -1e15, 1e15),
			source = enum(get(rawMeta, "source"), { "offline", "ai", "manual" }, "manual"),
			-- v1.7: the sub-genre the generator committed to ("hotel", "backrooms"...)
			flavor = slug(get(rawMeta, "flavor"), nil),
			-- v1.8: the HUD kit the generator chose (the builder's "Auto")
			kit = metaKit(get(rawMeta, "kit")),
		},
	}
	bp.style = style(ctx, get(input, "style"), genre)
	bp.music = music(ctx, get(input, "music"))
	bp.logo = logo(ctx, get(input, "logo"), title)
	bp.currencies = currencies(ctx, get(input, "currencies"))

	local rawScreens, headers = screenHeaders(ctx, get(input, "screens"))
	local screens = {}
	for index, entry in rawScreens do
		local header = headers[index]
		if header then
			table.insert(screens, screen(ctx, (unwrap(entry)), header, index))
		else
			warnf(ctx, `screens[{index}]`, "not a table, dropped")
		end
	end
	bp.screens = screens

	local rawHud = get(input, "hud")
	local layout = enum(get(rawHud, "layout"), Schema.HUD_LAYOUTS, "Classic") :: string
	-- v1.8: the HUD kit (any case / spelling; "Auto", "None" and "Classic" mean none)
	local kit = nil
	local rawKit = get(rawHud, "kit")
	if typeof(rawKit) == "string" then
		kit = HudKits.canonical(rawKit)
		local plain = string.lower((string.gsub(rawKit, "%s", "")))
		if kit == nil and plain ~= "" and plain ~= "auto" and plain ~= "none" and plain ~= "classic" and plain ~= "original" then
			warnf(ctx, "hud.kit", `unknown HUD kit {text(rawKit, "(none)", 24)}, ignored (one of {table.concat(Schema.HUD_KITS, ", ")})`)
		end
	elseif rawKit ~= nil then
		warnf(ctx, "hud.kit", "not a kit name, ignored")
	end
	local elements = {}
	for index, entry in limited(ctx, get(rawHud, "elements") or (if isTable(rawHud) and rawget(rawHud, 1) then rawHud else nil), LIMITS.hudElements, "hud.elements") do
		local e = hudElement(ctx, (unwrap(entry)), index, layout)
		if e then
			table.insert(elements, e)
		end
	end
	-- one of each overlay is plenty
	local seen: { [string]: boolean } = {}
	for i = #elements, 1, -1 do
		local kind = elements[i].type
		if kind == "Crosshair" or kind == "Clicker" or kind == "Fear" or kind == "EmoteWheel" or kind == "MobileControls" or kind == "BossBar"
			or kind == "Camcorder" or kind == "CameraPanel" or kind == "Compass" then
			if seen[kind] then
				warnf(ctx, "hud.elements", `more than one {kind}, kept the first`)
			end
			seen[kind] = true
		end
	end
	local kept, once = {}, {}
	for _, e in elements do
		if seen[e.type] then
			if once[e.type] then
				continue
			end
			once[e.type] = true
		end
		table.insert(kept, e)
	end
	bp.hud = { layout = layout, kit = kit, elements = kept }
	-- v2.3: the layout changed since the HUD was last arranged (the editor set
	-- hud.layout): what still sits where the old layout put it moves
	local arranged = readArrangement(get(rawHud, "arranged"))
	if arranged and arranged.layout ~= layout then
		relayout(kept, arranged, layout)
	end

	local start = get(input, "startScreen")
	if start ~= nil then
		bp.startScreen = matchScreen(ctx, start)
		if bp.startScreen == nil then
			warnf(ctx, "startScreen", "no such screen, ignored")
		end
	end
	if bp.startScreen == nil then
		for _, s in screens do
			if s.kind == "Loading" or s.kind == "MainMenu" then
				bp.startScreen = s.id
				break
			end
		end
	end

	-- nothing at all to show: give players at least a settings menu
	if #screens == 0 and #kept == 0 and #bp.currencies == 0 then
		local id = "settings"
		table.insert(ctx.screenIds, id)
		ctx.screenSet[id] = true
		ctx.screenTitles[id] = "Settings"
		table.insert(screens, screen(ctx, {
			kind = "Window",
			size = "Medium",
			sections = {
				{ kind = "Settings", title = "Audio", settings = {
					{ id = "music", label = "Music", icon = "music", type = "Toggle", default = true },
					{ id = "sfx", label = "Sound Effects", icon = "sound", type = "Toggle", default = true },
				} },
			},
		}, { id = id, title = "Settings" }, 1))
		warnf(ctx, "blueprint", "empty blueprint, added a Settings screen")
	end

	guaranteeHud(ctx, bp)
	bp.hud.arranged = arrangement(layout, bp.hud.elements)
	return bp, ctx.warnings
end

-- Never throws. Returns a complete blueprint plus a list of warnings.
function Normalize.run(input: any): ({ [string]: any }, { string })
	local ok, bp, warnings = pcall(run, input)
	if ok then
		return bp, warnings
	end
	local fallback, more = run({})
	table.insert(more, 1, `blueprint: could not be read ({tostring(bp)}), using an empty blueprint`)
	return fallback, more
end

-- The smallest valid blueprint.
function Normalize.default(): { [string]: any }
	return (Normalize.run({}))
end

return Normalize
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="46">
            <Properties>
              <string name="Name">Rewards</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Blueprint.Rewards: the client side of the v1.6 reward systems (Spin,
	Gifts, Index, Achievements and Social sections; Offer and OfflineEarnings
	screens). Every reward section gets one shared state, so the HUD's
	notification badges know what can be claimed before a window is opened.

		local rewards = Rewards.attach(root, ui, { preview = false, player = player, clock = clock })
		rewards.sections[sectionId]          -- the live state of one reward section / screen
		rewards:claimable(screenId)          -- Computed number (HUD and tab badges)
		rewards:claim(sectionId, item, mode, function(ok, reason, data) end)
		rewards:payout(prize, fromGui)       -- the reward flies into its counter
		rewards:start()                      -- offers / offline earnings pop up by themselves

	Live games: the truth is in player attributes UI.Server sets
	(Blueprint.Schema.ATTRIBUTES) and every claim asks the server
	("BloxUI.Claim"); the client only shows and asks. Preview builds (the
	plugin) and games without UI.Server play locally: gifts count this UI's
	own playtime, spins roll here, and only client-side ("none") currencies
	are paid.
]]

local HttpService = game:GetService("HttpService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Bind = require(script.Parent.Parent.Util.Bind)
local Format = require(script.Parent.Parent.Util.Format)
local Schema = require(script.Parent.Schema)
local Actions = require(script.Parent.Actions)

local peek = Fusion.peek
local ATTR = Schema.ATTRIBUTES

local Rewards = {}

-- Reward section kinds (the rest of the kinds are plain content).
Rewards.KINDS = Schema.set({ "Spin", "Gifts", "Index", "Achievements", "Social" })

local function bitOn(mask: number, index: number): boolean
	return bit32.btest(mask, bit32.lshift(1, index - 1))
end
Rewards.bitOn = bitOn

local function withBit(mask: number, index: number): number
	return bit32.bor(mask, bit32.lshift(1, index - 1))
end

local function number(value: any, default: number): number
	return if typeof(value) == "number" and value == value then value else default
end

-- "3h 12m", "45m", "30s"
function Rewards.duration(seconds: number): string
	seconds = math.max(0, math.floor(seconds))
	local hours = seconds // 3600
	local minutes = (seconds % 3600) // 60
	if hours > 0 then
		return if minutes > 0 then `{hours}h {minutes}m` else `{hours}h`
	elseif minutes > 0 then
		return `{minutes}m`
	end
	return `{seconds}s`
end

-- A countdown for a gift / spin / offer: "1:02:05", or "2d 4h" past a day.
function Rewards.countdown(seconds: number): string
	seconds = math.max(0, math.floor(seconds))
	if seconds >= 86400 then
		return `{seconds // 86400}d {(seconds % 86400) // 3600}h`
	end
	return Format.time(seconds)
end

-- Weighted pick by `chance` (local spins: previews and games without a server).
-- `luck` doubles Epic and rarer wedges, like the server.
function Rewards.roll(wedges: { any }, luck: boolean?): number
	local total = 0
	local weights = {}
	for index, wedge in wedges do
		local weight = math.max(0, number(wedge.chance, 0))
		if luck and (Schema.RARITY_RANK[wedge.rarity] or 1) >= 4 then
			weight *= 2
		end
		weights[index] = weight
		total += weight
	end
	if total <= 0 then
		return math.random(1, #wedges)
	end
	local roll = math.random() * total
	local last = 1
	for index, weight in weights do
		if weight > 0 then
			last = index
			roll -= weight
			if roll <= 0 then
				return index
			end
		end
	end
	return last
end

export type Options = {
	preview: boolean,
	player: Player?,
	clock: any, -- Value<number>: os.time(), ticking every second
}

function Rewards.attach(scope: any, ui: any, options: Options): { [string]: any }
	local bp = ui.blueprint
	local preview = options.preview
	local player = options.player
	local clock = options.clock
	local live = not preview and player ~= nil
	local started = os.time()
	local alive = true
	table.insert(scope, function()
		alive = false
	end)

	-- a server-set attribute when live, else a local Value
	local function attribute(name: string, default: any): any
		if live then
			return Bind.fromAttribute(scope, player :: Player, name, default)
		end
		return scope:Value(default)
	end

	local rewards: { [string]: any } = { sections = {}, where = {}, offers = {}, offline = {} }
	ui.rewards = rewards

	-- The server's clock as this client sees it (workspace:GetServerTimeNow
	-- where it exists): cooldowns, gift countdowns and offer timers are the
	-- server's unix times, so they read the same on every device.
	local offset = 0
	if live then
		local ok, serverNow = pcall(function()
			return (workspace :: any):GetServerTimeNow()
		end)
		if ok and typeof(serverNow) == "number" then
			offset = math.floor(serverNow - os.time() + 0.5)
		end
	end
	local now = scope:Computed(function(use)
		return use(clock) + offset
	end)
	rewards.now = now
	-- When this session started: the server's join time (BloxUI_Joined), else
	-- when this UI was built. Previews and demos may set it to show gifts ready.
	rewards.sessionStart = attribute(ATTR.joined, started)
	-- ui:setOwned(ids): what the player owns, for Index sections (display only;
	-- UI.Server.setIndexOwned is what claims are checked against)
	local ownedLocal = scope:Value(nil)
	local ownedAttribute = if live then attribute(ATTR.owned, nil) else nil
	local ownedSet = scope:Computed(function(use)
		local set = {}
		local given = use(ownedLocal)
		local fromServer = if ownedAttribute then use(ownedAttribute) else nil
		if typeof(fromServer) == "string" and given == nil then
			local ok, list = pcall(function()
				return HttpService:JSONDecode(fromServer)
			end)
			if ok and typeof(list) == "table" then
				for _, id in list do
					if typeof(id) == "string" then
						set[id] = true
					end
				end
				return set
			end
		end
		if given then
			return given
		end
		return nil -- nothing said: the blueprint's own `owned` flags
	end)
	function ui.setOwned(_self, ids: { any })
		local set = {}
		for _, id in if typeof(ids) == "table" then ids else {} do
			if typeof(id) == "string" then
				set[id] = true
			end
		end
		ownedLocal:set(set)
	end

	------------------------------------------------------------------------
	-- One state per reward section / screen
	------------------------------------------------------------------------

	local function spinState(s: any): { [string]: any }
		local state = { kind = "Spin", def = s }
		state.nextFree = attribute(ATTR.spinNext .. s.id, 0)
		state.luck = attribute(ATTR.spinLuck .. s.id, false)
		state.spinning = scope:Value(false)
		state.result = scope:Value(nil) -- { index, token }: the wheel plays it
		state.freeReady = scope:Computed(function(use)
			return s.freeHours > 0 and use(now) >= number(use(state.nextFree), 0)
		end)
		state.claimable = scope:Computed(function(use)
			return if use(state.freeReady) and not use(state.spinning) then 1 else 0
		end)
		return state
	end

	local function giftsState(s: any): { [string]: any }
		local state = { kind = "Gifts", def = s }
		state.mask = attribute(ATTR.gifts .. s.id, 0)
		state.playtime = scope:Computed(function(use)
			return math.max(0, use(now) - number(use(rewards.sessionStart), started))
		end)
		state.readyCount = scope:Computed(function(use)
			local mask = number(use(state.mask), 0)
			local playtime = use(state.playtime)
			local count = 0
			for index, gift in s.gifts do
				if not bitOn(mask, index) and playtime >= gift.minutes * 60 then
					count += 1
				end
			end
			return count
		end)
		state.claimable = state.readyCount
		return state
	end

	local function indexState(s: any): { [string]: any }
		local state = { kind = "Index", def = s }
		local total = 0
		for _, group in s.groups do
			total += #group.items
		end
		state.total = total
		state.claimedMask = attribute(ATTR.indexClaimed .. s.id, 0)
		state.serverCount = if live then attribute(ATTR.indexCount .. s.id, nil) else nil
		-- item id -> owned (the server's list, ui:setOwned, else the blueprint's flags)
		state.owned = scope:Computed(function(use)
			local set = use(ownedSet)
			if set then
				return set
			end
			local flags = {}
			for _, group in s.groups do
				for _, item in group.items do
					if item.owned then
						flags[item.id] = true
					end
				end
			end
			return flags
		end)
		state.found = scope:Computed(function(use)
			local count = if state.serverCount then use(state.serverCount) else nil
			if typeof(count) == "number" then
				return math.clamp(math.floor(count), 0, total)
			end
			local owned = use(state.owned)
			local found = 0
			for _, group in s.groups do
				for _, item in group.items do
					if owned[item.id] then
						found += 1
					end
				end
			end
			return found
		end)
		state.claimable = scope:Computed(function(use)
			local mask = number(use(state.claimedMask), 0)
			local found = use(state.found)
			local count = 0
			for index, milestone in s.milestones do
				if not bitOn(mask, index) and total > 0 and found * 100 >= milestone.percent * total then
					count += 1
				end
			end
			return count
		end)
		return state
	end

	local function achievementsState(s: any): { [string]: any }
		local state = { kind = "Achievements", def = s, progress = {}, done = {} }
		for _, a in s.achievements do
			-- live: the game sets BloxUI_Ach_<id> on the server; preview: the blueprint's value
			state.progress[a.id] = attribute(ATTR.achievement .. a.id, if live then 0 else a.progress)
			state.done[a.id] = attribute(ATTR.achievementDone .. a.id, nil)
		end
		state.claimable = scope:Computed(function(use)
			local count = 0
			for _, a in s.achievements do
				if number(use(state.progress[a.id]), 0) >= a.goal and use(state.done[a.id]) == nil then
					count += 1
				end
			end
			return count
		end)
		state.score = scope:Computed(function(use)
			local score, total = 0, 0
			for _, a in s.achievements do
				total += a.points
				if use(state.done[a.id]) ~= nil then
					score += a.points
				end
			end
			return { score = score, total = total }
		end)
		return state
	end

	local function socialState(s: any): { [string]: any }
		local state = { kind = "Social", def = s }
		state.mask = attribute(ATTR.social .. s.id, 0)
		state.invites = attribute(ATTR.invites .. s.id, 0)
		state.inGroup = scope:Value(preview)
		state.canInvite = scope:Value(preview)
		if live and s.groupId then
			task.spawn(function()
				local ok, member = pcall(function()
					return (player :: any):IsInGroup(s.groupId)
				end)
				if alive then
					state.inGroup:set(ok and member == true)
				end
			end)
		end
		if live then
			task.spawn(function()
				local ok, allowed = pcall(function()
					return game:GetService("SocialService"):CanSendGameInviteAsync(player)
				end)
				if alive then
					state.canInvite:set(ok and allowed == true)
				end
			end)
		end
		state.claimable = scope:Computed(function(use)
			local mask = number(use(state.mask), 0)
			for index, r in s.rewards do
				if r.kind == "Group" and use(state.inGroup) and not bitOn(mask, index) and not preview then
					return 1
				end
			end
			return 0
		end)
		return state
	end

	local function offerState(screen: any): { [string]: any }
		local state = { kind = "Offer", def = screen }
		state.owned = attribute(ATTR.offerOwned .. screen.id, false)
		-- ends for this player: the server's clock (first seen + timer), else this session's
		state.endsAt = attribute(ATTR.offerEnds .. screen.id, if screen.timer then started + screen.timer else nil)
		state.available = scope:Computed(function(use)
			local ends = use(state.endsAt)
			return use(state.owned) ~= true and (ends == nil or use(now) < ends)
		end)
		state.claimable = scope:Computed(function(use)
			return if use(state.available) then 1 else 0
		end)
		return state
	end

	local function offlineState(screen: any): { [string]: any }
		local state = { kind = "OfflineEarnings", def = screen }
		-- preview: an example of what a player would see after 3h 12m away
		local exampleSeconds = math.min(3 * 3600 + 12 * 60, screen.capHours * 3600)
		state.amount = attribute(ATTR.offlineAmount .. screen.id, if live then 0 else math.floor(screen.rate * exampleSeconds / 60))
		state.seconds = attribute(ATTR.offlineSeconds .. screen.id, if live then 0 else exampleSeconds)
		state.claimable = scope:Computed(function(use)
			return if number(use(state.amount), 0) > 0 then 1 else 0
		end)
		return state
	end

	local BUILDERS = {
		Spin = spinState,
		Gifts = giftsState,
		Index = indexState,
		Achievements = achievementsState,
		Social = socialState,
	}

	for _, screen in bp.screens do
		if screen.kind == "Offer" then
			local state = offerState(screen)
			rewards.sections[screen.id] = state
			rewards.where[screen.id] = { screen = screen.id }
			table.insert(rewards.offers, screen.id)
		elseif screen.kind == "OfflineEarnings" then
			local state = offlineState(screen)
			rewards.sections[screen.id] = state
			rewards.where[screen.id] = { screen = screen.id }
			table.insert(rewards.offline, screen.id)
		end
		local lists = { { tab = nil, sections = screen.sections } }
		for _, tab in screen.tabs or {} do
			table.insert(lists, { tab = tab.id, sections = tab.sections })
		end
		for _, list in lists do
			for _, s in list.sections or {} do
				local build = BUILDERS[s.kind]
				if build and rewards.sections[s.id] == nil then
					rewards.sections[s.id] = build(s)
					rewards.where[s.id] = { screen = screen.id, tab = list.tab }
				end
			end
		end
	end

	-- How many rewards wait on a screen (or one tab of it): HUD / tab badges.
	local badgeCache: { [string]: any } = {}
	function rewards.claimable(_self, screenId: string, tabId: string?): any
		local key = screenId .. "/" .. tostring(tabId)
		if badgeCache[key] then
			return badgeCache[key]
		end
		local states = {}
		for id, where in rewards.where do
			if where.screen == screenId and (tabId == nil or where.tab == tabId) then
				table.insert(states, rewards.sections[id])
			end
		end
		local computed = nil
		if #states > 0 then
			computed = scope:Computed(function(use)
				local total = 0
				for _, state in states do
					total += use(state.claimable)
				end
				return total
			end)
		end
		badgeCache[key] = computed or false
		return computed
	end

	------------------------------------------------------------------------
	-- Claims
	------------------------------------------------------------------------

	local function isClientCurrency(currency: string?): boolean
		local def = currency and ui.currencyDefs[currency]
		return def ~= nil and def.source.kind == "none"
	end

	-- Everything a local claim would pay can be paid without a server.
	local function payableHere(prizes: { any }): boolean
		if preview then
			return true
		end
		for _, prize in prizes do
			if typeof(prize) == "table" and prize.grant and not isClientCurrency(prize.grant.currency) then
				return false
			end
		end
		return true
	end

	-- The same rules as UI.Server, played on this client (previews, and games
	-- without UI.Server where every reward is client-side).
	local LOCAL = {}

	LOCAL.Spin = function(state, _item, mode)
		local s = state.def
		if mode == "free" then
			if not peek(state.freeReady) then
				return false, "cooldown"
			end
		elseif mode == "paid" then
			-- (a paid spin that pays back too much is off: Schema.SPIN_MAX_RETURN)
			if s.price == nil or s.currency == "robux" or (Schema.spinReturn(s) or 0) > Schema.SPIN_MAX_RETURN then
				return false, "unknown"
			end
			local value = ui.currencies[s.currency]
			if value == nil or (peek(value) or 0) < s.price then
				return false, "funds"
			end
		else
			return false, "unknown"
		end
		if not payableHere(s.wedges) then
			return false, "offline"
		end
		if mode == "paid" then
			local value = ui.currencies[s.currency]
			value:set(peek(value) - s.price)
		else
			state.nextFree:set(peek(now) + s.freeHours * 3600)
		end
		return true, nil, { index = Rewards.roll(s.wedges, peek(state.luck) == true) }
	end

	LOCAL.Gifts = function(state, item)
		local s = state.def
		local mask = number(peek(state.mask), 0)
		local playtime = peek(state.playtime)
		local claimed = {}
		local prizes = {}
		for index, gift in s.gifts do
			if (item == "all" or item == index) and not bitOn(mask, index) and playtime >= gift.minutes * 60 then
				table.insert(claimed, index)
				table.insert(prizes, gift)
			end
		end
		if #claimed == 0 then
			return false, if item == "all" or (s.gifts[item] and not bitOn(mask, item)) then "early" else "claimed"
		end
		if not payableHere(prizes) then
			return false, "offline"
		end
		for _, index in claimed do
			mask = withBit(mask, index)
		end
		state.mask:set(mask)
		return true, nil, { claimed = claimed, mask = mask }
	end

	LOCAL.Index = function(state, item)
		local s = state.def
		local milestone = s.milestones[item]
		if milestone == nil then
			return false, "unknown"
		end
		local mask = number(peek(state.claimedMask), 0)
		if bitOn(mask, item) then
			return false, "claimed"
		end
		if state.total == 0 or peek(state.found) * 100 < milestone.percent * state.total then
			return false, "locked"
		end
		if not payableHere({ milestone }) then
			return false, "offline"
		end
		state.claimedMask:set(withBit(mask, item))
		return true, nil, { mask = withBit(mask, item) }
	end

	LOCAL.Achievements = function(state, item)
		local a = nil
		for _, entry in state.def.achievements do
			if entry.id == item then
				a = entry
			end
		end
		if a == nil then
			return false, "unknown"
		end
		if peek(state.done[a.id]) ~= nil then
			return false, "claimed"
		end
		if number(peek(state.progress[a.id]), 0) < a.goal then
			return false, "locked"
		end
		if not payableHere({ a.reward }) then
			return false, "offline"
		end
		state.done[a.id]:set(os.time())
		return true, nil, { at = os.time() }
	end

	LOCAL.Social = function(state, item)
		local s = state.def
		for index, r in s.rewards do
			if r.id == item then
				if r.kind == "Invite" then
					return false, "unknown"
				end
				local mask = number(peek(state.mask), 0)
				if bitOn(mask, index) then
					return false, "claimed"
				end
				if r.kind == "Group" and not peek(state.inGroup) then
					return false, "notInGroup"
				end
				if not payableHere({ r.reward }) then
					return false, "offline"
				end
				state.mask:set(withBit(mask, index))
				return true, nil, { mask = withBit(mask, index) }
			end
		end
		return false, "unknown"
	end

	LOCAL.OfflineEarnings = function(state)
		local amount = number(peek(state.amount), 0)
		if amount <= 0 then
			return false, "none"
		end
		local currency = state.def.currency
		if not payableHere({ { grant = { currency = currency, amount = amount } } }) then
			return false, "offline"
		end
		state.amount:set(0)
		return true, nil, { amount = amount }
	end

	-- The server's answer lands in the state at once (its attributes follow).
	local function apply(state: any, data: any)
		if typeof(data) ~= "table" then
			return
		end
		if state.kind == "Spin" and typeof(data.next) == "number" then
			state.nextFree:set(data.next)
		elseif state.kind == "Gifts" and typeof(data.mask) == "number" then
			state.mask:set(data.mask)
		elseif state.kind == "Index" and typeof(data.mask) == "number" then
			state.claimedMask:set(data.mask)
		elseif state.kind == "Social" and typeof(data.mask) == "number" then
			state.mask:set(data.mask)
		elseif state.kind == "OfflineEarnings" then
			state.amount:set(0)
		end
	end

	local busy: { [string]: boolean } = {}

	-- Asks for a reward. `item`: a gift index or "all", a milestone index, an
	-- achievement / social reward id; `mode`: "free" | "paid" for spins.
	-- done(ok, reason, data) runs once it's settled; a refusal is explained
	-- to the player here.
	function rewards.claim(_self, id: string, item: any, mode: string?, done: ((boolean, string?, any) -> ())?)
		local state = rewards.sections[id]
		if state == nil then
			return
		end
		local function finish(ok: boolean, reason: string?, data: any)
			if not ok and reason ~= "busy" then
				local message = Actions.CLAIM_FAILED[reason or ""] or "That didn't work. Try again."
				if reason == "funds" then
					local def = ui.currencyDefs[state.def.currency or ""]
					message = string.format(message, if def then def.name else "currency")
					ui:shakeCurrency(state.def.currency)
				elseif reason == "cooldown" and typeof(data) == "table" and typeof(data.wait) == "number" then
					message = `Play {Rewards.duration(data.wait)} more, then claim your thank-you!`
				end
				ui:notify(message, if reason == "early" or reason == "cooldown" or reason == "locked" or reason == "notInGroup" then "warning" else "error")
			end
			if done then
				done(ok, reason, data)
			end
		end
		if busy[id] then
			return
		end
		local remote = if preview then nil else Actions.listen()
		if remote == nil then
			local rule = LOCAL[state.kind]
			local ok, reason, data = rule(state, item, mode)
			finish(ok, reason, data)
			if ok then
				ui:emit("RewardClaimed", { id = id, payload = { kind = state.kind, item = item, data = data } })
			end
			return
		end
		busy[id] = true
		Actions.request(Actions.CLAIM, id, { section = id, item = item, mode = mode }, function(ok, reason, data)
			busy[id] = nil
			if not alive then
				return
			end
			if ok then
				apply(state, data)
				ui:emit("RewardClaimed", { id = id, payload = { kind = state.kind, item = item, data = data } })
			end
			finish(ok, reason, data)
		end)
	end

	-- A reward flies into its counter (currency), or pops as a toast (items).
	function rewards.payout(_self, prize: any, from: any?)
		if typeof(prize) ~= "table" then
			return
		end
		if prize.grant and ui.currencies[prize.grant.currency] then
			ui:give(prize.grant.currency, prize.grant.amount, from)
		else
			local label = if prize.label then prize.label elseif prize.amount then `x{prize.amount}` else "a reward"
			ui:notify(`You got {label}!`, "reward", true)
		end
	end

	-- Plays a spin the server already rolled (Robux spins): opens the wheel's
	-- screen and tab, then lands on `index`.
	function ui.spin(_self, sectionId: string, index: number)
		local state = rewards.sections[sectionId]
		if state == nil or state.kind ~= "Spin" or state.def.wedges[index] == nil then
			return false
		end
		local where = rewards.where[sectionId]
		if where and not ui:isOpen(where.screen) then
			ui:open(where.screen)
		end
		local handle = where and ui.screens[where.screen]
		if handle and where.tab and handle.Selected then
			handle.Selected:set(where.tab)
		end
		state.result:set({ index = index, token = os.clock(), mode = "robux" })
		return true
	end

	------------------------------------------------------------------------
	-- Pop-ups the game shows by itself: offline earnings, then offers
	------------------------------------------------------------------------

	function rewards.start(_self)
		if preview or player == nil then
			return
		end
		local queue: { string } = {}
		local queued: { [string]: boolean } = {}
		local function enqueue(id: string)
			if not queued[id] then
				queued[id] = true
				table.insert(queue, id)
			end
		end
		-- offline earnings first (they're the player's own)
		for _, id in rewards.offline do
			local state = rewards.sections[id]
			local function check()
				if number(peek(state.amount), 0) > 0 then
					enqueue(id)
				end
			end
			check()
			table.insert(scope, scope:Observer(state.amount):onChange(check))
		end
		-- offers: the server's list, or (no server) every "Session" offer
		local showList = if Actions.listen() then attribute(ATTR.showOffers, "") else nil
		local function readOffers()
			if showList then
				for id in string.gmatch(tostring(peek(showList) or ""), "[^,]+") do
					if rewards.sections[id] and rewards.sections[id].kind == "Offer" then
						enqueue(id)
					end
				end
			else
				for _, id in rewards.offers do
					if rewards.sections[id].def.show == "Session" then
						enqueue(id)
					end
				end
			end
		end
		readOffers()
		if showList then
			table.insert(scope, scope:Observer(showList):onChange(readOffers))
		end
		-- one at a time, never over the menu or the loading screen, and at
		-- most one offer a session
		local startedClock = os.clock() -- (sub-second: os.time() is whole seconds)
		task.spawn(function()
			local showing: string? = nil
			local offerShown = false
			while alive do
				task.wait(0.5)
				if not alive then
					break
				end
				if showing then
					if not ui:isOpen(showing) then
						showing = nil
					end
					continue
				end
				local start = bp.startScreen
				if start and ui:isOpen(start) then
					continue
				end
				local id = queue[1]
				if id == nil then
					continue
				end
				local state = rewards.sections[id]
				local wait = if state.kind == "Offer" then state.def.delay or 4 else 1
				if os.clock() - startedClock < wait then
					continue
				end
				table.remove(queue, 1)
				local isOffer = state.kind == "Offer"
				local ready = if isOffer then peek(state.available) and not offerShown else number(peek(state.amount), 0) > 0
				if ready then
					ui:open(id)
					showing = id
					offerShown = offerShown or isOffer
				end
			end
		end)
	end

	return rewards
end

return Rewards
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="47">
            <Properties>
              <string name="Name">Samples</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Blueprint.Samples: ready-made blueprints, two per genre, made ahead of time
	by BloxUI's generator (tools/gen_samples.py). They are plain data: the
	plugin offers them as "Start from a sample" when you're signed out or
	offline, and the Self-Test builds every one.

		local Samples = UI.Blueprint.Samples
		Samples.list()              -- { { name = "fps_7", genre = "fps", seed = 7 }, ... }
		Samples.genres()            -- { "fps", "horror", ... } genres that have samples
		Samples.forGenre("fps")     -- { "fps_7", "fps_21" }
		Samples.get("fps_7")        -- a fresh copy of that blueprint (nil when unknown)
		local ui = UI.Blueprint.build(scope, Samples.get("fps_7"))

	Each sample is its own ModuleScript and only loads when asked for, so a
	game that never uses them doesn't pay for them. The plugin's "Install
	BloxUI" leaves them out of your place (the Self-Test button adds them).
]]

-- Genre order (the same as Blueprint.Schema.GENRES).
local GENRES = {
	"fps", "horror", "simulator", "tycoon", "obby", "rpg", "racing", "battleroyale",
	"towerdefense", "fighting", "survival", "roleplay", "sports", "casual", "scifi",
}

local Samples = {}

local function deepCopy(value: any): any
	if typeof(value) ~= "table" then
		return value
	end
	local out = {}
	for key, item in value do
		out[key] = deepCopy(item)
	end
	return out
end

local function genreRank(genre: string): number
	return table.find(GENRES, genre) or (#GENRES + 1)
end

-- Every sample in this copy of BloxUI, ordered by genre then seed.
function Samples.list(): { { name: string, genre: string, seed: number } }
	local out = {}
	for _, child in script:GetChildren() do
		if child:IsA("ModuleScript") then
			local genre, seed = string.match(child.Name, "^(%a+)_(%d+)$")
			if genre and seed then
				table.insert(out, { name = child.Name, genre = genre, seed = tonumber(seed) :: number })
			end
		end
	end
	table.sort(out, function(a, b)
		local ra, rb = genreRank(a.genre), genreRank(b.genre)
		if ra ~= rb then
			return ra < rb
		elseif a.genre ~= b.genre then
			return a.genre < b.genre
		end
		return a.seed < b.seed
	end)
	return out
end

-- Genres that have at least one sample, in genre order.
function Samples.genres(): { string }
	local out = {}
	for _, entry in Samples.list() do
		if not table.find(out, entry.genre) then
			table.insert(out, entry.genre)
		end
	end
	return out
end

-- Sample names for one genre ({} when there are none).
function Samples.forGenre(genre: string): { string }
	local out = {}
	for _, entry in Samples.list() do
		if entry.genre == genre then
			table.insert(out, entry.name)
		end
	end
	return out
end

-- A fresh copy of one sample (safe to edit), or nil.
function Samples.get(name: string): { [string]: any }?
	if typeof(name) ~= "string" then
		return nil
	end
	local module = script:FindFirstChild(name)
	if module == nil or not module:IsA("ModuleScript") then
		return nil
	end
	local ok, data = pcall(require, module)
	if not ok or typeof(data) ~= "table" then
		return nil
	end
	return deepCopy(data)
end

return Samples
]]></string>
            </Properties>
            <Item class="ModuleScript" referent="48">
              <Properties>
                <string name="Name">battleroyale_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: battleroyale, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD21F",
			format = "comma",
			icon = "coin",
			id = "coins",
			name = "Coins",
			source = { kind = "leaderstat", name = "Coins" },
			start = 2250,
		},
		{
			color = "#FFC845",
			format = "comma",
			icon = "crown",
			id = "crowns",
			name = "Crowns",
			source = { kind = "attribute", name = "Crowns" },
			start = 425,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Storm closes",
				seconds = 106,
				type = "Timer",
				visible = true,
			},
			{ anchor = "TopRight", id = "hud_minimap", shape = "Round", type = "Minimap", visible = true },
			{ anchor = "Right", id = "hud_killfeed", type = "KillFeed", visible = true },
			{ anchor = "Center", id = "hud_crosshair", style = "Dot", type = "Crosshair", visible = true },
			{
				anchor = "BottomLeft",
				id = "hud_healthbar",
				max = 100,
				source = { kind = "humanoid" },
				type = "HealthBar",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				slots = {
					{ ammo = 30, icon = "crosshairs", name = "Assault Rifle", reserve = 150 },
					{ ammo = 6, icon = "bullet-flying", name = "Tactical Shotgun", reserve = 24 },
					{ ammo = 25, icon = "lightning-bolt", name = "Burst SMG", reserve = 100 },
					{ icon = "shield-check", name = "Shield Potion" },
					{ icon = "heart", name = "Bandage" },
				},
				type = "Hotbar",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonB", key = "Q", text = "Build" },
					{ gamepad = "ButtonX", key = "R", text = "Reload" },
					{ gamepad = "DPadDown", key = "B", text = "Emote" },
					{ gamepad = "ButtonSelect", key = "M", text = "Map" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				action = { kind = "Open", target = "menu" },
				anchor = "TopLeft",
				icon = "menu",
				id = "hud_button",
				label = "Menu",
				tone = "Dark",
				type = "Button",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Fire" },
						icon = "crosshairs",
						id = "touch_fire",
						label = "Fire",
					},
					{
						action = { kind = "Event", name = "Reload" },
						icon = "arrow-spin-clockwise",
						id = "touch_reload",
						label = "Reload",
					},
					{
						action = { kind = "Event", name = "Crouch" },
						icon = "person-crouching",
						id = "touch_crouch",
						label = "Crouch",
					},
					{
						action = { kind = "Event", name = "Build" },
						icon = "cube",
						id = "touch_build",
						label = "Build",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopRight",
				currencies = { "coins", "crowns" },
				id = "hud_currencybar",
				look = "Pill",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				icon = "shield-check",
				id = "hud_meter",
				label = "SHIELD",
				low = 0,
				style = "Bar",
				tone = "Blue",
				type = "Meter",
				value = 0.41,
				visible = true,
			},
			{
				anchor = "TopRight",
				icon = "person",
				id = "hud_counter",
				label = "ALIVE",
				style = "Stamp",
				type = "Counter",
				value = 36,
				visible = true,
			},
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Challenges",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "leaderboard" },
						icon = "trophy",
						id = "btn_leaderboard",
						label = "Rankings",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
		},
		kit = "Arcade",
		layout = "Corners",
	},
	logo = {
		icon = "trophy",
		subtitle = "DROP IN",
		text = "Party Royale",
		tilt = 5,
		tone = "Pink",
		type = "Badge",
	},
	meta = {
		flavor = "cartoon",
		genre = "battleroyale",
		kit = "Arcade",
		prompt = "a battleroyale game",
		seed = 21,
		source = "offline",
		title = "Party Royale",
	},
	screens = {
		{
			description = "The first thing players see: the Party Royale logo, a progress bar and rotating tips while the place streams in.",
			duration = 4,
			icon = "timer",
			id = "loading",
			kind = "Loading",
			tips = {
				"The storm hits harder every circle. Don't get caught out.",
				"Emotes are free to use in the pre-game lobby.",
				"Supply drops always hold Epic or better loot.",
				"Land at the edge of the map for a quieter start.",
				"Shield potions take five seconds. Find cover first.",
			},
			title = "Loading",
		},
		{
			description = "The title screen: the Party Royale logo, DROP IN, Party, Locker, Item Shop, Pass and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "DROP IN",
							tone = "Pink",
						},
						{
							action = { kind = "Open", target = "party" },
							icon = "friends",
							id = "menu_party",
							label = "Party",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "locker" },
							icon = "skins",
							id = "menu_locker",
							label = "Locker",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Item Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "battlepass" },
							icon = "premium",
							id = "menu_battlepass",
							label = "Pass",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "shield",
					id = "welcome_bundle",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 172800,
					title = "Welcome Bundle",
					tone = "Yellow",
				},
			},
			title = "Lobby",
		},
		{
			description = "Invite friends into your party and pick a mode together; the party leader's pick drives matchmaking.",
			icon = "friends",
			id = "party",
			kind = "Window",
			nav = "None",
			openKey = "P",
			sections = {
				{ id = "party_2", kind = "Party", slots = 4 },
				{
					buttons = {
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "trophy",
							id = "mode_ranked",
							label = "Ranked",
							tone = "Pink",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "play",
							id = "mode_quick_match",
							label = "Quick Match",
							tone = "Dark",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "gear",
							id = "mode_custom",
							label = "Custom",
							tone = "Dark",
						},
					},
					id = "modes",
					kind = "Buttons",
					layout = "Grid",
					title = "Modes",
				},
			},
			size = "Medium",
			title = "Party",
		},
		{
			description = "Everything cosmetic the player owns: outfits, extras and emotes, previewed on the avatar.",
			icon = "skins",
			id = "locker",
			kind = "Fullscreen",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "skins",
					id = "tab_outfits",
					label = "Outfits",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{
									equipped = true,
									icon = "cloud",
									id = "inv_frost_queen",
									name = "Frost Queen",
									rarity = "Legendary",
								},
								{ icon = "shield", id = "inv_knight_commander", name = "Knight Commander", rarity = "Epic" },
								{ icon = "ninja", id = "inv_shadow_agent", name = "Shadow Agent", rarity = "Epic" },
								{ icon = "music", id = "inv_punk_rocker", name = "Punk Rocker", rarity = "Rare" },
								{ icon = "pumpkin", id = "inv_pumpkin_head", name = "Pumpkin Head", rarity = "Uncommon" },
								{
									icon = "paint-brush",
									id = "inv_street_artist",
									name = "Street Artist",
									rarity = "Uncommon",
								},
								{ icon = "wheat", id = "inv_jungle_scout", name = "Jungle Scout", rarity = "Common" },
								{
									icon = "nebula",
									id = "locked_astro_explorer",
									locked = true,
									name = "Astro Explorer",
									rarity = "Legendary",
								},
								{
									icon = "crown",
									id = "locked_captain_royale",
									locked = true,
									name = "Captain Royale",
									rarity = "Legendary",
								},
							},
							kind = "Inventory",
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_back_blings",
					label = "Back Blings",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_2",
							items = {
								{
									equipped = true,
									icon = "crown",
									id = "inv_crown_cape",
									name = "Crown Cape",
									rarity = "Legendary",
								},
								{ icon = "butterfly-wings", id = "inv_angel_wings", name = "Angel Wings", rarity = "Epic" },
								{
									icon = "double-chevron-large-up",
									id = "inv_rocket_pack",
									name = "Rocket Pack",
									rarity = "Rare",
								},
								{ icon = "star", id = "inv_star_satchel", name = "Star Satchel", rarity = "Uncommon" },
								{ icon = "backpack", id = "inv_mini_backpack", name = "Mini Backpack", rarity = "Common" },
							},
							kind = "Inventory",
						},
					},
				},
				{
					icon = "face-winking",
					id = "tab_emotes",
					label = "Emotes",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_3",
							items = {
								{ equipped = true, icon = "thumb-down", id = "inv_boo", name = "Boo", rarity = "Epic" },
								{
									equipped = true,
									icon = "megaphone-cheer",
									id = "inv_cheer",
									name = "Cheer",
									rarity = "Rare",
								},
								{ equipped = true, icon = "moon", id = "inv_nap", name = "Nap", rarity = "Rare" },
								{ equipped = true, icon = "thumb-up", id = "inv_gg", name = "GG", rarity = "Uncommon" },
								{ icon = "heart", id = "inv_heart", name = "Heart", rarity = "Uncommon" },
								{ icon = "face-winking", id = "inv_laugh", name = "Laugh", rarity = "Uncommon" },
								{ icon = "crown", id = "inv_crown", name = "Crown", rarity = "Common" },
								{ icon = "square-face-waving-hand", id = "inv_wave", name = "Wave", rarity = "Common" },
							},
							kind = "Inventory",
						},
					},
				},
			},
			title = "Locker",
		},
		{
			description = "20 tiers of free and premium rewards for the season, plus weekly challenges that feed pass XP.",
			icon = "premium",
			id = "battlepass",
			kind = "Fullscreen",
			nav = "None",
			sections = {
				{
					button = { action = { kind = "Event", name = "BuyPremiumPass" }, label = "Unlock Premium" },
					icon = "premium",
					id = "season_1_pass",
					kind = "Banner",
					subtitle = "Unlock the premium track: 20 exclusive rewards",
					timer = 1382400,
					title = "Season 1 Pass",
					tone = "Pink",
				},
				{
					id = "pass",
					kind = "Pass",
					tiers = {
						{
							free = { amount = "125", icon = "coin" },
							premium = { amount = "x1", icon = "gift" },
							tier = 1,
						},
						{ premium = { amount = "x1", icon = "paint-brush" }, tier = 2 },
						{
							free = { amount = "200", icon = "coin" },
							premium = { amount = "x1", icon = "backpack" },
							tier = 3,
						},
						{ premium = { amount = "40", icon = "crown" }, tier = 4 },
						{
							free = { amount = "x1", icon = "backpack" },
							premium = { amount = "x1", icon = "star" },
							tier = 5,
						},
						{ premium = { amount = "x1", icon = "star" }, tier = 6 },
						{
							free = { amount = "300", icon = "coin" },
							premium = { amount = "x1", icon = "gift" },
							tier = 7,
						},
						{ premium = { amount = "60", icon = "crown" }, tier = 8 },
						{
							free = { amount = "375", icon = "coin" },
							premium = { amount = "x1", icon = "star" },
							tier = 9,
						},
						{
							free = { amount = "x1", icon = "tshirt" },
							premium = { amount = "70", icon = "crown" },
							tier = 10,
						},
						{
							free = { amount = "425", icon = "coin" },
							premium = { amount = "x1", icon = "music" },
							tier = 11,
						},
						{ premium = { amount = "80", icon = "crown" }, tier = 12 },
						{
							free = { amount = "500", icon = "coin" },
							premium = { amount = "x1", icon = "paint-brush" },
							tier = 13,
						},
						{ premium = { amount = "x1", icon = "gift" }, tier = 14 },
						{
							free = { amount = "x1", icon = "backpack" },
							premium = { amount = "x1", icon = "tshirt" },
							tier = 15,
						},
						{ premium = { amount = "100", icon = "crown" }, tier = 16 },
						{
							free = { amount = "600", icon = "coin" },
							premium = { amount = "x1", icon = "gift" },
							tier = 17,
						},
						{ premium = { amount = "x1", icon = "music" }, tier = 18 },
						{
							free = { amount = "675", icon = "coin" },
							premium = { amount = "x1", icon = "paint-brush" },
							tier = 19,
						},
						{
							free = { amount = "x1", icon = "gem" },
							premium = { amount = "125", icon = "crown" },
							tier = 20,
						},
					},
				},
				{
					id = "challenges",
					kind = "Quests",
					quests = {
						{
							description = "Visit 4 named locations",
							goal = 4,
							icon = "location-pin-map",
							id = "quest_explorer",
							progress = 4,
							reward = { amount = "10", icon = "crown" },
							title = "Explorer",
						},
						{
							description = "Place top 10 8 times",
							goal = 8,
							icon = "trophy",
							id = "quest_survivor",
							progress = 0,
							reward = { amount = "50", icon = "crown" },
							title = "Survivor",
						},
						{
							description = "Deal 1,000 damage with snipers",
							goal = 1000,
							icon = "circle-line-target-cursor",
							id = "quest_sharpshooter",
							progress = 555,
							reward = { amount = "375", icon = "coin" },
							title = "Sharpshooter",
						},
						{
							description = "Open 20 chests",
							goal = 20,
							icon = "file-box",
							id = "quest_looter",
							progress = 2,
							reward = { amount = "250", icon = "coin" },
							title = "Looter",
						},
					},
					title = "Challenges",
				},
			},
			size = "Large",
			title = "Pass",
		},
		{
			description = "Where Coins and Robux turn into progress. Tabs: Featured, Back Blings, Supply Drops, Crowns and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			openKey = "B",
			size = "Large",
			tabs = {
				{
					callout = "Refreshes daily",
					icon = "sale",
					id = "tab_featured",
					label = "Featured",
					sections = {
						{
							id = "today_s_picks",
							items = {
								{
									amount = "3,250",
									currency = "robux",
									description = "3,250 Crowns + an exclusive title",
									grant = { amount = 3250, currency = "crowns" },
									icon = "crown",
									id = "featured_mega",
									price = 999,
									tag = "LIMITED",
									title = "Mega Bundle",
									tone = "Pink",
									was = 1999,
								},
								{
									currency = "crowns",
									description = "Leaves the shop soon",
									icon = "crown",
									id = "featured_crown_cape",
									price = 925,
									tag = "FEATURED",
									title = "Crown Cape",
									tone = "Yellow",
								},
								{
									amount = "475",
									currency = "robux",
									description = "475 Crowns + Crown Cape",
									grant = { amount = 475, currency = "crowns" },
									icon = "gift",
									id = "featured_starter",
									price = 149,
									tag = "ONE TIME",
									title = "Newcomer Bundle",
									tone = "Blue",
									was = 299,
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Today's Picks",
						},
					},
				},
				{
					icon = "skins",
					id = "tab_back_blings_2",
					label = "Back Blings",
					sections = {
						{
							id = "products",
							items = {
								{
									amount = "Common",
									currency = "crowns",
									icon = "backpack",
									id = "buy_mini_backpack",
									price = 60,
									title = "Mini Backpack",
									tone = "Gray",
								},
								{
									amount = "Uncommon",
									currency = "crowns",
									icon = "star",
									id = "buy_star_satchel",
									price = 125,
									title = "Star Satchel",
									tone = "Green",
								},
								{
									amount = "Rare",
									currency = "crowns",
									icon = "double-chevron-large-up",
									id = "buy_rocket_pack",
									price = 275,
									title = "Rocket Pack",
									tone = "Blue",
								},
								{
									amount = "Epic",
									currency = "crowns",
									icon = "butterfly-wings",
									id = "buy_angel_wings",
									price = 525,
									title = "Angel Wings",
									tone = "Purple",
								},
							},
							kind = "Products",
							layout = "Compact",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_supply_drops",
					label = "Supply Drops",
					sections = {
						{
							cases = {
								{
									currency = "coins",
									icon = "file-box",
									id = "case_supply_drop",
									pool = {
										{ chance = 56.4, icon = "crosshairs", name = "Assault Rifle", rarity = "Common" },
										{ chance = 26, icon = "bullet-flying", name = "Tactical Shotgun", rarity = "Uncommon" },
										{ chance = 12, icon = "lightning-bolt", name = "Burst SMG", rarity = "Rare" },
										{ chance = 5.6, icon = "flame", name = "Rocket Launcher", rarity = "Epic" },
									},
									price = 200,
									title = "Supply Drop",
									tone = "Yellow",
								},
								{
									currency = "coins",
									icon = "paint-bucket",
									id = "case_wrap_crate",
									pool = {
										{ chance = 56.5, icon = "bullet-flying", name = "Tactical Shotgun", rarity = "Uncommon" },
										{ chance = 27, icon = "lightning-bolt", name = "Burst SMG", rarity = "Rare" },
										{ chance = 12, icon = "flame", name = "Rocket Launcher", rarity = "Epic" },
										{
											chance = 4.5,
											icon = "circle-line-target-cursor",
											name = "Heavy Sniper",
											rarity = "Legendary",
										},
									},
									price = 575,
									title = "Wrap Crate",
									tone = "Purple",
								},
								{
									currency = "crowns",
									icon = "gift",
									id = "case_llama_loot",
									pool = {
										{ chance = 62, icon = "lightning-bolt", name = "Burst SMG", rarity = "Rare" },
										{ chance = 28, icon = "flame", name = "Rocket Launcher", rarity = "Epic" },
										{
											chance = 10,
											icon = "circle-line-target-cursor",
											name = "Heavy Sniper",
											rarity = "Legendary",
										},
									},
									price = 375,
									title = "Llama Loot",
									tone = "Pink",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "crown",
					id = "tab_crowns",
					label = "Crowns",
					sections = {
						{
							id = "crown_bundles",
							items = {
								{
									amount = "200",
									currency = "robux",
									grant = { amount = 200, currency = "crowns" },
									icon = "crown",
									id = "crowns_pack_1",
									price = 49,
									title = "Handful of Crowns",
									tone = "Blue",
								},
								{
									amount = "450",
									bonus = 11,
									currency = "robux",
									grant = { amount = 450, currency = "crowns" },
									icon = "crown",
									id = "crowns_pack_2",
									price = 99,
									tag = "SALE",
									title = "Pouch of Crowns",
									tone = "Blue",
									was = 149,
								},
								{
									amount = "975",
									bonus = 20,
									currency = "robux",
									grant = { amount = 975, currency = "crowns" },
									icon = "crown",
									id = "crowns_pack_3",
									price = 199,
									tag = "POPULAR",
									title = "Sack of Crowns",
									tone = "Pink",
								},
								{
									amount = "2,250",
									bonus = 38,
									currency = "robux",
									grant = { amount = 2250, currency = "crowns" },
									icon = "file-box",
									id = "crowns_pack_4",
									price = 399,
									title = "Chest of Crowns",
									tone = "Pink",
								},
								{
									amount = "5,000",
									bonus = 53,
									currency = "robux",
									grant = { amount = 5000, currency = "crowns" },
									icon = "building-bank",
									id = "crowns_pack_5",
									price = 799,
									title = "Vault of Crowns",
									tone = "Yellow",
								},
								{
									amount = "12,500",
									bonus = 104,
									currency = "robux",
									grant = { amount = 12500, currency = "crowns" },
									icon = "crown",
									id = "crowns_pack_6",
									price = 1499,
									tag = "BEST VALUE",
									title = "Mountain of Crowns",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Crown Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Coins",
									icon = "crown",
									id = "pass_vip",
									price = 699,
									tag = "MUST HAVE",
									title = "VIP",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Your own server for you and friends",
									icon = "house",
									id = "pass_private_servers",
									price = 399,
									title = "Private Servers",
									tone = "Blue",
								},
								{
									currency = "robux",
									description = "Level up twice as fast, forever",
									icon = "arrow-progress-up",
									id = "pass_2x_xp",
									price = 299,
									title = "2x XP",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "Stand out in every lobby",
									icon = "cursor-stars",
									id = "pass_contrails",
									price = 99,
									title = "Contrails",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "More moves to show off in the lobby",
									icon = "face-winking",
									id = "pass_extra_emotes",
									price = 75,
									title = "Extra Emotes",
									tone = "Blue",
								},
							},
							kind = "Products",
							layout = "Rows",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Item Shop",
		},
		{
			description = "Quest cards with progress bars under a reset countdown; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "Tabs",
			openKey = "L",
			size = "Large",
			tabs = {
				{
					icon = "quest",
					id = "tab_challenges",
					label = "Challenges",
					sections = {
						{
							icon = "timer",
							id = "new_quests_in",
							kind = "Banner",
							subtitle = "Finish all quests for a bonus chest",
							timer = 39600,
							title = "New quests in",
							tone = "Blue",
						},
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Visit 4 named locations",
									goal = 4,
									icon = "location-pin-map",
									id = "quest_explorer_2",
									progress = 4,
									reward = { amount = "350", icon = "coin" },
									title = "Explorer",
								},
								{
									description = "Deal 1,750 damage with snipers",
									goal = 1750,
									icon = "circle-line-target-cursor",
									id = "quest_sharpshooter_2",
									progress = 948,
									reward = { amount = "10", icon = "crown" },
									title = "Sharpshooter",
								},
								{
									description = "Open 10 chests",
									goal = 10,
									icon = "file-box",
									id = "quest_looter_2",
									progress = 0,
									reward = { amount = "325", icon = "coin" },
									title = "Looter",
								},
								{
									description = "Place top 10 10 times",
									goal = 10,
									icon = "trophy",
									id = "quest_survivor_2",
									progress = 2,
									reward = { amount = "575", icon = "coin" },
									title = "Survivor",
								},
							},
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_achievements",
					label = "Achievements",
					sections = {
						{
							achievements = {
								{
									description = "Play your first match",
									goal = 1,
									icon = "person-falling",
									id = "ach_dropped_in",
									points = 5,
									progress = 1,
									reward = { amount = "6", grant = { amount = 6, currency = "crowns" }, icon = "crown" },
									title = "Dropped In",
								},
								{
									description = "Open 200 chests",
									goal = 200,
									icon = "file-box",
									id = "ach_collector",
									points = 15,
									progress = 136,
									reward = { amount = "15", grant = { amount = 15, currency = "crowns" }, icon = "crown" },
									title = "Collector",
								},
								{
									description = "Finish top 10 25 times",
									goal = 25,
									icon = "trophy",
									id = "ach_top_10",
									points = 20,
									progress = 13,
									reward = { amount = "25", grant = { amount = 25, currency = "crowns" }, icon = "crown" },
									title = "Top 10",
								},
								{
									description = "Outlast the storm 50 times",
									goal = 50,
									icon = "shield-check",
									id = "ach_survivor",
									points = 25,
									progress = 0,
									reward = { amount = "25", grant = { amount = 25, currency = "crowns" }, icon = "crown" },
									title = "Survivor",
								},
								{
									description = "Get 500 eliminations",
									goal = 500,
									icon = "crosshairs",
									id = "ach_hunter",
									points = 30,
									progress = 0,
									reward = { amount = "30", grant = { amount = 30, currency = "crowns" }, icon = "crown" },
									title = "Hunter",
								},
								{
									description = "Win a match",
									goal = 1,
									icon = "crown",
									id = "ach_victory_royale",
									points = 30,
									progress = 0,
									reward = { amount = "25", grant = { amount = 25, currency = "crowns" }, icon = "crown" },
									title = "Victory Royale",
								},
								{
									description = "Win with a full squad",
									goal = 1,
									icon = "three-people",
									id = "ach_squad_goals",
									points = 35,
									progress = 0,
									reward = { amount = "40", grant = { amount = 40, currency = "crowns" }, icon = "crown" },
									title = "Squad Goals",
								},
							},
							id = "achievements",
							kind = "Achievements",
						},
					},
				},
			},
			title = "Challenges",
		},
		{
			description = "Global top ten for Wins, Top 10s and Eliminations, with podium colours for the top three.",
			icon = "trophy",
			id = "leaderboard",
			kind = "Window",
			nav = "Tabs",
			size = "Medium",
			tabs = {
				{
					icon = "trophy",
					id = "tab_wins",
					label = "Wins",
					sections = {
						{
							id = "leaderboard_2",
							kind = "Leaderboard",
							rows = {
								{ name = "Mango12", rank = 1, value = "1,816" },
								{ name = "PickleOtter42", rank = 2, value = "1,540" },
								{ name = "xXNimbusFoxXx", rank = 3, value = "1,256" },
								{ name = "xXTurboMooseXx", rank = 4, value = "944" },
								{ name = "ItsStormRider", rank = 5, value = "706" },
								{ name = "NotFrostSlayer", rank = 6, value = "610" },
								{ name = "NotRogueGamer", rank = 7, value = "511" },
								{ name = "ItsTurboRider", rank = 8, value = "482" },
								{ name = "ToastyLlama27", rank = 9, value = "361" },
								{ name = "WaffleNinjaYT", rank = 10, value = "331" },
							},
							stat = "Wins",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_top_10s",
					label = "Top 10s",
					sections = {
						{
							id = "leaderboard_3",
							kind = "Leaderboard",
							rows = {
								{ name = "Nova12", rank = 1, value = "91M" },
								{ name = "RealArcticPilot", rank = 2, value = "79.5M" },
								{ name = "NimbusMooseYT", rank = 3, value = "57.6M" },
								{ name = "RealShadowSpark", rank = 4, value = "46M" },
								{ name = "Onyx_King", rank = 5, value = "35.4M" },
								{ name = "xXCometRiderXx", rank = 6, value = "29M" },
								{ name = "Glitch88", rank = 7, value = "25M" },
								{ name = "Viper12", rank = 8, value = "23.4M" },
								{ name = "ItsGlitchNinja", rank = 9, value = "22.2M" },
								{ name = "ItsShadowYeti", rank = 10, value = "17M" },
							},
							stat = "Top 10s",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_eliminations",
					label = "Eliminations",
					sections = {
						{
							id = "leaderboard_4",
							kind = "Leaderboard",
							rows = {
								{ name = "GhostSparkYT", rank = 1, value = "1.2M" },
								{ name = "NotPepperRider", rank = 2, value = "869K" },
								{ name = "Bubble88", rank = 3, value = "805K" },
								{ name = "MochiCat7", rank = 4, value = "635K" },
								{ name = "NotSkyFox", rank = 5, value = "496K" },
								{ name = "ItsBubbleOwl", rank = 6, value = "457K" },
								{ name = "GlitchWolf", rank = 7, value = "368K" },
								{ name = "NotLunaMoose", rank = 8, value = "271K" },
								{ name = "MapleLlama360", rank = 9, value = "212K" },
								{ name = "MapleHunterYT", rank = 10, value = "187K" },
							},
							stat = "Eliminations",
						},
					},
				},
			},
			title = "Rankings",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Pink",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "lobby",
					label = "Lobby",
					tone = "Dark",
				},
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Victory",
			rewards = { { amount = "+250", icon = "coin" }, { amount = "+300 XP", icon = "level" } },
			stats = {
				{ label = "Placement", value = "#its world" },
				{ label = "Eliminations", value = "24" },
				{ label = "Damage", value = "3,287" },
				{ label = "Survived", value = "17:06" },
			},
			title = "Results",
		},
		{
			delay = 5,
			description = "A one-time Welcome Bundle: 4 rewards for 99 Robux (was 299), counting down 48h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "shield",
			id = "offer",
			items = {
				{
					amount = "600",
					grant = { amount = 600, currency = "crowns" },
					icon = "crown",
					label = "Crowns",
				},
				{
					amount = "12,500",
					grant = { amount = 12500, currency = "coins" },
					icon = "coin",
					label = "Coins",
				},
				{ amount = "1", icon = "shield", label = "Epic Knight Commander" },
				{ amount = "3", icon = "arrow-progress-up", label = "2x XP (60m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "LIMITED",
			timer = 172800,
			title = "Welcome Bundle",
			tone = "Yellow",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "crosshairs",
									id = "hitMarkers",
									label = "Hit Markers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "hashtag",
									id = "damageNumbers",
									label = "Damage Numbers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "list-bulleted",
									id = "killFeed",
									label = "Kill Feed",
									type = "Toggle",
								},
								{
									default = true,
									icon = "controller",
									id = "aimAssist",
									label = "Aim Assist",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Fire", gamepad = "ButtonR2", key = "MouseButton1" },
								{ action = "Aim", gamepad = "ButtonL2", key = "MouseButton2" },
								{ action = "Jump", gamepad = "ButtonA", key = "Space" },
								{ action = "Reload", gamepad = "ButtonX", key = "R" },
								{ action = "Crouch", gamepad = "ButtonR3", key = "LeftControl" },
								{ action = "Sprint", gamepad = "ButtonL3", key = "LeftShift" },
								{ action = "Map", gamepad = "ButtonSelect", key = "M" },
								{ action = "Emote", gamepad = "DPadDown", key = "B" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "loading",
	style = {
		archetype = "Candy",
		buttonStyle = "Glossy3D",
		clickFx = "Squash",
		corner = "Bubbly",
		decoration = "Sparkles",
		density = "Compact",
		fonts = { body = "BuilderSans:Bold", display = "FredokaOne", title = "FredokaOne" },
		iconStyle = "Filled",
		palette = {
			accent = "#FFD23F",
			bad = "#FF4F4F",
			good = "#4DF08A",
			primary = "#FF4F8B",
			secondary = "#4FB8FF",
			surface = "#2B1650",
			text = "#FFFFFF",
			warn = "#FFB020",
		},
		panelStyle = "Ribbon",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Pop",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="49">
              <Properties>
                <string name="Name">battleroyale_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: battleroyale, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD21F",
			format = "comma",
			icon = "coin",
			id = "coins",
			name = "Coins",
			source = { kind = "leaderstat", name = "Coins" },
			start = 2000,
		},
		{
			color = "#7CC7FF",
			format = "comma",
			icon = "star",
			id = "bux",
			name = "Royale Bux",
			source = { kind = "attribute", name = "RoyaleBux" },
			start = 1500,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Gas closes",
				seconds = 93,
				type = "Timer",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_objective",
				text = "14 players left",
				type = "Objective",
				visible = true,
			},
			{ anchor = "TopLeft", id = "hud_minimap", shape = "Round", type = "Minimap", visible = true },
			{ anchor = "Right", id = "hud_killfeed", type = "KillFeed", visible = true },
			{ anchor = "Center", id = "hud_crosshair", style = "Dot", type = "Crosshair", visible = true },
			{
				anchor = "BottomLeft",
				id = "hud_healthbar",
				max = 100,
				source = { kind = "humanoid" },
				type = "HealthBar",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				slots = {
					{ ammo = 30, icon = "crosshairs", name = "Assault Rifle", reserve = 150 },
					{ ammo = 6, icon = "bullet-flying", name = "Tactical Shotgun", reserve = 24 },
					{ ammo = 25, icon = "lightning-bolt", name = "Burst SMG", reserve = 100 },
					{ icon = "shield-check", name = "Shield Potion" },
					{ icon = "heart", name = "Bandage" },
				},
				type = "Hotbar",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonB", key = "Q", text = "Build" },
					{ gamepad = "ButtonX", key = "R", text = "Reload" },
					{ gamepad = "DPadDown", key = "B", text = "Emote" },
					{ gamepad = "ButtonSelect", key = "M", text = "Map" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				action = { kind = "Open", target = "menu" },
				anchor = "TopRight",
				icon = "menu",
				id = "hud_button",
				label = "Menu",
				tone = "Dark",
				type = "Button",
				visible = true,
			},
			{
				anchor = "Center",
				emotes = {
					{ icon = "person-jumping", id = "emote_jump", label = "Jump" },
					{ icon = "moon", id = "emote_nap", label = "Nap" },
					{ icon = "thumb-down", id = "emote_boo", label = "Boo" },
					{ icon = "arm", id = "emote_flex", label = "Flex" },
					{ icon = "circle-question", id = "emote_shrug", label = "Shrug" },
					{ icon = "heart", id = "emote_heart", label = "Heart" },
				},
				id = "hud_emotewheel",
				key = "B",
				type = "EmoteWheel",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Fire" },
						icon = "crosshairs",
						id = "touch_fire",
						label = "Fire",
					},
					{
						action = { kind = "Event", name = "Reload" },
						icon = "arrow-spin-clockwise",
						id = "touch_reload",
						label = "Reload",
					},
					{
						action = { kind = "Event", name = "Crouch" },
						icon = "person-crouching",
						id = "touch_crouch",
						label = "Crouch",
					},
					{
						action = { kind = "Event", name = "Build" },
						icon = "cube",
						id = "touch_build",
						label = "Build",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopLeft",
				currencies = { "coins", "bux" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "TopCenter",
				heading = 271,
				id = "hud_compass",
				marker = 223,
				type = "Compass",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				icon = "shield-check",
				id = "hud_meter",
				label = "ARMOR",
				low = 0,
				style = "Bar",
				tone = "Gray",
				type = "Meter",
				value = 0.61,
				visible = true,
			},
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Challenges",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "emotes" },
						icon = "face-winking",
						id = "btn_emotes",
						label = "Emotes",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
		},
		kit = "Tactical",
		layout = "Corners",
	},
	logo = {
		icon = "crown",
		subtitle = "SEASON 3",
		text = "Dead Front",
		tilt = 0,
		tone = "Yellow",
		type = "Wordmark",
	},
	meta = {
		flavor = "tactical",
		genre = "battleroyale",
		kit = "Tactical",
		prompt = "a battleroyale game",
		seed = 7,
		source = "offline",
		title = "Dead Front",
	},
	screens = {
		{
			description = "The first thing players see: the Dead Front logo, a progress bar and rotating tips while the place streams in.",
			duration = 3,
			icon = "timer",
			id = "loading",
			kind = "Loading",
			tips = {
				"Land at the edge of the map for a quieter start.",
				"Supply drops always hold Epic or better loot.",
				"The storm hits harder every circle. Don't get caught out.",
				"Shield potions take five seconds. Find cover first.",
				"Emotes are free to use in the pre-game lobby.",
			},
			title = "Loading",
		},
		{
			bannerPosition = "Bottom",
			description = "The title screen: the Dead Front logo, DROP IN, Party, Locker, Item Shop, Battle Pass and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Center",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "DROP IN",
							tone = "Yellow",
						},
						{
							action = { kind = "Open", target = "party" },
							icon = "friends",
							id = "menu_party",
							label = "Party",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "locker" },
							icon = "skins",
							id = "menu_locker",
							label = "Locker",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Item Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "battlepass" },
							icon = "premium",
							id = "menu_battlepass",
							label = "Battle Pass",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "shield",
					id = "starter_pack",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 86400,
					title = "Starter Pack",
					tone = "Orange",
				},
			},
			title = "Lobby",
		},
		{
			description = "Invite friends into your party and pick a mode together; the party leader's pick drives matchmaking.",
			icon = "friends",
			id = "party",
			kind = "Window",
			nav = "None",
			openKey = "P",
			sections = {
				{ id = "party_2", kind = "Party", slots = 4 },
				{
					buttons = {
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "trophy",
							id = "mode_ranked",
							label = "Ranked",
							tone = "Yellow",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "gear",
							id = "mode_custom",
							label = "Custom",
							tone = "Dark",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "play",
							id = "mode_quick_match",
							label = "Quick Match",
							tone = "Dark",
						},
					},
					id = "modes",
					kind = "Buttons",
					layout = "Grid",
					title = "Modes",
				},
			},
			size = "Medium",
			title = "Party",
		},
		{
			description = "Everything cosmetic the player owns: outfits, extras and emotes, previewed on the avatar.",
			icon = "skins",
			id = "locker",
			kind = "Fullscreen",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					icon = "skins",
					id = "tab_outfits",
					label = "Outfits",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{
									equipped = true,
									icon = "crown",
									id = "inv_captain_royale",
									name = "Captain Royale",
									rarity = "Legendary",
								},
								{ icon = "shield", id = "inv_knight_commander", name = "Knight Commander", rarity = "Epic" },
								{ icon = "ninja", id = "inv_shadow_agent", name = "Shadow Agent", rarity = "Epic" },
								{ icon = "lightning-bolt", id = "inv_neon_rider", name = "Neon Rider", rarity = "Rare" },
								{ icon = "music", id = "inv_punk_rocker", name = "Punk Rocker", rarity = "Rare" },
								{ icon = "pumpkin", id = "inv_pumpkin_head", name = "Pumpkin Head", rarity = "Uncommon" },
								{
									icon = "paint-brush",
									id = "inv_street_artist",
									name = "Street Artist",
									rarity = "Uncommon",
								},
								{ icon = "wheat", id = "inv_jungle_scout", name = "Jungle Scout", rarity = "Common" },
								{
									icon = "nebula",
									id = "locked_astro_explorer",
									locked = true,
									name = "Astro Explorer",
									rarity = "Legendary",
								},
								{
									icon = "cloud",
									id = "locked_frost_queen",
									locked = true,
									name = "Frost Queen",
									rarity = "Legendary",
								},
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_back_blings",
					label = "Back Blings",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_2",
							items = {
								{
									equipped = true,
									icon = "crown",
									id = "inv_crown_cape",
									name = "Crown Cape",
									rarity = "Legendary",
								},
								{ icon = "butterfly-wings", id = "inv_angel_wings", name = "Angel Wings", rarity = "Epic" },
								{
									icon = "double-chevron-large-up",
									id = "inv_rocket_pack",
									name = "Rocket Pack",
									rarity = "Rare",
								},
								{ icon = "star", id = "inv_star_satchel", name = "Star Satchel", rarity = "Uncommon" },
								{ icon = "backpack", id = "inv_mini_backpack", name = "Mini Backpack", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
				{
					icon = "face-winking",
					id = "tab_emotes",
					label = "Emotes",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_3",
							items = {
								{ equipped = true, icon = "thumb-down", id = "inv_boo", name = "Boo", rarity = "Epic" },
								{ equipped = true, icon = "flame", id = "inv_taunt", name = "Taunt", rarity = "Epic" },
								{ equipped = true, icon = "crown", id = "inv_crown", name = "Crown", rarity = "Rare" },
								{ equipped = true, icon = "thumb-up", id = "inv_gg", name = "GG", rarity = "Rare" },
								{ icon = "heart", id = "inv_heart", name = "Heart", rarity = "Uncommon" },
								{ icon = "square-face-waving-hand", id = "inv_wave", name = "Wave", rarity = "Uncommon" },
								{ icon = "moon", id = "inv_nap", name = "Nap", rarity = "Common" },
								{ icon = "circle-question", id = "inv_shrug", name = "Shrug", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
			},
			title = "Locker",
		},
		{
			description = "20 tiers of free and premium rewards for the season, plus weekly challenges that feed pass XP.",
			icon = "premium",
			id = "battlepass",
			kind = "Window",
			nav = "None",
			sections = {
				{
					button = { action = { kind = "Event", name = "BuyPremiumPass" }, label = "Unlock Premium" },
					icon = "premium",
					id = "season_2_pass",
					kind = "Banner",
					subtitle = "Unlock the premium track: 20 exclusive rewards",
					timer = 1728000,
					title = "Season 2 Pass",
					tone = "Yellow",
				},
				{
					id = "pass",
					kind = "Pass",
					tiers = {
						{
							free = { amount = "125", icon = "coin" },
							premium = { amount = "x1", icon = "gem" },
							tier = 1,
						},
						{ premium = { amount = "x1", icon = "paint-brush" }, tier = 2 },
						{
							free = { amount = "200", icon = "coin" },
							premium = { amount = "x1", icon = "gift" },
							tier = 3,
						},
						{ premium = { amount = "40", icon = "star" }, tier = 4 },
						{
							free = { amount = "x1", icon = "backpack" },
							premium = { amount = "x1", icon = "gift" },
							tier = 5,
						},
						{ premium = { amount = "x1", icon = "paint-brush" }, tier = 6 },
						{
							free = { amount = "300", icon = "coin" },
							premium = { amount = "x1", icon = "tshirt" },
							tier = 7,
						},
						{ premium = { amount = "60", icon = "star" }, tier = 8 },
						{
							free = { amount = "375", icon = "coin" },
							premium = { amount = "x1", icon = "gift" },
							tier = 9,
						},
						{
							free = { amount = "x1", icon = "backpack" },
							premium = { amount = "x1", icon = "crown" },
							tier = 10,
						},
						{
							free = { amount = "425", icon = "coin" },
							premium = { amount = "x1", icon = "paint-brush" },
							tier = 11,
						},
						{ premium = { amount = "80", icon = "star" }, tier = 12 },
						{
							free = { amount = "500", icon = "coin" },
							premium = { amount = "x1", icon = "gem" },
							tier = 13,
						},
						{ premium = { amount = "x1", icon = "gift" }, tier = 14 },
						{
							free = { amount = "x1", icon = "tshirt" },
							premium = { amount = "95", icon = "star" },
							tier = 15,
						},
						{ premium = { amount = "100", icon = "star" }, tier = 16 },
						{
							free = { amount = "600", icon = "coin" },
							premium = { amount = "x1", icon = "tshirt" },
							tier = 17,
						},
						{ premium = { amount = "x1", icon = "paint-brush" }, tier = 18 },
						{
							free = { amount = "675", icon = "coin" },
							premium = { amount = "x1", icon = "paint-brush" },
							tier = 19,
						},
						{
							free = { amount = "x1", icon = "tshirt" },
							premium = { amount = "x1", icon = "crown" },
							tier = 20,
						},
					},
				},
				{
					id = "challenges",
					kind = "Quests",
					quests = {
						{
							description = "Place top 10 10 times",
							goal = 10,
							icon = "trophy",
							id = "quest_survivor",
							progress = 10,
							reward = { amount = "500", icon = "coin" },
							title = "Survivor",
						},
						{
							description = "Revive 2 teammates",
							goal = 2,
							icon = "heart",
							id = "quest_medic",
							progress = 0,
							reward = { amount = "575", icon = "coin" },
							title = "Medic",
						},
						{
							description = "Deal 1,750 damage with snipers",
							goal = 1750,
							icon = "circle-line-target-cursor",
							id = "quest_sharpshooter",
							progress = 180,
							reward = { amount = "400", icon = "coin" },
							title = "Sharpshooter",
						},
						{
							description = "Visit 5 named locations",
							goal = 5,
							icon = "location-pin-map",
							id = "quest_explorer",
							progress = 3,
							reward = { amount = "50", icon = "star" },
							title = "Explorer",
						},
					},
					title = "Challenges",
				},
			},
			size = "Large",
			title = "Battle Pass",
		},
		{
			description = "Where Coins and Robux turn into progress. Tabs: Featured, Back Blings, Supply Drops, Royale Bux and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			openKey = "B",
			size = "Large",
			tabs = {
				{
					callout = "Refreshes daily",
					icon = "sale",
					id = "tab_featured",
					label = "Featured",
					sections = {
						{
							id = "today_s_picks",
							items = {
								{
									amount = "6,000",
									currency = "robux",
									description = "6,000 Royale Bux + an exclusive title",
									grant = { amount = 6000, currency = "bux" },
									icon = "crown",
									id = "featured_mega",
									price = 1499,
									tag = "LIMITED",
									title = "Mega Bundle",
									tone = "Yellow",
									was = 3999,
								},
								{
									currency = "bux",
									description = "This week only",
									icon = "crown",
									id = "featured_crown_cape",
									price = 975,
									tag = "FEATURED",
									title = "Crown Cape",
									tone = "Yellow",
								},
								{
									amount = "800",
									currency = "robux",
									description = "800 Royale Bux + Crown Cape",
									grant = { amount = 800, currency = "bux" },
									icon = "gift",
									id = "featured_starter",
									price = 199,
									tag = "ONE TIME",
									title = "Newcomer Bundle",
									tone = "Yellow",
									was = 399,
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Today's Picks",
						},
					},
				},
				{
					icon = "skins",
					id = "tab_back_blings_2",
					label = "Back Blings",
					sections = {
						{
							id = "products",
							items = {
								{
									amount = "Common",
									currency = "bux",
									icon = "backpack",
									id = "buy_mini_backpack",
									price = 55,
									title = "Mini Backpack",
									tone = "Gray",
								},
								{
									amount = "Uncommon",
									currency = "bux",
									icon = "star",
									id = "buy_star_satchel",
									price = 125,
									title = "Star Satchel",
									tone = "Green",
								},
								{
									amount = "Rare",
									currency = "bux",
									icon = "double-chevron-large-up",
									id = "buy_rocket_pack",
									price = 275,
									title = "Rocket Pack",
									tone = "Blue",
								},
								{
									amount = "Epic",
									currency = "bux",
									icon = "butterfly-wings",
									id = "buy_angel_wings",
									price = 475,
									title = "Angel Wings",
									tone = "Purple",
								},
							},
							kind = "Products",
							layout = "Rows",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_supply_drops",
					label = "Supply Drops",
					sections = {
						{
							cases = {
								{
									currency = "coins",
									icon = "file-box",
									id = "case_supply_drop",
									pool = {
										{ chance = 56.4, icon = "crosshairs", name = "Assault Rifle", rarity = "Common" },
										{ chance = 26, icon = "bullet-flying", name = "Tactical Shotgun", rarity = "Uncommon" },
										{ chance = 12, icon = "lightning-bolt", name = "Burst SMG", rarity = "Rare" },
										{ chance = 5.6, icon = "flame", name = "Rocket Launcher", rarity = "Epic" },
									},
									price = 175,
									title = "Supply Drop",
									tone = "Yellow",
								},
								{
									currency = "coins",
									icon = "paint-bucket",
									id = "case_wrap_crate",
									pool = {
										{ chance = 56.5, icon = "bullet-flying", name = "Tactical Shotgun", rarity = "Uncommon" },
										{ chance = 27, icon = "lightning-bolt", name = "Burst SMG", rarity = "Rare" },
										{ chance = 12, icon = "flame", name = "Rocket Launcher", rarity = "Epic" },
										{
											chance = 4.5,
											icon = "circle-line-target-cursor",
											name = "Heavy Sniper",
											rarity = "Legendary",
										},
									},
									price = 700,
									title = "Wrap Crate",
									tone = "Purple",
								},
								{
									currency = "coins",
									icon = "gift",
									id = "case_llama_loot",
									pool = {
										{ chance = 62, icon = "lightning-bolt", name = "Burst SMG", rarity = "Rare" },
										{ chance = 28, icon = "flame", name = "Rocket Launcher", rarity = "Epic" },
										{
											chance = 10,
											icon = "circle-line-target-cursor",
											name = "Heavy Sniper",
											rarity = "Legendary",
										},
									},
									price = 2000,
									title = "Llama Loot",
									tone = "Pink",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "star",
					id = "tab_royale_bux",
					label = "Royale Bux",
					sections = {
						{
							id = "royale_bux_bundles",
							items = {
								{
									amount = "100",
									currency = "robux",
									grant = { amount = 100, currency = "bux" },
									icon = "star",
									id = "bux_pack_1",
									price = 49,
									title = "Starter Royale Buxs",
									tone = "Yellow",
								},
								{
									amount = "225",
									bonus = 11,
									currency = "robux",
									grant = { amount = 225, currency = "bux" },
									icon = "star",
									id = "bux_pack_2",
									price = 99,
									tag = "SALE",
									title = "Small Royale Bux Pack",
									tone = "Yellow",
									was = 149,
								},
								{
									amount = "500",
									bonus = 23,
									currency = "robux",
									grant = { amount = 500, currency = "bux" },
									icon = "star",
									id = "bux_pack_3",
									price = 199,
									title = "Big Royale Bux Pack",
									tone = "Yellow",
								},
								{
									amount = "1,250",
									bonus = 54,
									currency = "robux",
									grant = { amount = 1250, currency = "bux" },
									icon = "file-box",
									id = "bux_pack_4",
									price = 399,
									tag = "BEST VALUE",
									title = "Mega Royale Bux Pack",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Rows",
							title = "Royale Bux Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Coins",
									icon = "crown",
									id = "pass_vip",
									price = 699,
									tag = "POPULAR",
									title = "VIP",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "Your own server for you and friends",
									icon = "house",
									id = "pass_private_servers",
									price = 299,
									title = "Private Servers",
									tone = "Orange",
								},
								{
									currency = "robux",
									description = "Level up twice as fast, forever",
									icon = "arrow-progress-up",
									id = "pass_2x_xp",
									price = 249,
									title = "2x XP",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "Stand out in every lobby",
									icon = "cursor-stars",
									id = "pass_contrails",
									price = 99,
									title = "Contrails",
									tone = "Orange",
								},
								{
									currency = "robux",
									description = "More moves to show off in the lobby",
									icon = "face-winking",
									id = "pass_extra_emotes",
									price = 75,
									title = "Extra Emotes",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Rows",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Item Shop",
		},
		{
			description = "Daily and weekly quests with progress bars; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "SideTabs",
			openKey = "L",
			size = "Large",
			tabs = {
				{
					badge = 1,
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Deal 950 damage with snipers",
									goal = 950,
									icon = "circle-line-target-cursor",
									id = "quest_sharpshooter_2",
									progress = 950,
									reward = { amount = "300", icon = "coin" },
									title = "Sharpshooter",
								},
								{
									description = "Revive 6 teammates",
									goal = 6,
									icon = "heart",
									id = "quest_medic_2",
									progress = 0,
									reward = { amount = "575", icon = "coin" },
									title = "Medic",
								},
								{
									description = "Open 15 chests",
									goal = 15,
									icon = "file-box",
									id = "quest_looter",
									progress = 3,
									reward = { amount = "175", icon = "coin" },
									title = "Looter",
								},
							},
						},
					},
				},
				{
					icon = "calendar",
					id = "tab_weekly",
					label = "Weekly",
					sections = {
						{
							id = "quests_3",
							kind = "Quests",
							quests = {
								{
									description = "Visit 6 named locations",
									goal = 6,
									icon = "location-pin-map",
									id = "quest_explorer_2",
									progress = 6,
									reward = { amount = "375", icon = "coin" },
									title = "Explorer",
								},
								{
									description = "Place top 10 5 times",
									goal = 5,
									icon = "trophy",
									id = "quest_survivor_2",
									progress = 1,
									reward = { amount = "175", icon = "coin" },
									title = "Survivor",
								},
							},
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_achievements",
					label = "Achievements",
					sections = {
						{
							achievements = {
								{
									description = "Play your first match",
									goal = 1,
									icon = "person-falling",
									id = "ach_dropped_in",
									points = 5,
									progress = 1,
									reward = { amount = "5", grant = { amount = 5, currency = "bux" }, icon = "star" },
									title = "Dropped In",
								},
								{
									description = "Open 200 chests",
									goal = 200,
									icon = "file-box",
									id = "ach_collector",
									points = 15,
									progress = 111,
									reward = { amount = "15", grant = { amount = 15, currency = "bux" }, icon = "star" },
									title = "Collector",
								},
								{
									description = "Finish top 10 25 times",
									goal = 25,
									icon = "trophy",
									id = "ach_top_10",
									points = 20,
									progress = 5,
									reward = { amount = "20", grant = { amount = 20, currency = "bux" }, icon = "star" },
									title = "Top 10",
								},
								{
									description = "Outlast the storm 50 times",
									goal = 50,
									icon = "shield-check",
									id = "ach_survivor",
									points = 25,
									progress = 0,
									reward = { amount = "20", grant = { amount = 20, currency = "bux" }, icon = "star" },
									title = "Survivor",
								},
								{
									description = "Get 500 eliminations",
									goal = 500,
									icon = "crosshairs",
									id = "ach_hunter",
									points = 30,
									progress = 0,
									reward = { amount = "25", grant = { amount = 25, currency = "bux" }, icon = "star" },
									title = "Hunter",
								},
								{
									description = "Win a match",
									goal = 1,
									icon = "crown",
									id = "ach_victory_royale",
									points = 30,
									progress = 0,
									reward = { amount = "30", grant = { amount = 30, currency = "bux" }, icon = "star" },
									title = "Victory Royale",
								},
								{
									description = "Win with a full squad",
									goal = 1,
									icon = "three-people",
									id = "ach_squad_goals",
									points = 35,
									progress = 0,
									reward = { amount = "35", grant = { amount = 35, currency = "bux" }, icon = "star" },
									title = "Squad Goals",
								},
							},
							id = "achievements",
							kind = "Achievements",
						},
					},
				},
			},
			title = "Challenges",
		},
		{
			description = "Pick up to eight emotes for the radial wheel (opened with B in game).",
			icon = "face-winking",
			id = "emotes",
			kind = "Window",
			nav = "None",
			sections = {
				{
					columns = 5,
					detail = true,
					id = "inventory_4",
					items = {
						{ equipped = true, icon = "arm", id = "inv_flex", name = "Flex", rarity = "Legendary" },
						{
							equipped = true,
							icon = "person-jumping",
							id = "inv_jump",
							name = "Jump",
							rarity = "Legendary",
						},
						{ equipped = true, icon = "thumb-down", id = "inv_boo_2", name = "Boo", rarity = "Rare" },
						{
							equipped = true,
							icon = "megaphone-cheer",
							id = "inv_cheer",
							name = "Cheer",
							rarity = "Uncommon",
						},
						{
							equipped = true,
							icon = "music-note",
							id = "inv_dance",
							name = "Dance",
							rarity = "Uncommon",
						},
						{
							equipped = true,
							icon = "circle-question",
							id = "inv_shrug_2",
							name = "Shrug",
							rarity = "Uncommon",
						},
						{ icon = "flame", id = "inv_taunt_2", name = "Taunt", rarity = "Uncommon" },
						{ icon = "crown", id = "inv_crown_2", name = "Crown", rarity = "Common" },
						{ icon = "thumb-up", id = "inv_gg_2", name = "GG", rarity = "Common" },
						{ icon = "square-face-waving-hand", id = "inv_wave_2", name = "Wave", rarity = "Common" },
					},
					kind = "Inventory",
					layout = "Bottom",
				},
				{
					body = "Equipped emotes appear on your emote wheel. Press B to open it in game.",
					id = "text",
					kind = "Text",
				},
			},
			size = "Medium",
			title = "Emotes",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Yellow",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "lobby",
					label = "Lobby",
					tone = "Dark",
				},
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Defeat",
			rewards = {
				{ amount = "+325", icon = "coin" },
				{ amount = "+400 XP", icon = "level" },
				{ amount = "+25", icon = "star" },
			},
			stats = {
				{ label = "Placement", value = "#its world" },
				{ label = "Eliminations", value = "29" },
				{ label = "Damage", value = "1,418" },
				{ label = "Survived", value = "5:41" },
			},
			title = "Results",
		},
		{
			delay = 5,
			description = "A one-time Starter Pack: 4 rewards for 99 Robux (was 299), counting down 24h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "shield",
			id = "offer",
			items = {
				{
					amount = "300",
					grant = { amount = 300, currency = "bux" },
					icon = "star",
					label = "Royale Bux",
				},
				{
					amount = "8,000",
					grant = { amount = 8000, currency = "coins" },
					icon = "coin",
					label = "Coins",
				},
				{ amount = "1", icon = "shield", label = "Epic Knight Commander" },
				{ amount = "3", icon = "arrow-progress-up", label = "2x XP (60m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "STARTER PACK",
			timer = 86400,
			title = "Starter Pack",
			tone = "Orange",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "crosshairs",
									id = "hitMarkers",
									label = "Hit Markers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "hashtag",
									id = "damageNumbers",
									label = "Damage Numbers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "list-bulleted",
									id = "killFeed",
									label = "Kill Feed",
									type = "Toggle",
								},
								{
									default = true,
									icon = "controller",
									id = "aimAssist",
									label = "Aim Assist",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Fire", gamepad = "ButtonR2", key = "MouseButton1" },
								{ action = "Aim", gamepad = "ButtonL2", key = "MouseButton2" },
								{ action = "Jump", gamepad = "ButtonA", key = "Space" },
								{ action = "Reload", gamepad = "ButtonX", key = "R" },
								{ action = "Crouch", gamepad = "ButtonR3", key = "LeftControl" },
								{ action = "Sprint", gamepad = "ButtonL3", key = "LeftShift" },
								{ action = "Map", gamepad = "ButtonSelect", key = "M" },
								{ action = "Emote", gamepad = "DPadDown", key = "B" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "loading",
	style = {
		archetype = "Sleek",
		buttonStyle = "Outline",
		clickFx = "Glow",
		corner = "Sharp",
		decoration = "Grain",
		density = "Compact",
		fonts = { body = "BuilderSans:SemiBold", display = "Oswald:Bold", title = "Oswald:Bold" },
		iconStyle = "Filled",
		palette = {
			accent = "#FF7A30",
			bad = "#E5484D",
			good = "#7ED957",
			primary = "#A8B545",
			secondary = "#C2A878",
			surface = "#1C1F17",
			text = "#EEF0E6",
			warn = "#F2C14E",
		},
		panelStyle = "Glass",
		rarityScheme = "Classic",
		shadow = "Soft",
		textCase = "Normal",
		transition = "Swap",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="50">
              <Properties>
                <string name="Name">casual_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: casual, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FF8FD0",
			format = "comma",
			icon = "tag",
			id = "tickets",
			name = "Tickets",
			source = { kind = "leaderstat", name = "Tickets" },
			start = 425,
		},
		{
			color = "#3AD1FF",
			format = "comma",
			icon = "gem",
			id = "gems",
			name = "Gems",
			source = { kind = "attribute", name = "Gems" },
			start = 125,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopLeft",
				id = "hud_timer",
				label = "Round",
				seconds = 120,
				type = "Timer",
				visible = true,
			},
			{
				anchor = "TopLeft",
				id = "hud_objective",
				text = "Dodge the Balls",
				type = "Objective",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopCenter",
				currencies = { "tickets", "gems" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Toggle", target = "inventory" },
						icon = "backpack",
						id = "btn_inventory",
						label = "Collection",
						tone = "Blue",
					},
					{
						action = { kind = "Toggle", target = "rewards" },
						icon = "gift",
						id = "btn_rewards",
						label = "Free Rewards",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Shop",
						tone = "Pink",
					},
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Challenges",
						tone = "Yellow",
					},
					{
						action = { kind = "Open", target = "codes" },
						icon = "tag",
						id = "btn_codes",
						label = "Codes",
						tone = "Blue",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
			{
				anchor = "Right",
				id = "hud_questtracker",
				tasks = {
					{
						goal = 10,
						icon = "trophy",
						progress = 10,
						reward = { amount = "250", icon = "tag" },
						text = "Win 10 rounds",
					},
					{
						goal = 5,
						icon = "calendar",
						progress = 4,
						reward = { amount = "100", icon = "tag" },
						text = "Play 5 days in a row",
					},
				},
				title = "Quests",
				type = "QuestTracker",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_xpbar",
				level = 25,
				progress = 0.53,
				type = "XPBar",
				visible = true,
			},
			{
				anchor = "TopRight",
				goal = 8,
				icon = "crown",
				id = "hud_counter",
				label = "ROUND",
				style = "Plaque",
				type = "Counter",
				value = 2,
				visible = true,
			},
			{
				anchor = "BottomLeft",
				icon = "crown",
				id = "hud_meter",
				label = "CROWN",
				segments = 5,
				style = "Dial",
				tone = "Yellow",
				type = "Meter",
				value = 0.4,
				visible = true,
			},
		},
		kit = "Chunky Toy",
		layout = "TopBar",
	},
	logo = {
		icon = "gift",
		subtitle = "100+ MINIGAMES",
		text = "Minigames Party",
		tilt = 5,
		tone = "Pink",
		type = "Stacked",
	},
	meta = {
		flavor = "party",
		genre = "casual",
		kit = "Chunky Toy",
		prompt = "a casual game",
		seed = 21,
		source = "offline",
		title = "Minigames Party",
	},
	screens = {
		{
			description = "The title screen: the Minigames Party logo, JOIN ROUND, Collection, Shop, Challenges and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "JOIN ROUND",
							tone = "Pink",
						},
						{
							action = { kind = "Open", target = "inventory" },
							icon = "backpack",
							id = "menu_inventory",
							label = "Collection",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "quests" },
							icon = "quest",
							id = "menu_quests",
							label = "Challenges",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "cursor-stars",
					id = "welcome_bundle",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 172800,
					title = "Welcome Bundle",
					tone = "Yellow",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Every cosmetic the player owns in one grid, rarest first, with a detail bar underneath for equipping.",
			icon = "backpack",
			id = "inventory",
			kind = "Window",
			nav = "None",
			sections = {
				{
					columns = 6,
					detail = true,
					id = "inventory_2",
					items = {
						{
							equipped = true,
							icon = "butterfly-wings",
							id = "inv_rainbow_wings",
							name = "Rainbow Wings",
							rarity = "Mythic",
						},
						{ equipped = true, icon = "crown", id = "inv_crown", name = "Crown", rarity = "Legendary" },
						{
							equipped = true,
							icon = "cursor-stars",
							id = "inv_confetti_burst",
							name = "Confetti Burst",
							rarity = "Epic",
						},
						{ icon = "heart", id = "inv_heart_aura", name = "Heart Aura", rarity = "Rare" },
						{ icon = "star", id = "inv_star_trail", name = "Star Trail", rarity = "Rare" },
						{ count = 24, icon = "bow-tie", id = "inv_bow_tie", name = "Bow Tie", rarity = "Uncommon" },
						{
							count = 23,
							icon = "music",
							id = "inv_music_aura",
							name = "Music Aura",
							rarity = "Uncommon",
						},
						{ count = 13, icon = "hat-fedora", id = "inv_top_hat", name = "Top Hat", rarity = "Uncommon" },
						{ count = 5, icon = "hat-toque", id = "inv_party_hat", name = "Party Hat", rarity = "Common" },
						{
							count = 22,
							icon = "glasses",
							id = "inv_sunglasses",
							name = "Sunglasses",
							rarity = "Common",
						},
					},
					kind = "Inventory",
					layout = "Bottom",
				},
			},
			size = "Large",
			title = "Collection",
		},
		{
			description = "Everything free in one place: Daily, Gifts and Spin. Every tab badges what's ready, and so does the HUD button.",
			icon = "gift",
			id = "rewards",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							days = {
								{ amount = "150", day = 1, icon = "tag" },
								{ amount = "225", day = 2, icon = "tag" },
								{ amount = "x2", day = 3, icon = "boost" },
								{ amount = "30", day = 4, icon = "gem" },
								{ amount = "450", day = 5, icon = "tag" },
								{ amount = "525", day = 6, icon = "tag" },
								{ amount = "75", day = 7, icon = "gem" },
								{ amount = "250", day = 8, icon = "tag" },
								{ amount = "350", day = 9, icon = "tag" },
								{ amount = "x2", day = 10, icon = "boost" },
								{ amount = "50", day = 11, icon = "gem" },
								{ amount = "725", day = 12, icon = "tag" },
								{ amount = "850", day = 13, icon = "tag" },
								{ amount = "125", day = 14, icon = "gem" },
								{ amount = "325", day = 15, icon = "tag" },
								{ amount = "500", day = 16, icon = "tag" },
								{ amount = "x3", day = 17, icon = "boost" },
								{ amount = "65", day = 18, icon = "gem" },
								{ amount = "1,000", day = 19, icon = "tag" },
								{ amount = "1,250", day = 20, icon = "tag" },
								{ amount = "175", day = 21, icon = "gem" },
								{ amount = "425", day = 22, icon = "tag" },
								{ amount = "625", day = 23, icon = "tag" },
								{ amount = "x3", day = 24, icon = "boost" },
								{ amount = "85", day = 25, icon = "gem" },
								{ amount = "1,250", day = 26, icon = "tag" },
								{ amount = "1,500", day = 27, icon = "tag" },
								{ amount = "x1", day = 28, icon = "crown", label = "Mega Capsule" },
							},
							id = "daily",
							kind = "Daily",
							layout = "Calendar",
							streak = 4,
						},
					},
				},
				{
					icon = "gift",
					id = "tab_gifts",
					label = "Gifts",
					sections = {
						{
							gifts = {
								{
									amount = "55",
									grant = { amount = 55, currency = "tickets" },
									icon = "tag",
									id = "gift1",
									minutes = 1,
									rarity = "Common",
								},
								{
									amount = "80",
									grant = { amount = 80, currency = "tickets" },
									icon = "tag",
									id = "gift2",
									minutes = 3,
									rarity = "Common",
								},
								{
									amount = "100",
									grant = { amount = 100, currency = "tickets" },
									icon = "tag",
									id = "gift3",
									minutes = 5,
									rarity = "Common",
								},
								{
									amount = "30m",
									icon = "coin",
									id = "gift4",
									label = "2x Tickets",
									minutes = 10,
									rarity = "Rare",
								},
								{
									amount = "250",
									grant = { amount = 250, currency = "tickets" },
									icon = "tag",
									id = "gift5",
									minutes = 15,
									rarity = "Uncommon",
								},
								{
									amount = "325",
									grant = { amount = 325, currency = "tickets" },
									icon = "tag",
									id = "gift6",
									minutes = 20,
									rarity = "Rare",
								},
								{
									amount = "10",
									grant = { amount = 10, currency = "gems" },
									icon = "gem",
									id = "gift7",
									minutes = 30,
									rarity = "Rare",
								},
								{
									amount = "50",
									grant = { amount = 50, currency = "gems" },
									icon = "gem",
									id = "gift8",
									minutes = 45,
									rarity = "Legendary",
								},
							},
							id = "gifts",
							kind = "Gifts",
						},
					},
				},
				{
					icon = "chart-pie",
					id = "tab_spin",
					label = "Spin",
					sections = {
						{
							currency = "gems",
							freeHours = 24,
							id = "spin",
							kind = "Spin",
							luckPrice = 249,
							price = 60,
							wedges = {
								{
									amount = "90",
									chance = 26,
									grant = { amount = 90, currency = "tickets" },
									icon = "tag",
									id = "wedge1",
									rarity = "Common",
								},
								{
									amount = "900",
									chance = 10,
									grant = { amount = 900, currency = "tickets" },
									icon = "tag",
									id = "wedge5",
									rarity = "Rare",
								},
								{
									amount = "175",
									chance = 22,
									grant = { amount = 175, currency = "tickets" },
									icon = "tag",
									id = "wedge2",
									rarity = "Common",
								},
								{
									amount = "30",
									chance = 7,
									grant = { amount = 30, currency = "gems" },
									icon = "gem",
									id = "wedge6",
									rarity = "Epic",
								},
								{
									amount = "350",
									chance = 16,
									grant = { amount = 350, currency = "tickets" },
									icon = "tag",
									id = "wedge3",
									rarity = "Uncommon",
								},
								{
									amount = "x1",
									chance = 1.5,
									icon = "crown",
									id = "wedge8",
									label = "Crown",
									rarity = "Legendary",
								},
								{
									amount = "10",
									chance = 12,
									grant = { amount = 10, currency = "gems" },
									icon = "gem",
									id = "wedge4",
									rarity = "Uncommon",
								},
								{
									amount = "1,500",
									chance = 0.5,
									grant = { amount = 1500, currency = "gems" },
									icon = "gem",
									id = "wedge9",
									rarity = "Mythic",
								},
								{
									amount = "30m",
									chance = 5,
									icon = "coin",
									id = "wedge7",
									label = "2x Tickets",
									rarity = "Rare",
								},
							},
						},
					},
				},
			},
			title = "Free Rewards",
		},
		{
			description = "Where Tickets and Robux turn into progress. Tabs: Items, Capsules, Gems and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					icon = "backpack",
					id = "tab_items",
					label = "Items",
					sections = {
						{
							id = "products",
							items = {
								{
									amount = "Common",
									currency = "tickets",
									icon = "hat-toque",
									id = "buy_party_hat",
									price = 100,
									title = "Party Hat",
									tone = "Gray",
								},
								{
									amount = "Common",
									currency = "tickets",
									icon = "glasses",
									id = "buy_sunglasses",
									price = 100,
									title = "Sunglasses",
									tone = "Gray",
								},
								{
									amount = "Uncommon",
									currency = "tickets",
									icon = "music",
									id = "buy_music_aura",
									price = 300,
									title = "Music Aura",
									tone = "Green",
								},
								{
									amount = "Rare",
									currency = "tickets",
									icon = "heart",
									id = "buy_heart_aura",
									price = 700,
									title = "Heart Aura",
									tone = "Blue",
								},
								{
									amount = "Epic",
									currency = "tickets",
									icon = "cursor-stars",
									id = "buy_confetti_burst",
									price = 1750,
									title = "Confetti Burst",
									tone = "Purple",
								},
								{
									amount = "Mythic",
									currency = "tickets",
									icon = "butterfly-wings",
									id = "buy_rainbow_wings",
									price = 12500,
									tag = "LIMITED",
									title = "Rainbow Wings",
									tone = "Red",
								},
							},
							kind = "Products",
							layout = "Compact",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_capsules",
					label = "Capsules",
					sections = {
						{
							cases = {
								{
									currency = "tickets",
									icon = "gift",
									id = "case_capsule",
									pool = {
										{ chance = 29.5, icon = "hat-toque", name = "Party Hat", rarity = "Common" },
										{ chance = 29, icon = "glasses", name = "Sunglasses", rarity = "Common" },
										{ chance = 13, icon = "bow-tie", name = "Bow Tie", rarity = "Uncommon" },
										{ chance = 13, icon = "music", name = "Music Aura", rarity = "Uncommon" },
										{ chance = 6.3, icon = "heart", name = "Heart Aura", rarity = "Rare" },
										{ chance = 6.3, icon = "star", name = "Star Trail", rarity = "Rare" },
										{ chance = 2.9, icon = "cursor-stars", name = "Confetti Burst", rarity = "Epic" },
									},
									price = 100,
									title = "Capsule",
									tone = "Pink",
								},
								{
									currency = "tickets",
									icon = "star",
									id = "case_star_capsule",
									pool = {
										{ chance = 29.8, icon = "bow-tie", name = "Bow Tie", rarity = "Uncommon" },
										{ chance = 31, icon = "music", name = "Music Aura", rarity = "Uncommon" },
										{ chance = 15, icon = "heart", name = "Heart Aura", rarity = "Rare" },
										{ chance = 15, icon = "star", name = "Star Trail", rarity = "Rare" },
										{ chance = 6.7, icon = "cursor-stars", name = "Confetti Burst", rarity = "Epic" },
										{ chance = 2.5, icon = "crown", name = "Crown", rarity = "Legendary" },
									},
									price = 275,
									title = "Star Capsule",
									tone = "Yellow",
								},
								{
									currency = "gems",
									icon = "crown",
									id = "case_mega_capsule",
									pool = {
										{ chance = 37.3, icon = "heart", name = "Heart Aura", rarity = "Rare" },
										{ chance = 38, icon = "star", name = "Star Trail", rarity = "Rare" },
										{ chance = 17, icon = "cursor-stars", name = "Confetti Burst", rarity = "Epic" },
										{ chance = 6.3, icon = "crown", name = "Crown", rarity = "Legendary" },
										{ chance = 1.4, icon = "butterfly-wings", name = "Rainbow Wings", rarity = "Mythic" },
									},
									price = 375,
									title = "Mega Capsule",
									tone = "Purple",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "gem",
					id = "tab_gems",
					label = "Gems",
					sections = {
						{
							id = "gem_bundles",
							items = {
								{
									amount = "100",
									currency = "robux",
									grant = { amount = 100, currency = "gems" },
									icon = "gem",
									id = "gems_pack_1",
									price = 49,
									title = "Handful of Gems",
									tone = "Blue",
								},
								{
									amount = "225",
									bonus = 11,
									currency = "robux",
									grant = { amount = 225, currency = "gems" },
									icon = "gem",
									id = "gems_pack_2",
									price = 99,
									tag = "SALE",
									title = "Pouch of Gems",
									tone = "Blue",
									was = 149,
								},
								{
									amount = "500",
									bonus = 23,
									currency = "robux",
									grant = { amount = 500, currency = "gems" },
									icon = "gem",
									id = "gems_pack_3",
									price = 199,
									title = "Sack of Gems",
									tone = "Pink",
								},
								{
									amount = "1,250",
									bonus = 54,
									currency = "robux",
									grant = { amount = 1250, currency = "gems" },
									icon = "file-box",
									id = "gems_pack_4",
									price = 399,
									tag = "BEST VALUE",
									title = "Chest of Gems",
									tone = "Pink",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Gem Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Tickets",
									icon = "crown",
									id = "pass_vip",
									price = 499,
									tag = "BEST VALUE",
									title = "VIP",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Earn twice the Tickets, forever",
									icon = "coin",
									id = "pass_2x_tickets",
									price = 299,
									title = "2x Tickets",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "More moves to show off in the lobby",
									icon = "face-winking",
									id = "pass_extra_emotes",
									price = 75,
									title = "Extra Emotes",
									tone = "Blue",
								},
							},
							kind = "Products",
							layout = "Carousel",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Quest cards with progress bars under a reset countdown; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "None",
			sections = {
				{
					icon = "timer",
					id = "new_quests_in",
					kind = "Banner",
					subtitle = "Finish all quests for a bonus chest",
					timer = 39600,
					title = "New quests in",
					tone = "Blue",
				},
				{
					id = "quests_2",
					kind = "Quests",
					quests = {
						{
							description = "Play with 3 friends",
							goal = 3,
							icon = "two-people",
							id = "quest_social",
							progress = 3,
							reward = { amount = "175", icon = "tag" },
							title = "Social",
						},
						{
							description = "Play 5 days in a row",
							goal = 5,
							icon = "calendar",
							id = "quest_daily_streak",
							progress = 0,
							reward = { amount = "25", icon = "gem" },
							title = "Daily Streak",
						},
						{
							description = "Win 5 rounds",
							goal = 5,
							icon = "trophy",
							id = "quest_winner",
							progress = 0,
							reward = { amount = "300", icon = "tag" },
							title = "Winner",
						},
						{
							description = "Unlock 3 cosmetics",
							goal = 3,
							icon = "gift",
							id = "quest_collector",
							progress = 0,
							reward = { amount = "125", icon = "tag" },
							title = "Collector",
						},
					},
				},
			},
			size = "Medium",
			title = "Challenges",
		},
		{
			description = "Redeem promo codes. The server validates them through the RedeemCode remote.",
			icon = "tag",
			id = "codes",
			kind = "Popup",
			nav = "None",
			sections = {
				{ body = "Like the game and join the group for secret codes.", id = "text", kind = "Text" },
				{
					action = { kind = "Remote", name = "RedeemCode" },
					button = "Redeem",
					id = "codes_2",
					kind = "Codes",
					placeholder = "e.g. RELEASE",
				},
			},
			size = "Small",
			title = "Codes",
			transition = "Pop",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Pink",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "main_menu",
					label = "Main Menu",
					tone = "Dark",
				},
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Victory",
			rewards = {
				{ amount = "+55", icon = "tag" },
				{ amount = "+500 XP", icon = "level" },
				{ amount = "+25", icon = "gem" },
			},
			stats = {
				{ label = "Place", value = "2nd" },
				{ label = "Rounds Won", value = "5" },
				{ label = "Streak", value = "8" },
			},
			title = "Results",
		},
		{
			delay = 5,
			description = "A one-time Welcome Bundle: 4 rewards for 99 Robux (was 299), counting down 48h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "cursor-stars",
			id = "offer",
			items = {
				{ amount = "300", grant = { amount = 300, currency = "gems" }, icon = "gem", label = "Gems" },
				{
					amount = "6,000",
					grant = { amount = 6000, currency = "tickets" },
					icon = "tag",
					label = "Tickets",
				},
				{ amount = "1", icon = "cursor-stars", label = "Epic Confetti Burst" },
				{ amount = "3", icon = "coin", label = "2x Tickets (30m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "LIMITED",
			timer = 172800,
			title = "Welcome Bundle",
			tone = "Yellow",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Candy",
		buttonStyle = "Glossy3D",
		clickFx = "Squash",
		corner = "Bubbly",
		decoration = "Sparkles",
		density = "Compact",
		fonts = { body = "BuilderSans:Bold", display = "LuckiestGuy", title = "LuckiestGuy" },
		iconStyle = "Filled",
		palette = {
			accent = "#FFD23F",
			bad = "#FF4F4F",
			good = "#4DF08A",
			primary = "#FF4F8B",
			secondary = "#4FB8FF",
			surface = "#2B1650",
			text = "#FFFFFF",
			warn = "#FFB020",
		},
		panelStyle = "Ribbon",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Pop",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="51">
              <Properties>
                <string name="Name">casual_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: casual, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFE14F",
			format = "comma",
			icon = "star",
			id = "stars",
			name = "Stars",
			source = { kind = "leaderstat", name = "Stars" },
			start = 100,
		},
		{
			color = "#3AD1FF",
			format = "comma",
			icon = "gem",
			id = "gems",
			name = "Gems",
			source = { kind = "attribute", name = "Gems" },
			start = 200,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Free chest",
				look = "Flip",
				seconds = 1800,
				type = "Timer",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_objective",
				text = "Merge a level 5 cake",
				type = "Objective",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopRight",
				currencies = { "stars", "gems" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				buttons = {
					{
						action = { kind = "Toggle", target = "party" },
						icon = "friends",
						id = "btn_party",
						label = "Party",
						tone = "Purple",
					},
					{
						action = { kind = "Toggle", target = "rewards" },
						icon = "gift",
						id = "btn_rewards",
						label = "Free Rewards",
						tone = "Cyan",
					},
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Shop",
						tone = "Pink",
					},
					{
						action = { kind = "Toggle", target = "worlds" },
						icon = "map",
						id = "btn_worlds",
						label = "Minigames",
						tone = "Cyan",
					},
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Challenges",
						tone = "Purple",
					},
					{
						action = { kind = "Toggle", target = "leaderboard" },
						icon = "trophy",
						id = "btn_leaderboard",
						label = "Rankings",
						tone = "Cyan",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "White",
					},
				},
				id = "hud_menubuttons",
				style = "Dock",
				type = "MenuButtons",
				visible = true,
			},
			{
				anchor = "Right",
				boosts = { { icon = "coin", seconds = 1508, text = "2x Stars" } },
				id = "hud_boosts",
				type = "Boosts",
				visible = true,
			},
			{
				anchor = "Right",
				id = "hud_questtracker",
				tasks = {
					{
						goal = 5,
						icon = "gift",
						progress = 5,
						reward = { amount = "125", icon = "star" },
						text = "Unlock 5 cosmetics",
					},
					{
						goal = 5,
						icon = "trophy",
						progress = 2,
						reward = { amount = "225", icon = "star" },
						text = "Win 5 rounds",
					},
				},
				title = "Quests",
				type = "QuestTracker",
				visible = true,
			},
			{
				anchor = "Left",
				icon = "lightning-bolt",
				id = "hud_meter",
				label = "ENERGY",
				low = 0.2,
				segments = 8,
				style = "Segments",
				tone = "Green",
				type = "Meter",
				value = 0.63,
				visible = true,
			},
		},
		kit = "Chunky Toy",
		layout = "Dock",
	},
	logo = { icon = "star", text = "Merge Garden", tilt = -5, tone = "Pink", type = "Wordmark" },
	meta = {
		flavor = "merge",
		genre = "casual",
		kit = "Chunky Toy",
		prompt = "a casual game",
		seed = 7,
		source = "offline",
		title = "Merge Garden",
	},
	screens = {
		{
			description = "The title screen: the Merge Garden logo, JOIN ROUND, Party, Shop, Minigames, Challenges and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "JOIN ROUND",
							tone = "Pink",
						},
						{
							action = { kind = "Open", target = "party" },
							icon = "friends",
							id = "menu_party",
							label = "Party",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "worlds" },
							icon = "map",
							id = "menu_worlds",
							label = "Minigames",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "quests" },
							icon = "quest",
							id = "menu_quests",
							label = "Challenges",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "White",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "cursor-stars",
					id = "starter_pack",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 86400,
					title = "Starter Pack",
					tone = "Cyan",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Invite friends into your party and pick a mode together; the party leader's pick drives matchmaking.",
			icon = "friends",
			id = "party",
			kind = "Window",
			nav = "None",
			sections = {
				{ id = "party_2", kind = "Party", slots = 4 },
				{
					buttons = {
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "trophy",
							id = "mode_ranked",
							label = "Ranked",
							tone = "Pink",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "gear",
							id = "mode_custom",
							label = "Custom",
							tone = "White",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "play",
							id = "mode_quick_match",
							label = "Quick Match",
							tone = "White",
						},
					},
					id = "modes",
					kind = "Buttons",
					layout = "Grid",
					title = "Modes",
				},
			},
			size = "Medium",
			title = "Party",
		},
		{
			description = "Everything free in one place: Daily, Gifts and Spin. Every tab badges what's ready, and so does the HUD button.",
			icon = "gift",
			id = "rewards",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							days = {
								{ amount = "150", day = 1, icon = "star" },
								{ amount = "225", day = 2, icon = "star" },
								{ amount = "40", day = 3, icon = "gem" },
								{ amount = "375", day = 4, icon = "star" },
								{ amount = "x2", day = 5, icon = "boost", label = "15 min" },
								{ amount = "525", day = 6, icon = "star" },
								{ amount = "x1", day = 7, icon = "crown", label = "Mega Capsule" },
							},
							id = "daily",
							kind = "Daily",
							streak = 2,
						},
					},
				},
				{
					icon = "gift",
					id = "tab_gifts",
					label = "Gifts",
					sections = {
						{
							gifts = {
								{
									amount = "70",
									grant = { amount = 70, currency = "stars" },
									icon = "star",
									id = "gift1",
									minutes = 1,
									rarity = "Common",
								},
								{
									amount = "100",
									grant = { amount = 100, currency = "stars" },
									icon = "star",
									id = "gift2",
									minutes = 3,
									rarity = "Common",
								},
								{
									amount = "150",
									grant = { amount = 150, currency = "stars" },
									icon = "star",
									id = "gift3",
									minutes = 5,
									rarity = "Common",
								},
								{
									amount = "225",
									grant = { amount = 225, currency = "stars" },
									icon = "star",
									id = "gift4",
									minutes = 10,
									rarity = "Uncommon",
								},
								{
									amount = "325",
									grant = { amount = 325, currency = "stars" },
									icon = "star",
									id = "gift5",
									minutes = 15,
									rarity = "Uncommon",
								},
								{
									amount = "30m",
									icon = "coin",
									id = "gift6",
									label = "2x Stars",
									minutes = 20,
									rarity = "Rare",
								},
								{
									amount = "10",
									grant = { amount = 10, currency = "gems" },
									icon = "gem",
									id = "gift7",
									minutes = 30,
									rarity = "Rare",
								},
								{
									amount = "850",
									grant = { amount = 850, currency = "stars" },
									icon = "star",
									id = "gift8",
									minutes = 45,
									rarity = "Rare",
								},
								{
									amount = "20",
									grant = { amount = 20, currency = "gems" },
									icon = "gem",
									id = "gift9",
									minutes = 60,
									rarity = "Epic",
								},
								{
									amount = "1,250",
									grant = { amount = 1250, currency = "stars" },
									icon = "star",
									id = "gift10",
									minutes = 75,
									rarity = "Rare",
								},
								{
									amount = "35",
									grant = { amount = 35, currency = "gems" },
									icon = "gem",
									id = "gift11",
									minutes = 90,
									rarity = "Epic",
								},
								{
									amount = "x1",
									icon = "cursor-stars",
									id = "gift12",
									label = "Confetti Burst",
									minutes = 120,
									rarity = "Epic",
								},
							},
							id = "gifts",
							kind = "Gifts",
						},
					},
				},
				{
					icon = "chart-pie",
					id = "tab_spin",
					label = "Spin",
					sections = {
						{
							currency = "gems",
							freeHours = 24,
							id = "spin",
							kind = "Spin",
							luckPrice = 249,
							price = 75,
							wedges = {
								{
									amount = "100",
									chance = 26,
									grant = { amount = 100, currency = "stars" },
									icon = "star",
									id = "wedge1",
									rarity = "Common",
								},
								{
									amount = "1,000",
									chance = 10,
									grant = { amount = 1000, currency = "stars" },
									icon = "star",
									id = "wedge5",
									rarity = "Rare",
								},
								{
									amount = "200",
									chance = 22,
									grant = { amount = 200, currency = "stars" },
									icon = "star",
									id = "wedge2",
									rarity = "Common",
								},
								{
									amount = "25",
									chance = 7,
									grant = { amount = 25, currency = "gems" },
									icon = "gem",
									id = "wedge6",
									rarity = "Epic",
								},
								{
									amount = "400",
									chance = 16,
									grant = { amount = 400, currency = "stars" },
									icon = "star",
									id = "wedge3",
									rarity = "Uncommon",
								},
								{
									amount = "500",
									chance = 1.5,
									grant = { amount = 500, currency = "gems" },
									icon = "gem",
									id = "wedge8",
									rarity = "Legendary",
								},
								{
									amount = "10",
									chance = 12,
									grant = { amount = 10, currency = "gems" },
									icon = "gem",
									id = "wedge4",
									rarity = "Uncommon",
								},
								{
									amount = "1,000",
									chance = 0.5,
									grant = { amount = 1000, currency = "gems" },
									icon = "gem",
									id = "wedge9",
									rarity = "Mythic",
								},
								{
									amount = "30m",
									chance = 5,
									icon = "coin",
									id = "wedge7",
									label = "2x Stars",
									rarity = "Rare",
								},
							},
						},
					},
				},
			},
			title = "Free Rewards",
		},
		{
			description = "Where Stars and Robux turn into progress. Tabs: Items, Capsules, Gems and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "backpack",
					id = "tab_items",
					label = "Items",
					sections = {
						{
							id = "products",
							items = {
								{
									amount = "Uncommon",
									currency = "stars",
									icon = "music",
									id = "buy_music_aura",
									price = 250,
									title = "Music Aura",
									tone = "Green",
								},
								{
									amount = "Uncommon",
									currency = "stars",
									icon = "hat-fedora",
									id = "buy_top_hat",
									price = 250,
									title = "Top Hat",
									tone = "Green",
								},
								{
									amount = "Rare",
									currency = "stars",
									icon = "star",
									id = "buy_star_trail",
									price = 700,
									title = "Star Trail",
									tone = "Blue",
								},
								{
									amount = "Rare",
									currency = "stars",
									icon = "heart",
									id = "buy_heart_aura",
									price = 775,
									title = "Heart Aura",
									tone = "Blue",
								},
								{
									amount = "Legendary",
									currency = "stars",
									icon = "crown",
									id = "buy_crown",
									price = 5000,
									title = "Crown",
									tone = "Yellow",
								},
								{
									amount = "Mythic",
									currency = "stars",
									icon = "butterfly-wings",
									id = "buy_rainbow_wings",
									price = 12500,
									tag = "HOT",
									title = "Rainbow Wings",
									tone = "Red",
								},
							},
							kind = "Products",
							layout = "Cards",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_capsules",
					label = "Capsules",
					sections = {
						{
							cases = {
								{
									currency = "stars",
									icon = "gift",
									id = "case_capsule",
									pool = {
										{ chance = 29.5, icon = "hat-toque", name = "Party Hat", rarity = "Common" },
										{ chance = 29, icon = "glasses", name = "Sunglasses", rarity = "Common" },
										{ chance = 13, icon = "bow-tie", name = "Bow Tie", rarity = "Uncommon" },
										{ chance = 13, icon = "music", name = "Music Aura", rarity = "Uncommon" },
										{ chance = 6.3, icon = "heart", name = "Heart Aura", rarity = "Rare" },
										{ chance = 6.3, icon = "star", name = "Star Trail", rarity = "Rare" },
										{ chance = 2.9, icon = "cursor-stars", name = "Confetti Burst", rarity = "Epic" },
									},
									price = 90,
									title = "Capsule",
									tone = "Pink",
								},
								{
									currency = "stars",
									icon = "star",
									id = "case_star_capsule",
									pool = {
										{ chance = 29.8, icon = "bow-tie", name = "Bow Tie", rarity = "Uncommon" },
										{ chance = 31, icon = "music", name = "Music Aura", rarity = "Uncommon" },
										{ chance = 15, icon = "heart", name = "Heart Aura", rarity = "Rare" },
										{ chance = 15, icon = "star", name = "Star Trail", rarity = "Rare" },
										{ chance = 6.7, icon = "cursor-stars", name = "Confetti Burst", rarity = "Epic" },
										{ chance = 2.5, icon = "crown", name = "Crown", rarity = "Legendary" },
									},
									price = 350,
									title = "Star Capsule",
									tone = "Yellow",
								},
								{
									currency = "stars",
									icon = "crown",
									id = "case_mega_capsule",
									pool = {
										{ chance = 37.3, icon = "heart", name = "Heart Aura", rarity = "Rare" },
										{ chance = 38, icon = "star", name = "Star Trail", rarity = "Rare" },
										{ chance = 17, icon = "cursor-stars", name = "Confetti Burst", rarity = "Epic" },
										{ chance = 6.3, icon = "crown", name = "Crown", rarity = "Legendary" },
										{ chance = 1.4, icon = "butterfly-wings", name = "Rainbow Wings", rarity = "Mythic" },
									},
									price = 1000,
									title = "Mega Capsule",
									tone = "Purple",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "gem",
					id = "tab_gems",
					label = "Gems",
					sections = {
						{
							icon = "gem",
							id = "stock_up_on_gems",
							kind = "Banner",
							subtitle = "Bigger packs pack more bonus Gems",
							title = "Stock Up on Gems",
							tone = "Pink",
						},
						{
							id = "gem_bundles",
							items = {
								{
									amount = "150",
									currency = "robux",
									grant = { amount = 150, currency = "gems" },
									icon = "gem",
									id = "gems_pack_1",
									price = 49,
									title = "Starter Gems",
									tone = "Purple",
								},
								{
									amount = "350",
									bonus = 15,
									currency = "robux",
									grant = { amount = 350, currency = "gems" },
									icon = "gem",
									id = "gems_pack_2",
									price = 99,
									tag = "POPULAR",
									title = "Small Gem Pack",
									tone = "Purple",
								},
								{
									amount = "750",
									bonus = 23,
									currency = "robux",
									grant = { amount = 750, currency = "gems" },
									icon = "gem",
									id = "gems_pack_3",
									price = 199,
									title = "Big Gem Pack",
									tone = "Pink",
								},
								{
									amount = "1,750",
									bonus = 43,
									currency = "robux",
									grant = { amount = 1750, currency = "gems" },
									icon = "file-box",
									id = "gems_pack_4",
									price = 399,
									tag = "BEST VALUE",
									title = "Mega Gem Pack",
									tone = "Pink",
								},
							},
							kind = "Products",
							layout = "Cards",
							title = "Gem Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Stars",
									icon = "crown",
									id = "pass_vip",
									price = 399,
									tag = "BEST VALUE",
									title = "VIP",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Earn twice the Stars, forever",
									icon = "coin",
									id = "pass_2x_stars",
									price = 299,
									title = "2x Stars",
									tone = "Cyan",
								},
								{
									currency = "robux",
									description = "More moves to show off in the lobby",
									icon = "face-winking",
									id = "pass_extra_emotes",
									price = 99,
									title = "Extra Emotes",
									tone = "Purple",
								},
							},
							kind = "Products",
							layout = "Cards",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "The map of Merge Garden: every area as a card, locked ones showing what it takes to get there.",
			icon = "map",
			id = "worlds",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "worlds_2",
					kind = "Worlds",
					worlds = {
						{
							icon = "flame",
							id = "world_floor_is_lava",
							locked = false,
							name = "Floor is Lava",
							tone = "Pink",
						},
						{
							icon = "four-squares-grid",
							id = "world_colour_block",
							locked = false,
							name = "Colour Block",
							tone = "Purple",
						},
						{
							icon = "music",
							id = "world_musical_chairs",
							locked = true,
							name = "Musical Chairs",
							requirement = "Level 20",
							tone = "Cyan",
						},
						{
							icon = "person-running",
							id = "world_tag",
							locked = true,
							name = "Tag",
							requirement = "Level 30",
							tone = "Pink",
						},
						{
							icon = "circle-question",
							id = "world_trivia",
							locked = true,
							name = "Trivia",
							requirement = "Level 40",
							tone = "Purple",
						},
						{
							icon = "pumpkin",
							id = "world_hot_potato",
							locked = true,
							name = "Hot Potato",
							requirement = "Level 50",
							tone = "Cyan",
						},
					},
				},
			},
			size = "Large",
			title = "Minigames",
		},
		{
			description = "Daily and weekly quests with progress bars; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "Tabs",
			size = "Medium",
			tabs = {
				{
					badge = 1,
					icon = "daily",
					id = "tab_daily_2",
					label = "Daily",
					sections = {
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Play with 2 friends",
									goal = 2,
									icon = "two-people",
									id = "quest_social",
									progress = 2,
									reward = { amount = "50", icon = "gem" },
									title = "Social",
								},
								{
									description = "Unlock 3 cosmetics",
									goal = 3,
									icon = "gift",
									id = "quest_collector",
									progress = 1,
									reward = { amount = "275", icon = "star" },
									title = "Collector",
								},
								{
									description = "Play 4 days in a row",
									goal = 4,
									icon = "calendar",
									id = "quest_daily_streak",
									progress = 0,
									reward = { amount = "90", icon = "star" },
									title = "Daily Streak",
								},
							},
						},
					},
				},
				{
					icon = "calendar",
					id = "tab_weekly",
					label = "Weekly",
					sections = {
						{
							id = "quests_3",
							kind = "Quests",
							quests = {
								{
									description = "Win 3 rounds",
									goal = 3,
									icon = "trophy",
									id = "quest_winner",
									progress = 0,
									reward = { amount = "25", icon = "gem" },
									title = "Winner",
								},
							},
						},
					},
				},
			},
			title = "Challenges",
		},
		{
			description = "Global top ten for Wins, Streak and Stars, with podium colours for the top three.",
			icon = "trophy",
			id = "leaderboard",
			kind = "Window",
			nav = "Tabs",
			size = "Medium",
			tabs = {
				{
					icon = "trophy",
					id = "tab_wins",
					label = "Wins",
					sections = {
						{
							id = "leaderboard_2",
							kind = "Leaderboard",
							rows = {
								{ name = "Mango101", rank = 1, value = "2,197" },
								{ name = "Turbo2014", rank = 2, value = "1,941" },
								{ name = "ItsDoodleGamer", rank = 3, value = "1,791" },
								{ name = "NotLunaNinja", rank = 4, value = "1,397" },
								{ name = "xXSunnyAceXx", rank = 5, value = "1,234" },
								{ name = "CrimsonChefYT", rank = 6, value = "976" },
								{ name = "ShadowSprout88", rank = 7, value = "924" },
								{ name = "WobblyQueen", rank = 8, value = "700" },
								{ name = "Pixel21", rank = 9, value = "633" },
								{ name = "BlitzMoose_21", rank = 10, value = "525" },
							},
							stat = "Wins",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_streak",
					label = "Streak",
					sections = {
						{
							id = "leaderboard_3",
							kind = "Leaderboard",
							rows = {
								{ name = "ItsMochiWolf", rank = 1, value = "1,635" },
								{ name = "Crimson_Gamer", rank = 2, value = "1,210" },
								{ name = "xXBoltGoblinXx", rank = 3, value = "1,095" },
								{ name = "NotWobblyPilot", rank = 4, value = "930" },
								{ name = "RealRocketKing", rank = 5, value = "832" },
								{ name = "xXPixelDragonXx", rank = 6, value = "602" },
								{ name = "ZapPup64", rank = 7, value = "546" },
								{ name = "CrimsonOwl2011", rank = 8, value = "512" },
								{ name = "NotTacoDuck", rank = 9, value = "392" },
								{ name = "PicklePilot", rank = 10, value = "333" },
							},
							stat = "Streak",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_stars",
					label = "Stars",
					sections = {
						{
							id = "leaderboard_4",
							kind = "Leaderboard",
							rows = {
								{ name = "ChaosYeti", rank = 1, value = "91M" },
								{ name = "CosmicPilot_2011", rank = 2, value = "74.7M" },
								{ name = "xXMangoToastXx", rank = 3, value = "55.6M" },
								{ name = "MapleWolfYT", rank = 4, value = "50.3M" },
								{ name = "NotPickleHunter", rank = 5, value = "47.1M" },
								{ name = "RealNeonOtter", rank = 6, value = "42.3M" },
								{ name = "BubbleToast33", rank = 7, value = "34.3M" },
								{ name = "EmberChef7", rank = 8, value = "28.6M" },
								{ name = "xXCookieSlayerXx", rank = 9, value = "26.5M" },
								{ name = "MochiPanda99", rank = 10, value = "24.9M" },
							},
							stat = "Stars",
						},
					},
				},
			},
			title = "Rankings",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Pink",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "main_menu",
					label = "Main Menu",
					tone = "White",
				},
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Victory",
			rewards = { { amount = "+125", icon = "star" }, { amount = "+600 XP", icon = "level" } },
			stats = {
				{ label = "Place", value = "2nd" },
				{ label = "Rounds Won", value = "3" },
				{ label = "Streak", value = "2" },
			},
			title = "Results",
		},
		{
			delay = 5,
			description = "A one-time Starter Pack: 4 rewards for 99 Robux (was 299), counting down 24h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "cursor-stars",
			id = "offer",
			items = {
				{ amount = "450", grant = { amount = 450, currency = "gems" }, icon = "gem", label = "Gems" },
				{
					amount = "4,000",
					grant = { amount = 4000, currency = "stars" },
					icon = "star",
					label = "Stars",
				},
				{ amount = "1", icon = "cursor-stars", label = "Epic Confetti Burst" },
				{ amount = "3", icon = "coin", label = "2x Stars (30m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "STARTER PACK",
			timer = 86400,
			title = "Starter Pack",
			tone = "Cyan",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Bubbly",
		buttonStyle = "Soft",
		clickFx = "Poof",
		corner = "Bubbly",
		decoration = "Dots",
		density = "Comfy",
		fonts = { body = "Nunito:Bold", display = "DenkOne", title = "DenkOne" },
		iconStyle = "Filled",
		palette = {
			accent = "#4DB6AC",
			bad = "#E53935",
			good = "#4CAF50",
			primary = "#F06292",
			secondary = "#7E57C2",
			surface = "#3E2238",
			text = "#FFF5F8",
			warn = "#FFB300",
		},
		panelStyle = "Outlined",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Pop",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="52">
              <Properties>
                <string name="Name">fighting_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: fighting, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#5BE36B",
			format = "comma",
			icon = "cash",
			id = "cash",
			name = "Cash",
			source = { kind = "leaderstat", name = "Cash" },
			start = 9000,
		},
		{
			color = "#3AD1FF",
			format = "comma",
			icon = "gem",
			id = "gems",
			name = "Gems",
			source = { kind = "attribute", name = "Gems" },
			start = 475,
		},
	},
	hud = {
		elements = {
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopLeft",
				currencies = { "cash", "gems" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "TopCenter",
				goal = 3,
				id = "hud_teamscore",
				teams = {
					{ color = "#3AA8FF", name = "You", score = 2 },
					{ color = "#FF4B4B", name = "Rival", score = 1 },
				},
				type = "TeamScore",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Round",
				seconds = 99,
				type = "Timer",
				visible = true,
			},
			{
				anchor = "TopLeft",
				id = "hud_healthbar",
				max = 100,
				source = { kind = "humanoid" },
				type = "HealthBar",
				visible = true,
			},
			{ anchor = "BottomLeft", id = "hud_stamina", type = "Stamina", visible = true },
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				slots = {
					{ icon = "arm", name = "Punch" },
					{ icon = "shoe", name = "Kick" },
					{ icon = "shield", name = "Block" },
					{ icon = "flame", name = "Ultimate" },
				},
				type = "Hotbar",
				visible = true,
			},
			{
				action = { kind = "Open", target = "menu" },
				anchor = "TopLeft",
				icon = "menu",
				id = "hud_button",
				label = "Menu",
				tone = "Dark",
				type = "Button",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonL1", key = "F", text = "Block" },
					{ gamepad = "ButtonB", key = "Q", text = "Dash" },
					{ gamepad = "ButtonY", key = "G", text = "Ultimate" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "Center",
				emotes = {
					{ icon = "megaphone-cheer", id = "emote_cheer", label = "Cheer" },
					{ icon = "flame", id = "emote_taunt", label = "Taunt" },
					{ icon = "music-note", id = "emote_dance", label = "Dance" },
					{ icon = "face-winking", id = "emote_laugh", label = "Laugh" },
					{ icon = "square-face-waving-hand", id = "emote_wave", label = "Wave" },
					{ icon = "crown", id = "emote_crown", label = "Crown" },
				},
				id = "hud_emotewheel",
				key = "T",
				type = "EmoteWheel",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Punch" },
						icon = "arm",
						id = "touch_punch",
						label = "Punch",
					},
					{
						action = { kind = "Event", name = "Block" },
						icon = "shield",
						id = "touch_block",
						label = "Block",
					},
					{
						action = { kind = "Event", name = "Dash" },
						icon = "double-chevron-large-right",
						id = "touch_dash",
						label = "Dash",
					},
					{
						action = { kind = "Event", name = "Ultimate" },
						icon = "flame",
						id = "touch_ultimate",
						label = "Ultimate",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				buttons = {
					{
						action = { kind = "Toggle", target = "skills" },
						icon = "upgrade",
						id = "btn_skills",
						label = "Moves",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Dock",
				type = "MenuButtons",
				visible = true,
			},
		},
		kit = "Arcade",
		layout = "Dock",
	},
	logo = {
		icon = "lightning-bolt",
		subtitle = "KNOCKOUT EDITION",
		text = "Heroes Brawl",
		tilt = 5,
		tone = "Purple",
		type = "Badge",
	},
	meta = {
		genre = "fighting",
		kit = "Arcade",
		prompt = "a fighting game",
		seed = 21,
		source = "offline",
		title = "Heroes Brawl",
	},
	screens = {
		{
			bannerPosition = "Bottom",
			description = "The title screen: the Heroes Brawl logo, PLAY, Roster, Shop, Pass, Rankings and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Center",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "PLAY",
							tone = "Purple",
						},
						{
							action = { kind = "Open", target = "fighters" },
							icon = "arm",
							id = "menu_fighters",
							label = "Roster",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "battlepass" },
							icon = "premium",
							id = "menu_battlepass",
							label = "Pass",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "leaderboard" },
							icon = "trophy",
							id = "menu_leaderboard",
							label = "Rankings",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "cloud",
					id = "welcome_bundle",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 172800,
					title = "Welcome Bundle",
					tone = "Cyan",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Choose who you fight as. Each fighter card shows power, speed, defence and combo ratings.",
			icon = "arm",
			id = "fighters",
			kind = "Fullscreen",
			nav = "None",
			openKey = "C",
			sections = {
				{
					id = "loadout",
					kind = "Loadout",
					slots = {
						{
							icon = "sword",
							name = "Samurai",
							rarity = "Epic",
							stats = {
								{ label = "Power", value = 0.77 },
								{ label = "Speed", value = 0.7 },
								{ label = "Defence", value = 0.86 },
								{ label = "Combo", value = 0.89 },
							},
						},
						{
							icon = "lightning-bolt",
							name = "Speedster",
							rarity = "Rare",
							stats = {
								{ label = "Power", value = 0.44 },
								{ label = "Speed", value = 0.47 },
								{ label = "Defence", value = 0.63 },
								{ label = "Combo", value = 0.58 },
							},
						},
						{
							icon = "nebula",
							name = "Void Walker",
							rarity = "Mythic",
							stats = {
								{ label = "Power", value = 0.87 },
								{ label = "Speed", value = 0.98 },
								{ label = "Defence", value = 0.98 },
								{ label = "Combo", value = 0.78 },
							},
						},
					},
				},
				{
					columns = 5,
					detail = true,
					id = "inventory",
					items = {
						{ icon = "shield", id = "inv_titan", name = "Titan", rarity = "Legendary" },
						{ icon = "cloud", id = "inv_frost_monk", name = "Frost Monk", rarity = "Epic" },
						{ equipped = true, icon = "sword", id = "inv_samurai", name = "Samurai", rarity = "Epic" },
						{ icon = "flame", id = "inv_pyro", name = "Pyro", rarity = "Rare" },
						{
							equipped = true,
							icon = "lightning-bolt",
							id = "inv_speedster",
							name = "Speedster",
							rarity = "Rare",
						},
						{ icon = "star", id = "inv_hero", name = "Hero", rarity = "Uncommon" },
						{ icon = "ninja", id = "inv_ninja", name = "Ninja", rarity = "Uncommon" },
						{ icon = "arm", id = "inv_boxer", name = "Boxer", rarity = "Common" },
						{ icon = "arm", id = "inv_brawler", name = "Brawler", rarity = "Common" },
						{
							equipped = true,
							icon = "nebula",
							id = "locked_void_walker",
							locked = true,
							name = "Void Walker",
							rarity = "Mythic",
						},
					},
					kind = "Inventory",
					layout = "Bottom",
				},
			},
			size = "Large",
			title = "Roster",
		},
		{
			description = "Where Cash and Robux turn into progress. Tabs: Featured, Crates, Gems and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "Tabs",
			openKey = "B",
			size = "Large",
			tabs = {
				{
					callout = "Refreshes daily",
					icon = "sale",
					id = "tab_featured",
					label = "Featured",
					sections = {
						{
							id = "today_s_picks",
							items = {
								{
									amount = "3,250",
									currency = "robux",
									description = "3,250 Gems + an exclusive title",
									grant = { amount = 3250, currency = "gems" },
									icon = "crown",
									id = "featured_mega",
									price = 999,
									tag = "LIMITED",
									title = "Mega Bundle",
									tone = "Purple",
									was = 1999,
								},
								{
									currency = "gems",
									description = "Leaves the shop soon",
									icon = "cloud",
									id = "featured_frost_monk",
									price = 525,
									tag = "FEATURED",
									title = "Frost Monk",
									tone = "Purple",
								},
								{
									amount = "475",
									currency = "robux",
									description = "475 Gems + Frost Monk",
									grant = { amount = 475, currency = "gems" },
									icon = "gift",
									id = "featured_starter",
									price = 149,
									tag = "ONE TIME",
									title = "Newcomer Bundle",
									tone = "Pink",
									was = 299,
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Today's Picks",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_crates",
					label = "Crates",
					sections = {
						{
							cases = {
								{
									currency = "cash",
									icon = "star",
									id = "case_fighter_crate",
									pool = {
										{ chance = 28, icon = "arm", name = "Boxer", rarity = "Common" },
										{ chance = 28, icon = "arm", name = "Brawler", rarity = "Common" },
										{ chance = 13, icon = "star", name = "Hero", rarity = "Uncommon" },
										{ chance = 13, icon = "ninja", name = "Ninja", rarity = "Uncommon" },
										{ chance = 6.2, icon = "flame", name = "Pyro", rarity = "Rare" },
										{ chance = 6.2, icon = "lightning-bolt", name = "Speedster", rarity = "Rare" },
										{ chance = 2.8, icon = "cloud", name = "Frost Monk", rarity = "Epic" },
										{ chance = 2.8, icon = "sword", name = "Samurai", rarity = "Epic" },
									},
									price = 275,
									title = "Fighter Crate",
									tone = "Blue",
								},
								{
									currency = "cash",
									icon = "crown",
									id = "case_legend_crate",
									pool = {
										{ chance = 28.1, icon = "star", name = "Hero", rarity = "Uncommon" },
										{ chance = 29, icon = "ninja", name = "Ninja", rarity = "Uncommon" },
										{ chance = 14, icon = "flame", name = "Pyro", rarity = "Rare" },
										{ chance = 14, icon = "lightning-bolt", name = "Speedster", rarity = "Rare" },
										{ chance = 6.3, icon = "cloud", name = "Frost Monk", rarity = "Epic" },
										{ chance = 6.3, icon = "sword", name = "Samurai", rarity = "Epic" },
										{ chance = 2.3, icon = "shield", name = "Titan", rarity = "Legendary" },
									},
									price = 700,
									title = "Legend Crate",
									tone = "Yellow",
								},
								{
									currency = "gems",
									icon = "cursor-stars",
									id = "case_aura_crate",
									pool = {
										{ chance = 31.5, icon = "flame", name = "Pyro", rarity = "Rare" },
										{ chance = 32, icon = "lightning-bolt", name = "Speedster", rarity = "Rare" },
										{ chance = 15, icon = "cloud", name = "Frost Monk", rarity = "Epic" },
										{ chance = 15, icon = "sword", name = "Samurai", rarity = "Epic" },
										{ chance = 5.3, icon = "shield", name = "Titan", rarity = "Legendary" },
										{ chance = 1.2, icon = "nebula", name = "Void Walker", rarity = "Mythic" },
									},
									price = 375,
									title = "Aura Crate",
									tone = "Purple",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "gem",
					id = "tab_gems",
					label = "Gems",
					sections = {
						{
							id = "gem_bundles",
							items = {
								{
									amount = "100",
									currency = "robux",
									grant = { amount = 100, currency = "gems" },
									icon = "gem",
									id = "gems_pack_1",
									price = 49,
									title = "Handful of Gems",
									tone = "Pink",
								},
								{
									amount = "225",
									bonus = 11,
									currency = "robux",
									grant = { amount = 225, currency = "gems" },
									icon = "gem",
									id = "gems_pack_2",
									price = 99,
									tag = "SALE",
									title = "Pouch of Gems",
									tone = "Pink",
									was = 149,
								},
								{
									amount = "500",
									bonus = 23,
									currency = "robux",
									grant = { amount = 500, currency = "gems" },
									icon = "gem",
									id = "gems_pack_3",
									price = 199,
									title = "Sack of Gems",
									tone = "Purple",
								},
								{
									amount = "1,250",
									bonus = 54,
									currency = "robux",
									grant = { amount = 1250, currency = "gems" },
									icon = "file-box",
									id = "gems_pack_4",
									price = 399,
									tag = "BEST VALUE",
									title = "Chest of Gems",
									tone = "Purple",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Gem Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Cash",
									icon = "crown",
									id = "pass_vip",
									price = 499,
									tag = "MUST HAVE",
									title = "VIP",
									tone = "Purple",
								},
								{
									currency = "robux",
									description = "Earn twice the Cash, forever",
									icon = "coin",
									id = "pass_2x_cash",
									price = 249,
									title = "2x Cash",
									tone = "Cyan",
								},
								{
									currency = "robux",
									description = "Look the part, every match",
									icon = "cursor-stars",
									id = "pass_custom_aura",
									price = 199,
									title = "Custom Aura",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Stand out in every lobby",
									icon = "trophy",
									id = "pass_victory_pose_pack",
									price = 75,
									title = "Victory Pose Pack",
									tone = "Cyan",
								},
							},
							kind = "Products",
							layout = "Rows",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "20 tiers of free and premium rewards for the season, plus weekly challenges that feed pass XP.",
			icon = "premium",
			id = "battlepass",
			kind = "Fullscreen",
			nav = "None",
			sections = {
				{
					button = { action = { kind = "Event", name = "BuyPremiumPass" }, label = "Unlock Premium" },
					icon = "premium",
					id = "season_1_pass",
					kind = "Banner",
					subtitle = "Unlock the premium track: 20 exclusive rewards",
					timer = 1382400,
					title = "Season 1 Pass",
					tone = "Purple",
				},
				{
					id = "pass",
					kind = "Pass",
					tiers = {
						{
							free = { amount = "175", icon = "cash" },
							premium = { amount = "x1", icon = "trophy" },
							tier = 1,
						},
						{ premium = { amount = "x1", icon = "cursor-stars" }, tier = 2 },
						{
							free = { amount = "250", icon = "cash" },
							premium = { amount = "x1", icon = "star" },
							tier = 3,
						},
						{ premium = { amount = "40", icon = "gem" }, tier = 4 },
						{
							free = { amount = "x1", icon = "star" },
							premium = { amount = "x1", icon = "cursor-stars" },
							tier = 5,
						},
						{ premium = { amount = "x1", icon = "cursor-stars" }, tier = 6 },
						{
							free = { amount = "400", icon = "cash" },
							premium = { amount = "x1", icon = "trophy" },
							tier = 7,
						},
						{ premium = { amount = "60", icon = "gem" }, tier = 8 },
						{
							free = { amount = "450", icon = "cash" },
							premium = { amount = "x1", icon = "cursor-stars" },
							tier = 9,
						},
						{
							free = { amount = "x1", icon = "gem" },
							premium = { amount = "x1", icon = "crown" },
							tier = 10,
						},
						{
							free = { amount = "550", icon = "cash" },
							premium = { amount = "x1", icon = "star" },
							tier = 11,
						},
						{ premium = { amount = "80", icon = "gem" }, tier = 12 },
						{
							free = { amount = "625", icon = "cash" },
							premium = { amount = "x1", icon = "trophy" },
							tier = 13,
						},
						{ premium = { amount = "x1", icon = "trophy" }, tier = 14 },
						{
							free = { amount = "x1", icon = "star" },
							premium = { amount = "95", icon = "gem" },
							tier = 15,
						},
						{ premium = { amount = "100", icon = "gem" }, tier = 16 },
						{
							free = { amount = "775", icon = "cash" },
							premium = { amount = "x1", icon = "trophy" },
							tier = 17,
						},
						{ premium = { amount = "x1", icon = "star" }, tier = 18 },
						{
							free = { amount = "850", icon = "cash" },
							premium = { amount = "x1", icon = "trophy" },
							tier = 19,
						},
						{
							free = { amount = "x1", icon = "gem" },
							premium = { amount = "x1", icon = "crown" },
							tier = 20,
						},
					},
				},
				{
					id = "challenges",
					kind = "Quests",
					quests = {
						{
							description = "Land a 6-hit combo",
							goal = 6,
							icon = "arm",
							id = "quest_combo_artist",
							progress = 6,
							reward = { amount = "10", icon = "gem" },
							title = "Combo Artist",
						},
						{
							description = "Use your ultimate 8 times",
							goal = 8,
							icon = "flame",
							id = "quest_finisher",
							progress = 6,
							reward = { amount = "50", icon = "gem" },
							title = "Finisher",
						},
						{
							description = "Win 5 fights",
							goal = 5,
							icon = "trophy",
							id = "quest_winner",
							progress = 2,
							reward = { amount = "475", icon = "cash" },
							title = "Winner",
						},
						{
							description = "Knock 7 players off the map",
							goal = 7,
							icon = "double-chevron-large-right",
							id = "quest_ring_out",
							progress = 0,
							reward = { amount = "300", icon = "cash" },
							title = "Ring Out",
						},
					},
					title = "Challenges",
				},
			},
			size = "Large",
			title = "Pass",
		},
		{
			description = "Spend skill points on permanent perks. Points come from levelling up, so this screen gives levels meaning.",
			icon = "upgrade",
			id = "skills",
			kind = "Window",
			nav = "None",
			openKey = "K",
			sections = {
				{
					id = "stats",
					kind = "Stats",
					stats = { { icon = "star", label = "Skill Points", value = "4" } },
				},
				{
					id = "upgrades",
					kind = "Upgrades",
					upgrades = {
						{
							cost = 300,
							currency = "cash",
							effect = "+2 studs",
							icon = "double-chevron-large-right",
							id = "upgrade_dash_distance",
							level = 0,
							maxLevel = 10,
							title = "Dash Distance",
						},
						{
							cost = 1500,
							currency = "cash",
							effect = "+6% charge",
							icon = "flame",
							id = "upgrade_ultimate_charge",
							level = 2,
							maxLevel = 10,
							title = "Ultimate Charge",
						},
						{
							cost = 975,
							currency = "cash",
							effect = "+1% speed",
							icon = "lightning-bolt",
							id = "upgrade_combo_speed",
							level = 0,
							maxLevel = 10,
							title = "Combo Speed",
						},
						{
							cost = 1500,
							currency = "cash",
							effect = "+20% block",
							icon = "shield",
							id = "upgrade_block_strength",
							level = 1,
							maxLevel = 10,
							title = "Block Strength",
						},
					},
				},
			},
			size = "Medium",
			title = "Moves",
		},
		{
			description = "Global top ten for Wins, Win Streak and KOs, with podium colours for the top three.",
			icon = "trophy",
			id = "leaderboard",
			kind = "Window",
			nav = "Tabs",
			size = "Medium",
			tabs = {
				{
					icon = "trophy",
					id = "tab_wins",
					label = "Wins",
					sections = {
						{
							id = "leaderboard_2",
							kind = "Leaderboard",
							rows = {
								{ name = "Mango12", rank = 1, value = "1,816" },
								{ name = "PickleOtter42", rank = 2, value = "1,540" },
								{ name = "xXNimbusFoxXx", rank = 3, value = "1,256" },
								{ name = "xXTurboMooseXx", rank = 4, value = "944" },
								{ name = "ItsStormRider", rank = 5, value = "706" },
								{ name = "NotFrostSlayer", rank = 6, value = "610" },
								{ name = "NotRogueGamer", rank = 7, value = "511" },
								{ name = "ItsTurboRider", rank = 8, value = "482" },
								{ name = "ToastyLlama27", rank = 9, value = "361" },
								{ name = "WaffleNinjaYT", rank = 10, value = "331" },
							},
							stat = "Wins",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_win_streak",
					label = "Win Streak",
					sections = {
						{
							id = "leaderboard_3",
							kind = "Leaderboard",
							rows = {
								{ name = "RealNeonChef", rank = 1, value = "2,489" },
								{ name = "NovaOwl7", rank = 2, value = "2,356" },
								{ name = "RealKiwiRanger", rank = 3, value = "2,234" },
								{ name = "FrostPanda", rank = 4, value = "2,051" },
								{ name = "RealPepperOtter", rank = 5, value = "1,616" },
								{ name = "ItsGlitchByte", rank = 6, value = "1,529" },
								{ name = "xXJellyWolfXx", rank = 7, value = "1,444" },
								{ name = "RealLuckyGamer", rank = 8, value = "1,049" },
								{ name = "PepperNinja", rank = 9, value = "826" },
								{ name = "ItsBeanHunter", rank = 10, value = "759" },
							},
							stat = "Win Streak",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_kos",
					label = "KOs",
					sections = {
						{
							id = "leaderboard_4",
							kind = "Leaderboard",
							rows = {
								{ name = "Bean12", rank = 1, value = "340M" },
								{ name = "NotDoodleSlayer", rank = 2, value = "288M" },
								{ name = "Crimson12", rank = 3, value = "268M" },
								{ name = "RogueToast_42", rank = 4, value = "216M" },
								{ name = "ItsEchoByte", rank = 5, value = "185M" },
								{ name = "RealLunaBunny", rank = 6, value = "156M" },
								{ name = "Comet_Chef", rank = 7, value = "134M" },
								{ name = "Toasty33", rank = 8, value = "122M" },
								{ name = "NotCosmicCat", rank = 9, value = "112M" },
								{ name = "ViperBunnyYT", rank = 10, value = "90.4M" },
							},
							stat = "KOs",
						},
					},
				},
			},
			title = "Rankings",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Purple",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "main_menu",
					label = "Main Menu",
					tone = "Dark",
				},
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Victory",
			rewards = { { amount = "+325", icon = "cash" }, { amount = "+300 XP", icon = "level" } },
			stats = {
				{ label = "KOs", value = "24" },
				{ label = "Damage", value = "3,287" },
				{ label = "Max Combo", value = "23" },
				{ label = "Ultimates", value = "1" },
			},
			title = "Results",
		},
		{
			delay = 5,
			description = "A one-time Welcome Bundle: 4 rewards for 99 Robux (was 299), counting down 48h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "cloud",
			id = "offer",
			items = {
				{ amount = "300", grant = { amount = 300, currency = "gems" }, icon = "gem", label = "Gems" },
				{
					amount = "15,000",
					grant = { amount = 15000, currency = "cash" },
					icon = "cash",
					label = "Cash",
				},
				{ amount = "1", icon = "cloud", label = "Epic Frost Monk" },
				{ amount = "3", icon = "coin", label = "2x Cash (30m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "LIMITED",
			timer = 172800,
			title = "Welcome Bundle",
			tone = "Cyan",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "hashtag",
									id = "damageNumbers",
									label = "Damage Numbers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Punch", gamepad = "ButtonX", key = "MouseButton1" },
								{ action = "Kick", gamepad = "ButtonA", key = "E" },
								{ action = "Block", gamepad = "ButtonL1", key = "F" },
								{ action = "Dash", gamepad = "ButtonB", key = "Q" },
								{ action = "Ultimate", gamepad = "ButtonY", key = "G" },
								{ action = "Jump", gamepad = "ButtonA", key = "Space" },
								{ action = "Emote", gamepad = "DPadDown", key = "T" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Candy",
		buttonStyle = "Pill",
		clickFx = "Squash",
		corner = "Bubbly",
		decoration = "Dots",
		density = "Compact",
		fonts = { body = "BuilderSans:Bold", display = "Bangers", title = "Bangers" },
		iconStyle = "Filled",
		palette = {
			accent = "#5CF2FF",
			bad = "#FF4F78",
			good = "#4DF08A",
			primary = "#B266FF",
			secondary = "#FF5FC8",
			surface = "#2A1450",
			text = "#FFFFFF",
			warn = "#FFC23D",
		},
		panelStyle = "Ribbon",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Pop",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="53">
              <Properties>
                <string name="Name">fighting_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: fighting, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD21F",
			format = "comma",
			icon = "coin",
			id = "coins",
			name = "Coins",
			source = { kind = "leaderstat", name = "Coins" },
			start = 6500,
		},
		{
			color = "#FF5FC8",
			format = "comma",
			icon = "star",
			id = "tokens",
			name = "Tokens",
			source = { kind = "attribute", name = "Tokens" },
			start = 150,
		},
	},
	hud = {
		elements = {
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopRight",
				currencies = { "coins", "tokens" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "TopCenter",
				goal = 3,
				id = "hud_teamscore",
				teams = {
					{ color = "#3AA8FF", name = "You", score = 2 },
					{ color = "#FF4B4B", name = "Rival", score = 1 },
				},
				type = "TeamScore",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Round",
				seconds = 99,
				type = "Timer",
				visible = true,
			},
			{
				anchor = "TopRight",
				id = "hud_healthbar",
				max = 150,
				source = { kind = "humanoid" },
				type = "HealthBar",
				visible = true,
			},
			{ anchor = "BottomLeft", id = "hud_stamina", type = "Stamina", visible = true },
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				slots = {
					{ icon = "arm", name = "Punch" },
					{ icon = "shoe", name = "Kick" },
					{ icon = "shield", name = "Block" },
					{ icon = "flame", name = "Ultimate" },
				},
				type = "Hotbar",
				visible = true,
			},
			{
				action = { kind = "Open", target = "menu" },
				anchor = "TopRight",
				icon = "menu",
				id = "hud_button",
				label = "Menu",
				tone = "Dark",
				type = "Button",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonL1", key = "F", text = "Block" },
					{ gamepad = "ButtonB", key = "Q", text = "Dash" },
					{ gamepad = "ButtonY", key = "G", text = "Ultimate" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "Center",
				emotes = {
					{ icon = "person-jumping", id = "emote_jump", label = "Jump" },
					{ icon = "moon", id = "emote_nap", label = "Nap" },
					{ icon = "thumb-down", id = "emote_boo", label = "Boo" },
					{ icon = "arm", id = "emote_flex", label = "Flex" },
					{ icon = "circle-question", id = "emote_shrug", label = "Shrug" },
					{ icon = "heart", id = "emote_heart", label = "Heart" },
				},
				id = "hud_emotewheel",
				key = "T",
				type = "EmoteWheel",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Punch" },
						icon = "arm",
						id = "touch_punch",
						label = "Punch",
					},
					{
						action = { kind = "Event", name = "Block" },
						icon = "shield",
						id = "touch_block",
						label = "Block",
					},
					{
						action = { kind = "Event", name = "Dash" },
						icon = "double-chevron-large-right",
						id = "touch_dash",
						label = "Dash",
					},
					{
						action = { kind = "Event", name = "Ultimate" },
						icon = "flame",
						id = "touch_ultimate",
						label = "Ultimate",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				icon = "flame",
				id = "hud_meter",
				label = "ULTIMATE",
				segments = 5,
				style = "Dial",
				tone = "Orange",
				type = "Meter",
				value = 0.56,
				visible = true,
			},
			{ anchor = "Right", id = "hud_killfeed", type = "KillFeed", visible = true },
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Open", target = "daily" },
						badge = 1,
						icon = "daily",
						id = "btn_daily",
						label = "Daily Rewards",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "emotes" },
						icon = "face-winking",
						id = "btn_emotes",
						label = "Emotes",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
		},
		kit = "Arcade",
		layout = "Corners",
	},
	logo = { icon = "arm", text = "THE FINAL BATTLEGROUNDS", tilt = -4, tone = "Pink", type = "Stacked" },
	meta = {
		flavor = "battlegrounds",
		genre = "fighting",
		kit = "Arcade",
		prompt = "a fighting game",
		seed = 7,
		source = "offline",
		title = "The Final Battlegrounds",
	},
	screens = {
		{
			bannerPosition = "Bottom",
			description = "The title screen: the The Final Battlegrounds logo, PLAY, Roster, Shop, Challenges, Rankings and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Split",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "PLAY",
							tone = "Pink",
						},
						{
							action = { kind = "Open", target = "fighters" },
							icon = "arm",
							id = "menu_fighters",
							label = "Roster",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "quests" },
							icon = "quest",
							id = "menu_quests",
							label = "Challenges",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "leaderboard" },
							icon = "trophy",
							id = "menu_leaderboard",
							label = "Rankings",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "cloud",
					id = "starter_pack",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 86400,
					title = "Starter Pack",
					tone = "Yellow",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Choose who you fight as. Each fighter card shows power, speed, defence and combo ratings.",
			icon = "arm",
			id = "fighters",
			kind = "Fullscreen",
			nav = "SideTabs",
			openKey = "C",
			panelStyle = "Fade",
			size = "Large",
			tabs = {
				{
					icon = "arm",
					id = "tab_selected",
					label = "Selected",
					sections = {
						{
							id = "loadout",
							kind = "Loadout",
							slots = {
								{
									icon = "sword",
									name = "Samurai",
									rarity = "Epic",
									stats = {
										{ label = "Power", value = 0.6 },
										{ label = "Speed", value = 0.95 },
										{ label = "Defence", value = 0.8 },
										{ label = "Combo", value = 0.58 },
									},
								},
								{
									icon = "lightning-bolt",
									name = "Speedster",
									rarity = "Rare",
									stats = {
										{ label = "Power", value = 0.81 },
										{ label = "Speed", value = 0.51 },
										{ label = "Defence", value = 0.68 },
										{ label = "Combo", value = 0.79 },
									},
								},
								{
									icon = "nebula",
									name = "Void Walker",
									rarity = "Mythic",
									stats = {
										{ label = "Power", value = 0.83 },
										{ label = "Speed", value = 0.87 },
										{ label = "Defence", value = 0.78 },
										{ label = "Combo", value = 0.98 },
									},
								},
							},
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_roster",
					label = "Roster",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{
									equipped = true,
									icon = "nebula",
									id = "inv_void_walker",
									name = "Void Walker",
									rarity = "Mythic",
								},
								{ icon = "shield", id = "inv_titan", name = "Titan", rarity = "Legendary" },
								{ icon = "cloud", id = "inv_frost_monk", name = "Frost Monk", rarity = "Epic" },
								{ equipped = true, icon = "sword", id = "inv_samurai", name = "Samurai", rarity = "Epic" },
								{ icon = "flame", id = "inv_pyro", name = "Pyro", rarity = "Rare" },
								{
									equipped = true,
									icon = "lightning-bolt",
									id = "inv_speedster",
									name = "Speedster",
									rarity = "Rare",
								},
								{ icon = "star", id = "inv_hero", name = "Hero", rarity = "Uncommon" },
								{ icon = "ninja", id = "inv_ninja", name = "Ninja", rarity = "Uncommon" },
								{ icon = "arm", id = "inv_boxer", name = "Boxer", rarity = "Common" },
								{ icon = "arm", id = "inv_brawler", name = "Brawler", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
			},
			title = "Roster",
		},
		{
			description = "Where Coins and Robux turn into progress. Tabs: Featured, Crates, Tokens and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					callout = "New today!",
					icon = "sale",
					id = "tab_featured",
					label = "Featured",
					sections = {
						{
							icon = "trophy",
							id = "double_kills_weekend",
							kind = "Banner",
							subtitle = "Every KO counts twice",
							timer = 21600,
							title = "Double Kills Weekend",
							tone = "Yellow",
						},
						{
							id = "today_s_picks",
							items = {
								{
									amount = "6,000",
									currency = "robux",
									description = "6,000 Tokens + an exclusive title",
									grant = { amount = 6000, currency = "tokens" },
									icon = "crown",
									id = "featured_mega",
									price = 1499,
									tag = "LIMITED",
									title = "Mega Bundle",
									tone = "Pink",
									was = 3999,
								},
								{
									amount = "800",
									currency = "robux",
									description = "800 Tokens + Titan",
									grant = { amount = 800, currency = "tokens" },
									icon = "gift",
									id = "featured_starter",
									price = 199,
									tag = "ONE TIME",
									title = "Newcomer Bundle",
									tone = "Cyan",
									was = 399,
								},
								{
									currency = "tokens",
									description = "This week only",
									icon = "shield",
									id = "featured_titan",
									price = 950,
									tag = "FEATURED",
									title = "Titan",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Featured",
							title = "Today's Picks",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_crates",
					label = "Crates",
					sections = {
						{
							cases = {
								{
									currency = "coins",
									icon = "star",
									id = "case_fighter_crate",
									pool = {
										{ chance = 28, icon = "arm", name = "Boxer", rarity = "Common" },
										{ chance = 28, icon = "arm", name = "Brawler", rarity = "Common" },
										{ chance = 13, icon = "star", name = "Hero", rarity = "Uncommon" },
										{ chance = 13, icon = "ninja", name = "Ninja", rarity = "Uncommon" },
										{ chance = 6.2, icon = "flame", name = "Pyro", rarity = "Rare" },
										{ chance = 6.2, icon = "lightning-bolt", name = "Speedster", rarity = "Rare" },
										{ chance = 2.8, icon = "cloud", name = "Frost Monk", rarity = "Epic" },
										{ chance = 2.8, icon = "sword", name = "Samurai", rarity = "Epic" },
									},
									price = 225,
									title = "Fighter Crate",
									tone = "Blue",
								},
								{
									currency = "coins",
									icon = "crown",
									id = "case_legend_crate",
									pool = {
										{ chance = 28.1, icon = "star", name = "Hero", rarity = "Uncommon" },
										{ chance = 29, icon = "ninja", name = "Ninja", rarity = "Uncommon" },
										{ chance = 14, icon = "flame", name = "Pyro", rarity = "Rare" },
										{ chance = 14, icon = "lightning-bolt", name = "Speedster", rarity = "Rare" },
										{ chance = 6.3, icon = "cloud", name = "Frost Monk", rarity = "Epic" },
										{ chance = 6.3, icon = "sword", name = "Samurai", rarity = "Epic" },
										{ chance = 2.3, icon = "shield", name = "Titan", rarity = "Legendary" },
									},
									price = 850,
									title = "Legend Crate",
									tone = "Yellow",
								},
								{
									currency = "coins",
									icon = "cursor-stars",
									id = "case_aura_crate",
									pool = {
										{ chance = 31.5, icon = "flame", name = "Pyro", rarity = "Rare" },
										{ chance = 32, icon = "lightning-bolt", name = "Speedster", rarity = "Rare" },
										{ chance = 15, icon = "cloud", name = "Frost Monk", rarity = "Epic" },
										{ chance = 15, icon = "sword", name = "Samurai", rarity = "Epic" },
										{ chance = 5.3, icon = "shield", name = "Titan", rarity = "Legendary" },
										{ chance = 1.2, icon = "nebula", name = "Void Walker", rarity = "Mythic" },
									},
									price = 2500,
									title = "Aura Crate",
									tone = "Purple",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "star",
					id = "tab_tokens",
					label = "Tokens",
					sections = {
						{
							id = "token_bundles",
							items = {
								{
									amount = "125",
									currency = "robux",
									grant = { amount = 125, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_1",
									price = 49,
									title = "Starter Tokens",
									tone = "Cyan",
								},
								{
									amount = "300",
									bonus = 19,
									currency = "robux",
									grant = { amount = 300, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_2",
									price = 99,
									title = "Small Token Pack",
									tone = "Cyan",
								},
								{
									amount = "625",
									bonus = 23,
									currency = "robux",
									grant = { amount = 625, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_3",
									price = 199,
									tag = "POPULAR",
									title = "Big Token Pack",
									tone = "Pink",
								},
								{
									amount = "1,500",
									bonus = 47,
									currency = "robux",
									grant = { amount = 1500, currency = "tokens" },
									icon = "file-box",
									id = "tokens_pack_4",
									price = 399,
									title = "Mega Token Pack",
									tone = "Pink",
								},
								{
									amount = "3,250",
									bonus = 59,
									currency = "robux",
									grant = { amount = 3250, currency = "tokens" },
									icon = "building-bank",
									id = "tokens_pack_5",
									price = 799,
									title = "Giga Token Pack",
									tone = "Yellow",
								},
								{
									amount = "6,750",
									bonus = 77,
									currency = "robux",
									grant = { amount = 6750, currency = "tokens" },
									icon = "crown",
									id = "tokens_pack_6",
									price = 1499,
									tag = "BEST VALUE",
									title = "Ultra Token Pack",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Token Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Coins",
									icon = "crown",
									id = "pass_vip",
									price = 499,
									tag = "BEST VALUE",
									title = "VIP",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Earn twice the Coins, forever",
									icon = "coin",
									id = "pass_2x_coins",
									price = 399,
									title = "2x Coins",
									tone = "Cyan",
								},
								{
									currency = "robux",
									description = "Stand out in every lobby",
									icon = "cursor-stars",
									id = "pass_custom_aura",
									price = 199,
									title = "Custom Aura",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "Look the part, every match",
									icon = "trophy",
									id = "pass_victory_pose_pack",
									price = 75,
									title = "Victory Pose Pack",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Daily and weekly quests with progress bars; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "SideTabs",
			openKey = "L",
			size = "Medium",
			tabs = {
				{
					badge = 1,
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Win 6 fights",
									goal = 6,
									icon = "trophy",
									id = "quest_winner",
									progress = 6,
									reward = { amount = "50", icon = "star" },
									title = "Winner",
								},
								{
									description = "Knock 5 players off the map",
									goal = 5,
									icon = "double-chevron-large-right",
									id = "quest_ring_out",
									progress = 3,
									reward = { amount = "725", icon = "coin" },
									title = "Ring Out",
								},
								{
									description = "Use your ultimate 10 times",
									goal = 10,
									icon = "flame",
									id = "quest_finisher",
									progress = 2,
									reward = { amount = "225", icon = "coin" },
									title = "Finisher",
								},
							},
						},
					},
				},
				{
					icon = "calendar",
					id = "tab_weekly",
					label = "Weekly",
					sections = {
						{
							id = "quests_3",
							kind = "Quests",
							quests = {
								{
									description = "Land a 5-hit combo",
									goal = 5,
									icon = "arm",
									id = "quest_combo_artist",
									progress = 1,
									reward = { amount = "25", icon = "star" },
									title = "Combo Artist",
								},
							},
						},
					},
				},
			},
			title = "Challenges",
		},
		{
			description = "Pops up on the first join of the day: a streak staircase where every day stands taller than the last; missing a day drops you back to the bottom step.",
			icon = "daily",
			id = "daily",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					days = {
						{ amount = "375", day = 1, icon = "coin" },
						{ amount = "575", day = 2, icon = "coin" },
						{ amount = "40", day = 3, icon = "star" },
						{ amount = "950", day = 4, icon = "coin" },
						{ amount = "x2", day = 5, icon = "boost", label = "15 min" },
						{ amount = "1,250", day = 6, icon = "coin" },
						{ amount = "x1", day = 7, icon = "cursor-stars", label = "Aura Crate" },
					},
					id = "daily_2",
					kind = "Daily",
					layout = "Ladder",
					streak = 2,
				},
			},
			size = "Medium",
			title = "Daily Rewards",
			transition = "Rise",
		},
		{
			description = "Global top ten for Wins, Win Streak and KOs, with podium colours for the top three.",
			icon = "trophy",
			id = "leaderboard",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "trophy",
					id = "tab_wins",
					label = "Wins",
					sections = {
						{
							id = "leaderboard_2",
							kind = "Leaderboard",
							rows = {
								{ name = "Mango101", rank = 1, value = "2,197" },
								{ name = "Turbo2014", rank = 2, value = "1,941" },
								{ name = "ItsDoodleGamer", rank = 3, value = "1,791" },
								{ name = "NotLunaNinja", rank = 4, value = "1,397" },
								{ name = "xXSunnyAceXx", rank = 5, value = "1,234" },
								{ name = "CrimsonChefYT", rank = 6, value = "976" },
								{ name = "ShadowSprout88", rank = 7, value = "924" },
								{ name = "WobblyQueen", rank = 8, value = "700" },
								{ name = "Pixel21", rank = 9, value = "633" },
								{ name = "BlitzMoose_21", rank = 10, value = "525" },
							},
							stat = "Wins",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_win_streak",
					label = "Win Streak",
					sections = {
						{
							id = "leaderboard_3",
							kind = "Leaderboard",
							rows = {
								{ name = "NotTurboQueen", rank = 1, value = "1,478" },
								{ name = "Arctic777", rank = 2, value = "1,167" },
								{ name = "ItsDoodleToast", rank = 3, value = "948" },
								{ name = "SkyKnightYT", rank = 4, value = "835" },
								{ name = "ItsWobblyLlama", rank = 5, value = "727" },
								{ name = "Pickle_Toast", rank = 6, value = "660" },
								{ name = "MochiKnight7", rank = 7, value = "545" },
								{ name = "PixelByte2014", rank = 8, value = "487" },
								{ name = "Storm360", rank = 9, value = "409" },
								{ name = "Blitz42", rank = 10, value = "387" },
							},
							stat = "Win Streak",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_kos",
					label = "KOs",
					sections = {
						{
							id = "leaderboard_4",
							kind = "Leaderboard",
							rows = {
								{ name = "BoltPilotYT", rank = 1, value = "1.2B" },
								{ name = "Doodle42", rank = 2, value = "1.1B" },
								{ name = "ItsNeonSprout", rank = 3, value = "1B" },
								{ name = "NotWobblyCat", rank = 4, value = "842M" },
								{ name = "RealStormGamer", rank = 5, value = "634M" },
								{ name = "CosmicSprout", rank = 6, value = "485M" },
								{ name = "RealGlitchSpark", rank = 7, value = "423M" },
								{ name = "xXTacoOtterXx", rank = 8, value = "323M" },
								{ name = "Waffle_Rider", rank = 9, value = "246M" },
								{ name = "NotSkyWolf", rank = 10, value = "229M" },
							},
							stat = "KOs",
						},
					},
				},
			},
			title = "Rankings",
		},
		{
			description = "Pick up to eight emotes for the radial wheel (opened with T in game).",
			icon = "face-winking",
			id = "emotes",
			kind = "Window",
			nav = "None",
			sections = {
				{
					columns = 5,
					detail = true,
					id = "inventory_2",
					items = {
						{ equipped = true, icon = "arm", id = "inv_flex", name = "Flex", rarity = "Legendary" },
						{
							equipped = true,
							icon = "person-jumping",
							id = "inv_jump",
							name = "Jump",
							rarity = "Legendary",
						},
						{ equipped = true, icon = "thumb-down", id = "inv_boo", name = "Boo", rarity = "Rare" },
						{
							equipped = true,
							icon = "megaphone-cheer",
							id = "inv_cheer",
							name = "Cheer",
							rarity = "Uncommon",
						},
						{
							equipped = true,
							icon = "music-note",
							id = "inv_dance",
							name = "Dance",
							rarity = "Uncommon",
						},
						{
							equipped = true,
							icon = "circle-question",
							id = "inv_shrug",
							name = "Shrug",
							rarity = "Uncommon",
						},
						{ icon = "flame", id = "inv_taunt", name = "Taunt", rarity = "Uncommon" },
						{ icon = "crown", id = "inv_crown", name = "Crown", rarity = "Common" },
						{ icon = "thumb-up", id = "inv_gg", name = "GG", rarity = "Common" },
						{ icon = "square-face-waving-hand", id = "inv_wave", name = "Wave", rarity = "Common" },
					},
					kind = "Inventory",
					layout = "Bottom",
				},
				{
					body = "Equipped emotes appear on your emote wheel. Press T to open it in game.",
					id = "text",
					kind = "Text",
				},
			},
			size = "Medium",
			title = "Emotes",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Pink",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "main_menu",
					label = "Main Menu",
					tone = "Dark",
				},
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Defeat",
			rewards = {
				{ amount = "+400", icon = "coin" },
				{ amount = "+400 XP", icon = "level" },
				{ amount = "+25", icon = "star" },
			},
			stats = {
				{ label = "KOs", value = "29" },
				{ label = "Damage", value = "1,418" },
				{ label = "Max Combo", value = "7" },
				{ label = "Ultimates", value = "4" },
			},
			title = "Results",
		},
		{
			delay = 5,
			description = "A one-time Starter Pack: 4 rewards for 99 Robux (was 299), counting down 24h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "cloud",
			id = "offer",
			items = {
				{
					amount = "375",
					grant = { amount = 375, currency = "tokens" },
					icon = "star",
					label = "Tokens",
				},
				{
					amount = "10,000",
					grant = { amount = 10000, currency = "coins" },
					icon = "coin",
					label = "Coins",
				},
				{ amount = "1", icon = "cloud", label = "Epic Frost Monk" },
				{ amount = "3", icon = "coin", label = "2x Coins (30m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "STARTER PACK",
			timer = 86400,
			title = "Starter Pack",
			tone = "Yellow",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "hashtag",
									id = "damageNumbers",
									label = "Damage Numbers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Punch", gamepad = "ButtonX", key = "MouseButton1" },
								{ action = "Kick", gamepad = "ButtonA", key = "E" },
								{ action = "Block", gamepad = "ButtonL1", key = "F" },
								{ action = "Dash", gamepad = "ButtonB", key = "Q" },
								{ action = "Ultimate", gamepad = "ButtonY", key = "G" },
								{ action = "Jump", gamepad = "ButtonA", key = "Space" },
								{ action = "Emote", gamepad = "DPadDown", key = "T" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Arena",
		buttonStyle = "Glossy3D",
		clickFx = "Punch",
		corner = "Round",
		decoration = "Stripes",
		density = "Compact",
		fonts = { body = "BuilderSans:Bold", display = "Bangers", title = "Bangers" },
		iconStyle = "Filled",
		palette = {
			accent = "#F9ED69",
			bad = "#FF5A5F",
			good = "#3DDC97",
			primary = "#FF2E63",
			secondary = "#08D9D6",
			surface = "#1A1423",
			text = "#F7F7FF",
			warn = "#F9C74F",
		},
		panelStyle = "Glass",
		rarityScheme = "Arena",
		shadow = "Soft",
		textCase = "Normal",
		transition = "Zoom",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="54">
              <Properties>
                <string name="Name">fps_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: fps, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#5BE36B",
			format = "comma",
			icon = "cash",
			id = "bucks",
			name = "Bucks",
			source = { kind = "leaderstat", name = "Bucks" },
			start = 2250,
		},
		{
			color = "#FFB84D",
			format = "comma",
			icon = "key",
			id = "keys",
			name = "Keys",
			source = { kind = "attribute", name = "Keys" },
			start = 4,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				goal = 100,
				id = "hud_teamscore",
				teams = {
					{ color = "#2E8BFF", name = "Blue Squad", score = 52 },
					{ color = "#FF4D4D", name = "Red Squad", score = 44 },
				},
				type = "TeamScore",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Round ends",
				seconds = 240,
				type = "Timer",
				visible = true,
			},
			{ anchor = "TopLeft", id = "hud_minimap", shape = "Square", type = "Minimap", visible = true },
			{ anchor = "TopRight", id = "hud_killfeed", type = "KillFeed", visible = true },
			{ anchor = "Center", id = "hud_crosshair", style = "Cross", type = "Crosshair", visible = true },
			{
				anchor = "BottomCenter",
				id = "hud_healthbar",
				max = 100,
				source = { kind = "humanoid" },
				type = "HealthBar",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				slots = {
					{ ammo = 30, icon = "crosshairs", name = "Viper AR", reserve = 120 },
					{ ammo = 12, icon = "bullet-flying", name = "Kinetic Pistol", reserve = 48 },
					{ icon = "sword", name = "Combat Knife" },
					{ icon = "hexagon-dot", name = "Frag Grenade" },
				},
				type = "Hotbar",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonSelect", key = "Tab", text = "Scoreboard" },
					{ gamepad = "ButtonX", key = "R", text = "Reload" },
					{ gamepad = "ButtonB", key = "C", text = "Crouch" },
					{ gamepad = "ButtonL3", key = "LeftShift", text = "Sprint" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				action = { kind = "Open", target = "menu" },
				anchor = "Left",
				icon = "menu",
				id = "hud_button",
				label = "Menu",
				tone = "Dark",
				type = "Button",
				visible = true,
			},
			{
				anchor = "Center",
				emotes = {
					{ icon = "megaphone-cheer", id = "emote_cheer", label = "Cheer" },
					{ icon = "flame", id = "emote_taunt", label = "Taunt" },
					{ icon = "music-note", id = "emote_dance", label = "Dance" },
					{ icon = "face-winking", id = "emote_laugh", label = "Laugh" },
					{ icon = "square-face-waving-hand", id = "emote_wave", label = "Wave" },
					{ icon = "crown", id = "emote_crown", label = "Crown" },
				},
				id = "hud_emotewheel",
				key = "T",
				type = "EmoteWheel",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Aim" },
						icon = "crosshairs",
						id = "touch_aim",
						label = "Aim",
					},
					{
						action = { kind = "Event", name = "Reload" },
						icon = "arrow-spin-clockwise",
						id = "touch_reload",
						label = "Reload",
					},
					{
						action = { kind = "Event", name = "Crouch" },
						icon = "person-crouching",
						id = "touch_crouch",
						label = "Crouch",
					},
					{
						action = { kind = "Event", name = "Grenade" },
						icon = "hexagon-dot",
						id = "touch_grenade",
						label = "Grenade",
					},
					{
						action = { kind = "Open", target = "scoreboard" },
						icon = "trophy",
						id = "touch_scoreboard",
						label = "Scoreboard",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopLeft",
				currencies = { "bucks", "keys" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "Right",
				buttons = {
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Challenges",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
		},
		kit = "Neon",
		layout = "TopBar",
	},
	logo = { icon = "bullet-flying", text = "ZERO FRONT", tilt = 0, tone = "Green", type = "Badge" },
	meta = {
		genre = "fps",
		kit = "Neon",
		prompt = "a fps game",
		seed = 21,
		source = "offline",
		title = "Zero Front",
	},
	screens = {
		{
			description = "The first thing players see: the Zero Front logo, a progress bar and rotating tips while the place streams in.",
			duration = 3,
			icon = "timer",
			id = "loading",
			kind = "Loading",
			tips = {
				"Headshots deal double damage. Aim for the head!",
				"Crouching tightens your spread for the first three shots.",
				"Slide into cover: moving targets are harder to hit.",
				"Press Tab at any time to check the scoreboard.",
				"Every case holds at least one Rare or better.",
				"A five-kill streak reveals every enemy on the minimap.",
			},
			title = "Loading",
		},
		{
			bannerPosition = "Side",
			description = "The title screen: the Zero Front logo, DEPLOY, Party, Arsenal, Shop, Pass and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Corner",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "DEPLOY",
							tone = "Green",
						},
						{
							action = { kind = "Open", target = "party" },
							icon = "friends",
							id = "menu_party",
							label = "Party",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "loadout" },
							icon = "crosshairs",
							id = "menu_loadout",
							label = "Arsenal",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "battlepass" },
							icon = "premium",
							id = "menu_battlepass",
							label = "Pass",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "flame",
					id = "welcome_bundle",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 172800,
					title = "Welcome Bundle",
					tone = "Pink",
				},
			},
			title = "Lobby",
		},
		{
			description = "Build the kit you take into a match: 4 slots, each a big card with stat bars so trade-offs are obvious.",
			icon = "crosshairs",
			id = "loadout",
			kind = "Window",
			nav = "Tabs",
			openKey = "L",
			size = "Large",
			tabs = {
				{
					icon = "crosshairs",
					id = "tab_equipped",
					label = "Equipped",
					sections = {
						{
							id = "loadout_2",
							kind = "Loadout",
							slots = {
								{
									icon = "crosshairs",
									name = "Viper AR",
									rarity = "Rare",
									stats = {
										{ label = "Damage", value = 0.67 },
										{ label = "Fire Rate", value = 0.6 },
										{ label = "Range", value = 0.76 },
										{ label = "Mobility", value = 0.79 },
									},
								},
								{
									icon = "bullet-flying",
									name = "Kinetic Pistol",
									rarity = "Common",
									stats = {
										{ label = "Damage", value = 0.24 },
										{ label = "Fire Rate", value = 0.27 },
										{ label = "Range", value = 0.43 },
										{ label = "Mobility", value = 0.38 },
									},
								},
								{
									icon = "sword",
									name = "Combat Knife",
									rarity = "Common",
									stats = {
										{ label = "Damage", value = 0.37 },
										{ label = "Fire Rate", value = 0.59 },
										{ label = "Range", value = 0.57 },
										{ label = "Mobility", value = 0.28 },
									},
								},
								{
									icon = "hexagon-dot",
									name = "Frag Grenade",
									rarity = "Common",
									stats = {
										{ label = "Damage", value = 0.54 },
										{ label = "Fire Rate", value = 0.57 },
										{ label = "Range", value = 0.35 },
										{ label = "Mobility", value = 0.6 },
									},
								},
							},
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_weapons",
					label = "Weapons",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{ icon = "nebula", id = "inv_void_revolver", name = "Void Revolver", rarity = "Mythic" },
								{ icon = "sword", id = "inv_ember_katana", name = "Ember Katana", rarity = "Legendary" },
								{ icon = "lightning-bolt", id = "inv_railgun", name = "Railgun", rarity = "Legendary" },
								{ icon = "flame", id = "inv_hand_cannon", name = "Hand Cannon", rarity = "Epic" },
								{
									icon = "circle-line-target-cursor",
									id = "inv_longshot_sniper",
									name = "Longshot Sniper",
									rarity = "Epic",
								},
								{ icon = "bullet-flying", id = "inv_tempest_lmg", name = "Tempest LMG", rarity = "Rare" },
								{ icon = "crosshairs", id = "inv_vector_burst", name = "Vector Burst", rarity = "Rare" },
								{
									equipped = true,
									icon = "crosshairs",
									id = "inv_viper_ar",
									name = "Viper AR",
									rarity = "Rare",
								},
								{ icon = "sun", id = "inv_flashbang", name = "Flashbang", rarity = "Uncommon" },
								{ icon = "lightning-bolt", id = "inv_pulse_smg", name = "Pulse SMG", rarity = "Uncommon" },
								{
									icon = "bullet-flying",
									id = "inv_riot_shotgun",
									name = "Riot Shotgun",
									rarity = "Uncommon",
								},
								{
									equipped = true,
									icon = "sword",
									id = "inv_combat_knife",
									name = "Combat Knife",
									rarity = "Common",
								},
								{
									equipped = true,
									icon = "hexagon-dot",
									id = "inv_frag_grenade",
									name = "Frag Grenade",
									rarity = "Common",
								},
								{
									equipped = true,
									icon = "bullet-flying",
									id = "inv_kinetic_pistol",
									name = "Kinetic Pistol",
									rarity = "Common",
								},
								{ icon = "cloud", id = "inv_smoke_grenade", name = "Smoke Grenade", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
				{
					icon = "skins",
					id = "tab_skins",
					label = "Skins",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_2",
							items = {
								{
									equipped = true,
									icon = "crown",
									id = "inv_gold_plated",
									name = "Gold Plated",
									rarity = "Legendary",
								},
								{ icon = "lightning-bolt", id = "inv_neon_circuit", name = "Neon Circuit", rarity = "Epic" },
								{ icon = "cloud", id = "inv_arctic_camo", name = "Arctic Camo", rarity = "Rare" },
								{ icon = "lab-beaker", id = "inv_toxic_splash", name = "Toxic Splash", rarity = "Rare" },
								{ icon = "gift", id = "inv_candy_cane", name = "Candy Cane", rarity = "Uncommon" },
								{ icon = "grid", id = "inv_carbon_fibre", name = "Carbon Fibre", rarity = "Uncommon" },
								{
									icon = "diagonal-line-pattern",
									id = "inv_tiger_stripe",
									name = "Tiger Stripe",
									rarity = "Uncommon",
								},
								{ icon = "cube", id = "inv_digital_desert", name = "Digital Desert", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
			},
			title = "Arsenal",
		},
		{
			description = "Invite friends into your party and pick a mode together; the party leader's pick drives matchmaking.",
			icon = "friends",
			id = "party",
			kind = "Window",
			nav = "None",
			openKey = "P",
			sections = {
				{ id = "party_2", kind = "Party", slots = 5 },
				{
					buttons = {
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "two-people",
							id = "mode_team_deathmatch",
							label = "Team Deathmatch",
							tone = "Green",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "flag",
							id = "mode_capture_the_flag",
							label = "Capture the Flag",
							tone = "Dark",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "arrow-progress-up",
							id = "mode_gun_game",
							label = "Gun Game",
							tone = "Dark",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "hexagon-dot",
							id = "mode_search_destroy",
							label = "Search & Destroy",
							tone = "Dark",
						},
					},
					id = "modes",
					kind = "Buttons",
					layout = "Grid",
					title = "Modes",
				},
			},
			size = "Medium",
			title = "Party",
		},
		{
			description = "Where Bucks and Robux turn into progress. Tabs: Featured, Cases, Skins, Keys and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					callout = "New today!",
					icon = "sale",
					id = "tab_featured",
					label = "Featured",
					sections = {
						{
							id = "today_s_picks",
							items = {
								{
									amount = "3,250",
									currency = "robux",
									description = "3,250 Keys + an exclusive title",
									grant = { amount = 3250, currency = "keys" },
									icon = "crown",
									id = "featured_mega",
									price = 999,
									tag = "LIMITED",
									title = "Mega Bundle",
									tone = "Green",
									was = 1999,
								},
								{
									currency = "keys",
									description = "Leaves the shop soon",
									icon = "nebula",
									id = "featured_galaxy",
									price = 1500,
									tag = "FEATURED",
									title = "Galaxy",
									tone = "Red",
								},
								{
									amount = "475",
									currency = "robux",
									description = "475 Keys + Galaxy",
									grant = { amount = 475, currency = "keys" },
									icon = "gift",
									id = "featured_starter",
									price = 149,
									tag = "ONE TIME",
									title = "Newcomer Bundle",
									tone = "Purple",
									was = 299,
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Today's Picks",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_cases",
					label = "Cases",
					sections = {
						{
							cases = {
								{
									currency = "bucks",
									icon = "file-box",
									id = "case_recon_case",
									pool = {
										{ chance = 39, icon = "cube", name = "Digital Desert", rarity = "Common" },
										{ chance = 18, icon = "gift", name = "Candy Cane", rarity = "Uncommon" },
										{ chance = 18, icon = "grid", name = "Carbon Fibre", rarity = "Uncommon" },
										{ chance = 8.6, icon = "cloud", name = "Arctic Camo", rarity = "Rare" },
										{ chance = 8.6, icon = "lollipop", name = "Bubblegum", rarity = "Rare" },
										{ chance = 3.9, icon = "moon", name = "Blood Moon", rarity = "Epic" },
										{ chance = 3.9, icon = "lightning-bolt", name = "Neon Circuit", rarity = "Epic" },
									},
									price = 225,
									title = "Recon Case",
									tone = "Blue",
								},
								{
									currency = "bucks",
									icon = "flame",
									id = "case_viper_case",
									pool = {
										{ chance = 29.2, icon = "gift", name = "Candy Cane", rarity = "Uncommon" },
										{ chance = 28, icon = "grid", name = "Carbon Fibre", rarity = "Uncommon" },
										{ chance = 13, icon = "cloud", name = "Arctic Camo", rarity = "Rare" },
										{ chance = 13, icon = "lollipop", name = "Bubblegum", rarity = "Rare" },
										{ chance = 6.2, icon = "moon", name = "Blood Moon", rarity = "Epic" },
										{ chance = 6.2, icon = "lightning-bolt", name = "Neon Circuit", rarity = "Epic" },
										{ chance = 2.2, icon = "flame", name = "Dragonfire", rarity = "Legendary" },
										{ chance = 2.2, icon = "crown", name = "Gold Plated", rarity = "Legendary" },
									},
									price = 900,
									title = "Viper Case",
									tone = "Red",
								},
								{
									currency = "bucks",
									icon = "lightning-bolt",
									id = "case_neon_case",
									pool = {
										{ chance = 30.7, icon = "cloud", name = "Arctic Camo", rarity = "Rare" },
										{ chance = 30, icon = "lollipop", name = "Bubblegum", rarity = "Rare" },
										{ chance = 14, icon = "moon", name = "Blood Moon", rarity = "Epic" },
										{ chance = 14, icon = "lightning-bolt", name = "Neon Circuit", rarity = "Epic" },
										{ chance = 5.1, icon = "flame", name = "Dragonfire", rarity = "Legendary" },
										{ chance = 5.1, icon = "crown", name = "Gold Plated", rarity = "Legendary" },
										{ chance = 1.1, icon = "nebula", name = "Galaxy", rarity = "Mythic" },
									},
									price = 2750,
									title = "Neon Case",
									tone = "Pink",
								},
								{
									currency = "keys",
									icon = "moon",
									id = "case_spectre_case",
									pool = {
										{ chance = 30.7, icon = "cloud", name = "Arctic Camo", rarity = "Rare" },
										{ chance = 30, icon = "lollipop", name = "Bubblegum", rarity = "Rare" },
										{ chance = 14, icon = "moon", name = "Blood Moon", rarity = "Epic" },
										{ chance = 14, icon = "lightning-bolt", name = "Neon Circuit", rarity = "Epic" },
										{ chance = 5.1, icon = "flame", name = "Dragonfire", rarity = "Legendary" },
										{ chance = 5.1, icon = "crown", name = "Gold Plated", rarity = "Legendary" },
										{ chance = 1.1, icon = "nebula", name = "Galaxy", rarity = "Mythic" },
									},
									price = 400,
									title = "Spectre Case",
									tone = "Purple",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "skins",
					id = "tab_skins_2",
					label = "Skins",
					sections = {
						{
							id = "products",
							items = {
								{
									amount = "Common",
									currency = "keys",
									icon = "cube",
									id = "buy_digital_desert",
									price = 55,
									title = "Digital Desert",
									tone = "Gray",
								},
								{
									amount = "Uncommon",
									currency = "keys",
									icon = "grid",
									id = "buy_carbon_fibre",
									price = 125,
									title = "Carbon Fibre",
									tone = "Green",
								},
								{
									amount = "Rare",
									currency = "keys",
									icon = "lollipop",
									id = "buy_bubblegum",
									price = 250,
									title = "Bubblegum",
									tone = "Blue",
								},
								{
									amount = "Rare",
									currency = "keys",
									icon = "cloud",
									id = "buy_arctic_camo",
									price = 275,
									title = "Arctic Camo",
									tone = "Blue",
								},
								{
									amount = "Epic",
									currency = "keys",
									icon = "lightning-bolt",
									id = "buy_neon_circuit",
									price = 525,
									title = "Neon Circuit",
									tone = "Purple",
								},
								{
									amount = "Legendary",
									currency = "keys",
									icon = "flame",
									id = "buy_dragonfire",
									price = 950,
									tag = "NEW",
									title = "Dragonfire",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Rows",
						},
					},
				},
				{
					icon = "key",
					id = "tab_keys",
					label = "Keys",
					sections = {
						{
							icon = "key",
							id = "stock_up_on_keys",
							kind = "Banner",
							subtitle = "Bigger packs pack more bonus Keys",
							title = "Stock Up on Keys",
							tone = "Green",
						},
						{
							id = "key_bundles",
							items = {
								{
									amount = "100",
									currency = "robux",
									grant = { amount = 100, currency = "keys" },
									icon = "key",
									id = "keys_pack_1",
									price = 49,
									title = "Pile of Keys",
									tone = "Purple",
								},
								{
									amount = "225",
									bonus = 11,
									currency = "robux",
									grant = { amount = 225, currency = "keys" },
									icon = "key",
									id = "keys_pack_2",
									price = 99,
									title = "Stack of Keys",
									tone = "Purple",
								},
								{
									amount = "500",
									bonus = 23,
									currency = "robux",
									grant = { amount = 500, currency = "keys" },
									icon = "key",
									id = "keys_pack_3",
									price = 199,
									tag = "POPULAR",
									title = "Bag of Keys",
									tone = "Green",
								},
								{
									amount = "1,250",
									bonus = 54,
									currency = "robux",
									grant = { amount = 1250, currency = "keys" },
									icon = "file-box",
									id = "keys_pack_4",
									price = 399,
									title = "Crate of Keys",
									tone = "Green",
								},
								{
									amount = "2,500",
									bonus = 53,
									currency = "robux",
									grant = { amount = 2500, currency = "keys" },
									icon = "building-bank",
									id = "keys_pack_5",
									price = 799,
									title = "Treasury of Keys",
									tone = "Pink",
								},
								{
									amount = "5,500",
									bonus = 80,
									currency = "robux",
									grant = { amount = 5500, currency = "keys" },
									icon = "crown",
									id = "keys_pack_6",
									price = 1499,
									tag = "BEST VALUE",
									title = "Hoard of Keys",
									tone = "Pink",
								},
							},
							kind = "Products",
							layout = "Rows",
							title = "Key Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Bucks",
									icon = "crown",
									id = "pass_vip",
									price = 699,
									tag = "MUST HAVE",
									title = "VIP",
									tone = "Green",
								},
								{
									currency = "robux",
									description = "Earn twice the Bucks, forever",
									icon = "coin",
									id = "pass_2x_bucks",
									price = 399,
									title = "2x Bucks",
									tone = "Purple",
								},
								{
									currency = "robux",
									description = "Bring one more pick into every match",
									icon = "backpack",
									id = "pass_extra_loadout_slot",
									price = 199,
									title = "Extra Loadout Slot",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Stand out in every lobby",
									icon = "crosshairs",
									id = "pass_custom_crosshair",
									price = 99,
									title = "Custom Crosshair",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Play your own songs in game",
									icon = "music",
									id = "pass_radio",
									price = 75,
									title = "Radio",
									tone = "Purple",
								},
							},
							kind = "Products",
							layout = "Rows",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "20 tiers of free and premium rewards for the season, plus weekly challenges that feed pass XP.",
			icon = "premium",
			id = "battlepass",
			kind = "Fullscreen",
			nav = "None",
			panelStyle = "Fade",
			sections = {
				{
					button = { action = { kind = "Event", name = "BuyPremiumPass" }, label = "Unlock Premium" },
					icon = "premium",
					id = "season_1_pass",
					kind = "Banner",
					subtitle = "Unlock the premium track: 20 exclusive rewards",
					timer = 1382400,
					title = "Season 1 Pass",
					tone = "Green",
				},
				{
					id = "pass",
					kind = "Pass",
					tiers = {
						{
							free = { amount = "175", icon = "cash" },
							premium = { amount = "x1", icon = "gift" },
							tier = 1,
						},
						{ premium = { amount = "x1", icon = "tshirt" }, tier = 2 },
						{
							free = { amount = "250", icon = "cash" },
							premium = { amount = "x1", icon = "paint-brush" },
							tier = 3,
						},
						{ premium = { amount = "40", icon = "key" }, tier = 4 },
						{
							free = { amount = "x1", icon = "paint-brush" },
							premium = { amount = "x1", icon = "star" },
							tier = 5,
						},
						{ premium = { amount = "x1", icon = "star" }, tier = 6 },
						{
							free = { amount = "400", icon = "cash" },
							premium = { amount = "x1", icon = "gift" },
							tier = 7,
						},
						{ premium = { amount = "60", icon = "key" }, tier = 8 },
						{
							free = { amount = "450", icon = "cash" },
							premium = { amount = "x1", icon = "star" },
							tier = 9,
						},
						{
							free = { amount = "x1", icon = "file-box" },
							premium = { amount = "x1", icon = "crown" },
							tier = 10,
						},
						{
							free = { amount = "550", icon = "cash" },
							premium = { amount = "x1", icon = "crosshairs" },
							tier = 11,
						},
						{ premium = { amount = "80", icon = "key" }, tier = 12 },
						{
							free = { amount = "625", icon = "cash" },
							premium = { amount = "x1", icon = "tshirt" },
							tier = 13,
						},
						{ premium = { amount = "x1", icon = "gift" }, tier = 14 },
						{
							free = { amount = "x1", icon = "paint-brush" },
							premium = { amount = "x1", icon = "file-box" },
							tier = 15,
						},
						{ premium = { amount = "100", icon = "key" }, tier = 16 },
						{
							free = { amount = "775", icon = "cash" },
							premium = { amount = "x1", icon = "gift" },
							tier = 17,
						},
						{ premium = { amount = "x1", icon = "crosshairs" }, tier = 18 },
						{
							free = { amount = "850", icon = "cash" },
							premium = { amount = "x1", icon = "tshirt" },
							tier = 19,
						},
						{
							free = { amount = "x1", icon = "gem" },
							premium = { amount = "x1", icon = "crown" },
							tier = 20,
						},
					},
				},
				{
					id = "challenges",
					kind = "Quests",
					quests = {
						{
							description = "Win 4 matches",
							goal = 4,
							icon = "trophy",
							id = "quest_on_a_roll",
							progress = 4,
							reward = { amount = "675", icon = "cash" },
							title = "On a Roll",
						},
						{
							description = "Play 8 matches",
							goal = 8,
							icon = "clock",
							id = "quest_marathon",
							progress = 0,
							reward = { amount = "500", icon = "cash" },
							title = "Marathon",
						},
						{
							description = "Reach a 8-kill streak",
							goal = 8,
							icon = "flame",
							id = "quest_unstoppable",
							progress = 3,
							reward = { amount = "375", icon = "cash" },
							title = "Unstoppable",
						},
						{
							description = "Get 10 headshots",
							goal = 10,
							icon = "crosshairs",
							id = "quest_sharpshooter",
							progress = 0,
							reward = { amount = "15", icon = "key" },
							title = "Sharpshooter",
						},
					},
					title = "Challenges",
				},
			},
			size = "Large",
			title = "Pass",
		},
		{
			description = "Quest cards with progress bars under a reset countdown; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "Tabs",
			openKey = "L",
			size = "Large",
			tabs = {
				{
					icon = "quest",
					id = "tab_challenges",
					label = "Challenges",
					sections = {
						{
							icon = "timer",
							id = "new_quests_in",
							kind = "Banner",
							subtitle = "Finish all quests for a bonus chest",
							timer = 39600,
							title = "New quests in",
							tone = "Purple",
						},
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Reach a 7-kill streak",
									goal = 7,
									icon = "flame",
									id = "quest_unstoppable_2",
									progress = 7,
									reward = { amount = "275", icon = "cash" },
									title = "Unstoppable",
								},
								{
									description = "Get 35 headshots",
									goal = 35,
									icon = "crosshairs",
									id = "quest_sharpshooter_2",
									progress = 9,
									reward = { amount = "50", icon = "key" },
									title = "Sharpshooter",
								},
								{
									description = "Play 15 matches",
									goal = 15,
									icon = "clock",
									id = "quest_marathon_2",
									progress = 12,
									reward = { amount = "325", icon = "cash" },
									title = "Marathon",
								},
								{
									description = "Get 20 grenade eliminations",
									goal = 20,
									icon = "hexagon-dot",
									id = "quest_demolition",
									progress = 0,
									reward = { amount = "350", icon = "cash" },
									title = "Demolition",
								},
							},
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_achievements",
					label = "Achievements",
					sections = {
						{
							achievements = {
								{
									description = "Get your first elimination",
									goal = 1,
									icon = "crosshairs",
									id = "ach_first_blood",
									points = 5,
									progress = 1,
									reward = { amount = "5", grant = { amount = 5, currency = "keys" }, icon = "key" },
									title = "First Blood",
								},
								{
									description = "Revive 50 teammates",
									goal = 50,
									icon = "heart",
									id = "ach_medic",
									points = 15,
									progress = 30,
									reward = { amount = "15", grant = { amount = 15, currency = "keys" }, icon = "key" },
									title = "Medic",
								},
								{
									description = "Unlock 20 weapons",
									goal = 20,
									icon = "bullet-flying",
									id = "ach_arsenal",
									points = 25,
									progress = 15,
									reward = { amount = "25", grant = { amount = 25, currency = "keys" }, icon = "key" },
									title = "Arsenal",
								},
								{
									description = "Play 500 matches",
									goal = 500,
									icon = "clock",
									id = "ach_marathon",
									points = 30,
									progress = 0,
									reward = { amount = "30", grant = { amount = 30, currency = "keys" }, icon = "key" },
									title = "Marathon",
								},
								{
									description = "Get 500 headshots",
									goal = 500,
									icon = "target",
									id = "ach_sharpshooter",
									points = 30,
									progress = 0,
									reward = { amount = "25", grant = { amount = 25, currency = "keys" }, icon = "key" },
									title = "Sharpshooter",
								},
								{
									description = "Get a 10 elimination streak",
									goal = 1,
									icon = "flame",
									id = "ach_unstoppable",
									points = 40,
									progress = 0,
									reward = { amount = "45", grant = { amount = 45, currency = "keys" }, icon = "key" },
									title = "Unstoppable",
								},
								{
									description = "Win 10 1v3s",
									goal = 10,
									icon = "shield-check",
									id = "ach_clutch_king",
									points = 50,
									progress = 0,
									reward = { amount = "45", grant = { amount = 45, currency = "keys" }, icon = "key" },
									title = "Clutch King",
								},
							},
							id = "achievements",
							kind = "Achievements",
						},
					},
				},
			},
			title = "Challenges",
		},
		{
			description = "Held open with Tab mid-match: both teams side by side, sorted by score.",
			icon = "trophy",
			id = "scoreboard",
			kind = "Popup",
			nav = "None",
			openKey = "Tab",
			sections = {
				{
					columns = { "K", "D", "A", "Score" },
					id = "scoreboard_2",
					kind = "Scoreboard",
					teams = {
						{
							color = "#2E8BFF",
							name = "Blue Squad",
							rows = {
								{ name = "NotLunaFox", values = { 26, 8, 10, 3025 } },
								{ name = "RealNoodleMoose", values = { 26, 17, 9, 2625 } },
								{ name = "MangoWolf_404", values = { 14, 18, 0, 1825 } },
								{ name = "ShadowLlama_99", values = { 11, 10, 2, 1525 } },
								{ name = "NotToastyGamer", values = { 9, 20, 6, 1350 } },
							},
						},
						{
							color = "#FF4D4D",
							name = "Red Squad",
							rows = {
								{ name = "ItsNeonKnight", values = { 28, 11, 6, 2950 } },
								{ name = "RocketSlayer12", values = { 21, 6, 8, 2325 } },
								{ name = "xXSkyBotXx", values = { 14, 12, 5, 1750 } },
								{ name = "RealPickleYeti", values = { 11, 3, 2, 1600 } },
								{ name = "RealPixelToast", values = { 7, 17, 12, 1175 } },
							},
						},
					},
				},
			},
			size = "Medium",
			title = "Scoreboard",
			transition = "Slide",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Green",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "lobby",
					label = "Lobby",
					tone = "Dark",
				},
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Victory",
			rewards = {
				{ amount = "+375", icon = "cash" },
				{ amount = "+150 XP", icon = "level" },
				{ amount = "+10", icon = "key" },
			},
			stats = {
				{ label = "Eliminations", value = "24" },
				{ label = "Deaths", value = "11" },
				{ label = "Assists", value = "11" },
				{ label = "Accuracy", value = "22%" },
				{ label = "Headshots", value = "9" },
				{ label = "Score", value = "4,125" },
			},
			title = "Results",
		},
		{
			delay = 5,
			description = "A one-time Welcome Bundle: 4 rewards for 99 Robux (was 299), counting down 48h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "flame",
			id = "offer",
			items = {
				{ amount = "300", grant = { amount = 300, currency = "keys" }, icon = "key", label = "Keys" },
				{
					amount = "15,000",
					grant = { amount = 15000, currency = "bucks" },
					icon = "cash",
					label = "Bucks",
				},
				{ amount = "1", icon = "flame", label = "Epic Hand Cannon" },
				{ amount = "3", icon = "arrow-progress-up", label = "2x XP (60m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "LIMITED",
			timer = 172800,
			title = "Welcome Bundle",
			tone = "Pink",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "crosshairs",
									id = "hitMarkers",
									label = "Hit Markers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "hashtag",
									id = "damageNumbers",
									label = "Damage Numbers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "list-bulleted",
									id = "killFeed",
									label = "Kill Feed",
									type = "Toggle",
								},
								{
									default = true,
									icon = "controller",
									id = "aimAssist",
									label = "Aim Assist",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Fire", gamepad = "ButtonR2", key = "MouseButton1" },
								{ action = "Aim", gamepad = "ButtonL2", key = "MouseButton2" },
								{ action = "Reload", gamepad = "ButtonX", key = "R" },
								{ action = "Jump", gamepad = "ButtonA", key = "Space" },
								{ action = "Crouch", gamepad = "ButtonB", key = "C" },
								{ action = "Sprint", gamepad = "ButtonL3", key = "LeftShift" },
								{ action = "Melee", gamepad = "ButtonR3", key = "V" },
								{ action = "Grenade", gamepad = "ButtonR1", key = "G" },
								{ action = "Emote", gamepad = "DPadDown", key = "T" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "loading",
	style = {
		archetype = "Tactical",
		buttonStyle = "Outline",
		clickFx = "Shake",
		corner = "Sharp",
		decoration = "Grain",
		density = "Compact",
		fonts = { body = "BuilderSans:Bold", display = "FredokaOne", title = "FredokaOne" },
		iconStyle = "Filled",
		palette = {
			accent = "#FF2E88",
			bad = "#FF3B5C",
			good = "#00FFA3",
			primary = "#00FFA3",
			secondary = "#7A5CFF",
			surface = "#0B0F1A",
			text = "#E8FFF6",
			warn = "#FFD23F",
		},
		panelStyle = "Solid",
		rarityScheme = "Arena",
		shadow = "Hard",
		textCase = "Upper",
		transition = "Rise",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="55">
              <Properties>
                <string name="Name">fps_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: fps, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD21F",
			format = "comma",
			icon = "coin",
			id = "coins",
			name = "Coins",
			source = { kind = "leaderstat", name = "Coins" },
			start = 3500,
		},
		{
			color = "#C07BFF",
			format = "comma",
			icon = "star",
			id = "tokens",
			name = "Tokens",
			source = { kind = "attribute", name = "Tokens" },
			start = 300,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				goal = 75,
				id = "hud_teamscore",
				teams = {
					{ color = "#2E8BFF", name = "Blue Squad", score = 36 },
					{ color = "#FF4D4D", name = "Red Squad", score = 30 },
				},
				type = "TeamScore",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Round ends",
				seconds = 180,
				type = "Timer",
				visible = true,
			},
			{ anchor = "TopLeft", id = "hud_minimap", shape = "Square", type = "Minimap", visible = true },
			{ anchor = "TopRight", id = "hud_killfeed", type = "KillFeed", visible = true },
			{ anchor = "Center", id = "hud_crosshair", style = "Cross", type = "Crosshair", visible = true },
			{
				anchor = "BottomLeft",
				id = "hud_healthbar",
				max = 150,
				source = { kind = "humanoid" },
				type = "HealthBar",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				slots = {
					{ ammo = 30, icon = "crosshairs", name = "Viper AR", reserve = 120 },
					{ ammo = 12, icon = "bullet-flying", name = "Kinetic Pistol", reserve = 48 },
					{ icon = "sword", name = "Combat Knife" },
					{ icon = "hexagon-dot", name = "Frag Grenade" },
				},
				type = "Hotbar",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonSelect", key = "Tab", text = "Scoreboard" },
					{ gamepad = "ButtonX", key = "R", text = "Reload" },
					{ gamepad = "ButtonB", key = "C", text = "Crouch" },
					{ gamepad = "ButtonL3", key = "LeftShift", text = "Sprint" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				action = { kind = "Open", target = "menu" },
				anchor = "Left",
				icon = "menu",
				id = "hud_button",
				label = "Menu",
				tone = "Dark",
				type = "Button",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Aim" },
						icon = "crosshairs",
						id = "touch_aim",
						label = "Aim",
					},
					{
						action = { kind = "Event", name = "Reload" },
						icon = "arrow-spin-clockwise",
						id = "touch_reload",
						label = "Reload",
					},
					{
						action = { kind = "Event", name = "Crouch" },
						icon = "person-crouching",
						id = "touch_crouch",
						label = "Crouch",
					},
					{
						action = { kind = "Event", name = "Grenade" },
						icon = "hexagon-dot",
						id = "touch_grenade",
						label = "Grenade",
					},
					{
						action = { kind = "Open", target = "scoreboard" },
						icon = "trophy",
						id = "touch_scoreboard",
						label = "Scoreboard",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopRight",
				currencies = { "coins", "tokens" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "TopLeft",
				icon = "flame",
				id = "hud_counter",
				label = "ROUND",
				style = "Stamp",
				type = "Counter",
				value = 16,
				visible = true,
			},
			{
				anchor = "Right",
				buttons = {
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Challenges",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Grid",
				type = "MenuButtons",
				visible = true,
			},
		},
		kit = "Arcade",
		layout = "Classic",
	},
	logo = { icon = "crosshairs", text = "APEX BREACH", tilt = -4, tone = "Green", type = "Wordmark" },
	meta = {
		flavor = "zombies",
		genre = "fps",
		kit = "Arcade",
		prompt = "a fps game",
		seed = 7,
		source = "offline",
		title = "Apex Breach",
	},
	screens = {
		{
			description = "The first thing players see: the Apex Breach logo, a progress bar and rotating tips while the place streams in.",
			duration = 4,
			icon = "timer",
			id = "loading",
			kind = "Loading",
			tips = {
				"Daily challenges reset at midnight. Don't leave XP on the table.",
				"Every case holds at least one Rare or better.",
				"A five-kill streak reveals every enemy on the minimap.",
				"Headshots deal double damage. Aim for the head!",
				"Press Tab at any time to check the scoreboard.",
				"Switching to your pistol is faster than reloading.",
			},
			title = "Loading",
		},
		{
			bannerPosition = "Side",
			description = "The title screen: the Apex Breach logo, QUICK PLAY, Party, Loadout, Shop, Battle Pass and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Corner",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "QUICK PLAY",
							tone = "Green",
						},
						{
							action = { kind = "Open", target = "party" },
							icon = "friends",
							id = "menu_party",
							label = "Party",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "loadout" },
							icon = "crosshairs",
							id = "menu_loadout",
							label = "Loadout",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "battlepass" },
							icon = "premium",
							id = "menu_battlepass",
							label = "Battle Pass",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "flame",
					id = "starter_pack",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 86400,
					title = "Starter Pack",
					tone = "Orange",
				},
			},
			title = "Lobby",
		},
		{
			description = "Invite friends into your party and pick a mode together; the party leader's pick drives matchmaking.",
			icon = "friends",
			id = "party",
			kind = "Window",
			nav = "None",
			openKey = "P",
			sections = {
				{ id = "party_2", kind = "Party", slots = 4 },
				{
					buttons = {
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "two-people",
							id = "mode_co_op",
							label = "Co-op",
							tone = "Green",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "shield-check",
							id = "mode_hardcore",
							label = "Hardcore",
							tone = "Dark",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "flame",
							id = "mode_survival",
							label = "Survival",
							tone = "Dark",
						},
					},
					id = "modes",
					kind = "Buttons",
					layout = "Grid",
					title = "Modes",
				},
			},
			size = "Medium",
			title = "Party",
		},
		{
			description = "Build the kit you take into a match: 4 slots, each a big card with stat bars so trade-offs are obvious.",
			icon = "crosshairs",
			id = "loadout",
			kind = "Window",
			nav = "Tabs",
			openKey = "L",
			size = "Large",
			tabs = {
				{
					icon = "crosshairs",
					id = "tab_equipped",
					label = "Equipped",
					sections = {
						{
							id = "loadout_2",
							kind = "Loadout",
							slots = {
								{
									icon = "crosshairs",
									name = "Viper AR",
									rarity = "Rare",
									stats = {
										{ label = "Damage", value = 0.5 },
										{ label = "Fire Rate", value = 0.85 },
										{ label = "Range", value = 0.7 },
										{ label = "Mobility", value = 0.48 },
									},
								},
								{
									icon = "bullet-flying",
									name = "Kinetic Pistol",
									rarity = "Common",
									stats = {
										{ label = "Damage", value = 0.61 },
										{ label = "Fire Rate", value = 0.31 },
										{ label = "Range", value = 0.48 },
										{ label = "Mobility", value = 0.59 },
									},
								},
								{
									icon = "sword",
									name = "Combat Knife",
									rarity = "Common",
									stats = {
										{ label = "Damage", value = 0.33 },
										{ label = "Fire Rate", value = 0.37 },
										{ label = "Range", value = 0.28 },
										{ label = "Mobility", value = 0.63 },
									},
								},
								{
									icon = "hexagon-dot",
									name = "Frag Grenade",
									rarity = "Common",
									stats = {
										{ label = "Damage", value = 0.53 },
										{ label = "Fire Rate", value = 0.4 },
										{ label = "Range", value = 0.53 },
										{ label = "Mobility", value = 0.44 },
									},
								},
							},
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_weapons",
					label = "Weapons",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{ icon = "sword", id = "inv_ember_katana", name = "Ember Katana", rarity = "Legendary" },
								{ icon = "lightning-bolt", id = "inv_railgun", name = "Railgun", rarity = "Legendary" },
								{ icon = "flame", id = "inv_hand_cannon", name = "Hand Cannon", rarity = "Epic" },
								{
									icon = "circle-line-target-cursor",
									id = "inv_longshot_sniper",
									name = "Longshot Sniper",
									rarity = "Epic",
								},
								{ icon = "bullet-flying", id = "inv_tempest_lmg", name = "Tempest LMG", rarity = "Rare" },
								{ icon = "crosshairs", id = "inv_vector_burst", name = "Vector Burst", rarity = "Rare" },
								{
									equipped = true,
									icon = "crosshairs",
									id = "inv_viper_ar",
									name = "Viper AR",
									rarity = "Rare",
								},
								{ icon = "sun", id = "inv_flashbang", name = "Flashbang", rarity = "Uncommon" },
								{ icon = "lightning-bolt", id = "inv_pulse_smg", name = "Pulse SMG", rarity = "Uncommon" },
								{
									icon = "bullet-flying",
									id = "inv_riot_shotgun",
									name = "Riot Shotgun",
									rarity = "Uncommon",
								},
								{
									equipped = true,
									icon = "sword",
									id = "inv_combat_knife",
									name = "Combat Knife",
									rarity = "Common",
								},
								{
									equipped = true,
									icon = "hexagon-dot",
									id = "inv_frag_grenade",
									name = "Frag Grenade",
									rarity = "Common",
								},
								{
									equipped = true,
									icon = "bullet-flying",
									id = "inv_kinetic_pistol",
									name = "Kinetic Pistol",
									rarity = "Common",
								},
								{ icon = "cloud", id = "inv_smoke_grenade", name = "Smoke Grenade", rarity = "Common" },
								{
									icon = "nebula",
									id = "locked_void_revolver",
									locked = true,
									name = "Void Revolver",
									rarity = "Mythic",
								},
							},
							kind = "Inventory",
						},
					},
				},
				{
					icon = "skins",
					id = "tab_skins",
					label = "Skins",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_2",
							items = {
								{ equipped = true, icon = "nebula", id = "inv_galaxy", name = "Galaxy", rarity = "Mythic" },
								{ icon = "flame", id = "inv_dragonfire", name = "Dragonfire", rarity = "Legendary" },
								{ icon = "lightning-bolt", id = "inv_neon_circuit", name = "Neon Circuit", rarity = "Epic" },
								{ icon = "cloud", id = "inv_arctic_camo", name = "Arctic Camo", rarity = "Rare" },
								{ icon = "gift", id = "inv_candy_cane", name = "Candy Cane", rarity = "Uncommon" },
								{ icon = "grid", id = "inv_carbon_fibre", name = "Carbon Fibre", rarity = "Uncommon" },
								{
									icon = "diagonal-line-pattern",
									id = "inv_tiger_stripe",
									name = "Tiger Stripe",
									rarity = "Uncommon",
								},
								{ icon = "cube", id = "inv_digital_desert", name = "Digital Desert", rarity = "Common" },
							},
							kind = "Inventory",
						},
					},
				},
			},
			title = "Loadout",
		},
		{
			description = "20 tiers of free and premium rewards for the season, plus weekly challenges that feed pass XP.",
			icon = "premium",
			id = "battlepass",
			kind = "Window",
			nav = "None",
			sections = {
				{
					button = { action = { kind = "Event", name = "BuyPremiumPass" }, label = "Unlock Premium" },
					icon = "premium",
					id = "season_2_pass",
					kind = "Banner",
					subtitle = "Unlock the premium track: 20 exclusive rewards",
					timer = 1728000,
					title = "Season 2 Pass",
					tone = "Green",
				},
				{
					id = "pass",
					kind = "Pass",
					tiers = {
						{
							free = { amount = "175", icon = "coin" },
							premium = { amount = "x1", icon = "gem" },
							tier = 1,
						},
						{ premium = { amount = "x1", icon = "tshirt" }, tier = 2 },
						{
							free = { amount = "250", icon = "coin" },
							premium = { amount = "x1", icon = "gift" },
							tier = 3,
						},
						{ premium = { amount = "40", icon = "star" }, tier = 4 },
						{
							free = { amount = "x1", icon = "paint-brush" },
							premium = { amount = "x1", icon = "gift" },
							tier = 5,
						},
						{ premium = { amount = "x1", icon = "tshirt" }, tier = 6 },
						{
							free = { amount = "400", icon = "coin" },
							premium = { amount = "x1", icon = "file-box" },
							tier = 7,
						},
						{ premium = { amount = "60", icon = "star" }, tier = 8 },
						{
							free = { amount = "450", icon = "coin" },
							premium = { amount = "x1", icon = "gift" },
							tier = 9,
						},
						{
							free = { amount = "x1", icon = "paint-brush" },
							premium = { amount = "x1", icon = "crown" },
							tier = 10,
						},
						{
							free = { amount = "550", icon = "coin" },
							premium = { amount = "x1", icon = "tshirt" },
							tier = 11,
						},
						{ premium = { amount = "80", icon = "star" }, tier = 12 },
						{
							free = { amount = "625", icon = "coin" },
							premium = { amount = "x1", icon = "gem" },
							tier = 13,
						},
						{ premium = { amount = "x1", icon = "gift" }, tier = 14 },
						{
							free = { amount = "x1", icon = "file-box" },
							premium = { amount = "95", icon = "star" },
							tier = 15,
						},
						{ premium = { amount = "100", icon = "star" }, tier = 16 },
						{
							free = { amount = "775", icon = "coin" },
							premium = { amount = "x1", icon = "file-box" },
							tier = 17,
						},
						{ premium = { amount = "x1", icon = "tshirt" }, tier = 18 },
						{
							free = { amount = "850", icon = "coin" },
							premium = { amount = "x1", icon = "tshirt" },
							tier = 19,
						},
						{
							free = { amount = "x1", icon = "file-box" },
							premium = { amount = "x1", icon = "crown" },
							tier = 20,
						},
					},
				},
				{
					id = "challenges",
					kind = "Quests",
					quests = {
						{
							description = "Get 40 headshots",
							goal = 40,
							icon = "crosshairs",
							id = "quest_sharpshooter",
							progress = 40,
							reward = { amount = "25", icon = "star" },
							title = "Sharpshooter",
						},
						{
							description = "Get 15 assists",
							goal = 15,
							icon = "two-people",
							id = "quest_team_player",
							progress = 11,
							reward = { amount = "350", icon = "coin" },
							title = "Team Player",
						},
						{
							description = "Reach a 5-kill streak",
							goal = 5,
							icon = "flame",
							id = "quest_unstoppable",
							progress = 2,
							reward = { amount = "400", icon = "coin" },
							title = "Unstoppable",
						},
						{
							description = "Get 10 melee eliminations",
							goal = 10,
							icon = "sword",
							id = "quest_knife_master",
							progress = 7,
							reward = { amount = "10", icon = "star" },
							title = "Knife Master",
						},
					},
					title = "Challenges",
				},
			},
			size = "Large",
			title = "Battle Pass",
		},
		{
			description = "Where Coins and Robux turn into progress. Tabs: Featured, Cases, Skins, Tokens and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			openKey = "B",
			size = "Large",
			tabs = {
				{
					callout = "Refreshes daily",
					icon = "sale",
					id = "tab_featured",
					label = "Featured",
					sections = {
						{
							id = "today_s_picks",
							items = {
								{
									amount = "6,000",
									currency = "robux",
									description = "6,000 Tokens + an exclusive title",
									grant = { amount = 6000, currency = "tokens" },
									icon = "crown",
									id = "featured_mega",
									price = 1499,
									tag = "LIMITED",
									title = "Mega Bundle",
									tone = "Green",
									was = 3999,
								},
								{
									currency = "tokens",
									description = "This week only",
									icon = "flame",
									id = "featured_dragonfire",
									price = 950,
									tag = "FEATURED",
									title = "Dragonfire",
									tone = "Yellow",
								},
								{
									amount = "800",
									currency = "robux",
									description = "800 Tokens + Dragonfire",
									grant = { amount = 800, currency = "tokens" },
									icon = "gift",
									id = "featured_starter",
									price = 199,
									tag = "ONE TIME",
									title = "Newcomer Bundle",
									tone = "Green",
									was = 399,
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Today's Picks",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_cases",
					label = "Cases",
					sections = {
						{
							cases = {
								{
									currency = "coins",
									icon = "file-box",
									id = "case_recon_case",
									pool = {
										{ chance = 39, icon = "cube", name = "Digital Desert", rarity = "Common" },
										{ chance = 18, icon = "gift", name = "Candy Cane", rarity = "Uncommon" },
										{ chance = 18, icon = "grid", name = "Carbon Fibre", rarity = "Uncommon" },
										{ chance = 8.6, icon = "cloud", name = "Arctic Camo", rarity = "Rare" },
										{ chance = 8.6, icon = "lollipop", name = "Bubblegum", rarity = "Rare" },
										{ chance = 3.9, icon = "moon", name = "Blood Moon", rarity = "Epic" },
										{ chance = 3.9, icon = "lightning-bolt", name = "Neon Circuit", rarity = "Epic" },
									},
									price = 275,
									title = "Recon Case",
									tone = "Blue",
								},
								{
									currency = "coins",
									icon = "flame",
									id = "case_viper_case",
									pool = {
										{ chance = 29.2, icon = "gift", name = "Candy Cane", rarity = "Uncommon" },
										{ chance = 28, icon = "grid", name = "Carbon Fibre", rarity = "Uncommon" },
										{ chance = 13, icon = "cloud", name = "Arctic Camo", rarity = "Rare" },
										{ chance = 13, icon = "lollipop", name = "Bubblegum", rarity = "Rare" },
										{ chance = 6.2, icon = "moon", name = "Blood Moon", rarity = "Epic" },
										{ chance = 6.2, icon = "lightning-bolt", name = "Neon Circuit", rarity = "Epic" },
										{ chance = 2.2, icon = "flame", name = "Dragonfire", rarity = "Legendary" },
										{ chance = 2.2, icon = "crown", name = "Gold Plated", rarity = "Legendary" },
									},
									price = 875,
									title = "Viper Case",
									tone = "Red",
								},
								{
									currency = "coins",
									icon = "lightning-bolt",
									id = "case_neon_case",
									pool = {
										{ chance = 30.7, icon = "cloud", name = "Arctic Camo", rarity = "Rare" },
										{ chance = 30, icon = "lollipop", name = "Bubblegum", rarity = "Rare" },
										{ chance = 14, icon = "moon", name = "Blood Moon", rarity = "Epic" },
										{ chance = 14, icon = "lightning-bolt", name = "Neon Circuit", rarity = "Epic" },
										{ chance = 5.1, icon = "flame", name = "Dragonfire", rarity = "Legendary" },
										{ chance = 5.1, icon = "crown", name = "Gold Plated", rarity = "Legendary" },
										{ chance = 1.1, icon = "nebula", name = "Galaxy", rarity = "Mythic" },
									},
									price = 2500,
									title = "Neon Case",
									tone = "Pink",
								},
								{
									currency = "tokens",
									icon = "moon",
									id = "case_spectre_case",
									pool = {
										{ chance = 30.7, icon = "cloud", name = "Arctic Camo", rarity = "Rare" },
										{ chance = 30, icon = "lollipop", name = "Bubblegum", rarity = "Rare" },
										{ chance = 14, icon = "moon", name = "Blood Moon", rarity = "Epic" },
										{ chance = 14, icon = "lightning-bolt", name = "Neon Circuit", rarity = "Epic" },
										{ chance = 5.1, icon = "flame", name = "Dragonfire", rarity = "Legendary" },
										{ chance = 5.1, icon = "crown", name = "Gold Plated", rarity = "Legendary" },
										{ chance = 1.1, icon = "nebula", name = "Galaxy", rarity = "Mythic" },
									},
									price = 450,
									title = "Spectre Case",
									tone = "Purple",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "skins",
					id = "tab_skins_2",
					label = "Skins",
					sections = {
						{
							id = "products",
							items = {
								{
									amount = "Common",
									currency = "tokens",
									icon = "cube",
									id = "buy_digital_desert",
									price = 55,
									title = "Digital Desert",
									tone = "Gray",
								},
								{
									amount = "Uncommon",
									currency = "tokens",
									icon = "grid",
									id = "buy_carbon_fibre",
									price = 100,
									title = "Carbon Fibre",
									tone = "Green",
								},
								{
									amount = "Rare",
									currency = "tokens",
									icon = "cloud",
									id = "buy_arctic_camo",
									price = 250,
									title = "Arctic Camo",
									tone = "Blue",
								},
								{
									amount = "Rare",
									currency = "tokens",
									icon = "lab-beaker",
									id = "buy_toxic_splash",
									price = 250,
									title = "Toxic Splash",
									tone = "Blue",
								},
								{
									amount = "Epic",
									currency = "tokens",
									icon = "lightning-bolt",
									id = "buy_neon_circuit",
									price = 475,
									title = "Neon Circuit",
									tone = "Purple",
								},
							},
							kind = "Products",
							layout = "Rows",
						},
					},
				},
				{
					icon = "star",
					id = "tab_tokens",
					label = "Tokens",
					sections = {
						{
							id = "token_bundles",
							items = {
								{
									amount = "125",
									currency = "robux",
									grant = { amount = 125, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_1",
									price = 49,
									title = "Starter Tokens",
									tone = "Green",
								},
								{
									amount = "300",
									bonus = 19,
									currency = "robux",
									grant = { amount = 300, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_2",
									price = 99,
									title = "Small Token Pack",
									tone = "Green",
								},
								{
									amount = "625",
									bonus = 23,
									currency = "robux",
									grant = { amount = 625, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_3",
									price = 199,
									tag = "POPULAR",
									title = "Big Token Pack",
									tone = "Green",
								},
								{
									amount = "1,500",
									bonus = 47,
									currency = "robux",
									grant = { amount = 1500, currency = "tokens" },
									icon = "file-box",
									id = "tokens_pack_4",
									price = 399,
									title = "Mega Token Pack",
									tone = "Green",
								},
								{
									amount = "3,250",
									bonus = 59,
									currency = "robux",
									grant = { amount = 3250, currency = "tokens" },
									icon = "building-bank",
									id = "tokens_pack_5",
									price = 799,
									title = "Giga Token Pack",
									tone = "Orange",
								},
								{
									amount = "6,750",
									bonus = 77,
									currency = "robux",
									grant = { amount = 6750, currency = "tokens" },
									icon = "crown",
									id = "tokens_pack_6",
									price = 1499,
									tag = "BEST VALUE",
									title = "Ultra Token Pack",
									tone = "Orange",
								},
							},
							kind = "Products",
							layout = "Rows",
							title = "Token Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Coins",
									icon = "crown",
									id = "pass_vip",
									price = 699,
									tag = "POPULAR",
									title = "VIP",
									tone = "Green",
								},
								{
									currency = "robux",
									description = "Earn twice the Coins, forever",
									icon = "coin",
									id = "pass_2x_coins",
									price = 299,
									title = "2x Coins",
									tone = "Orange",
								},
								{
									currency = "robux",
									description = "Bring one more pick into every match",
									icon = "backpack",
									id = "pass_extra_loadout_slot",
									price = 149,
									title = "Extra Loadout Slot",
									tone = "Green",
								},
								{
									currency = "robux",
									description = "Stand out in every lobby",
									icon = "crosshairs",
									id = "pass_custom_crosshair",
									price = 99,
									title = "Custom Crosshair",
									tone = "Orange",
								},
								{
									currency = "robux",
									description = "Play your own songs in game",
									icon = "music",
									id = "pass_radio",
									price = 75,
									title = "Radio",
									tone = "Green",
								},
							},
							kind = "Products",
							layout = "Rows",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Daily and weekly quests with progress bars; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "Tabs",
			openKey = "L",
			size = "Large",
			tabs = {
				{
					badge = 1,
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Play 8 matches",
									goal = 8,
									icon = "clock",
									id = "quest_marathon",
									progress = 8,
									reward = { amount = "600", icon = "coin" },
									title = "Marathon",
								},
								{
									description = "Get 10 grenade eliminations",
									goal = 10,
									icon = "hexagon-dot",
									id = "quest_demolition",
									progress = 0,
									reward = { amount = "25", icon = "star" },
									title = "Demolition",
								},
								{
									description = "Get 15 assists",
									goal = 15,
									icon = "two-people",
									id = "quest_team_player_2",
									progress = 2,
									reward = { amount = "50", icon = "star" },
									title = "Team Player",
								},
							},
						},
					},
				},
				{
					icon = "calendar",
					id = "tab_weekly",
					label = "Weekly",
					sections = {
						{
							id = "quests_3",
							kind = "Quests",
							quests = {
								{
									description = "Get 4 melee eliminations",
									goal = 4,
									icon = "sword",
									id = "quest_knife_master_2",
									progress = 2,
									reward = { amount = "25", icon = "star" },
									title = "Knife Master",
								},
							},
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_achievements",
					label = "Achievements",
					sections = {
						{
							achievements = {
								{
									description = "Get your first elimination",
									goal = 1,
									icon = "crosshairs",
									id = "ach_first_blood",
									points = 5,
									progress = 1,
									reward = { amount = "5", grant = { amount = 5, currency = "tokens" }, icon = "star" },
									title = "First Blood",
								},
								{
									description = "Revive 50 teammates",
									goal = 50,
									icon = "heart",
									id = "ach_medic",
									points = 15,
									progress = 13,
									reward = { amount = "10", grant = { amount = 10, currency = "tokens" }, icon = "star" },
									title = "Medic",
								},
								{
									description = "Unlock 20 weapons",
									goal = 20,
									icon = "bullet-flying",
									id = "ach_arsenal",
									points = 25,
									progress = 13,
									reward = { amount = "20", grant = { amount = 20, currency = "tokens" }, icon = "star" },
									title = "Arsenal",
								},
								{
									description = "Play 500 matches",
									goal = 500,
									icon = "clock",
									id = "ach_marathon",
									points = 30,
									progress = 0,
									reward = { amount = "25", grant = { amount = 25, currency = "tokens" }, icon = "star" },
									title = "Marathon",
								},
								{
									description = "Get 500 headshots",
									goal = 500,
									icon = "target",
									id = "ach_sharpshooter",
									points = 30,
									progress = 0,
									reward = { amount = "30", grant = { amount = 30, currency = "tokens" }, icon = "star" },
									title = "Sharpshooter",
								},
								{
									description = "Get a 10 elimination streak",
									goal = 1,
									icon = "flame",
									id = "ach_unstoppable",
									points = 40,
									progress = 0,
									reward = { amount = "40", grant = { amount = 40, currency = "tokens" }, icon = "star" },
									title = "Unstoppable",
								},
								{
									description = "Win 10 1v3s",
									goal = 10,
									icon = "shield-check",
									id = "ach_clutch_king",
									points = 50,
									progress = 0,
									reward = { amount = "45", grant = { amount = 45, currency = "tokens" }, icon = "star" },
									title = "Clutch King",
								},
							},
							id = "achievements",
							kind = "Achievements",
						},
					},
				},
			},
			title = "Challenges",
		},
		{
			description = "Held open with Tab mid-match: both teams side by side, sorted by score.",
			icon = "trophy",
			id = "scoreboard",
			kind = "Popup",
			nav = "None",
			openKey = "Tab",
			sections = {
				{
					columns = { "K", "D", "A", "Score" },
					id = "scoreboard_2",
					kind = "Scoreboard",
					teams = {
						{
							color = "#2E8BFF",
							name = "Blue Squad",
							rows = {
								{ name = "MochiQueen", values = { 25, 12, 4, 2625 } },
								{ name = "Crimson101", values = { 22, 8, 4, 2550 } },
								{ name = "RealChaosKing", values = { 10, 16, 1, 1250 } },
								{ name = "Nimbus_Llama", values = { 8, 5, 10, 1175 } },
								{ name = "RealSkySlayer", values = { 8, 4, 5, 1100 } },
							},
						},
						{
							color = "#FF4D4D",
							name = "Red Squad",
							rows = {
								{ name = "RealLuckyAce", values = { 23, 10, 4, 2725 } },
								{ name = "xXCometSlayerXx", values = { 21, 3, 12, 2450 } },
								{ name = "xXEchoMooseXx", values = { 24, 12, 9, 2400 } },
								{ name = "DoodleToastYT", values = { 10, 7, 3, 1125 } },
								{ name = "RocketMoose101", values = { 8, 20, 2, 800 } },
							},
						},
					},
				},
			},
			size = "Medium",
			title = "Scoreboard",
			transition = "Zoom",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Green",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "lobby",
					label = "Lobby",
					tone = "Dark",
				},
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Victory",
			rewards = { { amount = "+125", icon = "coin" }, { amount = "+600 XP", icon = "level" } },
			stats = {
				{ label = "Eliminations", value = "29" },
				{ label = "Deaths", value = "5" },
				{ label = "Assists", value = "2" },
				{ label = "Accuracy", value = "50%" },
				{ label = "Headshots", value = "12" },
				{ label = "Score", value = "5,375" },
			},
			title = "Results",
		},
		{
			delay = 5,
			description = "A one-time Starter Pack: 4 rewards for 99 Robux (was 299), counting down 24h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "flame",
			id = "offer",
			items = {
				{
					amount = "375",
					grant = { amount = 375, currency = "tokens" },
					icon = "star",
					label = "Tokens",
				},
				{
					amount = "10,000",
					grant = { amount = 10000, currency = "coins" },
					icon = "coin",
					label = "Coins",
				},
				{ amount = "1", icon = "flame", label = "Epic Hand Cannon" },
				{ amount = "3", icon = "arrow-progress-up", label = "2x XP (60m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "STARTER PACK",
			timer = 86400,
			title = "Starter Pack",
			tone = "Orange",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "crosshairs",
									id = "hitMarkers",
									label = "Hit Markers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "hashtag",
									id = "damageNumbers",
									label = "Damage Numbers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "list-bulleted",
									id = "killFeed",
									label = "Kill Feed",
									type = "Toggle",
								},
								{
									default = true,
									icon = "controller",
									id = "aimAssist",
									label = "Aim Assist",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Fire", gamepad = "ButtonR2", key = "MouseButton1" },
								{ action = "Aim", gamepad = "ButtonL2", key = "MouseButton2" },
								{ action = "Reload", gamepad = "ButtonX", key = "R" },
								{ action = "Jump", gamepad = "ButtonA", key = "Space" },
								{ action = "Crouch", gamepad = "ButtonB", key = "C" },
								{ action = "Sprint", gamepad = "ButtonL3", key = "LeftShift" },
								{ action = "Melee", gamepad = "ButtonR3", key = "V" },
								{ action = "Grenade", gamepad = "ButtonR1", key = "G" },
								{ action = "Emote", gamepad = "DPadDown", key = "T" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "loading",
	style = {
		archetype = "Arena",
		buttonStyle = "Glossy3D",
		clickFx = "Glow",
		corner = "Round",
		decoration = "None",
		density = "Compact",
		fonts = { body = "BuilderSans:Bold", display = "Bangers", title = "Bangers" },
		iconStyle = "Filled",
		palette = {
			accent = "#FF5A2E",
			bad = "#FF4A3D",
			good = "#9BE22D",
			primary = "#9BE22D",
			secondary = "#6B7A5A",
			surface = "#0C0F0A",
			text = "#EFF5E6",
			warn = "#FFC23D",
		},
		panelStyle = "Glass",
		rarityScheme = "Arena",
		shadow = "Soft",
		textCase = "Normal",
		transition = "Zoom",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="56">
              <Properties>
                <string name="Name">horror_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: horror, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#E8E4DA",
			format = "comma",
			icon = "photo-camera",
			id = "film",
			name = "Film",
			source = { kind = "leaderstat", name = "Film" },
			start = 150,
		},
		{
			color = "#62FF8A",
			format = "comma",
			icon = "lightning-bolt",
			id = "batteries",
			name = "Batteries",
			source = { kind = "attribute", name = "Batteries" },
			start = 3,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopLeft",
				battery = 0.71,
				brackets = true,
				date = "NOV 02 1994",
				id = "hud_camcorder",
				label = "REC",
				mode = "Digital",
				scanlines = false,
				type = "Camcorder",
				visible = true,
			},
			{
				anchor = "TopRight",
				goal = 5,
				id = "hud_counter",
				label = "CLIPS",
				style = "Digital",
				type = "Counter",
				value = 2,
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_objective",
				label = "TAPE 3",
				text = "Record the cellar door",
				type = "Objective",
				visible = true,
			},
			{
				addAction = { kind = "None" },
				anchor = "TopRight",
				currencies = { "film" },
				id = "hud_currencybar",
				look = "Pill",
				showAdd = false,
				type = "CurrencyBar",
				visible = true,
			},
			{
				action = { kind = "Open", target = "journal" },
				anchor = "TopRight",
				icon = "frame-record",
				id = "hud_button",
				label = "Tapes",
				tone = "Dark",
				type = "Button",
				visible = true,
			},
			{ anchor = "Center", id = "hud_fear", intensity = 0.33, type = "Fear", visible = true },
			{
				anchor = "Center",
				id = "hud_vignette",
				intensity = 0.29,
				style = "Soft",
				type = "Vignette",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonY", key = "N", text = "Night Vision" },
					{ gamepad = "ButtonL2", key = "Q", text = "Zoom" },
					{ gamepad = "ButtonB", key = "C", text = "Crouch" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "NightVision" },
						icon = "moon",
						id = "touch_night_vision",
						label = "Night Vision",
					},
					{
						action = { kind = "Event", name = "Zoom" },
						icon = "magnifying-glass",
						id = "touch_zoom",
						label = "Zoom",
					},
					{
						action = { kind = "Event", name = "Crouch" },
						icon = "person-crouching",
						id = "touch_crouch",
						label = "Crouch",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
		},
		kit = "Retro",
		layout = "Corners",
	},
	logo = { icon = "video-camera", text = "LAST FOOTAGE", tilt = 0, tone = "White", type = "Badge" },
	meta = {
		flavor = "footage",
		genre = "horror",
		kit = "Retro",
		prompt = "a horror game",
		seed = 21,
		source = "offline",
		title = "Last Footage",
	},
	screens = {
		{
			bannerPosition = "Corner",
			description = "The title screen: the Last Footage logo, RECORD, Party, Tapes, Evidence and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Corner",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "RECORD",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "party" },
							icon = "friends",
							id = "menu_party",
							label = "Party",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "chapters" },
							icon = "frame-record",
							id = "menu_chapters",
							label = "Tapes",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "journal" },
							icon = "book-open",
							id = "menu_journal",
							label = "Evidence",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "chapters" }, label = "Explore" },
					icon = "frame-record",
					id = "tape_6_recovered",
					kind = "Banner",
					subtitle = "The final tape update",
					title = "Tape 6 Recovered",
					tone = "Red",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Tape select: each chapter is a recovered tape, labelled in marker; tapes you haven't found are blank.",
			icon = "frame-record",
			id = "chapters",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "worlds",
					kind = "Worlds",
					worlds = {
						{
							icon = "frame-record",
							id = "world_tape_1_arrival",
							locked = false,
							name = "Tape 1 Arrival",
							tone = "White",
						},
						{
							icon = "frame-record",
							id = "world_tape_2_the_woods",
							locked = false,
							name = "Tape 2 The Woods",
							tone = "Blue",
						},
						{
							icon = "frame-record",
							id = "world_tape_3_the_house",
							locked = false,
							name = "Tape 3 The House",
							tone = "Red",
						},
						{
							icon = "frame-record",
							id = "world_tape_4_cellar",
							locked = true,
							name = "Tape 4 Cellar",
							requirement = "Beat Tape 3 The House",
							tone = "White",
						},
						{
							icon = "frame-record",
							id = "world_tape_5_night_two",
							locked = true,
							name = "Tape 5 Night Two",
							requirement = "Beat Tape 4 Cellar",
							tone = "Blue",
						},
						{
							icon = "lock",
							id = "world_tape_6",
							locked = true,
							name = "Tape 6 ???",
							requirement = "Beat Tape 5 Night Two",
							tone = "Red",
						},
					},
				},
			},
			size = "Large",
			title = "Tapes",
		},
		{
			description = "Invite friends into your party and pick a mode together; the party leader's pick drives matchmaking.",
			icon = "friends",
			id = "party",
			kind = "Window",
			nav = "None",
			openKey = "P",
			sections = {
				{ id = "party_2", kind = "Party", slots = 4 },
				{
					buttons = {
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "trophy",
							id = "mode_ranked",
							label = "Ranked",
							tone = "White",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "play",
							id = "mode_quick_match",
							label = "Quick Match",
							tone = "Dark",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "gear",
							id = "mode_custom",
							label = "Custom",
							tone = "Dark",
						},
					},
					id = "modes",
					kind = "Buttons",
					layout = "Grid",
					title = "Modes",
				},
			},
			size = "Medium",
			title = "Party",
		},
		{
			description = "The only 'menu' during a run: the last note found, and every item picked up. It reads like evidence, not a game menu.",
			icon = "book-open",
			id = "journal",
			kind = "Window",
			nav = "SideTabs",
			openKey = "J",
			size = "Large",
			tabs = {
				{
					icon = "backpack",
					id = "tab_evidence",
					label = "Evidence",
					sections = {
						{
							columns = 4,
							detail = true,
							id = "inventory",
							items = {
								{ icon = "video-camera", id = "inv_camcorder", name = "Camcorder", rarity = "Common" },
								{ icon = "key", id = "inv_car_keys", name = "Car Keys", rarity = "Common" },
								{ icon = "sun", id = "inv_flashlight", name = "Flashlight", rarity = "Common" },
								{ icon = "map", id = "inv_map_of_the_woods", name = "Map of the Woods", rarity = "Common" },
								{ icon = "image", id = "inv_missing_poster", name = "Missing Poster", rarity = "Common" },
								{ icon = "frame-record", id = "inv_tape_1", name = "Tape 1", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
				{
					icon = "book-open",
					id = "tab_notes",
					label = "Notes",
					sections = {
						{
							body = "TAPE LABEL: 'DO NOT WATCH ALONE' in someone else's handwriting.",
							id = "latest_note",
							kind = "Text",
							title = "Latest Note",
						},
						{
							body = "Ranger log: three hikers entered on the 29th. Only a camera came back.",
							id = "older_note",
							kind = "Text",
							title = "Older Note",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_achievements",
					label = "Achievements",
					sections = {
						{
							achievements = {
								{
									description = "Start recording",
									goal = 1,
									icon = "video-camera",
									id = "ach_rolling",
									points = 5,
									progress = 1,
									reward = { amount = "6", grant = { amount = 6, currency = "batteries" }, icon = "lightning-bolt" },
									title = "Rolling",
								},
								{
									description = "Use night vision for 30 minutes",
									goal = 30,
									icon = "moon",
									id = "ach_night_owl",
									points = 15,
									progress = 26,
									reward = { amount = "20", grant = { amount = 20, currency = "batteries" }, icon = "lightning-bolt" },
									title = "Night Owl",
								},
								{
									description = "Film 10 anomalies",
									goal = 10,
									icon = "eye",
									id = "ach_evidence",
									points = 25,
									progress = 6,
									reward = { amount = "25", grant = { amount = 25, currency = "batteries" }, icon = "lightning-bolt" },
									title = "Evidence",
								},
								{
									description = "Recover 6 tapes",
									goal = 6,
									icon = "frame-record",
									id = "ach_archivist",
									points = 40,
									progress = 0,
									reward = { amount = "40", grant = { amount = 40, currency = "batteries" }, icon = "lightning-bolt" },
									title = "Archivist",
								},
								{
									description = "Finish the final tape",
									goal = 1,
									icon = "flag",
									id = "ach_last_tape",
									points = 60,
									progress = 0,
									reward = { amount = "70", grant = { amount = 70, currency = "batteries" }, icon = "lightning-bolt" },
									title = "Last Tape",
								},
							},
							id = "achievements",
							kind = "Achievements",
						},
					},
				},
			},
			title = "Evidence",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "Retry" },
					icon = "rebirth",
					id = "try_again",
					label = "Try Again",
					tone = "White",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "main_menu",
					label = "Main Menu",
					tone = "Dark",
				},
			},
			description = "Shown the instant a run ends: what you achieved, what you keep, and a one-click way back in.",
			icon = "health",
			id = "gameover",
			kind = "Results",
			result = "Defeat",
			rewards = {
				{ amount = "+9", icon = "photo-camera" },
				{ amount = "+250 XP", icon = "level" },
				{ amount = "+25", icon = "lightning-bolt" },
			},
			stats = {
				{ label = "Tapes Found", value = "5/6" },
				{ label = "Time Recorded", value = "18:25" },
				{ label = "Battery Left", value = "41%" },
				{ label = "Film Earned", value = "+171" },
			},
			title = "Tape Ends",
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Flashes & Shake",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Interact", gamepad = "ButtonX", key = "E" },
								{ action = "Night Vision", gamepad = "ButtonY", key = "N" },
								{ action = "Zoom", gamepad = "ButtonL2", key = "Q" },
								{ action = "Sprint", gamepad = "ButtonL3", key = "LeftShift" },
								{ action = "Crouch", gamepad = "ButtonB", key = "C" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Tactical",
		buttonStyle = "Outline",
		clickFx = "None",
		corner = "Sharp",
		decoration = "Grain",
		density = "Compact",
		fonts = { body = "RobotoMono:Medium", display = "PressStart2P", title = "PressStart2P" },
		iconStyle = "Filled",
		palette = {
			accent = "#FF2D3A",
			bad = "#FF2D3A",
			good = "#7BD389",
			primary = "#E8E4DA",
			secondary = "#3A3F4B",
			surface = "#0A0B0D",
			text = "#F2EFE6",
			warn = "#E9C46A",
		},
		panelStyle = "Solid",
		rarityScheme = "Arena",
		shadow = "Hard",
		textCase = "Upper",
		transition = "Fade",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="57">
              <Properties>
                <string name="Name">horror_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: horror, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD23F",
			format = "comma",
			icon = "tag",
			id = "tickets",
			name = "Tickets",
			source = { kind = "leaderstat", name = "Tickets" },
			start = 275,
		},
		{
			color = "#FF3FA4",
			format = "comma",
			icon = "star",
			id = "stars",
			name = "Stars",
			source = { kind = "attribute", name = "Stars" },
			start = 40,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopRight",
				id = "hud_objective",
				label = "SHIFT TASK",
				text = "Find the red hand",
				type = "Objective",
				visible = true,
			},
			{
				action = { kind = "Open", target = "journal" },
				anchor = "TopRight",
				icon = "gift-box",
				id = "hud_button",
				label = "Toy Box",
				tone = "Blue",
				type = "Button",
				visible = true,
			},
			{
				addAction = { kind = "None" },
				anchor = "TopLeft",
				currencies = { "tickets" },
				id = "hud_currencybar",
				showAdd = false,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "TopLeft",
				goal = 6,
				icon = "rectangle-play-stacked",
				id = "hud_counter",
				label = "VHS TAPES",
				style = "Stamp",
				type = "Counter",
				value = 4,
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				slots = {
					{ icon = "hand", name = "Blue Hand" },
					{ icon = "hand", name = "Red Hand" },
					{ icon = "hand", name = "Green Hand" },
					{ icon = "flame", name = "Flare Hand" },
				},
				type = "Hotbar",
				visible = true,
			},
			{ anchor = "BottomLeft", id = "hud_stamina", type = "Stamina", visible = true },
			{
				anchor = "Center",
				id = "hud_vignette",
				intensity = 0.31,
				style = "Soft",
				type = "Vignette",
				visible = true,
			},
			{ anchor = "Center", id = "hud_fear", intensity = 0.3, type = "Fear", visible = true },
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonL1", key = "Q", text = "Left Hand" },
					{ gamepad = "ButtonR1", key = "E", text = "Right Hand" },
					{ gamepad = "ButtonL3", key = "LeftShift", text = "Sprint" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "LeftHand" },
						icon = "hand",
						id = "touch_left_hand",
						label = "Left Hand",
					},
					{
						action = { kind = "Event", name = "RightHand" },
						icon = "hand",
						id = "touch_right_hand",
						label = "Right Hand",
					},
					{
						action = { kind = "Event", name = "Sprint" },
						icon = "person-running",
						id = "touch_sprint",
						label = "Sprint",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
		},
		kit = "Chunky Toy",
		layout = "TopBar",
	},
	logo = { icon = "gift-box", text = "Snuggle Playhouse", tilt = -5, tone = "Red", type = "Stacked" },
	meta = {
		flavor = "toys",
		genre = "horror",
		kit = "Chunky Toy",
		prompt = "a horror game",
		seed = 7,
		source = "offline",
		title = "Snuggle Playhouse",
	},
	screens = {
		{
			description = "The first thing players see: the Snuggle Playhouse logo, a progress bar and rotating tips while the place streams in.",
			duration = 4,
			icon = "timer",
			id = "loading",
			kind = "Loading",
			tips = {
				"Tapes hide in the break rooms. Listen for static.",
				"Flares buy you three seconds. Use them wisely.",
				"Mascots only move when you aren't looking.",
				"Some doors need two hands. Some need both players.",
			},
			title = "Loading",
		},
		{
			bannerPosition = "Bottom",
			description = "The title screen: the Snuggle Playhouse logo, CLOCK IN, Party, Chapters, Collectibles and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Center",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "CLOCK IN",
							tone = "Red",
						},
						{
							action = { kind = "Open", target = "party" },
							icon = "friends",
							id = "menu_party",
							label = "Party",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "chapters" },
							icon = "gift-box",
							id = "menu_chapters",
							label = "Chapters",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "journal" },
							icon = "book-open",
							id = "menu_journal",
							label = "Collectibles",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "White",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Grid",
				},
				{
					button = { action = { kind = "Close", target = "menu" }, label = "Play Now" },
					icon = "tag",
					id = "double_tickets",
					kind = "Banner",
					subtitle = "Every tape pays twice",
					timer = 691200,
					title = "Double Tickets",
					tone = "Yellow",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Invite friends into your party and pick a mode together; the party leader's pick drives matchmaking.",
			icon = "friends",
			id = "party",
			kind = "Window",
			nav = "None",
			openKey = "P",
			sections = {
				{ id = "party_2", kind = "Party", slots = 4 },
				{
					buttons = {
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "trophy",
							id = "mode_ranked",
							label = "Ranked",
							tone = "Red",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "gear",
							id = "mode_custom",
							label = "Custom",
							tone = "White",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "play",
							id = "mode_quick_match",
							label = "Quick Match",
							tone = "White",
						},
					},
					id = "modes",
					kind = "Buttons",
					layout = "Grid",
					title = "Modes",
				},
			},
			size = "Medium",
			title = "Party",
		},
		{
			description = "Chapter select: each chapter card is a toy box; locked chapters are taped shut.",
			icon = "gift-box",
			id = "chapters",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "worlds",
					kind = "Worlds",
					worlds = {
						{
							icon = "gift-box",
							id = "world_ch_1_clocking_in",
							locked = false,
							name = "Ch 1 Clocking In",
							tone = "Red",
						},
						{
							icon = "gear",
							id = "world_ch_2_assembly_line",
							locked = false,
							name = "Ch 2 Assembly Line",
							tone = "Blue",
						},
						{
							icon = "house",
							id = "world_ch_3_playhouse",
							locked = true,
							name = "Ch 3 Playhouse",
							requirement = "Beat Ch 2 Assembly Line",
							tone = "Yellow",
						},
						{
							icon = "lock",
							id = "world_ch_4_mascot_vault",
							locked = true,
							name = "Ch 4 Mascot Vault",
							requirement = "Beat Ch 3 Playhouse",
							tone = "Red",
						},
						{
							icon = "moon",
							id = "world_ch_5_lights_out",
							locked = true,
							name = "Ch 5 Lights Out",
							requirement = "Beat Ch 4 Mascot Vault",
							tone = "Blue",
						},
					},
				},
			},
			size = "Large",
			title = "Chapters",
		},
		{
			description = "The only 'menu' during a run: the last note found, and every item picked up. It reads like evidence, not a game menu.",
			icon = "book-open",
			id = "journal",
			kind = "Window",
			nav = "Tabs",
			openKey = "J",
			size = "Large",
			tabs = {
				{
					icon = "backpack",
					id = "tab_collectibles",
					label = "Collectibles",
					sections = {
						{
							columns = 4,
							detail = true,
							id = "inventory",
							items = {
								{ icon = "gift-box", id = "inv_gift_box", name = "Gift Box", rarity = "Legendary" },
								{ icon = "rectangle-play-stacked", id = "inv_vhs_tape", name = "VHS Tape", rarity = "Rare" },
								{ icon = "key", id = "inv_keycard", name = "Keycard", rarity = "Uncommon" },
								{ icon = "hand", id = "inv_red_hand", name = "Red Hand", rarity = "Uncommon" },
								{ icon = "hand", id = "inv_blue_hand", name = "Blue Hand", rarity = "Common" },
								{ icon = "pencil", id = "inv_crayon", name = "Crayon", rarity = "Common" },
							},
							kind = "Inventory",
						},
					},
				},
				{
					icon = "book-open",
					id = "tab_memos",
					label = "Memos",
					sections = {
						{
							body = "VHS 2: 'Say hello to our newest friend!' The recording stops halfway through the song.",
							id = "latest_note",
							kind = "Text",
							title = "Latest Note",
						},
						{
							body = "STAFF MEMO: Do not feed the mascots after closing. Do not let them out of the test rooms.",
							id = "older_note",
							kind = "Text",
							title = "Older Note",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_achievements",
					label = "Achievements",
					sections = {
						{
							achievements = {
								{
									description = "Start your first shift",
									goal = 1,
									icon = "clock",
									id = "ach_clocked_in",
									points = 5,
									progress = 1,
									reward = { amount = "4", grant = { amount = 4, currency = "stars" }, icon = "star" },
									title = "Clocked In",
								},
								{
									description = "Hide from a mascot 25 times",
									goal = 25,
									icon = "eye-slash",
									id = "ach_hide_and_squeak",
									points = 20,
									progress = 22,
									reward = { amount = "20", grant = { amount = 20, currency = "stars" }, icon = "star" },
									title = "Hide and Squeak",
								},
								{
									description = "Unlock 4 hands",
									goal = 4,
									icon = "hand",
									id = "ach_handy",
									points = 25,
									progress = 2,
									reward = { amount = "20", grant = { amount = 20, currency = "stars" }, icon = "star" },
									title = "Handy",
								},
								{
									description = "Find 12 VHS tapes",
									goal = 12,
									icon = "rectangle-play-stacked",
									id = "ach_tape_collector",
									points = 30,
									progress = 0,
									reward = { amount = "25", grant = { amount = 25, currency = "stars" }, icon = "star" },
									title = "Tape Collector",
								},
								{
									description = "Finish chapter 5",
									goal = 1,
									icon = "moon",
									id = "ach_lights_out",
									points = 60,
									progress = 0,
									reward = { amount = "65", grant = { amount = 65, currency = "stars" }, icon = "star" },
									title = "Lights Out",
								},
							},
							id = "achievements",
							kind = "Achievements",
						},
					},
				},
			},
			title = "Collectibles",
		},
		{
			description = "Redeem promo codes. The server validates them through the RedeemCode remote.",
			icon = "tag",
			id = "codes",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					body = "New codes drop with every update and milestone. Check the game page!",
					id = "text",
					kind = "Text",
				},
				{
					action = { kind = "Remote", name = "RedeemCode" },
					button = "Redeem",
					id = "codes_2",
					kind = "Codes",
					placeholder = "Enter a code",
				},
			},
			size = "Small",
			title = "Codes",
			transition = "Drop",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "Retry" },
					icon = "rebirth",
					id = "try_again",
					label = "Try Again",
					tone = "Red",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "main_menu",
					label = "Main Menu",
					tone = "White",
				},
			},
			description = "Shown the instant a run ends: what you achieved, what you keep, and a one-click way back in.",
			icon = "health",
			id = "gameover",
			kind = "Results",
			result = "Defeat",
			rewards = { { amount = "+25", icon = "tag" }, { amount = "+200 XP", icon = "level" } },
			stats = {
				{ label = "Tapes Found", value = "3/6" },
				{ label = "Time", value = "10:16" },
				{ label = "Tickets", value = "+59" },
				{ label = "Chapter", value = "24" },
			},
			title = "Playtime's Over",
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Flashes & Shake",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
						{
							buttons = {
								{
									action = { kind = "Open", target = "codes" },
									icon = "tag",
									id = "goto_codes",
									label = "Codes",
									tone = "White",
								},
							},
							id = "buttons_2",
							kind = "Buttons",
							layout = "Row",
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Left Hand", gamepad = "ButtonL1", key = "Q" },
								{ action = "Right Hand", gamepad = "ButtonR1", key = "E" },
								{ action = "Interact", gamepad = "ButtonX", key = "F" },
								{ action = "Sprint", gamepad = "ButtonL3", key = "LeftShift" },
								{ action = "Crouch", gamepad = "ButtonB", key = "C" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "loading",
	style = {
		archetype = "Bubbly",
		buttonStyle = "Glossy3D",
		clickFx = "Poof",
		corner = "Bubbly",
		decoration = "Dots",
		density = "Comfy",
		fonts = { body = "Nunito:Bold", display = "DenkOne", title = "DenkOne" },
		iconStyle = "Filled",
		palette = {
			accent = "#FFC93C",
			bad = "#E8403C",
			good = "#55D66B",
			primary = "#FF4D4D",
			secondary = "#3D8BFF",
			surface = "#1C1530",
			text = "#FFFBF2",
			warn = "#FFA62B",
		},
		panelStyle = "Gradient",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Pop",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="58">
              <Properties>
                <string name="Name">obby_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: obby, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFE14F",
			format = "comma",
			icon = "star",
			id = "stars",
			name = "Stars",
			source = { kind = "leaderstat", name = "Stars" },
			start = 70,
		},
		{
			color = "#3AD1FF",
			format = "comma",
			icon = "gem",
			id = "gems",
			name = "Gems",
			source = { kind = "attribute", name = "Gems" },
			start = 70,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				id = "hud_objective",
				text = "Stage 37 / 50",
				type = "Objective",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Run time",
				seconds = 0,
				type = "Timer",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopRight",
				currencies = { "stars", "gems" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Toggle", target = "stages" },
						icon = "map",
						id = "btn_stages",
						label = "Stages",
						tone = "Gray",
					},
					{
						action = { kind = "Toggle", target = "trails" },
						icon = "cursor-stars",
						id = "btn_trails",
						label = "Trails & Gear",
						tone = "Pink",
					},
					{
						action = { kind = "Toggle", target = "rewards" },
						icon = "gift",
						id = "btn_rewards",
						label = "Free Rewards",
						tone = "Gray",
					},
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Shop",
						tone = "Green",
					},
					{
						action = { kind = "Open", target = "codes" },
						icon = "tag",
						id = "btn_codes",
						label = "Codes",
						tone = "Gray",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_xpbar",
				level = 37,
				look = "Thin",
				progress = 0.74,
				type = "XPBar",
				visible = true,
			},
			{
				action = { kind = "Event", name = "SkipStage" },
				anchor = "Right",
				icon = "double-chevron-large-right",
				id = "hud_button",
				label = "Skip Stage",
				tone = "Pink",
				type = "Button",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonL3", key = "LeftShift", text = "Shift Lock" },
					{ gamepad = "ButtonY", key = "R", text = "Reset" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				icon = "flame",
				id = "hud_meter",
				label = "DIFFICULTY",
				low = 0,
				readout = "Difficult",
				style = "Bar",
				tone = "Orange",
				type = "Meter",
				value = 0.5,
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_counter",
				label = "DEATHS",
				style = "Scrawl",
				type = "Counter",
				value = 391,
				visible = true,
			},
			{ anchor = "TopRight", id = "hud_minimap", shape = "Square", type = "Minimap", visible = true },
		},
		kit = "Retro",
		layout = "Corners",
	},
	logo = { icon = "person-running", text = "Ultimate Chart Obby", tilt = 0, tone = "Green", type = "Neon" },
	meta = {
		flavor = "chart",
		genre = "obby",
		kit = "Retro",
		prompt = "a obby game",
		seed = 21,
		source = "offline",
		title = "Ultimate Chart Obby",
	},
	screens = {
		{
			description = "Stage select by world, with checkpoints you have reached and a skip option for the stuck.",
			icon = "map",
			id = "stages",
			kind = "Window",
			nav = "None",
			openKey = "M",
			sections = {
				{
					id = "worlds",
					kind = "Worlds",
					worlds = {
						{
							icon = "wheat",
							id = "world_grasslands_1_10",
							locked = false,
							name = "Grasslands 1-10",
							tone = "Green",
						},
						{
							icon = "flame",
							id = "world_lava_lake_11_20",
							locked = false,
							name = "Lava Lake 11-20",
							tone = "Gray",
						},
						{
							icon = "cloud",
							id = "world_ice_caves_21_30",
							locked = false,
							name = "Ice Caves 21-30",
							tone = "Pink",
						},
						{
							icon = "lollipop",
							id = "world_candy_land_31_40",
							locked = true,
							name = "Candy Land 31-40",
							requirement = "Beat Ice Caves 21-30",
							tone = "Green",
						},
						{
							icon = "crown",
							id = "world_sky_castle_41_50",
							locked = true,
							name = "Sky Castle 41-50",
							requirement = "Beat Candy Land 31-40",
							tone = "Gray",
						},
						{
							icon = "nebula",
							id = "world_the_void_51_60",
							locked = true,
							name = "The Void 51-60",
							requirement = "Beat Sky Castle 41-50",
							tone = "Pink",
						},
					},
				},
				{
					id = "stuck",
					items = {
						{
							currency = "robux",
							description = "Jump straight past this stage",
							icon = "double-chevron-large-right",
							id = "skip_1",
							price = 19,
							title = "Skip 1 Stage",
							tone = "Green",
						},
						{
							currency = "robux",
							description = "Rescue for a truly evil section",
							icon = "double-chevron-large-right",
							id = "skip_5",
							price = 79,
							tag = "SAVE 17%",
							title = "Skip 5 Stages",
							tone = "Pink",
							was = 95,
						},
					},
					kind = "Products",
					layout = "Rows",
					title = "Stuck?",
				},
			},
			size = "Large",
			title = "Stages",
		},
		{
			description = "Owned trails and gear coils; equipping one shows it behind the avatar instantly.",
			icon = "cursor-stars",
			id = "trails",
			kind = "Window",
			nav = "None",
			sections = {
				{
					columns = 5,
					detail = true,
					id = "inventory",
					items = {
						{
							equipped = true,
							icon = "nebula",
							id = "inv_galaxy_trail",
							name = "Galaxy Trail",
							rarity = "Legendary",
						},
						{ icon = "flame", id = "inv_fire_trail", name = "Fire Trail", rarity = "Epic" },
						{ icon = "arrow-large-up", id = "inv_gravity_coil", name = "Gravity Coil", rarity = "Rare" },
						{ icon = "heart", id = "inv_heart_trail", name = "Heart Trail", rarity = "Uncommon" },
						{
							icon = "double-chevron-large-up",
							id = "inv_speed_coil",
							name = "Speed Coil",
							rarity = "Uncommon",
						},
						{ icon = "circle", id = "inv_bubble_trail", name = "Bubble Trail", rarity = "Common" },
						{ icon = "star", id = "inv_star_trail", name = "Star Trail", rarity = "Common" },
						{
							icon = "lightning-bolt",
							id = "locked_lightning_trail",
							locked = true,
							name = "Lightning Trail",
							rarity = "Epic",
						},
						{
							icon = "moon",
							id = "locked_ghost_trail",
							locked = true,
							name = "Ghost Trail",
							rarity = "Mythic",
						},
					},
					kind = "Inventory",
				},
			},
			size = "Medium",
			title = "Trails & Gear",
		},
		{
			description = "Everything free in one place: Daily, Gifts and Social. Every tab badges what's ready, and so does the HUD button.",
			icon = "gift",
			id = "rewards",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							days = {
								{ amount = "150", day = 1, icon = "star" },
								{ amount = "225", day = 2, icon = "star" },
								{ amount = "x2", day = 3, icon = "boost", label = "15 min" },
								{ amount = "30", day = 4, icon = "gem" },
								{ amount = "450", day = 5, icon = "star" },
								{ amount = "525", day = 6, icon = "star" },
								{ amount = "150", day = 7, icon = "gift", label = "Mega Reward" },
							},
							id = "daily",
							kind = "Daily",
							streak = 1,
						},
					},
				},
				{
					icon = "gift",
					id = "tab_gifts",
					label = "Gifts",
					sections = {
						{
							gifts = {
								{
									amount = "55",
									grant = { amount = 55, currency = "stars" },
									icon = "star",
									id = "gift1",
									minutes = 1,
									rarity = "Common",
								},
								{
									amount = "80",
									grant = { amount = 80, currency = "stars" },
									icon = "star",
									id = "gift2",
									minutes = 3,
									rarity = "Common",
								},
								{
									amount = "100",
									grant = { amount = 100, currency = "stars" },
									icon = "star",
									id = "gift3",
									minutes = 5,
									rarity = "Common",
								},
								{
									amount = "175",
									grant = { amount = 175, currency = "stars" },
									icon = "star",
									id = "gift4",
									minutes = 10,
									rarity = "Uncommon",
								},
								{
									amount = "250",
									grant = { amount = 250, currency = "stars" },
									icon = "star",
									id = "gift5",
									minutes = 15,
									rarity = "Uncommon",
								},
								{
									amount = "325",
									grant = { amount = 325, currency = "stars" },
									icon = "star",
									id = "gift6",
									minutes = 20,
									rarity = "Rare",
								},
								{
									amount = "10",
									grant = { amount = 10, currency = "gems" },
									icon = "gem",
									id = "gift7",
									minutes = 30,
									rarity = "Rare",
								},
								{
									amount = "50",
									grant = { amount = 50, currency = "gems" },
									icon = "gem",
									id = "gift8",
									minutes = 45,
									rarity = "Legendary",
								},
							},
							id = "gifts",
							kind = "Gifts",
						},
					},
				},
				{
					icon = "three-people",
					id = "tab_social",
					label = "Social",
					sections = {
						{
							id = "social",
							kind = "Social",
							rewards = {
								{
									icon = "three-people",
									id = "group",
									kind = "Group",
									label = "Join the Group",
									reward = { amount = "400", grant = { amount = 400, currency = "stars" }, icon = "star" },
								},
								{
									icon = "thumb-up",
									id = "like",
									kind = "Like",
									label = "Like the Game",
									reward = { amount = "10", grant = { amount = 10, currency = "gems" }, icon = "gem" },
									wait = 60,
								},
								{
									icon = "person-plus",
									id = "invite",
									kind = "Invite",
									label = "Invite a Friend",
									limit = 10,
									reward = { amount = "25", grant = { amount = 25, currency = "gems" }, icon = "gem" },
								},
							},
						},
					},
				},
			},
			title = "Free Rewards",
		},
		{
			description = "Where Stars and Robux turn into progress. Tabs: Gear, Passes and Gems.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					icon = "backpack",
					id = "tab_gear",
					label = "Gear",
					sections = {
						{
							id = "products",
							items = {
								{
									amount = "Uncommon",
									currency = "stars",
									icon = "double-chevron-large-up",
									id = "buy_speed_coil",
									price = 300,
									title = "Speed Coil",
									tone = "Green",
								},
								{
									amount = "Rare",
									currency = "stars",
									icon = "arrow-large-up",
									id = "buy_gravity_coil",
									price = 575,
									title = "Gravity Coil",
									tone = "Blue",
								},
								{
									amount = "Epic",
									currency = "stars",
									icon = "lightning-bolt",
									id = "buy_lightning_trail",
									price = 1500,
									title = "Lightning Trail",
									tone = "Purple",
								},
								{
									amount = "Epic",
									currency = "stars",
									icon = "flame",
									id = "buy_fire_trail",
									price = 2000,
									title = "Fire Trail",
									tone = "Purple",
								},
								{
									amount = "Legendary",
									currency = "stars",
									icon = "nebula",
									id = "buy_galaxy_trail",
									price = 4500,
									title = "Galaxy Trail",
									tone = "Yellow",
								},
								{
									amount = "Mythic",
									currency = "stars",
									icon = "moon",
									id = "buy_ghost_trail",
									price = 10000,
									tag = "NEW",
									title = "Ghost Trail",
									tone = "Red",
								},
							},
							kind = "Products",
							layout = "Rows",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Stars",
									icon = "crown",
									id = "pass_vip",
									price = 499,
									tag = "MUST HAVE",
									title = "VIP",
									tone = "Green",
								},
								{
									currency = "robux",
									description = "One more chance when it counts",
									icon = "flag",
									id = "pass_checkpoint_saver",
									price = 149,
									title = "Checkpoint Saver",
									tone = "Gray",
								},
								{
									currency = "robux",
									description = "Stand out in every lobby",
									icon = "cursor-stars",
									id = "pass_rainbow_trail",
									price = 99,
									title = "Rainbow Trail",
									tone = "Pink",
								},
							},
							kind = "Products",
							layout = "Rows",
							title = "Gamepasses",
						},
					},
				},
				{
					icon = "gem",
					id = "tab_gems",
					label = "Gems",
					sections = {
						{
							id = "gem_bundles",
							items = {
								{
									amount = "100",
									currency = "robux",
									grant = { amount = 100, currency = "gems" },
									icon = "gem",
									id = "gems_pack_1",
									price = 49,
									title = "Handful of Gems",
									tone = "Gray",
								},
								{
									amount = "225",
									bonus = 11,
									currency = "robux",
									grant = { amount = 225, currency = "gems" },
									icon = "gem",
									id = "gems_pack_2",
									price = 99,
									tag = "SALE",
									title = "Pouch of Gems",
									tone = "Gray",
									was = 149,
								},
								{
									amount = "500",
									bonus = 23,
									currency = "robux",
									grant = { amount = 500, currency = "gems" },
									icon = "gem",
									id = "gems_pack_3",
									price = 199,
									title = "Sack of Gems",
									tone = "Green",
								},
								{
									amount = "1,250",
									bonus = 54,
									currency = "robux",
									grant = { amount = 1250, currency = "gems" },
									icon = "file-box",
									id = "gems_pack_4",
									price = 399,
									tag = "BEST VALUE",
									title = "Chest of Gems",
									tone = "Green",
								},
							},
							kind = "Products",
							layout = "Rows",
							title = "Gem Bundles",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Redeem promo codes. The server validates them through the RedeemCode remote.",
			icon = "tag",
			id = "codes",
			kind = "Popup",
			nav = "None",
			sections = {
				{ body = "Like the game and join the group for secret codes.", id = "text", kind = "Text" },
				{
					action = { kind = "Remote", name = "RedeemCode" },
					button = "Redeem",
					id = "codes_2",
					kind = "Codes",
					placeholder = "e.g. RELEASE",
				},
			},
			size = "Small",
			title = "Codes",
			transition = "Fade",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Green",
				},
				{ action = { kind = "Close" }, icon = "close", id = "close", label = "Close", tone = "Dark" },
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Victory",
			rewards = {
				{ amount = "+150", icon = "star" },
				{ amount = "+150 XP", icon = "level" },
				{ amount = "+10", icon = "gem" },
			},
			stats = {
				{ label = "Stage", value = "48" },
				{ label = "Time", value = "15:54" },
				{ label = "Deaths", value = "7" },
				{ label = "Best", value = "17:21" },
			},
			title = "Results",
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "eye-slash",
									id = "hideOtherPlayers",
									label = "Hide Other Players",
									type = "Toggle",
								},
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Jump", gamepad = "ButtonA", key = "Space" },
								{ action = "Shift Lock", gamepad = "ButtonL3", key = "LeftShift" },
								{ action = "Reset", gamepad = "ButtonY", key = "R" },
								{ action = "Skip Stage", gamepad = "DPadRight", key = "K" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	style = {
		archetype = "Sleek",
		buttonStyle = "Flat",
		clickFx = "Squash",
		corner = "Sharp",
		decoration = "Grain",
		density = "Compact",
		fonts = {
			body = "BuilderSans:SemiBold",
			display = "BuilderExtended:ExtraBold",
			title = "BuilderExtended:ExtraBold",
		},
		iconStyle = "Filled",
		palette = {
			accent = "#FF5C8A",
			bad = "#FF5C5C",
			good = "#4ADE80",
			primary = "#C6F432",
			secondary = "#9AA3B2",
			surface = "#121314",
			text = "#F5F5F5",
			warn = "#FACC15",
		},
		panelStyle = "Solid",
		rarityScheme = "Arena",
		shadow = "Soft",
		textCase = "Normal",
		transition = "Fade",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="59">
              <Properties>
                <string name="Name">obby_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: obby, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD21F",
			format = "comma",
			icon = "coin",
			id = "coins",
			name = "Coins",
			source = { kind = "leaderstat", name = "Coins" },
			start = 975,
		},
		{
			color = "#3AD1FF",
			format = "comma",
			icon = "gem",
			id = "gems",
			name = "Gems",
			source = { kind = "attribute", name = "Gems" },
			start = 125,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				id = "hud_objective",
				text = "Stage 62 / 100",
				type = "Objective",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Run time",
				seconds = 0,
				type = "Timer",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopLeft",
				currencies = { "coins", "gems" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Toggle", target = "trails" },
						icon = "cursor-stars",
						id = "btn_trails",
						label = "Trails",
						tone = "Blue",
					},
					{
						action = { kind = "Toggle", target = "stages" },
						icon = "map",
						id = "btn_stages",
						label = "Stages",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Shop",
						tone = "Pink",
					},
					{
						action = { kind = "Toggle", target = "rewards" },
						icon = "gift",
						id = "btn_rewards",
						label = "Free Rewards",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "leaderboard" },
						icon = "trophy",
						id = "btn_leaderboard",
						label = "Rankings",
						tone = "Blue",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "Dark",
					},
					{
						action = { kind = "Open", target = "codes" },
						icon = "tag",
						id = "btn_codes",
						label = "Codes",
						tone = "Dark",
					},
					{
						action = { kind = "Open", target = "vip" },
						icon = "crown",
						id = "btn_vip",
						label = "VIP",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_xpbar",
				level = 62,
				progress = 0.62,
				type = "XPBar",
				visible = true,
			},
			{
				action = { kind = "Event", name = "SkipStage" },
				anchor = "Right",
				icon = "double-chevron-large-right",
				id = "hud_button",
				label = "Skip Stage",
				tone = "Yellow",
				type = "Button",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonL3", key = "LeftShift", text = "Shift Lock" },
					{ gamepad = "ButtonY", key = "R", text = "Reset" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				icon = "eye",
				id = "hud_meter",
				label = "DANGER",
				segments = 5,
				style = "Dial",
				tone = "Red",
				type = "Meter",
				value = 0.23,
				visible = true,
			},
			{
				action = { kind = "Event", name = "Hide" },
				anchor = "Right",
				icon = "door",
				id = "hud_button_2",
				label = "Hide",
				tone = "Yellow",
				type = "Button",
				visible = true,
			},
		},
		kit = "Chunky Toy",
		layout = "Corners",
	},
	logo = { icon = "flag", text = "Escape the School", tilt = -4, tone = "Pink", type = "Stacked" },
	meta = {
		flavor = "escape",
		genre = "obby",
		kit = "Chunky Toy",
		prompt = "a obby game",
		seed = 7,
		source = "offline",
		title = "Escape the School",
	},
	screens = {
		{
			description = "Owned trails and gear coils; equipping one shows it behind the avatar instantly.",
			icon = "cursor-stars",
			id = "trails",
			kind = "Window",
			nav = "None",
			sections = {
				{
					columns = 5,
					detail = true,
					id = "inventory",
					items = {
						{
							equipped = true,
							icon = "nebula",
							id = "inv_galaxy_trail",
							name = "Galaxy Trail",
							rarity = "Legendary",
						},
						{ icon = "flame", id = "inv_fire_trail", name = "Fire Trail", rarity = "Epic" },
						{
							icon = "lightning-bolt",
							id = "inv_lightning_trail",
							name = "Lightning Trail",
							rarity = "Epic",
						},
						{ icon = "arrow-large-up", id = "inv_gravity_coil", name = "Gravity Coil", rarity = "Rare" },
						{ icon = "cursor-stars", id = "inv_rainbow_trail", name = "Rainbow Trail", rarity = "Rare" },
						{
							icon = "double-chevron-large-up",
							id = "inv_speed_coil",
							name = "Speed Coil",
							rarity = "Uncommon",
						},
						{ icon = "circle", id = "inv_bubble_trail", name = "Bubble Trail", rarity = "Common" },
						{ icon = "star", id = "inv_star_trail", name = "Star Trail", rarity = "Common" },
						{
							icon = "moon",
							id = "locked_ghost_trail",
							locked = true,
							name = "Ghost Trail",
							rarity = "Mythic",
						},
					},
					kind = "Inventory",
					layout = "Showcase",
				},
			},
			size = "Medium",
			title = "Trails",
		},
		{
			description = "Stage select by world, with checkpoints you have reached and a skip option for the stuck.",
			icon = "map",
			id = "stages",
			kind = "Window",
			nav = "None",
			openKey = "M",
			sections = {
				{
					id = "worlds",
					kind = "Worlds",
					worlds = {
						{
							icon = "wheat",
							id = "world_grasslands_1_10",
							locked = false,
							name = "Grasslands 1-10",
							tone = "Pink",
						},
						{
							icon = "flame",
							id = "world_lava_lake_11_20",
							locked = false,
							name = "Lava Lake 11-20",
							tone = "Blue",
						},
						{
							icon = "cloud",
							id = "world_ice_caves_21_30",
							locked = true,
							name = "Ice Caves 21-30",
							requirement = "Beat Lava Lake 11-20",
							tone = "Yellow",
						},
						{
							icon = "lollipop",
							id = "world_candy_land_31_40",
							locked = true,
							name = "Candy Land 31-40",
							requirement = "Beat Ice Caves 21-30",
							tone = "Pink",
						},
						{
							icon = "crown",
							id = "world_sky_castle_41_50",
							locked = true,
							name = "Sky Castle 41-50",
							requirement = "Beat Candy Land 31-40",
							tone = "Blue",
						},
						{
							icon = "nebula",
							id = "world_the_void_51_60",
							locked = true,
							name = "The Void 51-60",
							requirement = "Beat Sky Castle 41-50",
							tone = "Yellow",
						},
					},
				},
				{
					id = "stuck",
					items = {
						{
							currency = "robux",
							description = "Jump straight past this stage",
							icon = "double-chevron-large-right",
							id = "skip_1",
							price = 19,
							title = "Skip 1 Stage",
							tone = "Pink",
						},
						{
							currency = "robux",
							description = "Rescue for a truly evil section",
							icon = "double-chevron-large-right",
							id = "skip_5",
							price = 59,
							tag = "SAVE 38%",
							title = "Skip 5 Stages",
							tone = "Yellow",
							was = 95,
						},
					},
					kind = "Products",
					layout = "Compact",
					title = "Stuck?",
				},
			},
			size = "Large",
			title = "Stages",
		},
		{
			description = "Where Coins and Robux turn into progress. Tabs: Gear and Gems.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "backpack",
					id = "tab_gear",
					label = "Gear",
					sections = {
						{
							id = "products",
							items = {
								{
									amount = "Uncommon",
									currency = "coins",
									icon = "double-chevron-large-up",
									id = "buy_speed_coil",
									price = 300,
									title = "Speed Coil",
									tone = "Green",
								},
								{
									amount = "Rare",
									currency = "coins",
									icon = "arrow-large-up",
									id = "buy_gravity_coil",
									price = 750,
									title = "Gravity Coil",
									tone = "Blue",
								},
								{
									amount = "Rare",
									currency = "coins",
									icon = "cursor-stars",
									id = "buy_rainbow_trail",
									price = 800,
									title = "Rainbow Trail",
									tone = "Blue",
								},
								{
									amount = "Epic",
									currency = "coins",
									icon = "lightning-bolt",
									id = "buy_lightning_trail",
									price = 1500,
									title = "Lightning Trail",
									tone = "Purple",
								},
								{
									amount = "Epic",
									currency = "coins",
									icon = "flame",
									id = "buy_fire_trail",
									price = 1750,
									title = "Fire Trail",
									tone = "Purple",
								},
								{
									amount = "Legendary",
									currency = "coins",
									icon = "nebula",
									id = "buy_galaxy_trail",
									price = 4750,
									tag = "LIMITED",
									title = "Galaxy Trail",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Cards",
						},
					},
				},
				{
					icon = "gem",
					id = "tab_gems",
					label = "Gems",
					sections = {
						{
							id = "gem_bundles",
							items = {
								{
									amount = "150",
									currency = "robux",
									grant = { amount = 150, currency = "gems" },
									icon = "gem",
									id = "gems_pack_1",
									price = 49,
									title = "Starter Gems",
									tone = "Blue",
								},
								{
									amount = "350",
									bonus = 15,
									currency = "robux",
									grant = { amount = 350, currency = "gems" },
									icon = "gem",
									id = "gems_pack_2",
									price = 99,
									tag = "POPULAR",
									title = "Small Gem Pack",
									tone = "Blue",
								},
								{
									amount = "750",
									bonus = 23,
									currency = "robux",
									grant = { amount = 750, currency = "gems" },
									icon = "gem",
									id = "gems_pack_3",
									price = 199,
									title = "Big Gem Pack",
									tone = "Pink",
								},
								{
									amount = "1,750",
									bonus = 43,
									currency = "robux",
									grant = { amount = 1750, currency = "gems" },
									icon = "file-box",
									id = "gems_pack_4",
									price = 399,
									tag = "BEST VALUE",
									title = "Mega Gem Pack",
									tone = "Pink",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Gem Bundles",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Everything free in one place: Daily, Gifts and Social. Every tab badges what's ready, and so does the HUD button.",
			icon = "gift",
			id = "rewards",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							days = {
								{ amount = "150", day = 1, icon = "coin" },
								{ amount = "225", day = 2, icon = "coin" },
								{ amount = "40", day = 3, icon = "gem" },
								{ amount = "375", day = 4, icon = "coin" },
								{ amount = "x2", day = 5, icon = "boost", label = "15 min" },
								{ amount = "525", day = 6, icon = "coin" },
								{ amount = "200", day = 7, icon = "gift", label = "Mega Reward" },
							},
							id = "daily",
							kind = "Daily",
							layout = "Ladder",
							streak = 2,
						},
					},
				},
				{
					icon = "gift",
					id = "tab_gifts",
					label = "Gifts",
					sections = {
						{
							gifts = {
								{
									amount = "70",
									grant = { amount = 70, currency = "coins" },
									icon = "coin",
									id = "gift1",
									minutes = 1,
									rarity = "Common",
								},
								{
									amount = "100",
									grant = { amount = 100, currency = "coins" },
									icon = "coin",
									id = "gift2",
									minutes = 3,
									rarity = "Common",
								},
								{
									amount = "150",
									grant = { amount = 150, currency = "coins" },
									icon = "coin",
									id = "gift3",
									minutes = 5,
									rarity = "Common",
								},
								{
									amount = "225",
									grant = { amount = 225, currency = "coins" },
									icon = "coin",
									id = "gift4",
									minutes = 10,
									rarity = "Uncommon",
								},
								{
									amount = "325",
									grant = { amount = 325, currency = "coins" },
									icon = "coin",
									id = "gift5",
									minutes = 15,
									rarity = "Uncommon",
								},
								{
									amount = "400",
									grant = { amount = 400, currency = "coins" },
									icon = "coin",
									id = "gift6",
									minutes = 20,
									rarity = "Rare",
								},
								{
									amount = "10",
									grant = { amount = 10, currency = "gems" },
									icon = "gem",
									id = "gift7",
									minutes = 30,
									rarity = "Rare",
								},
								{
									amount = "850",
									grant = { amount = 850, currency = "coins" },
									icon = "coin",
									id = "gift8",
									minutes = 45,
									rarity = "Rare",
								},
								{
									amount = "20",
									grant = { amount = 20, currency = "gems" },
									icon = "gem",
									id = "gift9",
									minutes = 60,
									rarity = "Epic",
								},
								{
									amount = "1,250",
									grant = { amount = 1250, currency = "coins" },
									icon = "coin",
									id = "gift10",
									minutes = 75,
									rarity = "Rare",
								},
								{
									amount = "35",
									grant = { amount = 35, currency = "gems" },
									icon = "gem",
									id = "gift11",
									minutes = 90,
									rarity = "Epic",
								},
								{
									amount = "x1",
									icon = "flame",
									id = "gift12",
									label = "Fire Trail",
									minutes = 120,
									rarity = "Epic",
								},
							},
							id = "gifts",
							kind = "Gifts",
						},
					},
				},
				{
					icon = "three-people",
					id = "tab_social",
					label = "Social",
					sections = {
						{
							id = "social",
							kind = "Social",
							rewards = {
								{
									icon = "three-people",
									id = "group",
									kind = "Group",
									label = "Join the Group",
									reward = { amount = "400", grant = { amount = 400, currency = "coins" }, icon = "coin" },
								},
								{
									icon = "thumb-up",
									id = "like",
									kind = "Like",
									label = "Like the Game",
									reward = { amount = "10", grant = { amount = 10, currency = "gems" }, icon = "gem" },
									wait = 60,
								},
								{
									icon = "person-plus",
									id = "invite",
									kind = "Invite",
									label = "Invite a Friend",
									limit = 10,
									reward = { amount = "25", grant = { amount = 25, currency = "gems" }, icon = "gem" },
								},
							},
						},
					},
				},
			},
			title = "Free Rewards",
		},
		{
			description = "Global top ten for Stage, Wins and Best Time, with podium colours for the top three.",
			icon = "trophy",
			id = "leaderboard",
			kind = "Window",
			nav = "Tabs",
			size = "Medium",
			tabs = {
				{
					icon = "trophy",
					id = "tab_stage",
					label = "Stage",
					sections = {
						{
							id = "leaderboard_2",
							kind = "Leaderboard",
							rows = {
								{ name = "Jelly101", rank = 1, value = "201" },
								{ name = "ItsGlitchPanda", rank = 2, value = "184" },
								{ name = "BubblePandaYT", rank = 3, value = "137" },
								{ name = "xXWobblyQueenXx", rank = 4, value = "126" },
								{ name = "BubbleKingYT", rank = 5, value = "96" },
								{ name = "CookieBlade_360", rank = 6, value = "82" },
								{ name = "NotWafflePup", rank = 7, value = "76" },
								{ name = "ZapByte_777", rank = 8, value = "72" },
								{ name = "BubbleQueenYT", rank = 9, value = "66" },
								{ name = "ZapFox2014", rank = 10, value = "51" },
							},
							stat = "Stage",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_wins",
					label = "Wins",
					sections = {
						{
							id = "leaderboard_3",
							kind = "Leaderboard",
							rows = {
								{ name = "Mango101", rank = 1, value = "2,197" },
								{ name = "Turbo2014", rank = 2, value = "1,941" },
								{ name = "ItsDoodleGamer", rank = 3, value = "1,791" },
								{ name = "NotLunaNinja", rank = 4, value = "1,397" },
								{ name = "xXSunnyAceXx", rank = 5, value = "1,234" },
								{ name = "CrimsonChefYT", rank = 6, value = "976" },
								{ name = "ShadowSprout88", rank = 7, value = "924" },
								{ name = "WobblyQueen", rank = 8, value = "700" },
								{ name = "Pixel21", rank = 9, value = "633" },
								{ name = "BlitzMoose_21", rank = 10, value = "525" },
							},
							stat = "Wins",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_best_time",
					label = "Best Time",
					sections = {
						{
							id = "leaderboard_4",
							kind = "Leaderboard",
							rows = {
								{ name = "ItsNoodleChef", rank = 1, value = "1:19.85" },
								{ name = "RealTurboByte", rank = 2, value = "1:21.80" },
								{ name = "PixelLlama33", rank = 3, value = "1:23.57" },
								{ name = "LuckyPanda", rank = 4, value = "1:27.95" },
								{ name = "ItsBlitzBot", rank = 5, value = "1:29.82" },
								{ name = "xXSunnyChefXx", rank = 6, value = "1:31.99" },
								{ name = "ZapRanger_360", rank = 7, value = "1:35.96" },
								{ name = "Nova_Knight", rank = 8, value = "1:38.77" },
								{ name = "Nimbus_Llama", rank = 9, value = "1:53.82" },
								{ name = "OnyxAce_42", rank = 10, value = "2:07.98" },
							},
							stat = "Best Time",
						},
					},
				},
			},
			title = "Rankings",
		},
		{
			description = "Redeem promo codes. The server validates them through the RedeemCode remote.",
			icon = "tag",
			id = "codes",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					body = "New codes drop with every update and milestone. Check the game page!",
					id = "text",
					kind = "Text",
				},
				{
					action = { kind = "Remote", name = "RedeemCode" },
					button = "Redeem",
					id = "codes_2",
					kind = "Codes",
					placeholder = "Enter a code",
				},
			},
			size = "Small",
			title = "Codes",
			transition = "Drop",
		},
		{
			description = "Upsell for the VIP pass and other gamepasses: 2x daily rewards, a gold name tag and an exclusive VIP area.",
			icon = "crown",
			id = "vip",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					icon = "crown",
					id = "become_vip",
					kind = "Banner",
					subtitle = "2X Daily Rewards, A Gold Name Tag And An Exclusive VIP Area",
					title = "Become VIP",
					tone = "Pink",
				},
				{
					id = "gamepasses",
					items = {
						{
							currency = "robux",
							description = "Gold name, VIP chat and +10% Coins",
							icon = "crown",
							id = "pass_vip",
							price = 399,
							tag = "POPULAR",
							title = "VIP",
							tone = "Pink",
						},
						{
							currency = "robux",
							description = "Jump higher and move faster",
							icon = "arrow-large-up",
							id = "pass_gravity_coil",
							price = 249,
							title = "Gravity Coil",
							tone = "Yellow",
						},
						{
							currency = "robux",
							description = "Move twice as fast, everywhere",
							icon = "double-chevron-large-up",
							id = "pass_speed_coil",
							price = 199,
							title = "Speed Coil",
							tone = "Yellow",
						},
						{
							currency = "robux",
							description = "One more chance when it counts",
							icon = "flag",
							id = "pass_checkpoint_saver",
							price = 149,
							title = "Checkpoint Saver",
							tone = "Blue",
						},
						{
							currency = "robux",
							description = "Stand out in every lobby",
							icon = "cursor-stars",
							id = "pass_rainbow_trail",
							price = 99,
							title = "Rainbow Trail",
							tone = "Yellow",
						},
					},
					kind = "Products",
					layout = "Carousel",
					title = "Gamepasses",
				},
			},
			size = "Medium",
			title = "VIP",
			transition = "Drop",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Pink",
				},
				{ action = { kind = "Close" }, icon = "close", id = "close", label = "Close", tone = "Dark" },
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Victory",
			rewards = { { amount = "+55", icon = "coin" }, { amount = "+600 XP", icon = "level" } },
			stats = {
				{ label = "Stage", value = "57" },
				{ label = "Time", value = "7:05" },
				{ label = "Deaths", value = "29" },
				{ label = "Best", value = "20:35" },
			},
			title = "Results",
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "eye-slash",
									id = "hideOtherPlayers",
									label = "Hide Other Players",
									type = "Toggle",
								},
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Jump", gamepad = "ButtonA", key = "Space" },
								{ action = "Shift Lock", gamepad = "ButtonL3", key = "LeftShift" },
								{ action = "Reset", gamepad = "ButtonY", key = "R" },
								{ action = "Skip Stage", gamepad = "DPadRight", key = "K" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	style = {
		archetype = "Adventure",
		buttonStyle = "Soft",
		clickFx = "Glow",
		corner = "Bubbly",
		decoration = "None",
		density = "Compact",
		fonts = { body = "BuilderSans:Bold", display = "Bangers", title = "Bangers" },
		iconStyle = "Filled",
		palette = {
			accent = "#FFD23F",
			bad = "#FF4F4F",
			good = "#4DF08A",
			primary = "#FF4F8B",
			secondary = "#4FB8FF",
			surface = "#2B1650",
			text = "#FFFFFF",
			warn = "#FFB020",
		},
		panelStyle = "Gradient",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Zoom",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="60">
              <Properties>
                <string name="Name">racing_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: racing, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD21F",
			format = "abbrev",
			icon = "coin",
			id = "credits",
			name = "Credits",
			source = { kind = "leaderstat", name = "Credits" },
			start = 52500,
		},
		{
			color = "#3AD1FF",
			format = "comma",
			icon = "gem",
			id = "gems",
			name = "Gems",
			source = { kind = "attribute", name = "Gems" },
			start = 200,
		},
	},
	hud = {
		elements = {
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopRight",
				currencies = { "credits", "gems" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Lap time",
				seconds = 0,
				type = "Timer",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_objective",
				look = "Waypoint",
				text = "Lap 3 / 3  ·  P3",
				type = "Objective",
				visible = true,
			},
			{ anchor = "TopRight", id = "hud_minimap", shape = "Square", type = "Minimap", visible = true },
			{
				anchor = "BottomCenter",
				boosts = { { icon = "lightning-bolt", seconds = 8, text = "Nitro" } },
				id = "hud_boosts",
				type = "Boosts",
				visible = true,
			},
			{
				action = { kind = "Open", target = "menu" },
				anchor = "TopRight",
				icon = "menu",
				id = "hud_button",
				label = "Menu",
				tone = "Dark",
				type = "Button",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonX", key = "LeftShift", text = "Nitro" },
					{ gamepad = "ButtonA", key = "Space", text = "Handbrake" },
					{ gamepad = "ButtonY", key = "R", text = "Respawn" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Nitro" },
						icon = "lightning-bolt",
						id = "touch_nitro",
						label = "Nitro",
					},
					{
						action = { kind = "Event", name = "Drift" },
						icon = "two-arrows-spin-clockwise",
						id = "touch_drift",
						label = "Drift",
					},
					{
						action = { kind = "Event", name = "Respawn" },
						icon = "arrow-spin-clockwise",
						id = "touch_respawn",
						label = "Respawn",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				id = "hud_counter",
				label = "MPH",
				style = "Digital",
				type = "Counter",
				value = 146,
				visible = true,
			},
			{
				anchor = "Right",
				icon = "flame",
				id = "hud_meter",
				label = "DRIFT",
				low = 0,
				readout = "x6",
				segments = 5,
				style = "Segments",
				tone = "Pink",
				type = "Meter",
				value = 0.2,
				visible = true,
			},
			{
				anchor = "Right",
				buttons = {
					{
						action = { kind = "Toggle", target = "rewards" },
						icon = "gift",
						id = "btn_rewards",
						label = "Free Rewards",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
		},
		kit = "Neon",
		layout = "Minimal",
	},
	logo = { icon = "speedometer", text = "Neon Racers", tilt = 0, tone = "Pink", type = "Neon" },
	meta = {
		flavor = "street",
		genre = "racing",
		kit = "Neon",
		prompt = "a racing game",
		seed = 21,
		source = "offline",
		title = "Neon Racers",
	},
	screens = {
		{
			description = "The first thing players see: the Neon Racers logo, a progress bar and rotating tips while the place streams in.",
			duration = 4,
			icon = "timer",
			id = "loading",
			kind = "Loading",
			tips = {
				"Tap nitro coming out of corners, not going into them.",
				"Draft behind a rival for a free speed boost.",
				"Your first win of the day pays double.",
				"On tight tracks, tyres matter more than horsepower.",
				"Drifting fills your nitro bar faster.",
			},
			title = "Loading",
		},
		{
			bannerPosition = "Side",
			description = "The title screen: the Neon Racers logo, PLAY, Garage, Tracks, Shop and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Split",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "PLAY",
							tone = "Pink",
						},
						{
							action = { kind = "Open", target = "garage" },
							icon = "speedometer",
							id = "menu_garage",
							label = "Garage",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "tracks" },
							icon = "flag",
							id = "menu_tracks",
							label = "Tracks",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "two-arrows-spin-clockwise",
					id = "welcome_bundle",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 172800,
					title = "Welcome Bundle",
					tone = "Yellow",
				},
			},
			title = "Main Menu",
		},
		{
			description = "The garage is the lobby: favourite cars on stat cards, the whole collection a tab away, and tuning to squeeze out more speed.",
			icon = "speedometer",
			id = "garage",
			kind = "Fullscreen",
			nav = "SideTabs",
			openKey = "G",
			panelStyle = "Glass",
			size = "Large",
			tabs = {
				{
					icon = "speedometer",
					id = "tab_garage",
					label = "Garage",
					sections = {
						{
							id = "loadout",
							kind = "Loadout",
							slots = {
								{
									icon = "lightning-bolt",
									name = "Neon Coupe",
									rarity = "Rare",
									stats = {
										{ label = "Top Speed", value = 0.67 },
										{ label = "Acceleration", value = 0.6 },
										{ label = "Handling", value = 0.76 },
										{ label = "Nitro", value = 0.79 },
									},
								},
								{
									icon = "two-arrows-spin-clockwise",
									name = "Drift Spec",
									rarity = "Epic",
									stats = {
										{ label = "Top Speed", value = 0.54 },
										{ label = "Acceleration", value = 0.57 },
										{ label = "Handling", value = 0.73 },
										{ label = "Nitro", value = 0.68 },
									},
								},
								{
									icon = "moon",
									name = "Phantom S",
									rarity = "Legendary",
									stats = {
										{ label = "Top Speed", value = 0.77 },
										{ label = "Acceleration", value = 0.98 },
										{ label = "Handling", value = 0.97 },
										{ label = "Nitro", value = 0.68 },
									},
								},
							},
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_all_cars",
					label = "All Cars",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{ icon = "sun", id = "inv_solar_prototype", name = "Solar Prototype", rarity = "Mythic" },
								{ icon = "lightning-bolt", id = "inv_aurora_ev", name = "Aurora EV", rarity = "Legendary" },
								{ icon = "flame", id = "inv_muscle_v8", name = "Muscle V8", rarity = "Rare" },
								{
									equipped = true,
									icon = "lightning-bolt",
									id = "inv_neon_coupe",
									name = "Neon Coupe",
									rarity = "Rare",
								},
								{ icon = "flag", id = "inv_rally_beast", name = "Rally Beast", rarity = "Uncommon" },
								{ icon = "shield-check", id = "inv_titan_truck", name = "Titan Truck", rarity = "Uncommon" },
								{ icon = "circle", id = "inv_kart", name = "Kart", rarity = "Common" },
								{ icon = "speedometer", id = "inv_street_hatch", name = "Street Hatch", rarity = "Common" },
								{
									equipped = true,
									icon = "two-arrows-spin-clockwise",
									id = "locked_drift_spec",
									locked = true,
									name = "Drift Spec",
									rarity = "Epic",
								},
								{
									icon = "double-chevron-large-right",
									id = "locked_hyperion_gt",
									locked = true,
									name = "Hyperion GT",
									rarity = "Epic",
								},
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
				{
					icon = "upgrade",
					id = "tab_tuning",
					label = "Tuning",
					sections = {
						{
							id = "upgrades",
							kind = "Upgrades",
							upgrades = {
								{
									cost = 6750,
									currency = "credits",
									effect = "+3% boost",
									icon = "double-chevron-large-up",
									id = "upgrade_turbo",
									level = 2,
									maxLevel = 10,
									title = "Turbo",
								},
								{
									cost = 12500,
									currency = "credits",
									effect = "+8% grip",
									icon = "circle",
									id = "upgrade_tyres",
									level = 3,
									maxLevel = 10,
									title = "Tyres",
								},
								{
									cost = 27500,
									currency = "credits",
									effect = "-4 kg",
									icon = "arrow-large-down",
									id = "upgrade_weight_reduction",
									level = 3,
									maxLevel = 10,
									title = "Weight Reduction",
								},
								{
									cost = 37500,
									currency = "credits",
									effect = "+8% stopping",
									icon = "stop-large",
									id = "upgrade_brakes",
									level = 3,
									maxLevel = 50,
									title = "Brakes",
								},
							},
						},
					},
				},
				{
					icon = "paint-bucket",
					id = "tab_wraps",
					label = "Wraps",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_2",
							items = {
								{
									equipped = true,
									icon = "nebula",
									id = "inv_galaxy_wrap",
									name = "Galaxy Wrap",
									rarity = "Mythic",
								},
								{ icon = "star", id = "inv_chrome", name = "Chrome", rarity = "Legendary" },
								{
									icon = "lightning-bolt",
									id = "inv_neon_underglow",
									name = "Neon Underglow",
									rarity = "Rare",
								},
								{ icon = "grid", id = "inv_carbon_wrap", name = "Carbon Wrap", rarity = "Uncommon" },
								{
									icon = "diagonal-line-pattern",
									id = "inv_racing_stripes",
									name = "Racing Stripes",
									rarity = "Common",
								},
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
			},
			title = "Garage",
		},
		{
			description = "Everything free in one place: Daily and Gifts. Every tab badges what's ready, and so does the HUD button.",
			icon = "gift",
			id = "rewards",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							days = {
								{ amount = "3,750", day = 1, icon = "coin" },
								{ amount = "5,750", day = 2, icon = "coin" },
								{ amount = "x2", day = 3, icon = "boost", label = "15 min" },
								{ amount = "30", day = 4, icon = "gem" },
								{ amount = "12,500", day = 5, icon = "coin" },
								{ amount = "12,500", day = 6, icon = "coin" },
								{ amount = "x1", day = 7, icon = "crown", label = "Supercar Crate" },
							},
							id = "daily",
							kind = "Daily",
							layout = "Ladder",
							streak = 1,
						},
					},
				},
				{
					icon = "gift",
					id = "tab_gifts",
					label = "Gifts",
					sections = {
						{
							gifts = {
								{
									amount = "1,250",
									grant = { amount = 1250, currency = "credits" },
									icon = "coin",
									id = "gift1",
									minutes = 1,
									rarity = "Common",
								},
								{
									amount = "2,000",
									grant = { amount = 2000, currency = "credits" },
									icon = "coin",
									id = "gift2",
									minutes = 3,
									rarity = "Common",
								},
								{
									amount = "2,750",
									grant = { amount = 2750, currency = "credits" },
									icon = "coin",
									id = "gift3",
									minutes = 5,
									rarity = "Common",
								},
								{
									amount = "30m",
									icon = "cash",
									id = "gift4",
									label = "2x Credits",
									minutes = 10,
									rarity = "Rare",
								},
								{
									amount = "6,250",
									grant = { amount = 6250, currency = "credits" },
									icon = "coin",
									id = "gift5",
									minutes = 15,
									rarity = "Uncommon",
								},
								{
									amount = "8,000",
									grant = { amount = 8000, currency = "credits" },
									icon = "coin",
									id = "gift6",
									minutes = 20,
									rarity = "Rare",
								},
								{
									amount = "10",
									grant = { amount = 10, currency = "gems" },
									icon = "gem",
									id = "gift7",
									minutes = 30,
									rarity = "Rare",
								},
								{
									amount = "50",
									grant = { amount = 50, currency = "gems" },
									icon = "gem",
									id = "gift8",
									minutes = 45,
									rarity = "Legendary",
								},
							},
							id = "gifts",
							kind = "Gifts",
						},
					},
				},
			},
			title = "Free Rewards",
		},
		{
			description = "Track select: each track card shows its unlock requirement until it is earned.",
			icon = "flag",
			id = "tracks",
			kind = "Window",
			nav = "None",
			openKey = "M",
			sections = {
				{
					id = "worlds",
					kind = "Worlds",
					worlds = {
						{
							icon = "flag",
							id = "world_harbor_sprint",
							locked = false,
							name = "Harbor Sprint",
							tone = "Pink",
						},
						{
							icon = "lightning-bolt",
							id = "world_neon_downtown",
							locked = false,
							name = "Neon Downtown",
							tone = "Cyan",
						},
						{
							icon = "sun",
							id = "world_canyon_pass",
							locked = false,
							name = "Canyon Pass",
							tone = "Yellow",
						},
						{
							icon = "cloud",
							id = "world_snowline_rally",
							locked = true,
							name = "Snowline Rally",
							requirement = "Level 30",
							tone = "Pink",
						},
						{
							icon = "moon",
							id = "world_midnight_highway",
							locked = true,
							name = "Midnight Highway",
							requirement = "Level 40",
							tone = "Cyan",
						},
						{
							icon = "flame",
							id = "world_volcano_loop",
							locked = true,
							name = "Volcano Loop",
							requirement = "Level 50",
							tone = "Yellow",
						},
					},
				},
			},
			size = "Large",
			title = "Tracks",
		},
		{
			description = "Where Credits and Robux turn into progress. Tabs: Featured, Cars, Crates, Gems and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			openKey = "B",
			size = "Large",
			tabs = {
				{
					callout = "Refreshes daily",
					icon = "sale",
					id = "tab_featured",
					label = "Featured",
					sections = {
						{
							id = "today_s_picks",
							items = {
								{
									amount = "3,250",
									currency = "robux",
									description = "3,250 Gems + an exclusive title",
									grant = { amount = 3250, currency = "gems" },
									icon = "crown",
									id = "featured_mega",
									price = 999,
									tag = "LIMITED",
									title = "Mega Bundle",
									tone = "Pink",
									was = 1999,
								},
								{
									currency = "gems",
									description = "Leaves the shop soon",
									icon = "flame",
									id = "featured_flame_decal",
									price = 525,
									tag = "FEATURED",
									title = "Flame Decal",
									tone = "Purple",
								},
								{
									amount = "475",
									currency = "robux",
									description = "475 Gems + Flame Decal",
									grant = { amount = 475, currency = "gems" },
									icon = "gift",
									id = "featured_starter",
									price = 149,
									tag = "ONE TIME",
									title = "Newcomer Bundle",
									tone = "Cyan",
									was = 299,
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Today's Picks",
						},
					},
				},
				{
					icon = "speedometer",
					id = "tab_cars",
					label = "Cars",
					sections = {
						{
							id = "products",
							items = {
								{
									amount = "Common",
									currency = "credits",
									icon = "circle",
									id = "buy_kart",
									price = 2500,
									title = "Kart",
									tone = "Gray",
								},
								{
									amount = "Uncommon",
									currency = "credits",
									icon = "flag",
									id = "buy_rally_beast",
									price = 6750,
									title = "Rally Beast",
									tone = "Green",
								},
								{
									amount = "Uncommon",
									currency = "credits",
									icon = "shield-check",
									id = "buy_titan_truck",
									price = 7750,
									title = "Titan Truck",
									tone = "Green",
								},
								{
									amount = "Legendary",
									currency = "credits",
									icon = "lightning-bolt",
									id = "buy_aurora_ev",
									price = 110000,
									title = "Aurora EV",
									tone = "Yellow",
								},
								{
									amount = "Legendary",
									currency = "credits",
									icon = "moon",
									id = "buy_phantom_s",
									price = 120000,
									title = "Phantom S",
									tone = "Yellow",
								},
								{
									amount = "Mythic",
									currency = "credits",
									icon = "sun",
									id = "buy_solar_prototype",
									price = 300000,
									tag = "NEW",
									title = "Solar Prototype",
									tone = "Red",
								},
							},
							kind = "Products",
							layout = "Compact",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_crates",
					label = "Crates",
					sections = {
						{
							cases = {
								{
									currency = "credits",
									icon = "paint-bucket",
									id = "case_wrap_crate",
									pool = {
										{
											chance = 56.4,
											icon = "diagonal-line-pattern",
											name = "Racing Stripes",
											rarity = "Common",
										},
										{ chance = 26, icon = "grid", name = "Carbon Wrap", rarity = "Uncommon" },
										{ chance = 12, icon = "lightning-bolt", name = "Neon Underglow", rarity = "Rare" },
										{ chance = 5.6, icon = "flame", name = "Flame Decal", rarity = "Epic" },
									},
									price = 2750,
									title = "Wrap Crate",
									tone = "Blue",
								},
								{
									currency = "credits",
									icon = "circle",
									id = "case_rim_crate",
									pool = {
										{ chance = 56.5, icon = "grid", name = "Carbon Wrap", rarity = "Uncommon" },
										{ chance = 27, icon = "lightning-bolt", name = "Neon Underglow", rarity = "Rare" },
										{ chance = 12, icon = "flame", name = "Flame Decal", rarity = "Epic" },
										{ chance = 4.5, icon = "star", name = "Chrome", rarity = "Legendary" },
									},
									price = 7000,
									title = "Rim Crate",
									tone = "Yellow",
								},
								{
									currency = "gems",
									icon = "crown",
									id = "case_supercar_crate",
									pool = {
										{ chance = 59.7, icon = "lightning-bolt", name = "Neon Underglow", rarity = "Rare" },
										{ chance = 28, icon = "flame", name = "Flame Decal", rarity = "Epic" },
										{ chance = 10, icon = "star", name = "Chrome", rarity = "Legendary" },
										{ chance = 2.3, icon = "nebula", name = "Galaxy Wrap", rarity = "Mythic" },
									},
									price = 375,
									title = "Supercar Crate",
									tone = "Purple",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "gem",
					id = "tab_gems",
					label = "Gems",
					sections = {
						{
							id = "gem_bundles",
							items = {
								{
									amount = "100",
									currency = "robux",
									grant = { amount = 100, currency = "gems" },
									icon = "gem",
									id = "gems_pack_1",
									price = 49,
									title = "Handful of Gems",
									tone = "Cyan",
								},
								{
									amount = "225",
									bonus = 11,
									currency = "robux",
									grant = { amount = 225, currency = "gems" },
									icon = "gem",
									id = "gems_pack_2",
									price = 99,
									tag = "SALE",
									title = "Pouch of Gems",
									tone = "Cyan",
									was = 149,
								},
								{
									amount = "500",
									bonus = 23,
									currency = "robux",
									grant = { amount = 500, currency = "gems" },
									icon = "gem",
									id = "gems_pack_3",
									price = 199,
									title = "Sack of Gems",
									tone = "Pink",
								},
								{
									amount = "1,250",
									bonus = 54,
									currency = "robux",
									grant = { amount = 1250, currency = "gems" },
									icon = "file-box",
									id = "gems_pack_4",
									price = 399,
									tag = "BEST VALUE",
									title = "Chest of Gems",
									tone = "Pink",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Gem Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Credits",
									icon = "crown",
									id = "pass_vip_garage",
									price = 699,
									tag = "MUST HAVE",
									title = "VIP Garage",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Earn twice the Credits, forever",
									icon = "cash",
									id = "pass_2x_credits",
									price = 399,
									title = "2x Credits",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "Look the part, every match",
									icon = "tag",
									id = "pass_custom_plates",
									price = 99,
									title = "Custom Plates",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "Stand out in every lobby",
									icon = "lightning-bolt",
									id = "pass_neon_underglow",
									price = 99,
									title = "Neon Underglow",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "Play your own songs in game",
									icon = "music",
									id = "pass_radio",
									price = 75,
									title = "Radio",
									tone = "Cyan",
								},
							},
							kind = "Products",
							layout = "Rows",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Pink",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "main_menu",
					label = "Main Menu",
					tone = "Dark",
				},
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Victory",
			rewards = {
				{ amount = "+2,000", icon = "coin" },
				{ amount = "+550 XP", icon = "level" },
				{ amount = "+5", icon = "gem" },
			},
			stats = {
				{ label = "Position", value = "2nd" },
				{ label = "Best Lap", value = "1:31.90" },
				{ label = "Top Speed", value = "223 km/h" },
				{ label = "Drift Score", value = "15,000" },
			},
			title = "Results",
		},
		{
			delay = 5,
			description = "A one-time Welcome Bundle: 4 rewards for 99 Robux (was 299), counting down 48h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "two-arrows-spin-clockwise",
			id = "offer",
			items = {
				{ amount = "300", grant = { amount = 300, currency = "gems" }, icon = "gem", label = "Gems" },
				{
					amount = "150K",
					grant = { amount = 150000, currency = "credits" },
					icon = "coin",
					label = "Credits",
				},
				{ amount = "1", icon = "two-arrows-spin-clockwise", label = "Epic Drift Spec" },
				{ amount = "3", icon = "cash", label = "2x Credits (30m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "LIMITED",
			timer = 172800,
			title = "Welcome Bundle",
			tone = "Yellow",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = "km/h",
									icon = "speedometer",
									id = "speedUnits",
									label = "Speed Units",
									options = { "km/h", "mph" },
									type = "Choice",
								},
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Accelerate", gamepad = "ButtonR2", key = "W" },
								{ action = "Brake", gamepad = "ButtonL2", key = "S" },
								{ action = "Steer Left", gamepad = "DPadLeft", key = "A" },
								{ action = "Steer Right", gamepad = "DPadRight", key = "D" },
								{ action = "Nitro", gamepad = "ButtonX", key = "LeftShift" },
								{ action = "Handbrake", gamepad = "ButtonA", key = "Space" },
								{ action = "Respawn", gamepad = "ButtonY", key = "R" },
								{ action = "Camera", gamepad = "ButtonR3", key = "C" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "loading",
	style = {
		archetype = "Sleek",
		buttonStyle = "Outline",
		clickFx = "Squash",
		corner = "Sharp",
		decoration = "None",
		density = "Compact",
		fonts = { body = "TitilliumWeb:SemiBold", display = "Michroma", title = "Michroma" },
		iconStyle = "Filled",
		palette = {
			accent = "#F9ED69",
			bad = "#FF5A5F",
			good = "#3DDC97",
			primary = "#FF2E63",
			secondary = "#08D9D6",
			surface = "#1A1423",
			text = "#F7F7FF",
			warn = "#F9C74F",
		},
		panelStyle = "Solid",
		rarityScheme = "Arena",
		shadow = "Soft",
		textCase = "Normal",
		transition = "Fade",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="61">
              <Properties>
                <string name="Name">racing_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: racing, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#5BE36B",
			format = "abbrev",
			icon = "cash",
			id = "cash",
			name = "Cash",
			source = { kind = "leaderstat", name = "Cash" },
			start = 100000,
		},
		{
			color = "#FF6A3D",
			format = "comma",
			icon = "flag",
			id = "tokens",
			name = "Race Tokens",
			source = { kind = "attribute", name = "RaceTokens" },
			start = 45,
		},
	},
	hud = {
		elements = {
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopRight",
				currencies = { "cash", "tokens" },
				id = "hud_currencybar",
				look = "Tiles",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Quarter mile",
				seconds = 0,
				type = "Timer",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_objective",
				text = "Lap 2 / 3  ·  P1",
				type = "Objective",
				visible = true,
			},
			{ anchor = "TopLeft", id = "hud_minimap", shape = "Square", type = "Minimap", visible = true },
			{
				anchor = "BottomCenter",
				boosts = { { icon = "lightning-bolt", seconds = 8, text = "Nitro" } },
				id = "hud_boosts",
				type = "Boosts",
				visible = true,
			},
			{
				action = { kind = "Open", target = "menu" },
				anchor = "TopLeft",
				icon = "menu",
				id = "hud_button",
				label = "Menu",
				tone = "Dark",
				type = "Button",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonX", key = "LeftShift", text = "Nitro" },
					{ gamepad = "ButtonA", key = "Space", text = "Handbrake" },
					{ gamepad = "ButtonY", key = "R", text = "Respawn" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Nitro" },
						icon = "lightning-bolt",
						id = "touch_nitro",
						label = "Nitro",
					},
					{
						action = { kind = "Event", name = "Drift" },
						icon = "two-arrows-spin-clockwise",
						id = "touch_drift",
						label = "Drift",
					},
					{
						action = { kind = "Event", name = "Respawn" },
						icon = "arrow-spin-clockwise",
						id = "touch_respawn",
						label = "Respawn",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				anchor = "TopCenter",
				goal = 3,
				id = "hud_teamscore",
				teams = {
					{ color = "#3AA8FF", name = "You", score = 2 },
					{ color = "#FF4B4B", name = "Rival", score = 2 },
				},
				type = "TeamScore",
				visible = true,
			},
			{
				anchor = "Right",
				icon = "speedometer",
				id = "hud_meter",
				label = "RPM",
				segments = 5,
				style = "Dial",
				tone = "Orange",
				type = "Meter",
				value = 1,
				visible = true,
			},
			{
				anchor = "BottomLeft",
				id = "hud_counter",
				label = "MPH",
				style = "Digital",
				type = "Counter",
				value = 158,
				visible = true,
			},
			{
				anchor = "Right",
				buttons = {
					{
						action = { kind = "Toggle", target = "tuning" },
						icon = "speedometer",
						id = "btn_tuning",
						label = "Tuning",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "rewards" },
						icon = "gift",
						id = "btn_rewards",
						label = "Free Rewards",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "leaderboard" },
						icon = "trophy",
						id = "btn_leaderboard",
						label = "Rankings",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
		},
		kit = "Neon",
		layout = "Minimal",
	},
	logo = { icon = "speedometer", text = "LAUNCH STRIP", tilt = -4, tone = "Red", type = "Wordmark" },
	meta = {
		flavor = "drag",
		genre = "racing",
		kit = "Neon",
		prompt = "a racing game",
		seed = 7,
		source = "offline",
		title = "Launch Strip",
	},
	screens = {
		{
			description = "The title screen: the Launch Strip logo, PLAY, Party, My Garage, Tracks, Shop and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "PLAY",
							tone = "Red",
						},
						{
							action = { kind = "Open", target = "party" },
							icon = "friends",
							id = "menu_party",
							label = "Party",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "garage" },
							icon = "speedometer",
							id = "menu_garage",
							label = "My Garage",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "tracks" },
							icon = "flag",
							id = "menu_tracks",
							label = "Tracks",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "two-arrows-spin-clockwise",
					id = "starter_pack",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 86400,
					title = "Starter Pack",
					tone = "Yellow",
				},
			},
			title = "Main Menu",
		},
		{
			description = "The garage is the lobby: favourite cars on stat cards, the whole collection a tab away, and tuning to squeeze out more speed.",
			icon = "speedometer",
			id = "garage",
			kind = "Fullscreen",
			nav = "Tabs",
			openKey = "G",
			size = "Large",
			tabs = {
				{
					icon = "speedometer",
					id = "tab_garage",
					label = "Garage",
					sections = {
						{
							id = "loadout",
							kind = "Loadout",
							slots = {
								{
									icon = "lightning-bolt",
									name = "Neon Coupe",
									rarity = "Rare",
									stats = {
										{ label = "Top Speed", value = 0.5 },
										{ label = "Acceleration", value = 0.85 },
										{ label = "Handling", value = 0.7 },
										{ label = "Nitro", value = 0.48 },
									},
								},
								{
									icon = "two-arrows-spin-clockwise",
									name = "Drift Spec",
									rarity = "Epic",
									stats = {
										{ label = "Top Speed", value = 0.91 },
										{ label = "Acceleration", value = 0.61 },
										{ label = "Handling", value = 0.78 },
										{ label = "Nitro", value = 0.89 },
									},
								},
								{
									icon = "moon",
									name = "Phantom S",
									rarity = "Legendary",
									stats = {
										{ label = "Top Speed", value = 0.73 },
										{ label = "Acceleration", value = 0.77 },
										{ label = "Handling", value = 0.68 },
										{ label = "Nitro", value = 0.98 },
									},
								},
							},
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_all_cars",
					label = "All Cars",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{ icon = "sun", id = "inv_solar_prototype", name = "Solar Prototype", rarity = "Mythic" },
								{ icon = "lightning-bolt", id = "inv_aurora_ev", name = "Aurora EV", rarity = "Legendary" },
								{
									equipped = true,
									icon = "moon",
									id = "inv_phantom_s",
									name = "Phantom S",
									rarity = "Legendary",
								},
								{
									equipped = true,
									icon = "two-arrows-spin-clockwise",
									id = "inv_drift_spec",
									name = "Drift Spec",
									rarity = "Epic",
								},
								{
									icon = "double-chevron-large-right",
									id = "inv_hyperion_gt",
									name = "Hyperion GT",
									rarity = "Epic",
								},
								{ icon = "flame", id = "inv_muscle_v8", name = "Muscle V8", rarity = "Rare" },
								{
									equipped = true,
									icon = "lightning-bolt",
									id = "inv_neon_coupe",
									name = "Neon Coupe",
									rarity = "Rare",
								},
								{ icon = "flag", id = "inv_rally_beast", name = "Rally Beast", rarity = "Uncommon" },
								{ icon = "shield-check", id = "inv_titan_truck", name = "Titan Truck", rarity = "Uncommon" },
								{ icon = "circle", id = "inv_kart", name = "Kart", rarity = "Common" },
								{ icon = "speedometer", id = "inv_street_hatch", name = "Street Hatch", rarity = "Common" },
							},
							kind = "Inventory",
						},
					},
				},
				{
					icon = "paint-bucket",
					id = "tab_wraps",
					label = "Wraps",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_2",
							items = {
								{
									equipped = true,
									icon = "nebula",
									id = "inv_galaxy_wrap",
									name = "Galaxy Wrap",
									rarity = "Mythic",
								},
								{ icon = "flame", id = "inv_flame_decal", name = "Flame Decal", rarity = "Epic" },
								{
									icon = "lightning-bolt",
									id = "inv_neon_underglow",
									name = "Neon Underglow",
									rarity = "Rare",
								},
								{ icon = "grid", id = "inv_carbon_wrap", name = "Carbon Wrap", rarity = "Uncommon" },
								{
									icon = "diagonal-line-pattern",
									id = "inv_racing_stripes",
									name = "Racing Stripes",
									rarity = "Common",
								},
							},
							kind = "Inventory",
						},
					},
				},
			},
			title = "My Garage",
		},
		{
			description = "Invite friends into your party and pick a mode together; the party leader's pick drives matchmaking.",
			icon = "friends",
			id = "party",
			kind = "Window",
			nav = "None",
			openKey = "P",
			sections = {
				{ id = "party_2", kind = "Party", slots = 4 },
				{
					buttons = {
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "trophy",
							id = "mode_ranked",
							label = "Ranked",
							tone = "Red",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "gear",
							id = "mode_custom",
							label = "Custom",
							tone = "Dark",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "play",
							id = "mode_quick_match",
							label = "Quick Match",
							tone = "Dark",
						},
					},
					id = "modes",
					kind = "Buttons",
					layout = "Grid",
					title = "Modes",
				},
			},
			size = "Medium",
			title = "Party",
		},
		{
			description = "Track select: each track card shows its unlock requirement until it is earned.",
			icon = "flag",
			id = "tracks",
			kind = "Window",
			nav = "None",
			openKey = "M",
			sections = {
				{
					id = "worlds",
					kind = "Worlds",
					worlds = {
						{
							icon = "flag",
							id = "world_harbor_sprint",
							locked = false,
							name = "Harbor Sprint",
							tone = "Red",
						},
						{
							icon = "lightning-bolt",
							id = "world_neon_downtown",
							locked = false,
							name = "Neon Downtown",
							tone = "Blue",
						},
						{
							icon = "sun",
							id = "world_canyon_pass",
							locked = true,
							name = "Canyon Pass",
							requirement = "Level 20",
							tone = "Yellow",
						},
						{
							icon = "cloud",
							id = "world_snowline_rally",
							locked = true,
							name = "Snowline Rally",
							requirement = "Level 30",
							tone = "Red",
						},
						{
							icon = "moon",
							id = "world_midnight_highway",
							locked = true,
							name = "Midnight Highway",
							requirement = "Level 40",
							tone = "Blue",
						},
						{
							icon = "flame",
							id = "world_volcano_loop",
							locked = true,
							name = "Volcano Loop",
							requirement = "Level 50",
							tone = "Yellow",
						},
					},
				},
			},
			size = "Large",
			title = "Tracks",
		},
		{
			description = "Engine, turbo, tyres and nitro upgrades for the selected car; each level is a visible bump on the stat bars.",
			icon = "speedometer",
			id = "tuning",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "upgrades",
					kind = "Upgrades",
					upgrades = {
						{
							cost = 82500,
							currency = "cash",
							effect = "+80 hp",
							icon = "flame",
							id = "upgrade_engine",
							level = 7,
							maxLevel = 25,
							title = "Engine",
						},
						{
							cost = 3500,
							currency = "cash",
							effect = "+2% boost",
							icon = "double-chevron-large-up",
							id = "upgrade_turbo",
							level = 0,
							maxLevel = 20,
							title = "Turbo",
						},
						{
							cost = 9500,
							currency = "cash",
							effect = "-4 kg",
							icon = "arrow-large-down",
							id = "upgrade_weight_reduction",
							level = 1,
							maxLevel = 10,
							title = "Weight Reduction",
						},
						{
							cost = 42500,
							currency = "cash",
							effect = "+4s nitro",
							icon = "lightning-bolt",
							id = "upgrade_nitro_tank",
							level = 3,
							maxLevel = 10,
							title = "Nitro Tank",
						},
					},
				},
			},
			size = "Medium",
			title = "Tuning",
		},
		{
			description = "Everything free in one place: Daily and Gifts. Every tab badges what's ready, and so does the HUD button.",
			icon = "gift",
			id = "rewards",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							days = {
								{ amount = "3,750", day = 1, icon = "cash" },
								{ amount = "5,750", day = 2, icon = "cash" },
								{ amount = "40", day = 3, icon = "flag" },
								{ amount = "9,500", day = 4, icon = "cash" },
								{ amount = "x2", day = 5, icon = "boost" },
								{ amount = "12,500", day = 6, icon = "cash" },
								{ amount = "100", day = 7, icon = "flag" },
								{ amount = "6,000", day = 8, icon = "cash" },
								{ amount = "9,000", day = 9, icon = "cash" },
								{ amount = "65", day = 10, icon = "flag" },
								{ amount = "15,000", day = 11, icon = "cash" },
								{ amount = "x2", day = 12, icon = "boost" },
								{ amount = "20,000", day = 13, icon = "cash" },
								{ amount = "150", day = 14, icon = "flag" },
								{ amount = "8,250", day = 15, icon = "cash" },
								{ amount = "12,500", day = 16, icon = "cash" },
								{ amount = "90", day = 17, icon = "flag" },
								{ amount = "20,000", day = 18, icon = "cash" },
								{ amount = "x3", day = 19, icon = "boost" },
								{ amount = "30,000", day = 20, icon = "cash" },
								{ amount = "225", day = 21, icon = "flag" },
								{ amount = "10,000", day = 22, icon = "cash" },
								{ amount = "15,000", day = 23, icon = "cash" },
								{ amount = "100", day = 24, icon = "flag" },
								{ amount = "27,500", day = 25, icon = "cash" },
								{ amount = "x3", day = 26, icon = "boost" },
								{ amount = "37,500", day = 27, icon = "cash" },
								{ amount = "x1", day = 28, icon = "crown", label = "Supercar Crate" },
							},
							id = "daily",
							kind = "Daily",
							layout = "Calendar",
							streak = 7,
						},
					},
				},
				{
					icon = "gift",
					id = "tab_gifts",
					label = "Gifts",
					sections = {
						{
							gifts = {
								{
									amount = "1,750",
									grant = { amount = 1750, currency = "cash" },
									icon = "cash",
									id = "gift1",
									minutes = 1,
									rarity = "Common",
								},
								{
									amount = "2,500",
									grant = { amount = 2500, currency = "cash" },
									icon = "cash",
									id = "gift2",
									minutes = 3,
									rarity = "Common",
								},
								{
									amount = "3,500",
									grant = { amount = 3500, currency = "cash" },
									icon = "cash",
									id = "gift3",
									minutes = 5,
									rarity = "Common",
								},
								{
									amount = "5,750",
									grant = { amount = 5750, currency = "cash" },
									icon = "cash",
									id = "gift4",
									minutes = 10,
									rarity = "Uncommon",
								},
								{
									amount = "7,750",
									grant = { amount = 7750, currency = "cash" },
									icon = "cash",
									id = "gift5",
									minutes = 15,
									rarity = "Uncommon",
								},
								{
									amount = "30m",
									icon = "cash",
									id = "gift6",
									label = "2x Cash",
									minutes = 20,
									rarity = "Rare",
								},
								{
									amount = "10",
									grant = { amount = 10, currency = "tokens" },
									icon = "flag",
									id = "gift7",
									minutes = 30,
									rarity = "Rare",
								},
								{
									amount = "20,000",
									grant = { amount = 20000, currency = "cash" },
									icon = "cash",
									id = "gift8",
									minutes = 45,
									rarity = "Rare",
								},
								{
									amount = "20",
									grant = { amount = 20, currency = "tokens" },
									icon = "flag",
									id = "gift9",
									minutes = 60,
									rarity = "Epic",
								},
								{
									amount = "35,000",
									grant = { amount = 35000, currency = "cash" },
									icon = "cash",
									id = "gift10",
									minutes = 75,
									rarity = "Rare",
								},
								{
									amount = "35",
									grant = { amount = 35, currency = "tokens" },
									icon = "flag",
									id = "gift11",
									minutes = 90,
									rarity = "Epic",
								},
								{
									amount = "x1",
									icon = "two-arrows-spin-clockwise",
									id = "gift12",
									label = "Drift Spec",
									minutes = 120,
									rarity = "Epic",
								},
							},
							id = "gifts",
							kind = "Gifts",
						},
					},
				},
			},
			title = "Free Rewards",
		},
		{
			description = "Where Cash and Robux turn into progress. Tabs: Featured, Cars, Crates, Race Tokens and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					callout = "New today!",
					icon = "sale",
					id = "tab_featured",
					label = "Featured",
					sections = {
						{
							id = "today_s_picks",
							items = {
								{
									amount = "6,000",
									currency = "robux",
									description = "6,000 Race Tokens + an exclusive title",
									grant = { amount = 6000, currency = "tokens" },
									icon = "crown",
									id = "featured_mega",
									price = 1499,
									tag = "LIMITED",
									title = "Mega Bundle",
									tone = "Red",
									was = 3999,
								},
								{
									currency = "tokens",
									description = "This week only",
									icon = "star",
									id = "featured_chrome",
									price = 950,
									tag = "FEATURED",
									title = "Chrome",
									tone = "Yellow",
								},
								{
									amount = "800",
									currency = "robux",
									description = "800 Race Tokens + Chrome",
									grant = { amount = 800, currency = "tokens" },
									icon = "gift",
									id = "featured_starter",
									price = 199,
									tag = "ONE TIME",
									title = "Newcomer Bundle",
									tone = "Blue",
									was = 399,
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Today's Picks",
						},
					},
				},
				{
					icon = "speedometer",
					id = "tab_cars",
					label = "Cars",
					sections = {
						{
							id = "products",
							items = {
								{
									amount = "Common",
									currency = "cash",
									icon = "speedometer",
									id = "buy_street_hatch",
									price = 2500,
									title = "Street Hatch",
									tone = "Gray",
								},
								{
									amount = "Uncommon",
									currency = "cash",
									icon = "flag",
									id = "buy_rally_beast",
									price = 5750,
									title = "Rally Beast",
									tone = "Green",
								},
								{
									amount = "Rare",
									currency = "cash",
									icon = "flame",
									id = "buy_muscle_v8",
									price = 17500,
									title = "Muscle V8",
									tone = "Blue",
								},
								{
									amount = "Rare",
									currency = "cash",
									icon = "lightning-bolt",
									id = "buy_neon_coupe",
									price = 20000,
									title = "Neon Coupe",
									tone = "Blue",
								},
								{
									amount = "Legendary",
									currency = "cash",
									icon = "lightning-bolt",
									id = "buy_aurora_ev",
									price = 130000,
									title = "Aurora EV",
									tone = "Yellow",
								},
								{
									amount = "Mythic",
									currency = "cash",
									icon = "sun",
									id = "buy_solar_prototype",
									price = 340000,
									tag = "LIMITED",
									title = "Solar Prototype",
									tone = "Red",
								},
							},
							kind = "Products",
							layout = "Cards",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_crates",
					label = "Crates",
					sections = {
						{
							cases = {
								{
									currency = "cash",
									icon = "paint-bucket",
									id = "case_wrap_crate",
									pool = {
										{
											chance = 56.4,
											icon = "diagonal-line-pattern",
											name = "Racing Stripes",
											rarity = "Common",
										},
										{ chance = 26, icon = "grid", name = "Carbon Wrap", rarity = "Uncommon" },
										{ chance = 12, icon = "lightning-bolt", name = "Neon Underglow", rarity = "Rare" },
										{ chance = 5.6, icon = "flame", name = "Flame Decal", rarity = "Epic" },
									},
									price = 2250,
									title = "Wrap Crate",
									tone = "Blue",
								},
								{
									currency = "cash",
									icon = "circle",
									id = "case_rim_crate",
									pool = {
										{ chance = 56.5, icon = "grid", name = "Carbon Wrap", rarity = "Uncommon" },
										{ chance = 27, icon = "lightning-bolt", name = "Neon Underglow", rarity = "Rare" },
										{ chance = 12, icon = "flame", name = "Flame Decal", rarity = "Epic" },
										{ chance = 4.5, icon = "star", name = "Chrome", rarity = "Legendary" },
									},
									price = 8500,
									title = "Rim Crate",
									tone = "Yellow",
								},
								{
									currency = "cash",
									icon = "crown",
									id = "case_supercar_crate",
									pool = {
										{ chance = 59.7, icon = "lightning-bolt", name = "Neon Underglow", rarity = "Rare" },
										{ chance = 28, icon = "flame", name = "Flame Decal", rarity = "Epic" },
										{ chance = 10, icon = "star", name = "Chrome", rarity = "Legendary" },
										{ chance = 2.3, icon = "nebula", name = "Galaxy Wrap", rarity = "Mythic" },
									},
									price = 25000,
									title = "Supercar Crate",
									tone = "Purple",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "flag",
					id = "tab_race_tokens",
					label = "Race Tokens",
					sections = {
						{
							id = "race_token_bundles",
							items = {
								{
									amount = "125",
									currency = "robux",
									grant = { amount = 125, currency = "tokens" },
									icon = "flag",
									id = "tokens_pack_1",
									price = 49,
									title = "Starter Race Tokens",
									tone = "Blue",
								},
								{
									amount = "300",
									bonus = 19,
									currency = "robux",
									grant = { amount = 300, currency = "tokens" },
									icon = "flag",
									id = "tokens_pack_2",
									price = 99,
									title = "Small Race Token Pack",
									tone = "Blue",
								},
								{
									amount = "625",
									bonus = 23,
									currency = "robux",
									grant = { amount = 625, currency = "tokens" },
									icon = "flag",
									id = "tokens_pack_3",
									price = 199,
									tag = "POPULAR",
									title = "Big Race Token Pack",
									tone = "Red",
								},
								{
									amount = "1,500",
									bonus = 47,
									currency = "robux",
									grant = { amount = 1500, currency = "tokens" },
									icon = "file-box",
									id = "tokens_pack_4",
									price = 399,
									title = "Mega Race Token Pack",
									tone = "Red",
								},
								{
									amount = "3,250",
									bonus = 59,
									currency = "robux",
									grant = { amount = 3250, currency = "tokens" },
									icon = "building-bank",
									id = "tokens_pack_5",
									price = 799,
									title = "Giga Race Token Pack",
									tone = "Yellow",
								},
								{
									amount = "6,750",
									bonus = 77,
									currency = "robux",
									grant = { amount = 6750, currency = "tokens" },
									icon = "crown",
									id = "tokens_pack_6",
									price = 1499,
									tag = "BEST VALUE",
									title = "Ultra Race Token Pack",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Race Token Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Cash",
									icon = "crown",
									id = "pass_vip_garage",
									price = 499,
									tag = "POPULAR",
									title = "VIP Garage",
									tone = "Red",
								},
								{
									currency = "robux",
									description = "Earn twice the Cash, forever",
									icon = "cash",
									id = "pass_2x_cash",
									price = 399,
									title = "2x Cash",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "Stand out in every lobby",
									icon = "tag",
									id = "pass_custom_plates",
									price = 99,
									title = "Custom Plates",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "Look the part, every match",
									icon = "lightning-bolt",
									id = "pass_neon_underglow",
									price = 75,
									title = "Neon Underglow",
									tone = "Blue",
								},
								{
									currency = "robux",
									description = "Play your own songs in game",
									icon = "music",
									id = "pass_radio",
									price = 75,
									title = "Radio",
									tone = "Blue",
								},
							},
							kind = "Products",
							layout = "Rows",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Global top ten for Wins, Drift Score and Best Lap, with podium colours for the top three.",
			icon = "trophy",
			id = "leaderboard",
			kind = "Window",
			nav = "Tabs",
			size = "Medium",
			tabs = {
				{
					icon = "trophy",
					id = "tab_wins",
					label = "Wins",
					sections = {
						{
							id = "leaderboard_2",
							kind = "Leaderboard",
							rows = {
								{ name = "Mango101", rank = 1, value = "2,197" },
								{ name = "Turbo2014", rank = 2, value = "1,941" },
								{ name = "ItsDoodleGamer", rank = 3, value = "1,791" },
								{ name = "NotLunaNinja", rank = 4, value = "1,397" },
								{ name = "xXSunnyAceXx", rank = 5, value = "1,234" },
								{ name = "CrimsonChefYT", rank = 6, value = "976" },
								{ name = "ShadowSprout88", rank = 7, value = "924" },
								{ name = "WobblyQueen", rank = 8, value = "700" },
								{ name = "Pixel21", rank = 9, value = "633" },
								{ name = "BlitzMoose_21", rank = 10, value = "525" },
							},
							stat = "Wins",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_drift_score",
					label = "Drift Score",
					sections = {
						{
							id = "leaderboard_3",
							kind = "Leaderboard",
							rows = {
								{ name = "ZapKingYT", rank = 1, value = "340M" },
								{ name = "Wobbly_Gamer", rank = 2, value = "260M" },
								{ name = "NoodleKing", rank = 3, value = "221M" },
								{ name = "BoltHunter", rank = 4, value = "186M" },
								{ name = "GhostLlamaYT", rank = 5, value = "134M" },
								{ name = "LunaSproutYT", rank = 6, value = "107M" },
								{ name = "ItsBubblePup", rank = 7, value = "80M" },
								{ name = "NovaWolfYT", rank = 8, value = "75.1M" },
								{ name = "NotNoodleCat", rank = 9, value = "69.5M" },
								{ name = "RealSkySpark", rank = 10, value = "64.1M" },
							},
							stat = "Drift Score",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_best_lap",
					label = "Best Lap",
					sections = {
						{
							id = "leaderboard_4",
							kind = "Leaderboard",
							rows = {
								{ name = "CometWizard_21", rank = 1, value = "1:24.93" },
								{ name = "Wobbly_Ace", rank = 2, value = "1:25.57" },
								{ name = "NotGhostHunter", rank = 3, value = "1:27.67" },
								{ name = "MangoDragon21", rank = 4, value = "1:33.96" },
								{ name = "xXWobblyWolfXx", rank = 5, value = "1:38.07" },
								{ name = "JellyChef", rank = 6, value = "1:44.99" },
								{ name = "RealMangoKnight", rank = 7, value = "1:57.35" },
								{ name = "Mochi99", rank = 8, value = "2:11.16" },
								{ name = "ItsLunaPanda", rank = 9, value = "2:24.31" },
								{ name = "SunnyPanda777", rank = 10, value = "2:34.34" },
							},
							stat = "Best Lap",
						},
					},
				},
			},
			title = "Rankings",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Red",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "main_menu",
					label = "Main Menu",
					tone = "Dark",
				},
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Defeat",
			rewards = { { amount = "+2,750", icon = "cash" }, { amount = "+200 XP", icon = "level" } },
			stats = {
				{ label = "Position", value = "2nd" },
				{ label = "Best Lap", value = "1:12.08" },
				{ label = "Top Speed", value = "299 km/h" },
				{ label = "Drift Score", value = "20,000" },
			},
			title = "Results",
		},
		{
			delay = 5,
			description = "A one-time Starter Pack: 4 rewards for 99 Robux (was 299), counting down 24h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "two-arrows-spin-clockwise",
			id = "offer",
			items = {
				{
					amount = "375",
					grant = { amount = 375, currency = "tokens" },
					icon = "flag",
					label = "Race Tokens",
				},
				{
					amount = "100K",
					grant = { amount = 100000, currency = "cash" },
					icon = "cash",
					label = "Cash",
				},
				{ amount = "1", icon = "two-arrows-spin-clockwise", label = "Epic Drift Spec" },
				{ amount = "3", icon = "cash", label = "2x Cash (30m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "STARTER PACK",
			timer = 86400,
			title = "Starter Pack",
			tone = "Yellow",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = "km/h",
									icon = "speedometer",
									id = "speedUnits",
									label = "Speed Units",
									options = { "km/h", "mph" },
									type = "Choice",
								},
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Accelerate", gamepad = "ButtonR2", key = "W" },
								{ action = "Brake", gamepad = "ButtonL2", key = "S" },
								{ action = "Steer Left", gamepad = "DPadLeft", key = "A" },
								{ action = "Steer Right", gamepad = "DPadRight", key = "D" },
								{ action = "Nitro", gamepad = "ButtonX", key = "LeftShift" },
								{ action = "Handbrake", gamepad = "ButtonA", key = "Space" },
								{ action = "Respawn", gamepad = "ButtonY", key = "R" },
								{ action = "Camera", gamepad = "ButtonR3", key = "C" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Arena",
		buttonStyle = "Outline",
		clickFx = "Glow",
		corner = "Sharp",
		decoration = "Stripes",
		density = "Compact",
		fonts = { body = "BuilderSans:SemiBold", display = "HighwayGothic", title = "HighwayGothic" },
		iconStyle = "Filled",
		palette = {
			accent = "#FFD400",
			bad = "#FF6A3D",
			good = "#2EE59D",
			primary = "#FF2D2D",
			secondary = "#3D5AFE",
			surface = "#111418",
			text = "#F7F7F7",
			warn = "#FFD400",
		},
		panelStyle = "Glass",
		rarityScheme = "Arena",
		shadow = "Soft",
		textCase = "Normal",
		transition = "Zoom",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="62">
              <Properties>
                <string name="Name">roleplay_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: roleplay, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD21F",
			format = "abbrev",
			icon = "coin",
			id = "coins",
			name = "Coins",
			source = { kind = "leaderstat", name = "Coins" },
			start = 12500,
		},
		{
			color = "#FF8FD0",
			format = "comma",
			icon = "gem",
			id = "gems",
			name = "Gems",
			source = { kind = "attribute", name = "Gems" },
			start = 200,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				id = "hud_objective",
				text = "Hand in your homework",
				type = "Objective",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Next class",
				seconds = 420,
				type = "Timer",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopLeft",
				currencies = { "coins", "gems" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Toggle", target = "wardrobe" },
						icon = "skins",
						id = "btn_wardrobe",
						label = "Style",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Shop",
						tone = "Green",
					},
					{
						action = { kind = "Toggle", target = "rewards" },
						icon = "gift",
						id = "btn_rewards",
						label = "Free Rewards",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "homes" },
						icon = "house",
						id = "btn_homes",
						label = "Houses",
						tone = "Pink",
					},
					{
						action = { kind = "Toggle", target = "jobs" },
						icon = "tasks",
						id = "btn_jobs",
						label = "Jobs",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "emotes" },
						icon = "face-winking",
						id = "btn_emotes",
						label = "Emotes",
						tone = "Pink",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "White",
					},
					{
						action = { kind = "Toggle", target = "trade" },
						icon = "two-arrows-left-right",
						id = "btn_trade",
						label = "Trade",
						tone = "White",
					},
				},
				id = "hud_menubuttons",
				style = "Grid",
				type = "MenuButtons",
				visible = true,
			},
			{
				anchor = "Right",
				id = "hud_questtracker",
				look = "Checklist",
				tasks = {
					{
						goal = 4,
						icon = "clipboard-pencil",
						progress = 4,
						reward = { amount = "1,250", icon = "coin" },
						text = "Work 4 shifts",
					},
					{
						goal = 4,
						icon = "two-people",
						progress = 3,
						reward = { amount = "500", icon = "coin" },
						text = "Visit 4 friends' houses",
					},
				},
				title = "Quests",
				type = "QuestTracker",
				visible = true,
			},
			{
				anchor = "Center",
				emotes = {
					{ icon = "megaphone-cheer", id = "emote_cheer", label = "Cheer" },
					{ icon = "flame", id = "emote_taunt", label = "Taunt" },
					{ icon = "music-note", id = "emote_dance", label = "Dance" },
					{ icon = "face-winking", id = "emote_laugh", label = "Laugh" },
					{ icon = "square-face-waving-hand", id = "emote_wave", label = "Wave" },
					{ icon = "crown", id = "emote_crown", label = "Crown" },
				},
				id = "hud_emotewheel",
				key = "G",
				type = "EmoteWheel",
				visible = true,
			},
			{
				anchor = "TopLeft",
				goal = 6,
				icon = "book-open",
				id = "hud_counter",
				label = "PERIOD",
				style = "Plaque",
				type = "Counter",
				value = 3,
				visible = true,
			},
			{
				anchor = "BottomLeft",
				icon = "star",
				id = "hud_meter",
				label = "GRADE",
				low = 0,
				readout = "A-",
				style = "Bar",
				tone = "Green",
				type = "Meter",
				value = 0.62,
				visible = true,
			},
		},
		kit = "Chunky Toy",
		layout = "Classic",
	},
	logo = {
		icon = "heart",
		subtitle = "SCHOOL LIFE",
		text = "Maple High",
		tilt = 5,
		tone = "Green",
		type = "Badge",
	},
	meta = {
		flavor = "school",
		genre = "roleplay",
		kit = "Chunky Toy",
		prompt = "a roleplay game",
		seed = 21,
		source = "offline",
		title = "Maple High",
	},
	screens = {
		{
			description = "Dress-up: tabs per body slot, items previewed on the avatar live.",
			icon = "skins",
			id = "wardrobe",
			kind = "Fullscreen",
			nav = "Tabs",
			panelStyle = "Outlined",
			size = "Large",
			tabs = {
				{
					icon = "hat-toque",
					id = "tab_hats",
					label = "Hats",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{ equipped = true, icon = "crown", id = "inv_tiara", name = "Tiara", rarity = "Epic" },
								{ icon = "hat-toque", id = "inv_party_hat", name = "Party Hat", rarity = "Rare" },
								{ icon = "hat-fedora", id = "inv_fedora", name = "Fedora", rarity = "Uncommon" },
								{ icon = "hat-toque", id = "inv_beanie", name = "Beanie", rarity = "Common" },
								{ icon = "glasses", id = "inv_sunglasses", name = "Sunglasses", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
				{
					icon = "tshirt",
					id = "tab_tops",
					label = "Tops",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_2",
							items = {
								{
									equipped = true,
									icon = "dress",
									id = "inv_ball_gown",
									name = "Ball Gown",
									rarity = "Legendary",
								},
								{ icon = "dress", id = "inv_sundress", name = "Sundress", rarity = "Rare" },
								{ icon = "jacket", id = "inv_denim_jacket", name = "Denim Jacket", rarity = "Uncommon" },
								{ icon = "sweater", id = "inv_knit_sweater", name = "Knit Sweater", rarity = "Uncommon" },
								{ icon = "tshirt", id = "inv_graphic_tee", name = "Graphic Tee", rarity = "Common" },
								{ icon = "sweater", id = "inv_hoodie", name = "Hoodie", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
				{
					icon = "pants",
					id = "tab_bottoms",
					label = "Bottoms",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_3",
							items = {
								{ equipped = true, icon = "shoe", id = "inv_boots", name = "Boots", rarity = "Rare" },
								{ icon = "shoe", id = "inv_sneakers", name = "Sneakers", rarity = "Uncommon" },
								{ icon = "shorts", id = "inv_cargo_shorts", name = "Cargo Shorts", rarity = "Common" },
								{ icon = "pants", id = "inv_jeans", name = "Jeans", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
				{
					icon = "necklace",
					id = "tab_extras",
					label = "Extras",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_4",
							items = {
								{
									equipped = true,
									icon = "diamond",
									id = "inv_diamond_ring",
									name = "Diamond Ring",
									rarity = "Legendary",
								},
								{ icon = "necklace", id = "inv_necklace", name = "Necklace", rarity = "Rare" },
								{ icon = "purse", id = "inv_handbag", name = "Handbag", rarity = "Uncommon" },
								{ icon = "lipstick", id = "inv_lipstick", name = "Lipstick", rarity = "Uncommon" },
								{ icon = "bow-tie", id = "inv_bow_tie", name = "Bow Tie", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
			},
			title = "Style",
		},
		{
			description = "Where Coins and Robux turn into progress. Tabs: Passes, Gems and Items.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Coins",
									icon = "crown",
									id = "pass_vip",
									price = 699,
									tag = "MUST HAVE",
									title = "VIP",
									tone = "Green",
								},
								{
									currency = "robux",
									description = "A bigger, better base to show off",
									icon = "house",
									id = "pass_premium_house_pack",
									price = 399,
									title = "Premium House Pack",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "Stand out in every lobby",
									icon = "tshirt",
									id = "pass_fashion_pass",
									price = 199,
									title = "Fashion Pass",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Play your own songs in game",
									icon = "music",
									id = "pass_radio",
									price = 99,
									title = "Radio",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Move twice as fast, everywhere",
									icon = "double-chevron-large-up",
									id = "pass_speed_coil",
									price = 75,
									title = "Speed Coil",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Gamepasses",
						},
					},
				},
				{
					icon = "gem",
					id = "tab_gems",
					label = "Gems",
					sections = {
						{
							id = "gem_bundles",
							items = {
								{
									amount = "100",
									currency = "robux",
									grant = { amount = 100, currency = "gems" },
									icon = "gem",
									id = "gems_pack_1",
									price = 49,
									title = "Handful of Gems",
									tone = "Yellow",
								},
								{
									amount = "225",
									bonus = 11,
									currency = "robux",
									grant = { amount = 225, currency = "gems" },
									icon = "gem",
									id = "gems_pack_2",
									price = 99,
									tag = "SALE",
									title = "Pouch of Gems",
									tone = "Yellow",
									was = 149,
								},
								{
									amount = "500",
									bonus = 23,
									currency = "robux",
									grant = { amount = 500, currency = "gems" },
									icon = "gem",
									id = "gems_pack_3",
									price = 199,
									title = "Sack of Gems",
									tone = "Green",
								},
								{
									amount = "1,250",
									bonus = 54,
									currency = "robux",
									grant = { amount = 1250, currency = "gems" },
									icon = "file-box",
									id = "gems_pack_4",
									price = 399,
									tag = "BEST VALUE",
									title = "Chest of Gems",
									tone = "Green",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Gem Bundles",
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_items",
					label = "Items",
					sections = {
						{
							id = "products",
							items = {
								{
									currency = "coins",
									icon = "house",
									id = "buy_premium_sofa",
									price = 1500,
									title = "Premium Sofa",
									tone = "Green",
								},
								{
									currency = "coins",
									icon = "heart",
									id = "buy_pet_bed",
									price = 1000,
									title = "Pet Bed",
									tone = "Yellow",
								},
								{
									currency = "coins",
									icon = "lightning-bolt",
									id = "buy_neon_sign",
									price = 1500,
									title = "Neon Sign",
									tone = "Pink",
								},
								{
									currency = "coins",
									icon = "cloud",
									id = "buy_hot_tub",
									price = 3000,
									title = "Hot Tub",
									tone = "Green",
								},
								{
									currency = "coins",
									icon = "music",
									id = "buy_disco_ball",
									price = 1750,
									title = "Disco Ball",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Carousel",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Everything free in one place: Daily, Gifts and Social. Every tab badges what's ready, and so does the HUD button.",
			icon = "gift",
			id = "rewards",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							days = {
								{ amount = "750", day = 1, icon = "coin" },
								{ amount = "1,250", day = 2, icon = "coin" },
								{ amount = "x2", day = 3, icon = "boost", label = "15 min" },
								{ amount = "30", day = 4, icon = "gem" },
								{ amount = "2,250", day = 5, icon = "coin" },
								{ amount = "2,750", day = 6, icon = "coin" },
								{ amount = "150", day = 7, icon = "gift", label = "Mega Reward" },
							},
							id = "daily",
							kind = "Daily",
							streak = 1,
						},
					},
				},
				{
					icon = "gift",
					id = "tab_gifts",
					label = "Gifts",
					sections = {
						{
							gifts = {
								{
									amount = "275",
									grant = { amount = 275, currency = "coins" },
									icon = "coin",
									id = "gift1",
									minutes = 1,
									rarity = "Common",
								},
								{
									amount = "400",
									grant = { amount = 400, currency = "coins" },
									icon = "coin",
									id = "gift2",
									minutes = 3,
									rarity = "Common",
								},
								{
									amount = "550",
									grant = { amount = 550, currency = "coins" },
									icon = "coin",
									id = "gift3",
									minutes = 5,
									rarity = "Common",
								},
								{
									amount = "900",
									grant = { amount = 900, currency = "coins" },
									icon = "coin",
									id = "gift4",
									minutes = 10,
									rarity = "Uncommon",
								},
								{
									amount = "1,250",
									grant = { amount = 1250, currency = "coins" },
									icon = "coin",
									id = "gift5",
									minutes = 15,
									rarity = "Uncommon",
								},
								{
									amount = "1,500",
									grant = { amount = 1500, currency = "coins" },
									icon = "coin",
									id = "gift6",
									minutes = 20,
									rarity = "Rare",
								},
								{
									amount = "10",
									grant = { amount = 10, currency = "gems" },
									icon = "gem",
									id = "gift7",
									minutes = 30,
									rarity = "Rare",
								},
								{
									amount = "50",
									grant = { amount = 50, currency = "gems" },
									icon = "gem",
									id = "gift8",
									minutes = 45,
									rarity = "Legendary",
								},
							},
							id = "gifts",
							kind = "Gifts",
						},
					},
				},
				{
					icon = "three-people",
					id = "tab_social",
					label = "Social",
					sections = {
						{
							id = "social",
							kind = "Social",
							rewards = {
								{
									icon = "three-people",
									id = "group",
									kind = "Group",
									label = "Join the Group",
									reward = { amount = "2,000", grant = { amount = 2000, currency = "coins" }, icon = "coin" },
								},
								{
									icon = "thumb-up",
									id = "like",
									kind = "Like",
									label = "Like the Game",
									reward = { amount = "10", grant = { amount = 10, currency = "gems" }, icon = "gem" },
									wait = 60,
								},
								{
									icon = "person-plus",
									id = "invite",
									kind = "Invite",
									label = "Invite a Friend",
									limit = 10,
									reward = { amount = "25", grant = { amount = 25, currency = "gems" }, icon = "gem" },
								},
							},
						},
					},
				},
			},
			title = "Free Rewards",
		},
		{
			description = "Buy and switch homes. Prices escalate from a free starter apartment to a Sky Castle for the richest players.",
			icon = "house",
			id = "homes",
			kind = "Window",
			nav = "None",
			sections = {
				{
					icon = "cloud",
					id = "featured_sky_castle",
					kind = "Banner",
					subtitle = "The dream home",
					title = "Featured: Sky Castle",
					tone = "Pink",
				},
				{
					id = "for_sale",
					items = {
						{
							currency = "coins",
							icon = "house",
							id = "buy_starter_apartment",
							price = 3500,
							title = "Starter Apartment",
							tone = "Gray",
						},
						{
							currency = "coins",
							icon = "wheat",
							id = "buy_treehouse",
							price = 10000,
							title = "Treehouse",
							tone = "Green",
						},
						{
							currency = "coins",
							icon = "sun",
							id = "buy_beach_house",
							price = 27500,
							title = "Beach House",
							tone = "Blue",
						},
						{
							currency = "coins",
							icon = "building-store",
							id = "buy_modern_villa",
							price = 77500,
							title = "Modern Villa",
							tone = "Purple",
						},
						{
							currency = "coins",
							icon = "crown",
							id = "buy_mansion",
							price = 180000,
							title = "Mansion",
							tone = "Yellow",
						},
						{
							currency = "coins",
							icon = "cloud",
							id = "buy_sky_castle",
							price = 500000,
							tag = "NEW",
							title = "Sky Castle",
							tone = "Red",
						},
					},
					kind = "Products",
					layout = "Cards",
					title = "For Sale",
				},
			},
			size = "Large",
			title = "Houses",
		},
		{
			description = "Pick a job to start earning. Each job button fires a StartJob remote with its id so the server can hand out uniforms and tasks.",
			icon = "tasks",
			id = "jobs",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "current_job",
					kind = "Stats",
					stats = {
						{ icon = "paper-airplane", label = "Job", value = "Pilot" },
						{ icon = "coin", label = "Pay per shift", value = "220" },
						{ icon = "timer", label = "Next paycheck", value = "4:32" },
					},
					title = "Current Job",
				},
				{
					buttons = {
						{
							action = { kind = "Remote", name = "StartJob" },
							icon = "paper-airplane",
							id = "job_pilot",
							label = "Pilot",
							tone = "Green",
						},
						{
							action = { kind = "Remote", name = "StartJob" },
							icon = "flame",
							id = "job_chef",
							label = "Chef",
							tone = "White",
						},
						{
							action = { kind = "Remote", name = "StartJob" },
							icon = "wheat",
							id = "job_farmer",
							label = "Farmer",
							tone = "White",
						},
						{
							action = { kind = "Remote", name = "StartJob" },
							icon = "shield",
							id = "job_police",
							label = "Police",
							tone = "White",
						},
						{
							action = { kind = "Remote", name = "StartJob" },
							icon = "book-open",
							id = "job_teacher",
							label = "Teacher",
							tone = "White",
						},
						{
							action = { kind = "Remote", name = "StartJob" },
							icon = "headphones",
							id = "job_dj",
							label = "DJ",
							tone = "White",
						},
					},
					id = "available_jobs",
					kind = "Buttons",
					layout = "Grid",
					title = "Available Jobs",
				},
			},
			size = "Medium",
			title = "Jobs",
		},
		{
			currency = "coins",
			description = "Two-sided trade window: your offer on the left, theirs on the right, Coins can be added, and both players must accept.",
			icon = "two-arrows-left-right",
			id = "trade",
			kind = "Trade",
			size = "Large",
			title = "Trade",
		},
		{
			description = "Pick up to eight emotes for the radial wheel (opened with G in game).",
			icon = "face-winking",
			id = "emotes",
			kind = "Window",
			nav = "None",
			sections = {
				{
					columns = 5,
					detail = true,
					id = "inventory_5",
					items = {
						{
							equipped = true,
							icon = "circle-question",
							id = "inv_shrug",
							name = "Shrug",
							rarity = "Legendary",
						},
						{ equipped = true, icon = "crown", id = "inv_crown", name = "Crown", rarity = "Epic" },
						{ equipped = true, icon = "person-jumping", id = "inv_jump", name = "Jump", rarity = "Rare" },
						{ equipped = true, icon = "moon", id = "inv_nap", name = "Nap", rarity = "Rare" },
						{
							equipped = true,
							icon = "square-face-waving-hand",
							id = "inv_wave",
							name = "Wave",
							rarity = "Rare",
						},
						{
							equipped = true,
							icon = "music-note",
							id = "inv_dance",
							name = "Dance",
							rarity = "Uncommon",
						},
						{ icon = "arm", id = "inv_flex", name = "Flex", rarity = "Common" },
						{ icon = "heart", id = "inv_heart", name = "Heart", rarity = "Common" },
						{ icon = "face-winking", id = "inv_laugh", name = "Laugh", rarity = "Common" },
						{ icon = "star", id = "inv_salute", name = "Salute", rarity = "Common" },
					},
					kind = "Inventory",
					layout = "Bottom",
				},
				{
					body = "Equipped emotes appear on your emote wheel. Press G to open it in game.",
					id = "text",
					kind = "Text",
				},
			},
			size = "Medium",
			title = "Emotes",
		},
		{
			description = "Grouped into General, Graphics, Audio and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "two-arrows-left-right",
									id = "tradeRequests",
									label = "Trade Requests",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	style = {
		archetype = "Candy",
		buttonStyle = "Soft",
		clickFx = "Squash",
		corner = "Round",
		decoration = "Dots",
		density = "Compact",
		fonts = { body = "Nunito:ExtraBold", display = "FredokaOne", title = "FredokaOne" },
		iconStyle = "Filled",
		palette = {
			accent = "#FF7EB6",
			bad = "#F25F5C",
			good = "#4CAF50",
			primary = "#7CC832",
			secondary = "#FFC93D",
			surface = "#2F4214",
			text = "#FBFFF0",
			warn = "#FFA630",
		},
		panelStyle = "Ribbon",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Pop",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="63">
              <Properties>
                <string name="Name">roleplay_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: roleplay, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#5BE36B",
			format = "abbrev",
			icon = "cash",
			id = "bucks",
			name = "Bucks",
			source = { kind = "leaderstat", name = "Bucks" },
			start = 15000,
		},
		{
			color = "#FFD84D",
			format = "comma",
			icon = "star",
			id = "stars",
			name = "Stars",
			source = { kind = "attribute", name = "Stars" },
			start = 175,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				id = "hud_objective",
				text = "Theme: Villain",
				type = "Objective",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Styling",
				seconds = 300,
				type = "Timer",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopRight",
				currencies = { "bucks", "stars" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Toggle", target = "homes" },
						icon = "house",
						id = "btn_homes",
						label = "Houses",
						tone = "Purple",
					},
					{
						action = { kind = "Toggle", target = "wardrobe" },
						icon = "skins",
						id = "btn_wardrobe",
						label = "Style",
						tone = "Cyan",
					},
					{
						action = { kind = "Toggle", target = "rewards" },
						icon = "gift",
						id = "btn_rewards",
						label = "Free Rewards",
						tone = "Purple",
					},
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Shop",
						tone = "Pink",
					},
					{
						action = { kind = "Toggle", target = "jobs" },
						icon = "tasks",
						id = "btn_jobs",
						label = "Work",
						tone = "Purple",
					},
					{
						action = { kind = "Toggle", target = "worlds" },
						icon = "map",
						id = "btn_worlds",
						label = "Map",
						tone = "Cyan",
					},
					{
						action = { kind = "Toggle", target = "emotes" },
						icon = "face-winking",
						id = "btn_emotes",
						label = "Emotes",
						tone = "Purple",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "White",
					},
				},
				id = "hud_menubuttons",
				style = "Grid",
				type = "MenuButtons",
				visible = true,
			},
			{
				anchor = "Center",
				emotes = {
					{ icon = "person-jumping", id = "emote_jump", label = "Jump" },
					{ icon = "moon", id = "emote_nap", label = "Nap" },
					{ icon = "thumb-down", id = "emote_boo", label = "Boo" },
					{ icon = "arm", id = "emote_flex", label = "Flex" },
					{ icon = "circle-question", id = "emote_shrug", label = "Shrug" },
					{ icon = "heart", id = "emote_heart", label = "Heart" },
				},
				id = "hud_emotewheel",
				key = "G",
				type = "EmoteWheel",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				icon = "star",
				id = "hud_meter",
				label = "STARS",
				low = 0,
				readout = "2/5",
				style = "Bar",
				tone = "Pink",
				type = "Meter",
				value = 0.4,
				visible = true,
			},
			{
				action = { kind = "Event", name = "OpenVote" },
				anchor = "Right",
				icon = "star",
				id = "hud_button",
				label = "Vote",
				tone = "Cyan",
				type = "Button",
				visible = true,
			},
		},
		kit = "Chunky Toy",
		layout = "Classic",
	},
	logo = { icon = "house", text = "Dress to Slay", tilt = -5, tone = "Pink", type = "Wordmark" },
	meta = {
		flavor = "dressup",
		genre = "roleplay",
		kit = "Chunky Toy",
		prompt = "a roleplay game",
		seed = 7,
		source = "offline",
		title = "Dress to Slay",
	},
	screens = {
		{
			description = "Buy and switch homes. Prices escalate from a free starter apartment to a Sky Castle for the richest players.",
			icon = "house",
			id = "homes",
			kind = "Window",
			nav = "None",
			sections = {
				{
					icon = "cloud",
					id = "featured_sky_castle",
					kind = "Banner",
					subtitle = "Only the richest can move in",
					title = "Featured: Sky Castle",
					tone = "Cyan",
				},
				{
					id = "for_sale",
					items = {
						{
							currency = "bucks",
							icon = "house",
							id = "buy_starter_apartment",
							price = 4000,
							title = "Starter Apartment",
							tone = "Gray",
						},
						{
							currency = "bucks",
							icon = "wheat",
							id = "buy_treehouse",
							price = 10000,
							title = "Treehouse",
							tone = "Green",
						},
						{
							currency = "bucks",
							icon = "sun",
							id = "buy_beach_house",
							price = 27500,
							title = "Beach House",
							tone = "Blue",
						},
						{
							currency = "bucks",
							icon = "building-store",
							id = "buy_modern_villa",
							price = 62500,
							title = "Modern Villa",
							tone = "Purple",
						},
						{
							currency = "bucks",
							icon = "crown",
							id = "buy_mansion",
							price = 220000,
							title = "Mansion",
							tone = "Yellow",
						},
						{
							currency = "bucks",
							icon = "cloud",
							id = "buy_sky_castle",
							price = 560000,
							tag = "RARE FIND",
							title = "Sky Castle",
							tone = "Red",
						},
					},
					kind = "Products",
					layout = "Cards",
					title = "For Sale",
				},
			},
			size = "Large",
			title = "Houses",
		},
		{
			description = "Dress-up: tabs per body slot, items previewed on the avatar live.",
			icon = "skins",
			id = "wardrobe",
			kind = "Fullscreen",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "hat-toque",
					id = "tab_hats",
					label = "Hats",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{ equipped = true, icon = "crown", id = "inv_tiara", name = "Tiara", rarity = "Epic" },
								{ icon = "hat-toque", id = "inv_party_hat", name = "Party Hat", rarity = "Rare" },
								{ icon = "hat-fedora", id = "inv_fedora", name = "Fedora", rarity = "Uncommon" },
								{ icon = "hat-toque", id = "inv_beanie", name = "Beanie", rarity = "Common" },
								{ icon = "glasses", id = "inv_sunglasses", name = "Sunglasses", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Showcase",
						},
						{
							buttons = {
								{
									action = { kind = "Open", target = "trade" },
									icon = "two-arrows-left-right",
									id = "goto_trade",
									label = "Trade",
									tone = "White",
								},
							},
							id = "buttons",
							kind = "Buttons",
							layout = "Row",
						},
					},
				},
				{
					icon = "tshirt",
					id = "tab_tops",
					label = "Tops",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_2",
							items = {
								{
									equipped = true,
									icon = "dress",
									id = "inv_ball_gown",
									name = "Ball Gown",
									rarity = "Legendary",
								},
								{ icon = "dress", id = "inv_sundress", name = "Sundress", rarity = "Rare" },
								{ icon = "jacket", id = "inv_denim_jacket", name = "Denim Jacket", rarity = "Uncommon" },
								{ icon = "sweater", id = "inv_knit_sweater", name = "Knit Sweater", rarity = "Uncommon" },
								{ icon = "tshirt", id = "inv_graphic_tee", name = "Graphic Tee", rarity = "Common" },
								{ icon = "sweater", id = "inv_hoodie", name = "Hoodie", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Showcase",
						},
					},
				},
				{
					icon = "pants",
					id = "tab_bottoms",
					label = "Bottoms",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_3",
							items = {
								{ equipped = true, icon = "shoe", id = "inv_boots", name = "Boots", rarity = "Rare" },
								{ icon = "shoe", id = "inv_sneakers", name = "Sneakers", rarity = "Uncommon" },
								{ icon = "shorts", id = "inv_cargo_shorts", name = "Cargo Shorts", rarity = "Common" },
								{ icon = "pants", id = "inv_jeans", name = "Jeans", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Showcase",
						},
					},
				},
				{
					icon = "necklace",
					id = "tab_extras",
					label = "Extras",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_4",
							items = {
								{
									equipped = true,
									icon = "diamond",
									id = "inv_diamond_ring",
									name = "Diamond Ring",
									rarity = "Legendary",
								},
								{ icon = "necklace", id = "inv_necklace", name = "Necklace", rarity = "Rare" },
								{ icon = "purse", id = "inv_handbag", name = "Handbag", rarity = "Uncommon" },
								{ icon = "lipstick", id = "inv_lipstick", name = "Lipstick", rarity = "Uncommon" },
								{ icon = "bow-tie", id = "inv_bow_tie", name = "Bow Tie", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Showcase",
						},
					},
				},
			},
			title = "Style",
		},
		{
			description = "Everything free in one place: Daily, Gifts and Social. Every tab badges what's ready, and so does the HUD button.",
			icon = "gift",
			id = "rewards",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							days = {
								{ amount = "775", day = 1, icon = "cash" },
								{ amount = "1,250", day = 2, icon = "cash" },
								{ amount = "40", day = 3, icon = "star" },
								{ amount = "2,000", day = 4, icon = "cash" },
								{ amount = "x2", day = 5, icon = "boost" },
								{ amount = "2,750", day = 6, icon = "cash" },
								{ amount = "100", day = 7, icon = "star" },
								{ amount = "1,250", day = 8, icon = "cash" },
								{ amount = "1,750", day = 9, icon = "cash" },
								{ amount = "65", day = 10, icon = "star" },
								{ amount = "3,000", day = 11, icon = "cash" },
								{ amount = "x2", day = 12, icon = "boost" },
								{ amount = "4,250", day = 13, icon = "cash" },
								{ amount = "150", day = 14, icon = "star" },
								{ amount = "1,750", day = 15, icon = "cash" },
								{ amount = "2,500", day = 16, icon = "cash" },
								{ amount = "90", day = 17, icon = "star" },
								{ amount = "4,250", day = 18, icon = "cash" },
								{ amount = "x3", day = 19, icon = "boost" },
								{ amount = "6,000", day = 20, icon = "cash" },
								{ amount = "225", day = 21, icon = "star" },
								{ amount = "2,250", day = 22, icon = "cash" },
								{ amount = "3,250", day = 23, icon = "cash" },
								{ amount = "100", day = 24, icon = "star" },
								{ amount = "5,500", day = 25, icon = "cash" },
								{ amount = "x3", day = 26, icon = "boost" },
								{ amount = "7,500", day = 27, icon = "cash" },
								{ amount = "550", day = 28, icon = "gift", label = "Mega Reward" },
							},
							id = "daily",
							kind = "Daily",
							layout = "Calendar",
							streak = 7,
						},
					},
				},
				{
					icon = "gift",
					id = "tab_gifts",
					label = "Gifts",
					sections = {
						{
							gifts = {
								{
									amount = "350",
									grant = { amount = 350, currency = "bucks" },
									icon = "cash",
									id = "gift1",
									minutes = 1,
									rarity = "Common",
								},
								{
									amount = "525",
									grant = { amount = 525, currency = "bucks" },
									icon = "cash",
									id = "gift2",
									minutes = 3,
									rarity = "Common",
								},
								{
									amount = "700",
									grant = { amount = 700, currency = "bucks" },
									icon = "cash",
									id = "gift3",
									minutes = 5,
									rarity = "Common",
								},
								{
									amount = "1,250",
									grant = { amount = 1250, currency = "bucks" },
									icon = "cash",
									id = "gift4",
									minutes = 10,
									rarity = "Uncommon",
								},
								{
									amount = "1,500",
									grant = { amount = 1500, currency = "bucks" },
									icon = "cash",
									id = "gift5",
									minutes = 15,
									rarity = "Uncommon",
								},
								{
									amount = "2,000",
									grant = { amount = 2000, currency = "bucks" },
									icon = "cash",
									id = "gift6",
									minutes = 20,
									rarity = "Rare",
								},
								{
									amount = "10",
									grant = { amount = 10, currency = "stars" },
									icon = "star",
									id = "gift7",
									minutes = 30,
									rarity = "Rare",
								},
								{
									amount = "4,250",
									grant = { amount = 4250, currency = "bucks" },
									icon = "cash",
									id = "gift8",
									minutes = 45,
									rarity = "Rare",
								},
								{
									amount = "20",
									grant = { amount = 20, currency = "stars" },
									icon = "star",
									id = "gift9",
									minutes = 60,
									rarity = "Epic",
								},
								{
									amount = "6,750",
									grant = { amount = 6750, currency = "bucks" },
									icon = "cash",
									id = "gift10",
									minutes = 75,
									rarity = "Rare",
								},
								{
									amount = "35",
									grant = { amount = 35, currency = "stars" },
									icon = "star",
									id = "gift11",
									minutes = 90,
									rarity = "Epic",
								},
								{
									amount = "x1",
									icon = "crown",
									id = "gift12",
									label = "Tiara",
									minutes = 120,
									rarity = "Epic",
								},
							},
							id = "gifts",
							kind = "Gifts",
						},
					},
				},
				{
					icon = "three-people",
					id = "tab_social",
					label = "Social",
					sections = {
						{
							id = "social",
							kind = "Social",
							rewards = {
								{
									icon = "three-people",
									id = "group",
									kind = "Group",
									label = "Join the Group",
									reward = { amount = "2,000", grant = { amount = 2000, currency = "bucks" }, icon = "cash" },
								},
								{
									icon = "thumb-up",
									id = "like",
									kind = "Like",
									label = "Like the Game",
									reward = { amount = "10", grant = { amount = 10, currency = "stars" }, icon = "star" },
									wait = 60,
								},
								{
									icon = "person-plus",
									id = "invite",
									kind = "Invite",
									label = "Invite a Friend",
									limit = 10,
									reward = { amount = "25", grant = { amount = 25, currency = "stars" }, icon = "star" },
								},
							},
						},
					},
				},
			},
			title = "Free Rewards",
		},
		{
			description = "Where Bucks and Robux turn into progress. Tabs: Passes, Stars and Items.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Bucks",
									icon = "crown",
									id = "pass_vip",
									price = 699,
									tag = "POPULAR",
									title = "VIP",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "A bigger, better base to show off",
									icon = "house",
									id = "pass_premium_house_pack",
									price = 299,
									title = "Premium House Pack",
									tone = "Cyan",
								},
								{
									currency = "robux",
									description = "Stand out in every lobby",
									icon = "tshirt",
									id = "pass_fashion_pass",
									price = 149,
									title = "Fashion Pass",
									tone = "Purple",
								},
								{
									currency = "robux",
									description = "Play your own songs in game",
									icon = "music",
									id = "pass_radio",
									price = 99,
									title = "Radio",
									tone = "Cyan",
								},
								{
									currency = "robux",
									description = "Move twice as fast, everywhere",
									icon = "double-chevron-large-up",
									id = "pass_speed_coil",
									price = 75,
									title = "Speed Coil",
									tone = "Purple",
								},
							},
							kind = "Products",
							layout = "Carousel",
							title = "Gamepasses",
						},
					},
				},
				{
					icon = "star",
					id = "tab_stars",
					label = "Stars",
					sections = {
						{
							id = "star_bundles",
							items = {
								{
									amount = "80",
									currency = "robux",
									grant = { amount = 80, currency = "stars" },
									icon = "star",
									id = "stars_pack_1",
									price = 49,
									title = "Handful of Stars",
									tone = "Purple",
								},
								{
									amount = "200",
									bonus = 24,
									currency = "robux",
									grant = { amount = 200, currency = "stars" },
									icon = "star",
									id = "stars_pack_2",
									price = 99,
									title = "Pouch of Stars",
									tone = "Purple",
								},
								{
									amount = "400",
									bonus = 23,
									currency = "robux",
									grant = { amount = 400, currency = "stars" },
									icon = "star",
									id = "stars_pack_3",
									price = 199,
									tag = "POPULAR",
									title = "Sack of Stars",
									tone = "Pink",
								},
								{
									amount = "900",
									bonus = 38,
									currency = "robux",
									grant = { amount = 900, currency = "stars" },
									icon = "file-box",
									id = "stars_pack_4",
									price = 399,
									title = "Chest of Stars",
									tone = "Pink",
								},
								{
									amount = "2,000",
									bonus = 53,
									currency = "robux",
									grant = { amount = 2000, currency = "stars" },
									icon = "building-bank",
									id = "stars_pack_5",
									price = 799,
									title = "Vault of Stars",
									tone = "Cyan",
								},
								{
									amount = "4,500",
									bonus = 84,
									currency = "robux",
									grant = { amount = 4500, currency = "stars" },
									icon = "crown",
									id = "stars_pack_6",
									price = 1499,
									tag = "BEST VALUE",
									title = "Mountain of Stars",
									tone = "Cyan",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Star Bundles",
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_items",
					label = "Items",
					sections = {
						{
							id = "products",
							items = {
								{
									currency = "bucks",
									icon = "house",
									id = "buy_premium_sofa",
									price = 2000,
									title = "Premium Sofa",
									tone = "Pink",
								},
								{
									currency = "bucks",
									icon = "heart",
									id = "buy_pet_bed",
									price = 900,
									title = "Pet Bed",
									tone = "Purple",
								},
								{
									currency = "bucks",
									icon = "lightning-bolt",
									id = "buy_neon_sign",
									price = 1750,
									title = "Neon Sign",
									tone = "Cyan",
								},
								{
									currency = "bucks",
									icon = "cloud",
									id = "buy_hot_tub",
									price = 2750,
									title = "Hot Tub",
									tone = "Pink",
								},
								{
									currency = "bucks",
									icon = "music",
									id = "buy_disco_ball",
									price = 1750,
									title = "Disco Ball",
									tone = "Purple",
								},
							},
							kind = "Products",
							layout = "Cards",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Pick a job to start earning. Each job button fires a StartJob remote with its id so the server can hand out uniforms and tasks.",
			icon = "tasks",
			id = "jobs",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "current_job",
					kind = "Stats",
					stats = {
						{ icon = "paper-airplane", label = "Job", value = "Pilot" },
						{ icon = "cash", label = "Pay per shift", value = "220" },
						{ icon = "timer", label = "Next paycheck", value = "4:32" },
					},
					title = "Current Job",
				},
				{
					buttons = {
						{
							action = { kind = "Remote", name = "StartJob" },
							icon = "paper-airplane",
							id = "job_pilot",
							label = "Pilot",
							tone = "Pink",
						},
						{
							action = { kind = "Remote", name = "StartJob" },
							icon = "milk",
							id = "job_barista",
							label = "Barista",
							tone = "White",
						},
						{
							action = { kind = "Remote", name = "StartJob" },
							icon = "paint-brush",
							id = "job_artist",
							label = "Artist",
							tone = "White",
						},
						{
							action = { kind = "Remote", name = "StartJob" },
							icon = "heart",
							id = "job_doctor",
							label = "Doctor",
							tone = "White",
						},
						{
							action = { kind = "Remote", name = "StartJob" },
							icon = "wheat",
							id = "job_farmer",
							label = "Farmer",
							tone = "White",
						},
						{
							action = { kind = "Remote", name = "StartJob" },
							icon = "shield",
							id = "job_police",
							label = "Police",
							tone = "White",
						},
					},
					id = "available_jobs",
					kind = "Buttons",
					layout = "Grid",
					title = "Available Jobs",
				},
			},
			size = "Medium",
			title = "Work",
		},
		{
			description = "The map of Dress to Slay: every area as a card, locked ones showing what it takes to get there.",
			icon = "map",
			id = "worlds",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "worlds_2",
					kind = "Worlds",
					worlds = {
						{
							icon = "building-store",
							id = "world_downtown",
							locked = false,
							name = "Downtown",
							tone = "Pink",
						},
						{
							icon = "sun",
							id = "world_beachfront",
							locked = false,
							name = "Beachfront",
							tone = "Purple",
						},
						{
							icon = "house",
							id = "world_suburbs",
							locked = true,
							name = "Suburbs",
							requirement = "Level 20",
							tone = "Cyan",
						},
						{
							icon = "heart",
							id = "world_hospital",
							locked = true,
							name = "Hospital",
							requirement = "Level 30",
							tone = "Pink",
						},
						{
							icon = "book-open",
							id = "world_school",
							locked = true,
							name = "School",
							requirement = "Level 40",
							tone = "Purple",
						},
						{
							icon = "paper-airplane",
							id = "world_airport",
							locked = true,
							name = "Airport",
							requirement = "Level 50",
							tone = "Cyan",
						},
					},
				},
			},
			size = "Large",
			title = "Map",
		},
		{
			currency = "bucks",
			description = "Two-sided trade window: your offer on the left, theirs on the right, Bucks can be added, and both players must accept.",
			icon = "two-arrows-left-right",
			id = "trade",
			kind = "Trade",
			size = "Large",
			title = "Trade",
		},
		{
			description = "Pick up to eight emotes for the radial wheel (opened with G in game).",
			icon = "face-winking",
			id = "emotes",
			kind = "Window",
			nav = "None",
			sections = {
				{
					columns = 5,
					detail = true,
					id = "inventory_5",
					items = {
						{ equipped = true, icon = "arm", id = "inv_flex", name = "Flex", rarity = "Legendary" },
						{
							equipped = true,
							icon = "person-jumping",
							id = "inv_jump",
							name = "Jump",
							rarity = "Legendary",
						},
						{ equipped = true, icon = "thumb-down", id = "inv_boo", name = "Boo", rarity = "Rare" },
						{
							equipped = true,
							icon = "megaphone-cheer",
							id = "inv_cheer",
							name = "Cheer",
							rarity = "Uncommon",
						},
						{
							equipped = true,
							icon = "music-note",
							id = "inv_dance",
							name = "Dance",
							rarity = "Uncommon",
						},
						{
							equipped = true,
							icon = "circle-question",
							id = "inv_shrug",
							name = "Shrug",
							rarity = "Uncommon",
						},
						{ icon = "flame", id = "inv_taunt", name = "Taunt", rarity = "Uncommon" },
						{ icon = "crown", id = "inv_crown", name = "Crown", rarity = "Common" },
						{ icon = "thumb-up", id = "inv_gg", name = "GG", rarity = "Common" },
						{ icon = "square-face-waving-hand", id = "inv_wave", name = "Wave", rarity = "Common" },
					},
					kind = "Inventory",
					layout = "Bottom",
				},
				{
					body = "Equipped emotes appear on your emote wheel. Press G to open it in game.",
					id = "text",
					kind = "Text",
				},
			},
			size = "Medium",
			title = "Emotes",
		},
		{
			description = "Redeem promo codes. The server validates them through the RedeemCode remote.",
			icon = "tag",
			id = "codes",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					body = "New codes drop with every update and milestone. Check the game page!",
					id = "text_2",
					kind = "Text",
				},
				{
					action = { kind = "Remote", name = "RedeemCode" },
					button = "Redeem",
					id = "codes_2",
					kind = "Codes",
					placeholder = "Enter a code",
				},
			},
			size = "Small",
			title = "Codes",
			transition = "Drop",
		},
		{
			description = "Grouped into General, Graphics, Audio and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "two-arrows-left-right",
									id = "tradeRequests",
									label = "Trade Requests",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
						{
							buttons = {
								{
									action = { kind = "Open", target = "codes" },
									icon = "tag",
									id = "goto_codes",
									label = "Codes",
									tone = "White",
								},
							},
							id = "buttons_2",
							kind = "Buttons",
							layout = "Row",
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	style = {
		archetype = "Bubbly",
		buttonStyle = "Soft",
		clickFx = "Poof",
		corner = "Bubbly",
		decoration = "Sparkles",
		density = "Comfy",
		fonts = { body = "JosefinSans:SemiBold", display = "JosefinSans:Bold", title = "JosefinSans:Bold" },
		iconStyle = "Filled",
		palette = {
			accent = "#4DB6AC",
			bad = "#E53935",
			good = "#4CAF50",
			primary = "#F06292",
			secondary = "#7E57C2",
			surface = "#3E2238",
			text = "#FFF5F8",
			warn = "#FFB300",
		},
		panelStyle = "Outlined",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Pop",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="64">
              <Properties>
                <string name="Name">rpg_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: rpg, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD21F",
			format = "abbrev",
			icon = "coin",
			id = "beli",
			name = "Beli",
			source = { kind = "leaderstat", name = "Beli" },
			start = 60000,
		},
		{
			color = "#B45CFF",
			format = "comma",
			icon = "diamond",
			id = "fragments",
			name = "Fragments",
			source = { kind = "attribute", name = "Fragments" },
			start = 900,
		},
	},
	hud = {
		elements = {
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopRight",
				currencies = { "beli", "fragments" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{ anchor = "TopRight", id = "hud_minimap", shape = "Round", type = "Minimap", visible = true },
			{
				anchor = "TopLeft",
				id = "hud_questtracker",
				tasks = {
					{
						goal = 25,
						icon = "sword",
						progress = 25,
						reward = { amount = "450", icon = "coin" },
						text = "Slime Trouble",
					},
					{
						goal = 1,
						icon = "compass",
						progress = 0,
						reward = { amount = "500", icon = "coin" },
						text = "Lost Relic",
					},
				},
				title = "Quests",
				type = "QuestTracker",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				id = "hud_healthbar",
				max = 480,
				source = { kind = "humanoid" },
				type = "HealthBar",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				slots = {
					{ icon = "arm", name = "Combat" },
					{ icon = "sword", name = "Katana" },
					{ icon = "flame", name = "Flame Fruit" },
					{ icon = "crosshairs", name = "Flintlock" },
				},
				type = "Hotbar",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_xpbar",
				level = 51,
				look = "Ring",
				progress = 0.51,
				type = "XPBar",
				visible = true,
			},
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Toggle", target = "character" },
						icon = "player",
						id = "btn_character",
						label = "Hero",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "inventory" },
						icon = "backpack",
						id = "btn_inventory",
						label = "Collection",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Quests",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "skills" },
						icon = "upgrade",
						id = "btn_skills",
						label = "Talents",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Merchant",
						tone = "Cyan",
					},
					{
						action = { kind = "Toggle", target = "leaderboard" },
						icon = "trophy",
						id = "btn_leaderboard",
						label = "Rankings",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "Dark",
					},
					{
						action = { kind = "Open", target = "daily" },
						badge = 1,
						icon = "daily",
						id = "btn_daily",
						label = "Daily Streak",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonX", key = "E", text = "Interact" },
					{ gamepad = "ButtonB", key = "Q", text = "Dodge" },
					{ gamepad = "ButtonL1", key = "One", text = "Ability" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Attack" },
						icon = "sword",
						id = "touch_attack",
						label = "Attack",
					},
					{
						action = { kind = "Event", name = "Dodge" },
						icon = "double-chevron-large-right",
						id = "touch_dodge",
						label = "Dodge",
					},
					{
						action = { kind = "Event", name = "Ability" },
						icon = "flame",
						id = "touch_skill",
						label = "Skill",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				icon = "lightning-bolt",
				id = "hud_meter",
				label = "ENERGY",
				low = 0.2,
				style = "Bar",
				tone = "Blue",
				type = "Meter",
				value = 0.47,
				visible = true,
			},
			{
				anchor = "TopLeft",
				icon = "crosshairs",
				id = "hud_counter",
				label = "BOUNTY",
				style = "Stamp",
				type = "Counter",
				value = 2525749,
				visible = true,
			},
		},
		kit = "Arcade",
		layout = "Corners",
	},
	logo = {
		icon = "sword",
		subtitle = "UPDATE 20",
		text = "GRAND SEAS",
		tilt = 0,
		tone = "Cyan",
		type = "Badge",
	},
	meta = {
		flavor = "fruit",
		genre = "rpg",
		kit = "Arcade",
		prompt = "a rpg game",
		seed = 21,
		source = "offline",
		title = "Grand Seas",
	},
	screens = {
		{
			description = "The title screen: the Grand Seas logo, ADVENTURE, Hero, Collection, Merchant, Quests and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "ADVENTURE",
							tone = "Cyan",
						},
						{
							action = { kind = "Open", target = "character" },
							icon = "player",
							id = "menu_character",
							label = "Hero",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "inventory" },
							icon = "backpack",
							id = "menu_inventory",
							label = "Collection",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Merchant",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "quests" },
							icon = "quest",
							id = "menu_quests",
							label = "Quests",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "flame",
					id = "welcome_bundle",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 172800,
					title = "Welcome Bundle",
					tone = "Yellow",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Who you are: level, class, headline stats and badges, plus the four equipment slots with their stat bars.",
			icon = "player",
			id = "character",
			kind = "Window",
			nav = "Tabs",
			openKey = "C",
			size = "Large",
			tabs = {
				{
					icon = "player",
					id = "tab_stats",
					label = "Stats",
					sections = {
						{
							badges = {
								{ icon = "compass", label = "Explorer" },
								{ icon = "crown", label = "Raid Leader" },
								{ icon = "flame", label = "Dragonslayer" },
								{ icon = "book-open", label = "Lorekeeper" },
							},
							id = "profile",
							kind = "Profile",
							level = 37,
							name = "ArcticWizard_99",
							stats = {
								{ icon = "arrow-progress-up", label = "Level", value = "37" },
								{ icon = "sword", label = "Class", value = "Spellblade" },
								{ icon = "flame", label = "Bosses Slain", value = "58" },
								{ icon = "coin", label = "Gold", value = "48,250" },
								{ icon = "shield", label = "Guild", value = "Emberguard" },
							},
						},
					},
				},
				{
					icon = "shield",
					id = "tab_equipment",
					label = "Equipment",
					sections = {
						{
							id = "loadout",
							kind = "Loadout",
							slots = {
								{
									icon = "sword",
									name = "Frost Blade",
									rarity = "Rare",
									stats = {
										{ label = "Attack", value = 0.67 },
										{ label = "Defence", value = 0.6 },
										{ label = "Speed", value = 0.76 },
										{ label = "Magic", value = 0.79 },
									},
								},
								{
									icon = "shield-check",
									name = "Dragonscale Armor",
									rarity = "Legendary",
									stats = {
										{ label = "Attack", value = 0.64 },
										{ label = "Defence", value = 0.67 },
										{ label = "Speed", value = 0.83 },
										{ label = "Magic", value = 0.78 },
									},
								},
								{
									icon = "hat-toque",
									name = "Hunter's Cap",
									rarity = "Uncommon",
									stats = {
										{ label = "Attack", value = 0.47 },
										{ label = "Defence", value = 0.69 },
										{ label = "Speed", value = 0.67 },
										{ label = "Magic", value = 0.38 },
									},
								},
								{
									icon = "diamond",
									name = "Gemstone Ring",
									rarity = "Epic",
									stats = {
										{ label = "Attack", value = 0.84 },
										{ label = "Defence", value = 0.87 },
										{ label = "Speed", value = 0.65 },
										{ label = "Magic", value = 0.9 },
									},
								},
							},
						},
					},
				},
			},
			title = "Hero",
		},
		{
			description = "Every item the player owns in one grid, rarest first, with a detail panel for equipping.",
			icon = "backpack",
			id = "inventory",
			kind = "Window",
			nav = "Tabs",
			openKey = "B",
			size = "Large",
			tabs = {
				{
					icon = "backpack",
					id = "tab_collection",
					label = "Collection",
					sections = {
						{
							columns = 6,
							detail = true,
							id = "inventory_2",
							items = {
								{
									equipped = true,
									icon = "flame",
									id = "inv_phoenix_feather",
									name = "Phoenix Feather",
									rarity = "Mythic",
								},
								{
									equipped = true,
									icon = "crown",
									id = "inv_crown_of_ages",
									name = "Crown of Ages",
									rarity = "Legendary",
								},
								{
									equipped = true,
									icon = "flame",
									id = "inv_emberstaff",
									name = "Emberstaff",
									rarity = "Epic",
								},
								{ icon = "diamond", id = "inv_gemstone_ring", name = "Gemstone Ring", rarity = "Epic" },
								{ icon = "lightning-bolt", id = "inv_storm_bow", name = "Storm Bow", rarity = "Epic" },
								{ icon = "book-closed", id = "inv_ancient_tome", name = "Ancient Tome", rarity = "Rare" },
								{ icon = "sword", id = "inv_frost_blade", name = "Frost Blade", rarity = "Rare" },
								{
									count = 23,
									icon = "hat-toque",
									id = "inv_hunter_s_cap",
									name = "Hunter's Cap",
									rarity = "Uncommon",
								},
								{
									count = 8,
									icon = "shoe",
									id = "inv_leather_boots",
									name = "Leather Boots",
									rarity = "Uncommon",
								},
								{
									count = 14,
									icon = "lab-beaker",
									id = "inv_mana_potion",
									name = "Mana Potion",
									rarity = "Uncommon",
								},
								{
									count = 15,
									icon = "heart",
									id = "inv_health_potion",
									name = "Health Potion",
									rarity = "Common",
								},
								{ count = 5, icon = "sword", id = "inv_iron_sword", name = "Iron Sword", rarity = "Common" },
								{
									count = 22,
									icon = "shield",
									id = "inv_oak_shield",
									name = "Oak Shield",
									rarity = "Common",
								},
								{
									icon = "shield-check",
									id = "locked_dragonscale_armor",
									locked = true,
									name = "Dragonscale Armor",
									rarity = "Legendary",
								},
								{
									icon = "necklace",
									id = "locked_void_amulet",
									locked = true,
									name = "Void Amulet",
									rarity = "Mythic",
								},
							},
							kind = "Inventory",
						},
					},
				},
				{
					icon = "book-open",
					id = "tab_bestiary",
					label = "Bestiary",
					sections = {
						{
							groupBy = "World",
							groups = {
								{
									icon = "wheat",
									id = "greenvale",
									items = {
										{
											icon = "heart",
											id = "dex_health_potion",
											name = "Health Potion",
											owned = true,
											rarity = "Common",
										},
										{
											icon = "shoe",
											id = "dex_leather_boots",
											name = "Leather Boots",
											owned = false,
											rarity = "Uncommon",
										},
										{
											icon = "flame",
											id = "dex_emberstaff",
											name = "Emberstaff",
											owned = false,
											rarity = "Epic",
										},
										{
											icon = "shield-check",
											id = "dex_dragonscale_armor",
											name = "Dragonscale Armor",
											owned = false,
											rarity = "Legendary",
										},
									},
									name = "Greenvale",
								},
								{
									icon = "flag",
									id = "king_s_road",
									items = {
										{
											icon = "sword",
											id = "dex_iron_sword",
											name = "Iron Sword",
											owned = false,
											rarity = "Common",
										},
										{
											icon = "lab-beaker",
											id = "dex_mana_potion",
											name = "Mana Potion",
											owned = true,
											rarity = "Uncommon",
										},
										{
											icon = "diamond",
											id = "dex_gemstone_ring",
											name = "Gemstone Ring",
											owned = true,
											rarity = "Epic",
										},
										{
											icon = "flame",
											id = "dex_phoenix_feather",
											name = "Phoenix Feather",
											owned = false,
											rarity = "Mythic",
										},
									},
									name = "King's Road",
								},
								{
									icon = "moon",
									id = "sunken_crypt",
									items = {
										{
											icon = "shield",
											id = "dex_oak_shield",
											name = "Oak Shield",
											owned = false,
											rarity = "Common",
										},
										{
											icon = "book-closed",
											id = "dex_ancient_tome",
											name = "Ancient Tome",
											owned = true,
											rarity = "Rare",
										},
										{
											icon = "lightning-bolt",
											id = "dex_storm_bow",
											name = "Storm Bow",
											owned = false,
											rarity = "Epic",
										},
										{
											icon = "necklace",
											id = "dex_void_amulet",
											name = "Void Amulet",
											owned = false,
											rarity = "Mythic",
										},
									},
									name = "Sunken Crypt",
								},
								{
									icon = "flame",
									id = "emberpeak",
									items = {
										{
											icon = "hat-toque",
											id = "dex_hunter_s_cap",
											name = "Hunter's Cap",
											owned = true,
											rarity = "Uncommon",
										},
										{
											icon = "sword",
											id = "dex_frost_blade",
											name = "Frost Blade",
											owned = true,
											rarity = "Rare",
										},
										{
											icon = "crown",
											id = "dex_crown_of_ages",
											name = "Crown of Ages",
											owned = false,
											rarity = "Legendary",
										},
									},
									name = "Emberpeak",
								},
							},
							id = "index",
							kind = "Index",
							milestones = {
								{
									amount = "25",
									grant = { amount = 25, currency = "fragments" },
									icon = "diamond",
									percent = 25,
								},
								{
									amount = "75",
									grant = { amount = 75, currency = "fragments" },
									icon = "diamond",
									percent = 50,
								},
								{
									amount = "200",
									grant = { amount = 200, currency = "fragments" },
									icon = "diamond",
									percent = 75,
								},
								{
									amount = "500",
									grant = { amount = 500, currency = "fragments" },
									icon = "diamond",
									percent = 100,
								},
							},
						},
					},
				},
			},
			title = "Collection",
		},
		{
			description = "Quest cards with progress bars under a reset countdown; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "Tabs",
			openKey = "L",
			size = "Large",
			tabs = {
				{
					icon = "quest",
					id = "tab_quests",
					label = "Quests",
					sections = {
						{
							icon = "timer",
							id = "new_quests_in",
							kind = "Banner",
							subtitle = "Finish all quests for a bonus chest",
							timer = 39600,
							title = "New quests in",
							tone = "Orange",
						},
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Defeat Ignaroth the Ancient",
									goal = 1,
									icon = "flame",
									id = "quest_the_dragon_s_lair",
									progress = 1,
									reward = { amount = "875", icon = "coin" },
									title = "The Dragon's Lair",
								},
								{
									description = "Defeat 15 bandits on the King's Road",
									goal = 15,
									icon = "shield",
									id = "quest_bandit_bounty",
									progress = 8,
									reward = { amount = "10", icon = "diamond" },
									title = "Bandit Bounty",
								},
								{
									description = "Gather 6 moonpetals",
									goal = 6,
									icon = "wheat",
									id = "quest_herbalist",
									progress = 0,
									reward = { amount = "800", icon = "coin" },
									title = "Herbalist",
								},
								{
									description = "Recover the relic from the Sunken Crypt",
									goal = 1,
									icon = "compass",
									id = "quest_lost_relic",
									progress = 0,
									reward = { amount = "650", icon = "coin" },
									title = "Lost Relic",
								},
							},
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_achievements",
					label = "Achievements",
					sections = {
						{
							achievements = {
								{
									description = "Complete your first quest",
									goal = 1,
									icon = "quest",
									id = "ach_adventurer",
									points = 5,
									progress = 1,
									reward = { amount = "5", grant = { amount = 5, currency = "fragments" }, icon = "diamond" },
									title = "Adventurer",
								},
								{
									description = "Visit 6 regions",
									goal = 6,
									icon = "map",
									id = "ach_explorer",
									points = 25,
									progress = 3,
									reward = { amount = "25", grant = { amount = 25, currency = "fragments" }, icon = "diamond" },
									title = "Explorer",
								},
								{
									description = "Complete 100 quests",
									goal = 100,
									icon = "quest",
									id = "ach_hero",
									points = 30,
									progress = 79,
									reward = { amount = "30", grant = { amount = 30, currency = "fragments" }, icon = "diamond" },
									title = "Hero",
								},
								{
									description = "Discover 30 creatures",
									goal = 30,
									icon = "book-open",
									id = "ach_bestiary",
									points = 35,
									progress = 0,
									reward = { amount = "35", grant = { amount = 35, currency = "fragments" }, icon = "diamond" },
									title = "Bestiary",
								},
								{
									description = "Find a Legendary item",
									goal = 1,
									icon = "crown",
									id = "ach_legendary_loot",
									points = 40,
									progress = 0,
									reward = { amount = "30", grant = { amount = 30, currency = "fragments" }, icon = "diamond" },
									title = "Legendary Loot",
								},
								{
									description = "Reach level 50",
									goal = 50,
									icon = "level",
									id = "ach_master",
									points = 50,
									progress = 0,
									reward = { amount = "55", grant = { amount = 55, currency = "fragments" }, icon = "diamond" },
									title = "Master",
								},
								{
									description = "Defeat a world boss",
									goal = 1,
									icon = "flame",
									id = "ach_dragon_slayer",
									points = 60,
									progress = 0,
									reward = { amount = "55", grant = { amount = 55, currency = "fragments" }, icon = "diamond" },
									title = "Dragon Slayer",
								},
							},
							id = "achievements",
							kind = "Achievements",
						},
					},
				},
			},
			title = "Quests",
		},
		{
			description = "Spend skill points on permanent perks. Points come from levelling up, so this screen gives levels meaning.",
			icon = "upgrade",
			id = "skills",
			kind = "Window",
			nav = "None",
			openKey = "K",
			sections = {
				{
					id = "stats",
					kind = "Stats",
					stats = { { icon = "star", label = "Skill Points", value = "4" } },
				},
				{
					id = "upgrades",
					kind = "Upgrades",
					upgrades = {
						{
							cost = 1250,
							currency = "beli",
							effect = "+3 max mana",
							icon = "lab-beaker",
							id = "upgrade_arcane_mind",
							level = 2,
							maxLevel = 10,
							title = "Arcane Mind",
						},
						{
							cost = 2500,
							currency = "beli",
							effect = "+8% defence",
							icon = "shield",
							id = "upgrade_iron_skin",
							level = 3,
							maxLevel = 10,
							title = "Iron Skin",
						},
						{
							cost = 5500,
							currency = "beli",
							effect = "+4% loot luck",
							icon = "diamond",
							id = "upgrade_treasure_sense",
							level = 3,
							maxLevel = 10,
							title = "Treasure Sense",
						},
						{
							cost = 7500,
							currency = "beli",
							effect = "+8% spell damage",
							icon = "flame",
							id = "upgrade_fireball",
							level = 3,
							maxLevel = 50,
							title = "Fireball",
						},
					},
				},
			},
			size = "Medium",
			title = "Talents",
		},
		{
			description = "Where Beli and Robux turn into progress. Tabs: Wares, Chests, Fragments and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "backpack",
					id = "tab_wares",
					label = "Wares",
					sections = {
						{
							id = "products",
							items = {
								{
									currency = "beli",
									icon = "heart",
									id = "buy_health_potion",
									price = 850,
									title = "Health Potion",
									tone = "Cyan",
								},
								{
									currency = "beli",
									icon = "lab-beaker",
									id = "buy_mana_potion",
									price = 1000,
									title = "Mana Potion",
									tone = "Orange",
								},
								{
									currency = "beli",
									icon = "flame",
									id = "buy_phoenix_down",
									price = 2750,
									title = "Phoenix Down",
									tone = "Yellow",
								},
								{
									currency = "beli",
									icon = "location-pin-map",
									id = "buy_teleport_scroll",
									price = 1750,
									title = "Teleport Scroll",
									tone = "Cyan",
								},
								{
									currency = "beli",
									icon = "diamond",
									id = "buy_whetstone",
									price = 1750,
									title = "Whetstone",
									tone = "Orange",
								},
								{
									currency = "beli",
									icon = "star",
									id = "buy_lucky_clover",
									price = 3250,
									title = "Lucky Clover",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Carousel",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_chests",
					label = "Chests",
					sections = {
						{
							cases = {
								{
									currency = "beli",
									icon = "file-box",
									id = "case_wooden_chest",
									pool = {
										{ chance = 28, icon = "heart", name = "Health Potion", rarity = "Common" },
										{ chance = 28, icon = "sword", name = "Iron Sword", rarity = "Common" },
										{ chance = 13, icon = "hat-toque", name = "Hunter's Cap", rarity = "Uncommon" },
										{ chance = 13, icon = "shoe", name = "Leather Boots", rarity = "Uncommon" },
										{ chance = 6.2, icon = "book-closed", name = "Ancient Tome", rarity = "Rare" },
										{ chance = 6.2, icon = "sword", name = "Frost Blade", rarity = "Rare" },
										{ chance = 2.8, icon = "flame", name = "Emberstaff", rarity = "Epic" },
										{ chance = 2.8, icon = "diamond", name = "Gemstone Ring", rarity = "Epic" },
									},
									price = 475,
									title = "Wooden Chest",
									tone = "Orange",
								},
								{
									currency = "beli",
									icon = "file-box",
									id = "case_iron_chest",
									pool = {
										{ chance = 29.2, icon = "hat-toque", name = "Hunter's Cap", rarity = "Uncommon" },
										{ chance = 28, icon = "shoe", name = "Leather Boots", rarity = "Uncommon" },
										{ chance = 13, icon = "book-closed", name = "Ancient Tome", rarity = "Rare" },
										{ chance = 13, icon = "sword", name = "Frost Blade", rarity = "Rare" },
										{ chance = 6.2, icon = "flame", name = "Emberstaff", rarity = "Epic" },
										{ chance = 6.2, icon = "diamond", name = "Gemstone Ring", rarity = "Epic" },
										{ chance = 2.2, icon = "crown", name = "Crown of Ages", rarity = "Legendary" },
										{ chance = 2.2, icon = "shield-check", name = "Dragonscale Armor", rarity = "Legendary" },
									},
									price = 1500,
									title = "Iron Chest",
									tone = "Gray",
								},
								{
									currency = "beli",
									icon = "crown",
									id = "case_royal_chest",
									pool = {
										{ chance = 29.8, icon = "book-closed", name = "Ancient Tome", rarity = "Rare" },
										{ chance = 30, icon = "sword", name = "Frost Blade", rarity = "Rare" },
										{ chance = 14, icon = "flame", name = "Emberstaff", rarity = "Epic" },
										{ chance = 14, icon = "diamond", name = "Gemstone Ring", rarity = "Epic" },
										{ chance = 5, icon = "crown", name = "Crown of Ages", rarity = "Legendary" },
										{ chance = 5, icon = "shield-check", name = "Dragonscale Armor", rarity = "Legendary" },
										{ chance = 1.1, icon = "flame", name = "Phoenix Feather", rarity = "Mythic" },
										{ chance = 1.1, icon = "necklace", name = "Void Amulet", rarity = "Mythic" },
									},
									price = 5250,
									title = "Royal Chest",
									tone = "Yellow",
								},
								{
									currency = "beli",
									icon = "flame",
									id = "case_dragon_chest",
									pool = {
										{ chance = 29.8, icon = "book-closed", name = "Ancient Tome", rarity = "Rare" },
										{ chance = 30, icon = "sword", name = "Frost Blade", rarity = "Rare" },
										{ chance = 14, icon = "flame", name = "Emberstaff", rarity = "Epic" },
										{ chance = 14, icon = "diamond", name = "Gemstone Ring", rarity = "Epic" },
										{ chance = 5, icon = "crown", name = "Crown of Ages", rarity = "Legendary" },
										{ chance = 5, icon = "shield-check", name = "Dragonscale Armor", rarity = "Legendary" },
										{ chance = 1.1, icon = "flame", name = "Phoenix Feather", rarity = "Mythic" },
										{ chance = 1.1, icon = "necklace", name = "Void Amulet", rarity = "Mythic" },
									},
									price = 12500,
									title = "Dragon Chest",
									tone = "Red",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "diamond",
					id = "tab_fragments",
					label = "Fragments",
					sections = {
						{
							id = "fragment_bundles",
							items = {
								{
									amount = "200",
									currency = "robux",
									grant = { amount = 200, currency = "fragments" },
									icon = "diamond",
									id = "fragments_pack_1",
									price = 49,
									title = "Starter Fragments",
									tone = "Orange",
								},
								{
									amount = "450",
									bonus = 11,
									currency = "robux",
									grant = { amount = 450, currency = "fragments" },
									icon = "diamond",
									id = "fragments_pack_2",
									price = 99,
									title = "Small Fragment Pack",
									tone = "Orange",
								},
								{
									amount = "975",
									bonus = 20,
									currency = "robux",
									grant = { amount = 975, currency = "fragments" },
									icon = "diamond",
									id = "fragments_pack_3",
									price = 199,
									tag = "POPULAR",
									title = "Big Fragment Pack",
									tone = "Cyan",
								},
								{
									amount = "2,250",
									bonus = 38,
									currency = "robux",
									grant = { amount = 2250, currency = "fragments" },
									icon = "file-box",
									id = "fragments_pack_4",
									price = 399,
									title = "Mega Fragment Pack",
									tone = "Cyan",
								},
								{
									amount = "5,000",
									bonus = 53,
									currency = "robux",
									grant = { amount = 5000, currency = "fragments" },
									icon = "building-bank",
									id = "fragments_pack_5",
									price = 799,
									title = "Giga Fragment Pack",
									tone = "Yellow",
								},
								{
									amount = "12,500",
									bonus = 104,
									currency = "robux",
									grant = { amount = 12500, currency = "fragments" },
									icon = "crown",
									id = "fragments_pack_6",
									price = 1499,
									tag = "BEST VALUE",
									title = "Ultra Fragment Pack",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Fragment Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "A loyal companion at your side",
									icon = "parrot",
									id = "pass_griffin_mount",
									price = 499,
									tag = "MUST HAVE",
									title = "Griffin Mount",
									tone = "Cyan",
								},
								{
									currency = "robux",
									description = "Level up twice as fast, forever",
									icon = "arrow-progress-up",
									id = "pass_2x_xp",
									price = 399,
									title = "2x XP",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Beli",
									icon = "crown",
									id = "pass_vip",
									price = 399,
									title = "VIP",
									tone = "Cyan",
								},
								{
									currency = "robux",
									description = "More room for everything you find",
									icon = "backpack",
									id = "pass_extra_backpack",
									price = 249,
									title = "Extra Backpack",
									tone = "Orange",
								},
								{
									currency = "robux",
									description = "Skip the walk: teleport anywhere",
									icon = "location-pin-map",
									id = "pass_fast_travel",
									price = 199,
									title = "Fast Travel",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Merchant",
		},
		{
			description = "Pops up on the first join of the day: a streak staircase where every day stands taller than the last; missing a day drops you back to the bottom step.",
			icon = "daily",
			id = "daily",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					days = {
						{ amount = "750", day = 1, icon = "coin" },
						{ amount = "1,250", day = 2, icon = "coin" },
						{ amount = "x2", day = 3, icon = "boost", label = "15 min" },
						{ amount = "30", day = 4, icon = "diamond" },
						{ amount = "2,250", day = 5, icon = "coin" },
						{ amount = "2,750", day = 6, icon = "coin" },
						{ amount = "x1", day = 7, icon = "flame", label = "Dragon Chest" },
					},
					id = "daily_2",
					kind = "Daily",
					layout = "Ladder",
					streak = 1,
				},
			},
			size = "Medium",
			title = "Daily Streak",
			transition = "Zoom",
		},
		{
			description = "Global top ten for Level, Gold and Bosses Slain, with podium colours for the top three.",
			icon = "trophy",
			id = "leaderboard",
			kind = "Window",
			nav = "Tabs",
			size = "Medium",
			tabs = {
				{
					icon = "trophy",
					id = "tab_level",
					label = "Level",
					sections = {
						{
							id = "leaderboard_2",
							kind = "Leaderboard",
							rows = {
								{ name = "MochiDashYT", rank = 1, value = "117" },
								{ name = "NotBubblePilot", rank = 2, value = "93" },
								{ name = "Wobbly_Cat", rank = 3, value = "81" },
								{ name = "xXNovaToastXx", rank = 4, value = "74" },
								{ name = "Bean2011", rank = 5, value = "62" },
								{ name = "Maple42", rank = 6, value = "55" },
								{ name = "Rogue64", rank = 7, value = "50" },
								{ name = "RogueGamer2011", rank = 8, value = "37" },
								{ name = "BlitzDashYT", rank = 9, value = "28" },
								{ name = "Cosmic_Ninja", rank = 10, value = "25" },
							},
							stat = "Level",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_gold",
					label = "Gold",
					sections = {
						{
							id = "leaderboard_3",
							kind = "Leaderboard",
							rows = {
								{ name = "Sunny_Moose", rank = 1, value = "1.2M" },
								{ name = "MochiYeti", rank = 2, value = "999K" },
								{ name = "ItsWobblyDash", rank = 3, value = "731K" },
								{ name = "ItsCometQueen", rank = 4, value = "646K" },
								{ name = "WaffleKnight64", rank = 5, value = "569K" },
								{ name = "WaffleOwl", rank = 6, value = "448K" },
								{ name = "Lucky_Hunter", rank = 7, value = "333K" },
								{ name = "ViperPilot", rank = 8, value = "300K" },
								{ name = "RealKiwiQueen", rank = 9, value = "226K" },
								{ name = "xXNeonBladeXx", rank = 10, value = "207K" },
							},
							stat = "Gold",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_bosses_slain",
					label = "Bosses Slain",
					sections = {
						{
							id = "leaderboard_4",
							kind = "Leaderboard",
							rows = {
								{ name = "ShadowOwl_7", rank = 1, value = "1,795" },
								{ name = "FrostPupYT", rank = 2, value = "1,382" },
								{ name = "Rogue404", rank = 3, value = "1,101" },
								{ name = "NoodleChef2014", rank = 4, value = "957" },
								{ name = "Doodle42", rank = 5, value = "841" },
								{ name = "RealJellyFox", rank = 6, value = "710" },
								{ name = "WobblySprout", rank = 7, value = "559" },
								{ name = "NotTacoPanda", rank = 8, value = "438" },
								{ name = "WobblySlayerYT", rank = 9, value = "368" },
								{ name = "LunaRider360", rank = 10, value = "316" },
							},
							stat = "Bosses Slain",
						},
					},
				},
			},
			title = "Rankings",
		},
		{
			delay = 5,
			description = "A one-time Welcome Bundle: 4 rewards for 99 Robux (was 299), counting down 48h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "flame",
			id = "offer",
			items = {
				{
					amount = "600",
					grant = { amount = 600, currency = "fragments" },
					icon = "diamond",
					label = "Fragments",
				},
				{
					amount = "30,000",
					grant = { amount = 30000, currency = "beli" },
					icon = "coin",
					label = "Beli",
				},
				{ amount = "1", icon = "flame", label = "Epic Emberstaff" },
				{ amount = "3", icon = "arrow-progress-up", label = "2x XP (30m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "LIMITED",
			timer = 172800,
			title = "Welcome Bundle",
			tone = "Yellow",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "hashtag",
									id = "damageNumbers",
									label = "Damage Numbers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
								{
									default = true,
									icon = "stackable-star",
									id = "autoEquipBest",
									label = "Auto-equip Best",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Attack", gamepad = "ButtonR2", key = "MouseButton1" },
								{ action = "Block", gamepad = "ButtonL2", key = "MouseButton2" },
								{ action = "Interact", gamepad = "ButtonX", key = "E" },
								{ action = "Dodge", gamepad = "ButtonB", key = "Q" },
								{ action = "Ability 1", gamepad = "ButtonL1", key = "One" },
								{ action = "Ability 2", gamepad = "ButtonR1", key = "Two" },
								{ action = "Ability 3", gamepad = "ButtonY", key = "Three" },
								{ action = "Sprint", gamepad = "ButtonL3", key = "LeftShift" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Arena",
		buttonStyle = "Glossy3D",
		clickFx = "Squash",
		corner = "Soft",
		decoration = "Sparkles",
		density = "Compact",
		fonts = { body = "BuilderSans:Bold", display = "LuckiestGuy", title = "LuckiestGuy" },
		iconStyle = "Filled",
		palette = {
			accent = "#FFE66D",
			bad = "#FF6B6B",
			good = "#4BE3A0",
			primary = "#1FC8DB",
			secondary = "#FF9E57",
			surface = "#0E2A47",
			text = "#EFFBFF",
			warn = "#FFC75F",
		},
		panelStyle = "Glass",
		rarityScheme = "Arena",
		shadow = "Soft",
		textCase = "Normal",
		transition = "Rise",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="65">
              <Properties>
                <string name="Name">rpg_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: rpg, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD21F",
			format = "abbrev",
			icon = "coin",
			id = "beli",
			name = "Beli",
			source = { kind = "leaderstat", name = "Beli" },
			start = 55000,
		},
		{
			color = "#B45CFF",
			format = "comma",
			icon = "diamond",
			id = "fragments",
			name = "Fragments",
			source = { kind = "attribute", name = "Fragments" },
			start = 1500,
		},
	},
	hud = {
		elements = {
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopRight",
				currencies = { "beli", "fragments" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "Right",
				id = "hud_questtracker",
				tasks = {
					{
						goal = 15,
						icon = "sword",
						progress = 15,
						reward = { amount = "975", icon = "coin" },
						text = "Slime Trouble",
					},
					{
						goal = 1,
						icon = "flame",
						progress = 0,
						reward = { amount = "525", icon = "coin" },
						text = "The Dragon's Lair",
					},
				},
				title = "Quests",
				type = "QuestTracker",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				id = "hud_healthbar",
				max = 250,
				source = { kind = "humanoid" },
				type = "HealthBar",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				slots = {
					{ icon = "arm", name = "Combat" },
					{ icon = "sword", name = "Katana" },
					{ icon = "flame", name = "Flame Fruit" },
					{ icon = "crosshairs", name = "Flintlock" },
				},
				type = "Hotbar",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_xpbar",
				level = 48,
				progress = 0.61,
				type = "XPBar",
				visible = true,
			},
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Toggle", target = "party" },
						icon = "friends",
						id = "btn_party",
						label = "Party",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "character" },
						icon = "player",
						id = "btn_character",
						label = "Character",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Merchant",
						tone = "Cyan",
					},
					{
						action = { kind = "Toggle", target = "inventory" },
						icon = "backpack",
						id = "btn_inventory",
						label = "Inventory",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "worlds" },
						icon = "map",
						id = "btn_worlds",
						label = "World Map",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "skills" },
						icon = "upgrade",
						id = "btn_skills",
						label = "Talents",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Quest Log",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonX", key = "E", text = "Interact" },
					{ gamepad = "ButtonB", key = "Q", text = "Dodge" },
					{ gamepad = "ButtonL1", key = "One", text = "Ability" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Attack" },
						icon = "sword",
						id = "touch_attack",
						label = "Attack",
					},
					{
						action = { kind = "Event", name = "Dodge" },
						icon = "double-chevron-large-right",
						id = "touch_dodge",
						label = "Dodge",
					},
					{
						action = { kind = "Event", name = "Ability" },
						icon = "flame",
						id = "touch_skill",
						label = "Skill",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				anchor = "TopCenter",
				heading = 255,
				id = "hud_compass",
				marker = 290,
				type = "Compass",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				icon = "lightning-bolt",
				id = "hud_meter",
				label = "ENERGY",
				low = 0.2,
				segments = 10,
				style = "Segments",
				tone = "Blue",
				type = "Meter",
				value = 0.94,
				visible = true,
			},
		},
		kit = "Storybook",
		layout = "Classic",
	},
	logo = { icon = "flame", text = "DEVIL VOYAGE", tilt = -4, tone = "Cyan", type = "Emblem" },
	meta = {
		flavor = "fruit",
		genre = "rpg",
		kit = "Storybook",
		prompt = "a rpg game",
		seed = 7,
		source = "offline",
		title = "Devil Voyage",
	},
	screens = {
		{
			bannerPosition = "Bottom",
			description = "The title screen: the Devil Voyage logo, CONTINUE, Party, Character, Inventory, Merchant and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Split",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "CONTINUE",
							tone = "Cyan",
						},
						{
							action = { kind = "Open", target = "party" },
							icon = "friends",
							id = "menu_party",
							label = "Party",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "character" },
							icon = "player",
							id = "menu_character",
							label = "Character",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "inventory" },
							icon = "backpack",
							id = "menu_inventory",
							label = "Inventory",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Merchant",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Close", target = "menu" }, label = "Play Now" },
					icon = "flame",
					id = "fruit_rain",
					kind = "Banner",
					subtitle = "Rare fruits spawn x2",
					timer = 691200,
					title = "Fruit Rain",
					tone = "Yellow",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Invite friends into your party and pick a mode together; the party leader's pick drives matchmaking.",
			icon = "friends",
			id = "party",
			kind = "Window",
			nav = "None",
			openKey = "P",
			sections = {
				{ id = "party_2", kind = "Party", slots = 4 },
				{
					buttons = {
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "trophy",
							id = "mode_ranked",
							label = "Ranked",
							tone = "Cyan",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "gear",
							id = "mode_custom",
							label = "Custom",
							tone = "Dark",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "play",
							id = "mode_quick_match",
							label = "Quick Match",
							tone = "Dark",
						},
					},
					id = "modes",
					kind = "Buttons",
					layout = "Grid",
					title = "Modes",
				},
			},
			size = "Medium",
			title = "Party",
		},
		{
			description = "Who you are: level, class, headline stats and badges, plus the four equipment slots with their stat bars.",
			icon = "player",
			id = "character",
			kind = "Window",
			nav = "None",
			openKey = "C",
			sections = {
				{
					badges = {
						{ icon = "flame", label = "Dragonslayer" },
						{ icon = "compass", label = "Explorer" },
						{ icon = "book-open", label = "Lorekeeper" },
						{ icon = "crown", label = "Raid Leader" },
					},
					id = "profile",
					kind = "Profile",
					level = 37,
					name = "ItsWaffleKnight",
					stats = {
						{ icon = "arrow-progress-up", label = "Level", value = "37" },
						{ icon = "sword", label = "Class", value = "Spellblade" },
						{ icon = "flame", label = "Bosses Slain", value = "58" },
						{ icon = "coin", label = "Gold", value = "48,250" },
						{ icon = "shield", label = "Guild", value = "Emberguard" },
					},
				},
				{
					id = "loadout",
					kind = "Loadout",
					slots = {
						{
							icon = "sword",
							name = "Frost Blade",
							rarity = "Rare",
							stats = {
								{ label = "Attack", value = 0.5 },
								{ label = "Defence", value = 0.85 },
								{ label = "Speed", value = 0.7 },
								{ label = "Magic", value = 0.48 },
							},
						},
						{
							icon = "shield-check",
							name = "Dragonscale Armor",
							rarity = "Legendary",
							stats = {
								{ label = "Attack", value = 0.98 },
								{ label = "Defence", value = 0.71 },
								{ label = "Speed", value = 0.88 },
								{ label = "Magic", value = 0.98 },
							},
						},
						{
							icon = "hat-toque",
							name = "Hunter's Cap",
							rarity = "Uncommon",
							stats = {
								{ label = "Attack", value = 0.43 },
								{ label = "Defence", value = 0.47 },
								{ label = "Speed", value = 0.38 },
								{ label = "Magic", value = 0.73 },
							},
						},
						{
							icon = "diamond",
							name = "Gemstone Ring",
							rarity = "Epic",
							stats = {
								{ label = "Attack", value = 0.83 },
								{ label = "Defence", value = 0.7 },
								{ label = "Speed", value = 0.83 },
								{ label = "Magic", value = 0.74 },
							},
						},
					},
				},
				{
					buttons = {
						{
							action = { kind = "Toggle", target = "leaderboard" },
							icon = "trophy",
							id = "goto_leaderboard",
							label = "Rankings",
							tone = "Dark",
						},
					},
					id = "buttons_2",
					kind = "Buttons",
					layout = "Row",
				},
			},
			size = "Large",
			title = "Character",
		},
		{
			description = "Where Beli and Robux turn into progress. Tabs: Wares, Chests, Fragments and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					icon = "backpack",
					id = "tab_wares",
					label = "Wares",
					sections = {
						{
							id = "products",
							items = {
								{
									currency = "beli",
									icon = "heart",
									id = "buy_health_potion",
									price = 1000,
									title = "Health Potion",
									tone = "Cyan",
								},
								{
									currency = "beli",
									icon = "lab-beaker",
									id = "buy_mana_potion",
									price = 900,
									title = "Mana Potion",
									tone = "Orange",
								},
								{
									currency = "beli",
									icon = "flame",
									id = "buy_phoenix_down",
									price = 3250,
									title = "Phoenix Down",
									tone = "Yellow",
								},
								{
									currency = "beli",
									icon = "location-pin-map",
									id = "buy_teleport_scroll",
									price = 1500,
									title = "Teleport Scroll",
									tone = "Cyan",
								},
								{
									currency = "beli",
									icon = "diamond",
									id = "buy_whetstone",
									price = 1750,
									title = "Whetstone",
									tone = "Orange",
								},
								{
									currency = "beli",
									icon = "star",
									id = "buy_lucky_clover",
									price = 2750,
									title = "Lucky Clover",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Cards",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_chests",
					label = "Chests",
					sections = {
						{
							cases = {
								{
									currency = "beli",
									icon = "file-box",
									id = "case_wooden_chest",
									pool = {
										{ chance = 28, icon = "heart", name = "Health Potion", rarity = "Common" },
										{ chance = 28, icon = "sword", name = "Iron Sword", rarity = "Common" },
										{ chance = 13, icon = "hat-toque", name = "Hunter's Cap", rarity = "Uncommon" },
										{ chance = 13, icon = "shoe", name = "Leather Boots", rarity = "Uncommon" },
										{ chance = 6.2, icon = "book-closed", name = "Ancient Tome", rarity = "Rare" },
										{ chance = 6.2, icon = "sword", name = "Frost Blade", rarity = "Rare" },
										{ chance = 2.8, icon = "flame", name = "Emberstaff", rarity = "Epic" },
										{ chance = 2.8, icon = "diamond", name = "Gemstone Ring", rarity = "Epic" },
									},
									price = 575,
									title = "Wooden Chest",
									tone = "Orange",
								},
								{
									currency = "beli",
									icon = "file-box",
									id = "case_iron_chest",
									pool = {
										{ chance = 29.2, icon = "hat-toque", name = "Hunter's Cap", rarity = "Uncommon" },
										{ chance = 28, icon = "shoe", name = "Leather Boots", rarity = "Uncommon" },
										{ chance = 13, icon = "book-closed", name = "Ancient Tome", rarity = "Rare" },
										{ chance = 13, icon = "sword", name = "Frost Blade", rarity = "Rare" },
										{ chance = 6.2, icon = "flame", name = "Emberstaff", rarity = "Epic" },
										{ chance = 6.2, icon = "diamond", name = "Gemstone Ring", rarity = "Epic" },
										{ chance = 2.2, icon = "crown", name = "Crown of Ages", rarity = "Legendary" },
										{ chance = 2.2, icon = "shield-check", name = "Dragonscale Armor", rarity = "Legendary" },
									},
									price = 1750,
									title = "Iron Chest",
									tone = "Gray",
								},
								{
									currency = "beli",
									icon = "crown",
									id = "case_royal_chest",
									pool = {
										{ chance = 29.8, icon = "book-closed", name = "Ancient Tome", rarity = "Rare" },
										{ chance = 30, icon = "sword", name = "Frost Blade", rarity = "Rare" },
										{ chance = 14, icon = "flame", name = "Emberstaff", rarity = "Epic" },
										{ chance = 14, icon = "diamond", name = "Gemstone Ring", rarity = "Epic" },
										{ chance = 5, icon = "crown", name = "Crown of Ages", rarity = "Legendary" },
										{ chance = 5, icon = "shield-check", name = "Dragonscale Armor", rarity = "Legendary" },
										{ chance = 1.1, icon = "flame", name = "Phoenix Feather", rarity = "Mythic" },
										{ chance = 1.1, icon = "necklace", name = "Void Amulet", rarity = "Mythic" },
									},
									price = 5250,
									title = "Royal Chest",
									tone = "Yellow",
								},
								{
									currency = "beli",
									icon = "flame",
									id = "case_dragon_chest",
									pool = {
										{ chance = 29.8, icon = "book-closed", name = "Ancient Tome", rarity = "Rare" },
										{ chance = 30, icon = "sword", name = "Frost Blade", rarity = "Rare" },
										{ chance = 14, icon = "flame", name = "Emberstaff", rarity = "Epic" },
										{ chance = 14, icon = "diamond", name = "Gemstone Ring", rarity = "Epic" },
										{ chance = 5, icon = "crown", name = "Crown of Ages", rarity = "Legendary" },
										{ chance = 5, icon = "shield-check", name = "Dragonscale Armor", rarity = "Legendary" },
										{ chance = 1.1, icon = "flame", name = "Phoenix Feather", rarity = "Mythic" },
										{ chance = 1.1, icon = "necklace", name = "Void Amulet", rarity = "Mythic" },
									},
									price = 12500,
									title = "Dragon Chest",
									tone = "Red",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "diamond",
					id = "tab_fragments",
					label = "Fragments",
					sections = {
						{
							id = "fragment_bundles",
							items = {
								{
									amount = "150",
									currency = "robux",
									grant = { amount = 150, currency = "fragments" },
									icon = "diamond",
									id = "fragments_pack_1",
									price = 49,
									title = "Starter Fragments",
									tone = "Orange",
								},
								{
									amount = "350",
									bonus = 15,
									currency = "robux",
									grant = { amount = 350, currency = "fragments" },
									icon = "diamond",
									id = "fragments_pack_2",
									price = 99,
									tag = "POPULAR",
									title = "Small Fragment Pack",
									tone = "Orange",
								},
								{
									amount = "750",
									bonus = 23,
									currency = "robux",
									grant = { amount = 750, currency = "fragments" },
									icon = "diamond",
									id = "fragments_pack_3",
									price = 199,
									title = "Big Fragment Pack",
									tone = "Cyan",
								},
								{
									amount = "1,750",
									bonus = 43,
									currency = "robux",
									grant = { amount = 1750, currency = "fragments" },
									icon = "file-box",
									id = "fragments_pack_4",
									price = 399,
									tag = "BEST VALUE",
									title = "Mega Fragment Pack",
									tone = "Cyan",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Fragment Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Level up twice as fast, forever",
									icon = "arrow-progress-up",
									id = "pass_2x_xp",
									price = 399,
									tag = "POPULAR",
									title = "2x XP",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "A loyal companion at your side",
									icon = "parrot",
									id = "pass_griffin_mount",
									price = 399,
									title = "Griffin Mount",
									tone = "Cyan",
								},
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Beli",
									icon = "crown",
									id = "pass_vip",
									price = 399,
									title = "VIP",
									tone = "Cyan",
								},
								{
									currency = "robux",
									description = "More room for everything you find",
									icon = "backpack",
									id = "pass_extra_backpack",
									price = 199,
									title = "Extra Backpack",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "Skip the walk: teleport anywhere",
									icon = "location-pin-map",
									id = "pass_fast_travel",
									price = 199,
									title = "Fast Travel",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Carousel",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Merchant",
		},
		{
			description = "Every item the player owns in one grid, rarest first, with a detail panel for equipping.",
			icon = "backpack",
			id = "inventory",
			kind = "Window",
			nav = "SideTabs",
			openKey = "G",
			size = "Large",
			tabs = {
				{
					icon = "backpack",
					id = "tab_inventory",
					label = "Inventory",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory_2",
							items = {
								{
									equipped = true,
									icon = "necklace",
									id = "inv_void_amulet",
									name = "Void Amulet",
									rarity = "Mythic",
								},
								{ icon = "crown", id = "inv_crown_of_ages", name = "Crown of Ages", rarity = "Legendary" },
								{ icon = "flame", id = "inv_emberstaff", name = "Emberstaff", rarity = "Epic" },
								{ icon = "diamond", id = "inv_gemstone_ring", name = "Gemstone Ring", rarity = "Epic" },
								{ icon = "lightning-bolt", id = "inv_storm_bow", name = "Storm Bow", rarity = "Epic" },
								{ icon = "book-closed", id = "inv_ancient_tome", name = "Ancient Tome", rarity = "Rare" },
								{ icon = "sword", id = "inv_frost_blade", name = "Frost Blade", rarity = "Rare" },
								{
									count = 15,
									icon = "hat-toque",
									id = "inv_hunter_s_cap",
									name = "Hunter's Cap",
									rarity = "Uncommon",
								},
								{
									count = 23,
									icon = "shoe",
									id = "inv_leather_boots",
									name = "Leather Boots",
									rarity = "Uncommon",
								},
								{
									count = 18,
									icon = "lab-beaker",
									id = "inv_mana_potion",
									name = "Mana Potion",
									rarity = "Uncommon",
								},
								{
									count = 17,
									icon = "heart",
									id = "inv_health_potion",
									name = "Health Potion",
									rarity = "Common",
								},
								{ count = 4, icon = "sword", id = "inv_iron_sword", name = "Iron Sword", rarity = "Common" },
								{
									count = 13,
									icon = "shield",
									id = "inv_oak_shield",
									name = "Oak Shield",
									rarity = "Common",
								},
								{
									icon = "shield-check",
									id = "locked_dragonscale_armor",
									locked = true,
									name = "Dragonscale Armor",
									rarity = "Legendary",
								},
							},
							kind = "Inventory",
						},
					},
				},
				{
					icon = "book-open",
					id = "tab_bestiary",
					label = "Bestiary",
					sections = {
						{
							groupBy = "World",
							groups = {
								{
									icon = "wheat",
									id = "greenvale",
									items = {
										{
											icon = "heart",
											id = "dex_health_potion",
											name = "Health Potion",
											owned = true,
											rarity = "Common",
										},
										{
											icon = "shoe",
											id = "dex_leather_boots",
											name = "Leather Boots",
											owned = true,
											rarity = "Uncommon",
										},
										{
											icon = "flame",
											id = "dex_emberstaff",
											name = "Emberstaff",
											owned = false,
											rarity = "Epic",
										},
										{
											icon = "shield-check",
											id = "dex_dragonscale_armor",
											name = "Dragonscale Armor",
											owned = false,
											rarity = "Legendary",
										},
									},
									name = "Greenvale",
								},
								{
									icon = "flag",
									id = "king_s_road",
									items = {
										{
											icon = "sword",
											id = "dex_iron_sword",
											name = "Iron Sword",
											owned = true,
											rarity = "Common",
										},
										{
											icon = "lab-beaker",
											id = "dex_mana_potion",
											name = "Mana Potion",
											owned = false,
											rarity = "Uncommon",
										},
										{
											icon = "diamond",
											id = "dex_gemstone_ring",
											name = "Gemstone Ring",
											owned = false,
											rarity = "Epic",
										},
										{
											icon = "flame",
											id = "dex_phoenix_feather",
											name = "Phoenix Feather",
											owned = false,
											rarity = "Mythic",
										},
									},
									name = "King's Road",
								},
								{
									icon = "moon",
									id = "sunken_crypt",
									items = {
										{
											icon = "shield",
											id = "dex_oak_shield",
											name = "Oak Shield",
											owned = true,
											rarity = "Common",
										},
										{
											icon = "book-closed",
											id = "dex_ancient_tome",
											name = "Ancient Tome",
											owned = true,
											rarity = "Rare",
										},
										{
											icon = "lightning-bolt",
											id = "dex_storm_bow",
											name = "Storm Bow",
											owned = true,
											rarity = "Epic",
										},
										{
											icon = "necklace",
											id = "dex_void_amulet",
											name = "Void Amulet",
											owned = false,
											rarity = "Mythic",
										},
									},
									name = "Sunken Crypt",
								},
								{
									icon = "flame",
									id = "emberpeak",
									items = {
										{
											icon = "hat-toque",
											id = "dex_hunter_s_cap",
											name = "Hunter's Cap",
											owned = true,
											rarity = "Uncommon",
										},
										{
											icon = "sword",
											id = "dex_frost_blade",
											name = "Frost Blade",
											owned = false,
											rarity = "Rare",
										},
										{
											icon = "crown",
											id = "dex_crown_of_ages",
											name = "Crown of Ages",
											owned = false,
											rarity = "Legendary",
										},
									},
									name = "Emberpeak",
								},
							},
							id = "index",
							kind = "Index",
							milestones = {
								{
									amount = "25",
									grant = { amount = 25, currency = "fragments" },
									icon = "diamond",
									percent = 25,
								},
								{
									amount = "75",
									grant = { amount = 75, currency = "fragments" },
									icon = "diamond",
									percent = 50,
								},
								{
									amount = "200",
									grant = { amount = 200, currency = "fragments" },
									icon = "diamond",
									percent = 75,
								},
								{
									amount = "500",
									grant = { amount = 500, currency = "fragments" },
									icon = "diamond",
									percent = 100,
								},
							},
						},
					},
				},
			},
			title = "Inventory",
		},
		{
			description = "The map of Devil Voyage: every area as a card, locked ones showing what it takes to get there.",
			icon = "map",
			id = "worlds",
			kind = "Window",
			nav = "None",
			openKey = "M",
			sections = {
				{
					id = "worlds_2",
					kind = "Worlds",
					worlds = {
						{ icon = "wheat", id = "world_greenvale", locked = false, name = "Greenvale", tone = "Cyan" },
						{
							icon = "flag",
							id = "world_king_s_road",
							locked = false,
							name = "King's Road",
							tone = "Orange",
						},
						{
							icon = "moon",
							id = "world_sunken_crypt",
							locked = true,
							name = "Sunken Crypt",
							requirement = "Level 20",
							tone = "Yellow",
						},
						{
							icon = "flame",
							id = "world_emberpeak",
							locked = true,
							name = "Emberpeak",
							requirement = "Level 30",
							tone = "Cyan",
						},
						{
							icon = "cloud",
							id = "world_frosthold",
							locked = true,
							name = "Frosthold",
							requirement = "Level 40",
							tone = "Orange",
						},
						{
							icon = "star",
							id = "world_skyreach",
							locked = true,
							name = "Skyreach",
							requirement = "Level 50",
							tone = "Yellow",
						},
						{
							icon = "nebula",
							id = "world_the_void",
							locked = true,
							name = "The Void",
							requirement = "Level 60",
							tone = "Cyan",
						},
					},
				},
			},
			size = "Large",
			title = "World Map",
		},
		{
			description = "Spend skill points on permanent perks. Points come from levelling up, so this screen gives levels meaning.",
			icon = "upgrade",
			id = "skills",
			kind = "Window",
			nav = "None",
			openKey = "K",
			sections = {
				{
					id = "stats",
					kind = "Stats",
					stats = { { icon = "star", label = "Skill Points", value = "6" } },
				},
				{
					id = "upgrades",
					kind = "Upgrades",
					upgrades = {
						{
							cost = 17500,
							currency = "beli",
							effect = "+80% melee damage",
							icon = "sword",
							id = "upgrade_power_strike",
							level = 7,
							maxLevel = 25,
							title = "Power Strike",
						},
						{
							cost = 700,
							currency = "beli",
							effect = "+2 max mana",
							icon = "lab-beaker",
							id = "upgrade_arcane_mind",
							level = 0,
							maxLevel = 20,
							title = "Arcane Mind",
						},
						{
							cost = 2000,
							currency = "beli",
							effect = "+4% loot luck",
							icon = "diamond",
							id = "upgrade_treasure_sense",
							level = 1,
							maxLevel = 10,
							title = "Treasure Sense",
						},
						{
							cost = 8500,
							currency = "beli",
							effect = "+4% move speed",
							icon = "run",
							id = "upgrade_swift_step",
							level = 3,
							maxLevel = 10,
							title = "Swift Step",
						},
					},
				},
			},
			size = "Medium",
			title = "Talents",
		},
		{
			description = "Daily and weekly quests with progress bars; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "SideTabs",
			openKey = "L",
			size = "Large",
			tabs = {
				{
					badge = 1,
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Gather 15 moonpetals",
									goal = 15,
									icon = "wheat",
									id = "quest_herbalist",
									progress = 15,
									reward = { amount = "25", icon = "diamond" },
									title = "Herbalist",
								},
								{
									description = "Clear 8 dungeon floors",
									goal = 8,
									icon = "door",
									id = "quest_deep_delve",
									progress = 6,
									reward = { amount = "25", icon = "diamond" },
									title = "Deep Delve",
								},
								{
									description = "Defeat 9 bandits on the King's Road",
									goal = 9,
									icon = "shield",
									id = "quest_bandit_bounty",
									progress = 1,
									reward = { amount = "50", icon = "diamond" },
									title = "Bandit Bounty",
								},
							},
						},
					},
				},
				{
					icon = "calendar",
					id = "tab_weekly",
					label = "Weekly",
					sections = {
						{
							id = "quests_3",
							kind = "Quests",
							quests = {
								{
									description = "Defeat Ignaroth the Ancient",
									goal = 1,
									icon = "flame",
									id = "quest_the_dragon_s_lair",
									progress = 0,
									reward = { amount = "400", icon = "coin" },
									title = "The Dragon's Lair",
								},
							},
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_achievements",
					label = "Achievements",
					sections = {
						{
							achievements = {
								{
									description = "Complete your first quest",
									goal = 1,
									icon = "quest",
									id = "ach_adventurer",
									points = 5,
									progress = 1,
									reward = { amount = "5", grant = { amount = 5, currency = "fragments" }, icon = "diamond" },
									title = "Adventurer",
								},
								{
									description = "Visit 6 regions",
									goal = 6,
									icon = "map",
									id = "ach_explorer",
									points = 25,
									progress = 1,
									reward = { amount = "20", grant = { amount = 20, currency = "fragments" }, icon = "diamond" },
									title = "Explorer",
								},
								{
									description = "Complete 100 quests",
									goal = 100,
									icon = "quest",
									id = "ach_hero",
									points = 30,
									progress = 66,
									reward = { amount = "25", grant = { amount = 25, currency = "fragments" }, icon = "diamond" },
									title = "Hero",
								},
								{
									description = "Discover 30 creatures",
									goal = 30,
									icon = "book-open",
									id = "ach_bestiary",
									points = 35,
									progress = 0,
									reward = { amount = "30", grant = { amount = 30, currency = "fragments" }, icon = "diamond" },
									title = "Bestiary",
								},
								{
									description = "Find a Legendary item",
									goal = 1,
									icon = "crown",
									id = "ach_legendary_loot",
									points = 40,
									progress = 0,
									reward = { amount = "40", grant = { amount = 40, currency = "fragments" }, icon = "diamond" },
									title = "Legendary Loot",
								},
								{
									description = "Reach level 50",
									goal = 50,
									icon = "level",
									id = "ach_master",
									points = 50,
									progress = 0,
									reward = { amount = "50", grant = { amount = 50, currency = "fragments" }, icon = "diamond" },
									title = "Master",
								},
								{
									description = "Defeat a world boss",
									goal = 1,
									icon = "flame",
									id = "ach_dragon_slayer",
									points = 60,
									progress = 0,
									reward = { amount = "55", grant = { amount = 55, currency = "fragments" }, icon = "diamond" },
									title = "Dragon Slayer",
								},
							},
							id = "achievements",
							kind = "Achievements",
						},
					},
				},
			},
			title = "Quest Log",
		},
		{
			description = "Global top ten for Level, Gold and Bosses Slain, with podium colours for the top three.",
			icon = "trophy",
			id = "leaderboard",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "trophy",
					id = "tab_level",
					label = "Level",
					sections = {
						{
							id = "leaderboard_2",
							kind = "Leaderboard",
							rows = {
								{ name = "NotFrostDragon", rank = 1, value = "91" },
								{ name = "xXShadowLlamaXx", rank = 2, value = "82" },
								{ name = "xXNovaSlayerXx", rank = 3, value = "76" },
								{ name = "ShadowLlama", rank = 4, value = "62" },
								{ name = "xXGhostYetiXx", rank = 5, value = "52" },
								{ name = "Cookie_Blade", rank = 6, value = "38" },
								{ name = "NotNeonSpark", rank = 7, value = "35" },
								{ name = "EmberOtterYT", rank = 8, value = "29" },
								{ name = "RealCosmicGoblin", rank = 9, value = "23" },
								{ name = "BeanSpark", rank = 10, value = "17" },
							},
							stat = "Level",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_gold",
					label = "Gold",
					sections = {
						{
							id = "leaderboard_3",
							kind = "Leaderboard",
							rows = {
								{ name = "DoodleRider", rank = 1, value = "1.2M" },
								{ name = "ArcticWizard88", rank = 2, value = "962K" },
								{ name = "NotViperYeti", rank = 3, value = "864K" },
								{ name = "CosmicCat", rank = 4, value = "649K" },
								{ name = "RealTacoHunter", rank = 5, value = "612K" },
								{ name = "RealBubbleBunny", rank = 6, value = "507K" },
								{ name = "RoguePanda", rank = 7, value = "465K" },
								{ name = "xXDoodleLlamaXx", rank = 8, value = "436K" },
								{ name = "Bubble_Duck", rank = 9, value = "334K" },
								{ name = "Pickle_Yeti", rank = 10, value = "291K" },
							},
							stat = "Gold",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_bosses_slain",
					label = "Bosses Slain",
					sections = {
						{
							id = "leaderboard_4",
							kind = "Leaderboard",
							rows = {
								{ name = "RogueCatYT", rank = 1, value = "624" },
								{ name = "RealPickleYeti", rank = 2, value = "461" },
								{ name = "KiwiRanger", rank = 3, value = "410" },
								{ name = "CometSpark", rank = 4, value = "311" },
								{ name = "Waffle_Ace", rank = 5, value = "238" },
								{ name = "ItsArcticChef", rank = 6, value = "217" },
								{ name = "WobblyBot27", rank = 7, value = "188" },
								{ name = "TurboWolf", rank = 8, value = "146" },
								{ name = "PepperSprout", rank = 9, value = "116" },
								{ name = "xXLunaPandaXx", rank = 10, value = "95" },
							},
							stat = "Bosses Slain",
						},
					},
				},
			},
			title = "Rankings",
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "hashtag",
									id = "damageNumbers",
									label = "Damage Numbers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
								{
									default = true,
									icon = "stackable-star",
									id = "autoEquipBest",
									label = "Auto-equip Best",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Attack", gamepad = "ButtonR2", key = "MouseButton1" },
								{ action = "Block", gamepad = "ButtonL2", key = "MouseButton2" },
								{ action = "Interact", gamepad = "ButtonX", key = "E" },
								{ action = "Dodge", gamepad = "ButtonB", key = "Q" },
								{ action = "Ability 1", gamepad = "ButtonL1", key = "One" },
								{ action = "Ability 2", gamepad = "ButtonR1", key = "Two" },
								{ action = "Ability 3", gamepad = "ButtonY", key = "Three" },
								{ action = "Sprint", gamepad = "ButtonL3", key = "LeftShift" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Adventure",
		buttonStyle = "Glossy3D",
		clickFx = "Glow",
		corner = "Round",
		decoration = "Sparkles",
		density = "Compact",
		fonts = { body = "BuilderSans:Bold", display = "Bangers", title = "Bangers" },
		iconStyle = "Filled",
		palette = {
			accent = "#FFE66D",
			bad = "#FF6B6B",
			good = "#4BE3A0",
			primary = "#1FC8DB",
			secondary = "#FF9E57",
			surface = "#0E2A47",
			text = "#EFFBFF",
			warn = "#FFC75F",
		},
		panelStyle = "Gradient",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Zoom",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="66">
              <Properties>
                <string name="Name">scifi_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: scifi, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#C9A26B",
			format = "comma",
			icon = "gear",
			id = "scrap",
			name = "Scrap",
			source = { kind = "leaderstat", name = "Scrap" },
			start = 1250,
		},
		{
			color = "#B45CFF",
			format = "comma",
			icon = "diamond",
			id = "crystals",
			name = "Crystals",
			source = { kind = "attribute", name = "Crystals" },
			start = 225,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopLeft",
				id = "hud_objective",
				text = "Hold the reactor",
				type = "Objective",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopRight",
				currencies = { "scrap", "crystals" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{ anchor = "TopRight", id = "hud_minimap", shape = "Round", type = "Minimap", visible = true },
			{ anchor = "Center", id = "hud_crosshair", style = "Dot", type = "Crosshair", visible = true },
			{
				anchor = "BottomLeft",
				id = "hud_healthbar",
				max = 250,
				source = { kind = "humanoid" },
				type = "HealthBar",
				visible = true,
			},
			{ anchor = "BottomLeft", id = "hud_stamina", type = "Stamina", visible = true },
			{
				anchor = "Right",
				boosts = {
					{ icon = "lightning-bolt", seconds = 210, text = "Overdrive" },
					{ icon = "coin", seconds = 1742, text = "2x Scrap" },
				},
				id = "hud_boosts",
				type = "Boosts",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				look = "Slots",
				slots = {
					{ ammo = 40, icon = "lightning-bolt", name = "Pulse Rifle", reserve = 200 },
					{ ammo = 6, icon = "flame", name = "Plasma Cannon", reserve = 18 },
					{ icon = "shield-check", name = "Shield Drone" },
					{ icon = "plus", name = "Repair Kit" },
				},
				type = "Hotbar",
				visible = true,
			},
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Toggle", target = "loadout" },
						icon = "shield-check",
						id = "btn_loadout",
						label = "Arsenal",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Shop",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Missions",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "worlds" },
						icon = "map",
						id = "btn_worlds",
						label = "Star Map",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "leaderboard" },
						icon = "trophy",
						id = "btn_leaderboard",
						label = "Rankings",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonL3", key = "LeftShift", text = "Boost" },
					{ gamepad = "ButtonY", key = "Q", text = "Scan" },
					{ gamepad = "ButtonX", key = "R", text = "Reload" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Fire" },
						icon = "crosshairs",
						id = "touch_fire",
						label = "Fire",
					},
					{
						action = { kind = "Event", name = "Boost" },
						icon = "double-chevron-large-up",
						id = "touch_boost",
						label = "Boost",
					},
					{
						action = { kind = "Event", name = "Scan" },
						icon = "magnifying-glass",
						id = "touch_scan",
						label = "Scan",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				anchor = "TopCenter",
				heading = 96,
				id = "hud_compass",
				marker = 56,
				type = "Compass",
				visible = true,
			},
			{
				anchor = "Right",
				icon = "flame",
				id = "hud_meter",
				label = "HEAT",
				segments = 5,
				style = "Dial",
				tone = "Red",
				type = "Meter",
				value = 0.77,
				visible = true,
			},
		},
		kit = "Tactical",
		layout = "Corners",
	},
	logo = { icon = "nebula", text = "STEEL FRAME", tilt = 0, tone = "Yellow", type = "Emblem" },
	meta = {
		flavor = "mech",
		genre = "scifi",
		kit = "Tactical",
		prompt = "a scifi game",
		seed = 21,
		source = "offline",
		title = "Steel Frame",
	},
	screens = {
		{
			bannerPosition = "Corner",
			description = "The title screen: the Steel Frame logo, DEPLOY, Arsenal, Shop, Star Map, Missions and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Corner",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "DEPLOY",
							tone = "Yellow",
						},
						{
							action = { kind = "Open", target = "loadout" },
							icon = "shield-check",
							id = "menu_loadout",
							label = "Arsenal",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "worlds" },
							icon = "map",
							id = "menu_worlds",
							label = "Star Map",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "quests" },
							icon = "quest",
							id = "menu_quests",
							label = "Missions",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "sword",
					id = "welcome_bundle",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 172800,
					title = "Welcome Bundle",
					tone = "Red",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Build the kit you take into a match: 4 slots, each a big card with stat bars so trade-offs are obvious.",
			icon = "shield-check",
			id = "loadout",
			kind = "Window",
			nav = "SideTabs",
			openKey = "L",
			size = "Large",
			tabs = {
				{
					icon = "shield-check",
					id = "tab_equipped",
					label = "Equipped",
					sections = {
						{
							id = "loadout_2",
							kind = "Loadout",
							slots = {
								{
									icon = "lightning-bolt",
									name = "Pulse Rifle",
									rarity = "Common",
									stats = {
										{ label = "Damage", value = 0.47 },
										{ label = "Energy", value = 0.4 },
										{ label = "Range", value = 0.56 },
										{ label = "Weight", value = 0.59 },
									},
								},
								{
									icon = "flame",
									name = "Plasma Cannon",
									rarity = "Rare",
									stats = {
										{ label = "Damage", value = 0.44 },
										{ label = "Energy", value = 0.47 },
										{ label = "Range", value = 0.63 },
										{ label = "Weight", value = 0.58 },
									},
								},
								{
									icon = "shield-check",
									name = "Shield Drone",
									rarity = "Rare",
									stats = {
										{ label = "Damage", value = 0.57 },
										{ label = "Energy", value = 0.79 },
										{ label = "Range", value = 0.77 },
										{ label = "Weight", value = 0.48 },
									},
								},
								{
									icon = "double-chevron-large-up",
									name = "Jetpack",
									rarity = "Epic",
									stats = {
										{ label = "Damage", value = 0.84 },
										{ label = "Energy", value = 0.87 },
										{ label = "Range", value = 0.65 },
										{ label = "Weight", value = 0.9 },
									},
								},
							},
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_armory",
					label = "Armory",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{ icon = "diamond", id = "inv_void_shard", name = "Void Shard", rarity = "Mythic" },
								{ icon = "nebula", id = "inv_quantum_core", name = "Quantum Core", rarity = "Legendary" },
								{
									icon = "circle-line-target-cursor",
									id = "inv_railgun",
									name = "Railgun",
									rarity = "Legendary",
								},
								{ icon = "sword", id = "inv_ion_blade", name = "Ion Blade", rarity = "Epic" },
								{
									equipped = true,
									icon = "double-chevron-large-up",
									id = "inv_jetpack",
									name = "Jetpack",
									rarity = "Epic",
								},
								{
									equipped = true,
									icon = "flame",
									id = "inv_plasma_cannon",
									name = "Plasma Cannon",
									rarity = "Rare",
								},
								{
									equipped = true,
									icon = "shield-check",
									id = "inv_shield_drone",
									name = "Shield Drone",
									rarity = "Rare",
								},
								{
									icon = "hexagon-dot",
									id = "inv_gravity_grenade",
									name = "Gravity Grenade",
									rarity = "Uncommon",
								},
								{
									equipped = true,
									icon = "lightning-bolt",
									id = "inv_pulse_rifle",
									name = "Pulse Rifle",
									rarity = "Common",
								},
								{ icon = "plus", id = "inv_repair_kit", name = "Repair Kit", rarity = "Common" },
								{ icon = "magnifying-glass", id = "inv_scanner", name = "Scanner", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
			},
			title = "Arsenal",
		},
		{
			description = "Where Scrap and Robux turn into progress. Tabs: Featured, Items, Caches and Crystals.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			openKey = "B",
			size = "Large",
			tabs = {
				{
					callout = "Refreshes daily",
					icon = "sale",
					id = "tab_featured",
					label = "Featured",
					sections = {
						{
							id = "today_s_picks",
							items = {
								{
									amount = "3,250",
									currency = "robux",
									description = "3,250 Crystals + an exclusive title",
									grant = { amount = 3250, currency = "crystals" },
									icon = "crown",
									id = "featured_mega",
									price = 999,
									tag = "LIMITED",
									title = "Mega Bundle",
									tone = "Yellow",
									was = 1999,
								},
								{
									currency = "crystals",
									description = "Leaves the shop soon",
									icon = "nebula",
									id = "featured_quantum_core",
									price = 975,
									tag = "FEATURED",
									title = "Quantum Core",
									tone = "Yellow",
								},
								{
									amount = "475",
									currency = "robux",
									description = "475 Crystals + Quantum Core",
									grant = { amount = 475, currency = "crystals" },
									icon = "gift",
									id = "featured_starter",
									price = 149,
									tag = "ONE TIME",
									title = "Newcomer Bundle",
									tone = "Blue",
									was = 299,
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Today's Picks",
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_items",
					label = "Items",
					sections = {
						{
							id = "products",
							items = {
								{
									amount = "Common",
									currency = "scrap",
									icon = "plus",
									id = "buy_repair_kit",
									price = 350,
									title = "Repair Kit",
									tone = "Gray",
								},
								{
									amount = "Rare",
									currency = "scrap",
									icon = "flame",
									id = "buy_plasma_cannon",
									price = 2500,
									title = "Plasma Cannon",
									tone = "Blue",
								},
								{
									amount = "Epic",
									currency = "scrap",
									icon = "sword",
									id = "buy_ion_blade",
									price = 6000,
									title = "Ion Blade",
									tone = "Purple",
								},
								{
									amount = "Epic",
									currency = "scrap",
									icon = "double-chevron-large-up",
									id = "buy_jetpack",
									price = 6500,
									title = "Jetpack",
									tone = "Purple",
								},
								{
									amount = "Legendary",
									currency = "scrap",
									icon = "circle-line-target-cursor",
									id = "buy_railgun",
									price = 17500,
									tag = "LIMITED",
									title = "Railgun",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Compact",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_caches",
					label = "Caches",
					sections = {
						{
							cases = {
								{
									currency = "scrap",
									icon = "file-box",
									id = "case_supply_cache",
									pool = {
										{ chance = 32.4, icon = "lightning-bolt", name = "Pulse Rifle", rarity = "Common" },
										{ chance = 32, icon = "plus", name = "Repair Kit", rarity = "Common" },
										{ chance = 15, icon = "hexagon-dot", name = "Gravity Grenade", rarity = "Uncommon" },
										{ chance = 7.1, icon = "flame", name = "Plasma Cannon", rarity = "Rare" },
										{ chance = 7.1, icon = "shield-check", name = "Shield Drone", rarity = "Rare" },
										{ chance = 3.2, icon = "sword", name = "Ion Blade", rarity = "Epic" },
										{ chance = 3.2, icon = "double-chevron-large-up", name = "Jetpack", rarity = "Epic" },
									},
									price = 425,
									title = "Supply Cache",
									tone = "Cyan",
								},
								{
									currency = "scrap",
									icon = "nebula",
									id = "case_alien_cache",
									pool = {
										{ chance = 38.6, icon = "hexagon-dot", name = "Gravity Grenade", rarity = "Uncommon" },
										{ chance = 19, icon = "flame", name = "Plasma Cannon", rarity = "Rare" },
										{ chance = 19, icon = "shield-check", name = "Shield Drone", rarity = "Rare" },
										{ chance = 8.6, icon = "sword", name = "Ion Blade", rarity = "Epic" },
										{ chance = 8.6, icon = "double-chevron-large-up", name = "Jetpack", rarity = "Epic" },
										{ chance = 3.1, icon = "nebula", name = "Quantum Core", rarity = "Legendary" },
										{
											chance = 3.1,
											icon = "circle-line-target-cursor",
											name = "Railgun",
											rarity = "Legendary",
										},
									},
									price = 1250,
									title = "Alien Cache",
									tone = "Purple",
								},
								{
									currency = "crystals",
									icon = "lightning-bolt",
									id = "case_prototype_cache",
									pool = {
										{ chance = 30.7, icon = "flame", name = "Plasma Cannon", rarity = "Rare" },
										{ chance = 30, icon = "shield-check", name = "Shield Drone", rarity = "Rare" },
										{ chance = 14, icon = "sword", name = "Ion Blade", rarity = "Epic" },
										{ chance = 14, icon = "double-chevron-large-up", name = "Jetpack", rarity = "Epic" },
										{ chance = 5.1, icon = "nebula", name = "Quantum Core", rarity = "Legendary" },
										{
											chance = 5.1,
											icon = "circle-line-target-cursor",
											name = "Railgun",
											rarity = "Legendary",
										},
										{ chance = 1.1, icon = "diamond", name = "Void Shard", rarity = "Mythic" },
									},
									price = 375,
									title = "Prototype Cache",
									tone = "Yellow",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "diamond",
					id = "tab_crystals",
					label = "Crystals",
					sections = {
						{
							id = "crystal_bundles",
							items = {
								{
									amount = "125",
									currency = "robux",
									grant = { amount = 125, currency = "crystals" },
									icon = "diamond",
									id = "crystals_pack_1",
									price = 49,
									title = "Handful of Crystals",
									tone = "Blue",
								},
								{
									amount = "300",
									bonus = 19,
									currency = "robux",
									grant = { amount = 300, currency = "crystals" },
									icon = "diamond",
									id = "crystals_pack_2",
									price = 99,
									tag = "SALE",
									title = "Pouch of Crystals",
									tone = "Blue",
									was = 149,
								},
								{
									amount = "625",
									bonus = 23,
									currency = "robux",
									grant = { amount = 625, currency = "crystals" },
									icon = "diamond",
									id = "crystals_pack_3",
									price = 199,
									tag = "POPULAR",
									title = "Sack of Crystals",
									tone = "Yellow",
								},
								{
									amount = "1,500",
									bonus = 47,
									currency = "robux",
									grant = { amount = 1500, currency = "crystals" },
									icon = "file-box",
									id = "crystals_pack_4",
									price = 399,
									title = "Chest of Crystals",
									tone = "Yellow",
								},
								{
									amount = "3,250",
									bonus = 59,
									currency = "robux",
									grant = { amount = 3250, currency = "crystals" },
									icon = "building-bank",
									id = "crystals_pack_5",
									price = 799,
									title = "Vault of Crystals",
									tone = "Red",
								},
								{
									amount = "6,750",
									bonus = 77,
									currency = "robux",
									grant = { amount = 6750, currency = "crystals" },
									icon = "crown",
									id = "crystals_pack_6",
									price = 1499,
									tag = "BEST VALUE",
									title = "Mountain of Crystals",
									tone = "Red",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Crystal Bundles",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Quest cards with progress bars under a reset countdown; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "SideTabs",
			openKey = "L",
			size = "Large",
			tabs = {
				{
					icon = "quest",
					id = "tab_missions",
					label = "Missions",
					sections = {
						{
							icon = "timer",
							id = "new_quests_in",
							kind = "Banner",
							subtitle = "Finish all quests for a bonus chest",
							timer = 39600,
							title = "New quests in",
							tone = "Blue",
						},
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Protect the freighter for 5 jumps",
									goal = 5,
									icon = "shield",
									id = "quest_escort_duty",
									progress = 5,
									reward = { amount = "675", icon = "gear" },
									title = "Escort Duty",
								},
								{
									description = "Destroy 35 drones",
									goal = 35,
									icon = "crosshairs",
									id = "quest_rogue_ai",
									progress = 0,
									reward = { amount = "25", icon = "diamond" },
									title = "Rogue AI",
								},
								{
									description = "Recover 20 tons of scrap from the belt",
									goal = 20,
									icon = "gear",
									id = "quest_salvage_run",
									progress = 3,
									reward = { amount = "1,250", icon = "gear" },
									title = "Salvage Run",
								},
								{
									description = "Scan 5 planets",
									goal = 5,
									icon = "magnifying-glass",
									id = "quest_survey",
									progress = 0,
									reward = { amount = "525", icon = "gear" },
									title = "Survey",
								},
							},
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_achievements",
					label = "Achievements",
					sections = {
						{
							achievements = {
								{
									description = "Launch your first mission",
									goal = 1,
									icon = "arrow-large-up",
									id = "ach_liftoff",
									points = 5,
									progress = 1,
									reward = { amount = "6", grant = { amount = 6, currency = "crystals" }, icon = "diamond" },
									title = "Liftoff",
								},
								{
									description = "Install 50 upgrades",
									goal = 50,
									icon = "upgrade",
									id = "ach_engineer",
									points = 20,
									progress = 43,
									reward = { amount = "20", grant = { amount = 20, currency = "crystals" }, icon = "diamond" },
									title = "Engineer",
								},
								{
									description = "Destroy 500 ships",
									goal = 500,
									icon = "crosshairs",
									id = "ach_ace",
									points = 30,
									progress = 308,
									reward = { amount = "30", grant = { amount = 30, currency = "crystals" }, icon = "diamond" },
									title = "Ace",
								},
								{
									description = "Complete 100 missions",
									goal = 100,
									icon = "quest",
									id = "ach_pilot",
									points = 30,
									progress = 0,
									reward = { amount = "35", grant = { amount = 35, currency = "crystals" }, icon = "diamond" },
									title = "Pilot",
								},
								{
									description = "Chart 10 planets",
									goal = 10,
									icon = "globe-simplified",
									id = "ach_explorer",
									points = 35,
									progress = 0,
									reward = { amount = "35", grant = { amount = 35, currency = "crystals" }, icon = "diamond" },
									title = "Explorer",
								},
								{
									description = "Reach rank 50",
									goal = 50,
									icon = "level",
									id = "ach_legend",
									points = 50,
									progress = 0,
									reward = { amount = "50", grant = { amount = 50, currency = "crystals" }, icon = "diamond" },
									title = "Legend",
								},
							},
							id = "achievements",
							kind = "Achievements",
						},
					},
				},
			},
			title = "Missions",
		},
		{
			description = "The map of Steel Frame: every area as a card, locked ones showing what it takes to get there.",
			icon = "map",
			id = "worlds",
			kind = "Window",
			nav = "None",
			openKey = "M",
			sections = {
				{
					id = "worlds_2",
					kind = "Worlds",
					worlds = {
						{
							icon = "location-pin",
							id = "world_kepler_station",
							locked = false,
							name = "Kepler Station",
							tone = "Yellow",
						},
						{
							icon = "hexagon-dot",
							id = "world_asteroid_belt",
							locked = false,
							name = "Asteroid Belt",
							tone = "Blue",
						},
						{
							icon = "cloud",
							id = "world_europa_colony",
							locked = false,
							name = "Europa Colony",
							tone = "Red",
						},
						{
							icon = "nebula",
							id = "world_the_veil_nebula",
							locked = true,
							name = "The Veil Nebula",
							requirement = "Level 30",
							tone = "Yellow",
						},
						{
							icon = "sun",
							id = "world_red_giant",
							locked = true,
							name = "Red Giant",
							requirement = "Level 40",
							tone = "Blue",
						},
						{
							icon = "moon",
							id = "world_deep_void",
							locked = true,
							name = "Deep Void",
							requirement = "Level 50",
							tone = "Red",
						},
					},
				},
			},
			size = "Large",
			title = "Star Map",
		},
		{
			description = "Global top ten for Credits Earned, Systems Explored and Drones Destroyed, with podium colours for the top three.",
			icon = "trophy",
			id = "leaderboard",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "trophy",
					id = "tab_credits_earned",
					label = "Credits Earned",
					sections = {
						{
							id = "leaderboard_2",
							kind = "Leaderboard",
							rows = {
								{ name = "LunaDuckYT", rank = 1, value = "91M" },
								{ name = "LunaToastYT", rank = 2, value = "72.8M" },
								{ name = "ItsRocketAce", rank = 3, value = "63.6M" },
								{ name = "Crimson_Dash", rank = 4, value = "49.9M" },
								{ name = "xXRocketOtterXx", rank = 5, value = "46.7M" },
								{ name = "CrimsonPilot", rank = 6, value = "33.7M" },
								{ name = "FrostKing_33", rank = 7, value = "27.8M" },
								{ name = "Pixel_Moose", rank = 8, value = "23.2M" },
								{ name = "xXEmberDuckXx", rank = 9, value = "17.2M" },
								{ name = "LunaByte", rank = 10, value = "13.1M" },
							},
							stat = "Credits Earned",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_top",
					label = "Top",
					sections = {
						{
							id = "leaderboard_3",
							kind = "Leaderboard",
							rows = {
								{ name = "Arctic27", rank = 1, value = "1.2M" },
								{ name = "ShadowRider", rank = 2, value = "895K" },
								{ name = "RealMangoSpark", rank = 3, value = "707K" },
								{ name = "RealRogueLlama", rank = 4, value = "596K" },
								{ name = "Ghost12", rank = 5, value = "454K" },
								{ name = "PickleBunnyYT", rank = 6, value = "331K" },
								{ name = "Glitch_Byte", rank = 7, value = "313K" },
								{ name = "NotEmberPanda", rank = 8, value = "295K" },
								{ name = "RealViperPilot", rank = 9, value = "219K" },
								{ name = "ArcticOtter360", rank = 10, value = "173K" },
							},
							stat = "Systems Explored",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_top_2",
					label = "Top",
					sections = {
						{
							id = "leaderboard_4",
							kind = "Leaderboard",
							rows = {
								{ name = "TurboWolf_64", rank = 1, value = "1.2B" },
								{ name = "Luna_Pilot", rank = 2, value = "1B" },
								{ name = "TacoPilotYT", rank = 3, value = "838M" },
								{ name = "BlitzPilot777", rank = 4, value = "611M" },
								{ name = "NotKiwiKing", rank = 5, value = "560M" },
								{ name = "ChaosSprout27", rank = 6, value = "487M" },
								{ name = "BubbleBunny42", rank = 7, value = "416M" },
								{ name = "LuckyOtterYT", rank = 8, value = "351M" },
								{ name = "xXNimbusPandaXx", rank = 9, value = "290M" },
								{ name = "xXNoodleMooseXx", rank = 10, value = "215M" },
							},
							stat = "Drones Destroyed",
						},
					},
				},
			},
			title = "Rankings",
		},
		{
			delay = 5,
			description = "A one-time Welcome Bundle: 4 rewards for 99 Robux (was 299), counting down 48h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "sword",
			id = "offer",
			items = {
				{
					amount = "375",
					grant = { amount = 375, currency = "crystals" },
					icon = "diamond",
					label = "Crystals",
				},
				{
					amount = "25,000",
					grant = { amount = 25000, currency = "scrap" },
					icon = "gear",
					label = "Scrap",
				},
				{ amount = "1", icon = "sword", label = "Epic Ion Blade" },
				{ amount = "3", icon = "coin", label = "2x Scrap (30m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "LIMITED",
			timer = 172800,
			title = "Welcome Bundle",
			tone = "Red",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "crosshairs",
									id = "hitMarkers",
									label = "Hit Markers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "hashtag",
									id = "damageNumbers",
									label = "Damage Numbers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Fire", gamepad = "ButtonR2", key = "MouseButton1" },
								{ action = "Aim", gamepad = "ButtonL2", key = "MouseButton2" },
								{ action = "Boost", gamepad = "ButtonL3", key = "LeftShift" },
								{ action = "Scan", gamepad = "ButtonY", key = "Q" },
								{ action = "Reload", gamepad = "ButtonX", key = "R" },
								{ action = "Jetpack", gamepad = "ButtonA", key = "Space" },
								{ action = "Map", gamepad = "ButtonSelect", key = "M" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Tactical",
		buttonStyle = "Flat",
		clickFx = "Shake",
		corner = "Sharp",
		decoration = "Stripes",
		density = "Compact",
		fonts = { body = "RobotoCondensed:Bold", display = "Oswald:Bold", title = "Oswald:SemiBold" },
		iconStyle = "Filled",
		palette = {
			accent = "#FF4D6D",
			bad = "#FF4D4D",
			good = "#3DDC84",
			primary = "#FFC21A",
			secondary = "#2E8BFF",
			surface = "#121620",
			text = "#F5F7FA",
			warn = "#FFB020",
		},
		panelStyle = "Solid",
		rarityScheme = "Arena",
		shadow = "Hard",
		textCase = "Upper",
		transition = "Rise",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="67">
              <Properties>
                <string name="Name">scifi_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: scifi, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#5CE1FF",
			format = "abbrev",
			icon = "coin",
			id = "credits",
			name = "Credits",
			source = { kind = "leaderstat", name = "Credits" },
			start = 20000,
		},
		{
			color = "#FF7AC6",
			format = "comma",
			icon = "nebula",
			id = "cores",
			name = "Cores",
			source = { kind = "attribute", name = "Cores" },
			start = 55,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopRight",
				id = "hud_objective",
				text = "Destroy the enemy walker",
				type = "Objective",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopLeft",
				currencies = { "credits", "cores" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{ anchor = "TopLeft", id = "hud_minimap", shape = "Round", type = "Minimap", visible = true },
			{ anchor = "Center", id = "hud_crosshair", style = "Dot", type = "Crosshair", visible = true },
			{
				anchor = "BottomLeft",
				id = "hud_healthbar",
				max = 200,
				source = { kind = "humanoid" },
				type = "HealthBar",
				visible = true,
			},
			{ anchor = "BottomLeft", id = "hud_stamina", type = "Stamina", visible = true },
			{
				anchor = "Right",
				boosts = {
					{ icon = "coin", seconds = 1585, text = "2x Credits" },
					{ icon = "lightning-bolt", seconds = 278, text = "Overdrive" },
				},
				id = "hud_boosts",
				type = "Boosts",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				slots = {
					{ ammo = 40, icon = "lightning-bolt", name = "Pulse Rifle", reserve = 200 },
					{ ammo = 6, icon = "flame", name = "Plasma Cannon", reserve = 18 },
					{ icon = "shield-check", name = "Shield Drone" },
					{ icon = "plus", name = "Repair Kit" },
				},
				type = "Hotbar",
				visible = true,
			},
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Toggle", target = "loadout" },
						icon = "shield-check",
						id = "btn_loadout",
						label = "Loadout",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "party" },
						icon = "friends",
						id = "btn_party",
						label = "Party",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "worlds" },
						icon = "map",
						id = "btn_worlds",
						label = "Star Map",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Shop",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "upgrades" },
						icon = "upgrade",
						id = "btn_upgrades",
						label = "Upgrades",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Contracts",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonL3", key = "LeftShift", text = "Boost" },
					{ gamepad = "ButtonY", key = "Q", text = "Scan" },
					{ gamepad = "ButtonX", key = "R", text = "Reload" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Fire" },
						icon = "crosshairs",
						id = "touch_fire",
						label = "Fire",
					},
					{
						action = { kind = "Event", name = "Boost" },
						icon = "double-chevron-large-up",
						id = "touch_boost",
						label = "Boost",
					},
					{
						action = { kind = "Event", name = "Scan" },
						icon = "magnifying-glass",
						id = "touch_scan",
						label = "Scan",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				anchor = "TopCenter",
				heading = 353,
				id = "hud_compass",
				marker = 185,
				type = "Compass",
				visible = true,
			},
			{
				anchor = "Right",
				icon = "flame",
				id = "hud_meter",
				label = "HEAT",
				segments = 5,
				style = "Dial",
				tone = "Red",
				type = "Meter",
				value = 0.73,
				visible = true,
			},
		},
		kit = "Tactical",
		layout = "Corners",
	},
	logo = { icon = "nebula", text = "TITAN LEGION", tilt = -4, tone = "Yellow", type = "Emblem" },
	meta = {
		flavor = "mech",
		genre = "scifi",
		kit = "Tactical",
		prompt = "a scifi game",
		seed = 7,
		source = "offline",
		title = "Titan Legion",
	},
	screens = {
		{
			description = "The first thing players see: the Titan Legion logo, a progress bar and rotating tips while the place streams in.",
			duration = 3,
			icon = "timer",
			id = "loading",
			kind = "Loading",
			tips = {
				"Jumping with an empty fuel cell strands you. Refuel first.",
				"Crystals power every legendary upgrade.",
				"Shields recharge only when you stop taking damage.",
				"Scrap from derelicts sells better at outer stations.",
				"Scan before you land. Some planets bite back.",
			},
			title = "Loading",
		},
		{
			bannerPosition = "Bottom",
			description = "The title screen: the Titan Legion logo, DEPLOY, Party, Loadout, Shop, Star Map and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Center",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "DEPLOY",
							tone = "Yellow",
						},
						{
							action = { kind = "Open", target = "party" },
							icon = "friends",
							id = "menu_party",
							label = "Party",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "loadout" },
							icon = "shield-check",
							id = "menu_loadout",
							label = "Loadout",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "worlds" },
							icon = "map",
							id = "menu_worlds",
							label = "Star Map",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Row",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "sword",
					id = "starter_pack",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 86400,
					title = "Starter Pack",
					tone = "Red",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Build the kit you take into a match: 4 slots, each a big card with stat bars so trade-offs are obvious.",
			icon = "shield-check",
			id = "loadout",
			kind = "Window",
			nav = "SideTabs",
			openKey = "L",
			size = "Large",
			tabs = {
				{
					icon = "shield-check",
					id = "tab_equipped",
					label = "Equipped",
					sections = {
						{
							id = "loadout_2",
							kind = "Loadout",
							slots = {
								{
									icon = "lightning-bolt",
									name = "Pulse Rifle",
									rarity = "Common",
									stats = {
										{ label = "Damage", value = 0.3 },
										{ label = "Energy", value = 0.65 },
										{ label = "Range", value = 0.5 },
										{ label = "Weight", value = 0.28 },
									},
								},
								{
									icon = "flame",
									name = "Plasma Cannon",
									rarity = "Rare",
									stats = {
										{ label = "Damage", value = 0.81 },
										{ label = "Energy", value = 0.51 },
										{ label = "Range", value = 0.68 },
										{ label = "Weight", value = 0.79 },
									},
								},
								{
									icon = "shield-check",
									name = "Shield Drone",
									rarity = "Rare",
									stats = {
										{ label = "Damage", value = 0.53 },
										{ label = "Energy", value = 0.57 },
										{ label = "Range", value = 0.48 },
										{ label = "Weight", value = 0.83 },
									},
								},
								{
									icon = "double-chevron-large-up",
									name = "Jetpack",
									rarity = "Epic",
									stats = {
										{ label = "Damage", value = 0.83 },
										{ label = "Energy", value = 0.7 },
										{ label = "Range", value = 0.83 },
										{ label = "Weight", value = 0.74 },
									},
								},
							},
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_armory",
					label = "Armory",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{ icon = "nebula", id = "inv_quantum_core", name = "Quantum Core", rarity = "Legendary" },
								{
									icon = "circle-line-target-cursor",
									id = "inv_railgun",
									name = "Railgun",
									rarity = "Legendary",
								},
								{ icon = "sword", id = "inv_ion_blade", name = "Ion Blade", rarity = "Epic" },
								{
									equipped = true,
									icon = "double-chevron-large-up",
									id = "inv_jetpack",
									name = "Jetpack",
									rarity = "Epic",
								},
								{
									equipped = true,
									icon = "flame",
									id = "inv_plasma_cannon",
									name = "Plasma Cannon",
									rarity = "Rare",
								},
								{
									equipped = true,
									icon = "shield-check",
									id = "inv_shield_drone",
									name = "Shield Drone",
									rarity = "Rare",
								},
								{
									icon = "hexagon-dot",
									id = "inv_gravity_grenade",
									name = "Gravity Grenade",
									rarity = "Uncommon",
								},
								{
									equipped = true,
									icon = "lightning-bolt",
									id = "inv_pulse_rifle",
									name = "Pulse Rifle",
									rarity = "Common",
								},
								{ icon = "plus", id = "inv_repair_kit", name = "Repair Kit", rarity = "Common" },
								{ icon = "magnifying-glass", id = "inv_scanner", name = "Scanner", rarity = "Common" },
								{
									icon = "diamond",
									id = "locked_void_shard",
									locked = true,
									name = "Void Shard",
									rarity = "Mythic",
								},
							},
							kind = "Inventory",
						},
					},
				},
			},
			title = "Loadout",
		},
		{
			description = "Invite friends into your party and pick a mode together; the party leader's pick drives matchmaking.",
			icon = "friends",
			id = "party",
			kind = "Window",
			nav = "None",
			openKey = "P",
			sections = {
				{ id = "party_2", kind = "Party", slots = 4 },
				{
					buttons = {
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "trophy",
							id = "mode_ranked",
							label = "Ranked",
							tone = "Yellow",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "gear",
							id = "mode_custom",
							label = "Custom",
							tone = "Dark",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "play",
							id = "mode_quick_match",
							label = "Quick Match",
							tone = "Dark",
						},
					},
					id = "modes",
					kind = "Buttons",
					layout = "Grid",
					title = "Modes",
				},
			},
			size = "Medium",
			title = "Party",
		},
		{
			description = "The map of Titan Legion: every area as a card, locked ones showing what it takes to get there.",
			icon = "map",
			id = "worlds",
			kind = "Window",
			nav = "None",
			openKey = "M",
			sections = {
				{
					id = "worlds_2",
					kind = "Worlds",
					worlds = {
						{
							icon = "location-pin",
							id = "world_kepler_station",
							locked = false,
							name = "Kepler Station",
							tone = "Yellow",
						},
						{
							icon = "hexagon-dot",
							id = "world_asteroid_belt",
							locked = false,
							name = "Asteroid Belt",
							tone = "Blue",
						},
						{
							icon = "cloud",
							id = "world_europa_colony",
							locked = true,
							name = "Europa Colony",
							requirement = "Level 20",
							tone = "Red",
						},
						{
							icon = "nebula",
							id = "world_the_veil_nebula",
							locked = true,
							name = "The Veil Nebula",
							requirement = "Level 30",
							tone = "Yellow",
						},
						{
							icon = "sun",
							id = "world_red_giant",
							locked = true,
							name = "Red Giant",
							requirement = "Level 40",
							tone = "Blue",
						},
						{
							icon = "moon",
							id = "world_deep_void",
							locked = true,
							name = "Deep Void",
							requirement = "Level 50",
							tone = "Red",
						},
					},
				},
			},
			size = "Large",
			title = "Star Map",
		},
		{
			description = "Where Credits and Robux turn into progress. Tabs: Featured, Items, Caches and Cores.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			openKey = "B",
			size = "Large",
			tabs = {
				{
					callout = "Refreshes daily",
					icon = "sale",
					id = "tab_featured",
					label = "Featured",
					sections = {
						{
							id = "today_s_picks",
							items = {
								{
									amount = "6,000",
									currency = "robux",
									description = "6,000 Cores + an exclusive title",
									grant = { amount = 6000, currency = "cores" },
									icon = "crown",
									id = "featured_mega",
									price = 1499,
									tag = "LIMITED",
									title = "Mega Bundle",
									tone = "Yellow",
									was = 3999,
								},
								{
									currency = "cores",
									description = "This week only",
									icon = "circle-line-target-cursor",
									id = "featured_railgun",
									price = 1000,
									tag = "FEATURED",
									title = "Railgun",
									tone = "Yellow",
								},
								{
									amount = "800",
									currency = "robux",
									description = "800 Cores + Railgun",
									grant = { amount = 800, currency = "cores" },
									icon = "gift",
									id = "featured_starter",
									price = 199,
									tag = "ONE TIME",
									title = "Newcomer Bundle",
									tone = "Blue",
									was = 399,
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Today's Picks",
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_items",
					label = "Items",
					sections = {
						{
							id = "products",
							items = {
								{
									amount = "Common",
									currency = "credits",
									icon = "lightning-bolt",
									id = "buy_pulse_rifle",
									price = 350,
									title = "Pulse Rifle",
									tone = "Gray",
								},
								{
									amount = "Common",
									currency = "credits",
									icon = "plus",
									id = "buy_repair_kit",
									price = 375,
									title = "Repair Kit",
									tone = "Gray",
								},
								{
									amount = "Uncommon",
									currency = "credits",
									icon = "hexagon-dot",
									id = "buy_gravity_grenade",
									price = 1000,
									title = "Gravity Grenade",
									tone = "Green",
								},
								{
									amount = "Rare",
									currency = "credits",
									icon = "flame",
									id = "buy_plasma_cannon",
									price = 2500,
									title = "Plasma Cannon",
									tone = "Blue",
								},
								{
									amount = "Rare",
									currency = "credits",
									icon = "shield-check",
									id = "buy_shield_drone",
									price = 2750,
									title = "Shield Drone",
									tone = "Blue",
								},
							},
							kind = "Products",
							layout = "Rows",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_caches",
					label = "Caches",
					sections = {
						{
							cases = {
								{
									currency = "credits",
									icon = "file-box",
									id = "case_supply_cache",
									pool = {
										{ chance = 32.4, icon = "lightning-bolt", name = "Pulse Rifle", rarity = "Common" },
										{ chance = 32, icon = "plus", name = "Repair Kit", rarity = "Common" },
										{ chance = 15, icon = "hexagon-dot", name = "Gravity Grenade", rarity = "Uncommon" },
										{ chance = 7.1, icon = "flame", name = "Plasma Cannon", rarity = "Rare" },
										{ chance = 7.1, icon = "shield-check", name = "Shield Drone", rarity = "Rare" },
										{ chance = 3.2, icon = "sword", name = "Ion Blade", rarity = "Epic" },
										{ chance = 3.2, icon = "double-chevron-large-up", name = "Jetpack", rarity = "Epic" },
									},
									price = 375,
									title = "Supply Cache",
									tone = "Cyan",
								},
								{
									currency = "credits",
									icon = "nebula",
									id = "case_alien_cache",
									pool = {
										{ chance = 38.6, icon = "hexagon-dot", name = "Gravity Grenade", rarity = "Uncommon" },
										{ chance = 19, icon = "flame", name = "Plasma Cannon", rarity = "Rare" },
										{ chance = 19, icon = "shield-check", name = "Shield Drone", rarity = "Rare" },
										{ chance = 8.6, icon = "sword", name = "Ion Blade", rarity = "Epic" },
										{ chance = 8.6, icon = "double-chevron-large-up", name = "Jetpack", rarity = "Epic" },
										{ chance = 3.1, icon = "nebula", name = "Quantum Core", rarity = "Legendary" },
										{
											chance = 3.1,
											icon = "circle-line-target-cursor",
											name = "Railgun",
											rarity = "Legendary",
										},
									},
									price = 1500,
									title = "Alien Cache",
									tone = "Purple",
								},
								{
									currency = "credits",
									icon = "lightning-bolt",
									id = "case_prototype_cache",
									pool = {
										{ chance = 30.7, icon = "flame", name = "Plasma Cannon", rarity = "Rare" },
										{ chance = 30, icon = "shield-check", name = "Shield Drone", rarity = "Rare" },
										{ chance = 14, icon = "sword", name = "Ion Blade", rarity = "Epic" },
										{ chance = 14, icon = "double-chevron-large-up", name = "Jetpack", rarity = "Epic" },
										{ chance = 5.1, icon = "nebula", name = "Quantum Core", rarity = "Legendary" },
										{
											chance = 5.1,
											icon = "circle-line-target-cursor",
											name = "Railgun",
											rarity = "Legendary",
										},
										{ chance = 1.1, icon = "diamond", name = "Void Shard", rarity = "Mythic" },
									},
									price = 4250,
									title = "Prototype Cache",
									tone = "Yellow",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "nebula",
					id = "tab_cores",
					label = "Cores",
					sections = {
						{
							id = "core_bundles",
							items = {
								{
									amount = "200",
									currency = "robux",
									grant = { amount = 200, currency = "cores" },
									icon = "nebula",
									id = "cores_pack_1",
									price = 49,
									title = "Starter Cores",
									tone = "Blue",
								},
								{
									amount = "450",
									bonus = 11,
									currency = "robux",
									grant = { amount = 450, currency = "cores" },
									icon = "nebula",
									id = "cores_pack_2",
									price = 99,
									title = "Small Core Pack",
									tone = "Blue",
								},
								{
									amount = "975",
									bonus = 20,
									currency = "robux",
									grant = { amount = 975, currency = "cores" },
									icon = "nebula",
									id = "cores_pack_3",
									price = 199,
									tag = "POPULAR",
									title = "Big Core Pack",
									tone = "Yellow",
								},
								{
									amount = "2,250",
									bonus = 38,
									currency = "robux",
									grant = { amount = 2250, currency = "cores" },
									icon = "file-box",
									id = "cores_pack_4",
									price = 399,
									title = "Mega Core Pack",
									tone = "Yellow",
								},
								{
									amount = "5,000",
									bonus = 53,
									currency = "robux",
									grant = { amount = 5000, currency = "cores" },
									icon = "building-bank",
									id = "cores_pack_5",
									price = 799,
									tag = "BEST VALUE",
									title = "Giga Core Pack",
									tone = "Red",
								},
							},
							kind = "Products",
							layout = "Rows",
							title = "Core Bundles",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Permanent upgrades bought with Credits; each card shows the next level's effect before you pay.",
			icon = "upgrade",
			id = "upgrades",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "upgrades_2",
					kind = "Upgrades",
					upgrades = {
						{
							cost = 12500,
							currency = "credits",
							effect = "+80 hull",
							icon = "shield",
							id = "upgrade_hull_plating",
							level = 7,
							maxLevel = 25,
							title = "Hull Plating",
						},
						{
							cost = 550,
							currency = "credits",
							effect = "+2% shields",
							icon = "shield-check",
							id = "upgrade_shield_generator",
							level = 0,
							maxLevel = 20,
							title = "Shield Generator",
						},
						{
							cost = 1500,
							currency = "credits",
							effect = "+4 slots",
							icon = "backpack",
							id = "upgrade_cargo_bay",
							level = 1,
							maxLevel = 10,
							title = "Cargo Bay",
						},
						{
							cost = 6750,
							currency = "credits",
							effect = "+4% damage",
							icon = "lightning-bolt",
							id = "upgrade_laser_array",
							level = 3,
							maxLevel = 10,
							title = "Laser Array",
						},
					},
				},
			},
			size = "Medium",
			title = "Upgrades",
		},
		{
			description = "Daily and weekly quests with progress bars; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "SideTabs",
			openKey = "L",
			size = "Large",
			tabs = {
				{
					badge = 1,
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Protect the freighter for 3 jumps",
									goal = 3,
									icon = "shield",
									id = "quest_escort_duty",
									progress = 3,
									reward = { amount = "50", icon = "nebula" },
									title = "Escort Duty",
								},
								{
									description = "Scan 4 planets",
									goal = 4,
									icon = "magnifying-glass",
									id = "quest_survey",
									progress = 2,
									reward = { amount = "1,250", icon = "coin" },
									title = "Survey",
								},
								{
									description = "Destroy 35 drones",
									goal = 35,
									icon = "crosshairs",
									id = "quest_rogue_ai",
									progress = 7,
									reward = { amount = "375", icon = "coin" },
									title = "Rogue AI",
								},
							},
						},
					},
				},
				{
					icon = "calendar",
					id = "tab_weekly",
					label = "Weekly",
					sections = {
						{
							id = "quests_3",
							kind = "Quests",
							quests = {
								{
									description = "Recover 10 tons of scrap from the belt",
									goal = 10,
									icon = "gear",
									id = "quest_salvage_run",
									progress = 2,
									reward = { amount = "25", icon = "nebula" },
									title = "Salvage Run",
								},
							},
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_achievements",
					label = "Achievements",
					sections = {
						{
							achievements = {
								{
									description = "Launch your first mission",
									goal = 1,
									icon = "arrow-large-up",
									id = "ach_liftoff",
									points = 5,
									progress = 1,
									reward = { amount = "6", grant = { amount = 6, currency = "cores" }, icon = "nebula" },
									title = "Liftoff",
								},
								{
									description = "Install 50 upgrades",
									goal = 50,
									icon = "upgrade",
									id = "ach_engineer",
									points = 20,
									progress = 27,
									reward = { amount = "20", grant = { amount = 20, currency = "cores" }, icon = "nebula" },
									title = "Engineer",
								},
								{
									description = "Complete 100 missions",
									goal = 100,
									icon = "quest",
									id = "ach_pilot",
									points = 30,
									progress = 26,
									reward = { amount = "25", grant = { amount = 25, currency = "cores" }, icon = "nebula" },
									title = "Pilot",
								},
								{
									description = "Destroy 500 ships",
									goal = 500,
									icon = "crosshairs",
									id = "ach_ace",
									points = 30,
									progress = 0,
									reward = { amount = "30", grant = { amount = 30, currency = "cores" }, icon = "nebula" },
									title = "Ace",
								},
								{
									description = "Chart 10 planets",
									goal = 10,
									icon = "globe-simplified",
									id = "ach_explorer",
									points = 35,
									progress = 0,
									reward = { amount = "30", grant = { amount = 30, currency = "cores" }, icon = "nebula" },
									title = "Explorer",
								},
								{
									description = "Reach rank 50",
									goal = 50,
									icon = "level",
									id = "ach_legend",
									points = 50,
									progress = 0,
									reward = { amount = "45", grant = { amount = 45, currency = "cores" }, icon = "nebula" },
									title = "Legend",
								},
							},
							id = "achievements",
							kind = "Achievements",
						},
					},
				},
			},
			title = "Contracts",
		},
		{
			delay = 5,
			description = "A one-time Starter Pack: 4 rewards for 99 Robux (was 299), counting down 24h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "sword",
			id = "offer",
			items = {
				{
					amount = "600",
					grant = { amount = 600, currency = "cores" },
					icon = "nebula",
					label = "Cores",
				},
				{
					amount = "15,000",
					grant = { amount = 15000, currency = "credits" },
					icon = "coin",
					label = "Credits",
				},
				{ amount = "1", icon = "sword", label = "Epic Ion Blade" },
				{ amount = "3", icon = "coin", label = "2x Credits (30m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "STARTER PACK",
			timer = 86400,
			title = "Starter Pack",
			tone = "Red",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "crosshairs",
									id = "hitMarkers",
									label = "Hit Markers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "hashtag",
									id = "damageNumbers",
									label = "Damage Numbers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Fire", gamepad = "ButtonR2", key = "MouseButton1" },
								{ action = "Aim", gamepad = "ButtonL2", key = "MouseButton2" },
								{ action = "Boost", gamepad = "ButtonL3", key = "LeftShift" },
								{ action = "Scan", gamepad = "ButtonY", key = "Q" },
								{ action = "Reload", gamepad = "ButtonX", key = "R" },
								{ action = "Jetpack", gamepad = "ButtonA", key = "Space" },
								{ action = "Map", gamepad = "ButtonSelect", key = "M" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "loading",
	style = {
		archetype = "Arena",
		buttonStyle = "Outline",
		clickFx = "Glow",
		corner = "Sharp",
		decoration = "Grain",
		density = "Compact",
		fonts = {
			body = "BuilderSans:SemiBold",
			display = "BuilderExtended:ExtraBold",
			title = "BuilderExtended:ExtraBold",
		},
		iconStyle = "Filled",
		palette = {
			accent = "#FF4D6D",
			bad = "#FF4D4D",
			good = "#3DDC84",
			primary = "#FFC21A",
			secondary = "#2E8BFF",
			surface = "#121620",
			text = "#F5F7FA",
			warn = "#FFB020",
		},
		panelStyle = "Glass",
		rarityScheme = "Arena",
		shadow = "Soft",
		textCase = "Normal",
		transition = "Zoom",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="68">
              <Properties>
                <string name="Name">simulator_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: simulator, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#C9A26B",
			format = "abbrev",
			icon = "cube",
			id = "ore",
			name = "Ore",
			source = { kind = "leaderstat", name = "Ore" },
			start = 15000,
		},
		{
			color = "#3AD1FF",
			format = "comma",
			icon = "gem",
			id = "gems",
			name = "Gems",
			source = { kind = "attribute", name = "Gems" },
			start = 475,
		},
	},
	hud = {
		elements = {
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopLeft",
				currencies = { "ore", "gems" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "Right",
				buttons = {
					{
						action = { kind = "Toggle", target = "pets" },
						icon = "pet",
						id = "btn_pets",
						label = "Pets",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "rewards" },
						icon = "gift",
						id = "btn_rewards",
						label = "Free Rewards",
						tone = "Purple",
					},
					{
						action = { kind = "Toggle", target = "upgrades" },
						icon = "upgrade",
						id = "btn_upgrades",
						label = "Upgrades",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Shop",
						tone = "Orange",
					},
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Challenges",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "leaderboard" },
						icon = "trophy",
						id = "btn_leaderboard",
						label = "Rankings",
						tone = "Purple",
					},
					{
						action = { kind = "Open", target = "rebirth" },
						icon = "rebirth",
						id = "btn_rebirth",
						label = "Prestige",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
			{
				amount = 5,
				anchor = "BottomCenter",
				currency = "ore",
				icon = "cube",
				id = "hud_clicker",
				text = "Dig!",
				type = "Clicker",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				boosts = {
					{ icon = "star", seconds = 853, text = "2x Luck" },
					{ icon = "lightning-bolt", seconds = 1050, text = "2x Hatch Speed" },
				},
				id = "hud_boosts",
				type = "Boosts",
				visible = true,
			},
			{
				anchor = "Left",
				id = "hud_questtracker",
				tasks = {
					{
						goal = 65,
						icon = "egg",
						progress = 65,
						reward = { amount = "3,250", icon = "cube" },
						text = "Hatch 65 eggs",
					},
					{
						goal = 2,
						icon = "world",
						progress = 0,
						reward = { amount = "1,500", icon = "cube" },
						text = "Unlock 2 new worlds",
					},
				},
				title = "Quests",
				type = "QuestTracker",
				visible = true,
			},
			{
				anchor = "TopCenter",
				digits = 4,
				id = "hud_counter",
				label = "DEPTH",
				style = "Digital",
				type = "Counter",
				value = 486,
				visible = true,
			},
			{
				anchor = "BottomLeft",
				icon = "backpack",
				id = "hud_meter",
				label = "BACKPACK",
				low = 0,
				segments = 8,
				style = "Segments",
				tone = "Orange",
				type = "Meter",
				value = 0.51,
				visible = true,
			},
			{
				action = { kind = "Open", target = "offer" },
				anchor = "Left",
				icon = "tag-sparkle",
				id = "hud_button",
				label = "Welcome Bundle",
				tone = "Purple",
				type = "Button",
				visible = true,
			},
		},
		kit = "Chunky Toy",
		layout = "Rail",
	},
	logo = {
		icon = "cube",
		subtitle = "DIG DEEPER",
		text = "Dig Simulator",
		tilt = 5,
		tone = "Orange",
		type = "Badge",
	},
	meta = {
		flavor = "mining",
		genre = "simulator",
		kit = "Chunky Toy",
		prompt = "a simulator game",
		seed = 21,
		source = "offline",
		title = "Dig Simulator",
	},
	screens = {
		{
			description = "The collection at the heart of the game: every hatched pet, sorted rarest-first, with equip, golden crafting and trading.",
			icon = "pet",
			id = "pets",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "pet",
					id = "tab_pets",
					label = "Pets",
					sections = {
						{
							id = "stats",
							kind = "Stats",
							stats = {
								{ bar = 0.5, icon = "pet", label = "Equipped", value = "3/6" },
								{ icon = "backpack", label = "Storage", value = "69/150" },
								{ icon = "boost", label = "Best Multiplier", value = "x8.2" },
							},
						},
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{
									equipped = true,
									icon = "butterfly-wings",
									id = "inv_rainbow_butterfly",
									name = "Rainbow Butterfly",
									rarity = "Mythic",
								},
								{
									equipped = true,
									icon = "flame",
									id = "inv_blazing_dragon",
									name = "Blazing Dragon",
									rarity = "Legendary",
								},
								{
									equipped = true,
									icon = "moon",
									id = "inv_moon_spirit",
									name = "Moon Spirit",
									rarity = "Legendary",
								},
								{ icon = "sun", id = "inv_sun_phoenix", name = "Sun Phoenix", rarity = "Legendary" },
								{ icon = "pig", id = "inv_golden_pig", name = "Golden Pig", rarity = "Epic" },
								{ icon = "lightning-bolt", id = "inv_storm_sprite", name = "Storm Sprite", rarity = "Epic" },
								{ icon = "bug", id = "inv_firefly", name = "Firefly", rarity = "Rare" },
								{ icon = "fish", id = "inv_koi", name = "Koi", rarity = "Rare" },
								{ icon = "parrot", id = "inv_toucan", name = "Toucan", rarity = "Rare" },
								{ icon = "butterfly-wings", id = "inv_butterfly", name = "Butterfly", rarity = "Uncommon" },
								{ icon = "bug", id = "inv_ladybug", name = "Ladybug", rarity = "Uncommon" },
								{ icon = "shrimp", id = "inv_shrimpy", name = "Shrimpy", rarity = "Uncommon" },
								{ icon = "fish", id = "inv_goldfish", name = "Goldfish", rarity = "Common" },
								{ icon = "parrot", id = "inv_parrot", name = "Parrot", rarity = "Common" },
								{ icon = "pig", id = "inv_piggy", name = "Piggy", rarity = "Common" },
								{
									icon = "nebula",
									id = "locked_nebula_wisp",
									locked = true,
									name = "Nebula Wisp",
									rarity = "Mythic",
								},
							},
							kind = "Inventory",
						},
						{
							buttons = {
								{
									action = { kind = "Event", name = "EquipBest" },
									icon = "stackable-star",
									id = "equip_best",
									label = "Equip Best",
									tone = "Orange",
								},
								{
									action = { kind = "Open", target = "trade" },
									icon = "two-arrows-left-right",
									id = "trade_2",
									label = "Trade",
									tone = "Dark",
								},
								{
									action = { kind = "Event", name = "CraftGolden" },
									icon = "crown",
									id = "craft_golden",
									label = "Craft Golden",
									tone = "Dark",
								},
							},
							id = "buttons",
							kind = "Buttons",
							layout = "Row",
						},
					},
				},
				{
					icon = "book-open",
					id = "tab_index",
					label = "Index",
					sections = {
						{
							groupBy = "Rarity",
							groups = {
								{
									icon = "star",
									id = "common",
									items = {
										{ icon = "fish", id = "dex_goldfish", name = "Goldfish", owned = true, rarity = "Common" },
										{ icon = "parrot", id = "dex_parrot", name = "Parrot", owned = false, rarity = "Common" },
										{ icon = "pig", id = "dex_piggy", name = "Piggy", owned = true, rarity = "Common" },
									},
									name = "Common",
									tone = "Gray",
								},
								{
									icon = "star",
									id = "uncommon",
									items = {
										{
											icon = "butterfly-wings",
											id = "dex_butterfly",
											name = "Butterfly",
											owned = true,
											rarity = "Uncommon",
										},
										{ icon = "bug", id = "dex_ladybug", name = "Ladybug", owned = false, rarity = "Uncommon" },
										{
											icon = "shrimp",
											id = "dex_shrimpy",
											name = "Shrimpy",
											owned = true,
											rarity = "Uncommon",
										},
									},
									name = "Uncommon",
									tone = "Green",
								},
								{
									icon = "star",
									id = "rare",
									items = {
										{ icon = "bug", id = "dex_firefly", name = "Firefly", owned = true, rarity = "Rare" },
										{ icon = "fish", id = "dex_koi", name = "Koi", owned = false, rarity = "Rare" },
										{ icon = "parrot", id = "dex_toucan", name = "Toucan", owned = false, rarity = "Rare" },
									},
									name = "Rare",
									tone = "Blue",
								},
								{
									icon = "star",
									id = "epic",
									items = {
										{
											icon = "pig",
											id = "dex_golden_pig",
											name = "Golden Pig",
											owned = true,
											rarity = "Epic",
										},
										{
											icon = "lightning-bolt",
											id = "dex_storm_sprite",
											name = "Storm Sprite",
											owned = false,
											rarity = "Epic",
										},
									},
									name = "Epic",
									tone = "Purple",
								},
								{
									icon = "star",
									id = "legendary",
									items = {
										{
											icon = "flame",
											id = "dex_blazing_dragon",
											name = "Blazing Dragon",
											owned = false,
											rarity = "Legendary",
										},
										{
											icon = "moon",
											id = "dex_moon_spirit",
											name = "Moon Spirit",
											owned = false,
											rarity = "Legendary",
										},
										{
											icon = "sun",
											id = "dex_sun_phoenix",
											name = "Sun Phoenix",
											owned = true,
											rarity = "Legendary",
										},
									},
									name = "Legendary",
									tone = "Yellow",
								},
								{
									icon = "star",
									id = "mythic",
									items = {
										{
											icon = "nebula",
											id = "dex_nebula_wisp",
											name = "Nebula Wisp",
											owned = false,
											rarity = "Mythic",
										},
										{
											icon = "butterfly-wings",
											id = "dex_rainbow_butterfly",
											name = "Rainbow Butterfly",
											owned = false,
											rarity = "Mythic",
										},
									},
									name = "Mythic",
									tone = "Red",
								},
							},
							id = "index",
							kind = "Index",
							milestones = {
								{ amount = "25", grant = { amount = 25, currency = "gems" }, icon = "gem", percent = 25 },
								{ amount = "75", grant = { amount = 75, currency = "gems" }, icon = "gem", percent = 50 },
								{ amount = "200", grant = { amount = 200, currency = "gems" }, icon = "gem", percent = 75 },
								{ amount = "500", grant = { amount = 500, currency = "gems" }, icon = "gem", percent = 100 },
							},
						},
					},
				},
			},
			title = "Pets",
		},
		{
			description = "Everything free in one place: Daily, Gifts and Spin. Every tab badges what's ready, and so does the HUD button.",
			icon = "gift",
			id = "rewards",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							days = {
								{ amount = "2,250", day = 1, icon = "cube" },
								{ amount = "3,500", day = 2, icon = "cube" },
								{ amount = "x2", day = 3, icon = "boost", label = "15 min" },
								{ amount = "30", day = 4, icon = "gem" },
								{ amount = "6,750", day = 5, icon = "cube" },
								{ amount = "8,000", day = 6, icon = "cube" },
								{ amount = "x1", day = 7, icon = "egg", label = "Rainbow Egg" },
							},
							id = "daily",
							kind = "Daily",
							streak = 1,
						},
					},
				},
				{
					icon = "gift",
					id = "tab_gifts",
					label = "Gifts",
					sections = {
						{
							gifts = {
								{
									amount = "850",
									grant = { amount = 850, currency = "ore" },
									icon = "cube",
									id = "gift1",
									minutes = 1,
									rarity = "Common",
								},
								{
									amount = "1,250",
									grant = { amount = 1250, currency = "ore" },
									icon = "cube",
									id = "gift2",
									minutes = 3,
									rarity = "Common",
								},
								{
									amount = "1,750",
									grant = { amount = 1750, currency = "ore" },
									icon = "cube",
									id = "gift3",
									minutes = 5,
									rarity = "Common",
								},
								{
									amount = "15m",
									icon = "coin",
									id = "gift4",
									label = "2x Ore",
									minutes = 10,
									rarity = "Rare",
								},
								{
									amount = "4,000",
									grant = { amount = 4000, currency = "ore" },
									icon = "cube",
									id = "gift5",
									minutes = 15,
									rarity = "Uncommon",
								},
								{
									amount = "5,000",
									grant = { amount = 5000, currency = "ore" },
									icon = "cube",
									id = "gift6",
									minutes = 20,
									rarity = "Rare",
								},
								{
									amount = "10",
									grant = { amount = 10, currency = "gems" },
									icon = "gem",
									id = "gift7",
									minutes = 30,
									rarity = "Rare",
								},
								{
									amount = "50",
									grant = { amount = 50, currency = "gems" },
									icon = "gem",
									id = "gift8",
									minutes = 45,
									rarity = "Legendary",
								},
							},
							id = "gifts",
							kind = "Gifts",
						},
					},
				},
				{
					icon = "chart-pie",
					id = "tab_spin",
					label = "Spin",
					sections = {
						{
							currency = "gems",
							freeHours = 24,
							id = "spin",
							kind = "Spin",
							luckPrice = 199,
							price = 60,
							wedges = {
								{
									amount = "1,250",
									chance = 26,
									grant = { amount = 1250, currency = "ore" },
									icon = "cube",
									id = "wedge1",
									rarity = "Common",
								},
								{
									amount = "12,500",
									chance = 10,
									grant = { amount = 12500, currency = "ore" },
									icon = "cube",
									id = "wedge5",
									rarity = "Rare",
								},
								{
									amount = "2,500",
									chance = 22,
									grant = { amount = 2500, currency = "ore" },
									icon = "cube",
									id = "wedge2",
									rarity = "Common",
								},
								{
									amount = "30",
									chance = 7,
									grant = { amount = 30, currency = "gems" },
									icon = "gem",
									id = "wedge6",
									rarity = "Epic",
								},
								{
									amount = "5,000",
									chance = 16,
									grant = { amount = 5000, currency = "ore" },
									icon = "cube",
									id = "wedge3",
									rarity = "Uncommon",
								},
								{
									amount = "x1",
									chance = 1.5,
									icon = "flame",
									id = "wedge8",
									label = "Blazing Dragon",
									rarity = "Legendary",
								},
								{
									amount = "10",
									chance = 12,
									grant = { amount = 10, currency = "gems" },
									icon = "gem",
									id = "wedge4",
									rarity = "Uncommon",
								},
								{
									amount = "1,500",
									chance = 0.5,
									grant = { amount = 1500, currency = "gems" },
									icon = "gem",
									id = "wedge9",
									rarity = "Mythic",
								},
								{
									amount = "15m",
									chance = 5,
									icon = "coin",
									id = "wedge7",
									label = "2x Ore",
									rarity = "Rare",
								},
							},
						},
					},
				},
			},
			title = "Free Rewards",
		},
		{
			description = "Permanent upgrades bought with Ore; each card shows the next level's effect before you pay.",
			icon = "upgrade",
			id = "upgrades",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "upgrades_2",
					kind = "Upgrades",
					upgrades = {
						{
							cost = 1500,
							currency = "ore",
							effect = "+10 capacity",
							icon = "backpack",
							id = "upgrade_backpack_size",
							level = 1,
							maxLevel = 10,
							title = "Backpack Size",
						},
						{
							cost = 6750,
							currency = "ore",
							effect = "+30% rare ore",
							icon = "star",
							id = "upgrade_ore_luck",
							level = 2,
							maxLevel = 20,
							title = "Ore Luck",
						},
						{
							cost = 3000,
							currency = "ore",
							effect = "+10% speed",
							icon = "run",
							id = "upgrade_walk_speed",
							level = 0,
							maxLevel = 10,
							title = "Walk Speed",
						},
						{
							cost = 22500,
							currency = "ore",
							effect = "+8 ore per swing",
							icon = "diamond",
							id = "upgrade_pickaxe_power",
							level = 3,
							maxLevel = 10,
							title = "Pickaxe Power",
						},
					},
				},
			},
			size = "Medium",
			title = "Boosts & Upgrades",
		},
		{
			description = "Where Ore and Robux turn into progress. Tabs: Featured, Eggs, Passes, Gems and Boosts.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					callout = "Refreshes daily",
					icon = "sale",
					id = "tab_featured",
					label = "Featured",
					sections = {
						{
							icon = "diamond",
							id = "crystal_rush",
							kind = "Banner",
							subtitle = "Rare crystals x3",
							timer = 43200,
							title = "Crystal Rush",
							tone = "Purple",
						},
						{
							id = "today_s_picks",
							items = {
								{
									currency = "gems",
									description = "Leaves the shop soon",
									icon = "flame",
									id = "featured_blazing_dragon",
									price = 950,
									tag = "FEATURED",
									title = "Blazing Dragon",
									tone = "Yellow",
								},
								{
									amount = "475",
									currency = "robux",
									description = "475 Gems + Blazing Dragon",
									grant = { amount = 475, currency = "gems" },
									icon = "gift",
									id = "featured_starter",
									price = 149,
									tag = "ONE TIME",
									title = "Newcomer Bundle",
									tone = "Yellow",
									was = 299,
								},
								{
									amount = "3,250",
									currency = "robux",
									description = "3,250 Gems + an exclusive title",
									grant = { amount = 3250, currency = "gems" },
									icon = "crown",
									id = "featured_mega",
									price = 999,
									tag = "LIMITED",
									title = "Mega Bundle",
									tone = "Orange",
									was = 1999,
								},
							},
							kind = "Products",
							layout = "Featured",
							title = "Today's Picks",
						},
					},
				},
				{
					badge = "NEW",
					icon = "egg",
					id = "tab_eggs",
					label = "Eggs",
					sections = {
						{
							cases = {
								{
									currency = "ore",
									icon = "egg",
									id = "case_spotted_egg",
									pool = {
										{ chance = 28, icon = "fish", name = "Goldfish", rarity = "Common" },
										{ chance = 28, icon = "parrot", name = "Parrot", rarity = "Common" },
										{ chance = 13, icon = "butterfly-wings", name = "Butterfly", rarity = "Uncommon" },
										{ chance = 13, icon = "bug", name = "Ladybug", rarity = "Uncommon" },
										{ chance = 6.2, icon = "bug", name = "Firefly", rarity = "Rare" },
										{ chance = 6.2, icon = "fish", name = "Koi", rarity = "Rare" },
										{ chance = 2.8, icon = "pig", name = "Golden Pig", rarity = "Epic" },
										{ chance = 2.8, icon = "lightning-bolt", name = "Storm Sprite", rarity = "Epic" },
									},
									price = 1750,
									title = "Spotted Egg",
									tone = "Green",
								},
								{
									currency = "ore",
									icon = "egg",
									id = "case_magma_egg",
									pool = {
										{ chance = 29.2, icon = "butterfly-wings", name = "Butterfly", rarity = "Uncommon" },
										{ chance = 28, icon = "bug", name = "Ladybug", rarity = "Uncommon" },
										{ chance = 13, icon = "bug", name = "Firefly", rarity = "Rare" },
										{ chance = 13, icon = "fish", name = "Koi", rarity = "Rare" },
										{ chance = 6.2, icon = "pig", name = "Golden Pig", rarity = "Epic" },
										{ chance = 6.2, icon = "lightning-bolt", name = "Storm Sprite", rarity = "Epic" },
										{ chance = 2.2, icon = "flame", name = "Blazing Dragon", rarity = "Legendary" },
										{ chance = 2.2, icon = "moon", name = "Moon Spirit", rarity = "Legendary" },
									},
									price = 4000,
									title = "Magma Egg",
									tone = "Orange",
								},
								{
									currency = "ore",
									icon = "egg",
									id = "case_galaxy_egg",
									pool = {
										{ chance = 29.8, icon = "bug", name = "Firefly", rarity = "Rare" },
										{ chance = 30, icon = "fish", name = "Koi", rarity = "Rare" },
										{ chance = 14, icon = "pig", name = "Golden Pig", rarity = "Epic" },
										{ chance = 14, icon = "lightning-bolt", name = "Storm Sprite", rarity = "Epic" },
										{ chance = 5, icon = "flame", name = "Blazing Dragon", rarity = "Legendary" },
										{ chance = 5, icon = "moon", name = "Moon Spirit", rarity = "Legendary" },
										{ chance = 1.1, icon = "nebula", name = "Nebula Wisp", rarity = "Mythic" },
										{ chance = 1.1, icon = "butterfly-wings", name = "Rainbow Butterfly", rarity = "Mythic" },
									},
									price = 12500,
									title = "Galaxy Egg",
									tone = "Purple",
								},
								{
									currency = "gems",
									icon = "egg",
									id = "case_rainbow_egg",
									pool = {
										{ chance = 29.8, icon = "bug", name = "Firefly", rarity = "Rare" },
										{ chance = 30, icon = "fish", name = "Koi", rarity = "Rare" },
										{ chance = 14, icon = "pig", name = "Golden Pig", rarity = "Epic" },
										{ chance = 14, icon = "lightning-bolt", name = "Storm Sprite", rarity = "Epic" },
										{ chance = 5, icon = "flame", name = "Blazing Dragon", rarity = "Legendary" },
										{ chance = 5, icon = "moon", name = "Moon Spirit", rarity = "Legendary" },
										{ chance = 1.1, icon = "nebula", name = "Nebula Wisp", rarity = "Mythic" },
										{ chance = 1.1, icon = "butterfly-wings", name = "Rainbow Butterfly", rarity = "Mythic" },
									},
									price = 500,
									title = "Rainbow Egg",
									tone = "Pink",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Earn twice the Ore, forever",
									icon = "coin",
									id = "pass_2x_ore",
									price = 399,
									tag = "POPULAR",
									title = "2x Ore",
									tone = "Purple",
								},
								{
									currency = "robux",
									description = "Crack three eggs at once",
									icon = "egg",
									id = "pass_triple_hatch",
									price = 399,
									title = "Triple Hatch",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "More room for everything you find",
									icon = "backpack",
									id = "pass_3_pet_slots",
									price = 249,
									title = "+3 Pet Slots",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "Eggs crack open in half the time",
									icon = "lightning-bolt",
									id = "pass_fast_hatch",
									price = 199,
									title = "Fast Hatch",
									tone = "Purple",
								},
								{
									currency = "robux",
									description = "Grinds for you while you're away",
									icon = "cursor",
									id = "pass_auto_clicker",
									price = 149,
									title = "Auto Clicker",
									tone = "Purple",
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Gamepasses",
						},
					},
				},
				{
					icon = "gem",
					id = "tab_gems",
					label = "Gems",
					sections = {
						{
							id = "gem_bundles",
							items = {
								{
									amount = "100",
									currency = "robux",
									grant = { amount = 100, currency = "gems" },
									icon = "gem",
									id = "gems_pack_1",
									price = 49,
									title = "Handful of Gems",
									tone = "Yellow",
								},
								{
									amount = "225",
									bonus = 11,
									currency = "robux",
									grant = { amount = 225, currency = "gems" },
									icon = "gem",
									id = "gems_pack_2",
									price = 99,
									tag = "SALE",
									title = "Pouch of Gems",
									tone = "Yellow",
									was = 149,
								},
								{
									amount = "500",
									bonus = 23,
									currency = "robux",
									grant = { amount = 500, currency = "gems" },
									icon = "gem",
									id = "gems_pack_3",
									price = 199,
									title = "Sack of Gems",
									tone = "Orange",
								},
								{
									amount = "1,250",
									bonus = 54,
									currency = "robux",
									grant = { amount = 1250, currency = "gems" },
									icon = "file-box",
									id = "gems_pack_4",
									price = 399,
									tag = "BEST VALUE",
									title = "Chest of Gems",
									tone = "Orange",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Gem Bundles",
						},
					},
				},
				{
					icon = "boost",
					id = "tab_boosts",
					label = "Boosts",
					sections = {
						{
							id = "boosts",
							items = {
								{
									amount = "15 min",
									currency = "gems",
									icon = "star",
									id = "boost_2x_luck",
									price = 75,
									title = "2x Luck",
									tone = "Yellow",
								},
								{
									amount = "30 min",
									currency = "gems",
									icon = "cursor-stars",
									id = "boost_super_luck",
									price = 250,
									title = "Super Luck",
									tone = "Purple",
								},
								{
									amount = "20 min",
									currency = "gems",
									icon = "lightning-bolt",
									id = "boost_2x_hatch_speed",
									price = 125,
									title = "2x Hatch Speed",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Compact",
							title = "Boosts",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Quest cards with progress bars under a reset countdown; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "quest",
					id = "tab_challenges",
					label = "Challenges",
					sections = {
						{
							icon = "timer",
							id = "new_quests_in",
							kind = "Banner",
							subtitle = "Finish all quests for a bonus chest",
							timer = 39600,
							title = "New quests in",
							tone = "Yellow",
						},
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Equip 4 pets",
									goal = 4,
									icon = "pet",
									id = "quest_pet_pal",
									progress = 4,
									reward = { amount = "3,750", icon = "cube" },
									title = "Pet Pal",
								},
								{
									description = "Unlock 2 new worlds",
									goal = 2,
									icon = "world",
									id = "quest_explorer",
									progress = 0,
									reward = { amount = "10", icon = "gem" },
									title = "Explorer",
								},
								{
									description = "Earn 100,000 Ore",
									goal = 100000,
									icon = "coin",
									id = "quest_collector",
									progress = 0,
									reward = { amount = "4,500", icon = "cube" },
									title = "Collector",
								},
								{
									description = "Click 1,750 times",
									goal = 1750,
									icon = "cursor",
									id = "quest_clicker",
									progress = 1480,
									reward = { amount = "3,500", icon = "cube" },
									title = "Clicker",
								},
							},
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_achievements",
					label = "Achievements",
					sections = {
						{
							achievements = {
								{
									description = "Hatch your first egg",
									goal = 1,
									icon = "egg",
									id = "ach_first_hatch",
									points = 5,
									progress = 1,
									reward = { amount = "5", grant = { amount = 5, currency = "gems" }, icon = "gem" },
									title = "First Hatch",
								},
								{
									description = "Click 50,000 times",
									goal = 50000,
									icon = "cursor",
									id = "ach_clicker_pro",
									points = 20,
									progress = 39977,
									reward = { amount = "20", grant = { amount = 20, currency = "gems" }, icon = "gem" },
									title = "Clicker Pro",
								},
								{
									description = "Rebirth 5 times",
									goal = 5,
									icon = "rebirth",
									id = "ach_reborn",
									points = 25,
									progress = 3,
									reward = { amount = "20", grant = { amount = 20, currency = "gems" }, icon = "gem" },
									title = "Reborn",
								},
								{
									description = "Earn 1,000,000 Ore",
									goal = 1000000,
									icon = "coin",
									id = "ach_millionaire",
									points = 30,
									progress = 0,
									reward = { amount = "35", grant = { amount = 35, currency = "gems" }, icon = "gem" },
									title = "Millionaire",
								},
								{
									description = "Discover 25 pets",
									goal = 25,
									icon = "pet",
									id = "ach_collector",
									points = 35,
									progress = 0,
									reward = { amount = "30", grant = { amount = 30, currency = "gems" }, icon = "gem" },
									title = "Collector",
								},
								{
									description = "Hatch a Legendary pet",
									goal = 1,
									icon = "star",
									id = "ach_lucky_star",
									points = 40,
									progress = 0,
									reward = { amount = "35", grant = { amount = 35, currency = "gems" }, icon = "gem" },
									title = "Lucky Star",
								},
								{
									description = "Hatch 10,000 eggs",
									goal = 10000,
									icon = "egg",
									id = "ach_egg_legend",
									points = 60,
									progress = 0,
									reward = { amount = "70", grant = { amount = 70, currency = "gems" }, icon = "gem" },
									title = "Egg Legend",
								},
							},
							id = "achievements",
							kind = "Achievements",
						},
					},
				},
			},
			title = "Challenges",
		},
		{
			currency = "ore",
			description = "Two-sided trade window: your offer on the left, theirs on the right, Ore can be added, and both players must accept.",
			icon = "two-arrows-left-right",
			id = "trade",
			kind = "Trade",
			size = "Large",
			title = "Trade",
		},
		{
			description = "Global top ten for Total Ore, Rebirths and Eggs Hatched, with podium colours for the top three.",
			icon = "trophy",
			id = "leaderboard",
			kind = "Window",
			nav = "Tabs",
			size = "Medium",
			tabs = {
				{
					icon = "trophy",
					id = "tab_total_ore",
					label = "Total Ore",
					sections = {
						{
							id = "leaderboard_2",
							kind = "Leaderboard",
							rows = {
								{ name = "ItsLuckyFox", rank = 1, value = "340M" },
								{ name = "ZapFox_2011", rank = 2, value = "257M" },
								{ name = "Ember777", rank = 3, value = "185M" },
								{ name = "WaffleOtterYT", rank = 4, value = "164M" },
								{ name = "Glitch_Yeti", rank = 5, value = "132M" },
								{ name = "RealWobblyKnight", rank = 6, value = "115M" },
								{ name = "ViperOwl_64", rank = 7, value = "84.6M" },
								{ name = "Ember_Dash", rank = 8, value = "63.1M" },
								{ name = "Echo_Hunter", rank = 9, value = "46.4M" },
								{ name = "EchoBunny404", rank = 10, value = "41.5M" },
							},
							stat = "Total Ore",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_rebirths",
					label = "Rebirths",
					sections = {
						{
							id = "leaderboard_3",
							kind = "Leaderboard",
							rows = {
								{ name = "MochiLlamaYT", rank = 1, value = "2,172" },
								{ name = "NimbusToast101", rank = 2, value = "1,972" },
								{ name = "Toasty42", rank = 3, value = "1,847" },
								{ name = "Lucky2014", rank = 4, value = "1,517" },
								{ name = "PepperCatYT", rank = 5, value = "1,248" },
								{ name = "Cookie_Spark", rank = 6, value = "909" },
								{ name = "Echo360", rank = 7, value = "723" },
								{ name = "xXTacoSproutXx", rank = 8, value = "578" },
								{ name = "NotMapleGamer", rank = 9, value = "511" },
								{ name = "EchoPupYT", rank = 10, value = "392" },
							},
							stat = "Rebirths",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_eggs_hatched",
					label = "Eggs Hatched",
					sections = {
						{
							id = "leaderboard_4",
							kind = "Leaderboard",
							rows = {
								{ name = "RealCookieRanger", rank = 1, value = "25M" },
								{ name = "ItsCosmicCat", rank = 2, value = "22.8M" },
								{ name = "MangoByte", rank = 3, value = "17.8M" },
								{ name = "GhostOtter_64", rank = 4, value = "13.9M" },
								{ name = "Neon_Rider", rank = 5, value = "10.9M" },
								{ name = "RealViperOwl", rank = 6, value = "8.9M" },
								{ name = "ShadowCat64", rank = 7, value = "7.1M" },
								{ name = "LunaPilot", rank = 8, value = "5.9M" },
								{ name = "ViperAce", rank = 9, value = "5.1M" },
								{ name = "RealGlitchPup", rank = 10, value = "4.2M" },
							},
							stat = "Eggs Hatched",
						},
					},
				},
			},
			title = "Rankings",
		},
		{
			description = "Trade your Ore for a permanent multiplier. The popup spells out exactly what is lost and gained so the decision feels safe.",
			icon = "rebirth",
			id = "rebirth",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					id = "stats_2",
					kind = "Stats",
					stats = {
						{ icon = "rebirth", label = "Prestiges", value = "5" },
						{ icon = "boost", label = "Multiplier", value = "x10 → x12" },
						{ icon = "cube", label = "Cost", value = "12M" },
						{ bar = 0.76881884508766, icon = "level", label = "Progress", value = "94%" },
					},
				},
				{
					body = "Prestige resets your Ore and upgrades, but keeps your pets and Gems.",
					id = "text",
					kind = "Text",
				},
				{
					buttons = {
						{
							action = { kind = "Event", name = "Rebirth" },
							icon = "rebirth",
							id = "prestige",
							label = "Prestige",
							tone = "Orange",
						},
						{
							action = { kind = "Close" },
							icon = "close",
							id = "not_yet",
							label = "Not Yet",
							tone = "Dark",
						},
					},
					id = "buttons_2",
					kind = "Buttons",
					layout = "Row",
				},
			},
			size = "Medium",
			title = "Prestige",
			transition = "Pop",
		},
		{
			description = "Redeem promo codes. The server validates them through the RedeemCode remote.",
			icon = "tag",
			id = "codes",
			kind = "Popup",
			nav = "None",
			sections = {
				{ body = "Like the game and join the group for secret codes.", id = "text_2", kind = "Text" },
				{
					action = { kind = "Remote", name = "RedeemCode" },
					button = "Redeem",
					id = "codes_2",
					kind = "Codes",
					placeholder = "e.g. RELEASE",
				},
			},
			size = "Small",
			title = "Codes",
			transition = "Pop",
		},
		{
			delay = 5,
			description = "A one-time Welcome Bundle: 4 rewards for 99 Robux (was 299), counting down 48h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "pig",
			id = "offer",
			items = {
				{ amount = "300", grant = { amount = 300, currency = "gems" }, icon = "gem", label = "Gems" },
				{
					amount = "90,000",
					grant = { amount = 90000, currency = "ore" },
					icon = "cube",
					label = "Ore",
				},
				{ amount = "1", icon = "pig", label = "Epic Golden Pig" },
				{ amount = "3", icon = "coin", label = "2x Ore (15m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "LIMITED",
			timer = 172800,
			title = "Welcome Bundle",
			tone = "Purple",
			was = 299,
		},
		{
			capHours = 8,
			currency = "ore",
			description = "Welcomes players back with what they earned while away: 325 Ore a minute, up to 8h, worked out by the server from their last visit.",
			doublePrice = 25,
			icon = "moon-with-stars",
			id = "offline",
			kind = "OfflineEarnings",
			minMinutes = 5,
			rate = 325,
			title = "Welcome Back!",
		},
		{
			description = "Grouped into General, Graphics, Audio and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "skip-next-large",
									id = "skipHatch",
									label = "Skip Hatch Animation",
									type = "Toggle",
								},
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "pet",
									id = "showOtherPets",
									label = "Show Other Pets",
									type = "Toggle",
								},
								{
									default = true,
									icon = "two-arrows-left-right",
									id = "tradeRequests",
									label = "Trade Requests",
									type = "Toggle",
								},
							},
						},
						{
							buttons = {
								{
									action = { kind = "Open", target = "codes" },
									icon = "tag",
									id = "goto_codes",
									label = "Codes",
									tone = "Dark",
								},
							},
							id = "buttons_3",
							kind = "Buttons",
							layout = "Row",
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	style = {
		archetype = "Candy",
		buttonStyle = "Glossy3D",
		clickFx = "Squash",
		corner = "Bubbly",
		decoration = "Dots",
		density = "Compact",
		fonts = { body = "BuilderSans:Bold", display = "DenkOne", title = "DenkOne" },
		iconStyle = "Filled",
		palette = {
			accent = "#8B5CFF",
			bad = "#FF3D3D",
			good = "#5CE08A",
			primary = "#FF5A1F",
			secondary = "#FFB627",
			surface = "#1C0F0C",
			text = "#FFF4EC",
			warn = "#FFB627",
		},
		panelStyle = "Ribbon",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Pop",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="69">
              <Properties>
                <string name="Name">simulator_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: simulator, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD21F",
			format = "abbrev",
			icon = "coin",
			id = "coins",
			name = "Coins",
			source = { kind = "leaderstat", name = "Coins" },
			start = 55000,
		},
		{
			color = "#FF8FD0",
			format = "comma",
			icon = "star",
			id = "tokens",
			name = "Tokens",
			source = { kind = "attribute", name = "Tokens" },
			start = 400,
		},
	},
	hud = {
		elements = {
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopRight",
				currencies = { "coins", "tokens" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Toggle", target = "pets" },
						icon = "pet",
						id = "btn_pets",
						label = "My Pets",
						tone = "Purple",
					},
					{
						action = { kind = "Toggle", target = "rewards" },
						icon = "gift",
						id = "btn_rewards",
						label = "Free Rewards",
						tone = "Cyan",
					},
					{
						action = { kind = "Toggle", target = "upgrades" },
						icon = "upgrade",
						id = "btn_upgrades",
						label = "Upgrades",
						tone = "Purple",
					},
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Shop",
						tone = "Pink",
					},
					{
						action = { kind = "Toggle", target = "worlds" },
						icon = "map",
						id = "btn_worlds",
						label = "Worlds",
						tone = "Purple",
					},
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Challenges",
						tone = "Cyan",
					},
					{
						action = { kind = "Toggle", target = "leaderboard" },
						icon = "trophy",
						id = "btn_leaderboard",
						label = "Rankings",
						tone = "Purple",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "White",
					},
				},
				id = "hud_menubuttons",
				style = "Grid",
				type = "MenuButtons",
				visible = true,
			},
			{
				amount = 1,
				anchor = "BottomCenter",
				currency = "coins",
				icon = "cursor-stars",
				id = "hud_clicker",
				text = "Tap!",
				type = "Clicker",
				visible = true,
			},
			{
				anchor = "Right",
				boosts = {
					{ icon = "coin", seconds = 796, text = "2x Coins" },
					{ icon = "lightning-bolt", seconds = 1161, text = "2x Hatch Speed" },
				},
				id = "hud_boosts",
				type = "Boosts",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_xpbar",
				level = 29,
				progress = 0.76,
				type = "XPBar",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				goal = 5,
				icon = "pet",
				id = "hud_counter",
				label = "PETS",
				style = "Stamp",
				type = "Counter",
				value = 5,
				visible = true,
			},
			{
				action = { kind = "Open", target = "offer" },
				anchor = "Right",
				icon = "tag-sparkle",
				id = "hud_button",
				label = "Starter Pack",
				tone = "Cyan",
				type = "Button",
				visible = true,
			},
		},
		kit = "Chunky Toy",
		layout = "Classic",
	},
	logo = { icon = "pet", text = "Critter Simulator", tilt = -5, tone = "Pink", type = "Stacked" },
	meta = {
		flavor = "pets",
		genre = "simulator",
		kit = "Chunky Toy",
		prompt = "a simulator game",
		seed = 7,
		source = "offline",
		title = "Critter Simulator",
	},
	screens = {
		{
			description = "The collection at the heart of the game: every hatched pet, sorted rarest-first, with equip, golden crafting and trading.",
			icon = "pet",
			id = "pets",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "pet",
					id = "tab_my_pets",
					label = "My Pets",
					sections = {
						{
							id = "stats",
							kind = "Stats",
							stats = {
								{ bar = 0.5, icon = "pet", label = "Equipped", value = "3/6" },
								{ icon = "backpack", label = "Storage", value = "38/150" },
								{ icon = "boost", label = "Best Multiplier", value = "x26" },
							},
						},
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{
									equipped = true,
									icon = "flame",
									id = "inv_blazing_dragon",
									name = "Blazing Dragon",
									rarity = "Legendary",
								},
								{
									equipped = true,
									icon = "moon",
									id = "inv_moon_spirit",
									name = "Moon Spirit",
									rarity = "Legendary",
								},
								{
									equipped = true,
									icon = "pig",
									id = "inv_golden_pig",
									name = "Golden Pig",
									rarity = "Epic",
								},
								{ icon = "lightning-bolt", id = "inv_storm_sprite", name = "Storm Sprite", rarity = "Epic" },
								{ icon = "bug", id = "inv_firefly", name = "Firefly", rarity = "Rare" },
								{ icon = "fish", id = "inv_koi", name = "Koi", rarity = "Rare" },
								{ icon = "parrot", id = "inv_toucan", name = "Toucan", rarity = "Rare" },
								{ icon = "butterfly-wings", id = "inv_butterfly", name = "Butterfly", rarity = "Uncommon" },
								{ icon = "bug", id = "inv_ladybug", name = "Ladybug", rarity = "Uncommon" },
								{ icon = "shrimp", id = "inv_shrimpy", name = "Shrimpy", rarity = "Uncommon" },
								{ icon = "fish", id = "inv_goldfish", name = "Goldfish", rarity = "Common" },
								{ icon = "parrot", id = "inv_parrot", name = "Parrot", rarity = "Common" },
								{ icon = "pig", id = "inv_piggy", name = "Piggy", rarity = "Common" },
								{
									icon = "sun",
									id = "locked_sun_phoenix",
									locked = true,
									name = "Sun Phoenix",
									rarity = "Legendary",
								},
								{
									icon = "nebula",
									id = "locked_nebula_wisp",
									locked = true,
									name = "Nebula Wisp",
									rarity = "Mythic",
								},
							},
							kind = "Inventory",
							layout = "Bottom",
						},
						{
							buttons = {
								{
									action = { kind = "Event", name = "EquipBest" },
									icon = "stackable-star",
									id = "equip_best",
									label = "Equip Best",
									tone = "Pink",
								},
								{
									action = { kind = "Event", name = "CraftGolden" },
									icon = "crown",
									id = "craft_golden",
									label = "Craft Golden",
									tone = "White",
								},
							},
							id = "buttons",
							kind = "Buttons",
							layout = "Row",
						},
					},
				},
				{
					icon = "book-open",
					id = "tab_index",
					label = "Index",
					sections = {
						{
							groupBy = "Rarity",
							groups = {
								{
									icon = "star",
									id = "common",
									items = {
										{ icon = "fish", id = "dex_goldfish", name = "Goldfish", owned = true, rarity = "Common" },
										{ icon = "parrot", id = "dex_parrot", name = "Parrot", owned = true, rarity = "Common" },
										{ icon = "pig", id = "dex_piggy", name = "Piggy", owned = true, rarity = "Common" },
									},
									name = "Common",
									tone = "Gray",
								},
								{
									icon = "star",
									id = "uncommon",
									items = {
										{
											icon = "butterfly-wings",
											id = "dex_butterfly",
											name = "Butterfly",
											owned = false,
											rarity = "Uncommon",
										},
										{ icon = "bug", id = "dex_ladybug", name = "Ladybug", owned = true, rarity = "Uncommon" },
										{
											icon = "shrimp",
											id = "dex_shrimpy",
											name = "Shrimpy",
											owned = false,
											rarity = "Uncommon",
										},
									},
									name = "Uncommon",
									tone = "Green",
								},
								{
									icon = "star",
									id = "rare",
									items = {
										{ icon = "bug", id = "dex_firefly", name = "Firefly", owned = false, rarity = "Rare" },
										{ icon = "fish", id = "dex_koi", name = "Koi", owned = false, rarity = "Rare" },
										{ icon = "parrot", id = "dex_toucan", name = "Toucan", owned = true, rarity = "Rare" },
									},
									name = "Rare",
									tone = "Blue",
								},
								{
									icon = "star",
									id = "epic",
									items = {
										{
											icon = "pig",
											id = "dex_golden_pig",
											name = "Golden Pig",
											owned = false,
											rarity = "Epic",
										},
										{
											icon = "lightning-bolt",
											id = "dex_storm_sprite",
											name = "Storm Sprite",
											owned = true,
											rarity = "Epic",
										},
									},
									name = "Epic",
									tone = "Purple",
								},
								{
									icon = "star",
									id = "legendary",
									items = {
										{
											icon = "flame",
											id = "dex_blazing_dragon",
											name = "Blazing Dragon",
											owned = false,
											rarity = "Legendary",
										},
										{
											icon = "moon",
											id = "dex_moon_spirit",
											name = "Moon Spirit",
											owned = false,
											rarity = "Legendary",
										},
										{
											icon = "sun",
											id = "dex_sun_phoenix",
											name = "Sun Phoenix",
											owned = false,
											rarity = "Legendary",
										},
									},
									name = "Legendary",
									tone = "Yellow",
								},
								{
									icon = "star",
									id = "mythic",
									items = {
										{
											icon = "nebula",
											id = "dex_nebula_wisp",
											name = "Nebula Wisp",
											owned = false,
											rarity = "Mythic",
										},
										{
											icon = "butterfly-wings",
											id = "dex_rainbow_butterfly",
											name = "Rainbow Butterfly",
											owned = false,
											rarity = "Mythic",
										},
									},
									name = "Mythic",
									tone = "Red",
								},
							},
							id = "index",
							kind = "Index",
							milestones = {
								{ amount = "25", grant = { amount = 25, currency = "tokens" }, icon = "star", percent = 25 },
								{ amount = "75", grant = { amount = 75, currency = "tokens" }, icon = "star", percent = 50 },
								{
									amount = "200",
									grant = { amount = 200, currency = "tokens" },
									icon = "star",
									percent = 75,
								},
								{
									amount = "500",
									grant = { amount = 500, currency = "tokens" },
									icon = "star",
									percent = 100,
								},
							},
						},
					},
				},
			},
			title = "My Pets",
		},
		{
			description = "Everything free in one place: Daily, Gifts and Spin. Every tab badges what's ready, and so does the HUD button.",
			icon = "gift",
			id = "rewards",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							days = {
								{ amount = "2,250", day = 1, icon = "coin" },
								{ amount = "3,500", day = 2, icon = "coin" },
								{ amount = "40", day = 3, icon = "star" },
								{ amount = "5,750", day = 4, icon = "coin" },
								{ amount = "x2", day = 5, icon = "boost" },
								{ amount = "8,000", day = 6, icon = "coin" },
								{ amount = "100", day = 7, icon = "star" },
								{ amount = "3,500", day = 8, icon = "coin" },
								{ amount = "5,500", day = 9, icon = "coin" },
								{ amount = "65", day = 10, icon = "star" },
								{ amount = "9,000", day = 11, icon = "coin" },
								{ amount = "x2", day = 12, icon = "boost" },
								{ amount = "12,500", day = 13, icon = "coin" },
								{ amount = "150", day = 14, icon = "star" },
								{ amount = "5,000", day = 15, icon = "coin" },
								{ amount = "7,500", day = 16, icon = "coin" },
								{ amount = "90", day = 17, icon = "star" },
								{ amount = "12,500", day = 18, icon = "coin" },
								{ amount = "x3", day = 19, icon = "boost" },
								{ amount = "17,500", day = 20, icon = "coin" },
								{ amount = "225", day = 21, icon = "star" },
								{ amount = "6,250", day = 22, icon = "coin" },
								{ amount = "9,500", day = 23, icon = "coin" },
								{ amount = "100", day = 24, icon = "star" },
								{ amount = "15,000", day = 25, icon = "coin" },
								{ amount = "x3", day = 26, icon = "boost" },
								{ amount = "22,500", day = 27, icon = "coin" },
								{ amount = "x1", day = 28, icon = "egg", label = "Rainbow Egg" },
							},
							id = "daily",
							kind = "Daily",
							layout = "Calendar",
							streak = 7,
						},
					},
				},
				{
					icon = "gift",
					id = "tab_gifts",
					label = "Gifts",
					sections = {
						{
							gifts = {
								{
									amount = "975",
									grant = { amount = 975, currency = "coins" },
									icon = "coin",
									id = "gift1",
									minutes = 1,
									rarity = "Common",
								},
								{
									amount = "1,500",
									grant = { amount = 1500, currency = "coins" },
									icon = "coin",
									id = "gift2",
									minutes = 3,
									rarity = "Common",
								},
								{
									amount = "2,000",
									grant = { amount = 2000, currency = "coins" },
									icon = "coin",
									id = "gift3",
									minutes = 5,
									rarity = "Common",
								},
								{
									amount = "3,250",
									grant = { amount = 3250, currency = "coins" },
									icon = "coin",
									id = "gift4",
									minutes = 10,
									rarity = "Uncommon",
								},
								{
									amount = "4,500",
									grant = { amount = 4500, currency = "coins" },
									icon = "coin",
									id = "gift5",
									minutes = 15,
									rarity = "Uncommon",
								},
								{
									amount = "15m",
									icon = "coin",
									id = "gift6",
									label = "2x Coins",
									minutes = 20,
									rarity = "Rare",
								},
								{
									amount = "10",
									grant = { amount = 10, currency = "tokens" },
									icon = "star",
									id = "gift7",
									minutes = 30,
									rarity = "Rare",
								},
								{
									amount = "12,500",
									grant = { amount = 12500, currency = "coins" },
									icon = "coin",
									id = "gift8",
									minutes = 45,
									rarity = "Rare",
								},
								{
									amount = "20",
									grant = { amount = 20, currency = "tokens" },
									icon = "star",
									id = "gift9",
									minutes = 60,
									rarity = "Epic",
								},
								{
									amount = "20,000",
									grant = { amount = 20000, currency = "coins" },
									icon = "coin",
									id = "gift10",
									minutes = 75,
									rarity = "Rare",
								},
								{
									amount = "35",
									grant = { amount = 35, currency = "tokens" },
									icon = "star",
									id = "gift11",
									minutes = 90,
									rarity = "Epic",
								},
								{
									amount = "x1",
									icon = "pig",
									id = "gift12",
									label = "Golden Pig",
									minutes = 120,
									rarity = "Epic",
								},
							},
							id = "gifts",
							kind = "Gifts",
						},
					},
				},
				{
					icon = "chart-pie",
					id = "tab_spin",
					label = "Spin",
					sections = {
						{
							currency = "tokens",
							freeHours = 12,
							id = "spin",
							kind = "Spin",
							luckPrice = 199,
							price = 75,
							wedges = {
								{
									amount = "1,500",
									chance = 26,
									grant = { amount = 1500, currency = "coins" },
									icon = "coin",
									id = "wedge1",
									rarity = "Common",
								},
								{
									amount = "15,000",
									chance = 10,
									grant = { amount = 15000, currency = "coins" },
									icon = "coin",
									id = "wedge5",
									rarity = "Rare",
								},
								{
									amount = "3,000",
									chance = 22,
									grant = { amount = 3000, currency = "coins" },
									icon = "coin",
									id = "wedge2",
									rarity = "Common",
								},
								{
									amount = "25",
									chance = 7,
									grant = { amount = 25, currency = "tokens" },
									icon = "star",
									id = "wedge6",
									rarity = "Epic",
								},
								{
									amount = "6,000",
									chance = 16,
									grant = { amount = 6000, currency = "coins" },
									icon = "coin",
									id = "wedge3",
									rarity = "Uncommon",
								},
								{
									amount = "500",
									chance = 1.5,
									grant = { amount = 500, currency = "tokens" },
									icon = "star",
									id = "wedge8",
									rarity = "Legendary",
								},
								{
									amount = "10",
									chance = 12,
									grant = { amount = 10, currency = "tokens" },
									icon = "star",
									id = "wedge4",
									rarity = "Uncommon",
								},
								{
									amount = "1,000",
									chance = 0.5,
									grant = { amount = 1000, currency = "tokens" },
									icon = "star",
									id = "wedge9",
									rarity = "Mythic",
								},
								{
									amount = "15m",
									chance = 5,
									icon = "coin",
									id = "wedge7",
									label = "2x Coins",
									rarity = "Rare",
								},
							},
						},
					},
				},
			},
			title = "Free Rewards",
		},
		{
			description = "Permanent upgrades bought with Coins; each card shows the next level's effect before you pay.",
			icon = "upgrade",
			id = "upgrades",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "upgrades_2",
					kind = "Upgrades",
					upgrades = {
						{
							cost = 50000,
							currency = "coins",
							effect = "+80 Coins per click",
							icon = "cursor",
							id = "upgrade_click_power",
							level = 7,
							maxLevel = 25,
							title = "Click Power",
						},
						{
							cost = 2000,
							currency = "coins",
							effect = "+2 pet slots",
							icon = "backpack",
							id = "upgrade_pet_storage",
							level = 0,
							maxLevel = 20,
							title = "Pet Storage",
						},
						{
							cost = 5750,
							currency = "coins",
							effect = "-4% egg price",
							icon = "tag",
							id = "upgrade_egg_discount",
							level = 1,
							maxLevel = 10,
							title = "Egg Discount",
						},
						{
							cost = 25000,
							currency = "coins",
							effect = "+4% luck",
							icon = "star",
							id = "upgrade_hatch_luck",
							level = 3,
							maxLevel = 10,
							title = "Hatch Luck",
						},
					},
				},
				{
					buttons = {
						{
							action = { kind = "Open", target = "rebirth" },
							icon = "rebirth",
							id = "goto_rebirth",
							label = "Prestige",
							tone = "White",
						},
					},
					id = "buttons_3",
					kind = "Buttons",
					layout = "Row",
				},
			},
			size = "Medium",
			title = "Boosts & Upgrades",
		},
		{
			description = "Where Coins and Robux turn into progress. Tabs: Featured, Eggs, Passes, Tokens and Boosts.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					callout = "New today!",
					icon = "sale",
					id = "tab_featured",
					label = "Featured",
					sections = {
						{
							id = "today_s_picks",
							items = {
								{
									amount = "6,000",
									currency = "robux",
									description = "6,000 Tokens + an exclusive title",
									grant = { amount = 6000, currency = "tokens" },
									icon = "crown",
									id = "featured_mega",
									price = 1499,
									tag = "LIMITED",
									title = "Mega Bundle",
									tone = "Pink",
									was = 3999,
								},
								{
									currency = "tokens",
									description = "This week only",
									icon = "butterfly-wings",
									id = "featured_rainbow_butterfly",
									price = 1750,
									tag = "FEATURED",
									title = "Rainbow Butterfly",
									tone = "Red",
								},
								{
									amount = "800",
									currency = "robux",
									description = "800 Tokens + Rainbow Butterfly",
									grant = { amount = 800, currency = "tokens" },
									icon = "gift",
									id = "featured_starter",
									price = 199,
									tag = "ONE TIME",
									title = "Newcomer Bundle",
									tone = "Purple",
									was = 399,
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Today's Picks",
						},
					},
				},
				{
					badge = "NEW",
					icon = "egg",
					id = "tab_eggs",
					label = "Eggs",
					sections = {
						{
							cases = {
								{
									currency = "coins",
									icon = "egg",
									id = "case_spotted_egg",
									pool = {
										{ chance = 28, icon = "fish", name = "Goldfish", rarity = "Common" },
										{ chance = 28, icon = "parrot", name = "Parrot", rarity = "Common" },
										{ chance = 13, icon = "butterfly-wings", name = "Butterfly", rarity = "Uncommon" },
										{ chance = 13, icon = "bug", name = "Ladybug", rarity = "Uncommon" },
										{ chance = 6.2, icon = "bug", name = "Firefly", rarity = "Rare" },
										{ chance = 6.2, icon = "fish", name = "Koi", rarity = "Rare" },
										{ chance = 2.8, icon = "pig", name = "Golden Pig", rarity = "Epic" },
										{ chance = 2.8, icon = "lightning-bolt", name = "Storm Sprite", rarity = "Epic" },
									},
									price = 1750,
									title = "Spotted Egg",
									tone = "Green",
								},
								{
									currency = "coins",
									icon = "egg",
									id = "case_crystal_egg",
									pool = {
										{ chance = 29.2, icon = "butterfly-wings", name = "Butterfly", rarity = "Uncommon" },
										{ chance = 28, icon = "bug", name = "Ladybug", rarity = "Uncommon" },
										{ chance = 13, icon = "bug", name = "Firefly", rarity = "Rare" },
										{ chance = 13, icon = "fish", name = "Koi", rarity = "Rare" },
										{ chance = 6.2, icon = "pig", name = "Golden Pig", rarity = "Epic" },
										{ chance = 6.2, icon = "lightning-bolt", name = "Storm Sprite", rarity = "Epic" },
										{ chance = 2.2, icon = "flame", name = "Blazing Dragon", rarity = "Legendary" },
										{ chance = 2.2, icon = "moon", name = "Moon Spirit", rarity = "Legendary" },
									},
									price = 4500,
									title = "Crystal Egg",
									tone = "Cyan",
								},
								{
									currency = "coins",
									icon = "egg",
									id = "case_magma_egg",
									pool = {
										{ chance = 29.8, icon = "bug", name = "Firefly", rarity = "Rare" },
										{ chance = 30, icon = "fish", name = "Koi", rarity = "Rare" },
										{ chance = 14, icon = "pig", name = "Golden Pig", rarity = "Epic" },
										{ chance = 14, icon = "lightning-bolt", name = "Storm Sprite", rarity = "Epic" },
										{ chance = 5, icon = "flame", name = "Blazing Dragon", rarity = "Legendary" },
										{ chance = 5, icon = "moon", name = "Moon Spirit", rarity = "Legendary" },
										{ chance = 1.1, icon = "nebula", name = "Nebula Wisp", rarity = "Mythic" },
										{ chance = 1.1, icon = "butterfly-wings", name = "Rainbow Butterfly", rarity = "Mythic" },
									},
									price = 15000,
									title = "Magma Egg",
									tone = "Orange",
								},
								{
									currency = "coins",
									icon = "egg",
									id = "case_galaxy_egg",
									pool = {
										{ chance = 29.8, icon = "bug", name = "Firefly", rarity = "Rare" },
										{ chance = 30, icon = "fish", name = "Koi", rarity = "Rare" },
										{ chance = 14, icon = "pig", name = "Golden Pig", rarity = "Epic" },
										{ chance = 14, icon = "lightning-bolt", name = "Storm Sprite", rarity = "Epic" },
										{ chance = 5, icon = "flame", name = "Blazing Dragon", rarity = "Legendary" },
										{ chance = 5, icon = "moon", name = "Moon Spirit", rarity = "Legendary" },
										{ chance = 1.1, icon = "nebula", name = "Nebula Wisp", rarity = "Mythic" },
										{ chance = 1.1, icon = "butterfly-wings", name = "Rainbow Butterfly", rarity = "Mythic" },
									},
									price = 40000,
									title = "Galaxy Egg",
									tone = "Purple",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Coins",
									icon = "crown",
									id = "pass_vip",
									price = 399,
									tag = "MUST HAVE",
									title = "VIP",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Earn twice the Coins, forever",
									icon = "coin",
									id = "pass_2x_coins",
									price = 299,
									title = "2x Coins",
									tone = "Cyan",
								},
								{
									currency = "robux",
									description = "Crack three eggs at once",
									icon = "egg",
									id = "pass_triple_hatch",
									price = 299,
									title = "Triple Hatch",
									tone = "Purple",
								},
								{
									currency = "robux",
									description = "Eggs crack open in half the time",
									icon = "lightning-bolt",
									id = "pass_fast_hatch",
									price = 249,
									title = "Fast Hatch",
									tone = "Cyan",
								},
								{
									currency = "robux",
									description = "More room for everything you find",
									icon = "backpack",
									id = "pass_3_pet_slots",
									price = 149,
									title = "+3 Pet Slots",
									tone = "Purple",
								},
							},
							kind = "Products",
							layout = "Carousel",
							title = "Gamepasses",
						},
					},
				},
				{
					icon = "star",
					id = "tab_tokens",
					label = "Tokens",
					sections = {
						{
							id = "token_bundles",
							items = {
								{
									amount = "125",
									currency = "robux",
									grant = { amount = 125, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_1",
									price = 49,
									title = "Starter Tokens",
									tone = "Purple",
								},
								{
									amount = "300",
									bonus = 19,
									currency = "robux",
									grant = { amount = 300, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_2",
									price = 99,
									title = "Small Token Pack",
									tone = "Purple",
								},
								{
									amount = "625",
									bonus = 23,
									currency = "robux",
									grant = { amount = 625, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_3",
									price = 199,
									tag = "POPULAR",
									title = "Big Token Pack",
									tone = "Pink",
								},
								{
									amount = "1,500",
									bonus = 47,
									currency = "robux",
									grant = { amount = 1500, currency = "tokens" },
									icon = "file-box",
									id = "tokens_pack_4",
									price = 399,
									title = "Mega Token Pack",
									tone = "Pink",
								},
								{
									amount = "3,250",
									bonus = 59,
									currency = "robux",
									grant = { amount = 3250, currency = "tokens" },
									icon = "building-bank",
									id = "tokens_pack_5",
									price = 799,
									title = "Giga Token Pack",
									tone = "Cyan",
								},
								{
									amount = "6,750",
									bonus = 77,
									currency = "robux",
									grant = { amount = 6750, currency = "tokens" },
									icon = "crown",
									id = "tokens_pack_6",
									price = 1499,
									tag = "BEST VALUE",
									title = "Ultra Token Pack",
									tone = "Cyan",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Token Bundles",
						},
					},
				},
				{
					icon = "boost",
					id = "tab_boosts",
					label = "Boosts",
					sections = {
						{
							id = "boosts",
							items = {
								{
									amount = "15 min",
									currency = "tokens",
									icon = "star",
									id = "boost_2x_luck",
									price = 90,
									title = "2x Luck",
									tone = "Purple",
								},
								{
									amount = "30 min",
									currency = "tokens",
									icon = "cursor-stars",
									id = "boost_super_luck",
									price = 150,
									title = "Super Luck",
									tone = "Cyan",
								},
								{
									amount = "20 min",
									currency = "tokens",
									icon = "lightning-bolt",
									id = "boost_2x_hatch_speed",
									price = 80,
									title = "2x Hatch Speed",
									tone = "Purple",
								},
							},
							kind = "Products",
							layout = "Compact",
							title = "Boosts",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "The map of Critter Simulator: every world as a card, locked ones showing what it takes to get there.",
			icon = "map",
			id = "worlds",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "worlds_2",
					kind = "Worlds",
					worlds = {
						{
							icon = "wheat",
							id = "world_spawn_meadow",
							locked = false,
							name = "Spawn Meadow",
							tone = "Pink",
						},
						{
							icon = "diamond",
							id = "world_crystal_caves",
							locked = false,
							name = "Crystal Caves",
							tone = "Purple",
						},
						{
							icon = "lollipop",
							id = "world_candy_land",
							locked = true,
							name = "Candy Land",
							requirement = "Rebirth 2",
							tone = "Cyan",
						},
						{
							icon = "flame",
							id = "world_volcano",
							locked = true,
							name = "Volcano",
							requirement = "2.4M Coins",
							tone = "Pink",
						},
						{
							icon = "cloud",
							id = "world_frozen_peaks",
							locked = true,
							name = "Frozen Peaks",
							requirement = "13M Coins",
							tone = "Purple",
						},
						{
							icon = "lightning-bolt",
							id = "world_cyber_city",
							locked = true,
							name = "Cyber City",
							requirement = "Rebirth 5",
							tone = "Cyan",
						},
						{
							icon = "nebula",
							id = "world_galaxy",
							locked = true,
							name = "Galaxy",
							requirement = "280M Coins",
							tone = "Pink",
						},
					},
				},
			},
			size = "Large",
			title = "Worlds",
		},
		{
			description = "Daily and weekly quests with progress bars; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					badge = 1,
					icon = "daily",
					id = "tab_daily_2",
					label = "Daily",
					sections = {
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Earn 92,500 Coins",
									goal = 92500,
									icon = "coin",
									id = "quest_collector",
									progress = 92500,
									reward = { amount = "25", icon = "star" },
									title = "Collector",
								},
								{
									description = "Hatch 8 Rare pets",
									goal = 8,
									icon = "star",
									id = "quest_lucky_day",
									progress = 6,
									reward = { amount = "25", icon = "star" },
									title = "Lucky Day",
								},
								{
									description = "Unlock 2 new worlds",
									goal = 2,
									icon = "world",
									id = "quest_explorer",
									progress = 0,
									reward = { amount = "50", icon = "star" },
									title = "Explorer",
								},
							},
						},
					},
				},
				{
					icon = "calendar",
					id = "tab_weekly",
					label = "Weekly",
					sections = {
						{
							id = "quests_3",
							kind = "Quests",
							quests = {
								{
									description = "Equip 3 pets",
									goal = 3,
									icon = "pet",
									id = "quest_pet_pal",
									progress = 1,
									reward = { amount = "25", icon = "star" },
									title = "Pet Pal",
								},
							},
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_achievements",
					label = "Achievements",
					sections = {
						{
							achievements = {
								{
									description = "Hatch 100 eggs",
									goal = 100,
									icon = "egg",
									id = "ach_egg_cracker",
									points = 15,
									progress = 100,
									reward = { amount = "10", grant = { amount = 10, currency = "tokens" }, icon = "star" },
									title = "Egg Cracker",
								},
								{
									description = "Click 50,000 times",
									goal = 50000,
									icon = "cursor",
									id = "ach_clicker_pro",
									points = 20,
									progress = 33433,
									reward = { amount = "20", grant = { amount = 20, currency = "tokens" }, icon = "star" },
									title = "Clicker Pro",
								},
								{
									description = "Rebirth 5 times",
									goal = 5,
									icon = "rebirth",
									id = "ach_reborn",
									points = 25,
									progress = 1,
									reward = { amount = "25", grant = { amount = 25, currency = "tokens" }, icon = "star" },
									title = "Reborn",
								},
								{
									description = "Unlock 4 worlds",
									goal = 4,
									icon = "world",
									id = "ach_world_hopper",
									points = 30,
									progress = 0,
									reward = { amount = "30", grant = { amount = 30, currency = "tokens" }, icon = "star" },
									title = "World Hopper",
								},
								{
									description = "Earn 1,000,000 Coins",
									goal = 1000000,
									icon = "coin",
									id = "ach_millionaire",
									points = 30,
									progress = 0,
									reward = { amount = "25", grant = { amount = 25, currency = "tokens" }, icon = "star" },
									title = "Millionaire",
								},
								{
									description = "Hatch a Legendary pet",
									goal = 1,
									icon = "star",
									id = "ach_lucky_star",
									points = 40,
									progress = 0,
									reward = { amount = "45", grant = { amount = 45, currency = "tokens" }, icon = "star" },
									title = "Lucky Star",
								},
								{
									description = "Hatch 10,000 eggs",
									goal = 10000,
									icon = "egg",
									id = "ach_egg_legend",
									points = 60,
									progress = 0,
									reward = { amount = "65", grant = { amount = 65, currency = "tokens" }, icon = "star" },
									title = "Egg Legend",
								},
							},
							id = "achievements",
							kind = "Achievements",
						},
					},
				},
			},
			title = "Challenges",
		},
		{
			description = "Global top ten for Total Coins, Rebirths and Eggs Hatched, with podium colours for the top three.",
			icon = "trophy",
			id = "leaderboard",
			kind = "Window",
			nav = "Tabs",
			size = "Medium",
			tabs = {
				{
					icon = "trophy",
					id = "tab_total_coins",
					label = "Total Coins",
					sections = {
						{
							id = "leaderboard_2",
							kind = "Leaderboard",
							rows = {
								{ name = "NeonLlama404", rank = 1, value = "25M" },
								{ name = "RealNoodleYeti", rank = 2, value = "19.2M" },
								{ name = "FrostDash", rank = 3, value = "14.1M" },
								{ name = "RealGhostRider", rank = 4, value = "12.9M" },
								{ name = "ItsStormGoblin", rank = 5, value = "11.6M" },
								{ name = "xXBlitzGamerXx", rank = 6, value = "9.9M" },
								{ name = "GhostBot88", rank = 7, value = "7.1M" },
								{ name = "ItsOnyxRider", rank = 8, value = "6.7M" },
								{ name = "ZapPanda_88", rank = 9, value = "5.9M" },
								{ name = "Rogue_Byte", rank = 10, value = "4.5M" },
							},
							stat = "Total Coins",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_rebirths",
					label = "Rebirths",
					sections = {
						{
							id = "leaderboard_3",
							kind = "Leaderboard",
							rows = {
								{ name = "NotArcticCat", rank = 1, value = "2,400" },
								{ name = "GlitchSlayerYT", rank = 2, value = "1,978" },
								{ name = "ItsCosmicSpark", rank = 3, value = "1,835" },
								{ name = "RealMapleWolf", rank = 4, value = "1,678" },
								{ name = "NovaWolfYT", rank = 5, value = "1,517" },
								{ name = "RealRocketBlade", rank = 6, value = "1,239" },
								{ name = "RocketDuck", rank = 7, value = "1,006" },
								{ name = "RealSunnyRanger", rank = 8, value = "810" },
								{ name = "xXEchoDashXx", rank = 9, value = "619" },
								{ name = "ItsNoodlePup", rank = 10, value = "505" },
							},
							stat = "Rebirths",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_eggs_hatched",
					label = "Eggs Hatched",
					sections = {
						{
							id = "leaderboard_4",
							kind = "Leaderboard",
							rows = {
								{ name = "xXPepperByteXx", rank = 1, value = "25M" },
								{ name = "xXNeonDuckXx", rank = 2, value = "21.9M" },
								{ name = "Chaos404", rank = 3, value = "20.3M" },
								{ name = "RealNeonFox", rank = 4, value = "16.7M" },
								{ name = "CookieLlama_2014", rank = 5, value = "14.5M" },
								{ name = "xXNovaBladeXx", rank = 6, value = "13.8M" },
								{ name = "ViperHunter_777", rank = 7, value = "10.4M" },
								{ name = "ItsGhostOtter", rank = 8, value = "8.3M" },
								{ name = "xXCrimsonWolfXx", rank = 9, value = "6.8M" },
								{ name = "ZapBunny_101", rank = 10, value = "5.5M" },
							},
							stat = "Eggs Hatched",
						},
					},
				},
			},
			title = "Rankings",
		},
		{
			description = "Trade your Coins for a permanent multiplier. The popup spells out exactly what is lost and gained so the decision feels safe.",
			icon = "rebirth",
			id = "rebirth",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					id = "stats_2",
					kind = "Stats",
					stats = {
						{ icon = "rebirth", label = "Prestiges", value = "3" },
						{ icon = "boost", label = "Multiplier", value = "x4.5 → x6" },
						{ icon = "coin", label = "Cost", value = "6.8M" },
						{ bar = 0.80365246906877, icon = "level", label = "Progress", value = "65%" },
					},
				},
				{
					body = "Prestige resets your Coins and upgrades, but keeps your pets and Tokens.",
					id = "text",
					kind = "Text",
				},
				{
					buttons = {
						{
							action = { kind = "Event", name = "Rebirth" },
							icon = "rebirth",
							id = "prestige",
							label = "Prestige",
							tone = "Pink",
						},
						{
							action = { kind = "Close" },
							icon = "close",
							id = "not_yet",
							label = "Not Yet",
							tone = "White",
						},
					},
					id = "buttons_2",
					kind = "Buttons",
					layout = "Row",
				},
			},
			size = "Medium",
			title = "Prestige",
			transition = "Pop",
		},
		{
			description = "Redeem promo codes. The server validates them through the RedeemCode remote.",
			icon = "tag",
			id = "codes",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					body = "New codes drop with every update and milestone. Check the game page!",
					id = "text_2",
					kind = "Text",
				},
				{
					action = { kind = "Remote", name = "RedeemCode" },
					button = "Redeem",
					id = "codes_2",
					kind = "Codes",
					placeholder = "Enter a code",
				},
			},
			size = "Small",
			title = "Codes",
			transition = "Pop",
		},
		{
			delay = 5,
			description = "A one-time Starter Pack: 4 rewards for 99 Robux (was 299), counting down 24h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "pig",
			id = "offer",
			items = {
				{
					amount = "375",
					grant = { amount = 375, currency = "tokens" },
					icon = "star",
					label = "Tokens",
				},
				{
					amount = "60,000",
					grant = { amount = 60000, currency = "coins" },
					icon = "coin",
					label = "Coins",
				},
				{ amount = "1", icon = "pig", label = "Epic Golden Pig" },
				{ amount = "3", icon = "coin", label = "2x Coins (15m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "STARTER PACK",
			timer = 86400,
			title = "Starter Pack",
			tone = "Cyan",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "skip-next-large",
									id = "skipHatch",
									label = "Skip Hatch Animation",
									type = "Toggle",
								},
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "pet",
									id = "showOtherPets",
									label = "Show Other Pets",
									type = "Toggle",
								},
								{
									default = true,
									icon = "two-arrows-left-right",
									id = "tradeRequests",
									label = "Trade Requests",
									type = "Toggle",
								},
							},
						},
						{
							buttons = {
								{
									action = { kind = "Open", target = "codes" },
									icon = "tag",
									id = "goto_codes",
									label = "Codes",
									tone = "White",
								},
							},
							id = "buttons_4",
							kind = "Buttons",
							layout = "Row",
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	style = {
		archetype = "Bubbly",
		buttonStyle = "Glossy3D",
		clickFx = "Poof",
		corner = "Bubbly",
		decoration = "Dots",
		density = "Comfy",
		fonts = { body = "Nunito:Bold", display = "DenkOne", title = "DenkOne" },
		iconStyle = "Filled",
		palette = {
			accent = "#4DB6AC",
			bad = "#E53935",
			good = "#4CAF50",
			primary = "#F06292",
			secondary = "#7E57C2",
			surface = "#3E2238",
			text = "#FFF5F8",
			warn = "#FFB300",
		},
		panelStyle = "Outlined",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Pop",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="70">
              <Properties>
                <string name="Name">sports_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: sports, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#5BE36B",
			format = "comma",
			icon = "cash",
			id = "cash",
			name = "Cash",
			source = { kind = "leaderstat", name = "Cash" },
			start = 4750,
		},
		{
			color = "#3AD1FF",
			format = "comma",
			icon = "gem",
			id = "gems",
			name = "Gems",
			source = { kind = "attribute", name = "Gems" },
			start = 300,
		},
	},
	hud = {
		elements = {
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopLeft",
				currencies = { "cash", "gems" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "TopCenter",
				goal = 7,
				id = "hud_teamscore",
				teams = {
					{ color = "#2E8BFF", name = "Blue Lions", score = 3 },
					{ color = "#FF4D4D", name = "Red Hawks", score = 2 },
				},
				type = "TeamScore",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Match",
				seconds = 300,
				type = "Timer",
				visible = true,
			},
			{ anchor = "BottomCenter", id = "hud_stamina", type = "Stamina", visible = true },
			{
				action = { kind = "Open", target = "menu" },
				anchor = "Left",
				icon = "menu",
				id = "hud_button",
				label = "Menu",
				tone = "Dark",
				type = "Button",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonR2", key = "F", text = "Shoot" },
					{ gamepad = "ButtonA", key = "E", text = "Pass" },
					{ gamepad = "ButtonL3", key = "LeftShift", text = "Sprint" },
					{ gamepad = "ButtonB", key = "Q", text = "Tackle" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "Center",
				emotes = {
					{ icon = "megaphone-cheer", id = "emote_cheer", label = "Cheer" },
					{ icon = "flame", id = "emote_taunt", label = "Taunt" },
					{ icon = "music-note", id = "emote_dance", label = "Dance" },
					{ icon = "face-winking", id = "emote_laugh", label = "Laugh" },
					{ icon = "square-face-waving-hand", id = "emote_wave", label = "Wave" },
					{ icon = "crown", id = "emote_crown", label = "Crown" },
				},
				id = "hud_emotewheel",
				key = "T",
				type = "EmoteWheel",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Shoot" },
						icon = "trophy",
						id = "touch_shoot",
						label = "Shoot",
					},
					{
						action = { kind = "Event", name = "Pass" },
						icon = "two-arrows-left-right",
						id = "touch_pass",
						label = "Pass",
					},
					{
						action = { kind = "Event", name = "Tackle" },
						icon = "shield",
						id = "touch_tackle",
						label = "Tackle",
					},
					{
						action = { kind = "Event", name = "Sprint" },
						icon = "run",
						id = "touch_sprint",
						label = "Sprint",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				icon = "shoe",
				id = "hud_meter",
				label = "POWER",
				low = 0,
				segments = 8,
				style = "Segments",
				tone = "Green",
				type = "Meter",
				value = 0.85,
				visible = true,
			},
			{ anchor = "Right", id = "hud_killfeed", type = "KillFeed", visible = true },
			{
				anchor = "Right",
				buttons = {
					{
						action = { kind = "Open", target = "teams" },
						icon = "friends",
						id = "btn_teams",
						label = "Teams",
						tone = "Dark",
					},
					{
						action = { kind = "Open", target = "daily" },
						badge = 1,
						icon = "daily",
						id = "btn_daily",
						label = "Daily Rewards",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
		},
		kit = "Arcade",
		layout = "TopBar",
	},
	logo = { icon = "trophy", text = "STREET LEGENDS", tilt = 0, tone = "Green", type = "Emblem" },
	meta = {
		flavor = "soccer",
		genre = "sports",
		kit = "Arcade",
		prompt = "a sports game",
		seed = 21,
		source = "offline",
		title = "Street Legends",
	},
	screens = {
		{
			description = "The title screen: the Street Legends logo, KICK OFF, Locker Room, Shop, Pass and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "KICK OFF",
							tone = "Green",
						},
						{
							action = { kind = "Open", target = "kit" },
							icon = "tshirt",
							id = "menu_kit",
							label = "Locker Room",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "battlepass" },
							icon = "premium",
							id = "menu_battlepass",
							label = "Pass",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons_2",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "flame",
					id = "welcome_bundle",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 172800,
					title = "Welcome Bundle",
					tone = "Yellow",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Team selection before kick-off: live player counts per side and a big join button in each team's colour.",
			icon = "friends",
			id = "teams",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					id = "stats",
					kind = "Stats",
					stats = {
						{ bar = 1, icon = "two-people", label = "Blue Lions", value = "5/5 players" },
						{ bar = 0.6, icon = "two-people", label = "Red Hawks", value = "3/5 players" },
					},
				},
				{
					buttons = {
						{
							action = { kind = "Remote", name = "JoinTeam" },
							icon = "flag",
							id = "join_blue_lions",
							label = "Join Blue Lions",
							tone = "Blue",
						},
						{
							action = { kind = "Remote", name = "JoinTeam" },
							icon = "flag",
							id = "join_red_hawks",
							label = "Join Red Hawks",
							tone = "Red",
						},
						{
							action = { kind = "Remote", name = "JoinTeam" },
							icon = "two-arrows-switch-right",
							id = "join_auto",
							label = "Auto-Assign",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Row",
				},
			},
			size = "Medium",
			title = "Teams",
			transition = "Fade",
		},
		{
			description = "Where Cash and Robux turn into progress. Tabs: Featured, Items, Packs and Gems.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					callout = "New today!",
					icon = "sale",
					id = "tab_featured",
					label = "Featured",
					sections = {
						{
							id = "today_s_picks",
							items = {
								{
									amount = "3,250",
									currency = "robux",
									description = "3,250 Gems + an exclusive title",
									grant = { amount = 3250, currency = "gems" },
									icon = "crown",
									id = "featured_mega",
									price = 999,
									tag = "LIMITED",
									title = "Mega Bundle",
									tone = "Green",
									was = 1999,
								},
								{
									currency = "gems",
									description = "Leaves the shop soon",
									icon = "flame",
									id = "featured_flame_ball",
									price = 525,
									tag = "FEATURED",
									title = "Flame Ball",
									tone = "Purple",
								},
								{
									amount = "475",
									currency = "robux",
									description = "475 Gems + Flame Ball",
									grant = { amount = 475, currency = "gems" },
									icon = "gift",
									id = "featured_starter",
									price = 149,
									tag = "ONE TIME",
									title = "Newcomer Bundle",
									tone = "Blue",
									was = 299,
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Today's Picks",
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_items",
					label = "Items",
					sections = {
						{
							id = "products",
							items = {
								{
									amount = "Common",
									currency = "cash",
									icon = "tshirt",
									id = "buy_away_kit",
									price = 225,
									title = "Away Kit",
									tone = "Gray",
								},
								{
									amount = "Common",
									currency = "cash",
									icon = "tshirt",
									id = "buy_home_kit",
									price = 225,
									title = "Home Kit",
									tone = "Gray",
								},
								{
									amount = "Rare",
									currency = "cash",
									icon = "lightning-bolt",
									id = "buy_neon_ball",
									price = 1750,
									title = "Neon Ball",
									tone = "Blue",
								},
								{
									amount = "Rare",
									currency = "cash",
									icon = "shoe",
									id = "buy_speed_boots",
									price = 1750,
									title = "Speed Boots",
									tone = "Blue",
								},
								{
									amount = "Legendary",
									currency = "cash",
									icon = "shoe",
									id = "buy_golden_boots",
									price = 10000,
									title = "Golden Boots",
									tone = "Yellow",
								},
								{
									amount = "Mythic",
									currency = "cash",
									icon = "nebula",
									id = "buy_galaxy_ball",
									price = 27500,
									tag = "LIMITED",
									title = "Galaxy Ball",
									tone = "Red",
								},
							},
							kind = "Products",
							layout = "Rows",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_packs",
					label = "Packs",
					sections = {
						{
							cases = {
								{
									currency = "cash",
									icon = "star",
									id = "case_gold_pack",
									pool = {
										{ chance = 32.4, icon = "tshirt", name = "Away Kit", rarity = "Common" },
										{ chance = 32, icon = "tshirt", name = "Home Kit", rarity = "Common" },
										{ chance = 15, icon = "shirt", name = "Retro Kit", rarity = "Uncommon" },
										{ chance = 7.1, icon = "star", name = "Captain Armband", rarity = "Rare" },
										{ chance = 7.1, icon = "lightning-bolt", name = "Neon Ball", rarity = "Rare" },
										{ chance = 3.2, icon = "flame", name = "Flame Ball", rarity = "Epic" },
										{ chance = 3.2, icon = "shoe", name = "Power Boots", rarity = "Epic" },
									},
									price = 275,
									title = "Gold Pack",
									tone = "Yellow",
								},
								{
									currency = "cash",
									icon = "diamond",
									id = "case_rare_pack",
									pool = {
										{ chance = 41, icon = "shirt", name = "Retro Kit", rarity = "Uncommon" },
										{ chance = 19, icon = "star", name = "Captain Armband", rarity = "Rare" },
										{ chance = 19, icon = "lightning-bolt", name = "Neon Ball", rarity = "Rare" },
										{ chance = 8.9, icon = "flame", name = "Flame Ball", rarity = "Epic" },
										{ chance = 8.9, icon = "shoe", name = "Power Boots", rarity = "Epic" },
										{ chance = 3.2, icon = "shoe", name = "Golden Boots", rarity = "Legendary" },
									},
									price = 700,
									title = "Rare Pack",
									tone = "Blue",
								},
								{
									currency = "gems",
									icon = "crown",
									id = "case_icon_pack",
									pool = {
										{ chance = 31.5, icon = "star", name = "Captain Armband", rarity = "Rare" },
										{ chance = 32, icon = "lightning-bolt", name = "Neon Ball", rarity = "Rare" },
										{ chance = 15, icon = "flame", name = "Flame Ball", rarity = "Epic" },
										{ chance = 15, icon = "shoe", name = "Power Boots", rarity = "Epic" },
										{ chance = 5.3, icon = "shoe", name = "Golden Boots", rarity = "Legendary" },
										{ chance = 1.2, icon = "nebula", name = "Galaxy Ball", rarity = "Mythic" },
									},
									price = 375,
									title = "Icon Pack",
									tone = "Purple",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "gem",
					id = "tab_gems",
					label = "Gems",
					sections = {
						{
							icon = "gem",
							id = "stock_up_on_gems",
							kind = "Banner",
							subtitle = "Bigger packs pack more bonus Gems",
							title = "Stock Up on Gems",
							tone = "Green",
						},
						{
							id = "gem_bundles",
							items = {
								{
									amount = "100",
									currency = "robux",
									grant = { amount = 100, currency = "gems" },
									icon = "gem",
									id = "gems_pack_1",
									price = 49,
									title = "Handful of Gems",
									tone = "Blue",
								},
								{
									amount = "225",
									bonus = 11,
									currency = "robux",
									grant = { amount = 225, currency = "gems" },
									icon = "gem",
									id = "gems_pack_2",
									price = 99,
									tag = "SALE",
									title = "Pouch of Gems",
									tone = "Blue",
									was = 149,
								},
								{
									amount = "500",
									bonus = 23,
									currency = "robux",
									grant = { amount = 500, currency = "gems" },
									icon = "gem",
									id = "gems_pack_3",
									price = 199,
									title = "Sack of Gems",
									tone = "Green",
								},
								{
									amount = "1,250",
									bonus = 54,
									currency = "robux",
									grant = { amount = 1250, currency = "gems" },
									icon = "file-box",
									id = "gems_pack_4",
									price = 399,
									tag = "BEST VALUE",
									title = "Chest of Gems",
									tone = "Green",
								},
							},
							kind = "Products",
							layout = "Rows",
							title = "Gem Bundles",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Boots, kit and ball: what you take onto the pitch, with pace, shooting and passing bars.",
			icon = "tshirt",
			id = "kit",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "tshirt",
					id = "tab_equipped",
					label = "Equipped",
					sections = {
						{
							id = "loadout",
							kind = "Loadout",
							slots = {
								{
									icon = "shoe",
									name = "Speed Boots",
									rarity = "Rare",
									stats = {
										{ label = "Pace", value = 0.67 },
										{ label = "Shooting", value = 0.6 },
										{ label = "Passing", value = 0.76 },
										{ label = "Stamina", value = 0.79 },
									},
								},
								{
									icon = "shirt",
									name = "Retro Kit",
									rarity = "Uncommon",
									stats = {
										{ label = "Pace", value = 0.34 },
										{ label = "Shooting", value = 0.37 },
										{ label = "Passing", value = 0.53 },
										{ label = "Stamina", value = 0.48 },
									},
								},
								{
									icon = "flame",
									name = "Flame Ball",
									rarity = "Epic",
									stats = {
										{ label = "Pace", value = 0.67 },
										{ label = "Shooting", value = 0.89 },
										{ label = "Passing", value = 0.87 },
										{ label = "Stamina", value = 0.58 },
									},
								},
							},
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_all_gear",
					label = "All Gear",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{ icon = "nebula", id = "inv_galaxy_ball", name = "Galaxy Ball", rarity = "Mythic" },
								{ icon = "shoe", id = "inv_golden_boots", name = "Golden Boots", rarity = "Legendary" },
								{
									equipped = true,
									icon = "flame",
									id = "inv_flame_ball",
									name = "Flame Ball",
									rarity = "Epic",
								},
								{ icon = "shoe", id = "inv_power_boots", name = "Power Boots", rarity = "Epic" },
								{ icon = "lightning-bolt", id = "inv_neon_ball", name = "Neon Ball", rarity = "Rare" },
								{
									equipped = true,
									icon = "shoe",
									id = "inv_speed_boots",
									name = "Speed Boots",
									rarity = "Rare",
								},
								{
									equipped = true,
									icon = "shirt",
									id = "inv_retro_kit",
									name = "Retro Kit",
									rarity = "Uncommon",
								},
								{ icon = "tshirt", id = "inv_away_kit", name = "Away Kit", rarity = "Common" },
								{ icon = "tshirt", id = "inv_home_kit", name = "Home Kit", rarity = "Common" },
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
			},
			title = "Locker Room",
		},
		{
			description = "20 tiers of free and premium rewards for the season, plus weekly challenges that feed pass XP.",
			icon = "premium",
			id = "battlepass",
			kind = "Fullscreen",
			nav = "None",
			panelStyle = "Glass",
			sections = {
				{
					button = { action = { kind = "Event", name = "BuyPremiumPass" }, label = "Unlock Premium" },
					icon = "premium",
					id = "season_1_pass",
					kind = "Banner",
					subtitle = "Unlock the premium track: 20 exclusive rewards",
					timer = 1382400,
					title = "Season 1 Pass",
					tone = "Green",
				},
				{
					id = "pass",
					kind = "Pass",
					tiers = {
						{
							free = { amount = "175", icon = "cash" },
							premium = { amount = "x1", icon = "megaphone-cheer" },
							tier = 1,
						},
						{ premium = { amount = "x1", icon = "shoe" }, tier = 2 },
						{
							free = { amount = "250", icon = "cash" },
							premium = { amount = "x1", icon = "tshirt" },
							tier = 3,
						},
						{ premium = { amount = "40", icon = "gem" }, tier = 4 },
						{
							free = { amount = "x1", icon = "tshirt" },
							premium = { amount = "x1", icon = "shoe" },
							tier = 5,
						},
						{ premium = { amount = "x1", icon = "shoe" }, tier = 6 },
						{
							free = { amount = "400", icon = "cash" },
							premium = { amount = "x1", icon = "megaphone-cheer" },
							tier = 7,
						},
						{ premium = { amount = "60", icon = "gem" }, tier = 8 },
						{
							free = { amount = "450", icon = "cash" },
							premium = { amount = "x1", icon = "shoe" },
							tier = 9,
						},
						{
							free = { amount = "x1", icon = "gem" },
							premium = { amount = "x1", icon = "crown" },
							tier = 10,
						},
						{
							free = { amount = "550", icon = "cash" },
							premium = { amount = "x1", icon = "tshirt" },
							tier = 11,
						},
						{ premium = { amount = "80", icon = "gem" }, tier = 12 },
						{
							free = { amount = "625", icon = "cash" },
							premium = { amount = "x1", icon = "megaphone-cheer" },
							tier = 13,
						},
						{ premium = { amount = "x1", icon = "megaphone-cheer" }, tier = 14 },
						{
							free = { amount = "x1", icon = "tshirt" },
							premium = { amount = "95", icon = "gem" },
							tier = 15,
						},
						{ premium = { amount = "100", icon = "gem" }, tier = 16 },
						{
							free = { amount = "775", icon = "cash" },
							premium = { amount = "x1", icon = "megaphone-cheer" },
							tier = 17,
						},
						{ premium = { amount = "x1", icon = "tshirt" }, tier = 18 },
						{
							free = { amount = "850", icon = "cash" },
							premium = { amount = "x1", icon = "megaphone-cheer" },
							tier = 19,
						},
						{
							free = { amount = "x1", icon = "gem" },
							premium = { amount = "x1", icon = "crown" },
							tier = 20,
						},
					},
				},
				{
					id = "challenges",
					kind = "Quests",
					quests = {
						{
							description = "Score 4 goals",
							goal = 4,
							icon = "trophy",
							id = "quest_hat_trick",
							progress = 4,
							reward = { amount = "10", icon = "gem" },
							title = "Hat-trick",
						},
						{
							description = "Make 7 saves",
							goal = 7,
							icon = "shield",
							id = "quest_brick_wall",
							progress = 5,
							reward = { amount = "50", icon = "gem" },
							title = "Brick Wall",
						},
						{
							description = "Make 5 assists",
							goal = 5,
							icon = "two-people",
							id = "quest_playmaker",
							progress = 2,
							reward = { amount = "475", icon = "cash" },
							title = "Playmaker",
						},
						{
							description = "Win 4 matches in a row",
							goal = 4,
							icon = "flame",
							id = "quest_on_fire",
							progress = 0,
							reward = { amount = "300", icon = "cash" },
							title = "On Fire",
						},
					},
					title = "Challenges",
				},
			},
			size = "Large",
			title = "Pass",
		},
		{
			description = "Pops up on the first join of the day: a month of rewards seven across, today's tile pulsing until claimed, the streak counted on top.",
			icon = "daily",
			id = "daily",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					days = {
						{ amount = "375", day = 1, icon = "cash" },
						{ amount = "575", day = 2, icon = "cash" },
						{ amount = "x2", day = 3, icon = "boost" },
						{ amount = "30", day = 4, icon = "gem" },
						{ amount = "1,250", day = 5, icon = "cash" },
						{ amount = "1,250", day = 6, icon = "cash" },
						{ amount = "75", day = 7, icon = "gem" },
						{ amount = "600", day = 8, icon = "cash" },
						{ amount = "900", day = 9, icon = "cash" },
						{ amount = "x2", day = 10, icon = "boost" },
						{ amount = "50", day = 11, icon = "gem" },
						{ amount = "1,750", day = 12, icon = "cash" },
						{ amount = "2,000", day = 13, icon = "cash" },
						{ amount = "125", day = 14, icon = "gem" },
						{ amount = "825", day = 15, icon = "cash" },
						{ amount = "1,250", day = 16, icon = "cash" },
						{ amount = "x3", day = 17, icon = "boost" },
						{ amount = "65", day = 18, icon = "gem" },
						{ amount = "2,500", day = 19, icon = "cash" },
						{ amount = "3,000", day = 20, icon = "cash" },
						{ amount = "175", day = 21, icon = "gem" },
						{ amount = "1,000", day = 22, icon = "cash" },
						{ amount = "1,500", day = 23, icon = "cash" },
						{ amount = "x3", day = 24, icon = "boost" },
						{ amount = "85", day = 25, icon = "gem" },
						{ amount = "3,250", day = 26, icon = "cash" },
						{ amount = "3,750", day = 27, icon = "cash" },
						{ amount = "x1", day = 28, icon = "crown", label = "Icon Pack" },
					},
					id = "daily_2",
					kind = "Daily",
					layout = "Calendar",
					streak = 4,
				},
			},
			size = "Large",
			title = "Daily Rewards",
			transition = "Fade",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Green",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "main_menu",
					label = "Main Menu",
					tone = "Dark",
				},
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Victory",
			rewards = { { amount = "+325", icon = "cash" }, { amount = "+300 XP", icon = "level" } },
			stats = {
				{ label = "Goals", value = "4" },
				{ label = "Assists", value = "3" },
				{ label = "Shots", value = "12" },
				{ label = "Pass Accuracy", value = "22%" },
			},
			title = "Results",
		},
		{
			delay = 5,
			description = "A one-time Welcome Bundle: 4 rewards for 99 Robux (was 299), counting down 48h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "flame",
			id = "offer",
			items = {
				{ amount = "300", grant = { amount = 300, currency = "gems" }, icon = "gem", label = "Gems" },
				{
					amount = "15,000",
					grant = { amount = 15000, currency = "cash" },
					icon = "cash",
					label = "Cash",
				},
				{ amount = "1", icon = "flame", label = "Epic Flame Ball" },
				{ amount = "3", icon = "coin", label = "2x Cash (30m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "LIMITED",
			timer = 172800,
			title = "Welcome Bundle",
			tone = "Yellow",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Shoot", gamepad = "ButtonR2", key = "F" },
								{ action = "Pass", gamepad = "ButtonA", key = "E" },
								{ action = "Sprint", gamepad = "ButtonL3", key = "LeftShift" },
								{ action = "Tackle", gamepad = "ButtonB", key = "Q" },
								{ action = "Skill Move", gamepad = "ButtonY", key = "R" },
								{ action = "Emote", gamepad = "DPadDown", key = "T" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Sleek",
		buttonStyle = "Flat",
		clickFx = "Squash",
		corner = "Round",
		decoration = "None",
		density = "Compact",
		fonts = { body = "Roboto:Medium", display = "Oswald:SemiBold", title = "Oswald:Bold" },
		iconStyle = "Filled",
		palette = {
			accent = "#FFC93C",
			bad = "#FF5252",
			good = "#2FBF71",
			primary = "#2FBF71",
			secondary = "#1E6FFF",
			surface = "#0F1A14",
			text = "#F2FFF6",
			warn = "#FFC93C",
		},
		panelStyle = "Solid",
		rarityScheme = "Arena",
		shadow = "Soft",
		textCase = "Normal",
		transition = "Fade",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="71">
              <Properties>
                <string name="Name">sports_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: sports, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD21F",
			format = "comma",
			icon = "coin",
			id = "coins",
			name = "Coins",
			source = { kind = "leaderstat", name = "Coins" },
			start = 5000,
		},
		{
			color = "#FFC93C",
			format = "comma",
			icon = "star",
			id = "tokens",
			name = "Tokens",
			source = { kind = "attribute", name = "Tokens" },
			start = 125,
		},
	},
	hud = {
		elements = {
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopLeft",
				currencies = { "coins", "tokens" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "TopCenter",
				goal = 7,
				id = "hud_teamscore",
				teams = {
					{ color = "#B45CFF", name = "Dodgers", score = 3 },
					{ color = "#5BE22B", name = "Blasters", score = 1 },
				},
				type = "TeamScore",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Match",
				seconds = 240,
				type = "Timer",
				visible = true,
			},
			{ anchor = "BottomCenter", id = "hud_stamina", type = "Stamina", visible = true },
			{
				action = { kind = "Open", target = "menu" },
				anchor = "Left",
				icon = "menu",
				id = "hud_button",
				label = "Menu",
				tone = "Dark",
				type = "Button",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonR2", key = "F", text = "Shoot" },
					{ gamepad = "ButtonA", key = "E", text = "Pass" },
					{ gamepad = "ButtonL3", key = "LeftShift", text = "Sprint" },
					{ gamepad = "ButtonB", key = "Q", text = "Tackle" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "Center",
				emotes = {
					{ icon = "person-jumping", id = "emote_jump", label = "Jump" },
					{ icon = "moon", id = "emote_nap", label = "Nap" },
					{ icon = "thumb-down", id = "emote_boo", label = "Boo" },
					{ icon = "arm", id = "emote_flex", label = "Flex" },
					{ icon = "circle-question", id = "emote_shrug", label = "Shrug" },
					{ icon = "heart", id = "emote_heart", label = "Heart" },
				},
				id = "hud_emotewheel",
				key = "T",
				type = "EmoteWheel",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Shoot" },
						icon = "trophy",
						id = "touch_shoot",
						label = "Shoot",
					},
					{
						action = { kind = "Event", name = "Pass" },
						icon = "two-arrows-left-right",
						id = "touch_pass",
						label = "Pass",
					},
					{
						action = { kind = "Event", name = "Tackle" },
						icon = "shield",
						id = "touch_tackle",
						label = "Tackle",
					},
					{
						action = { kind = "Event", name = "Sprint" },
						icon = "run",
						id = "touch_sprint",
						label = "Sprint",
					},
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				anchor = "TopRight",
				id = "hud_teamstatus",
				members = {
					{ icon = "head-blank", name = "You", state = "Alive" },
					{ icon = "head-blank", name = "Ava", state = "Alive" },
					{ icon = "head-blank", name = "Leo", state = "Dead" },
					{ icon = "head-blank", name = "Kim", state = "Alive" },
					{ icon = "head-blank", name = "Sam", state = "Alive" },
				},
				style = "Portraits",
				title = "YOUR TEAM",
				type = "TeamStatus",
				visible = true,
			},
			{
				anchor = "Right",
				buttons = {
					{
						action = { kind = "Open", target = "teams" },
						icon = "friends",
						id = "btn_teams",
						label = "Choose Team",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "emotes" },
						icon = "face-winking",
						id = "btn_emotes",
						label = "Emotes",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
		},
		kit = "Arcade",
		layout = "TopBar",
	},
	logo = { icon = "trophy", text = "ULTIMATE STARS", tilt = -4, tone = "Green", type = "Stacked" },
	meta = {
		flavor = "dodgeball",
		genre = "sports",
		kit = "Arcade",
		prompt = "a sports game",
		seed = 7,
		source = "offline",
		title = "Ultimate Stars",
	},
	screens = {
		{
			bannerPosition = "Side",
			description = "The title screen: the Ultimate Stars logo, KICK OFF, Locker Room, Shop, Challenges, Rankings and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Split",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "KICK OFF",
							tone = "Green",
						},
						{
							action = { kind = "Open", target = "kit" },
							icon = "tshirt",
							id = "menu_kit",
							label = "Locker Room",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "quests" },
							icon = "quest",
							id = "menu_quests",
							label = "Challenges",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "leaderboard" },
							icon = "trophy",
							id = "menu_leaderboard",
							label = "Rankings",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons_2",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "flame",
					id = "starter_pack",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 86400,
					title = "Starter Pack",
					tone = "Yellow",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Team selection before kick-off: live player counts per side and a big join button in each team's colour.",
			icon = "friends",
			id = "teams",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					id = "stats",
					kind = "Stats",
					stats = {
						{ bar = 1, icon = "two-people", label = "Dodgers", value = "5/5 players" },
						{ bar = 0.8, icon = "two-people", label = "Blasters", value = "4/5 players" },
					},
				},
				{
					buttons = {
						{
							action = { kind = "Remote", name = "JoinTeam" },
							icon = "flag",
							id = "join_dodgers",
							label = "Join Dodgers",
							tone = "Purple",
						},
						{
							action = { kind = "Remote", name = "JoinTeam" },
							icon = "flag",
							id = "join_blasters",
							label = "Join Blasters",
							tone = "Green",
						},
						{
							action = { kind = "Remote", name = "JoinTeam" },
							icon = "two-arrows-switch-right",
							id = "join_auto",
							label = "Auto-Assign",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Row",
				},
			},
			size = "Medium",
			title = "Choose Team",
			transition = "Zoom",
		},
		{
			description = "Boots, kit and ball: what you take onto the pitch, with pace, shooting and passing bars.",
			icon = "tshirt",
			id = "kit",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "tshirt",
					id = "tab_equipped",
					label = "Equipped",
					sections = {
						{
							id = "loadout",
							kind = "Loadout",
							slots = {
								{
									icon = "shoe",
									name = "Speed Boots",
									rarity = "Rare",
									stats = {
										{ label = "Pace", value = 0.5 },
										{ label = "Shooting", value = 0.85 },
										{ label = "Passing", value = 0.7 },
										{ label = "Stamina", value = 0.48 },
									},
								},
								{
									icon = "shirt",
									name = "Retro Kit",
									rarity = "Uncommon",
									stats = {
										{ label = "Pace", value = 0.71 },
										{ label = "Shooting", value = 0.41 },
										{ label = "Passing", value = 0.58 },
										{ label = "Stamina", value = 0.69 },
									},
								},
								{
									icon = "flame",
									name = "Flame Ball",
									rarity = "Epic",
									stats = {
										{ label = "Pace", value = 0.63 },
										{ label = "Shooting", value = 0.67 },
										{ label = "Passing", value = 0.58 },
										{ label = "Stamina", value = 0.93 },
									},
								},
							},
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_all_gear",
					label = "All Gear",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{ icon = "nebula", id = "inv_galaxy_ball", name = "Galaxy Ball", rarity = "Mythic" },
								{ icon = "shoe", id = "inv_golden_boots", name = "Golden Boots", rarity = "Legendary" },
								{
									equipped = true,
									icon = "flame",
									id = "inv_flame_ball",
									name = "Flame Ball",
									rarity = "Epic",
								},
								{ icon = "shoe", id = "inv_power_boots", name = "Power Boots", rarity = "Epic" },
								{ icon = "star", id = "inv_captain_armband", name = "Captain Armband", rarity = "Rare" },
								{ icon = "lightning-bolt", id = "inv_neon_ball", name = "Neon Ball", rarity = "Rare" },
								{
									equipped = true,
									icon = "shoe",
									id = "inv_speed_boots",
									name = "Speed Boots",
									rarity = "Rare",
								},
								{
									equipped = true,
									icon = "shirt",
									id = "inv_retro_kit",
									name = "Retro Kit",
									rarity = "Uncommon",
								},
								{ icon = "tshirt", id = "inv_away_kit", name = "Away Kit", rarity = "Common" },
								{ icon = "tshirt", id = "inv_home_kit", name = "Home Kit", rarity = "Common" },
							},
							kind = "Inventory",
						},
					},
				},
			},
			title = "Locker Room",
		},
		{
			description = "Where Coins and Robux turn into progress. Tabs: Featured, Items, Packs and Tokens.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					callout = "Refreshes daily",
					icon = "sale",
					id = "tab_featured",
					label = "Featured",
					sections = {
						{
							icon = "star",
							id = "golden_ball",
							kind = "Banner",
							subtitle = "One-hit outs this weekend",
							timer = 86400,
							title = "Golden Ball",
							tone = "Yellow",
						},
						{
							id = "today_s_picks",
							items = {
								{
									amount = "6,000",
									currency = "robux",
									description = "6,000 Tokens + an exclusive title",
									grant = { amount = 6000, currency = "tokens" },
									icon = "crown",
									id = "featured_mega",
									price = 1499,
									tag = "LIMITED",
									title = "Mega Bundle",
									tone = "Green",
									was = 3999,
								},
								{
									amount = "800",
									currency = "robux",
									description = "800 Tokens + Golden Boots",
									grant = { amount = 800, currency = "tokens" },
									icon = "gift",
									id = "featured_starter",
									price = 199,
									tag = "ONE TIME",
									title = "Newcomer Bundle",
									tone = "Pink",
									was = 399,
								},
								{
									currency = "tokens",
									description = "This week only",
									icon = "shoe",
									id = "featured_golden_boots",
									price = 950,
									tag = "FEATURED",
									title = "Golden Boots",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Featured",
							title = "Today's Picks",
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_items",
					label = "Items",
					sections = {
						{
							id = "products",
							items = {
								{
									amount = "Rare",
									currency = "coins",
									icon = "star",
									id = "buy_captain_armband",
									price = 2000,
									title = "Captain Armband",
									tone = "Blue",
								},
								{
									amount = "Rare",
									currency = "coins",
									icon = "lightning-bolt",
									id = "buy_neon_ball",
									price = 2000,
									title = "Neon Ball",
									tone = "Blue",
								},
								{
									amount = "Rare",
									currency = "coins",
									icon = "shoe",
									id = "buy_speed_boots",
									price = 2000,
									title = "Speed Boots",
									tone = "Blue",
								},
								{
									amount = "Epic",
									currency = "coins",
									icon = "shoe",
									id = "buy_power_boots",
									price = 4000,
									title = "Power Boots",
									tone = "Purple",
								},
								{
									amount = "Epic",
									currency = "coins",
									icon = "flame",
									id = "buy_flame_ball",
									price = 4500,
									title = "Flame Ball",
									tone = "Purple",
								},
							},
							kind = "Products",
							layout = "Cards",
						},
					},
				},
				{
					icon = "file-box",
					id = "tab_packs",
					label = "Packs",
					sections = {
						{
							cases = {
								{
									currency = "coins",
									icon = "star",
									id = "case_gold_pack",
									pool = {
										{ chance = 32.4, icon = "tshirt", name = "Away Kit", rarity = "Common" },
										{ chance = 32, icon = "tshirt", name = "Home Kit", rarity = "Common" },
										{ chance = 15, icon = "shirt", name = "Retro Kit", rarity = "Uncommon" },
										{ chance = 7.1, icon = "star", name = "Captain Armband", rarity = "Rare" },
										{ chance = 7.1, icon = "lightning-bolt", name = "Neon Ball", rarity = "Rare" },
										{ chance = 3.2, icon = "flame", name = "Flame Ball", rarity = "Epic" },
										{ chance = 3.2, icon = "shoe", name = "Power Boots", rarity = "Epic" },
									},
									price = 225,
									title = "Gold Pack",
									tone = "Yellow",
								},
								{
									currency = "coins",
									icon = "diamond",
									id = "case_rare_pack",
									pool = {
										{ chance = 41, icon = "shirt", name = "Retro Kit", rarity = "Uncommon" },
										{ chance = 19, icon = "star", name = "Captain Armband", rarity = "Rare" },
										{ chance = 19, icon = "lightning-bolt", name = "Neon Ball", rarity = "Rare" },
										{ chance = 8.9, icon = "flame", name = "Flame Ball", rarity = "Epic" },
										{ chance = 8.9, icon = "shoe", name = "Power Boots", rarity = "Epic" },
										{ chance = 3.2, icon = "shoe", name = "Golden Boots", rarity = "Legendary" },
									},
									price = 850,
									title = "Rare Pack",
									tone = "Blue",
								},
								{
									currency = "coins",
									icon = "crown",
									id = "case_icon_pack",
									pool = {
										{ chance = 31.5, icon = "star", name = "Captain Armband", rarity = "Rare" },
										{ chance = 32, icon = "lightning-bolt", name = "Neon Ball", rarity = "Rare" },
										{ chance = 15, icon = "flame", name = "Flame Ball", rarity = "Epic" },
										{ chance = 15, icon = "shoe", name = "Power Boots", rarity = "Epic" },
										{ chance = 5.3, icon = "shoe", name = "Golden Boots", rarity = "Legendary" },
										{ chance = 1.2, icon = "nebula", name = "Galaxy Ball", rarity = "Mythic" },
									},
									price = 2500,
									title = "Icon Pack",
									tone = "Purple",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "star",
					id = "tab_tokens",
					label = "Tokens",
					sections = {
						{
							id = "token_bundles",
							items = {
								{
									amount = "125",
									currency = "robux",
									grant = { amount = 125, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_1",
									price = 49,
									title = "Starter Tokens",
									tone = "Pink",
								},
								{
									amount = "300",
									bonus = 19,
									currency = "robux",
									grant = { amount = 300, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_2",
									price = 99,
									title = "Small Token Pack",
									tone = "Pink",
								},
								{
									amount = "625",
									bonus = 23,
									currency = "robux",
									grant = { amount = 625, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_3",
									price = 199,
									tag = "POPULAR",
									title = "Big Token Pack",
									tone = "Green",
								},
								{
									amount = "1,500",
									bonus = 47,
									currency = "robux",
									grant = { amount = 1500, currency = "tokens" },
									icon = "file-box",
									id = "tokens_pack_4",
									price = 399,
									title = "Mega Token Pack",
									tone = "Green",
								},
								{
									amount = "3,250",
									bonus = 59,
									currency = "robux",
									grant = { amount = 3250, currency = "tokens" },
									icon = "building-bank",
									id = "tokens_pack_5",
									price = 799,
									title = "Giga Token Pack",
									tone = "Yellow",
								},
								{
									amount = "6,750",
									bonus = 77,
									currency = "robux",
									grant = { amount = 6750, currency = "tokens" },
									icon = "crown",
									id = "tokens_pack_6",
									price = 1499,
									tag = "BEST VALUE",
									title = "Ultra Token Pack",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Cards",
							title = "Token Bundles",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Pick up to eight emotes for the radial wheel (opened with T in game).",
			icon = "face-winking",
			id = "emotes",
			kind = "Window",
			nav = "None",
			sections = {
				{
					columns = 5,
					detail = true,
					id = "inventory_2",
					items = {
						{ equipped = true, icon = "arm", id = "inv_flex", name = "Flex", rarity = "Legendary" },
						{
							equipped = true,
							icon = "person-jumping",
							id = "inv_jump",
							name = "Jump",
							rarity = "Legendary",
						},
						{ equipped = true, icon = "thumb-down", id = "inv_boo", name = "Boo", rarity = "Rare" },
						{
							equipped = true,
							icon = "megaphone-cheer",
							id = "inv_cheer",
							name = "Cheer",
							rarity = "Uncommon",
						},
						{
							equipped = true,
							icon = "music-note",
							id = "inv_dance",
							name = "Dance",
							rarity = "Uncommon",
						},
						{
							equipped = true,
							icon = "circle-question",
							id = "inv_shrug",
							name = "Shrug",
							rarity = "Uncommon",
						},
						{ icon = "flame", id = "inv_taunt", name = "Taunt", rarity = "Uncommon" },
						{ icon = "crown", id = "inv_crown", name = "Crown", rarity = "Common" },
						{ icon = "thumb-up", id = "inv_gg", name = "GG", rarity = "Common" },
						{ icon = "square-face-waving-hand", id = "inv_wave", name = "Wave", rarity = "Common" },
					},
					kind = "Inventory",
				},
				{
					body = "Equipped emotes appear on your emote wheel. Press T to open it in game.",
					id = "text",
					kind = "Text",
				},
			},
			size = "Medium",
			title = "Emotes",
		},
		{
			description = "Daily and weekly quests with progress bars; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "Tabs",
			openKey = "L",
			size = "Medium",
			tabs = {
				{
					badge = 1,
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Make 6 assists",
									goal = 6,
									icon = "two-people",
									id = "quest_playmaker",
									progress = 6,
									reward = { amount = "50", icon = "star" },
									title = "Playmaker",
								},
								{
									description = "Win 3 matches in a row",
									goal = 3,
									icon = "flame",
									id = "quest_on_fire",
									progress = 1,
									reward = { amount = "725", icon = "coin" },
									title = "On Fire",
								},
								{
									description = "Make 8 saves",
									goal = 8,
									icon = "shield",
									id = "quest_brick_wall",
									progress = 1,
									reward = { amount = "225", icon = "coin" },
									title = "Brick Wall",
								},
							},
						},
					},
				},
				{
					icon = "calendar",
					id = "tab_weekly",
					label = "Weekly",
					sections = {
						{
							id = "quests_3",
							kind = "Quests",
							quests = {
								{
									description = "Score 3 goals",
									goal = 3,
									icon = "trophy",
									id = "quest_hat_trick",
									progress = 0,
									reward = { amount = "25", icon = "star" },
									title = "Hat-trick",
								},
							},
						},
					},
				},
			},
			title = "Challenges",
		},
		{
			description = "Global top ten for Goals, MVPs and Wins, with podium colours for the top three.",
			icon = "trophy",
			id = "leaderboard",
			kind = "Window",
			nav = "Tabs",
			size = "Medium",
			tabs = {
				{
					icon = "trophy",
					id = "tab_goals",
					label = "Goals",
					sections = {
						{
							id = "leaderboard_2",
							kind = "Leaderboard",
							rows = {
								{ name = "NeonDash", rank = 1, value = "25M" },
								{ name = "xXTacoBladeXx", rank = 2, value = "20.1M" },
								{ name = "CrimsonGamer_21", rank = 3, value = "18.7M" },
								{ name = "Shadow404", rank = 4, value = "15.6M" },
								{ name = "OnyxRanger404", rank = 5, value = "12.7M" },
								{ name = "RealFrostFox", rank = 6, value = "10.2M" },
								{ name = "MaplePupYT", rank = 7, value = "7.8M" },
								{ name = "RealDoodleSpark", rank = 8, value = "6.8M" },
								{ name = "RealPickleOwl", rank = 9, value = "5.6M" },
								{ name = "BoltRanger99", rank = 10, value = "5.1M" },
							},
							stat = "Goals",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_mvps",
					label = "MVPs",
					sections = {
						{
							id = "leaderboard_3",
							kind = "Leaderboard",
							rows = {
								{ name = "xXShadowWolfXx", rank = 1, value = "2,188" },
								{ name = "BubbleKingYT", rank = 2, value = "1,958" },
								{ name = "ArcticPup27", rank = 3, value = "1,756" },
								{ name = "TurboBladeYT", rank = 4, value = "1,473" },
								{ name = "ItsBlitzYeti", rank = 5, value = "1,233" },
								{ name = "xXCosmicLlamaXx", rank = 6, value = "1,094" },
								{ name = "NovaHunter27", rank = 7, value = "837" },
								{ name = "ChaosSpark", rank = 8, value = "729" },
								{ name = "Lucky33", rank = 9, value = "613" },
								{ name = "xXLuckyFoxXx", rank = 10, value = "548" },
							},
							stat = "MVPs",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_wins",
					label = "Wins",
					sections = {
						{
							id = "leaderboard_4",
							kind = "Leaderboard",
							rows = {
								{ name = "Mango101", rank = 1, value = "2,197" },
								{ name = "Turbo2014", rank = 2, value = "1,941" },
								{ name = "ItsDoodleGamer", rank = 3, value = "1,791" },
								{ name = "NotLunaNinja", rank = 4, value = "1,397" },
								{ name = "xXSunnyAceXx", rank = 5, value = "1,234" },
								{ name = "CrimsonChefYT", rank = 6, value = "976" },
								{ name = "ShadowSprout88", rank = 7, value = "924" },
								{ name = "WobblyQueen", rank = 8, value = "700" },
								{ name = "Pixel21", rank = 9, value = "633" },
								{ name = "BlitzMoose_21", rank = 10, value = "525" },
							},
							stat = "Wins",
						},
					},
				},
			},
			title = "Rankings",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Green",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "main_menu",
					label = "Main Menu",
					tone = "Dark",
				},
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Defeat",
			rewards = {
				{ amount = "+400", icon = "coin" },
				{ amount = "+400 XP", icon = "level" },
				{ amount = "+25", icon = "star" },
			},
			stats = {
				{ label = "Goals", value = "5" },
				{ label = "Assists", value = "1" },
				{ label = "Shots", value = "4" },
				{ label = "Pass Accuracy", value = "50%" },
			},
			title = "Results",
		},
		{
			delay = 5,
			description = "A one-time Starter Pack: 4 rewards for 99 Robux (was 299), counting down 24h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "flame",
			id = "offer",
			items = {
				{
					amount = "375",
					grant = { amount = 375, currency = "tokens" },
					icon = "star",
					label = "Tokens",
				},
				{
					amount = "10,000",
					grant = { amount = 10000, currency = "coins" },
					icon = "coin",
					label = "Coins",
				},
				{ amount = "1", icon = "flame", label = "Epic Flame Ball" },
				{ amount = "3", icon = "coin", label = "2x Coins (30m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "STARTER PACK",
			timer = 86400,
			title = "Starter Pack",
			tone = "Yellow",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Shoot", gamepad = "ButtonR2", key = "F" },
								{ action = "Pass", gamepad = "ButtonA", key = "E" },
								{ action = "Sprint", gamepad = "ButtonL3", key = "LeftShift" },
								{ action = "Tackle", gamepad = "ButtonB", key = "Q" },
								{ action = "Skill Move", gamepad = "ButtonY", key = "R" },
								{ action = "Emote", gamepad = "DPadDown", key = "T" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Arena",
		buttonStyle = "Soft",
		clickFx = "Glow",
		corner = "Bubbly",
		decoration = "None",
		density = "Compact",
		fonts = { body = "BuilderSans:Bold", display = "DenkOne", title = "DenkOne" },
		iconStyle = "Filled",
		palette = {
			accent = "#FFE14F",
			bad = "#FF4F6A",
			good = "#7CFF4F",
			primary = "#7CFF4F",
			secondary = "#FF4FD1",
			surface = "#1E1238",
			text = "#FFFFFF",
			warn = "#FFB84F",
		},
		panelStyle = "Glass",
		rarityScheme = "Arena",
		shadow = "Soft",
		textCase = "Normal",
		transition = "Zoom",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="72">
              <Properties>
                <string name="Name">survival_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: survival, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#C98B4B",
			format = "comma",
			icon = "cube",
			id = "wood",
			name = "Wood",
			source = { kind = "leaderstat", name = "Wood" },
			start = 100,
		},
		{
			color = "#9AA3B2",
			format = "comma",
			icon = "hexagon-dot",
			id = "stone",
			name = "Stone",
			source = { kind = "attribute", name = "Stone" },
			start = 55,
		},
		{
			color = "#C9A26B",
			format = "comma",
			icon = "gear",
			id = "scrap",
			name = "Scrap",
			source = { kind = "attribute", name = "Scrap" },
			start = 20,
		},
		{
			color = "#3AD1FF",
			format = "comma",
			icon = "gem",
			id = "gems",
			name = "Gems",
			source = { kind = "attribute", name = "Gems" },
			start = 30,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Night falls in",
				seconds = 171,
				type = "Timer",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_objective",
				text = "Build a shelter",
				type = "Objective",
				visible = true,
			},
			{
				addAction = { kind = "None" },
				anchor = "TopLeft",
				currencies = { "wood", "stone", "scrap", "gems" },
				id = "hud_currencybar",
				showAdd = false,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				id = "hud_healthbar",
				max = 100,
				source = { kind = "humanoid" },
				type = "HealthBar",
				visible = true,
			},
			{ anchor = "BottomLeft", id = "hud_stamina", type = "Stamina", visible = true },
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				slots = {
					{ icon = "sword", name = "Stone Axe" },
					{ icon = "flame", name = "Torch" },
					{ icon = "flame", name = "Cooked Meat" },
					{ icon = "lab-beaker", name = "Water Flask" },
					{ icon = "plus", name = "Bandage" },
				},
				type = "Hotbar",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				buttons = {
					{
						action = { kind = "Toggle", target = "inventory" },
						icon = "backpack",
						id = "btn_inventory",
						label = "Collection",
						tone = "White",
					},
					{
						action = { kind = "Toggle", target = "crafting" },
						icon = "cube-vertexes",
						id = "btn_crafting",
						label = "Crafting",
						tone = "White",
					},
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Challenges",
						tone = "White",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "White",
					},
				},
				id = "hud_menubuttons",
				look = "Dock",
				style = "Dock",
				type = "MenuButtons",
				visible = true,
			},
			{
				anchor = "Center",
				id = "hud_vignette",
				intensity = 0.3,
				style = "Soft",
				type = "Vignette",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonX", key = "E", text = "Interact" },
					{ gamepad = "ButtonY", key = "C", text = "Craft" },
					{ gamepad = "ButtonB", key = "F", text = "Eat" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Attack" },
						icon = "sword",
						id = "touch_attack",
						label = "Attack",
					},
					{
						action = { kind = "Event", name = "Interact" },
						icon = "hand",
						id = "touch_interact",
						label = "Interact",
					},
					{ action = { kind = "Event", name = "Eat" }, icon = "heart", id = "touch_eat", label = "Eat" },
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				anchor = "Right",
				icon = "milk",
				id = "hud_meter",
				label = "THIRST",
				low = 0.25,
				style = "Bar",
				tone = "Blue",
				type = "Meter",
				value = 0.77,
				visible = true,
			},
			{
				anchor = "Right",
				icon = "wheat",
				id = "hud_meter_2",
				label = "HUNGER",
				low = 0.25,
				style = "Bar",
				tone = "Orange",
				type = "Meter",
				value = 0.54,
				visible = true,
			},
		},
		kit = "Storybook",
		layout = "Dock",
	},
	logo = {
		icon = "moon",
		subtitle = "STRANDED",
		text = "Stranded Island",
		tilt = 5,
		tone = "Cyan",
		type = "Badge",
	},
	meta = {
		flavor = "island",
		genre = "survival",
		kit = "Storybook",
		prompt = "a survival game",
		seed = 21,
		source = "offline",
		title = "Stranded Island",
	},
	screens = {
		{
			description = "The first thing players see: the Stranded Island logo, a progress bar and rotating tips while the place streams in.",
			duration = 4,
			icon = "timer",
			id = "loading",
			kind = "Loading",
			tips = {
				"Fire keeps the wolves away. Keep it fed.",
				"Scrap only comes from wrecks. Guard it.",
				"Bushes hide you from almost everything.",
				"Storms roll in every third night. Reinforce your walls.",
				"Cooked food restores twice as much hunger as raw.",
			},
			title = "Loading",
		},
		{
			description = "The title screen: the Stranded Island logo, SURVIVE, Crafting, Collection, Challenges and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "SURVIVE",
							tone = "Cyan",
						},
						{
							action = { kind = "Open", target = "crafting" },
							icon = "cube-vertexes",
							id = "menu_crafting",
							label = "Crafting",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "inventory" },
							icon = "backpack",
							id = "menu_inventory",
							label = "Collection",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "quests" },
							icon = "quest",
							id = "menu_quests",
							label = "Challenges",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "White",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					icon = "compass",
					id = "the_raft_update",
					kind = "Banner",
					subtitle = "Sail to new islands",
					title = "The Raft Update",
					tone = "Red",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Every item the player owns in one grid, rarest first, with a detail panel for equipping.",
			icon = "backpack",
			id = "inventory",
			kind = "Window",
			nav = "None",
			openKey = "B",
			sections = {
				{
					columns = 6,
					detail = true,
					id = "inventory_2",
					items = {
						{
							equipped = true,
							icon = "crown",
							id = "inv_golden_axe",
							name = "Golden Axe",
							rarity = "Legendary",
						},
						{
							equipped = true,
							icon = "crosshairs",
							id = "inv_hunting_rifle",
							name = "Hunting Rifle",
							rarity = "Epic",
						},
						{
							equipped = true,
							icon = "shield",
							id = "inv_wolf_pelt",
							name = "Wolf Pelt",
							rarity = "Epic",
						},
						{ icon = "backpack", id = "inv_backpack", name = "Backpack", rarity = "Rare" },
						{ icon = "triangle-exclamation", id = "inv_bear_trap", name = "Bear Trap", rarity = "Rare" },
						{ icon = "moon", id = "inv_sleeping_bag", name = "Sleeping Bag", rarity = "Rare" },
						{ count = 13, icon = "compass", id = "inv_compass", name = "Compass", rarity = "Uncommon" },
						{
							count = 14,
							icon = "flame",
							id = "inv_cooked_meat",
							name = "Cooked Meat",
							rarity = "Uncommon",
						},
						{ count = 18, icon = "sun", id = "inv_lantern", name = "Lantern", rarity = "Uncommon" },
						{ count = 23, icon = "map", id = "inv_map", name = "Map", rarity = "Uncommon" },
						{ count = 14, icon = "fish", id = "inv_raw_fish", name = "Raw Fish", rarity = "Common" },
						{ count = 15, icon = "chain-link", id = "inv_rope", name = "Rope", rarity = "Common" },
						{ count = 5, icon = "sword", id = "inv_stone_axe", name = "Stone Axe", rarity = "Common" },
						{ count = 13, icon = "flame", id = "inv_torch", name = "Torch", rarity = "Common" },
						{
							count = 22,
							icon = "lab-beaker",
							id = "inv_water_flask",
							name = "Water Flask",
							rarity = "Common",
						},
					},
					kind = "Inventory",
				},
			},
			size = "Large",
			title = "Collection",
		},
		{
			description = "Turn gathered materials into tools, shelter and gear. Costs are shown in the same icons as the resource counters.",
			icon = "cube-vertexes",
			id = "crafting",
			kind = "Window",
			nav = "None",
			openKey = "C",
			sections = {
				{
					id = "recipes",
					items = {
						{
							currency = "wood",
							icon = "flame",
							id = "craft_campfire",
							price = 10,
							title = "Campfire",
							tone = "Cyan",
						},
						{
							currency = "wood",
							icon = "flame",
							id = "craft_torch",
							price = 4,
							title = "Torch",
							tone = "Orange",
						},
						{
							currency = "stone",
							icon = "sword",
							id = "craft_stone_axe",
							price = 8,
							title = "Stone Axe",
							tone = "Cyan",
						},
						{
							currency = "wood",
							icon = "square",
							id = "craft_wooden_wall",
							price = 15,
							title = "Wooden Wall",
							tone = "Orange",
						},
						{
							currency = "wood",
							icon = "moon",
							id = "craft_bed",
							price = 25,
							title = "Bed",
							tone = "Cyan",
						},
						{
							currency = "wood",
							icon = "cube",
							id = "craft_workbench",
							price = 30,
							title = "Workbench",
							tone = "Orange",
						},
						{
							currency = "scrap",
							icon = "sun",
							id = "craft_lantern",
							price = 6,
							title = "Lantern",
							tone = "Cyan",
						},
						{
							currency = "scrap",
							icon = "triangle-exclamation",
							id = "craft_bear_trap",
							price = 12,
							title = "Bear Trap",
							tone = "Orange",
						},
						{
							currency = "scrap",
							icon = "crosshairs",
							id = "craft_hunting_rifle",
							price = 40,
							title = "Hunting Rifle",
							tone = "Cyan",
						},
					},
					kind = "Products",
					layout = "Compact",
					title = "Recipes",
				},
			},
			size = "Medium",
			title = "Crafting",
		},
		{
			description = "Quest cards with progress bars under a reset countdown; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "Tabs",
			openKey = "L",
			size = "Large",
			tabs = {
				{
					icon = "quest",
					id = "tab_challenges",
					label = "Challenges",
					sections = {
						{
							icon = "timer",
							id = "new_quests_in",
							kind = "Banner",
							subtitle = "Finish all quests for a bonus chest",
							timer = 39600,
							title = "New quests in",
							tone = "Orange",
						},
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Cook 10 meals",
									goal = 10,
									icon = "flame",
									id = "quest_cook",
									progress = 10,
									reward = { amount = "15", icon = "cube" },
									title = "Cook",
								},
								{
									description = "Place 35 walls",
									goal = 35,
									icon = "square",
									id = "quest_builder",
									progress = 18,
									reward = { amount = "10", icon = "gem" },
									title = "Builder",
								},
								{
									description = "Chop 35 trees",
									goal = 35,
									icon = "cube",
									id = "quest_lumberjack",
									progress = 0,
									reward = { amount = "15", icon = "cube" },
									title = "Lumberjack",
								},
								{
									description = "Survive 5 nights",
									goal = 5,
									icon = "moon",
									id = "quest_first_night",
									progress = 1,
									reward = { amount = "30", icon = "cube" },
									title = "First Night",
								},
							},
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_achievements",
					label = "Achievements",
					sections = {
						{
							achievements = {
								{
									description = "Survive a day",
									goal = 1,
									icon = "sun",
									id = "ach_day_one",
									points = 5,
									progress = 1,
									reward = { amount = "6", grant = { amount = 6, currency = "gems" }, icon = "gem" },
									title = "Day One",
								},
								{
									description = "Cook 50 meals",
									goal = 50,
									icon = "flame",
									id = "ach_well_fed",
									points = 15,
									progress = 43,
									reward = { amount = "15", grant = { amount = 15, currency = "gems" }, icon = "gem" },
									title = "Well Fed",
								},
								{
									description = "Hunt 100 animals",
									goal = 100,
									icon = "crosshairs",
									id = "ach_hunter",
									points = 20,
									progress = 61,
									reward = { amount = "20", grant = { amount = 20, currency = "gems" }, icon = "gem" },
									title = "Hunter",
								},
								{
									description = "Craft 200 items",
									goal = 200,
									icon = "cube",
									id = "ach_builder",
									points = 20,
									progress = 0,
									reward = { amount = "25", grant = { amount = 25, currency = "gems" }, icon = "gem" },
									title = "Builder",
								},
								{
									description = "Survive 30 days",
									goal = 30,
									icon = "moon",
									id = "ach_survivor",
									points = 30,
									progress = 0,
									reward = { amount = "30", grant = { amount = 30, currency = "gems" }, icon = "gem" },
									title = "Survivor",
								},
								{
									description = "Build a base with 50 walls",
									goal = 1,
									icon = "building-bank",
									id = "ach_fortress",
									points = 40,
									progress = 0,
									reward = { amount = "40", grant = { amount = 40, currency = "gems" }, icon = "gem" },
									title = "Fortress",
								},
							},
							id = "achievements",
							kind = "Achievements",
						},
					},
				},
			},
			title = "Challenges",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "Retry" },
					icon = "rebirth",
					id = "try_again",
					label = "Try Again",
					tone = "Cyan",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "main_menu",
					label = "Main Menu",
					tone = "White",
				},
			},
			description = "Shown the instant a run ends: what you achieved, what you keep, and a one-click way back in.",
			icon = "health",
			id = "gameover",
			kind = "Results",
			result = "Defeat",
			rewards = {
				{ amount = "+15", icon = "cube" },
				{ amount = "+150 XP", icon = "level" },
				{ amount = "+5", icon = "gem" },
			},
			stats = {
				{ label = "Nights Survived", value = "13" },
				{ label = "Trees Chopped", value = "213" },
				{ label = "Items Crafted", value = "31" },
				{ label = "Distance", value = "5.3 km" },
			},
			title = "Caught",
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "hashtag",
									id = "damageNumbers",
									label = "Damage Numbers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Attack", gamepad = "ButtonR2", key = "MouseButton1" },
								{ action = "Interact", gamepad = "ButtonX", key = "E" },
								{ action = "Craft", gamepad = "ButtonY", key = "C" },
								{ action = "Eat", gamepad = "ButtonB", key = "F" },
								{ action = "Sprint", gamepad = "ButtonL3", key = "LeftShift" },
								{ action = "Jump", gamepad = "ButtonA", key = "Space" },
								{ action = "Map", gamepad = "ButtonSelect", key = "M" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "loading",
	style = {
		archetype = "Bubbly",
		buttonStyle = "Soft",
		clickFx = "Squash",
		corner = "Round",
		decoration = "Dots",
		density = "Compact",
		fonts = { body = "Nunito:Bold", display = "DenkOne", title = "DenkOne" },
		iconStyle = "Filled",
		palette = {
			accent = "#FF6F91",
			bad = "#F25F5C",
			good = "#3CC47C",
			primary = "#1FB6E8",
			secondary = "#FFB347",
			surface = "#16425B",
			text = "#FFF9EC",
			warn = "#FFB347",
		},
		panelStyle = "Ribbon",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Drop",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="73">
              <Properties>
                <string name="Name">survival_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: survival, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#C98B4B",
			format = "comma",
			icon = "cube",
			id = "wood",
			name = "Wood",
			source = { kind = "leaderstat", name = "Wood" },
			start = 100,
		},
		{
			color = "#9AA3B2",
			format = "comma",
			icon = "hexagon-dot",
			id = "stone",
			name = "Stone",
			source = { kind = "attribute", name = "Stone" },
			start = 40,
		},
		{
			color = "#C9A26B",
			format = "comma",
			icon = "gear",
			id = "scrap",
			name = "Scrap",
			source = { kind = "attribute", name = "Scrap" },
			start = 20,
		},
		{
			color = "#3AD1FF",
			format = "comma",
			icon = "gem",
			id = "gems",
			name = "Gems",
			source = { kind = "attribute", name = "Gems" },
			start = 50,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Dark falls in",
				seconds = 99,
				type = "Timer",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_objective",
				text = "Feed the campfire",
				type = "Objective",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopLeft",
				currencies = { "wood", "stone", "scrap", "gems" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				id = "hud_healthbar",
				max = 100,
				source = { kind = "humanoid" },
				type = "HealthBar",
				visible = true,
			},
			{ anchor = "BottomLeft", id = "hud_stamina", type = "Stamina", visible = true },
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				slots = {
					{ icon = "sword", name = "Stone Axe" },
					{ icon = "flame", name = "Torch" },
					{ icon = "flame", name = "Cooked Meat" },
					{ icon = "lab-beaker", name = "Water Flask" },
					{ icon = "plus", name = "Bandage" },
				},
				type = "Hotbar",
				visible = true,
			},
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Toggle", target = "party" },
						icon = "friends",
						id = "btn_party",
						label = "Party",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "crafting" },
						icon = "cube-vertexes",
						id = "btn_crafting",
						label = "Workbench",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "inventory" },
						icon = "backpack",
						id = "btn_inventory",
						label = "Inventory",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Shop",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "worlds" },
						icon = "map",
						id = "btn_worlds",
						label = "Map",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
			{
				anchor = "Center",
				id = "hud_vignette",
				intensity = 0.3,
				style = "Soft",
				type = "Vignette",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonX", key = "E", text = "Interact" },
					{ gamepad = "ButtonY", key = "C", text = "Craft" },
					{ gamepad = "ButtonB", key = "F", text = "Eat" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "BottomRight",
				buttons = {
					{
						action = { kind = "Event", name = "Attack" },
						icon = "sword",
						id = "touch_attack",
						label = "Attack",
					},
					{
						action = { kind = "Event", name = "Interact" },
						icon = "hand",
						id = "touch_interact",
						label = "Interact",
					},
					{ action = { kind = "Event", name = "Eat" }, icon = "heart", id = "touch_eat", label = "Eat" },
				},
				id = "hud_mobilecontrols",
				type = "MobileControls",
				visible = true,
			},
			{
				anchor = "Right",
				icon = "flame",
				id = "hud_meter",
				label = "CAMPFIRE",
				low = 0.25,
				style = "Bar",
				tone = "Orange",
				type = "Meter",
				value = 0.63,
				visible = true,
			},
			{
				anchor = "Right",
				icon = "wheat",
				id = "hud_meter_2",
				label = "HUNGER",
				low = 0.25,
				style = "Bar",
				tone = "Yellow",
				type = "Meter",
				value = 0.61,
				visible = true,
			},
		},
		kit = "Storybook",
		layout = "Corners",
	},
	logo = { icon = "flame", text = "Hundred Nights", tilt = -4, tone = "Yellow", type = "Wordmark" },
	meta = {
		flavor = "forest",
		genre = "survival",
		kit = "Storybook",
		prompt = "a survival game",
		seed = 7,
		source = "offline",
		title = "Hundred Nights",
	},
	screens = {
		{
			description = "The title screen: the Hundred Nights logo, SURVIVE, Party, Workbench, Inventory, Shop and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "SURVIVE",
							tone = "Yellow",
						},
						{
							action = { kind = "Open", target = "party" },
							icon = "friends",
							id = "menu_party",
							label = "Party",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "crafting" },
							icon = "cube-vertexes",
							id = "menu_crafting",
							label = "Workbench",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "inventory" },
							icon = "backpack",
							id = "menu_inventory",
							label = "Inventory",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "shop" }, label = "Shop Now" },
					icon = "crosshairs",
					id = "class_pack",
					kind = "Banner",
					subtitle = "Start as the Hunter",
					timer = 691200,
					title = "Class Pack",
					tone = "Orange",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Invite friends into your party and pick a mode together; the party leader's pick drives matchmaking.",
			icon = "friends",
			id = "party",
			kind = "Window",
			nav = "None",
			openKey = "P",
			sections = {
				{ id = "party_2", kind = "Party", slots = 4 },
				{
					buttons = {
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "trophy",
							id = "mode_ranked",
							label = "Ranked",
							tone = "Yellow",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "gear",
							id = "mode_custom",
							label = "Custom",
							tone = "Dark",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "play",
							id = "mode_quick_match",
							label = "Quick Match",
							tone = "Dark",
						},
					},
					id = "modes",
					kind = "Buttons",
					layout = "Grid",
					title = "Modes",
				},
			},
			size = "Medium",
			title = "Party",
		},
		{
			description = "Turn gathered materials into tools, shelter and gear. Costs are shown in the same icons as the resource counters.",
			icon = "cube-vertexes",
			id = "crafting",
			kind = "Window",
			nav = "None",
			openKey = "C",
			sections = {
				{
					id = "recipes",
					items = {
						{
							currency = "wood",
							icon = "flame",
							id = "craft_campfire",
							price = 10,
							title = "Campfire",
							tone = "Yellow",
						},
						{
							currency = "wood",
							icon = "flame",
							id = "craft_torch",
							price = 4,
							title = "Torch",
							tone = "Yellow",
						},
						{
							currency = "stone",
							icon = "sword",
							id = "craft_stone_axe",
							price = 8,
							title = "Stone Axe",
							tone = "Yellow",
						},
						{
							currency = "wood",
							icon = "square",
							id = "craft_wooden_wall",
							price = 15,
							title = "Wooden Wall",
							tone = "Yellow",
						},
						{
							currency = "wood",
							icon = "moon",
							id = "craft_bed",
							price = 25,
							title = "Bed",
							tone = "Yellow",
						},
						{
							currency = "wood",
							icon = "cube",
							id = "craft_workbench",
							price = 30,
							title = "Workbench",
							tone = "Yellow",
						},
						{
							currency = "scrap",
							icon = "sun",
							id = "craft_lantern",
							price = 6,
							title = "Lantern",
							tone = "Yellow",
						},
						{
							currency = "scrap",
							icon = "triangle-exclamation",
							id = "craft_bear_trap",
							price = 12,
							title = "Bear Trap",
							tone = "Yellow",
						},
						{
							currency = "scrap",
							icon = "crosshairs",
							id = "craft_hunting_rifle",
							price = 40,
							title = "Hunting Rifle",
							tone = "Yellow",
						},
					},
					kind = "Products",
					layout = "Compact",
					title = "Recipes",
				},
			},
			size = "Medium",
			title = "Workbench",
		},
		{
			description = "Every item the player owns in one grid, rarest first, with the equipped ones on show above it.",
			icon = "backpack",
			id = "inventory",
			kind = "Window",
			nav = "None",
			openKey = "G",
			sections = {
				{
					columns = 5,
					detail = true,
					id = "inventory_2",
					items = {
						{
							equipped = true,
							icon = "crown",
							id = "inv_golden_axe",
							name = "Golden Axe",
							rarity = "Legendary",
						},
						{ icon = "crosshairs", id = "inv_hunting_rifle", name = "Hunting Rifle", rarity = "Epic" },
						{ icon = "shield", id = "inv_wolf_pelt", name = "Wolf Pelt", rarity = "Epic" },
						{ icon = "backpack", id = "inv_backpack", name = "Backpack", rarity = "Rare" },
						{ icon = "triangle-exclamation", id = "inv_bear_trap", name = "Bear Trap", rarity = "Rare" },
						{ count = 20, icon = "compass", id = "inv_compass", name = "Compass", rarity = "Uncommon" },
						{
							count = 18,
							icon = "flame",
							id = "inv_cooked_meat",
							name = "Cooked Meat",
							rarity = "Uncommon",
						},
						{ count = 15, icon = "sun", id = "inv_lantern", name = "Lantern", rarity = "Uncommon" },
						{ count = 5, icon = "map", id = "inv_map", name = "Map", rarity = "Uncommon" },
						{ count = 17, icon = "fish", id = "inv_raw_fish", name = "Raw Fish", rarity = "Common" },
						{ count = 17, icon = "chain-link", id = "inv_rope", name = "Rope", rarity = "Common" },
						{ count = 3, icon = "sword", id = "inv_stone_axe", name = "Stone Axe", rarity = "Common" },
						{ count = 4, icon = "flame", id = "inv_torch", name = "Torch", rarity = "Common" },
						{
							count = 13,
							icon = "lab-beaker",
							id = "inv_water_flask",
							name = "Water Flask",
							rarity = "Common",
						},
					},
					kind = "Inventory",
					layout = "Showcase",
				},
			},
			size = "Large",
			title = "Inventory",
		},
		{
			description = "Where Wood and Robux turn into progress. Tabs: Items, Gems and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			openKey = "B",
			size = "Large",
			tabs = {
				{
					icon = "backpack",
					id = "tab_items",
					label = "Items",
					sections = {
						{
							id = "products",
							items = {
								{
									currency = "wood",
									icon = "backpack",
									id = "buy_starter_kit",
									price = 70,
									title = "Starter Kit",
									tone = "Yellow",
								},
								{
									currency = "wood",
									icon = "jacket",
									id = "buy_warm_coat",
									price = 60,
									title = "Warm Coat",
									tone = "Yellow",
								},
								{
									currency = "wood",
									icon = "compass",
									id = "buy_lucky_compass",
									price = 35,
									title = "Lucky Compass",
									tone = "Orange",
								},
								{
									currency = "wood",
									icon = "heart",
									id = "buy_revive_totem",
									price = 100,
									title = "Revive Totem",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Carousel",
						},
					},
				},
				{
					icon = "gem",
					id = "tab_gems",
					label = "Gems",
					sections = {
						{
							id = "gem_bundles",
							items = {
								{
									amount = "150",
									currency = "robux",
									grant = { amount = 150, currency = "gems" },
									icon = "gem",
									id = "gems_pack_1",
									price = 49,
									title = "Starter Gems",
									tone = "Yellow",
								},
								{
									amount = "350",
									bonus = 15,
									currency = "robux",
									grant = { amount = 350, currency = "gems" },
									icon = "gem",
									id = "gems_pack_2",
									price = 99,
									tag = "POPULAR",
									title = "Small Gem Pack",
									tone = "Yellow",
								},
								{
									amount = "750",
									bonus = 23,
									currency = "robux",
									grant = { amount = 750, currency = "gems" },
									icon = "gem",
									id = "gems_pack_3",
									price = 199,
									title = "Big Gem Pack",
									tone = "Yellow",
								},
								{
									amount = "1,750",
									bonus = 43,
									currency = "robux",
									grant = { amount = 1750, currency = "gems" },
									icon = "file-box",
									id = "gems_pack_4",
									price = 399,
									tag = "BEST VALUE",
									title = "Mega Gem Pack",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Gem Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Wood",
									icon = "crown",
									id = "pass_vip_camp",
									price = 499,
									tag = "BEST VALUE",
									title = "VIP Camp",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "A loyal companion at your side",
									icon = "shield",
									id = "pass_pet_wolf",
									price = 299,
									title = "Pet Wolf",
									tone = "Yellow",
								},
								{
									currency = "robux",
									description = "More room for everything you find",
									icon = "backpack",
									id = "pass_bigger_backpack",
									price = 149,
									title = "Bigger Backpack",
									tone = "Orange",
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "The map of Hundred Nights: every area as a card, locked ones showing what it takes to get there.",
			icon = "map",
			id = "worlds",
			kind = "Window",
			nav = "None",
			openKey = "M",
			sections = {
				{
					id = "worlds_2",
					kind = "Worlds",
					worlds = {
						{
							icon = "sun",
							id = "world_the_clearing",
							locked = false,
							name = "The Clearing",
							tone = "Yellow",
						},
						{
							icon = "wheat",
							id = "world_pine_woods",
							locked = false,
							name = "Pine Woods",
							tone = "Yellow",
						},
						{
							icon = "cloud",
							id = "world_frozen_lake",
							locked = true,
							name = "Frozen Lake",
							requirement = "Level 20",
							tone = "Orange",
						},
						{
							icon = "hexagon-dot",
							id = "world_old_mine",
							locked = true,
							name = "Old Mine",
							requirement = "Level 30",
							tone = "Yellow",
						},
						{
							icon = "wifi-signal-good",
							id = "world_radio_tower",
							locked = true,
							name = "Radio Tower",
							requirement = "Level 40",
							tone = "Yellow",
						},
						{
							icon = "flame",
							id = "world_crash_site",
							locked = true,
							name = "Crash Site",
							requirement = "Level 50",
							tone = "Orange",
						},
					},
				},
			},
			size = "Large",
			title = "Map",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "Retry" },
					icon = "rebirth",
					id = "try_again",
					label = "Try Again",
					tone = "Yellow",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "main_menu",
					label = "Main Menu",
					tone = "Dark",
				},
			},
			description = "Shown the instant a run ends: what you achieved, what you keep, and a one-click way back in.",
			icon = "health",
			id = "gameover",
			kind = "Results",
			result = "Defeat",
			rewards = {
				{ amount = "+5", icon = "cube" },
				{ amount = "+500 XP", icon = "level" },
				{ amount = "+15", icon = "gem" },
			},
			stats = {
				{ label = "Nights Survived", value = "8" },
				{ label = "Trees Chopped", value = "122" },
				{ label = "Items Crafted", value = "24" },
				{ label = "Distance", value = "3.3 km" },
			},
			title = "Game Over",
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "hashtag",
									id = "damageNumbers",
									label = "Damage Numbers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = 70,
									icon = "binoculars",
									id = "fov",
									label = "Field of View",
									max = 120,
									min = 70,
									type = "Slider",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "cursor",
									id = "sensitivity",
									label = "Mouse Sensitivity",
									max = 100,
									min = 5,
									type = "Slider",
								},
							},
						},
						{
							binds = {
								{ action = "Attack", gamepad = "ButtonR2", key = "MouseButton1" },
								{ action = "Interact", gamepad = "ButtonX", key = "E" },
								{ action = "Craft", gamepad = "ButtonY", key = "C" },
								{ action = "Eat", gamepad = "ButtonB", key = "F" },
								{ action = "Sprint", gamepad = "ButtonL3", key = "LeftShift" },
								{ action = "Jump", gamepad = "ButtonA", key = "Space" },
								{ action = "Map", gamepad = "ButtonSelect", key = "M" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_6",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Adventure",
		buttonStyle = "Outline",
		clickFx = "Glow",
		corner = "Sharp",
		decoration = "Grain",
		density = "Compact",
		fonts = { body = "Kalam:Regular", display = "Kalam:Bold", title = "AmaticSC:Bold" },
		iconStyle = "Filled",
		palette = {
			accent = "#FF7A30",
			bad = "#E5484D",
			good = "#7ED957",
			primary = "#A8B545",
			secondary = "#C2A878",
			surface = "#1C1F17",
			text = "#EEF0E6",
			warn = "#F2C14E",
		},
		panelStyle = "Gradient",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Zoom",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="74">
              <Properties>
                <string name="Name">towerdefense_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: towerdefense, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD21F",
			format = "comma",
			icon = "coin",
			id = "coins",
			name = "Coins",
			source = { kind = "leaderstat", name = "Coins" },
			start = 8000,
		},
		{
			color = "#3AD1FF",
			format = "comma",
			icon = "gem",
			id = "gems",
			name = "Gems",
			source = { kind = "attribute", name = "Gems" },
			start = 575,
		},
		{
			color = "#5BE36B",
			format = "comma",
			icon = "cash",
			id = "cash",
			name = "Cash",
			source = { kind = "attribute", name = "Cash" },
			start = 850,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				id = "hud_objective",
				text = "Wave 11 / 30",
				type = "Objective",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Next wave",
				seconds = 27,
				type = "Timer",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_healthbar",
				max = 100,
				source = { kind = "humanoid" },
				type = "HealthBar",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopCenter",
				currencies = { "cash", "gems" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				slots = {
					{ icon = "circle-line-target-cursor", name = "Railgunner" },
					{ icon = "lightning-bolt", name = "Tesla Coil" },
					{ icon = "arrow-large-right", name = "Archer" },
					{ icon = "wheat", name = "Farm" },
					{ icon = "crosshairs", name = "Soldier" },
				},
				type = "Hotbar",
				visible = true,
			},
			{
				action = { kind = "Event", name = "SkipWave" },
				anchor = "Right",
				icon = "double-chevron-large-right",
				id = "hud_button",
				label = "Skip Wave",
				tone = "Yellow",
				type = "Button",
				visible = true,
			},
			{
				action = { kind = "Open", target = "menu" },
				anchor = "Left",
				icon = "menu",
				id = "hud_button_2",
				label = "Menu",
				tone = "White",
				type = "Button",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonY", key = "E", text = "Upgrade" },
					{ gamepad = "ButtonB", key = "X", text = "Sell" },
					{ gamepad = "ButtonX", key = "R", text = "Rotate" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "Right",
				buttons = {
					{
						action = { kind = "Toggle", target = "rewards" },
						icon = "gift",
						id = "btn_rewards",
						label = "Free Rewards",
						tone = "White",
					},
					{
						action = { kind = "Open", target = "codes" },
						icon = "tag",
						id = "btn_codes",
						label = "Codes",
						tone = "White",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
		},
		kit = "Chunky Toy",
		layout = "TopBar",
	},
	logo = {
		icon = "crown",
		subtitle = "TOWER DEFENSE",
		text = "Robot Tower Defense",
		tilt = 5,
		tone = "Pink",
		type = "Stacked",
	},
	meta = {
		genre = "towerdefense",
		kit = "Chunky Toy",
		prompt = "a towerdefense game",
		seed = 21,
		source = "offline",
		title = "Robot Tower Defense",
	},
	screens = {
		{
			bannerPosition = "Bottom",
			description = "The title screen: the Robot Tower Defense logo, DEFEND, Towers, Shop, Challenges and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Center",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "DEFEND",
							tone = "Pink",
						},
						{
							action = { kind = "Open", target = "towers" },
							icon = "shield",
							id = "menu_towers",
							label = "Towers",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "quests" },
							icon = "quest",
							id = "menu_quests",
							label = "Challenges",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "White",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "flame",
					id = "welcome_bundle",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 172800,
					title = "Welcome Bundle",
					tone = "Yellow",
				},
			},
			title = "Lobby",
		},
		{
			description = "Pick the five towers you take into a match; the collection tab shows everything summoned so far.",
			icon = "shield",
			id = "towers",
			kind = "Window",
			nav = "SideTabs",
			openKey = "U",
			size = "Large",
			tabs = {
				{
					icon = "shield",
					id = "tab_loadout",
					label = "Loadout",
					sections = {
						{
							id = "loadout",
							kind = "Loadout",
							slots = {
								{
									icon = "circle-line-target-cursor",
									name = "Railgunner",
									rarity = "Legendary",
									stats = {
										{ label = "Damage", value = 0.68 },
										{ label = "Range", value = 0.94 },
										{ label = "Fire Rate", value = 0.97 },
									},
								},
								{
									icon = "lightning-bolt",
									name = "Tesla Coil",
									rarity = "Epic",
									stats = {
										{ label = "Damage", value = 0.65 },
										{ label = "Range", value = 0.9 },
										{ label = "Fire Rate", value = 0.64 },
									},
								},
								{
									icon = "arrow-large-right",
									name = "Archer",
									rarity = "Uncommon",
									stats = {
										{ label = "Damage", value = 0.73 },
										{ label = "Range", value = 0.55 },
										{ label = "Fire Rate", value = 0.59 },
									},
								},
								{
									icon = "wheat",
									name = "Farm",
									rarity = "Uncommon",
									stats = {
										{ label = "Damage", value = 0.35 },
										{ label = "Range", value = 0.53 },
										{ label = "Fire Rate", value = 0.45 },
									},
								},
								{
									icon = "crosshairs",
									name = "Soldier",
									rarity = "Common",
									stats = {
										{ label = "Damage", value = 0.37 },
										{ label = "Range", value = 0.27 },
										{ label = "Fire Rate", value = 0.24 },
									},
								},
							},
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_collection",
					label = "Collection",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{ icon = "flag", id = "inv_commander", name = "Commander", rarity = "Legendary" },
								{
									equipped = true,
									icon = "circle-line-target-cursor",
									id = "inv_railgunner",
									name = "Railgunner",
									rarity = "Legendary",
								},
								{ icon = "flame", id = "inv_pyromancer", name = "Pyromancer", rarity = "Epic" },
								{
									equipped = true,
									icon = "lightning-bolt",
									id = "inv_tesla_coil",
									name = "Tesla Coil",
									rarity = "Epic",
								},
								{ icon = "heart", id = "inv_medic", name = "Medic", rarity = "Rare" },
								{ icon = "bullet-flying", id = "inv_minigunner", name = "Minigunner", rarity = "Rare" },
								{
									equipped = true,
									icon = "arrow-large-right",
									id = "inv_archer",
									name = "Archer",
									rarity = "Uncommon",
								},
								{ equipped = true, icon = "wheat", id = "inv_farm", name = "Farm", rarity = "Uncommon" },
								{ icon = "binoculars", id = "inv_scout", name = "Scout", rarity = "Common" },
								{
									equipped = true,
									icon = "crosshairs",
									id = "inv_soldier",
									name = "Soldier",
									rarity = "Common",
								},
								{
									icon = "nebula",
									id = "locked_void_titan",
									locked = true,
									name = "Void Titan",
									rarity = "Mythic",
								},
							},
							kind = "Inventory",
						},
					},
				},
			},
			title = "Towers",
		},
		{
			description = "Everything free in one place: Daily, Gifts and Spin. Every tab badges what's ready, and so does the HUD button.",
			icon = "gift",
			id = "rewards",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							days = {
								{ amount = "450", day = 1, icon = "coin" },
								{ amount = "675", day = 2, icon = "coin" },
								{ amount = "x2", day = 3, icon = "boost" },
								{ amount = "30", day = 4, icon = "gem" },
								{ amount = "1,250", day = 5, icon = "coin" },
								{ amount = "1,500", day = 6, icon = "coin" },
								{ amount = "75", day = 7, icon = "gem" },
								{ amount = "725", day = 8, icon = "coin" },
								{ amount = "1,000", day = 9, icon = "coin" },
								{ amount = "x2", day = 10, icon = "boost" },
								{ amount = "50", day = 11, icon = "gem" },
								{ amount = "2,250", day = 12, icon = "coin" },
								{ amount = "2,500", day = 13, icon = "coin" },
								{ amount = "125", day = 14, icon = "gem" },
								{ amount = "1,000", day = 15, icon = "coin" },
								{ amount = "1,500", day = 16, icon = "coin" },
								{ amount = "x3", day = 17, icon = "boost" },
								{ amount = "65", day = 18, icon = "gem" },
								{ amount = "3,000", day = 19, icon = "coin" },
								{ amount = "3,500", day = 20, icon = "coin" },
								{ amount = "175", day = 21, icon = "gem" },
								{ amount = "1,250", day = 22, icon = "coin" },
								{ amount = "2,000", day = 23, icon = "coin" },
								{ amount = "x3", day = 24, icon = "boost" },
								{ amount = "85", day = 25, icon = "gem" },
								{ amount = "3,750", day = 26, icon = "coin" },
								{ amount = "4,500", day = 27, icon = "coin" },
								{ amount = "x1", day = 28, icon = "gift", label = "Event Crate" },
							},
							id = "daily",
							kind = "Daily",
							layout = "Calendar",
							streak = 4,
						},
					},
				},
				{
					icon = "gift",
					id = "tab_gifts",
					label = "Gifts",
					sections = {
						{
							gifts = {
								{
									amount = "175",
									grant = { amount = 175, currency = "coins" },
									icon = "coin",
									id = "gift1",
									minutes = 1,
									rarity = "Common",
								},
								{
									amount = "250",
									grant = { amount = 250, currency = "coins" },
									icon = "coin",
									id = "gift2",
									minutes = 3,
									rarity = "Common",
								},
								{
									amount = "350",
									grant = { amount = 350, currency = "coins" },
									icon = "coin",
									id = "gift3",
									minutes = 5,
									rarity = "Common",
								},
								{
									amount = "30m",
									icon = "coin",
									id = "gift4",
									label = "2x Coins",
									minutes = 10,
									rarity = "Rare",
								},
								{
									amount = "775",
									grant = { amount = 775, currency = "coins" },
									icon = "coin",
									id = "gift5",
									minutes = 15,
									rarity = "Uncommon",
								},
								{
									amount = "1,000",
									grant = { amount = 1000, currency = "coins" },
									icon = "coin",
									id = "gift6",
									minutes = 20,
									rarity = "Rare",
								},
								{
									amount = "10",
									grant = { amount = 10, currency = "gems" },
									icon = "gem",
									id = "gift7",
									minutes = 30,
									rarity = "Rare",
								},
								{
									amount = "50",
									grant = { amount = 50, currency = "gems" },
									icon = "gem",
									id = "gift8",
									minutes = 45,
									rarity = "Legendary",
								},
							},
							id = "gifts",
							kind = "Gifts",
						},
					},
				},
				{
					icon = "chart-pie",
					id = "tab_spin",
					label = "Spin",
					sections = {
						{
							currency = "gems",
							freeHours = 24,
							id = "spin",
							kind = "Spin",
							luckPrice = 249,
							price = 60,
							wedges = {
								{
									amount = "275",
									chance = 26,
									grant = { amount = 275, currency = "coins" },
									icon = "coin",
									id = "wedge1",
									rarity = "Common",
								},
								{
									amount = "2,750",
									chance = 10,
									grant = { amount = 2750, currency = "coins" },
									icon = "coin",
									id = "wedge5",
									rarity = "Rare",
								},
								{
									amount = "550",
									chance = 22,
									grant = { amount = 550, currency = "coins" },
									icon = "coin",
									id = "wedge2",
									rarity = "Common",
								},
								{
									amount = "30",
									chance = 7,
									grant = { amount = 30, currency = "gems" },
									icon = "gem",
									id = "wedge6",
									rarity = "Epic",
								},
								{
									amount = "1,000",
									chance = 16,
									grant = { amount = 1000, currency = "coins" },
									icon = "coin",
									id = "wedge3",
									rarity = "Uncommon",
								},
								{
									amount = "x1",
									chance = 1.5,
									icon = "flag",
									id = "wedge8",
									label = "Commander",
									rarity = "Legendary",
								},
								{
									amount = "10",
									chance = 12,
									grant = { amount = 10, currency = "gems" },
									icon = "gem",
									id = "wedge4",
									rarity = "Uncommon",
								},
								{
									amount = "1,500",
									chance = 0.5,
									grant = { amount = 1500, currency = "gems" },
									icon = "gem",
									id = "wedge9",
									rarity = "Mythic",
								},
								{
									amount = "30m",
									chance = 5,
									icon = "coin",
									id = "wedge7",
									label = "2x Coins",
									rarity = "Rare",
								},
							},
						},
					},
				},
			},
			title = "Free Rewards",
		},
		{
			description = "Where Coins and Robux turn into progress. Tabs: Summons, Gems and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					icon = "file-box",
					id = "tab_summons",
					label = "Summons",
					sections = {
						{
							cases = {
								{
									currency = "coins",
									icon = "file-box",
									id = "case_basic_crate",
									pool = {
										{ chance = 28, icon = "binoculars", name = "Scout", rarity = "Common" },
										{ chance = 28, icon = "crosshairs", name = "Soldier", rarity = "Common" },
										{ chance = 13, icon = "arrow-large-right", name = "Archer", rarity = "Uncommon" },
										{ chance = 13, icon = "wheat", name = "Farm", rarity = "Uncommon" },
										{ chance = 6.2, icon = "cloud", name = "Freezer", rarity = "Rare" },
										{ chance = 6.2, icon = "heart", name = "Medic", rarity = "Rare" },
										{ chance = 2.8, icon = "flame", name = "Pyromancer", rarity = "Epic" },
										{ chance = 2.8, icon = "lightning-bolt", name = "Tesla Coil", rarity = "Epic" },
									},
									price = 275,
									title = "Basic Crate",
									tone = "Gray",
								},
								{
									currency = "coins",
									icon = "star",
									id = "case_elite_crate",
									pool = {
										{ chance = 29.2, icon = "arrow-large-right", name = "Archer", rarity = "Uncommon" },
										{ chance = 28, icon = "wheat", name = "Farm", rarity = "Uncommon" },
										{ chance = 13, icon = "cloud", name = "Freezer", rarity = "Rare" },
										{ chance = 13, icon = "heart", name = "Medic", rarity = "Rare" },
										{ chance = 6.2, icon = "flame", name = "Pyromancer", rarity = "Epic" },
										{ chance = 6.2, icon = "lightning-bolt", name = "Tesla Coil", rarity = "Epic" },
										{ chance = 2.2, icon = "flag", name = "Commander", rarity = "Legendary" },
										{
											chance = 2.2,
											icon = "circle-line-target-cursor",
											name = "Railgunner",
											rarity = "Legendary",
										},
									},
									price = 825,
									title = "Elite Crate",
									tone = "Blue",
								},
								{
									currency = "coins",
									icon = "crown",
									id = "case_mythic_crate",
									pool = {
										{ chance = 30.7, icon = "cloud", name = "Freezer", rarity = "Rare" },
										{ chance = 30, icon = "heart", name = "Medic", rarity = "Rare" },
										{ chance = 14, icon = "flame", name = "Pyromancer", rarity = "Epic" },
										{ chance = 14, icon = "lightning-bolt", name = "Tesla Coil", rarity = "Epic" },
										{ chance = 5.1, icon = "flag", name = "Commander", rarity = "Legendary" },
										{
											chance = 5.1,
											icon = "circle-line-target-cursor",
											name = "Railgunner",
											rarity = "Legendary",
										},
										{ chance = 1.1, icon = "nebula", name = "Void Titan", rarity = "Mythic" },
									},
									price = 3250,
									title = "Mythic Crate",
									tone = "Purple",
								},
								{
									currency = "coins",
									icon = "gift",
									id = "case_event_crate",
									pool = {
										{ chance = 30.7, icon = "cloud", name = "Freezer", rarity = "Rare" },
										{ chance = 30, icon = "heart", name = "Medic", rarity = "Rare" },
										{ chance = 14, icon = "flame", name = "Pyromancer", rarity = "Epic" },
										{ chance = 14, icon = "lightning-bolt", name = "Tesla Coil", rarity = "Epic" },
										{ chance = 5.1, icon = "flag", name = "Commander", rarity = "Legendary" },
										{
											chance = 5.1,
											icon = "circle-line-target-cursor",
											name = "Railgunner",
											rarity = "Legendary",
										},
										{ chance = 1.1, icon = "nebula", name = "Void Titan", rarity = "Mythic" },
									},
									price = 7250,
									title = "Event Crate",
									tone = "Pink",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "gem",
					id = "tab_gems",
					label = "Gems",
					sections = {
						{
							id = "gem_bundles",
							items = {
								{
									amount = "100",
									currency = "robux",
									grant = { amount = 100, currency = "gems" },
									icon = "gem",
									id = "gems_pack_1",
									price = 49,
									title = "Handful of Gems",
									tone = "Blue",
								},
								{
									amount = "225",
									bonus = 11,
									currency = "robux",
									grant = { amount = 225, currency = "gems" },
									icon = "gem",
									id = "gems_pack_2",
									price = 99,
									tag = "SALE",
									title = "Pouch of Gems",
									tone = "Blue",
									was = 149,
								},
								{
									amount = "500",
									bonus = 23,
									currency = "robux",
									grant = { amount = 500, currency = "gems" },
									icon = "gem",
									id = "gems_pack_3",
									price = 199,
									title = "Sack of Gems",
									tone = "Pink",
								},
								{
									amount = "1,250",
									bonus = 54,
									currency = "robux",
									grant = { amount = 1250, currency = "gems" },
									icon = "file-box",
									id = "gems_pack_4",
									price = 399,
									tag = "BEST VALUE",
									title = "Chest of Gems",
									tone = "Pink",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Gem Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Coins",
									icon = "crown",
									id = "pass_vip",
									price = 399,
									tag = "MUST HAVE",
									title = "VIP",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Place one more tower every wave",
									icon = "plus",
									id = "pass_extra_tower_slot",
									price = 299,
									title = "Extra Tower Slot",
									tone = "Blue",
								},
								{
									currency = "robux",
									description = "Move twice as fast, everywhere",
									icon = "double-chevron-large-right",
									id = "pass_2x_speed",
									price = 199,
									title = "2x Speed",
									tone = "Blue",
								},
								{
									currency = "robux",
									description = "Skip the wait and jump straight in",
									icon = "clock",
									id = "pass_auto_skip",
									price = 149,
									title = "Auto Skip",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Hero",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Quest cards with progress bars under a reset countdown; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "None",
			openKey = "L",
			sections = {
				{
					icon = "timer",
					id = "new_quests_in",
					kind = "Banner",
					subtitle = "Finish all quests for a bonus chest",
					timer = 39600,
					title = "New quests in",
					tone = "Blue",
				},
				{
					id = "quests_2",
					kind = "Quests",
					quests = {
						{
							description = "Place 175 towers",
							goal = 175,
							icon = "cube",
							id = "quest_architect",
							progress = 175,
							reward = { amount = "500", icon = "coin" },
							title = "Architect",
						},
						{
							description = "Defeat 6 bosses",
							goal = 6,
							icon = "crown",
							id = "quest_boss_hunter",
							progress = 0,
							reward = { amount = "25", icon = "gem" },
							title = "Boss Hunter",
						},
						{
							description = "Survive wave 25 on Hard",
							goal = 25,
							icon = "shield",
							id = "quest_wave_breaker",
							progress = 4,
							reward = { amount = "900", icon = "coin" },
							title = "Wave Breaker",
						},
						{
							description = "Earn 4,750 cash from farms",
							goal = 4750,
							icon = "wheat",
							id = "quest_economist",
							progress = 0,
							reward = { amount = "375", icon = "coin" },
							title = "Economist",
						},
					},
				},
			},
			size = "Medium",
			title = "Challenges",
		},
		{
			description = "Redeem promo codes. The server validates them through the RedeemCode remote.",
			icon = "tag",
			id = "codes",
			kind = "Popup",
			nav = "None",
			sections = {
				{ body = "Like the game and join the group for secret codes.", id = "text", kind = "Text" },
				{
					action = { kind = "Remote", name = "RedeemCode" },
					button = "Redeem",
					id = "codes_2",
					kind = "Codes",
					placeholder = "e.g. RELEASE",
				},
			},
			size = "Small",
			title = "Codes",
			transition = "Zoom",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Pink",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "main_menu",
					label = "Main Menu",
					tone = "White",
				},
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Victory",
			rewards = { { amount = "+375", icon = "coin" }, { amount = "+300 XP", icon = "level" } },
			stats = {
				{ label = "Waves", value = "30/30" },
				{ label = "Damage", value = "3,121" },
				{ label = "Towers Placed", value = "50" },
				{ label = "Time", value = "17:06" },
			},
			title = "Results",
		},
		{
			delay = 5,
			description = "A one-time Welcome Bundle: 4 rewards for 99 Robux (was 299), counting down 48h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "flame",
			id = "offer",
			items = {
				{ amount = "300", grant = { amount = 300, currency = "gems" }, icon = "gem", label = "Gems" },
				{
					amount = "17,500",
					grant = { amount = 17500, currency = "coins" },
					icon = "coin",
					label = "Coins",
				},
				{ amount = "1", icon = "flame", label = "Epic Pyromancer" },
				{ amount = "3", icon = "coin", label = "2x Coins (30m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "LIMITED",
			timer = 172800,
			title = "Welcome Bundle",
			tone = "Yellow",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "skip-next-large",
									id = "skipHatch",
									label = "Skip Summon Animation",
									type = "Toggle",
								},
								{
									default = true,
									icon = "hashtag",
									id = "damageNumbers",
									label = "Damage Numbers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							binds = {
								{ action = "Place Tower", gamepad = "ButtonA", key = "MouseButton1" },
								{ action = "Upgrade", gamepad = "ButtonY", key = "E" },
								{ action = "Sell", gamepad = "ButtonB", key = "X" },
								{ action = "Rotate", gamepad = "ButtonX", key = "R" },
								{ action = "Tower 1", gamepad = "DPadLeft", key = "One" },
								{ action = "Tower 2", gamepad = "DPadUp", key = "Two" },
								{ action = "Skip Wave", gamepad = "ButtonSelect", key = "K" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Candy",
		buttonStyle = "Pill",
		clickFx = "Squash",
		corner = "Bubbly",
		decoration = "Dots",
		density = "Compact",
		fonts = { body = "FredokaOne", display = "LuckiestGuy", title = "LuckiestGuy" },
		iconStyle = "Filled",
		palette = {
			accent = "#FFD84D",
			bad = "#FF5A6E",
			good = "#43D17A",
			primary = "#FF6FA5",
			secondary = "#6CC6FF",
			surface = "#4A2340",
			text = "#FFF6F0",
			warn = "#FFB938",
		},
		panelStyle = "Ribbon",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Pop",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="75">
              <Properties>
                <string name="Name">towerdefense_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: towerdefense, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD21F",
			format = "comma",
			icon = "coin",
			id = "coins",
			name = "Coins",
			source = { kind = "leaderstat", name = "Coins" },
			start = 7250,
		},
		{
			color = "#B45CFF",
			format = "comma",
			icon = "diamond",
			id = "crystals",
			name = "Crystals",
			source = { kind = "attribute", name = "Crystals" },
			start = 450,
		},
		{
			color = "#5BE36B",
			format = "comma",
			icon = "cash",
			id = "cash",
			name = "Cash",
			source = { kind = "attribute", name = "Cash" },
			start = 575,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				id = "hud_objective",
				text = "Wave 3 / 30",
				type = "Objective",
				visible = true,
			},
			{
				anchor = "TopCenter",
				id = "hud_timer",
				label = "Next wave",
				seconds = 25,
				type = "Timer",
				visible = true,
			},
			{
				anchor = "TopRight",
				id = "hud_healthbar",
				max = 100,
				source = { kind = "humanoid" },
				type = "HealthBar",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "Left",
				currencies = { "cash", "crystals" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "BottomCenter",
				id = "hud_hotbar",
				slots = {
					{ icon = "nebula", name = "Void Titan" },
					{ icon = "bullet-flying", name = "Minigunner" },
					{ icon = "wheat", name = "Farm" },
					{ icon = "binoculars", name = "Scout" },
					{ icon = "crosshairs", name = "Soldier" },
				},
				type = "Hotbar",
				visible = true,
			},
			{
				action = { kind = "Event", name = "SkipWave" },
				anchor = "Right",
				icon = "double-chevron-large-right",
				id = "hud_button",
				label = "Skip Wave",
				tone = "Cyan",
				type = "Button",
				visible = true,
			},
			{
				action = { kind = "Open", target = "menu" },
				anchor = "Left",
				icon = "menu",
				id = "hud_button_2",
				label = "Menu",
				tone = "Dark",
				type = "Button",
				visible = true,
			},
			{
				anchor = "BottomRight",
				hints = {
					{ gamepad = "ButtonY", key = "E", text = "Upgrade" },
					{ gamepad = "ButtonB", key = "X", text = "Sell" },
					{ gamepad = "ButtonX", key = "R", text = "Rotate" },
				},
				id = "hud_keyhints",
				type = "KeyHints",
				visible = true,
			},
			{
				anchor = "TopLeft",
				goal = 12,
				id = "hud_counter",
				label = "UNITS",
				style = "Digital",
				type = "Counter",
				value = 12,
				visible = true,
			},
			{
				anchor = "BottomLeft",
				icon = "star",
				id = "hud_counter_2",
				label = "TICKETS",
				style = "Stamp",
				type = "Counter",
				value = 29,
				visible = true,
			},
			{
				anchor = "Right",
				buttons = {
					{
						action = { kind = "Toggle", target = "rewards" },
						icon = "gift",
						id = "btn_rewards",
						label = "Free Rewards",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Challenges",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "leaderboard" },
						icon = "trophy",
						id = "btn_leaderboard",
						label = "Rankings",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Grid",
				type = "MenuButtons",
				visible = true,
			},
		},
		kit = "Chunky Toy",
		layout = "Classic",
	},
	logo = { icon = "shield", text = "MECHA DEFENDERS", tilt = -4, tone = "Pink", type = "Stacked" },
	meta = {
		flavor = "units",
		genre = "towerdefense",
		kit = "Chunky Toy",
		prompt = "a towerdefense game",
		seed = 7,
		source = "offline",
		title = "Mecha Defenders",
	},
	screens = {
		{
			bannerPosition = "Corner",
			description = "The title screen: the Mecha Defenders logo, DEFEND, Party, Loadout, Shop, Maps and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Corner",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "DEFEND",
							tone = "Pink",
						},
						{
							action = { kind = "Open", target = "party" },
							icon = "friends",
							id = "menu_party",
							label = "Party",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "towers" },
							icon = "shield",
							id = "menu_towers",
							label = "Loadout",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "worlds" },
							icon = "map",
							id = "menu_worlds",
							label = "Maps",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "flame",
					id = "starter_pack",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 86400,
					title = "Starter Pack",
					tone = "Cyan",
				},
			},
			title = "Lobby",
		},
		{
			description = "Invite friends into your party and pick a mode together; the party leader's pick drives matchmaking.",
			icon = "friends",
			id = "party",
			kind = "Window",
			nav = "None",
			openKey = "P",
			sections = {
				{ id = "party_2", kind = "Party", slots = 4 },
				{
					buttons = {
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "trophy",
							id = "mode_ranked",
							label = "Ranked",
							tone = "Pink",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "gear",
							id = "mode_custom",
							label = "Custom",
							tone = "Dark",
						},
						{
							action = { kind = "Remote", name = "Matchmake" },
							icon = "play",
							id = "mode_quick_match",
							label = "Quick Match",
							tone = "Dark",
						},
					},
					id = "modes",
					kind = "Buttons",
					layout = "Grid",
					title = "Modes",
				},
			},
			size = "Medium",
			title = "Party",
		},
		{
			description = "Pick the five towers you take into a match; the collection tab shows everything summoned so far.",
			icon = "shield",
			id = "towers",
			kind = "Fullscreen",
			nav = "SideTabs",
			openKey = "U",
			size = "Large",
			tabs = {
				{
					icon = "shield",
					id = "tab_loadout",
					label = "Loadout",
					sections = {
						{
							id = "loadout",
							kind = "Loadout",
							slots = {
								{
									icon = "nebula",
									name = "Void Titan",
									rarity = "Mythic",
									stats = {
										{ label = "Damage", value = 0.98 },
										{ label = "Range", value = 0.98 },
										{ label = "Fire Rate", value = 0.9 },
									},
								},
								{
									icon = "bullet-flying",
									name = "Minigunner",
									rarity = "Rare",
									stats = {
										{ label = "Damage", value = 0.73 },
										{ label = "Range", value = 0.64 },
										{ label = "Fire Rate", value = 0.78 },
									},
								},
								{
									icon = "wheat",
									name = "Farm",
									rarity = "Uncommon",
									stats = {
										{ label = "Damage", value = 0.35 },
										{ label = "Range", value = 0.49 },
										{ label = "Fire Rate", value = 0.63 },
									},
								},
								{
									icon = "binoculars",
									name = "Scout",
									rarity = "Common",
									stats = {
										{ label = "Damage", value = 0.55 },
										{ label = "Range", value = 0.33 },
										{ label = "Fire Rate", value = 0.53 },
									},
								},
								{
									icon = "crosshairs",
									name = "Soldier",
									rarity = "Common",
									stats = {
										{ label = "Damage", value = 0.29 },
										{ label = "Range", value = 0.21 },
										{ label = "Fire Rate", value = 0.57 },
									},
								},
							},
						},
					},
				},
				{
					icon = "backpack",
					id = "tab_collection",
					label = "Collection",
					sections = {
						{
							columns = 5,
							detail = true,
							id = "inventory",
							items = {
								{ icon = "flag", id = "inv_commander", name = "Commander", rarity = "Legendary" },
								{ icon = "flame", id = "inv_pyromancer", name = "Pyromancer", rarity = "Epic" },
								{ icon = "lightning-bolt", id = "inv_tesla_coil", name = "Tesla Coil", rarity = "Epic" },
								{ icon = "cloud", id = "inv_freezer", name = "Freezer", rarity = "Rare" },
								{ icon = "heart", id = "inv_medic", name = "Medic", rarity = "Rare" },
								{
									equipped = true,
									icon = "bullet-flying",
									id = "inv_minigunner",
									name = "Minigunner",
									rarity = "Rare",
								},
								{ icon = "arrow-large-right", id = "inv_archer", name = "Archer", rarity = "Uncommon" },
								{ equipped = true, icon = "wheat", id = "inv_farm", name = "Farm", rarity = "Uncommon" },
								{
									equipped = true,
									icon = "binoculars",
									id = "inv_scout",
									name = "Scout",
									rarity = "Common",
								},
								{
									equipped = true,
									icon = "crosshairs",
									id = "inv_soldier",
									name = "Soldier",
									rarity = "Common",
								},
								{
									icon = "circle-line-target-cursor",
									id = "locked_railgunner",
									locked = true,
									name = "Railgunner",
									rarity = "Legendary",
								},
							},
							kind = "Inventory",
							layout = "Bottom",
						},
					},
				},
			},
			title = "Loadout",
		},
		{
			description = "Everything free in one place: Daily, Gifts and Spin. Every tab badges what's ready, and so does the HUD button.",
			icon = "gift",
			id = "rewards",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							days = {
								{ amount = "450", day = 1, icon = "coin" },
								{ amount = "675", day = 2, icon = "coin" },
								{ amount = "40", day = 3, icon = "diamond" },
								{ amount = "1,250", day = 4, icon = "coin" },
								{ amount = "x2", day = 5, icon = "boost", label = "15 min" },
								{ amount = "1,500", day = 6, icon = "coin" },
								{ amount = "x1", day = 7, icon = "gift", label = "Event Crate" },
							},
							id = "daily",
							kind = "Daily",
							layout = "Ladder",
							streak = 2,
						},
					},
				},
				{
					icon = "gift",
					id = "tab_gifts",
					label = "Gifts",
					sections = {
						{
							gifts = {
								{
									amount = "200",
									grant = { amount = 200, currency = "coins" },
									icon = "coin",
									id = "gift1",
									minutes = 1,
									rarity = "Common",
								},
								{
									amount = "300",
									grant = { amount = 300, currency = "coins" },
									icon = "coin",
									id = "gift2",
									minutes = 3,
									rarity = "Common",
								},
								{
									amount = "425",
									grant = { amount = 425, currency = "coins" },
									icon = "coin",
									id = "gift3",
									minutes = 5,
									rarity = "Common",
								},
								{
									amount = "675",
									grant = { amount = 675, currency = "coins" },
									icon = "coin",
									id = "gift4",
									minutes = 10,
									rarity = "Uncommon",
								},
								{
									amount = "950",
									grant = { amount = 950, currency = "coins" },
									icon = "coin",
									id = "gift5",
									minutes = 15,
									rarity = "Uncommon",
								},
								{
									amount = "30m",
									icon = "coin",
									id = "gift6",
									label = "2x Coins",
									minutes = 20,
									rarity = "Rare",
								},
								{
									amount = "10",
									grant = { amount = 10, currency = "crystals" },
									icon = "diamond",
									id = "gift7",
									minutes = 30,
									rarity = "Rare",
								},
								{
									amount = "2,500",
									grant = { amount = 2500, currency = "coins" },
									icon = "coin",
									id = "gift8",
									minutes = 45,
									rarity = "Rare",
								},
								{
									amount = "20",
									grant = { amount = 20, currency = "crystals" },
									icon = "diamond",
									id = "gift9",
									minutes = 60,
									rarity = "Epic",
								},
								{
									amount = "4,000",
									grant = { amount = 4000, currency = "coins" },
									icon = "coin",
									id = "gift10",
									minutes = 75,
									rarity = "Rare",
								},
								{
									amount = "35",
									grant = { amount = 35, currency = "crystals" },
									icon = "diamond",
									id = "gift11",
									minutes = 90,
									rarity = "Epic",
								},
								{
									amount = "x1",
									icon = "flame",
									id = "gift12",
									label = "Pyromancer",
									minutes = 120,
									rarity = "Epic",
								},
							},
							id = "gifts",
							kind = "Gifts",
						},
					},
				},
				{
					icon = "chart-pie",
					id = "tab_spin",
					label = "Spin",
					sections = {
						{
							currency = "crystals",
							freeHours = 24,
							id = "spin",
							kind = "Spin",
							luckPrice = 249,
							price = 75,
							wedges = {
								{
									amount = "300",
									chance = 26,
									grant = { amount = 300, currency = "coins" },
									icon = "coin",
									id = "wedge1",
									rarity = "Common",
								},
								{
									amount = "3,000",
									chance = 10,
									grant = { amount = 3000, currency = "coins" },
									icon = "coin",
									id = "wedge5",
									rarity = "Rare",
								},
								{
									amount = "600",
									chance = 22,
									grant = { amount = 600, currency = "coins" },
									icon = "coin",
									id = "wedge2",
									rarity = "Common",
								},
								{
									amount = "25",
									chance = 7,
									grant = { amount = 25, currency = "crystals" },
									icon = "diamond",
									id = "wedge6",
									rarity = "Epic",
								},
								{
									amount = "1,250",
									chance = 16,
									grant = { amount = 1250, currency = "coins" },
									icon = "coin",
									id = "wedge3",
									rarity = "Uncommon",
								},
								{
									amount = "500",
									chance = 1.5,
									grant = { amount = 500, currency = "crystals" },
									icon = "diamond",
									id = "wedge8",
									rarity = "Legendary",
								},
								{
									amount = "10",
									chance = 12,
									grant = { amount = 10, currency = "crystals" },
									icon = "diamond",
									id = "wedge4",
									rarity = "Uncommon",
								},
								{
									amount = "1,000",
									chance = 0.5,
									grant = { amount = 1000, currency = "crystals" },
									icon = "diamond",
									id = "wedge9",
									rarity = "Mythic",
								},
								{
									amount = "30m",
									chance = 5,
									icon = "coin",
									id = "wedge7",
									label = "2x Coins",
									rarity = "Rare",
								},
							},
						},
					},
				},
			},
			title = "Free Rewards",
		},
		{
			description = "Where Coins and Robux turn into progress. Tabs: Summons, Crystals and Passes.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					icon = "file-box",
					id = "tab_summons",
					label = "Summons",
					sections = {
						{
							cases = {
								{
									currency = "coins",
									icon = "file-box",
									id = "case_basic_crate",
									pool = {
										{ chance = 28, icon = "binoculars", name = "Scout", rarity = "Common" },
										{ chance = 28, icon = "crosshairs", name = "Soldier", rarity = "Common" },
										{ chance = 13, icon = "arrow-large-right", name = "Archer", rarity = "Uncommon" },
										{ chance = 13, icon = "wheat", name = "Farm", rarity = "Uncommon" },
										{ chance = 6.2, icon = "cloud", name = "Freezer", rarity = "Rare" },
										{ chance = 6.2, icon = "heart", name = "Medic", rarity = "Rare" },
										{ chance = 2.8, icon = "flame", name = "Pyromancer", rarity = "Epic" },
										{ chance = 2.8, icon = "lightning-bolt", name = "Tesla Coil", rarity = "Epic" },
									},
									price = 350,
									title = "Basic Crate",
									tone = "Gray",
								},
								{
									currency = "coins",
									icon = "star",
									id = "case_elite_crate",
									pool = {
										{ chance = 29.2, icon = "arrow-large-right", name = "Archer", rarity = "Uncommon" },
										{ chance = 28, icon = "wheat", name = "Farm", rarity = "Uncommon" },
										{ chance = 13, icon = "cloud", name = "Freezer", rarity = "Rare" },
										{ chance = 13, icon = "heart", name = "Medic", rarity = "Rare" },
										{ chance = 6.2, icon = "flame", name = "Pyromancer", rarity = "Epic" },
										{ chance = 6.2, icon = "lightning-bolt", name = "Tesla Coil", rarity = "Epic" },
										{ chance = 2.2, icon = "flag", name = "Commander", rarity = "Legendary" },
										{
											chance = 2.2,
											icon = "circle-line-target-cursor",
											name = "Railgunner",
											rarity = "Legendary",
										},
									},
									price = 1000,
									title = "Elite Crate",
									tone = "Blue",
								},
								{
									currency = "coins",
									icon = "crown",
									id = "case_mythic_crate",
									pool = {
										{ chance = 30.7, icon = "cloud", name = "Freezer", rarity = "Rare" },
										{ chance = 30, icon = "heart", name = "Medic", rarity = "Rare" },
										{ chance = 14, icon = "flame", name = "Pyromancer", rarity = "Epic" },
										{ chance = 14, icon = "lightning-bolt", name = "Tesla Coil", rarity = "Epic" },
										{ chance = 5.1, icon = "flag", name = "Commander", rarity = "Legendary" },
										{
											chance = 5.1,
											icon = "circle-line-target-cursor",
											name = "Railgunner",
											rarity = "Legendary",
										},
										{ chance = 1.1, icon = "nebula", name = "Void Titan", rarity = "Mythic" },
									},
									price = 3000,
									title = "Mythic Crate",
									tone = "Purple",
								},
								{
									currency = "coins",
									icon = "gift",
									id = "case_event_crate",
									pool = {
										{ chance = 30.7, icon = "cloud", name = "Freezer", rarity = "Rare" },
										{ chance = 30, icon = "heart", name = "Medic", rarity = "Rare" },
										{ chance = 14, icon = "flame", name = "Pyromancer", rarity = "Epic" },
										{ chance = 14, icon = "lightning-bolt", name = "Tesla Coil", rarity = "Epic" },
										{ chance = 5.1, icon = "flag", name = "Commander", rarity = "Legendary" },
										{
											chance = 5.1,
											icon = "circle-line-target-cursor",
											name = "Railgunner",
											rarity = "Legendary",
										},
										{ chance = 1.1, icon = "nebula", name = "Void Titan", rarity = "Mythic" },
									},
									price = 8000,
									title = "Event Crate",
									tone = "Pink",
								},
							},
							id = "cases",
							kind = "Cases",
						},
					},
				},
				{
					icon = "diamond",
					id = "tab_crystals",
					label = "Crystals",
					sections = {
						{
							icon = "diamond",
							id = "stock_up_on_crystals",
							kind = "Banner",
							subtitle = "Bigger packs pack more bonus Crystals",
							title = "Stock Up on Crystals",
							tone = "Pink",
						},
						{
							id = "crystal_bundles",
							items = {
								{
									amount = "80",
									currency = "robux",
									grant = { amount = 80, currency = "crystals" },
									icon = "diamond",
									id = "crystals_pack_1",
									price = 49,
									title = "Starter Crystals",
									tone = "Blue",
								},
								{
									amount = "200",
									bonus = 24,
									currency = "robux",
									grant = { amount = 200, currency = "crystals" },
									icon = "diamond",
									id = "crystals_pack_2",
									price = 99,
									tag = "SALE",
									title = "Small Crystal Pack",
									tone = "Blue",
									was = 149,
								},
								{
									amount = "400",
									bonus = 23,
									currency = "robux",
									grant = { amount = 400, currency = "crystals" },
									icon = "diamond",
									id = "crystals_pack_3",
									price = 199,
									tag = "POPULAR",
									title = "Big Crystal Pack",
									tone = "Pink",
								},
								{
									amount = "900",
									bonus = 38,
									currency = "robux",
									grant = { amount = 900, currency = "crystals" },
									icon = "file-box",
									id = "crystals_pack_4",
									price = 399,
									title = "Mega Crystal Pack",
									tone = "Pink",
								},
								{
									amount = "2,000",
									bonus = 53,
									currency = "robux",
									grant = { amount = 2000, currency = "crystals" },
									icon = "building-bank",
									id = "crystals_pack_5",
									price = 799,
									title = "Giga Crystal Pack",
									tone = "Cyan",
								},
								{
									amount = "4,500",
									bonus = 84,
									currency = "robux",
									grant = { amount = 4500, currency = "crystals" },
									icon = "crown",
									id = "crystals_pack_6",
									price = 1499,
									tag = "BEST VALUE",
									title = "Ultra Crystal Pack",
									tone = "Cyan",
								},
							},
							kind = "Products",
							layout = "Cards",
							title = "Crystal Bundles",
						},
					},
				},
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Coins",
									icon = "crown",
									id = "pass_vip",
									price = 699,
									tag = "BEST VALUE",
									title = "VIP",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Place one more tower every wave",
									icon = "plus",
									id = "pass_extra_tower_slot",
									price = 299,
									title = "Extra Tower Slot",
									tone = "Cyan",
								},
								{
									currency = "robux",
									description = "Move twice as fast, everywhere",
									icon = "double-chevron-large-right",
									id = "pass_2x_speed",
									price = 199,
									title = "2x Speed",
									tone = "Blue",
								},
								{
									currency = "robux",
									description = "Skip the wait and jump straight in",
									icon = "clock",
									id = "pass_auto_skip",
									price = 149,
									title = "Auto Skip",
									tone = "Cyan",
								},
							},
							kind = "Products",
							layout = "Cards",
							title = "Gamepasses",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "The map of Mecha Defenders: every area as a card, locked ones showing what it takes to get there.",
			icon = "map",
			id = "worlds",
			kind = "Window",
			nav = "None",
			openKey = "M",
			sections = {
				{
					id = "worlds_2",
					kind = "Worlds",
					worlds = {
						{
							icon = "wheat",
							id = "world_grass_isle",
							locked = false,
							name = "Grass Isle",
							tone = "Pink",
						},
						{ icon = "flag", id = "world_crossroads", locked = false, name = "Crossroads", tone = "Blue" },
						{
							icon = "cloud",
							id = "world_frozen_pass",
							locked = true,
							name = "Frozen Pass",
							requirement = "Level 20",
							tone = "Cyan",
						},
						{
							icon = "flame",
							id = "world_volcano_rim",
							locked = true,
							name = "Volcano Rim",
							requirement = "Level 30",
							tone = "Pink",
						},
						{
							icon = "moon",
							id = "world_moon_base",
							locked = true,
							name = "Moon Base",
							requirement = "Level 40",
							tone = "Blue",
						},
						{
							icon = "nebula",
							id = "world_void_gate",
							locked = true,
							name = "Void Gate",
							requirement = "Level 50",
							tone = "Cyan",
						},
					},
				},
			},
			size = "Large",
			title = "Maps",
		},
		{
			description = "Daily and weekly quests with progress bars; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "SideTabs",
			openKey = "L",
			size = "Medium",
			tabs = {
				{
					badge = 1,
					icon = "daily",
					id = "tab_daily_2",
					label = "Daily",
					sections = {
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Place 125 towers",
									goal = 125,
									icon = "cube",
									id = "quest_architect",
									progress = 125,
									reward = { amount = "50", icon = "diamond" },
									title = "Architect",
								},
								{
									description = "Earn 4,500 cash from farms",
									goal = 4500,
									icon = "wheat",
									id = "quest_economist",
									progress = 2917,
									reward = { amount = "850", icon = "coin" },
									title = "Economist",
								},
								{
									description = "Defeat 5 bosses",
									goal = 5,
									icon = "crown",
									id = "quest_boss_hunter",
									progress = 1,
									reward = { amount = "275", icon = "coin" },
									title = "Boss Hunter",
								},
							},
						},
					},
				},
				{
					icon = "calendar",
					id = "tab_weekly",
					label = "Weekly",
					sections = {
						{
							id = "quests_3",
							kind = "Quests",
							quests = {
								{
									description = "Survive wave 20 on Hard",
									goal = 20,
									icon = "shield",
									id = "quest_wave_breaker",
									progress = 4,
									reward = { amount = "25", icon = "diamond" },
									title = "Wave Breaker",
								},
							},
						},
					},
				},
			},
			title = "Challenges",
		},
		{
			description = "Global top ten for Highest Wave, Towers Placed and Wins, with podium colours for the top three.",
			icon = "trophy",
			id = "leaderboard",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "trophy",
					id = "tab_highest_wave",
					label = "Highest Wave",
					sections = {
						{
							id = "leaderboard_2",
							kind = "Leaderboard",
							rows = {
								{ name = "RogueQueen_101", rank = 1, value = "179" },
								{ name = "Storm360", rank = 2, value = "164" },
								{ name = "NotLuckyFox", rank = 3, value = "148" },
								{ name = "NotRogueYeti", rank = 4, value = "107" },
								{ name = "NotGhostBot", rank = 5, value = "90" },
								{ name = "NotFrostKing", rank = 6, value = "78" },
								{ name = "RealBubbleOtter", rank = 7, value = "69" },
								{ name = "BoltWizard_12", rank = 8, value = "62" },
								{ name = "PixelBunnyYT", rank = 9, value = "57" },
								{ name = "RealJellyByte", rank = 10, value = "47" },
							},
							stat = "Highest Wave",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_towers_placed",
					label = "Towers Placed",
					sections = {
						{
							id = "leaderboard_3",
							kind = "Leaderboard",
							rows = {
								{ name = "ItsRocketMoose", rank = 1, value = "340M" },
								{ name = "Onyx404", rank = 2, value = "319M" },
								{ name = "Bean_Knight", rank = 3, value = "287M" },
								{ name = "xXMapleSparkXx", rank = 4, value = "264M" },
								{ name = "xXJellyPupXx", rank = 5, value = "233M" },
								{ name = "Glitch2011", rank = 6, value = "182M" },
								{ name = "MochiCat", rank = 7, value = "169M" },
								{ name = "Cookie88", rank = 8, value = "150M" },
								{ name = "ItsShadowNinja", rank = 9, value = "128M" },
								{ name = "CosmicSproutYT", rank = 10, value = "110M" },
							},
							stat = "Towers Placed",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_wins",
					label = "Wins",
					sections = {
						{
							id = "leaderboard_4",
							kind = "Leaderboard",
							rows = {
								{ name = "Mango101", rank = 1, value = "2,197" },
								{ name = "Turbo2014", rank = 2, value = "1,941" },
								{ name = "ItsDoodleGamer", rank = 3, value = "1,791" },
								{ name = "NotLunaNinja", rank = 4, value = "1,397" },
								{ name = "xXSunnyAceXx", rank = 5, value = "1,234" },
								{ name = "CrimsonChefYT", rank = 6, value = "976" },
								{ name = "ShadowSprout88", rank = 7, value = "924" },
								{ name = "WobblyQueen", rank = 8, value = "700" },
								{ name = "Pixel21", rank = 9, value = "633" },
								{ name = "BlitzMoose_21", rank = 10, value = "525" },
							},
							stat = "Wins",
						},
					},
				},
			},
			title = "Rankings",
		},
		{
			buttons = {
				{
					action = { kind = "Event", name = "PlayAgain" },
					icon = "play",
					id = "play_again",
					label = "Play Again",
					tone = "Pink",
				},
				{
					action = { kind = "Open", target = "menu" },
					icon = "home",
					id = "main_menu",
					label = "Main Menu",
					tone = "Dark",
				},
			},
			description = "End-of-match screen: the result in huge type, the match stats, rewards counting up, and Play Again as the obvious next click.",
			icon = "trophy",
			id = "results",
			kind = "Results",
			result = "Defeat",
			rewards = {
				{ amount = "+475", icon = "coin" },
				{ amount = "+400 XP", icon = "level" },
				{ amount = "+25", icon = "diamond" },
			},
			stats = {
				{ label = "Waves", value = "30/30" },
				{ label = "Damage", value = "3,887" },
				{ label = "Towers Placed", value = "27" },
				{ label = "Time", value = "5:41" },
			},
			title = "Results",
		},
		{
			delay = 5,
			description = "A one-time Starter Pack: 4 rewards for 99 Robux (was 299), counting down 24h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "flame",
			id = "offer",
			items = {
				{
					amount = "250",
					grant = { amount = 250, currency = "crystals" },
					icon = "diamond",
					label = "Crystals",
				},
				{
					amount = "12,500",
					grant = { amount = 12500, currency = "coins" },
					icon = "coin",
					label = "Coins",
				},
				{ amount = "1", icon = "flame", label = "Epic Pyromancer" },
				{ amount = "3", icon = "coin", label = "2x Coins (30m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "STARTER PACK",
			timer = 86400,
			title = "Starter Pack",
			tone = "Cyan",
			was = 299,
		},
		{
			description = "Grouped into General, Graphics, Audio, Controls and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "skip-next-large",
									id = "skipHatch",
									label = "Skip Summon Animation",
									type = "Toggle",
								},
								{
									default = true,
									icon = "hashtag",
									id = "damageNumbers",
									label = "Damage Numbers",
									type = "Toggle",
								},
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "controller",
					id = "tab_controls",
					label = "Controls",
					sections = {
						{
							binds = {
								{ action = "Place Tower", gamepad = "ButtonA", key = "MouseButton1" },
								{ action = "Upgrade", gamepad = "ButtonY", key = "E" },
								{ action = "Sell", gamepad = "ButtonB", key = "X" },
								{ action = "Rotate", gamepad = "ButtonX", key = "R" },
								{ action = "Tower 1", gamepad = "DPadLeft", key = "One" },
								{ action = "Tower 2", gamepad = "DPadUp", key = "Two" },
								{ action = "Skip Wave", gamepad = "ButtonSelect", key = "K" },
							},
							id = "keybinds",
							kind = "Keybinds",
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Arena",
		buttonStyle = "Glossy3D",
		clickFx = "Glow",
		corner = "Round",
		decoration = "Sparkles",
		density = "Compact",
		fonts = { body = "BuilderSans:Bold", display = "Bangers", title = "Bangers" },
		iconStyle = "Filled",
		palette = {
			accent = "#00F0FF",
			bad = "#FF4F6E",
			good = "#3CFFB0",
			primary = "#FF3CAC",
			secondary = "#2B86FF",
			surface = "#140B24",
			text = "#FBEFFF",
			warn = "#FFD23F",
		},
		panelStyle = "Glass",
		rarityScheme = "Arena",
		shadow = "Soft",
		textCase = "Normal",
		transition = "Zoom",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="76">
              <Properties>
                <string name="Name">tycoon_21</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: tycoon, seed 21. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#FFD21F",
			format = "abbrev",
			icon = "coin",
			id = "coins",
			name = "Coins",
			source = { kind = "leaderstat", name = "Coins" },
			start = 85000,
		},
		{
			color = "#3AD1FF",
			format = "comma",
			icon = "gem",
			id = "gems",
			name = "Gems",
			source = { kind = "attribute", name = "Gems" },
			start = 250,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				id = "hud_objective",
				text = "Goal: reach 1M Coins",
				type = "Objective",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopRight",
				currencies = { "coins", "gems" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "Right",
				buttons = {
					{
						action = { kind = "Toggle", target = "build" },
						icon = "cube",
						id = "btn_build",
						label = "Catalog",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "rewards" },
						icon = "gift",
						id = "btn_rewards",
						label = "Free Rewards",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Shop",
						tone = "Green",
					},
					{
						action = { kind = "Toggle", target = "upgrades" },
						icon = "upgrade",
						id = "btn_upgrades",
						label = "Upgrades",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "leaderboard" },
						icon = "trophy",
						id = "btn_leaderboard",
						label = "Rankings",
						tone = "Dark",
					},
					{
						action = { kind = "Open", target = "rebirth" },
						icon = "rebirth",
						id = "btn_rebirth",
						label = "Prestige",
						tone = "Dark",
					},
					{
						action = { kind = "Open", target = "codes" },
						icon = "tag",
						id = "btn_codes",
						label = "Codes",
						tone = "Dark",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "Dark",
					},
				},
				id = "hud_menubuttons",
				style = "Column",
				type = "MenuButtons",
				visible = true,
			},
			{
				anchor = "Right",
				boosts = {
					{ icon = "lightning-bolt", seconds = 210, text = "Rush Hour" },
					{ icon = "cash", seconds = 842, text = "2x Income" },
				},
				id = "hud_boosts",
				look = "Badges",
				type = "Boosts",
				visible = true,
			},
		},
		kit = "Glass",
		layout = "Minimal",
	},
	logo = { icon = "building-store", text = "Mega Tycoon", tilt = 0, tone = "Green", type = "Badge" },
	meta = {
		genre = "tycoon",
		kit = "Glass",
		prompt = "a tycoon game",
		seed = 21,
		source = "offline",
		title = "Mega Tycoon",
	},
	screens = {
		{
			bannerPosition = "Side",
			description = "The title screen: the Mega Tycoon logo, PLAY, Shop, Rankings and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Split",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "PLAY",
							tone = "Green",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "leaderboard" },
							icon = "trophy",
							id = "menu_leaderboard",
							label = "Rankings",
							tone = "Dark",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "Dark",
						},
					},
					id = "buttons_2",
					kind = "Buttons",
					layout = "Column",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "flame",
					id = "welcome_bundle",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 172800,
					title = "Welcome Bundle",
					tone = "Pink",
				},
			},
			title = "Main Menu",
		},
		{
			description = "A catalogue of every machine you can place on your plot, priced in Coins and ordered cheapest to priciest.",
			icon = "cube",
			id = "build",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "products",
					items = {
						{
							amount = "Common",
							currency = "coins",
							icon = "cube",
							id = "buy_basic_dropper",
							price = 4750,
							title = "Basic Dropper",
							tone = "Gray",
						},
						{
							amount = "Common",
							currency = "coins",
							icon = "double-chevron-large-right",
							id = "buy_conveyor_belt",
							price = 5250,
							title = "Conveyor Belt",
							tone = "Gray",
						},
						{
							amount = "Uncommon",
							currency = "coins",
							icon = "arrow-large-up",
							id = "buy_upgrader",
							price = 15000,
							title = "Upgrader",
							tone = "Green",
						},
						{
							amount = "Epic",
							currency = "coins",
							icon = "flame",
							id = "buy_golden_furnace",
							price = 100000,
							title = "Golden Furnace",
							tone = "Purple",
						},
						{
							amount = "Epic",
							currency = "coins",
							icon = "building-bank",
							id = "buy_vault",
							price = 100000,
							title = "Vault",
							tone = "Purple",
						},
						{
							amount = "Legendary",
							currency = "coins",
							icon = "diamond",
							id = "buy_diamond_press",
							price = 270000,
							title = "Diamond Press",
							tone = "Yellow",
						},
						{
							amount = "Legendary",
							currency = "coins",
							icon = "location-pin",
							id = "buy_helipad",
							price = 270000,
							title = "Helipad",
							tone = "Yellow",
						},
						{
							amount = "Mythic",
							currency = "coins",
							icon = "nebula",
							id = "buy_quantum_reactor",
							price = 610000,
							tag = "HOT",
							title = "Quantum Reactor",
							tone = "Red",
						},
					},
					kind = "Products",
					layout = "Compact",
				},
			},
			size = "Large",
			title = "Catalog",
		},
		{
			description = "Everything free in one place: Daily, Gifts and Social. Every tab badges what's ready, and so does the HUD button.",
			icon = "gift",
			id = "rewards",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							days = {
								{ amount = "7,500", day = 1, icon = "coin" },
								{ amount = "12,500", day = 2, icon = "coin" },
								{ amount = "x2", day = 3, icon = "boost" },
								{ amount = "30", day = 4, icon = "gem" },
								{ amount = "22,500", day = 5, icon = "coin" },
								{ amount = "27,500", day = 6, icon = "coin" },
								{ amount = "75", day = 7, icon = "gem" },
								{ amount = "12,500", day = 8, icon = "coin" },
								{ amount = "17,500", day = 9, icon = "coin" },
								{ amount = "x2", day = 10, icon = "boost" },
								{ amount = "50", day = 11, icon = "gem" },
								{ amount = "35,000", day = 12, icon = "coin" },
								{ amount = "42,500", day = 13, icon = "coin" },
								{ amount = "125", day = 14, icon = "gem" },
								{ amount = "17,500", day = 15, icon = "coin" },
								{ amount = "25,000", day = 16, icon = "coin" },
								{ amount = "x3", day = 17, icon = "boost" },
								{ amount = "65", day = 18, icon = "gem" },
								{ amount = "50,000", day = 19, icon = "coin" },
								{ amount = "57,500", day = 20, icon = "coin" },
								{ amount = "175", day = 21, icon = "gem" },
								{ amount = "20,000", day = 22, icon = "coin" },
								{ amount = "32,500", day = 23, icon = "coin" },
								{ amount = "x3", day = 24, icon = "boost" },
								{ amount = "85", day = 25, icon = "gem" },
								{ amount = "62,500", day = 26, icon = "coin" },
								{ amount = "72,500", day = 27, icon = "coin" },
								{ amount = "425", day = 28, icon = "gift", label = "Mega Reward" },
							},
							id = "daily",
							kind = "Daily",
							layout = "Calendar",
							streak = 4,
						},
					},
				},
				{
					icon = "gift",
					id = "tab_gifts",
					label = "Gifts",
					sections = {
						{
							gifts = {
								{
									amount = "2,750",
									grant = { amount = 2750, currency = "coins" },
									icon = "coin",
									id = "gift1",
									minutes = 1,
									rarity = "Common",
								},
								{
									amount = "4,000",
									grant = { amount = 4000, currency = "coins" },
									icon = "coin",
									id = "gift2",
									minutes = 3,
									rarity = "Common",
								},
								{
									amount = "5,500",
									grant = { amount = 5500, currency = "coins" },
									icon = "coin",
									id = "gift3",
									minutes = 5,
									rarity = "Common",
								},
								{
									amount = "15m",
									icon = "cash",
									id = "gift4",
									label = "2x Income",
									minutes = 10,
									rarity = "Rare",
								},
								{
									amount = "12,500",
									grant = { amount = 12500, currency = "coins" },
									icon = "coin",
									id = "gift5",
									minutes = 15,
									rarity = "Uncommon",
								},
								{
									amount = "15,000",
									grant = { amount = 15000, currency = "coins" },
									icon = "coin",
									id = "gift6",
									minutes = 20,
									rarity = "Rare",
								},
								{
									amount = "10",
									grant = { amount = 10, currency = "gems" },
									icon = "gem",
									id = "gift7",
									minutes = 30,
									rarity = "Rare",
								},
								{
									amount = "50",
									grant = { amount = 50, currency = "gems" },
									icon = "gem",
									id = "gift8",
									minutes = 45,
									rarity = "Legendary",
								},
							},
							id = "gifts",
							kind = "Gifts",
						},
					},
				},
				{
					icon = "three-people",
					id = "tab_social",
					label = "Social",
					sections = {
						{
							id = "social",
							kind = "Social",
							rewards = {
								{
									icon = "three-people",
									id = "group",
									kind = "Group",
									label = "Join the Group",
									reward = { amount = "20,000", grant = { amount = 20000, currency = "coins" }, icon = "coin" },
								},
								{
									icon = "thumb-up",
									id = "like",
									kind = "Like",
									label = "Like the Game",
									reward = { amount = "10", grant = { amount = 10, currency = "gems" }, icon = "gem" },
									wait = 60,
								},
								{
									icon = "person-plus",
									id = "invite",
									kind = "Invite",
									label = "Invite a Friend",
									limit = 10,
									reward = { amount = "25", grant = { amount = 25, currency = "gems" }, icon = "gem" },
								},
							},
						},
					},
				},
			},
			title = "Free Rewards",
		},
		{
			description = "Where Coins and Robux turn into progress. Tabs: Passes, Gems and Boosts.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "SideTabs",
			size = "Large",
			tabs = {
				{
					icon = "premium",
					id = "tab_passes",
					label = "Passes",
					sections = {
						{
							id = "gamepasses",
							items = {
								{
									currency = "robux",
									description = "Gold name, VIP chat and +10% Coins",
									icon = "crown",
									id = "pass_vip_plot",
									price = 499,
									tag = "MUST HAVE",
									title = "VIP Plot",
									tone = "Green",
								},
								{
									currency = "robux",
									description = "Earn twice the Coins, forever",
									icon = "cash",
									id = "pass_2x_coins",
									price = 399,
									title = "2x Coins",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Grinds for you while you're away",
									icon = "wallet",
									id = "pass_auto_collect",
									price = 249,
									title = "Auto Collect",
									tone = "Purple",
								},
								{
									currency = "robux",
									description = "Your factory runs twice as fast",
									icon = "lightning-bolt",
									id = "pass_faster_droppers",
									price = 199,
									title = "Faster Droppers",
									tone = "Pink",
								},
								{
									currency = "robux",
									description = "Gold tools that work twice as hard",
									icon = "star",
									id = "pass_golden_tools",
									price = 75,
									title = "Golden Tools",
									tone = "Purple",
								},
							},
							kind = "Products",
							layout = "Carousel",
							title = "Gamepasses",
						},
					},
				},
				{
					icon = "gem",
					id = "tab_gems",
					label = "Gems",
					sections = {
						{
							id = "gem_bundles",
							items = {
								{
									amount = "100",
									currency = "robux",
									grant = { amount = 100, currency = "gems" },
									icon = "gem",
									id = "gems_pack_1",
									price = 49,
									title = "Handful of Gems",
									tone = "Purple",
								},
								{
									amount = "225",
									bonus = 11,
									currency = "robux",
									grant = { amount = 225, currency = "gems" },
									icon = "gem",
									id = "gems_pack_2",
									price = 99,
									tag = "SALE",
									title = "Pouch of Gems",
									tone = "Purple",
									was = 149,
								},
								{
									amount = "500",
									bonus = 23,
									currency = "robux",
									grant = { amount = 500, currency = "gems" },
									icon = "gem",
									id = "gems_pack_3",
									price = 199,
									title = "Sack of Gems",
									tone = "Green",
								},
								{
									amount = "1,250",
									bonus = 54,
									currency = "robux",
									grant = { amount = 1250, currency = "gems" },
									icon = "file-box",
									id = "gems_pack_4",
									price = 399,
									tag = "BEST VALUE",
									title = "Chest of Gems",
									tone = "Green",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Gem Bundles",
						},
					},
				},
				{
					icon = "boost",
					id = "tab_boosts",
					label = "Boosts",
					sections = {
						{
							id = "boosts",
							items = {
								{
									amount = "15 min",
									currency = "gems",
									icon = "cash",
									id = "boost_2x_income",
									price = 60,
									title = "2x Income",
									tone = "Pink",
								},
								{
									amount = "10 min",
									currency = "gems",
									icon = "lightning-bolt",
									id = "boost_rush_hour",
									price = 50,
									title = "Rush Hour",
									tone = "Purple",
								},
							},
							kind = "Products",
							layout = "Compact",
							title = "Boosts",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "Permanent upgrades bought with Coins; each card shows the next level's effect before you pay.",
			icon = "upgrade",
			id = "upgrades",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "upgrades_2",
					kind = "Upgrades",
					upgrades = {
						{
							cost = 12500,
							currency = "coins",
							effect = "+3% value",
							icon = "diamond",
							id = "upgrade_ore_value",
							level = 2,
							maxLevel = 10,
							title = "Ore Value",
						},
						{
							cost = 25000,
							currency = "coins",
							effect = "+8% speed",
							icon = "double-chevron-large-right",
							id = "upgrade_conveyor_speed",
							level = 3,
							maxLevel = 10,
							title = "Conveyor Speed",
						},
						{
							cost = 55000,
							currency = "coins",
							effect = "+4%",
							icon = "run",
							id = "upgrade_walk_speed",
							level = 3,
							maxLevel = 10,
							title = "Walk Speed",
						},
						{
							cost = 75000,
							currency = "coins",
							effect = "+8%",
							icon = "moon",
							id = "upgrade_offline_earnings",
							level = 3,
							maxLevel = 50,
							title = "Offline Earnings",
						},
					},
				},
			},
			size = "Medium",
			title = "Boosts & Upgrades",
		},
		{
			description = "Global top ten for Coins Earned, Machines Built and Rebirths, with podium colours for the top three.",
			icon = "trophy",
			id = "leaderboard",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "trophy",
					id = "tab_coins_earned",
					label = "Coins Earned",
					sections = {
						{
							id = "leaderboard_2",
							kind = "Leaderboard",
							rows = {
								{ name = "xXStormOtterXx", rank = 1, value = "91M" },
								{ name = "ToastyAce_99", rank = 2, value = "81.8M" },
								{ name = "Cookie2014", rank = 3, value = "62.3M" },
								{ name = "RealEchoByte", rank = 4, value = "56.8M" },
								{ name = "TacoCat33", rank = 5, value = "43.4M" },
								{ name = "xXPixelBunnyXx", rank = 6, value = "39.4M" },
								{ name = "Zap_Bunny", rank = 7, value = "35.3M" },
								{ name = "Toasty33", rank = 8, value = "28.8M" },
								{ name = "NotBubbleAce", rank = 9, value = "21.8M" },
								{ name = "NotViperPilot", rank = 10, value = "19.9M" },
							},
							stat = "Coins Earned",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_machines_built",
					label = "Machines Built",
					sections = {
						{
							id = "leaderboard_3",
							kind = "Leaderboard",
							rows = {
								{ name = "NotNeonFox", rank = 1, value = "1.2B" },
								{ name = "SkyKing7", rank = 2, value = "1B" },
								{ name = "LuckyKnight_42", rank = 3, value = "869M" },
								{ name = "xXChaosRiderXx", rank = 4, value = "629M" },
								{ name = "NotNovaKing", rank = 5, value = "472M" },
								{ name = "ToastyCatYT", rank = 6, value = "404M" },
								{ name = "xXChaosKnightXx", rank = 7, value = "350M" },
								{ name = "NeonCat7", rank = 8, value = "322M" },
								{ name = "ViperSlayer_88", rank = 9, value = "264M" },
								{ name = "xXKiwiBladeXx", rank = 10, value = "215M" },
							},
							stat = "Machines Built",
						},
					},
				},
				{
					icon = "trophy",
					id = "tab_rebirths",
					label = "Rebirths",
					sections = {
						{
							id = "leaderboard_4",
							kind = "Leaderboard",
							rows = {
								{ name = "MochiLlamaYT", rank = 1, value = "2,172" },
								{ name = "NimbusToast101", rank = 2, value = "1,972" },
								{ name = "Toasty42", rank = 3, value = "1,847" },
								{ name = "Lucky2014", rank = 4, value = "1,517" },
								{ name = "PepperCatYT", rank = 5, value = "1,248" },
								{ name = "Cookie_Spark", rank = 6, value = "909" },
								{ name = "Echo360", rank = 7, value = "723" },
								{ name = "xXTacoSproutXx", rank = 8, value = "578" },
								{ name = "NotMapleGamer", rank = 9, value = "511" },
								{ name = "EchoPupYT", rank = 10, value = "392" },
							},
							stat = "Rebirths",
						},
					},
				},
			},
			title = "Rankings",
		},
		{
			description = "Trade your Coins for a permanent multiplier. The popup spells out exactly what is lost and gained so the decision feels safe.",
			icon = "rebirth",
			id = "rebirth",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					id = "stats",
					kind = "Stats",
					stats = {
						{ icon = "rebirth", label = "Prestiges", value = "5" },
						{ icon = "boost", label = "Multiplier", value = "x10 → x12" },
						{ icon = "coin", label = "Cost", value = "40M" },
						{ bar = 0.76881884508766, icon = "level", label = "Progress", value = "94%" },
					},
				},
				{
					body = "Prestige resets your Coins and upgrades, but keeps your machines and Gems.",
					id = "text",
					kind = "Text",
				},
				{
					buttons = {
						{
							action = { kind = "Event", name = "Rebirth" },
							icon = "rebirth",
							id = "prestige",
							label = "Prestige",
							tone = "Green",
						},
						{
							action = { kind = "Close" },
							icon = "close",
							id = "not_yet",
							label = "Not Yet",
							tone = "Dark",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Row",
				},
			},
			size = "Medium",
			title = "Prestige",
			transition = "Zoom",
		},
		{
			description = "Redeem promo codes. The server validates them through the RedeemCode remote.",
			icon = "tag",
			id = "codes",
			kind = "Popup",
			nav = "None",
			sections = {
				{ body = "Like the game and join the group for secret codes.", id = "text_2", kind = "Text" },
				{
					action = { kind = "Remote", name = "RedeemCode" },
					button = "Redeem",
					id = "codes_2",
					kind = "Codes",
					placeholder = "e.g. RELEASE",
				},
			},
			size = "Small",
			title = "Codes",
			transition = "Zoom",
		},
		{
			delay = 5,
			description = "A one-time Welcome Bundle: 4 rewards for 99 Robux (was 299), counting down 48h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "flame",
			id = "offer",
			items = {
				{ amount = "300", grant = { amount = 300, currency = "gems" }, icon = "gem", label = "Gems" },
				{
					amount = "300K",
					grant = { amount = 300000, currency = "coins" },
					icon = "coin",
					label = "Coins",
				},
				{ amount = "1", icon = "flame", label = "Epic Golden Furnace" },
				{ amount = "3", icon = "cash", label = "2x Income (15m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "LIMITED",
			timer = 172800,
			title = "Welcome Bundle",
			tone = "Pink",
			was = 299,
		},
		{
			capHours = 8,
			currency = "coins",
			description = "Welcomes players back with what they earned while away: 1,000 Coins a minute, up to 8h, worked out by the server from their last visit.",
			doublePrice = 25,
			icon = "moon-with-stars",
			id = "offline",
			kind = "OfflineEarnings",
			minMinutes = 5,
			rate = 1000,
			title = "Welcome Back!",
		},
		{
			description = "Grouped into General, Graphics, Audio and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Sleek",
		buttonStyle = "Soft",
		clickFx = "Squash",
		corner = "Round",
		decoration = "None",
		density = "Compact",
		fonts = { body = "BuilderSans:Bold", display = "FredokaOne", title = "FredokaOne" },
		iconStyle = "Filled",
		palette = {
			accent = "#FF2E88",
			bad = "#FF3B5C",
			good = "#00FFA3",
			primary = "#00FFA3",
			secondary = "#7A5CFF",
			surface = "#0B0F1A",
			text = "#E8FFF6",
			warn = "#FFD23F",
		},
		panelStyle = "Solid",
		rarityScheme = "Arena",
		shadow = "Soft",
		textCase = "Normal",
		transition = "Fade",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="77">
              <Properties>
                <string name="Name">tycoon_7</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Sample blueprint: tycoon, seed 7. Pre-generated output of the offline generator (data only).
-- Regenerate with: python tools/gen_samples.py

return {
	currencies = {
		{
			color = "#4FD37A",
			format = "abbrev",
			icon = "cash",
			id = "money",
			name = "Money",
			source = { kind = "leaderstat", name = "Money" },
			start = 97500,
		},
		{
			color = "#FFB84D",
			format = "comma",
			icon = "star",
			id = "tokens",
			name = "Tokens",
			source = { kind = "attribute", name = "Tokens" },
			start = 70,
		},
	},
	hud = {
		elements = {
			{
				anchor = "TopCenter",
				id = "hud_objective",
				text = "Next: unlock the second floor",
				type = "Objective",
				visible = true,
			},
			{
				addAction = { kind = "Open", target = "shop" },
				anchor = "TopLeft",
				currencies = { "money", "tokens" },
				id = "hud_currencybar",
				showAdd = true,
				type = "CurrencyBar",
				visible = true,
			},
			{
				anchor = "Left",
				buttons = {
					{
						action = { kind = "Toggle", target = "shop" },
						icon = "shop",
						id = "btn_shop",
						label = "Shop",
						tone = "Red",
					},
					{
						action = { kind = "Toggle", target = "build" },
						icon = "cube",
						id = "btn_build",
						label = "Blueprints",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "rewards" },
						icon = "gift",
						id = "btn_rewards",
						label = "Free Rewards",
						tone = "Cyan",
					},
					{
						action = { kind = "Toggle", target = "upgrades" },
						icon = "upgrade",
						id = "btn_upgrades",
						label = "Upgrades",
						tone = "Yellow",
					},
					{
						action = { kind = "Toggle", target = "quests" },
						badge = 3,
						icon = "quest",
						id = "btn_quests",
						label = "Challenges",
						tone = "Cyan",
					},
					{
						action = { kind = "Open", target = "codes" },
						icon = "tag",
						id = "btn_codes",
						label = "Codes",
						tone = "Yellow",
					},
					{
						action = { kind = "Open", target = "vip" },
						icon = "crown",
						id = "btn_vip",
						label = "VIP",
						tone = "Cyan",
					},
					{
						action = { kind = "Toggle", target = "settings" },
						icon = "settings",
						id = "btn_settings",
						label = "Settings",
						tone = "White",
					},
				},
				id = "hud_menubuttons",
				style = "Grid",
				type = "MenuButtons",
				visible = true,
			},
			{
				anchor = "Right",
				boosts = {
					{ icon = "cash", seconds = 685, text = "2x Income" },
					{ icon = "lightning-bolt", seconds = 278, text = "Rush Hour" },
				},
				id = "hud_boosts",
				type = "Boosts",
				visible = true,
			},
			{
				anchor = "Right",
				id = "hud_questtracker_2",
				tasks = {
					{ goal = 1, icon = "flame", progress = 0, text = "Burger + Fries" },
					{ goal = 1, icon = "flame", progress = 0, text = "Iced Latte" },
					{ goal = 1, icon = "flame", progress = 0, text = "Pancake Stack" },
				},
				title = "Orders",
				type = "QuestTracker",
				visible = true,
			},
			{
				anchor = "BottomLeft",
				icon = "flame",
				id = "hud_counter",
				label = "SERVED",
				style = "Stamp",
				type = "Counter",
				value = 362,
				visible = true,
			},
		},
		kit = "Chunky Toy",
		layout = "Classic",
	},
	logo = { icon = "building-store", text = "Burger Tycoon", tilt = -5, tone = "Red", type = "Stacked" },
	meta = {
		flavor = "restaurant",
		genre = "tycoon",
		kit = "Chunky Toy",
		prompt = "a tycoon game",
		seed = 7,
		source = "offline",
		title = "Burger Tycoon",
	},
	screens = {
		{
			bannerPosition = "Bottom",
			description = "The title screen: the Burger Tycoon logo, START BUILDING, Shop, Challenges, Upgrades and Settings, and a news banner. It is also where players land after a match.",
			icon = "home",
			id = "menu",
			kind = "MainMenu",
			layout = "Center",
			sections = {
				{
					buttons = {
						{
							action = { kind = "Close", target = "menu" },
							icon = "play",
							id = "play",
							label = "START BUILDING",
							tone = "Red",
						},
						{
							action = { kind = "Open", target = "shop" },
							icon = "shop",
							id = "menu_shop",
							label = "Shop",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "quests" },
							icon = "quest",
							id = "menu_quests",
							label = "Challenges",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "upgrades" },
							icon = "upgrade",
							id = "menu_upgrades",
							label = "Upgrades",
							tone = "White",
						},
						{
							action = { kind = "Open", target = "settings" },
							icon = "settings",
							id = "menu_settings",
							label = "Settings",
							tone = "White",
						},
					},
					id = "buttons_2",
					kind = "Buttons",
					layout = "Row",
				},
				{
					button = { action = { kind = "Open", target = "offer" }, label = "View Offer" },
					icon = "flame",
					id = "starter_pack",
					kind = "Banner",
					subtitle = "4 rewards for 99 Robux instead of 299",
					timer = 86400,
					title = "Starter Pack",
					tone = "Yellow",
				},
			},
			title = "Main Menu",
		},
		{
			description = "Where Money and Robux turn into progress. Tabs: Tokens and Boosts.",
			icon = "shop",
			id = "shop",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "star",
					id = "tab_tokens",
					label = "Tokens",
					sections = {
						{
							id = "token_bundles",
							items = {
								{
									amount = "125",
									currency = "robux",
									grant = { amount = 125, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_1",
									price = 49,
									title = "Starter Tokens",
									tone = "Cyan",
								},
								{
									amount = "300",
									bonus = 19,
									currency = "robux",
									grant = { amount = 300, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_2",
									price = 99,
									title = "Small Token Pack",
									tone = "Cyan",
								},
								{
									amount = "625",
									bonus = 23,
									currency = "robux",
									grant = { amount = 625, currency = "tokens" },
									icon = "star",
									id = "tokens_pack_3",
									price = 199,
									tag = "POPULAR",
									title = "Big Token Pack",
									tone = "Red",
								},
								{
									amount = "1,500",
									bonus = 47,
									currency = "robux",
									grant = { amount = 1500, currency = "tokens" },
									icon = "file-box",
									id = "tokens_pack_4",
									price = 399,
									title = "Mega Token Pack",
									tone = "Red",
								},
								{
									amount = "3,250",
									bonus = 59,
									currency = "robux",
									grant = { amount = 3250, currency = "tokens" },
									icon = "building-bank",
									id = "tokens_pack_5",
									price = 799,
									title = "Giga Token Pack",
									tone = "Yellow",
								},
								{
									amount = "6,750",
									bonus = 77,
									currency = "robux",
									grant = { amount = 6750, currency = "tokens" },
									icon = "crown",
									id = "tokens_pack_6",
									price = 1499,
									tag = "BEST VALUE",
									title = "Ultra Token Pack",
									tone = "Yellow",
								},
							},
							kind = "Products",
							layout = "Bundles",
							title = "Token Bundles",
						},
					},
				},
				{
					icon = "boost",
					id = "tab_boosts",
					label = "Boosts",
					sections = {
						{
							id = "boosts",
							items = {
								{
									amount = "15 min",
									currency = "tokens",
									icon = "cash",
									id = "boost_2x_income",
									price = 125,
									title = "2x Income",
									tone = "Yellow",
								},
								{
									amount = "10 min",
									currency = "tokens",
									icon = "lightning-bolt",
									id = "boost_rush_hour",
									price = 60,
									title = "Rush Hour",
									tone = "Cyan",
								},
							},
							kind = "Products",
							layout = "Compact",
							title = "Boosts",
						},
					},
				},
			},
			title = "Shop",
		},
		{
			description = "A catalogue of every machine you can place on your plot, priced in Money and ordered cheapest to priciest.",
			icon = "cube",
			id = "build",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "products",
					items = {
						{
							amount = "Common",
							currency = "money",
							icon = "cube",
							id = "buy_basic_dropper",
							price = 5000,
							title = "Basic Dropper",
							tone = "Gray",
						},
						{
							amount = "Common",
							currency = "money",
							icon = "double-chevron-large-right",
							id = "buy_conveyor_belt",
							price = 5000,
							title = "Conveyor Belt",
							tone = "Gray",
						},
						{
							amount = "Rare",
							currency = "money",
							icon = "cube-vertexes",
							id = "buy_mega_dropper",
							price = 35000,
							title = "Mega Dropper",
							tone = "Blue",
						},
						{
							amount = "Epic",
							currency = "money",
							icon = "flame",
							id = "buy_golden_furnace",
							price = 90000,
							title = "Golden Furnace",
							tone = "Purple",
						},
						{
							amount = "Epic",
							currency = "money",
							icon = "building-bank",
							id = "buy_vault",
							price = 100000,
							title = "Vault",
							tone = "Purple",
						},
						{
							amount = "Legendary",
							currency = "money",
							icon = "location-pin",
							id = "buy_helipad",
							price = 240000,
							title = "Helipad",
							tone = "Yellow",
						},
						{
							amount = "Legendary",
							currency = "money",
							icon = "diamond",
							id = "buy_diamond_press",
							price = 250000,
							title = "Diamond Press",
							tone = "Yellow",
						},
						{
							amount = "Mythic",
							currency = "money",
							icon = "nebula",
							id = "buy_quantum_reactor",
							price = 620000,
							tag = "HOT",
							title = "Quantum Reactor",
							tone = "Red",
						},
					},
					kind = "Products",
					layout = "Compact",
				},
			},
			size = "Large",
			title = "Blueprints",
		},
		{
			description = "Everything free in one place: Daily, Gifts and Social. Every tab badges what's ready, and so does the HUD button.",
			icon = "gift",
			id = "rewards",
			kind = "Window",
			nav = "Tabs",
			size = "Large",
			tabs = {
				{
					icon = "daily",
					id = "tab_daily",
					label = "Daily",
					sections = {
						{
							days = {
								{ amount = "7,750", day = 1, icon = "cash" },
								{ amount = "12,500", day = 2, icon = "cash" },
								{ amount = "40", day = 3, icon = "star" },
								{ amount = "20,000", day = 4, icon = "cash" },
								{ amount = "x2", day = 5, icon = "boost", label = "15 min" },
								{ amount = "27,500", day = 6, icon = "cash" },
								{ amount = "200", day = 7, icon = "gift", label = "Mega Reward" },
							},
							id = "daily",
							kind = "Daily",
							streak = 2,
						},
					},
				},
				{
					icon = "gift",
					id = "tab_gifts",
					label = "Gifts",
					sections = {
						{
							gifts = {
								{
									amount = "3,500",
									grant = { amount = 3500, currency = "money" },
									icon = "cash",
									id = "gift1",
									minutes = 1,
									rarity = "Common",
								},
								{
									amount = "5,250",
									grant = { amount = 5250, currency = "money" },
									icon = "cash",
									id = "gift2",
									minutes = 3,
									rarity = "Common",
								},
								{
									amount = "7,000",
									grant = { amount = 7000, currency = "money" },
									icon = "cash",
									id = "gift3",
									minutes = 5,
									rarity = "Common",
								},
								{
									amount = "12,500",
									grant = { amount = 12500, currency = "money" },
									icon = "cash",
									id = "gift4",
									minutes = 10,
									rarity = "Uncommon",
								},
								{
									amount = "15,000",
									grant = { amount = 15000, currency = "money" },
									icon = "cash",
									id = "gift5",
									minutes = 15,
									rarity = "Uncommon",
								},
								{
									amount = "15m",
									icon = "cash",
									id = "gift6",
									label = "2x Income",
									minutes = 20,
									rarity = "Rare",
								},
								{
									amount = "10",
									grant = { amount = 10, currency = "tokens" },
									icon = "star",
									id = "gift7",
									minutes = 30,
									rarity = "Rare",
								},
								{
									amount = "42,500",
									grant = { amount = 42500, currency = "money" },
									icon = "cash",
									id = "gift8",
									minutes = 45,
									rarity = "Rare",
								},
								{
									amount = "20",
									grant = { amount = 20, currency = "tokens" },
									icon = "star",
									id = "gift9",
									minutes = 60,
									rarity = "Epic",
								},
								{
									amount = "67,500",
									grant = { amount = 67500, currency = "money" },
									icon = "cash",
									id = "gift10",
									minutes = 75,
									rarity = "Rare",
								},
								{
									amount = "35",
									grant = { amount = 35, currency = "tokens" },
									icon = "star",
									id = "gift11",
									minutes = 90,
									rarity = "Epic",
								},
								{
									amount = "x1",
									icon = "flame",
									id = "gift12",
									label = "Golden Furnace",
									minutes = 120,
									rarity = "Epic",
								},
							},
							id = "gifts",
							kind = "Gifts",
						},
					},
				},
				{
					icon = "three-people",
					id = "tab_social",
					label = "Social",
					sections = {
						{
							id = "social",
							kind = "Social",
							rewards = {
								{
									icon = "three-people",
									id = "group",
									kind = "Group",
									label = "Join the Group",
									reward = { amount = "20,000", grant = { amount = 20000, currency = "money" }, icon = "cash" },
								},
								{
									icon = "thumb-up",
									id = "like",
									kind = "Like",
									label = "Like the Game",
									reward = { amount = "10", grant = { amount = 10, currency = "tokens" }, icon = "star" },
									wait = 60,
								},
								{
									icon = "person-plus",
									id = "invite",
									kind = "Invite",
									label = "Invite a Friend",
									limit = 10,
									reward = { amount = "25", grant = { amount = 25, currency = "tokens" }, icon = "star" },
								},
							},
						},
					},
				},
			},
			title = "Free Rewards",
		},
		{
			description = "Permanent upgrades bought with Money; each card shows the next level's effect before you pay.",
			icon = "upgrade",
			id = "upgrades",
			kind = "Window",
			nav = "None",
			sections = {
				{
					id = "upgrades_2",
					kind = "Upgrades",
					upgrades = {
						{
							cost = 170000,
							currency = "money",
							effect = "+80% drop rate",
							icon = "double-chevron-large-up",
							id = "upgrade_dropper_speed",
							level = 7,
							maxLevel = 25,
							title = "Dropper Speed",
						},
						{
							cost = 7000,
							currency = "money",
							effect = "+2% value",
							icon = "diamond",
							id = "upgrade_ore_value",
							level = 0,
							maxLevel = 20,
							title = "Ore Value",
						},
						{
							cost = 20000,
							currency = "money",
							effect = "+4%",
							icon = "run",
							id = "upgrade_walk_speed",
							level = 1,
							maxLevel = 10,
							title = "Walk Speed",
						},
						{
							cost = 85000,
							currency = "money",
							effect = "+4% capacity",
							icon = "wallet",
							id = "upgrade_collector_size",
							level = 3,
							maxLevel = 10,
							title = "Collector Size",
						},
					},
				},
				{
					buttons = {
						{
							action = { kind = "Open", target = "rebirth" },
							icon = "rebirth",
							id = "goto_rebirth",
							label = "Prestige",
							tone = "White",
						},
					},
					id = "buttons_3",
					kind = "Buttons",
					layout = "Row",
				},
			},
			size = "Medium",
			title = "Boosts & Upgrades",
		},
		{
			description = "Daily and weekly quests with progress bars; completed ones glow and wait for a Claim.",
			icon = "quest",
			id = "quests",
			kind = "Window",
			nav = "Tabs",
			size = "Medium",
			tabs = {
				{
					badge = 1,
					icon = "daily",
					id = "tab_daily_2",
					label = "Daily",
					sections = {
						{
							id = "quests_2",
							kind = "Quests",
							quests = {
								{
									description = "Earn 240,000 Money",
									goal = 240000,
									icon = "cash",
									id = "quest_entrepreneur",
									progress = 240000,
									reward = { amount = "50", icon = "star" },
									title = "Entrepreneur",
								},
								{
									description = "Collect income 40 times",
									goal = 40,
									icon = "wallet",
									id = "quest_collector",
									progress = 25,
									reward = { amount = "15,000", icon = "cash" },
									title = "Collector",
								},
								{
									description = "Unlock floor 2",
									goal = 2,
									icon = "building-bank",
									id = "quest_expansion",
									progress = 0,
									reward = { amount = "4,500", icon = "cash" },
									title = "Expansion",
								},
							},
						},
					},
				},
				{
					icon = "calendar",
					id = "tab_weekly",
					label = "Weekly",
					sections = {
						{
							id = "quests_3",
							kind = "Quests",
							quests = {
								{
									description = "Buy 5 machines",
									goal = 5,
									icon = "cube",
									id = "quest_builder",
									progress = 1,
									reward = { amount = "25", icon = "star" },
									title = "Builder",
								},
							},
						},
					},
				},
			},
			title = "Challenges",
		},
		{
			description = "Redeem promo codes. The server validates them through the RedeemCode remote.",
			icon = "tag",
			id = "codes",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					body = "New codes drop with every update and milestone. Check the game page!",
					id = "text",
					kind = "Text",
				},
				{
					action = { kind = "Remote", name = "RedeemCode" },
					button = "Redeem",
					id = "codes_2",
					kind = "Codes",
					placeholder = "Enter a code",
				},
			},
			size = "Small",
			title = "Codes",
			transition = "Pop",
		},
		{
			description = "Upsell for the VIP pass and other gamepasses: 2x daily rewards, a gold name tag and an exclusive VIP area.",
			icon = "crown",
			id = "vip",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					icon = "crown",
					id = "become_vip",
					kind = "Banner",
					subtitle = "2X Daily Rewards, A Gold Name Tag And An Exclusive VIP Area",
					title = "Become VIP",
					tone = "Red",
				},
				{
					id = "gamepasses",
					items = {
						{
							currency = "robux",
							description = "Earn twice the Money, forever",
							icon = "cash",
							id = "pass_2x_money",
							price = 399,
							tag = "POPULAR",
							title = "2x Money",
							tone = "Yellow",
						},
						{
							currency = "robux",
							description = "Gold name, VIP chat and +10% Money",
							icon = "crown",
							id = "pass_vip_plot",
							price = 399,
							title = "VIP Plot",
							tone = "Red",
						},
						{
							currency = "robux",
							description = "Grinds for you while you're away",
							icon = "wallet",
							id = "pass_auto_collect",
							price = 199,
							title = "Auto Collect",
							tone = "Yellow",
						},
						{
							currency = "robux",
							description = "Your factory runs twice as fast",
							icon = "lightning-bolt",
							id = "pass_faster_droppers",
							price = 199,
							title = "Faster Droppers",
							tone = "Yellow",
						},
						{
							currency = "robux",
							description = "Gold tools that work twice as hard",
							icon = "star",
							id = "pass_golden_tools",
							price = 75,
							title = "Golden Tools",
							tone = "Cyan",
						},
					},
					kind = "Products",
					layout = "Carousel",
					title = "Gamepasses",
				},
			},
			size = "Medium",
			title = "VIP",
			transition = "Pop",
		},
		{
			description = "Trade your Money for a permanent multiplier. The popup spells out exactly what is lost and gained so the decision feels safe.",
			icon = "rebirth",
			id = "rebirth",
			kind = "Popup",
			nav = "None",
			sections = {
				{
					id = "stats",
					kind = "Stats",
					stats = {
						{ icon = "rebirth", label = "Prestiges", value = "3" },
						{ icon = "boost", label = "Multiplier", value = "x4.5 → x6" },
						{ icon = "cash", label = "Cost", value = "23M" },
						{ bar = 0.80365246906877, icon = "level", label = "Progress", value = "65%" },
					},
				},
				{
					body = "Prestige resets your Money and upgrades, but keeps your machines and Tokens.",
					id = "text_2",
					kind = "Text",
				},
				{
					buttons = {
						{
							action = { kind = "Event", name = "Rebirth" },
							icon = "rebirth",
							id = "prestige",
							label = "Prestige",
							tone = "Red",
						},
						{
							action = { kind = "Close" },
							icon = "close",
							id = "not_yet",
							label = "Not Yet",
							tone = "White",
						},
					},
					id = "buttons",
					kind = "Buttons",
					layout = "Row",
				},
			},
			size = "Medium",
			title = "Prestige",
			transition = "Pop",
		},
		{
			delay = 5,
			description = "A one-time Starter Pack: 4 rewards for 99 Robux (was 299), counting down 24h from the first time a player sees it. It shows by itself once, a few seconds after joining, and never to owners.",
			icon = "flame",
			id = "offer",
			items = {
				{
					amount = "375",
					grant = { amount = 375, currency = "tokens" },
					icon = "star",
					label = "Tokens",
				},
				{
					amount = "200K",
					grant = { amount = 200000, currency = "money" },
					icon = "cash",
					label = "Money",
				},
				{ amount = "1", icon = "flame", label = "Epic Golden Furnace" },
				{ amount = "3", icon = "cash", label = "2x Income (15m)" },
			},
			kind = "Offer",
			price = 99,
			show = "Once",
			subtitle = "Everything you need for a flying start",
			tag = "STARTER PACK",
			timer = 86400,
			title = "Starter Pack",
			tone = "Yellow",
			was = 299,
		},
		{
			capHours = 8,
			currency = "money",
			description = "Welcomes players back with what they earned while away: 1,500 Money a minute, up to 8h, worked out by the server from their last visit.",
			doublePrice = 49,
			icon = "moon-with-stars",
			id = "offline",
			kind = "OfflineEarnings",
			minMinutes = 5,
			rate = 1500,
			title = "Welcome Back!",
		},
		{
			description = "Grouped into General, Graphics, Audio and Accessibility. Everything applies instantly; there is no Save button to forget.",
			icon = "settings",
			id = "settings",
			kind = "Window",
			nav = "SideTabs",
			size = "Medium",
			tabs = {
				{
					icon = "gear",
					id = "tab_general",
					label = "General",
					sections = {
						{
							id = "settings_2",
							kind = "Settings",
							settings = {
								{
									default = true,
									icon = "bell",
									id = "notifications",
									label = "Notifications",
									type = "Toggle",
								},
								{
									default = true,
									icon = "frame-expanded",
									id = "showHud",
									label = "Show HUD",
									type = "Toggle",
								},
							},
						},
					},
				},
				{
					icon = "image",
					id = "tab_graphics",
					label = "Graphics",
					sections = {
						{
							id = "settings_3",
							kind = "Settings",
							settings = {
								{
									default = false,
									icon = "speedometer",
									id = "lowDetail",
									label = "Low Detail Mode",
									type = "Toggle",
								},
								{ default = true, icon = "sun", id = "shadows", label = "Shadows", type = "Toggle" },
							},
						},
					},
				},
				{
					icon = "sound",
					id = "tab_audio",
					label = "Audio",
					sections = {
						{
							id = "settings_4",
							kind = "Settings",
							settings = {
								{
									default = 50,
									icon = "music",
									id = "music",
									label = "Music",
									max = 100,
									min = 0,
									type = "Slider",
								},
								{
									default = 50,
									icon = "sound",
									id = "sfx",
									label = "Sound Effects",
									max = 100,
									min = 0,
									type = "Slider",
								},
							},
						},
					},
				},
				{
					icon = "circle-person",
					id = "tab_accessibility",
					label = "Accessibility",
					sections = {
						{
							id = "settings_5",
							kind = "Settings",
							settings = {
								{
									default = "Off",
									icon = "eye",
									id = "colorblind",
									label = "Colour-blind Mode",
									options = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" },
									type = "Choice",
								},
								{
									default = false,
									icon = "pause-large",
									id = "reducedMotion",
									label = "Reduce Motion",
									type = "Toggle",
								},
								{
									default = 100,
									icon = "dual-arrows-to-corners",
									id = "uiScale",
									label = "UI Scale",
									max = 120,
									min = 80,
									type = "Slider",
								},
							},
						},
					},
				},
			},
			title = "Settings",
		},
	},
	startScreen = "menu",
	style = {
		archetype = "Bubbly",
		buttonStyle = "Soft",
		clickFx = "Poof",
		corner = "Bubbly",
		decoration = "Dots",
		density = "Comfy",
		fonts = { body = "Nunito:Bold", display = "DenkOne", title = "DenkOne" },
		iconStyle = "Filled",
		palette = {
			accent = "#FFD166",
			bad = "#E63946",
			good = "#3DBE8B",
			primary = "#FF5A5F",
			secondary = "#3EC1D3",
			surface = "#3A2E39",
			text = "#FFF5E1",
			warn = "#F4A261",
		},
		panelStyle = "Outlined",
		rarityScheme = "Classic",
		shadow = "Hard",
		textCase = "Normal",
		transition = "Pop",
	},
	version = 1,
}
]]></string>
              </Properties>
            </Item>
          </Item>
          <Item class="ModuleScript" referent="78">
            <Properties>
              <string name="Name">Schema</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Blueprint.Schema: every name, list and limit the blueprint format knows
	about (docs/BLUEPRINT.md). Shared by Normalize, the builders and UI.Server,
	so there is exactly one source of truth.

		local Schema = require(BloxUI.Blueprint.Schema)
		Schema.HUD_TYPES.CurrencyBar   --> true
		Schema.LIMITS.products         --> 12
]]

local Schema = {}

Schema.VERSION = 1

local function set(list: { string }): { [string]: boolean }
	local out = {}
	for _, name in list do
		out[name] = true
	end
	return out
end
Schema.set = set

Schema.GENRES = {
	"fps", "horror", "simulator", "tycoon", "obby", "rpg", "racing", "battleroyale",
	"towerdefense", "fighting", "survival", "roleplay", "sports", "casual", "scifi",
}

Schema.ARCHETYPES = { "Arena", "Bubbly", "Adventure", "Sleek", "Candy", "Tactical", "Horror" }

-- Which archetype a genre gets when the blueprint doesn't say.
Schema.GENRE_ARCHETYPE = {
	fps = "Arena", horror = "Horror", simulator = "Candy", tycoon = "Bubbly", obby = "Candy",
	rpg = "Adventure", racing = "Sleek", battleroyale = "Arena", towerdefense = "Adventure",
	fighting = "Arena", survival = "Tactical", roleplay = "Bubbly", sports = "Sleek",
	casual = "Bubbly", scifi = "Sleek",
}

Schema.STYLE_ENUMS = {
	buttonStyle = { "Glossy3D", "Flat", "Pill", "Outline", "Soft" },
	panelStyle = { "Glass", "Solid", "Gradient", "Outlined", "Ribbon", "Fade" },
	corner = { "Sharp", "Soft", "Round", "Bubbly" },
	clickFx = { "Squash", "Ripple", "Sparkle", "Glow", "Shake", "Glitch", "Punch", "Poof", "None" },
	transition = { "Pop", "Slide", "Fade", "Drop", "Zoom", "Rise", "Swap" },
	iconStyle = { "Filled", "Outline" },
	textCase = { "Normal", "Upper" },
	decoration = { "None", "Sparkles", "Stripes", "Dots", "Grain" },
	shadow = { "Soft", "Hard", "None" },
	density = { "Compact", "Comfy" },
	rarityScheme = { "Classic", "Arena" },
}

Schema.PALETTE_KEYS = { "primary", "secondary", "accent", "surface", "text", "good", "bad", "warn" }

--------------------------------------------------------------------------
-- v2.4: style families (docs/BLUEPRINT.md "v2.4 style families").
--
-- `style.family` names one of these ids. A family is a complete runtime look
-- (palette envelope, light or dark surfaces, radius scale, stroke recipe,
-- shadow recipe, fills, texture, fonts, motion personality, sounds) built on
-- top of the archetype (Theme/Families.luau). Blueprints without a family
-- render exactly like before (the archetype alone decides the look).
--
-- `palette.surface` may be a hex colour (the v1 meaning: the dark surface
-- colour) or, from v2.4, the word "light" or "dark" to pick the family's
-- surface mode; when it's missing the family's default mode applies.
--
-- Generators should only emit `style.family` for plugins >= 2.4.0
-- (Schema.FAMILY_MIN_PLUGIN).
--------------------------------------------------------------------------
Schema.STYLE_FAMILIES = {
	"Toybox", "Studio", "Neon", "Clay", "Sketch", "Royal", "Arcade",
	"Comic", "Dread", "Holo", "Gacha", "Broadcast", "Tavern", "Mochi",
}
Schema.FAMILY_MIN_PLUGIN = "2.4.0"
Schema.SURFACE_MODES = { "light", "dark" }

-- Per family: display label, the archetype it builds on (used when a
-- blueprint names a family but no archetype), its default surface mode and
-- a one-line description (plugin picker, AI prompts).
Schema.FAMILY_INFO = {
	Toybox = { label = "Toybox Gloss", base = "Candy", surface = "light", blurb = "Glossy cartoon: chunky outlines, cream panels, bouncy." },
	Studio = { label = "Studio Minimal", base = "Sleek", surface = "dark", blurb = "Clean app UI: hairlines, soft shadows, crisp motion." },
	Neon = { label = "Neon Synth", base = "Arena", surface = "dark", blurb = "Cyber neon: glowing edges on near-black, snappy." },
	Clay = { label = "Soft Clay", base = "Bubbly", surface = "light", blurb = "Soft 3D pastels: puffy fills, big tinted shadows." },
	Sketch = { label = "Sketchbook", base = "Bubbly", surface = "light", blurb = "Paper and ink: hand fonts, hard offset shadows." },
	Royal = { label = "Royal Gold", base = "Adventure", surface = "dark", blurb = "Luxury: dark violet, double gold rules, stately." },
	Arcade = { label = "Arcade Pixel", base = "Candy", surface = "dark", blurb = "8-bit retro: square corners, pixel font, stepped." },
	Comic = { label = "Comic Pop", base = "Arena", surface = "light", blurb = "Comic book: ink outlines, halftone, punchy." },
	Dread = { label = "Dread", base = "Horror", surface = "dark", blurb = "Horror grunge: bone on black, grain, uneasy." },
	Holo = { label = "Holo HUD", base = "Tactical", surface = "dark", blurb = "Sci-fi hologram: cyan hairlines, chamfers, scanlines." },
	Gacha = { label = "Gacha Anime", base = "Adventure", surface = "dark", blurb = "Anime gacha: night gradients, white rims, star glows." },
	Broadcast = { label = "Broadcast", base = "Arena", surface = "dark", blurb = "Esports TV: flat slabs, accent bars, wide type." },
	Tavern = { label = "Tavern Fantasy", base = "Adventure", surface = "light", blurb = "Medieval: parchment, dark wood, gold trim." },
	Mochi = { label = "Mochi Kawaii", base = "Bubbly", surface = "light", blurb = "Cute pastels: pink, pill buttons, tinted outlines." },
}

-- Each family's own palette, used when a family blueprint leaves a colour out.
Schema.FAMILY_PALETTES = {
	Toybox = { primary = "#2F9BFF", secondary = "#FFC21A", accent = "#5BD62A" },
	Studio = { primary = "#6E7BFF", secondary = "#8A8A99", accent = "#6E7BFF" },
	Neon = { primary = "#FF2BD6", secondary = "#19E6FF", accent = "#FFE45C" },
	Clay = { primary = "#7C8CFF", secondary = "#FF9EC7", accent = "#FFD66B" },
	Sketch = { primary = "#3C6FD6", secondary = "#E8483A", accent = "#FFE55C" },
	Royal = { primary = "#5B2A86", secondary = "#E0245E", accent = "#F5C84C" },
	Arcade = { primary = "#FF004D", secondary = "#29ADFF", accent = "#FFEC27" },
	Comic = { primary = "#1E6BFF", secondary = "#FF2E4D", accent = "#FFD400" },
	Dread = { primary = "#B3121B", secondary = "#9FB36A", accent = "#B3121B" },
	Holo = { primary = "#3FE0FF", secondary = "#7A5CFF", accent = "#FFB020" },
	Gacha = { primary = "#4FA3FF", secondary = "#FF7EB6", accent = "#FFCB5C" },
	Broadcast = { primary = "#1F7BFF", secondary = "#FF3B30", accent = "#D7FF1F" },
	Tavern = { primary = "#3F7D3A", secondary = "#B8322E", accent = "#E8B64C" },
	Mochi = { primary = "#FF8FC0", secondary = "#9FD8FF", accent = "#FFE27A" },
}

-- With a family, the family decides these style keys; `style.custom` lists
-- the ones the user changed by hand (the plugin's Design tab adds them), and
-- those keep the blueprint's value.
Schema.FAMILY_CUSTOM_KEYS = {
	"fonts", "corner", "buttonStyle", "panelStyle", "clickFx", "transition", "textCase",
	"decoration", "shadow", "iconStyle", "rarityScheme", "density",
}

-- Suggested genre -> family weights (docs/research polish_spec 4.5). The
-- generator is free to use its own table; this one is the shared default.
Schema.GENRE_FAMILIES = {
	simulator = { Toybox = 5, Mochi = 2, Clay = 2, Neon = 1 },
	tycoon = { Toybox = 4, Royal = 2, Studio = 2, Clay = 1 },
	obby = { Toybox = 4, Arcade = 2, Neon = 2, Comic = 1 },
	fps = { Broadcast = 4, Holo = 2, Neon = 2, Studio = 1 },
	battleroyale = { Broadcast = 4, Neon = 2, Comic = 1, Holo = 1 },
	fighting = { Comic = 4, Gacha = 3, Broadcast = 2, Neon = 1 },
	horror = { Dread = 8, Sketch = 1, Arcade = 1 },
	rpg = { Tavern = 4, Gacha = 3, Royal = 2 },
	towerdefense = { Gacha = 4, Toybox = 3, Tavern = 2 },
	racing = { Neon = 4, Broadcast = 4, Holo = 1 },
	survival = { Tavern = 3, Dread = 2, Studio = 2, Holo = 1 },
	roleplay = { Mochi = 4, Studio = 3, Sketch = 2, Clay = 1 },
	sports = { Broadcast = 6, Toybox = 2, Studio = 1 },
	casual = { Toybox = 3, Comic = 3, Clay = 2, Arcade = 2 },
	scifi = { Holo = 5, Neon = 2, Studio = 2 },
}
-- Prompt keywords that pull a family in (Generator.Families.KEYWORDS is the
-- same list; a test keeps them equal). They match WHOLE words only (lower
-- case, "-" kept inside a word), never substrings: "royal" is not in
-- "battle royale". Use Schema.familyKeyword(prompt) rather than matching
-- these by hand.
Schema.FAMILY_KEYWORDS = {
	cute = "Mochi", pastel = "Mochi", kawaii = "Mochi", retro = "Arcade", pixel = "Arcade", ["8-bit"] = "Arcade",
	["8bit"] = "Arcade", luxury = "Royal", royal = "Royal", vip = "Royal", gold = "Royal", golden = "Royal", anime = "Gacha",
	neon = "Neon", cyber = "Neon", cyberpunk = "Neon", synth = "Neon", synthwave = "Neon", paper = "Sketch", doodle = "Sketch",
	notebook = "Sketch", clay = "Clay", minimal = "Studio", clean = "Studio", modern = "Studio", minimalist = "Studio",
	comic = "Comic", superhero = "Comic", medieval = "Tavern", tavern = "Tavern", hologram = "Holo", holographic = "Holo",
	esports = "Broadcast", broadcast = "Broadcast",
}
-- Phrases removed from a prompt before the keywords are looked up: a genre
-- name that happens to contain a style word ("battle royale" never asks for
-- Royal Gold, "victory royale" neither).
Schema.FAMILY_KEYWORD_SKIP = { "battle royale", "battle royales", "victory royale", "royale" }

-- The prompt's words (lower case, whole words; "-" kept inside a word) with
-- the FAMILY_KEYWORD_SKIP phrases taken out.
function Schema.familyWords(prompt: any): { [string]: boolean }
	local text = " " .. string.lower(if typeof(prompt) == "string" then string.sub(prompt, 1, 2000) else "") .. " "
	text = string.gsub(text, "[^%w%-]+", " ")
	for _, phrase in Schema.FAMILY_KEYWORD_SKIP do
		local pattern = "%f[%w%-]" .. string.gsub(phrase, "[%-%.%+%*%?%^%$%(%)%[%]%%]", "%%%0") .. "%f[^%w%-]"
		text = string.gsub(text, pattern, " ")
	end
	local out = {}
	for word in string.gmatch(text, "[%w%-]+") do
		out[word] = true
	end
	return out
end

-- The family a prompt names by keyword (whole words; the first in
-- alphabetical keyword order when several do), or nil.
function Schema.familyKeyword(prompt: any): string?
	local said = Schema.familyWords(prompt)
	local keys = {}
	for word in Schema.FAMILY_KEYWORDS do
		table.insert(keys, word)
	end
	table.sort(keys)
	for _, word in keys do
		if said[word] then
			return Schema.FAMILY_KEYWORDS[word]
		end
	end
	return nil
end

-- Full default style per archetype. Normalize fills anything missing from here.
Schema.ARCHETYPE_STYLE = {
	Arena = {
		palette = { primary = "#FFB400", secondary = "#2E7BFF", accent = "#FFD21F", surface = "#0D0A0E", text = "#FFFFFF", good = "#95FA26", bad = "#FF3233", warn = "#FFB300" },
		fonts = { display = "Rubik:Black", body = "Rubik:Bold", title = "Rubik:Black" },
		buttonStyle = "Flat", panelStyle = "Glass", corner = "Soft", clickFx = "Punch", transition = "Pop",
		iconStyle = "Filled", textCase = "Normal", decoration = "None", shadow = "Soft", density = "Comfy", rarityScheme = "Arena",
	},
	Bubbly = {
		palette = { primary = "#FF7AB6", secondary = "#5CC8FF", accent = "#FFD12E", surface = "#2B2470", text = "#FFFFFF", good = "#63E03A", bad = "#FF4B5C", warn = "#FFB300" },
		fonts = { display = "FredokaOne", body = "BuilderSans:Bold", title = "FredokaOne" },
		buttonStyle = "Glossy3D", panelStyle = "Outlined", corner = "Bubbly", clickFx = "Squash", transition = "Pop",
		iconStyle = "Filled", textCase = "Normal", decoration = "Sparkles", shadow = "Hard", density = "Comfy", rarityScheme = "Classic",
	},
	Adventure = {
		palette = { primary = "#F2B233", secondary = "#1E9C9C", accent = "#FFC845", surface = "#0C1633", text = "#FFF6E0", good = "#4FE07A", bad = "#FF4B4B", warn = "#FFB300" },
		fonts = { display = "BuilderSans:ExtraBold", body = "BuilderSans:Bold", title = "BuilderSans:ExtraBold" },
		buttonStyle = "Glossy3D", panelStyle = "Ribbon", corner = "Round", clickFx = "Sparkle", transition = "Drop",
		iconStyle = "Filled", textCase = "Normal", decoration = "Sparkles", shadow = "Soft", density = "Comfy", rarityScheme = "Classic",
	},
	Sleek = {
		palette = { primary = "#4D8DFF", secondary = "#7A5CFF", accent = "#4D8DFF", surface = "#121724", text = "#FFFFFF", good = "#2FD67B", bad = "#FF4D5E", warn = "#FFB020" },
		fonts = { display = "BuilderSans:Bold", body = "BuilderSans:Medium", title = "BuilderSans:Bold" },
		buttonStyle = "Pill", panelStyle = "Solid", corner = "Round", clickFx = "Ripple", transition = "Fade",
		iconStyle = "Filled", textCase = "Normal", decoration = "None", shadow = "Soft", density = "Comfy", rarityScheme = "Classic",
	},
	Candy = {
		palette = { primary = "#FF6FB5", secondary = "#9B6BFF", accent = "#FF5FC8", surface = "#2B1650", text = "#FFFFFF", good = "#4DF08A", bad = "#FF4B6B", warn = "#FFB84D" },
		fonts = { display = "FredokaOne", body = "BuilderSans:Bold", title = "FredokaOne" },
		buttonStyle = "Glossy3D", panelStyle = "Gradient", corner = "Bubbly", clickFx = "Sparkle", transition = "Pop",
		iconStyle = "Filled", textCase = "Normal", decoration = "Sparkles", shadow = "Hard", density = "Comfy", rarityScheme = "Classic",
	},
	Tactical = {
		palette = { primary = "#FFB020", secondary = "#6B7A8F", accent = "#FFB020", surface = "#0C1016", text = "#FFFFFF", good = "#3DDC84", bad = "#FF4A3D", warn = "#FFB020" },
		fonts = { display = "BuilderSans:Bold", body = "BuilderSans:Medium", title = "BuilderSans:Bold" },
		buttonStyle = "Flat", panelStyle = "Glass", corner = "Sharp", clickFx = "Shake", transition = "Slide",
		iconStyle = "Outline", textCase = "Upper", decoration = "Stripes", shadow = "Hard", density = "Compact", rarityScheme = "Classic",
	},
	Horror = {
		palette = { primary = "#B3121C", secondary = "#3A3A40", accent = "#B3121C", surface = "#070505", text = "#E8DCC8", good = "#6F9A3A", bad = "#C4202A", warn = "#C9A227" },
		fonts = { display = "SpecialElite", body = "SpecialElite", title = "SpecialElite" },
		buttonStyle = "Outline", panelStyle = "Solid", corner = "Sharp", clickFx = "Glitch", transition = "Fade",
		iconStyle = "Outline", textCase = "Upper", decoration = "Grain", shadow = "None", density = "Compact", rarityScheme = "Classic",
	},
}

Schema.LOGO_TYPES = { "Wordmark", "Emblem", "Badge", "Stacked", "Neon" }

Schema.TONES = {
	"Green", "Red", "Yellow", "Blue", "Purple", "Orange", "Pink", "Cyan", "Gray", "White", "Dark",
	-- v2.4: tones that follow the palette (Theme.fromStyle builds them); the
	-- generator sends them to plugins >= 2.4.0 only (Generator.Compat)
	"Primary", "Secondary", "Accent", "Good", "Bad", "Warn",
}
-- Tones used to colour menu buttons generated by the builder, in order.
Schema.MENU_TONES = { "Yellow", "Pink", "Blue", "Green", "Purple", "Orange", "Cyan", "Red" }

Schema.ANCHORS = { "TopLeft", "TopCenter", "TopRight", "Left", "Center", "Right", "BottomLeft", "BottomCenter", "BottomRight" }
-- v1.4 adds Rail (menu column down the right edge) and TopBar (currencies
-- in a row at the top centre). v2.3 adds Simulator, the simulator / tycoon
-- classic: currencies at the bottom centre, shop buttons and boosts on the
-- right, the menu grid on the left and a row of teleport buttons on top.
Schema.HUD_LAYOUTS = { "Classic", "Dock", "Corners", "Minimal", "Rail", "TopBar", "Simulator" }

-- Where each HUD layout puts the elements it arranges: the currencies (the
-- first CurrencyBar), the menu (the first MenuButtons that isn't a teleport
-- row) and the menu's style. Normalize gives such an element with no anchor
-- its layout's spot, and moves it when hud.layout changes (hud.arranged).
Schema.LAYOUT_ANCHORS = {
	Classic = { CurrencyBar = "TopLeft", MenuButtons = "Left", menuStyle = "Grid" },
	Dock = { CurrencyBar = "TopRight", MenuButtons = "BottomCenter", menuStyle = "Dock" },
	Corners = { CurrencyBar = "TopRight", MenuButtons = "Left", menuStyle = "Column" },
	Minimal = { CurrencyBar = "TopRight", MenuButtons = "Right", menuStyle = "Column" },
	-- v1.4
	Rail = { CurrencyBar = "TopLeft", MenuButtons = "Right", menuStyle = "Column" },
	TopBar = { CurrencyBar = "TopCenter", MenuButtons = "Left", menuStyle = "Column" },
	-- v2.3 (Boosts and Buttons already default to the right edge)
	Simulator = { CurrencyBar = "BottomCenter", MenuButtons = "Left", menuStyle = "Grid" },
}
-- The styles a MenuButtons element can have.
Schema.MENU_STYLES = { "Grid", "Column", "Row", "Dock" }

-- v2.3: what a MenuButtons element is for (`role`; nil = the HUD menu).
-- "teleports" is a row of teleport buttons (the Simulator layout's top row,
-- each firing the Event "Teleport" with its place as the payload): it sits at
-- TELEPORT_ANCHOR unless it names its own anchor, keeps it when the layout
-- changes, and never takes the menu's buttons.
Schema.MENU_ROLES = { "teleports" }
Schema.TELEPORT_ANCHOR = "TopCenter"

Schema.HUD_TYPE_LIST = {
	"CurrencyBar", "MenuButtons", "HealthBar", "XPBar", "QuestTracker", "Hotbar", "Crosshair",
	"KeyHints", "Objective", "Stamina", "Battery", "Fear", "Vignette", "Boosts", "Clicker",
	"KillFeed", "Timer", "TeamScore", "Button", "Minimap",
	-- v1.1
	"BossBar", "MobileControls", "EmoteWheel",
	-- v1.7: sub-genre readouts (horror flavours and more)
	"Counter", "Meter", "TeamStatus", "CameraPanel", "Camcorder", "Compass",
}
Schema.HUD_TYPES = set(Schema.HUD_TYPE_LIST)

-- v1.7: the look of each new HUD type (the first value is the default).
Schema.COUNTER_STYLES = { "Plaque", "Digital", "Stamp", "Scrawl" }
Schema.METER_STYLES = { "Bar", "Segments", "Dial", "ECG" }
Schema.TEAM_STATES = { "Alive", "Downed", "Escaped", "Dead", "Caught" }
Schema.TEAM_STYLES = { "Portraits", "List" }
Schema.CAMCORDER_MODES = { "VHS", "NightVision", "Digital" }
-- Vignette styles (v1.7 adds Static: TV snow crawling in from the edges)
Schema.VIGNETTE_STYLES = { "Soft", "Tunnel", "Static" }

-- HUD element types that draw full-screen overlays instead of taking anchor space.
Schema.OVERLAY_TYPES = set({ "Fear", "Vignette", "Crosshair", "EmoteWheel" })

-- v1.8 HUD kits (Components/HudKits): `hud.kit` names one; an element's
-- `look` picks one of its type's styles (the first is the original look).
-- Types without looks still take the kit's type and entrance.
do
	local HudKits = require(script.Parent.Parent.Components.HudKits)
	Schema.HUD_KITS = table.clone(HudKits.names)
	Schema.HUD_LOOKS = {}
	for kind, list in HudKits.styles do
		Schema.HUD_LOOKS[kind] = table.clone(list)
	end
end

Schema.SECTION_KIND_LIST = {
	"Products", "Inventory", "Upgrades", "Daily", "Quests", "Pass", "Leaderboard", "Scoreboard",
	"Loadout", "Stats", "Settings", "Banner", "Buttons", "Codes", "Text",
	-- v1.1
	"Cases", "Worlds", "Profile", "Party", "Keybinds",
	-- v1.6: retention and monetisation
	"Spin", "Gifts", "Index", "Achievements", "Social",
}
Schema.SECTION_KINDS = set(Schema.SECTION_KIND_LIST)

Schema.SCREEN_KIND_LIST = {
	"Window", "Fullscreen", "Popup", "MainMenu", "Loading", "Results", "Trade",
	-- v1.6
	"Offer", "OfflineEarnings",
}
Schema.SCREEN_KINDS = set(Schema.SCREEN_KIND_LIST)
-- Screen kinds that behave like windows (one open at a time, reachable from the HUD menu).
Schema.WINDOW_KINDS = set({ "Window", "Fullscreen", "Trade" })
-- v1.6: screens the game shows by itself (an offer on join, offline earnings);
-- they sit on the popup layer and need no menu button.
Schema.AUTO_KINDS = set({ "Offer", "OfflineEarnings" })

Schema.SCREEN_SIZES = { "Small", "Medium", "Large" }
Schema.NAVS = { "Tabs", "SideTabs", "None" }
-- v2.4: where a Window sits (`dock`; drawn only under a style family, older
-- runtimes and classic themes keep the centred window). Center = the
-- classic window; Right / Left = a side panel on that edge that slides in
-- from it; Wide = a showroom as wide as the screen allows (up to 1440 px);
-- Sheet = a sheet on the bottom edge that slides up (full width on phones).
-- All are as tall as their content. Generators send
-- it to plugins >= LAYOUT_MIN_PLUGIN only (Generator.Compat strips it).
Schema.SCREEN_DOCKS = { "Center", "Right", "Left", "Wide", "Sheet" }

--------------------------------------------------------------------------
-- v1.4: structural variants (docs/BLUEPRINT.md). Every field is optional;
-- leaving it out builds exactly what v1.3 built.
--------------------------------------------------------------------------

-- MainMenu `layout` and the `bannerPosition`s each layout can place its
-- Banner section at (the first one is the default).
Schema.MENU_LAYOUTS = { "Left", "Center", "Split", "Corner" }
Schema.MENU_BANNER = {
	Left = { "Side" },
	Center = { "Bottom", "Corner" },
	Split = { "Side", "Bottom" },
	Corner = { "Side", "Corner" },
}
Schema.BANNER_POSITIONS = { "Side", "Bottom", "Corner" }

-- Products section `layout`s (Cards, Compact and Featured are v1).
Schema.PRODUCT_LAYOUTS = { "Cards", "Compact", "Featured", "Hero", "Carousel", "Rows", "Bundles" }
-- Inventory section `layout`s: Side = grid + detail panel on the right (the
-- v1 look), Bottom = grid + a detail bar underneath, Showcase = the equipped
-- items as big slots on top of the grid.
Schema.INVENTORY_LAYOUTS = { "Side", "Bottom", "Showcase" }
-- Daily section `layout`s: Row = the v1 tile row, Calendar = a month grid
-- (up to 28 days, 7 across), Ladder = a rising streak staircase.
Schema.DAILY_LAYOUTS = { "Row", "Calendar", "Ladder" }

-- v2.4 section layouts (docs/BLUEPRINT.md "v2.4 section layouts"), drawn by
-- Prefabs/Layouts. Separate from the v1.4 lists above so older tools keep
-- theirs; Normalize accepts both. Generators send them to plugins >=
-- LAYOUT_MIN_PLUGIN only (Generator.Compat maps each one back: Grid ->
-- Cards, Showcase -> Featured, Wall -> Bundles, Passes -> Rows, Mosaic ->
-- Featured, Path -> Ladder, Filters -> Bottom, Team -> Showcase; a Cases /
-- Gifts layout is dropped), and an older runtime's Normalize falls back the
-- same way for anything it doesn't know.
--   Products  Grid      item tiles in rarity frames: name, rarity chip, price
--             Showcase  the selected item big with stat bars (items[].stats)
--             Wall      currency packs as a rising staircase
--             Passes    gamepass medallion cards
--             Mosaic    one tall featured offer beside a 2 x 2 of others
--   Cases     Tiers     crates in a rising row, each with a stacked odds bar
--             Banner    a gacha summon banner: rate-up unit, countdown (timer), x1 / x10
--   Daily     Path      seven stops along a winding trail
--   Gifts     Chests    a few chests on playtime timers
--   Inventory Filters   rarity filter chips over the grid, detail bar below
--             Team      the equipped team (slots) with its total boost, items[].power
Schema.LAYOUT_MIN_PLUGIN = "2.4.0"
Schema.SECTION_LAYOUTS_V24 = {
	Products = { "Grid", "Showcase", "Wall", "Passes", "Mosaic" },
	Cases = { "Tiers", "Banner" },
	Daily = { "Path" },
	Gifts = { "Chests" },
	Inventory = { "Filters", "Team" },
}
-- Window chrome overrides on Window / Fullscreen / Popup screens.
Schema.SCREEN_TRANSITIONS = { "Pop", "Rise", "Slide", "Fade", "Drop", "Zoom", "Swap" }
Schema.SCREEN_PANELS = { "Glass", "Solid", "Gradient", "Outlined", "Ribbon", "Fade" }

Schema.ACTION_KINDS = { "None", "Open", "Close", "Toggle", "Purchase", "GamePass", "Remote", "Event", "Notify" }

Schema.RARITIES = { "Common", "Uncommon", "Rare", "Epic", "Legendary", "Mythic", "Secret" }
Schema.RARITY_RANK = { Common = 1, Uncommon = 2, Rare = 3, Epic = 4, Legendary = 5, Mythic = 6, Secret = 7 }
Schema.RARITY_HEX = {
	Common = "#B0B7C3", Uncommon = "#5BE22B", Rare = "#3AA8FF", Epic = "#B45CFF",
	Legendary = "#FFB300", Mythic = "#FF3B6B", Secret = "#FFFFFF",
}

Schema.RESULTS = { "Victory", "Defeat", "Draw" }

-- Font families that ship with Roblox (content/fonts/families/*.json) and the
-- weights each one has, plus Rubik: a free Roblox-published family
-- (rbxassetid://12187365977), RIVALS' font.
Schema.FONTS = {
	AccanthisADFStd = { 400 },
	AmaticSC = { 400, 700 },
	Arimo = { 400, 500, 600, 700 },
	Balthazar = { 400 },
	Bangers = { 400 },
	BuilderExtended = { 300, 400, 600, 700, 800 },
	BuilderMono = { 300, 400, 700 },
	BuilderSans = { 100, 300, 400, 500, 600, 700, 800 },
	ComicNeueAngular = { 400 },
	Creepster = { 400 },
	DenkOne = { 400 },
	Fondamento = { 400 },
	FredokaOne = { 400 },
	GrenzeGotisch = { 100, 200, 300, 400, 500, 600, 700, 800, 900 },
	Guru = { 400 },
	HighwayGothic = { 400 },
	Inconsolata = { 200, 300, 400, 500, 600, 700, 800, 900 },
	IndieFlower = { 400 },
	JosefinSans = { 100, 200, 300, 400, 500, 600, 700 },
	Jura = { 300, 400, 500, 600, 700 },
	Kalam = { 300, 400, 700 },
	LegacyArial = { 400, 700 },
	LegacyArimo = { 400, 700 },
	LuckiestGuy = { 400 },
	Merriweather = { 300, 400, 700, 900 },
	Michroma = { 400 },
	Montserrat = { 100, 200, 300, 400, 500, 600, 700, 800, 900 },
	NotoSansCJKFallback = { 400 },
	Nunito = { 200, 300, 400, 600, 700, 800, 900 },
	Oswald = { 200, 300, 400, 500, 600, 700 },
	PatrickHand = { 400 },
	PermanentMarker = { 400 },
	PressStart2P = { 400 },
	Roboto = { 100, 300, 400, 500, 700, 900 },
	RobotoCondensed = { 300, 400, 700 },
	RobotoMono = { 100, 200, 300, 400, 500, 600, 700 },
	RomanAntique = { 400 },
	Rubik = { 300, 400, 500, 600, 700, 800, 900 },
	Sarpanch = { 400, 500, 600, 700, 800, 900 },
	SourceSansPro = { 200, 300, 400, 600, 700, 900 },
	SpecialElite = { 400 },
	TitilliumWeb = { 200, 300, 400, 600, 700, 900 },
	Ubuntu = { 300, 400, 500, 700 },
	Zekton = { 400 },
}
Schema.WEIGHTS = {
	Thin = 100, ExtraLight = 200, Light = 300, Regular = 400, Medium = 500,
	SemiBold = 600, Bold = 700, ExtraBold = 800, Heavy = 900, Black = 900,
}
-- The name Normalize writes for each weight.
Schema.WEIGHT_NAMES = {
	[100] = "Thin", [200] = "ExtraLight", [300] = "Light", [400] = "Regular", [500] = "Medium",
	[600] = "SemiBold", [700] = "Bold", [800] = "ExtraBold", [900] = "Black",
}

-- Upper bounds for every list, so a runaway generator can't build 500 cards.
Schema.LIMITS = {
	currencies = 4, hudElements = 24, screens = 16, tabs = 6, sections = 8, menuButtons = 8,
	products = 12, items = 60, upgrades = 12, days = 28, quests = 10, tiers = 30, rows = 50,
	teams = 4, teamRows = 12, columns = 5, loadout = 6, stats = 12, settings = 14, buttons = 8,
	options = 6, tasks = 6, slots = 9, hints = 5, boosts = 5, text = 1200, tips = 12, cases = 6,
	pool = 20, worlds = 9, badges = 8, binds = 16, emotes = 8, mobileButtons = 5, teamsHud = 2,
	resultStats = 6, rewards = 6, partySlots = 8,
	-- days outside a Calendar layout (the v1 limit)
	daysRow = 14,
	-- v1.6
	wedges = 12, gifts = 12, indexGroups = 8, indexItems = 96, milestones = 4, achievements = 12,
	socialRewards = 4, offerItems = 6,
	-- v1.7
	members = 8, cameras = 12,
}

--------------------------------------------------------------------------
-- v1.6: retention and monetisation (docs/BLUEPRINT.md "v1.6 additions").
-- Every reward is decided by UI.Server; these are the shapes it checks.
--------------------------------------------------------------------------

-- A Spin wheel has 6..12 wedges (fewer are padded, more are cut).
Schema.WEDGES_MIN = 6
-- Hours between free spins when a Spin section doesn't say (0 = no free spin).
Schema.SPIN_FREE_HOURS = 12
-- Index milestones when a section doesn't list its own.
Schema.INDEX_MILESTONES = { 25, 50, 75, 100 }
-- When an Offer shows by itself: every session, once ever, every N joins,
-- or only when something opens it.
Schema.OFFER_SHOW = { "Session", "Once", "Joins", "Manual" }
-- Social rewards: Group (verified with IsInGroup), Like (can't be verified:
-- a one-time thank-you after `wait` seconds of play), Invite (paid when a
-- friend joins through the player's invite).
Schema.SOCIAL_KINDS = { "Group", "Like", "Invite" }
-- Offline earnings: counted up to this many hours unless the screen says.
Schema.OFFLINE_CAP_HOURS = 8
Schema.OFFLINE_MIN_MINUTES = 5
-- Claimed playtime gifts stay claimed this long (saved), so rejoining
-- doesn't hand them out again; the timers themselves count this session's
-- playtime.
Schema.GIFT_CYCLE_HOURS = 6
-- A paid spin in an in-game currency may pay back at most this share of its
-- price in that same currency on average (with x2 luck), or it could be
-- spun forever for profit: UI.Server turns such paid spins off.
Schema.SPIN_MAX_RETURN = 0.8

-- What a paid spin pays back on average, as a share of its price, in the
-- currency it costs (the x2 luck pass counted when the wheel sells one).
-- nil when the wheel has no paid spin in an in-game currency.
function Schema.spinReturn(s: any): number?
	if typeof(s) ~= "table" or typeof(s.price) ~= "number" or s.price <= 0 or typeof(s.currency) ~= "string" or s.currency == "robux" then
		return nil
	end
	local luck = s.luckGamePassId ~= nil or s.luckPrice ~= nil
	local total, back = 0, 0
	for _, w in if typeof(s.wedges) == "table" then s.wedges else {} do
		local weight = if typeof(w.chance) == "number" and w.chance > 0 then w.chance else 0
		if luck and (Schema.RARITY_RANK[w.rarity] or 1) >= 4 then
			weight *= 2
		end
		total += weight
		local grant = w.grant
		if typeof(grant) == "table" and grant.currency == s.currency and typeof(grant.amount) == "number" then
			back += weight * math.floor(grant.amount)
		end
	end
	if total <= 0 then
		return 0
	end
	return back / total / math.ceil(s.price)
end

-- Player attributes the server sets and the client reads (all names start
-- with BloxUI_; <id> is a section / screen / achievement id).
Schema.ATTRIBUTES = {
	joined = "BloxUI_Joined", -- unix time this server saw the player join (playtime gifts)
	spinNext = "BloxUI_SpinNext_", -- unix time of the next free spin
	spinLuck = "BloxUI_SpinLuck_", -- true: owns the x2 luck pass
	gifts = "BloxUI_Gifts_", -- bit mask of the gifts claimed in the running cycle (GIFT_CYCLE_HOURS)
	indexCount = "BloxUI_Index_", -- items discovered (set by Server.setIndexOwned, or by the game)
	indexClaimed = "BloxUI_IndexClaimed_", -- bit mask of claimed milestones
	owned = "BloxUI_Owned", -- JSON list of owned item ids (Server.setIndexOwned)
	achievement = "BloxUI_Ach_", -- progress, set by the game on the server
	achievementDone = "BloxUI_AchDone_", -- unix time the reward was claimed
	social = "BloxUI_Social_", -- bit mask of claimed social rewards
	invites = "BloxUI_Invites_", -- friends who joined through an invite
	offerOwned = "BloxUI_OfferOwned_", -- true: bought (or owns the game pass)
	offerEnds = "BloxUI_OfferEnds_", -- unix time the offer ends for this player
	showOffers = "BloxUI_ShowOffers", -- comma-separated offer ids to show this session
	offlineAmount = "BloxUI_OfflineAmount_", -- earned while away, waiting to be claimed
	offlineSeconds = "BloxUI_OfflineSeconds_", -- how long they were away (counted part)
}

-- Default window sizes (design px, contract of the Window component).
Schema.WINDOW_SIZE = {
	Small = { 720, 480 },
	Medium = { 980, 620 },
	Large = { 1180, 700 },
}

-- HUD safe zones in design px (1920x1080 canvas).
Schema.SAFE = {
	TopBar = 58, -- Roblox top bar, in real screen pixels
	Edge = 24, -- gap from the screen edge (docs/QUALITY.md asks for >= 16)
	TouchJump = 250, -- height kept clear above the jump button on touch screens
	TouchControls = 360, -- ...and above the MobileControls arc when there is one
}

--------------------------------------------------------------------------
-- v1.3: sounds, music and built-in settings
--------------------------------------------------------------------------

-- style.sounds slots (UI.Sound.override keys). Normalize accepts any case.
Schema.SOUND_SLOTS = {
	"click", "hover", "open", "close", "purchase", "reward", "error", "notify", "tick",
	"swoosh", "pop", "coin", "hit", "kill", "levelUp",
}
-- Other names Normalize maps to a slot (lower case, no spaces / _ / -).
Schema.SOUND_ALIASES = {
	denied = "error", fail = "error", buy = "purchase", purchased = "purchase", whoosh = "swoosh",
	tab = "swoosh", hitmarker = "hit", notification = "notify", ping = "notify", levelup = "levelUp",
	rankup = "levelUp", press = "click", tap = "click",
}
-- music.volume when a blueprint doesn't say (0..1).
Schema.MUSIC_VOLUME = 0.5

-- Setting ids the builder turns into real effects (docs/BLUEPRINT.md v1.3,
-- v1.5). Everything else only fires ui:on(id, fn).
Schema.BUILTIN_SETTINGS = {
	"sfx", "music", "fov", "sensitivity", "shadows",
	-- v1.5
	"lowDetail", "reducedMotion", "colorblind", "uiScale", "damageNumbers", "hitMarkers", "killFeed",
	"notifications", "skipHatch", "showHud",
}

-- v1.5: colourblind palettes (Theme.setColorblind). "Off" first.
Schema.COLORBLIND_MODES = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" }

-- The canonical definition of every built-in setting: the control types it
-- works as (the first is the usual one), its label and icon, and defaults
-- matching Roblox's own (accessibility switches start off). Normalize fills
-- a built-in's missing fields from here; the generators and the plugin copy
-- them. `toggle` is the default of the Toggle form when `default` isn't a
-- boolean.
Schema.BUILTIN_SETTING_DEFS = {
	sfx = { types = { "Slider", "Toggle" }, label = "Sound Effects", icon = "sound", default = 50, min = 0, max = 100, toggle = true },
	music = { types = { "Slider", "Toggle" }, label = "Music", icon = "music", default = 50, min = 0, max = 100, toggle = true },
	fov = { types = { "Slider" }, label = "Field of View", icon = "binoculars", default = 70, min = 70, max = 120 },
	sensitivity = { types = { "Slider" }, label = "Mouse Sensitivity", icon = "cursor", default = 50, min = 5, max = 100 },
	shadows = { types = { "Toggle" }, label = "Shadows", icon = "sun", default = true },
	lowDetail = { types = { "Toggle", "Choice" }, label = "Low Detail Mode", icon = "speedometer", default = false },
	reducedMotion = { types = { "Toggle" }, label = "Reduce Motion", icon = "pause-large", default = false },
	colorblind = { types = { "Choice", "Toggle" }, label = "Colour-blind Mode", icon = "eye", default = "Off", options = Schema.COLORBLIND_MODES, toggle = false },
	uiScale = { types = { "Slider" }, label = "UI Scale", icon = "dual-arrows-to-corners", default = 100, min = 80, max = 120 },
	damageNumbers = { types = { "Toggle" }, label = "Damage Numbers", icon = "hashtag", default = true },
	hitMarkers = { types = { "Toggle" }, label = "Hit Markers", icon = "crosshairs", default = true },
	killFeed = { types = { "Toggle" }, label = "Kill Feed", icon = "list-bulleted", default = true },
	notifications = { types = { "Toggle" }, label = "Notifications", icon = "bell", default = true },
	skipHatch = { types = { "Toggle" }, label = "Skip Hatch Animation", icon = "skip-next-large", default = false },
	showHud = { types = { "Toggle" }, label = "Show HUD", icon = "frame-expanded", default = true },
}

-- Setting ids Normalize maps to a built-in id (lower case, no spaces / _ / -).
Schema.SETTING_ALIASES = {
	sfx = "sfx", sound = "sfx", sounds = "sfx", soundeffects = "sfx", soundfx = "sfx", sfxvolume = "sfx",
	soundvolume = "sfx", effectsvolume = "sfx", volume = "sfx", audio = "sfx", uisounds = "sfx",
	music = "music", musicvolume = "music", bgm = "music", soundtrack = "music", backgroundmusic = "music",
	fov = "fov", fieldofview = "fov", camerafov = "fov",
	sensitivity = "sensitivity", mousesensitivity = "sensitivity", camerasensitivity = "sensitivity",
	looksensitivity = "sensitivity", sens = "sensitivity", mousesens = "sensitivity",
	shadows = "shadows", shadow = "shadows", globalshadows = "shadows",
	-- v1.5
	lowdetail = "lowDetail", lowdetailmode = "lowDetail", lowgraphics = "lowDetail", lowgraphicsmode = "lowDetail",
	lowquality = "lowDetail", graphicsquality = "lowDetail", performancemode = "lowDetail", performance = "lowDetail",
	potatomode = "lowDetail", litemode = "lowDetail", lowendmode = "lowDetail", fpsboost = "lowDetail",
	boostfps = "lowDetail", antilag = "lowDetail", reducelag = "lowDetail", lagreduction = "lowDetail",
	reducedmotion = "reducedMotion", reducemotion = "reducedMotion", lessmotion = "reducedMotion",
	calmmode = "reducedMotion", reduceflashes = "reducedMotion", reducedflashes = "reducedMotion",
	noflashes = "reducedMotion", photosensitivemode = "reducedMotion", photosensitivity = "reducedMotion",
	epilepsymode = "reducedMotion",
	colorblind = "colorblind", colourblind = "colorblind", colorblindmode = "colorblind",
	colourblindmode = "colorblind", colorblindness = "colorblind", colourblindness = "colorblind",
	colorblindfilter = "colorblind", colourblindfilter = "colorblind", colorvision = "colorblind",
	colourvision = "colorblind", cvd = "colorblind", daltonism = "colorblind",
	uiscale = "uiScale", uisize = "uiScale", hudscale = "uiScale", hudsize = "uiScale", interfacescale = "uiScale",
	interfacesize = "uiScale", guiscale = "uiScale", guisize = "uiScale", menuscale = "uiScale", uizoom = "uiScale",
	damagenumbers = "damageNumbers", damagenumber = "damageNumbers", damagetext = "damageNumbers",
	showdamage = "damageNumbers", showdamagenumbers = "damageNumbers", damagepopups = "damageNumbers",
	hitmarkers = "hitMarkers", hitmarker = "hitMarkers", hitmarks = "hitMarkers", showhitmarkers = "hitMarkers",
	killfeed = "killFeed", showkillfeed = "killFeed", killlog = "killFeed",
	notifications = "notifications", notification = "notifications", notifs = "notifications",
	toasts = "notifications", alerts = "notifications", shownotifications = "notifications",
	skiphatch = "skipHatch", skiphatching = "skipHatch", skiphatchanimation = "skipHatch", fasthatch = "skipHatch",
	quickhatch = "skipHatch", instanthatch = "skipHatch", skipanimation = "skipHatch", skipanimations = "skipHatch",
	fastopen = "skipHatch", quickopen = "skipHatch", skipopening = "skipHatch", skipcaseanimation = "skipHatch",
	skipunboxing = "skipHatch", skipsummon = "skipHatch", skipsummonanimation = "skipHatch", fastsummon = "skipHatch",
	showhud = "showHud", hud = "showHud", hudvisible = "showHud", showui = "showHud", showinterface = "showHud",
	hudenabled = "showHud",
}
-- Toggles that mean the opposite of a built-in ("Screen Shake: on" is
-- reducedMotion off). Normalize turns them into the built-in: the default is
-- flipped and the label becomes the built-in's, so the switch says what it does.
Schema.SETTING_INVERTED = {
	motion = "reducedMotion", screenshake = "reducedMotion", camerashake = "reducedMotion", shake = "reducedMotion",
	screenflashes = "reducedMotion", flashes = "reducedMotion", flashingeffects = "reducedMotion",
	motioneffects = "reducedMotion",
	highdetail = "lowDetail", highquality = "lowDetail", highgraphics = "lowDetail",
	mutenotifications = "notifications", hidenotifications = "notifications", donotdisturb = "notifications",
	quietmode = "notifications",
	hidehud = "showHud", hideui = "showHud", hideinterface = "showHud", cinematicmode = "showHud",
	screenshotmode = "showHud",
}

-- The built-in setting id `id` stands for ("Volume" -> "sfx", "fieldOfView"
-- -> "fov", "set_music" -> "music"), or nil for a custom setting. The second
-- result is true when the name means the opposite of the built-in
-- ("Screen Shake" -> "reducedMotion", true).
function Schema.builtinSetting(id: any): (string?, boolean)
	if typeof(id) ~= "string" then
		return nil, false
	end
	local function lookup(key: string): (string?, boolean)
		local found = Schema.SETTING_ALIASES[key]
		if found then
			return found, false
		end
		found = Schema.SETTING_INVERTED[key]
		return found, found ~= nil
	end
	local key = string.lower((string.gsub(id, "[%s_%-]", "")))
	local found, inverted = lookup(key)
	if found == nil then
		for _, prefix in { "settings", "setting", "set" } do
			if #key > #prefix and string.sub(key, 1, #prefix) == prefix then
				found, inverted = lookup(string.sub(key, #prefix + 1))
				if found then
					break
				end
			end
		end
	end
	return found, inverted
end

-- The colourblind mode an option names ("Red-Green" -> "Deuteranopia",
-- "None" -> "Off"), or nil when it names none.
function Schema.colorblindMode(value: any): string?
	if typeof(value) ~= "string" then
		return nil
	end
	local key = string.lower((string.gsub(value, "[%s_%-]", "")))
	if key == "" then
		return nil
	end
	for _, mode in Schema.COLORBLIND_MODES do
		if key == string.lower(mode) then
			return mode
		end
	end
	if key == "none" or key == "normal" or key == "disabled" or key == "default" or key == "false" then
		return "Off"
	elseif string.find(key, "deuter", 1, true) or string.find(key, "green", 1, true) then
		return "Deuteranopia"
	elseif string.find(key, "prot", 1, true) or string.find(key, "red", 1, true) then
		return "Protanopia"
	elseif string.find(key, "trit", 1, true) or string.find(key, "blue", 1, true) or string.find(key, "yellow", 1, true) then
		return "Tritanopia"
	end
	return nil
end

return Schema
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="79">
            <Properties>
              <string name="Name">Screens</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Blueprint.Screens: builds one screen of a blueprint.

		local screen = Screens.build(ctx, scope, def)
		screen.Open:set(true)

	Kinds:
	  Window / Fullscreen  one Window with a title (or a tab bar), sections in a
	                       scrolling column. SideTabs get a left rail. The window
	                       height shrinks to its content so short screens never
	                       look empty.
	  Popup                small modal, closes on backdrop click
	  MainMenu / Loading   full-screen, with the blueprint logo
	  Results / Trade      end-of-match card / two-sided trade window

	v1.4 (all optional; nil builds the v1.3 look):
	  MainMenu `layout`    Left (the prefab) | Center | Split | Corner, with the
	                       Banner at `bannerPosition` (Schema.MENU_BANNER)
	  Window chrome        `transition`, `panelStyle` and `scrim` override the
	                       style's for one Window / Fullscreen / Popup
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Motion = require(script.Parent.Parent.FX.Motion)
local Schema = require(script.Parent.Schema)
local Common = require(script.Parent.Common)
local Sections = require(script.Parent.Sections)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local Screens = {}

local PAD = 24 -- window body padding
local PAGE_GAP = 32 -- between sections
-- The page's scrolling frame reaches BLEED px past the body padding on every
-- side, so tags that stick out of a card, selection rings, lifted cards and
-- hover pops are never clipped by the scroll edge. SCROLL_PAD (top, right,
-- bottom, left) puts the content back where the padding says.
local BLEED = 16
local SCROLL_PAD = { BLEED + 4, BLEED + 12, BLEED, BLEED + 4 }
local TAB_HEIGHT = 50
local RAIL = 220 -- side tab rail width
-- A window whose content is only a little taller than its size grows (up to
-- this height) instead of hiding its last row under the fold. Content that
-- wouldn't fit even then keeps the standard size and scrolls.
local GROW_MAX = 820

-- Section kinds that read fine in a narrower window.
local NARROW_KINDS = Schema.set({ "Settings", "Codes", "Text", "Keybinds", "Party", "Buttons", "Stats", "Profile", "Leaderboard", "Daily", "Social" })

local function autoSize(def: { [string]: any }): string
	if def.size then
		return def.size
	end
	if def.kind == "Popup" then
		return "Small"
	end
	local all = {}
	for _, s in def.sections do
		table.insert(all, s)
	end
	for _, tab in def.tabs do
		for _, s in tab.sections do
			table.insert(all, s)
		end
	end
	if #all == 0 then
		return "Medium"
	end
	for _, s in all do
		if not NARROW_KINDS[s.kind] then
			return "Large"
		end
	end
	return "Medium"
end

-- Builds the scrolling page for a list of sections. Returns the page and the
-- height its content needs.
local function page(ctx: any, scope: any, sections: { any }, width: number, pageHeight: number, screenId: string, screenTitle: string?): (Instance, number)
	local blocks = {}
	local total = 0
	for index, s in sections do
		local used = total + (if index > 1 then PAGE_GAP else 0)
		local block, height = Sections.build(ctx, scope, s, width, {
			pageHeight = pageHeight,
			available = math.max(0, pageHeight - used),
			screen = screenId,
			screenTitle = screenTitle,
		})
		block.LayoutOrder = index
		table.insert(blocks, block)
		total = used + height
	end
	if #blocks == 0 then
		local empty = Common.card(scope, {
			Name = "Empty",
			Size = UDim2.new(1, 0, 0, 200),
			Children = scope:Column {
				Size = UDim2.fromScale(1, 1),
				Gap = 8,
				VAlign = "Center",
				ZIndex = 3,
				[Children] = {
					scope:Icon {
						Icon = "sparkle",
						Size = 56,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Accent
						end),
						LayoutOrder = 1,
						ZIndex = 3,
					},
					scope:Text { Text = "Coming soon!", TextSize = 28, Font = "Title", LayoutOrder = 2, ZIndex = 3 },
				},
			},
		})
		table.insert(blocks, empty)
		total = 200
	end
	local scroll = scope:Scroll {
		Name = "Page",
		Position = UDim2.fromOffset(-BLEED, -BLEED),
		Size = UDim2.new(1, BLEED * 2, 1, BLEED * 2),
		Gap = PAGE_GAP,
		Padding = SCROLL_PAD,
		[Children] = blocks,
	}
	return scroll, total + SCROLL_PAD[1] + SCROLL_PAD[3] - BLEED * 2
end

-- The height page() would return, worked out from the section definitions
-- without building anything, or nil when a section can't be measured.
local function measurePage(sections: { any }, width: number, pageHeight: number, screenId: string, screenTitle: string?): number?
	local total = 0
	for index, s in sections do
		local used = total + (if index > 1 then PAGE_GAP else 0)
		local height = Sections.measure(s, width, {
			pageHeight = pageHeight,
			available = math.max(0, pageHeight - used),
			screen = screenId,
			screenTitle = screenTitle,
		})
		if height == nil then
			return nil
		end
		total = used + height
	end
	if #sections == 0 then
		total = 200
	end
	return total + SCROLL_PAD[1] + SCROLL_PAD[3] - BLEED * 2
end
Screens.measurePage = measurePage

-- A page frame's children, built the first time `shown` turns true.
local function lazyChildren(scope: any, shown: any, build: () -> any): any
	local content = scope:Value(nil)
	local built = false
	local function ensure()
		if not built then
			built = true
			content:set(build())
		end
	end
	if peek(shown) then
		ensure()
	end
	Kit.watch(scope, shown, function(now)
		if now then
			ensure()
		end
	end)
	return content
end

-- v1.6: a tab holding rewards that wait shows how many (else its own badge).
local function tabBadge(ctx: any, scope: any, def: { [string]: any }, tab: { [string]: any }): any
	local rewards = ctx.ui.rewards
	local count = if rewards then rewards:claimable(def.id, tab.id) else nil
	if count == nil then
		return tab.badge
	end
	return scope:Computed(function(use)
		local n = use(count)
		return if n > 0 then n else tab.badge
	end)
end

-- Left rail of tabs for Nav = "SideTabs".
local function sideRail(ctx: any, scope: any, def: { [string]: any }, tabs: { any }, selected: any, height: number): Frame
	-- v2.4 style families: the family's side tabs (Components/TabBar)
	if Theme.get().Family ~= nil then
		local list = {}
		for _, tab in tabs do
			local claimable = tab.badge ~= nil or (ctx.ui.rewards and ctx.ui.rewards:claimable(def.id, tab.id))
			table.insert(list, {
				Id = tab.id,
				Text = tab.label,
				Icon = tab.icon,
				Badge = if claimable then tabBadge(ctx, scope, def, tab) else nil,
			})
		end
		return scope:TabBar {
			Name = "SideTabs",
			Tabs = list,
			Selected = selected,
			Vertical = true,
			TabSize = Vector2.new(RAIL, 60),
			Gap = 8,
		}
	end
	local buttons = {}
	for index, tab in tabs do
		local isSelected = scope:Computed(function(use)
			return use(selected) == tab.id
		end)
		local function choose()
			selected:set(tab.id)
		end
		table.insert(buttons, scope:New "TextButton" {
			Name = `Tab_{tab.id}`,
			LayoutOrder = index,
			Text = "",
			AutoButtonColor = false,
			Size = UDim2.new(1, 0, 0, 64),
			BackgroundColor3 = scope:Computed(function(use)
				local theme = use(Theme.current)
				return if use(isSelected) then theme.Color.Light else theme.Color.Surface
			end),
			BackgroundTransparency = scope:Computed(function(use)
				return if use(isSelected) then 0 else use(Theme.current).Alpha.Surface
			end),
			[OnEvent "Activated"] = choose,
			[Children] = {
				Kit.corner(scope, "M"),
				-- phones: a comfortable hit area (taller only: the rail is wide)
				Kit.touchPad(scope, UDim2.new(1, 0, 0, 64), { Activated = choose }, { Axes = "Y" }),
				scope:Icon {
					Icon = tab.icon,
					Size = 40,
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 14, 0.5, 0),
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Accent
					end),
					Gradient = true,
					Shadow = true,
					ZIndex = 2,
				},
				scope:Text {
					Text = tab.label,
					TextSize = 24,
					Font = "Heavy",
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 64, 0.5, 0),
					Size = UDim2.new(1, -76, 0, 30),
					-- a long name in a wide face ("ACCESSIBILITY") shrinks to fit
					Scaled = true,
					Wrapped = false,
					MaxTextSize = 24,
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						return if use(isSelected) then theme.Color.TextDark else theme.Color.Text
					end),
					StrokeColor = scope:Computed(function(use)
						local theme = use(Theme.current)
						return if use(isSelected) then theme.Color.Light else theme.Color.Outline
					end),
					ZIndex = 2,
				},
				if tab.badge ~= nil or (ctx.ui.rewards and ctx.ui.rewards:claimable(def.id, tab.id))
					then scope:Badge { Value = tabBadge(ctx, scope, def, tab), Position = UDim2.new(1, -8, 0, 6), ZIndex = 5 }
					else nil,
			},
		})
	end
	return scope:Column {
		Name = "SideTabs",
		Size = UDim2.fromOffset(RAIL, height),
		Gap = 10,
		[Children] = buttons,
	}
end

--------------------------------------------------------------------------
-- Window, Fullscreen, Popup
--------------------------------------------------------------------------

local function windowScreen(ctx: any, scope: any, def: { [string]: any }, parent: Instance): { [string]: any }
	local open = scope:Value(false)
	local popup = def.kind == "Popup"
	local sizeName = autoSize(def)
	local preset = Schema.WINDOW_SIZE[sizeName]
	local maxW, maxH = preset[1], preset[2]
	if def.kind == "Fullscreen" then
		maxW, maxH = 1600, 900
	end
	-- Components/Window clamps itself to the screen (below the top bar); plan
	-- the pages for that height too, so short screens (phones) get sections
	-- sized for what's really visible instead of a page cut off at the fold
	local design = peek(Viewport.designSize)
	local fitH = math.floor(design.Y - (peek(Kit.topbarHeight) / math.max(0.05, peek(Viewport.scale)) + 24) - 24)
	local fitW = math.floor(design.X - 48)
	local growMax = math.min(GROW_MAX, fitH)
	maxH = math.max(320, math.min(maxH, fitH))
	maxW = math.max(480, math.min(maxW, fitW))

	-- v2.4 style families: the screen's `dock` (Schema.SCREEN_DOCKS; the
	-- generator sends it to plugins >= 2.4.0 only, classic themes ignore it).
	-- Right / Left are side panels on that edge, Wide a showroom as wide as
	-- the screen allows (up to 1440), Sheet a sheet on the bottom edge (full
	-- width on short, phone screens). All are as tall as their content (up
	-- to the screen below the top bar).
	local nav = def.nav
	local windowDock = nil
	local docked = false
	if Theme.get().Family ~= nil and not popup and def.kind == "Window" then
		local dock = def.dock
		local short = design.Y < 900
		local band = fitH + 8 -- Components/Window: the screen below the top bar, less a 16 px margin
		-- (phones: a side panel would be a narrow strip with its tabs cut off,
		-- so it becomes the bottom sheet)
		if short and (dock == "Right" or dock == "Left") then
			dock = "Sheet"
		end
		if dock == "Right" or dock == "Left" then
			maxW = math.clamp(math.floor(design.X * 0.42), 680, 860)
			maxH = band
			growMax = band
			windowDock = dock
			docked = true
			-- a side panel is too narrow for a side rail: its tabs go on top
			if nav == "SideTabs" then
				nav = "Tabs"
			end
		elseif dock == "Wide" then
			maxW = math.floor(math.min(design.X - 48, 1440))
			docked = true
		elseif dock == "Sheet" then
			maxW = if short then math.floor(design.X - 32) else math.min(maxW, 1100)
			maxH = fitH + 24
			growMax = maxH
			windowDock = "Bottom"
			docked = true
		end
	end

	local tabs = def.tabs
	local topTabs = nav == "Tabs" and #tabs > 0
	local sideTabs = nav == "SideTabs" and #tabs > 0
	-- Window geometry (Components/Window): a tab row above the body, or a
	-- title bar inside it (shorter in Ribbon themes, where the title is a ribbon)
	local theme = Theme.get()
	-- v1.4: a screen may override the theme's panel style (and so the header)
	local panelStyle = def.panelStyle or (typeof(theme.Style) == "table" and theme.Style.PanelStyle or nil)
	local ribbon = panelStyle == "Ribbon"
	local header = if topTabs then TAB_HEIGHT + 34 elseif ribbon then 52 else 92
	local bodyW = maxW - PAD * 2
	local contentW = bodyW + BLEED * 2 - SCROLL_PAD[2] - SCROLL_PAD[4] - (if sideTabs then RAIL + PAD else 0)
	local pageHeight = maxH - header - PAD * 2 - (SCROLL_PAD[1] + SCROLL_PAD[3] - BLEED * 2)

	local selected = scope:Value(if #tabs > 0 then tabs[1].id else nil)
	local pages = {}
	local tallest = 0
	local heights = {} -- page height per tab (v2.4 families fit the window to the selected one)
	if #tabs > 0 then
		for index, tab in tabs do
			-- a tab's label is its page title (the window shows tabs, not a title)
			local title = if #tab.sections == 1 then tab.label else nil
			-- Hidden tabs whose height can be worked out from their sections
			-- are built the first time they're selected; the rest are built
			-- now so the window can be sized to its tallest page.
			local check = Screens._checkMeasure -- tests: build everything, compare
			local measured = if index > 1 or check then measurePage(tab.sections, contentW, pageHeight, def.id, title) else nil
			if measured and not check then
				pages[tab.id] = function()
					return (page(ctx, scope, tab.sections, contentW, pageHeight, def.id, title))
				end
				tallest = math.max(tallest, measured)
				heights[tab.id] = measured
			else
				local content, height = page(ctx, scope, tab.sections, contentW, pageHeight, def.id, title)
				pages[tab.id] = content
				tallest = math.max(tallest, height)
				heights[tab.id] = height
				if check and measured then
					check(def.id, tab.id, measured, height)
				end
			end
		end
	else
		local content, height = page(ctx, scope, def.sections, contentW, pageHeight, def.id, def.title)
		pages.main = content
		tallest = height
		local check = Screens._checkMeasure
		local measured = if check then measurePage(def.sections, contentW, pageHeight, def.id, def.title) else nil
		if check and measured then
			check(def.id, "main", measured, height)
		end
	end
	if sideTabs then
		tallest = math.max(tallest, #tabs * 74)
	end

	-- v2.4 style families: windows fit their content instead of a 400 px
	-- floor, and a tabbed window follows its selected tab's height (a short
	-- Settings tab no longer sits in a window sized for the tallest one).
	-- Classic themes keep the v2.3 sizing.
	local family = theme.Family ~= nil
	local minH = if popup then 300 else 400
	if family then
		minH = if popup then 240 else 300
	end
	local function fit(pageH: number): number
		-- +8: rounding slack so a page that fits never shows a scrollbar
		local wanted = header + PAD * 2 + pageH + 8
		local limit = if wanted > maxH and wanted <= growMax then wanted else maxH
		return math.clamp(wanted, minH, limit)
	end
	local height = fit(tallest)
	local fitHeight = nil
	if family and #tabs > 1 then
		local rail = if sideTabs then #tabs * 74 else 0
		fitHeight = scope:Computed(function(use)
			local id = use(selected)
			return fit(math.max(heights[id] or tallest, rail))
		end)
	end

	local windowPages = nil
	local body = nil
	local windowTabs = nil
	if topTabs then
		windowTabs = {}
		-- wide enough for the longest label (TabBar sets labels at 0.52x the
		-- tab height, in capitals when the theme says so), within the row
		local fits = math.floor((maxW - 110 - #tabs * 14) / #tabs)
		local widest = 0
		for _, tab in tabs do
			widest = math.max(widest, Common.textWidth(string.upper(tab.label or ""), TAB_HEIGHT * 0.52))
		end
		local tabWidth = math.clamp(math.max(math.min(fits, 150), widest + 28), 110, math.max(110, math.min(fits, 210)))
		for _, tab in tabs do
			table.insert(windowTabs, {
				Id = tab.id,
				Text = tab.label,
				Icon = tab.icon,
				Badge = tabBadge(ctx, scope, def, tab),
				Callout = tab.callout,
			})
		end
		windowPages = {}
		for id, content in pages do
			-- functions are lazy pages (Window LazyPages builds them on select)
			windowPages[id] = if typeof(content) == "function"
				then content
				else function()
					return content
				end
		end
		def._tabSize = Vector2.new(tabWidth, TAB_HEIGHT)
	elseif sideTabs then
		local frames = {}
		for id, content in pages do
			local shown = scope:Computed(function(use)
				return use(selected) == id
			end)
			table.insert(frames, scope:New "Frame" {
				Name = `Page_{id}`,
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				Visible = shown,
				[Children] = if typeof(content) == "function" then lazyChildren(scope, shown, content) else content,
			})
		end
		body = {
			sideRail(ctx, scope, def, tabs, selected, height - header - PAD * 2),
			scope:New "Frame" {
				Name = "Pages",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(RAIL + PAD, 0),
				Size = UDim2.new(1, -(RAIL + PAD), 1, 0),
				[Children] = frames,
			},
		}
	else
		body = pages.main
	end

	local window = scope:Window {
		Name = def.id,
		Open = open,
		Size = Vector2.new(maxW, height),
		FitHeight = fitHeight, -- v2.4 families: follows the selected tab
		Dock = windowDock, -- v2.4 families: Right / Left drawer, Full, Bottom sheet
		Title = if topTabs then nil else def.title,
		Icon = if topTabs then nil else def.icon, -- the tab bar is the header
		Tabs = windowTabs,
		TabSize = def._tabSize,
		Selected = selected,
		Pages = windowPages,
		LazyPages = true,
		Panel = true,
		Padding = PAD,
		Blur = not ctx.preview,
		-- v1.4 chrome overrides (nil: the theme's)
		Transition = def.transition,
		PanelStyle = def.panelStyle,
		Scrim = def.scrim,
		CloseOnBackdrop = popup,
		OnClose = function()
			ctx.ui:close(def.id)
		end,
		ZIndex = 10,
		Parent = parent,
		[Children] = body,
	}
	return {
		Open = open,
		Gui = window,
		Selected = selected,
		Size = Vector2.new(maxW, height),
		-- big windows tuck the HUD away while they're open (Blueprint.build)
		-- (popups too when they're that big: a full scoreboard hides the match)
		-- (v2.4 docked windows sit on the HUD's edges: they always tuck it)
		Modal = { tuck = docked or def.kind == "Fullscreen" or maxW >= 1100 or height > 640 or height >= fitH - 60, width = maxW, height = height, dock = windowDock },
	}
end

--------------------------------------------------------------------------
-- Logo (component or text fallback)
--------------------------------------------------------------------------

-- Blueprint.applyStyle keeps a blackletter / script title face in
-- theme.Font.Ornate: the logo is one of the few places allowed to use it.
local function ornateFont(): Font?
	local fonts = Theme.get().Font
	return if fonts then fonts.Ornate else nil
end

-- Logo props for the MainMenu / LoadingScreen prefabs.
local function logoProps(ctx: any): { [string]: any }
	local logo = ctx.bp.logo
	return { Text = logo.text, Subtitle = logo.subtitle, Type = logo.type, Icon = logo.icon, Tone = logo.tone, Tilt = logo.tilt, Font = ornateFont() }
end

-- `extra`: more Logo props (Transparency, AnchorPoint, Position...).
function Screens.logo(ctx: any, scope: any, logo: { [string]: any }, height: number, extra: { [string]: any }?): Instance
	local Logo = Common.component("Logo")
	if Logo then
		local props = {
			Text = logo.text,
			Subtitle = logo.subtitle,
			Type = logo.type,
			Icon = logo.icon,
			Tone = logo.tone,
			Tilt = logo.tilt,
			Height = height,
			Font = ornateFont(),
		}
		for key, value in extra or {} do
			props[key] = value
		end
		local built = Common.try(Logo, scope, props)
		if built then
			return built
		end
	end
	local titleSize = math.floor(height * 0.52)
	return scope:New "Frame" {
		Name = "Logo",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(math.max(420, Common.textWidth(logo.text, titleSize) + 40), height),
		Rotation = logo.tilt or 0,
		[Children] = {
			scope:Column {
				Size = UDim2.fromScale(1, 1),
				Gap = 4,
				VAlign = "Center",
				[Children] = {
					if logo.icon
						then scope:Icon {
							Icon = logo.icon,
							Size = math.floor(height * 0.3),
							Color = Kit.themed(scope, function(theme)
								return Theme.tone(theme, logo.tone).Top
							end),
							Gradient = true,
							Shadow = true,
							LayoutOrder = 1,
						}
						else nil,
					scope:Text {
						Name = "Title",
						Text = logo.text,
						TextSize = titleSize,
						Font = ornateFont() or "Title",
						Gradient = logo.tone,
						Stroke = 6,
						StrokeColor = Kit.themed(scope, function(theme)
							return Theme.tone(theme, logo.tone).Deep
						end),
						LayoutOrder = 2,
					},
					if logo.subtitle
						then Common.pill(scope, {
							Text = logo.subtitle,
							Tone = "Dark",
							Height = math.floor(height * 0.2),
							LayoutOrder = 3,
						})
						else nil,
				},
			},
		},
	}
end

--------------------------------------------------------------------------
-- v2.4 style-family title backdrops (theme.Family.Backdrop.Kind), all in
-- the palette's own colours: Sunburst (toy sky + rays), Aurora (dark +
-- soft colour fields), Synth (neon horizon + grid), Blobs (pastel clay),
-- Paper (ruled notebook), Velvet (royal violet + gold), Pixel (navy +
-- pixel stars + scanlines), Burst (comic rays + halftone), Dark (horror),
-- Holo (grid + ring), Night (anime night + streaks), Stadium (esports
-- slabs + team glows), Wood (tavern), Candy (kawaii polka).
--------------------------------------------------------------------------

local WHITE = Color3.new(1, 1, 1)

local function themedColor(scope: any, read: (any) -> Color3)
	return Kit.themed(scope, read)
end

local function softGlow(scope: any, name: string, read: (any) -> Color3, position: UDim2, size: Vector2, alpha: number, z: number?): Instance
	return Kit.glow(scope, {
		Name = name,
		Color = themedColor(scope, read),
		Transparency = alpha,
		Position = position,
		Size = UDim2.fromOffset(size.X, size.Y),
		ZIndex = z or 2,
	})
end

-- Small square "pixel stars" / confetti at fixed pseudo-random spots.
local function specks(scope: any, count: number, seed: number, sizes: { number }, read: (any) -> Color3, alpha: number, z: number, round: boolean?): { Instance }
	local out = {}
	local state = seed
	local function rand()
		state = (state * 16807) % 2147483647
		return state / 2147483647
	end
	for i = 1, count do
		local size = sizes[(i % #sizes) + 1]
		table.insert(out, scope:New "Frame" {
			Name = "Speck",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.03 + rand() * 0.94, 0.04 + rand() * 0.9),
			Size = UDim2.fromOffset(size, size),
			Rotation = if round then 0 else (if i % 3 == 0 then 45 else 0),
			BackgroundColor3 = themedColor(scope, read),
			BackgroundTransparency = alpha + rand() * (1 - alpha) * 0.5,
			BorderSizePixel = 0,
			ZIndex = z,
			[Children] = if round then Kit.corner(scope, "Full") else nil,
		})
	end
	return out
end

-- The Sunburst sky colour: a palette colour whose hue sits well away from the
-- primary (the Play button and title colour), cool ones first, so the call to
-- action never sinks into the backdrop. No such colour: a sky blue (or violet
-- when the primary itself is blue).
local function sunburstSky(theme: any): Color3
	local c = theme.Color
	local Ok = Theme.Oklch
	local _, _, ph = Ok.fromColor(c.Primary or c.Accent)
	local best, bestScore = nil, -math.huge
	for i, color in { c.Secondary, c.Accent, c.Info } do
		if color ~= nil then
			local _, C, h = Ok.fromColor(color)
			local away = math.abs(Ok.hueDelta(ph, h))
			if C > 0.06 and away > 60 then
				local cool = if h >= 150 and h <= 310 then 2 else 0
				local score = cool + away / 180 - i * 0.1
				if score > bestScore then
					best, bestScore = color, score
				end
			end
		end
	end
	if best == nil then
		best = if math.abs(Ok.hueDelta(ph, 250)) > 60 then Color3.fromHex("2F8FFF") else Color3.fromHex("8A5CFF")
	end
	return best
end

local function familyBackdrop(scope: any, fam: any, focus: UDim2?): Frame
	local kind = (fam.Backdrop and fam.Backdrop.Kind) or "Aurora"
	local at = focus or UDim2.fromScale(0.5, 0.32)
	local layers = {}
	local function add(item: any)
		if item ~= nil then
			table.insert(layers, item)
		end
	end
	-- base gradient (top -> bottom), per kind
	local base = Kit.themed(scope, function(theme)
		local c = theme.Color
		local t = theme.Tone
		local Ok = Theme.Oklch
		local function seq(...: Color3)
			local list = { ... }
			local keys = {}
			for i, color in list do
				table.insert(keys, ColorSequenceKeypoint.new((i - 1) / math.max(1, #list - 1), color))
			end
			return ColorSequence.new(keys)
		end
		if kind == "Sunburst" then
			local sky = sunburstSky(theme)
			return seq(Ok.adjust(sky, 0.76, nil), Ok.adjust(sky, 0.6, nil), Ok.adjust(sky, 0.44, nil))
		elseif kind == "Candy" then
			return seq(Ok.adjust((c.Primary or c.Accent), 0.94, 0.05), Ok.adjust((c.Primary or c.Accent), 0.86, 0.09), Ok.adjust((c.Primary or c.Accent), 0.78, 0.12))
		elseif kind == "Blobs" then
			return seq(Ok.adjust((c.Primary or c.Accent), 0.96, 0.03), Ok.adjust((c.Secondary or c.Info or c.Accent), 0.92, 0.05))
		elseif kind == "Paper" then
			return seq(c.Panel, Ok.adjust(c.Panel, 0.9, nil))
		elseif kind == "Burst" then
			return seq(t.Yellow.Top, t.Yellow.Mid or t.Yellow.Bottom)
		elseif kind == "Velvet" then
			return seq(Ok.adjust((c.Primary or c.Accent), 0.3, 0.1), Ok.adjust((c.Primary or c.Accent), 0.16, 0.06), c.Backdrop)
		elseif kind == "Synth" then
			return seq(c.Backdrop, Ok.adjust((c.Primary or c.Accent), 0.22, 0.12), c.Backdrop)
		elseif kind == "Night" then
			return seq(Ok.adjust(c.Surface, 0.24, 0.1), Ok.adjust((c.Secondary or c.Info or c.Accent), 0.3, 0.11), Ok.adjust(c.Surface, 0.16, 0.08))
		elseif kind == "Wood" then
			return seq(c.Wood or c.Surface, c.WoodDark or c.Backdrop)
		elseif kind == "Stadium" or kind == "Holo" or kind == "Pixel" then
			return seq(Ok.adjust(c.Surface, 0.26, nil), c.Backdrop)
		elseif kind == "Dark" then
			return seq(c.Backdrop, Color3.new(0, 0, 0))
		end
		-- Aurora
		return seq(c.Surface, c.Backdrop)
	end)
	local light = kind == "Candy" or kind == "Blobs" or kind == "Paper" or kind == "Burst"

	if kind == "Sunburst" or kind == "Burst" then
		local rays = Kit.rays(scope, {
			Name = "Rays",
			Color = if kind == "Burst" then themedColor(scope, function(theme)
				return theme.Tone.Red.Mid or theme.Tone.Red.Bottom
			end) else WHITE,
			Count = if kind == "Burst" then 26 else 20,
			Width = if kind == "Burst" then 0.16 else 0.1,
			Transparency = if kind == "Burst" then 0.45 else 0.8,
			Size = UDim2.fromOffset(2800, 2800),
			Position = at,
			ZIndex = 2,
		})
		pcall(function()
			table.insert(scope, Motion.spin(rays, if kind == "Burst" then 8 else 50))
		end)
		add(rays)
	end
	if kind == "Sunburst" then
		add(softGlow(scope, "Sun", function()
			return WHITE
		end, at, Vector2.new(900, 900), 0.55))
		add(Kit.texture(scope, { Kind = "Dots", Light = false, Color = WHITE, ZIndex = 3 }))
	elseif kind == "Candy" then
		add(Kit.texture(scope, { Kind = "Dots", Light = true, Color = WHITE, ZIndex = 2 }))
		add(softGlow(scope, "GlowA", function()
			return WHITE
		end, UDim2.fromScale(0.25, 0.3), Vector2.new(1100, 900), 0.5))
		add(softGlow(scope, "GlowB", function(theme)
			return (theme.Color.Secondary or theme.Color.Info or theme.Color.Accent)
		end, UDim2.fromScale(0.85, 0.75), Vector2.new(1200, 1000), 0.6))
		add(Kit.decoration(scope, { Decoration = "Sparkles", Color = WHITE, Light = false, ZIndex = 3 }))
	elseif kind == "Blobs" then
		add(softGlow(scope, "BlobA", function(theme)
			return (theme.Color.Primary or theme.Color.Accent)
		end, UDim2.fromScale(0.2, 0.25), Vector2.new(1300, 1100), 0.45))
		add(softGlow(scope, "BlobB", function(theme)
			return (theme.Color.Secondary or theme.Color.Info or theme.Color.Accent)
		end, UDim2.fromScale(0.82, 0.7), Vector2.new(1400, 1200), 0.45))
		add(softGlow(scope, "BlobC", function(theme)
			return theme.Color.Accent
		end, UDim2.fromScale(0.62, 0.12), Vector2.new(900, 700), 0.55))
	elseif kind == "Paper" then
		add(Kit.texture(scope, { Kind = "Paper", Light = true, Transparency = 0.8, ZIndex = 2 }))
		add(softGlow(scope, "Stain", function()
			return Color3.fromHex("B08A50")
		end, UDim2.fromScale(0.86, 0.8), Vector2.new(520, 420), 0.88))
		add(Kit.decoration(scope, { Decoration = "Sparkles", Color = Color3.fromHex("2B2A33"), Light = true, ZIndex = 3 }))
	elseif kind == "Burst" then
		add(Kit.texture(scope, { Kind = "Halftone", Light = true, Transparency = 0.7, ZIndex = 3 }))
	elseif kind == "Velvet" then
		add(Kit.texture(scope, { Kind = "Damask", Light = false, Color = Color3.fromHex("F5C84C"), Transparency = 0.94, ZIndex = 2 }))
		add(softGlow(scope, "Gold", function(theme)
			return theme.Color.Accent
		end, at, Vector2.new(1500, 1100), 0.72))
	elseif kind == "Synth" then
		add(scope:New "Frame" {
			Name = "Floor",
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.fromScale(0, 1),
			Size = UDim2.fromScale(1, 0.4),
			BackgroundTransparency = 1,
			ZIndex = 2,
			[Children] = Kit.texture(scope, { Kind = "Grid", Light = false, Color = WHITE, Transparency = 0.82, ZIndex = 2 }),
		})
		add(softGlow(scope, "Sun", function(theme)
			return (theme.Color.Primary or theme.Color.Accent)
		end, UDim2.fromScale(0.5, 0.58), Vector2.new(1100, 700), 0.35))
		add(scope:New "Frame" {
			Name = "Horizon",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.6),
			Size = UDim2.new(1, 0, 0, 4),
			BackgroundColor3 = WHITE,
			BorderSizePixel = 0,
			ZIndex = 3,
			[Children] = scope:New "UIGradient" {
				Color = Kit.themed(scope, function(theme)
					return ColorSequence.new((theme.Color.Primary or theme.Color.Accent), (theme.Color.Secondary or theme.Color.Info or theme.Color.Accent))
				end),
			},
		})
		add(Kit.texture(scope, { Kind = "Scanlines", Light = false, Color = WHITE, Transparency = 0.95, ZIndex = 4 }))
	elseif kind == "Night" then
		add(softGlow(scope, "Moon", function(theme)
			return (theme.Color.Primary or theme.Color.Accent)
		end, at, Vector2.new(1300, 1000), 0.62))
		add(Kit.texture(scope, { Kind = "Streaks", Light = false, Color = WHITE, Transparency = 0.86, ZIndex = 2 }))
		add(specks(scope, 60, 5, { 2, 3, 4 }, function()
			return WHITE
		end, 0.35, 3, true))
		add(Kit.decoration(scope, { Decoration = "Sparkles", Color = WHITE, Light = false, ZIndex = 3 }))
	elseif kind == "Holo" then
		add(Kit.texture(scope, { Kind = "Grid", Light = false, Color = Color3.fromHex("3FE0FF"), Transparency = 0.9, ZIndex = 2 }))
		add(softGlow(scope, "Core", function(theme)
			return theme.Color.Trim
		end, at, Vector2.new(1200, 1000), 0.72))
		add(Kit.texture(scope, { Kind = "Scanlines", Light = false, Color = WHITE, Transparency = 0.95, ZIndex = 3 }))
		add(scope:New "Frame" {
			Name = "Sweep",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.fromScale(0, 0.3),
			Size = UDim2.new(1, 0, 0, 2),
			BackgroundColor3 = themedColor(scope, function(theme)
				return theme.Color.Trim
			end),
			BackgroundTransparency = 0.4,
			BorderSizePixel = 0,
			ZIndex = 3,
			[Children] = scope:New "UIGradient" {
				Transparency = NumberSequence.new({
					NumberSequenceKeypoint.new(0, 1),
					NumberSequenceKeypoint.new(0.5, 0),
					NumberSequenceKeypoint.new(1, 1),
				}),
			},
		})
	elseif kind == "Stadium" then
		add(Kit.texture(scope, { Kind = "Stripes", Light = false, Color = WHITE, ZIndex = 2 }))
		add(softGlow(scope, "TeamA", function(theme)
			return (theme.Color.Primary or theme.Color.Accent)
		end, UDim2.fromScale(0.12, 0.55), Vector2.new(1300, 1300), 0.62))
		add(softGlow(scope, "TeamB", function(theme)
			return (theme.Color.Secondary or theme.Color.Info or theme.Color.Accent)
		end, UDim2.fromScale(0.9, 0.5), Vector2.new(1300, 1300), 0.66))
		add(scope:New "Frame" {
			Name = "VoltBar",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.92),
			Size = UDim2.new(1.4, 0, 0, 10),
			Rotation = -4,
			BackgroundColor3 = themedColor(scope, function(theme)
				return theme.Color.Accent
			end),
			BorderSizePixel = 0,
			ZIndex = 3,
		})
	elseif kind == "Pixel" then
		add(Kit.texture(scope, { Kind = "Scanlines", Light = false, Color = Color3.new(0, 0, 0), Transparency = 0.8, ZIndex = 4 }))
		add(specks(scope, 46, 9, { 4, 8, 4, 12 }, function(theme)
			return theme.Color.Accent
		end, 0.2, 2))
		add(specks(scope, 30, 17, { 4, 8 }, function()
			return WHITE
		end, 0.35, 2))
	elseif kind == "Dark" then
		add(softGlow(scope, "Ember", function(theme)
			return theme.Color.Bad
		end, UDim2.fromScale(0.5, 1.05), Vector2.new(1900, 900), 0.82))
		add(Kit.texture(scope, { Kind = "Grain", Light = false, Color = Color3.fromHex("D9D4C7"), ZIndex = 3 }))
	elseif kind == "Wood" then
		add(Kit.texture(scope, { Kind = "Wood", Transparency = 0.55, ZIndex = 2 }))
		add(softGlow(scope, "Candle", function(theme)
			return theme.Color.Accent
		end, at, Vector2.new(1500, 1100), 0.7))
	else -- Aurora
		add(softGlow(scope, "FieldA", function(theme)
			return (theme.Color.Primary or theme.Color.Accent)
		end, UDim2.fromScale(0.18, 0.2), Vector2.new(1500, 1200), 0.72))
		add(softGlow(scope, "FieldB", function(theme)
			return (theme.Color.Secondary or theme.Color.Info or theme.Color.Accent)
		end, UDim2.fromScale(0.86, 0.85), Vector2.new(1500, 1200), 0.78))
		add(Kit.texture(scope, { Kind = "Grid", Light = false, Color = WHITE, Transparency = 0.965, ZIndex = 2 }))
	end

	return scope:New "Frame" {
		Name = "Backdrop",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = WHITE,
		ClipsDescendants = true,
		ZIndex = 0,
		[Children] = {
			scope:New "UIGradient" { Rotation = 90, Color = base },
			layers,
			scope:Vignette {
				Intensity = if light then 0.25 elseif kind == "Dark" then 0.85 else 0.55,
				Style = "Soft",
				ZIndex = 5,
			},
		},
	}
end
Screens.familyBackdrop = familyBackdrop

-- Full-screen themed backdrop with a big faded emblem.
local function fullBackdrop(ctx: any, scope: any, emblem: string?, children: any): Frame
	-- v2.4 style families: the family's title backdrop
	local fam = Theme.get().Family
	if fam ~= nil then
		local frame = familyBackdrop(scope, fam, UDim2.fromScale(0.5, 0.45))
		frame.Name = "Background"
		if children ~= nil then
			local holder = scope:New "Frame" {
				Name = "Content",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				ZIndex = 6,
				[Children] = children,
			}
			holder.Parent = frame
		end
		return frame
	end
	return scope:New "Frame" {
		Name = "Background",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Color3.new(1, 1, 1),
		ClipsDescendants = true,
		[Children] = {
			Kit.gradient(scope, {
				Rotation = 35,
				Color = Kit.themed(scope, function(theme)
					local base = theme.Color.Surface
					return ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color.lighten(base, 0.12)),
						ColorSequenceKeypoint.new(0.55, base),
						ColorSequenceKeypoint.new(1, Color.darken(base, 0.5)),
					})
				end),
			}),
			scope:Icon {
				Name = "Emblem",
				Icon = emblem or "star",
				Size = 900,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.82, 0.62),
				Rotation = -14,
				Color = Kit.themed(scope, function(theme)
					return theme.Color.Accent
				end),
				Transparency = 0.9,
				Stroke = false,
			},
			scope:Vignette { Intensity = 0.55, Style = "Soft", ZIndex = 1 },
			children,
		},
	}
end

-- Visibility + a gentle fade/scale for full-screen screens.
local function appear(scope: any, open: any): (any, any)
	local progress = scope:Spring(scope:Computed(function(use)
		return if use(open) then 1 else 0
	end), 22, 0.85)
	local visible = scope:Computed(function(use)
		return use(open) or use(progress) > 0.02
	end)
	return visible, progress
end

--------------------------------------------------------------------------
-- v1.4 menu layouts: Center, Split, Corner. "Left" (or no layout) is the
-- MainMenu prefab, exactly as in v1.3.
--------------------------------------------------------------------------

local RAY_IMAGE = "rbxasset://textures/ui/Emotes/Large/SelectedGradient.png"

-- A backdrop colour that makes a logo of each tone pop (as Prefabs/MainMenu).
local CONTRAST = {
	Yellow = "Blue", Orange = "Blue", Green = "Blue", Red = "Purple", Pink = "Purple", Purple = "Blue",
	Blue = "Purple", Cyan = "Purple", White = "Blue", Gray = "Dark", Dark = "Dark",
}

-- One 0..1 spring per delay: each rises `delay` seconds after the screen
-- opens; all of them drop together when it closes.
local function entrance(scope: any, open: any, delays: { number }): { any }
	local entries = {}
	local springs = {}
	for index, delay in delays do
		local value = scope:Value(0)
		entries[index] = { value = value, delay = delay }
		springs[index] = scope:Spring(value, 18, 0.72)
	end
	local alive = true
	table.insert(scope, function()
		alive = false
	end)
	local generation = 0
	Kit.watch(scope, open, function(now)
		generation += 1
		local mine = generation
		for _, entry in entries do
			if not now then
				entry.value:set(0)
			elseif entry.delay <= 0 then
				entry.value:set(1)
			else
				task.delay(entry.delay, function()
					if alive and generation == mine then
						entry.value:set(1)
					end
				end)
			end
		end
	end)
	return springs
end

-- A frame that animates in with `spring`: slides from `from` (an offset,
-- px) and/or pops from nothing. Its child fills it.
local function slot(scope: any, props: { [string]: any }): Frame
	local base: UDim2 = props.Position
	local from: Vector2? = props.From
	return scope:New "Frame" {
		Name = props.Name,
		BackgroundTransparency = 1,
		AnchorPoint = props.AnchorPoint or Vector2.zero,
		Position = scope:Computed(function(use)
			if from == nil then
				return base
			end
			local t = use(props.Spring)
			return base + UDim2.fromOffset(math.floor(from.X * (1 - t)), math.floor(from.Y * (1 - t)))
		end),
		Size = props.Size,
		ZIndex = props.ZIndex or 5,
		[Children] = {
			if props.Pop
				then scope:New "UIScale" {
					Scale = scope:Computed(function(use)
						return math.max(0, use(props.Spring))
					end),
				}
				else nil,
			props.Child,
		},
	}
end

-- A design box (w x h) centred in the space below the Roblox top bar and
-- scaled to fit it, so a layout keeps its proportions on every screen.
local function stage(scope: any, name: string, w: number, h: number, children: any): Frame
	local safeTop = Kit.safeTop(scope, 72)
	local fit = scope:Computed(function(use)
		local design = use(Viewport.designSize)
		local top = use(safeTop)
		local available = design.Y - top - 24
		local s = math.clamp(math.min((design.X - 64) / w, available / h), 0.4, 1.2)
		return { scale = s, y = top + available / 2 }
	end)
	return scope:New "Frame" {
		Name = name,
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = scope:Computed(function(use)
			return UDim2.new(0.5, 0, 0, math.floor(use(fit).y))
		end),
		Size = UDim2.fromOffset(w, h),
		ZIndex = 5,
		[Children] = {
			scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					return use(fit).scale
				end),
			},
			children,
		},
	}
end

-- The menu's backdrop. "Full": the theme surface tinted toward a colour
-- that makes the logo pop, slow light rays, a faint wallpaper of the logo
-- icon and a vignette. "Lobby": no fill at all (the 3D lobby shows), just a
-- shade along the left edge and the floor so the buttons read.
local function menuBackdrop(ctx: any, scope: any, mode: string, rayCentre: UDim2?, strength: number?): Frame
	-- v2.4 style families: the family's own backdrop recipe
	local fam = Theme.get().Family
	if fam ~= nil and mode ~= "Lobby" then
		return familyBackdrop(scope, fam, rayCentre)
	end
	local logo = ctx.bp.logo
	local tint = CONTRAST[logo.tone] or "Blue"
	local amount = if ctx.bp.style.archetype == "Horror" then 0.12 else (strength or 0.5)
	if mode == "Lobby" then
		local shade = Kit.themed(scope, function(theme)
			return Color.darken(theme.Color.Surface, 0.25)
		end)
		return scope:New "Frame" {
			Name = "Backdrop",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			[Children] = {
				scope:New "Frame" {
					Name = "LeftShade",
					Size = UDim2.fromScale(0.6, 1),
					BackgroundColor3 = shade,
					[Children] = Kit.gradient(scope, {
						Rotation = 0,
						Transparency = NumberSequence.new({
							NumberSequenceKeypoint.new(0, 0.1),
							NumberSequenceKeypoint.new(0.5, 0.5),
							NumberSequenceKeypoint.new(1, 1),
						}),
					}),
				},
				scope:New "Frame" {
					Name = "Floor",
					AnchorPoint = Vector2.new(0, 1),
					Position = UDim2.fromScale(0, 1),
					Size = UDim2.fromScale(1, 0.4),
					BackgroundColor3 = shade,
					[Children] = Kit.gradient(scope, {
						Rotation = 90,
						Transparency = NumberSequence.new({
							NumberSequenceKeypoint.new(0, 1),
							NumberSequenceKeypoint.new(1, 0.3),
						}),
					}),
				},
			},
		}
	end
	local toneState = Kit.themed(scope, function(theme)
		return Theme.tone(theme, tint)
	end)
	local rays = {}
	local count = 16
	local size = 2600
	for i = 1, count do
		local angle = (i - 1) / count * 360
		local rad = math.rad(angle)
		local length = size / 2
		table.insert(rays, scope:New "ImageLabel" {
			Name = "Ray",
			BackgroundTransparency = 1,
			Image = RAY_IMAGE,
			ImageColor3 = scope:Computed(function(use)
				return Color.lighten(use(toneState).Top, 0.6)
			end),
			ImageTransparency = 0.88,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5, math.cos(rad) * length / 2, 0.5, math.sin(rad) * length / 2),
			Size = UDim2.fromOffset(length, 2 * length * math.tan(math.rad(180 / count * 0.5))),
			Rotation = angle,
			ZIndex = 2,
		})
	end
	local rayFrame = scope:New "Frame" {
		Name = "Rays",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = rayCentre or UDim2.fromScale(0.5, 0.3),
		Size = UDim2.fromOffset(size, size),
		ZIndex = 2,
		[Children] = rays,
	}
	pcall(function()
		table.insert(scope, Motion.spin(rayFrame, 60))
	end)
	local motifs = {}
	for row = 0, 5 do
		for column = 0, 9 do
			table.insert(motifs, scope:Icon {
				Name = "Motif",
				Icon = logo.icon or "star",
				Size = 84,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromOffset(column * 240 + (row % 2) * 120, row * 240),
				Rotation = if (row + column) % 2 == 0 then -14 else 12,
				Color = scope:Computed(function(use)
					return Color.lighten(use(toneState).Top, 0.4)
				end),
				Transparency = 0.9,
				Stroke = false,
				ZIndex = 3,
			})
		end
	end
	return scope:New "Frame" {
		Name = "Backdrop",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Color3.new(1, 1, 1),
		ClipsDescendants = true,
		[Children] = {
			Kit.gradient(scope, {
				Rotation = 35,
				Color = Kit.themed(scope, function(theme)
					local base = theme.Color.Surface
					local t = Theme.tone(theme, tint)
					return ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color.lighten(base:Lerp(t.Bottom, amount), 0.08)),
						ColorSequenceKeypoint.new(0.55, base:Lerp(t.Edge, amount * 0.7)),
						ColorSequenceKeypoint.new(1, Color.darken(base, 0.45)),
					})
				end),
			}),
			rayFrame,
			scope:New "Frame" {
				Name = "Pattern",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.fromOffset(10 * 240, 6 * 240),
				ZIndex = 3,
				[Children] = motifs,
			},
			scope:Vignette { Intensity = 0.6, Style = "Soft", ZIndex = 4 },
		},
	}
end

-- The featured banner of a menu, `w` x `h`, with a small caption above it.
local function menuBanner(ctx: any, scope: any, def: { [string]: any }, banner: { [string]: any }, w: number, h: number): Instance
	local BannerComponent = Common.component("Banner")
	local click = if banner.button
		then function()
			ctx.run(banner.button.action, { id = banner.id, screen = def.id })
		end
		else nil
	local card = nil
	if BannerComponent then
		card = Common.try(BannerComponent, scope, {
			Title = banner.title,
			Subtitle = banner.subtitle,
			Icon = banner.icon,
			Tone = banner.tone,
			ButtonText = if banner.button then banner.button.label else nil,
			OnClick = click,
			EndsAt = if banner.timer then os.time() + banner.timer else nil,
			Size = UDim2.fromOffset(w, h),
		})
	end
	if card == nil then
		card = Sections.build(ctx, scope, banner, w, { pageHeight = h + 200, screen = def.id })
		card.Size = UDim2.fromOffset(w, h)
	end
	card.Position = UDim2.fromOffset(0, 40)
	return scope:New "Frame" {
		Name = "Featured",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		[Children] = {
			scope:Text {
				Name = "Caption",
				Text = if banner.timer then "LIMITED TIME" else "FEATURED",
				TextSize = 24,
				Font = "Heavy",
				Color = Kit.themed(scope, function(theme)
					return Theme.tone(theme, banner.tone).Top
				end),
				StrokeColor = Kit.themed(scope, function(theme)
					return Theme.tone(theme, banner.tone).Deep
				end),
				Position = UDim2.fromOffset(6, 4),
			},
			card,
		},
	}
end

-- Big saturated primary (shines and pulses) or a calm secondary.
local function menuButton(ctx: any, scope: any, def: { [string]: any }, b: { [string]: any }, primary: boolean, textSize: number): Instance
	return ctx.button(scope, {
		Name = b.id,
		Text = b.label,
		Icon = b.icon,
		Tone = if primary then (b.tone or "Green") else "Dark",
		Size = UDim2.fromScale(1, 1),
		TextSize = textSize,
		IconSize = math.floor(textSize * (if primary then 1.1 else 1.15)),
		Shine = primary,
		Pulse = primary,
		Primary = primary,
	}, b.action, { id = b.id, screen = def.id })
end

local function versionTag(ctx: any, scope: any, props: { [string]: any }): TextLabel
	return scope:Text {
		Name = "Version",
		Text = `{ctx.bp.meta.title} · v1.0`,
		TextSize = 18,
		Font = "Body",
		Color = Kit.themed(scope, function(theme)
			return theme.Color.TextMuted
		end),
		AnchorPoint = props.AnchorPoint,
		Position = props.Position,
		Stroke = false,
		ZIndex = 6,
	}
end

-- Center: the logo top-centre, a big PLAY under it, the other buttons in a
-- row (or a column / two-wide grid) below, the banner along the bottom or as
-- a card in the top-right corner.
local function centerMenu(ctx: any, scope: any, def: { [string]: any }, open: any, buttons: { any }, banner: any, bannerAt: string, style: string): Frame
	-- short screens (phones in landscape) get a tighter stack, so the stage
	-- scales down less and the buttons stay thumb-sized
	local short = peek(Viewport.designSize).Y < 900
	local W, H = 1920, 900
	local secondaries = { table.unpack(buttons, 2) }
	local n = #secondaries
	local primaryW, primaryH = 560, if short then 108 else 120
	local primaryTop = if short then 176 else 250
	local top = primaryTop + primaryH + (if short then 22 else 32)
	local cells = {}
	local cellW, cellH, columns
	if style == "Row" or n == 0 then
		columns = math.max(1, n)
		cellW = math.clamp(math.floor((1440 - (columns - 1) * 20) / columns), 220, 320)
		cellH = 84
	elseif style == "Grid" or n > 4 then
		columns = 2
		cellW, cellH = 340, 76
	else
		columns = 1
		cellW, cellH = 460, 72
	end
	local gapX, gapY = 20, 14
	local rows = math.ceil(n / columns)
	local bottom = top + rows * cellH + math.max(0, rows - 1) * gapY
	local bannerW, bannerH = 1000, if short then 150 else 176
	if short then
		-- the stage is only as tall as its content
		H = bottom + (if banner and bannerAt == "Bottom" then 30 + 40 + bannerH else 24)
		if banner and bannerAt == "Bottom" and H > 760 then
			bannerAt = "Corner"
			H = bottom + 24
		end
	elseif bannerAt == "Bottom" and banner and bottom > H - bannerH - 40 then
		bannerAt = "Corner" -- a tall column would run into it
	end
	if not short and not (banner and bannerAt == "Bottom") then
		-- nothing along the bottom: keep half the spare room, so the stack
		-- sits a little above the middle instead of hugging the top
		H = bottom + math.floor((H - bottom) / 2)
	end
	local delays = { 0, 0.16 }
	for i = 1, n do
		table.insert(delays, 0.24 + (i - 1) * 0.05)
	end
	table.insert(delays, 0.3)
	local springs = entrance(scope, open, delays)
	local logoHeight = if short then 150 elseif bannerAt == "Corner" and banner then 180 else 210
	-- v2.4 style families: a long title in the centre would run under the
	-- corner banner (500 px wide, top right). Measure the lockup roughly
	-- (~0.27 x its height per letter, plus the emblem / medallion) and
	-- move the banner to the bottom when it fits there, else shrink the logo
	-- until it clears the banner. Classic themes keep the v2.3 layout.
	if Theme.get().Family ~= nil and banner and bannerAt == "Corner" then
		local logo = ctx.bp.logo or {}
		local letters = utf8.len(logo.text or "") or #(logo.text or "")
		local art = if logo.type == "Emblem" or logo.type == "Badge" then 0.9 else 0.15
		local function logoWidth(height: number): number
			return height * (letters * 0.27 + art)
		end
		local room = 2 * (W / 2 - 500 - 48)
		if logoWidth(logoHeight) > room then
			if not short and bottom + 30 + 40 + bannerH <= 900 then
				bannerAt = "Bottom"
				H = 900
				logoHeight = if short then 150 else 210
			end
			if bannerAt == "Corner" then
				logoHeight = math.max(112, math.floor(room / (letters * 0.27 + art)))
			end
		end
	end
	table.insert(cells, slot(scope, {
		Name = "LogoHolder",
		Position = UDim2.fromOffset(W / 2, if short then 78 else 110),
		From = Vector2.new(0, -80),
		Pop = true,
		Spring = springs[1],
		Size = UDim2.fromOffset(10, 10),
		ZIndex = 8,
		Child = Screens.logo(ctx, scope, ctx.bp.logo, logoHeight, {
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
		}),
	}))
	table.insert(cells, slot(scope, {
		Name = "PrimarySlot",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.fromOffset(W / 2, primaryTop),
		Size = UDim2.fromOffset(primaryW, primaryH),
		Pop = true,
		Spring = springs[2],
		ZIndex = 7,
		Child = menuButton(ctx, scope, def, buttons[1], true, 46),
	}))
	local rowWidth = columns * cellW + (columns - 1) * gapX
	for i, b in secondaries do
		local column = (i - 1) % columns
		local row = (i - 1) // columns
		local inRow = math.min(columns, n - row * columns)
		local rowLeft = W / 2 - (inRow * cellW + (inRow - 1) * gapX) / 2
		table.insert(cells, slot(scope, {
			Name = `Slot{i + 1}`,
			Position = UDim2.fromOffset(rowLeft + column * (cellW + gapX), top + row * (cellH + gapY)),
			Size = UDim2.fromOffset(cellW, cellH),
			From = Vector2.new(0, 260),
			Spring = springs[2 + i],
			ZIndex = 6,
			Child = menuButton(ctx, scope, def, b, false, if columns > 1 then 28 else 30),
		}))
	end
	local _ = rowWidth
	if banner then
		local spring = springs[#springs]
		if bannerAt == "Corner" then
			local w, h = 500, 250
			table.insert(cells, slot(scope, {
				Name = "BannerSlot",
				AnchorPoint = Vector2.new(1, 0),
				Position = UDim2.fromOffset(W, 0),
				Size = UDim2.fromOffset(w, h + 40),
				From = Vector2.new(700, 0),
				Spring = spring,
				ZIndex = 6,
				Child = menuBanner(ctx, scope, def, banner, w, h),
			}))
		else
			table.insert(cells, slot(scope, {
				Name = "BannerSlot",
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.fromOffset(W / 2, H),
				Size = UDim2.fromOffset(bannerW, bannerH + 40),
				From = Vector2.new(0, 400),
				Spring = spring,
				ZIndex = 6,
				Child = menuBanner(ctx, scope, def, banner, bannerW, bannerH),
			}))
		end
	end
	return stage(scope, "Center", W, H, cells)
end

-- Split: a big key-art panel (logo, the game's emblem on a burst of light,
-- PLAY) beside a rail of the other buttons, with the banner at the top or the
-- bottom of the rail.
local function splitMenu(ctx: any, scope: any, def: { [string]: any }, open: any, buttons: { any }, banner: any, bannerAt: string): Frame
	-- short screens: a shorter stage (less shrinking) and a slimmer banner
	local short = peek(Viewport.designSize).Y < 900
	local W, H = 1920, if short then 760 else 920
	local panelW = 1180
	local railX, railW = 1220, 700
	local secondaries = { table.unpack(buttons, 2) }
	local n = #secondaries
	local logo = ctx.bp.logo
	local artTone = CONTRAST[logo.tone] or "Blue"
	-- serious looks: a bright blue slab is too toy-like
	if ctx.bp.style.archetype == "Tactical" then
		artTone = "Gray" -- slate key art
	elseif ctx.bp.style.archetype == "Horror" then
		artTone = "Dark" -- a dim, smoky panel
	end
	-- v2.4 style families: the key art is the family's own backdrop recipe
	-- (clipped in the card), the emblem in the palette's primary
	local fam = Theme.get().Family
	if fam ~= nil then
		artTone = "Primary"
	end
	local delays = { 0, 0.2 }
	for i = 1, n do
		table.insert(delays, 0.18 + i * 0.05)
	end
	table.insert(delays, 0.16)
	local springs = entrance(scope, open, delays)
	local bannerH = if banner then (if short then 220 else 290) else 0
	local bannerBlock = if banner then bannerH + 40 + 24 else 0
	local cellH = math.clamp(math.floor((H - bannerBlock - math.max(0, n - 1) * 14) / math.max(1, n)), 60, 92)
	local listTop = if banner and bannerAt ~= "Bottom" then bannerBlock else 0
	local rays = Kit.rays(scope, {
		Name = "Rays",
		Size = UDim2.fromOffset(1200, 1200),
		Position = UDim2.fromScale(0.7, 0.5),
		Color = Color3.new(1, 1, 1),
		Transparency = 0.62,
		ZIndex = 2,
	})
	pcall(function()
		table.insert(scope, Motion.spin(rays, 10))
	end)
	local panel = Common.card(scope, {
		Name = "Art",
		Size = UDim2.fromScale(1, 1),
		Tone = artTone,
		Radius = "L",
		Clip = true,
		Stroke = 3,
		Children = {
			if fam ~= nil then familyBackdrop(scope, fam, UDim2.fromScale(0.7, 0.5)) else nil,
			if fam ~= nil and not (fam.Backdrop and (fam.Backdrop.Kind == "Sunburst" or fam.Backdrop.Kind == "Burst")) then nil else rays,
			scope:Icon {
				Name = "Emblem",
				Icon = logo.icon or "star",
				Size = 560,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.7, 0.52),
				Rotation = -12,
				Color = Kit.themed(scope, function(theme)
					return Color.lighten(Theme.tone(theme, artTone).Top, 0.35)
				end),
				StrokeColor = Kit.themed(scope, function(theme)
					return Theme.tone(theme, artTone).Edge
				end),
				Stroke = 6,
				Gradient = true,
				Shadow = true,
				Transparency = 0.05,
				ZIndex = 3,
			},
			scope:New "Frame" {
				Name = "Shade",
				Size = UDim2.fromScale(0.7, 1),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return Theme.tone(theme, artTone).Deep
				end),
				ZIndex = 4,
				[Children] = Kit.gradient(scope, {
					Rotation = 0,
					Transparency = NumberSequence.new({
						NumberSequenceKeypoint.new(0, 0.25),
						NumberSequenceKeypoint.new(1, 1),
					}),
				}),
			},
			scope:New "Frame" {
				Name = "LogoHolder",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(60, 56),
				Size = UDim2.fromOffset(10, 10),
				ZIndex = 8,
				[Children] = Screens.logo(ctx, scope, logo, 190, {
					AnchorPoint = Vector2.new(0, 0),
					Position = UDim2.fromOffset(0, 0),
				}),
			},
			slot(scope, {
				Name = "PrimarySlot",
				AnchorPoint = Vector2.new(0, 1),
				Position = UDim2.new(0, 60, 1, -60),
				Size = UDim2.fromOffset(540, 128),
				Pop = true,
				Spring = springs[2],
				ZIndex = 9,
				Child = menuButton(ctx, scope, def, buttons[1], true, 48),
			}),
			versionTag(ctx, scope, { AnchorPoint = Vector2.new(1, 1), Position = UDim2.new(1, -28, 1, -22) }),
		},
	})
	local cells = {
		slot(scope, {
			Name = "PanelSlot",
			Position = UDim2.fromOffset(0, 0),
			Size = UDim2.fromOffset(panelW, H),
			From = Vector2.new(-400, 0),
			Spring = springs[1],
			ZIndex = 5,
			Child = panel,
		}),
	}
	for i, b in secondaries do
		table.insert(cells, slot(scope, {
			Name = `Slot{i + 1}`,
			Position = UDim2.fromOffset(railX, listTop + (i - 1) * (cellH + 14)),
			Size = UDim2.fromOffset(railW, cellH),
			From = Vector2.new(500, 0),
			Spring = springs[2 + i],
			ZIndex = 6,
			Child = menuButton(ctx, scope, def, b, false, 32),
		}))
	end
	if banner then
		table.insert(cells, slot(scope, {
			Name = "BannerSlot",
			Position = UDim2.fromOffset(railX, if bannerAt == "Bottom" then H - bannerH - 40 else 0),
			Size = UDim2.fromOffset(railW, bannerH + 40),
			From = Vector2.new(600, 0),
			Spring = springs[#springs],
			ZIndex = 6,
			Child = menuBanner(ctx, scope, def, banner, railW, bannerH),
		}))
	end
	return stage(scope, "Split", W, H, cells)
end

-- Corner: the RIVALS lobby. Nothing covers the 3D lobby but a shade on the
-- left; a small logo top-left, the buttons stacked in the bottom-left corner
-- with PLAY lowest (under the thumb), the banner on the right.
local function cornerMenu(ctx: any, scope: any, def: { [string]: any }, open: any, buttons: { any }, banner: any, bannerAt: string): { Instance }
	local secondaries = { table.unpack(buttons, 2) }
	local n = #secondaries
	local safeTop = Kit.safeTop(scope, 72)
	-- the logo and the button stack scale on their own: on a short screen the
	-- logo gives up room first, so the buttons stay thumb-sized
	local LOGO_H = 170
	local primaryH, cellH, gap = 116, 68, 12
	if n > 6 then
		cellH = 56
	end
	local STACK_W = 500
	-- short screens (phones): the secondaries pair up two to a row, so the
	-- stack is shorter and every button stays a thumb-sized target
	local columns = if peek(Viewport.designSize).Y < 900 and n > 3 then 2 else 1
	if columns == 2 then
		cellH = 76
	end
	local rows = math.ceil(n / columns)
	local cellW = if columns == 2 then math.floor((STACK_W - gap) / 2) else 440
	local stackH = primaryH + 24 + rows * (cellH + gap) - (if n > 0 then gap else 0)
	local logoFit = scope:Computed(function(use)
		return math.clamp(use(Viewport.designSize).Y / 1080, 0.55, 1.1)
	end)
	local stackFit = scope:Computed(function(use)
		local design = use(Viewport.designSize)
		local available = design.Y - use(safeTop) - 12 - (LOGO_H + 60) * use(logoFit) - 24 - 40
		return math.clamp(math.min(available / stackH, design.X * 0.4 / STACK_W, 1.1), 0.45, 1.1)
	end)
	local delays = { 0 }
	for i = 1, n + 1 do
		table.insert(delays, 0.08 + (n + 1 - i) * 0.05)
	end
	table.insert(delays, 0.3)
	local springs = entrance(scope, open, delays)
	local stackCells = {}
	for i, b in secondaries do
		local row, column = (i - 1) // columns, (i - 1) % columns
		-- an odd one out on the last row of a two-wide stack spans the row
		local wide = columns == 2 and i == n and column == 0
		table.insert(stackCells, slot(scope, {
			Name = `Slot{i + 1}`,
			Position = UDim2.fromOffset(column * (cellW + gap), row * (cellH + gap)),
			Size = UDim2.fromOffset(if wide then STACK_W else cellW, cellH),
			From = Vector2.new(-520, 0),
			Spring = springs[1 + i],
			ZIndex = 6,
			Child = menuButton(ctx, scope, def, b, false, if columns == 2 then 26 else 28),
		}))
	end
	table.insert(stackCells, slot(scope, {
		Name = "PrimarySlot",
		Position = UDim2.fromOffset(0, stackH - primaryH),
		Size = UDim2.fromOffset(STACK_W, primaryH),
		From = Vector2.new(-600, 0),
		Spring = springs[n + 2],
		ZIndex = 7,
		Child = menuButton(ctx, scope, def, buttons[1], true, 46),
	}))
	local holders = {
		scope:New "Frame" {
			Name = "LogoCorner",
			BackgroundTransparency = 1,
			Position = scope:Computed(function(use)
				return UDim2.fromOffset(56, use(safeTop) + 12)
			end),
			Size = UDim2.fromOffset(10, 10),
			ZIndex = 8,
			[Children] = {
				scope:New "UIScale" { Scale = logoFit },
				slot(scope, {
					Name = "LogoHolder",
					Position = UDim2.fromOffset(0, 0),
					From = Vector2.new(-160, 0),
					Spring = springs[1],
					Size = UDim2.fromOffset(10, 10),
					ZIndex = 8,
					Child = Screens.logo(ctx, scope, ctx.bp.logo, LOGO_H, {
						AnchorPoint = Vector2.new(0, 0),
						Position = UDim2.fromOffset(0, 0),
					}),
				}),
			},
		},
		scope:New "Frame" {
			Name = "Corner",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, 56, 1, -40),
			Size = UDim2.fromOffset(STACK_W, stackH),
			ZIndex = 5,
			[Children] = { scope:New "UIScale" { Scale = stackFit }, stackCells },
		},
		versionTag(ctx, scope, { AnchorPoint = Vector2.new(1, 1), Position = UDim2.new(1, -32, 1, -20) }),
	}
	-- the banner keeps its full size wherever the right side has room for it
	local bannerW = if bannerAt == "Corner" then 480 else 560
	local fit = scope:Computed(function(use)
		local design = use(Viewport.designSize)
		local room = design.X - 56 - STACK_W * use(stackFit) - 120
		return math.clamp(math.min(math.max(use(logoFit), 1), room / bannerW), 0.55, 1.1)
	end)
	if banner then
		local w, h = bannerW, if bannerAt == "Corner" then 240 else 300
		table.insert(holders, scope:New "Frame" {
			Name = "BannerHolder",
			BackgroundTransparency = 1,
			AnchorPoint = if bannerAt == "Corner" then Vector2.new(1, 0) else Vector2.new(1, 0.5),
			Position = if bannerAt == "Corner"
				then scope:Computed(function(use)
					return UDim2.new(1, -48, 0, use(safeTop) + 8)
				end)
				else UDim2.new(1, -56, 0.5, 20),
			Size = UDim2.fromOffset(w, h + 40),
			ZIndex = 5,
			[Children] = {
				scope:New "UIScale" { Scale = fit },
				slot(scope, {
					Name = "BannerSlot",
					Position = UDim2.fromOffset(0, 0),
					Size = UDim2.fromScale(1, 1),
					From = Vector2.new(700, 0),
					Spring = springs[#springs],
					ZIndex = 6,
					Child = menuBanner(ctx, scope, def, banner, w, h),
				}),
			},
		})
	end
	return holders
end

local function variantMenu(ctx: any, scope: any, def: { [string]: any }, parent: Instance, buttons: { any }, banner: any): { [string]: any }
	local open = scope:Value(false)
	local visible = appear(scope, open)
	local layout = def.layout
	local bannerAt = def.bannerPosition or (Schema.MENU_BANNER[layout] or { "Side" })[1]
	local style = "Column"
	for _, s in def.sections do
		if s.kind == "Buttons" then
			style = s.layout or "Column"
			break
		end
	end
	local backdrop, content
	if layout == "Corner" then
		backdrop = menuBackdrop(ctx, scope, "Lobby")
		content = cornerMenu(ctx, scope, def, open, buttons, banner, bannerAt)
	elseif layout == "Split" then
		backdrop = menuBackdrop(ctx, scope, "Full", UDim2.fromScale(0.3, 0.4), 0.28)
		content = { splitMenu(ctx, scope, def, open, buttons, banner, bannerAt) }
	else
		backdrop = menuBackdrop(ctx, scope, "Full", UDim2.fromScale(0.5, 0.22))
		content = {
			centerMenu(ctx, scope, def, open, buttons, banner, bannerAt, style),
			versionTag(ctx, scope, { AnchorPoint = Vector2.new(0, 1), Position = UDim2.new(0, 32, 1, -20) }),
		}
	end
	local gui = scope:New "Frame" {
		Name = def.id,
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		Visible = visible,
		Parent = parent,
		ZIndex = 20,
		[Children] = {
			backdrop,
			scope:New "Frame" {
				Name = "Content",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				ZIndex = 2,
				[Children] = content,
			},
		},
	}
	return { Open = open, Gui = gui }
end

local function menuScreen(ctx: any, scope: any, def: { [string]: any }, parent: Instance): { [string]: any }
	local open = scope:Value(false)
	local buttons = {}
	local banner = nil
	for _, s in def.sections do
		if s.kind == "Buttons" then
			for _, b in s.buttons do
				table.insert(buttons, b)
			end
		elseif s.kind == "Banner" and banner == nil then
			banner = s
		end
	end
	if #buttons == 0 then
		buttons = { { id = "play", label = "Play", icon = "play", tone = "Green", action = { kind = "Close" } } }
	end
	if def.layout == "Center" or def.layout == "Split" or def.layout == "Corner" then
		return variantMenu(ctx, scope, def, parent, buttons, banner)
	end

	local MainMenu = Common.component("Prefabs/MainMenu")
	if MainMenu then
		local list = {}
		for index, b in buttons do
			table.insert(list, {
				Text = b.label,
				Icon = b.icon,
				-- one saturated primary per screen: the rest are neutral plates
				Tone = if index == 1 then b.tone else nil,
				OnClick = function()
					ctx.run(b.action, { id = b.id, screen = def.id })
				end,
			})
		end
		-- v2.4 style families bring their own backdrop (added below)
		local fam = Theme.get().Family
		local result = Common.try(MainMenu, scope, {
			Logo = logoProps(ctx),
			Backdrop = if fam ~= nil then "None" else nil,
			Buttons = list,
			Banner = if banner
				then {
					Title = banner.title,
					Subtitle = banner.subtitle,
					Icon = banner.icon,
					Tone = banner.tone,
					ButtonText = if banner.button then banner.button.label else nil,
					OnClick = if banner.button
						then function()
							ctx.run(banner.button.action, { id = banner.id, screen = def.id })
						end
						else nil,
				}
				else nil,
			Version = "v1.0",
			Open = false,
			Parent = ctx.parent,
			DisplayOrder = 30,
		})
		if typeof(result) == "table" and typeof(result.Gui) == "Instance" then
			if result.Gui:IsA("LayerCollector") and result.Gui.Parent ~= ctx.parent and ctx.parent then
				result.Gui.Parent = ctx.parent
			end
			if fam ~= nil then
				local back = familyBackdrop(scope, fam, UDim2.fromScale(0.3, 0.35))
				back.ZIndex = 0
				back.Parent = result.Gui
			end
			local prefabOpen = result.Open
			if typeof(prefabOpen) == "table" and prefabOpen.set then
				return { Open = prefabOpen, Gui = result.Gui }
			end
		end
	end

	local visible, progress = appear(scope, open)
	local buttonCells = {}
	for index, b in buttons do
		table.insert(buttonCells, ctx.button(scope, {
			Name = b.id,
			Text = b.label,
			Icon = b.icon,
			Tone = if index == 1 then (b.tone or "Green") else "Dark",
			Size = UDim2.fromOffset(if index == 1 then 440 else 400, if index == 1 then 92 else 72),
			TextSize = if index == 1 then 40 else 30,
			Shine = index == 1,
			Pulse = index == 1,
			LayoutOrder = index,
		}, b.action, { id = b.id, screen = def.id }))
	end
	local content = scope:New "Frame" {
		Name = "Content",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		ZIndex = 2,
		[Children] = {
			scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					return 0.94 + 0.06 * use(progress)
				end),
			},
			scope:Column {
				Name = "Left",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 120, 0.5, 0),
				Size = UDim2.fromOffset(640, 760),
				Gap = 24,
				Align = "Left",
				VAlign = "Center",
				[Children] = {
					Screens.logo(ctx, scope, ctx.bp.logo, 220),
					scope:New "Frame" { Name = "Spacer", BackgroundTransparency = 1, Size = UDim2.fromOffset(10, 16), LayoutOrder = 2 },
					scope:Column {
						Name = "Buttons",
						Size = UDim2.fromOffset(460, #buttonCells * 88 + 24),
						Gap = 16,
						Align = "Left",
						LayoutOrder = 3,
						[Children] = buttonCells,
					},
				},
			},
			if banner
				then (function()
					local block = Sections.build(ctx, scope, banner, 680, { pageHeight = 400, screen = def.id })
					block.AnchorPoint = Vector2.new(1, 0.5)
					block.Position = UDim2.new(1, -120, 0.5, 0)
					block.Size = UDim2.fromOffset(680, block.Size.Y.Offset)
					return block
				end)()
				else nil,
			scope:Text {
				Name = "Version",
				Text = `{ctx.bp.meta.title} · v1.0`,
				TextSize = 18,
				Font = "Body",
				Color = Kit.themed(scope, function(theme)
					return theme.Color.TextMuted
				end),
				AnchorPoint = Vector2.new(0, 1),
				Position = UDim2.new(0, 32, 1, -24),
				Stroke = false,
			},
		},
	}
	local gui = scope:New "Frame" {
		Name = def.id,
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		Visible = visible,
		Parent = parent,
		ZIndex = 20,
		[Children] = fullBackdrop(ctx, scope, ctx.bp.logo.icon, content),
	}
	return { Open = open, Gui = gui }
end

local function loadingScreen(ctx: any, scope: any, def: { [string]: any }, parent: Instance): { [string]: any }
	local open = scope:Value(false)
	local progress = scope:Value(0)
	local done = false
	local function finish()
		if done then
			return
		end
		done = true
		ctx.ui:close(def.id)
		for _, s in ctx.bp.screens do
			if s.kind == "MainMenu" then
				ctx.ui:open(s.id)
				break
			end
		end
		ctx.ui:emit("Loaded", { id = def.id, screen = def.id })
	end

	local LoadingScreen = Common.component("Prefabs/LoadingScreen")
	if LoadingScreen then
		local result = Common.try(LoadingScreen, scope, {
			Logo = logoProps(ctx),
			Tips = def.tips,
			-- preview holds the bar part-way so the screen can be looked at
			Duration = if ctx.preview then 0 else def.duration,
			Destroy = false,
			Parent = ctx.parent,
			DisplayOrder = 40,
			OnDone = function()
				if not ctx.preview then
					finish()
				end
			end,
		})
		if typeof(result) == "table" and typeof(result.Gui) == "Instance" then
			local gui = result.Gui
			if ctx.preview and typeof(result.Progress) == "table" and result.Progress.set then
				result.Progress:set(0.65)
			end
			local function show(now: boolean)
				if gui:IsA("LayerCollector") then
					(gui :: any).Enabled = now
				elseif gui:IsA("GuiObject") then
					(gui :: any).Visible = now
				end
			end
			show(false)
			Kit.watch(scope, open, show)
			return {
				Open = open,
				Gui = gui,
				Progress = result.Progress,
				finish = result.finish or finish,
			}
		end
	end

	local visible = appear(scope, open)
	local tip = scope:Value(def.tips[1] or "")
	local started = false
	local function start()
		if started then
			return
		end
		started = true
		local alive = true
		table.insert(scope, function()
			alive = false
		end)
		task.spawn(function()
			local elapsed = 0
			local tipIndex = 1
			local tipTimer = 0
			while alive and not done do
				local dt = task.wait(1 / 30)
				elapsed += dt
				tipTimer += dt
				if not alive then
					return
				end
				local target = elapsed / def.duration
				if ctx.preview then
					target = math.min(target, 0.68)
				end
				progress:set(math.clamp(target, 0, 1))
				if tipTimer > 2.6 and #def.tips > 1 then
					tipTimer = 0
					tipIndex = tipIndex % #def.tips + 1
					tip:set(def.tips[tipIndex])
				end
				if target >= 1 and not ctx.preview then
					finish()
				end
			end
		end)
	end
	Kit.watch(scope, open, function(now)
		if now then
			start()
		end
	end)

	local content = scope:New "Frame" {
		Name = "Content",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		ZIndex = 2,
		[Children] = {
			(function()
				local logo = Screens.logo(ctx, scope, ctx.bp.logo, 240)
				logo.AnchorPoint = Vector2.new(0.5, 0.5)
				logo.Position = UDim2.fromScale(0.5, 0.4)
				return logo
			end)(),
			scope:ProgressBar {
				Name = "Progress",
				Value = progress,
				Max = 1,
				Tone = "Yellow",
				Ghost = false,
				Shine = true,
				Label = function(value)
					return Format.percent(math.clamp(value, 0, 1))
				end,
				Size = UDim2.fromOffset(720, 36),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.72),
			},
			scope:Text {
				Name = "Tip",
				Text = tip,
				TextSize = 24,
				Font = "Body",
				Color = Kit.themed(scope, function(theme)
					return theme.Color.TextMuted
				end),
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0.72, 40),
				Size = UDim2.fromOffset(900, 32),
				Stroke = false,
			},
		},
	}
	local gui = scope:New "Frame" {
		Name = def.id,
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		Visible = visible,
		Parent = parent,
		ZIndex = 30,
		[Children] = fullBackdrop(ctx, scope, ctx.bp.logo.icon, content),
	}
	return { Open = open, Gui = gui, Progress = progress, finish = finish }
end

--------------------------------------------------------------------------
-- Results and Trade
--------------------------------------------------------------------------

local RESULT_TONE = { Victory = "Yellow", Defeat = "Red", Draw = "Gray" }

local function resultsScreen(ctx: any, scope: any, def: { [string]: any }, parent: Instance): { [string]: any }
	local open = scope:Value(false)
	local buttons = {}
	for _, b in def.buttons do
		table.insert(buttons, {
			Text = b.label,
			Icon = b.icon,
			Tone = b.tone,
			OnClick = function()
				ctx.run(b.action, { id = b.id, screen = def.id })
			end,
		})
	end
	local Results = Common.component("Prefabs/Results")
	if Results then
		local stats = {}
		for _, stat in def.stats do
			table.insert(stats, { label = stat.label, value = stat.value })
		end
		local rewards = {}
		for _, r in def.rewards do
			table.insert(rewards, { icon = r.icon, amount = r.amount })
		end
		local result = Common.try(Results, scope, { Result = def.result, Stats = stats, Rewards = rewards, Buttons = buttons, Open = false })
		if typeof(result) == "table" and typeof(result.Gui) == "Instance" and typeof(result.Open) == "table" then
			if result.Gui:IsA("LayerCollector") then
				result.Gui.Parent = ctx.parent
				result.Gui.DisplayOrder = 25
			end
			return { Open = result.Open, Gui = result.Gui }
		end
	end

	local tone = RESULT_TONE[def.result] or "Yellow"
	local width = 980
	local statCells = {}
	for index, stat in def.stats do
		table.insert(statCells, Common.card(scope, {
			Name = stat.label,
			LayoutOrder = index,
			Children = {
				scope:Text {
					Text = stat.value,
					TextSize = 44,
					Font = "Display",
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, 14),
					ZIndex = 3,
				},
				scope:Text {
					Text = string.upper(stat.label),
					TextSize = 18,
					Font = "Heavy",
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -12),
					ZIndex = 3,
				},
			},
		}))
	end
	local rewardCells = {}
	for index, r in def.rewards do
		table.insert(rewardCells, scope:Column {
			Name = `Reward{index}`,
			Size = UDim2.fromOffset(110, 136),
			Gap = 6,
			LayoutOrder = index,
			[Children] = {
				Common.iconTile(scope, { Icon = r.icon, Tone = tone, Size = 96, LayoutOrder = 1 }),
				scope:Text { Text = if r.amount then `+{r.amount}` else "", TextSize = 26, Font = "Display", LayoutOrder = 2 },
			},
		})
	end
	local buttonCells = {}
	for index, b in def.buttons do
		table.insert(buttonCells, ctx.button(scope, {
			Name = b.id,
			Text = b.label,
			Icon = b.icon,
			Tone = b.tone,
			Size = UDim2.fromOffset(if index == 1 then 280 else 220, 72),
			TextSize = 30,
			Shine = index == 1,
			LayoutOrder = index,
		}, b.action, { id = b.id, screen = def.id }))
	end
	local blocks = {
		-- scope:Text draws sizes above Roblox's 100 cap through a UIScale
		scope:Text {
			Name = "Title",
			Text = string.upper(def.result),
			TextSize = 110,
			Font = "Title",
			Gradient = tone,
			Stroke = 6,
			StrokeColor = Kit.themed(scope, function(theme)
				return Theme.tone(theme, tone).Deep
			end),
			LayoutOrder = 1,
		},
	}
	local GAP = 24
	local height = 32 * 2 + 120
	if #statCells > 0 then
		local counts = Sections.balancedRows(#statCells, 4)
		local rows = {}
		local i = 1
		for r, count in counts do
			local row = {}
			for c = 1, count do
				statCells[i].Size = UDim2.new(1 / count, -math.ceil(16 * (count - 1) / count), 0, 120)
				statCells[i].LayoutOrder = c
				table.insert(row, statCells[i])
				i += 1
			end
			table.insert(rows, scope:Row { Name = `Stats{r}`, LayoutOrder = r, Size = UDim2.new(1, 0, 0, 120), Gap = 16, [Children] = row })
		end
		local statsHeight = #counts * 136 - 16
		table.insert(blocks, scope:Column { Name = "Stats", LayoutOrder = 2, Size = UDim2.new(1, 0, 0, statsHeight), Gap = 16, [Children] = rows })
		height += GAP + statsHeight
	end
	if #rewardCells > 0 then
		table.insert(blocks, scope:Column {
			Name = "Rewards",
			LayoutOrder = 3,
			Size = UDim2.new(1, 0, 0, 176),
			Gap = 8,
			[Children] = {
				scope:Text {
					Text = "REWARDS",
					TextSize = 22,
					Font = "Heavy",
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Accent
					end),
					LayoutOrder = 1,
				},
				scope:Row { Size = UDim2.new(1, 0, 0, 136), Gap = 16, Align = "Center", LayoutOrder = 2, [Children] = rewardCells },
			},
		})
		height += GAP + 176
	end
	table.insert(blocks, scope:Row {
		Name = "Buttons",
		LayoutOrder = 4,
		Size = UDim2.new(1, 0, 0, 72),
		Gap = 16,
		Align = "Center",
		[Children] = buttonCells,
	})
	height += GAP + 72
	local window = scope:Window {
		Name = def.id,
		Open = open,
		Size = Vector2.new(width, height),
		Panel = true,
		Padding = 32,
		Blur = not ctx.preview,
		ZIndex = 10,
		Parent = parent,
		[Children] = scope:Column {
			Name = "Results",
			Size = UDim2.fromScale(1, 1),
			Gap = GAP,
			[Children] = blocks,
		},
	}
	return { Open = open, Gui = window }
end

local function tradeScreen(ctx: any, scope: any, def: { [string]: any }, parent: Instance): { [string]: any }
	local inventory = {}
	for _, s in ctx.bp.screens do
		for _, list in { s.sections, (function()
			local all = {}
			for _, tab in s.tabs do
				for _, sec in tab.sections do
					table.insert(all, sec)
				end
			end
			return all
		end)() } do
			for _, sec in list do
				if sec.kind == "Inventory" and #inventory == 0 then
					inventory = sec.items
				end
			end
		end
	end
	local player = game:GetService("Players").LocalPlayer
	local Trade = Common.component("Prefabs/Trade")
	if Trade then
		local result = Common.try(Trade, scope, {
			Me = { name = if player then player.DisplayName else "You" },
			Them = { name = "Partner" },
			Inventory = inventory,
			Currency = if def.currency and ctx.currencyDefs[def.currency]
				then { Icon = ctx.currencyDefs[def.currency].icon }
				else nil,
			Open = false,
			OnAccept = function()
				ctx.ui:emit("TradeAccept", { id = def.id, screen = def.id })
			end,
			OnDecline = function()
				ctx.ui:emit("TradeDecline", { id = def.id, screen = def.id })
				ctx.ui:close(def.id)
			end,
		})
		if typeof(result) == "table" and typeof(result.Gui) == "Instance" and typeof(result.Open) == "table" then
			if result.Gui:IsA("LayerCollector") then
				result.Gui.Parent = ctx.parent
				result.Gui.DisplayOrder = 12
			end
			return { Open = result.Open, Gui = result.Gui, Trade = result }
		end
	end

	local open = scope:Value(false)
	local width, height = 1180, 700
	local columnW = math.floor((width - PAD * 2 - PAD) / 2)
	local function offerGrid(title: string, mine: boolean, order: number)
		local slots = {}
		for i = 1, 9 do
			table.insert(slots, Common.card(scope, {
				Name = `Slot{i}`,
				LayoutOrder = i,
				Highlight = false,
				Children = scope:Icon {
					Icon = "plus",
					Size = 36,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					Transparency = 0.4,
					Visible = mine,
					ZIndex = 3,
				},
			}))
		end
		return scope:Column {
			Name = title,
			Size = UDim2.fromOffset(columnW, 440),
			Gap = 16,
			LayoutOrder = order,
			[Children] = {
				Common.header(scope, { Text = title, Icon = if mine then "player" else "friends", LayoutOrder = 1 }),
				scope:Grid {
					Name = "Offer",
					Size = UDim2.fromOffset(columnW, 372),
					CellSize = Vector2.new(math.floor((columnW - 32) / 3), 116),
					Gap = 16,
					Columns = 3,
					LayoutOrder = 2,
					[Children] = slots,
				},
			},
		}
	end
	local window = scope:Window {
		Name = def.id,
		Open = open,
		Size = Vector2.new(width, height),
		Title = def.title,
		Icon = def.icon,
		Panel = true,
		Padding = PAD,
		Blur = not ctx.preview,
		OnClose = function()
			ctx.ui:close(def.id)
		end,
		ZIndex = 10,
		Parent = parent,
		[Children] = {
			scope:Row {
				Name = "Offers",
				Size = UDim2.new(1, 0, 0, 440),
				Gap = PAD,
				VAlign = "Top",
				[Children] = {
					offerGrid("Your Offer", true, 1),
					offerGrid("Their Offer", false, 2),
				},
			},
			scope:Row {
				Name = "Actions",
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.fromScale(0.5, 1),
				Size = UDim2.new(1, 0, 0, 72),
				Gap = 16,
				Align = "Center",
				[Children] = {
					ctx.button(scope, { Name = "Decline", Text = "Decline", Icon = "close", Tone = "Red", Size = UDim2.fromOffset(240, 72), LayoutOrder = 1 },
						{ kind = "Close" }, { id = "decline", screen = def.id }),
					ctx.button(scope, { Name = "Accept", Text = "Accept", Icon = "check", Tone = "Green", Size = UDim2.fromOffset(280, 72), Shine = true, LayoutOrder = 2 },
						{ kind = "Event", name = "TradeAccept" }, { id = "accept", screen = def.id }),
				},
			},
		},
	}
	return { Open = open, Gui = window }
end

--------------------------------------------------------------------------
-- Entry point
--------------------------------------------------------------------------

--------------------------------------------------------------------------
-- v1.6: Offer (limited-time offer / starter pack) and OfflineEarnings
--------------------------------------------------------------------------

local function offerScreen(ctx: any, scope: any, def: { [string]: any }, parent: Instance): { [string]: any }
	local open = scope:Value(false)
	local state = ctx.ui.rewards and ctx.ui.rewards.sections[def.id]
	local OfferCard = Common.component("OfferCard")
	local cardSize = Vector2.new(1000, 560)
	local function buy()
		if state and peek(state.owned) == true then
			return
		end
		if def.productId then
			ctx.run({ kind = "Purchase", productId = def.productId }, { id = def.id, screen = def.id })
		elseif def.gamePassId then
			ctx.run({ kind = "GamePass", gamePassId = def.gamePassId }, { id = def.id, screen = def.id })
		else
			ctx.ui:notify("Not for sale yet!")
		end
	end
	local body
	if OfferCard then
		body = Common.try(OfferCard, scope, {
			Title = def.title,
			Subtitle = def.subtitle,
			Icon = def.icon,
			Tone = def.tone,
			Tag = def.tag,
			Items = def.items,
			Price = def.price,
			Was = def.was,
			PriceIcon = if def.productId or def.gamePassId or def.price > 0 then "robux" else nil,
			EndsAt = if state then state.endsAt else nil,
			Clock = if ctx.ui.rewards then ctx.ui.rewards.now else ctx.clock,
			Owned = if state then state.owned else false,
			OnBuy = buy,
			Size = cardSize,
		})
	end
	if body == nil then
		body = scope:Text { Text = def.title, TextSize = 40, Font = "Title" }
	end
	local window = scope:Window {
		Name = def.id,
		Open = open,
		Size = cardSize + Vector2.new(56, 56),
		Panel = true,
		Padding = 28,
		Blur = not ctx.preview,
		Transition = "Zoom",
		CloseOnBackdrop = true,
		OnClose = function()
			ctx.ui:close(def.id)
		end,
		ZIndex = 10,
		Parent = parent,
		[Children] = body,
	}
	return { Open = open, Gui = window, Modal = { tuck = true, width = cardSize.X + 56, height = cardSize.Y + 56 } }
end

local function offlineScreen(ctx: any, scope: any, def: { [string]: any }, parent: Instance): { [string]: any }
	local open = scope:Value(false)
	local state = ctx.ui.rewards and ctx.ui.rewards.sections[def.id]
	local currency = ctx.ui.currencyDefs[def.currency or ""]
	local amount = if state then state.amount else scope:Value(0)
	local seconds = if state then state.seconds else scope:Value(0)
	local width, height = 780, 470
	local amountLabel
	local claiming = scope:Value(false)
	local Rewards = require(script.Parent.Rewards)
	local function finish()
		task.delay(0.9, function()
			ctx.ui:close(def.id)
		end)
	end
	local function claim()
		if peek(claiming) then
			return
		end
		local value = tonumber(peek(amount)) or 0
		claiming:set(true)
		ctx.ui.rewards:claim(def.id, nil, nil, function(ok)
			claiming:set(false)
			if ok and currency then
				ctx.ui:give(currency.id, value, amountLabel)
				finish()
			end
		end)
	end
	local function double()
		if def.doubleProductId then
			local value = tonumber(peek(amount)) or 0
			ctx.run({ kind = "Purchase", productId = def.doubleProductId }, { id = def.id, screen = def.id })
			-- the receipt pays it on the server; the popup follows the amount to 0
			if state and not ctx.preview then
				local connection
				connection = scope:Observer(amount):onChange(function()
					if (tonumber(peek(amount)) or 0) <= 0 and value > 0 and peek(open) and currency then
						ctx.ui:give(currency.id, value * 2, amountLabel)
						finish()
						value = 0
					end
				end)
				table.insert(scope, connection)
			end
		else
			ctx.ui:notify("Not for sale yet!")
		end
	end
	local tone = currency and Common.makeTone(Common.color(currency.color)) or nil
	-- the lines sit straight on the window panel: readable on cream too
	local ink = Kit.themed(scope, function(theme)
		return Common.panelInk(theme, def.panelStyle)
	end)
	local inkMuted = scope:Computed(function(use)
		return use(ink).muted
	end)
	local inkStroke = scope:Computed(function(use)
		return if use(ink).stroke then 1.5 else false
	end)
	local inkStrokeColor = scope:Computed(function(use)
		return use(ink).stroke
	end)
	amountLabel = scope:Text {
		Name = "Amount",
		Text = scope:Computed(function(use)
			return Format.abbrev(tonumber(use(amount)) or 0)
		end),
		TextSize = 84,
		Font = "Display",
		Gradient = "Yellow",
		Stroke = 5,
		StrokeColor = Kit.themed(scope, function(theme)
			return theme.Tone.Yellow.Deep
		end),
		LayoutOrder = 2,
	}
	local window = scope:Window {
		Name = def.id,
		Open = open,
		Size = Vector2.new(width, height),
		Title = def.title,
		Icon = def.icon,
		Panel = true,
		Padding = 28,
		Blur = not ctx.preview,
		CloseOnBackdrop = false,
		OnClose = function()
			-- closing still pays: nothing earned is ever lost to a stray tap
			if (tonumber(peek(amount)) or 0) > 0 and not peek(claiming) then
				claim()
			else
				ctx.ui:close(def.id)
			end
		end,
		ZIndex = 10,
		Parent = parent,
		[Children] = scope:Column {
			Name = "Earnings",
			Size = UDim2.fromScale(1, 1),
			Gap = 16,
			VAlign = "Center",
			[Children] = {
				scope:Text {
					Name = "Away",
					Text = scope:Computed(function(use)
						local away = tonumber(use(seconds)) or 0
						return `While you were away ({Rewards.duration(away)}) you earned`
					end),
					Case = "Normal",
					TextSize = 26,
					Font = "Body",
					Color = inkMuted,
					Stroke = inkStroke,
					StrokeColor = inkStrokeColor,
					LayoutOrder = 1,
				},
				scope:Row {
					Name = "Earned",
					Size = UDim2.new(1, 0, 0, 104),
					Gap = 16,
					Align = "Center",
					VAlign = "Center",
					LayoutOrder = 2,
					[Children] = {
						scope:Icon {
							Icon = if currency then currency.icon else "coin",
							Size = 92,
							Color = if tone then Color.lighten(tone.Top, 0.1) else nil,
							Gradient = true,
							Shadow = true,
							Glow = true,
							StrokeColor = if tone then tone.Edge else nil,
							LayoutOrder = 1,
						},
						amountLabel,
						scope:Text {
							Text = if currency then currency.name else "",
							TextSize = 36,
							Font = "Title",
							LayoutOrder = 3,
						},
					},
				},
				scope:Text {
					Name = "Cap",
					Text = `Counted up to {Rewards.duration(def.capHours * 3600)} away. Come back sooner for more!`,
					Case = "Normal",
					TextSize = 19,
					Font = "Body",
					Color = inkMuted,
					Stroke = inkStroke,
					StrokeColor = inkStrokeColor,
					LayoutOrder = 3,
				},
				scope:Row {
					Name = "Buttons",
					Size = UDim2.new(1, 0, 0, 80),
					Gap = 16,
					Align = "Center",
					VAlign = "Center",
					LayoutOrder = 4,
					[Children] = {
						ctx.button(scope, {
							Name = "Claim",
							Text = "CLAIM",
							Icon = if currency then currency.icon else "gift",
							Tone = "Green",
							Primary = true,
							Shine = true,
							Pulse = true,
							TextSize = 32,
							Size = UDim2.fromOffset(if def.doubleProductId or def.doublePrice then 260 else 320, 78),
							LayoutOrder = 1,
						}, { kind = "None" }, nil, claim),
						-- (no product id yet: the button says Not for sale yet)
						if def.doubleProductId or def.doublePrice
							then ctx.button(scope, {
								Name = "Double",
								Text = if def.doublePrice then `x2  {Format.comma(def.doublePrice)}` else "x2",
								Icon = "robux",
								Tone = "Yellow",
								TextSize = 28,
								Size = UDim2.fromOffset(220, 68),
								LayoutOrder = 2,
							}, { kind = "None" }, nil, double)
							else nil,
					},
				},
			},
		},
	}
	return { Open = open, Gui = window }
end

-- Builds a screen into its layer. Returns { Open = Value<boolean>, Gui, ... }.
function Screens.build(ctx: any, scope: any, def: { [string]: any }): { [string]: any }
	local kind = def.kind
	if kind == "Offer" then
		return offerScreen(ctx, scope, def, ctx.layers.popups)
	elseif kind == "OfflineEarnings" then
		return offlineScreen(ctx, scope, def, ctx.layers.popups)
	end
	if kind == "MainMenu" then
		return menuScreen(ctx, scope, def, ctx.layers.menus)
	elseif kind == "Loading" then
		return loadingScreen(ctx, scope, def, ctx.layers.menus)
	elseif kind == "Results" then
		return resultsScreen(ctx, scope, def, ctx.layers.popups)
	elseif kind == "Trade" then
		return tradeScreen(ctx, scope, def, ctx.layers.windows)
	elseif kind == "Popup" then
		return windowScreen(ctx, scope, def, ctx.layers.popups)
	end
	return windowScreen(ctx, scope, def, ctx.layers.windows)
end

Screens.autoSize = autoSize

return Screens
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="80">
            <Properties>
              <string name="Name">Sections</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Blueprint.Sections: one builder per section kind. Every builder gets the
	exact width it may use (design px) and returns its content plus its height,
	so pages stack sections with a fixed rhythm and never overlap.

		local block, height = Sections.build(ctx, scope, section, width, info)

	Builders prefer the real component (docs/COMPONENTS_V2.md) and fall back to
	a styled placeholder made of Panel/Text/Icon when it doesn't exist yet.

	v1.4 layouts (docs/BLUEPRINT.md): Products Hero / Carousel / Rows /
	Bundles, Inventory Side / Bottom / Showcase, Daily Row / Calendar / Ladder
	(with a streak strip). Sections without a layout build the v1.3 look.
]]

local Players = game:GetService("Players")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Util.Format)
local Color = require(script.Parent.Parent.Util.Color)
local Bind = require(script.Parent.Parent.Util.Bind)
local Schema = require(script.Parent.Schema)
local Common = require(script.Parent.Common)
local Actions = require(script.Parent.Actions)
local Rewards = require(script.Parent.Rewards)
-- v2.4 section layouts (Grid, Showcase, Wall, Passes, Mosaic, Tiers, Banner,
-- Path, Chests, Filters, Team)
local SectionLayouts = require(script.Parent.Parent.Prefabs.Layouts)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local Sections = {}

local GAP = 24 -- between cards
local HEADER = 44 -- section title row
local HEADER_GAP = 16 -- title row -> content

-- pageHeight: the whole visible page; available: what is left of it below the
-- sections before this one (both design px)
-- screenTitle: the window (or single-section tab) title the section sits under
type Info = { pageHeight: number, available: number?, screen: string, screenTitle: string?, section: string? }

-- True when a section title only repeats the page title above it.
local function repeatsTitle(title: string?, info: Info): boolean
	return typeof(title) == "string" and typeof(info.screenTitle) == "string" and string.lower(title) == string.lower(info.screenTitle)
end

-- Splits n items into rows of at most perRow, as evenly as possible, with
-- the fuller rows first (5 -> 3+2, 4 -> 2+2, 7 -> 3+2+2).
local function balancedRows(n: number, perRow: number): { number }
	if n <= 0 then
		return {}
	end
	local rows = math.ceil(n / perRow)
	local base = n // rows
	local extra = n % rows
	local out = {}
	for i = 1, rows do
		out[i] = base + (if i <= extra then 1 else 0)
	end
	return out
end
Sections.balancedRows = balancedRows

-- Lays `cells` (already built) into rows that fill the width. Every cell has
-- the same width (the fullest row's), so a shorter last row never stretches
-- its cards wider than the rest. `align` places that row: "Center" (cards,
-- default), "Left" (list rows that read like a grid) or "Fill" (table rows:
-- the last row's cells share the full width).
local function rowsOf(scope: any, cells: { Instance }, perRow: number, cellHeight: number, gap: number, align: string?): (Frame, number)
	local counts = balancedRows(#cells, perRow)
	local columns = math.max(1, counts[1] or 1)
	local rows = {}
	local index = 1
	for r, count in counts do
		local row = {}
		local share = if align == "Fill" then count else columns
		for c = 1, count do
			local cell = cells[index]
			index += 1
			cell.Size = UDim2.new(1 / share, -math.ceil(gap * (share - 1) / share), 0, cellHeight)
			cell.LayoutOrder = c
			table.insert(row, cell)
		end
		table.insert(rows, scope:Row {
			Name = `Row{r}`,
			LayoutOrder = r,
			Size = UDim2.new(1, 0, 0, cellHeight),
			Gap = gap,
			Align = if align == "Left" or align == "Fill" then "Left" else "Center",
			VAlign = "Top",
			[Children] = row,
		})
	end
	local height = #counts * cellHeight + math.max(0, #counts - 1) * gap
	return scope:Column {
		Name = "Rows",
		Size = UDim2.new(1, 0, 0, height),
		Gap = gap,
		[Children] = rows,
	}, height
end

-- The height a self-sizing component gave itself (fallback when it used
-- scale or automatic sizing). Stretches it to the section width.
local function measured(gui: Instance, fallback: number): number
	local ok, size = pcall(function()
		return (gui :: any).Size
	end)
	local height = fallback
	if ok and typeof(size) == "UDim2" and size.Y.Scale == 0 and size.Y.Offset > 0 then
		height = size.Y.Offset
	end
	pcall(function()
		(gui :: any).Size = UDim2.new(1, 0, 0, height)
	end)
	return height
end

local function muted(scope: any)
	return Kit.themed(scope, function(theme)
		return theme.Color.TextMuted
	end)
end

local function accent(scope: any)
	return Kit.themed(scope, function(theme)
		return theme.Color.Accent
	end)
end

-- The panel style of the window a section sits in (a screen's `panelStyle`
-- override, else nil = the theme's).
local function screenPanel(ctx: any, info: Info?): string?
	local id = info and info.screen
	for _, def in (ctx.bp and ctx.bp.screens) or {} do
		if def.id == id then
			return def.panelStyle
		end
	end
	return nil
end

-- Common.panelInk for text drawn straight on the section's window panel.
local function panelInk(scope: any, ctx: any, info: Info?)
	local style = screenPanel(ctx, info)
	return Kit.themed(scope, function(theme)
		return Common.panelInk(theme, style)
	end)
end

-- Text props for a muted line straight on the panel (readable on cream,
-- vivid and dark panels alike).
local function panelMuted(scope: any, ink: any): { [string]: any }
	return {
		Color = scope:Computed(function(use)
			return use(ink).muted
		end),
		Stroke = scope:Computed(function(use)
			return if use(ink).stroke then 1.5 else false
		end),
		StrokeColor = scope:Computed(function(use)
			return use(ink).stroke
		end),
	}
end

local function emptyState(scope: any, textValue: string, iconName: string, height: number): (Frame, number)
	return Common.card(scope, {
		Name = "Empty",
		Size = UDim2.new(1, 0, 0, height),
		Children = scope:Column {
			Size = UDim2.fromScale(1, 1),
			Gap = 8,
			VAlign = "Center",
			ZIndex = 3,
			[Children] = {
				scope:Icon { Icon = iconName, Size = 56, Color = muted(scope), LayoutOrder = 1, ZIndex = 3 },
				scope:Text { Text = textValue, TextSize = 24, Font = "Body", Color = muted(scope), LayoutOrder = 2, ZIndex = 3 },
			},
		},
	}), height
end

--------------------------------------------------------------------------
-- Builders
--------------------------------------------------------------------------

local BUILD: { [string]: (any, any, { [string]: any }, number, Info) -> (Instance, number) } = {}

local function productAmount(item: { [string]: any }): string?
	if item.amount then
		return item.amount
	end
	if item.grant then
		return `+ {Format.comma(item.grant.amount)}`
	end
	return nil
end

-- The priciest item in a section sparkles (the "legendary" offer).
local function topPrice(list: { any }): number
	local best = 0
	for _, item in list do
		if item.currency == "robux" or item.currency == nil then
			best = math.max(best, item.price or 0)
		end
	end
	return best
end

-- Room a card's selling line needs (ProductCard draws it on one line, at
-- 8% of the card height; lower-case body text runs narrower than textWidth).
local function subtitleWidth(text: string?, height: number): number
	if text == nil then
		return 0
	end
	return math.ceil(Common.textWidth(text, math.max(16, math.floor(height * 0.08))) * 0.78) + 32
end

-- `width` (optional): the card's width; a selling line that wouldn't fit
-- is left out rather than run past the card. `exact`: the card is exactly
-- `width` wide (so ProductCard fits its title without measuring).
local function productCard(ctx, scope, item, s, info, height: number, sparkle: boolean, width: number?, exact: boolean?): Instance
	local subtitle = item.description -- v1.4 (nil on v1 items)
	if subtitle and width and subtitleWidth(subtitle, height) > width then
		subtitle = nil
	end
	local card
	card = scope:ProductCard {
		Name = item.id,
		Title = item.title,
		Amount = productAmount(item) or "",
		Subtitle = subtitle,
		Icon = item.icon,
		Tone = item.tone,
		Price = if item.price > 0 then item.price else "FREE",
		PriceIcon = if item.price > 0 then Common.priceIcon(ctx.ui.currencyDefs, item.currency) else nil,
		Tag = item.tag,
		Sparkles = sparkle,
		Size = if exact and width then UDim2.fromOffset(width, height) else UDim2.new(1, 0, 0, height),
		-- the cell width, so long titles fit before the card is measured
		Width = if exact then nil else width,
		OnPurchase = function()
			ctx.buy(item, { id = item.id, screen = info.screen, section = s.id, source = card })
		end,
	}
	return card
end

-- "LEGENDARY" -> "Legendary" when the text is a rarity name, else nil.
local function rarityName(text: string?): string?
	if typeof(text) ~= "string" or text == "" then
		return nil
	end
	local name = string.upper(string.sub(text, 1, 1)) .. string.lower(string.sub(text, 2))
	return if Schema.RARITY_HEX[name] then name else nil
end

-- A compact product row: tile, title + amount, price button.
local function productRow(ctx, scope, item, s, info): Instance
	local row
	local subtitle = productAmount(item) or item.tag
	-- a rarity tag reads in its rarity colour; anything else in the accent
	local rarityTag = rarityName(item.tag)
	row = Common.card(scope, {
		Name = item.id,
		Size = UDim2.new(1, 0, 0, 96),
		Children = {
			Common.iconTile(scope, {
				Icon = item.icon,
				Tone = item.tone,
				Size = 72,
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 12, 0.5, 0),
				ZIndex = 3,
			}),
			-- title and subtitle sit as one block on the row's centre line; a
			-- lone title is centred rather than left hanging at the top
			scope:Text {
				Text = item.title,
				TextSize = 26,
				Font = "Title",
				XAlign = "Left",
				AnchorPoint = Vector2.new(0, if subtitle then 1 else 0.5),
				Size = UDim2.new(1, -300, 0, 30),
				Position = UDim2.new(0, 100, 0.5, if subtitle then 2 else 0),
				Truncate = true,
				ZIndex = 3,
			},
			if subtitle
				then scope:Text {
					Text = subtitle,
					TextSize = 22,
					Font = "Display",
					XAlign = "Left",
					Color = if rarityTag
						then Kit.themed(scope, function(theme)
							return Common.rarityTone(theme, rarityTag).Top
						end)
						else accent(scope),
					Size = UDim2.new(1, -300, 0, 26),
					Position = UDim2.new(0, 100, 0.5, 6),
					Truncate = true,
					ZIndex = 3,
				}
				else nil,
			ctx.button(scope, {
				Name = "Buy",
				Text = if item.price > 0 then Format.comma(item.price) else "FREE",
				Icon = if item.price > 0 then Common.priceIcon(ctx.ui.currencyDefs, item.currency) else nil,
				Tone = "Green",
				Size = UDim2.fromOffset(170, 60),
				TextSize = 26,
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -16, 0.5, 0),
				ZIndex = 4,
			}, { kind = "None" }, nil, function()
				ctx.buy(item, { id = item.id, screen = info.screen, section = s.id, source = row })
			end),
		},
	})
	return row
end

--------------------------------------------------------------------------
-- v1.4 product layouts: Hero, Carousel, Rows, Bundles
--------------------------------------------------------------------------

-- The ribbon a value offer wears: "SAVE 20%" when it has a struck-through
-- price, else "+20% BONUS", else its own tag.
local function valueRibbon(item: { [string]: any }): string?
	if item.was and item.price > 0 and item.was > item.price then
		local save = math.floor((1 - item.price / item.was) * 100 + 0.5)
		if save >= 5 then
			return `SAVE {save}%`
		end
	end
	if item.bonus then
		return `+{item.bonus}% BONUS`
	end
	return item.tag
end

local function priceText(item: { [string]: any }): string
	return if item.price > 0 then Format.comma(item.price) else "FREE"
end

local function priceIconFor(ctx, item: { [string]: any }): string?
	return if item.price > 0 then Common.priceIcon(ctx.ui.currencyDefs, item.currency) else nil
end

-- A struck-through old price ("was 1,299"), right-aligned.
local function wasLabel(scope: any, item: { [string]: any }, props: { [string]: any }): Instance?
	if not item.was then
		return nil
	end
	return scope:Text {
		Name = "Was",
		Text = `<s>{Format.comma(item.was)}</s>`,
		RichText = true,
		Case = "Normal",
		TextSize = props.TextSize or 22,
		Font = "Display",
		Color = Kit.themed(scope, function(theme)
			return theme.Color.TextMuted
		end),
		Stroke = props.Stroke,
		StrokeColor = props.StrokeColor,
		AnchorPoint = props.AnchorPoint,
		Position = props.Position,
		ZIndex = props.ZIndex or 4,
	}
end

-- Hero: one full-width offer on top (ProductCard's Wide layout), the rest
-- as the usual card grid underneath.
local function heroProduct(ctx, scope, item, s, info, height: number): Instance
	local card
	card = scope:ProductCard {
		Name = item.id,
		Title = item.title,
		Amount = productAmount(item) or "",
		Subtitle = item.description,
		Icon = item.icon,
		Tone = item.tone,
		Price = if item.price > 0 then item.price else "FREE",
		PriceIcon = priceIconFor(ctx, item),
		Tag = valueRibbon(item) or "FEATURED",
		Sparkles = true,
		Layout = "Wide",
		Size = UDim2.new(1, 0, 0, height),
		OnPurchase = function()
			ctx.buy(item, { id = item.id, screen = info.screen, section = s.id, source = card })
		end,
	}
	return card
end

-- Carousel: big cards in a horizontal track with arrow buttons and page
-- dots. Everything fits: no arrows, the cards centre themselves.
local function productCarousel(ctx, scope, s, info, list: { any }, width: number): (Instance, number)
	local cardH = if width >= 900 then 300 elseif width >= 600 then 272 else 240
	-- wide enough for the longest selling line (the next card peeks in)
	local cardW = math.floor(cardH * 1.18)
	for _, item in list do
		cardW = math.max(cardW, math.min(subtitleWidth(item.description, cardH), math.floor(width * 0.42)))
	end
	local gap = 20
	local pad = 14 -- room for hover pops and tags that stick out
	local step = cardW + gap
	local contentW = #list * cardW + math.max(0, #list - 1) * gap + pad * 2
	local overflow = contentW > width + 1
	local maxX = math.max(0, contentW - width)
	local best = topPrice(list)
	local cards = {}
	for index, item in list do
		local sparkle = best > 0 and item.price >= best and item.currency == "robux"
		local card = productCard(ctx, scope, item, s, info, cardH, sparkle, cardW, true)
		card.LayoutOrder = index
		table.insert(cards, card)
	end
	local position = scope:Value(0)
	local track
	track = scope:New "ScrollingFrame" {
		Name = "Track",
		Size = UDim2.new(1, 0, 0, cardH + pad * 2),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollingDirection = Enum.ScrollingDirection.X,
		CanvasSize = UDim2.fromOffset(if overflow then contentW else 0, 0),
		ScrollBarThickness = 0,
		ElasticBehavior = Enum.ElasticBehavior.WhenScrollable,
		Selectable = false,
		ZIndex = 2,
		[Fusion.OnChange "CanvasPosition"] = function(now)
			position:set(now.X)
		end,
		[Children] = {
			Kit.list(scope, { Direction = "X", Padding = gap, VAlign = "Center", HAlign = if overflow then "Left" else "Center" }),
			Kit.padding(scope, { pad, pad, pad, pad }),
			cards,
		},
	}
	local function go(direction: number)
		local target = math.clamp((math.floor(peek(position) / step + 0.5) + direction) * step, 0, maxX)
		local ok = pcall(function()
			game:GetService("TweenService"):Create(track, TweenInfo.new(0.35, Enum.EasingStyle.Quint, Enum.EasingDirection.Out), {
				CanvasPosition = Vector2.new(target, 0),
			}):Play()
		end)
		if not ok then
			track.CanvasPosition = Vector2.new(target, 0)
		end
	end
	local children = { track }
	local height = cardH + pad * 2
	if overflow then
		for _, side in { -1, 1 } do
			table.insert(children, ctx.button(scope, {
				Name = if side < 0 then "Previous" else "Next",
				Icon = if side < 0 then "left" else "right",
				Tone = "Dark",
				Style = "Flat",
				Size = UDim2.fromOffset(64, 64),
				IconSize = 36,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(if side < 0 then 0 else 1, if side < 0 then 10 else -10, 0, pad + cardH / 2),
				Visible = scope:Computed(function(use)
					local x = use(position)
					return if side < 0 then x > 4 else x < maxX - 4
				end),
				ZIndex = 8,
			}, { kind = "None" }, nil, function()
				go(side)
			end))
		end
		-- one dot per card; the lit one follows the scroll
		local dots = {}
		for index = 1, #list do
			local lit = scope:Computed(function(use)
				return math.clamp(math.floor(use(position) / step + 0.5) + 1, 1, #list) == index
			end)
			table.insert(dots, scope:New "Frame" {
				Name = `Dot{index}`,
				LayoutOrder = index,
				Size = scope:Computed(function(use)
					return UDim2.fromOffset(if use(lit) then 28 else 10, 10)
				end),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Text
				end),
				BackgroundTransparency = scope:Computed(function(use)
					return if use(lit) then 0.05 else 0.7
				end),
				[Children] = Kit.corner(scope, "Full"),
			})
		end
		table.insert(children, scope:Row {
			Name = "Dots",
			Size = UDim2.new(1, 0, 0, 10),
			Position = UDim2.fromOffset(0, height + 6),
			Gap = 8,
			Align = "Center",
			[Children] = dots,
		})
		height += 16
	end
	return scope:New "Frame" {
		Name = "Carousel",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, height),
		[Children] = children,
	}, height
end

-- Rows: a dense one-column list (many offers): a colour stripe, the art, the
-- name and one line under it, a value ribbon, and the price on the right.
local ROW_HEIGHT = 84

local function listRow(ctx, scope, item, s, info, index: number): Instance
	local row
	local ribbon = valueRibbon(item)
	local subtitle = item.description or productAmount(item)
	local rarityTag = rarityName(item.tag)
	local label = priceText(item)
	local hasIcon = item.price > 0
	local priceW = math.clamp(Common.textWidth(label, 26) + (if hasIcon then 76 else 48), 130, 230)
	local ribbonW = if ribbon then Common.textWidth(ribbon, 17) + 36 else 0
	local wasW = if item.was then Common.textWidth(Format.comma(item.was), 21) + 12 else 0
	local tile = ROW_HEIGHT - 20
	local textX = 30 + tile + 16
	local right = priceW + 20 + (if ribbon then ribbonW + 12 else 0) + wasW + 12
	row = Common.card(scope, {
		Name = item.id,
		Size = UDim2.new(1, 0, 0, ROW_HEIGHT),
		Highlight = index % 2 == 1,
		Children = {
			scope:New "Frame" {
				Name = "Stripe",
				Size = UDim2.new(0, 6, 1, -24),
				Position = UDim2.fromOffset(10, 12),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return Theme.tone(theme, item.tone).Top
				end),
				ZIndex = 3,
				[Children] = Kit.corner(scope, "Full"),
			},
			Common.iconTile(scope, {
				Icon = item.icon,
				Tone = item.tone,
				Size = tile,
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 30, 0.5, 0),
				ZIndex = 3,
			}),
			scope:Text {
				Name = "Title",
				Text = item.title,
				TextSize = 26,
				Font = "Title",
				XAlign = "Left",
				AnchorPoint = Vector2.new(0, if subtitle then 1 else 0.5),
				Size = UDim2.new(1, -(textX + right), 0, 30),
				Position = UDim2.new(0, textX, 0.5, if subtitle then 1 else 0),
				Truncate = true,
				ZIndex = 3,
			},
			if subtitle
				then scope:Text {
					Name = "Subtitle",
					Text = subtitle,
					TextSize = 20,
					Font = if item.description then "Body" else "Display",
					XAlign = "Left",
					Color = if rarityTag
						then Kit.themed(scope, function(theme)
							return Common.rarityTone(theme, rarityTag).Top
						end)
						elseif item.description then muted(scope)
						else accent(scope),
					Stroke = if item.description then false else nil,
					Size = UDim2.new(1, -(textX + right), 0, 24),
					Position = UDim2.new(0, textX, 0.5, 5),
					Truncate = true,
					ZIndex = 3,
				}
				else nil,
			if ribbon
				then Common.pill(scope, {
					Name = "Ribbon",
					Text = ribbon,
					Tone = if item.was or item.bonus then "Red" else "Yellow",
					Height = 30,
					TextSize = 17,
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -(priceW + 20 + wasW + 12), 0.5, 0),
					ZIndex = 4,
				})
				else nil,
			wasLabel(scope, item, {
				TextSize = 21,
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -(priceW + 28), 0.5, 0),
			}),
			ctx.button(scope, {
				Name = "Buy",
				Text = label,
				Icon = priceIconFor(ctx, item),
				Tone = "Green",
				Size = UDim2.fromOffset(priceW, 58),
				TextSize = 26,
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -14, 0.5, 0),
				ZIndex = 4,
			}, { kind = "None" }, nil, function()
				ctx.buy(item, { id = item.id, screen = info.screen, section = s.id, source = row })
			end),
		},
	})
	return row
end

local function productRows(ctx, scope, s, info, list: { any }): (Instance, number)
	local rows = {}
	for index, item in list do
		local row = listRow(ctx, scope, item, s, info, index)
		row.LayoutOrder = index
		table.insert(rows, row)
	end
	local gap = 10
	local height = #rows * ROW_HEIGHT + math.max(0, #rows - 1) * gap
	return scope:Column {
		Name = "Rows",
		Size = UDim2.new(1, 0, 0, height),
		Gap = gap,
		[Children] = rows,
	}, height
end

-- Bundles: value-comparison cards (x1 / x5 / x10 packs). Bigger packs show
-- more art, every saving gets a corner ribbon, and the best deal is lifted
-- and outlined.
local function bundleCard(ctx, scope, item, s, info, height: number, stack: number, isBest: boolean): Instance
	local card
	local ribbon = valueRibbon(item)
	-- one line under the name: the struck-through old price and the bonus
	-- (or a selling line), whichever the offer has
	local bonusText = if item.bonus and ribbon ~= `+{item.bonus}% BONUS` then `+{item.bonus}% BONUS` else nil
	local detailParts = {}
	if item.was then
		table.insert(detailParts, `<font color="#C9CED8"><s>{Format.comma(item.was)}</s></font>`)
	end
	if bonusText or item.description then
		table.insert(detailParts, bonusText or item.description)
	end
	local detail = if #detailParts > 0 then table.concat(detailParts, "  ") else nil
	-- lanes from the bottom up: price button, detail line, name, amount; the
	-- art takes what is left above them
	local buttonTop = height - 14 - 58
	local detailY = buttonTop - 8 - 24
	local titleY = (if detail then detailY - 2 else buttonTop - 10) - 26
	local amountY = titleY - 50
	local artCentre = math.floor(amountY / 2 + 8)
	local artSize = math.clamp(math.floor(amountY * 0.7), 64, 150)
	local offsets = ({ { 0 }, { -0.16, 0.16 }, { -0.22, 0, 0.22 } })[math.clamp(stack, 1, 3)]
	local edge = Kit.themed(scope, function(theme)
		return Theme.tone(theme, item.tone).Edge
	end)
	local art = {}
	for index, offset in offsets do
		local middle = offset == 0 or #offsets == 2
		table.insert(art, scope:Icon {
			Name = `Art{index}`,
			Icon = item.icon,
			Size = if offset == 0 then artSize else math.floor(artSize * 0.82),
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5 + offset, 0, 0, artCentre + (if offset == 0 then 0 else 10)),
			Rotation = offset * 60,
			Color = Kit.themed(scope, function(theme)
				return Color.lighten(Theme.tone(theme, item.tone).Top, 0.3)
			end),
			StrokeColor = edge,
			Stroke = 4,
			Gradient = true,
			Shadow = true,
			ZIndex = if middle then 4 else 3,
		})
	end
	local amount = productAmount(item) or ""
	local label = priceText(item)
	card = Common.card(scope, {
		Name = item.id,
		Size = UDim2.new(1, 0, 0, height),
		Tone = item.tone,
		Radius = "L",
		Clip = true,
		Children = {
			if isBest or stack >= 3
				then Kit.rays(scope, {
					Name = "Rays",
					Size = UDim2.fromOffset(artSize * 3, artSize * 3),
					Position = UDim2.new(0.5, 0, 0, artCentre),
					Color = Color3.new(1, 1, 1),
					Transparency = 0.55,
					ZIndex = 2,
				})
				else nil,
			art,
			if ribbon
				then scope:New "Frame" {
					Name = "Ribbon",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(1, -44, 0, 44),
					Size = UDim2.fromOffset(220, 34),
					Rotation = 45,
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Tone.Red.Bottom
					end),
					ZIndex = 6,
					[Children] = {
						Kit.gradient(scope, {
							Rotation = 90,
							Color = Kit.themed(scope, function(theme)
								return ColorSequence.new(theme.Tone.Red.Top, theme.Tone.Red.Bottom)
							end),
						}),
						scope:Text {
							Text = ribbon,
							TextSize = 17,
							Font = "Display",
							Size = UDim2.new(0, 110, 1, -6),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Scaled = true,
							Wrapped = false,
							MaxTextSize = 17,
							StrokeColor = Kit.themed(scope, function(theme)
								return theme.Tone.Red.Edge
							end),
							ZIndex = 7,
						},
					},
				}
				else nil,
			scope:Text {
				Name = "Amount",
				Text = amount,
				TextSize = 46,
				Font = "Display",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, amountY),
				Size = UDim2.new(1, -24, 0, 50),
				Scaled = true,
				Wrapped = false,
				MaxTextSize = 46,
				StrokeColor = edge,
				Stroke = 3,
				ZIndex = 5,
			},
			scope:Text {
				Name = "Title",
				Text = item.title,
				TextSize = 22,
				Font = "Heavy",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, titleY),
				Size = UDim2.new(1, -24, 0, 26),
				Scaled = true,
				Wrapped = false,
				MaxTextSize = 22,
				StrokeColor = edge,
				ZIndex = 5,
			},
			if detail
				then scope:Text {
					Name = "Detail",
					Text = detail,
					RichText = true,
					TextSize = 19,
					Font = "Display",
					Color = if bonusText
						then Kit.themed(scope, function(theme)
							return Color.lighten(theme.Color.Good, 0.35)
						end)
						else nil,
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, detailY),
					Size = UDim2.new(1, -24, 0, 24),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = 19,
					StrokeColor = edge,
					ZIndex = 5,
				}
				else nil,
			(function()
				local button = ctx.button(scope, {
					Name = "Buy",
					Text = label,
					Icon = priceIconFor(ctx, item),
					Tone = "Green",
					Shine = isBest,
					Size = UDim2.new(1, -28, 0, 58),
					TextSize = 26,
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -14),
					ZIndex = 5,
				}, { kind = "None" }, nil, function()
					ctx.buy(item, { id = item.id, screen = info.screen, section = s.id, source = card })
				end)
				return button
			end)(),
			-- the best deal gets a bright inner outline (the card clips, so it
			-- sits just inside the edge)
			if isBest
				then scope:New "Frame" {
					Name = "BestOutline",
					BackgroundTransparency = 1,
					Position = UDim2.fromOffset(3, 3),
					Size = UDim2.new(1, -6, 1, -6),
					ZIndex = 8,
					[Children] = {
						Kit.corner(scope, "L"),
						Kit.stroke(scope, {
							Color = Kit.themed(scope, function(theme)
								return theme.Color.Light
							end),
							Thickness = 3,
						}),
					},
				}
				else nil,
		},
	})
	return card
end

local function productBundles(ctx, scope, s, info, list: { any }, width: number): (Instance, number)
	-- a value comparison reads best side by side: one row whenever the cards
	-- keep ~200 px each
	local fits = math.max(1, math.floor((width + GAP) / (200 + GAP)))
	local perRow = if #list <= math.min(fits, 5) then #list else math.min(fits, 3)
	local cellW = (width - GAP * (perRow - 1)) / perRow
	local height = if cellW >= 300 then 360 elseif cellW >= 240 then 340 else 320
	-- the best deal: the biggest bonus / saving, else a BEST VALUE tag
	local bestIndex, bestScore = nil, 0
	for index, item in list do
		local score = item.bonus or 0
		if item.was and item.was > item.price and item.price > 0 then
			score = math.max(score, (1 - item.price / item.was) * 100)
		end
		if item.tag == "BEST VALUE" then
			score += 1000
		end
		if score > bestScore then
			bestIndex, bestScore = index, score
		end
	end
	local cells = {}
	for index, item in list do
		-- a bigger pack shows more of its icon: x1, x2, x3 from cheapest to dearest
		local stack = if #list <= 1 then 1 else 1 + math.floor((index - 1) / math.max(1, #list - 1) * 2 + 0.5)
		table.insert(cells, bundleCard(ctx, scope, item, s, info, height, stack, index == bestIndex))
	end
	return rowsOf(scope, cells, perRow, height, GAP)
end

BUILD.Products = function(ctx, scope, s, width, info)
	local list = s.items
	if #list == 0 then
		return emptyState(scope, "Nothing for sale yet", "shop", 160)
	end
	if s.layout == "Carousel" then
		return productCarousel(ctx, scope, s, info, list, width)
	elseif s.layout == "Rows" then
		return productRows(ctx, scope, s, info, list)
	elseif s.layout == "Bundles" then
		return productBundles(ctx, scope, s, info, list, width)
	end
	local best = topPrice(list)
	local perRow = if width >= 900 then 3 elseif width >= 560 then 2 else 1
	local blocks = {}
	local height = 0

	local rest = list
	if s.layout == "Hero" then
		local heroHeight = if width >= 900 then 240 elseif width >= 600 then 212 else 190
		table.insert(blocks, heroProduct(ctx, scope, list[1], s, info, heroHeight))
		height += heroHeight
		rest = { table.unpack(list, 2) }
	elseif s.layout == "Featured" then
		-- The featured row is a hero next to two regular offers (the hero 1.5x
		-- as wide, all three the same height, so text sizes match); narrow
		-- pages get a hero + one, or a short full-width hero. Never a lone
		-- tall slab: the art would be empty and the price button cropped.
		local count = if #list >= 3 and width >= 700 then 3 elseif #list >= 2 and width >= 520 then 2 else 1
		local shares = if count == 3 then { 1.5, 1, 1 } elseif count == 2 then { 1.4, 1 } else { 1 }
		local total = 0
		for _, share in shares do
			total += share
		end
		local gaps = GAP * (count - 1)
		local unit = (width - gaps) / total
		local rowHeight = if count == 1 then 200 elseif unit >= 330 then 248 else 232
		local cells = {}
		for i = 1, count do
			local item = list[i]
			local fraction = shares[i] / total
			local card = productCard(ctx, scope, item, s, info, rowHeight, i == 1, unit * shares[i])
			-- scale + offset, so the row still fits when the page shows a scrollbar
			card.Size = UDim2.new(fraction, -math.ceil(gaps * fraction), 0, rowHeight)
			card.LayoutOrder = i
			table.insert(cells, card)
		end
		table.insert(blocks, scope:Row {
			Name = "Featured",
			Size = UDim2.new(1, 0, 0, rowHeight),
			Gap = GAP,
			VAlign = "Top",
			[Children] = cells,
		})
		height += rowHeight
		rest = { table.unpack(list, count + 1) }
	end

	if s.layout == "Compact" then
		local cells = {}
		for _, item in rest do
			table.insert(cells, productRow(ctx, scope, item, s, info))
		end
		if #cells > 0 then
			-- two columns only when each row keeps room for its text
			local rows, h = rowsOf(scope, cells, if width >= 1100 then 2 else 1, 96, 16, "Left")
			table.insert(blocks, rows)
			height += (if height > 0 then GAP else 0) + h
		end
	elseif #rest > 0 then
		local cellWidth = (width - (perRow - 1) * GAP) / perRow
		if Theme.get().Family ~= nil then
			-- v2.4 style families: the cards' real width (rowsOf gives a short
			-- row fewer, wider cells), so a wide window's cards lay out their
			-- title for the width they really get
			local columns = math.max(1, balancedRows(#rest, perRow)[1] or 1)
			cellWidth = (width - (columns - 1) * GAP) / columns
		end
		local cardHeight = if cellWidth >= 330 then 248 else 220
		local cells = {}
		for _, item in rest do
			local sparkle = best > 0 and item.price >= best and (item.currency == "robux")
			table.insert(cells, productCard(ctx, scope, item, s, info, cardHeight, sparkle, cellWidth))
		end
		local rows, h = rowsOf(scope, cells, perRow, cardHeight, GAP)
		table.insert(blocks, rows)
		height += (if height > 0 then GAP else 0) + h
	end

	for i, block in blocks do
		block.LayoutOrder = i
	end
	return scope:Column {
		Name = "Products",
		Size = UDim2.new(1, 0, 0, height),
		Gap = GAP,
		[Children] = blocks,
	}, height
end

-- Placeholder item tile (when ItemSlot doesn't exist).
local function itemTile(ctx, scope, item, size: number, selected: any, onClick: (() -> ())?): Instance
	local ItemSlot = Common.component("ItemSlot")
	if ItemSlot then
		local slot = Common.try(ItemSlot, scope, {
			Name = item.id,
			Icon = item.icon,
			Rarity = item.rarity,
			Count = item.count,
			Equipped = item.equipped,
			Locked = item.locked,
			Selected = selected,
			Size = size,
			OnClick = onClick,
		})
		if slot then
			return slot
		end
	end
	local tone = scope:Computed(function(use)
		return Common.rarityTone(use(Theme.current), item.rarity)
	end)
	local tile = Common.iconTile(scope, {
		Name = "Art",
		Icon = item.icon,
		Tone = tone,
		Size = size,
		IconScale = 0.6,
		ZIndex = 2,
	})
	tile.Size = UDim2.fromScale(1, 1)
	return scope:New "TextButton" {
		Name = item.id,
		Text = "",
		AutoButtonColor = false,
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(size, size),
		[OnEvent "Activated"] = function()
			if onClick then
				onClick()
			end
		end,
		[Children] = {
			tile,
			if item.count and item.count > 1
				then scope:Text {
					Text = `x{item.count}`,
					TextSize = 20,
					Font = "Display",
					AnchorPoint = Vector2.new(1, 1),
					Position = UDim2.new(1, -8, 1, -6),
					ZIndex = 6,
				}
				else nil,
			if item.equipped
				then scope:Icon {
					Icon = "check",
					Size = 28,
					Position = UDim2.fromOffset(6, 6),
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Good
					end),
					ZIndex = 6,
				}
				else nil,
			if item.locked
				then scope:New "Frame" {
					Name = "Locked",
					Size = UDim2.fromScale(1, 1),
					BackgroundColor3 = Color3.new(0, 0, 0),
					BackgroundTransparency = 0.45,
					ZIndex = 7,
					[Children] = {
						Kit.corner(scope, "M"),
						scope:Icon {
							Icon = "lock",
							Size = 40,
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							ZIndex = 8,
						},
					},
				}
				else nil,
			scope:New "UIStroke" {
				ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				Color = Color3.new(1, 1, 1),
				Thickness = 4,
				Enabled = scope:Computed(function(use)
					return use(selected) == true
				end),
			},
		},
	}
end

-- Big art + name + rarity + actions (placeholder for ItemDetail).
-- layout: "Panel" (default, a tall side panel) | "Bar" (a wide strip).
local function detailPanel(ctx, scope, selectedItem: any, actions: { any }, width: number, height: number, layout: string?): Instance
	local ItemDetail = Common.component("ItemDetail")
	if ItemDetail then
		local panel = Common.try(ItemDetail, scope, {
			Item = selectedItem,
			Actions = actions,
			Layout = layout,
			Size = UDim2.fromOffset(width, height),
		})
		if panel then
			return panel
		end
	end
	local rarityTone = scope:Computed(function(use)
		local item = use(selectedItem)
		return Common.rarityTone(use(Theme.current), if item then item.rarity else "Common")
	end)
	local buttons = {}
	for index, a in actions do
		table.insert(buttons, ctx.button(scope, {
			Name = a.Text,
			Text = a.Text,
			Tone = a.Tone or "Green",
			Size = UDim2.new(1, 0, 0, 64),
			LayoutOrder = index,
			Shine = index == 1,
		}, { kind = "None" }, nil, function()
			local item = peek(selectedItem)
			if item and a.OnClick then
				a.OnClick(item)
			end
		end))
	end
	return Common.card(scope, {
		Name = "Detail",
		Size = UDim2.fromOffset(width, height),
		Children = {
			scope:Column {
				Size = UDim2.new(1, -40, 1, -40),
				Position = UDim2.fromOffset(20, 20),
				Gap = 12,
				ZIndex = 3,
				[Children] = {
					Common.iconTile(scope, {
						Icon = scope:Computed(function(use)
							local item = use(selectedItem)
							return if item then item.icon else "backpack"
						end),
						Tone = rarityTone,
						Size = math.min(200, width - 80),
						LayoutOrder = 1,
						ZIndex = 3,
					}),
					scope:Text {
						Text = scope:Computed(function(use)
							local item = use(selectedItem)
							return if item then item.name else "Select an item"
						end),
						TextSize = 32,
						Font = "Title",
						LayoutOrder = 2,
						ZIndex = 3,
					},
					scope:Text {
						Text = scope:Computed(function(use)
							local item = use(selectedItem)
							return if item then string.upper(item.rarity) else ""
						end),
						TextSize = 20,
						Font = "Heavy",
						Color = scope:Computed(function(use)
							return use(rarityTone).Top
						end),
						LayoutOrder = 3,
						ZIndex = 3,
					},
					scope:Column {
						Name = "Actions",
						Size = UDim2.new(1, 0, 0, #buttons * 76),
						Gap = 12,
						VAlign = "Bottom",
						LayoutOrder = 4,
						ZIndex = 3,
						[Children] = buttons,
					},
				},
			},
		},
	})
end

-- The item grid of an Inventory section (ItemGrid, or plain tiles). `items`
-- may be a list or a state of one.
local function inventoryGrid(ctx, scope, s, items: any, width: number, height: number, selectedId: any, onSelect: (any) -> ()): Instance
	local cellGap = 12
	local columns = math.max(3, math.floor((width - 16 + cellGap) / (112 + cellGap)))
	local cell = math.floor((width - 16 - (columns - 1) * cellGap) / columns)
	local ItemGrid = Common.component("ItemGrid")
	if ItemGrid then
		local grid = Common.try(ItemGrid, scope, {
			Items = items,
			Columns = columns,
			CellSize = cell,
			Selected = selectedId,
			OnSelect = onSelect,
			Size = UDim2.fromOffset(width, height),
		})
		if grid then
			return grid
		end
	end
	local tiles = {}
	for index, item in peek(items) do
		local isSelected = scope:Computed(function(use)
			return use(selectedId) == item.id
		end)
		local tile = itemTile(ctx, scope, item, cell, isSelected, function()
			onSelect(item)
		end)
		tile.LayoutOrder = index
		table.insert(tiles, tile)
	end
	return scope:Scroll {
		Name = "ItemGrid",
		Size = UDim2.fromOffset(width, height),
		Padding = { 4, 8, 16, 4 },
		Grid = { CellSize = Vector2.new(cell, cell), Gap = cellGap, Columns = columns },
		Align = "Left",
		[Children] = tiles,
	}
end

-- Rows the grid needs for `count` items at this width, in px.
local function gridNatural(width: number, count: number): number
	local cellGap = 12
	local columns = math.max(3, math.floor((width - 16 + cellGap) / (112 + cellGap)))
	local cell = math.floor((width - 16 - (columns - 1) * cellGap) / columns)
	return math.ceil(count / columns) * (cell + cellGap) + 24
end

-- v1.4 Inventory "Bottom": the grid across the whole width with a detail
-- bar (showcase, name, rarity, actions) underneath.
local function inventoryBottom(ctx, scope, s, width: number, info, selectedId: any, selectedItem: any, select: (any) -> ()): (Instance, number)
	local barHeight = if width >= 900 then 176 else 160
	local header = if s.title then HEADER + HEADER_GAP else 0
	local room = (info.available or info.pageHeight) - header
	local natural = gridNatural(width, #s.items)
	local gridHeight = math.clamp(math.min(natural, room - barHeight - GAP), 220, math.max(220, info.pageHeight - header - barHeight - GAP))
	local grid = inventoryGrid(ctx, scope, s, s.items, width, gridHeight, selectedId, select)
	local bar = detailPanel(ctx, scope, selectedItem, {
		{
			Text = "Equip",
			Tone = "Green",
			OnClick = function(item)
				ctx.ui:emit("Equip", { id = item.id, screen = info.screen, payload = item.id })
			end,
		},
		{
			Text = "Favorite",
			Tone = "Gray",
			Icon = "star",
			OnClick = function(item)
				ctx.ui:emit("Favorite", { id = item.id, screen = info.screen, payload = item.id })
			end,
		},
	}, width, barHeight, "Bar")
	bar.Position = UDim2.fromOffset(0, gridHeight + GAP)
	local height = gridHeight + GAP + barHeight
	return scope:New "Frame" {
		Name = "Inventory",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, height),
		[Children] = { grid, bar },
	}, height
end

-- v1.4 Inventory "Showcase": the equipped items as three big slots on top,
-- the collection grid underneath. Equip puts the selected item on show;
-- tapping a showcase slot takes it off.
local SHOWCASE_SLOTS = 3

local function inventoryShowcase(ctx, scope, s, width: number, info, selectedId: any, selectedItem: any, select: (any) -> ()): (Instance, number)
	local panelHeight = if width >= 800 then 216 else 192
	local slotSize = panelHeight - 44
	local header = if s.title then HEADER + HEADER_GAP else 0
	local room = (info.available or info.pageHeight) - header
	local natural = gridNatural(width, #s.items)
	local gridHeight = math.clamp(math.min(natural, room - panelHeight - GAP), 220, math.max(220, info.pageHeight - header - panelHeight - GAP))
	local initial = {}
	for _, item in s.items do
		if item.equipped and not item.locked and #initial < SHOWCASE_SLOTS then
			table.insert(initial, item.id)
		end
	end
	local equipped = ctx.ui:state(s.id, "showcase", initial)
	local byId = {}
	for _, item in s.items do
		byId[item.id] = item
	end
	-- the grid shows a check on whatever is on show
	local shown = scope:Computed(function(use)
		local ids = use(equipped)
		local out = {}
		for _, item in s.items do
			local copy = table.clone(item)
			copy.equipped = table.find(ids, item.id) ~= nil
			table.insert(out, copy)
		end
		return out
	end)
	local function equip()
		local item = peek(selectedItem)
		if item == nil or item.locked then
			ctx.ui:notify(if item and item.locked then "That one's still locked!" else "Pick something first!", "warning")
			return
		end
		local ids = table.clone(peek(equipped))
		if table.find(ids, item.id) then
			return
		end
		table.insert(ids, item.id)
		while #ids > SHOWCASE_SLOTS do
			table.remove(ids, 1)
		end
		equipped:set(ids)
		ctx.ui:emit("Equip", { id = item.id, screen = info.screen, payload = item.id })
	end
	local function unequip(id: string)
		local ids = table.clone(peek(equipped))
		local index = table.find(ids, id)
		if index then
			table.remove(ids, index)
			equipped:set(ids)
			ctx.ui:emit("Unequip", { id = id, screen = info.screen, payload = id })
		end
	end
	local ItemSlot = Common.component("ItemSlot")
	local slots = {}
	for index = 1, SHOWCASE_SLOTS do
		table.insert(slots, scope:Computed(function(use, inner)
			local id = use(equipped)[index]
			local item = if id then byId[id] else nil
			if item and ItemSlot then
				local slot = Common.try(ItemSlot, inner, {
					Name = item.name,
					Icon = item.icon,
					Rarity = item.rarity,
					Count = item.count,
					Size = slotSize,
					LayoutOrder = index,
					OnClick = function()
						unequip(item.id)
					end,
				})
				if slot then
					return slot
				end
			end
			if item then
				local tile = itemTile(ctx, inner, item, slotSize, false, function()
					unequip(item.id)
				end)
				tile.LayoutOrder = index
				return tile
			end
			-- an empty slot waiting for something
			return inner:New "Frame" {
				Name = `Empty{index}`,
				LayoutOrder = index,
				Size = UDim2.fromOffset(slotSize, slotSize),
				BackgroundColor3 = Kit.themed(inner, function(theme)
					return theme.Color.Surface
				end),
				BackgroundTransparency = 0.55,
				[Children] = {
					Kit.corner(inner, "M"),
					Kit.stroke(inner, {
						Color = Kit.themed(inner, function(theme)
							return theme.Color.TextMuted
						end),
						Thickness = 2,
						Transparency = 0.45,
					}),
					inner:Icon {
						Icon = "plus",
						Size = math.floor(slotSize * 0.32),
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.45),
						Color = Kit.themed(inner, function(theme)
							return theme.Color.TextMuted
						end),
						Transparency = 0.3,
						ZIndex = 2,
					},
					inner:Text {
						Text = "Empty",
						TextSize = 18,
						Font = "Heavy",
						Color = Kit.themed(inner, function(theme)
							return theme.Color.TextMuted
						end),
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.new(0.5, 0, 1, -12),
						ZIndex = 2,
					},
				},
			}
		end))
	end
	local slotsWidth = SHOWCASE_SLOTS * slotSize + (SHOWCASE_SLOTS - 1) * 16
	local panel = Common.card(scope, {
		Name = "Showcase",
		Size = UDim2.new(1, 0, 0, panelHeight),
		Radius = "L",
		Children = {
			scope:Column {
				Name = "Info",
				Size = UDim2.new(1, -(slotsWidth + 72), 1, -40),
				Position = UDim2.fromOffset(28, 20),
				Gap = 4,
				Align = "Left",
				ZIndex = 3,
				[Children] = {
					scope:Text {
						Text = "ON SHOW",
						TextSize = 20,
						Font = "Heavy",
						Color = accent(scope),
						LayoutOrder = 1,
						ZIndex = 3,
					},
					scope:Text {
						Text = scope:Computed(function(use)
							return `{#use(equipped)}/{SHOWCASE_SLOTS}`
						end),
						Case = "Normal",
						TextSize = 48,
						Font = "Display",
						LayoutOrder = 2,
						ZIndex = 3,
					},
					scope:Text {
						Text = scope:Computed(function(use)
							local item = use(selectedItem)
							return if item then item.name else "Pick an item below"
						end),
						TextSize = 20,
						Font = "Body",
						Color = muted(scope),
						Size = UDim2.new(1, 0, 0, 26),
						XAlign = "Left",
						Truncate = true,
						Stroke = false,
						LayoutOrder = 3,
						ZIndex = 3,
					},
				},
			},
			ctx.button(scope, {
				Name = "EquipSelected",
				Text = "Equip",
				Icon = "check",
				Tone = "Green",
				Shine = true,
				Size = UDim2.fromOffset(math.clamp(width - slotsWidth - 88, 150, 230), 58),
				TextSize = 26,
				AnchorPoint = Vector2.new(0, 1),
				Position = UDim2.new(0, 28, 1, -20),
				ZIndex = 4,
			}, { kind = "None" }, nil, equip),
			scope:Row {
				Name = "Slots",
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -22, 0.5, 0),
				Size = UDim2.fromOffset(slotsWidth, slotSize),
				Gap = 16,
				Align = "Right",
				ZIndex = 3,
				[Children] = slots,
			},
		},
	})
	local grid = inventoryGrid(ctx, scope, s, shown, width, gridHeight, selectedId, select)
	grid.Position = UDim2.fromOffset(0, panelHeight + GAP)
	local height = panelHeight + GAP + gridHeight
	return scope:New "Frame" {
		Name = "Inventory",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, height),
		[Children] = { panel, grid },
	}, height
end

BUILD.Inventory = function(ctx, scope, s, width, info)
	if #s.items == 0 then
		return emptyState(scope, "Your inventory is empty", "backpack", 200)
	end
	if s.layout == "Bottom" or s.layout == "Showcase" then
		local selectedId = ctx.ui:state(s.id, "selected", s.items[1].id)
		local selectedItem = scope:Computed(function(use)
			local id = use(selectedId)
			for _, item in s.items do
				if item.id == id then
					return item
				end
			end
			return nil
		end)
		local function select(item)
			selectedId:set(item.id)
			ctx.ui:emit(s.id, { id = s.id, screen = info.screen, payload = item.id })
		end
		if s.layout == "Showcase" and width >= 620 then
			return inventoryShowcase(ctx, scope, s, width, info, selectedId, selectedItem, select)
		elseif s.layout == "Bottom" and width >= 560 then
			return inventoryBottom(ctx, scope, s, width, info, selectedId, selectedItem, select)
		end
	end
	local detailWidth = if s.detail and s.layout ~= "Bottom" and s.layout ~= "Showcase" and width >= 800 then 340 else 0
	local gridWidth = width - (if detailWidth > 0 then detailWidth + GAP else 0)
	local cellGap = 12
	local columns = math.max(3, math.floor((gridWidth - 16 + cellGap) / (112 + cellGap)))
	local cell = math.floor((gridWidth - 16 - (columns - 1) * cellGap) / columns)
	-- as tall as its rows (the detail panel needs ~440), at most the page;
	-- more items than that scroll inside the grid
	local rowsNeeded = math.ceil(#s.items / columns)
	local natural = rowsNeeded * (cell + cellGap) + 24
	-- As tall as its rows (the detail panel likes 440, and needs 360). It
	-- takes the room left below the sections above it when that's enough (so
	-- the detail's buttons stay above the fold), otherwise at most one page:
	-- the page scrolls anyway, and more rows scroll inside the grid.
	local header = if s.title then HEADER + HEADER_GAP else 0
	local preferred = if detailWidth > 0 then 440 else 240
	local minimum = if detailWidth > 0 then 360 else 240
	local want = math.max(preferred, natural)
	local room = (info.available or info.pageHeight) - header
	local page = math.max(minimum, info.pageHeight - header)
	local height = if want <= room then want elseif room >= minimum then room else math.min(want, page)
	local selectedId = ctx.ui:state(s.id, "selected", s.items[1].id)
	local selectedItem = scope:Computed(function(use)
		local id = use(selectedId)
		for _, item in s.items do
			if item.id == id then
				return item
			end
		end
		return nil
	end)
	local function select(item)
		selectedId:set(item.id)
		ctx.ui:emit(s.id, { id = s.id, screen = info.screen, payload = item.id })
	end

	local grid = nil
	local ItemGrid = Common.component("ItemGrid")
	if ItemGrid then
		grid = Common.try(ItemGrid, scope, {
			Items = s.items,
			Columns = columns,
			CellSize = cell,
			Selected = selectedId,
			OnSelect = select,
			Size = UDim2.fromOffset(gridWidth, height),
		})
	end
	if grid == nil then
		local tiles = {}
		for index, item in s.items do
			local isSelected = scope:Computed(function(use)
				return use(selectedId) == item.id
			end)
			local tile = itemTile(ctx, scope, item, cell, isSelected, function()
				select(item)
			end)
			tile.LayoutOrder = index
			table.insert(tiles, tile)
		end
		grid = scope:Scroll {
			Name = "ItemGrid",
			Size = UDim2.fromOffset(gridWidth, height),
			Padding = { 4, 8, 16, 4 },
			Grid = { CellSize = Vector2.new(cell, cell), Gap = cellGap, Columns = columns },
			Align = "Left",
			[Children] = tiles,
		}
	end

	local children = { grid }
	if detailWidth > 0 then
		local detail = detailPanel(ctx, scope, selectedItem, {
			{
				Text = "Equip",
				Tone = "Green",
				OnClick = function(item)
					ctx.ui:emit("Equip", { id = item.id, screen = info.screen, payload = item.id })
				end,
			},
		}, detailWidth, height)
		detail.AnchorPoint = Vector2.new(1, 0)
		detail.Position = UDim2.fromScale(1, 0)
		table.insert(children, detail)
	end
	return scope:New "Frame" {
		Name = "Inventory",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, height),
		[Children] = children,
	}, height
end

-- Price of an upgrade's next level when it is at `level` (same curve as UI.Server).
local function upgradeCost(u: any, level: number): number
	local price = math.ceil(u.cost)
	for _ = u.level + 1, level do
		price = math.ceil(price * 1.6)
	end
	return price
end

BUILD.Upgrades = function(ctx, scope, s, width, info)
	if #s.upgrades == 0 then
		return emptyState(scope, "No upgrades yet", "upgrade", 160)
	end
	local UpgradeCard = Common.component("UpgradeCard")
	-- two across only when each card keeps ~490 px for its title, level and price
	local perRow = if width >= 1000 then 2 else 1
	local cardHeight = 136
	-- the card lays itself out for its real width (rowsOf sets the final size)
	local cardWidth = math.floor((width - 16 * (perRow - 1)) / perRow)
	local cells = {}
	for _, u in s.upgrades do
		-- server currencies: the level is the player's BloxUI_Upgrade_<id>
		-- attribute, raised only by UI.Server; the price follows from it
		local currencyDef = ctx.ui.currencyDefs[u.currency]
		local player = Players.LocalPlayer
		local serverLevel = not ctx.preview and player ~= nil and currencyDef ~= nil and currencyDef.source.kind ~= "none"
		local level, cost
		if serverLevel then
			level = Bind.fromAttribute(scope, player, `BloxUI_Upgrade_{u.id}`, u.level)
			cost = scope:Computed(function(use)
				local now = use(level)
				return upgradeCost(u, if typeof(now) == "number" then now else u.level)
			end)
		else
			level = ctx.ui:state(s.id, u.id .. ".level", u.level)
			cost = ctx.ui:state(s.id, u.id .. ".cost", u.cost)
		end
		local maxed = scope:Computed(function(use)
			local now = use(level)
			return typeof(now) == "number" and now >= u.maxLevel
		end)
		local card
		local function buy()
			if peek(maxed) then
				return
			end
			local item = { id = u.id, title = u.title, price = peek(cost), currency = u.currency }
			Actions.buy(ctx.ui, item, { id = u.id, screen = info.screen, section = s.id, source = card }, function(ok)
				if not ok then
					return
				end
				if not serverLevel then
					level:set(peek(level) + 1)
					cost:set(math.ceil(peek(cost) * 1.6))
				end
				ctx.ui:emit("Upgrade", { id = u.id, screen = info.screen, payload = { id = u.id, level = peek(level) } })
			end)
		end
		if UpgradeCard then
			card = Common.try(UpgradeCard, scope, {
				Name = u.id,
				Title = u.title,
				Icon = u.icon,
				Level = level,
				MaxLevel = u.maxLevel,
				Cost = cost,
				CurrencyIcon = Common.priceIcon(ctx.ui.currencyDefs, u.currency),
				Effect = u.effect,
				OnBuy = buy,
				Disabled = maxed,
				Size = UDim2.fromOffset(cardWidth, cardHeight),
			})
		end
		if card == nil then
			local tone = Schema.MENU_TONES[(#cells % #Schema.MENU_TONES) + 1]
			card = Common.card(scope, {
				Name = u.id,
				Size = UDim2.new(1, 0, 0, cardHeight),
				Children = {
					Common.iconTile(scope, {
						Icon = u.icon,
						Tone = tone,
						Size = 96,
						AnchorPoint = Vector2.new(0, 0.5),
						Position = UDim2.new(0, 20, 0.5, 0),
						ZIndex = 3,
					}),
					scope:Text {
						Text = u.title,
						TextSize = 28,
						Font = "Title",
						XAlign = "Left",
						Size = UDim2.new(1, -330, 0, 32),
						Position = UDim2.fromOffset(132, 18),
						Truncate = true,
						ZIndex = 3,
					},
					scope:Text {
						Text = u.effect or "",
						TextSize = 20,
						Font = "Body",
						Color = accent(scope),
						XAlign = "Left",
						Size = UDim2.new(1, -330, 0, 24),
						Position = UDim2.fromOffset(132, 54),
						Truncate = true,
						ZIndex = 3,
					},
					scope:ProgressBar {
						Name = "Level",
						Value = level,
						Max = u.maxLevel,
						Tone = "Blue",
						Ghost = false,
						Segments = math.min(u.maxLevel, 10),
						Label = function(value, max)
							return `Lv {math.floor(value)}/{max}`
						end,
						Size = UDim2.new(1, -330, 0, 26),
						Position = UDim2.fromOffset(132, 90),
						ZIndex = 3,
					},
					ctx.button(scope, {
						Name = "Buy",
						Text = scope:Computed(function(use)
							return if use(maxed) then "MAX" else Format.abbrev(use(cost))
						end),
						Icon = Common.priceIcon(ctx.ui.currencyDefs, u.currency),
						Tone = "Green",
						Disabled = maxed,
						Size = UDim2.fromOffset(164, 64),
						TextSize = 28,
						AnchorPoint = Vector2.new(1, 0.5),
						Position = UDim2.new(1, -20, 0.5, 0),
						ZIndex = 4,
					}, { kind = "None" }, nil, buy),
				},
			})
		end
		table.insert(cells, card)
	end
	local rows, height = rowsOf(scope, cells, perRow, cardHeight, 16, "Left")
	return rows, height
end

-- The streak strip over a Calendar / Ladder: a flame pill with the streak
-- and a one-line reminder of the rule.
local STREAK_STRIP = 44

local function streakStrip(scope: any, claimed: any, ink: any): Frame
	local note = panelMuted(scope, ink)
	return scope:New "Frame" {
		Name = "StreakStrip",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, STREAK_STRIP),
		[Children] = {
			Common.pill(scope, {
				Name = "Streak",
				Icon = "fire",
				Text = scope:Computed(function(use)
					local n = use(claimed)
					return if n == 1 then "1 DAY STREAK" else `{n} DAY STREAK`
				end),
				Tone = "Orange",
				Height = 40,
				TextSize = 22,
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 4, 0.5, 0),
			}),
			scope:Text {
				Text = "Miss a day and the streak starts over",
				TextSize = 20,
				Font = "Body",
				Color = note.Color,
				Stroke = note.Stroke,
				StrokeColor = note.StrokeColor,
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -8, 0.5, 0),
			},
		},
	}
end

BUILD.Daily = function(ctx, scope, s, width, info)
	-- v1.4: `streak` days are already claimed when the UI opens
	local claimed = ctx.ui:state(s.id, "claimed", s.streak or 0)
	local today = (s.streak or 0) + 1
	local function claim(day: number)
		if day ~= peek(claimed) + 1 then
			return
		end
		claimed:set(day)
		ctx.ui:emit("DailyClaim", { id = s.id, screen = info.screen, payload = day })
		ctx.ui:notify(`Day {day} reward claimed!`, "reward")
	end
	local count = #s.days
	local DailyGridComponent = Common.component("DailyGrid")
	if DailyGridComponent and (s.layout == "Calendar" or s.layout == "Ladder") then
		local gap = if s.layout == "Calendar" then 10 else 12
		local columns = if s.layout == "Calendar" then math.min(count, 7) else count
		local tileWidth, tileHeight
		if s.layout == "Calendar" then
			local rows = math.ceil(count / columns)
			local room = (info.available or info.pageHeight) - STREAK_STRIP - 16 - 24 - (rows - 1) * gap
			tileWidth = math.min(128, math.floor((width - 24 - gap * (columns - 1)) / columns))
			tileHeight = math.clamp(math.floor(room / rows), 96, math.min(120, tileWidth + 8))
		else
			tileWidth = math.min(150, math.floor((width - 24 - gap * (columns - 1)) / columns))
			tileHeight = math.clamp(math.floor(tileWidth * 1.6), 168, 220)
		end
		local grid = Common.try(DailyGridComponent, scope, {
			Days = s.days,
			Today = today,
			Claimed = claimed,
			OnClaim = claim,
			Columns = columns,
			Gap = gap,
			Style = s.layout,
			TileSize = Vector2.new(tileWidth, tileHeight),
		})
		if grid then
			local gridHeight = measured(grid, 200)
			grid.Position = UDim2.fromOffset(0, STREAK_STRIP + 16)
			local height = STREAK_STRIP + 16 + gridHeight
			return scope:New "Frame" {
				Name = "Daily",
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, height),
				[Children] = { streakStrip(scope, claimed, panelInk(scope, ctx, info)), grid },
			}, height
		end
	end
	local perRow = math.min(count, 7)
	local rowsCount = math.ceil(count / perRow)
	local cellHeight = 200
	local height = rowsCount * cellHeight + (rowsCount - 1) * 16
	local DailyGrid = Common.component("DailyGrid")
	if DailyGrid then
		local tileWidth = math.min(150, math.floor((width - 32 - 12 * (perRow - 1)) / perRow))
		local grid = Common.try(DailyGrid, scope, {
			Days = s.days,
			Today = today,
			Claimed = claimed,
			OnClaim = claim,
			Columns = perRow,
			TileSize = Vector2.new(tileWidth, 176),
		})
		if grid then
			return grid, measured(grid, height)
		end
	end
	local cells = {}
	for index, d in s.days do
		local isToday = scope:Computed(function(use)
			return use(claimed) + 1 == d.day
		end)
		local done = scope:Computed(function(use)
			return use(claimed) >= d.day
		end)
		local big = index == count
		table.insert(cells, Common.card(scope, {
			Name = `Day{d.day}`,
			Tone = scope:Computed(function(use)
				return if use(done) then "Gray" elseif use(isToday) then "Green" elseif big then "Purple" else "Blue"
			end),
			Children = {
				scope:Text {
					Text = d.label or `Day {d.day}`,
					TextSize = 22,
					Font = "Title",
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, 12),
					ZIndex = 3,
				},
				scope:Icon {
					Icon = scope:Computed(function(use)
						return if use(done) then "check" else d.icon
					end),
					Size = 72,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Color = Color3.new(1, 1, 1),
					Gradient = true,
					Shadow = true,
					ZIndex = 3,
				},
				scope:Text {
					Text = if d.amount then `x{d.amount}` else "",
					TextSize = 26,
					Font = "Display",
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -12),
					ZIndex = 3,
				},
				scope:New "TextButton" {
					Name = "Claim",
					Text = "",
					BackgroundTransparency = 1,
					Size = UDim2.fromScale(1, 1),
					ZIndex = 5,
					[OnEvent "Activated"] = function()
						claim(d.day)
					end,
				},
			},
		}))
	end
	local rows = rowsOf(scope, cells, perRow, cellHeight, 16)
	return rows, height
end

BUILD.Quests = function(ctx, scope, s, width, info)
	if #s.quests == 0 then
		return emptyState(scope, "No quests right now", "quest", 160)
	end
	local QuestCard = Common.component("QuestCard")
	local perRow = if width >= 1000 then 2 else 1
	local cardHeight = 124
	local cells = {}
	for _, q in s.quests do
		local progress = ctx.ui:state(s.id, q.id .. ".progress", q.progress)
		local claimedValue = ctx.ui:state(s.id, q.id .. ".claimed", false)
		local complete = scope:Computed(function(use)
			return use(progress) >= q.goal
		end)
		local function claim()
			if not peek(complete) or peek(claimedValue) then
				return
			end
			claimedValue:set(true)
			ctx.ui:emit("QuestClaim", { id = q.id, screen = info.screen, payload = q.id })
			ctx.ui:emit(q.id, { id = q.id, screen = info.screen })
		end
		local card = nil
		if QuestCard then
			card = Common.try(QuestCard, scope, {
				Name = q.id,
				Title = q.title,
				Description = q.description,
				Icon = q.icon,
				Progress = progress,
				Goal = q.goal,
				Reward = if q.reward then { Icon = q.reward.icon, Amount = q.reward.amount } else nil,
				Claimed = claimedValue,
				OnClaim = claim,
				Size = UDim2.new(1, 0, 0, cardHeight),
			})
		end
		if card == nil then
			card = Common.card(scope, {
				Name = q.id,
				Size = UDim2.new(1, 0, 0, cardHeight),
				Children = {
					Common.iconTile(scope, {
						Icon = q.icon,
						Tone = "Blue",
						Size = 84,
						AnchorPoint = Vector2.new(0, 0.5),
						Position = UDim2.new(0, 20, 0.5, 0),
						ZIndex = 3,
					}),
					scope:Text {
						Text = q.title,
						TextSize = 26,
						Font = "Title",
						XAlign = "Left",
						Size = UDim2.new(1, -310, 0, 30),
						Position = UDim2.fromOffset(122, 14),
						Truncate = true,
						ZIndex = 3,
					},
					scope:Text {
						Text = q.description or "",
						TextSize = 19,
						Font = "Body",
						Color = muted(scope),
						XAlign = "Left",
						Size = UDim2.new(1, -310, 0, 24),
						Position = UDim2.fromOffset(122, 48),
						Truncate = true,
						ZIndex = 3,
					},
					scope:ProgressBar {
						Value = progress,
						Max = q.goal,
						Tone = scope:Computed(function(use)
							return if use(complete) then "Green" else "Blue"
						end),
						Ghost = false,
						Label = function(value, max)
							return `{Format.abbrev(value)} / {Format.abbrev(max)}`
						end,
						Size = UDim2.new(1, -310, 0, 26),
						Position = UDim2.fromOffset(122, 82),
						ZIndex = 3,
					},
					if q.reward
						then scope:Row {
							Name = "Reward",
							Size = UDim2.fromOffset(150, 36),
							AnchorPoint = Vector2.new(1, 0),
							Position = UDim2.new(1, -20, 0, 14),
							Gap = 6,
							Align = "Right",
							ZIndex = 3,
							[Children] = {
								scope:Icon { Icon = q.reward.icon, Size = 32, Gradient = true, Color = accent(scope), LayoutOrder = 1, ZIndex = 3 },
								scope:Text { Text = if q.reward.amount then `x{q.reward.amount}` else "", TextSize = 24, Font = "Display", LayoutOrder = 2, ZIndex = 3 },
							},
						}
						else nil,
					ctx.button(scope, {
						Name = "Claim",
						Text = scope:Computed(function(use)
							return if use(claimedValue) then "Claimed" else "Claim"
						end),
						Tone = "Green",
						Disabled = scope:Computed(function(use)
							return not use(complete) or use(claimedValue)
						end),
						Size = UDim2.fromOffset(150, 52),
						TextSize = 24,
						AnchorPoint = Vector2.new(1, 1),
						Position = UDim2.new(1, -20, 1, -16),
						ZIndex = 4,
					}, { kind = "None" }, nil, claim),
				},
			})
		end
		table.insert(cells, card)
	end
	return rowsOf(scope, cells, perRow, cardHeight, 16, "Left")
end

BUILD.Pass = function(ctx, scope, s, width, info)
	local progress = ctx.ui:state(s.id, "progress", math.min(3, #s.tiers))
	-- live games: premium is the player's BloxUI_Premium_<id> attribute, set
	-- by UI.Server (Robux product receipt or game pass ownership)
	local player = Players.LocalPlayer
	local premium = if not ctx.preview and player
		then Bind.fromAttribute(scope, player, `BloxUI_Premium_{s.id}`, false)
		else ctx.ui:state(s.id, "premium", false)
	local function buyPremium()
		if peek(premium) == true then
			ctx.ui:notify("You already have the premium pass!", "success")
			return
		end
		if s.premiumGamePassId then
			ctx.run({ kind = "GamePass", gamePassId = s.premiumGamePassId }, { id = s.id, screen = info.screen })
		elseif s.premiumProductId then
			ctx.run({ kind = "Purchase", productId = s.premiumProductId }, { id = s.id, screen = info.screen })
		else
			ctx.ui:notify("Premium pass coming soon!")
		end
	end
	local function claim(tier: number, track: string)
		ctx.ui:emit("PassClaim", { id = s.id, screen = info.screen, payload = { tier = tier, track = track } })
	end
	local height = 380
	local PassTrack = Common.component("PassTrack")
	if PassTrack then
		local track = Common.try(PassTrack, scope, {
			Tiers = s.tiers,
			Progress = progress,
			Premium = premium,
			OnClaim = claim,
			OnBuyPremium = buyPremium,
		})
		if track then
			return track, measured(track, height)
		end
	end
	local columns = {}
	for index, t in s.tiers do
		local reached = scope:Computed(function(use)
			return use(progress) >= t.tier
		end)
		local function cell(reward, isPremium: boolean, order: number)
			return Common.card(scope, {
				Name = if isPremium then "Premium" else "Free",
				Size = UDim2.fromOffset(120, 120),
				LayoutOrder = order,
				Tone = scope:Computed(function(use)
					if not use(reached) then
						return "Dark"
					end
					return if isPremium then "Yellow" else "Blue"
				end),
				Children = {
					if reward
						then scope:Icon {
							Icon = reward.icon,
							Size = 60,
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.new(0.5, 0, 0.45, 0),
							Color = Color3.new(1, 1, 1),
							Gradient = true,
							Shadow = true,
							ZIndex = 3,
						}
						else nil,
					if reward and reward.amount
						then scope:Text {
							Text = `x{reward.amount}`,
							TextSize = 22,
							Font = "Display",
							AnchorPoint = Vector2.new(0.5, 1),
							Position = UDim2.new(0.5, 0, 1, -6),
							ZIndex = 3,
						}
						else nil,
					if isPremium
						then scope:Icon {
							Icon = "lock",
							Size = 26,
							Position = UDim2.fromOffset(6, 6),
							Visible = scope:Computed(function(use)
								return not use(premium)
							end),
							ZIndex = 4,
						}
						else nil,
					scope:New "TextButton" {
						Name = "Claim",
						Text = "",
						BackgroundTransparency = 1,
						Size = UDim2.fromScale(1, 1),
						ZIndex = 5,
						[OnEvent "Activated"] = function()
							if peek(reached) and (not isPremium or peek(premium)) then
								claim(t.tier, if isPremium then "premium" else "free")
							end
						end,
					},
				},
			})
		end
		table.insert(columns, scope:Column {
			Name = `Tier{t.tier}`,
			LayoutOrder = index,
			Size = UDim2.fromOffset(120, 300),
			Gap = 12,
			[Children] = {
				Common.pill(scope, {
					Text = tostring(t.tier),
					Tone = scope:Computed(function(use)
						return if use(reached) then "Green" else "Gray"
					end),
					Height = 36,
					LayoutOrder = 1,
				}),
				cell(t.free, false, 2),
				cell(t.premium, true, 3),
			},
		})
	end
	local strip = scope:Scroll {
		Name = "Track",
		Direction = "X",
		Size = UDim2.new(1, -220, 1, 0),
		Position = UDim2.fromOffset(220, 0),
		Gap = 16,
		Padding = { 8, 8, 16, 8 },
		[Children] = columns,
	}
	local side = Common.card(scope, {
		Name = "Premium",
		Size = UDim2.new(0, 200, 1, -16),
		Position = UDim2.fromOffset(0, 8),
		Tone = "Yellow",
		Children = scope:Column {
			Size = UDim2.new(1, -24, 1, -24),
			Position = UDim2.fromOffset(12, 12),
			Gap = 12,
			VAlign = "Center",
			ZIndex = 3,
			[Children] = {
				scope:Icon { Icon = "crown", Size = 84, Color = Color3.new(1, 1, 1), Gradient = true, Shadow = true, LayoutOrder = 1, ZIndex = 3 },
				scope:Text { Text = "PREMIUM", TextSize = 28, Font = "Title", LayoutOrder = 2, ZIndex = 3 },
				scope:Text { Text = "Double rewards on every tier", TextSize = 18, Font = "Body", Wrapped = true, Size = UDim2.new(1, 0, 0, 48), LayoutOrder = 3, ZIndex = 3 },
				ctx.button(scope, {
					Name = "Unlock",
					Text = "Unlock",
					Icon = if s.premiumProductId or s.premiumGamePassId then "robux" else nil,
					Tone = "Green",
					Size = UDim2.new(1, 0, 0, 56),
					Shine = true,
					LayoutOrder = 4,
					ZIndex = 4,
				}, { kind = "None" }, { id = s.id }, buyPremium),
			},
		},
	})
	return scope:New "Frame" {
		Name = "Pass",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, height),
		[Children] = { side, strip },
	}, height
end

local MEDALS = { "Yellow", "Gray", "Orange" }

BUILD.Leaderboard = function(ctx, scope, s, width, info)
	local rowHeight = 60
	local shown = math.min(#s.rows, 8)
	local height = math.max(1, shown) * (rowHeight + 8) - 8 + 56
	local Leaderboard = Common.component("Leaderboard")
	if Leaderboard then
		-- the component draws its own title row
		height += 56
		-- a title that only repeats the stat (already shown as the column pill)
		-- reads as a stutter: say who is on the board instead
		local title = s.title or s.stat
		if typeof(title) == "string" and typeof(s.stat) == "string" and string.lower(title) == string.lower(s.stat) or repeatsTitle(title, info) then
			title = "Top Players"
		end
		local board = Common.try(Leaderboard, scope, {
			Title = title,
			Stat = s.stat,
			Rows = s.rows,
			Size = UDim2.new(1, 0, 0, height),
		})
		if board then
			return board, height, true
		end
		height -= 56
	end
	local rows = {}
	for index, r in s.rows do
		local medal = MEDALS[r.rank]
		table.insert(rows, Common.card(scope, {
			Name = `Rank{r.rank}`,
			Size = UDim2.new(1, 0, 0, rowHeight),
			LayoutOrder = index,
			Tone = if medal then medal else nil,
			Children = {
				scope:Text {
					Text = `#{r.rank}`,
					TextSize = 30,
					Font = "Display",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 20, 0.5, 0),
					ZIndex = 3,
				},
				if medal
					then scope:Icon {
						Icon = if r.rank == 1 then "crown" else "medal",
						Size = 34,
						AnchorPoint = Vector2.new(0, 0.5),
						Position = UDim2.new(0, 84, 0.5, 0),
						Color = Color3.new(1, 1, 1),
						Gradient = true,
						ZIndex = 3,
					}
					else nil,
				scope:Text {
					Text = r.name,
					TextSize = 26,
					Font = "Heavy",
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 132, 0.5, 0),
					Size = UDim2.new(1, -340, 0, 32),
					Truncate = true,
					ZIndex = 3,
				},
				scope:Text {
					Text = r.value,
					TextSize = 28,
					Font = "Display",
					XAlign = "Right",
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -24, 0.5, 0),
					ZIndex = 3,
				},
			},
		}))
	end
	local header = scope:New "Frame" {
		Name = "Columns",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, 40),
		LayoutOrder = 0,
		[Children] = {
			scope:Text { Text = "RANK", TextSize = 18, Font = "Heavy", Color = muted(scope), Position = UDim2.fromOffset(20, 8) },
			scope:Text { Text = "PLAYER", TextSize = 18, Font = "Heavy", Color = muted(scope), Position = UDim2.fromOffset(132, 8) },
			scope:Text { Text = string.upper(s.stat), TextSize = 18, Font = "Heavy", Color = muted(scope), AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -24, 0, 8) },
		},
	}
	local list = scope:Scroll {
		Name = "Rows",
		Size = UDim2.new(1, 0, 1, -48),
		Position = UDim2.fromOffset(0, 48),
		Gap = 8,
		Padding = { 0, 4, 8, 0 },
		[Children] = rows,
	}
	if #rows == 0 then
		return emptyState(scope, "No scores yet", "trophy", 160)
	end
	return scope:New "Frame" {
		Name = "Leaderboard",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, height),
		[Children] = { header, list },
	}, height
end

BUILD.Scoreboard = function(ctx, scope, s, width, info)
	-- Scoreboard's own geometry: 16 padding, per team a 56 header + 48 per row,
	-- 16 between teams
	local height = 32
	for index, t in s.teams do
		height += 64 + #t.rows * 48 + (if index > 1 then 16 else 0)
	end
	height = math.max(120, height)
	local Scoreboard = Common.component("Scoreboard")
	if Scoreboard then
		local teams = {}
		for _, t in s.teams do
			table.insert(teams, { name = t.name, color = Common.color(t.color), rows = t.rows })
		end
		local board = Common.try(Scoreboard, scope, { Columns = s.columns, Teams = teams, Size = UDim2.new(1, 0, 0, height) })
		if board then
			return board, height
		end
	end
	local blocks = {}
	local columnWidth = 110
	for index, t in s.teams do
		local color = Common.color(t.color)
		local rows = {}
		local function valueCells(values: { any }, font: string, size: number, colorValue: any)
			local cells = {}
			for c, label in values do
				table.insert(cells, scope:Text {
					Text = if typeof(label) == "number" then Format.comma(label) else tostring(label),
					TextSize = size,
					Font = font,
					Color = colorValue,
					XAlign = "Right",
					Size = UDim2.fromOffset(columnWidth, 40),
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -20 - (#values - c) * columnWidth, 0.5, 0),
					ZIndex = 3,
				})
			end
			return cells
		end
		table.insert(rows, scope:New "Frame" {
			Name = "TeamHeader",
			LayoutOrder = 0,
			Size = UDim2.new(1, 0, 0, 52),
			BackgroundColor3 = color,
			[Children] = {
				Kit.corner(scope, "M"),
				Kit.gradient(scope, { Rotation = 0, Transparency = NumberSequence.new(0, 0.6) }),
				scope:Text { Text = t.name, TextSize = 28, Font = "Title", AnchorPoint = Vector2.new(0, 0.5), Position = UDim2.new(0, 20, 0.5, 0), StrokeColor = Color.darken(color, 0.55), ZIndex = 3 },
				valueCells(s.columns, "Heavy", 20, nil),
			},
		})
		for r, row in t.rows do
			table.insert(rows, Common.card(scope, {
				Name = row.name,
				Size = UDim2.new(1, 0, 0, 44),
				LayoutOrder = r,
				Highlight = false,
				Children = {
					scope:Text { Text = row.name, TextSize = 22, Font = "Heavy", XAlign = "Left", AnchorPoint = Vector2.new(0, 0.5), Position = UDim2.new(0, 20, 0.5, 0), ZIndex = 3 },
					valueCells(row.values, "Display", 22, nil),
				},
			}))
		end
		table.insert(blocks, scope:Column {
			Name = t.name,
			LayoutOrder = index,
			Size = UDim2.new(1, 0, 0, 52 + #t.rows * 48),
			Gap = 4,
			[Children] = rows,
		})
	end
	return scope:Column {
		Name = "Scoreboard",
		Size = UDim2.new(1, 0, 0, height),
		Gap = 16,
		[Children] = blocks,
	}, height
end

BUILD.Loadout = function(ctx, scope, s, width, info)
	if #s.slots == 0 then
		return emptyState(scope, "No loadout slots", "melee", 160)
	end
	local LoadoutCard = Common.component("LoadoutCard")
	local perRow = math.clamp(math.floor((width + GAP) / (240 + GAP)), 1, 4)
	local cardHeight = 320
	-- v2.4 style families: a loadout that would wrap (5 cards: 3 + 2) and
	-- not fit above the fold becomes one row of slimmer cards, so the page
	-- never opens on a second row cut in half. Classic themes keep v2.3's grid.
	if Theme.get().Family ~= nil and #s.slots > perRow then
		local rows = math.ceil(#s.slots / perRow)
		local fits = rows * cardHeight + (rows - 1) * GAP <= (info.available or math.huge)
		local slim = math.floor((width - GAP * (#s.slots - 1)) / #s.slots)
		if not fits and slim >= 196 then
			perRow = #s.slots
		end
	end
	-- the cards' real width (the columns rowsOf will give them), for their layout
	local columns = balancedRows(#s.slots, perRow)[1]
	local cardWidth = math.floor((width - GAP * (columns - 1)) / columns)
	local selected = ctx.ui:state(s.id, "selected", 1)
	local cells = {}
	for index, slot in s.slots do
		local isSelected = scope:Computed(function(use)
			return use(selected) == index
		end)
		local function choose()
			selected:set(index)
			ctx.ui:emit(s.id, { id = s.id, screen = info.screen, payload = slot.name })
		end
		local card = nil
		if LoadoutCard then
			card = Common.try(LoadoutCard, scope, {
				Name = slot.name,
				Icon = slot.icon,
				Rarity = slot.rarity,
				Stats = slot.stats,
				Selected = isSelected,
				OnClick = choose,
				Size = UDim2.fromOffset(cardWidth, cardHeight),
			})
		end
		if card == nil then
			local tone = scope:Computed(function(use)
				return Common.rarityTone(use(Theme.current), slot.rarity)
			end)
			local bars = {}
			for b, stat in slot.stats do
				table.insert(bars, scope:ProgressBar {
					Value = stat.value,
					Max = 1,
					Tone = "White",
					Ghost = false,
					Label = stat.label,
					LabelAlign = "Left",
					Size = UDim2.new(1, 0, 0, 22),
					LayoutOrder = b,
					ZIndex = 3,
				})
			end
			card = Common.card(scope, {
				Name = slot.name,
				Size = UDim2.new(1, 0, 0, cardHeight),
				Tone = tone,
				Children = {
					scope:Icon {
						Icon = slot.icon,
						Size = 120,
						AnchorPoint = Vector2.new(0.5, 0),
						Position = UDim2.new(0.5, 0, 0, 20),
						Color = Color3.new(1, 1, 1),
						Gradient = true,
						Shadow = true,
						ZIndex = 3,
					},
					scope:Text {
						Text = slot.name,
						TextSize = 26,
						Font = "Title",
						AnchorPoint = Vector2.new(0.5, 0),
						Position = UDim2.new(0.5, 0, 0, 148),
						Size = UDim2.new(1, -24, 0, 30),
						Truncate = true,
						ZIndex = 3,
					},
					scope:Column {
						Name = "Stats",
						Size = UDim2.new(1, -32, 0, 110),
						Position = UDim2.fromOffset(16, 194),
						Gap = 8,
						ZIndex = 3,
						[Children] = bars,
					},
					scope:New "UIStroke" {
						ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
						Color = Color3.new(1, 1, 1),
						Thickness = 4,
						Enabled = isSelected,
					},
					scope:New "TextButton" {
						Name = "Select",
						Text = "",
						BackgroundTransparency = 1,
						Size = UDim2.fromScale(1, 1),
						ZIndex = 6,
						[OnEvent "Activated"] = choose,
					},
				},
			})
		end
		table.insert(cells, card)
	end
	return rowsOf(scope, cells, perRow, cardHeight, GAP)
end

local function statRow(ctx, scope, stat, height: number, reserveBar: boolean?): Instance
	local StatRow = Common.component("StatRow")
	if StatRow then
		local row = Common.try(StatRow, scope, {
			Label = stat.label,
			Icon = stat.icon,
			Value = stat.value,
			Bar = stat.bar,
			ReserveBar = reserveBar,
			Size = UDim2.new(1, 0, 0, height),
		})
		if row then
			return row
		end
	end
	return Common.card(scope, {
		Name = stat.label,
		Size = UDim2.new(1, 0, 0, height),
		Children = {
			scope:Icon {
				Icon = stat.icon,
				Size = 36,
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 18, if stat.bar then 0.38 else 0.5, 0),
				Color = accent(scope),
				Gradient = true,
				ZIndex = 3,
			},
			scope:Text {
				Text = stat.label,
				TextSize = 24,
				Font = "Heavy",
				XAlign = "Left",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 66, if stat.bar then 0.38 else 0.5, 0),
				Size = UDim2.new(0.6, -66, 0, 30),
				Truncate = true,
				ZIndex = 3,
			},
			scope:Text {
				Text = stat.value,
				TextSize = 28,
				Font = "Display",
				XAlign = "Right",
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -18, if stat.bar then 0.38 else 0.5, 0),
				ZIndex = 3,
			},
			if stat.bar
				then scope:ProgressBar {
					Value = stat.bar,
					Max = 1,
					Tone = "Blue",
					Ghost = false,
					Size = UDim2.new(1, -84, 0, 12),
					Position = UDim2.new(0, 66, 1, -20),
					ZIndex = 3,
				}
				else nil,
		},
	})
end

BUILD.Stats = function(ctx, scope, s, width, info)
	if #s.stats == 0 then
		return emptyState(scope, "No stats yet", "stats", 140)
	end
	local cells = {}
	local hasBars = false
	for _, stat in s.stats do
		hasBars = hasBars or stat.bar ~= nil
	end
	local rowHeight = if hasBars then 84 else 68
	for _, stat in s.stats do
		-- rows share one baseline whether or not they have a bar
		table.insert(cells, statRow(ctx, scope, stat, rowHeight, hasBars))
	end
	-- three across on wide pages keeps a stat strip to one short row
	return rowsOf(scope, cells, if width >= 1000 then 3 elseif width >= 800 then 2 else 1, rowHeight, 16, "Fill")
end

BUILD.Profile = function(ctx, scope, s, width, info)
	local name = s.name or "Player"
	local player = game:GetService("Players").LocalPlayer
	if player and not s.name then
		name = player.DisplayName
	end
	local statsHeight = math.ceil(#s.stats / 2) * (68 + 16)
	local height = 180 + (if #s.stats > 0 then 24 + statsHeight else 0) + (if #s.badges > 0 then 24 + 96 else 0)
	local ProfileCard = Common.component("ProfileCard")
	if ProfileCard then
		local card = Common.try(ProfileCard, scope, {
			Name = name,
			Level = s.level,
			Stats = s.stats,
			Badges = s.badges,
			Width = width,
		})
		if card then
			return card, measured(card, height)
		end
	end
	local blocks = {
		Common.card(scope, {
			Name = "Card",
			Size = UDim2.new(1, 0, 0, 180),
			Tone = "Blue",
			LayoutOrder = 1,
			Children = {
				Common.iconTile(scope, {
					Icon = "player",
					Tone = "Purple",
					Size = 132,
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 24, 0.5, 0),
					ZIndex = 3,
				}),
				scope:Text { Text = name, TextSize = 48, Font = "Title", XAlign = "Left", Position = UDim2.fromOffset(184, 40), ZIndex = 3 },
				if s.level
					then Common.pill(scope, { Text = `Level {s.level}`, Tone = "Yellow", Icon = "level", Height = 40, Position = UDim2.fromOffset(184, 104), ZIndex = 3 })
					else nil,
			},
		}),
	}
	if #s.stats > 0 then
		local cells = {}
		for _, stat in s.stats do
			table.insert(cells, statRow(ctx, scope, stat, 68))
		end
		local rows = rowsOf(scope, cells, 2, 68, 16, "Fill")
		rows.LayoutOrder = 2
		table.insert(blocks, rows)
	end
	if #s.badges > 0 then
		local badges = {}
		for index, b in s.badges do
			table.insert(badges, scope:Column {
				Name = b.label,
				Size = UDim2.fromOffset(110, 96),
				Gap = 4,
				LayoutOrder = index,
				[Children] = {
					Common.iconTile(scope, { Icon = b.icon, Tone = "Yellow", Size = 64, LayoutOrder = 1 }),
					scope:Text { Text = b.label, TextSize = 16, Font = "Heavy", Size = UDim2.new(1, 0, 0, 22), Scaled = true, Wrapped = false, MaxTextSize = 16, LayoutOrder = 2 },
				},
			})
		end
		table.insert(blocks, scope:Row {
			Name = "Badges",
			Size = UDim2.new(1, 0, 0, 96),
			Gap = 16,
			LayoutOrder = 3,
			[Children] = badges,
		})
	end
	return scope:Column {
		Name = "Profile",
		Size = UDim2.new(1, 0, 0, height),
		Gap = GAP,
		[Children] = blocks,
	}, height
end

-- A settings control changed: Blueprint.Settings applies the built-in
-- effect, saves it for the player and fires ui:on(id, fn).
local function settingChanged(ctx, setting, value)
	local ui = ctx.ui
	if typeof(ui.setSetting) == "function" then
		ui:setSetting(setting.id, value)
	else
		ui:emit(setting.id, { id = setting.id, payload = value })
	end
end

-- A stepper for choices too long for a pill switch: < Deuteranopia >.
-- The arrows cycle through the options.
local function stepperControl(ctx, scope, setting, value, room: number): Instance
	local options = setting.options
	local longest = 0
	for _, option in options do
		longest = math.max(longest, Common.textWidth(option, 22))
	end
	-- colour-blind palettes show their good / bad / warning colours
	local palettes = if setting.id == "colorblind" then Theme.Base.COLORBLIND else nil
	local swatchWidth = if palettes then 58 else 0
	local labelWidth = math.clamp(longest + 40 + swatchWidth, 120, math.max(120, room - 2 * 56))
	local function step(direction: number)
		local index = table.find(options, peek(value)) or 1
		local option = options[(index - 1 + direction) % #options + 1]
		value:set(option)
		settingChanged(ctx, setting, option)
	end
	local function arrow(name: string, icon: string, direction: number, order: number)
		return ctx.button(scope, {
			Name = name,
			Icon = icon,
			Tone = "Dark",
			Size = UDim2.fromOffset(48, 48),
			IconSize = 26,
			LayoutOrder = order,
		}, { kind = "None" }, nil, function()
			step(direction)
		end)
	end
	return scope:Row {
		Name = "Choice",
		Size = UDim2.fromOffset(labelWidth + 2 * 56, 52),
		Gap = 8,
		VAlign = "Center",
		[Children] = {
			arrow("Previous", "chevron-large-left", -1, 1),
			scope:New "Frame" {
				Name = "Current",
				LayoutOrder = 2,
				Size = UDim2.fromOffset(labelWidth, 48),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Track
				end),
				BackgroundTransparency = Kit.themed(scope, function(theme)
					return theme.Alpha.Track
				end),
				[Children] = {
					Kit.corner(scope, "Full"),
					scope:New "UIStroke" {
						ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.SurfaceLight
						end),
						Thickness = 2,
					},
					scope:Row {
						Name = "Inner",
						Size = UDim2.new(1, -16, 1, 0),
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Gap = 10,
						Align = "Center",
						[Children] = {
							if palettes
								then scope:Row {
									Name = "Swatches",
									LayoutOrder = 1,
									Size = UDim2.fromOffset(48, 16),
									Gap = 0,
									Visible = scope:Computed(function(use)
										return palettes[use(value)] ~= nil
									end),
									[Children] = (function()
										local dots = {}
										for index, key in { "Good", "Bad", "Warn" } do
											table.insert(dots, scope:New "Frame" {
												Name = key,
												LayoutOrder = index,
												Size = UDim2.fromOffset(16, 16),
												BackgroundColor3 = scope:Computed(function(use)
													local palette = palettes[use(value)]
													return if palette then Color3.fromHex(palette[key]) else Color3.new(1, 1, 1)
												end),
												[Children] = {
													Kit.corner(scope, "Full"),
													Kit.stroke(scope, { Thickness = 2 }),
												},
											})
										end
										return dots
									end)(),
								}
								else nil,
							scope:Text {
								Name = "Label",
								LayoutOrder = 2,
								Text = value,
								TextSize = 22,
								Font = "Heavy",
								Size = UDim2.new(0, labelWidth - 16 - (if palettes then swatchWidth else 0), 1, 0),
								Scaled = true,
								MaxTextSize = 22,
							},
						},
					},
				},
			},
			arrow("Next", "chevron-large-right", 1, 3),
		},
	}
end

-- A Choice control: a pill switch sized to the row, or a stepper when the
-- options don't fit (`room` is the row's control width in design px).
local function choiceControl(ctx, scope, setting, value, room: number): Instance
	local needed = 0
	for _, option in setting.options do
		needed += Common.textWidth(option, 22) + 32
	end
	local Segmented = Common.component("Segmented")
	if Segmented and needed <= room then
		local control = Common.try(Segmented, scope, {
			Options = setting.options,
			Value = value,
			Size = UDim2.fromOffset(math.min(room, math.max(needed, #setting.options * 96)), 52),
			TextSize = 22,
			OnChanged = function(v)
				settingChanged(ctx, setting, v)
			end,
		})
		if control then
			return control
		end
	end
	if needed > room and #setting.options > 2 then
		return stepperControl(ctx, scope, setting, value, room)
	end
	local buttons = {}
	for index, option in setting.options do
		table.insert(buttons, ctx.button(scope, {
			Name = option,
			Text = option,
			TextSize = 20,
			Size = UDim2.fromOffset(math.max(84, Common.textWidth(option, 20) + 32), 44),
			Tone = scope:Computed(function(use)
				return if use(value) == option then "Blue" else "Dark"
			end),
			LayoutOrder = index,
		}, { kind = "None" }, nil, function()
			value:set(option)
			settingChanged(ctx, setting, option)
		end))
	end
	return scope:Row {
		Name = "Choice",
		Size = UDim2.fromOffset(0, 48),
		AutoSize = "X",
		Gap = 8,
		[Children] = buttons,
	}
end

BUILD.Settings = function(ctx, scope, s, width, info)
	local rows = {}
	local rowHeight = 72
	for index, setting in s.settings do
		-- one Value per id, made (with the player's saved value) by Blueprint.Settings
		local value = ctx.ui.settings[setting.id]
		if value == nil then
			value = scope:Value(setting.default)
			ctx.ui.settings[setting.id] = value
		end
		local control
		if setting.type == "Toggle" then
			control = scope:Toggle {
				Value = value,
				OnChanged = function(on)
					settingChanged(ctx, setting, on)
				end,
			}
		elseif setting.type == "Slider" then
			control = scope:Slider {
				Value = value,
				Min = setting.min,
				Max = setting.max,
				Step = if setting.max - setting.min >= 10 then 1 else nil,
				-- UI scale reads as a percentage
				Format = if setting.id == "uiScale" and setting.max > 5
					then function(v: number): string
						return `{math.round(v)}%`
					end
					else nil,
				Size = UDim2.fromOffset(math.min(360, math.floor(width * 0.4)), 30),
				OnChanged = function(v)
					settingChanged(ctx, setting, v)
				end,
			}
		else
			-- the row's Control frame is half its width, less the edge gap
			control = choiceControl(ctx, scope, setting, value, math.floor(width * 0.5) - 18)
		end
		-- a control of known width lets a long label use the rest of the row
		local controlSize = control.Size
		table.insert(rows, scope:SettingRow {
			Name = setting.id,
			Text = setting.label,
			Icon = setting.icon,
			ControlWidth = if typeof(controlSize) == "UDim2" and controlSize.X.Scale == 0 and controlSize.X.Offset > 0 then controlSize.X.Offset else nil,
			RowWidth = width,
			Size = UDim2.new(1, 0, 0, rowHeight),
			LayoutOrder = index,
			[Children] = control,
		})
	end
	if #rows == 0 then
		return emptyState(scope, "No settings", "settings", 140)
	end
	local height = #rows * rowHeight + (#rows - 1) * 12
	return scope:Column {
		Name = "Settings",
		Size = UDim2.new(1, 0, 0, height),
		Gap = 12,
		[Children] = rows,
	}, height
end

BUILD.Banner = function(ctx, scope, s, width, info)
	-- a banner without a call to action (a reset countdown, a notice) is a
	-- shorter strip, so it doesn't dominate the page; short pages (phones)
	-- get slimmer banners so the content below them stays in view
	local short = info.pageHeight < 560
	local height = if s.button then (if short then 200 else 220) else (if short then 160 else 180)
	local endsAt = if s.timer then os.time() + s.timer else nil
	local function click()
		if s.button then
			ctx.run(s.button.action, { id = s.id, screen = info.screen })
		end
	end
	local Banner = Common.component("Banner")
	if Banner then
		local banner = Common.try(Banner, scope, {
			Title = s.title,
			Subtitle = s.subtitle,
			Icon = s.icon,
			Tone = s.tone,
			ButtonText = if s.button then s.button.label else nil,
			OnClick = if s.button then click else nil,
			EndsAt = endsAt,
			Size = UDim2.fromOffset(width, height),
		})
		if banner then
			-- laid out for the page width; stretch with the page (scrollbar)
			banner.Size = UDim2.new(1, 0, 0, height)
			return banner, height
		end
	end
	return Common.card(scope, {
		Name = "Banner",
		Size = UDim2.new(1, 0, 0, height),
		Tone = s.tone,
		Radius = "L",
		Clip = true,
		Children = {
			scope:Icon {
				Name = "Art",
				Icon = s.icon,
				Size = 300,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(1, -150, 0.55, 0),
				Rotation = -12,
				Color = Kit.themed(scope, function(theme)
					return Color.lighten(Theme.tone(theme, s.tone).Top, 0.2)
				end),
				StrokeColor = Kit.themed(scope, function(theme)
					return Theme.tone(theme, s.tone).Edge
				end),
				Stroke = 4,
				Transparency = 0.1,
				Shadow = true,
				ZIndex = 2,
			},
			scope:Text {
				Text = s.title,
				TextSize = 52,
				Font = "Title",
				XAlign = "Left",
				Position = UDim2.fromOffset(32, 28),
				StrokeColor = Kit.themed(scope, function(theme)
					return Theme.tone(theme, s.tone).Edge
				end),
				Stroke = 3.5,
				ZIndex = 4,
			},
			if s.subtitle
				then scope:Text {
					Text = s.subtitle,
					TextSize = 24,
					Font = "Body",
					XAlign = "Left",
					Wrapped = true,
					YAlign = "Top",
					Size = UDim2.new(1, -420, 0, 60),
					Position = UDim2.fromOffset(34, 92),
					StrokeColor = Kit.themed(scope, function(theme)
						return Theme.tone(theme, s.tone).Edge
					end),
					Stroke = 2,
					ZIndex = 4,
				}
				else nil,
			if s.button
				then ctx.button(scope, {
					Name = "Action",
					Text = s.button.label,
					Tone = "Green",
					Size = UDim2.fromOffset(math.max(200, Common.textWidth(s.button.label, 30) + 64), 64),
					AnchorPoint = Vector2.new(0, 1),
					Position = UDim2.new(0, 32, 1, -24),
					Shine = true,
					Pulse = true,
					Primary = true,
					ZIndex = 5,
				}, s.button.action, { id = s.id, screen = info.screen })
				else nil,
			if endsAt
				then Common.pill(scope, {
					Name = "Timer",
					Icon = "timer",
					Text = Common.countdown(scope, ctx.clock, endsAt),
					Tone = "Dark",
					Height = 40,
					AnchorPoint = Vector2.new(1, 0),
					Position = UDim2.new(1, -20, 0, 20),
					ZIndex = 5,
				})
				else nil,
		},
	}), height
end

BUILD.Buttons = function(ctx, scope, s, width, info)
	local list = s.buttons
	if #list == 0 then
		return scope:New "Frame" { Name = "Buttons", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0) }, 0
	end
	local buttonHeight = 72
	local cells = {}
	for index, b in list do
		table.insert(cells, ctx.button(scope, {
			Name = b.id,
			Text = b.label,
			Icon = b.icon,
			Tone = b.tone,
			TextSize = 30,
			Shine = index == 1,
			Primary = index == 1,
		}, b.action, { id = b.id, screen = info.screen }))
	end
	if s.layout == "Column" then
		local buttonWidth = math.min(width, 420)
		for index, cell in cells do
			cell.Size = UDim2.fromOffset(buttonWidth, buttonHeight)
			cell.LayoutOrder = index
		end
		local height = #cells * buttonHeight + (#cells - 1) * 16
		return scope:Column {
			Name = "Buttons",
			Size = UDim2.new(1, 0, 0, height),
			Gap = 16,
			Align = "Center",
			[Children] = cells,
		}, height
	end
	local perRow = if s.layout == "Grid" then math.min(3, #cells) else math.min(4, #cells)
	return rowsOf(scope, cells, perRow, buttonHeight, 16)
end

BUILD.Codes = function(ctx, scope, s, width, info)
	local height = 84
	local textValue = ctx.ui:state(s.id, "text", "")
	local function submit(code: string?)
		local typed = code or peek(textValue)
		if typed == nil or typed == "" then
			ctx.ui:notify("Type a code first!", "warning")
			return
		end
		ctx.run(s.action, { id = s.id, screen = info.screen, payload = typed })
	end
	local TextInput = Common.component("TextInput")
	if TextInput then
		local input = Common.try(TextInput, scope, {
			Placeholder = s.placeholder,
			Text = textValue,
			Icon = "tag",
			ButtonText = s.button,
			OnSubmit = submit,
			Size = UDim2.new(1, 0, 0, height),
		})
		if input then
			return input, height
		end
	end
	local box = scope:New "TextBox" {
		Name = "Input",
		Text = "",
		PlaceholderText = s.placeholder,
		ClearTextOnFocus = false,
		BackgroundTransparency = 1,
		TextSize = 26,
		TextXAlignment = Enum.TextXAlignment.Left,
		FontFace = Kit.themed(scope, function(theme)
			return theme.Font.Body
		end),
		TextColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Text
		end),
		PlaceholderColor3 = Kit.themed(scope, function(theme)
			return theme.Color.TextMuted
		end),
		Size = UDim2.new(1, -300, 1, -16),
		Position = UDim2.fromOffset(72, 8),
		ZIndex = 3,
		[Fusion.OnChange "Text"] = function(now)
			textValue:set(now)
		end,
	}
	return Common.card(scope, {
		Name = "Codes",
		Size = UDim2.new(1, 0, 0, height),
		Radius = "L",
		Children = {
			scope:Icon {
				Icon = "tag",
				Size = 36,
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 22, 0.5, 0),
				Color = accent(scope),
				ZIndex = 3,
			},
			box,
			ctx.button(scope, {
				Name = "Redeem",
				Text = s.button,
				Tone = "Green",
				Size = UDim2.fromOffset(200, 60),
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -12, 0.5, 0),
				ZIndex = 4,
			}, { kind = "None" }, nil, function()
				submit(box.Text)
			end),
		},
	}), height
end

BUILD.Text = function(ctx, scope, s, width, info)
	local body = s.body or ""
	local charsPerLine = math.max(10, math.floor((width - 48) / (22 * 0.5)))
	local lines = 0
	for line in string.gmatch(body .. "\n", "([^\n]*)\n") do
		lines += math.max(1, math.ceil((utf8.len(line) or #line) / charsPerLine))
	end
	local height = math.max(72, lines * 30 + 40)
	return Common.card(scope, {
		Name = "Text",
		Size = UDim2.new(1, 0, 0, height),
		Highlight = false,
		Children = scope:Text {
			Text = body,
			TextSize = 22,
			Font = "Body",
			XAlign = "Left",
			YAlign = "Top",
			Wrapped = true,
			LineHeight = 1.15,
			Size = UDim2.new(1, -48, 1, -40),
			Position = UDim2.fromOffset(24, 20),
			Stroke = false,
			ZIndex = 3,
		},
	}), height
end

-- Buys one case: Robux cases roll on the server after the receipt
-- ("BloxUI.CaseResult"); currency cases are rolled by UI.Server, which
-- answers with the drop (data.won) and the opener plays.
local function openCase(ctx, s, info, c, source: Instance?)
	local item = { id = c.id, title = c.title, price = c.price, currency = c.currency, productId = c.productId }
	if c.currency == "robux" then
		Actions.buy(ctx.ui, item, { id = c.id, screen = info.screen, section = s.id, source = source })
		return
	end
	Actions.buy(ctx.ui, item, { id = c.id, screen = info.screen, section = s.id, source = source }, function(ok, data)
		if ok then
			ctx.ui:openCase(c, data and data.won)
		end
	end)
end

BUILD.Cases = function(ctx, scope, s, width, info)
	if #s.cases == 0 then
		return emptyState(scope, "No cases yet", "cube", 160)
	end
	local perRow = if width >= 900 then 3 elseif width >= 560 then 2 else 1
	-- lanes (design px): title 16-52, art 64-194, odds 208-226, button 240-304
	local cardHeight = 320
	local cells = {}
	for caseIndex, c in s.cases do
		local card
		local function open()
			openCase(ctx, s, info, c, card)
		end
		card = Common.card(scope, {
			Name = c.id,
			Size = UDim2.new(1, 0, 0, cardHeight),
			Tone = c.tone,
			Radius = "L",
			Clip = true,
			Children = {
				scope:Icon {
					Name = "Art",
					Icon = c.icon,
					Size = 130,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 0, 0, 129),
					Color = Kit.themed(scope, function(theme)
						return Color.lighten(Theme.tone(theme, c.tone).Top, 0.25)
					end),
					StrokeColor = Kit.themed(scope, function(theme)
						return Theme.tone(theme, c.tone).Edge
					end),
					Stroke = 4,
					Gradient = true,
					Shadow = true,
					ZIndex = 3,
				},
				scope:Text {
					Text = c.title,
					TextSize = 30,
					Font = "Title",
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, 16),
					Size = UDim2.new(1, -24, 0, 34),
					Scaled = true,
					Wrapped = false, -- single line: shrink, never break a word
					MaxTextSize = 30,
					StrokeColor = Kit.themed(scope, function(theme)
						return Theme.tone(theme, c.tone).Edge
					end),
					ZIndex = 4,
				},
				scope:Row {
					Name = "Odds",
					Size = UDim2.new(1, -32, 0, 18),
					Position = UDim2.new(0, 16, 0, 208),
					Gap = 6,
					Align = "Center",
					ZIndex = 4,
					[Children] = (function()
						local dots = {}
						for index, drop in c.pool do
							if index > 6 then
								break
							end
							table.insert(dots, scope:New "Frame" {
								Name = drop.rarity,
								LayoutOrder = index,
								Size = UDim2.fromOffset(18, 18),
								BackgroundColor3 = Kit.themed(scope, function(theme)
									return Common.rarityTone(theme, drop.rarity).Bottom
								end),
								ZIndex = 4,
								[Children] = {
									Kit.corner(scope, "Full"),
									Kit.stroke(scope, { Color = Color3.new(1, 1, 1), Thickness = 2, Transparency = 0.2 }),
								},
							})
						end
						return dots
					end)(),
				},
				ctx.button(scope, {
					Name = "Open",
					Text = if c.price > 0 then Format.comma(c.price) else "FREE",
					Icon = if c.price > 0 then Common.priceIcon(ctx.ui.currencyDefs, c.currency) else nil,
					Tone = "Green",
					Shine = caseIndex == 1, -- one shining call to action per section
					Size = UDim2.new(1, -40, 0, 64),
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -16),
					ZIndex = 5,
				}, { kind = "None" }, nil, open),
			},
		})
		table.insert(cells, card)
	end
	return rowsOf(scope, cells, perRow, cardHeight, GAP)
end

BUILD.Worlds = function(ctx, scope, s, width, info)
	if #s.worlds == 0 then
		return emptyState(scope, "No worlds yet", "world", 160)
	end
	local perRow = if width >= 900 then 3 elseif width >= 560 then 2 else 1
	local cardHeight = 220
	local columns = balancedRows(#s.worlds, perRow)[1]
	local cardWidth = math.floor((width - GAP * (columns - 1)) / columns)
	local WorldCard = Common.component("WorldCard")
	local cells = {}
	for _, w in s.worlds do
		local function go()
			if w.locked then
				ctx.ui:notify(w.requirement or "Locked!", "warning")
				return
			end
			ctx.ui:emit("Teleport", { id = w.id, screen = info.screen, payload = w.id })
			ctx.ui:emit(w.id, { id = w.id, screen = info.screen })
			if ctx.preview then
				ctx.ui:toast(`Teleport to {w.name}`, "world")
			end
		end
		local card = nil
		if WorldCard then
			card = Common.try(WorldCard, scope, {
				Name = w.name,
				Icon = w.icon,
				Tone = w.tone,
				Requirement = w.requirement,
				Locked = w.locked,
				OnClick = go,
				-- real width, so the name is sized for the card (rowsOf sets the final size)
				Size = UDim2.fromOffset(cardWidth, cardHeight),
			})
		end
		if card == nil then
			card = Common.card(scope, {
				Name = w.id,
				Size = UDim2.new(1, 0, 0, cardHeight),
				Tone = if w.locked then "Gray" else w.tone,
				Radius = "L",
				Clip = true,
				Children = {
					scope:Icon {
						Name = "Art",
						Icon = w.icon,
						Size = 190,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.new(1, -70, 0.62, 0),
						Rotation = -10,
						Color = Kit.themed(scope, function(theme)
							return Color.lighten(Theme.tone(theme, if w.locked then "Gray" else w.tone).Top, 0.2)
						end),
						StrokeColor = Kit.themed(scope, function(theme)
							return Theme.tone(theme, if w.locked then "Gray" else w.tone).Edge
						end),
						Stroke = 4,
						Transparency = 0.15,
						ZIndex = 2,
					},
					scope:Text {
						Text = w.name,
						TextSize = 36,
						Font = "Title",
						XAlign = "Left",
						Position = UDim2.fromOffset(22, 18),
						StrokeColor = Kit.themed(scope, function(theme)
							return Theme.tone(theme, if w.locked then "Gray" else w.tone).Edge
						end),
						Stroke = 3,
						ZIndex = 4,
					},
					if w.requirement
						then Common.pill(scope, {
							Icon = if w.locked then "lock" else "check",
							Text = w.requirement,
							Tone = "Dark",
							Transparency = 0.25,
							Height = 34,
							Position = UDim2.fromOffset(22, 70),
							ZIndex = 4,
						})
						else nil,
					ctx.button(scope, {
						Name = "Go",
						Text = if w.locked then "Locked" else "Teleport",
						Icon = if w.locked then "lock" else "play",
						Tone = if w.locked then "Gray" else "Green",
						Size = UDim2.fromOffset(190, 60),
						AnchorPoint = Vector2.new(0, 1),
						Position = UDim2.new(0, 22, 1, -20),
						ZIndex = 5,
					}, { kind = "None" }, nil, go),
				},
			})
		end
		table.insert(cells, card)
	end
	return rowsOf(scope, cells, perRow, cardHeight, GAP)
end

BUILD.Party = function(ctx, scope, s, width, info)
	local members = ctx.ui:state(s.id, "members", {})
	local slotHeight = 88
	local perRow = if width >= 900 then 2 else 1
	local rowsCount = math.ceil(s.slots / perRow)
	local height = rowsCount * slotHeight + (rowsCount - 1) * 16
	-- invites only where Roblox allows them (age / privacy settings, Studio):
	-- ask once, and until the answer comes the slots stay plain "open slots"
	local canInvite = scope:Value(ctx.preview)
	local localPlayer = game:GetService("Players").LocalPlayer
	if not ctx.preview and localPlayer then
		task.spawn(function()
			local ok, allowed = pcall(function()
				return game:GetService("SocialService"):CanSendGameInviteAsync(localPlayer)
			end)
			canInvite:set(ok and allowed == true)
		end)
	end
	local function invite()
		if not peek(canInvite) then
			return
		end
		ctx.ui:emit("PartyInvite", { id = s.id, screen = info.screen })
		if ctx.preview then
			ctx.ui:toast("Invite friends", "friends")
		else
			pcall(function()
				game:GetService("SocialService"):PromptGameInvite(localPlayer)
			end)
		end
	end
	local PartyPanel = Common.component("PartyPanel")
	if PartyPanel then
		local panel = Common.try(PartyPanel, scope, {
			-- under a window already called "Party", the list is the members
			Title = if s.title and not repeatsTitle(s.title, info) then s.title elseif repeatsTitle(s.title or "Party", info) then "Members" else "Party",
			Members = members,
			Slots = s.slots,
			OnInvite = invite,
			CanInvite = canInvite,
			Width = width,
		})
		if panel then
			return panel, measured(panel, height), true -- draws its own title
		end
	end
	local cells = {}
	local player = game:GetService("Players").LocalPlayer
	for index = 1, s.slots do
		local me = index == 1
		table.insert(cells, Common.card(scope, {
			Name = `Slot{index}`,
			Size = UDim2.new(1, 0, 0, slotHeight),
			Tone = if me then "Blue" else nil,
			Children = {
				Common.iconTile(scope, {
					Icon = if me then "player" else "plus",
					Tone = if me then "Purple" else "Dark",
					Size = 60,
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 14, 0.5, 0),
					ZIndex = 3,
				}),
				scope:Text {
					Text = if me then (if player then player.DisplayName else "You") else "Empty slot",
					TextSize = 26,
					Font = if me then "Title" else "Body",
					Color = if me then nil else muted(scope),
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 92, 0.5, 0),
					ZIndex = 3,
				},
				if not me
					then ctx.button(scope, {
						Name = "Invite",
						Text = "Invite",
						Icon = "friends",
						Tone = "Green",
						TextSize = 22,
						Size = UDim2.fromOffset(150, 52),
						AnchorPoint = Vector2.new(1, 0.5),
						Position = UDim2.new(1, -16, 0.5, 0),
						Visible = canInvite,
						ZIndex = 4,
					}, { kind = "None" }, nil, invite)
					else Common.pill(scope, {
						Text = "LEADER",
						Tone = "Yellow",
						Icon = "crown",
						Height = 34,
						AnchorPoint = Vector2.new(1, 0.5),
						Position = UDim2.new(1, -16, 0.5, 0),
						ZIndex = 4,
					}),
			},
		}))
	end
	return rowsOf(scope, cells, perRow, slotHeight, 16, "Left")
end

BUILD.Keybinds = function(ctx, scope, s, width, info)
	if #s.binds == 0 then
		return emptyState(scope, "No controls to change", "keyboard", 140)
	end
	local rowHeight = 64
	local KeybindRow = Common.component("KeybindRow")
	local rows = {}
	for index, bind in s.binds do
		local keyValue = ctx.ui:state(s.id, bind.action, bind.key)
		local row = nil
		if KeybindRow then
			-- mouse binds (MouseButton1/2) are UserInputTypes, not KeyCodes
			local enumValue = scope:Value(Common.inputEnum(bind.key))
			local pad = Common.inputEnum(bind.gamepad)
			row = Common.try(KeybindRow, scope, {
				Action = bind.action,
				Key = enumValue,
				Gamepad = pad,
				OnRebind = function(newKey)
					ctx.ui:emit("Rebind", { id = s.id, screen = info.screen, payload = { action = bind.action, key = tostring(newKey) } })
				end,
				Size = UDim2.new(1, 0, 0, rowHeight),
				LayoutOrder = index,
			})
		end
		if row == nil then
			row = Common.card(scope, {
				Name = bind.action,
				Size = UDim2.new(1, 0, 0, rowHeight),
				LayoutOrder = index,
				Highlight = false,
				Children = {
					scope:Text {
						Text = bind.action,
						TextSize = 24,
						Font = "Heavy",
						XAlign = "Left",
						AnchorPoint = Vector2.new(0, 0.5),
						Position = UDim2.new(0, 20, 0.5, 0),
						ZIndex = 3,
					},
					scope:Row {
						Name = "Keys",
						Size = UDim2.fromOffset(300, 44),
						AnchorPoint = Vector2.new(1, 0.5),
						Position = UDim2.new(1, -16, 0.5, 0),
						Gap = 8,
						Align = "Right",
						ZIndex = 3,
						[Children] = {
							scope:KeyCap {
								Key = scope:Computed(function(use)
									local name = use(keyValue)
									return Common.inputEnum(name) or name
								end),
								Size = 40,
								LayoutOrder = 1,
							},
						},
					},
				},
			})
		end
		table.insert(rows, row)
	end
	local height = #rows * rowHeight + (#rows - 1) * 10
	return scope:Column {
		Name = "Keybinds",
		Size = UDim2.new(1, 0, 0, height),
		Gap = 10,
		[Children] = rows,
	}, height
end

--------------------------------------------------------------------------
-- v1.6: retention and monetisation (Blueprint.Rewards holds the state,
-- UI.Server decides every reward)
--------------------------------------------------------------------------

local RARITY_ORDER = Schema.RARITIES

-- The shared state of a reward section (made by Blueprint.Rewards).
local function rewardState(ctx, s): any
	local rewards = ctx.ui.rewards
	return rewards and rewards.sections[s.id]
end

-- Chance per rarity, rarest last: { { rarity, chance } }.
local function oddsByRarity(wedges: { any }): { { any } }
	local totals: { [string]: number } = {}
	for _, w in wedges do
		totals[w.rarity] = (totals[w.rarity] or 0) + (w.chance or 0)
	end
	local out = {}
	for _, rarity in RARITY_ORDER do
		if totals[rarity] then
			table.insert(out, { rarity, math.floor(totals[rarity] * 100 + 0.5) / 100 })
		end
	end
	return out
end

local SPIN_STATUS = 76
local SPIN_BUTTON = 72
local SPIN_SECONDARY = 60
local SPIN_ODDS_ROW = 36

-- Spin section geometry, shared by BUILD.Spin and MEASURE.Spin.
-- A paid spin that would pay back more than SPIN_MAX_RETURN of its price is
-- off (UI.Server refuses it too): the wheel doesn't offer it.
local function paidSpinOn(s): boolean
	return s.price ~= nil and not ((Schema.spinReturn(s) or 0) > Schema.SPIN_MAX_RETURN)
end

-- an odds chip needs this much width ("Legendary" + the chance, readable)
local SPIN_ODDS_CHIP = 200

local function spinLayout(s, width: number, info: Info): { [string]: any }
	local room = info.available or info.pageHeight
	local paid = paidSpinOn(s)
	local luck = s.luckGamePassId ~= nil or s.luckPrice ~= nil
	local wide = width >= 760
	local wheel, panelW
	if wide then
		wheel = math.floor(math.clamp(math.min(width * 0.46, room - 8), 320, 480))
		if width - wheel - GAP < 380 then
			wheel = width - GAP - 380
		end
		panelW = width - wheel - GAP
	else
		wheel = math.floor(math.clamp(math.min(width - 32, room - 8), 280, 420))
		panelW = width
	end
	-- as many chips across as keep their text full size
	local oddsColumns = math.clamp(math.floor((panelW + 8) / (SPIN_ODDS_CHIP + 8)), 2, 3)
	local oddsRows = math.ceil(#oddsByRarity(s.wedges) / oddsColumns)
	local panelH = SPIN_STATUS + 12 + SPIN_BUTTON
	if s.freeHours > 0 and (paid or luck) then
		panelH += 12 + SPIN_SECONDARY
	elseif s.freeHours <= 0 and luck then
		panelH += 12 + SPIN_SECONDARY
	end
	panelH += 16 + 24 + 8 + oddsRows * SPIN_ODDS_ROW + (oddsRows - 1) * 8
	if wide then
		return { wide = true, wheel = wheel, panelW = panelW, panelH = panelH, height = math.max(wheel, panelH), oddsColumns = oddsColumns }
	end
	return { wide = false, wheel = wheel, panelW = panelW, panelH = panelH, height = wheel + GAP + panelH, oddsColumns = oddsColumns }
end

BUILD.Spin = function(ctx, scope, s, width, info)
	local state = rewardState(ctx, s)
	local SpinWheel = Common.component("SpinWheel")
	if state == nil or SpinWheel == nil then
		return emptyState(scope, "The wheel is resting", "chart-pie", 160)
	end
	local layout = spinLayout(s, width, info)
	local rewards = ctx.ui.rewards
	local clock = rewards.now or ctx.clock -- the server's clock (cooldowns are its unix times)
	local wheel
	local function stopped(index: number)
		local wedge = s.wedges[index]
		if wedge then
			rewards:payout(wedge, wheel)
			ctx.ui:emit("SpinWon", { id = s.id, screen = info.screen, payload = { index = index, wedge = wedge } })
		end
	end
	wheel = SpinWheel.new(scope, {
		Name = "Wheel",
		Wedges = s.wedges,
		Size = layout.wheel,
		Result = state.result,
		Spinning = state.spinning,
		Luck = state.luck,
		OnStop = stopped,
		AnchorPoint = if layout.wide then Vector2.new(0, 0.5) else Vector2.new(0.5, 0),
		Position = if layout.wide then UDim2.new(0, 0, 0, layout.height / 2) else UDim2.fromScale(0.5, 0),
	})
	local function spin(mode: string)
		if peek(state.spinning) then
			return
		end
		rewards:claim(s.id, nil, mode, function(ok, _reason, data)
			if ok and typeof(data) == "table" and typeof(data.index) == "number" then
				state.result:set({ index = data.index, token = os.clock(), mode = mode })
			end
		end)
	end
	local function buyRobuxSpin()
		if peek(state.spinning) then
			return
		end
		if s.productId then
			ctx.run({ kind = "Purchase", productId = s.productId }, { id = s.id, screen = info.screen })
		else
			ctx.ui:notify("Not for sale yet!")
		end
	end
	local nextIn = scope:Computed(function(use)
		return math.max(0, (tonumber(use(state.nextFree)) or 0) - use(clock))
	end)
	local ready = state.freeReady
	local busy = state.spinning

	-- status: when the next free spin is (or that it's ready). A quiet glass
	-- card: FREE SPIN under it is the one loud green thing in the panel.
	local status = Common.card(scope, {
		Name = "Status",
		Size = UDim2.new(1, 0, 0, SPIN_STATUS),
		Children = {
			Common.iconTile(scope, {
				Icon = scope:Computed(function(use)
					return if use(ready) then "gift" elseif s.freeHours > 0 then "clock" else "chart-pie"
				end),
				Tone = scope:Computed(function(use)
					return if use(ready) then "Green" else "Dark"
				end),
				Size = 56,
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 10, 0.5, 0),
				ZIndex = 3,
			}),
			scope:Text {
				Name = "Heading",
				Text = scope:Computed(function(use)
					if s.freeHours <= 0 then
						return "Spin to win"
					end
					return if use(ready) then "Free spin ready!" else "Next free spin"
				end),
				TextSize = 22,
				Font = "Title",
				XAlign = "Left",
				Position = UDim2.fromOffset(78, 10),
				Size = UDim2.new(1, -90, 0, 26),
				Truncate = true,
				ZIndex = 3,
			},
			scope:Text {
				Name = "When",
				Text = scope:Computed(function(use)
					if s.freeHours <= 0 then
						return "Rare wedges pay big"
					elseif use(ready) then
						return "Tap SPIN!"
					end
					return Rewards.countdown(use(nextIn))
				end),
				Case = "Normal",
				TextSize = 26,
				Font = "Display",
				XAlign = "Left",
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					return if use(ready) then theme.Tone.Green.Top else theme.Color.Accent
				end),
				Position = UDim2.fromOffset(78, 38),
				Size = UDim2.new(1, -90, 0, 30),
				ZIndex = 3,
			},
		},
	})

	local priceIcon = if s.price then Common.priceIcon(ctx.ui.currencyDefs, s.currency) else nil
	local paidOn = paidSpinOn(s)
	local primary, secondary = nil, {}
	if s.freeHours > 0 then
		primary = ctx.button(scope, {
			Name = "FreeSpin",
			Text = scope:Computed(function(use)
				if use(busy) then
					return "SPINNING..."
				end
				return if use(ready) then "FREE SPIN" else `FREE IN {Rewards.countdown(use(nextIn))}`
			end),
			Icon = "chart-pie",
			Tone = scope:Computed(function(use)
				return if use(ready) then "Green" else "Gray"
			end),
			Primary = true,
			Pulse = scope:Computed(function(use)
				return use(ready) and not use(busy)
			end),
			Disabled = scope:Computed(function(use)
				return not use(ready) or use(busy)
			end),
			TextSize = 30,
			Size = UDim2.new(1, 0, 0, SPIN_BUTTON),
			ZIndex = 4,
		}, { kind = "None" }, nil, function()
			spin("free")
		end)
		if paidOn then
			table.insert(secondary, ctx.button(scope, {
				Name = "PaidSpin",
				Text = `SPIN  {Format.comma(s.price)}`,
				Icon = priceIcon,
				Tone = if s.currency == "robux" then "Yellow" else "Blue",
				Disabled = busy,
				TextSize = 24,
				Size = UDim2.new(1, 0, 0, SPIN_SECONDARY),
				ZIndex = 4,
			}, { kind = "None" }, nil, function()
				if s.currency == "robux" then
					buyRobuxSpin()
				else
					spin("paid")
				end
			end))
		end
	else
		-- no free spins: the paid spin is the one primary action
		primary = ctx.button(scope, {
			Name = "PaidSpin",
			Text = scope:Computed(function(use)
				if not paidOn then
					return "SPINS PAUSED"
				end
				return if use(busy) then "SPINNING..." else `SPIN  {Format.comma(s.price or 0)}`
			end),
			Icon = priceIcon,
			Tone = if paidOn then "Green" else "Gray",
			Primary = paidOn,
			Shine = paidOn,
			Disabled = if paidOn then busy else true,
			TextSize = 30,
			Size = UDim2.new(1, 0, 0, SPIN_BUTTON),
			ZIndex = 4,
		}, { kind = "None" }, nil, function()
			if s.currency == "robux" then
				buyRobuxSpin()
			else
				spin("paid")
			end
		end)
	end
	if s.luckGamePassId or s.luckPrice then
		local luck = state.luck
		table.insert(secondary, ctx.button(scope, {
			Name = "Luck",
			Text = scope:Computed(function(use)
				return if use(luck) == true then "x2 LUCK ON" else `x2 LUCK  {if s.luckPrice then Format.comma(s.luckPrice) else ""}`
			end),
			Icon = scope:Computed(function(use)
				return if use(luck) == true then "check" elseif s.luckPrice then "robux" else "sparkle"
			end),
			Tone = "Yellow",
			Disabled = scope:Computed(function(use)
				return use(luck) == true
			end),
			TextSize = 22,
			Size = UDim2.new(1, 0, 0, SPIN_SECONDARY),
			ZIndex = 4,
		}, { kind = "None" }, nil, function()
			if peek(luck) == true then
				return
			elseif s.luckGamePassId then
				ctx.run({ kind = "GamePass", gamePassId = s.luckGamePassId }, { id = s.id, screen = info.screen })
			else
				ctx.ui:notify("Not for sale yet!")
			end
		end))
	end

	-- odds by rarity, so players always know what they spin for: quiet glass
	-- chips with a rarity gem, the rarity's name in its colour and the
	-- chance, so the panel isn't a wall of saturated pills
	local odds = {}
	local chipText = SPIN_ODDS_ROW - 16
	for index, pair in oddsByRarity(s.wedges) do
		local rarityTone = Kit.themed(scope, function(theme)
			return Common.rarityTone(theme, pair[1])
		end)
		table.insert(odds, scope:New "Frame" {
			Name = pair[1],
			LayoutOrder = index,
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = Kit.themed(scope, function(theme)
				return math.clamp(theme.Alpha.Surface - 0.1, 0, 1)
			end),
			ZIndex = 4,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.stroke(scope, {
					Color = scope:Computed(function(use)
						return use(rarityTone).Bottom
					end),
					Thickness = 1.5,
					Transparency = 0.45,
				}),
				scope:New "Frame" {
					Name = "Gem",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 12, 0.5, 0),
					Size = UDim2.fromOffset(12, 12),
					Rotation = 45,
					BackgroundColor3 = scope:Computed(function(use)
						return Color.lighten(use(rarityTone).Top, 0.1)
					end),
					ZIndex = 5,
					[Children] = {
						Kit.corner(scope, 3),
						Kit.stroke(scope, {
							Color = scope:Computed(function(use)
								return use(rarityTone).Edge
							end),
							Thickness = 1.5,
						}),
					},
				},
				scope:Text {
					Name = "Rarity",
					Text = pair[1],
					TextSize = chipText,
					Font = "Heavy",
					XAlign = "Left",
					Color = scope:Computed(function(use)
						-- light enough to read on dark glass, whatever the rarity
						local top = use(rarityTone).Top
						return if Color.luminance(top) < 0.55 then Color.lighten(top, 0.35) else top
					end),
					Stroke = false,
					Position = UDim2.fromOffset(32, 0),
					Size = UDim2.new(1, -104, 1, 0),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = chipText,
					ZIndex = 5,
				},
				scope:Text {
					Name = "Chance",
					Text = `{pair[2]}%`,
					Case = "Normal",
					TextSize = chipText,
					Font = "Display",
					XAlign = "Right",
					Stroke = false,
					AnchorPoint = Vector2.new(1, 0),
					Position = UDim2.new(1, -12, 0, 0),
					Size = UDim2.new(0, 58, 1, 0),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = chipText,
					ZIndex = 5,
				},
			},
		})
	end
	local oddsRows = math.ceil(#odds / layout.oddsColumns)
	local oddsH = oddsRows * SPIN_ODDS_ROW + (oddsRows - 1) * 8

	local blocks = { status, primary }
	local y = SPIN_STATUS + 12 + SPIN_BUTTON
	status.Position = UDim2.new()
	primary.Position = UDim2.fromOffset(0, SPIN_STATUS + 12)
	primary.AnchorPoint = Vector2.zero
	if #secondary > 0 then
		local row = {}
		for index, button in secondary do
			button.Size = UDim2.new(1 / #secondary, if #secondary > 1 then -6 else 0, 0, SPIN_SECONDARY)
			button.AnchorPoint = Vector2.zero
			button.Position = UDim2.fromScale(0, 0)
			button.LayoutOrder = index
			table.insert(row, button)
		end
		table.insert(blocks, scope:Row {
			Name = "More",
			Position = UDim2.fromOffset(0, y + 12),
			Size = UDim2.new(1, 0, 0, SPIN_SECONDARY),
			Gap = 12,
			[Children] = row,
		})
		y += 12 + SPIN_SECONDARY
	end
	local oddsNote = panelMuted(scope, panelInk(scope, ctx, info))
	table.insert(blocks, scope:Text {
		Name = "OddsLabel",
		Text = if s.luckGamePassId or s.luckPrice then "ODDS  (x2 luck doubles Epic and rarer)" else "ODDS",
		TextSize = 19,
		Font = "Heavy",
		XAlign = "Left",
		Color = oddsNote.Color,
		Stroke = oddsNote.Stroke,
		StrokeColor = oddsNote.StrokeColor,
		Position = UDim2.fromOffset(0, y + 16),
		Size = UDim2.new(1, 0, 0, 24),
		Truncate = true,
		ZIndex = 3,
	})
	table.insert(blocks, scope:New "Frame" {
		Name = "Odds",
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(0, y + 16 + 24 + 8),
		Size = UDim2.new(1, 0, 0, oddsH),
		ZIndex = 3,
		[Children] = {
			scope:New "UIGridLayout" {
				CellSize = UDim2.new(1 / layout.oddsColumns, -8, 0, SPIN_ODDS_ROW),
				CellPadding = UDim2.fromOffset(8, 8),
				SortOrder = Enum.SortOrder.LayoutOrder,
			},
			odds,
		},
	})
	local panel = scope:New "Frame" {
		Name = "Panel",
		BackgroundTransparency = 1,
		AnchorPoint = if layout.wide then Vector2.new(1, 0.5) else Vector2.zero,
		Position = if layout.wide then UDim2.new(1, 0, 0, layout.height / 2) else UDim2.fromOffset(0, layout.wheel + GAP),
		Size = UDim2.fromOffset(layout.panelW, layout.panelH),
		ZIndex = 3,
		[Children] = blocks,
	}
	return scope:New "Frame" {
		Name = "Spin",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, layout.height),
		[Children] = { wheel, panel },
	}, layout.height
end

-- Gifts section geometry (BUILD.Gifts and MEASURE.Gifts).
local GIFTS_HEADER = 60
local function giftsLayout(s, width: number): { [string]: any }
	local gap = 16
	local count = #s.gifts
	local columns = math.clamp(math.floor((width + gap) / (148 + gap)), 2, 6)
	-- balanced rows: 8 gifts on a 6-wide page read better as 4 + 4
	local rows = math.ceil(count / columns)
	columns = math.ceil(count / math.max(1, rows))
	local tileW = math.min(216, math.floor((width - (columns - 1) * gap) / columns))
	local tileH = math.clamp(math.floor(tileW * 0.95), 170, 206)
	local gridH = rows * tileH + (rows - 1) * gap
	return { columns = columns, rows = rows, tile = Vector2.new(tileW, tileH), gap = gap, gridH = gridH, height = GIFTS_HEADER + 12 + gridH }
end

BUILD.Gifts = function(ctx, scope, s, width, info)
	local state = rewardState(ctx, s)
	local GiftGrid = Common.component("GiftGrid")
	if state == nil or GiftGrid == nil or #s.gifts == 0 then
		return emptyState(scope, "No gifts right now", "gift", 160)
	end
	local layout = giftsLayout(s, width)
	local rewards = ctx.ui.rewards
	local grid: Instance? = nil
	local function opened(data: any)
		for _, index in (typeof(data) == "table" and data.claimed) or {} do
			local tile = grid and grid:FindFirstChild(`Gift{index}`)
			GiftGrid.open(tile)
			local gift = s.gifts[index]
			if gift then
				task.delay(0.18, function()
					rewards:payout(gift, tile)
				end)
			end
		end
	end
	grid = GiftGrid.new(scope, {
		Name = "Gifts",
		Gifts = s.gifts,
		Playtime = state.playtime,
		Claimed = state.mask,
		Columns = layout.columns,
		TileSize = layout.tile,
		Gap = layout.gap,
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, GIFTS_HEADER + 12),
		OnClaim = function(index)
			rewards:claim(s.id, index, nil, function(ok, _reason, data)
				if ok then
					opened(data)
				end
			end)
		end,
	})
	local ready = state.readyCount
	local total = #s.gifts
	local openedCount = scope:Computed(function(use)
		local mask = tonumber(use(state.mask)) or 0
		local n = 0
		for index = 1, total do
			if Rewards.bitOn(mask, index) then
				n += 1
			end
		end
		return n
	end)
	-- the next gift's countdown, and Claim All once several are ready
	local nextGift = scope:Computed(function(use)
		local mask = tonumber(use(state.mask)) or 0
		local played = use(state.playtime)
		for index, gift in s.gifts do
			if not Rewards.bitOn(mask, index) and played < gift.minutes * 60 then
				return gift.minutes * 60 - played
			end
		end
		return nil
	end)
	local giftNote = panelMuted(scope, panelInk(scope, ctx, info))
	local header = scope:New "Frame" {
		Name = "Header",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, GIFTS_HEADER),
		[Children] = {
			Common.iconTile(scope, {
				Icon = "gift",
				-- the theme's accent, not a fixed pink (a pink tile in a slate
				-- Tactical window reads as a stray sticker)
				Tone = accent(scope),
				Size = 52,
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 0, 0.5, 0),
				ZIndex = 3,
			}),
			scope:Text {
				Name = "Title",
				Text = scope:Computed(function(use)
					return `FREE GIFTS  {use(openedCount)}/{total}`
				end),
				TextSize = 26,
				Font = "Title",
				XAlign = "Left",
				Position = UDim2.fromOffset(66, 4),
				Size = UDim2.new(1, -300, 0, 30),
				Truncate = true,
				ZIndex = 3,
			},
			scope:Text {
				Name = "Next",
				Text = scope:Computed(function(use)
					local left = use(nextGift)
					if left == nil then
						return if use(ready) > 0 then "Every gift is ready!" else "All opened. New gifts next session!"
					end
					return `Keep playing: next gift in {Rewards.countdown(left)}`
				end),
				Case = "Normal",
				TextSize = 19,
				Font = "Body",
				XAlign = "Left",
				Color = giftNote.Color,
				Stroke = giftNote.Stroke,
				StrokeColor = giftNote.StrokeColor,
				Position = UDim2.fromOffset(66, 34),
				Size = UDim2.new(1, -300, 0, 22),
				Truncate = true,
				ZIndex = 3,
			},
			ctx.button(scope, {
				Name = "ClaimAll",
				Text = scope:Computed(function(use)
					return `CLAIM ALL ({use(ready)})`
				end),
				Icon = "gift",
				Tone = "Green",
				Primary = true,
				Shine = true,
				TextSize = 22,
				Size = UDim2.fromOffset(230, 54),
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, 0, 0.5, 0),
				Visible = scope:Computed(function(use)
					return use(ready) >= 2
				end),
				ZIndex = 4,
			}, { kind = "None" }, nil, function()
				rewards:claim(s.id, "all", nil, function(ok, _reason, data)
					if ok then
						opened(data)
					end
				end)
			end),
		},
	}
	return scope:New "Frame" {
		Name = "Gifts",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, layout.height),
		[Children] = { header, grid },
	}, layout.height
end

BUILD.Index = function(ctx, scope, s, width, info)
	local state = rewardState(ctx, s)
	local IndexBook = Common.component("IndexBook")
	if state == nil or IndexBook == nil then
		return emptyState(scope, "The index is empty", "book-open", 160)
	end
	local rewards = ctx.ui.rewards
	local height = IndexBook.height(s.groups, width, #s.milestones > 0)
	local book = IndexBook.new(scope, {
		Name = "Index",
		Groups = s.groups,
		Owned = state.owned,
		Found = state.found,
		Milestones = s.milestones,
		Claimed = state.claimedMask,
		Width = width,
		OnClaim = function(index, marker)
			rewards:claim(s.id, index, nil, function(ok)
				if ok then
					IndexBook.celebrate(marker)
					rewards:payout(s.milestones[index], marker)
				end
			end)
		end,
	})
	book.Size = UDim2.new(1, 0, 0, height)
	return book, height
end

local ACH_HEADER = 56
local ACH_TILE = 136
local function achievementsLayout(s, width: number): { [string]: any }
	local columns = if width >= 900 then 2 else 1
	local rows = math.ceil(#s.achievements / columns)
	local gridH = rows * ACH_TILE + math.max(0, rows - 1) * 16
	return { columns = columns, rows = rows, gridH = gridH, height = ACH_HEADER + 12 + gridH }
end

BUILD.Achievements = function(ctx, scope, s, width, info)
	local state = rewardState(ctx, s)
	local AchievementGrid = Common.component("AchievementGrid")
	if state == nil or AchievementGrid == nil or #s.achievements == 0 then
		return emptyState(scope, "No achievements yet", "trophy", 160)
	end
	local layout = achievementsLayout(s, width)
	local rewards = ctx.ui.rewards
	local byId = {}
	for _, a in s.achievements do
		byId[a.id] = a
	end
	local tileW = math.floor((width - (layout.columns - 1) * 16) / layout.columns)
	local grid = AchievementGrid.new(scope, {
		Name = "Achievements",
		Achievements = s.achievements,
		Progress = state.progress,
		Done = state.done,
		-- a currency reward in its currency's colour (like the HUD counter)
		RewardColor = function(reward)
			local def = reward.grant and ctx.ui.currencyDefs[reward.grant.currency]
			return if def and def.color then Common.color(def.color) else nil
		end,
		Columns = layout.columns,
		TileSize = Vector2.new(tileW, ACH_TILE),
		Gap = 16,
		Position = UDim2.fromOffset(0, ACH_HEADER + 12),
		OnClaim = function(id, tile)
			rewards:claim(s.id, id, nil, function(ok)
				if ok then
					AchievementGrid.celebrate(tile)
					local a = byId[id]
					if a and a.reward then
						rewards:payout(a.reward, tile)
					end
				end
			end)
		end,
	})
	local score = state.score
	local header = Common.card(scope, {
		Name = "Score",
		Size = UDim2.new(1, 0, 0, ACH_HEADER),
		Tone = "Dark",
		Children = {
			scope:Icon {
				Icon = "trophy",
				Size = 38,
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 14, 0.5, 0),
				Color = accent(scope),
				Gradient = true,
				Shadow = true,
				ZIndex = 3,
			},
			scope:Text {
				Text = scope:Computed(function(use)
					local sc = use(score)
					return `SCORE  {Format.comma(sc.score)} / {Format.comma(sc.total)}`
				end),
				TextSize = 26,
				Font = "Title",
				XAlign = "Left",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 62, 0.5, 0),
				Size = UDim2.new(0.5, -62, 0, 30),
				Truncate = true,
				ZIndex = 3,
			},
			scope:ProgressBar {
				Value = scope:Computed(function(use)
					return use(score).score
				end),
				Max = scope:Computed(function(use)
					return math.max(1, use(score).total)
				end),
				Tone = "Yellow",
				Ghost = false,
				Label = function(value, max)
					return Format.percent(if max > 0 then value / max else 0)
				end,
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -16, 0.5, 0),
				Size = UDim2.new(0.42, 0, 0, 26),
				ZIndex = 3,
			},
		},
	})
	return scope:New "Frame" {
		Name = "Achievements",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, layout.height),
		[Children] = { header, grid },
	}, layout.height
end

local SOCIAL_ROW = 100
BUILD.Social = function(ctx, scope, s, width, info)
	local state = rewardState(ctx, s)
	if state == nil or #s.rewards == 0 then
		return emptyState(scope, "Nothing here yet", "friends", 140)
	end
	local rewards = ctx.ui.rewards
	local clock = rewards.now or ctx.clock
	local rows = {}
	local started = tonumber(peek(rewards.sessionStart)) or os.time()
	for index, r in s.rewards do
		local claimed = scope:Computed(function(use)
			return Rewards.bitOn(tonumber(use(state.mask)) or 0, index)
		end)
		local prize = r.reward
		local prizeText = if prize and prize.amount then `+{prize.amount}` else ""
		local subtitle, buttonText
		if r.kind == "Group" then
			subtitle = `{prizeText} for group members`
		elseif r.kind == "Like" then
			subtitle = "A thank-you gift, once. We can't see likes: it's on trust!"
		else
			subtitle = `{prizeText} when a friend joins (up to {r.limit or 10})`
		end
		local row
		local function claim()
			if r.kind == "Group" and s.groupId == nil and not ctx.preview then
				ctx.ui:notify("The group reward isn't set up yet. Check back soon!", "info")
				return
			end
			rewards:claim(s.id, r.id, nil, function(ok)
				if ok and prize then
					rewards:payout(prize, row)
				end
			end)
		end
		-- a Like can be claimed after `wait` seconds of play (the server checks too)
		local likeLeft = scope:Computed(function(use)
			return math.max(0, (r.wait or 0) - (use(clock) - started))
		end)
		local buttons = {}
		if r.kind == "Group" then
			table.insert(buttons, ctx.button(scope, {
				Name = "Join",
				Text = "Join",
				Icon = "person-plus",
				Tone = "Blue",
				TextSize = 22,
				Size = UDim2.fromOffset(120, 54),
				LayoutOrder = 1,
				Visible = scope:Computed(function(use)
					return not use(claimed)
				end),
				ZIndex = 4,
			}, { kind = "None" }, nil, function()
				if ctx.preview then
					ctx.ui:toast(if s.groupId then `Opens group {s.groupId}` else "Set the section's groupId", "three-people")
					return
				end
				local ok = s.groupId ~= nil and pcall(function()
					(game:GetService("GroupService") :: any):PromptJoinAsync(s.groupId)
				end)
				if not ok then
					ctx.ui:notify("Join our group from the game's page, then press Claim!", "info")
				end
			end))
		end
		if r.kind == "Invite" then
			buttonText = scope:Computed(function(use)
				local n = tonumber(use(state.invites)) or 0
				return if n > 0 then `Invite ({n})` else "Invite"
			end)
			table.insert(buttons, ctx.button(scope, {
				Name = "Invite",
				Text = buttonText,
				Icon = "person-plus",
				Tone = "Green",
				TextSize = 22,
				Size = UDim2.fromOffset(170, 54),
				LayoutOrder = 2,
				Disabled = scope:Computed(function(use)
					return not use(state.canInvite)
				end),
				ZIndex = 4,
			}, { kind = "None" }, nil, function()
				ctx.ui:emit("SocialInvite", { id = r.id, screen = info.screen })
				if ctx.preview then
					ctx.ui:toast("Invite friends", "friends")
					return
				end
				pcall(function()
					game:GetService("SocialService"):PromptGameInvite(Players.LocalPlayer)
				end)
			end))
		else
			table.insert(buttons, ctx.button(scope, {
				Name = "Claim",
				Text = scope:Computed(function(use)
					if use(claimed) then
						return "Claimed"
					elseif r.kind == "Like" and use(likeLeft) > 0 then
						return Rewards.countdown(use(likeLeft))
					end
					return "Claim"
				end),
				Icon = scope:Computed(function(use)
					return if use(claimed) then "check" elseif prize then prize.icon else "gift"
				end),
				Tone = scope:Computed(function(use)
					return if use(claimed) then "Gray" else "Green"
				end),
				Disabled = scope:Computed(function(use)
					return use(claimed) or (r.kind == "Like" and use(likeLeft) > 0)
				end),
				TextSize = 22,
				Size = UDim2.fromOffset(160, 54),
				LayoutOrder = 2,
				ZIndex = 4,
			}, { kind = "None" }, nil, claim))
		end
		row = Common.card(scope, {
			Name = r.id,
			Size = UDim2.new(1, 0, 0, SOCIAL_ROW),
			LayoutOrder = index,
			Children = {
				Common.iconTile(scope, {
					Icon = r.icon,
					Tone = if r.kind == "Group" then "Blue" elseif r.kind == "Like" then "Pink" else "Green",
					Size = 72,
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 14, 0.5, 0),
					ZIndex = 3,
				}),
				scope:Text {
					Text = r.label,
					TextSize = 26,
					Font = "Title",
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 1),
					Position = UDim2.new(0, 102, 0.5, 2),
					Size = UDim2.new(1, -420, 0, 30),
					Truncate = true,
					ZIndex = 3,
				},
				scope:Row {
					Name = "Reward",
					AnchorPoint = Vector2.new(0, 0),
					Position = UDim2.new(0, 102, 0.5, 6),
					Size = UDim2.new(1, -420, 0, 28),
					Gap = 6,
					Align = "Left",
					VAlign = "Center",
					ZIndex = 3,
					[Children] = {
						if prize and r.kind ~= "Like"
							then scope:Icon { Icon = prize.icon, Size = 24, Gradient = true, Color = accent(scope), LayoutOrder = 1, ZIndex = 3 }
							else nil,
						scope:Text {
							Text = subtitle,
							TextSize = 19,
							Font = if r.kind == "Like" then "Body" else "Display",
							XAlign = "Left",
							Color = if r.kind == "Like" then muted(scope) else accent(scope),
							Stroke = r.kind ~= "Like",
							Size = UDim2.new(1, -30, 0, 26),
							Truncate = true,
							LayoutOrder = 2,
							ZIndex = 3,
						},
					},
				},
				scope:Row {
					Name = "Buttons",
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -16, 0.5, 0),
					Size = UDim2.fromOffset(300, 56),
					Gap = 10,
					Align = "Right",
					VAlign = "Center",
					ZIndex = 4,
					[Children] = buttons,
				},
			},
		})
		table.insert(rows, row)
	end
	local height = #rows * SOCIAL_ROW + (#rows - 1) * 12
	return scope:Column {
		Name = "Social",
		Size = UDim2.new(1, 0, 0, height),
		Gap = 12,
		[Children] = rows,
	}, height
end

--------------------------------------------------------------------------
-- Entry point
--------------------------------------------------------------------------

-- Titles that repeat the page title add nothing; builders skip them.
local HEADERLESS = { Banner = true, Buttons = true }

local SECTION_ICONS = {
	Products = "shop", Inventory = "backpack", Upgrades = "upgrade", Daily = "daily", Quests = "quest",
	Pass = "premium", Leaderboard = "trophy", Scoreboard = "trophy", Loadout = "melee", Stats = "level",
	Settings = "settings", Codes = "tag", Text = "info", Cases = "cube", Worlds = "world", Profile = "player",
	Party = "friends", Keybinds = "keyboard",
	-- v1.6
	Spin = "chart-pie", Gifts = "gift", Index = "book-open", Achievements = "trophy", Social = "three-people",
}

-- v2.4: what the section layouts in Prefabs.Layouts borrow from here, so
-- they behave like the v1 layouts beside them. `openCase(c, source)` needs
-- the section, so it's bound per build (layoutHelpers).
local HELPERS = {
	GAP = GAP,
	HEADER = HEADER,
	HEADER_GAP = HEADER_GAP,
	STREAK_STRIP = STREAK_STRIP,
	emptyState = emptyState,
	itemTile = itemTile,
	detailPanel = detailPanel,
	inventoryGrid = inventoryGrid,
	gridNatural = gridNatural,
	streakStrip = streakStrip,
	panelInk = panelInk,
	panelMuted = panelMuted,
	rewardState = rewardState,
}

local function layoutHelpers(ctx, s, info): { [string]: any }
	local helpers = table.clone(HELPERS)
	helpers.openCase = function(c, source)
		openCase(ctx, s, info, c, source)
	end
	return helpers
end

-- Builds one section block (title row + content). Returns the block and its height.
function Sections.build(ctx: any, scope: any, s: { [string]: any }, width: number, info: Info): (Instance, number)
	local builder = BUILD[s.kind]
	-- v2.4 section layouts (Blueprint.Schema.SECTION_LAYOUTS_V24)
	if SectionLayouts.has(s) then
		builder = function(ctx2, scope2, s2, width2, info2)
			return SectionLayouts.build(ctx2, scope2, s2, width2, info2, layoutHelpers(ctx2, s2, info2))
		end
	end
	local sectionScope = scope:innerScope()
	-- builders return (content, height, ownsTitle): ownsTitle = the component
	-- already drew the section title, so no header row is added
	local ok, content, height, ownsTitle = pcall(builder, ctx, sectionScope, s, width, info)
	if not ok or typeof(content) ~= "Instance" then
		warn(`[BloxUI] Blueprint: section "{s.id}" ({s.kind}) failed: {if ok then "no content" else content}`)
		Fusion.doCleanup(sectionScope)
		sectionScope = scope:innerScope()
		content, height = emptyState(sectionScope, "Couldn't build this section", "warning", 120)
	end
	content.LayoutOrder = 2
	-- no header row when the component draws its own, or when it would only
	-- repeat the window / tab title right above it
	local showHeader = s.title ~= nil and not HEADERLESS[s.kind] and not (ok and ownsTitle) and not repeatsTitle(s.title, info)
	local total = height + (if showHeader then HEADER + HEADER_GAP else 0)
	return sectionScope:New "Frame" {
		Name = s.id,
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, total),
		[Children] = {
			sectionScope:New "UIListLayout" {
				FillDirection = Enum.FillDirection.Vertical,
				SortOrder = Enum.SortOrder.LayoutOrder,
				Padding = UDim.new(0, HEADER_GAP),
				HorizontalAlignment = Enum.HorizontalAlignment.Center,
			},
			if showHeader
				then Common.header(sectionScope, {
					Text = s.title,
					Icon = SECTION_ICONS[s.kind],
					LayoutOrder = 1,
				})
				else nil,
			content,
		},
	}, total
end

--------------------------------------------------------------------------
-- Measuring without building (lazy tab pages)
--------------------------------------------------------------------------

-- rowsOf's height for `count` cells.
local function rowsHeight(count: number, perRow: number, cellHeight: number, gap: number): number
	local rows = #balancedRows(count, perRow)
	return rows * cellHeight + math.max(0, rows - 1) * gap
end

-- The content height a builder returns (and whether it draws its own
-- title), for kinds whose height follows from the definition alone. Each
-- one mirrors its BUILD twin above: change them together. suite_perf builds
-- every section of every example and genre and checks they agree. Kinds
-- missing here (their height depends on the built component) are built
-- up front as before.
local MEASURE: { [string]: (s: { [string]: any }, width: number, info: Info) -> (number?, boolean?) } = {}

MEASURE.Settings = function(s)
	local n = #s.settings
	return if n == 0 then 140 else n * 72 + (n - 1) * 12
end

MEASURE.Keybinds = function(s)
	local n = #s.binds
	return if n == 0 then 140 else n * 64 + (n - 1) * 10
end

MEASURE.Codes = function()
	return 84
end

MEASURE.Text = function(s, width)
	local body = s.body or ""
	local charsPerLine = math.max(10, math.floor((width - 48) / (22 * 0.5)))
	local lines = 0
	for line in string.gmatch(body .. "\n", "([^\n]*)\n") do
		lines += math.max(1, math.ceil((utf8.len(line) or #line) / charsPerLine))
	end
	return math.max(72, lines * 30 + 40)
end

MEASURE.Banner = function(s, width, info)
	local short = info.pageHeight < 560
	return if s.button then (if short then 200 else 220) else (if short then 160 else 180)
end

MEASURE.Buttons = function(s)
	local n = #s.buttons
	if n == 0 then
		return 0
	elseif s.layout == "Column" then
		return n * 72 + (n - 1) * 16
	end
	return rowsHeight(n, if s.layout == "Grid" then math.min(3, n) else math.min(4, n), 72, 16)
end

MEASURE.Leaderboard = function(s)
	if Common.component("Leaderboard") == nil then
		return nil -- the fallback list: build it
	end
	local shown = math.min(#s.rows, 8)
	return math.max(1, shown) * (60 + 8) - 8 + 56 + 56, true
end

MEASURE.Cases = function(s, width)
	if #s.cases == 0 then
		return 160
	end
	return rowsHeight(#s.cases, if width >= 900 then 3 elseif width >= 560 then 2 else 1, 320, GAP)
end

MEASURE.Upgrades = function(s, width)
	if #s.upgrades == 0 then
		return 160
	end
	return rowsHeight(#s.upgrades, if width >= 1000 then 2 else 1, 136, 16)
end

MEASURE.Inventory = function(s, width, info)
	if #s.items == 0 then
		return 200
	end
	local header = if s.title then HEADER + HEADER_GAP else 0
	local room = (info.available or info.pageHeight) - header
	local panel = nil
	if s.layout == "Showcase" and width >= 620 then
		panel = if width >= 800 then 216 else 192
	elseif s.layout == "Bottom" and width >= 560 then
		panel = if width >= 900 then 176 else 160
	end
	if panel then
		local natural = gridNatural(width, #s.items)
		local gridHeight = math.clamp(math.min(natural, room - panel - GAP), 220, math.max(220, info.pageHeight - header - panel - GAP))
		return gridHeight + GAP + panel
	end
	local detailWidth = if s.detail and s.layout ~= "Bottom" and s.layout ~= "Showcase" and width >= 800 then 340 else 0
	local gridWidth = width - (if detailWidth > 0 then detailWidth + GAP else 0)
	local cellGap = 12
	local columns = math.max(3, math.floor((gridWidth - 16 + cellGap) / (112 + cellGap)))
	local cell = math.floor((gridWidth - 16 - (columns - 1) * cellGap) / columns)
	local natural = math.ceil(#s.items / columns) * (cell + cellGap) + 24
	local preferred = if detailWidth > 0 then 440 else 240
	local minimum = if detailWidth > 0 then 360 else 240
	local want = math.max(preferred, natural)
	local page = math.max(minimum, info.pageHeight - header)
	return if want <= room then want elseif room >= minimum then room else math.min(want, page)
end

MEASURE.Products = function(s, width)
	local list = s.items
	local count = #list
	if count == 0 then
		return 160
	end
	if s.layout == "Carousel" then
		local cardH = if width >= 900 then 300 elseif width >= 600 then 272 else 240
		local cardW = math.floor(cardH * 1.18)
		for _, item in list do
			cardW = math.max(cardW, math.min(subtitleWidth(item.description, cardH), math.floor(width * 0.42)))
		end
		local contentW = count * cardW + math.max(0, count - 1) * 20 + 14 * 2
		return cardH + 14 * 2 + (if contentW > width + 1 then 16 else 0)
	elseif s.layout == "Rows" then
		return count * ROW_HEIGHT + math.max(0, count - 1) * 10
	elseif s.layout == "Bundles" then
		local fits = math.max(1, math.floor((width + GAP) / (200 + GAP)))
		local perRow = if count <= math.min(fits, 5) then count else math.min(fits, 3)
		local cellW = (width - GAP * (perRow - 1)) / perRow
		return rowsHeight(count, perRow, if cellW >= 300 then 360 elseif cellW >= 240 then 340 else 320, GAP)
	end
	local height = 0
	local rest = count
	if s.layout == "Hero" then
		height += if width >= 900 then 240 elseif width >= 600 then 212 else 190
		rest -= 1
	elseif s.layout == "Featured" then
		local featured = if count >= 3 and width >= 700 then 3 elseif count >= 2 and width >= 520 then 2 else 1
		local shares = if featured == 3 then { 1.5, 1, 1 } elseif featured == 2 then { 1.4, 1 } else { 1 }
		local total = 0
		for _, share in shares do
			total += share
		end
		local unit = (width - GAP * (featured - 1)) / total
		height += if featured == 1 then 200 elseif unit >= 330 then 248 else 232
		rest -= featured
	end
	if rest > 0 then
		local block
		if s.layout == "Compact" then
			block = rowsHeight(rest, if width >= 1100 then 2 else 1, 96, 16)
		else
			local perRow = if width >= 900 then 3 elseif width >= 560 then 2 else 1
			local cellWidth = (width - (perRow - 1) * GAP) / perRow
			block = rowsHeight(rest, perRow, if cellWidth >= 330 then 248 else 220, GAP)
		end
		height += (if height > 0 then GAP else 0) + block
	end
	return height
end

-- v1.6 (each mirrors its BUILD twin; the fallbacks build their empty state)
MEASURE.Spin = function(s, width, info)
	if Common.component("SpinWheel") == nil then
		return nil
	end
	return spinLayout(s, width, info).height
end

MEASURE.Gifts = function(s, width)
	if #s.gifts == 0 or Common.component("GiftGrid") == nil then
		return nil
	end
	return giftsLayout(s, width).height
end

MEASURE.Index = function(s, width)
	local IndexBook = Common.component("IndexBook")
	if IndexBook == nil then
		return nil
	end
	return IndexBook.height(s.groups, width, #s.milestones > 0)
end

MEASURE.Achievements = function(s, width)
	if #s.achievements == 0 or Common.component("AchievementGrid") == nil then
		return nil
	end
	return achievementsLayout(s, width).height
end

MEASURE.Social = function(s)
	local n = #s.rewards
	return if n == 0 then 140 else n * SOCIAL_ROW + (n - 1) * 12
end

--[[
	The height Sections.build would give this section, without building it,
	or nil when it can't tell (then build it). Lets the Blueprint size a
	tabbed window before its hidden tabs are built.
]]
function Sections.measure(s: { [string]: any }, width: number, info: Info): number?
	local measure = MEASURE[s.kind]
	-- v2.4 section layouts measure themselves (Prefabs.Layouts)
	if SectionLayouts.has(s) then
		measure = function(s2, width2, info2)
			return SectionLayouts.measure(s2, width2, info2, HELPERS)
		end
	end
	if measure == nil or BUILD[s.kind] == nil then
		return nil
	end
	local ok, height, ownsTitle = pcall(measure, s, width, info)
	if not ok or typeof(height) ~= "number" then
		return nil
	end
	local showHeader = s.title ~= nil and not HEADERLESS[s.kind] and not ownsTitle and not repeatsTitle(s.title, info)
	return height + (if showHeader then HEADER + HEADER_GAP else 0)
end

Sections.GAP = GAP
Sections.HEADER = HEADER

return Sections
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="81">
            <Properties>
              <string name="Name">Settings</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Blueprint.Settings: makes Settings sections real. Every setting id gets one
	Fusion Value (ui.settings[id]) shared by every section that shows it.
	Built-in ids change the game, every change fires ui:on(id, fn), and each
	player's choices are saved by UI.Server (DataStore "BloxUI_Settings").

		ui:setSetting("fov", 90)        -- sets, applies, emits "fov", saves
		ui:getSetting("fov")            --> 90
		ui.settings.music               -- Fusion Value
		ui:saveSettings()               -- send pending changes now

	Built-in ids (docs/BLUEPRINT.md v1.3 and v1.5; Normalize maps aliases to them):
		sfx            Toggle: UI sounds on/off     Slider: UI sound volume (v / 100 for 0..100)
		music          Toggle: music on/off         Slider: music volume
		fov            Slider: Camera.FieldOfView (clamped 50..120, kept when the camera changes)
		sensitivity    Slider: UserInputService.MouseDeltaSensitivity (raw when max <= 5, else v / 50)
		shadows        Toggle: Lighting.GlobalShadows (on this client)
		lowDetail      Toggle (or a quality Choice: its first / "Low" option): Theme.setLite
		               (live lite mode) + the touch particle caps
		reducedMotion  Toggle: UI.Motion.reduced (no shake / flashes / idle loops, fewer particles)
		colorblind     Choice Off / Deuteranopia / Protanopia / Tritanopia (or a Toggle =
		               Deuteranopia): Theme.setColorblind
		uiScale        Slider: Viewport.userScale (v / 100, or raw when max <= 5; 0.5..1.5,
		               and Viewport keeps the HUD on screen)
		damageNumbers  Toggle: FX.Particles.damageNumbers (FX.damageNumber)
		hitMarkers     Toggle: the HUD Crosshair's hit / kill markers
		killFeed       Toggle: the HUD KillFeed element
		notifications  Toggle: Popups.quiet (errors, warnings and purchases still show)
		skipHatch      Toggle: HatchReveal.skip + CaseSpinner.skip + SpinWheel.skip (their fast paths)
		showHud        Toggle: the whole HUD. While it's hidden, HUD_KEY (F1) or a small eye
		               button in the corner brings it back.
	Anything else only fires the event.

	Saving: changes are batched (2 s) and sent through the BloxUI_Action remote
	as the reserved action "BloxUI.Settings" with { [id] = value }. The server
	validates them against its own copy of the blueprint and writes the
	player attribute "BloxUI_Settings" (JSON), which is read here on build and
	whenever it changes. Without UI.Server the settings still work, they just
	aren't saved.

	Preview builds (the plugin) apply nothing, send nothing and play no music.
	Everything the builder changed is put back when the UI is destroyed,
	unless the game changed it again in between.
]]

local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Schema = require(script.Parent.Schema)

local peek = Fusion.peek

local Settings = {}

Settings.ATTRIBUTE = "BloxUI_Settings"
Settings.ACTION = "BloxUI.Settings"
Settings.REMOTE = "BloxUI_Action"
Settings.MAX_KEYS = 64 -- per message from a client
Settings.DEBOUNCE = 2 -- seconds of quiet before changes are sent
Settings.FOV_MIN = 50
Settings.FOV_MAX = 120
Settings.SENSITIVITY_MIN = 0.05
Settings.SENSITIVITY_MAX = 4
Settings.UI_SCALE_MIN = 0.5
Settings.UI_SCALE_MAX = 1.5
-- the key that shows / hides the HUD when the blueprint has a showHud setting
Settings.HUD_KEY = Enum.KeyCode.F1

Settings.builtin = Schema.builtinSetting

-- True when a lowDetail value asks for low detail: a Toggle that's on, or a
-- quality Choice on an option named Low / Potato / Performance / Minimal
-- (or, when the names don't say, its first option).
function Settings.lowDetail(def: any, value: any): boolean
	if def.type == "Choice" then
		if typeof(value) ~= "string" then
			return false
		end
		local key = string.lower(value)
		for _, word in { "low", "potato", "perf", "min", "fast" } do
			if string.find(key, word, 1, true) then
				return true
			end
		end
		for _, word in { "high", "ultra", "max", "med", "normal", "fancy", "quality", "epic" } do
			if string.find(key, word, 1, true) then
				return false
			end
		end
		return typeof(def.options) == "table" and def.options[1] == value and #def.options > 1
	end
	return value == true
end

-- The colourblind palette a colorblind value picks ("Off" for none).
function Settings.colorblindMode(def: any, value: any): string
	if def.type == "Toggle" then
		return if value == true then "Deuteranopia" else "Off"
	end
	return Schema.colorblindMode(value) or "Off"
end

--------------------------------------------------------------------------
-- Data (shared with UI.Server)
--------------------------------------------------------------------------

-- id -> every definition of that id in the blueprint (sections may repeat one).
function Settings.collect(bp: any): { [string]: { any } }
	local index: { [string]: { any } } = {}
	local function scan(sections: any)
		if typeof(sections) ~= "table" then
			return
		end
		for _, s in sections do
			if typeof(s) == "table" and s.kind == "Settings" and typeof(s.settings) == "table" then
				for _, def in s.settings do
					if typeof(def) == "table" and typeof(def.id) == "string" then
						local list = index[def.id]
						if list == nil then
							list = {}
							index[def.id] = list
						end
						table.insert(list, def)
					end
				end
			end
		end
	end
	for _, screen in (typeof(bp) == "table" and bp.screens) or {} do
		if typeof(screen) == "table" then
			scan(screen.sections)
			for _, tab in screen.tabs or {} do
				if typeof(tab) == "table" then
					scan(tab.sections)
				end
			end
		end
	end
	return index
end

local function finite(n: number): boolean
	return n == n and n ~= math.huge and n ~= -math.huge
end

-- True when `value` fits one of the definitions of an id: a boolean for a
-- Toggle, a number in min..max for a Slider, one of the options for a Choice.
function Settings.check(defs: { any }, value: any): boolean
	for _, def in defs do
		if def.type == "Toggle" then
			if typeof(value) == "boolean" then
				return true
			end
		elseif def.type == "Slider" then
			if typeof(value) == "number" and finite(value) and typeof(def.min) == "number" and typeof(def.max) == "number" and value >= def.min and value <= def.max then
				return true
			end
		elseif def.type == "Choice" then
			if typeof(value) == "string" and typeof(def.options) == "table" and table.find(def.options, value) then
				return true
			end
		end
	end
	return false
end

-- The valid entries of `data` (from a client or from storage) for `index`.
-- nil when `data` isn't a table or has more than `limit` keys.
function Settings.sanitize(index: { [string]: { any } }, data: any, limit: number?): ({ [string]: any }?, number)
	if typeof(data) ~= "table" then
		return nil, 0
	end
	local max = limit or Settings.MAX_KEYS
	local out = {}
	local count, dropped = 0, 0
	local ok = pcall(function()
		for key, value in next, data do
			count += 1
			if count > max then
				error("too many keys", 0)
			end
			local defs = if typeof(key) == "string" and #key <= 64 then index[key] else nil
			if defs and Settings.check(defs, value) then
				out[key] = value
			else
				dropped += 1
			end
		end
	end)
	if not ok then
		return nil, count
	end
	return out, dropped
end

function Settings.encode(values: { [string]: any }): string
	local ok, json = pcall(function()
		return HttpService:JSONEncode(values)
	end)
	return if ok and typeof(json) == "string" then json else "{}"
end

function Settings.decode(json: any): { [string]: any }?
	if typeof(json) ~= "string" or json == "" or #json > 200000 then
		return nil
	end
	local ok, data = pcall(function()
		return HttpService:JSONDecode(json)
	end)
	return if ok and typeof(data) == "table" then data else nil
end

-- Position of a slider value in its range, 0..1.
local function fraction(def: any, value: number): number
	local span = def.max - def.min
	if span <= 0 then
		return 1
	end
	return math.clamp((value - def.min) / span, 0, 1)
end

local function same(a: any, b: any): boolean
	if typeof(a) == "number" and typeof(b) == "number" then
		return math.abs(a - b) < 1e-3
	end
	return a == b
end

--------------------------------------------------------------------------
-- Effects (client)
--------------------------------------------------------------------------

-- Writes engine properties, remembering what was there first so it can be
-- put back later (unless something else changed it since).
local function newWriter()
	local touched: { [string]: { read: () -> any, set: (any) -> (), original: any, last: any } } = {}
	local writer = {}
	function writer.write(name: string, read: () -> any, set: (any) -> (), value: any)
		local entry = touched[name]
		if entry == nil then
			local ok, original = pcall(read)
			entry = { read = read, set = set, original = if ok then original else nil, last = nil }
			touched[name] = entry
		end
		entry.read, entry.set = read, set
		if pcall(set, value) then
			entry.last = value
		end
	end
	function writer.restore()
		for _, entry in touched do
			if entry.last ~= nil and entry.original ~= nil then
				local ok, now = pcall(entry.read)
				if ok and same(now, entry.last) then
					pcall(entry.set, entry.original)
				end
			end
		end
		table.clear(touched)
	end
	return writer
end

-- Hooks every built-in setting up to what it controls.
local function applyEffects(scope: any, ui: any, index: { [string]: { any } })
	local Sound = require(script.Parent.Parent.Util.Sound)
	local Theme = require(script.Parent.Parent.Theme)
	local Viewport = require(script.Parent.Parent.Util.Viewport)
	local Motion = require(script.Parent.Parent.FX.Motion)
	local Particles = require(script.Parent.Parent.FX.Particles)
	local Popups = require(script.Parent.Parent.Popups)
	local HatchReveal = require(script.Parent.Parent.Components.HatchReveal)
	local CaseSpinner = require(script.Parent.Parent.Components.CaseSpinner)
	local SpinWheel = require(script.Parent.Parent.Components.SpinWheel)
	local Lighting = game:GetService("Lighting")
	local UserInputService = game:GetService("UserInputService")
	local Workspace = game:GetService("Workspace")

	local writer = newWriter()
	table.insert(scope, writer.restore)

	-- a value held in a module field (Particles.damageNumbers = false ...)
	local function field(name: string, owner: any, key: string, value: any)
		writer.write(name, function()
			return owner[key]
		end, function(v)
			owner[key] = v
		end, value)
	end

	-- the particle caps before low detail lowered them
	local baseMax, baseBudget = Particles.MAX_ACTIVE, Particles.SPAWN_BUDGET
	local display = ui.display

	-- music: a Toggle mutes, a Slider sets the level; both may exist
	local musicBaseline = Sound.musicVolume
	local musicOn, musicLevel = true, nil
	local function applyMusic()
		local level = if musicOn == false then 0 else (musicLevel or musicBaseline)
		writer.write("music", function()
			return Sound.musicVolume
		end, function(v)
			Sound.setMusicVolume(v)
		end, level)
	end

	-- field of view: re-applied when the game swaps cameras
	local fov = nil
	local function applyFov()
		local camera = Workspace.CurrentCamera
		if fov == nil or camera == nil then
			return
		end
		writer.write("fov", function()
			return camera.FieldOfView
		end, function(v)
			camera.FieldOfView = v
		end, fov)
	end

	local effects: { [string]: (any, any) -> () } = {
		sfx = function(def, v)
			if def.type == "Toggle" then
				writer.write("sfx.enabled", function()
					return Sound.enabled
				end, function(on)
					Sound.enabled = on
				end, v == true)
			elseif def.type == "Slider" then
				writer.write("sfx.volume", function()
					return Sound.volume
				end, function(level)
					Sound.volume = level
				end, fraction(def, v))
			end
		end,
		music = function(def, v)
			if def.type == "Toggle" then
				musicOn = v == true
				applyMusic()
			elseif def.type == "Slider" then
				musicLevel = fraction(def, v)
				applyMusic()
			end
		end,
		fov = function(def, v)
			if def.type == "Slider" then
				fov = math.clamp(v, Settings.FOV_MIN, Settings.FOV_MAX)
				applyFov()
			end
		end,
		sensitivity = function(def, v)
			if def.type == "Slider" then
				local raw = if def.max <= 5 then v else v / 50
				writer.write("sensitivity", function()
					return UserInputService.MouseDeltaSensitivity
				end, function(s)
					UserInputService.MouseDeltaSensitivity = s
				end, math.clamp(raw, Settings.SENSITIVITY_MIN, Settings.SENSITIVITY_MAX))
			end
		end,
		shadows = function(def, v)
			if def.type == "Toggle" then
				writer.write("shadows", function()
					return Lighting.GlobalShadows
				end, function(on)
					Lighting.GlobalShadows = on
				end, v == true)
			end
		end,
		-- v1.5
		lowDetail = function(def, v)
			if def.type ~= "Toggle" and def.type ~= "Choice" then
				return
			end
			local on = Settings.lowDetail(def, v)
			writer.write("lite", function()
				return peek(Theme.liteMode)
			end, Theme.setLite, on)
			field("particles.max", Particles, "MAX_ACTIVE", if on then math.min(baseMax, Particles.TOUCH_MAX_ACTIVE) else baseMax)
			field("particles.budget", Particles, "SPAWN_BUDGET", if on then math.min(baseBudget, Particles.TOUCH_SPAWN_BUDGET) else baseBudget)
		end,
		reducedMotion = function(def, v)
			if def.type == "Toggle" then
				writer.write("reducedMotion", function()
					return Motion.reduced
				end, Motion.setReduced, v == true)
			end
		end,
		colorblind = function(def, v)
			if def.type == "Choice" or def.type == "Toggle" then
				writer.write("colorblind", Theme.getColorblind, Theme.setColorblind, Settings.colorblindMode(def, v))
			end
		end,
		uiScale = function(def, v)
			if def.type == "Slider" then
				local raw = if def.max <= 5 then v else v / 100
				writer.write("uiScale", function()
					return peek(Viewport.userScale)
				end, function(s)
					Viewport.userScale:set(s)
				end, math.clamp(raw, Settings.UI_SCALE_MIN, Settings.UI_SCALE_MAX))
			end
		end,
		damageNumbers = function(def, v)
			if def.type == "Toggle" then
				field("damageNumbers", Particles, "damageNumbers", v == true)
			end
		end,
		notifications = function(def, v)
			if def.type == "Toggle" then
				field("notifications", Popups, "quiet", v ~= true)
			end
		end,
		skipHatch = function(def, v)
			if def.type == "Toggle" then
				field("skipHatch", HatchReveal, "skip", v == true)
				field("skipCase", CaseSpinner, "skip", v == true)
				field("skipSpin", SpinWheel, "skip", v == true) -- v1.6 prize wheels
			end
		end,
		-- HUD switches live on the UI itself (Blueprint.Hud and build read them)
		hitMarkers = function(def, v)
			if def.type == "Toggle" then
				display.hitMarkers:set(v == true)
			end
		end,
		killFeed = function(def, v)
			if def.type == "Toggle" then
				display.killFeed:set(v == true)
			end
		end,
		showHud = function(def, v)
			if def.type == "Toggle" then
				display.hud:set(v == true)
			end
		end,
	}

	local ids = {}
	for id in index do
		table.insert(ids, id)
	end
	table.sort(ids)
	for _, id in ids do
		-- (a name that means the opposite of a built-in is never wired to it;
		-- Normalize has already turned those around)
		local builtin, inverted = Settings.builtin(id)
		local effect = if builtin and not inverted then effects[builtin] else nil
		local value = ui.settings[id]
		if effect and value then
			local def = index[id][1]
			table.insert(scope, scope:Observer(value):onBind(function()
				local ok, err = pcall(effect, def, peek(value))
				if not ok then
					warn(`[BloxUI] Blueprint: setting "{id}" could not be applied: {err}`)
				end
			end))
		end
	end

	pcall(function()
		table.insert(scope, Workspace:GetPropertyChangedSignal("CurrentCamera"):Connect(function()
			task.defer(applyFov)
		end))
	end)
end

--------------------------------------------------------------------------
-- Runtime (client)
--------------------------------------------------------------------------

export type AttachOptions = {
	preview: boolean,
	player: Player?,
}

-- Creates ui.settings[id] for every setting in the blueprint (saved value,
-- else the default), wires the built-in effects and saving, and adds
-- ui:setSetting / ui:getSetting / ui:saveSettings. Call before any screen is
-- built.
--
-- ui.display holds the switches the HUD reads (Fusion Values, all true until
-- a built-in setting turns one off): hud (showHud), killFeed, hitMarkers.
function Settings.attach(scope: any, ui: any, opts: AttachOptions)
	local index = Settings.collect(ui.blueprint)
	ui.display = {
		hud = scope:Value(true),
		killFeed = scope:Value(true),
		hitMarkers = scope:Value(true),
	}
	local player = opts.player
	local live = not opts.preview and player ~= nil
	local alive = true
	local pending: { [string]: any } = {}
	local token = 0

	local function saved(): { [string]: any }
		if not live then
			return {}
		end
		local data = Settings.decode((player :: Player):GetAttribute(Settings.ATTRIBUTE))
		return (Settings.sanitize(index, data, 4096)) or {}
	end

	local initial = saved()
	for id, defs in index do
		if ui.settings[id] == nil then
			local value = initial[id]
			if value == nil then
				value = defs[1].default
			end
			ui.settings[id] = scope:Value(value)
		end
	end

	local function send()
		if not live or next(pending) == nil then
			return
		end
		local payload = pending
		pending = {}
		local remote = ReplicatedStorage:FindFirstChild(Settings.REMOTE)
		if remote and remote:IsA("RemoteEvent") then
			pcall(function()
				(remote :: RemoteEvent):FireServer(Settings.ACTION, nil, payload)
			end)
		end
	end

	function ui.setSetting(_self: any, id: string, value: any): boolean
		local state = ui.settings[id]
		local defs = index[id]
		if state == nil or defs == nil or not Settings.check(defs, value) then
			return false
		end
		if peek(state) ~= value then
			state:set(value)
		end
		ui:emit(id, { id = id, payload = value })
		if live and alive then
			pending[id] = value
			token += 1
			local mine = token
			task.delay(Settings.DEBOUNCE, function()
				if mine == token and alive then
					send()
				end
			end)
		end
		return true
	end

	function ui.getSetting(_self: any, id: string): any
		local state = ui.settings[id]
		return if state then peek(state) else nil
	end

	function ui.saveSettings(_self: any)
		token += 1
		send()
	end

	if live then
		applyEffects(scope, ui, index)
		-- the server loads saved settings a moment after the player joins
		table.insert(scope, (player :: Player):GetAttributeChangedSignal(Settings.ATTRIBUTE):Connect(function()
			if not alive then
				return
			end
			for id, value in saved() do
				local state = ui.settings[id]
				if state and pending[id] == nil and peek(state) ~= value then
					state:set(value)
					ui:emit(id, { id = id, payload = value, source = "saved" })
				end
			end
		end))
		table.insert(scope, function()
			alive = false
			send()
		end)
	end
	return index
end

--------------------------------------------------------------------------
-- Music (client)
--------------------------------------------------------------------------

-- Plays blueprint.music: the lobby track while a MainMenu / Loading screen
-- is open, the gameplay track otherwise. `menusOpen` counts open menus.
function Settings.music(scope: any, ui: any, menusOpen: any, opts: AttachOptions)
	local music = ui.blueprint.music
	if opts.preview or opts.player == nil or typeof(music) ~= "table" then
		return
	end
	local Sound = require(script.Parent.Parent.Util.Sound)
	if typeof(Sound.playMusic) ~= "function" then
		return
	end
	local lobby, gameplay = music.lobby, music.gameplay
	local hasMenus = false
	for _, screen in ui.blueprint.screens do
		if screen.kind == "MainMenu" or screen.kind == "Loading" then
			hasMenus = true
		end
	end
	if not hasMenus and gameplay == nil then
		gameplay = lobby -- no start screen: the lobby track is the only music
	end

	-- music.muffle = false: windows no longer muffle the music
	if music.muffle == false and Sound.muffleEnabled ~= nil then
		local previousMuffle = Sound.muffleEnabled
		Sound.muffleEnabled = false
		table.insert(scope, function()
			Sound.muffleEnabled = previousMuffle
		end)
	end

	local alive = true
	local playing: string? = nil
	local stop: ((number?) -> ())? = nil
	local function update()
		if not alive then
			return
		end
		local want = if peek(menusOpen) > 0 then lobby else gameplay
		if want == playing then
			return
		end
		playing = want
		local previous = stop
		stop = nil
		if want then
			-- playMusic crossfades from the current track
			stop = Sound.playMusic(want, { Volume = music.volume, Fade = 1 })
		elseif previous then
			previous()
		end
	end
	table.insert(scope, scope:Observer(menusOpen):onChange(update))
	-- deferred: the loader opens the start screen right after build()
	task.defer(update)
	table.insert(scope, function()
		alive = false
		if stop then
			stop(0.5)
			stop = nil
		end
	end)
end

return Settings
]]></string>
            </Properties>
          </Item>
        </Item>
        <Item class="ModuleScript" referent="82">
          <Properties>
            <string name="Name">Components</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Every BloxUI component. Each one is a function(scope, props) -> Instance,
	so with UI.scope() you can write  scope:Button { ... }.
]]

local Layout = require(script.Layout)
local Controls = require(script.Controls)
local ScreenFX = require(script.ScreenFX)

return {
	-- basics
	Screen = require(script.Screen),
	Text = require(script.Text),
	Icon = require(script.Icon),
	Panel = require(script.Panel),
	Button = require(script.Button),
	Badge = require(script.Badge),
	Bubble = require(script.Bubble),
	KeyCap = require(script.KeyCap),

	-- layout
	Row = Layout.Row,
	Column = Layout.Column,
	Grid = Layout.Grid,
	Scroll = Layout.Scroll,

	-- menus & shops
	Window = require(script.Window),
	TabBar = require(script.TabBar),
	ProductCard = require(script.ProductCard),
	Currency = require(script.Currency),

	-- hud
	ProgressBar = require(script.ProgressBar),
	QuestTracker = require(script.QuestTracker),
	Hotbar = require(script.Hotbar),
	Crosshair = require(script.Crosshair),
	KeyHints = require(script.KeyHints),

	-- identity & input
	Logo = require(script.Logo),
	Banner = require(script.Banner),
	TextInput = require(script.TextInput),
	Segmented = require(script.Segmented),

	-- items, progression & competition
	ItemSlot = require(script.ItemSlot),
	ItemGrid = require(script.ItemGrid),
	ItemDetail = require(script.ItemDetail),
	UpgradeCard = require(script.UpgradeCard),
	QuestCard = require(script.QuestCard),
	DailyGrid = require(script.DailyGrid),
	PassTrack = require(script.PassTrack),
	Leaderboard = require(script.Leaderboard),
	Scoreboard = require(script.Scoreboard),
	StatRow = require(script.StatRow),
	Timer = require(script.Timer),
	TeamScore = require(script.TeamScore),
	LoadoutCard = require(script.LoadoutCard),

	-- combat & match
	BossBar = require(script.BossBar),
	Nameplate = require(script.Nameplate),
	RadialMenu = require(script.RadialMenu),
	MobileControls = require(script.MobileControls),

	-- social
	ProfileCard = require(script.ProfileCard),
	WorldCard = require(script.WorldCard),
	PartyPanel = require(script.PartyPanel),
	KeybindRow = require(script.KeybindRow),

	-- retention & monetisation (v1.6)
	SpinWheel = require(script.SpinWheel),
	GiftGrid = require(script.GiftGrid),
	IndexBook = require(script.IndexBook),
	AchievementGrid = require(script.AchievementGrid),
	OfferCard = require(script.OfferCard),

	-- sub-genre readouts (v1.7: horror flavours and more)
	Counter = require(script.Counter),
	Meter = require(script.Meter),
	TeamStatus = require(script.TeamStatus),
	CameraPanel = require(script.CameraPanel),
	Camcorder = require(script.Camcorder),
	Compass = require(script.Compass),
	Static = require(script.Static),

	-- HUD kit elements (each has Style + Kit; see HudKits.luau)
	HudCurrencies = require(script.HudCurrencies),
	HudHealth = require(script.HudHealth),
	HudXP = require(script.HudXP),
	HudBoosts = require(script.HudBoosts),
	HudObjective = require(script.HudObjective),
	HudMenu = require(script.HudMenu),
	KillFeed = require(script.KillFeed),
	Minimap = require(script.Minimap),
	Clicker = require(script.Clicker),

	-- settings
	Toggle = Controls.Toggle,
	Slider = Controls.Slider,
	SettingRow = Controls.SettingRow,

	-- screen overlays
	Vignette = ScreenFX.Vignette,
	Heartbeat = ScreenFX.Heartbeat,
}
]]></string>
          </Properties>
          <Item class="ModuleScript" referent="83">
            <Properties>
              <string name="Name">AchievementGrid</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	AchievementGrid: badge-style achievement tiles. Locked ones are a grey
	medal with a lock, ones in progress show a bar ("34 / 100"), finished
	ones glow gold with a Claim button, and claimed ones keep a check and the
	date they were completed. Every tile carries its points.

		scope:AchievementGrid {
			Achievements = {
				{ id = "hatch100", title = "Egg Enthusiast", description = "Hatch 100 eggs", icon = "egg",
				  goal = 100, points = 25, reward = { icon = "gem", amount = "50" } },
				...
			},
			Progress = { hatch100 = progressState, ... },   -- state per id (a number)
			Done = { hatch100 = doneState, ... },           -- state per id: unix time claimed, or nil
			OnClaim = function(id, tile) end,
			RewardColor = function(reward) return color end,   -- optional: the reward icon's colour (a currency's)
			Columns = 2,
			TileSize = Vector2.new(440, 132),
			Gap = 16,
		}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Button = require(script.Parent.Button)

local Children = Fusion.Children
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)
local MONTHS = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }

local AchievementGrid = {}

-- 1726000000 -> "10 Sep 2024" (UTC, so every player sees the same day)
function AchievementGrid.date(unix: number): string
	local ok, t = pcall(os.date, "!*t", math.floor(unix))
	if not ok or typeof(t) ~= "table" then
		return ""
	end
	return `{t.day} {MONTHS[t.month] or ""} {t.year}`
end

local function AchievementGridComponent(scope: any, props: { [any]: any }): Frame
	local list = props.Achievements or {}
	local columns = math.max(1, props.Columns or 2)
	local tileSize: Vector2 = props.TileSize or Vector2.new(440, 132)
	local gap = props.Gap or 16
	local tw, th = tileSize.X, tileSize.Y
	local medal = math.floor(th * 0.66)

	local tiles = {}
	for index, a in list do
		local progress = (props.Progress or {})[a.id] or scope:Value(a.progress or 0)
		local done = (props.Done or {})[a.id] or scope:Value(nil)
		local goal = math.max(1, a.goal or 1)
		local value = scope:Computed(function(use)
			return math.clamp(tonumber(use(progress)) or 0, 0, goal)
		end)
		-- locked (nothing yet) | progress | ready (finished, not claimed) | claimed
		local state = scope:Computed(function(use)
			if use(done) ~= nil then
				return "claimed"
			elseif use(value) >= goal then
				return "ready"
			elseif use(value) > 0 then
				return "progress"
			end
			return "locked"
		end)
		local tone = scope:Computed(function(use)
			local theme = use(Theme.current)
			local s = use(state)
			if s == "claimed" or s == "ready" then
				return theme.Tone.Yellow
			elseif s == "progress" then
				return theme.Tone.Blue
			end
			return theme.Tone.Gray
		end)
		local tile
		local claimButton = Button(scope, {
			Name = "Claim",
			Text = "Claim",
			Icon = if a.reward then a.reward.icon else "check",
			Tone = "Green",
			Size = UDim2.fromOffset(150, 48),
			TextSize = 22,
			Pulse = true,
			Visible = scope:Computed(function(use)
				return use(state) == "ready"
			end),
			AnchorPoint = Vector2.new(1, 1),
			Position = UDim2.new(1, -14, 1, -14),
			ZIndex = 6,
			OnClick = function()
				if peek(state) == "ready" and props.OnClaim then
					props.OnClaim(a.id, tile)
				end
			end,
		})
		tile = scope:New "Frame" {
			Name = a.id,
			LayoutOrder = index,
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = Kit.themed(scope, function(theme)
				return math.clamp(theme.Alpha.Surface - 0.1, 0, 1)
			end),
			ZIndex = 2,
			[Children] = {
				Kit.corner(scope, "M"),
				Kit.stroke(scope, {
					Thickness = scope:Computed(function(use)
						return if use(state) == "ready" then 3 else 2
					end),
					Color = scope:Computed(function(use)
						local s = use(state)
						local t = use(tone)
						return if s == "ready" or s == "claimed" then t.Top else use(Theme.current).Color.Outline
					end),
					Transparency = scope:Computed(function(use)
						return if use(state) == "locked" then 0.4 else 0
					end),
				}),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = ColorSequence.new(WHITE, Color3.fromRGB(200, 205, 220)),
				}),
				-- the medal
				scope:New "Frame" {
					Name = "Medal",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 16, 0.5, 0),
					Size = UDim2.fromOffset(medal, medal),
					BackgroundColor3 = WHITE,
					ZIndex = 3,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.gradient(scope, {
							Rotation = 90,
							Color = scope:Computed(function(use)
								local t = use(tone)
								return ColorSequence.new({
									ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.2)),
									ColorSequenceKeypoint.new(0.55, t.Bottom),
									ColorSequenceKeypoint.new(1, t.Edge),
								})
							end),
						}),
						Kit.stroke(scope, {
							Thickness = 4,
							Color = scope:Computed(function(use)
								return use(tone).Edge
							end),
						}),
						scope:New "Frame" {
							Name = "Ring",
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Size = UDim2.new(1, -14, 1, -14),
							BackgroundTransparency = 1,
							ZIndex = 4,
							[Children] = {
								Kit.corner(scope, "Full"),
								Kit.stroke(scope, {
									Thickness = 2,
									Color = WHITE,
									Transparency = 0.55,
								}),
							},
						},
						Icon(scope, {
							Icon = a.icon or "trophy",
							Size = math.floor(medal * 0.5),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = WHITE,
							Gradient = true,
							Stroke = 2.5,
							StrokeColor = scope:Computed(function(use)
								local t = use(tone)
								return t.Stroke or t.Edge
							end),
							Shadow = true,
							Transparency = scope:Computed(function(use)
								return if use(state) == "locked" then 0.45 else 0
							end),
							ZIndex = 5,
						}),
						-- a lock (not yet started) or a check (claimed): the state never
						-- relies on colour alone
						scope:New "Frame" {
							Name = "Corner",
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.new(1, -8, 1, -8),
							Size = UDim2.fromOffset(30, 30),
							BackgroundColor3 = scope:Computed(function(use)
								local theme = use(Theme.current)
								return if use(state) == "claimed" then theme.Tone.Green.Bottom else theme.Tone.Dark.Bottom
							end),
							Visible = scope:Computed(function(use)
								local s = use(state)
								return s == "claimed" or s == "locked"
							end),
							ZIndex = 6,
							[Children] = {
								Kit.corner(scope, "Full"),
								Kit.stroke(scope, {
									Thickness = 2.5,
									Color = Kit.themed(scope, function(theme)
										return theme.Color.Light
									end),
								}),
								Icon(scope, {
									Icon = scope:Computed(function(use)
										return if use(state) == "claimed" then "check" else "lock"
									end),
									Size = 18,
									AnchorPoint = Vector2.new(0.5, 0.5),
									Position = UDim2.fromScale(0.5, 0.5),
									Color = WHITE,
									ZIndex = 7,
								}),
							},
						},
					},
				},
				Text(scope, {
					Name = "Title",
					Text = a.title or "Achievement",
					TextSize = 24,
					Font = "Title",
					XAlign = "Left",
					Position = UDim2.fromOffset(medal + 32, 14),
					Size = UDim2.new(1, -(medal + 32) - 96, 0, 28),
					Truncate = true,
					ZIndex = 4,
				}),
				Text(scope, {
					Name = "Description",
					Text = a.description or "",
					TextSize = 18,
					Font = "Body",
					XAlign = "Left",
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					Stroke = false,
					Position = UDim2.fromOffset(medal + 32, 44),
					Size = UDim2.new(1, -(medal + 32) - 16, 0, 22),
					Truncate = true,
					ZIndex = 4,
				}),
				-- points, top right
				scope:New "Frame" {
					Name = "Points",
					AnchorPoint = Vector2.new(1, 0),
					Position = UDim2.new(1, -12, 0, 12),
					Size = UDim2.fromOffset(0, 28),
					AutomaticSize = Enum.AutomaticSize.X,
					BackgroundColor3 = scope:Computed(function(use)
						return use(tone).Deep
					end),
					ZIndex = 4,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.padding(scope, { 0, 10 }),
						Text(scope, {
							Text = `{a.points or 0} PTS`,
							TextSize = 17,
							Font = "Display",
							Stroke = 1.5,
							Size = UDim2.fromScale(0, 1),
							AutoSize = "X",
							ZIndex = 5,
						}),
					},
				},
				-- progress bar (locked / in progress) or the completion date
				scope:New "Frame" {
					Name = "Bar",
					Position = UDim2.new(0, medal + 32, 1, -38),
					Size = UDim2.new(1, -(medal + 32) - 16, 0, 24),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Track
					end),
					Visible = scope:Computed(function(use)
						local s = use(state)
						return s == "locked" or s == "progress"
					end),
					ZIndex = 4,
					[Children] = {
						Kit.corner(scope, "Full"),
						scope:New "Frame" {
							Name = "Fill",
							Size = scope:Computed(function(use)
								return UDim2.fromScale(use(value) / goal, 1)
							end),
							BackgroundColor3 = WHITE,
							ZIndex = 5,
							[Children] = {
								Kit.corner(scope, "Full"),
								Kit.gradient(scope, {
									Rotation = 90,
									Color = Kit.themed(scope, function(theme)
										return ColorSequence.new(theme.Tone.Blue.Top, theme.Tone.Blue.Bottom)
									end),
								}),
							},
						},
						Text(scope, {
							Name = "Count",
							Text = scope:Computed(function(use)
								return `{Format.abbrev(use(value))} / {Format.abbrev(goal)}`
							end),
							TextSize = 16,
							Font = "Display",
							Stroke = 1.5,
							Size = UDim2.fromScale(1, 1),
							ZIndex = 6,
						}),
					},
				},
				Text(scope, {
					Name = "Completed",
					Text = scope:Computed(function(use)
						local at = use(done)
						return if typeof(at) == "number" then `Completed {AchievementGrid.date(at)}` else "Completed"
					end),
					TextSize = 18,
					Font = "Heavy",
					XAlign = "Left",
					Color = Kit.themed(scope, function(theme)
						return theme.Tone.Green.Top
					end),
					Position = UDim2.new(0, medal + 32, 1, -38),
					Size = UDim2.new(1, -(medal + 32) - 16, 0, 24),
					Visible = scope:Computed(function(use)
						return use(state) == "claimed"
					end),
					ZIndex = 4,
				}),
				-- the reward, beside the Claim button
				if a.reward
					then scope:New "Frame" {
						Name = "Reward",
						BackgroundTransparency = 1,
						AnchorPoint = Vector2.new(1, 1),
						Position = UDim2.new(1, -176, 1, -20),
						Size = UDim2.fromOffset(120, 34),
						Visible = scope:Computed(function(use)
							return use(state) == "ready"
						end),
						ZIndex = 5,
						[Children] = {
							Kit.list(scope, { Direction = "X", Padding = 4, HAlign = "Right", VAlign = "Center" }),
							Icon(scope, { Icon = a.reward.icon or "gift", Size = 28, Gradient = true, Color = (props.RewardColor and props.RewardColor(a.reward)) or Kit.themed(scope, function(theme)
								return theme.Color.Accent
							end), LayoutOrder = 1, ZIndex = 5 }),
							Text(scope, { Text = if a.reward.amount then `x{a.reward.amount}` else "", Case = "Normal", TextSize = 20, Font = "Display", Stroke = 2, LayoutOrder = 2, ZIndex = 5 }),
						},
					}
					else nil,
				claimButton,
			},
		}
		table.insert(tiles, tile)
	end

	local rows = math.max(1, math.ceil(#list / columns))
	local width = columns * tw + (columns - 1) * gap
	local height = rows * th + (rows - 1) * gap
	-- placed by hand, so a claim's burst starts on its own medal
	for index, tile in tiles do
		tile.Size = UDim2.fromOffset(tw, th)
		tile.Position = UDim2.fromOffset(((index - 1) % columns) * (tw + gap), ((index - 1) // columns) * (th + gap))
	end
	local root = Kit.root(props, { Name = "AchievementGrid" })
	root.Size = props.Size or UDim2.fromOffset(width, height)
	root.BackgroundTransparency = 1
	root[Children] = { tiles, props[Children] }
	return scope:New "Frame"(root) :: Frame
end

-- The claimed tile shines once its claim went through.
function AchievementGrid.celebrate(tile: GuiObject?)
	if tile == nil then
		return
	end
	local theme = Theme.get()
	Sound.play("LevelUp")
	pcall(function()
		local medal = tile:FindFirstChild("Medal") or tile
		Particles.burst(medal, { Count = 20, Shape = "sparkle", Colors = { theme.Tone.Yellow.Top, WHITE, theme.Color.Accent }, Size = 24, Speed = 440 })
		Particles.ring(medal, { Color = theme.Tone.Yellow.Top, Time = 0.35 })
		Motion.pop(medal :: GuiObject, 0.22)
	end)
end

AchievementGrid.new = AchievementGridComponent
setmetatable(AchievementGrid, {
	__call = function(_, scope: any, props: { [any]: any })
		return AchievementGridComponent(scope, props)
	end,
})

return AchievementGrid
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="84">
            <Properties>
              <string name="Name">Badge</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Badge: a small notification bubble ("3", "!", "NEW"). Hides itself when
	the value is 0, nil, false or "". Pops whenever the value changes.

	scope:Badge {
		Value = unreadCount,      -- number, text or state
		Tone = "Red",
		Position = UDim2.new(1, -6, 0, 4),   -- anchored at its centre
	}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Text = require(script.Parent.Text)

local Children = Fusion.Children

return function(scope: Fusion.Scope<any>, props: { [any]: any }): Frame
	local tone = Kit.tone(scope, props.Tone, "Red")

	local label = scope:Computed(function(use)
		local value = use(props.Value)
		if value == nil or value == false or value == 0 or value == "" then
			return ""
		elseif value == true then
			return "!"
		elseif typeof(value) == "number" and value > 99 then
			return "99+"
		end
		return tostring(value)
	end)

	local popGoal = scope:Value(1)
	local pop = scope:Spring(popGoal, 30, 0.35)
	Kit.watch(scope, label, function(text)
		if text ~= "" then
			pop:addVelocity(9)
		end
	end)

	local root = Kit.root(props, { Name = "Badge", ZIndex = 10 })
	root.AnchorPoint = Vector2.new(0.5, 0.5)
	root.Size = UDim2.fromOffset(0, props.Height or 28)
	root.AutomaticSize = Enum.AutomaticSize.X
	root.BackgroundColor3 = Color3.new(1, 1, 1)
	root.Visible = scope:Computed(function(use)
		return use(label) ~= ""
	end)
	root[Children] = {
		Kit.corner(scope, "Full"),
		Kit.gradient(scope, {
			Rotation = 90,
			Color = scope:Computed(function(use)
				local t = use(tone)
				return ColorSequence.new(t.Top, t.Bottom)
			end),
		}),
		Kit.stroke(scope, { Thickness = 2.5, Color = scope:Computed(function(use)
			return use(Theme.current).Color.Light
		end) }),
		Kit.padding(scope, { 0, 8 }),
		Kit.list(scope, { Direction = "X", HAlign = "Center", VAlign = "Center", Padding = 0 }),
		scope:New "UISizeConstraint" {
			MinSize = Vector2.new(props.Height or 28, 0),
		},
		scope:New "UIScale" { Scale = pop },
		Text(scope, {
			Text = label,
			TextSize = math.floor((props.Height or 28) * 0.72),
			Font = "Display",
			Size = UDim2.fromScale(0, 1),
			AutoSize = "X",
			Color = scope:Computed(function(use)
				return use(tone).Text
			end),
			StrokeColor = scope:Computed(function(use)
				local t = use(tone)
				return t.Stroke or t.Edge
			end),
			Stroke = 1.5,
			Case = "Normal",
			ZIndex = (props.ZIndex or 10) + 1,
		}),
	}
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="85">
            <Properties>
              <string name="Name">Banner</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Banner: a featured-offer / event hero card. Gradient art with a big glowing
	icon and slow sun rays, a bold title, a subtitle, a call-to-action button
	and an optional live countdown.

	scope:Banner {
		Title = "EASTER EVENT",
		Subtitle = "Hatch the limited Golden Bunny!",
		Icon = "egg",
		Tone = "Purple",               -- card colour
		ButtonText = "Play now",       -- shows the CTA button
		ButtonTone = "Green",          -- optional (default: Green, or Yellow on green cards)
		OnClick = function() end,      -- CTA and card click
		EndsAt = os.time() + 86400,    -- optional countdown (number or state)
		Tag = "LIMITED",               -- optional chip above the title
		Size = UDim2.fromOffset(620, 300),
	}
]]

local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Button = require(script.Parent.Button)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local RAY = "rbxasset://textures/ui/Emotes/Large/SelectedGradient.png"
local GLOW = "rbxasset://textures/ui/InGameMenu/BackgroundGlow.png"

-- "2d 04h", "4h 13m", "12:45"
local function formatLeft(seconds: number): string
	seconds = math.max(0, math.floor(seconds))
	local days = seconds // 86400
	local hours = (seconds % 86400) // 3600
	local minutes = (seconds % 3600) // 60
	local secs = seconds % 60
	if days > 0 then
		return string.format("%dd %02dh", days, hours)
	elseif hours > 0 then
		return string.format("%dh %02dm", hours, minutes)
	end
	return string.format("%02d:%02d", minutes, secs)
end

-- Soft wedges fanning out from the centre of a square frame.
local function rays(scope: Scope, props: { [string]: any }): Frame
	local count = props.Count or 12
	local diameter = props.Size
	local length = diameter / 2
	local width = 2 * length * math.tan(math.rad(180 / count * (props.Duty or 0.5)))
	local children = {}
	for i = 1, count do
		local angle = (i - 1) / count * 360
		local rad = math.rad(angle)
		children[i] = scope:New "ImageLabel" {
			Name = "Ray",
			BackgroundTransparency = 1,
			Image = RAY,
			ImageColor3 = props.Color,
			ImageTransparency = props.Transparency or 0,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5, math.cos(rad) * length / 2, 0.5, math.sin(rad) * length / 2),
			Size = UDim2.fromOffset(length, width),
			Rotation = angle,
			ZIndex = props.ZIndex or 1,
		}
	end
	return scope:New "Frame" {
		Name = props.Name or "Rays",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position or UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(diameter, diameter),
		ZIndex = props.ZIndex or 1,
		[Children] = children,
	}
end

local function glow(scope: Scope, props: { [string]: any }): Frame
	local layers = {}
	for i = 1, props.Layers or 3 do
		layers[i] = scope:New "ImageLabel" {
			Name = "Glow" .. i,
			BackgroundTransparency = 1,
			Image = GLOW,
			ImageColor3 = props.Color,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromScale(1 - (i - 1) * 0.2, 1 - (i - 1) * 0.2),
			ZIndex = props.ZIndex or 1,
		}
	end
	return scope:New "Frame" {
		Name = "Glow",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position,
		Size = props.Size,
		ZIndex = props.ZIndex or 1,
		[Children] = layers,
	}
end

return function(scope: Scope, props: { [any]: any }): TextButton
	local size = peek(props.Size) or UDim2.fromOffset(620, 300)
	if typeof(size) == "Vector2" then
		size = UDim2.fromOffset(size.X, size.Y)
	end
	local height = if size.Y.Offset > 0 then size.Y.Offset else 300
	local k = height / 300 -- type scale factor
	local tone = Kit.tone(scope, props.Tone, "Purple")
	local hovering = scope:Value(false)

	local alive = true
	table.insert(scope, function()
		alive = false
	end)

	local scale = Kit.spring(scope, scope:Computed(function(use)
		return if use(hovering) then 1.025 else 1
	end), 0.9)
	local tiltGoal = scope:Value(0)
	local tilt = scope:Spring(tiltGoal, 20, 0.45)

	local outline = scope:Computed(function(use)
		local t = use(tone)
		return t.Stroke or Color.darken(t.Edge, 0.35)
	end)

	-- countdown
	local now = scope:Value(os.time())
	local hasTimer = props.EndsAt ~= nil
	if hasTimer then
		task.spawn(function()
			while alive do
				task.wait(1)
				if alive then
					now:set(os.time())
				end
			end
		end)
	end
	local timerText = scope:Computed(function(use)
		local ends = use(props.EndsAt)
		if typeof(ends) ~= "number" then
			return ""
		end
		local left = ends - use(now)
		return if left <= 0 then "ENDED" else "ENDS IN " .. formatLeft(left)
	end)

	local buttonTone = props.ButtonTone or scope:Computed(function(use)
		return if use(props.Tone) == "Green" then "Yellow" else "Green"
	end)

	local function activate()
		if props.OnClick then
			props.OnClick()
		end
	end

	-- art ----------------------------------------------------------------

	-- the hero art lives in the right ~40% so it never runs under the copy
	local width = if size.X.Offset > 0 then size.X.Offset else height * 2
	local iconSize = math.floor(math.min(height * 0.64, width * 0.32))
	local artX = -math.floor(math.min(height * 0.46, width * 0.21))
	local raysFrame = rays(scope, {
		Size = math.floor(height * 1.9),
		Count = 14,
		Duty = 0.55,
		Position = UDim2.new(1, artX, 0.5, 0),
		Color = scope:Computed(function(use)
			return Color.lighten(use(tone).Top, 0.5)
		end),
		Transparency = 0.55,
		ZIndex = 3,
	})

	local stripes = {}
	for i = 1, 4 do
		stripes[i] = scope:New "Frame" {
			Name = "Stripe",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.08 + i * 0.13, 0, 0.5, 0),
			Size = UDim2.new(0, math.floor(height * (0.06 + (i % 2) * 0.05)), 2, 0),
			Rotation = 24,
			BackgroundColor3 = Color3.new(1, 1, 1),
			BackgroundTransparency = 0.9,
			BorderSizePixel = 0,
			ZIndex = 2,
		}
	end

	local art = scope:New "CanvasGroup" {
		Name = "Art",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Color3.new(1, 1, 1),
		ZIndex = 1,
		[Children] = {
			Kit.corner(scope, props.Radius or "L"),
			Kit.gradient(scope, {
				Rotation = 35,
				Color = scope:Computed(function(use)
					local t = use(tone)
					return ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.1)),
						ColorSequenceKeypoint.new(0.5, t.Bottom),
						ColorSequenceKeypoint.new(1, t.Deep),
					})
				end),
			}),
			stripes,
			raysFrame,
			glow(scope, {
				Position = UDim2.new(1, artX, 0.5, 0),
				Size = UDim2.fromOffset(height * 1.3, height * 1.3),
				Color = scope:Computed(function(use)
					return Color.lighten(use(tone).Top, 0.6)
				end),
				Layers = 3,
				ZIndex = 4,
			}),
			Icon(scope, {
				Name = "Hero",
				Icon = props.Icon or "star",
				Size = iconSize,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(1, artX, 0.53, 0),
				Rotation = props.IconRotation or -10,
				Color = props.IconColor or scope:Computed(function(use)
					return Color.lighten(use(tone).Top, 0.35)
				end),
				StrokeColor = outline,
				Stroke = math.max(3, math.floor(5 * k)),
				Gradient = true,
				Shadow = true,
				ZIndex = 5,
			}),
			-- darken the text side so white type always reads
			scope:New "Frame" {
				Name = "Scrim",
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = scope:Computed(function(use)
					return use(tone).Deep
				end),
				ZIndex = 6,
				[Children] = Kit.gradient(scope, {
					Rotation = 0,
					Transparency = NumberSequence.new({
						NumberSequenceKeypoint.new(0, 0.35),
						NumberSequenceKeypoint.new(0.5, 0.75),
						NumberSequenceKeypoint.new(0.75, 1),
						NumberSequenceKeypoint.new(1, 1),
					}),
				}),
			},
			scope:New "Frame" {
				Name = "Gloss",
				Size = UDim2.fromScale(1, 0.45),
				BackgroundColor3 = Color3.new(1, 1, 1),
				ZIndex = 7,
				[Children] = Kit.gradient(scope, {
					Rotation = 90,
					Transparency = NumberSequence.new(0.82, 1),
				}),
			},
		},
	}

	-- text -----------------------------------------------------------------

	local titleSize = math.max(30, math.floor(48 * k))
	-- chips stay legible on short banners (a 180 px strip is k = 0.6)
	local chipHeight = math.max(30, math.floor(34 * k))
	local chipText = math.max(16, math.floor(19 * k))
	local chips: { any } = {}
	if props.Tag then
		table.insert(chips, scope:New "Frame" {
			Name = "Tag",
			Size = UDim2.fromOffset(0, chipHeight),
			AutomaticSize = Enum.AutomaticSize.X,
			BackgroundColor3 = Color3.new(1, 1, 1),
			LayoutOrder = 1,
			ZIndex = 12,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = scope:Computed(function(use)
						local red = Theme.tone(use(Theme.current), "Red")
						return ColorSequence.new(red.Top, red.Bottom)
					end),
				}),
				scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
					Color = scope:Computed(function(use)
						local red = Theme.tone(use(Theme.current), "Red")
						return red.Stroke or red.Edge
					end),
					Thickness = 2,
				},
				Kit.padding(scope, { 0, math.floor(14 * k) }),
				Text(scope, {
					Text = props.Tag,
					TextSize = chipText + 1,
					Font = "Heavy",
					Size = UDim2.fromScale(0, 1),
					AutoSize = "X",
					StrokeColor = scope:Computed(function(use)
						local red = Theme.tone(use(Theme.current), "Red")
						return red.Stroke or red.Edge
					end),
					Stroke = 1.5,
					ZIndex = 13,
				}),
			},
		})
	end
	if hasTimer then
		table.insert(chips, scope:New "Frame" {
			Name = "Timer",
			Size = UDim2.fromOffset(0, chipHeight),
			AutomaticSize = Enum.AutomaticSize.X,
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = 0.35,
			LayoutOrder = 2,
			ZIndex = 12,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.padding(scope, { 0, math.floor(14 * k), 0, math.floor(8 * k) }),
				Kit.list(scope, { Direction = "X", Padding = math.floor(6 * k), VAlign = "Center" }),
				Icon(scope, {
					Icon = "timer",
					Size = math.floor(chipHeight * 0.7),
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Accent
					end),
					Stroke = false,
					LayoutOrder = 1,
					ZIndex = 13,
				}),
				Text(scope, {
					Name = "Countdown",
					Text = timerText,
					TextSize = chipText,
					Font = "Heavy",
					Stroke = false,
					LayoutOrder = 2,
					ZIndex = 13,
				}),
			},
		})
	end

	local column = scope:New "Frame" {
		Name = "Copy",
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(math.floor(28 * k), math.floor(24 * k)),
		Size = UDim2.new(0.58, 0, 1, -math.floor(24 * k)),
		ZIndex = 10,
		[Children] = {
			Kit.list(scope, { Direction = "Y", Padding = math.floor(6 * k) }),
			if #chips > 0
				then scope:New "Frame" {
					Name = "Chips",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, 0, 0, chipHeight + 6),
					LayoutOrder = 1,
					ZIndex = 11,
					[Children] = {
						Kit.list(scope, { Direction = "X", Padding = math.floor(8 * k), VAlign = "Top" }),
						chips,
					},
				}
				else nil,
			Text(scope, {
				Name = "Title",
				Text = props.Title or "",
				TextSize = titleSize,
				Font = "Title",
				XAlign = "Left",
				Wrapped = true,
				LineHeight = 0.92,
				Size = UDim2.fromScale(1, 0),
				AutoSize = "Y",
				Stroke = math.max(2, math.floor(4 * k)),
				StrokeColor = outline,
				LayoutOrder = 2,
				ZIndex = 11,
			}),
			-- two lines at most; longer text ends in "..."
			if props.Subtitle
				then Text(scope, {
					Name = "Subtitle",
					Text = props.Subtitle,
					TextSize = math.max(18, math.floor(22 * k)),
					Font = "Body",
					XAlign = "Left",
					YAlign = "Top",
					Wrapped = true,
					Truncate = true,
					Size = UDim2.new(1, -math.floor(12 * k), 0, math.floor(math.max(18, 22 * k) * 2.1)),
					Stroke = 1.5,
					StrokeColor = outline,
					LayoutOrder = 3,
					ZIndex = 11,
				})
				else nil,
		},
	}

	local cta = if props.ButtonText
		then Button(scope, {
			Name = "Action",
			Text = props.ButtonText,
			Icon = props.ButtonIcon,
			Tone = buttonTone,
			Primary = true, -- the banner's call to action is never a quiet button
			Size = UDim2.fromOffset(math.floor(220 * k), math.floor(66 * k)),
			TextSize = math.floor(30 * k),
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, math.floor(28 * k), 1, -math.floor(26 * k)),
			Shine = true,
			OnClick = activate,
			ZIndex = 20,
		})
		else nil

	local root = Kit.root(props, { Name = "Banner" })
	root.Size = size
	root.BackgroundTransparency = 1
	root.Text = ""
	root.AutoButtonColor = false
	root.Selectable = false
	root.Rotation = tilt
	root[OnEvent "MouseEnter"] = function()
		hovering:set(true)
		tilt:addVelocity(if math.random() < 0.5 then 8 else -8)
	end
	root[OnEvent "MouseLeave"] = function()
		hovering:set(false)
	end
	root[OnEvent "Activated"] = function()
		if props.OnClick then
			Sound.play("Click")
			activate()
		end
	end
	root[Children] = {
		scope:New "UIScale" { Scale = scale },
		Kit.shadow(scope, { Spread = 22, Offset = Vector2.new(0, 10) }),
		art,
		scope:New "Frame" {
			Name = "Rim",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			ZIndex = 8,
			[Children] = {
				Kit.corner(scope, props.Radius or "L"),
				scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
					LineJoinMode = Enum.LineJoinMode.Round,
					Color = outline,
					Thickness = math.max(2, 3 * k),
				},
			},
		},
		scope:New "Frame" {
			Name = "InnerRim",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.new(1, -6, 1, -6),
			ZIndex = 8,
			[Children] = {
				Kit.corner(scope, props.Radius or "L"),
				scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
					Color = Color3.new(1, 1, 1),
					Thickness = 1.5,
					Transparency = 0.7,
				},
			},
		},
		column,
		cta,
		props[Children],
	}

	local card = scope:New "TextButton" (root) :: TextButton

	-- slow sunburst behind the hero icon
	local spin = TweenService:Create(
		raysFrame,
		TweenInfo.new(24, Enum.EasingStyle.Linear, Enum.EasingDirection.In, -1),
		{ Rotation = 360 }
	)
	spin:Play()
	local untrackSpin = Motion.track(spin, raysFrame)
	table.insert(scope, function()
		untrackSpin()
		spin:Cancel()
	end)

	return card
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="86">
            <Properties>
              <string name="Name">BossBar</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	BossBar: a wide top-centre boss health bar. A name plate with an icon
	medallion, a thick glossy bar with a white "ghost" trail that drains after
	each hit, phase ticks with diamond markers, a hit flash + shake, a pulsing
	glow at low health and a slide-in entrance where the bar fills up.

	local bossHealth = scope:Value(20000)
	scope:BossBar {
		Name = "Frost King",
		Value = bossHealth,          -- number or state
		Max = 20000,                 -- number or state
		Tone = "Red",                -- bar colour (any theme tone)
		Icon = "crown",              -- medallion icon
		Title = "World Boss",        -- tag next to the name (default: "PHASE n" when Phases is set)
		Phases = 3,                  -- equal phases, or thresholds like { 0.66, 0.33 }
		Low = 0.25,                  -- pulse below this fraction (false = off)
		Label = "Both",              -- "Both" (12,450 / 20,000) | "Value" | "Percent" | false | function(value, max)
		Shown = true,                -- bool or state: slides in / out
		Width = 900,
		DamageNumbers = true,        -- "-1,250" pops off the bar on hits (merged within 0.3 s)
		CritFraction = 0.08,         -- a hit this big (of Max) shows a bigger gold number
		OnPhase = function(phase) end,
		Kit = "Retro",               -- optional HudKits name / tokens: the kit's name and number type
	}

	It anchors itself top-centre, under the Roblox top bar. Pass Position /
	AnchorPoint to place it elsewhere.
]]

local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local HudKits = require(script.Parent.HudKits)

local Children = Fusion.Children
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)

-- Soft white radial glow tinted `Color`. The texture is faint, so it can be
-- stacked `Layers` times for a stronger bloom.
local GLOW = (Assets :: any).Glow or "rbxasset://textures/ui/InGameMenu/BackgroundGlow.png"
local function glow(scope: any, props: { [string]: any }): ImageLabel
	local extra = {}
	for _ = 2, props.Layers or 1 do
		table.insert(extra, scope:New "ImageLabel" {
			Name = "Layer",
			BackgroundTransparency = 1,
			Image = GLOW,
			ImageColor3 = props.Color,
			ImageTransparency = props.Transparency or 0,
			Size = UDim2.fromScale(1, 1),
			ZIndex = props.ZIndex or 0,
		})
	end
	return scope:New "ImageLabel" {
		Name = props.Name or "Glow",
		BackgroundTransparency = 1,
		Image = GLOW,
		ImageColor3 = props.Color,
		ImageTransparency = props.Transparency or 0,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position or UDim2.fromScale(0.5, 0.5),
		Size = props.Size,
		Visible = props.Visible,
		ZIndex = props.ZIndex or 0,
		[Fusion.Children] = extra,
	}
end

-- Poof (docs/RESEARCH.md 2.10): 7-9 soft circles fly out with drag, growing
-- 0.3 -> 1.3x and fading over ~0.4 s, plus one expanding ring. Drawn in
-- design px inside `parent`, centred on `at` (UDim2). Fire and forget.
local POOF_TWEEN = game:GetService("TweenService")
local function poof(parent: Instance, at: UDim2, options: { [string]: any }?)
	local opts = options or {}
	local count = opts.Count or 8
	local size = opts.Size or 34
	local reachMin, reachMax = opts.Reach and opts.Reach[1] or 40, opts.Reach and opts.Reach[2] or 90
	local colors = opts.Colors or { Color3.new(1, 1, 1) }
	local life = opts.Time or 0.42
	local zIndex = opts.ZIndex or 50
	for i = 1, count do
		local angle = (i / count) * math.pi * 2 + (math.random() - 0.5) * 0.7
		local direction = Vector2.new(math.cos(angle), math.sin(angle))
		local start = direction * math.random() * 12
		local finish = direction * (reachMin + math.random() * (reachMax - reachMin))
		local circle = Instance.new("ImageLabel")
		circle.Name = "Poof"
		circle.BackgroundTransparency = 1
		circle.Image = "rbxasset://textures/whiteCircle.png"
		circle.ImageColor3 = colors[(i - 1) % #colors + 1]
		circle.ImageTransparency = 0.1
		circle.AnchorPoint = Vector2.new(0.5, 0.5)
		circle.Position = at + UDim2.fromOffset(start.X, start.Y)
		local s = size * (0.85 + math.random() * 0.3)
		circle.Size = UDim2.fromOffset(s * 0.3, s * 0.3)
		circle.ZIndex = zIndex
		circle.Parent = parent
		local t = life * (0.85 + math.random() * 0.2)
		POOF_TWEEN:Create(circle, TweenInfo.new(t, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
			Position = at + UDim2.fromOffset(finish.X, finish.Y),
			Size = UDim2.fromOffset(s * 1.3, s * 1.3),
			ImageTransparency = 1,
		}):Play()
		task.delay(t + 0.05, function()
			circle:Destroy()
		end)
	end
	if opts.Ring ~= false then
		local ringSize = opts.RingSize or size * 3
		local ring = Instance.new("Frame")
		ring.Name = "PoofRing"
		ring.BackgroundTransparency = 1
		ring.AnchorPoint = Vector2.new(0.5, 0.5)
		ring.Position = at
		ring.Size = UDim2.fromOffset(ringSize * 0.4, ringSize * 0.4)
		ring.ZIndex = zIndex
		local corner = Instance.new("UICorner")
		corner.CornerRadius = UDim.new(0.5, 0)
		corner.Parent = ring
		local stroke = Instance.new("UIStroke")
		stroke.Thickness = opts.RingThickness or 6
		stroke.Color = opts.RingColor or colors[1]
		stroke.Transparency = 0.2
		stroke.Parent = ring
		ring.Parent = parent
		local ringTime = opts.RingTime or 0.25
		POOF_TWEEN:Create(ring, TweenInfo.new(ringTime, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
			Size = UDim2.fromOffset(ringSize * 1.6, ringSize * 1.6),
		}):Play()
		POOF_TWEEN:Create(stroke, TweenInfo.new(ringTime, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), { Transparency = 1 }):Play()
		task.delay(ringTime + 0.05, function()
			ring:Destroy()
		end)
	end
end

local function thresholdsFrom(phases: any): { number }
	local out = {}
	if typeof(phases) == "number" and phases > 1 then
		for i = phases - 1, 1, -1 do
			table.insert(out, i / phases)
		end
	elseif typeof(phases) == "table" then
		for _, t in phases do
			if typeof(t) == "number" and t > 0 and t < 1 then
				table.insert(out, t)
			end
		end
		table.sort(out, function(a, b)
			return a > b
		end)
	end
	return out
end

return function(scope: Fusion.Scope<any>, props: { [any]: any }): Frame
	local max = props.Max or 100
	local value = props.Value or max
	local width = props.Width or 900
	local barHeight = props.BarHeight or 38
	local plateHeight = 56
	local low = if props.Low == nil then 0.25 else props.Low
	local shown = if props.Shown == nil then true else props.Shown
	local thresholds = thresholdsFrom(props.Phases)

	-- a HUD kit lends its type (the plate and bar stay the boss bar's own)
	local kitTokens = if props.Kit ~= nil then HudKits.tokens(props.Kit) else nil
	local kitted = kitTokens ~= nil and kitTokens.name ~= "Classic"
	local TITLE = if kitted then kitTokens.labelFont else "Title"
	local HEAVY = if kitted then kitTokens.labelFont else "Heavy"
	local DISPLAY = if kitted then kitTokens.numberFont else "Display"
	local pixelType = kitted and string.find(tostring(kitTokens.labelFont), "PressStart2P", 1, true) ~= nil

	local tone = Kit.tone(scope, props.Tone, "Red")

	local fraction = scope:Computed(function(use)
		local total = use(max)
		if total == nil or total <= 0 then
			return 0
		end
		return math.clamp((use(value) or 0) / total, 0, 1)
	end)

	-- entrance: slide down, then the bar sweeps full
	local appear = scope:Spring(scope:Computed(function(use)
		return if use(shown) then 1 else 0
	end), 16, 0.72)
	local introGoal = scope:Value(0)
	local intro = scope:Tween(introGoal, TweenInfo.new(1.1, Enum.EasingStyle.Quart, Enum.EasingDirection.Out))
	local introToken = 0
	local function playIntro()
		introToken += 1
		local token = introToken
		task.delay(0.25, function()
			if token == introToken then
				introGoal:set(1)
			end
		end)
	end
	if peek(shown) then
		playIntro()
	end
	Kit.watch(scope, shown, function(now)
		if now then
			playIntro()
		end
	end)

	local shownFraction = scope:Computed(function(use)
		return use(fraction) * use(intro)
	end)
	local fill = scope:Spring(shownFraction, 20, 1)

	-- ghost: waits a beat after a hit, then drains to the real value
	local ghostGoal = scope:Value(0)
	local ghost = scope:Spring(ghostGoal, 5, 1)
	local ghostToken = 0
	Kit.watch(scope, shownFraction, function(now)
		ghostToken += 1
		if now >= peek(ghostGoal) then
			ghostGoal:set(now)
		else
			local token = ghostToken
			task.delay(0.45, function()
				if token == ghostToken then
					ghostGoal:set(now)
				end
			end)
		end
	end)

	-- numbers roll instead of jumping
	local rolled = scope:Spring(scope:Computed(function(use)
		return (use(value) or 0) * use(intro)
	end), 14, 1)

	local phase = scope:Computed(function(use)
		local f = use(fraction)
		local passed = 0
		for _, t in thresholds do
			if f <= t then
				passed += 1
			end
		end
		return passed + 1
	end)

	local dead = scope:Computed(function(use)
		return use(fraction) <= 0
	end)

	local tagText = scope:Computed(function(use)
		if use(dead) then
			return "DEFEATED"
		end
		local title = use(props.Title)
		if title ~= nil and title ~= "" then
			return string.upper(tostring(title))
		end
		if #thresholds > 0 then
			return `PHASE {use(phase)}`
		end
		return ""
	end)

	local labelText = scope:Computed(function(use)
		local mode = use(props.Label)
		if mode == false then
			return ""
		end
		local current = math.max(0, math.floor(use(rolled) + 0.5))
		local total = use(max) or 0
		if typeof(mode) == "function" then
			return mode(current, total)
		elseif mode == "Value" then
			return Format.comma(current)
		elseif mode == "Percent" then
			return Format.percent(if total > 0 then current / total else 0)
		end
		return `{Format.comma(current)} / {Format.comma(total)}`
	end)

	-- hit feedback
	local flashGoal = scope:Value(1)
	local flash = scope:Spring(flashGoal, 14, 1)
	local tagFlashGoal = scope:Value(0)
	local tagFlash = scope:Spring(tagFlashGoal, 6, 1)
	local medalPopGoal = scope:Value(1)
	local medalPop = scope:Spring(medalPopGoal, 35, 0.5)

	local barFrame: Frame
	local numbersLayer: Frame
	local fxLayer: Frame
	local lastShake = 0
	local lastValue = peek(value) or 0

	-- Damage numbers (docs/RESEARCH.md 2.9): pop 1.3 -> 1 in 0.08 s, drift
	-- 28 px over 0.6 s, fade over the last 0.25 s, +-12 px jitter. Hits
	-- within 0.3 s merge into one number that re-pops. Big hits go gold.
	-- The bar sits at the top of the screen, so numbers fall off its
	-- underside (like the lost chunk dropping) instead of rising into the
	-- name plate.
	local showNumbers = props.DamageNumbers ~= false
	local merged: { label: TextLabel?, amount: number, at: number } = { label = nil, amount = 0, at = -1 }
	local function damageNumber(amount: number, atFraction: number)
		-- (Particles.damageNumbers = false: the player turned damage numbers off)
		if not showNumbers or Particles.damageNumbers == false or numbersLayer == nil or numbersLayer.Parent == nil then
			return
		end
		local now = os.clock()
		if merged.label and now - merged.at < 0.3 then
			amount += merged.amount
			merged.label:Destroy()
		end
		local theme = Theme.get()
		local total = peek(max) or 1
		local big = total > 0 and amount / total >= (props.CritFraction or 0.08)
		local t = peek(tone)
		local label = Instance.new("TextLabel")
		label.Name = "DamageNumber"
		label.BackgroundTransparency = 1
		label.AnchorPoint = Vector2.new(0.5, 0)
		label.AutomaticSize = Enum.AutomaticSize.XY
		label.Size = UDim2.fromOffset(0, 0)
		local x = UDim2.new(math.clamp(atFraction, 0.04, 0.96), math.random(-12, 12), 1, 2)
		label.Position = x
		label.FontFace = Theme.font(theme, "Display")
		label.Text = "-" .. Format.comma(math.floor(amount + 0.5))
		label.TextSize = if big then 36 else 28
		label.TextColor3 = if big then theme.Color.Accent else WHITE
		label.ZIndex = 20
		local stroke = Instance.new("UIStroke")
		stroke.Color = if big then Color.darken(theme.Color.Accent, 0.6) else (t.Stroke or t.Edge)
		stroke.Thickness = 2.5
		stroke.LineJoinMode = Enum.LineJoinMode.Round
		stroke.Parent = label
		local pop = Instance.new("UIScale")
		pop.Scale = 1.3
		pop.Parent = label
		label.Parent = numbersLayer
		merged.label, merged.amount, merged.at = label, amount, now
		TweenService:Create(pop, TweenInfo.new(0.08, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), { Scale = 1 }):Play()
		TweenService:Create(label, TweenInfo.new(0.6, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), { Position = x + UDim2.fromOffset(0, 28) }):Play()
		local fade = TweenInfo.new(0.25, Enum.EasingStyle.Quad, Enum.EasingDirection.In, 0, false, 0.35)
		TweenService:Create(label, fade, { TextTransparency = 1 }):Play()
		TweenService:Create(stroke, fade, { Transparency = 1 }):Play()
		task.delay(0.65, function()
			label:Destroy()
		end)
	end

	Kit.watch(scope, value, function(now)
		now = now or 0
		if now < lastValue then
			flash:setPosition(0.35)
			medalPop:setPosition(0.9)
			local clock = os.clock()
			if barFrame and clock - lastShake > 0.12 then
				lastShake = clock
				Motion.shake(barFrame, 5, 0.22)
			end
			local total = peek(max) or 1
			damageNumber(lastValue - now, if total > 0 then now / total else 0)
			if now <= 0 and fxLayer then
				-- defeated: the bar bursts
				local t = peek(tone)
				poof(fxLayer, UDim2.new(0.5, 0, 0, plateHeight + 10 + barHeight / 2), {
					Count = 9,
					Size = 60,
					Reach = { 120, 260 },
					Colors = { t.Top, WHITE, t.Bottom },
					RingSize = 300,
					RingColor = t.Top,
				})
			end
		elseif now > lastValue then
			flash:setPosition(0.6)
		end
		lastValue = now
	end)

	local lastPhase = peek(phase)
	Kit.watch(scope, phase, function(now)
		if now > lastPhase then
			tagFlash:setPosition(1)
			medalPop:setPosition(1.3)
			flash:setPosition(0)
			Sound.play("Notify")
			-- the notch that was just crossed breaks with a poof
			local crossed = thresholds[now - 1]
			if crossed and fxLayer then
				local t = peek(tone)
				poof(fxLayer, UDim2.new(crossed, 0, 0, plateHeight + 10 + barHeight / 2), {
					Count = 8,
					Size = 34,
					Reach = { 40, 90 },
					Colors = { WHITE, Color.lighten(t.Top, 0.4) },
					RingSize = 110,
					RingColor = WHITE,
				})
			end
			if props.OnPhase then
				task.spawn(props.OnPhase, now)
			end
		end
		lastPhase = now
	end)

	local dimmed = scope:Computed(function(use)
		return use(dead)
	end)

	--------------------------------------------------------------------
	-- Name plate
	--------------------------------------------------------------------

	-- the disc sits centred inside a layout slot so its pop scales from the middle
	local medallionDisc = scope:New "Frame" {
		Name = "Disc",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = WHITE,
		ZIndex = 4,
		[Children] = {
			scope:New "UIScale" { Scale = medalPop },
			Kit.corner(scope, "Full"),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = scope:Computed(function(use)
					local t = use(tone)
					if use(dimmed) then
						local grey = Theme.tone(use(Theme.current), "Gray")
						return ColorSequence.new(grey.Top, grey.Bottom)
					end
					return ColorSequence.new(Color.lighten(t.Top, 0.15), t.Bottom)
				end),
			}),
			Kit.stroke(scope, {
				Thickness = 3,
				Color = scope:Computed(function(use)
					return use(tone).Edge
				end),
			}),
			scope:New "Frame" {
				Name = "Gloss",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, 4),
				Size = UDim2.new(0.72, 0, 0.4, 0),
				BackgroundColor3 = WHITE,
				BackgroundTransparency = 0.55,
				ZIndex = 5,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0.2, 1) }),
				},
			},
			Icon(scope, {
				Name = "BossIcon",
				Icon = props.Icon or "crown",
				Size = math.floor(plateHeight * 0.58),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Color = WHITE,
				Stroke = 2.5,
				StrokeColor = scope:Computed(function(use)
					local t = use(tone)
					return t.Stroke or t.Edge
				end),
				ZIndex = 6,
			}),
		},
	}
	local medallion = scope:New "Frame" {
		Name = "Medallion",
		Size = UDim2.fromOffset(plateHeight, plateHeight),
		BackgroundTransparency = 1,
		LayoutOrder = 1,
		ZIndex = 4,
		[Children] = medallionDisc,
	}

	local nameLabel = Text(scope, {
		Name = "BossName",
		Text = scope:Computed(function(use)
			return string.upper(tostring(use(props.Name) or "Boss"))
		end),
		TextSize = if pixelType then 26 else 40,
		Font = TITLE,
		Stroke = 3.5,
		Gradient = ColorSequence.new(WHITE, Color3.fromHex("DDE1EC")),
		LayoutOrder = 2,
		ZIndex = 4,
	})

	local tag = scope:New "Frame" {
		Name = "Tag",
		Size = UDim2.fromOffset(0, 30),
		AutomaticSize = Enum.AutomaticSize.X,
		BackgroundColor3 = scope:Computed(function(use)
			local t = use(tone)
			return t.Deep:Lerp(t.Bottom, math.clamp(use(tagFlash), 0, 1))
		end),
		BackgroundTransparency = 0.15,
		LayoutOrder = 3,
		Visible = scope:Computed(function(use)
			return use(tagText) ~= ""
		end),
		ZIndex = 4,
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.padding(scope, { 0, 14 }),
			Kit.stroke(scope, {
				Thickness = 2,
				Color = scope:Computed(function(use)
					return use(tone).Top
				end),
			}),
			Text(scope, {
				Name = "TagText",
				Text = tagText,
				TextSize = if pixelType then 13 else 19,
				Font = HEAVY,
				Stroke = false,
				Color = scope:Computed(function(use)
					return Color.lighten(use(tone).Top, 0.35)
				end),
				Size = UDim2.fromScale(0, 1),
				AutoSize = "X",
				ZIndex = 5,
			}),
		},
	}

	local plate = scope:New "Frame" {
		Name = "Plate",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.fromScale(0.5, 0),
		Size = UDim2.new(1, 0, 0, plateHeight),
		BackgroundTransparency = 1,
		ZIndex = 4,
		[Children] = {
			Kit.list(scope, { Direction = "X", Padding = 12, HAlign = "Center", VAlign = "Center" }),
			medallion,
			nameLabel,
			tag,
		},
	}

	--------------------------------------------------------------------
	-- Bar
	--------------------------------------------------------------------

	local fillFrame = scope:New "Frame" {
		Name = "Fill",
		Size = scope:Computed(function(use)
			return UDim2.fromScale(math.clamp(use(fill), 0, 1), 1)
		end),
		BackgroundColor3 = WHITE,
		ZIndex = 4,
		[Children] = {
			Kit.corner(scope, "S"),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = scope:Computed(function(use)
					local t = use(tone)
					return ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.18)),
						ColorSequenceKeypoint.new(0.5, t.Bottom),
						ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.28)),
					})
				end),
			}),
			scope:New "Frame" {
				Name = "Gloss",
				Position = UDim2.fromOffset(3, 2),
				Size = UDim2.new(1, -6, 0.42, 0),
				BackgroundColor3 = WHITE,
				BackgroundTransparency = 0.5,
				ZIndex = 5,
				[Children] = {
					Kit.corner(scope, "S"),
					Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0.15, 1) }),
				},
			},
			scope:New "Frame" {
				Name = "Edge",
				AnchorPoint = Vector2.new(1, 0),
				Position = UDim2.fromScale(1, 0),
				Size = UDim2.new(0, 3, 1, 0),
				BackgroundColor3 = WHITE,
				BackgroundTransparency = 0.25,
				Visible = scope:Computed(function(use)
					local f = use(fill)
					return f > 0.004 and f < 0.996
				end),
				ZIndex = 6,
			},
			scope:New "Frame" {
				Name = "HitFlash",
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = WHITE,
				BackgroundTransparency = scope:Computed(function(use)
					return math.clamp(use(flash), 0, 1)
				end),
				ZIndex = 7,
				[Children] = { Kit.corner(scope, "S") },
			},
		},
	}

	local lowFlash = scope:New "Frame" {
		Name = "LowPulse",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = WHITE,
		BackgroundTransparency = 1,
		ZIndex = 6,
		Parent = fillFrame,
		[Children] = { Kit.corner(scope, "S") },
	}

	local ticks = {}
	for index, t in thresholds do
		local passed = scope:Computed(function(use)
			return use(fraction) <= t
		end)
		table.insert(ticks, scope:New "Frame" {
			Name = `PhaseTick{index}`,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(t, 0),
			Size = UDim2.new(0, 4, 1, 0),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Outline
			end),
			BackgroundTransparency = 0.1,
			ZIndex = 8,
		})
		table.insert(ticks, scope:New "Frame" {
			Name = `PhaseMarker{index}`,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(t, 0, 0, -4),
			Size = UDim2.fromOffset(14, 14),
			Rotation = 45,
			BackgroundColor3 = scope:Computed(function(use)
				if use(passed) then
					return use(Theme.current).Color.Track
				end
				return Color.lighten(use(tone).Top, 0.3)
			end),
			ZIndex = 9,
			[Children] = {
				Kit.corner(scope, 3),
				Kit.stroke(scope, { Thickness = 2 }),
			},
		})
	end

	local lowGlow = glow(scope, {
		Name = "Glow",
		Color = scope:Computed(function(use)
			return use(tone).Top
		end),
		Size = UDim2.new(1, 160, 1, 150),
		Transparency = 1,
		Layers = 2,
		ZIndex = 1,
	})

	barFrame = scope:New "Frame" {
		Name = "Bar",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, plateHeight + 10),
		Size = UDim2.new(1, 0, 0, barHeight),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Track
		end),
		BackgroundTransparency = 0.08,
		ZIndex = 2,
		[Children] = {
			Kit.corner(scope, "M"),
			Kit.stroke(scope, { Thickness = 3 }),
			lowGlow,
			Kit.shadow(scope, { Spread = 14, Offset = Vector2.new(0, 5), ZIndex = 0 }),
			scope:New "Frame" {
				Name = "Inner",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(4, 4),
				Size = UDim2.new(1, -8, 1, -8),
				ZIndex = 3,
				[Children] = {
					scope:New "Frame" {
						Name = "Ghost",
						Size = scope:Computed(function(use)
							return UDim2.fromScale(math.clamp(math.max(use(ghost), use(fill)), 0, 1), 1)
						end),
						BackgroundColor3 = scope:Computed(function(use)
							return Color.lighten(use(tone).Top, 0.82)
						end),
						BackgroundTransparency = 0.05,
						ZIndex = 3,
						[Children] = { Kit.corner(scope, "S") },
					},
					fillFrame,
					ticks,
				},
			},
			Text(scope, {
				Name = "Label",
				Text = labelText,
				TextSize = math.floor(barHeight * (if pixelType then 0.42 else 0.6)),
				Font = DISPLAY,
				Stroke = 2.5,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Visible = scope:Computed(function(use)
					return use(labelText) ~= ""
				end),
				ZIndex = 10,
			}),
		},
	}

	-- low health: the fill breathes and the bar glows
	local lowOn = scope:Computed(function(use)
		if low == false then
			return false
		end
		local f = use(fraction)
		return f > 0 and f <= low
	end)
	local stopLow: (() -> ())? = nil
	local function updateLow(on: boolean)
		if on and stopLow == nil then
			local info = TweenInfo.new(0.42, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true)
			local glows = { lowGlow }
			for _, layer in lowGlow:GetChildren() do
				table.insert(glows, layer)
			end
			local loops = { TweenService:Create(lowFlash, info, { BackgroundTransparency = 0.55 }) }
			for _, g in glows do
				table.insert(loops, TweenService:Create(g, info, { ImageTransparency = 0 }))
			end
			local untracks = {}
			for _, loop in loops do
				loop:Play()
				-- pauses while the bar is hidden (e.g. a boss between phases off screen)
				table.insert(untracks, Motion.track(loop, lowFlash))
			end
			stopLow = function()
				for _, untrack in untracks do
					untrack()
				end
				for _, loop in loops do
					loop:Cancel()
				end
				lowFlash.BackgroundTransparency = 1
				for _, g in glows do
					g.ImageTransparency = 1
				end
			end
		elseif not on and stopLow then
			stopLow()
			stopLow = nil
		end
	end
	updateLow(peek(lowOn))
	Kit.watch(scope, lowOn, updateLow)
	table.insert(scope, function()
		if stopLow then
			stopLow()
		end
	end)

	--------------------------------------------------------------------
	-- Root
	--------------------------------------------------------------------

	local totalHeight = plateHeight + 10 + barHeight + 8

	numbersLayer = scope:New "Frame" {
		Name = "DamageNumbers",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, plateHeight + 10),
		Size = UDim2.new(1, -8, 0, barHeight),
		BackgroundTransparency = 1,
		ZIndex = 20,
	}
	fxLayer = scope:New "Frame" {
		Name = "FxLayer",
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		ZIndex = 30,
	}

	local slide = scope:New "Frame" {
		Name = "Slide",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		Position = scope:Computed(function(use)
			return UDim2.fromOffset(0, (1 - use(appear)) * -(totalHeight + 120))
		end),
		[Children] = {
			scope:New "ImageLabel" {
				Name = "Backshade",
				BackgroundTransparency = 1,
				Image = Assets.SoftShadow,
				ImageColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Backdrop
				end),
				ImageTransparency = 0.8,
				ScaleType = Enum.ScaleType.Slice,
				SliceCenter = Assets.SoftShadowSlice,
				SliceScale = 1.2,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.new(1, 120, 1, 80),
				ZIndex = 0,
			},
			plate,
			barFrame,
			numbersLayer,
			fxLayer,
		},
	}

	-- `Name` is the boss's name here, not the instance name
	local root = Kit.root(props)
	root.Name = props.InstanceName or "BossBar"
	root.AnchorPoint = props.AnchorPoint or Vector2.new(0.5, 0)
	root.Position = props.Position or UDim2.new(0.5, 0, 0, 74)
	root.Size = props.Size or UDim2.fromOffset(width, totalHeight)
	root.BackgroundTransparency = 1
	root.Visible = scope:Computed(function(use)
		local extra = use(props.Visible)
		if extra == false then
			return false
		end
		return use(shown) or use(appear) > 0.02
	end)
	root[Children] = {
		slide,
		props[Children],
	}

	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="87">
            <Properties>
              <string name="Name">Bubble</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Bubble: a speech-bubble tooltip or callout with a little pointer.

	scope:Bubble {
		Text = "Claim your FREE reward now!",
		Tone = "Red",            -- "White" (default) or any tone
		Pointer = "Bottom",      -- "Top" | "Bottom" | "Left" | "Right" | "None"
		Icon = "gift",           -- optional
		TextSize = 22,
		Bob = true,              -- floats up and down (needs a plain, non-state Position)
		Position = UDim2.new(0.5, 0, 0, -12),
		AnchorPoint = Vector2.new(0.5, 1),
	}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Motion = require(script.Parent.Parent.FX.Motion)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children

local POINTERS = {
	Top = UDim2.fromScale(0.5, 0),
	Bottom = UDim2.fromScale(0.5, 1),
	Left = UDim2.fromScale(0, 0.5),
	Right = UDim2.fromScale(1, 0.5),
}

-- a strip just inside the edge the pointer sits on
local POINTER_COVER = {
	Top = { anchor = Vector2.new(0.5, 0), size = UDim2.fromOffset(30, 14) },
	Bottom = { anchor = Vector2.new(0.5, 1), size = UDim2.fromOffset(30, 14) },
	Left = { anchor = Vector2.new(0, 0.5), size = UDim2.fromOffset(14, 30) },
	Right = { anchor = Vector2.new(1, 0.5), size = UDim2.fromOffset(14, 30) },
}

return function(scope: Fusion.Scope<any>, props: { [any]: any }): Frame
	local tone = Kit.tone(scope, props.Tone, "White")
	-- White bubbles are pure white; coloured callouts use the saturated body
	-- colour (Rivals' red "Claim your FREE reward now!").
	local fill = scope:Computed(function(use)
		local t = use(tone)
		if Color.luminance(t.Bottom) > 0.72 then
			return t.Top
		end
		local callout = use(Theme.current).Color.Callout
		if use(props.Tone) == "Red" and callout then
			return callout
		end
		return t.Mid or t.Bottom:Lerp(t.Top, 0.2)
	end)
	-- light fills (white, lime, yellow) get dark text; dark fills white text
	local isLight = scope:Computed(function(use)
		return Color.luminance(use(fill)) > 0.62
	end)
	local textColor = scope:Computed(function(use)
		local t = use(tone)
		if use(isLight) then
			return if Color.luminance(t.Text) < 0.5 then t.Text else t.Deep:Lerp(Color3.new(0, 0, 0), 0.25)
		end
		return if Color.luminance(t.Text) > 0.5 then t.Text else Color3.new(1, 1, 1)
	end)
	-- a light bubble gets a thin tinted edge so it reads on light panels too
	local edge = scope:Computed(function(use)
		return use(tone).Edge
	end)
	local textSize = props.TextSize or 22
	local pointer = props.Pointer or "Bottom"

	local root = Kit.root(props, { Name = "Bubble" })
	root.Size = UDim2.fromOffset(0, 0)
	root.AutomaticSize = Enum.AutomaticSize.XY
	root.BackgroundColor3 = fill
	root[Children] = {
		Kit.corner(scope, props.Radius or "S"),
		scope:New "UIStroke" {
			ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
			Color = edge,
			Thickness = 1.5,
			Transparency = 0.35,
			Enabled = isLight,
		},
		if POINTERS[pointer]
			then scope:New "Frame" {
				Name = "Pointer",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = POINTERS[pointer],
				Size = UDim2.fromOffset(18, 18),
				Rotation = 45,
				BackgroundColor3 = fill,
				ZIndex = 1,
				[Children] = scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
					Color = edge,
					Thickness = 1.5,
					Transparency = 0.35,
					Enabled = isLight,
				},
			}
			else nil,
		-- covers the pointer's inner half (and its outline there) so the
		-- pointer joins the bubble without a seam
		if POINTERS[pointer]
			then scope:New "Frame" {
				Name = "PointerJoin",
				AnchorPoint = POINTER_COVER[pointer].anchor,
				Position = POINTERS[pointer],
				Size = POINTER_COVER[pointer].size,
				BackgroundColor3 = fill,
				BorderSizePixel = 0,
				ZIndex = 1,
			}
			else nil,
		scope:New "Frame" {
			Name = "Content",
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(0, 0),
			AutomaticSize = Enum.AutomaticSize.XY,
			ZIndex = 2,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 8, VAlign = "Center" }),
				Kit.padding(scope, { 7, 18, 8, 18 }),
				if props.Icon
					then Icon(scope, {
						Icon = props.Icon,
						Size = math.floor(textSize * 1.3),
						Color = props.IconColor or textColor,
						Stroke = false,
						LayoutOrder = 1,
						ZIndex = 3,
					})
					else nil,
				Text(scope, {
					Text = props.Text,
					TextSize = textSize,
					Font = props.Font or "Small",
					Color = textColor,
					Stroke = props.Stroke or false,
					Case = props.Case or "Normal",
					RichText = props.RichText,
					LayoutOrder = 2,
					ZIndex = 3,
				}),
			},
		},
		props[Children],
	}

	local bubble = scope:New "Frame" (root) :: Frame

	-- Bobbing tweens Position, so it only works when Position isn't a state.
	local position = props.Position
	if props.Bob and (position == nil or typeof(position) == "UDim2") then
		table.insert(scope, Motion.bob(bubble, props.BobDistance or 6, 1.5))
	end

	return bubble
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="88">
            <Properties>
              <string name="Name">Button</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Button: a chunky, juicy game button. Five looks, all driven by the theme
	unless you pick one:

	  Glossy3D  gradient face, gloss band, a 3D lip that squashes when pressed
	  Flat      flat gradient with a tinted outline and a small lip (Rivals)
	  Pill      fully rounded, soft shadow, thin tinted outline (modern apps)
	  Outline   see-through fill with a bright tone outline (secondary actions)
	  Soft      pastel tinted fill with dark tinted text (quiet actions)

	scope:Button {
		Text = "48",
		Icon = "robux",               -- optional, shown left of the text
		Tone = "Green",               -- any theme tone or rarity ("Epic")
		Style = "Flat",               -- default: theme Style.ButtonStyle
		Primary = true,               -- the screen's hero action: never drawn Soft / Outline
		ClickFx = "Ripple",           -- Squash | Punch | Ripple | Sparkle | Glow | Poof | Shake | Glitch | None (default: theme)
		Size = UDim2.fromOffset(150, 60),
		OnClick = function() end,     -- ClickFx "Poof": OnClick(done) + done(true) on success
		                              -- (or return true / false); plain handlers count as success
		Disabled = false,             -- bool or state; greys out, no hover, blocks clicks
		Shine = true,                 -- light sweep every few seconds
		Pulse = false,                -- bool or state; breathing "click me" motion
		Badge = 3,                    -- red notification bubble (number, text or state)
		Hotkey = Enum.KeyCode.E,      -- also triggered by this key
		Sound = "Click",              -- theme sound name or id
		TextSize = 30,
		IconSize = 36,                -- default ~55% of the button height
		IconColor = Color3.fromHex("FFD21F"),
		RichIcon = true,              -- icon gets its own gradient, tinted outline and shadow
		Fit = true,                   -- long text shrinks to fit the button (default true)
		TouchPad = true,              -- phones: a see-through hit area of at least 44 px (Kit.touchPad)
	}

	With only an Icon and no Text it becomes a square icon button (close, +).
	Controller players see a themed focus ring (Kit.focusRing) when it's
	selected, instead of Roblox's default selection box.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Sound = require(script.Parent.Parent.Util.Sound)
local Input = require(script.Parent.Parent.Util.Input)
local Color = require(script.Parent.Parent.Util.Color)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local Click = require(script.Parent.Parent.FX.Click)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Badge = require(script.Parent.Badge)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)
local BLACK = Color3.new(0, 0, 0)

local STYLES = { Glossy3D = true, Flat = true, Pill = true, Outline = true, Soft = true }

-- v2.4 style families: an Outline button sits on anything (a title screen's
-- open sky, a busy backdrop), so instead of a see-through face it gets a
-- near-solid plate of the family's glass (panel on light families), tinted
-- with the tone. The label is the tone's light (or the theme's text for the
-- neutral Dark / Gray / White tones), kept at 4.5:1 against that plate even
-- where the last bit of transparency shows white or black through it.
-- The rim is the tone, or the family's trim / accent for neutral tones.
-- Classic themes never reach this (they keep the v2.3 see-through look).
local OUTLINE_PLATE_T = 0.12

local function familyOutline(theme: any, t: any, disabled: boolean): { [string]: any }
	local Oklch = Theme.Oklch
	local fam = theme.Family
	local light = fam ~= nil and fam.Light == true
	local c = theme.Color
	local _, chroma = Oklch.fromColor(t.Mid or t.Bottom)
	local neutral = chroma < 0.04
	local base = if light then (c.Panel or WHITE) else (c.Surface or BLACK)
	local plate = base:Lerp(if light then t.Top else t.Bottom, if neutral then 0.06 elseif light then 0.1 else 0.2)
	local worst = if Color.luminance(plate) < 0.5 then plate:Lerp(WHITE, OUTLINE_PLATE_T) else plate:Lerp(BLACK, OUTLINE_PLATE_T)
	local label
	if neutral then
		label = if light then (c.PanelText or c.TextDark or BLACK) else (c.Text or WHITE)
	else
		label = if light then t.Edge else Color.lighten(t.Top, 0.15)
	end
	if disabled then
		label = label:Lerp(plate, 0.35)
	else
		label = Oklch.readable(label, worst, 4.5)
	end
	local rim
	if neutral then
		local trim = c.Trim or c.Accent
		local _, trimChroma = Oklch.fromColor(trim)
		rim = if trimChroma >= 0.04 then trim else c.Accent
	else
		rim = if light then t.Edge else t.Top
	end
	return { plate = plate, plateT = OUTLINE_PLATE_T, label = label, rim = rim, worst = worst }
end

return function(scope: Fusion.Scope<any>, props: { [any]: any }): TextButton
	-- Theme.lite: no gloss layer, rim or lip outline (low-end devices); the
	-- gloss and the lip outline also follow Theme.setLite live (Kit.unlessLite)
	local lite = Theme.lite == true
	local disabled = props.Disabled or false
	local hovering = scope:Value(false)
	local pressing = scope:Value(false)
	local focused = scope:Value(false)

	-- v2.4 style families: the family's button recipe (theme.Family.Button):
	-- lip depth, radius, gloss, an outline round the whole button, the inner
	-- rim light and layered UIShadows. Classic themes skip all of it.
	local fam = Theme.get().Family
	local recipe = if fam then (fam.Button or {}) else nil

	local style = scope:Computed(function(use)
		local given = use(props.Style)
		if given ~= nil and STYLES[given] then
			return given
		end
		local themed = Theme.style(use(Theme.current), "ButtonStyle")
		-- the one hero action per screen is never quiet: Soft / Outline themes
		-- draw it as a solid pill so it still reads as "press me"
		if use(props.Primary) and (themed == "Soft" or themed == "Outline") then
			return "Pill"
		end
		return if STYLES[themed] then themed else "Glossy3D"
	end)
	local clickFx = Kit.style(scope, "ClickFx", props.ClickFx)

	local tone = scope:Computed(function(use)
		local theme = use(Theme.current)
		if use(disabled) then
			return theme.Tone.Disabled or theme.Tone.Gray
		end
		return Theme.tone(theme, use(props.Tone) or "Green")
	end)

	-- Height of the 3D lip for the current style.
	local depth = scope:Computed(function(use)
		local s = use(style)
		local base = use(props.Depth) or use(Theme.current).Depth
		if recipe and recipe.Depth ~= nil then
			if s == "Outline" or s == "Soft" then
				return 0
			end
			return use(props.Depth) or recipe.Depth
		end
		if s == "Glossy3D" then
			return math.max(3, base)
		elseif s == "Flat" then
			return math.clamp(base, 2, 3)
		end
		return 0
	end)

	local radius = scope:Computed(function(use)
		local given = use(props.Radius)
		if given ~= nil then
			return given
		end
		if recipe and recipe.Radius ~= nil then
			return recipe.Radius
		end
		return if use(style) == "Pill" then "Full" else "M"
	end)

	-- Springs per phase (docs/RESEARCH.md 8.2): hover, a critically damped
	-- press, and a bouncy release.
	local releasing = scope:Value(false)
	local releaseToken = 0
	local function release()
		releaseToken += 1
		local token = releaseToken
		releasing:set(true)
		task.delay(0.3, function()
			if token == releaseToken then
				releasing:set(false)
			end
		end)
	end

	local scaleGoal = scope:Computed(function(use)
		local motion = use(Theme.current).Motion
		local fx = use(clickFx)
		if use(disabled) then
			return 1
		elseif use(pressing) and fx ~= "None" then
			if fx == "Squash" or fx == "Punch" or fx == "Poof" then
				return motion.PressScale
			end
			return (1 + motion.PressScale) / 2
		elseif use(hovering) then
			return motion.HoverScale
		end
		return 1
	end)
	local scale = scope:Spring(
		scaleGoal,
		scope:Computed(function(use)
			local motion = use(Theme.current).Motion
			if use(pressing) then
				return motion.PressSpeed
			elseif use(releasing) then
				return motion.ReleaseSpeed
			end
			return motion.HoverSpeed
		end),
		scope:Computed(function(use)
			local motion = use(Theme.current).Motion
			if use(pressing) then
				return motion.PressDamping
			elseif use(releasing) then
				return motion.ReleaseDamping
			end
			return motion.HoverDamping
		end)
	)

	local punchGoal = scope:Value(0)
	local punch = scope:Spring(punchGoal, 32, 0.32)
	local flashGoal = scope:Value(1)
	local flash = scope:Spring(flashGoal, 22, 1)

	local press = scope:Spring(scope:Computed(function(use)
		return if use(pressing) and not use(disabled) then math.max(0, use(depth) - 1) else 0
	end), 45, 0.9)

	local lift = scope:Spring(scope:Computed(function(use)
		return if use(hovering) and not use(disabled) then 1 else 0
	end), 25, 1)

	local wobbleGoal = scope:Value(0)
	local wobble = scope:Spring(wobbleGoal, 30, 0.3)

	local hasText = props.Text ~= nil
	local textSize = props.TextSize or 28
	local iconSize = props.IconSize

	-- Long labels fit a fixed-width button instead of spilling over its
	-- edges (QUALITY rule 11): they shrink, and when that would make them
	-- small, break onto two lines instead.
	local fittedSize: any = textSize
	local labelText: any = props.Text
	local givenSize = peek(props.Size)
	if hasText and props.Fit ~= false and typeof(textSize) == "number" and typeof(givenSize) == "UDim2" and givenSize.X.Scale == 0 then
		local width = givenSize.X.Offset
		local height = if givenSize.Y.Scale == 0 and givenSize.Y.Offset > 0 then givenSize.Y.Offset else 64
		local fit = scope:Computed(function(use)
			local theme = use(Theme.current)
			local raw = use(props.Text)
			if raw == nil then
				return { text = "", size = textSize }
			end
			raw = tostring(raw)
			local case = use(props.Case) or (if Theme.style(theme, "ButtonCase") == "Upper" then "Upper" else nil) or Theme.style(theme, "TextCase")
			local measured = if case == "Upper" then Kit.upper(raw) else raw
			local font = use(props.Font) or "Display"
			local room = width - 24 - theme.Stroke.Text * 2
			local face = height - theme.Depth
			local icon = use(props.Icon)
			if icon ~= nil and icon ~= false and icon ~= "" then
				room -= (use(iconSize) or math.floor(math.min(textSize * 1.3, face * 0.6))) + 8
			end
			local single = Kit.fitTextSize(measured, textSize, room, font, math.floor(textSize * 0.5), theme)
			if single >= textSize * 0.78 or not string.find(raw, " ") or string.find(raw, "[\n<]") then
				return { text = raw, size = single }
			end
			-- two lines: split at the space that balances them best
			local words = string.split(raw, " ")
			local best, bestWidth = nil, math.huge
			for i = 1, #words - 1 do
				local a = table.concat(words, " ", 1, i)
				local b = table.concat(words, " ", i + 1)
				local ma = if case == "Upper" then Kit.upper(a) else a
				local mb = if case == "Upper" then Kit.upper(b) else b
				local w = math.max(Kit.textWidth(ma, textSize, font, theme), Kit.textWidth(mb, textSize, font, theme))
				if w < bestWidth then
					best, bestWidth = { a, b, ma, mb }, w
				end
			end
			local double = math.min(
				math.floor(textSize * 0.82),
				math.floor((face - 8) / 2.05),
				Kit.fitTextSize(best[3], textSize, room, font, 1, theme),
				Kit.fitTextSize(best[4], textSize, room, font, 1, theme)
			)
			if double > single then
				return { text = `{best[1]}\n{best[2]}`, size = double }
			end
			return { text = raw, size = single }
		end)
		fittedSize = scope:Computed(function(use)
			return use(fit).size
		end)
		labelText = scope:Computed(function(use)
			return use(fit).text
		end)
	end

	-- label colours per style
	-- style families: the Outline look on a near-solid plate (familyOutline)
	local outlined = scope:Computed(function(use)
		local theme = use(Theme.current)
		if recipe == nil or theme.Family == nil or use(style) ~= "Outline" then
			return nil
		end
		return familyOutline(theme, use(tone), use(disabled) == true)
	end)

	local labelColor = scope:Computed(function(use)
		local s = use(style)
		local t = use(tone)
		local fo = use(outlined)
		if fo then
			return fo.label
		end
		if s == "Outline" then
			local label = Color.lighten(t.Top, 0.15)
			-- a dark tone (Gray / Dark in Horror) would vanish on the dark
			-- glass behind it: pull the label towards the theme's text
			if Color.luminance(label) < 0.55 and not use(disabled) then
				label = label:Lerp(use(Theme.current).Color.Text, 0.6)
			end
			return label
		elseif s == "Soft" then
			return t.Deep:Lerp(t.Edge, 0.4)
		end
		return t.Text
	end)
	local labelStroke = scope:Computed(function(use)
		local s = use(style)
		local t = use(tone)
		local given = use(props.StrokeColor)
		if given then
			return given
		end
		local fo = use(outlined)
		if fo then
			-- a halo in the plate's colour (a dark outline round dark ink on a
			-- light plate would blur it)
			return fo.plate
		end
		if s == "Outline" then
			return use(Theme.current).Color.Outline
		end
		return t.Stroke or use(Theme.current).Color.Outline
	end)
	local labelRecipe = scope:Computed(function(use)
		local s = use(style)
		local t = use(tone)
		local theme = use(Theme.current)
		if s == "Outline" then
			return Theme.textStroke(theme, "Glass", theme.Color.Outline)
		end
		return Theme.textStroke(theme, "Button", use(props.StrokeColor) or t.Stroke or t.Edge)
	end)
	local labelStrokeOn = scope:Computed(function(use)
		local s = use(style)
		local t = use(tone)
		local r = use(labelRecipe)
		if s == "Soft" or r.Thickness <= 0 or r.Transparency >= 1 then
			return false
		elseif t.Stroke == nil and s ~= "Outline" and use(props.StrokeColor) == nil then
			return false
		end
		return r.Thickness
	end)
	local labelStrokeAlpha = scope:Computed(function(use)
		return use(labelRecipe).Transparency
	end)

	local glow = Kit.glow(scope, {
		Name = "ClickGlow",
		Color = scope:Computed(function(use)
			return use(tone).Top
		end),
		Transparency = 1,
		Size = UDim2.fromScale(1.35, 1.9),
		ZIndex = 0,
	})

	local fxLayer = scope:New "Frame" {
		Name = "FX",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		ZIndex = 4,
	}

	local face = scope:New "Frame" {
		Name = "Face",
		Size = scope:Computed(function(use)
			return UDim2.new(1, 0, 1, -use(depth))
		end),
		Position = scope:Computed(function(use)
			return UDim2.fromOffset(0, use(press))
		end),
		BackgroundColor3 = scope:Computed(function(use)
			local s = use(style)
			local t = use(tone)
			local fo = use(outlined)
			if fo then
				return fo.plate:Lerp(t.Bottom, use(lift) * 0.12)
			end
			if s == "Outline" then
				return t.Bottom
			elseif s == "Soft" then
				return Color.lighten(t.Top:Lerp(WHITE, 0.62), use(lift) * 0.4)
			end
			return WHITE
		end),
		BackgroundTransparency = scope:Computed(function(use)
			local fo = use(outlined)
			if fo then
				return fo.plateT * (1 - use(lift))
			end
			if use(style) == "Outline" then
				return 0.84 - use(lift) * 0.22
			end
			return 0
		end),
		ClipsDescendants = true,
		ZIndex = 2,

		[Children] = {
			Kit.corner(scope, radius),
			scope:New "UIGradient" {
				Rotation = 90,
				Enabled = scope:Computed(function(use)
					local s = use(style)
					return s ~= "Outline" and s ~= "Soft"
				end),
				Color = scope:Computed(function(use)
					return Kit.faceColors(use(tone), use(lift) * 0.5)
				end),
			},
			-- the rim (Pill and Outline styles only)
			if lite and peek(style) ~= "Pill" and peek(style) ~= "Outline" then nil else scope:New "UIStroke" {
				ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				LineJoinMode = Enum.LineJoinMode.Round,
				Color = scope:Computed(function(use)
					local s = use(style)
					local t = use(tone)
					local fo = use(outlined)
					if fo then
						return fo.rim
					end
					if s == "Outline" then
						-- dark tones get a lighter rim so the button still has an edge
						return if Color.luminance(t.Top) < 0.4 and not use(disabled) then Color.lighten(t.Top, 0.25) else t.Top
					end
					return use(props.BorderColor) or t.Edge
				end),
				Thickness = scope:Computed(function(use)
					local s = use(style)
					local theme = use(Theme.current)
					if s == "Outline" then
						return math.max(2, theme.Stroke.UI)
					end
					return theme.Stroke.Thin
				end),
				Transparency = scope:Computed(function(use)
					return if use(style) == "Pill" then 0.25 else 0
				end),
				Enabled = scope:Computed(function(use)
					local s = use(style)
					return s == "Pill" or s == "Outline"
				end),
			},
			Kit.unlessLite(scope, function(inner)
				return inner:New "Frame" {
					Name = "Gloss",
					BackgroundColor3 = WHITE,
					BackgroundTransparency = inner:Computed(function(use)
						local s = use(style)
						if s == "Glossy3D" then
							return 0.45
						elseif s == "Flat" then
							return 0.7
						end
						return 0.78
					end),
					-- round buttons get a centred oval gloss so it never pokes out
					-- past the curve (the face clips rectangularly)
					AnchorPoint = Vector2.new(0.5, 0),
					Position = inner:Computed(function(use)
						if use(radius) == "Full" then
							return UDim2.new(0.5, 0, 0.05, 0)
						end
						return if use(style) == "Glossy3D" then UDim2.new(0.5, 0, 0, 3) else UDim2.fromScale(0.5, 0)
					end),
					Size = inner:Computed(function(use)
						if use(radius) == "Full" then
							-- circles: a centred oval; pills: a long band inset from the ends
							local given = use(props.Size)
							if typeof(given) == "UDim2" and given.X.Scale == 0 and given.Y.Offset > 0 then
								local w, h = given.X.Offset, given.Y.Offset
								local width = math.max(w * 0.66, w - h * 0.75)
								return UDim2.new(0, width, 0.4, 0)
							end
							return UDim2.fromScale(0.66, 0.4)
						end
						return if use(style) == "Glossy3D" then UDim2.new(1, -8, 0.46, 0) else UDim2.new(1, 0, 0.5, 0)
					end),
					Visible = inner:Computed(function(use)
						local s = use(style)
						if recipe and not recipe.Gloss then
							return false
						end
						return s ~= "Outline" and s ~= "Soft"
					end),
					ZIndex = 3,
					[Children] = {
						Kit.corner(inner, radius),
						Kit.gradient(inner, {
							Rotation = 90,
							Transparency = NumberSequence.new(0.1, 1),
						}),
					},
				}
			end),
			-- style families: the inner top light (and bottom shade)
			if recipe and recipe.Rim then Kit.rim(scope, {
				Visible = scope:Computed(function(use)
					return not use(disabled) and use(style) ~= "Outline"
				end),
			}) else nil,
			scope:New "Frame" {
				Name = "PressFlash",
				BackgroundColor3 = WHITE,
				BackgroundTransparency = scope:Computed(function(use)
					return math.clamp(use(flash), 0, 1)
				end),
				Size = UDim2.fromScale(1, 1),
				ZIndex = 4,
				[Children] = Kit.corner(scope, radius),
			},
			fxLayer,
		},
	}

	local body = scope:New "Frame" {
		Name = "Body",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Rotation = wobble,
		ZIndex = 2,

		[Children] = {
			-- style families: one outline round the whole silhouette (face +
			-- lip), tinted to contrast the fill, and the layered shadow that
			-- lifts on hover (UIShadow draws outside this transparent frame)
			if recipe then Kit.corner(scope, radius) else nil,
			if recipe and typeof(recipe.Outline) == "number" and recipe.Outline > 0
				then Kit.outline(scope, {
					Tone = tone,
					Width = recipe.Outline,
					Inner = false,
					Visible = scope:Computed(function(use)
						return use(style) ~= "Outline"
					end),
				})
				else nil,
			if recipe then Kit.shadows(scope, {
				Role = "Button",
				Tone = tone,
				Lift = lift,
				Visible = scope:Computed(function(use)
					return not use(disabled)
				end),
				Strength = scope:Computed(function(use)
					-- pressed down: the shadow tucks in
					return if use(pressing) then 0.55 else 1
				end),
			}) else nil,
			scope:New "Frame" {
				Name = "Lip",
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = scope:Computed(function(use)
					local t = use(tone)
					return if use(style) == "Glossy3D" then t.Edge else t.Edge:Lerp(t.Deep, 0.15)
				end),
				Visible = scope:Computed(function(use)
					return use(depth) > 0
				end),
				ZIndex = 1,
				[Children] = {
					Kit.corner(scope, radius),
					-- (style families outline the whole button instead: see Body)
					if recipe then nil else Kit.unlessLite(scope, function(inner)
						return Kit.stroke(inner, {
							Color = inner:Computed(function(use)
								local given = use(props.BorderColor)
								if given then
									return given
								end
								local t = use(tone)
								return if use(style) == "Glossy3D" then t.Deep:Lerp(t.Edge, 0.35) else t.Edge
							end),
							Thickness = inner:Computed(function(use)
								local theme = use(Theme.current)
								return if use(style) == "Glossy3D" then theme.Stroke.UI else theme.Stroke.Thin + 0.5
							end),
						})
					end),
				},
			},
			face,
			scope:New "Frame" {
				Name = "Content",
				BackgroundTransparency = 1,
				Size = scope:Computed(function(use)
					return UDim2.new(1, 0, 1, -use(depth))
				end),
				Position = scope:Computed(function(use)
					return UDim2.fromOffset(0, use(press))
				end),
				ZIndex = 5,
				[Children] = {
					Kit.list(scope, { Direction = "X", Padding = 8, HAlign = "Center", VAlign = "Center" }),
					Kit.padding(scope, { 0, 12 }),
					if props.Icon ~= nil
						then Icon(scope, {
							Icon = props.Icon,
							Transparency = scope:Computed(function(use)
								return if use(disabled) then 0.35 else 0
							end),
							Size = iconSize or scope:Computed(function(use)
								local given = use(props.Size)
								local height = if typeof(given) == "UDim2" and given.Y.Offset > 0 then given.Y.Offset else (if hasText then 64 else 60)
								local h = height - use(depth)
								if hasText then
									return math.floor(math.min(textSize * 1.3, h * 0.6))
								end
								return math.floor(h * 0.6)
							end),
							LayoutOrder = 1,
							Visible = scope:Computed(function(use)
								local icon = use(props.Icon)
								return icon ~= nil and icon ~= false and icon ~= ""
							end),
							Color = scope:Computed(function(use)
								return use(props.IconColor) or use(labelColor)
							end),
							StrokeColor = if props.RichIcon then nil else labelStroke,
							Stroke = if props.RichIcon then nil else labelStrokeOn,
							Gradient = props.RichIcon,
							Shadow = props.RichIcon,
							ZIndex = 6,
						})
						else nil,
					if hasText
						then Text(scope, {
							Text = labelText,
							TextSize = fittedSize,
							LineHeight = 0.92,
							Font = props.Font or "Display",
							Color = labelColor,
							StrokeColor = labelStroke,
							Stroke = labelStrokeOn,
							StrokeTransparency = labelStrokeAlpha,
							Case = props.Case or Kit.themed(scope, function(theme)
								local buttonCase = Theme.style(theme, "ButtonCase")
								return if buttonCase == "Upper" then "Upper" else nil
							end),
							Transparency = scope:Computed(function(use)
								return if use(disabled) then 0.4 else 0
							end),
							LayoutOrder = 2,
							ZIndex = 6,
						})
						else nil,
				},
			},
		},
	}

	-- The click sound plays on press-down so it feels instant; `activate`
	-- only plays it when nothing pressed first (hotkeys, gamepad).
	local soundedAt = -1
	local function pressSound()
		if not peek(disabled) then
			soundedAt = os.clock()
			Sound.play(props.Sound or "Click")
		end
	end

	local alive = true
	table.insert(scope, function()
		alive = false
	end)

	-- A rounded clip for Ripple / Glitch (the face clips as a rectangle, so
	-- effects would show in its corners). A CanvasGroup costs a texture, so
	-- it's only made the first time one of those effects plays.
	local clip: CanvasGroup? = nil
	local function clipLayer(): GuiObject
		if clip == nil then
			clip = scope:New "CanvasGroup" {
				Name = "FXClip",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				ZIndex = 4,
				Parent = face,
				[Children] = Kit.corner(scope, radius),
			} :: CanvasGroup
		end
		return clip :: CanvasGroup
	end

	local function activate(inputObject: InputObject?)
		if peek(disabled) then
			Sound.play("Error")
			if not Motion.reduced then
				wobble:addVelocity(220)
			end
			Motion.shake(body, 6, 0.25)
			return
		end
		if os.clock() - soundedAt > 0.6 then
			Sound.play(props.Sound or "Click")
		end
		soundedAt = -1
		release()
		local kind = peek(clickFx)
		-- reduced motion: only the press itself (no punch, flash or FX)
		local calm = Motion.reduced
		if not calm and (kind == "Squash" or kind == "Sparkle" or kind == "Glow") then
			punch:addVelocity(1.4)
		elseif not calm and (kind == "Punch" or kind == "Poof") then
			flash:setPosition(0.85)
			flashGoal:set(1)
		end
		Click.click(kind, {
			-- ripples and glitch slices are clipped to the face's rounded shape
			Target = if kind == "Ripple" or kind == "Glitch" then clipLayer() else fxLayer,
			Root = body,
			Glow = glow,
			Color = peek(tone).Top,
			Input = inputObject,
			Wobble = function(velocity: number)
				wobble:addVelocity(velocity)
			end,
		})
		-- Poof celebrates success: a handler that takes an argument gets a
		-- `done(ok)` to call when the purchase / claim really went through
		-- (or can return true / false); a plain handler counts as success.
		local settled = false
		local function done(ok: boolean?)
			if settled or not alive then
				return
			end
			settled = true
			if ok == false then
				if kind == "Poof" then
					Sound.play("Error")
					Motion.shake(body, 5, 0.22)
				end
			elseif kind == "Poof" then
				Particles.poof(body, { Color = Color.lighten(peek(tone).Top, 0.5), Size = 30 })
			end
		end
		if props.OnClick then
			local waits = debug.info(props.OnClick, "a") >= 1
			local result = props.OnClick(done)
			if typeof(result) == "boolean" then
				done(result)
			elseif not waits then
				done(true)
			end
		else
			done(true)
		end
	end

	if props.Shine then
		table.insert(scope, Motion.shine(face, { ZIndex = 4, Transparency = 0.55 }))
	end

	if props.Pulse then
		local stopPulse: (() -> ())? = nil
		local function update(on: boolean)
			if on and stopPulse == nil then
				stopPulse = Motion.pulse(body, 0.06, 0.9)
			elseif not on and stopPulse then
				stopPulse()
				stopPulse = nil
			end
		end
		update(peek(props.Pulse) == true)
		Kit.watch(scope, props.Pulse, update)
		table.insert(scope, function()
			if stopPulse then
				stopPulse()
			end
		end)
	end

	if props.Hotkey then
		Input.bind(scope, props.Hotkey, function()
			pressing:set(true)
			task.delay(0.1, function()
				pressing:set(false)
			end)
			activate(nil)
		end)
	end

	local root = Kit.root(props, { Name = props.Text and tostring(peek(props.Text)) or "Button" })
	root.Size = props.Size or (if hasText then UDim2.fromOffset(200, 64) else UDim2.fromOffset(60, 60))
	root.BackgroundTransparency = 1
	root.Text = ""
	root.AutoButtonColor = false
	root.Selectable = true
	root.Active = scope:Computed(function(use)
		return not use(disabled)
	end)

	root[OnEvent "MouseEnter"] = function()
		hovering:set(true)
		if not peek(disabled) then
			Sound.play("Hover", { Volume = 0.4 })
		end
	end
	root[OnEvent "MouseLeave"] = function()
		hovering:set(false)
		pressing:set(false)
	end
	root.SelectionImageObject = Kit.NoSelection
	root[OnEvent "SelectionGained"] = function()
		hovering:set(true)
		focused:set(true)
	end
	root[OnEvent "SelectionLost"] = function()
		hovering:set(false)
		focused:set(false)
	end
	local function onDown()
		pressing:set(true)
		pressSound()
	end
	local function onUp()
		if peek(pressing) then
			release()
		end
		pressing:set(false)
	end
	local function onActivated(inputObject: InputObject?)
		if inputObject and inputObject.UserInputType == Enum.UserInputType.Touch then
			hovering:set(false)
		end
		pressing:set(false)
		activate(inputObject)
	end
	root[OnEvent "MouseButton1Down"] = onDown
	root[OnEvent "MouseButton1Up"] = onUp
	root[OnEvent "Activated"] = onActivated
	-- phones: a comfortable hit area around small buttons (Kit.touchPad)
	local touchPad = if props.TouchPad == false then nil else Kit.touchPad(scope, root.Size, {
		MouseButton1Down = onDown,
		MouseButton1Up = onUp,
		Activated = onActivated,
		MouseLeave = function()
			pressing:set(false)
		end,
	})

	root[Children] = {
		scope:New "UIScale" {
			Scale = scope:Computed(function(use)
				return use(scale) * (1 + use(punch))
			end),
		},
		Kit.elevation(scope, {
			Radius = radius,
			Spread = 10,
			Offset = Vector2.new(0, 5),
			Strength = 0.55,
			Visible = scope:Computed(function(use)
				local s = use(style)
				-- (style families shadow the Body with their own recipe)
				return recipe == nil and (s == "Pill" or s == "Soft" or s == "Flat") and not use(disabled)
			end),
		}),
		glow,
		body,
		Kit.focusRing(scope, { Focused = focused, Radius = radius }),
		if props.Badge ~= nil
			then Badge(scope, {
				Value = props.Badge,
				Position = UDim2.new(1, -6, 0, 4),
				ZIndex = 10,
			})
			else nil,
		touchPad,
		props[Children],
	}

	return scope:New "TextButton" (root) :: TextButton
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="89">
            <Properties>
              <string name="Name">Camcorder</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Camcorder: the found-footage look (Blueprint v1.7 HUD type Camcorder).
	The component is the on-screen display: a blinking REC dot, a running
	timecode, the tape mode and date, and a battery. Camcorder.overlay adds
	the full-screen part: corner brackets, a centre focus mark, scanlines
	with a rolling tracking band, and a night-vision or VHS tint.

	scope:Camcorder {
		Mode = "VHS",              -- "VHS" | "NightVision" | "Digital"
		Label = "REC",             -- the word beside the dot
		Date = "OCT 31 1998",      -- optional second line
		Battery = 0.6,             -- 0..1 (number or state); nil hides it
		Start = 0,                 -- timecode start, in seconds
		Size = UDim2.fromOffset(340, 80),
	}
	Camcorder.overlay(scope, { Mode = "VHS", Scanlines = true, Brackets = true, Top = safeTop, Parent = layer })

	Reduced motion keeps the REC dot lit and parks the tracking band; lite
	mode drops the scanlines, band and tint (the brackets stay: they are
	what makes it a camera). REC is written as a word, not only a red dot.
]]

local RunService = game:GetService("RunService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Motion = require(script.Parent.Parent.FX.Motion)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)
local NIGHT_GREEN = Color3.fromRGB(96, 255, 128)
local REC_RED = Color3.fromRGB(255, 45, 58)

local Camcorder = {}

local function inkFor(mode: string): Color3
	return if mode == "NightVision" then NIGHT_GREEN else WHITE
end

local function timecode(seconds: number): string
	seconds = math.max(0, math.floor(seconds))
	return string.format("%02d:%02d:%02d", seconds // 3600, (seconds % 3600) // 60, seconds % 60)
end

local function new(scope: Scope, props: { [any]: any }): Frame
	local mode = peek(props.Mode) or "VHS"
	local size = peek(props.Size) or UDim2.fromOffset(340, 80)
	local h = size.Y.Offset
	local ink = inkFor(mode)
	local lineH = math.floor(h * 0.46)

	local elapsed = scope:Value(tonumber(peek(props.Start)) or 0)
	local blink = scope:Value(true)
	local alive = true
	table.insert(scope, function()
		alive = false
	end)
	task.spawn(function()
		local half = 0
		while alive do
			task.wait(0.5)
			half += 1
			if half % 2 == 0 then
				elapsed:set(peek(elapsed) + 1)
			end
			local on = Motion.reduced or half % 2 == 0
			if on ~= peek(blink) then
				blink:set(on)
			end
		end
	end)

	local function osd(text: any, textSize: number, extra: { [any]: any }?)
		local p: { [any]: any } = {
			Text = text,
			TextSize = textSize,
			Font = "Display",
			Case = "Upper",
			XAlign = "Left",
			Color = ink,
			StrokeColor = Color3.new(0, 0, 0),
			Stroke = 2,
			StrokeTransparency = 0.35,
			ZIndex = 3,
		}
		for key, value in extra or {} do
			p[key] = value
		end
		return Text(scope, p)
	end

	local battery = props.Battery
	local batteryRow = nil
	if battery ~= nil then
		local cells = {}
		for i = 1, 4 do
			table.insert(cells, scope:New "Frame" {
				Name = "Cell" .. i,
				LayoutOrder = i,
				Size = UDim2.fromOffset(9, math.floor(lineH * 0.55)),
				BackgroundColor3 = scope:Computed(function(use)
					local v = tonumber(use(battery)) or 0
					return if v <= 0.25 then REC_RED else ink
				end),
				BackgroundTransparency = scope:Computed(function(use)
					local v = tonumber(use(battery)) or 0
					if v * 4 < i - 0.5 then
						return 1
					end
					-- the last cell blinks when the battery is almost flat
					return if v <= 0.25 and not use(blink) then 0.8 else 0
				end),
			})
		end
		batteryRow = scope:New "Frame" {
			Name = "Battery",
			Position = UDim2.fromOffset(math.floor(lineH * 3.3 + lineH * 0.82 * 4.9), math.floor(lineH * 0.16)),
			Size = UDim2.fromOffset(62, math.floor(lineH * 0.7)),
			BackgroundTransparency = 1,
			ZIndex = 3,
			[Children] = {
				scope:New "Frame" {
					Name = "Case",
					Size = UDim2.new(1, -6, 1, 0),
					BackgroundTransparency = 1,
					[Children] = {
						Kit.corner(scope, 2),
						Kit.stroke(scope, { Thickness = 2, Color = ink, Transparency = 0.05 }),
						Kit.padding(scope, { 3, 4, 3, 4 }),
						Kit.list(scope, { Direction = "X", Padding = 3, VAlign = "Center" }),
						cells,
					},
				},
				scope:New "Frame" {
					Name = "Nub",
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.fromScale(1, 0.5),
					Size = UDim2.new(0, 5, 0.45, 0),
					BackgroundColor3 = ink,
				},
			},
		}
	end

	local root = Kit.root(props, { Name = "Camcorder" })
	root.Size = size
	root.BackgroundTransparency = 1
	root[Children] = {
		scope:New "Frame" {
			Name = "Line1",
			BackgroundTransparency = 1,
			Size = UDim2.new(1, 0, 0, lineH),
			ZIndex = 2,
			[Children] = {
				scope:New "Frame" {
					Name = "Dot",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.fromScale(0, 0.5),
					Size = UDim2.fromOffset(math.floor(lineH * 0.55), math.floor(lineH * 0.55)),
					BackgroundColor3 = REC_RED,
					BackgroundTransparency = scope:Computed(function(use)
						return if use(blink) then 0 else 0.85
					end),
					ZIndex = 3,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.stroke(scope, { Thickness = 1.5, Color = Color3.new(0, 0, 0), Transparency = 0.5 }),
					},
				},
				osd(tostring(peek(props.Label) or "REC"), math.floor(lineH * 0.82), {
					Name = "Rec",
					Position = UDim2.fromOffset(math.floor(lineH * 0.75), 0),
					Size = UDim2.fromOffset(math.floor(lineH * 2.4), lineH),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = math.floor(lineH * 0.82),
				}),
				osd(scope:Computed(function(use)
					return timecode(use(elapsed))
				end), math.floor(lineH * 0.82), {
					Name = "Timecode",
					Case = "Normal",
					Position = UDim2.fromOffset(math.floor(lineH * 3.3), 0),
					Size = UDim2.fromOffset(math.floor(lineH * 0.82 * 4.7), lineH),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = math.floor(lineH * 0.82),
				}),
				batteryRow,
			},
		},
		osd(scope:Computed(function()
			local modeText = if mode == "NightVision" then "NIGHT SHOT" elseif mode == "Digital" then "HD  4K" else "PLAY  SP"
			local date = peek(props.Date)
			return if typeof(date) == "string" and date ~= "" then `{modeText}    {date}` else modeText
		end), math.floor(lineH * 0.62), {
			Name = "Line2",
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.fromScale(0, 1),
			Size = UDim2.new(1, 0, 0, math.floor(lineH * 0.8)),
			Scaled = true,
			Wrapped = false,
			MaxTextSize = math.floor(lineH * 0.62),
			Transparency = 0.1,
		}),
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end

-- The full-screen part. `Top`: design px (or state) kept clear at the top
-- (under Roblox's top bar); `Inset`: bracket distance from the edges.
function Camcorder.overlay(scope: Scope, props: { [any]: any }): Frame
	local mode = peek(props.Mode) or "VHS"
	local ink = inkFor(mode)
	local inset = peek(props.Inset) or 10
	local top = props.Top or 72
	local arm, thick = 70, 4

	local function corner(xScale: number, yScale: number, name: string): Frame
		local pos = scope:Computed(function(use)
			local t = use(top)
			local y = if yScale == 0 then math.max(inset, t - 24) else -inset
			local x = if xScale == 0 then inset else -inset
			return UDim2.new(xScale, x, yScale, y)
		end)
		return scope:New "Frame" {
			Name = name,
			AnchorPoint = Vector2.new(xScale, yScale),
			Position = pos,
			Size = UDim2.fromOffset(arm, arm),
			BackgroundTransparency = 1,
			ZIndex = 2,
			[Children] = {
				scope:New "Frame" {
					Name = "H",
					AnchorPoint = Vector2.new(xScale, yScale),
					Position = UDim2.fromScale(xScale, yScale),
					Size = UDim2.new(1, 0, 0, thick),
					BackgroundColor3 = ink,
					BackgroundTransparency = 0.12,
					BorderSizePixel = 0,
				},
				scope:New "Frame" {
					Name = "V",
					AnchorPoint = Vector2.new(xScale, yScale),
					Position = UDim2.fromScale(xScale, yScale),
					Size = UDim2.new(0, thick, 1, 0),
					BackgroundColor3 = ink,
					BackgroundTransparency = 0.12,
					BorderSizePixel = 0,
				},
			},
		}
	end

	local focus = {}
	for i, spec in { { 0, 0 }, { 1, 0 }, { 0, 1 }, { 1, 1 } } do
		table.insert(focus, scope:New "Frame" {
			Name = "Focus" .. i,
			AnchorPoint = Vector2.new(spec[1], spec[2]),
			Position = UDim2.fromScale(spec[1], spec[2]),
			Size = UDim2.fromOffset(18, 18),
			BackgroundTransparency = 1,
			[Children] = {
				scope:New "Frame" {
					AnchorPoint = Vector2.new(spec[1], spec[2]),
					Position = UDim2.fromScale(spec[1], spec[2]),
					Size = UDim2.new(1, 0, 0, 2),
					BackgroundColor3 = ink,
					BackgroundTransparency = 0.35,
					BorderSizePixel = 0,
				},
				scope:New "Frame" {
					AnchorPoint = Vector2.new(spec[1], spec[2]),
					Position = UDim2.fromScale(spec[1], spec[2]),
					Size = UDim2.new(0, 2, 1, 0),
					BackgroundColor3 = ink,
					BackgroundTransparency = 0.35,
					BorderSizePixel = 0,
				},
			},
		})
	end

	-- scanlines and the rolling tracking band (lite mode: none)
	local bandY = scope:Value(0.3)
	local effects = Kit.unlessLite(scope, function(inner)
		local lines = {}
		if props.Scanlines ~= false then
			for i = 0, 119 do
				table.insert(lines, inner:New "Frame" {
					Name = "Scan",
					Position = UDim2.fromScale(0, i / 120),
					Size = UDim2.new(1, 0, 0, 2),
					BackgroundColor3 = Color3.new(0, 0, 0),
					BackgroundTransparency = if mode == "NightVision" then 0.8 else 0.86,
					BorderSizePixel = 0,
					ZIndex = 1,
				})
			end
		end
		local tint = if mode == "NightVision" then Color3.fromRGB(40, 255, 90) elseif mode == "VHS" then Color3.fromRGB(150, 170, 255) else nil
		return inner:New "Frame" {
			Name = "Effects",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			ZIndex = 1,
			[Children] = {
				if tint
					then inner:New "Frame" {
						Name = "Tint",
						Size = UDim2.fromScale(1, 1),
						BackgroundColor3 = tint,
						BackgroundTransparency = if mode == "NightVision" then 0.84 else 0.95,
						BorderSizePixel = 0,
						ZIndex = 1,
					}
					else nil,
				inner:New "Frame" {
					Name = "Scanlines",
					BackgroundTransparency = 1,
					Size = UDim2.fromScale(1, 1),
					ZIndex = 1,
					[Children] = lines,
				},
				if mode ~= "Digital"
					then inner:New "Frame" {
						Name = "Tracking",
						AnchorPoint = Vector2.new(0, 0.5),
						Position = inner:Computed(function(use)
							return UDim2.fromScale(0, use(bandY))
						end),
						Size = UDim2.new(1, 0, 0, 46),
						BackgroundColor3 = WHITE,
						BackgroundTransparency = 0.93,
						BorderSizePixel = 0,
						ZIndex = 1,
						[Children] = Kit.gradient(inner, {
							Rotation = 90,
							Transparency = NumberSequence.new({
								NumberSequenceKeypoint.new(0, 1),
								NumberSequenceKeypoint.new(0.5, 0.2),
								NumberSequenceKeypoint.new(1, 1),
							}),
						}),
					}
					else nil,
			},
		}
	end)
	table.insert(scope, RunService.RenderStepped:Connect(function(dt)
		if Motion.reduced or Theme.isLite() then
			return
		end
		local y = peek(bandY) + dt * 0.07
		bandY:set(if y > 1.1 then -0.1 else y)
	end))

	local root = Kit.root(props, { Name = "CamcorderOverlay" })
	root.Size = UDim2.fromScale(1, 1)
	root.BackgroundTransparency = 1
	root.Active = false
	root[Children] = {
		effects,
		props.Brackets ~= false and corner(0, 0, "TopLeft") or nil,
		props.Brackets ~= false and corner(1, 0, "TopRight") or nil,
		props.Brackets ~= false and corner(0, 1, "BottomLeft") or nil,
		props.Brackets ~= false and corner(1, 1, "BottomRight") or nil,
		scope:New "Frame" {
			Name = "FocusMark",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromOffset(120, 76),
			BackgroundTransparency = 1,
			ZIndex = 2,
			[Children] = focus,
		},
	}
	return scope:New "Frame" (root) :: Frame
end

Camcorder.new = new

return setmetatable(Camcorder, {
	__call = function(_, scope: Scope, props: { [any]: any })
		return new(scope, props)
	end,
})
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="90">
            <Properties>
              <string name="Name">CameraPanel</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	CameraPanel: a security-camera switcher (FNAF-style). A header with a
	blinking LIVE dot, the big camera code ("CAM 1A") and its room, over a
	grid of camera buttons (Blueprint v1.7 HUD type CameraPanel).

	scope:CameraPanel {
		Title = "CAMERAS",
		Cameras = { { Id = "cam1a", Label = "1A", Name = "Show Stage" }, ... },  -- up to 12
		Selected = selected,           -- Value holding a camera id (made for you if missing)
		Columns = 3,
		OnSelect = function(id) end,   -- a camera button was pressed
		Size = UDim2.fromOffset(300, 250),
	}

	The selected camera is filled (not just recoloured) and its code is
	written in the header, so it reads without colour. Reduced motion keeps
	the LIVE dot steady; lite mode drops the header scanlines.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Motion = require(script.Parent.Parent.FX.Motion)
local Text = require(script.Parent.Text)
local HudKits = require(script.Parent.HudKits)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

return function(scope: Scope, props: { [any]: any }): Frame
	-- HUD kits (v1.8, Kit = a HudKits name / tokens): the kit's number and label type
	local kitTokens = if props.Kit ~= nil then HudKits.tokens(props.Kit) else nil
	local kitted = kitTokens ~= nil and kitTokens.name ~= "Classic"
	local DISPLAY = if kitted then kitTokens.numberFont else "Display"
	local HEAVY = if kitted then kitTokens.labelFont else "Heavy"
	local size = peek(props.Size) or UDim2.fromOffset(300, 250)
	local w, h = size.X.Offset, size.Y.Offset
	local cameras = props.Cameras or {}
	local columns = math.clamp(math.floor(tonumber(peek(props.Columns)) or 3), 1, 6)
	local selected = props.Selected
	if not Kit.isState(selected) then
		selected = scope:Value(selected or (cameras[1] and cameras[1].Id) or "")
	end
	local current = scope:Computed(function(use)
		local id = use(selected)
		for _, cam in cameras do
			if cam.Id == id then
				return cam
			end
		end
		return cameras[1] or { Id = "", Label = "--", Name = "" }
	end)

	local blink = scope:Value(true)
	local alive = true
	table.insert(scope, function()
		alive = false
	end)
	task.spawn(function()
		while alive do
			task.wait(0.6)
			local on = Motion.reduced or not peek(blink)
			if on ~= peek(blink) then
				blink:set(on)
			end
		end
	end)

	local headerH = 58
	local pad = 12
	local gap = 7
	local rows = math.max(1, math.ceil(#cameras / columns))
	local cellW = math.floor((w - pad * 2 - gap * (columns - 1)) / columns)
	local cellH = math.clamp(math.floor((h - headerH - pad * 2 - gap * (rows - 1)) / rows), 30, 52)

	local cells = {}
	for index, cam in cameras do
		local isOn = scope:Computed(function(use)
			return use(selected) == cam.Id
		end)
		table.insert(cells, scope:New "TextButton" {
			Name = cam.Id,
			LayoutOrder = index,
			Text = "",
			AutoButtonColor = false,
			Size = UDim2.fromOffset(cellW, cellH),
			BackgroundColor3 = scope:Computed(function(use)
				local theme = use(Theme.current)
				return if use(isOn) then (theme.Color.Primary or theme.Color.Accent) else theme.Color.Surface
			end),
			BackgroundTransparency = scope:Computed(function(use)
				return if use(isOn) then 0 else 0.25
			end),
			ZIndex = 3,
			[OnEvent "Activated"] = function()
				selected:set(cam.Id)
				if props.OnSelect then
					props.OnSelect(cam.Id)
				end
			end,
			[Children] = {
				Kit.corner(scope, "S"),
				Kit.stroke(scope, {
					Thickness = 2,
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Primary or theme.Color.Accent
					end),
					Transparency = scope:Computed(function(use)
						return if use(isOn) then 0 else 0.45
					end),
				}),
				Text(scope, {
					Text = tostring(cam.Label or ""),
					TextSize = math.floor(cellH * 0.46),
					Font = DISPLAY,
					Case = "Upper",
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						if use(isOn) then
							local fill = theme.Color.Primary or theme.Color.Accent
							return if Kit.isLight(fill) then Color3.new(0.06, 0.06, 0.07) else Color3.new(1, 1, 1)
						end
						return theme.Color.Text
					end),
					Stroke = false,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Size = UDim2.new(1, -8, 0.7, 0),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = math.floor(cellH * 0.46),
					ZIndex = 4,
				}),
			},
		})
	end

	local root = Kit.root(props, { Name = "CameraPanel" })
	root.Size = size
	root.BackgroundColor3 = Kit.themed(scope, function(theme)
		return theme.Color.Surface
	end)
	root.BackgroundTransparency = Kit.themed(scope, function(theme)
		return math.clamp(theme.Alpha.Surface - 0.1, 0, 1)
	end)
	root[Children] = {
		Kit.corner(scope, "M"),
		Kit.stroke(scope, {
			Thickness = 1.5,
			Color = Kit.themed(scope, function(theme)
				return theme.Color.Light
			end),
			Transparency = 0.75,
		}),
		scope:New "Frame" {
			Name = "Header",
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(pad, 6),
			Size = UDim2.new(1, -pad * 2, 0, headerH - 6),
			ZIndex = 2,
			[Children] = {
				scope:New "Frame" {
					Name = "LiveDot",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 0, 0, 14),
					Size = UDim2.fromOffset(12, 12),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Bad
					end),
					BackgroundTransparency = scope:Computed(function(use)
						return if use(blink) then 0 else 0.75
					end),
					ZIndex = 3,
					[Children] = Kit.corner(scope, "Full"),
				},
				Text(scope, {
					Name = "Live",
					Text = tostring(peek(props.Title) or "CAMERAS"),
					TextSize = 15,
					Font = HEAVY,
					Case = "Upper",
					XAlign = "Left",
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					Stroke = false,
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 18, 0, 14),
					Size = UDim2.fromOffset(math.floor(w * 0.45), 18),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = 15,
					ZIndex = 3,
				}),
				Text(scope, {
					Name = "Cam",
					Text = scope:Computed(function(use)
						return "CAM " .. tostring(use(current).Label or "")
					end),
					TextSize = 30,
					Font = DISPLAY,
					Case = "Upper",
					XAlign = "Right",
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Primary or theme.Color.Text
					end),
					Stroke = 2,
					AnchorPoint = Vector2.new(1, 0),
					Position = UDim2.fromScale(1, 0),
					Size = UDim2.new(0.5, 0, 0, 32),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = 30,
					ZIndex = 3,
				}),
				Text(scope, {
					Name = "Room",
					Text = scope:Computed(function(use)
						return tostring(use(current).Name or "")
					end),
					TextSize = 17,
					Font = "Body",
					Case = "Normal",
					XAlign = "Left",
					Stroke = false,
					AnchorPoint = Vector2.new(0, 1),
					Position = UDim2.new(0, 0, 1, -2),
					Size = UDim2.new(1, 0, 0, 20),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = 17,
					ZIndex = 3,
				}),
			},
		},
		Kit.unlessLite(scope, function(inner)
			local lines = {}
			for i = 1, math.floor((headerH - 6) / 4) do
				table.insert(lines, inner:New "Frame" {
					Name = "Scan",
					Position = UDim2.fromOffset(0, i * 4),
					Size = UDim2.new(1, 0, 0, 1),
					BackgroundColor3 = Color3.new(0, 0, 0),
					BackgroundTransparency = 0.8,
					ZIndex = 4,
				})
			end
			return inner:New "Frame" {
				Name = "Scanlines",
				BackgroundTransparency = 1,
				ClipsDescendants = true,
				Position = UDim2.fromOffset(4, 4),
				Size = UDim2.new(1, -8, 0, headerH - 4),
				ZIndex = 4,
				[Children] = lines,
			}
		end),
		scope:New "Frame" {
			Name = "Rule",
			Position = UDim2.fromOffset(pad, headerH),
			Size = UDim2.new(1, -pad * 2, 0, 1),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Light
			end),
			BackgroundTransparency = 0.8,
			ZIndex = 2,
		},
		scope:New "Frame" {
			Name = "Grid",
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(pad, headerH + pad),
			Size = UDim2.new(1, -pad * 2, 1, -(headerH + pad * 2)),
			ZIndex = 2,
			[Children] = {
				scope:New "UIGridLayout" {
					CellSize = UDim2.fromOffset(cellW, cellH),
					CellPadding = UDim2.fromOffset(gap, gap),
					FillDirection = Enum.FillDirection.Horizontal,
					SortOrder = Enum.SortOrder.LayoutOrder,
					HorizontalAlignment = Enum.HorizontalAlignment.Left,
					VerticalAlignment = Enum.VerticalAlignment.Top,
				},
				cells,
			},
		},
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="91">
            <Properties>
              <string name="Name">CaseSpinner</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	CaseSpinner: a case / crate opening. A long strip of rarity tiles races
	past a centre marker and decelerates (ease-out quint) onto the prize,
	ticking once per tile with a pitch that climbs as it slows. The winner
	then pops out of the strip over spinning rays with a rarity-coloured
	flash and poof, its name and rarity slam in, and a click closes it.

		local CaseSpinner = require(BloxUI.Components.CaseSpinner)
		local item = CaseSpinner.open({
			Case = { title = "Neon Case", icon = "gift", tone = "Purple" },
			Pool = {
				{ name = "Scrap Blade", icon = "melee", rarity = "Common", chance = 60 },
				{ name = "Pulse Rifle", icon = "ammo", rarity = "Rare", chance = 30 },
				{ name = "Void Reaver", icon = "sword", rarity = "Legendary", chance = 10 },
			},
			Winner = nil,     -- the pool entry (or its name) to land on; nil = weighted roll
			Seed = nil,       -- number: the same seed always gives the same roll
		})
		print("Unboxed", item.name)

	open() yields until the player clicks after the reveal, then returns the
	pool entry that was won. In a real game roll on the server
	(CaseSpinner.pick works there too) and pass the result as `Winner`.

	More options: Duration = 5.4 (seconds of spin), Slots = 40, SlotSize = 150,
	Parent, DisplayOrder = 970, OnReveal = function(item) end,
	AutoClose = seconds (close by itself after the reveal),
	Skip = true (the fast path by itself: the spin races to the stop as if
	the player had clicked, and the reveal closes after SKIP_HOLD seconds;
	default CaseSpinner.skip, which the Blueprint "skipHatch" setting sets).

	Helpers (no UI, safe on the server):
		CaseSpinner.pick(pool, seed?) -> entry, index     weighted by `chance`
		CaseSpinner.roll(pool, options?) -> { winner, strip, winnerIndex, landing }
		CaseSpinner.random(seed) -> () -> number          deterministic 0..1 generator
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local Lighting = game:GetService("Lighting")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Input = require(script.Parent.Parent.Util.Input)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local FXScreen = require(script.Parent.Parent.FX.Screen)
local ScreenComponent = require(script.Parent.Screen)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local ItemSlot: any = nil
do
	local module = script.Parent:FindFirstChild("ItemSlot")
	if module then
		local ok, result = pcall(require, module)
		if ok then
			ItemSlot = result
		end
	end
end

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)

-- Soft white radial glow tinted `Color`. The texture is faint, so it can be
-- stacked `Layers` times for a stronger bloom.
local GLOW = (Assets :: any).Glow or "rbxasset://textures/ui/InGameMenu/BackgroundGlow.png"
local function glow(scope: any, props: { [string]: any }): ImageLabel
	local extra = {}
	for _ = 2, props.Layers or 1 do
		table.insert(extra, scope:New "ImageLabel" {
			Name = "Layer",
			BackgroundTransparency = 1,
			Image = GLOW,
			ImageColor3 = props.Color,
			ImageTransparency = props.Transparency or 0,
			Size = UDim2.fromScale(1, 1),
			ZIndex = props.ZIndex or 0,
		})
	end
	return scope:New "ImageLabel" {
		Name = props.Name or "Glow",
		BackgroundTransparency = 1,
		Image = GLOW,
		ImageColor3 = props.Color,
		ImageTransparency = props.Transparency or 0,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position or UDim2.fromScale(0.5, 0.5),
		Size = props.Size,
		Visible = props.Visible,
		ZIndex = props.ZIndex or 0,
		[Fusion.Children] = extra,
	}
end

-- Light rays: rotated copies of Roblox's white emote-wheel wedge (bright at
-- the centre, fading out), so they tint cleanly to any colour. Spin the
-- returned frame with Motion.spin.
local WEDGE = "rbxasset://textures/ui/Emotes/Large/SelectedGradient@2x.png"
local function rays(scope: any, props: { [string]: any }): Frame
	local count = props.Count or 12
	local width = props.Width or 0.36
	local spokes = {}
	for i = 0, count - 1 do
		table.insert(spokes, scope:New "Frame" {
			Name = "Ray",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			Rotation = i * 360 / count,
			ZIndex = props.ZIndex or 0,
			[Fusion.Children] = scope:New "ImageLabel" {
				Name = "Wedge",
				BackgroundTransparency = 1,
				Image = WEDGE,
				ImageColor3 = props.Color,
				ImageTransparency = props.Transparency or 0,
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.fromScale(0.5, 0.5 * width),
				ZIndex = props.ZIndex or 0,
			},
		})
	end
	return scope:New "Frame" {
		Name = props.Name or "Rays",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position or UDim2.fromScale(0.5, 0.5),
		Size = props.Size,
		BackgroundTransparency = 1,
		Visible = props.Visible,
		ZIndex = props.ZIndex or 0,
		[Fusion.Children] = spokes,
	}
end

-- Poof (docs/RESEARCH.md 2.10): 7-9 soft circles fly out with drag, growing
-- 0.3 -> 1.3x and fading over ~0.4 s, plus one expanding ring. Drawn in
-- design px inside `parent`, centred on `at` (UDim2). Fire and forget.
local POOF_TWEEN = game:GetService("TweenService")
local function poof(parent: Instance, at: UDim2, options: { [string]: any }?)
	local opts = options or {}
	local count = opts.Count or 8
	local size = opts.Size or 34
	local reachMin, reachMax = opts.Reach and opts.Reach[1] or 40, opts.Reach and opts.Reach[2] or 90
	local colors = opts.Colors or { Color3.new(1, 1, 1) }
	local life = opts.Time or 0.42
	local zIndex = opts.ZIndex or 50
	for i = 1, count do
		local angle = (i / count) * math.pi * 2 + (math.random() - 0.5) * 0.7
		local direction = Vector2.new(math.cos(angle), math.sin(angle))
		local start = direction * math.random() * 12
		local finish = direction * (reachMin + math.random() * (reachMax - reachMin))
		local circle = Instance.new("ImageLabel")
		circle.Name = "Poof"
		circle.BackgroundTransparency = 1
		circle.Image = "rbxasset://textures/whiteCircle.png"
		circle.ImageColor3 = colors[(i - 1) % #colors + 1]
		circle.ImageTransparency = 0.1
		circle.AnchorPoint = Vector2.new(0.5, 0.5)
		circle.Position = at + UDim2.fromOffset(start.X, start.Y)
		local s = size * (0.85 + math.random() * 0.3)
		circle.Size = UDim2.fromOffset(s * 0.3, s * 0.3)
		circle.ZIndex = zIndex
		circle.Parent = parent
		local t = life * (0.85 + math.random() * 0.2)
		POOF_TWEEN:Create(circle, TweenInfo.new(t, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
			Position = at + UDim2.fromOffset(finish.X, finish.Y),
			Size = UDim2.fromOffset(s * 1.3, s * 1.3),
			ImageTransparency = 1,
		}):Play()
		task.delay(t + 0.05, function()
			circle:Destroy()
		end)
	end
	if opts.Ring ~= false then
		local ringSize = opts.RingSize or size * 3
		local ring = Instance.new("Frame")
		ring.Name = "PoofRing"
		ring.BackgroundTransparency = 1
		ring.AnchorPoint = Vector2.new(0.5, 0.5)
		ring.Position = at
		ring.Size = UDim2.fromOffset(ringSize * 0.4, ringSize * 0.4)
		ring.ZIndex = zIndex
		local corner = Instance.new("UICorner")
		corner.CornerRadius = UDim.new(0.5, 0)
		corner.Parent = ring
		local stroke = Instance.new("UIStroke")
		stroke.Thickness = opts.RingThickness or 6
		stroke.Color = opts.RingColor or colors[1]
		stroke.Transparency = 0.2
		stroke.Parent = ring
		ring.Parent = parent
		local ringTime = opts.RingTime or 0.25
		POOF_TWEEN:Create(ring, TweenInfo.new(ringTime, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
			Size = UDim2.fromOffset(ringSize * 1.6, ringSize * 1.6),
		}):Play()
		POOF_TWEEN:Create(stroke, TweenInfo.new(ringTime, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), { Transparency = 1 }):Play()
		task.delay(ringTime + 0.05, function()
			ring:Destroy()
		end)
	end
end

local CaseSpinner = {}

-- true: every case plays its fast path (see the top of the file).
CaseSpinner.skip = false
-- seconds a skipped reveal stays up before it closes by itself
CaseSpinner.SKIP_HOLD = 1.5

--------------------------------------------------------------------------
-- Deterministic randomness (mulberry32 on bit32, identical everywhere)
--------------------------------------------------------------------------

local function imul(a: number, b: number): number
	local aHi, aLo = bit32.rshift(a, 16), bit32.band(a, 0xFFFF)
	local bHi, bLo = bit32.rshift(b, 16), bit32.band(b, 0xFFFF)
	local high = bit32.band(aHi * bLo + aLo * bHi, 0xFFFF)
	return bit32.band(aLo * bLo + high * 65536, 0xFFFFFFFF)
end

-- A generator of numbers in [0, 1). Same seed, same sequence, on the client,
-- the server and in tests.
function CaseSpinner.random(seed: number?): () -> number
	local state = if seed == nil then math.random(0, 2147483646) else seed
	state = math.floor(state) % 4294967296
	return function(): number
		state = bit32.band(state + 0x6D2B79F5, 0xFFFFFFFF)
		local t = imul(bit32.bxor(state, bit32.rshift(state, 15)), bit32.bor(state, 1))
		t = bit32.bxor(bit32.band(t + imul(bit32.bxor(t, bit32.rshift(t, 7)), bit32.bor(t, 61)), 0xFFFFFFFF), t)
		return bit32.bxor(t, bit32.rshift(t, 14)) / 4294967296
	end
end

local function field(item: { [string]: any }, lower: string, upper: string): any
	local value = item[lower]
	if value == nil then
		value = item[upper]
	end
	return value
end

local function weightOf(entry: { [string]: any }): number
	local chance = tonumber(field(entry, "chance", "Chance"))
	return if chance and chance > 0 then chance else 0
end

local function weightedIndex(pool: { any }, weights: { number }, r: number): number
	local total = 0
	for _, w in weights do
		total += w
	end
	if total <= 0 then
		return math.clamp(math.floor(r * #pool) + 1, 1, #pool)
	end
	local target = r * total
	local running = 0
	for index, w in weights do
		running += w
		if target < running then
			return index
		end
	end
	for index = #weights, 1, -1 do
		if weights[index] > 0 then
			return index
		end
	end
	return #pool
end

local function exactWeights(pool: { any }): { number }
	local weights = {}
	for index, entry in pool do
		weights[index] = weightOf(entry)
	end
	return weights
end

-- Weighted pick by `chance`. With a seed the result is always the same.
function CaseSpinner.pick(pool: { any }, seed: number?): (any, number)
	assert(typeof(pool) == "table" and #pool > 0, "[BloxUI] CaseSpinner.pick: Pool is empty")
	local rng = CaseSpinner.random(seed)
	local index = weightedIndex(pool, exactWeights(pool), rng())
	return pool[index], index
end

local function resolveWinner(pool: { any }, winner: any): (any?, number?)
	if winner == nil then
		return nil, nil
	end
	for index, entry in pool do
		if entry == winner then
			return entry, index
		end
	end
	local name = if typeof(winner) == "table" then field(winner, "name", "Name") else winner
	for index, entry in pool do
		if field(entry, "name", "Name") == name then
			return entry, index
		end
	end
	if typeof(winner) == "table" then
		return winner, nil
	end
	return nil, nil
end

export type Plan = {
	winner: any,
	strip: { any },
	winnerIndex: number,
	landing: number, -- where on the winning tile the marker stops, -0.5..0.5
	nearMiss: { index: number, side: string }?, -- a rarer tile right beside the winner ("past" / "short")
}

local RARITY_RANK = { Common = 1, Uncommon = 2, Rare = 3, Epic = 4, Legendary = 5, Mythic = 6, Secret = 7 }

-- Everything the spin needs, decided up front. The winner uses the exact
-- chances (first draw of the seed, same as pick); filler tiles use flattened
-- chances so the strip shows off the whole case. Now and then (NearMiss,
-- default chance 0.35) the rarest item that beats the winner sits right
-- next to it and the strip stops just past it, or just short of it.
function CaseSpinner.roll(pool: { any }, options: { [string]: any }?): Plan
	local opts = options or {}
	assert(typeof(pool) == "table" and #pool > 0, "[BloxUI] CaseSpinner: Pool is empty")
	local rng = CaseSpinner.random(opts.Seed)
	local firstDraw = rng()
	local winner = resolveWinner(pool, opts.Winner)
	if winner == nil then
		winner = pool[weightedIndex(pool, exactWeights(pool), firstDraw)]
	end

	local count = math.max(12, opts.Slots or 40)
	local winnerIndex = count - 6 + math.floor(rng() * 3)

	local display = {}
	for index, entry in pool do
		display[index] = math.sqrt(weightOf(entry))
	end
	local strip = {}
	for i = 1, count do
		if i == winnerIndex then
			strip[i] = winner
		else
			strip[i] = pool[weightedIndex(pool, display, rng())]
		end
	end
	local landing = (rng() * 2 - 1) * 0.36

	local nearMiss = nil
	if opts.NearMiss ~= false then
		local chanceRoll, sideRoll, depthRoll = rng(), rng(), rng()
		local winnerRank = RARITY_RANK[field(winner, "rarity", "Rarity") or "Common"] or 1
		local rarest, rarestRank = nil, winnerRank
		for _, entry in pool do
			local rank = RARITY_RANK[field(entry, "rarity", "Rarity") or "Common"] or 1
			if rank > rarestRank and weightOf(entry) > 0 then
				rarest, rarestRank = entry, rank
			end
		end
		local odds = if typeof(opts.NearMiss) == "number" then opts.NearMiss else 0.35
		if rarest and chanceRoll < odds then
			local edge = 0.3 + depthRoll * 0.06 -- 0.3 .. 0.36 of a tile from the centre
			if sideRoll < 0.5 and winnerIndex > 1 then
				-- it crawled over the rare one and stopped just after it
				strip[winnerIndex - 1] = rarest
				landing = -edge
				nearMiss = { index = winnerIndex - 1, side = "past" }
			elseif winnerIndex < count then
				-- it stopped a hair before the rare one
				strip[winnerIndex + 1] = rarest
				landing = edge
				nearMiss = { index = winnerIndex + 1, side = "short" }
			end
		end
	end

	return { winner = winner, strip = strip, winnerIndex = winnerIndex, landing = landing, nearMiss = nearMiss }
end

--------------------------------------------------------------------------
-- Rarity helpers
--------------------------------------------------------------------------

local RANK = { Common = 1, Uncommon = 2, Rare = 3, Epic = 4, Legendary = 5, Mythic = 6, Secret = 7 }
local FALLBACK_HEX = {
	Common = "B0B7C3",
	Uncommon = "5BE22B",
	Rare = "3AA8FF",
	Epic = "B45CFF",
	Legendary = "FFB300",
	Mythic = "FF3B6B",
	Secret = "E9E2FF",
}

local function rarityTone(theme: any, name: string?): any
	local key = name or "Common"
	local themeRarity = (Theme :: any).rarity
	if themeRarity then
		return themeRarity(theme, key)
	end
	if theme.Rarity and theme.Rarity[key] then
		return theme.Rarity[key]
	end
	local base = Color3.fromHex(FALLBACK_HEX[key] or FALLBACK_HEX.Common)
	return {
		Top = Color.lighten(base, 0.3),
		Bottom = base,
		Edge = Color.darken(base, 0.45),
		Deep = Color.darken(base, 0.72),
		Text = WHITE,
		Stroke = Color.darken(base, 0.58),
	}
end

local function rankOf(name: string?): number
	return RANK[name or "Common"] or 1
end

-- Simple tile used only if ItemSlot isn't installed.
local function fallbackSlot(scope: any, props: { [string]: any }): GuiObject
	local tone = rarityTone(Theme.get(), props.Rarity)
	return scope:New "Frame" {
		Name = "ItemSlot",
		Size = UDim2.fromOffset(props.Size, props.Size),
		AnchorPoint = props.AnchorPoint,
		Position = props.Position,
		BackgroundColor3 = WHITE,
		ZIndex = props.ZIndex,
		[Children] = {
			Kit.corner(scope, "M"),
			Kit.gradient(scope, { Rotation = 90, Color = ColorSequence.new(tone.Top, tone.Bottom) }),
			Kit.stroke(scope, { Color = tone.Edge, Thickness = 3 }),
			Icon(scope, {
				Icon = props.Icon,
				Size = math.floor(props.Size * 0.55),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, if props.Name then 0.42 else 0.5),
				Color = WHITE,
				StrokeColor = tone.Stroke or tone.Edge,
				Shadow = true,
			}),
			if props.Name
				then Text(scope, {
					Text = props.Name,
					TextSize = math.floor(props.Size * 0.15),
					Font = "Display",
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -6),
					StrokeColor = tone.Stroke or tone.Edge,
				})
				else nil,
		},
	}
end

local function makeSlot(scope: any, props: { [string]: any }): GuiObject
	if ItemSlot then
		return ItemSlot(scope, props)
	end
	return fallbackSlot(scope, props)
end

local function themedSound(names: { string }): string?
	local sounds = Theme.get().Sound or {}
	for _, name in names do
		local id = sounds[name]
		if (typeof(id) == "string" and id ~= "") or typeof(id) == "number" then
			return name
		end
	end
	return nil
end

--------------------------------------------------------------------------
-- The opener
--------------------------------------------------------------------------

export type Options = {
	Case: { [string]: any }?,
	Pool: { any },
	Winner: any?,
	Seed: number?,
	Duration: number?,
	Slots: number?,
	SlotSize: number?,
	Parent: Instance?,
	DisplayOrder: number?,
	OnReveal: ((any) -> ())?,
	AutoClose: number?,
	Skip: boolean?, -- play the fast path by itself (default CaseSpinner.skip)
	Skippable: boolean?, -- a press mid-spin fast-forwards to the stop (default true)
	NearMiss: (boolean | number)?, -- false, or the chance (0.35) of stopping beside a rarer item
}

local STRIP_WIDTH = 1560
local STRIP_HEIGHT = 236
local STRIP_Y = -20

-- Opens the case. Yields until the player closes the reveal, then returns
-- the pool entry they won.
function CaseSpinner.open(options: Options): any
	local opts = options or ({} :: any)
	local pool = opts.Pool or {}
	local plan = CaseSpinner.roll(pool, opts)
	local winner = plan.winner
	if Players.LocalPlayer == nil and opts.Parent == nil then
		-- nobody to watch it (Studio edit mode, plugin preview)
		if opts.OnReveal then
			task.spawn(opts.OnReveal, winner)
		end
		return winner
	end
	local case = opts.Case or {}
	local caseTitle = field(case, "title", "Title") or "Case"
	local caseIcon = field(case, "icon", "Icon") or "gift"
	local caseTone = field(case, "tone", "Tone") or "Purple"
	local slotSize = opts.SlotSize or 150
	local pitch = slotSize + 14
	local duration = opts.Duration or 5.4
	local winnerName = tostring(field(winner, "name", "Name") or "")
	local winnerRarity = field(winner, "rarity", "Rarity") or "Common"
	local winnerRank = rankOf(winnerRarity)

	local scope = Fusion.scoped(Fusion)
	local alive = true
	table.insert(scope, function()
		alive = false
	end)

	local windowWidth = STRIP_WIDTH - 20
	local half = windowWidth / 2

	-- state
	local appearGoal = scope:Value(0)
	local appear = scope:Spring(appearGoal, 20, 0.75)
	local rowX = scope:Value(0)
	local centreIndex = scope:Value(0)
	local revealed = scope:Value(false)
	local ready = scope:Value(false)
	local markerBumpGoal = scope:Value(1)
	local markerBump = scope:Spring(markerBumpGoal, 40, 0.45)
	local heroGoal = scope:Value(0)
	local hero = scope:Spring(heroGoal, 20, 0.45)
	local textGoal = scope:Value(0)
	local textIn = scope:Spring(textGoal, 18, 0.6)
	local hintGoal = scope:Value(0)
	local hint = scope:Spring(hintGoal, 10, 1)
	local dimGoal = scope:Value(0)
	local dim = scope:Spring(dimGoal, 10, 1)
	local rarityShown = scope:Value(0)

	local winnerTone = scope:Computed(function(use)
		return rarityTone(use(Theme.current), winnerRarity)
	end)
	local caseToneState = Kit.tone(scope, caseTone)

	local continued = Instance.new("BindableEvent")
	table.insert(scope, continued)
	local closing = false
	-- the fast path: as if the player clicked straight away, then closes alone
	local skipAll = if opts.Skip ~= nil then opts.Skip == true else CaseSpinner.skip == true
	local skipping = skipAll
	local autoClose = opts.AutoClose or (if skipAll then CaseSpinner.SKIP_HOLD else nil)
	local function continue()
		if peek(ready) and not closing then
			closing = true
			continued:Fire()
		elseif not peek(revealed) and opts.Skippable ~= false then
			-- impatient: the rest of the spin plays fast (it still lands and ticks)
			skipping = true
		end
	end

	-- strip tiles
	local tiles = {}
	for i, entry in plan.strip do
		local isCentre = scope:Computed(function(use)
			return use(centreIndex) == i
		end)
		local isWinner = i == plan.winnerIndex
		local tileScale = scope:Spring(scope:Computed(function(use)
			if use(revealed) then
				return if isWinner then 1.08 else 0.94
			end
			return if use(isCentre) then 1.06 else 1
		end), 30, 0.6)
		table.insert(tiles, scope:New "Frame" {
			Name = `Tile{i}`,
			-- centred anchor so the reveal scale grows around the middle
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0, (i - 1) * pitch + slotSize / 2, 0.5, 0),
			Size = UDim2.fromOffset(slotSize, slotSize),
			BackgroundTransparency = 1,
			[Children] = {
				scope:New "UIScale" { Scale = tileScale },
				makeSlot(scope, {
					Icon = field(entry, "icon", "Icon") or "gift",
					Name = field(entry, "name", "Name"),
					Rarity = field(entry, "rarity", "Rarity") or "Common",
					Size = slotSize,
				}),
				scope:New "Frame" {
					Name = "Dim",
					Size = UDim2.fromScale(1, 1),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Backdrop
					end),
					BackgroundTransparency = scope:Computed(function(use)
						return if isWinner then 1 else 1 - 0.6 * math.clamp(use(dim), 0, 1)
					end),
					ZIndex = 20,
					[Children] = Kit.corner(scope, "M"),
				},
			},
		})
	end

	local row = scope:New "Frame" {
		Name = "Row",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0, 0.5),
		Position = scope:Computed(function(use)
			return UDim2.new(0, use(rowX), 0.5, 0)
		end),
		Size = UDim2.new(0, #plan.strip * pitch, 1, 0),
		[Children] = tiles,
	}

	local function edgeFade(side: "Left" | "Right")
		return scope:New "Frame" {
			Name = `Fade{side}`,
			AnchorPoint = Vector2.new(if side == "Left" then 0 else 1, 0),
			Position = UDim2.fromScale(if side == "Left" then 0 else 1, 0),
			Size = UDim2.new(0, 300, 1, 0),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
			ZIndex = 30,
			[Children] = scope:New "UIGradient" {
				Rotation = if side == "Left" then 0 else 180,
				Transparency = NumberSequence.new({
					NumberSequenceKeypoint.new(0, 0),
					NumberSequenceKeypoint.new(0.35, 0.35),
					NumberSequenceKeypoint.new(1, 1),
				}),
			},
		}
	end

	local accent = Kit.themed(scope, function(theme)
		return theme.Color.Accent
	end)

	local window = scope:New "Frame" {
		Name = "Window",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.new(1, -20, 1, -20),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Surface
		end),
		BackgroundTransparency = 0,
		ClipsDescendants = true,
		ZIndex = 3,
		[Children] = {
			Kit.corner(scope, "M"),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = ColorSequence.new({
					ColorSequenceKeypoint.new(0, Color3.fromRGB(150, 150, 165)),
					ColorSequenceKeypoint.new(0.5, WHITE),
					ColorSequenceKeypoint.new(1, Color3.fromRGB(150, 150, 165)),
				}),
			}),
			row,
			edgeFade("Left"),
			edgeFade("Right"),
			scope:New "Frame" {
				Name = "MarkerGlow",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.new(0, 56, 1, 0),
				BackgroundColor3 = accent,
				BackgroundTransparency = scope:Computed(function(use)
					return if use(revealed) then 1 else 0
				end),
				ZIndex = 39,
				[Children] = scope:New "UIGradient" {
					Transparency = NumberSequence.new({
						NumberSequenceKeypoint.new(0, 1),
						NumberSequenceKeypoint.new(0.5, 0.45),
						NumberSequenceKeypoint.new(1, 1),
					}),
				},
			},
			scope:New "Frame" {
				Name = "MarkerLine",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.new(0, 4, 1, 0),
				BackgroundColor3 = accent,
				BackgroundTransparency = scope:Computed(function(use)
					return if use(revealed) then 1 else 0
				end),
				ZIndex = 40,
				[Children] = Kit.corner(scope, 2),
			},
			-- the strip is for looking at: this keeps hover effects off the tiles
			scope:New "TextButton" {
				Name = "Shield",
				Size = UDim2.fromScale(1, 1),
				BackgroundTransparency = 1,
				Text = "",
				AutoButtonColor = false,
				Selectable = false,
				ZIndex = 50,
				[OnEvent "Activated"] = continue,
			},
		},
	}

	local function caret(top: boolean)
		return Icon(scope, {
			Name = if top then "CaretTop" else "CaretBottom",
			Icon = if top then "caret-small-down" else "caret-small-up",
			Size = 64,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5, 0, if top then 0 else 1, if top then 6 else -6),
			Color = accent,
			Gradient = true,
			Stroke = 3,
			Shadow = true,
			ZIndex = 60,
			[Children] = scope:New "UIScale" { Scale = markerBump },
		})
	end

	local strip = scope:New "Frame" {
		Name = "Strip",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = scope:Computed(function(use)
			return UDim2.new(0.5, 0, 0.5, STRIP_Y + (1 - use(appear)) * 40)
		end),
		Size = UDim2.fromOffset(STRIP_WIDTH, STRIP_HEIGHT),
		BackgroundTransparency = 1,
		ZIndex = 2,
		[Children] = {
			scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					return 0.9 + 0.1 * use(appear)
				end),
			},
			Kit.shadow(scope, { Spread = 30, Offset = Vector2.new(0, 12), ZIndex = 1 }),
			scope:New "Frame" {
				Name = "Frame",
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.SurfaceLight
				end),
				ZIndex = 2,
				[Children] = {
					Kit.corner(scope, "L"),
					Kit.stroke(scope, { Thickness = 3 }),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = ColorSequence.new(WHITE, Color3.fromRGB(120, 120, 135)),
					}),
				},
			},
			window,
			caret(true),
			caret(false),
		},
	}

	-- header: case medallion + title
	local header = scope:New "Frame" {
		Name = "Header",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = scope:Computed(function(use)
			return UDim2.new(0.5, 0, 0.5, STRIP_Y - STRIP_HEIGHT / 2 - 40 - (1 - use(appear)) * 30)
		end),
		Size = UDim2.fromOffset(900, 96),
		BackgroundTransparency = 1,
		ZIndex = 11, -- above the reveal rays
		[Children] = {
			Kit.list(scope, { Direction = "X", Padding = 20, HAlign = "Center", VAlign = "Center" }),
			scope:New "Frame" {
				Name = "CaseMedallion",
				Size = UDim2.fromOffset(92, 92),
				BackgroundColor3 = WHITE,
				LayoutOrder = 1,
				ZIndex = 5,
				[Children] = {
					Kit.corner(scope, "L"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = scope:Computed(function(use)
							local t = use(caseToneState)
							return ColorSequence.new(Color.lighten(t.Top, 0.1), t.Bottom)
						end),
					}),
					Kit.stroke(scope, {
						Thickness = 3,
						Color = scope:Computed(function(use)
							return use(caseToneState).Edge
						end),
					}),
					scope:New "Frame" {
						Name = "Gloss",
						Position = UDim2.fromOffset(5, 4),
						Size = UDim2.new(1, -10, 0.42, 0),
						BackgroundColor3 = WHITE,
						BackgroundTransparency = 0.55,
						ZIndex = 6,
						[Children] = {
							Kit.corner(scope, "M"),
							Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0.2, 1) }),
						},
					},
					Icon(scope, {
						Icon = caseIcon,
						Size = 58,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = WHITE,
						Stroke = 3,
						StrokeColor = scope:Computed(function(use)
							local t = use(caseToneState)
							return t.Stroke or t.Edge
						end),
						Shadow = true,
						ZIndex = 7,
					}),
				},
			},
			scope:New "Frame" {
				Name = "Titles",
				Size = UDim2.fromOffset(0, 92),
				AutomaticSize = Enum.AutomaticSize.X,
				BackgroundTransparency = 1,
				LayoutOrder = 2,
				ZIndex = 5,
				[Children] = {
					Kit.list(scope, { Direction = "Y", Padding = 0, HAlign = "Left", VAlign = "Center" }),
					Text(scope, {
						Name = "CaseTitle",
						Text = string.upper(tostring(caseTitle)),
						TextSize = 56,
						Font = "Title",
						Stroke = 4,
						XAlign = "Left",
						LayoutOrder = 1,
						ZIndex = 6,
					}),
					Text(scope, {
						Name = "Status",
						Text = scope:Computed(function(use)
							return if use(revealed) then "UNBOXED!" else "OPENING..."
						end),
						TextSize = 24,
						Font = "Heavy",
						Stroke = 2,
						XAlign = "Left",
						Color = scope:Computed(function(use)
							if use(revealed) then
								return Color.lighten(use(winnerTone).Top, 0.2)
							end
							return use(Theme.current).Color.TextMuted
						end),
						LayoutOrder = 2,
						ZIndex = 6,
					}),
				},
			},
		},
	}

	-- hero: the prize pops out of the strip over rays
	local heroSize = 236
	local heroRays = rays(scope, {
		Color = scope:Computed(function(use)
			return Color.lighten(use(winnerTone).Top, 0.2)
		end),
		Transparency = scope:Computed(function(use)
			return 1 - 0.5 * math.clamp(use(hero), 0, 1)
		end),
		Size = UDim2.fromOffset(820, 820),
		Count = 12,
		ZIndex = 1,
	})
	local heroSlot = makeSlot(scope, {
		Name = nil,
		Icon = field(winner, "icon", "Icon") or "gift",
		Rarity = winnerRarity,
		Size = heroSize,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		ZIndex = 3,
		OnClick = continue,
	})
	local heroFrame = scope:New "Frame" {
		Name = "Hero",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.new(0.5, 0, 0.5, STRIP_Y),
		Size = UDim2.fromOffset(heroSize, heroSize),
		BackgroundTransparency = 1,
		Visible = revealed,
		ZIndex = 10,
		[Children] = {
			scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					return math.max(0, use(hero))
				end),
			},
			heroRays,
			glow(scope, {
				Color = scope:Computed(function(use)
					return Color.lighten(use(winnerTone).Top, 0.1)
				end),
				Size = UDim2.new(1, 380, 1, 380),
				Layers = 3,
				ZIndex = 2,
			}),
			heroSlot,
		},
	}

	-- reveal text: name + rarity pill
	local textAlpha = scope:Computed(function(use)
		return 1 - math.clamp(use(textIn), 0, 1)
	end)
	local reveal = scope:New "Frame" {
		Name = "Reveal",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = scope:Computed(function(use)
			return UDim2.new(0.5, 0, 0.5, STRIP_Y + STRIP_HEIGHT / 2 + 44 + (1 - use(textIn)) * 30)
		end),
		Size = UDim2.fromOffset(1200, 140),
		BackgroundTransparency = 1,
		Visible = revealed,
		ZIndex = 12,
		[Children] = {
			Text(scope, {
				Name = "PrizeName",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.fromScale(0.5, 0),
				Text = string.upper(winnerName),
				TextSize = 64,
				Font = "Title",
				Stroke = 5,
				StrokeColor = scope:Computed(function(use)
					local t = use(winnerTone)
					return t.Stroke or t.Edge
				end),
				Gradient = scope:Computed(function(use)
					local t = use(winnerTone)
					if t.Rainbow or t.Shimmer then
						return t.Rainbow or t.Shimmer
					end
					return ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.6)),
						ColorSequenceKeypoint.new(0.5, Color.lighten(t.Top, 0.15)),
						ColorSequenceKeypoint.new(1, t.Bottom),
					})
				end),
				GradientRotation = if winnerRarity == "Secret" then 0 else 90,
				Transparency = textAlpha,
				ZIndex = 13,
			}),
			glow(scope, {
				Name = "RarityGlow",
				Color = scope:Computed(function(use)
					return use(winnerTone).Top
				end),
				Position = UDim2.new(0.5, 0, 0, 100),
				Size = UDim2.fromOffset(420, 150),
				Transparency = textAlpha,
				Layers = 2,
				ZIndex = 12,
			}),
			scope:New "Frame" {
				Name = "RarityPill",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 0, 0, 100),
				Size = UDim2.fromOffset(0, 40),
				AutomaticSize = Enum.AutomaticSize.X,
				BackgroundColor3 = WHITE,
				BackgroundTransparency = textAlpha,
				ZIndex = 13,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.padding(scope, { 0, 22 }),
					Kit.gradient(scope, {
						Rotation = scope:Computed(function(use)
							return if use(winnerTone).Rainbow then 0 else 90
						end),
						Color = scope:Computed(function(use)
							local t = use(winnerTone)
							return t.Rainbow or ColorSequence.new(Color.lighten(t.Top, 0.1), t.Bottom)
						end),
					}),
					scope:New "UIStroke" {
						ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
						Thickness = 3,
						Color = scope:Computed(function(use)
							return use(winnerTone).Edge
						end),
						Transparency = textAlpha,
					},
					Text(scope, {
						Name = "RarityText",
						Text = string.upper(tostring(winnerRarity)),
						MaxVisibleGraphemes = rarityShown,
						TextSize = 24,
						Font = "Heavy",
						Stroke = 2,
						StrokeColor = scope:Computed(function(use)
							local t = use(winnerTone)
							return t.Stroke or t.Edge
						end),
						Transparency = textAlpha,
						Size = UDim2.fromScale(0, 1),
						AutoSize = "X",
						ZIndex = 14,
					}),
				},
			},
		},
	}

	local hintLabel = Text(scope, {
		Name = "Hint",
		Text = scope:Computed(function(use)
			local device = use(Input.device)
			local verb = if use(revealed) then "continue" else "skip"
			if device == "Gamepad" then
				return `Press A to {verb}`
			elseif device == "Touch" then
				return `Tap to {verb}`
			end
			return if use(revealed) then "Click anywhere to continue" else "Click to skip"
		end),
		TextSize = 24,
		Font = "Body",
		Stroke = 2,
		Color = Kit.themed(scope, function(theme)
			return theme.Color.TextMuted
		end),
		Transparency = scope:Computed(function(use)
			return 1 - math.clamp(use(hint), 0, 1)
		end),
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -64),
		ZIndex = 12,
	})

	-- poofs and rings draw here, above everything, in design px
	local fxLayer = scope:New "Frame" {
		Name = "FxLayer",
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		ZIndex = 40,
	}

	local backdropAlpha = scope:Computed(function(use)
		local theme = use(Theme.current)
		return 1 - math.clamp(use(appear), 0, 1) * math.min(0.82, 1 - theme.Alpha.Backdrop + 0.25)
	end)

	local parent = opts.Parent
	if parent == nil then
		local player = Players.LocalPlayer
		parent = if player then player:FindFirstChildOfClass("PlayerGui") else nil
	end

	local gui = ScreenComponent(scope, {
		Name = "BloxUI_CaseSpinner",
		DisplayOrder = opts.DisplayOrder or 970,
		SafeArea = "None",
		Parent = parent,
		[Children] = {
			scope:New "TextButton" {
				Name = "Backdrop",
				Size = UDim2.fromScale(1, 1),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Text = "",
				AutoButtonColor = false,
				Selectable = false,
				Modal = true,
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Backdrop
				end),
				BackgroundTransparency = backdropAlpha,
				ZIndex = 1,
				[OnEvent "Activated"] = continue,
			},
			header,
			strip,
			heroFrame,
			reveal,
			hintLabel,
			fxLayer,
		},
	})

	scope:New "BlurEffect" {
		Name = "BloxUICaseBlur",
		Size = scope:Computed(function(use)
			return math.max(0, use(appear)) * 18
		end),
		Parent = Lighting,
	}

	Input.bind(scope, { Enum.KeyCode.Space, Enum.KeyCode.Return, Enum.KeyCode.ButtonA, Enum.KeyCode.E }, continue)

	--------------------------------------------------------------------
	-- Timeline
	--------------------------------------------------------------------

	appearGoal:set(1)
	Sound.play("Open")

	local startX = half - pitch * 2.5 - slotSize / 2
	local landX = half - ((plan.winnerIndex - 1) * pitch + slotSize / 2 + plan.landing * slotSize)
	local finalX = half - ((plan.winnerIndex - 1) * pitch + slotSize / 2)
	rowX:set(startX)

	task.wait(0.45)
	if opts.Skippable ~= false then
		-- a faint "click to skip" while it spins
		task.delay(0.6, function()
			if alive and not peek(revealed) then
				hintGoal:set(0.55)
			end
		end)
	end

	-- The spin: ease-out quint, so it races, then crawls the last few tiles.
	-- Each tile that crosses the marker ticks; the tick climbs in pitch and
	-- volume as the strip slows (the ticks thin out, the tension doesn't).
	local tickName = themedSound({ "CaseTick", "Tick" })
	local lastIndex = -1
	local lastSound = 0
	local elapsed = 0
	local nearTile = plan.nearMiss and plan.nearMiss.index
	while alive and gui.Parent do
		local t = math.clamp(elapsed / duration, 0, 1)
		local eased = 1 - (1 - t) ^ 5
		local x = startX + (landX - startX) * eased
		rowX:set(x)
		local index = math.floor((half - x) / pitch) + 1
		if index ~= lastIndex then
			lastIndex = index
			centreIndex:set(index)
			local now = os.clock()
			if now - lastSound > 0.045 then
				lastSound = now
				markerBump:setPosition(if index == nearTile then 1.32 else 1.18)
				if tickName then
					Sound.play(tickName, { Pitch = 0.8 + 0.75 * t ^ 1.5, Vary = 0.02, Volume = 0.55 + 0.35 * t })
				end
			end
		end
		if t >= 1 then
			break
		end
		local dt = RunService.RenderStepped:Wait()
		elapsed += dt * (if skipping then 5 else 1)
	end

	-- settle onto the centre of the tile
	local settleStart = os.clock()
	while alive and gui.Parent do
		local t = math.clamp((os.clock() - settleStart) / (if skipping then 0.16 else 0.32), 0, 1)
		local eased = t * t * (3 - 2 * t)
		rowX:set(landX + (finalX - landX) * eased)
		if t >= 1 then
			break
		end
		RunService.RenderStepped:Wait()
	end

	if not alive or gui.Parent == nil then
		Fusion.doCleanup(scope)
		return winner
	end

	-- reveal: the prize pops out (0.6 -> 1, speed 20 / damping 0.45, about
	-- 20% overshoot), with a white flash, a poof and rays. Rarer prizes add
	-- sparkles, a ring echo, camera shake and confetti, and can have their
	-- own sound ("RevealLegendary", "RevealMythic" ... theme slots).
	-- (A Fusion spring only takes in a new goal on its next frame, which
	-- would undo a jump made in the same frame: aim first, jump a frame on.)
	heroGoal:set(1)
	RunService.RenderStepped:Wait()
	if not alive or gui.Parent == nil then
		Fusion.doCleanup(scope)
		return winner
	end
	centreIndex:set(plan.winnerIndex)
	revealed:set(true)
	hintGoal:set(0)
	hint:setPosition(0)
	dimGoal:set(1)
	hero:setPosition(0.6)
	local stopSpin = Motion.spin(heroRays, 9)
	table.insert(scope, stopSpin)

	local theme = Theme.get()
	local tone = rarityTone(theme, winnerRarity)
	local colors = { tone.Top, WHITE, Color.lighten(tone.Top, 0.5), tone.Bottom }
	local revealSound = themedSound({ `Reveal{winnerRarity}`, "Reveal", "Reward" })
	if revealSound then
		Sound.play(revealSound, { Vary = 0 })
	end
	if tickName then
		Sound.play(tickName, { Pitch = 1.8, Vary = 0, Volume = 1 })
	end
	FXScreen.flash(Color.lighten(tone.Top, 0.7), 0.2, if winnerRank >= 5 then 0.6 else 0.45)
	local heroAt = UDim2.new(0.5, 0, 0.5, STRIP_Y)
	poof(fxLayer, heroAt, {
		Count = if winnerRank >= 5 then 9 else 8,
		Size = 74,
		Reach = { 110, 210 },
		Colors = colors,
		RingSize = heroSize * 1.2,
		RingThickness = 8,
		RingColor = Color.lighten(tone.Top, 0.3),
		Time = 0.45,
	})
	if winnerRank >= 3 then
		Particles.burst(heroSlot, { Count = 6 + winnerRank * 3, Shape = "sparkle", Colors = colors, Size = 34, Speed = 700, Gravity = 150 })
	end
	if winnerRank >= 4 then
		task.delay(0.09, function()
			if alive then
				poof(fxLayer, heroAt, { Count = 0, RingSize = heroSize * 1.7, RingThickness = 5, RingColor = WHITE, RingTime = 0.32 })
			end
		end)
		FXScreen.cameraShake(if winnerRank >= 5 then 0.35 else 0.2, 0.3)
	end
	if winnerRank >= 5 then
		Particles.confetti({ Count = if winnerRank >= 6 then 110 else 70, Colors = colors })
	end
	if opts.OnReveal then
		task.spawn(opts.OnReveal, winner)
	end

	-- the rarity label types in
	task.delay(0.2, function()
		local total = #tostring(winnerRarity)
		for n = 1, total do
			if not alive then
				return
			end
			rarityShown:set(n)
			task.wait(0.035)
		end
	end)

	task.delay(0.15, function()
		if alive then
			textGoal:set(1)
		end
	end)
	task.delay(0.7, function()
		if alive then
			hintGoal:set(1)
			ready:set(true)
		end
	end)
	if autoClose then
		task.delay(autoClose, function()
			if alive then
				ready:set(true)
				continue()
			end
		end)
	end

	continued.Event:Wait()

	Sound.play("Close")
	appearGoal:set(0)
	heroGoal:set(0)
	textGoal:set(0)
	hintGoal:set(0)
	task.wait(0.3)
	Fusion.doCleanup(scope)
	return winner
end

return CaseSpinner
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="92">
            <Properties>
              <string name="Name">Clicker</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Clicker: the big "TAP!" button of a clicker / simulator, in one of four looks.

	scope:Clicker {
		Text = "TAP!",
		Icon = "coin",
		Tone = "Yellow",
		OnClick = function() end,
		Style = "Button",       -- "Button" (the big glossy button; the original)
		                        -- | "Blob" (a squishy blob: squash and stretch)
		                        -- | "Orb" (a glowing orb that rings out on every tap)
		                        -- | "Capsule" (a tall capsule with a combo meter)
		Kit = "Chunky Toy",
		Compact = false,
		MakeButton = function(scope, props) return scope:Button(props) end,  -- Button look
		Sound = "Click",        -- "" for silent (previews)
	}

	The root has a fixed size (and a HudSize attribute) for HUD layouts.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local P = require(script.Parent.HudParts)
local Button = require(script.Parent.Button)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

local function label(scope: Scope, T: { [string]: any }, look: any, tone: any, props: { [any]: any }, size: number, extra: { [any]: any })
	local p = {
		Name = "Label",
		Role = "Number",
		Text = props.Text or "TAP!",
		TextSize = size,
		Color = scope:Computed(function(use)
			return use(tone).Text
		end),
		Stroke = math.max(2, math.floor(size / 14)),
		StrokeColor = scope:Computed(function(use)
			local t = use(tone)
			return t.Stroke or t.Edge
		end),
		StrokeTransparency = 0,
		ZIndex = 8,
	}
	for k, v in extra do
		p[k] = v
	end
	return P.text(scope, T, look, p)
end

local BUILD = {}

-- The original: the big glossy Button.
function BUILD.Button(scope: Scope, props: { [any]: any }, T: { [string]: any }): Instance
	local size = Vector2.new(if props.Compact then 280 else 320, if props.Compact then 100 else 112)
	local kitted = P.kitted(props.Kit)
	local buttonProps = {
		Name = props.Name or "Clicker",
		Text = props.Text,
		Icon = props.Icon,
		Tone = props.Tone or "Yellow",
		Size = UDim2.fromOffset(size.X, size.Y),
		TextSize = 50,
		IconSize = 64,
		Shine = true,
		-- with a kit: its button style, corners and number type
		Style = if kitted then P.buttonStyle(T) else nil,
		Radius = if kitted then P.buttonRadius(T) else nil,
		Font = if kitted then T.numberFont else nil,
		Position = props.Position,
		AnchorPoint = props.AnchorPoint,
		LayoutOrder = props.LayoutOrder,
		Visible = props.Visible,
	}
	local gui
	if props.MakeButton then
		gui = props.MakeButton(scope, buttonProps)
	else
		buttonProps.OnClick = props.OnClick
		gui = Button(scope, buttonProps)
	end
	gui:SetAttribute("HudSize", size)
	return gui
end

-- A squishy blob: squash on press, stretch on release, a gloss eye and a jelly wobble.
function BUILD.Blob(scope: Scope, props: { [any]: any }, T: { [string]: any }): Instance
	local size = Vector2.new(if props.Compact then 200 else 230, if props.Compact then 170 else 196)
	local look = P.look(scope, T)
	local tone = Kit.tone(scope, props.Tone or "Yellow")
	local squash = scope:Spring(scope:Value(Vector2.one), 18, 0.28)
	local body = Vector2.new(size.X - 20, size.Y - 44)
	-- a slow jelly breathe (idle; set up once the body exists)
	local wobble = scope:Value(0.5)
	local root = P.button(scope, {
		Name = props.Name or "Clicker",
		Size = UDim2.fromOffset(size.X, size.Y),
		Position = props.Position,
		AnchorPoint = props.AnchorPoint,
		LayoutOrder = props.LayoutOrder,
		Visible = props.Visible,
		HoverScale = 1.04,
		PressScale = 1,
		Radius = "Full",
		Sound = props.Sound,
		Action = true,
		OnPress = function()
			squash:setPosition(Vector2.new(1.14, 0.84))
		end,
		OnRelease = function()
			squash:setPosition(Vector2.new(0.9, 1.12))
		end,
		OnClick = props.OnClick,
		[Children] = {
			scope:New "Frame" {
				Name = "Body",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(0.5, 0, 1, -4),
				Size = scope:Computed(function(use)
					local s = use(squash)
					local w = (use(wobble) - 0.5) * 0.035
					return UDim2.fromOffset(math.floor(body.X * s.X * (1 + w)), math.floor(body.Y * s.Y * (1 - w)))
				end),
				ZIndex = 2,
				[Children] = {
					scope:New "Frame" {
						Name = "Shadow",
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.new(0.5, 0, 1, 2),
						Size = UDim2.new(0.72, 0, 0, 16),
						BackgroundColor3 = scope:Computed(function(use)
							return use(Theme.current).Color.Outline
						end),
						BackgroundTransparency = 0.72,
						ZIndex = 2,
						[Children] = scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
					},
					scope:New "Frame" {
						Name = "Jelly",
						Size = UDim2.fromScale(1, 1),
						BackgroundColor3 = WHITE,
						ZIndex = 3,
						[Children] = {
							scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
							scope:New "UIGradient" {
								Rotation = 90,
								Color = scope:Computed(function(use)
									return Kit.faceColors(use(tone), 0.15)
								end),
							},
							scope:New "UIStroke" {
								Color = scope:Computed(function(use)
									local t = use(tone)
									return if use(look).kind == "Paper" then use(look).stroke else t.Edge:Lerp(use(Theme.current).Color.Outline, 0.4)
								end),
								Thickness = 4,
							},
							Kit.unlessLite(scope, function(inner)
								return inner:New "Frame" {
									Name = "Shine",
									BackgroundColor3 = WHITE,
									BackgroundTransparency = 0.45,
									Position = UDim2.fromScale(0.16, 0.1),
									Size = UDim2.fromScale(0.34, 0.2),
									Rotation = -18,
									ZIndex = 4,
									[Children] = {
										inner:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
										inner:New "UIGradient" { Rotation = 90, Transparency = NumberSequence.new(0, 0.8) },
									},
								}
							end),
							Icon(scope, {
								Icon = props.Icon or "coin",
								Size = math.floor(body.Y * 0.5),
								AnchorPoint = Vector2.new(0.5, 0.5),
								Position = UDim2.fromScale(0.5, 0.46),
								Color = scope:Computed(function(use)
									return use(tone).Text
								end),
								Gradient = true,
								Shadow = true,
								StrokeColor = scope:Computed(function(use)
									return use(tone).Edge
								end),
								ZIndex = 5,
							}),
						},
					},
				},
			},
			label(scope, T, look, tone, props, if props.Compact then 34 else 40, {
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(0.5, 0, 1, 2),
			}),
		},
	})
	-- the jelly breathes (paused while hidden, still with reduced motion)
	local body0 = root:FindFirstChild("Body")
	if body0 and Theme.lite ~= true then
		P.loop(scope, body0, { From = 0, To = 1, Rest = 0.5, Period = 2.2, Name = "Wobble", Into = wobble })
	end
	P.footprint(root, size)
	return root
end

-- A glowing orb: a soft halo that breathes, a ring bursts out on every tap.
function BUILD.Orb(scope: Scope, props: { [any]: any }, T: { [string]: any }): Instance
	local size = Vector2.new(if props.Compact then 180 else 210, if props.Compact then 200 else 232)
	local look = P.look(scope, T)
	local tone = Kit.tone(scope, props.Tone or "Yellow")
	local orb = size.X - 40
	local lite = Theme.lite == true
	local root
	root = P.button(scope, {
		Name = props.Name or "Clicker",
		Size = UDim2.fromOffset(size.X, size.Y),
		Position = props.Position,
		AnchorPoint = props.AnchorPoint,
		LayoutOrder = props.LayoutOrder,
		Visible = props.Visible,
		Radius = "Full",
		Sound = props.Sound,
		Action = true,
		OnClick = function()
			local sphere = root:FindFirstChild("Sphere")
			if sphere and not Motion.reduced then
				pcall(Particles.ring, sphere, { Fit = true, Color = peek(tone).Top })
			end
			if props.OnClick then
				props.OnClick()
			end
		end,
		[Children] = {
			scope:New "Frame" {
				Name = "Sphere",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, 8),
				Size = UDim2.fromOffset(orb, orb),
				BackgroundColor3 = WHITE,
				ZIndex = 3,
				[Children] = {
					scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
					scope:New "UIGradient" {
						Rotation = 115,
						Color = scope:Computed(function(use)
							local t = use(tone)
							return ColorSequence.new({
								ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.45)),
								ColorSequenceKeypoint.new(0.45, t.Top),
								ColorSequenceKeypoint.new(1, t.Edge),
							})
						end),
					},
					scope:New "UIStroke" {
						Color = scope:Computed(function(use)
							return Color.lighten(use(tone).Top, 0.3)
						end),
						Thickness = 3,
						Transparency = 0.2,
					},
					if not lite
						then Kit.glow(scope, {
							Name = "Halo",
							Color = scope:Computed(function(use)
								return use(tone).Top
							end),
							Transparency = 0.35,
							Size = UDim2.fromScale(1.7, 1.7),
							ZIndex = 2,
						})
						else nil,
					if not lite
						then scope:New "Frame" {
							Name = "Specular",
							BackgroundColor3 = WHITE,
							BackgroundTransparency = 0.35,
							Position = UDim2.fromScale(0.2, 0.12),
							Size = UDim2.fromScale(0.32, 0.22),
							Rotation = -30,
							ZIndex = 4,
							[Children] = {
								scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
								scope:New "UIGradient" { Rotation = 90, Transparency = NumberSequence.new(0, 0.9) },
							},
						}
						else nil,
					Icon(scope, {
						Icon = props.Icon or "coin",
						Size = math.floor(orb * 0.46),
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = WHITE,
						Stroke = 2.5,
						StrokeColor = scope:Computed(function(use)
							return use(tone).Edge
						end),
						Shadow = true,
						ZIndex = 5,
					}),
				},
			},
			label(scope, T, look, tone, props, if props.Compact then 30 else 34, {
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(0.5, 0, 1, 0),
				Color = WHITE,
			}),
		},
	})
	-- the halo breathes
	local halo = root:FindFirstChild("Halo", true)
	if halo and T.idle ~= "None" then
		P.tweenLoop(
			scope,
			halo,
			TweenInfo.new(1.3, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true),
			{ ImageTransparency = 0.6, Size = UDim2.fromScale(1.55, 1.55) },
			function()
				halo.ImageTransparency = 0.35
				halo.Size = UDim2.fromScale(1.7, 1.7)
			end
		)
	end
	P.footprint(root, size)
	return root
end

-- A tall capsule with a combo meter that fills as you tap fast and drains.
function BUILD.Capsule(scope: Scope, props: { [any]: any }, T: { [string]: any }): Instance
	local size = Vector2.new(if props.Compact then 132 else 150, if props.Compact then 220 else 250)
	local look = P.look(scope, T)
	local tone = Kit.tone(scope, props.Tone or "Yellow")
	local combo = scope:Value(0)
	local meter = scope:Spring(combo, 14, 1)
	local alive = true
	table.insert(scope, function()
		alive = false
	end)
	local draining = false
	local lastTap = 0
	local function drain()
		if draining then
			return
		end
		draining = true
		task.spawn(function()
			while alive and peek(combo) > 0 do
				task.wait(0.1)
				if os.clock() - lastTap > 0.8 then
					combo:set(math.max(0, peek(combo) - 0.05))
				end
			end
			draining = false
		end)
	end
	local mult = scope:Computed(function(use)
		local c = use(combo)
		return if c >= 0.95 then 5 elseif c >= 0.7 then 4 elseif c >= 0.45 then 3 elseif c >= 0.2 then 2 else 1
	end)
	local multPop = scope:Spring(scope:Value(1), 30, T.bounce or 0.4)
	local before = peek(mult)
	Kit.watch(scope, mult, function(now)
		if now > before then
			multPop:setPosition(1.5)
		end
		before = now
	end)
	local w = size.X
	local meterW = 16
	local root = P.button(scope, {
		Name = props.Name or "Clicker",
		Size = UDim2.fromOffset(size.X, size.Y),
		Position = props.Position,
		AnchorPoint = props.AnchorPoint,
		LayoutOrder = props.LayoutOrder,
		Visible = props.Visible,
		Radius = "Full",
		Sound = props.Sound,
		Action = true,
		OnClick = function()
			lastTap = os.clock()
			combo:set(math.min(1, peek(combo) + 0.06))
			drain()
			if props.OnClick then
				props.OnClick()
			end
		end,
		[Children] = {
			P.plate(scope, T, { Radius = "Pill", Idle = true }),
			scope:New "Frame" {
				Name = "Face",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, 8),
				Size = UDim2.new(1, -16, 0, w - 16),
				BackgroundColor3 = WHITE,
				ZIndex = 4,
				[Children] = {
					P.corner(scope, T, "Pill"),
					scope:New "UIGradient" {
						Rotation = 90,
						Color = scope:Computed(function(use)
							return Kit.faceColors(use(tone), 0.1)
						end),
					},
					scope:New "UIStroke" {
						Color = scope:Computed(function(use)
							return use(tone).Edge
						end),
						Thickness = 3,
					},
					Icon(scope, {
						Icon = props.Icon or "coin",
						Size = math.floor((w - 16) * 0.55),
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = scope:Computed(function(use)
							return use(tone).Text
						end),
						Gradient = true,
						StrokeColor = scope:Computed(function(use)
							return use(tone).Edge
						end),
						ZIndex = 5,
					}),
				},
			},
			label(scope, T, look, tone, props, if props.Compact then 22 else 26, {
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, w - 2),
				Size = UDim2.fromOffset(w - 20, 30),
				Scaled = true,
				Wrapped = false,
				MaxTextSize = if props.Compact then 22 else 26,
			}),
			-- the combo meter along the bottom half
			P.bar(scope, T, {
				Name = "Combo",
				Fraction = meter,
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					local c = use(meter)
					return theme.Tone.Yellow.Top:Lerp(theme.Tone.Red.Top, math.clamp((c - 0.5) * 2, 0, 1))
				end),
				Size = UDim2.new(1, -36, 0, meterW),
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(0.5, 0, 1, -22),
				Track = scope:Computed(function(use)
					return use(look).track
				end),
				TrackAlpha = 0.15,
				Radius = "Pill",
				Ghost = false,
				ZIndex = 5,
			}),
			P.text(scope, T, look, {
				Name = "Multiplier",
				Role = "Number",
				Text = scope:Computed(function(use)
					return `x{use(mult)}`
				end),
				TextSize = 22,
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(0.5, 0, 1, -22 - meterW - 2),
				Color = scope:Computed(function(use)
					local m = use(mult)
					return if m > 1 then use(Theme.current).Color.Accent else use(look).muted
				end),
				ZIndex = 6,
				[Children] = scope:New "UIScale" { Scale = multPop },
			}),
		},
	})
	P.footprint(root, size)
	return root
end

return function(scope: Scope, props: { [any]: any }): Instance
	local style = P.style("Clicker", props.Style)
	return BUILD[style](scope, props, P.tokens(props.Kit))
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="93">
            <Properties>
              <string name="Name">Compass</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Compass: a heading strip across the top of the screen (Blueprint v1.7 HUD
	type Compass). Letters every 45 degrees, ticks between, a caret on the
	current heading and the heading in degrees.

	scope:Compass {
		Heading = 0,             -- degrees (number or state); 0 = north (-Z)
		Follow = true,           -- follow the camera (live games)
		Marker = 130,            -- optional heading of a goal (a dot on the strip)
		Size = UDim2.fromOffset(440, 58),
	}

	The ends of the strip fade out. Nothing animates by itself, so reduced
	motion changes nothing; lite mode drops the soft backing.
]]

local RunService = game:GetService("RunService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Kit = require(script.Parent.Parent.Kit)
local Text = require(script.Parent.Text)
local HudKits = require(script.Parent.HudKits)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local LETTERS = { [0] = "N", [45] = "NE", [90] = "E", [135] = "SE", [180] = "S", [225] = "SW", [270] = "W", [315] = "NW" }

local function delta(a: number, b: number): number
	return ((a - b + 540) % 360) - 180
end

return function(scope: Scope, props: { [any]: any }): Frame
	-- HUD kits (v1.8, Kit = a HudKits name / tokens): the kit's number type
	local kitTokens = if props.Kit ~= nil then HudKits.tokens(props.Kit) else nil
	local DISPLAY = if kitTokens ~= nil and kitTokens.name ~= "Classic" then kitTokens.numberFont else "Display"
	local size = peek(props.Size) or UDim2.fromOffset(440, 58)
	local w, h = size.X.Offset, size.Y.Offset
	local heading = props.Heading
	if not Kit.isState(heading) then
		heading = scope:Value(tonumber(heading) or 0)
	end
	local stripH = math.floor(h * 0.66)
	local span = 200 -- degrees shown across the strip
	local pxPerDegree = w / span

	local marks = {}
	for angle = 0, 345, 15 do
		local major = LETTERS[angle] ~= nil
		local x = scope:Computed(function(use)
			return w / 2 + delta(angle, use(heading)) * pxPerDegree
		end)
		table.insert(marks, scope:New "Frame" {
			Name = "Mark" .. angle,
			AnchorPoint = Vector2.new(0.5, 0),
			BackgroundTransparency = 1,
			Position = scope:Computed(function(use)
				return UDim2.fromOffset(math.floor(use(x)), 0)
			end),
			Size = UDim2.fromOffset(40, stripH),
			Visible = scope:Computed(function(use)
				return math.abs(use(x) - w / 2) <= w / 2 - 4
			end),
			ZIndex = 3,
			[Children] = {
				if major
					then Text(scope, {
						Text = LETTERS[angle],
						TextSize = if #LETTERS[angle] == 1 then math.floor(stripH * 0.58) else math.floor(stripH * 0.44),
						Font = DISPLAY,
						Case = "Upper",
						Color = Kit.themed(scope, function(theme)
							return if angle == 0 then theme.Color.Bad else theme.Color.Text
						end),
						Stroke = 2,
						StrokeTransparency = 0.3,
						AnchorPoint = Vector2.new(0.5, 0),
						Position = UDim2.fromScale(0.5, 0.14),
						Size = UDim2.new(1, 0, 0.6, 0),
						Scaled = true,
						Wrapped = false,
						MaxTextSize = math.floor(stripH * 0.58),
						ZIndex = 4,
					})
					else nil,
				scope:New "Frame" {
					Name = "Tick",
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.fromScale(0.5, 1),
					Size = UDim2.fromOffset(2, if major then math.floor(stripH * 0.3) else math.floor(stripH * 0.22)),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Text
					end),
					BackgroundTransparency = if major then 0.1 else 0.45,
					BorderSizePixel = 0,
					ZIndex = 4,
				},
			},
		})
	end

	local marker = props.Marker
	local markerDot = nil
	if marker ~= nil then
		local x = scope:Computed(function(use)
			return w / 2 + delta(tonumber(use(marker)) or 0, use(heading)) * pxPerDegree
		end)
		markerDot = scope:New "Frame" {
			Name = "Goal",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = scope:Computed(function(use)
				return UDim2.fromOffset(math.floor(math.clamp(use(x), 10, w - 10)), math.floor(stripH * 0.62))
			end),
			Size = UDim2.fromOffset(10, 10),
			Rotation = 45,
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Accent
			end),
			ZIndex = 5,
		}
	end

	if props.Follow and not props.Preview then
		table.insert(scope, RunService.RenderStepped:Connect(function()
			local camera = workspace.CurrentCamera
			if camera then
				local look = camera.CFrame.LookVector
				local deg = math.floor(math.deg(math.atan2(look.X, -look.Z)) % 360 + 0.5)
				if deg ~= peek(heading) then
					heading:set(deg)
				end
			end
		end))
	end

	local root = Kit.root(props, { Name = "Compass" })
	root.Size = size
	root.BackgroundTransparency = 1
	root[Children] = {
		Kit.unlessLite(scope, function(inner)
			return inner:New "Frame" {
				Name = "Backing",
				Size = UDim2.new(1, 0, 0, stripH),
				BackgroundColor3 = Kit.themed(inner, function(theme)
					return theme.Color.Surface
				end),
				BackgroundTransparency = 0.45,
				ZIndex = 1,
				[Children] = {
					Kit.corner(inner, "S"),
					Kit.gradient(inner, {
						Transparency = NumberSequence.new({
							NumberSequenceKeypoint.new(0, 1),
							NumberSequenceKeypoint.new(0.2, 0.2),
							NumberSequenceKeypoint.new(0.8, 0.2),
							NumberSequenceKeypoint.new(1, 1),
						}),
					}),
				},
			}
		end),
		scope:New "Frame" {
			Name = "Strip",
			BackgroundTransparency = 1,
			ClipsDescendants = true,
			Size = UDim2.new(1, 0, 0, stripH),
			ZIndex = 2,
			[Children] = { marks, markerDot },
		},
		scope:New "Frame" {
			Name = "Caret",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromOffset(math.floor(w / 2), 2),
			Size = UDim2.fromOffset(11, 11),
			Rotation = 45,
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Accent
			end),
			ZIndex = 5,
			[Children] = Kit.stroke(scope, { Thickness = 1.5, Transparency = 0.2 }),
		},
		Text(scope, {
			Name = "Degrees",
			Text = scope:Computed(function(use)
				return `{math.floor(use(heading)) % 360}°`
			end),
			TextSize = math.floor((h - stripH) * 0.8),
			Font = DISPLAY,
			Case = "Normal",
			Stroke = 2,
			StrokeTransparency = 0.3,
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.fromScale(0.5, 1),
			Size = UDim2.fromOffset(90, h - stripH - 6),
			Scaled = true,
			Wrapped = false,
			MaxTextSize = math.floor((h - stripH) * 0.8),
			ZIndex = 4,
		}),
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="94">
            <Properties>
              <string name="Name">Controls</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Settings controls.

	scope:Toggle { Value = musicOn, OnChanged = function(on) end, Disabled = false }
	scope:Slider { Value = volume, Min = 0, Max = 100, Step = 1, OnChanged = function(v) end }
	scope:SettingRow { Text = "Music", Icon = "music", [Children] = scope:Toggle { Value = musicOn } }

	Toggle and Slider show a themed focus ring when a controller selects
	them; a selected Slider moves with the d-pad / left stick (left, right).
	Slider props: Format (Format.number style or function), ShowValue = false,
	Height = 30, Tone = "Blue", Disabled.
]]

local UserInputService = game:GetService("UserInputService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Sound = require(script.Parent.Parent.Util.Sound)
local Format = require(script.Parent.Parent.Util.Format)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)
local KNOB_FACE = ColorSequence.new(WHITE, Color3.fromRGB(218, 222, 232))

type Scope = Fusion.Scope<any>

local Controls = {}

-- A round white knob with a soft contact shadow under it. `size` is a
-- state or number (px); returns a transparent holder to position.
local function knob(scope: Scope, props: { [string]: any }): Frame
	return scope:New "Frame" {
		Name = "Knob",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position,
		Size = props.Size,
		ZIndex = 3,
		[Children] = {
			scope:New "Frame" {
				Name = "KnobShadow",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 0, 0.5, 2),
				Size = UDim2.new(1, 2, 1, 2),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Outline
				end),
				BackgroundTransparency = 0.55,
				ZIndex = 3,
				[Children] = Kit.corner(scope, "Full"),
			},
			scope:New "Frame" {
				Name = "Face",
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Light
				end),
				ZIndex = 4,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.gradient(scope, { Rotation = 90, Color = KNOB_FACE }),
					Kit.stroke(scope, { Thickness = props.StrokeThickness or 2, Color = props.StrokeColor }),
				},
			},
		},
	}
end

function Controls.Toggle(scope: Scope, props: { [any]: any }): TextButton
	local value = props.Value or scope:Value(false)
	local disabled = props.Disabled or false
	local size: Vector2 = props.Size or Vector2.new(78, 40)
	local focused = scope:Value(false)
	local knobPos = Kit.spring(scope, scope:Computed(function(use)
		return if use(value) then 1 else 0
	end), 1.2)
	local mix = scope:Computed(function(use)
		return math.clamp(use(knobPos), 0, 1)
	end)
	local onTone = scope:Computed(function(use)
		local theme = use(Theme.current)
		if use(disabled) then
			return theme.Tone.Disabled or theme.Tone.Gray
		end
		return Theme.tone(theme, use(props.Tone) or "Green")
	end)

	local root = Kit.root(props, { Name = "Toggle" })
	root.Size = UDim2.fromOffset(size.X, size.Y)
	root.Text = ""
	root.AutoButtonColor = false
	root.Selectable = true
	root.SelectionImageObject = Kit.NoSelection
	root.Active = scope:Computed(function(use)
		return not use(disabled)
	end)
	root.BackgroundColor3 = WHITE
	root[OnEvent "SelectionGained"] = function()
		focused:set(true)
	end
	root[OnEvent "SelectionLost"] = function()
		focused:set(false)
	end
	root[OnEvent "Activated"] = function()
		if peek(disabled) then
			Sound.play("Error")
			return
		end
		local now = not peek(value)
		Sound.play("Click", { Pitch = if now then 1.2 else 0.9 })
		if value.set then
			value:set(now)
		end
		if props.OnChanged then
			props.OnChanged(now)
		end
	end
	root[Children] = {
		Kit.corner(scope, "Full"),
		-- off: a recessed slate track; on: the tone's glossy face
		Kit.gradient(scope, {
			Rotation = 90,
			Color = scope:Computed(function(use)
				local theme = use(Theme.current)
				local on = use(onTone)
				local off = theme.Tone.Gray
				local t = use(mix)
				local offTop = off.Edge:Lerp(off.Deep, 0.35)
				local offBottom = off.Edge:Lerp(off.Bottom, 0.25)
				return ColorSequence.new(offTop:Lerp(on.Top, t), offBottom:Lerp(on.Bottom, t))
			end),
		}),
		Kit.stroke(scope, {
			Thickness = Kit.themed(scope, function(theme)
				return math.max(1.5, theme.Stroke.Thin + 0.5)
			end),
			Color = scope:Computed(function(use)
				local theme = use(Theme.current)
				return theme.Tone.Gray.Deep:Lerp(use(onTone).Edge, use(mix))
			end),
		}),
		knob(scope, {
			Size = UDim2.fromOffset(size.Y - 8, size.Y - 8),
			Position = scope:Computed(function(use)
				local half = size.Y / 2
				return UDim2.new(0, half + (size.X - size.Y) * use(knobPos), 0.5, 0)
			end),
			StrokeColor = scope:Computed(function(use)
				local theme = use(Theme.current)
				return theme.Tone.Gray.Edge:Lerp(use(onTone).Edge, use(mix))
			end),
		}),
		Kit.focusRing(scope, { Focused = focused, Radius = "Full" }),
		-- phones: a comfortable hit area (Kit.touchPad)
		Kit.touchPad(scope, root.Size, { Activated = root[OnEvent "Activated"] }),
	}
	return scope:New "TextButton" (root) :: TextButton
end

-- Decimal places a slider value shows for a step size (0.1 -> 1, 0.05 -> 2).
local function decimalsFor(step: number?): number
	if step == nil then
		return 2
	elseif step >= 1 then
		return 0
	end
	return math.clamp(math.ceil(-math.log10(step) - 1e-9), 1, 3)
end

function Controls.Slider(scope: Scope, props: { [any]: any }): Frame
	local value = props.Value or scope:Value(0)
	local min = props.Min or 0
	local max = props.Max or 1
	local step = props.Step
	local disabled = props.Disabled or false
	local tone = scope:Computed(function(use)
		local theme = use(Theme.current)
		if use(disabled) then
			return theme.Tone.Disabled or theme.Tone.Gray
		end
		return Theme.tone(theme, use(props.Tone) or "Blue")
	end)
	local dragging = scope:Value(false)
	local focused = scope:Value(false)

	local fraction = scope:Computed(function(use)
		return math.clamp(((use(value) or min) - min) / (max - min), 0, 1)
	end)
	local shown = Kit.spring(scope, fraction, 1.6, 1)

	local track: TextButton? = nil

	local function commit(new: number)
		if step then
			new = math.clamp(math.round(new / step) * step, min, max)
		end
		new = math.clamp(new, min, max)
		if new ~= peek(value) then
			if value.set then
				value:set(new)
			end
			if props.OnChanged then
				props.OnChanged(new)
			end
		end
	end

	local function setFromX(x: number)
		if track == nil or peek(disabled) then
			return
		end
		local t = math.clamp((x - track.AbsolutePosition.X) / math.max(1, track.AbsoluteSize.X), 0, 1)
		commit(min + (max - min) * t)
	end

	table.insert(scope, UserInputService.InputChanged:Connect(function(input)
		if not peek(dragging) then
			return
		end
		if input.UserInputType == Enum.UserInputType.MouseMovement or input.UserInputType == Enum.UserInputType.Touch then
			setFromX(input.Position.X)
		end
	end))
	table.insert(scope, UserInputService.InputEnded:Connect(function(input)
		if input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch then
			if peek(dragging) then
				dragging:set(false)
				Sound.play("Tick", { Volume = 0.4 })
			end
		end
	end))
	-- controller: left / right nudge the value while the slider is selected
	table.insert(scope, UserInputService.InputBegan:Connect(function(input)
		if not peek(focused) or peek(disabled) then
			return
		end
		local key = input.KeyCode
		local direction = if key == Enum.KeyCode.DPadLeft or key == Enum.KeyCode.Left then -1
			elseif key == Enum.KeyCode.DPadRight or key == Enum.KeyCode.Right then 1
			else 0
		if direction ~= 0 then
			local nudge = if step then math.max(step, (max - min) / 20) else (max - min) / 20
			commit((peek(value) or min) + direction * nudge)
			Sound.play("Tick", { Volume = 0.4, Pitch = 0.9 + 0.3 * peek(fraction) })
		end
	end))

	local height = props.Height or 30
	local trackHeight = math.max(8, math.floor(height * 0.46))

	local function grab(input: InputObject)
		if input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch then
			if not peek(disabled) then
				dragging:set(true)
				setFromX(input.Position.X)
			end
		end
	end

	track = scope:New "TextButton" {
		Name = "Track",
		Text = "",
		AutoButtonColor = false,
		Selectable = true,
		SelectionImageObject = Kit.NoSelection,
		AnchorPoint = Vector2.new(0, 0.5),
		Position = UDim2.new(0, math.floor(height / 2), 0.5, 0),
		-- inset by half a knob at each end so the knob never pokes past the row
		Size = if props.ShowValue == false
			then UDim2.new(1, -height, 0, trackHeight)
			else UDim2.new(1, -height - 76, 0, trackHeight),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Track:Lerp(theme.Color.Glass, 0.08)
		end),
		BackgroundTransparency = Kit.themed(scope, function(theme)
			return math.min(theme.Alpha.Track, 0.2)
		end),
		[OnEvent "InputBegan"] = grab,
		[OnEvent "SelectionGained"] = function()
			focused:set(true)
		end,
		[OnEvent "SelectionLost"] = function()
			focused:set(false)
		end,
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.stroke(scope, { Thickness = 2 }),
			-- phones: a thumb-tall grab area over the thin track (Kit.touchPad)
			Kit.touchPad(scope, UDim2.fromOffset(0, trackHeight), { InputBegan = grab }, { Axes = "Y" }),
			-- a faint rim so the empty part of the track reads on dark panels
			scope:New "Frame" {
				Name = "Rim",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				[Children] = {
					Kit.corner(scope, "Full"),
					scope:New "UIStroke" {
						ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Glass
						end),
						Thickness = 1,
						Transparency = 0.86,
					},
				},
			},
			scope:New "Frame" {
				Name = "Fill",
				Size = scope:Computed(function(use)
					return UDim2.fromScale(math.clamp(use(shown), 0, 1), 1)
				end),
				BackgroundColor3 = WHITE,
				ZIndex = 2,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = scope:Computed(function(use)
							local t = use(tone)
							return ColorSequence.new(t.Top, t.Bottom)
						end),
					}),
				},
			},
			knob(scope, {
				Position = scope:Computed(function(use)
					return UDim2.fromScale(math.clamp(use(shown), 0, 1), 0.5)
				end),
				Size = scope:Computed(function(use)
					local s = if use(dragging) then height * 1.1 else height
					return UDim2.fromOffset(s, s)
				end),
				StrokeThickness = 3,
				StrokeColor = scope:Computed(function(use)
					return use(tone).Bottom
				end),
			}),
			Kit.focusRing(scope, { Focused = focused, Radius = "Full", Gap = math.ceil((height - trackHeight) / 2) + 4 }),
		},
	} :: TextButton

	-- keep a selected slider selected when left / right move its value
	pcall(function()
		(track :: any).NextSelectionLeft = track;
		(track :: any).NextSelectionRight = track
	end)

	local decimals = decimalsFor(step)

	local root = Kit.root(props, { Name = "Slider" })
	root.Size = props.Size or UDim2.fromOffset(340, height)
	root.BackgroundTransparency = 1
	root[Children] = {
		track,
		if props.ShowValue ~= false
			then Text(scope, {
				Text = scope:Computed(function(use)
					local v = use(value) or min
					if props.Format then
						return Format.number(v, props.Format)
					end
					return if decimals == 0 then tostring(math.round(v)) else string.format(`%.{decimals}f`, v)
				end),
				TextSize = 24,
				Font = "Display",
				XAlign = "Right",
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.fromScale(1, 0.5),
				Size = UDim2.fromOffset(64, height),
				Scaled = true,
				MaxTextSize = 24,
				Transparency = scope:Computed(function(use)
					return if use(disabled) then 0.4 else 0
				end),
			})
			else nil,
	}
	return scope:New "Frame" (root) :: Frame
end

-- SettingRow props: Text, Icon, Size, Style, TextSize, IconColor, and
-- ControlWidth (design px the control needs): the label then takes the rest
-- of the row instead of half of it, so "Reduce Flashes & Shake" fits beside
-- a toggle. RowWidth (design px): the label shrinks a little to fit before
-- it truncates ("Colour-blind Mode" in a wide typewriter face).
function Controls.SettingRow(scope: Scope, props: { [any]: any }): Frame
	local look, layers = Kit.surface(scope, { Style = props.Style or "Glass", Radius = "M" })
	local hasIcon = props.Icon ~= nil
	local labelLeft = if hasIcon then 18 + 34 + 14 else 20
	local controlWidth = props.ControlWidth
	local labelSize = props.TextSize or 26
	-- RowWidth (design px, optional): a long label shrinks a little to fit
	-- (wide faces, capitals) before it truncates
	local rowWidth = props.RowWidth
	local labelRoom = if typeof(rowWidth) ~= "number"
		then nil
		elseif typeof(controlWidth) == "number" then rowWidth - (labelLeft + controlWidth + 18 + 20)
		else rowWidth * 0.5 - labelLeft
	local fittedLabel = if labelRoom
		then scope:Computed(function(use)
			local theme = use(Theme.current)
			local value = tostring(use(props.Text) or "")
			local shown = if Theme.style(theme, "TextCase") == "Upper" then Kit.upper(value) else value
			return Kit.fitTextSize(shown, labelSize, labelRoom, "Heavy", math.floor(labelSize * 0.72), theme)
		end)
		else labelSize
	local root = Kit.root(props, { Name = "SettingRow" })
	root.Size = props.Size or UDim2.new(1, 0, 0, 64)
	root.BackgroundColor3 = look.BackgroundColor3
	root.BackgroundTransparency = look.BackgroundTransparency
	root[Children] = {
		layers,
		if hasIcon
			then Icon(scope, {
				Icon = props.Icon,
				Size = 34,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0, 18 + 17, 0.5, 0),
				Color = props.IconColor or Kit.themed(scope, function(theme)
					return theme.Color.Accent
				end),
				Gradient = true,
				ZIndex = 3,
			})
			else nil,
		-- the label takes the left half and truncates rather than running
		-- under the control
		Text(scope, {
			Name = "Label",
			Text = props.Text,
			TextSize = fittedLabel,
			Font = "Heavy",
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, labelLeft, 0.5, 0),
			Size = if typeof(controlWidth) == "number"
				then UDim2.new(1, -(labelLeft + controlWidth + 18 + 20), 0, 34)
				else UDim2.new(0.5, -labelLeft, 0, 34),
			Truncate = true,
			Color = scope:Computed(function(use)
				local theme = use(Theme.current)
				return if use(look.Look).light then theme.Color.PanelText else theme.Color.Text
			end),
			Stroke = scope:Computed(function(use)
				return if use(look.Look).light then false else nil
			end),
			ZIndex = 3,
		}),
		scope:New "Frame" {
			Name = "Control",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -18, 0.5, 0),
			Size = UDim2.new(0.5, -18, 1, 0),
			ZIndex = 2,
			[Children] = {
				Kit.list(scope, { Direction = "X", HAlign = "Right", VAlign = "Center" }),
				props[Children],
			},
		},
	}
	return scope:New "Frame" (root) :: Frame
end

return Controls
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="95">
            <Properties>
              <string name="Name">Countdown</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Countdown: a big centre-screen 3-2-1-GO! Each number slams in (scale 2 -> 1
	with a back-out bounce and a little twist) over a shockwave ring and a soft
	coloured glow, with a tick that rises in pitch. GO! lands with a tone
	gradient, spinning rays, a flash, a particle burst and a camera shake.

		local Countdown = require(BloxUI.Components.Countdown)
		Countdown.run({ From = 3, Text = "GO!" })   -- yields 3 s, returns the moment GO! lands
		startRound()

	Options (all optional):
		From = 3                    first number
		Text = "GO!"                final word ("" or false: none)
		Tone = "Green"              GO! colour (any theme tone)
		Tones = { "Red", "Orange", "Yellow" }   colour per number, counted from the
		                            end (here 3 = Red, 2 = Orange, 1 = Yellow); higher
		                            numbers reuse the first entry
		Interval = 1                seconds per number
		Title = "ROUND 2"           small label above the numbers
		Shake = true                camera shake on every slam
		Burst = true                particles on GO!
		Position = UDim2.fromScale(0.5, 0.42)
		Parent = playerGui          where the ScreenGui goes
		DisplayOrder = 960
		OnTick = function(n) end    every number, then 0 on GO!

	run() waits From * Interval seconds, so gameplay can start right after it
	returns while GO! finishes animating on its own.
]]

local Players = game:GetService("Players")
local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local FXScreen = require(script.Parent.Parent.FX.Screen)
local ScreenComponent = require(script.Parent.Screen)
local Text = require(script.Parent.Text)

local Children = Fusion.Children

local WHITE = Color3.new(1, 1, 1)

-- Soft white radial glow tinted `Color`. The texture is faint, so it can be
-- stacked `Layers` times for a stronger bloom.
local GLOW = (Assets :: any).Glow or "rbxasset://textures/ui/InGameMenu/BackgroundGlow.png"
local function glow(scope: any, props: { [string]: any }): ImageLabel
	local extra = {}
	for _ = 2, props.Layers or 1 do
		table.insert(extra, scope:New "ImageLabel" {
			Name = "Layer",
			BackgroundTransparency = 1,
			Image = GLOW,
			ImageColor3 = props.Color,
			ImageTransparency = props.Transparency or 0,
			Size = UDim2.fromScale(1, 1),
			ZIndex = props.ZIndex or 0,
		})
	end
	return scope:New "ImageLabel" {
		Name = props.Name or "Glow",
		BackgroundTransparency = 1,
		Image = GLOW,
		ImageColor3 = props.Color,
		ImageTransparency = props.Transparency or 0,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position or UDim2.fromScale(0.5, 0.5),
		Size = props.Size,
		Visible = props.Visible,
		ZIndex = props.ZIndex or 0,
		[Fusion.Children] = extra,
	}
end

-- Light rays: rotated copies of Roblox's white emote-wheel wedge (bright at
-- the centre, fading out), so they tint cleanly to any colour. Spin the
-- returned frame with Motion.spin.
local WEDGE = "rbxasset://textures/ui/Emotes/Large/SelectedGradient@2x.png"
local function rays(scope: any, props: { [string]: any }): Frame
	local count = props.Count or 12
	local width = props.Width or 0.36
	local spokes = {}
	for i = 0, count - 1 do
		table.insert(spokes, scope:New "Frame" {
			Name = "Ray",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			Rotation = i * 360 / count,
			ZIndex = props.ZIndex or 0,
			[Fusion.Children] = scope:New "ImageLabel" {
				Name = "Wedge",
				BackgroundTransparency = 1,
				Image = WEDGE,
				ImageColor3 = props.Color,
				ImageTransparency = props.Transparency or 0,
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.fromScale(0.5, 0.5 * width),
				ZIndex = props.ZIndex or 0,
			},
		})
	end
	return scope:New "Frame" {
		Name = props.Name or "Rays",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position or UDim2.fromScale(0.5, 0.5),
		Size = props.Size,
		BackgroundTransparency = 1,
		Visible = props.Visible,
		ZIndex = props.ZIndex or 0,
		[Fusion.Children] = spokes,
	}
end

-- Poof (docs/RESEARCH.md 2.10): 7-9 soft circles fly out with drag, growing
-- 0.3 -> 1.3x and fading over ~0.4 s, plus one expanding ring. Drawn in
-- design px inside `parent`, centred on `at` (UDim2). Fire and forget.
local POOF_TWEEN = game:GetService("TweenService")
local function poof(parent: Instance, at: UDim2, options: { [string]: any }?)
	local opts = options or {}
	local count = opts.Count or 8
	local size = opts.Size or 34
	local reachMin, reachMax = opts.Reach and opts.Reach[1] or 40, opts.Reach and opts.Reach[2] or 90
	local colors = opts.Colors or { Color3.new(1, 1, 1) }
	local life = opts.Time or 0.42
	local zIndex = opts.ZIndex or 50
	for i = 1, count do
		local angle = (i / count) * math.pi * 2 + (math.random() - 0.5) * 0.7
		local direction = Vector2.new(math.cos(angle), math.sin(angle))
		local start = direction * math.random() * 12
		local finish = direction * (reachMin + math.random() * (reachMax - reachMin))
		local circle = Instance.new("ImageLabel")
		circle.Name = "Poof"
		circle.BackgroundTransparency = 1
		circle.Image = "rbxasset://textures/whiteCircle.png"
		circle.ImageColor3 = colors[(i - 1) % #colors + 1]
		circle.ImageTransparency = 0.1
		circle.AnchorPoint = Vector2.new(0.5, 0.5)
		circle.Position = at + UDim2.fromOffset(start.X, start.Y)
		local s = size * (0.85 + math.random() * 0.3)
		circle.Size = UDim2.fromOffset(s * 0.3, s * 0.3)
		circle.ZIndex = zIndex
		circle.Parent = parent
		local t = life * (0.85 + math.random() * 0.2)
		POOF_TWEEN:Create(circle, TweenInfo.new(t, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
			Position = at + UDim2.fromOffset(finish.X, finish.Y),
			Size = UDim2.fromOffset(s * 1.3, s * 1.3),
			ImageTransparency = 1,
		}):Play()
		task.delay(t + 0.05, function()
			circle:Destroy()
		end)
	end
	if opts.Ring ~= false then
		local ringSize = opts.RingSize or size * 3
		local ring = Instance.new("Frame")
		ring.Name = "PoofRing"
		ring.BackgroundTransparency = 1
		ring.AnchorPoint = Vector2.new(0.5, 0.5)
		ring.Position = at
		ring.Size = UDim2.fromOffset(ringSize * 0.4, ringSize * 0.4)
		ring.ZIndex = zIndex
		local corner = Instance.new("UICorner")
		corner.CornerRadius = UDim.new(0.5, 0)
		corner.Parent = ring
		local stroke = Instance.new("UIStroke")
		stroke.Thickness = opts.RingThickness or 6
		stroke.Color = opts.RingColor or colors[1]
		stroke.Transparency = 0.2
		stroke.Parent = ring
		ring.Parent = parent
		local ringTime = opts.RingTime or 0.25
		POOF_TWEEN:Create(ring, TweenInfo.new(ringTime, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
			Size = UDim2.fromOffset(ringSize * 1.6, ringSize * 1.6),
		}):Play()
		POOF_TWEEN:Create(stroke, TweenInfo.new(ringTime, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), { Transparency = 1 }):Play()
		task.delay(ringTime + 0.05, function()
			ring:Destroy()
		end)
	end
end

local Countdown = {}

export type Options = {
	From: number?,
	Text: (string | boolean)?,
	Tone: any?,
	Tones: { any }?,
	Interval: number?,
	Title: string?,
	Shake: boolean?,
	Burst: boolean?,
	Position: UDim2?,
	Parent: Instance?,
	DisplayOrder: number?,
	OnTick: ((number) -> ())?,
}

local DEFAULT_TONES = { "Red", "Orange", "Yellow" }

local function tween(instance: Instance, time: number, goals: { [string]: any }, style: Enum.EasingStyle?, direction: Enum.EasingDirection?, delay: number?)
	local t = TweenService:Create(
		instance,
		TweenInfo.new(time, style or Enum.EasingStyle.Quad, direction or Enum.EasingDirection.Out, 0, false, delay or 0),
		goals
	)
	t:Play()
	return t
end

-- First theme sound slot from `names` that has an id, so games can set a
-- dedicated "Countdown" sound and everyone else still hears "Tick".
local function themedSound(names: { string }): string?
	local sounds = Theme.get().Sound or {}
	for _, name in names do
		local id = sounds[name]
		if (typeof(id) == "string" and id ~= "") or typeof(id) == "number" then
			return name
		end
	end
	return nil
end

-- Gradient for a big slam word: bright top, the tone, a deeper bottom edge.
local function slamGradient(tone: any): ColorSequence
	return ColorSequence.new({
		ColorSequenceKeypoint.new(0, Color.lighten(tone.Top, 0.55)),
		ColorSequenceKeypoint.new(0.45, tone.Top),
		ColorSequenceKeypoint.new(1, tone.Bottom),
	})
end

-- One number (or the final word). Builds its own little tree, animates it in,
-- schedules its exit and destroys itself.
local function slam(scope: any, holder: Frame, word: string, tone: any, final: boolean, hold: number, shake: boolean)
	local stepScope = scope:innerScope()
	local theme = Theme.get()
	local size = if final then 330 else 440
	local strokeColor = tone.Stroke or theme.Color.Outline

	local uiScale = stepScope:New "UIScale" { Scale = 2.1 }

	-- three stacked copies of the word: a drop shadow, a white "sticker" rim
	-- (reads on bright skies and dark caves alike) and the gradient face
	-- Roblox caps TextSize at 100: the word is laid out at 100 and a UIScale
	-- takes it to `size` (outlines are divided so they keep their weight)
	local factor = size / 100
	local function layer(name: string, zIndex: number, props: { [string]: any })
		props.Name = name
		props.Text = word
		props.TextSize = 100
		if typeof(props.Stroke) == "number" then
			props.Stroke = props.Stroke / factor
		end
		props[Children] = stepScope:New "UIScale" { Name = "BigText", Scale = factor }
		props.Font = "Title"
		props.Transparency = 1
		props.AnchorPoint = Vector2.new(0.5, 0.5)
		props.Position = props.Position or UDim2.fromScale(0.5, 0.5)
		props.ZIndex = zIndex
		return Text(stepScope, props)
	end
	local shadow = layer("Shadow", 3, {
		Color = theme.Color.Outline,
		Stroke = 20,
		StrokeColor = theme.Color.Outline,
		Position = UDim2.new(0.5, 0, 0.5, 16),
	})
	local rim = layer("Rim", 4, { Color = WHITE, Stroke = 20, StrokeColor = WHITE })
	local label = layer("Word", 5, { Gradient = slamGradient(tone), Stroke = 11, StrokeColor = strokeColor })
	local textLayers = {
		{ label = shadow, alpha = 0.55 },
		{ label = rim, alpha = 0 },
		{ label = label, alpha = 0 },
	}
	for _, entry in textLayers do
		entry.stroke = entry.label:FindFirstChildOfClass("UIStroke")
	end

	local ring = stepScope:New "Frame" {
		Name = "Shockwave",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(300, 300),
		BackgroundTransparency = 1,
		ZIndex = 2,
		[Children] = {
			stepScope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
			stepScope:New "UIStroke" {
				Name = "Ring",
				Color = Color.lighten(tone.Top, 0.25),
				Thickness = 10,
				Transparency = 1,
			},
		},
	}

	local bloom = glow(stepScope, {
		Name = "Glow",
		Color = Color.lighten(tone.Top, 0.35),
		Size = UDim2.fromOffset(if final then 1100 else 720, if final then 700 else 620),
		Transparency = 1,
		Layers = 2,
		ZIndex = 1,
	})

	local raysGoal = stepScope:Value(1)
	local burst = if final
		then rays(stepScope, {
			Color = Color.lighten(tone.Top, 0.25),
			Transparency = stepScope:Tween(raysGoal, TweenInfo.new(0.3, Enum.EasingStyle.Quad)),
			Size = UDim2.fromOffset(1000, 1000),
			Count = 14,
			Width = 0.3,
			ZIndex = 0,
		})
		else nil

	local step = stepScope:New "Frame" {
		Name = if final then "Final" else `Number{word}`,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(600, 360),
		BackgroundTransparency = 1,
		Rotation = if final then 0 else -9,
		Parent = holder,
		[Children] = { uiScale, burst, bloom, ring, shadow, rim, label },
	}

	local ringStroke = ring:FindFirstChildOfClass("UIStroke")

	-- in: slam from big to normal with a back-out bounce and untwist
	tween(uiScale, 0.34, { Scale = 1 }, Enum.EasingStyle.Back, Enum.EasingDirection.Out)
	tween(step, 0.34, { Rotation = 0 }, Enum.EasingStyle.Back, Enum.EasingDirection.Out)
	for _, entry in textLayers do
		tween(entry.label, 0.1, { TextTransparency = entry.alpha })
		if entry.stroke then
			tween(entry.stroke, 0.1, { Transparency = entry.alpha })
		end
	end
	local bloomLayers = { bloom }
	for _, layer in bloom:GetChildren() do
		table.insert(bloomLayers, layer)
	end
	for _, layer in bloomLayers do
		tween(layer, 0.2, { ImageTransparency = if final then 0.2 else 0.45 })
	end

	-- impact: the ring bursts out as the word lands
	task.delay(0.12, function()
		if ring.Parent == nil then
			return
		end
		if ringStroke then
			ringStroke.Transparency = 0.1
			tween(ringStroke, 0.3, { Transparency = 1, Thickness = 1 })
		end
		ring.Size = UDim2.fromOffset(260, 260)
		tween(ring, 0.3, { Size = UDim2.fromOffset(if final then 1100 else 760, if final then 1100 else 760) })
		if shake then
			-- trauma values from docs/RESEARCH.md 8.5 (shake = trauma^2)
			FXScreen.cameraShake(if final then 0.35 else 0.12, if final then 0.3 else 0.2)
		end
	end)

	local stopSpin: (() -> ())? = nil
	if burst then
		raysGoal:set(0.2)
		stopSpin = Motion.spin(burst, 7)
	end

	-- out: numbers shrink away just before the next one; the final word
	-- grows and fades so it never blocks the start of play for long
	task.delay(hold, function()
		if step.Parent == nil then
			return
		end
		local outTime = if final then 0.35 else 0.16
		local fade = { TextTransparency = 1 }
		tween(uiScale, outTime, { Scale = if final then 1.35 else 0.62 }, Enum.EasingStyle.Quad, Enum.EasingDirection.In)
		for _, entry in textLayers do
			tween(entry.label, outTime, fade, Enum.EasingStyle.Quad, Enum.EasingDirection.In)
			if entry.stroke then
				tween(entry.stroke, outTime, { Transparency = 1 }, Enum.EasingStyle.Quad, Enum.EasingDirection.In)
			end
		end
		for _, layer in bloomLayers do
			tween(layer, outTime, { ImageTransparency = 1 })
		end
		if burst then
			raysGoal:set(1)
		end
		task.wait(outTime + 0.05)
		if stopSpin then
			stopSpin()
		end
		Fusion.doCleanup(stepScope)
	end)

	return step
end

-- Runs the countdown. Yields From * Interval seconds and returns right as the
-- final word lands; the word animates out and cleans up by itself.
function Countdown.run(options: Options?)
	local opts = options or {}
	local from = math.max(0, math.floor(opts.From or 3))
	local interval = opts.Interval or 1
	local word = opts.Text
	if word == nil then
		word = "GO!"
	end
	local tones = opts.Tones or DEFAULT_TONES
	local shake = opts.Shake ~= false

	local parent = opts.Parent
	if parent == nil then
		local player = Players.LocalPlayer
		parent = if player then player:FindFirstChildOfClass("PlayerGui") else nil
	end
	if parent == nil then
		-- nobody to show it to (Studio edit mode, plugin preview)
		return
	end
	local scope = Fusion.scoped(Fusion)

	local holder = scope:New "Frame" {
		Name = "Countdown",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = opts.Position or UDim2.fromScale(0.5, 0.42),
		Size = UDim2.fromOffset(600, 360),
		BackgroundTransparency = 1,
		[Children] = {
			if opts.Title
				then Text(scope, {
					Name = "Title",
					Text = opts.Title,
					TextSize = 52,
					Font = "Heavy",
					Stroke = 3.5,
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 0, -40),
					ZIndex = 6,
				})
				else nil,
		},
	}

	ScreenComponent(scope, {
		Name = "BloxUI_Countdown",
		DisplayOrder = opts.DisplayOrder or 960,
		SafeArea = "None",
		Parent = parent,
		[Children] = holder,
	})

	local tickName = themedSound({ "Countdown", "Tick" })
	for n = from, 1, -1 do
		local theme = Theme.get()
		-- counted from the end: with 3 tones, 3 -> first, 1 -> last
		local toneName = tones[math.clamp(#tones - n + 1, 1, #tones)]
		slam(scope, holder, tostring(n), Theme.tone(theme, toneName), false, interval - 0.17, shake)
		if tickName then
			Sound.play(tickName, { Pitch = 0.9 + (from - n) * 0.12, Vary = 0, Volume = 1.2 })
		end
		if opts.OnTick then
			task.spawn(opts.OnTick, n)
		end
		task.wait(interval)
	end

	local hasWord = typeof(word) == "string" and word ~= ""
	if hasWord then
		local theme = Theme.get()
		local tone = Theme.tone(theme, opts.Tone or "Green")
		local step = slam(scope, holder, word :: string, tone, true, 0.75, shake)
		local goName = themedSound({ "Go", "Reward" })
		if goName then
			Sound.play(goName, { Vary = 0 })
		end
		if tickName then
			Sound.play(tickName, { Pitch = 1.6, Vary = 0, Volume = 1.4 })
		end
		task.delay(0.1, function()
			if step.Parent == nil then
				return
			end
			FXScreen.flash(Color.lighten(tone.Top, 0.6), 0.2, 0.35)
			poof(holder, UDim2.fromScale(0.5, 0.5), {
				Count = 9,
				Size = 80,
				Reach = { 180, 320 },
				Colors = { tone.Top, WHITE, Color.lighten(tone.Top, 0.5) },
				RingSize = 520,
				RingThickness = 10,
				RingColor = Color.lighten(tone.Top, 0.4),
				ZIndex = 1,
			})
			if opts.Burst ~= false then
				Particles.burst(step, {
					Count = 36,
					Speed = 950,
					Shape = "confetti",
					Colors = { tone.Top, tone.Bottom, Color.lighten(tone.Top, 0.6), theme.Color.Accent },
				})
				Particles.burst(step, { Count = 12, Speed = 520, Shape = "sparkle", Size = 36, Gravity = 0 })
			end
		end)
	end
	if opts.OnTick then
		task.spawn(opts.OnTick, 0)
	end

	task.delay(if hasWord then 1.3 else 0.3, function()
		Fusion.doCleanup(scope)
	end)
end

return Countdown
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="96">
            <Properties>
              <string name="Name">Counter</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Counter: one big stylised reading that says where you are in the run:
	"DOOR 0042", "PAGES 3/8", "LEVEL 0", "NIGHT 3 · 4 AM", "GENERATORS 2/5".
	(Blueprint v1.7 HUD type Counter.)

	scope:Counter {
		Label = "DOOR",            -- small caption (string or state)
		Value = 42,                -- number or state
		Goal = nil,                -- adds "/8" (and pips for goals up to 10 in Stamp / Scrawl)
		Digits = 4,                -- zero-pads the number ("0042")
		Suffix = "4 AM",           -- a second reading after a dot: NIGHT 3 · 4 AM
		Icon = "door",             -- optional
		Style = "Plaque",          -- "Plaque" (brass door plate) | "Digital" (LCD readout)
		                           -- | "Stamp" (poster type on a torn tape strip) | "Scrawl" (handwritten, underlined)
		Align = "Left",            -- "Left" | "Center" | "Right" (which edge it hugs)
		Size = UDim2.fromOffset(260, 88),
	}

	A new value pops (a quick scale kick; none with reduced motion). Lite mode
	drops the plate gradient and the ghost digits. Pips are shapes (filled vs
	hollow), so the count never depends on colour alone.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Motion = require(script.Parent.Parent.FX.Motion)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local HudKits = require(script.Parent.HudKits)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

local STYLES = { "Plaque", "Digital", "Stamp", "Scrawl" }

-- "0042", "3/8", "3 · 4 AM"
local function format(value: any, goal: any, digits: any, suffix: any): string
	local n = math.floor(tonumber(value) or 0)
	local text = tostring(n)
	local pad = tonumber(digits)
	if pad and pad > 1 and n >= 0 then
		text = string.rep("0", math.max(0, math.floor(pad) - #text)) .. text
	end
	local g = tonumber(goal)
	if g and g > 0 then
		text ..= "/" .. tostring(math.floor(g))
	end
	if typeof(suffix) == "string" and suffix ~= "" then
		text ..= " · " .. suffix
	end
	return text
end

return function(scope: Scope, props: { [any]: any }): Frame
	-- HUD kits (v1.8, Kit = a HudKits name / tokens): the kit's number and label type
	local kitTokens = if props.Kit ~= nil then HudKits.tokens(props.Kit) else nil
	local kitted = kitTokens ~= nil and kitTokens.name ~= "Classic"
	local DISPLAY = if kitted then kitTokens.numberFont else "Display"
	local HEAVY = if kitted then kitTokens.labelFont else "Heavy"
	local style = peek(props.Style) or "Plaque"
	if not table.find(STYLES, style) then
		style = "Plaque"
	end
	local align = peek(props.Align) or "Left"
	local value = props.Value or 0
	local reading = scope:Computed(function(use)
		return format(use(value), use(props.Goal), use(props.Digits), use(props.Suffix))
	end)
	local label = scope:Computed(function(use)
		return string.upper(tostring(use(props.Label) or ""))
	end)
	local size = peek(props.Size) or UDim2.fromOffset(260, 88)
	local height = size.Y.Offset
	local xAlign = if align == "Right" then "Right" elseif align == "Center" then "Center" else "Left"
	local anchorX = if align == "Right" then 1 elseif align == "Center" then 0.5 else 0

	-- a new reading kicks the number (reduced motion: it just changes)
	local popGoal = scope:Value(1)
	local pop = scope:Spring(popGoal, 30, 0.42)
	Kit.watch(scope, reading, function()
		if not Motion.reduced then
			pop:setPosition(1.16)
		end
	end)

	local goal = tonumber(peek(props.Goal))
	local pips = nil
	if goal and goal >= 2 and goal <= 10 and (style == "Stamp" or style == "Scrawl") then
		local cells = {}
		for i = 1, goal do
			table.insert(cells, scope:New "Frame" {
				Name = "Pip" .. i,
				LayoutOrder = i,
				Size = UDim2.fromOffset(if style == "Scrawl" then 14 else 16, if style == "Scrawl" then 18 else 12),
				Rotation = if style == "Scrawl" then ((i * 37) % 9) - 4 else 0,
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return if style == "Scrawl" then theme.Color.Text else theme.Color.Accent
				end),
				BackgroundTransparency = scope:Computed(function(use)
					return if (tonumber(use(value)) or 0) >= i then 0.05 else 1
				end),
				[Children] = {
					Kit.corner(scope, if style == "Scrawl" then 2 else "Full"),
					Kit.stroke(scope, {
						Thickness = 2,
						Color = Kit.themed(scope, function(theme)
							return if style == "Scrawl" then theme.Color.Text else theme.Color.Accent
						end),
						Transparency = 0.15,
					}),
				},
			})
		end
		pips = scope:New "Frame" {
			Name = "Pips",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(anchorX, 1),
			Position = UDim2.new(anchorX, 0, 1, -2),
			Size = UDim2.new(1, 0, 0, 20),
			ZIndex = 4,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = if style == "Scrawl" then 7 else 6, HAlign = xAlign, VAlign = "Center" }),
				cells,
			},
		}
	end
	local bodyHeight = if pips then height - 22 else height

	local children: { any } = {}
	local root = Kit.root(props, { Name = "Counter" })
	root.Size = size
	root.BackgroundTransparency = 1

	if style == "Plaque" then
		-- a brass door plate: gradient fill, a double border, dark engraved numerals
		local tone = Kit.tone(scope, props.Tone or "Primary")
		local plate = scope:New "Frame" {
			Name = "Plate",
			AnchorPoint = Vector2.new(anchorX, 0),
			Position = UDim2.fromScale(anchorX, 0),
			Size = UDim2.new(1, 0, 0, bodyHeight),
			BackgroundColor3 = scope:Computed(function(use)
				return use(tone).Bottom
			end),
			ZIndex = 2,
			[Children] = {
				Kit.corner(scope, "S"),
				Kit.stroke(scope, {
					Thickness = 3,
					Color = scope:Computed(function(use)
						return use(tone).Edge
					end),
				}),
				Kit.unlessLite(scope, function(inner)
					return Kit.gradient(inner, {
						Rotation = 90,
						Color = inner:Computed(function(use)
							local t = use(tone)
							return ColorSequence.new({
								ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.18)),
								ColorSequenceKeypoint.new(0.45, t.Top),
								ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.15)),
							})
						end),
					})
				end),
				scope:New "Frame" {
					Name = "Inset",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Size = UDim2.new(1, -12, 1, -12),
					BackgroundTransparency = 1,
					ZIndex = 3,
					[Children] = {
						Kit.corner(scope, 2),
						Kit.stroke(scope, {
							Thickness = 1.5,
							Transparency = 0.35,
							Color = scope:Computed(function(use)
								return use(tone).Edge
							end),
						}),
					},
				},
				-- screw heads
				scope:New "Frame" {
					Name = "ScrewL",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0, 14, 0.5, 0),
					Size = UDim2.fromOffset(7, 7),
					BackgroundColor3 = scope:Computed(function(use)
						return use(tone).Edge
					end),
					BackgroundTransparency = 0.25,
					ZIndex = 4,
					[Children] = Kit.corner(scope, "Full"),
				},
				scope:New "Frame" {
					Name = "ScrewR",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(1, -14, 0.5, 0),
					Size = UDim2.fromOffset(7, 7),
					BackgroundColor3 = scope:Computed(function(use)
						return use(tone).Edge
					end),
					BackgroundTransparency = 0.25,
					ZIndex = 4,
					[Children] = Kit.corner(scope, "Full"),
				},
				Text(scope, {
					Name = "Label",
					Text = label,
					TextSize = math.floor(bodyHeight * 0.2),
					Font = HEAVY,
					Case = "Upper",
					Color = scope:Computed(function(use)
						return use(tone).Text
					end),
					Stroke = false,
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, math.floor(bodyHeight * 0.1)),
					Size = UDim2.new(1, -40, 0, math.floor(bodyHeight * 0.22)),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = math.floor(bodyHeight * 0.2),
					ZIndex = 5,
				}),
				Text(scope, {
					Name = "Reading",
					Text = reading,
					TextSize = math.floor(bodyHeight * 0.52),
					-- the display face: the numbers' face (titles may be old-style or ornate)
					Font = DISPLAY,
					Case = "Normal",
					Color = scope:Computed(function(use)
						return use(tone).Text
					end),
					-- engraved: a light bevel under dark numerals
					StrokeColor = scope:Computed(function(use)
						return Color.lighten(use(tone).Top, 0.3)
					end),
					Stroke = 1,
					StrokeTransparency = 0.45,
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -math.floor(bodyHeight * 0.1)),
					Size = UDim2.new(1, -40, 0, math.floor(bodyHeight * 0.56)),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = math.floor(bodyHeight * 0.52),
					ZIndex = 5,
					[Children] = scope:New "UIScale" { Scale = pop },
				}),
			},
		}
		table.insert(children, plate)
	elseif style == "Digital" then
		-- an LCD readout: dark glass, a hairline in the primary colour, ghost segments
		local glow = Kit.themed(scope, function(theme)
			return theme.Color.Primary or theme.Color.Accent
		end)
		local labelWidth = math.floor(size.X.Offset * 0.3)
		table.insert(children, scope:New "Frame" {
			Name = "Screen",
			Size = UDim2.new(1, 0, 0, bodyHeight),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = Kit.themed(scope, function(theme)
				return math.clamp(theme.Alpha.Surface - 0.1, 0, 1)
			end),
			ZIndex = 2,
			[Children] = {
				Kit.corner(scope, "S"),
				Kit.stroke(scope, { Thickness = 1.5, Color = glow, Transparency = 0.45 }),
				scope:New "Frame" {
					Name = "Bezel",
					Size = UDim2.new(0, 4, 1, -16),
					Position = UDim2.fromOffset(8, 8),
					BackgroundColor3 = glow,
					ZIndex = 3,
					[Children] = Kit.corner(scope, "Full"),
				},
				Text(scope, {
					Name = "Label",
					Text = label,
					TextSize = math.floor(bodyHeight * 0.3),
					Font = HEAVY,
					Case = "Upper",
					XAlign = "Left",
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					Stroke = false,
					Position = UDim2.fromOffset(22, 0),
					Size = UDim2.new(0, labelWidth, 1, 0),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = math.floor(bodyHeight * 0.3),
					ZIndex = 4,
				}),
				Kit.unlessLite(scope, function(inner)
					return Text(inner, {
						Name = "Ghost",
						Text = inner:Computed(function(use)
							return (string.gsub(use(reading), "%d", "8"))
						end),
						TextSize = math.floor(bodyHeight * 0.74),
						Font = DISPLAY,
						Case = "Normal",
						XAlign = "Right",
						Color = glow,
						Transparency = 0.9,
						Stroke = false,
						AnchorPoint = Vector2.new(1, 0.5),
						Position = UDim2.new(1, -14, 0.5, 0),
						Size = UDim2.new(1, -(labelWidth + 40), 0, math.floor(bodyHeight * 0.82)),
						Scaled = true,
						Wrapped = false,
						MaxTextSize = math.floor(bodyHeight * 0.74),
						ZIndex = 4,
					})
				end),
				Text(scope, {
					Name = "Reading",
					Text = reading,
					TextSize = math.floor(bodyHeight * 0.74),
					Font = DISPLAY,
					Case = "Normal",
					XAlign = "Right",
					Color = glow,
					StrokeColor = Kit.themed(scope, function(theme)
						return theme.Color.Outline
					end),
					Stroke = 1.5,
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -14, 0.5, 0),
					Size = UDim2.new(1, -(labelWidth + 40), 0, math.floor(bodyHeight * 0.82)),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = math.floor(bodyHeight * 0.74),
					ZIndex = 5,
					[Children] = scope:New "UIScale" { Scale = pop },
				}),
			},
		})
	elseif style == "Stamp" then
		-- poster type on a torn strip of tape: loud, tilted, no panel
		local iconSize = if props.Icon then math.floor(bodyHeight * 0.62) else 0
		table.insert(children, scope:New "Frame" {
			Name = "Tape",
			AnchorPoint = Vector2.new(anchorX, 0),
			Position = UDim2.new(anchorX, 0, 0, math.floor(bodyHeight * 0.06)),
			Size = UDim2.new(0.62, 0, 0, math.floor(bodyHeight * 0.34)),
			Rotation = -2,
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Accent
			end),
			BackgroundTransparency = 0.05,
			ZIndex = 2,
			[Children] = {
				Kit.corner(scope, 2),
				Text(scope, {
					Name = "Label",
					Text = label,
					TextSize = math.floor(bodyHeight * 0.26),
					Font = HEAVY,
					Case = "Upper",
					Color = Kit.themed(scope, function(theme)
						return if Kit.isLight(theme.Color.Accent) then theme.Color.TextDark or Color3.new(0.08, 0.06, 0.06) else WHITE
					end),
					Stroke = false,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Size = UDim2.new(1, -16, 1, -4),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = math.floor(bodyHeight * 0.26),
					ZIndex = 3,
				}),
			},
		})
		table.insert(children, scope:New "Frame" {
			Name = "Line",
			AnchorPoint = Vector2.new(anchorX, 1),
			Position = UDim2.new(anchorX, 0, 0, bodyHeight),
			Size = UDim2.new(1, 0, 0, math.floor(bodyHeight * 0.62)),
			BackgroundTransparency = 1,
			Rotation = -2,
			ZIndex = 3,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 8, HAlign = xAlign, VAlign = "Center" }),
				if props.Icon
					then Icon(scope, {
						Icon = props.Icon,
						Size = iconSize,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Accent
						end),
						Stroke = 2,
						LayoutOrder = 1,
						ZIndex = 4,
					})
					else nil,
				Text(scope, {
					Name = "Reading",
					Text = reading,
					TextSize = math.floor(bodyHeight * 0.6),
					Font = DISPLAY,
					Case = "Normal",
					Stroke = 3,
					Size = UDim2.new(0, math.max(40, size.X.Offset - iconSize - 12), 1, 0),
					XAlign = xAlign,
					Scaled = true,
					Wrapped = false,
					MaxTextSize = math.floor(bodyHeight * 0.6),
					LayoutOrder = 2,
					ZIndex = 4,
					[Children] = scope:New "UIScale" { Scale = pop },
				}),
			},
		})
	else
		-- Scrawl: written on the screen by hand, underlined twice
		table.insert(children, Text(scope, {
			Name = "Reading",
			Text = scope:Computed(function(use)
				local l = use(label)
				return if l ~= "" then `{l}  {use(reading)}` else use(reading)
			end),
			TextSize = math.floor(bodyHeight * 0.62),
			Font = DISPLAY,
			Case = "Normal",
			XAlign = xAlign,
			Stroke = 2,
			StrokeTransparency = 0.25,
			AnchorPoint = Vector2.new(anchorX, 0),
			Position = UDim2.fromScale(anchorX, 0),
			Size = UDim2.new(1, 0, 0, math.floor(bodyHeight * 0.74)),
			Rotation = -1.5,
			Scaled = true,
			Wrapped = false,
			MaxTextSize = math.floor(bodyHeight * 0.62),
			ZIndex = 3,
			[Children] = scope:New "UIScale" { Scale = pop },
		}))
		for i, spec in { { 0.78, -1.2, 0.72 }, { 0.86, 0.8, 0.5 } } do
			table.insert(children, scope:New "Frame" {
				Name = "Underline" .. i,
				AnchorPoint = Vector2.new(anchorX, 0.5),
				Position = UDim2.new(anchorX, if anchorX == 0 then 4 * i else -4 * i, 0, math.floor(bodyHeight * spec[1])),
				Size = UDim2.new(spec[3], 0, 0, 3),
				Rotation = spec[2],
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Text
				end),
				BackgroundTransparency = 0.2 + 0.2 * (i - 1),
				ZIndex = 2,
				[Children] = Kit.corner(scope, "Full"),
			})
		end
	end
	if pips then
		table.insert(children, pips)
	end
	if props[Children] ~= nil then
		table.insert(children, props[Children])
	end
	root[Children] = children
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="97">
            <Properties>
              <string name="Name">Crosshair</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Crosshair: a dynamic crosshair with spread and hitmarkers (RIVALS style:
	thin white bars, a tiny centre dot, no heavy outline).

	local spread = scope:Value(6)     -- gap from centre in design px
	local hits = scope:Value(0)       -- +1 = white hitmarker
	local crits = scope:Value(0)      -- +1 = gold "critical" hitmarker (headshots)
	local kills = scope:Value(0)      -- +1 = red kill marker (bigger, holds longer)
	scope:Crosshair {
		Spread = spread,
		Hits = hits,
		Crits = crits,
		Kills = kills,
		Length = 9, Thickness = 2, Dot = true,
		Color = Color3.new(1, 1, 1),
		Outline = 0.72,               -- dark outline transparency (1 = none)
		Hitmarker = {                 -- mirrors the RIVALS hitmarker settings
			Scale = 1, Rotation = 0, Transparency = 0, CritTransparency = 0,
			Color = Color3.new(1, 1, 1), CritColor = gold, KillColor = red,
		},
		Visible = aimingNotScoped,
		Show = nil,                   -- "Hit" | "Crit" | "Kill" (or state): pin that marker on,
		                              -- e.g. a crosshair settings preview
		Markers = true,               -- false (or state): hits and kills only play their sound
	}

	It centres itself on its parent; put it straight in a Screen.

	Style (HUD kits): "Cross" (the four bars above, the original) | "Dot"
	(just the dot) | "Circle" (a ring that widens with spread) | "T" (no top
	bar) | "Chevron" (a small ^ under the dot that drops with spread) |
	"Brackets" ([ ] that open with spread). Hitmarkers work the same in all.

	Hitmarker timing (docs/RESEARCH.md 2.9): pops in at 1.35x and settles to
	1x in 0.06 s, holds 0.05 s, fades over 0.15 s. Kills use the kill colour,
	1.3x size and hold three times as long.
]]

local RunService = game:GetService("RunService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Sound = require(script.Parent.Parent.Util.Sound)

local Children = Fusion.Children
local peek = Fusion.peek

local BLACK = Color3.new(0, 0, 0)
local WHITE = Color3.new(1, 1, 1)

-- kind -> { scale, pop, hold, fade }
local TIMING = {
	Hit = { size = 1, pop = 0.06, hold = 0.05, fade = 0.15 },
	Crit = { size = 1.2, pop = 0.06, hold = 0.09, fade = 0.2 },
	Kill = { size = 1.3, pop = 0.07, hold = 0.18, fade = 0.3 },
}

return function(scope: Fusion.Scope<any>, props: { [any]: any }): Frame
	local length = props.Length or 9
	local thickness = props.Thickness or 2
	local spread = props.Spread or 6
	local marker = props.Hitmarker or {}
	local markerScale = marker.Scale or 1
	local outlineAlpha = props.Outline or 0.72

	local gap = scope:Spring(scope:Computed(function(use)
		return use(spread)
	end), 30, 0.8)

	local color = Kit.pick(scope, props.Color, function()
		return WHITE
	end)

	-- the hitmarker is driven frame by frame so pop / hold / fade are exact
	local markerAlpha = scope:Value(1)
	local markerPop = scope:Value(1)
	local kind = scope:Value("Hit")
	local age = math.huge
	local running: RBXScriptConnection? = nil

	local function step(dt: number)
		age += dt
		local t = TIMING[peek(kind)] or TIMING.Hit
		local base = if peek(kind) == "Crit" then (marker.CritTransparency or marker.Transparency or 0) else (marker.Transparency or 0)
		if age < t.pop then
			local u = age / t.pop
			markerPop:set(1.35 - 0.35 * (1 - (1 - u) ^ 2))
			markerAlpha:set(base)
		elseif age < t.pop + t.hold then
			markerPop:set(1)
			markerAlpha:set(base)
		elseif age < t.pop + t.hold + t.fade then
			local u = (age - t.pop - t.hold) / t.fade
			markerPop:set(1 + 0.08 * u)
			markerAlpha:set(base + (1 - base) * u)
		else
			markerAlpha:set(1)
			markerPop:set(1)
			if running then
				running:Disconnect()
				running = nil
			end
		end
	end

	local function flash(which: string)
		-- a kill marker is never downgraded by a hit landing in the same frame
		if peek(kind) == "Kill" and age < 0.12 and which ~= "Kill" then
			return
		end
		-- Markers = false (the player turned hit markers off): only the sound
		if peek(props.Markers) ~= false then
			kind:set(which)
			age = 0
			step(0)
			if running == nil then
				running = RunService.RenderStepped:Connect(step)
			end
		end
		if which == "Kill" then
			Sound.play("Kill", { Volume = 0.7 })
		elseif which == "Crit" then
			Sound.play("Crit", { Volume = 0.6 })
		else
			Sound.play("Hit", { Volume = 0.5 })
		end
		-- the classic tick doubles as a hit sound until the theme has one
		if Theme.get().Sound.Hit == nil or Theme.get().Sound.Hit == "" then
			Sound.play(if which == "Kill" then "Reward" else "Tick", {
				Pitch = if which == "Kill" then 1 elseif which == "Crit" then 1.9 else 1.6,
				Volume = 0.5,
			})
		end
	end
	table.insert(scope, function()
		if running then
			running:Disconnect()
			running = nil
		end
	end)

	local function watchCount(state: any, which: string)
		local last = peek(state) or 0
		Kit.watch(scope, state, function(now)
			if now > last then
				flash(which)
			end
			last = now
		end)
	end
	watchCount(props.Hits, "Hit")
	watchCount(props.Crits, "Crit")
	watchCount(props.Kills, "Kill")

	-- a pinned marker (settings previews, galleries) overrides the live one
	local shownKind = scope:Computed(function(use)
		return use(props.Show) or use(kind)
	end)
	local shownAlpha = scope:Computed(function(use)
		local pinned = use(props.Show)
		if pinned then
			return if pinned == "Crit" then (marker.CritTransparency or marker.Transparency or 0) else (marker.Transparency or 0)
		end
		return use(markerAlpha)
	end)
	local shownPop = scope:Computed(function(use)
		return if use(props.Show) then 1 else use(markerPop)
	end)

	local markerColor = scope:Computed(function(use)
		local theme = use(Theme.current)
		local now = use(shownKind)
		if now == "Kill" then
			return marker.KillColor or theme.Color.Callout or theme.Color.Bad
		elseif now == "Crit" then
			return marker.CritColor or theme.Tone.Yellow.Top
		end
		return marker.Color or WHITE
	end)

	local function arm(name: string, dx: number, dy: number)
		local vertical = dx == 0
		return scope:New "Frame" {
			Name = name,
			AnchorPoint = Vector2.new(0.5, 0.5),
			BackgroundColor3 = color,
			BorderSizePixel = 0,
			Size = if vertical then UDim2.fromOffset(thickness, length) else UDim2.fromOffset(length, thickness),
			Position = scope:Computed(function(use)
				local distance = use(gap) + length / 2
				return UDim2.fromOffset(dx * distance, dy * distance)
			end),
			[Children] = {
				Kit.corner(scope, 1),
				scope:New "UIStroke" {
					Color = BLACK,
					Thickness = 1,
					Transparency = outlineAlpha,
					Enabled = outlineAlpha < 1,
				},
			},
		}
	end

	local function hitBar(name: string, dx: number, dy: number)
		local barLength = length * 1.2
		return scope:New "Frame" {
			Name = name,
			AnchorPoint = Vector2.new(0.5, 0.5),
			BackgroundColor3 = markerColor,
			BackgroundTransparency = shownAlpha,
			BorderSizePixel = 0,
			Rotation = (if dx * dy > 0 then -45 else 45) + (marker.Rotation or 0),
			Size = scope:Computed(function(use)
				local now = use(shownKind)
				local grow = if now == "Kill" then 1.3 elseif now == "Crit" then 1.15 else 1
				return UDim2.fromOffset(thickness + (if now == "Kill" then 1 else 0), barLength * grow * markerScale)
			end),
			Position = scope:Computed(function(use)
				local t = TIMING[use(shownKind)] or TIMING.Hit
				local distance = (8 + barLength * 0.5) * 0.72 * t.size * markerScale * use(shownPop)
				return UDim2.fromOffset(dx * distance, dy * distance)
			end),
			ZIndex = 2,
			[Children] = {
				Kit.corner(scope, 1),
				scope:New "UIStroke" {
					Color = BLACK,
					Thickness = 1,
					Transparency = scope:Computed(function(use)
						return 0.55 + 0.45 * use(shownAlpha)
					end),
				},
			},
		}
	end

	-- a bar at an offset from the centre (px), rotated; the offset may be a
	-- function of the current gap
	local function bar(name: string, w: number, h: number, offset: (number) -> Vector2, rotation: number?)
		return scope:New "Frame" {
			Name = name,
			AnchorPoint = Vector2.new(0.5, 0.5),
			BackgroundColor3 = color,
			BorderSizePixel = 0,
			Size = UDim2.fromOffset(w, h),
			Rotation = rotation or 0,
			Position = scope:Computed(function(use)
				local o = offset(use(gap))
				return UDim2.fromOffset(o.X, o.Y)
			end),
			[Children] = {
				Kit.corner(scope, 1),
				scope:New "UIStroke" {
					Color = BLACK,
					Thickness = 1,
					Transparency = outlineAlpha,
					Enabled = outlineAlpha < 1,
				},
			},
		}
	end

	local style = props.Style or "Cross"
	local marks
	if style == "Dot" then
		marks = {}
	elseif style == "T" then
		marks = { arm("Bottom", 0, 1), arm("Left", -1, 0), arm("Right", 1, 0) }
	elseif style == "Circle" then
		local function ring(name: string, stroke: Color3 | any, width: number, alpha: any, grow: number)
			return scope:New "Frame" {
				Name = name,
				AnchorPoint = Vector2.new(0.5, 0.5),
				BackgroundTransparency = 1,
				Size = scope:Computed(function(use)
					local d = (use(gap) + 9) * 2 + grow
					return UDim2.fromOffset(d, d)
				end),
				[Children] = {
					scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
					scope:New "UIStroke" { Color = stroke, Thickness = width, Transparency = alpha },
				},
			}
		end
		marks = {
			ring("RingOutline", BLACK, thickness + 2, outlineAlpha, 0),
			ring("Ring", color, math.max(1.5, thickness - 0.5), 0, 0),
		}
	elseif style == "Chevron" then
		local arm2 = length * 0.9
		marks = {
			bar("ChevronLeft", thickness, arm2, function(g)
				return Vector2.new(-arm2 * 0.32, g + 4 + arm2 * 0.38)
			end, 40),
			bar("ChevronRight", thickness, arm2, function(g)
				return Vector2.new(arm2 * 0.32, g + 4 + arm2 * 0.38)
			end, -40),
		}
	elseif style == "Brackets" then
		local tall, tick = length * 1.6, math.max(4, length * 0.55)
		local function bracket(side: number)
			local name = if side < 0 then "Left" else "Right"
			return {
				bar(`Bracket{name}`, thickness, tall, function(g)
					return Vector2.new(side * (g + 10), 0)
				end),
				bar(`Bracket{name}Top`, tick, thickness, function(g)
					return Vector2.new(side * (g + 10) - side * tick / 2, -tall / 2 + thickness / 2)
				end),
				bar(`Bracket{name}Bottom`, tick, thickness, function(g)
					return Vector2.new(side * (g + 10) - side * tick / 2, tall / 2 - thickness / 2)
				end),
			}
		end
		marks = { bracket(-1), bracket(1) }
	else
		marks = {
			arm("Top", 0, -1),
			arm("Bottom", 0, 1),
			arm("Left", -1, 0),
			arm("Right", 1, 0),
		}
	end
	local dotSize = if style == "Dot" then math.max(thickness + 2, 4) else thickness

	local root = Kit.root(props, { Name = "Crosshair" })
	root.AnchorPoint = props.AnchorPoint or Vector2.new(0.5, 0.5)
	root.Position = props.Position or UDim2.fromScale(0.5, 0.5)
	root.Size = UDim2.fromOffset(0, 0)
	root.BackgroundTransparency = 1
	root[Children] = {
		marks,
		if props.Dot ~= false or style == "Dot"
			then scope:New "Frame" {
				Name = "Dot",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Size = UDim2.fromOffset(dotSize, dotSize),
				BackgroundColor3 = color,
				BorderSizePixel = 0,
				[Children] = {
					Kit.corner(scope, "Full"),
					scope:New "UIStroke" {
						Color = BLACK,
						Thickness = 1,
						Transparency = outlineAlpha,
						Enabled = outlineAlpha < 1,
					},
				},
			}
			else nil,
		hitBar("HitTL", -1, -1),
		hitBar("HitTR", 1, -1),
		hitBar("HitBL", -1, 1),
		hitBar("HitBR", 1, 1),
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="98">
            <Properties>
              <string name="Name">Currency</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Currency: icon + animated counter + optional "+" button. The number rolls
	up and down when the amount changes, and the icon pops.

	scope:Currency {
		Icon = "key",
		IconColor = Color3.fromHex("FFD21F"),
		Amount = keys,                 -- number or state
		Format = "comma",              -- "abbrev" (default) | "comma" | "plain" | function
		OnAdd = function() end,        -- shows a green + button
		Tooltip = "Complete your tasks for keys!",   -- optional bubble underneath
		Size = UDim2.fromOffset(220, 58),
		Style = "Glass",               -- pill surface (any Panel style; light ones get dark text)
		Align = "Center",              -- "Left" lines numbers up in a stack of counters
		Fit = false,                   -- true: the pill hugs the number (Size's width is ignored)
		TooltipVisible = true,         -- bool or state
		IconGlow = false,              -- soft halo behind the icon
	}

	HUD kit looks (Style = one of these instead of a surface name):
		"Pill"     the pill above with a big overlapping icon (the original; with a
		           Kit its plate, type and ink come from the kit)
		"Chip"     a slim chip: small icon, number, a small +
		"Tile"     icon above the number, + in the corner
		"Readout"  no plate: a tiny icon and an outlined number
		"Line"     icon + number for a row inside a bigger card (Wallet / Banner)
	Kit = "Chunky Toy"               -- HudKits name / tokens for those looks

	Tip: pass the returned frame to UI.FX.flyTo(from, currencyFrame, ...).
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Util.Format)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Button = require(script.Parent.Button)
local Bubble = require(script.Parent.Bubble)
local P = require(script.Parent.HudParts)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local COUNTER_STYLES = { Pill = true, Chip = true, Tile = true, Readout = true, Line = true }

-- The shared counter state: a rolling number, an icon pop and a gain/loss flash.
local function counter(scope: Scope, props: { [any]: any })
	local amount = props.Amount or 0
	-- numbers only: a string amount ("1,500", a StringValue) is read as a number
	local target = scope:Computed(function(use)
		local value = use(amount)
		if typeof(value) == "string" then
			value = tonumber((string.gsub(value, "[^%d%.%-]", "")))
		end
		return if typeof(value) == "number" and value == value then value else 0
	end)
	local rolling = scope:Spring(target, 9, 1)
	local shown = scope:Computed(function(use)
		return Format.number(math.round(use(rolling)), props.Format)
	end)

	local iconPopGoal = scope:Value(1)
	local iconPop = scope:Spring(iconPopGoal, 28, 0.35)
	local flash = scope:Value(0) -- 1 = gained, -1 = lost
	local flashTween = scope:Tween(flash, TweenInfo.new(0.5))

	local last = peek(target)
	Kit.watch(scope, target, function(now)
		if now > last then
			iconPop:addVelocity(7)
			flash:set(1)
		elseif now < last then
			iconPop:addVelocity(-4)
			flash:set(-1)
		end
		last = now
		task.delay(0.15, function()
			flash:set(0)
		end)
	end)

	-- the value to size for: the longer of the new and previous amount, so
	-- the number doesn't outgrow its space while it rolls between them
	local lastTarget = peek(target)
	local widest = scope:Computed(function(use)
		local now = use(target)
		local pick = if math.abs(lastTarget) > math.abs(now) then lastTarget else now
		lastTarget = now
		return Format.number(math.round(pick), props.Format)
	end)
	return {
		target = target,
		shown = shown,
		iconPop = iconPop,
		flashTween = flashTween,
		widest = widest,
	}
end

-- Base text colour tinted green on a gain and red on a loss.
local function flashColor(scope: Scope, c: any, base: any)
	return scope:Computed(function(use)
		local theme = use(Theme.current)
		local color = use(base)
		local f = use(c.flashTween)
		if f > 0 then
			return color:Lerp(theme.Color.Good, f)
		elseif f < 0 then
			return color:Lerp(theme.Color.Bad, -f)
		end
		return color
	end)
end

local function iconColor(scope: Scope, props: { [any]: any })
	return props.IconColor or Kit.themed(scope, function(theme)
		return theme.Tone.Yellow.Top
	end)
end

--------------------------------------------------------------------------
-- Pill: the original look
--------------------------------------------------------------------------

local function pill(scope: Scope, props: { [any]: any }, T: { [string]: any }?): Frame
	local c = counter(scope, props)
	local size = peek(props.Size) or UDim2.fromOffset(220, 58)
	local height = size.Y.Offset

	local hasAdd = props.OnAdd ~= nil
	local iconSize = math.floor(height * 1.25)
	local inset = math.floor(iconSize * 0.35)
	local leftAligned = props.Align == "Left"
	local numberFont = if T then T.numberFont else "Display"

	-- The number sits in the free space between the icon's right edge (plus
	-- a 12 px gap) and the + button, in pill coordinates. It's centred (or
	-- left-aligned) there and shrinks to fit, so a 1,234,567,890 never runs
	-- under the icon or the button. Fit = true instead sizes the pill to the
	-- number, so a short count doesn't sit in a long empty pill.
	local fitWidth = props.Fit == true
	local width = if size.X.Scale == 0 then size.X.Offset else 220
	local amountLeft = math.floor(iconSize * 0.86) + 12 - inset
	local rightPad = if hasAdd then (height - 10) + 5 + 10 else math.floor(height * 0.36)
	local fixedWidth = math.max(24, (width - inset) - rightPad - amountLeft)
	local maxTextSize = math.floor(height * 0.58)

	local amountWidth = if fitWidth
		then scope:Computed(function(use)
			local natural = P.textWidth(use(c.widest), maxTextSize, numberFont, use(Theme.current))
			return math.max(math.floor(height * 0.5), math.ceil(natural) + 4)
		end)
		else fixedWidth
	local fitSize = scope:Computed(function(use)
		if fitWidth then
			return maxTextSize
		end
		return P.fit(use(c.widest), maxTextSize, fixedWidth, numberFont, math.floor(maxTextSize * 0.5), use(Theme.current))
	end)

	-- the pill's surface: a Panel style (the original), or the kit's plate
	local pillColor, pillAlpha, pillChildren, onLight, kitLook
	if T then
		kitLook = P.look(scope, T)
		pillColor = Color3.new(1, 1, 1)
		pillAlpha = 1
		pillChildren = { P.plate(scope, T, { Radius = "Pill", ZIndex = 0, Idle = true }) }
		onLight = scope:Computed(function(use)
			return use(kitLook).light
		end)
	else
		local pillLook, pillLayers = Kit.surface(scope, {
			Style = props.Style or "Glass",
			Radius = "Full",
			Highlight = true,
			StrokeColor = props.RimColor,
		})
		pillColor = pillLook.BackgroundColor3
		pillAlpha = pillLook.BackgroundTransparency
		pillChildren = pillLayers
		onLight = scope:Computed(function(use)
			return use(pillLook.Look).light
		end)
	end

	local textColor = flashColor(scope, c, scope:Computed(function(use)
		local theme = use(Theme.current)
		if kitLook then
			return use(kitLook).ink
		end
		return if use(onLight) then theme.Color.TextDark else theme.Color.Text
	end))

	local iconGui = if T
		then P.icon(scope, T, {
			Name = "CurrencyIcon",
			Icon = props.Icon or "coin",
			Color = iconColor(scope, props),
			Size = iconSize,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0, math.floor(iconSize * 0.4), 0.5, 0),
			Rotation = props.IconRotation or -8,
			Treatment = if T.icon == "Badge" then "Gradient" else nil,
			ZIndex = 5,
			[Children] = scope:New "UIScale" { Scale = c.iconPop },
		})
		else Icon(scope, {
			Name = "CurrencyIcon",
			Icon = props.Icon or "coin",
			Color = iconColor(scope, props),
			Size = iconSize,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0, math.floor(iconSize * 0.4), 0.5, 0),
			Rotation = props.IconRotation or -8,
			Gradient = true,
			Shadow = true,
			Glow = props.IconGlow,
			ZIndex = 5,
			[Children] = scope:New "UIScale" { Scale = c.iconPop },
		})
	if T then
		-- the kit's idle life: a big icon that bobs gently (Toy, Arcade)
		P.idle(scope, T, iconGui, "Icon")
	end

	local root = Kit.root(props, { Name = "Currency" })
	root.Size = if fitWidth
		then scope:Computed(function(use)
			return UDim2.fromOffset(inset + amountLeft + use(amountWidth) + rightPad, height)
		end)
		else size
	root.BackgroundTransparency = 1
	root[Children] = {
		scope:New "Frame" {
			Name = "Pill",
			Position = UDim2.fromOffset(inset, 0),
			Size = UDim2.new(1, -inset, 1, 0),
			BackgroundColor3 = pillColor,
			BackgroundTransparency = pillAlpha,
			ZIndex = 1,
			[Children] = {
				pillChildren,
				(if kitLook
					then P.text(scope, T :: any, kitLook, {
						Name = "Amount",
						Role = "Number",
						Text = c.shown,
						TextSize = fitSize,
						Color = textColor,
						XAlign = if leftAligned then "Left" else "Center",
						AnchorPoint = Vector2.new(0, 0.5),
						Position = UDim2.new(0, amountLeft, 0.5, 0),
						Size = scope:Computed(function(use)
							return UDim2.fromOffset(use(amountWidth), math.floor(height * 0.8))
						end),
						ZIndex = 3,
					})
					else Text(scope, {
						Name = "Amount",
						Text = c.shown,
						TextSize = fitSize,
						Font = "Display",
						Color = textColor,
						Stroke = scope:Computed(function(use)
							return if use(onLight) then false else nil
						end),
						XAlign = if leftAligned then "Left" else "Center",
						AnchorPoint = Vector2.new(0, 0.5),
						Position = UDim2.new(0, amountLeft, 0.5, 0),
						Size = scope:Computed(function(use)
							return UDim2.fromOffset(use(amountWidth), math.floor(height * 0.8))
						end),
						ZIndex = 3,
					})) :: any,
				if hasAdd
					then Button(scope, {
						Name = "Add",
						Icon = "plus",
						Tone = "Green",
						Style = props.AddStyle or "Flat",
						AnchorPoint = Vector2.new(1, 0.5),
						Position = UDim2.new(1, -5, 0.5, 0),
						Size = UDim2.fromOffset(height - 10, height - 10),
						IconSize = math.floor(height * 0.52),
						Radius = if T and (T.corner == "Square" or T.corner == "Sharp" or T.plate == "Pixel") then T.corner == "Square" and 0 or 3 else "S",
						OnClick = props.OnAdd,
						ZIndex = 4,
					})
					else nil,
			},
		},
		iconGui,
		if props.Tooltip
			then Bubble(scope, {
				Text = props.Tooltip,
				Tone = "White",
				Pointer = "Top",
				TextSize = 20,
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 1, 16),
				Visible = props.TooltipVisible,
				ZIndex = 6,
			})
			else nil,
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end

--------------------------------------------------------------------------
-- Kit counter looks
--------------------------------------------------------------------------

-- A small + button in the kit's shape.
local function addButton(scope: Scope, T: { [string]: any }, props: { [any]: any }, size: number, extra: { [any]: any }): any
	if props.OnAdd == nil then
		return nil
	end
	local radius: any = if T.plate == "Pixel" or T.corner == "Square" then 0
		elseif T.corner == "Sharp" then 3
		elseif T.corner == "Round" or T.plate == "Toy" or T.plate == "Arcade" then "Full"
		else "S"
	local b = {
		Name = "Add",
		Icon = "plus",
		Tone = "Green",
		Style = if T.plate == "Neon" or T.plate == "Clear" then "Pill" else "Flat",
		Size = UDim2.fromOffset(size, size),
		IconSize = math.floor(size * 0.56),
		Radius = radius,
		OnClick = props.OnAdd,
		ZIndex = 6,
	}
	for k, v in extra do
		b[k] = v
	end
	return Button(scope, b)
end

local function kitIcon(scope: Scope, T: { [string]: any }, props: { [any]: any }, c: any, size: number, extra: { [any]: any })
	local p = {
		Name = "CurrencyIcon",
		Icon = props.Icon or "coin",
		Color = iconColor(scope, props),
		Size = size,
		ZIndex = 5,
		[Children] = scope:New "UIScale" { Scale = c.iconPop },
	}
	for k, v in extra do
		p[k] = v
	end
	return P.icon(scope, T, p)
end

local function counterRoot(scope: Scope, props: { [any]: any }, size: Vector2, children: { any }): Frame
	local root = Kit.root(props, { Name = "Currency" })
	root.BackgroundTransparency = 1
	root[Children] = { children, props[Children] }
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

local function sizeOf(props: { [any]: any }, default: Vector2): Vector2
	local s = peek(props.Size)
	if typeof(s) == "UDim2" and s.X.Scale == 0 and s.Y.Scale == 0 and s.X.Offset > 0 and s.Y.Offset > 0 then
		return Vector2.new(s.X.Offset, s.Y.Offset)
	end
	return default
end

local LOOKS = {}

-- A slim chip: small icon, the number, a small +.
function LOOKS.Chip(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local c = counter(scope, props)
	local look = P.look(scope, T)
	local size = sizeOf(props, Vector2.new(200, 42))
	local h = size.Y
	local icon = math.floor(h * 0.68)
	local addSize = if props.OnAdd then h - 10 else 0
	local left = 10 + icon + 8
	local numberW = size.X - left - (if props.OnAdd then addSize + 10 else 12)
	local maxText = math.floor(h * 0.58)
	return counterRoot(scope, props, size, {
		P.plate(scope, T, { Radius = if T.corner == "Round" then "Pill" else nil, Edge = "Left", Idle = true }),
		kitIcon(scope, T, props, c, icon, {
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, 10, 0.5, 0),
		}),
		P.text(scope, T, look, {
			Name = "Amount",
			Role = "Number",
			Text = c.shown,
			TextSize = scope:Computed(function(use)
				return P.fit(use(c.widest), maxText, numberW, T.numberFont, math.floor(maxText * 0.55), use(Theme.current))
			end),
			Color = flashColor(scope, c, scope:Computed(function(use)
				return use(look).ink
			end)),
			XAlign = if props.Align == "Right" then "Right" else "Left",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, left, 0.5, 0),
			Size = UDim2.fromOffset(numberW, h - 8),
			ZIndex = 4,
		}),
		addButton(scope, T, props, addSize, {
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -5, 0.5, 0),
		}),
	})
end

-- A tile: a big icon on top, the number underneath, + in the corner.
function LOOKS.Tile(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local c = counter(scope, props)
	local look = P.look(scope, T)
	local size = sizeOf(props, Vector2.new(92, 104))
	local icon = math.floor(size.X * 0.56)
	local maxText = math.floor(size.Y * 0.24)
	local numberW = size.X - 12
	local art = kitIcon(scope, T, props, c, icon, {
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, if T.plate == "Toy" or T.plate == "Arcade" then -10 else 6),
		Rotation = if (T.tilt or 0) ~= 0 then T.tilt * 2 else 0,
	})
	P.idle(scope, T, art, "Icon")
	return counterRoot(scope, props, size, {
		P.plate(scope, T, { Idle = true, Rotation = 0 }),
		art,
		P.text(scope, T, look, {
			Name = "Amount",
			Role = "Number",
			Text = c.shown,
			TextSize = scope:Computed(function(use)
				return P.fit(use(c.widest), maxText, numberW, T.numberFont, math.floor(maxText * 0.5), use(Theme.current))
			end),
			Color = flashColor(scope, c, scope:Computed(function(use)
				return use(look).ink
			end)),
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.new(0.5, 0, 1, -8),
			Size = UDim2.fromOffset(numberW, maxText + 6),
			ZIndex = 4,
		}),
		addButton(scope, T, props, 28, {
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(1, -4, 0, 4),
		}),
	})
end

-- No plate: a tiny icon and an outlined number.
function LOOKS.Readout(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local c = counter(scope, props)
	local readT = table.clone(T)
	readT.plate = "Clear"
	local look = P.look(scope, readT)
	local size = sizeOf(props, Vector2.new(180, 36))
	local h = size.Y
	local icon = math.floor(h * 0.62)
	local right = props.Align == "Right"
	local addSize = if props.OnAdd then math.floor(h * 0.7) else 0
	local maxText = math.floor(h * 0.84)
	local room = size.X - icon - 8 - (if props.OnAdd then addSize + 8 else 0)
	-- icon, number and + sit in a row that hugs the screen edge's side
	return counterRoot(scope, props, size, {
		scope:New "UIListLayout" {
			FillDirection = Enum.FillDirection.Horizontal,
			SortOrder = Enum.SortOrder.LayoutOrder,
			Padding = UDim.new(0, 8),
			HorizontalAlignment = if right then Enum.HorizontalAlignment.Right else Enum.HorizontalAlignment.Left,
			VerticalAlignment = Enum.VerticalAlignment.Center,
		},
		kitIcon(scope, T, props, c, icon, {
			LayoutOrder = if right then 3 else 1,
			Treatment = "Flat",
			Stroke = 1.5,
			StrokeColor = scope:Computed(function(use)
				return use(Theme.current).Color.Outline
			end),
		}),
		P.text(scope, readT, look, {
			Name = "Amount",
			Role = "Number",
			Text = c.shown,
			TextSize = scope:Computed(function(use)
				return P.fit(use(c.widest), maxText, room, T.numberFont, math.floor(maxText * 0.55), use(Theme.current))
			end),
			Font = T.numberFont,
			Color = flashColor(scope, c, scope:Computed(function(use)
				return use(Theme.current).Color.Text
			end)),
			LayoutOrder = 2,
			ZIndex = 4,
		}),
		addButton(scope, T, props, addSize, {
			LayoutOrder = if right then 0 else 3,
			Style = "Pill",
			Radius = "Full",
		}),
	})
end

-- A row entry for a bigger card (Wallet / Banner): icon + number (+).
function LOOKS.Line(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local c = counter(scope, props)
	local look = P.look(scope, T)
	local size = sizeOf(props, Vector2.new(160, 40))
	local h = size.Y
	local icon = math.floor(h * 0.78)
	local addSize = if props.OnAdd then math.floor(h * 0.7) else 0
	local left = icon + 8
	local numberW = size.X - left - (if props.OnAdd then addSize + 4 else 0)
	local maxText = math.floor(h * 0.62)
	return counterRoot(scope, props, size, {
		kitIcon(scope, T, props, c, icon, {
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, 0, 0.5, 0),
		}),
		P.text(scope, T, look, {
			Name = "Amount",
			Role = "Number",
			Text = c.shown,
			TextSize = scope:Computed(function(use)
				return P.fit(use(c.widest), maxText, numberW, T.numberFont, math.floor(maxText * 0.55), use(Theme.current))
			end),
			Color = flashColor(scope, c, scope:Computed(function(use)
				return use(look).ink
			end)),
			XAlign = props.XAlign or "Left",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, left, 0.5, 0),
			Size = UDim2.fromOffset(numberW, h),
			ZIndex = 4,
		}),
		addButton(scope, T, props, addSize, {
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, 0, 0.5, 0),
		}),
	})
end

return function(scope: Scope, props: { [any]: any }): Frame
	local style = props.Style
	if style == nil or not COUNTER_STYLES[style] then
		-- a surface name (or nothing): the original pill; with a Kit (and no
		-- surface), the pill in the kit's plate
		if props.Kit ~= nil and style == nil and P.kitted(props.Kit) then
			return pill(scope, props, P.tokens(props.Kit))
		end
		return pill(scope, props, nil)
	end
	if style == "Pill" then
		return pill(scope, props, if P.kitted(props.Kit) then P.tokens(props.Kit) else nil)
	end
	return LOOKS[style](scope, props, P.tokens(props.Kit))
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="99">
            <Properties>
              <string name="Name">DailyGrid</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	DailyGrid: a daily login reward calendar. Claimed days dim with a green
	check, today's reward glows, pulses and shines, future days wait in the
	theme's calm tone and the last day is a golden jackpot tile.

	local claimed = scope:Value(2)       -- last claimed day (0 = none)
	scope:DailyGrid {
		Days = {
			{ day = 1, icon = "coin", amount = 100, label = "Coins" },
			{ day = 2, icon = "gem", amount = 5, label = "Gems" },
			...
		},
		Today = 3,                       -- number or state: days unlocked so far
		Claimed = claimed,               -- Value<number>; set to the day when claimed
		OnClaim = function(day) end,
		Columns = 7,                     -- tiles per row (default: up to 7)
		TileSize = Vector2.new(128, 176),
		Style = "Tiles",                 -- "Tiles" | "Calendar" | "Ladder"
	}

	A day can be claimed when it's the next one after Claimed and <= Today.

	Styles:
	  Tiles     the classic reward row (scrolls past two rows)
	  Calendar  a month: compact tiles, every row shown (give it Columns = 7)
	  Ladder    a streak staircase: tiles rise from day 1 to the last day,
	            over a track that fills as the streak grows (one row)
]]

local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Format = require(script.Parent.Parent.Util.Format)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

local function field(entry: { [string]: any }, key: string): any
	local value = entry[key]
	if value == nil then
		value = entry[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

local function amountText(amount: any): string
	if typeof(amount) == "number" then
		return `x{Format.abbrev(amount)}`
	end
	return tostring(amount or "")
end

type TileState = "claimed" | "ready" | "waiting" | "locked"

-- Crisp four-point twinkles (two crossed gradient bars) that grow and fade
-- in turn. Returns stop().
local TWINKLE_SPOTS = {
	{ 0.2, 0.24, 0.2, 0 },
	{ 0.82, 0.3, 0.14, 0.7 },
	{ 0.74, 0.8, 0.17, 1.4 },
}
local function twinkles(parent: GuiObject, px: number, zIndex: number): () -> ()
	local tweens = {}
	local untracks = {}
	local roots = {}
	for _, spot in TWINKLE_SPOTS do
		local size = math.max(10, math.floor(px * spot[3]))
		local root = Instance.new("Frame")
		root.Name = "Twinkle"
		root.BackgroundTransparency = 1
		root.AnchorPoint = Vector2.new(0.5, 0.5)
		root.Position = UDim2.fromScale(spot[1], spot[2])
		root.Size = UDim2.fromOffset(size, size)
		root.Rotation = 0
		root.ZIndex = zIndex
		for _, rotation in { 0, 90 } do
			local bar = Instance.new("Frame")
			bar.Name = "Ray"
			bar.BackgroundColor3 = WHITE
			bar.BorderSizePixel = 0
			bar.AnchorPoint = Vector2.new(0.5, 0.5)
			bar.Position = UDim2.fromScale(0.5, 0.5)
			bar.Size = UDim2.new(1, 0, 0, math.max(2, math.floor(size * 0.16)))
			bar.Rotation = rotation
			bar.ZIndex = zIndex
			local corner = Instance.new("UICorner")
			corner.CornerRadius = UDim.new(0.5, 0)
			corner.Parent = bar
			local gradient = Instance.new("UIGradient")
			gradient.Transparency = NumberSequence.new({
				NumberSequenceKeypoint.new(0, 1),
				NumberSequenceKeypoint.new(0.5, 0),
				NumberSequenceKeypoint.new(1, 1),
			})
			gradient.Parent = bar
			bar.Parent = root
		end
		local scale = Instance.new("UIScale")
		scale.Scale = 0.15
		scale.Parent = root
		root.Parent = parent
		local tween = TweenService:Create(
			scale,
			TweenInfo.new(0.7, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true, spot[4] + 0.6),
			{ Scale = 1 }
		)
		tween:Play()
		table.insert(tweens, tween)
		table.insert(untracks, Motion.track(tween, root))
		table.insert(roots, root)
	end
	return function()
		for _, untrack in untracks do
			untrack()
		end
		for _, tween in tweens do
			tween:Cancel()
		end
		for _, root in roots do
			root:Destroy()
		end
	end
end


local function DailyGrid(scope: Scope, props: { [any]: any }): Frame
	local days = props.Days or {}
	local today = props.Today or 1
	local claimed = props.Claimed or scope:Value(0)
	local count = #days
	local columns = math.max(1, props.Columns or math.min(math.max(count, 1), 7))
	local tileSize: Vector2 = props.TileSize or Vector2.new(128, 176)
	local gap = props.Gap or 12
	local pad = 12
	local tw, th = tileSize.X, tileSize.Y

	local lastClaimed = scope:Computed(function(use)
		return tonumber(use(claimed)) or 0
	end)
	local todayNow = scope:Computed(function(use)
		return tonumber(use(today)) or 1
	end)

	local style = props.Style or "Tiles"
	local compact = style == "Calendar"

	-- One day tile, `tw` x `th`. Compact tiles (Calendar) have a slimmer
	-- header, a smaller amount and no label.
	local function makeTile(index: number, entry: { [string]: any }, tw: number, th: number): TextButton
		local day = field(entry, "day") or index
		local isFinal = index == count and count > 1
		local headerHeight = if compact then 26 else 36
		-- the icon sits between the header and the amount; a laned tile (Calendar,
		-- Ladder) centres it in exactly that gap so the claimed check never covers
		-- the header or the amount
		local laned = compact or style == "Ladder"
		-- the amount (and the label under it) take the bottom of the tile; the
		-- icon is centred in what is left, so it never covers them
		local footer = if compact then 26 elseif field(entry, "label") then 66 else 44
		local iconRoom = th - headerHeight - footer
		local iconY = headerHeight + math.floor(iconRoom / 2)
		-- a claimed laned tile shows only the check, centred under the header
		local checkY = if laned then headerHeight + math.floor((th - 5 - headerHeight) / 2) else iconY
		local state = scope:Computed(function(use): TileState
			local last = use(lastClaimed)
			if day <= last then
				return "claimed"
			elseif day == last + 1 and day <= use(todayNow) then
				return "ready"
			elseif day <= use(todayNow) then
				return "waiting"
			end
			return "locked"
		end)
		local ready = scope:Computed(function(use)
			return use(state) == "ready"
		end)
		local done = scope:Computed(function(use)
			return use(state) == "claimed"
		end)
		-- laned tiles (Calendar, Ladder) are short: once claimed they show only
		-- the check, so it never sits on top of the amount
		local hideWhenDone = scope:Computed(function(use)
			return not (laned and use(done))
		end)

		local tone = scope:Computed(function(use)
			local theme = use(Theme.current)
			local s = use(state)
			if s == "claimed" then
				return theme.Tone.Dark
			elseif s == "ready" then
				return theme.Tone.Green
			elseif isFinal then
				return theme.Tone.Yellow
			end
			-- days to come: blue, or slate in the military look (a month of
			-- bright blue tiles is a toy box, not a Tactical menu)
			local default = if Theme.style(theme, "Archetype") == "Tactical" then "Gray" else "Blue"
			return Theme.tone(theme, field(entry, "tone") or default)
		end)
		local strokeColor = scope:Computed(function(use)
			local t = use(tone)
			return t.Stroke or t.Edge
		end)

		local hovering = scope:Value(false)
		local pressing = scope:Value(false)
		local scale = Kit.spring(scope, scope:Computed(function(use)
			if not use(ready) then
				return 1
			elseif use(pressing) then
				return 0.94
			elseif use(hovering) then
				return 1.05
			end
			return 1
		end), 1.1)
		local popGoal = scope:Value(1)
		local pop = scope:Spring(popGoal, 30, 0.4)

		local tileRef: GuiObject? = nil
		local function claim()
			if not peek(ready) then
				Sound.play("Error")
				return
			end
			Sound.play("Reward")
			pop:setPosition(1.18)
			if tileRef then
				Particles.burst(tileRef, { Count = 22, Shape = "sparkle", Size = 24, Speed = 460 })
			end
			if typeof(claimed) == "table" and claimed.set then
				claimed:set(day)
			end
			if props.OnClaim then
				props.OnClaim(day)
			end
		end

		local face = scope:New "Frame" {
			Name = "Face",
			Size = UDim2.new(1, 0, 1, -5),
			BackgroundColor3 = WHITE,
			ZIndex = 2,
			[Children] = {
				Kit.corner(scope, "M"),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = scope:Computed(function(use)
						local t = use(tone)
						return ColorSequence.new({
							ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.08)),
							ColorSequenceKeypoint.new(0.55, t.Bottom),
							ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.3)),
						})
					end),
				}),
				scope:New "Frame" {
					Name = "Header",
					BackgroundColor3 = scope:Computed(function(use)
						return use(tone).Deep
					end),
					BackgroundTransparency = 0.45,
					Size = UDim2.new(1, 0, 0, headerHeight),
					ZIndex = 3,
					[Children] = {
						Kit.corner(scope, "M"),
						Text(scope, {
							Text = scope:Computed(function(use)
								return if use(ready) then "TODAY!" elseif compact then tostring(day) else `DAY {day}`
							end),
							TextSize = if compact then 18 else 22,
							Font = "Display",
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							StrokeColor = strokeColor,
							ZIndex = 4,
						}),
					},
				},
				scope:New "Frame" {
					Name = "Gloss",
					BackgroundColor3 = WHITE,
					BackgroundTransparency = 0.6,
					Position = UDim2.fromOffset(3, headerHeight + 2),
					Size = UDim2.new(1, -6, 0.3, 0),
					ZIndex = 3,
					[Children] = Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0.35, 1) }),
				},
				scope:New "ImageLabel" {
					Name = "Glow",
					BackgroundTransparency = 1,
					Image = Assets.SoftShadow,
					ImageColor3 = scope:Computed(function(use)
						return Color.lighten(use(tone).Top, 0.6)
					end),
					ImageTransparency = scope:Computed(function(use)
						return if use(ready) or isFinal then 0.3 else 0.75
					end),
					ScaleType = Enum.ScaleType.Slice,
					SliceCenter = Assets.SoftShadowSlice,
					SliceScale = (tw * 0.3) / Assets.SoftShadowFalloff,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 0, 0, iconY),
					Size = UDim2.fromOffset(tw * 0.8, tw * 0.8),
					ZIndex = 3,
				},
				Icon(scope, {
					Name = "RewardIcon",
					Icon = field(entry, "icon") or "gift",
					Size = math.floor(math.min(math.min(tw, th) * (if compact then 0.42 elseif isFinal then 0.62 else 0.56), iconRoom + 8)),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 0, 0, iconY),
					Rotation = -8,
					Color = scope:Computed(function(use)
						return Color.lighten(use(tone).Top, 0.5)
					end),
					Gradient = true,
					Shadow = true,
					Stroke = 3,
					StrokeColor = strokeColor,
					Transparency = scope:Computed(function(use)
						return if use(done) then 0.45 else 0
					end),
					Visible = hideWhenDone,
					ZIndex = 4,
				}),
				Text(scope, {
					Name = "Amount",
					Text = amountText(field(entry, "amount")),
					Case = "Normal",
					TextSize = if compact then 20 else 28,
					Font = "Display",
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, if compact then -7 elseif field(entry, "label") then -30 else -12),
					Size = UDim2.new(1, -8, 0, if compact then 22 else 32),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = if compact then 20 else 28,
					StrokeColor = strokeColor,
					Stroke = if compact then 2 else 3,
					Visible = hideWhenDone,
					ZIndex = 5,
				}),
				if field(entry, "label") and not compact
					then Text(scope, {
						Name = "Label",
						Text = field(entry, "label"),
						TextSize = 17,
						Font = "Heavy",
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.new(0.5, 0, 1, -10),
						Size = UDim2.new(1, -12, 0, 20),
						Truncate = true,
						StrokeColor = strokeColor,
						Stroke = 1.5,
						Visible = hideWhenDone,
						ZIndex = 5,
					})
					else nil,
			},
		}

		local tile = scope:New "TextButton" {
			Name = `Day{day}`,
			LayoutOrder = index,
			Text = "",
			AutoButtonColor = false,
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(tw, th),
			Selectable = true,
			[OnEvent "MouseEnter"] = function()
				hovering:set(true)
			end,
			[OnEvent "MouseLeave"] = function()
				hovering:set(false)
				pressing:set(false)
			end,
			[OnEvent "MouseButton1Down"] = function()
				pressing:set(true)
			end,
			[OnEvent "MouseButton1Up"] = function()
				pressing:set(false)
			end,
			[OnEvent "Activated"] = function()
				pressing:set(false)
				claim()
			end,
			[Children] = {
				scope:New "UIScale" {
					Scale = scope:Computed(function(use)
						return use(scale) * use(pop)
					end),
				},
				scope:New "Frame" {
					Name = "Lip",
					Size = UDim2.fromScale(1, 1),
					BackgroundColor3 = scope:Computed(function(use)
						return use(tone).Deep
					end),
					ZIndex = 1,
					[Children] = {
						Kit.corner(scope, "M"),
						Kit.stroke(scope, {
							Thickness = scope:Computed(function(use)
								return if use(ready) then 4 else 3
							end),
							Color = scope:Computed(function(use)
								local theme = use(Theme.current)
								return if use(ready) then theme.Color.Light else use(tone).Edge
							end),
						}),
					},
				},
				face,
				scope:New "Frame" {
					Name = "ClaimedMark",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 0, 0, checkY),
					Size = if compact then UDim2.fromOffset(34, 34) elseif laned then UDim2.fromOffset(46, 46) else UDim2.fromOffset(56, 56),
					BackgroundColor3 = WHITE,
					Visible = done,
					ZIndex = 6,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.gradient(scope, {
							Rotation = 90,
							Color = Kit.themed(scope, function(theme)
								return ColorSequence.new(theme.Tone.Green.Top, theme.Tone.Green.Bottom)
							end),
						}),
						Kit.stroke(scope, {
							Thickness = 3,
							Color = Kit.themed(scope, function(theme)
								return theme.Color.Light
							end),
						}),
						Icon(scope, {
							Icon = "check",
							Size = if compact then 26 else 38,
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = Kit.themed(scope, function(theme)
								return theme.Tone.Green.Text
							end),
							StrokeColor = Kit.themed(scope, function(theme)
								return theme.Tone.Green.Edge
							end),
							Stroke = 2,
							ZIndex = 7,
						}),
					},
				},
				if isFinal
					then scope:New "Frame" {
						Name = "Jackpot",
						AnchorPoint = Vector2.new(1, 1),
						Position = UDim2.new(1, 8, 0, 10),
						Rotation = 8,
						Size = UDim2.fromOffset(0, if compact then 20 else 26),
						AutomaticSize = Enum.AutomaticSize.X,
						BackgroundColor3 = Kit.themed(scope, function(theme)
							return theme.Tone.Red.Bottom
						end),
						Visible = scope:Computed(function(use)
							return not use(done)
						end),
						ZIndex = 8,
						[Children] = {
							Kit.corner(scope, "Full"),
							Kit.padding(scope, { 0, 10 }),
							Kit.stroke(scope, {
								Thickness = 2,
								Color = Kit.themed(scope, function(theme)
									return theme.Color.Light
								end),
							}),
							Text(scope, {
								Text = "BIG!",
								TextSize = if compact then 14 else 18,
								Font = "Display",
								Size = UDim2.fromScale(0, 1),
								AutoSize = "X",
								StrokeColor = Kit.themed(scope, function(theme)
									return theme.Tone.Red.Edge
								end),
								ZIndex = 9,
							}),
						},
					}
					else nil,
			},
		}
		tileRef = tile

		-- Today's tile breathes and shines; the jackpot sparkles.
		local stopPulse: (() -> ())? = nil
		local stopShine: (() -> ())? = nil
		local function update(isReady: boolean)
			if isReady and stopPulse == nil then
				stopPulse = Motion.pulse(face, 0.05, 1)
				stopShine = Motion.shine(face, { ZIndex = 4, Transparency = 0.5, Interval = 2.4 })
			elseif not isReady and stopPulse then
				stopPulse()
				stopPulse = nil
				if stopShine then
					stopShine()
					stopShine = nil
				end
			end
		end
		update(peek(ready))
		Kit.watch(scope, ready, update)
		table.insert(scope, function()
			if stopPulse then
				stopPulse()
			end
			if stopShine then
				stopShine()
			end
		end)
		if isFinal then
			table.insert(scope, twinkles(face, tw, 6))
		end

		return tile
	end

	-- Ladder: tiles rise from ~60% to full height, bottom-aligned, over a
	-- streak track that fills up to the last claimed day
	if style == "Ladder" and count > 0 then
		local tiles = {}
		local trackHeight = 34
		for index, entry in days do
			local rise = if count > 1 then (index - 1) / (count - 1) else 1
			local tileH = math.floor(th * (0.7 + 0.3 * rise))
			local tile = makeTile(index, entry, tw, tileH)
			tile.Position = UDim2.fromOffset(pad + (index - 1) * (tw + gap), pad + th - tileH)
			table.insert(tiles, tile)
		end
		local span = math.max(1, count - 1) * (tw + gap)
		local fill = scope:Spring(scope:Computed(function(use)
			if count <= 1 then
				return 1
			end
			return math.clamp((use(lastClaimed) - 1) / (count - 1), 0, 1)
		end), 18, 0.9)
		local dots = {}
		for index = 1, count do
			local reached = scope:Computed(function(use)
				return index <= use(lastClaimed)
			end)
			table.insert(dots, scope:New "Frame" {
				Name = `Step{index}`,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0, pad + tw / 2 + (index - 1) * (tw + gap), 0.5, 0),
				Size = UDim2.fromOffset(22, 22),
				BackgroundColor3 = scope:Computed(function(use)
					local theme = use(Theme.current)
					return if use(reached) then theme.Tone.Green.Top else theme.Color.Surface
				end),
				ZIndex = 4,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.stroke(scope, {
						Thickness = 3,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Light
						end),
					}),
				},
			})
		end
		local track = scope:New "Frame" {
			Name = "Streak",
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(0, pad + th + 8),
			Size = UDim2.new(1, 0, 0, trackHeight - 8),
			ZIndex = 2,
			[Children] = {
				scope:New "Frame" {
					Name = "Rail",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, pad + tw / 2, 0.5, 0),
					Size = UDim2.new(0, span, 0, 10),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Surface
					end),
					BackgroundTransparency = 0.25,
					ZIndex = 2,
					[Children] = {
						Kit.corner(scope, "Full"),
						scope:New "Frame" {
							Name = "Fill",
							Size = scope:Computed(function(use)
								return UDim2.fromScale(math.clamp(use(fill), 0, 1), 1)
							end),
							BackgroundColor3 = Kit.themed(scope, function(theme)
								return theme.Tone.Green.Top
							end),
							ZIndex = 3,
							[Children] = {
								Kit.corner(scope, "Full"),
								Kit.gradient(scope, {
									Rotation = 90,
									Color = Kit.themed(scope, function(theme)
										return ColorSequence.new(theme.Tone.Green.Top, theme.Tone.Green.Bottom)
									end),
								}),
							},
						},
					},
				},
				dots,
			},
		}
		local width = count * tw + (count - 1) * gap + pad * 2
		local height = th + pad * 2 + trackHeight
		local root = Kit.root(props, { Name = "DailyGrid" })
		root.Size = props.Size or UDim2.fromOffset(width, height)
		root.BackgroundTransparency = 1
		root[Children] = {
			scope:New "Frame" {
				Name = "Ladder",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.fromScale(0.5, 0),
				Size = UDim2.fromOffset(width, height),
				[Children] = { tiles, track },
			},
			props[Children],
		}
		return scope:New "Frame" (root) :: Frame
	end

	local tiles = {}
	for index, entry in days do
		table.insert(tiles, makeTile(index, entry, tw, th))
	end

	local rows = math.max(1, math.ceil(count / columns))
	local width = columns * tw + (columns - 1) * gap + pad * 2
	local height = rows * th + (rows - 1) * gap + pad * 2

	local root = Kit.root(props, { Name = "DailyGrid" })
	-- a calendar shows the whole month; the reward row scrolls past two rows
	root.Size = props.Size or UDim2.fromOffset(width, if compact then height else math.min(height, th * 2 + gap + pad * 2))
	root.BackgroundTransparency = 1
	root[Children] = {
		scope:New "ScrollingFrame" {
			Name = "Scroll",
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			BorderSizePixel = 0,
			ScrollingDirection = Enum.ScrollingDirection.Y,
			CanvasSize = UDim2.fromOffset(0, height),
			ScrollBarThickness = 6,
			ScrollBarImageColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Text
			end),
			ScrollBarImageTransparency = 0.5,
			Selectable = false,
			[Children] = {
				scope:New "UIGridLayout" {
					CellSize = UDim2.fromOffset(tw, th),
					CellPadding = UDim2.fromOffset(gap, gap),
					FillDirection = Enum.FillDirection.Horizontal,
					FillDirectionMaxCells = columns,
					SortOrder = Enum.SortOrder.LayoutOrder,
					HorizontalAlignment = Enum.HorizontalAlignment.Center,
					VerticalAlignment = Enum.VerticalAlignment.Top,
				},
				Kit.padding(scope, pad),
				tiles,
			},
		},
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end

return DailyGrid
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="100">
            <Properties>
              <string name="Name">GiftGrid</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	GiftGrid: free playtime gifts. A grid of wrapped boxes that open after so
	many minutes of play: each counts down on its tag, then glows, shakes
	and says OPEN! when it's ready. Opening one pops it with a poof and the
	reward jumps out; opened boxes stay dim with a check.

		scope:GiftGrid {
			Gifts = {
				{ minutes = 1, icon = "coin", amount = "50", rarity = "Common" },
				{ minutes = 5, icon = "gem", amount = "10", rarity = "Rare", label = "Gems" },
				...
			},
			Playtime = playtime,              -- state: seconds played this session
			Claimed = mask,                   -- state: bit mask of opened gifts (bit 1 = gift 1)
			OnClaim = function(index, tile) end,   -- a ready box was pressed
			Columns = 4,
			TileSize = Vector2.new(150, 184),
			Gap = 14,
		}

	GiftGrid.open(tile) plays the opening on a tile (call it once the claim
	went through). Reduced motion keeps the boxes still.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)

local GiftGrid = {}

local function bitOn(mask: number, index: number): boolean
	return bit32.btest(mask, bit32.lshift(1, index - 1))
end

-- Wrapping paper: every box its own bright colour; Legendary and rarer
-- prizes come wrapped in their rarity's colour. Serious archetypes wrap in
-- a few quiet tones of their own instead (supply crates, not candy), so
-- the rare prizes still stand out.
local PAPER = { "Pink", "Blue", "Green", "Orange", "Purple", "Cyan" }
GiftGrid.PAPERS = {
	Arena = { "Blue", "Dark", "Purple", "Gray" },
	Sleek = { "Blue", "Purple", "Cyan", "Dark" },
	Tactical = { "Gray", "Dark" },
	Horror = { "Dark", "Gray", "Red" },
}
local RANK = { Common = 1, Uncommon = 2, Rare = 3, Epic = 4, Legendary = 5, Mythic = 6, Secret = 7 }

-- The wrapping papers for a theme (GiftGrid.PAPERS by archetype).
function GiftGrid.papers(theme: any): { string }
	local archetype = Theme.style(theme, "Archetype")
	-- Toon: the Arena archetype in glossy 3D buttons, a playful theme
	if archetype == "Arena" and Theme.style(theme, "ButtonStyle") == "Glossy3D" then
		return PAPER
	end
	return GiftGrid.PAPERS[archetype] or PAPER
end

-- "2:14" / "1:02:05"
local function clockText(seconds: number): string
	return Format.time(math.max(0, math.ceil(seconds)))
end

local openers: { [Instance]: () -> () } = setmetatable({}, { __mode = "k" }) :: any

-- Plays the opening on a tile made by GiftGrid (after the claim succeeded).
function GiftGrid.open(tile: Instance?)
	local play = tile and openers[tile]
	if play then
		play()
	end
end

local function GiftGridComponent(scope: any, props: { [any]: any }): Frame
	local gifts = props.Gifts or {}
	local count = #gifts
	local columns = math.max(1, props.Columns or math.min(count, 4))
	local tileSize: Vector2 = props.TileSize or Vector2.new(150, 184)
	local gap = props.Gap or 14
	local tw, th = tileSize.X, tileSize.Y
	local playtime = props.Playtime or 0
	local claimed = props.Claimed or 0

	local tiles = {}
	for index, gift in gifts do
		local rarity = gift.rarity or "Common"
		local isClaimed = scope:Computed(function(use)
			return bitOn(tonumber(use(claimed)) or 0, index)
		end)
		local left = scope:Computed(function(use)
			return gift.minutes * 60 - (tonumber(use(playtime)) or 0)
		end)
		local ready = scope:Computed(function(use)
			return not use(isClaimed) and use(left) <= 0
		end)
		local tone = scope:Computed(function(use)
			local theme = use(Theme.current)
			if (RANK[rarity] or 1) >= 5 then
				return Theme.rarity(theme, rarity)
			end
			local papers = GiftGrid.papers(theme)
			return Theme.tone(theme, papers[((index - 1) % #papers) + 1])
		end)
		local boxTone = scope:Computed(function(use)
			local theme = use(Theme.current)
			if use(isClaimed) then
				return theme.Tone.Dark
			end
			return use(tone)
		end)

		local hovering = scope:Value(false)
		local pressing = scope:Value(false)
		local scale = Kit.spring(scope, scope:Computed(function(use)
			if not use(ready) then
				return 1
			elseif use(pressing) then
				return 0.94
			elseif use(hovering) then
				return 1.05
			end
			return 1
		end), 1.1)
		local popGoal = scope:Value(1)
		local pop = scope:Spring(popGoal, 26, 0.45)
		local revealGoal = scope:Value(0)
		local reveal = scope:Spring(revealGoal, 22, 0.5)
		local artSize = math.floor(math.min(tw * 0.58, th * 0.44))
		local artY = math.floor(th * 0.46)

		local box = Icon(scope, {
			Name = "Box",
			Icon = "gift",
			Size = artSize,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5, 0, 0, artY),
			Color = scope:Computed(function(use)
				return Color.lighten(use(boxTone).Top, 0.35)
			end),
			Gradient = true,
			Stroke = 3,
			StrokeColor = scope:Computed(function(use)
				local t = use(boxTone)
				return t.Stroke or t.Edge
			end),
			Shadow = true,
			Transparency = scope:Computed(function(use)
				return if use(isClaimed) then 1 elseif use(ready) then 0 else 0.18
			end),
			ZIndex = 5,
			[Children] = scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					return math.max(0, 1 - use(reveal) * 0.4)
				end),
			},
		})

		-- the prize, shown once the box is open
		local prize = Icon(scope, {
			Name = "Prize",
			Icon = gift.icon or "gift",
			Size = math.floor(artSize * 0.86),
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5, 0, 0, artY),
			Color = scope:Computed(function(use)
				return Color.lighten(use(tone).Top, 0.4)
			end),
			Gradient = true,
			Stroke = 3,
			StrokeColor = scope:Computed(function(use)
				local t = use(tone)
				return t.Stroke or t.Edge
			end),
			Shadow = true,
			Visible = isClaimed,
			Transparency = 0.35,
			ZIndex = 6,
			[Children] = scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					return math.max(0, 0.6 + 0.4 * math.clamp(use(reveal), 0, 1.4))
				end),
			},
		})

		local glow = Kit.unlessLite(scope, function(inner)
			return inner:New "ImageLabel" {
				Name = "Glow",
				BackgroundTransparency = 1,
				Image = "rbxasset://textures/ui/InGameMenu/BackgroundGlow.png",
				ImageColor3 = inner:Computed(function(use)
					return Color.lighten(use(tone).Top, 0.3)
				end),
				ImageTransparency = inner:Computed(function(use)
					return if use(ready) then 0.15 else 0.8
				end),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 0, 0, artY),
				Size = UDim2.fromOffset(artSize * 2.1, artSize * 2.1),
				ZIndex = 4,
			}
		end)

		-- the tag on top: a countdown, OPEN! or OPENED
		local tagTone = scope:Computed(function(use)
			local theme = use(Theme.current)
			if use(isClaimed) then
				return theme.Tone.Gray
			elseif use(ready) then
				return theme.Tone.Green
			end
			return theme.Tone.Dark
		end)
		local tag = scope:New "Frame" {
			Name = "Tag",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromOffset(tw / 2, 8),
			Size = UDim2.fromOffset(math.min(tw - 16, 118), 30),
			BackgroundColor3 = WHITE,
			ZIndex = 7,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = scope:Computed(function(use)
						local t = use(tagTone)
						return ColorSequence.new(Color.lighten(t.Top, 0.05), t.Bottom)
					end),
				}),
				Kit.stroke(scope, {
					Thickness = 2,
					Color = scope:Computed(function(use)
						return use(tagTone).Edge
					end),
				}),
				scope:New "Frame" {
					Name = "Row",
					BackgroundTransparency = 1,
					Size = UDim2.fromScale(1, 1),
					ZIndex = 8,
					[Children] = {
						Kit.list(scope, { Direction = "X", Padding = 5, HAlign = "Center", VAlign = "Center" }),
						Icon(scope, {
							Icon = scope:Computed(function(use)
								return if use(isClaimed) then "check" elseif use(ready) then "gift" else "clock"
							end),
							Size = 20,
							Color = WHITE,
							LayoutOrder = 1,
							ZIndex = 8,
						}),
						Text(scope, {
							Name = "Status",
							Text = scope:Computed(function(use)
								if use(isClaimed) then
									return "OPENED"
								elseif use(ready) then
									return "OPEN!"
								end
								return clockText(use(left))
							end),
							Case = "Normal",
							TextSize = 19,
							Font = "Display",
							Stroke = 2,
							StrokeColor = scope:Computed(function(use)
								local t = use(tagTone)
								return t.Stroke or t.Edge
							end),
							LayoutOrder = 2,
							ZIndex = 8,
						}),
					},
				},
			},
		}

		-- the reward along the bottom
		local footer = scope:New "Frame" {
			Name = "Reward",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.new(0.5, 0, 1, -12),
			Size = UDim2.fromOffset(tw - 16, 28),
			ZIndex = 7,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 5, HAlign = "Center", VAlign = "Center" }),
				Icon(scope, {
					Icon = gift.icon or "gift",
					Size = 24,
					Color = scope:Computed(function(use)
						return Color.lighten(use(tone).Top, 0.3)
					end),
					Gradient = true,
					StrokeColor = scope:Computed(function(use)
						return use(tone).Edge
					end),
					LayoutOrder = 1,
					ZIndex = 7,
				}),
				Text(scope, {
					Text = tostring(gift.label or gift.amount or ""),
					Case = "Normal",
					TextSize = 22,
					Font = "Display",
					Stroke = 2,
					LayoutOrder = 2,
					ZIndex = 7,
				}),
			},
		}
		local _ = footer

		local faceTone = scope:Computed(function(use)
			local theme = use(Theme.current)
			return if use(isClaimed) then theme.Tone.Dark else use(tone)
		end)
		local tile
		tile = scope:New "TextButton" {
			Name = `Gift{index}`,
			LayoutOrder = index,
			Text = "",
			AutoButtonColor = false,
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(tw, th),
			Selectable = true,
			[OnEvent "MouseEnter"] = function()
				hovering:set(true)
			end,
			[OnEvent "MouseLeave"] = function()
				hovering:set(false)
				pressing:set(false)
			end,
			[OnEvent "MouseButton1Down"] = function()
				pressing:set(true)
			end,
			[OnEvent "MouseButton1Up"] = function()
				pressing:set(false)
			end,
			[OnEvent "Activated"] = function()
				pressing:set(false)
				if peek(isClaimed) then
					return
				end
				if not peek(ready) then
					Sound.play("Error")
					pcall(Motion.shake, box, 5, 0.25)
					return
				end
				if props.OnClaim then
					props.OnClaim(index, tile)
				end
			end,
			[Children] = {
				scope:New "UIScale" {
					Scale = scope:Computed(function(use)
						return use(scale) * use(pop)
					end),
				},
				scope:New "Frame" {
					Name = "Lip",
					Size = UDim2.fromScale(1, 1),
					BackgroundColor3 = scope:Computed(function(use)
						return use(faceTone).Deep
					end),
					ZIndex = 1,
					[Children] = {
						Kit.corner(scope, "M"),
						Kit.stroke(scope, {
							Thickness = scope:Computed(function(use)
								return if use(ready) then 4 else 2.5
							end),
							Color = scope:Computed(function(use)
								local theme = use(Theme.current)
								return if use(ready) then theme.Color.Light else use(faceTone).Edge
							end),
						}),
					},
				},
				scope:New "Frame" {
					Name = "Face",
					Size = UDim2.new(1, 0, 1, -5),
					BackgroundColor3 = WHITE,
					ZIndex = 2,
					[Children] = {
						Kit.corner(scope, "M"),
						Kit.gradient(scope, {
							Rotation = 90,
							Color = scope:Computed(function(use)
								local t = use(faceTone)
								local claimedNow = use(isClaimed)
								local top = if claimedNow then t.Top else Color.darken(t.Bottom, 0.2)
								return ColorSequence.new({
									ColorSequenceKeypoint.new(0, top),
									ColorSequenceKeypoint.new(0.6, Color.darken(t.Bottom, 0.45)),
									ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.62)),
								})
							end),
						}),
						Kit.unlessLite(scope, function(inner)
							return inner:New "Frame" {
								Name = "Gloss",
								BackgroundColor3 = WHITE,
								BackgroundTransparency = 0.82,
								Position = UDim2.fromOffset(3, 3),
								Size = UDim2.new(1, -6, 0.38, 0),
								ZIndex = 3,
								[Children] = {
									Kit.corner(inner, "M"),
									Kit.gradient(inner, { Rotation = 90, Transparency = NumberSequence.new(0.2, 1) }),
								},
							}
						end),
					},
				},
				glow,
				box,
				prize,
				tag,
				footer,
			},
		}

		-- ready boxes wiggle and shine; opened ones rest
		local stopWiggle: (() -> ())? = nil
		local stopShine: (() -> ())? = nil
		local function update(isReady: boolean)
			if isReady and stopWiggle == nil then
				stopWiggle = Motion.wiggle(box, 7, 0.55)
				stopShine = Motion.shine(tile:FindFirstChild("Face") :: GuiObject, { ZIndex = 4, Transparency = 0.55, Interval = 2.2 })
			elseif not isReady and stopWiggle then
				stopWiggle()
				stopWiggle = nil
				if stopShine then
					stopShine()
					stopShine = nil
				end
			end
		end
		update(peek(ready))
		Kit.watch(scope, ready, update)
		table.insert(scope, function()
			if stopWiggle then
				stopWiggle()
			end
			if stopShine then
				stopShine()
			end
		end)

		openers[tile] = function()
			local t = peek(tone)
			Sound.play("Pop")
			Sound.play("Reward")
			pop:setPosition(1.22)
			reveal:setPosition(0)
			revealGoal:set(1)
			pcall(function()
				Particles.poof(box, { Color = Color.lighten(t.Top, 0.5), Count = 9, Size = 30, Reach = 70 })
				Particles.burst(box, { Count = 18, Shape = "sparkle", Colors = { t.Top, WHITE, Color.lighten(t.Top, 0.5) }, Size = 22, Speed = 420 })
			end)
		end
		table.insert(tiles, tile)
	end

	local rows = math.max(1, math.ceil(count / columns))
	local width = columns * tw + (columns - 1) * gap
	local height = rows * th + (rows - 1) * gap
	-- placed by hand (not a grid layout), so a tile's position is known the
	-- moment it's built: a claim's poof and flying reward start at its own box
	for index, tile in tiles do
		local row = (index - 1) // columns
		local col = (index - 1) % columns
		local inRow = math.min(columns, count - row * columns)
		local rowWidth = inRow * tw + (inRow - 1) * gap
		tile.Position = UDim2.fromOffset((width - rowWidth) / 2 + col * (tw + gap), row * (th + gap))
	end
	local root = Kit.root(props, { Name = "GiftGrid" })
	root.Size = props.Size or UDim2.fromOffset(width, height)
	root.BackgroundTransparency = 1
	root[Children] = { tiles, props[Children] }
	return scope:New "Frame"(root) :: Frame
end

GiftGrid.new = GiftGridComponent
setmetatable(GiftGrid, {
	__call = function(_, scope: any, props: { [any]: any })
		return GiftGridComponent(scope, props)
	end,
})

return GiftGrid
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="101">
            <Properties>
              <string name="Name">HatchReveal</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	HatchReveal: the egg-hatch moment (Pet Simulator style). The egg drops in
	and wobbles three times, each harder (the last one hops), cracks, charges
	up and bursts into shell shards with a white flash and a shake; the
	result pops out over spinning rays with its rarity in colour. Rare eggs
	give themselves away: while charging, a Legendary+ egg glows in its
	rarity colour and charges longer, then lands with a coloured flash,
	camera shake, a second shockwave, twinkles and confetti (party poppers for
	Mythic and Secret). Click, tap, Space, Enter or A fast-forwards the
	shaking and continues after the reveal. Yields until the player continues.

		local pet = UI.hatch({            -- or require(BloxUI.Components.HatchReveal).reveal
			Egg = "egg",                  -- icon for the egg
			EggTone = "Cyan",             -- tone of the egg
			Result = { name = "Golden Dragon", icon = "flame", rarity = "Legendary", chance = "1 in 250" },
			Title = "Hatching...",        -- optional headline while the egg shakes
		})

		-- triple hatch: up to 8 eggs side by side, they burst one after another
		local pets = UI.hatch({ Egg = "egg", EggTone = "Pink", Results = { petA, petB, petC } })

	Result fields: name, icon, rarity ("Common" .. "Secret"), plus optional
	chance (text, shown under the rarity) and color (Color3 for the icon).
	Capitalised keys (Name, Icon, Rarity) work too. Returns the result (or
	the list of results for `Results`). Without a LocalPlayer (plugin
	previews) it returns straight away.

	Skip = true (or HatchReveal.skip = true for every hatch; the Blueprint
	"skipHatch" setting) plays the fast path by itself: the shaking is
	fast-forwarded as if the player had tapped, and the reveal continues on
	its own after a moment. With reduced motion on there is no white flash.
]]

local Players = game:GetService("Players")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Input = require(script.Parent.Parent.Util.Input)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local FXScreen = require(script.Parent.Parent.FX.Screen)
local Screen = require(script.Parent.Screen)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local RAY = "rbxasset://textures/ui/Emotes/Large/SelectedGradient.png"
local GLOW = Assets.Glow or "rbxasset://textures/ui/InGameMenu/BackgroundGlow.png"
local TRIANGLE = "rbxasset://textures/triangle.png"
local CIRCLE = Assets.Circle or "rbxasset://textures/whiteCircle.png"
local WHITE = Color3.new(1, 1, 1)

local RANK = { Common = 1, Uncommon = 2, Rare = 3, Epic = 4, Legendary = 5, Mythic = 6, Secret = 7 }
local FALLBACK_TONE = {
	Common = "Gray",
	Uncommon = "Green",
	Rare = "Blue",
	Epic = "Purple",
	Legendary = "Yellow",
	Mythic = "Red",
	Secret = "White",
}

-- one slot is designed 560 px wide; the stage fits up to this many side by side
local SLOT_W = 560
local MAX_EGGS = 8

local HatchReveal = {}

-- true: every hatch plays its fast path (see the top of the file).
HatchReveal.skip = false
-- seconds a skipped reveal stays up before it continues by itself
HatchReveal.SKIP_HOLD = 1.2

export type HatchResult = {
	name: string?,
	icon: any?,
	rarity: string?,
	chance: string?,
	color: Color3?,
	[string]: any,
}

export type HatchOptions = {
	Egg: any?,
	EggTone: any?,
	Result: HatchResult?,
	Results: { HatchResult }?, -- several eggs at once (up to 8)
	Title: string?,
	Continue: string?,
	DisplayOrder: number?,
	Parent: Instance?,
	Skip: boolean?, -- play the fast path (default: HatchReveal.skip)
}

-- Rarity tone with fallbacks for themes from before Theme v2.
local function rarityTone(theme: any, name: string): any
	if Theme.rarity then
		return Theme.rarity(theme, name)
	end
	local set = theme.Rarity
	if set and set[name] then
		return set[name]
	end
	return Theme.tone(theme, FALLBACK_TONE[name] or "Gray")
end

local function outlineOf(tone: any): Color3
	return tone.Stroke or Color.darken(tone.Edge, 0.35)
end

-- Sunburst of soft wedges around the centre of a square frame.
local function rays(scope: Scope, props: { [string]: any }): Frame
	local count = props.Count or 12
	local diameter = props.Size
	local length = diameter / 2
	local width = 2 * length * math.tan(math.rad(180 / count * (props.Duty or 0.5)))
	local children = {}
	for i = 1, count do
		local angle = (i - 1) / count * 360 + (props.Offset or 0)
		local rad = math.rad(angle)
		children[i] = scope:New "ImageLabel" {
			Name = "Ray",
			BackgroundTransparency = 1,
			Image = RAY,
			ImageColor3 = if props.ColorAt then props.ColorAt(i, count) else props.Color,
			ImageTransparency = props.Transparency,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5, math.cos(rad) * length / 2, 0.5, math.sin(rad) * length / 2),
			Size = UDim2.fromOffset(length, width),
			Rotation = angle,
			ZIndex = props.ZIndex or 1,
			-- fade out along the ray so the tips never show a hard end
			[Children] = scope:New "UIGradient" {
				Transparency = NumberSequence.new({
					NumberSequenceKeypoint.new(0, 0),
					NumberSequenceKeypoint.new(0.55, 0.35),
					NumberSequenceKeypoint.new(1, 1),
				}),
			},
		}
	end
	return scope:New "Frame" {
		Name = props.Name or "Rays",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position or UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(diameter, diameter),
		ZIndex = props.ZIndex or 1,
		[Children] = children,
	}
end

local function glow(scope: Scope, props: { [string]: any }): Frame
	local layers = {}
	for i = 1, props.Layers or 3 do
		layers[i] = scope:New "ImageLabel" {
			Name = "Glow" .. i,
			BackgroundTransparency = 1,
			Image = GLOW,
			ImageColor3 = props.Color,
			ImageTransparency = props.Transparency or 0,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromScale(1 - (i - 1) * 0.22, 1 - (i - 1) * 0.22),
			ZIndex = props.ZIndex or 1,
		}
	end
	return scope:New "Frame" {
		Name = props.Name or "Glow",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position or UDim2.fromScale(0.5, 0.5),
		Size = props.Size,
		ZIndex = props.ZIndex or 1,
		[Children] = layers,
	}
end

-- Four point twinkle from triangle images.
local function twinkle(scope: Scope, props: { [string]: any }): Frame
	local size = props.Size or 30
	local long = size / 2
	local short = long * 0.62
	local width = math.max(3, size * 0.2)
	local function arm(name: string, length: number, rotation: number, offset: Vector2)
		return scope:New "ImageLabel" {
			Name = name,
			BackgroundTransparency = 1,
			Image = TRIANGLE,
			ImageColor3 = props.Color or WHITE,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5, offset.X, 0.5, offset.Y),
			Size = UDim2.fromOffset(width, length),
			Rotation = rotation,
			ZIndex = props.ZIndex or 1,
		}
	end
	return scope:New "Frame" {
		Name = "Twinkle",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position,
		Size = UDim2.fromOffset(size, size),
		ZIndex = props.ZIndex or 1,
		[Children] = {
			scope:New "UIScale" { Scale = props.Scale or 1 },
			arm("Up", long, 0, Vector2.new(0, -long / 2)),
			arm("Down", long, 180, Vector2.new(0, long / 2)),
			arm("Right", short, 90, Vector2.new(short / 2, 0)),
			arm("Left", short, -90, Vector2.new(-short / 2, 0)),
			scope:New "ImageLabel" {
				Name = "Core",
				BackgroundTransparency = 1,
				Image = CIRCLE,
				ImageColor3 = props.Color or WHITE,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.fromOffset(width * 1.5, width * 1.5),
				ZIndex = props.ZIndex or 1,
			},
		},
	}
end

-- A zig-zag crack drawn with thin rotated bars. Points are 0..1 of `box`.
local function crack(scope: Scope, points: { Vector2 }, box: number, color: any, visible: any, zIndex: number)
	local bars = {}
	for i = 1, #points - 1 do
		local a = points[i] * box
		local b = points[i + 1] * box
		local mid = (a + b) / 2
		local delta = b - a
		table.insert(bars, scope:New "Frame" {
			Name = "Crack",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromOffset(mid.X, mid.Y),
			Size = UDim2.fromOffset(delta.Magnitude + 5, 6),
			Rotation = math.deg(math.atan2(delta.Y, delta.X)),
			BackgroundColor3 = color,
			BorderSizePixel = 0,
			Visible = visible,
			ZIndex = zIndex,
			[Children] = Kit.corner(scope, "Full"),
		})
	end
	return bars
end

local function field(result: { [string]: any }, key: string): any
	local value = result[key]
	if value == nil then
		value = result[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

type Slot = {
	result: HatchResult,
	rank: number,
	rarityName: string,
	frame: Frame,
	eggRotation: any,
	eggOffset: any,
	eggPump: any,
	eggWhite: any,
	eggScaleGoal: any,
	eggScale: any,
	cracks: any,
	glowGoal: any,
	charge: any, -- 0..1: rare eggs glow in their rarity colour
	eggVisible: any,
	phase: any, -- "egg" | "reveal"
	revealGoal: any,
	reveal: any,
	textGoal: any,
	pillGoal: any,
	pillIn: any,
	waveGoal: any,
	wave2Goal: any,
	backRays: Frame,
	frontRays: Frame,
	resultIcon: Frame,
	twinkles: { { value: any, gui: Frame } },
}

-- Builds one egg + its result, laid out in a SLOT_W x full-height column.
local function buildSlot(scope: Scope, opts: HatchOptions, result: HatchResult, index: number, x: number, slotScale: number): Slot
	local resultName = tostring(field(result, "name") or "???")
	local resultIcon = field(result, "icon") or "star"
	local rarityName = tostring(field(result, "rarity") or "Common")
	local chance = field(result, "chance")
	local rank = RANK[rarityName] or 1

	local s = {} :: Slot
	s.result = result
	s.rank = rank
	s.rarityName = rarityName
	s.phase = scope:Value("egg")
	s.eggRotation = scope:Value(0)
	s.eggOffset = scope:Value(Vector2.zero)
	s.eggScaleGoal = scope:Value(0)
	s.eggScale = scope:Spring(s.eggScaleGoal, 22, 0.55)
	s.eggPump = scope:Value(1)
	s.eggWhite = scope:Value(1) -- transparency of the white-hot overlay
	s.cracks = scope:Value(0)
	s.glowGoal = scope:Value(0.15)
	s.charge = scope:Value(0)
	s.eggVisible = scope:Value(true)
	s.revealGoal = scope:Value(0)
	s.reveal = scope:Spring(s.revealGoal, 20, 0.5)
	s.textGoal = scope:Value(0)
	local textIn = scope:Spring(s.textGoal, 18, 0.62)
	s.pillGoal = scope:Value(0)
	s.pillIn = scope:Spring(s.pillGoal, 24, 0.45)
	s.eggScale:setPosition(0)
	s.reveal:setPosition(0)
	s.pillIn:setPosition(0)
	local glowLevel = scope:Spring(s.glowGoal, 8, 1)

	local eggTone = Kit.tone(scope, opts.EggTone, "White")
	local rarity = scope:Computed(function(use)
		return rarityTone(use(Theme.current), rarityName)
	end)
	local rarityOutline = scope:Computed(function(use)
		return outlineOf(use(rarity))
	end)
	local rainbow = scope:Computed(function(use)
		return use(rarity).Rainbow
	end)
	local eggOutline = scope:Computed(function(use)
		return outlineOf(use(eggTone))
	end)
	-- while charging, a rare egg's glow turns towards its rarity colour
	local chargeColor = scope:Computed(function(use)
		local base = Color.lighten(use(eggTone).Top, 0.3)
		local c = use(s.charge)
		if c <= 0 then
			return base
		end
		return base:Lerp(Color.lighten(use(rarity).Top, 0.15), math.clamp(c, 0, 1))
	end)

	local EGG = 330
	local eggHolder = scope:New "Frame" {
		Name = "Egg",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = scope:Computed(function(use)
			local offset = use(s.eggOffset)
			return UDim2.new(0.5, offset.X, 0.47, offset.Y)
		end),
		Size = UDim2.fromOffset(EGG, EGG),
		Rotation = s.eggRotation,
		Visible = s.eggVisible,
		ZIndex = 20,
		[Children] = {
			scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					return math.max(0, use(s.eggScale)) * use(s.eggPump)
				end),
			},
			Icon(scope, {
				Name = "Shell",
				Icon = opts.Egg or "egg",
				Size = EGG,
				Color = scope:Computed(function(use)
					return use(eggTone).Top
				end),
				Gradient = scope:Computed(function(use)
					local t = use(eggTone)
					return ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.45)),
						ColorSequenceKeypoint.new(0.55, t.Top),
						ColorSequenceKeypoint.new(1, t.Bottom),
					})
				end),
				StrokeColor = eggOutline,
				Stroke = 7,
				Shadow = true,
				ZIndex = 21,
			}),
			-- white-hot (or rarity-hot) glow just before it bursts
			Icon(scope, {
				Name = "Hot",
				Icon = opts.Egg or "egg",
				Size = EGG,
				Color = scope:Computed(function(use)
					return WHITE:Lerp(Color.lighten(use(rarity).Top, 0.45), math.clamp(use(s.charge), 0, 1) * 0.6)
				end),
				Stroke = false,
				Transparency = s.eggWhite,
				ZIndex = 24,
			}),
			crack(scope, {
				Vector2.new(0.16, 0.5),
				Vector2.new(0.27, 0.41),
				Vector2.new(0.37, 0.53),
				Vector2.new(0.47, 0.43),
			}, EGG, eggOutline, scope:Computed(function(use)
				return use(s.cracks) >= 1
			end), 23),
			crack(scope, {
				Vector2.new(0.47, 0.43),
				Vector2.new(0.58, 0.55),
				Vector2.new(0.68, 0.44),
				Vector2.new(0.79, 0.52),
			}, EGG, eggOutline, scope:Computed(function(use)
				return use(s.cracks) >= 2
			end), 23),
			crack(scope, {
				Vector2.new(0.37, 0.53),
				Vector2.new(0.35, 0.64),
				Vector2.new(0.42, 0.72),
			}, EGG, eggOutline, scope:Computed(function(use)
				return use(s.cracks) >= 3
			end), 23),
		},
	}

	local rayColor = scope:Computed(function(use)
		return Color.lighten(use(rarity).Top, 0.25)
	end)
	local rayAlpha = scope:Computed(function(use)
		local strength = if rank >= 5 then 0.62 elseif rank >= 3 then 0.5 else 0.38
		return 1 - strength * math.clamp(use(s.reveal), 0, 1)
	end)
	s.backRays = rays(scope, {
		Name = "BackRays",
		Size = 980,
		Count = 18,
		Duty = 0.5,
		Position = UDim2.fromScale(0.5, 0.45),
		Color = rayColor,
		ColorAt = if rank == 7
			then function(i: number, _count: number)
				local tone = rarityTone(Theme.get(), "Secret")
				local sequence = tone.Rainbow
				if sequence then
					local keys = sequence.Keypoints
					return keys[(i - 1) % #keys + 1].Value
				end
				return tone.Top
			end
			else nil,
		Transparency = rayAlpha,
		ZIndex = 5,
	})
	s.frontRays = rays(scope, {
		Name = "FrontRays",
		Size = 760,
		Count = 9,
		Duty = 0.2,
		Offset = 10,
		Position = UDim2.fromScale(0.5, 0.45),
		Color = WHITE,
		Transparency = scope:Computed(function(use)
			return 1 - 0.4 * math.clamp(use(s.reveal), 0, 1)
		end),
		ZIndex = 6,
	})

	-- rings that blast outwards when the egg bursts (a second, wider one
	-- for Legendary and better)
	local function shockwave(name: string, goal: any, reach: number, thickness: number)
		local wave = scope:Tween(goal, TweenInfo.new(0.6, Enum.EasingStyle.Quart, Enum.EasingDirection.Out))
		return scope:New "Frame" {
			Name = name,
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.45),
			Size = scope:Computed(function(use)
				local t = use(wave)
				return UDim2.fromOffset(120 + reach * t, 120 + reach * t)
			end),
			Visible = scope:Computed(function(use)
				local t = use(wave)
				return t > 0.001 and t < 0.999
			end),
			ZIndex = 35,
			[Children] = {
				Kit.corner(scope, "Full"),
				scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
					Color = scope:Computed(function(use)
						return Color.lighten(use(rarity).Top, 0.6)
					end),
					Thickness = scope:Computed(function(use)
						return thickness * (1 - use(wave)) + 2
					end),
					Transparency = scope:Computed(function(use)
						return 0.1 + 0.9 * use(wave)
					end),
				},
			},
		}
	end
	s.waveGoal = scope:Value(0)
	s.wave2Goal = scope:Value(0)

	s.twinkles = {}
	if rank >= 5 then
		local spots = {
			Vector2.new(-190, -150),
			Vector2.new(200, -120),
			Vector2.new(-230, 60),
			Vector2.new(215, 95),
			Vector2.new(-60, -230),
			Vector2.new(120, 200),
		}
		for i, spot in spots do
			local pulse = scope:Value(0)
			s.twinkles[i] = {
				value = pulse,
				gui = twinkle(scope, {
					Size = 44 + (i % 3) * 14,
					Position = UDim2.new(0.5, spot.X, 0.45, spot.Y),
					Color = if i % 2 == 0 then WHITE else Color.lighten(peek(rarity).Top, 0.5),
					Scale = pulse,
					ZIndex = 40,
				}),
			}
		end
	end
	local twinkleGuis = {}
	for i, entry in s.twinkles do
		twinkleGuis[i] = entry.gui
	end

	s.resultIcon = Icon(scope, {
		Name = "Result",
		Icon = resultIcon,
		Size = 300,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.45),
		Color = field(result, "color") or scope:Computed(function(use)
			return Color.lighten(use(rarity).Top, 0.15)
		end),
		Gradient = true,
		StrokeColor = rarityOutline,
		Stroke = 7,
		Shadow = true,
		Visible = scope:Computed(function(use)
			return use(s.phase) ~= "egg"
		end),
		ZIndex = 30,
		[Children] = scope:New "UIScale" {
			Scale = scope:Computed(function(use)
				return math.max(0, use(s.reveal))
			end),
		},
	})

	local nameGradient = scope:Computed(function(use)
		local sequence = use(rainbow)
		if sequence then
			return sequence
		end
		local tone = use(rarity)
		return ColorSequence.new({
			ColorSequenceKeypoint.new(0, Color.lighten(tone.Top, 0.7)),
			ColorSequenceKeypoint.new(0.5, Color.lighten(tone.Top, 0.25)),
			ColorSequenceKeypoint.new(1, tone.Top),
		})
	end)
	local textAlpha = scope:Computed(function(use)
		return 1 - math.clamp(use(textIn), 0, 1)
	end)

	-- several eggs: narrower columns shrink the name to fit
	local nameSize = if slotScale < 1 then 72 else 84

	s.frame = scope:New "Frame" {
		Name = `Slot{index}`,
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.new(0.5, x, 0.5, 0),
		Size = UDim2.new(0, SLOT_W, 1, 0),
		ZIndex = 2,
		[Children] = {
			scope:New "UIScale" { Scale = slotScale },
			s.backRays,
			s.frontRays,
			glow(scope, {
				Name = "EggGlow",
				Size = UDim2.fromOffset(760, 760),
				Position = UDim2.fromScale(0.5, 0.45),
				Color = scope:Computed(function(use)
					if use(s.phase) ~= "egg" then
						return Color.lighten(use(rarity).Top, 0.2)
					end
					return use(chargeColor)
				end),
				Transparency = scope:Computed(function(use)
					local level = if use(s.phase) == "egg" then use(glowLevel) else 0.6 + 0.4 * math.clamp(use(s.reveal), 0, 1)
					return 1 - math.clamp(level, 0, 1)
				end),
				Layers = 4,
				ZIndex = 7,
			}),
			scope:New "ImageLabel" {
				Name = "FloorShadow",
				BackgroundTransparency = 1,
				Image = CIRCLE,
				ImageColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Backdrop
				end),
				ImageTransparency = scope:Computed(function(use)
					-- the shadow shrinks while the egg hops
					local lift = math.clamp(-use(s.eggOffset).Y / 40, 0, 0.6)
					return 1 - 0.5 * math.clamp(use(s.eggScale), 0, 1) * (1 - lift)
				end),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 0, 0.47, EGG * 0.47),
				Size = scope:Computed(function(use)
					local lift = math.clamp(-use(s.eggOffset).Y / 40, 0, 0.5)
					return UDim2.fromOffset(EGG * 0.62 * (1 - lift * 0.4), EGG * 0.12 * (1 - lift * 0.4))
				end),
				Visible = s.eggVisible,
				ZIndex = 8,
			},
			eggHolder,
			s.resultIcon,
			shockwave("Shockwave", s.waveGoal, 900, 26),
			shockwave("Shockwave2", s.wave2Goal, 1300, 16),
			twinkleGuis,
			Text(scope, {
				Name = "ResultName",
				Text = resultName,
				TextSize = nameSize,
				Font = "Title",
				Gradient = nameGradient,
				Stroke = 6,
				StrokeColor = rarityOutline,
				Transparency = textAlpha,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = scope:Computed(function(use)
					return UDim2.new(0.5, 0, 0.745, (1 - use(textIn)) * 40)
				end),
				ZIndex = 50,
			}),
			scope:New "Frame" {
				Name = "Rarity",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.84),
				Size = UDim2.fromOffset(0, 56),
				AutomaticSize = Enum.AutomaticSize.X,
				BackgroundColor3 = WHITE,
				Visible = scope:Computed(function(use)
					return use(s.pillIn) > 0.02
				end),
				ZIndex = 50,
				[Children] = {
					scope:New "UIScale" {
						Scale = scope:Computed(function(use)
							return math.max(0, use(s.pillIn))
						end),
					},
					Kit.corner(scope, "Full"),
					scope:New "UIGradient" {
						Rotation = scope:Computed(function(use)
							return if use(rainbow) then 0 else 90
						end),
						Color = scope:Computed(function(use)
							local sequence = use(rainbow)
							if sequence then
								return sequence
							end
							local tone = use(rarity)
							return ColorSequence.new({
								ColorSequenceKeypoint.new(0, Color.lighten(tone.Top, 0.2)),
								ColorSequenceKeypoint.new(0.5, tone.Top),
								ColorSequenceKeypoint.new(1, tone.Bottom),
							})
						end),
					},
					scope:New "UIStroke" {
						ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
						LineJoinMode = Enum.LineJoinMode.Round,
						Color = rarityOutline,
						Thickness = 3.5,
					},
					Kit.padding(scope, { 0, 32 }),
					Text(scope, {
						Name = "Label",
						Text = string.upper(rarityName),
						TextSize = 34,
						Font = "Heavy",
						Color = WHITE,
						StrokeColor = rarityOutline,
						Stroke = 3,
						Size = UDim2.fromScale(0, 1),
						AutoSize = "X",
						ZIndex = 51,
					}),
				},
			},
			if chance
				then Text(scope, {
					Name = "Chance",
					Text = tostring(chance),
					TextSize = 26,
					Font = "Heavy",
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					Stroke = 2,
					Transparency = textAlpha,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.905),
					ZIndex = 50,
				})
				else nil,
		},
	}
	return s
end

-- Plays the whole hatch sequence. Yields until the player continues.
function HatchReveal.reveal(options: HatchOptions): any
	local opts = options or ({} :: any)
	local many = typeof(opts.Results) == "table" and #opts.Results > 0
	local results: { HatchResult } = if many then opts.Results :: any else { opts.Result or {} }
	if #results > MAX_EGGS then
		results = table.move(results, 1, MAX_EGGS, 1, {})
	end
	local answer = if many then results else results[1]
	if Players.LocalPlayer == nil and opts.Parent == nil then
		return answer -- nobody to watch it (Studio edit mode, plugin preview)
	end
	local count = #results

	local scope = Fusion.scoped(Fusion)
	local alive = true
	table.insert(scope, function()
		alive = false
	end)
	local done = Instance.new("BindableEvent")
	table.insert(scope, done)

	-- shared state ----------------------------------------------------------

	local phase = scope:Value("egg") -- "egg" | "reveal" | "ready" | "closing"
	-- the fast path: as if the player tapped straight away, then continues alone
	local skipping = if opts.Skip ~= nil then opts.Skip == true else HatchReveal.skip == true
	local speed = if skipping then 3 else 1
	local flash = scope:Value(1)
	local flashColor = scope:Value(WHITE)
	local hintGoal = scope:Value(0)
	local hintIn = scope:Spring(hintGoal, 10, 1)
	local exitGoal = scope:Value(1)
	local exitScale = scope:Spring(exitGoal, 26, 0.9)
	local backdropGoal = scope:Value(0)
	local backdropIn = scope:Spring(backdropGoal, 12, 1)
	local eggTone = Kit.tone(scope, opts.EggTone, "White")
	local eggOutline = scope:Computed(function(use)
		return outlineOf(use(eggTone))
	end)

	-- slots -------------------------------------------------------------------

	local slotScale = if count == 1 then 1 else math.min(if count == 2 then 0.8 else 0.68, 1760 / (count * SLOT_W))
	local pitch = SLOT_W * slotScale
	local slots: { Slot } = {}
	local slotFrames = {}
	local best = 1
	for index, result in results do
		local x = (index - (count + 1) / 2) * pitch
		local slot = buildSlot(scope, opts, result, index, x, slotScale)
		slots[index] = slot
		slotFrames[index] = slot.frame
		best = math.max(best, slot.rank)
	end

	local continueText = opts.Continue
		or (if peek(Input.device) == "Touch" then "Tap to continue" elseif peek(Input.device) == "Gamepad" then "Press A to continue" else "Click to continue")

	local stage = scope:New "Frame" {
		Name = "Stage",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromScale(1, 1),
		ZIndex = 2,
		[Children] = {
			scope:New "UIScale" { Scale = exitScale },
			slotFrames,
			Text(scope, {
				Name = "Title",
				Text = opts.Title or (if count > 1 then `Hatching {count} eggs...` else "Hatching..."),
				TextSize = 60,
				Font = "Title",
				Gradient = scope:Computed(function(use)
					local t = use(eggTone)
					return ColorSequence.new(WHITE, Color.lighten(t.Top, 0.35))
				end),
				Stroke = 5,
				StrokeColor = eggOutline,
				Transparency = scope:Computed(function(use)
					if use(phase) ~= "egg" then
						return 1
					end
					return 1 - math.clamp(use(slots[1].eggScale), 0, 1)
				end),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.13),
				ZIndex = 50,
			}),
			Text(scope, {
				Name = "Continue",
				Text = continueText,
				TextSize = 26,
				Font = "Heavy",
				Color = Kit.themed(scope, function(theme)
					return theme.Color.Text
				end),
				Stroke = 2,
				Transparency = scope:Computed(function(use)
					return 1 - 0.85 * math.clamp(use(hintIn), 0, 1)
				end),
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(0.5, 0, 1, -40),
				ZIndex = 50,
			}),
		},
	}
	local hint = stage:FindFirstChild("Continue") :: GuiObject

	local function onPress()
		local now = peek(phase)
		if now == "egg" then
			speed = 3
		elseif now == "ready" then
			phase:set("closing")
			done:Fire()
		end
	end

	Screen(scope, {
		Name = "BloxUI_Hatch",
		DisplayOrder = opts.DisplayOrder or 960,
		SafeArea = "None",
		Parent = opts.Parent,
		[Children] = {
			scope:New "TextButton" {
				Name = "Backdrop",
				Size = UDim2.fromScale(1, 1),
				Text = "",
				AutoButtonColor = false,
				Selectable = false,
				Modal = true,
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Backdrop
				end),
				BackgroundTransparency = scope:Computed(function(use)
					return 1 - 0.84 * math.clamp(use(backdropIn), 0, 1)
				end),
				ZIndex = 1,
				[OnEvent "Activated"] = onPress,
			},
			scope:New "ImageLabel" {
				Name = "Vignette",
				BackgroundTransparency = 1,
				Image = Assets.Vignette,
				ImageColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Backdrop
				end),
				ImageTransparency = scope:Computed(function(use)
					return 1 - math.clamp(use(backdropIn), 0, 1)
				end),
				Size = UDim2.fromScale(1, 1),
				ZIndex = 1,
			},
			stage,
			scope:New "Frame" {
				Name = "Flash",
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = flashColor,
				BackgroundTransparency = flash,
				ZIndex = 100,
			},
		},
	})

	Input.bind(scope, { Enum.KeyCode.Space, Enum.KeyCode.Return, Enum.KeyCode.ButtonA }, onPress)

	-- timeline ------------------------------------------------------------------

	local function wait(seconds: number)
		local elapsed = 0
		while elapsed < seconds and alive do
			elapsed += task.wait() * speed
		end
	end

	-- runs `step(t)` with t going 0..1 over `seconds` (scaled by `speed`)
	local function animate(seconds: number, step: (t: number) -> ())
		local elapsed = 0
		while alive do
			local t = math.min(1, elapsed / seconds)
			step(t)
			if t >= 1 then
				break
			end
			elapsed += task.wait() * speed
		end
	end

	local function flashOnce(color: Color3, from: number, length: number)
		if Motion.reduced then
			return -- reduced motion: no screen flash
		end
		flashColor:set(color)
		flash:set(from)
		task.spawn(function()
			local start = os.clock()
			while alive do
				local t = math.min(1, (os.clock() - start) / length)
				flash:set(from + (1 - from) * (1 - (1 - t) ^ 2.5))
				if t >= 1 then
					break
				end
				task.wait()
			end
		end)
	end

	-- 1. drop in (several eggs land one after another)
	backdropGoal:set(1)
	Sound.play("Open")
	for index, slot in slots do
		task.delay((index - 1) * 0.08, function()
			if alive then
				slot.eggScaleGoal:set(1)
			end
		end)
	end
	wait(0.55 + (count - 1) * 0.08)

	-- 2. three wobbles, each harder; the last one hops
	local wobbles = {
		{ amplitude = 9, length = 0.5, rest = 0.32, glow = 0.3, hop = 0 },
		{ amplitude = 15, length = 0.55, rest = 0.26, glow = 0.5, hop = 0 },
		{ amplitude = 24, length = 0.62, rest = 0.14, glow = 0.75, hop = 26 },
	}
	for index, wobble in wobbles do
		Sound.play("Tick", { Pitch = 0.8 + index * 0.15 })
		animate(wobble.length, function(t)
			for i, slot in slots do
				-- neighbours wobble a touch out of step, so a row looks alive
				local lt = math.clamp(t * 1.08 - (i - 1) * 0.08 / math.max(1, count - 1), 0, 1)
				local envelope = math.sin(lt * math.pi) ^ 0.7
				local dir = if i % 2 == 0 then -1 else 1
				slot.eggRotation:set(dir * wobble.amplitude * math.sin(lt * math.pi * 4) * envelope)
				slot.eggPump:set(1 + 0.05 * envelope)
				slot.eggOffset:set(Vector2.new(0, -wobble.hop * math.sin(lt * math.pi)))
			end
		end)
		for _, slot in slots do
			slot.eggRotation:set(0)
			slot.eggPump:set(1)
			slot.eggOffset:set(Vector2.zero)
			slot.glowGoal:set(wobble.glow)
			if index >= 2 then
				slot.cracks:set(index - 1)
			end
		end
		if index >= 2 then
			Sound.play("Tick", { Pitch = 1.4 })
		end
		wait(wobble.rest)
	end

	-- 3. charge: shiver, swell, glow hot. Rare eggs tease their colour and
	-- charge a little longer, with rising ticks.
	local rare = best >= 5
	local chargeTime = if rare then 0.75 else 0.45
	local lastTick = 0
	for _, slot in slots do
		slot.cracks:set(3)
		slot.glowGoal:set(1)
	end
	animate(chargeTime, function(t)
		for _, slot in slots do
			local shake = 3 + 7 * t
			slot.eggOffset:set(Vector2.new((math.random() * 2 - 1) * shake, (math.random() * 2 - 1) * shake))
			slot.eggPump:set(1 + 0.14 * t * t)
			slot.eggWhite:set(1 - 0.85 * t * t)
			if slot.rank >= 5 then
				slot.charge:set(math.clamp(t * 1.4, 0, 1))
			end
		end
		if rare and t - lastTick >= 0.12 then
			lastTick = t
			Sound.play("Tick", { Pitch = 1.1 + t * 0.9, Vary = 0 })
		end
	end)
	for _, slot in slots do
		slot.eggOffset:set(Vector2.zero)
	end

	-- 4. burst (one after another when there are several)
	local function burst(slot: Slot, first: boolean)
		if not alive then
			return
		end
		slot.eggVisible:set(false)
		slot.phase:set("reveal")
		local tone = rarityTone(Theme.get(), slot.rarityName)
		local egg = peek(eggTone)
		local shardColors = { egg.Top, Color.lighten(egg.Top, 0.4), WHITE }
		local burstAt = slot.resultIcon
		pcall(function()
			-- shell shards and dust
			Particles.burst(burstAt, { Count = 18, Shape = "triangle", Size = 34, Speed = 1100, Colors = shardColors, Lifetime = 1, Gravity = 1600 })
			Particles.burst(burstAt, { Count = 8, Shape = "square", Size = 20, Speed = 900, Colors = shardColors, Lifetime = 0.9 })
			Particles.burst(burstAt, { Count = 14, Shape = "sparkle", Size = 40, Speed = 650, Colors = { WHITE, Color.lighten(tone.Top, 0.5) } })
		end)
		slot.revealGoal:set(1)
		slot.reveal:addVelocity(6)
		slot.waveGoal:set(1)
		if slot.rank >= 5 then
			task.delay(0.12, function()
				if alive then
					slot.wave2Goal:set(1)
				end
			end)
		end
		if first then
			if slot.rank >= 5 then
				flashOnce(Color.lighten(tone.Top, 0.55), 0, 0.45)
			else
				flashOnce(WHITE, 0, 0.35)
			end
		end
		if first or slot.rank >= 5 then
			Sound.play("Reward", { Pitch = 1 + (slot.rank - 1) * 0.04 })
		else
			Sound.play("Pop")
			Sound.play("Tick", { Pitch = 1.4 + slot.rank * 0.05, Vary = 0 })
		end
	end

	if alive then
		phase:set("reveal")
		Motion.shake(stage, if rare then 22 else 16, 0.45)
		if rare then
			FXScreen.cameraShake(if best >= 6 then "Huge" else "Heavy", 0.45)
		end
		for index, slot in slots do
			if index > 1 then
				wait(0.14)
			end
			burst(slot, index == 1)
		end
	end

	-- 5. reveal
	for _, slot in slots do
		table.insert(scope, Motion.spin(slot.backRays, 26))
		table.insert(scope, Motion.spin(slot.frontRays, -18))
	end
	wait(0.18)
	for _, slot in slots do
		slot.textGoal:set(1)
	end
	wait(0.16)
	for _, slot in slots do
		slot.pillGoal:set(1)
		slot.pillIn:addVelocity(5)
	end
	if best >= 5 then
		pcall(function()
			local tone = rarityTone(Theme.get(), (function()
				for _, slot in slots do
					if slot.rank == best then
						return slot.rarityName
					end
				end
				return "Legendary"
			end)())
			local colors = { tone.Top, tone.Bottom, Color.lighten(tone.Top, 0.5), WHITE }
			if best >= 6 then
				Particles.confetti({ Style = "Cannons", Count = 110, Colors = colors })
			else
				Particles.confetti({ Count = 80, Colors = colors })
			end
		end)
	end
	for _, slot in slots do
		if slot.rank >= 5 then
			pcall(function()
				local tone = rarityTone(Theme.get(), slot.rarityName)
				Particles.burst(slot.resultIcon, {
					Count = 36,
					Speed = 950,
					Shape = "mix",
					Colors = { tone.Top, tone.Bottom, Color.lighten(tone.Top, 0.5), WHITE },
				})
			end)
			-- twinkles pop in and out around the prize
			for index, entry in slot.twinkles do
				task.spawn(function()
					task.wait(index * 0.13)
					while alive do
						local start = os.clock()
						while alive do
							local t = (os.clock() - start) / 1.1
							if t >= 1 then
								break
							end
							entry.value:set(math.sin(t * math.pi))
							task.wait()
						end
						if not alive then
							return
						end
						entry.value:set(0)
						task.wait(0.4 + math.random() * 0.8)
					end
				end)
			end
		end
	end
	speed = if skipping then 3 else 1
	wait(0.55)
	if alive and peek(phase) == "reveal" then
		phase:set("ready")
		hintGoal:set(1)
		table.insert(scope, Motion.pulse(hint, 0.06, 1.1))
		if skipping then
			task.delay(HatchReveal.SKIP_HOLD, function()
				if alive and peek(phase) == "ready" then
					phase:set("closing")
					done:Fire()
				end
			end)
		end
	end

	-- 6. wait for the player
	if alive and peek(phase) == "ready" then
		done.Event:Wait()
	end

	-- 7. pop out
	exitGoal:set(0)
	backdropGoal:set(0)
	Sound.play("Close")
	task.delay(0.35, function()
		Fusion.doCleanup(scope)
	end)
	return answer
end

return HatchReveal
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="102">
            <Properties>
              <string name="Name">Hotbar</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Hotbar: weapon/tool slots with ammo counts and a big name label (RIVALS
	layout: the equipped slot is darker and lifted, weapon art overflows the
	plate, the ammo reads "13 / 100" with the magazine big and the reserve
	small, and the equipped weapon's name sits above, right-aligned).

	local equipped = scope:Value(1)
	scope:Hotbar {
		Slots = {
			{ Name = "Assault Rifle", Icon = "ammo", Ammo = ammo, Reserve = reserve, MagSize = 30 },
			{ Name = "Pistol", Icon = "ammo", Ammo = 13, Reserve = 9 },
			{ Name = "Knife", Icon = "melee" },
			{ Name = "Grenade", Icon = "grenade", Count = 2 },         -- a stack count instead of ammo
			{ Name = "Sniper", Model = workspace.SniperModel },        -- a live 3D model (ViewportFrame)
		},
		Selected = equipped,             -- Value<number>
		OnSelect = function(index) end,
		BindKeys = true,                 -- number keys 1-9 and LB / RB pick slots
		Reloading = reloadProgress,      -- state: false, or 0..1 while the equipped slot reloads
		LowAmmo = 0.25,                  -- fraction of MagSize that counts as low (turns amber)
		SlotSize = 88,
		AnchorPoint = Vector2.new(1, 1),
		Position = UDim2.new(1, -20, 1, -64),
	}

	Slot fields: Name, Icon, IconColor, IconRotation, Ammo, Reserve, MagSize,
	Count, Model (Model / BasePart shown in a ViewportFrame instead of Icon).

	HUD kit looks: Style = "Slots" (above, the original) | "Cards" | "List" |
	"Stack" | "Round" | "Tactical", with Kit = a HudKits name / tokens (see the
	notes above the returned function). Every look sets a HudSize attribute.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Sound = require(script.Parent.Parent.Util.Sound)
local Input = require(script.Parent.Parent.Util.Input)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local KeyCap = require(script.Parent.KeyCap)
local P = require(script.Parent.HudParts)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)
local BLACK = Color3.new(0, 0, 0)
local LIFT = 8 -- design px the equipped slot rises

local NUMBER_KEYS = {
	Enum.KeyCode.One, Enum.KeyCode.Two, Enum.KeyCode.Three,
	Enum.KeyCode.Four, Enum.KeyCode.Five, Enum.KeyCode.Six,
	Enum.KeyCode.Seven, Enum.KeyCode.Eight, Enum.KeyCode.Nine,
}

-- A ViewportFrame showing a weapon model, framed by its bounding box.
-- Returns nil when the model can't be copied.
local function modelView(scope: Scope, model: Instance, size: number, rotation: number?): ViewportFrame?
	-- Clone() returns nil for non-Archivable instances; copy them anyway
	local wasArchivable = model.Archivable
	pcall(function()
		model.Archivable = true
	end)
	local ok, copy = pcall(model.Clone, model)
	pcall(function()
		model.Archivable = wasArchivable
	end)
	if not ok or copy == nil then
		warn(`[BloxUI] Hotbar: couldn't copy {model:GetFullName()} for its slot; showing the icon instead`)
		return nil
	end
	local camera = Instance.new("Camera")
	camera.FieldOfView = 30
	local cf, extents
	if copy:IsA("Model") then
		cf, extents = copy:GetBoundingBox()
	elseif copy:IsA("BasePart") then
		cf, extents = copy.CFrame, copy.Size
	else
		cf, extents = CFrame.new(), Vector3.one
	end
	local radius = extents.Magnitude / 2
	local distance = radius / math.tan(math.rad(camera.FieldOfView / 2)) * 1.05
	camera.CFrame = CFrame.lookAt(cf.Position + Vector3.new(0.35, 0.25, 1).Unit * distance, cf.Position)
	return scope:New "ViewportFrame" {
		Name = "Model",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Size = UDim2.fromOffset(size, size),
		Rotation = rotation or 0,
		Ambient = Color3.fromRGB(170, 170, 180),
		LightColor = WHITE,
		LightDirection = Vector3.new(-1, -1.4, -0.6),
		CurrentCamera = camera,
		[Children] = { camera, copy },
	} :: ViewportFrame
end

-- Three little rounds: the "bullets" glyph after the reserve count.
local function bullets(scope: Scope, color: any, transparency: any, order: number)
	local rounds = {}
	for index = 1, 3 do
		table.insert(rounds, scope:New "Frame" {
			Name = "Round",
			LayoutOrder = index,
			Size = UDim2.fromOffset(3, 10),
			BackgroundColor3 = color,
			BackgroundTransparency = transparency,
			BorderSizePixel = 0,
			[Children] = scope:New "UICorner" { CornerRadius = UDim.new(0, 2) },
		})
	end
	return scope:New "Frame" {
		Name = "Bullets",
		LayoutOrder = order,
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(15, 14),
		[Children] = {
			scope:New "UIListLayout" {
				FillDirection = Enum.FillDirection.Horizontal,
				SortOrder = Enum.SortOrder.LayoutOrder,
				Padding = UDim.new(0, 2),
				VerticalAlignment = Enum.VerticalAlignment.Bottom,
			},
			rounds,
		},
	}
end

-- Selection, number keys and LB / RB: shared by every look.
local function control(scope: Scope, props: { [any]: any })
	local slots = props.Slots or {}
	local selected = props.Selected or scope:Value(1)

	local function choose(index: number)
		if slots[index] == nil or peek(selected) == index then
			return
		end
		Sound.play("Click", { Pitch = 1.15 })
		if selected.set then
			selected:set(index)
		end
		if props.OnSelect then
			props.OnSelect(index)
		end
	end

	local function cycle(step: number)
		local count = #slots
		if count == 0 then
			return
		end
		choose((((peek(selected) or 1) - 1 + step) % count) + 1)
	end

	if props.BindKeys ~= false then
		for index = 1, math.min(#slots, #NUMBER_KEYS) do
			Input.bind(scope, NUMBER_KEYS[index], function()
				choose(index)
			end)
		end
		Input.bind(scope, Enum.KeyCode.ButtonL1, function()
			cycle(-1)
		end)
		Input.bind(scope, Enum.KeyCode.ButtonR1, function()
			cycle(1)
		end)
	end

	-- keyboard players see slot numbers; controllers get LB / RB at the ends
	local showNumbers = scope:Computed(function(use)
		return use(Input.device) == "Keyboard"
	end)
	local onGamepad = scope:Computed(function(use)
		return use(Input.device) == "Gamepad"
	end)
	return {
		slots = slots,
		selected = selected,
		choose = choose,
		cycle = cycle,
		showNumbers = showNumbers,
		onGamepad = onGamepad,
	}
end

-- The original RIVALS look.
local function slotsLook(scope: Scope, props: { [any]: any }, ctl: { [string]: any }): Frame
	local slots = ctl.slots
	local selected = ctl.selected
	local slotSize = props.SlotSize or 88
	local lowFraction = props.LowAmmo or 0.25
	local reloading = props.Reloading
	local choose = ctl.choose
	local showNumbers = ctl.showNumbers
	local onGamepad = ctl.onGamepad

	local namePopGoal = scope:Value(1)
	local namePop = scope:Spring(namePopGoal, 30, 0.5)
	local nameFade = scope:Value(0)
	local nameAlpha = scope:Tween(nameFade, TweenInfo.new(0.16, Enum.EasingStyle.Quad, Enum.EasingDirection.Out))
	Kit.watch(scope, selected, function()
		namePop:setPosition(1.18)
		nameFade:set(0.8)
		task.defer(function()
			nameFade:set(0)
		end)
	end)

	local slotFrames = {}
	for index, slot in slots do
		local isSelected = scope:Computed(function(use)
			return use(selected) == index
		end)
		local sizeGoal = scope:Computed(function(use)
			return if use(isSelected)
				then Vector3.new(slotSize * 1.14, slotSize * 0.98, LIFT)
				else Vector3.new(slotSize, slotSize * 0.84, 0)
		end)
		local animated = scope:Spring(sizeGoal, 34, 0.72)
		local hasAmmo = slot.Ammo ~= nil
		local hasCount = slot.Count ~= nil

		local ammoState = scope:Computed(function(use)
			if not hasAmmo then
				return "None"
			end
			local ammo = use(slot.Ammo) or 0
			if ammo <= 0 then
				return "Empty"
			end
			local mag = use(slot.MagSize)
			if mag and ammo <= math.max(1, math.floor(mag * lowFraction)) then
				return "Low"
			end
			return "Ok"
		end)

		local reloadFraction = scope:Computed(function(use)
			if not use(isSelected) then
				return nil
			end
			local value = use(reloading)
			if typeof(value) == "number" and value < 1 then
				return math.clamp(value, 0, 1)
			end
			return nil
		end)

		-- gamepad UI navigation: a themed focus ring on the plate
		local focused = scope:Value(false)

		local art = if slot.Model then modelView(scope, slot.Model, math.floor(slotSize * 1.05), slot.IconRotation) else nil
		if art then
			art.Position = UDim2.fromScale(0.5, if hasAmmo or hasCount then 0.26 else 0.42)
			art.ZIndex = 2
		else
			art = Icon(scope, {
				Name = "Art",
				Icon = slot.Icon,
				AnchorPoint = Vector2.new(0.5, 0.5),
				-- the art is bigger than the plate and breaks its top edge,
				-- leaving the bottom third for the ammo
				Position = scope:Computed(function(use)
					local y = if hasAmmo or hasCount then (if use(isSelected) then 0.25 else 0.3) else 0.44
					return UDim2.fromScale(0.5, y)
				end),
				Size = scope:Computed(function(use)
					local s = if hasAmmo or hasCount
						then math.floor(slotSize * (if use(isSelected) then 0.78 else 0.56))
						else math.floor(slotSize * (if use(isSelected) then 0.86 else 0.66))
					return UDim2.fromOffset(s, s)
				end),
				Rotation = slot.IconRotation or 0,
				Color = slot.IconColor,
				Gradient = isSelected,
				Shadow = true,
				Transparency = scope:Computed(function(use)
					return if use(isSelected) then 0 else 0.3
				end),
				ZIndex = 2,
			})
		end

		local ammoColor = scope:Computed(function(use)
			local theme = use(Theme.current)
			local state = use(ammoState)
			if state == "Empty" then
				return theme.Color.Bad:Lerp(WHITE, 0.15)
			elseif state == "Low" then
				return theme.Color.Warn
			end
			return theme.Color.Text
		end)
		local dimmed = scope:Computed(function(use)
			if use(reloadFraction) ~= nil then
				return 0.55
			end
			return if use(isSelected) then 0 else 0.15
		end)

		local plate = scope:New "Frame" {
			Name = "Plate",
			AnchorPoint = Vector2.new(0.5, 1),
			Position = scope:Computed(function(use)
				return UDim2.new(0.5, 0, 1, -use(animated).Z)
			end),
			Size = scope:Computed(function(use)
				return UDim2.new(1, 0, 0, use(animated).Y)
			end),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = scope:Computed(function(use)
				local alpha = use(Theme.current).Alpha.Surface
				return if use(isSelected) then math.min(alpha, 0.2) else math.max(alpha, 0.55)
			end),
			[Children] = {
				Kit.corner(scope, "L"),
				Kit.focusRing(scope, { Focused = focused, Radius = "L" }),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = ColorSequence.new(WHITE, Color3.fromRGB(200, 200, 208)),
				}),
				scope:New "UIStroke" {
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Glass
					end),
					Thickness = scope:Computed(function(use)
						return if use(isSelected) then 1.5 else 1
					end),
					Transparency = scope:Computed(function(use)
						return if use(isSelected) then 0.55 else 0.9
					end),
				},
				art,
				Text(scope, {
					Name = "Key",
					Text = tostring(index),
					TextSize = 15,
					Font = "Body",
					Stroke = false,
					AnchorPoint = Vector2.new(0, 0),
					Position = UDim2.fromOffset(8, 5),
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					Transparency = scope:Computed(function(use)
						return if use(isSelected) then 0.1 else 0.35
					end),
					Visible = scope:Computed(function(use)
						return index <= #NUMBER_KEYS and use(showNumbers)
					end),
					ZIndex = 3,
				}),
				if hasAmmo or hasCount
					then scope:New "Frame" {
						Name = "Ammo",
						BackgroundTransparency = 1,
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.new(0.5, 0, 1, -5),
						Size = UDim2.fromOffset(0, 30),
						AutomaticSize = Enum.AutomaticSize.X,
						ZIndex = 3,
						[Children] = {
							Kit.list(scope, { Direction = "X", Padding = 3, VAlign = "Bottom" }),
							Text(scope, {
								Name = "Mag",
								Text = scope:Computed(function(use)
									if hasAmmo then
										return tostring(use(slot.Ammo))
									end
									return `x{use(slot.Count)}`
								end),
								TextSize = scope:Computed(function(use)
									return if use(isSelected) then 30 else 21
								end),
								Font = "Display",
								Stroke = false,
								Color = ammoColor,
								Transparency = dimmed,
								LayoutOrder = 1,
								ZIndex = 4,
							}),
							if slot.Reserve ~= nil
								then Text(scope, {
									Name = "Reserve",
									RichText = true,
									Text = scope:Computed(function(use)
										return `<font transparency="0.45">/</font> {use(slot.Reserve)}`
									end),
									TextSize = scope:Computed(function(use)
										return if use(isSelected) then 16 else 13
									end),
									Font = "Body",
									Stroke = false,
									Color = scope:Computed(function(use)
										local theme = use(Theme.current)
										return if (use(slot.Reserve) or 0) <= 0 then theme.Color.Bad:Lerp(WHITE, 0.2) else theme.Color.Text
									end),
									Transparency = scope:Computed(function(use)
										return math.max(use(dimmed), 0.1)
									end),
									LayoutOrder = 2,
									ZIndex = 4,
									[Children] = scope:New "UIPadding" { PaddingBottom = UDim.new(0, 3) },
								})
								else nil,
							if hasAmmo
								then scope:New "Frame" {
									Name = "BulletsHolder",
									LayoutOrder = 3,
									BackgroundTransparency = 1,
									Size = UDim2.fromOffset(15, 20),
									Visible = isSelected,
									[Children] = {
										scope:New "UIPadding" { PaddingBottom = UDim.new(0, 6), PaddingLeft = UDim.new(0, 2) },
										bullets(scope, ammoColor, scope:Computed(function(use)
											return math.max(use(dimmed), 0.15)
										end), 1),
									},
								}
								else nil,
						},
					}
					else nil,
				-- reload progress along the bottom of the equipped slot
				scope:New "Frame" {
					Name = "Reload",
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -3),
					Size = UDim2.new(1, -16, 0, 4),
					BackgroundColor3 = BLACK,
					BackgroundTransparency = 0.45,
					BorderSizePixel = 0,
					Visible = scope:Computed(function(use)
						return use(reloadFraction) ~= nil
					end),
					ZIndex = 5,
					[Children] = {
						Kit.corner(scope, "Full"),
						scope:New "Frame" {
							Name = "Fill",
							Size = scope:Computed(function(use)
								return UDim2.fromScale(use(reloadFraction) or 0, 1)
							end),
							BackgroundColor3 = WHITE,
							BorderSizePixel = 0,
							ZIndex = 6,
							[Children] = Kit.corner(scope, "Full"),
						},
					},
				},
			},
		}

		table.insert(slotFrames, scope:New "TextButton" {
			Name = `Slot{index}`,
			LayoutOrder = index,
			Text = "",
			AutoButtonColor = false,
			BackgroundTransparency = 1,
			SelectionImageObject = Kit.NoSelection,
			Size = scope:Computed(function(use)
				return UDim2.fromOffset(use(animated).X, slotSize + LIFT)
			end),
			[OnEvent "Activated"] = function()
				choose(index)
			end,
			[OnEvent "SelectionGained"] = function()
				focused:set(true)
			end,
			[OnEvent "SelectionLost"] = function()
				focused:set(false)
			end,
			[Children] = plate,
		})
	end

	local function shoulder(key: Enum.KeyCode, order: number)
		return scope:New "Frame" {
			Name = if order < 0 then "PrevHint" else "NextHint",
			LayoutOrder = order,
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(40, slotSize * 0.84),
			Visible = onGamepad,
			[Children] = KeyCap(scope, {
				Key = key,
				Gamepad = key,
				Size = 34,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
			}),
		}
	end

	local root = Kit.root(props, { Name = "Hotbar" })
	root.Size = UDim2.fromOffset(0, 0)
	root.AutomaticSize = Enum.AutomaticSize.XY
	root.BackgroundTransparency = 1
	root[Children] = {
		scope:New "UIListLayout" {
			FillDirection = Enum.FillDirection.Vertical,
			SortOrder = Enum.SortOrder.LayoutOrder,
			Padding = UDim.new(0, 4),
			-- Align may be a state (e.g. centred on phones, right on PC)
			HorizontalAlignment = scope:Computed(function(use)
				return Enum.HorizontalAlignment[use(props.Align) or "Right"]
			end),
		},
		if props.ShowName ~= false
			then Text(scope, {
				Name = "WeaponName",
				Visible = scope:Computed(function(use)
					return use(props.ShowName) ~= false
				end),
				Text = scope:Computed(function(use)
					local slot = slots[use(selected)]
					return if slot then slot.Name or "" else ""
				end),
				-- Roblox caps TextSize at 100
				TextSize = math.min(100, props.NameSize or math.floor(slotSize * 0.44)),
				Font = "Title",
				-- a faint dark edge keeps white-on-sky readable without a cartoon outline
				StrokeColor = BLACK,
				Stroke = 1.5,
				StrokeTransparency = 0.8,
				Transparency = nameAlpha,
				LayoutOrder = 1,
				[Children] = scope:New "UIScale" { Scale = namePop },
			})
			else nil,
		scope:New "Frame" {
			Name = "Slots",
			LayoutOrder = 2,
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(0, slotSize + LIFT),
			AutomaticSize = Enum.AutomaticSize.X,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = props.Gap or 8, VAlign = "Bottom" }),
				if #slots > 1 then shoulder(Enum.KeyCode.ButtonL1, -1) else nil,
				slotFrames,
				if #slots > 1 then shoulder(Enum.KeyCode.ButtonR1, 100) else nil,
			},
		},
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame
	-- the footprint a HUD layout reserves (the equipped slot is wider, and the
	-- name sits above)
	local n = math.max(1, #slots)
	frame:SetAttribute("HudSize", Vector2.new(math.floor(slotSize * 1.2955 + 0.5) + (n - 1) * (slotSize + (props.Gap or 8)), math.floor(slotSize * 1.693 + 0.5)))
	return frame
end

--------------------------------------------------------------------------
-- Kit looks
--------------------------------------------------------------------------

-- Ammo text for a slot: "30" (and "/ 120"), "x2" for counts, "" for melee.
local function ammoText(scope: Scope, slot: { [string]: any }, reserve: boolean): any
	return scope:Computed(function(use)
		if slot.Ammo ~= nil then
			local mag = tostring(use(slot.Ammo))
			if reserve and slot.Reserve ~= nil then
				return `{mag} / {use(slot.Reserve)}`
			end
			return mag
		elseif slot.Count ~= nil then
			return `x{use(slot.Count)}`
		end
		return ""
	end)
end

local function ammoColor(scope: Scope, slot: { [string]: any }, props: { [any]: any }, base: any): any
	local lowFraction = props.LowAmmo or 0.25
	return scope:Computed(function(use)
		local theme = use(Theme.current)
		if slot.Ammo ~= nil then
			local ammo = use(slot.Ammo) or 0
			if ammo <= 0 then
				return theme.Color.Bad:Lerp(WHITE, 0.15)
			end
			local mag = use(slot.MagSize)
			if mag and ammo <= math.max(1, math.floor(mag * lowFraction)) then
				return theme.Color.Warn
			end
		end
		return use(base)
	end)
end

local function slotIcon(scope: Scope, T: { [string]: any }, slot: { [string]: any }, size: any, extra: { [any]: any })
	local p = {
		Name = "Art",
		Icon = slot.Icon or "melee",
		Size = size,
		Color = slot.IconColor,
		Rotation = slot.IconRotation or 0,
		AnchorPoint = Vector2.new(0.5, 0.5),
		ZIndex = 5,
	}
	for k, v in extra do
		p[k] = v
	end
	return P.icon(scope, T, p)
end

-- Rounded cards fanned out; the equipped card rises, straightens and grows.
local function cardsLook(scope: Scope, props: { [any]: any }, ctl: { [string]: any }, T: { [string]: any }): Frame
	local slots, selected = ctl.slots, ctl.selected
	local n = math.max(1, #slots)
	local cardW, cardH = 76, 98
	local step = 60
	local lift = 18
	local nameH = 34
	local size = Vector2.new(step * (n - 1) + cardW + 28, cardH + lift + nameH + 14)
	local look = P.look(scope, T)
	local mid = (n + 1) / 2
	local cards = {}
	for index, slot in slots do
		local isSelected = scope:Computed(function(use)
			return use(selected) == index
		end)
		local goal = scope:Computed(function(use)
			local sel = use(selected) or 1
			local offset = index - sel
			if offset == 0 then
				return Vector3.new(0, -lift, 1.12)
			end
			-- the rest fan out from the equipped card
			return Vector3.new(math.clamp(offset, -3, 3) * 6, math.abs(index - mid) * 3, 1)
		end)
		local pose = scope:Spring(goal, 26, 0.55)
		local x = 14 + (index - 1) * step
		local card = P.button(scope, {
			Name = `Slot{index}`,
			Size = UDim2.fromOffset(cardW, cardH),
			AnchorPoint = Vector2.new(0.5, 1),
			Position = scope:Computed(function(use)
				local p = use(pose)
				return UDim2.new(0, x + cardW / 2, 1, math.floor(p.Y) - 6)
			end),
			ZIndex = scope:Computed(function(use)
				local sel = use(selected) or 1
				return if index == sel then 30 else 20 - math.abs(index - sel)
			end),
			Scale = scope:Computed(function(use)
				return use(pose).Z
			end),
			HoverScale = 1.04,
			OnClick = function()
				ctl.choose(index)
			end,
			Sound = "",
			Radius = "L",
			[Children] = {
				scope:New "Frame" {
					Name = "Tilt",
					BackgroundTransparency = 1,
					Size = UDim2.fromScale(1, 1),
					Rotation = scope:Computed(function(use)
						return use(pose).X
					end),
					[Children] = {
						P.plate(scope, T, { Radius = "Round", Emphasis = isSelected, ZIndex = 1 }),
						slotIcon(scope, T, slot, 50, {
							Position = UDim2.fromScale(0.5, 0.4),
							Transparency = scope:Computed(function(use)
								return if use(isSelected) then 0 else 0.25
							end),
						}),
						P.text(scope, T, look, {
							Name = "Key",
							Role = "Label",
							Text = tostring(index),
							TextSize = 13,
							AnchorPoint = Vector2.new(0, 0),
							Position = UDim2.fromOffset(8, 6),
							Visible = ctl.showNumbers,
							ZIndex = 6,
						}),
						P.text(scope, T, look, {
							Name = "Ammo",
							Role = "Number",
							Text = ammoText(scope, slot, false),
							TextSize = 17,
							AnchorPoint = Vector2.new(0.5, 1),
							Position = UDim2.new(0.5, 0, 1, -8),
							Color = ammoColor(scope, slot, props, scope:Computed(function(use)
								return use(look).ink
							end)),
							ZIndex = 6,
						}),
					},
				},
			},
		})
		table.insert(cards, card)
	end
	local root = Kit.root(props, { Name = "Hotbar" })
	root.BackgroundTransparency = 1
	root[Children] = {
		P.text(scope, T, look, {
			Name = "WeaponName",
			Role = "Title",
			Text = scope:Computed(function(use)
				local slot = slots[use(selected)]
				return if slot then slot.Name or "" else ""
			end),
			TextSize = 24,
			Color = scope:Computed(function(use)
				return use(Theme.current).Color.Text
			end),
			Stroke = 2,
			StrokeColor = scope:Computed(function(use)
				return use(Theme.current).Color.Outline
			end),
			StrokeTransparency = 0.2,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5, 0),
			ZIndex = 40,
		}),
		cards,
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

-- A numbered text list; an accent bar springs to the equipped row.
local function listLook(scope: Scope, props: { [any]: any }, ctl: { [string]: any }, T: { [string]: any }): Frame
	local slots, selected = ctl.slots, ctl.selected
	local n = math.max(1, #slots)
	local touch = props.Touch == true
	local rowH = if touch then 44 else 36
	local pad = 8
	local width = 300
	local size = Vector2.new(width, n * rowH + pad * 2)
	local look = P.look(scope, T)
	local marker = scope:Spring(scope:Computed(function(use)
		return ((use(selected) or 1) - 1) * rowH
	end), 30, 0.55)
	local rows = {}
	for index, slot in slots do
		local isSelected = scope:Computed(function(use)
			return use(selected) == index
		end)
		local ink = scope:Computed(function(use)
			local l = use(look)
			return if use(isSelected) then l.ink else l.muted
		end)
		table.insert(rows, P.button(scope, {
			Name = `Slot{index}`,
			Size = UDim2.new(1, -pad * 2, 0, rowH),
			Position = UDim2.fromOffset(pad, pad + (index - 1) * rowH),
			ZIndex = 4,
			HoverScale = 1.02,
			PressScale = 0.97,
			OnClick = function()
				ctl.choose(index)
			end,
			Sound = "",
			Radius = "S",
			[Children] = {
				P.text(scope, T, look, {
					Name = "Key",
					Role = "Number",
					Text = tostring(index),
					TextSize = 16,
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 12, 0.5, 0),
					Color = scope:Computed(function(use)
						return if use(isSelected) then use(look).accent else use(look).muted
					end),
					ZIndex = 6,
				}),
				slotIcon(scope, T, slot, 22, {
					Position = UDim2.new(0, 46, 0.5, 0),
					Treatment = "Flat",
					Color = ink,
					ZIndex = 6,
				}),
				P.text(scope, T, look, {
					Name = "ItemName",
					Role = "Title",
					Text = slot.Name or "",
					TextSize = if touch then 19 else 18,
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 64, 0.5, 0),
					Size = UDim2.new(1, -150, 0, 22),
					Truncate = true,
					Color = ink,
					ZIndex = 6,
				}),
				P.text(scope, T, look, {
					Name = "Ammo",
					Role = "Number",
					Text = ammoText(scope, slot, true),
					TextSize = 16,
					XAlign = "Right",
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -10, 0.5, 0),
					Color = ammoColor(scope, slot, props, ink),
					ZIndex = 6,
				}),
			},
		}))
	end
	local root = Kit.root(props, { Name = "Hotbar" })
	root.BackgroundTransparency = 1
	root[Children] = {
		P.plate(scope, T, { Edge = if props.Align == "Right" then "Right" else "Left", Idle = true }),
		scope:New "Frame" {
			Name = "Selection",
			Position = scope:Computed(function(use)
				return UDim2.fromOffset(pad, pad + math.floor(use(marker)))
			end),
			Size = UDim2.new(1, -pad * 2, 0, rowH),
			BackgroundColor3 = scope:Computed(function(use)
				return use(look).accent
			end),
			BackgroundTransparency = 0.82,
			BorderSizePixel = 0,
			ZIndex = 3,
			[Children] = {
				P.corner(scope, T, "Small"),
				scope:New "Frame" {
					Name = "Bar",
					Size = UDim2.new(0, 3, 1, -8),
					Position = UDim2.fromOffset(0, 4),
					BackgroundColor3 = scope:Computed(function(use)
						return use(look).accent
					end),
					BorderSizePixel = 0,
					ZIndex = 4,
				},
			},
		},
		rows,
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

-- Square slots stacked up the screen edge; the equipped one slides out with its name.
local function stackLook(scope: Scope, props: { [any]: any }, ctl: { [string]: any }, T: { [string]: any }): Frame
	local slots, selected = ctl.slots, ctl.selected
	local n = math.max(1, #slots)
	local slotSize = if props.Touch then 76 else 70
	local gap = 8
	local push = 14
	local labelW = 190
	local right = props.Align == "Right"
	-- centred (a bottom-centre hotbar on phones): the stack lies down, the
	-- equipped slot rises and its name sits above the row
	local flat = props.Align == "Center"
	local nameH = 30
	local size = if flat
		then Vector2.new(n * slotSize + (n - 1) * gap, slotSize + push + nameH)
		else Vector2.new(slotSize + push + labelW, n * slotSize + (n - 1) * gap)
	local look = P.look(scope, T)
	local cells = {}
	for index, slot in slots do
		local isSelected = scope:Computed(function(use)
			return use(selected) == index
		end)
		local out = scope:Spring(scope:Computed(function(use)
			return if use(isSelected) then 1 else 0
		end), 24, 0.6)
		local y = (index - 1) * (slotSize + gap)
		local x = (index - 1) * (slotSize + gap)
		table.insert(cells, P.button(scope, {
			Name = `Slot{index}`,
			Size = UDim2.fromOffset(slotSize, slotSize),
			AnchorPoint = Vector2.new(if right then 1 else 0, if flat then 1 else 0),
			Position = scope:Computed(function(use)
				local d = math.floor(use(out) * push)
				if flat then
					return UDim2.new(0, x, 1, -d)
				end
				return if right then UDim2.new(1, -d, 0, y) else UDim2.fromOffset(d, y)
			end),
			Scale = scope:Computed(function(use)
				return 1 + use(out) * 0.08
			end),
			ZIndex = 4,
			OnClick = function()
				ctl.choose(index)
			end,
			Sound = "",
			Radius = "M",
			[Children] = {
				P.plate(scope, T, { Emphasis = isSelected, Edge = if right then "Right" else "Left", ZIndex = 1 }),
				slotIcon(scope, T, slot, math.floor(slotSize * 0.56), {
					Position = UDim2.fromScale(0.5, if slot.Ammo ~= nil or slot.Count ~= nil then 0.42 else 0.5),
					Transparency = scope:Computed(function(use)
						return if use(isSelected) then 0 else 0.3
					end),
				}),
				P.text(scope, T, look, {
					Name = "Key",
					Role = "Label",
					Text = tostring(index),
					TextSize = 12,
					Position = UDim2.fromOffset(6, 4),
					Visible = ctl.showNumbers,
					ZIndex = 6,
				}),
				P.text(scope, T, look, {
					Name = "Ammo",
					Role = "Number",
					Text = ammoText(scope, slot, false),
					TextSize = 14,
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -4),
					Color = ammoColor(scope, slot, props, scope:Computed(function(use)
						return use(look).ink
					end)),
					ZIndex = 6,
				}),
			},
		}))
	end
	local nameY = scope:Spring(scope:Computed(function(use)
		return ((use(selected) or 1) - 1) * (slotSize + gap) + slotSize / 2
	end), 26, 0.6)
	local root = Kit.root(props, { Name = "Hotbar" })
	root.BackgroundTransparency = 1
	root[Children] = {
		cells,
		P.text(scope, T, look, {
			Name = "WeaponName",
			Role = "Title",
			Text = scope:Computed(function(use)
				local slot = slots[use(selected)]
				return if slot then slot.Name or "" else ""
			end),
			TextSize = 22,
			XAlign = if right then "Right" elseif flat then "Center" else "Left",
			AnchorPoint = Vector2.new(if right then 1 elseif flat then 0.5 else 0, if flat then 0 else 0.5),
			Position = scope:Computed(function(use)
				if flat then
					return UDim2.fromScale(0.5, 0)
				end
				local x = slotSize + push + 12
				return if right then UDim2.new(1, -x, 0, use(nameY)) else UDim2.fromOffset(x, use(nameY))
			end),
			Size = if flat then UDim2.new(1, 0, 0, 26) else UDim2.fromOffset(labelW - 14, 26),
			Truncate = true,
			Color = scope:Computed(function(use)
				return use(Theme.current).Color.Text
			end),
			Stroke = 2,
			StrokeColor = scope:Computed(function(use)
				return use(Theme.current).Color.Outline
			end),
			StrokeTransparency = 0.1,
			ZIndex = 8,
		}),
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

-- Round slots; a ring springs (elastic) to the equipped one.
local function roundLook(scope: Scope, props: { [any]: any }, ctl: { [string]: any }, T: { [string]: any }): Frame
	local slots, selected = ctl.slots, ctl.selected
	local n = math.max(1, #slots)
	local disc = if props.Touch then 78 else 72
	local gap = 12
	local nameH = 32
	local size = Vector2.new(n * disc + (n - 1) * gap + 16, disc + nameH + 22)
	local look = P.look(scope, T)
	local ringX = scope:Spring(scope:Computed(function(use)
		return 8 + ((use(selected) or 1) - 1) * (disc + gap) + disc / 2
	end), 28, 0.45)
	local cells = {}
	for index, slot in slots do
		local isSelected = scope:Computed(function(use)
			return use(selected) == index
		end)
		local grow = scope:Spring(scope:Computed(function(use)
			return if use(isSelected) then 1.12 else 1
		end), 26, T.bounce or 0.5)
		table.insert(cells, P.button(scope, {
			Name = `Slot{index}`,
			Size = UDim2.fromOffset(disc, disc),
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.new(0, 8 + (index - 1) * (disc + gap) + disc / 2, 1, -10),
			Scale = grow,
			ZIndex = 4,
			OnClick = function()
				ctl.choose(index)
			end,
			Sound = "",
			Radius = "Full",
			[Children] = {
				P.plate(scope, T, { Radius = "Pill", Emphasis = isSelected, ZIndex = 1 }),
				slotIcon(scope, T, slot, math.floor(disc * 0.5), {
					Position = UDim2.fromScale(0.5, if slot.Ammo ~= nil or slot.Count ~= nil then 0.42 else 0.5),
				}),
				P.text(scope, T, look, {
					Name = "Ammo",
					Role = "Number",
					Text = ammoText(scope, slot, false),
					TextSize = 14,
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -7),
					Color = ammoColor(scope, slot, props, scope:Computed(function(use)
						return use(look).ink
					end)),
					ZIndex = 6,
				}),
				scope:New "Frame" {
					Name = "KeyBadge",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 0, 0, 0),
					Size = UDim2.fromOffset(22, 22),
					BackgroundColor3 = scope:Computed(function(use)
						local theme = use(Theme.current)
						return if use(isSelected) then theme.Color.Accent else theme.Color.Surface
					end),
					Visible = ctl.showNumbers,
					ZIndex = 7,
					[Children] = {
						P.corner(scope, T, "Pill"),
						scope:New "UIStroke" {
							Color = scope:Computed(function(use)
								return use(Theme.current).Color.Outline
							end),
							Thickness = 2,
						},
						Text(scope, {
							Text = tostring(index),
							TextSize = 13,
							Font = T.numberFont,
							Color = scope:Computed(function(use)
								local theme = use(Theme.current)
								if use(isSelected) and Kit.isLight(theme.Color.Accent) then
									return theme.Color.TextDark
								end
								return theme.Color.Text
							end),
							Stroke = false,
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							ZIndex = 8,
						}),
					},
				},
			},
		}))
	end
	local root = Kit.root(props, { Name = "Hotbar" })
	root.BackgroundTransparency = 1
	root[Children] = {
		P.text(scope, T, look, {
			Name = "WeaponName",
			Role = "Title",
			Text = scope:Computed(function(use)
				local slot = slots[use(selected)]
				return if slot then slot.Name or "" else ""
			end),
			TextSize = 24,
			Color = scope:Computed(function(use)
				return use(Theme.current).Color.Text
			end),
			Stroke = 2,
			StrokeColor = scope:Computed(function(use)
				return use(Theme.current).Color.Outline
			end),
			StrokeTransparency = 0.1,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5, 0),
			ZIndex = 8,
		}),
		-- the selection ring slides between slots
		scope:New "Frame" {
			Name = "SelectionRing",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = scope:Computed(function(use)
				return UDim2.new(0, math.floor(use(ringX)), 1, -10 - disc / 2)
			end),
			Size = UDim2.fromOffset(disc + 18, disc + 18),
			BackgroundTransparency = 1,
			ZIndex = 3,
			[Children] = {
				P.corner(scope, T, "Pill"),
				scope:New "UIStroke" {
					Color = scope:Computed(function(use)
						return use(Theme.current).Color.Accent
					end),
					Thickness = 3,
				},
			},
		},
		cells,
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

-- Tactical: no slots, just the weapon's name, big ammo and slot ticks.
local function tacticalLook(scope: Scope, props: { [any]: any }, ctl: { [string]: any }, T: { [string]: any }): Frame
	local slots, selected = ctl.slots, ctl.selected
	local n = math.max(1, #slots)
	local size = Vector2.new(360, 128)
	local look = P.look(scope, T)
	local current = scope:Computed(function(use)
		return slots[use(selected) or 1] or {}
	end)
	local pop = scope:Spring(scope:Value(1), 30, 0.55)
	Kit.watch(scope, selected, function()
		pop:setPosition(1.12)
	end)
	local reload = scope:Computed(function(use)
		local value = use(props.Reloading)
		if typeof(value) == "number" and value < 1 then
			return math.clamp(value, 0, 1)
		end
		return nil
	end)
	local magText = scope:Computed(function(use)
		local slot = use(current)
		if slot.Ammo ~= nil then
			return tostring(use(slot.Ammo))
		elseif slot.Count ~= nil then
			return `x{use(slot.Count)}`
		end
		return "--"
	end)
	local reserveText = scope:Computed(function(use)
		local slot = use(current)
		if slot.Reserve ~= nil then
			return `/ {use(slot.Reserve)}`
		end
		return ""
	end)
	local magColor = scope:Computed(function(use)
		local theme = use(Theme.current)
		local slot = use(current)
		if slot.Ammo ~= nil then
			local ammo = use(slot.Ammo) or 0
			if ammo <= 0 then
				return theme.Color.Bad:Lerp(WHITE, 0.15)
			end
			local mag = use(slot.MagSize)
			if mag and ammo <= math.max(1, math.floor(mag * (props.LowAmmo or 0.25))) then
				return theme.Color.Warn
			end
		end
		return use(look).ink
	end)
	local ticks = {}
	local tickW = math.min(40, math.floor((size.X - 40 - (n - 1) * 6) / n))
	for index = 1, #slots do
		local isSelected = scope:Computed(function(use)
			return use(selected) == index
		end)
		table.insert(ticks, P.button(scope, {
			Name = `Slot{index}`,
			Size = UDim2.fromOffset(tickW, 22),
			LayoutOrder = index,
			HoverScale = 1.06,
			OnClick = function()
				ctl.choose(index)
			end,
			Sound = "",
			Radius = "S",
			[Children] = {
				scope:New "Frame" {
					Name = "Tick",
					AnchorPoint = Vector2.new(0, 1),
					Position = UDim2.fromScale(0, 1),
					Size = UDim2.new(1, 0, 0, 5),
					BackgroundColor3 = scope:Computed(function(use)
						local l = use(look)
						return if use(isSelected) then l.accent else l.muted
					end),
					BackgroundTransparency = scope:Computed(function(use)
						return if use(isSelected) then 0 else 0.45
					end),
					BorderSizePixel = 0,
				},
				P.text(scope, T, look, {
					Name = "Key",
					Role = "Label",
					Text = tostring(index),
					TextSize = 12,
					AnchorPoint = Vector2.new(0, 0),
					Position = UDim2.fromOffset(0, 0),
					Color = scope:Computed(function(use)
						local l = use(look)
						return if use(isSelected) then l.accent else l.muted
					end),
				}),
			},
		}))
	end
	local root = Kit.root(props, { Name = "Hotbar" })
	root.BackgroundTransparency = 1
	root[Children] = {
		P.plate(scope, T, { Edge = "Right", Idle = true }),
		P.text(scope, T, look, {
			Name = "WeaponName",
			Role = "Title",
			Text = scope:Computed(function(use)
				return use(current).Name or ""
			end),
			TextSize = 22,
			XAlign = "Right",
			AnchorPoint = Vector2.new(1, 0),
			Position = UDim2.new(1, -18, 0, 12),
			Size = UDim2.new(1, -36, 0, 24),
			Truncate = true,
			ZIndex = 4,
			[Children] = scope:New "UIScale" { Scale = pop },
		}),
		P.text(scope, T, look, {
			Name = "Reserve",
			Role = "Number",
			Text = reserveText,
			TextSize = 22,
			XAlign = "Right",
			AnchorPoint = Vector2.new(1, 1),
			Position = UDim2.new(1, -18, 1, -42),
			Color = scope:Computed(function(use)
				return use(look).muted
			end),
			ZIndex = 4,
		}),
		P.text(scope, T, look, {
			Name = "Mag",
			Role = "Number",
			Text = magText,
			TextSize = 54,
			XAlign = "Right",
			AnchorPoint = Vector2.new(1, 1),
			Position = scope:Computed(function(use)
				local reserve = use(reserveText)
				local w = P.textWidth(reserve, 22, T.numberFont, use(Theme.current))
				return UDim2.new(1, -18 - (if reserve == "" then 0 else w + 8), 1, -34)
			end),
			Color = magColor,
			ZIndex = 4,
		}),
		scope:New "Frame" {
			Name = "Slots",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(1, 1),
			Position = UDim2.new(1, -18, 1, -8),
			Size = UDim2.new(1, -36, 0, 22),
			ZIndex = 4,
			[Children] = {
				scope:New "UIListLayout" {
					FillDirection = Enum.FillDirection.Horizontal,
					SortOrder = Enum.SortOrder.LayoutOrder,
					Padding = UDim.new(0, 6),
					HorizontalAlignment = Enum.HorizontalAlignment.Right,
					VerticalAlignment = Enum.VerticalAlignment.Bottom,
				},
				ticks,
			},
		},
		scope:New "Frame" {
			Name = "Reload",
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.new(0.5, 0, 1, -2),
			Size = UDim2.new(1, -24, 0, 3),
			BackgroundColor3 = BLACK,
			BackgroundTransparency = 0.45,
			BorderSizePixel = 0,
			Visible = scope:Computed(function(use)
				return use(reload) ~= nil
			end),
			ZIndex = 5,
			[Children] = scope:New "Frame" {
				Name = "Fill",
				Size = scope:Computed(function(use)
					return UDim2.fromScale(use(reload) or 0, 1)
				end),
				BackgroundColor3 = scope:Computed(function(use)
					return use(look).accent
				end),
				BorderSizePixel = 0,
				ZIndex = 6,
			},
		},
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

--[[
	Style (with Kit = a HudKits name / tokens):
		"Slots"    the RIVALS slots above (the original)
		"Cards"    rounded cards fanned out; the equipped one rises and straightens
		"List"     a numbered text list with a springing selection bar
		"Stack"    square slots up the screen edge; the equipped one slides out
		"Round"    round slots with a selection ring that springs between them
		"Tactical" the weapon name, big ammo and slot ticks only
	Touch = true gives the new looks thumb-sized rows.
]]
return function(scope: Scope, props: { [any]: any }): Frame
	local ctl = control(scope, props)
	local style = P.style("Hotbar", props.Style)
	if style == "Slots" then
		return slotsLook(scope, props, ctl)
	end
	local T = P.tokens(props.Kit)
	if style == "Cards" then
		return cardsLook(scope, props, ctl, T)
	elseif style == "List" then
		return listLook(scope, props, ctl, T)
	elseif style == "Stack" then
		return stackLook(scope, props, ctl, T)
	elseif style == "Round" then
		return roundLook(scope, props, ctl, T)
	end
	return tacticalLook(scope, props, ctl, T)
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="103">
            <Properties>
              <string name="Name">HudBoosts</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	HudBoosts: active boosts ("2x Coins 4:59") that drain as they run out.

	local boosts = scope:Value({
		{ icon = "boost", text = "2x Coins", endsAt = os.time() + 300, seconds = 300 },
	})
	scope:HudBoosts {
		Boosts = boosts,         -- a list or a state holding one; expired boosts hide
		Clock = clock,           -- optional os.time() state (default: its own 1 s tick)
		Style = "Pills",         -- "Pills" (the original) | "Radial" (round icons with a
		                         -- draining ring) | "Badges" (a stacked badge column)
		Kit = "Neon",            -- HudKits name / tokens
		Align = "Right",         -- the screen side
		Horizontal = false,      -- side by side (top / bottom centre)
		Slots = 2,               -- rows to leave room for (default: the starting count)
	}

	`seconds` is the boost's full length, so the drain knows where it began
	(default: whatever was left when it first showed).
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Util.Format)
local P = require(script.Parent.HudParts)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local function field(entry: { [string]: any }, key: string): any
	local value = entry[key]
	if value == nil then
		value = entry[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

-- "2x Coins" -> "2x", "x3 Luck" -> "x3", "+50% Speed" -> "+50%"
local function multiplier(text: string): string?
	local first = string.match(text, "^(%S+)")
	if first and (string.match(first, "^%d+[xX]$") or string.match(first, "^[xX]%d+$") or string.match(first, "^%+?%d+%%$")) then
		return first
	end
	return nil
end

local function rest(text: string): string
	local tag = multiplier(text)
	if tag then
		return (string.gsub(string.sub(text, #tag + 1), "^%s+", ""))
	end
	return text
end

return function(scope: Scope, props: { [any]: any }): Frame
	local style = P.style("Boosts", props.Style)
	local T = P.tokens(props.Kit)
	local align = props.Align or "Right"
	-- the original pills always stack (their footprint is a column)
	local horizontal = props.Horizontal == true and style ~= "Pills"
	local boosts = props.Boosts or {}
	local initial = peek(boosts) or {}
	local n = math.max(1, props.Slots or #initial)

	local clock = props.Clock
	if clock == nil then
		clock = scope:Value(os.time())
		local alive = true
		table.insert(scope, function()
			alive = false
		end)
		task.spawn(function()
			while alive do
				task.wait(1)
				if alive then
					clock:set(os.time())
				end
			end
		end)
	end

	-- the full length of each boost (for the drain), remembered per entry
	local totals: { [any]: number } = setmetatable({}, { __mode = "k" }) :: any
	local function total(boost: { [string]: any }): number
		local given = tonumber(field(boost, "seconds"))
		if given and given > 0 then
			return given
		end
		local known = totals[boost]
		if known == nil then
			known = math.max(1, (tonumber(field(boost, "endsAt")) or 0) - peek(clock))
			totals[boost] = known
		end
		return known
	end

	local look = P.look(scope, T)
	local entryW, entryH, gap
	if style == "Radial" then
		if horizontal then
			entryW, entryH, gap = 70, 84, 10
		else
			entryW, entryH, gap = 220, 60, 8
		end
	elseif style == "Badges" then
		if horizontal then
			entryW, entryH, gap = 72, 92, 10
		else
			entryW, entryH, gap = 230, 66, 8
		end
	else
		entryW, entryH, gap = 320, 50, 10
	end
	local size = if horizontal
		then Vector2.new(n * entryW + (n - 1) * gap, entryH)
		else Vector2.new(entryW, n * entryH + (n - 1) * gap)
	if style == "Pills" then
		size = Vector2.new(320, n * 50 + (n - 1) * 10)
	end

	local rows = scope:ForValues(boosts, function(_, inner, boost)
		local endsAt = tonumber(field(boost, "endsAt")) or 0
		local length = total(boost)
		local remaining = inner:Computed(function(use)
			return math.max(0, endsAt - use(clock))
		end)
		local fraction = inner:Spring(inner:Computed(function(use)
			return math.clamp(use(remaining) / length, 0, 1)
		end), 6, 1)
		local text = tostring(field(boost, "text") or "Boost")
		local icon = field(boost, "icon") or "boost"
		local visible = inner:Computed(function(use)
			return use(remaining) > 0
		end)
		local timeText = inner:Computed(function(use)
			return Format.time(use(remaining))
		end)

		if style == "Pills" then
			-- the original: a glass pill with icon, name and the time left
			return inner:New "Frame" {
				Name = "Boost",
				Size = UDim2.fromOffset(0, 50),
				AutomaticSize = Enum.AutomaticSize.X,
				Visible = visible,
				BackgroundColor3 = if P.kitted(props.Kit)
					then inner:Computed(function(use)
						return use(look).face
					end)
					else Kit.themed(inner, function(theme)
						return theme.Color.Surface
					end),
				BackgroundTransparency = if P.kitted(props.Kit)
					then inner:Computed(function(use)
						local l = use(look)
						return if l.alpha >= 1 then 0.6 else l.alpha
					end)
					else Kit.themed(inner, function(theme)
						return theme.Alpha.Surface
					end),
				[Children] = {
					if P.kitted(props.Kit) then P.corner(inner, T, "Pill") else Kit.corner(inner, "Full"),
					Kit.stroke(inner, { Color = Color3.new(1, 1, 1), Thickness = 1.5, Transparency = 0.85 }),
					Kit.padding(inner, { 0, 18, 0, 8 }),
					Kit.list(inner, { Direction = "X", Padding = 8, VAlign = "Center" }),
					Icon(inner, {
						Icon = icon,
						Size = 38,
						Color = Kit.themed(inner, function(theme)
							return theme.Tone.Green.Top
						end),
						Gradient = true,
						LayoutOrder = 1,
					}),
					if P.kitted(props.Kit)
						then P.text(inner, T, look, { Text = text, TextSize = 22, Role = "Title", LayoutOrder = 2 })
						else Text(inner, { Text = text, TextSize = 22, Font = "Display", LayoutOrder = 2 }),
					if P.kitted(props.Kit)
						then P.text(inner, T, look, {
							Text = timeText,
							TextSize = 22,
							Role = "Number",
							Color = inner:Computed(function(use)
								return use(look).accent
							end),
							LayoutOrder = 3,
						})
						else Text(inner, {
							Text = timeText,
							TextSize = 22,
							Font = "Display",
							Color = Kit.themed(inner, function(theme)
								return theme.Color.Accent
							end),
							LayoutOrder = 3,
						}),
				},
			}
		end

		local tag = multiplier(text)
		local name = rest(text)
		local green = Kit.tone(inner, "Green")
		local lowTime = inner:Computed(function(use)
			return use(remaining) <= 10
		end)
		local timeColor = inner:Computed(function(use)
			local theme = use(Theme.current)
			return if use(lowTime) then theme.Color.Bad:Lerp(Color3.new(1, 1, 1), 0.2) else use(look).ink
		end)

		if style == "Radial" then
			local disc = if horizontal then 60 else 56
			local ring = P.ring(inner, {
				Name = "Timer",
				Size = disc,
				Thickness = 5,
				Fraction = fraction,
				Color = inner:Computed(function(use)
					return if use(lowTime) then use(Theme.current).Color.Bad else use(green).Top
				end),
				Track = inner:Computed(function(use)
					return use(look).track
				end),
				TrackAlpha = 0.25,
				Cutout = P.holeColor(inner, look),
				ZIndex = 3,
				[Children] = {
					P.icon(inner, T, {
						Icon = icon,
						Size = math.floor(disc * 0.5),
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = inner:Computed(function(use)
							return use(green).Top
						end),
						Treatment = if T.icon == "Badge" then "Gradient" else nil,
						ZIndex = 8,
					}),
					if tag
						then inner:New "Frame" {
							Name = "Tag",
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.new(1, -4, 0, 6),
							Size = UDim2.fromOffset(0, 20),
							AutomaticSize = Enum.AutomaticSize.X,
							BackgroundColor3 = Kit.themed(inner, function(theme)
								return theme.Color.Accent
							end),
							ZIndex = 9,
							[Children] = {
								inner:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
								Kit.padding(inner, { 0, 5 }),
								Text(inner, {
									Text = tag,
									TextSize = 14,
									Font = T.numberFont,
									Color = Kit.themed(inner, function(theme)
										return Kit.isLight(theme.Color.Accent) and theme.Color.TextDark or theme.Color.Text
									end),
									Stroke = false,
									Size = UDim2.fromScale(0, 1),
									AutoSize = "X",
									ZIndex = 10,
								}),
							},
						}
						else nil,
				},
			})
			if horizontal then
				ring.AnchorPoint = Vector2.new(0.5, 0)
				ring.Position = UDim2.new(0.5, 0, 0, 0)
				return inner:New "Frame" {
					Name = "Boost",
					BackgroundTransparency = 1,
					Size = UDim2.fromOffset(entryW, entryH),
					Visible = visible,
					[Children] = {
						ring,
						P.text(inner, T, look, {
							Name = "Time",
							Role = "Number",
							Text = timeText,
							TextSize = 16,
							AnchorPoint = Vector2.new(0.5, 1),
							Position = UDim2.fromScale(0.5, 1),
							Color = timeColor,
							Stroke = 1.5,
							StrokeColor = inner:Computed(function(use)
								return use(Theme.current).Color.Outline
							end),
							ZIndex = 4,
						}),
					},
				}
			end
			local right = align == "Right"
			ring.AnchorPoint = Vector2.new(if right then 1 else 0, 0.5)
			ring.Position = UDim2.new(if right then 1 else 0, 0, 0.5, 0)
			local textX = disc + 10
			return inner:New "Frame" {
				Name = "Boost",
				BackgroundTransparency = 1,
				Size = UDim2.fromOffset(entryW, entryH),
				Visible = visible,
				[Children] = {
					P.plate(inner, T, {
						Name = "Tail",
						Radius = "Pill",
						AnchorPoint = Vector2.new(if right then 1 else 0, 0.5),
						Position = UDim2.new(if right then 1 else 0, if right then -disc / 2 else disc / 2, 0.5, 0),
						Size = UDim2.fromOffset(entryW - disc / 2, 44),
						ZIndex = 1,
					}),
					ring,
					P.text(inner, T, look, {
						Name = "Name",
						Role = "Title",
						Text = name,
						TextSize = 17,
						XAlign = if right then "Right" else "Left",
						AnchorPoint = Vector2.new(if right then 1 else 0, 1),
						Position = UDim2.new(if right then 1 else 0, if right then -textX else textX, 0.5, 1),
						Size = UDim2.fromOffset(entryW - textX - 12, 20),
						Truncate = true,
						ZIndex = 4,
					}),
					P.text(inner, T, look, {
						Name = "Time",
						Role = "Number",
						Text = timeText,
						TextSize = 17,
						XAlign = if right then "Right" else "Left",
						AnchorPoint = Vector2.new(if right then 1 else 0, 0),
						Position = UDim2.new(if right then 1 else 0, if right then -textX else textX, 0.5, 1),
						Size = UDim2.fromOffset(entryW - textX - 12, 20),
						Color = inner:Computed(function(use)
							return if use(lowTime) then use(Theme.current).Color.Bad else use(look).accent
						end),
						ZIndex = 4,
					}),
				},
			}
		end

		-- Badges: a tile that drains from the top, the name and time beside it
		local badge = if horizontal then 64 else 58
		local drain = inner:New "Frame" {
			Name = "Badge",
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(badge, badge),
			ZIndex = 3,
			[Children] = {
				P.plate(inner, T, { Radius = "Soft", ZIndex = 3 }),
				inner:New "Frame" {
					Name = "Charge",
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -4),
					Size = inner:Computed(function(use)
						return UDim2.new(1, -8, use(fraction), -8 * use(fraction))
					end),
					BackgroundColor3 = inner:Computed(function(use)
						return if use(lowTime) then use(Theme.current).Color.Bad else use(green).Bottom
					end),
					BackgroundTransparency = inner:Computed(function(use)
						return if use(look).light then 0.35 else 0.45
					end),
					BorderSizePixel = 0,
					ZIndex = 5,
					[Children] = inner:New "UICorner" {
						CornerRadius = inner:Computed(function(use)
							local r = P.radiusFor(use(Theme.current), T, "Soft")
							return UDim.new(0, math.max(0, r.Offset - 3))
						end),
					},
				},
				P.icon(inner, T, {
					Icon = icon,
					Size = math.floor(badge * 0.56),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Color = inner:Computed(function(use)
						return use(green).Top
					end),
					Treatment = if T.icon == "Badge" then "Gradient" else nil,
					ZIndex = 7,
				}),
				if tag
					then P.text(inner, T, look, {
						Name = "Tag",
						Role = "Number",
						Text = tag,
						TextSize = 16,
						AnchorPoint = Vector2.new(1, 0),
						Position = UDim2.new(1, 4, 0, -6),
						Color = inner:Computed(function(use)
							return use(Theme.current).Color.Accent
						end),
						Stroke = 2,
						StrokeColor = inner:Computed(function(use)
							return use(Theme.current).Color.Outline
						end),
						StrokeTransparency = 0,
						Rotation = if (T.tilt or 0) ~= 0 then T.tilt * 3 else 8,
						ZIndex = 9,
					})
					else nil,
			},
		}
		if horizontal then
			drain.AnchorPoint = Vector2.new(0.5, 0)
			drain.Position = UDim2.fromScale(0.5, 0)
			return inner:New "Frame" {
				Name = "Boost",
				BackgroundTransparency = 1,
				Size = UDim2.fromOffset(entryW, entryH),
				Visible = visible,
				[Children] = {
					drain,
					P.text(inner, T, look, {
						Name = "Time",
						Role = "Number",
						Text = timeText,
						TextSize = 16,
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.fromScale(0.5, 1),
						Color = timeColor,
						Stroke = 1.5,
						StrokeColor = inner:Computed(function(use)
							return use(Theme.current).Color.Outline
						end),
						ZIndex = 4,
					}),
				},
			}
		end
		local right = align == "Right"
		drain.AnchorPoint = Vector2.new(if right then 1 else 0, 0.5)
		drain.Position = UDim2.new(if right then 1 else 0, 0, 0.5, 0)
		local textX = badge + 12
		return inner:New "Frame" {
			Name = "Boost",
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(entryW, entryH),
			Visible = visible,
			[Children] = {
				drain,
				P.text(inner, T, look, {
					Name = "Name",
					Role = "Title",
					Text = text,
					TextSize = 18,
					XAlign = if right then "Right" else "Left",
					AnchorPoint = Vector2.new(if right then 1 else 0, 1),
					Position = UDim2.new(if right then 1 else 0, if right then -textX else textX, 0.5, 0),
					Size = UDim2.fromOffset(entryW - textX, 22),
					Truncate = true,
					Color = inner:Computed(function(use)
						local l = use(look)
						-- names float over the world here: plate ink only on a plate
						return if l.light then use(Theme.current).Color.Text else l.ink
					end),
					Stroke = 2,
					StrokeColor = inner:Computed(function(use)
						return use(Theme.current).Color.Outline
					end),
					StrokeTransparency = 0.1,
					ZIndex = 4,
				}),
				P.text(inner, T, look, {
					Name = "Time",
					Role = "Number",
					Text = timeText,
					TextSize = 18,
					XAlign = if right then "Right" else "Left",
					AnchorPoint = Vector2.new(if right then 1 else 0, 0),
					Position = UDim2.new(if right then 1 else 0, if right then -textX else textX, 0.5, 2),
					Size = UDim2.fromOffset(entryW - textX, 22),
					Color = inner:Computed(function(use)
						local theme = use(Theme.current)
						return if use(lowTime) then theme.Color.Bad else theme.Color.Accent
					end),
					Stroke = 2,
					StrokeColor = inner:Computed(function(use)
						return use(Theme.current).Color.Outline
					end),
					StrokeTransparency = 0.1,
					ZIndex = 4,
				}),
			},
		}
	end)

	local root = Kit.root(props, { Name = "Boosts" })
	root.BackgroundTransparency = 1
	root[Children] = {
		scope:New "UIListLayout" {
			FillDirection = if horizontal then Enum.FillDirection.Horizontal else Enum.FillDirection.Vertical,
			SortOrder = Enum.SortOrder.LayoutOrder,
			Padding = UDim.new(0, gap),
			HorizontalAlignment = if horizontal then Enum.HorizontalAlignment.Center else Enum.HorizontalAlignment[align],
			VerticalAlignment = Enum.VerticalAlignment.Top,
		},
		rows,
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="104">
            <Properties>
              <string name="Name">HudCurrencies</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	HudCurrencies: the HUD's currency bar (every currency the game shows),
	in one of six looks.

	scope:HudCurrencies {
		Currencies = {
			{ Id = "coins", Name = "Coins", Icon = "coin", Color = gold, Amount = coins,
			  Format = "abbrev", OnAdd = function() end },
			{ Id = "gems", Icon = "gem", Color = blue, Amount = gems },
		},
		Style = "Pill",        -- "Pill" (big overlapping icon; the original) | "Chips" (slim stacked
		                       -- chips) | "Banner" (one top strip) | "Wallet" (a corner card)
		                       -- | "Tiles" (icon above number) | "Readout" (text and a tiny icon)
		Kit = "Chunky Toy",    -- HudKits name / tokens
		Horizontal = false,    -- lay counters side by side (top / bottom centre)
		Align = "Left",        -- the screen side (counters hug it)
		Compact = false,
		Frames = {},           -- optional: filled with Frames[id] = counter frame (FX.flyTo targets)
	}

	Every counter rolls its number, flashes green / red and pops its icon.
	The root has a fixed size (and a HudSize attribute) for HUD layouts.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local P = require(script.Parent.HudParts)
local Currency = require(script.Parent.Currency)

local Children = Fusion.Children

type Scope = Fusion.Scope<any>

local function field(entry: { [string]: any }, key: string): any
	local value = entry[key]
	if value == nil then
		value = entry[string.lower(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

local function counterProps(entry: { [string]: any }, index: number): { [any]: any }
	local id = field(entry, "Id") or `Currency{index}`
	return {
		Name = id,
		Icon = field(entry, "Icon"),
		IconColor = field(entry, "Color"),
		Amount = field(entry, "Amount"),
		Format = field(entry, "Format"),
		OnAdd = field(entry, "OnAdd"),
		LayoutOrder = index,
	}
end

return function(scope: Scope, props: { [any]: any }): Frame
	local style = P.style("CurrencyBar", props.Style)
	local T = P.tokens(props.Kit)
	local kitted = P.kitted(props.Kit)
	local compact = props.Compact == true
	local horizontal = props.Horizontal == true
	local align = props.Align or "Left"
	local list = props.Currencies or {}
	local frames = props.Frames or {}
	local n = math.max(1, #list)

	local counterStyle, w, h, gap = "Pill", 0, 0, 0
	if style == "Pill" then
		counterStyle, w, h, gap = "Pill", if compact then 230 else 260, if compact then 54 else 60, if horizontal then 16 else 12
	elseif style == "Chips" then
		counterStyle, w, h, gap = "Chip", if compact then 184 else 204, if compact then 40 else 44, if horizontal then 10 else 8
	elseif style == "Tiles" then
		counterStyle, w, h, gap = "Tile", if compact then 84 else 94, if compact then 96 else 106, 12
		-- tiles read best side by side, even in a corner
		horizontal = horizontal or n <= 3
	elseif style == "Readout" then
		counterStyle, w, h, gap = "Readout", if compact then 170 else 190, if compact then 32 else 36, if horizontal then 24 else 4
	else
		counterStyle, w, h, gap = "Line", if compact then 140 else 156, if compact then 36 else 40, 0
		-- wide number faces (pixel type) need a wider row for "88.8K"
		local needed = math.floor(h * 0.78) + 8 + P.textWidth("88.8K", math.floor(h * 0.62), T.numberFont)
		for _, entry in list do
			if field(entry, "OnAdd") then
				needed += math.floor(h * 0.7) + 6
				break
			end
		end
		w = math.max(w, math.ceil(needed))
	end

	local counters = {}
	for index, entry in list do
		local p = counterProps(entry, index)
		p.Style = if counterStyle == "Pill" then (if kitted then "Pill" else nil) else counterStyle
		p.Kit = props.Kit
		p.Size = UDim2.fromOffset(w, h)
		p.Align = if counterStyle == "Readout" then align else nil
		local frame = Currency(scope, p)
		frames[p.Name] = frame
		table.insert(counters, frame)
	end

	local root = Kit.root(props, { Name = "CurrencyBar" })
	root.BackgroundTransparency = 1
	local size: Vector2

	if style == "Banner" or style == "Wallet" then
		local look = P.look(scope, T)
		local rows = {}
		if style == "Banner" then
			-- one strip, counters separated by thin rules
			local pad = 18
			local rule = 2
			local entryW = w
			size = Vector2.new(n * entryW + (n - 1) * (rule + 24) + pad * 2, if compact then 52 else 58)
			for index, frame in counters do
				frame.LayoutOrder = index * 2
				table.insert(rows, frame)
				if index < #counters then
					table.insert(rows, scope:New "Frame" {
						Name = "Rule",
						LayoutOrder = index * 2 + 1,
						Size = UDim2.fromOffset(rule, math.floor(size.Y * 0.5)),
						BackgroundColor3 = scope:Computed(function(use)
							return use(look).muted
						end),
						BackgroundTransparency = 0.55,
						BorderSizePixel = 0,
						ZIndex = 4,
					})
				end
			end
			root[Children] = {
				P.plate(scope, T, { Radius = "Pill", Edge = "Bottom", Idle = true }),
				scope:New "Frame" {
					Name = "Counters",
					BackgroundTransparency = 1,
					Size = UDim2.fromScale(1, 1),
					ZIndex = 4,
					[Children] = {
						scope:New "UIListLayout" {
							FillDirection = Enum.FillDirection.Horizontal,
							SortOrder = Enum.SortOrder.LayoutOrder,
							Padding = UDim.new(0, 12),
							HorizontalAlignment = Enum.HorizontalAlignment.Center,
							VerticalAlignment = Enum.VerticalAlignment.Center,
						},
						rows,
					},
				},
				props[Children],
			}
		else
			-- a card: a small header, then one row per currency
			local pad = 14
			local header = if compact then 28 else 32
			local rowH = h + 6
			w = w + 36
			size = Vector2.new(w + pad * 2, pad + header + n * rowH + pad - 4)
			for index, frame in counters do
				table.insert(rows, scope:New "Frame" {
					Name = `Row{index}`,
					BackgroundTransparency = 1,
					LayoutOrder = index,
					Size = UDim2.new(1, 0, 0, rowH),
					[Children] = {
						frame,
						if index < #counters
							then scope:New "Frame" {
								Name = "Rule",
								AnchorPoint = Vector2.new(0, 1),
								Position = UDim2.fromScale(0, 1),
								Size = UDim2.new(1, 0, 0, 1),
								BackgroundColor3 = scope:Computed(function(use)
									return use(look).muted
								end),
								BackgroundTransparency = 0.7,
								BorderSizePixel = 0,
								ZIndex = 4,
							}
							else nil,
					},
				})
				frame.Size = UDim2.new(0, w, 0, h)
				frame.AnchorPoint = Vector2.new(0, 0.5)
				frame.Position = UDim2.fromScale(0, 0.5)
			end
			root[Children] = {
				P.plate(scope, T, { Edge = if align == "Right" then "Right" else "Left", Idle = true }),
				P.icon(scope, T, {
					Name = "WalletIcon",
					Icon = "wallet",
					Size = header - 6,
					Color = scope:Computed(function(use)
						return use(look).accent
					end),
					Treatment = "Flat",
					Position = UDim2.fromOffset(pad, pad),
					ZIndex = 4,
				}),
				P.text(scope, T, look, {
					Name = "Title",
					Role = "Title",
					Text = props.Title or "Wallet",
					TextSize = if compact then 18 else 20,
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.fromOffset(pad + header + 2, pad + (header - 6) / 2),
					Color = scope:Computed(function(use)
						return use(look).ink
					end),
					ZIndex = 4,
				}),
				scope:New "Frame" {
					Name = "Rows",
					BackgroundTransparency = 1,
					Position = UDim2.fromOffset(pad, pad + header),
					Size = UDim2.new(1, -pad * 2, 0, n * rowH),
					ZIndex = 4,
					[Children] = {
						scope:New "UIListLayout" {
							FillDirection = Enum.FillDirection.Vertical,
							SortOrder = Enum.SortOrder.LayoutOrder,
						},
						rows,
					},
				},
				props[Children],
			}
		end
	else
		size = if horizontal
			then Vector2.new(n * w + (n - 1) * gap, h)
			else Vector2.new(w, n * h + (n - 1) * gap)
		if style == "Tiles" then
			-- room for icons that break out of the tile's top edge
			size = Vector2.new(size.X, size.Y + 10)
		end
		root[Children] = {
			scope:New "UIListLayout" {
				FillDirection = if horizontal then Enum.FillDirection.Horizontal else Enum.FillDirection.Vertical,
				SortOrder = Enum.SortOrder.LayoutOrder,
				Padding = UDim.new(0, gap),
				HorizontalAlignment = if horizontal then Enum.HorizontalAlignment.Center else Enum.HorizontalAlignment[align],
				VerticalAlignment = if style == "Tiles" then Enum.VerticalAlignment.Bottom else Enum.VerticalAlignment.Center,
			},
			counters,
			props[Children],
		}
	end

	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="105">
            <Properties>
              <string name="Name">HudHealth</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	HudHealth: the player's health in one of six looks.

	scope:HudHealth {
		Value = health, Max = maxHealth,     -- numbers or states
		Style = "Bar",       -- "Bar" (RIVALS flat bar, number inside; the original)
		                     -- | "Segments" (chunks) | "Arc" (arc round a portrait)
		                     -- | "Big" (big number + thin bar, Valorant) | "Hearts" (pips)
		                     -- | "Pulse" (an ECG line that flattens as you weaken)
		Kit = "Neon",        -- HudKits name / tokens (plates, fonts, outlines)
		Compact = false,     -- smaller (Compact density)
		Align = "Left",      -- the side of the screen it sits on
		Low = 0.3,           -- fraction that counts as low: pulses and shows a warning shape
		Icon = "heart",
		Portrait = "person", -- Arc: an icon name or an image id (e.g. the avatar headshot)
		Hearts = 10,         -- Hearts: how many (default Max / 10, 3..10)
	}

	Every style rolls its number, trails losses with a pale ghost, glows on a
	heal and pulses when low (a steady tint with reduced motion). Colours
	are the theme's Green / Yellow / Red tones, so colour-blind palettes apply.
	The root has a fixed size (and a HudSize attribute) for HUD layouts.
]]

local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Util.Format)
local Motion = require(script.Parent.Parent.FX.Motion)
local P = require(script.Parent.HudParts)
local Icon = require(script.Parent.Icon)
local ProgressBar = require(script.Parent.ProgressBar)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

local pulseWhile = P.pulseWhile

local function state(scope: Scope, props: { [any]: any })
	local value = P.number(scope, props.Value or 100)
	local max = P.number(scope, props.Max or 100)
	local fraction = scope:Computed(function(use)
		local m = use(max)
		return if m > 0 then math.clamp(use(value) / m, 0, 1) else 0
	end)
	local fill = scope:Spring(fraction, 16, 1)
	local ghostGoal = scope:Value(peek(fraction))
	local ghost = scope:Spring(ghostGoal, 6, 1)
	local heal = scope:Spring(scope:Value(1), 7, 1)
	local token = 0
	local last = peek(fraction)
	Kit.watch(scope, fraction, function(now)
		token += 1
		if now >= peek(ghostGoal) then
			ghostGoal:set(now)
		else
			local mine = token
			task.delay(0.35, function()
				if mine == token then
					ghostGoal:set(now)
				end
			end)
		end
		if now > last + 0.001 then
			heal:setPosition(0)
		end
		last = now
	end)
	local lowAt = props.Low or 0.3
	local low = scope:Computed(function(use)
		local f = use(fraction)
		return f > 0 and f <= lowAt
	end)
	local tone = P.healthTone(scope, fraction, lowAt)
	local toneColors = scope:Computed(function(use)
		return Theme.tone(use(Theme.current), use(tone))
	end)
	-- the number rolls (a spring), so hits and heals count down / up
	local rolling = scope:Spring(value, 14, 1)
	local shown = scope:Computed(function(use)
		return Format.comma(math.max(0, math.ceil(use(rolling) - 0.01)))
	end)
	return {
		value = value,
		max = max,
		fraction = fraction,
		fill = fill,
		ghost = ghost,
		heal = heal,
		low = low,
		lowAt = lowAt,
		tone = tone,
		toneColors = toneColors,
		shown = shown,
	}
end

local function root(props: { [any]: any }, size: Vector2): { [any]: any }
	local r = Kit.root(props, { Name = "Health" })
	r.Size = UDim2.fromOffset(size.X, size.Y)
	r.BackgroundTransparency = 1
	return r
end

local function finish(scope: Scope, props: { [any]: any }, r: { [any]: any }, size: Vector2, children: { any }): Frame
	r[Children] = { children, props[Children] }
	local frame = scope:New "Frame" (r) :: Frame
	P.footprint(frame, size)
	return frame
end

local BUILD: { [string]: (Scope, { [any]: any }, { [string]: any }) -> Frame } = {}

-- The original look: RIVALS' flat bar with the number (and a heart) inside.
function BUILD.Bar(scope, props, T)
	local size = Vector2.new(if props.Compact then 360 else 400, 44)
	local kitted = P.kitted(props.Kit)
	local s = state(scope, props)
	local bar = ProgressBar(scope, {
		Name = props.Name or "Health",
		Value = s.value,
		Max = s.max,
		Tone = s.tone,
		Size = UDim2.fromOffset(size.X, size.Y),
		Label = true,
		LabelIcon = props.Icon or "heart",
		Low = s.lowAt,
		LowIcon = "warning",
		Shine = not kitted or T.gloss ~= false,
		Heal = true,
		Gloss = if kitted then T.gloss ~= false else nil,
		Radius = if kitted
			then scope:Computed(function(use)
				return P.radiusFor(use(Theme.current), T, "Pill")
			end)
			else nil,
		Position = props.Position,
		AnchorPoint = props.AnchorPoint,
		LayoutOrder = props.LayoutOrder,
		Visible = props.Visible,
		ZIndex = props.ZIndex,
		[Children] = props[Children],
	})
	bar:SetAttribute("HudSize", size)
	return bar
end

-- Chunky segments with the number in front.
function BUILD.Segments(scope, props, T)
	local compact = props.Compact
	local size = Vector2.new(if compact then 350 else 410, if compact then 50 else 56)
	local s = state(scope, props)
	local look = P.look(scope, T)
	local r = root(props, size)
	local count = if compact then 8 else 10
	local numberW = if compact then 96 else 108
	local pad = 12
	local gap = 4
	local rowW = size.X - numberW - pad * 2
	local chunkW = (rowW - (count - 1) * gap) / count
	local chunkH = if compact then 20 else 24
	local host = scope:New "Frame" { Name = "Chunks", BackgroundTransparency = 1 }
	local pulse = pulseWhile(scope, host, s.low, 0.7)
	local chunkRadius = scope:Computed(function(use)
		local theme = use(Theme.current)
		local r0 = P.radiusFor(theme, T, "Small")
		return UDim.new(0, math.min(r0.Offset, 5))
	end)
	local chunks = {}
	for i = 0, count - 1 do
		local lit = scope:Computed(function(use)
			return math.clamp(use(s.fill) * count - i, 0, 1)
		end)
		local ghostLit = scope:Computed(function(use)
			return math.clamp(use(s.ghost) * count - i, 0, 1)
		end)
		table.insert(chunks, scope:New "Frame" {
			Name = `Chunk{i + 1}`,
			Position = UDim2.fromOffset(math.floor(i * (chunkW + gap)), 0),
			Size = UDim2.fromOffset(math.floor(chunkW), chunkH),
			BackgroundColor3 = scope:Computed(function(use)
				return use(look).track
			end),
			BackgroundTransparency = scope:Computed(function(use)
				return math.max(0.15, use(look).trackAlpha)
			end),
			BorderSizePixel = 0,
			ZIndex = 3,
			[Children] = {
				scope:New "UICorner" { CornerRadius = chunkRadius },
				scope:New "Frame" {
					Name = "Ghost",
					Size = scope:Computed(function(use)
						return UDim2.fromScale(math.max(use(ghostLit), use(lit)), 1)
					end),
					BackgroundColor3 = WHITE,
					BackgroundTransparency = 0.3,
					BorderSizePixel = 0,
					ZIndex = 4,
					[Children] = scope:New "UICorner" { CornerRadius = chunkRadius },
				},
				scope:New "Frame" {
					Name = "Fill",
					Size = scope:Computed(function(use)
						return UDim2.fromScale(use(lit), 1)
					end),
					BackgroundColor3 = scope:Computed(function(use)
						local t = use(s.toneColors)
						local heal = math.clamp(use(s.heal), 0, 1)
						local base = t.Top:Lerp(t.Bottom, 0.35)
						base = base:Lerp(WHITE, (1 - heal) * 0.6)
						return base:Lerp(use(Theme.current).Color.Bad, use(pulse) * 0.35)
					end),
					BorderSizePixel = 0,
					ZIndex = 5,
					[Children] = {
						scope:New "UICorner" { CornerRadius = chunkRadius },
						if T.gloss ~= false
							then Kit.unlessLite(scope, function(inner)
								return inner:New "Frame" {
									Name = "Gloss",
									BackgroundColor3 = WHITE,
									BackgroundTransparency = 0.62,
									BorderSizePixel = 0,
									Size = UDim2.new(1, 0, 0.4, 0),
									ZIndex = 6,
									[Children] = inner:New "UICorner" { CornerRadius = chunkRadius },
								}
							end)
							else nil,
					},
				},
			},
		})
	end
	host.Position = UDim2.new(0, pad + numberW, 0.5, 0)
	host.AnchorPoint = Vector2.new(0, 0.5)
	host.Size = UDim2.fromOffset(rowW, chunkH)
	host.ZIndex = 3
	for _, chunk in chunks do
		chunk.Parent = host
	end
	return finish(scope, props, r, size, {
		P.plate(scope, T, { Radius = "Soft", Edge = "Left", Idle = true }),
		P.icon(scope, T, {
			Name = "HeartIcon",
			Icon = scope:Computed(function(use)
				return if use(s.low) then "warning" else props.Icon or "heart"
			end),
			Size = if compact then 24 else 28,
			Color = scope:Computed(function(use)
				return use(Theme.current).Tone.Red.Top
			end),
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, pad, 0.5, 0),
			ZIndex = 4,
		}),
		P.text(scope, T, look, {
			Name = "Amount",
			Role = "Number",
			Text = s.shown,
			TextSize = if compact then 28 else 32,
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, pad + (if compact then 30 else 34), 0.5, 0),
			Size = UDim2.fromOffset(numberW - (if compact then 36 else 40), size.Y - 8),
			Scaled = true,
			Wrapped = false,
			MaxTextSize = if compact then 28 else 32,
			ZIndex = 4,
		}),
		host,
	})
end

-- A round portrait with a segmented arc round it and a name plate tail.
function BUILD.Arc(scope, props, T)
	local compact = props.Compact
	local disc = if compact then 96 else 112
	local size = Vector2.new(if compact then 270 else 300, disc)
	local s = state(scope, props)
	local look = P.look(scope, T)
	local r = root(props, size)
	local mirrored = props.Align == "Right"
	local ax = if mirrored then 1 else 0
	local portraitSize = disc - 44
	local portrait = props.Portrait or "person"
	local isImage = typeof(portrait) == "number" or (typeof(portrait) == "string" and (string.find(portrait, "://") ~= nil))
	local tailH = if compact then 44 else 50
	-- the name plate tail tucks under the portrait and runs to the far side
	local tailW = size.X - disc / 2 + 8
	local tail = P.plate(scope, T, {
		Name = "Tail",
		Radius = "Soft",
		AnchorPoint = Vector2.new(1 - ax, 0.5),
		Position = UDim2.new(1 - ax, 0, 0.5, 0),
		Size = UDim2.fromOffset(tailW, tailH),
		Edge = if mirrored then "Left" else "Right",
		ZIndex = 1,
	})
	local pulse = pulseWhile(scope, tail, s.low, 0.8)
	local captionW = if compact then 52 else 60
	local textX = if mirrored then captionW + 14 else disc + 8
	local textW = size.X - disc - 8 - captionW - 14
	local circle = scope:New "Frame" {
		Name = "Portrait",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(ax, 0.5),
		Position = UDim2.new(ax, 0, 0.5, 0),
		Size = UDim2.fromOffset(disc, disc),
		ZIndex = 3,
		[Children] = {
			P.plate(scope, T, { Radius = "Full", ZIndex = 3, Idle = true }),
			P.ticks(scope, {
				Name = "Arc",
				Size = disc - 6,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Count = if compact then 18 else 22,
				Start = 220,
				Span = 280,
				Length = if compact then 11 else 13,
				Width = if compact then 5 else 6,
				Fraction = s.fill,
				Ghost = s.ghost,
				Color = scope:Computed(function(use)
					local t = use(s.toneColors)
					local heal = math.clamp(use(s.heal), 0, 1)
					-- the saturated body of the tone reads on light plates too
					return t.Bottom:Lerp(t.Top, 0.3):Lerp(WHITE, (1 - heal) * 0.5)
				end),
				Track = scope:Computed(function(use)
					local l = use(look)
					return if l.light then l.ink else l.track
				end),
				TrackAlpha = 0.72,
				ZIndex = 5,
			}),
			scope:New "Frame" {
				Name = "Face",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.fromOffset(portraitSize, portraitSize),
				BackgroundColor3 = scope:Computed(function(use)
					local theme = use(Theme.current)
					return theme.Color.SurfaceLight:Lerp(theme.Color.Bad, use(pulse) * 0.45)
				end),
				BorderSizePixel = 0,
				ZIndex = 6,
				ClipsDescendants = true,
				[Children] = {
					scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
					scope:New "UIStroke" {
						Color = scope:Computed(function(use)
							return use(s.toneColors).Top
						end),
						Thickness = 2,
						Transparency = 0.2,
					},
					scope:New "UIGradient" {
						Rotation = 90,
						Color = ColorSequence.new(WHITE, Color3.fromRGB(150, 156, 176)),
					},
					if isImage
						then scope:New "ImageLabel" {
							Name = "Image",
							BackgroundTransparency = 1,
							Size = UDim2.fromScale(1, 1),
							Image = if typeof(portrait) == "number" then `rbxassetid://{portrait}` else portrait,
							ZIndex = 7,
						}
						else Icon(scope, {
							Name = "Image",
							Icon = portrait,
							Size = math.floor(portraitSize * 0.62),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.55),
							Color = scope:Computed(function(use)
								return use(Theme.current).Color.Text
							end),
							Stroke = false,
							ZIndex = 7,
						}),
				},
			},
		},
	}
	return finish(scope, props, r, size, {
		tail,
		circle,
		P.text(scope, T, look, {
			Name = "Amount",
			Role = "Number",
			Text = s.shown,
			TextSize = if compact then 30 else 36,
			XAlign = if mirrored then "Right" else "Left",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, textX, 0.5, -1),
			Size = UDim2.fromOffset(textW, tailH - 8),
			Scaled = true,
			Wrapped = false,
			MaxTextSize = if compact then 30 else 36,
			ZIndex = 4,
		}),
		P.text(scope, T, look, {
			Name = "Caption",
			Role = "Label",
			Text = scope:Computed(function(use)
				return if use(s.low) then "LOW" else `/ {Format.comma(math.ceil(use(s.max)))}`
			end),
			TextSize = if compact then 15 else 17,
			XAlign = if mirrored then "Left" else "Right",
			AnchorPoint = Vector2.new(if mirrored then 0 else 1, 0.5),
			Position = UDim2.new(if mirrored then 0 else 1, if mirrored then 12 else -14, 0.5, 2),
			Color = scope:Computed(function(use)
				local l = use(look)
				return if use(s.low) then use(Theme.current).Color.Bad else l.muted
			end),
			ZIndex = 4,
		}),
	})
end

-- A big number and a thin bar (Valorant).
function BUILD.Big(scope, props, T)
	local compact = props.Compact
	local size = Vector2.new(if compact then 330 else 370, if compact then 64 else 72)
	local s = state(scope, props)
	local look = P.look(scope, T)
	local r = root(props, size)
	local numberSize = if compact then 48 else 56
	local numberW = math.ceil(P.textWidth("100", numberSize, T.numberFont) + 6)
	local left = 14
	local iconSize = if compact then 24 else 28
	local barX = left + iconSize + 8 + numberW + 12
	return finish(scope, props, r, size, {
		P.plate(scope, T, { Radius = "Soft", Edge = "Left", Idle = true }),
		P.icon(scope, T, {
			Name = "HeartIcon",
			Icon = scope:Computed(function(use)
				return if use(s.low) then "warning" else props.Icon or "heart"
			end),
			Size = iconSize,
			Color = scope:Computed(function(use)
				local theme = use(Theme.current)
				return if use(s.low) then theme.Color.Bad else use(look).ink
			end),
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, left, 0.5, 0),
			ZIndex = 4,
		}),
		P.text(scope, T, look, {
			Name = "Amount",
			Role = "Number",
			Text = s.shown,
			TextSize = numberSize,
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, left + iconSize + 8, 0.5, 1),
			Size = UDim2.fromOffset(numberW, size.Y - 6),
			Scaled = true,
			Wrapped = false,
			MaxTextSize = numberSize,
			Color = scope:Computed(function(use)
				local theme = use(Theme.current)
				return if use(s.low) then theme.Color.Bad:Lerp(WHITE, 0.2) else use(look).ink
			end),
			ZIndex = 4,
		}),
		P.text(scope, T, look, {
			Name = "Caption",
			Role = "Label",
			Text = props.Label or "Health",
			TextSize = if compact then 14 else 16,
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, barX, 0.5, -2),
			ZIndex = 4,
		}),
		P.bar(scope, T, {
			Name = "Bar",
			Fraction = s.fraction,
			Tone = s.tone,
			Size = UDim2.fromOffset(size.X - barX - 16, if compact then 7 else 8),
			AnchorPoint = Vector2.new(0, 0),
			Position = UDim2.new(0, barX, 0.5, 5),
			Track = scope:Computed(function(use)
				return use(look).track
			end),
			TrackAlpha = 0.2,
			Radius = "Pill",
			Ghost = true,
			Heal = true,
			Low = s.lowAt,
			ZIndex = 4,
		}),
	})
end

-- Hearts (or pips): full, half and empty, the last one beating when low.
function BUILD.Hearts(scope, props, T)
	local compact = props.Compact
	local s = state(scope, props)
	local look = P.look(scope, T)
	local count = props.Hearts or math.clamp(math.floor((peek(s.max) or 100) / 10 + 0.5), 3, 10)
	local heart = if compact then 30 else 36
	local gap = if compact then 3 else 4
	local pad = 12
	local size = Vector2.new(count * heart + (count - 1) * gap + pad * 2, heart + 20)
	local r = root(props, size)
	local host = scope:New "Frame" {
		Name = "Hearts",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0, 0.5),
		Position = UDim2.new(0, pad, 0.5, 0),
		Size = UDim2.fromOffset(size.X - pad * 2, heart),
		ZIndex = 3,
	}
	local beat = pulseWhile(scope, host, s.low, 0.55)
	local red = scope:Computed(function(use)
		return use(Theme.current).Tone.Red
	end)
	-- the number of lit hearts in halves (so they show 1, 1/2 or 0)
	local halves = scope:Computed(function(use)
		return math.floor(use(s.fraction) * count * 2 + 0.5)
	end)
	local lastLit = scope:Computed(function(use)
		return math.ceil(use(halves) / 2)
	end)
	for i = 1, count do
		local amount = scope:Computed(function(use)
			return math.clamp(use(halves) - (i - 1) * 2, 0, 2) / 2
		end)
		local popGoal = scope:Value(1)
		local pop = scope:Spring(popGoal, 30, T.bounce or 0.4)
		local before = peek(amount)
		Kit.watch(scope, amount, function(now)
			if now < before then
				pop:setPosition(0.7)
			elseif now > before then
				pop:setPosition(1.3)
			end
			before = now
		end)
		scope:New "Frame" {
			Name = `Heart{i}`,
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset((i - 1) * (heart + gap), 0),
			Size = UDim2.fromOffset(heart, heart),
			ZIndex = 3,
			Parent = host,
			[Children] = {
				scope:New "UIScale" {
					Scale = scope:Computed(function(use)
						local b = if i == use(lastLit) then use(beat) * 0.18 else 0
						return use(pop) + b
					end),
				},
				Icon(scope, {
					Name = "Empty",
					Icon = "heart",
					Size = heart,
					Filled = true,
					Color = scope:Computed(function(use)
						local l = use(look)
						return if l.light then l.track else use(Theme.current).Color.Text
					end),
					Transparency = scope:Computed(function(use)
						return if use(look).light then 0 else 0.74
					end),
					Stroke = if T.plate == "Pixel" or T.plate == "Clear" then 2 else false,
					StrokeColor = scope:Computed(function(use)
						return use(Theme.current).Color.Outline
					end),
					ZIndex = 3,
				}),
				scope:New "Frame" {
					Name = "Clip",
					BackgroundTransparency = 1,
					ClipsDescendants = true,
					Size = scope:Computed(function(use)
						return UDim2.new(use(amount), 0, 1, 0)
					end),
					Visible = scope:Computed(function(use)
						return use(amount) > 0
					end),
					ZIndex = 4,
					[Children] = P.icon(scope, T, {
						Name = "Full",
						Icon = "heart",
						Size = heart,
						Color = scope:Computed(function(use)
							return use(red).Top
						end),
						Treatment = if T.icon == "Glow" or T.icon == "Badge" then "Gradient" else T.icon,
						Stroke = if T.plate == "Pixel" or T.plate == "Clear" or T.plate == "Toy" then 2 else nil,
						StrokeColor = scope:Computed(function(use)
							return use(red).Stroke or use(red).Edge
						end),
						ZIndex = 4,
					}),
				},
			},
		}
	end
	return finish(scope, props, r, size, {
		P.plate(scope, T, { Radius = "Soft", Idle = true }),
		host,
	})
end

-- ECG waveform: two beats, x 0..1, y -1..1 (up)
local ECG = {
	{ 0, 0 }, { 0.08, 0 }, { 0.11, 0.14 }, { 0.14, 0 }, { 0.18, 0 }, { 0.2, -0.18 }, { 0.23, 1 },
	{ 0.26, -0.5 }, { 0.29, 0 }, { 0.35, 0 }, { 0.39, 0.24 }, { 0.43, 0 },
	{ 0.58, 0 }, { 0.61, 0.14 }, { 0.64, 0 }, { 0.68, 0 }, { 0.7, -0.18 }, { 0.73, 1 },
	{ 0.76, -0.5 }, { 0.79, 0 }, { 0.85, 0 }, { 0.89, 0.24 }, { 0.93, 0 }, { 1, 0 },
}

-- A heart monitor: the trace flattens as health drops, a bright sweep runs
-- along it (faster when low) and the number sits beside a beating heart.
function BUILD.Pulse(scope, props, T)
	local compact = props.Compact
	local size = Vector2.new(if compact then 340 else 390, if compact then 60 else 68)
	local s = state(scope, props)
	local look = P.look(scope, T)
	local r = root(props, size)
	local traceW = if compact then 196 else 228
	local traceH = size.Y - 22
	local left = 14
	local thick = 2.5
	local amplitude = scope:Computed(function(use)
		local f = use(s.fill)
		return if f <= 0.001 then 0 else 0.3 + 0.7 * f
	end)
	local lineColor = scope:Computed(function(use)
		return use(s.toneColors).Top
	end)
	local trace = scope:New "Frame" {
		Name = "Trace",
		BackgroundTransparency = 1,
		ClipsDescendants = true,
		AnchorPoint = Vector2.new(0, 0.5),
		Position = UDim2.new(0, left, 0.5, 0),
		Size = UDim2.fromOffset(traceW, traceH),
		ZIndex = 3,
	}
	-- a faint baseline grid
	for i = 1, 3 do
		scope:New "Frame" {
			Name = "Grid",
			BackgroundColor3 = lineColor,
			BackgroundTransparency = 0.9,
			BorderSizePixel = 0,
			Position = UDim2.fromScale(i / 4, 0),
			Size = UDim2.new(0, 1, 1, 0),
			ZIndex = 3,
			Parent = trace,
		}
	end
	for index = 1, #ECG - 1 do
		local a, b = ECG[index], ECG[index + 1]
		local geometry = scope:Computed(function(use)
			local amp = use(amplitude)
			local x1, x2 = a[1] * traceW, b[1] * traceW
			local y1 = traceH / 2 - a[2] * amp * (traceH / 2 - 3)
			local y2 = traceH / 2 - b[2] * amp * (traceH / 2 - 3)
			local dx, dy = x2 - x1, y2 - y1
			return {
				center = Vector2.new((x1 + x2) / 2, (y1 + y2) / 2),
				length = math.sqrt(dx * dx + dy * dy) + thick * 0.6,
				angle = math.deg(math.atan2(dy, dx)),
			}
		end)
		scope:New "Frame" {
			Name = "Segment",
			AnchorPoint = Vector2.new(0.5, 0.5),
			BackgroundColor3 = lineColor,
			BorderSizePixel = 0,
			Position = scope:Computed(function(use)
				local g = use(geometry)
				return UDim2.fromOffset(g.center.X, g.center.Y)
			end),
			Size = scope:Computed(function(use)
				return UDim2.fromOffset(use(geometry).length, thick)
			end),
			Rotation = scope:Computed(function(use)
				return use(geometry).angle
			end),
			ZIndex = 4,
			Parent = trace,
			[Children] = scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
		}
	end
	-- the sweep: a bright head running along the trace
	local sweep = scope:New "Frame" {
		Name = "Sweep",
		BackgroundColor3 = lineColor,
		BorderSizePixel = 0,
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.fromScale(0, 0),
		Size = UDim2.new(0, 34, 1, 0),
		ZIndex = 5,
		Parent = trace,
		[Children] = scope:New "UIGradient" {
			Transparency = NumberSequence.new({
				NumberSequenceKeypoint.new(0, 1),
				NumberSequenceKeypoint.new(0.85, 0.65),
				NumberSequenceKeypoint.new(1, 0.2),
			}),
		},
	}
	local stopSweep: (() -> ())? = nil
	local function runSweep(fast: boolean)
		if stopSweep then
			stopSweep()
		end
		sweep.Position = UDim2.fromScale(0, 0)
		local tween = TweenService:Create(
			sweep,
			TweenInfo.new(if fast then 0.95 else 1.9, Enum.EasingStyle.Linear, Enum.EasingDirection.In, -1),
			{ Position = UDim2.new(1, 34, 0, 0) }
		)
		tween:Play()
		local untrack = Motion.track(tween, sweep, function()
			sweep.Position = UDim2.new(0.6, 0, 0, 0)
		end)
		stopSweep = function()
			untrack()
			tween:Cancel()
		end
	end
	runSweep(peek(s.low))
	Kit.watch(scope, s.low, runSweep)
	table.insert(scope, function()
		if stopSweep then
			stopSweep()
		end
	end)
	local beat = pulseWhile(scope, trace, scope:Computed(function(use)
		return use(s.fraction) > 0
	end), scope:Computed(function(use)
		return if use(s.low) then 0.48 else 0.95
	end))
	local readX = left + traceW + 12
	return finish(scope, props, r, size, {
		P.plate(scope, T, { Radius = "Soft", Edge = "Left", Idle = true }),
		trace,
		P.icon(scope, T, {
			Name = "HeartIcon",
			Icon = scope:Computed(function(use)
				return if use(s.low) then "warning" else props.Icon or "heart"
			end),
			Size = if compact then 20 else 22,
			Color = lineColor,
			Treatment = "Flat",
			AnchorPoint = Vector2.new(0, 0),
			Position = UDim2.fromOffset(readX, 8),
			ZIndex = 4,
			[Children] = scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					return 1 + use(beat) * 0.22
				end),
			},
		}),
		P.text(scope, T, look, {
			Name = "Caption",
			Role = "Label",
			Text = props.Label or "HP",
			TextSize = if compact then 13 else 14,
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 0),
			Position = UDim2.fromOffset(readX + (if compact then 24 else 27), 11),
			ZIndex = 4,
		}),
		P.text(scope, T, look, {
			Name = "Amount",
			Role = "Number",
			Text = s.shown,
			TextSize = if compact then 28 else 32,
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, readX, 1, -6),
			Size = UDim2.fromOffset(size.X - readX - 10, if compact then 30 else 34),
			Scaled = true,
			Wrapped = false,
			MaxTextSize = if compact then 28 else 32,
			Color = lineColor,
			ZIndex = 4,
		}),
	})
end

return function(scope: Scope, props: { [any]: any }): Frame
	local style = P.style("HealthBar", props.Style)
	local T = P.tokens(props.Kit)
	return BUILD[style](scope, props, T)
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="106">
            <Properties>
              <string name="Name">HudKits</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	HudKits: named HUD looks, so every generated game's HUD looks like a
	different game. A kit picks one visual style per HUD element and a set of
	shared details (backing plates, outline weight, corner treatment, number
	typography, icon treatment, idle motion, entrance). Colours always come
	from the active theme; the shapes and details come from the kit, so all
	9 kits work with all 8 themes (and the colour-blind palettes).

		local HudKits = require(BloxUI.Components.HudKits)

		HudKits.names                      --> { "Competitive", "Arcade", "Glass", ... }
		HudKits.get("Neon")                --> { name, label, about, styles = {...}, tokens = {...} }
		HudKits.styles.HealthBar           --> { "Bar", "Segments", "Arc", "Big", "Hearts", "Pulse" }
		HudKits.style("Neon", "Timer")     --> "Ring"

		-- props for a HUD component: the kit's style (unless props.Style is set)
		-- and its tokens (props.Kit)
		scope:HudHealth(HudKits.apply({ Value = hp, Max = 100 }, "Neon", "HealthBar"))

		HudKits.sizeOf(gui)                --> Vector2: a built HUD element's footprint (design px)
		HudKits.enter(scope, gui, { From = "Left", Delay = 0.12, Kit = "Arcade" })
		                                   --> a wrapper that slides/pops the element in on spawn
		HudKits.pick("fps", seed)          --> a kit that suits the genre, varied by seed

	Element types: CurrencyBar, HealthBar, XPBar, Hotbar, Timer, Objective,
	QuestTracker, Boosts, KillFeed, MenuButtons, Minimap, TeamScore,
	KeyHints, Clicker, Crosshair. Every element's first style is its
	original look, so leaving Style (and Kit) out changes nothing.
]]

-- Pure data up to HudKits.enter, which loads Fusion and FX.Motion the first
-- time it runs: Blueprint.Schema / Normalize read the kit lists without
-- pulling in any UI code.
local HudKits = {}

-- Style names per element. The first one is the original (default) look.
HudKits.styles = {
	CurrencyBar = { "Pill", "Chips", "Banner", "Wallet", "Tiles", "Readout" },
	HealthBar = { "Bar", "Segments", "Arc", "Big", "Hearts", "Pulse" },
	XPBar = { "Bar", "Thin", "Ring", "Chunky" },
	Hotbar = { "Slots", "Cards", "List", "Stack", "Round", "Tactical" },
	Timer = { "Pill", "Big", "Ring", "Flip", "Stopwatch" },
	Objective = { "Panel", "Card", "Ticker", "Checklist", "Waypoint" },
	QuestTracker = { "Panel", "Card", "Ticker", "Checklist", "Waypoint" },
	Boosts = { "Pills", "Radial", "Badges" },
	KillFeed = { "Rows", "Compact", "Callout" },
	MenuButtons = { "Column", "Grid", "Dock", "Bubbles", "TabBar", "Drawer", "Tiles" },
	Minimap = { "Square", "Compass", "Card" },
	TeamScore = { "Blocks", "TugOfWar", "Line" },
	KeyHints = { "Strip", "Prompts", "Sheet" },
	Clicker = { "Button", "Blob", "Orb", "Capsule" },
	Crosshair = { "Cross", "Dot", "Circle", "T", "Chevron", "Brackets" },
}

HudKits.elements = {
	"CurrencyBar", "HealthBar", "XPBar", "Hotbar", "Timer", "Objective", "QuestTracker", "Boosts",
	"KillFeed", "MenuButtons", "Minimap", "TeamScore", "KeyHints", "Clicker", "Crosshair",
}

-- The component that draws each element type (all in Components/).
HudKits.components = {
	CurrencyBar = "HudCurrencies",
	HealthBar = "HudHealth",
	XPBar = "HudXP",
	Hotbar = "Hotbar",
	Timer = "Timer",
	Objective = "HudObjective",
	QuestTracker = "QuestTracker",
	Boosts = "HudBoosts",
	KillFeed = "KillFeed",
	MenuButtons = "HudMenu",
	Minimap = "Minimap",
	TeamScore = "TeamScore",
	KeyHints = "KeyHints",
	Clicker = "Clicker",
	Crosshair = "Crosshair",
}

--[[
	Tokens: the details a kit shares across every element.

	  plate       backing plate: Glass (frosted) | Solid (flat dark) | Clear (none,
	              outlined text) | Toy (cream, thick outline, 3D lip) | Arcade
	              (deep colour face, bold outline, hard shadow) | Neon (dark, glowing
	              rim) | Tactical (dark, corner brackets) | Paper (parchment, double
	              rule) | Pixel (notched border, scanlines) | Fade (RIVALS edge fade)
	  corner      Square | Sharp | Theme | Soft | Round | Pill  (relative to the theme's radii)
	  outline     plate outline width in px (0 = the plate's own)
	  numberFont  theme font slot ("Display", "Mono") or "Family:Weight"
	  labelFont   the same, for captions
	  case        "Upper" | "Normal" | nil (theme)
	  icon        Gradient | Flat | Glow | Badge (icon on a tinted disc)
	  idle        None | Shine | Breathe | Bob | Scan  (subtle, paused while hidden,
	              off with reduced motion)
	  entrance    Slide | Pop | Drop | Fade | None
	  stagger     seconds between elements entering
	  shadow      Soft | Hard | None
	  accent      None | Bar | Brackets | Glow | Trim
	  gloss       glossy highlights on fills
	  bounce      damping of pops (lower = bouncier)
	  tilt        degrees some badges lean (Arcade)
	  tone        tone for coloured plates (Arcade)
]]
local DEFAULT_TOKENS = {
	name = "Classic",
	plate = "Glass",
	corner = "Theme",
	outline = 0,
	numberFont = "Display",
	labelFont = "Heavy",
	case = nil,
	icon = "Gradient",
	idle = "None",
	entrance = "Slide",
	stagger = 0.06,
	shadow = "Soft",
	accent = "None",
	gloss = true,
	bounce = 0.55,
	tilt = 0,
	tone = "Blue",
}
HudKits.DEFAULT_TOKENS = DEFAULT_TOKENS

local KITS: { [string]: { [string]: any } } = {
	Competitive = {
		about = "RIVALS / Valorant: flat dark plates, thin accent bars, big clean numbers, no bounce",
		genres = { "fps", "shooter", "arena", "pvp", "battle", "combat", "racing" },
		styles = {
			CurrencyBar = "Chips", HealthBar = "Bar", XPBar = "Thin", Hotbar = "Slots", Timer = "Big",
			Objective = "Ticker", QuestTracker = "Panel", Boosts = "Radial", KillFeed = "Rows",
			MenuButtons = "Column", Minimap = "Square", TeamScore = "Line", KeyHints = "Strip",
			Clicker = "Button", Crosshair = "Cross",
		},
		tokens = {
			plate = "Solid", corner = "Sharp", numberFont = "Display", labelFont = "Heavy", case = "Upper",
			icon = "Flat", idle = "None", entrance = "Slide", stagger = 0.05, shadow = "None", accent = "Bar",
			gloss = false, bounce = 0.8,
		},
	},
	Arcade = {
		about = "Arsenal / Fortnite party: deep coloured plates, bold outlines, hard shadows, bouncy pops",
		genres = { "party", "arcade", "minigame", "fps", "shooter", "obby", "battle" },
		styles = {
			CurrencyBar = "Tiles", HealthBar = "Segments", XPBar = "Chunky", Hotbar = "Round", Timer = "Flip",
			Objective = "Waypoint", QuestTracker = "Checklist", Boosts = "Badges", KillFeed = "Callout",
			MenuButtons = "Bubbles", Minimap = "Compass", TeamScore = "TugOfWar", KeyHints = "Prompts",
			Clicker = "Blob", Crosshair = "Chevron",
		},
		tokens = {
			plate = "Arcade", corner = "Theme", outline = 3, numberFont = "LuckiestGuy:400", labelFont = "LuckiestGuy:400",
			case = "Upper", icon = "Gradient", idle = "Bob", entrance = "Pop", stagger = 0.07, shadow = "Hard",
			accent = "None", gloss = true, bounce = 0.32, tilt = -3, tone = "Blue",
		},
	},
	Glass = {
		about = "Frosted translucent panels, soft light rims, rings instead of bars, a slow shine",
		genres = { "social", "roleplay", "life", "hangout", "tycoon", "racing", "simulator" },
		styles = {
			CurrencyBar = "Pill", HealthBar = "Bar", XPBar = "Ring", Hotbar = "Cards", Timer = "Ring",
			Objective = "Card", QuestTracker = "Card", Boosts = "Radial", KillFeed = "Compact",
			MenuButtons = "Drawer", Minimap = "Card", TeamScore = "Blocks", KeyHints = "Strip",
			Clicker = "Orb", Crosshair = "Circle",
		},
		tokens = {
			plate = "Glass", corner = "Round", numberFont = "Display", labelFont = "Heavy",
			icon = "Gradient", idle = "Shine", entrance = "Slide", stagger = 0.06, shadow = "Soft",
			accent = "None", gloss = true, bounce = 0.6,
		},
	},
	["Chunky Toy"] = {
		label = "Chunky Toy",
		about = "Pet Simulator 99 / Adopt Me: cream plates, thick outlines, big overlapping icons, rounded type",
		genres = { "simulator", "pet", "clicker", "tycoon", "roleplay", "life", "obby", "farm" },
		styles = {
			CurrencyBar = "Pill", HealthBar = "Hearts", XPBar = "Chunky", Hotbar = "Round", Timer = "Pill",
			Objective = "Card", QuestTracker = "Card", Boosts = "Badges", KillFeed = "Compact",
			MenuButtons = "Tiles", Minimap = "Compass", TeamScore = "Blocks", KeyHints = "Prompts",
			Clicker = "Blob", Crosshair = "Dot",
		},
		tokens = {
			plate = "Toy", corner = "Round", outline = 3, numberFont = "FredokaOne:400", labelFont = "FredokaOne:400",
			icon = "Gradient", idle = "Bob", entrance = "Pop", stagger = 0.07, shadow = "Soft",
			accent = "None", gloss = true, bounce = 0.4,
		},
	},
	Minimal = {
		about = "Almost no plates: outlined text, hairline bars, small icons, quiet fades",
		genres = { "horror", "story", "puzzle", "survival", "exploration", "obby" },
		styles = {
			CurrencyBar = "Readout", HealthBar = "Big", XPBar = "Thin", Hotbar = "List", Timer = "Stopwatch",
			Objective = "Ticker", QuestTracker = "Ticker", Boosts = "Radial", KillFeed = "Compact",
			MenuButtons = "Bubbles", Minimap = "Square", TeamScore = "Line", KeyHints = "Strip",
			Clicker = "Orb", Crosshair = "Dot",
		},
		tokens = {
			plate = "Clear", corner = "Theme", numberFont = "Display", labelFont = "Small",
			icon = "Flat", idle = "None", entrance = "Fade", stagger = 0.08, shadow = "None",
			accent = "None", gloss = false, bounce = 0.9,
		},
	},
	Neon = {
		about = "Synthwave: near-black plates, glowing rims in the theme accent, breathing light",
		genres = { "scifi", "cyber", "space", "racing", "rhythm", "arena", "shooter" },
		styles = {
			CurrencyBar = "Chips", HealthBar = "Segments", XPBar = "Ring", Hotbar = "Cards", Timer = "Ring",
			Objective = "Card", QuestTracker = "Waypoint", Boosts = "Radial", KillFeed = "Callout",
			MenuButtons = "Bubbles", Minimap = "Compass", TeamScore = "TugOfWar", KeyHints = "Prompts",
			Clicker = "Orb", Crosshair = "Circle",
		},
		tokens = {
			plate = "Neon", corner = "Theme", numberFont = "Sarpanch:800", labelFont = "Sarpanch:700", case = "Upper",
			icon = "Glow", idle = "Breathe", entrance = "Fade", stagger = 0.06, shadow = "None",
			accent = "Glow", gloss = false, bounce = 0.6,
		},
	},
	Tactical = {
		about = "Military sim: dark plates with corner brackets, mono numbers, scan lines, a heart monitor",
		genres = { "military", "tactical", "war", "survival", "shooter", "fps", "zombie" },
		styles = {
			CurrencyBar = "Readout", HealthBar = "Pulse", XPBar = "Thin", Hotbar = "Tactical", Timer = "Stopwatch",
			Objective = "Waypoint", QuestTracker = "Checklist", Boosts = "Badges", KillFeed = "Rows",
			MenuButtons = "Column", Minimap = "Card", TeamScore = "Line", KeyHints = "Sheet",
			Clicker = "Capsule", Crosshair = "Brackets",
		},
		tokens = {
			plate = "Tactical", corner = "Square", numberFont = "Mono", labelFont = "Mono", case = "Upper",
			icon = "Flat", idle = "Scan", entrance = "Slide", stagger = 0.05, shadow = "None",
			accent = "Brackets", gloss = false, bounce = 0.85,
		},
	},
	Storybook = {
		label = "Storybook",
		about = "RPG parchment: paper plates with a double rule, serif numbers, a health arc round the portrait",
		genres = { "rpg", "adventure", "fantasy", "quest", "story", "medieval", "farm" },
		styles = {
			CurrencyBar = "Wallet", HealthBar = "Arc", XPBar = "Chunky", Hotbar = "Cards", Timer = "Flip",
			Objective = "Checklist", QuestTracker = "Checklist", Boosts = "Badges", KillFeed = "Rows",
			MenuButtons = "Drawer", Minimap = "Compass", TeamScore = "Blocks", KeyHints = "Sheet",
			Clicker = "Button", Crosshair = "Dot",
		},
		tokens = {
			plate = "Paper", corner = "Soft", numberFont = "Merriweather:900", labelFont = "Merriweather:700",
			icon = "Gradient", idle = "None", entrance = "Drop", stagger = 0.08, shadow = "Soft",
			accent = "Trim", gloss = false, bounce = 0.7,
		},
	},
	Retro = {
		about = "8-bit: notched pixel frames, pixel type, scan lines, hard shadows, heart pips",
		genres = { "retro", "arcade", "platformer", "obby", "minigame", "party", "dungeon" },
		styles = {
			CurrencyBar = "Banner", HealthBar = "Hearts", XPBar = "Chunky", Hotbar = "Stack", Timer = "Flip",
			Objective = "Ticker", QuestTracker = "Checklist", Boosts = "Pills", KillFeed = "Compact",
			MenuButtons = "TabBar", Minimap = "Square", TeamScore = "Line", KeyHints = "Strip",
			Clicker = "Capsule", Crosshair = "T",
		},
		tokens = {
			plate = "Pixel", corner = "Square", numberFont = "PressStart2P:400", labelFont = "PressStart2P:400",
			case = "Upper", icon = "Flat", idle = "None", entrance = "Drop", stagger = 0.06, shadow = "Hard",
			accent = "None", gloss = false, bounce = 1,
		},
	},
}

-- The kit names, exactly as blueprints (hud.kit) and generators write them.
HudKits.names = { "Competitive", "Arcade", "Glass", "Chunky Toy", "Minimal", "Neon", "Tactical", "Storybook", "Retro" }

local resolved: { [string]: { [string]: any } } = {}

-- Spellings people (and AI generators) use for a kit, squashed (lower case,
-- letters and digits only) -> the kit's name.
local ALIASES = {
	toy = "Chunky Toy",
	chunky = "Chunky Toy",
	chunkytoy = "Chunky Toy",
	competitive = "Competitive",
	esports = "Competitive",
	clean = "Competitive",
	arcade = "Arcade",
	party = "Arcade",
	glass = "Glass",
	frosted = "Glass",
	minimal = "Minimal",
	minimalist = "Minimal",
	neon = "Neon",
	synthwave = "Neon",
	tactical = "Tactical",
	military = "Tactical",
	storybook = "Storybook",
	story = "Storybook",
	paper = "Storybook",
	parchment = "Storybook",
	retro = "Retro",
	pixel = "Retro",
	eightbit = "Retro",
	["8bit"] = "Retro",
}

local function squash(text: string): string
	return (string.gsub(string.lower(text), "[^%w]", ""))
end

-- The kit's exact name for any spelling ("chunky toy", "CHUNKY_TOY", "toy",
-- "Pixel"...), or nil when it isn't a kit.
function HudKits.canonical(name: any): string?
	if typeof(name) ~= "string" then
		return nil
	end
	if KITS[name] then
		return name
	end
	local key = squash(name)
	for kitName in KITS do
		if squash(kitName) == key then
			return kitName
		end
	end
	return ALIASES[key]
end

-- A complete kit: { name, label, about, genres, styles, tokens }. Any
-- spelling HudKits.canonical knows works; unknown names (and nil) give the
-- "Classic" kit: every element's original look.
function HudKits.get(name: any): { [string]: any }
	if typeof(name) == "table" and name.styles and name.tokens then
		return name
	end
	local key = HudKits.canonical(name) or "Classic"
	local cached = resolved[key]
	if cached then
		return cached
	end
	local def = KITS[key]
	local tokens = table.clone(DEFAULT_TOKENS)
	local styles = {}
	for element, list in HudKits.styles do
		styles[element] = list[1]
	end
	if def then
		for k, v in def.tokens do
			tokens[k] = v
		end
		for element, style in def.styles do
			styles[element] = style
		end
	end
	tokens.name = if def then key else "Classic"
	local kit = {
		name = tokens.name,
		label = if def then def.label or key else "Classic",
		about = if def then def.about else "Every element's original look",
		genres = if def then def.genres else {},
		styles = styles,
		tokens = table.freeze(tokens),
	}
	resolved[key] = kit
	return kit
end

-- True for a known kit (any spelling HudKits.canonical knows).
function HudKits.has(name: any): boolean
	return HudKits.canonical(name) ~= nil
end

-- The kit's style for an element ("Bar", "Ring"...).
function HudKits.style(kit: any, elementType: string): string?
	local list = HudKits.styles[elementType]
	if list == nil then
		return nil
	end
	return HudKits.get(kit).styles[elementType] or list[1]
end

-- The active v2.4 style family's id and text case, or nil (Theme loaded
-- lazily: Schema / Normalize read this module's data without any UI code).
local function activeFamily(): (string?, string?)
	local ok, theme = pcall(function()
		return require(script.Parent.Parent.Theme).get()
	end)
	if ok and typeof(theme) == "table" and theme.Family then
		return theme.Family.Id, theme.Style and theme.Style.TextCase
	end
	return nil, nil
end

-- The resolved tokens of a kit (a kit name, kit, tokens table or nil). A
-- kit named while a style family is active gets the family's finish.
function HudKits.tokens(kit: any): { [string]: any }
	if typeof(kit) == "table" and kit.plate ~= nil and kit.styles == nil then
		return kit
	end
	local tokens = HudKits.get(kit).tokens
	if kit ~= nil then
		local family, case = activeFamily()
		if family then
			return HudKits.familyTokens(tokens, family, case)
		end
	end
	return tokens
end

-- True when `style` is one of the element's styles.
function HudKits.valid(elementType: string, style: any): boolean
	local list = HudKits.styles[elementType]
	return list ~= nil and typeof(style) == "string" and table.find(list, style) ~= nil
end

--[[
	v2.4 style families: the kit keeps its element looks (so HUDs stay
	varied) but takes the family's finish: plate, corners, outline, icon
	treatment, idle motion, entrance, shadow, and the family's type roles
	instead of hard-coded faces (no more kit font clashes).
]]
HudKits.FAMILY_TOKENS = {
	Toybox = { plate = "Toy", corner = "Round", outline = 3, icon = "Gradient", idle = "Bob", entrance = "Pop", shadow = "Hard", accent = "None", gloss = true, bounce = 0.4, tilt = 0 },
	Mochi = { plate = "Toy", corner = "Round", outline = 3, icon = "Gradient", idle = "Bob", entrance = "Pop", shadow = "Hard", accent = "None", gloss = true, bounce = 0.4, tilt = 0 },
	Clay = { plate = "Toy", corner = "Round", outline = 3, icon = "Gradient", idle = "None", entrance = "Pop", shadow = "Soft", accent = "None", gloss = false, bounce = 0.5, tilt = 0 },
	Sketch = { plate = "Paper", corner = "Theme", icon = "Flat", idle = "None", entrance = "Drop", shadow = "Hard", accent = "Trim", gloss = false, bounce = 0.6, tilt = 0 },
	Tavern = { plate = "Paper", corner = "Soft", icon = "Gradient", idle = "None", entrance = "Drop", shadow = "Soft", accent = "Trim", gloss = false, bounce = 0.7, tilt = 0 },
	Royal = { plate = "Solid", corner = "Theme", icon = "Gradient", idle = "Shine", entrance = "Fade", shadow = "Soft", accent = "Trim", gloss = false, bounce = 0.85, tilt = 0 },
	Arcade = { plate = "Pixel", corner = "Square", icon = "Flat", idle = "None", entrance = "Drop", shadow = "Hard", accent = "None", gloss = false, bounce = 1, tilt = 0 },
	Comic = { plate = "Arcade", corner = "Theme", outline = 4, icon = "Gradient", idle = "None", entrance = "Pop", shadow = "Hard", accent = "None", gloss = false, bounce = 0.45, tilt = -2 },
	Dread = { plate = "Clear", corner = "Theme", icon = "Flat", idle = "None", entrance = "Fade", shadow = "None", accent = "None", gloss = false, bounce = 1, tilt = 0 },
	Holo = { plate = "Tactical", corner = "Sharp", icon = "Flat", idle = "Scan", entrance = "Slide", shadow = "None", accent = "Brackets", gloss = false, bounce = 0.9, tilt = 0 },
	Neon = { plate = "Neon", corner = "Theme", icon = "Glow", idle = "Breathe", entrance = "Fade", shadow = "None", accent = "Glow", gloss = false, bounce = 0.8, tilt = 0 },
	Gacha = { plate = "Glass", corner = "Round", icon = "Gradient", idle = "Shine", entrance = "Slide", shadow = "Soft", accent = "None", gloss = true, bounce = 0.8, tilt = 0 },
	Broadcast = { plate = "Solid", corner = "Sharp", icon = "Flat", idle = "None", entrance = "Slide", shadow = "None", accent = "Bar", gloss = false, bounce = 1, tilt = 0 },
	Studio = { plate = "Glass", corner = "Theme", icon = "Flat", idle = "None", entrance = "Fade", shadow = "Soft", accent = "None", gloss = false, bounce = 1, tilt = 0 },
}

local familyCache: { [string]: { [string]: any } } = {}

-- The kit's tokens dressed for a style family (nil family: the kit's own).
function HudKits.familyTokens(tokens: { [string]: any }, family: string?, textCase: string?): { [string]: any }
	local overlay = family and HudKits.FAMILY_TOKENS[family]
	if overlay == nil then
		return tokens
	end
	local key = `{tokens.name or "?"}|{family}|{textCase or ""}`
	local cached = familyCache[key]
	if cached then
		return cached
	end
	local out = table.clone(tokens)
	for k, v in overlay do
		out[k] = v
	end
	out.numberFont = "Number"
	out.labelFont = "Label"
	out.case = if textCase == "Upper" then "Upper" else nil
	out.family = family
	out = table.freeze(out)
	familyCache[key] = out
	return out
end

--[[
	Props for a HUD element in a kit: a copy of `props` with Style set to the
	kit's style for `elementType` (unless props.Style already names one of
	that element's styles) and Kit set to the kit's tokens (unless given).
	`kit` nil leaves the element in its original look. Under a v2.4 style
	family the tokens take the family's finish (HudKits.familyTokens).
]]
function HudKits.apply(props: { [any]: any }?, kit: any, elementType: string): { [any]: any }
	local out = table.clone(props or {})
	if kit == nil then
		return out
	end
	local k = HudKits.get(kit)
	if not HudKits.valid(elementType, out.Style) then
		out.Style = k.styles[elementType] or out.Style
	end
	if out.Kit == nil then
		out.Kit = HudKits.tokens(k)
	end
	return out
end

-- A kit that suits a genre word ("fps", "simulator", "horror"...), varied by
-- `seed` so two games of the same genre don't share a HUD.
function HudKits.pick(genre: string?, seed: number?): string
	local word = string.lower(genre or "")
	local fits = {}
	for _, name in HudKits.names do
		for _, g in KITS[name].genres do
			if word ~= "" and (string.find(word, g, 1, true) or string.find(g, word, 1, true)) then
				table.insert(fits, name)
				break
			end
		end
	end
	if #fits == 0 then
		fits = HudKits.names
	end
	local n = math.floor(math.abs(seed or 0))
	return fits[(n % #fits) + 1]
end

-- Genre -> kits that suit it (best first).
function HudKits.forGenre(genre: string?): { string }
	local word = string.lower(genre or "")
	local out = {}
	for _, name in HudKits.names do
		for _, g in KITS[name].genres do
			if word ~= "" and (string.find(word, g, 1, true) or string.find(g, word, 1, true)) then
				table.insert(out, name)
				break
			end
		end
	end
	return out
end

-- The footprint (design px) of a built HUD element: its HudSize attribute,
-- else its offset Size.
function HudKits.sizeOf(gui: Instance): Vector2
	local size = gui:GetAttribute("HudSize")
	if typeof(size) == "Vector2" then
		return size
	end
	local s = (gui :: any).Size
	if typeof(s) == "UDim2" then
		return Vector2.new(s.X.Offset, s.Y.Offset)
	end
	return Vector2.zero
end

--[[
	Entrance: wraps a built HUD element (a fixed-size frame) so it arrives
	from its screen edge when it spawns. Returns the wrapper, the same size as
	`gui`, to put where `gui` would have gone.

		opts.From    "Left" | "Right" | "Top" | "Bottom" | "Center" (the edge it comes from)
		opts.Delay   seconds before it starts (stagger by element order)
		opts.Kit     kit name / tokens: the entrance kind (Slide, Pop, Drop, Fade, None)
		opts.Pin     Vector2 anchor (e.g. (1, 1)): pins the element to that corner of
		             the wrapper, for elements that size themselves
		opts.Size    Vector2 footprint (default HudKits.sizeOf(gui))

	Reduced motion: it simply appears.
]]
function HudKits.enter(scope: any, gui: GuiObject, opts: { [string]: any }?): Frame
	local Fusion = require(script.Parent.Parent.Packages.Fusion)
	local Motion = require(script.Parent.Parent.FX.Motion)
	local Children = Fusion.Children
	local o = opts or {}
	local tokens = HudKits.tokens(o.Kit)
	local kind = o.Kind or tokens.entrance or "Slide"
	local size = if typeof(o.Size) == "Vector2" then o.Size else HudKits.sizeOf(gui)
	local from = o.From or "Left"
	local delay = o.Delay or 0

	local wrapper = scope:New "Frame" {
		Name = gui.Name,
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(size.X, size.Y),
		LayoutOrder = gui.LayoutOrder,
	}
	wrapper:SetAttribute("HudSize", size)
	local calm = Motion.reduced or kind == "None"

	local progress = scope:Value(if calm then 1 else 0)
	local damping = if kind == "Pop" then math.clamp(tokens.bounce or 0.55, 0.3, 1) elseif kind == "Drop" then 0.72 else 0.9
	local speed = if kind == "Pop" then 22 elseif kind == "Fade" then 14 else 20
	local t = scope:Spring(progress, speed, damping)
	if calm then
		t:setPosition(1)
	end

	local reach = if kind == "Fade" then 18 else 64
	local dir = if from == "Left" then Vector2.new(-1, 0)
		elseif from == "Right" then Vector2.new(1, 0)
		elseif from == "Top" then Vector2.new(0, -1)
		elseif from == "Bottom" then Vector2.new(0, 1)
		else Vector2.zero
	if kind == "Drop" then
		dir = Vector2.new(0, -1)
	end

	if typeof(o.Pin) == "Vector2" then
		gui.AnchorPoint = o.Pin
		gui.Position = UDim2.fromScale(o.Pin.X, o.Pin.Y)
	end
	-- the element rides on a full-size "mover" inside the wrapper (the
	-- wrapper is what a layout places, so nothing fights the motion)
	scope:New "Frame" {
		Name = "Enter",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Size = UDim2.fromScale(1, 1),
		Position = scope:Computed(function(use)
			local k = use(t)
			if kind == "Pop" then
				return UDim2.fromScale(0.5, 0.5)
			end
			local off = dir * reach * (1 - k)
			return UDim2.new(0.5, math.floor(off.X), 0.5, math.floor(off.Y))
		end),
		Visible = scope:Computed(function(use)
			return use(t) > 0.02
		end),
		Parent = wrapper,
		[Children] = {
			if kind == "Pop"
				then scope:New "UIScale" {
					Scale = scope:Computed(function(use)
						return math.max(0.01, 0.35 + 0.65 * use(t))
					end),
				}
				else nil,
			gui,
		},
	}
	if not calm then
		task.delay(delay, function()
			progress:set(1)
		end)
	end
	return wrapper
end

return HudKits
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="107">
            <Properties>
              <string name="Name">HudMenu</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	HudMenu: the HUD's menu buttons (Shop, Pets, Quests...), in one of seven looks.

	scope:HudMenu {
		Buttons = {
			{ Id = "shop", Label = "Shop", Icon = "shop", Tone = "Yellow", Badge = 3, OnClick = fn },
		},
		Style = "Column",      -- "Column" | "Grid" | "Dock" (the originals; "Row" too)
		                       -- | "Bubbles" (round floating buttons) | "TabBar" (a mobile-game
		                       -- tab bar with a raised centre button) | "Drawer" (a side drawer
		                       -- that folds away) | "Tiles" (labelled square tiles, PS99)
		Kit = "Chunky Toy",
		Align = "Left",        -- the screen side
		Row = "Middle",        -- "Top" | "Middle" | "Bottom"
		Horizontal = false,    -- top / bottom centre
		Touch = false,         -- phone layout (bigger cells, fewer per column)
		Minimal = false,       -- the "Minimal" HUD layout (smaller buttons on PC)
		ButtonStyle = state,   -- Button Style for the original looks (e.g. never "Outline" over the world)
		MakeButton = function(scope, props, button) return scope:Button(props) end,
		                       -- builds the round / square buttons (Blueprint wires actions here)
		Handles = {},          -- optional: filled with Handles[id] = the button
		Open = state,          -- Drawer: open or folded (default: a Value(true))
		Sound = "Click",       -- "" for silent cells (previews)
	}

	The root has a fixed size (and a HudSize attribute) for HUD layouts.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Motion = require(script.Parent.Parent.FX.Motion)
local P = require(script.Parent.HudParts)
local Button = require(script.Parent.Button)
local Badge = require(script.Parent.Badge)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Layout = require(script.Parent.Layout)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

local function field(entry: { [string]: any }, key: string): any
	local value = entry[key]
	if value == nil then
		value = entry[string.lower(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

local function textWidth(text: string?, size: number): number
	local length = if text then (utf8.len(text) or #text) else 0
	return math.ceil(length * size * 0.56)
end

local function makeButton(scope: Scope, props: { [any]: any }, spec: { [string]: any }, button: { [string]: any }): Instance
	if spec.MakeButton then
		return spec.MakeButton(scope, props, button)
	end
	props.OnClick = field(button, "OnClick")
	return Button(scope, props)
end

-- The glass card behind the Dock (Blueprint's card look).
local function card(scope: Scope, props: { [any]: any }, size: Vector2, children: any): Frame
	local root = Kit.root(props, { Name = "MenuDock" })
	root.Size = UDim2.fromOffset(size.X, size.Y)
	root.BackgroundColor3 = Kit.themed(scope, function(theme)
		return theme.Color.Surface
	end)
	root.BackgroundTransparency = Kit.themed(scope, function(theme)
		return math.clamp(theme.Alpha.Surface - 0.08, 0, 1)
	end)
	root[Children] = {
			Kit.corner(scope, "L"),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = ColorSequence.new(WHITE, Color3.fromRGB(200, 205, 220)),
			}),
			scope:New "Frame" {
				Name = "Highlight",
				BackgroundColor3 = WHITE,
				BackgroundTransparency = 0.9,
				Position = UDim2.fromOffset(4, 3),
				Size = UDim2.new(1, -8, 0.4, 0),
				[Children] = {
					Kit.corner(scope, "L"),
					Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0, 1) }),
				},
			},
			children,
			props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

local function finish(scope: Scope, props: { [any]: any }, size: Vector2, children: { any }): Frame
	local root = Kit.root(props, { Name = "Menu" })
	root.BackgroundTransparency = 1
	root[Children] = { children, props[Children] }
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

--------------------------------------------------------------------------
-- The original looks: Column / Grid / Row / Dock
--------------------------------------------------------------------------

local function classic(scope: Scope, props: { [any]: any }, style: string): Frame
	local buttons = props.Buttons or {}
	local touch = props.Touch == true
	local row = props.Row or "Middle"
	local horizontal = props.Horizontal == true
	local align = props.Align or "Left"
	local handles = props.Handles or {}
	if touch and style == "Column" and row == "Middle" and #buttons > 3 then
		-- phones are short: a tall column becomes a two-wide grid
		style = "Grid"
	end
	if style == "Grid" and horizontal then
		style = "Row"
	elseif (style == "Row" or style == "Dock") and row == "Middle" then
		style = "Column"
	end
	-- Minimal shrinks the buttons, except on touch screens where they are
	-- already at the smallest comfortable thumb size
	local minimal = props.Minimal == true and not touch
	local dock = style == "Dock"
	local buttonW = if minimal then 72 elseif dock then 84 else 96
	local buttonH = if minimal then 66 elseif dock then 76 else 88
	local labelSize = if minimal then 18 elseif dock then 20 else 22
	-- cells widen (a little) for long labels like LEADERBOARD; anything longer
	-- still shrinks to its cell instead of running into its neighbour
	local widest = 0
	for _, b in buttons do
		-- capitals in a display face run ~15% wider than textWidth's average
		widest = math.max(widest, textWidth(string.upper(field(b, "Label") or ""), labelSize * 1.15))
	end
	local cellW = math.clamp(widest + 8, buttonW + 8, buttonW + 60)
	local cellH = buttonH + labelSize + 10
	-- with a kit: its button style, corners and label type
	local kitted = P.kitted(props.Kit)
	local T = P.tokens(props.Kit)
	local look = if kitted then P.look(scope, T) else nil
	local cells = {}
	for index, b in buttons do
		local id = field(b, "Id") or `Button{index}`
		local button = makeButton(scope, {
			Name = "Button",
			Icon = field(b, "Icon"),
			Tone = field(b, "Tone") or "Blue",
			Style = if kitted and props.ButtonStyle == nil then P.buttonStyle(T) else props.ButtonStyle,
			Radius = if kitted then P.buttonRadius(T) else nil,
			Size = UDim2.fromOffset(buttonW, buttonH),
			IconSize = math.floor(buttonH * 0.62),
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5, 0),
			Badge = field(b, "Badge"),
		}, props, b)
		handles[id] = button
		table.insert(cells, scope:New "Frame" {
			Name = id,
			LayoutOrder = index,
			-- earlier cells draw on top, so a button's drop shadow never covers
			-- the label of the cell above it
			ZIndex = #buttons - index + 2,
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(cellW, cellH),
			[Children] = {
				button,
				Text(scope, {
					Name = "Label",
					Text = field(b, "Label"),
					TextSize = labelSize,
					Font = if kitted then T.labelFont else "Display",
					Case = if kitted then T.case else nil,
					Color = if look
						then scope:Computed(function(use)
							return use(Theme.current).Color.Text
						end)
						else nil,
					-- labels float over the game world: always outlined, even in
					-- themes whose text has no stroke (docs/RESEARCH.md 7.4)
					Stroke = if dock then nil else 2,
					Size = UDim2.new(1, 2, 0, labelSize + 4),
					Scaled = true,
					Wrapped = false, -- single line: shrink, never break a word
					MaxTextSize = labelSize,
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, 0),
					ZIndex = 3,
				}),
			},
		})
	end
	local n = math.max(1, #cells)
	-- phones are short: five or more grid buttons go three across (two rows)
	-- so the menu keeps full-size touch targets instead of shrinking
	local columns = if style == "Grid" then (if touch and n >= 5 then 3 else math.min(2, n))
		elseif style == "Column" then 1
		else n
	local rows = math.ceil(n / columns)
	local gapX, gapY = if dock then 8 else 12, 8
	local inner = Vector2.new(columns * cellW + (columns - 1) * gapX, rows * cellH + (rows - 1) * gapY)
	local pad = if dock then 12 else 0
	local size = inner + Vector2.new(pad * 2, pad * 2)
	local grid = Layout.Grid(scope, {
		Name = "Buttons",
		Size = UDim2.fromOffset(inner.X, inner.Y),
		Position = UDim2.fromOffset(pad, pad),
		CellSize = Vector2.new(cellW, cellH),
		Gap = Vector2.new(gapX, gapY),
		Columns = columns,
		Align = if align == "Right" then "Right" elseif align == "Left" then "Left" else "Center",
		[Children] = cells,
	})
	if dock then
		return card(scope, props, size, grid)
	end
	return finish(scope, props, size, { grid })
end

--------------------------------------------------------------------------
-- Kit looks
--------------------------------------------------------------------------

local function badgeOf(scope: Scope, b: { [string]: any }, position: UDim2): any
	local value = field(b, "Badge")
	if value == nil then
		return nil
	end
	return Badge(scope, {
		Value = value,
		Position = position,
		ZIndex = 12,
	})
end

-- Round floating buttons with a small label underneath.
local function bubbles(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local buttons = props.Buttons or {}
	local touch = props.Touch == true
	local horizontal = props.Horizontal == true
	local align = props.Align or "Left"
	local handles = props.Handles or {}
	local bubble = if touch then 80 elseif props.Minimal then 64 else 74
	local labelSize = if props.Minimal then 15 else 17
	local cellW = bubble + 30
	local cellH = bubble + labelSize + 14
	local n = math.max(1, #buttons)
	local columns = if horizontal then n elseif n > 5 or (touch and n > 3) then 2 else 1
	local rows = math.ceil(n / columns)
	local gap = 8
	local size = Vector2.new(columns * cellW + (columns - 1) * gap, rows * cellH + (rows - 1) * gap)
	local style = if T.plate == "Toy" or T.plate == "Arcade" then "Glossy3D" else "Pill"
	local look = P.look(scope, T)
	local cells = {}
	for index, b in buttons do
		local id = field(b, "Id") or `Button{index}`
		local tone = field(b, "Tone") or "Blue"
		local button = makeButton(scope, {
			Name = "Button",
			Icon = field(b, "Icon"),
			Tone = tone,
			Style = style,
			Radius = "Full",
			Size = UDim2.fromOffset(bubble, bubble),
			IconSize = math.floor(bubble * 0.56),
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5, 0),
			Badge = field(b, "Badge"),
			-- Neon: each bubble glows in its own colour
			[Children] = if T.plate == "Neon" and Theme.lite ~= true
				then Kit.rimGlow(scope, {
					Color = scope:Computed(function(use)
						return Theme.tone(use(Theme.current), tone).Top
					end),
					Radius = "Full",
					Thickness = 16,
					Strength = 0.9,
				})
				else nil,
		}, props, b)
		handles[id] = button
		if T.idle == "Bob" then
			-- floating: each bubble drifts on its own beat
			task.defer(function()
				if button.Parent then
					table.insert(scope, Motion.bob(button :: any, 3, 2.3 + (index % 3) * 0.35))
				end
			end)
		end
		table.insert(cells, scope:New "Frame" {
			Name = id,
			LayoutOrder = index,
			ZIndex = n - index + 2,
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(cellW, cellH),
			[Children] = {
				button,
				P.text(scope, T, look, {
					Name = "Label",
					Role = "Title",
					Text = field(b, "Label"),
					TextSize = labelSize,
					Color = scope:Computed(function(use)
						return use(Theme.current).Color.Text
					end),
					Stroke = 2,
					StrokeColor = scope:Computed(function(use)
						return use(Theme.current).Color.Outline
					end),
					StrokeTransparency = 0,
					Size = UDim2.new(1, 0, 0, labelSize + 4),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = labelSize,
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, 0),
					ZIndex = 3,
				}),
			},
		})
	end
	return finish(scope, props, size, {
		Layout.Grid(scope, {
			Name = "Buttons",
			Size = UDim2.fromScale(1, 1),
			CellSize = Vector2.new(cellW, cellH),
			Gap = Vector2.new(gap, gap),
			Columns = columns,
			Align = if align == "Right" then "Right" elseif align == "Left" then "Left" else "Center",
			[Children] = cells,
		}),
	})
end

-- A mobile-game tab bar (or a side rail) with a raised centre button.
local function tabBar(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local buttons = props.Buttons or {}
	local touch = props.Touch == true
	local vertical = not props.Horizontal and (props.Row or "Middle") == "Middle"
	local handles = props.Handles or {}
	local n = math.max(1, #buttons)
	local cellW = if touch then 96 else 90
	local cellH = if touch then 84 else 78
	local hero = if n >= 3 and n % 2 == 1 and not vertical then (n + 1) // 2 else nil
	local pad = 8
	local lift = if hero then 26 else 0
	local size = if vertical
		then Vector2.new(cellW + pad * 2, n * cellH + pad * 2)
		else Vector2.new(n * cellW + pad * 2, cellH + pad * 2 + lift)
	local look = P.look(scope, T)
	local cells = {}
	for index, b in buttons do
		local id = field(b, "Id") or `Button{index}`
		local isHero = index == hero
		local tone = Kit.tone(scope, field(b, "Tone") or "Blue")
		local x = if vertical then pad else pad + (index - 1) * cellW
		local y = if vertical then pad + (index - 1) * cellH else lift + pad
		local heroSize = cellH + 10
		local content
		if isHero then
			content = {
				scope:New "Frame" {
					Name = "Hero",
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, -lift - 4),
					Size = UDim2.fromOffset(heroSize, heroSize),
					BackgroundColor3 = WHITE,
					ZIndex = 5,
					[Children] = {
						scope:New "UICorner" {
							CornerRadius = scope:Computed(function(use)
								return P.radiusFor(use(Theme.current), T, "Pill")
							end),
						},
						scope:New "UIGradient" {
							Rotation = 90,
							Color = scope:Computed(function(use)
								return Kit.faceColors(use(tone), 0.1)
							end),
						},
						scope:New "UIStroke" {
							Color = scope:Computed(function(use)
								local l = use(look)
								return if l.light then l.face else use(Theme.current).Color.Outline
							end),
							Thickness = 4,
						},
						P.icon(scope, T, {
							Icon = field(b, "Icon"),
							Size = math.floor(heroSize * 0.5),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.42),
							Color = WHITE,
							Treatment = "Flat",
							Stroke = 2,
							StrokeColor = scope:Computed(function(use)
								return use(tone).Edge
							end),
							ZIndex = 6,
						}),
						P.text(scope, T, look, {
							Name = "Label",
							Role = "Title",
							Text = field(b, "Label"),
							TextSize = 14,
							Color = WHITE,
							Stroke = 1.5,
							StrokeColor = scope:Computed(function(use)
								return use(tone).Edge
							end),
							AnchorPoint = Vector2.new(0.5, 1),
							Position = UDim2.new(0.5, 0, 1, -8),
							Size = UDim2.new(1, -12, 0, 16),
							Scaled = true,
							Wrapped = false,
							MaxTextSize = 14,
							ZIndex = 6,
						}),
						badgeOf(scope, b, UDim2.new(1, -10, 0, 10)),
					},
				},
			}
		else
			content = {
				P.icon(scope, T, {
					Icon = field(b, "Icon"),
					Size = math.floor(cellH * 0.44),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.4),
					Color = scope:Computed(function(use)
						return use(tone).Top
					end),
					ZIndex = 5,
				}),
				P.text(scope, T, look, {
					Name = "Label",
					Role = "Label",
					Text = field(b, "Label"),
					TextSize = 14,
					Color = scope:Computed(function(use)
						return use(look).ink
					end),
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -6),
					Size = UDim2.new(1, -8, 0, 16),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = 14,
					ZIndex = 5,
				}),
				badgeOf(scope, b, UDim2.new(0.5, 20, 0, 10)),
			}
		end
		local cell = P.button(scope, {
			Name = id,
			Size = UDim2.fromOffset(cellW, cellH),
			Position = UDim2.fromOffset(x, y),
			ZIndex = if isHero then 6 else 4,
			OnClick = field(b, "OnClick"),
			Sound = props.Sound,
			Action = true,
			Radius = if isHero then "Full" else "M",
			[Children] = content,
		})
		handles[id] = cell
		table.insert(cells, cell)
		-- thin dividers between plain cells
		if index < #buttons and index ~= hero and index + 1 ~= hero then
			table.insert(cells, scope:New "Frame" {
				Name = "Divider",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = if vertical
					then UDim2.fromOffset(pad + cellW / 2, pad + index * cellH)
					else UDim2.fromOffset(pad + index * cellW, lift + pad + cellH / 2),
				Size = if vertical then UDim2.fromOffset(cellW - 24, 1) else UDim2.fromOffset(1, cellH - 24),
				BackgroundColor3 = scope:Computed(function(use)
					return use(look).muted
				end),
				BackgroundTransparency = 0.6,
				BorderSizePixel = 0,
				ZIndex = 4,
			})
		end
	end
	return finish(scope, props, size, {
		P.plate(scope, T, {
			Name = "Bar",
			Radius = if T.corner == "Round" then "Pill" else nil,
			Position = UDim2.fromOffset(0, lift),
			Size = UDim2.new(1, 0, 1, -lift),
			Edge = if vertical then (if props.Align == "Right" then "Right" else "Left") else "Top",
			Idle = true,
		}),
		cells,
	})
end

-- A drawer that folds into a handle at the screen edge.
local function drawer(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local buttons = props.Buttons or {}
	local touch = props.Touch == true
	local right = props.Align == "Right"
	local handles = props.Handles or {}
	local n = math.max(1, #buttons)
	local rowH = if touch then 60 else 54
	local panelW = if touch then 240 else 226
	local header = 40
	local pad = 10
	local handleW = 34
	local panelH = header + n * rowH + pad * 2
	local size = Vector2.new(panelW + handleW, panelH)
	local open = props.Open or scope:Value(true)
	local slide = scope:Spring(scope:Computed(function(use)
		return if use(open) then 1 else 0
	end), 24, 0.82)
	local look = P.look(scope, T)
	local rows = {}
	for index, b in buttons do
		local id = field(b, "Id") or `Button{index}`
		local tone = Kit.tone(scope, field(b, "Tone") or "Blue")
		local cell = P.button(scope, {
			Name = id,
			Size = UDim2.new(1, -pad * 2, 0, rowH - 6),
			Position = UDim2.fromOffset(pad, header + pad + (index - 1) * rowH),
			ZIndex = 4,
			HoverScale = 1.03,
			OnClick = field(b, "OnClick"),
			Sound = props.Sound,
			Action = true,
			Radius = "S",
			[Children] = {
				scope:New "Frame" {
					Name = "Row",
					BackgroundColor3 = scope:Computed(function(use)
						local l = use(look)
						return if l.light then l.ink else WHITE
					end),
					BackgroundTransparency = 0.93,
					Size = UDim2.fromScale(1, 1),
					ZIndex = 4,
					[Children] = P.corner(scope, T, "Small"),
				},
				scope:New "Frame" {
					Name = "Disc",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 6, 0.5, 0),
					Size = UDim2.fromOffset(rowH - 16, rowH - 16),
					BackgroundColor3 = WHITE,
					ZIndex = 5,
					[Children] = {
						P.corner(scope, T, if T.corner == "Square" or T.corner == "Sharp" then nil else "Pill"),
						scope:New "UIGradient" {
							Rotation = 90,
							Color = scope:Computed(function(use)
								return Kit.faceColors(use(tone), 0)
							end),
						},
						scope:New "UIStroke" {
							Color = scope:Computed(function(use)
								return use(tone).Edge
							end),
							Thickness = 2,
						},
						Icon(scope, {
							Icon = field(b, "Icon"),
							Size = math.floor((rowH - 16) * 0.6),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = scope:Computed(function(use)
								return use(tone).Text
							end),
							Stroke = 1.5,
							StrokeColor = scope:Computed(function(use)
								return use(tone).Edge
							end),
							ZIndex = 6,
						}),
					},
				},
				P.text(scope, T, look, {
					Name = "Label",
					Role = "Title",
					Text = field(b, "Label"),
					TextSize = 19,
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, rowH - 2, 0.5, 0),
					Size = UDim2.new(1, -(rowH + 24), 0, 24),
					Truncate = true,
					Color = scope:Computed(function(use)
						return use(look).ink
					end),
					ZIndex = 5,
				}),
				P.icon(scope, T, {
					Name = "Go",
					Icon = "chevron-small-right",
					Size = 20,
					Treatment = "Flat",
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -6, 0.5, 0),
					Color = scope:Computed(function(use)
						return use(look).muted
					end),
					ZIndex = 5,
				}),
				badgeOf(scope, b, UDim2.new(0, rowH - 18, 0, 6)),
			},
		})
		handles[id] = cell
		table.insert(rows, cell)
	end
	local panel = scope:New "Frame" {
		Name = "Panel",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(panelW, panelH),
		Position = scope:Computed(function(use)
			local hidden = math.floor((1 - use(slide)) * (panelW + 8))
			return UDim2.fromOffset(if right then handleW + hidden else -hidden, 0)
		end),
		Visible = scope:Computed(function(use)
			return use(slide) > 0.01
		end),
		ZIndex = 2,
		[Children] = {
			P.plate(scope, T, { Edge = if right then "Right" else "Left", Idle = true }),
			P.icon(scope, T, {
				Name = "MenuIcon",
				Icon = "menu",
				Size = 22,
				Treatment = "Flat",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, pad + 6, 0, header / 2 + 4),
				Color = scope:Computed(function(use)
					return use(look).accent
				end),
				ZIndex = 4,
			}),
			P.text(scope, T, look, {
				Name = "Title",
				Role = "Label",
				Text = props.Title or "Menu",
				TextSize = 16,
				XAlign = "Left",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, pad + 36, 0, header / 2 + 4),
				ZIndex = 4,
			}),
			rows,
		},
	}
	-- the handle rides on the panel's outer edge and stays in view when folded
	local handle = P.button(scope, {
		Name = "Handle",
		Size = UDim2.fromOffset(handleW, 84),
		AnchorPoint = Vector2.new(0, 0.5),
		Position = scope:Computed(function(use)
			local s = use(slide)
			-- left drawers: the handle trails the panel's right edge and ends at
			-- the screen edge; right drawers: it leads the panel's left edge
			local x = if right then math.floor(panelW * (1 - s)) else math.floor(panelW * s)
			return UDim2.new(0, x, 0, math.min(panelH / 2, 60))
		end),
		ZIndex = 3,
		HoverScale = 1.06,
		OnClick = function()
			open:set(not peek(open))
		end,
		Sound = props.Sound,
		Radius = "S",
		[Children] = {
			P.plate(scope, T, { Name = "Tab", Radius = "Soft", Shadow = false, ZIndex = 3 }),
			P.icon(scope, T, {
				Name = "Chevron",
				Icon = "chevron-small-right",
				Size = 24,
				Treatment = "Flat",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Rotation = scope:Computed(function(use)
					local s = use(slide)
					-- points out when folded, in when open
					return if right then 180 * (1 - s) else 180 * s
				end),
				Color = scope:Computed(function(use)
					return use(look).accent
				end),
				ZIndex = 5,
			}),
		},
	})
	return finish(scope, props, size, {
		-- clips on the screen-edge side only (room for shadows elsewhere)
		scope:New "Frame" {
			Name = "Clip",
			BackgroundTransparency = 1,
			ClipsDescendants = true,
			Position = UDim2.fromOffset(if right then -24 else 0, -24),
			Size = UDim2.new(1, 24, 1, 48),
			[Children] = scope:New "Frame" {
				Name = "Tray",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(if right then 24 else 0, 24),
				Size = UDim2.fromOffset(size.X, size.Y),
				[Children] = { panel, handle },
			},
		},
	})
end

-- Labelled square tiles in the button's colour (Pet Simulator 99).
local function tiles(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local buttons = props.Buttons or {}
	local touch = props.Touch == true
	local horizontal = props.Horizontal == true
	local align = props.Align or "Left"
	local handles = props.Handles or {}
	local tile = if touch then 100 elseif props.Minimal then 84 else 96
	local n = math.max(1, #buttons)
	local columns = if horizontal then n elseif touch and n >= 5 then 3 elseif n >= 3 then 2 else 1
	local rows = math.ceil(n / columns)
	local gap = 10
	local size = Vector2.new(columns * tile + (columns - 1) * gap, rows * tile + (rows - 1) * gap)
	local outline = P.look(scope, T)
	local cells = {}
	for index, b in buttons do
		local id = field(b, "Id") or `Button{index}`
		local tone = Kit.tone(scope, field(b, "Tone") or "Blue")
		local cell = P.button(scope, {
			Name = id,
			Size = UDim2.fromOffset(tile, tile),
			LayoutOrder = index,
			OnClick = field(b, "OnClick"),
			Sound = props.Sound,
			Action = true,
			Radius = "L",
			ZIndex = 3,
			[Children] = {
				scope:New "Frame" {
					Name = "Lip",
					Position = UDim2.fromOffset(0, 5),
					Size = UDim2.fromScale(1, 1),
					BackgroundColor3 = scope:Computed(function(use)
						return use(tone).Edge
					end),
					ZIndex = 3,
					[Children] = {
						P.corner(scope, T, nil),
						scope:New "UIStroke" {
							Color = scope:Computed(function(use)
								return use(Theme.current).Color.Outline
							end),
							Thickness = 3,
						},
					},
				},
				scope:New "Frame" {
					Name = "Face",
					Size = UDim2.fromScale(1, 1),
					BackgroundColor3 = WHITE,
					ZIndex = 4,
					[Children] = {
						P.corner(scope, T, nil),
						scope:New "UIGradient" {
							Rotation = 90,
							Color = scope:Computed(function(use)
								return Kit.faceColors(use(tone), 0.1)
							end),
						},
						scope:New "UIStroke" {
							Color = scope:Computed(function(use)
								return use(Theme.current).Color.Outline
							end),
							Thickness = 3,
						},
						Kit.unlessLite(scope, function(inner)
							return inner:New "Frame" {
								Name = "Gloss",
								BackgroundColor3 = WHITE,
								BackgroundTransparency = 0.6,
								Position = UDim2.fromOffset(5, 4),
								Size = UDim2.new(1, -10, 0.36, 0),
								ZIndex = 5,
								[Children] = {
									P.corner(inner, T, nil),
									inner:New "UIGradient" { Rotation = 90, Transparency = NumberSequence.new(0.15, 1) },
								},
							}
						end),
						Icon(scope, {
							Icon = field(b, "Icon"),
							Size = math.floor(tile * 0.52),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.4),
							Color = scope:Computed(function(use)
								return use(tone).Text
							end),
							Gradient = true,
							Shadow = true,
							StrokeColor = scope:Computed(function(use)
								return use(tone).Edge
							end),
							ZIndex = 6,
						}),
						P.text(scope, T, outline, {
							Name = "Label",
							Role = "Title",
							Text = field(b, "Label"),
							TextSize = if tile >= 96 then 17 else 15,
							Color = scope:Computed(function(use)
								return use(tone).Text
							end),
							Stroke = 2,
							StrokeColor = scope:Computed(function(use)
								local t = use(tone)
								return t.Stroke or t.Edge
							end),
							StrokeTransparency = 0,
							AnchorPoint = Vector2.new(0.5, 1),
							Position = UDim2.new(0.5, 0, 1, -7),
							Size = UDim2.new(1, -10, 0, 20),
							Scaled = true,
							Wrapped = false,
							MaxTextSize = if tile >= 96 then 17 else 15,
							ZIndex = 7,
						}),
					},
				},
				badgeOf(scope, b, UDim2.new(1, -8, 0, 8)),
			},
		})
		handles[id] = cell
		table.insert(cells, cell)
	end
	return finish(scope, props, size + Vector2.new(0, 6), {
		Layout.Grid(scope, {
			Name = "Tiles",
			Size = UDim2.fromScale(1, 1),
			CellSize = Vector2.new(tile, tile),
			Gap = Vector2.new(gap, gap),
			Columns = columns,
			Align = if align == "Right" then "Right" elseif align == "Left" then "Left" else "Center",
			[Children] = cells,
		}),
	})
end

return function(scope: Scope, props: { [any]: any }): Frame
	local style = props.Style
	if style == "Row" then
		return classic(scope, props, "Row")
	end
	style = P.style("MenuButtons", style)
	if style == "Column" or style == "Grid" or style == "Dock" then
		return classic(scope, props, style)
	end
	local T = P.tokens(props.Kit)
	if style == "Bubbles" then
		return bubbles(scope, props, T)
	elseif style == "TabBar" then
		return tabBar(scope, props, T)
	elseif style == "Drawer" then
		return drawer(scope, props, T)
	end
	return tiles(scope, props, T)
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="108">
            <Properties>
              <string name="Name">HudObjective</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	HudObjective: the current objective ("Find the red key"), in one of five looks.

	local objective = scope:Value("Find the red key")
	scope:HudObjective {
		Text = objective,         -- string or state; a change animates the new text in
		Label = "OBJECTIVE",      -- small caption
		Icon = "objective",
		Progress = progress,      -- optional 0..1 (Card's ring)
		Done = done,              -- optional state: Checklist ticks and strikes it through
		Style = "Panel",          -- "Panel" (faded panel, typewriter; the original) | "Card"
		                          -- (a card with a progress ring) | "Ticker" (one line)
		                          -- | "Checklist" (a checkbox that strikes through when done)
		                          -- | "Waypoint" (a banner with a marker and moving chevrons)
		Kit = "Storybook",
		Align = "Left",
		Animate = true,           -- false: no typewriter / slide (previews)
	}

	The root has a fixed size (and a HudSize attribute) for HUD layouts.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Motion = require(script.Parent.Parent.FX.Motion)
local P = require(script.Parent.HudParts)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Layout = require(script.Parent.Layout)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

local function finish(scope: Scope, props: { [any]: any }, size: Vector2, children: { any }): Frame
	local root = Kit.root(props, { Name = "Objective" })
	root.BackgroundTransparency = 1
	root[Children] = { children, props[Children] }
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

-- A spring offset that kicks when the text changes (slides the new text in).
local function swapIn(scope: Scope, text: any, animate: boolean): any
	local offset = scope:Spring(scope:Value(0), 22, 0.8)
	if animate then
		Kit.watch(scope, text, function()
			if not Motion.reduced then
				offset:setPosition(18)
			end
		end)
	end
	return offset
end

-- A text size that shrinks a long objective (or a wide kit face) to fit.
local function fitSize(scope: Scope, T: { [string]: any }, text: any, size: number, width: number): any
	return scope:Computed(function(use)
		return P.fit(tostring(use(text) or ""), size, width, T.labelFont, math.floor(size * 0.62), use(Theme.current))
	end)
end

local BUILD = {}

-- The original: a faded panel with an accent bar, a caption and a typewriter.
function BUILD.Panel(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local objective = props.Text or ""
	local size = Vector2.new(560, 88)
	local right = props.Align == "Right"
	-- on the right edge the whole plate mirrors: accent bar, caption and text
	-- all hug the right, so nothing is split across the plate
	local inset = if right then 4 else 20
	local kitted = P.kitted(props.Kit)
	local look = P.look(scope, T)
	local label = Text(scope, {
		Name = "ObjectiveText",
		Text = peek(objective),
		TextSize = 30,
		Font = if kitted then T.labelFont else "Display",
		Case = if kitted then T.case else nil,
		XAlign = if right then "Right" else "Left",
		Size = UDim2.new(1, -24, 0, 36),
		Position = UDim2.fromOffset(inset, 40),
		Truncate = true,
		Color = if kitted
			then scope:Computed(function(use)
				return use(look).ink
			end)
			else nil,
		ZIndex = 3,
	})
	local frame = scope:New "Frame" {
		Name = "Objective",
		Size = UDim2.fromOffset(size.X, size.Y),
		BackgroundColor3 = if kitted
			then scope:Computed(function(use)
				return use(look).face
			end)
			else Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
		BackgroundTransparency = if kitted
			then scope:Computed(function(use)
				local l = use(look)
				return if l.alpha >= 1 then 0.55 else math.min(0.35, l.alpha)
			end)
			else 0.35,
		Position = props.Position,
		AnchorPoint = props.AnchorPoint,
		LayoutOrder = props.LayoutOrder,
		Visible = props.Visible,
		[Children] = {
			if kitted then P.corner(scope, T, nil) else Kit.corner(scope, "M"),
			Kit.gradient(scope, {
				Rotation = if right then 180 else 0,
				Transparency = NumberSequence.new({
					NumberSequenceKeypoint.new(0, 0),
					NumberSequenceKeypoint.new(0.6, 0.35),
					NumberSequenceKeypoint.new(1, 1),
				}),
			}),
			scope:New "Frame" {
				Name = "Accent",
				Size = UDim2.new(0, 5, 1, -24),
				Position = if right then UDim2.new(1, -5, 0, 12) else UDim2.fromOffset(0, 12),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Accent
				end),
				ZIndex = 2,
				[Children] = Kit.corner(scope, "Full"),
			},
			Layout.Row(scope, {
				Name = "Caption",
				Size = UDim2.new(1, -24, 0, 26),
				Position = UDim2.fromOffset(inset, 12),
				Gap = 8,
				Align = if right then "Right" else "Left",
				ZIndex = 3,
				[Children] = {
					Icon(scope, {
						Icon = props.Icon or "objective",
						Size = 22,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Accent
						end),
						LayoutOrder = 1,
						ZIndex = 3,
					}),
					Text(scope, {
						Text = props.Label or "OBJECTIVE",
						TextSize = 20,
						Font = if kitted then T.labelFont else "Heavy",
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Accent
						end),
						LayoutOrder = 2,
						ZIndex = 3,
					}),
				},
			}),
			label,
			props[Children],
		},
	}
	frame:SetAttribute("HudSize", size)
	local skip: (() -> ())? = nil
	local function show(textValue: string)
		if skip then
			skip()
			skip = nil
		end
		if props.Animate == false or textValue == "" then
			label.Text = textValue
			return
		end
		skip = Motion.typewrite(label, textValue, { Speed = 32, Sound = "" })
	end
	if props.Animate ~= false then
		show(peek(objective))
	end
	Kit.watch(scope, objective, show)
	table.insert(scope, function()
		if skip then
			skip()
			skip = nil
		end
	end)
	return frame
end

-- A card with a progress ring round the objective's icon.
function BUILD.Card(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local size = Vector2.new(440, 96)
	local look = P.look(scope, T)
	local ringSize = 68
	local progress = scope:Spring(scope:Computed(function(use)
		local p = tonumber(use(props.Progress))
		if p == nil then
			return if use(props.Done) then 1 else 0.25
		end
		return math.clamp(p, 0, 1)
	end), 12, 1)
	local offset = swapIn(scope, props.Text, props.Animate ~= false)
	local textX = 14 + ringSize + 14
	return finish(scope, props, size, {
		P.plate(scope, T, { Edge = "Left", Idle = true }),
		P.ring(scope, {
			Name = "Ring",
			Size = ringSize,
			Thickness = 6,
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, 14, 0.5, 0),
			Fraction = progress,
			Color = scope:Computed(function(use)
				return if use(props.Done) then use(Theme.current).Color.Good else use(look).accent
			end),
			Track = scope:Computed(function(use)
				return use(look).track
			end),
			TrackAlpha = 0.2,
			Cutout = P.holeColor(scope, look),
			ZIndex = 3,
			[Children] = P.icon(scope, T, {
				Icon = scope:Computed(function(use)
					return if use(props.Done) then "check" else props.Icon or "objective"
				end),
				Size = 30,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Color = scope:Computed(function(use)
					return use(look).accent
				end),
				Treatment = if T.icon == "Badge" then "Gradient" else nil,
				ZIndex = 8,
			}),
		}),
		P.text(scope, T, look, {
			Name = "Caption",
			Role = "Label",
			Text = props.Label or "Objective",
			TextSize = 15,
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 0),
			Position = UDim2.fromOffset(textX, 16),
			Color = scope:Computed(function(use)
				return use(look).accent
			end),
			ZIndex = 4,
		}),
		P.text(scope, T, look, {
			Name = "ObjectiveText",
			Role = "Title",
			Text = props.Text or "",
			Case = "Normal",
			TextSize = 22,
			XAlign = "Left",
			YAlign = "Top",
			Position = scope:Computed(function(use)
				return UDim2.fromOffset(textX, 38 + use(offset))
			end),
			Size = UDim2.fromOffset(size.X - textX - 14, 52),
			Wrapped = true,
			Truncate = true,
			ZIndex = 4,
		}),
	})
end

-- One line: a tag chip and the objective.
function BUILD.Ticker(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local size = Vector2.new(520, 46)
	local look = P.look(scope, T)
	local label = props.Label or "Objective"
	local chipW = math.ceil(P.textWidth(string.upper(label), 14, T.labelFont) + 30)
	local offset = swapIn(scope, props.Text, props.Animate ~= false)
	return finish(scope, props, size, {
		P.plate(scope, T, { Radius = if T.corner == "Round" then "Pill" else nil }),
		scope:New "Frame" {
			Name = "Tag",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, 7, 0.5, 0),
			Size = UDim2.fromOffset(chipW, size.Y - 14),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Accent
			end),
			BorderSizePixel = 0,
			ZIndex = 3,
			[Children] = {
				P.corner(scope, T, if T.corner == "Round" then "Pill" else "Small"),
				Text(scope, {
					Text = string.upper(label),
					TextSize = 14,
					Font = T.labelFont,
					Color = Kit.themed(scope, function(theme)
						return if Kit.isLight(theme.Color.Accent) then theme.Color.TextDark else theme.Color.Text
					end),
					Stroke = false,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					ZIndex = 4,
				}),
			},
		},
		scope:New "Frame" {
			Name = "Window",
			BackgroundTransparency = 1,
			ClipsDescendants = true,
			Position = UDim2.fromOffset(chipW + 18, 0),
			Size = UDim2.new(1, -(chipW + 30), 1, 0),
			ZIndex = 3,
			[Children] = P.text(scope, T, look, {
				Name = "ObjectiveText",
				Role = "Title",
				Text = props.Text or "",
				Case = "Normal",
				TextSize = fitSize(scope, T, props.Text, 21, size.X - chipW - 30),
				XAlign = "Left",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = scope:Computed(function(use)
					return UDim2.new(0, 0, 0.5, use(offset))
				end),
				Size = UDim2.new(1, 0, 0, 26),
				Truncate = true,
				ZIndex = 4,
			}),
		},
	})
end

-- A checkbox row: ticks and strikes through when Done.
function BUILD.Checklist(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local size = Vector2.new(470, 70)
	local look = P.look(scope, T)
	local done = scope:Computed(function(use)
		return use(props.Done) == true
	end)
	local tickGoal = scope:Computed(function(use)
		return if use(done) then 1 else 0
	end)
	local tick = scope:Spring(tickGoal, 26, T.bounce or 0.5)
	local box = 30
	local offset = swapIn(scope, props.Text, props.Animate ~= false)
	return finish(scope, props, size, {
		P.plate(scope, T, { Edge = "Left", Idle = true }),
		P.text(scope, T, look, {
			Name = "Caption",
			Role = "Label",
			Text = props.Label or "Objective",
			TextSize = 14,
			XAlign = "Left",
			Position = UDim2.fromOffset(16 + box + 12, 10),
			ZIndex = 4,
		}),
		scope:New "Frame" {
			Name = "Box",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, 16, 0.5, 0),
			Size = UDim2.fromOffset(box, box),
			BackgroundColor3 = scope:Computed(function(use)
				local l = use(look)
				return if l.light then l.face else use(Theme.current).Color.Surface
			end),
			BackgroundTransparency = scope:Computed(function(use)
				return if use(look).light then 0 else 0.3
			end),
			ZIndex = 4,
			[Children] = {
				P.corner(scope, T, "Small"),
				scope:New "UIStroke" {
					Color = scope:Computed(function(use)
						local l = use(look)
						return if l.light then l.ink else use(Theme.current).Color.Text
					end),
					Thickness = 2,
					Transparency = 0.15,
				},
				Icon(scope, {
					Name = "Tick",
					Icon = "check",
					Size = box + 6,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 3, 0.5, -3),
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Good
					end),
					Stroke = 2,
					Visible = scope:Computed(function(use)
						return use(tick) > 0.05
					end),
					ZIndex = 6,
					[Children] = scope:New "UIScale" {
						Scale = scope:Computed(function(use)
							return math.max(0.01, use(tick))
						end),
					},
				}),
			},
		},
		P.text(scope, T, look, {
			Name = "ObjectiveText",
			Role = "Title",
			RichText = true,
			Text = scope:Computed(function(use)
				local t = tostring(use(props.Text) or "")
				return if use(done) then `<s>{t}</s>` else t
			end),
			Case = "Normal",
			TextSize = fitSize(scope, T, props.Text, 22, size.X - box - 44),
			XAlign = "Left",
			Position = scope:Computed(function(use)
				return UDim2.fromOffset(16 + box + 12, 30 + use(offset))
			end),
			Size = UDim2.fromOffset(size.X - box - 44, 28),
			Truncate = true,
			Color = scope:Computed(function(use)
				local l = use(look)
				return if use(done) then l.muted else l.ink
			end),
			ZIndex = 4,
		}),
	})
end

-- A banner: a diamond marker, the objective, and chevrons pointing on.
function BUILD.Waypoint(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local size = Vector2.new(520, 74)
	local look = P.look(scope, T)
	local marker = 44
	local offset = swapIn(scope, props.Text, props.Animate ~= false)
	local banner = P.plate(scope, T, {
		Name = "Banner",
		Radius = if T.corner == "Round" then "Pill" else nil,
		AnchorPoint = Vector2.new(1, 0.5),
		Position = UDim2.new(1, 0, 0.5, 0),
		Size = UDim2.new(1, -marker / 2, 0, 56),
		Edge = "Bottom",
		Idle = true,
	})
	-- three chevrons that light up in turn
	local chevrons = {}
	for i = 1, 3 do
		local glow = P.loop(scope, banner, { From = 0.75, To = 0.05, Rest = 0.3, Period = 1.2, Delay = (i - 1) * 0.18, Name = `Chevron{i}` })
		table.insert(chevrons, P.icon(scope, T, {
			Name = `Chevron{i}`,
			Icon = "chevron-small-right",
			Size = 26,
			Treatment = "Flat",
			Color = scope:Computed(function(use)
				return use(look).accent
			end),
			Transparency = glow,
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -14 - (3 - i) * 13, 0.5, 0),
			ZIndex = 5,
		}))
	end
	return finish(scope, props, size, {
		banner,
		scope:New "Frame" {
			Name = "Marker",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0, marker / 2 + 4, 0.5, 0),
			Size = UDim2.fromOffset(marker, marker),
			Rotation = 45,
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Accent
			end),
			BorderSizePixel = 0,
			ZIndex = 5,
			[Children] = {
				P.corner(scope, T, "Small"),
				scope:New "UIStroke" {
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						return if use(look).kind == "Paper" then use(look).stroke else theme.Color.Outline
					end),
					Thickness = 3,
				},
			},
		},
		P.icon(scope, T, {
			Name = "MarkerIcon",
			Icon = props.Icon or "location-pin",
			Size = 26,
			Treatment = "Flat",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0, marker / 2 + 4, 0.5, 0),
			Color = Kit.themed(scope, function(theme)
				return if Kit.isLight(theme.Color.Accent) then theme.Color.TextDark else WHITE
			end),
			ZIndex = 6,
		}),
		P.text(scope, T, look, {
			Name = "Caption",
			Role = "Label",
			Text = props.Label or "Objective",
			TextSize = 14,
			XAlign = "Left",
			Position = UDim2.fromOffset(marker + 22, 14),
			Color = scope:Computed(function(use)
				return use(look).accent
			end),
			ZIndex = 5,
		}),
		scope:New "Frame" {
			Name = "Window",
			BackgroundTransparency = 1,
			ClipsDescendants = true,
			Position = UDim2.fromOffset(marker + 22, 30),
			Size = UDim2.new(1, -(marker + 22) - 64, 0, 30),
			ZIndex = 5,
			[Children] = P.text(scope, T, look, {
				Name = "ObjectiveText",
				Role = "Title",
				Text = props.Text or "",
				Case = "Normal",
				TextSize = fitSize(scope, T, props.Text, 22, size.X - marker - 86),
				XAlign = "Left",
				Position = scope:Computed(function(use)
					return UDim2.fromOffset(0, use(offset))
				end),
				Size = UDim2.new(1, 0, 0, 28),
				Truncate = true,
				ZIndex = 6,
			}),
		},
		chevrons,
	})
end

return function(scope: Scope, props: { [any]: any }): Frame
	local style = P.style("Objective", props.Style)
	return BUILD[style](scope, props, P.tokens(props.Kit))
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="109">
            <Properties>
              <string name="Name">HudParts</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	HudParts: the shared drawing kit behind every HUD style. Not a component;
	HUD components (HudHealth, HudXP, Hotbar, Timer, ...) build their looks
	from these so one kit reads the same on every element.

		local P = require(script.Parent.HudParts)
		local T = P.tokens(props.Kit)                 -- kit tokens (HudKits)
		local look = P.look(scope, T)                  -- Computed: plate colours in this theme
		P.plate(scope, T, { Radius = "Pill" })         -- a backing plate (shadow, lip, face, trims)
		P.text(scope, T, look, { Role = "Number", Text = ... })
		P.icon(scope, T, { Icon = "heart", Size = 30, Color = ... })
		P.ring(scope, { Size = 64, Thickness = 8, Fraction = f, Color = c })   -- smooth ring
		P.ticks(scope, { Size = 96, Count = 24, Start = 225, Span = 270, Fraction = f })
		P.bar(scope, T, { Fraction = f, Tone = "Green", Ghost = true, Heal = true, Low = 0.3 })
		P.loop(scope, host, { From = 0.4, To = 1, Period = 2.4 })   -- idle value, paused while hidden
		P.button(scope, { OnClick = fn, Size = ..., [Children] = ... })  -- hover / press springs

	Every loop is tracked (FX.track): it pauses while hidden and rests with
	reduced motion. Decorative layers (gloss, glow rims, grain, scan lines)
	are left out in lite mode.
]]

local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local HudKits = require(script.Parent.HudKits)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local P = {}

local WHITE = Color3.new(1, 1, 1)
local BLACK = Color3.new(0, 0, 0)
local SEPIA = Color3.fromRGB(74, 48, 24)
local PARCHMENT = Color3.fromRGB(238, 223, 190)

P.WHITE = WHITE
P.BLACK = BLACK

-- Kit tokens (HudKits.tokens): nil gives the Classic (original) look.
function P.tokens(kit: any): { [string]: any }
	return HudKits.tokens(kit)
end

-- True when a kit was given (so a default style may take kit details).
function P.kitted(kit: any): boolean
	return kit ~= nil and HudKits.tokens(kit).name ~= "Classic"
end

-- The Button Style that suits a kit's plates (never Outline / Soft: HUD
-- buttons float over the busy world).
local BUTTON_STYLES = {
	Solid = "Flat", Tactical = "Flat", Pixel = "Flat", Fade = "Flat",
	Glass = "Pill", Clear = "Pill", Neon = "Pill",
	Toy = "Glossy3D", Arcade = "Glossy3D", Paper = "Glossy3D",
}
function P.buttonStyle(T: { [string]: any }): string
	return BUTTON_STYLES[T.plate or "Glass"] or "Glossy3D"
end

-- A Button Radius for a kit (nil: the Button's own).
function P.buttonRadius(T: { [string]: any }): any
	if T.plate == "Pixel" or T.corner == "Square" then
		return 0
	elseif T.corner == "Sharp" then
		return 4
	elseif T.corner == "Round" then
		return "L"
	end
	return nil
end

-- Resolves a style prop against an element's style list (unknown -> first).
function P.style(elementType: string, style: any): string
	if HudKits.valid(elementType, style) then
		return style
	end
	return HudKits.styles[elementType][1]
end

-- Fixed footprint: the root's Size and its HudSize attribute.
function P.footprint(root: GuiObject, size: Vector2)
	root.Size = UDim2.fromOffset(math.ceil(size.X), math.ceil(size.Y))
	root:SetAttribute("HudSize", Vector2.new(math.ceil(size.X), math.ceil(size.Y)))
end

-- Anchor x for a side ("Left" 0, "Center" 0.5, "Right" 1).
function P.alignX(align: string?): number
	return if align == "Left" then 0 elseif align == "Right" then 1 else 0.5
end

--------------------------------------------------------------------------
-- Measuring text in kit fonts
--------------------------------------------------------------------------

-- advance widths (fraction of TextSize) for kit faces Kit.textWidth doesn't know
local EXTRA_ADVANCE = {
	PressStart2P = 1.0,
	Merriweather = 0.56,
}

function P.textWidth(text: string, size: number, font: any, theme: any?): number
	local ref = if typeof(font) == "string" then font else nil
	if ref then
		local family = string.match(ref, "^([%w]+):")
		local advance = family and EXTRA_ADVANCE[family]
		if advance then
			local plain = string.gsub(tostring(text), "<[^>]*>", "")
			return utf8.len(plain) * size * advance * 1.04
		end
	end
	return Kit.textWidth(text, size, font, theme)
end

-- Largest size <= `size` (>= minSize) at which `text` fits `width`.
function P.fit(text: string, size: number, width: number, font: any, minSize: number?, theme: any?): number
	local w = P.textWidth(text, size, font, theme)
	if w <= width or w <= 0 then
		return size
	end
	return math.max(minSize or 10, math.floor(size * width / w))
end

--------------------------------------------------------------------------
-- Plate colours: one table per theme x kit
--------------------------------------------------------------------------

export type Look = {
	kind: string,
	face: Color3,
	alpha: number,
	gradient: ColorSequence,
	stroke: Color3,
	strokeAlpha: number,
	strokeWidth: number,
	highlight: number, -- 1 = none
	ink: Color3,
	muted: Color3,
	inkStroke: Color3,
	inkStrokeWidth: number?, -- nil = the theme's text outline, 0 = none
	inkStrokeAlpha: number,
	accent: Color3,
	glow: Color3,
	track: Color3,
	trackAlpha: number,
	lip: Color3?,
	trim: Color3?,
	light: boolean,
}

local GREY_FADE = ColorSequence.new(WHITE, Color3.fromRGB(205, 208, 220))

function P.lookFor(theme: any, T: { [string]: any }, toneName: any?): Look
	local c = theme.Color
	local kind = T.plate or "Glass"
	local look: any = {
		kind = kind,
		face = c.Surface,
		alpha = theme.Alpha.Surface,
		gradient = GREY_FADE,
		stroke = c.Glass,
		strokeAlpha = 1,
		strokeWidth = 0,
		highlight = 0.9,
		ink = c.Text,
		muted = c.TextMuted,
		inkStroke = c.Outline,
		inkStrokeWidth = nil,
		inkStrokeAlpha = 0,
		accent = c.AccentText or c.Accent,
		glow = c.Accent,
		track = c.Track,
		trackAlpha = theme.Alpha.Track,
		lip = nil,
		trim = nil,
		light = false,
	}
	if kind == "Glass" then
		look.face = c.Surface:Lerp(c.Light, 0.07)
		look.alpha = math.clamp(theme.Alpha.Surface + 0.12, 0.3, 0.52)
		look.strokeAlpha = 0.55
		look.strokeWidth = 1.5
		look.highlight = 0.84
		look.gradient = ColorSequence.new(WHITE, Color3.fromRGB(196, 202, 222))
	elseif kind == "Solid" then
		look.face = c.Pill or c.Surface
		look.alpha = math.min(theme.Alpha.Surface, 0.18)
		look.gradient = ColorSequence.new(WHITE, Color3.fromRGB(222, 222, 230))
		look.highlight = 1
	elseif kind == "Clear" then
		look.alpha = 1
		look.highlight = 1
		-- a firm but thin outline: thick ones swamp thin faces (typewriter type)
		look.inkStrokeWidth = 1.75
		look.inkStrokeAlpha = 0.15
		look.track = c.Backdrop
		look.trackAlpha = 0.55
	elseif kind == "Fade" then
		look.face = c.Panel
		look.alpha = 0
		look.highlight = 1
		look.gradient = ColorSequence.new(WHITE)
	elseif kind == "Toy" then
		local face = c.Light:Lerp(Color3.fromRGB(255, 246, 226), 0.4)
		look.face = face
		look.alpha = 0
		look.gradient = ColorSequence.new(WHITE, Color3.fromRGB(234, 226, 212))
		look.stroke = c.Outline
		look.strokeAlpha = 0
		look.strokeWidth = if (T.outline or 0) > 0 then T.outline else 3
		look.highlight = 0.45
		look.ink = c.TextDark
		look.muted = c.TextDark:Lerp(face, 0.42)
		look.inkStrokeWidth = 0
		look.lip = face:Lerp(c.Outline, 0.3)
		look.track = face:Lerp(c.Outline, 0.2)
		look.trackAlpha = 0
		look.light = true
		look.accent = c.Accent:Lerp(c.TextDark, 0.25)
	elseif kind == "Arcade" then
		local tone = Theme.tone(theme, toneName or T.tone or "Blue")
		look.face = WHITE
		look.alpha = 0
		look.gradient = ColorSequence.new({
			ColorSequenceKeypoint.new(0, tone.Bottom:Lerp(tone.Top, 0.35)),
			ColorSequenceKeypoint.new(0.55, tone.Bottom:Lerp(tone.Edge, 0.35)),
			ColorSequenceKeypoint.new(1, tone.Edge),
		})
		look.stroke = c.Outline
		look.strokeAlpha = 0
		look.strokeWidth = if (T.outline or 0) > 0 then T.outline else 3
		look.highlight = 0.8
		look.ink = WHITE
		look.muted = WHITE:Lerp(tone.Top, 0.45)
		look.inkStroke = c.Outline
		look.inkStrokeWidth = 3
		look.lip = tone.Deep
		look.track = tone.Deep
		look.trackAlpha = 0
	elseif kind == "Neon" then
		local glow = c.Accent
		if Color.luminance(glow) < 0.35 then
			glow = glow:Lerp(WHITE, 0.35)
		end
		look.face = c.Surface:Lerp(BLACK, 0.45)
		look.alpha = 0.14
		look.gradient = ColorSequence.new(WHITE, Color3.fromRGB(170, 172, 186))
		look.glow = glow
		look.stroke = glow
		look.strokeAlpha = 0
		look.strokeWidth = 2
		look.highlight = 1
		look.inkStroke = glow
		look.inkStrokeWidth = 1.5
		look.inkStrokeAlpha = 0.45
		look.accent = glow
		look.track = glow:Lerp(BLACK, 0.72)
		look.trackAlpha = 0.1
	elseif kind == "Tactical" then
		look.face = c.Surface:Lerp(BLACK, 0.25)
		look.alpha = math.clamp(theme.Alpha.Surface, 0.26, 0.4)
		look.gradient = ColorSequence.new(WHITE, Color3.fromRGB(200, 204, 212))
		look.strokeAlpha = 0.86
		look.strokeWidth = 1
		look.highlight = 1
		look.inkStrokeWidth = 1
		look.inkStrokeAlpha = 0.45
		look.accent = c.Accent
		look.track = BLACK
		look.trackAlpha = 0.45
	elseif kind == "Paper" then
		local face = PARCHMENT:Lerp(c.Light, 0.22):Lerp(c.Accent, 0.05)
		local ink = c.TextDark:Lerp(SEPIA, 0.35)
		look.face = face
		look.alpha = 0
		look.gradient = ColorSequence.new({
			ColorSequenceKeypoint.new(0, WHITE),
			ColorSequenceKeypoint.new(0.7, Color3.fromRGB(242, 236, 224)),
			ColorSequenceKeypoint.new(1, Color3.fromRGB(214, 200, 176)),
		})
		look.stroke = ink:Lerp(face, 0.15)
		look.strokeAlpha = 0
		look.strokeWidth = 2.5
		look.highlight = 1
		look.ink = ink
		look.muted = ink:Lerp(face, 0.45)
		look.inkStrokeWidth = 0
		look.trim = (c.Trim or c.Accent):Lerp(ink, 0.25)
		look.accent = (c.Trim or c.Accent):Lerp(ink, 0.35)
		look.track = face:Lerp(ink, 0.22)
		look.trackAlpha = 0
		look.light = true
	elseif kind == "Pixel" then
		look.face = c.Surface
		look.alpha = 0.08
		look.gradient = ColorSequence.new(WHITE, Color3.fromRGB(214, 214, 224))
		look.stroke = c.Light
		look.strokeAlpha = 0
		look.strokeWidth = 3
		look.highlight = 1
		look.inkStroke = BLACK
		look.inkStrokeWidth = 2
		look.track = BLACK
		look.trackAlpha = 0.3
	end
	-- v2.4 style families: the plate takes the family's panel, ink and outline
	local fam = theme.Family
	if fam ~= nil then
		if kind == "Toy" and fam.Light then
			-- light families (Toybox, Clay, Mochi): only the windows are light.
			-- The HUD's toy chips and cards stay colour-filled, like the
			-- cartoon HUDs they come from: the tone's gloss face, white text
			-- with a solid ink outline, the family's outline round the chip
			local tone = Theme.tone(theme, toneName or T.tone or "Primary")
			local outline = Kit.outlineColor(theme)
			look.face = WHITE
			look.alpha = 0
			look.gradient = ColorSequence.new({
				ColorSequenceKeypoint.new(0, tone.Top:Lerp(WHITE, 0.08)),
				ColorSequenceKeypoint.new(0.55, tone.Mid),
				ColorSequenceKeypoint.new(1, tone.Bottom),
			})
			look.stroke = outline
			look.strokeAlpha = if fam.Outline then 0 else 1
			look.highlight = 0.6
			look.ink = WHITE
			look.muted = WHITE:Lerp(tone.Top, 0.25)
			look.inkStroke = tone.Stroke or tone.Deep or outline
			look.inkStrokeWidth = 2.5
			look.inkStrokeAlpha = 0
			look.accent = WHITE
			look.lip = tone.Edge
			look.track = tone.Deep
			look.trackAlpha = 0
			look.light = false
		elseif kind == "Toy" or kind == "Paper" then
			local face = if fam.Light then c.Panel else look.face
			look.face = face
			if fam.Light then
				look.ink = c.PanelText or look.ink
				look.muted = c.PanelMuted or look.muted
			else
				-- dark-surface families keep the cream / parchment plate, so the
				-- ink stays dark (the panel text is light, for dark windows):
				-- the family's ink, kept readable on the plate
				local Oklch = Theme.Oklch
				look.ink = Oklch.readable(c.Ink or c.TextDark or look.ink, face, 7)
				look.muted = Oklch.readable(look.ink:Lerp(face, 0.4), face, 4.5)
			end
			look.stroke = Kit.outlineColor(theme)
			look.strokeAlpha = if fam.Outline then 0 else 1
			look.lip = face:Lerp(look.stroke, 0.3)
			look.track = face:Lerp(look.stroke, 0.18)
			if kind == "Paper" then
				look.trim = c.Accent
				look.accent = c.AccentText or c.Accent
				look.gradient = ColorSequence.new(WHITE, Color3.fromRGB(236, 230, 216))
			end
			if not fam.Light then
				-- (AccentText is tuned for the dark glass: on the cream plate the
				-- accent goes deeper until it reads)
				look.accent = Theme.Oklch.readable(c.Accent, face, 3)
			end
		elseif kind == "Fade" and fam.Light then
			-- the Fade plate is the window panel: panel ink on light families
			look.ink = c.PanelText or look.ink
			look.muted = c.PanelMuted or look.muted
		elseif kind == "Tactical" or kind == "Neon" then
			look.accent = c.Trim or look.accent
			look.glow = c.Trim or look.glow
			look.stroke = if kind == "Neon" then (c.Trim or look.stroke) else look.stroke
			look.inkStroke = if kind == "Neon" then (c.Trim or look.inkStroke) else look.inkStroke
		elseif kind == "Solid" and fam.Outline and (fam.Outline.Color == "gold" or fam.Outline.Color == "accent") then
			look.stroke = c.Trim
			look.strokeAlpha = 0.3
			look.strokeWidth = 1
		elseif kind == "Arcade" then
			look.stroke = Kit.outlineColor(theme)
			look.inkStroke = look.stroke
		end
	end
	return look :: Look
end

-- A Computed Look for the current theme.
function P.look(scope: Scope, T: { [string]: any }, toneName: any?): any
	return scope:Computed(function(use)
		return P.lookFor(use(Theme.current), T, use(toneName))
	end)
end

-- Corner radius for a kit (relative to the theme's radii).
function P.radiusFor(theme: any, T: { [string]: any }, override: any?): UDim
	if typeof(override) == "UDim" then
		return override
	elseif typeof(override) == "number" then
		return UDim.new(0, override)
	elseif override == "Full" or override == "Pill" then
		if T.plate == "Pixel" or T.corner == "Square" then
			return UDim.new(0, 0)
		elseif T.corner == "Sharp" then
			return UDim.new(0, math.min(theme.Radius.S, 4))
		end
		return UDim.new(0.5, 0)
	end
	local corner = override or T.corner or "Theme"
	if T.plate == "Pixel" or corner == "Square" then
		return UDim.new(0, 0)
	elseif corner == "Sharp" then
		return UDim.new(0, math.min(theme.Radius.S, 4))
	elseif corner == "Round" then
		return UDim.new(0, math.max(theme.Radius.L, 14))
	elseif corner == "Soft" then
		return UDim.new(0, theme.Radius.M)
	elseif corner == "Small" then
		return UDim.new(0, theme.Radius.S)
	end
	return UDim.new(0, theme.Radius.M)
end

function P.corner(scope: Scope, T: { [string]: any }, override: any?): UICorner
	return scope:New "UICorner" {
		CornerRadius = scope:Computed(function(use)
			return P.radiusFor(use(Theme.current), T, use(override))
		end),
	}
end

--------------------------------------------------------------------------
-- Idle loops
--------------------------------------------------------------------------

--[[
	A value that eases between From and To forever (period seconds per
	round trip), driven by a tween on a NumberValue under `host`, so it
	pauses while `host` can't be seen and rests at `Rest` (default From)
	with reduced motion. Returns a Fusion Value.
]]
function P.loop(scope: Scope, host: Instance, opts: { [string]: any }): any
	local from, to = opts.From or 0, opts.To or 1
	local rest = if opts.Rest ~= nil then opts.Rest else from
	local state = opts.Into or scope:Value(rest)
	state:set(rest)
	local carrier = Instance.new("NumberValue")
	carrier.Name = opts.Name or "IdleLoop"
	carrier.Value = from
	carrier.Parent = host
	local connection = carrier:GetPropertyChangedSignal("Value"):Connect(function()
		state:set(carrier.Value)
	end)
	local tween = TweenService:Create(
		carrier,
		TweenInfo.new((opts.Period or 2) / 2, opts.Easing or Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true, opts.Delay or 0),
		{ Value = to }
	)
	tween:Play()
	local untrack = Motion.track(tween, carrier, function()
		carrier.Value = rest
		state:set(rest)
	end)
	table.insert(scope, function()
		untrack()
		tween:Cancel()
		connection:Disconnect()
		carrier:Destroy()
	end)
	return state
end

--[[
	A Value that eases 0 -> 1 -> 0 while `condition` (a state) is true: a
	tracked loop on `host`, so it pauses while hidden; with reduced motion it
	rests at 0.4 (a steady cue instead of flashing). 0 while the condition is
	false. `period` (number or state) is seconds per beat.
]]
function P.pulseWhile(scope: Scope, host: Instance, condition: any, period: any): any
	local value = scope:Value(0)
	local alive = true
	local stop: (() -> ())? = nil
	local function update(on: boolean)
		if not alive then
			return
		end
		if on and stop == nil then
			local carrier = Instance.new("NumberValue")
			carrier.Name = "Pulse"
			carrier.Parent = host
			local connection = carrier:GetPropertyChangedSignal("Value"):Connect(function()
				value:set(carrier.Value)
			end)
			local tween = TweenService:Create(
				carrier,
				TweenInfo.new((peek(period) or 0.8) / 2, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true),
				{ Value = 1 }
			)
			tween:Play()
			local untrack = Motion.track(tween, carrier, function()
				carrier.Value = 0.4
				value:set(0.4)
			end)
			stop = function()
				untrack()
				tween:Cancel()
				connection:Disconnect()
				carrier:Destroy()
				value:set(0)
			end
		elseif not on and stop then
			stop()
			stop = nil
		end
	end
	task.defer(function()
		update(peek(condition) == true)
	end)
	Kit.watch(scope, condition, function(on)
		update(on == true)
	end)
	-- a new beat speed restarts the loop
	Kit.watch(scope, period, function()
		if stop then
			stop()
			stop = nil
			update(peek(condition) == true)
		end
	end)
	table.insert(scope, function()
		alive = false
		if stop then
			stop()
			stop = nil
		end
	end)
	return value
end

-- A tracked looping tween on an instance's properties. Returns nothing; it's
-- cleaned up with the scope. `reset` puts the target at rest (reduced motion).
function P.tweenLoop(scope: Scope, target: Instance, info: TweenInfo, goals: { [string]: any }, reset: (() -> ())?)
	local tween = TweenService:Create(target, info, goals)
	tween:Play()
	local untrack = Motion.track(tween, target, reset)
	table.insert(scope, function()
		untrack()
		tween:Cancel()
	end)
end

-- The kit's idle motion on a plate face / icon. Only call it for the one or
-- two things per element that deserve it.
function P.idle(scope: Scope, T: { [string]: any }, gui: GuiObject, what: string?)
	if Theme.lite == true then
		return
	end
	local kind = T.idle
	if kind == "Shine" and what ~= "Icon" then
		table.insert(scope, Motion.shine(gui, { Interval = 5.5, Duration = 0.9, Transparency = 0.78, Width = 0.12, ZIndex = gui.ZIndex + 1 }))
	elseif kind == "Bob" and what == "Icon" then
		table.insert(scope, Motion.bob(gui, 3, 2.6))
	end
end

--------------------------------------------------------------------------
-- Plates
--------------------------------------------------------------------------

-- L-shaped corner marks (tactical framing).
function P.brackets(scope: Scope, props: { [string]: any }): { Instance }
	local color = props.Color
	local thick = props.Thickness or 2
	local arm = props.Arm or 10
	local inset = props.Inset or 0
	local z = props.ZIndex or 3
	local corners = props.Corners or { "TL", "TR", "BL", "BR" }
	local out = {}
	for _, corner in corners do
		local right = string.sub(corner, 2, 2) == "R"
		local bottom = string.sub(corner, 1, 1) == "B"
		local ax, ay = if right then 1 else 0, if bottom then 1 else 0
		local px = if right then -inset else inset
		local py = if bottom then -inset else inset
		table.insert(out, scope:New "Frame" {
			Name = `Bracket{corner}`,
			AnchorPoint = Vector2.new(ax, ay),
			Position = UDim2.new(ax, px, ay, py),
			Size = UDim2.fromOffset(arm, thick),
			BackgroundColor3 = color,
			BorderSizePixel = 0,
			ZIndex = z,
		})
		table.insert(out, scope:New "Frame" {
			Name = `Bracket{corner}V`,
			AnchorPoint = Vector2.new(ax, ay),
			Position = UDim2.new(ax, px, ay, py),
			Size = UDim2.fromOffset(thick, arm),
			BackgroundColor3 = color,
			BorderSizePixel = 0,
			ZIndex = z,
		})
	end
	return out
end

-- A notched pixel border (4 bars that stop short of the corners).
local function pixelBorder(scope: Scope, color: any, width: number, z: number): { Instance }
	local out = {}
	for _, side in { "Top", "Bottom", "Left", "Right" } do
		local horizontal = side == "Top" or side == "Bottom"
		table.insert(out, scope:New "Frame" {
			Name = `Pixel{side}`,
			AnchorPoint = Vector2.new(if side == "Right" then 1 else 0, if side == "Bottom" then 1 else 0),
			Position = UDim2.new(if side == "Right" then 1 else 0, if horizontal then width else 0, if side == "Bottom" then 1 else 0, if horizontal then 0 else width),
			Size = if horizontal then UDim2.new(1, -width * 2, 0, width) else UDim2.new(0, width, 1, -width * 2),
			BackgroundColor3 = color,
			BorderSizePixel = 0,
			ZIndex = z,
		})
	end
	return out
end

-- CRT scan lines: one frame, a banded transparency gradient.
local function scanlines(scope: Scope, z: number): Frame
	local keys = { NumberSequenceKeypoint.new(0, 1) }
	for band = 1, 9 do
		local at = band / 10
		table.insert(keys, NumberSequenceKeypoint.new(at - 0.012, 1))
		table.insert(keys, NumberSequenceKeypoint.new(at - 0.011, 0.86))
		table.insert(keys, NumberSequenceKeypoint.new(at, 1))
	end
	-- NumberSequence allows at most 20 keypoints
	while #keys > 19 do
		table.remove(keys)
	end
	table.insert(keys, NumberSequenceKeypoint.new(1, 1))
	return scope:New "Frame" {
		Name = "Scanlines",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = BLACK,
		BorderSizePixel = 0,
		ZIndex = z,
		[Children] = scope:New "UIGradient" { Rotation = 90, Transparency = NumberSequence.new(keys) },
	}
end

export type PlateOptions = {
	Name: string?,
	Size: any?,
	Position: any?,
	AnchorPoint: Vector2?,
	ZIndex: number?,
	Visible: any?,
	Radius: any?, -- "Pill" | "Round" | "Soft" | "Sharp" | "Square" | px | UDim (default: kit corner)
	Tone: any?, -- Arcade face tone
	Edge: string?, -- where an accent bar sits: "Left" | "Right" | "Top" | "Bottom"
	Fade: string?, -- Fade plates: the side that stays solid ("Left" | "Right")
	Emphasis: any?, -- state: brighter rim / face (selected slots)
	Idle: boolean?, -- run the kit's plate idle (shine, breathe, scan)
	Shadow: boolean?, -- false: no shadow / lip
	Kind: string?, -- another plate kind than the kit's
	Transparency: any?, -- extra face transparency override
	Rotation: number?,
}

--[[
	A backing plate: a transparent container (sized like the element) holding
	the shadow, the 3D lip and the face with its kit trims. Put content next
	to it with a higher ZIndex.
]]
function P.plate(scope: Scope, T: { [string]: any }, opts: PlateOptions?): Frame
	local o: any = opts or {}
	local kind = o.Kind or T.plate or "Glass"
	local plateT = if o.Kind then table.clone(T) else T
	if o.Kind then
		plateT.plate = o.Kind
	end
	local look = P.look(scope, plateT, o.Tone)
	local radius = scope:Computed(function(use)
		return P.radiusFor(use(Theme.current), plateT, use(o.Radius))
	end)
	local emphasis = o.Emphasis
	local lite = Theme.lite == true
	local breath: any = nil

	local faceChildren: { any } = {
		scope:New "UICorner" { CornerRadius = radius },
		scope:New "UIGradient" {
			Rotation = if kind == "Fade" then (if o.Fade == "Right" then 180 else 0) else 90,
			Color = scope:Computed(function(use)
				return use(look).gradient
			end),
			Transparency = if kind == "Fade"
				then NumberSequence.new({
					NumberSequenceKeypoint.new(0, 0.1),
					NumberSequenceKeypoint.new(0.55, 0.55),
					NumberSequenceKeypoint.new(1, 1),
				})
				else NumberSequence.new(0),
		},
	}
	if kind ~= "Pixel" and kind ~= "Clear" and kind ~= "Fade" then
		table.insert(faceChildren, scope:New "UIStroke" {
			ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
			LineJoinMode = Enum.LineJoinMode.Round,
			Color = scope:Computed(function(use)
				local l = use(look)
				if kind == "Glass" and use(emphasis) then
					return use(Theme.current).Color.Accent
				end
				return l.stroke
			end),
			Thickness = scope:Computed(function(use)
				local l = use(look)
				local w = l.strokeWidth
				if use(emphasis) then
					w = math.max(w, 2) + (if kind == "Neon" then 1 else 0.5)
				end
				return w
			end),
			Transparency = scope:Computed(function(use)
				local l = use(look)
				if use(emphasis) then
					return math.min(l.strokeAlpha, 0.1)
				end
				return if l.strokeWidth <= 0 then 1 else l.strokeAlpha
			end),
		})
	end
	-- inner gloss highlight
	if kind ~= "Clear" and kind ~= "Fade" and T.gloss ~= false then
		table.insert(faceChildren, Kit.unlessLite(scope, function(inner)
			return inner:New "Frame" {
				Name = "Highlight",
				BackgroundColor3 = WHITE,
				BorderSizePixel = 0,
				Position = UDim2.fromOffset(3, 2),
				Size = UDim2.new(1, -6, 0.44, 0),
				ZIndex = 2,
				Visible = inner:Computed(function(use)
					return use(look).highlight < 1
				end),
				[Children] = {
					inner:New "UICorner" { CornerRadius = radius },
					inner:New "UIGradient" {
						Rotation = 90,
						Transparency = inner:Computed(function(use)
							return NumberSequence.new(use(look).highlight, 1)
						end),
					},
				},
			}
		end))
	end
	-- kit accents
	if T.accent == "Bar" and o.Edge then
		local edge = o.Edge
		local horizontal = edge == "Top" or edge == "Bottom"
		table.insert(faceChildren, scope:New "Frame" {
			Name = "AccentBar",
			AnchorPoint = Vector2.new(if edge == "Right" then 1 else 0, if edge == "Bottom" then 1 else 0),
			Position = UDim2.fromScale(if edge == "Right" then 1 else 0, if edge == "Bottom" then 1 else 0),
			Size = if horizontal then UDim2.new(1, 0, 0, 3) else UDim2.new(0, 3, 1, 0),
			BackgroundColor3 = scope:Computed(function(use)
				return use(Theme.current).Color.Accent
			end),
			BorderSizePixel = 0,
			ZIndex = 3,
		})
	end
	if kind == "Tactical" then
		local accent = scope:Computed(function(use)
			return use(look).accent
		end)
		for _, part in P.brackets(scope, { Color = accent, Thickness = 2, Arm = 9, Corners = { "TL", "BR" }, ZIndex = 3 }) do
			table.insert(faceChildren, part)
		end
		if not lite then
			table.insert(faceChildren, Kit.decoration(scope, { Decoration = "Stripes", Color = WHITE, ZIndex = 1 }))
		end
		if o.Edge then
			local edge = o.Edge
			local horizontal = edge == "Top" or edge == "Bottom"
			table.insert(faceChildren, scope:New "Frame" {
				Name = "AccentBar",
				AnchorPoint = Vector2.new(if edge == "Right" then 1 else 0, if edge == "Bottom" then 1 else 0),
				Position = UDim2.fromScale(if edge == "Right" then 1 else 0, if edge == "Bottom" then 1 else 0),
				Size = if horizontal then UDim2.new(0.35, 0, 0, 2) else UDim2.new(0, 2, 0.5, 0),
				BackgroundColor3 = accent,
				BorderSizePixel = 0,
				ZIndex = 3,
			})
		end
	elseif kind == "Paper" then
		-- the inner rule (a gold / trim line inside the ink border)
		table.insert(faceChildren, scope:New "Frame" {
			Name = "Rule",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.new(1, -10, 1, -10),
			BackgroundTransparency = 1,
			ZIndex = 2,
			[Children] = {
				scope:New "UICorner" {
					CornerRadius = scope:Computed(function(use)
						local r = use(radius)
						return UDim.new(r.Scale, math.max(0, r.Offset - 4))
					end),
				},
				scope:New "UIStroke" {
					Color = scope:Computed(function(use)
						return use(look).trim or use(look).stroke
					end),
					Thickness = 1.5,
					Transparency = 0.25,
				},
			},
		})
		if not lite then
			table.insert(faceChildren, Kit.decoration(scope, { Decoration = "Grain", Light = true, ZIndex = 1 }))
		end
	elseif kind == "Pixel" then
		local border = scope:Computed(function(use)
			local l = use(look)
			if use(emphasis) then
				return use(Theme.current).Color.Accent
			end
			return l.stroke
		end)
		for _, part in pixelBorder(scope, border, 3, 3) do
			table.insert(faceChildren, part)
		end
		if not lite then
			table.insert(faceChildren, scanlines(scope, 2))
		end
	elseif kind == "Neon" and not lite then
		local strength = scope:Value(0.85)
		breath = if T.idle == "Breathe" and o.Idle then strength else nil
		local rimGlow = Kit.rimGlow(scope, {
			Color = scope:Computed(function(use)
				return use(look).glow
			end),
			Thickness = 18,
			Radius = scope:Computed(function(use)
				return use(radius)
			end),
			Strength = scope:Computed(function(use)
				local s = use(strength)
				return if use(emphasis) then 1 else s
			end),
			ZIndex = 0,
		})
		table.insert(faceChildren, rimGlow)
	end

	-- v2.4 style families: the family's HUD shadow (UIShadow on the face) and
	-- rim light replace the hard-shadow frame and the 9-slice shadow
	local familyFinish = T.family ~= nil and Theme.get().Family ~= nil
	if familyFinish and kind ~= "Clear" and kind ~= "Fade" then
		if o.Shadow ~= false then
			table.insert(faceChildren, Kit.shadows(scope, { Role = "Hud" }))
		end
		table.insert(faceChildren, Kit.rim(scope, {}))
	end

	local face = scope:New "Frame" {
		Name = "Face",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = scope:Computed(function(use)
			local l = use(look)
			if use(emphasis) and kind ~= "Toy" and kind ~= "Paper" and kind ~= "Arcade" then
				return l.face:Lerp(use(Theme.current).Color.SurfaceLight, 0.35)
			end
			return l.face
		end),
		BackgroundTransparency = scope:Computed(function(use)
			local l = use(look)
			local extra = use(o.Transparency)
			if typeof(extra) == "number" then
				return extra
			end
			if use(emphasis) and l.alpha < 1 then
				return math.max(0, l.alpha - 0.18)
			end
			return l.alpha
		end),
		BorderSizePixel = 0,
		ZIndex = 3,
		[Children] = faceChildren,
	}
	-- Neon: the rim glow breathes (a tracked loop hosted on the face, so it
	-- pauses while the plate is hidden)
	if breath then
		P.loop(scope, face, { From = 0.6, To = 1, Rest = 0.85, Period = 3.2, Into = breath, Name = "Breath" })
	end

	local under: { any } = {}
	if o.Shadow ~= false then
		local shadow = if familyFinish then "Family" else T.shadow
		if kind == "Toy" or kind == "Arcade" then
			table.insert(under, scope:New "Frame" {
				Name = "Lip",
				Position = UDim2.fromOffset(0, if kind == "Toy" then 5 else 4),
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = scope:Computed(function(use)
					return use(look).lip or use(look).face
				end),
				BorderSizePixel = 0,
				ZIndex = 2,
				[Children] = {
					scope:New "UICorner" { CornerRadius = radius },
					scope:New "UIStroke" {
						Color = scope:Computed(function(use)
							return use(look).stroke
						end),
						Thickness = scope:Computed(function(use)
							return use(look).strokeWidth
						end),
					},
				},
			})
		end
		if shadow == "Hard" or kind == "Pixel" then
			table.insert(under, scope:New "Frame" {
				Name = "HardShadow",
				Position = UDim2.fromOffset(if kind == "Pixel" then 5 else 2, if kind == "Pixel" then 5 else 8),
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = scope:Computed(function(use)
					return use(Theme.current).Color.Outline
				end),
				BackgroundTransparency = if kind == "Pixel" then 0.35 else 0.45,
				BorderSizePixel = 0,
				ZIndex = 1,
				[Children] = scope:New "UICorner" { CornerRadius = radius },
			})
		elseif shadow == "Soft" and kind ~= "Clear" and kind ~= "Fade" and not lite then
			table.insert(under, Kit.shadow(scope, { Spread = 14, Offset = Vector2.new(0, 5), ZIndex = 1 }))
		end
		-- Clear: no plate at all; its text carries a firm outline instead
		-- (P.lookFor gives Clear an ink stroke), which reads on a bright sky
		-- or a busy map without boxing anything in
	end

	local root = scope:New "Frame" {
		Name = o.Name or "Plate",
		BackgroundTransparency = 1,
		Size = o.Size or UDim2.fromScale(1, 1),
		Position = o.Position or UDim2.new(),
		AnchorPoint = o.AnchorPoint or Vector2.zero,
		Rotation = o.Rotation or 0,
		ZIndex = o.ZIndex or 1,
		Visible = if o.Visible == nil then true else o.Visible,
		[Children] = { under, face },
	}

	if o.Idle then
		if T.idle == "Shine" then
			P.idle(scope, T, face)
		elseif T.idle == "Scan" and not lite then
			local line = scope:New "Frame" {
				Name = "Scan",
				BackgroundColor3 = WHITE,
				BackgroundTransparency = 0.9,
				BorderSizePixel = 0,
				Size = UDim2.new(1, -4, 0, 2),
				Position = UDim2.new(0, 2, 0, 2),
				ZIndex = 4,
				Parent = face,
			}
			P.tweenLoop(
				scope,
				line,
				TweenInfo.new(1.4, Enum.EasingStyle.Linear, Enum.EasingDirection.In, -1, false, 3.2),
				{ Position = UDim2.new(0, 2, 1, -4) },
				function()
					line.Position = UDim2.new(0, 2, 0, 2)
				end
			)
		end
	end
	return root
end

--------------------------------------------------------------------------
-- Text and icons in kit style
--------------------------------------------------------------------------

--[[
	Kit text. props.Role: "Number" (kit number font, no case change),
	"Label" (kit label font and case, muted colour), "Title" (label font,
	full ink) or "Body". Colour, outline and font come from the kit and the
	look; anything in props overrides them.
]]
function P.text(scope: Scope, T: { [string]: any }, look: any, props: { [any]: any }): TextLabel
	local role = props.Role or "Body"
	local out = table.clone(props)
	out.Role = nil
	out.Muted = nil
	if out.Font == nil then
		out.Font = if role == "Number" then T.numberFont elseif role == "Label" or role == "Title" then T.labelFont else "Body"
	end
	if out.Case == nil and role ~= "Number" and T.case ~= nil then
		out.Case = T.case
	end
	if out.Color == nil then
		local muted = props.Muted or role == "Label"
		out.Color = scope:Computed(function(use)
			local l = use(look)
			return if muted then l.muted else l.ink
		end)
	end
	if out.Stroke == nil then
		out.Stroke = scope:Computed(function(use)
			local l = use(look)
			if l.inkStrokeWidth == 0 then
				return false
			end
			if l.inkStrokeWidth == nil then
				return nil
			end
			-- labels get a lighter outline than numbers
			return if role == "Number" then l.inkStrokeWidth else math.max(1, l.inkStrokeWidth * 0.7)
		end)
	end
	if out.StrokeColor == nil then
		out.StrokeColor = scope:Computed(function(use)
			local l = use(look)
			return if l.inkStrokeWidth == nil then nil else l.inkStroke
		end)
	end
	if out.StrokeTransparency == nil then
		out.StrokeTransparency = scope:Computed(function(use)
			local l = use(look)
			return if l.inkStrokeWidth == nil then nil else l.inkStrokeAlpha
		end)
	end
	-- v2.4 style families: no HUD text under the HUD minimum (Base.TYPE_MIN.Hud)
	if T.family ~= nil and typeof(out.TextSize) == "number" and out.TextSize < Theme.Base.TYPE_MIN.Hud then
		out.TextSize = Theme.Base.TYPE_MIN.Hud
	end
	return Text(scope, out)
end

--[[
	A kit icon: Gradient (glossy, outlined, shadowed), Flat (plain glyph),
	Glow (glyph with a soft halo) or Badge (glyph on a tinted disc).
]]
function P.icon(scope: Scope, T: { [string]: any }, props: { [any]: any }): Frame
	local treatment = props.Treatment or T.icon or "Gradient"
	local out = table.clone(props)
	out.Treatment = nil
	if treatment == "Flat" then
		out.Gradient = false
		out.Shadow = false
		if out.Stroke == nil then
			out.Stroke = false
		end
	elseif treatment == "Glow" then
		out.Gradient = false
		out.Shadow = false
		if out.Stroke == nil then
			out.Stroke = false
		end
		if Theme.lite ~= true then
			out.Glow = out.Glow or props.Color or true
		end
	elseif treatment == "Badge" then
		local size = props.Size or 32
		local disc = size
		out.Size = math.floor(size * 0.62)
		out.AnchorPoint = Vector2.new(0.5, 0.5)
		out.Position = UDim2.fromScale(0.5, 0.5)
		out.Gradient = false
		out.Shadow = false
		out.Stroke = false
		out.Color = WHITE
		out.Name = "Glyph"
		out.LayoutOrder = nil
		return scope:New "Frame" {
			Name = props.Name or "IconBadge",
			Size = UDim2.fromOffset(disc, disc),
			AnchorPoint = props.AnchorPoint or Vector2.zero,
			Position = props.Position or UDim2.new(),
			LayoutOrder = props.LayoutOrder or 0,
			ZIndex = props.ZIndex or 1,
			BackgroundColor3 = props.Color or Kit.themed(scope, function(theme)
				return theme.Color.Accent
			end),
			[Children] = {
				scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
				Icon(scope, out),
			},
		}
	else
		if out.Gradient == nil then
			out.Gradient = true
		end
		if out.Shadow == nil then
			out.Shadow = true
		end
	end
	return Icon(scope, out)
end

--------------------------------------------------------------------------
-- Numbers
--------------------------------------------------------------------------

-- A number state (numbers or numeric strings), 0 for anything else.
function P.number(scope: Scope, value: any): any
	return scope:Computed(function(use)
		local v = use(value)
		if typeof(v) == "string" then
			v = tonumber((string.gsub(v, "[^%d%.%-]", "")))
		end
		return if typeof(v) == "number" and v == v then v else 0
	end)
end

-- A rolling (spring) number shown with Format.number(n, format).
function P.rolling(scope: Scope, value: any, format: any?, speed: number?): (any, any)
	local target = P.number(scope, value)
	local spring = scope:Spring(target, speed or 10, 1)
	return scope:Computed(function(use)
		return Format.number(math.round(use(spring)), format)
	end), target
end

--------------------------------------------------------------------------
-- Rings and arcs
--------------------------------------------------------------------------

local HALF = NumberSequence.new({
	NumberSequenceKeypoint.new(0, 0),
	NumberSequenceKeypoint.new(0.5, 0),
	NumberSequenceKeypoint.new(0.502, 1),
	NumberSequenceKeypoint.new(1, 1),
})

--[[
	A smooth progress ring (or pie with Thickness >= Size / 2), clockwise
	from 12 o'clock. Two clipped half discs whose hard-edged gradients turn
	with the fraction; the hole is a disc in `Cutout` (the plate colour).
	props: Size, Thickness, Fraction (0..1 state), Color, Track, TrackAlpha,
	Cutout, CutoutAlpha, ZIndex, Name, Position, AnchorPoint, [Children]
	(content drawn in the middle), Visible.
]]
function P.ring(scope: Scope, props: { [any]: any }): Frame
	local size = props.Size or 64
	local thick = props.Thickness or math.floor(size * 0.14)
	local z = props.ZIndex or 2
	local fraction = scope:Computed(function(use)
		return math.clamp(tonumber(use(props.Fraction)) or 0, 0, 1)
	end)
	local color = props.Color or WHITE
	local function half(right: boolean): Frame
		return scope:New "Frame" {
			Name = if right then "RightHalf" else "LeftHalf",
			BackgroundTransparency = 1,
			ClipsDescendants = true,
			Position = UDim2.fromScale(if right then 0.5 else 0, 0),
			Size = UDim2.fromScale(0.5, 1),
			ZIndex = z + 1,
			[Children] = scope:New "Frame" {
				Name = "Disc",
				BackgroundColor3 = color,
				BorderSizePixel = 0,
				Position = UDim2.fromScale(if right then -1 else 0, 0),
				Size = UDim2.fromScale(2, 1),
				ZIndex = z + 1,
				Visible = scope:Computed(function(use)
					local f = use(fraction)
					return if right then f > 0.001 else f > 0.5
				end),
				[Children] = {
					scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
					scope:New "UIGradient" {
						Transparency = HALF,
						Rotation = scope:Computed(function(use)
							local f = use(fraction)
							if right then
								return math.clamp(f, 0, 0.5) * 360
							end
							return math.clamp(f, 0.5, 1) * 360
						end),
					},
				},
			},
		}
	end
	local hole = size - thick * 2
	return scope:New "Frame" {
		Name = props.Name or "Ring",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(size, size),
		Position = props.Position or UDim2.new(),
		AnchorPoint = props.AnchorPoint or Vector2.zero,
		LayoutOrder = props.LayoutOrder or 0,
		ZIndex = z,
		Visible = if props.Visible == nil then true else props.Visible,
		[Children] = {
			scope:New "Frame" {
				Name = "Track",
				BackgroundColor3 = props.Track or BLACK,
				BackgroundTransparency = props.TrackAlpha or 0.5,
				BorderSizePixel = 0,
				Size = UDim2.fromScale(1, 1),
				ZIndex = z,
				[Children] = scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
			},
			half(true),
			half(false),
			if hole > 0 and props.Cutout ~= false
				then scope:New "Frame" {
					Name = "Hole",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Size = UDim2.fromOffset(hole, hole),
					BackgroundColor3 = props.Cutout or BLACK,
					BackgroundTransparency = props.CutoutAlpha or 0,
					BorderSizePixel = 0,
					ZIndex = z + 2,
					[Children] = scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
				}
				else nil,
			scope:New "Frame" {
				Name = "Content",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				ZIndex = z + 3,
				[Children] = props[Children],
			},
		},
	}
end

-- The colour for a ring's hole: the plate face on light plates, else a
-- slightly lifted surface (opaque: the pie underneath must not show through).
function P.holeColor(scope: Scope, look: any): any
	return scope:Computed(function(use)
		local l = use(look)
		if l.light then
			return l.face
		end
		local theme = use(Theme.current)
		if l.kind == "Neon" then
			return l.face
		end
		return theme.Color.Surface:Lerp(theme.Color.SurfaceLight, 0.18)
	end)
end

--[[
	A segmented arc: Count short bars round a circle from Start (degrees,
	0 = 12 o'clock, clockwise) across Span degrees, lit up to Fraction (the
	last lit bar partly). props: Size, Count, Start, Span, Fraction, Color,
	Track, TrackAlpha, Length, Width, ZIndex, Name, Position, AnchorPoint.
]]
function P.ticks(scope: Scope, props: { [any]: any }): Frame
	local size = props.Size or 96
	local count = props.Count or 24
	local start = props.Start or 0
	local span = props.Span or 360
	local length = props.Length or math.floor(size * 0.12)
	local width = props.Width or math.max(3, math.floor(size * 0.04))
	local z = props.ZIndex or 2
	local radius = size / 2 - length / 2
	local fraction = scope:Computed(function(use)
		return math.clamp(tonumber(use(props.Fraction)) or 0, 0, 1)
	end)
	local bars = {}
	for i = 0, count - 1 do
		local step = if span >= 360 then span / count else span / math.max(1, count - 1)
		local angle = start + step * i
		local rad = math.rad(angle)
		local x, y = math.sin(rad) * radius, -math.cos(rad) * radius
		table.insert(bars, scope:New "Frame" {
			Name = `Tick{i + 1}`,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5, math.floor(x + 0.5), 0.5, math.floor(y + 0.5)),
			Size = UDim2.fromOffset(width, length),
			Rotation = angle,
			BorderSizePixel = 0,
			BackgroundColor3 = scope:Computed(function(use)
				local lit = math.clamp(use(fraction) * count - i, 0, 1)
				if lit > 0 then
					return use(props.Color) or WHITE
				end
				local ghost = tonumber(use(props.Ghost)) or 0
				if ghost * count - i > 0 then
					return WHITE
				end
				return use(props.Track) or BLACK
			end),
			BackgroundTransparency = scope:Computed(function(use)
				local lit = math.clamp(use(fraction) * count - i, 0, 1)
				if lit > 0 then
					return (1 - lit) * 0.6
				end
				local ghost = tonumber(use(props.Ghost)) or 0
				if ghost * count - i > 0 then
					return 0.35
				end
				return props.TrackAlpha or 0.55
			end),
			ZIndex = z,
			[Children] = scope:New "UICorner" { CornerRadius = UDim.new(0, math.floor(width / 2)) },
		})
	end
	return scope:New "Frame" {
		Name = props.Name or "Ticks",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(size, size),
		Position = props.Position or UDim2.new(),
		AnchorPoint = props.AnchorPoint or Vector2.zero,
		ZIndex = z,
		[Children] = bars,
	}
end

--------------------------------------------------------------------------
-- Bars
--------------------------------------------------------------------------

--[[
	A flat HUD bar with the juice: the fill springs, a pale ghost trails a
	loss (waits, then drains), a bright heal glow sweeps in on a gain, and
	below `Low` the fill pulses (tracked; a steady tint with reduced motion).
	props: Fraction (0..1 state), Size, Tone (name / state) or Color, Track,
	TrackAlpha, Radius, Ghost, Heal, Low, Segments (divider count), Gloss,
	ZIndex, Name, Position, AnchorPoint, LayoutOrder, Flip (fills from the right).
]]
function P.bar(scope: Scope, T: { [string]: any }, props: { [any]: any }): Frame
	local fraction = scope:Computed(function(use)
		return math.clamp(tonumber(use(props.Fraction)) or 0, 0, 1)
	end)
	local fill = scope:Spring(fraction, props.Speed or 18, 1)
	local ghostGoal = scope:Value(peek(fraction))
	local ghost = scope:Spring(ghostGoal, 6, 1)
	local healGoal = scope:Value(1)
	local heal = scope:Spring(healGoal, 7, 1)
	local token = 0
	local last = peek(fraction)
	Kit.watch(scope, fraction, function(now)
		token += 1
		if now >= peek(ghostGoal) then
			ghostGoal:set(now)
		else
			local mine = token
			task.delay(0.35, function()
				if mine == token then
					ghostGoal:set(now)
				end
			end)
		end
		if now > last + 0.001 and props.Heal then
			heal:setPosition(0)
		end
		last = now
	end)
	local z = props.ZIndex or 2
	local flip = props.Flip == true
	local radius = props.Radius or "Full"
	local corner = function(s)
		return s:New "UICorner" {
			CornerRadius = s:Computed(function(use)
				return P.radiusFor(use(Theme.current), T, use(radius))
			end),
		}
	end
	local toneState = if props.Color == nil then Kit.tone(scope, props.Tone, "Green") else nil
	local fillColors = scope:Computed(function(use)
		local given = use(props.Color)
		if given then
			return ColorSequence.new(Color.lighten(given, 0.12), given)
		end
		local t = use(toneState)
		if T.gloss == false then
			return ColorSequence.new(t.Top:Lerp(t.Bottom, 0.3), t.Bottom)
		end
		return ColorSequence.new({
			ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.12)),
			ColorSequenceKeypoint.new(0.6, t.Top:Lerp(t.Bottom, 0.6)),
			ColorSequenceKeypoint.new(1, t.Bottom),
		})
	end)
	local anchorX = if flip then 1 else 0

	local fillFrame = scope:New "Frame" {
		Name = "Fill",
		AnchorPoint = Vector2.new(anchorX, 0),
		Position = UDim2.fromScale(anchorX, 0),
		Size = scope:Computed(function(use)
			return UDim2.fromScale(use(fill), 1)
		end),
		BackgroundColor3 = WHITE,
		BorderSizePixel = 0,
		ZIndex = z + 2,
		[Children] = {
			corner(scope),
			scope:New "UIGradient" { Rotation = 90, Color = fillColors },
			if props.Gloss ~= false and T.gloss ~= false
				then Kit.unlessLite(scope, function(inner)
					return inner:New "Frame" {
						Name = "Gloss",
						BackgroundColor3 = WHITE,
						BackgroundTransparency = 0.6,
						BorderSizePixel = 0,
						Position = UDim2.new(0, 2, 0, 1),
						Size = UDim2.new(1, -4, 0.38, 0),
						ZIndex = z + 3,
						[Children] = { corner(inner), inner:New "UIGradient" { Rotation = 90, Transparency = NumberSequence.new(0.2, 1) } },
					}
				end)
				else nil,
		},
	}

	local segments = {}
	local count = props.Segments or 0
	for i = 1, count - 1 do
		table.insert(segments, scope:New "Frame" {
			Name = "Segment",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(i / count, 0),
			Size = UDim2.new(0, props.SegmentWidth or 2, 1, 0),
			BackgroundColor3 = props.SegmentColor or scope:Computed(function(use)
				return use(Theme.current).Color.Outline
			end),
			BackgroundTransparency = props.SegmentAlpha or 0.35,
			BorderSizePixel = 0,
			ZIndex = z + 5,
		})
	end

	local root = scope:New "Frame" {
		Name = props.Name or "Bar",
		Size = props.Size or UDim2.fromOffset(240, 10),
		Position = props.Position or UDim2.new(),
		AnchorPoint = props.AnchorPoint or Vector2.zero,
		LayoutOrder = props.LayoutOrder or 0,
		BackgroundColor3 = props.Track or scope:Computed(function(use)
			return use(Theme.current).Color.Track
		end),
		BackgroundTransparency = props.TrackAlpha or 0.2,
		BorderSizePixel = 0,
		ZIndex = z,
		[Children] = {
			corner(scope),
			if props.Stroke
				then scope:New "UIStroke" {
					Color = props.Stroke,
					Thickness = props.StrokeWidth or 1.5,
					Transparency = props.StrokeAlpha or 0,
				}
				else nil,
			if props.Ghost ~= false
				then scope:New "Frame" {
					Name = "Ghost",
					AnchorPoint = Vector2.new(anchorX, 0),
					Position = UDim2.fromScale(anchorX, 0),
					Size = scope:Computed(function(use)
						return UDim2.fromScale(math.max(use(ghost), use(fill)), 1)
					end),
					BackgroundColor3 = WHITE,
					BackgroundTransparency = 0.3,
					BorderSizePixel = 0,
					ZIndex = z + 1,
					[Children] = corner(scope),
				}
				else nil,
			fillFrame,
			if props.Heal
				then scope:New "Frame" {
					Name = "HealGlow",
					AnchorPoint = Vector2.new(anchorX, 0),
					Position = UDim2.fromScale(anchorX, 0),
					Size = scope:Computed(function(use)
						return UDim2.fromScale(use(fill), 1)
					end),
					BackgroundColor3 = scope:Computed(function(use)
						local theme = use(Theme.current)
						return (theme.Color.Good or WHITE):Lerp(WHITE, 0.55)
					end),
					BackgroundTransparency = scope:Computed(function(use)
						return 0.25 + 0.75 * math.clamp(use(heal), 0, 1)
					end),
					BorderSizePixel = 0,
					ZIndex = z + 4,
					[Children] = corner(scope),
				}
				else nil,
			segments,
			props[Children],
		},
	}

	if props.Low then
		local flash = scope:New "Frame" {
			Name = "LowFlash",
			Size = UDim2.fromScale(1, 1),
			BackgroundColor3 = scope:Computed(function(use)
				return use(Theme.current).Color.Bad
			end),
			BackgroundTransparency = 1,
			BorderSizePixel = 0,
			ZIndex = z + 4,
			Parent = fillFrame,
			[Children] = corner(scope),
		}
		local stop: (() -> ())? = nil
		local function update(value: number)
			local low = value > 0 and value <= props.Low
			if low and stop == nil then
				local loop = TweenService:Create(
					flash,
					TweenInfo.new(0.4, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true),
					{ BackgroundTransparency = 0.3 }
				)
				loop:Play()
				local untrack = Motion.track(loop, flash, function()
					flash.BackgroundTransparency = 0.55
				end)
				stop = function()
					untrack()
					loop:Cancel()
					flash.BackgroundTransparency = 1
				end
			elseif not low and stop then
				stop()
				stop = nil
			end
		end
		update(peek(fraction))
		Kit.watch(scope, fraction, update)
		table.insert(scope, function()
			if stop then
				stop()
			end
		end)
	end
	return root
end

-- Health colour by fraction, from the theme (colour-blind palettes too).
function P.healthTone(scope: Scope, fraction: any, low: number?): any
	local lowAt = low or 0.3
	return scope:Computed(function(use)
		local f = use(fraction)
		return if f > 0.5 then "Green" elseif f > lowAt then "Yellow" else "Red"
	end)
end

--------------------------------------------------------------------------
-- Pressable cells (menus, tiles, clickers)
--------------------------------------------------------------------------

--[[
	A transparent TextButton with the house feel: hover grows it, a press
	squashes it (critically damped) and the release overshoots, a click
	sound, a gamepad focus ring and, on phones, a 44 px touch pad.
	props: Name, Size, Position, AnchorPoint, LayoutOrder, ZIndex, OnClick,
	Radius (focus ring), HoverScale, PressScale, Sound, Action (true: marks
	it BloxUIAction like Blueprint buttons), Scale (extra state multiplier),
	OnPress / OnRelease (callbacks), [Children].
]]
function P.button(scope: Scope, props: { [any]: any }): TextButton
	local hovering = scope:Value(false)
	local pressing = scope:Value(false)
	local focused = scope:Value(false)
	local goal = scope:Computed(function(use)
		local motion = use(Theme.current).Motion
		if use(pressing) then
			return props.PressScale or motion.PressScale
		elseif use(hovering) or use(focused) then
			return props.HoverScale or motion.HoverScale
		end
		return 1
	end)
	local scale = scope:Spring(
		goal,
		scope:Computed(function(use)
			local motion = use(Theme.current).Motion
			return if use(pressing) then motion.PressSpeed else motion.ReleaseSpeed
		end),
		scope:Computed(function(use)
			local motion = use(Theme.current).Motion
			return if use(pressing) then motion.PressDamping else motion.ReleaseDamping
		end)
	)
	local function activate()
		if props.Sound ~= "" then
			Sound.play(props.Sound or "Click")
		end
		if props.OnClick then
			props.OnClick()
		end
	end
	local function down()
		pressing:set(true)
		if props.OnPress then
			props.OnPress()
		end
	end
	local function up()
		pressing:set(false)
		if props.OnRelease then
			props.OnRelease()
		end
	end
	local button = scope:New "TextButton" {
		Name = props.Name or "Cell",
		Text = "",
		AutoButtonColor = false,
		BackgroundTransparency = 1,
		SelectionImageObject = Kit.NoSelection,
		Size = props.Size or UDim2.fromOffset(80, 80),
		Position = props.Position or UDim2.new(),
		AnchorPoint = props.AnchorPoint or Vector2.zero,
		LayoutOrder = props.LayoutOrder or 0,
		ZIndex = props.ZIndex or 1,
		Visible = if props.Visible == nil then true else props.Visible,
		[OnEvent "Activated"] = activate,
		[OnEvent "MouseEnter"] = function()
			hovering:set(true)
		end,
		[OnEvent "MouseLeave"] = function()
			hovering:set(false)
			if peek(pressing) then
				up()
			end
		end,
		[OnEvent "MouseButton1Down"] = down,
		[OnEvent "MouseButton1Up"] = up,
		[OnEvent "SelectionGained"] = function()
			focused:set(true)
		end,
		[OnEvent "SelectionLost"] = function()
			focused:set(false)
		end,
		[Children] = {
			scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					return use(scale) * (use(props.Scale) or 1)
				end),
			},
			Kit.focusRing(scope, { Focused = focused, Radius = props.Radius or "M" }),
			Kit.touchPad(scope, props.Size or UDim2.fromOffset(80, 80), {
				Activated = activate,
				MouseButton1Down = down,
				MouseButton1Up = up,
			}),
			props[Children],
		},
	}
	if props.Action then
		button:SetAttribute("BloxUIAction", true)
	end
	return button
end

return P
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="110">
            <Properties>
              <string name="Name">HudXP</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	HudXP: level and progress to the next level, in one of four looks.

	scope:HudXP {
		Level = level,            -- number or state
		Progress = progress,      -- 0..1 (number or state)
		Style = "Bar",            -- "Bar" (bar + round level badge; the original)
		                          -- | "Thin" (a hairline bar, the level above it)
		                          -- | "Ring" (a level badge inside a progress ring)
		                          -- | "Chunky" (a segmented bar with a level shield)
		Kit = "Arcade",           -- HudKits name / tokens
		Compact = false,
		Tone = "Blue",            -- the fill
	}

	The fill springs, the level pops when it goes up, and the root has a
	fixed size (and a HudSize attribute) for HUD layouts.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local P = require(script.Parent.HudParts)
local ProgressBar = require(script.Parent.ProgressBar)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

local function state(scope: Scope, props: { [any]: any })
	local level = P.number(scope, props.Level or 1)
	local progress = scope:Computed(function(use)
		return math.clamp(tonumber(use(props.Progress)) or 0, 0, 1)
	end)
	local popGoal = scope:Value(1)
	local pop = scope:Spring(popGoal, 28, 0.4)
	local before = peek(level)
	Kit.watch(scope, level, function(now)
		if now > before then
			pop:setPosition(1.35)
		end
		before = now
	end)
	local fill = scope:Spring(progress, 14, 1)
	return {
		level = level,
		progress = progress,
		fill = fill,
		pop = pop,
		levelText = scope:Computed(function(use)
			return tostring(math.floor(use(level)))
		end),
		-- the percentage rolls with the fill
		percent = scope:Computed(function(use)
			return Format.percent(math.clamp(use(fill), 0, 1))
		end),
	}
end

local function frame(scope: Scope, props: { [any]: any }, size: Vector2, children: { any }): Frame
	local r = Kit.root(props, { Name = "XPBar" })
	r.BackgroundTransparency = 1
	r[Children] = { children, props[Children] }
	local f = scope:New "Frame" (r) :: Frame
	P.footprint(f, size)
	return f
end

local BUILD: { [string]: (Scope, { [any]: any }, { [string]: any }) -> Frame } = {}

-- The original: a glossy bar with a round yellow level badge on its left.
function BUILD.Bar(scope, props, T)
	local s = state(scope, props)
	local barW = if props.Compact then 420 else 480
	local badge = 60
	local size = Vector2.new(barW + badge - 12, badge)
	local yellow = Kit.tone(scope, "Yellow")
	return frame(scope, props, size, {
		ProgressBar(scope, {
			Name = "Bar",
			Value = s.progress,
			Max = 1,
			Tone = props.Tone or "Blue",
			Ghost = false,
			Shine = true,
			Size = UDim2.fromOffset(barW, 30),
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.fromScale(1, 0.5),
			Label = s.percent,
		}),
		scope:New "Frame" {
			Name = "Level",
			Size = UDim2.fromOffset(badge, badge),
			BackgroundColor3 = WHITE,
			ZIndex = 4,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = scope:Computed(function(use)
						local t = use(yellow)
						return ColorSequence.new({
							ColorSequenceKeypoint.new(0, t.Top),
							ColorSequenceKeypoint.new(0.55, t.Bottom),
							ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.12)),
						})
					end),
				}),
				Kit.stroke(scope, {
					Color = scope:Computed(function(use)
						return use(yellow).Edge
					end),
					Thickness = 2,
				}),
				scope:New "Frame" {
					Name = "Highlight",
					BackgroundColor3 = WHITE,
					BackgroundTransparency = 0.72,
					Position = UDim2.fromOffset(4, 3),
					Size = UDim2.new(1, -8, 0.4, 0),
					ZIndex = 4,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0, 1) }),
					},
				},
				Text(scope, {
					Text = s.levelText,
					TextSize = 30,
					Font = "Display",
					Size = UDim2.new(1, -10, 0, 34),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = 30,
					StrokeColor = scope:Computed(function(use)
						return use(Theme.current).Tone.Yellow.Stroke or use(Theme.current).Color.Outline
					end),
					ZIndex = 6,
					[Children] = scope:New "UIScale" { Scale = s.pop },
				}),
			},
		},
	})
end

-- A hairline bar with the level in a small chip above its middle.
function BUILD.Thin(scope, props, T)
	local s = state(scope, props)
	local look = P.look(scope, T)
	local w = if props.Compact then 480 else 560
	local size = Vector2.new(w, 44)
	local chipW = 64
	return frame(scope, props, size, {
		P.bar(scope, T, {
			Name = "Bar",
			Fraction = s.progress,
			Tone = props.Tone or "Blue",
			Size = UDim2.new(1, 0, 0, 8),
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, 0, 1, -2),
			Track = scope:Computed(function(use)
				return use(look).track
			end),
			TrackAlpha = 0.25,
			Stroke = scope:Computed(function(use)
				return use(Theme.current).Color.Outline
			end),
			StrokeWidth = 1.5,
			StrokeAlpha = 0.4,
			Ghost = false,
			Radius = "Pill",
			ZIndex = 2,
		}),
		scope:New "Frame" {
			Name = "Chip",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5, 0),
			Size = UDim2.fromOffset(chipW, 28),
			ZIndex = 3,
			[Children] = {
				P.plate(scope, T, { Radius = "Pill", ZIndex = 3, Shadow = false }),
				P.text(scope, T, look, {
					Name = "Level",
					Role = "Number",
					Text = scope:Computed(function(use)
						return `LV {use(s.levelText)}`
					end),
					TextSize = 18,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Size = UDim2.new(1, -8, 1, -4),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = 18,
					Color = scope:Computed(function(use)
						return use(look).accent
					end),
					ZIndex = 5,
					[Children] = scope:New "UIScale" { Scale = s.pop },
				}),
			},
		},
		P.text(scope, T, look, {
			Name = "Percent",
			Role = "Label",
			Text = s.percent,
			TextSize = 15,
			XAlign = "Right",
			AnchorPoint = Vector2.new(1, 1),
			Position = UDim2.new(1, -2, 1, -14),
			ZIndex = 3,
		}),
	})
end

-- A level badge inside a progress ring, with the level caption beside it.
function BUILD.Ring(scope, props, T)
	local s = state(scope, props)
	local look = P.look(scope, T)
	local compact = props.Compact
	local ring = if compact then 72 else 80
	local size = Vector2.new(if compact then 206 else 224, ring)
	local tone = Kit.tone(scope, props.Tone or "Blue")
	return frame(scope, props, size, {
		P.plate(scope, T, {
			Name = "Tail",
			Radius = "Pill",
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.fromScale(1, 0.5),
			Size = UDim2.fromOffset(size.X - ring / 2, math.floor(ring * 0.62)),
			ZIndex = 1,
		}),
		scope:New "Frame" {
			Name = "Badge",
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(ring, ring),
			ZIndex = 3,
			[Children] = {
				P.plate(scope, T, { Radius = "Full", ZIndex = 3, Idle = true }),
				P.ring(scope, {
					Name = "Progress",
					Size = ring - 10,
					Thickness = if compact then 6 else 7,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Fraction = s.fill,
					Color = scope:Computed(function(use)
						return use(tone).Top
					end),
					Track = scope:Computed(function(use)
						return use(look).track
					end),
					TrackAlpha = 0.2,
					Cutout = P.holeColor(scope, look),
					ZIndex = 5,
					[Children] = {
						P.text(scope, T, look, {
							Name = "Level",
							Role = "Number",
							Text = s.levelText,
							TextSize = if compact then 26 else 30,
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.56),
							Size = UDim2.new(1, -22, 0, 30),
							Scaled = true,
							Wrapped = false,
							MaxTextSize = if compact then 26 else 30,
							Color = scope:Computed(function(use)
								local l = use(look)
								return if l.light then l.ink else use(Theme.current).Color.Text
							end),
							ZIndex = 9,
							[Children] = scope:New "UIScale" { Scale = s.pop },
						}),
						P.text(scope, T, look, {
							Name = "Tag",
							Role = "Label",
							Text = "LV",
							TextSize = 11,
							AnchorPoint = Vector2.new(0.5, 0),
							Position = UDim2.fromScale(0.5, 0.2),
							Stroke = false,
							Color = scope:Computed(function(use)
								return use(tone).Top
							end),
							ZIndex = 9,
						}),
					},
				}),
			},
		},
		P.text(scope, T, look, {
			Name = "Caption",
			Role = "Title",
			Text = scope:Computed(function(use)
				return `Level {use(s.levelText)}`
			end),
			TextSize = if compact then 18 else 20,
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, ring + 8, 0.5, 2),
			ZIndex = 3,
		}),
		P.text(scope, T, look, {
			Name = "Percent",
			Role = "Label",
			Text = scope:Computed(function(use)
				return `{use(s.percent)} to next`
			end),
			TextSize = if compact then 13 else 14,
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 0),
			Position = UDim2.new(0, ring + 8, 0.5, 2),
			ZIndex = 3,
		}),
	})
end

-- A chunky segmented bar with a shield badge holding the level.
function BUILD.Chunky(scope, props, T)
	local s = state(scope, props)
	local look = P.look(scope, T)
	local compact = props.Compact
	local size = Vector2.new(if compact then 470 else 540, if compact then 62 else 68)
	local shieldW = if compact then 56 else 62
	local barH = if compact then 30 else 34
	local tone = Kit.tone(scope, props.Tone or "Blue")
	local shieldTone = Kit.tone(scope, "Yellow")
	local lite = Theme.lite == true
	-- the shield: a rounded top block plus a diamond point, drawn twice (outline, then fill)
	local function shieldLayer(grow: number, color: any, z: number)
		local topH = math.floor(size.Y * 0.62)
		local diamond = math.floor(shieldW * 0.72)
		return {
			scope:New "Frame" {
				Name = "ShieldTop",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, 2 - grow),
				Size = UDim2.fromOffset(shieldW + grow * 2, topH + grow * 2),
				BackgroundColor3 = color,
				BorderSizePixel = 0,
				ZIndex = z,
				[Children] = scope:New "UICorner" {
					CornerRadius = scope:Computed(function(use)
						local r = P.radiusFor(use(Theme.current), T, "Soft")
						return UDim.new(0, math.min(12, r.Offset + grow))
					end),
				},
			},
			scope:New "Frame" {
				Name = "ShieldPoint",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 0, 0, 2 + topH - math.floor(diamond * 0.28)),
				Size = UDim2.fromOffset(diamond + grow * 2, diamond + grow * 2),
				Rotation = 45,
				BackgroundColor3 = color,
				BorderSizePixel = 0,
				ZIndex = z,
				[Children] = scope:New "UICorner" { CornerRadius = UDim.new(0, 4 + grow) },
			},
		}
	end
	local outline = scope:Computed(function(use)
		local theme = use(Theme.current)
		return if use(look).kind == "Paper" then use(look).stroke else theme.Color.Outline
	end)
	local faceColor = scope:Computed(function(use)
		return use(shieldTone).Bottom
	end)
	return frame(scope, props, size, {
		P.plate(scope, T, {
			Name = "BarPlate",
			Radius = "Soft",
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.fromScale(1, 0.5),
			Size = UDim2.fromOffset(size.X - shieldW / 2, barH + 16),
			ZIndex = 1,
		}),
		P.bar(scope, T, {
			Name = "Bar",
			Fraction = s.progress,
			Tone = props.Tone or "Blue",
			Size = UDim2.fromOffset(size.X - shieldW - 22, barH),
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -10, 0.5, 0),
			Track = scope:Computed(function(use)
				return use(look).track
			end),
			TrackAlpha = 0.1,
			Stroke = outline,
			StrokeWidth = 2,
			Radius = "Soft",
			Segments = 10,
			SegmentWidth = 3,
			SegmentColor = outline,
			SegmentAlpha = 0.45,
			Ghost = false,
			Heal = true,
			ZIndex = 3,
			[Children] = P.text(scope, T, look, {
				Name = "Percent",
				Role = "Number",
				Text = s.percent,
				TextSize = if compact then 18 else 20,
				XAlign = "Right",
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -10, 0.5, 0),
				Color = WHITE,
				Stroke = 2,
				StrokeColor = scope:Computed(function(use)
					return use(tone).Stroke or use(tone).Edge
				end),
				StrokeTransparency = 0,
				ZIndex = 12,
			}),
		}),
		scope:New "Frame" {
			Name = "Shield",
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(shieldW, size.Y),
			ZIndex = 5,
			[Children] = {
				shieldLayer(3, outline, 5),
				shieldLayer(0, faceColor, 6),
				if not lite
					then scope:New "Frame" {
						Name = "Gloss",
						AnchorPoint = Vector2.new(0.5, 0),
						Position = UDim2.new(0.5, 0, 0, 5),
						Size = UDim2.fromOffset(shieldW - 10, math.floor(size.Y * 0.26)),
						BackgroundColor3 = WHITE,
						BackgroundTransparency = 0.6,
						BorderSizePixel = 0,
						ZIndex = 7,
						[Children] = {
							scope:New "UICorner" { CornerRadius = UDim.new(0, 8) },
							scope:New "UIGradient" { Rotation = 90, Transparency = NumberSequence.new(0.1, 1) },
						},
					}
					else nil,
				P.text(scope, T, look, {
					Name = "Level",
					Role = "Number",
					Text = s.levelText,
					TextSize = if compact then 26 else 30,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 0, 0.42, 0),
					Size = UDim2.fromOffset(shieldW - 8, 32),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = if compact then 26 else 30,
					Color = scope:Computed(function(use)
						return use(shieldTone).Text
					end),
					Stroke = 2.5,
					StrokeColor = scope:Computed(function(use)
						local t = use(shieldTone)
						return t.Stroke or t.Edge
					end),
					StrokeTransparency = 0,
					ZIndex = 9,
					[Children] = scope:New "UIScale" { Scale = s.pop },
				}),
			},
		},
	})
end

return function(scope: Scope, props: { [any]: any }): Frame
	local style = P.style("XPBar", props.Style)
	return BUILD[style](scope, props, P.tokens(props.Kit))
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="111">
            <Properties>
              <string name="Name">Icon</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Icon: a vector icon (Builder Icons), your own image, or an emoji.

	scope:Icon {
		Icon = "key",             -- icon name, alias, "rbxassetid://..." or emoji
		Size = 64,                -- number, Vector2 or UDim2
		Color = Color3.fromHex("FFD21F"),
		Filled = true,            -- false for the outline style (default: theme IconStyle)
		Stroke = true,            -- tinted outline (false / number)
		StrokeColor = nil,        -- default: a deep shade of Color (QUALITY rule 1)
		Gradient = true,          -- glossy top-to-bottom shading
		Shadow = true,            -- soft drop shadow underneath
		Glow = true,              -- soft halo behind (true = icon colour, or a Color3)
		Rotation = -12,
	}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Icons = require(script.Parent.Parent.Icons)
local Color = require(script.Parent.Parent.Util.Color)

local Children = Fusion.Children

return function(scope: Fusion.Scope<any>, props: { [any]: any }): Frame
	-- A plain (non-state) icon resolves once, and only the parts it can show
	-- are built: no hidden ImageLabel behind every glyph, and vice versa.
	local fixed = not Kit.isState(props.Icon)
	local resolved
	if fixed then
		local kind, value = Icons.resolve(props.Icon)
		resolved = { kind = kind, value = value }
	else
		resolved = scope:Computed(function(use)
			local kind, value = Icons.resolve(use(props.Icon))
			return { kind = kind, value = value }
		end)
	end
	local fixedKind = if fixed then resolved.kind else nil
	local buildText = not fixed or fixedKind == "glyph" or fixedKind == "text"
	local buildImage = not fixed or fixedKind == "image"
	local buildShadow = props.Shadow and (not fixed or fixedKind == "glyph")

	local color = Kit.pick(scope, props.Color, function(theme)
		return theme.Color.Text
	end)

	local filled = scope:Computed(function(use)
		local given = use(props.Filled)
		if given ~= nil then
			return given ~= false
		end
		return Theme.style(use(Theme.current), "IconStyle") ~= "Outline"
	end)

	local fontFace = scope:Computed(function(use)
		local info = use(resolved)
		if info.kind == "glyph" then
			return Icons.font(use(filled), use)
		end
		return use(Theme.current).Font.Body
	end)

	local glyphText = if fixed
		then (if fixedKind == "glyph" or fixedKind == "text" then resolved.value else "")
		else scope:Computed(function(use)
			local info = use(resolved)
			return if info.kind == "glyph" or info.kind == "text" then info.value else ""
		end)

	local isGlyph = if fixed
		then fixedKind == "glyph"
		else scope:Computed(function(use)
			return use(resolved).kind == "glyph"
		end)

	local strokeColor = scope:Computed(function(use)
		local given = use(props.StrokeColor)
		if given ~= nil then
			return given
		end
		return Kit.tintedStroke(use(Theme.current), use(color))
	end)

	local transparency = props.Transparency or 0

	-- Roblox caps TextScaled text at 100 px, so big icons draw their glyph at
	-- a smaller size and magnify it with a UIScale.
	local magnify = scope:Computed(function(use)
		local size = use(props.Size) or 40
		local px: number? = nil
		if typeof(size) == "number" then
			px = size
		elseif typeof(size) == "Vector2" then
			px = math.min(size.X, size.Y)
		elseif typeof(size) == "UDim2" and size.X.Scale == 0 and size.Y.Scale == 0 then
			px = math.min(size.X.Offset, size.Y.Offset)
		end
		if px and px > 96 then
			return px / 96
		end
		return 1
	end)
	local glyphSize = scope:Computed(function(use)
		local k = use(magnify)
		return UDim2.fromScale(1 / k, 1 / k)
	end)

	local root = Kit.root(props, { Name = "Icon" })
	root.Size = scope:Computed(function(use)
		return Kit.udim2(use(props.Size) or 40)
	end)
	root.BackgroundTransparency = 1

	root[Children] = {
		if props.Glow
			then Kit.glow(scope, {
				Color = scope:Computed(function(use)
					local glow = use(props.Glow)
					return if typeof(glow) == "Color3" then glow else Color.lighten(use(color), 0.25)
				end),
				Transparency = scope:Computed(function(use)
					return 1 - (1 - use(transparency)) * 0.55
				end),
				Size = UDim2.fromScale(1.9, 1.9),
				ZIndex = 0,
			})
			else nil,

		if buildShadow
			then scope:New "TextLabel" {
				Name = "Shadow",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Size = glyphSize,
				Position = UDim2.fromScale(0.5, 0.57),
				FontFace = fontFace,
				Text = glyphText,
				TextScaled = true,
				TextColor3 = scope:Computed(function(use)
					return Color.darken(use(strokeColor), 0.35)
				end),
				TextTransparency = scope:Computed(function(use)
					return 1 - (1 - use(transparency)) * 0.6
				end),
				Visible = isGlyph,
				ZIndex = 1,
				[Children] = scope:New "UIScale" { Scale = magnify },
			}
			else nil,

		if buildText then scope:New "TextLabel" {
			Name = "Glyph",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = glyphSize,
			FontFace = fontFace,
			Text = glyphText,
			TextScaled = true,
			TextColor3 = scope:Computed(function(use)
				return if use(props.Gradient) then Color3.new(1, 1, 1) else use(color)
			end),
			TextTransparency = transparency,
			Visible = if fixed
				then true
				else scope:Computed(function(use)
					local kind = use(resolved).kind
					return kind == "glyph" or kind == "text"
				end),
			ZIndex = 2,

			[Children] = {
				scope:New "UIScale" { Scale = magnify },
				scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual,
					LineJoinMode = Enum.LineJoinMode.Round,
					Color = strokeColor,
					Thickness = scope:Computed(function(use)
						local stroke = use(props.Stroke)
						local width = if typeof(stroke) == "number" then stroke else use(Theme.current).Stroke.Text
						return width / use(magnify)
					end),
					Transparency = transparency,
					Enabled = scope:Computed(function(use)
						local stroke = use(props.Stroke)
						return stroke ~= false and stroke ~= 0 and use(isGlyph)
					end),
				},

				scope:New "UIGradient" {
					Rotation = 90,
					Enabled = scope:Computed(function(use)
						return use(props.Gradient) and true or false
					end),
					Color = scope:Computed(function(use)
						local gradient = use(props.Gradient)
						if typeof(gradient) == "ColorSequence" then
							return gradient
						end
						local base = use(color)
						return ColorSequence.new({
							ColorSequenceKeypoint.new(0, Color.lighten(base, 0.5)),
							ColorSequenceKeypoint.new(0.45, Color.lighten(base, 0.08)),
							ColorSequenceKeypoint.new(1, Color.darken(base, 0.18)),
						})
					end),
				},
			},
		} else nil,

		if buildImage then scope:New "ImageLabel" {
			Name = "Image",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			ScaleType = Enum.ScaleType.Fit,
			Image = if fixed
				then resolved.value
				else scope:Computed(function(use)
					local info = use(resolved)
					return if info.kind == "image" then info.value else ""
				end),
			ImageColor3 = props.ImageColor or Color3.new(1, 1, 1),
			ImageTransparency = transparency,
			Visible = if fixed
				then true
				else scope:Computed(function(use)
					return use(resolved).kind == "image"
				end),
			ZIndex = 2,
		} else nil,

		props[Children],
	}

	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="112">
            <Properties>
              <string name="Name">IndexBook</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	IndexBook: the collection book (Pet Simulator's Index). Every item in
	the game, grouped (by rarity, world...): owned ones in full colour,
	undiscovered ones as dark silhouettes marked ???. Each group has its own
	completion bar; a milestone track on top pays out at 25 / 50 / 75 / 100%.

		scope:IndexBook {
			Groups = {
				{ name = "Spawn World", icon = "world", items = { { id = "cat", name = "Cat", icon = "pet", rarity = "Common" }, ... } },
				...
			},
			Owned = owned,                    -- state: { [itemId] = true }
			Milestones = { { percent = 25, icon = "gem", amount = "25" }, ... },   -- optional
			Found = found,                    -- state: items discovered (default: counted from Owned)
			Claimed = mask,                   -- state: bit mask of claimed milestones
			OnClaim = function(index, marker) end,
			Width = 900,                      -- design px; the grid fits this width
		}

	IndexBook.height(groups, width, hasMilestones) is the height it will
	take (Blueprint's lazy pages measure with it).
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)

local IndexBook = {}

IndexBook.TRACK = 148 -- the milestone track on top
IndexBook.GROUP_HEADER = 44
IndexBook.GAP = 12 -- between tiles
IndexBook.GROUP_GAP = 22 -- between groups
IndexBook.CELL = 104 -- preferred tile size

local function bitOn(mask: number, index: number): boolean
	return bit32.btest(mask, bit32.lshift(1, index - 1))
end

-- Columns and tile size for a width.
function IndexBook.grid(width: number): (number, number)
	local gap = IndexBook.GAP
	local columns = math.max(3, math.floor((width + gap) / (IndexBook.CELL + gap)))
	local cell = math.floor((width - (columns - 1) * gap) / columns)
	return columns, cell
end

function IndexBook.height(groups: { any }, width: number, hasMilestones: boolean): number
	local columns, cell = IndexBook.grid(width)
	local height = if hasMilestones then IndexBook.TRACK + IndexBook.GROUP_GAP else 0
	for index, group in groups do
		local rows = math.max(1, math.ceil(#(group.items or {}) / columns))
		height += IndexBook.GROUP_HEADER + 8 + rows * cell + (rows - 1) * IndexBook.GAP
		if index < #groups then
			height += IndexBook.GROUP_GAP
		end
	end
	return height
end

local function IndexBookComponent(scope: any, props: { [any]: any }): Frame
	local groups = props.Groups or {}
	local width = props.Width or 900
	local owned = props.Owned or {}
	local milestones = props.Milestones or {}
	local claimed = props.Claimed or 0
	local columns, cell = IndexBook.grid(width)
	local total = 0
	for _, group in groups do
		total += #(group.items or {})
	end
	local found = props.Found or scope:Computed(function(use)
		local set = use(owned) or {}
		local n = 0
		for _, group in groups do
			for _, item in group.items or {} do
				if set[item.id] then
					n += 1
				end
			end
		end
		return n
	end)
	local fraction = scope:Computed(function(use)
		return if total > 0 then math.clamp((tonumber(use(found)) or 0) / total, 0, 1) else 0
	end)

	local blocks = {}
	local y = 0

	------------------------------------------------------------------------
	-- Milestone track: one bar for the whole book, a chest at each milestone
	------------------------------------------------------------------------

	if #milestones > 0 then
		local barY = 84
		local barH = 22
		local fill = Kit.spring(scope, fraction, 0.8)
		local markers = {}
		for index, milestone in milestones do
			local reached = scope:Computed(function(use)
				return total > 0 and (tonumber(use(found)) or 0) * 100 >= milestone.percent * total
			end)
			local isClaimed = scope:Computed(function(use)
				return bitOn(tonumber(use(claimed)) or 0, index)
			end)
			local ready = scope:Computed(function(use)
				return use(reached) and not use(isClaimed)
			end)
			local tone = scope:Computed(function(use)
				local theme = use(Theme.current)
				if use(isClaimed) then
					return theme.Tone.Gray
				elseif use(ready) then
					return theme.Tone.Yellow
				end
				return theme.Tone.Dark
			end)
			local size = 58
			local marker
			marker = scope:New "TextButton" {
				Name = `Milestone{milestone.percent}`,
				Text = "",
				AutoButtonColor = false,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(milestone.percent / 100, 0.5),
				Size = UDim2.fromOffset(size, size),
				BackgroundColor3 = WHITE,
				ZIndex = 6,
				[OnEvent "Activated"] = function()
					if not peek(ready) then
						Sound.play("Error")
						return
					end
					if props.OnClaim then
						props.OnClaim(index, marker)
					end
				end,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = scope:Computed(function(use)
							local t = use(tone)
							return ColorSequence.new(Color.lighten(t.Top, 0.1), t.Bottom)
						end),
					}),
					Kit.stroke(scope, {
						Thickness = scope:Computed(function(use)
							return if use(ready) then 4 else 3
						end),
						Color = scope:Computed(function(use)
							local theme = use(Theme.current)
							return if use(ready) then theme.Color.Light else use(tone).Edge
						end),
					}),
					Icon(scope, {
						Name = "Reward",
						Icon = scope:Computed(function(use)
							return if use(isClaimed) then "check" else (milestone.icon or "gift")
						end),
						Size = 34,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = WHITE,
						Gradient = true,
						Stroke = 2.5,
						StrokeColor = scope:Computed(function(use)
							local t = use(tone)
							return t.Stroke or t.Edge
						end),
						Shadow = true,
						Transparency = scope:Computed(function(use)
							return if use(reached) or use(isClaimed) then 0 else 0.35
						end),
						ZIndex = 7,
					}),
					Text(scope, {
						Name = "Percent",
						Text = `{milestone.percent}%`,
						TextSize = 18,
						Font = "Display",
						Stroke = 2,
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.new(0.5, 0, 0, -4),
						ZIndex = 7,
					}),
					Text(scope, {
						Name = "Amount",
						Text = if milestone.amount then `x{milestone.amount}` else "",
						TextSize = 17,
						Font = "Display",
						Case = "Normal",
						Stroke = 2,
						AnchorPoint = Vector2.new(0.5, 0),
						Position = UDim2.new(0.5, 0, 1, 2),
						ZIndex = 7,
					}),
				},
			}
			local stop: (() -> ())? = nil
			local function update(isReady: boolean)
				if isReady and stop == nil then
					stop = Motion.pulse(marker, 0.08, 0.9)
				elseif not isReady and stop then
					stop()
					stop = nil
				end
			end
			update(peek(ready))
			Kit.watch(scope, ready, update)
			table.insert(scope, function()
				if stop then
					stop()
				end
			end)
			table.insert(markers, marker)
		end
		table.insert(blocks, scope:New "Frame" {
			Name = "Milestones",
			BackgroundTransparency = 1,
			Size = UDim2.new(1, 0, 0, IndexBook.TRACK),
			Position = UDim2.fromOffset(0, y),
			ZIndex = 2,
			[Children] = {
				scope:New "Frame" {
					Name = "Heading",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, 0, 0, 36),
					ZIndex = 3,
					[Children] = {
						Kit.list(scope, { Direction = "X", Padding = 12, VAlign = "Center" }),
						Icon(scope, {
							Icon = "book-open",
							Size = 34,
							Color = Kit.themed(scope, function(theme)
								return theme.Color.Accent
							end),
							Gradient = true,
							Shadow = true,
							LayoutOrder = 1,
							ZIndex = 3,
						}),
						Text(scope, {
							Name = "Completion",
							Text = scope:Computed(function(use)
								local n = tonumber(use(found)) or 0
								return `{n} / {total} DISCOVERED`
							end),
							TextSize = 26,
							Font = "Title",
							LayoutOrder = 2,
							ZIndex = 3,
						}),
						-- the whole book's completion, in a gold pill
						scope:New "Frame" {
							Name = "Percent",
							Size = UDim2.fromOffset(0, 32),
							AutomaticSize = Enum.AutomaticSize.X,
							BackgroundColor3 = WHITE,
							LayoutOrder = 3,
							ZIndex = 3,
							[Children] = {
								Kit.corner(scope, "Full"),
								Kit.padding(scope, { 0, 12 }),
								Kit.gradient(scope, {
									Rotation = 90,
									Color = Kit.themed(scope, function(theme)
										return ColorSequence.new(theme.Tone.Yellow.Top, theme.Tone.Yellow.Bottom)
									end),
								}),
								Kit.stroke(scope, {
									Thickness = 2,
									Color = Kit.themed(scope, function(theme)
										return theme.Tone.Yellow.Edge
									end),
								}),
								Text(scope, {
									Text = scope:Computed(function(use)
										return `{math.floor(use(fraction) * 100)}%`
									end),
									TextSize = 22,
									Font = "Display",
									StrokeColor = Kit.themed(scope, function(theme)
										return theme.Tone.Yellow.Stroke or theme.Tone.Yellow.Edge
									end),
									Size = UDim2.fromScale(0, 1),
									AutoSize = "X",
									ZIndex = 4,
								}),
							},
						},
					},
				},
				scope:New "Frame" {
					Name = "Bar",
					Position = UDim2.fromOffset(0, barY),
					Size = UDim2.new(1, -30, 0, barH),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Track
					end),
					ZIndex = 3,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.stroke(scope, {
							Thickness = 2,
							Color = Kit.themed(scope, function(theme)
								return theme.Color.Outline
							end),
						}),
						scope:New "Frame" {
							Name = "Fill",
							Size = scope:Computed(function(use)
								return UDim2.fromScale(math.clamp(use(fill), 0, 1), 1)
							end),
							BackgroundColor3 = WHITE,
							ZIndex = 4,
							[Children] = {
								Kit.corner(scope, "Full"),
								Kit.gradient(scope, {
									Rotation = 90,
									Color = Kit.themed(scope, function(theme)
										return ColorSequence.new(Color.lighten(theme.Tone.Yellow.Top, 0.15), theme.Tone.Yellow.Bottom)
									end),
								}),
							},
						},
						scope:New "Frame" {
							Name = "Markers",
							BackgroundTransparency = 1,
							Size = UDim2.fromScale(1, 1),
							ZIndex = 6,
							[Children] = markers,
						},
					},
				},
			},
		})
		y += IndexBook.TRACK + IndexBook.GROUP_GAP
	end

	------------------------------------------------------------------------
	-- Groups
	------------------------------------------------------------------------

	for gIndex, group in groups do
		local items = group.items or {}
		local rows = math.max(1, math.ceil(#items / columns))
		local gridHeight = rows * cell + (rows - 1) * IndexBook.GAP
		local groupFound = scope:Computed(function(use)
			local set = use(owned) or {}
			local n = 0
			for _, item in items do
				if set[item.id] then
					n += 1
				end
			end
			return n
		end)
		local tiles = {}
		for iIndex, item in items do
			local have = scope:Computed(function(use)
				return (use(owned) or {})[item.id] == true
			end)
			local rarityTone = scope:Computed(function(use)
				return Theme.rarity(use(Theme.current), item.rarity or "Common")
			end)
			local tone = scope:Computed(function(use)
				return if use(have) then use(rarityTone) else use(Theme.current).Tone.Dark
			end)
			table.insert(tiles, scope:New "Frame" {
				Name = item.id,
				LayoutOrder = iIndex,
				BackgroundColor3 = WHITE,
				ZIndex = 3,
				[Children] = {
					Kit.corner(scope, "M"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = scope:Computed(function(use)
							local t = use(tone)
							if not use(have) then
								-- a lit plate, so the silhouette on it reads on any theme
								local theme = use(Theme.current)
								local plate = theme.Tone.Gray
								return ColorSequence.new(Color.darken(plate.Top, 0.25), Color.darken(plate.Bottom, 0.45))
							end
							return ColorSequence.new({
								ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.05)),
								ColorSequenceKeypoint.new(0.55, t.Bottom),
								ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.3)),
							})
						end),
					}),
					Kit.stroke(scope, {
						Thickness = 2.5,
						Color = scope:Computed(function(use)
							local theme = use(Theme.current)
							return if use(have) then use(tone).Edge else theme.Color.Outline
						end),
					}),
					Kit.unlessLite(scope, function(inner)
						return inner:New "Frame" {
							Name = "Gloss",
							BackgroundColor3 = WHITE,
							BackgroundTransparency = inner:Computed(function(use)
								return if use(have) then 0.7 else 0.94
							end),
							Position = UDim2.fromOffset(3, 3),
							Size = UDim2.new(1, -6, 0.4, 0),
							ZIndex = 3,
							[Children] = {
								Kit.corner(inner, "M"),
								Kit.gradient(inner, { Rotation = 90, Transparency = NumberSequence.new(0.1, 1) }),
							},
						}
					end),
					-- owned: the item in colour; undiscovered: its silhouette
					Icon(scope, {
						Name = "Art",
						Icon = item.icon or "star",
						Size = math.floor(cell * 0.56),
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.42),
						Color = scope:Computed(function(use)
							if use(have) then
								return WHITE
							end
							return Color.darken(use(Theme.current).Tone.Gray.Deep, 0.5)
						end),
						Gradient = have,
						Stroke = scope:Computed(function(use)
							return if use(have) then 2.5 else false
						end),
						StrokeColor = scope:Computed(function(use)
							local t = use(tone)
							return t.Stroke or t.Edge
						end),
						Shadow = have,
						ZIndex = 4,
					}),
					Text(scope, {
						Name = "Label",
						Text = scope:Computed(function(use)
							return if use(have) then item.name else "???"
						end),
						Case = "Normal",
						TextSize = math.clamp(math.floor(cell * 0.15), 13, 17),
						Font = "Display",
						Color = scope:Computed(function(use)
							local theme = use(Theme.current)
							return if use(have) then WHITE else theme.Color.TextMuted
						end),
						Stroke = 1.5,
						StrokeColor = scope:Computed(function(use)
							local t = use(tone)
							return t.Stroke or t.Edge
						end),
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.new(0.5, 0, 1, -5),
						Size = UDim2.new(1, -8, 0, math.floor(cell * 0.2)),
						Scaled = true,
						Wrapped = false,
						MaxTextSize = math.clamp(math.floor(cell * 0.15), 13, 17),
						ZIndex = 5,
					}),
				},
			})
		end
		local groupTone = Kit.tone(scope, group.tone or "Blue")
		local complete = scope:Computed(function(use)
			return #items > 0 and use(groupFound) >= #items
		end)
		table.insert(blocks, scope:New "Frame" {
			Name = `Group_{group.id or gIndex}`,
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(0, y),
			Size = UDim2.new(1, 0, 0, IndexBook.GROUP_HEADER + 8 + gridHeight),
			ZIndex = 2,
			[Children] = {
				scope:New "Frame" {
					Name = "Header",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, 0, 0, IndexBook.GROUP_HEADER),
					ZIndex = 3,
					[Children] = {
						Icon(scope, {
							Icon = group.icon or "book-open",
							Size = 32,
							AnchorPoint = Vector2.new(0, 0.5),
							Position = UDim2.fromScale(0, 0.5),
							Color = scope:Computed(function(use)
								return Color.lighten(use(groupTone).Top, 0.2)
							end),
							Gradient = true,
							Shadow = true,
							ZIndex = 4,
						}),
						Text(scope, {
							Name = "Name",
							Text = group.name or "Group",
							TextSize = 24,
							Font = "Title",
							XAlign = "Left",
							AnchorPoint = Vector2.new(0, 0.5),
							Position = UDim2.new(0, 42, 0.5, 0),
							Size = UDim2.new(0.5, -42, 0, 30),
							Truncate = true,
							ZIndex = 4,
						}),
						-- this group's completion: a short bar and n / total
						scope:New "Frame" {
							Name = "Completion",
							AnchorPoint = Vector2.new(1, 0.5),
							Position = UDim2.new(1, -86, 0.5, 0),
							Size = UDim2.fromOffset(math.clamp(math.floor(width * 0.26), 120, 240), 14),
							BackgroundColor3 = Kit.themed(scope, function(theme)
								return theme.Color.Track
							end),
							ZIndex = 4,
							[Children] = {
								Kit.corner(scope, "Full"),
								scope:New "Frame" {
									Name = "Fill",
									Size = scope:Computed(function(use)
										return UDim2.fromScale(if #items > 0 then math.clamp(use(groupFound) / #items, 0, 1) else 0, 1)
									end),
									BackgroundColor3 = scope:Computed(function(use)
										local theme = use(Theme.current)
										return if use(complete) then theme.Tone.Green.Top else use(groupTone).Top
									end),
									ZIndex = 5,
									[Children] = Kit.corner(scope, "Full"),
								},
							},
						},
						Text(scope, {
							Name = "Count",
							Text = scope:Computed(function(use)
								return if use(complete) then "DONE!" else `{use(groupFound)}/{#items}`
							end),
							TextSize = 22,
							Font = "Display",
							XAlign = "Right",
							Color = scope:Computed(function(use)
								local theme = use(Theme.current)
								return if use(complete) then theme.Tone.Green.Top else theme.Color.Text
							end),
							AnchorPoint = Vector2.new(1, 0.5),
							Position = UDim2.new(1, 0, 0.5, 0),
							Size = UDim2.fromOffset(78, 28),
							ZIndex = 4,
						}),
					},
				},
				scope:New "Frame" {
					Name = "Grid",
					BackgroundTransparency = 1,
					Position = UDim2.fromOffset(0, IndexBook.GROUP_HEADER + 8),
					Size = UDim2.new(1, 0, 0, gridHeight),
					ZIndex = 3,
					[Children] = {
						scope:New "UIGridLayout" {
							CellSize = UDim2.fromOffset(cell, cell),
							CellPadding = UDim2.fromOffset(IndexBook.GAP, IndexBook.GAP),
							FillDirection = Enum.FillDirection.Horizontal,
							FillDirectionMaxCells = columns,
							SortOrder = Enum.SortOrder.LayoutOrder,
							HorizontalAlignment = Enum.HorizontalAlignment.Left,
						},
						tiles,
					},
				},
			},
		})
		y += IndexBook.GROUP_HEADER + 8 + gridHeight + (if gIndex < #groups then IndexBook.GROUP_GAP else 0)
	end

	local root = Kit.root(props, { Name = "IndexBook" })
	root.Size = props.Size or UDim2.fromOffset(width, y)
	root.BackgroundTransparency = 1
	root[Children] = { blocks, props[Children] }
	return scope:New "Frame"(root) :: Frame
end

-- Pops a milestone marker once its claim went through.
function IndexBook.celebrate(marker: GuiObject?)
	if marker == nil then
		return
	end
	local theme = Theme.get()
	Sound.play("Reward")
	pcall(function()
		Particles.burst(marker, { Count = 22, Shape = "sparkle", Colors = { theme.Tone.Yellow.Top, WHITE, theme.Color.Accent }, Size = 24, Speed = 460 })
		Particles.ring(marker, { Color = theme.Tone.Yellow.Top, Time = 0.35 })
	end)
	pcall(Motion.pop, marker, 0.2)
end

IndexBook.new = IndexBookComponent
setmetatable(IndexBook, {
	__call = function(_, scope: any, props: { [any]: any })
		return IndexBookComponent(scope, props)
	end,
})

return IndexBook
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="113">
            <Properties>
              <string name="Name">ItemDetail</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	ItemDetail: the side panel next to an ItemGrid. A big rarity showcase with
	rays and a bobbing icon, the item name, rarity pill, description, stats
	and action buttons. Shows a friendly placeholder while nothing is picked.

	local selectedItem = scope:Value(nil)   -- item table or nil
	scope:ItemDetail {
		Item = selectedItem,
		Actions = {
			{ Text = "Equip", Tone = "Green", OnClick = function(item) end },
			{ Text = function(item) return if item.locked then "Unlock" else "Lock" end,
			  Tone = "Gray", Icon = "lock", OnClick = function(item) end },
			{ Text = "Delete", Tone = "Red", Icon = "trash", OnClick = function(item) end },
		},
		Stats = { { Label = "Power", Value = "x2.5", Icon = "bolt" } },
		                               -- or function(item) -> stats; item.stats is used when omitted
		EmptyText = "Select a pet",
		Size = UDim2.fromOffset(380, 600),
		Layout = "Panel",              -- "Panel" (tall side panel) | "Bar" (wide strip under a grid)
	}

	Item fields: id, name, icon, rarity, count?, equipped?, locked?,
	description?, stats? ({ {label, value, icon?, bar?} }).
	Action Text / Tone / Disabled may be values or functions of the item.

	Bar: a square showcase on the left, name, rarity and description in the
	middle, and the actions stacked on the right. Give it ~150-200 px of
	height and at least ~640 px of width.
]]

local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local Motion = require(script.Parent.Parent.FX.Motion)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Button = require(script.Parent.Button)
local StatRow = require(script.Parent.StatRow)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

local RARITY_HEX = {
	Common = "B0B7C3",
	Uncommon = "5BE22B",
	Rare = "3AA8FF",
	Epic = "B45CFF",
	Legendary = "FFB300",
	Mythic = "FF3B6B",
	Secret = "FFFFFF",
}
local RARITY_RANK = { Common = 1, Uncommon = 2, Rare = 3, Epic = 4, Legendary = 5, Mythic = 6, Secret = 7 }

local fallbackCache: { [string]: any } = {}
local function fallbackRarity(name: string): any
	local cached = fallbackCache[name]
	if cached then
		return cached
	end
	local tone
	if name == "Secret" then
		tone = {
			Top = WHITE,
			Bottom = Color3.fromHex("DCD2FF"),
			Edge = Color3.fromHex("6B5AA8"),
			Deep = Color3.fromHex("2E2748"),
			Text = WHITE,
			Stroke = Color3.fromHex("4A3F75"),
		}
	else
		local base = Color3.fromHex(RARITY_HEX[name] or RARITY_HEX.Common)
		tone = {
			Top = Color.lighten(base, 0.32),
			Bottom = base,
			Edge = Color.darken(base, 0.45),
			Deep = Color.darken(base, 0.72),
			Text = WHITE,
			Stroke = Color.darken(base, 0.58),
		}
	end
	fallbackCache[name] = tone
	return tone
end

local function rarityTone(scope: Scope, rarity: any): any
	local kitRarity = (Kit :: any).rarity
	if kitRarity then
		return kitRarity(scope, rarity)
	end
	return scope:Computed(function(use)
		local theme = use(Theme.current)
		local name = use(rarity) or "Common"
		if theme.Rarity and theme.Rarity[name] then
			return theme.Rarity[name]
		end
		return fallbackRarity(name)
	end)
end

local RAINBOW = ColorSequence.new({
	ColorSequenceKeypoint.new(0, Color3.fromHex("FF7AD9")),
	ColorSequenceKeypoint.new(0.25, Color3.fromHex("FFD36B")),
	ColorSequenceKeypoint.new(0.5, Color3.fromHex("7CF29A")),
	ColorSequenceKeypoint.new(0.75, Color3.fromHex("6BD8FF")),
	ColorSequenceKeypoint.new(1, Color3.fromHex("A98BFF")),
})

local function field(item: any, key: string): any
	if typeof(item) ~= "table" then
		return nil
	end
	local value = item[key]
	if value == nil then
		value = item[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

-- Values or functions of the item (functions only run with an item).
local function resolve(value: any, item: any): any
	if typeof(value) == "function" then
		if item == nil then
			return nil
		end
		return value(item)
	end
	return value
end

local function ItemDetail(scope: Scope, props: { [any]: any }): Frame
	local item = props.Item or scope:Value(nil)
	local size = peek(props.Size) or UDim2.fromOffset(380, 600)
	local width = if typeof(size) == "UDim2" and size.X.Offset > 0 then size.X.Offset else 380
	local height = if typeof(size) == "UDim2" and size.Y.Offset > 0 then size.Y.Offset else 600
	local bar = props.Layout == "Bar"
	-- the showcase shrinks on short panels so the name and the action buttons
	-- always fit below it (they need ~184 px); a bar's showcase is a square
	-- as tall as the bar
	local heroHeight = if bar
		then math.max(80, height - 32)
		else math.floor(math.clamp(math.min(width * 0.62, height - 184), 140, 260))
	local iconSize = math.floor(heroHeight * 0.62)
	-- Bar: room for the stacked action buttons on the right
	local actionsWidth = if bar then math.clamp(math.floor(width * 0.24), 170, 240) else 0

	local hasItem = scope:Computed(function(use)
		return use(item) ~= nil
	end)
	local rarity = scope:Computed(function(use)
		return field(use(item), "rarity") or "Common"
	end)
	local rank = scope:Computed(function(use)
		return RARITY_RANK[use(rarity)] or 1
	end)
	local tone = rarityTone(scope, rarity)
	local strokeColor = scope:Computed(function(use)
		local t = use(tone)
		return t.Stroke or t.Edge
	end)

	local popGoal = scope:Value(1)
	local pop = scope:Spring(popGoal, 30, 0.42)
	Kit.watch(scope, item, function(now)
		if now ~= nil then
			pop:setPosition(0.7)
			pop:addVelocity(4)
		end
	end)

	local stats = scope:Computed(function(use)
		local current = use(item)
		if current == nil then
			return {}
		end
		local list = resolve(props.Stats, current) or field(current, "stats") or {}
		return list
	end)

	local statRows = scope:ForPairs(stats, function(_, innerScope, index, stat)
		return index,
			StatRow(innerScope, {
				LayoutOrder = index,
				Label = field(stat, "label") or "",
				Value = field(stat, "value"),
				Icon = field(stat, "icon"),
				Bar = field(stat, "bar"),
				Tone = field(stat, "tone") or "Blue",
				Size = UDim2.new(1, 0, 0, if field(stat, "bar") ~= nil then 60 else 48),
			})
	end)

	local actions = props.Actions or {}
	local actionButtons = {}
	-- Bar: buttons stack down the right edge, as tall as the bar allows
	local stackedHeight = if bar then math.clamp(math.floor((height - 32 - 10 * math.max(0, #actions - 1)) / math.max(1, #actions)), 40, 60) else 0
	for index, action in actions do
		local text = scope:Computed(function(use)
			local current = use(item)
			return tostring(resolve(action.Text, current) or "")
		end)
		table.insert(actionButtons, Button(scope, {
			Name = `Action{index}`,
			LayoutOrder = index,
			Text = text,
			Icon = action.Icon,
			Tone = scope:Computed(function(use)
				return resolve(action.Tone, use(item)) or (if index == 1 then "Green" else "Gray")
			end),
			Disabled = scope:Computed(function(use)
				local current = use(item)
				return current == nil or resolve(action.Disabled, current) == true
			end),
			Shine = index == 1,
			Size = if bar
				then UDim2.new(1, 0, 0, stackedHeight)
				else UDim2.new(1 / #actions, -math.floor(12 * (#actions - 1) / #actions), 1, 0),
			TextSize = if bar then math.min(26, math.floor(stackedHeight * 0.46)) elseif #actions >= 3 then 22 else 26,
			IconSize = if #actions >= 3 then 24 else 28,
			OnClick = function()
				local current = peek(item)
				if current ~= nil and action.OnClick then
					action.OnClick(current)
				end
			end,
		}))
	end
	local actionHeight = if #actions > 0 then 60 else 0

	local rays = scope:New "ImageLabel" {
		Name = "Rays",
		BackgroundTransparency = 1,
		Image = Assets.Rays,
		ImageColor3 = scope:Computed(function(use)
			return Color.lighten(use(tone).Top, 0.5)
		end),
		ImageTransparency = scope:Computed(function(use)
			local r = use(rank)
			return if r >= 5 then 0.2 elseif r >= 3 then 0.45 else 0.7
		end),
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(heroHeight * 1.5, heroHeight * 1.5),
		ZIndex = 2,
	}

	local heroIcon = Icon(scope, {
		Name = "HeroIcon",
		Icon = scope:Computed(function(use)
			return field(use(item), "icon") or "gift"
		end),
		Size = iconSize,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Color = scope:Computed(function(use)
			return Color.lighten(use(tone).Top, 0.55)
		end),
		Gradient = true,
		Shadow = true,
		Stroke = 4,
		StrokeColor = strokeColor,
		ZIndex = 4,
		[Children] = scope:New "UIScale" { Scale = pop },
	})

	local heroGradient = scope:New "UIGradient" {
		Rotation = 70,
		Color = scope:Computed(function(use)
			if use(rarity) == "Secret" then
				return use(tone).Rainbow or RAINBOW
			end
			local t = use(tone)
			return ColorSequence.new({
				ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.1)),
				ColorSequenceKeypoint.new(0.5, t.Bottom),
				ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.4)),
			})
		end),
	}

	-- the showcase fills the panel's width (Panel) or is a square (Bar)
	local heroSize = if bar then UDim2.fromOffset(heroHeight, heroHeight) else UDim2.new(1, -32, 0, heroHeight)
	local hero = scope:New "CanvasGroup" {
		Name = "Hero",
		Position = UDim2.fromOffset(16, 16),
		Size = heroSize,
		BackgroundColor3 = WHITE,
		ZIndex = 2,
		[Children] = {
			Kit.corner(scope, "M"),
			heroGradient,
			rays,
			scope:New "Frame" {
				Name = "Gloss",
				BackgroundColor3 = WHITE,
				BackgroundTransparency = 0.6,
				Size = UDim2.fromScale(1, 0.4),
				ZIndex = 3,
				[Children] = Kit.gradient(scope, {
					Rotation = 90,
					Transparency = NumberSequence.new(0.2, 1),
				}),
			},
			scope:New "Frame" {
				Name = "Vignette",
				BackgroundColor3 = scope:Computed(function(use)
					return use(tone).Deep
				end),
				Size = UDim2.fromScale(1, 1),
				ZIndex = 3,
				[Children] = Kit.gradient(scope, {
					Rotation = 90,
					Transparency = NumberSequence.new({
						NumberSequenceKeypoint.new(0, 1),
						NumberSequenceKeypoint.new(0.6, 1),
						NumberSequenceKeypoint.new(1, 0.45),
					}),
				}),
			},
			heroIcon,
		},
	}

	-- the rarity pill: under the showcase (Panel) or under the name (Bar)
	local function rarityPill(props2: { [string]: any }): Frame
		return scope:New "Frame" {
			Name = "RarityPill",
			AnchorPoint = props2.AnchorPoint,
			Position = props2.Position,
			LayoutOrder = props2.LayoutOrder,
			Size = UDim2.fromOffset(0, 34),
			AutomaticSize = Enum.AutomaticSize.X,
			BackgroundColor3 = WHITE,
			ZIndex = 6,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.padding(scope, { 0, 16 }),
				scope:New "UIGradient" {
					Rotation = 90,
					Color = scope:Computed(function(use)
						if use(rarity) == "Secret" then
							return use(tone).Rainbow or RAINBOW
						end
						local t = use(tone)
						return ColorSequence.new(Color.lighten(t.Top, 0.1), t.Bottom)
					end),
				},
				Kit.stroke(scope, {
					Thickness = 2.5,
					Color = scope:Computed(function(use)
						return use(tone).Deep
					end),
				}),
				Text(scope, {
					Text = scope:Computed(function(use)
						return string.upper(use(rarity))
					end),
					TextSize = 20,
					Font = "Display",
					Size = UDim2.fromScale(0, 1),
					AutoSize = "X",
					StrokeColor = strokeColor,
					Color = scope:Computed(function(use)
						return use(tone).Text
					end),
					ZIndex = 7,
				}),
			},
		}
	end

	local heroFrame = scope:New "Frame" {
		Name = "HeroFrame",
		BackgroundTransparency = 1,
		Size = if bar then UDim2.new(0, heroHeight + 32, 1, 0) else UDim2.new(1, 0, 0, heroHeight + 16),
		ZIndex = 2,
		[Children] = {
			hero,
			-- tinted outline drawn over the canvas group
			scope:New "Frame" {
				Name = "HeroOutline",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(16, 16),
				Size = heroSize,
				ZIndex = 5,
				[Children] = {
					Kit.corner(scope, "M"),
					Kit.stroke(scope, {
						Thickness = 3,
						Color = scope:Computed(function(use)
							return use(tone).Edge
						end),
					}),
				},
			},
			if bar
				then nil
				else rarityPill({ AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.new(0.5, 0, 0, heroHeight + 16) }),
			Text(scope, {
				Name = "Count",
				Case = "Normal",
				Text = scope:Computed(function(use)
					local count = field(use(item), "count")
					return if typeof(count) == "number" and count > 1 then `x{Format.abbrev(count)}` else ""
				end),
				TextSize = 26,
				Font = "Display",
				AnchorPoint = Vector2.new(1, 0),
				Position = UDim2.new(1, -28, 0, 24),
				StrokeColor = strokeColor,
				ZIndex = 6,
			}),
			Icon(scope, {
				Name = "EquippedMark",
				Icon = "check",
				Size = 34,
				Position = UDim2.fromOffset(26, 24),
				Color = Kit.themed(scope, function(theme)
					return theme.Tone.Green.Top
				end),
				StrokeColor = Kit.themed(scope, function(theme)
					return theme.Tone.Green.Edge
				end),
				Stroke = 3,
				Shadow = true,
				Visible = scope:Computed(function(use)
					return field(use(item), "equipped") == true
				end),
				ZIndex = 6,
			}),
			Icon(scope, {
				Name = "LockedMark",
				Icon = "lock",
				Size = 30,
				AnchorPoint = Vector2.new(0, 0),
				Position = scope:Computed(function(use)
					local offset = if field(use(item), "equipped") == true then 66 else 26
					return UDim2.fromOffset(offset, 26)
				end),
				Color = WHITE,
				StrokeColor = strokeColor,
				Stroke = 3,
				Shadow = true,
				Visible = scope:Computed(function(use)
					return field(use(item), "locked") == true
				end),
				ZIndex = 6,
			}),
		},
	}

	local info = scope:New "Frame" {
		Name = "Info",
		BackgroundTransparency = 1,
		Position = if bar then UDim2.fromOffset(heroHeight + 40, 16) else UDim2.fromOffset(0, heroHeight + 40),
		Size = if bar
			then UDim2.new(1, -(heroHeight + 40) - actionsWidth - 32, 1, -32)
			else UDim2.new(1, 0, 1, -(heroHeight + 40) - actionHeight - 24),
		ClipsDescendants = true,
		ZIndex = 2,
		[Children] = {
			Kit.padding(scope, if bar then { 0, 4 } else { 0, 16 }),
			Kit.list(scope, { Direction = "Y", Padding = 8, HAlign = if bar then "Left" else "Center", VAlign = if bar then "Center" else "Top" }),
			Text(scope, {
				Name = "ItemName",
				LayoutOrder = 1,
				Text = scope:Computed(function(use)
					return tostring(field(use(item), "name") or "")
				end),
				TextSize = 34,
				Font = "Title",
				XAlign = if bar then "Left" else "Center",
				Size = UDim2.new(1, 0, 0, 40),
				Truncate = true,
				StrokeColor = Kit.themed(scope, function(theme)
					return theme.Color.Outline
				end),
				ZIndex = 3,
			}),
			if bar then rarityPill({ LayoutOrder = 2 }) else nil,
			Text(scope, {
				Name = "Description",
				LayoutOrder = 3,
				XAlign = if bar then "Left" else "Center",
				Text = scope:Computed(function(use)
					return tostring(field(use(item), "description") or "")
				end),
				Visible = scope:Computed(function(use)
					return field(use(item), "description") ~= nil
				end),
				TextSize = 20,
				Font = "Body",
				Wrapped = not bar,
				Truncate = bar,
				Size = UDim2.new(1, 0, 0, if bar then 26 else 48),
				YAlign = "Top",
				Color = Kit.themed(scope, function(theme)
					return theme.Color.TextMuted
				end),
				Stroke = false,
				ZIndex = 3,
			}),
			scope:New "Frame" {
				Name = "Stats",
				LayoutOrder = 4,
				Visible = not bar,
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				ZIndex = 3,
				[Children] = {
					Kit.list(scope, { Direction = "Y", Padding = 8 }),
					statRows,
				},
			},
		},
	}

	local actionsRow = if #actions > 0 and bar
		then scope:New "Frame" {
			Name = "Actions",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -16, 0.5, 0),
			Size = UDim2.new(0, actionsWidth, 1, -32),
			ZIndex = 4,
			[Children] = {
				Kit.list(scope, { Direction = "Y", Padding = 10, HAlign = "Center", VAlign = "Center" }),
				actionButtons,
			},
		}
		elseif #actions > 0 then scope:New "Frame" {
			Name = "Actions",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, 16, 1, -16),
			Size = UDim2.new(1, -32, 0, actionHeight),
			ZIndex = 4,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 12, HAlign = "Center", VAlign = "Center" }),
				actionButtons,
			},
		}
		else nil

	local content = scope:New "Frame" {
		Name = "Content",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		Visible = hasItem,
		ZIndex = 2,
		[Children] = { heroFrame, info, actionsRow },
	}

	local empty = scope:New "Frame" {
		Name = "Empty",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.new(1, -48, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		Visible = scope:Computed(function(use)
			return not use(hasItem)
		end),
		ZIndex = 2,
		[Children] = {
			Kit.list(scope, { Direction = "Y", Padding = 12, HAlign = "Center" }),
			scope:New "Frame" {
				Name = "Placeholder",
				LayoutOrder = 1,
				Size = UDim2.fromOffset(148, 148),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.SurfaceLight
				end),
				BackgroundTransparency = 0.4,
				ZIndex = 2,
				[Children] = {
					Kit.corner(scope, "L"),
					Kit.stroke(scope, {
						Thickness = 3,
						Transparency = 0.55,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.TextMuted
						end),
					}),
					Icon(scope, {
						Icon = props.EmptyIcon or "search",
						Size = 72,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = Kit.themed(scope, function(theme)
							return theme.Color.TextMuted
						end),
						Gradient = true,
						Shadow = true,
						ZIndex = 3,
					}),
				},
			},
			Text(scope, {
				LayoutOrder = 2,
				Text = props.EmptyText or "Select an item",
				TextSize = 30,
				Font = "Title",
				ZIndex = 2,
			}),
			Text(scope, {
				LayoutOrder = 3,
				Text = props.EmptyHint or "Tap an item to see its details",
				TextSize = 20,
				Font = "Body",
				Wrapped = true,
				Size = UDim2.new(1, 0, 0, 0),
				AutoSize = "Y",
				Color = Kit.themed(scope, function(theme)
					return theme.Color.TextMuted
				end),
				Stroke = false,
				ZIndex = 2,
			}),
		},
	}

	local root = Kit.root(props, { Name = "ItemDetail" })
	root.Size = size
	root.BackgroundColor3 = Kit.themed(scope, function(theme)
		return theme.Color.Surface
	end)
	root.BackgroundTransparency = Kit.themed(scope, function(theme)
		return theme.Alpha.Surface
	end)
	root[Children] = {
		Kit.corner(scope, "L"),
		content,
		empty,
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame

	-- Epic+ rays turn slowly; the icon bobs; Legendary+ shines.
	local alive = true
	local stopSpin: (() -> ())? = nil
	local stopShine: (() -> ())? = nil
	local stopBob = Motion.bob(heroIcon, 6, 2.4)
	local spinTween: Tween? = nil
	local spinUntrack: (() -> ())? = nil
	local function updateFx()
		if not alive then
			return
		end
		local r = peek(rank)
		if r >= 4 and stopSpin == nil then
			stopSpin = Motion.spin(rays, 14)
		elseif r < 4 and stopSpin then
			stopSpin()
			stopSpin = nil
		end
		if r >= 5 and stopShine == nil then
			stopShine = Motion.shine(hero, { ZIndex = 3, Transparency = 0.5, Interval = 3, Duration = 1, Width = 0.12 })
		elseif r < 5 and stopShine then
			stopShine()
			stopShine = nil
		end
		local secret = peek(rarity) == "Secret"
		if secret and spinTween == nil then
			heroGradient.Rotation = 0
			local tween = TweenService:Create(
				heroGradient,
				TweenInfo.new(6, Enum.EasingStyle.Linear, Enum.EasingDirection.In, -1),
				{ Rotation = 360 }
			)
			tween:Play()
			spinTween = tween
			spinUntrack = Motion.track(tween, heroGradient) -- pauses while the panel is hidden
		elseif not secret and spinTween then
			if spinUntrack then
				spinUntrack()
				spinUntrack = nil
			end
			spinTween:Cancel()
			spinTween = nil
			heroGradient.Rotation = 70
		end
	end
	updateFx()
	Kit.watch(scope, rank, updateFx)
	Kit.watch(scope, rarity, updateFx)
	table.insert(scope, function()
		alive = false
		stopBob()
		if stopSpin then
			stopSpin()
		end
		if stopShine then
			stopShine()
		end
		if spinUntrack then
			spinUntrack()
		end
		if spinTween then
			spinTween:Cancel()
		end
	end)

	return frame
end

return ItemDetail
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="114">
            <Properties>
              <string name="Name">ItemGrid</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	ItemGrid: a scrolling grid of ItemSlots (inventory, pet index, rewards).
	Cells shrink to fit the width they're given, so the grid never overflows;
	an empty inventory shows a friendly placeholder instead of a blank box.

	local selected = scope:Value(nil)
	scope:ItemGrid {
		Items = items,                 -- array or state of { id, name, icon, rarity, count?, equipped?, locked? }
		Columns = 5,
		CellSize = 108,                -- preferred px; smaller if the width needs it
		Gap = 12,
		Selected = selected,           -- Value<id>
		OnSelect = function(item) end,
		ShowNames = false,             -- names under the icons
		EmptyText = "No pets yet",
		EmptyHint = "Hatch an egg to get your first pet!",
		EmptyIcon = "pet",
		Size = UDim2.fromScale(1, 1),  -- default: exactly Columns wide, 4 rows tall
	}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Kit = require(script.Parent.Parent.Kit)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local ItemSlot = require(script.Parent.ItemSlot)

local Children = Fusion.Children
local OnChange = Fusion.OnChange
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local SCROLLBAR = 6

local function field(item: { [string]: any }, key: string): any
	local value = item[key]
	if value == nil then
		value = item[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

-- The fields a slot shows: a refreshed item with the same ones keeps its slot.
local SLOT_FIELDS = { "id", "name", "icon", "rarity", "count", "equipped", "locked" }
local function sameFields(a: { [string]: any }, b: { [string]: any }): boolean
	if a == b then
		return true
	end
	for _, key in SLOT_FIELDS do
		if field(a, key) ~= field(b, key) then
			return false
		end
	end
	return true
end

-- Cache key for an item: its id (a repeated id gets #2, #3...), or the
-- table itself when it has none.
local function cacheKey(item: { [string]: any }, seen: { [any]: number }): any
	local id = field(item, "id")
	if id == nil then
		return item
	end
	local n = (seen[id] or 0) + 1
	seen[id] = n
	return if n == 1 then id else `{id}\0{n}`
end

-- Product of every UIScale from `gui` up, so AbsoluteSize can be turned
-- back into design pixels.
local function accumulatedScale(gui: Instance): number
	local scale = 1
	local current: Instance? = gui
	while current and current:IsA("GuiObject") do
		local uiScale = current:FindFirstChildOfClass("UIScale")
		if uiScale then
			scale *= (uiScale :: UIScale).Scale
		end
		current = current.Parent
	end
	return if scale > 0 then scale else 1
end

local function ItemGrid(scope: Scope, props: { [any]: any }): Frame
	-- themed scrollbar parts (Kit.scrollBar), shared by this component's scroll areas
	local scrollBar = Kit.scrollBar(scope)
	local items = props.Items or {}
	local columns = math.max(1, peek(props.Columns) or 5)
	local preferred = peek(props.CellSize) or 108
	local gap = peek(props.Gap) or 12
	local pad = math.max(12, math.floor(preferred * 0.12))
	local selected = props.Selected or scope:Value(nil)
	local showNames = props.ShowNames == true

	-- Design-pixel width of the scroll area once the engine has laid it out
	-- (0 until then: the preferred cell size is used).
	local givenSize = peek(props.Size)
	local measured = scope:Value(
		if typeof(givenSize) == "UDim2" and givenSize.X.Scale == 0 and givenSize.X.Offset > 0 then givenSize.X.Offset else 0
	)
	local cell = scope:Computed(function(use)
		local width = use(measured)
		if width <= 0 then
			return preferred
		end
		local fit = math.floor((width - pad * 2 - SCROLLBAR - gap * (columns - 1)) / columns)
		return math.clamp(fit, math.min(48, preferred), preferred)
	end)

	local count = scope:Computed(function(use)
		return #(use(items) or {})
	end)

	local function choose(item: { [string]: any })
		local id = field(item, "id")
		if selected.set then
			selected:set(id)
		end
		if props.OnSelect then
			props.OnSelect(item)
		end
	end

	-- Only the rows in view (plus one either side) are built. The rows above
	-- them are stood in for by the grid's top padding, so scrolling looks
	-- exactly like a fully built grid.
	local rows = Kit.virtualRows(scope, {
		Step = scope:Computed(function(use)
			return use(cell) + gap
		end),
		Leading = pad,
		View = if typeof(givenSize) == "UDim2" and givenSize.Y.Scale == 0 and givenSize.Y.Offset > 0 then givenSize.Y.Offset else 0,
	})
	local firstRow, lastRow = rows.First, rows.Last

	-- Pair each item with the current cell size, so slots rebuild (with
	-- pixel-perfect insides) only when the fitted size really changes. Pairs
	-- are cached by item id and reused while the fields a slot shows are the
	-- same, so a data refresh with fresh tables rebuilds only what changed.
	local cache: { [any]: { item: any, cell: number } } = {}
	local entries = scope:Computed(function(use)
		local size = use(cell)
		local from = use(firstRow) * columns + 1
		local to = (use(lastRow) + 1) * columns
		local out = {}
		local nextCache = {}
		local seen = {}
		for index, item in use(items) or {} do
			local key = cacheKey(item, seen)
			if index >= from and index <= to then
				local cached = cache[key]
				if cached == nil or cached.cell ~= size or not sameFields(cached.item, item) then
					cached = { item = item, cell = size }
				else
					cached.item = item
				end
				nextCache[key] = cached
				out[index] = cached
			end
		end
		cache = nextCache
		return out
	end)

	local slots = scope:ForPairs(entries, function(_, innerScope, index, entry)
		local item = entry.item
		local id = field(item, "id")
		return index,
			ItemSlot(innerScope, {
				Name = if showNames then field(item, "name") else nil,
				Icon = field(item, "icon"),
				Rarity = field(item, "rarity") or "Common",
				Count = field(item, "count"),
				Equipped = field(item, "equipped"),
				Locked = field(item, "locked"),
				Size = entry.cell,
				LayoutOrder = index,
				Selected = innerScope:Computed(function(use)
					return id ~= nil and use(selected) == id
				end),
				OnClick = function()
					choose(entry.item)
				end,
			})
	end)

	local defaultWidth = columns * preferred + (columns - 1) * gap + pad * 2 + SCROLLBAR
	local defaultHeight = 4 * preferred + 3 * gap + pad * 2

	local scroller: ScrollingFrame? = nil
	scroller = scope:New "ScrollingFrame" {
		Name = "Scroll",
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollingDirection = Enum.ScrollingDirection.Y,
		AutomaticCanvasSize = Enum.AutomaticSize.None,
		CanvasSize = scope:Computed(function(use)
			local n = use(count)
			local rows = math.ceil(n / columns)
			local size = use(cell)
			local height = if rows > 0 then rows * size + (rows - 1) * gap + pad * 2 else 0
			return UDim2.fromOffset(0, height)
		end),
		ScrollBarThickness = SCROLLBAR,
		ScrollBarImageColor3 = scrollBar.ScrollBarImageColor3,
		ScrollBarImageTransparency = scrollBar.ScrollBarImageTransparency,
		TopImage = scrollBar.TopImage,
		MidImage = scrollBar.MidImage,
		BottomImage = scrollBar.BottomImage,
		VerticalScrollBarInset = Enum.ScrollBarInset.Always,
		ElasticBehavior = Enum.ElasticBehavior.WhenScrollable,
		Selectable = false,
		Visible = scope:Computed(function(use)
			return use(count) > 0
		end),
		ZIndex = 1,
		[OnChange "AbsoluteSize"] = function(size: Vector2)
			-- runs after layout; ignore the zero size before parenting
			if size.X > 0 and scroller then
				local width = size.X / accumulatedScale(scroller)
				if math.abs(width - peek(measured)) >= 1 then
					measured:set(width)
				end
			end
		end,
		[Children] = {
			scope:New "UIGridLayout" {
				CellSize = scope:Computed(function(use)
					local size = use(cell)
					return UDim2.fromOffset(size, size)
				end),
				CellPadding = UDim2.fromOffset(gap, gap),
				FillDirection = Enum.FillDirection.Horizontal,
				FillDirectionMaxCells = columns,
				SortOrder = Enum.SortOrder.LayoutOrder,
				-- left: a short first row reads as the start of a grid, not
				-- as a centred strip floating in the middle of the panel
				HorizontalAlignment = Enum.HorizontalAlignment.Left,
				VerticalAlignment = Enum.VerticalAlignment.Top,
			},
			scope:New "UIPadding" {
				-- the rows above the built ones
				PaddingTop = scope:Computed(function(use)
					return UDim.new(0, pad + use(firstRow) * (use(cell) + gap))
				end),
				PaddingBottom = UDim.new(0, pad),
				PaddingLeft = UDim.new(0, pad),
				PaddingRight = UDim.new(0, pad),
			},
			slots,
		},
	}
	rows.bind(scroller :: ScrollingFrame)

	local empty = scope:New "Frame" {
		Name = "Empty",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.new(1, -48, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		Visible = scope:Computed(function(use)
			return use(count) == 0
		end),
		ZIndex = 2,
		[Children] = {
			Kit.list(scope, { Direction = "Y", Padding = 8, HAlign = "Center" }),
			scope:New "Frame" {
				Name = "IconDisc",
				LayoutOrder = 1,
				Size = UDim2.fromOffset(120, 120),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.SurfaceLight
				end),
				BackgroundTransparency = 0.35,
				ZIndex = 2,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.stroke(scope, {
						Thickness = 3,
						Transparency = 0.5,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.TextMuted
						end),
					}),
					Icon(scope, {
						Icon = props.EmptyIcon or "backpack",
						Size = 64,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = Kit.themed(scope, function(theme)
							return theme.Color.TextMuted
						end),
						Gradient = true,
						Shadow = true,
						ZIndex = 3,
					}),
				},
			},
			Text(scope, {
				Name = "EmptyText",
				LayoutOrder = 2,
				Text = props.EmptyText or "Nothing here yet",
				TextSize = 30,
				Font = "Title",
				ZIndex = 2,
			}),
			if props.EmptyHint
				then Text(scope, {
					Name = "EmptyHint",
					LayoutOrder = 3,
					Text = props.EmptyHint,
					TextSize = 20,
					Font = "Body",
					Wrapped = true,
					Size = UDim2.new(1, 0, 0, 0),
					AutoSize = "Y",
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					Stroke = false,
					ZIndex = 2,
				})
				else nil,
		},
	}

	local root = Kit.root(props, { Name = "ItemGrid" })
	root.Size = props.Size or UDim2.fromOffset(defaultWidth, defaultHeight)
	root.BackgroundTransparency = 1
	root[Children] = {
		scroller,
		empty,
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end

return ItemGrid
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="115">
            <Properties>
              <string name="Name">ItemSlot</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	ItemSlot: one inventory / reward item. A rarity-tinted 3-stop gradient tile
	with a 3D lip, tinted outline, glossy highlight, soft glow and a big shaded
	icon. Shows a count, an equipped check, a lock overlay and a springy
	selection ring. Legendary and up get a slow shine and sparkles; Secret gets
	a slowly turning rainbow.

	scope:ItemSlot {
		Icon = "pet",
		Name = "Dragon",               -- optional label along the bottom
		Rarity = "Legendary",          -- Common | Uncommon | Rare | Epic | Legendary | Mythic | Secret (or state)
		Count = 3,                     -- optional "x3" (hidden at 0 / 1 / nil; text shown as-is)
		Equipped = true,               -- bool or state: green check badge
		Locked = false,                -- bool or state: dark overlay + lock
		Selected = isSelected,         -- bool or state: ring + pop
		Size = 108,                    -- px, square (a UDim2 also works, e.g. to fill a grid cell)
		OnClick = function() end,
	}
]]

local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

-- Rarity colours from docs/QUALITY.md, used until the theme defines its own.
local RARITY_HEX = {
	Common = "B0B7C3",
	Uncommon = "5BE22B",
	Rare = "3AA8FF",
	Epic = "B45CFF",
	Legendary = "FFB300",
	Mythic = "FF3B6B",
	Secret = "FFFFFF",
}
local RARITY_RANK = { Common = 1, Uncommon = 2, Rare = 3, Epic = 4, Legendary = 5, Mythic = 6, Secret = 7 }

local fallbackCache: { [string]: any } = {}
local function fallbackRarity(name: string): any
	local cached = fallbackCache[name]
	if cached then
		return cached
	end
	local tone
	if name == "Secret" then
		tone = {
			Top = WHITE,
			Bottom = Color3.fromHex("DCD2FF"),
			Edge = Color3.fromHex("6B5AA8"),
			Deep = Color3.fromHex("2E2748"),
			Text = WHITE,
			Stroke = Color3.fromHex("4A3F75"),
		}
	else
		local base = Color3.fromHex(RARITY_HEX[name] or RARITY_HEX.Common)
		tone = {
			Top = Color.lighten(base, 0.32),
			Bottom = base,
			Edge = Color.darken(base, 0.45),
			Deep = Color.darken(base, 0.72),
			Text = WHITE,
			Stroke = Color.darken(base, 0.58),
		}
	end
	fallbackCache[name] = tone
	return tone
end

-- Computed rarity tone: Kit.rarity / theme.Rarity when the theme has them,
-- otherwise tones built from the QUALITY.md hex colours.
local function rarityTone(scope: Scope, rarity: any): any
	local kitRarity = (Kit :: any).rarity
	if kitRarity then
		return kitRarity(scope, rarity)
	end
	return scope:Computed(function(use)
		local theme = use(Theme.current)
		local name = use(rarity) or "Common"
		if theme.Rarity and theme.Rarity[name] then
			return theme.Rarity[name]
		end
		return fallbackRarity(name)
	end)
end

local RAINBOW = ColorSequence.new({
	ColorSequenceKeypoint.new(0, Color3.fromHex("FF7AD9")),
	ColorSequenceKeypoint.new(0.2, Color3.fromHex("FFD36B")),
	ColorSequenceKeypoint.new(0.4, Color3.fromHex("7CF29A")),
	ColorSequenceKeypoint.new(0.6, Color3.fromHex("6BD8FF")),
	ColorSequenceKeypoint.new(0.8, Color3.fromHex("A98BFF")),
	ColorSequenceKeypoint.new(1, Color3.fromHex("FF7AD9")),
})

-- Rich 3-stop face gradient: bright top, true colour, deep bottom. Higher
-- rarities get more contrast.
local function faceSequence(tone: any, rank: number, lift: number): ColorSequence
	local boost = if rank >= 3 then 0.12 else 0.04
	return ColorSequence.new({
		ColorSequenceKeypoint.new(0, Color.lighten(tone.Top, boost + lift * 0.2)),
		ColorSequenceKeypoint.new(0.55, Color.lighten(tone.Bottom, lift * 0.12)),
		ColorSequenceKeypoint.new(1, Color.darken(tone.Bottom, if rank >= 3 then 0.32 else 0.2)),
	})
end

-- Crisp four-point twinkles (two crossed gradient bars) that grow and fade
-- in turn. Returns stop().
local TWINKLE_SPOTS = {
	{ 0.2, 0.24, 0.2, 0 },
	{ 0.82, 0.3, 0.14, 0.7 },
	{ 0.74, 0.8, 0.17, 1.4 },
}
local function twinkles(parent: GuiObject, px: number, zIndex: number): () -> ()
	local tweens = {}
	local untracks = {}
	local roots = {}
	for _, spot in TWINKLE_SPOTS do
		local size = math.max(10, math.floor(px * spot[3]))
		local root = Instance.new("Frame")
		root.Name = "Twinkle"
		root.BackgroundTransparency = 1
		root.AnchorPoint = Vector2.new(0.5, 0.5)
		root.Position = UDim2.fromScale(spot[1], spot[2])
		root.Size = UDim2.fromOffset(size, size)
		root.Rotation = 0
		root.ZIndex = zIndex
		for _, rotation in { 0, 90 } do
			local bar = Instance.new("Frame")
			bar.Name = "Ray"
			bar.BackgroundColor3 = WHITE
			bar.BorderSizePixel = 0
			bar.AnchorPoint = Vector2.new(0.5, 0.5)
			bar.Position = UDim2.fromScale(0.5, 0.5)
			bar.Size = UDim2.new(1, 0, 0, math.max(2, math.floor(size * 0.16)))
			bar.Rotation = rotation
			bar.ZIndex = zIndex
			local corner = Instance.new("UICorner")
			corner.CornerRadius = UDim.new(0.5, 0)
			corner.Parent = bar
			local gradient = Instance.new("UIGradient")
			gradient.Transparency = NumberSequence.new({
				NumberSequenceKeypoint.new(0, 1),
				NumberSequenceKeypoint.new(0.5, 0),
				NumberSequenceKeypoint.new(1, 1),
			})
			gradient.Parent = bar
			bar.Parent = root
		end
		local scale = Instance.new("UIScale")
		scale.Scale = 0.15
		scale.Parent = root
		root.Parent = parent
		local tween = TweenService:Create(
			scale,
			TweenInfo.new(0.7, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true, spot[4] + 0.6),
			{ Scale = 1 }
		)
		tween:Play()
		table.insert(tweens, tween)
		table.insert(untracks, Motion.track(tween, root))
		table.insert(roots, root)
	end
	return function()
		for _, untrack in untracks do
			untrack()
		end
		for _, tween in tweens do
			tween:Cancel()
		end
		for _, root in roots do
			root:Destroy()
		end
	end
end

local function ItemSlot(scope: Scope, props: { [any]: any }): TextButton
	local sizeProp = peek(props.Size) or 108
	local px = if typeof(sizeProp) == "number"
		then sizeProp
		elseif typeof(sizeProp) == "UDim2" and sizeProp.Y.Offset > 0 then sizeProp.Y.Offset
		else 108
	local rarity = props.Rarity or "Common"
	local tone = rarityTone(scope, rarity)
	local rank = scope:Computed(function(use)
		return RARITY_RANK[use(rarity) or "Common"] or 1
	end)
	local isSecret = scope:Computed(function(use)
		return use(rarity) == "Secret"
	end)

	local selected = props.Selected or false
	local locked = props.Locked or false
	local equipped = props.Equipped or false
	local hasName = props.Name ~= nil

	local hovering = scope:Value(false)
	local pressing = scope:Value(false)

	local hoverScale = Kit.spring(scope, scope:Computed(function(use)
		local motion = use(Theme.current).Motion
		if use(pressing) then
			return math.max(motion.PressScale, 0.94)
		elseif use(hovering) then
			return math.min(motion.HoverScale, 1.07)
		end
		return 1
	end), 1.1)
	local popGoal = scope:Value(1)
	local pop = scope:Spring(popGoal, 32, 0.38)
	local ringGoal = scope:Value(1)
	local ringScale = scope:Spring(ringGoal, 26, 0.5)
	local lift = scope:Spring(scope:Computed(function(use)
		return if use(hovering) then 1 else 0
	end), 22, 1)

	Kit.watch(scope, selected, function(now)
		if now then
			pop:setPosition(0.9)
			pop:addVelocity(5)
			ringScale:setPosition(1.22)
		end
	end)

	local depth = math.max(3, math.floor(px * 0.045))
	local strokeWidth = math.max(2, math.floor(px * 0.028 + 0.5))
	local iconSize = math.floor(px * (if hasName then 0.56 else 0.64))

	local countText = scope:Computed(function(use)
		local count = use(props.Count)
		if count == nil or count == false then
			return ""
		elseif typeof(count) == "number" then
			return if count > 1 then `x{Format.abbrev(count)}` else ""
		end
		return tostring(count)
	end)

	local edgeColor = scope:Computed(function(use)
		return use(tone).Edge
	end)
	local strokeColor = scope:Computed(function(use)
		local t = use(tone)
		return t.Stroke or t.Edge
	end)

	local faceGradient = scope:New "UIGradient" {
		Name = "FaceGradient",
		Rotation = 75,
		Color = scope:Computed(function(use)
			if use(isSecret) then
				return use(tone).Rainbow or RAINBOW
			end
			-- v2.4 style families: the family's card fill (vivid / tint / flat)
			local fill = Kit.cardFill(use(Theme.current), use(tone), use(lift))
			if fill then
				return fill
			end
			return faceSequence(use(tone), use(rank), use(lift))
		end),
	}

	local face = scope:New "Frame" {
		Name = "Face",
		Size = UDim2.new(1, 0, 1, -depth),
		BackgroundColor3 = WHITE,
		ZIndex = 2,
		[Children] = {
			Kit.corner(scope, "M"),
			faceGradient,
			scope:New "ImageLabel" {
				Name = "Glow",
				BackgroundTransparency = 1,
				Image = Assets.SoftShadow,
				ImageColor3 = scope:Computed(function(use)
					return Color.lighten(use(tone).Top, 0.6)
				end),
				ImageTransparency = scope:Computed(function(use)
					local r = use(rank)
					return if r >= 5 then 0.25 elseif r >= 3 then 0.45 else 0.7
				end),
				ScaleType = Enum.ScaleType.Slice,
				SliceCenter = Assets.SoftShadowSlice,
				SliceScale = math.max(0.05, (px * 0.3) / Assets.SoftShadowFalloff),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, if hasName then 0.42 else 0.5),
				Size = UDim2.fromScale(0.78, 0.78),
				ZIndex = 1,
			},
			-- (Theme.lite / Theme.setLite: no gloss layer or lip outline)
			Kit.unlessLite(scope, function(inner)
				return inner:New "Frame" {
					Name = "Gloss",
					BackgroundColor3 = WHITE,
					BackgroundTransparency = 0.45,
					Position = UDim2.fromOffset(3, 3),
					Size = UDim2.new(1, -6, 0.42, 0),
					ZIndex = 3,
					[Children] = {
						Kit.corner(inner, "M"),
						Kit.gradient(inner, {
							Rotation = 90,
							Transparency = NumberSequence.new({
								NumberSequenceKeypoint.new(0, 0.25),
								NumberSequenceKeypoint.new(1, 1),
							}),
						}),
					},
				}
			end),
			Icon(scope, {
				Name = "ItemIcon",
				Icon = props.Icon or "gift",
				Size = iconSize,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, if hasName then 0.42 else 0.5),
				Color = scope:Computed(function(use)
					return Color.lighten(use(tone).Top, 0.55)
				end),
				Gradient = true,
				Shadow = true,
				Stroke = math.max(2, px * 0.03),
				StrokeColor = strokeColor,
				Transparency = scope:Computed(function(use)
					return if use(locked) then 0.2 else 0
				end),
				ZIndex = 4,
			}),
			if hasName
				then Text(scope, {
					Name = "ItemName",
					Text = props.Name,
					TextSize = math.max(14, math.floor(px * 0.155)),
					Font = "Display",
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -math.floor(px * 0.04)),
					Size = UDim2.new(1, -8, 0, math.floor(px * 0.2)),
					Truncate = true,
					StrokeColor = strokeColor,
					Color = scope:Computed(function(use)
						return use(tone).Text
					end),
					ZIndex = 5,
				})
				else nil,
		},
	}

	local body = scope:New "Frame" {
		Name = "Body",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromScale(1, 1),
		ZIndex = 2,
		[Children] = {
			scope:New "Frame" {
				Name = "Lip",
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = scope:Computed(function(use)
					return use(tone).Deep
				end),
				ZIndex = 1,
				[Children] = {
					Kit.corner(scope, "M"),
					Kit.unlessLite(scope, function(inner)
						return Kit.stroke(inner, { Color = edgeColor, Thickness = strokeWidth })
					end),
				},
			},
			face,
			Text(scope, {
				Name = "Count",
				Text = countText,
				Case = "Normal",
				TextSize = math.max(15, math.floor(px * 0.19)),
				Font = "Display",
				XAlign = "Left",
				AnchorPoint = Vector2.new(0, 0),
				Position = UDim2.fromOffset(math.floor(px * 0.07), math.floor(px * 0.04)),
				StrokeColor = strokeColor,
				Visible = scope:Computed(function(use)
					return use(countText) ~= ""
				end),
				ZIndex = 6,
			}),
			scope:New "Frame" {
				Name = "LockOverlay",
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Backdrop
				end),
				BackgroundTransparency = 0.5,
				Visible = scope:Computed(function(use)
					return use(locked) == true
				end),
				ZIndex = 7,
				[Children] = {
					Kit.corner(scope, "M"),
					Icon(scope, {
						Name = "Lock",
						Icon = "lock",
						Size = math.floor(px * 0.36),
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Light
						end),
						StrokeColor = Kit.themed(scope, function(theme)
							return theme.Color.Outline
						end),
						Stroke = math.max(2, px * 0.025),
						Gradient = true,
						Shadow = true,
						ZIndex = 8,
					}),
				},
			},
			scope:New "Frame" {
				Name = "Equipped",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(1, -math.floor(px * 0.1), 0, math.floor(px * 0.1)),
				Size = UDim2.fromOffset(math.floor(px * 0.28), math.floor(px * 0.28)),
				BackgroundColor3 = WHITE,
				Visible = scope:Computed(function(use)
					return use(equipped) == true
				end),
				ZIndex = 9,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = Kit.themed(scope, function(theme)
							local green = theme.Tone.Green
							return ColorSequence.new(green.Top, green.Bottom)
						end),
					}),
					Kit.stroke(scope, {
						Thickness = math.max(2, math.floor(px * 0.022)),
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Light
						end),
					}),
					Icon(scope, {
						Icon = "check",
						Size = math.floor(px * 0.2),
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = Kit.themed(scope, function(theme)
							return theme.Tone.Green.Text
						end),
						StrokeColor = Kit.themed(scope, function(theme)
							return theme.Tone.Green.Edge
						end),
						Stroke = 1.5,
						ZIndex = 10,
					}),
				},
			},
		},
	}

	local ringPad = math.max(8, math.floor(px * 0.08))
	local ring = scope:New "Frame" {
		Name = "SelectedRing",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.new(1, ringPad, 1, ringPad),
		BackgroundTransparency = 1,
		Visible = scope:Computed(function(use)
			return use(selected) == true
		end),
		ZIndex = 3,
		[Children] = {
			scope:New "UIScale" { Scale = ringScale },
			scope:New "UICorner" {
				CornerRadius = Kit.themed(scope, function(theme)
					return UDim.new(0, theme.Radius.M + math.floor(ringPad / 2))
				end),
			},
			Kit.stroke(scope, {
				Thickness = math.max(3, math.floor(px * 0.035)),
				Color = Kit.themed(scope, function(theme)
					return theme.Color.Light
				end),
			}),
		},
	}

	local ringGlow = scope:New "ImageLabel" {
		Name = "RingGlow",
		BackgroundTransparency = 1,
		Image = Assets.SoftShadow,
		ImageColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Accent
		end),
		ImageTransparency = 0.3,
		ScaleType = Enum.ScaleType.Slice,
		SliceCenter = Assets.SoftShadowSlice,
		SliceScale = math.max(0.05, (px * 0.22) / Assets.SoftShadowFalloff),
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.new(1, math.floor(px * 0.3), 1, math.floor(px * 0.3)),
		Visible = scope:Computed(function(use)
			return use(selected) == true
		end),
		ZIndex = 1,
	}

	local root = Kit.root(props, { Name = "ItemSlot" })
	root.Size = scope:Computed(function(use)
		local size = use(props.Size) or px
		return if typeof(size) == "UDim2" then size else Kit.udim2(size)
	end)
	root.BackgroundTransparency = 1
	root.Text = ""
	root.AutoButtonColor = false
	root.Selectable = true

	root[OnEvent "MouseEnter"] = function()
		hovering:set(true)
		Sound.play("Hover", { Volume = 0.3 })
	end
	root[OnEvent "MouseLeave"] = function()
		hovering:set(false)
		pressing:set(false)
	end
	root[OnEvent "SelectionGained"] = function()
		hovering:set(true)
	end
	root[OnEvent "SelectionLost"] = function()
		hovering:set(false)
	end
	root[OnEvent "MouseButton1Down"] = function()
		pressing:set(true)
	end
	root[OnEvent "MouseButton1Up"] = function()
		pressing:set(false)
	end
	root[OnEvent "Activated"] = function(input: InputObject?)
		if input and input.UserInputType == Enum.UserInputType.Touch then
			hovering:set(false)
		end
		pressing:set(false)
		Sound.play("Click", { Pitch = 1.1 })
		if props.OnClick then
			props.OnClick()
		end
	end

	root[Children] = {
		scope:New "UIScale" {
			Scale = scope:Computed(function(use)
				return use(hoverScale) * use(pop)
			end),
		},
		Kit.shadow(scope, {
			Spread = math.max(6, math.floor(px * 0.1)),
			Offset = Vector2.new(0, math.floor(px * 0.05)),
			ZIndex = 0,
		}),
		ringGlow,
		body,
		ring,
		props[Children],
	}

	local slot = scope:New "TextButton" (root) :: TextButton

	-- Legendary+ shine and sparkles; Secret rainbow turns slowly.
	local alive = true
	local stopShine: (() -> ())? = nil
	local stopSparkles: (() -> ())? = nil
	local spinTween: Tween? = nil
	local untrackSpin: (() -> ())? = nil
	local function updateFx()
		if not alive then
			return
		end
		local r = peek(rank)
		local fancy = r >= 5
		if fancy and stopShine == nil then
			stopShine = Motion.shine(face, { ZIndex = 3, Transparency = 0.45, Interval = 3.2, Duration = 0.9, Width = 0.14 })
			stopSparkles = twinkles(face, px, 6)
		elseif not fancy and stopShine then
			stopShine()
			stopShine = nil
			if stopSparkles then
				stopSparkles()
				stopSparkles = nil
			end
		end
		local secret = peek(isSecret)
		if secret and spinTween == nil then
			faceGradient.Rotation = 0
			local tween = TweenService:Create(
				faceGradient,
				TweenInfo.new(6, Enum.EasingStyle.Linear, Enum.EasingDirection.In, -1),
				{ Rotation = 360 }
			)
			tween:Play()
			spinTween = tween
			untrackSpin = Motion.track(tween, face)
		elseif not secret and spinTween then
			if untrackSpin then
				untrackSpin()
				untrackSpin = nil
			end
			spinTween:Cancel()
			spinTween = nil
			faceGradient.Rotation = 75
		end
	end
	updateFx()
	Kit.watch(scope, rank, updateFx)
	Kit.watch(scope, isSecret, updateFx)
	table.insert(scope, function()
		alive = false
		if stopShine then
			stopShine()
		end
		if stopSparkles then
			stopSparkles()
		end
		if untrackSpin then
			untrackSpin()
		end
		if spinTween then
			spinTween:Cancel()
		end
	end)

	return slot
end

return ItemSlot
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="116">
            <Properties>
              <string name="Name">KeyCap</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	KeyCap: shows a key the player should press, and swaps to the matching
	Xbox / PlayStation button when they pick up a controller.

	scope:KeyCap {
		Key = Enum.KeyCode.E,               -- keyboard/mouse key (or text like "LMB")
		Gamepad = Enum.KeyCode.ButtonX,     -- optional controller button
		Size = 30,
	}

	Keyboard and mouse keys sit on a keycap with a small lip (the theme's
	Keycap colour, e.g. Rivals' #484848, else a white cap). Controller
	buttons draw as their own glyph: A / B / X / Y (and the PlayStation
	shapes) in their familiar colours, other buttons outlined so they read
	on light and dark panels alike.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Input = require(script.Parent.Parent.Util.Input)
local Icons = require(script.Parent.Parent.Icons)

local Children = Fusion.Children

local BLACK = Color3.new(0, 0, 0)

-- Face buttons: the colour that shows through the glyph's cut-out letter.
local FACE_COLORS = {
	["xbox-a"] = Color3.fromHex("5CBA3A"),
	["xbox-b"] = Color3.fromHex("E0413A"),
	["xbox-x"] = Color3.fromHex("2E7BE6"),
	["xbox-y"] = Color3.fromHex("E8B90F"),
	["ps-x"] = Color3.fromHex("6F9BE8"),
	["ps-circle"] = Color3.fromHex("E25A5A"),
	["ps-square"] = Color3.fromHex("D57BD0"),
	["ps-triagle"] = Color3.fromHex("3CC8A0"),
}

local function isPadGlyph(name: string): boolean
	return string.find(name, "^xbox") ~= nil or string.find(name, "^ps") ~= nil
end

return function(scope: Fusion.Scope<any>, props: { [any]: any }): Frame
	local size = props.Size or 30
	local lip = math.clamp(math.floor(size * 0.08 + 0.5), 2, 3)

	local glyph = scope:Computed(function(use)
		local device = use(Input.device)
		local key = use(props.Key)
		local pad = use(props.Gamepad)
		if device == "Gamepad" and pad then
			key = pad
		end
		local kind, value = Input.glyph(key)
		return { kind = kind, value = value }
	end)

	local isIcon = scope:Computed(function(use)
		return use(glyph).kind == "icon"
	end)
	-- controller buttons are their own shape: no keycap under them
	local isPad = scope:Computed(function(use)
		local g = use(glyph)
		return g.kind == "icon" and isPadGlyph(g.value)
	end)
	local faceColor = scope:Computed(function(use)
		local g = use(glyph)
		return if g.kind == "icon" then FACE_COLORS[g.value] else nil
	end)

	local capColor = Kit.themed(scope, function(theme)
		return theme.Color.Keycap or theme.Color.Light
	end)
	local darkCap = Kit.themed(scope, function(theme)
		return theme.Color.Keycap ~= nil
	end)
	local legend = scope:Computed(function(use)
		local theme = use(Theme.current)
		return if use(darkCap) then theme.Color.Light else theme.Color.TextDark
	end)

	local root = Kit.root(props, { Name = "KeyCap" })
	root.Size = UDim2.fromOffset(size, size)
	root.AutomaticSize = scope:Computed(function(use)
		return if use(isIcon) then Enum.AutomaticSize.None else Enum.AutomaticSize.X
	end)
	root.BackgroundTransparency = 1
	root[Children] = {
		scope:New "UIPadding" {
			PaddingLeft = UDim.new(0, math.floor(size * 0.26)),
			PaddingRight = UDim.new(0, math.floor(size * 0.26)),
		},
		scope:New "UISizeConstraint" {
			MinSize = Vector2.new(size, size),
		},
		-- the key's side: a darker band under the cap
		scope:New "Frame" {
			Name = "Lip",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.new(1, math.floor(size * 0.52), 1, 0),
			BackgroundColor3 = scope:Computed(function(use)
				local theme = use(Theme.current)
				return if use(darkCap) then theme.Color.Keycap:Lerp(BLACK, 0.5) else theme.Tone.White.Edge
			end),
			Visible = scope:Computed(function(use)
				return not use(isPad)
			end),
			ZIndex = 1,
			[Children] = {
				Kit.corner(scope, math.floor(size * 0.2)),
				Kit.stroke(scope, {
					Thickness = 1,
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						return if use(darkCap) then theme.Color.Keycap:Lerp(BLACK, 0.7) else theme.Tone.White.Deep
					end),
					Transparency = 0.3,
				}),
			},
		},
		scope:New "Frame" {
			Name = "Cap",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5, 0),
			Size = UDim2.new(1, math.floor(size * 0.52), 1, -lip),
			BackgroundColor3 = capColor,
			Visible = scope:Computed(function(use)
				return not use(isPad)
			end),
			ZIndex = 2,
			[Children] = {
				Kit.corner(scope, math.floor(size * 0.2)),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = ColorSequence.new(Color3.new(1, 1, 1), Color3.fromRGB(214, 218, 228)),
				}),
			},
		},
		-- face buttons: the button colour shows through the glyph's letter
		scope:New "Frame" {
			Name = "FaceColor",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromOffset(math.floor(size * 0.78), math.floor(size * 0.78)),
			BackgroundColor3 = scope:Computed(function(use)
				return use(faceColor) or BLACK
			end),
			Visible = scope:Computed(function(use)
				return use(faceColor) ~= nil
			end),
			ZIndex = 3,
			[Children] = Kit.corner(scope, "Full"),
		},
		scope:New "TextLabel" {
			Name = "Label",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = scope:Computed(function(use)
				return if use(isPad) then UDim2.fromScale(0.5, 0.5) else UDim2.new(0.5, 0, 0.5, -math.floor(lip / 2))
			end),
			Size = scope:Computed(function(use)
				if use(isPad) then
					return UDim2.fromOffset(size * 1.1, size * 1.1)
				elseif use(isIcon) then
					return UDim2.fromOffset(math.floor(size * 0.66), math.floor(size * 0.66))
				end
				return UDim2.fromScale(0, 1)
			end),
			AutomaticSize = scope:Computed(function(use)
				return if use(isIcon) then Enum.AutomaticSize.None else Enum.AutomaticSize.X
			end),
			FontFace = scope:Computed(function(use)
				return if use(isIcon) then Icons.font(true, use) else use(Theme.current).Font.Heavy
			end),
			Text = scope:Computed(function(use)
				return use(glyph).value
			end),
			TextSize = math.floor(size * 0.6),
			TextScaled = isIcon,
			TextColor3 = scope:Computed(function(use)
				if use(isPad) then
					return Color3.new(1, 1, 1)
				end
				return use(legend)
			end),
			ZIndex = 4,
			[Children] = {
				-- controller glyphs get a thin dark outline so the white
				-- shape still reads on light panels
				scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual,
					LineJoinMode = Enum.LineJoinMode.Round,
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Outline
					end),
					Thickness = 1.5 / math.max(1, size * 1.1 / 96),
					Transparency = 0.25,
					Enabled = isPad,
				},
				scope:New "UITextSizeConstraint" {
					MaxTextSize = 100,
				},
			},
		},
	}
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="117">
            <Properties>
              <string name="Name">KeyHints</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	KeyHints: a pill of control hints, "Leave Shooting Range [M]  Quick Melee [F]".
	Hidden automatically on touch screens.

	scope:KeyHints {
		Hints = {
			{ Text = "Leave Shooting Range", Key = Enum.KeyCode.M },
			{ Text = "Quick Melee", Key = Enum.KeyCode.F, Gamepad = Enum.KeyCode.ButtonB },
			{ Text = "Quick Utility", Key = Enum.KeyCode.G },
		},
		AnchorPoint = Vector2.new(1, 1),
		Position = UDim2.new(1, -16, 1, -16),
	}

	HUD kit looks (Style, with Kit = a HudKits name / tokens):
		"Strip"    the pill above (the original)
		"Prompts"  one floating prompt per hint (a big key and a label), stacked
		"Sheet"    a corner cheat-sheet card: CONTROLS, a key column and actions
		Align = "Right"   -- the screen side the prompts hug
	Every look sets a HudSize attribute (its footprint in a HUD layout).
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Input = require(script.Parent.Parent.Util.Input)
local Text = require(script.Parent.Text)
local KeyCap = require(script.Parent.KeyCap)
local P = require(script.Parent.HudParts)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local function visibleOnKeys(scope: Scope, props: { [any]: any }): any
	return scope:Computed(function(use)
		local visible = use(props.Visible)
		return visible ~= false and use(Input.device) ~= "Touch"
	end)
end

local function hintsOf(props: { [any]: any }): { any }
	return peek(props.Hints) or {}
end

-- Width of a hint's text in the kit's label face (and case).
local function measure(T: { [string]: any }, text: any, size: number): number
	local s = tostring(text or "")
	if T.case == "Upper" then
		s = string.upper(s)
	end
	return P.textWidth(s, size, T.labelFont)
end

-- One floating prompt per hint.
local function prompts(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local list = hintsOf(props)
	local look = P.look(scope, T)
	local right = props.Align ~= "Left"
	local cap = 34
	local rowH = 46
	local gap = 8
	local capW = 52 -- a wide key ("Tab", "Shift") runs wider than it is tall
	local widest = 0
	for _, hint in list do
		widest = math.max(widest, measure(T, hint.Text, 18))
	end
	local width = math.ceil(widest) + capW + 44
	local size = Vector2.new(width, #list * rowH + math.max(0, #list - 1) * gap)
	local rows = {}
	for index, hint in list do
		local textW = math.ceil(measure(T, hint.Text, 18)) + capW + 22
		table.insert(rows, scope:New "Frame" {
			Name = `Hint{index}`,
			BackgroundTransparency = 1,
			LayoutOrder = index,
			Size = UDim2.new(1, 0, 0, rowH),
			[Children] = {
				P.plate(scope, T, {
					Name = "Tag",
					Radius = if T.corner == "Round" then "Pill" else nil,
					AnchorPoint = Vector2.new(if right then 1 else 0, 0.5),
					Position = UDim2.new(if right then 1 else 0, if right then -(cap / 2) else cap / 2, 0.5, 0),
					Size = UDim2.fromOffset(textW, rowH - 12),
					ZIndex = 1,
				}),
				P.text(scope, T, look, {
					Name = "Action",
					Role = "Title",
					Text = hint.Text,
					TextSize = 18,
					XAlign = if right then "Right" else "Left",
					AnchorPoint = Vector2.new(if right then 1 else 0, 0.5),
					Position = UDim2.new(if right then 1 else 0, if right then -(capW + 12) else capW + 12, 0.5, 0),
					Color = scope:Computed(function(use)
						return use(look).ink
					end),
					ZIndex = 4,
				}),
				KeyCap(scope, {
					Key = hint.Key,
					Gamepad = hint.Gamepad,
					Size = cap,
					AnchorPoint = Vector2.new(if right then 1 else 0, 0.5),
					Position = UDim2.new(if right then 1 else 0, 0, 0.5, 0),
					ZIndex = 5,
				}),
			},
		})
	end
	local root = Kit.root(props, { Name = "KeyHints" })
	root.BackgroundTransparency = 1
	root.Visible = visibleOnKeys(scope, props)
	root[Children] = {
		scope:New "UIListLayout" {
			FillDirection = Enum.FillDirection.Vertical,
			SortOrder = Enum.SortOrder.LayoutOrder,
			Padding = UDim.new(0, gap),
			VerticalAlignment = Enum.VerticalAlignment.Bottom,
		},
		rows,
	}
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

-- A corner cheat-sheet: a CONTROLS header, keys in a column, actions beside.
local function sheet(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local list = hintsOf(props)
	local look = P.look(scope, T)
	local rowH = 34
	local head = 38
	local pad = 14
	local keyCol = 52
	local widest = 0
	for _, hint in list do
		widest = math.max(widest, measure(T, hint.Text, 17))
	end
	local width = math.max(200, math.ceil(widest) + keyCol + pad * 2 + 8)
	local size = Vector2.new(width, head + #list * rowH + pad)
	local rows = {}
	for index, hint in list do
		table.insert(rows, scope:New "Frame" {
			Name = `Hint{index}`,
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(pad, head + (index - 1) * rowH),
			Size = UDim2.new(1, -pad * 2, 0, rowH),
			ZIndex = 4,
			[Children] = {
				KeyCap(scope, {
					Key = hint.Key,
					Gamepad = hint.Gamepad,
					Size = 26,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0, keyCol / 2 - 6, 0.5, 0),
					ZIndex = 5,
				}),
				P.text(scope, T, look, {
					Name = "Action",
					Role = "Title",
					Text = hint.Text,
					TextSize = 17,
					Color = scope:Computed(function(use)
						return use(look).ink
					end),
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, keyCol, 0.5, 0),
					Size = UDim2.new(1, -keyCol, 0, 22),
					Truncate = true,
					ZIndex = 5,
				}),
				if index < #list
					then scope:New "Frame" {
						Name = "Rule",
						AnchorPoint = Vector2.new(0, 1),
						Position = UDim2.fromScale(0, 1),
						Size = UDim2.new(1, 0, 0, 1),
						BackgroundColor3 = scope:Computed(function(use)
							return use(look).muted
						end),
						BackgroundTransparency = 0.78,
						BorderSizePixel = 0,
						ZIndex = 4,
					}
					else nil,
			},
		})
	end
	local root = Kit.root(props, { Name = "KeyHints" })
	root.BackgroundTransparency = 1
	root.Visible = visibleOnKeys(scope, props)
	root[Children] = {
		P.plate(scope, T, { Edge = if props.Align == "Left" then "Left" else "Right", Idle = true }),
		P.icon(scope, T, {
			Name = "HeaderIcon",
			Icon = "keyboard",
			Size = 22,
			Treatment = "Flat",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.fromOffset(pad, head / 2 + 2),
			Color = scope:Computed(function(use)
				return use(look).accent
			end),
			ZIndex = 4,
		}),
		P.text(scope, T, look, {
			Name = "Title",
			Role = "Label",
			Text = props.Title or "Controls",
			TextSize = 15,
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.fromOffset(pad + 30, head / 2 + 2),
			Color = scope:Computed(function(use)
				return use(look).accent
			end),
			ZIndex = 4,
		}),
		rows,
	}
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

return function(scope: Scope, props: { [any]: any }): Frame
	local style = P.style("KeyHints", props.Style)
	if style == "Prompts" then
		return prompts(scope, props, P.tokens(props.Kit))
	elseif style == "Sheet" then
		return sheet(scope, props, P.tokens(props.Kit))
	end

	local textSize = props.TextSize or 17

	-- the footprint a HUD layout reserves
	local footprint = 28
	for index, hint in hintsOf(props) do
		local length = utf8.len(tostring(hint.Text or "")) or 0
		footprint += math.ceil(length * 17 * 0.56) + 8 + 30 + (if index > 1 then 20 else 0)
	end

	if P.kitted(props.Kit) then
		-- the strip in the kit's plate and type
		local T = P.tokens(props.Kit)
		local look = P.look(scope, T)
		local items = scope:ForPairs(props.Hints or {}, function(_, innerScope, index, hint)
			return index, innerScope:New "Frame" {
				Name = `Hint{index}`,
				LayoutOrder = index,
				BackgroundTransparency = 1,
				Size = UDim2.fromOffset(0, textSize + 12),
				AutomaticSize = Enum.AutomaticSize.X,
				[Children] = {
					Kit.list(innerScope, { Direction = "X", Padding = 8, VAlign = "Center" }),
					P.text(innerScope, T, look, {
						Text = hint.Text,
						TextSize = textSize,
						Role = "Title",
						Color = innerScope:Computed(function(use)
							return use(look).ink
						end),
						LayoutOrder = 1,
						ZIndex = 4,
					}),
					KeyCap(innerScope, {
						Key = hint.Key,
						Gamepad = hint.Gamepad,
						Size = textSize + 9,
						LayoutOrder = 2,
						ZIndex = 4,
					}),
				},
			}
		end)
		local root = Kit.root(props, { Name = "KeyHints" })
		root.Size = UDim2.fromOffset(0, textSize + 24)
		root.AutomaticSize = Enum.AutomaticSize.X
		root.BackgroundTransparency = 1
		root.Visible = visibleOnKeys(scope, props)
		root[Children] = {
			P.plate(scope, T, { Radius = if T.corner == "Round" or T.corner == "Theme" then "Pill" else nil, Idle = true }),
			scope:New "Frame" {
				Name = "Hints",
				BackgroundTransparency = 1,
				Size = UDim2.fromOffset(0, textSize + 24),
				AutomaticSize = Enum.AutomaticSize.X,
				ZIndex = 3,
				[Children] = {
					Kit.padding(scope, { 0, 16 }),
					Kit.list(scope, { Direction = "X", Padding = 20, VAlign = "Center" }),
					items,
				},
			},
		}
		local frame = scope:New "Frame" (root) :: Frame
		frame:SetAttribute("HudSize", Vector2.new(footprint, 40))
		return frame
	end

	local items = scope:ForPairs(props.Hints or {}, function(_, innerScope, index, hint)
		return index, innerScope:New "Frame" {
			Name = `Hint{index}`,
			LayoutOrder = index,
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(0, textSize + 12),
			AutomaticSize = Enum.AutomaticSize.X,
			[Children] = {
				Kit.list(innerScope, { Direction = "X", Padding = 8, VAlign = "Center" }),
				Text(innerScope, {
					Text = hint.Text,
					TextSize = textSize,
					Font = "Small",
					Stroke = false,
					LayoutOrder = 1,
					ZIndex = 2,
				}),
				KeyCap(innerScope, {
					Key = hint.Key,
					Gamepad = hint.Gamepad,
					Size = textSize + 9,
					LayoutOrder = 2,
					ZIndex = 2,
				}),
			},
		}
	end)

	local look, layers = Kit.surface(scope, {
		Style = "Glass",
		Radius = "Full",
		Highlight = false,
		Color = Kit.themed(scope, function(theme)
			return theme.Color.Pill or theme.Color.Surface
		end),
	})
	local root = Kit.root(props, { Name = "KeyHints" })
	root.Size = UDim2.fromOffset(0, textSize + 24)
	root.AutomaticSize = Enum.AutomaticSize.X
	root.BackgroundColor3 = look.BackgroundColor3
	root.BackgroundTransparency = Kit.themed(scope, function(theme)
		return math.min(theme.Alpha.Surface, 0.2)
	end)
	root.Visible = visibleOnKeys(scope, props)
	root[Children] = {
		layers,
		Kit.padding(scope, { 0, 16 }),
		Kit.list(scope, { Direction = "X", Padding = 20, VAlign = "Center" }),
		items,
	}
	local frame = scope:New "Frame" (root) :: Frame
	frame:SetAttribute("HudSize", Vector2.new(footprint, 40))
	return frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="118">
            <Properties>
              <string name="Name">KeybindRow</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	KeybindRow: one row of a controls menu. Action name on the left, chunky
	key caps on the right. Click a cap (or press A on it with a controller)
	and the row listens: "Press a key…". The next key becomes the binding;
	a controller button rebinds the Gamepad cap instead. Escape (or clicking
	anywhere else) cancels.

	local binds = scope:Value({})            -- share one Group so rows spot clashes
	scope:KeybindRow {
		Action = "Sprint",
		Icon = "run",                           -- optional
		Key = sprintKey,                        -- Value<KeyCode>
		Gamepad = sprintPad,                    -- optional KeyCode or Value<KeyCode>
		Group = binds,                          -- optional: rows in a group warn about duplicate keys
		Conflicts = function(key, action) return "Chat" end,  -- optional extra clash check (name or nil)
		OnRebind = function(newKey, oldKey, info) end,        -- info = { Gamepad = bool, Conflict = name? }
		Size = UDim2.new(1, 0, 0, 76),
	}

	Clashes don't block the rebind (players often swap keys on purpose): both
	rows light up with an "Also used by …" chip until one of them changes.
]]

local UserInputService = game:GetService("UserInputService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Input = require(script.Parent.Parent.Util.Input)
local Sound = require(script.Parent.Parent.Util.Sound)
local Color = require(script.Parent.Parent.Util.Color)
local Motion = require(script.Parent.Parent.FX.Motion)
local Icons = require(script.Parent.Parent.Icons)
local Gamepad = require(script.Parent.Parent.Util.Gamepad)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)
local LISTEN_TIMEOUT = 8
local CAP_HEIGHT = 52
local CAP_DEPTH = 5

-- Colour of each controller face button, like the real pad.
local PAD_COLORS = {
	ButtonA = Color3.fromHex("5BD32B"),
	ButtonB = Color3.fromHex("FF4A4A"),
	ButtonX = Color3.fromHex("2F9BFF"),
	ButtonY = Color3.fromHex("FFC21F"),
}

-- Only one row listens at a time.
local cancelActive: (() -> ())? = nil

local function isGamepadInput(inputType: EnumItem): boolean
	return string.find(inputType.Name, "^Gamepad") ~= nil
end

local function isPadButton(key: EnumItem): boolean
	local name = key.Name
	return string.find(name, "^Button") ~= nil or string.find(name, "^DPad") ~= nil
end

-- A chunky 3D key cap button. `key` is a state of KeyCode / UserInputType.
local function KeyCapButton(scope: Scope, props: { [any]: any }): TextButton
	local key = props.Key
	local pad = props.Pad == true
	local hovering = scope:Value(false)
	local pressing = scope:Value(false)
	local warn = props.Warn

	local glyph = scope:Computed(function(use)
		local kind, value = Input.glyph(use(key))
		return { kind = kind, value = value }
	end)
	local isIcon = scope:Computed(function(use)
		return use(glyph).kind == "icon"
	end)

	local width = scope:Computed(function(use)
		local g = use(glyph)
		if g.kind == "icon" then
			return CAP_HEIGHT + 4
		end
		return math.max(CAP_HEIGHT + 12, 30 + utf8.len(g.value) * 14)
	end)

	local scale = Kit.spring(scope, scope:Computed(function(use)
		if use(pressing) then
			return 0.93
		elseif use(hovering) then
			return 1.06
		end
		return 1
	end), 1.2)
	local press = scope:Spring(scope:Computed(function(use)
		return if use(pressing) then CAP_DEPTH - 1 else 0
	end), 45, 0.9)

	local tone = scope:Computed(function(use)
		local theme = use(Theme.current)
		if pad then
			return theme.Tone.Dark
		end
		return theme.Tone.White
	end)

	local glyphColor = scope:Computed(function(use)
		local theme = use(Theme.current)
		if pad then
			local k = use(key)
			local name = if typeof(k) == "EnumItem" then k.Name else ""
			return PAD_COLORS[name] or theme.Color.Light
		end
		return theme.Color.TextDark
	end)

	local edgeColor = scope:Computed(function(use)
		if use(warn) then
			return use(Theme.current).Color.Bad
		end
		return use(tone).Deep
	end)

	local root = Kit.root(props, { Name = if pad then "PadCap" else "KeyCap" })
	root.Size = scope:Computed(function(use)
		return UDim2.fromOffset(use(width), CAP_HEIGHT)
	end)
	root.BackgroundTransparency = 1
	root.Text = ""
	root.AutoButtonColor = false
	root.Selectable = true
	root.Active = true
	root[OnEvent "MouseEnter"] = function()
		hovering:set(true)
		Sound.play("Hover", { Volume = 0.35 })
	end
	root[OnEvent "MouseLeave"] = function()
		hovering:set(false)
		pressing:set(false)
	end
	root[OnEvent "SelectionGained"] = function()
		hovering:set(true)
	end
	root[OnEvent "SelectionLost"] = function()
		hovering:set(false)
	end
	root[OnEvent "MouseButton1Down"] = function()
		pressing:set(true)
	end
	root[OnEvent "MouseButton1Up"] = function()
		pressing:set(false)
	end
	root[OnEvent "Activated"] = function()
		pressing:set(false)
		if props.OnClick then
			props.OnClick()
		end
	end

	root[Children] = {
		scope:New "UIScale" { Scale = scale },
		scope:New "Frame" {
			Name = "Lip",
			Size = UDim2.fromScale(1, 1),
			BackgroundColor3 = scope:Computed(function(use)
				return use(tone).Edge
			end),
			ZIndex = 1,
			[Children] = {
				Kit.corner(scope, if pad then "Full" else "M"),
				Kit.stroke(scope, { Color = edgeColor, Thickness = 2.5 }),
			},
		},
		scope:New "Frame" {
			Name = "Face",
			Size = UDim2.new(1, 0, 1, -CAP_DEPTH),
			Position = scope:Computed(function(use)
				return UDim2.fromOffset(0, use(press))
			end),
			BackgroundColor3 = WHITE,
			ZIndex = 2,
			[Children] = {
				Kit.corner(scope, if pad then "Full" else "M"),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = scope:Computed(function(use)
						local t = use(tone)
						local lift = if use(hovering) then 0.08 else 0
						return ColorSequence.new(Color.lighten(t.Top, lift), Color.lighten(t.Bottom, lift))
					end),
				}),
				scope:New "Frame" {
					Name = "Gloss",
					BackgroundColor3 = WHITE,
					BackgroundTransparency = if pad then 0.82 else 0.35,
					Position = UDim2.fromOffset(4, 3),
					Size = UDim2.new(1, -8, 0.42, 0),
					ZIndex = 3,
					[Children] = {
						Kit.corner(scope, if pad then "Full" else "M"),
						Kit.gradient(scope, { Transparency = NumberSequence.new(0.1, 1) }),
					},
				},
				scope:New "TextLabel" {
					Name = "Glyph",
					BackgroundTransparency = 1,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Size = scope:Computed(function(use)
						return if use(isIcon) then UDim2.fromOffset(32, 32) else UDim2.new(1, -12, 0, 26)
					end),
					FontFace = scope:Computed(function(use)
						-- passing `use` re-resolves the icon font after a fallback swap
						return if use(isIcon) then Icons.font(true, use) else use(Theme.current).Font.Heavy
					end),
					Text = scope:Computed(function(use)
						return use(glyph).value
					end),
					TextSize = 24,
					TextScaled = isIcon,
					TextColor3 = glyphColor,
					ZIndex = 4,
				},
			},
		},
		scope:New "Frame" {
			Name = "WarnDot",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(1, -4, 0, 4),
			Size = UDim2.fromOffset(22, 22),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Bad
			end),
			Visible = scope:Computed(function(use)
				return use(warn) == true
			end),
			ZIndex = 6,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.stroke(scope, {
					Thickness = 2,
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Light
					end),
				}),
				scope:New "TextLabel" {
					BackgroundTransparency = 1,
					Size = UDim2.fromScale(1, 1),
					FontFace = Kit.themed(scope, function(theme)
						return theme.Font.Heavy
					end),
					Text = "!",
					TextSize = 16,
					TextColor3 = WHITE,
					ZIndex = 7,
				},
			},
		},
	}
	return scope:New "TextButton" (root) :: TextButton
end

return function(scope: Scope, props: { [any]: any }): Frame
	local action = props.Action or "Action"
	local key = props.Key or scope:Value(Enum.KeyCode.Unknown)
	local padKey = props.Gamepad
	local hasPad = padKey ~= nil
	local padSettable = hasPad and typeof(padKey) == "table" and padKey.set ~= nil
	local group = props.Group

	local alive = true
	local listening = scope:Value(false)
	local startedAt = 0
	local stoppedAt = -1
	local token = 0

	-- keep the shared group up to date so every row can spot clashes
	if group and group.set then
		local function sync()
			if not alive then
				return
			end
			local map = table.clone(peek(group) or {})
			map[action] = { Key = peek(key), Gamepad = peek(padKey) }
			group:set(map)
		end
		sync()
		Kit.watch(scope, key, sync)
		Kit.watch(scope, padKey, sync)
	end

	local function findConflict(k: any, p: any, map: { [string]: any }?): string
		if map then
			for name, entry in map do
				if name ~= action then
					if k ~= nil and k ~= Enum.KeyCode.Unknown and entry.Key == k then
						return name
					end
					if p ~= nil and entry.Gamepad == p then
						return name
					end
				end
			end
		end
		if props.Conflicts then
			local found = props.Conflicts(k, action)
			if found then
				return tostring(found)
			end
		end
		return ""
	end

	local conflict = scope:Computed(function(use)
		local map = if group then use(group) else nil
		return findConflict(use(key), use(padKey), map)
	end)
	local hasConflict = scope:Computed(function(use)
		return use(conflict) ~= ""
	end)

	local capsHolder: Frame? = nil
	local releaseHold: (() -> ())? = nil

	-- let go of the controller shortcuts a moment after listening ends, so
	-- the button that was just captured doesn't also close the window
	local function letGo()
		local release = releaseHold
		releaseHold = nil
		if release then
			task.delay(0.1, release)
		end
	end

	local function stop()
		if not alive then
			return
		end
		if cancelActive == stop then
			cancelActive = nil
		end
		letGo()
		if peek(listening) then
			stoppedAt = os.clock()
			listening:set(false)
		end
	end

	local function start()
		if not alive or peek(listening) then
			return
		end
		if os.clock() - stoppedAt < 0.3 then
			-- this click is the one that just cancelled listening
			return
		end
		if cancelActive then
			cancelActive()
		end
		cancelActive = stop
		if releaseHold == nil then
			releaseHold = Gamepad.hold()
		end
		startedAt = os.clock()
		token += 1
		local mine = token
		listening:set(true)
		Sound.play("Click")
		task.delay(LISTEN_TIMEOUT, function()
			if alive and token == mine and peek(listening) then
				stop()
			end
		end)
	end

	local function apply(isPad: boolean, newKey: any)
		local target = if isPad then padKey else key
		local old = peek(target)
		stop()
		if newKey == old then
			Sound.play("Click", { Pitch = 0.9 })
			return
		end
		if target.set then
			target:set(newKey)
		end
		local clash = findConflict(
			if isPad then nil else newKey,
			if isPad then newKey else nil,
			if group then peek(group) else nil
		)
		Sound.play("Click", { Pitch = 1.25 })
		if capsHolder then
			if clash ~= "" then
				Motion.shake(capsHolder, 5, 0.3)
			else
				Motion.pop(capsHolder, 0.12)
			end
		end
		if props.OnRebind then
			props.OnRebind(newKey, old, { Gamepad = isPad, Conflict = if clash ~= "" then clash else nil })
		end
	end

	table.insert(scope, UserInputService.InputBegan:Connect(function(input: InputObject)
		if not alive or not peek(listening) then
			return
		end
		if os.clock() - startedAt < 0.12 then
			return -- the press that opened listening (gamepad A / Enter)
		end
		if UserInputService:GetFocusedTextBox() then
			return
		end
		local inputType = input.UserInputType
		local code = input.KeyCode
		if inputType == Enum.UserInputType.Keyboard then
			if code == Enum.KeyCode.Escape then
				stop()
				Sound.play("Close")
			elseif code ~= Enum.KeyCode.Unknown then
				apply(false, code)
			end
		elseif isGamepadInput(inputType) then
			if not isPadButton(code) then
				return -- stick wiggles
			end
			if code == Enum.KeyCode.ButtonStart or code == Enum.KeyCode.ButtonSelect then
				stop()
			elseif padSettable then
				apply(true, code)
			end
		elseif inputType == Enum.UserInputType.MouseButton1 or inputType == Enum.UserInputType.Touch then
			stop() -- clicked somewhere else
		elseif props.AllowMouse and (inputType == Enum.UserInputType.MouseButton2 or inputType == Enum.UserInputType.MouseButton3) then
			apply(false, inputType)
		end
	end))

	local stopPulse: (() -> ())? = nil
	table.insert(scope, function()
		alive = false
		token += 1
		if cancelActive == stop then
			cancelActive = nil
		end
		if releaseHold then
			releaseHold()
			releaseHold = nil
		end
		if stopPulse then
			stopPulse()
			stopPulse = nil
		end
	end)

	local notListening = scope:Computed(function(use)
		return not use(listening)
	end)

	local listenPill = scope:New "Frame" {
		Name = "Listening",
		Size = UDim2.fromOffset(212, CAP_HEIGHT),
		BackgroundColor3 = WHITE,
		Visible = listening,
		LayoutOrder = 3,
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = Kit.themed(scope, function(theme)
					local t = theme.Tone.Yellow
					return ColorSequence.new(t.Top, t.Bottom)
				end),
			}),
			Kit.stroke(scope, {
				Thickness = 2.5,
				Color = Kit.themed(scope, function(theme)
					return theme.Tone.Yellow.Edge
				end),
			}),
			scope:New "Frame" {
				Name = "Content",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				[Children] = {
					Kit.list(scope, { Direction = "X", Padding = 8, HAlign = "Center", VAlign = "Center" }),
					Icon(scope, {
						Icon = "keyboard",
						Size = 26,
						LayoutOrder = 1,
						Color = Kit.themed(scope, function(theme)
							return theme.Tone.Yellow.Deep
						end),
						Stroke = false,
					}),
					Text(scope, {
						Text = "Press a key…",
						TextSize = 23,
						Font = "Heavy",
						LayoutOrder = 2,
						Color = Kit.themed(scope, function(theme)
							return theme.Tone.Yellow.Deep:Lerp(theme.Color.TextDark, 0.5)
						end),
						Stroke = false,
					}),
				},
			},
		},
	}

	Kit.watch(scope, listening, function(now)
		if now and stopPulse == nil then
			stopPulse = Motion.pulse(listenPill, 0.05, 0.8)
		elseif not now and stopPulse then
			stopPulse()
			stopPulse = nil
		end
	end)

	local caps = scope:New "Frame" {
		Name = "Keys",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(1, 0.5),
		Position = UDim2.new(1, -16, 0.5, 0),
		Size = UDim2.fromOffset(0, CAP_HEIGHT),
		AutomaticSize = Enum.AutomaticSize.X,
		ZIndex = 3,
		[Children] = {
			Kit.list(scope, { Direction = "X", Padding = 12, HAlign = "Right", VAlign = "Center" }),
			Text(scope, {
				Name = "CancelHint",
				Text = "Esc to cancel",
				TextSize = 17,
				Font = "Body",
				Stroke = false,
				LayoutOrder = 0,
				Visible = listening,
				Color = Kit.themed(scope, function(theme)
					return theme.Color.TextMuted
				end),
			}),
			KeyCapButton(scope, {
				Key = key,
				Warn = hasConflict,
				LayoutOrder = 1,
				Visible = notListening,
				OnClick = start,
			}),
			if hasPad
				then KeyCapButton(scope, {
					Key = padKey,
					Pad = true,
					Warn = false,
					LayoutOrder = 2,
					Visible = notListening,
					OnClick = start,
				})
				else nil,
			listenPill,
		},
	}
	capsHolder = caps

	local strokeColor = scope:Computed(function(use)
		local theme = use(Theme.current)
		if use(listening) then
			return theme.Color.Accent
		elseif use(hasConflict) then
			return theme.Color.Bad
		end
		return theme.Color.Outline
	end)

	local root = Kit.root(props, { Name = `Keybind_{action}` })
	root.Size = props.Size or UDim2.new(1, 0, 0, 76)
	root.BackgroundColor3 = Kit.themed(scope, function(theme)
		return theme.Color.Surface
	end)
	root.BackgroundTransparency = Kit.themed(scope, function(theme)
		return theme.Alpha.Surface
	end)
	root[Children] = {
		Kit.corner(scope, "M"),
		Kit.stroke(scope, {
			Color = strokeColor,
			Thickness = scope:Computed(function(use)
				return if use(listening) or use(hasConflict) then 3 else 1.5
			end),
			Transparency = scope:Computed(function(use)
				return if use(listening) or use(hasConflict) then 0 else 0.45
			end),
		}),
		scope:New "Frame" {
			Name = "Highlight",
			BackgroundColor3 = WHITE,
			BackgroundTransparency = 0.9,
			Size = UDim2.new(1, 0, 0.45, 0),
			ZIndex = 1,
			[Children] = {
				Kit.corner(scope, "M"),
				Kit.gradient(scope, { Transparency = NumberSequence.new(0, 1) }),
			},
		},
		scope:New "Frame" {
			Name = "Label",
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(20, 0),
			Size = UDim2.new(1, -40, 1, 0),
			ZIndex = 2,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 14, VAlign = "Center" }),
				if props.Icon
					then scope:New "Frame" {
						Name = "IconTile",
						LayoutOrder = 1,
						Size = UDim2.fromOffset(44, 44),
						BackgroundColor3 = WHITE,
						ZIndex = 2,
						[Children] = {
							Kit.corner(scope, "M"),
							Kit.gradient(scope, {
								Rotation = 90,
								Color = Kit.themed(scope, function(theme)
									local t = theme.Tone.Dark
									return ColorSequence.new(t.Top, t.Bottom)
								end),
							}),
							Kit.stroke(scope, {
								Thickness = 2,
								Color = Kit.themed(scope, function(theme)
									return theme.Tone.Dark.Edge
								end),
							}),
							Icon(scope, {
								Icon = props.Icon,
								Size = 28,
								AnchorPoint = Vector2.new(0.5, 0.5),
								Position = UDim2.fromScale(0.5, 0.5),
								Color = props.IconColor or Kit.themed(scope, function(theme)
									return theme.Color.Accent
								end),
								Gradient = true,
								ZIndex = 3,
							}),
						},
					}
					else nil,
				Text(scope, {
					Name = "Action",
					Text = action,
					TextSize = 26,
					Font = "Heavy",
					LayoutOrder = 2,
					ZIndex = 2,
				}),
				scope:New "Frame" {
					Name = "Conflict",
					LayoutOrder = 3,
					Size = UDim2.fromOffset(0, 32),
					AutomaticSize = Enum.AutomaticSize.X,
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Bad
					end),
					BackgroundTransparency = 0.82,
					Visible = hasConflict,
					ZIndex = 2,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.stroke(scope, {
							Thickness = 1.5,
							Color = Kit.themed(scope, function(theme)
								return theme.Color.Bad
							end),
						}),
						Kit.padding(scope, { 0, 12, 0, 10 }),
						Kit.list(scope, { Direction = "X", Padding = 6, VAlign = "Center" }),
						Icon(scope, {
							Icon = "warning",
							Size = 20,
							LayoutOrder = 1,
							Color = Kit.themed(scope, function(theme)
								return theme.Color.Bad
							end),
							Stroke = false,
							ZIndex = 3,
						}),
						Text(scope, {
							Text = scope:Computed(function(use)
								return `Also used by {use(conflict)}`
							end),
							TextSize = 18,
							Font = "Body",
							Stroke = false,
							LayoutOrder = 2,
							Color = Kit.themed(scope, function(theme)
								return Color.lighten(theme.Color.Bad, 0.35)
							end),
							ZIndex = 3,
						}),
					},
				},
			},
		},
		caps,
		props[Children],
	}

	local frame = scope:New "Frame" (root) :: Frame
	frame:SetAttribute("BloxUIKeybind", action)
	return frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="119">
            <Properties>
              <string name="Name">KillFeed</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	KillFeed: who eliminated whom, newest first.

	local feed = scope:Value({})     -- { { killer, victim, icon, order, mine }, ... }
	scope:KillFeed {
		Entries = feed,
		Lines = 3,                   -- rows to leave room for
		Style = "Rows",              -- "Rows" (the original) | "Compact" (slim icon rows)
		                             -- | "Callout" (compact rows + a big centre ELIMINATED
		                             --   for the player's own kills, with a streak count)
		Kit = "Arcade",
		Align = "Right", Row = "Top",
		CalloutParent = hud.Overlays,   -- Callout: where the centre callout goes (default:
		                                -- its own ScreenGui, made the first time it shows)
	}

	`order` sorts the rows (newest first); `mine` marks the local player's
	kills (accent name; Callout pops for them).
]]

local Players = game:GetService("Players")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local P = require(script.Parent.HudParts)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local function field(entry: { [string]: any }, key: string): any
	local value = entry[key]
	if value == nil then
		value = entry[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

-- The big centre "ELIMINATED" callout. Returns show(victimName).
local function callout(scope: Scope, T: { [string]: any }, parent: any): (string) -> ()
	local look = P.look(scope, T)
	local shown = scope:Value(0)
	local fade = scope:Spring(shown, 16, 1)
	local slamGoal = scope:Value(1)
	local slam = scope:Spring(slamGoal, 26, math.clamp(T.bounce or 0.5, 0.35, 0.8))
	local victim = scope:Value("")
	local streak = scope:Value(0)
	local lastAt = -math.huge
	local token = 0
	local frame: Frame? = nil

	local function build(into: Instance)
		frame = scope:New "Frame" {
			Name = "KillCallout",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			-- under the crosshair, clear of a bottom-centre hotbar on phones
			Position = UDim2.fromScale(0.5, 0.62),
			Size = UDim2.fromOffset(460, 110),
			Visible = scope:Computed(function(use)
				return use(fade) > 0.02
			end),
			ZIndex = 40,
			Parent = into,
			[Children] = {
				scope:New "UIScale" {
					Scale = scope:Computed(function(use)
						return math.max(0.05, use(slam))
					end),
				},
				P.text(scope, T, look, {
					Name = "Title",
					Role = "Number",
					Text = "ELIMINATED",
					TextSize = 48,
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.fromScale(0.5, 0),
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						return theme.Color.Callout or theme.Color.Bad
					end),
					Stroke = 3,
					StrokeColor = scope:Computed(function(use)
						return use(Theme.current).Color.Outline
					end),
					StrokeTransparency = scope:Computed(function(use)
						return 1 - use(fade)
					end),
					Transparency = scope:Computed(function(use)
						return 1 - math.clamp(use(fade), 0, 1)
					end),
					ZIndex = 41,
				}),
				P.text(scope, T, look, {
					Name = "Victim",
					Role = "Title",
					Text = victim,
					TextSize = 28,
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, 58),
					Color = scope:Computed(function(use)
						return use(Theme.current).Color.Text
					end),
					Stroke = 2.5,
					StrokeColor = scope:Computed(function(use)
						return use(Theme.current).Color.Outline
					end),
					StrokeTransparency = scope:Computed(function(use)
						return 1 - use(fade)
					end),
					Transparency = scope:Computed(function(use)
						return 1 - math.clamp(use(fade), 0, 1)
					end),
					ZIndex = 41,
				}),
				P.text(scope, T, look, {
					Name = "Streak",
					Role = "Number",
					Text = scope:Computed(function(use)
						local s = use(streak)
						return if s >= 2 then `x{s}` else ""
					end),
					TextSize = 34,
					AnchorPoint = Vector2.new(0, 0),
					Position = UDim2.new(1, -38, 0, -8),
					Rotation = 12,
					Color = scope:Computed(function(use)
						return use(Theme.current).Color.Accent
					end),
					Stroke = 3,
					StrokeColor = scope:Computed(function(use)
						return use(Theme.current).Color.Outline
					end),
					Transparency = scope:Computed(function(use)
						return 1 - math.clamp(use(fade), 0, 1)
					end),
					ZIndex = 42,
				}),
			},
		}
	end
	if parent then
		build(parent)
	end

	return function(name: string)
		if frame == nil then
			local player = Players.LocalPlayer
			if player == nil then
				return
			end
			task.spawn(function()
				local gui = scope:New "ScreenGui" {
					Name = "BloxUI_KillCallout",
					DisplayOrder = 6,
					ResetOnSpawn = false,
					ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
					IgnoreGuiInset = true,
					Parent = player:WaitForChild("PlayerGui"),
				}
				build(gui)
			end)
		end
		local now = os.clock()
		streak:set(if now - lastAt < 4 then peek(streak) + 1 else 1)
		lastAt = now
		victim:set(name)
		shown:set(1)
		if not Motion.reduced then
			slam:setPosition(1.6)
		end
		Sound.play("Kill", { Volume = 0.6 })
		token += 1
		local mine = token
		task.delay(1.9, function()
			if mine == token then
				shown:set(0)
			end
		end)
	end
end

return function(scope: Scope, props: { [any]: any }): Frame
	local style = P.style("KillFeed", props.Style)
	local T = P.tokens(props.Kit)
	local kitted = P.kitted(props.Kit)
	local entries = props.Entries or scope:Value({})
	local lines = props.Lines or 3
	local align = props.Align or "Right"
	local bottom = props.Row == "Bottom"
	local look = P.look(scope, T)

	local rowH = if style == "Rows" then 40 else 30
	local gap = if style == "Rows" then 6 else 4
	local width = if style == "Rows" then 460 else 400
	local size = Vector2.new(width, lines * rowH + (lines - 1) * gap)

	local rows = scope:ForValues(entries, function(_, inner, entry)
		local killer = tostring(field(entry, "killer") or "")
		local victim = tostring(field(entry, "victim") or "")
		local icon = field(entry, "icon") or "target"
		local mine = field(entry, "mine") == true
		local order = tonumber(field(entry, "order")) or 0

		if style == "Rows" then
			-- the original: a glass row per kill
			return inner:New "Frame" {
				Name = "FeedEntry",
				LayoutOrder = -order,
				Size = UDim2.fromOffset(0, 40),
				AutomaticSize = Enum.AutomaticSize.X,
				BackgroundColor3 = if kitted
					then inner:Computed(function(use)
						return use(look).face
					end)
					else Kit.themed(inner, function(theme)
						return theme.Color.Surface
					end),
				BackgroundTransparency = if kitted
					then inner:Computed(function(use)
						local l = use(look)
						return if l.alpha >= 1 then 0.55 else l.alpha
					end)
					else Kit.themed(inner, function(theme)
						return theme.Alpha.Surface
					end),
				[Children] = {
					if kitted then P.corner(inner, T, "Small") else Kit.corner(inner, "S"),
					Kit.padding(inner, { 0, 12 }),
					Kit.list(inner, { Direction = "X", Padding = 10, VAlign = "Center" }),
					(if kitted
						then P.text(inner, T, look, {
							Text = killer,
							TextSize = 20,
							Role = "Title",
							Color = if mine
								then inner:Computed(function(use)
									return use(look).accent
								end)
								else nil,
							LayoutOrder = 1,
						})
						else Text(inner, {
							Text = killer,
							TextSize = 20,
							Font = "Heavy",
							Color = if mine
								then Kit.themed(inner, function(theme)
									return theme.Color.Accent
								end)
								else nil,
							LayoutOrder = 1,
						})) :: any,
					Icon(inner, {
						Icon = icon,
						Size = 24,
						Color = if kitted
							then inner:Computed(function(use)
								return use(look).ink
							end)
							else nil,
						LayoutOrder = 2,
					}),
					(if kitted
						then P.text(inner, T, look, { Text = victim, TextSize = 20, Role = "Title", LayoutOrder = 3 })
						else Text(inner, { Text = victim, TextSize = 20, Font = "Heavy", LayoutOrder = 3 })) :: any,
				},
			}
		end

		-- Compact: a slim row on a strip that fades away from the screen edge
		local right = align ~= "Left"
		local clear = table.clone(T)
		clear.plate = "Clear"
		local ink = P.look(inner, clear)
		return inner:New "Frame" {
			Name = "FeedEntry",
			LayoutOrder = -order,
			Size = UDim2.new(1, 0, 0, rowH),
			BackgroundColor3 = Kit.themed(inner, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = 0.3,
			BorderSizePixel = 0,
			[Children] = {
				inner:New "UIGradient" {
					Rotation = if right then 180 else 0,
					Transparency = NumberSequence.new({
						NumberSequenceKeypoint.new(0, 0.05),
						NumberSequenceKeypoint.new(0.45, 0.35),
						NumberSequenceKeypoint.new(1, 1),
					}),
				},
				if mine
					then inner:New "Frame" {
						Name = "Mine",
						AnchorPoint = Vector2.new(if right then 1 else 0, 0),
						Position = UDim2.fromScale(if right then 1 else 0, 0),
						Size = UDim2.new(0, 3, 1, 0),
						BackgroundColor3 = Kit.themed(inner, function(theme)
							return theme.Color.Accent
						end),
						BorderSizePixel = 0,
					}
					else nil,
				inner:New "Frame" {
					Name = "Line",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, -14, 1, 0),
					Position = UDim2.fromOffset(if right then 0 else 14, 0),
					[Children] = {
						Kit.list(inner, { Direction = "X", Padding = 8, VAlign = "Center", HAlign = if right then "Right" else "Left" }),
						P.text(inner, clear, ink, {
							Text = killer,
							TextSize = 17,
							Role = "Title",
							Color = if mine
								then Kit.themed(inner, function(theme)
									return theme.Color.Accent
								end)
								else Kit.themed(inner, function(theme)
									return theme.Color.Text
								end),
							LayoutOrder = 1,
						}),
						P.icon(inner, T, {
							Icon = icon,
							Size = 20,
							Treatment = "Flat",
							Color = Kit.themed(inner, function(theme)
								return theme.Color.Text
							end),
							LayoutOrder = 2,
						}),
						P.text(inner, clear, ink, {
							Text = victim,
							TextSize = 17,
							Role = "Title",
							Color = Kit.themed(inner, function(theme)
								return theme.Color.TextMuted:Lerp(theme.Color.Text, 0.5)
							end),
							LayoutOrder = 3,
						}),
					},
				},
			},
		}
	end)

	if style == "Callout" then
		local show = callout(scope, T, props.CalloutParent)
		local seen: { [any]: boolean } = setmetatable({}, { __mode = "k" }) :: any
		for _, entry in peek(entries) or {} do
			seen[entry] = true
		end
		Kit.watch(scope, entries, function(list)
			for _, entry in list or {} do
				if not seen[entry] then
					seen[entry] = true
					if field(entry, "mine") == true then
						show(tostring(field(entry, "victim") or ""))
					end
				end
			end
		end)
	end

	local root = Kit.root(props, { Name = "KillFeed" })
	root.BackgroundTransparency = 1
	root[Children] = {
		scope:New "UIListLayout" {
			FillDirection = Enum.FillDirection.Vertical,
			SortOrder = Enum.SortOrder.LayoutOrder,
			Padding = UDim.new(0, gap),
			HorizontalAlignment = Enum.HorizontalAlignment[align],
			VerticalAlignment = if bottom then Enum.VerticalAlignment.Bottom else Enum.VerticalAlignment.Top,
		},
		rows,
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="120">
            <Properties>
              <string name="Name">Layout</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Layout helpers: invisible frames that arrange their children.

	scope:Row    { Gap = 12, Align = "Center", [Children] = {...} }
	scope:Column { Gap = 8, Padding = 16, AutoSize = "Y", [Children] = {...} }
	scope:Grid   { CellSize = Vector2.new(360, 240), Gap = 24, Columns = 3, [Children] = {...} }
	scope:Scroll { Direction = "Y", Gap = 16, [Children] = {...} }
	                   -- optional: ScrollBarThickness, ScrollBarColor (default: the theme's)

	Children are ordered by their LayoutOrder.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Kit = require(script.Parent.Parent.Kit)

local Children = Fusion.Children

local Layout = {}

type Scope = Fusion.Scope<any>

local function autoSize(props: { [any]: any }): Enum.AutomaticSize
	return if props.AutoSize then Enum.AutomaticSize[props.AutoSize] else Enum.AutomaticSize.None
end

local function stack(scope: Scope, props: { [any]: any }, direction: "X" | "Y"): Frame
	local root = Kit.root(props, { Name = if direction == "X" then "Row" else "Column" })
	root.Size = props.Size or UDim2.fromScale(1, 1)
	root.AutomaticSize = autoSize(props)
	root.BackgroundTransparency = 1
	root[Children] = {
		Kit.list(scope, {
			Direction = direction,
			Padding = props.Gap or 8,
			HAlign = props.Align or (if direction == "X" then "Left" else "Center"),
			VAlign = props.VAlign or (if direction == "X" then "Center" else "Top"),
			Wraps = props.Wraps,
		}),
		if props.Padding then Kit.padding(scope, props.Padding) else nil,
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end

function Layout.Row(scope: Scope, props: { [any]: any }): Frame
	return stack(scope, props, "X")
end

function Layout.Column(scope: Scope, props: { [any]: any }): Frame
	return stack(scope, props, "Y")
end

function Layout.Grid(scope: Scope, props: { [any]: any }): Frame
	local gap = props.Gap or 16
	local root = Kit.root(props, { Name = "Grid" })
	root.Size = props.Size or UDim2.fromScale(1, 1)
	root.AutomaticSize = autoSize(props)
	root.BackgroundTransparency = 1
	root[Children] = {
		scope:New "UIGridLayout" {
			CellSize = Kit.udim2(props.CellSize or Vector2.new(200, 200)),
			CellPadding = if typeof(gap) == "Vector2" then UDim2.fromOffset(gap.X, gap.Y) else UDim2.fromOffset(gap, gap),
			FillDirectionMaxCells = props.Columns or 0,
			SortOrder = Enum.SortOrder.LayoutOrder,
			HorizontalAlignment = Enum.HorizontalAlignment[props.Align or "Center"],
			VerticalAlignment = Enum.VerticalAlignment[props.VAlign or "Top"],
		},
		if props.Padding then Kit.padding(scope, props.Padding) else nil,
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end

function Layout.Scroll(scope: Scope, props: { [any]: any }): ScrollingFrame
	local direction = props.Direction or "Y"
	local root = Kit.root(props, { Name = "Scroll" })
	root.Size = props.Size or UDim2.fromScale(1, 1)
	root.BackgroundTransparency = 1
	root.BorderSizePixel = 0
	root.ScrollingDirection = Enum.ScrollingDirection[direction]
	root.CanvasSize = UDim2.fromScale(0, 0)
	root.AutomaticCanvasSize = Enum.AutomaticSize[direction]
	-- a rounded thumb in the theme's scroll colour and thickness
	for key, value in Kit.scrollBar(scope, { Thickness = props.ScrollBarThickness, Color = props.ScrollBarColor }) do
		root[key] = value
	end
	root.VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar
	root.HorizontalScrollBarInset = Enum.ScrollBarInset.ScrollBar
	root.ElasticBehavior = Enum.ElasticBehavior.WhenScrollable
	root.Selectable = false
	root[Children] = {
		if props.Grid
			then scope:New "UIGridLayout" {
				CellSize = Kit.udim2(props.Grid.CellSize or Vector2.new(200, 200)),
				CellPadding = UDim2.fromOffset(props.Grid.Gap or 16, props.Grid.Gap or 16),
				FillDirectionMaxCells = props.Grid.Columns or 0,
				SortOrder = Enum.SortOrder.LayoutOrder,
				HorizontalAlignment = Enum.HorizontalAlignment[props.Align or "Center"],
			}
			else Kit.list(scope, {
				Direction = direction,
				Padding = props.Gap or 12,
				HAlign = props.Align or (if direction == "X" then "Left" else "Center"),
				VAlign = if direction == "X" then "Center" else "Top",
			}),
		Kit.padding(scope, props.Padding or 8),
		props[Children],
	}
	return scope:New "ScrollingFrame" (root) :: ScrollingFrame
end

return Layout
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="121">
            <Properties>
              <string name="Name">Leaderboard</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Leaderboard: a ranked list of players. Gold, silver and bronze medals for
	the top three, alternating row tints, avatar headshots (when rows carry a
	userId), right-aligned stat values and a highlighted "you" row. If you're
	not in the list, pass `You` to pin your own row at the bottom.

	scope:Leaderboard {
		Title = "Top Players",
		Stat = "Coins",
		StatIcon = "coin",               -- optional icon next to values
		Rows = rows,                     -- array or state of { rank, name, value, userId? }
		Highlight = "Builderman",        -- name of the local player's row
		You = { rank = 128, name = "Builderman", value = 1520 },   -- optional pinned row
		Format = "comma",                -- "comma" (default) | "abbrev" | function(value) -> text
		Size = UDim2.fromOffset(480, 620),
	}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Util.Format)
local Color = require(script.Parent.Parent.Util.Color)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)
local ROW_HEIGHT = 56
local ROW_GAP = 6
local HEADER = 72

local function field(entry: any, key: string): any
	if typeof(entry) ~= "table" then
		return nil
	end
	local value = entry[key]
	if value == nil then
		value = entry[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

-- Medal tones straight from the theme: gold, silver, bronze.
local function medalTone(theme: any, rank: number): any?
	if rank == 1 then
		return theme.Tone.Yellow
	elseif rank == 2 then
		local white, gray = theme.Tone.White, theme.Tone.Gray
		return {
			Top = white.Top,
			Bottom = gray.Top,
			Edge = gray.Edge,
			Deep = gray.Deep,
			Text = WHITE,
			Stroke = gray.Stroke or gray.Edge,
		}
	elseif rank == 3 then
		local orange = theme.Tone.Orange
		return {
			Top = orange.Top,
			Bottom = Color.darken(orange.Bottom, 0.18),
			Edge = orange.Edge,
			Deep = orange.Deep,
			Text = WHITE,
			Stroke = orange.Stroke or orange.Edge,
		}
	end
	return nil
end

-- What a row shows: a refreshed entry with the same fields keeps its row.
local ROW_FIELDS = { "rank", "name", "userId", "value" }
local function sameRow(a: any, b: any): boolean
	if a == b then
		return true
	elseif typeof(a) ~= "table" or typeof(b) ~= "table" then
		return false
	end
	for _, key in ROW_FIELDS do
		if field(a, key) ~= field(b, key) then
			return false
		end
	end
	return true
end

-- Cache key for a row: its userId, else its name (a repeat gets #2, #3...).
local function rowKey(entry: any, seen: { [any]: number }): any
	local id = field(entry, "userId") or field(entry, "name")
	if id == nil then
		return entry
	end
	local n = (seen[id] or 0) + 1
	seen[id] = n
	return if n == 1 then id else `{id}\0{n}`
end

local function formatValue(value: any, style: any): string
	if typeof(value) ~= "number" then
		return tostring(value or "")
	end
	if style == nil or style == "comma" then
		return if math.abs(value) >= 1e9 then Format.abbrev(value) else Format.comma(value)
	end
	return Format.number(value, style)
end

local function Row(scope: Scope, props: { [any]: any }, entry: any, index: number, pinned: boolean): Frame
	local rank = tonumber(field(entry, "rank")) or index
	local name = tostring(field(entry, "name") or "Player")
	local userId = field(entry, "userId")
	local isYou = pinned or (props.Highlight ~= nil and scope:Computed(function(use)
		return use(props.Highlight) == name
	end)) or false

	local medal = scope:Computed(function(use)
		return medalTone(use(Theme.current), rank)
	end)
	local hasMedal = rank == 1 or rank == 2 or rank == 3

	local avatar = if userId
		then `rbxthumb://type=AvatarHeadShot&id={userId}&w=60&h=60`
		else nil

	return scope:New "Frame" {
		Name = if pinned then "You" else `Row{index}`,
		LayoutOrder = index,
		Size = UDim2.new(1, 0, 0, ROW_HEIGHT),
		BackgroundColor3 = scope:Computed(function(use)
			local theme = use(Theme.current)
			if use(isYou) then
				return theme.Color.Accent
			end
			local m = use(medal)
			if m then
				return m.Bottom
			end
			return theme.Color.SurfaceLight
		end),
		BackgroundTransparency = scope:Computed(function(use)
			if use(isYou) then
				return 0.72
			elseif use(medal) then
				return 0.8
			end
			return if index % 2 == 0 then 0.78 else 0.55
		end),
		ZIndex = 2,
		[Children] = {
			Kit.corner(scope, "M"),
			-- (only rows that can be "you" get the outline)
			if isYou == false then nil else Kit.stroke(scope, {
				Thickness = 2.5,
				Color = Kit.themed(scope, function(theme)
					return theme.Color.Accent
				end),
				Enabled = isYou,
			}),
			-- rank: medal for 1-3, "#n" otherwise
			scope:New "Frame" {
				Name = "Rank",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 10, 0.5, 0),
				Size = UDim2.fromOffset(42, 42),
				BackgroundColor3 = WHITE,
				BackgroundTransparency = scope:Computed(function(use)
					return if use(medal) then 0 else 1
				end),
				ZIndex = 3,
				[Children] = {
					Kit.corner(scope, "Full"),
					-- the medal disc (only ranks 1-3 ever have one)
					if not hasMedal then nil else Kit.gradient(scope, {
						Rotation = 90,
						Color = scope:Computed(function(use)
							local m = use(medal)
							if m == nil then
								return ColorSequence.new(WHITE)
							end
							return ColorSequence.new({
								ColorSequenceKeypoint.new(0, Color.lighten(m.Top, 0.2)),
								ColorSequenceKeypoint.new(0.55, m.Bottom),
								ColorSequenceKeypoint.new(1, Color.darken(m.Bottom, 0.2)),
							})
						end),
					}),
					if not hasMedal then nil else Kit.stroke(scope, {
						Thickness = 3,
						Color = scope:Computed(function(use)
							local m = use(medal)
							return if m then m.Edge else WHITE
						end),
						Enabled = scope:Computed(function(use)
							return use(medal) ~= nil
						end),
					}),
					Text(scope, {
						Text = if rank <= 3 then tostring(rank) else `#{rank}`,
						TextSize = if rank <= 3 then 24 elseif rank < 1000 then 22 else 16,
						Font = "Display",
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = scope:Computed(function(use)
							local m = use(medal)
							return if m then m.Text else use(Theme.current).Color.TextMuted
						end),
						StrokeColor = scope:Computed(function(use)
							local m = use(medal)
							return if m then (m.Stroke or m.Edge) else use(Theme.current).Color.Outline
						end),
						Stroke = scope:Computed(function(use)
							return if use(medal) then 2.5 else false
						end),
						ZIndex = 4,
					}),
				},
			},
			-- avatar
			scope:New "Frame" {
				Name = "Avatar",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 62, 0.5, 0),
				Size = UDim2.fromOffset(40, 40),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Surface
				end),
				BackgroundTransparency = 0.2,
				ZIndex = 3,
				[Children] = {
					Kit.corner(scope, "Full"),
					-- (Theme.lite / Theme.setLite: no avatar rim)
					Kit.unlessLite(scope, function(inner)
						return Kit.stroke(inner, {
							Thickness = 2,
							Transparency = 0.4,
							Color = Kit.themed(inner, function(theme)
								return theme.Color.Light
							end),
						})
					end),
					if avatar
						then scope:New "ImageLabel" {
							Name = "Headshot",
							BackgroundTransparency = 1,
							Image = avatar,
							Size = UDim2.fromScale(1, 1),
							ZIndex = 4,
							[Children] = Kit.corner(scope, "Full"),
						}
						else Icon(scope, {
							Icon = "player",
							Size = 24,
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = Kit.themed(scope, function(theme)
								return theme.Color.TextMuted
							end),
							Stroke = false,
							ZIndex = 4,
						}),
				},
			},
			Text(scope, {
				Name = "PlayerName",
				Text = name,
				TextSize = 22,
				Font = "Heavy",
				XAlign = "Left",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 114, 0.5, 0),
				Size = UDim2.new(0.62, -114, 0, 28),
				Truncate = true,
				ZIndex = 3,
			}),
			scope:New "Frame" {
				Name = "Value",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -14, 0.5, 0),
				Size = UDim2.new(0.38, 0, 1, 0),
				ZIndex = 3,
				[Children] = {
					Kit.list(scope, { Direction = "X", Padding = 6, HAlign = "Right", VAlign = "Center" }),
					Text(scope, {
						LayoutOrder = 1,
						Text = scope:Computed(function(use)
							return formatValue(use(field(entry, "value")), props.Format)
						end),
						TextSize = 26,
						Font = "Display",
						ZIndex = 4,
					}),
					if props.StatIcon
						then Icon(scope, {
							LayoutOrder = 2,
							Icon = props.StatIcon,
							Size = 26,
							Color = props.StatIconColor or Kit.themed(scope, function(theme)
								return theme.Tone.Yellow.Top
							end),
							Gradient = true,
							ZIndex = 4,
						})
						else nil,
				},
			},
		},
	}
end

local function Leaderboard(scope: Scope, props: { [any]: any }): Frame
	-- themed scrollbar parts (Kit.scrollBar), shared by this component's scroll areas
	local scrollBar = Kit.scrollBar(scope)
	local rows = props.Rows or {}
	local hasYou = props.You ~= nil
	local footer = if hasYou then ROW_HEIGHT + 20 else 0

	-- Only the rows in view (plus one either side) are built; the list's top
	-- padding stands in for the rows above them.
	local givenSize = peek(props.Size)
	local window = Kit.virtualRows(scope, {
		Step = ROW_HEIGHT + ROW_GAP,
		Leading = 4,
		View = if typeof(givenSize) == "UDim2" and givenSize.Y.Scale == 0
			then givenSize.Y.Offset - (HEADER + 8) - footer - 8
			elseif givenSize == nil then 620 - (HEADER + 8) - footer - 8
			else 0,
	})

	-- Rows are cached by userId (or name) and kept while what they show is
	-- the same, so a refresh with fresh tables only rebuilds rows that changed.
	local cache: { [any]: { entry: any } } = {}
	local entries = scope:Computed(function(use)
		local from = use(window.First) + 1
		local to = use(window.Last) + 1
		local out = {}
		local nextCache = {}
		local seen = {}
		for index, entry in use(rows) or {} do
			local key = rowKey(entry, seen)
			if index >= from and index <= to then
				local cached = cache[key]
				if cached == nil or not sameRow(cached.entry, entry) then
					cached = { entry = entry }
				else
					cached.entry = entry
				end
				nextCache[key] = cached
				out[index] = cached
			end
		end
		cache = nextCache
		return out
	end)

	local list = scope:ForPairs(entries, function(_, innerScope, index, cached)
		return index, Row(innerScope, props, cached.entry, index, false)
	end)

	local count = scope:Computed(function(use)
		return #(use(rows) or {})
	end)

	-- the pinned row only shows when you're not already in the list
	local youVisible = scope:Computed(function(use)
		if not hasYou then
			return false
		end
		local you = use(props.You)
		local youName = field(you, "name")
		for _, entry in use(rows) or {} do
			if field(entry, "name") == youName then
				return false
			end
		end
		return true
	end)

	local rowsFrame = scope:New "ScrollingFrame" {
		Name = "Rows",
		Position = UDim2.fromOffset(0, HEADER + 8),
		Size = UDim2.new(1, 0, 1, -(HEADER + 8) - footer - 8),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollingDirection = Enum.ScrollingDirection.Y,
		CanvasSize = scope:Computed(function(use)
			local n = use(count)
			return UDim2.fromOffset(0, math.max(0, n * ROW_HEIGHT + (n - 1) * ROW_GAP + 8))
		end),
		ScrollBarThickness = 6,
		ScrollBarImageColor3 = scrollBar.ScrollBarImageColor3,
		ScrollBarImageTransparency = scrollBar.ScrollBarImageTransparency,
		TopImage = scrollBar.TopImage,
		MidImage = scrollBar.MidImage,
		BottomImage = scrollBar.BottomImage,
		VerticalScrollBarInset = Enum.ScrollBarInset.Always,
		Selectable = false,
		ZIndex = 2,
		[Children] = {
			Kit.list(scope, { Direction = "Y", Padding = ROW_GAP }),
			scope:New "UIPadding" {
				-- the rows above the built ones
				PaddingTop = scope:Computed(function(use)
					return UDim.new(0, 4 + use(window.First) * (ROW_HEIGHT + ROW_GAP))
				end),
				PaddingRight = UDim.new(0, 12),
				PaddingBottom = UDim.new(0, 4),
				PaddingLeft = UDim.new(0, 16),
			},
			list,
		},
	} :: ScrollingFrame
	window.bind(rowsFrame)

	local root = Kit.root(props, { Name = "Leaderboard" })
	root.Size = props.Size or UDim2.fromOffset(480, 620)
	root.BackgroundColor3 = Kit.themed(scope, function(theme)
		return theme.Color.Surface
	end)
	root.BackgroundTransparency = Kit.themed(scope, function(theme)
		return theme.Alpha.Surface
	end)
	root[Children] = {
		Kit.corner(scope, "L"),
		Kit.stroke(scope, {
			Thickness = 2,
			Transparency = 0.75,
			Color = Kit.themed(scope, function(theme)
				return theme.Color.Light
			end),
		}),
		scope:New "Frame" {
			Name = "Header",
			BackgroundTransparency = 1,
			Size = UDim2.new(1, 0, 0, HEADER),
			ZIndex = 2,
			[Children] = {
				Icon(scope, {
					Icon = props.Icon or "trophy",
					Size = 44,
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 18, 0.5, 0),
					Rotation = -8,
					Color = Kit.themed(scope, function(theme)
						return theme.Tone.Yellow.Top
					end),
					StrokeColor = Kit.themed(scope, function(theme)
						return theme.Tone.Yellow.Edge
					end),
					Gradient = true,
					Shadow = true,
					ZIndex = 3,
				}),
				Text(scope, {
					Name = "Title",
					Text = props.Title or "Leaderboard",
					TextSize = 32,
					Font = "Title",
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 72, 0.5, 0),
					Size = UDim2.new(0.62, -72, 0, 38),
					Truncate = true,
					ZIndex = 3,
				}),
				scope:New "Frame" {
					Name = "StatPill",
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -16, 0.5, 0),
					Size = UDim2.fromOffset(0, 34),
					AutomaticSize = Enum.AutomaticSize.X,
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.SurfaceLight
					end),
					BackgroundTransparency = 0.2,
					ZIndex = 3,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.padding(scope, { 0, 14 }),
						Text(scope, {
							Text = string.upper(tostring(peek(props.Stat) or "Score")),
							TextSize = 18,
							Font = "Heavy",
							Size = UDim2.fromScale(0, 1),
							AutoSize = "X",
							Color = Kit.themed(scope, function(theme)
								return theme.Color.TextMuted
							end),
							Stroke = false,
							ZIndex = 4,
						}),
					},
				},
				scope:New "Frame" {
					Name = "Divider",
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, 0),
					Size = UDim2.new(1, -32, 0, 2),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Light
					end),
					BackgroundTransparency = 0.85,
					BorderSizePixel = 0,
					ZIndex = 3,
				},
			},
		},
		rowsFrame,
		Text(scope, {
			Name = "Empty",
			Text = props.EmptyText or "No scores yet",
			TextSize = 24,
			Font = "Heavy",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Color = Kit.themed(scope, function(theme)
				return theme.Color.TextMuted
			end),
			Visible = scope:Computed(function(use)
				return use(count) == 0
			end),
			ZIndex = 2,
		}),
		if hasYou
			then scope:New "Frame" {
				Name = "Pinned",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(0, 1),
				Position = UDim2.new(0, 16, 1, -12),
				Size = UDim2.new(1, -34, 0, ROW_HEIGHT),
				Visible = youVisible,
				ZIndex = 3,
				[Children] = Row(scope, props, peek(props.You), 0, true),
			}
			else nil,
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end

return Leaderboard
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="122">
            <Properties>
              <string name="Name">LoadoutCard</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	LoadoutCard: a weapon / class / kit card for loadout screens. Rarity art
	with rays and a big icon, the name, a rarity tag and stat bars. Hovering
	lifts it; the selected card gets a bright ring, an EQUIPPED tag and a pop.

	scope:LoadoutCard {
		Name = "Plasma Rifle",
		Icon = "ammo",
		Rarity = "Epic",
		Stats = {
			{ label = "Damage", value = 0.7 },     -- value 0..1 (number or state)
			{ label = "Fire rate", value = 0.45 },
			{ label = "Range", value = 0.85 },
		},
		Selected = isSelected,           -- bool or state
		OnClick = function() end,
		Size = UDim2.fromOffset(260, 360),
	}
]]

local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

local RARITY_HEX = {
	Common = "B0B7C3",
	Uncommon = "5BE22B",
	Rare = "3AA8FF",
	Epic = "B45CFF",
	Legendary = "FFB300",
	Mythic = "FF3B6B",
	Secret = "FFFFFF",
}
local RARITY_RANK = { Common = 1, Uncommon = 2, Rare = 3, Epic = 4, Legendary = 5, Mythic = 6, Secret = 7 }

local fallbackCache: { [string]: any } = {}
local function fallbackRarity(name: string): any
	local cached = fallbackCache[name]
	if cached then
		return cached
	end
	local tone
	if name == "Secret" then
		tone = {
			Top = WHITE,
			Bottom = Color3.fromHex("DCD2FF"),
			Edge = Color3.fromHex("6B5AA8"),
			Deep = Color3.fromHex("2E2748"),
			Text = WHITE,
			Stroke = Color3.fromHex("4A3F75"),
		}
	else
		local base = Color3.fromHex(RARITY_HEX[name] or RARITY_HEX.Common)
		tone = {
			Top = Color.lighten(base, 0.32),
			Bottom = base,
			Edge = Color.darken(base, 0.45),
			Deep = Color.darken(base, 0.72),
			Text = WHITE,
			Stroke = Color.darken(base, 0.58),
		}
	end
	fallbackCache[name] = tone
	return tone
end

local function rarityTone(scope: Scope, rarity: any): any
	local kitRarity = (Kit :: any).rarity
	if kitRarity then
		return kitRarity(scope, rarity)
	end
	return scope:Computed(function(use)
		local theme = use(Theme.current)
		local name = use(rarity) or "Common"
		if theme.Rarity and theme.Rarity[name] then
			return theme.Rarity[name]
		end
		return fallbackRarity(name)
	end)
end

local RAINBOW = ColorSequence.new({
	ColorSequenceKeypoint.new(0, Color3.fromHex("FF7AD9")),
	ColorSequenceKeypoint.new(0.25, Color3.fromHex("FFD36B")),
	ColorSequenceKeypoint.new(0.5, Color3.fromHex("7CF29A")),
	ColorSequenceKeypoint.new(0.75, Color3.fromHex("6BD8FF")),
	ColorSequenceKeypoint.new(1, Color3.fromHex("A98BFF")),
})

local function field(entry: any, key: string): any
	if typeof(entry) ~= "table" then
		return nil
	end
	local value = entry[key]
	if value == nil then
		value = entry[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

-- Crisp four-point twinkles (two crossed gradient bars) that grow and fade
-- in turn. Returns stop().
local TWINKLE_SPOTS = {
	{ 0.2, 0.24, 0.2, 0 },
	{ 0.82, 0.3, 0.14, 0.7 },
	{ 0.74, 0.8, 0.17, 1.4 },
}
local function twinkles(parent: GuiObject, px: number, zIndex: number): () -> ()
	local tweens = {}
	local untracks = {}
	local roots = {}
	for _, spot in TWINKLE_SPOTS do
		local size = math.max(10, math.floor(px * spot[3]))
		local root = Instance.new("Frame")
		root.Name = "Twinkle"
		root.BackgroundTransparency = 1
		root.AnchorPoint = Vector2.new(0.5, 0.5)
		root.Position = UDim2.fromScale(spot[1], spot[2])
		root.Size = UDim2.fromOffset(size, size)
		root.Rotation = 0
		root.ZIndex = zIndex
		for _, rotation in { 0, 90 } do
			local bar = Instance.new("Frame")
			bar.Name = "Ray"
			bar.BackgroundColor3 = WHITE
			bar.BorderSizePixel = 0
			bar.AnchorPoint = Vector2.new(0.5, 0.5)
			bar.Position = UDim2.fromScale(0.5, 0.5)
			bar.Size = UDim2.new(1, 0, 0, math.max(2, math.floor(size * 0.16)))
			bar.Rotation = rotation
			bar.ZIndex = zIndex
			local corner = Instance.new("UICorner")
			corner.CornerRadius = UDim.new(0.5, 0)
			corner.Parent = bar
			local gradient = Instance.new("UIGradient")
			gradient.Transparency = NumberSequence.new({
				NumberSequenceKeypoint.new(0, 1),
				NumberSequenceKeypoint.new(0.5, 0),
				NumberSequenceKeypoint.new(1, 1),
			})
			gradient.Parent = bar
			bar.Parent = root
		end
		local scale = Instance.new("UIScale")
		scale.Scale = 0.15
		scale.Parent = root
		root.Parent = parent
		local tween = TweenService:Create(
			scale,
			TweenInfo.new(0.7, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true, spot[4] + 0.6),
			{ Scale = 1 }
		)
		tween:Play()
		table.insert(tweens, tween)
		table.insert(untracks, Motion.track(tween, root))
		table.insert(roots, root)
	end
	return function()
		for _, untrack in untracks do
			untrack()
		end
		for _, tween in tweens do
			tween:Cancel()
		end
		for _, root in roots do
			root:Destroy()
		end
	end
end

local function LoadoutCard(scope: Scope, props: { [any]: any }): TextButton
	local size = peek(props.Size) or UDim2.fromOffset(260, 360)
	local width = if typeof(size) == "UDim2" and size.X.Offset > 0 then size.X.Offset else 260
	local height = if typeof(size) == "UDim2" and size.Y.Offset > 0 then size.Y.Offset else 360
	local rarity = props.Rarity or "Common"
	local tone = rarityTone(scope, rarity)
	local rank = scope:Computed(function(use)
		return RARITY_RANK[use(rarity) or "Common"] or 1
	end)
	local isSecret = scope:Computed(function(use)
		return use(rarity) == "Secret"
	end)
	local selected = props.Selected or false
	local stats = props.Stats or {}

	local pad = 12
	local statRow = 30
	local statCount = #(peek(stats) or {})
	local statsHeight = statCount * statRow
	local artHeight = math.max(120, height - pad * 3 - 40 - statsHeight - 8)

	local hovering = scope:Value(false)
	local pressing = scope:Value(false)
	local hoverScale = Kit.spring(scope, scope:Computed(function(use)
		if use(pressing) then
			return 0.96
		elseif use(hovering) then
			return 1.04
		end
		return 1
	end), 1)
	local lift = Kit.spring(scope, scope:Computed(function(use)
		return if use(hovering) or use(selected) == true then -8 else 0
	end), 0.9)
	local popGoal = scope:Value(1)
	local pop = scope:Spring(popGoal, 30, 0.4)
	Kit.watch(scope, selected, function(now)
		if now then
			pop:setPosition(0.92)
			pop:addVelocity(3)
		end
	end)

	local strokeColor = scope:Computed(function(use)
		local t = use(tone)
		return t.Stroke or t.Edge
	end)

	local artGradient = scope:New "UIGradient" {
		Rotation = 70,
		Color = scope:Computed(function(use)
			local t = use(tone)
			if use(isSecret) then
				return t.Rainbow or RAINBOW
			end
			return ColorSequence.new({
				ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.1)),
				ColorSequenceKeypoint.new(0.5, t.Bottom),
				ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.45)),
			})
		end),
	}

	local rays = scope:New "ImageLabel" {
		Name = "Rays",
		BackgroundTransparency = 1,
		Image = Assets.Rays,
		ImageColor3 = scope:Computed(function(use)
			return Color.lighten(use(tone).Top, 0.5)
		end),
		ImageTransparency = scope:Computed(function(use)
			local r = use(rank)
			return if r >= 5 then 0.25 elseif r >= 3 then 0.5 else 0.72
		end),
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.55),
		Size = UDim2.fromOffset(artHeight * 1.6, artHeight * 1.6),
		ZIndex = 2,
	}

	local art = scope:New "CanvasGroup" {
		Name = "Art",
		Position = UDim2.fromOffset(pad, pad),
		Size = UDim2.new(1, -pad * 2, 0, artHeight),
		BackgroundColor3 = WHITE,
		ZIndex = 3,
		[Children] = {
			Kit.corner(scope, "M"),
			artGradient,
			rays,
			scope:New "Frame" {
				Name = "Gloss",
				BackgroundColor3 = WHITE,
				BackgroundTransparency = 0.6,
				Size = UDim2.fromScale(1, 0.42),
				ZIndex = 3,
				[Children] = Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0.2, 1) }),
			},
			scope:New "Frame" {
				Name = "Vignette",
				BackgroundColor3 = scope:Computed(function(use)
					return use(tone).Deep
				end),
				Size = UDim2.fromScale(1, 1),
				ZIndex = 3,
				[Children] = Kit.gradient(scope, {
					Rotation = 90,
					Transparency = NumberSequence.new({
						NumberSequenceKeypoint.new(0, 1),
						NumberSequenceKeypoint.new(0.62, 1),
						NumberSequenceKeypoint.new(1, 0.4),
					}),
				}),
			},
			Icon(scope, {
				Name = "CardIcon",
				Icon = props.Icon or "melee",
				Size = math.floor(math.min(artHeight, width) * 0.62),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.55),
				Rotation = -10,
				Color = scope:Computed(function(use)
					return Color.lighten(use(tone).Top, 0.55)
				end),
				Gradient = true,
				Shadow = true,
				Stroke = 4,
				StrokeColor = strokeColor,
				ZIndex = 4,
			}),
			scope:New "Frame" {
				Name = "RarityTag",
				Position = UDim2.fromOffset(10, 10),
				Size = UDim2.fromOffset(0, 28),
				AutomaticSize = Enum.AutomaticSize.X,
				BackgroundColor3 = scope:Computed(function(use)
					return use(tone).Deep
				end),
				BackgroundTransparency = 0.25,
				ZIndex = 5,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.padding(scope, { 0, 12 }),
					Text(scope, {
						Text = scope:Computed(function(use)
							return string.upper(tostring(use(rarity) or "Common"))
						end),
						TextSize = 16,
						Font = "Display",
						Size = UDim2.fromScale(0, 1),
						AutoSize = "X",
						Color = scope:Computed(function(use)
							return Color.lighten(use(tone).Top, 0.25)
						end),
						Stroke = false,
						ZIndex = 6,
					}),
				},
			},
		},
	}

	local statFrames = {}
	for index, stat in peek(stats) or {} do
		local value = scope:Spring(scope:Computed(function(use)
			return math.clamp(tonumber(use(field(stat, "value"))) or 0, 0, 1)
		end), 14, 1)
		table.insert(statFrames, scope:New "Frame" {
			Name = `Stat{index}`,
			LayoutOrder = index,
			BackgroundTransparency = 1,
			Size = UDim2.new(1, 0, 0, statRow),
			ZIndex = 3,
			[Children] = {
				Text(scope, {
					Text = tostring(field(stat, "label") or ""),
					TextSize = 17,
					Font = "Heavy",
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.fromScale(0, 0.5),
					Size = UDim2.new(0.42, -8, 1, 0),
					Truncate = true,
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					Stroke = false,
					ZIndex = 4,
				}),
				scope:New "Frame" {
					Name = "Track",
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.fromScale(1, 0.5),
					Size = UDim2.new(0.58, 0, 0, 10),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Track
					end),
					ZIndex = 4,
					[Children] = {
						Kit.corner(scope, "Full"),
						scope:New "Frame" {
							Name = "Fill",
							Size = scope:Computed(function(use)
								return UDim2.fromScale(use(value), 1)
							end),
							BackgroundColor3 = WHITE,
							ZIndex = 5,
							[Children] = {
								Kit.corner(scope, "Full"),
								Kit.gradient(scope, {
									Rotation = 90,
									Color = scope:Computed(function(use)
										local t = use(tone)
										if use(isSecret) then
											return t.Rainbow or RAINBOW
										end
										return ColorSequence.new(Color.lighten(t.Top, 0.15), t.Bottom)
									end),
								}),
							},
						},
					},
				},
			},
		})
	end

	local card = scope:New "Frame" {
		Name = "Card",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = scope:Computed(function(use)
			return UDim2.new(0.5, 0, 0.5, use(lift))
		end),
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.SurfaceLight
		end),
		ZIndex = 2,
		[Children] = {
			Kit.corner(scope, "L"),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					local base = theme.Color.SurfaceLight
					return ColorSequence.new(Color.lighten(base, 0.06), Color.darken(base, 0.25))
				end),
			}),
			Kit.stroke(scope, {
				Thickness = scope:Computed(function(use)
					return if use(selected) == true then 4 else 3
				end),
				Color = scope:Computed(function(use)
					if use(selected) == true then
						return use(Theme.current).Color.Light
					end
					return use(tone).Edge
				end),
			}),
			art,
			-- tinted outline over the art (CanvasGroups can't hold strokes nicely)
			scope:New "Frame" {
				Name = "ArtOutline",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(pad, pad),
				Size = UDim2.new(1, -pad * 2, 0, artHeight),
				ZIndex = 4,
				[Children] = {
					Kit.corner(scope, "M"),
					Kit.stroke(scope, {
						Thickness = 2.5,
						Color = scope:Computed(function(use)
							return use(tone).Edge
						end),
					}),
				},
			},
			Text(scope, {
				Name = "CardName",
				Text = props.Name or "",
				TextSize = 28,
				Font = "Title",
				XAlign = "Left",
				Position = UDim2.fromOffset(pad + 4, pad + artHeight + 8),
				Size = UDim2.new(1, -(pad * 2 + 8), 0, 34),
				Truncate = true,
				ZIndex = 3,
			}),
			scope:New "Frame" {
				Name = "Stats",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(pad + 4, pad + artHeight + 48),
				Size = UDim2.new(1, -(pad * 2 + 8), 0, statsHeight),
				ZIndex = 3,
				[Children] = {
					Kit.list(scope, { Direction = "Y", Padding = 0 }),
					statFrames,
				},
			},
			scope:New "Frame" {
				Name = "EquippedTag",
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -4, 0, 12),
				Size = UDim2.fromOffset(0, 30),
				AutomaticSize = Enum.AutomaticSize.X,
				BackgroundColor3 = WHITE,
				Rotation = 4,
				Visible = scope:Computed(function(use)
					return use(selected) == true
				end),
				ZIndex = 8,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.padding(scope, { 0, 12 }),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = Kit.themed(scope, function(theme)
							return ColorSequence.new(theme.Tone.Green.Top, theme.Tone.Green.Bottom)
						end),
					}),
					Kit.stroke(scope, {
						Thickness = 2.5,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Light
						end),
					}),
					Text(scope, {
						Text = "EQUIPPED",
						TextSize = 17,
						Font = "Display",
						Size = UDim2.fromScale(0, 1),
						AutoSize = "X",
						Color = Kit.themed(scope, function(theme)
							return theme.Tone.Green.Text
						end),
						StrokeColor = Kit.themed(scope, function(theme)
							local t = theme.Tone.Green
							return t.Stroke or t.Edge
						end),
						ZIndex = 9,
					}),
				},
			},
		},
	}

	local root = Kit.root(props, { Name = props.Name or "LoadoutCard" })
	root.Size = size
	root.BackgroundTransparency = 1
	root.Text = ""
	root.AutoButtonColor = false
	root.Selectable = true
	root[OnEvent "MouseEnter"] = function()
		hovering:set(true)
		Sound.play("Hover", { Volume = 0.3 })
	end
	root[OnEvent "MouseLeave"] = function()
		hovering:set(false)
		pressing:set(false)
	end
	root[OnEvent "SelectionGained"] = function()
		hovering:set(true)
	end
	root[OnEvent "SelectionLost"] = function()
		hovering:set(false)
	end
	root[OnEvent "MouseButton1Down"] = function()
		pressing:set(true)
	end
	root[OnEvent "MouseButton1Up"] = function()
		pressing:set(false)
	end
	root[OnEvent "Activated"] = function()
		pressing:set(false)
		Sound.play("Click")
		if props.OnClick then
			props.OnClick()
		end
	end
	root[Children] = {
		scope:New "UIScale" {
			Scale = scope:Computed(function(use)
				return use(hoverScale) * use(pop)
			end),
		},
		scope:New "ImageLabel" {
			Name = "SelectedGlow",
			BackgroundTransparency = 1,
			Image = Assets.SoftShadow,
			ImageColor3 = scope:Computed(function(use)
				return Color.lighten(use(tone).Top, 0.3)
			end),
			ImageTransparency = 0.25,
			ScaleType = Enum.ScaleType.Slice,
			SliceCenter = Assets.SoftShadowSlice,
			SliceScale = 40 / Assets.SoftShadowFalloff,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = scope:Computed(function(use)
				return UDim2.new(0.5, 0, 0.5, use(lift))
			end),
			Size = UDim2.new(1, 40, 1, 40),
			Visible = scope:Computed(function(use)
				return use(selected) == true
			end),
			ZIndex = 0,
		},
		Kit.shadow(scope, { Spread = 18, Offset = Vector2.new(0, 10), ZIndex = 1 }),
		card,
		props[Children],
	}
	local button = scope:New "TextButton" (root) :: TextButton

	-- Legendary+ shines and sparkles; Epic+ rays turn; Secret rainbow turns.
	local alive = true
	local stopShine: (() -> ())? = nil
	local stopSparkles: (() -> ())? = nil
	local stopSpin: (() -> ())? = nil
	local rainbowTween: Tween? = nil
	local untrackRainbow: (() -> ())? = nil
	local function updateFx()
		if not alive then
			return
		end
		local r = peek(rank)
		if r >= 5 and stopShine == nil then
			stopShine = Motion.shine(art, { ZIndex = 3, Transparency = 0.5, Interval = 3.4, Duration = 1, Width = 0.12 })
			stopSparkles = twinkles(art, math.min(artHeight, width), 6)
		elseif r < 5 and stopShine then
			stopShine()
			stopShine = nil
			if stopSparkles then
				stopSparkles()
				stopSparkles = nil
			end
		end
		if r >= 4 and stopSpin == nil then
			stopSpin = Motion.spin(rays, 16)
		elseif r < 4 and stopSpin then
			stopSpin()
			stopSpin = nil
		end
		local secret = peek(isSecret)
		if secret and rainbowTween == nil then
			artGradient.Rotation = 0
			local tween = TweenService:Create(
				artGradient,
				TweenInfo.new(6, Enum.EasingStyle.Linear, Enum.EasingDirection.In, -1),
				{ Rotation = 360 }
			)
			tween:Play()
			rainbowTween = tween
			untrackRainbow = Motion.track(tween, art)
		elseif not secret and rainbowTween then
			if untrackRainbow then
				untrackRainbow()
				untrackRainbow = nil
			end
			rainbowTween:Cancel()
			rainbowTween = nil
			artGradient.Rotation = 70
		end
	end
	updateFx()
	Kit.watch(scope, rank, updateFx)
	Kit.watch(scope, isSecret, updateFx)
	table.insert(scope, function()
		alive = false
		if stopShine then
			stopShine()
		end
		if stopSparkles then
			stopSparkles()
		end
		if stopSpin then
			stopSpin()
		end
		if untrackRainbow then
			untrackRainbow()
		end
		if rainbowTween then
			rainbowTween:Cancel()
		end
	end)

	return button
end

return LoadoutCard
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="123">
            <Properties>
              <string name="Name">Logo</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Logo: a game-title lockup built only from text, icons and frames (no
	uploaded images), so it restyles with the theme and can never be moderated.

	scope:Logo {
		Text = "NEON STRIKE",
		Subtitle = "SEASON 1",     -- optional small tag under the title
		Type = "Wordmark",         -- "Wordmark" | "Emblem" | "Badge" | "Stacked" | "Neon"
		Icon = "crosshairs",       -- Emblem / Badge art
		Tone = "Yellow",           -- any theme tone (or a tone table)
		Tilt = -4,                 -- degrees; each Type has a good default
		Height = 160,              -- px; the whole lockup scales with it
		Animate = true,            -- idle shine sweep / neon flicker
		Transparency = 0,          -- number or state: fades the whole logo
		Font = "Title",            -- theme font slot or a Font
		SubtitleTone = "Dark",     -- tone of the subtitle tag (Neon: second tube colour)
	}

	Types
	  Wordmark  huge display text: gradient face, tinted outline, 3D side, drop shadow
	  Emblem    icon inside a gem-cut badge, title beside it
	  Badge     title on a ribbon banner with an icon medallion on top
	  Stacked   two-line title (small top line, huge bottom line) + subtitle bar
	  Neon      glowing tube lettering built from layered strokes + soft glow

	The root sizes itself to the text (AutomaticSize X), so centre it with an
	AnchorPoint of (0.5, 0.5).
]]

local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Motion = require(script.Parent.Parent.FX.Motion)
local Icon = require(script.Parent.Icon)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

-- Every size in this file is designed for a 160 px tall logo; a UIScale on the
-- root takes it to `Height`. (Roblox caps TextSize at 100, so big logos must
-- scale rather than use bigger text sizes.)
local REF = 160

local TRIANGLE = "rbxasset://textures/triangle.png"
local CIRCLE = "rbxasset://textures/whiteCircle.png"
local GLOW = "rbxasset://textures/ui/InGameMenu/BackgroundGlow.png"

local DEFAULT_TILT = {
	Wordmark = -4,
	Stacked = -3,
	Emblem = 0,
	Badge = 0,
	Neon = -2,
}

-- Neon subtitles glow in a complementary tube colour.
local NEON_PAIR = {
	Cyan = "Pink",
	Blue = "Pink",
	Purple = "Cyan",
	Pink = "Cyan",
	Red = "Cyan",
	Yellow = "Pink",
	Orange = "Cyan",
	Green = "Pink",
}

type Context = {
	scope: Scope,
	font: any,
	fade: any,
	animate: boolean,
	[string]: any,
}

--------------------------------------------------------------------------
-- Colour helpers
--------------------------------------------------------------------------

-- The darkest tinted shade of a tone, used for outlines (never pure black).
local function outlineOf(tone: any): Color3
	return tone.Stroke or Color.darken(tone.Edge, 0.35)
end

local function shadowOf(tone: any): Color3
	return Color.darken(tone.Deep, 0.55)
end

-- Candy face: bright top, glossy step just above the middle, rich bottom.
local function faceColors(tone: any): ColorSequence
	return ColorSequence.new({
		ColorSequenceKeypoint.new(0, Color.lighten(tone.Top, 0.55)),
		ColorSequenceKeypoint.new(0.46, Color.lighten(tone.Top, 0.18)),
		ColorSequenceKeypoint.new(0.5, tone.Top),
		ColorSequenceKeypoint.new(1, tone.Bottom),
	})
end

--------------------------------------------------------------------------
-- Shared bits
--------------------------------------------------------------------------

-- Combines a layer's own transparency with the whole-logo fade.
local function faded(ctx: Context, base: any): any
	return ctx.scope:Computed(function(use)
		local own = use(base) or 0
		return 1 - (1 - own) * (1 - (use(ctx.fade) or 0))
	end)
end

local function textStroke(ctx: Context, color: any, thickness: number, transparency: any?)
	return ctx.scope:New "UIStroke" {
		ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual,
		LineJoinMode = Enum.LineJoinMode.Round,
		Color = color,
		Thickness = thickness,
		Transparency = faded(ctx, transparency or 0),
	}
end

local function sidePadding(ctx: Context, pad: number)
	return ctx.scope:New "UIPadding" {
		PaddingLeft = UDim.new(0, pad),
		PaddingRight = UDim.new(0, pad),
	}
end

-- A four point twinkle made from triangle images (no uploaded art).
local function twinkle(ctx: Context, props: { [string]: any }): Frame
	local scope = ctx.scope
	local size = props.Size or 30
	local long = size / 2
	local short = long * 0.62
	local width = math.max(3, size * 0.2)
	local color = props.Color or Color3.new(1, 1, 1)
	local alpha = faded(ctx, props.Transparency or 0)

	local function arm(name: string, length: number, rotation: number, offset: Vector2)
		return scope:New "ImageLabel" {
			Name = name,
			BackgroundTransparency = 1,
			Image = TRIANGLE,
			ImageColor3 = color,
			ImageTransparency = alpha,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5, offset.X, 0.5, offset.Y),
			Size = UDim2.fromOffset(width, length),
			Rotation = rotation,
			ZIndex = props.ZIndex or 1,
		}
	end

	-- idle life: each twinkle breathes on its own rhythm (engine tweens, no
	-- per-frame script work)
	local breathe = scope:New "UIScale" { Scale = 1 }
	if ctx.animate then
		ctx.twinkles = (ctx.twinkles or 0) + 1
		local nth = ctx.twinkles
		local tween = TweenService:Create(
			breathe,
			TweenInfo.new(0.7 + (nth % 3) * 0.23, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true, 0.15 * nth),
			{ Scale = 0.45 }
		)
		tween:Play()
		local untrack = Motion.track(tween, breathe)
		table.insert(scope, function()
			untrack()
			tween:Cancel()
		end)
	end

	return scope:New "Frame" {
		Name = props.Name or "Twinkle",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position,
		Size = UDim2.fromOffset(size, size),
		Rotation = props.Rotation or 0,
		-- sparkles are for playful looks; serious archetypes stay clean
		Visible = scope:Computed(function(use)
			local archetype = Theme.style and Theme.style(use(Theme.current), "Archetype")
			return archetype ~= "Horror" and archetype ~= "Tactical"
		end),
		ZIndex = props.ZIndex or 1,
		[Children] = {
			breathe,
			arm("Up", long, 0, Vector2.new(0, -long / 2)),
			arm("Down", long, 180, Vector2.new(0, long / 2)),
			arm("Right", short, 90, Vector2.new(short / 2, 0)),
			arm("Left", short, -90, Vector2.new(-short / 2, 0)),
			scope:New "ImageLabel" {
				Name = "Core",
				BackgroundTransparency = 1,
				Image = CIRCLE,
				ImageColor3 = color,
				ImageTransparency = alpha,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.fromOffset(width * 1.5, width * 1.5),
				ZIndex = props.ZIndex or 1,
			},
		},
	}
end

-- Soft radial glow (a stack of the client's built-in glow texture).
local function glow(ctx: Context, props: { [string]: any }): Frame
	local scope = ctx.scope
	local layers = {}
	for i = 1, props.Layers or 3 do
		layers[i] = scope:New "ImageLabel" {
			Name = "Glow" .. i,
			BackgroundTransparency = 1,
			Image = GLOW,
			ImageColor3 = props.Color,
			ImageTransparency = faded(ctx, props.Transparency or 0),
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromScale(1 - (i - 1) * 0.18, 1 - (i - 1) * 0.18),
			ZIndex = props.ZIndex or 0,
		}
	end
	return scope:New "Frame" {
		Name = props.Name or "Glow",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position or UDim2.fromScale(0.5, 0.5),
		Size = props.Size,
		ZIndex = props.ZIndex or 0,
		[Children] = layers,
	}
end

-- A light band that sweeps across the glyphs every few seconds.
local function shineLayer(ctx: Context, base: { [any]: any }): TextLabel
	local scope = ctx.scope
	local gradient = scope:New "UIGradient" {
		Rotation = 18,
		Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 1),
			NumberSequenceKeypoint.new(0.4, 1),
			NumberSequenceKeypoint.new(0.5, 0.25),
			NumberSequenceKeypoint.new(0.6, 1),
			NumberSequenceKeypoint.new(1, 1),
		}),
		Offset = Vector2.new(-1, 0),
	}
	local props = table.clone(base)
	props.Name = "Shine"
	props.TextColor3 = Color3.new(1, 1, 1)
	props[Children] = { props[Children], gradient }
	local label = scope:New "TextLabel" (props)
	if ctx.animate then
		local tween = TweenService:Create(
			gradient,
			TweenInfo.new(1.1, Enum.EasingStyle.Quad, Enum.EasingDirection.InOut, -1, false, 3.4),
			{ Offset = Vector2.new(1, 0) }
		)
		tween:Play()
		local untrack = Motion.track(tween, gradient)
		table.insert(scope, function()
			untrack()
			tween:Cancel()
		end)
	end
	return label
end

--------------------------------------------------------------------------
-- Chunky 3D word: shadow, outline rim, extruded side, gradient face, shine
--------------------------------------------------------------------------

type WordSpec = {
	Name: string?,
	Text: any,
	Tone: any, -- Computed tone table (face)
	OutlineTone: any?, -- Computed tone table for outline, side and shadow (default: Tone)
	TextSize: any, -- number or state
	Height: any, -- number or state
	Depth: number, -- 3D side thickness
	Stroke: number, -- face outline
	Rim: number, -- extra outline around the side
	Align: string?,
	LayoutOrder: number?,
	Position: UDim2?,
	AnchorPoint: Vector2?,
	Shine: boolean?,
	ZIndex: number?,
}

local function word(ctx: Context, spec: WordSpec): Frame
	local scope = ctx.scope
	local tone = spec.Tone
	local rimTone = spec.OutlineTone or tone
	local depth = spec.Depth
	local stroke = spec.Stroke
	local rim = spec.Rim
	local pad = stroke + rim + 4
	local z = spec.ZIndex or 1
	local xAlign = Enum.TextXAlignment[spec.Align or "Center"]

	local outline = scope:Computed(function(use)
		return outlineOf(use(rimTone))
	end)
	local side = scope:Computed(function(use)
		if spec.OutlineTone then
			-- a light face on a coloured rim: side is the rim's mid shade
			return use(rimTone).Edge
		end
		return use(tone).Edge
	end)
	local shadow = scope:Computed(function(use)
		return shadowOf(use(rimTone))
	end)

	local function base(name: string, dy: number, zIndex: number): { [any]: any }
		return {
			Name = name,
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			Position = UDim2.fromOffset(0, dy),
			Text = spec.Text,
			TextSize = spec.TextSize,
			FontFace = ctx.font,
			TextXAlignment = xAlign,
			TextYAlignment = Enum.TextYAlignment.Center,
			ZIndex = z + zIndex,
		}
	end

	local layers = {}

	-- soft-ish drop shadow
	do
		local p = base("Shadow", depth + 7, 0)
		p.TextColor3 = shadow
		p.TextTransparency = faded(ctx, 0.45)
		p[Children] = { sidePadding(ctx, pad), textStroke(ctx, shadow, stroke + rim + 2, 0.45) }
		table.insert(layers, scope:New "TextLabel" (p))
	end

	-- outer rim around face + side
	for _, dy in { 0, math.floor(depth / 2), depth } do
		local p = base("Rim", dy, 1)
		p.TextColor3 = outline
		p.TextTransparency = faded(ctx, 0)
		p[Children] = { sidePadding(ctx, pad), textStroke(ctx, outline, stroke + rim) }
		table.insert(layers, scope:New "TextLabel" (p))
	end

	-- extruded side
	local step = math.max(1, depth / 4)
	local dy = step
	while dy <= depth + 0.01 do
		local p = base("Side", math.floor(dy + 0.5), 2)
		p.TextColor3 = side
		p.TextTransparency = faded(ctx, 0)
		p[Children] = { sidePadding(ctx, pad), textStroke(ctx, side, stroke) }
		table.insert(layers, scope:New "TextLabel" (p))
		dy += step
	end

	-- face: sizes the whole word (AutomaticSize X), gradient fill, tinted outline
	do
		local p = base("Face", 0, 3)
		p.Size = UDim2.fromScale(0, 1)
		p.AutomaticSize = Enum.AutomaticSize.X
		p.TextColor3 = Color3.new(1, 1, 1)
		p.TextTransparency = faded(ctx, 0)
		p[Children] = {
			sidePadding(ctx, pad),
			textStroke(ctx, outline, stroke),
			scope:New "UIGradient" {
				Rotation = 90,
				Color = scope:Computed(function(use)
					return faceColors(use(tone))
				end),
			},
		}
		table.insert(layers, scope:New "TextLabel" (p))
	end

	if spec.Shine ~= false then
		local p = base("Shine", 0, 4)
		p.TextTransparency = faded(ctx, 0)
		p[Children] = sidePadding(ctx, pad)
		table.insert(layers, shineLayer(ctx, p))
	end

	return scope:New "Frame" {
		Name = spec.Name or "Word",
		BackgroundTransparency = 1,
		AnchorPoint = spec.AnchorPoint or Vector2.zero,
		Position = spec.Position or UDim2.new(),
		Size = if typeof(spec.Height) == "number"
			then UDim2.fromOffset(0, spec.Height)
			else scope:Computed(function(use)
				return UDim2.fromOffset(0, use(spec.Height))
			end),
		AutomaticSize = Enum.AutomaticSize.X,
		LayoutOrder = spec.LayoutOrder or 0,
		ZIndex = z,
		[Children] = layers,
	}
end

-- Small tag under a title. By default a deep pill tinted with the logo tone
-- and light tone-coloured text; with SubtitleTone it's that tone's gradient.
local function tag(ctx: Context, props: { [string]: any }): Frame
	local scope = ctx.scope
	local height = props.Height or 34
	local textSize = props.TextSize or math.floor(height * 0.62)
	local custom = ctx.customSubtitle
	local tone = if custom then ctx.subtitleTone else ctx.tone
	local fillColors = scope:Computed(function(use)
		local t = use(tone)
		if custom then
			return ColorSequence.new(Color.lighten(t.Top, 0.08), t.Bottom)
		end
		return ColorSequence.new(Color.darken(t.Deep, 0.05), Color.darken(t.Deep, 0.45))
	end)
	local strokeColor = scope:Computed(function(use)
		local t = use(tone)
		return if custom then outlineOf(t) else t.Edge
	end)
	local textColor = scope:Computed(function(use)
		local t = use(tone)
		return if custom then t.Text else Color.lighten(t.Top, 0.35)
	end)
	return scope:New "Frame" {
		Name = props.Name or "Subtitle",
		AnchorPoint = props.AnchorPoint or Vector2.new(0.5, 0),
		Position = props.Position or UDim2.new(),
		Size = UDim2.fromOffset(0, height),
		AutomaticSize = Enum.AutomaticSize.X,
		BackgroundColor3 = Color3.new(1, 1, 1),
		BackgroundTransparency = faded(ctx, 0),
		LayoutOrder = props.LayoutOrder or 0,
		ZIndex = props.ZIndex or 10,
		[Children] = {
			Kit.corner(scope, "Full"),
			scope:New "UIStroke" {
				ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				LineJoinMode = Enum.LineJoinMode.Round,
				Color = strokeColor,
				Thickness = props.StrokeThickness or 3,
				Transparency = faded(ctx, 0),
			},
			scope:New "UIGradient" {
				Rotation = 90,
				Color = fillColors,
			},
			scope:New "UIPadding" {
				PaddingLeft = UDim.new(0, math.floor(height * 0.62)),
				PaddingRight = UDim.new(0, math.floor(height * 0.62)),
			},
			scope:New "TextLabel" {
				Name = "Label",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(0, 1),
				AutomaticSize = Enum.AutomaticSize.X,
				Text = props.Text,
				TextSize = textSize,
				FontFace = scope:Computed(function(use)
					return Theme.font(use(Theme.current), "Heavy")
				end),
				TextColor3 = textColor,
				TextTransparency = faded(ctx, 0),
				ZIndex = (props.ZIndex or 10) + 1,
				[Children] = textStroke(
					ctx,
					scope:Computed(function(use)
						return Color.darken(use(tone).Deep, 0.3)
					end),
					2,
					scope:Computed(function(use)
						if custom and use(tone).Stroke == nil then
							return 1
						end
						return 0
					end)
				),
			},
		},
	}
end

--------------------------------------------------------------------------
-- Types
--------------------------------------------------------------------------

local function wordmark(ctx: Context, props: { [any]: any }, tone: any): { any }
	local hasSubtitle = props.Subtitle ~= nil
	local titleHeight = if hasSubtitle then 128 else 150
	local children: { any } = {
		word(ctx, {
			Name = "Title",
			Text = props.Text,
			Tone = tone,
			TextSize = if hasSubtitle then 92 else 100,
			Height = titleHeight,
			Depth = 10,
			Stroke = 5,
			Rim = 5,
			ZIndex = 1,
		}),
		twinkle(ctx, { Name = "TwinkleA", Size = 34, Position = UDim2.new(1, -14, 0, 16), ZIndex = 8 }),
		twinkle(ctx, { Name = "TwinkleB", Size = 20, Position = UDim2.new(1, 8, 0, 42), ZIndex = 8 }),
		twinkle(ctx, { Name = "TwinkleC", Size = 24, Position = UDim2.new(0, 10, 1, -26), ZIndex = 8 }),
	}
	if hasSubtitle then
		table.insert(children, tag(ctx, {
			Text = props.Subtitle,
			Height = 34,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(0.5, 0, 0, titleHeight - 12),
			ZIndex = 20,
		}))
	end
	return children
end

-- Splits a title into a small top line and a big bottom line and sizes both
-- from the actual text, so any length stacks cleanly: 1 word = one big line,
-- long titles shrink, the top line is always smaller than the bottom line,
-- and lines only tuck into each other when the top line has no descenders.
local STACK_BOTTOM_MAX = 96
local STACK_TOP_RATIO = 0.6
local STACK_SUBTITLE = 38

local function textLength(text: string): number
	return utf8.len(text) or #text
end

local function splitStacked(text: string): (string, string)
	local words = {}
	for word in string.gmatch(text, "%S+") do
		table.insert(words, word)
	end
	if #words < 2 then
		return "", table.concat(words, " ")
	end
	-- the bottom line is the hero: give it the tail of the title, balanced so
	-- the (smaller) top line comes out about as wide as the bottom line
	local best, bestScore = #words - 1, math.huge
	for i = 1, #words - 1 do
		local top = table.concat(words, " ", 1, i)
		local bottom = table.concat(words, " ", i + 1)
		local score = math.abs(textLength(top) * STACK_TOP_RATIO - textLength(bottom))
		if score < bestScore - 1e-6 then
			best, bestScore = i, score
		end
	end
	return table.concat(words, " ", 1, best), table.concat(words, " ", best + 1)
end

local function stackLayout(text: string, hasSubtitle: boolean): { [string]: any }
	local top, bottom = splitStacked(text)
	local topLen, bottomLen = textLength(top), math.max(1, textLength(bottom))
	-- long bottom lines shrink (about 8 capitals fit at full size)
	local bottomSize = math.clamp(math.floor(STACK_BOTTOM_MAX * 8 / math.max(8, bottomLen)), 56, STACK_BOTTOM_MAX)
	local topSize = 0
	if topLen > 0 then
		-- smaller than the bottom line, and never much wider than it
		topSize = math.min(bottomSize * STACK_TOP_RATIO, bottomSize * 1.1 * bottomLen / topLen)
		topSize = math.floor(math.clamp(topSize, 26, bottomSize * 0.72))
	end
	-- descenders (g, j, p, q, y) hang into the gap: only tuck lines together without them
	local descends = string.find(top, "[gjpqy,;Q]") ~= nil
	local topBox = if topLen > 0 then math.ceil(topSize * 1.12) else 0
	local bottomBox = math.ceil(bottomSize * 1.06)
	local gap = if topLen == 0 then 0 elseif descends then 2 else -math.floor(topSize * 0.2)
	-- the whole block fits the logo's height (the subtitle pill may hang a
	-- little below it, like the Wordmark's does); only the lines scale
	local lines = topBox + gap + bottomBox
	local room = REF + 12 - (if hasSubtitle then STACK_SUBTITLE else 0)
	local fit = math.min(1, room / lines)
	return {
		top = top,
		bottom = bottom,
		topSize = math.max(1, math.floor(topSize * fit)),
		bottomSize = math.max(1, math.floor(bottomSize * fit)),
		topBox = math.floor(topBox * fit),
		bottomBox = math.floor(bottomBox * fit),
		gap = math.floor(gap * fit),
	}
end

local function stacked(ctx: Context, props: { [any]: any }, tone: any): { any }
	local scope = ctx.scope
	local hasSubtitle = props.Subtitle ~= nil
	local layout = scope:Computed(function(use)
		return stackLayout(tostring(use(props.Text) or ""), hasSubtitle)
	end)
	local function field(key: string)
		return scope:Computed(function(use)
			return use(layout)[key]
		end)
	end
	local topTone = scope:Computed(function(use)
		return Theme.tone(use(Theme.current), use(props.TopTone) or "White")
	end)
	local hasTop = scope:Computed(function(use)
		return use(layout).top ~= ""
	end)
	local topBox, bottomBox = field("topBox"), field("bottomBox")

	local children: { any } = {
		scope:New "UIListLayout" {
			FillDirection = Enum.FillDirection.Vertical,
			HorizontalAlignment = Enum.HorizontalAlignment.Center,
			VerticalAlignment = Enum.VerticalAlignment.Center,
			SortOrder = Enum.SortOrder.LayoutOrder,
			Padding = scope:Computed(function(use)
				return UDim.new(0, use(layout).gap)
			end),
		},
		scope:New "Frame" {
			Name = "TopLine",
			BackgroundTransparency = 1,
			Size = scope:Computed(function(use)
				return UDim2.fromOffset(0, use(topBox))
			end),
			AutomaticSize = Enum.AutomaticSize.X,
			LayoutOrder = 1,
			Visible = hasTop,
			ZIndex = 2,
			[Children] = word(ctx, {
				Name = "Word",
				Text = field("top"),
				Tone = topTone,
				OutlineTone = tone,
				TextSize = field("topSize"),
				Height = topBox,
				Depth = 6,
				Stroke = 4,
				Rim = 4,
				ZIndex = 2,
			}),
		},
		scope:New "Frame" {
			Name = "BottomLine",
			BackgroundTransparency = 1,
			Size = scope:Computed(function(use)
				return UDim2.fromOffset(0, use(bottomBox))
			end),
			AutomaticSize = Enum.AutomaticSize.X,
			LayoutOrder = 2,
			ZIndex = 1,
			[Children] = {
				word(ctx, {
					Name = "Word",
					Text = field("bottom"),
					Tone = tone,
					TextSize = field("bottomSize"),
					Height = bottomBox,
					Depth = 10,
					Stroke = 5,
					Rim = 5,
					ZIndex = 1,
				}),
				-- beside the big line (the top line may be wider, so not above it)
				twinkle(ctx, { Name = "TwinkleA", Size = 30, Position = UDim2.new(1, 6, 0.4, 0), ZIndex = 8 }),
				twinkle(ctx, { Name = "TwinkleB", Size = 17, Position = UDim2.new(1, 24, 0.66, 0), ZIndex = 8 }),
			},
		},
	}
	if hasSubtitle then
		table.insert(children, scope:New "Frame" {
			Name = "SubtitleRow",
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(0, STACK_SUBTITLE),
			AutomaticSize = Enum.AutomaticSize.X,
			LayoutOrder = 3,
			ZIndex = 20,
			[Children] = {
				scope:New "UIPadding" { PaddingTop = UDim.new(0, 8) },
				tag(ctx, {
					Text = props.Subtitle,
					Height = 30,
					AnchorPoint = Vector2.new(0, 0),
					Position = UDim2.new(),
					ZIndex = 20,
				}),
			},
		})
	end
	return children
end

-- Rounded square turned 45 degrees (a cut gem shape).
local function diamond(ctx: Context, props: { [string]: any }): Frame
	local scope = ctx.scope
	return scope:New "Frame" {
		Name = props.Name,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position or UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(props.Size, props.Size),
		Rotation = 45,
		BackgroundColor3 = props.Color,
		BackgroundTransparency = faded(ctx, props.Transparency or 0),
		ZIndex = props.ZIndex or 1,
		[Children] = {
			Kit.corner(scope, props.Radius or math.floor(props.Size * 0.2)),
			props.Gradient,
			props.Stroke,
		},
	}
end

local function emblem(ctx: Context, props: { [any]: any }, tone: any): { any }
	local scope = ctx.scope
	local outline = scope:Computed(function(use)
		return outlineOf(use(tone))
	end)
	local badgeSize = 156
	local hasSubtitle = props.Subtitle ~= nil

	local badge = scope:New "Frame" {
		Name = "Emblem",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(badgeSize, badgeSize),
		LayoutOrder = 1,
		ZIndex = 1,
		[Children] = {
			glow(ctx, {
				Color = scope:Computed(function(use)
					return use(tone).Top
				end),
				Size = UDim2.fromOffset(badgeSize * 1.7, badgeSize * 1.7),
				Layers = 2,
				ZIndex = 1,
			}),
			diamond(ctx, {
				Name = "Shadow",
				Size = 110,
				Position = UDim2.new(0.5, 0, 0.5, 9),
				Color = scope:Computed(function(use)
					return shadowOf(use(tone))
				end),
				Transparency = 0.45,
				ZIndex = 2,
			}),
			diamond(ctx, {
				Name = "Rim",
				Size = 110,
				Color = outline,
				ZIndex = 3,
			}),
			diamond(ctx, {
				Name = "Bevel",
				Size = 98,
				Color = Color3.new(1, 1, 1),
				ZIndex = 4,
				Gradient = scope:New "UIGradient" {
					Rotation = 45,
					Color = scope:Computed(function(use)
						local t = use(tone)
						return ColorSequence.new(Color.lighten(t.Top, 0.6), t.Edge)
					end),
				},
			}),
			diamond(ctx, {
				Name = "Face",
				Size = 86,
				Color = Color3.new(1, 1, 1),
				ZIndex = 5,
				Gradient = scope:New "UIGradient" {
					Rotation = 45,
					Color = scope:Computed(function(use)
						local t = use(tone)
						return ColorSequence.new({
							ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.25)),
							ColorSequenceKeypoint.new(0.5, t.Top),
							ColorSequenceKeypoint.new(1, t.Bottom),
						})
					end),
				},
			}),
			scope:New "Frame" {
				Name = "Gloss",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, -12, 0.5, -12),
				Size = UDim2.fromOffset(56, 22),
				Rotation = -45,
				BackgroundColor3 = Color3.new(1, 1, 1),
				BackgroundTransparency = faded(ctx, 0.72),
				ZIndex = 6,
				[Children] = Kit.corner(scope, "Full"),
			},
			Icon(scope, {
				Name = "Art",
				Icon = props.Icon or "star",
				Size = 74,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Color = Color3.new(1, 1, 1),
				StrokeColor = outline,
				Stroke = 4,
				Shadow = true,
				Transparency = ctx.fade,
				ZIndex = 7,
			}),
			twinkle(ctx, { Name = "Twinkle", Size = 30, Position = UDim2.new(1, -18, 0, 20), ZIndex = 9 }),
		},
	}

	local column = scope:New "Frame" {
		Name = "Titles",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(0, badgeSize),
		AutomaticSize = Enum.AutomaticSize.X,
		LayoutOrder = 2,
		ZIndex = 2,
		[Children] = {
			scope:New "UIListLayout" {
				FillDirection = Enum.FillDirection.Vertical,
				HorizontalAlignment = Enum.HorizontalAlignment.Left,
				VerticalAlignment = Enum.VerticalAlignment.Center,
				SortOrder = Enum.SortOrder.LayoutOrder,
				Padding = UDim.new(0, -4),
			},
			word(ctx, {
				Name = "Title",
				Text = props.Text,
				Tone = tone,
				TextSize = 76,
				Height = 88,
				Depth = 8,
				Stroke = 4,
				Rim = 4,
				Align = "Left",
				LayoutOrder = 1,
				ZIndex = 2,
			}),
			if hasSubtitle
				then scope:New "Frame" {
					Name = "SubtitleRow",
					BackgroundTransparency = 1,
					Size = UDim2.fromOffset(0, 34),
					AutomaticSize = Enum.AutomaticSize.X,
					LayoutOrder = 2,
					ZIndex = 20,
					[Children] = {
						scope:New "UIPadding" { PaddingLeft = UDim.new(0, 10) },
						tag(ctx, {
							Text = props.Subtitle,
											Height = 30,
							AnchorPoint = Vector2.new(0, 0),
							ZIndex = 20,
						}),
					},
				}
				else nil,
		},
	}

	return {
		scope:New "UIListLayout" {
			FillDirection = Enum.FillDirection.Horizontal,
			VerticalAlignment = Enum.VerticalAlignment.Center,
			SortOrder = Enum.SortOrder.LayoutOrder,
			Padding = UDim.new(0, 2),
		},
		badge,
		column,
	}
end

local function badgeType(ctx: Context, props: { [any]: any }, tone: any): { any }
	local scope = ctx.scope
	local outline = scope:Computed(function(use)
		return outlineOf(use(tone))
	end)
	local hasIcon = props.Icon ~= nil
	local hasSubtitle = props.Subtitle ~= nil
	local bandHeight = 80
	local bandTop = if hasIcon then 50 else 24
	local tailColor = scope:Computed(function(use)
		return use(tone).Edge
	end)

	-- one ribbon tail: body + two outward points (a notched end) + fold
	local function tail(side: number)
		local x = if side < 0 then 0 else 1
		local dir = side
		local bodyWidth = 64
		local tailHeight = 64
		local drop = 24
		local function piece(name: string, props2: { [string]: any })
			props2.Name = name
			props2.BackgroundTransparency = 1
			props2.Image = TRIANGLE
			props2.ImageTransparency = faded(ctx, 0)
			props2.AnchorPoint = Vector2.new(0.5, 0.5)
			return scope:New "ImageLabel" (props2)
		end
		local inner = 30 -- how far the tail tucks under the band
		local bodyCenterX = dir * (bodyWidth / 2 - inner)
		local tipX = dir * (bodyWidth - inner + 10)
		return scope:New "Frame" {
			Name = if side < 0 then "TailLeft" else "TailRight",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(x, 0, 0, drop),
			Size = UDim2.fromOffset(0, tailHeight),
			ZIndex = 1,
			[Children] = {
				-- shadow
				scope:New "Frame" {
					Name = "Shadow",
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.fromOffset(bodyCenterX, 8),
					Size = UDim2.fromOffset(bodyWidth + 8, tailHeight),
					BackgroundColor3 = scope:Computed(function(use)
						return shadowOf(use(tone))
					end),
					BackgroundTransparency = faded(ctx, 0.55),
					ZIndex = 1,
				},
				-- outline underlay
				scope:New "Frame" {
					Name = "Edge",
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.fromOffset(bodyCenterX, -4),
					Size = UDim2.fromOffset(bodyWidth + 8, tailHeight + 8),
					BackgroundColor3 = outline,
					BackgroundTransparency = faded(ctx, 0),
					ZIndex = 2,
				},
				piece("EdgeTipTop", {
					Position = UDim2.fromOffset(tipX - dir * 2, tailHeight / 4 - 1),
					Size = UDim2.fromOffset(tailHeight / 2 + 8, 26),
					Rotation = dir * 90,
					ImageColor3 = outline,
					ZIndex = 2,
				}),
				piece("EdgeTipBottom", {
					Position = UDim2.fromOffset(tipX - dir * 2, tailHeight * 3 / 4 + 1),
					Size = UDim2.fromOffset(tailHeight / 2 + 8, 26),
					Rotation = dir * 90,
					ImageColor3 = outline,
					ZIndex = 2,
				}),
				scope:New "Frame" {
					Name = "Body",
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.fromOffset(bodyCenterX, 0),
					Size = UDim2.fromOffset(bodyWidth, tailHeight),
					BackgroundColor3 = tailColor,
					BackgroundTransparency = faded(ctx, 0),
					ZIndex = 3,
				},
				piece("TipTop", {
					Position = UDim2.fromOffset(tipX - dir * 6, tailHeight / 4),
					Size = UDim2.fromOffset(tailHeight / 2, 18),
					Rotation = dir * 90,
					ImageColor3 = tailColor,
					ZIndex = 3,
				}),
				piece("TipBottom", {
					Position = UDim2.fromOffset(tipX - dir * 6, tailHeight * 3 / 4),
					Size = UDim2.fromOffset(tailHeight / 2, 18),
					Rotation = dir * 90,
					ImageColor3 = tailColor,
					ZIndex = 3,
				}),
			},
		}
	end

	local band = scope:New "Frame" {
		Name = "Band",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(0, bandHeight),
		AutomaticSize = Enum.AutomaticSize.X,
		ZIndex = 3,
		[Children] = {
			scope:New "Frame" {
				Name = "Tails",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				ZIndex = 1,
				[Children] = { tail(-1), tail(1) },
			},
			scope:New "Frame" {
				Name = "Shadow",
				Size = UDim2.fromScale(1, 1),
				Position = UDim2.fromOffset(0, 9),
				BackgroundColor3 = scope:Computed(function(use)
					return shadowOf(use(tone))
				end),
				BackgroundTransparency = faded(ctx, 0.5),
				ZIndex = 2,
				[Children] = Kit.corner(scope, 10),
			},
			scope:New "Frame" {
				Name = "Face",
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = Color3.new(1, 1, 1),
				BackgroundTransparency = faded(ctx, 0),
				ZIndex = 3,
				[Children] = {
					Kit.corner(scope, 10),
					scope:New "UIGradient" {
						Rotation = 90,
						Color = scope:Computed(function(use)
							local t = use(tone)
							return ColorSequence.new({
								ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.3)),
								ColorSequenceKeypoint.new(0.48, t.Top),
								ColorSequenceKeypoint.new(0.52, t.Bottom),
								ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.12)),
							})
						end),
					},
					scope:New "UIStroke" {
						ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
						LineJoinMode = Enum.LineJoinMode.Round,
						Color = outline,
						Thickness = 4,
						Transparency = faded(ctx, 0),
					},
				},
			},
			scope:New "Frame" {
				Name = "Trim",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.new(1, -12, 1, -12),
				BackgroundTransparency = 1,
				ZIndex = 4,
				[Children] = {
					Kit.corner(scope, 6),
					scope:New "UIStroke" {
						ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
						Color = Color3.new(1, 1, 1),
						Thickness = 2,
						Transparency = faded(ctx, 0.55),
					},
				},
			},
			scope:New "Frame" {
				Name = "Content",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(0, 1),
				AutomaticSize = Enum.AutomaticSize.X,
				ZIndex = 5,
				[Children] = {
					scope:New "UIPadding" {
						PaddingLeft = UDim.new(0, 30),
						PaddingRight = UDim.new(0, 30),
					},
					word(ctx, {
						Name = "Title",
						Text = props.Text,
						Tone = scope:Computed(function(use)
							return Theme.tone(use(Theme.current), "White")
						end),
						OutlineTone = tone,
						TextSize = 58,
						Height = bandHeight,
						Depth = 5,
						Stroke = 4,
						Rim = 2,
						Position = UDim2.fromOffset(0, -2),
						ZIndex = 5,
					}),
				},
			},
		},
	}

	local children: { any } = {
		scope:New "Frame" {
			Name = "Ribbon",
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(0, bandTop),
			Size = UDim2.fromOffset(0, bandHeight),
			AutomaticSize = Enum.AutomaticSize.X,
			ZIndex = 2,
			[Children] = {
				scope:New "UIPadding" {
					PaddingLeft = UDim.new(0, 58),
					PaddingRight = UDim.new(0, 58),
				},
				band,
			},
		},
	}

	if hasIcon then
		table.insert(children, scope:New "Frame" {
			Name = "Medallion",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(0.5, 0, 0, 0),
			Size = UDim2.fromOffset(70, 70),
			BackgroundColor3 = outline,
			BackgroundTransparency = faded(ctx, 0),
			ZIndex = 10,
			[Children] = {
				Kit.corner(scope, "Full"),
				scope:New "Frame" {
					Name = "Face",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Size = UDim2.new(1, -10, 1, -10),
					BackgroundColor3 = Color3.new(1, 1, 1),
					BackgroundTransparency = faded(ctx, 0),
					ZIndex = 11,
					[Children] = {
						Kit.corner(scope, "Full"),
						scope:New "UIGradient" {
							Rotation = 90,
							Color = scope:Computed(function(use)
								local t = use(tone)
								return ColorSequence.new(Color.lighten(t.Top, 0.35), t.Bottom)
							end),
						},
					},
				},
				Icon(scope, {
					Name = "Art",
					Icon = props.Icon,
					Size = 42,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Color = Color3.new(1, 1, 1),
					StrokeColor = outline,
					Stroke = 3,
					Transparency = ctx.fade,
					ZIndex = 12,
				}),
			},
		})
	end

	if hasSubtitle then
		table.insert(children, tag(ctx, {
			Text = props.Subtitle,
			Height = 30,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(0.5, 0, 0, bandTop + bandHeight - 6),
			ZIndex = 20,
		}))
	end

	table.insert(children, twinkle(ctx, {
		Name = "Twinkle",
		Size = 30,
		Position = UDim2.new(1, -40, 0, bandTop - 4),
		ZIndex = 21,
	}))

	return children
end

local function neon(ctx: Context, props: { [any]: any }, tone: any): { any }
	local scope = ctx.scope
	local flicker = ctx.flicker
	local hasSubtitle = props.Subtitle ~= nil
	local titleHeight = if hasSubtitle then 118 else 150
	local tube = scope:Computed(function(use)
		return use(tone).Top
	end)
	local core = scope:Computed(function(use)
		return Color.lighten(use(tone).Top, 0.62)
	end)
	local pairTone = scope:Computed(function(use)
		local theme = use(Theme.current)
		local given = use(props.SubtitleTone)
		if given ~= nil then
			return Theme.tone(theme, given)
		end
		local name = use(props.Tone)
		return Theme.tone(theme, NEON_PAIR[if typeof(name) == "string" then name else ""] or "Pink")
	end)
	local pad = 30

	-- Neon tubes are thin: use a lighter cut of the same family when it has one.
	local tubeFont = scope:Computed(function(use)
		local font = use(ctx.font)
		local family = string.match(font.Family, "families/([%w_]+)%.json$")
		local weights = family and Theme.FONT_FAMILIES and Theme.FONT_FAMILIES[family]
		if weights then
			for _, pick in { { 600, Enum.FontWeight.SemiBold }, { 500, Enum.FontWeight.Medium }, { 700, Enum.FontWeight.Bold } } do
				if table.find(weights, pick[1]) then
					return Font.new(font.Family, pick[2], font.Style)
				end
			end
		end
		return font
	end)

	-- stepped glow: filled text + stroke in the tube colour, widest and faintest first
	local HALOS = { { 32, 0.9 }, { 22, 0.8 }, { 14, 0.64 }, { 8, 0.42 } }
	local layers: { any } = {
		glow(ctx, {
			Color = tube,
			Size = UDim2.new(1.2, 0, 2.2, 0),
			Layers = 3,
			Transparency = flicker,
			ZIndex = 1,
		}),
	}
	for index, halo in HALOS do
		local thickness, transparency = halo[1], halo[2]
		local alpha = faded(ctx, scope:Computed(function(use)
			return 1 - (1 - transparency) * (1 - use(flicker))
		end))
		table.insert(layers, scope:New "TextLabel" {
			Name = "Halo" .. index,
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			Text = props.Text,
			TextSize = 96,
			FontFace = tubeFont,
			TextColor3 = tube,
			TextTransparency = alpha,
			ZIndex = 1 + index,
			[Children] = {
				sidePadding(ctx, pad),
				scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual,
					LineJoinMode = Enum.LineJoinMode.Round,
					Color = tube,
					Thickness = thickness,
					Transparency = alpha,
				},
			},
		})
	end
	-- the sizing label: white-hot core inside a tube-coloured outline
	table.insert(layers, scope:New "TextLabel" {
		Name = "Tube",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(0, 1),
		AutomaticSize = Enum.AutomaticSize.X,
		Text = props.Text,
		TextSize = 96,
		FontFace = tubeFont,
		TextColor3 = core,
		TextTransparency = faded(ctx, flicker),
		ZIndex = 10,
		[Children] = {
			sidePadding(ctx, pad),
			scope:New "UIStroke" {
				ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual,
				LineJoinMode = Enum.LineJoinMode.Round,
				Color = tube,
				Thickness = 4.5,
				Transparency = faded(ctx, flicker),
			},
		},
	})

	local title = scope:New "Frame" {
		Name = "Title",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(0, titleHeight),
		AutomaticSize = Enum.AutomaticSize.X,
		ZIndex = 1,
		[Children] = layers,
	}

	local children: { any } = { title }

	if hasSubtitle then
		local pairTube = scope:Computed(function(use)
			return use(pairTone).Top
		end)
		local pairCore = scope:Computed(function(use)
			return Color.lighten(use(pairTone).Top, 0.75)
		end)
		local function frameStroke(thickness: number, transparency: number)
			return scope:New "Frame" {
				Name = "Halo",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				ZIndex = 21,
				[Children] = {
					Kit.corner(scope, 12),
					scope:New "UIStroke" {
						ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
						Color = pairTube,
						Thickness = thickness,
						Transparency = faded(ctx, transparency),
					},
				},
			}
		end
		table.insert(children, scope:New "Frame" {
			Name = "Subtitle",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(0.5, 0, 0, titleHeight - 8),
			Size = UDim2.fromOffset(0, 38),
			AutomaticSize = Enum.AutomaticSize.X,
			BackgroundTransparency = 1,
			ZIndex = 20,
			[Children] = {
				Kit.corner(scope, 12),
				frameStroke(10, 0.82),
				frameStroke(5, 0.6),
				frameStroke(2.5, 0),
				scope:New "UIPadding" {
					PaddingLeft = UDim.new(0, 20),
					PaddingRight = UDim.new(0, 20),
				},
				scope:New "TextLabel" {
					Name = "Label",
					BackgroundTransparency = 1,
					Size = UDim2.fromScale(0, 1),
					AutomaticSize = Enum.AutomaticSize.X,
					Text = props.Subtitle,
					TextSize = 24,
					FontFace = scope:Computed(function(use)
						return Theme.font(use(Theme.current), "Heavy")
					end),
					TextColor3 = pairCore,
					TextTransparency = faded(ctx, 0),
					ZIndex = 22,
					[Children] = scope:New "UIStroke" {
						ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual,
						LineJoinMode = Enum.LineJoinMode.Round,
						Color = pairTube,
						Thickness = 2.5,
						Transparency = faded(ctx, 0.2),
					},
				},
			},
		})
	end

	return children
end

local BUILDERS = {
	Wordmark = wordmark,
	Emblem = emblem,
	Badge = badgeType,
	Stacked = stacked,
	Neon = neon,
}

--------------------------------------------------------------------------
-- Component
--------------------------------------------------------------------------

return function(scope: Scope, props: { [any]: any }): Frame
	local kind = peek(props.Type) or "Wordmark"
	if BUILDERS[kind] == nil then
		warn(`[BloxUI] Logo: unknown Type "{kind}", using Wordmark`)
		kind = "Wordmark"
	end
	local height = peek(props.Height) or REF
	local tone = Kit.tone(scope, props.Tone, "Yellow")

	local flickerValue = scope:Value(0)
	local ctx = {
		scope = scope,
		fade = props.Transparency or 0,
		animate = props.Animate ~= false,
		flicker = flickerValue,
		font = scope:Computed(function(use)
			return Theme.font(use(Theme.current), use(props.Font) or "Title")
		end),
		tone = tone,
		customSubtitle = props.SubtitleTone ~= nil,
		subtitleTone = scope:Computed(function(use)
			return Theme.tone(use(Theme.current), use(props.SubtitleTone) or "Dark")
		end),
	}

	-- neon signs stutter now and then
	local logo: Frame? = nil
	if kind == "Neon" and ctx.animate then
		local alive = true
		table.insert(scope, function()
			alive = false
		end)
		task.spawn(function()
			while alive do
				task.wait(3.5 + math.random() * 3)
				if logo == nil or not Motion.shown(logo) then
					continue -- nobody to stutter for
				end
				for _, value in { 0.55, 0, 0.35, 0 } do
					if not alive then
						return
					end
					flickerValue:set(value)
					task.wait(0.06)
				end
			end
		end)
	end

	local root = Kit.root(props, { Name = "Logo" })
	root.Size = UDim2.fromOffset(0, REF)
	root.AutomaticSize = Enum.AutomaticSize.X
	root.BackgroundTransparency = 1
	root.Rotation = props.Rotation or props.Tilt or DEFAULT_TILT[kind]
	root[Children] = {
		scope:New "UIScale" { Scale = height / REF },
		BUILDERS[kind](ctx, props, tone),
		props[Children],
	}
	logo = scope:New "Frame" (root) :: Frame
	return logo :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="124">
            <Properties>
              <string name="Name">Meter</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Meter: one survival reading (sanity, oxygen, power, EMF, lantern oil,
	health) in the look the game calls for (Blueprint v1.7 HUD type Meter).

	scope:Meter {
		Label = "SANITY",
		Icon = "eye",
		Value = sanity,           -- 0..1 (number or state)
		Style = "Bar",            -- "Bar" (a ProgressBar) | "Segments" (chunky cells, FNAF power)
		                          -- | "Dial" (an arc of LEDs, an EMF reader) | "ECG" (a heartbeat monitor)
		Tone = "Primary",         -- fill colour (Bar / Segments)
		Segments = 5,             -- cells (Segments) or LEDs (Dial)
		Usage = 2,                -- Segments: the little usage bars (FNAF), 0..5; nil hides them
		Readout = "72%",          -- text (or state, or function(value) -> text); default a percentage
		Low = 0.25,               -- below this it warns (the last cell flickers, the bar flashes)
		Size = UDim2.fromOffset(300, 56),
	}

	ECG reads FINE / CAUTION / DANGER in words and speeds the trace up as the
	value drops. Reduced motion stops the sweep and the flicker; lite mode
	leaves out the monitor grid, the sweep and the LED glow. Every style
	writes its reading as text, so nothing depends on colour alone.
]]

local RunService = game:GetService("RunService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Motion = require(script.Parent.Parent.FX.Motion)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local ProgressBar = require(script.Parent.ProgressBar)
local HudKits = require(script.Parent.HudKits)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local STYLES = { "Bar", "Segments", "Dial", "ECG" }

-- one heartbeat, x 0..1, y (up is negative) -1..1
local BEAT = {
	{ 0, 0 }, { 0.14, 0 }, { 0.19, -0.14 }, { 0.24, 0 }, { 0.31, 0 }, { 0.34, 0.16 }, { 0.39, -1 }, { 0.44, 0.38 },
	{ 0.49, 0 }, { 0.6, 0 }, { 0.68, -0.26 }, { 0.77, 0 }, { 1, 0 },
}

local function condition(value: number): string
	if value > 0.6 then
		return "FINE"
	elseif value > 0.3 then
		return "CAUTION"
	end
	return "DANGER"
end

return function(scope: Scope, props: { [any]: any }): Frame
	-- HUD kits (v1.8, Kit = a HudKits name / tokens): the kit's number and label type
	local kitTokens = if props.Kit ~= nil then HudKits.tokens(props.Kit) else nil
	local kitted = kitTokens ~= nil and kitTokens.name ~= "Classic"
	local DISPLAY = if kitted then kitTokens.numberFont else "Display"
	local HEAVY = if kitted then kitTokens.labelFont else "Heavy"
	local style = peek(props.Style) or "Bar"
	if not table.find(STYLES, style) then
		style = "Bar"
	end
	local size = peek(props.Size) or UDim2.fromOffset(340, 74)
	-- Bar and Segments sit on a glass plate like the other HUD modules
	local plated = style == "Bar" or style == "Segments"
	local pad = if plated then 12 else 0
	local w, h = size.X.Offset - pad * 2, size.Y.Offset - pad * 2
	local lowAt = tonumber(peek(props.Low)) or 0.25
	local value = scope:Computed(function(use)
		return math.clamp(tonumber(use(props.Value)) or 0, 0, 1)
	end)
	local segments = math.clamp(math.floor(tonumber(peek(props.Segments)) or 5), 2, 12)
	local tone = Kit.tone(scope, props.Tone or "Primary")
	local low = scope:Computed(function(use)
		return use(value) < lowAt
	end)
	local readout = scope:Computed(function(use)
		local given = props.Readout
		local v = use(value)
		if typeof(given) == "function" then
			return tostring(given(v))
		end
		local text = use(given)
		if typeof(text) == "string" and text ~= "" then
			return text
		end
		if style == "Dial" then
			return tostring(math.clamp(math.ceil(v * segments - 1e-6), 0, segments))
		elseif style == "ECG" then
			return condition(v)
		end
		return `{math.floor(v * 100 + 0.5)}%`
	end)
	local label = scope:Computed(function(use)
		return string.upper(tostring(use(props.Label) or ""))
	end)
	local fillColor = scope:Computed(function(use)
		local theme = use(Theme.current)
		return if use(low) then theme.Color.Bad else use(tone).Top
	end)

	-- a flicker flag that toggles while low (never with reduced motion)
	local flicker = scope:Value(false)
	if style == "Segments" or style == "Bar" then
		local alive = true
		table.insert(scope, function()
			alive = false
		end)
		task.spawn(function()
			local n = 0
			while alive do
				task.wait(0.12)
				n += 1
				local on = peek(low) and not Motion.reduced and (n % 7 == 0 or n % 11 == 0)
				if on ~= peek(flicker) then
					flicker:set(on)
				end
			end
		end)
	end

	local root = Kit.root(props, { Name = "Meter" })
	root.Size = size
	root.BackgroundTransparency = 1
	local children: { any } = {}

	local function header(rowHeight: number, rightInset: number?): Frame
		local iconSize = math.floor(rowHeight * 0.95)
		return scope:New "Frame" {
			Name = "Header",
			BackgroundTransparency = 1,
			Size = UDim2.new(1, -(rightInset or 0), 0, rowHeight),
			ZIndex = 3,
			[Children] = {
				if props.Icon
					then Icon(scope, {
						Icon = props.Icon,
						Size = iconSize,
						Color = fillColor,
						Stroke = 2,
						Position = UDim2.fromOffset(0, math.floor((rowHeight - iconSize) / 2)),
						ZIndex = 4,
					})
					else nil,
				Text(scope, {
					Name = "Label",
					Text = label,
					TextSize = math.floor(rowHeight * 0.82),
					Font = HEAVY,
					Case = "Upper",
					XAlign = "Left",
					Stroke = 2,
					StrokeTransparency = 0.3,
					Position = UDim2.fromOffset(if props.Icon then iconSize + 8 else 0, 0),
					Size = UDim2.new(0.62, -(if props.Icon then iconSize + 8 else 0), 1, 0),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = math.floor(rowHeight * 0.82),
					ZIndex = 4,
				}),
				Text(scope, {
					Name = "Readout",
					Text = readout,
					TextSize = math.floor(rowHeight * 0.95),
					Font = DISPLAY,
					Case = "Normal",
					XAlign = "Right",
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						return if use(low) then theme.Color.Bad else theme.Color.Text
					end),
					Stroke = 2,
					StrokeTransparency = 0.3,
					AnchorPoint = Vector2.new(1, 0),
					Position = UDim2.fromScale(1, 0),
					Size = UDim2.new(0.38, 0, 1, 0),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = math.floor(rowHeight * 0.95),
					ZIndex = 4,
				}),
			},
		}
	end

	if style == "Bar" then
		local rowHeight = math.floor(h * 0.44)
		table.insert(children, header(rowHeight))
		table.insert(children, ProgressBar(scope, {
			Name = "Bar",
			Value = value,
			Max = 1,
			Tone = scope:Computed(function(use)
				local theme = use(Theme.current)
				return if use(low) then theme.Tone.Bad or theme.Tone.Red else use(tone)
			end),
			Ghost = false,
			Low = lowAt,
			Size = UDim2.new(1, 0, 0, math.max(10, h - rowHeight - 8)),
			Position = UDim2.new(0, 0, 1, -math.max(10, h - rowHeight - 8)),
			ZIndex = 2,
		}))
	elseif style == "Segments" then
		local rowHeight = math.floor(h * 0.4)
		local usage = props.Usage
		local usageWidth = if usage ~= nil then 86 else 0
		table.insert(children, header(rowHeight))
		local cellH = math.max(12, h - rowHeight - 8)
		local gap = 5
		local cellW = math.floor((w - usageWidth - gap * (segments - 1)) / segments)
		local cells = {}
		for i = 1, segments do
			local lit = scope:Computed(function(use)
				return use(value) * segments >= i - 0.5
			end)
			local lastLit = scope:Computed(function(use)
				return math.clamp(math.floor(use(value) * segments + 0.5), 0, segments) == i
			end)
			table.insert(cells, scope:New "Frame" {
				Name = "Cell" .. i,
				LayoutOrder = i,
				Size = UDim2.fromOffset(cellW, cellH),
				BackgroundColor3 = scope:Computed(function(use)
					local theme = use(Theme.current)
					return if use(lit) then use(fillColor) else theme.Color.Track
				end),
				BackgroundTransparency = scope:Computed(function(use)
					if not use(lit) then
						return 0.35
					end
					return if use(lastLit) and use(flicker) then 0.7 else 0
				end),
				ZIndex = 2,
				[Children] = {
					Kit.corner(scope, 2),
					-- empty cells keep a rim in the fill colour, so the scale always shows
					Kit.stroke(scope, {
						Thickness = 1.5,
						Transparency = scope:Computed(function(use)
							return if use(lit) then 0.2 else 0.6
						end),
						Color = scope:Computed(function(use)
							return if use(lit) then Color.darken(use(fillColor), 0.45) else use(fillColor)
						end),
					}),
					Kit.unlessLite(scope, function(inner)
						return inner:New "Frame" {
							Name = "Shine",
							Size = UDim2.new(1, -4, 0.35, 0),
							Position = UDim2.fromOffset(2, 2),
							BackgroundColor3 = Color3.new(1, 1, 1),
							BackgroundTransparency = inner:Computed(function(use)
								return if use(lit) then 0.72 else 1
							end),
							ZIndex = 3,
							[Children] = Kit.corner(inner, 1),
						}
					end),
				},
			})
		end
		table.insert(children, scope:New "Frame" {
			Name = "Cells",
			BackgroundTransparency = 1,
			Position = UDim2.new(0, 0, 1, -cellH),
			Size = UDim2.new(1, -usageWidth, 0, cellH),
			ZIndex = 2,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = gap, VAlign = "Center" }),
				cells,
			},
		})
		if usage ~= nil then
			local bars = {}
			for i = 1, 5 do
				local on = scope:Computed(function(use)
					return (tonumber(use(usage)) or 0) >= i
				end)
				table.insert(bars, scope:New "Frame" {
					Name = "Use" .. i,
					LayoutOrder = i,
					Size = UDim2.fromOffset(8, math.floor(cellH * (0.45 + i * 0.11))),
					BackgroundColor3 = scope:Computed(function(use)
						local theme = use(Theme.current)
						if not use(on) then
							return theme.Color.Light
						end
						return if i >= 4 then theme.Color.Bad elseif i == 3 then theme.Color.Warn else theme.Color.Good
					end),
					BackgroundTransparency = scope:Computed(function(use)
						return if use(on) then 0 else 0.82
					end),
					ZIndex = 3,
					[Children] = Kit.corner(scope, 1),
				})
			end
			table.insert(children, scope:New "Frame" {
				Name = "Usage",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(1, 1),
				Position = UDim2.fromScale(1, 1),
				Size = UDim2.fromOffset(usageWidth - 10, cellH),
				ZIndex = 3,
				[Children] = {
					Kit.list(scope, { Direction = "X", Padding = 4, HAlign = "Right", VAlign = "Bottom" }),
					bars,
				},
			})
		end
	elseif style == "Dial" then
		-- an arc of LEDs, green to red, with the level written in the middle
		local cx, cy = w / 2, h - 26
		local radius = math.min(w / 2 - 26, cy - 30)
		local ledSize = math.clamp(math.floor(radius * 0.36), 18, 30)
		local sweep = if segments <= 5 then 140 else 160
		local leds = {}
		for i = 1, segments do
			local angle = math.rad(90 + sweep / 2 - (i - 1) * sweep / (segments - 1))
			local fraction = (i - 1) / math.max(1, segments - 1)
			local lit = scope:Computed(function(use)
				return use(value) * segments >= i - 0.5
			end)
			local ledColor = Kit.themed(scope, function(theme)
				return if fraction < 0.45 then theme.Color.Good elseif fraction < 0.8 then theme.Color.Warn else theme.Color.Bad
			end)
			table.insert(leds, scope:New "Frame" {
				Name = "Led" .. i,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromOffset(math.floor(cx + math.cos(angle) * radius), math.floor(cy - math.sin(angle) * radius)),
				Size = UDim2.fromOffset(ledSize, ledSize),
				BackgroundColor3 = scope:Computed(function(use)
					local theme = use(Theme.current)
					return if use(lit) then use(ledColor) else theme.Color.SurfaceLight or theme.Color.Track
				end),
				BackgroundTransparency = 0,
				ZIndex = 3,
				[Children] = {
					Kit.corner(scope, "Full"),
					-- unlit LEDs keep a visible rim, so the scale always reads 1-5
					Kit.stroke(scope, {
						Thickness = 2,
						Color = scope:Computed(function(use)
							local theme = use(Theme.current)
							return if use(lit) then theme.Color.Outline else use(ledColor)
						end),
						Transparency = scope:Computed(function(use)
							return if use(lit) then 0.2 else 0.45
						end),
					}),
					Kit.unlessLite(scope, function(inner)
						return inner:New "Frame" {
							Name = "Glow",
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Size = UDim2.fromScale(1.8, 1.8),
							BackgroundColor3 = ledColor,
							BackgroundTransparency = inner:Computed(function(use)
								return if use(lit) then 0.8 else 1
							end),
							ZIndex = 2,
							[Children] = Kit.corner(inner, "Full"),
						}
					end),
					-- the LED's own level, so the reading never rests on colour
					Text(scope, {
						Text = tostring(i),
						TextSize = math.floor(ledSize * 0.6),
						Font = HEAVY,
						Case = "Normal",
						Color = scope:Computed(function(use)
							local theme = use(Theme.current)
							return if use(lit) then Color.darken(use(ledColor), 0.7) else theme.Color.TextMuted
						end),
						Stroke = false,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.52),
						ZIndex = 4,
					}),
				},
			})
		end
		-- the arc the LEDs sit on
		local track = {}
		local steps = 18
		for s = 0, steps - 1 do
			local a0 = math.rad(90 + sweep / 2 + 8 - s * (sweep + 16) / steps)
			local a1 = math.rad(90 + sweep / 2 + 8 - (s + 1) * (sweep + 16) / steps)
			local r = radius + ledSize * 0.5 + 8
			local x0, y0 = cx + math.cos(a0) * r, cy - math.sin(a0) * r
			local x1, y1 = cx + math.cos(a1) * r, cy - math.sin(a1) * r
			local dx, dy = x1 - x0, y1 - y0
			table.insert(track, scope:New "Frame" {
				Name = "Arc",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromOffset(math.floor((x0 + x1) / 2 + 0.5), math.floor((y0 + y1) / 2 + 0.5)),
				Size = UDim2.fromOffset(math.ceil(math.sqrt(dx * dx + dy * dy)) + 1, 2),
				Rotation = math.deg(math.atan2(dy, dx)),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Light
				end),
				BackgroundTransparency = 0.7,
				BorderSizePixel = 0,
				ZIndex = 2,
			})
		end
		table.insert(leds, 1, scope:New "Frame" {
			Name = "Track",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			ZIndex = 2,
			[Children] = track,
		})
		table.insert(children, scope:New "Frame" {
			Name = "Face",
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.fromScale(0.5, 1),
			Size = UDim2.fromScale(1, 1),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = Kit.themed(scope, function(theme)
				return math.clamp(theme.Alpha.Surface, 0, 1)
			end),
			ZIndex = 1,
			[Children] = {
				Kit.corner(scope, "L"),
				Kit.stroke(scope, {
					Thickness = 1.5,
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Light
					end),
					Transparency = 0.8,
				}),
			},
		})
		table.insert(children, scope:New "Frame" {
			Name = "Leds",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			ZIndex = 3,
			[Children] = leds,
		})
		table.insert(children, Text(scope, {
			Name = "Readout",
			Text = readout,
			TextSize = math.floor(radius * 0.62),
			Font = DISPLAY,
			Case = "Normal",
			Stroke = 2,
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.new(0.5, 0, 0, math.floor(cy)),
			Size = UDim2.fromOffset(math.floor(radius * 1.1), math.floor(radius * 0.66)),
			Scaled = true,
			Wrapped = false,
			MaxTextSize = math.floor(radius * 0.62),
			ZIndex = 4,
		}))
		table.insert(children, scope:New "Frame" {
			Name = "Caption",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.new(0.5, 0, 1, -6),
			Size = UDim2.new(1, -20, 0, 22),
			ZIndex = 4,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 6, HAlign = "Center", VAlign = "Center" }),
				if props.Icon
					then Icon(scope, {
						Icon = props.Icon,
						Size = 20,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.TextMuted
						end),
						LayoutOrder = 1,
						ZIndex = 4,
					})
					else nil,
				Text(scope, {
					Text = label,
					TextSize = 18,
					Font = HEAVY,
					Case = "Upper",
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					Stroke = false,
					LayoutOrder = 2,
					ZIndex = 4,
				}),
			},
		})
	else
		-- ECG: a heartbeat monitor that says FINE / CAUTION / DANGER
		local rowHeight = math.floor(h * 0.26)
		local traceTop = rowHeight + 10
		local traceH = h - traceTop - 8
		local traceW = w - 20
		local state = scope:Computed(function(use)
			return condition(use(value))
		end)
		local stateColor = scope:Computed(function(use)
			local theme = use(Theme.current)
			local s = use(state)
			return if s == "FINE" then theme.Color.Good elseif s == "CAUTION" then theme.Color.Warn else theme.Color.Bad
		end)
		table.insert(children, scope:New "Frame" {
			Name = "Monitor",
			Size = UDim2.fromScale(1, 1),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = Kit.themed(scope, function(theme)
				return math.clamp(theme.Alpha.Surface - 0.1, 0, 1)
			end),
			ZIndex = 1,
			[Children] = {
				Kit.corner(scope, "S"),
				Kit.stroke(scope, { Thickness = 1.5, Color = stateColor, Transparency = 0.55 }),
			},
		})
		table.insert(children, Kit.unlessLite(scope, function(inner)
			local lines = {}
			for i = 1, 7 do
				table.insert(lines, inner:New "Frame" {
					Name = "GridV",
					Size = UDim2.new(0, 1, 0, traceH),
					Position = UDim2.fromOffset(10 + math.floor(traceW * i / 8), traceTop),
					BackgroundColor3 = stateColor,
					BackgroundTransparency = 0.88,
					ZIndex = 2,
				})
			end
			for i = 1, 2 do
				table.insert(lines, inner:New "Frame" {
					Name = "GridH",
					Size = UDim2.new(0, traceW, 0, 1),
					Position = UDim2.fromOffset(10, traceTop + math.floor(traceH * i / 3)),
					BackgroundColor3 = stateColor,
					BackgroundTransparency = 0.88,
					ZIndex = 2,
				})
			end
			return inner:New "Frame" {
				Name = "Grid",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				ZIndex = 2,
				[Children] = lines,
			}
		end))
		table.insert(children, scope:New "Frame" {
			Name = "Header",
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(10, 6),
			Size = UDim2.new(1, -20, 0, rowHeight),
			ZIndex = 3,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 8, VAlign = "Center" }),
				if props.Icon
					then Icon(scope, { Icon = props.Icon, Size = rowHeight, Color = stateColor, LayoutOrder = 1, ZIndex = 4 })
					else nil,
				Text(scope, {
					Text = label,
					TextSize = math.floor(rowHeight * 0.8),
					Font = HEAVY,
					Case = "Upper",
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					Stroke = false,
					LayoutOrder = 2,
					ZIndex = 4,
				}),
			},
		})
		table.insert(children, Text(scope, {
			Name = "State",
			Text = readout,
			TextSize = math.floor(rowHeight * 1.05),
			Font = DISPLAY,
			Case = "Upper",
			XAlign = "Right",
			Color = stateColor,
			Stroke = 2,
			AnchorPoint = Vector2.new(1, 0),
			Position = UDim2.new(1, -10, 0, 4),
			Size = UDim2.new(0.55, 0, 0, rowHeight + 4),
			Scaled = true,
			Wrapped = false,
			MaxTextSize = math.floor(rowHeight * 1.05),
			ZIndex = 4,
		}))
		-- the trace: straight segments through BEAT, 2-4 beats wide
		local trace = scope:Computed(function(use, inner)
			local s = use(state)
			local beats = if s == "FINE" then 2 elseif s == "CAUTION" then 3 else 4
			local amplitude = traceH * 0.46
			local mid = traceTop + traceH * 0.58
			local points = {}
			for b = 0, beats - 1 do
				for i, p in BEAT do
					if not (b > 0 and i == 1) then
						table.insert(points, { 10 + (b + p[1]) / beats * traceW, mid + p[2] * amplitude })
					end
				end
			end
			local segs = {}
			for i = 1, #points - 1 do
				local a, bPoint = points[i], points[i + 1]
				local dx, dy = bPoint[1] - a[1], bPoint[2] - a[2]
				local length = math.sqrt(dx * dx + dy * dy)
				table.insert(segs, inner:New "Frame" {
					Name = "Seg",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromOffset(math.floor((a[1] + bPoint[1]) / 2 + 0.5), math.floor((a[2] + bPoint[2]) / 2 + 0.5)),
					Size = UDim2.fromOffset(math.ceil(length) + 2, 3),
					Rotation = math.deg(math.atan2(dy, dx)),
					BackgroundColor3 = stateColor,
					BorderSizePixel = 0,
					ZIndex = 3,
				})
			end
			return inner:New "Frame" {
				Name = "Trace",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				ZIndex = 3,
				[Children] = segs,
			}
		end)
		table.insert(children, trace)
		-- the sweep: an eraser gap and a bright head crawl across (not with
		-- reduced motion or lite mode)
		local sweepX = scope:Value(traceW * 0.72)
		local head = scope:New "Frame" {
			Name = "Sweep",
			AnchorPoint = Vector2.new(0, 0),
			BackgroundTransparency = 1,
			Position = scope:Computed(function(use)
				return UDim2.fromOffset(10 + math.floor(use(sweepX)), traceTop)
			end),
			Size = UDim2.fromOffset(26, traceH),
			Visible = scope:Computed(function(use)
				return not use(Theme.liteMode) and not use(Motion.reducedState)
			end),
			ZIndex = 4,
			[Children] = {
				scope:New "Frame" {
					Name = "Eraser",
					Position = UDim2.fromOffset(4, -2),
					Size = UDim2.new(1, -4, 1, 4),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Surface
					end),
					BackgroundTransparency = 0.05,
					ZIndex = 4,
				},
				scope:New "Frame" {
					Name = "Head",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0, 2, 0.58, 0),
					Size = UDim2.fromOffset(7, 7),
					BackgroundColor3 = Color3.new(1, 1, 1),
					ZIndex = 5,
					[Children] = Kit.corner(scope, "Full"),
				},
			},
		}
		table.insert(children, head)
		local speed = traceW / 2.2
		table.insert(scope, RunService.RenderStepped:Connect(function(dt)
			if Motion.reduced or Theme.isLite() then
				return
			end
			local x = peek(sweepX) + dt * speed * (if peek(state) == "DANGER" then 1.6 elseif peek(state) == "CAUTION" then 1.25 else 1)
			if x > traceW - 20 then
				x = 0
			end
			sweepX:set(x)
		end))
	end
	if props[Children] ~= nil then
		table.insert(children, props[Children])
	end
	if plated then
		root.BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Surface
		end)
		root.BackgroundTransparency = Kit.themed(scope, function(theme)
			return math.clamp(theme.Alpha.Surface, 0, 1)
		end)
		table.insert(children, Kit.corner(scope, "M"))
		table.insert(children, Kit.padding(scope, pad))
		table.insert(children, Kit.stroke(scope, {
			Thickness = 1.5,
			Color = scope:Computed(function(use)
				local theme = use(Theme.current)
				return if use(low) then theme.Color.Bad else theme.Color.Light
			end),
			Transparency = scope:Computed(function(use)
				return if use(low) then 0.3 else 0.85
			end),
		}))
	end
	root[Children] = children
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="125">
            <Properties>
              <string name="Name">Minimap</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Minimap: a map frame. Put your map content (an image, markers) in its
	"Map" child (a CanvasGroup that clips to the frame's shape).

	scope:Minimap {
		Style = "Square",        -- "Square" (the original; Shape = "Round" rounds it)
		                         -- | "Compass" (a round map in a compass ring that turns
		                         --   with Heading) | "Card" (a rounded card with the zone
		                         --   name and coordinates)
		Kit = "Glass",
		Size = 220,              -- the map's size in design px
		Heading = heading,       -- degrees (state): Compass turns its ring, Card prints it
		Coordinates = position,  -- Vector3 / Vector2 (state): Card prints it
		Zone = "Spawn Island",   -- Card header
	}

	The root has a fixed size (and a HudSize attribute) for HUD layouts.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local P = require(script.Parent.HudParts)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

local function gridLines(scope: Scope, alpha: number): { Instance }
	local lines = {}
	for i = 1, 3 do
		table.insert(lines, scope:New "Frame" {
			Name = "GridX",
			Size = UDim2.new(1, 0, 0, 1),
			Position = UDim2.fromScale(0, i / 4),
			BackgroundColor3 = WHITE,
			BackgroundTransparency = alpha,
			ZIndex = 2,
		})
		table.insert(lines, scope:New "Frame" {
			Name = "GridY",
			Size = UDim2.new(0, 1, 1, 0),
			Position = UDim2.fromScale(i / 4, 0),
			BackgroundColor3 = WHITE,
			BackgroundTransparency = alpha,
			ZIndex = 2,
		})
	end
	return lines
end

local function playerMarker(scope: Scope, T: { [string]: any }?, size: number, heading: any): Frame
	if T == nil then
		-- the original pin
		return Icon(scope, {
			Name = "Player",
			Icon = "location-pin",
			Size = size,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Color = Kit.themed(scope, function(theme)
				return theme.Color.Accent
			end),
			ZIndex = 4,
		})
	end
	return Icon(scope, {
		Name = "Player",
		Icon = if heading then "arrow-large-up" else "location-pin",
		Size = size,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Color = Kit.themed(scope, function(theme)
			return theme.Color.Accent
		end),
		Stroke = 2,
		StrokeColor = Kit.themed(scope, function(theme)
			return theme.Color.Outline
		end),
		ZIndex = 8,
	})
end

-- Root props: layout props from `props`, but Size is the map's px size (a
-- number), so the frame's Size comes from the look.
local function rootProps(props: { [any]: any }, size: Vector2): { [any]: any }
	local root = Kit.root(props, { Name = "Minimap" })
	root.Size = UDim2.fromOffset(size.X, size.Y)
	return root
end

local function heading8(degrees: number): string
	local names = { "N", "NE", "E", "SE", "S", "SW", "W", "NW" }
	return names[(math.floor(((degrees % 360) + 22.5) / 45) % 8) + 1]
end

local BUILD = {}

-- The original: a square (or round) glass frame with a grid and a pin.
function BUILD.Square(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local s = props.Size or 220
	local kitted = P.kitted(props.Kit)
	local radius: any = if props.Shape == "Round" then "Full" else "L"
	local look = P.look(scope, T)
	local map = scope:New "CanvasGroup" {
		Name = "Map",
		Size = UDim2.new(1, -12, 1, -12),
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Surface
		end),
		BackgroundTransparency = 0.25,
		ZIndex = 2,
		[Children] = {
			if kitted then P.corner(scope, T, if radius == "Full" then "Pill" else nil) else Kit.corner(scope, radius),
			gridLines(scope, 0.9),
		},
	}
	local root = rootProps(props, Vector2.new(s, s))
	root.BackgroundColor3 = if kitted
		then scope:Computed(function(use)
			return use(look).face
		end)
		else Kit.themed(scope, function(theme)
			return theme.Color.Surface
		end)
	root.BackgroundTransparency = if kitted
		then scope:Computed(function(use)
			local l = use(look)
			return if l.alpha >= 1 then 0.5 else l.alpha
		end)
		else Kit.themed(scope, function(theme)
			return theme.Alpha.Surface
		end)
	root[Children] = {
		if kitted then P.corner(scope, T, if radius == "Full" then "Pill" else nil) else Kit.corner(scope, radius),
		Kit.stroke(scope, {
			Color = if kitted
				then scope:Computed(function(use)
					local l = use(look)
					return if l.strokeWidth > 0 then l.stroke else WHITE
				end)
				else WHITE,
			Thickness = 3,
			Transparency = if kitted
				then scope:Computed(function(use)
					local l = use(look)
					return if l.strokeWidth > 0 then l.strokeAlpha else 0.6
				end)
				else 0.6,
		}),
		map,
		playerMarker(scope, nil, 30, nil),
		Text(scope, {
			Name = "North",
			Text = "N",
			TextSize = 22,
			Font = if kitted then T.numberFont else "Display",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5, 0, 0, 14),
			ZIndex = 4,
		}),
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, Vector2.new(s, s))
	return frame
end

-- A round map inside a compass ring that turns with the heading.
function BUILD.Compass(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local s = (props.Size or 220) + 20
	local ringW = 26
	local look = P.look(scope, T)
	local heading = scope:Computed(function(use)
		return tonumber(use(props.Heading)) or 0
	end)
	local turn = scope:Spring(heading, 20, 1)
	local letters = {}
	for index, name in { "N", "E", "S", "W" } do
		local angle = (index - 1) * 90
		table.insert(letters, P.text(scope, T, look, {
			Name = name,
			Role = "Number",
			Text = name,
			TextSize = if name == "N" then 18 else 15,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = scope:Computed(function(use)
				local a = math.rad(angle - use(turn))
				local r = s / 2 - ringW / 2
				return UDim2.new(0.5, math.floor(math.sin(a) * r + 0.5), 0.5, math.floor(-math.cos(a) * r + 0.5))
			end),
			Color = if name == "N"
				then scope:Computed(function(use)
					return use(Theme.current).Color.Bad
				end)
				else nil,
			ZIndex = 6,
		}))
	end
	local ticks = P.ticks(scope, {
		Name = "Dial",
		Size = s - 6,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Count = 36,
		Start = 5,
		Span = 360,
		Length = 5,
		Width = 2,
		Fraction = 1,
		Color = scope:Computed(function(use)
			return use(look).muted
		end),
		ZIndex = 5,
	})
	local inner = s - ringW * 2
	local root = rootProps(props, Vector2.new(s, s))
	root.BackgroundTransparency = 1
	root[Children] = {
		P.plate(scope, T, { Radius = "Full", Idle = true }),
		scope:New "Frame" {
			Name = "Ring",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			Rotation = scope:Computed(function(use)
				return -use(turn)
			end),
			ZIndex = 5,
			[Children] = ticks,
		},
		letters,
		scope:New "CanvasGroup" {
			Name = "Map",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromOffset(inner, inner),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface:Lerp(theme.Color.SurfaceLight, 0.4)
			end),
			BackgroundTransparency = 0.2,
			ZIndex = 4,
			[Children] = {
				scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
				gridLines(scope, 0.88),
			},
		},
		scope:New "Frame" {
			Name = "MapRim",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromOffset(inner, inner),
			BackgroundTransparency = 1,
			ZIndex = 5,
			[Children] = {
				scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
				scope:New "UIStroke" {
					Color = scope:Computed(function(use)
						return use(look).accent
					end),
					Thickness = 2,
					Transparency = 0.35,
				},
			},
		},
		-- the heading marker at the top of the ring
		scope:New "Frame" {
			Name = "Lubber",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(0.5, 0, 0, -2),
			Size = UDim2.fromOffset(12, 12),
			Rotation = 45,
			BackgroundColor3 = scope:Computed(function(use)
				return use(look).accent
			end),
			BorderSizePixel = 0,
			ZIndex = 7,
		},
		playerMarker(scope, T, 28, props.Heading),
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, Vector2.new(s, s))
	return frame
end

-- A rounded card: zone name on top, the map, coordinates underneath.
function BUILD.Card(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local mapSize = (props.Size or 220) - 8
	local pad = 10
	local header = 34
	local footer = 30
	local size = Vector2.new(mapSize + pad * 2, header + mapSize + footer + pad)
	local look = P.look(scope, T)
	local coords = scope:Computed(function(use)
		local c = use(props.Coordinates)
		local h = tonumber(use(props.Heading))
		local parts = {}
		if typeof(c) == "Vector3" then
			table.insert(parts, string.format("X %d  Z %d", math.floor(c.X), math.floor(c.Z)))
		elseif typeof(c) == "Vector2" then
			table.insert(parts, string.format("X %d  Y %d", math.floor(c.X), math.floor(c.Y)))
		end
		if h then
			table.insert(parts, `{heading8(h)} {math.floor(h % 360)}°`)
		end
		if #parts == 0 then
			return "X 0  Z 0"
		end
		return table.concat(parts, "   ")
	end)
	local mapCorner = scope:Computed(function(use)
		local r = P.radiusFor(use(Theme.current), T, nil)
		return UDim.new(0, math.max(0, r.Offset - 2))
	end)
	local root = rootProps(props, size)
	root.BackgroundTransparency = 1
	root[Children] = {
		P.plate(scope, T, { Edge = "Top", Idle = true }),
		P.icon(scope, T, {
			Name = "ZoneIcon",
			Icon = "location-pin-map",
			Size = 22,
			Color = scope:Computed(function(use)
				return use(look).accent
			end),
			Treatment = "Flat",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.fromOffset(pad + 2, header / 2 + 2),
			ZIndex = 4,
		}),
		P.text(scope, T, look, {
			Name = "Zone",
			Role = "Title",
			Text = props.Zone or "Map",
			TextSize = 18,
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.fromOffset(pad + 30, header / 2 + 2),
			Size = UDim2.fromOffset(mapSize - 30, 22),
			Truncate = true,
			Color = scope:Computed(function(use)
				return use(look).ink
			end),
			ZIndex = 4,
		}),
		scope:New "CanvasGroup" {
			Name = "Map",
			Position = UDim2.fromOffset(pad, header),
			Size = UDim2.fromOffset(mapSize, mapSize),
			BackgroundColor3 = scope:Computed(function(use)
				local l = use(look)
				local theme = use(Theme.current)
				return if l.light then l.track else theme.Color.Surface:Lerp(theme.Color.SurfaceLight, 0.35)
			end),
			BackgroundTransparency = 0.1,
			ZIndex = 3,
			[Children] = {
				scope:New "UICorner" { CornerRadius = mapCorner },
				gridLines(scope, 0.86),
			},
		},
		scope:New "Frame" {
			Name = "MapEdge",
			Position = UDim2.fromOffset(pad, header),
			Size = UDim2.fromOffset(mapSize, mapSize),
			BackgroundTransparency = 1,
			ZIndex = 4,
			[Children] = {
				scope:New "UICorner" { CornerRadius = mapCorner },
				scope:New "UIStroke" {
					Color = scope:Computed(function(use)
						local l = use(look)
						return if l.light then l.stroke else WHITE
					end),
					Thickness = 1.5,
					Transparency = scope:Computed(function(use)
						return if use(look).light then 0.3 else 0.8
					end),
				},
				playerMarker(scope, T, 26, props.Heading),
				P.text(scope, T, look, {
					Name = "North",
					Role = "Number",
					Text = "N",
					TextSize = 16,
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, 6),
					Color = scope:Computed(function(use)
						return use(Theme.current).Color.Text
					end),
					Stroke = 1.5,
					StrokeColor = scope:Computed(function(use)
						return use(Theme.current).Color.Outline
					end),
					ZIndex = 9,
				}),
			},
		},
		P.text(scope, T, look, {
			Name = "Coordinates",
			Role = "Number",
			Text = coords,
			TextSize = 15,
			Font = "Mono",
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, pad + 2, 1, -footer / 2),
			Size = UDim2.fromOffset(mapSize, 18),
			Truncate = true,
			Color = scope:Computed(function(use)
				return use(look).muted
			end),
			ZIndex = 4,
		}),
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

return function(scope: Scope, props: { [any]: any }): Frame
	local style = P.style("Minimap", props.Style)
	return BUILD[style](scope, props, P.tokens(props.Kit))
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="126">
            <Properties>
              <string name="Name">MobileControls</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	MobileControls: big round touch buttons (attack, reload, crouch, ability)
	in an arc around Roblox's own jump button, bottom-right. They only show on
	touch devices (and hide again if the player switches to keyboard or
	gamepad), react instantly on press with a squash, glow and ripple, and
	call OnPress / OnRelease so holds work (auto-fire, aim).

	scope:MobileControls {
		Buttons = {
			{ id = "fire", label = "Fire", icon = "crosshair", OnPress = startFiring, OnRelease = stopFiring, Tone = "Red" },
			{ id = "reload", label = "Reload", icon = "rebirth", OnPress = reload },
			{ id = "crouch", label = "Crouch", icon = "crouch", OnPress = crouch },
		},
		AlwaysShow = false,        -- true to show on every device (testing, previews)
		Scale = 1,                 -- size multiplier
	}

	Put it straight in a Screen: it fills its parent and lays itself out from
	the screen's bottom-right corner. The first ring holds 3 buttons, the next
	ring 4 more. Buttons read id/label/icon (or Id/Label/Icon), OnPress(id),
	OnRelease(id), an optional Tone (default: dark glass) and Sound (theme
	sound name, or false for silent presses).
]]

local UserInputService = game:GetService("UserInputService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Input = require(script.Parent.Parent.Util.Input)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)

local function field(item: { [string]: any }, lower: string, upper: string): any
	local value = item[lower]
	if value == nil then
		value = item[upper]
	end
	return value
end

-- Roblox's jump button (TouchGui), in real screen pixels measured from the
-- bottom-right corner: centre and diameter. Small screens (phones) use the
-- compact layout.
local function jumpButton(screen: Vector2): (Vector2, number)
	if math.min(screen.X, screen.Y) <= 500 then
		return Vector2.new(60, 55), 70
	end
	return Vector2.new(110, 150), 120
end

-- Angles (degrees; 0 = left of the jump button, 90 = straight above) for
-- `count` buttons on a ring.
local function ringAngles(count: number): { number }
	if count == 1 then
		return { 45 }
	elseif count == 2 then
		return { 22, 68 }
	end
	local out = {}
	for i = 1, count do
		table.insert(out, (i - 1) * 90 / (count - 1))
	end
	return out
end

type Slot = { angle: number, ring: number }

local function layout(count: number): { Slot }
	local slots = {}
	local first = math.min(count, 3)
	for _, angle in ringAngles(first) do
		table.insert(slots, { angle = angle, ring = 1 })
	end
	local rest = count - first
	local ring = 2
	while rest > 0 do
		local n = math.min(rest, 4)
		for _, angle in ringAngles(n) do
			table.insert(slots, { angle = angle, ring = ring })
		end
		rest -= n
		ring += 1
	end
	return slots
end

return function(scope: Fusion.Scope<any>, props: { [any]: any }): Frame
	local buttons = peek(props.Buttons) or {}
	local slots = layout(#buttons)
	local sizeScale = props.Scale or 1

	local shown = scope:Computed(function(use)
		if use(props.Visible) == false then
			return false
		end
		return use(props.AlwaysShow) == true or use(Input.device) == "Touch"
	end)
	local appear = scope:Spring(scope:Computed(function(use)
		return if use(shown) then 1 else 0
	end), 22, 0.7)

	-- geometry in design px, recomputed when the screen or scale changes
	local metrics = scope:Computed(function(use)
		local screen = use(Viewport.size)
		local scale = math.max(0.01, use(Viewport.scale))
		local jumpCentre, jumpSize = jumpButton(screen)
		local button = jumpSize * 0.84 * sizeScale
		local gap = jumpSize * 0.16
		local ring1 = math.max(jumpSize / 2 + gap + button / 2, (button + gap) / (2 * math.sin(math.rad(22.5))))
		local ringStep = button + gap
		return {
			centre = jumpCentre / scale,
			button = button / scale,
			ring1 = ring1 / scale,
			ringStep = ringStep / scale,
		}
	end)

	local nodes = {}
	for index, item in buttons do
		local slot = slots[index]
		local pressed = scope:Value(false)
		local activeInput: InputObject? = nil
		local rippleGoal = scope:Value(1)
		local ripple = scope:Spring(rippleGoal, 9, 1)
		local tone = scope:Computed(function(use)
			local toneName = field(item, "tone", "Tone")
			return if toneName then Theme.tone(use(Theme.current), toneName) else nil
		end)
		local squash = scope:Spring(scope:Computed(function(use)
			return if use(pressed) then 0.88 else 1
		end), 40, 0.5)

		local function press(input: InputObject?)
			if peek(pressed) then
				return
			end
			activeInput = input
			pressed:set(true)
			ripple:setPosition(0)
			local sound = field(item, "sound", "Sound")
			if sound ~= false then
				Sound.play(sound or "Click", { Volume = 0.6 })
			end
			local onPress = field(item, "OnPress", "onPress")
			if onPress then
				task.spawn(onPress, field(item, "id", "Id"))
			end
		end

		local function release()
			if not peek(pressed) then
				return
			end
			activeInput = nil
			pressed:set(false)
			local onRelease = field(item, "OnRelease", "onRelease")
			if onRelease then
				task.spawn(onRelease, field(item, "id", "Id"))
			end
		end

		local function isPointer(input: InputObject): boolean
			return input.UserInputType == Enum.UserInputType.Touch or input.UserInputType == Enum.UserInputType.MouseButton1
		end

		-- a finger that slides off the button still releases it
		table.insert(scope, UserInputService.InputEnded:Connect(function(input)
			if activeInput ~= nil and input == activeInput then
				release()
			end
		end))
		table.insert(scope, release)

		local position = scope:Computed(function(use)
			local m = use(metrics)
			local r = m.ring1 + (slot.ring - 1) * m.ringStep
			local a = math.rad(slot.angle)
			local x = m.centre.X + math.cos(a) * r
			local y = m.centre.Y + math.sin(a) * r
			local drop = (1 - use(appear)) * 60
			return UDim2.new(1, -x, 1, -y + drop)
		end)

		local faceColors = scope:Computed(function(use)
			local t = use(tone)
			local isDown = use(pressed)
			if t then
				return ColorSequence.new(Color.lighten(t.Top, if isDown then 0.25 else 0.05), if isDown then t.Top else t.Bottom)
			end
			local surface = use(Theme.current).Color.SurfaceLight
			return ColorSequence.new(Color.lighten(surface, if isDown then 0.35 else 0.12), surface)
		end)

		table.insert(nodes, scope:New "TextButton" {
			Name = `Action_{field(item, "id", "Id") or index}`,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = position,
			Size = scope:Computed(function(use)
				local d = use(metrics).button
				return UDim2.fromOffset(d, d)
			end),
			BackgroundTransparency = 1,
			Text = "",
			AutoButtonColor = false,
			Active = true,
			Selectable = false,
			ZIndex = 5,
			[OnEvent "InputBegan"] = function(input: InputObject)
				if isPointer(input) then
					press(input)
				end
			end,
			[OnEvent "InputEnded"] = function(input: InputObject)
				if isPointer(input) then
					release()
				end
			end,
			[Children] = {
				scope:New "UIScale" {
					Scale = scope:Computed(function(use)
						return use(squash) * math.clamp(use(appear), 0, 1)
					end),
				},
				scope:New "Frame" {
					Name = "Ripple",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Size = scope:Computed(function(use)
						local grow = 1 + 0.55 * math.clamp(use(ripple), 0, 1)
						return UDim2.fromScale(grow, grow)
					end),
					BackgroundTransparency = 1,
					ZIndex = 5,
					[Children] = {
						Kit.corner(scope, "Full"),
						scope:New "UIStroke" {
							Color = scope:Computed(function(use)
								local t = use(tone)
								return if t then Color.lighten(t.Top, 0.3) else WHITE
							end),
							Thickness = 4,
							Transparency = scope:Computed(function(use)
								return 0.2 + 0.8 * math.clamp(use(ripple), 0, 1)
							end),
						},
					},
				},
				scope:New "Frame" {
					Name = "Face",
					Size = UDim2.fromScale(1, 1),
					BackgroundColor3 = WHITE,
					BackgroundTransparency = scope:Computed(function(use)
						if use(tone) then
							return if use(pressed) then 0.05 else 0.18
						end
						return if use(pressed) then 0.15 else 0.35
					end),
					ZIndex = 6,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.gradient(scope, { Rotation = 90, Color = faceColors }),
						scope:New "UIStroke" {
							Color = scope:Computed(function(use)
								local t = use(tone)
								if use(pressed) then
									return WHITE
								end
								return if t then t.Edge else use(Theme.current).Color.Outline
							end),
							Thickness = 3,
							Transparency = 0.05,
						},
						scope:New "Frame" {
							Name = "Rim",
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Size = UDim2.new(1, -5, 1, -5),
							BackgroundTransparency = 1,
							ZIndex = 7,
							[Children] = {
								Kit.corner(scope, "Full"),
								scope:New "UIStroke" {
									Color = WHITE,
									Thickness = 1.5,
									Transparency = 0.65,
								},
							},
						},
						scope:New "Frame" {
							Name = "Gloss",
							AnchorPoint = Vector2.new(0.5, 0),
							Position = UDim2.new(0.5, 0, 0.06, 0),
							Size = UDim2.fromScale(0.68, 0.36),
							BackgroundColor3 = WHITE,
							BackgroundTransparency = 0.72,
							ZIndex = 7,
							[Children] = {
								Kit.corner(scope, "Full"),
								Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0.1, 1) }),
							},
						},
						Icon(scope, {
							Name = "ActionIcon",
							Icon = field(item, "icon", "Icon") or "star",
							Size = if field(item, "label", "Label") then UDim2.fromScale(0.46, 0.46) else UDim2.fromScale(0.52, 0.52),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, if field(item, "label", "Label") then 0.39 else 0.5),
							Color = WHITE,
							Stroke = 2,
							StrokeColor = scope:Computed(function(use)
								local t = use(tone)
								return if t then (t.Stroke or t.Edge) else use(Theme.current).Color.Outline
							end),
							ZIndex = 8,
						}),
						if field(item, "label", "Label")
							then Text(scope, {
								Name = "ActionLabel",
								Text = field(item, "label", "Label"),
								-- one line, sized to fit the button in the theme's own font:
								-- wide faces must never wrap a word ("Sprin / t")
								TextSize = scope:Computed(function(use)
									local d = use(metrics).button
									local label = tostring(field(item, "label", "Label"))
									local theme = use(Theme.current)
									return Kit.fitTextSize(label, math.floor(math.min(40, d * 0.22)), d * 0.78, "Heavy", 8, theme)
								end),
								Wrapped = false,
								Font = "Heavy",
								Stroke = 1.5,
								AnchorPoint = Vector2.new(0.5, 0.5),
								Position = UDim2.fromScale(0.5, 0.75),
								Size = UDim2.fromScale(0.8, 0.23),
								ZIndex = 8,
							})
							else nil,
					},
				},
			},
		})
	end

	local root = Kit.root(props, { Name = "MobileControls" })
	root.Size = props.Size or UDim2.fromScale(1, 1)
	root.Position = props.Position or UDim2.fromScale(0, 0)
	root.BackgroundTransparency = 1
	root.Active = false
	root.Visible = scope:Computed(function(use)
		return use(shown) or use(appear) > 0.02
	end)
	root[Children] = {
		nodes,
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="127">
            <Properties>
              <string name="Name">Nameplate</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Nameplate: an overhead name tag (BillboardGui) for NPCs, enemies and
	players. Name with a tinted outline, an optional title line, level badge
	and icon, and a slim health bar with a damage ghost and hit flash. It
	shrinks a little with distance and fades out near its max distance.

	scope:Nameplate {
		Adornee = npc.Head,            -- BasePart, Attachment or Model (uses its Head / PrimaryPart)
		Name = "Goblin Chief",
		Title = "Camp Boss",           -- optional smaller line above the name
		Level = 12,                    -- optional badge before the name
		Icon = "crown",                -- optional icon before the name
		Health = hp, MaxHealth = 100,  -- numbers or states (no Health = no bar)
		Humanoid = npc.Humanoid,       -- or bind health straight to a Humanoid (true = find it in the Adornee's model)
		Tone = "Red",                  -- bar/title/badge colour; default bar goes green -> yellow -> red
		NameColor = Color3.new(1, 1, 1),
		Distance = 80,                 -- studs; fades between 75% and 100% of this
		Offset = Vector3.new(0, 1.2, 0),   -- studs above the top of the adornee
		AlwaysOnTop = false,
		Parent = playerGui,            -- default: the local PlayerGui (nothing replicates)
	}

	Returns the BillboardGui. Clean the scope to remove it.
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)
local BASE_WIDTH = 230
local BASE_HEIGHT = 78

--------------------------------------------------------------------------
-- One shared loop updates the distance fade of every nameplate.
--------------------------------------------------------------------------

type Entry = {
	target: () -> Instance?,
	distance: () -> number,
	fade: any, -- Value<number> 0 = visible, 1 = hidden
	scale: any, -- Value<number>
}

local entries: { [Entry]: boolean } = {}
local connection: RBXScriptConnection? = nil

local function worldPosition(target: Instance?): Vector3?
	if target == nil then
		return nil
	end
	if target:IsA("BasePart") then
		return target.Position
	elseif target:IsA("Attachment") then
		return target.WorldPosition
	end
	return nil
end

local function update()
	local camera = Workspace.CurrentCamera
	if camera == nil then
		return
	end
	local eye = camera.CFrame.Position
	for entry in entries do
		local position = worldPosition(entry.target())
		local fade, scale = 1, 1
		if position then
			local maxDistance = math.max(1, entry.distance())
			local d = (position - eye).Magnitude
			fade = math.clamp((d - maxDistance * 0.75) / (maxDistance * 0.25), 0, 1)
			scale = 1 - 0.28 * math.clamp((d - 12) / math.max(1, maxDistance - 12), 0, 1)
		end
		if math.abs(peek(entry.fade) - fade) > 0.01 or (fade == 0) ~= (peek(entry.fade) == 0) then
			entry.fade:set(fade)
		end
		if math.abs(peek(entry.scale) - scale) > 0.01 then
			entry.scale:set(scale)
		end
	end
end

local function register(entry: Entry)
	entries[entry] = true
	if connection == nil then
		connection = RunService.Heartbeat:Connect(update)
	end
end

local function unregister(entry: Entry)
	entries[entry] = nil
	if next(entries) == nil and connection then
		connection:Disconnect()
		connection = nil
	end
end

-- Models are adorned by their Head (characters), PrimaryPart or first part.
local function resolveAdornee(adornee: any): Instance?
	if typeof(adornee) ~= "Instance" then
		return nil
	end
	if adornee:IsA("Model") then
		local head = adornee:FindFirstChild("Head")
		if head and head:IsA("BasePart") then
			return head
		end
		return adornee.PrimaryPart or adornee:FindFirstChildWhichIsA("BasePart")
	end
	return adornee
end

local function findHumanoid(adornee: any): Humanoid?
	if typeof(adornee) ~= "Instance" then
		return nil
	end
	local model = if adornee:IsA("Model") then adornee else adornee:FindFirstAncestorOfClass("Model")
	return if model then model:FindFirstChildOfClass("Humanoid") else nil
end

return function(scope: Fusion.Scope<any>, props: { [any]: any }): BillboardGui
	local distance = props.Distance or 80
	local hasTitle = props.Title ~= nil
	local hasLevel = props.Level ~= nil

	-- health: explicit values, or a Humanoid
	local health = props.Health
	local maxHealth = props.MaxHealth or 100
	local humanoid = props.Humanoid
	if humanoid == true then
		humanoid = findHumanoid(peek(props.Adornee))
	end
	if typeof(humanoid) == "Instance" and humanoid:IsA("Humanoid") then
		local h = humanoid :: Humanoid
		local healthValue = scope:Value(h.Health)
		local maxValue = scope:Value(h.MaxHealth)
		table.insert(scope, h:GetPropertyChangedSignal("Health"):Connect(function()
			healthValue:set(h.Health)
		end))
		table.insert(scope, h:GetPropertyChangedSignal("MaxHealth"):Connect(function()
			maxValue:set(h.MaxHealth)
		end))
		health, maxHealth = healthValue, maxValue
	end
	local hasHealth = health ~= nil

	local fraction = scope:Computed(function(use)
		local total = use(maxHealth)
		if total == nil or total <= 0 then
			return 0
		end
		return math.clamp((use(health) or 0) / total, 0, 1)
	end)
	local fill = scope:Spring(fraction, 22, 1)
	local ghostGoal = scope:Value(peek(fraction))
	local ghost = scope:Spring(ghostGoal, 7, 1)
	local ghostToken = 0
	local flashGoal = scope:Value(1)
	local flash = scope:Spring(flashGoal, 16, 1)
	Kit.watch(scope, fraction, function(now)
		ghostToken += 1
		if now >= peek(ghostGoal) then
			ghostGoal:set(now)
		else
			flash:setPosition(0.2)
			local token = ghostToken
			task.delay(0.35, function()
				if token == ghostToken then
					ghostGoal:set(now)
				end
			end)
		end
	end)

	-- the tone: fixed if given, otherwise health-coloured
	local barTone = scope:Computed(function(use)
		local theme = use(Theme.current)
		local given = use(props.Tone)
		if given ~= nil then
			return Theme.tone(theme, given)
		end
		local f = use(fraction)
		return Theme.tone(theme, if f > 0.5 then "Green" elseif f > 0.25 then "Yellow" else "Red")
	end)
	local accentTone = Kit.tone(scope, props.Tone, "Yellow")

	-- distance fade + scale, fed by the shared loop
	local fade = scope:Value(0)
	local distanceScale = scope:Value(1)
	local adornee = scope:Computed(function(use)
		return resolveAdornee(use(props.Adornee))
	end)
	local entry: Entry = {
		target = function()
			return peek(adornee)
		end,
		distance = function()
			return peek(distance) or 80
		end,
		fade = fade,
		scale = distanceScale,
	}
	register(entry)
	table.insert(scope, function()
		unregister(entry)
	end)

	local scale = scope:Computed(function(use)
		return math.clamp(use(Viewport.scale), 0.75, 1.25) * use(distanceScale)
	end)
	local alpha = fade -- 0 visible .. 1 hidden
	local function faded(base: number)
		return scope:Computed(function(use)
			return 1 - (1 - base) * (1 - use(alpha))
		end)
	end

	local strokeColor = Kit.themed(scope, function(theme)
		return theme.Color.Outline
	end)

	local nameRow = scope:New "Frame" {
		Name = "NameRow",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, 28),
		LayoutOrder = 2,
		[Children] = {
			Kit.list(scope, { Direction = "X", Padding = 6, HAlign = "Center", VAlign = "Center" }),
			if hasLevel
				then scope:New "Frame" {
					Name = "Level",
					Size = UDim2.fromOffset(0, 24),
					AutomaticSize = Enum.AutomaticSize.X,
					BackgroundColor3 = WHITE,
					BackgroundTransparency = faded(0),
					LayoutOrder = 1,
					[Children] = {
						Kit.corner(scope, 6),
						Kit.padding(scope, { 0, 6 }),
						Kit.gradient(scope, {
							Rotation = 90,
							Color = scope:Computed(function(use)
								local t = use(accentTone)
								return ColorSequence.new(t.Top, t.Bottom)
							end),
						}),
						scope:New "UIStroke" {
							ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
							Thickness = 2,
							Color = scope:Computed(function(use)
								return use(accentTone).Edge
							end),
							Transparency = faded(0),
						},
						scope:New "UISizeConstraint" { MinSize = Vector2.new(24, 0) },
						Text(scope, {
							Name = "LevelText",
							Text = scope:Computed(function(use)
								return tostring(use(props.Level))
							end),
							TextSize = 17,
							Font = "Display",
							Stroke = 1.5,
							StrokeColor = scope:Computed(function(use)
								local t = use(accentTone)
								return t.Stroke or t.Edge
							end),
							Color = scope:Computed(function(use)
								return use(accentTone).Text
							end),
							Transparency = faded(0),
							Size = UDim2.fromScale(0, 1),
							AutoSize = "X",
						}),
					},
				}
				else nil,
			if props.Icon
				then Icon(scope, {
					Name = "NameIcon",
					Icon = props.Icon,
					Size = 22,
					Color = scope:Computed(function(use)
						return Color.lighten(use(accentTone).Top, 0.2)
					end),
					Stroke = 2,
					Transparency = faded(0),
					LayoutOrder = 2,
				})
				else nil,
			Text(scope, {
				Name = "Name",
				Text = props.Name or "",
				TextSize = 24,
				Font = "Heavy",
				Stroke = 2.25,
				StrokeColor = strokeColor,
				Color = props.NameColor,
				Transparency = faded(0),
				Truncate = true,
				LayoutOrder = 3,
				-- long names truncate instead of spilling off the plate
				[Children] = scope:New "UISizeConstraint" { MaxSize = Vector2.new(196, 30) },
			}),
		},
	}

	local bar = if hasHealth
		then scope:New "Frame" {
			Name = "Health",
			Size = UDim2.fromOffset(150, 11),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Track
			end),
			BackgroundTransparency = faded(0.2),
			LayoutOrder = 3,
			[Children] = {
				Kit.corner(scope, "Full"),
				scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
					Thickness = 2,
					Color = strokeColor,
					Transparency = faded(0),
				},
				scope:New "Frame" {
					Name = "Inner",
					BackgroundTransparency = 1,
					Position = UDim2.fromOffset(2, 2),
					Size = UDim2.new(1, -4, 1, -4),
					[Children] = {
						scope:New "Frame" {
							Name = "Ghost",
							Size = scope:Computed(function(use)
								return UDim2.fromScale(math.clamp(math.max(use(ghost), use(fill)), 0, 1), 1)
							end),
							BackgroundColor3 = WHITE,
							BackgroundTransparency = faded(0.15),
							ZIndex = 1,
							[Children] = Kit.corner(scope, "Full"),
						},
						scope:New "Frame" {
							Name = "Fill",
							Size = scope:Computed(function(use)
								return UDim2.fromScale(math.clamp(use(fill), 0, 1), 1)
							end),
							BackgroundColor3 = WHITE,
							BackgroundTransparency = faded(0),
							ZIndex = 2,
							[Children] = {
								Kit.corner(scope, "Full"),
								Kit.gradient(scope, {
									Rotation = 90,
									Color = scope:Computed(function(use)
										local t = use(barTone)
										return ColorSequence.new(Color.lighten(t.Top, 0.2), t.Bottom)
									end),
								}),
								scope:New "Frame" {
									Name = "Flash",
									Size = UDim2.fromScale(1, 1),
									BackgroundColor3 = WHITE,
									BackgroundTransparency = scope:Computed(function(use)
										return 1 - (1 - math.clamp(use(flash), 0, 1)) * (1 - use(alpha))
									end),
									ZIndex = 3,
									[Children] = Kit.corner(scope, "Full"),
								},
							},
						},
					},
				},
			},
		}
		else nil

	local content = scope:New "Frame" {
		Name = "Content",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.fromScale(0.5, 1),
		Size = UDim2.fromOffset(BASE_WIDTH, BASE_HEIGHT),
		[Children] = {
			scope:New "UIScale" { Scale = scale },
			Kit.list(scope, { Direction = "Y", Padding = 3, HAlign = "Center", VAlign = "Bottom" }),
			if hasTitle
				then Text(scope, {
					Name = "Title",
					Text = props.Title,
					TextSize = 17,
					Font = "Heavy",
					Stroke = 2,
					StrokeColor = strokeColor,
					Color = scope:Computed(function(use)
						return Color.lighten(use(accentTone).Top, 0.25)
					end),
					Transparency = faded(0),
					LayoutOrder = 1,
				})
				else nil,
			nameRow,
			bar,
		},
	}

	local parent = props.Parent
	if parent == nil then
		local player = Players.LocalPlayer
		parent = if player then player:FindFirstChildOfClass("PlayerGui") else nil
	end

	local offset = props.Offset or Vector3.new(0, 1.2, 0)

	return scope:New "BillboardGui" {
		Name = props.Name and `Nameplate_{peek(props.Name)}` or "Nameplate",
		Adornee = adornee,
		AlwaysOnTop = props.AlwaysOnTop or false,
		LightInfluence = 0,
		ResetOnSpawn = false,
		MaxDistance = distance,
		ExtentsOffsetWorldSpace = Vector3.new(0, 1, 0),
		StudsOffsetWorldSpace = offset,
		Size = scope:Computed(function(use)
			local s = use(scale)
			return UDim2.fromOffset(math.ceil(BASE_WIDTH * s), math.ceil(BASE_HEIGHT * s))
		end),
		SizeOffset = Vector2.new(0, 0.5),
		Enabled = scope:Computed(function(use)
			return use(adornee) ~= nil and use(alpha) < 0.99 and use(props.Visible) ~= false
		end),
		Parent = parent,
		[Children] = content,
	} :: BillboardGui
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="128">
            <Properties>
              <string name="Name">OfferCard</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	OfferCard: a limited-time offer / starter pack, the way top games sell
	them. Big art on a burst of light with the offer's ribbon, a "-60%"
	starburst, what's inside as a list of icons, the old price struck through,
	a countdown and one shining Robux button.

		scope:OfferCard {
			Title = "Starter Pack",
			Subtitle = "Everything you need for a flying start",
			Icon = "gift", Tone = "Purple", Tag = "STARTER PACK",
			Items = { { icon = "coin", amount = "25,000", label = "Coins" }, ... },   -- up to 6
			Price = 99, Was = 249, PriceIcon = "robux",
			EndsAt = endsAt, Clock = clock,   -- states (unix times); no EndsAt = no countdown
			Owned = owned,                    -- state: bought (the button says OWNED)
			OnBuy = function() end,
			OnClose = function() end,
			Size = Vector2.new(1000, 560),
		}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local Motion = require(script.Parent.Parent.FX.Motion)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Button = require(script.Parent.Button)

local Children = Fusion.Children

local WHITE = Color3.new(1, 1, 1)

local OfferCard = {}

-- "-60%" for a price that used to be `was` (nil below 5% off).
function OfferCard.discount(price: number, was: number?): string?
	if was == nil or was <= price or was <= 0 then
		return nil
	end
	local off = math.floor((1 - price / was) * 100 + 0.5)
	return if off >= 5 then `-{off}%` else nil
end

local function countdownText(seconds: number): string
	seconds = math.max(0, math.floor(seconds))
	if seconds >= 86400 then
		return `{seconds // 86400}d {(seconds % 86400) // 3600}h`
	end
	return Format.time(seconds)
end

-- A many-pointed burst from overlapping squares (no image needed).
local function starburst(scope: any, size: number, fill: any, edge: any, z: number): Frame
	local layers = {}
	for i = 0, 2 do
		table.insert(layers, scope:New "Frame" {
			Name = "Edge",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromOffset(size * 0.74 + 6, size * 0.74 + 6),
			Rotation = i * 30,
			BackgroundColor3 = edge,
			BorderSizePixel = 0,
			ZIndex = z,
		})
	end
	for i = 0, 2 do
		table.insert(layers, scope:New "Frame" {
			Name = "Point",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromOffset(size * 0.74, size * 0.74),
			Rotation = i * 30,
			BackgroundColor3 = fill,
			BorderSizePixel = 0,
			ZIndex = z + 1,
		})
	end
	return scope:New "Frame" {
		Name = "Burst",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(size, size),
		ZIndex = z,
		[Children] = layers,
	}
end

local function OfferCardComponent(scope: any, props: { [any]: any }): Frame
	local size: Vector2 = props.Size or Vector2.new(1000, 560)
	local w, h = size.X, size.Y
	local items = props.Items or {}
	local tone = Kit.tone(scope, props.Tone or "Purple")
	local owned = props.Owned or false
	local clock = props.Clock or os.time()
	local endsAt = props.EndsAt
	local price = props.Price or 0
	local off = OfferCard.discount(price, props.Was)
	local compact = w < 900
	local artW = math.floor(w * (if compact then 0.4 else 0.43))
	local pad = 24
	local rightX = artW + pad + 8
	local rightW = w - rightX

	local remaining = scope:Computed(function(use)
		local ends = use(endsAt)
		if typeof(ends) ~= "number" then
			return nil
		end
		return math.max(0, ends - (tonumber(use(clock)) or 0))
	end)
	local expired = scope:Computed(function(use)
		local left = use(remaining)
		return left ~= nil and left <= 0
	end)

	------------------------------------------------------------------------
	-- Art: the offer on a burst of light
	------------------------------------------------------------------------

	local art = scope:New "CanvasGroup" {
		Name = "Art",
		Size = UDim2.fromOffset(artW, h),
		BackgroundColor3 = WHITE,
		ZIndex = 2,
		[Children] = {
			Kit.corner(scope, "L"),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = scope:Computed(function(use)
					local t = use(tone)
					return ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.1)),
						ColorSequenceKeypoint.new(0.6, t.Bottom),
						ColorSequenceKeypoint.new(1, t.Deep),
					})
				end),
			}),
			-- the burst of light behind the offer (lite mode leaves it out)
			Kit.unlessLite(scope, function(inner)
				return Kit.rays(inner, {
					Color = inner:Computed(function(use)
						return Color.lighten(use(tone).Top, 0.5)
					end) :: any,
					Transparency = 0.45,
					Size = UDim2.fromOffset(artW * 1.9, artW * 1.9),
					Position = UDim2.fromScale(0.5, 0.52),
					ZIndex = 2,
				})
			end),
			Icon(scope, {
				Name = "Hero",
				Icon = props.Icon or "gift",
				Size = math.floor(math.min(artW * 0.62, h * 0.46)),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.52),
				Rotation = -8,
				Color = scope:Computed(function(use)
					return Color.lighten(use(tone).Top, 0.45)
				end),
				Gradient = true,
				Stroke = 5,
				StrokeColor = scope:Computed(function(use)
					local t = use(tone)
					return t.Stroke or t.Edge
				end),
				Shadow = true,
				ZIndex = 4,
			}),
			-- the value line along the bottom of the art
			Text(scope, {
				Name = "Value",
				Text = if props.Was and props.Was > price then `{math.floor(props.Was / math.max(1, price) * 10 + 0.5) / 10}x THE VALUE` else "LIMITED OFFER",
				TextSize = 26,
				Font = "Display",
				Stroke = 3,
				StrokeColor = scope:Computed(function(use)
					local t = use(tone)
					return t.Stroke or t.Edge
				end),
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(0.5, 0, 1, -22),
				ZIndex = 5,
			}),
		},
	}
	local artStroke = scope:New "Frame" {
		Name = "ArtEdge",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(artW, h),
		ZIndex = 6,
		[Children] = {
			Kit.corner(scope, "L"),
			Kit.stroke(scope, {
				Thickness = 3,
				Color = scope:Computed(function(use)
					return use(tone).Edge
				end),
			}),
		},
	}

	local ribbon = if props.Tag
		then scope:New "Frame" {
			Name = "Ribbon",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0, artW / 2, 0, 30),
			Size = UDim2.fromOffset(0, 40),
			AutomaticSize = Enum.AutomaticSize.X,
			BackgroundColor3 = WHITE,
			ZIndex = 8,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.padding(scope, { 0, 18 }),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = Kit.themed(scope, function(theme)
						return ColorSequence.new(theme.Tone.Yellow.Top, theme.Tone.Yellow.Bottom)
					end),
				}),
				Kit.stroke(scope, {
					Thickness = 3,
					Color = Kit.themed(scope, function(theme)
						return theme.Tone.Yellow.Edge
					end),
				}),
				Text(scope, {
					Text = props.Tag,
					TextSize = 22,
					Font = "Display",
					Stroke = 2,
					StrokeColor = Kit.themed(scope, function(theme)
						return theme.Tone.Yellow.Stroke or theme.Tone.Yellow.Edge
					end),
					Size = UDim2.fromScale(0, 1),
					AutoSize = "X",
					ZIndex = 9,
				}),
			},
		}
		else nil

	-- "-60%" starburst over the art's corner
	local burstSize = 118
	local burst = if off
		then scope:New "Frame" {
			Name = "Discount",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromOffset(artW - 18, h - 70),
			Size = UDim2.fromOffset(burstSize, burstSize),
			Rotation = -12,
			ZIndex = 9,
			[Children] = {
				starburst(scope, burstSize, Kit.themed(scope, function(theme)
					return theme.Tone.Red.Bottom
				end), Kit.themed(scope, function(theme)
					return theme.Color.Light
				end), 9),
				Text(scope, {
					Text = off,
					TextSize = 34,
					Font = "Display",
					Stroke = 3,
					StrokeColor = Kit.themed(scope, function(theme)
						return theme.Tone.Red.Edge
					end),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					ZIndex = 12,
				}),
			},
		}
		else nil
	if burst then
		table.insert(scope, Motion.pulse(burst, 0.06, 1.2))
	end

	------------------------------------------------------------------------
	-- Right: title, contents, price
	------------------------------------------------------------------------

	local rows = {}
	local itemColumns = if #items > 3 and rightW >= 440 then 2 else 1
	-- the contents fill the room between the heading and the price row
	local contentsTop = if compact then 124 else 142
	local itemRowCount = math.max(1, math.ceil(#items / itemColumns))
	local room = h - contentsTop - 160
	local itemH = math.clamp(math.floor((room - (itemRowCount - 1) * 12) / itemRowCount), 60, 96)
	local itemW = math.floor((rightW - (itemColumns - 1) * 14) / itemColumns)
	for index, item in items do
		table.insert(rows, scope:New "Frame" {
			Name = `Item{index}`,
			LayoutOrder = index,
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = Kit.themed(scope, function(theme)
				return math.clamp(theme.Alpha.Surface, 0, 1)
			end),
			ZIndex = 3,
			[Children] = {
				Kit.corner(scope, "M"),
				scope:New "Frame" {
					Name = "Tile",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 8, 0.5, 0),
					Size = UDim2.fromOffset(itemH - 16, itemH - 16),
					BackgroundColor3 = WHITE,
					ZIndex = 4,
					[Children] = {
						Kit.corner(scope, "M"),
						Kit.gradient(scope, {
							Rotation = 90,
							Color = scope:Computed(function(use)
								local t = use(tone)
								return ColorSequence.new(Color.lighten(t.Top, 0.05), t.Bottom)
							end),
						}),
						Kit.stroke(scope, {
							Thickness = 2,
							Color = scope:Computed(function(use)
								return use(tone).Edge
							end),
						}),
						Icon(scope, {
							Icon = item.icon or "gift",
							Size = math.floor((itemH - 16) * 0.66),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = WHITE,
							Gradient = true,
							Shadow = true,
							StrokeColor = scope:Computed(function(use)
								local t = use(tone)
								return t.Stroke or t.Edge
							end),
							ZIndex = 5,
						}),
					},
				},
				Text(scope, {
					Name = "Amount",
					Text = if item.amount then `x{item.amount}` else (item.label or ""),
					Case = "Normal",
					TextSize = if itemH >= 76 then 28 else 24,
					Font = "Display",
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, if item.label and item.amount then 1 else 0.5),
					Position = UDim2.new(0, itemH, 0.5, if item.label and item.amount then 4 else 0),
					Size = UDim2.new(1, -itemH - 8, 0, 30),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = if itemH >= 76 then 28 else 24,
					ZIndex = 4,
				}),
				if item.label and item.amount
					then Text(scope, {
						Name = "Label",
						Text = item.label,
						TextSize = 18,
						Font = "Body",
						XAlign = "Left",
						Color = Kit.themed(scope, function(theme)
							return theme.Color.TextMuted
						end),
						Stroke = false,
						Position = UDim2.new(0, itemH, 0.5, 4),
						Size = UDim2.new(1, -itemH - 8, 0, 22),
						Truncate = true,
						ZIndex = 4,
					})
					else nil,
			},
		})
	end
	local itemRows = math.ceil(#items / itemColumns)
	local listH = itemRows * itemH + math.max(0, itemRows - 1) * 12

	local buyW = if compact then 220 else 260
	local buy = Button(scope, {
		Name = "Buy",
		Text = scope:Computed(function(use)
			if use(owned) == true then
				return "OWNED"
			elseif use(expired) then
				return "ENDED"
			end
			return Format.comma(price)
		end),
		Icon = scope:Computed(function(use)
			return if use(owned) == true then "check" elseif use(expired) then "clock" else (props.PriceIcon or "robux")
		end),
		Tone = "Green",
		Primary = true,
		Shine = true,
		Pulse = scope:Computed(function(use)
			return use(owned) ~= true and not use(expired)
		end),
		Disabled = scope:Computed(function(use)
			return use(owned) == true or use(expired)
		end),
		TextSize = 34,
		Size = UDim2.fromOffset(buyW, 78),
		AnchorPoint = Vector2.new(1, 1),
		Position = UDim2.new(1, 0, 1, 0),
		ZIndex = 6,
		OnClick = function()
			if props.OnBuy then
				props.OnBuy()
			end
		end,
	})

	local was = if props.Was and props.Was > price
		then Text(scope, {
			Name = "Was",
			Text = `<s>{Format.comma(props.Was)}</s>`,
			RichText = true,
			Case = "Normal",
			TextSize = 30,
			Font = "Display",
			Color = Kit.themed(scope, function(theme)
				return theme.Color.TextMuted
			end),
			Stroke = 2,
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -buyW - 18, 1, -39),
			ZIndex = 6,
		})
		else nil

	local timer = scope:New "Frame" {
		Name = "Timer",
		-- right-aligned over the buy button, clear of the discount burst
		AnchorPoint = Vector2.new(1, 1),
		Position = UDim2.new(1, 0, 1, -94),
		Size = UDim2.fromOffset(0, 40),
		AutomaticSize = Enum.AutomaticSize.X,
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Tone.Red.Bottom
		end),
		Visible = scope:Computed(function(use)
			return use(remaining) ~= nil and use(owned) ~= true
		end),
		ZIndex = 5,
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.padding(scope, { 0, 14 }),
			Kit.list(scope, { Direction = "X", Padding = 8, VAlign = "Center" }),
			Kit.stroke(scope, {
				Thickness = 2,
				Color = Kit.themed(scope, function(theme)
					return theme.Tone.Red.Edge
				end),
			}),
			Icon(scope, { Icon = "clock", Size = 24, Color = WHITE, LayoutOrder = 1, ZIndex = 6 }),
			Text(scope, {
				Text = scope:Computed(function(use)
					local left = use(remaining)
					if left == nil then
						return ""
					end
					return if left <= 0 then "Offer ended" else `Ends in {countdownText(left)}`
				end),
				Case = "Normal",
				TextSize = 22,
				Font = "Display",
				Stroke = 2,
				StrokeColor = Kit.themed(scope, function(theme)
					return theme.Tone.Red.Edge
				end),
				LayoutOrder = 2,
				ZIndex = 6,
			}),
		},
	}

	local right = scope:New "Frame" {
		Name = "Details",
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(rightX, 0),
		Size = UDim2.new(0, rightW, 1, 0),
		ZIndex = 3,
		[Children] = {
			Text(scope, {
				Name = "Title",
				Text = props.Title or "Special Offer",
				TextSize = if compact then 44 else 54,
				Font = "Title",
				XAlign = "Left",
				Gradient = scope:Computed(function(use)
					local t = use(tone)
					return ColorSequence.new(Color.lighten(t.Top, 0.55), Color.lighten(t.Top, 0.1))
				end),
				Stroke = 4,
				StrokeColor = scope:Computed(function(use)
					return use(tone).Deep
				end),
				Position = UDim2.fromOffset(0, 6),
				Size = UDim2.new(1, -60, 0, if compact then 50 else 60),
				Scaled = true,
				Wrapped = false,
				MaxTextSize = if compact then 44 else 54,
				ZIndex = 4,
			}),
			if props.Subtitle
				then Text(scope, {
					Name = "Subtitle",
					Text = props.Subtitle,
					TextSize = 22,
					Font = "Body",
					XAlign = "Left",
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					Position = UDim2.fromOffset(0, if compact then 60 else 72),
					Size = UDim2.new(1, -20, 0, 26),
					Truncate = true,
					ZIndex = 4,
				})
				else nil,
			Text(scope, {
				Name = "Includes",
				Text = "WHAT'S INSIDE",
				TextSize = 18,
				Font = "Heavy",
				XAlign = "Left",
				Color = Kit.themed(scope, function(theme)
					return theme.Color.Accent
				end),
				Position = UDim2.fromOffset(0, if compact then 98 else 114),
				ZIndex = 4,
			}),
			scope:New "Frame" {
				Name = "Contents",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(0, contentsTop),
				Size = UDim2.new(1, 0, 0, listH),
				ZIndex = 3,
				[Children] = {
					scope:New "UIGridLayout" {
						CellSize = UDim2.fromOffset(itemW, itemH),
						CellPadding = UDim2.fromOffset(14, 12),
						FillDirection = Enum.FillDirection.Horizontal,
						FillDirectionMaxCells = itemColumns,
						SortOrder = Enum.SortOrder.LayoutOrder,
					},
					rows,
				},
			},
			timer,
			was,
			buy,
		},
	}

	-- its own close button, unless it sits in a Window that has one
	local close = if props.OnClose
		then Button(scope, {
			Name = "Close",
			Icon = "close",
			Tone = "Red",
			Size = UDim2.fromOffset(52, 52),
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(1, -4, 0, 4),
			ZIndex = 20,
			OnClick = props.OnClose,
		})
		else nil

	local root = Kit.root(props, { Name = "OfferCard" })
	root.Size = UDim2.fromOffset(w, h)
	root.BackgroundTransparency = 1
	root[Children] = { art, artStroke, ribbon, burst, right, close, props[Children] }
	return scope:New "Frame"(root) :: Frame
end

OfferCard.new = OfferCardComponent
setmetatable(OfferCard, {
	__call = function(_, scope: any, props: { [any]: any })
		return OfferCardComponent(scope, props)
	end,
})

return OfferCard
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="129">
            <Properties>
              <string name="Name">Panel</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Panel: a themed, layered background box.

	scope:Panel {
		Style = "Glass",        -- default: theme Style.PanelStyle
		                        --   "Glass"    dark see-through with a light rim (HUD, Rivals)
		                        --   "Solid"    opaque, subtle gradient, tinted outline
		                        --   "Light"    white card
		                        --   "Tone"     gradient from Tone
		                        --   "Gradient" rich 3-stop diagonal gradient from Tone
		                        --   "Outlined" panel colour with a thick coloured border (Adopt Me)
		                        --   "Ribbon"   Outlined + a title ribbon across the top edge
		Tone = "Yellow",        -- for Tone / Gradient, and the border / ribbon colour
		Title = "Upgrades",     -- ribbon (Ribbon style) or a heading in the top-left
		TitleIcon = "upgrade",  -- optional icon next to the title
		Decoration = "Dots",    -- "None" | "Sparkles" | "Stripes" | "Dots" | "Grain" (default: theme)
		Radius = "M",           -- "S" | "M" | "L" | number
		Stroke = true,          -- outline (true / false / number)
		Shadow = true,          -- drop shadow (soft or hard, from the theme)
		Padding = 12,
		Layout = "Column",      -- optional: "Row" | "Column" arranges children
		Gap = 8,
		[Children] = { ... },
	}

	Children go in an inner "Content" frame, so padding never shifts the
	surface layers (highlight, rim, decoration).
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children

local WHITE = Color3.new(1, 1, 1)

type Scope = Fusion.Scope<any>

local function paddingTable(padding: any): { number }
	if padding == nil then
		return { 0, 0, 0, 0 }
	elseif typeof(padding) == "number" then
		return { padding, padding, padding, padding }
	end
	local t = padding[1] or 0
	local r = padding[2] or t
	local b = padding[3] or t
	local l = padding[4] or r
	return { t, r, b, l }
end

-- The title ribbon that sits across the top edge of Ribbon panels and windows.
local function Ribbon(scope: Scope, props: { [any]: any }, look: any)
	local height = props.RibbonHeight or 60
	local tone = scope:Computed(function(use)
		local theme = use(Theme.current)
		local given = use(props.Tone)
		if given ~= nil then
			return Theme.tone(theme, given)
		end
		return Theme.makeTone(use(look).stroke)
	end)
	local function tail(side: number)
		return scope:New "Frame" {
			Name = if side < 0 then "TailLeft" else "TailRight",
			AnchorPoint = Vector2.new(if side < 0 then 0 else 1, 0),
			Position = UDim2.new(if side < 0 then 0 else 1, side * 26, 0, 14),
			Size = UDim2.fromOffset(56, height - 8),
			BackgroundColor3 = scope:Computed(function(use)
				return use(tone).Edge
			end),
			ZIndex = 1,
			[Children] = {
				Kit.corner(scope, "S"),
				Kit.stroke(scope, {
					Color = scope:Computed(function(use)
						return use(tone).Deep
					end),
					Thickness = Kit.themed(scope, function(theme)
						return theme.Stroke.UI
					end),
				}),
			},
		}
	end
	return scope:New "Frame" {
		Name = "Ribbon",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.new(0.5, 0, 0, 0),
		Size = UDim2.fromOffset(0, height),
		AutomaticSize = Enum.AutomaticSize.X,
		BackgroundTransparency = 1,
		ZIndex = 6,
		[Children] = {
			tail(-1),
			tail(1),
			scope:New "Frame" {
				Name = "Band",
				Size = UDim2.fromOffset(0, height),
				AutomaticSize = Enum.AutomaticSize.X,
				BackgroundColor3 = WHITE,
				ZIndex = 2,
				[Children] = {
					Kit.corner(scope, "S"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = scope:Computed(function(use)
							return Kit.faceColors(use(tone), 0)
						end),
					}),
					Kit.stroke(scope, {
						Color = scope:Computed(function(use)
							local t = use(tone)
							return t.Deep:Lerp(t.Edge, 0.4)
						end),
						Thickness = Kit.themed(scope, function(theme)
							return theme.Stroke.UI
						end),
					}),
					scope:New "UISizeConstraint" { MinSize = Vector2.new(props.RibbonWidth or 240, height) },
					scope:New "Frame" {
						Name = "Gloss",
						BackgroundColor3 = WHITE,
						BackgroundTransparency = 0.55,
						Position = UDim2.fromOffset(4, 3),
						Size = UDim2.new(1, -8, 0.45, 0),
						ZIndex = 2,
						[Children] = {
							Kit.corner(scope, "S"),
							Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0.2, 1) }),
						},
					},
					scope:New "Frame" {
						Name = "Label",
						BackgroundTransparency = 1,
						Size = UDim2.fromScale(0, 1),
						AutomaticSize = Enum.AutomaticSize.X,
						ZIndex = 3,
						[Children] = {
							Kit.padding(scope, { 0, if props.RibbonWidth and props.RibbonWidth < 200 then 18 else 40 }),
							Kit.list(scope, { Direction = "X", Padding = 10, HAlign = "Center", VAlign = "Center" }),
							if props.TitleIcon
								then Icon(scope, {
									Icon = props.TitleIcon,
									Size = math.floor(height * 0.62),
									Color = WHITE,
									StrokeColor = scope:Computed(function(use)
										return use(tone).Stroke or use(tone).Deep
									end),
									LayoutOrder = 1,
									ZIndex = 4,
								})
								else nil,
							Text(scope, {
								Text = props.Title,
								TextSize = math.floor(height * 0.56),
								Font = "Title",
								Color = scope:Computed(function(use)
									return use(tone).Text
								end),
								StrokeColor = scope:Computed(function(use)
									local t = use(tone)
									return t.Stroke or t.Deep
								end),
								Stroke = scope:Computed(function(use)
									return if use(tone).Stroke then use(Theme.current).Stroke.Text else false
								end),
								LayoutOrder = 2,
								ZIndex = 4,
							}),
						},
					},
				},
			},
		},
	}
end

local function Heading(scope: Scope, props: { [any]: any }, look: any)
	return scope:New "Frame" {
		Name = "Heading",
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(24, 16),
		Size = UDim2.new(1, -48, 0, 40),
		ZIndex = 3,
		[Children] = {
			Kit.list(scope, { Direction = "X", Padding = 10, VAlign = "Center" }),
			if props.TitleIcon
				then Icon(scope, {
					Icon = props.TitleIcon,
					Size = 36,
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Accent
					end),
					Gradient = true,
					LayoutOrder = 1,
					ZIndex = 4,
				})
				else nil,
			Text(scope, {
				Text = props.Title,
				TextSize = "H3",
				Font = "Title",
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					return if use(look).light then theme.Color.PanelText else theme.Color.Text
				end),
				Stroke = scope:Computed(function(use)
					return if use(look).light then false else nil
				end),
				LayoutOrder = 2,
				ZIndex = 4,
			}),
		},
	}
end

return function(scope: Scope, props: { [any]: any }): Frame
	-- HeaderOnly: just the title ribbon / heading on a transparent frame, for
	-- windows that draw their own body (used by Window).
	if props.HeaderOnly then
		local headerLook = scope:Computed(function(use)
			local theme = use(Theme.current)
			return Kit.surfaceLook(theme, "Ribbon", use(props.Tone))
		end)
		local root = Kit.root(props, { Name = "PanelHeader" })
		root.Size = props.Size or UDim2.fromScale(1, 0)
		root.BackgroundTransparency = 1
		root[Children] = Ribbon(scope, props, headerLook)
		return scope:New "Frame" (root) :: Frame
	end

	local surfaceProps, layers = Kit.surface(scope, {
		Style = props.Style,
		Tone = props.Tone,
		Radius = props.Radius or "M",
		Stroke = props.Stroke,
		StrokeColor = props.StrokeColor,
		Color = props.Color,
		Transparency = props.Transparency,
		Gradient = props.Gradient,
		GradientRotation = props.GradientRotation,
		Highlight = props.Highlight,
		Decoration = Kit.style(scope, "Decoration", props.Decoration),
	})
	local style = surfaceProps.Style
	local look = surfaceProps.Look

	local hasTitle = props.Title ~= nil
	local isRibbon = scope:Computed(function(use)
		return use(style) == "Ribbon"
	end)

	local pad = paddingTable(props.Padding)
	local autoSize = props.AutoSize

	local content = scope:New "Frame" {
		Name = "Content",
		BackgroundTransparency = 1,
		Size = if autoSize
			then UDim2.new(if string.find(autoSize, "X") then 0 else 1, 0, if string.find(autoSize, "Y") then 0 else 1, 0)
			else UDim2.fromScale(1, 1),
		AutomaticSize = if autoSize then Enum.AutomaticSize[autoSize] else Enum.AutomaticSize.None,
		ZIndex = 2,
		[Children] = {
			if props.Padding or hasTitle
				then scope:New "UIPadding" {
					PaddingTop = scope:Computed(function(use)
						local extra = if hasTitle then (if use(isRibbon) then 34 else 56) else 0
						return UDim.new(0, pad[1] + extra)
					end),
					PaddingRight = UDim.new(0, pad[2]),
					PaddingBottom = UDim.new(0, pad[3]),
					PaddingLeft = UDim.new(0, pad[4]),
				}
				else nil,
			if props.Layout
				then Kit.list(scope, {
					Direction = if props.Layout == "Row" then "X" else "Y",
					Padding = props.Gap or 8,
					HAlign = props.Align or (if props.Layout == "Row" then "Left" else "Center"),
					VAlign = props.VAlign or (if props.Layout == "Row" then "Center" else "Top"),
				})
				else nil,
			props[Children],
		},
	}

	local header = if hasTitle
		then scope:Computed(function(use, inner)
			if use(isRibbon) then
				return Ribbon(inner, props, look)
			end
			return Heading(inner, props, look)
		end)
		else nil

	local root = Kit.root(props, { Name = "Panel" })
	root.Size = props.Size or UDim2.fromOffset(300, 200)

	-- Without a shadow the panel itself is the surface, so AutomaticSize works.
	if not props.Shadow then
		root.AutomaticSize = if autoSize then Enum.AutomaticSize[autoSize] else Enum.AutomaticSize.None
		root.BackgroundColor3 = surfaceProps.BackgroundColor3
		root.BackgroundTransparency = surfaceProps.BackgroundTransparency
		root.ClipsDescendants = props.Clip or false
		root[Children] = { layers, content, header }
		return scope:New "Frame" (root) :: Frame
	end

	root.BackgroundTransparency = 1
	root[Children] = {
		Kit.elevation(scope, { Radius = props.Radius or "M", Spread = 20, Offset = Vector2.new(0, 8) }),
		scope:New "Frame" {
			Name = "Body",
			Size = UDim2.fromScale(1, 1),
			BackgroundColor3 = surfaceProps.BackgroundColor3,
			BackgroundTransparency = surfaceProps.BackgroundTransparency,
			ClipsDescendants = props.Clip or false,
			ZIndex = 1,
			[Children] = { layers, content },
		},
		header,
	}
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="130">
            <Properties>
              <string name="Name">PartyPanel</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	PartyPanel: a party / squad list. Member rows with an avatar, name,
	leader crown and a ready tick; empty slots become big "Invite" rows.

	local members = scope:Value({
		{ name = "Builderman", ready = true, leader = true },
		{ name = "Noob123", ready = false },
	})
	scope:PartyPanel {
		Members = members,                 -- state (or table) of { name, ready?, leader?, icon? }
		Slots = 4,
		Me = "Builderman",                 -- optional: marks your row, never shows a kick button on it
		OnInvite = function(slot) end,     -- empty slot clicked
		CanInvite = true,                  -- bool or state: false when invites can't be sent
		                                   -- (SocialService:CanSendGameInviteAsync); empty
		                                   -- slots then just read "Open slot"
		OnKick = function(member, index) end,   -- optional: adds kick buttons
		CanKick = true,                    -- bool or state (e.g. only the leader may kick)
		OnLeave = function() end,          -- optional: "Leave" button in the footer
		Title = "Party",
		Width = 460,
	}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Button = require(script.Parent.Button)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)
local HEADER = 68
local ROW = 76
local GAP = 10
local PAD = 16
local FOOTER = 76

local function field(item: { [string]: any }?, key: string): any
	if item == nil then
		return nil
	end
	local value = item[key]
	if value == nil then
		value = item[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

local function elevation(scope: Scope): any
	local kit = Kit :: any
	if kit.elevation then
		return kit.elevation(scope, { Radius = "L", Spread = 20, Offset = Vector2.new(0, 8) })
	end
	return Kit.shadow(scope, { Spread = 20, Offset = Vector2.new(0, 8) })
end

local function slotRow(scope: Scope, props: { [any]: any }, index: number, width: number): Frame
	local members = props.Members or {}
	local member = scope:Computed(function(use)
		local list = use(members) or {}
		return list[index] or false
	end)
	local filled = scope:Computed(function(use)
		return use(member) ~= false
	end)
	local empty = scope:Computed(function(use)
		return use(member) == false
	end)
	local name = scope:Computed(function(use)
		local m = use(member)
		return if m then tostring(field(m, "name") or "Player") else ""
	end)
	local ready = scope:Computed(function(use)
		local m = use(member)
		return m ~= false and field(m, "ready") == true
	end)
	local leader = scope:Computed(function(use)
		local m = use(member)
		return m ~= false and field(m, "leader") == true
	end)
	local isMe = scope:Computed(function(use)
		local me = use(props.Me)
		return me ~= nil and use(name) == me
	end)
	local canKick = scope:Computed(function(use)
		if props.OnKick == nil or use(isMe) then
			return false
		end
		local allowed = use(props.CanKick)
		return use(filled) and allowed ~= false
	end)

	-- pop the avatar when someone joins, the tick when they ready up
	local joinGoal = scope:Value(1)
	local joinPop = scope:Spring(joinGoal, 26, 0.4)
	Kit.watch(scope, name, function(now)
		if now ~= "" then
			joinPop:setPosition(0.6)
			joinPop:addVelocity(4)
		end
	end)
	local tickGoal = scope:Value(1)
	local tickPop = scope:Spring(tickGoal, 30, 0.35)
	Kit.watch(scope, ready, function(now)
		if now then
			tickPop:setPosition(0.5)
			tickPop:addVelocity(6)
			Sound.play("Click", { Pitch = 1.3, Volume = 0.6 })
		end
	end)

	local canInvite = scope:Computed(function(use)
		return props.OnInvite ~= nil and use(props.CanInvite) ~= false
	end)
	local hovering = scope:Value(false)
	local inviteScale = Kit.spring(scope, scope:Computed(function(use)
		return if use(hovering) and use(canInvite) then 1.02 else 1
	end), 1.1)

	local memberRow = scope:New "Frame" {
		Name = "Member",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.SurfaceLight
		end),
		BackgroundTransparency = 0.15,
		Visible = filled,
		ZIndex = 3,
		[Children] = {
			Kit.corner(scope, "M"),
			Kit.stroke(scope, {
				Thickness = 2,
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					if use(ready) then
						return theme.Color.Good
					elseif use(isMe) then
						return theme.Color.Accent
					end
					return theme.Color.Outline
				end),
				Transparency = scope:Computed(function(use)
					return if use(ready) or use(isMe) then 0.15 else 0.4
				end),
			}),
			Kit.gradient(scope, {
				Rotation = 90,
				Transparency = NumberSequence.new(0, 0.25),
			}),
			scope:New "Frame" {
				Name = "Avatar",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 12, 0.5, 0),
				Size = UDim2.fromOffset(52, 52),
				BackgroundColor3 = WHITE,
				ZIndex = 4,
				[Children] = {
					scope:New "UIScale" { Scale = joinPop },
					Kit.corner(scope, "Full"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = Kit.themed(scope, function(theme)
							local t = theme.Tone.Blue
							return ColorSequence.new(Color.lighten(t.Top, 0.2), t.Bottom)
						end),
					}),
					Kit.stroke(scope, {
						Thickness = 2.5,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Light
						end),
					}),
					Icon(scope, {
						Icon = scope:Computed(function(use)
							local m = use(member)
							return (m and field(m, "icon")) or "person"
						end),
						Size = 34,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.52),
						Color = WHITE,
						StrokeColor = Kit.themed(scope, function(theme)
							return theme.Tone.Blue.Edge
						end),
						Stroke = 2,
						ZIndex = 5,
					}),
					scope:New "Frame" {
						Name = "Crown",
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.new(1, -4, 0, 4),
						Size = UDim2.fromOffset(26, 26),
						BackgroundColor3 = WHITE,
						Visible = leader,
						ZIndex = 6,
						[Children] = {
							Kit.corner(scope, "Full"),
							Kit.gradient(scope, {
								Rotation = 90,
								Color = Kit.themed(scope, function(theme)
									local t = theme.Tone.Yellow
									return ColorSequence.new(t.Top, t.Bottom)
								end),
							}),
							Kit.stroke(scope, {
								Thickness = 2,
								Color = Kit.themed(scope, function(theme)
									return theme.Color.Light
								end),
							}),
							Icon(scope, {
								Icon = "crown",
								Size = 16,
								AnchorPoint = Vector2.new(0.5, 0.5),
								Position = UDim2.fromScale(0.5, 0.5),
								Color = Kit.themed(scope, function(theme)
									return theme.Tone.Yellow.Text
								end),
								StrokeColor = Kit.themed(scope, function(theme)
									return theme.Tone.Yellow.Edge
								end),
								Stroke = 1,
								ZIndex = 7,
							}),
						},
					},
				},
			},
			Text(scope, {
				Name = "PlayerName",
				Text = name,
				TextSize = 24,
				Font = "Heavy",
				XAlign = "Left",
				Position = UDim2.fromOffset(78, 12),
				Size = UDim2.new(1, -210, 0, 28),
				Truncate = true,
				ZIndex = 4,
			}),
			Text(scope, {
				Name = "Status",
				Text = scope:Computed(function(use)
					local parts = {}
					if use(isMe) then
						table.insert(parts, "You")
					end
					if use(leader) then
						table.insert(parts, "Leader")
					end
					table.insert(parts, if use(ready) then "Ready" else "Not ready")
					return table.concat(parts, "  ·  ")
				end),
				TextSize = 17,
				Font = "Body",
				Stroke = false,
				XAlign = "Left",
				Position = UDim2.fromOffset(78, 42),
				Size = UDim2.new(1, -210, 0, 20),
				Truncate = true,
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					return if use(ready) then theme.Color.Good else theme.Color.TextMuted
				end),
				ZIndex = 4,
			}),
			scope:New "Frame" {
				Name = "Right",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -14, 0.5, 0),
				Size = UDim2.fromOffset(0, 46),
				AutomaticSize = Enum.AutomaticSize.X,
				ZIndex = 4,
				[Children] = {
					Kit.list(scope, { Direction = "X", Padding = 10, HAlign = "Right", VAlign = "Center" }),
					scope:New "Frame" {
						Name = "Ready",
						LayoutOrder = 1,
						Size = UDim2.fromOffset(42, 42),
						BackgroundColor3 = WHITE,
						ZIndex = 5,
						[Children] = {
							scope:New "UIScale" { Scale = tickPop },
							Kit.corner(scope, "Full"),
							Kit.gradient(scope, {
								Rotation = 90,
								Color = scope:Computed(function(use)
									local theme = use(Theme.current)
									local t = if use(ready) then theme.Tone.Green else theme.Tone.Dark
									return ColorSequence.new(t.Top, t.Bottom)
								end),
							}),
							Kit.stroke(scope, {
								Thickness = 2.5,
								Color = scope:Computed(function(use)
									local theme = use(Theme.current)
									return if use(ready) then theme.Tone.Green.Edge else theme.Color.Outline
								end),
							}),
							Icon(scope, {
								Icon = scope:Computed(function(use)
									return if use(ready) then "check" else "timer"
								end),
								Size = scope:Computed(function(use)
									return if use(ready) then 28 else 22
								end),
								AnchorPoint = Vector2.new(0.5, 0.5),
								Position = UDim2.fromScale(0.5, 0.5),
								Color = scope:Computed(function(use)
									local theme = use(Theme.current)
									return if use(ready) then theme.Tone.Green.Text else theme.Color.TextMuted
								end),
								StrokeColor = Kit.themed(scope, function(theme)
									return theme.Tone.Green.Edge
								end),
								Stroke = scope:Computed(function(use)
									return if use(ready) then 1.5 else false
								end),
								ZIndex = 6,
							}),
						},
					},
					if props.OnKick
						then Button(scope, {
							Name = "Kick",
							Icon = "close",
							Tone = "Red",
							Size = UDim2.fromOffset(46, 46),
							IconSize = 24,
							LayoutOrder = 2,
							Visible = canKick,
							OnClick = function()
								local m = peek(member)
								if m and props.OnKick then
									props.OnKick(m, index)
								end
							end,
						})
						else nil,
				},
			},
		},
	}

	local inviteRow = scope:New "TextButton" {
		Name = "Invite",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Surface
		end),
		BackgroundTransparency = scope:Computed(function(use)
			return if use(hovering) and use(canInvite) then 0.5 else 0.72
		end),
		Text = "",
		AutoButtonColor = false,
		Selectable = canInvite,
		Active = true,
		Visible = empty,
		ZIndex = 3,
		[OnEvent "MouseEnter"] = function()
			hovering:set(true)
			Sound.play("Hover", { Volume = 0.35 })
		end,
		[OnEvent "MouseLeave"] = function()
			hovering:set(false)
		end,
		[OnEvent "SelectionGained"] = function()
			hovering:set(true)
		end,
		[OnEvent "SelectionLost"] = function()
			hovering:set(false)
		end,
		[OnEvent "Activated"] = function()
			if props.OnInvite and peek(canInvite) then
				Sound.play("Click")
				props.OnInvite(index)
			end
		end,
		[Children] = {
			scope:New "UIScale" { Scale = inviteScale },
			Kit.corner(scope, "M"),
			Kit.stroke(scope, {
				Thickness = 2,
				Color = Kit.themed(scope, function(theme)
					return theme.Color.Light
				end),
				Transparency = scope:Computed(function(use)
					return if use(hovering) then 0.35 else 0.72
				end),
			}),
			scope:New "Frame" {
				Name = "Plus",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 12, 0.5, 0),
				Size = UDim2.fromOffset(52, 52),
				BackgroundColor3 = WHITE,
				Visible = canInvite,
				ZIndex = 4,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = scope:Computed(function(use)
							local theme = use(Theme.current)
							local t = theme.Tone.Green
							local l = if use(hovering) then 0.12 else 0
							return ColorSequence.new(Color.lighten(t.Top, l), Color.lighten(t.Bottom, l))
						end),
					}),
					Kit.stroke(scope, {
						Thickness = 2.5,
						Color = Kit.themed(scope, function(theme)
							return theme.Tone.Green.Edge
						end),
					}),
					Icon(scope, {
						Icon = "plus",
						Size = 30,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = Kit.themed(scope, function(theme)
							return theme.Tone.Green.Text
						end),
						StrokeColor = Kit.themed(scope, function(theme)
							local t = theme.Tone.Green
							return t.Stroke or t.Edge
						end),
						Stroke = 2,
						ZIndex = 5,
					}),
				},
			},
			Text(scope, {
				Text = scope:Computed(function(use)
					return if use(canInvite) then "Invite a friend" else "Open slot"
				end),
				TextSize = 23,
				Font = "Heavy",
				XAlign = "Left",
				Position = scope:Computed(function(use)
					return UDim2.fromOffset(if use(canInvite) then 78 else 20, 12)
				end),
				Size = UDim2.new(1, -96, 0, 28),
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					return if use(hovering) then theme.Color.Text else theme.Color.TextMuted
				end),
				Stroke = false,
				ZIndex = 4,
			}),
			Text(scope, {
				Text = `Empty slot {index}`,
				TextSize = 17,
				Font = "Body",
				XAlign = "Left",
				Position = scope:Computed(function(use)
					return UDim2.fromOffset(if use(canInvite) then 78 else 20, 42)
				end),
				Size = UDim2.new(1, -96, 0, 20),
				Stroke = false,
				Color = Kit.themed(scope, function(theme)
					return theme.Color.TextMuted
				end),
				Transparency = 0.3,
				ZIndex = 4,
			}),
		},
	}

	return scope:New "Frame" {
		Name = `Slot{index}`,
		LayoutOrder = index,
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(width, ROW),
		ZIndex = 3,
		[Children] = { memberRow, inviteRow },
	}
end

return function(scope: Scope, props: { [any]: any }): Frame
	local slots = math.max(1, peek(props.Slots) or 4)
	local width = props.Width or 460
	local inner = width - PAD * 2
	local members = props.Members or {}
	local hasFooter = props.OnLeave ~= nil

	local count = scope:Computed(function(use)
		return math.min(#(use(members) or {}), slots)
	end)
	local readyCount = scope:Computed(function(use)
		local n = 0
		for i, m in use(members) or {} do
			if i <= slots and field(m, "ready") == true then
				n += 1
			end
		end
		return n
	end)

	local rows = {}
	for index = 1, slots do
		table.insert(rows, slotRow(scope, props, index, inner))
	end

	local listHeight = slots * ROW + (slots - 1) * GAP
	local height = HEADER + listHeight + PAD + (if hasFooter then FOOTER else PAD)

	local root = Kit.root(props, { Name = "PartyPanel" })
	root.Size = props.Size or UDim2.fromOffset(width, height)
	root.BackgroundTransparency = 1
	root[Children] = {
		elevation(scope),
		scope:New "Frame" {
			Name = "Body",
			Size = UDim2.fromScale(1, 1),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = Kit.themed(scope, function(theme)
				return math.min(theme.Alpha.Surface, 0.2)
			end),
			ZIndex = 1,
			[Children] = {
				Kit.corner(scope, "L"),
				Kit.stroke(scope, {
					Thickness = 1.5,
					Transparency = 0.8,
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Glass or theme.Color.Light
					end),
				}),
				scope:New "Frame" {
					Name = "Highlight",
					BackgroundColor3 = WHITE,
					BackgroundTransparency = 0.93,
					Size = UDim2.new(1, 0, 0, HEADER),
					ZIndex = 1,
					[Children] = {
						Kit.corner(scope, "L"),
						Kit.gradient(scope, { Transparency = NumberSequence.new(0, 1) }),
					},
				},
				scope:New "Frame" {
					Name = "Header",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, 0, 0, HEADER),
					ZIndex = 2,
					[Children] = {
						Icon(scope, {
							Icon = props.Icon or "friends",
							Size = 38,
							AnchorPoint = Vector2.new(0, 0.5),
							Position = UDim2.new(0, PAD + 2, 0.5, 2),
							Color = Kit.themed(scope, function(theme)
								return theme.Color.Accent
							end),
							Gradient = true,
							Shadow = true,
							ZIndex = 3,
						}),
						Text(scope, {
							Name = "Title",
							Text = props.Title or "Party",
							TextSize = 32,
							Font = "Title",
							AnchorPoint = Vector2.new(0, 0.5),
							Position = UDim2.new(0, PAD + 50, 0.5, 2),
							ZIndex = 3,
						}),
						scope:New "Frame" {
							Name = "Count",
							AnchorPoint = Vector2.new(1, 0.5),
							Position = UDim2.new(1, -PAD, 0.5, 2),
							Size = UDim2.fromOffset(0, 34),
							AutomaticSize = Enum.AutomaticSize.X,
							BackgroundColor3 = Kit.themed(scope, function(theme)
								return theme.Color.SurfaceLight
							end),
							ZIndex = 3,
							[Children] = {
								Kit.corner(scope, "Full"),
								Kit.padding(scope, { 0, 14 }),
								Kit.list(scope, { Direction = "X", Padding = 6, VAlign = "Center" }),
								Icon(scope, {
									Icon = "check",
									Size = 18,
									LayoutOrder = 1,
									Color = Kit.themed(scope, function(theme)
										return theme.Color.Good
									end),
									Stroke = false,
									ZIndex = 4,
								}),
								Text(scope, {
									Name = "ReadyCount",
									Text = scope:Computed(function(use)
										return `{use(readyCount)} ready  ·  {use(count)}/{slots}`
									end),
									TextSize = 18,
									Font = "Heavy",
									Stroke = false,
									LayoutOrder = 2,
									ZIndex = 4,
								}),
							},
						},
					},
				},
				scope:New "Frame" {
					Name = "Slots",
					BackgroundTransparency = 1,
					Position = UDim2.fromOffset(PAD, HEADER),
					Size = UDim2.fromOffset(inner, listHeight),
					ZIndex = 2,
					[Children] = {
						Kit.list(scope, { Direction = "Y", Padding = GAP }),
						rows,
					},
				},
				if hasFooter
					then Button(scope, {
						Name = "Leave",
						Text = "Leave",
						Icon = "door",
						Tone = "Red",
						TextSize = 24,
						Size = UDim2.fromOffset(170, 54),
						AnchorPoint = Vector2.new(1, 1),
						Position = UDim2.new(1, -PAD, 1, -PAD),
						OnClick = props.OnLeave,
						ZIndex = 3,
					})
					else nil,
			},
		},
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="131">
            <Properties>
              <string name="Name">PassTrack</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	PassTrack: a battle / season pass. A horizontally scrolling track of tiers
	with a free reward row, a premium reward row and a progress line with tier
	badges between them. Reached rewards glow and can be claimed; premium
	rewards stay locked (with an "Unlock" call to action) until the player
	owns the pass.

	local progress = scope:Value(4)            -- tiers reached
	local premium = scope:Value(false)
	scope:PassTrack {
		Tiers = {
			{ tier = 1, free = { icon = "coin", amount = 100 }, premium = { icon = "gem", amount = 25 } },
			{ tier = 2, free = { icon = "gift", amount = 1 }, premium = { icon = "pet", amount = 1 } },
			...
		},
		Progress = progress,
		Premium = premium,
		OnClaim = function(tier, track) end,   -- track = "free" | "premium"
		OnBuyPremium = function() end,         -- shows the Unlock button
		Claimed = claimed,                     -- optional Value<{ free = {[tier]=true}, premium = {...} }>
		UnlockText = "Unlock",
		Size = UDim2.fromOffset(1100, 340),
	}
]]

local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Format = require(script.Parent.Parent.Util.Format)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Button = require(script.Parent.Button)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

local TILE = 112 -- reward tile size
local COLUMN = 136 -- tier column width
local ROW = 128 -- reward row height
local BAND = 64 -- progress line band height
local RAIL = 208 -- left label column width
local PAD = 12

local function field(entry: any, key: string): any
	if typeof(entry) ~= "table" then
		return nil
	end
	local value = entry[key]
	if value == nil then
		value = entry[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

local function amountText(amount: any): string
	if typeof(amount) == "number" then
		return `x{Format.abbrev(amount)}`
	end
	return tostring(amount or "")
end

type TileState = "claimed" | "ready" | "waiting" | "locked"

local function PassTrack(scope: Scope, props: { [any]: any }): Frame
	-- themed scrollbar parts (Kit.scrollBar), shared by this component's scroll areas
	local scrollBar = Kit.scrollBar(scope)
	local tiers = props.Tiers or {}
	local count = #tiers
	local progress = props.Progress or scope:Value(0)
	local premium = props.Premium or scope:Value(false)
	local claimed = props.Claimed or scope:Value({ free = {}, premium = {} })

	local reached = scope:Computed(function(use)
		return tonumber(use(progress)) or 0
	end)
	local owned = scope:Computed(function(use)
		return use(premium) == true
	end)

	local contentWidth = count * COLUMN + PAD * 2
	local contentHeight = ROW * 2 + BAND

	local function isClaimed(state: any, track: string, tier: number): boolean
		local t = if typeof(state) == "table" then state[track] else nil
		return t ~= nil and t[tier] == true
	end

	local function claim(tier: number, track: string, from: GuiObject?)
		Sound.play("Reward")
		if from then
			Particles.burst(from, { Count = 18, Shape = "sparkle", Size = 22, Speed = 420 })
		end
		if typeof(claimed) == "table" and claimed.set then
			local current = peek(claimed) or {}
			local copy = { free = table.clone(current.free or {}), premium = table.clone(current.premium or {}) }
			copy[track][tier] = true
			claimed:set(copy)
		end
		if props.OnClaim then
			props.OnClaim(tier, track)
		end
	end

	-- One reward tile (free or premium) for a tier, in its column's scope.
	local function rewardTile(scope: Scope, index: number, tier: number, track: string, reward: any): GuiObject
		local isPremium = track == "premium"
		local state = scope:Computed(function(use): TileState
			if isClaimed(use(claimed), track, tier) then
				return "claimed"
			end
			local isReached = tier <= use(reached)
			if isPremium and not use(owned) then
				return "locked"
			end
			return if isReached then "ready" else "waiting"
		end)
		local ready = scope:Computed(function(use)
			return use(state) == "ready"
		end)
		local hovering = scope:Value(false)
		local scale = Kit.spring(scope, scope:Computed(function(use)
			if not use(hovering) then
				return 1
			elseif use(ready) then
				return 1.06
			elseif use(state) == "locked" and props.OnBuyPremium then
				return 1.03
			end
			return 1
		end), 1.1)

		local tone = scope:Computed(function(use)
			local theme = use(Theme.current)
			local s = use(state)
			if s == "claimed" then
				return theme.Tone.Dark
			elseif s == "ready" then
				return theme.Tone.Green
			elseif isPremium then
				return theme.Tone.Yellow
			end
			return theme.Tone.Blue
		end)
		local strokeColor = scope:Computed(function(use)
			local t = use(tone)
			return t.Stroke or t.Edge
		end)
		local dim = scope:Computed(function(use)
			local s = use(state)
			return s == "claimed" or (s ~= "ready" and tier > use(reached))
		end)

		local face = scope:New "Frame" {
			Name = "Face",
			Size = UDim2.new(1, 0, 1, -4),
			BackgroundColor3 = WHITE,
			ZIndex = 2,
			[Children] = {
				Kit.corner(scope, "M"),
				Kit.gradient(scope, {
					Rotation = 80,
					Color = scope:Computed(function(use)
						local t = use(tone)
						return ColorSequence.new({
							ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.1)),
							ColorSequenceKeypoint.new(0.55, t.Bottom),
							ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.3)),
						})
					end),
				}),
				scope:New "Frame" {
					Name = "Gloss",
					BackgroundColor3 = WHITE,
					BackgroundTransparency = 0.5,
					Position = UDim2.fromOffset(3, 3),
					Size = UDim2.new(1, -6, 0.4, 0),
					ZIndex = 3,
					[Children] = {
						Kit.corner(scope, "M"),
						Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0.25, 1) }),
					},
				},
				scope:New "ImageLabel" {
					Name = "Glow",
					BackgroundTransparency = 1,
					Image = Assets.SoftShadow,
					ImageColor3 = scope:Computed(function(use)
						return Color.lighten(use(tone).Top, 0.6)
					end),
					ImageTransparency = scope:Computed(function(use)
						return if use(ready) then 0.3 else 0.7
					end),
					ScaleType = Enum.ScaleType.Slice,
					SliceCenter = Assets.SoftShadowSlice,
					SliceScale = (TILE * 0.28) / Assets.SoftShadowFalloff,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.42),
					Size = UDim2.fromScale(0.8, 0.8),
					ZIndex = 3,
				},
				Icon(scope, {
					Name = "RewardIcon",
					Icon = field(reward, "icon") or "gift",
					Size = math.floor(TILE * 0.52),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.4),
					Rotation = -8,
					Color = scope:Computed(function(use)
						return Color.lighten(use(tone).Top, 0.5)
					end),
					Gradient = true,
					Shadow = true,
					Stroke = 3,
					StrokeColor = strokeColor,
					ZIndex = 4,
				}),
				Text(scope, {
					Name = "Amount",
					Text = amountText(field(reward, "amount")),
					Case = "Normal",
					TextSize = 24,
					Font = "Display",
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -8),
					StrokeColor = strokeColor,
					Stroke = 2.5,
					ZIndex = 5,
				}),
				scope:New "Frame" {
					Name = "Dim",
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Backdrop
					end),
					BackgroundTransparency = 0.5,
					Size = UDim2.fromScale(1, 1),
					Visible = dim,
					ZIndex = 6,
					[Children] = Kit.corner(scope, "M"),
				},
			},
		}

		local tile: TextButton
		tile = scope:New "TextButton" {
			Name = `{track}{tier}`,
			Text = "",
			AutoButtonColor = false,
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromOffset(
				PAD + (index - 1) * COLUMN + COLUMN / 2,
				if isPremium then ROW + BAND + ROW / 2 else ROW / 2
			),
			Size = UDim2.fromOffset(TILE, TILE),
			ZIndex = 3,
			[OnEvent "MouseEnter"] = function()
				hovering:set(true)
			end,
			[OnEvent "MouseLeave"] = function()
				hovering:set(false)
			end,
			[OnEvent "Activated"] = function()
				local s = peek(state)
				if s == "ready" then
					claim(tier, track, tile)
				elseif s == "locked" and props.OnBuyPremium then
					props.OnBuyPremium()
				else
					Sound.play("Error")
				end
			end,
			[Children] = {
				scope:New "UIScale" { Scale = scale },
				scope:New "Frame" {
					Name = "Lip",
					Size = UDim2.fromScale(1, 1),
					BackgroundColor3 = scope:Computed(function(use)
						return use(tone).Deep
					end),
					ZIndex = 1,
					[Children] = {
						Kit.corner(scope, "M"),
						Kit.stroke(scope, {
							Thickness = scope:Computed(function(use)
								return if use(ready) then 3.5 else 2.5
							end),
							Color = scope:Computed(function(use)
								local theme = use(Theme.current)
								return if use(ready) then theme.Color.Light else use(tone).Edge
							end),
						}),
					},
				},
				face,
				-- lock badge (premium, not owned)
				scope:New "Frame" {
					Name = "Lock",
					AnchorPoint = Vector2.new(1, 0),
					Position = UDim2.new(1, -6, 0, 6),
					Size = UDim2.fromOffset(32, 32),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Tone.Dark.Bottom
					end),
					Visible = scope:Computed(function(use)
						return use(state) == "locked"
					end),
					ZIndex = 8,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.stroke(scope, {
							Thickness = 2.5,
							Color = Kit.themed(scope, function(theme)
								return theme.Tone.Yellow.Top
							end),
						}),
						Icon(scope, {
							Icon = "lock",
							Size = 20,
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = Kit.themed(scope, function(theme)
								return theme.Tone.Yellow.Top
							end),
							Stroke = false,
							ZIndex = 9,
						}),
					},
				},
				-- claimed check
				scope:New "Frame" {
					Name = "Check",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.42),
					Size = UDim2.fromOffset(48, 48),
					BackgroundColor3 = WHITE,
					Visible = scope:Computed(function(use)
						return use(state) == "claimed"
					end),
					ZIndex = 8,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.gradient(scope, {
							Rotation = 90,
							Color = Kit.themed(scope, function(theme)
								return ColorSequence.new(theme.Tone.Green.Top, theme.Tone.Green.Bottom)
							end),
						}),
						Kit.stroke(scope, {
							Thickness = 3,
							Color = Kit.themed(scope, function(theme)
								return theme.Color.Light
							end),
						}),
						Icon(scope, {
							Icon = "check",
							Size = 32,
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = Kit.themed(scope, function(theme)
								return theme.Tone.Green.Text
							end),
							StrokeColor = Kit.themed(scope, function(theme)
								return theme.Tone.Green.Edge
							end),
							Stroke = 2,
							ZIndex = 9,
						}),
					},
				},
			},
		}

		local stopPulse: (() -> ())? = nil
		local stopShine: (() -> ())? = nil
		local function update(isReady: boolean)
			if isReady and stopPulse == nil then
				stopPulse = Motion.pulse(face, 0.05, 1)
				stopShine = Motion.shine(face, { ZIndex = 5, Transparency = 0.5, Interval = 2.6 })
			elseif not isReady and stopPulse then
				stopPulse()
				stopPulse = nil
				if stopShine then
					stopShine()
					stopShine = nil
				end
			end
		end
		update(peek(ready))
		Kit.watch(scope, ready, update)
		table.insert(scope, function()
			if stopPulse then
				stopPulse()
			end
			if stopShine then
				stopShine()
			end
		end)
		return tile
	end

	-- A tier's column: its reward tiles and its badge on the line. Only the
	-- columns in view (plus one either side) are built, as the track scrolls.
	local function column(scope: Scope, index: number, entry: any): { Instance }
		local columns = {}
		local tier = field(entry, "tier") or index
		local centerX = PAD + (index - 1) * COLUMN + COLUMN / 2
		local isReached = scope:Computed(function(use)
			return tier <= use(reached)
		end)
		if field(entry, "free") ~= nil then
			table.insert(columns, rewardTile(scope, index, tier, "free", field(entry, "free")))
		end
		if field(entry, "premium") ~= nil then
			table.insert(columns, rewardTile(scope, index, tier, "premium", field(entry, "premium")))
		end
		table.insert(columns, scope:New "Frame" {
			Name = `Tier{tier}`,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromOffset(centerX, ROW + BAND / 2),
			Size = UDim2.fromOffset(44, 44),
			BackgroundColor3 = WHITE,
			ZIndex = 4,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						local t = if use(isReached) then theme.Tone.Yellow else theme.Tone.Dark
						return ColorSequence.new(Color.lighten(t.Top, 0.1), t.Bottom)
					end),
				}),
				Kit.stroke(scope, {
					Thickness = 3,
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						return if use(isReached) then theme.Tone.Yellow.Edge else theme.Tone.Dark.Edge
					end),
				}),
				Text(scope, {
					Text = tostring(tier),
					TextSize = 22,
					Font = "Display",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						return if use(isReached) then theme.Tone.Yellow.Text else theme.Color.TextMuted
					end),
					StrokeColor = scope:Computed(function(use)
						local theme = use(Theme.current)
						local t = if use(isReached) then theme.Tone.Yellow else theme.Tone.Dark
						return t.Stroke or t.Edge
					end),
					ZIndex = 5,
				}),
			},
		})
		return columns
	end

	local givenSize = peek(props.Size)
	local window = Kit.virtualRows(scope, {
		Axis = "X",
		Step = COLUMN,
		Leading = PAD,
		View = if typeof(givenSize) == "UDim2" and givenSize.X.Scale == 0
			then givenSize.X.Offset - (RAIL + 12)
			elseif givenSize == nil then 1100 - (RAIL + 12)
			else 0,
	})
	local inView = scope:Computed(function(use)
		local out = {}
		for index = math.max(1, use(window.First) + 1), math.min(count, use(window.Last) + 1) do
			out[index] = tiers[index]
		end
		return out
	end)
	local columns = scope:ForPairs(inView, function(_, columnScope, index, entry)
		return index, column(columnScope, index, entry)
	end)

	-- Progress line from the first tier's centre to the last's.
	local lineStart = PAD + COLUMN / 2
	local lineLength = math.max(0, (count - 1) * COLUMN)
	local fill = scope:Spring(scope:Computed(function(use)
		if count <= 1 then
			return if use(reached) >= 1 then 1 else 0
		end
		return math.clamp((use(reached) - 1) / (count - 1), 0, 1)
	end), 12, 1)

	local line = scope:New "Frame" {
		Name = "Line",
		AnchorPoint = Vector2.new(0, 0.5),
		Position = UDim2.fromOffset(lineStart, ROW + BAND / 2),
		Size = UDim2.fromOffset(lineLength, 14),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Track
		end),
		ZIndex = 2,
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.stroke(scope, {
				Thickness = 2,
				Color = Kit.themed(scope, function(theme)
					return theme.Color.Outline
				end),
			}),
			scope:New "Frame" {
				Name = "Fill",
				Size = scope:Computed(function(use)
					return UDim2.fromScale(use(fill), 1)
				end),
				BackgroundColor3 = WHITE,
				ZIndex = 3,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = Kit.themed(scope, function(theme)
							local t = theme.Tone.Yellow
							return ColorSequence.new(Color.lighten(t.Top, 0.2), t.Bottom)
						end),
					}),
				},
			},
		},
	}

	local content = scope:New "Frame" {
		Name = "Track",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(contentWidth, contentHeight),
		[Children] = { line, columns },
	}

	local scroller = scope:New "ScrollingFrame" {
		Name = "Scroll",
		Position = UDim2.fromOffset(RAIL + 12, 0),
		Size = UDim2.new(1, -(RAIL + 12), 1, 0),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollingDirection = Enum.ScrollingDirection.X,
		CanvasSize = UDim2.fromOffset(contentWidth, 0),
		ScrollBarThickness = 6,
		ScrollBarImageColor3 = scrollBar.ScrollBarImageColor3,
		ScrollBarImageTransparency = scrollBar.ScrollBarImageTransparency,
		TopImage = scrollBar.TopImage,
		MidImage = scrollBar.MidImage,
		BottomImage = scrollBar.BottomImage,
		HorizontalScrollBarInset = Enum.ScrollBarInset.Always,
		ElasticBehavior = Enum.ElasticBehavior.WhenScrollable,
		Selectable = false,
		ZIndex = 2,
		[Children] = content,
	}
	window.bind(scroller :: ScrollingFrame)

	-- Left rail: FREE / PREMIUM labels, tier counter, unlock button.
	local function railChip(name: string, y: number, children: { any }, premiumChip: boolean)
		return scope:New "Frame" {
			Name = name,
			Position = UDim2.fromOffset(0, y + (ROW - TILE) / 2),
			Size = UDim2.fromOffset(RAIL, TILE),
			BackgroundColor3 = WHITE,
			ZIndex = 2,
			[Children] = {
				Kit.corner(scope, "M"),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = Kit.themed(scope, function(theme)
						local t = if premiumChip then theme.Tone.Yellow else theme.Tone.Dark
						return ColorSequence.new({
							ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.08)),
							ColorSequenceKeypoint.new(0.6, t.Bottom),
							ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.25)),
						})
					end),
				}),
				Kit.stroke(scope, {
					Thickness = 3,
					Color = Kit.themed(scope, function(theme)
						return if premiumChip then theme.Tone.Yellow.Edge else theme.Tone.Dark.Edge
					end),
				}),
				children,
			},
		}
	end

	local function chipTitle(text: string, icon: string, premiumChip: boolean, y: number)
		local function toneOf(theme: any)
			return if premiumChip then theme.Tone.Yellow else theme.Tone.Dark
		end
		return scope:New "Frame" {
			Name = "Title",
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(0, y),
			Size = UDim2.new(1, 0, 0, 40),
			ZIndex = 3,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 8, HAlign = "Center", VAlign = "Center" }),
				Icon(scope, {
					LayoutOrder = 1,
					Icon = icon,
					Size = 34,
					Color = Kit.themed(scope, function(theme)
						return toneOf(theme).Text
					end),
					StrokeColor = Kit.themed(scope, function(theme)
						local t = toneOf(theme)
						return t.Stroke or t.Edge
					end),
					Shadow = true,
					ZIndex = 4,
				}),
				Text(scope, {
					LayoutOrder = 2,
					Text = text,
					TextSize = 28,
					Font = "Display",
					Color = Kit.themed(scope, function(theme)
						return toneOf(theme).Text
					end),
					StrokeColor = Kit.themed(scope, function(theme)
						local t = toneOf(theme)
						return t.Stroke or t.Edge
					end),
					ZIndex = 4,
				}),
			},
		}
	end

	local function railHint(text: string, visible: any)
		return Text(scope, {
			Name = "Hint",
			Text = text,
			TextSize = 19,
			Font = "Body",
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.new(0.5, 0, 1, -20),
			Color = Kit.themed(scope, function(theme)
				return theme.Color.TextMuted
			end),
			Stroke = false,
			Visible = if visible == nil then true else visible,
			ZIndex = 4,
		})
	end

	local unlockButton = if props.OnBuyPremium
		then Button(scope, {
			Name = "Unlock",
			Text = props.UnlockText or "Unlock",
			Icon = props.UnlockIcon or "robux",
			Tone = "Green",
			Shine = true,
			Pulse = scope:Computed(function(use)
				return not use(owned)
			end),
			Visible = scope:Computed(function(use)
				return not use(owned)
			end),
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.new(0.5, 0, 1, -10),
			Size = UDim2.fromOffset(RAIL - 28, 50),
			TextSize = 24,
			IconSize = 26,
			Sound = "Purchase",
			OnClick = props.OnBuyPremium,
			ZIndex = 5,
		})
		else nil

	local rail = scope:New "Frame" {
		Name = "Rail",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(RAIL, contentHeight),
		ZIndex = 2,
		[Children] = {
			railChip("FreeLabel", 0, {
				chipTitle("FREE", "gift", false, 14),
				railHint("Everyone", nil),
			}, false),
			scope:New "Frame" {
				Name = "TierCounter",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(0, ROW),
				Size = UDim2.fromOffset(RAIL, BAND),
				ZIndex = 3,
				[Children] = {
					Kit.list(scope, { Direction = "X", Padding = 8, HAlign = "Center", VAlign = "Center" }),
					Text(scope, {
						LayoutOrder = 1,
						Text = "TIER",
						TextSize = 22,
						Font = "Heavy",
						Color = Kit.themed(scope, function(theme)
							return theme.Color.TextMuted
						end),
						ZIndex = 4,
					}),
					Text(scope, {
						LayoutOrder = 2,
						Text = scope:Computed(function(use)
							return `{math.clamp(use(reached), 0, count)}/{count}`
						end),
						TextSize = 32,
						Font = "Display",
						Gradient = "Yellow",
						StrokeColor = Kit.themed(scope, function(theme)
							return theme.Tone.Yellow.Edge
						end),
						ZIndex = 4,
					}),
				},
			},
			railChip("PremiumLabel", ROW + BAND, {
				chipTitle("PREMIUM", "crown", true, 14),
				unlockButton,
				railHint(
					"Pass holders",
					scope:Computed(function(use)
						return not use(owned) and unlockButton == nil
					end)
				),
				scope:New "Frame" {
					Name = "Active",
					BackgroundTransparency = 1,
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -14),
					Size = UDim2.fromOffset(RAIL - 28, 40),
					Visible = owned,
					ZIndex = 4,
					[Children] = {
						Kit.list(scope, { Direction = "X", Padding = 6, HAlign = "Center", VAlign = "Center" }),
						Icon(scope, {
							LayoutOrder = 1,
							Icon = "check",
							Size = 26,
							Color = Kit.themed(scope, function(theme)
								return theme.Tone.Yellow.Text
							end),
							StrokeColor = Kit.themed(scope, function(theme)
								local t = theme.Tone.Yellow
								return t.Stroke or t.Edge
							end),
							ZIndex = 5,
						}),
						Text(scope, {
							LayoutOrder = 2,
							Text = "ACTIVE",
							TextSize = 22,
							Font = "Display",
							Color = Kit.themed(scope, function(theme)
								return theme.Tone.Yellow.Text
							end),
							StrokeColor = Kit.themed(scope, function(theme)
								local t = theme.Tone.Yellow
								return t.Stroke or t.Edge
							end),
							ZIndex = 5,
						}),
					},
				},
			}, true),
		},
	}

	local root = Kit.root(props, { Name = "PassTrack" })
	root.Size = props.Size or UDim2.fromOffset(1100, contentHeight + 12)
	root.BackgroundTransparency = 1
	root[Children] = {
		rail,
		scroller,
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame

	-- Scroll so the first reward waiting to be claimed (or the current tier)
	-- is the second column in view, and follow progress as it grows.
	local function focusTier(): number
		local state = peek(claimed)
		local limit = math.min(peek(reached), count)
		for index = 1, limit do
			local entry = tiers[index]
			local tier = field(entry, "tier") or index
			local freeWaiting = field(entry, "free") ~= nil and not isClaimed(state, "free", tier)
			local premiumWaiting = peek(owned) and field(entry, "premium") ~= nil and not isClaimed(state, "premium", tier)
			if freeWaiting or premiumWaiting then
				return index
			end
		end
		return math.max(1, limit)
	end
	local function focus(animate: boolean)
		local target = Vector2.new(math.max(0, (focusTier() - 2) * COLUMN), 0)
		if animate then
			TweenService:Create(scroller, TweenInfo.new(0.5, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
				CanvasPosition = target,
			}):Play()
		else
			scroller.CanvasPosition = target
		end
	end
	focus(false)
	Kit.watch(scope, reached, function()
		focus(true)
	end)

	return frame
end

return PassTrack
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="132">
            <Properties>
              <string name="Name">ProductCard</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	ProductCard: a shop offer card (Rivals bundle style). A rich gradient with
	big embossed icon art, a title with a soft outline, a huge amount, a
	bright price button, and an optional tag and sparkles.

	scope:ProductCard {
		Title = "Mega Key Bundle",
		Amount = "+ 40",
		Subtitle = "Best for new players",   -- optional small line under the amount
		Icon = "key",                 -- used for the art
		Tone = "Yellow",              -- card colour ("Red" for legendary, or a rarity like "Epic")
		Price = 160,                  -- number or text
		PriceIcon = "robux",          -- nil for no icon
		Tag = "BEST VALUE",           -- optional tag (state ok: nil / "" hides it)
		Owned = false,                -- state or bool: shows OWNED and disables
		SoldOut = false,              -- state or bool: SOLD OUT stamp, disabled price
		Stock = 3,                    -- optional count (state ok): "3 LEFT"; 0 = sold out
		Sparkles = true,              -- twinkles for premium offers
		Shine = nil,                  -- idle light sweep (default: only tagged / Sparkles cards)
		Layout = "Auto",              -- "Auto" | "Card" | "Wide"
		OnPurchase = function() end,
		Size = UDim2.fromOffset(368, 248),
		Width = nil,                  -- design width of a scale-sized card, when the caller knows it
	}

	Layouts. "Card" (up to ~2.6:1) puts the title and amount top-left, art
	bleeding off the right and the price bottom-right. "Wide" (hero banners,
	full-width featured offers) reads left to right: tag, title, amount and
	subtitle; then the item art big on a burst of light; then a large price
	button, over a faint pattern of the icon so the card is never an empty
	slab. "Auto" picks by the card's shape (it measures scale-sized cards).
	The price button always grows to fit its price.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Util.Format)
local Sound = require(script.Parent.Parent.Util.Sound)
local Color = require(script.Parent.Parent.Util.Color)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local Icons = require(script.Parent.Parent.Icons)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Button = require(script.Parent.Button)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local Out = Fusion.Out
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)

type Scope = Fusion.Scope<any>

-- Wider than this (width / height) and "Auto" uses the Wide layout.
local WIDE_RATIO = 2.6

return function(scope: Scope, props: { [any]: any }): TextButton
	-- Width may be Scale-based (cards stretch to fill a row); height sets the
	-- text sizes, so give it in pixels.
	local size = peek(props.Size) or UDim2.fromOffset(368, 248)
	local height = size.Y.Offset
	local tone = Kit.tone(scope, props.Tone, "Yellow")
	local owned = props.Owned or false
	local hovering = scope:Value(false)

	-- sold out: SoldOut = true, or a Stock count that reached 0
	local soldOut = scope:Computed(function(use)
		if use(props.SoldOut) == true then
			return true
		end
		local stock = use(props.Stock)
		return typeof(stock) == "number" and stock <= 0
	end)
	-- owned or sold out: no hover, no purchase, a disabled price button
	local locked = scope:Computed(function(use)
		return use(owned) == true or use(soldOut)
	end)
	local tagText = scope:Computed(function(use)
		local value = use(props.Tag)
		if value == nil or value == false or value == "" then
			return nil
		end
		return tostring(value)
	end)
	-- idle motion only on the offers you want clicked (QUALITY rule 10)
	local shineOn = if props.Shine ~= nil then props.Shine ~= false else (props.Tag ~= nil or props.Sparkles == true)

	-- which layout: fixed by props, by the offset width, or measured
	local measured = scope:Value(Vector2.zero)
	-- the card's width in design px (offset, or measured for scale sizes)
	local cardWidth = scope:Computed(function(use)
		if size.X.Scale == 0 then
			return size.X.Offset
		end
		-- a layout that sized the card knows its width: long titles fit it
		-- from the first frame, before (or without) a measurement
		local hint = use(props.Width)
		if typeof(hint) == "number" and hint > 0 then
			return hint
		end
		local abs = use(measured)
		if abs.X > 0 then
			return abs.X / math.max(0.05, use(Viewport.scale))
		end
		return math.floor(height * 1.5)
	end)
	local layout = scope:Computed(function(use)
		local forced = use(props.Layout)
		if forced == "Card" or forced == "Wide" then
			return forced
		end
		local ratio = 1.5
		if size.X.Scale == 0 then
			ratio = size.X.Offset / math.max(1, height)
		else
			local abs = use(measured)
			if abs.X > 0 and abs.Y > 0 then
				ratio = abs.X / abs.Y
			end
		end
		return if ratio >= WIDE_RATIO then "Wide" else "Card"
	end)

	local scale = Kit.spring(scope, scope:Computed(function(use)
		if use(hovering) and not use(locked) then
			return if use(layout) == "Wide" then 1.012 else 1.035
		end
		return 1
	end), 0.9)
	local ring = Kit.spring(scope, scope:Computed(function(use)
		return if use(hovering) and not use(locked) then 1 else 0
	end), 0.8)
	local tiltGoal = scope:Value(0)
	local tilt = scope:Spring(tiltGoal, 20, 0.5)

	-- Embossed art: a lighter face with a tinted edge and a soft drop.
	local decoColor = scope:Computed(function(use)
		return Color.lighten(use(tone).Top, 0.22)
	end)
	local decoStroke = scope:Computed(function(use)
		local t = use(tone)
		return t.Edge:Lerp(t.Bottom, 0.25)
	end)
	local decoGradient = scope:Computed(function(use)
		local t = use(tone)
		return ColorSequence.new(Color.lighten(t.Top, 0.35), t.Top:Lerp(t.Bottom, 0.35))
	end)
	local textStroke = scope:Computed(function(use)
		local t = use(tone)
		return t.Stroke or t.Deep
	end)

	local function deco(inner: Scope, name: string, sizePx: number, position: UDim2, rotation: number, transparency: number, zIndex: number?)
		return Icon(inner, {
			Name = name,
			Icon = props.Icon or "star",
			Size = sizePx,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = position,
			Rotation = rotation,
			Color = decoColor,
			StrokeColor = decoStroke,
			Stroke = math.max(3, math.floor(sizePx / 90)),
			Shadow = true,
			Gradient = decoGradient,
			Transparency = transparency,
			ZIndex = zIndex or 2,
		})
	end

	-- A faint repeat of the icon across the whole card (Rivals' full-bleed
	-- offer art), so wide cards have texture everywhere. Plain glyphs: cheap.
	local function pattern(inner: Scope)
		local kind, value = Icons.resolve(props.Icon or "star")
		local tiles = {}
		if kind ~= "glyph" and kind ~= "image" then
			return tiles
		end
		local columns = 8
		local tileSize = math.floor(height * 0.5)
		for row = 0, 1 do
			for col = 0, columns - 1 do
				local x = (col + 0.5 + (if row == 1 then 0.5 else 0)) / columns
				local y = if row == 0 then 0.27 else 0.8
				local common = {
					Name = "Tile",
					BackgroundTransparency = 1,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(x, y),
					Size = UDim2.fromOffset(tileSize, tileSize),
					Rotation = if (row + col) % 2 == 0 then -16 else 14,
					ZIndex = 2,
				}
				if kind == "glyph" then
					common.FontFace = inner:Computed(function(use)
						return Icons.font(true, use)
					end)
					common.Text = value
					common.TextScaled = true
					common.TextColor3 = WHITE
					common.TextTransparency = 0.87
					table.insert(tiles, inner:New "TextLabel" (common))
				else
					common.Image = value
					common.ImageTransparency = 0.87
					common.ScaleType = Enum.ScaleType.Fit
					table.insert(tiles, inner:New "ImageLabel" (common))
				end
			end
		end
		return tiles
	end

	-- The card's background art, per layout.
	local artLayers = scope:Computed(function(use, inner)
		if use(layout) == "Wide" then
			return pattern(inner)
		end
		return {
			deco(inner, "DecoBig", math.floor(height * 1.08), UDim2.new(1, -math.floor(height * 0.34), 0.56, 0), -20, 0.12),
			deco(inner, "DecoSmall", math.floor(height * 0.72), UDim2.new(0, math.floor(height * 0.1), 1, -math.floor(height * 0.1)), 22, 0.3),
		}
	end)

	local art = scope:New "CanvasGroup" {
		Name = "Art",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = WHITE,
		GroupTransparency = scope:Computed(function(use)
			return if use(owned) then 0.35 elseif use(soldOut) then 0.15 else 0
		end),
		ZIndex = 1,
		[Children] = {
			Kit.corner(scope, props.Radius or "M"),
			Kit.gradient(scope, {
				Rotation = scope:Computed(function(use)
					return if use(layout) == "Wide" then 0 else 55
				end),
				Color = scope:Computed(function(use)
					local t = use(tone)
					-- v2.4 style families: the family's card fill (vivid / tint / flat);
					-- vivid wide cards keep the darker-left gradient behind their text
					local theme = use(Theme.current)
					local fam = theme.Family
					if fam and not (use(layout) == "Wide" and (fam.Card or "Vivid") == "Vivid") then
						local fill = Kit.cardFill(theme, t)
						if fill then
							return fill
						end
					end
					if use(layout) == "Wide" then
						-- darker behind the text on the left, bright behind the art
						return ColorSequence.new({
							ColorSequenceKeypoint.new(0, t.Deep:Lerp(t.Bottom, 0.55)),
							ColorSequenceKeypoint.new(0.4, t.Bottom),
							ColorSequenceKeypoint.new(1, Color.lighten(t.Top, 0.06)),
						})
					end
					return ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.1)),
						ColorSequenceKeypoint.new(0.45, t.Bottom),
						ColorSequenceKeypoint.new(1, t.Deep:Lerp(t.Bottom, 0.35)),
					})
				end),
			}),
			artLayers,
			scope:New "Frame" {
				Name = "Sheen",
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = WHITE,
				ZIndex = 5,
				[Children] = Kit.gradient(scope, {
					Rotation = 90,
					Transparency = NumberSequence.new({
						NumberSequenceKeypoint.new(0, 0.72),
						NumberSequenceKeypoint.new(0.3, 1),
						NumberSequenceKeypoint.new(1, 1),
					}),
				}),
			},
			scope:New "Frame" {
				Name = "SoldOutShade",
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = Color3.new(0, 0, 0),
				BackgroundTransparency = 0.45,
				Visible = soldOut,
				ZIndex = 7,
			},
			scope:New "Frame" {
				Name = "Vignette",
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = scope:Computed(function(use)
					return use(tone).Deep
				end),
				ZIndex = 5,
				[Children] = Kit.gradient(scope, {
					Rotation = 90,
					Transparency = NumberSequence.new({
						NumberSequenceKeypoint.new(0, 1),
						NumberSequenceKeypoint.new(0.5, 1),
						NumberSequenceKeypoint.new(1, 0.5),
					}),
				}),
			},
		},
	}

	-- Title / amount text with a soft outline for busy art (Rivals: black at
	-- ~0.7, or the tone tint). The amount also gets a small drop shadow.
	-- `room`: px the text may take across (card width minus `room`), so a
	-- long title shrinks on one line instead of wrapping
	local function label(inner: Scope, name: string, text: any, textSize: number, font: string, sizeUDim: any, position: UDim2, strokeScale: number, shadow: boolean, room: any)
		local stroke = inner:Computed(function(use)
			return Theme.textStroke(use(Theme.current), "Art").Thickness * strokeScale
		end)
		local fitted = inner:Computed(function(use)
			local value = use(text)
			local width = use(cardWidth) - use(room)
			if value == nil or value == "" or width <= 0 then
				return textSize
			end
			local theme = use(Theme.current)
			local shown = if Theme.style(theme, "TextCase") == "Upper" then Kit.upper(tostring(value)) else tostring(value)
			return Kit.fitTextSize(shown, textSize, width - use(stroke) * 2, font, math.floor(textSize * 0.45), theme)
		end)
		local common = {
			Text = text,
			TextSize = fitted,
			Font = font,
			Size = sizeUDim,
			XAlign = "Left",
			ZIndex = 5,
		}
		local out = {}
		if shadow then
			local drop = table.clone(common)
			drop.Name = `{name}Shadow`
			drop.Position = position + UDim2.fromOffset(0, math.max(2, math.floor(textSize * 0.06)))
			drop.Color = inner:Computed(function(use)
				return use(tone).Deep
			end)
			drop.StrokeColor = drop.Color
			drop.Stroke = stroke
			drop.Transparency = 0.72
			drop.ZIndex = 4
			table.insert(out, Text(inner, drop))
		end
		local main = table.clone(common)
		main.Name = name
		main.Position = position
		main.Outline = "Art"
		main.StrokeColor = textStroke
		main.Stroke = stroke
		table.insert(out, Text(inner, main))
		return out
	end

	local function priceText(use: any): string
		if use(owned) then
			return "OWNED"
		elseif use(soldOut) then
			return "SOLD OUT"
		end
		local price = use(props.Price)
		return if typeof(price) == "number" then Format.comma(price) else tostring(price or "FREE")
	end

	-- a price button sized to its price (never cropped)
	local function priceButton(inner: Scope, buttonHeight: number, textSize: number, minWidth: number, maxWidth: number, extra: { [string]: any })
		local theme = Theme.get()
		local hasIcon = peek(props.PriceIcon) ~= nil and not peek(locked)
		local needed = Kit.textWidth(priceText(peek), textSize, "Display", theme)
			+ (if hasIcon then math.floor(textSize * 1.05) + 8 else 0)
		-- room for the words a state change can bring in, too
		if props.SoldOut ~= nil or props.Stock ~= nil then
			needed = math.max(needed, Kit.textWidth("SOLD OUT", textSize, "Display", theme))
		end
		if typeof(owned) == "table" then
			needed = math.max(needed, Kit.textWidth("OWNED", textSize, "Display", theme))
		end
		needed += theme.Stroke.Text * 2 + 36
		local width = math.clamp(math.ceil(needed), minWidth, math.max(minWidth, maxWidth))
		local buttonProps = {
			Name = "Price",
			Text = inner:Computed(priceText),
			Icon = inner:Computed(function(use)
				return if use(locked) then nil else use(props.PriceIcon)
			end),
			Tone = props.ButtonTone or "Green",
			-- a price button on busy art must stay solid
			Style = props.ButtonStyle or Kit.themed(inner, function(t)
				local style = Theme.style(t, "ButtonStyle")
				return if style == "Outline" or style == "Soft" then "Flat" else nil
			end),
			Disabled = locked,
			Shine = shineOn,
			Size = UDim2.fromOffset(width, buttonHeight),
			TextSize = textSize,
			IconSize = textSize,
			Sound = "Purchase",
			OnClick = props.OnPurchase,
			ZIndex = 6,
		}
		for key, value in extra do
			buttonProps[key] = value
		end
		return Button(inner, buttonProps), width
	end

	-- the tag as a crisp chip; `corner` = tilted on the top-right corner (Card)
	local function tag(inner: Scope, corner: boolean, textSize: number)
		local tagTone = props.TagTone or "Red"
		return inner:New "Frame" {
			Name = "Tag",
			Visible = inner:Computed(function(use)
				return use(tagText) ~= nil
			end),
			AnchorPoint = if corner then Vector2.new(1, 0.5) else Vector2.new(0, 0.5),
			Position = if corner then UDim2.new(1, -10, 0, 6) else UDim2.fromOffset(math.floor(height * 0.12), 0),
			Size = UDim2.fromOffset(0, math.floor(textSize * 1.75)),
			AutomaticSize = Enum.AutomaticSize.X,
			Rotation = if corner then 4 else 0,
			BackgroundColor3 = WHITE,
			ZIndex = 7,
			[Children] = {
				Kit.corner(inner, "S"),
				Kit.gradient(inner, {
					Rotation = 90,
					Color = Kit.themed(inner, function(theme)
						return Kit.faceColors(Theme.tone(theme, tagTone), 0)
					end),
				}),
				Kit.stroke(inner, {
					Thickness = 2,
					Color = Kit.themed(inner, function(theme)
						return Theme.tone(theme, tagTone).Edge
					end),
				}),
				Kit.padding(inner, { 0, math.floor(textSize * 0.62) }),
				Text(inner, {
					Text = inner:Computed(function(use)
						return use(tagText) or ""
					end),
					TextSize = textSize,
					Font = "Display",
					Size = UDim2.fromScale(0, 1),
					AutoSize = "X",
					Color = Kit.themed(inner, function(theme)
						return Theme.tone(theme, tagTone).Text
					end),
					StrokeColor = Kit.themed(inner, function(theme)
						local t = Theme.tone(theme, tagTone)
						return t.Stroke or t.Deep
					end),
					Stroke = 1.5,
					ZIndex = 8,
				}),
			},
		}
	end

	-- "3 LEFT": a small chip beside the price while stock is limited
	local function stockChip(inner: Scope, textSize: number, anchor: Vector2, position: UDim2)
		return inner:New "Frame" {
			Name = "Stock",
			AnchorPoint = anchor,
			Position = position,
			Size = UDim2.fromOffset(0, math.floor(textSize * 1.65)),
			AutomaticSize = Enum.AutomaticSize.X,
			BackgroundColor3 = Kit.themed(inner, function(theme)
				return theme.Color.Outline
			end),
			BackgroundTransparency = 0.2,
			Visible = inner:Computed(function(use)
				local stock = use(props.Stock)
				return typeof(stock) == "number" and stock > 0 and not use(locked)
			end),
			ZIndex = 7,
			[Children] = {
				Kit.corner(inner, "Full"),
				Kit.padding(inner, { 0, math.floor(textSize * 0.7) }),
				Text(inner, {
					Text = inner:Computed(function(use)
						local stock = use(props.Stock)
						return if typeof(stock) == "number" then `{Format.comma(stock)} LEFT` else ""
					end),
					TextSize = textSize,
					Font = "Display",
					Size = UDim2.fromScale(0, 1),
					AutoSize = "X",
					Color = inner:Computed(function(use)
						local theme = use(Theme.current)
						local stock = use(props.Stock)
						return if typeof(stock) == "number" and stock <= 3 then Color.lighten(theme.Color.Bad, 0.2) else theme.Color.Warn
					end),
					Stroke = false,
					ZIndex = 8,
				}),
			},
		}
	end

	-- Card layout: title and amount top-left, price bottom-right.
	local function cardContent(inner: Scope, withAmount: boolean)
		local titleSize = math.floor(height * 0.14)
		local amountSize = math.floor(height * 0.29)
		-- the title keeps clear of the tag in the top-right corner
		local tagRoom = inner:Computed(function(use)
			local value = use(tagText)
			return if value then math.floor(Kit.textWidth(value, 19, "Display") * 0.8) + 20 else 0
		end)
		local buttonHeight = math.floor(height * 0.25)
		local button = priceButton(inner, buttonHeight, math.floor(height * 0.14), math.floor(height * 0.6), math.floor(height * 1.15), {
			AnchorPoint = Vector2.new(1, 1),
			Position = UDim2.new(1, -18, 1, -18),
		})
		return {
			-- no amount: the title takes the big line, level with the amounts
			-- of the cards beside it
			if not withAmount
				then label(inner, "Title", props.Title or "", amountSize, "Title", UDim2.new(1, -44, 0, math.floor(amountSize * 1.12)), UDim2.fromOffset(20, math.floor(height * 0.2)), 1.5, true, 44)
				else nil,
			if withAmount then label(
				inner,
				"Title",
				props.Title or "",
				titleSize,
				"Title",
				inner:Computed(function(use)
					return UDim2.new(1, -44 - use(tagRoom), 0, math.floor(titleSize * 1.15))
				end),
				UDim2.fromOffset(22, math.floor(height * 0.065)),
				1,
				false,
				inner:Computed(function(use)
					return 44 + use(tagRoom)
				end)
			) else nil,
			if withAmount
				then label(inner, "Amount", props.Amount or "", amountSize, "Display", UDim2.new(1, -44, 0, math.floor(amountSize * 1.12)), UDim2.fromOffset(20, math.floor(height * 0.2)), 1.5, true, 44)
				else nil,
			if props.Subtitle
				then (function()
					-- one line that fits the card: shrinks a little for wide
					-- faces / capitals (Tactical, Horror), then truncates
					local subtitleSize = math.max(16, math.floor(height * 0.08))
					local fittedSubtitle = inner:Computed(function(use)
						local theme = use(Theme.current)
						local value = tostring(props.Subtitle)
						local shown = if Theme.style(theme, "TextCase") == "Upper" then Kit.upper(value) else value
						return Kit.fitTextSize(shown, subtitleSize, use(cardWidth) - 48, "Body", math.max(14, math.floor(subtitleSize * 0.8)), theme)
					end)
					return Text(inner, {
						Name = "Subtitle",
						Text = props.Subtitle,
						TextSize = fittedSubtitle,
						Font = "Body",
						XAlign = "Left",
						Position = UDim2.fromOffset(24, math.floor(height * 0.52)),
						Size = UDim2.new(1, -44, 0, math.floor(subtitleSize * 1.3)),
						Truncate = true,
						StrokeColor = textStroke,
						ZIndex = 5,
					})
				end)()
				else nil,
			button,
			if props.Tag ~= nil then tag(inner, true, 19) else nil,
			if props.Stock ~= nil
				then stockChip(inner, math.max(15, math.floor(height * 0.075)), Vector2.new(1, 1), UDim2.new(1, -18, 1, -(18 + buttonHeight + 8)))
				else nil,
		}
	end

	-- Wide layout: [tag / title / amount / subtitle] [art on a burst] [price]
	local function wideContent(inner: Scope, withAmount: boolean)
		local pad = math.floor(height * 0.12)
		local titleSize = math.floor(height * 0.15)
		local amountSize = math.floor(height * 0.3)
		local subtitleSize = math.max(16, math.floor(height * 0.085))
		local button, buttonWidth = priceButton(inner, math.floor(height * 0.3), math.floor(height * 0.15), math.floor(height * 0.85), math.floor(height * 1.6), {
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -pad, 0.5, math.floor(height * 0.02)),
		})
		local artSize = math.floor(height * 0.9)
		local artX = -(pad + buttonWidth + math.floor(height * 0.12) + math.floor(artSize * 0.5))
		local artPosition = UDim2.new(1, artX, 0.5, 0)
		local textRight = pad + buttonWidth + math.floor(height * 0.12) + artSize + 16

		local rays = Kit.rays(inner, {
			Color = inner:Computed(function(use)
				return Color.lighten(use(tone).Top, 0.75)
			end),
			Count = 14,
			Transparency = 0.42,
			Size = UDim2.fromOffset(math.floor(artSize * 2.1), math.floor(artSize * 2.1)),
			Position = artPosition,
			ZIndex = 2,
		})
		-- these live in the art group so the card's corners clip them; the
		-- inner scope destroys them when the layout changes
		rays.Parent = art
		table.insert(inner, Motion.spin(rays, 40))
		local hero = deco(inner, "Hero", artSize, artPosition, -10, 0, 3)
		hero.Parent = art
		local echo = deco(inner, "Echo", math.floor(artSize * 0.55), artPosition + UDim2.fromOffset(-math.floor(artSize * 0.52), math.floor(artSize * 0.3)), 18, 0.35, 3)
		echo.Parent = art

		local rows = {}
		local specs = if withAmount
			then {
				{ "Title", props.Title or "", titleSize, "Title", 1.15, 1, false },
				{ "Amount", props.Amount or "", amountSize, "Display", 1.1, 1.5, true },
			}
			else { { "Title", props.Title or "", amountSize, "Title", 1.1, 1.5, true } }
		for index, spec in specs do
			local rowSize = UDim2.new(1, 0, 0, math.floor(spec[3] * spec[5]))
			table.insert(rows, inner:New "Frame" {
				Name = `{spec[1]}Row`,
				BackgroundTransparency = 1,
				Size = rowSize,
				LayoutOrder = index,
				ZIndex = 5,
				[Children] = label(inner, spec[1], spec[2], spec[3], spec[4], rowSize, UDim2.new(), spec[6], spec[7], pad + 4 + textRight),
			})
		end
		if props.Subtitle then
			table.insert(rows, Text(inner, {
				Name = "Subtitle",
				Text = props.Subtitle,
				TextSize = subtitleSize,
				Font = "Body",
				XAlign = "Left",
				Size = UDim2.new(1, 0, 0, math.floor(subtitleSize * 1.25)),
				Truncate = true,
				StrokeColor = textStroke,
				LayoutOrder = 3,
				ZIndex = 5,
			}))
		end

		return {
			inner:New "Frame" {
				Name = "Info",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(0, 0.5),
				Position = inner:Computed(function(use)
					return UDim2.new(0, pad + 4, 0.5, if use(tagText) then math.floor(height * 0.04) else 0)
				end),
				Size = UDim2.new(1, -(pad + 4 + textRight), 1, -pad * 2),
				ZIndex = 5,
				[Children] = {
					Kit.list(inner, { Direction = "Y", Padding = 2, VAlign = "Center" }),
					rows,
				},
			},
			button,
			if props.Tag ~= nil then tag(inner, false, math.max(17, math.floor(height * 0.085))) else nil,
			if props.Stock ~= nil
				then stockChip(inner, math.max(15, math.floor(height * 0.075)), Vector2.new(0.5, 0), UDim2.new(1, -(pad + math.floor(buttonWidth / 2)), 0.5, math.floor(height * 0.19) + 6))
				else nil,
		}
	end

	-- an empty / missing Amount lets the title use the big line instead
	local hasAmount = scope:Computed(function(use)
		local amount = use(props.Amount)
		return amount ~= nil and amount ~= false and tostring(amount) ~= ""
	end)
	local content = scope:Computed(function(use, inner)
		if use(layout) == "Wide" then
			return wideContent(inner, use(hasAmount))
		end
		return cardContent(inner, use(hasAmount))
	end)

	local root = Kit.root(props, { Name = props.Title or "ProductCard" })
	root.Size = size
	root.BackgroundTransparency = 1
	root.Text = ""
	root.AutoButtonColor = false
	root.Selectable = false
	root.Rotation = tilt
	if size.X.Scale ~= 0 then
		root[Out "AbsoluteSize"] = measured
	end

	root[OnEvent "MouseEnter"] = function()
		hovering:set(true)
		if peek(layout) ~= "Wide" and not peek(locked) then
			tilt:addVelocity(if math.random() < 0.5 then 12 else -12)
		end
	end
	root[OnEvent "MouseLeave"] = function()
		hovering:set(false)
	end
	root[OnEvent "Activated"] = function()
		if not peek(locked) and props.OnPurchase then
			Sound.play("Purchase")
			props.OnPurchase()
		end
	end

	root[Children] = {
		scope:New "UIScale" { Scale = scale },
		Kit.elevation(scope, { Radius = props.Radius or "M", Spread = 16, Offset = Vector2.new(0, 8) }),
		art,
		scope:New "Frame" {
			Name = "Ring",
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			ZIndex = 3,
			[Children] = {
				Kit.corner(scope, props.Radius or "M"),
				scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
					Color = WHITE,
					Thickness = 3,
					Transparency = scope:Computed(function(use)
						return 1 - math.clamp(use(ring), 0, 1) * 0.9
					end),
				},
				-- v2.4 style families: the family outline (tinted to the card) and
				-- the inner top light (nothing in a classic theme)
				Kit.outline(scope, { Tone = tone }),
				Kit.rim(scope, {}),
			},
		},
		content,
		-- a rubber stamp across the art
		scope:New "Frame" {
			Name = "SoldOutStamp",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.6),
			Size = UDim2.fromOffset(0, math.floor(height * 0.18)),
			AutomaticSize = Enum.AutomaticSize.X,
			Rotation = -9,
			BackgroundColor3 = Color3.new(0, 0, 0),
			BackgroundTransparency = 0.35,
			Visible = soldOut,
			ZIndex = 8,
			[Children] = {
				Kit.corner(scope, "S"),
				Kit.stroke(scope, {
					Thickness = 4,
					Color = Kit.themed(scope, function(theme)
						return Color.lighten(theme.Color.Bad, 0.1)
					end),
				}),
				Kit.padding(scope, { 0, math.floor(height * 0.07) }),
				Text(scope, {
					Text = "SOLD OUT",
					TextSize = math.floor(height * 0.13),
					Font = "Display",
					Size = UDim2.fromScale(0, 1),
					AutoSize = "X",
					Color = Kit.themed(scope, function(theme)
						return Color.lighten(theme.Color.Bad, 0.15)
					end),
					Stroke = false,
					ZIndex = 9,
				}),
			},
		},
		props[Children],
	}

	local card = scope:New "TextButton" (root) :: TextButton

	-- scale-sized cards: measure once the card is in place (Out keeps it
	-- updated afterwards)
	if size.X.Scale ~= 0 then
		task.defer(function()
			if card.Parent then
				local abs = card.AbsoluteSize
				if abs.X > 0 and abs.Y > 0 and abs ~= peek(measured) then
					measured:set(abs)
				end
			end
		end)
	end

	if shineOn then
		table.insert(scope, Motion.shine(art, { ZIndex = 6, Transparency = 0.72, Interval = 3.5, Duration = 0.9, Width = 0.12 }))
	end
	if props.Sparkles then
		table.insert(scope, Particles.sparkles(art, { Rate = 3, Size = 30 }))
	end

	return card
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="133">
            <Properties>
              <string name="Name">ProfileCard</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	ProfileCard: a player card. Toned banner, a round avatar that overlaps
	it, a level badge, name and title, a grid of stat tiles and badge chips.

	scope:ProfileCard {
		Name = "Builderman",
		Title = "Master Trader",           -- optional tag under the name
		Level = 42,                        -- number or state (optional)
		Avatar = "person",                 -- icon name or image (e.g. an rbxthumb:// headshot)
		Tone = "Blue",                     -- banner colour
		Banner = "crown",                  -- optional big faint icon on the banner
		Stats = {                          -- { label, icon, value } (value may be a state)
			{ label = "Wins", icon = "trophy", value = "1,204" },
			{ label = "K/D", icon = "target", value = "2.4" },
			{ label = "Playtime", icon = "timer", value = "88h" },
		},
		Badges = {                         -- { icon, label }
			{ icon = "crown", label = "Top 100" },
			{ icon = "star", label = "Early Player" },
		},
		Width = 460,                       -- height fits the content
		InstanceName = "MyCard",           -- optional instance name (default "ProfileCard")
	}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

local HEADER = 118
local AVATAR = 132
local PAD = 24
local GAP = 12
local TILE_H = 104
local CHIP_H = 46

local function field(item: { [string]: any }, key: string): any
	local value = item[key]
	if value == nil then
		value = item[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

-- Soft shadow that follows the theme's shadow style when the kit supports it.
local function elevation(scope: Scope): any
	local kit = Kit :: any
	if kit.elevation then
		return kit.elevation(scope, { Radius = "L", Spread = 22, Offset = Vector2.new(0, 10) })
	end
	return Kit.shadow(scope, { Spread = 22, Offset = Vector2.new(0, 10) })
end

local function statTile(scope: Scope, stat: { [string]: any }, tone: any, index: number, width: number): Frame
	return scope:New "Frame" {
		Name = `Stat_{field(stat, "label") or index}`,
		LayoutOrder = index,
		Size = UDim2.fromOffset(width, TILE_H),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Surface
		end),
		BackgroundTransparency = 0.35,
		ZIndex = 3,
		[Children] = {
			Kit.corner(scope, "M"),
			Kit.stroke(scope, {
				Thickness = 1.5,
				Transparency = 0.6,
				Color = Kit.themed(scope, function(theme)
					return theme.Color.Glass or theme.Color.Light
				end),
			}),
			Kit.gradient(scope, {
				Rotation = 90,
				Transparency = NumberSequence.new(0, 0.35),
			}),
			Icon(scope, {
				Icon = field(stat, "icon") or "star",
				Size = 30,
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, 10),
				Color = scope:Computed(function(use)
					return Color.lighten(use(tone).Top, 0.15)
				end),
				StrokeColor = scope:Computed(function(use)
					return use(tone).Stroke or use(tone).Edge
				end),
				Gradient = true,
				ZIndex = 4,
			}),
			Text(scope, {
				Name = "Value",
				Text = scope:Computed(function(use)
					local value = use(field(stat, "value"))
					return if value == nil then "-" else tostring(value)
				end),
				TextSize = 30,
				Font = "Display",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, 42),
				Size = UDim2.new(1, -12, 0, 32),
				Scaled = true,
				Wrapped = false, -- single line: shrink, never break a word
				MaxTextSize = 30,
				StrokeColor = Kit.themed(scope, function(theme)
					return theme.Color.Outline
				end),
				ZIndex = 4,
			}),
			Text(scope, {
				Name = "Label",
				Text = field(stat, "label") or "",
				TextSize = 17,
				Font = "Body",
				Stroke = false,
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(0.5, 0, 1, -8),
				Size = UDim2.new(1, -12, 0, 20),
				Truncate = true,
				Color = Kit.themed(scope, function(theme)
					return theme.Color.TextMuted
				end),
				ZIndex = 4,
			}),
		},
	}
end

local function badgeChip(scope: Scope, badge: { [string]: any }, index: number, width: number): Frame
	local badgeTone = field(badge, "tone")
	local color = scope:Computed(function(use)
		local theme = use(Theme.current)
		if badgeTone then
			return Theme.tone(theme, badgeTone).Top
		end
		return theme.Color.Accent
	end)
	return scope:New "Frame" {
		Name = `Badge_{field(badge, "label") or index}`,
		LayoutOrder = index,
		Size = UDim2.fromOffset(width, CHIP_H),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.SurfaceLight
		end),
		BackgroundTransparency = 0.25,
		ZIndex = 3,
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.stroke(scope, {
				Thickness = 1.5,
				Color = color,
				Transparency = 0.45,
			}),
			scope:New "Frame" {
				Name = "IconDisc",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 5, 0.5, 0),
				Size = UDim2.fromOffset(CHIP_H - 10, CHIP_H - 10),
				BackgroundColor3 = color,
				BackgroundTransparency = 0.78,
				ZIndex = 4,
				[Children] = {
					Kit.corner(scope, "Full"),
					Icon(scope, {
						Icon = field(badge, "icon") or "star",
						Size = 24,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = color,
						Gradient = true,
						Stroke = 1.5,
						ZIndex = 5,
					}),
				},
			},
			Text(scope, {
				Name = "Label",
				Text = field(badge, "label") or "",
				TextSize = 19,
				Font = "Heavy",
				Stroke = false,
				XAlign = "Left",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, CHIP_H + 2, 0.5, 0),
				Size = UDim2.new(1, -(CHIP_H + 14), 0, 24),
				Truncate = true,
				ZIndex = 4,
			}),
		},
	}
end

local function gridHeight(count: number, columns: number, cell: number, gap: number): number
	if count <= 0 then
		return 0
	end
	local rows = math.ceil(count / columns)
	return rows * cell + (rows - 1) * gap
end

local function ProfileCard(scope: Scope, props: { [any]: any }): Frame
	local width = props.Width or 460
	local stats = props.Stats or {}
	local badges = props.Badges or {}
	local tone = Kit.tone(scope, props.Tone, "Blue")
	local hasTitle = props.Title ~= nil
	local hasLevel = props.Level ~= nil
	local inner = width - PAD * 2

	-- vertical layout (design px)
	local nameY = HEADER + AVATAR / 2 + 10
	local titleY = nameY + 46
	local y = if hasTitle then titleY + 34 + 18 else nameY + 46 + 14

	local statColumns = math.clamp(#stats, 1, 3)
	local tileWidth = math.floor((inner - GAP * (statColumns - 1)) / statColumns)
	local statsY = y
	local statsH = gridHeight(#stats, statColumns, TILE_H, GAP)
	if statsH > 0 then
		y += statsH + 20
	end

	local badgesY = y
	local chipWidth = math.floor((inner - GAP) / 2)
	local badgesH = gridHeight(#badges, 2, CHIP_H, 10)
	if badgesH > 0 then
		y += 26 + badgesH + 20
	end
	local height = y + PAD - 20

	local tiles = {}
	for index, stat in stats do
		table.insert(tiles, statTile(scope, stat, tone, index, tileWidth))
	end
	local chips = {}
	for index, badge in badges do
		table.insert(chips, badgeChip(scope, badge, index, chipWidth))
	end

	local header = scope:New "Frame" {
		Name = "Banner",
		Size = UDim2.new(1, 0, 0, HEADER),
		BackgroundColor3 = WHITE,
		ClipsDescendants = true,
		ZIndex = 2,
		[Children] = {
			Kit.corner(scope, "L"),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = scope:Computed(function(use)
					local t = use(tone)
					return ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.1)),
						ColorSequenceKeypoint.new(0.6, t.Bottom),
						ColorSequenceKeypoint.new(1, t.Edge:Lerp(t.Bottom, 0.45)),
					})
				end),
			}),
			scope:New "Frame" {
				Name = "Gloss",
				BackgroundColor3 = WHITE,
				BackgroundTransparency = 0.8,
				Size = UDim2.new(1, 0, 0.45, 0),
				ZIndex = 2,
				[Children] = {
					Kit.corner(scope, "L"),
					Kit.gradient(scope, { Transparency = NumberSequence.new(0, 1) }),
				},
			},
			Icon(scope, {
				Name = "Watermark",
				Icon = props.Banner or "star",
				Size = 150,
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, 18, 0.55, 0),
				Rotation = -14,
				Color = WHITE,
				Stroke = false,
				Transparency = 0.84,
				ZIndex = 2,
			}),
			-- squares off the banner's bottom corners (same colour as the
			-- gradient's last stop, so there is no seam)
			scope:New "Frame" {
				Name = "Fill",
				AnchorPoint = Vector2.new(0, 1),
				Position = UDim2.fromScale(0, 1),
				Size = UDim2.new(1, 0, 0, 16),
				BackgroundColor3 = scope:Computed(function(use)
					local t = use(tone)
					return t.Edge:Lerp(t.Bottom, 0.45)
				end),
				BorderSizePixel = 0,
				ZIndex = 1,
			},
			scope:New "Frame" {
				Name = "Edge",
				AnchorPoint = Vector2.new(0, 1),
				Position = UDim2.fromScale(0, 1),
				Size = UDim2.new(1, 0, 0, 4),
				BackgroundColor3 = scope:Computed(function(use)
					return use(tone).Edge
				end),
				BorderSizePixel = 0,
				ZIndex = 3,
			},
		},
	}

	local avatar = scope:New "Frame" {
		Name = "Avatar",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.new(0.5, 0, 0, HEADER),
		Size = UDim2.fromOffset(AVATAR, AVATAR),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Light
		end),
		ZIndex = 5,
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.stroke(scope, {
				Thickness = 3,
				Color = scope:Computed(function(use)
					return use(tone).Edge
				end),
			}),
			scope:New "Frame" {
				Name = "Disc",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.new(1, -14, 1, -14),
				BackgroundColor3 = WHITE,
				ClipsDescendants = true,
				ZIndex = 6,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = scope:Computed(function(use)
							local t = use(tone)
							return ColorSequence.new(Color.lighten(t.Top, 0.25), t.Bottom)
						end),
					}),
					Icon(scope, {
						Name = "Picture",
						Icon = props.Avatar or "person",
						Size = 84,
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.new(0.5, 0, 1, 6),
						Color = WHITE,
						StrokeColor = scope:Computed(function(use)
							return use(tone).Stroke or use(tone).Edge
						end),
						Gradient = true,
						ZIndex = 7,
					}),
				},
			},
			if hasLevel
				then scope:New "Frame" {
					Name = "Level",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(1, -8, 1, -12),
					Size = UDim2.fromOffset(0, 36),
					AutomaticSize = Enum.AutomaticSize.X,
					BackgroundColor3 = WHITE,
					ZIndex = 9,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.gradient(scope, {
							Rotation = 90,
							Color = Kit.themed(scope, function(theme)
								local t = theme.Tone.Yellow
								return ColorSequence.new(Color.lighten(t.Top, 0.1), t.Bottom)
							end),
						}),
						Kit.stroke(scope, {
							Thickness = 3,
							Color = Kit.themed(scope, function(theme)
								return theme.Color.Light
							end),
						}),
						Kit.padding(scope, { 0, 12 }),
						scope:New "UISizeConstraint" { MinSize = Vector2.new(52, 0) },
						Kit.list(scope, { Direction = "X", HAlign = "Center", VAlign = "Center", Padding = 0 }),
						Text(scope, {
							Name = "LevelText",
							Text = scope:Computed(function(use)
								return `Lv {use(props.Level)}`
							end),
							TextSize = 21,
							Font = "Display",
							Size = UDim2.fromScale(0, 1),
							AutoSize = "X",
							Color = Kit.themed(scope, function(theme)
								return theme.Tone.Yellow.Text
							end),
							StrokeColor = Kit.themed(scope, function(theme)
								local t = theme.Tone.Yellow
								return t.Stroke or t.Edge
							end),
							Stroke = Kit.themed(scope, function(theme)
								return if theme.Tone.Yellow.Stroke then 2 else false
							end),
							ZIndex = 10,
						}),
					},
				}
				else nil,
		},
	}

	local body = {
		header,
		avatar,
		Text(scope, {
			Name = "PlayerName",
			Text = props.Name or "Player",
			TextSize = 40,
			Font = "Title",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(0.5, 0, 0, nameY),
			Size = UDim2.new(1, -PAD * 2, 0, 44),
			Scaled = true,
			Wrapped = false, -- single line: shrink, never break a word
			MaxTextSize = 40,
			ZIndex = 4,
		}),
		if hasTitle
			then scope:New "Frame" {
				Name = "Title",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, titleY),
				Size = UDim2.fromOffset(0, 34),
				AutomaticSize = Enum.AutomaticSize.X,
				BackgroundColor3 = scope:Computed(function(use)
					return use(tone).Bottom
				end),
				BackgroundTransparency = 0.72,
				ZIndex = 4,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.stroke(scope, {
						Thickness = 1.5,
						Transparency = 0.3,
						Color = scope:Computed(function(use)
							return use(tone).Top
						end),
					}),
					Kit.padding(scope, { 0, 16 }),
					Kit.list(scope, { Direction = "X", HAlign = "Center", VAlign = "Center", Padding = 6 }),
					Text(scope, {
						Text = props.Title,
						TextSize = 19,
						Font = "Heavy",
						Stroke = false,
						Size = UDim2.fromScale(0, 1),
						AutoSize = "X",
						Color = scope:Computed(function(use)
							return Color.lighten(use(tone).Top, 0.45)
						end),
						ZIndex = 5,
					}),
				},
			}
			else nil,
		if #tiles > 0
			then scope:New "Frame" {
				Name = "Stats",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(PAD, statsY),
				Size = UDim2.fromOffset(inner, statsH),
				ZIndex = 3,
				[Children] = {
					scope:New "UIGridLayout" {
						CellSize = UDim2.fromOffset(tileWidth, TILE_H),
						CellPadding = UDim2.fromOffset(GAP, GAP),
						FillDirection = Enum.FillDirection.Horizontal,
						FillDirectionMaxCells = statColumns,
						SortOrder = Enum.SortOrder.LayoutOrder,
						HorizontalAlignment = Enum.HorizontalAlignment.Center,
						VerticalAlignment = Enum.VerticalAlignment.Top,
					},
					tiles,
				},
			}
			else nil,
		if #chips > 0
			then Text(scope, {
				Name = "BadgesHeading",
				Text = `BADGES  ·  {#chips}`,
				TextSize = 16,
				Font = "Heavy",
				Stroke = false,
				XAlign = "Left",
				Position = UDim2.fromOffset(PAD + 4, badgesY),
				Size = UDim2.fromOffset(inner, 18),
				Color = Kit.themed(scope, function(theme)
					return theme.Color.TextMuted
				end),
				ZIndex = 3,
			})
			else nil,
		if #chips > 0
			then scope:New "Frame" {
				Name = "Badges",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(PAD, badgesY + 26),
				Size = UDim2.fromOffset(inner, badgesH),
				ZIndex = 3,
				[Children] = {
					scope:New "UIGridLayout" {
						CellSize = UDim2.fromOffset(chipWidth, CHIP_H),
						CellPadding = UDim2.fromOffset(GAP, 10),
						FillDirection = Enum.FillDirection.Horizontal,
						FillDirectionMaxCells = 2,
						SortOrder = Enum.SortOrder.LayoutOrder,
						HorizontalAlignment = Enum.HorizontalAlignment.Left,
						VerticalAlignment = Enum.VerticalAlignment.Top,
					},
					chips,
				},
			}
			else nil,
	}

	-- `Name` is the player's display name, so the instance gets its own
	local root = Kit.root(props)
	root.Name = props.InstanceName or "ProfileCard"
	root.Size = props.Size or UDim2.fromOffset(width, height)
	root.BackgroundTransparency = 1
	root[Children] = {
		elevation(scope),
		scope:New "Frame" {
			Name = "Body",
			Size = UDim2.fromScale(1, 1),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.SurfaceLight
			end),
			ZIndex = 1,
			[Children] = {
				Kit.corner(scope, "L"),
				Kit.stroke(scope, {
					Thickness = 2.5,
					Color = scope:Computed(function(use)
						return use(tone).Edge:Lerp(use(Theme.current).Color.Outline, 0.5)
					end),
				}),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = Kit.themed(scope, function(theme)
						return ColorSequence.new(WHITE, Color3.fromRGB(170, 170, 185))
					end),
				}),
				body,
			},
		},
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end

return ProfileCard
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="134">
            <Properties>
              <string name="Name">ProgressBar</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	ProgressBar: health, XP, stamina, loading, anything with a fill.

	scope:ProgressBar {
		Value = health,          -- number or state
		Max = 150,               -- number or state
		Tone = "Green",
		Size = UDim2.fromOffset(360, 34),
		Label = true,            -- true = "value", or text/state, or function(value, max) -> string
		                         -- (Inset themes put it inside the fill, or just past its end)
		LabelAlign = "Right",    -- Outlined themes: "Left" | "Center" | "Right"
		LabelIcon = "heart",     -- icon after the label
		Ghost = true,            -- white trailing bar that shows recent loss
		Low = 0.25,              -- below this fraction the bar flashes (nil = off)
		LowIcon = "warning",     -- the label icon while low (a shape cue, not just colour)
		Segments = 0,            -- tick marks (e.g. 5 for chunked stamina)
		Shine = false,
		Heal = false,            -- a bright glow washes over the fill when it grows (health)
		Gloss = true,            -- false: a flat fill (no gloss band)
	}
]]

local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Util.Format)
local Color = require(script.Parent.Parent.Util.Color)
local Motion = require(script.Parent.Parent.FX.Motion)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local peek = Fusion.peek

return function(scope: Fusion.Scope<any>, props: { [any]: any }): Frame
	local max = props.Max or 100
	local tone = Kit.tone(scope, props.Tone, "Green")

	local fraction = scope:Computed(function(use)
		local total = use(max)
		if total == nil or total <= 0 then
			return 0
		end
		return math.clamp((use(props.Value) or 0) / total, 0, 1)
	end)

	local fill = scope:Spring(fraction, 18, 1)

	-- The ghost bar waits a moment before draining, so hits read clearly.
	local ghostGoal = scope:Value(peek(fraction))
	local ghost = scope:Spring(ghostGoal, 6, 1)
	local ghostToken = 0
	Kit.watch(scope, fraction, function(now)
		ghostToken += 1
		if now >= peek(ghostGoal) then
			ghostGoal:set(now)
		else
			local token = ghostToken
			task.delay(0.35, function()
				if token == ghostToken then
					ghostGoal:set(now)
				end
			end)
		end
	end)

	local height = scope:Computed(function(use)
		local size = use(props.Size)
		return if typeof(size) == "UDim2" then size.Y.Offset else 34
	end)

	local labelText = scope:Computed(function(use)
		local label = use(props.Label)
		if label == nil or label == false then
			return ""
		elseif label == true then
			return Format.comma(math.ceil(use(props.Value) or 0))
		elseif typeof(label) == "function" then
			return label(use(props.Value) or 0, use(max))
		end
		return tostring(label)
	end)

	-- Where the label goes. "Outlined" themes: white with a tinted outline,
	-- placed by LabelAlign across the whole bar. "Inset" themes (Rivals
	-- health): inside the fill, right-aligned, in a darker shade of the fill,
	-- with no outline; when the fill is too short to hold it, the label sits
	-- just past the fill's end in plain white instead of straddling it.
	local labelSize = scope:Computed(function(use)
		return math.floor(use(height) * 0.58)
	end)
	local measured = scope:Value(Vector2.zero)
	local barWidth = scope:Computed(function(use)
		local size = use(props.Size)
		if typeof(size) == "UDim2" and size.X.Scale == 0 then
			return size.X.Offset
		elseif size == nil then
			return 360
		end
		local px = use(measured).X / math.max(0.05, use(Viewport.scale))
		return if px > 1 then px else 360
	end)
	-- the fill fraction the label needs to fit inside the fill
	local insideAt = scope:Computed(function(use)
		local value = use(labelText)
		if value == "" then
			return 0
		end
		local h = use(height)
		local pad = if h >= 30 then 5 else 3
		local need = Kit.textWidth(value, use(labelSize), "Display", use(Theme.current)) + 18
		if props.LabelIcon then
			need += math.floor(h * 0.56) + 4
		end
		return need / math.max(1, use(barWidth) - pad * 2)
	end)
	local mode = scope:Computed(function(use)
		if Theme.style(use(Theme.current), "BarLabel") ~= "Inset" then
			return "Outlined"
		end
		return if use(fill) >= use(insideAt) then "Inside" else "Outside"
	end)
	local labelColor = scope:Computed(function(use)
		local theme = use(Theme.current)
		if use(mode) == "Inside" then
			return use(tone).Edge
		end
		return theme.Color.Text
	end)

	-- Heal: a gain washes the fill with a bright glow that fades out
	local healGlow = nil
	if props.Heal then
		local heal = scope:Spring(scope:Value(1), 7, 1)
		local before = peek(fraction)
		Kit.watch(scope, fraction, function(now)
			if now > before + 0.001 then
				heal:setPosition(0)
			end
			before = now
		end)
		healGlow = scope:New "Frame" {
			Name = "HealGlow",
			Size = UDim2.fromScale(1, 1),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return (theme.Color.Good or Color3.new(1, 1, 1)):Lerp(Color3.new(1, 1, 1), 0.55)
			end),
			BackgroundTransparency = scope:Computed(function(use)
				return 0.2 + 0.8 * math.clamp(use(heal), 0, 1)
			end),
			ZIndex = 5,
			[Children] = Kit.corner(scope, props.Radius or "Full"),
		}
	end

	local fillFrame = scope:New "Frame" {
		Name = "Fill",
		Size = scope:Computed(function(use)
			return UDim2.fromScale(use(fill), 1)
		end),
		BackgroundColor3 = Color3.new(1, 1, 1),
		ZIndex = 3,
		[Children] = {
			Kit.corner(scope, props.Radius or "Full"),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = scope:Computed(function(use)
					local t = use(tone)
					return ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.12)),
						ColorSequenceKeypoint.new(0.6, t.Top:Lerp(t.Bottom, 0.6)),
						ColorSequenceKeypoint.new(1, t.Bottom),
					})
				end),
			}),
			if props.Gloss ~= false
				then scope:New "Frame" {
					Name = "Gloss",
					BackgroundColor3 = Color3.new(1, 1, 1),
					BackgroundTransparency = 0.55,
					Position = UDim2.new(0, 3, 0, 2),
					Size = UDim2.new(1, -6, 0.36, 0),
					ZIndex = 4,
					[Children] = {
						Kit.corner(scope, props.Radius or "Full"),
						Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0.1, 0.9) }),
					},
				}
				else nil,
			healGlow,
		},
	}

	local segments = {}
	local count = props.Segments or 0
	for i = 1, count - 1 do
		table.insert(segments, scope:New "Frame" {
			Name = "Segment",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(i / count, 0),
			Size = UDim2.new(0, 3, 1, 0),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Outline
			end),
			BackgroundTransparency = 0.3,
			ZIndex = 5,
		})
	end

	local root = Kit.root(props, { Name = "ProgressBar" })
	root.Size = props.Size or UDim2.fromOffset(360, 34)
	-- tall bars (health) sit in an opaque pill frame; thin ones on a track
	root.BackgroundColor3 = scope:Computed(function(use)
		local theme = use(Theme.current)
		if use(height) >= 30 and theme.Color.Pill then
			return theme.Color.Pill
		end
		return theme.Color.Track
	end)
	root.BackgroundTransparency = Kit.themed(scope, function(theme)
		return theme.Alpha.Track
	end)
	local givenSize = peek(props.Size)
	if typeof(givenSize) == "UDim2" and givenSize.X.Scale ~= 0 then
		root[Fusion.Out "AbsoluteSize"] = measured
	end
	root[Children] = {
		Kit.corner(scope, props.Radius or "Full"),
		Kit.stroke(scope, {
			Color = props.StrokeColor or Kit.themed(scope, function(theme)
				return theme.Color.Outline
			end),
			Thickness = Kit.themed(scope, function(theme)
				return theme.Stroke.UI + 0.5
			end),
		}),
		scope:New "Frame" {
			Name = "Inner",
			BackgroundTransparency = 1,
			Position = scope:Computed(function(use)
				local pad = if use(height) >= 30 then 5 else 3
				return UDim2.fromOffset(pad, pad)
			end),
			Size = scope:Computed(function(use)
				local pad = if use(height) >= 30 then 5 else 3
				return UDim2.new(1, -pad * 2, 1, -pad * 2)
			end),
			ClipsDescendants = false,
			[Children] = {
				if props.Ghost ~= false
					then scope:New "Frame" {
						Name = "Ghost",
						Size = scope:Computed(function(use)
							return UDim2.fromScale(math.max(use(ghost), use(fill)), 1)
						end),
						BackgroundColor3 = Color3.new(1, 1, 1),
						BackgroundTransparency = 0.25,
						ZIndex = 2,
						[Children] = { Kit.corner(scope, props.Radius or "Full") },
					}
					else nil,
				fillFrame,
				segments,
				scope:New "Frame" {
					Name = "Label",
					BackgroundTransparency = 1,
					Position = scope:Computed(function(use)
						local m = use(mode)
						if m == "Inside" then
							return UDim2.fromOffset(0, 0)
						elseif m == "Outside" then
							return UDim2.new(math.clamp(use(fill), 0, 1), 8, 0, 0)
						end
						return UDim2.fromOffset(8, 0)
					end),
					Size = scope:Computed(function(use)
						local m = use(mode)
						local f = math.clamp(use(fill), 0, 1)
						if m == "Inside" then
							return UDim2.new(f, -10, 1, 0)
						elseif m == "Outside" then
							return UDim2.new(1 - f, -8, 1, 0)
						end
						return UDim2.new(1, -16, 1, 0)
					end),
					ZIndex = 6,
					Visible = scope:Computed(function(use)
						return use(labelText) ~= ""
					end),
					[Children] = {
						scope:New "UIListLayout" {
							FillDirection = Enum.FillDirection.Horizontal,
							SortOrder = Enum.SortOrder.LayoutOrder,
							Padding = UDim.new(0, 4),
							VerticalAlignment = Enum.VerticalAlignment.Center,
							HorizontalAlignment = scope:Computed(function(use)
								local m = use(mode)
								if m == "Inside" then
									return Enum.HorizontalAlignment.Right
								elseif m == "Outside" then
									return Enum.HorizontalAlignment.Left
								end
								return Enum.HorizontalAlignment[props.LabelAlign or "Right"]
							end),
						},
						Text(scope, {
							Text = labelText,
							TextSize = labelSize,
							Font = "Display",
							Color = labelColor,
							StrokeColor = scope:Computed(function(use)
								local t = use(tone)
								return t.Stroke or t.Deep
							end),
							Stroke = scope:Computed(function(use)
								if use(mode) ~= "Outlined" then
									return false
								end
								return math.max(1.5, use(Theme.current).Stroke.Text)
							end),
							Case = "Normal",
							LayoutOrder = 1,
							ZIndex = 7,
						}),
						if props.LabelIcon
							then Icon(scope, {
								-- LowIcon: a shape cue for the low state, so it
								-- never rests on colour alone
								Icon = if props.LowIcon and props.Low
									then scope:Computed(function(use)
										local f = use(fraction)
										return if f > 0 and f <= props.Low then props.LowIcon else props.LabelIcon
									end)
									else props.LabelIcon,
								Size = scope:Computed(function(use)
									return math.floor(use(height) * 0.56)
								end),
								Color = labelColor,
								Stroke = scope:Computed(function(use)
									return if use(mode) == "Outlined" then nil else false
								end),
								StrokeColor = scope:Computed(function(use)
									local t = use(tone)
									return t.Stroke or t.Deep
								end),
								LayoutOrder = 2,
								ZIndex = 7,
							})
							else nil,
					},
				},
			},
		},
		props[Children],
	}

	local bar = scope:New "Frame" (root) :: Frame

	if props.Shine then
		table.insert(scope, Motion.shine(fillFrame, { ZIndex = 5, Transparency = 0.5, Interval = 3 }))
	end

	if props.Low then
		local flash = scope:New "Frame" {
			Name = "LowFlash",
			Size = UDim2.fromScale(1, 1),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Bad
			end),
			BackgroundTransparency = 1,
			ZIndex = 4,
			Parent = fillFrame,
			[Children] = { Kit.corner(scope, props.Radius or "Full") },
		}
		local stop: (() -> ())? = nil
		local function update(value: number)
			local low = value > 0 and value <= props.Low
			if low and stop == nil then
				local loop = TweenService:Create(
					flash,
					TweenInfo.new(0.35, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true),
					{ BackgroundTransparency = 0.25 }
				)
				loop:Play()
				-- reduced motion: a steady tint instead of the flashing
				local untrack = Motion.track(loop, flash, function()
					flash.BackgroundTransparency = 0.55
				end)
				stop = function()
					untrack()
					loop:Cancel()
					flash.BackgroundTransparency = 1
				end
			elseif not low and stop then
				stop()
				stop = nil
			end
		end
		update(peek(fraction))
		Kit.watch(scope, fraction, update)
		table.insert(scope, function()
			if stop then
				stop()
			end
		end)
	end

	return bar
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="135">
            <Properties>
              <string name="Name">QuestCard</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	QuestCard: one quest / mission row. Icon tile, title, description, a
	progress bar with "3 / 5", the reward, and a Claim button that lights up
	(pulse + shine) when the quest is done. Claimed quests dim with a check.

	scope:QuestCard {
		Title = "Hatch 5 eggs",
		Description = "Open any egg in the world",
		Icon = "egg",
		Progress = hatched,            -- number or state
		Goal = 5,
		Reward = { Icon = "gem", Amount = 250 },
		Claimed = claimed,             -- bool or Value<bool> (set to true on claim when it's a Value)
		OnClaim = function() end,
		Tone = "Blue",                 -- icon tile + bar while in progress (turns green when done)
		Size = UDim2.fromOffset(680, 140),
	}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Util.Format)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Particles = require(script.Parent.Parent.FX.Particles)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Button = require(script.Parent.Button)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

local function QuestCard(scope: Scope, props: { [any]: any }): Frame
	local goal = props.Goal or 1
	local claimed = props.Claimed or scope:Value(false)
	local reward = props.Reward

	local size = peek(props.Size) or UDim2.fromOffset(680, 140)
	local height = if typeof(size) == "UDim2" and size.Y.Offset > 0 then size.Y.Offset else 140
	local pad = 16
	local tile = height - pad * 2
	local left = pad + tile + 16
	local rightWidth = 176
	local buttonHeight = math.floor(math.clamp(height * 0.43, 48, 64))

	local progress = scope:Computed(function(use)
		return math.clamp(tonumber(use(props.Progress)) or 0, 0, math.max(1, tonumber(use(goal)) or 1))
	end)
	local goalNow = scope:Computed(function(use)
		return math.max(1, tonumber(use(goal)) or 1)
	end)
	local done = scope:Computed(function(use)
		return use(progress) >= use(goalNow)
	end)
	local isClaimed = scope:Computed(function(use)
		return use(claimed) == true
	end)
	local claimable = scope:Computed(function(use)
		return use(done) and not use(isClaimed)
	end)

	local tone = scope:Computed(function(use)
		local theme = use(Theme.current)
		if use(done) then
			return theme.Tone.Green
		end
		return Theme.tone(theme, use(props.Tone) or "Blue")
	end)
	local strokeColor = scope:Computed(function(use)
		local t = use(tone)
		return t.Stroke or t.Edge
	end)

	local fraction = scope:Spring(scope:Computed(function(use)
		return use(progress) / use(goalNow)
	end), 14, 1)

	local claimButton: GuiObject? = nil
	local function claim()
		if not peek(claimable) then
			return
		end
		Sound.play("Reward")
		if claimButton then
			Particles.burst(claimButton, { Count = 18, Shape = "sparkle", Size = 22, Speed = 420 })
		end
		if typeof(claimed) == "table" and claimed.set then
			claimed:set(true)
		end
		if props.OnClaim then
			props.OnClaim()
		end
	end

	claimButton = Button(scope, {
		Name = "Claim",
		Text = "CLAIM",
		Tone = "Green",
		Disabled = scope:Computed(function(use)
			return not use(claimable)
		end),
		Shine = true,
		Pulse = claimable,
		Visible = scope:Computed(function(use)
			return not use(isClaimed)
		end),
		AnchorPoint = Vector2.new(1, 1),
		Position = UDim2.new(1, -pad, 1, -pad),
		Size = UDim2.fromOffset(rightWidth, buttonHeight),
		TextSize = math.floor(buttonHeight * 0.46),
		Sound = "Reward",
		OnClick = claim,
		ZIndex = 6,
	})

	local card = Kit.root(props, { Name = props.Title or "QuestCard" })
	card.Size = size
	card.BackgroundColor3 = Kit.themed(scope, function(theme)
		return theme.Color.SurfaceLight
	end)
	card.BackgroundTransparency = Kit.themed(scope, function(theme)
		return math.clamp(theme.Alpha.Surface * 0.9, 0, 0.6)
	end)
	card[Children] = {
		Kit.corner(scope, "L"),
		Kit.stroke(scope, {
			Thickness = scope:Computed(function(use)
				return if use(claimable) then 3 else 2
			end),
			Transparency = scope:Computed(function(use)
				return if use(claimable) then 0 else 0.6
			end),
			Color = scope:Computed(function(use)
				local theme = use(Theme.current)
				return if use(claimable) then theme.Tone.Green.Top else theme.Color.Light
			end),
		}),
		scope:New "Frame" {
			Name = "Highlight",
			BackgroundColor3 = WHITE,
			BackgroundTransparency = 0.9,
			Size = UDim2.fromScale(1, 0.5),
			ZIndex = 1,
			[Children] = {
				Kit.corner(scope, "L"),
				Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0, 1) }),
			},
		},
		-- icon tile
		scope:New "Frame" {
			Name = "IconTile",
			Position = UDim2.fromOffset(pad, pad),
			Size = UDim2.fromOffset(tile, tile),
			BackgroundColor3 = WHITE,
			ZIndex = 2,
			[Children] = {
				Kit.corner(scope, "M"),
				Kit.gradient(scope, {
					Rotation = 75,
					Color = scope:Computed(function(use)
						local t = use(tone)
						return ColorSequence.new({
							ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.12)),
							ColorSequenceKeypoint.new(0.55, t.Bottom),
							ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.3)),
						})
					end),
				}),
				Kit.stroke(scope, {
					Thickness = 3,
					Color = scope:Computed(function(use)
						return use(tone).Edge
					end),
				}),
				scope:New "Frame" {
					Name = "Gloss",
					BackgroundColor3 = WHITE,
					BackgroundTransparency = 0.5,
					Position = UDim2.fromOffset(3, 3),
					Size = UDim2.new(1, -6, 0.42, 0),
					ZIndex = 3,
					[Children] = {
						Kit.corner(scope, "M"),
						Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0.2, 1) }),
					},
				},
				Icon(scope, {
					Icon = props.Icon or "quest",
					Size = math.floor(tile * 0.62),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Color = scope:Computed(function(use)
						return Color.lighten(use(tone).Top, 0.6)
					end),
					Gradient = true,
					Shadow = true,
					Stroke = 3,
					StrokeColor = strokeColor,
					ZIndex = 4,
				}),
				-- done: a check badge on the tile, so "finished" never rests on
				-- the green alone (colourblind players)
				scope:New "Frame" {
					Name = "DoneBadge",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(1, -6, 0, 6),
					Size = UDim2.fromOffset(math.floor(tile * 0.36), math.floor(tile * 0.36)),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Tone.Green.Bottom
					end),
					Visible = done,
					ZIndex = 6,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.stroke(scope, {
							Thickness = 3,
							Color = Kit.themed(scope, function(theme)
								return theme.Color.Light
							end),
						}),
						Icon(scope, {
							Icon = "check",
							Size = math.floor(tile * 0.24),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = WHITE,
							Stroke = 2,
							StrokeColor = Kit.themed(scope, function(theme)
								return theme.Tone.Green.Edge
							end),
							ZIndex = 7,
						}),
					},
				},
			},
		},
		Text(scope, {
			Name = "Title",
			Text = props.Title or "Quest",
			TextSize = 28,
			Font = "Title",
			XAlign = "Left",
			Position = UDim2.fromOffset(left, pad - 2),
			Size = UDim2.new(1, -(left + rightWidth + pad * 2), 0, 34),
			Truncate = true,
			ZIndex = 3,
		}),
		Text(scope, {
			Name = "Description",
			Text = props.Description or "",
			TextSize = 20,
			Font = "Body",
			XAlign = "Left",
			Position = UDim2.fromOffset(left, pad + 34),
			Size = UDim2.new(1, -(left + rightWidth + pad * 2), 0, 24),
			Truncate = true,
			Color = Kit.themed(scope, function(theme)
				return theme.Color.TextMuted
			end),
			Stroke = false,
			ZIndex = 3,
		}),
		scope:New "Frame" {
			Name = "Track",
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, left, 1, -pad - 2),
			Size = UDim2.new(1, -(left + rightWidth + pad * 2), 0, 26),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Track
			end),
			BackgroundTransparency = Kit.themed(scope, function(theme)
				return theme.Alpha.Track
			end),
			ZIndex = 3,
			[Children] = {
				Kit.corner(scope, "Full"),
				scope:New "Frame" {
					Name = "Fill",
					Position = UDim2.fromOffset(3, 3),
					Size = scope:Computed(function(use)
						return UDim2.new(math.clamp(use(fraction), 0, 1), -6, 1, -6)
					end),
					Visible = scope:Computed(function(use)
						return use(fraction) > 0.01
					end),
					BackgroundColor3 = WHITE,
					ZIndex = 4,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.gradient(scope, {
							Rotation = 90,
							Color = scope:Computed(function(use)
								local t = use(tone)
								return ColorSequence.new(Color.lighten(t.Top, 0.15), t.Bottom)
							end),
						}),
						scope:New "Frame" {
							Name = "Gloss",
							BackgroundColor3 = WHITE,
							BackgroundTransparency = 0.6,
							Position = UDim2.fromOffset(4, 2),
							Size = UDim2.new(1, -8, 0.4, 0),
							ZIndex = 5,
							[Children] = Kit.corner(scope, "Full"),
						},
					},
				},
				Text(scope, {
					Name = "Count",
					Text = scope:Computed(function(use)
						return `{Format.comma(use(progress))} / {Format.comma(use(goalNow))}`
					end),
					TextSize = 18,
					Font = "Display",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					ZIndex = 6,
				}),
			},
		},
		-- reward chip (top right)
		if reward
			then scope:New "Frame" {
				Name = "Reward",
				AnchorPoint = Vector2.new(1, 0),
				Position = UDim2.new(1, -pad, 0, pad),
				Size = UDim2.fromOffset(rightWidth, height - buttonHeight - pad * 2 - 8),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Surface
				end),
				BackgroundTransparency = 0.35,
				ZIndex = 3,
				[Children] = {
					Kit.corner(scope, "M"),
					Kit.list(scope, { Direction = "X", Padding = 6, HAlign = "Center", VAlign = "Center" }),
					Icon(scope, {
						LayoutOrder = 1,
						Icon = reward.Icon or reward.icon or "gift",
						Size = 36,
						Rotation = -10,
						Color = reward.Color or Kit.themed(scope, function(theme)
							return theme.Tone.Yellow.Top
						end),
						Gradient = true,
						Shadow = true,
						ZIndex = 4,
					}),
					Text(scope, {
						LayoutOrder = 2,
						Case = "Normal",
						Text = scope:Computed(function(use)
							local amount = use(reward.Amount or reward.amount)
							if typeof(amount) == "number" then
								return `x{Format.abbrev(amount)}`
							end
							return tostring(amount or "")
						end),
						TextSize = 26,
						Font = "Display",
						ZIndex = 4,
					}),
				},
			}
			else nil,
		claimButton,
		scope:New "Frame" {
			Name = "ClaimedBadge",
			AnchorPoint = Vector2.new(1, 1),
			Position = UDim2.new(1, -pad, 1, -pad),
			Size = UDim2.fromOffset(rightWidth, buttonHeight),
			BackgroundTransparency = 1,
			Visible = isClaimed,
			ZIndex = 6,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 8, HAlign = "Center", VAlign = "Center" }),
				Icon(scope, {
					LayoutOrder = 1,
					Icon = "check",
					Size = 34,
					Color = Kit.themed(scope, function(theme)
						return theme.Tone.Green.Top
					end),
					StrokeColor = Kit.themed(scope, function(theme)
						return theme.Tone.Green.Edge
					end),
					Stroke = 3,
					ZIndex = 7,
				}),
				Text(scope, {
					LayoutOrder = 2,
					Text = "CLAIMED",
					TextSize = 24,
					Font = "Display",
					Color = Kit.themed(scope, function(theme)
						return theme.Tone.Green.Top
					end),
					StrokeColor = Kit.themed(scope, function(theme)
						return theme.Tone.Green.Edge
					end),
					ZIndex = 7,
				}),
			},
		},
		scope:New "Frame" {
			Name = "Dim",
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Backdrop
			end),
			BackgroundTransparency = 0.5,
			Size = UDim2.fromScale(1, 1),
			Visible = isClaimed,
			ZIndex = 5,
			[Children] = Kit.corner(scope, "L"),
		},
		props[Children],
	}

	return scope:New "Frame" (card) :: Frame
end

return QuestCard
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="136">
            <Properties>
              <string name="Name">QuestTracker</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	QuestTracker: a task list with progress bars, reward icons and a
	satisfying check-off animation.

	local targets = scope:Value(2)
	scope:QuestTracker {
		Title = "Beginner Tasks",
		Icon = "quest",
		Tasks = {                        -- a table or a state holding one
			{ Text = "Go to Shooting Range", Icon = "flag", Progress = 1, Goal = 1 },
			{ Text = "Break targets", Icon = "target", Progress = targets, Goal = 5,
			  Reward = { Icon = "key", Amount = 1 } },
			{ Text = "Run + crouch to Slide", Icon = "run", Progress = slid, Goal = 1 },
		},
		OnComplete = function(task) end,
		Width = 340,
		Compact = false,                 -- true: smaller rows (phones)
		Entrance = true,                 -- rows slide in one after another
	}

	Arena themes draw RIVALS' faded edge panel (dark at the screen edge,
	fading out towards the centre); other themes a glass card. A finished
	task fills its bar green first, then a bold check pops in with a flash.

	HUD kit looks (Style, with Kit = a HudKits name / tokens):
		"Panel"      the panel above (the original)
		"Card"       a card; each task has a small progress ring round its icon
		"Ticker"     one line: the current task, its count and a thin bar
		"Checklist"  checkboxes; finished tasks are ticked and struck through
		"Waypoint"   a banner with a marker for the current task
	Every look sets a HudSize attribute (its footprint in a HUD layout).
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Sound = require(script.Parent.Parent.Util.Sound)
local Format = require(script.Parent.Parent.Util.Format)
local Color = require(script.Parent.Parent.Util.Color)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local P = require(script.Parent.HudParts)
local Motion = require(script.Parent.Parent.FX.Motion)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

-- A chunky outlined check mark built from two rounded bars (the Builder
-- Icons check is a thin line; RIVALS' is a fat sticker check).
local function CheckMark(scope: Scope, size: number, props: { [string]: any })
	local fill = Kit.themed(scope, function(theme)
		return theme.Color.Good or theme.Tone.Green.Top
	end)
	local edge = Kit.themed(scope, function(theme)
		local green = theme.Tone.Green
		return green.Deep:Lerp(green.Edge, 0.35)
	end)
	local thick = math.max(4, math.floor(size * 0.2 + 0.5))
	local outline = math.max(2, math.floor(size * 0.06 + 0.5))
	-- two arms in a 1x1 box: A (0.1, 0.52) -> B (0.38, 0.8) -> C (0.92, 0.18)
	local arms = {
		{ cx = 0.24, cy = 0.66, length = 0.396, angle = 45 },
		{ cx = 0.65, cy = 0.49, length = 0.822, angle = -48.9 },
	}
	local parts = {}
	for layer = 1, 2 do
		local isOutline = layer == 1
		for index, arm in arms do
			local grow = if isOutline then outline * 2 else 0
			table.insert(parts, scope:New "Frame" {
				Name = if isOutline then `Edge{index}` else `Arm{index}`,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(arm.cx, arm.cy),
				Size = UDim2.fromOffset(arm.length * size + thick + grow, thick + grow),
				Rotation = arm.angle,
				BackgroundColor3 = if isOutline then edge else fill,
				BorderSizePixel = 0,
				ZIndex = (props.ZIndex or 4) + (if isOutline then 0 else 1),
				[Children] = {
					scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
					if isOutline
						then nil
						else scope:New "UIGradient" {
							Rotation = 90,
							Color = scope:Computed(function(use)
								local c = use(fill)
								return ColorSequence.new(Color.lighten(c, 0.3), c)
							end),
						},
				},
			})
		end
	end
	return scope:New "Frame" {
		Name = props.Name or "Check",
		BackgroundTransparency = 1,
		AnchorPoint = props.AnchorPoint or Vector2.new(0.5, 0.5),
		Position = props.Position,
		Size = UDim2.fromOffset(size, size),
		Visible = props.Visible,
		ZIndex = props.ZIndex or 4,
		[Children] = { parts, props[Children] },
	}
end

local function Row(scope: Scope, props: { [any]: any }, quest: { [string]: any }, index: number)
	local compact = props.Compact == true
	local rowHeight = if compact then 44 else 58
	local textY = if compact then 15 else 20
	local textSize = if compact then 18 else 23
	local barY = if compact then 29 else 38
	local iconSize = if compact then 24 else 30
	local checkSize = if compact then 30 else 40

	local goal = quest.Goal or 1
	local progress = scope:Computed(function(use)
		return math.clamp(use(quest.Progress) or 0, 0, use(goal))
	end)
	local done = scope:Computed(function(use)
		return use(progress) >= use(goal)
	end)
	local fraction = scope:Spring(scope:Computed(function(use)
		return use(progress) / math.max(1, use(goal))
	end), 16, 1)

	-- the check waits for the bar to fill before it pops (bar, then ✔, then tick)
	local checkShown = scope:Value(peek(done))
	local checkPopGoal = scope:Value(1)
	local checkPop = scope:Spring(checkPopGoal, 35, 0.5)
	local flash = scope:Value(1)
	local flashTween = scope:Tween(flash, TweenInfo.new(0.6, Enum.EasingStyle.Quad, Enum.EasingDirection.Out))

	local token = 0
	Kit.watch(scope, done, function(now)
		token += 1
		if now then
			local mine = token
			if props.OnComplete then
				props.OnComplete(quest)
			end
			task.delay(0.25, function()
				if mine ~= token then
					return
				end
				checkShown:set(true)
				checkPop:setPosition(0.2)
				checkPop:addVelocity(12)
				flash:set(0.4)
				task.delay(0.08, function()
					flash:set(1)
				end)
				Sound.play("Reward")
				Sound.play("Tick", { Pitch = 1.4, Volume = 0.5 })
			end)
		else
			checkShown:set(false)
		end
	end)

	-- entrance: slide in from the left, one row after another
	local slideGoal = scope:Value(0)
	local slide = scope:Spring(slideGoal, 22, 0.8)
	if props.Entrance ~= false then
		slide:setPosition(-60)
		slideGoal:set(-60)
		task.delay(0.08 + index * 0.07, function()
			slideGoal:set(0)
		end)
	end

	local reward = quest.Reward
	local showCount = scope:Computed(function(use)
		return use(goal) > 1
	end)
	local rightColumn = if compact then 40 else 52

	return scope:New "Frame" {
		Name = `Task{index}`,
		LayoutOrder = index + 1,
		Size = UDim2.new(1, 0, 0, rowHeight),
		BackgroundTransparency = 1,

		[Children] = scope:New "Frame" {
			Name = "Content",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			Position = scope:Computed(function(use)
				return UDim2.fromOffset(use(slide), 0)
			end),
			[Children] = {
				scope:New "Frame" {
					Name = "Flash",
					Size = UDim2.new(1, 8, 1, 0),
					Position = UDim2.fromOffset(-4, 0),
					BackgroundColor3 = WHITE,
					BackgroundTransparency = flashTween,
					ZIndex = 1,
					[Children] = { Kit.corner(scope, "S") },
				},
				Icon(scope, {
					Icon = quest.Icon or "check",
					Size = iconSize,
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 0, 0, textY + 4),
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Text
					end),
					ZIndex = 3,
				}),
				Text(scope, {
					Text = quest.Text,
					TextSize = textSize,
					Font = "Body",
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, iconSize + 10, 0, textY),
					Size = scope:Computed(function(use)
						local reserve = 6
						if use(showCount) then
							-- room for "2K / 2K" in this theme's face (wide typewriter
							-- faces need more than the usual 58)
							local theme = use(Theme.current)
							local goalText = Format.abbrev(use(goal))
							local counted = Kit.textWidth(`{goalText} `, textSize, "Title", theme)
								+ Kit.textWidth(`/ {goalText}`, math.floor(textSize * 0.66), "Title", theme)
							reserve = math.max(58, math.ceil(counted) + 10)
						end
						return UDim2.new(1, -(iconSize + 10) - rightColumn - reserve, 0, textSize + 4)
					end),
					Truncate = true,
					ZIndex = 3,
				}),
				-- "2 / 5": the progress big, the goal small (RIVALS)
				Text(scope, {
					Name = "Count",
					RichText = true,
					Text = scope:Computed(function(use)
						local small = math.floor(textSize * 0.66)
						return `{Format.abbrev(math.floor(use(progress)))} <font size="{small}">/ {Format.abbrev(use(goal))}</font>`
					end),
					TextSize = textSize,
					Font = "Title",
					XAlign = "Right",
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -rightColumn, 0, textY),
					Visible = showCount,
					ZIndex = 3,
				}),
				scope:New "Frame" {
					Name = "Track",
					Position = UDim2.fromOffset(iconSize + 10, barY),
					Size = UDim2.new(1, -(iconSize + 10) - rightColumn, 0, if compact then 5 else 6),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Track
					end),
					BackgroundTransparency = 0.15,
					ZIndex = 2,
					[Children] = {
						Kit.corner(scope, "Full"),
						scope:New "Frame" {
							Name = "Fill",
							Size = scope:Computed(function(use)
								return UDim2.fromScale(math.clamp(use(fraction), 0, 1), 1)
							end),
							BackgroundColor3 = scope:Computed(function(use)
								local theme = use(Theme.current)
								if use(done) then
									return theme.Color.Good or theme.Tone.Green.Top
								end
								return theme.Color.Progress or theme.Tone.Blue.Bottom
							end),
							ZIndex = 3,
							[Children] = Kit.corner(scope, "Full"),
						},
					},
				},
				CheckMark(scope, checkSize, {
					Name = "Check",
					Position = UDim2.new(1, -rightColumn / 2 + 2, 0, textY + 6),
					Visible = checkShown,
					ZIndex = 4,
					[Children] = scope:New "UIScale" { Scale = checkPop },
				}),
				if reward
					then scope:New "Frame" {
						Name = "Reward",
						BackgroundTransparency = 1,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.new(1, -rightColumn / 2 + 2, 0, textY + 4),
						Size = UDim2.fromOffset(40, 44),
						Visible = scope:Computed(function(use)
							return not use(checkShown)
						end),
						ZIndex = 4,
						[Children] = {
							Icon(scope, {
								Icon = reward.Icon or "gift",
								Size = if compact then 26 else 34,
								AnchorPoint = Vector2.new(0.5, 0.5),
								Position = UDim2.fromScale(0.5, 0.38),
								Rotation = -12,
								Color = reward.Color or Kit.themed(scope, function(theme)
									return theme.Tone.Yellow.Top
								end),
								Gradient = true,
								ZIndex = 5,
							}),
							Text(scope, {
								Text = `×{reward.Amount or 1}`,
								TextSize = if compact then 14 else 17,
								Font = "Display",
								Outline = "Art",
								AnchorPoint = Vector2.new(0.5, 1),
								Position = UDim2.new(0.5, 4, 1, 4),
								ZIndex = 6,
							}),
						},
					}
					else nil,
			},
		},
	}
end

--------------------------------------------------------------------------
-- Kit looks
--------------------------------------------------------------------------

-- Per task: progress (clamped), goal, done, and a fraction spring.
local function taskState(scope: Scope, quest: { [string]: any })
	local goal = quest.Goal or 1
	local progress = scope:Computed(function(use)
		return math.clamp(use(quest.Progress) or 0, 0, use(goal))
	end)
	local done = scope:Computed(function(use)
		return use(progress) >= use(goal)
	end)
	local fraction = scope:Spring(scope:Computed(function(use)
		return use(progress) / math.max(1, use(goal))
	end), 14, 1)
	local count = scope:Computed(function(use)
		return `{Format.abbrev(math.floor(use(progress)))}/{Format.abbrev(use(goal))}`
	end)
	return { goal = goal, progress = progress, done = done, fraction = fraction, count = count }
end

local function counterOf(scope: Scope, tasks: any): any
	return scope:Computed(function(use)
		local list = use(tasks)
		local complete = 0
		for _, quest in list do
			if (use(quest.Progress) or 0) >= (use(quest.Goal) or 1) then
				complete += 1
			end
		end
		return `{complete}/{#list}`
	end)
end

-- The first unfinished task (or the last one when all are done).
local function currentTask(scope: Scope, tasks: any): any
	return scope:Computed(function(use)
		local list = use(tasks)
		for index, quest in list do
			if (use(quest.Progress) or 0) < (use(quest.Goal) or 1) then
				return index
			end
		end
		return math.max(1, #list)
	end)
end

local function finishLook(scope: Scope, props: { [any]: any }, size: Vector2, children: { any }): Frame
	local root = Kit.root(props, { Name = "QuestTracker" })
	root.BackgroundTransparency = 1
	root[Children] = { children, props[Children] }
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

local function header(scope: Scope, T: { [string]: any }, look: any, props: { [any]: any }, counter: any, y: number, width: number)
	return {
		P.icon(scope, T, {
			Name = "HeaderIcon",
			Icon = props.Icon or "quest",
			Size = 26,
			Color = scope:Computed(function(use)
				return use(look).accent
			end),
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.fromOffset(14, y),
			ZIndex = 4,
		}),
		P.text(scope, T, look, {
			Name = "Title",
			Role = "Title",
			Text = props.Title or "Tasks",
			TextSize = 20,
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.fromOffset(48, y),
			Size = UDim2.fromOffset(width - 120, 24),
			Truncate = true,
			Color = scope:Computed(function(use)
				return use(look).ink
			end),
			ZIndex = 4,
		}),
		P.text(scope, T, look, {
			Name = "Counter",
			Role = "Number",
			Text = counter,
			TextSize = 18,
			XAlign = "Right",
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -14, 0, y),
			Color = scope:Computed(function(use)
				return use(look).accent
			end),
			ZIndex = 4,
		}),
	}
end

local LOOKS = {}

-- A card; each task has a small ring round its icon.
function LOOKS.Card(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local tasks = props.Tasks or {}
	local n = math.max(1, #(peek(tasks) or {}))
	local width = props.Width or 360
	local rowH = 52
	local head = 44
	local size = Vector2.new(width, head + n * rowH + 12)
	local look = P.look(scope, T)
	local rows = scope:ForPairs(tasks, function(_, inner, index, quest)
		local st = taskState(inner, quest)
		local ringSize = 38
		return index, inner:New "Frame" {
			Name = `Task{index}`,
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(12, head + (index - 1) * rowH),
			Size = UDim2.new(1, -24, 0, rowH),
			ZIndex = 4,
			[Children] = {
				P.ring(inner, {
					Name = "Ring",
					Size = ringSize,
					Thickness = 4,
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 0, 0.5, 0),
					Fraction = st.fraction,
					Color = inner:Computed(function(use)
						local theme = use(Theme.current)
						return if use(st.done) then theme.Color.Good else use(look).accent
					end),
					Track = inner:Computed(function(use)
						return use(look).track
					end),
					TrackAlpha = 0.2,
					Cutout = P.holeColor(inner, look),
					ZIndex = 4,
					[Children] = P.icon(inner, T, {
						Icon = inner:Computed(function(use)
							return if use(st.done) then "check" else quest.Icon or "quest"
						end),
						Size = 18,
						Treatment = "Flat",
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = inner:Computed(function(use)
							local l = use(look)
							return if use(st.done) then use(Theme.current).Color.Good elseif l.light then l.ink else use(Theme.current).Color.Text
						end),
						ZIndex = 9,
					}),
				}),
				P.text(inner, T, look, {
					Name = "TaskText",
					Role = "Body",
					Text = quest.Text or "",
					TextSize = 18,
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, ringSize + 12, 0.5, 0),
					Size = UDim2.new(1, -(ringSize + 12) - 70, 0, 22),
					Truncate = true,
					Color = inner:Computed(function(use)
						local l = use(look)
						return if use(st.done) then l.muted else l.ink
					end),
					ZIndex = 5,
				}),
				P.text(inner, T, look, {
					Name = "Count",
					Role = "Number",
					Text = st.count,
					TextSize = 16,
					XAlign = "Right",
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, 0, 0.5, 0),
					Color = inner:Computed(function(use)
						local l = use(look)
						return if use(st.done) then use(Theme.current).Color.Good else l.muted
					end),
					ZIndex = 5,
				}),
			},
		}
	end)
	return finishLook(scope, props, size, {
		P.plate(scope, T, { Edge = "Left", Idle = true }),
		header(scope, T, look, props, counterOf(scope, tasks), 24, width),
		rows,
	})
end

-- One line: the current task with its count and a thin bar.
function LOOKS.Ticker(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local tasks = props.Tasks or {}
	local width = props.Width or 420
	local size = Vector2.new(width, 52)
	local look = P.look(scope, T)
	local current = currentTask(scope, tasks)
	local quest = scope:Computed(function(use)
		return (use(tasks) or {})[use(current)] or {}
	end)
	local fraction = scope:Spring(scope:Computed(function(use)
		local q = use(quest)
		local goal = use(q.Goal) or 1
		return math.clamp((use(q.Progress) or 0) / math.max(1, goal), 0, 1)
	end), 14, 1)
	local slide = scope:Spring(scope:Value(0), 22, 0.8)
	Kit.watch(scope, current, function()
		if not Motion.reduced then
			slide:setPosition(16)
		end
	end)
	local chipW = 58
	return finishLook(scope, props, size, {
		P.plate(scope, T, { Radius = if T.corner == "Round" then "Pill" else nil, Edge = "Left", Idle = true }),
		scope:New "Frame" {
			Name = "Chip",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, 8, 0.5, 0),
			Size = UDim2.fromOffset(chipW, size.Y - 18),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Accent
			end),
			ZIndex = 4,
			[Children] = {
				P.corner(scope, T, if T.corner == "Round" then "Pill" else "Small"),
				Text(scope, {
					Text = counterOf(scope, tasks),
					TextSize = 16,
					Font = T.numberFont,
					Color = Kit.themed(scope, function(theme)
						return if Kit.isLight(theme.Color.Accent) then theme.Color.TextDark else theme.Color.Text
					end),
					Stroke = false,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					ZIndex = 5,
				}),
			},
		},
		scope:New "Frame" {
			Name = "Window",
			BackgroundTransparency = 1,
			ClipsDescendants = true,
			Position = UDim2.fromOffset(chipW + 16, 4),
			Size = UDim2.new(1, -(chipW + 26), 1, -14),
			ZIndex = 4,
			[Children] = {
				P.icon(scope, T, {
					Name = "TaskIcon",
					Icon = scope:Computed(function(use)
						return use(quest).Icon or "quest"
					end),
					Size = 22,
					Treatment = "Flat",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = scope:Computed(function(use)
						return UDim2.new(0, 0, 0.5, use(slide))
					end),
					Color = scope:Computed(function(use)
						return use(look).accent
					end),
					ZIndex = 5,
				}),
				P.text(scope, T, look, {
					Name = "TaskText",
					Role = "Body",
					Text = scope:Computed(function(use)
						return use(quest).Text or ""
					end),
					TextSize = 19,
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = scope:Computed(function(use)
						return UDim2.new(0, 30, 0.5, use(slide))
					end),
					Size = UDim2.new(1, -100, 0, 24),
					Truncate = true,
					ZIndex = 5,
				}),
				P.text(scope, T, look, {
					Name = "Count",
					Role = "Number",
					Text = scope:Computed(function(use)
						local q = use(quest)
						return `{Format.abbrev(math.floor(use(q.Progress) or 0))}/{Format.abbrev(use(q.Goal) or 1)}`
					end),
					TextSize = 17,
					XAlign = "Right",
					AnchorPoint = Vector2.new(1, 0.5),
					Position = scope:Computed(function(use)
						return UDim2.new(1, 0, 0.5, use(slide))
					end),
					Color = scope:Computed(function(use)
						return use(look).accent
					end),
					ZIndex = 5,
				}),
			},
		},
		P.bar(scope, T, {
			Name = "Progress",
			Fraction = fraction,
			Color = scope:Computed(function(use)
				return use(Theme.current).Color.Progress or use(look).accent
			end),
			Size = UDim2.new(1, -(chipW + 26), 0, 4),
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, chipW + 16, 1, -7),
			Track = scope:Computed(function(use)
				return use(look).track
			end),
			TrackAlpha = 0.3,
			Radius = "Pill",
			Ghost = false,
			Gloss = false,
			ZIndex = 4,
		}),
	})
end

-- Checkboxes; finished tasks are ticked and struck through.
function LOOKS.Checklist(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local tasks = props.Tasks or {}
	local n = math.max(1, #(peek(tasks) or {}))
	local width = props.Width or 360
	local rowH = 40
	local head = 44
	local size = Vector2.new(width, head + n * rowH + 12)
	local look = P.look(scope, T)
	local rows = scope:ForPairs(tasks, function(_, inner, index, quest)
		local st = taskState(inner, quest)
		local tick = inner:Spring(inner:Computed(function(use)
			return if use(st.done) then 1 else 0
		end), 26, T.bounce or 0.5)
		local box = 24
		return index, inner:New "Frame" {
			Name = `Task{index}`,
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(14, head + (index - 1) * rowH),
			Size = UDim2.new(1, -28, 0, rowH),
			ZIndex = 4,
			[Children] = {
				inner:New "Frame" {
					Name = "Box",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 0, 0.5, 0),
					Size = UDim2.fromOffset(box, box),
					BackgroundColor3 = inner:Computed(function(use)
						local l = use(look)
						return if l.light then l.face else use(Theme.current).Color.Surface
					end),
					BackgroundTransparency = inner:Computed(function(use)
						return if use(look).light then 0 else 0.3
					end),
					ZIndex = 5,
					[Children] = {
						P.corner(inner, T, "Small"),
						inner:New "UIStroke" {
							Color = inner:Computed(function(use)
								local l = use(look)
								return if l.light then l.ink else use(Theme.current).Color.Text
							end),
							Thickness = 2,
							Transparency = 0.2,
						},
						Icon(inner, {
							Name = "Tick",
							Icon = "check",
							Size = box + 8,
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.new(0.5, 3, 0.5, -3),
							Color = Kit.themed(inner, function(theme)
								return theme.Color.Good
							end),
							Stroke = 2,
							Visible = inner:Computed(function(use)
								return use(tick) > 0.05
							end),
							ZIndex = 7,
							[Children] = inner:New "UIScale" {
								Scale = inner:Computed(function(use)
									return math.max(0.01, use(tick))
								end),
							},
						}),
					},
				},
				P.text(inner, T, look, {
					Name = "TaskText",
					Role = "Body",
					RichText = true,
					Text = inner:Computed(function(use)
						local t = tostring(quest.Text or "")
						return if use(st.done) then `<s>{t}</s>` else t
					end),
					TextSize = 18,
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, box + 12, 0.5, 0),
					Size = UDim2.new(1, -(box + 12) - 64, 0, 22),
					Truncate = true,
					Color = inner:Computed(function(use)
						local l = use(look)
						return if use(st.done) then l.muted else l.ink
					end),
					ZIndex = 5,
				}),
				P.text(inner, T, look, {
					Name = "Count",
					Role = "Number",
					Text = st.count,
					TextSize = 15,
					XAlign = "Right",
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, 0, 0.5, 0),
					Color = inner:Computed(function(use)
						return use(look).muted
					end),
					ZIndex = 5,
				}),
				if index < #(peek(tasks) or {})
					then inner:New "Frame" {
						Name = "Rule",
						AnchorPoint = Vector2.new(0, 1),
						Position = UDim2.new(0, box + 12, 1, 0),
						Size = UDim2.new(1, -(box + 12), 0, 1),
						BackgroundColor3 = inner:Computed(function(use)
							return use(look).muted
						end),
						BackgroundTransparency = 0.75,
						BorderSizePixel = 0,
						ZIndex = 4,
					}
					else nil,
			},
		}
	end)
	return finishLook(scope, props, size, {
		P.plate(scope, T, { Edge = "Left", Idle = true }),
		header(scope, T, look, props, counterOf(scope, tasks), 24, width),
		rows,
	})
end

-- A banner with a marker for the current task.
function LOOKS.Waypoint(scope: Scope, props: { [any]: any }, T: { [string]: any }): Frame
	local tasks = props.Tasks or {}
	local width = props.Width or 460
	local size = Vector2.new(width, 76)
	local look = P.look(scope, T)
	local current = currentTask(scope, tasks)
	local quest = scope:Computed(function(use)
		return (use(tasks) or {})[use(current)] or {}
	end)
	local fraction = scope:Spring(scope:Computed(function(use)
		local q = use(quest)
		local goal = use(q.Goal) or 1
		return math.clamp((use(q.Progress) or 0) / math.max(1, goal), 0, 1)
	end), 14, 1)
	local marker = 44
	local slide = scope:Spring(scope:Value(0), 22, 0.8)
	Kit.watch(scope, current, function()
		if not Motion.reduced then
			slide:setPosition(18)
		end
	end)
	local counter = counterOf(scope, tasks)
	return finishLook(scope, props, size, {
		P.plate(scope, T, {
			Name = "Banner",
			Radius = if T.corner == "Round" then "Pill" else nil,
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, 0, 0.5, 0),
			Size = UDim2.new(1, -marker / 2, 0, 60),
			Edge = "Bottom",
			Idle = true,
		}),
		scope:New "Frame" {
			Name = "Marker",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0, marker / 2 + 4, 0.5, 0),
			Size = UDim2.fromOffset(marker, marker),
			Rotation = 45,
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Accent
			end),
			BorderSizePixel = 0,
			ZIndex = 5,
			[Children] = {
				P.corner(scope, T, "Small"),
				scope:New "UIStroke" {
					Color = scope:Computed(function(use)
						local l = use(look)
						return if l.kind == "Paper" then l.stroke else use(Theme.current).Color.Outline
					end),
					Thickness = 3,
				},
			},
		},
		P.icon(scope, T, {
			Name = "MarkerIcon",
			Icon = scope:Computed(function(use)
				return use(quest).Icon or "quest"
			end),
			Size = 24,
			Treatment = "Flat",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0, marker / 2 + 4, 0.5, 0),
			Color = Kit.themed(scope, function(theme)
				return if Kit.isLight(theme.Color.Accent) then theme.Color.TextDark else Color3.new(1, 1, 1)
			end),
			ZIndex = 6,
		}),
		P.text(scope, T, look, {
			Name = "Caption",
			Role = "Label",
			Text = scope:Computed(function(use)
				return `{props.Title or "Tasks"}  {use(counter)}`
			end),
			TextSize = 13,
			XAlign = "Left",
			Position = UDim2.fromOffset(marker + 22, 14),
			Color = scope:Computed(function(use)
				return use(look).accent
			end),
			ZIndex = 5,
		}),
		scope:New "Frame" {
			Name = "Window",
			BackgroundTransparency = 1,
			ClipsDescendants = true,
			Position = UDim2.fromOffset(marker + 22, 29),
			Size = UDim2.new(1, -(marker + 22) - 18, 0, 28),
			ZIndex = 5,
			[Children] = {
				P.text(scope, T, look, {
					Name = "TaskText",
					Role = "Title",
					Text = scope:Computed(function(use)
						return use(quest).Text or ""
					end),
					Case = "Normal",
					TextSize = 20,
					XAlign = "Left",
					Position = scope:Computed(function(use)
						return UDim2.fromOffset(0, use(slide))
					end),
					Size = UDim2.new(1, -70, 0, 26),
					Truncate = true,
					ZIndex = 6,
				}),
				P.text(scope, T, look, {
					Name = "Count",
					Role = "Number",
					Text = scope:Computed(function(use)
						local q = use(quest)
						return `{Format.abbrev(math.floor(use(q.Progress) or 0))}/{Format.abbrev(use(q.Goal) or 1)}`
					end),
					TextSize = 18,
					XAlign = "Right",
					AnchorPoint = Vector2.new(1, 0),
					Position = scope:Computed(function(use)
						return UDim2.new(1, 0, 0, use(slide) + 2)
					end),
					Color = scope:Computed(function(use)
						return use(look).accent
					end),
					ZIndex = 6,
				}),
			},
		},
		P.bar(scope, T, {
			Name = "Progress",
			Fraction = fraction,
			Color = scope:Computed(function(use)
				return use(look).accent
			end),
			Size = UDim2.new(1, -(marker + 40), 0, 4),
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, marker + 22, 1, -14),
			Track = scope:Computed(function(use)
				return use(look).track
			end),
			TrackAlpha = 0.3,
			Radius = "Pill",
			Ghost = false,
			Gloss = false,
			ZIndex = 5,
		}),
	})
end

return function(scope: Scope, props: { [any]: any }): Frame
	local style = P.style("QuestTracker", props.Style)
	if style ~= "Panel" then
		return LOOKS[style](scope, props, P.tokens(props.Kit))
	end
	local tasks = props.Tasks or {}
	local compact = props.Compact == true

	local completeCount = scope:Computed(function(use)
		local list = use(tasks)
		local complete = 0
		for _, quest in list do
			if (use(quest.Progress) or 0) >= (use(quest.Goal) or 1) then
				complete += 1
			end
		end
		return complete
	end)
	local counter = scope:Computed(function(use)
		local list = use(tasks)
		local complete = 0
		for _, quest in list do
			if (use(quest.Progress) or 0) >= (use(quest.Goal) or 1) then
				complete += 1
			end
		end
		return `[{complete} / {#list}]`
	end)

	local counterPopGoal = scope:Value(1)
	local counterPop = scope:Spring(counterPopGoal, 35, 0.5)
	Kit.watch(scope, completeCount, function()
		task.delay(0.25, function()
			counterPop:setPosition(1.25)
		end)
	end)

	local rows = scope:ForPairs(tasks, function(_, innerScope, index, quest)
		return index, Row(innerScope, props, quest, index)
	end)

	-- Arena themes use Rivals' faded edge panel; others a glass card. (Style
	-- may also be a surface name, the original API; "Panel" is the HUD look.)
	local surfaceStyle = if props.Style ~= nil and props.Style ~= "Panel" then props.Style else nil
	local look, layers = Kit.surface(scope, {
		Style = surfaceStyle or Kit.themed(scope, function(theme)
			return if Theme.style(theme, "Archetype") == "Arena" then "Fade" else "Glass"
		end),
		Radius = "M",
		Highlight = false,
	})
	local root = Kit.root(props, { Name = "QuestTracker" })
	root.Size = UDim2.fromOffset(props.Width or 340, 0)
	root.AutomaticSize = Enum.AutomaticSize.Y
	root.BackgroundColor3 = look.BackgroundColor3
	root.BackgroundTransparency = look.BackgroundTransparency
	root[Children] = {
		layers,
		Kit.padding(scope, if compact then { 8, 12, 8, 12 } else { 12, 16, 12, 16 }),
		Kit.list(scope, { Direction = "Y", Padding = 0, HAlign = "Left" }),
		scope:New "Frame" {
			Name = "Header",
			LayoutOrder = 0,
			BackgroundTransparency = 1,
			Size = UDim2.new(1, 0, 0, if compact then 32 else 42),
			ZIndex = 2,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = if compact then 8 else 12, VAlign = "Center" }),
				Icon(scope, {
					Icon = props.Icon or "quest",
					Size = if compact then 26 else 34,
					Color = props.IconColor or Kit.themed(scope, function(theme)
						return theme.Color.Light
					end),
					Gradient = true,
					LayoutOrder = 1,
					ZIndex = 2,
				}),
				Text(scope, {
					Text = props.Title or "Tasks",
					TextSize = if compact then 21 else 27,
					Font = "Title",
					LayoutOrder = 2,
					ZIndex = 2,
				}),
				Text(scope, {
					Name = "Counter",
					Text = counter,
					TextSize = if compact then 21 else 27,
					Font = "Title",
					LayoutOrder = 3,
					ZIndex = 2,
					[Children] = scope:New "UIScale" { Scale = counterPop },
				}),
			},
		},
		rows,
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame
	-- the footprint a HUD layout reserves (the height grows with the tasks)
	local n = #(peek(tasks) or {})
	frame:SetAttribute("HudSize", Vector2.new(props.Width or 340, if compact then 48 + n * 44 else 64 + n * 58))
	return frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="137">
            <Properties>
              <string name="Name">RadialMenu</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	RadialMenu: an emote / weapon wheel. Items sit in a circle around a hub
	that names the highlighted one. The highlight (a glowing arc, a pointer and
	a lit-up item) follows the mouse direction or a gamepad stick; click, tap,
	press A or a number key, or just release the hold key, to pick.

	local open = scope:Value(false)
	scope:RadialMenu {
		Items = {
			{ id = "wave", label = "Wave", icon = "square-face-waving-hand" },
			{ id = "dance", label = "Dance", icon = "music", tone = "Pink" },
			{ id = "cheer", label = "Cheer", icon = "trophy" },
		},
		Open = open,                        -- Value<bool>
		OnSelect = function(id, item) end,
		Key = Enum.KeyCode.B,               -- optional: hold to open, release to pick
		Mode = "Hold",                      -- "Hold" | "Toggle" (for Key)
		Title = "EMOTES",                   -- hub text when nothing is highlighted
		Radius = 190,                       -- distance of items from the centre
		SelectOnRelease = true,             -- closing with an item highlighted picks it
	}

	While open, the wheel frees a locked (first person) mouse. It centres itself
	on its parent unless you pass Position / AnchorPoint.
]]

local GuiService = game:GetService("GuiService")
local UserInputService = game:GetService("UserInputService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Input = require(script.Parent.Parent.Util.Input)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)
local WEDGE = "rbxasset://textures/ui/Emotes/Large/SelectedGradient@2x.png"

-- Soft white radial glow tinted `Color`. The texture is faint, so it can be
-- stacked `Layers` times for a stronger bloom.
local GLOW = (Assets :: any).Glow or "rbxasset://textures/ui/InGameMenu/BackgroundGlow.png"
local function glow(scope: any, props: { [string]: any }): ImageLabel
	local extra = {}
	for _ = 2, props.Layers or 1 do
		table.insert(extra, scope:New "ImageLabel" {
			Name = "Layer",
			BackgroundTransparency = 1,
			Image = GLOW,
			ImageColor3 = props.Color,
			ImageTransparency = props.Transparency or 0,
			Size = UDim2.fromScale(1, 1),
			ZIndex = props.ZIndex or 0,
		})
	end
	return scope:New "ImageLabel" {
		Name = props.Name or "Glow",
		BackgroundTransparency = 1,
		Image = GLOW,
		ImageColor3 = props.Color,
		ImageTransparency = props.Transparency or 0,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position or UDim2.fromScale(0.5, 0.5),
		Size = props.Size,
		Visible = props.Visible,
		ZIndex = props.ZIndex or 0,
		[Fusion.Children] = extra,
	}
end

local NUMBER_KEYS = {
	Enum.KeyCode.One,
	Enum.KeyCode.Two,
	Enum.KeyCode.Three,
	Enum.KeyCode.Four,
	Enum.KeyCode.Five,
	Enum.KeyCode.Six,
	Enum.KeyCode.Seven,
	Enum.KeyCode.Eight,
	Enum.KeyCode.Nine,
}

local function field(item: { [string]: any }, lower: string, upper: string): any
	local value = item[lower]
	if value == nil then
		value = item[upper]
	end
	return value
end

-- Index of the item in direction `v` (screen space, y down). Items start at
-- the top and go clockwise.
local function indexForDirection(v: Vector2, count: number): number
	local angle = math.deg(math.atan2(v.X, -v.Y)) % 360
	local step = 360 / count
	return math.floor((angle + step / 2) / step) % count + 1
end

return function(scope: Fusion.Scope<any>, props: { [any]: any }): Frame
	local items = peek(props.Items) or {}
	local count = #items
	local open = props.Open or scope:Value(false)
	local radius = props.Radius or 190
	local nodeSize = props.NodeSize or 104
	local hubSize = props.HubSize or 184
	local ringSize = radius * 2 + nodeSize + 60
	local diameter = ringSize + 70 -- room for the hot item's glow
	local step = 360 / math.max(1, count)
	local selectOnRelease = props.SelectOnRelease ~= false

	local highlighted = scope:Value(nil :: number?)
	local pointerGoal = scope:Value(0)
	local pointer = scope:Spring(pointerGoal, 32, 0.78)

	local progress = scope:Spring(scope:Computed(function(use)
		return if use(open) then 1 else 0
	end), 26, 0.72)
	local shown = scope:Computed(function(use)
		return use(open) or use(progress) > 0.02
	end)
	local reach = scope:Spring(scope:Computed(function(use)
		return if use(open) then 1 else 0.35
	end), 22, 0.62)

	local highlightTone = scope:Computed(function(use)
		local index = use(highlighted)
		local theme = use(Theme.current)
		local item = index and items[index]
		local toneName = item and field(item, "tone", "Tone")
		return Theme.tone(theme, toneName or "Yellow")
	end)

	local function setHighlight(index: number?)
		if index == peek(highlighted) then
			return
		end
		highlighted:set(index)
		if index then
			-- the pointer spins the short way round
			local target = (index - 1) * step
			local current = peek(pointerGoal)
			local delta = ((target - current + 180) % 360) - 180
			pointerGoal:set(current + delta)
			Sound.play("Tick", { Volume = 0.35, Pitch = 1.5 })
			if props.OnHighlight then
				task.spawn(props.OnHighlight, field(items[index], "id", "Id"), items[index])
			end
		end
	end

	local selectedThisOpen = false
	local cancelled = false

	local function close()
		if open.set and peek(open) then
			open:set(false)
		end
	end

	local function choose(index: number)
		local item = items[index]
		if item == nil or selectedThisOpen then
			return
		end
		selectedThisOpen = true
		setHighlight(index)
		Sound.play("Click")
		if props.OnSelect then
			task.spawn(props.OnSelect, field(item, "id", "Id"), item)
		end
		close()
	end

	local wasOpen = peek(open) == true
	Kit.watch(scope, open, function(now)
		if now and not wasOpen then
			selectedThisOpen = false
			cancelled = false
			highlighted:set(nil)
			Sound.play("Open")
		elseif not now and wasOpen then
			local index = peek(highlighted)
			if selectOnRelease and not selectedThisOpen and not cancelled and index then
				choose(index)
			end
			Sound.play("Close")
		end
		wasOpen = now == true
	end)

	--------------------------------------------------------------------
	-- Pieces
	--------------------------------------------------------------------

	local nodes = {}
	for index, item in items do
		local isHot = scope:Computed(function(use)
			return use(highlighted) == index
		end)
		local itemTone = scope:Computed(function(use)
			return Theme.tone(use(Theme.current), field(item, "tone", "Tone") or "Yellow")
		end)
		local hotScale = scope:Spring(scope:Computed(function(use)
			return if use(isHot) then 1.14 else 1
		end), 30, 0.55)
		local angle = math.rad((index - 1) * step)

		table.insert(nodes, scope:New "TextButton" {
			Name = `Item_{field(item, "id", "Id") or index}`,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = scope:Computed(function(use)
				local r = radius * use(reach)
				return UDim2.new(0.5, math.sin(angle) * r, 0.5, -math.cos(angle) * r)
			end),
			Size = UDim2.fromOffset(nodeSize, nodeSize),
			BackgroundTransparency = 1,
			Text = "",
			AutoButtonColor = false,
			ZIndex = 10,
			[OnEvent "MouseEnter"] = function()
				if peek(open) then
					setHighlight(index)
				end
			end,
			[OnEvent "SelectionGained"] = function()
				setHighlight(index)
			end,
			[OnEvent "Activated"] = function()
				if peek(open) then
					choose(index)
				end
			end,
			[Children] = {
				scope:New "UIScale" { Scale = hotScale },
				glow(scope, {
					Color = scope:Computed(function(use)
						return use(itemTone).Top
					end),
					Transparency = scope:Computed(function(use)
						return if use(isHot) then 0 else 1
					end),
					Size = UDim2.new(1, 90, 1, 90),
					Layers = 3,
					ZIndex = 10,
				}),
				scope:New "Frame" {
					Name = "Face",
					Size = UDim2.fromScale(1, 1),
					BackgroundColor3 = scope:Computed(function(use)
						return if use(isHot) then WHITE else use(Theme.current).Color.SurfaceLight
					end),
					BackgroundTransparency = scope:Computed(function(use)
						return if use(isHot) then 0 else 0.12
					end),
					ZIndex = 11,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.gradient(scope, {
							Rotation = 90,
							Color = scope:Computed(function(use)
								if use(isHot) then
									local t = use(itemTone)
									return ColorSequence.new(Color.lighten(t.Top, 0.1), t.Bottom)
								end
								return ColorSequence.new(WHITE, Color3.fromRGB(170, 170, 185))
							end),
						}),
						Kit.stroke(scope, {
							Thickness = 3,
							Color = scope:Computed(function(use)
								return if use(isHot) then use(itemTone).Edge else use(Theme.current).Color.Outline
							end),
						}),
						scope:New "Frame" {
							Name = "Gloss",
							AnchorPoint = Vector2.new(0.5, 0),
							Position = UDim2.new(0.5, 0, 0, 5),
							Size = UDim2.new(0.7, 0, 0.36, 0),
							BackgroundColor3 = WHITE,
							BackgroundTransparency = scope:Computed(function(use)
								return if use(isHot) then 0.55 else 0.85
							end),
							ZIndex = 12,
							[Children] = {
								Kit.corner(scope, "Full"),
								Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0.1, 1) }),
							},
						},
						Icon(scope, {
							Name = "ItemIcon",
							Icon = field(item, "icon", "Icon") or "star",
							Size = math.floor(nodeSize * 0.5),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = scope:Computed(function(use)
								return if use(isHot) then use(itemTone).Text else WHITE
							end),
							Stroke = 2.5,
							StrokeColor = scope:Computed(function(use)
								if use(isHot) then
									local t = use(itemTone)
									return t.Stroke or t.Edge
								end
								return use(Theme.current).Color.Outline
							end),
							Shadow = true,
							ZIndex = 13,
						}),
					},
				},
				if index <= #NUMBER_KEYS
					then scope:New "Frame" {
						Name = "Hotkey",
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.86, 0.14),
						Size = UDim2.fromOffset(28, 28),
						BackgroundColor3 = Kit.themed(scope, function(theme)
							return theme.Color.Surface
						end),
						Visible = scope:Computed(function(use)
							return use(Input.device) == "Keyboard"
						end),
						ZIndex = 14,
						[Children] = {
							Kit.corner(scope, "Full"),
							Kit.stroke(scope, { Thickness = 2 }),
							Text(scope, {
								Text = tostring(index),
								TextSize = 17,
								Font = "Display",
								Stroke = false,
								AnchorPoint = Vector2.new(0.5, 0.5),
								Position = UDim2.fromScale(0.5, 0.5),
								ZIndex = 15,
							}),
						},
					}
					else nil,
			},
		})
	end

	local arcThreshold = math.clamp((1 + math.cos(math.rad(step / 2))) / 2, 0.02, 0.975)
	local gradientRotation = scope:Computed(function(use)
		return use(pointer) - 90
	end)
	local hasHighlight = scope:Computed(function(use)
		return use(highlighted) ~= nil
	end)

	local arcWidth = 10
	-- the wedge texture's height/length ratio that makes it span one slice
	local wedgeRatio = math.min(2.4, 2 * math.tan(math.rad(math.min(step, 130) / 2)))
	local dividers = {}
	if count > 1 then
		for i = 1, count do
			table.insert(dividers, scope:New "Frame" {
				Name = "Divider",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.fromScale(1, 1),
				BackgroundTransparency = 1,
				Rotation = (i - 0.5) * step,
				ZIndex = 4,
				[Children] = scope:New "Frame" {
					Name = "Line",
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, 10),
					Size = UDim2.new(0, 2, 0.5, -(hubSize / 2) - 14),
					BackgroundColor3 = WHITE,
					BackgroundTransparency = 0.9,
					ZIndex = 4,
					[Children] = scope:New "UIGradient" {
						Rotation = 90,
						Transparency = NumberSequence.new({
							NumberSequenceKeypoint.new(0, 1),
							NumberSequenceKeypoint.new(0.3, 0),
							NumberSequenceKeypoint.new(1, 0.4),
						}),
					},
				},
			})
		end
	end
	local arcColors = scope:Computed(function(use)
		local t = use(highlightTone)
		return ColorSequence.new(Color.lighten(t.Top, 0.35), t.Bottom)
	end)

	-- The ring is a dark body inset inside a same-sized accent disc whose
	-- transparency gradient only lets through a cap facing the pointer: the
	-- sliver left around the body's rim is the lit arc of the highlighted
	-- slice, and the body lets a little of the cap glow through.
	local ring = scope:New "Frame" {
		Name = "Ring",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(ringSize, ringSize),
		BackgroundTransparency = 1,
		ZIndex = 2,
		[Children] = {
			scope:New "Frame" {
				Name = "Arc",
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = WHITE,
				Visible = hasHighlight,
				ZIndex = 2,
				[Children] = {
					Kit.corner(scope, "Full"),
					scope:New "UIGradient" {
						Rotation = gradientRotation,
						Color = arcColors,
						Transparency = NumberSequence.new({
							NumberSequenceKeypoint.new(0, 1),
							NumberSequenceKeypoint.new(arcThreshold - 0.006, 1),
							NumberSequenceKeypoint.new(arcThreshold + 0.006, 0),
							NumberSequenceKeypoint.new(1, 0),
						}),
					},
				},
			},
			scope:New "Frame" {
				Name = "Body",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.new(1, -arcWidth * 2, 1, -arcWidth * 2),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Surface
				end),
				BackgroundTransparency = 0.18,
				ZIndex = 3,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.stroke(scope, { Thickness = 3 }),
					scope:New "Frame" {
						Name = "Rim",
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Size = UDim2.new(1, -6, 1, -6),
						BackgroundTransparency = 1,
						ZIndex = 3,
						[Children] = {
							Kit.corner(scope, "Full"),
							scope:New "UIStroke" {
								Color = WHITE,
								Thickness = 1.5,
								Transparency = 0.82,
							},
						},
					},
					-- the highlighted slice: a lit wedge that swings with the pointer
					scope:New "Frame" {
						Name = "Slice",
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Size = UDim2.fromScale(1, 1),
						BackgroundTransparency = 1,
						Rotation = gradientRotation,
						Visible = hasHighlight,
						ZIndex = 4,
						[Children] = scope:New "ImageLabel" {
							Name = "Wedge",
							BackgroundTransparency = 1,
							Image = WEDGE,
							ImageColor3 = scope:Computed(function(use)
								return use(highlightTone).Top
							end),
							ImageTransparency = 0.5,
							AnchorPoint = Vector2.new(0, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Size = UDim2.fromScale(0.5, 0.5 * wedgeRatio),
							ZIndex = 4,
						},
					},
					dividers,
				},
			},
		},
	}

	local hubIcon = scope:Computed(function(use)
		local index = use(highlighted)
		return if index then field(items[index], "icon", "Icon") or "star" else ""
	end)
	local hubLabel = scope:Computed(function(use)
		local index = use(highlighted)
		if index then
			return tostring(field(items[index], "label", "Label") or field(items[index], "id", "Id") or "")
		end
		return tostring(use(props.Title) or "")
	end)
	local hint = scope:Computed(function(use)
		local device = use(Input.device)
		if device == "Gamepad" then
			return "Stick to aim, A to use"
		elseif device == "Touch" then
			return "Tap to use"
		end
		return if props.Key and (props.Mode or "Hold") == "Hold" then "Release to use" else "Click to use"
	end)

	local hub = scope:New "Frame" {
		Name = "Hub",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(hubSize, hubSize),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Surface
		end),
		BackgroundTransparency = 0.08,
		ZIndex = 6,
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.stroke(scope, { Thickness = 3 }),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = ColorSequence.new(Color3.fromRGB(235, 235, 245), WHITE),
			}),
			Icon(scope, {
				Name = "HubIcon",
				Icon = hubIcon,
				Size = 58,
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(0.5, 0, 0.5, 4),
				Color = scope:Computed(function(use)
					return use(highlightTone).Top
				end),
				Gradient = true,
				Stroke = 2.5,
				Visible = hasHighlight,
				ZIndex = 7,
			}),
			Text(scope, {
				Name = "HubLabel",
				Text = hubLabel,
				TextSize = scope:Computed(function(use)
					return if use(hasHighlight) then 25 else 30
				end),
				Font = "Heavy",
				Stroke = 2.5,
				Truncate = true,
				Size = UDim2.new(1, -28, 0, 32),
				AnchorPoint = Vector2.new(0.5, 0),
				Position = scope:Computed(function(use)
					return if use(hasHighlight) then UDim2.new(0.5, 0, 0.5, 10) else UDim2.new(0.5, 0, 0.5, -26)
				end),
				ZIndex = 7,
			}),
			Text(scope, {
				Name = "Hint",
				Text = hint,
				TextSize = 16,
				Font = "Body",
				Stroke = false,
				Wrapped = true,
				Size = UDim2.new(1, -40, 0, 40),
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0.5, 10),
				Color = Kit.themed(scope, function(theme)
					return theme.Color.TextMuted
				end),
				Visible = scope:Computed(function(use)
					return not use(hasHighlight)
				end),
				ZIndex = 7,
			}),
		},
	}

	local pointerFrame = scope:New "Frame" {
		Name = "Pointer",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(hubSize, hubSize),
		BackgroundTransparency = 1,
		Rotation = pointer,
		Visible = hasHighlight,
		ZIndex = 8,
		[Children] = Icon(scope, {
			Name = "Chevron",
			Icon = "up",
			Size = 34,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5, 0, 0, -14),
			Color = scope:Computed(function(use)
				return use(highlightTone).Top
			end),
			Stroke = 2.5,
			ZIndex = 9,
		}),
	}

	local wheel = scope:New "CanvasGroup" {
		Name = "Wheel",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		GroupTransparency = scope:Computed(function(use)
			return 1 - math.clamp(use(progress), 0, 1)
		end),
		ZIndex = 2,
		[Children] = {
			scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					return 0.78 + 0.22 * use(progress)
				end),
			},
			ring,
			hub,
			pointerFrame,
			nodes,
		},
	}

	local root = Kit.root(props, { Name = "RadialMenu" })
	root.AnchorPoint = props.AnchorPoint or Vector2.new(0.5, 0.5)
	root.Position = props.Position or UDim2.fromScale(0.5, 0.5)
	root.Size = props.Size or UDim2.fromOffset(diameter, diameter)
	root.BackgroundTransparency = 1
	root.Visible = scope:Computed(function(use)
		return use(shown) and use(props.Visible) ~= false
	end)
	root.ZIndex = props.ZIndex or 20

	local rootFrame: Frame

	-- Clicking outside the items picks the highlighted one (or just closes).
	-- Modal frees a first-person mouse while the wheel is open.
	local backdrop = scope:New "TextButton" {
		Name = "Backdrop",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(6000, 6000),
		Text = "",
		AutoButtonColor = false,
		Selectable = false,
		Modal = open,
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Backdrop
		end),
		BackgroundTransparency = scope:Computed(function(use)
			return 1 - 0.35 * math.clamp(use(progress), 0, 1)
		end),
		ZIndex = 1,
		[OnEvent "Activated"] = function()
			if not peek(open) then
				return
			end
			local index = peek(highlighted)
			if index then
				choose(index)
			else
				cancelled = true
				close()
			end
		end,
	}

	root[Children] = {
		backdrop,
		wheel,
		props[Children],
	}
	rootFrame = scope:New "Frame" (root) :: Frame

	--------------------------------------------------------------------
	-- Input
	--------------------------------------------------------------------

	local function centre(): (Vector2, number)
		local position = rootFrame.AbsolutePosition
		local size = rootFrame.AbsoluteSize
		local c = position + size / 2
		local gui = rootFrame:FindFirstAncestorWhichIsA("ScreenGui")
		if gui and not gui.IgnoreGuiInset then
			local inset = GuiService:GetGuiInset()
			c += inset
		end
		local pixelsPerDesign = if diameter > 0 then size.X / diameter else 1
		return c, pixelsPerDesign
	end

	local function fromMouse()
		if count == 0 then
			return
		end
		local c, pixels = centre()
		local v = UserInputService:GetMouseLocation() - c
		if v.Magnitude < hubSize * 0.3 * pixels then
			setHighlight(nil)
			return
		end
		setHighlight(indexForDirection(v, count))
	end

	local function fromStick(position: Vector3)
		if count == 0 then
			return
		end
		local v = Vector2.new(position.X, -position.Y)
		if v.Magnitude > 0.45 then
			setHighlight(indexForDirection(v, count))
		end
	end

	table.insert(scope, UserInputService.InputChanged:Connect(function(input)
		if not peek(open) then
			return
		end
		if input.UserInputType == Enum.UserInputType.MouseMovement then
			fromMouse()
		elseif input.KeyCode == Enum.KeyCode.Thumbstick1 or input.KeyCode == Enum.KeyCode.Thumbstick2 then
			fromStick(input.Position)
		end
	end))

	table.insert(scope, UserInputService.InputBegan:Connect(function(input, processed)
		local key = input.KeyCode
		if props.Key and key == props.Key and not processed then
			if (props.Mode or "Hold") == "Toggle" then
				if open.set then
					open:set(not peek(open))
				end
			elseif open.set then
				open:set(true)
			end
			return
		end
		if not peek(open) then
			return
		end
		if key == Enum.KeyCode.ButtonA or key == Enum.KeyCode.Return then
			local index = peek(highlighted)
			if index then
				choose(index)
			end
		elseif key == Enum.KeyCode.ButtonB or key == Enum.KeyCode.Escape then
			cancelled = true
			close()
		elseif not processed then
			for index, numberKey in NUMBER_KEYS do
				if key == numberKey and index <= count then
					choose(index)
					break
				end
			end
		end
	end))

	table.insert(scope, UserInputService.InputEnded:Connect(function(input)
		if props.Key and input.KeyCode == props.Key and (props.Mode or "Hold") == "Hold" then
			close()
		end
	end))

	return rootFrame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="138">
            <Properties>
              <string name="Name">Scoreboard</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Scoreboard: the Tab-key match scoreboard. One block per team with a header
	bar in the team's colour (name, team score, column labels) and player rows
	whose stat columns line up exactly under their labels.

	scope:Scoreboard {
		Title = "Scoreboard",            -- optional heading
		Columns = { "K", "D", "Score" },
		Teams = {
			{ name = "Red", color = Color3.fromHex("FF4B4B"), score = 12,     -- score optional (default: sum of last column)
			  rows = { { name = "Builderman", values = { 12, 3, 1450 } }, ... } },
			{ name = "Blue", color = Color3.fromHex("3AA8FF"), rows = { ... } },
		},                               -- array or state
		Highlight = "Builderman",        -- the local player's row
		Layout = "Stack",                -- "Stack" (default) | "Side" (teams side by side)
		Size = UDim2.fromOffset(760, 620),
	}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Util.Format)
local Color = require(script.Parent.Parent.Util.Color)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)
local HEADER = 56
local ROW = 44
local GAP = 4
local COLUMN = 84

local function field(entry: any, key: string): any
	if typeof(entry) ~= "table" then
		return nil
	end
	local value = entry[key]
	if value == nil then
		value = entry[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

-- A tone from the team's colour (Theme.makeTone when the theme has it).
local function teamTone(color: Color3): any
	local makeTone = (Theme :: any).makeTone
	if makeTone then
		return makeTone(color)
	end
	return {
		Top = Color.lighten(color, 0.25),
		Bottom = color,
		Edge = Color.darken(color, 0.45),
		Deep = Color.darken(color, 0.72),
		Text = WHITE,
		Stroke = Color.darken(color, 0.55),
	}
end

local function formatValue(value: any): string
	if typeof(value) == "number" then
		if value ~= math.floor(value) then
			return string.format("%.2f", value)
		end
		return if math.abs(value) >= 1e6 then Format.abbrev(value) else Format.comma(value)
	end
	return tostring(value or "-")
end

local function Scoreboard(scope: Scope, props: { [any]: any }): Frame
	-- themed scrollbar parts (Kit.scrollBar), shared by this component's scroll areas
	local scrollBar = Kit.scrollBar(scope)
	local columns = props.Columns or { "K", "D", "Score" }
	local teams = props.Teams or {}
	local side = props.Layout == "Side"
	local statsWidth = #columns * COLUMN

	local function valueCells(innerScope: Scope, values: any, textSize: number, font: string, muted: boolean)
		local cells = {}
		for i = 1, #columns do
			table.insert(cells, Text(innerScope, {
				Name = `Col{i}`,
				LayoutOrder = i,
				Text = innerScope:Computed(function(use)
					local list = use(values)
					return formatValue(if typeof(list) == "table" then use(list[i]) else nil)
				end),
				TextSize = textSize,
				Font = font,
				Size = UDim2.fromOffset(COLUMN, ROW),
				Color = if muted
					then Kit.themed(innerScope, function(theme)
						return theme.Color.TextMuted
					end)
					else nil,
				Stroke = not muted,
				ZIndex = 4,
			}))
		end
		return innerScope:New "Frame" {
			Name = "Values",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -8, 0.5, 0),
			Size = UDim2.fromOffset(statsWidth, ROW),
			ZIndex = 4,
			[Children] = {
				Kit.list(innerScope, { Direction = "X", Padding = 0, HAlign = "Right", VAlign = "Center" }),
				cells,
			},
		}
	end

	local blocks = scope:ForPairs(teams, function(_, innerScope, teamIndex, team)
		local color = field(team, "color") or Color3.fromHex("3AA8FF")
		local tone = teamTone(if typeof(color) == "Color3" then color else Color3.fromHex(tostring(color)))
		local rows = field(team, "rows") or {}
		local strokeColor = tone.Stroke or tone.Edge

		local teamScore = innerScope:Computed(function(use)
			local given = use(field(team, "score"))
			if given ~= nil then
				return given
			end
			local total = 0
			for _, row in use(rows) do
				local values = use(field(row, "values")) or {}
				local last = use(values[#columns])
				if typeof(last) == "number" then
					total += last
				end
			end
			return total
		end)

		local rowFrames = innerScope:ForPairs(rows, function(_, rowScope, rowIndex, row)
			local name = tostring(field(row, "name") or "Player")
			local isYou = rowScope:Computed(function(use)
				return props.Highlight ~= nil and use(props.Highlight) == name
			end)
			return rowIndex,
				rowScope:New "Frame" {
					Name = `Player{rowIndex}`,
					LayoutOrder = rowIndex + 1,
					Size = UDim2.new(1, 0, 0, ROW),
					BackgroundColor3 = rowScope:Computed(function(use)
						return if use(isYou) then Color.lighten(tone.Bottom, 0.2) else tone.Deep
					end),
					BackgroundTransparency = rowScope:Computed(function(use)
						if use(isYou) then
							return 0.45
						end
						return if rowIndex % 2 == 0 then 0.7 else 0.5
					end),
					ZIndex = 2,
					[Children] = {
						Kit.corner(rowScope, "S"),
						Kit.stroke(rowScope, {
							Thickness = 2,
							Color = Kit.themed(rowScope, function(theme)
								return theme.Color.Light
							end),
							Enabled = isYou,
						}),
						Text(rowScope, {
							Name = "Place",
							Text = tostring(rowIndex),
							TextSize = 18,
							Font = "Display",
							AnchorPoint = Vector2.new(0, 0.5),
							Position = UDim2.new(0, 8, 0.5, 0),
							Size = UDim2.fromOffset(28, ROW),
							Color = Kit.themed(rowScope, function(theme)
								return theme.Color.TextMuted
							end),
							Stroke = false,
							ZIndex = 3,
						}),
						Text(rowScope, {
							Name = "PlayerName",
							Text = name,
							TextSize = 21,
							Font = "Heavy",
							XAlign = "Left",
							AnchorPoint = Vector2.new(0, 0.5),
							Position = UDim2.new(0, 44, 0.5, 0),
							Size = UDim2.new(1, -(44 + statsWidth + 16), 0, 26),
							Truncate = true,
							Stroke = 1.5,
							ZIndex = 3,
						}),
						valueCells(rowScope, field(row, "values") or {}, 22, "Display", false),
					},
				}
		end)

		local header = innerScope:New "Frame" {
			Name = "Header",
			LayoutOrder = 0,
			Size = UDim2.new(1, 0, 0, HEADER),
			BackgroundColor3 = WHITE,
			ZIndex = 2,
			[Children] = {
				Kit.corner(innerScope, "M"),
				Kit.gradient(innerScope, {
					Rotation = 90,
					Color = ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color.lighten(tone.Top, 0.1)),
						ColorSequenceKeypoint.new(0.55, tone.Bottom),
						ColorSequenceKeypoint.new(1, Color.darken(tone.Bottom, 0.25)),
					}),
				}),
				Kit.stroke(innerScope, { Thickness = 2.5, Color = tone.Edge }),
				innerScope:New "Frame" {
					Name = "Gloss",
					BackgroundColor3 = WHITE,
					BackgroundTransparency = 0.6,
					Position = UDim2.fromOffset(3, 3),
					Size = UDim2.new(1, -6, 0.42, 0),
					ZIndex = 3,
					[Children] = {
						Kit.corner(innerScope, "M"),
						Kit.gradient(innerScope, { Rotation = 90, Transparency = NumberSequence.new(0.3, 1) }),
					},
				},
				innerScope:New "Frame" {
					Name = "NameAndScore",
					BackgroundTransparency = 1,
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 14, 0.5, 0),
					Size = UDim2.new(1, -(statsWidth + 28), 1, 0),
					ZIndex = 4,
					[Children] = {
						Kit.list(innerScope, { Direction = "X", Padding = 12, VAlign = "Center" }),
						Text(innerScope, {
							LayoutOrder = 1,
							Text = string.upper(tostring(field(team, "name") or `Team {teamIndex}`)),
							TextSize = 26,
							Font = "Display",
							Color = tone.Text,
							StrokeColor = strokeColor,
							ZIndex = 5,
						}),
						innerScope:New "Frame" {
							Name = "TeamScore",
							LayoutOrder = 2,
							Size = UDim2.fromOffset(0, 34),
							AutomaticSize = Enum.AutomaticSize.X,
							BackgroundColor3 = tone.Deep,
							BackgroundTransparency = 0.35,
							ZIndex = 5,
							[Children] = {
								Kit.corner(innerScope, "Full"),
								Kit.padding(innerScope, { 0, 12 }),
								Text(innerScope, {
									Text = innerScope:Computed(function(use)
										return formatValue(use(teamScore))
									end),
									TextSize = 24,
									Font = "Display",
									Size = UDim2.fromScale(0, 1),
									AutoSize = "X",
									StrokeColor = strokeColor,
									ZIndex = 6,
								}),
							},
						},
					},
				},
				-- column labels, aligned with the value cells below
				(function()
					local labels = {}
					for i, label in columns do
						table.insert(labels, Text(innerScope, {
							Name = `Label{i}`,
							LayoutOrder = i,
							Text = string.upper(tostring(label)),
							TextSize = 18,
							Font = "Heavy",
							Size = UDim2.fromOffset(COLUMN, HEADER),
							Color = tone.Text,
							StrokeColor = strokeColor,
							Stroke = 1.5,
							ZIndex = 5,
						}))
					end
					return innerScope:New "Frame" {
						Name = "Labels",
						BackgroundTransparency = 1,
						AnchorPoint = Vector2.new(1, 0.5),
						Position = UDim2.new(1, -8, 0.5, 0),
						Size = UDim2.fromOffset(statsWidth, HEADER),
						ZIndex = 5,
						[Children] = {
							Kit.list(innerScope, { Direction = "X", Padding = 0, HAlign = "Right", VAlign = "Center" }),
							labels,
						},
					}
				end)(),
			},
		}

		return teamIndex,
			innerScope:New "Frame" {
				Name = `Team{teamIndex}`,
				LayoutOrder = teamIndex,
				BackgroundTransparency = 1,
				-- side by side: each team takes its share, following the team count
				Size = if side
					then innerScope:Computed(function(use)
						return UDim2.new(1 / math.max(1, #(use(teams) or {})), -8, 0, 0)
					end)
					else UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				ZIndex = 2,
				[Children] = {
					Kit.list(innerScope, { Direction = "Y", Padding = GAP }),
					header,
					rowFrames,
				},
			}
	end)

	local root = Kit.root(props, { Name = "Scoreboard" })
	root.Size = props.Size or UDim2.fromOffset(760, 620)
	root.BackgroundColor3 = Kit.themed(scope, function(theme)
		return theme.Color.Surface
	end)
	root.BackgroundTransparency = Kit.themed(scope, function(theme)
		return theme.Alpha.Surface
	end)
	local titleHeight = if props.Title then 56 else 0
	root[Children] = {
		Kit.corner(scope, "L"),
		Kit.stroke(scope, {
			Thickness = 2,
			Transparency = 0.75,
			Color = Kit.themed(scope, function(theme)
				return theme.Color.Light
			end),
		}),
		if props.Title
			then Text(scope, {
				Name = "Title",
				Text = props.Title,
				TextSize = 32,
				Font = "Title",
				XAlign = "Left",
				Position = UDim2.fromOffset(20, 12),
				Size = UDim2.new(1, -40, 0, 40),
				Truncate = true,
				ZIndex = 2,
			})
			else nil,
		scope:New "ScrollingFrame" {
			Name = "Teams",
			Position = UDim2.fromOffset(0, titleHeight),
			Size = UDim2.new(1, 0, 1, -titleHeight),
			BackgroundTransparency = 1,
			BorderSizePixel = 0,
			ScrollingDirection = Enum.ScrollingDirection.Y,
			CanvasSize = UDim2.fromScale(0, 0),
			AutomaticCanvasSize = Enum.AutomaticSize.Y,
			ScrollBarThickness = 6,
			ScrollBarImageColor3 = scrollBar.ScrollBarImageColor3,
			ScrollBarImageTransparency = scrollBar.ScrollBarImageTransparency,
			TopImage = scrollBar.TopImage,
			MidImage = scrollBar.MidImage,
			BottomImage = scrollBar.BottomImage,
			VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
			Selectable = false,
			ZIndex = 2,
			[Children] = {
				Kit.list(scope, {
					Direction = if side then "X" else "Y",
					Padding = if side then 16 else 16,
					HAlign = "Center",
				}),
				Kit.padding(scope, 16),
				blocks,
			},
		},
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end

return Scoreboard
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="139">
            <Properties>
              <string name="Name">Screen</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Screen: a ScreenGui with an auto-scaled canvas.

	Lay everything out in design pixels for a 1920x1080 screen. The canvas is
	scaled to fit the real screen, so the same layout works on PC, console,
	tablet and phone.

	scope:Screen {
		Name = "HUD",
		DisplayOrder = 5,
		SafeArea = "Device",   -- "Device" (avoid notches, default) | "Core" (also avoid the Roblox top bar) | "None"
		[Children] = { ... },
	}
]]

local Players = game:GetService("Players")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Viewport = require(script.Parent.Parent.Util.Viewport)

local Children = Fusion.Children

local INSETS = {
	Device = { ignore = true, insets = Enum.ScreenInsets.DeviceSafeInsets },
	Core = { ignore = false, insets = Enum.ScreenInsets.CoreUISafeInsets },
	None = { ignore = true, insets = Enum.ScreenInsets.None },
}

return function(scope: Fusion.Scope<any>, props: { [any]: any }): ScreenGui
	local safeArea = INSETS[props.SafeArea or "Device"] or INSETS.Device

	local parent = props.Parent
	if parent == nil then
		local player = Players.LocalPlayer
		parent = if player then player:WaitForChild("PlayerGui") else nil
	end

	return scope:New "ScreenGui" {
		Name = props.Name or "BloxUI",
		DisplayOrder = props.DisplayOrder or 0,
		Enabled = if props.Enabled == nil then true else props.Enabled,
		ResetOnSpawn = false,
		ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
		IgnoreGuiInset = safeArea.ignore,
		ScreenInsets = safeArea.insets,
		Parent = parent,

		[Children] = scope:New "Frame" {
			Name = "Canvas",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = scope:Computed(function(use)
				local scale = use(Viewport.scale)
				return UDim2.fromScale(1 / scale, 1 / scale)
			end),

			[Children] = {
				scope:New "UIScale" {
					Scale = Viewport.scale,
				},
				props[Children],
			},
		},
	} :: ScreenGui
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="140">
            <Properties>
              <string name="Name">ScreenFX</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Persistent full-screen overlays. Put them in a Screen with SafeArea = "None"
	so they cover the whole display:

	scope:Screen {
		Name = "Overlay", SafeArea = "None", DisplayOrder = -1,
		[Children] = {
			scope:Vignette { Intensity = fear, Style = "Tunnel" },
			scope:Heartbeat { BPM = heartRate, Intensity = fear },
		},
	}
]]

local RunService = game:GetService("RunService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)

local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local ScreenFX = {}

-- Dark screen edges.
--   Intensity: 0..1 (state or number)
--   Style: "Soft" (gentle) | "Tunnel" (hard circle, e.g. low health, flashlight)
--   Spread: >1 widens the clear centre
--   Color: tint (black by default; red for damage looks)
function ScreenFX.Vignette(scope: Scope, props: { [any]: any }): ImageLabel
	local intensity = props.Intensity or 0.6
	local spread = props.Spread or 1
	local animated = scope:Spring(scope:Computed(function(use)
		return use(intensity)
	end), 6, 1)

	local root = Kit.root(props, { Name = "Vignette", ZIndex = 0 })
	root.AnchorPoint = Vector2.new(0.5, 0.5)
	root.Position = UDim2.fromScale(0.5, 0.5)
	root.Size = scope:Computed(function(use)
		local s = use(spread)
		return UDim2.fromScale(s, s)
	end)
	root.BackgroundTransparency = 1
	root.Image = if props.Style == "Tunnel" then Assets.Tunnel else Assets.Vignette
	root.ImageColor3 = props.Color or Color3.new(0, 0, 0)
	root.ImageTransparency = scope:Computed(function(use)
		return 1 - math.clamp(use(animated), 0, 1)
	end)
	root.Active = false
	return scope:New "ImageLabel" (root) :: ImageLabel
end

-- A red vignette that thumps with a double "lub-dub" heartbeat.
--   BPM: beats per minute (state or number)
--   Intensity: 0..1, 0 hides it
--   Sound: optional heartbeat sound played on each beat
-- With reduced motion on it glows steadily and only swells a little.
function ScreenFX.Heartbeat(scope: Scope, props: { [any]: any }): ImageLabel
	local bpm = props.BPM or 80
	local intensity = props.Intensity or 0.5

	local root = Kit.root(props, { Name = "Heartbeat", ZIndex = 0 })
	root.Size = UDim2.fromScale(1, 1)
	root.BackgroundTransparency = 1
	root.Image = Assets.Vignette
	root.ImageColor3 = Kit.pick(scope, props.Color, function(theme)
		return theme.Color.Bad
	end)
	root.ImageTransparency = 1
	root.Active = false

	local image = scope:New "ImageLabel" (root) :: ImageLabel

	local phase = 0
	local lastBeat = 0
	table.insert(scope, RunService.RenderStepped:Connect(function(dt)
		local strength = peek(intensity) or 0
		if strength <= 0.001 then
			image.ImageTransparency = 1
			return
		end
		phase = (phase + dt * (peek(bpm) or 80) / 60) % 1
		local first = math.exp(-((phase - 0.05) / 0.05) ^ 2)
		local second = 0.65 * math.exp(-((phase - 0.22) / 0.05) ^ 2)
		local pulse = math.max(first, second)
		-- reduced motion: a steady glow that only swells gently with the beat
		local glow = if Motion.reduced then 0.45 + 0.12 * pulse else 0.3 + 0.7 * pulse
		image.ImageTransparency = 1 - math.clamp(strength * glow, 0, 1)
		if props.Sound and phase < lastBeat then
			Sound.play(props.Sound, { Volume = strength })
		end
		lastBeat = phase
	end))

	return image
end

return ScreenFX
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="141">
            <Properties>
              <string name="Name">Segmented</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Segmented: a pill switch between a few options, with a glossy highlight
	that springs across to the selected one.

	local quality = scope:Value("High")
	scope:Segmented {
		Options = { "Low", "Medium", "High" },   -- strings, or { Text, Icon, Id } tables
		Value = quality,                          -- Value<string> (the option text or Id)
		OnChanged = function(option) end,
		Tone = "Green",                           -- highlight colour
		Size = UDim2.fromOffset(420, 56),         -- default: 140 px per option
	}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Icon = require(script.Parent.Icon)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

type Option = { Text: string, Icon: any?, Id: any }

local function normalize(options: { any }): { Option }
	local out = {}
	for index, option in options do
		if typeof(option) == "table" then
			local label = option.Text or option.text or tostring(option.Id or option.id or index)
			table.insert(out, {
				Text = label,
				Icon = option.Icon or option.icon,
				Id = option.Id or option.id or label,
			})
		else
			table.insert(out, { Text = tostring(option), Id = option })
		end
	end
	return out
end

return function(scope: Scope, props: { [any]: any }): Frame
	local options = normalize(peek(props.Options) or {})
	local count = math.max(1, #options)
	local value = props.Value
	if typeof(value) ~= "table" or value.set == nil then
		value = scope:Value(if props.Value ~= nil then props.Value else (options[1] and options[1].Id))
	end
	local tone = Kit.tone(scope, props.Tone, "Green")
	local inset = 5

	local size = peek(props.Size) or UDim2.fromOffset(140 * count, 56)
	if typeof(size) == "Vector2" then
		size = UDim2.fromOffset(size.X, size.Y)
	end
	local height = if size.Y.Offset > 0 then size.Y.Offset else 56
	local textSize = props.TextSize or math.floor(height * 0.42)

	local index = scope:Computed(function(use)
		local current = use(value)
		for i, option in options do
			if option.Id == current then
				return i
			end
		end
		return 1
	end)
	local slide = Kit.spring(scope, index, 1.05, 0.72)

	local function choose(option: Option)
		if peek(value) == option.Id then
			return
		end
		Sound.play("Click", { Pitch = 1.1 })
		value:set(option.Id)
		if props.OnChanged then
			props.OnChanged(option.Id)
		end
	end

	local outline = scope:Computed(function(use)
		local t = use(tone)
		return t.Stroke or Color.darken(t.Edge, 0.35)
	end)

	local highlight = scope:New "Frame" {
		Name = "Highlight",
		AnchorPoint = Vector2.new(0, 0.5),
		Position = scope:Computed(function(use)
			return UDim2.new((use(slide) - 1) / count, inset, 0.5, 0)
		end),
		Size = UDim2.new(1 / count, -inset * 2, 1, -inset * 2),
		BackgroundColor3 = Color3.new(1, 1, 1),
		ZIndex = 2,
		[Children] = {
			Kit.corner(scope, props.Radius or "Full"),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = scope:Computed(function(use)
					return Kit.faceColors(use(tone), 0)
				end),
			}),
			scope:New "UIStroke" {
				ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				Color = scope:Computed(function(use)
					return use(tone).Edge
				end),
				Thickness = 2,
			},
			scope:New "Frame" {
				Name = "Gloss",
				BackgroundColor3 = Color3.new(1, 1, 1),
				Position = UDim2.fromOffset(4, 2),
				Size = UDim2.new(1, -8, 0.46, 0),
				ZIndex = 3,
				[Children] = {
					Kit.corner(scope, props.Radius or "Full"),
					Kit.gradient(scope, {
						Rotation = 90,
						Transparency = NumberSequence.new(0.45, 1),
					}),
				},
			},
		},
	}

	local cells = {}
	for i, option in options do
		local selected = scope:Computed(function(use)
			return use(index) == i
		end)
		local hovering = scope:Value(false)
		local mix = Kit.spring(scope, scope:Computed(function(use)
			return if use(selected) then 1 else 0
		end), 1.4, 1)
		local labelColor = scope:Computed(function(use)
			local theme = use(Theme.current)
			local off = if use(hovering) then theme.Color.Text else theme.Color.TextMuted
			return off:Lerp(use(tone).Text, math.clamp(use(mix), 0, 1))
		end)
		local strokeOn = scope:Computed(function(use)
			return use(mix) > 0.5 and use(tone).Stroke ~= nil
		end)
		cells[i] = scope:New "TextButton" {
			Name = option.Text,
			BackgroundTransparency = 1,
			Text = "",
			AutoButtonColor = false,
			LayoutOrder = i,
			Size = UDim2.new(1 / count, 0, 1, 0),
			ZIndex = 4,
			[OnEvent "MouseEnter"] = function()
				hovering:set(true)
			end,
			[OnEvent "MouseLeave"] = function()
				hovering:set(false)
			end,
			[OnEvent "Activated"] = function()
				choose(option)
			end,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 6, HAlign = "Center", VAlign = "Center" }),
				if option.Icon
					then Icon(scope, {
						Icon = option.Icon,
						Size = math.floor(height * 0.46),
						Color = labelColor,
						Stroke = scope:Computed(function(use)
							return if use(strokeOn) then 2 else false
						end),
						StrokeColor = outline,
						LayoutOrder = 1,
						ZIndex = 5,
					})
					else nil,
				scope:New "TextLabel" {
					Name = "Label",
					BackgroundTransparency = 1,
					Size = UDim2.fromScale(0, 1),
					AutomaticSize = Enum.AutomaticSize.X,
					Text = option.Text,
					TextSize = textSize,
					FontFace = scope:Computed(function(use)
						return Theme.font(use(Theme.current), "Heavy")
					end),
					TextColor3 = labelColor,
					LayoutOrder = 2,
					ZIndex = 5,
					[Children] = scope:New "UIStroke" {
						ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual,
						LineJoinMode = Enum.LineJoinMode.Round,
						Color = outline,
						Thickness = 2,
						Enabled = strokeOn,
					},
				},
			},
		}
	end

	local root = Kit.root(props, { Name = "Segmented" })
	root.Size = size
	root.BackgroundColor3 = Kit.themed(scope, function(theme)
		return theme.Color.Track
	end)
	root.BackgroundTransparency = Kit.themed(scope, function(theme)
		return theme.Alpha.Track
	end)
	root[Children] = {
		Kit.corner(scope, props.Radius or "Full"),
		scope:New "UIStroke" {
			ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
			Color = Kit.themed(scope, function(theme)
				return theme.Color.SurfaceLight
			end),
			Thickness = 2,
		},
		highlight,
		scope:New "Frame" {
			Name = "Options",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			ZIndex = 3,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 0, VAlign = "Center" }),
				cells,
			},
		},
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="142">
            <Properties>
              <string name="Name">SpinWheel</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	SpinWheel: a prize wheel. 6-12 wedges in tone / rarity colours, each with
	an icon and an amount, under a chunky rim of blinking bulbs, a glossy dome
	and a hub. A pointer at the top flicks at every wedge boundary with a tick
	that climbs in pitch as the wheel slows; the spin eases out a little past
	the prize, settles back onto it, and the winning wedge lights up in a
	burst.

		local result = scope:Value(nil)
		scope:SpinWheel {
			Wedges = {
				{ icon = "coin", amount = "500", rarity = "Common" },
				{ icon = "gem", amount = "25", rarity = "Epic", tone = "Purple" },
				...                               -- 6 to 12 wedges
			},
			Size = 460,                           -- diameter, design px
			Result = result,                      -- set { index = 3 } (a new table each time) to spin onto wedge 3
			OnStop = function(index) end,         -- once it has settled (the burst plays)
			Spinning = spinning,                  -- optional Value<boolean>, kept up to date
			Luck = false,                         -- bool or state: the rim glows gold (x2 luck)
			Skip = nil,                           -- true: the fast path (default SpinWheel.skip)
			Duration = 5,                         -- seconds of a full spin
		}

	The server decides the wedge (UI.Server rolls it); the wheel only lands
	on it. Reduced motion (UI.Motion.reduced) spins briefly with no
	overshoot; SpinWheel.skip (the Blueprint "skipHatch" setting) plays the
	fast path. Lite mode drops the bulbs and the dome gloss.
]]

local RunService = game:GetService("RunService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)

local SpinWheel = {}

-- true: every spin plays its fast path (the "skipHatch" setting sets it).
SpinWheel.skip = false
SpinWheel.DURATION = 5 -- seconds of a normal spin
SpinWheel.TURNS = 5 -- full turns before it lands
SpinWheel.OVERSHOOT = 0.12 -- of a wedge, past the prize before settling back

local RANK = { Common = 1, Uncommon = 2, Rare = 3, Epic = 4, Legendary = 5, Mythic = 6, Secret = 7 }
-- common wedges alternate between these so neighbours never match
local ALTERNATE = { "Blue", "Pink", "Cyan", "Orange", "Green", "Purple" }

--[[
	How each archetype dresses the wheel. The playful ones get the full
	carnival: bright wedges, a gold rim, chasing bulbs, a glossy dome and a
	gold hub. The serious ones get the same wheel in their own restraint:
	common wedges in two quiet tones (so the rarity wedges carry the colour),
	the rim trimmed in the theme's trim colour, dim or no bulbs, no gloss and
	a dark hub.
	  commons  tones common / uncommon wedges alternate between
	  trim     "Gold" (the Yellow tone) | "Theme" (theme.Color.Trim)
	  bulbs    "Chase" (blinking) | "Dim" (small, steady) | "None"
	  gloss    dome transparency (1 = no dome)
	  hub      "Gold" | "Dark"
	  pointer  "Red" | "Trim" | "Accent"
]]
local CARNIVAL = { commons = ALTERNATE, trim = "Gold", bulbs = "Chase", gloss = 0.8, hub = "Gold", pointer = "Red" }
SpinWheel.LOOKS = {
	Arena = { commons = { "Dark", "Gray" }, trim = "Gold", bulbs = "Dim", gloss = 0.9, hub = "Dark", pointer = "Red" },
	Sleek = { commons = { "Blue", "Dark" }, trim = "Theme", bulbs = "None", gloss = 1, hub = "Dark", pointer = "Accent" },
	Tactical = { commons = { "Gray", "Dark" }, trim = "Theme", bulbs = "Dim", gloss = 1, hub = "Dark", pointer = "Trim" },
	Horror = { commons = { "Dark", "Gray" }, trim = "Theme", bulbs = "None", gloss = 1, hub = "Dark", pointer = "Red" },
}

-- The look for a theme (SpinWheel.LOOKS by archetype, else the carnival).
function SpinWheel.look(theme: any): { [string]: any }
	local archetype = Theme.style(theme, "Archetype")
	-- Toon (the default preset) reports the Arena archetype but dresses it
	-- in glossy 3D buttons: a playful theme, so it keeps the carnival
	if archetype == "Arena" and Theme.style(theme, "ButtonStyle") == "Glossy3D" then
		return CARNIVAL
	end
	return SpinWheel.LOOKS[archetype] or CARNIVAL
end

-- A hard-edged half of a rectangle (a right triangle) from a UIGradient:
-- Rotation 45 keeps the upper-left half, 135 the upper-right one.
local HALF = NumberSequence.new({
	NumberSequenceKeypoint.new(0, 0),
	NumberSequenceKeypoint.new(0.5, 0),
	NumberSequenceKeypoint.new(0.503, 1),
	NumberSequenceKeypoint.new(1, 1),
})

local function toneFor(theme: any, wedge: { [string]: any }, index: number): any
	if wedge.tone then
		return Theme.tone(theme, wedge.tone)
	end
	local rank = RANK[wedge.rarity or "Common"] or 1
	if rank >= 3 then
		return Theme.rarity(theme, wedge.rarity)
	end
	local commons = SpinWheel.look(theme).commons
	return Theme.tone(theme, commons[((index - 1) % #commons) + 1])
end

-- Angle (degrees, clockwise from the top) of wedge `index`'s centre.
function SpinWheel.angleOf(index: number, count: number): number
	return (index - 1) * 360 / count
end

-- The wedge under the pointer when the face is turned `rotation` degrees.
function SpinWheel.wedgeAt(rotation: number, count: number): number
	local step = 360 / count
	local a = (-rotation) % 360
	return (math.floor((a + step / 2) / step) % count) + 1
end

local function SpinWheelComponent(scope: any, props: { [any]: any }): Frame
	local wedges = props.Wedges or {}
	local count = math.max(1, #wedges)
	local size = props.Size or 460
	local radius = size / 2
	local step = 360 / count
	-- the wedges are triangles whose corners poke past the face; the rim
	-- covers them (a wider rim for fewer, fatter wedges)
	local faceRadius = math.floor(math.min(radius * 0.9, radius * math.cos(math.rad(step / 2)) - 3))
	local rimWidth = radius - faceRadius
	local halfWidth = faceRadius * math.tan(math.rad(step / 2))

	local rotation = scope:Value(0)
	local spinning = props.Spinning or scope:Value(false)
	local winner = scope:Value(0) -- the wedge that just won (0 = none)
	local pointerKick = scope:Value(0)
	local pointerAngle = scope:Spring(pointerKick, 40, 0.35)
	local bulbPhase = scope:Value(false)
	local alive = true
	table.insert(scope, function()
		alive = false
	end)

	local luck = scope:Computed(function(use)
		return use(props.Luck) == true
	end)

	local accent = Kit.themed(scope, function(theme)
		return theme.Color.Accent
	end)
	local look = Kit.themed(scope, function(theme)
		return SpinWheel.look(theme)
	end)
	local rimTone = scope:Computed(function(use)
		local theme = use(Theme.current)
		return if use(luck) then theme.Tone.Yellow else Theme.tone(theme, "Dark")
	end)
	-- the rim's trim, bulbs and (gold) hub: gold, or the theme's own trim
	local gold = scope:Computed(function(use)
		local theme = use(Theme.current)
		if use(look).trim == "Theme" and not use(luck) then
			return Theme.makeTone(theme.Color.Trim)
		end
		return theme.Tone.Yellow
	end)

	------------------------------------------------------------------------
	-- The face (everything that turns)
	------------------------------------------------------------------------

	local wedgeParts = {}
	for index, wedge in wedges do
		local angle = SpinWheel.angleOf(index, count)
		local tone = scope:Computed(function(use)
			return toneFor(use(Theme.current), wedge, index)
		end)
		local won = scope:Computed(function(use)
			return use(winner) == index
		end)
		local function half(side: number): Frame
			-- side 1: right of the centre line (Rotation 45), -1: left (135)
			return scope:New "Frame" {
				Name = if side > 0 then "Right" else "Left",
				BackgroundColor3 = WHITE,
				BorderSizePixel = 0,
				AnchorPoint = Vector2.new(if side > 0 then 0 else 1, 1),
				-- 1 px across the centre line: the halves overlap, no seam
				Position = UDim2.new(0.5, -side, 0.5, 0),
				Size = UDim2.fromOffset(halfWidth + 1, faceRadius),
				ZIndex = 2,
				[Children] = scope:New "UIGradient" {
					Rotation = if side > 0 then 45 else 135,
					Transparency = HALF,
					Color = scope:Computed(function(use)
						local t = use(tone)
						local lift = if use(won) then 0.35 else 0
						return ColorSequence.new({
							ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.18 + lift)),
							ColorSequenceKeypoint.new(0.3, Color.lighten(t.Bottom, lift)),
							ColorSequenceKeypoint.new(0.5, Color.darken(t.Bottom, 0.25 - lift * 0.5)),
							ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.25)),
						})
					end),
				},
			}
		end
		-- along the wedge's centre line, reading outwards: the amount out by the
		-- rim (where the wedge is widest), the icon further in
		local amountRadius = faceRadius * 0.8
		local iconRadius = faceRadius * 0.5
		local tangent = math.tan(math.rad(step / 2))
		local iconSize = math.floor(math.clamp(2 * iconRadius * tangent * 0.82, size * 0.07, size * 0.13))
		local amountWidth = math.floor(2 * amountRadius * tangent * 0.86)
		local amountSize = math.floor(math.clamp(size * 0.048, 14, 26))
		local text = tostring(wedge.amount or "")
		if wedge.label and (text == "" or text == "x1") and #wedge.label <= 14 then
			text = wedge.label -- a one-off prize reads better by name (it shrinks to fit)
		end
		table.insert(wedgeParts, scope:New "Frame" {
			Name = `Wedge{index}`,
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromOffset(faceRadius * 2, faceRadius * 2),
			Rotation = angle,
			ZIndex = 2,
			[Children] = {
				half(1),
				half(-1),
				Icon(scope, {
					Name = "Prize",
					Icon = wedge.icon or "gift",
					Size = iconSize,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 0, 0.5, -iconRadius),
					Color = WHITE,
					Gradient = true,
					Stroke = 2.5,
					StrokeColor = scope:Computed(function(use)
						local t = use(tone)
						return t.Stroke or t.Edge
					end),
					Shadow = true,
					ZIndex = 4,
				}),
				Text(scope, {
					Name = "Amount",
					Text = text,
					TextSize = amountSize,
					Font = "Display",
					Case = "Normal",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 0, 0.5, -amountRadius),
					Size = UDim2.fromOffset(amountWidth, amountSize + 4),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = amountSize,
					Stroke = 2,
					StrokeColor = scope:Computed(function(use)
						local t = use(tone)
						return t.Stroke or t.Edge
					end),
					ZIndex = 4,
				}),
			},
		})
		-- a separator along the wedge's leading edge
		table.insert(wedgeParts, scope:New "Frame" {
			Name = `Spoke{index}`,
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromOffset(faceRadius * 2, faceRadius * 2),
			Rotation = angle + step / 2,
			ZIndex = 3,
			[Children] = scope:New "Frame" {
				Name = "Line",
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.fromOffset(math.max(3, math.floor(size / 150)), faceRadius),
				BackgroundColor3 = scope:Computed(function(use)
					return Color.lighten(use(rimTone).Top, 0.55)
				end),
				BorderSizePixel = 0,
				ZIndex = 3,
				[Children] = scope:New "UIGradient" {
					Rotation = 90,
					Transparency = NumberSequence.new({
						NumberSequenceKeypoint.new(0, 0),
						NumberSequenceKeypoint.new(0.8, 0.1),
						NumberSequenceKeypoint.new(1, 0.7),
					}),
				},
			},
		})
	end

	local face = scope:New "Frame" {
		Name = "Face",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(faceRadius * 2, faceRadius * 2),
		Rotation = rotation,
		ZIndex = 2,
		[Children] = wedgeParts,
	}

	------------------------------------------------------------------------
	-- Static parts: base, rim, bulbs, dome, hub, pointer
	------------------------------------------------------------------------

	local function circle(name: string, diameter: number, props2: { [any]: any }): Frame
		local frame = {
			Name = name,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromOffset(diameter, diameter),
			BorderSizePixel = 0,
		}
		for key, value in props2 do
			frame[key] = value
		end
		return scope:New "Frame"(frame) :: Frame
	end

	-- seams between wedges show this (a dark hairline, like a real wheel)
	local base = circle("Base", faceRadius * 2, {
		BackgroundColor3 = scope:Computed(function(use)
			return use(rimTone).Deep
		end),
		ZIndex = 1,
		[Children] = Kit.corner(scope, "Full"),
	})

	-- a soft shadow inside the rim gives the face depth
	local inner = circle("InnerShade", faceRadius * 2, {
		BackgroundTransparency = 1,
		ZIndex = 5,
		[Children] = {
			Kit.corner(scope, "Full"),
			scope:New "UIStroke" {
				Thickness = math.max(4, math.floor(size / 60)),
				Color = Color3.new(0, 0, 0),
				Transparency = 0.55,
				BorderStrokePosition = Enum.BorderStrokePosition.Inner,
			},
		},
	})

	local rim = circle("Rim", faceRadius * 2, {
		BackgroundTransparency = 1,
		ZIndex = 6,
		[Children] = {
			Kit.corner(scope, "Full"),
			scope:New "UIStroke" {
				Thickness = rimWidth,
				Color = scope:Computed(function(use)
					local t = use(rimTone)
					return if use(luck) then t.Bottom else Color.lighten(t.Bottom, 0.12)
				end),
			},
		},
	})
	-- gold (or theme) trim inside and outside the rim
	local trim = math.max(3, math.floor(size / 110))
	local rimEdge = circle("RimEdge", size - trim * 2, {
		BackgroundTransparency = 1,
		ZIndex = 7,
		[Children] = {
			Kit.corner(scope, "Full"),
			scope:New "UIStroke" {
				Thickness = trim,
				Color = scope:Computed(function(use)
					return use(gold).Bottom
				end),
			},
		},
	})
	local rimOuter = circle("RimOuter", size, {
		BackgroundTransparency = 1,
		ZIndex = 7,
		[Children] = {
			Kit.corner(scope, "Full"),
			scope:New "UIStroke" {
				Thickness = 2,
				Color = scope:Computed(function(use)
					return use(gold).Edge
				end),
			},
		},
	})
	local rimLight = circle("RimLight", faceRadius * 2, {
		BackgroundTransparency = 1,
		ZIndex = 7,
		[Children] = {
			Kit.corner(scope, "Full"),
			scope:New "UIStroke" {
				Thickness = trim,
				Color = scope:Computed(function(use)
					return Color.lighten(use(gold).Top, 0.1)
				end),
			},
		},
	})
	-- a round drop shadow under the wheel
	local shadow = circle("Shadow", size, {
		BackgroundColor3 = Color3.new(0, 0, 0),
		BackgroundTransparency = 0.6,
		Position = UDim2.new(0.5, 0, 0.5, math.max(6, math.floor(size / 50))),
		ZIndex = 0,
		[Children] = Kit.corner(scope, "Full"),
	})

	-- bulbs around the rim, blinking in two alternating sets (serious themes:
	-- small steady lights, or none)
	local bulbs = Kit.unlessLite(scope, function(inner2)
		local list = {}
		local bulbCount = math.clamp(count * 2, 12, 24)
		local ring = faceRadius + rimWidth / 2
		local bulbSize = math.clamp(math.floor(rimWidth * 0.42), 7, 16)
		local dim = inner2:Computed(function(use)
			return use(look).bulbs == "Dim"
		end)
		for i = 1, bulbCount do
			local a = math.rad((i - 1) * 360 / bulbCount)
			local lit = inner2:Computed(function(use)
				if use(dim) then
					return use(spinning) and (i % 2 == 0) == use(bulbPhase)
				end
				return (i % 2 == 0) == use(bulbPhase) or use(spinning)
			end)
			table.insert(list, inner2:New "Frame" {
				Name = "Bulb",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, math.sin(a) * ring, 0.5, -math.cos(a) * ring),
				Size = inner2:Computed(function(use)
					local s = if use(dim) then math.max(5, math.floor(bulbSize * 0.55)) else bulbSize
					return UDim2.fromOffset(s, s)
				end),
				BackgroundColor3 = inner2:Computed(function(use)
					local t = use(gold)
					if use(dim) then
						return if use(lit) then Color.lighten(t.Top, 0.35) else t.Bottom
					end
					return if use(lit) then Color.lighten(t.Top, 0.35) else Color.darken(t.Bottom, 0.35)
				end),
				BorderSizePixel = 0,
				ZIndex = 8,
				[Children] = {
					Kit.corner(inner2, "Full"),
					inner2:New "UIStroke" {
						Thickness = 1.5,
						Color = inner2:Computed(function(use)
							return use(rimTone).Edge
						end),
					},
				},
			})
		end
		return inner2:New "Frame" {
			Name = "Bulbs",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			Visible = inner2:Computed(function(use)
				return use(look).bulbs ~= "None"
			end),
			ZIndex = 8,
			[Children] = list,
		}
	end)

	-- a glassy dome over the top half of the face
	local dome = Kit.unlessLite(scope, function(inner2)
		return inner2:New "Frame" {
			Name = "Dome",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(0.5, 0, 0.5, -faceRadius + 6),
			Size = UDim2.fromOffset(faceRadius * 1.7, faceRadius * 0.95),
			BackgroundColor3 = WHITE,
			BackgroundTransparency = inner2:Computed(function(use)
				return use(look).gloss
			end),
			Visible = inner2:Computed(function(use)
				return use(look).gloss < 1
			end),
			BorderSizePixel = 0,
			ZIndex = 5,
			[Children] = {
				inner2:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
				inner2:New "UIGradient" {
					Rotation = 90,
					Transparency = NumberSequence.new({
						NumberSequenceKeypoint.new(0, 0.1),
						NumberSequenceKeypoint.new(0.55, 0.85),
						NumberSequenceKeypoint.new(1, 1),
					}),
				},
			},
		}
	end)

	local hubSize = math.floor(size * 0.2)
	-- a gold boss, or a dark one trimmed in the theme's colour
	local darkHub = scope:Computed(function(use)
		return use(look).hub == "Dark" and not use(luck)
	end)
	local hubTone = scope:Computed(function(use)
		local theme = use(Theme.current)
		return if use(darkHub) then Theme.tone(theme, "Dark") else theme.Tone.Yellow
	end)
	local hub = circle("Hub", hubSize, {
		BackgroundColor3 = WHITE,
		ZIndex = 9,
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = scope:Computed(function(use)
					local t = use(hubTone)
					return ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.3)),
						ColorSequenceKeypoint.new(0.5, t.Bottom),
						ColorSequenceKeypoint.new(1, t.Edge),
					})
				end),
			}),
			Kit.stroke(scope, {
				Thickness = math.max(3, math.floor(size / 120)),
				Color = scope:Computed(function(use)
					return if use(darkHub) then use(gold).Bottom else use(hubTone).Edge
				end),
			}),
			Icon(scope, {
				Icon = if typeof(props.HubIcon) == "string" then props.HubIcon else "star",
				Size = math.floor(hubSize * 0.56),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Color = scope:Computed(function(use)
					return if use(darkHub) then Color.lighten(use(gold).Top, 0.15) else WHITE
				end),
				Stroke = 2.5,
				StrokeColor = scope:Computed(function(use)
					local t = if use(darkHub) then use(gold) else use(hubTone)
					return t.Stroke or t.Edge
				end),
				Shadow = true,
				ZIndex = 10,
			}),
		},
	})

	-- the pointer: a pin head over the rim with a triangle pointing down
	local pointerW = math.floor(size * 0.12)
	local pointerH = math.floor(size * 0.15)
	local function triangle(name: string, w: number, h: number, z: number, color: any): Frame
		local function halfOf(side: number)
			return scope:New "Frame" {
				Name = if side > 0 then "Right" else "Left",
				AnchorPoint = Vector2.new(if side > 0 then 0 else 1, 0),
				Position = UDim2.new(0.5, -side, 0, 0),
				Size = UDim2.fromOffset(w / 2 + 1, h),
				BackgroundColor3 = color,
				BorderSizePixel = 0,
				ZIndex = z,
				-- a triangle pointing down: wide at the top like a wedge (45 / 135)
				[Children] = scope:New "UIGradient" { Rotation = if side > 0 then 45 else 135, Transparency = HALF },
			}
		end
		return scope:New "Frame" {
			Name = name,
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5, 0),
			Size = UDim2.fromOffset(w, h),
			ZIndex = z,
			[Children] = { halfOf(1), halfOf(-1) },
		}
	end
	local pointerTone = scope:Computed(function(use)
		local theme = use(Theme.current)
		local kind = use(look).pointer
		if kind == "Trim" then
			return Theme.makeTone(theme.Color.Trim)
		elseif kind == "Accent" then
			return Theme.makeTone(theme.Color.Accent)
		end
		return theme.Tone.Red
	end)
	local pointer = scope:New "Frame" {
		Name = "Pointer",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, -math.floor(pointerH * 0.35)),
		Size = UDim2.fromOffset(pointerW + 8, pointerH + 8),
		Rotation = pointerAngle,
		ZIndex = 12,
		[Children] = {
			-- outline, face, highlight
			scope:New "Frame" {
				Name = "Outline",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(0, pointerW * 0.28),
				Size = UDim2.new(1, 0, 1, -pointerW * 0.28),
				ZIndex = 12,
				[Children] = triangle("Shape", pointerW + 8, pointerH - pointerW * 0.28 + 8, 12, scope:Computed(function(use)
					return use(pointerTone).Edge
				end)),
			},
			scope:New "Frame" {
				Name = "Body",
				BackgroundTransparency = 1,
				Position = UDim2.fromOffset(4, pointerW * 0.28 + 3),
				Size = UDim2.new(1, -8, 1, -pointerW * 0.28 - 8),
				ZIndex = 13,
				[Children] = triangle("Shape", pointerW, pointerH - pointerW * 0.28, 13, scope:Computed(function(use)
					return use(pointerTone).Bottom
				end)),
			},
			scope:New "Frame" {
				Name = "Head",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.fromScale(0.5, 0),
				Size = UDim2.fromOffset(pointerW * 0.62, pointerW * 0.62),
				BackgroundColor3 = WHITE,
				ZIndex = 14,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = scope:Computed(function(use)
							local t = use(pointerTone)
							return ColorSequence.new(Color.lighten(t.Top, 0.15), t.Bottom)
						end),
					}),
					Kit.stroke(scope, {
						Thickness = 3,
						Color = scope:Computed(function(use)
							return use(pointerTone).Edge
						end),
					}),
				},
			},
		},
	}

	-- the win flash over the pointer's wedge
	local flashGoal = scope:Value(0)
	local flash = scope:Spring(flashGoal, 12, 0.9)
	local winGlow = scope:New "ImageLabel" {
		Name = "WinGlow",
		BackgroundTransparency = 1,
		Image = "rbxasset://textures/ui/InGameMenu/BackgroundGlow.png",
		ImageColor3 = accent,
		ImageTransparency = scope:Computed(function(use)
			return 1 - 0.8 * math.clamp(use(flash), 0, 1)
		end),
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.new(0.5, 0, 0.5, -faceRadius * 0.62),
		Size = UDim2.fromOffset(halfWidth * 2.6, faceRadius * 0.95),
		ZIndex = 5,
	}

	local root = Kit.root(props, { Name = "SpinWheel" })
	root.Size = props.Size and (if typeof(props.Size) == "UDim2" then props.Size else UDim2.fromOffset(size, size)) or UDim2.fromOffset(size, size)
	root.BackgroundTransparency = 1
	root[Children] = {
		shadow,
		base,
		face,
		inner,
		winGlow,
		dome,
		rim,
		rimLight,
		rimEdge,
		rimOuter,
		bulbs,
		hub,
		pointer,
		props[Children],
	}
	local gui = scope:New "Frame"(root) :: Frame

	------------------------------------------------------------------------
	-- Motion
	------------------------------------------------------------------------

	-- idle: the bulbs chase (not with reduced motion, and never in lite mode)
	task.spawn(function()
		while alive do
			task.wait(0.55)
			if alive and not Motion.reduced and not peek(spinning) then
				bulbPhase:set(not peek(bulbPhase))
			end
		end
	end)

	local tickName = if (Theme.get().Sound or {}).CaseTick ~= nil and (Theme.get().Sound or {}).CaseTick ~= "" then "CaseTick" else "Tick"

	local function spinTo(index: number)
		if peek(spinning) or wedges[index] == nil then
			return
		end
		spinning:set(true)
		winner:set(0)
		flashGoal:set(0)
		local skip = if props.Skip ~= nil then peek(props.Skip) == true else SpinWheel.skip == true
		local reduced = Motion.reduced == true
		local duration = if skip then 1.1 elseif reduced then 1.8 else (props.Duration or SpinWheel.DURATION)
		local turns = if skip then 1 elseif reduced then 2 else SpinWheel.TURNS
		local over = if reduced or skip then 0 else SpinWheel.OVERSHOOT * step
		local jitter = (math.random() * 2 - 1) * 0.3 * step
		local start = peek(rotation)
		local landing = -SpinWheel.angleOf(index, count) + jitter
		local forward = (landing - start) % 360
		local target = start + turns * 360 + forward
		local lastWedge = SpinWheel.wedgeAt(start, count)
		local began = os.clock()
		Sound.play("Swoosh")
		while alive do
			local t = math.clamp((os.clock() - began) / duration, 0, 1)
			-- ease-out (quint): races, then crawls past the prize
			local eased = 1 - (1 - t) ^ 5
			local angle = start + (target + over - start) * eased
			rotation:set(angle)
			local now = SpinWheel.wedgeAt(angle, count)
			if now ~= lastWedge then
				lastWedge = now
				-- the pointer flicks against the pegs, harder as it slows
				pointerAngle:setPosition(if reduced then -6 else -14 - 10 * t)
				Sound.play(tickName, { Pitch = 0.8 + 0.8 * t ^ 1.3, Vary = 0.02, Volume = 0.45 + 0.4 * t })
			end
			if t >= 1 then
				break
			end
			RunService.RenderStepped:Wait()
		end
		-- settle back onto the prize
		if over > 0 and alive then
			local settleStart = os.clock()
			local from = peek(rotation)
			while alive do
				local t = math.clamp((os.clock() - settleStart) / 0.45, 0, 1)
				local eased = t * t * (3 - 2 * t)
				rotation:set(from + (target - from) * eased)
				if t >= 1 then
					break
				end
				RunService.RenderStepped:Wait()
			end
		end
		if not alive then
			return
		end
		rotation:set(target % 360)
		spinning:set(false)
		winner:set(index)
		flashGoal:set(1)
		-- the win: a burst at the pointer, a ring, and a pop for rare prizes
		local wedge = wedges[index]
		local theme = Theme.get()
		local tone = toneFor(theme, wedge, index)
		local colors = { tone.Top, WHITE, Color.lighten(tone.Top, 0.4), theme.Color.Accent }
		Sound.play(tickName, { Pitch = 1.9, Vary = 0, Volume = 1 })
		Sound.play("Reward")
		pcall(function()
			local rank = RANK[wedge.rarity or "Common"] or 1
			Particles.burst(pointer, { Count = 14 + rank * 4, Shape = "sparkle", Colors = colors, Size = 26, Speed = 560, Gravity = 260 })
			Particles.ring(winGlow, { Color = Color.lighten(tone.Top, 0.3), Time = 0.4 })
			if rank >= 5 then
				Particles.confetti({ Count = if rank >= 6 then 90 else 60, Colors = colors })
			end
		end)
		pcall(Motion.pop, hub, 0.18)
		task.delay(1.4, function()
			if alive then
				flashGoal:set(0.35)
			end
		end)
		if props.OnStop then
			task.spawn(props.OnStop, index)
		end
	end

	local result = props.Result
	if typeof(result) == "table" and result.type == "State" then
		local function play(value: any)
			if typeof(value) == "table" and typeof(value.index) == "number" then
				task.spawn(spinTo, value.index)
			end
		end
		Kit.watch(scope, result, play)
		play(peek(result))
	end

	return gui
end

-- scope:SpinWheel { ... } (the module is callable), or SpinWheel.new(scope, props)
SpinWheel.new = SpinWheelComponent
setmetatable(SpinWheel, {
	__call = function(_, scope: any, props: { [any]: any })
		return SpinWheelComponent(scope, props)
	end,
})

return SpinWheel
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="143">
            <Properties>
              <string name="Name">Spotlight</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Spotlight: tutorial highlights. The screen dims except for a rounded
	cut-out around a target, a tinted ring pulses around it, and a bouncing
	arrow points at it from a speech bubble.

		local Spotlight = require(BloxUI.Components.Spotlight)

		-- yields until the player clicks (anywhere, or the target)
		Spotlight.highlight(shopButton, {
			Text = "Open the <b>Shop</b> to buy your first pet!",
			Title = "Welcome!",               -- optional
			Side = "Auto",                    -- "Auto" | "Top" | "Bottom" | "Left" | "Right"
			RequireTarget = true,             -- only the target continues (it still gets its click)
			Step = { 1, 3 },                  -- optional progress dots
			Button = "Next",                  -- optional button in the bubble
			Skip = true,                      -- optional "Skip" link (returns "skip")
			Tone = "Yellow",                  -- ring / arrow colour (default: theme accent)
			Padding = 12,                     -- space around the target (design px)
			Radius = 18,                      -- cut-out corner radius
			Dim = 0.7,                        -- darkness outside the cut-out
			Timeout = 20,                     -- optional auto-continue (seconds)
		})

		-- several steps in one overlay: the cut-out glides between targets
		Spotlight.tour({
			{ Target = coins, Text = "These are your coins." },
			{ Target = shopButton, Text = "Spend them here!", RequireTarget = true },
		}, { Skip = true })

	Both return how each step ended: "anywhere" | "target" | "button" | "key"
	| "timeout" | "skip" | "gone" (the target was destroyed).

	The cut-out is four dim frames around the target plus four clipped corner
	pieces (a ring stroke shows only outside the rounded corner), so it is one
	even darkness with no overlaps, and clicks inside the cut-out reach the
	real button underneath.
]]

local GuiService = game:GetService("GuiService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Input = require(script.Parent.Parent.Util.Input)
local Sound = require(script.Parent.Parent.Util.Sound)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Screen = require(script.Parent.Screen)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Button = require(script.Parent.Button)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local Spotlight = {}

export type Options = {
	Text: string?,
	Title: string?,
	Side: string?,
	RequireTarget: boolean?,
	Step: { number }?,
	Button: string?,
	Skip: boolean?,
	Tone: any?,
	Padding: number?,
	Radius: number?,
	Dim: number?,
	Timeout: number?,
	Width: number?,
	DisplayOrder: number?,
	SafeArea: string?, -- match the target's ScreenGui ("Device" | "Core" | "None"); highlight/tour pick it for you
}

local WHITE = Color3.new(1, 1, 1)
local MARGIN = 16
local ARROW = 92 -- arrow length (design px)
local ARROW_W = 72
local ARROW_GAP = 10
local RING_PAD = 6
local SIDES = { Top = true, Bottom = true, Left = true, Right = true }

-- Rough height of the bubble for a text, used to choose a side before the
-- engine has laid the bubble out.
local function estimateHeight(options: Options, width: number): number
	local inner = width - 48
	local size = 24
	local charW = size * 0.52
	local lines = 1
	local line = 0
	for word in string.gmatch(string.gsub(options.Text or "", "<[^>]*>", ""), "%S+") do
		local w = (utf8.len(word) or #word) * charW
		if line > 0 and line + charW * 0.55 + w > inner then
			lines += 1
			line = w
		else
			line += (if line > 0 then charW * 0.55 else 0) + w
		end
	end
	local height = 40 + lines * size * 1.2
	if options.Title then
		height += 38
	end
	height += 40 -- footer
	return height
end

local function screenGuiOf(gui: Instance): Instance?
	local current: Instance? = gui
	while current do
		if current:IsA("LayerCollector") then
			return current
		end
		current = current.Parent
	end
	return nil
end

local function safeAreaFor(target: Instance): string
	local gui = screenGuiOf(target)
	if gui and gui:IsA("ScreenGui") then
		local screenGui = gui :: ScreenGui
		if not screenGui.IgnoreGuiInset then
			return "Core"
		elseif screenGui.ScreenInsets == Enum.ScreenInsets.None then
			return "None"
		end
	end
	return "Device"
end

export type Layout = {
	side: string,
	bubble: Vector2, -- anchor point position
	anchor: Vector2,
	arrow: Vector2, -- centre
	dir: Vector2, -- unit vector from arrow towards the target
	icon: string, -- matching Builder Icons arrow
	rotation: number, -- degrees for an up-pointing arrow
}

-- Places arrow + bubble next to the hole, keeping both on screen.
local function layout(pos: Vector2, size: Vector2, screen: Vector2, wanted: string, bubbleW: number, bubbleH: number): Layout
	local ringPos = pos - Vector2.new(RING_PAD, RING_PAD)
	local ringSize = size + Vector2.new(RING_PAD * 2, RING_PAD * 2)
	local centre = ringPos + ringSize / 2
	local needV = ARROW + ARROW_GAP * 2 + bubbleH + MARGIN
	local needH = ARROW + ARROW_GAP * 2 + bubbleW + MARGIN
	local space = {
		Top = ringPos.Y,
		Bottom = screen.Y - (ringPos.Y + ringSize.Y),
		Left = ringPos.X,
		Right = screen.X - (ringPos.X + ringSize.X),
	}
	local side = wanted
	if not SIDES[side] then
		if space.Bottom >= needV then
			side = "Bottom"
		elseif space.Top >= needV then
			side = "Top"
		elseif space.Right >= needH then
			side = "Right"
		elseif space.Left >= needH then
			side = "Left"
		else
			side = "Bottom"
			for name, amount in space do
				if amount > space[side] then
					side = name
				end
			end
		end
	end

	local minX, maxX = MARGIN, math.max(MARGIN, screen.X - bubbleW - MARGIN)
	local minY, maxY = MARGIN, math.max(MARGIN, screen.Y - bubbleH - MARGIN)
	local arrowMinX, arrowMaxX = MARGIN + ARROW / 2, screen.X - MARGIN - ARROW / 2
	local arrowMinY, arrowMaxY = MARGIN + ARROW / 2, screen.Y - MARGIN - ARROW / 2

	local out: Layout = {
		side = side,
		bubble = Vector2.zero,
		anchor = Vector2.zero,
		arrow = Vector2.zero,
		dir = Vector2.new(0, -1),
		icon = "arrow-large-up",
		rotation = 0,
	}
	if side == "Bottom" or side == "Top" then
		local ax = math.clamp(centre.X, arrowMinX, arrowMaxX)
		local bx = math.clamp(centre.X - bubbleW / 2, minX, maxX)
		if side == "Bottom" then
			local ay = ringPos.Y + ringSize.Y + ARROW_GAP + ARROW / 2
			out.arrow = Vector2.new(ax, math.clamp(ay, arrowMinY, arrowMaxY))
			out.bubble = Vector2.new(bx, math.clamp(ay + ARROW / 2 + ARROW_GAP, minY, maxY))
			out.anchor = Vector2.new(0, 0)
			out.dir = Vector2.new(0, -1)
			out.icon = "arrow-large-up"
			out.rotation = 0
		else
			local ay = ringPos.Y - ARROW_GAP - ARROW / 2
			out.arrow = Vector2.new(ax, math.clamp(ay, arrowMinY, arrowMaxY))
			out.bubble = Vector2.new(bx, math.clamp(ay - ARROW / 2 - ARROW_GAP, minY + bubbleH, screen.Y - MARGIN))
			out.anchor = Vector2.new(0, 1)
			out.dir = Vector2.new(0, 1)
			out.icon = "arrow-large-down"
			out.rotation = 180
		end
	else
		local ay = math.clamp(centre.Y, arrowMinY, arrowMaxY)
		local by = math.clamp(centre.Y, minY + bubbleH / 2, math.max(minY + bubbleH / 2, screen.Y - MARGIN - bubbleH / 2))
		if side == "Right" then
			local ax = ringPos.X + ringSize.X + ARROW_GAP + ARROW / 2
			out.arrow = Vector2.new(math.clamp(ax, arrowMinX, arrowMaxX), ay)
			out.bubble = Vector2.new(math.clamp(ax + ARROW / 2 + ARROW_GAP, minX, maxX), by)
			out.anchor = Vector2.new(0, 0.5)
			out.dir = Vector2.new(-1, 0)
			out.icon = "arrow-large-left"
			out.rotation = -90
		else
			local ax = ringPos.X - ARROW_GAP - ARROW / 2
			out.arrow = Vector2.new(math.clamp(ax, arrowMinX, arrowMaxX), ay)
			out.bubble = Vector2.new(math.clamp(ax - ARROW / 2 - ARROW_GAP, minX + bubbleW, screen.X - MARGIN), by)
			out.anchor = Vector2.new(1, 0.5)
			out.dir = Vector2.new(1, 0)
			out.icon = "arrow-large-right"
			out.rotation = 90
		end
	end
	return out
end
Spotlight.layout = layout

export type Session = {
	focus: (self: Session, target: GuiObject, options: Options?) -> string,
	close: (self: Session) -> (),
	Gui: ScreenGui,
}

--[[
	Opens a spotlight overlay you can move between targets. `focus` yields
	until the step ends; `close` fades the overlay out. highlight() and tour()
	are built on this.
]]
function Spotlight.new(options: Options?): Session
	local base: Options = options or {}
	local scope = Fusion.scoped(Fusion)
	local alive = true
	local done = Instance.new("BindableEvent")
	table.insert(scope, done)

	local current: GuiObject? = nil
	local finished = true
	local stepOptions: Options = base

	-- what the overlay shows
	local shown = scope:Value(false)
	local text = scope:Value(base.Text or "")
	local title = scope:Value(base.Title or "")
	local stepIndex = scope:Value(0)
	local stepCount = scope:Value(0)
	local buttonText = scope:Value(base.Button or "")
	local skippable = scope:Value(base.Skip == true)
	local requireTarget = scope:Value(base.RequireTarget == true)
	local side = scope:Value(base.Side or "Auto")
	local toneName = scope:Value(base.Tone or false)
	local radius = scope:Value(base.Radius or 18)
	local dim = scope:Value(base.Dim or 0.7)
	local bubbleWidth = base.Width or 440
	local skipFocused = scope:Value(false)
	local estimate = scope:Value(estimateHeight(base, bubbleWidth))

	-- geometry in design px
	local screenSize = scope:Value(peek(Viewport.designSize))
	local goalPos = scope:Value(Vector2.new(960, 540))
	local goalSize = scope:Value(Vector2.new(0, 0))
	local holePos = scope:Spring(goalPos, 20, 0.9)
	local holeSize = scope:Spring(goalSize, 20, 0.9)
	local clock = scope:Value(0)

	local appear = scope:Spring(scope:Computed(function(use)
		return if use(shown) then 1 else 0
	end), 22, 1)
	local bubblePopGoal = scope:Value(1)
	local bubblePop = scope:Spring(bubblePopGoal, 24, 0.5)

	local accent = scope:Computed(function(use)
		local theme = use(Theme.current)
		local name = use(toneName)
		if name then
			return Theme.tone(theme, name).Top
		end
		return theme.Color.Accent
	end)
	local accentEdge = scope:Computed(function(use)
		local theme = use(Theme.current)
		local name = use(toneName)
		if name then
			local t = Theme.tone(theme, name)
			return t.Stroke or t.Edge
		end
		return theme.Color.Outline
	end)

	local dimColor = Kit.themed(scope, function(theme)
		return theme.Color.Backdrop
	end)
	local dimAlpha = scope:Computed(function(use)
		return 1 - use(dim) * math.clamp(use(appear), 0, 1)
	end)

	-- hole rectangle (clamped to sensible values)
	local hole = scope:Computed(function(use)
		local p = use(holePos)
		local s = use(holeSize)
		local w, h = math.max(0, s.X), math.max(0, s.Y)
		local r = math.clamp(use(radius), 0, math.min(w, h) / 2)
		return { x = p.X, y = p.Y, w = w, h = h, r = math.floor(r) }
	end)

	local function onDim()
		if finished then
			return
		end
		if peek(requireTarget) then
			Sound.play("Error")
			bubblePop:addVelocity(-3)
			return
		end
		done:Fire("anywhere")
	end

	local function dimPiece(name: string, rect: (h: any, screen: Vector2) -> (number, number, number, number))
		return scope:New "TextButton" {
			Name = name,
			Text = "",
			AutoButtonColor = false,
			Selectable = false,
			BorderSizePixel = 0,
			BackgroundColor3 = dimColor,
			BackgroundTransparency = dimAlpha,
			Position = scope:Computed(function(use)
				local x, y = rect(use(hole), use(screenSize))
				return UDim2.fromOffset(x, y)
			end),
			Size = scope:Computed(function(use)
				local _, _, w, h = rect(use(hole), use(screenSize))
				return UDim2.fromOffset(math.max(0, w), math.max(0, h))
			end),
			ZIndex = 1,
			[OnEvent "Activated"] = onDim,
		}
	end

	-- corner pieces: a circle's outer stroke, clipped to the hole's corner square
	local function cornerPiece(name: string, right: boolean, bottom: boolean)
		return scope:New "Frame" {
			Name = name,
			BackgroundTransparency = 1,
			ClipsDescendants = true,
			Position = scope:Computed(function(use)
				local h = use(hole)
				return UDim2.fromOffset(if right then h.x + h.w - h.r else h.x, if bottom then h.y + h.h - h.r else h.y)
			end),
			Size = scope:Computed(function(use)
				local r = use(hole).r
				return UDim2.fromOffset(r, r)
			end),
			Visible = scope:Computed(function(use)
				return use(hole).r >= 1
			end),
			ZIndex = 1,
			[Children] = scope:New "Frame" {
				Name = "Arc",
				BackgroundTransparency = 1,
				Position = scope:Computed(function(use)
					local r = use(hole).r
					return UDim2.fromOffset(if right then -r else 0, if bottom then -r else 0)
				end),
				Size = scope:Computed(function(use)
					local r = use(hole).r
					return UDim2.fromOffset(r * 2, r * 2)
				end),
				[Children] = {
					scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
					scope:New "UIStroke" {
						ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
						Color = dimColor,
						Transparency = dimAlpha,
						Thickness = scope:Computed(function(use)
							return use(hole).r + 2
						end),
					},
				},
			},
		}
	end

	local ringFrame = function(name: string, extra: number, thickness: number, transparency: any, scale: any)
		return scope:New "Frame" {
			Name = name,
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = scope:Computed(function(use)
				local h = use(hole)
				return UDim2.fromOffset(h.x + h.w / 2, h.y + h.h / 2)
			end),
			Size = scope:Computed(function(use)
				local h = use(hole)
				return UDim2.fromOffset(h.w + (RING_PAD + extra) * 2, h.h + (RING_PAD + extra) * 2)
			end),
			ZIndex = 2,
			[Children] = {
				scope:New "UIScale" { Scale = scale },
				scope:New "UICorner" {
					CornerRadius = scope:Computed(function(use)
						local h = use(hole)
						return UDim.new(0, h.r + RING_PAD + extra)
					end),
				},
				scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
					Color = accent,
					Thickness = thickness,
					Transparency = transparency,
				},
			},
		}
	end

	local visibleAmount = scope:Computed(function(use)
		return math.clamp(use(appear), 0, 1)
	end)

	local ring = ringFrame(
		"Ring",
		0,
		4,
		scope:Computed(function(use)
			return 1 - use(visibleAmount)
		end),
		scope:Computed(function(use)
			return 1 + 0.035 * math.sin(use(clock) * 5)
		end)
	)
	local ping = ringFrame(
		"Ping",
		0,
		3,
		scope:Computed(function(use)
			local phase = (use(clock) % 1.3) / 1.3
			return 1 - (1 - phase) * 0.75 * use(visibleAmount)
		end),
		scope:Computed(function(use)
			local phase = (use(clock) % 1.3) / 1.3
			return 1 + phase * 0.22
		end)
	)
	local place = scope:Computed(function(use)
		local h = use(hole)
		return layout(Vector2.new(h.x, h.y), Vector2.new(h.w, h.h), use(screenSize), use(side), bubbleWidth, use(estimate))
	end)

	-- chunky cartoon arrow: a rounded triangle glyph on a rounded shaft, with
	-- a patch hiding the seam so the outline reads as one shape
	local arrowScale = scope:Computed(function(use)
		return 0.6 + 0.4 * use(visibleAmount)
	end)
	local arrow = scope:New "Frame" {
		Name = "Arrow",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Size = UDim2.fromOffset(ARROW_W, ARROW),
		Rotation = scope:Computed(function(use)
			return use(place).rotation
		end),
		Position = scope:Computed(function(use)
			local l = use(place)
			local bob = math.abs(math.sin(use(clock) * 4)) * 10
			local p = l.arrow + l.dir * bob
			return UDim2.fromOffset(p.X, p.Y)
		end),
		Visible = scope:Computed(function(use)
			return use(visibleAmount) > 0.05
		end),
		ZIndex = 3,
		[Children] = {
			scope:New "UIScale" { Scale = arrowScale },
			scope:New "Frame" {
				Name = "Shaft",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, 42),
				Size = UDim2.fromOffset(28, ARROW - 46),
				BackgroundColor3 = accent,
				ZIndex = 3,
				[Children] = {
					scope:New "UICorner" { CornerRadius = UDim.new(0, 9) },
					scope:New "UIStroke" { Color = accentEdge, Thickness = 3.5 },
					scope:New "Frame" {
						Name = "Shine",
						BackgroundColor3 = WHITE,
						BackgroundTransparency = 0.55,
						BorderSizePixel = 0,
						Position = UDim2.fromOffset(5, 12),
						Size = UDim2.new(0, 6, 1, -20),
						ZIndex = 5,
						[Children] = scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
					},
				},
			},
			Icon(scope, {
				Name = "Head",
				Icon = "caret-small-up",
				Size = 170,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 0, 0, 27),
				Color = accent,
				StrokeColor = accentEdge,
				Stroke = 3.5,
				ZIndex = 4,
			}),
			scope:New "Frame" {
				Name = "Seam",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, 38),
				Size = UDim2.fromOffset(28, 13),
				BackgroundColor3 = accent,
				BorderSizePixel = 0,
				ZIndex = 5,
			},
		},
	}

	local dots = scope:Computed(function(use, inner)
		local count = use(stepCount)
		if count <= 1 then
			return {}
		end
		local index = use(stepIndex)
		local list = {}
		for i = 1, count do
			table.insert(list, inner:New "Frame" {
				Name = `Dot{i}`,
				LayoutOrder = i,
				Size = if i == index then UDim2.fromOffset(22, 10) else UDim2.fromOffset(10, 10),
				BackgroundColor3 = inner:Computed(function(useInner)
					local theme = useInner(Theme.current)
					return if i == index then useInner(accent) else theme.Tone.White.Edge
				end),
				[Children] = inner:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
			})
		end
		return list
	end)

	local bubble = scope:New "Frame" {
		Name = "Bubble",
		Size = UDim2.fromOffset(bubbleWidth, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		AnchorPoint = scope:Computed(function(use)
			return use(place).anchor
		end),
		Position = scope:Computed(function(use)
			local b = use(place).bubble
			return UDim2.fromOffset(b.X, b.Y)
		end),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Light
		end),
		Visible = scope:Computed(function(use)
			return use(visibleAmount) > 0.05
		end),
		ZIndex = 4,
		[Children] = {
			scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					return (0.85 + 0.15 * use(visibleAmount)) * use(bubblePop)
				end),
			},
			Kit.corner(scope, "L"),
			Kit.stroke(scope, {
				Thickness = 3,
				Color = scope:Computed(function(use)
					return use(accent):Lerp(use(Theme.current).Tone.White.Edge, 0.35)
				end),
			}),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = Kit.themed(scope, function(theme)
					return ColorSequence.new(WHITE, theme.Tone.White.Bottom)
				end),
			}),
			Kit.padding(scope, { 20, 24, 18, 24 }),
			Kit.list(scope, { Direction = "Y", Padding = 10 }),
			scope:New "Frame" {
				Name = "Header",
				LayoutOrder = 1,
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 34),
				Visible = scope:Computed(function(use)
					return use(title) ~= ""
				end),
				ZIndex = 5,
				[Children] = {
					Kit.list(scope, { Direction = "X", Padding = 10, VAlign = "Center" }),
					scope:New "Frame" {
						Name = "Spark",
						LayoutOrder = 1,
						Size = UDim2.fromOffset(34, 34),
						BackgroundColor3 = accent,
						ZIndex = 5,
						[Children] = {
							Kit.corner(scope, "Full"),
							Icon(scope, {
								Icon = "star",
								Size = 22,
								AnchorPoint = Vector2.new(0.5, 0.5),
								Position = UDim2.fromScale(0.5, 0.5),
								Color = WHITE,
								StrokeColor = accentEdge,
								Stroke = 1.5,
								ZIndex = 6,
							}),
						},
					},
					Text(scope, {
						Name = "Title",
						Text = title,
						TextSize = 30,
						Font = "Title",
						LayoutOrder = 2,
						Stroke = false,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.TextDark
						end),
						ZIndex = 5,
					}),
				},
			},
			Text(scope, {
				Name = "Body",
				LayoutOrder = 2,
				Text = text,
				TextSize = 24,
				Font = "Body",
				RichText = true,
				Wrapped = true,
				XAlign = "Left",
				YAlign = "Top",
				Stroke = false,
				Size = UDim2.new(1, 0, 0, 0),
				AutoSize = "Y",
				LineHeight = 1.1,
				Color = Kit.themed(scope, function(theme)
					return theme.Color.TextDark
				end),
				ZIndex = 5,
			}),
			scope:New "Frame" {
				Name = "Footer",
				LayoutOrder = 3,
				BackgroundTransparency = 1,
				Size = UDim2.new(1, 0, 0, 40),
				ZIndex = 5,
				[Children] = {
					scope:New "Frame" {
						Name = "Dots",
						BackgroundTransparency = 1,
						AnchorPoint = Vector2.new(0, 0.5),
						Position = UDim2.fromScale(0, 0.5),
						Size = UDim2.fromOffset(0, 12),
						AutomaticSize = Enum.AutomaticSize.X,
						ZIndex = 5,
						[Children] = {
							Kit.list(scope, { Direction = "X", Padding = 6, VAlign = "Center" }),
							dots,
						},
					},
					Text(scope, {
						Name = "Hint",
						Text = scope:Computed(function(use)
							if use(requireTarget) then
								return if use(Input.device) == "Touch" then "Tap the highlighted button" else "Click the highlighted button"
							elseif use(buttonText) ~= "" then
								return ""
							end
							local device = use(Input.device)
							if device == "Gamepad" then
								return "Press A to continue"
							elseif device == "Touch" then
								return "Tap anywhere to continue"
							end
							return "Click anywhere to continue"
						end),
						TextSize = 17,
						Font = "Heavy",
						Stroke = false,
						AnchorPoint = Vector2.new(1, 0.5),
						Position = UDim2.fromScale(1, 0.5),
						Color = Kit.themed(scope, function(theme)
							return theme.Tone.White.Deep
						end),
						Visible = scope:Computed(function(use)
							return use(buttonText) == "" or use(requireTarget)
						end),
						ZIndex = 5,
					}),
					scope:New "TextButton" {
						Name = "Skip",
						Text = "<u>Skip tutorial</u>",
						RichText = true,
						FontFace = Kit.themed(scope, function(theme)
							return theme.Font.Heavy
						end),
						TextSize = 17,
						TextColor3 = Kit.themed(scope, function(theme)
							return theme.Tone.White.Deep
						end),
						BackgroundTransparency = 1,
						AutoButtonColor = false,
						AnchorPoint = Vector2.new(0, 0.5),
						Position = scope:Computed(function(use)
							return UDim2.new(0, if use(stepCount) > 1 then use(stepCount) * 16 + 24 else 0, 0.5, 0)
						end),
						Size = UDim2.fromOffset(110, 30),
						TextXAlignment = Enum.TextXAlignment.Left,
						Visible = skippable,
						ZIndex = 6,
						-- a themed ring instead of Roblox's blue box on gamepads
						SelectionImageObject = Kit.NoSelection,
						[OnEvent "SelectionGained"] = function()
							skipFocused:set(true)
						end,
						[OnEvent "SelectionLost"] = function()
							skipFocused:set(false)
						end,
						[OnEvent "Activated"] = function()
							if not finished then
								Sound.play("Close")
								done:Fire("skip")
							end
						end,
						[Children] = Kit.focusRing(scope, { Focused = skipFocused, Radius = "S", Gap = 2 }),
					},
					Button(scope, {
						Name = "Next",
						Text = buttonText,
						Tone = "Green",
						TextSize = 22,
						Size = UDim2.fromOffset(128, 44),
						AnchorPoint = Vector2.new(1, 0.5),
						Position = UDim2.fromScale(1, 0.5),
						Visible = scope:Computed(function(use)
							return use(buttonText) ~= "" and not use(requireTarget)
						end),
						OnClick = function()
							if not finished then
								done:Fire("button")
							end
						end,
						ZIndex = 6,
					}),
				},
			},
		},
	}

	local canvasRef: Frame? = nil
	local gui = Screen(scope, {
		Name = "BloxUI_Spotlight",
		DisplayOrder = base.DisplayOrder or 980,
		SafeArea = base.SafeArea or "Device",
		[Children] = {
			dimPiece("DimTop", function(h, s)
				return 0, 0, s.X, h.y
			end),
			dimPiece("DimBottom", function(h, s)
				local top = h.y + h.h
				return 0, top, s.X, s.Y - top
			end),
			dimPiece("DimLeft", function(h, _)
				return 0, h.y, h.x, h.h
			end),
			dimPiece("DimRight", function(h, s)
				local left = h.x + h.w
				return left, h.y, s.X - left, h.h
			end),
			cornerPiece("CornerTL", false, false),
			cornerPiece("CornerTR", true, false),
			cornerPiece("CornerBL", false, true),
			cornerPiece("CornerBR", true, true),
			ping,
			ring,
			arrow,
			bubble,
		},
	})
	canvasRef = gui:FindFirstChild("Canvas") :: Frame?

	local function measure()
		local target = current
		if target == nil or canvasRef == nil then
			return
		end
		if target.Parent == nil then
			if not finished then
				done:Fire("gone")
			end
			return
		end
		local scale = peek(Viewport.scale)
		local canvasSize = canvasRef.AbsoluteSize / scale
		if canvasSize.X > 1 and (canvasSize - peek(screenSize)).Magnitude > 0.5 then
			screenSize:set(canvasSize)
		end
		local padding = stepOptions.Padding or base.Padding or 12
		local pos = (target.AbsolutePosition - canvasRef.AbsolutePosition) / scale - Vector2.new(padding, padding)
		local size = target.AbsoluteSize / scale + Vector2.new(padding * 2, padding * 2)
		if (pos - peek(goalPos)).Magnitude > 0.5 then
			goalPos:set(pos)
		end
		if (size - peek(goalSize)).Magnitude > 0.5 then
			goalSize:set(size)
		end
	end

	local elapsed = 0
	table.insert(scope, RunService.RenderStepped:Connect(function(dt: number)
		if not alive then
			return
		end
		elapsed += dt
		clock:set(elapsed)
		measure()
	end))

	-- clicks inside the cut-out on something that isn't a button
	table.insert(scope, UserInputService.InputBegan:Connect(function(input: InputObject)
		if not alive or finished or current == nil then
			return
		end
		local inputType = input.UserInputType
		local key = input.KeyCode
		if inputType == Enum.UserInputType.MouseButton1 or inputType == Enum.UserInputType.Touch then
			if current:IsA("GuiButton") then
				return -- its Activated event handles it
			end
			local gui = screenGuiOf(current)
			local offset = Vector2.zero
			if gui and gui:IsA("ScreenGui") and (gui :: ScreenGui).IgnoreGuiInset then
				offset = GuiService:GetGuiInset()
			end
			local point = Vector2.new(input.Position.X, input.Position.Y) + offset
			local topLeft = current.AbsolutePosition
			local bottomRight = topLeft + current.AbsoluteSize
			if point.X >= topLeft.X and point.X <= bottomRight.X and point.Y >= topLeft.Y and point.Y <= bottomRight.Y then
				done:Fire("target")
			end
		elseif key == Enum.KeyCode.Space or key == Enum.KeyCode.Return or key == Enum.KeyCode.ButtonA then
			if not peek(requireTarget) then
				done:Fire("key")
			end
		elseif (key == Enum.KeyCode.Escape or key == Enum.KeyCode.ButtonB) and peek(skippable) then
			Sound.play("Close")
			done:Fire("skip")
		end
	end))

	local session = {} :: any
	session.Gui = gui

	function session.focus(_self: any, target: GuiObject, opts: Options?): string
		assert(alive, "[BloxUI] Spotlight: this spotlight was closed")
		local o: Options = opts or {}
		local function pick(key: string): any
			local value = (o :: any)[key]
			if value == nil then
				value = (base :: any)[key]
			end
			return value
		end
		stepOptions = o
		local first = current == nil
		current = target
		finished = false

		text:set(pick("Text") or "")
		title:set(pick("Title") or "")
		buttonText:set(pick("Button") or "")
		skippable:set(pick("Skip") == true)
		requireTarget:set(pick("RequireTarget") == true)
		side:set(pick("Side") or "Auto")
		toneName:set(pick("Tone") or false)
		radius:set(pick("Radius") or 18)
		dim:set(pick("Dim") or 0.7)
		local step = pick("Step")
		stepIndex:set(if step then step[1] else 0)
		stepCount:set(if step then step[2] else 0)
		estimate:set(estimateHeight({ Text = pick("Text"), Title = pick("Title") }, bubbleWidth))

		measure()
		if first then
			-- iris in from a wide circle. A Fusion spring only takes in a new
			-- goal on its next frame, and that would undo a jump made now (the
			-- hole would grow out of the screen centre), so let a frame pass
			-- first; the overlay is still invisible then.
			RunService.RenderStepped:Wait()
			measure()
			local pos, size = peek(goalPos), peek(goalSize)
			holePos:setPosition(pos - Vector2.new(420, 420))
			holeSize:setPosition(size + Vector2.new(840, 840))
		end
		shown:set(true)
		bubblePop:setPosition(0.8)
		bubblePop:addVelocity(3)
		Sound.play("Notify")

		local connection: RBXScriptConnection? = nil
		if target:IsA("GuiButton") then
			connection = (target :: GuiButton).Activated:Connect(function()
				if not finished then
					done:Fire("target")
				end
			end)
			if peek(Input.device) == "Gamepad" then
				GuiService.SelectedObject = target
			end
		end

		local timeout = pick("Timeout")
		local token = {}
		;(session :: any)._token = token
		if timeout then
			task.delay(timeout, function()
				if alive and not finished and (session :: any)._token == token then
					done:Fire("timeout")
				end
			end)
		end

		local how = done.Event:Wait()
		finished = true
		if connection then
			connection:Disconnect()
		end
		if how ~= "gone" then
			Sound.play("Click", { Pitch = 1.15 })
		end
		return how
	end

	function session.close(_self: any)
		if not alive then
			return
		end
		finished = true
		shown:set(false)
		goalSize:set(peek(goalSize) + Vector2.new(260, 260))
		goalPos:set(peek(goalPos) - Vector2.new(130, 130))
		task.delay(0.3, function()
			if alive then
				alive = false
				Fusion.doCleanup(scope)
			end
		end)
	end

	return session :: Session
end

-- Highlights one GuiObject and yields until the step ends. Returns how it
-- ended ("anywhere" | "target" | "button" | "key" | "timeout" | "skip" | "gone").
function Spotlight.highlight(target: GuiObject, options: Options?): string
	if Players.LocalPlayer == nil then
		return "gone" -- nobody to teach (Studio edit mode, plugin preview)
	end
	local opts = table.clone(options or {}) :: any
	opts.SafeArea = opts.SafeArea or safeAreaFor(target)
	local session = Spotlight.new(opts)
	local how = session:focus(target, options)
	session:close()
	return how
end

-- Walks through several targets in one overlay. Each step is Options plus
-- `Target`. Stops early when the player skips. Returns each step's result.
function Spotlight.tour(steps: { { [string]: any } }, options: Options?): { string }
	local results = {}
	if Players.LocalPlayer == nil then
		return results
	end
	local opts = table.clone(options or {}) :: any
	if opts.SafeArea == nil and steps[1] and steps[1].Target then
		opts.SafeArea = safeAreaFor(steps[1].Target)
	end
	local session = Spotlight.new(opts)
	for index, step in steps do
		local stepOptions = table.clone(step)
		if stepOptions.Step == nil and #steps > 1 then
			stepOptions.Step = { index, #steps }
		end
		local how = session:focus(step.Target, stepOptions :: any)
		table.insert(results, how)
		if how == "skip" then
			break
		end
	end
	session:close()
	return results
end

return Spotlight
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="144">
            <Properties>
              <string name="Name">StatRow</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	StatRow: one labelled stat ("Damage 120", "Walk Speed 24") with an optional
	icon tile and an optional progress bar. Stack them in a Column for stats
	pages, item details and profiles.

	scope:StatRow {
		Label = "Damage",
		Icon = "melee",               -- optional, shown in a tone tile
		Value = damage,               -- text, number or state
		Bar = 0.6,                    -- optional 0..1 (or state): thin bar under the row
		ReserveBar = false,           -- true: lay out as if it had a bar, so rows with and
		                              -- without bars side by side share one baseline
		Tone = "Red",                 -- tile + bar colour (default "Blue")
		Size = UDim2.new(1, 0, 0, 56),
	}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Kit = require(script.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Util.Format)
local Color = require(script.Parent.Parent.Util.Color)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local function formatValue(value: any): string
	if value == nil then
		return ""
	elseif typeof(value) == "number" then
		return if math.abs(value) >= 1e6 then Format.abbrev(value) else Format.comma(value)
	end
	return tostring(value)
end

local function StatRow(scope: Scope, props: { [any]: any }): Frame
	local tone = Kit.tone(scope, props.Tone, "Blue")
	local hasBar = props.Bar ~= nil
	local hasIcon = props.Icon ~= nil
	local barLayout = hasBar or props.ReserveBar == true

	local size = peek(props.Size)
	local height = if typeof(size) == "UDim2" and size.Y.Offset > 0 then size.Y.Offset else (if barLayout then 68 else 56)
	local tile = if barLayout then height - 20 else height - 16
	local textLeft = if hasIcon then 8 + tile + 12 else 16
	local labelSize = if height >= 44 then 20 else math.max(15, math.floor(height * 0.4))
	local valueSize = if height >= 44 then 24 else math.max(17, math.floor(height * 0.48))
	local rowTop = if barLayout then math.floor(height * 0.4) else math.floor(height / 2)

	local fraction = scope:Spring(scope:Computed(function(use)
		return math.clamp(tonumber(use(props.Bar)) or 0, 0, 1)
	end), 16, 1)

	local strokeColor = scope:Computed(function(use)
		local t = use(tone)
		return t.Stroke or t.Edge
	end)

	local root = Kit.root(props, { Name = "StatRow" })
	root.Size = props.Size or UDim2.new(1, 0, 0, height)
	root.BackgroundColor3 = Kit.themed(scope, function(theme)
		return theme.Color.SurfaceLight
	end)
	root.BackgroundTransparency = 0.45
	root[Children] = {
		Kit.corner(scope, "M"),
		scope:New "Frame" {
			Name = "Highlight",
			BackgroundColor3 = Color3.new(1, 1, 1),
			BackgroundTransparency = 0.92,
			Size = UDim2.new(1, 0, 0.5, 0),
			ZIndex = 1,
			[Children] = {
				Kit.corner(scope, "M"),
				Kit.gradient(scope, {
					Rotation = 90,
					Transparency = NumberSequence.new(0, 1),
				}),
			},
		},
		if hasIcon
			then scope:New "Frame" {
				Name = "IconTile",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 8, 0.5, 0),
				Size = UDim2.fromOffset(tile, tile),
				BackgroundColor3 = Color3.new(1, 1, 1),
				ZIndex = 2,
				[Children] = {
					Kit.corner(scope, "S"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = scope:Computed(function(use)
							local t = use(tone)
							return ColorSequence.new({
								ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.1)),
								ColorSequenceKeypoint.new(0.6, t.Bottom),
								ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.2)),
							})
						end),
					}),
					Kit.stroke(scope, {
						Thickness = 2,
						Color = scope:Computed(function(use)
							return use(tone).Edge
						end),
					}),
					Icon(scope, {
						Icon = props.Icon,
						Size = math.floor(tile * 0.62),
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = scope:Computed(function(use)
							return use(tone).Text
						end),
						StrokeColor = strokeColor,
						Stroke = 2,
						Shadow = true,
						ZIndex = 3,
					}),
				},
			}
			else nil,
		Text(scope, {
			Name = "Label",
			Text = props.Label or "",
			TextSize = labelSize,
			Font = "Heavy",
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, textLeft, 0, rowTop),
			Size = UDim2.new(0.62, -textLeft, 0, labelSize + 6),
			Truncate = true,
			Color = Kit.themed(scope, function(theme)
				return theme.Color.TextMuted
			end),
			Stroke = false,
			ZIndex = 3,
		}),
		Text(scope, {
			Name = "Value",
			Text = scope:Computed(function(use)
				return formatValue(use(props.Value))
			end),
			TextSize = valueSize,
			Font = "Display",
			XAlign = "Right",
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -16, 0, rowTop),
			Size = UDim2.new(0.38, 0, 0, valueSize + 6),
			Truncate = true,
			ZIndex = 3,
		}),
		if hasBar
			then scope:New "Frame" {
				Name = "Track",
				AnchorPoint = Vector2.new(0, 1),
				Position = UDim2.new(0, textLeft, 1, -12),
				Size = UDim2.new(1, -textLeft - 16, 0, 8),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Track
				end),
				BackgroundTransparency = Kit.themed(scope, function(theme)
					return theme.Alpha.Track
				end),
				ZIndex = 2,
				[Children] = {
					Kit.corner(scope, "Full"),
					scope:New "Frame" {
						Name = "Fill",
						Size = scope:Computed(function(use)
							return UDim2.fromScale(use(fraction), 1)
						end),
						BackgroundColor3 = Color3.new(1, 1, 1),
						Visible = scope:Computed(function(use)
							return use(fraction) > 0.005
						end),
						ZIndex = 3,
						[Children] = {
							Kit.corner(scope, "Full"),
							Kit.gradient(scope, {
								Rotation = 90,
								Color = scope:Computed(function(use)
									local t = use(tone)
									return ColorSequence.new(Color.lighten(t.Top, 0.15), t.Bottom)
								end),
							}),
						},
					},
				},
			}
			else nil,
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end

return StatRow
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="145">
            <Properties>
              <string name="Name">Static</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Static: TV snow crawling in from the screen edges (the Slender-style
	"it's close" signal). Used by the Blueprint Vignette style "Static" (v1.7).

	scope:Static {
		Intensity = 0.4,     -- 0..1 (number or state): how many specks show and how far in
	}

	Reduced motion freezes the snow (a still, fainter grain); lite mode draws
	none of it. The specks are placed with a fixed seed, so renders are stable.
]]

local RunService = game:GetService("RunService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Motion = require(script.Parent.Parent.FX.Motion)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local COUNT = 140

-- deterministic noise so renders stay stable
local function seeded(seed: number): () -> number
	local state = seed
	return function()
		state = (state * 16807) % 2147483647
		return (state - 1) / 2147483646
	end
end

-- a point hugging the screen edges (more specks the closer to an edge)
local function edgePoint(random: () -> number): (number, number)
	local along = random()
	local depth = (random() ^ 2.2) * 0.22
	local side = math.floor(random() * 4)
	if side == 0 then
		return along, depth
	elseif side == 1 then
		return along, 1 - depth
	elseif side == 2 then
		return depth, along
	end
	return 1 - depth, along
end

return function(scope: Scope, props: { [any]: any }): Frame
	local intensity = props.Intensity or 0.4
	local strength = scope:Computed(function(use)
		return math.clamp(tonumber(use(intensity)) or 0, 0, 1)
	end)
	local random = seeded(4211)
	local specks = {}
	local weights = {}
	for index = 1, COUNT do
		local x, y = edgePoint(random)
		local big = random() > 0.8
		local weight = 0.25 + random() * 0.6
		weights[index] = weight
		table.insert(specks, scope:New "Frame" {
			Name = "Speck",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(x, y),
			Size = UDim2.fromOffset(if big then 9 else 4, if big then 3 else 4),
			BackgroundColor3 = if index % 4 == 0 then Color3.new(0, 0, 0) else Color3.new(1, 1, 1),
			BackgroundTransparency = 1 - peek(strength) * weight * 0.8,
			BorderSizePixel = 0,
		})
	end

	local clock = 0
	local shake = seeded(97)
	table.insert(scope, RunService.RenderStepped:Connect(function(dt)
		local s = peek(strength)
		if s <= 0 or Theme.isLite() then
			return
		end
		clock += dt
		if clock < 0.06 then
			return
		end
		clock = 0
		local calm = Motion.reduced
		for index, speck in specks do
			speck.BackgroundTransparency = 1 - s * weights[index] * (if calm then 0.45 else 0.8)
			if not calm then
				local x, y = edgePoint(shake)
				speck.Position = UDim2.fromScale(x, y)
			end
		end
	end))

	local root = Kit.root(props, { Name = "Static" })
	root.Size = UDim2.fromScale(1, 1)
	root.BackgroundTransparency = 1
	root.Active = false
	root.Visible = scope:Computed(function(use)
		return use(strength) > 0 and not use(Theme.liteMode) and Theme.lite ~= true
	end)
	root[Children] = { specks, props[Children] }
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="146">
            <Properties>
              <string name="Name">TabBar</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	TabBar: a row of glass tabs whose big icons pop out over the top edge
	(Rivals style). The selected tab flips to solid white with dark text.
	Optional badges, callout bubbles and a red close button.

	local selected = scope:Value("keys")
	scope:TabBar {
		Tabs = {
			{ Id = "home", Text = "Home", Icon = "shop", IconColor = Color3.fromHex("FF4B4B") },
			{ Id = "daily", Text = "Daily", Icon = "daily", Callout = "Claim your FREE reward now!" },
			{ Id = "keys", Text = "Keys", Icon = "key", Badge = 2 },
		},
		Selected = selected,          -- Value holding the selected Id
		OnSelect = function(id) end,  -- optional
		OnClose = function() end,     -- optional: adds a red close button
		TabSize = Vector2.new(150, 50),
		Gap = 16,
		Vertical = false,             -- true: a vertical list of wide tabs (side navigation)
		OnLight = false,              -- true (or state) when drawn on a light panel: white tabs,
		                              -- the selected one in the theme's trim colour
		ShoulderHints = false,        -- show LB / RB at the ends while a controller is in use
		                              -- (Window turns this on: its tabs switch with the bumpers)
	}

	Controller players get a themed focus ring on the selected tab button
	(Kit.focusRing), and LB / RB switch tabs inside a Window (Util/Gamepad).
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Sound = require(script.Parent.Parent.Util.Sound)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Badge = require(script.Parent.Badge)
local Bubble = require(script.Parent.Bubble)
local Button = require(script.Parent.Button)
local KeyCap = require(script.Parent.KeyCap)
local Input = require(script.Parent.Parent.Util.Input)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)

type Scope = Fusion.Scope<any>

-- Shared state + events for one tab.
local function tabState(scope: Scope, props: { [any]: any }, tab: { [string]: any })
	local selected = props.Selected
	local isSelected = scope:Computed(function(use)
		return use(selected) == tab.Id
	end)
	local hovering = scope:Value(false)
	local pressing = scope:Value(false)
	local focused = scope:Value(false)

	local function choose()
		if peek(selected) ~= tab.Id then
			Sound.play("Click")
			if selected.set then
				selected:set(tab.Id)
			end
			if props.OnSelect then
				props.OnSelect(tab.Id)
			end
		end
	end

	local handlers = {
		MouseEnter = function()
			hovering:set(true)
			Sound.play("Hover", { Volume = 0.4 })
		end,
		MouseLeave = function()
			hovering:set(false)
			pressing:set(false)
		end,
		SelectionGained = function()
			hovering:set(true)
			focused:set(true)
		end,
		SelectionLost = function()
			hovering:set(false)
			focused:set(false)
		end,
		MouseButton1Down = function()
			pressing:set(true)
		end,
		MouseButton1Up = function()
			pressing:set(false)
		end,
		Activated = function()
			pressing:set(false)
			choose()
		end,
	}

	local iconColor = tab.IconColor or Kit.themed(scope, function(theme)
		return theme.Color.Accent
	end)

	return isSelected, hovering, pressing, handlers, iconColor, focused
end

local function background(scope: Scope, isSelected: any, hovering: any, onLight: any)
	return scope:New "Frame" {
		Name = "Background",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = scope:Computed(function(use)
			local theme = use(Theme.current)
			if use(onLight) then
				-- on light panels: white tabs, the selected one in the trim colour
				return if use(isSelected) then theme.Color.Trim else theme.Color.Light
			end
			if use(isSelected) then
				return theme.Color.Light
			end
			return if use(hovering) then theme.Color.SurfaceLight else theme.Color.Surface
		end),
		BackgroundTransparency = scope:Computed(function(use)
			if use(isSelected) or use(onLight) then
				return 0
			end
			local alpha = use(Theme.current).Alpha.Surface
			return if use(hovering) then alpha * 0.8 else alpha
		end),
		ZIndex = 1,
		[Children] = {
			Kit.corner(scope, "M"),
			scope:New "UIGradient" {
				Rotation = 90,
				Color = scope:Computed(function(use)
					if use(isSelected) or use(onLight) then
						return ColorSequence.new(WHITE, Color3.fromRGB(222, 224, 232))
					end
					return ColorSequence.new(WHITE, Color3.fromRGB(190, 190, 190))
				end),
			},
			scope:New "UIStroke" {
				ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					if use(onLight) then
						return if use(isSelected) then theme.Color.Trim:Lerp(Color3.new(0, 0, 0), 0.3) else theme.Tone.White.Edge
					end
					return theme.Color.Glass
				end),
				Thickness = Kit.themed(scope, function(theme)
					return theme.Stroke.Thin + 0.5
				end),
				Transparency = scope:Computed(function(use)
					if use(onLight) then
						return 0
					end
					return if use(isSelected) then 0.2 elseif use(hovering) then 0.7 else 0.88
				end),
			},
		},
	}
end

local function extras(scope: Scope, tab: { [string]: any }, isSelected: any, tabSize: Vector2, vertical: boolean, cornerBadge: boolean?)
	return {
		if tab.Badge ~= nil
			then Badge(scope, {
				Value = tab.Badge,
				-- (cornerBadge: a family rail tab keeps its whole row for the label)
				Position = if cornerBadge
					then UDim2.new(1, -12, 0, 8)
					elseif vertical then UDim2.new(1, -30, 0.5, 0)
					else UDim2.new(1, -4, 0, 2),
				ZIndex = 8,
			})
			else nil,
		if tab.Callout ~= nil
			then Bubble(scope, {
				Text = tab.Callout,
				Tone = tab.CalloutTone or "Red",
				Pointer = if vertical then "Left" else "Bottom",
				TextSize = 21,
				AnchorPoint = if vertical then Vector2.new(0, 0.5) else Vector2.new(0.5, 1),
				Position = if vertical
					then UDim2.new(1, 16, 0.5, 0)
					else UDim2.new(0.5, 0, 0, -math.floor(tabSize.Y * 0.78)),
				Bob = true,
				Visible = scope:Computed(function(use)
					local text = use(tab.Callout)
					return text ~= nil and text ~= "" and not use(isSelected)
				end),
				ZIndex = 9,
			})
			else nil,
	}
end

-- Classic horizontal tab: icon pops out over the top edge, label at the bottom.
local function Tab(scope: Scope, props: { [any]: any }, tab: { [string]: any }, index: number)
	local tabSize: Vector2 = props.TabSize or Vector2.new(150, 50)
	local isSelected, hovering, pressing, handlers, iconColor, focused = tabState(scope, props, tab)
	local onLight = props.OnLight

	local scale = Kit.spring(scope, scope:Computed(function(use)
		if use(pressing) then
			return 0.94
		elseif use(hovering) and not use(isSelected) then
			return 1.05
		end
		return 1
	end), 1.1)

	local iconRotGoal = scope:Value(0)
	local iconRot = scope:Spring(iconRotGoal, 25, 0.3)
	local iconPopGoal = scope:Value(1)
	local iconPop = scope:Spring(iconPopGoal, 25, 0.35)
	local iconLift = Kit.spring(scope, scope:Computed(function(use)
		return if use(isSelected) or use(hovering) then 1 else 0
	end), 0.8)

	Kit.watch(scope, isSelected, function(now)
		if now then
			iconPop:addVelocity(6)
		end
	end)

	local root = {
		Name = `Tab_{tab.Id}`,
		LayoutOrder = index,
		Size = UDim2.fromOffset(tabSize.X, tabSize.Y),
		BackgroundTransparency = 1,
		Text = "",
		AutoButtonColor = false,
		Selectable = true,
		SelectionImageObject = Kit.NoSelection,

		[Children] = {
			scope:New "UIScale" { Scale = scale },
			background(scope, isSelected, hovering, onLight),
			Kit.focusRing(scope, { Focused = focused, Radius = "M" }),
			Icon(scope, {
				Icon = tab.Icon,
				-- pops well out over the top edge but stops above the label
				Size = math.floor(tabSize.Y * 1.2),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = scope:Computed(function(use)
					return UDim2.new(0.5, 0, 0, -math.floor(tabSize.Y * 0.1) - use(iconLift) * 5)
				end),
				Rotation = iconRot,
				Color = iconColor,
				Gradient = true,
				Shadow = true,
				ZIndex = 3,
				[Children] = scope:New "UIScale" { Scale = iconPop },
			}),
			-- The label straddles the plate's bottom edge. On the white selected
			-- plate it stays white with a crisp solid outline (Rivals).
			Text(scope, {
				Text = tab.Text,
				TextSize = math.floor(tabSize.Y * 0.52),
				Font = "Heavy",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 0, 1, -math.floor(tabSize.Y * 0.2)),
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					if use(onLight) and not use(isSelected) then
						return theme.Color.PanelText
					end
					return theme.Color.Text
				end),
				-- on light panels: dark text on white tabs, white on the trim-coloured one
				Outline = scope:Computed(function(use)
					if use(onLight) then
						return if use(isSelected) then "Button" else "None"
					end
					return if use(isSelected) then "Solid" else "Glass"
				end),
				StrokeColor = scope:Computed(function(use)
					if use(onLight) then
						return use(Theme.current).Color.Trim:Lerp(Color3.new(0, 0, 0), 0.45)
					end
					return nil
				end),
				ZIndex = 4,
			}),
			extras(scope, tab, isSelected, tabSize, false),
		},
	}
	local enter = handlers.MouseEnter
	handlers.MouseEnter = function()
		enter()
		iconRot:addVelocity(if math.random() < 0.5 then 140 else -140)
	end
	for event, handler in handlers do
		root[OnEvent(event)] = handler
	end
	-- phones: a comfortable hit area (Kit.touchPad)
	local pad = Kit.touchPad(scope, root.Size, handlers)
	if pad then
		table.insert(root[Children], pad)
	end
	return scope:New "TextButton" (root)
end

-- Side-navigation tab: a wide row with the icon on the left.
local function SideTab(scope: Scope, props: { [any]: any }, tab: { [string]: any }, index: number)
	local tabSize: Vector2 = props.TabSize or Vector2.new(220, 64)
	local isSelected, hovering, pressing, handlers, iconColor, focused = tabState(scope, props, tab)

	local nudge = Kit.spring(scope, scope:Computed(function(use)
		if use(pressing) then
			return 2
		elseif use(hovering) and not use(isSelected) then
			return 8
		end
		return 0
	end), 1)
	local iconPopGoal = scope:Value(1)
	local iconPop = scope:Spring(iconPopGoal, 25, 0.35)
	Kit.watch(scope, isSelected, function(now)
		if now then
			iconPop:addVelocity(6)
		end
	end)

	local iconSize = math.floor(tabSize.Y * 0.78)

	local root = {
		Name = `Tab_{tab.Id}`,
		LayoutOrder = index,
		Size = UDim2.fromOffset(tabSize.X, tabSize.Y),
		BackgroundTransparency = 1,
		Text = "",
		AutoButtonColor = false,
		Selectable = true,
		SelectionImageObject = Kit.NoSelection,
		[Children] = {
			Kit.focusRing(scope, { Focused = focused, Radius = "M" }),
			scope:New "Frame" {
				Name = "Slide",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				Position = scope:Computed(function(use)
					return UDim2.fromOffset(use(nudge), 0)
				end),
				[Children] = {
					background(scope, isSelected, hovering, props.OnLight),
					scope:New "Frame" {
						Name = "Marker",
						AnchorPoint = Vector2.new(0, 0.5),
						Position = UDim2.new(0, 0, 0.5, 0),
						Size = UDim2.new(0, 5, 0.56, 0),
						BackgroundColor3 = iconColor,
						Visible = isSelected,
						ZIndex = 3,
						[Children] = Kit.corner(scope, "Full"),
					},
					Icon(scope, {
						Icon = tab.Icon,
						Size = iconSize,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.new(0, math.floor(tabSize.Y * 0.62), 0.5, 0),
						Color = iconColor,
						Gradient = true,
						Shadow = true,
						ZIndex = 3,
						[Children] = scope:New "UIScale" { Scale = iconPop },
					}),
					Text(scope, {
						Text = tab.Text,
						TextSize = math.floor(tabSize.Y * 0.42),
						Font = "Heavy",
						XAlign = "Left",
						AnchorPoint = Vector2.new(0, 0.5),
						Position = UDim2.new(0, math.floor(tabSize.Y * 1.2), 0.5, 0),
						-- leave room for a badge on the right; a long name in a wide
						-- face ("ACCESSIBILITY") shrinks to fit on one line
						Size = UDim2.new(1, -math.floor(tabSize.Y * 1.2) - (if tab.Badge ~= nil then 60 else 16), 0, math.floor(tabSize.Y * 0.6)),
						Scaled = true,
						Wrapped = false,
						MaxTextSize = math.floor(tabSize.Y * 0.42),
						Color = scope:Computed(function(use)
							local theme = use(Theme.current)
							if use(props.OnLight) then
								return if use(isSelected) then theme.Color.Light else theme.Color.PanelText
							end
							return if use(isSelected) then theme.Color.TextDark else theme.Color.Text
						end),
						StrokeColor = scope:Computed(function(use)
							local theme = use(Theme.current)
							return if use(props.OnLight) then theme.Color.Trim:Lerp(Color3.new(0, 0, 0), 0.45) else theme.Color.Outline
						end),
						Stroke = scope:Computed(function(use)
							if use(props.OnLight) then
								return if use(isSelected) then 2 else false
							end
							return if use(isSelected) then false else nil
						end),
						ZIndex = 4,
					}),
				},
			},
			extras(scope, tab, isSelected, tabSize, true),
		},
	}
	for event, handler in handlers do
		root[OnEvent(event)] = handler
	end
	-- phones: a comfortable hit area (Kit.touchPad)
	local pad = Kit.touchPad(scope, root.Size, handlers)
	if pad then
		table.insert(root[Children], pad)
	end
	return scope:New "TextButton" (root)
end

--------------------------------------------------------------------------
-- v2.4 style families: tabs dressed by theme.Family.Chrome.Tabs
--   Chip / Pill   filled tone pill when selected (toy, clay, kawaii)
--   Segment       a raised plate on a flat row (minimal)
--   Underline     label + a glowing underline bar (neon, royal, holo, dread)
--   Slab          solid slabs, outlined (pixel, comic, broadcast)
--   Folder        folder tabs, top corners round (sketchbook, tavern)
--   Slant         a diagonal-gradient pill (gacha)
--------------------------------------------------------------------------

local function familyTab(scope: Scope, props: { [any]: any }, tab: { [string]: any }, index: number, vertical: boolean)
	local tabSize: Vector2 = props.TabSize or (if vertical then Vector2.new(220, 64) else Vector2.new(150, 50))
	local isSelected, hovering, pressing, handlers, iconColor, focused = tabState(scope, props, tab)
	local fam = Theme.get().Family
	local kind = (fam and fam.Chrome and fam.Chrome.Tabs) or "Pill"
	local light = fam ~= nil and fam.Light == true
	-- docked on a coloured header band (Window): the selected tab is a plate
	-- in the panel colour with ink text, the others white labels on the band
	local onBand = props.OnBand == true and not vertical
	if onBand then
		kind = if kind == "Folder" or kind == "Slab" then kind else "Chip"
	end

	local scale = Kit.spring(scope, scope:Computed(function(use)
		if use(pressing) then
			return use(Theme.current).Motion.PressScale or 0.95
		elseif use(hovering) and not use(isSelected) then
			return math.min(1.04, use(Theme.current).Motion.HoverScale or 1.03)
		end
		return 1
	end), 1.1)
	local on = scope:Spring(scope:Computed(function(use)
		return if use(isSelected) then 1 else 0
	end), 34, 0.85)
	local hover = scope:Spring(scope:Computed(function(use)
		return if use(hovering) then 1 else 0
	end), 40, 1)

	local filled = kind == "Chip" or kind == "Pill" or kind == "Slant" or kind == "Slab" or kind == "Segment" or kind == "Folder"
	local radius: any = if kind == "Pill" or kind == "Slant" then "Full" elseif kind == "Slab" then "S" else "M"

	-- label / icon colours: on the selected fill, else ink / light text
	local labelColor = scope:Computed(function(use)
		local theme = use(Theme.current)
		local c = theme.Color
		local sel = use(isSelected)
		if onBand then
			return if sel then c.PanelText else (if kind == "Folder" or kind == "Slab" then c.PanelText else WHITE)
		end
		if sel then
			if kind == "Underline" then
				return c.Trim or c.Accent
			elseif kind == "Segment" or kind == "Folder" then
				return c.PanelText
			end
			return (theme.Tone.Primary and theme.Tone.Primary.Text) or WHITE
		end
		if kind == "Slab" then
			return if light then c.PanelText else c.Text
		end
		-- (unselected labels in full ink: the fill already marks the selected
		-- one, and muted labels read as disabled on pale panels)
		return c.PanelText or c.PanelMuted
	end)
	-- the icons: the accent, kept readable on light panels (a pastel accent
	-- on white all but vanished)
	local familyIcon = scope:Computed(function(use)
		local theme = use(Theme.current)
		local given = use(iconColor)
		if light and given == theme.Color.Accent and theme.Color.AccentText then
			return theme.Color.AccentText
		end
		return given
	end)

	local fill = scope:New "Frame" {
		Name = "Fill",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = WHITE,
		BackgroundTransparency = scope:Computed(function(use)
			local o = use(on)
			if not filled then
				return 1 - 0.07 * use(hover)
			elseif kind == "Slab" then
				return 0
			elseif kind == "Folder" then
				return 0
			end
			return 1 - math.clamp(o + use(hover) * 0.1, 0, 1)
		end),
		ZIndex = 1,
		[Children] = {
			if kind == "Folder"
				then Kit.cornerRadii(scope, { TopLeft = "M", TopRight = "M", BottomLeft = 0, BottomRight = 0 })
				else Kit.corner(scope, radius),
			scope:New "UIGradient" {
				Rotation = if kind == "Slant" then 0 else 90,
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					local c = theme.Color
					local sel = use(isSelected)
					if onBand and kind == "Chip" then
						return if sel then ColorSequence.new(c.Panel) else ColorSequence.new(WHITE)
					end
					if not filled then
						return ColorSequence.new(if light then c.PanelText else WHITE)
					elseif kind == "Segment" then
						return ColorSequence.new(if light then WHITE else c.Card or c.SurfaceLight)
					elseif kind == "Folder" then
						-- the selected folder is the panel itself; the others a shade darker
						return ColorSequence.new(if sel then c.Panel else c.Panel:Lerp(c.PanelText or c.Outline, if light then 0.09 else 0.12))
					elseif kind == "Slab" and not sel then
						if light then
							return ColorSequence.new(c.Panel:Lerp(c.PanelText or c.Outline, 0.07))
						end
						return ColorSequence.new(c.Surface:Lerp(WHITE, 0.06), c.Surface)
					elseif kind == "Slant" then
						return ColorSequence.new(c.Primary or c.Accent, c.Secondary or c.Accent)
					end
					-- (a classic theme switched in at runtime has no Primary tone)
					local t = Theme.tone(theme, if theme.Tone.Primary then "Primary" else "Blue")
					return ColorSequence.new({
						ColorSequenceKeypoint.new(0, t.Top),
						ColorSequenceKeypoint.new(0.55, t.Mid or t.Bottom),
						ColorSequenceKeypoint.new(1, t.Bottom),
					})
				end),
			},
			-- sticker / ink families outline the selected tab
			if kind == "Chip" or kind == "Pill" or kind == "Slab" or kind == "Folder"
				then Kit.outline(scope, {
					-- chips take the tone's own dark edge; folders / slabs the family ink
					Tone = if kind == "Chip" or kind == "Pill" then "Primary" else nil,
					Width = scope:Computed(function(use)
						local f = use(Theme.current).Family
						local w = f and f.Outline and f.Outline.Width or 0
						if kind == "Folder" or kind == "Slab" then
							return math.min(w, 3)
						end
						return if use(isSelected) then math.min(w, 3) else 0
					end),
					Inner = false,
				})
				else nil,
			if kind ~= "Underline" then Kit.rim(scope, { Visible = isSelected }) else nil,
			if kind == "Segment" or kind == "Chip" or kind == "Pill" or kind == "Slant"
				then Kit.shadows(scope, { Role = "Chip", Tone = "Primary", Visible = isSelected })
				else nil,
		},
	}

	local underline = if kind == "Underline"
		then scope:New "Frame" {
			Name = "Underline",
			AnchorPoint = if vertical then Vector2.new(0, 0.5) else Vector2.new(0.5, 1),
			Position = if vertical then UDim2.fromScale(0, 0.5) else UDim2.fromScale(0.5, 1),
			Size = scope:Computed(function(use)
				local o = math.clamp(use(on), 0, 1)
				return if vertical then UDim2.new(0, 3, 0.2 + 0.5 * o, 0) else UDim2.new(0.2 + 0.7 * o, 0, 0, 3)
			end),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Trim or theme.Color.Accent
			end),
			BackgroundTransparency = scope:Computed(function(use)
				return 1 - math.clamp(use(on), 0, 1)
			end),
			BorderSizePixel = 0,
			ZIndex = 3,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.shadows(scope, { Role = "Chip", Color = Kit.themed(scope, function(theme)
					return theme.Color.Trim or theme.Color.Accent
				end), Visible = isSelected }),
			},
		}
		else nil

	local iconSize = math.floor(tabSize.Y * (if vertical then 0.56 else 0.58))
	-- (rail labels at 0.42 of the tab height, like the docked ones: 0.36 read tiny)
	local labelShare = 0.42
	local labelSize = scope:Computed(function(use)
		local theme = use(Theme.current)
		local want = math.floor(tabSize.Y * labelShare)
		return Kit.minText(theme, want)
	end)

	local content = scope:New "Frame" {
		Name = "Content",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		ZIndex = 4,
		[Children] = {
			Kit.padding(scope, { 0, if vertical then 14 else 12 }),
			Kit.list(scope, { Direction = "X", Padding = 8, HAlign = if vertical then "Left" else "Center", VAlign = "Center" }),
			if tab.Icon
				then Icon(scope, {
					Icon = tab.Icon,
					Size = iconSize,
					Color = scope:Computed(function(use)
						if onBand then
							return if use(isSelected) then use(familyIcon) else use(labelColor)
						end
						if use(isSelected) and kind ~= "Underline" and kind ~= "Segment" and kind ~= "Folder" then
							return use(labelColor)
						end
						return use(familyIcon)
					end),
					Stroke = false,
					LayoutOrder = 1,
					ZIndex = 5,
				})
				else nil,
			Text(scope, {
				Text = tab.Text,
				TextSize = labelSize,
				Font = "Label",
				Color = labelColor,
				Stroke = scope:Computed(function(use)
					local theme = use(Theme.current)
					local sel = use(isSelected)
					local t = theme.Tone.Primary
					if onBand then
						-- white labels on the band keep an outline; ink on the plate doesn't
						if sel or kind == "Folder" or kind == "Slab" or not (t and t.Stroke) then
							return false
						end
						return math.max(1.5, (theme.Stroke.Text or 2) * 0.8)
					end
					if sel and (kind == "Chip" or kind == "Pill" or kind == "Slab") and t and t.Stroke then
						return math.max(1.5, (theme.Stroke.Text or 2) * 0.8)
					end
					return false
				end),
				StrokeColor = Kit.themed(scope, function(theme)
					local t = theme.Tone.Primary
					return (t and t.Stroke) or theme.Color.Outline
				end),
				-- (a rail tab's badge sits on its top-right corner, clear of the label)
				Size = if vertical then UDim2.new(1, -(iconSize + 36), 0, math.floor(tabSize.Y * 0.6)) else nil,
				Scaled = vertical,
				Wrapped = false,
				MaxTextSize = if vertical then math.floor(tabSize.Y * labelShare) else nil,
				MinTextSize = if vertical then 16 else nil,
				XAlign = if vertical then "Left" else "Center",
				LayoutOrder = 2,
				ZIndex = 5,
			}),
		},
	}

	-- a row tab is as wide as its icon + label (measured in the label's own
	-- font and case): the content frame is scale-sized, so automatic sizing
	-- alone left every tab at its minimum and wide faces (pixel, typewriter)
	-- ran into the next tab
	local rowWidth = scope:Computed(function(use)
		local theme = use(Theme.current)
		local text = tostring(tab.Text or "")
		if Theme.style(theme, "TextCase") == "Upper" then
			text = Kit.upper(text)
		end
		local width = 24 + Kit.textWidth(text, use(labelSize), "Label", theme) + 6
		if tab.Icon then
			width += iconSize + 8
		end
		return math.max(math.min(tabSize.X, 110), math.ceil(width))
	end)

	local root = {
		Name = `Tab_{tab.Id}`,
		LayoutOrder = index,
		Size = if vertical then UDim2.fromOffset(tabSize.X, tabSize.Y) else scope:Computed(function(use)
			return UDim2.fromOffset(use(rowWidth), tabSize.Y)
		end),
		AutomaticSize = Enum.AutomaticSize.None,
		BackgroundTransparency = 1,
		Text = "",
		AutoButtonColor = false,
		Selectable = true,
		SelectionImageObject = Kit.NoSelection,
		[Children] = {
			scope:New "UIScale" { Scale = scale },
			if not vertical then scope:New "UISizeConstraint" { MinSize = Vector2.new(math.min(tabSize.X, 110), tabSize.Y) } else nil,
			fill,
			underline,
			content,
			Kit.focusRing(scope, { Focused = focused, Radius = radius }),
			extras(scope, tab, isSelected, tabSize, vertical, vertical),
		},
	}
	for event, handler in handlers do
		root[OnEvent(event)] = handler
	end
	local pad = Kit.touchPad(scope, UDim2.fromOffset(tabSize.X, tabSize.Y), handlers)
	if pad then
		table.insert(root[Children], pad)
	end
	return scope:New "TextButton" (root)
end

local function shoulder(scope: Scope, key: Enum.KeyCode, order: number, tabSize: Vector2): Frame
	return scope:New "Frame" {
		Name = if key == Enum.KeyCode.ButtonL1 then "PrevTabHint" else "NextTabHint",
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(math.floor(tabSize.Y * 0.9), tabSize.Y),
		LayoutOrder = order,
		Visible = scope:Computed(function(use)
			return use(Input.device) == "Gamepad"
		end),
		[Children] = KeyCap(scope, {
			Key = key,
			Gamepad = key,
			Size = math.floor(tabSize.Y * 0.72),
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
		}),
	}
end

return function(scope: Scope, props: { [any]: any }): Frame
	if props.Selected == nil then
		props.Selected = scope:Value(props.Tabs[1] and props.Tabs[1].Id)
	end
	local vertical = props.Vertical == true
	local tabSize: Vector2 = props.TabSize or (if vertical then Vector2.new(220, 64) else Vector2.new(150, 50))
	props.TabSize = tabSize

	-- v2.4: style families dress their tabs (docked in a window's header, or
	-- the side column); classic themes keep the pop-out icon tabs
	local family = Theme.get().Family ~= nil and props.Family ~= false and (props.Docked or vertical)
	local tabs = {}
	for index, tab in props.Tabs do
		if family then
			table.insert(tabs, familyTab(scope, props, tab, index, vertical))
		else
			table.insert(tabs, if vertical then SideTab(scope, props, tab, index) else Tab(scope, props, tab, index))
		end
	end

	local root = Kit.root(props, { Name = "TabBar" })
	root.BackgroundTransparency = 1
	if vertical then
		root.Size = UDim2.fromOffset(tabSize.X, 0)
		root.AutomaticSize = Enum.AutomaticSize.Y
	else
		root.Size = UDim2.fromOffset(0, tabSize.Y)
		root.AutomaticSize = Enum.AutomaticSize.X
	end
	root[Children] = {
		Kit.list(scope, {
			Direction = if vertical then "Y" else "X",
			Padding = props.Gap or (if family then (if vertical then 8 else 10) elseif vertical then 10 else 16),
			VAlign = if vertical then "Top" else "Center",
		}),
		tabs,
		-- LB / RB hints, only while a controller is in use
		if props.ShoulderHints and not vertical
			then {
				shoulder(scope, Enum.KeyCode.ButtonL1, 0, tabSize),
				shoulder(scope, Enum.KeyCode.ButtonR1, #props.Tabs + 0.5, tabSize),
			}
			else nil,
		if props.OnClose and not vertical
			then Button(scope, {
				Name = "Close",
				Icon = "close",
				Tone = "Red",
				Size = UDim2.fromOffset(math.floor(tabSize.Y * 1.8), tabSize.Y),
				IconSize = math.floor(tabSize.Y * 0.72),
				LayoutOrder = #props.Tabs + 1,
				Sound = "Close",
				OnClick = props.OnClose,
			})
			else nil,
	}
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="147">
            <Properties>
              <string name="Name">TeamScore</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	TeamScore: the top-centre match header. A score block per team in the
	team's colour with a big rolling number, a progress bar towards the goal
	and a crown over the leader, around a centre clock ("FIRST TO 50").

	scope:TeamScore {
		Teams = {
			{ name = "Red", color = Color3.fromHex("FF4B4B"), score = redScore },   -- score: number or state
			{ name = "Blue", color = Color3.fromHex("3AA8FF"), score = blueScore },
		},
		Goal = 50,                       -- optional: bars + "FIRST TO 50"
		EndsAt = os.time() + 300,        -- optional: centre countdown (number or state)
		Seconds = 300,                   -- ...or seconds left (number or state)
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, 72),
	}

	HUD kit looks (Style, with Kit = a HudKits name / tokens):
		"Blocks"    the team blocks above (the original)
		"TugOfWar"  one bar split between two teams; the split springs as the
		            lead changes (more than two teams: Blocks)
		"Line"      a minimal score line: RED 42 - 37 BLUE, the clock underneath
	Every look sets a HudSize attribute (its footprint in a HUD layout).
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Util.Format)
local Color = require(script.Parent.Parent.Util.Color)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local P = require(script.Parent.HudParts)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)
local BLOCK_W = 168
local BLOCK_H = 92
local CENTER_W = 164

local function field(entry: any, key: string): any
	if typeof(entry) ~= "table" then
		return nil
	end
	local value = entry[key]
	if value == nil then
		value = entry[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

local function teamTone(color: Color3): any
	local makeTone = (Theme :: any).makeTone
	if makeTone then
		return makeTone(color)
	end
	return {
		Top = Color.lighten(color, 0.25),
		Bottom = color,
		Edge = Color.darken(color, 0.45),
		Deep = Color.darken(color, 0.72),
		Text = WHITE,
		Stroke = Color.darken(color, 0.55),
	}
end

local function clock(seconds: number): string
	seconds = math.max(0, math.ceil(seconds))
	local hours = seconds // 3600
	local minutes = (seconds % 3600) // 60
	local secs = seconds % 60
	if hours > 0 then
		return string.format("%d:%02d:%02d", hours, minutes, secs)
	end
	return string.format("%d:%02d", minutes, secs)
end

--------------------------------------------------------------------------
-- Kit looks (two teams)
--------------------------------------------------------------------------

local LOOKS = {}

local function teamColor(team: any): Color3
	local color = field(team, "color")
	return if typeof(color) == "Color3" then color else Color3.fromHex(tostring(color or "3AA8FF"))
end

local function finish(scope: Scope, props: { [any]: any }, size: Vector2, children: { any }): Frame
	local root = Kit.root(props, { Name = "TeamScore" })
	root.BackgroundTransparency = 1
	root[Children] = { children, props[Children] }
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

local function centreText(scope: Scope, s: { [string]: any }): any
	return scope:Computed(function(use)
		if s.hasClock then
			return clock(use(s.remaining))
		elseif s.goal ~= nil then
			return `FIRST TO {use(s.goal)}`
		end
		return "VS"
	end)
end

local function scoreText(scope: Scope, score: any): (any, any)
	local rolling = scope:Spring(score, 14, 1)
	local pop = scope:Spring(scope:Value(1), 30, 0.4)
	local last = peek(score)
	Kit.watch(scope, score, function(now)
		if now > last then
			pop:setPosition(1.3)
		end
		last = now
	end)
	return scope:Computed(function(use)
		return Format.comma(math.round(use(rolling)))
	end), pop
end

-- One bar split between the teams; the split springs as the lead changes.
function LOOKS.TugOfWar(scope: Scope, props: { [any]: any }, T: { [string]: any }, s: { [string]: any }): Frame
	local size = Vector2.new(620, 86)
	local look = P.look(scope, T)
	local a, b = s.scores[1], s.scores[2]
	local share = scope:Spring(scope:Computed(function(use)
		local x, y = use(a), use(b)
		if x + y <= 0 then
			return 0.5
		end
		return math.clamp(x / (x + y), 0.06, 0.94)
	end), 16, 0.45)
	local barW = size.X - 2 * 110
	local barH = 24
	local function side(index: number)
		local team = s.teams[index]
		local color = teamColor(team)
		local tone = teamTone(color)
		local text, pop = scoreText(scope, s.scores[index])
		local left = index == 1
		return {
			P.text(scope, T, look, {
				Name = `Team{index}Name`,
				Role = "Label",
				Text = string.upper(tostring(field(team, "name") or `Team {index}`)),
				TextSize = 14,
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(if left then 0 else 1, if left then 55 else -55, 0, 6),
				Color = Color.lighten(color, 0.25),
				Stroke = 1.5,
				StrokeColor = tone.Stroke or tone.Edge,
				StrokeTransparency = 0,
				ZIndex = 5,
			}),
			P.text(scope, T, look, {
				Name = `Team{index}Score`,
				Role = "Number",
				Text = text,
				TextSize = 42,
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(if left then 0 else 1, if left then 55 else -55, 1, -6),
				Color = WHITE,
				Stroke = 3,
				StrokeColor = tone.Stroke or tone.Edge,
				StrokeTransparency = 0,
				ZIndex = 5,
				[Children] = scope:New "UIScale" { Scale = pop },
			}),
		}
	end
	local toneA, toneB = teamTone(teamColor(s.teams[1])), teamTone(teamColor(s.teams[2]))
	return finish(scope, props, size, {
		P.plate(scope, T, { Radius = if T.corner == "Round" then "Pill" else nil, Edge = "Bottom", Idle = true }),
		side(1),
		side(2),
		scope:New "Frame" {
			Name = "Rope",
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.new(0.5, 0, 1, -14),
			Size = UDim2.fromOffset(barW, barH),
			BackgroundColor3 = toneB.Bottom,
			ZIndex = 4,
			[Children] = {
				P.corner(scope, T, "Small"),
				scope:New "UIGradient" {
					Rotation = 90,
					Color = ColorSequence.new(Color.lighten(toneB.Top, 0.1), toneB.Bottom),
				},
				scope:New "UIStroke" {
					Color = scope:Computed(function(use)
						return use(Theme.current).Color.Outline
					end),
					Thickness = 2,
				},
				scope:New "Frame" {
					Name = "TeamA",
					Size = scope:Computed(function(use)
						return UDim2.fromScale(use(share), 1)
					end),
					BackgroundColor3 = WHITE,
					ZIndex = 5,
					[Children] = {
						P.corner(scope, T, "Small"),
						scope:New "UIGradient" {
							Rotation = 90,
							Color = ColorSequence.new(Color.lighten(toneA.Top, 0.1), toneA.Bottom),
						},
					},
				},
				-- the knot: where the teams meet
				scope:New "Frame" {
					Name = "Knot",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = scope:Computed(function(use)
						return UDim2.fromScale(use(share), 0.5)
					end),
					Size = UDim2.fromOffset(barH + 6, barH + 6),
					Rotation = 45,
					BackgroundColor3 = WHITE,
					ZIndex = 7,
					[Children] = {
						P.corner(scope, T, "Small"),
						scope:New "UIStroke" {
							Color = scope:Computed(function(use)
								return use(Theme.current).Color.Outline
							end),
							Thickness = 3,
						},
					},
				},
			},
		},
		P.text(scope, T, look, {
			Name = "Centre",
			Role = "Number",
			Text = centreText(scope, s),
			TextSize = if s.hasClock then 26 else 16,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(0.5, 0, 0, 8),
			Color = scope:Computed(function(use)
				local theme = use(Theme.current)
				local left = use(s.remaining)
				return if s.hasClock and left > 0 and left <= 10 then theme.Tone.Red.Top else use(look).ink
			end),
			ZIndex = 5,
		}),
	})
end

-- A minimal score line: RED 42 - 37 BLUE, the clock underneath.
function LOOKS.Line(scope: Scope, props: { [any]: any }, T: { [string]: any }, s: { [string]: any }): Frame
	local size = Vector2.new(440, 66)
	local look = P.look(scope, T)
	local function half(index: number)
		local team = s.teams[index]
		local color = teamColor(team)
		local tone = teamTone(color)
		local text, pop = scoreText(scope, s.scores[index])
		local left = index == 1
		local ax = if left then 1 else 0
		local sign = if left then -1 else 1
		return {
			P.text(scope, T, look, {
				Name = `Team{index}Score`,
				Role = "Number",
				Text = text,
				TextSize = 38,
				XAlign = if left then "Right" else "Left",
				AnchorPoint = Vector2.new(ax, 0.5),
				Position = UDim2.new(0.5, sign * 22, 0, 26),
				Color = WHITE,
				Stroke = 2.5,
				StrokeColor = tone.Stroke or tone.Edge,
				StrokeTransparency = 0,
				ZIndex = 5,
				[Children] = scope:New "UIScale" { Scale = pop },
			}),
			scope:New "Frame" {
				Name = `Team{index}Chip`,
				AnchorPoint = Vector2.new(ax, 0.5),
				Position = UDim2.new(0.5, sign * 108, 0, 26),
				Size = UDim2.fromOffset(6, 26),
				BackgroundColor3 = color,
				BorderSizePixel = 0,
				ZIndex = 5,
				[Children] = P.corner(scope, T, "Small"),
			},
			P.text(scope, T, look, {
				Name = `Team{index}Name`,
				Role = "Title",
				Text = string.upper(tostring(field(team, "name") or `Team {index}`)),
				TextSize = 18,
				XAlign = if left then "Right" else "Left",
				AnchorPoint = Vector2.new(ax, 0.5),
				Position = UDim2.new(0.5, sign * 122, 0, 26),
				Color = Color.lighten(color, 0.3),
				Stroke = 1.5,
				StrokeColor = tone.Stroke or tone.Edge,
				StrokeTransparency = 0,
				ZIndex = 5,
			}),
		}
	end
	return finish(scope, props, size, {
		P.plate(scope, T, { Radius = if T.corner == "Round" then "Pill" else nil, Edge = "Bottom", Idle = true }),
		half(1),
		half(2),
		P.text(scope, T, look, {
			Name = "Dash",
			Role = "Number",
			Text = "-",
			TextSize = 30,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5, 0, 0, 25),
			Color = scope:Computed(function(use)
				return use(look).muted
			end),
			ZIndex = 5,
		}),
		P.text(scope, T, look, {
			Name = "Centre",
			Role = "Label",
			Text = centreText(scope, s),
			TextSize = 14,
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.new(0.5, 0, 1, -6),
			Color = scope:Computed(function(use)
				local theme = use(Theme.current)
				local left = use(s.remaining)
				return if s.hasClock and left > 0 and left <= 10 then theme.Tone.Red.Top else use(look).muted
			end),
			ZIndex = 5,
		}),
	})
end

local function TeamScore(scope: Scope, props: { [any]: any }): Frame
	local teams = props.Teams or {}
	local goal = props.Goal
	local hasClock = props.EndsAt ~= nil or props.Seconds ~= nil

	-- countdown
	local baseSeconds = tonumber(peek(props.Seconds)) or 0
	local startedAt = os.clock()
	local function remainingNow(): number
		if props.EndsAt ~= nil then
			return (tonumber(peek(props.EndsAt)) or 0) - os.time()
		end
		return baseSeconds - (os.clock() - startedAt)
	end
	local remaining = scope:Value(math.max(0, math.ceil(remainingNow())))
	if hasClock then
		local alive = true
		table.insert(scope, function()
			alive = false
		end)
		Kit.watch(scope, props.Seconds, function(value)
			baseSeconds = tonumber(value) or 0
			startedAt = os.clock()
		end)
		task.spawn(function()
			while alive do
				local left = math.max(0, math.ceil(remainingNow()))
				if left ~= peek(remaining) then
					remaining:set(left)
				end
				task.wait(0.2)
			end
		end)
	end

	local scores = {}
	for index, team in teams do
		scores[index] = scope:Computed(function(use)
			return tonumber(use(field(team, "score"))) or 0
		end)
	end
	local best = scope:Computed(function(use)
		local top, count = -math.huge, 0
		for _, score in scores do
			local value = use(score)
			if value > top then
				top, count = value, 1
			elseif value == top then
				count += 1
			end
		end
		return if count == 1 then top else math.huge -- no crown on a tie
	end)

	local style = P.style("TeamScore", props.Style)
	if style ~= "Blocks" and #teams == 2 then
		return LOOKS[style](scope, props, P.tokens(props.Kit), {
			teams = teams,
			scores = scores,
			goal = goal,
			hasClock = hasClock,
			remaining = remaining,
		})
	end

	local function block(index: number, team: any, mirrored: boolean)
		local color = field(team, "color")
		local tone = teamTone(if typeof(color) == "Color3" then color else Color3.fromHex(tostring(color or "3AA8FF")))
		local strokeColor = tone.Stroke or tone.Edge
		local score = scores[index]
		local rolling = scope:Spring(score, 14, 1)
		local popGoal = scope:Value(1)
		local pop = scope:Spring(popGoal, 30, 0.4)
		local last = peek(score)
		Kit.watch(scope, score, function(now)
			if now > last then
				pop:setPosition(1.3)
			end
			last = now
		end)
		local leading = scope:Computed(function(use)
			return use(score) == use(best)
		end)
		local fraction = scope:Spring(scope:Computed(function(use)
			local target = tonumber(use(goal))
			if target == nil or target <= 0 then
				return 0
			end
			return math.clamp(use(score) / target, 0, 1)
		end), 12, 1)

		return scope:New "Frame" {
			Name = `Team{index}`,
			LayoutOrder = index,
			Size = UDim2.fromOffset(BLOCK_W, BLOCK_H),
			BackgroundTransparency = 1,
			ZIndex = 2,
			[Children] = {
				Kit.shadow(scope, { Spread = 12, Offset = Vector2.new(0, 5), ZIndex = 1 }),
				scope:New "Frame" {
					Name = "Lip",
					Size = UDim2.new(1, 0, 1, -10),
					BackgroundColor3 = tone.Deep,
					ZIndex = 2,
					[Children] = {
						Kit.corner(scope, "M"),
						Kit.stroke(scope, { Thickness = 3, Color = tone.Edge }),
					},
				},
				scope:New "Frame" {
					Name = "Face",
					Size = UDim2.new(1, 0, 1, -14),
					BackgroundColor3 = WHITE,
					ZIndex = 3,
					[Children] = {
						Kit.corner(scope, "M"),
						Kit.gradient(scope, {
							Rotation = 90,
							Color = ColorSequence.new({
								ColorSequenceKeypoint.new(0, Color.lighten(tone.Top, 0.12)),
								ColorSequenceKeypoint.new(0.55, tone.Bottom),
								ColorSequenceKeypoint.new(1, Color.darken(tone.Bottom, 0.25)),
							}),
						}),
						scope:New "Frame" {
							Name = "Gloss",
							BackgroundColor3 = WHITE,
							BackgroundTransparency = 0.55,
							Position = UDim2.fromOffset(3, 3),
							Size = UDim2.new(1, -6, 0.42, 0),
							ZIndex = 4,
							[Children] = {
								Kit.corner(scope, "M"),
								Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0.25, 1) }),
							},
						},
						Text(scope, {
							Name = "TeamName",
							Text = string.upper(tostring(field(team, "name") or `Team {index}`)),
							TextSize = 18,
							Font = "Heavy",
							AnchorPoint = Vector2.new(0.5, 0),
							Position = UDim2.new(0.5, 0, 0, 6),
							Size = UDim2.new(1, -16, 0, 20),
							Truncate = true,
							Color = tone.Text,
							StrokeColor = strokeColor,
							Stroke = 1.5,
							ZIndex = 5,
						}),
						Text(scope, {
							Name = "Score",
							Text = scope:Computed(function(use)
								return Format.comma(math.round(use(rolling)))
							end),
							TextSize = 48,
							Font = "Display",
							AnchorPoint = Vector2.new(0.5, 1),
							Position = UDim2.new(0.5, 0, 1, -2),
							Color = tone.Text,
							StrokeColor = strokeColor,
							Stroke = 3,
							ZIndex = 5,
							[Children] = scope:New "UIScale" { Scale = pop },
						}),
					},
				},
				-- progress towards the goal, filling towards the centre
				if goal ~= nil
					then scope:New "Frame" {
						Name = "GoalTrack",
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.new(0.5, 0, 1, 0),
						Size = UDim2.new(1, -8, 0, 7),
						BackgroundColor3 = Kit.themed(scope, function(theme)
							return theme.Color.Track
						end),
						ZIndex = 3,
						[Children] = {
							Kit.corner(scope, "Full"),
							scope:New "Frame" {
								Name = "Fill",
								AnchorPoint = Vector2.new(if mirrored then 0 else 1, 0),
								Position = UDim2.fromScale(if mirrored then 0 else 1, 0),
								Size = scope:Computed(function(use)
									return UDim2.fromScale(use(fraction), 1)
								end),
								BackgroundColor3 = Color.lighten(tone.Top, 0.1),
								ZIndex = 4,
								[Children] = Kit.corner(scope, "Full"),
							},
						},
					}
					else nil,
				Icon(scope, {
					Name = "Crown",
					Icon = "crown",
					Size = 34,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(if mirrored then 1 else 0, if mirrored then -10 else 10, 0, 0),
					Rotation = if mirrored then 18 else -18,
					Color = Kit.themed(scope, function(theme)
						return theme.Tone.Yellow.Top
					end),
					StrokeColor = Kit.themed(scope, function(theme)
						return theme.Tone.Yellow.Edge
					end),
					Gradient = true,
					Shadow = true,
					Visible = leading,
					ZIndex = 8,
				}),
			},
		}
	end

	local center = scope:New "Frame" {
		Name = "Center",
		LayoutOrder = 0,
		Size = UDim2.fromOffset(CENTER_W, BLOCK_H - 14),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Surface
		end),
		BackgroundTransparency = Kit.themed(scope, function(theme)
			return math.min(theme.Alpha.Surface, 0.25)
		end),
		ZIndex = 2,
		[Children] = {
			Kit.corner(scope, "M"),
			Kit.stroke(scope, {
				Thickness = 2,
				Transparency = 0.7,
				Color = Kit.themed(scope, function(theme)
					return theme.Color.Light
				end),
			}),
			Kit.list(scope, { Direction = "Y", Padding = 0, HAlign = "Center", VAlign = "Center" }),
			Text(scope, {
				Name = "Clock",
				LayoutOrder = 1,
				Text = scope:Computed(function(use)
					return if hasClock then clock(use(remaining)) else "VS"
				end),
				TextSize = 40,
				Font = "Display",
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					local left = use(remaining)
					return if hasClock and left > 0 and left <= 10 then theme.Tone.Red.Top else theme.Color.Text
				end),
				LineHeight = 0.95,
				ZIndex = 3,
			}),
			if goal ~= nil
				then Text(scope, {
					Name = "Goal",
					LayoutOrder = 2,
					Text = scope:Computed(function(use)
						return `FIRST TO {use(goal)}`
					end),
					TextSize = 16,
					Font = "Heavy",
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					Stroke = false,
					ZIndex = 3,
				})
				else nil,
		},
	}

	-- [first half] [centre] [second half]
	local children = {}
	local half = math.ceil(#teams / 2)
	for index, team in teams do
		local mirrored = index > half
		local frame = block(index, team, mirrored)
		frame.LayoutOrder = if mirrored then index + 1 else index
		table.insert(children, frame)
	end
	center.LayoutOrder = half + 1

	local root = Kit.root(props, { Name = "TeamScore" })
	root.Size = UDim2.fromOffset(0, BLOCK_H)
	root.AutomaticSize = Enum.AutomaticSize.X
	root.BackgroundTransparency = 1
	root[Children] = {
		Kit.list(scope, { Direction = "X", Padding = 8, VAlign = "Top" }),
		center,
		children,
		props[Children],
	}
	local frame = scope:New "Frame" (root) :: Frame
	-- the footprint a HUD layout reserves
	frame:SetAttribute("HudSize", Vector2.new(#teams * (BLOCK_W + 8) + CENTER_W, BLOCK_H))
	return frame
end

return TeamScore
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="148">
            <Properties>
              <string name="Name">TeamStatus</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	TeamStatus: who's still in the round. Survivor portraits with their state
	(alive, downed, escaped, dead) and, optionally, the killer they're up
	against (Blueprint v1.7 HUD type TeamStatus; Piggy, Forsaken, Dead by
	Daylight-style chases).

	scope:TeamStatus {
		Title = "SURVIVORS",
		Members = {                       -- up to 8
			{ Name = "Mia", Icon = "head-female", State = "Alive" },   -- State: string or state
			{ Name = "Theo", Icon = "head-male", State = "Downed" },
		},
		Killer = { Name = "The Butcher", Icon = "hand" },    -- optional
		Style = "Portraits",              -- "Portraits" (a row of rings) | "List" (rows, for a side edge)
		Size = UDim2.fromOffset(460, 118),
	}

	Every state has its own icon and word, not just a colour: Alive (a clean
	ring), Downed (amber, a falling figure; it pulses unless reduced motion is
	on), Escaped (green, a door), Dead (greyed out, a cross).
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Motion = require(script.Parent.Parent.FX.Motion)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local HudKits = require(script.Parent.HudKits)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local STATE_ICON = { Alive = nil, Downed = "person-falling", Escaped = "door", Dead = "x", Caught = "lock" }

local function stateColor(theme: any, state: string): Color3
	if state == "Downed" or state == "Caught" then
		return theme.Color.Warn
	elseif state == "Escaped" then
		return theme.Color.Good
	elseif state == "Dead" then
		return theme.Color.TextMuted
	end
	return theme.Color.Text
end

return function(scope: Scope, props: { [any]: any }): Frame
	-- HUD kits (v1.8, Kit = a HudKits name / tokens): the kit's number and label type
	local kitTokens = if props.Kit ~= nil then HudKits.tokens(props.Kit) else nil
	local kitted = kitTokens ~= nil and kitTokens.name ~= "Classic"
	local DISPLAY = if kitted then kitTokens.numberFont else "Display"
	local HEAVY = if kitted then kitTokens.labelFont else "Heavy"
	local style = peek(props.Style) or "Portraits"
	local size = peek(props.Size) or UDim2.fromOffset(460, 118)
	local w, h = size.X.Offset, size.Y.Offset
	local members = props.Members or {}
	local killer = props.Killer

	-- the downed ring breathes (a steady ring with reduced motion)
	local pulse = scope:Value(0)
	local alive = true
	table.insert(scope, function()
		alive = false
	end)
	task.spawn(function()
		local t = 0
		while alive do
			task.wait(0.1)
			t += 0.1
			local v = if Motion.reduced then 0 else (math.sin(t * 5) + 1) / 2
			if math.abs(v - peek(pulse)) > 0.05 or v == 0 then
				pulse:set(v)
			end
		end
	end)

	local function portrait(member: { [string]: any }, index: number, ring: number, isKiller: boolean): Frame
		local state = scope:Computed(function(use)
			return if isKiller then "Killer" else (use(member.State) or "Alive")
		end)
		local ringColor = scope:Computed(function(use)
			local theme = use(Theme.current)
			if isKiller then
				return theme.Color.Bad
			end
			return stateColor(theme, use(state))
		end)
		local faded = scope:Computed(function(use)
			local s = use(state)
			return s == "Dead" or s == "Escaped"
		end)
		local nameSize = math.clamp(math.floor(ring * 0.28), 13, 18)
		return scope:New "Frame" {
			Name = if isKiller then "Killer" else "Member" .. index,
			LayoutOrder = index,
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(ring + 18, ring + nameSize + 8),
			[Children] = {
				scope:New "Frame" {
					Name = "Ring",
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.fromScale(0.5, 0),
					Size = UDim2.fromOffset(ring, ring),
					BackgroundColor3 = scope:Computed(function(use)
						local theme = use(Theme.current)
						return if isKiller then Color.darken(theme.Color.Bad, 0.55) else theme.Color.Surface
					end),
					BackgroundTransparency = scope:Computed(function(use)
						return if use(faded) then 0.55 else 0.12
					end),
					ZIndex = 2,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.stroke(scope, {
							Thickness = scope:Computed(function(use)
								local s = use(state)
								if s == "Downed" then
									return 3 + use(pulse) * 2.5
								end
								return if isKiller then 4 else 3
							end),
							Color = ringColor,
							Transparency = scope:Computed(function(use)
								return if use(faded) then 0.45 else 0
							end),
						}),
						Icon(scope, {
							Icon = member.Icon or (if isKiller then "hand" else "head-blank"),
							Size = math.floor(ring * 0.56),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = scope:Computed(function(use)
								local theme = use(Theme.current)
								if isKiller then
									return theme.Color.Text
								end
								return if use(faded) then theme.Color.TextMuted else theme.Color.Text
							end),
							ZIndex = 3,
						}),
						-- the state badge: a shape for every state
						scope:Computed(function(use, inner)
							local s = use(state)
							local iconName = STATE_ICON[s]
							if isKiller or iconName == nil then
								return nil
							end
							return inner:New "Frame" {
								Name = "Badge",
								AnchorPoint = Vector2.new(0.5, 0.5),
								Position = UDim2.fromScale(0.86, 0.84),
								Size = UDim2.fromOffset(math.floor(ring * 0.42), math.floor(ring * 0.42)),
								BackgroundColor3 = ringColor,
								ZIndex = 4,
								[Children] = {
									Kit.corner(inner, "Full"),
									Kit.stroke(inner, {
										Thickness = 2,
										Color = Kit.themed(inner, function(theme)
											return theme.Color.Outline
										end),
									}),
									Icon(inner, {
										Icon = iconName,
										Size = math.floor(ring * 0.3),
										AnchorPoint = Vector2.new(0.5, 0.5),
										Position = UDim2.fromScale(0.5, 0.5),
										Color = Kit.themed(inner, function(theme)
											return theme.Color.Surface
										end),
										Stroke = false,
										ZIndex = 5,
									}),
								},
							}
						end),
					},
				},
				Text(scope, {
					Name = "Name",
					Text = scope:Computed(function(use)
						local s = use(state)
						local name = tostring(member.Name or "")
						if s == "Downed" or s == "Escaped" or s == "Dead" or s == "Caught" then
							return string.upper(s)
						end
						return name
					end),
					TextSize = nameSize,
					Font = HEAVY,
					Case = "Normal",
					Color = ringColor,
					Stroke = 2,
					StrokeTransparency = 0.3,
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.fromScale(0.5, 1),
					Size = UDim2.new(1, 4, 0, nameSize + 4),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = nameSize,
					ZIndex = 3,
				}),
			},
		}
	end

	local root = Kit.root(props, { Name = "TeamStatus" })
	root.Size = size
	root.BackgroundTransparency = 1
	local titleSize = 18
	local children: { any } = {}
	if style == "List" then
		local rowH = math.floor((h - titleSize - 10) / math.max(1, #members + (if killer then 1 else 0)))
		rowH = math.clamp(rowH, 30, 48)
		local rows = {}
		local function row(member: { [string]: any }, index: number, isKiller: boolean)
			local state = scope:Computed(function(use)
				return if isKiller then "Killer" else (use(member.State) or "Alive")
			end)
			local color = scope:Computed(function(use)
				local theme = use(Theme.current)
				return if isKiller then theme.Color.Bad else stateColor(theme, use(state))
			end)
			return scope:New "Frame" {
				Name = if isKiller then "Killer" else "Member" .. index,
				LayoutOrder = index,
				Size = UDim2.new(1, 0, 0, rowH - 4),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Surface
				end),
				BackgroundTransparency = Kit.themed(scope, function(theme)
					return theme.Alpha.Surface
				end),
				[Children] = {
					Kit.corner(scope, "S"),
					scope:New "Frame" {
						Name = "Stripe",
						Size = UDim2.new(0, 4, 1, -8),
						Position = UDim2.fromOffset(4, 4),
						BackgroundColor3 = color,
						[Children] = Kit.corner(scope, "Full"),
					},
					Icon(scope, {
						Icon = scope:Computed(function(use)
							local s = use(state)
							return STATE_ICON[s] or member.Icon or (if isKiller then "hand" else "head-blank")
						end),
						Size = rowH - 14,
						Position = UDim2.fromOffset(14, 5),
						Color = color,
					}),
					Text(scope, {
						Text = tostring(member.Name or ""),
						TextSize = math.floor(rowH * 0.42),
						Font = HEAVY,
						Case = "Normal",
						XAlign = "Left",
						Stroke = false,
						Position = UDim2.fromOffset(rowH + 8, 0),
						Size = UDim2.new(0.6, -rowH, 1, 0),
						Scaled = true,
						Wrapped = false,
						MaxTextSize = math.floor(rowH * 0.42),
					}),
					Text(scope, {
						Text = scope:Computed(function(use)
							return string.upper(use(state))
						end),
						TextSize = math.floor(rowH * 0.34),
						Font = DISPLAY,
						Case = "Upper",
						XAlign = "Right",
						Color = color,
						Stroke = false,
						AnchorPoint = Vector2.new(1, 0),
						Position = UDim2.new(1, -10, 0, 0),
						Size = UDim2.new(0.4, 0, 1, 0),
						Scaled = true,
						Wrapped = false,
						MaxTextSize = math.floor(rowH * 0.34),
					}),
				},
			}
		end
		for index, member in members do
			table.insert(rows, row(member, index, false))
		end
		if killer then
			table.insert(rows, row(killer, #members + 1, true))
		end
		table.insert(children, Text(scope, {
			Name = "Title",
			Text = tostring(peek(props.Title) or "SURVIVORS"),
			TextSize = titleSize,
			Font = HEAVY,
			Case = "Upper",
			XAlign = "Left",
			Color = Kit.themed(scope, function(theme)
				return theme.Color.TextMuted
			end),
			Stroke = 2,
			StrokeTransparency = 0.4,
			Size = UDim2.new(1, 0, 0, titleSize + 2),
		}))
		table.insert(children, scope:New "Frame" {
			Name = "Rows",
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(0, titleSize + 8),
			Size = UDim2.new(1, 0, 1, -(titleSize + 8)),
			[Children] = {
				Kit.list(scope, { Direction = "Y", Padding = 4 }),
				rows,
			},
		})
	else
		local ring = math.clamp(h - titleSize - 36, 40, 76)
		local cells = {}
		for index, member in members do
			table.insert(cells, portrait(member, index, ring, false))
		end
		if killer then
			table.insert(cells, scope:New "Frame" {
				Name = "Versus",
				LayoutOrder = #members + 1,
				BackgroundTransparency = 1,
				Size = UDim2.fromOffset(34, ring),
				[Children] = {
					scope:New "Frame" {
						Name = "Rule",
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Size = UDim2.new(0, 2, 1, -6),
						BackgroundColor3 = Kit.themed(scope, function(theme)
							return theme.Color.Bad
						end),
						BackgroundTransparency = 0.3,
					},
					Text(scope, {
						Text = "VS",
						TextSize = 18,
						Font = DISPLAY,
						Case = "Upper",
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Bad
						end),
						Stroke = 3,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						ZIndex = 3,
					}),
				},
			})
			table.insert(cells, portrait(killer, #members + 2, ring + 8, true))
		end
		table.insert(children, Text(scope, {
			Name = "Title",
			Text = tostring(peek(props.Title) or "SURVIVORS"),
			TextSize = titleSize,
			Font = HEAVY,
			Case = "Upper",
			Color = Kit.themed(scope, function(theme)
				return theme.Color.TextMuted
			end),
			Stroke = 2,
			StrokeTransparency = 0.4,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5, 0),
			Size = UDim2.new(1, 0, 0, titleSize + 2),
		}))
		table.insert(children, scope:New "Frame" {
			Name = "Portraits",
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(0, titleSize + 6),
			Size = UDim2.new(1, 0, 1, -(titleSize + 6)),
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 2, HAlign = "Center", VAlign = "Top" }),
				cells,
			},
		})
	end
	local _ = w
	if props[Children] ~= nil then
		table.insert(children, props[Children])
	end
	root[Children] = children
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="149">
            <Properties>
              <string name="Name">Text</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Text: a themed label with a tinted outline.

	scope:Text {
		Text = "Key Bundle",
		TextSize = 34,             -- px, or a type-scale step: "Hero" | "H1" | "H2" | "H3" | "Body" | "Small" | "Caption"
		Font = "Display",          -- "Title" | "Display" | "Body" | "Heavy" | "Mono" | Font | "Family:Weight"
		Color = Color3.new(1, 1, 1),  -- the theme's accent reads as Color.AccentText
		Stroke = true,             -- false to turn off, or a number for thickness
		                           -- (default: the theme's width, scaled with the text size)
		StrokeColor = tone.Stroke, -- default: the theme outline (a deep tint, never pure black)
		Gradient = "Yellow",       -- tone name or ColorSequence (optional)
		XAlign = "Left",           -- "Left" | "Center" | "Right"
		Case = "Normal",           -- "Normal" | "Upper"; default follows the theme's TextCase
		Outline = "Art",           -- outline recipe by background: "Glass" | "Art" | "Button" | "Solid" | "None"
		                           -- (theme.TextStroke); Stroke / StrokeColor still override it
	}

	With no Size it sizes itself to fit (AutoSize = "XY").

	Sizes above 100 just work: Roblox caps TextSize at 100, so a 150 px title
	renders at TextSize 75 inside a UIScale of 2 (its Size, outline and
	MaxTextSize are divided to match). Use TextSize = 150 as usual.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)

local Children = Fusion.Children

return function(scope: Fusion.Scope<any>, props: { [any]: any }): TextLabel
	local autoSize = props.AutoSize
	if autoSize == nil and props.Size == nil then
		autoSize = "XY"
	end

	local textSize = props.TextSize or 24

	-- Roblox clamps TextSize at 100: bigger text draws at size / k and is
	-- magnified by a UIScale of k (k = 1 for the usual sizes, no extra
	-- instance then).
	local plain = typeof(textSize) == "number" and textSize <= 100
	local resolved = if typeof(textSize) == "number" then textSize else Kit.textSize(scope, textSize)
	local magnify: any = if plain then 1 else scope:Computed(function(use)
		return math.max(1, math.ceil(use(resolved) / 100))
	end)

	local root = Kit.root(props, { Name = "Text" })
	if plain then
		root.Size = props.Size or UDim2.fromOffset(0, 0)
	else
		root.Size = scope:Computed(function(use)
			local size = use(props.Size) or UDim2.fromOffset(0, 0)
			local k = use(magnify)
			if k == 1 then
				return size
			end
			return UDim2.new(size.X.Scale / k, size.X.Offset / k, size.Y.Scale / k, size.Y.Offset / k)
		end)
	end
	root.AutomaticSize = if autoSize then Enum.AutomaticSize[autoSize] else Enum.AutomaticSize.None
	root.BackgroundTransparency = 1
	root.Text = Kit.caseText(scope, props.Text or "", props.Case)
	-- v2.4 style families: nothing renders under 14 px at 1080p, and small
	-- text is raised further to its font's optical minimum (Kit.opticalMin:
	-- letters with an em of Base.GLYPH_MIN px, so a tall-line-box face such
	-- as Titillium isn't drawn tiny), but never past its own box: a fixed
	-- height caps it, a fixed width fits it (Kit.fitTextSize), and wrapped
	-- text in a fixed box keeps its size. Scaled text keeps shrinking to fit;
	-- classic themes are unchanged.
	local function familySize(use: any, size: number): number
		local theme = use(Theme.current)
		if theme.Family == nil or props.Scaled or use(magnify) ~= 1 then
			return size
		end
		local base = math.max(size, 14)
		local font = Theme.font(theme, use(props.Font) or "Body")
		local want = math.max(base, Kit.opticalMin(theme, font))
		if want <= base then
			return base
		end
		local box = use(props.Size)
		local wrapped = if props.Wrapped ~= nil then use(props.Wrapped) == true else false
		if typeof(box) == "UDim2" then
			local fixedH = box.Y.Scale == 0 and box.Y.Offset > 0
			if fixedH and wrapped then
				return base
			end
			if fixedH then
				want = math.min(want, math.max(base, box.Y.Offset))
			end
			if box.X.Scale == 0 and box.X.Offset > 0 and not wrapped then
				local shown = use(root.Text)
				want = math.max(base, math.min(want, Kit.fitTextSize(tostring(shown or ""), want, box.X.Offset - 2, font, base, theme)))
			end
		end
		return want
	end
	-- (the largest optical minimum is ~23: sizes above it never change)
	root.TextSize = if plain and textSize >= 24 then textSize
		elseif plain then scope:Computed(function(use)
			return familySize(use, textSize)
		end)
		else scope:Computed(function(use)
			local size = use(resolved) / use(magnify)
			if typeof(size) == "number" and size < 24 then
				return familySize(use, size)
			end
			return size
		end)
	root.TextScaled = props.Scaled or false
	-- Scaled text wraps unless Wrapped = false (then it shrinks on one line)
	root.TextWrapped = if props.Wrapped ~= nil then props.Wrapped else (props.Scaled or false)
	root.RichText = props.RichText or false
	root.TextTransparency = props.Transparency or 0
	root.LineHeight = props.LineHeight or 1
	root.MaxVisibleGraphemes = props.MaxVisibleGraphemes or -1
	root.TextTruncate = if props.Truncate then Enum.TextTruncate.AtEnd else Enum.TextTruncate.None

	root.FontFace = scope:Computed(function(use)
		return Theme.font(use(Theme.current), use(props.Font) or "Body")
	end)
	root.TextColor3 = scope:Computed(function(use)
		if use(props.Gradient) ~= nil then
			return Color3.new(1, 1, 1)
		end
		local theme = use(Theme.current)
		local color = use(props.Color) or theme.Color.Text
		-- text in the accent colour reads in its readable twin (Horror's
		-- blood red is lifted so it shows on near-black)
		local readable = theme.Color.AccentText
		if readable ~= nil and color == theme.Color.Accent then
			return readable
		end
		return color
	end)
	root.TextXAlignment = scope:Computed(function(use)
		return Enum.TextXAlignment[use(props.XAlign) or "Center"]
	end)
	root.TextYAlignment = scope:Computed(function(use)
		return Enum.TextYAlignment[use(props.YAlign) or "Center"]
	end)

	-- outline recipe (docs/RESEARCH.md 2.2), if one was asked for
	local recipe = scope:Computed(function(use)
		local name = use(props.Outline)
		if name == nil then
			return nil
		end
		return Theme.textStroke(use(Theme.current), name, use(props.StrokeColor))
	end)

	root[Children] = {
		scope:New "UIStroke" {
			ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual,
			LineJoinMode = Enum.LineJoinMode.Round,
			Color = scope:Computed(function(use)
				local r = use(recipe)
				if r then
					return r.Color
				end
				return use(props.StrokeColor) or use(Theme.current).Color.Outline
			end),
			Thickness = scope:Computed(function(use)
				local stroke = use(props.Stroke)
				local k = use(magnify)
				if typeof(stroke) == "number" then
					return stroke / k
				end
				local r = use(recipe)
				if r then
					return r.Thickness / k
				end
				-- the theme width is tuned for ~30 px text: thinner on small
				-- labels (crisper), a touch heavier on big titles
				local size = use(resolved) or 24
				return use(Theme.current).Stroke.Text * math.clamp(size / 30, 0.7, 1.4) / k
			end),
			Transparency = scope:Computed(function(use)
				local base = use(props.Transparency) or 0
				local extra = use(props.StrokeTransparency)
				if extra == nil then
					local r = use(recipe)
					extra = if r then r.Transparency else 0
				end
				return 1 - (1 - base) * (1 - extra)
			end),
			Enabled = scope:Computed(function(use)
				local stroke = use(props.Stroke)
				return stroke ~= false and stroke ~= 0
			end),
		},

		if props.Gradient ~= nil
			then scope:New "UIGradient" {
				Rotation = props.GradientRotation or 90,
				Color = scope:Computed(function(use)
					local gradient = use(props.Gradient)
					if typeof(gradient) == "ColorSequence" then
						return gradient
					end
					local tone = Theme.tone(use(Theme.current), gradient)
					return ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color.lighten(tone.Top, 0.45)),
						ColorSequenceKeypoint.new(0.5, tone.Top),
						ColorSequenceKeypoint.new(1, tone.Bottom),
					})
				end),
			}
			else nil,

		if props.Scaled or props.MaxTextSize
			then scope:New "UITextSizeConstraint" {
				MaxTextSize = scope:Computed(function(use)
					return math.clamp(math.floor((props.MaxTextSize or 100) / use(magnify)), 1, 100)
				end),
				MinTextSize = scope:Computed(function(use)
					return math.max(1, math.floor((props.MinTextSize or 1) / use(magnify)))
				end),
			}
			else nil,

		if plain
			then nil
			else scope:Computed(function(use, inner)
				if use(magnify) <= 1 then
					return nil
				end
				return inner:New "UIScale" { Scale = magnify }
			end),

		props[Children],
	}

	return scope:New "TextLabel" (root) :: TextLabel
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="150">
            <Properties>
              <string name="Name">TextInput</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	TextInput: a rounded text field for codes and search. Icon on the left,
	placeholder, a glow while focused, an optional submit button, Enter to
	submit, and success / error feedback (green glow + check, or red shake
	with a message).

	local code = scope:Value("")
	scope:TextInput {
		Placeholder = "Enter code...",
		Text = code,                     -- Value<string> (optional)
		Icon = "tag",
		ButtonText = "Redeem",           -- shows the submit button
		ButtonTone = "Green",
		OnSubmit = function(text)        -- may yield (e.g. a RemoteFunction)
			return true                  -- true = success, false / "message" = error, nil = no feedback
		end,
		SuccessText = "Code redeemed!",  -- shown after a true result
		ClearOnSuccess = true,
		MaxLength = 20,
		Size = UDim2.fromOffset(560, 72),
	}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Button = require(script.Parent.Button)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local OnChange = Fusion.OnChange
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

return function(scope: Scope, props: { [any]: any }): Frame
	local size = peek(props.Size) or UDim2.fromOffset(560, 72)
	if typeof(size) == "Vector2" then
		size = UDim2.fromOffset(size.X, size.Y)
	end
	local height = if size.Y.Offset > 0 then size.Y.Offset else 72
	local text = props.Text
	if typeof(text) ~= "table" or text.set == nil then
		text = scope:Value(if typeof(props.Text) == "string" then props.Text else "")
	end

	local alive = true
	table.insert(scope, function()
		alive = false
	end)

	local focused = scope:Value(false)
	local hovering = scope:Value(false)
	local busy = scope:Value(false)
	-- "idle" | "success" | "error"
	local status = scope:Value("idle")
	local message = scope:Value("")
	local token = 0

	local focus = Kit.spring(scope, scope:Computed(function(use)
		return if use(focused) then 1 elseif use(hovering) then 0.35 else 0
	end), 1.1, 0.8)

	local statusColor = scope:Computed(function(use)
		local theme = use(Theme.current)
		local s = use(status)
		if s == "success" then
			return theme.Color.Good
		elseif s == "error" then
			return theme.Color.Bad
		end
		return theme.Color.Accent
	end)

	local hasButton = props.ButtonText ~= nil or props.ButtonIcon ~= nil
	local buttonWidth = if hasButton then (props.ButtonWidth or math.floor(height * 2.5)) else 0
	local iconSize = math.floor(height * 0.46)
	local textSize = props.TextSize or math.floor(height * 0.36)

	local field: Frame? = nil
	local box: TextBox? = nil

	local function setStatus(kind: string, note: string?)
		token += 1
		local mine = token
		status:set(kind)
		message:set(note or "")
		if kind ~= "idle" then
			task.delay(2.4, function()
				if alive and token == mine then
					status:set("idle")
					message:set("")
				end
			end)
		end
	end

	local function submit()
		if peek(busy) then
			return
		end
		local value = peek(text)
		if value == "" and not props.AllowEmpty then
			setStatus("error", props.EmptyText or "Type something first")
			Sound.play("Error")
			if field then
				Motion.shake(field, 10, 0.35)
			end
			return
		end
		if props.OnSubmit == nil then
			return
		end
		busy:set(true)
		task.spawn(function()
			local ok, result = pcall(props.OnSubmit, value)
			if not alive then
				return
			end
			busy:set(false)
			if not ok then
				warn(`[BloxUI] TextInput OnSubmit errored: {result}`)
				result = false
			end
			if result == true then
				setStatus("success", props.SuccessText or "Success!")
				Sound.play("Reward")
				if props.ClearOnSuccess ~= false then
					text:set("")
				end
				if field then
					Motion.pop(field, 0.06)
				end
			elseif result == false or typeof(result) == "string" then
				setStatus("error", if typeof(result) == "string" then result else (props.ErrorText or "That didn't work"))
				Sound.play("Error")
				if field then
					Motion.shake(field, 10, 0.4)
				end
			end
		end)
	end

	local stroke = scope:Computed(function(use)
		local theme = use(Theme.current)
		local s = use(status)
		local base = theme.Color.SurfaceLight
		if s ~= "idle" then
			return use(statusColor)
		end
		return base:Lerp(theme.Color.Accent, math.clamp(use(focus), 0, 1))
	end)

	local glowTransparency = scope:Computed(function(use)
		local strength = math.clamp(use(focus), 0, 1)
		if use(status) ~= "idle" then
			strength = 1
		end
		return 1 - strength * 0.55
	end)

	box = scope:New "TextBox" {
		Name = "Box",
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(height, 0),
		Size = UDim2.new(1, -height - buttonWidth - 14, 1, 0),
		ClearTextOnFocus = false,
		MultiLine = false,
		Text = text,
		PlaceholderText = props.Placeholder or "",
		PlaceholderColor3 = Kit.themed(scope, function(theme)
			return theme.Color.TextMuted
		end),
		TextColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Text
		end),
		FontFace = scope:Computed(function(use)
			return Theme.font(use(Theme.current), use(props.Font) or "Heavy")
		end),
		TextSize = textSize,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextTruncate = Enum.TextTruncate.AtEnd,
		TextEditable = scope:Computed(function(use)
			return not use(busy)
		end),
		ZIndex = 5,
		[OnChange "Text"] = function(new: string)
			local limit = props.MaxLength
			if limit and #new > limit then
				new = string.sub(new, 1, limit)
				if box then
					box.Text = new
				end
			end
			if new ~= peek(text) then
				text:set(new)
			end
			if peek(status) == "error" then
				setStatus("idle")
			end
		end,
		[OnEvent "Focused"] = function()
			focused:set(true)
		end,
		[OnEvent "FocusLost"] = function(enterPressed: boolean)
			focused:set(false)
			if enterPressed then
				submit()
			end
		end,
	} :: TextBox

	field = scope:New "Frame" {
		Name = "Field",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Surface
		end),
		BackgroundTransparency = Kit.themed(scope, function(theme)
			return math.min(theme.Alpha.Surface, 0.25)
		end),
		ZIndex = 2,
		[OnEvent "MouseEnter"] = function()
			hovering:set(true)
		end,
		[OnEvent "MouseLeave"] = function()
			hovering:set(false)
		end,
		[Children] = {
			Kit.corner(scope, props.Radius or "Full"),
			scope:New "UIStroke" {
				ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				LineJoinMode = Enum.LineJoinMode.Round,
				Color = stroke,
				Thickness = scope:Computed(function(use)
					return 2 + math.clamp(use(focus), 0, 1)
				end),
			},
			-- inner top highlight (layered fill)
			scope:New "Frame" {
				Name = "Sheen",
				BackgroundColor3 = Color3.new(1, 1, 1),
				Size = UDim2.fromScale(1, 0.5),
				ZIndex = 3,
				[Children] = {
					Kit.corner(scope, props.Radius or "Full"),
					Kit.gradient(scope, {
						Rotation = 90,
						Transparency = NumberSequence.new(0.9, 1),
					}),
				},
			},
			-- icon well
			scope:New "Frame" {
				Name = "IconWell",
				BackgroundTransparency = 1,
				Size = UDim2.fromOffset(height, height),
				ZIndex = 4,
				[Children] = Icon(scope, {
					Name = "Glyph",
					Icon = scope:Computed(function(use)
						local s = use(status)
						if s == "success" then
							return "check"
						elseif s == "error" then
							return "warning"
						elseif use(busy) then
							return "timer"
						end
						return use(props.Icon) or "search"
					end),
					Size = iconSize,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.55, 0.5),
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						if use(status) ~= "idle" then
							return use(statusColor)
						end
						return theme.Color.TextMuted:Lerp(theme.Color.Text, math.clamp(use(focus), 0, 1))
					end),
					Stroke = false,
					ZIndex = 5,
				}),
			},
			box,
			if hasButton
				then Button(scope, {
					Name = "Submit",
					Text = props.ButtonText,
					Icon = props.ButtonIcon,
					Tone = props.ButtonTone or "Green",
					Disabled = busy,
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -7, 0.5, 0),
					Size = UDim2.fromOffset(buttonWidth, height - 14),
					TextSize = math.floor(height * 0.36),
					IconSize = math.floor(height * 0.4),
					Radius = props.Radius or "Full",
					Depth = 3,
					OnClick = submit,
					ZIndex = 6,
				})
				else nil,
		},
	} :: Frame

	local root = Kit.root(props, { Name = "TextInput" })
	root.Size = size
	root.BackgroundTransparency = 1
	root[Children] = {
		-- focus / status glow around the field
		scope:New "Frame" {
			Name = "Glow",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.new(1, 10, 1, 10),
			BackgroundColor3 = statusColor,
			BackgroundTransparency = glowTransparency,
			ZIndex = 1,
			[Children] = {
				Kit.corner(scope, props.Radius or "Full"),
				Kit.gradient(scope, {
					Rotation = 90,
					Transparency = NumberSequence.new(0.35, 0.75),
				}),
			},
		},
		field,
		Text(scope, {
			Name = "Message",
			Text = message,
			TextSize = math.max(16, math.floor(height * 0.28)),
			Font = "Heavy",
			Color = statusColor,
			Stroke = 1.5,
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 0),
			Position = UDim2.new(0, math.floor(height * 0.4), 1, 6),
			Visible = scope:Computed(function(use)
				return use(message) ~= ""
			end),
			ZIndex = 7,
		}),
		props[Children],
	}
	return scope:New "Frame" (root) :: Frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="151">
            <Properties>
              <string name="Name">Timer</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Timer: a HUD pill that counts down. Tone icon badge, a small label and a
	big number in the display font. The last seconds turn red and tick with
	a pulse. Counts to an os.time() deadline, or down from a number of seconds.

	scope:Timer {
		EndsAt = os.time() + 90,         -- deadline (number or state), or...
		Seconds = 90,                    -- ...seconds left (number or state; restarts when it changes)
		Label = "Round ends",            -- optional small caption
		Icon = "timer",
		Tone = "Blue",                   -- icon badge colour
		Urgent = 10,                     -- seconds left when it turns red (0 = never)
		OnEnd = function() end,          -- fires once when it reaches 0
		Height = 64,                     -- px; width fits the text
	}

	HUD kit looks (Style, with Kit = a HudKits name / tokens):
		"Pill"       the pill above (the original)
		"Big"        big centre digits under a small caption
		"Ring"       a ring that drains round the digits (Total = the full length,
		             default: the time left when it started)
		"Flip"       flip-clock digit tiles that flip as they change
		"Stopwatch"  a small corner stopwatch with a ticking hand
	New looks have a fixed size; every look sets a HudSize attribute.
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Sound = require(script.Parent.Parent.Util.Sound)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local P = require(script.Parent.HudParts)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)
local BLACK = Color3.new(0, 0, 0)
local Motion = require(script.Parent.Parent.FX.Motion)

-- 45 -> "0:45", 725 -> "12:05", 3725 -> "1:02:05", 90000 -> "1d 01h"
local function clock(seconds: number): string
	seconds = math.max(0, math.ceil(seconds))
	if seconds >= 86400 then
		return string.format("%dd %02dh", seconds // 86400, (seconds % 86400) // 3600)
	end
	local hours = seconds // 3600
	local minutes = (seconds % 3600) // 60
	local secs = seconds % 60
	if hours > 0 then
		return string.format("%d:%02d:%02d", hours, minutes, secs)
	end
	return string.format("%d:%02d", minutes, secs)
end

--------------------------------------------------------------------------
-- Kit looks
--------------------------------------------------------------------------

local LOOKS = {}

local function finish(scope: Scope, props: { [any]: any }, size: Vector2, children: { any }): Frame
	local root = Kit.root(props, { Name = "Timer" })
	root.BackgroundTransparency = 1
	root[Children] = { children, props[Children] }
	local frame = scope:New "Frame" (root) :: Frame
	P.footprint(frame, size)
	return frame
end

local function urgentColor(scope: Scope, s: { [string]: any }, base: any): any
	return scope:Computed(function(use)
		local theme = use(Theme.current)
		return if use(s.urgent) then theme.Tone.Red.Top else use(base)
	end)
end

-- Big centre digits under a small caption.
function LOOKS.Big(scope: Scope, props: { [any]: any }, T: { [string]: any }, s: { [string]: any }): Frame
	local size = Vector2.new(280, if s.hasLabel then 108 else 88)
	local look = P.look(scope, T)
	return finish(scope, props, size, {
		P.plate(scope, T, { Radius = if T.corner == "Round" then "Pill" else nil, Edge = "Bottom", Idle = true }),
		if s.hasLabel
			then P.text(scope, T, look, {
				Name = "Label",
				Role = "Label",
				Text = scope:Computed(function(use)
					return string.upper(tostring(use(props.Label) or ""))
				end),
				TextSize = 16,
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, 10),
				ZIndex = 4,
			})
			else nil,
		P.text(scope, T, look, {
			Name = "Time",
			Role = "Number",
			Text = scope:Computed(function(use)
				return clock(use(s.remaining))
			end),
			TextSize = 64,
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.new(0.5, 0, 1, -6),
			Size = UDim2.new(1, -24, 0, 70),
			Scaled = true,
			Wrapped = false,
			MaxTextSize = 64,
			Color = urgentColor(scope, s, scope:Computed(function(use)
				return use(look).ink
			end)),
			ZIndex = 4,
			[Children] = scope:New "UIScale" { Scale = s.pop },
		}),
	})
end

-- A ring that drains round the digits.
function LOOKS.Ring(scope: Scope, props: { [any]: any }, T: { [string]: any }, s: { [string]: any }): Frame
	local ring = 100
	local size = Vector2.new(150, ring + (if s.hasLabel then 30 else 4))
	local look = P.look(scope, T)
	local total = tonumber(peek(props.Total)) or math.max(1, peek(s.remaining))
	Kit.watch(scope, s.remaining, function(now)
		if now > total then
			total = now
		end
	end)
	local fraction = scope:Spring(scope:Computed(function(use)
		local t = tonumber(use(props.Total)) or total
		return math.clamp(use(s.remaining) / math.max(1, t), 0, 1)
	end), 8, 1)
	local tone = Kit.tone(scope, props.Tone, "Blue")
	return finish(scope, props, size, {
		scope:New "Frame" {
			Name = "Dial",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5, 0),
			Size = UDim2.fromOffset(ring, ring),
			ZIndex = 2,
			[Children] = {
				P.plate(scope, T, { Radius = "Full", Idle = true, ZIndex = 2 }),
				P.ring(scope, {
					Name = "Countdown",
					Size = ring - 12,
					Thickness = 8,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Fraction = fraction,
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						return if use(s.urgent) then theme.Tone.Red.Top else use(tone).Top
					end),
					Track = scope:Computed(function(use)
						return use(look).track
					end),
					TrackAlpha = 0.25,
					Cutout = P.holeColor(scope, look),
					ZIndex = 4,
					[Children] = P.text(scope, T, look, {
						Name = "Time",
						Role = "Number",
						Text = scope:Computed(function(use)
							return clock(use(s.remaining))
						end),
						-- one line that shrinks to the hole (never wraps at the colon)
						TextSize = scope:Computed(function(use)
							return P.fit(clock(use(s.remaining)), 26, ring - 36, T.numberFont, 14, use(Theme.current))
						end),
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = urgentColor(scope, s, scope:Computed(function(use)
							local l = use(look)
							return if l.light then l.ink else use(Theme.current).Color.Text
						end)),
						ZIndex = 9,
						[Children] = scope:New "UIScale" { Scale = s.pop },
					}),
				}),
			},
		},
		if s.hasLabel
			then P.text(scope, T, look, {
				Name = "Label",
				Role = "Label",
				Text = scope:Computed(function(use)
					return string.upper(tostring(use(props.Label) or ""))
				end),
				TextSize = 15,
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(0.5, 0, 1, 0),
				Color = scope:Computed(function(use)
					return use(Theme.current).Color.Text
				end),
				Stroke = 2,
				StrokeColor = scope:Computed(function(use)
					return use(Theme.current).Color.Outline
				end),
				StrokeTransparency = 0.1,
				ZIndex = 4,
			})
			else nil,
	})
end

-- Flip-clock tiles (MM:SS) that flip as each digit changes.
function LOOKS.Flip(scope: Scope, props: { [any]: any }, T: { [string]: any }, s: { [string]: any }): Frame
	local tileW, tileH = 42, 58
	local gap, colon, pad = 4, 16, 12
	local labelH = if s.hasLabel then 24 else 0
	local size = Vector2.new(4 * tileW + 2 * gap + colon + 2 * gap + pad * 2, tileH + labelH + pad * 2)
	local look = P.look(scope, T)
	local digits = scope:Computed(function(use)
		local left = math.max(0, math.ceil(use(s.remaining)))
		local minutes = math.min(99, left // 60)
		local seconds = left % 60
		return string.format("%02d%02d", minutes, seconds)
	end)
	local tiles = {}
	local x = pad
	for i = 1, 4 do
		local digit = scope:Computed(function(use)
			return string.sub(use(digits), i, i)
		end)
		local flip = scope:Spring(scope:Value(1), 32, 0.72)
		Kit.watch(scope, digit, function()
			if not Motion.reduced then
				flip:setPosition(0.08)
			end
		end)
		local tileRadius = scope:Computed(function(use)
			local r = P.radiusFor(use(Theme.current), T, "Small")
			return UDim.new(0, math.min(r.Offset, 6))
		end)
		table.insert(tiles, scope:New "Frame" {
			Name = `Digit{i}`,
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, x, 1, -pad),
			Size = UDim2.fromOffset(tileW, tileH),
			ZIndex = 4,
			[Children] = scope:New "Frame" {
				Name = "Card",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = scope:Computed(function(use)
					return UDim2.new(1, 0, math.clamp(use(flip), 0.02, 1.1), 0)
				end),
				BackgroundColor3 = scope:Computed(function(use)
					local l = use(look)
					local theme = use(Theme.current)
					if l.light then
						return l.ink
					elseif l.kind == "Pixel" or l.kind == "Neon" then
						-- dark plates: lift the tiles so they read as cards
						return theme.Color.SurfaceLight:Lerp(theme.Color.Light, 0.08)
					end
					return theme.Color.SurfaceLight:Lerp(theme.Color.Surface, 0.35)
				end),
				ClipsDescendants = true,
				ZIndex = 4,
				[Children] = {
					scope:New "UICorner" { CornerRadius = tileRadius },
					scope:New "UIGradient" {
						Rotation = 90,
						Color = ColorSequence.new({
							ColorSequenceKeypoint.new(0, Color3.fromRGB(255, 255, 255)),
							ColorSequenceKeypoint.new(0.495, Color3.fromRGB(235, 235, 240)),
							ColorSequenceKeypoint.new(0.505, Color3.fromRGB(190, 190, 200)),
							ColorSequenceKeypoint.new(1, Color3.fromRGB(170, 170, 182)),
						}),
					},
					scope:New "Frame" {
						Name = "Hinge",
						AnchorPoint = Vector2.new(0, 0.5),
						Position = UDim2.fromScale(0, 0.5),
						Size = UDim2.new(1, 0, 0, 2),
						BackgroundColor3 = BLACK,
						BackgroundTransparency = 0.45,
						BorderSizePixel = 0,
						ZIndex = 6,
					},
					P.text(scope, T, look, {
						Name = "Value",
						Role = "Number",
						Text = digit,
						TextSize = 40,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.52),
						Color = urgentColor(scope, s, scope:Computed(function(use)
							local l = use(look)
							return if l.light then l.face else use(Theme.current).Color.Text
						end)),
						Stroke = false,
						ZIndex = 5,
					}),
				},
			},
		})
		x += tileW + gap
		if i == 2 then
			table.insert(tiles, P.text(scope, T, look, {
				Name = "Colon",
				Role = "Number",
				Text = ":",
				TextSize = 38,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0, x + colon / 2 - gap / 2, 1, -pad - tileH / 2),
				ZIndex = 4,
			}))
			x += colon + gap
		end
	end
	return finish(scope, props, size, {
		P.plate(scope, T, { Edge = "Bottom", Idle = true }),
		if s.hasLabel
			then P.text(scope, T, look, {
				Name = "Label",
				Role = "Label",
				Text = scope:Computed(function(use)
					return string.upper(tostring(use(props.Label) or ""))
				end),
				TextSize = 14,
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, pad - 2),
				ZIndex = 4,
			})
			else nil,
		tiles,
	})
end

-- A small corner stopwatch: a dial with a ticking hand and the time.
function LOOKS.Stopwatch(scope: Scope, props: { [any]: any }, T: { [string]: any }, s: { [string]: any }): Frame
	local dial = 40
	local size = Vector2.new(if s.hasLabel then 210 else 150, 50)
	local look = P.look(scope, T)
	local hand = scope:Spring(scope:Computed(function(use)
		-- one step per second, clockwise as it counts down
		return -math.ceil(use(s.remaining)) * 6
	end), 40, 0.5)
	return finish(scope, props, size, {
		P.plate(scope, T, { Radius = if T.corner == "Round" then "Pill" else nil, Edge = "Left", Idle = true }),
		scope:New "Frame" {
			Name = "Dial",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, 6, 0.5, 0),
			Size = UDim2.fromOffset(dial, dial),
			BackgroundColor3 = scope:Computed(function(use)
				local l = use(look)
				return if l.light then l.face else use(Theme.current).Color.Surface
			end),
			ZIndex = 4,
			[Children] = {
				scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
				scope:New "UIStroke" {
					Color = urgentColor(scope, s, scope:Computed(function(use)
						return use(look).accent
					end)),
					Thickness = 2.5,
				},
				scope:New "Frame" {
					Name = "Crown",
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 0, -1),
					Size = UDim2.fromOffset(8, 5),
					BackgroundColor3 = scope:Computed(function(use)
						return use(look).accent
					end),
					BorderSizePixel = 0,
					ZIndex = 5,
				},
				scope:New "Frame" {
					Name = "Hand",
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.fromScale(0.5, 0.5),
					Size = UDim2.fromOffset(2, math.floor(dial * 0.36)),
					Rotation = hand,
					BackgroundColor3 = urgentColor(scope, s, scope:Computed(function(use)
						local l = use(look)
						return if l.light then l.ink else use(Theme.current).Color.Text
					end)),
					BorderSizePixel = 0,
					ZIndex = 6,
					[Children] = scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
				},
			},
		},
		P.text(scope, T, look, {
			Name = "Time",
			Role = "Number",
			Text = scope:Computed(function(use)
				return clock(use(s.remaining))
			end),
			TextSize = 26,
			XAlign = "Left",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, dial + 16, 0.5, if s.hasLabel then 7 else 1),
			Color = urgentColor(scope, s, scope:Computed(function(use)
				return use(look).ink
			end)),
			ZIndex = 4,
			[Children] = scope:New "UIScale" { Scale = s.pop },
		}),
		if s.hasLabel
			then P.text(scope, T, look, {
				Name = "Label",
				Role = "Label",
				Text = scope:Computed(function(use)
					return string.upper(tostring(use(props.Label) or ""))
				end),
				TextSize = 12,
				XAlign = "Left",
				AnchorPoint = Vector2.new(0, 0),
				Position = UDim2.new(0, dial + 16, 0, 5),
				Size = UDim2.fromOffset(size.X - dial - 24, 14),
				Truncate = true,
				ZIndex = 4,
			})
			else nil,
	})
end

local function Timer(scope: Scope, props: { [any]: any }): Frame
	local height = peek(props.Height) or 64
	local urgentAt = props.Urgent or 10
	local hasLabel = props.Label ~= nil
	local tone = Kit.tone(scope, props.Tone, "Blue")

	-- Seconds left, updated a few times a second while the scope lives.
	local function now(): number
		local endsAt = peek(props.EndsAt)
		if endsAt ~= nil then
			return (tonumber(endsAt) or 0) - os.time()
		end
		return 0
	end
	local baseSeconds = tonumber(peek(props.Seconds)) or 0
	local startedAt = os.clock()
	local function remainingNow(): number
		if props.EndsAt ~= nil then
			return now()
		end
		return baseSeconds - (os.clock() - startedAt)
	end

	local remaining = scope:Value(math.max(0, math.ceil(remainingNow())))
	local urgent = scope:Computed(function(use)
		local left = use(remaining)
		return urgentAt > 0 and left > 0 and left <= urgentAt
	end)

	local popGoal = scope:Value(1)
	local pop = scope:Spring(popGoal, 34, 0.45)

	local ended = peek(remaining) <= 0
	local function refresh()
		local left = math.max(0, math.ceil(remainingNow()))
		if left ~= peek(remaining) then
			remaining:set(left)
			if urgentAt > 0 and left > 0 and left <= urgentAt then
				pop:setPosition(1.18)
				Sound.play("Tick", { Volume = 0.5, Pitch = 1.3 })
			end
		end
		if left <= 0 and not ended then
			ended = true
			if props.OnEnd then
				props.OnEnd()
			end
		elseif left > 0 then
			ended = false
		end
	end

	Kit.watch(scope, props.Seconds, function(value)
		baseSeconds = tonumber(value) or 0
		startedAt = os.clock()
		refresh()
	end)
	Kit.watch(scope, props.EndsAt, function()
		refresh()
	end)

	local alive = true
	table.insert(scope, function()
		alive = false
	end)
	task.spawn(function()
		while alive do
			refresh()
			task.wait(0.2)
		end
	end)

	local style = P.style("Timer", props.Style)
	if style ~= "Pill" then
		return LOOKS[style](scope, props, P.tokens(props.Kit), {
			remaining = remaining,
			urgent = urgent,
			pop = pop,
			hasLabel = hasLabel,
		})
	end

	local badge = height - 12
	local timeSize = if hasLabel then math.floor(height * 0.5) else math.floor(height * 0.58)
	local labelSize = math.clamp(math.floor(height * 0.26), 14, 20)

	-- with a kit: the same pill in the kit's plate and type
	local kitted = P.kitted(props.Kit)
	local T = P.tokens(props.Kit)
	local look = if kitted then P.look(scope, T) else nil

	local timeColor = scope:Computed(function(use)
		local theme = use(Theme.current)
		if use(urgent) then
			return theme.Tone.Red.Top
		end
		return if look then use(look).ink else theme.Color.Text
	end)

	local root = Kit.root(props, { Name = "Timer" })
	root.Size = UDim2.fromOffset(0, height)
	root.AutomaticSize = Enum.AutomaticSize.X
	root.BackgroundColor3 = Kit.themed(scope, function(theme)
		return theme.Color.Surface
	end)
	root.BackgroundTransparency = if kitted
		then 1
		else Kit.themed(scope, function(theme)
			return theme.Alpha.Surface
		end)
	local content = {
		Kit.padding(scope, { 0, math.floor(height * 0.34), 0, 6 }),
		Kit.list(scope, { Direction = "X", Padding = 12, VAlign = "Center" }),
		scope:New "Frame" {
			Name = "Badge",
			LayoutOrder = 1,
			Size = UDim2.fromOffset(badge, badge),
			BackgroundColor3 = WHITE,
			ZIndex = 2,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						local t = if use(urgent) then theme.Tone.Red else use(tone)
						return ColorSequence.new({
							ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.12)),
							ColorSequenceKeypoint.new(0.6, t.Bottom),
							ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.2)),
						})
					end),
				}),
				Kit.stroke(scope, {
					Thickness = 2.5,
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						return (if use(urgent) then theme.Tone.Red else use(tone)).Edge
					end),
				}),
				Icon(scope, {
					Icon = props.Icon or "timer",
					Size = math.floor(badge * 0.62),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						return (if use(urgent) then theme.Tone.Red else use(tone)).Text
					end),
					StrokeColor = scope:Computed(function(use)
						local theme = use(Theme.current)
						local t = if use(urgent) then theme.Tone.Red else use(tone)
						return t.Stroke or t.Edge
					end),
					Stroke = 2,
					ZIndex = 3,
				}),
			},
		},
		scope:New "Frame" {
			Name = "Texts",
			LayoutOrder = 2,
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(0, height),
			AutomaticSize = Enum.AutomaticSize.X,
			ZIndex = 2,
			[Children] = {
				Kit.list(scope, { Direction = "Y", Padding = 0, HAlign = "Left", VAlign = "Center" }),
				if hasLabel
					then Text(scope, {
						Name = "Label",
						LayoutOrder = 1,
						Text = scope:Computed(function(use)
							return string.upper(tostring(use(props.Label) or ""))
						end),
						TextSize = labelSize,
						Font = if kitted then T.labelFont else "Heavy",
						XAlign = "Left",
						Color = if look
							then scope:Computed(function(use)
								return use(look).muted
							end)
							else Kit.themed(scope, function(theme)
								return theme.Color.TextMuted
							end),
						Stroke = false,
						ZIndex = 3,
					})
					else nil,
				Text(scope, {
					Name = "Time",
					LayoutOrder = 2,
					Case = "Normal",
					Text = scope:Computed(function(use)
						return clock(use(remaining))
					end),
					TextSize = timeSize,
					Font = if kitted then T.numberFont else "Display",
					XAlign = "Left",
					Color = timeColor,
					Stroke = if look
						then scope:Computed(function(use)
							local l = use(look)
							return if l.inkStrokeWidth == 0 then false else l.inkStrokeWidth
						end)
						else nil,
					StrokeColor = if look
						then scope:Computed(function(use)
							return use(look).inkStroke
						end)
						else nil,
					LineHeight = 0.95,
					ZIndex = 3,
					[Children] = scope:New "UIScale" { Scale = pop },
				}),
			},
		},
	}
	if kitted then
		root[Children] = {
			P.plate(scope, T, { Radius = "Pill", Idle = true }),
			scope:New "Frame" {
				Name = "Content",
				BackgroundTransparency = 1,
				Size = UDim2.fromOffset(0, height),
				AutomaticSize = Enum.AutomaticSize.X,
				ZIndex = 3,
				[Children] = content,
			},
			props[Children],
		}
	else
		root[Children] = {
			Kit.corner(scope, "Full"),
			Kit.stroke(scope, {
				Thickness = 2,
				Transparency = scope:Computed(function(use)
					return if use(urgent) then 0.1 else 0.75
				end),
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					return if use(urgent) then theme.Tone.Red.Top else theme.Color.Light
				end),
			}),
			content,
			props[Children],
		}
	end
	local frame = scope:New "Frame" (root) :: Frame
	-- the footprint a HUD layout reserves (the width follows the caption)
	local labelText = if hasLabel then tostring(peek(props.Label) or "") else ""
	frame:SetAttribute("HudSize", Vector2.new(math.max(240, 150 + math.ceil(#labelText * 20 * 0.56)), height))
	return frame
end

return Timer
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="152">
            <Properties>
              <string name="Name">Tooltip</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Tooltip: a hover label that fades in next to any GuiObject, follows it if
	it moves, and always stays on screen (flips below when there's no room
	above, slides sideways near the edges).

		local Tooltip = UI.Tooltip   -- or require(BloxUI.Components.Tooltip)
		Tooltip.attach(scope, shopButton, "Open the shop (B)")
		Tooltip.attach(scope, coinIcon, scope:Computed(function(use)
			return `{use(coins)} coins`
		end))

	Options (4th argument, optional):
		Side = "Top",        -- preferred side: "Top" | "Bottom"
		Delay = 0.15,        -- hover time before it shows
		Tone = "White",      -- bubble colour (White reads on every theme)
		TextSize = 20,
		MaxWidth = 360,      -- long text wraps

	Shows on mouse hover and while a controller has the element selected;
	never on touch (a tap has no hover). Removed automatically when `scope`
	is cleaned up.
]]

local RunService = game:GetService("RunService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Input = require(script.Parent.Parent.Util.Input)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

export type TooltipOptions = {
	Side: ("Top" | "Bottom")?,
	Delay: number?,
	Tone: any?,
	TextSize: number?,
	MaxWidth: number?,
}

local Tooltip = {}

local MARGIN = 12 -- screen px kept clear at the edges
local GAP = 10 -- design px between the element and the bubble

-- Shows `text` near `target` while the pointer is over it.
function Tooltip.attach(scope: Scope, target: GuiObject, text: any, options: TooltipOptions?)
	local opts = options or {}
	local alive = true
	local hovering = false
	local token = 0
	local bubble: Frame? = nil
	local follow: RBXScriptConnection? = nil

	local shown = scope:Value(false)
	local below = scope:Value(opts.Side == "Bottom")
	local pointerX = scope:Value(0.5)
	local appear = Kit.spring(scope, scope:Computed(function(use)
		return if use(shown) then 1 else 0
	end), 1.1, 0.8)
	local tone = Kit.tone(scope, opts.Tone, "White")
	local alpha = scope:Computed(function(use)
		return 1 - math.clamp(use(appear), 0, 1)
	end)

	local function stopFollowing()
		if follow then
			follow:Disconnect()
			follow = nil
		end
	end

	table.insert(scope, function()
		alive = false
		stopFollowing()
	end)

	-- Centres the bubble over (or under) the target with anchor points, so it
	-- is right even before its own size is known; clamps to the screen once
	-- the size is measured.
	local function place()
		local frame = bubble
		if frame == nil or frame.Parent == nil or target.Parent == nil then
			return
		end
		local gui = frame.Parent :: any
		local origin: Vector2 = gui.AbsolutePosition
		local screen: Vector2 = gui.AbsoluteSize
		local scale = peek(Viewport.scale)
		local size = frame.AbsoluteSize
		if size.X <= 1 or size.Y <= 1 then
			-- not measured yet (its first frame): estimate from the text so
			-- it already flips / clamps correctly
			local textSize = opts.TextSize or 20
			local room = (opts.MaxWidth or 360) - 28
			local width = Kit.textWidth(tostring(peek(text) or ""), textSize, "Body")
			local lines = math.max(1, math.ceil(width / room))
			size = Vector2.new(math.min(width, room) + 28, textSize * lines + 16 + 7) * scale
		end
		local topLeft = target.AbsolutePosition - origin
		local targetSize = target.AbsoluteSize
		local centerX = topLeft.X + targetSize.X / 2
		local gap = GAP * scale

		local wantBelow = opts.Side == "Bottom"
		local aboveY = topLeft.Y - gap -- bubble bottom edge when above
		local belowY = topLeft.Y + targetSize.Y + gap -- bubble top edge when below
		local useBelow
		if wantBelow then
			useBelow = belowY + size.Y <= screen.Y - MARGIN or aboveY - size.Y < MARGIN
		else
			useBelow = aboveY - size.Y < MARGIN
		end

		local x = centerX
		if size.X > 0 then
			x = math.clamp(x, MARGIN + size.X / 2, math.max(MARGIN + size.X / 2, screen.X - MARGIN - size.X / 2))
		end
		local y = if useBelow then belowY else aboveY
		local rise = (1 - math.clamp(peek(appear), 0, 1)) * 8 * scale * (if useBelow then 1 else -1)

		frame.AnchorPoint = Vector2.new(0.5, if useBelow then 0 else 1)
		frame.Position = UDim2.fromOffset(math.floor(x + 0.5), math.floor(y - rise + 0.5))
		below:set(useBelow)
		if size.X > 0 then
			-- the arrow points at the target's centre, but never onto the
			-- bubble's rounded corner
			local edge = math.min(0.45, ((Theme.get().Radius.S or 6) + 10) * scale / size.X)
			pointerX:set(math.clamp(0.5 + (centerX - x) / size.X, edge, 1 - edge))
		else
			pointerX:set(0.5)
		end
	end

	local function build(gui: Instance)
		local textSize = opts.TextSize or 20
		local maxWidth = opts.MaxWidth or 360
		local fill = scope:Computed(function(use)
			return use(tone).Top
		end)
		-- sizes are in real screen pixels (design px * Viewport.scale), so the
		-- bubble matches the scaled UI without relying on UIScale pivots
		local function px(value: number)
			return scope:Computed(function(use)
				return math.max(1, math.floor(value * use(Viewport.scale) + 0.5))
			end)
		end
		local padX, padY = px(14), px(8)
		local pointer = px(14)
		local frame = scope:New "Frame" {
			Name = "BloxUI_Tooltip",
			Parent = gui,
			AnchorPoint = Vector2.new(0.5, 1),
			Size = UDim2.fromOffset(0, 0),
			AutomaticSize = Enum.AutomaticSize.XY,
			BackgroundColor3 = fill,
			BackgroundTransparency = alpha,
			ZIndex = 10000,
			Visible = scope:Computed(function(use)
				return use(appear) > 0.02
			end),
			[Children] = {
				scope:New "UICorner" {
					CornerRadius = scope:Computed(function(use)
						return UDim.new(0, math.floor(use(Theme.current).Radius.S * use(Viewport.scale) + 0.5))
					end),
				},
				scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
					Color = scope:Computed(function(use)
						return use(tone).Edge
					end),
					Thickness = 1.5,
					Transparency = scope:Computed(function(use)
						return 1 - (1 - use(alpha)) * 0.6
					end),
				},
				scope:New "UIPadding" {
					PaddingTop = scope:Computed(function(use)
						return UDim.new(0, use(padY))
					end),
					PaddingBottom = scope:Computed(function(use)
						return UDim.new(0, use(padY))
					end),
					PaddingLeft = scope:Computed(function(use)
						return UDim.new(0, use(padX))
					end),
					PaddingRight = scope:Computed(function(use)
						return UDim.new(0, use(padX))
					end),
				},
				scope:New "TextLabel" {
					Name = "Label",
					BackgroundTransparency = 1,
					Size = UDim2.fromOffset(0, 0),
					AutomaticSize = Enum.AutomaticSize.XY,
					Text = text,
					TextSize = px(textSize),
					TextWrapped = true,
					FontFace = scope:Computed(function(use)
						return Theme.font(use(Theme.current), "Body")
					end),
					TextColor3 = scope:Computed(function(use)
						return use(tone).Text
					end),
					TextTransparency = alpha,
					ZIndex = 10002,
					[Children] = scope:New "UISizeConstraint" {
						MaxSize = scope:Computed(function(use)
							return Vector2.new((maxWidth - 28) * use(Viewport.scale), math.huge)
						end),
					},
				},
				scope:New "Frame" {
					Name = "Pointer",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = scope:Computed(function(use)
						return UDim2.fromScale(use(pointerX), if use(below) then 0 else 1)
					end),
					Size = scope:Computed(function(use)
						return UDim2.fromOffset(use(pointer), use(pointer))
					end),
					Rotation = 45,
					BackgroundColor3 = fill,
					BackgroundTransparency = alpha,
					ZIndex = 10001,
				},
			},
		} :: Frame
		bubble = frame
	end

	local function show()
		if not alive then
			return
		end
		local gui = target:FindFirstAncestorWhichIsA("LayerCollector")
		if gui == nil then
			return
		end
		if bubble == nil or bubble.Parent ~= gui then
			if bubble then
				bubble.Parent = gui
			else
				build(gui)
			end
		end
		place()
		shown:set(true)
		stopFollowing()
		follow = RunService.RenderStepped:Connect(function()
			if not alive or target.Parent == nil then
				stopFollowing()
				if alive then
					shown:set(false)
				end
				return
			end
			place()
		end)
	end

	local function hide()
		token += 1
		hovering = false
		if not alive then
			return
		end
		shown:set(false)
		local mine = token
		task.delay(0.3, function()
			if alive and token == mine and not hovering then
				stopFollowing()
			end
		end)
	end

	local function begin(delay: number)
		hovering = true
		token += 1
		local mine = token
		task.delay(delay, function()
			if alive and hovering and token == mine then
				show()
			end
		end)
	end

	table.insert(scope, target.MouseEnter:Connect(function()
		-- a tap fires MouseEnter but often never MouseLeave, so touch players
		-- don't get hover tips (they'd stick open)
		if peek(Input.device) == "Touch" then
			return
		end
		begin(opts.Delay or 0.15)
	end))
	table.insert(scope, target.MouseLeave:Connect(hide))
	-- controllers: the tip shows while the element is selected
	table.insert(scope, target.SelectionGained:Connect(function()
		begin(math.max(opts.Delay or 0.15, 0.35))
	end))
	table.insert(scope, target.SelectionLost:Connect(hide))
	-- a touch that slipped through (hybrid devices) hides when released
	table.insert(scope, target.InputEnded:Connect(function(input: InputObject)
		if input.UserInputType == Enum.UserInputType.Touch then
			hide()
		end
	end))
	table.insert(scope, target.AncestryChanged:Connect(function()
		if target.Parent == nil then
			hide()
		end
	end))
end

return Tooltip
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="153">
            <Properties>
              <string name="Name">UpgradeCard</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	UpgradeCard: one purchasable upgrade row. Tone icon tile, title with a
	level pill, effect text, level pips and a buy button with the cost. Pops
	and flashes when the level goes up; shows a golden MAX badge at the cap.

	scope:UpgradeCard {
		Title = "Coin Magnet",
		Icon = "coin",
		Level = level,                 -- number or state
		MaxLevel = 10,
		Cost = cost,                   -- number, text or state (number -> "1.2K")
		CurrencyIcon = "coin",
		Effect = "+25% coin pickup range",   -- text, state or function(level) -> text
		Tone = "Yellow",               -- icon tile + pips
		Disabled = cantAfford,         -- bool or state: greys the buy button
		OnBuy = function() end,
		Size = UDim2.fromOffset(600, 136),
	}
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Util.Format)
local Color = require(script.Parent.Parent.Util.Color)
local Motion = require(script.Parent.Parent.FX.Motion)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)
local Button = require(script.Parent.Button)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)
local MAX_PIPS = 12

local function UpgradeCard(scope: Scope, props: { [any]: any }): Frame
	local tone = Kit.tone(scope, props.Tone, "Blue")
	local level = props.Level or 0
	local maxLevel = props.MaxLevel or 10
	local disabled = props.Disabled or false

	local size = peek(props.Size) or UDim2.fromOffset(600, 136)
	local height = if typeof(size) == "UDim2" and size.Y.Offset > 0 then size.Y.Offset else 136
	local pad = 16
	local tile = height - pad * 2
	local left = pad + tile + 16
	local buttonWidth = math.floor(math.clamp(height * 1.35, 150, 200))
	local buttonHeight = math.floor(math.clamp(height * 0.48, 52, 68))
	-- pixel width when the caller knows it (Scale-sized cards assume 600)
	local width = if typeof(size) == "UDim2" and size.X.Offset > 0 then size.X.Offset else 600
	local narrow = width < 700

	local levelNow = scope:Computed(function(use)
		return math.max(0, tonumber(use(level)) or 0)
	end)
	local levelMax = scope:Computed(function(use)
		return math.max(1, tonumber(use(maxLevel)) or 1)
	end)
	local maxed = scope:Computed(function(use)
		return use(levelNow) >= use(levelMax)
	end)

	local strokeColor = scope:Computed(function(use)
		local t = use(tone)
		return t.Stroke or t.Edge
	end)

	local popGoal = scope:Value(1)
	local pop = scope:Spring(popGoal, 30, 0.4)
	local flash = scope:Value(1)
	local flashTween = scope:Tween(flash, TweenInfo.new(0.45, Enum.EasingStyle.Quad, Enum.EasingDirection.Out))
	local last = peek(levelNow)
	Kit.watch(scope, levelNow, function(now)
		if now > last then
			pop:setPosition(1.35)
			flash:set(0.7)
			task.delay(0.06, function()
				flash:set(1)
			end)
		end
		last = now
	end)

	local effectText = scope:Computed(function(use)
		local effect = props.Effect
		if typeof(effect) == "function" then
			return tostring(effect(use(levelNow)) or "")
		end
		return tostring(use(effect) or "")
	end)

	local costText = scope:Computed(function(use)
		local cost = use(props.Cost)
		if typeof(cost) == "number" then
			return if cost == 0 then "FREE" else Format.abbrev(cost)
		end
		return tostring(cost or "")
	end)

	-- Pips: one per level up to MAX_PIPS, otherwise a smooth bar.
	local pipCount = scope:Computed(function(use)
		local total = use(levelMax)
		return if total <= MAX_PIPS then total else 0
	end)
	local pips = scope:ForValues(scope:Computed(function(use)
		local out = {}
		for i = 1, use(pipCount) do
			out[i] = i
		end
		return out
	end), function(_, innerScope, index)
		return innerScope:New "Frame" {
			Name = `Pip{index}`,
			LayoutOrder = index,
			Size = innerScope:Computed(function(use)
				local n = math.max(1, use(pipCount))
				return UDim2.new(1 / n, -6, 1, 0)
			end),
			BackgroundColor3 = WHITE,
			ZIndex = 3,
			[Children] = {
				Kit.corner(innerScope, "Full"),
				innerScope:New "UIGradient" {
					Rotation = 90,
					Color = innerScope:Computed(function(use)
						local theme = use(Theme.current)
						if index <= use(levelNow) then
							local t = if use(maxed) then theme.Tone.Yellow else use(tone)
							return ColorSequence.new(Color.lighten(t.Top, 0.15), t.Bottom)
						end
						return ColorSequence.new(theme.Color.Track)
					end),
				},
			},
		}
	end)

	local barFill = scope:Spring(scope:Computed(function(use)
		return math.clamp(use(levelNow) / use(levelMax), 0, 1)
	end), 16, 1)

	local levelPill = scope:New "Frame" {
		Name = "LevelPill",
		LayoutOrder = 2,
		-- narrow cards: a badge on the icon tile's bottom edge, so the title
		-- gets the whole header
		AnchorPoint = if narrow then Vector2.new(0.5, 0.5) else nil,
		Position = if narrow then UDim2.fromOffset(pad + tile / 2, pad + tile - 2) else nil,
		ZIndex = if narrow then 6 else 3,
		Size = UDim2.fromOffset(0, 28),
		AutomaticSize = Enum.AutomaticSize.X,
		BackgroundColor3 = WHITE,
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.padding(scope, { 0, 10 }),
			scope:New "UIScale" { Scale = pop },
			Kit.gradient(scope, {
				Rotation = 90,
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					local t = if use(maxed) then theme.Tone.Yellow else use(tone)
					return ColorSequence.new(Color.lighten(t.Top, 0.1), t.Bottom)
				end),
			}),
			Kit.stroke(scope, {
				Thickness = 2,
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					return if use(maxed) then theme.Tone.Yellow.Edge else use(tone).Edge
				end),
			}),
			Text(scope, {
				Text = scope:Computed(function(use)
					return `LV {use(levelNow)}/{use(levelMax)}`
				end),
				TextSize = 18,
				Font = "Display",
				Size = UDim2.fromScale(0, 1),
				AutoSize = "X",
				StrokeColor = scope:Computed(function(use)
					local theme = use(Theme.current)
					local t = if use(maxed) then theme.Tone.Yellow else use(tone)
					return t.Stroke or t.Edge
				end),
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					return (if use(maxed) then theme.Tone.Yellow else use(tone)).Text
				end),
				ZIndex = 4,
			}),
		},
	}

	local card = Kit.root(props, { Name = props.Title or "UpgradeCard" })
	card.Size = size
	card.BackgroundColor3 = Kit.themed(scope, function(theme)
		return theme.Color.SurfaceLight
	end)
	card.BackgroundTransparency = Kit.themed(scope, function(theme)
		return math.clamp(theme.Alpha.Surface * 0.9, 0, 0.6)
	end)
	card[Children] = {
		Kit.corner(scope, "L"),
		Kit.stroke(scope, {
			Thickness = 2,
			Transparency = 0.6,
			Color = Kit.themed(scope, function(theme)
				return theme.Color.Light
			end),
		}),
		scope:New "Frame" {
			Name = "Highlight",
			BackgroundColor3 = WHITE,
			BackgroundTransparency = 0.9,
			Size = UDim2.fromScale(1, 0.5),
			ZIndex = 1,
			[Children] = {
				Kit.corner(scope, "L"),
				Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0, 1) }),
			},
		},
		scope:New "Frame" {
			Name = "Flash",
			BackgroundColor3 = WHITE,
			BackgroundTransparency = flashTween,
			Size = UDim2.fromScale(1, 1),
			ZIndex = 8,
			[Children] = Kit.corner(scope, "L"),
		},
		-- icon tile
		scope:New "Frame" {
			Name = "IconTile",
			Position = UDim2.fromOffset(pad, pad),
			Size = UDim2.fromOffset(tile, tile),
			BackgroundColor3 = WHITE,
			ZIndex = 2,
			[Children] = {
				Kit.corner(scope, "M"),
				Kit.gradient(scope, {
					Rotation = 75,
					Color = scope:Computed(function(use)
						local t = use(tone)
						return ColorSequence.new({
							ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.12)),
							ColorSequenceKeypoint.new(0.55, t.Bottom),
							ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.3)),
						})
					end),
				}),
				Kit.stroke(scope, {
					Thickness = 3,
					Color = scope:Computed(function(use)
						return use(tone).Edge
					end),
				}),
				scope:New "Frame" {
					Name = "Gloss",
					BackgroundColor3 = WHITE,
					BackgroundTransparency = 0.5,
					Position = UDim2.fromOffset(3, 3),
					Size = UDim2.new(1, -6, 0.42, 0),
					ZIndex = 3,
					[Children] = {
						Kit.corner(scope, "M"),
						Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0.2, 1) }),
					},
				},
				Icon(scope, {
					Icon = props.Icon or "upgrade",
					Size = math.floor(tile * 0.64),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Color = scope:Computed(function(use)
						return Color.lighten(use(tone).Top, 0.6)
					end),
					Gradient = true,
					Shadow = true,
					Stroke = 3,
					StrokeColor = strokeColor,
					ZIndex = 4,
				}),
			},
		},
		if narrow then levelPill else nil,
		-- title + level pill
		scope:New "Frame" {
			Name = "Header",
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(left, pad - 2),
			Size = UDim2.new(1, -(left + buttonWidth + pad * 2), 0, 36),
			ZIndex = 3,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 12, VAlign = "Center" }),
				Text(scope, {
					Name = "Title",
					LayoutOrder = 1,
					Text = props.Title or "Upgrade",
					TextSize = 28,
					Font = "Title",
					XAlign = "Left",
					Size = UDim2.new(0, 0, 1, 0),
					AutoSize = "X",
					Truncate = true,
					ZIndex = 3,
					[Children] = scope:New "UISizeConstraint" {
						MaxSize = Vector2.new(math.max(80, width - left - buttonWidth - pad * 2 - (if narrow then 0 else 108)), 36),
					},
				}),
				if narrow then nil else levelPill,
			},
		},
		Text(scope, {
			Name = "Effect",
			Text = effectText,
			TextSize = 20,
			Font = "Body",
			XAlign = "Left",
			Position = UDim2.fromOffset(left, pad + 38),
			Size = UDim2.new(1, -(left + buttonWidth + pad * 2), 0, 26),
			Truncate = true,
			Color = Kit.themed(scope, function(theme)
				return theme.Color.TextMuted
			end),
			Stroke = false,
			ZIndex = 3,
		}),
		scope:New "Frame" {
			Name = "Pips",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, left, 1, -pad - 2),
			Size = UDim2.new(1, -(left + buttonWidth + pad * 2), 0, 12),
			ZIndex = 3,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 6, VAlign = "Center" }),
				pips,
				-- smooth bar when there are too many levels for pips
				scope:New "Frame" {
					Name = "Bar",
					Size = UDim2.fromScale(1, 1),
					Visible = scope:Computed(function(use)
						return use(pipCount) == 0
					end),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Track
					end),
					ZIndex = 3,
					[Children] = {
						Kit.corner(scope, "Full"),
						scope:New "Frame" {
							Name = "Fill",
							Size = scope:Computed(function(use)
								return UDim2.fromScale(use(barFill), 1)
							end),
							BackgroundColor3 = WHITE,
							ZIndex = 4,
							[Children] = {
								Kit.corner(scope, "Full"),
								Kit.gradient(scope, {
									Rotation = 90,
									Color = scope:Computed(function(use)
										local t = use(tone)
										return ColorSequence.new(Color.lighten(t.Top, 0.15), t.Bottom)
									end),
								}),
							},
						},
					},
				},
			},
		},
		Button(scope, {
			Name = "Buy",
			Text = costText,
			Icon = props.CurrencyIcon or "coin",
			Tone = props.ButtonTone or "Green",
			Disabled = disabled,
			Visible = scope:Computed(function(use)
				return not use(maxed)
			end),
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -pad, 0.5, 0),
			Size = UDim2.fromOffset(buttonWidth, buttonHeight),
			TextSize = math.floor(buttonHeight * 0.46),
			IconSize = math.floor(buttonHeight * 0.52),
			Sound = "Purchase",
			OnClick = function()
				if not peek(maxed) and props.OnBuy then
					props.OnBuy()
				end
			end,
			ZIndex = 5,
		}),
		scope:New "Frame" {
			Name = "MaxBadge",
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -pad, 0.5, 0),
			Size = UDim2.fromOffset(buttonWidth, buttonHeight),
			BackgroundColor3 = WHITE,
			Visible = maxed,
			ZIndex = 5,
			[Children] = {
				Kit.corner(scope, "M"),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = Kit.themed(scope, function(theme)
						local t = theme.Tone.Yellow
						return ColorSequence.new({
							ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.15)),
							ColorSequenceKeypoint.new(0.6, t.Bottom),
							ColorSequenceKeypoint.new(1, Color.darken(t.Bottom, 0.15)),
						})
					end),
				}),
				Kit.stroke(scope, {
					Thickness = 3,
					Color = Kit.themed(scope, function(theme)
						return theme.Tone.Yellow.Edge
					end),
				}),
				scope:New "Frame" {
					Name = "Content",
					BackgroundTransparency = 1,
					Size = UDim2.fromScale(1, 1),
					ZIndex = 6,
					[Children] = {
						Kit.list(scope, { Direction = "X", Padding = 6, HAlign = "Center", VAlign = "Center" }),
						Icon(scope, {
							LayoutOrder = 1,
							Icon = "crown",
							Size = math.floor(buttonHeight * 0.5),
							Color = Kit.themed(scope, function(theme)
								return theme.Tone.Yellow.Text
							end),
							StrokeColor = Kit.themed(scope, function(theme)
								local t = theme.Tone.Yellow
								return t.Stroke or t.Edge
							end),
							ZIndex = 6,
						}),
						Text(scope, {
							LayoutOrder = 2,
							Text = "MAX",
							TextSize = math.floor(buttonHeight * 0.46),
							Font = "Display",
							Color = Kit.themed(scope, function(theme)
								return theme.Tone.Yellow.Text
							end),
							StrokeColor = Kit.themed(scope, function(theme)
								local t = theme.Tone.Yellow
								return t.Stroke or t.Edge
							end),
							ZIndex = 6,
						}),
					},
				},
			},
		},
		props[Children],
	}

	local frame = scope:New "Frame" (card) :: Frame

	local maxBadge = frame:FindFirstChild("MaxBadge")
	if maxBadge then
		table.insert(scope, Motion.shine(maxBadge :: GuiObject, { ZIndex = 7, Transparency = 0.5, Interval = 3.5 }))
	end

	return frame
end

return UpgradeCard
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="154">
            <Properties>
              <string name="Name">Window</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Window: a pop-up menu with a dimmed, blurred background, a themed open /
	close transition, and optional tabs (across the top or down the side).

	local open = scope:Value(false)
	scope:Window {
		Open = open,                          -- Value<boolean>
		Size = "Medium",                      -- "Small" 720x480 | "Medium" 980x620 | "Large" 1180x700 | Vector2 | UDim2
		Title = "Upgrades",                   -- window title (ribbon in Ribbon themes)
		Icon = "upgrade",                     -- big title icon that pops out of the corner
		Nav = "SideTabs",                     -- "Tabs" (default with Tabs) | "SideTabs"
		Tabs = { ... },                       -- same format as TabBar (optional)
		Selected = scope:Value("keys"),       -- optional
		Pages = {                             -- one per tab Id (optional)
			keys = function(scope) return { scope:Text { Text = "Keys!" } } end,
		},
		LazyPages = true,                     -- build each function page the first time its
		                                      -- tab is selected (hidden tabs cost nothing)
		Transition = "Pop",                   -- Pop | Rise | Slide | Fade | Drop | Zoom | Swap (default: theme;
		                                      -- reduced motion always uses a quick fade)
		Panel = true,                         -- themed background behind the body
		PanelStyle = "Glass",                 -- override the theme's panel style for this window
		OnClose = function() open:set(false) end,   -- default does exactly this
		CloseOnBackdrop = true,
		Stagger = true,                       -- page content steps in one after another on open
		Scrim = true,                         -- dim the world / HUD behind: true (theme amount,
		                                      -- Theme.Scrim ~0.35-0.6), false, or an opacity 0..1
		Blur = true,                          -- blur the 3D world behind (theme Blur size)
		-- v2.4 style families only (classic themes ignore both):
		FitHeight = scope:Computed(...),      -- design px: the height follows it (springs), e.g. the selected tab's
		Dock = "Right",                       -- "Right" | "Left" (side panels) | "Bottom" (a sheet); nil = centred
		[Children] = { ... },                 -- extra body content
	}

	The scrim fades in with the window: a flat dim in the theme's scrim colour
	plus darker edges (a soft vignette), so the window reads as the only
	thing that matters. It sits on top of the blur and the music muffle.
]]

local Lighting = game:GetService("Lighting")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Sound = require(script.Parent.Parent.Util.Sound)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Gamepad = require(script.Parent.Parent.Util.Gamepad)
local Motion = require(script.Parent.Parent.FX.Motion)
local TabBar = require(script.Parent.TabBar)
local Text = require(script.Parent.Text)
local Icon = require(script.Parent.Icon)
local Button = require(script.Parent.Button)
local Panel = require(script.Parent.Panel)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local SIZES = {
	Small = Vector2.new(720, 480),
	Medium = Vector2.new(980, 620),
	Large = Vector2.new(1180, 700),
}

-- Open springs per transition. `theme = true` takes the theme's
-- Motion.OpenSpeed / OpenDamping (so Toon pops bouncy, Arena rises flat).
local TRANSITIONS = {
	Pop = { speed = 24, damping = 0.62, theme = true },
	Rise = { speed = 26, damping = 0.8 },
	Swap = { speed = 26, damping = 0.85 },
	Zoom = { speed = 26, damping = 0.8 },
	Slide = { speed = 22, damping = 0.78 },
	Drop = { speed = 18, damping = 0.55, theme = true },
	Fade = { speed = 16, damping = 1 },
	-- reduced motion (UI.Motion.reduced): a quick fade, whatever was asked for
	Quick = { speed = 34, damping = 1 },
}

-- Transitions that fade the whole window (need a CanvasGroup).
local FADES = { Fade = true, Rise = true, Swap = true, Quick = true }

local SIDE_WIDTH = 260

--------------------------------------------------------------------------
-- v2.4 style-family chrome (theme.Family.Chrome). Classic themes never
-- reach this code: they build exactly the v2.3 window.
--   Header  Band (a coloured title band) | Hairline | Neon | Rule (gold
--           ornament) | Pixel | Burst (comic plate) | Scratch | Brackets
--           (holo corners) | Slant | Slab (broadcast bar) | Plank (wood) |
--           Tape (sketch) | Soft
--   Close   Round | Pill | Ghost | Bracket | Square | Pixel | Ink | Gold | Iron
-- Tabs dock inside the body's header strip instead of floating above it.
--------------------------------------------------------------------------

local WHITE = Color3.new(1, 1, 1)

-- How tall the header strip is inside the body (a title window).
local FAMILY_HEADER = {
	Band = 88, Hairline = 80, Neon = 84, Rule = 88, Pixel = 84, Burst = 88, Scratch = 80,
	Brackets = 80, Slant = 84, Slab = 84, Plank = 88, Tape = 84, Soft = 84,
}

-- Text props for a header title drawn on the family's header.
local function titleProps(scope: any, fam: any, kind: string): { [string]: any }
	local onBand = kind == "Band" or kind == "Pixel" or kind == "Slab" or kind == "Plank"
	return {
		Font = Kit.themed(scope, function(theme)
			return theme.Font.Ornate or theme.Font.Title
		end),
		Color = Kit.themed(scope, function(theme)
			local c = theme.Color
			if onBand then
				return if kind == "Plank" then Color3.fromHex("F6E7C1") else WHITE
			elseif kind == "Neon" or kind == "Brackets" then
				return c.Text
			elseif kind == "Rule" then
				return c.Accent
			elseif kind == "Burst" then
				return c.Ink or c.PanelText
			end
			return c.PanelText
		end),
		Stroke = if onBand or kind == "Neon" then nil else false,
		StrokeColor = Kit.themed(scope, function(theme)
			local c = theme.Color
			if kind == "Neon" then
				return c.Trim
			elseif kind == "Plank" then
				return c.WoodEdge or c.Outline
			end
			return (theme.Tone.Primary and (theme.Tone.Primary.Stroke or theme.Tone.Primary.Deep)) or c.Outline
		end),
		StrokeTransparency = if kind == "Neon" then 0.35 else nil,
		Gradient = if kind == "Rule" then ColorSequence.new({
			ColorSequenceKeypoint.new(0, Color3.fromHex("FFF3C4")),
			ColorSequenceKeypoint.new(0.5, Color3.fromHex("F5C84C")),
			ColorSequenceKeypoint.new(1, Color3.fromHex("C8901E")),
		}) else nil,
	}
end

-- The family's close button, `size` px square.
local function familyClose(scope: any, fam: any, kind: string, size: number, close: () -> (), position: UDim2, anchor: Vector2): Instance
	if kind == "Ghost" or kind == "Bracket" then
		local hovering = scope:Value(false)
		local hover = scope:Spring(scope:Computed(function(use)
			return if use(hovering) then 1 else 0
		end), 40, 1)
		return scope:New "TextButton" {
			Name = "Close",
			Text = "",
			AutoButtonColor = false,
			AnchorPoint = anchor,
			Position = position,
			Size = UDim2.fromOffset(size, size),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return if theme.Family and theme.Family.Light then theme.Color.PanelText else WHITE
			end),
			BackgroundTransparency = scope:Computed(function(use)
				return 1 - 0.1 * use(hover)
			end),
			ZIndex = 9,
			[OnEvent "MouseEnter"] = function()
				hovering:set(true)
				Sound.play("Hover", { Volume = 0.3 })
			end,
			[OnEvent "MouseLeave"] = function()
				hovering:set(false)
			end,
			[OnEvent "Activated"] = function()
				Sound.play("Close")
				close()
			end,
			[Children] = {
				Kit.corner(scope, "S"),
				Kit.touchPad(scope, UDim2.fromOffset(size, size), { Activated = close }),
				Icon(scope, {
					Icon = "close",
					Size = math.floor(size * 0.6),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Color = Kit.themed(scope, function(theme)
						if kind == "Bracket" then
							return theme.Color.Trim
						end
						return if theme.Family and theme.Family.Light then theme.Color.PanelText else theme.Color.Text
					end),
					Stroke = false,
					ZIndex = 10,
				}),
				if kind == "Bracket"
					then scope:New "UIStroke" {
						ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
						LineJoinMode = Enum.LineJoinMode.Miter,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Trim
						end),
						Thickness = 1,
						Transparency = 0.4,
					}
					else nil,
			},
		}
	end
	return Button(scope, {
		Name = "Close",
		Icon = "close",
		Tone = "Red",
		AnchorPoint = anchor,
		Position = position,
		Size = UDim2.fromOffset(size, size),
		IconSize = math.floor(size * 0.58),
		Radius = if kind == "Round" or kind == "Pill" then "Full" else nil,
		Sound = "Close",
		OnClick = close,
		ZIndex = 9,
	})
end

-- Four L-shaped brackets hugging the body's corners (Holo HUD).
local function brackets(scope: any): { Instance }
	local out = {}
	local arm, thick = 30, 3
	for _, corner in { { 0, 0 }, { 1, 0 }, { 0, 1 }, { 1, 1 } } do
		local x, y = corner[1], corner[2]
		local color = Kit.themed(scope, function(theme)
			return theme.Color.Trim
		end)
		for _, horizontal in { true, false } do
			table.insert(out, scope:New "Frame" {
				Name = "Bracket",
				AnchorPoint = Vector2.new(x, y),
				Position = UDim2.new(x, if x == 0 then -6 else 6, y, if y == 0 then -6 else 6),
				Size = if horizontal then UDim2.fromOffset(arm, thick) else UDim2.fromOffset(thick, arm),
				BackgroundColor3 = color,
				BorderSizePixel = 0,
				ZIndex = 8,
			})
		end
	end
	return out
end

-- The header strip at the top of the body: title (and icon), or the docked
-- tab row, dressed in the family's header recipe.
local function familyHeader(scope: any, fam: any, props: { [any]: any }, height: number, tabRow: Instance?, close: Instance?): Frame
	local chrome = fam.Chrome or {}
	local kind = chrome.Header or "Hairline"
	local hasTitle = props.Title ~= nil and tabRow == nil
	local radiusL = Kit.themed(scope, function(theme)
		return Kit.nestRadius("L", 0, theme)
	end)
	local layers = {}
	local band = kind == "Band" or kind == "Pixel" or kind == "Slab" or kind == "Plank"
	if band then
		-- a coloured band across the top, its top corners nested in the body's
		table.insert(layers, scope:New "Frame" {
			Name = "Band",
			BackgroundColor3 = WHITE,
			BorderSizePixel = 0,
			Size = UDim2.fromScale(1, 1),
			ZIndex = 1,
			[Children] = {
				Kit.cornerRadii(scope, { TopLeft = radiusL, TopRight = radiusL, BottomLeft = 0, BottomRight = 0 }),
				scope:New "UIGradient" {
					Rotation = 90,
					Color = Kit.themed(scope, function(theme)
						local c = theme.Color
						if kind == "Plank" then
							return ColorSequence.new({
								ColorSequenceKeypoint.new(0, c.Wood or Color3.fromHex("6B4226")),
								ColorSequenceKeypoint.new(1, c.WoodDark or Color3.fromHex("3E2616")),
							})
						elseif kind == "Slab" then
							return ColorSequence.new(c.Surface:Lerp(WHITE, 0.08), c.Surface)
						elseif kind == "Pixel" then
							local t = Theme.tone(theme, "Primary")
							return ColorSequence.new(t.Mid or t.Bottom, t.Mid or t.Bottom)
						end
						-- (Theme.tone: a classic theme switched in at runtime has no Primary)
						local t = Theme.tone(theme, if theme.Tone.Primary then "Primary" else "Blue")
						return ColorSequence.new({
							ColorSequenceKeypoint.new(0, t.Top),
							ColorSequenceKeypoint.new(0.55, t.Mid or t.Bottom),
							ColorSequenceKeypoint.new(1, t.Bottom),
						})
					end),
				},
				-- the band's own bottom edge: a darker line
				scope:New "Frame" {
					Name = "Edge",
					AnchorPoint = Vector2.new(0, 1),
					Position = UDim2.fromScale(0, 1),
					Size = UDim2.new(1, 0, 0, if kind == "Pixel" then 4 else 3),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						if kind == "Pixel" then
							return Color3.new(0, 0, 0)
						elseif kind == "Plank" then
							return theme.Color.Accent
						elseif kind == "Slab" then
							return theme.Color.Accent
						end
						return Theme.tone(theme, if theme.Tone.Primary then "Primary" else "Blue").Edge
					end),
					BorderSizePixel = 0,
					ZIndex = 2,
				},
				-- gloss on toy bands
				if kind == "Band"
					then scope:New "Frame" {
						Name = "Gloss",
						BackgroundColor3 = WHITE,
						BackgroundTransparency = 0.72,
						BorderSizePixel = 0,
						Position = UDim2.fromOffset(6, 5),
						Size = UDim2.new(1, -12, 0.42, 0),
						ZIndex = 2,
						[Children] = {
							Kit.corner(scope, "M"),
							Kit.gradient(scope, { Rotation = 90, Transparency = NumberSequence.new(0.1, 1) }),
						},
					}
					else nil,
				if kind == "Slab" then Kit.texture(scope, { Kind = "Stripes", Light = false, ZIndex = 2 }) else nil,
				if kind == "Plank" then Kit.texture(scope, { Kind = "Wood", Transparency = 0.82, ZIndex = 2 }) else nil,
			},
		})
		if kind == "Slab" or chrome.AccentBar then
			table.insert(layers, scope:New "Frame" {
				Name = "AccentBar",
				Position = UDim2.fromOffset(0, 16),
				Size = UDim2.new(0, 8, 1, -32),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Accent
				end),
				BorderSizePixel = 0,
				ZIndex = 3,
			})
		end
	elseif kind == "Hairline" or kind == "Scratch" or kind == "Soft" or kind == "Tape" or kind == "Brackets" then
		table.insert(layers, scope:New "Frame" {
			Name = "Divider",
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.new(0.5, 0, 1, 0),
			Size = UDim2.new(1, -48, 0, if kind == "Scratch" then 2 else 1),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				if kind == "Scratch" then
					return theme.Color.Accent
				elseif kind == "Brackets" then
					return theme.Color.Trim
				end
				return if theme.Family and theme.Family.Light then theme.Color.PanelText else WHITE
			end),
			BackgroundTransparency = if kind == "Scratch" then 0.35 elseif kind == "Brackets" then 0.55 else 0.88,
			BorderSizePixel = 0,
			ZIndex = 2,
		})
	elseif kind == "Neon" then
		table.insert(layers, scope:New "Frame" {
			Name = "NeonRule",
			AnchorPoint = Vector2.new(0.5, 1),
			Position = UDim2.new(0.5, 0, 1, 0),
			Size = UDim2.new(1, -48, 0, 2),
			BackgroundColor3 = WHITE,
			BorderSizePixel = 0,
			ZIndex = 2,
			[Children] = {
				scope:New "UIGradient" {
					Color = Kit.themed(scope, function(theme)
						return ColorSequence.new(theme.Color.Primary or theme.Color.Trim, theme.Color.Secondary or theme.Color.Accent)
					end),
					Transparency = NumberSequence.new({
						NumberSequenceKeypoint.new(0, 0),
						NumberSequenceKeypoint.new(0.7, 0.2),
						NumberSequenceKeypoint.new(1, 1),
					}),
				},
				Kit.shadows(scope, { Role = "Chip", Tone = "Primary" }),
			},
		})
	end

	local titleRow = nil
	if hasTitle then
		local tp = titleProps(scope, fam, kind)
		local iconSize = if kind == "Band" then 58 elseif kind == "Hairline" then 34 else 44
		local centred = kind == "Rule"
		local titleText = Text(scope, {
			Text = props.Title,
			TextSize = Kit.themed(scope, function(theme)
				local size = (theme.Type and theme.Type.Title) or 40
				return if kind == "Hairline" then math.floor(size * 0.85) elseif kind == "Pixel" then math.floor(size * 0.8) else size
			end),
			Font = tp.Font,
			Color = tp.Color,
			Stroke = tp.Stroke,
			StrokeColor = tp.StrokeColor,
			StrokeTransparency = tp.StrokeTransparency,
			Gradient = tp.Gradient,
			LineHeight = 0.95,
			LayoutOrder = 2,
			ZIndex = 5,
		})
		local titleNode: Instance = titleText
		if kind == "Burst" or kind == "Slant" then
			-- the title on its own plate: comic yellow, slanted; gacha a pill
			titleNode = scope:New "Frame" {
				Name = "TitlePlate",
				BackgroundColor3 = WHITE,
				Size = UDim2.fromOffset(0, 58),
				AutomaticSize = Enum.AutomaticSize.X,
				Rotation = if kind == "Burst" then -2 else 0,
				LayoutOrder = 2,
				ZIndex = 4,
				[Children] = {
					Kit.corner(scope, if kind == "Slant" then "Full" else "S"),
					Kit.padding(scope, { 0, 22 }),
					scope:New "UIGradient" {
						Rotation = if kind == "Slant" then 0 else 90,
						Color = Kit.themed(scope, function(theme)
							if kind == "Burst" then
								local t = theme.Tone.Yellow
								return ColorSequence.new(t.Top, t.Mid or t.Bottom)
							end
							return ColorSequence.new(theme.Color.Primary or theme.Color.Accent, theme.Color.Secondary or theme.Color.Accent)
						end),
					},
					Kit.outline(scope, { Width = if kind == "Burst" then 4 else 0 }),
					Kit.shadows(scope, { Role = "Chip" }),
					Kit.list(scope, { Direction = "X", VAlign = "Center" }),
					Text(scope, {
						Text = props.Title,
						TextSize = Kit.themed(scope, function(theme)
							return math.floor(((theme.Type and theme.Type.Title) or 40) * 0.92)
						end),
						Font = "Title",
						Color = if kind == "Burst" then Kit.themed(scope, function(theme)
							return theme.Color.Ink or theme.Color.TextDark
						end) else WHITE,
						Stroke = false,
						ZIndex = 5,
					}),
				},
			}
		end
		titleRow = scope:New "Frame" {
			Name = "Title",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(if centred then 0.5 else 0, 0.5),
			Position = if centred then UDim2.new(0.5, 0, 0.5, if band then -2 else 0) else UDim2.new(0, if kind == "Slab" then 34 else 28, 0.5, if band then -2 else 0),
			Size = UDim2.new(1, -140, 1, 0),
			ZIndex = 4,
			[Children] = {
				Kit.list(scope, { Direction = "X", Padding = 14, VAlign = "Center", HAlign = if centred then "Center" else "Left" }),
				if props.Icon and not centred
					then Icon(scope, {
						Icon = props.Icon,
						Size = iconSize,
						Rotation = if kind == "Band" or kind == "Burst" then -8 else 0,
						Color = props.IconColor or Kit.themed(scope, function(theme)
							if band and kind ~= "Plank" then
								return WHITE
							end
							return if kind == "Hairline" or kind == "Brackets" or kind == "Neon" then theme.Color.Trim else theme.Color.Accent
						end),
						Gradient = kind == "Band" or kind == "Plank" or kind == "Soft" or kind == "Burst",
						Shadow = kind == "Band" or kind == "Burst",
						Stroke = if kind == "Hairline" or kind == "Brackets" then false else nil,
						LayoutOrder = 1,
						ZIndex = 5,
					})
					else nil,
				titleNode,
			},
		}
		if kind == "Rule" then
			-- a gold ornament either side of the centred title
			for _, side in { -1, 1 } do
				table.insert(layers, scope:New "Frame" {
					Name = "Rule",
					AnchorPoint = Vector2.new(if side < 0 then 1 else 0, 0.5),
					Position = UDim2.new(0.5, side * 200, 0.5, 0),
					Size = UDim2.new(0.5, -230, 0, 2),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Accent
					end),
					BorderSizePixel = 0,
					ZIndex = 3,
					[Children] = scope:New "UIGradient" {
						Transparency = if side < 0 then NumberSequence.new(1, 0) else NumberSequence.new(0, 1),
					},
				})
			end
		elseif kind == "Tape" then
			-- masking tape on the top corners
			for _, side in { -1, 1 } do
				table.insert(layers, scope:New "Frame" {
					Name = "Tape",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(if side < 0 then 0 else 1, side * -26, 0, -2),
					Size = UDim2.fromOffset(120, 34),
					Rotation = side * 32,
					BackgroundColor3 = Color3.fromHex("F3D9A4"),
					BackgroundTransparency = 0.22,
					BorderSizePixel = 0,
					ZIndex = 9,
				})
			end
		end
	end

	local tabs = nil
	if tabRow then
		tabs = scope:New "Frame" {
			Name = "TabDock",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, 24, 0.5, if band then -2 else 2),
			Size = UDim2.new(1, -120, 1, 0),
			ZIndex = 5,
			[Children] = tabRow,
		}
	end

	return scope:New "Frame" {
		Name = "FamilyHeader",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, height),
		ZIndex = 6,
		[Children] = { layers, titleRow, tabs, close },
	}
end

return function(scope: Fusion.Scope<any>, props: { [any]: any }): Frame
	local open = props.Open or scope:Value(true)
	local hasTabs = props.Tabs ~= nil and #props.Tabs > 0
	local nav = if hasTabs then (if props.Nav == "SideTabs" then "SideTabs" else "Tabs") else "None"
	local selected = props.Selected or scope:Value(if hasTabs then props.Tabs[1].Id else nil)

	-- v2.4: a style family builds its own chrome (header recipe, tabs docked
	-- in the body, its close button, layered shadows, texture) and moves
	-- with its motion personality
	local fam = Theme.get().Family
	local familyChrome = fam ~= nil and props.Chrome ~= false

	local wanted = peek(props.Size) or Vector2.new(1100, 680)
	if typeof(wanted) == "string" then
		wanted = SIZES[wanted] or SIZES.Medium
	elseif typeof(wanted) == "UDim2" then
		wanted = Vector2.new(wanted.X.Offset, wanted.Y.Offset)
	end
	-- v2.4 style families: FitHeight (a state, design px) lets the window
	-- follow its selected tab's height, springing between tabs. Classic
	-- windows keep their fixed Size.
	local fitSpring = if familyChrome and props.FitHeight ~= nil
		then scope:Spring(scope:Computed(function(use)
			return use(props.FitHeight)
		end), 30, 1)
		else nil
	local function wantedHeight(use: any): number
		if fitSpring then
			return math.max(120, use(fitSpring))
		end
		return wanted.Y
	end
	-- v2.4 style families: where the window sits (Dock). "Right" / "Left" are
	-- side panels on that edge (vertically centred below the top bar) that
	-- slide in from it, "Bottom" is a sheet on the bottom edge that slides
	-- up; both as tall as Size / FitHeight. nil / "Center" is the classic
	-- centred window.
	local dock = if familyChrome then peek(props.Dock) else nil
	if dock ~= "Right" and dock ~= "Left" and dock ~= "Bottom" then
		dock = nil
	end
	local DOCK_MARGIN = 16

	local transition = scope:Computed(function(use)
		if use(Motion.reducedState) then
			return "Quick"
		end
		local given = use(props.Transition)
		if given ~= nil and TRANSITIONS[given] then
			return given
		end
		local themed = Theme.style(use(Theme.current), "Transition")
		return if TRANSITIONS[themed] then themed else "Pop"
	end)

	-- Closing is quicker than opening (~60% of the time) and never bounces.
	local progress = scope:Spring(
		scope:Computed(function(use)
			return if use(open) then 1 else 0
		end),
		scope:Computed(function(use)
			local kind = use(transition)
			local t = TRANSITIONS[kind]
			local motion = use(Theme.current).Motion
			-- style families open every transition with their own personality
			local themed = t.theme or (familyChrome and kind ~= "Quick")
			local speed = if themed and motion.OpenSpeed then motion.OpenSpeed else t.speed
			return if use(open) then speed else speed * 1.6
		end),
		scope:Computed(function(use)
			local kind = use(transition)
			local t = TRANSITIONS[kind]
			local motion = use(Theme.current).Motion
			local themed = t.theme or (familyChrome and kind ~= "Quick")
			local damping = if themed and motion.OpenDamping then motion.OpenDamping else t.damping
			return if use(open) then damping else math.max(damping, 0.9)
		end)
	)

	local visible = scope:Computed(function(use)
		return use(open) or use(progress) > 0.01
	end)

	local function close()
		if props.OnClose then
			props.OnClose()
		elseif open.set then
			open:set(false)
		end
	end

	-- music is muffled while any window is open (RIVALS); refcounted
	local muffled = false
	local function setMuffled(on: boolean)
		if on ~= muffled then
			muffled = on
			Sound.muffle(on)
		end
	end
	setMuffled(peek(open) == true)
	table.insert(scope, function()
		setMuffled(false)
	end)

	local wasOpen = peek(open)
	Kit.watch(scope, open, function(now)
		if now ~= wasOpen then
			Sound.play(if now then "Open" else "Close")
		end
		wasOpen = now
		setMuffled(now == true)
	end)

	if props.Blur ~= false then
		scope:New "BlurEffect" {
			Name = "BloxUIWindowBlur",
			Size = scope:Computed(function(use)
				return math.clamp(use(progress), 0, 1) * use(Theme.current).Blur
			end),
			Enabled = scope:Computed(function(use)
				return use(progress) > 0.02 and use(Theme.current).Blur > 0
			end),
			Parent = Lighting,
		}
	end

	local panelStyle = Kit.surfaceStyle(scope, props.PanelStyle)
	local isRibbon = scope:Computed(function(use)
		return use(panelStyle) == "Ribbon"
	end)

	local usePanel = if props.Panel == nil then nav ~= "Tabs" else props.Panel
	local tabSize: Vector2 = props.TabSize or (if nav == "SideTabs" then Vector2.new(SIDE_WIDTH - 40, 64) else Vector2.new(150, 50))
	local hasTitle = props.Title ~= nil

	local headerKind = if familyChrome then ((fam.Chrome and fam.Chrome.Header) or "Hairline") else nil
	local familyHeaderHeight = if nav == "Tabs" then tabSize.Y + 34 else (FAMILY_HEADER[headerKind or ""] or 84)

	-- Space above the body: tab row (Tabs) or a title bar (None, non-ribbon).
	local topSpace = if nav == "Tabs" and not familyChrome then tabSize.Y + 34 else 0

	-- Space inside the body before content starts.
	local bodyTop = scope:Computed(function(use)
		if familyChrome and (nav == "Tabs" or (nav == "None" and (hasTitle or props.Icon))) then
			return familyHeaderHeight
		elseif nav == "Tabs" then
			return 0
		elseif nav == "SideTabs" then
			return if use(isRibbon) and hasTitle then 44 else 24
		elseif hasTitle or props.Icon then
			return if use(isRibbon) then 52 else 92
		end
		return 0
	end)

	--------------------------------------------------------------------
	-- Pages
	--------------------------------------------------------------------

	-- Tab pages slide in from the direction of travel (a few px, quick).
	local tabOrder = {}
	for index, tab in props.Tabs or {} do
		tabOrder[tab.Id] = index
	end
	local lastTab = peek(selected)
	local travel = 1
	Kit.watch(scope, selected, function(now)
		local from, to = tabOrder[lastTab], tabOrder[now]
		travel = if from and to and to < from then -1 else 1
		lastTab = now
	end)

	local pages = {}
	if props.Pages then
		for id, page in props.Pages do
			local shown = scope:Computed(function(use)
				return use(selected) == id
			end)
			local slideGoal = scope:Value(0)
			local slide = scope:Spring(slideGoal, 30, 0.85)
			Kit.watch(scope, shown, function(now)
				if now then
					local distance = (Theme.get().Motion.TabSlide or 16) * 2
					slide:setPosition(travel * distance)
				end
			end)
			local content
			if props.LazyPages and typeof(page) == "function" then
				-- built the first time its tab is selected
				local lazy = scope:Value(nil)
				local built = false
				local function ensure()
					if not built then
						built = true
						lazy:set(page(scope))
					end
				end
				if peek(shown) then
					ensure()
				end
				Kit.watch(scope, shown, function(now)
					if now then
						ensure()
					end
				end)
				content = lazy
			else
				content = if typeof(page) == "function" then page(scope) else page
			end
			table.insert(pages, scope:New "Frame" {
				Name = `Page_{id}`,
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				Position = scope:Computed(function(use)
					local offset = use(slide)
					return if nav == "SideTabs" then UDim2.fromOffset(offset, 0) else UDim2.fromOffset(0, offset)
				end),
				Visible = shown,
				[Children] = content,
			})
		end
	end

	--------------------------------------------------------------------
	-- Chrome
	--------------------------------------------------------------------

	-- style families: the close button lives in the header strip (or the
	-- body's top-right corner beside side tabs)
	local familyCloseButton = if familyChrome
		then familyClose(
			scope,
			fam,
			(fam.Chrome and fam.Chrome.Close) or "Round",
			if nav == "SideTabs" then 52 else 54,
			close,
			if nav == "SideTabs" then UDim2.new(1, -20, 0, 20) else UDim2.new(1, -22, 0.5, 0),
			if nav == "SideTabs" then Vector2.new(1, 0) else Vector2.new(1, 0.5)
		)
		else nil

	-- tabbed windows close from the tab bar, so they don't build this one
	local closeButton = if nav ~= "Tabs" and not familyChrome then Button(scope, {
		Name = "Close",
		Icon = "close",
		Tone = "Red",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.new(1, -18, 0, 18),
		Size = UDim2.fromOffset(64, 60),
		IconSize = 38,
		Sound = "Close",
		OnClick = close,
		ZIndex = 8,
	}) else nil

	local titleIcon = if props.Icon and not familyChrome
		then Icon(scope, {
			Name = "TitleIcon",
			Icon = props.Icon,
			Size = 96,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromOffset(40, 34),
			Rotation = -10,
			Color = props.IconColor or Kit.themed(scope, function(theme)
				return theme.Color.Accent
			end),
			Gradient = true,
			Shadow = true,
			Visible = scope:Computed(function(use)
				return nav ~= "SideTabs" and not use(isRibbon)
			end),
			ZIndex = 7,
		})
		else nil

	-- the docked tab row (style families): inside the body's header strip
	local dockedTabs = if familyChrome and nav == "Tabs"
		then TabBar(scope, {
			Tabs = props.Tabs,
			Selected = selected,
			TabSize = tabSize,
			OnSelect = props.OnSelect,
			ShoulderHints = true,
			Docked = true,
			OnBand = headerKind == "Band" or headerKind == "Pixel" or headerKind == "Slab" or headerKind == "Plank",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.fromScale(0, 0.5),
			ZIndex = 5,
		})
		else nil

	local titleBar = if familyChrome and (nav == "Tabs" or (nav == "None" and (hasTitle or props.Icon)))
		then familyHeader(scope, fam, props, familyHeaderHeight, dockedTabs, familyCloseButton)
		elseif familyChrome then nil
		elseif nav == "None" and (hasTitle or props.Icon)
		then scope:New "Frame" {
			Name = "TitleBar",
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(0, 0),
			Size = UDim2.new(1, 0, 0, 92),
			Visible = scope:Computed(function(use)
				return not use(isRibbon)
			end),
			ZIndex = 6,
			[Children] = {
				Text(scope, {
					Text = props.Title or "",
					TextSize = "H1",
					-- a theme's decorative face (Font.Ornate) shows on window titles only
					Font = Kit.themed(scope, function(theme)
						return theme.Font.Ornate or theme.Font.Title
					end),
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, if props.Icon then 104 else 32, 0.5, 2),
					ZIndex = 7,
				}),
				scope:New "Frame" {
					Name = "Divider",
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, 0),
					Size = UDim2.new(1, -48, 0, 2),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Glass
					end),
					BackgroundTransparency = 0.9,
					BorderSizePixel = 0,
					ZIndex = 7,
				},
			},
		}
		else nil

	local ribbon = if hasTitle and nav ~= "Tabs" and not familyChrome
		then scope:Computed(function(use, inner)
			if not use(isRibbon) then
				return nil
			end
			return Panel(inner, {
				Name = "RibbonHolder",
				HeaderOnly = true,
				Title = props.Title,
				TitleIcon = props.Icon,
				Tone = props.RibbonTone,
				Size = UDim2.fromScale(1, 0),
				ZIndex = 7,
			})
		end)
		else nil

	local surfaceProps, layers = Kit.surface(scope, {
		Style = panelStyle,
		Radius = "L",
		Decoration = Kit.style(scope, "Decoration", props.Decoration),
		-- style families: the window's own layered shadows, texture and
		-- (Holo / Neon) cut corners
		Role = if familyChrome then "Window" else nil,
		Chamfer = if familyChrome and fam.Chamfer then true else nil,
	})

	local bodyIsLight = scope:Computed(function(use)
		return use(surfaceProps.Look).light
	end)

	local sideColumn = if nav == "SideTabs"
		then scope:New "Frame" {
			Name = "Side",
			BackgroundTransparency = 1,
			Size = UDim2.new(0, SIDE_WIDTH, 1, 0),
			ZIndex = 4,
			[Children] = {
				Kit.padding(scope, { 0, 20, 20, 20 }),
				scope:New "Frame" {
					Name = "SideTitle",
					BackgroundTransparency = 1,
					Size = UDim2.new(1, 0, 0, 72),
					Position = UDim2.fromOffset(0, 20),
					Visible = scope:Computed(function(use)
						return hasTitle and not use(isRibbon)
					end),
					ZIndex = 5,
					[Children] = {
						Kit.list(scope, { Direction = "X", Padding = 10, VAlign = "Center" }),
						if props.Icon
							then Icon(scope, {
								Icon = props.Icon,
								Size = 52,
								Rotation = -8,
								Color = props.IconColor or Kit.themed(scope, function(theme)
									return theme.Color.Accent
								end),
								Gradient = true,
								Shadow = true,
								LayoutOrder = 1,
								ZIndex = 6,
							})
							else nil,
						Text(scope, {
							Text = props.Title or "",
							TextSize = "H3",
							Font = "Title",
							-- style families: ink on light panels, no outline
							Color = if familyChrome then Kit.themed(scope, function(theme)
								return theme.Color.PanelText or theme.Color.Text
							end) else nil,
							Stroke = if familyChrome then Kit.themed(scope, function(theme)
								return if theme.Family and theme.Family.Light then false else nil
							end) else nil,
							LayoutOrder = 2,
							ZIndex = 6,
						}),
					},
				},
				TabBar(scope, {
					Tabs = props.Tabs,
					Selected = selected,
					Vertical = true,
					OnLight = bodyIsLight,
					TabSize = tabSize,
					OnSelect = props.OnSelect,
					Position = scope:Computed(function(use)
						return UDim2.fromOffset(0, if hasTitle and not use(isRibbon) then 108 else use(bodyTop))
					end),
					ZIndex = 5,
				}),
			},
		}
		else nil

	local body = scope:New "Frame" {
		Name = "Body",
		BackgroundColor3 = surfaceProps.BackgroundColor3,
		BackgroundTransparency = if usePanel then surfaceProps.BackgroundTransparency else 1,
		Position = UDim2.fromOffset(0, topSpace),
		Size = UDim2.new(1, 0, 1, -topSpace),
		ZIndex = 3,
		[Children] = {
			if usePanel then layers else nil,
			sideColumn,
			if nav == "SideTabs"
				then scope:New "Frame" {
					Name = "SideDivider",
					Position = UDim2.new(0, SIDE_WIDTH, 0, 24),
					Size = UDim2.new(0, 2, 1, -48),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Glass
					end),
					BackgroundTransparency = 0.88,
					BorderSizePixel = 0,
					ZIndex = 2,
				}
				else nil,
			scope:New "Frame" {
				Name = "Content",
				BackgroundTransparency = 1,
				Position = scope:Computed(function(use)
					return UDim2.fromOffset(if nav == "SideTabs" then SIDE_WIDTH else 0, use(bodyTop))
				end),
				Size = scope:Computed(function(use)
					return UDim2.new(1, if nav == "SideTabs" then -SIDE_WIDTH else 0, 1, -use(bodyTop))
				end),
				ZIndex = 3,
				[Children] = {
					if usePanel then Kit.padding(scope, (function()
						local pad = props.Padding or (if nav == "SideTabs" then { 20, 24, 24, 24 } else 24)
						-- style families: the padding follows the space scale (density)
						if familyChrome and typeof(pad) == "number" then
							local xl = (Theme.get().Space and Theme.get().Space.XL) or 24
							return math.floor(pad * xl / 24 + 0.5)
						end
						return pad
					end)()) else nil,
					pages,
					props[Children],
				},
			},
			titleBar,
			-- style families: the close button when there's no header strip for it
			if familyChrome and titleBar == nil then familyCloseButton else nil,
			if familyChrome and fam.Chrome and fam.Chrome.Brackets then brackets(scope) else nil,
		},
	}

	--------------------------------------------------------------------
	-- Transitions
	--------------------------------------------------------------------

	-- Exits are shorter and simpler than entries (RESEARCH 8.4): Pop shrinks
	-- all the way away instead of freezing at 72%, Rise barely scales.
	local scaleState = scope:Computed(function(use)
		local p = use(progress)
		local kind = use(transition)
		local opening = use(open) == true
		-- (drawers and sheets slide in at full size)
		if dock == "Right" or dock == "Left" or dock == "Bottom" then
			return 1
		end
		if familyChrome then
			local motion = use(Theme.current).Motion
			-- pixel families move in 4 hard steps (no tweening)
			if motion.Stepped then
				p = math.floor(math.clamp(p, 0, 1) * 4 + 0.5) / 4
			end
			-- the family's entrance scale (toy pop from 0.6, comic slam from 1.3,
			-- minimal barely moves); exits shrink away without overshoot
			if kind == "Pop" or kind == "Zoom" then
				local from = motion.EnterScale or (if kind == "Zoom" then 1.3 else 0.72)
				if kind == "Zoom" and from < 1 then
					from = 1.3
				end
				if opening then
					return from + (1 - from) * p
				end
			end
		end
		if kind == "Zoom" then
			return 1.3 - 0.3 * p
		elseif kind == "Drop" and not opening then
			-- no fade layer: it sinks and shrinks away instead of blinking out
			return math.max(0, p)
		elseif kind == "Slide" or kind == "Drop" then
			return 0.97 + 0.03 * math.clamp(p, 0, 1)
		elseif kind == "Rise" then
			return if opening then 0.97 + 0.03 * p else 0.98 + 0.02 * p
		elseif kind == "Swap" then
			return 0.97 + 0.03 * p
		elseif kind == "Fade" then
			return 0.95 + 0.05 * p
		elseif kind == "Quick" then
			return 1
		end
		if not opening then
			return math.max(0, p)
		end
		return 0.72 + 0.28 * p
	end)

	-- Roblox's top bar is ~58 real px (Kit.topbarHeight); keep the window (and its pop-out title
	-- icon / ribbon) below it on small screens.
	local topInset = scope:Computed(function(use)
		return use(Kit.topbarHeight) / math.max(0.05, use(Viewport.scale)) + 24
	end)

	-- the holder's size (design px), clamped to the screen below the top bar
	local function holderSize(use: any): Vector2
		local screen = use(Viewport.designSize)
		local top = use(topInset)
		if dock == "Right" or dock == "Left" then
			return Vector2.new(math.min(wanted.X, screen.X - 48), math.min(wantedHeight(use), screen.Y - top - DOCK_MARGIN))
		elseif dock == "Bottom" then
			return Vector2.new(math.min(wanted.X, screen.X - DOCK_MARGIN * 2), math.min(wantedHeight(use), screen.Y - top))
		end
		return Vector2.new(math.min(wanted.X, screen.X - 48), math.min(wantedHeight(use), screen.Y - top - 36))
	end

	local holderPosition = scope:Computed(function(use)
		local p = use(progress)
		local kind = use(transition)
		local screen = use(Viewport.designSize)
		if dock ~= nil then
			-- docked: pinned to its edge; it slides in from that edge (a sheet
			-- from below), whatever the theme's transition
			local size = holderSize(use)
			local top = use(topInset)
			local slide = 1 - math.clamp(p, 0, 1.2)
			-- side panels sit in the middle of the band below the top bar
			local middle = top + (screen.Y - top - DOCK_MARGIN) / 2
			if dock == "Right" then
				return UDim2.fromOffset(screen.X - DOCK_MARGIN - size.X / 2 + slide * (size.X + 60), middle)
			elseif dock == "Left" then
				return UDim2.fromOffset(DOCK_MARGIN + size.X / 2 - slide * (size.X + 60), middle)
			end
			return UDim2.fromOffset(screen.X / 2, screen.Y - size.Y / 2 + slide * (size.Y + 40))
		end
		local height = math.min(wantedHeight(use), screen.Y - use(topInset) - 36)
		-- centre in the space below the top bar when the window fills the screen
		local free = screen.Y - height
		local y = props.OffsetY or 16
		if free < use(topInset) * 2 then
			y = use(topInset) - free / 2
		end
		local opening = use(open) == true
		if kind == "Slide" then
			y += (1 - p) * 180
		elseif kind == "Drop" then
			-- drops in from above; on close it sinks a little as it goes
			y += if opening then -(1 - p) * 820 else (1 - p) * 60
		elseif kind == "Rise" then
			y += (1 - p) * (if opening then 24 else 8)
		elseif kind == "Swap" then
			y += (1 - p) * 12
		end
		return UDim2.new(0.5, 0, 0.5, y)
	end)

	local holder = scope:New "Frame" {
		Name = "Holder",
		-- Active stops clicks on empty parts of the window from reaching
		-- the backdrop behind it (which may close the window).
		Active = true,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = holderPosition,
		Size = scope:Computed(function(use)
			local size = holderSize(use)
			return UDim2.fromOffset(size.X, size.Y)
		end),
		BackgroundTransparency = 1,
		ZIndex = 2,

		[Children] = {
			scope:New "UIScale" { Scale = scaleState },
			-- the drop shadow follows the body only (not the floating tab row above it)
			-- (style families: the body carries its own UIShadows)
			if usePanel and not familyChrome
				then scope:New "Frame" {
					Name = "BodyShadow",
					BackgroundTransparency = 1,
					Position = UDim2.fromOffset(0, topSpace),
					Size = UDim2.new(1, 0, 1, -topSpace),
					ZIndex = 1,
					[Children] = Kit.elevation(scope, { Radius = "L", Spread = 28, Offset = Vector2.new(0, 12), ZIndex = 1 }),
				}
				else nil,
			if nav == "Tabs" and not familyChrome
				then TabBar(scope, {
					Tabs = props.Tabs,
					Selected = selected,
					TabSize = tabSize,
					OnSelect = props.OnSelect,
					OnClose = close,
					ShoulderHints = true,
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, 12),
					ZIndex = 5,
				})
				else nil,
			body,
			titleIcon,
			ribbon,
			if nav ~= "Tabs" and not familyChrome then closeButton else nil,
		},
	}

	-- Fade needs a CanvasGroup to fade everything together; only built when
	-- the window starts with the Fade transition.
	local stage = holder
	if FADES[peek(transition)] then
		stage = scope:New "CanvasGroup" {
			Name = "Fader",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			GroupTransparency = scope:Computed(function(use)
				if not FADES[use(transition)] then
					return 0
				end
				-- fade faster than the motion so content is solid quickly
				return 1 - math.clamp(use(progress) * 1.6, 0, 1)
			end),
			ZIndex = 2,
			[Children] = holder,
		}
	end

	-- how dark the scrim gets when fully open (0..1 opacity)
	local scrim = scope:Computed(function(use)
		local given = use(props.Scrim)
		if given == false then
			return 0
		elseif typeof(given) == "number" then
			return math.clamp(given, 0, 1)
		end
		return use(Theme.current).Scrim or 0.45
	end)
	local shownScrim = scope:Computed(function(use)
		return use(scrim) * math.clamp(use(progress), 0, 1)
	end)

	local root = Kit.root(props, { Name = "Window" })
	root.Size = UDim2.fromScale(1, 1)
	root.BackgroundTransparency = 1
	root.Visible = visible
	root.ZIndex = props.ZIndex or 10
	root[Fusion.Attribute "BloxUIWindow"] = true
	root[Children] = {
		scope:New "TextButton" {
			Name = "Backdrop",
			Size = UDim2.fromScale(1, 1),
			Text = "",
			AutoButtonColor = false,
			Selectable = false,
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Scrim
			end),
			BackgroundTransparency = scope:Computed(function(use)
				return 1 - use(shownScrim)
			end),
			ZIndex = 1,
			[OnEvent "Activated"] = function()
				if props.CloseOnBackdrop then
					close()
				end
			end,
			-- darker edges pull the eye to the window in the middle
			[Children] = scope:New "ImageLabel" {
				Name = "ScrimEdges",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				Image = Assets.Vignette,
				ImageColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Scrim
				end),
				ImageTransparency = scope:Computed(function(use)
					return 1 - math.min(1, use(shownScrim) * 1.3)
				end),
				Visible = scope:Computed(function(use)
					return use(shownScrim) > 0.01
				end),
				ZIndex = 1,
			},
		},
		stage,
	}

	local frame = scope:New "Frame" (root) :: Frame

	-- As the window opens, the page's pieces step in one after another
	-- (absolutely placed children; list layouts keep their own order).
	if props.Stagger ~= false then
		local alive = true
		table.insert(scope, function()
			alive = false
		end)
		local function staggerIn()
			task.defer(function()
				if not alive or frame.Parent == nil then
					return
				end
				local content = body:FindFirstChild("Content")
				local container: Instance? = content
				local page = if content then content:FindFirstChild(`Page_{peek(selected)}`) else nil
				if page then
					container = page
				end
				if container == nil then
					return
				end
				local items = {}
				for _, child in container:GetChildren() do
					if child:IsA("GuiObject") and child.Visible and not string.find(child.Name, "^Page_") then
						table.insert(items, child)
					end
				end
				if familyChrome then
					-- the family's choreography: its step, rise and feel
					local m = Theme.get().Motion
					local personality = m.Personality
					local rise = m.Rise or 14
					Motion.stagger(items, {
						Delay = m.Stagger or 0.025,
						Distance = if math.abs(rise) > 0 then math.abs(rise) else 12,
						From = if rise < 0 then "Top" elseif personality == "Broadcast" then "Left" else "Bottom",
						Easing = if personality == "Bouncy" or personality == "Punchy" or personality == "Squishy" then Enum.EasingStyle.Back
							elseif personality == "Stepped" then Enum.EasingStyle.Linear
							elseif personality == "Uneasy" or personality == "Stately" then Enum.EasingStyle.Sine
							else Enum.EasingStyle.Quint,
						Duration = if personality == "Stately" or personality == "Uneasy" then 0.4 elseif personality == "Stepped" then 0.12 else 0.26,
					})
				else
					Motion.stagger(items, { Distance = 14 })
				end
			end)
		end
		if peek(open) == true then
			staggerIn()
		end
		Kit.watch(scope, open, function(now)
			if now == true then
				staggerIn()
			end
		end)
	end

	-- controller support: B closes, LB / RB switch tabs
	Gamepad.register(scope, frame, {
		Close = close,
		Open = open,
		Tabs = if hasTabs then selected else nil,
		TabIds = if hasTabs then props.Tabs else nil,
	})

	return frame
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="155">
            <Properties>
              <string name="Name">WorldCard</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	WorldCard: a big art card for world / teleport / map select screens.
	Rich tone gradient with a glow and a giant icon, a name plate, an
	optional requirement chip, and a locked state with a lock and the
	unlock cost.

	scope:WorldCard {
		Name = "Candy Land",
		Icon = "lollipop",                  -- any icon or image
		Tone = "Pink",
		Requirement = "Rebirth 5",          -- optional chip (text, or { icon, text })
		Locked = locked,                    -- bool or state
		Cost = 25000,                       -- optional: shown on the lock (number or text)
		CostIcon = "coin",
		Current = false,                    -- bool or state: "YOU ARE HERE" tag
		Subtitle = "12 players",            -- optional small line under the name
		OnClick = function(locked) end,     -- called with the current locked state
		Size = UDim2.fromOffset(300, 380),
		InstanceName = "World_Candy",       -- optional instance name (default "World_<Name>")
	}

	Clicking a locked card shakes it (and still calls OnClick(true), so you
	can offer the unlock).
]]

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Icon = require(script.Parent.Icon)
local Text = require(script.Parent.Text)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)
local BLACK = Color3.new(0, 0, 0)

local function sizeOf(value: any): Vector2
	if typeof(value) == "UDim2" then
		return Vector2.new(value.X.Offset, value.Y.Offset)
	elseif typeof(value) == "Vector2" then
		return value
	end
	return Vector2.new(300, 380)
end

local function chipParts(requirement: any): (any, any)
	if typeof(requirement) == "table" then
		return requirement.icon or requirement.Icon, requirement.text or requirement.Text
	end
	return nil, requirement
end

return function(scope: Scope, props: { [any]: any }): TextButton
	local size = sizeOf(peek(props.Size))
	local w, h = math.max(160, size.X), math.max(200, size.Y)
	local tone = Kit.tone(scope, props.Tone, "Blue")
	local locked = props.Locked or false
	local current = props.Current or false
	local depth = 6
	local plate = math.floor(h * 0.26)
	local iconSize = math.floor(math.min(w * 0.62, h * 0.46))

	local hovering = scope:Value(false)
	local pressing = scope:Value(false)

	local scale = Kit.spring(scope, scope:Computed(function(use)
		if use(pressing) then
			return 0.95
		elseif use(hovering) then
			return 1.04
		end
		return 1
	end), 1.1)
	local lift = scope:Spring(scope:Computed(function(use)
		return if use(hovering) and not use(locked) then 1 else 0
	end), 22, 1)
	local tiltGoal = scope:Value(0)
	local tilt = scope:Spring(tiltGoal, 24, 0.35)

	local isLocked = scope:Computed(function(use)
		return use(locked) == true
	end)

	local reqIcon, reqText = chipParts(props.Requirement)
	local costText = scope:Computed(function(use)
		local cost = use(props.Cost)
		if cost == nil then
			return ""
		elseif typeof(cost) == "number" then
			return Format.abbrev(cost)
		end
		return tostring(cost)
	end)

	local face = scope:New "Frame" {
		Name = "Face",
		Size = UDim2.new(1, 0, 1, -depth),
		BackgroundColor3 = WHITE,
		ZIndex = 2,
		[Children] = {
			Kit.corner(scope, "L"),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = scope:Computed(function(use)
					local t = use(tone)
					local l = use(lift)
					return ColorSequence.new({
						ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.12 + l * 0.1)),
						ColorSequenceKeypoint.new(0.55, Color.lighten(t.Bottom, l * 0.06)),
						ColorSequenceKeypoint.new(1, t.Deep:Lerp(t.Bottom, 0.35)),
					})
				end),
			}),
			-- big soft light behind the icon
			scope:New "ImageLabel" {
				Name = "Glow",
				BackgroundTransparency = 1,
				Image = Assets.SoftShadow,
				ImageColor3 = scope:Computed(function(use)
					return Color.lighten(use(tone).Top, 0.7)
				end),
				ImageTransparency = scope:Computed(function(use)
					return 0.45 - use(lift) * 0.2
				end),
				ScaleType = Enum.ScaleType.Slice,
				SliceCenter = Assets.SoftShadowSlice,
				SliceScale = math.max(0.05, (iconSize * 0.45) / Assets.SoftShadowFalloff),
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 0, 0, math.floor((h - plate) * 0.5)),
				Size = UDim2.fromOffset(math.floor(iconSize * 0.9), math.floor(iconSize * 0.9)),
				ZIndex = 1,
			},
			-- a few soft floating orbs for depth
			scope:New "ImageLabel" {
				Name = "OrbA",
				BackgroundTransparency = 1,
				Image = Assets.Circle,
				ImageColor3 = WHITE,
				ImageTransparency = 0.88,
				Position = UDim2.fromOffset(math.floor(w * 0.08), math.floor(h * 0.1)),
				Size = UDim2.fromOffset(math.floor(w * 0.22), math.floor(w * 0.22)),
				ZIndex = 1,
			},
			scope:New "ImageLabel" {
				Name = "OrbB",
				BackgroundTransparency = 1,
				Image = Assets.Circle,
				ImageColor3 = WHITE,
				ImageTransparency = 0.9,
				AnchorPoint = Vector2.new(1, 0),
				Position = UDim2.fromOffset(w - math.floor(w * 0.06), math.floor(h * 0.3)),
				Size = UDim2.fromOffset(math.floor(w * 0.12), math.floor(w * 0.12)),
				ZIndex = 1,
			},
			scope:New "Frame" {
				Name = "Gloss",
				BackgroundColor3 = WHITE,
				BackgroundTransparency = 0.78,
				Size = UDim2.new(1, 0, 0.4, 0),
				ZIndex = 2,
				[Children] = {
					Kit.corner(scope, "L"),
					Kit.gradient(scope, { Transparency = NumberSequence.new(0, 1) }),
				},
			},
			Icon(scope, {
				Name = "Art",
				Icon = props.Icon or "world",
				Size = iconSize,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 0, 0, math.floor((h - plate) * 0.5)),
				Rotation = tilt,
				Color = scope:Computed(function(use)
					local t = use(tone)
					if use(isLocked) then
						return t.Deep:Lerp(BLACK, 0.35)
					end
					return Color.lighten(t.Top, 0.5)
				end),
				StrokeColor = scope:Computed(function(use)
					local t = use(tone)
					if use(isLocked) then
						return t.Edge
					end
					return t.Stroke or t.Edge
				end),
				Stroke = math.max(3, math.floor(iconSize * 0.03)),
				Gradient = scope:Computed(function(use)
					return not use(isLocked)
				end),
				Shadow = true,
				ZIndex = 3,
			}),
			-- dark fade the name sits on
			scope:New "Frame" {
				Name = "Plate",
				AnchorPoint = Vector2.new(0, 1),
				Position = UDim2.fromScale(0, 1),
				Size = UDim2.new(1, 0, 0, plate),
				BackgroundColor3 = scope:Computed(function(use)
					return use(tone).Deep
				end),
				BackgroundTransparency = 0,
				ZIndex = 4,
				[Children] = {
					Kit.corner(scope, "L"),
					Kit.gradient(scope, {
						Rotation = 90,
						Transparency = NumberSequence.new({
							NumberSequenceKeypoint.new(0, 1),
							NumberSequenceKeypoint.new(0.45, 0.35),
							NumberSequenceKeypoint.new(1, 0.1),
						}),
					}),
				},
			},
		},
	}

	local lockOverlay = scope:New "Frame" {
		Name = "LockOverlay",
		Size = UDim2.new(1, 0, 1, -depth),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Backdrop
		end),
		BackgroundTransparency = 0.5,
		Visible = isLocked,
		ZIndex = 7,
		[Children] = {
			Kit.corner(scope, "L"),
			Kit.gradient(scope, {
				Rotation = 90,
				Transparency = NumberSequence.new(0.2, 0),
			}),
			scope:New "Frame" {
				Name = "LockDisc",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 0, 0, math.floor((h - plate) * 0.5)),
				Size = UDim2.fromOffset(math.floor(iconSize * 0.46), math.floor(iconSize * 0.46)),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Surface
				end),
				BackgroundTransparency = 0.05,
				ZIndex = 8,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.stroke(scope, {
						Thickness = 3,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Light
						end),
						Transparency = 0.2,
					}),
					Icon(scope, {
						Name = "Lock",
						Icon = "lock",
						Size = math.floor(iconSize * 0.25),
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Light
						end),
						Gradient = true,
						Shadow = true,
						ZIndex = 9,
					}),
				},
			},
			scope:New "Frame" {
				Name = "Cost",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 0, 0, math.floor((h - plate) * 0.5 + iconSize * 0.23 + 28)),
				Size = UDim2.fromOffset(0, 40),
				AutomaticSize = Enum.AutomaticSize.X,
				BackgroundColor3 = WHITE,
				Visible = scope:Computed(function(use)
					return use(costText) ~= ""
				end),
				ZIndex = 9,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = Kit.themed(scope, function(theme)
							local t = theme.Tone.Dark
							return ColorSequence.new(t.Top, t.Bottom)
						end),
					}),
					Kit.stroke(scope, {
						Thickness = 2,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Accent
						end),
					}),
					Kit.padding(scope, { 0, 14, 0, 8 }),
					Kit.list(scope, { Direction = "X", Padding = 6, VAlign = "Center" }),
					Icon(scope, {
						Icon = props.CostIcon or "coin",
						Size = 28,
						LayoutOrder = 1,
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Accent
						end),
						Gradient = true,
						ZIndex = 10,
					}),
					Text(scope, {
						Text = costText,
						TextSize = 24,
						Font = "Display",
						LayoutOrder = 2,
						ZIndex = 10,
					}),
				},
			},
		},
	}

	local chip = if reqText ~= nil
		then scope:New "Frame" {
			Name = "Requirement",
			Position = UDim2.fromOffset(12, 12),
			Size = UDim2.fromOffset(0, 34),
			AutomaticSize = Enum.AutomaticSize.X,
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = 0.25,
			ZIndex = 10,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.stroke(scope, {
					Thickness = 1.5,
					Transparency = 0.5,
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Light
					end),
				}),
				Kit.padding(scope, { 0, 12, 0, 8 }),
				Kit.list(scope, { Direction = "X", Padding = 6, VAlign = "Center" }),
				Icon(scope, {
					Icon = scope:Computed(function(use)
						return reqIcon or (if use(isLocked) then "lock" else "check")
					end),
					Size = 20,
					LayoutOrder = 1,
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						return if use(isLocked) then theme.Color.Warn else theme.Color.Good
					end),
					Stroke = false,
					ZIndex = 11,
				}),
				Text(scope, {
					Text = reqText,
					TextSize = 18,
					Font = "Heavy",
					Stroke = false,
					LayoutOrder = 2,
					ZIndex = 11,
				}),
			},
		}
		else nil

	local here = scope:New "Frame" {
		Name = "Current",
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.new(1, -12, 0, 12),
		Size = UDim2.fromOffset(0, 34),
		AutomaticSize = Enum.AutomaticSize.X,
		BackgroundColor3 = WHITE,
		Visible = scope:Computed(function(use)
			return use(current) == true and not use(isLocked)
		end),
		ZIndex = 10,
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.gradient(scope, {
				Rotation = 90,
				Color = Kit.themed(scope, function(theme)
					local t = theme.Tone.Green
					return ColorSequence.new(t.Top, t.Bottom)
				end),
			}),
			Kit.stroke(scope, {
				Thickness = 2,
				Color = Kit.themed(scope, function(theme)
					return theme.Tone.Green.Edge
				end),
			}),
			Kit.padding(scope, { 0, 12 }),
			Kit.list(scope, { Direction = "X", Padding = 4, VAlign = "Center" }),
			Text(scope, {
				Text = "YOU ARE HERE",
				TextSize = 16,
				Font = "Heavy",
				Color = Kit.themed(scope, function(theme)
					return theme.Tone.Green.Text
				end),
				StrokeColor = Kit.themed(scope, function(theme)
					local t = theme.Tone.Green
					return t.Stroke or t.Edge
				end),
				Stroke = 1.5,
				ZIndex = 11,
			}),
		},
	}

	local body = scope:New "Frame" {
		Name = "Body",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		ZIndex = 2,
		[Children] = {
			scope:New "Frame" {
				Name = "Lip",
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = scope:Computed(function(use)
					return use(tone).Edge
				end),
				ZIndex = 1,
				[Children] = {
					Kit.corner(scope, "L"),
					Kit.stroke(scope, {
						Thickness = 3,
						Color = scope:Computed(function(use)
							local t = use(tone)
							if use(current) == true and not use(isLocked) then
								return use(Theme.current).Tone.Green.Top
							end
							return t.Stroke or t.Deep
						end),
					}),
				},
			},
			face,
			lockOverlay,
			Text(scope, {
				Name = "WorldName",
				Text = props.Name or "World",
				TextSize = math.clamp(math.floor(w * 0.13), 22, 42),
				Font = "Title",
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(0.5, 0, 1, -depth + (if props.Subtitle then -math.floor(plate * 0.36) else -math.floor(plate * 0.2))),
				Size = UDim2.new(1, -24, 0, math.clamp(math.floor(w * 0.14), 24, 46)),
				Scaled = true,
				Wrapped = false, -- single line: shrink, never break a word
				MaxTextSize = math.clamp(math.floor(w * 0.13), 22, 42),
				StrokeColor = scope:Computed(function(use)
					return use(tone).Stroke or use(tone).Deep
				end),
				Stroke = 3,
				ZIndex = 9,
			}),
			if props.Subtitle
				then Text(scope, {
					Name = "Subtitle",
					Text = props.Subtitle,
					TextSize = 18,
					Font = "Body",
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -depth - math.floor(plate * 0.14)),
					Stroke = false,
					Color = scope:Computed(function(use)
						return Color.lighten(use(tone).Top, 0.55)
					end),
					ZIndex = 9,
				})
				else nil,
			chip,
			here,
		},
	}

	local function click()
		local isLockedNow = peek(isLocked)
		if isLockedNow then
			Sound.play("Error")
			Motion.shake(body, 7, 0.35)
		else
			Sound.play("Click")
			tilt:addVelocity(if math.random() < 0.5 then 90 else -90)
		end
		if props.OnClick then
			props.OnClick(isLockedNow)
		end
	end

	-- `Name` is the world's display name, so the instance gets its own
	local root = Kit.root(props)
	root.Name = props.InstanceName or `World_{peek(props.Name) or "World"}`
	root.Size = UDim2.fromOffset(w, h)
	root.BackgroundTransparency = 1
	root.Text = ""
	root.AutoButtonColor = false
	root.Selectable = true
	root.Active = true
	root[OnEvent "MouseEnter"] = function()
		hovering:set(true)
		if not peek(isLocked) then
			tilt:addVelocity(-60)
		end
		Sound.play("Hover", { Volume = 0.4 })
	end
	root[OnEvent "MouseLeave"] = function()
		hovering:set(false)
		pressing:set(false)
	end
	root[OnEvent "SelectionGained"] = function()
		hovering:set(true)
	end
	root[OnEvent "SelectionLost"] = function()
		hovering:set(false)
	end
	root[OnEvent "MouseButton1Down"] = function()
		pressing:set(true)
	end
	root[OnEvent "MouseButton1Up"] = function()
		pressing:set(false)
	end
	root[OnEvent "Activated"] = function(input: InputObject?)
		if input and input.UserInputType == Enum.UserInputType.Touch then
			hovering:set(false)
		end
		pressing:set(false)
		click()
	end
	root[Children] = {
		scope:New "UIScale" { Scale = scale },
		Kit.shadow(scope, { Spread = 20, Offset = Vector2.new(0, 10), ZIndex = 0, Color = BLACK }),
		body,
		props[Children],
	}
	return scope:New "TextButton" (root) :: TextButton
end
]]></string>
            </Properties>
          </Item>
        </Item>
        <Item class="ModuleScript" referent="156">
          <Properties>
            <string name="Name">FX</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	FX: fire-and-forget effects.

	Element effects (any GuiObject):
		FX.pop(gui)                  FX.shake(gui)            FX.bob(gui) -> stop
		FX.spin(gui) -> stop         FX.pulse(gui) -> stop    FX.wiggle(gui) -> stop
		FX.shine(gui) -> stop        FX.typewrite(label, text) -> skip
		FX.countTo(label, 0, 500, { Tick = true, Punch = true }) -> stop
		FX.tween(instance, goals, time)
		FX.rays(gui) -> stop         FX.stagger(children)
	Endless loops (bob, spin, pulse, wiggle, shine, rays, sparkles) pause while
	their GuiObject can't be seen (closed window, hidden tab, disabled
	ScreenGui) and resume when it shows. Your own looping tweens can too:
		FX.track(tween, gui) -> untrack   FX.shown(gui) -> boolean

	Particles (positions: GuiObject, screen Vector2, UDim2, or world Vector3/Part):
		FX.burst(at, { Shape = "mix" })         -- confetti | ribbon | sparkle | circle | square | triangle | mix | icon
		FX.confetti({ Style = "Cannons" })      -- Rain | Cannons | Burst
		FX.poof(button)                         -- puffs burst off a GuiObject's edges
		FX.ring(at) / FX.ring(button, { Fit = true })
		FX.popText(at, "+10")
		FX.damageNumber(at, 42, { Crit = true })  -- hits on the same target merge
		FX.flyTo(from, to, { Icon = "coin", Count = 8, OnArrive = fn })
		FX.sparkles(gui) -> stop
	Particles are capped (FX.Particles.MAX_ACTIVE live, SPAWN_BUDGET per frame;
	lower on touch devices). Effects fired in the same frame share the budget
	fairly, and spawn just before the frame renders.

	Screen:
		FX.flash(color)              FX.damage()              FX.cameraShake("Heavy" | 0.35)
		FX.fadeOut() / FX.fadeIn()   FX.transition(fn)        FX.flicker(1)
		FX.announce("LEVEL UP!", { Tone = "Yellow", Burst = true })
		FX.jumpscare({ Image = "rbxassetid://..." })

	Buttons:
		FX.click("Ripple", { Target = face, Root = button, Color = tone.Top })
		(kinds: Squash, Punch, Ripple, Sparkle, Glow, Poof, Shake, Glitch, None;
		Squash / Punch / Poof are played by Button itself)

	Accessibility:
		UI.Motion.reduced = true     -- reduced motion: no shake / flash / idle loops, fewer particles
		FX.Particles.damageNumbers = false   -- FX.damageNumber does nothing

	Everything is a safe no-op without a LocalPlayer (edit mode, plugin preview).
]]

local Motion = require(script.Motion)
local Particles = require(script.Particles)
local Screen = require(script.Screen)
local Layer = require(script.Layer)
local Click = require(script.Click)

local FX = {
	Layer = Layer,
	Particles = Particles, -- caps and counters: MAX_ACTIVE, SPAWN_BUDGET, activeCount(); damageNumbers
	Motion = Motion, -- Motion.reduced: the reduced-motion switch (see FX/Motion)
	SHAKES = Screen.SHAKES, -- named cameraShake intensities
}

for _, group in { Motion, Particles, Screen, Click } do
	for name, fn in group do
		if typeof(fn) == "function" then
			FX[name] = fn
		end
	end
end

return FX
]]></string>
          </Properties>
          <Item class="ModuleScript" referent="157">
            <Properties>
              <string name="Name">Click</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Click: the one-shot effects buttons play when they're clicked. Chosen by
	the theme's Style.ClickFx (or a Button's ClickFx prop).

		FX.click("Ripple", {
			Target = buttonFace,      -- GuiObject the effect lives in (should clip)
			Root = button,            -- the whole button (for Sparkle / Shake / Glitch)
			Color = Color3.new(1, 1, 1),
			Input = inputObject,      -- optional, ripples start where you clicked
		})

	Kinds played here:
		Ripple   a soft light circle spreads from the click point
		Sparkle  twinkles burst off the button's edges
		Glow     a halo flares behind the button and a light ring leaves its edge
		Shake    a quick "boing" wobble with a little jitter
		Glitch   RGB-split copies of the label jitter with scanline slices
	"Squash", "Punch" and "Poof" live in the Button itself (press springs, the
	white flash and FX.poof on release); on any other GuiObject FX.click plays
	a small pop / white flash / poof instead. "None" does nothing.
]]

local TweenService = game:GetService("TweenService")

local Assets = require(script.Parent.Parent.Assets)
local Particles = require(script.Parent.Particles)
local Motion = require(script.Parent.Motion)
local Layer = require(script.Parent.Layer)

local Click = {}

local WHITE = Color3.new(1, 1, 1)
local GOLD = Color3.fromHex("FFE66B")

export type ClickOptions = {
	Target: GuiObject?, -- clipping layer on the button face
	Root: GuiObject?, -- the whole button
	Glow: ImageLabel?, -- a halo image behind the button (Glow kind)
	Color: Color3?, -- main effect colour (usually the tone's Top)
	Input: InputObject?,
	Wobble: ((velocity: number) -> ())?, -- rotation kick (Shake kind)
}

local function relativePoint(target: GuiObject, input: InputObject?): Vector2
	if input == nil then
		return Vector2.new(0.5, 0.5)
	end
	local ok, position = pcall(function()
		return input.Position
	end)
	if not ok or typeof(position) ~= "Vector3" then
		return Vector2.new(0.5, 0.5)
	end
	local size = target.AbsoluteSize
	if size.X < 1 or size.Y < 1 then
		return Vector2.new(0.5, 0.5)
	end
	local rel = (Vector2.new(position.X, position.Y) - target.AbsolutePosition) / size
	if rel.X < 0 or rel.X > 1 or rel.Y < 0 or rel.Y > 1 then
		return Vector2.new(0.5, 0.5)
	end
	return rel
end

local function ripple(options: ClickOptions)
	local target = options.Target
	if target == nil or target.Parent == nil then
		return
	end
	local at = relativePoint(target, options.Input)
	local tint = (options.Color or WHITE):Lerp(WHITE, 0.6)
	-- a fast bright core and a slower soft wave behind it
	for index, spec in { { size = 2.8, time = 0.55, alpha = 0.6 }, { size = 1.4, time = 0.3, alpha = 0.4 } } do
		local circle = Instance.new("ImageLabel")
		circle.Name = "Ripple"
		circle.BackgroundTransparency = 1
		circle.Image = Assets.Circle
		circle.ImageColor3 = tint
		circle.ImageTransparency = spec.alpha
		circle.AnchorPoint = Vector2.new(0.5, 0.5)
		circle.Position = UDim2.fromScale(at.X, at.Y)
		circle.Size = UDim2.fromScale(0.08, 0.08)
		circle.ZIndex = 20 + index
		local aspect = Instance.new("UIAspectRatioConstraint")
		aspect.AspectRatio = 1
		aspect.DominantAxis = Enum.DominantAxis.Width
		aspect.Parent = circle
		circle.Parent = target
		local info = TweenInfo.new(spec.time, Enum.EasingStyle.Quart, Enum.EasingDirection.Out)
		local grow = TweenService:Create(circle, info, { Size = UDim2.fromScale(spec.size, spec.size), ImageTransparency = 1 })
		grow.Completed:Once(function()
			circle:Destroy()
		end)
		grow:Play()
	end
end

-- Points on a GuiObject's edge (layer px) with the outward direction.
local function edgePoints(root: GuiObject, count: number): { { at: Vector2, angle: number } }
	local centre = Layer.point(root)
	local half = root.AbsoluteSize / 2
	local out = {}
	for i = 1, count do
		local angle = (i / count + (math.random() - 0.5) * 0.08) * math.pi * 2 - math.pi / 2
		local dir = Vector2.new(math.cos(angle), math.sin(angle))
		local k = math.min(
			if math.abs(dir.X) > 1e-3 then half.X / math.abs(dir.X) else math.huge,
			if math.abs(dir.Y) > 1e-3 then half.Y / math.abs(dir.Y) else math.huge
		)
		table.insert(out, { at = centre + dir * k, angle = math.deg(angle) })
	end
	return out
end

local function sparkle(options: ClickOptions)
	local root = options.Root
	if root == nil or root.Parent == nil then
		return
	end
	local color = options.Color or WHITE
	local colors = { WHITE, GOLD, color:Lerp(WHITE, 0.35) }
	for index, point in edgePoints(root, 10) do
		local big = index % 2 == 1
		Particles.burst(point.at, {
			Shape = if big then "sparkle" else "circle",
			Count = 1,
			Colors = { colors[(index - 1) % #colors + 1] },
			Direction = point.angle,
			Spread = 24,
			Speed = if big then 320 + (index % 3) * 60 else 420,
			Size = if big then 24 + (index % 3) * 7 else 9,
			Gravity = if big then 0 else 250,
			Drag = 3.6,
			Lifetime = if big then 0.5 else 0.4,
			EndScale = 0.15,
			PopIn = 0.08,
		})
	end
end

local function glow(options: ClickOptions)
	local halo = options.Glow
	if halo and halo.Parent then
		halo.ImageTransparency = 0
		halo.Size = UDim2.fromScale(1.25, 1.7)
		local info = TweenInfo.new(0.6, Enum.EasingStyle.Quart, Enum.EasingDirection.Out)
		TweenService:Create(halo, info, { ImageTransparency = 1, Size = UDim2.fromScale(1.95, 2.7) }):Play()
	end
	local root = options.Root
	if root and root.Parent then
		Particles.ring(root, {
			Fit = true,
			Color = (options.Color or WHITE):Lerp(WHITE, 0.45),
			Thickness = 4,
			Grow = 22,
			Time = 0.4,
		})
	end
	-- the face lights up for a moment
	local target = options.Target
	if target and target.Parent then
		local light = Instance.new("Frame")
		light.Name = "GlowFlash"
		light.BackgroundColor3 = (options.Color or WHITE):Lerp(WHITE, 0.7)
		light.BackgroundTransparency = 0.55
		light.BorderSizePixel = 0
		light.Size = UDim2.fromScale(1, 1)
		light.ZIndex = 19
		light.Parent = target
		local fade = TweenService:Create(light, TweenInfo.new(0.35, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), { BackgroundTransparency = 1 })
		fade.Completed:Once(function()
			light:Destroy()
		end)
		fade:Play()
	end
end

local function shake(options: ClickOptions)
	if options.Wobble then
		-- a boing: a firm kick one way; the spring swings it back
		options.Wobble(if math.random() < 0.5 then 130 else -130)
		if options.Root then
			Motion.shake(options.Root, 5, 0.22)
		end
	elseif options.Root then
		Motion.shake(options.Root, 5, 0.25)
	end
end

local RED = Color3.fromHex("FF2E63")
local CYAN = Color3.fromHex("2EF2FF")

-- Tinted, see-through copy of the button's label for the RGB split.
local function ghost(content: GuiObject, tint: Color3): GuiObject?
	local ok, copy = pcall(function()
		return content:Clone()
	end)
	if not ok or copy == nil then
		return nil
	end
	copy.Name = "GlitchGhost"
	for _, item in copy:GetDescendants() do
		if item:IsA("TextLabel") or item:IsA("TextButton") then
			item.TextColor3 = tint
			item.TextTransparency = 0.25
		elseif item:IsA("ImageLabel") or item:IsA("ImageButton") then
			item.ImageColor3 = tint
			item.ImageTransparency = 0.25
		elseif item:IsA("UIGradient") or item:IsA("UIStroke") then
			item.Enabled = false
		elseif item:IsA("GuiObject") and item.BackgroundTransparency < 1 then
			item.BackgroundTransparency = 1
		end
	end
	return copy
end

local function glitch(options: ClickOptions)
	local target = options.Target
	if target == nil or target.Parent == nil then
		return
	end
	-- scanline slices on the face
	local slices = {}
	for index, tint in { RED, CYAN, WHITE } do
		local slice = Instance.new("Frame")
		slice.Name = "Glitch"
		slice.BackgroundColor3 = tint
		slice.BackgroundTransparency = if tint == WHITE then 0.7 else 0.45
		slice.BorderSizePixel = 0
		slice.Size = UDim2.fromScale(1.1, 0.12)
		slice.ZIndex = 21
		slice.Position = UDim2.fromScale(-0.05, 0.08 + index * 0.2)
		slice.Parent = target
		table.insert(slices, slice)
	end
	-- the label splits into red and cyan copies that jitter sideways
	local ghosts = {}
	local root = options.Root
	local content = if root then root:FindFirstChild("Content") else nil
	if content and content:IsA("GuiObject") then
		for index, tint in { RED, CYAN } do
			local copy = ghost(content, tint)
			if copy then
				copy.ZIndex = math.max(1, content.ZIndex - 1)
				copy.Parent = root
				ghosts[index] = { gui = copy, base = content.Position }
			end
		end
	end
	task.spawn(function()
		for step = 1, 7 do
			local strength = 1 - (step - 1) / 7
			for index, slice in slices do
				if slice.Parent == nil then
					break
				end
				local dx = (math.random() - 0.5) * 0.16 * strength
				-- stay clear of the rounded corners (the face clips as a rectangle)
				local h = 0.05 + math.random() * 0.14
				slice.Position = UDim2.fromScale(-0.05 + dx, 0.16 + math.random() * (0.68 - h))
				slice.Size = UDim2.fromScale(1.1, h)
				slice.BackgroundTransparency = math.min(1, (if index == 3 then 0.7 else 0.4) + step * 0.07)
			end
			for index, entry in ghosts do
				if entry.gui.Parent then
					local side = if index == 1 then -1 else 1
					local dx = side * (3 + math.random() * 6) * strength
					local dy = (math.random() - 0.5) * 3 * strength
					entry.gui.Position = entry.base + UDim2.fromOffset(dx, dy)
				end
			end
			task.wait(0.035)
		end
		for _, slice in slices do
			slice:Destroy()
		end
		for _, entry in ghosts do
			entry.gui:Destroy()
		end
	end)
end

-- Punch outside a Button: a quick white flash over the target.
local function punch(options: ClickOptions)
	local target = options.Target or options.Root
	if target == nil or target.Parent == nil then
		return
	end
	local flash = Instance.new("Frame")
	flash.Name = "PunchFlash"
	flash.BackgroundColor3 = WHITE
	flash.BackgroundTransparency = 0.85
	flash.BorderSizePixel = 0
	flash.Size = UDim2.fromScale(1, 1)
	flash.ZIndex = 20
	flash.Parent = target
	local fade = TweenService:Create(flash, TweenInfo.new(0.12, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), { BackgroundTransparency = 1 })
	fade.Completed:Once(function()
		flash:Destroy()
	end)
	fade:Play()
	if options.Root then
		Motion.pop(options.Root, 0.06)
	end
end

local KINDS = {
	Ripple = ripple,
	Sparkle = sparkle,
	Glow = glow,
	Shake = shake,
	Glitch = glitch,
}

-- Kinds a Button plays itself (press springs, flash, poof). Called directly
-- on any other GuiObject they still do something sensible.
local STANDALONE = {
	Punch = punch,
	Squash = function(options: ClickOptions)
		if options.Root then
			Motion.pop(options.Root, 0.1)
		end
	end,
	Poof = function(options: ClickOptions)
		punch(options)
		if options.Root then
			Particles.poof(options.Root, { Color = (options.Color or WHITE):Lerp(WHITE, 0.5), Size = 30 })
		end
	end,
}

-- Plays click effect `kind` (see top of file). Unknown kinds do nothing.
-- (A Button passes its `Glow` halo; without one, Squash / Punch / Poof play
-- their standalone versions.) Nothing plays with reduced motion on.
function Click.click(kind: string?, options: ClickOptions)
	if Motion.reduced then
		return
	end
	local play = KINDS[kind or ""]
	if play then
		play(options)
	elseif options.Glow == nil and STANDALONE[kind or ""] then
		STANDALONE[kind :: string](options)
	end
end

return Click
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="158">
            <Properties>
              <string name="Name">Layer</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Layer: a full-screen ScreenGui that effects draw into (particles, flashes,
	announcements). Created the first time an effect runs.

	Positions in this layer are raw screen pixels with (0, 0) at the very top
	left of the screen, the same space as UserInputService:GetMouseLocation().
]]

local GuiService = game:GetService("GuiService")
local Players = game:GetService("Players")
local Workspace = game:GetService("Workspace")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Viewport = require(script.Parent.Parent.Util.Viewport)

local Layer = {}

local gui: ScreenGui? = nil
local root: Frame? = nil
local sink: Frame? = nil

-- Set to false to make every effect draw into a hidden, detached frame
-- (e.g. a Studio plugin preview). Effects also go there automatically when
-- there is no LocalPlayer, so calling FX from edit mode never errors.
Layer.enabled = true

local function getSink(): Frame
	if sink == nil then
		local frame = Instance.new("Frame")
		frame.Name = "BloxUI_FXSink"
		frame.BackgroundTransparency = 1
		frame.Size = UDim2.fromOffset(1920, 1080)
		frame.Visible = false
		sink = frame
	end
	return sink :: Frame
end

local function build(): Frame
	local player = Players.LocalPlayer
	if player == nil then
		return getSink()
	end

	local newGui = Instance.new("ScreenGui")
	newGui.Name = "BloxUI_FX"
	newGui.ResetOnSpawn = false
	newGui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
	newGui.IgnoreGuiInset = true
	newGui.ScreenInsets = Enum.ScreenInsets.None
	newGui.DisplayOrder = 1000

	local newRoot = Instance.new("Frame")
	newRoot.Name = "Root"
	newRoot.BackgroundTransparency = 1
	newRoot.Size = UDim2.fromScale(1, 1)
	newRoot.Parent = newGui

	newGui.Parent = player:WaitForChild("PlayerGui")
	gui, root = newGui, newRoot
	return newRoot
end

-- The frame effects are parented to.
function Layer.get(): Frame
	if not Layer.enabled or Players.LocalPlayer == nil then
		return getSink()
	end
	if root and gui and root.Parent == gui and gui.Parent then
		return root
	end
	return build()
end

-- Turns effects on / off (off = they draw into a hidden frame).
function Layer.setEnabled(on: boolean)
	Layer.enabled = on
end

-- Current design-pixel scale; multiply effect sizes by this.
function Layer.scale(): number
	return Fusion.peek(Viewport.scale)
end

local function worldToLayer(position: Vector3): Vector2
	local camera = Workspace.CurrentCamera
	if camera == nil then
		return Layer.get().AbsoluteSize / 2
	end
	local point = camera:WorldToViewportPoint(position)
	return Vector2.new(point.X, point.Y)
end

-- Converts a target into a point in the layer:
--   Vector2      screen position (e.g. GetMouseLocation())
--   UDim2        position relative to the screen
--   GuiObject    its centre
--   Vector3 / BasePart / Model / Attachment    world position
function Layer.point(target: any): Vector2
	local layerRoot = Layer.get()
	local kind = typeof(target)
	if kind == "Vector2" then
		return target
	elseif kind == "UDim2" then
		local size = layerRoot.AbsoluteSize
		return Vector2.new(
			size.X * target.X.Scale + target.X.Offset,
			size.Y * target.Y.Scale + target.Y.Offset
		)
	elseif kind == "Vector3" then
		return worldToLayer(target)
	elseif kind == "Instance" then
		if target:IsA("GuiObject") then
			local centre = target.AbsolutePosition + target.AbsoluteSize / 2 - layerRoot.AbsolutePosition
			-- GUIs that respect the top bar measure from below it; the FX
			-- layer measures from the very top of the screen
			local screenGui = target:FindFirstAncestorWhichIsA("ScreenGui")
			if screenGui and not screenGui.IgnoreGuiInset then
				local ok, inset = pcall(function()
					return (GuiService:GetGuiInset())
				end)
				if ok and typeof(inset) == "Vector2" then
					centre += inset
				end
			end
			return centre
		elseif target:IsA("BasePart") then
			return worldToLayer(target.Position)
		elseif target:IsA("Model") then
			return worldToLayer(target:GetPivot().Position)
		elseif target:IsA("Attachment") then
			return worldToLayer(target.WorldPosition)
		end
	end
	return layerRoot.AbsoluteSize / 2
end

return Layer
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="159">
            <Properties>
              <string name="Name">Motion</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Motion effects for any GuiObject: pop, shake, bob, spin, pulse, shine,
	typewriter text and counting numbers.

	Looping effects return a `stop()` function. Put it in a scope to stop it
	when the UI is cleaned up:  table.insert(scope, UI.FX.bob(arrow))

	Reduced motion (an accessibility switch; the Blueprint "reducedMotion"
	setting):

		UI.Motion.reduced = true        -- or UI.Motion.setReduced(true)
		UI.Motion.reducedState          -- the same flag as a Fusion Value

	While it's on: idle loops (bob, spin, pulse, wiggle, shine, rays, and any
	loop handed to Motion.track) stop and rest; shake and stagger do nothing;
	FX.flash / cameraShake / flicker are skipped and FX.announce doesn't slam;
	particles are thinned out; button click effects are skipped; the
	Heartbeat vignette pulses gently; windows open with a quick fade. It
	applies at once to everything on screen.
]]

local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Sound = require(script.Parent.Parent.Util.Sound)
local Format = require(script.Parent.Parent.Util.Format)

local moduleScope = Fusion.scoped(Fusion)
local reducedState = moduleScope:Value(false)

-- `Motion.reduced` reads and writes reducedState (see the top of the file).
local Motion = setmetatable({}, {
	__index = function(_, key)
		if key == "reduced" then
			return Fusion.peek(reducedState)
		end
		return nil
	end,
	__newindex = function(self, key, value)
		if key == "reduced" then
			(self :: any).setReduced(value == true)
		else
			rawset(self, key, value)
		end
	end,
}) :: any

Motion.reducedState = reducedState

--------------------------------------------------------------------------
-- Endless loops pause while nobody can see them
--------------------------------------------------------------------------

-- The instance that stops `inst` from being seen (a Visible = false frame, a
-- disabled ScreenGui, or the top of a tree that isn't parented anywhere), or
-- nil when it can be seen.
local function hiddenBy(inst: Instance): Instance?
	local node: Instance = inst
	while true do
		if node:IsA("GuiObject") then
			if not (node :: GuiObject).Visible then
				return node
			end
		elseif node:IsA("LayerCollector") then
			if (node :: LayerCollector).Enabled and node.Parent ~= nil then
				return nil
			end
			return node
		end
		local parent = node.Parent
		if parent == nil then
			return node
		end
		node = parent
	end
end

-- True when `inst` could be on screen: it and every GUI ancestor are
-- Visible, and its ScreenGui (or other LayerCollector) is enabled and parented.
function Motion.shown(inst: Instance): boolean
	return hiddenBy(inst) == nil
end

local POLL_SECONDS = 0.25
local playing: { [Tween]: Instance } = {} -- tracked loops that are running -> target
local waitingOn: { [Tween]: Instance } = {} -- tracked loops paused by us -> the hiding instance
local gates: { [Instance]: { connection: RBXScriptConnection, tweens: { [Tween]: Instance } } } = {}
local calmed: { [Tween]: Instance } = {} -- tracked loops stopped by reduced motion -> target
local resets: { [Tween]: () -> () } = {} -- how to put a loop's target back at rest
local polling = false

local place: (Tween, Instance) -> ()

-- Reduced motion: stops a loop and puts its target back at rest.
local function calm(tween: Tween, target: Instance)
	playing[tween] = nil
	tween:Pause()
	calmed[tween] = target
	local reset = resets[tween]
	if reset then
		pcall(reset)
	end
end

-- Pauses a loop until `gate` changes (becomes Visible / Enabled / parented).
-- One connection per hiding instance, shared by every loop inside it.
local function park(tween: Tween, target: Instance, gate: Instance)
	playing[tween] = nil
	tween:Pause()
	waitingOn[tween] = gate
	local entry = gates[gate]
	if entry == nil then
		local signal
		if gate:IsA("GuiObject") and not (gate :: GuiObject).Visible then
			signal = gate:GetPropertyChangedSignal("Visible")
		elseif gate:IsA("LayerCollector") and not (gate :: LayerCollector).Enabled then
			signal = gate:GetPropertyChangedSignal("Enabled")
		else
			signal = gate.AncestryChanged
		end
		local tweens = {}
		entry = {
			tweens = tweens,
			connection = signal:Connect(function()
				local current = gates[gate]
				if current == nil then
					return
				end
				current.connection:Disconnect()
				gates[gate] = nil
				for waiting, waitingTarget in current.tweens do
					waitingOn[waiting] = nil
					place(waiting, waitingTarget)
				end
			end),
		}
		gates[gate] = entry
	end
	entry.tweens[tween] = target
end

-- Checks the running loops a few times a second and parks the ones that
-- became hidden. Only runs while some tracked loop is playing.
local function startPolling()
	if polling then
		return
	end
	polling = true
	task.spawn(function()
		while next(playing) ~= nil do
			task.wait(POLL_SECONDS)
			for tween, target in playing do
				local gate = hiddenBy(target)
				if gate then
					park(tween, target, gate)
				end
			end
		end
		polling = false
	end)
end

function place(tween: Tween, target: Instance)
	if Fusion.peek(reducedState) then
		calm(tween, target)
		return
	end
	local gate = hiddenBy(target)
	if gate then
		park(tween, target, gate)
	else
		playing[tween] = target
		tween:Play()
		startPolling()
	end
end

local function untrack(tween: Tween)
	playing[tween] = nil
	calmed[tween] = nil
	resets[tween] = nil
	local gate = waitingOn[tween]
	if gate then
		waitingOn[tween] = nil
		local entry = gates[gate]
		if entry then
			entry.tweens[tween] = nil
			if next(entry.tweens) == nil then
				entry.connection:Disconnect()
				gates[gate] = nil
			end
		end
	end
end

--[[
	Registers a looping tween (already playing) so it pauses whenever `target`
	can't be seen (closed window, hidden tab page, disabled ScreenGui) and
	resumes as soon as it shows again. Returns untrack(); call it before
	cancelling the tween. Tracking also ends when `target` is destroyed.
	While reduced motion is on the loop stays stopped; `reset` (optional)
	puts its target back at rest when that happens.

		local tween = TweenService:Create(icon, info, goals)
		tween:Play()
		local untrack = UI.FX.track(tween, icon)
]]
function Motion.track(tween: Tween, target: Instance, reset: (() -> ())?): () -> ()
	resets[tween] = reset
	local destroying = target.Destroying:Connect(function()
		untrack(tween)
	end)
	if Fusion.peek(reducedState) then
		calm(tween, target)
		return function()
			destroying:Disconnect()
			untrack(tween)
		end
	end
	playing[tween] = target
	startPolling()
	if hiddenBy(target) ~= nil then
		-- Freshly built UI is usually parented a moment after its loops
		-- start; decide once the build has finished.
		task.defer(function()
			if playing[tween] == target then
				local gate = hiddenBy(target)
				if gate then
					park(tween, target, gate)
				end
			end
		end)
	end
	return function()
		destroying:Disconnect()
		untrack(tween)
	end
end

-- Turns reduced motion on or off (see the top of the file). Loops running
-- now stop and rest at once; stopped ones start again when it turns off.
function Motion.setReduced(on: boolean)
	on = on == true
	if Fusion.peek(reducedState) == on then
		return
	end
	reducedState:set(on)
	if on then
		for tween, target in table.clone(playing) do
			calm(tween, target)
		end
	else
		for tween, target in table.clone(calmed) do
			calmed[tween] = nil
			place(tween, target)
		end
	end
end

-- TweenService shortcut. Plays the tween and returns it.
function Motion.tween(
	instance: Instance,
	goals: { [string]: any },
	time: number?,
	style: Enum.EasingStyle?,
	direction: Enum.EasingDirection?
): Tween
	local tween = TweenService:Create(
		instance,
		TweenInfo.new(time or 0.25, style or Enum.EasingStyle.Quad, direction or Enum.EasingDirection.Out),
		goals
	)
	tween:Play()
	return tween
end

local function getScale(gui: GuiObject): UIScale
	local scale = gui:FindFirstChildOfClass("UIScale")
	if scale == nil then
		local newScale = Instance.new("UIScale")
		newScale.Name = "BloxUIScale"
		newScale.Parent = gui
		scale = newScale
	end
	return scale :: UIScale
end

-- Quick scale punch, e.g. when a number goes up.
function Motion.pop(gui: GuiObject, strength: number?)
	local scale = getScale(gui)
	local base = scale:GetAttribute("BloxUIBase")
	if typeof(base) ~= "number" then
		base = scale.Scale
		scale:SetAttribute("BloxUIBase", base)
	end
	scale.Scale = base * (1 + (strength or 0.18))
	local tween = TweenService:Create(
		scale,
		TweenInfo.new(0.4, Enum.EasingStyle.Back, Enum.EasingDirection.Out),
		{ Scale = base }
	)
	tween.Completed:Once(function(state)
		if state == Enum.PlaybackState.Completed then
			scale:SetAttribute("BloxUIBase", nil)
		end
	end)
	tween:Play()
end

local shaking: { [GuiObject]: { base: UDim2, token: {} } } = {}

-- Rattles a GuiObject in place (errors, damage, "not enough coins").
-- Nothing happens with reduced motion on.
function Motion.shake(gui: GuiObject, intensity: number?, duration: number?)
	if Fusion.peek(reducedState) then
		return
	end
	local strength = intensity or 8
	local length = duration or 0.4
	local current = shaking[gui]
	local base = if current then current.base else gui.Position
	local token = {}
	shaking[gui] = { base = base, token = token }
	local start = os.clock()
	local connection: RBXScriptConnection
	connection = RunService.RenderStepped:Connect(function()
		local state = shaking[gui]
		if state == nil or state.token ~= token or gui.Parent == nil then
			connection:Disconnect()
			return
		end
		local t = (os.clock() - start) / length
		if t >= 1 then
			gui.Position = base
			shaking[gui] = nil
			connection:Disconnect()
			return
		end
		local falloff = (1 - t) ^ 2 * strength
		gui.Position = base + UDim2.fromOffset((math.random() * 2 - 1) * falloff, (math.random() * 2 - 1) * falloff)
	end)
end

-- Floats a GuiObject up and down forever.
function Motion.bob(gui: GuiObject, distance: number?, period: number?): () -> ()
	local base = gui.Position
	local tween = TweenService:Create(
		gui,
		TweenInfo.new((period or 1.4) / 2, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true),
		{ Position = base + UDim2.fromOffset(0, -(distance or 6)) }
	)
	tween:Play()
	local untrack = Motion.track(tween, gui, function()
		gui.Position = base
	end)
	return function()
		untrack()
		tween:Cancel()
		if gui.Parent then
			gui.Position = base
		end
	end
end

-- Spins a GuiObject forever (reward rays, loading icons).
function Motion.spin(gui: GuiObject, secondsPerTurn: number?): () -> ()
	local base = gui.Rotation
	local tween = TweenService:Create(
		gui,
		TweenInfo.new(secondsPerTurn or 8, Enum.EasingStyle.Linear, Enum.EasingDirection.In, -1),
		{ Rotation = base + 360 }
	)
	tween:Play()
	-- (reduced motion: rays hold still where they are)
	local untrack = Motion.track(tween, gui)
	return function()
		untrack()
		tween:Cancel()
		if gui.Parent then
			gui.Rotation = base
		end
	end
end

-- Gently grows and shrinks forever ("claim me!" buttons).
function Motion.pulse(gui: GuiObject, amount: number?, period: number?): () -> ()
	local scale = getScale(gui)
	local base = scale.Scale
	local tween = TweenService:Create(
		scale,
		TweenInfo.new((period or 0.9) / 2, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true),
		{ Scale = base * (1 + (amount or 0.06)) }
	)
	tween:Play()
	local untrack = Motion.track(tween, gui, function()
		scale.Scale = base
	end)
	return function()
		untrack()
		tween:Cancel()
		if scale.Parent then
			scale.Scale = base
		end
	end
end

-- Wiggles rotation back and forth forever (attention grabbers).
function Motion.wiggle(gui: GuiObject, degrees: number?, period: number?): () -> ()
	local base = gui.Rotation
	local amount = degrees or 8
	gui.Rotation = base - amount
	local tween = TweenService:Create(
		gui,
		TweenInfo.new((period or 0.5) / 2, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true),
		{ Rotation = base + amount }
	)
	tween:Play()
	local untrack = Motion.track(tween, gui, function()
		gui.Rotation = base
	end)
	return function()
		untrack()
		tween:Cancel()
		if gui.Parent then
			gui.Rotation = base
		end
	end
end

export type ShineOptions = {
	Interval: number?, -- seconds between sweeps
	Duration: number?, -- seconds per sweep
	Width: number?, -- 0..0.5 band width
	Transparency: number?, -- band strength (0 = solid white)
	Rotation: number?,
	Color: Color3?,
	ZIndex: number?,
}

-- A glossy light band that sweeps across a GuiObject every few seconds.
-- The band copies the object's UICorner so it never spills past the corners.
function Motion.shine(gui: GuiObject, options: ShineOptions?): () -> ()
	local opts = options or {}
	-- A layout would arrange the band like any other child; skip those.
	if gui:FindFirstChildWhichIsA("UIGridStyleLayout") then
		return function() end
	end
	local width = opts.Width or 0.16
	local strength = opts.Transparency or 0.55

	local band = Instance.new("Frame")
	band.Name = "Shine"
	band.BackgroundColor3 = opts.Color or Color3.new(1, 1, 1)
	band.BorderSizePixel = 0
	band.Size = UDim2.fromScale(1, 1)
	band.ZIndex = opts.ZIndex or 20
	band.Active = false

	local corner = gui:FindFirstChildOfClass("UICorner")
	if corner then
		corner:Clone().Parent = band
	end

	local gradient = Instance.new("UIGradient")
	gradient.Rotation = opts.Rotation or 20
	gradient.Transparency = NumberSequence.new({
		NumberSequenceKeypoint.new(0, 1),
		NumberSequenceKeypoint.new(0.5 - width, 1),
		NumberSequenceKeypoint.new(0.5, strength),
		NumberSequenceKeypoint.new(0.5 + width, 1),
		NumberSequenceKeypoint.new(1, 1),
	})
	gradient.Offset = Vector2.new(-1.2, 0)
	gradient.Parent = band
	band.Parent = gui

	local tween = TweenService:Create(
		gradient,
		TweenInfo.new(
			opts.Duration or 0.7,
			Enum.EasingStyle.Quad,
			Enum.EasingDirection.InOut,
			-1,
			false,
			opts.Interval or 2.6
		),
		{ Offset = Vector2.new(1.2, 0) }
	)
	tween:Play()
	local untrack = Motion.track(tween, gui, function()
		gradient.Offset = Vector2.new(-1.2, 0)
	end)
	return function()
		untrack()
		tween:Cancel()
		band:Destroy()
	end
end

export type TypewriteOptions = {
	Speed: number?, -- characters per second
	Sound: string?, -- theme sound name or id, "" for silent
	OnDone: (() -> ())?,
}

-- Reveals a label's text one character at a time. Returns `skip()` which
-- shows everything immediately. Works with RichText.
function Motion.typewrite(label: TextLabel, text: string, options: TypewriteOptions?): () -> ()
	local opts = options or {}
	local speed = opts.Speed or 45
	local sound = if opts.Sound == nil then "Tick" else opts.Sound
	label.Text = text
	label.MaxVisibleGraphemes = 0
	local content = label.ContentText
	local total = utf8.len(content) or #content
	local finished = false

	local function finish()
		if finished then
			return
		end
		finished = true
		label.MaxVisibleGraphemes = -1
		if opts.OnDone then
			opts.OnDone()
		end
	end

	local thread = task.spawn(function()
		local shown = 0
		for _, codepoint in utf8.codes(content) do
			if finished or label.Parent == nil then
				return
			end
			shown += 1
			label.MaxVisibleGraphemes = shown
			local char = utf8.char(codepoint)
			if shown % 2 == 0 and char ~= " " then
				Sound.play(sound, { Volume = 0.35, Pitch = 1.4 })
			end
			local pause = 1 / speed
			if char == "." or char == "!" or char == "?" then
				pause *= 8
			elseif char == "," then
				pause *= 4
			end
			task.wait(pause)
		end
		finish()
	end)

	if total == 0 then
		finish()
	end

	return function()
		if coroutine.status(thread) ~= "dead" then
			task.cancel(thread)
		end
		finish()
	end
end

export type CountOptions = {
	Duration: number?, -- seconds (default: from the size of the change, 0.3 .. 1)
	Format: any?, -- Format style ("abbrev", "comma", "plain") or a function
	Tick: (string | boolean)?, -- a sound per visible step, rising in pitch (true = "Tick"); off by default
	Punch: (number | boolean)?, -- pop the label when it lands (true = 0.12); off by default
	Prefix: string?,
	Suffix: string?,
	OnDone: (() -> ())?,
}

-- the running count per label, so a new count takes over cleanly
local counting: { [Instance]: {} } = setmetatable({}, { __mode = "k" }) :: any

--[[
	Counts a label's number from `from` to `to` (Quint Out). Without a
	duration it picks one from the size of the change (0.3 s .. 1 s,
	docs/RESEARCH.md 2.9). `format` is a Format style ("abbrev", "comma",
	"plain") or a function. The 4th argument may instead be an options table:

		FX.countTo(label, 0, 2500, { Tick = true, Punch = true })

	Returns stop(), which jumps to the final value. Starting another count on
	the same label stops the old one.
]]
function Motion.countTo(label: TextLabel, from: number, to: number, duration: any?, format: any?, options: CountOptions?): () -> ()
	local opts: CountOptions = options or {}
	if typeof(duration) == "table" then
		opts = duration
		duration = opts.Duration
	end
	format = format or opts.Format
	local delta = math.abs(to - from)
	local length = duration or math.clamp(0.3 + 0.12 * math.log10(math.max(1, delta)), 0.3, 1)
	local tickSound = if opts.Tick == true then "Tick" elseif typeof(opts.Tick) == "string" then opts.Tick else nil
	local punch = if opts.Punch == true then 0.12 elseif typeof(opts.Punch) == "number" then opts.Punch else nil
	local prefix, suffix = opts.Prefix or "", opts.Suffix or ""
	local token = {}
	counting[label] = token
	local start = os.clock()
	local lastText = ""
	local lastTick = 0
	local finished = false
	local connection: RBXScriptConnection? = nil

	local function show(value: number)
		local text = prefix .. Format.number(value, format) .. suffix
		if text ~= lastText then
			lastText = text
			label.Text = text
			return true
		end
		return false
	end

	local function finish(landed: boolean)
		if finished then
			return
		end
		finished = true
		if connection then
			connection:Disconnect()
		end
		if counting[label] == token then
			counting[label] = nil
			if label.Parent then
				show(to)
				if landed and punch then
					Motion.pop(label, punch)
				end
			end
		end
		if opts.OnDone then
			opts.OnDone()
		end
	end

	show(from)
	connection = RunService.RenderStepped:Connect(function()
		if counting[label] ~= token or label.Parent == nil then
			finish(false)
			return
		end
		local t = math.clamp((os.clock() - start) / length, 0, 1)
		local eased = 1 - (1 - t) ^ 5
		if show(from + (to - from) * eased) and tickSound and t < 1 then
			local now = os.clock()
			if now - lastTick >= 0.05 then
				lastTick = now
				Sound.play(tickSound, { Pitch = 1 + 0.3 * eased, Vary = 0, Volume = 0.3 })
			end
		end
		if t >= 1 then
			finish(true)
		end
	end)

	return function()
		finish(false)
	end
end

export type StaggerOptions = {
	Delay: number?, -- seconds between items (total capped at 0.3 s)
	Distance: number?, -- design px each item rises from
	From: ("Bottom" | "Top" | "Left" | "Right")?,
	Easing: Enum.EasingStyle?, -- v2.4: the style family's feel (default Quart)
	Duration: number?, -- v2.4: seconds per item (default 0.22)
}

-- List / grid entrance: children slide in one after another. Call right
-- after building (e.g. when a page opens). Only GuiObjects move. Reduced
-- motion: nothing moves.
function Motion.stagger(children: { Instance }, options: StaggerOptions?)
	if Fusion.peek(reducedState) then
		return
	end
	local opts = options or {}
	local delay = opts.Delay or 0.025
	local distance = opts.Distance or 12
	local items = {}
	for _, child in children do
		if typeof(child) == "Instance" and child:IsA("GuiObject") then
			table.insert(items, child)
		end
	end
	if #items == 0 then
		return
	end
	delay = math.min(delay, 0.3 / #items)
	local dx, dy = 0, distance
	if opts.From == "Top" then
		dy = -distance
	elseif opts.From == "Left" then
		dx, dy = -distance, 0
	elseif opts.From == "Right" then
		dx, dy = distance, 0
	end
	for index, gui in items do
		local final = gui.Position
		gui.Position = final + UDim2.fromOffset(dx, dy)
		local tween = TweenService:Create(
			gui,
			TweenInfo.new(opts.Duration or 0.22, opts.Easing or Enum.EasingStyle.Quart, Enum.EasingDirection.Out, 0, false, (index - 1) * delay),
			{ Position = final }
		)
		tween:Play()
	end
end

-- Rotating light rays behind a GuiObject (reward reveals). Returns stop().
function Motion.rays(gui: GuiObject, options: { Color: Color3?, Size: number?, Count: number?, Transparency: number?, Period: number? }?): () -> ()
	local opts = options or {}
	local count = math.max(2, math.floor((opts.Count or 12) / 2))
	local holder = Instance.new("Frame")
	holder.Name = "Rays"
	holder.BackgroundTransparency = 1
	holder.AnchorPoint = Vector2.new(0.5, 0.5)
	holder.Position = UDim2.fromScale(0.5, 0.5)
	holder.Size = UDim2.fromScale(opts.Size or 2.4, opts.Size or 2.4)
	holder.ZIndex = 0
	local aspect = Instance.new("UIAspectRatioConstraint")
	aspect.AspectRatio = 1
	aspect.Parent = holder
	for index = 1, count do
		local strong = index % 2 == 1
		local beam = Instance.new("Frame")
		beam.Name = "Beam"
		beam.BorderSizePixel = 0
		beam.AnchorPoint = Vector2.new(0.5, 0.5)
		beam.Position = UDim2.fromScale(0.5, 0.5)
		beam.Size = UDim2.fromScale(if strong then 0.09 else 0.055, 1)
		beam.Rotation = (index - 1) * 180 / count
		beam.BackgroundColor3 = opts.Color or Color3.new(1, 1, 1)
		beam.BackgroundTransparency = opts.Transparency or 0.3
		beam.ZIndex = 0
		local gradient = Instance.new("UIGradient")
		gradient.Rotation = 90
		gradient.Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 1),
			NumberSequenceKeypoint.new(0.22, if strong then 0.7 else 0.85),
			NumberSequenceKeypoint.new(0.5, 0),
			NumberSequenceKeypoint.new(0.78, if strong then 0.7 else 0.85),
			NumberSequenceKeypoint.new(1, 1),
		})
		gradient.Parent = beam
		beam.Parent = holder
	end
	holder.Parent = gui
	local stopSpin = Motion.spin(holder, opts.Period or 14)
	return function()
		stopSpin()
		holder:Destroy()
	end
end

return Motion
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="160">
            <Properties>
              <string name="Name">Particles</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	2D particle effects drawn on the FX layer: bursts, confetti, sparkles,
	floating "+10" text, damage numbers and icons that fly into a counter.

	Positions (`at`, `from`, `to`) accept anything Layer.point understands:
	a GuiObject, a screen Vector2, a UDim2, or a world Vector3 / Part.

	Shapes (burst / confetti `Shape`):
		"confetti"  paper rectangle that tumbles (darker on its back face)
		"ribbon"    long thin streamer that twists
		"square", "triangle", "circle"
		"sparkle"   four point twinkle (no dark fringes)
		"mix"       confetti + ribbons + circles + triangles + squares + twinkles
		any icon    "star", "coin", "heart" ... (tinted outline, never black)

	Everything here is a safe no-op without a LocalPlayer (edit mode, plugin
	preview): nothing is drawn and callbacks still run.
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")

local Theme = require(script.Parent.Parent.Theme)
local Icons = require(script.Parent.Parent.Icons)
local Assets = require(script.Parent.Parent.Assets)
local Sound = require(script.Parent.Parent.Util.Sound)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Layer = require(script.Parent.Layer)
local Motion = require(script.Parent.Motion)

local Particles = {}

local WHITE = Color3.new(1, 1, 1)
local BLACK = Color3.new(0, 0, 0)
local TRIANGLE = "rbxasset://textures/triangle.png"

-- True when effects can actually be seen (a player, and the layer is on).
local function live(): boolean
	return Layer.enabled ~= false and Players.LocalPlayer ~= nil
end
Particles.live = live

-- 0 -> ~1.1 -> 1: the "pop" curve used for things appearing.
local function backOut(t: number): number
	local c1 = 1.70158
	local c3 = c1 + 1
	return 1 + c3 * (t - 1) ^ 3 + c1 * (t - 1) ^ 2
end

-- A darker shade of `color` for outlines (QUALITY rule 1: never pure black).
local function tinted(color: Color3): Color3
	local h, s, v = color:ToHSV()
	if s < 0.12 then
		return Theme.get().Color.Outline
	end
	return Color3.fromHSV(h, math.min(1, s * 1.05 + 0.1), math.max(0.12, v * 0.36))
end
Particles.tinted = tinted

type Particle = {
	gui: GuiObject,
	position: Vector2,
	velocity: Vector2,
	rotation: number,
	spin: number,
	age: number,
	life: number,
	gravity: number,
	drag: number,
	size: Vector2,
	endScale: number,
	flip: number?, -- tumble speed (rad/s); the width follows |cos|
	fadeFrom: number, -- 0..1 of the life where fading starts
	fadePower: number?, -- > 1 stays solid longer, then fades fast
	popIn: number?, -- seconds to grow in from nothing
	sway: number?, -- side-to-side flutter, px
	phase: number?,
	front: Color3?, -- two-sided shapes: colour of each face
	back: Color3?,
	showingBack: boolean?,
	stroke: UIStroke?, -- icon outlines thin out with the particle
	strokeBase: number?,
	baseAlpha: number?,
}

local active: { Particle } = {}
local connection: RBXScriptConnection? = nil

-- A four-point twinkle built from two tapered bars and a bright core. Scales
-- with its container, so particles can resize it freely. (The engine's
-- sparkle.png has dark rays baked in, which look muddy on bright UI.)
local function newTwinkle(color: Color3): Frame
	local holder = Instance.new("Frame")
	holder.BackgroundTransparency = 1
	holder:SetAttribute("BloxUITwinkle", true)
	for index = 1, 2 do
		local arm = Instance.new("Frame")
		arm.Name = if index == 1 then "ArmV" else "ArmH"
		arm.BorderSizePixel = 0
		arm.BackgroundColor3 = color
		arm.AnchorPoint = Vector2.new(0.5, 0.5)
		arm.Position = UDim2.fromScale(0.5, 0.5)
		arm.Size = if index == 1 then UDim2.fromScale(0.18, 1) else UDim2.fromScale(1, 0.18)
		local corner = Instance.new("UICorner")
		corner.CornerRadius = UDim.new(0.5, 0)
		corner.Parent = arm
		local taper = Instance.new("UIGradient")
		taper.Rotation = if index == 1 then 90 else 0
		taper.Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 1),
			NumberSequenceKeypoint.new(0.5, 0),
			NumberSequenceKeypoint.new(1, 1),
		})
		taper.Parent = arm
		arm.Parent = holder
	end
	local core = Instance.new("Frame")
	core.Name = "Core"
	core.BorderSizePixel = 0
	core.BackgroundColor3 = WHITE
	core.AnchorPoint = Vector2.new(0.5, 0.5)
	core.Position = UDim2.fromScale(0.5, 0.5)
	core.Size = UDim2.fromScale(0.26, 0.26)
	local round = Instance.new("UICorner")
	round.CornerRadius = UDim.new(0.5, 0)
	round.Parent = core
	core.Parent = holder
	return holder
end
Particles.newTwinkle = newTwinkle

local function setAlpha(gui: GuiObject, alpha: number)
	if gui:GetAttribute("BloxUITwinkle") then
		for _, child in gui:GetChildren() do
			if child:IsA("Frame") then
				child.BackgroundTransparency = alpha
			end
		end
	elseif gui:IsA("ImageLabel") then
		gui.ImageTransparency = alpha
	elseif gui:IsA("TextLabel") then
		gui.TextTransparency = alpha
		local stroke = gui:FindFirstChildOfClass("UIStroke")
		if stroke then
			stroke.Transparency = alpha
		end
	else
		gui.BackgroundTransparency = alpha
	end
end

local function setColor(gui: GuiObject, color: Color3)
	if gui:IsA("ImageLabel") then
		gui.ImageColor3 = color
	elseif not gui:IsA("TextLabel") then
		gui.BackgroundColor3 = color
	end
end

--------------------------------------------------------------------------
-- Budget: auto-clickers and busy HUDs must never pile up thousands of GUI
-- objects. At most MAX_ACTIVE particles live at once (the oldest make room
-- for new ones) and at most SPAWN_BUDGET are created per frame (extra
-- spawns are skipped before any instance is made).
--------------------------------------------------------------------------

Particles.MAX_ACTIVE = 300
Particles.SPAWN_BUDGET = 150
-- Phones and tablets get a smaller budget: fewer GUI objects to lay out and
-- draw on weaker hardware. (The Blueprint "lowDetail" setting uses these caps
-- on any device.)
Particles.TOUCH_MAX_ACTIVE = 120
Particles.TOUCH_SPAWN_BUDGET = 40
if Viewport.isTouch then
	Particles.MAX_ACTIVE = Particles.TOUCH_MAX_ACTIVE
	Particles.SPAWN_BUDGET = Particles.TOUCH_SPAWN_BUDGET
end

-- Reduced motion (UI.Motion.reduced) keeps about this share of every
-- effect's particles, and at most REDUCED_FLYERS icons per flyTo.
Particles.REDUCED_SHARE = 0.35
Particles.REDUCED_FLYERS = 5

-- false: FX.damageNumber does nothing (the Blueprint "damageNumbers" setting).
Particles.damageNumbers = true

local aliveCount = 0
local order: { Particle } = {} -- spawn order, oldest first (may hold dead entries)
local orderHead = 1
local budgetStart = -math.huge
local spawnedInWindow = 0

-- Spawns left in this frame's budget.
local function available(): number
	local now = os.clock()
	if now - budgetStart >= 1 / 60 then
		budgetStart = now
		spawnedInWindow = 0
	end
	return math.max(0, Particles.SPAWN_BUDGET - spawnedInWindow)
end

-- Asks for room for one more particle this frame. False = skip it.
local function reserve(): boolean
	if available() <= 0 then
		return false
	end
	spawnedInWindow += 1
	return true
end
Particles.reserve = reserve

--[[
	Fair shares: effects ask for their particles with request(count, spawn)
	and the requests made during a frame are served together just before it
	renders. When they ask for more than the budget between them, each gets
	the same fraction of what it asked for (at least one while any budget is
	left), so the tenth burst in a busy frame still shows up instead of the
	first few taking everything.
]]
local pending: { { count: number, spawn: (number) -> () } } = {}
local flushQueued = false
local function flush()
	flushQueued = false
	local list = pending
	pending = {}
	local asked = 0
	for _, r in list do
		asked += r.count
	end
	local budget = available()
	local share = if asked > budget then budget / asked else 1
	for _, r in list do
		local n = if share >= 1 then r.count else math.max(1, math.floor(r.count * share))
		n = math.min(n, budget)
		if n > 0 then
			budget -= n
			spawnedInWindow += n
			task.spawn(r.spawn, n)
		end
	end
end
local function request(count: number, spawn: (allowed: number) -> ())
	if Motion.reduced and count > 0 then
		-- reduced motion: a share of the particles (one-offs pass by chance)
		local keep = count * Particles.REDUCED_SHARE
		count = math.floor(keep) + (if math.random() < keep % 1 then 1 else 0)
	end
	if count <= 0 then
		return
	end
	table.insert(pending, { count = count, spawn = spawn })
	if not flushQueued then
		flushQueued = true
		RunService.RenderStepped:Once(flush)
	end
end

local function kill(p: Particle)
	if not (p :: any).dead then
		(p :: any).dead = true
		aliveCount -= 1
		p.gui:Destroy()
	end
end

-- Recycles the oldest particles until there's room for `extra` more.
local function makeRoom(extra: number)
	while aliveCount + extra > Particles.MAX_ACTIVE and orderHead <= #order do
		local oldest = order[orderHead]
		orderHead += 1
		kill(oldest)
	end
	-- compact the queue now and then (it also holds particles that died of age)
	if orderHead > 512 or #order - orderHead > Particles.MAX_ACTIVE * 2 then
		local fresh = {}
		for i = orderHead, #order do
			if not (order[i] :: any).dead then
				table.insert(fresh, order[i])
			end
		end
		order = fresh
		orderHead = 1
	end
end

-- How many particles are alive right now (for tests and debug overlays).
function Particles.activeCount(): number
	return aliveCount
end

-- Keeps at most `limit` of one kind of tweened effect (pop texts, rings)
-- alive, destroying the oldest first.
local function capped(list: { Instance }, limit: number, new: Instance)
	local i = 1
	while i <= #list do
		if list[i].Parent == nil then
			table.remove(list, i)
		else
			i += 1
		end
	end
	while #list >= limit do
		local oldest = table.remove(list, 1)
		if oldest then
			oldest:Destroy()
		end
	end
	table.insert(list, new)
end
local liveTexts: { Instance } = {}
local liveRings: { Instance } = {}

local function step(dt: number)
	local index = 1
	while index <= #active do
		local p = active[index]
		p.age += dt
		if (p :: any).dead or p.age >= p.life or p.gui.Parent == nil then
			kill(p)
			active[index] = active[#active]
			active[#active] = nil
		else
			p.velocity += Vector2.new(0, p.gravity * dt)
			p.velocity *= math.max(0, 1 - p.drag * dt)
			p.position += p.velocity * dt
			p.rotation += p.spin * dt
			local t = p.age / p.life
			local scale = 1 + (p.endScale - 1) * t
			if p.popIn and p.age < p.popIn then
				scale *= math.max(0, backOut(p.age / p.popIn))
			end
			local width = p.size.X * scale
			if p.flip then
				local c = math.cos(p.age * p.flip + (p.phase or 0))
				width *= math.max(0.06, math.abs(c))
				if p.back then
					local showBack = c < 0
					if showBack ~= p.showingBack then
						p.showingBack = showBack
						setColor(p.gui, if showBack then p.back else p.front :: Color3)
					end
				end
			end
			local x = p.position.X
			if p.sway then
				x += math.sin(p.age * 2.6 + (p.phase or 0)) * p.sway
			end
			p.gui.Position = UDim2.fromOffset(x, p.position.Y)
			p.gui.Size = UDim2.fromOffset(width, p.size.Y * scale)
			p.gui.Rotation = p.rotation
			if p.stroke and p.strokeBase then
				p.stroke.Thickness = p.strokeBase * math.max(0, scale)
			end
			local alpha = p.baseAlpha or 0
			if t > p.fadeFrom then
				local fade = ((t - p.fadeFrom) / (1 - p.fadeFrom)) ^ (p.fadePower or 1)
				alpha = 1 - (1 - alpha) * (1 - fade)
			end
			setAlpha(p.gui, alpha)
			index += 1
		end
	end
	if #active == 0 and connection then
		connection:Disconnect()
		connection = nil
	end
end

local function add(particle: Particle)
	makeRoom(1)
	aliveCount += 1
	table.insert(active, particle)
	table.insert(order, particle)
	if connection == nil then
		connection = RunService.RenderStepped:Connect(step)
	end
	-- first frame at the right place and size (no one-frame flash at 0,0)
	particle.gui.Position = UDim2.fromOffset(particle.position.X, particle.position.Y)
	local start = if particle.popIn then 0 else 1
	particle.gui.Size = UDim2.fromOffset(particle.size.X * start, particle.size.Y * start)
	particle.gui.Rotation = particle.rotation
	if particle.baseAlpha then
		setAlpha(particle.gui, particle.baseAlpha)
	end
end

-- Party colours from the current theme: the accent first, then the tones.
local function defaultColors(): { Color3 }
	local theme = Theme.get()
	local tone = theme.Tone
	local colors = {}
	local function push(color: Color3?)
		if color then
			table.insert(colors, color)
		end
	end
	push(theme.Color and theme.Color.Accent)
	for _, name in { "Pink", "Cyan", "Green", "Purple", "Orange", "Blue", "Yellow" } do
		push(tone[name] and tone[name].Top)
	end
	if #colors == 0 then
		colors = { WHITE }
	end
	return colors
end
Particles.defaultColors = defaultColors

local CONFETTI_MIX = { "confetti", "confetti", "ribbon", "confetti", "circle", "triangle", "confetti", "square", "ribbon" }
local PARTY_MIX = { "confetti", "confetti", "ribbon", "sparkle", "confetti", "circle", "triangle", "confetti", "square", "ribbon" }

-- Shapes that tumble (their width follows a cosine) and show a darker back.
local TUMBLES = { confetti = true, ribbon = true, square = true, triangle = true }

local function pickShape(shape: string, index: number): string
	if shape == "mix" then
		return PARTY_MIX[(index - 1) % #PARTY_MIX + 1]
	elseif shape == "paper" then
		return CONFETTI_MIX[(index - 1) % #CONFETTI_MIX + 1]
	end
	return shape
end

-- Size of each shape's box for a nominal size `s` (px).
local function shapeSize(shape: string, s: number): Vector2
	if shape == "confetti" then
		return Vector2.new(s, s * 0.6)
	elseif shape == "ribbon" then
		return Vector2.new(s * 0.34, s * 1.5)
	elseif shape == "circle" then
		return Vector2.new(s * 0.62, s * 0.62)
	elseif shape == "triangle" then
		return Vector2.new(s * 0.9, s * 0.78)
	elseif shape == "square" then
		return Vector2.new(s * 0.72, s * 0.72)
	elseif shape == "sparkle" then
		return Vector2.new(s * 1.25, s * 1.25)
	end
	return Vector2.new(s, s)
end

-- Builds one particle's visual. Returns the gui and, for icons, the outline
-- (which the step thins out as the particle shrinks).
local function makeVisual(shape: string, color: Color3, size: number): (GuiObject, UIStroke?)
	local gui: GuiObject
	local stroke: UIStroke? = nil
	if shape == "sparkle" or shape == "twinkle" then
		gui = newTwinkle(color)
	elseif shape == "circle" or shape == "dot" then
		local image = Instance.new("ImageLabel")
		image.Image = Assets.Circle
		image.ImageColor3 = color
		image.BackgroundTransparency = 1
		gui = image
	elseif shape == "triangle" then
		local image = Instance.new("ImageLabel")
		image.Image = TRIANGLE
		image.ImageColor3 = color
		image.BackgroundTransparency = 1
		gui = image
	elseif shape == "confetti" or shape == "square" or shape == "ribbon" then
		local frame = Instance.new("Frame")
		frame.BackgroundColor3 = color
		frame.BorderSizePixel = 0
		local corner = Instance.new("UICorner")
		corner.CornerRadius = if shape == "ribbon" then UDim.new(0.5, 0) else UDim.new(0.18, 0)
		corner.Parent = frame
		gui = frame
	else
		-- any icon name, e.g. "star", "coin", "heart"
		local label = Instance.new("TextLabel")
		local kind, value = Icons.resolve(shape)
		label.FontFace = if kind == "glyph" then Icons.font(true) else Theme.get().Font.Display
		label.Text = value
		label.TextScaled = true
		label.TextColor3 = color
		label.BackgroundTransparency = 1
		local outline = Instance.new("UIStroke")
		outline.Color = tinted(color)
		outline.LineJoinMode = Enum.LineJoinMode.Round
		outline.Thickness = math.max(1, size * 0.07)
		outline.Parent = label
		stroke = outline
		gui = label
	end
	gui.Name = "Particle"
	gui.AnchorPoint = Vector2.new(0.5, 0.5)
	gui.ZIndex = 50
	return gui, stroke
end

export type BurstOptions = {
	Count: number?,
	Colors: { Color3 }?,
	Shape: string?, -- "confetti" | "ribbon" | "sparkle" | "circle" | "square" | "triangle" | "mix" | any icon name
	Size: number?, -- design px
	Speed: number?, -- design px per second
	Direction: number?, -- degrees, -90 = up
	Spread: number?, -- degrees of the cone, 360 = all around
	Gravity: number?,
	Drag: number?,
	Lifetime: number?,
	EndScale: number?,
	PopIn: number?, -- seconds each particle takes to grow in (default 0.06)
	Radius: number?, -- design px: start on a circle this big instead of the exact point
	Sound: string?,
}

-- A one-shot explosion of particles. Great for purchases, level ups, hits.
function Particles.burst(at: any, options: BurstOptions?)
	local opts = options or {}
	if opts.Sound then
		Sound.play(opts.Sound)
	end
	if not live() then
		return
	end
	local root = Layer.get()
	local origin = Layer.point(at)
	local scale = Layer.scale()
	local colors = opts.Colors or defaultColors()
	local shape = opts.Shape or "confetti"
	local count = opts.Count or 24
	local spread = math.rad(opts.Spread or 360)
	local direction = math.rad(opts.Direction or -90)
	local baseSpeed = (opts.Speed or 520) * scale
	local size = (opts.Size or 14) * scale
	local radius = (opts.Radius or 0) * scale
	local function spawnOne(i: number)
		local color = colors[(i - 1) % #colors + 1]
		local kind = pickShape(shape, i)
		local s = size * (if kind == "sparkle" then 0.8 + math.random() * 0.5 else 0.85 + math.random() * 0.3)
		local gui, stroke = makeVisual(kind, color, s)
		local angle = direction + (math.random() - 0.5) * spread
		local dir = Vector2.new(math.cos(angle), math.sin(angle))
		local speed = baseSpeed * (0.45 + math.random() * 0.75)
		local tumbles = TUMBLES[kind] == true
		local paper = tumbles or kind == "circle" and shape == "mix"
		gui.Parent = root
		add({
			gui = gui,
			position = origin + dir * radius,
			velocity = dir * speed,
			rotation = math.random() * 360,
			spin = (math.random() - 0.5) * (if kind == "sparkle" then 240 else 720),
			age = 0,
			life = (opts.Lifetime or 0.9) * (0.7 + math.random() * 0.6),
			gravity = (opts.Gravity or 1400) * scale,
			drag = opts.Drag or 2.2,
			size = shapeSize(kind, s),
			endScale = opts.EndScale or (if paper then 1 else 0.2),
			flip = if tumbles then 6 + math.random() * 8 else nil,
			phase = math.random() * math.pi * 2,
			front = if tumbles then color else nil,
			back = if tumbles then color:Lerp(BLACK, 0.18) else nil,
			fadeFrom = 0.6,
			popIn = if opts.PopIn == 0 then nil else (opts.PopIn or 0.06),
			stroke = stroke,
			strokeBase = if stroke then stroke.Thickness else nil,
		})
	end
	request(count, function(allowed)
		for i = 1, allowed do
			spawnOne(i)
		end
	end)
end

export type ConfettiOptions = {
	Count: number?,
	Colors: { Color3 }?,
	Duration: number?, -- seconds the rain keeps falling in (Rain)
	Style: ("Rain" | "Cannons" | "Burst")?, -- Rain: from the top; Cannons: party poppers from both bottom corners; Burst: out of `At`
	Shape: string?, -- default "paper" (rectangles, ribbons, circles, triangles, squares)
	At: any?, -- Burst origin (default the screen centre)
	Size: number?, -- design px (default 14)
}

-- Confetti over the whole screen: tumbling two-sided paper, ribbons and
-- dots in the theme's colours. Style = "Cannons" fires party poppers from
-- the bottom corners (victories, big unlocks).
function Particles.confetti(options: ConfettiOptions?)
	local opts = options or {}
	if not live() then
		return
	end
	local root = Layer.get()
	local scale = Layer.scale()
	local screen = root.AbsoluteSize
	local width = screen.X
	local height = screen.Y
	local colors = opts.Colors or defaultColors()
	local count = opts.Count or 90
	local style = opts.Style or "Rain"
	local shape = opts.Shape or "paper"
	local baseSize = opts.Size or 16

	local function makePiece(i: number, position: Vector2, velocity: Vector2, gravity: number, drag: number, life: number)
		local kind = pickShape(shape, i)
		local color = colors[(i - 1) % #colors + 1]
		local s = (baseSize * (0.75 + math.random() * 0.55)) * scale
		local gui = makeVisual(kind, color, s)
		gui.Parent = root
		local tumbles = TUMBLES[kind] == true
		add({
			gui = gui,
			position = position,
			velocity = velocity,
			rotation = math.random() * 360,
			spin = (math.random() - 0.5) * 540,
			age = 0,
			life = life,
			gravity = gravity,
			drag = drag,
			size = shapeSize(kind, s),
			endScale = if kind == "sparkle" then 0.3 else 1,
			flip = if tumbles or kind == "circle" then 4 + math.random() * 7 else nil,
			phase = math.random() * math.pi * 2,
			sway = (10 + math.random() * 26) * scale,
			front = if tumbles then color else nil,
			back = if tumbles then color:Lerp(BLACK, 0.18) else nil,
			fadeFrom = 0.78,
			fadePower = 1.4,
		})
	end
	local function spawnPiece(i: number, position: Vector2, velocity: Vector2, gravity: number, drag: number, life: number)
		request(1, function()
			makePiece(i, position, velocity, gravity, drag, life)
		end)
	end

	if style == "Cannons" then
		task.spawn(function()
			local volleys = 3
			local perVolley = math.ceil(count / 2 / volleys)
			local i = 0
			for _ = 1, volleys do
				for side = -1, 1, 2 do
					local origin = Vector2.new(if side < 0 then -10 * scale else width + 10 * scale, height + 10 * scale)
					local aim = math.rad(if side < 0 then -62 else -118)
					for _ = 1, perVolley do
						i += 1
						local angle = aim + (math.random() - 0.5) * math.rad(28)
						local speed = (1700 + math.random() * 1000) * scale
						spawnPiece(i, origin, Vector2.new(math.cos(angle), math.sin(angle)) * speed, 1000 * scale, 1.15, 2.6 + math.random() * 1.1)
					end
				end
				task.wait(0.09)
			end
		end)
		return
	elseif style == "Burst" then
		local at = if opts.At ~= nil then Layer.point(opts.At) else Vector2.new(width / 2, height / 2)
		for i = 1, count do
			local angle = math.random() * math.pi * 2
			local speed = (600 + math.random() * 900) * scale
			spawnPiece(i, at, Vector2.new(math.cos(angle), math.sin(angle) - 0.35) * speed, 900 * scale, 1.8, 2 + math.random())
		end
		return
	end

	local duration = opts.Duration or 1.2
	task.spawn(function()
		for i = 1, count do
			local position = Vector2.new(math.random() * width, -(20 + math.random() * 40) * scale)
			local velocity = Vector2.new((math.random() - 0.5) * 260, 200 + math.random() * 260) * scale
			spawnPiece(i, position, velocity, 380 * scale, 0.9, 2.6 + math.random() * 1.2)
			if i % 6 == 0 then
				task.wait(duration / (count / 6))
			end
		end
	end)
end

export type PopTextOptions = {
	Color: Color3?,
	Size: number?, -- text size in design px
	Font: any?, -- theme font slot or Font
	Rise: number?, -- design px
	Lifetime: number?,
	Jitter: number?, -- random sideways offset
	Stroke: Color3?,
	RichText: boolean?,
}

-- Floating text that pops in, drifts up and fades: "+10", "CRIT!", "-25".
function Particles.popText(at: any, text: string, options: PopTextOptions?): TextLabel
	local opts = options or {}
	local theme = Theme.get()
	local root = Layer.get()
	local scale = Layer.scale()
	local origin = Layer.point(at) + Vector2.new((math.random() - 0.5) * 2 * (opts.Jitter or 24) * scale, 0)
	local size = (opts.Size or 38) * scale
	local lifetime = opts.Lifetime or 0.9

	local label = Instance.new("TextLabel")
	label.Name = "PopText"
	label.AnchorPoint = Vector2.new(0.5, 0.5)
	label.BackgroundTransparency = 1
	label.Size = UDim2.fromOffset(size * 10, size * 1.3)
	label.Position = UDim2.fromOffset(origin.X, origin.Y)
	label.FontFace = Theme.font(theme, opts.Font or "Display")
	label.Text = text
	label.RichText = opts.RichText or false
	label.TextSize = size
	label.TextColor3 = opts.Color or theme.Color.Text
	label.ZIndex = 60

	local stroke = Instance.new("UIStroke")
	stroke.Color = opts.Stroke or (if opts.Color then tinted(opts.Color) else theme.Color.Outline)
	stroke.LineJoinMode = Enum.LineJoinMode.Round
	stroke.Thickness = theme.Stroke.Text * 1.2 * scale
	stroke.Parent = label

	local uiScale = Instance.new("UIScale")
	uiScale.Scale = 0.2
	uiScale.Parent = label
	label.Parent = root
	capped(liveTexts, 40, label)

	TweenService:Create(uiScale, TweenInfo.new(0.35, Enum.EasingStyle.Back, Enum.EasingDirection.Out), { Scale = 1 }):Play()
	TweenService
		:Create(label, TweenInfo.new(lifetime, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
			Position = UDim2.fromOffset(origin.X, origin.Y - (opts.Rise or 90) * scale),
		})
		:Play()
	local fadeInfo = TweenInfo.new(lifetime * 0.4, Enum.EasingStyle.Quad, Enum.EasingDirection.In, 0, false, lifetime * 0.6)
	TweenService:Create(label, fadeInfo, { TextTransparency = 1 }):Play()
	TweenService:Create(stroke, fadeInfo, { Transparency = 1 }):Play()
	task.delay(lifetime + 0.05, function()
		label:Destroy()
	end)
	return label
end

export type FlyOptions = {
	Icon: any?, -- icon name, image id or emoji
	Color: Color3?,
	Count: number?,
	Size: number?,
	Duration: number?, -- average travel time (0.62)
	TravelTime: { number }?, -- { min, max } seconds, overrides Duration
	Scatter: number?, -- design px the icons burst out before flying (125)
	BurstRadius: { number }?, -- { min, max } design px, overrides Scatter
	BurstTime: number?, -- seconds of the burst (0.15)
	Stagger: number?, -- seconds between icons leaving (0.025)
	Arc: number?, -- design px the flight path bows upwards (120)
	PitchRamp: { number }?, -- arrival sound pitch { first, last } (1 -> 1.3)
	TargetPunch: number?, -- how hard the target pops per arrival (0.06)
	Sound: string?,
	OnArrive: ((index: number) -> ())?,
	OnDone: (() -> ())?,
}

local lastArrivalSound = 0
local flyersAlive = 0
Particles.MAX_FLYERS = 30 -- icons drawn per flyTo call
Particles.MAX_FLYERS_ALIVE = 120 -- icons in the air across all calls

local function bezier(a: Vector2, b: Vector2, c: Vector2, t: number): Vector2
	local u = 1 - t
	return a * (u * u) + b * (2 * u * t) + c * (t * t)
end

-- Icons burst out of `from` and swoop into `to` (usually a currency counter)
-- along an arc, one after another. Each arrival ticks (rising pitch) and
-- punches the target; the last one lands with a ring and a sparkle.
function Particles.flyTo(from: any, to: any, options: FlyOptions?)
	local opts = options or {}
	local count = math.max(1, math.floor(opts.Count or 8))
	local arrived = 0
	local finished = false

	local function arrive(index: number)
		arrived += 1
		if opts.OnArrive then
			opts.OnArrive(index)
		end
		if arrived >= count and not finished then
			finished = true
			if opts.OnDone then
				opts.OnDone()
			end
		end
	end

	if not live() then
		-- nothing to see: still pay out, so game logic never stalls
		for i = 1, count do
			arrive(i)
		end
		return
	end

	local theme = Theme.get()
	local root = Layer.get()
	local scale = Layer.scale()
	local start = Layer.point(from)
	local size = (opts.Size or 44) * scale
	local duration = opts.Duration or 0.58
	local travelMin = if opts.TravelTime then opts.TravelTime[1] else duration * 0.88
	local travelMax = if opts.TravelTime then (opts.TravelTime[2] or travelMin) else duration * 1.12
	local scatter = opts.Scatter or 125
	local burstMin = (if opts.BurstRadius then opts.BurstRadius[1] else scatter * 0.72) * scale
	local burstMax = (if opts.BurstRadius then (opts.BurstRadius[2] or opts.BurstRadius[1]) else scatter * 1.28) * scale
	local burstTime = opts.BurstTime or 0.15
	local stagger = opts.Stagger or 0.025
	local arc = (opts.Arc or 120) * scale
	local pitchFrom = if opts.PitchRamp then opts.PitchRamp[1] else 1
	local pitchTo = if opts.PitchRamp then (opts.PitchRamp[2] or pitchFrom) else 1.3
	local punch = opts.TargetPunch or 0.06
	local kind, value = Icons.resolve(opts.Icon or "coin")
	local color = opts.Color or theme.Tone.Yellow.Top
	local targetGui = if typeof(to) == "Instance" and to:IsA("GuiObject") then to else nil
	-- Big payouts show at most MAX_FLYERS icons; each carries its share of
	-- the arrivals, so OnArrive still runs `count` times.
	local visuals = math.min(count, if Motion.reduced then Particles.REDUCED_FLYERS else Particles.MAX_FLYERS)
	local landed = 0

	local function land(j: number)
		landed += 1
		if targetGui and targetGui.Parent then
			Motion.pop(targetGui, if landed >= visuals then punch * 2.2 else punch)
		end
		-- rising pitch across the batch, rate limited
		local now = os.clock()
		if now - lastArrivalSound >= 0.035 then
			lastArrivalSound = now
			local progress = if visuals > 1 then (landed - 1) / (visuals - 1) else 1
			Sound.play(opts.Sound or "Coin", { Volume = 0.55, Pitch = pitchFrom + (pitchTo - pitchFrom) * progress })
		end
		if landed >= visuals then
			local at = if targetGui and targetGui.Parent then targetGui else Layer.point(to)
			Particles.ring(at, { Color = color, Size = 64, From = 0.5, To = 1.5, Time = 0.3, Thickness = 5 })
			Particles.burst(at, {
				Shape = "sparkle",
				Count = 6,
				Speed = 300,
				Size = 20,
				Gravity = 0,
				Drag = 4,
				Lifetime = 0.45,
				Colors = { WHITE, color },
			})
		end
		for index = math.floor((j - 1) * count / visuals) + 1, math.floor(j * count / visuals) do
			arrive(index)
		end
	end

	for i = 1, visuals do
		-- too many icons already in the air (auto-clickers): pay out at once
		if flyersAlive >= Particles.MAX_FLYERS_ALIVE then
			land(i)
			continue
		end
		flyersAlive += 1
		local gui: GuiObject
		local stroke: UIStroke? = nil
		if kind == "image" then
			local image = Instance.new("ImageLabel")
			image.Image = value
			image.BackgroundTransparency = 1
			image.ScaleType = Enum.ScaleType.Fit
			gui = image
		else
			local label = Instance.new("TextLabel")
			label.FontFace = if kind == "glyph" then Icons.font(true) else theme.Font.Display
			label.Text = value
			label.TextScaled = true
			label.TextColor3 = color
			label.BackgroundTransparency = 1
			local outline = Instance.new("UIStroke")
			outline.Color = tinted(color)
			outline.LineJoinMode = Enum.LineJoinMode.Round
			outline.Thickness = math.max(1, size * 0.06)
			outline.Parent = label
			stroke = outline
			gui = label
		end
		gui.Name = "Flyer"
		gui.AnchorPoint = Vector2.new(0.5, 0.5)
		gui.Size = UDim2.fromOffset(0, 0)
		gui.Position = UDim2.fromOffset(start.X, start.Y)
		gui.ZIndex = 70
		gui.Parent = root
		local strokeBase = if stroke then stroke.Thickness else 0

		local function setSize(s: number)
			gui.Size = UDim2.fromOffset(s, s)
			if stroke then
				stroke.Thickness = strokeBase * math.max(0, s / size)
			end
		end

		-- a fountain: evenly spread angles with a little jitter, biased upwards
		local angle = (i / visuals + (math.random() - 0.5) * 0.18) * math.pi * 2
		local reach = burstMin + math.random() * (burstMax - burstMin)
		local burstPoint = start + Vector2.new(math.cos(angle), math.sin(angle) * 0.8 - 0.2) * reach
		local spinDir = if math.random() < 0.5 then -1 else 1

		local function done()
			flyersAlive -= 1
			if gui.Parent then
				gui:Destroy()
			end
			land(i)
		end

		task.spawn(function()
			-- 1. burst out, popping in
			local t0 = os.clock()
			while true do
				local t = math.min(1, (os.clock() - t0) / burstTime)
				local eased = 1 - (1 - t) ^ 3
				local p = start:Lerp(burstPoint, eased)
				gui.Position = UDim2.fromOffset(p.X, p.Y)
				gui.Rotation = spinDir * eased * 22
				setSize(size * math.max(0, backOut(t)))
				if t >= 1 then
					break
				end
				RunService.RenderStepped:Wait()
				if gui.Parent == nil then
					done()
					return
				end
			end
			-- 2. a beat in the air (the stagger), then swoop in along an arc
			task.wait(stagger * (i - 1) + 0.04)
			if gui.Parent == nil then
				done()
				return
			end
			local travel = travelMin + math.random() * (travelMax - travelMin)
			-- the path bows out to the side the coin burst towards (a fountain),
			-- or upwards when the flight is mostly sideways
			local chord = Layer.point(to) - burstPoint
			local long = chord.Magnitude > 1
			local perp = if long then Vector2.new(-chord.Y, chord.X).Unit else Vector2.new(0, -1)
			local out = burstPoint - start
			local side = perp:Dot(out)
			if math.abs(side) < 1e-3 or (long and math.abs(chord.Unit.Y) < 0.5) then
				side = -perp.Y
			end
			local bow = perp * (if side < 0 then -1 else 1) * arc * (0.55 + math.random() * 0.6)
			local t1 = os.clock()
			while true do
				local t = math.min(1, (os.clock() - t1) / travel)
				local target = Layer.point(to)
				local control = (burstPoint + target) / 2 + bow
				local eased = 0.35 * t + 0.65 * t * t -- leaves gently, accelerates into the target
				local p = bezier(burstPoint, control, target, eased)
				gui.Position = UDim2.fromOffset(p.X, p.Y)
				gui.Rotation = spinDir * 22 * (1 - eased)
				setSize(size * (1 - 0.42 * eased))
				if t >= 1 then
					break
				end
				RunService.RenderStepped:Wait()
				if gui.Parent == nil then
					break
				end
			end
			done()
		end)
	end
end

-- Twinkling sparkles scattered over a GuiObject until `stop()` is called.
function Particles.sparkles(gui: GuiObject, options: { Color: Color3?, Rate: number?, Size: number? }?): () -> ()
	local opts = options or {}
	local running = true
	task.spawn(function()
		while running and gui.Parent do
			-- nothing to twinkle for while the card is in a closed window (or
			-- while reduced motion is on)
			if Motion.reduced or not Motion.shown(gui) then
				task.wait(0.5)
				continue
			end
			local sparkle = newTwinkle(opts.Color or WHITE)
			sparkle.Name = "Sparkle"
			sparkle.AnchorPoint = Vector2.new(0.5, 0.5)
			sparkle.Position = UDim2.fromScale(0.08 + math.random() * 0.84, 0.08 + math.random() * 0.84)
			sparkle.Size = UDim2.fromOffset(0, 0)
			sparkle.ZIndex = 30
			for _, child in sparkle:GetChildren() do
				if child:IsA("GuiObject") then
					child.ZIndex = 30
				end
			end
			sparkle.Parent = gui
			local size = (opts.Size or 26) * (0.6 + math.random() * 0.6)
			local grow = TweenService:Create(
				sparkle,
				TweenInfo.new(0.4, Enum.EasingStyle.Back, Enum.EasingDirection.Out, 0, true),
				{ Size = UDim2.fromOffset(size, size), Rotation = 45 }
			)
			grow:Play()
			grow.Completed:Once(function()
				sparkle:Destroy()
			end)
			task.wait(1 / (opts.Rate or 3))
		end
	end)
	return function()
		running = false
	end
end

-- The rounded-rect outline of a GuiObject in layer px: centre, size, radius.
local function outlineOf(gui: GuiObject): (Vector2, Vector2, number)
	local centre = Layer.point(gui)
	local size = gui.AbsoluteSize
	local corner = gui:FindFirstChildOfClass("UICorner")
	if corner == nil then
		-- e.g. a Button's body: the rounded face is one level down
		for _, child in gui:GetChildren() do
			local inner = child:FindFirstChildOfClass("UICorner")
			if inner and child:IsA("GuiObject") and child.AbsoluteSize.X >= size.X * 0.9 then
				corner = inner
				break
			end
		end
	end
	local radius = 0
	if corner then
		local r = corner.CornerRadius
		radius = r.Scale * math.min(size.X, size.Y) + r.Offset * Layer.scale()
	elseif size.Y > 0 and size.X / size.Y > 0.8 and size.X / size.Y < 1.25 then
		-- a square box with no corners is an icon's frame: treat it as round
		radius = math.min(size.X, size.Y) / 2
	end
	return centre, size, math.clamp(radius, 0, math.min(size.X, size.Y) / 2)
end

export type PoofOptions = {
	Color: Color3?,
	Colors: { Color3 }?, -- puffs cycle through these (default: Color and a lighter tint)
	Count: number?,
	Size: number?, -- design px
	Reach: number?, -- design px the puffs drift
	Ring: boolean?,
	Sound: string?,
}

-- A soft "poof" cloud (claims, purchases, things vanishing): 7-9 puffs
-- drift outward, swell and fade, with an expanding ring, about 0.4 s. On a
-- GuiObject the puffs burst off its edges and the ring hugs its shape, so
-- the thing itself (a button label) stays readable.
function Particles.poof(at: any, options: PoofOptions?)
	local opts = options or {}
	if opts.Sound then
		Sound.play(opts.Sound)
	end
	if not live() then
		return
	end
	local root = Layer.get()
	local scale = Layer.scale()
	local color = opts.Color or WHITE
	local colors = opts.Colors or { color, color:Lerp(WHITE, 0.55) }
	local size = (opts.Size or 34) * scale
	local reach = (opts.Reach or 60) * scale

	local isGui = typeof(at) == "Instance" and at:IsA("GuiObject")
	local origin: Vector2
	local box = Vector2.zero
	local radius = 0
	if isGui then
		origin, box, radius = outlineOf(at)
		-- a whole-screen frame is just "the middle of the screen"
		if box.X > root.AbsoluteSize.X * 0.9 and box.Y > root.AbsoluteSize.Y * 0.9 then
			box = Vector2.zero
		end
	else
		origin = Layer.point(at)
	end
	local onEdge = box.X > 4 and box.Y > 4
	local count = opts.Count or (if onEdge then 14 else 8)

	local function spawnOne(i: number)
		local angle = (i / count + math.random() * 0.12) * math.pi * 2
		local dir = Vector2.new(math.cos(angle), math.sin(angle))
		local start: Vector2
		if onEdge then
			-- the point where this direction leaves the box (or circle)
			local half = box / 2
			local k = if radius >= math.min(half.X, half.Y) - 0.5
				then math.min(half.X, half.Y)
				else math.min(
					if math.abs(dir.X) > 1e-3 then half.X / math.abs(dir.X) else math.huge,
					if math.abs(dir.Y) > 1e-3 then half.Y / math.abs(dir.Y) else math.huge
				)
			start = origin + dir * k * 0.94
			-- puffs leaving a long button's ends fly sideways, the rest mostly out
			dir = (dir + Vector2.new(0, -0.15)).Unit
		else
			start = origin + dir * (math.random() * 12 * scale)
		end
		local circle = Instance.new("ImageLabel")
		circle.Name = "Poof"
		circle.BackgroundTransparency = 1
		circle.Image = Assets.Circle
		circle.ImageColor3 = colors[(i - 1) % #colors + 1]
		circle.AnchorPoint = Vector2.new(0.5, 0.5)
		circle.ZIndex = 55
		circle.Parent = root
		local life = 0.38 + math.random() * 0.12
		-- alternate big and small puffs so they overlap into a cloud
		local s = size * (if i % 2 == 0 then 1 + math.random() * 0.35 else 0.65 + math.random() * 0.25)
		local drag = 6
		add({
			gui = circle,
			position = start,
			velocity = dir * reach * (0.7 + math.random() * 0.6) * drag,
			rotation = 0,
			spin = 0,
			age = 0,
			life = life,
			gravity = -90 * scale, -- smoke rises a little
			drag = drag,
			size = Vector2.new(s * 0.5, s * 0.5),
			endScale = 1.45 / 0.5,
			fadeFrom = 0.2,
			fadePower = 1.4,
			baseAlpha = 0.05,
			popIn = 0.05,
		})
	end
	request(count, function(allowed)
		for i = 1, allowed do
			spawnOne(i)
		end
	end)
	if opts.Ring ~= false then
		if onEdge then
			Particles.ring(at, { Color = color:Lerp(WHITE, 0.3), Fit = true, Time = 0.3, Thickness = 5 })
		else
			Particles.ring(origin, { Color = color, Size = if opts.Size then opts.Size * 2.4 else 82 })
		end
	end
end

export type RingOptions = {
	Color: Color3?,
	Thickness: number?,
	From: number?, -- start scale
	To: number?, -- end scale
	Time: number?,
	Size: number?, -- design px at scale 1
	Fit: boolean?, -- GuiObject targets: the ring hugs its rounded rectangle and grows past it
	Grow: number?, -- Fit: design px the ring grows beyond the edge (default 26)
}

-- An expanding shockwave ring (hits, level ups, claims).
function Particles.ring(at: any, options: RingOptions?)
	local opts = options or {}
	if not live() then
		return
	end
	local root = Layer.get()
	local scale = Layer.scale()
	local fit = opts.Fit == true and typeof(at) == "Instance" and at:IsA("GuiObject")
	local origin: Vector2
	local fromSize: Vector2
	local toSize: Vector2
	local radius: UDim
	if fit then
		local centre, box, r = outlineOf(at)
		local grow = (opts.Grow or 26) * scale
		origin = centre
		fromSize = box
		toSize = box + Vector2.new(grow * 2, grow * 2)
		radius = UDim.new(0, r + grow * 0.5)
	else
		origin = Layer.point(at)
		local size = (opts.Size or 120) * scale
		fromSize = Vector2.new(size, size) * (opts.From or 0.4)
		toSize = Vector2.new(size, size) * (opts.To or 1.6)
		radius = UDim.new(0.5, 0)
	end
	local ring = Instance.new("Frame")
	ring.Name = "Ring"
	ring.BackgroundTransparency = 1
	ring.AnchorPoint = Vector2.new(0.5, 0.5)
	ring.Position = UDim2.fromOffset(origin.X, origin.Y)
	ring.Size = UDim2.fromOffset(fromSize.X, fromSize.Y)
	ring.ZIndex = 54
	local corner = Instance.new("UICorner")
	corner.CornerRadius = radius
	corner.Parent = ring
	local stroke = Instance.new("UIStroke")
	stroke.Color = opts.Color or WHITE
	stroke.Thickness = (opts.Thickness or 6) * scale
	stroke.Transparency = 0.15
	stroke.Parent = ring
	ring.Parent = root
	capped(liveRings, 40, ring)
	local info = TweenInfo.new(opts.Time or 0.28, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
	TweenService:Create(ring, info, { Size = UDim2.fromOffset(toSize.X, toSize.Y) }):Play()
	local fade = TweenService:Create(stroke, info, { Transparency = 1, Thickness = math.max(1, scale) })
	fade.Completed:Once(function()
		ring:Destroy()
	end)
	fade:Play()
end

--------------------------------------------------------------------------
-- Damage numbers
--------------------------------------------------------------------------

export type DamageOptions = {
	Crit: boolean?,
	Color: Color3?, -- normal hits (default white)
	CritColor: Color3?, -- crits (default the theme's gold)
	Size: number?, -- design px (default 26, crits 1.5x)
	Merge: boolean?, -- hits on the same target within 0.3 s add up (default true)
	Key: any?, -- what counts as "the same target" (default: `at` when it's an Instance)
}

type DamageEntry = {
	label: TextLabel,
	stroke: UIStroke,
	uiScale: UIScale,
	gradient: UIGradient?,
	origin: Vector2,
	age: number,
	popAge: number,
	life: number,
	peak: number,
	crit: boolean,
	total: number?,
	key: any,
	lastHit: number,
}

local numbers: { DamageEntry } = {}
local byKey: { [any]: DamageEntry } = {}
local numbersConnection: RBXScriptConnection? = nil
local RISE, RISE_TIME, FADE = 28, 0.6, 0.25

local function stepNumbers(dt: number)
	local scale = Layer.scale()
	for index = #numbers, 1, -1 do
		local e = numbers[index]
		e.age += dt
		e.popAge += dt
		if e.age >= e.life or e.label.Parent == nil then
			e.label:Destroy()
			table.remove(numbers, index)
			if e.key ~= nil and byKey[e.key] == e then
				byKey[e.key] = nil
			end
		else
			local r = math.min(1, e.age / RISE_TIME)
			local rise = RISE * scale * (1 - (1 - r) ^ 2)
			e.label.Position = UDim2.fromOffset(e.origin.X, e.origin.Y - rise)
			-- pop: normal hits snap 1.3 -> 1 in 0.08 s; crits slam in with a wobble
			if e.crit then
				local k = math.min(1, e.popAge / 0.22)
				e.uiScale.Scale = 1 + (e.peak - 1) * (1 - k) ^ 2 * math.cos(k * math.pi * 2.2)
			else
				local k = math.min(1, e.popAge / 0.08)
				e.uiScale.Scale = 1 + (e.peak - 1) * (1 - k) ^ 2
			end
			local left = e.life - e.age
			local alpha = if left < FADE then 1 - left / FADE else 0
			e.label.TextTransparency = alpha
			e.stroke.Transparency = alpha
		end
	end
	if #numbers == 0 and numbersConnection then
		numbersConnection:Disconnect()
		numbersConnection = nil
	end
end

local function styleNumber(e: DamageEntry, crit: boolean, opts: DamageOptions)
	local theme = Theme.get()
	local scale = Layer.scale()
	local base = (opts.Size or 26) * scale
	e.crit = crit
	if crit then
		local gold = opts.CritColor or theme.Tone.Yellow.Top
		e.label.TextSize = math.floor(base * 1.5)
		e.label.TextColor3 = WHITE
		e.label.Rotation = (math.random() - 0.5) * 14
		e.stroke.Color = tinted(theme.Tone.Orange and theme.Tone.Orange.Bottom or gold)
		e.stroke.Thickness = math.max(1.5, 3 * scale)
		local gradient = e.gradient
		if gradient == nil then
			local created = Instance.new("UIGradient")
			created.Rotation = 90
			created.Parent = e.label
			gradient = created
			e.gradient = created
		end
		gradient.Color = ColorSequence.new({
			ColorSequenceKeypoint.new(0, gold:Lerp(WHITE, 0.65)),
			ColorSequenceKeypoint.new(0.5, gold),
			ColorSequenceKeypoint.new(1, gold:Lerp(Color3.fromHex("FF7A00"), 0.45)),
		})
		e.peak = 1.9
	else
		e.label.TextSize = math.floor(base)
		e.label.TextColor3 = opts.Color or WHITE
		e.stroke.Color = if opts.Color then tinted(opts.Color) else theme.Color.Outline
		e.stroke.Thickness = math.max(1.2, 2.2 * scale)
		e.peak = 1.3
	end
end

local function numberText(amount: any, crit: boolean): string
	local text = if typeof(amount) == "number" then tostring(math.floor(amount + 0.5)) else tostring(amount)
	return if crit then text .. "!" else text
end

-- A damage number: pops in, rises ~28 px and fades (docs/RESEARCH.md 2.9).
-- Crits are 1.5x bigger, gold, tilted and slam in with a twinkle. Numeric
-- hits on the same target within 0.3 s merge into one number that re-pops.
-- Returns nil (and shows nothing) while Particles.damageNumbers is false.
function Particles.damageNumber(at: any, amount: any, options: DamageOptions?): TextLabel?
	if Particles.damageNumbers == false then
		return nil
	end
	local opts = options or {}
	local crit = opts.Crit == true
	local key = opts.Key
	if key == nil and typeof(at) == "Instance" then
		key = at
	end
	local now = os.clock()

	-- merge into the number already rising over this target
	if opts.Merge ~= false and key ~= nil and typeof(amount) == "number" then
		local e = byKey[key]
		if e and e.label.Parent and e.total and now - e.lastHit < 0.3 then
			e.total += amount
			e.lastHit = now
			if crit and not e.crit then
				styleNumber(e, true, opts)
			end
			e.label.Text = numberText(e.total, e.crit)
			e.popAge = 0
			e.peak = if e.crit then 1.6 else 1.3
			e.life = math.max(e.life, e.age + RISE_TIME * 0.75 + FADE)
			return e.label
		end
	end

	local root = Layer.get()
	local scale = Layer.scale()
	local jitter = 12 * scale
	local origin = Layer.point(at) + Vector2.new((math.random() - 0.5) * 2 * jitter, 0)

	local label = Instance.new("TextLabel")
	label.Name = if crit then "CritNumber" else "DamageNumber"
	label.AnchorPoint = Vector2.new(0.5, 0.5)
	label.BackgroundTransparency = 1
	label.Size = UDim2.fromOffset(320 * scale, 80 * scale)
	label.Position = UDim2.fromOffset(origin.X, origin.Y)
	label.FontFace = Theme.font(Theme.get(), "Display")
	label.ZIndex = if crit then 62 else 61
	local stroke = Instance.new("UIStroke")
	stroke.LineJoinMode = Enum.LineJoinMode.Round
	stroke.Parent = label
	local uiScale = Instance.new("UIScale")
	uiScale.Parent = label

	local entry: DamageEntry = {
		label = label,
		stroke = stroke,
		uiScale = uiScale,
		gradient = nil,
		origin = origin,
		age = 0,
		popAge = 0,
		life = RISE_TIME + FADE * 0.6,
		peak = 1.3,
		crit = crit,
		total = if typeof(amount) == "number" then amount else nil,
		key = key,
		lastHit = now,
	}
	styleNumber(entry, crit, opts)
	label.Text = numberText(amount, crit)
	uiScale.Scale = entry.peak
	label.Parent = root

	if not live() then
		-- edit mode / plugin preview: nothing animates, so don't leave it behind
		task.defer(function()
			label:Destroy()
		end)
		return label
	end

	-- at most 60 numbers on screen: the oldest go first
	while #numbers >= 60 do
		local oldest = table.remove(numbers, 1)
		if oldest then
			oldest.label:Destroy()
			if oldest.key ~= nil and byKey[oldest.key] == oldest then
				byKey[oldest.key] = nil
			end
		end
	end
	table.insert(numbers, entry)
	if key ~= nil then
		byKey[key] = entry
	end
	if numbersConnection == nil then
		numbersConnection = RunService.RenderStepped:Connect(stepNumbers)
	end
	if crit then
		local gold = opts.CritColor or Theme.get().Tone.Yellow.Top
		Particles.burst(origin, {
			Shape = "sparkle",
			Count = 5,
			Speed = 260,
			Size = 18,
			Gravity = 0,
			Drag = 4,
			Lifetime = 0.4,
			Colors = { WHITE, gold },
		})
	end
	return label
end

return Particles
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="161">
            <Properties>
              <string name="Name">Screen</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Full-screen effects: flashes, fades, damage, camera shake, flicker,
	jumpscares and big announcement text.

	With reduced motion on (UI.Motion.reduced) flash, cameraShake and flicker
	do nothing, damage is a soft edge tint, and announce fades its words in
	instead of slamming them down.
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local Workspace = game:GetService("Workspace")

local Theme = require(script.Parent.Parent.Theme)
local Icons = require(script.Parent.Parent.Icons)
local Assets = require(script.Parent.Parent.Assets)
local Sound = require(script.Parent.Parent.Util.Sound)
local Color = require(script.Parent.Parent.Util.Color)
local Layer = require(script.Parent.Layer)
local Particles = require(script.Parent.Particles)
local Motion = require(script.Parent.Motion)

local Screen = {}

-- Roblox caps TextSize (and TextScaled) at 100 px. Text that must be bigger
-- is laid out at size / factor with a UIScale of `factor` on the label.
local function bigText(px: number): number
	return math.max(1, px / 100)
end

local function upscale(gui: GuiObject, factor: number)
	if factor > 1 then
		local scale = Instance.new("UIScale")
		scale.Name = "BigText"
		scale.Scale = factor
		scale.Parent = gui
	end
end

local function overlay(name: string, zIndex: number): Frame
	local frame = Instance.new("Frame")
	frame.Name = name
	frame.BackgroundColor3 = Color3.new(0, 0, 0)
	frame.BackgroundTransparency = 1
	frame.BorderSizePixel = 0
	frame.Size = UDim2.fromScale(1, 1)
	frame.ZIndex = zIndex
	frame.Parent = Layer.get()
	return frame
end

-- Full-screen colour flash (camera flash, hits, lightning).
function Screen.flash(color: Color3?, duration: number?, intensity: number?)
	if Motion.reduced then
		return
	end
	local frame = overlay("Flash", 90)
	frame.BackgroundColor3 = color or Color3.new(1, 1, 1)
	frame.BackgroundTransparency = 1 - (intensity or 0.7)
	local tween = TweenService:Create(
		frame,
		TweenInfo.new(duration or 0.35, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
		{ BackgroundTransparency = 1 }
	)
	tween.Completed:Once(function()
		frame:Destroy()
	end)
	tween:Play()
end

local fader: Frame? = nil

local function getFader(): Frame
	if fader == nil or fader.Parent == nil then
		fader = overlay("Fader", 100)
	end
	return fader :: Frame
end

-- Fades the screen to a colour (default black). Yields until done.
function Screen.fadeOut(duration: number?, color: Color3?)
	local frame = getFader()
	frame.BackgroundColor3 = color or Color3.new(0, 0, 0)
	local tween = TweenService:Create(frame, TweenInfo.new(duration or 0.4), { BackgroundTransparency = 0 })
	tween:Play()
	tween.Completed:Wait()
end

-- Fades back in from Screen.fadeOut. Yields until done.
function Screen.fadeIn(duration: number?)
	local frame = getFader()
	local tween = TweenService:Create(frame, TweenInfo.new(duration or 0.4), { BackgroundTransparency = 1 })
	tween:Play()
	tween.Completed:Wait()
end

-- Fade out, run `callback` (teleport the player, swap maps...), fade in.
function Screen.transition(callback: () -> (), options: { Duration: number?, Hold: number?, Color: Color3? }?)
	local opts = options or {}
	Screen.fadeOut(opts.Duration, opts.Color)
	callback()
	if opts.Hold then
		task.wait(opts.Hold)
	end
	Screen.fadeIn(opts.Duration)
end

local damageImage: ImageLabel? = nil

-- Red edges that flash when the player gets hurt.
function Screen.damage(intensity: number?, color: Color3?)
	if damageImage == nil or damageImage.Parent == nil then
		local image = Instance.new("ImageLabel")
		image.Name = "Damage"
		image.BackgroundTransparency = 1
		image.Image = Assets.Vignette
		image.Size = UDim2.fromScale(1, 1)
		image.ZIndex = 80
		image.ImageTransparency = 1
		image.Parent = Layer.get()
		damageImage = image
	end
	local image = damageImage :: ImageLabel
	image.ImageColor3 = color or Theme.get().Color.Bad
	-- reduced motion: a soft tint that fades slowly instead of a flash
	local calm = Motion.reduced
	image.ImageTransparency = math.clamp(1 - (intensity or 1) * (if calm then 0.45 else 1), 0, 1)
	TweenService:Create(
		image,
		TweenInfo.new(if calm then 1.1 else 0.7, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
		{ ImageTransparency = 1 }
	):Play()
end

-- Camera shake using "trauma" (Eiserloh): overlapping shakes add up, the
-- visible shake is trauma^2, and it decays to zero over `duration`.
local trauma = 0
local traumaDecay = 1.5
local shakeBound = false
local SHAKE_NAME = "BloxUICameraShake"
local MAX_ANGLE = math.rad(5)

-- Named intensities (docs/RESEARCH.md 8.5): purchase 0, level up 0.25,
-- legendary reveal 0.35, a nearby explosion 0.5.
Screen.SHAKES = {
	Tiny = 0.08,
	Light = 0.15,
	Medium = 0.25,
	Heavy = 0.35,
	Huge = 0.5,
	Explosion = 0.6,
}

-- the rotation we added last frame, and the CFrame it produced
local appliedAngles: Vector3? = nil
local producedCFrame: CFrame? = nil

local function angles(v: Vector3): CFrame
	return CFrame.Angles(v.X, 0, 0) * CFrame.Angles(0, v.Y, 0) * CFrame.Angles(0, 0, v.Z)
end

local function inverseAngles(v: Vector3): CFrame
	return CFrame.Angles(0, 0, -v.Z) * CFrame.Angles(0, -v.Y, 0) * CFrame.Angles(-v.X, 0, 0)
end

-- Removes last frame's shake if nothing else moved the camera since
-- (scriptable cameras would otherwise drift).
local function unshake(camera: Camera): CFrame
	local base = camera.CFrame
	if appliedAngles and producedCFrame and base == producedCFrame then
		base = base * inverseAngles(appliedAngles)
	end
	appliedAngles, producedCFrame = nil, nil
	return base
end

-- Shakes the camera. `intensity` is trauma 0..1 or a name from
-- Screen.SHAKES ("Light", "Medium", "Heavy", "Huge"...); the shake fades out
-- over `duration` seconds. Does nothing without a LocalPlayer (so a Studio
-- preview never shakes the editor camera).
function Screen.cameraShake(intensity: (number | string)?, duration: number?)
	if Players.LocalPlayer == nil or Motion.reduced then
		return
	end
	local amount = if typeof(intensity) == "string"
		then Screen.SHAKES[intensity] or Screen.SHAKES.Medium
		else intensity or 0.5
	if amount <= 0 then
		return
	end
	trauma = math.min(1, trauma + amount)
	traumaDecay = trauma / math.max(0.05, duration or 0.5)
	if shakeBound then
		return
	end
	shakeBound = true
	local seed = math.random() * 1000
	RunService:BindToRenderStep(SHAKE_NAME, Enum.RenderPriority.Camera.Value + 1, function(dt)
		local camera = Workspace.CurrentCamera
		trauma = math.max(0, trauma - traumaDecay * dt)
		if trauma <= 0 or camera == nil then
			if camera then
				camera.CFrame = unshake(camera)
			end
			RunService:UnbindFromRenderStep(SHAKE_NAME)
			shakeBound = false
			return
		end
		local base = unshake(camera)
		local amount2 = trauma * trauma
		local t = os.clock() * 25
		local offset = Vector3.new(
			math.noise(seed, t) * MAX_ANGLE * amount2,
			math.noise(seed + 10, t) * MAX_ANGLE * amount2,
			math.noise(seed + 20, t) * MAX_ANGLE * amount2 * 0.5
		)
		local shaken = base * angles(offset)
		camera.CFrame = shaken
		appliedAngles, producedCFrame = offset, shaken
	end)
end

-- Lights-failing flicker: the screen stutters dark for `duration` seconds.
function Screen.flicker(duration: number?, darkness: number?)
	if Motion.reduced then
		return
	end
	local frame = overlay("Flicker", 85)
	local length = duration or 1.2
	local dark = darkness or 0.85
	task.spawn(function()
		local start = os.clock()
		while os.clock() - start < length do
			frame.BackgroundTransparency = if math.random() < 0.45 then 1 - dark * (0.6 + math.random() * 0.4) else 1
			task.wait(0.03 + math.random() * 0.09)
		end
		frame:Destroy()
	end)
end

export type AnnounceOptions = {
	Subtitle: string?,
	Icon: any?,
	Tone: any?, -- gradient on the title, e.g. "Yellow"
	Duration: number?,
	Burst: boolean?,
	Shake: boolean?,
	Sound: string?,
	Position: UDim2?,
	Banner: boolean?, -- dark band behind the words (default true)
}

-- Big slam-in title text: "LEVEL UP!", "WAVE 3", "YOU DIED", "Find the key".
function Screen.announce(text: string, options: AnnounceOptions?)
	local opts = options or {}
	local theme = Theme.get()
	local scale = Layer.scale()
	local tone = if opts.Tone then Theme.tone(theme, opts.Tone) else nil
	-- reduced motion: the words fade in where they land, no slam or flash
	local calm = Motion.reduced

	local holder = Instance.new("Frame")
	holder.Name = "Announce"
	holder.BackgroundTransparency = 1
	holder.AnchorPoint = Vector2.new(0.5, 0.5)
	holder.Position = opts.Position or UDim2.fromScale(0.5, 0.32)
	holder.Size = UDim2.fromOffset(1400 * scale, 260 * scale)
	holder.ZIndex = 95

	local uiScale = Instance.new("UIScale")
	uiScale.Scale = if calm then 1 else 2.4
	uiScale.Parent = holder

	local list = Instance.new("UIListLayout")
	list.HorizontalAlignment = Enum.HorizontalAlignment.Center
	list.VerticalAlignment = Enum.VerticalAlignment.Center
	list.SortOrder = Enum.SortOrder.LayoutOrder
	list.Padding = UDim.new(0, 4 * scale)
	list.Parent = holder

	local fading: { Instance } = {}

	if opts.Icon then
		local kind, value = Icons.resolve(opts.Icon)
		local iconFactor = bigText(96 * scale)
		local icon = Instance.new("TextLabel")
		icon.Name = "Icon"
		icon.LayoutOrder = 0
		icon.BackgroundTransparency = 1
		icon.Size = UDim2.fromOffset(96 * scale / iconFactor, 96 * scale / iconFactor)
		icon.FontFace = if kind == "glyph" then Icons.font(true) else theme.Font.Display
		icon.Text = value
		icon.TextScaled = true
		icon.TextColor3 = if tone then tone.Top else theme.Color.Accent
		icon.ZIndex = 96
		local stroke = Instance.new("UIStroke")
		stroke.Color = if tone then tone.Stroke or tone.Edge else theme.Color.Outline
		stroke.LineJoinMode = Enum.LineJoinMode.Round
		stroke.Thickness = theme.Stroke.Text * 1.6 * scale / iconFactor
		stroke.Parent = icon
		upscale(icon, iconFactor)
		icon.Parent = holder
		table.insert(fading, icon)
		table.insert(fading, stroke)
	end

	-- Roblox caps TextSize at 100: bigger titles render at 100 and scale up
	local titleFactor = bigText(104 * scale)
	local titleSize = 104 * scale / titleFactor
	local title = Instance.new("TextLabel")
	title.Name = "Title"
	title.LayoutOrder = 1
	title.BackgroundTransparency = 1
	title.AutomaticSize = Enum.AutomaticSize.XY
	title.Size = UDim2.fromOffset(0, 0)
	title.FontFace = theme.Font.Title
	title.Text = text
	title.TextSize = titleSize
	title.TextColor3 = if tone then Color3.new(1, 1, 1) else theme.Color.Text
	title.ZIndex = 96
	local titleStroke = Instance.new("UIStroke")
	titleStroke.Color = if tone and tone.Stroke then tone.Stroke else theme.Color.Outline
	titleStroke.LineJoinMode = Enum.LineJoinMode.Round
	titleStroke.Thickness = theme.Stroke.Text * 2.2 * scale / titleFactor
	titleStroke.Parent = title
	upscale(title, titleFactor)
	if tone then
		local gradient = Instance.new("UIGradient")
		gradient.Rotation = 90
		gradient.Color = ColorSequence.new({
			ColorSequenceKeypoint.new(0, Color.lighten(tone.Top, 0.5)),
			ColorSequenceKeypoint.new(0.5, tone.Top),
			ColorSequenceKeypoint.new(1, tone.Bottom),
		})
		gradient.Parent = title
	end
	-- impact flash: a white copy of the word that fades as it lands
	local hit = Instance.new("TextLabel")
	hit.Name = "HitFlash"
	hit.BackgroundTransparency = 1
	hit.Size = UDim2.fromScale(1, 1)
	hit.FontFace = theme.Font.Title
	hit.Text = text
	hit.TextSize = titleSize
	hit.TextColor3 = Color3.new(1, 1, 1)
	hit.TextTransparency = 1
	hit.ZIndex = 97
	hit.Parent = title
	title.Parent = holder
	table.insert(fading, title)
	table.insert(fading, titleStroke)

	if opts.Subtitle then
		local subtitle = Instance.new("TextLabel")
		subtitle.Name = "Subtitle"
		subtitle.LayoutOrder = 2
		subtitle.BackgroundTransparency = 1
		subtitle.AutomaticSize = Enum.AutomaticSize.XY
		subtitle.Size = UDim2.fromOffset(0, 0)
		subtitle.FontFace = theme.Font.Body
		subtitle.Text = opts.Subtitle
		local subFactor = bigText(34 * scale)
		subtitle.TextSize = 34 * scale / subFactor
		subtitle.TextColor3 = theme.Color.Text
		subtitle.ZIndex = 96
		local stroke = Instance.new("UIStroke")
		stroke.Color = theme.Color.Outline
		stroke.LineJoinMode = Enum.LineJoinMode.Round
		stroke.Thickness = theme.Stroke.Text * scale / subFactor
		stroke.Parent = subtitle
		upscale(subtitle, subFactor)
		subtitle.Parent = holder
		table.insert(fading, subtitle)
		table.insert(fading, stroke)
	end

	-- a dark band behind the words that sweeps open (reads on any background)
	local band: Frame? = nil
	if opts.Banner ~= false then
		local bandHeight = (104 * scale * 1.25 + (if opts.Icon then 100 * scale else 0) + (if opts.Subtitle then 46 * scale else 0) + 36 * scale)
		-- a neutral dark, only faintly tinted, so warm tones don't turn muddy
		local dark = Color.darken(theme.Color.Surface, 0.2)
		if tone then
			dark = dark:Lerp(tone.Deep, 0.25)
		end
		local newBand = Instance.new("Frame")
		newBand.Name = "AnnounceBand"
		newBand.AnchorPoint = Vector2.new(0.5, 0.5)
		newBand.Position = holder.Position
		newBand.Size = UDim2.new(0, 0, 0, bandHeight)
		newBand.BackgroundColor3 = dark
		newBand.BackgroundTransparency = 0.3
		newBand.BorderSizePixel = 0
		newBand.ZIndex = 45 -- under the confetti (50+), over most game UI effects
		local fadeEdges = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 1),
			NumberSequenceKeypoint.new(0.22, 0),
			NumberSequenceKeypoint.new(0.78, 0),
			NumberSequenceKeypoint.new(1, 1),
		})
		local gradient = Instance.new("UIGradient")
		gradient.Transparency = fadeEdges
		gradient.Parent = newBand
		for _, edge in { 0, 1 } do
			local line = Instance.new("Frame")
			line.Name = "Edge"
			line.AnchorPoint = Vector2.new(0, edge)
			line.Position = UDim2.fromScale(0, edge)
			line.Size = UDim2.new(1, 0, 0, math.max(2, 3 * scale))
			line.BackgroundColor3 = if tone then tone.Top else theme.Color.Accent
			line.BorderSizePixel = 0
			line.ZIndex = 45
			local lineGradient = Instance.new("UIGradient")
			lineGradient.Transparency = fadeEdges
			lineGradient.Parent = line
			line.Parent = newBand
		end
		newBand.Parent = Layer.get()
		if calm then
			newBand.Size = UDim2.new(0, 1500 * scale, 0, bandHeight)
		else
			TweenService:Create(newBand, TweenInfo.new(0.32, Enum.EasingStyle.Quart, Enum.EasingDirection.Out), {
				Size = UDim2.new(0, 1500 * scale, 0, bandHeight),
			}):Play()
		end
		band = newBand
	end

	holder.Parent = Layer.get()
	Sound.play(opts.Sound or "Reward")

	if calm then
		local fadeIn = TweenInfo.new(0.2, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
		for _, item in fading do
			if item:IsA("TextLabel") then
				item.TextTransparency = 1
				TweenService:Create(item, fadeIn, { TextTransparency = 0 }):Play()
			elseif item:IsA("UIStroke") then
				item.Transparency = 1
				TweenService:Create(item, fadeIn, { Transparency = 0 }):Play()
			end
		end
	else
		TweenService:Create(uiScale, TweenInfo.new(0.42, Enum.EasingStyle.Back, Enum.EasingDirection.Out), { Scale = 1 }):Play()
	end
	-- the moment it lands: white flash, shake, burst
	task.delay(0.12, function()
		if holder.Parent == nil then
			return
		end
		if not calm then
			hit.TextTransparency = 0.1
			TweenService:Create(hit, TweenInfo.new(0.28, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), { TextTransparency = 1 }):Play()
		end
		if opts.Shake ~= false then
			Screen.cameraShake(0.3, 0.35)
		end
		if opts.Burst then
			local colors = if tone
				then { tone.Top, Color.lighten(tone.Top, 0.5), theme.Color.Accent, Color3.new(1, 1, 1) }
				else nil
			-- the title's centre (the holder is laid out icon / title / subtitle)
			local at = Layer.point(holder)
				+ Vector2.new(0, ((if opts.Icon then 50 else 0) - (if opts.Subtitle then 20 else 0)) * scale)
			Particles.burst(at, { Shape = "mix", Count = 42, Speed = 900, Size = 16, Colors = colors })
			Particles.ring(at, { Size = 300, From = 0.3, To = 2.2, Time = 0.4, Thickness = 8, Color = if tone then Color.lighten(tone.Top, 0.3) else nil })
		end
	end)

	task.delay(opts.Duration or 1.8, function()
		local info = TweenInfo.new(0.4, Enum.EasingStyle.Quad, Enum.EasingDirection.In)
		for _, item in fading do
			if item:IsA("TextLabel") then
				TweenService:Create(item, info, { TextTransparency = 1 }):Play()
			elseif item:IsA("UIStroke") then
				TweenService:Create(item, info, { Transparency = 1 }):Play()
			end
		end
		if band then
			TweenService:Create(band, info, { BackgroundTransparency = 1, Size = if calm then band.Size else UDim2.new(0, 1500 * scale, 0, 0) }):Play()
			for _, line in band:GetChildren() do
				if line:IsA("Frame") then
					TweenService:Create(line, info, { BackgroundTransparency = 1 }):Play()
				end
			end
		end
		if not calm then
			TweenService:Create(uiScale, info, { Scale = 0.92 }):Play()
		end
		local rise = TweenService:Create(holder, info, { Position = holder.Position - UDim2.fromOffset(0, if calm then 0 else 40 * scale) })
		rise:Play()
		rise.Completed:Wait()
		holder:Destroy()
		if band then
			band:Destroy()
		end
	end)
end

export type JumpscareOptions = {
	Image: any?, -- your scary image id, or an icon name
	Color: Color3?,
	Sound: string?,
	Duration: number?,
}

-- The default scare: the lights die and two glaring red slit eyes lunge out
-- of the dark (inner corners low: a glare, not a cartoon stare).
local function eyesScare(opts: JumpscareOptions)
	local root = Layer.get()
	local scale = Layer.scale()
	local dark = overlay("JumpscareDark", 86) -- under the red flash (90)
	dark.BackgroundTransparency = 0
	local red = opts.Color or Color3.fromRGB(170, 4, 12)
	local parts: { ImageLabel } = {}
	local eyes: { Frame } = {}
	for index, x in { 0.43, 0.57 } do
		local eye = Instance.new("Frame")
		eye.Name = `ScareEye{index}`
		eye.BackgroundTransparency = 1
		eye.AnchorPoint = Vector2.new(0.5, 0.5)
		eye.Position = UDim2.fromScale(x, 0.45)
		eye.Size = UDim2.fromOffset(300 * scale, 180 * scale)
		eye.Rotation = if index == 1 then 15 else -15
		eye.ZIndex = 87
		local lunge = Instance.new("UIScale")
		lunge.Name = "Lunge"
		lunge.Scale = 0.3
		lunge.Parent = eye
		local function image(name: string, texture: string, color: Color3, size: UDim2, position: UDim2, z: number)
			local part = Instance.new("ImageLabel")
			part.Name = name
			part.BackgroundTransparency = 1
			part.Image = texture
			part.ImageColor3 = color
			part.AnchorPoint = Vector2.new(0.5, 0.5)
			part.Position = position
			part.Size = size
			part.ZIndex = z
			part.Parent = eye
			table.insert(parts, part)
		end
		image("Glow", Assets.Glow, red:Lerp(Color3.fromRGB(255, 30, 40), 0.3), UDim2.fromScale(1.6, 1.4), UDim2.fromScale(0.5, 0.5), 87)
		image("Iris", Assets.Circle, red, UDim2.fromScale(0.5, 0.36), UDim2.fromScale(0.5, 0.55), 88)
		-- a thin vertical slit, hot white
		image("Pupil", Assets.Circle, Color3.fromRGB(255, 244, 214), UDim2.fromScale(0.045, 0.3), UDim2.fromScale(0.5, 0.55), 89)
		eye.Parent = root
		table.insert(eyes, eye)
		TweenService:Create(lunge, TweenInfo.new(0.16, Enum.EasingStyle.Back, Enum.EasingDirection.Out), { Scale = 1.7 }):Play()
	end
	Sound.play(opts.Sound or "Error", { Volume = 2 })
	Screen.flash(Color3.fromRGB(150, 0, 8), 0.45, 0.7)
	Screen.cameraShake(1, 0.8)
	task.delay(opts.Duration or 0.75, function()
		local info = TweenInfo.new(0.4, Enum.EasingStyle.Quad, Enum.EasingDirection.In)
		for _, part in parts do
			TweenService:Create(part, info, { ImageTransparency = 1 }):Play()
		end
		task.wait(0.35)
		TweenService:Create(dark, TweenInfo.new(0.5), { BackgroundTransparency = 1 }):Play()
		task.wait(0.5)
		for _, eye in eyes do
			eye:Destroy()
		end
		dark:Destroy()
	end)
end

-- Full-screen jumpscare. With no `Image`, two red slit eyes lunge out of the
-- dark with a red flash and a heavy camera shake. Pass your own `Image`
-- (rbxassetid, or an icon name) for a custom one.
function Screen.jumpscare(options: JumpscareOptions?)
	local opts = options or {}
	if opts.Image == nil or opts.Image == "eyes" then
		eyesScare(opts)
		return
	end
	local theme = Theme.get()
	local kind, value = Icons.resolve(opts.Image)

	local gui: GuiObject
	if kind == "image" then
		local image = Instance.new("ImageLabel")
		image.Image = value
		image.ScaleType = Enum.ScaleType.Crop
		image.BackgroundTransparency = 1
		gui = image
	else
		local label = Instance.new("TextLabel")
		label.FontFace = if kind == "glyph" then Icons.font(true) else theme.Font.Title
		label.Text = value
		label.TextScaled = true
		label.TextColor3 = opts.Color or theme.Color.Bad
		label.BackgroundTransparency = 1
		gui = label
	end
	gui.Name = "Jumpscare"
	gui.AnchorPoint = Vector2.new(0.5, 0.5)
	gui.Position = UDim2.fromScale(0.5, 0.5)
	gui.ZIndex = 99
	local fit = 1
	if gui:IsA("TextLabel") then
		-- TextScaled stops at 100 px: lay the glyph out at 100 and scale it up
		local screen = Layer.get().AbsoluteSize
		fit = math.max(1, math.min(screen.X * 0.7, screen.Y * 0.9) / 100)
		gui.Size = UDim2.fromOffset(100, 100)
	else
		gui.Size = UDim2.fromScale(0.7, 0.9)
	end

	local backdrop = overlay("JumpscareBackdrop", 98)
	backdrop.BackgroundTransparency = 0.15

	local uiScale = Instance.new("UIScale")
	uiScale.Scale = 0.5 * fit
	uiScale.Parent = gui
	gui.Parent = Layer.get()

	Sound.play(opts.Sound or "Error", { Volume = 2 })
	Screen.flash(opts.Color or theme.Color.Bad, 0.5, 0.8)
	Screen.cameraShake(1, 0.8)
	TweenService:Create(uiScale, TweenInfo.new(0.18, Enum.EasingStyle.Back, Enum.EasingDirection.Out), { Scale = 1.5 * fit }):Play()

	task.delay(opts.Duration or 0.9, function()
		local info = TweenInfo.new(0.35)
		TweenService:Create(backdrop, info, { BackgroundTransparency = 1 }):Play()
		if gui:IsA("ImageLabel") then
			TweenService:Create(gui, info, { ImageTransparency = 1 }):Play()
		elseif gui:IsA("TextLabel") then
			TweenService:Create(gui, info, { TextTransparency = 1 }):Play()
		end
		task.wait(0.4)
		gui:Destroy()
		backdrop:Destroy()
	end)
end

return Screen
]]></string>
            </Properties>
          </Item>
        </Item>
        <Item class="ModuleScript" referent="162">
          <Properties>
            <string name="Name">Icons</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Icons

	BloxUI icons come from "Builder Icons", a vector icon font that ships inside
	every Roblox client. No uploads, no asset IDs, crisp at any size, and they
	can be recoloured, outlined and gradient-filled like text.

	Anywhere a component takes an `Icon` you can pass:
		"key"                      a Builder Icons name (771 of them, see Icons.names)
		"shop"                     a friendly alias (see Icons.alias below)
		"rbxassetid://123456"      your own uploaded image
		123456                     same, as a number
		"🔑"                       an emoji / unicode character

	The font is read from a path inside the Roblox client. The first time
	icons are used on a client, Icons.verify() checks (in the background)
	that it loads on this device and otherwise switches every icon to
	Roblox's published fallback copy (Assets.IconFontFallback). Components
	re-resolve by themselves; in your own Computeds pass `use` so they do too:

		FontFace = scope:Computed(function(use) return UI.Icons.font(true, use) end)

	Icons.setFont("rbxassetid://...") switches to your own icon font.
]]

local ContentProvider = game:GetService("ContentProvider")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

local Fusion = require(script.Parent.Packages.Fusion)
local Assets = require(script.Parent.Assets)

local Icons = {}

local scope = Fusion.scoped(Fusion)

-- The icon font family in use, as a Fusion Value (changes if the fallback
-- font takes over, or after Icons.setFont).
Icons.family = scope:Value(Assets.IconFont)

-- True once Icons.verify() has switched to Assets.IconFontFallback.
Icons.usingFallback = false

-- Friendly names you can use instead of the raw Builder Icons names.
-- Add your own: Icons.alias.pet = "parrot"
Icons.alias = {
	-- navigation
	close = "x-small",
	x = "x-small",
	plus = "plus-large",
	add = "plus-large",
	minus = "minus",
	check = "check-large",
	back = "chevron-large-left",
	left = "chevron-large-left",
	right = "chevron-large-right",
	next = "chevron-large-right",
	up = "chevron-large-up",
	down = "chevron-large-down",
	menu = "three-bars-horizontal",
	more = "three-dots-horizontal",
	home = "house",
	search = "magnifying-glass",
	settings = "gear",
	edit = "pencil",
	trash = "trash-can",
	info = "circle-i",
	help = "circle-question",
	warning = "triangle-exclamation",
	success = "circle-check",
	error = "circle-x",
	lock = "lock-closed",
	play = "play-large",
	pause = "pause-large",

	-- shop & economy
	shop = "shopping-basket",
	cart = "shopping-cart",
	gift = "gift-box",
	daily = "calendar-star",
	calendar = "calendar",
	bundle = "file-box",
	skins = "tshirt",
	coin = "circle-dollar-sign",
	coins = "circle-dollar-sign",
	cash = "wallet",
	gem = "diamond",
	gems = "diamond",
	robux = "robux",
	premium = "premium",
	sale = "tag-sparkle",
	tag = "tag",

	-- rewards & progression
	trophy = "trophy",
	crown = "crown",
	medal = "rosette-seven-point",
	quest = "clipboard-pencil",
	tasks = "clipboard-pencil",
	level = "arrow-progress-up",
	upgrade = "arrow-large-up",
	boost = "double-chevron-large-up",
	rebirth = "arrow-spin-clockwise",
	timer = "clock",
	time = "clock",
	energy = "lightning-bolt",
	bolt = "lightning-bolt",
	fire = "flame",
	health = "heart",
	shield = "shield-check",
	sparkle = "cursor-stars",

	-- combat / fps
	target = "crosshairs",
	crosshair = "crosshairs",
	ammo = "bullet-flying",
	melee = "sword",
	grenade = "hexagon-dot",
	run = "person-running",
	sprint = "person-running",
	crouch = "person-crouching",
	jump = "person-jumping",

	-- horror / atmosphere
	night = "moon",
	eye = "eye",
	hide = "eye-slash",
	door = "door-open-arrow-to-bottom-right",
	hand = "hand-grip",
	look = "binoculars",
	map = "location-pin-map",
	objective = "flag",

	-- simulator
	pet = "parrot",
	pets = "parrot",
	egg = "egg",
	farm = "wheat",
	fish = "fish",
	backpack = "backpack",
	world = "globe-simplified",

	-- social / system
	friends = "two-people",
	player = "person",
	chat = "speech-bubble-round",
	mail = "envelope",
	bell = "bell",
	sound = "speaker",
	mute = "speaker-slash",
	music = "music-note",
	controller = "controller",
}

Icons.names = {} :: { [string]: boolean }
Icons.list = {} :: { string }
for name in string.gmatch(require(script.Names), "%S+") do
	Icons.names[name] = true
	table.insert(Icons.list, name)
end

local fontCache: { [string]: Font } = {}

-- Starts Icons.verify() once, in the background, on a running client.
local verifyStarted = false
local function autoVerify()
	if verifyStarted then
		return
	end
	verifyStarted = true
	local client = false
	pcall(function()
		client = RunService:IsClient() and RunService:IsRunning()
	end)
	if client then
		task.spawn(function()
			Icons.verify()
		end)
	end
end

--[[
	The icon font. Filled icons use the Bold face, outline icons use Regular.
	Pass `use` from inside a Computed so it re-resolves if the font switches
	to the fallback.
]]
function Icons.font(filled: boolean?, use: ((any) -> any)?): Font
	autoVerify()
	if use then
		use(Icons.family)
	end
	local family = Assets.IconFont
	local key = family .. (if filled == false then "|outline" else "|filled")
	local font = fontCache[key]
	if font == nil then
		font = Font.new(
			family,
			if filled == false then Enum.FontWeight.Regular else Enum.FontWeight.Bold,
			Enum.FontStyle.Normal
		)
		fontCache[key] = font
	end
	return font
end

-- Points text already on screen at the new icon font: FontFaces built from
-- the old family, and RichText made with Icons.inline.
local function sweep(old: string, new: string)
	local player = Players.LocalPlayer
	local gui = player and player:FindFirstChildOfClass("PlayerGui")
	if gui == nil then
		return
	end
	for _, d in gui:GetDescendants() do
		if d:IsA("TextLabel") or d:IsA("TextButton") or d:IsA("TextBox") then
			local label: any = d
			pcall(function()
				local face = label.FontFace
				if face and face.Family == old then
					label.FontFace = Font.new(new, face.Weight, face.Style)
				end
			end)
			pcall(function()
				if label.RichText and string.find(label.Text, old, 1, true) then
					local pattern = string.gsub(old, "[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%0")
					label.Text = string.gsub(label.Text, pattern, function()
						return new
					end)
				end
			end)
		end
	end
end

--[[
	Switches every icon to another icon font family ("rbxassetid://..." or
	"rbxasset://..."). Icon components update themselves; static labels and
	inline RichText icons already on screen are re-pointed too.
]]
function Icons.setFont(family: string)
	local old = Assets.IconFont
	if family == old then
		return
	end
	Assets.IconFont = family
	table.clear(fontCache)
	Icons.usingFallback = family == Assets.IconFontFallback
	Icons.family:set(family)
	sweep(old, family)
end

local verifyState = "idle" -- "idle" | "running" | "done"
local verifyResult = true

--[[
	Checks that the icon font loads on this device (preloads a label that
	uses it). If it doesn't, switches to Assets.IconFontFallback. Yields;
	returns true when the original font is fine. Runs by itself once the
	first time icons are used on a client; `force` checks again.
]]
function Icons.verify(force: boolean?): boolean
	if verifyState == "running" and not force then
		while verifyState == "running" do
			task.wait(0.05)
		end
		return verifyResult
	elseif verifyState == "done" and not force then
		return verifyResult
	end
	verifyState = "running"
	verifyStarted = true
	local family = Assets.IconFont
	local ok = true
	local label = Instance.new("TextLabel")
	label.FontFace = Font.new(family, Enum.FontWeight.Bold, Enum.FontStyle.Normal)
	label.Text = "star"
	pcall(function()
		ContentProvider:PreloadAsync({ label }, function(_, status)
			if status ~= Enum.AssetFetchStatus.Success then
				ok = false
			end
		end)
	end)
	label:Destroy()
	if not ok and family ~= Assets.IconFontFallback then
		warn(`[BloxUI] The Builder Icons font didn't load here; using the fallback ({Assets.IconFontFallback}).`)
		Icons.setFont(Assets.IconFontFallback)
	end
	verifyResult = ok
	verifyState = "done"
	return ok
end

local warned: { [string]: boolean } = {}

export type Resolved = "none" | "glyph" | "image" | "text"

-- Works out what kind of icon a value is. Returns the kind plus the value to
-- display (the glyph name, image id or text).
function Icons.resolve(icon: any): (Resolved, string)
	if icon == nil or icon == "" or icon == false then
		return "none", ""
	end
	if typeof(icon) == "number" then
		return "image", "rbxassetid://" .. tostring(icon)
	end
	if typeof(icon) ~= "string" then
		return "none", ""
	end
	if string.find(icon, "^rbx") or string.find(icon, "^https?://") then
		return "image", icon
	end
	local name = Icons.alias[icon] or icon
	if Icons.names[name] then
		return "glyph", name
	end
	if string.find(name, "[\128-\255]") then
		return "text", name
	end
	if not warned[name] then
		warned[name] = true
		warn(`[BloxUI] Unknown icon "{name}". See BloxUI.Icons.list for every icon name.`)
	end
	return "glyph", name
end

-- Returns true if `name` is a Builder Icons name or alias.
function Icons.has(name: string): boolean
	return Icons.names[Icons.alias[name] or name] == true
end

-- Finds icon names containing `query`, handy in the command bar:
-- print(require(game.ReplicatedStorage.BloxUI).Icons.search("arrow"))
function Icons.search(query: string): { string }
	local results = {}
	query = string.lower(query)
	for _, name in Icons.list do
		if string.find(name, query, 1, true) then
			table.insert(results, name)
		end
	end
	for alias, target in Icons.alias do
		if string.find(alias, query, 1, true) then
			table.insert(results, `{alias} -> {target}`)
		end
	end
	return results
end

-- Rich text markup that draws an icon inline inside a RichText label:
-- label.Text = "Buy for " .. Icons.inline("robux") .. " 48"
function Icons.inline(icon: string, filled: boolean?): string
	autoVerify()
	local kind, value = Icons.resolve(icon)
	if kind ~= "glyph" then
		return value
	end
	local markup = `<font family="{Assets.IconFont}">{value}</font>`
	if filled ~= false then
		markup = `<b>{markup}</b>`
	end
	return markup
end

return Icons
]]></string>
          </Properties>
          <Item class="ModuleScript" referent="163">
            <Properties>
              <string name="Name">Names</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Generated by tools/gen_icon_names.py. Do not edit by hand.
-- 771 Builder Icons shipped with the Roblox client.
return [[
3d-cube-arrow-left 5-g-signal-fair 5-g-signal-good 5-g-signal-poor amazon android apple arm 
arm-left arm-right arrow-curl-to-left arrow-curl-to-right arrow-curved-path-left 
arrow-curved-path-right arrow-down-to-line arrow-large-down arrow-large-left arrow-large-right 
arrow-large-up arrow-medium-down arrow-medium-left arrow-medium-right arrow-medium-up 
arrow-progress-up arrow-right-from-portrait-rectangle arrow-right-to-portrait-rectangle 
arrow-rotate-down-dashed arrow-rotate-right arrow-rotate-right-dashed arrow-small-down 
arrow-small-left arrow-small-right arrow-small-up arrow-spin-clockwise arrow-spin-clockwise-10 
arrow-spin-clockwise-15 arrow-spin-clockwise-30 arrow-spin-clockwise-dashed 
arrow-spin-clockwise-dashed-dot arrow-spin-counter-clockwise arrow-spin-counter-clockwise-10 
arrow-spin-counter-clockwise-15 arrow-spin-counter-clockwise-30 arrow-thick-to-left 
arrow-thick-to-right arrow-up-from-landscape-rectangle arrow-up-from-line 
arrow-up-right-from-square arrow-wide-down arrow-wide-left arrow-wide-right arrow-wide-short-down 
arrow-wide-short-left arrow-wide-short-right arrow-wide-short-up arrow-wide-up 
arrow-winding-down-left arrow-winding-down-right arrow-winding-up-left arrow-winding-up-right 
arrows-small-directional at-sign audio-wave-dotted-line backpack beans beard bell bell-clock 
bell-plus bell-slash belt binoculars book-closed book-open bookmark bow-tie bug building-bank 
building-store bullet-flying butterfly-wings calendar calendar-plus calendar-star camera-slash 
camera-small caret-small-down caret-small-left caret-small-right caret-small-up chain-link 
chain-link-slash chart-four-vertical-bars chart-line chart-pie chart-scatter-plot 
chart-three-horizontal-bars chart-three-vertical-bars check check-large check-small 
checkmark-square chevron-large-condense-down chevron-large-condensed-up chevron-large-down 
chevron-large-down-to-line chevron-large-left chevron-large-left-to-line chevron-large-right 
chevron-large-right-to-line chevron-large-up chevron-large-up-to-line chevron-small-down 
chevron-small-down-to-line chevron-small-left chevron-small-left-to-line chevron-small-right 
chevron-small-right-to-line chevron-small-up chevron-small-up-to-line circle circle-arrow-down 
circle-arrow-left circle-arrow-right circle-arrow-up circle-british-pound-sign circle-check 
circle-dashed circle-dollar-sign circle-i circle-line-target-cursor circle-minus circle-outline 
circle-person circle-person-three-horizontal-bars-wrapping-right circle-play circle-plus 
circle-question circle-slash circle-star circle-three-dots-horizontal circle-three-dots-vertical 
circle-x clipboard-pencil clock clock-dashed clock-spin-reverse clock-spin-reverse-dashed 
clothes-hanger cloud cloud-arrow-down code compact-makeup-brush compass computer-monitor 
constrain-x-center constrain-x-pin-both constrain-x-pin-end constrain-x-pin-scale 
constrain-x-pin-start constrain-x-scale-both constrain-x-scale-end constrain-x-scale-pin 
constrain-x-scale-start constrain-y-center constrain-y-pin-both constrain-y-pin-end 
constrain-y-pin-scale constrain-y-pin-start constrain-y-scale-both constrain-y-scale-end 
constrain-y-scale-pin constrain-y-scale-start controller controller-with-cog 
corner-radius-bottom-left corner-radius-bottom-right corner-radius-top-left corner-radius-top-right 
credit-card crop cross-frame crosshairs crosshairs-slash crown cube cube-question-mark 
cube-vertexes cursor cursor-stars curved-rectangle-megaphone design-assist diagonal-line-pattern 
diagonal-line-pattern-sticker diamond diamond-simplified discord disguise-nose-glasses 
document-circle-slash document-list-heart document-question-mark door-open-arrow-to-bottom-right 
dot-frame-head-blank dot-frame-person-standing dot-frame-tshirt double-chevron-large-condense-down 
double-chevron-large-condensed-up double-chevron-large-down double-chevron-large-left 
double-chevron-large-right double-chevron-large-up dress dual-arrows-horizontal 
dual-arrows-horizontal-text-v-a dual-arrows-large-horizontal dual-arrows-large-vertical 
dual-arrows-to-corners dual-arrows-vertical dual-arrows-vertical-four-horizontal-lines egg envelope 
eraser ethernet eye eye-slash eye-with-eyeliner eyebrows eyelashes face-winking facebook figma 
file-box fingerprint fish five-vertical-lines-wave flag flame floppy-disk folder folder-tabs 
folder-tilt fountain-pen-nib four-bars-horizontal-center-aligned four-bars-horizontal-chevron-left 
four-bars-horizontal-chevron-right four-bars-horizontal-justified-aligned 
four-bars-horizontal-left-aligned four-bars-horizontal-right-aligned four-rectangles-mason-grid 
four-squares-diamond-grid four-squares-grid frame-bubble-slash frame-bubble-soundwave frame-camera 
frame-camera-center frame-collapsed frame-corners frame-expanded frame-face frame-person-torso 
frame-record frame-single-bar-horizontal frame-small-diamond frame-soundwave frame-video-camera 
gear generic-dpad gift-box gift-card github glasses globe-detailed globe-simplified 
globe-simplipfied-speech-bubble google grid guilded hack-week hammer-code hand-arrow-down 
hand-curved-arrow-left hand-dual-arrows hand-ellipse hand-grip hand-grip-2x-text hand-half-ellipse 
hand-two-arrows-horizontal hashtag hat-fedora hat-toque head-blank head-blush head-female 
head-freckles head-lips head-male headphones headphones-arrow-up headphones-arrow-up-lock 
headphones-slash headphones-x headphones-x-lock heart hexagon-binding-complex hexagon-dot 
horiztonal-lines-descending-rectangle house image image-circle-slash image-stacked instagram jacket 
key key-alt key-apostrophe key-arrow-down key-arrow-left key-arrow-right key-arrow-up key-asterisk 
key-backspace key-caps-lock key-caret key-comma key-command key-control key-grave-accent key-period 
key-return key-shift key-shift-down key-shift-right-chevron key-space key-tab keyboard lab-beaker 
language-characters leg leg-left leg-right lightning-bolt line-frame-plus line-horizontal 
line-segment-horizontal line-segment-vertical line-vertical linkedin lips 
lips-lipstick-makeup-brush lipstick list-bulleted list-numbered location-pin location-pin-map 
lock-closed lollipop luau magnifying-glass magnifying-glass-minus magnifying-glass-plus mascara 
megaphone megaphone-cheer memory-card messenger meta microphone microphone-slash 
microphone-text-box microphone-triangle-exclamation microsoft middle-dot milk minus minus-small 
mirror-standing moments moon moon-with-stars mouse-button-center mouse-button-left 
mouse-button-right mouse-scrollwheel music-note nebula necklace nexus nine-dots-grid ninja nose 
octogon page paint-brush paint-bucket pants pants-2d-text paper-airplane paragraph parrot 
pause-large pause-small peanut pencil pencil-square person person-arrow-from-bottom-right 
person-check person-circle-slash person-climbing person-clock person-crouching person-falling 
person-graduate person-jumping person-magnifying-glass person-photo-camera person-play 
person-play-clock person-plus person-racing person-rectangle-horizontal-line person-running 
person-standing person-standing-arrow-reverse person-standing-dual-arrows-vertical 
person-standing-gear person-swimming person-teleport person-trash-can person-walking 
person-walking-dual-chevrons-horizontal person-with-smaller-person phone phone-arrow-diagonal-down 
phone-arrow-diagonal-up phone-down phone-plus phone-triangle-exclamation phone-volume phone-x 
photo-camera photo-camera-face photo-camera-face-lightning-bolt photo-camera-slash 
picture-in-picture pig pin pin-slash pistachio play-large play-small play-xlarge playstation 
playstation-circle plus-large plus-small premium ps-circle ps-circle-pressed ps-circle-unpressed 
ps-dpad ps-dpad-down ps-dpad-left ps-dpad-right ps-dpad-up ps-l1 ps-l2 ps-l3 ps-r1 ps-r2 ps-r3 
ps-square ps-square-pressed ps-square-unpressed ps-stick-left ps-stick-right ps-triagle 
ps-triangle-pressed ps-triangle-unpressed ps-x ps-x-pressed ps-x-unpressed ps4-options ps4-share 
ps4-touchpad ps5-options ps5-share ps5-touchpad pumpkin purse quotation react rectangle-cursor 
rectangle-line-divider rectangle-list rectangle-list-pencil rectangle-numbers-counting 
rectangle-person-with-three-horizontal-lines rectangle-play-stacked rectangle-two-chevrons-fill-x 
rectangle-two-chevrons-fill-y rectangle-two-chevrons-fit-x rectangle-two-chevrons-fit-y 
rectangle-two-chevrons-x-axis rectangle-two-chevrons-y-axis ro-gro roblox-plus robux 
rosette-seven-point rosette-ten-point ruler sesame-seeds seven-point-rosette shield-check 
shield-lock shirt shirt-2d-text shirt-pants shoe shoe-left shoe-right shopping-basket 
shopping-basket-check shopping-cart shorts shrimp sidebar sidebar-right signal-exclamation 
six-dots-two-column-grid skip-end-large skip-end-small skip-next-large skip-next-small 
skip-previous-large skip-previous-small skip-start-large skip-start-small skip-to-first-frame-large 
skip-to-first-frame-small skip-to-last-frame-large skip-to-last-frame-small slack 
small-square-over-dashed-large-square smartphone-portrait speaker speaker-slash 
speaker-triangle-exclamation speaker-x speech-bubble-align-center speech-bubble-align-left 
speech-bubble-exclamation speech-bubble-round speedometer square square-arrow-rotate-bottom-left 
square-arrow-rotate-bottom-right square-arrow-rotate-top-left square-arrow-rotate-top-right 
square-bar-graph square-bone square-books square-check square-clipping-circle square-code 
square-corner-line square-corner-line-small square-dashed-person-standing 
square-dots-increasing-size square-dual-arrows-horizontal square-dual-arrows-to-corner 
square-face-emoji square-face-sound square-face-waving-hand square-face-winking square-minus 
square-person square-small square-text-alt square-text-esc square-text-fix-width square-text-ip 
square-text-l square-text-r square-thick-line-bottom square-thick-line-frame square-thick-line-left 
square-thick-line-right square-thick-line-top square-two-parallel-thick-lines-left-right 
square-two-parallel-thick-lines-top-bottom squares-grid-plus squares-grid-qr stackable-check 
stackable-exclamation stackable-i stackable-star stackable-x stacked-squares-arrow-down-left 
stacked-squares-arrow-up-right stacked-squares-plus star star-three-vertical-bars stop-large 
stop-small studio sun sweater sword tag tag-arrow-up tag-sparkle tag-sparkle-arrow-up teletype 
ten-point-rosette tencent-qq text-b-bold text-box-microphone text-ff-farm text-g-arrow-down 
text-h-humane text-h-subscript-1 text-h-subscript-2 text-h-subscript-3 text-hl-halal text-i-italic 
text-in-balance text-lc-local text-o-organic text-s-seafood text-s-strikethrough text-t-serif 
text-u-underline text-uppercase-a-lowercase-a text-v-vegetarian text-vg-vegan text-x-subscript-2 
text-x-superscript-2 three-bars-horizontal three-bars-horizontal-chevron-left 
three-bars-horizontal-narrowing three-bars-horizontal-triangles-vertical 
three-bars-vertical-triangles-horizontal three-bars-x-aligned-center three-bars-x-aligned-left 
three-bars-x-aligned-right three-bars-y-aligned-bottom three-bars-y-aligned-center 
three-bars-y-aligned-top three-box-flowchart three-chevrons-enlarging-down 
three-chevrons-enlarging-up three-dots-horizontal three-dots-vertical 
three-horizontal-bars-wrapping-right three-people three-ring-note three-sliders-horizontal 
three-small-squares three-stacked-squares-tilted thumb-down thumb-up tik-tok tilt toggle-left 
toggle-right torso trash-can triangle triangle-exclamation triple-chevron-large-condense-down 
triple-chevron-large-condensed-up trophy tshirt tshirt-2d-text tshirt-dual-arrows tshirt-play 
twitch twitter two-arrows-branch-right two-arrows-down-and-up two-arrows-down-and-up-to-center 
two-arrows-from-center two-arrows-left-and-right-to-center two-arrows-left-right 
two-arrows-loop-clockwise two-arrows-loop-clockwise-1 two-arrows-loop-clockwise-infinity 
two-arrows-spin-clockwise two-arrows-spin-clockwise-plus two-arrows-switch-right 
two-arrows-to-center two-bars-x-aligned-center-to-line two-bars-x-aligned-left-to-line 
two-bars-x-aligned-right-to-line two-bars-y-aligned-bottom-to-line 
two-bars-y-aligned-center-to-line two-bars-y-aligned-top-to-line two-carets-bar-horizontal-flip 
two-carets-bar-vertical-flip two-folders two-location-pins-connecting-arrow two-makeup-brushes 
two-people two-people-speech-bubble two-rulers-cursor two-sliders-horizontal two-sliders-vertical 
two-speech-bubbles two-squares-arrow-down two-squares-arrow-left two-stacked-squares 
two-stacked-squares-code two-switches-horizontal two-vertical-bars-dot 
two-vertical-bars-text-auto-width verified-backplate verified-check verified-mono video-camera 
video-camera-arrow-to-bottom-left video-camera-arrow-to-top-right video-camera-slash 
video-camera-triangle-exclamation video-camera-x wallet we-chat whatsapp wheat wifi-signal-fair 
wifi-signal-good wifi-signal-poor windows-app-close windows-app-fullscreen windows-app-minimize 
windows-app-restore x-small xbox xbox-a xbox-a-pressed xbox-a-unpressed xbox-b xbox-b-pressed 
xbox-b-unpressed xbox-dpad xbox-dpad-down xbox-dpad-left xbox-dpad-right xbox-dpad-up xbox-lb 
xbox-lt xbox-menu xbox-rb xbox-rt xbox-stick-left xbox-stick-left-directional 
xbox-stick-left-horizontal xbox-stick-left-vertical xbox-stick-right xbox-stick-right-directional 
xbox-stick-right-horizontal xbox-stick-right-vertical xbox-view xbox-x xbox-x-pressed 
xbox-x-unpressed xbox-y xbox-y-pressed xbox-y-unpressed xr-headset youtube
]]
]]></string>
            </Properties>
          </Item>
        </Item>
        <Item class="ModuleScript" referent="164">
          <Properties>
            <string name="Name">Kit</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Kit: small building blocks shared by every BloxUI component. You can use
	these in your own components too:

		local Kit = UI.Kit
		scope:New "Frame" {
			[Kit.Children] = {
				Kit.corner(scope, "M"),
				Kit.stroke(scope, { Thickness = 3 }),
			},
		}

		-- a themed surface (glass, solid, gradient, outlined ...) in one call
		local look, layers = Kit.surface(scope, { Style = "Gradient", Tone = "Purple" })
		scope:New "Frame" {
			BackgroundColor3 = look.BackgroundColor3,
			BackgroundTransparency = look.BackgroundTransparency,
			[Kit.Children] = layers,
		}

	Every prop accepts a plain value or a Fusion state object.

	v2.4 style-family helpers (additive). In a classic theme (no
	style.family) every one of them returns nil / an empty list, so a
	component can add them unconditionally and older blueprints render
	exactly as before. Put the results in [Kit.Children].

		Kit.family(scope)                           Computed theme.Family recipe (or nil)
		Kit.shadows(scope, { Role, Tone, Color, Visible, Strength })
		    layered UIShadows for an elevation role: "Button" | "Card" | "Window"
		    | "Hud" | "Chip" (the family's recipe: tight ambient + large soft key,
		    tinted with the surface hue, a hard offset slab or a coloured glow).
		    UIShadow draws outside its parent's shape only, so a transparent
		    frame works as a shadow caster.
		Kit.rim(scope, { Role, Top, Width, Bottom, BottomWidth })
		    an inner top highlight (Inner UIStroke, white, lit from the top by a
		    UIGradient) and, for puffy families, an inner bottom shade
		Kit.outline(scope, { Tone, Color, Width, Inner, Transparency })
		    the family's outline: an Outer stroke tinted to contrast the fill (a
		    toned element gets its own dark shade) plus an optional light Inner
		    line (double / sticker outline, gold double rule)
		Kit.nestRadius(outer, inset)                UDim: inner radius = outer - inset (rule R1)
		Kit.cornerRadii(scope, { TopLeft, TopRight, BottomRight, BottomLeft })
		    a UICorner with per-corner radii (px, UDim, "S".."L", "Full")
		Kit.chamfer(scope, radius?)                 cut corners (Holo / Neon): radius on the
		                                            top-left / bottom-right, square elsewhere
		Kit.cardFill(theme, tone, lift?)            ColorSequence for a coloured card in the
		                                            family's fill recipe (Vivid | Tint | Flat)
		Kit.texture(scope, { Kind, Role, Color, Transparency })
		    a subtle full-size texture layer: "Grain" | "Noise" | "Scanlines" |
		    "Grid" | "Paper" | "Halftone" | "Dots" | "Stripes" | "Wood" |
		    "Parchment" | "Damask" | "Streaks" (default: the family's)
		Kit.textSize(scope, "Label")                type roles: Display, Title, Heading,
		                                            Body, Label, Caption, Number (+ the
		                                            classic Hero, H1..H3, Small)
		Kit.minText(theme, size, hud?)              a size raised to the family's minimum
		Kit.budget(root)                            { shadows, strokes } under an instance
		                                            (screens stay < 100 shadows, < 300 strokes)
]]

local Fusion = require(script.Parent.Packages.Fusion)
local Theme = require(script.Parent.Theme)
local Assets = require(script.Parent.Assets)
local Color = require(script.Parent.Util.Color)
local Viewport = require(script.Parent.Util.Viewport)

local peek = Fusion.peek

local Kit = {}

Kit.Children = Fusion.Children
Kit.OnEvent = Fusion.OnEvent
Kit.OnChange = Fusion.OnChange
Kit.Out = Fusion.Out
Kit.peek = peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)
local BLACK = Color3.new(0, 0, 0)

-- UIShadow (2026-06) and per-corner UICorner radii are engine features:
-- older clients (and some test hosts) don't have them, so everything that
-- uses them checks once and quietly does without.
local SHADOWS_OK = pcall(function()
	local probe = Instance.new("UIShadow")
	probe:Destroy()
end)
local CORNERS_OK = pcall(function()
	local probe = Instance.new("UICorner")
	local anyProbe: any = probe
	anyProbe.TopLeftRadius = UDim.new(0, 1)
	probe:Destroy()
end)

local LAYOUT_KEYS = {
	"Name",
	"Position",
	"AnchorPoint",
	"Size",
	"LayoutOrder",
	"ZIndex",
	"Visible",
	"Rotation",
	"AutomaticSize",
	"SizeConstraint",
	"Parent",
}

-- Copies the layout props every component supports onto a new property table
-- for the component's root instance. `defaults` fills in anything missing.
function Kit.root(props: { [any]: any }, defaults: { [string]: any }?): { [any]: any }
	local out = {}
	for _, key in LAYOUT_KEYS do
		local value = props[key]
		if value == nil and defaults then
			value = defaults[key]
		end
		if value ~= nil then
			out[key] = value
		end
	end
	return out
end

-- True for Fusion state objects (Value, Computed, Spring, ...).
local function isState(value: any): boolean
	return typeof(value) == "table" and value.type == "State"
end
Kit.isState = isState

-- Computeds that only read the theme are shared by everything that asks for
-- the same thing (one "Yellow" tone for the whole UI, not one per button).
-- They live as long as the theme does.
local sharedScope = Fusion.scoped(Fusion)
local sharedStates: { [string]: any } = {}
local function shared(key: string, read: (theme: any) -> any): any
	local state = sharedStates[key]
	if state == nil then
		state = sharedScope:Computed(function(use)
			return read(use(Theme.current))
		end)
		sharedStates[key] = state
	end
	return state
end

-- A Computed that reads the active theme.
--   Kit.themed(scope, function(theme) return theme.Color.Text end)
function Kit.themed(scope: Scope, read: (theme: any, use: Fusion.Use) -> any)
	return scope:Computed(function(use)
		return read(use(Theme.current), use)
	end)
end

-- Uses `value` if given, otherwise asks the theme. A plain value comes
-- straight back (no Computed needed).
--   Kit.pick(scope, props.Color, function(theme) return theme.Color.Text end)
function Kit.pick(scope: Scope, value: any, fallback: (theme: any, use: Fusion.Use) -> any)
	if value ~= nil and not isState(value) then
		return value
	end
	return scope:Computed(function(use)
		local given = use(value)
		if given ~= nil then
			return given
		end
		return fallback(use(Theme.current), use)
	end)
end

-- A Computed tone table ({ Top, Bottom, Edge, Deep, Text, Stroke }). Accepts
-- tone names, rarity names ("Epic"), tone tables or a Color3.
function Kit.tone(scope: Scope, tone: any, default: string?)
	if tone == nil or typeof(tone) == "string" then
		local name = tone or default or "Gray"
		return shared(`tone:{name}`, function(theme)
			return Theme.tone(theme, name)
		end)
	end
	return scope:Computed(function(use)
		return Theme.tone(use(Theme.current), use(tone) or default or "Gray")
	end)
end

-- The current theme.Style[key] ("ClickFx", "ButtonStyle", ...), or `override`
-- when that is given (plain value or state).
function Kit.style(scope: Scope, key: string, override: any?)
	if override ~= nil and not isState(override) then
		return override
	elseif override == nil then
		return shared(`style:{key}`, function(theme)
			return Theme.style(theme, key)
		end)
	end
	return scope:Computed(function(use)
		local given = use(override)
		if given ~= nil then
			return given
		end
		return Theme.style(use(Theme.current), key)
	end)
end

-- A Computed rarity tone ("Common" .. "Secret", or a state holding one).
function Kit.rarity(scope: Scope, rarity: any)
	if rarity == nil or typeof(rarity) == "string" then
		local name = rarity or "Common"
		return shared(`rarity:{name}`, function(theme)
			return Theme.rarity(theme, name)
		end)
	end
	return scope:Computed(function(use)
		return Theme.rarity(use(Theme.current), use(rarity) or "Common")
	end)
end

-- Upper-cases text but leaves RichText tags and entities (<b>, &amp;) alone.
function Kit.upper(text: string): string
	if not string.find(text, "[<&]") then
		return string.upper(text)
	end
	local out = {}
	local index = 1
	local length = #text
	while index <= length do
		local tagStart, tagEnd = string.find(text, "<[^>]*>", index)
		local entStart, entEnd = string.find(text, "&#?%w+;", index)
		local startAt, endAt = tagStart, tagEnd
		if entStart and (startAt == nil or entStart < startAt) then
			startAt, endAt = entStart, entEnd
		end
		if startAt == nil then
			table.insert(out, string.upper(string.sub(text, index)))
			break
		end
		table.insert(out, string.upper(string.sub(text, index, startAt - 1)))
		table.insert(out, string.sub(text, startAt, endAt))
		index = endAt + 1
	end
	return table.concat(out)
end

-- Text with the theme's TextCase applied ("Upper" themes shout). Pass
-- `case` ("Normal" | "Upper") to override the theme.
function Kit.caseText(scope: Scope, text: any, case: any?)
	return scope:Computed(function(use)
		local value = use(text)
		if value == nil then
			return ""
		end
		value = tostring(value)
		local mode = use(case) or Theme.style(use(Theme.current), "TextCase")
		if mode == "Upper" then
			return Kit.upper(value)
		end
		return value
	end)
end

-- A size from the theme's type scale: "Hero" | "H1" | "H2" | "H3" | "Body"
-- | "Small" | "Caption". Numbers pass straight through.
function Kit.textSize(scope: Scope, size: any)
	if typeof(size) == "number" then
		return size
	elseif size == nil or typeof(size) == "string" then
		local name = size or "Body"
		return shared(`textSize:{name}`, function(theme)
			local scale = theme.Type
			return (scale and scale[name]) or 23
		end)
	end
	return scope:Computed(function(use)
		local value = use(size)
		if typeof(value) == "number" then
			return value
		end
		local scale = use(Theme.current).Type
		return (scale and scale[value or "Body"]) or 23
	end)
end

-- A spring that uses the theme's motion settings, so springs get bouncier or
-- stiffer with the theme. `speedScale` multiplies the theme speed.
function Kit.spring(scope: Scope, goal: any, speedScale: number?, damping: number?)
	local scale = speedScale or 1
	local speed = shared(`springSpeed:{scale}`, function(theme)
		return theme.Motion.Speed * scale
	end)
	local dampingState = damping or shared("springDamping", function(theme)
		return theme.Motion.Damping
	end)
	return scope:Spring(goal, speed, dampingState)
end

--[[
	Kicks a spring (setPosition / addVelocity) one frame later. Fusion drops
	a kick made in the same frame as a change to the spring's goal, so use
	this when both happen together (e.g. a tab is selected and its icon pops):

		Kit.springKick(iconPop, function(spring) spring:addVelocity(6) end)

	Safe if the spring's scope is cleaned up meanwhile.
]]
function Kit.springKick(spring: any, kick: (spring: any) -> ())
	task.delay(0, function()
		pcall(kick, spring)
	end)
end

local function resolveRadius(theme: any, radius: any): UDim
	if typeof(radius) == "UDim" then
		return radius
	elseif typeof(radius) == "number" then
		return UDim.new(0, radius)
	elseif radius == "Full" then
		return UDim.new(0.5, 0)
	end
	return UDim.new(0, theme.Radius[radius or "M"] or theme.Radius.M)
end
Kit.resolveRadius = resolveRadius

-- UICorner. radius: number (px) | "S" | "M" | "L" | "Full" | UDim
function Kit.corner(scope: Scope, radius: any?): UICorner
	local value
	if typeof(radius) == "UDim" or typeof(radius) == "number" or radius == "Full" then
		value = resolveRadius(nil, radius) -- doesn't depend on the theme
	elseif radius == nil or typeof(radius) == "string" then
		local name = radius or "M"
		value = shared(`radius:{name}`, function(theme)
			return resolveRadius(theme, name)
		end)
	else
		value = scope:Computed(function(use)
			return resolveRadius(use(Theme.current), use(radius))
		end)
	end
	return scope:New "UICorner" {
		CornerRadius = value,
	}
end

export type StrokeProps = {
	Color: any?,
	Thickness: any?,
	Transparency: any?,
	Enabled: any?,
	Contextual: boolean?, -- true = outline text instead of the box
}

-- UIStroke that defaults to the theme's outline colour and width.
function Kit.stroke(scope: Scope, props: StrokeProps?): UIStroke
	local p = props or {}
	return scope:New "UIStroke" {
		ApplyStrokeMode = if p.Contextual then Enum.ApplyStrokeMode.Contextual else Enum.ApplyStrokeMode.Border,
		LineJoinMode = Enum.LineJoinMode.Round,
		Color = Kit.pick(scope, p.Color, function(theme)
			return theme.Color.Outline
		end),
		Thickness = Kit.pick(scope, p.Thickness, function(theme)
			return if p.Contextual then theme.Stroke.Text else theme.Stroke.UI
		end),
		Transparency = p.Transparency or 0,
		Enabled = if p.Enabled == nil then true else p.Enabled,
	}
end

-- UIPadding. One number for all sides, or { top, right, bottom, left }.
function Kit.padding(scope: Scope, padding: number | { number }): UIPadding
	local t, r, b, l
	if typeof(padding) == "number" then
		t, r, b, l = padding, padding, padding, padding
	else
		t, r, b, l = padding[1], padding[2] or padding[1], padding[3] or padding[1], padding[4] or padding[2] or padding[1]
	end
	return scope:New "UIPadding" {
		PaddingTop = UDim.new(0, t),
		PaddingRight = UDim.new(0, r),
		PaddingBottom = UDim.new(0, b),
		PaddingLeft = UDim.new(0, l),
	}
end

export type ListProps = {
	Direction: ("X" | "Y")?,
	Padding: any?,
	HAlign: ("Left" | "Center" | "Right")?,
	VAlign: ("Top" | "Center" | "Bottom")?,
	Wraps: boolean?,
}

-- UIListLayout sorted by LayoutOrder.
function Kit.list(scope: Scope, props: ListProps?): UIListLayout
	local p = props or {}
	local padding = p.Padding or 8
	return scope:New "UIListLayout" {
		FillDirection = if p.Direction == "X" then Enum.FillDirection.Horizontal else Enum.FillDirection.Vertical,
		SortOrder = Enum.SortOrder.LayoutOrder,
		Padding = if isState(padding)
			then scope:Computed(function(use)
				local value = use(padding)
				return if typeof(value) == "UDim" then value else UDim.new(0, value)
			end)
			elseif typeof(padding) == "UDim" then padding
			else UDim.new(0, padding),
		HorizontalAlignment = Enum.HorizontalAlignment[p.HAlign or "Left"],
		VerticalAlignment = Enum.VerticalAlignment[p.VAlign or "Top"],
		Wraps = p.Wraps or false,
	}
end

export type GradientProps = {
	Color: any?, -- ColorSequence
	Transparency: any?, -- NumberSequence
	Rotation: any?,
	Offset: any?,
	Enabled: any?,
}

function Kit.gradient(scope: Scope, props: GradientProps): UIGradient
	return scope:New "UIGradient" {
		Color = props.Color or ColorSequence.new(WHITE),
		Transparency = props.Transparency or NumberSequence.new(0),
		Rotation = props.Rotation or 90,
		Offset = props.Offset or Vector2.zero,
		Enabled = if props.Enabled == nil then true else props.Enabled,
	}
end

-- Vertical ColorSequence for a tone's face, lightened by `lift` (0..1).
-- Three stops: a bright top, the tone body, and a slightly deeper base.
function Kit.faceColors(tone: any, lift: number?): ColorSequence
	local amount = lift or 0
	local top = Color.lighten(tone.Top, amount * 0.35)
	local bottom = Color.lighten(tone.Bottom, amount * 0.25)
	local mid = if tone.Mid then Color.lighten(tone.Mid, amount * 0.3) else top:Lerp(bottom, 0.7)
	return ColorSequence.new({
		ColorSequenceKeypoint.new(0, top),
		ColorSequenceKeypoint.new(if tone.Mid then 0.45 else 0.55, mid),
		ColorSequenceKeypoint.new(1, bottom),
	})
end

export type ShadowProps = {
	Spread: number?, -- blur size in design px
	Offset: Vector2?,
	Transparency: any?,
	Color: any?,
	ZIndex: number?,
	Visible: any?,
}

-- v2.4: in a style family a shadow is a transparent "caster" the size of
-- its parent carrying the family's UIShadow layers (UIShadow only draws
-- outside its parent's shape, so the caster itself never shows).
local function shadowCaster(scope: Scope, role: string, radius: any, zIndex: number?, visible: any?, layers: number?): Frame
	return scope:New "Frame" {
		Name = "ShadowCaster",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromScale(1, 1),
		ZIndex = zIndex or 0,
		Visible = if visible == nil then true else visible,
		[Kit.Children] = {
			if radius ~= nil then Kit.corner(scope, radius) else nil,
			Kit.shadows(scope, { Role = role, Layers = layers }),
		},
	}
end

-- Soft drop shadow. Parent it next to (not inside) the thing it shadows, with
-- a lower ZIndex. Sized to its parent. (In a style family: the family's
-- layered UIShadows on a transparent caster; Radius picks its corners.)
function Kit.shadow(scope: Scope, props: ShadowProps?): ImageLabel
	local p = props or {}
	local spread = p.Spread or 18
	local offset = p.Offset or Vector2.new(0, 6)
	local fam = Theme.get().Family
	if fam ~= nil and SHADOWS_OK then
		-- (grid items and small cards: one layer, polish spec 5.5 budget)
		local role = if spread >= 26 then "Window" elseif spread >= 14 then "Card" else "Chip"
		return shadowCaster(scope, role, (p :: any).Radius or "M", p.ZIndex, p.Visible, if role == "Window" then nil else 1) :: any
	end
	return scope:New "ImageLabel" {
		Name = "Shadow",
		BackgroundTransparency = 1,
		Image = Assets.SoftShadow,
		ImageColor3 = p.Color or BLACK,
		ImageTransparency = Kit.pick(scope, p.Transparency, function(theme)
			return theme.Alpha.Shadow
		end),
		ScaleType = Enum.ScaleType.Slice,
		SliceCenter = Assets.SoftShadowSlice,
		SliceScale = (spread * 2) / Assets.SoftShadowFalloff,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.new(0.5, offset.X, 0.5, offset.Y),
		Size = UDim2.new(1, spread * 2, 1, spread * 2),
		ZIndex = p.ZIndex or 0,
		Visible = if p.Visible == nil then true else p.Visible,
	}
end

export type ElevationProps = {
	Radius: any?,
	Spread: number?,
	Offset: Vector2?,
	Kind: any?, -- "Soft" | "Hard" | "None" (default: theme Style.Shadow)
	Strength: number?, -- 0..1 multiplier on darkness (default 1)
	ZIndex: number?,
	Visible: any?,
	Role: string?, -- v2.4 style families: the Kit.shadows role (default from Spread)
}

-- A drop shadow that follows the theme's Style.Shadow: a soft blur, a hard
-- offset slab (sticker look), or nothing. Returns a list of children to put
-- next to (not inside) the thing being shadowed. Only the kind in use is
-- built (and rebuilt if the theme switches kind).
function Kit.elevation(scope: Scope, props: ElevationProps?): { any }
	local p = props or {}
	local kind = Kit.style(scope, "Shadow", p.Kind)
	local strength = p.Strength or 1
	local offset = p.Offset or Vector2.new(0, 6)
	local visible = p.Visible
	local function shown(inner: Scope): any
		if isState(visible) then
			return inner:Computed(function(use)
				return use(visible) ~= false
			end)
		end
		return visible ~= false
	end
	return {
		scope:Computed(function(use, inner: Scope): Instance?
			-- v2.4 style families: the family's layered UIShadows on a caster
			local fam = use(Theme.current).Family
			if fam ~= nil and SHADOWS_OK then
				if use(kind) == "None" and fam.Shadow == nil then
					return nil
				end
				local role = p.Role or (if (p.Spread or 18) >= 26 then "Window" elseif (p.Spread or 18) >= 14 then "Card" else "Button")
				return shadowCaster(inner, role, p.Radius or "M", p.ZIndex, shown(inner))
			end
			local current = use(kind)
			if current == "Soft" then
				return Kit.shadow(inner, {
					Spread = p.Spread or 18,
					Offset = offset,
					ZIndex = p.ZIndex or 0,
					Transparency = inner:Computed(function(useInner)
						local alpha = useInner(Theme.current).Alpha.Shadow
						return 1 - (1 - alpha) * strength
					end),
					Visible = shown(inner),
				})
			elseif current == "Hard" then
				return inner:New "Frame" {
					Name = "HardShadow",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, math.max(0, offset.X * 0.5), 0.5, math.max(3, offset.Y * 0.8)),
					Size = UDim2.fromScale(1, 1),
					BackgroundColor3 = Kit.themed(inner, function(theme)
						return theme.Color.Outline
					end),
					BackgroundTransparency = 1 - 0.55 * strength,
					Visible = shown(inner),
					ZIndex = p.ZIndex or 0,
					[Kit.Children] = Kit.corner(inner, p.Radius or "M"),
				}
			end
			return nil
		end),
	}
end

--------------------------------------------------------------------------
-- v2.4 style families: layered light, outlines, radii, textures
--------------------------------------------------------------------------

Kit.SHADOWS_SUPPORTED = SHADOWS_OK
Kit.CORNERS_SUPPORTED = CORNERS_OK
-- Roblox's guidance per screen (polish spec 5.5)
Kit.SHADOW_BUDGET = 100
Kit.STROKE_BUDGET = 300

-- The active family recipe (theme.Family), or nil in a classic theme.
function Kit.family(scope: Scope)
	return shared("family", function(theme)
		return theme.Family
	end)
end

--[[
	A family colour kind -> Color3:
	  "deep"   the theme's tinted shadow colour     "tone"   the tone's darkest shade
	  "glow"   the tone's light (else the trim)     "accent" the family trim / signature colour
	  "ink"    the family's outline ink             "trim"/"gold"/"wood"/"bone"  the trim
	  "hairline" ink on light panels, white on dark  "white" / "black"
]]
function Kit.familyColor(theme: any, kind: any, tone: any?): Color3
	if typeof(kind) == "Color3" then
		return kind
	end
	local c = theme.Color
	local fam = theme.Family or {}
	if kind == "deep" then
		return c.Shadow or c.Outline
	elseif kind == "tone" then
		return if tone then tone.Deep else (c.Shadow or c.Outline)
	elseif kind == "glow" then
		return if tone then tone.Top else (c.Trim or c.Accent)
	elseif kind == "accent" or kind == "trim" or kind == "gold" or kind == "bone" then
		return c.Trim or c.Accent
	elseif kind == "wood" then
		return c.WoodEdge or c.Trim or c.Outline
	elseif kind == "ink" then
		return fam.Ink or c.Outline
	elseif kind == "hairline" then
		return if fam.Light then (fam.Ink or c.Outline) else WHITE
	elseif kind == "white" then
		return WHITE
	elseif kind == "black" then
		return Color3.fromRGB(8, 8, 12)
	end
	return c.Outline
end

export type ShadowsProps = {
	Role: any?, -- "Button" | "Card" | "Window" | "Hud" | "Chip" (default "Card")
	Tone: any?, -- tone for "tone" / "glow" coloured layers
	Color: any?, -- overrides every layer's colour
	Visible: any?,
	Strength: any?, -- 0..1 (state ok), fades the shadows
	Lift: any?, -- 0..1 (state ok): hover lift, the key layer drops 3 px further and blurs more
	Layers: number?, -- at most this many layers (grid items: 1)
}

--[[
	Layered UIShadows for an elevation role, from the family recipe: a tight
	ambient + a large soft key tinted with the surface hue, a hard offset
	slab (sticker / comic / pixel), or a coloured glow (neon, holo). Nothing
	in a classic theme, in lite mode only the first layer.
]]
function Kit.shadows(scope: Scope, props: ShadowsProps?)
	if not SHADOWS_OK then
		return nil
	end
	local p = props or {}
	return scope:Computed(function(use, inner)
		local theme = use(Theme.current)
		local fam = theme.Family
		if fam == nil or fam.Shadow == nil then
			return nil
		end
		local role = use(p.Role) or "Card"
		if role == "None" or role == false then
			return nil
		end
		local layers = fam.Shadow[role] or fam.Shadow.Card
		if layers == nil or #layers == 0 then
			return nil
		end
		local lite = Theme.lite == true or use(Theme.liteMode) == true
		local cap = if lite then 1 else (p.Layers or #layers)
		local tone = if p.Tone ~= nil then Theme.tone(theme, use(p.Tone)) else nil
		local out = {}
		for index, layer in layers do
			if index > cap then
				break
			end
			local baseT = layer.T or 0
			local key = index == #layers and #layers > 1 or (#layers == 1 and (layer.Blur or 0) > 0)
			out[index] = inner:New "UIShadow" {
				Name = `Shadow{index}`,
				Color = inner:Computed(function(u)
					return u(p.Color) or Kit.familyColor(theme, layer.Color, tone)
				end),
				BlurRadius = inner:Computed(function(u)
					local lift = if key then (u(p.Lift) or 0) else 0
					return UDim.new(0, (layer.Blur or 0) + lift * 6)
				end),
				Offset = inner:Computed(function(u)
					local lift = if key then (u(p.Lift) or 0) else 0
					return UDim2.fromOffset(layer.X or 0, (layer.Y or 0) + lift * 3)
				end),
				Spread = UDim2.fromOffset(layer.Spread or 0, layer.Spread or 0),
				Transparency = inner:Computed(function(u)
					local strength = u(p.Strength)
					if strength == nil then
						strength = 1
					end
					return 1 - (1 - baseT) * math.clamp(strength, 0, 1)
				end),
				Enabled = inner:Computed(function(u)
					return u(p.Visible) ~= false
				end),
				ZIndex = -index,
			}
		end
		return out
	end)
end

export type RimProps = {
	Top: number?, -- transparency of the top light (default: the family's)
	Width: number?,
	Bottom: any?, -- transparency of the bottom shade, or false
	BottomWidth: number?,
	Force: boolean?, -- draw even when the family has no rim (with Top / Width given)
	Visible: any?,
}

local function litSequence(top: number, hard: boolean?): NumberSequence
	if hard then
		return NumberSequence.new({
			NumberSequenceKeypoint.new(0, top),
			NumberSequenceKeypoint.new(0.499, top),
			NumberSequenceKeypoint.new(0.5, 1),
			NumberSequenceKeypoint.new(1, 1),
		})
	end
	return NumberSequence.new({
		NumberSequenceKeypoint.new(0, top),
		NumberSequenceKeypoint.new(0.32, 1),
		NumberSequenceKeypoint.new(1, 1),
	})
end

local function shadeSequence(bottom: number, hard: boolean?): NumberSequence
	if hard then
		return NumberSequence.new({
			NumberSequenceKeypoint.new(0, 1),
			NumberSequenceKeypoint.new(0.5, 1),
			NumberSequenceKeypoint.new(0.501, bottom),
			NumberSequenceKeypoint.new(1, bottom),
		})
	end
	return NumberSequence.new({
		NumberSequenceKeypoint.new(0, 1),
		NumberSequenceKeypoint.new(0.62, 1),
		NumberSequenceKeypoint.new(1, bottom),
	})
end

--[[
	The "expensive" 1-3 px inner top highlight (Linear / Raycast / Catalyst,
	and the bevel of top Roblox games): an Inner UIStroke in white whose
	UIGradient keeps it only along the top edge; puffy and pixel families
	add a darker inner bottom shade. Nothing in a classic theme.
]]
function Kit.rim(scope: Scope, props: RimProps?)
	local p = props or {}
	return scope:Computed(function(use, inner)
		local theme = use(Theme.current)
		local fam = theme.Family
		if fam == nil or Theme.lite == true or use(Theme.liteMode) == true then
			return nil
		end
		local recipe = fam.Rim
		if (recipe == false or recipe == nil) and not p.Force then
			return nil
		end
		local r = if typeof(recipe) == "table" then recipe else {}
		local top = p.Top or r.Top
		local width = p.Width or r.Width or 1
		local hard = r.Hard == true
		local out = {}
		if typeof(top) == "number" and top < 1 then
			table.insert(out, inner:New "UIStroke" {
				Name = "RimLight",
				ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				BorderStrokePosition = Enum.BorderStrokePosition.Inner,
				LineJoinMode = if hard then Enum.LineJoinMode.Miter else Enum.LineJoinMode.Round,
				Color = WHITE,
				Thickness = width,
				Transparency = 0,
				ZIndex = 3,
				Enabled = inner:Computed(function(u)
					return u(p.Visible) ~= false
				end),
				[Kit.Children] = inner:New "UIGradient" {
					Rotation = if hard then 45 else 90,
					Transparency = litSequence(top, hard),
				},
			})
		end
		local bottom = if p.Bottom ~= nil then p.Bottom else r.Bottom
		if typeof(bottom) == "number" and bottom < 1 then
			table.insert(out, inner:New "UIStroke" {
				Name = "RimShade",
				ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				BorderStrokePosition = Enum.BorderStrokePosition.Inner,
				LineJoinMode = if hard then Enum.LineJoinMode.Miter else Enum.LineJoinMode.Round,
				Color = if hard then Color3.new(0, 0, 0) else (theme.Color.Shadow or theme.Color.Outline),
				Thickness = p.BottomWidth or r.BottomWidth or width,
				Transparency = 0,
				ZIndex = 3,
				Enabled = inner:Computed(function(u)
					return u(p.Visible) ~= false
				end),
				[Kit.Children] = inner:New "UIGradient" {
					Rotation = if hard then 45 else 90,
					Transparency = shadeSequence(bottom, hard),
				},
			})
		end
		return out
	end)
end

export type OutlineProps = {
	Tone: any?, -- a toned element: tinted families outline it in its own dark shade
	Color: any?, -- override the outer colour
	Width: any?, -- override the outer width (0 = none)
	Transparency: any?,
	Outer: boolean?, -- false: only the inner line (the element already has its outer stroke)
	Inner: boolean?, -- false: only the outer stroke
	Visible: any?,
}

-- Families whose outlines take each toned element's own dark shade (sticker
-- look); the rest keep their ink / trim everywhere.
local TINTED = { Toybox = true, Mochi = true, Tavern = true, Clay = true, Gacha = true }

-- The outer outline colour for an element (Kit.outline's rule).
function Kit.outlineColor(theme: any, tone: any?): Color3
	local fam = theme.Family
	local o = fam and fam.Outline
	if not o then
		return theme.Color.Outline
	end
	if tone and TINTED[fam.Id] then
		return tone.Stroke or tone.Edge
	end
	-- light-edged families: a toned element's rim glows in its own colour
	if tone and (fam.Id == "Neon" or fam.Id == "Holo") then
		return tone.Top
	end
	return Kit.familyColor(theme, o.Color, tone)
end

local function strokeGradient(inner: Scope, theme: any, fam: any): Instance?
	local o = fam.Outline
	if not (o and o.Gradient) then
		return nil
	end
	local c = theme.Color
	if fam.Id == "Royal" then
		-- a gold ramp lit from the top
		return inner:New "UIGradient" {
			Rotation = 90,
			Color = ColorSequence.new({
				ColorSequenceKeypoint.new(0, Color3.fromHex("FFF3C4")),
				ColorSequenceKeypoint.new(0.45, c.Accent),
				ColorSequenceKeypoint.new(1, c.Accent:Lerp(Color3.fromHex("7A4E0A"), 0.6)),
			}),
		}
	end
	-- neon: primary -> secondary across the diagonal
	return inner:New "UIGradient" {
		Rotation = 35,
		Color = ColorSequence.new(c.Primary or c.Trim, c.Secondary or c.Accent),
	}
end

--[[
	The family outline (top Roblox games' "strong stroke with contrast to
	the fill"): an Outer UIStroke tinted dark (a toned element gets its own
	dark shade in sticker families), plus an optional light Inner line: the
	double / sticker outline, Royal's inset gold rule, Sketch's pencil pass.
	Nothing in a classic theme.
]]
function Kit.outline(scope: Scope, props: OutlineProps?)
	local p = props or {}
	return scope:Computed(function(use, inner)
		local theme = use(Theme.current)
		local fam = theme.Family
		if fam == nil or not fam.Outline then
			return nil
		end
		local o = fam.Outline
		local tone = if p.Tone ~= nil then Theme.tone(theme, use(p.Tone)) else nil
		local out = {}
		local width = use(p.Width)
		if width == nil then
			width = o.Width or 0
		end
		local join = if o.Join == "Miter" then Enum.LineJoinMode.Miter else Enum.LineJoinMode.Round
		if p.Outer ~= false and width > 0 then
			table.insert(out, inner:New "UIStroke" {
				Name = "Outline",
				ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				BorderStrokePosition = Enum.BorderStrokePosition.Outer,
				LineJoinMode = join,
				Color = inner:Computed(function(u)
					return u(p.Color) or Kit.outlineColor(theme, tone)
				end),
				Thickness = width,
				Transparency = inner:Computed(function(u)
					return u(p.Transparency) or o.T or 0
				end),
				Enabled = inner:Computed(function(u)
					return u(p.Visible) ~= false
				end),
				ZIndex = 1,
				[Kit.Children] = strokeGradient(inner, theme, fam),
			})
		end
		local lining = o.Inner
		if p.Inner ~= false and lining and not (Theme.lite == true or use(Theme.liteMode) == true) then
			table.insert(out, inner:New "UIStroke" {
				Name = "OutlineInner",
				ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				BorderStrokePosition = Enum.BorderStrokePosition.Inner,
				BorderOffset = UDim.new(0, -(lining.Offset or 0)),
				LineJoinMode = join,
				Color = Kit.familyColor(theme, lining.Color, tone),
				Thickness = lining.Width or 1,
				Transparency = lining.T or 0,
				Enabled = inner:Computed(function(u)
					return u(p.Visible) ~= false
				end),
				ZIndex = 2,
				[Kit.Children] = if lining.Color == "gold" then strokeGradient(inner, theme, fam) else nil,
			})
		end
		return out
	end)
end

-- inner radius = outer radius - inset (polish spec rule R1). `outer` is a
-- UDim, px, or a theme radius name (with `theme`). Pills stay pills.
function Kit.nestRadius(outer: any, inset: number, theme: any?): UDim
	local r = if typeof(outer) == "UDim" then outer else resolveRadius(theme or Theme.get(), outer)
	if r.Scale > 0 then
		return r
	end
	return UDim.new(0, math.max(0, r.Offset - inset))
end

local function radiusOf(theme: any, value: any): UDim
	if value == nil then
		return UDim.new(0, 0)
	end
	return resolveRadius(theme, value)
end

--[[
	A UICorner with per-corner radii (tabs attached to a panel, ribbons,
	speech bubbles, cut corners). Each corner: px, UDim, "S" | "M" | "L" |
	"Full" or a state; missing = square. Without engine support it falls
	back to one radius (the largest given).
]]
function Kit.cornerRadii(scope: Scope, radii: { [string]: any }): UICorner
	local function get(name: string)
		return scope:Computed(function(use)
			return radiusOf(use(Theme.current), use(radii[name]))
		end)
	end
	if not CORNERS_OK then
		return scope:New "UICorner" {
			CornerRadius = scope:Computed(function(use)
				local theme = use(Theme.current)
				local best = UDim.new(0, 0)
				for _, name in { "TopLeft", "TopRight", "BottomRight", "BottomLeft" } do
					local r = radiusOf(theme, use(radii[name]))
					if r.Scale > best.Scale or (r.Scale == best.Scale and r.Offset > best.Offset) then
						best = r
					end
				end
				return best
			end),
		}
	end
	return scope:New "UICorner" {
		TopLeftRadius = get("TopLeft"),
		TopRightRadius = get("TopRight"),
		BottomRightRadius = get("BottomRight"),
		BottomLeftRadius = get("BottomLeft"),
	} :: any
end

-- Cut-corner plates for sci-fi families (Holo / Neon): `radius` on the
-- top-left and bottom-right corners, square top-right and bottom-left.
function Kit.chamfer(scope: Scope, radius: any?): UICorner
	local r = radius or scope:Computed(function(use)
		local fam = use(Theme.current).Family
		return (fam and fam.Chamfer) or 12
	end)
	return Kit.cornerRadii(scope, { TopLeft = r, BottomRight = r, TopRight = 0, BottomLeft = 0 })
end

--[[
	The fill of a coloured card / tile / item slot in a style family
	(theme.Family.Card):
	  "Vivid"  the tone's own light-to-deep gradient (toy, clay, kawaii, anime, fantasy)
	  "Tint"   a dark card carrying a wash of the tone, brightest at the top
	           (minimal, neon, holo, royal, dread: colour lives in rims and glows)
	  "Flat"   a nearly flat slab of the tone (broadcast, pixel, comic)
	Returns a ColorSequence for a UIGradient over a white fill; nil in a
	classic theme (keep the component's own gradient). `lift` (0..1) brightens
	it (hover).
]]
function Kit.cardFill(theme: any, tone: any, lift: number?): ColorSequence?
	local fam = theme.Family
	if fam == nil or tone == nil then
		return nil
	end
	local l = lift or 0
	local mid = tone.Mid or tone.Bottom
	local kind = fam.Card or "Vivid"
	if kind == "Tint" then
		local base = if fam.Light then (theme.Color.Card or theme.Color.Surface) else (theme.Color.Panel or theme.Color.Surface)
		return ColorSequence.new({
			ColorSequenceKeypoint.new(0, Color.lighten(mid:Lerp(base, 0.42), l * 0.2)),
			ColorSequenceKeypoint.new(0.55, Color.lighten(mid:Lerp(base, 0.7), l * 0.15)),
			ColorSequenceKeypoint.new(1, base:Lerp(tone.Deep, 0.25)),
		})
	elseif kind == "Flat" then
		return ColorSequence.new({
			ColorSequenceKeypoint.new(0, Color.lighten(mid, l * 0.2)),
			ColorSequenceKeypoint.new(0.7, Color.lighten(mid, l * 0.2)),
			ColorSequenceKeypoint.new(1, tone.Bottom),
		})
	end
	return ColorSequence.new({
		ColorSequenceKeypoint.new(0, Color.lighten(tone.Top, 0.06 + l * 0.25)),
		ColorSequenceKeypoint.new(0.5, Color.lighten(mid, l * 0.2)),
		ColorSequenceKeypoint.new(1, tone.Bottom:Lerp(tone.Deep, 0.28)),
	})
end

-- A text size raised to the family's minimum (HUD text has its own floor).
-- Classic themes get the size back unchanged.
function Kit.minText(theme: any, size: number, hud: boolean?): number
	local fam = theme and theme.Family
	if fam == nil or typeof(size) ~= "number" then
		return size
	end
	local floor = if hud then Theme.Base.TYPE_MIN.Hud else (fam.MinText or 14)
	return math.max(size, floor)
end

-- v2.4 style families: the smallest TextSize at which `font` (a Font or a
-- theme slot name) draws letters with an em of Base.GLYPH_MIN design px
-- (Base.FONT_EM). 0 in a classic theme (no optical floor).
function Kit.opticalMin(theme: any, font: any): number
	if theme == nil or theme.Family == nil then
		return 0
	end
	local face = if typeof(font) == "Font" then font else Theme.font(theme, font or "Body")
	return math.ceil(Theme.Base.GLYPH_MIN / Theme.Base.emRatio(face))
end

-- UIShadows and UIStrokes drawn on screen under `root` (the per-screen
-- budget): enabled, and with every GuiObject above them visible (Roblox
-- doesn't draw what sits in a hidden frame).
function Kit.budget(root: Instance): { shadows: number, strokes: number }
	local shadows, strokes = 0, 0
	local shown: { [Instance]: boolean } = {}
	local function visible(item: Instance): boolean
		local parent = item.Parent
		if parent == nil or item == root then
			return true
		end
		local cached = shown[parent]
		if cached ~= nil then
			return cached
		end
		local ok = true
		if parent:IsA("GuiObject") and not (parent :: GuiObject).Visible then
			ok = false
		elseif parent:IsA("LayerCollector") and not (parent :: any).Enabled then
			ok = false
		elseif parent ~= root then
			ok = visible(parent)
		end
		shown[parent] = ok
		return ok
	end
	for _, item in root:GetDescendants() do
		if item.ClassName == "UIShadow" then
			if (item :: any).Enabled ~= false and visible(item) then
				shadows += 1
			end
		elseif item:IsA("UIStroke") and (item :: UIStroke).Enabled and visible(item) then
			strokes += 1
		end
	end
	return { shadows = shadows, strokes = strokes }
end

export type SoftGlowProps = {
	Color: any?, -- any colour (state ok), default white
	Spread: number?, -- how far the glow reaches past the element, px (default 18)
	Transparency: any?, -- at the element's edge (state ok), default 0.35
	Offset: Vector2?,
	ZIndex: number?,
	Visible: any?,
}

--[[
	A soft rounded glow in ANY colour (warm golds and reds too; the engine's
	9-slice shadow texture is baked blue). Parent it next to (behind) the
	element, like Kit.shadow; it's sized to its parent.
]]
function Kit.softGlow(scope: Scope, props: SoftGlowProps?): ImageLabel
	local p = props or {}
	local spread = p.Spread or 18
	local slice = spread / Assets.SoftGlowFalloff
	local reach = math.ceil(256 * slice)
	local offset = p.Offset or Vector2.zero
	return scope:New "ImageLabel" {
		Name = "SoftGlow",
		BackgroundTransparency = 1,
		Image = Assets.Glow,
		ImageColor3 = p.Color or WHITE,
		ImageTransparency = p.Transparency or 0.35,
		ScaleType = Enum.ScaleType.Slice,
		SliceCenter = Assets.SoftGlowSlice,
		SliceScale = slice,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.new(0.5, offset.X, 0.5, offset.Y),
		Size = UDim2.new(1, reach * 2, 1, reach * 2),
		ZIndex = p.ZIndex or 0,
		Visible = if p.Visible == nil then true else p.Visible,
	}
end

export type RimGlowProps = {
	Color: any?, -- default: theme Color.Focus
	Thickness: number?, -- outer width in px (default 28)
	Radius: any?, -- the element's corner radius (default "M")
	Strength: any?, -- 0..1 (state ok): fades / breathes the whole glow
	ZIndex: number?,
}

-- Rim layers: { share of Thickness, transparency } from dense to faint.
local RIM = { { 0.14, 0.5 }, { 0.36, 0.72 }, { 0.64, 0.85 }, { 1, 0.93 } }

--[[
	A soft coloured rim around a rounded element, from stacked strokes: dense
	at the edge, fading outward. Strokes only paint outside the element, so
	it can sit inside [Children] without tinting the face. Any colour works.

		[Children] = Kit.rimGlow(scope, { Color = gold, Thickness = 28, Strength = breath })
]]
function Kit.rimGlow(scope: Scope, props: RimGlowProps?): Frame
	local p = props or {}
	local thickness = p.Thickness or 28
	local color = Kit.pick(scope, p.Color, function(theme)
		return theme.Color.Focus or theme.Color.Accent
	end)
	local strength = p.Strength or 1
	local layers = {}
	for index, layer in RIM do
		local width, alpha = math.max(1, thickness * layer[1]), layer[2]
		table.insert(layers, scope:New "Frame" {
			Name = `Rim{index}`,
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			ZIndex = p.ZIndex or 0,
			[Kit.Children] = {
				Kit.corner(scope, p.Radius or "M"),
				scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
					LineJoinMode = Enum.LineJoinMode.Round,
					Color = color,
					Thickness = width,
					Transparency = scope:Computed(function(use)
						local s = math.clamp(use(strength), 0, 1)
						return 1 - (1 - alpha) * s
					end),
				},
			},
		})
	end
	return scope:New "Frame" {
		Name = "RimGlow",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		ZIndex = p.ZIndex or 0,
		[Kit.Children] = layers,
	}
end

-- Soft round glow (for icons, rewards, hover halos). Centred on its parent.
function Kit.glow(scope: Scope, props: { [string]: any }): ImageLabel
	return scope:New "ImageLabel" {
		Name = props.Name or "Glow",
		BackgroundTransparency = 1,
		Image = Assets.Glow,
		ImageColor3 = props.Color or WHITE,
		ImageTransparency = props.Transparency or 0.4,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position or UDim2.fromScale(0.5, 0.5),
		Size = props.Size or UDim2.fromScale(1.8, 1.8),
		ZIndex = props.ZIndex or 0,
		Visible = props.Visible,
	}
end

--------------------------------------------------------------------------
-- Surfaces: the layered fills every panel-like thing uses (QUALITY rule 2)
--------------------------------------------------------------------------

local SURFACES = {
	Glass = true,
	Solid = true,
	Light = true,
	Tone = true,
	Gradient = true,
	Outlined = true,
	Ribbon = true,
	Fade = true,
}
Kit.SURFACES = SURFACES

export type SurfaceLook = {
	fill: Color3,
	alpha: number,
	gradient: ColorSequence,
	rotation: number,
	stroke: Color3,
	strokeAlpha: number,
	strokeWidth: number,
	highlight: number, -- 1 = none
	rim: boolean,
	rimColor: Color3,
	light: boolean, -- true when the surface is light (use dark text / tinted decorations)
	accent: Color3, -- colour for decorations
	fade: NumberSequence?, -- transparency ramp (Fade panels)
}

local GREY_FADE = ColorSequence.new(WHITE, Color3.fromRGB(200, 200, 200))
local GREY_SOFT = ColorSequence.new(WHITE, Color3.fromRGB(226, 226, 226))

-- v2.4: how a surface style looks in a style family. Glass stays the dark
-- HUD glass (with the family's edge); Solid / Outlined / Ribbon / Light are
-- the family panel (light or dark, outlined by the family recipe); Tone /
-- Gradient are coloured cards shaded in OKLCH. The inner highlight frame and
-- the classic rim are off: Kit.rim / Kit.outline / Kit.shadows draw the light.
local function familyLook(theme: any, fam: any, style: string, tone: any?, strokeOverride: any?): SurfaceLook
	local c = theme.Color
	local t = if tone ~= nil then Theme.tone(theme, tone) else nil
	local o = fam.Outline or {}
	local lightPanel = fam.Light == true
	local look: any = {
		fill = c.Panel,
		alpha = 0,
		gradient = ColorSequence.new(WHITE, if lightPanel then Color3.new(0.955, 0.955, 0.955) else Color3.new(0.8, 0.8, 0.8)),
		rotation = 90,
		stroke = Kit.familyColor(theme, o.Color or "hairline"),
		strokeAlpha = if fam.Outline then (o.T or 0) else 1,
		strokeWidth = o.Width or theme.Stroke.UI,
		highlight = 1,
		rim = false,
		rimColor = WHITE,
		light = lightPanel,
		accent = if lightPanel then (c.Trim or c.Accent) else WHITE,
		family = fam.Id,
	}
	if style == "Glass" then
		look.fill = c.Surface
		look.alpha = theme.Alpha.Surface
		look.gradient = ColorSequence.new(WHITE, Color3.new(0.86, 0.86, 0.86))
		look.stroke = WHITE
		look.strokeAlpha = 0.84
		look.strokeWidth = 1.5
		look.light = false
		look.accent = WHITE
		-- ink families outline their HUD plates too
		if fam.Outline and (o.Color == "ink" or o.Color == "black") and (o.Width or 0) >= 3 then
			look.stroke = Kit.familyColor(theme, o.Color)
			look.strokeAlpha = 0
			look.strokeWidth = math.max(2, (o.Width or 3) * 0.75)
		elseif fam.Outline and (o.Color == "accent" or o.Color == "gold") then
			look.stroke = c.Trim
			look.strokeAlpha = math.max(0.3, o.T or 0)
			look.strokeWidth = math.max(1, o.Width or 1)
		end
	elseif style == "Tone" or style == "Gradient" then
		local base = t or theme.Tone.Primary or Theme.makeTone(c.Panel)
		look.fill = WHITE
		look.gradient = ColorSequence.new({
			ColorSequenceKeypoint.new(0, base.Top),
			ColorSequenceKeypoint.new(0.5, base.Mid or base.Bottom),
			ColorSequenceKeypoint.new(1, base.Bottom),
		})
		look.rotation = if style == "Gradient" then ((fam.Fill and fam.Fill.Angle ~= 90 and fam.Fill.Angle) or 65) else 90
		look.stroke = Kit.outlineColor(theme, base)
		look.strokeAlpha = if fam.Outline then (o.T or 0) else 1
		look.strokeWidth = if fam.Outline then (o.Width or 2) else 0
		look.light = Color.luminance(base.Mid or base.Bottom) > 0.72
		look.accent = WHITE
	elseif style == "Light" then
		look.fill = WHITE
		look.gradient = ColorSequence.new(WHITE, Color3.new(0.95, 0.95, 0.95))
		look.light = true
		look.stroke = Kit.familyColor(theme, "ink")
		look.strokeAlpha = if fam.Outline and (o.T or 0) < 0.5 then o.T or 0 else 0.85
		look.accent = c.Trim or c.Accent
	else
		-- Solid / Outlined / Ribbon: the family's panel
		look.alpha = (fam.Fill and fam.Fill.Translucent) or 0
		if style == "Outlined" or style == "Ribbon" then
			look.stroke = c.Trim or look.stroke
			look.strokeAlpha = 0
			look.strokeWidth = math.max(3, (o.Width or 2) * 1.25)
		end
		if c.Panel and Color.luminance(c.Panel) > 0.6 then
			look.light = true
		end
	end
	if typeof(strokeOverride) == "number" then
		look.strokeWidth = strokeOverride
	end
	if look.strokeWidth <= 0 then
		look.strokeAlpha = 1
		look.strokeWidth = 1
	end
	return look :: SurfaceLook
end
Kit.familyLook = familyLook

-- Works out how a surface style looks in a theme. Pure function, handy in
-- your own Computeds.
function Kit.surfaceLook(theme: any, style: string, tone: any?, strokeOverride: any?): SurfaceLook
	if theme.Family ~= nil and style ~= "Fade" then
		return familyLook(theme, theme.Family, style, tone, strokeOverride)
	end
	local c = theme.Color
	local t = if tone ~= nil then Theme.tone(theme, tone) else nil
	local look: any = {
		fill = c.Surface,
		alpha = 0,
		gradient = GREY_FADE,
		rotation = 90,
		stroke = c.Outline,
		strokeAlpha = 0,
		strokeWidth = theme.Stroke.UI,
		highlight = 0.9,
		rim = false,
		rimColor = WHITE,
		light = false,
		accent = c.Glass,
	}
	if style == "Glass" then
		look.fill = c.Surface
		look.alpha = theme.Alpha.Surface
		look.stroke = c.Glass
		look.strokeAlpha = theme.Alpha.Rim or 0.88
		look.strokeWidth = theme.Stroke.Thin
		look.highlight = 0.94
	elseif style == "Fade" then
		-- Rivals' task panel: a dark plate attached to a screen edge that
		-- fades out towards the centre instead of ending in a box. (A style
		-- family's panel may be light: its fade plate stays the dark glass.)
		look.fill = if theme.Family then c.Surface else c.Panel
		look.alpha = 0
		look.gradient = ColorSequence.new(WHITE)
		look.rotation = 0
		look.strokeAlpha = 1
		look.highlight = 1
		look.fade = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 0.12),
			NumberSequenceKeypoint.new(0.55, 0.6),
			NumberSequenceKeypoint.new(1, 1),
		})
	elseif style == "Solid" then
		look.fill = c.SurfaceLight
		look.gradient = GREY_FADE
		look.stroke = c.Outline
		look.highlight = 0.9
	elseif style == "Light" then
		look.fill = c.Light
		look.gradient = GREY_SOFT
		look.stroke = theme.Tone.White.Edge
		look.highlight = 1
		look.light = true
	elseif style == "Tone" or style == "Gradient" then
		-- no tone given: the theme's primary, else a tone built from its panel colour
		local base = t or theme.Tone.Primary or Theme.makeTone(c.Panel)
		local archetype = Theme.style(theme, "Archetype")
		local smoky = style == "Gradient" and t == nil and (archetype == "Horror" or archetype == "Tactical")
		look.fill = WHITE
		if style == "Tone" then
			look.gradient = ColorSequence.new(base.Top, Color.darken(base.Bottom, 0.08))
		elseif smoky then
			-- serious themes: a deep, smoky wash of the primary that sinks into
			-- the theme's surface (a full-strength blood red / amber window is
			-- a toy, not a menu)
			look.gradient = ColorSequence.new({
				ColorSequenceKeypoint.new(0, base.Edge:Lerp(c.Surface, 0.3)),
				ColorSequenceKeypoint.new(0.55, base.Deep:Lerp(c.Surface, 0.25)),
				ColorSequenceKeypoint.new(1, c.Surface),
			})
			look.rotation = 65
		else
			look.gradient = ColorSequence.new({
				ColorSequenceKeypoint.new(0, Color.lighten(base.Top, 0.08)),
				ColorSequenceKeypoint.new(0.5, base.Bottom),
				ColorSequenceKeypoint.new(1, base.Deep:Lerp(base.Bottom, 0.45)),
			})
			look.rotation = 65
		end
		look.stroke = base.Edge
		look.highlight = if smoky then 0.92 else 0.8
		look.light = not smoky and Color.luminance(base.Bottom) > 0.72
		look.accent = WHITE
	elseif style == "Outlined" or style == "Ribbon" then
		look.fill = c.Panel
		look.gradient = GREY_SOFT
		look.stroke = if t then t.Bottom else c.Trim
		look.strokeWidth = theme.Stroke.UI * 1.6
		look.rim = true
		look.light = Color.luminance(c.Panel) > 0.6
		look.rimColor = if look.light then WHITE else look.stroke:Lerp(WHITE, 0.35)
		look.highlight = if look.light then 1 else 0.92
		look.accent = if look.light then look.stroke else c.Trim
	end
	if typeof(strokeOverride) == "number" then
		look.strokeWidth = strokeOverride
	end
	return look
end

export type SurfaceSpec = {
	Style: any?, -- "Glass" | "Solid" | "Light" | "Tone" | "Gradient" | "Outlined" | "Ribbon" (default: theme PanelStyle)
	Tone: any?,
	Radius: any?,
	Stroke: any?, -- true | false | number
	StrokeColor: any?,
	Color: any?, -- fill override
	Transparency: any?, -- fill transparency override
	Gradient: any?, -- ColorSequence override
	GradientRotation: any?,
	Highlight: any?, -- false to turn the inner highlight off
	Decoration: any?, -- "None" | "Sparkles" | "Stripes" | "Dots" | "Grain" (default: theme)
	DefaultStyle: string?, -- used instead of the theme PanelStyle when Style is nil
	FadeFrom: string?, -- Fade panels: "Left" (default, fades to the right) | "Right"
	-- v2.4 (style families only; classic themes ignore these):
	Role: any?, -- elevation role for Kit.shadows: "Window" | "Card" | "Hud" | "Chip" | "Button"
	-- (default: "Hud" for Glass, none otherwise); false = no family layers at all
	Texture: any?, -- a Kit.texture kind, true = the family's for the role, false = none
	-- (default: windows get the family's texture, nothing else does)
	Chamfer: boolean?, -- cut corners (Kit.chamfer) instead of Radius
}

-- The current surface style name for a spec.
function Kit.surfaceStyle(scope: Scope, style: any, default: string?)
	return scope:Computed(function(use)
		local given = use(style)
		if given ~= nil and SURFACES[given] then
			return given
		end
		if default then
			return default
		end
		return Theme.style(use(Theme.current), "PanelStyle")
	end)
end

--[[
	Builds a themed, layered surface. Returns two things:
	  look      { BackgroundColor3, BackgroundTransparency, Style, Look }
	            (put the first two on your frame; Look is a Computed SurfaceLook)
	  children  corner, stroke, gradient, inner highlight, inner rim and
	            decoration layers to parent to the same frame.
	Content you add to the frame should use ZIndex >= 1 (the default).
]]
function Kit.surface(scope: Scope, spec: SurfaceSpec?): ({ [string]: any }, { any })
	local s = spec or {}
	local style = Kit.surfaceStyle(scope, s.Style, s.DefaultStyle)
	local look = scope:Computed(function(use)
		return Kit.surfaceLook(use(Theme.current), use(style), use(s.Tone), use(s.Stroke))
	end)
	local radius = s.Radius or "M"

	local fill = scope:Computed(function(use)
		return use(s.Color) or use(look).fill
	end)
	local alpha = scope:Computed(function(use)
		local given = use(s.Transparency)
		if given ~= nil then
			return given
		end
		return use(look).alpha
	end)

	local strokeOn = scope:Computed(function(use)
		return use(s.Stroke) ~= false
	end)

	local children = {
		if s.Chamfer then Kit.chamfer(scope, scope:Computed(function(use)
			local fam = use(Theme.current).Family
			return (fam and fam.Chamfer) or resolveRadius(use(Theme.current), use(radius)).Offset
		end)) else Kit.corner(scope, radius),
		scope:New "UIStroke" {
			ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
			LineJoinMode = shared("surfaceJoin", function(theme)
				local fam = theme.Family
				local join = fam and fam.Outline and fam.Outline.Join
				return if join == "Miter" then Enum.LineJoinMode.Miter else Enum.LineJoinMode.Round
			end),
			Color = scope:Computed(function(use)
				return use(s.StrokeColor) or use(look).stroke
			end),
			Thickness = scope:Computed(function(use)
				return use(look).strokeWidth
			end),
			Transparency = scope:Computed(function(use)
				return use(look).strokeAlpha
			end),
			Enabled = strokeOn,
		},
		scope:New "UIGradient" {
			Rotation = scope:Computed(function(use)
				local current = use(look)
				if current.fade then
					return if s.FadeFrom == "Right" then 180 else 0
				end
				return use(s.GradientRotation) or current.rotation
			end),
			Color = scope:Computed(function(use)
				local given = use(s.Gradient)
				if typeof(given) == "ColorSequence" then
					return given
				end
				return use(look).gradient
			end),
			Transparency = scope:Computed(function(use)
				return use(look).fade or NumberSequence.new(0)
			end),
		},
		-- Highlight = false leaves the layer out entirely (for frames whose
		-- children are arranged by a UIListLayout).
		if s.Highlight == false then nil else scope:New "Frame" {
			Name = "Highlight",
			BackgroundColor3 = WHITE,
			BorderSizePixel = 0,
			Size = UDim2.fromScale(1, 0.48),
			ZIndex = 0,
			Visible = scope:Computed(function(use)
				return use(s.Highlight) ~= false and use(look).highlight < 1
			end),
			[Kit.Children] = {
				Kit.corner(scope, radius),
				scope:New "UIGradient" {
					Rotation = 90,
					Transparency = scope:Computed(function(use)
						return NumberSequence.new(use(look).highlight, 1)
					end),
				},
			},
		},
		scope:Computed(function(use, inner)
			local current = use(look)
			if not current.rim then
				return nil
			end
			local inset = math.max(2, math.floor(current.strokeWidth * 0.6))
			return inner:New "Frame" {
				Name = "Rim",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.new(1, -inset * 2, 1, -inset * 2),
				BackgroundTransparency = 1,
				ZIndex = 0,
				[Kit.Children] = {
					inner:New "UICorner" {
						CornerRadius = inner:Computed(function(useInner)
							local r = resolveRadius(useInner(Theme.current), useInner(radius))
							return UDim.new(r.Scale, math.max(0, r.Offset - inset))
						end),
					},
					inner:New "UIStroke" {
						Color = current.rimColor,
						Thickness = math.max(1.5, current.strokeWidth * 0.45),
						Transparency = if current.light then 0.15 else 0.55,
					},
				},
			}
		end),
		Kit.decoration(scope, {
			Decoration = scope:Computed(function(use)
				return use(s.Decoration) or "None"
			end),
			Look = look,
		}),
	}

	-- v2.4 style families: layered light (nothing is built in a classic theme)
	if s.Role ~= false then
		local role = scope:Computed(function(use)
			local given = use(s.Role)
			if given ~= nil then
				return given
			end
			-- HUD glass floats over the world; other surfaces get their shadow
			-- from Kit.elevation / the component (or pass a Role)
			return if use(style) == "Glass" then "Hud" else "None"
		end)
		local fading = scope:Computed(function(use)
			return use(look).fade ~= nil
		end)
		-- (the list above can hold a nil, so these go at fixed indices)
		children[7] = Kit.shadows(scope, {
			Role = role,
			Tone = s.Tone,
			Visible = scope:Computed(function(use)
				return not use(fading)
			end),
		})
		children[8] = Kit.rim(scope, {
			Visible = scope:Computed(function(use)
				return not use(fading) and use(s.Highlight) ~= false
			end),
		})
		children[9] = Kit.outline(scope, {
			Tone = s.Tone,
			Outer = false,
			Visible = scope:Computed(function(use)
				return not use(fading) and use(style) ~= "Glass" and use(s.Stroke) ~= false
			end),
		})
		-- (the texture is a Frame: a surface whose children a UIListLayout
		-- arranges, Highlight = false, would get it laid out as a block above
		-- its content, like the wood slab over the quest list in Tavern HUDs)
		if s.Texture ~= false and s.Highlight ~= false then
			children[10] = Kit.texture(scope, {
				Kind = scope:Computed(function(use)
					local given = use(s.Texture)
					if typeof(given) == "string" then
						return given
					end
					local fam = use(Theme.current).Family
					if fam == nil or fam.Texture == nil then
						return nil
					end
					if given == true or use(role) == "Window" then
						return fam.Texture.Window
					end
					return nil
				end),
				Light = scope:Computed(function(use)
					return use(look).light
				end),
				Inset = scope:Computed(function(use)
					local r = resolveRadius(use(Theme.current), use(radius))
					return if r.Scale > 0 then 12 else math.floor(r.Offset * 0.35)
				end),
			})
		end
	end

	return {
		BackgroundColor3 = fill,
		BackgroundTransparency = alpha,
		Style = style,
		Look = look,
	}, children
end

--------------------------------------------------------------------------
-- Decorations: subtle pattern layers (Sparkles / Stripes / Dots / Grain)
--------------------------------------------------------------------------

-- Deterministic pseudo random numbers so renders are stable.
local function seeded(seed: number): () -> number
	local state = seed % 2147483647
	if state <= 0 then
		state += 2147483646
	end
	return function()
		state = (state * 16807) % 2147483647
		return (state - 1) / 2147483646
	end
end

local function sparkle(inner: Scope, position: UDim2, size: number, color: Color3, alpha: number, rotation: number)
	local function arm(vertical: boolean)
		return inner:New "Frame" {
			Name = if vertical then "ArmV" else "ArmH",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = if vertical then UDim2.new(0, math.max(2, size * 0.14), 1, 0) else UDim2.new(1, 0, 0, math.max(2, size * 0.14)),
			BackgroundColor3 = color,
			BackgroundTransparency = alpha,
			BorderSizePixel = 0,
			[Kit.Children] = {
				inner:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
				inner:New "UIGradient" {
					Rotation = if vertical then 90 else 0,
					Transparency = NumberSequence.new({
						NumberSequenceKeypoint.new(0, 1),
						NumberSequenceKeypoint.new(0.5, 0),
						NumberSequenceKeypoint.new(1, 1),
					}),
				},
			},
		}
	end
	return inner:New "Frame" {
		Name = "Sparkle",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = position,
		Size = UDim2.fromOffset(size, size),
		Rotation = rotation,
		BackgroundTransparency = 1,
		[Kit.Children] = {
			arm(true),
			arm(false),
			inner:New "Frame" {
				Name = "Core",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.fromOffset(math.max(3, size * 0.22), math.max(3, size * 0.22)),
				BackgroundColor3 = color,
				BackgroundTransparency = math.max(0, alpha - 0.1),
				BorderSizePixel = 0,
				[Kit.Children] = inner:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
			},
		},
	}
end

local DECORATIONS = {}

function DECORATIONS.Sparkles(inner: Scope, color: Color3, light: boolean)
	local alpha = if light then 0.45 else 0.35
	return {
		sparkle(inner, UDim2.new(1, -34, 0, 30), 30, color, alpha, 0),
		sparkle(inner, UDim2.new(1, -70, 0, 58), 16, color, alpha + 0.15, 0),
		sparkle(inner, UDim2.new(0, 28, 1, -30), 24, color, alpha + 0.05, 0),
		sparkle(inner, UDim2.new(0, 58, 1, -54), 12, color, alpha + 0.2, 0),
		sparkle(inner, UDim2.new(0.62, 0, 0, 16), 12, color, alpha + 0.25, 0),
	}
end

function DECORATIONS.Stripes(inner: Scope, color: Color3, light: boolean)
	local on = if light then 0.9 else 0.955
	local keys = { NumberSequenceKeypoint.new(0, 1) }
	for band = 1, 9 do
		local at = band / 10
		local before = if band % 2 == 1 then 1 else on
		local after = if band % 2 == 1 then on else 1
		table.insert(keys, NumberSequenceKeypoint.new(at - 0.001, before))
		table.insert(keys, NumberSequenceKeypoint.new(at, after))
	end
	table.insert(keys, NumberSequenceKeypoint.new(1, on))
	return {
		inner:New "Frame" {
			Name = "Stripes",
			Size = UDim2.fromScale(1, 1),
			BackgroundColor3 = color,
			BorderSizePixel = 0,
			[Kit.Children] = {
				inner:New "UIGradient" {
					Rotation = 35,
					Transparency = NumberSequence.new(keys),
				},
			},
		},
	}
end

function DECORATIONS.Dots(inner: Scope, color: Color3, light: boolean)
	local dots = {}
	local base = if light then 0.72 else 0.86
	local spacing = 22
	local function cluster(fromRight: boolean, fromBottom: boolean, cols: number, rows: number, maxSize: number)
		local reach = math.sqrt(cols * cols + rows * rows)
		for row = 0, rows - 1 do
			for col = 0, cols - 1 do
				local d = math.sqrt(col * col + row * row) / reach
				local size = math.floor(maxSize * (1 - d) + 0.5)
				if size >= 3 then
					local x = 18 + col * spacing + (if row % 2 == 1 then spacing / 2 else 0)
					local y = 18 + row * spacing
					table.insert(dots, inner:New "ImageLabel" {
						Name = "Dot",
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.new(if fromRight then 1 else 0, if fromRight then -x else x, if fromBottom then 1 else 0, if fromBottom then -y else y),
						Size = UDim2.fromOffset(size, size),
						BackgroundTransparency = 1,
						Image = Assets.Dot,
						ImageColor3 = color,
						ImageTransparency = base + (1 - base) * d * 0.6,
					})
				end
			end
		end
	end
	cluster(true, false, 7, 5, 10)
	cluster(false, true, 5, 3, 8)
	return dots
end

function DECORATIONS.Grain(inner: Scope, color: Color3, light: boolean)
	local random = seeded(7)
	local specks = {}
	for _ = 1, 70 do
		local size = if random() > 0.8 then 3 else 2
		table.insert(specks, inner:New "Frame" {
			Name = "Speck",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.03 + random() * 0.94, 0, 0.04 + random() * 0.92, 0),
			Size = UDim2.fromOffset(size, size),
			BackgroundColor3 = if light then BLACK else color,
			BackgroundTransparency = (if light then 0.82 else 0.8) + random() * 0.12,
			BorderSizePixel = 0,
		})
	end
	return specks
end

Kit.DECORATIONS = { "None", "Sparkles", "Stripes", "Dots", "Grain" }

--------------------------------------------------------------------------
-- v2.4 textures: subtle full-size pattern layers built from frames and
-- hard-stop gradients (no uploaded images, so nothing can be moderated
-- away and the preview renderer draws them exactly)
--------------------------------------------------------------------------

-- `count` thin lines spread evenly across the frame, four per gradient frame
-- (a NumberSequence holds at most 20 keypoints). axis "H" = horizontal lines,
-- "V" = vertical; `width` is the line's share of its period.
local function bands(inner: Scope, count: number, axis: string, width: number, color: Color3, alpha: number, z: number): { any }
	local per = 4
	local frames = {}
	local total = math.max(1, math.ceil(count / per))
	for f = 0, total - 1 do
		local keys = { NumberSequenceKeypoint.new(0, 1) }
		for b = 0, per - 1 do
			local start = (b + 0.5 - width / 2) / per
			local stop = (b + 0.5 + width / 2) / per
			table.insert(keys, NumberSequenceKeypoint.new(math.max(0.0001, start - 0.0008), 1))
			table.insert(keys, NumberSequenceKeypoint.new(start, alpha))
			table.insert(keys, NumberSequenceKeypoint.new(stop, alpha))
			table.insert(keys, NumberSequenceKeypoint.new(math.min(0.9999, stop + 0.0008), 1))
		end
		table.insert(keys, NumberSequenceKeypoint.new(1, 1))
		local horizontal = axis == "H"
		table.insert(frames, inner:New "Frame" {
			Name = "Band",
			BackgroundColor3 = color,
			BorderSizePixel = 0,
			Position = if horizontal then UDim2.fromScale(0, f / total) else UDim2.fromScale(f / total, 0),
			Size = if horizontal then UDim2.fromScale(1, 1 / total) else UDim2.fromScale(1 / total, 1),
			ZIndex = z,
			[Kit.Children] = inner:New "UIGradient" {
				Rotation = if horizontal then 90 else 0,
				Transparency = NumberSequence.new(keys),
			},
		})
	end
	return frames
end

local function edgeFade(inner: Scope, side: string, depth: number, color: Color3, alpha: number, z: number)
	local horizontal = side == "Top" or side == "Bottom"
	local far = side == "Bottom" or side == "Right"
	return inner:New "Frame" {
		Name = `Edge{side}`,
		BackgroundColor3 = color,
		BorderSizePixel = 0,
		AnchorPoint = Vector2.new(if side == "Right" then 1 else 0, if side == "Bottom" then 1 else 0),
		Position = UDim2.fromScale(if side == "Right" then 1 else 0, if side == "Bottom" then 1 else 0),
		Size = if horizontal then UDim2.new(1, 0, 0, depth) else UDim2.new(0, depth, 1, 0),
		ZIndex = z,
		[Kit.Children] = inner:New "UIGradient" {
			Rotation = if horizontal then (if far then 270 else 90) else (if far then 180 else 0),
			Transparency = NumberSequence.new(alpha, 1),
		},
	}
end

local TEXTURES = {}

function TEXTURES.Grain(inner: Scope, color: Color3, light: boolean, _: number, z: number)
	local specks = DECORATIONS.Grain(inner, color, light)
	for _, speck in specks do
		speck.ZIndex = z
	end
	return specks
end

function TEXTURES.Noise(inner: Scope, color: Color3, light: boolean, _: number, z: number)
	local random = seeded(11)
	local out = {}
	for _ = 1, 110 do
		table.insert(out, inner:New "Frame" {
			Name = "Speck",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.02 + random() * 0.96, 0.03 + random() * 0.94),
			Size = UDim2.fromOffset(2, 2),
			BackgroundColor3 = if light then BLACK else color,
			BackgroundTransparency = 0.9 + random() * 0.06,
			BorderSizePixel = 0,
			ZIndex = z,
		})
	end
	return out
end

function TEXTURES.Scanlines(inner: Scope, color: Color3, light: boolean, alpha: number, z: number)
	return bands(inner, 64, "H", 0.4, if light then BLACK else color, math.max(alpha, if light then 0.9 else 0.93), z)
end

function TEXTURES.Grid(inner: Scope, color: Color3, light: boolean, alpha: number, z: number)
	local a = math.max(alpha, if light then 0.9 else 0.92)
	local lineColor = if light then BLACK else color
	local out = bands(inner, 24, "V", 0.04, lineColor, a, z)
	for _, frame in bands(inner, 16, "H", 0.06, lineColor, a, z) do
		table.insert(out, frame)
	end
	return out
end

function TEXTURES.Paper(inner: Scope, _: Color3, _: boolean, alpha: number, z: number)
	local out = bands(inner, 20, "H", 0.05, Color3.fromHex("6F9AD8"), math.max(0.55, alpha - 0.2), z)
	-- the red margin line
	table.insert(out, inner:New "Frame" {
		Name = "Margin",
		BackgroundColor3 = Color3.fromHex("E8483A"),
		BackgroundTransparency = 0.55,
		BorderSizePixel = 0,
		Position = UDim2.fromOffset(56, 0),
		Size = UDim2.new(0, 2, 1, 0),
		ZIndex = z,
	})
	return out
end

function TEXTURES.Halftone(inner: Scope, color: Color3, light: boolean, alpha: number, z: number)
	local dots = {}
	local ink = if light then Color3.fromRGB(17, 17, 17) else color
	local function cluster(fromRight: boolean, fromBottom: boolean, cols: number, rows: number, maxSize: number)
		local reach = math.sqrt(cols * cols + rows * rows)
		for row = 0, rows - 1 do
			for col = 0, cols - 1 do
				local d = math.sqrt(col * col + row * row) / reach
				local size = math.floor(maxSize * (1 - d) + 0.5)
				if size >= 3 then
					local x = 14 + col * 16 + (if row % 2 == 1 then 8 else 0)
					local y = 14 + row * 14
					table.insert(dots, inner:New "ImageLabel" {
						Name = "Dot",
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.new(if fromRight then 1 else 0, if fromRight then -x else x, if fromBottom then 1 else 0, if fromBottom then -y else y),
						Size = UDim2.fromOffset(size, size),
						BackgroundTransparency = 1,
						Image = Assets.Dot,
						ImageColor3 = ink,
						ImageTransparency = alpha,
						ZIndex = z,
					})
				end
			end
		end
	end
	cluster(true, true, 11, 7, 11)
	cluster(false, false, 6, 4, 8)
	return dots
end

function TEXTURES.Dots(inner: Scope, color: Color3, light: boolean, _: number, z: number)
	local dots = DECORATIONS.Dots(inner, color, light)
	for _, dot in dots do
		dot.ZIndex = z
	end
	return dots
end

function TEXTURES.Stripes(inner: Scope, color: Color3, light: boolean, _: number, z: number)
	local stripes = DECORATIONS.Stripes(inner, color, light)
	for _, frame in stripes do
		frame.ZIndex = z
	end
	return stripes
end

function TEXTURES.Wood(inner: Scope, _: Color3, _: boolean, alpha: number, z: number)
	local light, dark = Color3.fromHex("8A5A36"), Color3.fromHex("4A2C18")
	local random = seeded(23)
	local keys = {}
	for i = 0, 18 do
		local t = i / 18
		table.insert(keys, ColorSequenceKeypoint.new(t, if i % 2 == 0 then light:Lerp(dark, random() * 0.4) else dark:Lerp(light, random() * 0.3)))
	end
	return {
		inner:New "Frame" {
			Name = "Grain",
			BackgroundColor3 = WHITE,
			BackgroundTransparency = alpha,
			BorderSizePixel = 0,
			Size = UDim2.fromScale(1, 1),
			ZIndex = z,
			[Kit.Children] = inner:New "UIGradient" { Rotation = 88, Color = ColorSequence.new(keys) },
		},
	}
end

function TEXTURES.Parchment(inner: Scope, _: Color3, _: boolean, _: number, z: number)
	local burn = Color3.fromHex("8A5A2B")
	return {
		edgeFade(inner, "Top", 46, burn, 0.62, z),
		edgeFade(inner, "Bottom", 60, burn, 0.55, z),
		edgeFade(inner, "Left", 46, burn, 0.66, z),
		edgeFade(inner, "Right", 46, burn, 0.66, z),
		inner:New "ImageLabel" {
			Name = "Stain",
			BackgroundTransparency = 1,
			Image = Assets.Glow,
			ImageColor3 = burn,
			ImageTransparency = 0.9,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.78, 0.7),
			Size = UDim2.fromOffset(420, 300),
			ZIndex = z,
		},
	}
end

function TEXTURES.Damask(inner: Scope, color: Color3, light: boolean, alpha: number, z: number)
	local lineColor = if light then Color3.fromHex("8A6A2A") else color
	local a = math.max(alpha, 0.94)
	local out = {}
	for index, rotation in { 45, -45 } do
		for f = 0, 1 do
			local keys = { NumberSequenceKeypoint.new(0, 1) }
			for b = 0, 3 do
				local c0 = (b + 0.5) / 4
				table.insert(keys, NumberSequenceKeypoint.new(c0 - 0.006, 1))
				table.insert(keys, NumberSequenceKeypoint.new(c0 - 0.003, a))
				table.insert(keys, NumberSequenceKeypoint.new(c0 + 0.003, a))
				table.insert(keys, NumberSequenceKeypoint.new(c0 + 0.006, 1))
			end
			table.insert(keys, NumberSequenceKeypoint.new(1, 1))
			table.insert(out, inner:New "Frame" {
				Name = `Lattice{index}{f}`,
				BackgroundColor3 = lineColor,
				BorderSizePixel = 0,
				Size = UDim2.fromScale(1, 1),
				ZIndex = z,
				[Kit.Children] = inner:New "UIGradient" {
					Rotation = rotation,
					Offset = Vector2.new(f * 0.125, 0),
					Transparency = NumberSequence.new(keys),
				},
			})
		end
	end
	return out
end

function TEXTURES.Streaks(inner: Scope, color: Color3, light: boolean, alpha: number, z: number)
	local c = if light then Color3.fromHex("9FB6FF") else color
	local a = math.max(alpha, 0.9)
	return {
		inner:New "Frame" {
			Name = "Streaks",
			BackgroundColor3 = c,
			BorderSizePixel = 0,
			Size = UDim2.fromScale(1, 1),
			ZIndex = z,
			[Kit.Children] = inner:New "UIGradient" {
				Rotation = 120,
				Transparency = NumberSequence.new({
					NumberSequenceKeypoint.new(0, 1),
					NumberSequenceKeypoint.new(0.18, 1),
					NumberSequenceKeypoint.new(0.24, a),
					NumberSequenceKeypoint.new(0.3, 1),
					NumberSequenceKeypoint.new(0.33, 1),
					NumberSequenceKeypoint.new(0.35, a + (1 - a) * 0.4),
					NumberSequenceKeypoint.new(0.37, 1),
					NumberSequenceKeypoint.new(0.7, 1),
					NumberSequenceKeypoint.new(0.78, a),
					NumberSequenceKeypoint.new(0.9, 1),
					NumberSequenceKeypoint.new(1, 1),
				}),
			},
		},
	}
end

Kit.TEXTURES = { "None", "Grain", "Noise", "Scanlines", "Grid", "Paper", "Halftone", "Dots", "Stripes", "Wood", "Parchment", "Damask", "Streaks" }

export type TextureProps = {
	Kind: any?, -- a texture name (state ok); nil = the family's window texture
	Role: string?, -- "Window" | "Backdrop": which family texture to use when Kind is nil
	Color: any?, -- default: white on dark surfaces, ink on light ones
	Light: any?, -- true when drawn on a light surface
	Transparency: any?, -- default: the family's (0.85 .. 0.97)
	Inset: any?, -- px kept clear on every side (rounded corners), state ok
	ZIndex: number?,
}

--[[
	A full-size, non-interactive texture layer (polish spec change 13):
	grain, halftone, scanlines, paper, wood... what separates "designed" from
	"default flat frame" at a glance. Nothing in a classic theme unless Kind
	is given. Put it in the surface's [Children].
]]
function Kit.texture(scope: Scope, props: TextureProps?)
	local p = props or {}
	return scope:Computed(function(use, inner)
		local theme = use(Theme.current)
		local fam = theme.Family
		local kind = use(p.Kind)
		if kind == nil then
			if fam == nil or fam.Texture == nil then
				return nil
			end
			kind = fam.Texture[p.Role or "Window"]
		end
		local build = TEXTURES[kind or ""]
		if build == nil or Theme.lite == true or use(Theme.liteMode) == true then
			return nil
		end
		local light = use(p.Light)
		if light == nil then
			light = fam ~= nil and fam.Light == true
		end
		local color = use(p.Color) or (if light then ((fam and fam.Ink) or theme.Color.Outline) else WHITE)
		local alpha = use(p.Transparency) or ((fam and fam.Texture and fam.Texture.T) or 0.9)
		local z = p.ZIndex or 0
		-- ClipsDescendants clips to the rectangle, not the rounded corners:
		-- keep the pattern clear of them
		local inset = use(p.Inset) or 0
		return inner:New "Frame" {
			Name = "Texture",
			BackgroundTransparency = 1,
			Position = UDim2.fromOffset(inset, inset),
			Size = UDim2.new(1, -inset * 2, 1, -inset * 2),
			ClipsDescendants = true,
			ZIndex = z,
			Active = false,
			[Kit.Children] = build(inner, color, light, alpha, z),
		}
	end)
end

export type DecorationProps = {
	Decoration: any?, -- name or state (default: theme Style.Decoration)
	Look: any?, -- Computed SurfaceLook (for colours); optional
	Color: any?, -- override decoration colour
	Light: any?, -- true when drawn on a light surface
	ZIndex: number?,
}

-- A full-size, non-interactive layer of subtle decoration. Returns a
-- Computed that holds the layer (or nil for "None"); put it in [Children].
function Kit.decoration(scope: Scope, props: DecorationProps?)
	local p = props or {}
	return scope:Computed(function(use, inner)
		local name = use(p.Decoration)
		if name == nil then
			name = Theme.style(use(Theme.current), "Decoration")
		end
		local build = DECORATIONS[name]
		if build == nil then
			return nil
		end
		local look = use(p.Look)
		local light = use(p.Light)
		if light == nil then
			light = if look then look.light else false
		end
		local color = use(p.Color) or (if look then look.accent else WHITE)
		return inner:New "Frame" {
			Name = "Decoration",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			ZIndex = p.ZIndex or -1,
			Active = false,
			[Kit.Children] = build(inner, color, light),
		}
	end)
end

--------------------------------------------------------------------------
-- Rays: procedural light beams for reward reveals (no texture needed)
--------------------------------------------------------------------------

export type RaysProps = {
	Color: any?, -- beam colour (state ok), default white
	Count: number?, -- number of beams (even), default 12
	Transparency: any?, -- overall strength, default 0.3
	Width: number?, -- beam width as a fraction of the diameter, default 0.09
	Name: string?,
	Size: any?,
	Position: any?,
	AnchorPoint: any?,
	ZIndex: number?,
	Visible: any?,
}

-- A square frame of light beams radiating from its centre, with a soft glow
-- core. Spin it with FX.spin(rays, 12).
function Kit.rays(scope: Scope, props: RaysProps?): Frame
	local p = props or {}
	local count = math.max(2, math.floor((p.Count or 12) / 2))
	local width = p.Width or 0.09
	local color = p.Color or WHITE
	local alpha = p.Transparency or 0.3
	local beams = {}
	for index = 1, count do
		local strong = index % 2 == 1
		table.insert(beams, scope:New "Frame" {
			Name = "Beam",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromScale(if strong then width else width * 0.6, 1),
			Rotation = (index - 1) * 180 / count,
			BackgroundColor3 = color,
			BackgroundTransparency = alpha,
			BorderSizePixel = 0,
			ZIndex = p.ZIndex or 1,
			[Kit.Children] = scope:New "UIGradient" {
				Rotation = 90,
				Transparency = NumberSequence.new({
					NumberSequenceKeypoint.new(0, 1),
					NumberSequenceKeypoint.new(0.22, if strong then 0.7 else 0.85),
					NumberSequenceKeypoint.new(0.5, 0),
					NumberSequenceKeypoint.new(0.78, if strong then 0.7 else 0.85),
					NumberSequenceKeypoint.new(1, 1),
				}),
			},
		})
	end
	return scope:New "Frame" {
		Name = p.Name or "Rays",
		BackgroundTransparency = 1,
		AnchorPoint = p.AnchorPoint or Vector2.new(0.5, 0.5),
		Position = p.Position or UDim2.fromScale(0.5, 0.5),
		Size = p.Size or UDim2.fromOffset(600, 600),
		ZIndex = p.ZIndex or 1,
		Visible = if p.Visible == nil then true else p.Visible,
		[Kit.Children] = {
			scope:New "UIAspectRatioConstraint" { AspectRatio = 1 },
			beams,
			Kit.glow(scope, {
				Color = color,
				Transparency = 0.25,
				Size = UDim2.fromScale(0.62, 0.62),
				ZIndex = p.ZIndex or 1,
			}),
		},
	}
end

--------------------------------------------------------------------------
-- Gamepad focus: a themed glowing ring instead of Roblox's thin blue box
--------------------------------------------------------------------------

-- An invisible frame for SelectionImageObject. Elements that draw their own
-- Kit.focusRing set it so Roblox doesn't also draw its default box.
local noSelection = Instance.new("Frame")
noSelection.Name = "BloxUINoSelection"
noSelection.BackgroundTransparency = 1
noSelection.Size = UDim2.fromScale(0, 0)
Kit.NoSelection = noSelection

export type FocusRingProps = {
	Focused: any, -- boolean or state: show the ring
	Radius: any?, -- the element's own corner radius ("S" | "M" | "L" | "Full" | px | UDim), default "M"
	Gap: number?, -- space between the element's edge and the ring (default 4)
	Thickness: number?, -- ring width (default 3)
	Color: any?, -- default: theme Color.Focus
	ZIndex: number?,
}

local function halo(inner: Scope, radius: any, width: number, strength: number, shown: any, color: any): Frame
	return inner:New "Frame" {
		Name = "Halo",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		[Kit.Children] = {
			inner:New "UICorner" { CornerRadius = radius },
			inner:New "UIStroke" {
				ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				LineJoinMode = Enum.LineJoinMode.Round,
				Color = color,
				Thickness = width,
				Transparency = inner:Computed(function(use)
					return 1 - use(shown) * strength
				end),
			},
		},
	}
end

--[[
	A rounded ring that hugs its parent (same corner radius plus the gap), in
	the theme's focus colour, with a soft glow and a slow shimmer. It is drawn
	behind the element (ZIndex 0 by default) and only outside its edge, so it
	never tints the face. Put the returned value in the element's [Children]
	and drive `Focused` from its SelectionGained / SelectionLost events:

		local focused = scope:Value(false)
		scope:New "TextButton" {
			SelectionImageObject = Kit.NoSelection,
			[OnEvent "SelectionGained"] = function() focused:set(true) end,
			[OnEvent "SelectionLost"] = function() focused:set(false) end,
			[Children] = Kit.focusRing(scope, { Focused = focused, Radius = "M" }),
		}

	Nothing is built until the element is first focused, so mouse and touch
	players pay nothing for it.
]]
function Kit.focusRing(scope: Scope, props: FocusRingProps)
	local gap = props.Gap or 4
	local thickness = props.Thickness or 3
	local radius = props.Radius or "M"
	local built = scope:Value(peek(props.Focused) == true)
	Kit.watch(scope, props.Focused, function(now)
		if now and not peek(built) then
			built:set(true)
		end
	end)

	return scope:Computed(function(use, inner)
		if not use(built) then
			return nil
		end
		local focused = props.Focused
		local appear = inner:Spring(inner:Computed(function(useInner)
			return if useInner(focused) then 1 else 0
		end), 30, 0.62)
		appear:setPosition(0)
		local shown = inner:Computed(function(useInner)
			return math.clamp(useInner(appear), 0, 1)
		end)
		local color = Kit.pick(inner, props.Color, function(theme)
			return theme.Color.Focus or theme.Color.Accent
		end)
		local ringRadius = inner:Computed(function(useInner)
			local r = resolveRadius(useInner(Theme.current), useInner(radius))
			if r.Scale > 0 then
				return r
			end
			return UDim.new(0, r.Offset + gap)
		end)

		-- a highlight that travels round the ring while it's focused
		local shimmer = inner:New "UIGradient" {
			Transparency = NumberSequence.new({
				NumberSequenceKeypoint.new(0, 0),
				NumberSequenceKeypoint.new(0.5, 0.35),
				NumberSequenceKeypoint.new(1, 0),
			}),
		}
		local spin: Tween? = nil
		local function setSpinning(on: boolean)
			if on and spin == nil then
				shimmer.Rotation = 0
				spin = game:GetService("TweenService"):Create(
					shimmer,
					TweenInfo.new(2.6, Enum.EasingStyle.Linear, Enum.EasingDirection.In, -1),
					{ Rotation = 360 }
				)
				spin:Play()
			elseif not on and spin then
				spin:Cancel()
				spin = nil
			end
		end
		setSpinning(peek(focused) == true)
		Kit.watch(inner, focused, function(now)
			setSpinning(now == true)
		end)
		table.insert(inner, function()
			setSpinning(false)
		end)

		return inner:New "Frame" {
			Name = "FocusRing",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.new(1, gap * 2, 1, gap * 2),
			ZIndex = props.ZIndex or 0,
			Visible = inner:Computed(function(useInner)
				return useInner(shown) > 0.01
			end),
			[Kit.Children] = {
				-- pops in from a little larger, like a lock-on
				inner:New "UIScale" {
					Scale = inner:Computed(function(useInner)
						return 1.1 - 0.1 * useInner(appear)
					end),
				},
				inner:New "UICorner" { CornerRadius = ringRadius },
				inner:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
					LineJoinMode = Enum.LineJoinMode.Round,
					Color = color,
					Thickness = thickness,
					Transparency = inner:Computed(function(useInner)
						return 1 - useInner(shown)
					end),
					[Kit.Children] = shimmer,
				},
				-- a soft glow: wider, fainter strokes. Strokes only paint outside
				-- the element, so the glow never tints its face.
				halo(inner, ringRadius, thickness + 5, 0.22, shown, color),
				halo(inner, ringRadius, thickness + 11, 0.1, shown, color),
			},
		}
	end)
end

--------------------------------------------------------------------------
-- Text measuring (so labels shrink to fit instead of overflowing)
--------------------------------------------------------------------------

-- Average advance widths as a fraction of TextSize (Roblox sizes text by
-- line height), per character class: digit, upper, lower, space, other.
-- Measured from each family's bold face.
local ADVANCE = {
	BuilderSans = { 0.48, 0.53, 0.45, 0.2, 0.27 },
	BuilderExtended = { 0.53, 0.59, 0.49, 0.2, 0.26 },
	Rubik = { 0.57, 0.6, 0.51, 0.17, 0.31 },
	Montserrat = { 0.52, 0.62, 0.52, 0.25, 0.28 },
	FredokaOne = { 0.47, 0.57, 0.46, 0.21, 0.26 },
	Nunito = { 0.46, 0.5, 0.4, 0.19, 0.22 },
	Oswald = { 0.35, 0.37, 0.32, 0.18, 0.21 },
	RobotoCondensed = { 0.43, 0.48, 0.39, 0.2, 0.22 },
	Sarpanch = { 0.46, 0.5, 0.43, 0.2, 0.25 },
	SpecialElite = { 0.58, 0.6, 0.58, 0.29, 0.39 },
	Creepster = { 0.36, 0.39, 0.39, 0.2, 0.2 },
	LuckiestGuy = { 0.54, 0.6, 0.6, 0.2, 0.3 },
}
local ADVANCE_DEFAULT = { 0.53, 0.6, 0.5, 0.22, 0.3 }

local function advanceTable(font: any): { number }
	if typeof(font) ~= "Font" then
		return ADVANCE.BuilderSans
	end
	local family = string.match(font.Family, "families/([%w]+)%.json$")
	if family == nil and font.Family == Theme.Base.RUBIK then
		family = "Rubik"
	end
	return (family and ADVANCE[family]) or ADVANCE_DEFAULT
end

--[[
	Estimated width in px of `text` at `textSize` in `font` (a Font, a theme
	font slot like "Display", or nil for Builder Sans). RichText tags are
	ignored. It errs slightly wide, so a label sized with it never overflows.
]]
function Kit.textWidth(text: string, textSize: number, font: any?, theme: any?): number
	local face = font
	if typeof(face) ~= "Font" then
		face = Theme.font(theme or Theme.get(), face or "Body")
	end
	local widths = advanceTable(face)
	local weight = if typeof(face) == "Font" then face.Weight.Value else 700
	-- the table is measured from each family's bold face; lighter weights of
	-- a multi-weight family run a little narrower. A single-weight family
	-- (SpecialElite, FredokaOne, Creepster...) was measured at the weight it
	-- has, so it isn't narrowed.
	local family = if typeof(face) == "Font" then string.match(face.Family, "families/([%w]+)%.json$") else nil
	local weights = family and Theme.FONT_FAMILIES and Theme.FONT_FAMILIES[family]
	local scale = if weights and #weights == 1 then 1 else 1 + (weight - 700) / 2500
	local plain = string.gsub(tostring(text), "<[^>]*>", "")
	local total = 0
	for _, code in utf8.codes(plain) do
		local w
		if code >= 48 and code <= 57 then
			w = widths[1]
		elseif code >= 65 and code <= 90 then
			w = widths[2]
		elseif code >= 97 and code <= 122 then
			w = widths[3]
		elseif code == 32 then
			w = widths[4]
		elseif code < 128 then
			w = widths[5]
		else
			w = widths[2]
		end
		total += w
	end
	return total * textSize * scale * 1.04
end

--[[
	The largest text size (<= `textSize`, >= `minSize`, default 12) at which
	`text` fits in `maxWidth` px. Use it for labels in fixed-width places
	(buttons, pills, bars) so long text shrinks instead of spilling out.
]]
function Kit.fitTextSize(text: string, textSize: number, maxWidth: number, font: any?, minSize: number?, theme: any?): number
	local width = Kit.textWidth(text, textSize, font, theme)
	if width <= maxWidth or width <= 0 then
		return textSize
	end
	return math.max(minSize or 12, math.floor(textSize * maxWidth / width))
end

--------------------------------------------------------------------------
-- Scroll bars
--------------------------------------------------------------------------

--[[
	Themed scroll bar properties for a ScrollingFrame: a rounded thumb in the
	theme's scroll colour and thickness. Merge them into your props:

		local props = Kit.scrollBar(scope)
		props.Size = UDim2.fromScale(1, 1)
		scope:New "ScrollingFrame" (props)

	options: { Thickness: number?, Color: any?, Transparency: number? }
]]
function Kit.scrollBar(scope: Scope, options: { [string]: any }?): { [string]: any }
	local o = options or {}
	return {
		ScrollBarThickness = Kit.pick(scope, o.Thickness, function(theme)
			return theme.ScrollBar.Thickness
		end),
		ScrollBarImageColor3 = Kit.pick(scope, o.Color, function(theme)
			return theme.Color.Scroll or theme.Color.Text
		end),
		ScrollBarImageTransparency = Kit.pick(scope, o.Transparency, function(theme)
			return theme.ScrollBar.Transparency
		end),
		TopImage = Assets.ScrollTop,
		MidImage = Assets.ScrollMid,
		BottomImage = Assets.ScrollBottom,
	}
end

-- Product of every UIScale from `gui` up: turns AbsoluteSize back into
-- design pixels.
function Kit.designScale(gui: Instance): number
	local scale = 1
	local current: Instance? = gui
	while current and current:IsA("GuiObject") do
		local uiScale = current:FindFirstChildOfClass("UIScale")
		if uiScale then
			scale *= (uiScale :: UIScale).Scale
		end
		current = current.Parent
	end
	return if scale > 0 then scale else 1
end

export type VirtualRows = {
	First: any, -- Value<number>: first row to build (0-based)
	Last: any, -- Value<number>: last row to build
	bind: (scroller: ScrollingFrame) -> (), -- follow this frame's scrolling and size
}

--[[
	Virtual scrolling for long lists: works out which rows of a ScrollingFrame
	are in view, plus `Buffer` rows either side, so a list only builds those.
	Rows are `Step` design px apart (a number or state) after `Leading` px of
	padding; `View` is the visible length until the frame has been laid out.

		local rows = Kit.virtualRows(scope, { Step = 62, Leading = 4, View = 520 })
		-- build rows peek(rows.First) .. peek(rows.Last); pad the list by First * Step
		rows.bind(scrollingFrame)
]]
function Kit.virtualRows(scope: Scope, options: { [string]: any }): VirtualRows
	local axis = options.Axis or "Y"
	local buffer = options.Buffer or 1
	local leading = options.Leading or 0
	local first = scope:Value(0)
	local last = scope:Value(0)
	-- before layout: assume a full design screen
	local view = if (options.View or 0) > 0 then options.View else (if axis == "X" then 1920 else 1080)
	local scroll = 0
	local function update()
		local step = peek(options.Step)
		if typeof(step) ~= "number" or step <= 0 then
			return
		end
		local top = math.max(0, scroll - leading)
		local f = math.max(0, math.floor(top / step) - buffer)
		local l = math.floor((top + view) / step) + buffer
		if f ~= peek(first) then
			first:set(f)
		end
		if l ~= peek(last) then
			last:set(l)
		end
	end
	update()
	Kit.watch(scope, options.Step, update)
	local function bind(scroller: ScrollingFrame)
		local function read()
			-- AbsoluteSize is in screen px; CanvasPosition is in the frame's
			-- own (design) px, like CanvasSize
			local size = scroller.AbsoluteSize
			local length = if axis == "X" then size.X else size.Y
			if length > 0 then
				view = length / Kit.designScale(scroller)
			end
			local position = scroller.CanvasPosition
			scroll = if axis == "X" then position.X else position.Y
			update()
		end
		table.insert(scope, scroller:GetPropertyChangedSignal("CanvasPosition"):Connect(read))
		table.insert(scope, scroller:GetPropertyChangedSignal("AbsoluteSize"):Connect(read))
		read()
	end
	return { First = first, Last = last, bind = bind }
end

--------------------------------------------------------------------------
-- Misc
--------------------------------------------------------------------------

-- Converts a size-ish value into a UDim2: number -> square offset,
-- Vector2 -> offset, UDim2 -> itself.
function Kit.udim2(value: any): UDim2
	if typeof(value) == "number" then
		return UDim2.fromOffset(value, value)
	elseif typeof(value) == "Vector2" then
		return UDim2.fromOffset(value.X, value.Y)
	end
	return value
end

--[[
	A purely decorative layer (a gloss band, a rim outline) that lite mode
	leaves out. `build(innerScope)` makes it. With Theme.lite set at build
	time it's never made; otherwise it follows Theme.setLite live (made when
	lite mode turns off, destroyed when it turns on). Use it as a child:

		[Children] = { Kit.unlessLite(scope, function(s) return s:New "Frame" { Name = "Gloss" } end) }
]]
function Kit.unlessLite(scope: Scope, build: (Scope) -> Instance?): any
	if Theme.lite == true then
		return nil
	end
	return scope:Computed(function(use, inner)
		if use(Theme.liteMode) then
			return nil
		end
		return build(inner :: any)
	end)
end

--[[
	Touch screens: a see-through hit area around a small button, so it takes
	a comfortable tap (at least Kit.TOUCH_MIN = 44 screen px each way, the
	iOS / Android guideline) without looking any different. A phone draws
	the design canvas at about half size, so a 60 px design button is only
	~29 px there.

		root[Children] = { ..., Kit.touchPad(scope, props.Size, {
			Activated = onActivated, MouseButton1Down = onDown, MouseButton1Up = onUp,
		}) }

	`size`: the button's design size (UDim2, or a state of one; only offset
	sizes are padded). `handlers`: the button's own event handlers, so a tap
	on the pad does exactly what a tap on the button does. Returns nil off
	touch screens. `options.Scale` (a state) overrides Viewport.scale, for a
	button inside an extra UIScale; `options.Axes = "Y"` (or "X") only grows
	it that way (cells of a segmented switch, which touch side by side).
	Leave dense grids (inventory slots) without pads: a pad reaching into
	the next slot would steal its taps.
]]
Kit.TOUCH_MIN = 44
function Kit.touchPad(scope: Scope, size: any, handlers: { [string]: (...any) -> () }, options: { [string]: any }?): TextButton?
	if not Viewport.isTouch then
		return nil
	end
	local opts = options or {}
	local growX, growY = opts.Axes ~= "Y", opts.Axes ~= "X"
	-- the button's size on screen once it's laid out: it covers the HUD's own
	-- fitting UIScale and buttons sized by scale (a menu slot, a grid cell)
	local measured = scope:Value(nil)
	local padSize = scope:Computed(function(use)
		local abs: Vector2? = use(measured)
		local s = use(size)
		if abs then
			-- as a share of the button, whatever scales it (thin sizes known in
			-- design px may grow further: their measurement has a floor)
			local limitX = if typeof(s) == "UDim2" and s.X.Scale == 0 then 8 else 3
			local limitY = if typeof(s) == "UDim2" and s.Y.Scale == 0 then 8 else 3
			local kx = if growX then math.clamp(Kit.TOUCH_MIN / math.max(1, abs.X), 1, limitX) else 1
			local ky = if growY then math.clamp(Kit.TOUCH_MIN / math.max(1, abs.Y), 1, limitY) else 1
			return UDim2.fromScale(kx, ky)
		end
		-- until then: from the design size and the canvas scale
		if typeof(s) ~= "UDim2" then
			return UDim2.fromScale(1, 1)
		end
		local need = Kit.TOUCH_MIN / math.max(0.05, use(opts.Scale or Viewport.scale))
		local x = if growX and s.X.Scale == 0 and s.X.Offset > 0 then math.max(0, need - s.X.Offset) else 0
		local y = if growY and s.Y.Scale == 0 and s.Y.Offset > 0 then math.max(0, need - s.Y.Offset) else 0
		return UDim2.new(1, math.ceil(x), 1, math.ceil(y))
	end)
	local props: { [any]: any } = {
		Name = "TouchPad",
		Text = "",
		AutoButtonColor = false,
		BackgroundTransparency = 1,
		Selectable = false,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = padSize,
		ZIndex = opts.ZIndex or 20,
	}
	for event, handler in handlers do
		props[Fusion.OnEvent(event)] = handler
	end
	local pad = scope:New "TextButton"(props) :: TextButton
	if opts.Scale == nil then
		task.defer(function()
			local button = pad.Parent
			if button == nil or not button:IsA("GuiObject") then
				return -- destroyed before it was laid out
			end
			local function measure()
				local abs = (button :: GuiObject).AbsoluteSize
				if abs.X < 1 or abs.Y < 1 then
					return
				end
				-- opening animations scale windows up from almost nothing: a
				-- passing frame never blows the pad up (the design size at half
				-- the canvas scale is the floor)
				local s = peek(size)
				if typeof(s) == "UDim2" then
					local floor = peek(Viewport.scale) * 0.5
					if s.X.Scale == 0 and s.X.Offset > 0 then
						abs = Vector2.new(math.max(abs.X, s.X.Offset * floor), abs.Y)
					end
					if s.Y.Scale == 0 and s.Y.Offset > 0 then
						abs = Vector2.new(abs.X, math.max(abs.Y, s.Y.Offset * floor))
					end
				end
				measured:set(abs)
			end
			measure()
			table.insert(scope, (button :: GuiObject):GetPropertyChangedSignal("AbsoluteSize"):Connect(measure))
		end)
	end
	return pad
end

-- Calls `callback` whenever `state` changes. Cleaned up with the scope.
function Kit.watch(scope: Scope, state: any, callback: (value: any) -> ())
	if typeof(state) ~= "table" or state.type ~= "State" then
		return
	end
	local observer = scope:Observer(state)
	table.insert(scope, observer:onChange(function()
		callback(peek(state))
	end))
end

-- Height of Roblox's top bar in real screen px, from GuiService.TopbarInset
-- (58 where that isn't available). A Value: it follows the engine.
local moduleScope = Fusion.scoped(Fusion)
Kit.topbarHeight = moduleScope:Value(58)
do
	local GuiService = game:GetService("GuiService")
	local function update()
		local ok, inset = pcall(function()
			return (GuiService :: any).TopbarInset
		end)
		if ok and typeof(inset) == "Rect" then
			Kit.topbarHeight:set(math.max(0, inset.Max.Y))
		end
	end
	update()
	pcall(function()
		GuiService:GetPropertyChangedSignal("TopbarInset"):Connect(update)
	end)
end

-- The first safe y (design px) below Roblox's top bar, which is ~58 real
-- px on every device (so many more design px on a phone). Never less than
-- `minimum` (default 72, the 1080p value).
function Kit.safeTop(scope: Scope, minimum: number?)
	return scope:Computed(function(use)
		return math.max(minimum or 72, use(Kit.topbarHeight) / math.max(0.05, use(Viewport.scale)) + 14)
	end)
end

-- True for light colours (where dark text reads better).
function Kit.isLight(color: Color3): boolean
	return Color.luminance(color) > 0.62
end

-- A tinted dark outline for something painted `color` (QUALITY rule 1):
-- the colour's own deep shade, or the theme outline for near-white colours.
function Kit.tintedStroke(theme: any, color: Color3): Color3
	local h, s, v = color:ToHSV()
	if s < 0.12 then
		return theme.Color.Outline
	end
	return Color3.fromHSV(h, math.min(1, s * 1.05 + 0.1), math.max(0.12, v * 0.36))
end

return Kit
]]></string>
          </Properties>
        </Item>
        <Item class="Folder" referent="165">
          <Properties>
            <string name="Name">Packages</string>
          </Properties>
          <Item class="ModuleScript" referent="166">
            <Properties>
              <string name="Name">Fusion</string>
              <string name="Source"><![CDATA[--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler
--[[
	The entry point for the Fusion library.
]]

local Types = require(script.Types)
local External = require(script.External)

type Fusion = Types.Fusion

export type Animatable = Types.Animatable
export type UsedAs<T> = Types.UsedAs<T>
export type Child = Types.Child
export type Computed<T> = Types.Computed<T>
export type Contextual<T> = Types.Contextual<T>
export type GraphObject = Types.GraphObject
export type For<KO, VO> = Types.For<KO, VO>
export type Observer = Types.Observer
export type PropertyTable = Types.PropertyTable
export type Scope<Constructors = Fusion> = Types.Scope<Constructors>
export type ScopedObject = Types.ScopedObject
export type SpecialKey = Types.SpecialKey
export type Spring<T> = Types.Spring<T>
export type StateObject<T> = Types.StateObject<T>
export type Task = Types.Task
export type Tween<T> = Types.Tween<T>
export type Use = Types.Use
export type Value<T, S = T> = Types.Value<T, S>
export type Version = Types.Version

-- Down the line, this will be conditional based on whether Fusion is being
-- compiled for Roblox.
do
	local RobloxExternal = require(script.RobloxExternal)
	External.setExternalProvider(RobloxExternal)
end

local Fusion: Fusion = table.freeze {
	-- General
	version = {major = 0, minor = 3, isRelease = true},
	Contextual = require(script.Utility.Contextual),
	Safe = require(script.Utility.Safe),

	-- Memory
	cleanup = require(script.Memory.legacyCleanup),
	deriveScope = require(script.Memory.deriveScope),
	doCleanup = require(script.Memory.doCleanup),
	innerScope = require(script.Memory.innerScope),
	scoped = require(script.Memory.scoped),
	
	-- Graph
	Observer = require(script.Graph.Observer),

	-- State
	Computed = require(script.State.Computed),
	ForKeys = require(script.State.ForKeys) :: Types.ForKeysConstructor,
	ForPairs = require(script.State.ForPairs) :: Types.ForPairsConstructor,
	ForValues = require(script.State.ForValues) :: Types.ForValuesConstructor,
	peek = require(script.State.peek),
	Value = require(script.State.Value),

	-- Roblox API
	Attribute = require(script.Instances.Attribute),
	AttributeChange = require(script.Instances.AttributeChange),
	AttributeOut = require(script.Instances.AttributeOut),
	Child = require(script.Instances.Child),
	Children = require(script.Instances.Children),
	Hydrate = require(script.Instances.Hydrate),
	New = require(script.Instances.New),
	OnChange = require(script.Instances.OnChange),
	OnEvent = require(script.Instances.OnEvent),
	Out = require(script.Instances.Out),

	-- Animation
	Tween = require(script.Animation.Tween),
	Spring = require(script.Animation.Spring),
}

return Fusion
]]></string>
            </Properties>
            <Item class="Folder" referent="167">
              <Properties>
                <string name="Name">Animation</string>
              </Properties>
              <Item class="ModuleScript" referent="168">
                <Properties>
                  <string name="Name">ExternalTime</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Outputs the current external time as a state object.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- Graph
local change = require(Package.Graph.change)
-- Utility
local nicknames = require(Package.Utility.nicknames)

type ExternalTime = Types.StateObject&lt;number>

type Self = ExternalTime

local class = {}
class.type = "State"
class.kind = "ExternalTime"
class.timeliness = "lazy"
class.dependencySet = table.freeze {}
class._EXTREMELY_DANGEROUS_usedAsValue = External.lastUpdateStep()

local METATABLE = table.freeze {__index = class}

local allTimers: {Self} = {}

local function ExternalTime(
	scope: Types.Scope&lt;unknown>
): ExternalTime
	local createdAt = os.clock()
	local self: Self = setmetatable(
		{
			createdAt = createdAt,
			dependentSet = {},
			lastChange = nil,
			scope = scope,
			validity = "invalid"
		}, 
		METATABLE
	) :: any
	local destroy = function()
		self.scope = nil
		local index = table.find(allTimers, self)
		if index ~= nil then
			table.remove(allTimers, index)
		end
	end
	self.oldestTask = destroy
	nicknames[self.oldestTask] = "ExternalTime"
	table.insert(scope, destroy)
	table.insert(allTimers, self)
	return self
end

function class._evaluate(
	self: Self
): boolean
	-- While someone else could call `change()` on this object, it wouldn't be
	-- idiomatic. So, since the only idiomatic time this function runs is when
	-- the external update step runs, it's safe enough to assume that the result
	-- has always meaningfully changed. The worst that can happen is unexpected
	-- refreshing for people doing unorthodox shenanigans, which is an OK trade.
	return true
end

External.bindToUpdateStep(function(
	externalNow: number
): ()
	class._EXTREMELY_DANGEROUS_usedAsValue = External.lastUpdateStep()
	-- BloxUI patch (see PATCHES.md): only tick timers something listens to.
	-- Sleeping springs pause their stopwatch, which drops it from the timer's
	-- dependents, so an idle UI does no graph work here at all.
	for _, timer in allTimers do
		if next(timer.dependentSet) ~= nil then
			change(timer)
		end
	end
end)

-- Do *not* freeze the class table, because it stores the shared value of all
-- external time objects, and is updated every frame because of that.
-- table.freeze(class)
return ExternalTime</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="169">
                <Properties>
                  <string name="Name">Spring</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A specialised state object for following a goal state smoothly over time,
	using physics to shape the motion.

	https://elttob.uk/Fusion/0.3/api-reference/animation/types/spring/
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- Memory
local checkLifetime = require(Package.Memory.checkLifetime)
-- Graph
local depend = require(Package.Graph.depend)
local change = require(Package.Graph.change)
local evaluate = require(Package.Graph.evaluate)
-- State
local castToState = require(Package.State.castToState)
local peek = require(Package.State.peek)
-- Animation
local ExternalTime = require(Package.Animation.ExternalTime)
local Stopwatch = require(Package.Animation.Stopwatch)
local packType = require(Package.Animation.packType)
local unpackType = require(Package.Animation.unpackType)
local springCoefficients = require(Package.Animation.springCoefficients)
-- Utility
local nicknames = require(Package.Utility.nicknames)

local EPSILON = 0.00001

type Self&lt;T> = Types.Spring&lt;T> &amp; {
	_activeDamping: number,
	_activeGoal: T,
	_activeLatestP: {number},
	_activeLatestV: {number},
	_activeNumSprings: number,
	_activeSpeed: number,
	_activeStartP: {number},
	_activeStartV: {number},
	_activeTargetP: {number},
	_activeType: string,
	_speed: Types.UsedAs&lt;number>,
	_damping: Types.UsedAs&lt;number>,
	_goal: Types.UsedAs&lt;T>,
	_stopwatch: Stopwatch.Stopwatch
}

local class = {}
class.type = "State"
class.kind = "Spring"
class.timeliness = "eager"

local METATABLE = table.freeze {__index = class}

local function Spring&lt;T>(
	scope: Types.Scope&lt;unknown>,
	goal: Types.UsedAs&lt;T>,
	speed: Types.UsedAs&lt;number>?,
	damping: Types.UsedAs&lt;number>?
): Types.Spring&lt;T>
	local createdAt = os.clock()
	if typeof(scope) ~= "table" or castToState(scope) ~= nil then
		External.logError("scopeMissing", nil, "Springs", "myScope:Spring(goalState, speed, damping)")
	end

	local goalState = castToState(goal)
	local stopwatch = nil
	if goalState ~= nil then
		stopwatch = Stopwatch(scope, ExternalTime(scope))
		stopwatch:unpause()
	end

	local speed = speed or 10
	local damping = damping or 1

	local self: Self&lt;T> = setmetatable(
		{
			createdAt = createdAt,
			dependencySet = {},
			dependentSet = {},
			lastChange = nil,
			scope = scope,
			validity = "invalid",
			_activeDamping = -1,
			_activeGoal = nil,
			_activeLatestP = {},
			_activeLatestV = {},
			_activeNumSprings = 0,
			_activeSpeed = -1,
			_activeStartP = {},
			_activeStartV = {},
			_activeTargetP = {},
			_activeType = "",
			_damping = damping,
			_EXTREMELY_DANGEROUS_usedAsValue = peek(goal),
			_goal = goal,
			_speed = speed,
			_stopwatch = stopwatch
		},
		METATABLE
	) :: any
	local destroy = function()
		self.scope = nil
		for dependency in pairs(self.dependencySet) do
			dependency.dependentSet[self] = nil
		end
	end
	self.oldestTask = destroy
	nicknames[self.oldestTask] = "Spring"
	table.insert(scope, destroy)
	
	if goalState ~= nil then
		checkLifetime.bOutlivesA(
			scope, self.oldestTask,
			goalState.scope, goalState.oldestTask,
			checkLifetime.formatters.animationGoal
		)
	end
	local speedState = castToState(speed)
	if speedState ~= nil then
		checkLifetime.bOutlivesA(
			scope, self.oldestTask,
			speedState.scope, speedState.oldestTask,
			checkLifetime.formatters.parameter, "speed"
		)
	end
	local dampingState = castToState(damping)
	if dampingState ~= nil then
		checkLifetime.bOutlivesA(
			scope, self.oldestTask,
			dampingState.scope, dampingState.oldestTask,
			checkLifetime.formatters.parameter, "damping"
		)
	end

	-- Eagerly evaluated objects need to evaluate themselves so that they're
	-- valid at all times.
	evaluate(self, true)

	return self
end

function class.addVelocity&lt;T>(
	self: Self&lt;T>,
	deltaValue: T
): ()
	evaluate(self, false) -- ensure the _active params are up to date
	local deltaType = typeof(deltaValue)
	if deltaType ~= self._activeType then
		External.logError("springTypeMismatch", nil, deltaType, self._activeType)
	end
	local newStartV = unpackType(deltaValue, deltaType)
	for index, velocity in self._activeLatestV do
		newStartV[index] += velocity
	end
	self._activeStartP = table.clone(self._activeLatestP)
	self._activeStartV = newStartV
	self._stopwatch:zero()
	self._stopwatch:unpause()
	change(self)
end

function class.get&lt;T>(
	self: Self&lt;T>
): never
	return External.logError("stateGetWasRemoved")
end

function class.setPosition&lt;T>(
	self: Self&lt;T>,
	newValue: T
): ()
	evaluate(self, false) -- ensure the _active params are up to date
	local newType = typeof(newValue)
	if newType ~= self._activeType then
		External.logError("springTypeMismatch", nil, newType, self._activeType)
	end
	self._activeStartP = unpackType(newValue, newType)
	self._activeStartV = table.clone(self._activeLatestV)
	self._stopwatch:zero()
	self._stopwatch:unpause()
	change(self)
end

function class.setVelocity&lt;T>(
	self: Self&lt;T>,
	newValue: T
): ()
	evaluate(self, false) -- ensure the _active params are up to date
	local newType = typeof(newValue)
	if newType ~= self._activeType then
		External.logError("springTypeMismatch", nil, newType, self._activeType)
	end
	self._activeStartP = table.clone(self._activeLatestP)
	self._activeStartV = unpackType(newValue, newType)
	self._stopwatch:zero()
	self._stopwatch:unpause()
	change(self)
end

function class._evaluate&lt;T>(
	self: Self&lt;T>
): boolean
	local goal = castToState(self._goal)
	-- Allow non-state goals to pass through transparently.
	if goal == nil then
		self._EXTREMELY_DANGEROUS_usedAsValue = self._goal :: T
		return false
	end
	-- depend(self, goal)
	local nextFrameGoal = peek(goal)
	-- Protect against NaN goals.
	if nextFrameGoal ~= nextFrameGoal then
		External.logWarn("springNanGoal")
		return false
	end
	local nextFrameGoalType = typeof(nextFrameGoal)
	local discontinuous = nextFrameGoalType ~= self._activeType

	local stopwatch = self._stopwatch :: Stopwatch.Stopwatch
	local elapsed = peek(stopwatch)
	-- BloxUI patch (see PATCHES.md): springs sleep once they settle. An awake
	-- spring depends on its stopwatch (so it re-evaluates every frame and
	-- picks up goal changes from there); a sleeping spring depends only on its
	-- goal and pauses its stopwatch, so it costs nothing until the goal moves.
	-- The dependency is formed at the end of this function.
	local awake = true

	local oldValue = self._EXTREMELY_DANGEROUS_usedAsValue
	local newValue: T

	if discontinuous then
		awake = false
		-- Propagate changes in type instantly throughout the whole reactive
		-- graph, even if simulation is logically one frame behind, because it
		-- makes the whole graph behave more consistently.
		newValue = nextFrameGoal
	elseif elapsed &lt;= 0 then
		newValue = oldValue
	else
		-- Calculate spring motion.
		-- IMPORTANT: use the parameters from last frame, not this frame. We're
		-- integrating the motion that happened over the last frame, after all.
		-- The stopwatch will have captured the length of time needed correctly.
		local posPos, posVel, velPos, velVel = springCoefficients(
			elapsed, 
			self._activeDamping, 
			self._activeSpeed
		)
		local isMoving = false
		for index = 1, self._activeNumSprings do
			local startP = self._activeStartP[index]
			local targetP = self._activeTargetP[index]
			local startV = self._activeStartV[index]
			local startD = startP - targetP
			local latestD = startD * posPos + startV * posVel
			local latestV = startD * velPos + startV * velVel
			if latestD ~= latestD or latestV ~= latestV then
				External.logWarn("springNanMotion")
				latestD, latestV = 0, 0
			end
			if math.abs(latestD) > EPSILON or math.abs(latestV) > EPSILON then
				isMoving = true
			end
			local latestP = latestD + targetP
			self._activeLatestP[index] = latestP
			self._activeLatestV[index] = latestV
		end
		-- Sleep and snap to goal if the motion has decayed to a negligible amount.
		awake = isMoving
		if not isMoving then
			for index = 1, self._activeNumSprings do
				self._activeLatestP[index] = self._activeTargetP[index]
			end
		end
		-- Pack springs into final value.
		newValue = packType(self._activeLatestP, self._activeType) :: any
	end

	-- Reconfigure spring when any of its parameters are changed.
	-- This should happen after integrating the last frame's motion.
	-- NOTE: don't need to add a dependency on these objects! they do not cause
	-- a spring to wake from sleep, so the stopwatch dependency is sufficient.
	local nextFrameSpeed = peek(self._speed) :: number
	local nextFrameDamping = peek(self._damping) :: number
	if
		discontinuous or
		nextFrameGoal ~= self._activeGoal or
		nextFrameSpeed ~= self._activeSpeed or
		nextFrameDamping ~= self._activeDamping
	then
		self._activeTargetP = unpackType(nextFrameGoal, nextFrameGoalType)
		self._activeNumSprings = #self._activeTargetP
		if discontinuous then
			self._activeStartP = table.clone(self._activeTargetP)
			self._activeLatestP = table.clone(self._activeTargetP)
			self._activeStartV = table.create(self._activeNumSprings, 0)
			self._activeLatestV = table.create(self._activeNumSprings, 0)
		else
			self._activeStartP = table.clone(self._activeLatestP)
			self._activeStartV = table.clone(self._activeLatestV)
		end
		self._activeType = nextFrameGoalType
		self._activeGoal = nextFrameGoal
		self._activeDamping = nextFrameDamping
		self._activeSpeed = nextFrameSpeed
		stopwatch:zero()
		stopwatch:unpause()
		if not discontinuous then
			awake = true
		end
	end

	if awake then
		depend(self, stopwatch)
	else
		-- Asleep: only a goal change wakes the spring. Don't depend on the goal
		-- while awake too, or springs whose goal changes every frame re-run
		-- mid-frame with a stale stopwatch and never move.
		depend(self, goal)
		stopwatch:pause()
	end

	-- Push update and check for similarity.
	-- Don't need to use the similarity test here because this code doesn't
	-- deal with tables, and NaN is already guarded against, so the similarity
	-- test doesn't actually add any new safety here.
	self._EXTREMELY_DANGEROUS_usedAsValue = newValue
	return oldValue ~= newValue
end

table.freeze(class)
return Spring :: Types.SpringConstructor</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="170">
                <Properties>
                  <string name="Name">Stopwatch</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	State object for measuring time since an event using a reference timer.

	TODO: this should not be exposed to users until it has a proper reactive API
	surface
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
-- Memory
local checkLifetime = require(Package.Memory.checkLifetime)
-- Graph
local depend = require(Package.Graph.depend)
local change = require(Package.Graph.change)
-- State
local peek = require(Package.State.peek)
-- Utility
local nicknames = require(Package.Utility.nicknames)

export type Stopwatch = Types.StateObject&lt;number> &amp; {
	zero: (Stopwatch) -> (),
	pause: (Stopwatch) -> (),
	unpause: (Stopwatch) -> ()
}

type Self = Stopwatch &amp; {
	_measureTimeSince: number,
	_playing: boolean,
	_timer: Types.StateObject&lt;number>
}

local class = {}
class.type = "State"
class.kind = "Stopwatch"
class.timeliness = "lazy"

local METATABLE = table.freeze {__index = class}

local function Stopwatch(
	scope: Types.Scope&lt;unknown>,
	timer: Types.StateObject&lt;number>
): Stopwatch
	local createdAt = os.clock()
	local self: Self = setmetatable(
		{
			awake = true,
			createdAt = createdAt,
			dependencySet = {},
			dependentSet = {},
			lastChange = nil,
			scope = scope,
			validity = "invalid",
			_EXTREMELY_DANGEROUS_usedAsValue = 0,
			_measureTimeSince = 0, -- this should be set on unpause
			_playing = false,
			_timer = timer
		}, 
		METATABLE
	) :: any
	local destroy = function()
		self.scope = nil
	end
	self.oldestTask = destroy
	nicknames[self.oldestTask] = "Stopwatch"
	table.insert(scope, destroy)

	checkLifetime.bOutlivesA(
		scope, self.oldestTask,
		timer.scope, timer.oldestTask,
		checkLifetime.formatters.parameter, "timer"
	)
	depend(self, timer)
	return self
end

function class.zero(
	self: Self
): ()
	local newTimepoint = peek(self._timer)
	if newTimepoint ~= self._measureTimeSince then
		self._measureTimeSince = newTimepoint
		self._EXTREMELY_DANGEROUS_usedAsValue = 0
		change(self)
	end
end

function class.pause(
	self: Self
): ()
	if self._playing == true then
		self._playing = false
		change(self)
	end
end

function class.unpause(
	self: Self
): ()
	if self._playing == false then
		self._playing = true
		self._measureTimeSince = peek(self._timer) - self._EXTREMELY_DANGEROUS_usedAsValue
		change(self)
	end
end

function class._evaluate(
	self: Self
): boolean
	if self._playing then
		depend(self, self._timer)
		local currentTime = peek(self._timer)
		local oldValue = self._EXTREMELY_DANGEROUS_usedAsValue
		local newValue = currentTime - self._measureTimeSince
		self._EXTREMELY_DANGEROUS_usedAsValue = newValue
		return oldValue ~= newValue
	else
		return false
	end
	
end

table.freeze(class)
return Stopwatch</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="171">
                <Properties>
                  <string name="Name">Tween</string>
                  <string name="Source"><![CDATA[--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A specialised state object for following a goal state smoothly over time,
	using a TweenInfo to shape the motion.

	https://elttob.uk/Fusion/0.3/api-reference/animation/types/tween/
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- Memory
local checkLifetime = require(Package.Memory.checkLifetime)
-- Graph
local depend = require(Package.Graph.depend)
local evaluate = require(Package.Graph.evaluate)
-- State
local castToState = require(Package.State.castToState)
local peek = require(Package.State.peek)
-- Animation
local ExternalTime = require(Package.Animation.ExternalTime)
local Stopwatch = require(Package.Animation.Stopwatch)
local lerpType = require(Package.Animation.lerpType)
local getTweenRatio = require(Package.Animation.getTweenRatio)
local getTweenDuration = require(Package.Animation.getTweenDuration)
-- Utility
local nicknames = require(Package.Utility.nicknames)

export type Self<T> = Types.Tween<T> & {
	_activeDuration: number,
	_activeElapsed: number,
	_activeFrom: T,
	_activeTo: T,
	_activeTweenInfo: TweenInfo,
	_goal: Types.UsedAs<T>,
	_stopwatch: Stopwatch.Stopwatch?,
	_tweenInfo: Types.UsedAs<TweenInfo>,
}

local class = {}
class.type = "State"
class.kind = "Tween"
class.timeliness = "eager"

local METATABLE = table.freeze {__index = class}

local function Tween<T>(
	scope: Types.Scope<unknown>,
	goal: Types.UsedAs<T>,
	tweenInfo: Types.UsedAs<TweenInfo>?
): Types.Tween<T>
	local createdAt = os.clock()
	if castToState(scope) then
		External.logError("scopeMissing", nil, "Tweens", "myScope:Tween(goalState, tweenInfo)")
	end

	local goalState = castToState(goal)
	local stopwatch = nil
	if goalState ~= nil then
		stopwatch = Stopwatch(scope, ExternalTime(scope))
	end

	local self: Self<T> = setmetatable(
		{
			createdAt = createdAt,
			dependencySet = {},
			dependentSet = {},
			lastChange = nil,
			scope = scope,
			validity = "invalid",
			_activeDuration = nil,
			_activeElapsed = nil,
			_activeFrom = nil,
			_activeTo = nil,
			_activeTweenInfo = nil,
			_EXTREMELY_DANGEROUS_usedAsValue = peek(goal),
			_goal = goal,
			_stopwatch = stopwatch,
			_tweenInfo = tweenInfo or TweenInfo.new()
		},
		METATABLE
	) :: any
	local destroy = function()
		self.scope = nil
		for dependency in pairs(self.dependencySet) do
			dependency.dependentSet[self] = nil
		end
	end
	self.oldestTask = destroy
	nicknames[self.oldestTask] = "Tween"
	table.insert(scope, destroy)

	if goalState ~= nil then
		checkLifetime.bOutlivesA(
			scope, self.oldestTask,
			goalState.scope, goalState.oldestTask,
			checkLifetime.formatters.animationGoal
		)
	end

	local tweenInfoState = castToState(tweenInfo)
	if tweenInfoState ~= nil then
		checkLifetime.bOutlivesA(
			scope, self.oldestTask,
			tweenInfoState.scope, tweenInfoState.oldestTask,
			checkLifetime.formatters.parameter, "tween info"
		)
	end

	-- Eagerly evaluated objects need to evaluate themselves so that they're
	-- valid at all times.
	evaluate(self, true)

	return self
end

function class.get<T>(
	self: Self<T>
): never
	return External.logError("stateGetWasRemoved")
end

function class._evaluate<T>(
	self: Self<T>
): boolean
	local goal = castToState(self._goal)
	-- Allow non-state goals to pass through transparently.
	if goal == nil then
		self._EXTREMELY_DANGEROUS_usedAsValue = self._goal :: T
		return false
	end
	depend(self, goal)
	local newTweenTo = peek(goal)
	-- Protect against NaN goals.
	if newTweenTo ~= newTweenTo then
		External.logWarn("tweenNanGoal")
		return false
	end
	local stopwatch = self._stopwatch :: Stopwatch.Stopwatch
	local tweenInfo = peek(self._tweenInfo) :: TweenInfo
	-- Restart animation when the goal changes, or if the tween info changes
	-- partway through another animation.
	if 
		self._activeTo ~= newTweenTo or
		(self._activeElapsed < self._activeDuration and self._activeTweenInfo ~= tweenInfo)
	then
		self._activeDuration = getTweenDuration(tweenInfo)
		self._activeFrom = self._EXTREMELY_DANGEROUS_usedAsValue
		self._activeTo = newTweenTo
		self._activeTweenInfo = tweenInfo
		stopwatch:zero()
		stopwatch:unpause()
	end
	depend(self, stopwatch)
	self._activeElapsed = peek(stopwatch)
	if
		self._activeFrom == self._activeTo or -- endpoints match
		self._activeElapsed >= self._activeDuration or -- animation is done
		typeof(self._activeTo) ~= typeof(self._activeFrom) -- type difference
	then
		self._activeFrom = self._activeTo
		self._activeElapsed = self._activeDuration
		stopwatch:pause()
	end
	-- Compute actual tweened value.
	local ratio = getTweenRatio(tweenInfo, self._activeElapsed)
	local oldValue = self._EXTREMELY_DANGEROUS_usedAsValue
	local newValue = lerpType(self._activeFrom, self._activeTo, ratio) :: T
	-- Protect against NaN after motion.
	if newValue ~= newValue then
		External.logWarn("tweenNanMotion")
		newValue = self._activeTo
	end
	-- Push update and check for similarity.
	-- Don't need to use the similarity test here because this code doesn't
	-- deal with tables, and NaN is already guarded against, so the similarity
	-- test doesn't actually add any new safety here.
	self._EXTREMELY_DANGEROUS_usedAsValue = newValue
	return oldValue ~= newValue
end

table.freeze(class)
return Tween :: Types.TweenConstructor
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="172">
                <Properties>
                  <string name="Name">getTweenDuration</string>
                  <string name="Source"><![CDATA[--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Given a `tweenInfo`, returns how many seconds it will take before the tween
	finishes moving. The result may be infinite if the tween repeats forever.
]]

local TweenService = game:GetService("TweenService")

local function getTweenDuration(
	tweenInfo: TweenInfo
): number
	if tweenInfo.RepeatCount <= -1 then
		return math.huge
	end
	local tweenDuration = tweenInfo.DelayTime + tweenInfo.Time
	if tweenInfo.Reverses then
		tweenDuration += tweenInfo.Time
	end
	tweenDuration *= tweenInfo.RepeatCount + 1
	return tweenDuration
end

return getTweenDuration
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="173">
                <Properties>
                  <string name="Name">getTweenRatio</string>
                  <string name="Source"><![CDATA[--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Given a `tweenInfo` and `currentTime`, returns a ratio which can be used to
	tween between two values over time.
]]

local TweenService = game:GetService("TweenService")

local function getTweenRatio(
	tweenInfo: TweenInfo,
	currentTime: number
): number
	local delay = tweenInfo.DelayTime
	local duration = tweenInfo.Time
	local reverses = tweenInfo.Reverses
	local numCycles = 1 + tweenInfo.RepeatCount
	local easeStyle = tweenInfo.EasingStyle
	local easeDirection = tweenInfo.EasingDirection
	local cycleDuration = delay + duration
	if reverses then
		cycleDuration += duration
	end
	-- If currentTime is infinity, then presumably the tween should be over.
	-- This avoids NaN when the duration of an infinitely repeating tween is given.
	if currentTime == math.huge then
		return 1
	end
	if currentTime >= cycleDuration * numCycles and tweenInfo.RepeatCount > -1 then
		return 1
	end
	local cycleTime = currentTime % cycleDuration
	if cycleTime <= delay then
		return 0
	end
	local tweenProgress = (cycleTime - delay) / duration
	if tweenProgress > 1 then
		tweenProgress = 2 - tweenProgress
	end
	local ratio = TweenService:GetValue(tweenProgress, easeStyle, easeDirection)
	return ratio
end

return getTweenRatio
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="174">
                <Properties>
                  <string name="Name">lerpType</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Linearly interpolates the given animatable types by a ratio.
	If the types are different or not animatable, then the first value will be
	returned for ratios below 0.5, and the second value for 0.5 and above.
]]

local Package = script.Parent.Parent
local Oklab = require(Package.Colour.Oklab)

local function lerpType(
	from: unknown, 
	to: unknown, 
	ratio: number
): unknown
	local typeString = typeof(from)

	if typeof(to) == typeString then
		-- both types must match for interpolation to make sense
		if typeString == "number" then
			local to, from = to :: number, from :: number
			return (to - from) * ratio + from

		elseif typeString == "CFrame" then
			local to, from = to :: CFrame, from :: CFrame
			return from:Lerp(to, ratio)

		elseif typeString == "Color3" then
			local to, from = to :: Color3, from :: Color3
			local fromLab = Oklab.fromSRGB(from)
			local toLab = Oklab.fromSRGB(to)
			return Oklab.toSRGB(
				fromLab:Lerp(toLab, ratio),
				false
			)

		elseif typeString == "ColorSequenceKeypoint" then
			local to, from = to :: ColorSequenceKeypoint, from :: ColorSequenceKeypoint
			local fromLab = Oklab.fromSRGB(from.Value)
			local toLab = Oklab.fromSRGB(to.Value)
			return ColorSequenceKeypoint.new(
				(to.Time - from.Time) * ratio + from.Time,
				Oklab.toSRGB(
					fromLab:Lerp(toLab, ratio),
					false
				)
			)

		elseif typeString == "DateTime" then
			local to, from = to :: DateTime, from :: DateTime
			return DateTime.fromUnixTimestampMillis(
				(to.UnixTimestampMillis - from.UnixTimestampMillis) * ratio + from.UnixTimestampMillis
			)

		elseif typeString == "NumberRange" then
			local to, from = to :: NumberRange, from :: NumberRange
			return NumberRange.new(
				(to.Min - from.Min) * ratio + from.Min,
				(to.Max - from.Max) * ratio + from.Max
			)

		elseif typeString == "NumberSequenceKeypoint" then
			local to, from = to :: NumberSequenceKeypoint, from :: NumberSequenceKeypoint
			return NumberSequenceKeypoint.new(
				(to.Time - from.Time) * ratio + from.Time,
				(to.Value - from.Value) * ratio + from.Value,
				(to.Envelope - from.Envelope) * ratio + from.Envelope
			)

		elseif typeString == "PhysicalProperties" then
			local to, from = to :: PhysicalProperties, from :: PhysicalProperties
			return PhysicalProperties.new(
				(to.Density - from.Density) * ratio + from.Density,
				(to.Friction - from.Friction) * ratio + from.Friction,
				(to.Elasticity - from.Elasticity) * ratio + from.Elasticity,
				(to.FrictionWeight - from.FrictionWeight) * ratio + from.FrictionWeight,
				(to.ElasticityWeight - from.ElasticityWeight) * ratio + from.ElasticityWeight
			)

		elseif typeString == "Ray" then
			local to, from = to :: Ray, from :: Ray
			return Ray.new(
				from.Origin:Lerp(to.Origin, ratio),
				from.Direction:Lerp(to.Direction, ratio)
			)

		elseif typeString == "Rect" then
			local to, from = to :: Rect, from :: Rect
			return Rect.new(
				from.Min:Lerp(to.Min, ratio),
				from.Max:Lerp(to.Max, ratio)
			)

		elseif typeString == "Region3" then
			local to, from = to :: Region3, from :: Region3
			-- FUTURE: support rotated Region3s if/when they become constructable
			local position = from.CFrame.Position:Lerp(to.CFrame.Position, ratio)
			local halfSize = from.Size:Lerp(to.Size, ratio) / 2
			return Region3.new(position - halfSize, position + halfSize)

		elseif typeString == "Region3int16" then
			local to, from = to :: Region3int16, from :: Region3int16
			return Region3int16.new(
				Vector3int16.new(
					(to.Min.X - from.Min.X) * ratio + from.Min.X,
					(to.Min.Y - from.Min.Y) * ratio + from.Min.Y,
					(to.Min.Z - from.Min.Z) * ratio + from.Min.Z
				),
				Vector3int16.new(
					(to.Max.X - from.Max.X) * ratio + from.Max.X,
					(to.Max.Y - from.Max.Y) * ratio + from.Max.Y,
					(to.Max.Z - from.Max.Z) * ratio + from.Max.Z
				)
			)

		elseif typeString == "UDim" then
			local to, from = to :: UDim, from :: UDim
			return UDim.new(
				(to.Scale - from.Scale) * ratio + from.Scale,
				(to.Offset - from.Offset) * ratio + from.Offset
			)

		elseif typeString == "UDim2" then
			local to, from = to :: UDim2, from :: UDim2
			return from:Lerp(to, ratio)

		elseif typeString == "Vector2" then
			local to, from = to :: Vector2, from :: Vector2
			return from:Lerp(to, ratio)

		elseif typeString == "Vector2int16" then
			local to, from = to :: Vector2int16, from :: Vector2int16
			return Vector2int16.new(
				(to.X - from.X) * ratio + from.X,
				(to.Y - from.Y) * ratio + from.Y
			)

		elseif typeString == "Vector3" then
			local to, from = to :: Vector3, from :: Vector3
			return from:Lerp(to, ratio)

		elseif typeString == "Vector3int16" then
			local to, from = to :: Vector3int16, from :: Vector3int16
			return Vector3int16.new(
				(to.X - from.X) * ratio + from.X,
				(to.Y - from.Y) * ratio + from.Y,
				(to.Z - from.Z) * ratio + from.Z
			)
		end
	end

	-- fallback case: the types are different or not animatable
	if ratio &lt; 0.5 then
		return from
	else
		return to
	end
end

return lerpType</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="175">
                <Properties>
                  <string name="Name">packType</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Packs an array of numbers into a given animatable data type.
	If the type is not animatable, nil will be returned.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local Oklab = require(Package.Colour.Oklab)

local function packType(
	numbers: {number},
	typeString: string
): Types.Animatable?
	if typeString == "number" then
		return numbers[1]

	elseif typeString == "CFrame" then
		return
			CFrame.new(numbers[1], numbers[2], numbers[3]) *
			CFrame.fromAxisAngle(
				Vector3.new(numbers[4], numbers[5], numbers[6]).Unit,
				numbers[7]
			)

	elseif typeString == "Color3" then
		return Oklab.toSRGB(
			Vector3.new(numbers[1], numbers[2], numbers[3]),
			false
		)

	elseif typeString == "ColorSequenceKeypoint" then
		return ColorSequenceKeypoint.new(
			numbers[4],
			Oklab.toSRGB(
				Vector3.new(numbers[1], numbers[2], numbers[3]),
				false
			)
		)

	elseif typeString == "DateTime" then
		return DateTime.fromUnixTimestampMillis(numbers[1])

	elseif typeString == "NumberRange" then
		return NumberRange.new(numbers[1], numbers[2])

	elseif typeString == "NumberSequenceKeypoint" then
		return NumberSequenceKeypoint.new(numbers[2], numbers[1], numbers[3])

	elseif typeString == "PhysicalProperties" then
		return PhysicalProperties.new(numbers[1], numbers[2], numbers[3], numbers[4], numbers[5])

	elseif typeString == "Ray" then
		return Ray.new(
			Vector3.new(numbers[1], numbers[2], numbers[3]),
			Vector3.new(numbers[4], numbers[5], numbers[6])
		)

	elseif typeString == "Rect" then
		return Rect.new(numbers[1], numbers[2], numbers[3], numbers[4])

	elseif typeString == "Region3" then
		-- FUTURE: support rotated Region3s if/when they become constructable
		local position = Vector3.new(numbers[1], numbers[2], numbers[3])
		local halfSize = Vector3.new(numbers[4] / 2, numbers[5] / 2, numbers[6] / 2)
		return Region3.new(position - halfSize, position + halfSize)

	elseif typeString == "Region3int16" then
		return Region3int16.new(
			Vector3int16.new(numbers[1], numbers[2], numbers[3]),
			Vector3int16.new(numbers[4], numbers[5], numbers[6])
		)

	elseif typeString == "UDim" then
		return UDim.new(numbers[1], numbers[2])

	elseif typeString == "UDim2" then
		return UDim2.new(numbers[1], numbers[2], numbers[3], numbers[4])

	elseif typeString == "Vector2" then
		return Vector2.new(numbers[1], numbers[2])

	elseif typeString == "Vector2int16" then
		return Vector2int16.new(numbers[1], numbers[2])

	elseif typeString == "Vector3" then
		return Vector3.new(numbers[1], numbers[2], numbers[3])

	elseif typeString == "Vector3int16" then
		return Vector3int16.new(numbers[1], numbers[2], numbers[3])
	else
		return nil
	end
end

return packType</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="176">
                <Properties>
                  <string name="Name">springCoefficients</string>
                  <string name="Source"><![CDATA[--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Returns a 2x2 matrix of coefficients for a given time, damping and angular
	frequency (aka 'speed').
	
	Specifically, this returns four coefficients - posPos, posVel, velPos, and
	velVel - which can be multiplied with position and velocity like so:

	local newPosition = oldPosition * posPos + oldVelocity * posVel
	local newVelocity = oldPosition * velPos + oldVelocity * velVel

	For speed = 1 and damping = 0, the result is a simple harmonic oscillator
	with a period of tau.

	Special thanks to AxisAngle for helping to improve numerical precision.
]]

local function springCoefficients(
	time: number,
	damping: number,
	speed: number
): (number, number, number, number)
	-- if time or speed is 0, then the spring won't move
	if time == 0 or speed == 0 then
		return 1, 0, 0, 1
	end
	local posPos, posVel, velPos, velVel

	if damping > 1 then
		-- overdamped spring

		local alpha = math.sqrt(damping^2 - 1)
		local negHalf_over_alpha_speed = -0.5 / (alpha * speed)
		local z1 = speed * (alpha + damping) * -1
		local z2 = speed * (alpha - damping)
		local exp1 = math.exp(time * z1)
		local exp2 = math.exp(time * z2)

		posPos = (exp2 * z1 - exp1 * z2) * negHalf_over_alpha_speed
		posVel = (exp1 - exp2) * negHalf_over_alpha_speed / speed
		velPos = (exp2 - exp1) * negHalf_over_alpha_speed * speed
		velVel = (exp1 * z1 - exp2 * z2) * negHalf_over_alpha_speed

	elseif damping == 1 then
		-- critically damped spring

		local time_speed = time * speed
		local time_speed_neg1 = time_speed * -1
		local exp = math.exp(time_speed_neg1)

		posPos = exp * (time_speed + 1)
		posVel = exp * time
		velPos = exp * (time_speed_neg1 * speed)
		velVel = exp * (time_speed_neg1 + 1)
	else
		-- underdamped spring

		local alpha = speed * math.sqrt(1 - damping^2)
		local overAlpha = 1 / alpha
		local exp = math.exp(-1 * time * speed * damping)
		local sin = math.sin(alpha * time)
		local cos = math.cos(alpha * time)
		local exp_sin = exp * sin
		local exp_cos = exp * cos
		local exp_sin_speed_damping_overAlpha = exp_sin * speed * damping * overAlpha

		posPos = exp_sin_speed_damping_overAlpha + exp_cos
		posVel = exp_sin * overAlpha
		velPos = -1 * ( exp_sin * alpha + speed * damping * exp_sin_speed_damping_overAlpha )
		velVel = exp_cos - exp_sin_speed_damping_overAlpha
	end

	return posPos, posVel, velPos, velVel
end

return springCoefficients
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="177">
                <Properties>
                  <string name="Name">unpackType</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Unpacks an animatable type into an array of numbers.
	If the type is not animatable, an empty array will be returned.
]]

local Package = script.Parent.Parent
local Oklab = require(Package.Colour.Oklab)

local function unpackType(
	value: unknown,
	typeString: string
): {number}
	if typeString == "number" then
		local value = value :: number
		return {value}

	elseif typeString == "CFrame" then
		local value = value :: CFrame
		-- FUTURE: is there a better way of doing this? doing distance
		-- calculations on `angle` may be incorrect
		local axis, angle = value:ToAxisAngle()
		return {value.X, value.Y, value.Z, axis.X, axis.Y, axis.Z, angle}

	elseif typeString == "Color3" then
		local value = value :: Color3
		local lab = Oklab.fromSRGB(value)
		return {lab.X, lab.Y, lab.Z}

	elseif typeString == "ColorSequenceKeypoint" then
		local value = value :: ColorSequenceKeypoint
		local lab = Oklab.fromSRGB(value.Value)
		return {lab.X, lab.Y, lab.Z, value.Time}

	elseif typeString == "DateTime" then
		local value = value :: DateTime
		return {value.UnixTimestampMillis}

	elseif typeString == "NumberRange" then
		local value = value :: NumberRange
		return {value.Min, value.Max}

	elseif typeString == "NumberSequenceKeypoint" then
		local value = value :: NumberSequenceKeypoint
		return {value.Value, value.Time, value.Envelope}

	elseif typeString == "PhysicalProperties" then
		local value = value :: PhysicalProperties
		return {value.Density, value.Friction, value.Elasticity, value.FrictionWeight, value.ElasticityWeight}

	elseif typeString == "Ray" then
		local value = value :: Ray
		return {value.Origin.X, value.Origin.Y, value.Origin.Z, value.Direction.X, value.Direction.Y, value.Direction.Z}

	elseif typeString == "Rect" then
		local value = value :: Rect
		return {value.Min.X, value.Min.Y, value.Max.X, value.Max.Y}

	elseif typeString == "Region3" then
		local value = value :: Region3
		-- FUTURE: support rotated Region3s if/when they become constructable
		return {
			value.CFrame.X, value.CFrame.Y, value.CFrame.Z,
			value.Size.X, value.Size.Y, value.Size.Z
		}

	elseif typeString == "Region3int16" then
		local value = value :: Region3int16
		return {value.Min.X, value.Min.Y, value.Min.Z, value.Max.X, value.Max.Y, value.Max.Z}

	elseif typeString == "UDim" then
		local value = value :: UDim
		return {value.Scale, value.Offset}

	elseif typeString == "UDim2" then
		local value = value :: UDim2
		return {value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset}

	elseif typeString == "Vector2" then
		local value = value :: Vector2
		return {value.X, value.Y}

	elseif typeString == "Vector2int16" then
		local value = value :: Vector2int16
		return {value.X, value.Y}

	elseif typeString == "Vector3" then
		local value = value :: Vector3
		return {value.X, value.Y, value.Z}

	elseif typeString == "Vector3int16" then
		local value = value :: Vector3int16
		return {value.X, value.Y, value.Z}
	else
		return {}
	end
end

return unpackType</string>
                </Properties>
              </Item>
            </Item>
            <Item class="Folder" referent="178">
              <Properties>
                <string name="Name">Colour</string>
              </Properties>
              <Item class="ModuleScript" referent="179">
                <Properties>
                  <string name="Name">Oklab</string>
                  <string name="Source"><![CDATA[--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Provides functions for converting Color3s into Oklab space, for more
	perceptually uniform colour blending.

	See: https://bottosson.github.io/posts/oklab/
]]

local sRGB = require(script.Parent.sRGB)

local Oklab = {}

-- Converts a Color3 in linear RGB space to a Vector3 in Oklab space.
function Oklab.fromLinear(rgb: Color3): Vector3

	local l = rgb.R * 0.4122214708 + rgb.G * 0.5363325363 + rgb.B * 0.0514459929
	local m = rgb.R * 0.2119034982 + rgb.G * 0.6806995451 + rgb.B * 0.1073969566
	local s = rgb.R * 0.0883024619 + rgb.G * 0.2817188376 + rgb.B * 0.6299787005

	local lRoot = l ^ (1/3)
	local mRoot = m ^ (1/3)
	local sRoot = s ^ (1/3)

	return Vector3.new(
		lRoot * 0.2104542553 + mRoot * 0.7936177850 - sRoot * 0.0040720468,
		lRoot * 1.9779984951 - mRoot * 2.4285922050 + sRoot * 0.4505937099,
		lRoot * 0.0259040371 + mRoot * 0.7827717662 - sRoot * 0.8086757660
	)
end

-- Converts a Color3 in sRGB space to a Vector3 in Oklab space.
function Oklab.fromSRGB(srgb: Color3): Vector3
	return Oklab.fromLinear(sRGB.toLinear(srgb))
end

-- Converts a Vector3 in Oklab space to a Color3 in linear RGB space.
-- The Color3 will be clamped by default unless specified otherwise.
function Oklab.toLinear(lab: Vector3, unclamped: boolean?): Color3
	local lRoot = lab.X + lab.Y * 0.3963377774 + lab.Z * 0.2158037573
	local mRoot = lab.X - lab.Y * 0.1055613458 - lab.Z * 0.0638541728
	local sRoot = lab.X - lab.Y * 0.0894841775 - lab.Z * 1.2914855480

	local l = lRoot ^ 3
	local m = mRoot ^ 3
	local s = sRoot ^ 3

	local red = l * 4.0767416621 - m * 3.3077115913 + s * 0.2309699292
	local green = l * -1.2684380046 + m * 2.6097574011 - s * 0.3413193965
	local blue = l * -0.0041960863 - m * 0.7034186147 + s * 1.7076147010

	if not unclamped then
		red = math.clamp(red, 0, 1)
		green = math.clamp(green, 0, 1)
		blue = math.clamp(blue, 0, 1)
	end

	return Color3.new(red, green, blue)
end

-- Converts a Vector3 in Oklab space to a Color3 in sRGB space.
-- The Color3 will be clamped by default unless specified otherwise.
function Oklab.toSRGB(lab: Vector3, unclamped: boolean?): Color3
	return sRGB.fromLinear(Oklab.toLinear(lab, unclamped))
end

return Oklab
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="180">
                <Properties>
                  <string name="Name">sRGB</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
    Provides transformation functions for converting linear RGB values
    into sRGB values.

    RGB color channel transformations are outlined here:
    https://bottosson.github.io/posts/colorwrong/#what-can-we-do%3F
]]

local sRGB = {}

-- Equivalent to f_inv. Takes a linear sRGB channel and returns
-- the sRGB channel
local function transform(channel: number): number
    if channel >= 0.04045 then
        return ((channel + 0.055)/(1 + 0.055))^2.4
    else
        return channel / 12.92
    end
end

-- Equivalent to f. Takes an sRGB channel and returns
-- the linear sRGB channel
local function inverse(channel: number): number
    if channel >= 0.0031308 then
        return (1.055) * channel^(1.0/2.4) - 0.055
    else
        return 12.92 * channel
    end
end

-- Uses a tranformation to convert linear RGB into sRGB.
function sRGB.fromLinear(rgb: Color3): Color3
    return Color3.new(
        transform(rgb.R),
        transform(rgb.G),
        transform(rgb.B)
    )
end

-- Converts an sRGB into linear RGB using a
-- (The inverse of sRGB.fromLinear).
function sRGB.toLinear(srgb: Color3): Color3
    return Color3.new(
        inverse(srgb.R),
        inverse(srgb.G),
        inverse(srgb.B)
    )
end

return sRGB</string>
                </Properties>
              </Item>
            </Item>
            <Item class="ModuleScript" referent="181">
              <Properties>
                <string name="Name">External</string>
                <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Abstraction layer between Fusion internals and external environments,
	allowing for flexible integration with schedulers and test mocks.
]]

local Package = script.Parent
local formatError = require(Package.Logging.formatError)
local Types = require(Package.Types)

local ERROR_INFO_URL = "https://elttob.uk/Fusion/0.3/api-reference/general/errors/#"

local External = {}

-- Indicates that a highly time-critical passage of code is running. During
-- critical periods of a program, Fusion might decide to change some of its
-- internal behaviour to be more performance friendly.
local timeCritical = false

-- Multiplier for running-time safety checks across the Fusion codebase. Used to
-- stricten tests on infinite loop detection during unit testing.
External.safetyTimerMultiplier = 1

local updateStepCallbacks = {}
local currentProvider: Types.ExternalProvider? = nil
local lastUpdateStep = 0

--[[
	Swaps to a new provider for external operations.
	Returns the old provider, so it can be used again later.
]]
function External.setExternalProvider(
	newProvider: Types.ExternalProvider?
): Types.ExternalProvider?
	local oldProvider = currentProvider
	if oldProvider ~= nil then
		oldProvider.stopScheduler()
	end
	currentProvider = newProvider
	if newProvider ~= nil then
		newProvider.startScheduler()
	end
	return oldProvider
end

--[[
	Returns true if a highly time-critical passage of code is running.
]]
function External.isTimeCritical(): boolean
	return timeCritical
end

--[[
   Sends an immediate task to the external provider. Throws if none is set.
]]
function External.doTaskImmediate(
	resume: () -> ()
)
	if currentProvider == nil then
		External.logError("noTaskScheduler")
	else
		currentProvider.doTaskImmediate(resume)
	end
end

--[[
	Sends a deferred task to the external provider. Throws if none is set.
]]
function External.doTaskDeferred(
	resume: () -> ()
)
	if currentProvider == nil then
		External.logError("noTaskScheduler")
	else
		currentProvider.doTaskDeferred(resume)
	end
end

--[[
	Errors in the current thread and halts execution.
]]
function External.logError(
	messageID: string,
	errObj: Types.Error?,
	...: unknown
): never
	error(formatError(currentProvider, messageID, errObj, ...), 0)
end

--[[
	Errors in a different thread to preserve the flow of execution.
]]
function External.logErrorNonFatal(
	messageID: string,
	errObj: Types.Error?,
	...: unknown
): ()
	local errorString = formatError(currentProvider, messageID, errObj, ...)
	if currentProvider ~= nil then
		currentProvider.logErrorNonFatal(errorString)
	else
		print(errorString)
	end
end

--[[
	Shows a warning message in the output.
]]
function External.logWarn(
	messageID: string,
	...: unknown
): ()
	local errorString = formatError(currentProvider, messageID, debug.traceback(nil, 2), ...)
	if currentProvider ~= nil then
		currentProvider.logWarn(errorString)
	else
		print(errorString)
	end
end

--[[
	Registers a callback to the update step of the external provider.
	Returns a function that can be used to disconnect later.

	Callbacks are given the current number of seconds since an arbitrary epoch.
	
	TODO: This epoch may change between providers. We could investigate ways
	of allowing providers to co-operate to keep the epoch the same, so that
	monotonicity can be better preserved.
]]
function External.bindToUpdateStep(
	callback: (
		now: number
	) -> ()
): () -> ()
	local uniqueIdentifier = {}
	updateStepCallbacks[uniqueIdentifier] = callback
	return function()
		updateStepCallbacks[uniqueIdentifier] = nil
	end
end

--[[
	Steps time-dependent systems with the current number of seconds since an
	arbitrary epoch. This should be called as early as possible in the external
	provider's update cycle.
]]
function External.performUpdateStep(
	now: number
)
	lastUpdateStep = now
	for _, callback in updateStepCallbacks do
		callback(now)
	end
end

--[[
	Returns the timestamp of the last update step.
]]
function External.lastUpdateStep()
	return lastUpdateStep
end

return External</string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="182">
              <Properties>
                <string name="Name">ExternalDebug</string>
                <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Abstraction layer between Fusion internals and external debuggers, allowing
	for deep introspection using function hooks.

	Unlike `External`, attaching a debugger is optional, and all debugger
	functions are expected to be infallible and non-blocking.
]]

local Package = script.Parent
local Types = require(Package.Types)

local currentProvider: Types.ExternalDebugger? = nil
local lastUpdateStep = 0

local Debugger = {}

--[[
	Swaps to a new debugger.
	Returns the old debugger, so it can be used again later.
]]
function Debugger.setDebugger(
	newProvider: Types.ExternalDebugger?
): Types.ExternalDebugger?
	local oldProvider = currentProvider
	if oldProvider ~= nil then
		oldProvider.stopDebugging()
	end
	currentProvider = newProvider
	if newProvider ~= nil then
		newProvider.startDebugging()
	end
	return oldProvider
end

--[[
	Called at the earliest moment after a scope is created or removed from the
	scope pool, but not before the scope has finished being prepared by the
	library, so that debuggers can register its existence and track changes
	to the scope over time.
]]
function Debugger.trackScope(
	scope: Types.Scope&lt;unknown>
): ()
	if currentProvider == nil then
		return
	end
	currentProvider.trackScope(scope)
end

--[[
	Called at the final moment before a scope is poisoned or added to the scope
	pool, after all cleanup tasks have completed, so that debuggers can erase 
	the scope from internal trackers. Note that, due to scope pooling and user
	code, never assume that this correlates with garbage collection events.
]]
function Debugger.untrackScope(
	scope: Types.Scope&lt;unknown>
): ()
	if currentProvider == nil then
		return
	end
	currentProvider.trackScope(scope)
end

return Debugger</string>
              </Properties>
            </Item>
            <Item class="Folder" referent="183">
              <Properties>
                <string name="Name">Graph</string>
              </Properties>
              <Item class="ModuleScript" referent="184">
                <Properties>
                  <string name="Name">Observer</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A graph object that runs user code when it's updated by the reactive graph.

	http://elttob.uk/Fusion/0.3/api-reference/state/types/observer/
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- Memory
local checkLifetime = require(Package.Memory.checkLifetime)
-- Graph
local castToGraph = require(Package.Graph.castToGraph)
local depend = require(Package.Graph.depend)
local evaluate = require(Package.Graph.evaluate)
-- Utility
local nicknames = require(Package.Utility.nicknames)

type Self = Types.Observer &amp; {
	_watchingGraph: Types.GraphObject?,
	_changeListeners: {[unknown]: () -> ()}
}

local class = {}
class.type = "Observer"
class.timeliness = "eager"
class.dependentSet = table.freeze {}

local METATABLE = table.freeze {__index = class}

local function Observer(
	scope: Types.Scope&lt;unknown>,
	watching: unknown
): Types.Observer
	local createdAt = os.clock()
	if watching == nil then
		External.logError("scopeMissing", nil, "Observers", "myScope:Observer(watching)")
	end

	local self: Self = setmetatable(
		{
			scope = scope,
			createdAt = createdAt,
			dependencySet = {},
			lastChange = nil,
			validity = "invalid",
			_watchingGraph = castToGraph(watching),
			_changeListeners = {}
		},
		METATABLE
	) :: any
	local destroy = function()
		self.scope = nil
		for dependency in pairs(self.dependencySet) do
			dependency.dependentSet[self] = nil
		end
	end
	self.oldestTask = destroy
	nicknames[self.oldestTask] = "Observer"
	table.insert(scope, destroy)

	if self._watchingGraph ~= nil then
		checkLifetime.bOutlivesA(
			scope, self.oldestTask,
			self._watchingGraph.scope, self._watchingGraph.oldestTask,
			checkLifetime.formatters.observer
		)
	end

	-- Eagerly evaluated objects need to evaluate themselves so that they're
	-- valid at all times.
	evaluate(self, true)

	return self
end

function class.onBind(
	self: Self,
	callback: () -> ()
): () -> ()
	External.doTaskImmediate(callback)
	return self:onChange(callback)
end

function class.onChange(
	self: Self,
	callback: () -> ()
): () -> ()
	local uniqueIdentifier = table.freeze {}
	self._changeListeners[uniqueIdentifier] = callback
	return function()
		self._changeListeners[uniqueIdentifier] = nil
	end
end

function class._evaluate(
	self: Self
): ()
	if self._watchingGraph ~= nil then
		depend(self, self._watchingGraph)
	end
	for _, callback in self._changeListeners do
		External.doTaskImmediate(callback)
	end
	return true
end

table.freeze(class)
return Observer :: Types.ObserverConstructor</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="185">
                <Properties>
                  <string name="Name">castToGraph</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Returns the input *only* if it is a graph object.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)

local function castToGraph(
	target: any
): Types.GraphObject?
	if 
		typeof(target) == "table" and
		typeof(target.validity) == "string" and
		typeof(target.timeliness) == "string" and
		typeof(target.dependencySet) == "table" and
		typeof(target.dependentSet) == "table"
	then
		return target
	else
		return nil
	end
end

return castToGraph</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="186">
                <Properties>
                  <string name="Name">change</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Prompts a graph object to re-evaluate its own value. If it meaningfully
	changes, then dependents will have to re-evaluate their own values in the
	future.

	https://fluff.blog/2024/04/16/monotonic-painting.html
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
local evaluate = require(Package.Graph.evaluate)

-- How long should this function run before it's considered to be in an infinite
-- cycle and error out?
local TERMINATION_TIME = 1

local function change(
	target: Types.GraphObject
): ()
	if target.validity == "busy" then
		return External.logError("infiniteLoop")
	end

	local meaningfullyChanged = evaluate(target, true)
	if not meaningfullyChanged then
		return
	end

	local searchInNow: {Types.GraphObject} = {}
	local searchInNext: {Types.GraphObject} = {}
	local invalidateList: {Types.GraphObject} = {}

	searchInNow[1] = target
	local terminateBy = os.clock() + TERMINATION_TIME * External.safetyTimerMultiplier
	repeat
		if os.clock() > terminateBy then
			return External.logError("infiniteLoop")
		end
		local done = true
		for _, searchTarget in searchInNow do
			for dependent in searchTarget.dependentSet do
				if dependent.validity == "valid" then
					done = false
					table.insert(invalidateList, dependent)
					table.insert(searchInNext, dependent)
				elseif dependent.validity == "busy" then
					return External.logError("infiniteLoop")
				end
			end
		end
		searchInNow, searchInNext = searchInNext, searchInNow
		table.clear(searchInNext)
	until done

	local eagerList: {Types.GraphObject} = {}

	for _, invalidateTarget in invalidateList do
		invalidateTarget.validity = "invalid"
		if invalidateTarget.timeliness == "eager" then
			table.insert(eagerList, invalidateTarget)
		end
	end
	-- If objects are not executed in order of creations, then dynamic graphs
	-- may experience 'glitches' where nested graph objects see intermediate
	-- values before being destroyed.
	-- https://fluff.blog/2024/07/14/glitches-in-dynamic-reactive-graphs.html
	table.sort(eagerList, function(a, b)
		return a.createdAt &lt; b.createdAt
	end)
	for _, eagerTarget in eagerList do
		evaluate(eagerTarget, false)
	end
end

return change</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="187">
                <Properties>
                  <string name="Name">depend</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Forms a dependency on a graph object.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
local evaluate = require(Package.Graph.evaluate)
local nameOf = require(Package.Utility.nameOf)

local function depend&lt;T>(
	dependent: Types.GraphObject,
	dependency: Types.GraphObject
): ()
	-- Ensure dependencies are evaluated and up-to-date
	-- when they are depended on. Also, newly created objects
	-- might not have any transitive dependencies captured yet,
	-- so ensure that they're present.
	evaluate(dependency, false)

	if table.isfrozen(dependent.dependencySet) or table.isfrozen(dependency.dependentSet) then
		External.logError("cannotDepend", nil, nameOf(dependent, "Dependent"), nameOf(dependency, "dependency"))
	end
	dependency.dependentSet[dependent] = true
	dependent.dependencySet[dependency] = true
end

return depend</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="188">
                <Properties>
                  <string name="Name">evaluate</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Evaluates the graph object if necessary, so that it is up to date.
	Returns true if it meaningfully changed.

	https://fluff.blog/2024/04/16/monotonic-painting.html
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)

local function evaluate(
	target: Types.GraphObject,
	forceComputation: boolean
): boolean
	if target.validity == "busy" then
		return External.logError("infiniteLoop")
	end
	local firstEvaluation = target.lastChange == nil
	local isInvalid = target.validity == "invalid"
	if firstEvaluation or isInvalid or forceComputation then
		local needsComputation = firstEvaluation or forceComputation
		if not needsComputation then
			for dependency in target.dependencySet do
				evaluate(dependency, false)
				if dependency.lastChange > target.lastChange then
					needsComputation = true
					break
				end
			end
		end
		local targetMeaningfullyChanged = false
		if needsComputation then
			for dependency in target.dependencySet do
				dependency.dependentSet[target] = nil
				target.dependencySet[dependency] = nil
			end
			target.validity = "busy"
			targetMeaningfullyChanged = target:_evaluate() or firstEvaluation
		end
		if targetMeaningfullyChanged then
			target.lastChange = os.clock()
		end
		target.validity = "valid"
		return targetMeaningfullyChanged
	else
		return false
	end
end

return evaluate</string>
                </Properties>
              </Item>
            </Item>
            <Item class="Folder" referent="189">
              <Properties>
                <string name="Name">Instances</string>
              </Properties>
              <Item class="ModuleScript" referent="190">
                <Properties>
                  <string name="Name">Attribute</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A special key for property tables, which allows users to apply custom
	attributes to instances
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
-- Memory
local checkLifetime = require(Package.Memory.checkLifetime)
-- Graph
local Observer = require(Package.Graph.Observer)
-- State
local castToState = require(Package.State.castToState)
local peek = require(Package.State.peek)

local keyCache: {[string]: Types.SpecialKey} = {}

local function Attribute(
	attributeName: string
): Types.SpecialKey
	local key = keyCache[attributeName]
	if key == nil then
		key = {
			type = "SpecialKey",
			kind = "Attribute",
			stage = "self",
			apply = function(
				self: Types.SpecialKey,
				scope: Types.Scope&lt;unknown>,
				value: unknown,
				applyTo: Instance
			)
				if castToState(value) then
					local value = value :: Types.StateObject&lt;unknown>
					checkLifetime.bOutlivesA(
						scope, applyTo,
						value.scope, value.oldestTask,
						checkLifetime.formatters.boundAttribute, attributeName
					)
					Observer(scope, value :: any):onBind(function()
						applyTo:SetAttribute(attributeName, peek(value))
					end)
				else
					applyTo:SetAttribute(attributeName, value)
				end
			end
		}
		keyCache[attributeName] = key
	end
	return key
end

return Attribute</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="191">
                <Properties>
                  <string name="Name">AttributeChange</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A special key for property tables, which allows users to connect to
	an attribute change on an instance.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)

local keyCache: {[string]: Types.SpecialKey} = {}

local function AttributeChange(
	attributeName: string
): Types.SpecialKey
	local key = keyCache[attributeName]
	if key == nil then
		key = {
			type = "SpecialKey",
			kind = "AttributeChange",
			stage = "observer",
			apply = function(
				self: Types.SpecialKey,
				scope: Types.Scope&lt;unknown>,
				value: unknown,
				applyTo: Instance
			)
				if typeof(value) ~= "function" then
					External.logError("invalidAttributeChangeHandler", nil, attributeName)
				end
				local value = value :: (...unknown) -> (...unknown)
				local event = applyTo:GetAttributeChangedSignal(attributeName)
				table.insert(scope, event:Connect(function()
					value((applyTo :: any):GetAttribute(attributeName))
				end))
			end
		}
		keyCache[attributeName] = key
	end
	return key
end

return AttributeChange</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="192">
                <Properties>
                  <string name="Name">AttributeOut</string>
                  <string name="Source"><![CDATA[--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A special key for property tables, which allows users to save instance attributes
	into state objects
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- Memory
local checkLifetime = require(Package.Memory.checkLifetime)
-- State
local castToState = require(Package.State.castToState)

local keyCache: {[string]: Types.SpecialKey} = {}

local function AttributeOut(
	attributeName: string
): Types.SpecialKey
	local key = keyCache[attributeName]
	if key == nil then
		key = {
			type = "SpecialKey",
			kind = "AttributeOut",
			stage = "observer",
			apply = function(
				self: Types.SpecialKey,
				scope: Types.Scope<unknown>,
				value: unknown,
				applyTo: Instance
			)
				local event = applyTo:GetAttributeChangedSignal(attributeName)
	
				if not castToState(value) then
					External.logError("invalidAttributeOutType")
				end
				local value = value :: Types.StateObject<unknown>
				if value.kind ~= "Value" then
					External.logError("invalidAttributeOutType")
				end
				local value = value :: Types.Value<unknown>
				checkLifetime.bOutlivesA(
					scope, applyTo,
					value.scope, value.oldestTask,
					checkLifetime.formatters.attributeOutputsTo, attributeName
				)

				value:set((applyTo :: any):GetAttribute(attributeName))
				table.insert(scope, event:Connect(function()	
					value:set((applyTo :: any):GetAttribute(attributeName))
				end))
			end
		}
		keyCache[attributeName] = key
	end
	return key
end

return AttributeOut
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="193">
                <Properties>
                  <string name="Name">Child</string>
                  <string name="Source"><![CDATA[--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Helper function for type checking purposes. Casts the input to a `Child`
	type, while constraining the input to be an array of `Child` - this prevents
	Luau from erroneously inferring a different array type for the input.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)

local function Child(
	x: {Types.Child}
): Types.Child
	return x
end

return Child
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="194">
                <Properties>
                  <string name="Name">Children</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A special key for property tables, which parents any given descendants into
	an instance.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
local Observer = require(Package.Graph.Observer)
local peek = require(Package.State.peek)
local castToState = require(Package.State.castToState)
local doCleanup = require(Package.Memory.doCleanup)

type Set&lt;T> = {[T]: unknown}

-- Experimental flag: name children based on the key used in the [Children] table
local EXPERIMENTAL_AUTO_NAMING = false

return {
	type = "SpecialKey",
	kind = "Children",
	stage = "descendants",
	apply = function(
		self: Types.SpecialKey,
		scope: Types.Scope&lt;unknown>,
		value: unknown,
		applyTo: Instance
	)
		local newParented: Set&lt;Instance> = {}
		local oldParented: Set&lt;Instance> = {}
	
		-- save scopes for state object observers
		local newScopes: {[Types.StateObject&lt;unknown>]: Types.Scope&lt;unknown>} = {}
		local oldScopes: {[Types.StateObject&lt;unknown>]: Types.Scope&lt;unknown>} = {}
	
		-- Rescans this key's value to find new instances to parent and state objects
		-- to observe for changes; then unparents instances no longer found and
		-- disconnects observers for state objects no longer present.
		local function updateChildren()
			oldParented, newParented = newParented, oldParented
			oldScopes, newScopes = newScopes, oldScopes
	
			local function processChild(
				child: unknown,
				autoName: string?
			)
				local childType = typeof(child)
	
				if childType == "Instance" then
					-- case 1; single instance
					local child = child :: Instance
	
					newParented[child] = true
					if oldParented[child] == nil then
						-- wasn't previously present
	
						-- TODO: check for ancestry conflicts here
						child.Parent = applyTo
					else
						-- previously here; we want to reuse, so remove from old
						-- set so we don't encounter it during unparenting
						oldParented[child] = nil
					end
	
					if EXPERIMENTAL_AUTO_NAMING and autoName ~= nil then
						child.Name = autoName
					end
	
				elseif castToState(child) then
					-- case 2; state object
					local child = child :: Types.StateObject&lt;unknown>
	
					local value = peek(child)
					-- allow nil to represent the absence of a child
					if value ~= nil then
						processChild(value, autoName)
					end
	
					local childScope = oldScopes[child]
					if childScope == nil then
						-- wasn't previously present
						childScope = {}
						Observer(childScope, child):onChange(updateChildren)
					else
						-- previously here; we want to reuse, so remove from old
						-- set so we don't encounter it during unparenting
						oldScopes[child] = nil
					end
	
					newScopes[child] = childScope
	
				elseif childType == "table" then
					-- case 3; table of objects
					local child = child :: {[unknown]: unknown}
	
					for key, subChild in pairs(child) do
						local keyType = typeof(key)
						local subAutoName: string? = nil
	
						if keyType == "string" then
							local key = key :: string
							subAutoName = key
						elseif keyType == "number" and autoName ~= nil then
							local key = key :: number
							subAutoName = autoName .. "_" .. key
						end
	
						processChild(subChild, subAutoName)
					end
	
				else
					External.logWarn("unrecognisedChildType", childType)
				end
			end
	
			if value ~= nil then
				-- `propValue` is set to nil on cleanup, so we don't process children
				-- in that case
				processChild(value)
			end
	
			-- unparent any children that are no longer present
			for oldInstance in pairs(oldParented) do
				oldInstance.Parent = nil
			end
			table.clear(oldParented)
	
			-- disconnect observers which weren't reused
			for oldState, childScope in pairs(oldScopes) do
				doCleanup(childScope)
			end
			table.clear(oldScopes)
		end
	
		table.insert(scope, function()
			value = nil
			updateChildren()
		end)
	
		-- perform initial child parenting
		updateChildren()
	end
} :: Types.SpecialKey</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="195">
                <Properties>
                  <string name="Name">Hydrate</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Processes and returns an existing instance, with options for setting
	properties, event handlers and other attributes on the instance.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
local applyInstanceProps = require(Package.Instances.applyInstanceProps)

local function Hydrate(
	scope: Types.Scope&lt;unknown>,
	target: Instance
)
	if target :: any == nil then
		External.logError("scopeMissing", nil, "instances using Hydrate", "myScope:Hydrate (instance) { ... }")
	end
	return function(
		props: Types.PropertyTable
	): Instance
	
		table.insert(scope, target)
		applyInstanceProps(scope, props, target)
		return target
	end
end

return Hydrate</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="196">
                <Properties>
                  <string name="Name">New</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Constructs and returns a new instance, with options for setting properties,
	event handlers and other attributes on the instance right away.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
local defaultProps = require(Package.Instances.defaultProps)
local applyInstanceProps = require(Package.Instances.applyInstanceProps)

type Component = (Types.PropertyTable) -> Instance

local function New(
	scope: Types.Scope&lt;unknown>,
	className: string
)
	if (className :: any) == nil then
		local scope = (scope :: any) :: string
		External.logError("scopeMissing", nil, "instances using New", "myScope:New \"" .. scope .. "\" { ... }")
	end

	-- This might look appealing to try and cache. But please don't. The scope
	-- upvalue is shared between the two curried function calls, so this will
	-- open incredible cross-codebase wormholes like you've never seen before.
	return function(
		props: Types.PropertyTable
	): Instance
		local ok, instance = pcall(Instance.new, className)
		if not ok then
			External.logError("cannotCreateClass", nil, className)
		end

		local classDefaults = defaultProps[className]
		if classDefaults ~= nil then
			for defaultProp, defaultValue in pairs(classDefaults) do
				(instance :: any)[defaultProp] = defaultValue
			end
		end

		table.insert(scope, instance)
		applyInstanceProps(scope, props, instance)

		return instance
	end
end

return New</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="197">
                <Properties>
                  <string name="Name">OnChange</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Constructs special keys for property tables which connect property change
	listeners to an instance.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)

local keyCache: {[string]: Types.SpecialKey} = {}

local function OnChange(
	propertyName: string
): Types.SpecialKey
	local key = keyCache[propertyName]
	if key == nil then
		key = {
			type = "SpecialKey",
			kind = "OnChange",
			stage = "observer",
			apply = function(
				self: Types.SpecialKey,
				scope: Types.Scope&lt;unknown>,
				callback: unknown,
				applyTo: Instance
			)
				local ok, event = pcall(applyTo.GetPropertyChangedSignal, applyTo, propertyName)
				if not ok then
					External.logError("cannotConnectChange", nil, applyTo.ClassName, propertyName)
				elseif typeof(callback) ~= "function" then
					External.logError("invalidChangeHandler", nil, propertyName)
				else
					local callback = callback :: (...unknown) -> (...unknown)
					table.insert(scope, event:Connect(function()
						callback((applyTo :: any)[propertyName])
					end))
				end
			end
		}
		keyCache[propertyName] = key
	end
	return key
end

return OnChange</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="198">
                <Properties>
                  <string name="Name">OnEvent</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Constructs special keys for property tables which connect event listeners to
	an instance.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)

local keyCache: {[string]: Types.SpecialKey} = {}

local function getProperty_unsafe(
	instance: Instance,
	property: string
)
	return (instance :: any)[property]
end

local function OnEvent(
	eventName: string
): Types.SpecialKey
	local key = keyCache[eventName]
	if key == nil then
		key = {
			type = "SpecialKey",
			kind = "OnEvent",
			stage = "observer",
			apply = function(
				self: Types.SpecialKey,
				scope: Types.Scope&lt;unknown>,
				callback: unknown,
				applyTo: Instance
			)
				local ok, event = pcall(getProperty_unsafe, applyTo, eventName)
				if not ok or typeof(event) ~= "RBXScriptSignal" then
					External.logError("cannotConnectEvent", nil, applyTo.ClassName, eventName)
				elseif typeof(callback) ~= "function" then
					External.logError("invalidEventHandler", nil, eventName)
				else
					table.insert(scope, event:Connect(callback :: any))
				end
			end
		}
		keyCache[eventName] = key
	end
	return key
end

return OnEvent</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="199">
                <Properties>
                  <string name="Name">Out</string>
                  <string name="Source"><![CDATA[--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A special key for property tables, which allows users to extract values from
	an instance into an automatically-updated Value object.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- Memory
local checkLifetime = require(Package.Memory.checkLifetime)
-- State
local castToState = require(Package.State.castToState)

local keyCache: {[string]: Types.SpecialKey} = {}

local function Out(
	propertyName: string
): Types.SpecialKey
	local key = keyCache[propertyName]
	if key == nil then
		key = {
			type = "SpecialKey",
			kind = "Out",
			stage = "observer",
			apply = function(
				self: Types.SpecialKey,
				scope: Types.Scope<unknown>,
				value: unknown,
				applyTo: Instance
			)
				local ok, event = pcall(applyTo.GetPropertyChangedSignal, applyTo, propertyName)
				if not ok then
					External.logError("invalidOutProperty", nil, applyTo.ClassName, propertyName)
				end
	
				if not castToState(value) then
					External.logError("invalidOutType")
				end
				local value = value :: Types.StateObject<unknown>
				if value.kind ~= "Value" then
					External.logError("invalidOutType")
				end
				local value = value :: Types.Value<unknown>
				checkLifetime.bOutlivesA(
					scope, applyTo, 
					value.scope, value.oldestTask,
					checkLifetime.formatters.propertyOutputsTo, propertyName
				)

				value:set((applyTo :: any)[propertyName])
				table.insert(
					scope,
					event:Connect(function()
						value:set((applyTo :: any)[propertyName])
					end)
				)
			end
		}
		keyCache[propertyName] = key
	end
	return key
end

return Out
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="200">
                <Properties>
                  <string name="Name">applyInstanceProps</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Applies a table of properties to an instance, including binding to any
	given state objects and applying any special keys.

	No strong reference is kept by default - special keys should take care not
	to accidentally hold strong references to instances forever.

	If a key is used twice, an error will be thrown. This is done to avoid
	double assignments or double bindings. However, some special keys may want
	to enable such assignments - in which case unique keys should be used for
	each occurence.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- Logging
local parseError = require(Package.Logging.parseError)
-- Memory
local checkLifetime = require(Package.Memory.checkLifetime)
-- Graph
local Observer = require(Package.Graph.Observer)
-- State
local castToState = require(Package.State.castToState)
local peek = require(Package.State.peek)
-- Utility
local xtypeof = require(Package.Utility.xtypeof)

local function setProperty_unsafe(
	instance: Instance,
	property: string,
	value: unknown
)
	(instance :: any)[property] = value
end

local function testPropertyAssignable(
	instance: Instance,
	property: string
)
	(instance :: any)[property] = (instance :: any)[property]
end

local function setProperty(
	instance: Instance,
	property: string,
	value: unknown
)
	local success, err = xpcall(setProperty_unsafe :: any, parseError, instance, property, value)

	if not success then
		if not pcall(testPropertyAssignable, instance, property) then
			External.logErrorNonFatal("cannotAssignProperty", nil, instance.ClassName, property)
		else
			-- property is assignable, but this specific assignment failed
			-- this typically implies the wrong type was received
			local givenType = typeof(value)
			local expectedType = typeof((instance :: any)[property])

			if givenType == expectedType then
				External.logErrorNonFatal("propertySetError", err)
			else
				External.logErrorNonFatal("invalidPropertyType", nil, instance.ClassName, property, expectedType, givenType)
			end
		end
	end
end

local function bindProperty(
	scope: Types.Scope&lt;unknown>,
	instance: Instance,
	property: string,
	value: Types.UsedAs&lt;unknown>
)
	if castToState(value) then
		local value = value :: Types.StateObject&lt;unknown>
		checkLifetime.bOutlivesA(
			scope, instance,
			value.scope, value.oldestTask,
			checkLifetime.formatters.boundProperty, property
		)
		-- value is a state object - bind to changes
		Observer(scope, value :: any):onBind(function()
			setProperty(instance, property, peek(value))
		end)
	else
		-- value is a constant - assign once only
		setProperty(instance, property, value)
	end
end

local function applyInstanceProps(
	scope: Types.Scope&lt;unknown>,
	props: Types.PropertyTable,
	applyTo: Instance
)
	local specialKeys = {
		self = {} :: {[Types.SpecialKey]: unknown},
		descendants = {} :: {[Types.SpecialKey]: unknown},
		ancestor = {} :: {[Types.SpecialKey]: unknown},
		observer = {} :: {[Types.SpecialKey]: unknown}
	}

	for key, value in pairs(props) do
		local keyType = xtypeof(key)

		if keyType == "string" then
			if key ~= "Parent" then
				bindProperty(scope, applyTo, key :: string, value)
			end
		elseif keyType == "SpecialKey" then
			local stage = (key :: Types.SpecialKey).stage
			local keys = specialKeys[stage]
			if keys == nil then
				External.logError("unrecognisedPropertyStage", nil, stage)
			else
				keys[key] = value
			end
		else
			-- we don't recognise what this key is supposed to be
			External.logError("unrecognisedPropertyKey", nil, keyType)
		end
	end

	for key, value in pairs(specialKeys.self) do
		key:apply(scope, value, applyTo)
	end
	for key, value in pairs(specialKeys.descendants) do
		key:apply(scope, value, applyTo)
	end

	if props.Parent ~= nil then
		bindProperty(scope, applyTo, "Parent", props.Parent)
	end

	for key, value in pairs(specialKeys.ancestor) do
		key:apply(scope, value, applyTo)
	end
	for key, value in pairs(specialKeys.observer) do
		key:apply(scope, value, applyTo)
	end
end

return applyInstanceProps</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="201">
                <Properties>
                  <string name="Name">defaultProps</string>
                  <string name="Source"><![CDATA[--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Stores 'sensible default' properties to be applied to instances created by
	the New function.
]]

return {
	ScreenGui = {
		ResetOnSpawn = false,
		ZIndexBehavior = Enum.ZIndexBehavior.Sibling
	},

	BillboardGui = {
		ResetOnSpawn = false,
		ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
		Active = true
	},

	SurfaceGui = {
		ResetOnSpawn = false,
		ZIndexBehavior = Enum.ZIndexBehavior.Sibling,

		SizingMode = Enum.SurfaceGuiSizingMode.PixelsPerStud,
		PixelsPerStud = 50
	},

	Frame = {
		BackgroundColor3 = Color3.new(1, 1, 1),
		BorderColor3 = Color3.new(0, 0, 0),
		BorderSizePixel = 0
	},

	ScrollingFrame = {
		BackgroundColor3 = Color3.new(1, 1, 1),
		BorderColor3 = Color3.new(0, 0, 0),
		BorderSizePixel = 0,

		ScrollBarImageColor3 = Color3.new(0, 0, 0)
	},

	TextLabel = {
		BackgroundColor3 = Color3.new(1, 1, 1),
		BorderColor3 = Color3.new(0, 0, 0),
		BorderSizePixel = 0,

		Font = Enum.Font.SourceSans,
		Text = "",
		TextColor3 = Color3.new(0, 0, 0),
		TextSize = 14
	},

	TextButton = {
		BackgroundColor3 = Color3.new(1, 1, 1),
		BorderColor3 = Color3.new(0, 0, 0),
		BorderSizePixel = 0,

		AutoButtonColor = false,

		Font = Enum.Font.SourceSans,
		Text = "",
		TextColor3 = Color3.new(0, 0, 0),
		TextSize = 14
	},

	TextBox = {
		BackgroundColor3 = Color3.new(1, 1, 1),
		BorderColor3 = Color3.new(0, 0, 0),
		BorderSizePixel = 0,

		ClearTextOnFocus = false,

		Font = Enum.Font.SourceSans,
		Text = "",
		TextColor3 = Color3.new(0, 0, 0),
		TextSize = 14
	},

	ImageLabel = {
		BackgroundColor3 = Color3.new(1, 1, 1),
		BorderColor3 = Color3.new(0, 0, 0),
		BorderSizePixel = 0
	},

	ImageButton = {
		BackgroundColor3 = Color3.new(1, 1, 1),
		BorderColor3 = Color3.new(0, 0, 0),
		BorderSizePixel = 0,

		AutoButtonColor = false
	},

	ViewportFrame = {
		BackgroundColor3 = Color3.new(1, 1, 1),
		BorderColor3 = Color3.new(0, 0, 0),
		BorderSizePixel = 0
	},

	VideoFrame = {
		BackgroundColor3 = Color3.new(1, 1, 1),
		BorderColor3 = Color3.new(0, 0, 0),
		BorderSizePixel = 0
	},
	
	CanvasGroup = {
		BackgroundColor3 = Color3.new(1, 1, 1),
		BorderColor3 = Color3.new(0, 0, 0),
		BorderSizePixel = 0
	},

	SpawnLocation = {
		Duration = 0
	},

	BoxHandleAdornment = {
		ZIndex = 0
	},
	ConeHandleAdornment = {
		ZIndex = 0
	},
	CylinderHandleAdornment = {
		ZIndex = 0
	},
	ImageHandleAdornment = {
		ZIndex = 0
	},
	LineHandleAdornment = {
		ZIndex = 0
	},
	SphereHandleAdornment = {
		ZIndex = 0
	},
	WireframeHandleAdornment = {
		ZIndex = 0
	},
	
	Part = {
		Anchored = true,
		Size = Vector3.one,
		FrontSurface = Enum.SurfaceType.Smooth,
		BackSurface = Enum.SurfaceType.Smooth,
		LeftSurface = Enum.SurfaceType.Smooth,
		RightSurface = Enum.SurfaceType.Smooth,
		TopSurface = Enum.SurfaceType.Smooth,
		BottomSurface = Enum.SurfaceType.Smooth,
	},
	
	TrussPart = {
		Anchored = true,
		Size = Vector3.one * 2,
		FrontSurface = Enum.SurfaceType.Smooth,
		BackSurface = Enum.SurfaceType.Smooth,
		LeftSurface = Enum.SurfaceType.Smooth,
		RightSurface = Enum.SurfaceType.Smooth,
		TopSurface = Enum.SurfaceType.Smooth,
		BottomSurface = Enum.SurfaceType.Smooth,
	},

	MeshPart = {
		Anchored = true,
		Size = Vector3.one,
		FrontSurface = Enum.SurfaceType.Smooth,
		BackSurface = Enum.SurfaceType.Smooth,
		LeftSurface = Enum.SurfaceType.Smooth,
		RightSurface = Enum.SurfaceType.Smooth,
		TopSurface = Enum.SurfaceType.Smooth,
		BottomSurface = Enum.SurfaceType.Smooth,
	},

	CornerWedgePart = {
		Anchored = true,
		Size = Vector3.one,
		FrontSurface = Enum.SurfaceType.Smooth,
		BackSurface = Enum.SurfaceType.Smooth,
		LeftSurface = Enum.SurfaceType.Smooth,
		RightSurface = Enum.SurfaceType.Smooth,
		TopSurface = Enum.SurfaceType.Smooth,
		BottomSurface = Enum.SurfaceType.Smooth,
	},

	VehicleSeat = {
		Anchored = true,
		Size = Vector3.one,
		FrontSurface = Enum.SurfaceType.Smooth,
		BackSurface = Enum.SurfaceType.Smooth,
		LeftSurface = Enum.SurfaceType.Smooth,
		RightSurface = Enum.SurfaceType.Smooth,
		TopSurface = Enum.SurfaceType.Smooth,
		BottomSurface = Enum.SurfaceType.Smooth,
	},
}
]]></string>
                </Properties>
              </Item>
            </Item>
            <Item class="StringValue" referent="202">
              <Properties>
                <string name="Name">LICENSE</string>
                <string name="Value"><![CDATA[MIT License

Copyright (c) 2024 Daniel P H Fox

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]]></string>
              </Properties>
            </Item>
            <Item class="Folder" referent="203">
              <Properties>
                <string name="Name">Logging</string>
              </Properties>
              <Item class="ModuleScript" referent="204">
                <Properties>
                  <string name="Name">formatError</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Formats a Fusion-specific error message.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local messages = require(Package.Logging.messages)

local ERROR_INFO_URL = "https://elttob.uk/Fusion/0.3/api-reference/general/errors/#"

local function formatError(
	externalProvider: Types.ExternalProvider?,
	messageID: string,
	errorOrTrace: Types.Error | string | nil,
	...: unknown
): string
	local originalMessageID = messageID
	local error: Types.Error? = if typeof(errorOrTrace) == "table" then errorOrTrace else nil
	local trace: string? = if typeof(errorOrTrace) == "table" then errorOrTrace.trace else errorOrTrace
	local messageText = messages[messageID]
	if messageText == nil then
		messageID = "unknownMessage"
		messageText = messages[messageID]
	end
	messageText = messageText:format(...)
	if error ~= nil then
		messageText = messageText:gsub("ERROR_MESSAGE", error.message)
		if error.context ~= nil then
			messageText ..= ` ({error.context})`
		end
	else
		messageText = messageText:gsub("ERROR_MESSAGE", originalMessageID)
	end
	messageText = `[Fusion] {messageText} \nID: {messageID}`
	if externalProvider ~= nil and externalProvider.policies.allowWebLinks then
		messageText ..= `\nLearn more: {ERROR_INFO_URL}{messageID:lower()}`
	end
	if trace ~= nil then
		messageText ..= ` \n---- Stack trace ----\n{trace}`
	end
	return messageText:gsub("\n", "\n    ")
end

return formatError</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="205">
                <Properties>
                  <string name="Name">messages</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Stores templates for different kinds of logging messages.
]]

return {
	callbackError = "Error in callback:\nERROR_MESSAGE",
	cannotAssignProperty = "The class type '%s' has no assignable property '%s'.",
	cannotConnectChange = "The %s class doesn't have a property called '%s'.",
	cannotConnectEvent = "The %s class doesn't have an event called '%s'.",
	cannotCreateClass = "Can't create a new instance of class '%s'.",
	cannotDepend = "%s can't depend on %s.",
	cleanupWasRenamed = "`Fusion.cleanup` was renamed to `Fusion.doCleanup`. This will be an error in future versions of Fusion.",
	destroyedTwice = "`doCleanup()` was given something that it is already cleaning up. Unclear how to proceed.",
	destructorRedundant = "%s destructors no longer do anything. If you wish to run code on destroy, `table.insert` a function into the `scope` argument. See discussion #292 on GitHub for advice.",
	forKeyCollision = "The key '%s' was returned multiple times simultaneously, which is not allowed in `For` objects.",
	infiniteLoop = "Detected an infinite loop. Consider adding an explicit breakpoint to your code to prevent a cyclic dependency.",
	invalidAttributeChangeHandler = "The change handler for the '%s' attribute must be a function.",
	invalidAttributeOutType = "[AttributeOut] properties must be given Value objects.",
	invalidChangeHandler = "The change handler for the '%s' property must be a function.",
	invalidEventHandler = "The handler for the '%s' event must be a function.",
	invalidOutProperty = "The %s class doesn't have a property called '%s'.",
	invalidOutType = "[Out] properties must be given Value objects.",
	invalidPropertyType = "'%s.%s' expected a '%s' type, but got a '%s' type.",
	invalidRefType = "Instance refs must be Value objects.",
	invalidSpringDamping = "The damping ratio for a spring must be >= 0. (damping was %.2f)",
	invalidSpringSpeed = "The speed of a spring must be >= 0. (speed was %.2f)",
	mergeConflict = "Multiple definitions for '%s' found while merging.",
	mistypedSpringDamping = "The damping ratio for a spring must be a number. (got a %s)",
	mistypedSpringSpeed = "The speed of a spring must be a number. (got a %s)",
	mistypedTweenInfo = "The tween info of a tween must be a TweenInfo. (got a %s)",
	noTaskScheduler = "Fusion is not connected to an external task scheduler.",
	poisonedScope = "Attempted to use a scope after it's been destroyed; %s",
	possiblyOutlives = "%s will be destroyed before %s; %s. To fix this, review the order they're created in, and what scopes they belong to. See discussion #292 on GitHub for advice.",
	propertySetError = "Error setting property:\nERROR_MESSAGE",
	scopeMissing = "To create %s, provide a scope. (e.g. `%s`). See discussion #292 on GitHub for advice.",
	springNanGoal = "A spring was given a NaN goal, so some simulation has been skipped. Ensure no springs have NaN goals.",
	springNanMotion = "A spring encountered NaN during motion, so has snapped to the goal position. Ensure no springs have NaN positions or velocities.",
	springTypeMismatch = "The type '%s' doesn't match the spring's type '%s'.",
	stateGetWasRemoved = "`StateObject:get()` has been replaced by `use()` and `peek()` - see discussion #217 on GitHub.",
	tweenNanGoal = "A tween was given a NaN goal, so some animation has been skipped. Ensure no tweens have NaN goals.",
	tweenNanMotion = "A tween encountered NaN during motion, so has snapped to the goal. Ensure no tweens have NaN in their tween infos.",
	unknownMessage = "Unknown error:\nERROR_MESSAGE",
	unrecognisedChildType = "'%s' type children aren't accepted by `[Children]`.",
	unrecognisedPropertyKey = "'%s' keys aren't accepted in property tables.",
	unrecognisedPropertyStage = "'%s' isn't a valid stage for a special key to be applied at.",
	useAfterDestroy = "%s is no longer valid - it was destroyed before %s. See discussion #292 on GitHub for advice."
}</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="206">
                <Properties>
                  <string name="Name">parseError</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	An xpcall() error handler to collect and parse useful information about
	errors, such as clean messages and stack traces.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)

local function parseError(
	err: string
): Types.Error
	return {
		type = "Error",
		raw = err,
		message = err:gsub("^.+:%d+:%s*", ""),
		trace = debug.traceback(nil, 2)
	}
end

return parseError</string>
                </Properties>
              </Item>
            </Item>
            <Item class="Folder" referent="207">
              <Properties>
                <string name="Name">Memory</string>
              </Properties>
              <Item class="ModuleScript" referent="208">
                <Properties>
                  <string name="Name">checkLifetime</string>
                  <string name="Source"><![CDATA[

--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Procedures for checking lifetimes and printing helpful warnings about them.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
local whichLivesLonger = require(Package.Memory.whichLivesLonger)
local nameOf = require(Package.Utility.nameOf)

local checkLifetime = {}

checkLifetime.formatters = {}

function checkLifetime.formatters.useFunction(
	self: unknown,
	used: unknown
): (string, string)
	local selfName = nameOf(self, "object")
	local usedName = nameOf(used, "object")
	return `The use()-d {usedName}`, `the {selfName}`
end
	
function checkLifetime.formatters.boundProperty(
	instance: Instance,
	bound: unknown,
	property: string
): (string, string)
	local selfName = instance.Name
	local boundName = nameOf(bound, "value")
	return `The {boundName} (bound to the {property} property)`, `the {selfName} instance`
end

function checkLifetime.formatters.boundAttribute(
	instance: Instance,
	bound: unknown,
	attribute: string
): (string, string)
	local selfName = instance.Name
	local boundName = nameOf(bound, "value")
	return `The {boundName} (bound to the {attribute} attribute)`, `the {selfName} instance`
end

function checkLifetime.formatters.propertyOutputsTo(
	instance: Instance,
	bound: unknown,
	property: string
): (string, string)
	local selfName = instance.Name
	local boundName = nameOf(bound, "object")
	return `The {boundName} (which the {property} property outputs to)`, `the {selfName} instance`
end

function checkLifetime.formatters.attributeOutputsTo(
	instance: Instance,
	bound: unknown,
	attribute: string
): (string, string)
	local selfName = instance.Name
	local boundName = nameOf(bound, "object")
	return `The {boundName} (which the {attribute} attribute outputs to)`, `the {selfName} instance`
end

function checkLifetime.formatters.refOutputsTo(
	instance: Instance,
	bound: unknown
): (string, string)
	local selfName = instance.Name
	local boundName = nameOf(bound, "object")
	return `The {boundName} (which the Ref key outputs to)`, `the {selfName} instance`
end

function checkLifetime.formatters.animationGoal(
	self: unknown,
	goal: unknown
): (string, string)
	local selfName = nameOf(self, "object")
	local goalName = nameOf(goal, "object")
	return `The goal {goalName}`, `the {selfName} that is following it`
end

function checkLifetime.formatters.parameter(
	self: unknown,
	used: unknown,
	parameterName: string | false
): (string, string)
	local selfName = nameOf(self, "object")
	local usedName = nameOf(used, "object")
	if parameterName == false then
		return `The {usedName} parameter`, `the {selfName} that it was used for`
	else
		return `The {usedName} representing the {parameterName} parameter`, `the {selfName} that it was used for`
	end
end

function checkLifetime.formatters.observer(
	self: unknown,
	watched: unknown
): (string, string)
	local selfName = nameOf(self, "object")
	local watchedName = nameOf(watched, "object")
	return `The watched {watchedName}`, `the {selfName} that's observing it for changes`
end

function checkLifetime.bOutlivesA<A, B, Args...>(
	scopeA: Types.Scope<unknown>,
	a: A,
	scopeB: Types.Scope<unknown>?,
	b: B,
	formatter: (a: A, b: B, Args...) -> (string, string),
	...: Args...
)
	if scopeB == nil then
		External.logError("useAfterDestroy", nil, formatter(a, b, ...))
	elseif whichLivesLonger(scopeA, a, scopeB, b) == "definitely-a" then
		local aName, bName = formatter(a, b, ...)
		External.logWarn(
			"possiblyOutlives",
			aName, bName, 
			if scopeA == scopeB then
				"they're in the same scope, but the latter is destroyed too quickly"
			else
				"the latter is in a different scope that gets destroyed too quickly"
		)
	end
end
return checkLifetime]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="209">
                <Properties>
                  <string name="Name">deriveScope</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Creates an empty scope with the same metatables as the original scope. Used
	for preserving access to constructors when creating inner scopes.

	This is the public version of the function, which implements external
	debugging hooks.
]]
local Package = script.Parent.Parent
local Types = require(Package.Types)
local ExternalDebug = require(Package.ExternalDebug)
local deriveScopeImpl = require(Package.Memory.deriveScopeImpl)

local function deriveScope(...)
	local scope = deriveScopeImpl(...)
	ExternalDebug.trackScope(scope)
	return scope
end

return deriveScope :: Types.DeriveScopeConstructor</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="210">
                <Properties>
                  <string name="Name">deriveScopeImpl</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Creates an empty scope with the same metatables as the original scope. Used
	for preserving access to constructors when creating inner scopes.

	This is the internal version of the function, which does not implement
	external debugging hooks.
]]
local Package = script.Parent.Parent
local Types = require(Package.Types)
local merge = require(Package.Utility.merge)
local scopePool = require(Package.Memory.scopePool)

-- This return type is technically a lie, but it's required for useful type
-- checking behaviour.
local function deriveScopeImpl&lt;T>(
	existing: Types.Scope&lt;T>,
	methods: {[unknown]: unknown}?,
	...: {[unknown]: unknown}
): any
	local metatable = getmetatable(existing)
	if methods ~= nil then
		metatable = table.clone(metatable)
		metatable.__index = merge(
			true, {}, 
			metatable.__index, 
			merge(
				false, {},
				methods, 
				...
			)
		)
	end
	local scope = setmetatable(
		scopePool.reuseAny() :: any or {},
		metatable
	)
	return scope
end

return (deriveScopeImpl :: any) :: Types.DeriveScopeConstructor</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="211">
                <Properties>
                  <string name="Name">doCleanup</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Cleans up the tasks passed in as the arguments.
	A task can be any of the following:

	- an Instance - will be destroyed
	- an RBXScriptConnection - will be disconnected
	- a function - will be run
	- a table with a `Destroy` or `destroy` function - will be called
	- an array - `cleanup` will be called on each item
]]
local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
local scopePool = require(Package.Memory.scopePool)
local poisonScope = require(Package.Memory.poisonScope)

local alreadyDestroying: {[Types.Task]: true} = {}

local function doCleanup(
	task: Types.Task
): ()
	if alreadyDestroying[task] then
		return External.logError("destroyedTwice")
	end
	alreadyDestroying[task] = true

	-- case 1: Instance
	if typeof(task) == "Instance" then
		task:Destroy()

	-- case 2: RBXScriptConnection
	elseif typeof(task) == "RBXScriptConnection" then
		task:Disconnect()

	-- case 3: callback
	elseif typeof(task) == "function" then
		task()

	elseif typeof(task) == "table" then
		local task = (task :: any) :: {Destroy: (...unknown) -> (...unknown)?, destroy: (...unknown) -> (...unknown)?}

		-- case 4: destroy() function
		if typeof(task.destroy) == "function" then
			local task = (task :: any) :: {destroy: (...unknown) -> (...unknown)}
			task:destroy()

		-- case 5: Destroy() function
		elseif typeof(task.Destroy) == "function" then
			local task = (task :: any) :: {Destroy: (...unknown) -> (...unknown)}
			task:Destroy()

		-- case 6: table of tasks with an array part
		elseif task[1] ~= nil then
			local task = task :: {Types.Task}

			-- It is important to iterate backwards through the table, since
			-- objects are added in order of construction.
			for index = #task, 1, -1 do
				doCleanup(task[index])
				task[index] = nil
			end

			if External.isTimeCritical() then
				scopePool.giveIfEmpty(task)
			else
				poisonScope(task, "`doCleanup()` was previously called on this scope. Ensure you are not reusing scopes after cleanup.")
			end
		end
	end
	
	alreadyDestroying[task] = nil
end

return doCleanup</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="212">
                <Properties>
                  <string name="Name">innerScope</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Derives a new scope that's destroyed exactly once, whether by the user or by
	the scope that it's inside of.
]]
local Package = script.Parent.Parent
local Types = require(Package.Types)
local ExternalDebug = require(Package.ExternalDebug)
local deriveScopeImpl = require(Package.Memory.deriveScopeImpl)

local function innerScope&lt;T>(
	existing: Types.Scope&lt;T>,
	...: {[unknown]: unknown}
): any
	local new = deriveScopeImpl(existing, ...)
	table.insert(existing, new)
	table.insert(
		new, 
		function()
			local index = table.find(existing, new)
			if index ~= nil then
				table.remove(existing, index)
			end
		end
	)
	ExternalDebug.trackScope(new)
	return new
end

return (innerScope :: any) :: Types.DeriveScopeConstructor</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="213">
                <Properties>
                  <string name="Name">legacyCleanup</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
local doCleanup = require(Package.Memory.doCleanup)

local function legacyCleanup(
	value: Types.Task
)
	External.logWarn("cleanupWasRenamed")
	return doCleanup(value)
end

return legacyCleanup</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="214">
                <Properties>
                  <string name="Name">needsDestruction</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Returns true if the given value is not automatically memory managed, and
	requires manual cleanup.
]]

local function needsDestruction(
	x: unknown
): boolean
	return typeof(x) == "Instance"
end

return needsDestruction</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="215">
                <Properties>
                  <string name="Name">poisonScope</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	'Poisons' the given scope; if the scope is used again, then it will cause
	the program to crash.
]]
local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)

local function poisonScope(
	scope: Types.Scope,
	context: string
): ()
	local mt = getmetatable(scope)
	if typeof(mt) == "table" and mt._FUSION_POISONED then
		return
	end
	table.clear(scope)
	setmetatable(scope :: any, {
		_FUSION_POISONED = true,
		__index = function()
			External.logError("poisonedScope", nil, context)
		end,
		__newindex = function()
			External.logError("poisonedScope", nil, context)
		end
	})
end

return poisonScope</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="216">
                <Properties>
                  <string name="Name">scopePool</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

local Package = script.Parent.Parent
local Types = require(Package.Types)
local poisonScope = require(Package.Memory.poisonScope)
local ExternalDebug = require(Package.ExternalDebug)

local ENABLE_POOLING = false
local MAX_POOL_SIZE = 16 -- TODO: need to test what an ideal number for this is

local pool = {}
local poolSize = 0

return {
	giveIfEmpty = function&lt;S>(
		scope: Types.Scope&lt;S>
	): Types.Scope&lt;S>?
		if next(scope) == nil then
			ExternalDebug.untrackScope(scope)
			if ENABLE_POOLING and poolSize &lt; MAX_POOL_SIZE then
				poolSize += 1
				pool[poolSize] = scope
			else
				poisonScope(scope, "previously passed to the internal scope pool, which indicates a Fusion bug.")
			end
			return nil
		else
			return scope
		end
	end,
	clearAndGive = function(
		scope: Types.Scope&lt;unknown>
	)
		ExternalDebug.untrackScope(scope)
		table.clear(scope)
		if ENABLE_POOLING and poolSize &lt; MAX_POOL_SIZE then
			poolSize += 1
			pool[poolSize] = scope :: any
		else
			poisonScope(scope, "previously passed to the internal scope pool, which indicates a Fusion bug.")
		end
	end,
	reuseAny = function(): Types.Scope&lt;unknown>
		if poolSize == 0 then
			return nil :: any
		else
			local scope = pool[poolSize]
			poolSize -= 1
			return scope
		end
	end
}</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="217">
                <Properties>
                  <string name="Name">scoped</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Creates cleanup tables with access to constructors as methods.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local ExternalDebug = require(Package.ExternalDebug)
local merge = require(Package.Utility.merge)
local scopePool = require(Package.Memory.scopePool)

local function scoped(
	...: {[unknown]: unknown}
): any
	local scope = setmetatable(
		scopePool.reuseAny() :: any or {},
		{__index = merge(false, {}, ...)}
	) :: any
	ExternalDebug.trackScope(scope)
	return scope
end

return (scoped :: any) :: Types.ScopedConstructor</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="218">
                <Properties>
                  <string name="Name">whichLivesLonger</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Calculates how the lifetimes of the two values relate. Specifically, it
	calculates which value will be destroyed earlier or later, if it is possible
	to infer this from their scopes.
]]
local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)

-- BloxUI patch (see PATCHES.md): the lifetime analysis only produces
-- warnings, but it walks the whole scope tree for every dependency, which was
-- over half of all screen build time. It's off by default. To hunt scope bugs,
-- set the boolean attribute "CheckLifetimes" = true on the Fusion ModuleScript
-- before anything requires Fusion (the test suites' mock engine does this).
local CHECK_LIFETIMES = false
do
	local ok, attribute = pcall(function()
		return (Package :: any):GetAttribute("CheckLifetimes")
	end)
	if ok and attribute == true then
		CHECK_LIFETIMES = true
	end
end

local function whichScopeLivesLonger(
	scopeA: Types.Scope&lt;unknown>,
	scopeB: Types.Scope&lt;unknown>
): "definitely-a" | "definitely-b" | "unsure"
	-- If we can prove one scope is inside of the other scope, then the outer
	-- scope must live longer than the inner scope (assuming idiomatic scopes).
	-- So, we will search the scopes recursively until we find one of them, at
	-- which point we know they must have been found inside the other scope.
	local openSet: {Types.Scope&lt;unknown>} = {scopeA, scopeB}
	local nextOpenSet: {Types.Scope&lt;unknown>} = {}
	local openSetSize, nextOpenSetSize = 2, 0
	local closedSet = {}
	while openSetSize > 0 do
		for _, scope in openSet do
			closedSet[scope] = true
			for _, inScope in ipairs(scope) do
				if inScope == scopeA then
					return "definitely-b"
				elseif inScope == scopeB then
					return "definitely-a"
				elseif typeof(inScope) == "table" then
					local inScope = inScope :: {unknown}
					if inScope[1] ~= nil and closedSet[scope] == nil then
						nextOpenSetSize += 1
						nextOpenSet[nextOpenSetSize] = inScope :: Types.Scope&lt;unknown>
					end
				end 
			end
		end
		table.clear(openSet)
		openSet, nextOpenSet = nextOpenSet, openSet
		openSetSize, nextOpenSetSize = nextOpenSetSize, 0
	end
	return "unsure"
end

local function whichLivesLonger(
	scopeA: Types.Scope&lt;unknown>,
	a: unknown,
	scopeB: Types.Scope&lt;unknown>,
	b: unknown
): "definitely-a" | "definitely-b" | "unsure"
	if not CHECK_LIFETIMES or External.isTimeCritical() then
		return "unsure"
	elseif scopeA == scopeB then
		local scopeA: {unknown} = scopeA
		for index = #scopeA, 1, -1 do
			local value = scopeA[index]
			if value == a then
				return "definitely-b"
			elseif value == b then
				return "definitely-a"
			end
		end
		return "unsure"
	else
		return whichScopeLivesLonger(scopeA, scopeB)
	end
end

return whichLivesLonger</string>
                </Properties>
              </Item>
            </Item>
            <Item class="ModuleScript" referent="219">
              <Properties>
                <string name="Name">RobloxExternal</string>
                <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow

--[[
	Roblox implementation for Fusion's abstract provider layer.
]]

local RunService = game:GetService("RunService")
local HttpService = game:GetService("HttpService")

local Package = script.Parent
local External = require(Package.External)

local RobloxExternal = {}

RobloxExternal.policies = {
	allowWebLinks = RunService:IsStudio()
}

--[[
   Sends an immediate task to the external provider. Throws if none is set.
]]
function RobloxExternal.doTaskImmediate(
	resume: () -> ()
)
   task.spawn(resume)
end

--[[
	Sends a deferred task to the external provider. Throws if none is set.
]]
function RobloxExternal.doTaskDeferred(
	resume: () -> ()
)
	task.defer(resume)
end

--[[
	Errors in a different thread to preserve the flow of execution.
]]
function RobloxExternal.logErrorNonFatal(
	errorString: string
)
	task.spawn(error, errorString, 0)
end

--[[
	Shows a warning message in the output.
]]
RobloxExternal.logWarn = warn

--[[
	Sends an update step to Fusion using the Roblox clock time.
]]
local function performUpdateStep()
	External.performUpdateStep(os.clock())
end

--[[
	Binds Fusion's update step to RunService step events.
]]
local stopSchedulerFunc = nil :: (() -> ())?
function RobloxExternal.startScheduler()
	if stopSchedulerFunc ~= nil then
		return
	end
	if RunService:IsClient() then
		-- In cases where multiple Fusion modules are running simultaneously,
		-- this prevents collisions.
		local id = "FusionUpdateStep_" .. HttpService:GenerateGUID()
		RunService:BindToRenderStep(
			id,
			Enum.RenderPriority.First.Value,
			performUpdateStep
		)
		stopSchedulerFunc = function()
			RunService:UnbindFromRenderStep(id)
		end
	else
		local connection = RunService.Heartbeat:Connect(performUpdateStep)
		stopSchedulerFunc = function()
			connection:Disconnect()
		end
	end
end

--[[
	Unbinds Fusion's update step from RunService step events.
]]
function RobloxExternal.stopScheduler()
	if stopSchedulerFunc ~= nil then
		stopSchedulerFunc()
		stopSchedulerFunc = nil
	end
end

return RobloxExternal</string>
              </Properties>
            </Item>
            <Item class="Folder" referent="220">
              <Properties>
                <string name="Name">State</string>
              </Properties>
              <Item class="ModuleScript" referent="221">
                <Properties>
                  <string name="Name">Computed</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A specialised state object for tracking single values computed from a
	user-defined computation.

	https://elttob.uk/Fusion/0.3/api-reference/state/types/computed/
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- Logging
local parseError = require(Package.Logging.parseError)
-- Utility
local isSimilar = require(Package.Utility.isSimilar)
local never = require(Package.Utility.never)
-- Graph
local depend = require(Package.Graph.depend)
-- State
local castToState = require(Package.State.castToState)
local peek = require(Package.State.peek)
-- Memory
local doCleanup = require(Package.Memory.doCleanup)
local deriveScope = require(Package.Memory.deriveScope)
local checkLifetime = require(Package.Memory.checkLifetime)
local scopePool = require(Package.Memory.scopePool)
-- Utility
local nicknames = require(Package.Utility.nicknames)

type Self&lt;T, S> = Types.Computed&lt;T> &amp; {
	_innerScope: Types.Scope&lt;S>?,
	_processor: (Types.Use, Types.Scope&lt;S>) -> T
}

local class = {}
class.type = "State"
class.kind = "Computed"
class.timeliness = "lazy"

local METATABLE = table.freeze {__index = class}

local function Computed&lt;T, S>(
	scope: S &amp; Types.Scope&lt;unknown>,
	processor: (Types.Use, S) -> T,
	destructor: unknown?
): Types.Computed&lt;T>
	local createdAt = os.clock()
	if typeof(scope) == "function" then
		External.logError("scopeMissing", nil, "Computeds", "myScope:Computed(function(use, scope) ... end)")
	elseif destructor ~= nil then
		External.logWarn("destructorRedundant", "Computed")
	end
	local self: Self&lt;T, S> = setmetatable(
		{
			createdAt = createdAt,
			dependencySet = {},
			dependentSet = {},
			lastChange = nil,
			scope = scope,
			validity = "invalid",
			_EXTREMELY_DANGEROUS_usedAsValue = nil,
			_innerScope = nil,
			_processor = processor
		}, 
		METATABLE
	) :: any
	local destroy = function()
		self.scope = nil
		for dependency in pairs(self.dependencySet) do
			dependency.dependentSet[self] = nil
		end
		if self._innerScope ~= nil then
			doCleanup(self._innerScope)
		end
	end
	self.oldestTask = destroy
	nicknames[self.oldestTask] = "Computed"
	table.insert(scope, destroy)
	return self
end

function class.get&lt;T, S>(
	_self: Self&lt;T, S>
): never
	External.logError("stateGetWasRemoved")
	return never()
end

function class._evaluate&lt;T, S>(
	self: Self&lt;T, S>
): boolean
	if self.scope == nil then
		return false
	end
	local outerScope = self.scope :: S &amp; Types.Scope&lt;unknown>
	local innerScope = deriveScope(outerScope)
	local function use&lt;T>(target: Types.UsedAs&lt;T>): T
		local targetState = castToState(target)
		if targetState ~= nil then
			checkLifetime.bOutlivesA(
				outerScope, self.oldestTask, 
				targetState.scope, targetState.oldestTask, 
				checkLifetime.formatters.useFunction
			)
			depend(self, targetState)
		end
		return peek(target)
	end
	local ok, newValue = xpcall(self._processor, parseError, use, innerScope)
	local innerScope = scopePool.giveIfEmpty(innerScope)
	if ok then
		local similar = isSimilar(self._EXTREMELY_DANGEROUS_usedAsValue, newValue)
		if self._innerScope ~= nil then
			doCleanup(self._innerScope)
		end
		self._innerScope = innerScope

		self._EXTREMELY_DANGEROUS_usedAsValue = newValue
		return not similar
	else
		local errorObj = (newValue :: any) :: Types.Error
		if innerScope ~= nil then
			doCleanup(innerScope)
		end
		innerScope = nil
		
		-- this needs to be non-fatal, because otherwise it'd disrupt the
		-- update process
		External.logErrorNonFatal("callbackError", errorObj)
		return false
	end
end

table.freeze(class)
return Computed :: Types.ComputedConstructor</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="222">
                <Properties>
                  <string name="Name">For</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	The generic implementation for all `For` objects.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- Graph
local depend = require(Package.Graph.depend)
-- State
local peek = require(Package.State.peek)
local castToState = require(Package.State.castToState)
local ForTypes = require(Package.State.For.ForTypes)
-- Utility
local never = require(Package.Utility.never)
local nicknames = require(Package.Utility.nicknames)

local Disassembly = require(Package.State.For.Disassembly)

type Self&lt;S, KI, KO, VI, VO> = Types.For&lt;KO, VO> &amp; {
	_disassembly: ForTypes.Disassembly&lt;S, KI, KO, VI, VO>
}

local class = {}
class.type = "State"
class.kind = "For"
class.timeliness = "lazy"

local METATABLE = table.freeze {__index = class}

local function For&lt;S, KI, KO, VI, VO>(
	scope: Types.Scope&lt;S>,
	inputTable: Types.UsedAs&lt;{[KI]: VI}>,
	constructor: (
		Types.Scope&lt;S>,
		initialKey: KI,
		initialValue: VI
	) -> ForTypes.SubObject&lt;S, KI, KO, VI, VO>
): Types.For&lt;KO, VO>
	local createdAt = os.clock()
	local self: Self&lt;S, KI, KO, VI, VO> = setmetatable(
		{
			createdAt = createdAt,
			dependencySet = {},
			dependentSet = {},
			scope = scope,
			validity = "invalid",
			_EXTREMELY_DANGEROUS_usedAsValue = {},
			_disassembly = Disassembly(
				scope,
				inputTable,
				constructor
			)
		}, 
		METATABLE
	) :: any

	local destroy = function()
		self.scope = nil
		for dependency in pairs(self.dependencySet) do
			dependency.dependentSet[self] = nil
		end
	end
	self.oldestTask = destroy
	nicknames[self.oldestTask] = "For"
	table.insert(scope, destroy)

	return self
end

function class.get&lt;S, KI, KO, VI, VO>(
	_self: Self&lt;S, KI, KO, VI, VO>
): never
	External.logError("stateGetWasRemoved")
	return never()
end

function class._evaluate&lt;S, KI, KO, VI, VO>(
	self: Self&lt;S, KI, KO, VI, VO>
): boolean
	if self.scope == nil then
		return false
	end
	local outerScope = self.scope :: S &amp; Types.Scope&lt;unknown>

	depend(self, self._disassembly)
	table.clear(self._EXTREMELY_DANGEROUS_usedAsValue)
	self._disassembly:populate(
		function&lt;T>(
			maybeState: Types.UsedAs&lt;T>
		): T
			local state = castToState(maybeState)
			if state ~= nil then
				depend(self, state)
			end
			return peek(maybeState)
		end,
		self._EXTREMELY_DANGEROUS_usedAsValue
	)

	return true
end

table.freeze(class)
return For</string>
                </Properties>
                <Item class="ModuleScript" referent="223">
                  <Properties>
                    <string name="Name">Disassembly</string>
                    <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Breaks down an input table into reactive sub-objects for each pair.
]]

local Package = script.Parent.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- Graph
local depend = require(Package.Graph.depend)
-- State
local peek = require(Package.State.peek)
local castToState = require(Package.State.castToState)
local ForTypes = require(Package.State.For.ForTypes)
-- Memory
local doCleanup = require(Package.Memory.doCleanup)
local deriveScope = require(Package.Memory.deriveScope)
local scopePool = require(Package.Memory.scopePool)
-- Utility
local nameOf = require(Package.Utility.nameOf)
local nicknames = require(Package.Utility.nicknames)

type Self&lt;S, KI, KO, VI, VO> = ForTypes.Disassembly&lt;S, KI, KO, VI, KO> &amp; {
	scope: (S &amp; Types.Scope&lt;unknown>)?,
	_inputTable: Types.UsedAs&lt;{[KI]: VI}>,
	_constructor: (
		Types.Scope&lt;S>,
		initialKey: KI,
		initialValue: VI
	) -> ForTypes.SubObject&lt;S, KI, KO, VI, VO>,
	_subObjects: {[ForTypes.SubObject&lt;S, KI, KO, VI, VO>]: true}
}


local class = {}
class.type = "Graph"
class.kind = "For.Disassembly"
class.timeliness = "lazy"

local METATABLE = table.freeze {__index = class}

local function Disassembly&lt;S, KI, KO, VI, VO>(
	scope: S &amp; Types.Scope&lt;unknown>,
	inputTable: Types.UsedAs&lt;{[KI]: VI}>,
	constructor: (
		Types.Scope&lt;S>,
		initialKey: KI,
		initialValue: VI
	) -> ForTypes.SubObject&lt;S, KI, KO, VI, VO>
): ForTypes.Disassembly&lt;S, KI, KO, VI, KO>
	local createdAt = os.clock()
	local self = setmetatable(
		{
			createdAt = createdAt,
			dependencySet = {},
			dependentSet = {},
			scope = scope,
			validity = "invalid",
			_inputTable = inputTable,
			_constructor = constructor,
			_subObjects = {}
		}, 
		METATABLE
	) :: any

	local destroy = function()
		self.scope = nil
		for dependency in pairs(self.dependencySet) do
			dependency.dependentSet[self] = nil
		end
		for subObject in self._subObjects do
			if subObject.maybeScope ~= nil then
				doCleanup(subObject.maybeScope)
				subObject.maybeScope = nil
			end
		end
	end
	self.oldestTask = destroy
	nicknames[self.oldestTask] = "For (internal disassembler)"
	table.insert(scope, destroy)
	
	return self
end

function class.populate&lt;S, KI, KO, VI, VO>(
	self: Self&lt;S, KI, KO, VI, VO>,
	use: Types.Use,
	output: {[KO]: VO}
): ()
	local minArrayIndex = math.huge
	local maxArrayIndex = -math.huge
	local hasHoles = false
	for subObject in self._subObjects do
		local outputKey, outputValue = subObject:useOutputPair(use)
		if outputKey == nil or outputValue == nil then
			hasHoles = true
			continue
		elseif output[outputKey] ~= nil then
			External.logErrorNonFatal("forKeyCollision", nil, tostring(outputKey))
			continue
		end
		output[outputKey] = outputValue
		if typeof(outputKey) == "number" then
			minArrayIndex = math.min(minArrayIndex, outputKey)
			maxArrayIndex = math.max(maxArrayIndex, outputKey)
		end
	end
	-- Be careful of NaN here
	if hasHoles and maxArrayIndex > minArrayIndex then
		local output: {[number]: VO} = output :: any
		local moveToIndex = minArrayIndex
		for moveFromIndex = minArrayIndex, maxArrayIndex do
			local outputValue = output[moveFromIndex]
			if outputValue == nil then
				continue
			end
			-- The ordering is important in case the indices are the same
			output[moveFromIndex] = nil
			output[moveToIndex] = outputValue
			moveToIndex += 1
		end
	end
end

function class._evaluate&lt;S, KI, KO, VI, VO>(
	self: Self&lt;S, KI, KO, VI, VO>
): boolean
	local outerScope = self.scope :: S &amp; Types.Scope&lt;unknown>
	local inputState = castToState(self._inputTable)
	if inputState ~= nil then
		if inputState.scope == nil then
			External.logError(
				"useAfterDestroy",
				nil,
				`The input {nameOf(inputState, "table")}`,
				`the For object that is watching it`
			)
		end
		depend(self, inputState)
	end

	local pendingPairs = {} :: {[KI]: VI}
	for key, value in peek(self._inputTable) do
		pendingPairs[key] = value
	end

	local newSubObjects = {} :: typeof(self._subObjects)

	for subObject in self._subObjects do
		local reused = false
		local oldInputKey = subObject.inputKey
		local oldInputValue = subObject.inputValue
		local newInputKey: KI
		-- Reuse when the keys are identical.
		if not subObject.roamKeys and pendingPairs[oldInputKey] ~= nil then
			reused = true
			newInputKey = oldInputKey
		else -- Try and reuse some other pair instead.
			for pendingKey, pendingValue in pendingPairs do
				reused = true
				newInputKey = pendingKey
				if subObject.roamValues then
					break
				end
				if pendingValue == oldInputValue then
					-- If the values are the same, then no need to update those,
					-- so prefer this choice to any other.
					break 
				end
			end
		end
		if reused then
			local newInputValue = pendingPairs[newInputKey]
			newSubObjects[subObject] = true
			if newInputKey ~= oldInputKey then
				subObject.inputKey = newInputKey
				subObject:invalidateInputKey()
			end
			if newInputValue ~= oldInputValue then
				subObject.inputValue = newInputValue
				subObject:invalidateInputValue()
			end
			pendingPairs[newInputKey] = nil
		else -- Too many sub objects for the number of pairs.
			if subObject.maybeScope ~= nil then
				doCleanup(subObject.maybeScope)
				subObject.maybeScope = nil
			end
		end
	end

	-- Generate new objects if needed to cover the remaining pending pairs.
	for pendingKey, pendingValue in pendingPairs do
		local subObject = self._constructor(deriveScope(outerScope), pendingKey, pendingValue)
		if subObject.maybeScope ~= nil then
			subObject.maybeScope = scopePool.giveIfEmpty(subObject.maybeScope)
		end
		newSubObjects[subObject] = true
	end

	self._subObjects = newSubObjects

	return true
end

table.freeze(class)
return Disassembly</string>
                  </Properties>
                </Item>
                <Item class="ModuleScript" referent="224">
                  <Properties>
                    <string name="Name">ForTypes</string>
                    <string name="Source"><![CDATA[--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Stores types that are commonly used between For objects.
]]

local Package = script.Parent.Parent.Parent
local Types = require(Package.Types)

export type SubObject<S, KI, KO, VI, VO> = {
	-- Not all sub objects need to store a scope, for example if the scope
	-- remains empty, it'll be given back to the scope pool.
	maybeScope: Types.Scope<S>?,
	inputKey: KI,
	inputValue: VI,
	roamKeys: boolean,
	roamValues: boolean,
	invalidateInputKey: (SubObject<S, KI, KO, VI, VO>) -> (),
	invalidateInputValue: (SubObject<S, KI, KO, VI, VO>) -> (),
	useOutputPair: (SubObject<S, KI, KO, VI, VO>, Types.Use) -> (KO?, VO?)
}

export type Disassembly<S, KI, KO, VI, VO> = Types.GraphObject & {
	populate: (Disassembly<S, KI, KO, VI, VO>, Types.Use, output: {[KO]: VO}) -> ()
}

return nil
]]></string>
                  </Properties>
                </Item>
              </Item>
              <Item class="ModuleScript" referent="225">
                <Properties>
                  <string name="Name">ForKeys</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Constructs and returns a new For state object which processes keys and
	preserves values.

	https://elttob.uk/Fusion/0.3/api-reference/state/members/forkeys/

	TODO: the sub objects constructed here can be more efficiently implemented
	as a dedicated state object.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- Memory
local doCleanup = require(Package.Memory.doCleanup)
-- State
local For = require(Package.State.For)
local Value = require(Package.State.Value)
local Computed = require(Package.State.Computed)
local ForTypes = require(Package.State.For.ForTypes)
-- Logging
local parseError = require(Package.Logging.parseError)

local SUB_OBJECT_META = {
	__index = {
		roamKeys = false,
		roamValues = true,
		invalidateInputKey = function(self): ()
			self._inputKeyState:set(self.inputKey)
		end,
		invalidateInputValue = function(self): ()
			-- do nothing
		end,
		useOutputPair = function(self, use)
			return use(self._outputKeyState), self.inputValue
		end
	}
}

local function SubObject&lt;KI, KO, V, S>(
	scope: Types.Scope&lt;S>,
	initialKey: KI,
	initialValue: V,
	processor: (Types.Use, Types.Scope&lt;S>, KI) -> KO
): ForTypes.SubObject&lt;S, KI, KO, V, V>
	local self = {}
	self.maybeScope = scope
	self.inputKey = initialKey
	self.inputValue = initialValue
	self._inputKeyState = Value(scope, initialKey)
	self._processor = processor
	self._outputKeyState = Computed(scope, function(use, scope): KO?
		local inputKey = use(self._inputKeyState)
		local ok, outputKey = xpcall(self._processor, parseError, use, scope, inputKey)
		if ok then
			return outputKey
		else
			local error: Types.Error = outputKey :: any
			error.context = `while processing key {tostring(inputKey)}`
			External.logErrorNonFatal("callbackError", error)
			doCleanup(scope)
			table.clear(scope)
			return nil
		end
	end)
	return setmetatable(self, SUB_OBJECT_META) :: any
end

local function ForKeys&lt;KI, KO, V, S>(
	scope: Types.Scope&lt;S>,
	inputTable: Types.UsedAs&lt;{[KI]: V}>,
	processor: (Types.Use, Types.Scope&lt;S>, KI) -> KO,
	destructor: unknown?
): Types.For&lt;KO, V>
	if typeof(inputTable) == "function" then
		External.logError("scopeMissing", nil, "ForKeys", "myScope:ForKeys(inputTable, function(scope, use, key) ... end)")
	elseif destructor ~= nil then
		External.logWarn("destructorRedundant", "ForKeys")
	end
	return For(
		scope, 
		inputTable, 
		function(scope, initialKey, initialValue)
			return SubObject(scope, initialKey, initialValue, processor)
		end
	)
end

return ForKeys :: Types.ForKeysConstructor</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="226">
                <Properties>
                  <string name="Name">ForPairs</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Constructs and returns a new For state object which processes keys and
	values in pairs.

	https://elttob.uk/Fusion/0.3/api-reference/state/members/forpairs/

	TODO: the sub objects constructed here can be more efficiently implemented
	as a dedicated state object.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- State
local For = require(Package.State.For)
local Value = require(Package.State.Value)
local Computed = require(Package.State.Computed)
local ForTypes = require(Package.State.For.ForTypes)
-- Logging
local parseError = require(Package.Logging.parseError)
-- Memory
local doCleanup = require(Package.Memory.doCleanup)

local SUB_OBJECT_META = {
	__index = {
		roamKeys = false,
		roamValues = false,
		invalidateInputKey = function(self): ()
			self._inputKeyState:set(self.inputKey)
		end,
		invalidateInputValue = function(self): ()
			self._inputValueState:set(self.inputValue)
		end,
		useOutputPair = function(self, use)
			local pair = use(self._outputPairState)
			return pair.key, pair.value
		end
	}
}

local function SubObject&lt;KI, KO, VI, VO, S>(
	scope: Types.Scope&lt;S>,
	initialKey: KI,
	initialValue: VI,
	processor: (Types.Use, Types.Scope&lt;S>, KI, VI) -> (KO, VO)
): ForTypes.SubObject&lt;S, KI, KO, VI, VO>
	local self = {}
	self.maybeScope = scope
	self.inputKey = initialKey
	self.inputValue = initialValue
	self._inputKeyState = Value(scope, initialKey)
	self._inputValueState = Value(scope, initialValue)
	self._processor = processor
	self._outputPairState = Computed(scope, function(use, scope): {key: KO?, value: VO?}
		local inputKey = use(self._inputKeyState)
		local inputValue = use(self._inputValueState)
		local ok, outputKey, outputValue = xpcall(self._processor, parseError, use, scope, inputKey, inputValue)
		if ok then
			return {key = outputKey, value = outputValue}
		else
			local error: Types.Error = outputKey :: any
			error.context = `while processing key {tostring(inputValue)} and value {tostring(inputValue)}`
			External.logErrorNonFatal("callbackError", error)
			doCleanup(scope)
			table.clear(scope)
			return {key = nil, value = nil}
		end
	end)
	return setmetatable(self, SUB_OBJECT_META) :: any
end

local function ForPairs&lt;KI, KO, VI, VO, S>(
	scope: Types.Scope&lt;S>,
	inputTable: Types.UsedAs&lt;{[KI]: VI}>,
	processor: (Types.Use, Types.Scope&lt;S>, KI, VI) -> (KO, VO),
	destructor: unknown?
): Types.For&lt;KO, VO>
	if typeof(inputTable) == "function" then
		External.logError("scopeMissing", nil, "ForPairs", "myScope:ForPairs(inputTable, function(scope, use, key, value) ... end)")
	elseif destructor ~= nil then
		External.logWarn("destructorRedundant", "ForPairs")
	end
	return For(
		scope,
		inputTable,
		function(scope, initialKey, initialValue)
			return SubObject(scope, initialKey, initialValue, processor)
		end
	)
end

return ForPairs :: Types.ForPairsConstructor</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="227">
                <Properties>
                  <string name="Name">ForValues</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Constructs and returns a new For state object which processes values and
	preserves keys.

	https://elttob.uk/Fusion/0.3/api-reference/state/members/forvalues/

	TODO: the sub objects constructed here can be more efficiently implemented
	as a dedicated state object.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- State
local For = require(Package.State.For)
local Value = require(Package.State.Value)
local Computed = require(Package.State.Computed)
local ForTypes = require(Package.State.For.ForTypes)
-- Logging
local parseError = require(Package.Logging.parseError)
-- Memory
local doCleanup = require(Package.Memory.doCleanup)

local SUB_OBJECT_META = {
	__index = {
		roamKeys = true,
		roamValues = false,
		invalidateInputKey = function(self): ()
			-- do nothing
		end,
		invalidateInputValue = function(self): ()
			self._inputValueState:set(self.inputValue)
		end,
		useOutputPair = function(self, use)
			return self.inputKey, use(self._outputValueState)
		end
	}
}

local function SubObject&lt;K, VI, VO, S>(
	scope: Types.Scope&lt;S>,
	initialKey: K,
	initialValue: VI,
	processor: (Types.Use, Types.Scope&lt;S>, VI) -> VO
): ForTypes.SubObject&lt;S, K, K, VI, VO>
	local self = {}
	self.maybeScope = scope
	self.inputKey = initialKey
	self.inputValue = initialValue
	self._inputValueState = Value(scope, initialValue)
	self._processor = processor
	self._outputValueState = Computed(scope, function(use, scope): VO?
		local inputValue = use(self._inputValueState)
		local ok, outputValue = xpcall(self._processor, parseError, use, scope, inputValue)
		if ok then
			return outputValue
		else
			local error: Types.Error = outputValue :: any
			error.context = `while processing value {tostring(inputValue)}`
			External.logErrorNonFatal("callbackError", error)
			doCleanup(scope)
			table.clear(scope)
			return nil
		end
	end)
	return setmetatable(self, SUB_OBJECT_META) :: any
end

local function ForValues&lt;K, VI, VO, S>(
	scope: Types.Scope&lt;S>,
	inputTable: Types.UsedAs&lt;{[K]: VI}>,
	processor: (Types.Use, Types.Scope&lt;S>, VI) -> VO,
	destructor: unknown?
): Types.For&lt;K, VO>
	if typeof(inputTable) == "function" then
		External.logError("scopeMissing", nil, "ForValues", "myScope:ForValues(inputTable, function(scope, use, value) ... end)")
	elseif destructor ~= nil then
		External.logWarn("destructorRedundant", "ForValues")
	end
	return For(
		scope,
		inputTable,
		function(scope, initialKey, initialValue)
			return SubObject(scope, initialKey, initialValue, processor)
		end
	)
end

return ForValues :: Types.ForValuesConstructor</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="228">
                <Properties>
                  <string name="Name">Value</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A state object which allows regular Luau code to control its value.

	https://elttob.uk/Fusion/0.3/api-reference/state/types/value/
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- Graph
local change = require(Package.Graph.change)
-- Utility
local isSimilar = require(Package.Utility.isSimilar)
local never = require(Package.Utility.never)
local nicknames = require(Package.Utility.nicknames)

type Self&lt;T, S> = Types.Value&lt;T, S>

local class = {}
class.type = "State"
class.kind = "Value"
class.timeliness = "lazy"
class.dependencySet = table.freeze {}

local METATABLE = table.freeze {__index = class}

local function Value&lt;T>(
	scope: Types.Scope&lt;unknown>,
	initialValue: T
): Types.Value&lt;T, any>
	local createdAt = os.clock()
	if initialValue == nil and (typeof(scope) ~= "table" or (scope[1] == nil and next(scope) ~= nil)) then
		External.logError("scopeMissing", nil, "Value", "myScope:Value(initialValue)")
	end
	local self: Self&lt;T, any> = setmetatable(
		{
			createdAt = createdAt,
			dependentSet = {},
			lastChange = os.clock(),
			scope = scope,
			validity = "valid",
			_EXTREMELY_DANGEROUS_usedAsValue = initialValue
		}, 
		METATABLE
	) :: any
	local destroy = function()
		self.scope = nil
	end
	self.oldestTask = destroy
	nicknames[self.oldestTask] = "Value"
	table.insert(scope, destroy)
	return self
end

function class:get&lt;T, S>(
	_self: Self&lt;T, S>
): never
	External.logError("stateGetWasRemoved")
	return never()
end

function class.set&lt;T, S>(
	self: Self&lt;T, S>,
	newValue: S
): S
	local oldValue = self._EXTREMELY_DANGEROUS_usedAsValue
	if not isSimilar(oldValue, newValue) then
		self._EXTREMELY_DANGEROUS_usedAsValue = newValue :: any
		change(self)
	end
	return newValue
end

function class._evaluate&lt;T, S>(
	_self: Self&lt;T, S>
): boolean
	-- The similarity test is done in advance when the value is set, so this
	-- should be fine.
	return true
end

table.freeze(class)
return Value :: Types.ValueConstructor</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="229">
                <Properties>
                  <string name="Name">castToState</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Returns the input *only* if it is a state object.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)

local function castToState&lt;T>(
	target: Types.UsedAs&lt;T>
): Types.StateObject&lt;T>?
	if
		typeof(target) == "table" and
		target.type == "State" 
	then
		return target
	else
		return nil
	end
end

return castToState</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="230">
                <Properties>
                  <string name="Name">peek</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Extracts a value of type T from its input.

	https://elttob.uk/Fusion/0.3/api-reference/state/members/peek/
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
-- State
local castToState = require(Package.State.castToState)
-- Graph
local evaluate = require(Package.Graph.evaluate)

local function peek&lt;T>(
	target: Types.UsedAs&lt;T>
): T
	local targetState = castToState(target)
	if targetState ~= nil then
		evaluate(targetState, false)
		return targetState._EXTREMELY_DANGEROUS_usedAsValue :: T
	else
		return target :: T
	end
end

return peek</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="231">
                <Properties>
                  <string name="Name">updateAll</string>
                  <string name="Source">return nil -- dummy file so I can write tests</string>
                </Properties>
              </Item>
            </Item>
            <Item class="ModuleScript" referent="232">
              <Properties>
                <string name="Name">Types</string>
                <string name="Source"><![CDATA[--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Stores common public-facing type information for Fusion APIs.
]]

export type Error = {
	type: "Error",
	raw: string,
	message: string,
	trace: string,
	context: string?
}

-- Types that can be expressed as vectors of numbers, and so can be animated.
export type Animatable =
	number |
	CFrame |
	Color3 |
	ColorSequenceKeypoint |
	DateTime |
	NumberRange |
	NumberSequenceKeypoint |
	PhysicalProperties |
	Ray |
	Rect |
	Region3 |
	Region3int16 |
	UDim |
	UDim2 |
	Vector2 |
	Vector2int16 |
	Vector3 |
	Vector3int16

-- A task which can be accepted for cleanup.
export type Task =
	Instance |
	RBXScriptConnection |
	() -> () |
	{destroy: (unknown) -> ()} |
	{Destroy: (unknown) -> ()} |
	{Task}

-- A scope of tasks to clean up.
export type Scope<Constructors = any> = {Task} & Constructors

-- An object which uses a scope to dictate how long it lives.
export type ScopedObject = {
	scope: Scope<unknown>?,
	oldestTask: unknown
}

-- Script-readable version information.
export type Version = {
	major: number,
	minor: number,
	isRelease: boolean
}

-- An object which stores a value scoped in time.
export type Contextual<T> = {
	type: "Contextual",
	now: (Contextual<T>) -> T,
	is: (Contextual<T>, T) -> ContextualIsMethods
}

type ContextualIsMethods = {
	during: <R, A...>(ContextualIsMethods, (A...) -> R, A...) -> R
}

-- A graph object which can have dependencies and dependencies.
export type GraphObject = ScopedObject & {
	createdAt: number,
	dependencySet: {[GraphObject]: unknown},
	dependentSet: {[GraphObject]: unknown},
	lastChange: number?,
	timeliness: "lazy" | "eager",
	validity: "valid" | "invalid" | "busy",
	_evaluate: (GraphObject) -> boolean
}

-- An object which stores a piece of reactive state.
export type StateObject<T> = GraphObject & {
	type: "State",
	kind: string,
	_EXTREMELY_DANGEROUS_usedAsValue: T
}

-- Passing values of this type to `Use` returns `T`.
export type UsedAs<T> = StateObject<T> | T

-- Function signature for use callbacks.
export type Use = <T>(target: UsedAs<T>) -> T

-- A state object whose value can be set at any time by the user.
export type Value<T, S = T> = StateObject<T> & {
	kind: "State",
	timeliness: "lazy",
 	set: (Value<T, S>, newValue: S, force: boolean?) -> S,
	____phantom_setType: (never) -> S -- phantom data so this contains S
}
export type ValueConstructor = <T>(
	scope: Scope<unknown>,
	initialValue: T
) -> Value<T, any>

-- A state object whose value is derived from other objects using a callback.
export type Computed<T> = StateObject<T> & {
	kind: "Computed",
	timeliness: "lazy"
}
export type ComputedConstructor = <T, S>(
	scope: S & Scope<unknown>,
	callback: (Use, S) -> T
) -> Computed<T>

-- A state object which maps over keys and/or values in another table.
export type For<KO, VO> = StateObject<{[KO]: VO}>  & {
	kind: "For"
}
export type ForPairsConstructor =  <KI, KO, VI, VO, S>(
	scope: S & Scope<unknown>,
	inputTable: UsedAs<{[KI]: VI}>,
	processor: (Use, S, key: KI, value: VI) -> (KO, VO)
) -> For<KO, VO>
export type ForKeysConstructor =  <KI, KO, V, S>(
	scope: S & Scope<unknown>,
	inputTable: UsedAs<{[KI]: V}>,
	processor: (Use, S, key: KI) -> KO
) -> For<KO, V>
export type ForValuesConstructor =  <K, VI, VO, S>(
	scope: S & Scope<unknown>,
	inputTable: UsedAs<{[K]: VI}>,
	processor: (Use, S, value: VI) -> VO
) -> For<K, VO>

-- An object which can listen for updates on another state object.
export type Observer = GraphObject & {
	type: "Observer",
	timeliness: "eager",
	onChange: (Observer, callback: () -> ()) -> (() -> ()),
	onBind: (Observer, callback: () -> ()) -> (() -> ())
}
export type ObserverConstructor = (
	scope: Scope<unknown>,
	watching: unknown
) -> Observer

-- A state object which follows another state object using tweens.
export type Tween<T> = StateObject<T> & {
	kind: "Tween"
}
export type TweenConstructor = <T>(
	scope: Scope<unknown>,
	goalState: UsedAs<T>,
	tweenInfo: UsedAs<TweenInfo>?
) -> Tween<T>

-- A state object which follows another state object using spring simulation.
export type Spring<T> = StateObject<T>  & {
	kind: "Spring",
	setPosition: (Spring<T>, newPosition: T) -> (),
	setVelocity: (Spring<T>, newVelocity: T) -> (),
	addVelocity: (Spring<T>, deltaVelocity: T) -> ()
}
export type SpringConstructor = <T>(
	scope: Scope<unknown>,
	goalState: UsedAs<T>,
	speed: UsedAs<number>?,
	damping: UsedAs<number>?
) -> Spring<T>

-- Denotes children instances in an instance or component's property table.
export type SpecialKey = {
	type: "SpecialKey",
	kind: string,
	stage: "self" | "descendants" | "ancestor" | "observer",
	apply: (
		self: SpecialKey,
		scope: Scope<unknown>,
		value: unknown,
		applyTo: Instance
	) -> ()
}

-- A collection of instances that may be parented to another instance.
export type Child = Instance | StateObject<Child> | {[unknown]: Child}

-- A table that defines an instance's properties, handlers and children.
export type PropertyTable = {[string | SpecialKey]: unknown}

export type NewConstructor = (
	scope: Scope<unknown>,
	className: string
) -> (propertyTable: PropertyTable) -> Instance

export type HydrateConstructor = (
	scope: Scope<unknown>,
	target: Instance
) -> (propertyTable: PropertyTable) -> Instance

-- Is there a sane way to write out this type?
-- ... I sure hope so.

export type DeriveScopeConstructor = (<S>(Scope<S>) -> Scope<S>)
	& (<S, A>(Scope<S>, A & {}) -> Scope<S & A>)
	& (<S, A, B>(Scope<S>, A & {}, B & {}) -> Scope<S & A & B>)
	& (<S, A, B, C>(Scope<S>, A & {}, B & {}, C & {}) -> Scope<S & A & B & C>)
	& (<S, A, B, C, D>(Scope<S>, A & {}, B & {}, C & {}, D & {}) -> Scope<S & A & B & C & D>)
	& (<S, A, B, C, D, E>(Scope<S>, A & {}, B & {}, C & {}, D & {}, E & {}) -> Scope<S & A & B & C & D & E>)
	& (<S, A, B, C, D, E, F>(Scope<S>, A & {}, B & {}, C & {}, D & {}, E & {}, F & {}) -> Scope<S & A & B & C & D & E & F>)
	& (<S, A, B, C, D, E, F, G>(Scope<S>, A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}) -> Scope<S & A & B & C & D & E & F & G>)
	& (<S, A, B, C, D, E, F, G, H>(Scope<S>, A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}) -> Scope<S & A & B & C & D & E & F & G & H>)
	& (<S, A, B, C, D, E, F, G, H, I>(Scope<S>, A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}, I & {}) -> Scope<S & A & B & C & D & E & F & G & H & I>)
	& (<S, A, B, C, D, E, F, G, H, I, J>(Scope<S>, A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}, I & {}, J & {}) -> Scope<S & A & B & C & D & E & F & G & H & I & J>)
	& (<S, A, B, C, D, E, F, G, H, I, J, K>(Scope<S>, A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}, I & {}, J & {}, K & {}) -> Scope<S & A & B & C & D & E & F & G & H & I & J & K>)
	& (<S, A, B, C, D, E, F, G, H, I, J, K, L>(Scope<S>, A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}, I & {}, J & {}, K & {}, L & {}) -> Scope<S & A & B & C & D & E & F & G & H & I & J & K & L>)

export type ScopedConstructor = (() -> Scope<{}>)
	& (<A>(A & {}) -> Scope<A>)
	& (<A, B>(A & {}, B & {}) -> Scope<A & B>)
	& (<A, B, C>(A & {}, B & {}, C & {}) -> Scope<A & B & C>)
	& (<A, B, C, D>(A & {}, B & {}, C & {}, D & {}) -> Scope<A & B & C & D>)
	& (<A, B, C, D, E>(A & {}, B & {}, C & {}, D & {}, E & {}) -> Scope<A & B & C & D & E>)
	& (<A, B, C, D, E, F>(A & {}, B & {}, C & {}, D & {}, E & {}, F & {}) -> Scope<A & B & C & D & E & F>)
	& (<A, B, C, D, E, F, G>(A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}) -> Scope<A & B & C & D & E & F & G>)
	& (<A, B, C, D, E, F, G, H>(A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}) -> Scope<A & B & C & D & E & F & G & H>)
	& (<A, B, C, D, E, F, G, H, I>(A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}, I & {}) -> Scope<A & B & C & D & E & F & G & H & I>)
	& (<A, B, C, D, E, F, G, H, I, J>(A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}, I & {}, J & {}) -> Scope<A & B & C & D & E & F & G & H & I & J>)
	& (<A, B, C, D, E, F, G, H, I, J, K>(A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}, I & {}, J & {}, K & {}) -> Scope<A & B & C & D & E & F & G & H & I & J & K>)
	& (<A, B, C, D, E, F, G, H, I, J, K, L>(A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}, I & {}, J & {}, K & {}, L & {}) -> Scope<A & B & C & D & E & F & G & H & I & J & K & L>)

export type ContextualConstructor = <T>(defaultValue: T) -> Contextual<T>

export type Safe = <Success, Fail>(
	callbacks: {
		try: () -> Success,
		fallback: (err: unknown) -> Fail
	}
) -> Success | Fail

export type Fusion = {
	version: Version,
	Contextual: ContextualConstructor,
	Safe: Safe,

	doCleanup: (Task) -> (),
	scoped: ScopedConstructor,
	deriveScope: DeriveScopeConstructor,
	innerScope: DeriveScopeConstructor,

	peek: Use,
	Value: ValueConstructor,
	Computed: ComputedConstructor,
	ForPairs: ForPairsConstructor,
	ForKeys: ForKeysConstructor,
	ForValues: ForValuesConstructor,
	Observer: ObserverConstructor,

	Tween: TweenConstructor,
	Spring: SpringConstructor,

	New: NewConstructor,
	Hydrate: HydrateConstructor,

	Child: ({Child}) -> Child,
	Children: SpecialKey,
	Out: (propertyName: string) -> SpecialKey,
	OnEvent: (eventName: string) -> SpecialKey,
	OnChange: (propertyName: string) -> SpecialKey,
	Attribute: (attributeName: string) -> SpecialKey,
	AttributeChange: (attributeName: string) -> SpecialKey,
	AttributeOut: (attributeName: string) -> SpecialKey,
}

export type ExternalProvider = {
	policies: {
		allowWebLinks: boolean
	},

	logErrorNonFatal: (
		errorString: string
	) -> (),
	logWarn: (
		errorString: string
	) -> (),
	
	doTaskImmediate: (
		resume: () -> ()
	) -> (),
	doTaskDeferred: (
		resume: () -> ()
	) -> (),
	startScheduler: () -> (),
	stopScheduler: () -> ()
}

export type ExternalDebugger = {
	startDebugging: () -> (),
	stopDebugging: () -> (),

	trackScope: (
		scope: Scope<unknown>
	) -> (),
	untrackScope: (
		scope: Scope<unknown>
	) -> ()
}

return nil
]]></string>
              </Properties>
            </Item>
            <Item class="Folder" referent="233">
              <Properties>
                <string name="Name">Utility</string>
              </Properties>
              <Item class="ModuleScript" referent="234">
                <Properties>
                  <string name="Name">Contextual</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
    Time-based contextual values, to allow for transparently passing values down
	the call stack.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
-- Logging
local parseError = require(Package.Logging.parseError)

export type Self&lt;T> = Types.Contextual&lt;T> &amp; {
	_valuesNow: {[thread]: {value: T}},
	_defaultValue: T
}

local class = {}
class.type = "Contextual"

local METATABLE = table.freeze {__index = class}
local WEAK_KEYS_METATABLE = table.freeze {__mode = "k"}

local function Contextual&lt;T>(
	defaultValue: T
): Types.Contextual&lt;T>
	local self: Self&lt;T> = setmetatable(
		{
			-- if we held strong references to threads here, then if a thread was
			-- killed before this contextual had a chance to finish executing its
			-- callback, it would be held strongly in this table forever
			_valuesNow = setmetatable({}, WEAK_KEYS_METATABLE),
			_defaultValue = defaultValue
		}, 
		METATABLE
	) :: any

	return self
end

--[[
	Returns the current value of this contextual.
]]
function class.now&lt;T>(
	self: Self&lt;T>
): T
	local thread = coroutine.running()
	local value = self._valuesNow[thread]
	if typeof(value) ~= "table" then
		return self._defaultValue
	else
		return value.value
	end
end

--[[
	Temporarily assigns a value to this contextual.
]]
function class.is&lt;T>(
	self: Self&lt;T>,
	newValue: T
)
	local methods = {}
	
	function methods.during&lt;T, A...>(
		_: any, -- during is called with colon syntax but we don't care
		callback: (A...) -> T,
		...: A...
	): T
		local thread = coroutine.running()
		local prevValue = self._valuesNow[thread]
		-- Storing the value in this format allows us to distinguish storing
		-- `nil` from not calling `:during()` at all.
		self._valuesNow[thread] = { value = newValue }
		local ok, value = xpcall(callback, parseError, ...)
		self._valuesNow[thread] = prevValue
		if not ok then
			External.logError("callbackError", value :: any)
		end
		return value
	end

	return methods
end

table.freeze(class)
return Contextual</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="235">
                <Properties>
                  <string name="Name">Safe</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
    A variant of xpcall() designed for inline usage, letting you define fallback
	values based on caught errors.
]]

local Package = script.Parent.Parent

local function Safe&lt;Success, Fail>(
	callbacks: {
		try: () -> Success,
		fallback: (err: unknown) -> Fail
	}
): Success | Fail
	local _, value = xpcall(callbacks.try, callbacks.fallback)
	return value
end

return Safe</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="236">
                <Properties>
                  <string name="Name">isSimilar</string>
                  <string name="Source"><![CDATA[--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Implements the 'similarity test' used to determine whether two values have
	a meaningful difference.

	https://elttob.uk/Fusion/0.3/tutorials/best-practices/optimisation/#similarity
]]

local function isSimilar(
	a: unknown, 
	b: unknown
): boolean
	local typeA = typeof(a)
	local isTable = typeA == "table"
	local isUserdata = typeA == "userdata"
	return
		if not (isTable or isUserdata) then
			a == b or a ~= a and b ~= b
		elseif typeA == typeof(b) and (isUserdata or table.isfrozen(a :: any) or getmetatable(a :: any) ~= nil) then
			a == b
		else
			false
end

return isSimilar
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="237">
                <Properties>
                  <string name="Name">merge</string>
                  <string name="Source"><![CDATA[--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Attempts to merge a variadic number of tables together.
]]

local Package = script.Parent.Parent
local External = require(Package.External)

local function merge(
	overwrite: boolean,
	into: {[unknown]: unknown},
	...: {[unknown]: unknown}
): {[unknown]: unknown}
	local fromTables = {...}
	if #fromTables < 1 then
		return into
	else
		for _, fromTable in fromTables do
			for key, value in fromTable do
				if into[key] == nil then
					into[key] = value
				elseif not overwrite then
					External.logError("mergeConflict", nil, tostring(key))
				end
			end
		end
		return into
	end
end

return merge
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="238">
                <Properties>
                  <string name="Name">nameOf</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Returns the most specific custom name for the given object.
]]

local Package = script.Parent.Parent
-- Utility
local nicknames = require(Package.Utility.nicknames)

local function nameOf(
	x: unknown,
	defaultName: string
): string
	local nickname = nicknames[x]
	if typeof(nickname) == "string" then
		return nickname
	end
	if typeof(x) == "table" then
		local x = x :: {[any]: any}
		if typeof(x.name) == "string" then
			return x.name
		elseif typeof(x.kind) == "string" then
			return x.kind
		elseif typeof(x.type) == "string" then
			return x.type
		end
	end
	return defaultName
end

return nameOf</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="239">
                <Properties>
                  <string name="Name">never</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Never returns.
]]

local function never(): never
	error("This codepath should not be reachable")
end

return never</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="240">
                <Properties>
                  <string name="Name">nicknames</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Stores nicknames for values that don't support metatables, so that `nameOf`
	can return values for them.
]]

return setmetatable({}, {__mode = "k"})</string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="241">
                <Properties>
                  <string name="Name">xtypeof</string>
                  <string name="Source">--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Extended typeof, designed for identifying custom objects.
	If given a table with a `type` string, returns that.
	Otherwise, returns `typeof()` the argument.
]]

local function xtypeof(
	x: unknown
): string
	local typeString = typeof(x)

	if typeString == "table" then
		local x = x :: {type: unknown?}
		if typeof(x.type) == "string" then
			return x.type
		end
	end

	return typeString
end

return xtypeof</string>
                </Properties>
              </Item>
            </Item>
          </Item>
        </Item>
        <Item class="ModuleScript" referent="242">
          <Properties>
            <string name="Name">Particles</string>
            <string name="Source"></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="243">
          <Properties>
            <string name="Name">Popups</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Popups: one-line UI you can call from anywhere in a LocalScript.

		UI.notify("Daily reward ready!", { Type = "reward", Icon = "gift" })

		if UI.confirm({ Title = "Buy Mega Bundle?", Message = "40 keys for 160 Robux", Confirm = "Buy" }) then
			-- prompt the purchase
		end

		UI.reward({ Title = "DAILY REWARD", Icon = "key", Amount = "+40", To = keysCounter })   -- waits for "Claim"

		local choice = UI.dialogue({
			{ Speaker = "Old Man", Text = "Don't go into the woods at night..." },
			{ Speaker = "Old Man", Text = "Will you help me?", Choices = { "Sure", "No way" } },
		})

	confirm, reward and dialogue yield until the player answers (Esc / B
	cancels a confirm). Without a LocalPlayer (Studio edit mode, plugin
	previews) they return straight away: confirm -> false, dialogue -> nil.
]]

local GuiService = game:GetService("GuiService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

local Fusion = require(script.Parent.Packages.Fusion)
local Theme = require(script.Parent.Theme)
local Kit = require(script.Parent.Kit)
local Components = require(script.Parent.Components)
local Sound = require(script.Parent.Util.Sound)
local Input = require(script.Parent.Util.Input)
local Gamepad = require(script.Parent.Util.Gamepad)
local Viewport = require(script.Parent.Util.Viewport)
local Motion = require(script.Parent.FX.Motion)
local Particles = require(script.Parent.FX.Particles)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)

local Popups = {}

local function newScope()
	return Fusion.scoped(Fusion, Components)
end

-- Popups need someone to click them.
local function hasPlayer(): boolean
	return Players.LocalPlayer ~= nil
end

-- Waits for `signal:Fire(...)` and returns what was fired.
local function waitFor(event: BindableEvent): ...any
	return event.Event:Wait()
end

-- Puts the gamepad selection on `button` when the player uses a gamepad.
local function focus(button: GuiObject?)
	if button and peek(Input.device) == "Gamepad" then
		pcall(function()
			GuiService.SelectedObject = button
		end)
	end
end

--------------------------------------------------------------------------
-- Notifications
--------------------------------------------------------------------------

local TYPE_TONES = {
	info = "Blue",
	success = "Green",
	warning = "Yellow",
	error = "Red",
	reward = "Yellow",
}

local TYPE_ICONS = {
	info = "info",
	success = "success",
	warning = "warning",
	error = "error",
	reward = "gift",
}

-- At most this many toasts on screen; a new one pushes the oldest out.
local MAX_TOASTS = 4
local TOAST_H, TOAST_GAP = 68, 10

local toastScope: any = nil
local toastStack: Frame? = nil
local toastCount = 0

type Toast = {
	key: string,
	alive: boolean,
	bump: (duration: number?) -> (),
	dismiss: (how: string) -> (),
}
local toasts: { Toast } = {} -- on screen, oldest first

-- Where toasts appear. Change with Popups.configure({ Toasts = "Top" }).
local toastPlacement = "Bottom"
local PLACEMENTS = {
	Bottom = { anchor = Vector2.new(0.5, 1), position = UDim2.new(0.5, 0, 1, -236), valign = "Bottom" },
	Top = { anchor = Vector2.new(0.5, 0), position = UDim2.new(0.5, 0, 0, 76), valign = "Top" },
	Center = { anchor = Vector2.new(0.5, 0), position = UDim2.new(0.5, 0, 0.3, 0), valign = "Top" },
}

-- Bottom toasts keep out of the way of an open Window (below it if there's
-- room, otherwise above it).
local avoidWindows = true

--[[
	Popups.configure({
		Toasts = "Bottom" | "Top" | "Center",
		BottomOffset = 236,   -- design px from the screen bottom to the stack ("Bottom")
		TopOffset = 76,       -- design px from the top ("Top"), e.g. below a goal bar
		MaxToasts = 4,
		AvoidWindows = true,  -- "Bottom" toasts move clear of an open Window
	})
	Pick the spot that doesn't fight your HUD (e.g. "Top" when the bottom
	centre has a big action button).
]]
function Popups.configure(options: { [string]: any })
	if typeof(options.MaxToasts) == "number" then
		MAX_TOASTS = math.max(1, math.floor(options.MaxToasts))
	end
	if options.AvoidWindows ~= nil then
		avoidWindows = options.AvoidWindows == true
	end
	if typeof(options.BottomOffset) == "number" then
		PLACEMENTS.Bottom.position = UDim2.new(0.5, 0, 1, -options.BottomOffset)
		if toastStack and toastPlacement == "Bottom" then
			toastStack.Position = PLACEMENTS.Bottom.position
		end
	end
	if typeof(options.TopOffset) == "number" then
		PLACEMENTS.Top.position = UDim2.new(0.5, 0, 0, options.TopOffset)
		if toastStack and toastPlacement == "Top" then
			toastStack.Position = PLACEMENTS.Top.position
		end
	end
	if options.Toasts and PLACEMENTS[options.Toasts] and options.Toasts ~= toastPlacement then
		toastPlacement = options.Toasts
		if toastStack then
			local placement = PLACEMENTS[toastPlacement]
			toastStack.AnchorPoint = placement.anchor
			toastStack.Position = placement.position
			local list = toastStack:FindFirstChildOfClass("UIListLayout")
			if list then
				list.VerticalAlignment = Enum.VerticalAlignment[placement.valign]
			end
		end
	end
end

local function topInset(): number
	local ok, inset = pcall(function()
		return (GuiService:GetGuiInset())
	end)
	return if ok and typeof(inset) == "Vector2" then inset.Y else 0
end

-- The panel of the first open Window on screen, as full-screen px
-- (top-left, bottom-right), or nil. Windows sit near the top of their
-- ScreenGui (on its canvas), so only a few levels are searched.
local function openWindowRect(): (Vector2?, Vector2?)
	local player = Players.LocalPlayer
	local playerGui = player and player:FindFirstChildOfClass("PlayerGui")
	if playerGui == nil then
		return nil, nil
	end
	local function search(parent: Instance, depth: number): GuiObject?
		for _, child in parent:GetChildren() do
			if child:IsA("GuiObject") then
				if child:GetAttribute("BloxUIWindow") == true and child.Visible then
					local entry = Gamepad.entry(child)
					if (entry == nil or entry.Open == nil or peek(entry.Open) ~= false) and Gamepad.isShown(child) then
						return child
					end
				elseif depth > 0 and child.Visible then
					local found = search(child, depth - 1)
					if found then
						return found
					end
				end
			end
		end
		return nil
	end
	for _, gui in playerGui:GetChildren() do
		if gui:IsA("ScreenGui") and gui.Enabled and gui.Name ~= "BloxUI_Toasts" then
			local window = search(gui, 3)
			local panel = window and window:FindFirstChild("Holder", true)
			if panel and panel:IsA("GuiObject") then
				local shift = if gui.IgnoreGuiInset then 0 else topInset()
				local topLeft = panel.AbsolutePosition + Vector2.new(0, shift)
				return topLeft, topLeft + panel.AbsoluteSize
			end
		end
	end
	return nil, nil
end

-- Moves "Bottom" toasts clear of an open Window: into the gap below it when
-- there's room, otherwise just above it. Back to normal when it closes.
local function placeStack()
	local stack = toastStack
	if stack == nil or stack.Parent == nil or toastPlacement ~= "Bottom" then
		return
	end
	local home = PLACEMENTS.Bottom.position
	if not avoidWindows then
		stack.Position = home
		return
	end
	local canvas = stack.Parent :: GuiObject
	local scale = math.max(0.05, peek(Viewport.scale))
	local canvasTop = canvas.AbsolutePosition.Y + topInset() -- the toast gui respects the top bar
	local height = canvas.AbsoluteSize.Y / scale
	local topLeft, bottomRight = openWindowRect()
	if topLeft == nil or bottomRight == nil then
		stack.Position = home
		return
	end
	local winTop = (topLeft.Y - canvasTop) / scale
	local winBottom = (bottomRight.Y - canvasTop) / scale
	local bottom = height + home.Y.Offset -- the stack's normal bottom edge
	local stackHeight = math.max(1, #toasts) * (TOAST_H + TOAST_GAP)
	if not (winTop < bottom and winBottom > bottom - stackHeight) then
		stack.Position = home
		return
	end
	if height - winBottom >= TOAST_H + TOAST_GAP + 12 then
		stack.Position = UDim2.new(0.5, 0, 1, -12) -- the gap under the window
	elseif winTop >= stackHeight + 90 then
		stack.Position = UDim2.new(0.5, 0, 0, winTop - 8) -- just above it
	else
		stack.Position = home
	end
end

local function getToastStack(): Frame
	if toastStack and toastStack.Parent and toastStack:IsDescendantOf(game) then
		return toastStack
	end
	if toastScope then
		Fusion.doCleanup(toastScope)
	end
	toastScope = newScope()
	-- Toasts stack upwards from just above the bottom-centre HUD, well clear
	-- of the top bar, currency counters and hotbars. The gap lives inside
	-- each toast (so a leaving toast can shrink it smoothly), not the list.
	local placement = PLACEMENTS[toastPlacement]
	local stack = toastScope:New "Frame" {
		Name = "Stack",
		AnchorPoint = placement.anchor,
		Position = placement.position,
		Size = UDim2.fromOffset(600, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundTransparency = 1,
		[Children] = Kit.list(toastScope, { Direction = "Y", Padding = 0, HAlign = "Center", VAlign = placement.valign }),
	}
	toastScope:Screen {
		Name = "BloxUI_Toasts",
		DisplayOrder = 900,
		SafeArea = "Core",
		[Children] = stack,
	}
	toastStack = stack
	-- keep clear of windows while toasts are up (a cheap check, 4x a second)
	local alive = true
	table.insert(toastScope, function()
		alive = false
	end)
	task.spawn(function()
		while alive do
			if #toasts > 0 then
				placeStack()
			end
			task.wait(0.25)
		end
	end)
	return stack
end

export type NotifyOptions = {
	Type: ("info" | "success" | "warning" | "error" | "reward")?,
	Icon: any?,
	Tone: any?,
	Duration: number?,
	To: GuiObject?, -- when it expires, its icon flies here (an Inbox button, like RIVALS)
	Dedupe: boolean?, -- identical toasts merge into one with a "x2" counter (default true)
	Essential: boolean?, -- shows even while Popups.quiet is on (purchase confirmations)
}

--[[
	Popups.quiet = true (the Blueprint "notifications" setting turned off)
	drops non-essential toasts. Errors and warnings (something went wrong or
	needs the player) and toasts marked Essential (purchase confirmations)
	still show.
]]
Popups.quiet = false

-- Shows a toast above the bottom-centre HUD that hides itself. Identical
-- toasts merge ("x3"), at most 4 show at once, and a tap dismisses one.
function Popups.notify(text: string, options: NotifyOptions?)
	if not hasPlayer() then
		return
	end
	local opts = options or {}
	local kind = opts.Type or "info"
	if Popups.quiet and kind ~= "error" and kind ~= "warning" and opts.Essential ~= true then
		return
	end
	local icon = opts.Icon or TYPE_ICONS[kind]
	local duration = opts.Duration or 3.2
	local key = `{kind}\0{text}\0{tostring(icon)}\0{tostring(opts.Tone)}`

	-- the same message again: count it on the toast that's already up
	if opts.Dedupe ~= false then
		for _, toast in toasts do
			if toast.alive and toast.key == key then
				toast.bump(duration)
				return
			end
		end
	end
	-- too many: the oldest makes room
	while #toasts >= MAX_TOASTS do
		toasts[1].dismiss("overflow")
	end

	local stack = getToastStack()
	-- an independent scope: rebuilding the toast stack must not clean it
	-- a second time behind our back
	local scope = newScope()
	local cleaned = false
	local function cleanup()
		if not cleaned then
			cleaned = true
			Fusion.doCleanup(scope)
		end
	end
	toastCount += 1

	local shown = scope:Value(false)
	local appear = scope:Spring(scope:Computed(function(use)
		return if use(shown) then 1 else 0
	end), 22, 0.72)
	local count = scope:Value(1)
	local punchGoal = scope:Value(1)
	local punch = scope:Spring(punchGoal, 30, 0.4)
	local timeLeft = scope:Value(1) -- 1 -> 0 over the toast's life
	local tone = Kit.tone(scope, opts.Tone or TYPE_TONES[kind], "Blue")
	local deadline = os.clock() + duration
	local life = duration

	local look, layers = Kit.surface(scope, { Style = "Glass", Radius = "Full" })

	local toast: Toast
	local iconBack: Frame

	iconBack = scope:New "Frame" {
		Name = "IconBack",
		LayoutOrder = 1,
		Size = UDim2.fromOffset(52, 52),
		BackgroundColor3 = WHITE,
		ZIndex = 3,
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.gradient(scope, {
				Color = scope:Computed(function(use)
					return Kit.faceColors(use(tone), 0)
				end),
			}),
			Kit.stroke(scope, {
				Thickness = 2,
				Color = scope:Computed(function(use)
					return use(tone).Edge
				end),
			}),
			scope:Icon {
				Icon = icon,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = 32,
				Color = WHITE,
				StrokeColor = scope:Computed(function(use)
					return use(tone).Stroke or use(tone).Edge
				end),
				ZIndex = 4,
			},
		},
	}

	scope:New "TextButton" {
		Name = "Toast",
		LayoutOrder = toastCount,
		Text = "",
		AutoButtonColor = false,
		Selectable = false,
		BackgroundTransparency = 1,
		-- the slot opens and closes smoothly, so the stack glides instead of jumping
		Size = scope:Computed(function(use)
			local a = math.clamp(use(appear), 0, 1)
			return UDim2.fromOffset(600, math.floor((TOAST_H + TOAST_GAP) * a + 0.5))
		end),
		Parent = stack,
		[OnEvent "Activated"] = function()
			if toast and toast.alive then
				toast.dismiss("tap")
			end
		end,
		[Children] = scope:New "CanvasGroup" {
			Name = "Body",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = scope:Computed(function(use)
				return UDim2.new(0.5, 0, 0.5, (1 - use(appear)) * 30)
			end),
			Size = UDim2.new(0, 0, 0, TOAST_H),
			AutomaticSize = Enum.AutomaticSize.X,
			GroupTransparency = scope:Computed(function(use)
				return 1 - math.clamp(use(appear), 0, 1)
			end),
			BackgroundTransparency = 1,
			[Children] = {
				scope:New "UIScale" {
					Scale = scope:Computed(function(use)
						return (0.92 + 0.08 * math.clamp(use(appear), 0, 1.05)) * use(punch)
					end),
				},
				scope:New "UISizeConstraint" { MinSize = Vector2.new(280, TOAST_H), MaxSize = Vector2.new(600, TOAST_H) },
				scope:New "Frame" {
					Name = "Surface",
					Size = UDim2.fromScale(1, 1),
					BackgroundColor3 = look.BackgroundColor3,
					BackgroundTransparency = Kit.themed(scope, function(theme)
						return math.min(theme.Alpha.Surface, 0.15)
					end),
					ZIndex = 1,
					[Children] = layers,
				},
				scope:New "Frame" {
					Name = "Row",
					BackgroundTransparency = 1,
					Size = UDim2.fromScale(0, 1),
					AutomaticSize = Enum.AutomaticSize.X,
					ZIndex = 2,
					[Children] = {
						Kit.padding(scope, { 0, 28, 0, 8 }),
						Kit.list(scope, { Direction = "X", Padding = 14, VAlign = "Center" }),
						iconBack,
						scope:Text {
							Text = text,
							TextSize = 23,
							Font = "Heavy",
							XAlign = "Left",
							RichText = true,
							Wrapped = true,
							Case = "Normal",
							LayoutOrder = 2,
							ZIndex = 3,
							[Children] = scope:New "UISizeConstraint" { MaxSize = Vector2.new(440, 64) },
						},
						-- "x2", "x3" ... when the same message repeats
						scope:New "Frame" {
							Name = "Count",
							LayoutOrder = 3,
							Size = UDim2.fromOffset(0, 32),
							AutomaticSize = Enum.AutomaticSize.X,
							BackgroundColor3 = WHITE,
							Visible = scope:Computed(function(use)
								return use(count) > 1
							end),
							ZIndex = 3,
							[Children] = {
								Kit.corner(scope, "Full"),
								Kit.gradient(scope, {
									Color = scope:Computed(function(use)
										return Kit.faceColors(use(tone), 0)
									end),
								}),
								Kit.stroke(scope, {
									Thickness = 2,
									Color = scope:Computed(function(use)
										return use(tone).Edge
									end),
								}),
								Kit.padding(scope, { 0, 10 }),
								scope:Text {
									Text = scope:Computed(function(use)
										return `x{use(count)}`
									end),
									TextSize = 20,
									Font = "Display",
									Case = "Normal",
									Size = UDim2.fromScale(0, 1),
									AutoSize = "X",
									StrokeColor = scope:Computed(function(use)
										return use(tone).Stroke or use(tone).Edge
									end),
									ZIndex = 4,
								},
							},
						},
					},
				},
				scope:New "Frame" {
					Name = "Timer",
					AnchorPoint = Vector2.new(0, 1),
					Position = UDim2.new(0, 34, 1, -3),
					Size = scope:Computed(function(use)
						return UDim2.new(math.clamp(use(timeLeft), 0, 1), -68, 0, 3)
					end),
					BackgroundColor3 = scope:Computed(function(use)
						return use(tone).Top
					end),
					BackgroundTransparency = 0.2,
					ZIndex = 4,
					[Children] = Kit.corner(scope, "Full"),
				},
			},
		},
	}

	toast = {
		key = key,
		alive = true,
		bump = function(newDuration: number?)
			count:set(peek(count) + 1)
			life = math.max(newDuration or duration, 1)
			deadline = os.clock() + life
			punch:setPosition(1.12)
			Sound.play(if kind == "error" then "Error" else "Notify", { Pitch = 1 + math.min(peek(count) - 1, 5) * 0.06 })
		end,
		dismiss = function(how: string)
			if not toast.alive then
				return
			end
			toast.alive = false
			local index = table.find(toasts, toast)
			if index then
				table.remove(toasts, index)
			end
			-- RIVALS: expired notifications fly into the inbox
			if how == "expired" and opts.To and opts.To.Parent then
				pcall(Particles.flyTo, iconBack, opts.To, {
					Icon = icon,
					Count = 1,
					Size = 44,
					Color = peek(tone).Top,
					BurstRadius = { 12, 24 },
					Arc = 70,
				})
			end
			shown:set(false)
			task.delay(0.45, cleanup)
		end,
	}
	table.insert(toasts, toast)
	placeStack()

	-- the timer bar and expiry
	table.insert(scope, RunService.RenderStepped:Connect(function()
		if not toast.alive then
			return
		end
		local left = deadline - os.clock()
		timeLeft:set(left / life)
		if left <= 0 then
			toast.dismiss("expired")
		end
	end))
	table.insert(scope, function()
		toast.alive = false
		local index = table.find(toasts, toast)
		if index then
			table.remove(toasts, index)
		end
	end)

	Sound.play(if kind == "error" then "Error" else "Notify")
	shown:set(true)
end

-- Clears every toast (e.g. on a screen change).
function Popups.clearToasts()
	for index = #toasts, 1, -1 do
		toasts[index].dismiss("clear")
	end
end

--------------------------------------------------------------------------
-- Modal shell (confirm, reward)
--------------------------------------------------------------------------

export type ConfirmOptions = {
	Title: string?,
	Message: string?,
	Icon: any?,
	IconColor: Color3?,
	Confirm: string?,
	Cancel: string?,
	Tone: any?,
}

-- A dimmed backdrop and a centred dialog that pops in (0.6 -> 1 with a
-- little overshoot) and pops out (shrinks away) when `open` goes false.
local function modal(scope: any, open: any, body: { any }, size: Vector2, onBackdrop: (() -> ())?)
	local progress = scope:Spring(scope:Computed(function(use)
		return if use(open) then 1 else 0
	end), 26, 0.6)
	return scope:Screen {
		Name = "BloxUI_Modal",
		DisplayOrder = 950,
		SafeArea = "None",
		[Children] = {
			scope:New "TextButton" {
				Name = "Backdrop",
				Size = UDim2.fromScale(1, 1),
				Text = "",
				AutoButtonColor = false,
				Selectable = false,
				Modal = open,
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Backdrop
				end),
				BackgroundTransparency = scope:Computed(function(use)
					local alpha = use(Theme.current).Alpha.Backdrop
					return 1 - (1 - alpha) * math.clamp(use(progress), 0, 1)
				end),
				[OnEvent "Activated"] = function()
					if onBackdrop then
						onBackdrop()
					end
				end,
			},
			scope:New "Frame" {
				Name = "Dialog",
				Active = true, -- clicks on the dialog shouldn't count as backdrop clicks
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.fromOffset(size.X, size.Y),
				-- height 0 = grow to fit the content
				AutomaticSize = if size.Y == 0 then Enum.AutomaticSize.Y else Enum.AutomaticSize.None,
				BackgroundTransparency = 1,
				ZIndex = 2,
				[Children] = {
					scope:New "UIScale" {
						Scale = scope:Computed(function(use)
							local p = use(progress)
							if use(open) then
								return 0.6 + 0.4 * p
							end
							return math.max(0, p) -- closing: shrink away
						end),
					},
					body,
				},
			},
		},
	}
end

-- Asks a yes/no question. Yields and returns true if the player confirmed.
-- Enter confirms; Esc, B or a click on the backdrop cancels.
function Popups.confirm(options: ConfirmOptions?): boolean
	if not hasPlayer() then
		return false
	end
	local opts = options or {}
	local scope = newScope()
	local open = scope:Value(false)
	local answered = Instance.new("BindableEvent")
	table.insert(scope, answered)

	local function answer(result: boolean)
		if peek(open) then
			open:set(false)
			answered:Fire(result)
		end
	end
	-- Esc / B / the backdrop: the buttons make their own click sounds
	local function cancel()
		if peek(open) then
			Sound.play("Close")
			answer(false)
		end
	end

	-- Dialogs want a calmer, more opaque surface than HUD glass.
	local dialogStyle = Kit.themed(scope, function(theme)
		local style = Theme.style(theme, "PanelStyle")
		if style == "Glass" then
			return "Solid"
		elseif style == "Ribbon" then
			return "Outlined"
		end
		return style
	end)
	local onLight = scope:Computed(function(use)
		return Kit.surfaceLook(use(Theme.current), use(dialogStyle)).light
	end)

	local iconPopGoal = scope:Value(1)
	local iconPop = scope:Spring(iconPopGoal, 22, 0.4)

	local confirmButton = scope:Button {
		Text = opts.Confirm or "OK",
		Tone = opts.Tone or "Green",
		Size = UDim2.fromOffset(236, 68),
		TextSize = 30,
		Shine = true,
		LayoutOrder = 2,
		Hotkey = Enum.KeyCode.Return,
		OnClick = function()
			answer(true)
		end,
	}

	modal(scope, open, {
		Kit.elevation(scope, { Radius = "L", Spread = 24, Offset = Vector2.new(0, 10) }),
		scope:Panel {
			Name = "Card",
			Style = dialogStyle,
			Radius = "L",
			Size = UDim2.new(1, 0, 0, 0),
			AutoSize = "Y",
			Padding = { 28, 32, 28, 32 },
			Layout = "Column",
			Gap = 16,
			[Children] = {
				if opts.Icon
					then scope:Icon {
						Icon = opts.Icon,
						Size = 96,
						Color = opts.IconColor or Kit.themed(scope, function(theme)
							return theme.Tone.Yellow.Top
						end),
						Gradient = true,
						Shadow = true,
						Glow = true,
						LayoutOrder = 1,
						[Children] = scope:New "UIScale" { Scale = iconPop },
					}
					else nil,
				scope:Text {
					Text = opts.Title or "Are you sure?",
					TextSize = "H2",
					Font = "Title",
					LayoutOrder = 2,
				},
				if opts.Message
					then scope:Text {
						Text = opts.Message,
						TextSize = 24,
						Font = "Body",
						Wrapped = true,
						RichText = true,
						Case = "Normal",
						Size = UDim2.new(1, 0, 0, 0),
						AutoSize = "Y",
						Color = scope:Computed(function(use)
							local theme = use(Theme.current)
							return if use(onLight) then theme.Color.PanelText else theme.Color.TextMuted
						end),
						Stroke = false,
						LayoutOrder = 3,
					}
					else nil,
				scope:Row {
					Name = "Actions",
					Size = UDim2.new(1, 0, 0, 76),
					Gap = 16,
					Align = "Center",
					LayoutOrder = 4,
					[Children] = {
						-- secondary: smaller, neutral; primary: bigger, toned, shining
						scope:Button {
							Text = opts.Cancel or "Cancel",
							Tone = "Gray",
							Size = UDim2.fromOffset(200, 64),
							LayoutOrder = 1,
							OnClick = function()
								answer(false)
							end,
						},
						confirmButton,
					},
				},
			},
		},
	}, Vector2.new(620, 0), cancel)

	Input.bind(scope, { Enum.KeyCode.Escape, Enum.KeyCode.ButtonB }, cancel)

	open:set(true)
	Sound.play("Open")
	iconPop:setPosition(0.4)
	iconPop:addVelocity(6)
	focus(confirmButton)
	local result = waitFor(answered)
	task.delay(0.35, function()
		Fusion.doCleanup(scope)
	end)
	return result == true
end

--------------------------------------------------------------------------
-- Reward reveal
--------------------------------------------------------------------------

export type RewardOptions = {
	Title: string?,
	Icon: any?,
	IconColor: Color3?,
	Amount: (string | number)?, -- "+40" / 40 counts up as it appears
	Tone: any?,
	Button: string?,
	To: GuiObject?, -- claiming sends the reward flying here (a currency counter)
	Count: number?, -- icons that fly to `To` (default 8)
	OnArrive: ((index: number) -> ())?, -- each icon reaching `To`
}

-- Splits "+1,250 Gems" into ("+", 1250, " Gems") so it can count up.
local function parseAmount(amount: any): (string, number?, string)
	if typeof(amount) == "number" then
		return "+", amount, ""
	end
	local text = tostring(amount or "")
	local prefix, digits, suffix = string.match(text, "^(%D-)([%d,]+)(.*)$")
	if digits then
		local number = tonumber((string.gsub(digits, ",", "")))
		if number then
			return prefix, number, suffix
		end
	end
	return text, nil, ""
end

-- Big "you got a reward" reveal: spinning rays, the icon pops with a
-- burst and confetti, the amount counts up. Claim (button, Enter, A)
-- poofs it, sends it flying to `To` if given, and closes. Yields until
-- claimed.
function Popups.reward(options: RewardOptions?)
	if not hasPlayer() then
		return
	end
	local opts = options or {}
	local scope = newScope()
	local open = scope:Value(false)
	local claimed = Instance.new("BindableEvent")
	table.insert(scope, claimed)
	local tone = Kit.tone(scope, opts.Tone, "Yellow")

	local iconPopGoal = scope:Value(1)
	local iconPop = scope:Spring(iconPopGoal, 18, 0.35)
	local titlePopGoal = scope:Value(1)
	local titlePop = scope:Spring(titlePopGoal, 22, 0.5)
	local bob = scope:Value(0)

	local prefix, amountNumber, suffix = parseAmount(opts.Amount)

	local rays = Kit.rays(scope, {
		Name = "Rays",
		Position = UDim2.fromScale(0.5, 0.46),
		Size = UDim2.fromOffset(820, 820),
		Count = 16,
		Transparency = 0.25,
		Color = scope:Computed(function(use)
			return use(tone).Top:Lerp(WHITE, 0.35)
		end),
		ZIndex = 1,
	})

	local iconGui = scope:Icon {
		Name = "RewardIcon",
		Icon = opts.Icon or "gift",
		Size = 230,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = scope:Computed(function(use)
			return UDim2.new(0.5, 0, 0.46, use(bob))
		end),
		Color = opts.IconColor or scope:Computed(function(use)
			return use(tone).Top
		end),
		Gradient = true,
		Shadow = true,
		Stroke = 5,
		ZIndex = 3,
		[Children] = scope:New "UIScale" {
			Scale = scope:Computed(function(use)
				return math.max(0, use(iconPop))
			end),
		},
	}

	local amountLabel = if opts.Amount ~= nil
		then scope:Text {
			Name = "Amount",
			Text = if amountNumber then `{prefix}0{suffix}` else tostring(opts.Amount),
			TextSize = 72,
			Font = "Display",
			Stroke = 5,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.74),
			ZIndex = 4,
		}
		else nil

	local claim: () -> ()
	local claimButton = scope:Button {
		Text = opts.Button or "Claim!",
		Tone = "Green",
		ClickFx = "Poof",
		Size = UDim2.fromOffset(260, 76),
		TextSize = 36,
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.fromScale(0.5, 1),
		Shine = true,
		Pulse = true,
		Hotkey = Enum.KeyCode.Return,
		OnClick = function()
			claim()
		end,
		ZIndex = 5,
	}

	claim = function()
		if not peek(open) then
			return
		end
		-- the claim moment: the prize punches, poofs and flies off
		iconPop:addVelocity(5)
		local color = opts.IconColor or peek(tone).Top
		Particles.poof(iconGui, { Color = color:Lerp(WHITE, 0.4), Size = 60, Reach = 90 })
		Particles.burst(iconGui, { Shape = "sparkle", Count = 10, Speed = 560, Size = 34, Gravity = 0, Drag = 3, Colors = { WHITE, color } })
		Sound.play("Purchase")
		if opts.To and opts.To.Parent then
			Particles.flyTo(iconGui, opts.To, {
				Icon = opts.Icon or "gift",
				Color = color,
				Count = opts.Count or 8,
				OnArrive = opts.OnArrive,
			})
		end
		open:set(false)
		claimed:Fire()
	end
	Input.bind(scope, { Enum.KeyCode.ButtonA, Enum.KeyCode.Space }, claim)

	modal(scope, open, {
		rays,
		scope:Text {
			Name = "Title",
			Text = opts.Title or "REWARD!",
			TextSize = 84,
			Font = "Title",
			Gradient = opts.Tone or "Yellow",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5, 0),
			ZIndex = 3,
			[Children] = scope:New "UIScale" { Scale = titlePop },
		},
		iconGui,
		amountLabel,
		claimButton,
	}, Vector2.new(760, 640), nil)

	table.insert(scope, Motion.spin(rays, 10))
	iconPop:setPosition(0)
	titlePop:setPosition(1.5)
	open:set(true)
	Sound.play("Reward")
	focus(claimButton)

	-- the icon floats while it waits to be claimed
	local started = os.clock()
	table.insert(scope, RunService.RenderStepped:Connect(function()
		bob:set(math.sin((os.clock() - started) * 2.4) * 8)
	end))

	task.delay(0.1, function()
		if not peek(open) then
			return
		end
		iconPop:addVelocity(4)
		local color = opts.IconColor or peek(tone).Top
		Particles.ring(iconGui, { Size = 300, From = 0.3, To = 1.8, Time = 0.4, Thickness = 10, Color = color:Lerp(WHITE, 0.4) })
		Particles.burst(iconGui, { Count = 44, Speed = 950, Shape = "mix", Size = 16 })
		Particles.burst(iconGui, { Count = 14, Speed = 500, Shape = "sparkle", Size = 34 })
		Particles.confetti({ Count = 70 })
		table.insert(scope, Particles.sparkles(iconGui, { Rate = 3, Size = 40 }))
	end)
	-- the amount rolls up while the prize lands
	if amountLabel and amountNumber then
		table.insert(scope, Motion.countTo(amountLabel, 0, amountNumber, {
			Duration = 0.9,
			Format = "comma",
			Prefix = prefix,
			Suffix = suffix,
			Tick = true,
			Punch = 0.15,
		}))
	end

	waitFor(claimed)
	task.delay(0.35, function()
		Fusion.doCleanup(scope)
	end)
end

--------------------------------------------------------------------------
-- Dialogue
--------------------------------------------------------------------------

export type DialogueLine = {
	Speaker: string?,
	Text: string,
	Icon: any?, -- portrait: icon name or image id
	Tone: any?, -- name tag colour
	Choices: { string }?,
}

export type DialogueOptions = {
	Speed: number?, -- characters per second (45)
	Skippable: boolean?, -- Esc / B ends the conversation early (returns nil)
}

-- Talks through `lines` in a dialogue box with typewriter text. Click, tap,
-- Space, E or A advances (a press while a line types shows it all). Returns
-- the index of the choice picked on the last line that had choices (or nil).
function Popups.dialogue(lines: { DialogueLine }, options: DialogueOptions?): number?
	if not hasPlayer() then
		return nil
	end
	local opts = options or {}
	local scope = newScope()
	local open = scope:Value(false)
	local speaker = scope:Value("")
	local portrait = scope:Value(false :: any)
	local choices = scope:Value({} :: { string })
	local typing = scope:Value(false)
	local toneName = scope:Value("Blue" :: any)
	local advance = Instance.new("BindableEvent")
	table.insert(scope, advance)
	local skipped = false

	local progress = scope:Spring(scope:Computed(function(use)
		return if use(open) then 1 else 0
	end), 24, 0.75)
	local tagPopGoal = scope:Value(1)
	local tagPop = scope:Spring(tagPopGoal, 26, 0.45)
	local portraitPopGoal = scope:Value(1)
	local portraitPop = scope:Spring(portraitPopGoal, 22, 0.4)
	local continueBob = scope:Value(0)

	local label = scope:Text {
		Name = "Line",
		Text = "",
		TextSize = 28,
		Font = "Body",
		XAlign = "Left",
		YAlign = "Top",
		Wrapped = true,
		RichText = true,
		Stroke = false,
		Size = UDim2.new(1, 0, 1, 0),
		ZIndex = 4,
	}

	local firstChoice: GuiObject? = nil
	local choiceButtons = scope:ForPairs(choices, function(_, inner, index, text)
		-- choices pop in one after another (the slot scales; the button keeps
		-- its own press springs)
		local popGoal = inner:Value(0)
		local pop = inner:Spring(popGoal, 24, 0.5)
		local gone = false
		table.insert(inner, function()
			gone = true
		end)
		task.delay(0.05 + (index - 1) * 0.07, function()
			if not gone then
				popGoal:set(1)
			end
		end)
		local button = inner:Button {
			Name = text,
			Text = text,
			Tone = if index == 1 then "Green" else "Gray",
			Size = UDim2.fromScale(1, 1),
			TextSize = 26,
			OnClick = function()
				advance:Fire(index)
			end,
		}
		if index == 1 then
			firstChoice = button
		end
		return index, inner:New "Frame" {
			Name = "Choice" .. index,
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(240, 58),
			LayoutOrder = index,
			[Children] = {
				inner:New "UIScale" {
					Scale = inner:Computed(function(use)
						return math.max(0, use(pop))
					end),
				},
				button,
			},
		}
	end)

	local function onAdvance()
		if #peek(choices) == 0 then
			advance:Fire(nil)
		end
	end

	Input.bind(scope, { Enum.KeyCode.Space, Enum.KeyCode.E, Enum.KeyCode.ButtonA, Enum.KeyCode.Return }, onAdvance)
	if opts.Skippable then
		Input.bind(scope, { Enum.KeyCode.Escape, Enum.KeyCode.ButtonB }, function()
			skipped = true
			advance:Fire(nil)
		end)
	end

	scope:Screen {
		Name = "BloxUI_Dialogue",
		DisplayOrder = 940,
		[Children] = {
			scope:New "TextButton" {
				Name = "ClickCatcher",
				Size = UDim2.fromScale(1, 1),
				BackgroundTransparency = 1,
				Text = "",
				Selectable = false,
				[OnEvent "Activated"] = onAdvance,
			},
			scope:New "Frame" {
				Name = "Box",
				AnchorPoint = Vector2.new(0.5, 1),
				Position = scope:Computed(function(use)
					return UDim2.new(0.5, 0, 1, -30 + (1 - use(progress)) * 300)
				end),
				Size = UDim2.fromOffset(1120, 230),
				BackgroundTransparency = 1,
				ZIndex = 2,
				[Children] = {
					scope:Panel {
						Style = "Solid",
						Shadow = true,
						Radius = "L",
						Size = UDim2.fromScale(1, 1),
						[Children] = {
							scope:New "Frame" {
								Name = "Portrait",
								BackgroundTransparency = 1,
								Position = UDim2.fromOffset(24, 34),
								Size = UDim2.fromOffset(150, 150),
								Visible = scope:Computed(function(use)
									return use(portrait) ~= false
								end),
								[Children] = scope:Icon {
									Icon = portrait,
									-- a number, so big glyphs magnify instead of capping at 100 px
									Size = 150,
									AnchorPoint = Vector2.new(0.5, 1),
									Position = UDim2.fromScale(0.5, 1),
									Color = scope:Computed(function(use)
										return Theme.tone(use(Theme.current), use(toneName)).Top
									end),
									Gradient = true,
									Shadow = true,
									[Children] = scope:New "UIScale" { Scale = portraitPop },
								},
							},
							scope:New "Frame" {
								Name = "TextArea",
								BackgroundTransparency = 1,
								Position = scope:Computed(function(use)
									return UDim2.fromOffset(if use(portrait) ~= false then 196 else 36, 40)
								end),
								Size = scope:Computed(function(use)
									return UDim2.new(1, if use(portrait) ~= false then -230 else -72, 1, -110)
								end),
								[Children] = label,
							},
							scope:Row {
								Name = "Choices",
								AnchorPoint = Vector2.new(1, 1),
								Position = UDim2.new(1, -24, 1, -18),
								Size = UDim2.fromOffset(0, 64),
								AutoSize = "X",
								Gap = 14,
								Visible = scope:Computed(function(use)
									return #use(choices) > 0 and not use(typing)
								end),
								[Children] = choiceButtons,
							},
							scope:Icon {
								Name = "Continue",
								Icon = "down",
								Size = 36,
								AnchorPoint = Vector2.new(1, 1),
								Position = scope:Computed(function(use)
									return UDim2.new(1, -26, 1, -18 + use(continueBob))
								end),
								Visible = scope:Computed(function(use)
									return #use(choices) == 0 and not use(typing)
								end),
							},
						},
					},
					scope:New "Frame" {
						Name = "NameTag",
						AnchorPoint = Vector2.new(0, 0.5),
						Position = UDim2.fromOffset(28, 0),
						Size = UDim2.fromOffset(0, 48),
						AutomaticSize = Enum.AutomaticSize.X,
						BackgroundColor3 = WHITE,
						Visible = scope:Computed(function(use)
							return use(speaker) ~= ""
						end),
						ZIndex = 5,
						[Children] = {
							scope:New "UIScale" { Scale = tagPop },
							Kit.corner(scope, "M"),
							Kit.stroke(scope, {
								Color = scope:Computed(function(use)
									local t = Theme.tone(use(Theme.current), use(toneName))
									return t.Stroke or t.Edge
								end),
							}),
							Kit.gradient(scope, {
								Color = scope:Computed(function(use)
									return Kit.faceColors(Theme.tone(use(Theme.current), use(toneName)), 0)
								end),
							}),
							Kit.padding(scope, { 0, 22 }),
							scope:Text {
								Text = speaker,
								TextSize = 28,
								Font = "Display",
								StrokeColor = scope:Computed(function(use)
									local t = Theme.tone(use(Theme.current), use(toneName))
									return t.Stroke or t.Edge
								end),
								Size = UDim2.fromScale(0, 1),
								AutoSize = "X",
								ZIndex = 6,
							},
						},
					},
				},
			},
		},
	}

	-- the "next" arrow bobs while it waits
	local started = os.clock()
	table.insert(scope, RunService.RenderStepped:Connect(function()
		continueBob:set(-math.abs(math.sin((os.clock() - started) * 5)) * 6)
	end))

	open:set(true)
	local picked: number? = nil
	local lastSpeaker: string? = nil

	for _, line in lines do
		if skipped then
			break
		end
		local who = line.Speaker or ""
		speaker:set(who)
		if who ~= lastSpeaker then
			-- a new voice: the name tag and portrait bounce in
			lastSpeaker = who
			tagPop:setPosition(0.6)
			tagPop:addVelocity(4)
			portraitPop:setPosition(0.8)
			portraitPop:addVelocity(3)
		end
		portrait:set(line.Icon or false)
		toneName:set(line.Tone or "Blue")
		choices:set({})
		firstChoice = nil
		typing:set(true)
		local done = false
		local skip = Motion.typewrite(label, line.Text, {
			Speed = opts.Speed,
			OnDone = function()
				done = true
				typing:set(false)
			end,
		})
		-- a press while the text is typing shows the whole line at once
		local pressed = false
		local connection = advance.Event:Connect(function()
			pressed = true
		end)
		while not done and not pressed do
			task.wait()
		end
		connection:Disconnect()
		if not done then
			skip()
		end
		if skipped then
			break
		end
		if line.Choices and #line.Choices > 0 then
			choices:set(line.Choices)
			task.defer(function()
				focus(firstChoice)
			end)
			local index = waitFor(advance)
			while index == nil and not skipped do
				index = waitFor(advance)
			end
			picked = index
			Sound.play("Click")
		else
			waitFor(advance)
		end
	end

	open:set(false)
	Sound.play("Close")
	task.delay(0.4, function()
		Fusion.doCleanup(scope)
	end)
	return if skipped then nil else picked
end

return Popups
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="244">
          <Properties>
            <string name="Name">Prefabs</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs: complete screens built from BloxUI components. Each one is
	function(scope, config) -> table of Values and helper functions.

		UI.Prefabs.Shop(scope, { ... })
		UI.Prefabs.FPSHud(scope, { ... })
		UI.Prefabs.HorrorHud(scope, { ... })
		UI.Prefabs.SimulatorHud(scope, { ... })

	They're plain Luau: copy one into your game and change anything you like.
]]

return {
	Shop = require(script.Shop),
	FPSHud = require(script.FPSHud),
	HorrorHud = require(script.HorrorHud),
	SimulatorHud = require(script.SimulatorHud),
	LoadingScreen = require(script.LoadingScreen),
	MainMenu = require(script.MainMenu),
	Results = require(script.Results),
	Trade = require(script.Trade),
}
]]></string>
          </Properties>
          <Item class="ModuleScript" referent="245">
            <Properties>
              <string name="Name">FPSHud</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs.FPSHud: the RIVALS-style shooter HUD. Health bar with the number
	inside, weapon hotbar, crosshair with hit / crit / kill markers, a task
	tracker, key hints, a kill feed, an elimination banner, reload and
	low-ammo prompts and red damage-direction arcs.

	local hud = UI.Prefabs.FPSHud(scope, {
		BindCharacter = true,                  -- health follows the local Humanoid
		Weapons = {
			{ Name = "Assault Rifle", Icon = "ammo", Ammo = ammo, Reserve = reserve, MagSize = 30 },
			{ Name = "Knife", Icon = "melee" },
		},
		Tasks = { ... },  TasksTitle = "Beginner Tasks",    -- optional QuestTracker
		Hints = { { Text = "Reload", Key = Enum.KeyCode.R } },
		ReloadKey = Enum.KeyCode.R, ReloadGamepad = Enum.KeyCode.ButtonX,  -- shown in the "RELOAD" prompt
	})

	hud.hit()                   white hitmarker          hud.hit(true)   gold crit marker
	hud.kill("Noob123")         red kill marker + elimination banner + kill feed
	hud.damage(25, from)        lose health: bar flash, red screen edge, shake, and a red arc
	                            pointing at `from` (a Vector3, Part, Model, or an angle in
	                            degrees: 0 = ahead, 90 = right, 180 = behind)
	hud.heal(20)                gain health with a green glint
	hud.reload(1.2, onDone)     shows reload progress on the slot and under the crosshair
	hud.recoil(8)               kick the crosshair open (it settles back)
	hud.feed(killer, victim)    add a kill feed line
	hud.Health / hud.MaxHealth / hud.Equipped / hud.Spread / hud.Reloading / hud.Streak   (Values)

	Layout follows the device: on PC the hotbar and key hints sit bottom-right
	with health bottom-left (RIVALS); on touch screens health and hotbar move
	to the bottom centre (clear of the thumbstick and the jump button) and the
	tracker shrinks under the top bar; on a gamepad everything keeps a TV-safe
	margin and the hotbar shows LB / RB.
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local Workspace = game:GetService("Workspace")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Bind = require(script.Parent.Parent.Util.Bind)
local Input = require(script.Parent.Parent.Util.Input)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local Sound = require(script.Parent.Parent.Util.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)
local Screen = require(script.Parent.Parent.FX.Screen)

local Children = Fusion.Children
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)
local BLACK = Color3.new(0, 0, 0)

local ARC_RADIUS = 136
local ARC_SPAN = 52 -- degrees
local ARC_SEGMENTS = 15
local ARC_LIFE = 1.7

-- Angle (degrees, 0 = straight ahead, clockwise) from the camera to a damage
-- source, or nil when it can't be worked out.
local function angleTo(source: any): number?
	if typeof(source) == "number" then
		return source
	end
	local position: Vector3? = nil
	if typeof(source) == "Vector3" then
		position = source
	elseif typeof(source) == "Instance" then
		if source:IsA("BasePart") then
			position = source.Position
		elseif source:IsA("Model") then
			local ok, pivot = pcall(source.GetPivot, source)
			position = if ok then pivot.Position else nil
		elseif source:IsA("Attachment") then
			position = source.WorldPosition
		end
	end
	local camera = Workspace.CurrentCamera
	if position == nil or camera == nil then
		return nil
	end
	local ok, relative = pcall(function()
		return camera.CFrame:PointToObjectSpace(position)
	end)
	if not ok or typeof(relative) ~= "Vector3" then
		return nil
	end
	return math.deg(math.atan2(relative.X, -relative.Z))
end

return function(scope: any, config: { [string]: any }?)
	local cfg = config or {}

	local health, maxHealth
	if cfg.BindCharacter then
		health, maxHealth = Bind.characterHealth(scope)
	else
		health = Bind.asValue(scope, cfg.Health, 100)
		maxHealth = Bind.asValue(scope, cfg.MaxHealth, 100)
	end
	local equipped = Bind.asValue(scope, cfg.Equipped, 1)
	local spread = Bind.asValue(scope, cfg.Spread, 6)
	local reloading = scope:Value(false) -- false, or 0..1 while reloading
	local recoil = scope:Value(0)
	local hits = scope:Value(0)
	local crits = scope:Value(0)
	local kills = scope:Value(0)
	local streak = scope:Value(0)
	local feed = scope:Value({})
	local weapons = cfg.Weapons or {}

	local device = Input.device
	local onTouch = scope:Computed(function(use)
		return use(device) == "Touch"
	end)
	-- consoles need a TV-safe margin
	local edge = scope:Computed(function(use)
		return if use(device) == "Gamepad" then 44 else 18
	end)
	local safeTop = Kit.safeTop(scope, 72)

	-- entrance: everything eases in from its screen edge
	local introGoal = scope:Value(0)
	local intro = scope:Spring(introGoal, 16, 0.85)
	task.delay(0.05, function()
		introGoal:set(1)
	end)
	local function slideIn(use: any, distance: number): number
		return (1 - use(intro)) * distance
	end

	-- recoil settles back to zero
	table.insert(scope, RunService.RenderStepped:Connect(function(dt)
		local current = peek(recoil)
		if current > 0.01 then
			recoil:set(current * math.exp(-dt * 7))
		elseif current ~= 0 then
			recoil:set(0)
		end
	end))

	local fraction = scope:Computed(function(use)
		local max = use(maxHealth)
		return if max and max > 0 then math.clamp((use(health) or 0) / max, 0, 1) else 0
	end)

	--------------------------------------------------------------------
	-- Health bar (RIVALS: opaque dark pill, flat lime fill, the number and
	-- heart inside the fill in a darker shade of it)
	--------------------------------------------------------------------

	local healthColor = scope:Computed(function(use)
		local theme = use(Theme.current)
		local f = use(fraction)
		if f > 0.5 then
			return theme.Color.Health or theme.Tone.Green.Top
		elseif f > 0.25 then
			return theme.Color.Warn or theme.Tone.Yellow.Top
		end
		return theme.Color.Callout or theme.Color.Bad
	end)
	local barFill = scope:Spring(fraction, 20, 1)
	local ghostGoal = scope:Value(peek(fraction))
	local ghost = scope:Spring(ghostGoal, 5, 1)
	local hitFlash = scope:Value(1)
	local hitFlashTween = scope:Tween(hitFlash, TweenInfo.new(0.32, Enum.EasingStyle.Quad, Enum.EasingDirection.Out))
	local healGlint = scope:Value(1)
	local healGlintTween = scope:Tween(healGlint, TweenInfo.new(0.5, Enum.EasingStyle.Quad, Enum.EasingDirection.Out))
	local labelPopGoal = scope:Value(1)
	local labelPop = scope:Spring(labelPopGoal, 34, 0.5)
	local lowPulse = scope:Value(0)

	local healthBody: Frame
	local ghostToken = 0
	local lastHealth = peek(health) or 0
	Kit.watch(scope, fraction, function(now)
		ghostToken += 1
		if now >= peek(ghostGoal) then
			ghostGoal:set(now)
		else
			local token = ghostToken
			task.delay(0.4, function()
				if token == ghostToken then
					ghostGoal:set(now)
				end
			end)
		end
	end)
	Kit.watch(scope, health, function(now)
		now = now or 0
		if now < lastHealth - 0.5 then
			hitFlash:set(0.1)
			task.defer(function()
				hitFlash:set(1)
			end)
			labelPop:setPosition(1.28)
			if healthBody then
				Motion.shake(healthBody, 5, 0.22)
			end
		elseif now > lastHealth + 4 then
			healGlint:set(0.35)
			task.defer(function()
				healGlint:set(1)
			end)
		end
		lastHealth = now
	end)
	-- low health: the fill breathes red
	table.insert(scope, RunService.RenderStepped:Connect(function()
		local f = peek(fraction)
		if f > 0 and f <= 0.25 then
			lowPulse:set(0.5 + 0.5 * math.sin(os.clock() * 7))
		elseif peek(lowPulse) ~= 0 then
			lowPulse:set(0)
		end
	end))

	local barHeight = scope:Computed(function(use)
		return if use(onTouch) then 30 else 38
	end)
	local barPad = scope:Computed(function(use)
		return if use(onTouch) then 5 else 6
	end)

	-- the number + heart ride the end of the fill (inside it, in a darker
	-- shade of the fill); once the bar is too short they hop just outside
	local labelInside = scope:Computed(function(use)
		return use(barFill) > 0.3
	end)
	local healthLabel = scope:New "Frame" {
		Name = "Label",
		BackgroundTransparency = 1,
		AnchorPoint = scope:Computed(function(use)
			return if use(labelInside) then Vector2.new(1, 0.5) else Vector2.new(0, 0.5)
		end),
		Position = scope:Computed(function(use)
			return if use(labelInside) then UDim2.new(1, -10, 0.5, 0) else UDim2.new(1, 10, 0.5, 0)
		end),
		Size = UDim2.fromOffset(0, 0),
		AutomaticSize = Enum.AutomaticSize.XY,
		ZIndex = 6,
		[Children] = {
			scope:New "UIListLayout" {
				FillDirection = Enum.FillDirection.Horizontal,
				SortOrder = Enum.SortOrder.LayoutOrder,
				VerticalAlignment = Enum.VerticalAlignment.Center,
				Padding = UDim.new(0, 5),
			},
			scope:New "UIScale" { Scale = labelPop },
			scope:Text {
				Name = "Value",
				Text = scope:Computed(function(use)
					return Format.comma(math.ceil(math.max(0, use(health) or 0)))
				end),
				TextSize = scope:Computed(function(use)
					return math.floor(use(barHeight) * 0.6)
				end),
				Font = "Display",
				-- a darker shade of the fill; white once the bar is short
				Color = scope:Computed(function(use)
					if use(barFill) > 0.3 then
						return Color.darken(use(healthColor), 0.28)
					end
					return WHITE
				end),
				Stroke = scope:Computed(function(use)
					return if use(barFill) > 0.3 then false else 1.5
				end),
				StrokeColor = BLACK,
				StrokeTransparency = 0.4,
				Case = "Normal",
				LayoutOrder = 1,
				ZIndex = 7,
			},
			scope:Icon {
				Icon = "heart",
				Size = scope:Computed(function(use)
					return math.floor(use(barHeight) * 0.52)
				end),
				Color = scope:Computed(function(use)
					if use(barFill) > 0.3 then
						return Color.darken(use(healthColor), 0.28)
					end
					return WHITE
				end),
				Stroke = false,
				LayoutOrder = 2,
				ZIndex = 7,
			},
		},
	}

	healthBody = scope:New "Frame" {
		Name = "Body",
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Pill or theme.Color.Surface
		end),
		BackgroundTransparency = 0,
		Size = UDim2.fromScale(1, 1),
		[Children] = {
			Kit.corner(scope, "Full"),
			scope:New "UIStroke" {
				Color = scope:Computed(function(use)
					return BLACK:Lerp(use(Theme.current).Color.Bad, use(lowPulse))
				end),
				Thickness = scope:Computed(function(use)
					return 1 + use(lowPulse) * 1.5
				end),
				Transparency = scope:Computed(function(use)
					return 0.6 - use(lowPulse) * 0.5
				end),
			},
			scope:New "Frame" {
				Name = "Inner",
				BackgroundTransparency = 1,
				Position = scope:Computed(function(use)
					local pad = use(barPad)
					return UDim2.fromOffset(pad, pad)
				end),
				Size = scope:Computed(function(use)
					local pad = use(barPad)
					return UDim2.new(1, -pad * 2, 1, -pad * 2)
				end),
				[Children] = {
					scope:New "Frame" {
						Name = "Ghost",
						Size = scope:Computed(function(use)
							return UDim2.fromScale(math.max(use(ghost), use(barFill)), 1)
						end),
						BackgroundColor3 = WHITE,
						BackgroundTransparency = 0.3,
						BorderSizePixel = 0,
						ZIndex = 2,
						[Children] = Kit.corner(scope, "Full"),
					},
					scope:New "Frame" {
						Name = "Fill",
						Size = scope:Computed(function(use)
							return UDim2.fromScale(use(barFill), 1)
						end),
						BackgroundColor3 = scope:Computed(function(use)
							return use(healthColor):Lerp(Color3.fromRGB(255, 70, 70), use(lowPulse) * 0.45)
						end),
						BorderSizePixel = 0,
						ZIndex = 3,
						[Children] = {
							Kit.corner(scope, "Full"),
							-- a hairline highlight on top instead of a glossy band
							scope:New "UIGradient" {
								Rotation = 90,
								Color = ColorSequence.new({
									ColorSequenceKeypoint.new(0, Color3.fromRGB(255, 255, 255)),
									ColorSequenceKeypoint.new(0.35, Color3.fromRGB(245, 245, 245)),
									ColorSequenceKeypoint.new(1, Color3.fromRGB(222, 222, 222)),
								}),
							},
							scope:New "Frame" {
								Name = "Flash",
								Size = UDim2.fromScale(1, 1),
								BackgroundColor3 = WHITE,
								BackgroundTransparency = hitFlashTween,
								BorderSizePixel = 0,
								ZIndex = 4,
								[Children] = Kit.corner(scope, "Full"),
							},
							scope:New "Frame" {
								Name = "Glint",
								Size = UDim2.fromScale(1, 1),
								BackgroundColor3 = Color3.fromRGB(220, 255, 220),
								BackgroundTransparency = healGlintTween,
								BorderSizePixel = 0,
								ZIndex = 4,
								[Children] = Kit.corner(scope, "Full"),
							},
							healthLabel,
						},
					},
				},
			},
		},
	}

	local healthBar = scope:New "Frame" {
		Name = "Health",
		BackgroundTransparency = 1,
		AnchorPoint = scope:Computed(function(use)
			return if use(onTouch) then Vector2.new(0.5, 1) else Vector2.new(0, 1)
		end),
		Position = scope:Computed(function(use)
			local drop = slideIn(use, 90)
			if use(onTouch) then
				-- above the hotbar, between the thumbs
				return UDim2.new(0.5, 0, 1, -128 + drop)
			end
			local e = use(edge)
			return UDim2.new(0, e, 1, -e + drop)
		end),
		Size = scope:Computed(function(use)
			return if use(onTouch) then UDim2.fromOffset(300, 30) else UDim2.fromOffset(380, 38)
		end),
		[Children] = healthBody,
	}

	--------------------------------------------------------------------
	-- Damage direction: red crescents around the crosshair that point at
	-- whoever hit you, and keep pointing as you turn
	--------------------------------------------------------------------

	type Arc = { angle: any, alpha: any, source: any, age: number, strength: number }
	local arcs: { Arc } = {}
	local arcFrames = {}
	for index = 1, 4 do
		local angle = scope:Value(0)
		local alpha = scope:Value(1)
		local arcColor = Kit.themed(scope, function(theme)
			return theme.Color.Callout or theme.Color.Bad
		end)
		local segments = {}
		local step = ARC_SPAN / (ARC_SEGMENTS - 1)
		local segmentLength = 2 * ARC_RADIUS * math.sin(math.rad(step) / 2) + 3
		for s = 1, ARC_SEGMENTS do
			local t = (s - 1) / (ARC_SEGMENTS - 1)
			local around = -90 + (t - 0.5) * ARC_SPAN
			local rad = math.rad(around)
			local fromMiddle = math.abs(t - 0.5) * 2
			table.insert(segments, scope:New "Frame" {
				Name = "Segment",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromOffset(math.cos(rad) * ARC_RADIUS, math.sin(rad) * ARC_RADIUS),
				Size = UDim2.fromOffset(segmentLength, 9 * (1 - 0.55 * fromMiddle ^ 1.5)),
				Rotation = around + 90,
				BackgroundColor3 = arcColor,
				BackgroundTransparency = scope:Computed(function(use)
					local a = use(alpha)
					return a + (1 - a) * (fromMiddle ^ 2) * 0.7
				end),
				BorderSizePixel = 0,
				ZIndex = 2,
				[Children] = Kit.corner(scope, "Full"),
			})
		end
		local frame = scope:New "Frame" {
			Name = `DamageArc{index}`,
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromOffset(0, 0),
			Rotation = angle,
			Visible = scope:Computed(function(use)
				return use(alpha) < 0.99
			end),
			[Children] = {
				scope:New "ImageLabel" {
					Name = "Glow",
					BackgroundTransparency = 1,
					Image = Assets.Glow,
					ImageColor3 = arcColor,
					ImageTransparency = scope:Computed(function(use)
						return 1 - (1 - use(alpha)) * 0.55
					end),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromOffset(0, -ARC_RADIUS + 4),
					Size = UDim2.fromOffset(ARC_RADIUS * 1.25, 70),
					ZIndex = 1,
				},
				segments,
				-- a small pointer notch outside the arc's middle
				scope:New "Frame" {
					Name = "Notch",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromOffset(0, -ARC_RADIUS - 12),
					Size = UDim2.fromOffset(9, 9),
					Rotation = 45,
					BackgroundColor3 = arcColor,
					BackgroundTransparency = alpha,
					BorderSizePixel = 0,
					ZIndex = 2,
					[Children] = scope:New "UICorner" { CornerRadius = UDim.new(0, 2) },
				},
			},
		}
		table.insert(arcs, { angle = angle, alpha = alpha, source = nil, age = math.huge, strength = 1 })
		table.insert(arcFrames, frame)
	end

	local arcsRunning: RBXScriptConnection? = nil
	local function stepArcs(dt: number)
		local busy = false
		for _, arc in arcs do
			if arc.age < ARC_LIFE then
				busy = true
				arc.age += dt
				local t = arc.age
				local a
				if t < 0.06 then
					a = 1 - t / 0.06
				elseif t < 0.6 then
					a = 0
				else
					a = math.clamp((t - 0.6) / (ARC_LIFE - 0.6), 0, 1)
				end
				arc.alpha:set(1 - (1 - a) * arc.strength)
				if arc.source ~= nil and typeof(arc.source) ~= "number" then
					local now = angleTo(arc.source)
					if now then
						arc.angle:set(now)
					end
				end
			elseif peek(arc.alpha) < 1 then
				arc.alpha:set(1)
			end
		end
		if not busy and arcsRunning then
			arcsRunning:Disconnect()
			arcsRunning = nil
		end
	end
	table.insert(scope, function()
		if arcsRunning then
			arcsRunning:Disconnect()
			arcsRunning = nil
		end
	end)

	local function showArc(source: any, amount: number)
		local angle = angleTo(source)
		if angle == nil then
			return
		end
		-- reuse the arc already pointing that way, else the oldest one
		local pick = arcs[1]
		for _, arc in arcs do
			if arc.age < ARC_LIFE and math.abs(((peek(arc.angle) - angle + 180) % 360) - 180) < 20 then
				pick = arc
				break
			end
			if arc.age > pick.age then
				pick = arc
			end
		end
		pick.source = source
		pick.age = 0
		pick.strength = math.clamp(0.55 + amount / 40, 0.55, 1)
		pick.angle:set(angle)
		stepArcs(0)
		if arcsRunning == nil then
			arcsRunning = RunService.RenderStepped:Connect(stepArcs)
		end
	end

	--------------------------------------------------------------------
	-- Elimination banner (under the crosshair)
	--------------------------------------------------------------------

	local bannerName = scope:Value("")
	local bannerAlphaGoal = scope:Value(1)
	local bannerAlpha = scope:Tween(bannerAlphaGoal, TweenInfo.new(0.1, Enum.EasingStyle.Quad, Enum.EasingDirection.Out))
	local bannerScaleGoal = scope:Value(1)
	local bannerScale = scope:Spring(bannerScaleGoal, 32, 0.55)
	local bannerToken = 0
	local lastKillAt = -math.huge

	local killBanner = scope:New "CanvasGroup" {
		Name = "KillBanner",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0.5, 168),
		Size = UDim2.fromOffset(520, 92),
		GroupTransparency = bannerAlpha,
		Visible = scope:Computed(function(use)
			return use(bannerAlpha) < 0.99
		end),
		[Children] = {
			scope:New "UIScale" { Scale = bannerScale },
			scope:New "UIListLayout" {
				SortOrder = Enum.SortOrder.LayoutOrder,
				HorizontalAlignment = Enum.HorizontalAlignment.Center,
				Padding = UDim.new(0, 0),
			},
			scope:Row {
				Name = "Top",
				LayoutOrder = 1,
				Size = UDim2.fromOffset(0, 28),
				AutoSize = "X",
				Gap = 8,
				Align = "Center",
				[Children] = {
					scope:New "Frame" {
						Name = "Line",
						LayoutOrder = 1,
						Size = UDim2.fromOffset(26, 3),
						BackgroundColor3 = Kit.themed(scope, function(theme)
							return theme.Color.Callout or theme.Color.Bad
						end),
						BorderSizePixel = 0,
					},
					scope:Text {
						Text = "ELIMINATED",
						TextSize = 20,
						Font = "Title",
						Color = Kit.themed(scope, function(theme)
							return (theme.Color.Callout or theme.Color.Bad):Lerp(WHITE, 0.15)
						end),
						StrokeColor = BLACK,
						Stroke = 1.5,
						StrokeTransparency = 0.72,
						Case = "Normal",
						LayoutOrder = 2,
					},
					scope:New "Frame" {
						Name = "Streak",
						LayoutOrder = 3,
						Size = UDim2.fromOffset(0, 24),
						AutomaticSize = Enum.AutomaticSize.X,
						BackgroundColor3 = Kit.themed(scope, function(theme)
							return theme.Color.Callout or theme.Color.Bad
						end),
						Visible = scope:Computed(function(use)
							return use(streak) > 1
						end),
						[Children] = {
							Kit.corner(scope, "S"),
							Kit.padding(scope, { 0, 7 }),
							scope:Text {
								Text = scope:Computed(function(use)
									return `×{use(streak)}`
								end),
								TextSize = 18,
								Font = "Display",
								Stroke = false,
								Size = UDim2.fromScale(0, 1),
								AutoSize = "X",
							},
						},
					},
					scope:New "Frame" {
						Name = "Line2",
						LayoutOrder = 4,
						Size = UDim2.fromOffset(26, 3),
						BackgroundColor3 = Kit.themed(scope, function(theme)
							return theme.Color.Callout or theme.Color.Bad
						end),
						BorderSizePixel = 0,
					},
				},
			},
			scope:Text {
				Name = "Victim",
				Text = bannerName,
				TextSize = 34,
				Font = "Title",
				StrokeColor = BLACK,
				Stroke = 2,
				StrokeTransparency = 0.68,
				Case = "Normal",
				LayoutOrder = 2,
			},
		},
	}

	local function showBanner(victim: string)
		bannerToken += 1
		local token = bannerToken
		local now = os.clock()
		streak:set(if now - lastKillAt < 4 then peek(streak) + 1 else 1)
		lastKillAt = now
		bannerName:set(victim)
		bannerAlphaGoal:set(0)
		bannerScale:setPosition(1.35)
		task.delay(1.5, function()
			if token == bannerToken then
				bannerAlphaGoal:set(1)
			end
		end)
	end

	--------------------------------------------------------------------
	-- Reload / low-ammo prompt (under the crosshair)
	--------------------------------------------------------------------

	local ammoStatus = scope:Computed(function(use)
		local weapon = weapons[use(equipped)]
		if weapon == nil or weapon.Ammo == nil then
			return "None"
		end
		local r = use(reloading)
		if typeof(r) == "number" and r < 1 then
			return "Reloading"
		end
		local ammo = use(weapon.Ammo) or 0
		local reserve = use(weapon.Reserve)
		if ammo <= 0 then
			return if reserve ~= nil and reserve <= 0 then "Empty" else "Reload"
		end
		local mag = use(weapon.MagSize)
		if mag and ammo <= math.max(1, math.floor(mag * 0.25)) then
			return if reserve ~= nil and reserve <= 0 then "Last" else "Low"
		end
		return "None"
	end)

	local promptColor = scope:Computed(function(use)
		local theme = use(Theme.current)
		local status = use(ammoStatus)
		if status == "Reload" or status == "Empty" then
			return (theme.Color.Callout or theme.Color.Bad):Lerp(WHITE, 0.1)
		elseif status == "Low" or status == "Last" then
			return theme.Color.Warn
		end
		return WHITE
	end)

	local ammoPrompt = scope:New "Frame" {
		Name = "AmmoPrompt",
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Pill or theme.Color.Surface
		end),
		BackgroundTransparency = 0.2,
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0.5, 46),
		Size = UDim2.fromOffset(0, 0),
		AutomaticSize = Enum.AutomaticSize.XY,
		Visible = scope:Computed(function(use)
			return use(ammoStatus) ~= "None"
		end),
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.padding(scope, { 4, 12, 5, 14 }),
			scope:New "UIListLayout" {
				SortOrder = Enum.SortOrder.LayoutOrder,
				HorizontalAlignment = Enum.HorizontalAlignment.Center,
				Padding = UDim.new(0, 3),
			},
			scope:Row {
				Name = "Line",
				LayoutOrder = 1,
				Size = UDim2.fromOffset(0, 28),
				AutoSize = "X",
				Gap = 8,
				Align = "Center",
				[Children] = {
					scope:Text {
						Name = "Status",
						Text = scope:Computed(function(use)
							local status = use(ammoStatus)
							if status == "Reloading" then
								return "RELOADING"
							elseif status == "Empty" then
								return "NO AMMO"
							elseif status == "Last" then
								return "LAST MAG"
							elseif status == "Low" then
								return "LOW AMMO"
							end
							return "RELOAD"
						end),
						TextSize = 17,
						Font = "Title",
						Color = promptColor,
						Stroke = false,
						Case = "Normal",
						LayoutOrder = 1,
					},
					scope:KeyCap {
						Key = cfg.ReloadKey or Enum.KeyCode.R,
						Gamepad = cfg.ReloadGamepad or Enum.KeyCode.ButtonX,
						Size = 24,
						LayoutOrder = 2,
						Visible = scope:Computed(function(use)
							local status = use(ammoStatus)
							return (status == "Reload" or status == "Low") and not use(onTouch)
						end),
					},
				},
			},
			scope:New "Frame" {
				Name = "Progress",
				LayoutOrder = 2,
				Size = UDim2.fromOffset(110, 3),
				BackgroundColor3 = WHITE,
				BackgroundTransparency = 0.8,
				BorderSizePixel = 0,
				Visible = scope:Computed(function(use)
					return use(ammoStatus) == "Reloading"
				end),
				[Children] = {
					Kit.corner(scope, "Full"),
					scope:New "Frame" {
						Name = "Fill",
						Size = scope:Computed(function(use)
							local r = use(reloading)
							return UDim2.fromScale(if typeof(r) == "number" then math.clamp(r, 0, 1) else 0, 1)
						end),
						BackgroundColor3 = WHITE,
						BorderSizePixel = 0,
						[Children] = Kit.corner(scope, "Full"),
					},
				},
			},
		},
	}
	-- low / empty prompts breathe so they're noticed without shouting
	table.insert(scope, Motion.pulse(ammoPrompt, 0.04, 0.9))

	--------------------------------------------------------------------
	-- Kill feed (top-right): rows slide in from the right and fade out
	--------------------------------------------------------------------

	local feedRows = scope:ForValues(feed, function(_, inner, entry)
		local slideGoal = inner:Value(70)
		local slide = inner:Spring(slideGoal, 30, 0.8)
		local fadeGoal = inner:Value(1)
		local fade = inner:Tween(fadeGoal, TweenInfo.new(0.15, Enum.EasingStyle.Quad, Enum.EasingDirection.Out))
		task.defer(function()
			slideGoal:set(0)
			fadeGoal:set(0)
		end)
		Kit.watch(inner, entry.Leaving, function(leaving)
			if leaving then
				fadeGoal:set(1)
				slideGoal:set(24)
			end
		end)
		local mine = entry.Mine
		return inner:New "Frame" {
			Name = "FeedEntry",
			LayoutOrder = -entry.Order,
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(0, 38),
			AutomaticSize = Enum.AutomaticSize.X,
			[Children] = inner:New "CanvasGroup" {
				Name = "Plate",
				Size = UDim2.fromScale(0, 1),
				AutomaticSize = Enum.AutomaticSize.X,
				Position = inner:Computed(function(use)
					return UDim2.fromOffset(use(slide), 0)
				end),
				GroupTransparency = fade,
				BackgroundColor3 = Kit.themed(inner, function(theme)
					return theme.Color.Pill or theme.Color.Surface
				end),
				BackgroundTransparency = if mine then 0.08 else 0.25,
				[Children] = {
					Kit.corner(inner, "M"),
					Kit.padding(inner, { 0, 14, 0, if mine then 16 else 14 }),
					Kit.list(inner, { Direction = "X", Padding = 10, VAlign = "Center" }),
					if mine
						then inner:New "Frame" {
							Name = "Accent",
							LayoutOrder = 0,
							Size = UDim2.fromOffset(4, 22),
							BackgroundColor3 = Kit.themed(inner, function(theme)
								return theme.Color.Accent
							end),
							BorderSizePixel = 0,
							[Children] = Kit.corner(inner, "Full"),
						}
						else nil,
					inner:Text {
						Text = entry.Killer,
						TextSize = 19,
						Font = "Body",
						Stroke = false,
						Case = "Normal",
						Color = if mine
							then Kit.themed(inner, function(theme)
								return theme.Color.Accent
							end)
							else nil,
						LayoutOrder = 1,
					},
					inner:Icon { Icon = entry.Icon, Size = 22, Stroke = false, LayoutOrder = 2 },
					inner:Text {
						Text = entry.Victim,
						TextSize = 19,
						Font = "Body",
						Stroke = false,
						Case = "Normal",
						Color = if entry.Victim == entry.Me
							then Kit.themed(inner, function(theme)
								return theme.Color.Callout or theme.Color.Bad
							end)
							else nil,
						LayoutOrder = 3,
					},
				},
			},
		}
	end)

	--------------------------------------------------------------------
	-- Screens
	--------------------------------------------------------------------

	-- full-bleed overlay: the low-health heartbeat vignette
	local overlay = scope:Screen {
		Name = (cfg.Name or "FPSHud") .. "Overlay",
		SafeArea = "None",
		DisplayOrder = (cfg.DisplayOrder or 5) - 1,
		[Children] = scope:Heartbeat {
			BPM = scope:Computed(function(use)
				return 80 + (1 - use(fraction)) * 70
			end),
			Intensity = scope:Computed(function(use)
				local f = use(fraction)
				return if f > 0 and f < 0.3 then (0.3 - f) / 0.3 * 0.55 + 0.2 else 0
			end),
			Color = Kit.themed(scope, function(theme)
				return theme.Color.Callout or theme.Color.Bad
			end),
		},
	}

	local gui = scope:Screen {
		Name = cfg.Name or "FPSHud",
		DisplayOrder = cfg.DisplayOrder or 5,
		[Children] = {
			arcFrames,

			if cfg.Crosshair ~= false
				then scope:Crosshair {
					Spread = scope:Computed(function(use)
						return use(spread) + use(recoil)
					end),
					Hits = hits,
					Crits = crits,
					Kills = kills,
				}
				else nil,

			killBanner,
			ammoPrompt,

			if cfg.Tasks
				then scope:QuestTracker {
					Title = cfg.TasksTitle or "Tasks",
					Icon = cfg.TasksIcon or "quest",
					Tasks = cfg.Tasks,
					Width = cfg.TasksWidth or 360,
					AnchorPoint = scope:Computed(function(use)
						return if use(onTouch) then Vector2.new(0, 0) else Vector2.new(0, 0.5)
					end),
					-- Arena themes draw a fade panel that is attached to the edge
					Position = scope:Computed(function(use)
						local theme = use(Theme.current)
						local attached = theme.Style and theme.Style.Archetype == "Arena"
						local x = (if attached then 0 else use(edge)) - slideIn(use, 380)
						if use(onTouch) then
							return UDim2.new(0, x, 0, use(safeTop) + 6)
						end
						return UDim2.new(0, x, 0.42, 0)
					end),
					[Children] = scope:New "UIScale" {
						Scale = scope:Computed(function(use)
							return if use(onTouch) then 0.9 else 1
						end),
					},
				}
				else nil,

			healthBar,

			if cfg.Weapons
				then scope:Hotbar {
					Slots = weapons,
					Selected = equipped,
					OnSelect = cfg.OnEquip,
					Reloading = reloading,
					-- the equipped slot already says which weapon it is on a phone
					ShowName = scope:Computed(function(use)
						return not use(onTouch)
					end),
					Align = scope:Computed(function(use)
						return if use(onTouch) then "Center" else "Right"
					end),
					-- touch screens keep the bottom-right free for the jump button
					AnchorPoint = scope:Computed(function(use)
						return if use(onTouch) then Vector2.new(0.5, 1) else Vector2.new(1, 1)
					end),
					Position = scope:Computed(function(use)
						local drop = slideIn(use, 160)
						if use(onTouch) then
							return UDim2.new(0.5, 0, 1, -14 + drop)
						end
						local e = use(edge)
						-- sits on top of the key hints pill when there is one
						return UDim2.new(1, -e + drop, 1, (if cfg.Hints and not use(onTouch) then -e - 48 else -e) + drop * 0.2)
					end),
				}
				else nil,

			if cfg.Hints
				then scope:KeyHints {
					Hints = cfg.Hints,
					AnchorPoint = Vector2.new(1, 1),
					Position = scope:Computed(function(use)
						local e = use(edge)
						return UDim2.new(1, -e, 1, -e + 2 + slideIn(use, 80))
					end),
				}
				else nil,

			scope:Column {
				Name = "KillFeed",
				AnchorPoint = Vector2.new(1, 0),
				Position = scope:Computed(function(use)
					return UDim2.new(1, -use(edge), 0, use(safeTop))
				end),
				Size = UDim2.fromOffset(520, 0),
				AutoSize = "Y",
				Gap = 6,
				Align = "Right",
				[Children] = feedRows,
			},
		},
	}

	local hud = {
		Gui = gui,
		Overlay = overlay,
		Health = health,
		MaxHealth = maxHealth,
		Equipped = equipped,
		Spread = spread,
		Reloading = reloading,
		Streak = streak,
		Kills = kills,
	}

	local feedOrder = 0
	function hud.feed(killer: string, victim: string, icon: string?)
		feedOrder += 1
		local player = Players.LocalPlayer
		local me = if player then player.DisplayName else nil
		local entry = {
			Killer = killer,
			Victim = victim,
			Icon = icon or "target",
			Order = feedOrder,
			Me = me,
			Mine = me ~= nil and killer == me,
			Leaving = scope:Value(false),
		}
		local list = table.clone(peek(feed))
		table.insert(list, entry)
		while #list > (cfg.FeedLength or 5) do
			table.remove(list, 1)
		end
		feed:set(list)
		task.delay(cfg.FeedTime or 6, function()
			entry.Leaving:set(true)
			task.wait(0.2)
			local now = table.clone(peek(feed))
			local index = table.find(now, entry)
			if index then
				table.remove(now, index)
				feed:set(now)
			end
		end)
	end

	function hud.hit(crit: boolean?)
		if crit then
			crits:set(peek(crits) + 1)
		else
			hits:set(peek(hits) + 1)
		end
	end

	function hud.kill(victim: string?, weaponIcon: string?)
		kills:set(peek(kills) + 1)
		showBanner(victim or "Enemy")
		if victim then
			local player = Players.LocalPlayer
			hud.feed(if player then player.DisplayName else "You", victim, weaponIcon)
		end
	end

	function hud.damage(amount: number, from: any?)
		if not cfg.BindCharacter then
			health:set(math.max(0, peek(health) - amount))
		end
		Screen.damage(math.clamp(0.35 + amount / 50, 0.35, 1))
		Screen.cameraShake(math.clamp(amount / 60, 0.15, 0.6), 0.3)
		if from ~= nil then
			showArc(from, amount)
		end
	end

	function hud.heal(amount: number)
		if not cfg.BindCharacter then
			health:set(math.min(peek(maxHealth), peek(health) + amount))
		end
	end

	function hud.recoil(amount: number)
		recoil:set(math.min(60, peek(recoil) + amount))
	end

	local reloadToken = 0
	function hud.reload(duration: number?, onDone: (() -> ())?)
		reloadToken += 1
		local token = reloadToken
		local length = duration or 1.2
		local elapsed = 0
		reloading:set(0)
		Sound.play("Click", { Pitch = 0.8, Volume = 0.4 })
		local connection
		connection = RunService.Heartbeat:Connect(function(dt)
			if token ~= reloadToken then
				connection:Disconnect()
				return
			end
			elapsed += dt
			if elapsed >= length then
				connection:Disconnect()
				reloading:set(false)
				Sound.play("Click", { Pitch = 1.3, Volume = 0.5 })
				if onDone then
					onDone()
				end
				return
			end
			reloading:set(elapsed / length)
		end)
		table.insert(scope, connection)
	end

	function hud.cancelReload()
		reloadToken += 1
		reloading:set(false)
	end

	return hud
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="246">
            <Properties>
              <string name="Name">HorrorHud</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs.HorrorHud: a minimal, diegetic horror HUD (DOORS / Apeirophobia):
	a typewritten objective, a hairline stamina bar that only appears while
	you use it, a flashlight battery, and a fear system that closes in the
	screen edges, makes the heart pound and the picture crawl with grain.

	local hud = UI.Prefabs.HorrorHud(scope, {
		Objective = "Find the basement key",
		Stamina = stamina,       -- 0..1 (Value or number)
		Battery = battery,       -- 0..1, omit to hide the battery
		Flashlight = lightOn,    -- Value<bool>: the battery dims while the light is off
		Fear = fear,             -- 0..1: tunnel vision, heartbeat, grain
		Darkness = 0.55,         -- constant soft vignette
		Hints = { { Text = "Flashlight", Key = Enum.KeyCode.F } },
		Reticle = false,         -- a faint centre dot (first-person games)
		DimObjective = 8,        -- seconds before the objective fades back (false = never)
	})

	hud.objective("Escape the house")   strikes out the old objective, types the new one
	hud.hint("Hold Shift to sprint")    a subtitle line at the bottom that types in and fades
	hud.interact("Open", { Key = Enum.KeyCode.E, Hold = progress })   look-at prompt
	hud.interact(nil)                   hide it
	hud.flicker(1.5)                    lights stutter
	hud.jumpscare({ Image = "rbxassetid://..." })
	hud.Fear / hud.Stamina / hud.Battery / hud.Objective / hud.Flashlight   (Values)
]]

local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Bind = require(script.Parent.Parent.Util.Bind)
local Input = require(script.Parent.Parent.Util.Input)
local Sound = require(script.Parent.Parent.Util.Sound)
local Format = require(script.Parent.Parent.Util.Format)
local Screen = require(script.Parent.Parent.FX.Screen)
local Assets = require(script.Parent.Parent.Assets)

local Children = Fusion.Children
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)
local BLACK = Color3.new(0, 0, 0)

-- deterministic noise so grain renders stay stable
local function seeded(seed: number): () -> number
	local state = seed
	return function()
		state = (state * 16807) % 2147483647
		return (state - 1) / 2147483646
	end
end

local function stripTags(text: string): string
	return (string.gsub(text, "<[^>]->", ""))
end

return function(scope: any, config: { [string]: any }?)
	local cfg = config or {}
	local stamina = Bind.asValue(scope, cfg.Stamina, 1)
	local battery = if cfg.Battery ~= nil then Bind.asValue(scope, cfg.Battery, 1) else nil
	local flashlight = Bind.asValue(scope, cfg.Flashlight, true)
	local fear = Bind.asValue(scope, cfg.Fear, 0)
	local objective = Bind.asValue(scope, cfg.Objective, "")

	local device = Input.device
	local onTouch = scope:Computed(function(use)
		return use(device) == "Touch"
	end)
	local edge = scope:Computed(function(use)
		return if use(device) == "Gamepad" then 48 else 32
	end)
	local safeTop = Kit.safeTop(scope, 90)

	local bone = Kit.themed(scope, function(theme)
		return theme.Color.Text
	end)
	local blood = Kit.themed(scope, function(theme)
		return theme.Color.Accent:Lerp(WHITE, 0.2)
	end)

	--------------------------------------------------------------------
	-- Overlay: darkness, tunnel vision, heartbeat and film grain
	--------------------------------------------------------------------

	local grainAlpha = scope:Computed(function(use)
		local f = math.clamp(use(fear), 0, 1)
		return if f > 0.35 then (f - 0.35) / 0.65 else 0
	end)
	local random = seeded(1337)
	local speckFrames = {}
	local speckStrength = {}
	local shownAlpha = peek(grainAlpha)
	for index = 1, 60 do
		local size = if random() > 0.85 then 3 else 2
		local alpha = random()
		local strength = 0.1 + alpha * 0.25
		table.insert(speckStrength, strength)
		table.insert(speckFrames, scope:New "Frame" {
			Name = "Speck",
			AnchorPoint = Vector2.new(0.5, 0.5),
			-- moved and faded directly each grain tick (not through state: cheaper)
			Position = UDim2.fromScale(random(), random()),
			Size = UDim2.fromOffset(size, size),
			BackgroundColor3 = if index % 3 == 0 then BLACK else WHITE,
			BackgroundTransparency = 1 - shownAlpha * strength,
			BorderSizePixel = 0,
		})
	end
	local grainClock = 0
	local lastAlpha = shownAlpha
	table.insert(scope, RunService.RenderStepped:Connect(function(dt)
		local grain = peek(grainAlpha)
		if grain <= 0 then
			return
		end
		grainClock += dt
		if grainClock < 0.05 then
			return
		end
		grainClock = 0
		-- re-fade the specks only when fear has really moved (or settled on
		-- a new value), not on every tiny change
		if grain ~= shownAlpha and (math.abs(grain - shownAlpha) > 0.01 or grain == lastAlpha) then
			shownAlpha = grain
			for index, speck in speckFrames do
				speck.BackgroundTransparency = 1 - grain * speckStrength[index]
			end
		end
		lastAlpha = grain
		for _, speck in speckFrames do
			speck.Position = UDim2.fromScale(math.random(), math.random())
		end
	end))

	local overlay = scope:Screen {
		Name = "HorrorOverlay",
		SafeArea = "None",
		DisplayOrder = cfg.DisplayOrder and cfg.DisplayOrder - 1 or 0,
		[Children] = {
			scope:Vignette {
				Name = "Darkness",
				Intensity = cfg.Darkness or 0.55,
				Style = "Soft",
			},
			scope:Vignette {
				Name = "Tunnel",
				Style = "Tunnel",
				Intensity = scope:Computed(function(use)
					return math.clamp(use(fear), 0, 1) * 0.95
				end),
				Spread = scope:Computed(function(use)
					return 2.2 - math.clamp(use(fear), 0, 1) * 0.9
				end),
			},
			scope:Heartbeat {
				BPM = scope:Computed(function(use)
					return 60 + math.clamp(use(fear), 0, 1) * 110
				end),
				Intensity = scope:Computed(function(use)
					local f = use(fear)
					return if f > 0.35 then (f - 0.35) / 0.65 * 0.8 else 0
				end),
				Sound = cfg.HeartbeatSound,
			},
			scope:New "Frame" {
				Name = "Grain",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				Visible = scope:Computed(function(use)
					return use(grainAlpha) > 0
				end),
				[Children] = speckFrames,
			},
		},
	}

	--------------------------------------------------------------------
	-- Objective (top-left): typed, the old one struck out, dims when idle
	--------------------------------------------------------------------

	local objectiveText = scope:Value("")
	local objectiveAlphaGoal = scope:Value(0)
	local objectiveAlpha = scope:Tween(objectiveAlphaGoal, TweenInfo.new(0.6, Enum.EasingStyle.Quad, Enum.EasingDirection.Out))
	local jitter = scope:Value(Vector2.zero)

	-- at high fear the words won't sit still
	local jitterClock = 0
	table.insert(scope, RunService.RenderStepped:Connect(function(dt)
		local f = peek(fear)
		if f < 0.7 then
			if peek(jitter) ~= Vector2.zero then
				jitter:set(Vector2.zero)
			end
			return
		end
		jitterClock += dt
		if jitterClock > 0.07 then
			jitterClock = 0
			local amount = (f - 0.7) / 0.3 * 2
			jitter:set(Vector2.new((math.random() - 0.5) * amount, (math.random() - 0.5) * amount))
		end
	end))

	local objectiveLabel = scope:Text {
		Name = "ObjectiveText",
		Text = objectiveText,
		RichText = true,
		TextSize = 30,
		Font = "Display",
		XAlign = "Left",
		Case = "Normal",
		Stroke = 1.5,
		StrokeColor = BLACK,
		StrokeTransparency = 0.35,
		Transparency = objectiveAlpha,
		LayoutOrder = 2,
	}

	local objectiveColumn = scope:Column {
		Name = "Objective",
		AnchorPoint = Vector2.new(0, 0),
		Position = scope:Computed(function(use)
			local j = use(jitter)
			return UDim2.fromOffset(use(edge) + j.X, use(safeTop) + j.Y)
		end),
		Size = UDim2.fromOffset(640, 0),
		AutoSize = "Y",
		Gap = 4,
		Align = "Left",
		Visible = scope:Computed(function(use)
			return use(objectiveText) ~= ""
		end),
		[Children] = {
			scope:Row {
				Name = "Header",
				LayoutOrder = 1,
				Size = UDim2.fromOffset(0, 22),
				AutoSize = "X",
				Gap = 10,
				[Children] = {
					scope:New "Frame" {
						Name = "Bar",
						LayoutOrder = 1,
						Size = UDim2.fromOffset(28, 2),
						BackgroundColor3 = blood,
						BackgroundTransparency = scope:Computed(function(use)
							return 0.1 + use(objectiveAlpha) * 0.5
						end),
						BorderSizePixel = 0,
					},
					scope:Text {
						Text = "OBJECTIVE",
						TextSize = 17,
						Font = "Body",
						Color = blood,
						Stroke = 1.5,
						StrokeColor = BLACK,
						StrokeTransparency = 0.4,
						Transparency = scope:Computed(function(use)
							return use(objectiveAlpha) * 0.6
						end),
						LayoutOrder = 2,
					},
				},
			},
			objectiveLabel,
		},
	}

	local typingToken = 0
	local lastObjective = ""
	local function showObjective(text: string)
		typingToken += 1
		local token = typingToken
		text = text or ""
		local old = lastObjective
		lastObjective = text
		task.spawn(function()
			-- strike out and fade the old objective first
			if old ~= "" and peek(objectiveText) ~= "" then
				objectiveText:set(`<s>{old}</s>`)
				objectiveAlphaGoal:set(0.2)
				task.wait(0.35)
				if token ~= typingToken then
					return
				end
				objectiveAlphaGoal:set(1)
				task.wait(0.45)
				if token ~= typingToken then
					return
				end
			end
			if text == "" then
				objectiveText:set("")
				return
			end
			objectiveAlphaGoal:set(0)
			local plain = stripTags(text)
			local shown = 0
			for _, codepoint in utf8.codes(plain) do
				if token ~= typingToken then
					return
				end
				shown += 1
				local offset = utf8.offset(plain, shown + 1) or (#plain + 1)
				objectiveText:set(string.sub(plain, 1, offset - 1) .. "_")
				local char = utf8.char(codepoint)
				if char ~= " " then
					Sound.play("Tick", { Volume = 0.22, Pitch = 1.15 + math.random() * 0.3 })
				end
				task.wait(if char == "." or char == "," then 0.14 else 1 / 26)
			end
			if token ~= typingToken then
				return
			end
			objectiveText:set(text)
			-- the caret blinks a few times, then the line settles
			for _ = 1, 3 do
				task.wait(0.28)
				if token ~= typingToken then
					return
				end
				objectiveText:set(text .. "_")
				task.wait(0.28)
				if token ~= typingToken then
					return
				end
				objectiveText:set(text)
			end
			local dimAfter = cfg.DimObjective
			if dimAfter ~= false then
				task.wait(dimAfter or 8)
				if token == typingToken then
					objectiveAlphaGoal:set(0.45)
				end
			end
		end)
	end

	--------------------------------------------------------------------
	-- Stamina: a hairline that drains from both ends, only while used
	--------------------------------------------------------------------

	local staminaShown = scope:Value(peek(stamina) < 0.995)
	local staminaHideToken = 0
	Kit.watch(scope, stamina, function(now)
		if now < 0.995 then
			staminaHideToken += 1
			if not peek(staminaShown) then
				staminaShown:set(true)
			end
		elseif peek(staminaShown) then
			staminaHideToken += 1
			local token = staminaHideToken
			task.delay(1.1, function()
				if token == staminaHideToken then
					staminaShown:set(false)
				end
			end)
		end
	end)
	local staminaFade = scope:Tween(scope:Computed(function(use)
		return if use(staminaShown) then 0 else 1
	end), TweenInfo.new(0.35, Enum.EasingStyle.Quad, Enum.EasingDirection.Out))
	local exhaustedPulse = scope:Value(0)
	table.insert(scope, RunService.RenderStepped:Connect(function()
		local s = peek(stamina)
		if s < 0.25 and peek(staminaShown) then
			exhaustedPulse:set(0.5 + 0.5 * math.sin(os.clock() * 9))
		elseif peek(exhaustedPulse) ~= 0 then
			exhaustedPulse:set(0)
		end
	end))

	local staminaBar = scope:New "Frame" {
		Name = "Stamina",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -44),
		Size = UDim2.fromOffset(420, 4),
		BackgroundColor3 = BLACK,
		BackgroundTransparency = scope:Computed(function(use)
			return 0.55 + 0.45 * use(staminaFade)
		end),
		BorderSizePixel = 0,
		Visible = scope:Computed(function(use)
			return use(staminaFade) < 0.99
		end),
		[Children] = {
			Kit.corner(scope, "Full"),
			scope:New "Frame" {
				Name = "Fill",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = scope:Computed(function(use)
					return UDim2.fromScale(math.clamp(use(stamina), 0, 1), 1)
				end),
				BackgroundColor3 = scope:Computed(function(use)
					local theme = use(Theme.current)
					if use(stamina) < 0.25 then
						return theme.Color.Bad:Lerp(theme.Color.Text, 0.25 * use(exhaustedPulse))
					end
					return theme.Color.Text
				end),
				BackgroundTransparency = scope:Computed(function(use)
					return 0.1 + 0.9 * use(staminaFade)
				end),
				BorderSizePixel = 0,
				[Children] = Kit.corner(scope, "Full"),
			},
		},
	}

	--------------------------------------------------------------------
	-- Flashlight battery: a drawn battery with four cells
	--------------------------------------------------------------------

	local batteryWidget = nil
	if battery then
		local low = scope:Computed(function(use)
			return use(battery) < 0.2
		end)
		local blink = scope:Value(0)
		table.insert(scope, RunService.RenderStepped:Connect(function()
			if peek(battery) < 0.1 and peek(battery) > 0 then
				blink:set(if math.floor(os.clock() * 2.5) % 2 == 0 then 1 else 0)
			elseif peek(blink) ~= 0 then
				blink:set(0)
			end
		end))
		local lineColor = scope:Computed(function(use)
			return if use(low) then use(blood) else use(bone)
		end)
		local cells = {}
		for index = 1, 4 do
			table.insert(cells, scope:New "Frame" {
				Name = `Cell{index}`,
				LayoutOrder = index,
				Size = UDim2.fromOffset(8, 12),
				BackgroundColor3 = lineColor,
				BackgroundTransparency = scope:Computed(function(use)
					local level = math.clamp(use(battery), 0, 1) * 4
					local lit = math.clamp(level - (index - 1), 0, 1)
					if lit <= 0 then
						return 0.92
					end
					return (1 - lit) * 0.6 + use(blink) * 0.8
				end),
				BorderSizePixel = 0,
				[Children] = scope:New "UICorner" { CornerRadius = UDim.new(0, 1) },
			})
		end
		batteryWidget = scope:New "CanvasGroup" {
			Name = "Battery",
			BackgroundTransparency = 1,
			AnchorPoint = scope:Computed(function(use)
				return if use(onTouch) then Vector2.new(1, 0) else Vector2.new(0, 1)
			end),
			Position = scope:Computed(function(use)
				local e = use(edge)
				-- phones: top-right, away from the thumbstick
				if use(onTouch) then
					return UDim2.new(1, -e, 0, use(safeTop))
				end
				return UDim2.new(0, e, 1, -e + 4)
			end),
			Size = UDim2.fromOffset(150, 32),
			GroupTransparency = scope:Tween(scope:Computed(function(use)
				return if use(flashlight) then 0 else 0.55
			end), TweenInfo.new(0.25)),
			[Children] = {
				scope:New "Frame" {
					Name = "Body",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.fromScale(0, 0.5),
					Size = UDim2.fromOffset(48, 22),
					BackgroundColor3 = BLACK,
					BackgroundTransparency = 0.5,
					[Children] = {
						Kit.corner(scope, 3),
						scope:New "UIStroke" {
							Color = lineColor,
							Thickness = 2,
							Transparency = 0.1,
						},
						Kit.padding(scope, { 5, 4, 5, 4 }),
						scope:New "UIListLayout" {
							FillDirection = Enum.FillDirection.Horizontal,
							SortOrder = Enum.SortOrder.LayoutOrder,
							Padding = UDim.new(0, 2),
							VerticalAlignment = Enum.VerticalAlignment.Center,
						},
						cells,
					},
				},
				scope:New "Frame" {
					Name = "Nub",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 49, 0.5, 0),
					Size = UDim2.fromOffset(4, 10),
					BackgroundColor3 = lineColor,
					BorderSizePixel = 0,
					[Children] = scope:New "UICorner" { CornerRadius = UDim.new(0, 1) },
				},
				scope:Text {
					Name = "Percent",
					Text = scope:Computed(function(use)
						return Format.percent(math.clamp(use(battery), 0, 1))
					end),
					TextSize = 19,
					Font = "Display",
					Color = scope:Computed(function(use)
						return if use(low) then use(blood) else use(bone)
					end),
					Stroke = 1.5,
					StrokeColor = BLACK,
					StrokeTransparency = 0.4,
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 64, 0.5, 1),
				},
			},
		}
	end

	--------------------------------------------------------------------
	-- Look-at interaction prompt (centre)
	--------------------------------------------------------------------

	local interactText = scope:Value("")
	local interactKey = scope:Value(Enum.KeyCode.E)
	local interactPad = scope:Value(Enum.KeyCode.ButtonX)
	-- hold progress (0..1) or nil; a caller's state is mirrored in, never use()d
	-- directly, so it may be created after (and live shorter than) this HUD
	local interactHold = scope:Value(nil)
	local holdMirror: RBXScriptConnection? = nil
	local function stopMirror()
		if holdMirror then
			holdMirror:Disconnect()
			holdMirror = nil
		end
	end
	table.insert(scope, stopMirror)
	local interactGoal = scope:Value(0)
	local interactShow = scope:Spring(interactGoal, 22, 0.8)
	local reticleOn = cfg.Reticle == true

	local interactFrame = scope:New "Frame" {
		Name = "Interact",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(0, 0),
		[Children] = {
			scope:New "Frame" {
				Name = "Dot",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Size = UDim2.fromOffset(5, 5),
				BackgroundColor3 = bone,
				BackgroundTransparency = scope:Computed(function(use)
					local base = if reticleOn then 0.45 else 1
					return base - (base - 0.1) * use(interactShow)
				end),
				BorderSizePixel = 0,
				[Children] = Kit.corner(scope, "Full"),
			},
			scope:New "Frame" {
				Name = "Ring",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Size = scope:Computed(function(use)
					local s = 18 + 16 * use(interactShow)
					return UDim2.fromOffset(s, s)
				end),
				BackgroundTransparency = 1,
				Visible = scope:Computed(function(use)
					return use(interactShow) > 0.02
				end),
				[Children] = {
					Kit.corner(scope, "Full"),
					scope:New "UIStroke" {
						Color = bone,
						Thickness = 1.5,
						Transparency = scope:Computed(function(use)
							return 1 - 0.7 * math.clamp(use(interactShow), 0, 1)
						end),
					},
				},
			},
			scope:New "CanvasGroup" {
				Name = "Label",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(0.5, 0),
				Position = scope:Computed(function(use)
					return UDim2.fromOffset(0, 26 + 8 * (1 - use(interactShow)))
				end),
				Size = UDim2.fromOffset(360, 60),
				GroupTransparency = scope:Computed(function(use)
					return 1 - math.clamp(use(interactShow), 0, 1)
				end),
				Visible = scope:Computed(function(use)
					return use(interactShow) > 0.02
				end),
				[Children] = {
					scope:New "UIListLayout" {
						SortOrder = Enum.SortOrder.LayoutOrder,
						HorizontalAlignment = Enum.HorizontalAlignment.Center,
						Padding = UDim.new(0, 6),
					},
					scope:Row {
						LayoutOrder = 1,
						Size = UDim2.fromOffset(0, 30),
						AutoSize = "X",
						Gap = 10,
						Align = "Center",
						[Children] = {
							scope:KeyCap {
								Key = interactKey,
								Gamepad = interactPad,
								Size = 28,
								LayoutOrder = 1,
								Visible = scope:Computed(function(use)
									return not use(onTouch)
								end),
							},
							scope:Text {
								Text = interactText,
								TextSize = 22,
								Font = "Display",
								Case = "Normal",
								Stroke = 1.5,
								StrokeColor = BLACK,
								StrokeTransparency = 0.3,
								LayoutOrder = 2,
							},
						},
					},
					scope:New "Frame" {
						Name = "Hold",
						LayoutOrder = 2,
						Size = UDim2.fromOffset(110, 2),
						BackgroundColor3 = bone,
						BackgroundTransparency = 0.75,
						BorderSizePixel = 0,
						Visible = scope:Computed(function(use)
							return use(interactHold) ~= nil
						end),
						[Children] = scope:New "Frame" {
							Name = "Fill",
							Size = scope:Computed(function(use)
								local hold = use(interactHold)
								return UDim2.fromScale(if typeof(hold) == "number" then math.clamp(hold, 0, 1) else 0, 1)
							end),
							BackgroundColor3 = bone,
							BorderSizePixel = 0,
						},
					},
				},
			},
		},
	}

	--------------------------------------------------------------------
	-- Subtitle hints (bottom centre)
	--------------------------------------------------------------------

	local hintText = scope:Value("")
	local hintGoal = scope:Value(1)
	local hintAlpha = scope:Tween(hintGoal, TweenInfo.new(0.5, Enum.EasingStyle.Quad, Enum.EasingDirection.Out))
	local hintFrame = scope:New "Frame" {
		Name = "Hint",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -92),
		Size = UDim2.fromOffset(900, 52),
		BackgroundColor3 = BLACK,
		BackgroundTransparency = scope:Computed(function(use)
			return 0.45 + 0.55 * use(hintAlpha)
		end),
		BorderSizePixel = 0,
		Visible = scope:Computed(function(use)
			return use(hintAlpha) < 0.99 and use(hintText) ~= ""
		end),
		[Children] = {
			-- soft edges instead of a box
			scope:New "UIGradient" {
				Transparency = NumberSequence.new({
					NumberSequenceKeypoint.new(0, 1),
					NumberSequenceKeypoint.new(0.22, 0),
					NumberSequenceKeypoint.new(0.78, 0),
					NumberSequenceKeypoint.new(1, 1),
				}),
			},
			scope:Text {
				Text = hintText,
				RichText = true,
				TextSize = 23,
				Font = "Body",
				Case = "Normal",
				Stroke = false,
				Transparency = hintAlpha,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
			},
		},
	}

	--------------------------------------------------------------------
	-- Screen
	--------------------------------------------------------------------

	local hudVisible = scope:Value(true)
	local gui = scope:Screen {
		Name = cfg.Name or "HorrorHud",
		DisplayOrder = cfg.DisplayOrder or 5,
		Enabled = hudVisible,
		[Children] = {
			objectiveColumn,
			staminaBar,
			batteryWidget,
			interactFrame,
			hintFrame,
			if cfg.Hints
				then scope:KeyHints {
					Hints = cfg.Hints,
					AnchorPoint = Vector2.new(1, 1),
					Position = scope:Computed(function(use)
						local e = use(edge)
						return UDim2.new(1, -e, 1, -e + 8)
					end),
				}
				else nil,
		},
	}

	showObjective(peek(objective))
	Kit.watch(scope, objective, showObjective)

	local hud = {
		Gui = gui,
		Overlay = overlay,
		Fear = fear,
		Stamina = stamina,
		Battery = battery,
		Flashlight = flashlight,
		Objective = objective,
	}

	function hud.objective(text: string, announce: boolean?)
		objective:set(text)
		Sound.play("Notify")
		if announce ~= false then
			Screen.flicker(0.5, 0.6)
		end
	end

	-- brings a dimmed objective back to full strength for a few seconds
	function hud.showObjective()
		showObjective(peek(objective))
	end

	local hintToken = 0
	function hud.hint(text: string, duration: number?)
		hintToken += 1
		local token = hintToken
		hintText:set(text)
		hintGoal:set(0)
		task.delay(duration or 4, function()
			if token == hintToken then
				hintGoal:set(1)
			end
		end)
	end

	function hud.interact(text: string?, options: { [string]: any }?)
		stopMirror()
		if text == nil or text == "" then
			interactGoal:set(0)
			interactHold:set(nil)
			return
		end
		local opts = options or {}
		interactText:set(text)
		interactKey:set(opts.Key or Enum.KeyCode.E)
		interactPad:set(opts.Gamepad or Enum.KeyCode.ButtonX)
		local hold = opts.Hold
		if typeof(hold) == "table" and hold.type == "State" then
			interactHold:set(peek(hold))
			holdMirror = RunService.RenderStepped:Connect(function()
				local now = peek(hold)
				if now ~= peek(interactHold) then
					interactHold:set(now)
				end
			end)
		else
			interactHold:set(hold)
		end
		interactGoal:set(1)
	end

	function hud.flicker(duration: number?)
		Screen.flicker(duration or 1.2)
	end

	-- The default scare (no image given): the lights die and two red eyes
	-- lunge out of the dark. Pass { Image = "rbxassetid://..." } for your own.
	local scareEyes = {}
	for index, x in { 0.43, 0.57 } do
		-- a thin vertical slit, hot white
		local core = scope:New "ImageLabel" {
			Name = "Pupil",
			BackgroundTransparency = 1,
			Image = Assets.Circle,
			ImageColor3 = Color3.fromRGB(255, 244, 214),
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.55),
			Size = UDim2.fromScale(0.045, 0.3),
			ZIndex = 3,
		}
		local eye = scope:New "Frame" {
			Name = `Eye{index}`,
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(x, 0.45),
			Size = UDim2.fromOffset(300, 180),
			-- inner corners low: a glare, not a cartoon stare
			Rotation = if index == 1 then 15 else -15,
			[Children] = {
				scope:New "UIScale" { Name = "Lunge", Scale = 0.3 },
				scope:New "ImageLabel" {
					Name = "Glow",
					BackgroundTransparency = 1,
					Image = Assets.Glow,
					ImageColor3 = Color3.fromRGB(200, 12, 20),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Size = UDim2.fromScale(1.6, 1.4),
					ZIndex = 1,
				},
				scope:New "ImageLabel" {
					Name = "Iris",
					BackgroundTransparency = 1,
					Image = Assets.Circle,
					ImageColor3 = Color3.fromRGB(170, 4, 12),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.55),
					Size = UDim2.fromScale(0.5, 0.36),
					ZIndex = 2,
				},
				core,
			},
		}
		table.insert(scareEyes, eye)
	end
	local scareFrame = scope:New "Frame" {
		Name = "Scare",
		BackgroundColor3 = BLACK,
		BackgroundTransparency = 0,
		Size = UDim2.fromScale(1, 1),
		Visible = false,
		[Children] = scareEyes,
	}
	scope:Screen {
		Name = "HorrorScare",
		SafeArea = "None",
		-- over the HUD and popups, under the FX layer so the red flash lands on top
		DisplayOrder = 999,
		[Children] = scareFrame,
	}

	local function eyesScare(options: { [string]: any })
		scareFrame.BackgroundTransparency = 0
		scareFrame.Visible = true
		for _, eye in scareEyes do
			local lunge = eye:FindFirstChild("Lunge") :: UIScale
			lunge.Scale = 0.3
			TweenService:Create(lunge, TweenInfo.new(0.16, Enum.EasingStyle.Back, Enum.EasingDirection.Out), { Scale = 1.7 }):Play()
			for _, part in eye:GetChildren() do
				if part:IsA("ImageLabel") then
					part.ImageTransparency = 0
				end
			end
		end
		Sound.play(options.Sound or "Error", { Volume = 2 })
		Screen.flash(Color3.fromRGB(150, 0, 8), 0.45, 0.7)
		Screen.cameraShake(1, 0.8)
		task.delay(options.Duration or 0.75, function()
			local info = TweenInfo.new(0.4, Enum.EasingStyle.Quad, Enum.EasingDirection.In)
			for _, eye in scareEyes do
				for _, part in eye:GetChildren() do
					if part:IsA("ImageLabel") then
						TweenService:Create(part, info, { ImageTransparency = 1 }):Play()
					end
				end
			end
			task.wait(0.35)
			TweenService:Create(scareFrame, TweenInfo.new(0.5), { BackgroundTransparency = 1 }):Play()
			task.wait(0.5)
			scareFrame.Visible = false
		end)
	end

	-- the HUD vanishes while the scare owns the screen, then creeps back
	function hud.jumpscare(options: { [string]: any }?)
		local opts = options or {}
		hudVisible:set(false)
		if opts.Image then
			Screen.jumpscare(opts)
		else
			eyesScare(opts)
		end
		fear:set(1)
		task.delay(1.4, function()
			hudVisible:set(true)
		end)
	end

	return hud
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="247">
            <Properties>
              <string name="Name">Layouts</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs.Layouts (v2.4): the section layouts Blueprint.Schema lists in
	SECTION_LAYOUTS_V24. Blueprint.Sections hands a section here when its
	kind + layout is one of them:

		local Layouts = require(BloxUI.Prefabs.Layouts)
		if Layouts.has(section) then
			local content, height = Layouts.build(ctx, scope, section, width, info, helpers)
		end
		Layouts.measure(section, width, info, helpers)   --> the same height, unbuilt

	  Products  Grid | Showcase | Wall | Passes | Mosaic      (Layouts.Store)
	  Cases     Tiers | Banner                              (Layouts.Crates)
	  Daily     Path;  Gifts  Chests                        (Layouts.Rewards)
	  Inventory Filters | Team                              (Layouts.Collection)

	`helpers` are Blueprint.Sections' own pieces (item tiles, the detail
	panel, the item grid, the streak strip, reward state, case opening...),
	so these layouts behave exactly like the v1 ones they sit beside.
]]

local Store = require(script.Store)
local Crates = require(script.Crates)
local Rewards = require(script.Rewards)
local Collection = require(script.Collection)

local Layouts = {}

local MODULES = {
	Products = Store,
	Cases = Crates,
	Daily = Rewards,
	Gifts = Rewards,
	Inventory = Collection,
}

-- The layouts each kind has here.
local ALLOWED = {
	Products = { Grid = true, Showcase = true, Wall = true, Passes = true, Mosaic = true },
	Cases = { Tiers = true, Banner = true },
	Daily = { Path = true },
	Gifts = { Chests = true },
	Inventory = { Filters = true, Team = true },
}

-- The list a section kind can't do without (empty lists keep the v1 empty state).
local LISTS = { Products = "items", Cases = "cases", Daily = "days", Gifts = "gifts", Inventory = "items" }

-- True when this module draws the section (a v2.4 layout with content).
function Layouts.has(s: { [string]: any }): boolean
	local allowed = ALLOWED[s.kind]
	if allowed == nil or typeof(s.layout) ~= "string" or not allowed[s.layout] then
		return false
	end
	local list = s[LISTS[s.kind]]
	return typeof(list) == "table" and #list > 0
end

function Layouts.build(ctx, scope, s: { [string]: any }, width: number, info: any, helpers: { [string]: any }): (Instance, number)
	return MODULES[s.kind].build[s.layout](ctx, scope, s, width, info, helpers)
end

-- The height build gives, without building (nil: build it to know).
function Layouts.measure(s: { [string]: any }, width: number, info: any, helpers: { [string]: any }): number?
	if not Layouts.has(s) then
		return nil
	end
	local measure = MODULES[s.kind].measure[s.layout]
	if measure == nil then
		return nil
	end
	return measure(s, width, info, helpers)
end

-- Every layout id this module draws, per kind (tests keep it equal to
-- Blueprint.Schema.SECTION_LAYOUTS_V24).
function Layouts.ids(): { [string]: { string } }
	local out = {}
	for kind, allowed in ALLOWED do
		out[kind] = {}
		for id in allowed do
			table.insert(out[kind], id)
		end
		table.sort(out[kind])
	end
	return out
end

Layouts.Store = Store
Layouts.Crates = Crates
Layouts.Rewards = Rewards
Layouts.Collection = Collection

return Layouts
]]></string>
            </Properties>
            <Item class="ModuleScript" referent="248">
              <Properties>
                <string name="Name">Collection</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs.Layouts.Collection: the v2.4 Inventory layouts.

	  Filters  rarity filter chips (with counts) and the total over the grid;
	           a chip narrows the grid, a detail bar underneath equips
	  Team     the equipped team on top (s.slots slots, each showing what it
	           adds) with the team's total boost; every owned one below with
	           its power ("x12"); tap to add to / take off the team
]]

local Fusion = require(script.Parent.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Parent.Util.Format)
local Common = require(script.Parent.Parent.Parent.Blueprint.Common)
local Schema = require(script.Parent.Parent.Parent.Blueprint.Schema)
local U = require(script.Parent.Util)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local Collection = { build = {}, measure = {} }

local GAP = U.GAP
local CHIPS_H = 44

--------------------------------------------------------------------------
-- Filters
--------------------------------------------------------------------------

local function filtersGeometry(s, width: number, info, H): { [string]: any }
	local header = if s.title then H.HEADER + H.HEADER_GAP else 0
	local room = (info.available or info.pageHeight) - header
	local barH = if width >= 560 then (if width >= 900 then 176 else 160) else 0
	local above = CHIPS_H + 14
	local below = if barH > 0 then GAP + barH else 0
	local natural = H.gridNatural(width, #s.items)
	local gridH = math.clamp(math.min(natural, room - above - below), 220, math.max(220, info.pageHeight - header - above - below))
	return { barH = barH, above = above, gridH = gridH, height = above + gridH + below }
end

Collection.build.Filters = function(ctx, scope, s, width, info, H)
	local g = filtersGeometry(s, width, info, H)
	local filter = ctx.ui:state(s.id, "filter", "All")
	local shown = scope:Computed(function(use)
		local wanted = use(filter)
		local out = {}
		for _, item in s.items do
			if wanted == "All" or item.rarity == wanted then
				table.insert(out, item)
			end
		end
		return out
	end)
	local selectedId = ctx.ui:state(s.id, "selected", s.items[1].id)
	local selectedItem = scope:Computed(function(use)
		local id = use(selectedId)
		for _, item in s.items do
			if item.id == id then
				return item
			end
		end
		return nil
	end)
	local function select(item)
		selectedId:set(item.id)
		ctx.ui:emit(s.id, { id = s.id, screen = info.screen, payload = item.id })
	end
	-- one chip per rarity the collection holds, rarest last
	local counts: { [string]: number } = {}
	for _, item in s.items do
		counts[item.rarity] = (counts[item.rarity] or 0) + 1
	end
	local names = { "All" }
	for _, rarity in Schema.RARITIES do
		if counts[rarity] then
			table.insert(names, rarity)
		end
	end
	local chips = {}
	local used = 0
	local countW = 150
	-- a chip's label: white on its lit colour, the panel's ink when it's off
	local ink = H.panelInk(scope, ctx, info)
	for index, name in names do
		local label = if name == "All" then `All {#s.items}` else `{name} {counts[name]}`
		local chipW = Common.textWidth(label, 19) + (if name == "All" then 32 else 50)
		-- chips that don't fit the row are left out (All always stays)
		if used + chipW > width - countW and index > 1 then
			break
		end
		used += chipW + 8
		local active = scope:Computed(function(use)
			return use(filter) == name
		end)
		table.insert(chips, scope:New "TextButton" {
			Name = `Chip{name}`,
			Text = "",
			AutoButtonColor = false,
			LayoutOrder = index,
			Size = UDim2.fromOffset(chipW, 40),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				if name == "All" then
					return theme.Color.Accent
				end
				return Common.rarityTone(theme, name).Bottom
			end),
			BackgroundTransparency = scope:Computed(function(use)
				return if use(active) then 0 else 0.72
			end),
			[OnEvent "Activated"] = function()
				filter:set(name)
			end,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.stroke(scope, {
					Color = Kit.themed(scope, function(theme)
						if name == "All" then
							return theme.Color.Accent
						end
						return Common.rarityTone(theme, name).Top
					end),
					Thickness = 2,
				}),
				if name ~= "All"
					then scope:New "Frame" {
						Name = "Dot",
						AnchorPoint = Vector2.new(0, 0.5),
						Position = UDim2.new(0, 14, 0.5, 0),
						Size = UDim2.fromOffset(14, 14),
						BackgroundColor3 = Kit.themed(scope, function(theme)
							return Common.rarityTone(theme, name).Top
						end),
						ZIndex = 2,
						[Children] = Kit.corner(scope, "Full"),
					}
					else nil,
				scope:Text {
					Name = "Label",
					Text = label,
					TextSize = 19,
					Font = "Heavy",
					Color = scope:Computed(function(use)
						return if use(active) then Color3.new(1, 1, 1) else use(ink).text
					end),
					Stroke = scope:Computed(function(use)
						return if use(ink).light and not use(active) then false else 1.5
					end),
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, if name == "All" then 16 else 34, 0.5, 0),
					ZIndex = 3,
				},
			},
		})
	end
	local bar = scope:New "Frame" {
		Name = "Filters",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, CHIPS_H),
		[Children] = {
			scope:Row {
				Name = "Chips",
				Size = UDim2.new(1, -countW, 1, 0),
				Gap = 8,
				Align = "Left",
				VAlign = "Center",
				[Children] = chips,
			},
			scope:Text {
				Name = "Count",
				Text = scope:Computed(function(use)
					local n = #use(shown)
					return if n == 1 then "1 item" else `{n} items`
				end),
				Case = "Normal",
				TextSize = 19,
				Font = "Body",
				Color = Kit.themed(scope, function(theme)
					return Common.panelInk(theme, nil).muted
				end),
				Stroke = false,
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -4, 0.5, 0),
			},
		},
	}
	local grid = H.inventoryGrid(ctx, scope, s, shown, width, g.gridH, selectedId, select)
	grid.Position = UDim2.fromOffset(0, g.above)
	local children = { bar, grid }
	if g.barH > 0 then
		local detail = H.detailPanel(ctx, scope, selectedItem, {
			{
				Text = "Equip",
				Tone = "Green",
				OnClick = function(item)
					ctx.ui:emit("Equip", { id = item.id, screen = info.screen, payload = item.id })
				end,
			},
			{
				Text = "Favorite",
				Tone = "Gray",
				Icon = "star",
				OnClick = function(item)
					ctx.ui:emit("Favorite", { id = item.id, screen = info.screen, payload = item.id })
				end,
			},
		}, width, g.barH, "Bar")
		detail.Position = UDim2.fromOffset(0, g.above + g.gridH + GAP)
		table.insert(children, detail)
	end
	return scope:New "Frame" {
		Name = "Inventory",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, g.height),
		[Children] = children,
	}, g.height
end

Collection.measure.Filters = function(s, width, info, H)
	if #s.items == 0 then
		return 200
	end
	return filtersGeometry(s, width, info, H).height
end

--------------------------------------------------------------------------
-- Team
--------------------------------------------------------------------------

-- "x12.5" -> 12.5
local function powerOf(item): number
	return tonumber((string.gsub(tostring(item.power or ""), "[^%d%.]", ""))) or 1
end

local function teamGeometry(s, width: number, info, H): { [string]: any }
	local header = if s.title then H.HEADER + H.HEADER_GAP else 0
	local room = (info.available or info.pageHeight) - header
	local panelH = if width >= 800 then 196 else 176
	local slots = math.clamp(s.slots or 4, 1, 8)
	local infoW = if width >= 700 then 260 else 190
	local slotGap = 12
	local slotSize = math.clamp(math.floor((width - infoW - 40 - slotGap * (slots - 1)) / slots), 64, panelH - 56)
	local natural = H.gridNatural(width, #s.items)
	local gridH = math.clamp(math.min(natural, room - panelH - GAP), 220, math.max(220, info.pageHeight - header - panelH - GAP))
	return { panelH = panelH, slots = slots, infoW = infoW, slotGap = slotGap, slotSize = slotSize, gridH = gridH, height = panelH + GAP + gridH }
end

-- A team / collection tile: the ItemSlot with its power where the count
-- goes ("x63"), no name label; the plain tile plus a power badge without it.
local function teamTile(ctx, scope, H, item, size: number, onClick: () -> ()): Instance
	local ItemSlot = Common.component("ItemSlot")
	if ItemSlot then
		local slot = Common.try(ItemSlot, scope, {
			Icon = item.icon,
			Rarity = item.rarity,
			Count = item.power,
			Equipped = item.equipped,
			Locked = item.locked,
			Size = size,
			OnClick = onClick,
		})
		if slot then
			return slot
		end
	end
	return H.itemTile(ctx, scope, item, size, false, onClick)
end

local function powerBadge(scope, text: string, z: number): Frame
	return Common.pill(scope, {
		Name = "Power",
		Text = text,
		Tone = "Dark",
		Transparency = 0.1,
		Height = 24,
		TextSize = 16,
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -4),
		ZIndex = z,
	})
end

Collection.build.Team = function(ctx, scope, s, width, info, H)
	local g = teamGeometry(s, width, info, H)
	local initial = {}
	for _, item in s.items do
		if item.equipped and not item.locked and #initial < g.slots then
			table.insert(initial, item.id)
		end
	end
	local team = ctx.ui:state(s.id, "team", initial)
	local byId = {}
	for _, item in s.items do
		byId[item.id] = item
	end
	local function toggle(item)
		if item.locked then
			ctx.ui:notify("That one's still locked!", "warning")
			return
		end
		local ids = table.clone(peek(team))
		local at = table.find(ids, item.id)
		if at then
			table.remove(ids, at)
			team:set(ids)
			ctx.ui:emit("Unequip", { id = item.id, screen = info.screen, payload = item.id })
			return
		end
		if #ids >= g.slots then
			ctx.ui:notify("Your team is full: take one off first", "warning")
			return
		end
		table.insert(ids, item.id)
		team:set(ids)
		ctx.ui:emit("Equip", { id = item.id, screen = info.screen, payload = item.id })
	end
	local boost = scope:Computed(function(use)
		local total = 0
		for _, id in use(team) do
			local item = byId[id]
			if item then
				total += powerOf(item)
			end
		end
		return total
	end)
	-- the team slots
	local slotTiles = {}
	for index = 1, g.slots do
		table.insert(slotTiles, scope:Computed(function(use, inner)
			local id = use(team)[index]
			local item = if id then byId[id] else nil
			if item then
				local copy = table.clone(item)
				copy.equipped = false
				local tile = teamTile(ctx, inner, H, copy, g.slotSize, function()
					toggle(item)
				end)
				local plain = Common.component("ItemSlot") == nil
				return inner:New "Frame" {
					Name = `Slot{index}`,
					BackgroundTransparency = 1,
					LayoutOrder = index,
					Size = UDim2.fromOffset(g.slotSize, g.slotSize),
					[Children] = {
						tile,
						if plain and item.power then powerBadge(inner, item.power, 2) else nil,
					},
				}
			end
			return inner:New "Frame" {
				Name = `Empty{index}`,
				LayoutOrder = index,
				Size = UDim2.fromOffset(g.slotSize, g.slotSize),
				BackgroundColor3 = Kit.themed(inner, function(theme)
					return theme.Color.Surface
				end),
				BackgroundTransparency = 0.55,
				[Children] = {
					Kit.corner(inner, "M"),
					Kit.stroke(inner, {
						Color = Kit.themed(inner, function(theme)
							return theme.Color.TextMuted
						end),
						Thickness = 2,
						Transparency = 0.45,
					}),
					inner:Icon {
						Icon = "plus",
						Size = math.floor(g.slotSize * 0.34),
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = Kit.themed(inner, function(theme)
							return theme.Color.TextMuted
						end),
						Transparency = 0.3,
						ZIndex = 2,
					},
				},
			}
		end))
	end
	local slotsW = g.slots * g.slotSize + (g.slots - 1) * g.slotGap
	-- the count and the boost sit on a plain card: the card's own text colour
	local ink = U.cardText(scope)
	local panel = Common.card(scope, {
		Name = "Team",
		Size = UDim2.new(1, 0, 0, g.panelH),
		Radius = "L",
		Children = {
			scope:Column {
				Name = "Info",
				Size = UDim2.new(0, g.infoW, 1, -36),
				Position = UDim2.fromOffset(24, 18),
				Gap = 2,
				Align = "Left",
				ZIndex = 3,
				[Children] = {
					scope:Text {
						Text = "TEAM",
						TextSize = 19,
						Font = "Heavy",
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Accent
						end),
						LayoutOrder = 1,
						ZIndex = 3,
					},
					scope:Text {
						Text = scope:Computed(function(use)
							return `{#use(team)}/{g.slots}`
						end),
						Case = "Normal",
						TextSize = 52,
						Font = "Display",
						Color = ink.Color,
						Stroke = ink.Stroke,
						LayoutOrder = 2,
						ZIndex = 3,
					},
					scope:Row {
						Name = "Boost",
						Size = UDim2.new(1, 0, 0, 34),
						Gap = 6,
						Align = "Left",
						VAlign = "Center",
						LayoutOrder = 3,
						ZIndex = 3,
						[Children] = {
							scope:Icon {
								Icon = "boost",
								Size = 28,
								Color = Kit.themed(scope, function(theme)
									return theme.Color.Good
								end),
								Gradient = true,
								LayoutOrder = 1,
								ZIndex = 3,
							},
							scope:Text {
								Text = scope:Computed(function(use)
									local total = use(boost)
									return `x{if total >= 100 then Format.abbrev(math.floor(total + 0.5)) else math.floor(total * 10 + 0.5) / 10} BOOST`
								end),
								Case = "Normal",
								TextSize = 23,
								Font = "Display",
								Color = ink.Color,
								Stroke = ink.Stroke,
								LayoutOrder = 2,
								ZIndex = 3,
							},
						},
					},
				},
			},
			scope:Row {
				Name = "Slots",
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -20, 0.5, 0),
				Size = UDim2.fromOffset(slotsW, g.slotSize),
				Gap = g.slotGap,
				Align = "Right",
				ZIndex = 3,
				[Children] = slotTiles,
			},
		},
	})
	-- the collection: every one with its power, a check when it's on the team
	local cellGap = 12
	local columns = math.max(3, math.floor((width - 16 + cellGap) / (112 + cellGap)))
	local cell = math.floor((width - 16 - (columns - 1) * cellGap) / columns)
	local tiles = scope:Computed(function(use, inner)
		local ids = use(team)
		local out = {}
		local plain = Common.component("ItemSlot") == nil
		for index, item in s.items do
			local copy = table.clone(item)
			copy.equipped = table.find(ids, item.id) ~= nil
			local tile = teamTile(ctx, inner, H, copy, cell, function()
				toggle(item)
			end)
			table.insert(out, inner:New "Frame" {
				Name = item.id,
				BackgroundTransparency = 1,
				LayoutOrder = index,
				Size = UDim2.fromOffset(cell, cell),
				[Children] = {
					tile,
					if plain and item.power then powerBadge(inner, item.power, 2) else nil,
				},
			})
		end
		return out
	end)
	local grid = scope:Scroll {
		Name = "ItemGrid",
		Size = UDim2.fromOffset(width, g.gridH),
		Position = UDim2.fromOffset(0, g.panelH + GAP),
		Padding = { 4, 8, 16, 4 },
		Grid = { CellSize = Vector2.new(cell, cell), Gap = cellGap, Columns = columns },
		Align = "Left",
		[Children] = tiles,
	}
	return scope:New "Frame" {
		Name = "Inventory",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, g.height),
		[Children] = { panel, grid },
	}, g.height
end

Collection.measure.Team = function(s, width, info, H)
	if #s.items == 0 then
		return 200
	end
	return teamGeometry(s, width, info, H).height
end

local _ = Theme
return Collection
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="249">
              <Properties>
                <string name="Name">Crates</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs.Layouts.Crates: the v2.4 Cases layouts.

	  Tiers   the crates / eggs in a rising row from humble to legendary,
	          each bigger than the last, with its odds as a stacked rarity bar
	  Banner  a gacha summon banner: the rate-up unit big on light rays with
	          its rarity stars, a countdown (`timer`), the odds and summon x1
	          (the case's own purchase) / x10 (fires "Summon10" for the game)
]]

local Fusion = require(script.Parent.Parent.Parent.Packages.Fusion)
local Kit = require(script.Parent.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Parent.Util.Format)
local Common = require(script.Parent.Parent.Parent.Blueprint.Common)
local Schema = require(script.Parent.Parent.Parent.Blueprint.Schema)
local U = require(script.Parent.Util)

local Children = Fusion.Children

local Crates = { build = {}, measure = {} }

-- Chance per rarity, rarest last: { { rarity, chance } }.
local function odds(pool: { any }): { { any } }
	local totals: { [string]: number } = {}
	for _, drop in pool do
		totals[drop.rarity] = (totals[drop.rarity] or 0) + (drop.chance or 0)
	end
	local out = {}
	for _, rarity in Schema.RARITIES do
		if totals[rarity] then
			table.insert(out, { rarity, math.floor(totals[rarity] * 100 + 0.5) / 100 })
		end
	end
	return out
end

local function chanceText(chance: number): string
	if chance >= 10 then
		return `{math.floor(chance + 0.5)}%`
	end
	return `{chance}%`
end

-- A stacked bar: one segment per rarity, as wide as its chance.
local function oddsBar(scope, list: { { any } }, width: number, props: { [string]: any }): Frame
	local total = 0
	for _, entry in list do
		total += entry[2]
	end
	local segments = {}
	local x = 0
	for index, entry in list do
		local share = if total > 0 then entry[2] / total else 1 / #list
		-- every rarity keeps a sliver, however rare
		local w = math.max(6, math.floor(share * width + 0.5))
		if index == #list then
			w = math.max(6, width - x)
		end
		table.insert(segments, scope:New "Frame" {
			Name = entry[1],
			Position = UDim2.fromOffset(x, 0),
			Size = UDim2.new(0, w, 1, 0),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return Common.rarityTone(theme, entry[1]).Top
			end),
			BorderSizePixel = 0,
			ZIndex = (props.ZIndex or 4) + 1,
		})
		x += w
	end
	return scope:New "Frame" {
		Name = "Odds",
		Size = UDim2.fromOffset(width, props.Height or 14),
		AnchorPoint = props.AnchorPoint or Vector2.zero,
		Position = props.Position or UDim2.new(),
		BackgroundColor3 = Color3.new(0, 0, 0),
		BackgroundTransparency = 0.4,
		ClipsDescendants = true,
		ZIndex = props.ZIndex or 4,
		[Children] = {
			Kit.corner(scope, 4),
			segments,
		},
	}
end

--------------------------------------------------------------------------
-- Tiers
--------------------------------------------------------------------------

local function tiersGeometry(count: number, width: number): { [string]: number }
	local gap = 20
	local perRow = count
	if (width - gap * (count - 1)) / count < 180 then
		perRow = math.ceil(count / 2)
	end
	local colW = math.min(300, math.floor((width - gap * (perRow - 1)) / perRow))
	local minH, maxH = 304, 384
	local rows = math.ceil(count / perRow)
	return { perRow = perRow, colW = colW, minH = minH, maxH = maxH, gap = gap, height = rows * maxH + math.max(0, rows - 1) * gap }
end

Crates.build.Tiers = function(ctx, scope, s, width, info, H)
	local list = s.cases
	local g = tiersGeometry(#list, width)
	local rows = {}
	local index = 1
	local rowIndex = 0
	while index <= #list do
		rowIndex += 1
		local cells = {}
		local inRow = math.min(g.perRow, #list - index + 1)
		for j = 1, inRow do
			local c = list[index]
			local step = if g.perRow <= 1 then 1 else (j - 1) / (g.perRow - 1)
			local h = math.floor(g.minH + (g.maxH - g.minH) * step)
			local isTop = index == #list
			local tone = c.tone or "Blue"
			local buttonH = 56
			local oddsY = h - 14 - buttonH - 12 - 24 - 6 - 14
			local art = math.clamp(math.floor(86 + 64 * step), 70, math.floor(g.colW * 0.66))
			local artY = math.floor(58 + (oddsY - 58) / 2)
			local chances = odds(c.pool)
			local best = chances[#chances]
			local card
			card = Common.card(scope, {
				Name = c.id,
				Size = UDim2.fromOffset(g.colW, h),
				Tone = tone,
				Radius = "L",
				Clip = true,
				Children = {
					if isTop or step >= 0.99
						then Kit.rays(scope, {
							Name = "Rays",
							Size = UDim2.fromOffset(art * 2.8, art * 2.8),
							Position = UDim2.new(0.5, 0, 0, artY),
							Color = Color3.new(1, 1, 1),
							Transparency = 0.5,
							ZIndex = 2,
						})
						else nil,
					scope:Text {
						Name = "Title",
						Text = c.title,
						TextSize = 29,
						Font = "Title",
						AnchorPoint = Vector2.new(0.5, 0),
						Position = UDim2.new(0.5, 0, 0, 14),
						Size = UDim2.new(1, -24, 0, 34),
						Scaled = true,
						Wrapped = false,
						MaxTextSize = 29,
						StrokeColor = U.edgeColor(scope, tone),
						ZIndex = 5,
					},
					scope:Icon {
						Name = "Art",
						Icon = c.icon,
						Size = art,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.new(0.5, 0, 0, artY),
						Color = U.artColor(scope, tone, 0.25),
						StrokeColor = U.edgeColor(scope, tone),
						Stroke = 4,
						Gradient = true,
						Shadow = true,
						ZIndex = 3,
					},
					oddsBar(scope, chances, g.colW - 32, { Position = UDim2.fromOffset(16, oddsY), Height = 14, ZIndex = 4 }),
					if best
						then scope:Text {
							Name = "Best",
							Text = `{best[1]} {chanceText(best[2])}`,
							TextSize = 19,
							Font = "Heavy",
							Color = Kit.themed(scope, function(theme)
								local tone2 = Common.rarityTone(theme, best[1])
								return tone2.Top
							end),
							StrokeColor = U.edgeColor(scope, tone),
							AnchorPoint = Vector2.new(0.5, 0),
							Position = UDim2.new(0.5, 0, 0, oddsY + 20),
							ZIndex = 5,
						}
						else nil,
					ctx.button(scope, {
						Name = "Open",
						Text = if c.price > 0 then Format.comma(c.price) else "FREE",
						Icon = if c.price > 0 then Common.priceIcon(ctx.ui.currencyDefs, c.currency) else nil,
						Tone = "Green",
						Shine = isTop,
						Size = UDim2.new(1, -28, 0, buttonH),
						TextSize = 23,
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.new(0.5, 0, 1, -14),
						ZIndex = 6,
					}, { kind = "None" }, nil, function()
						H.openCase(c, card)
					end),
				},
			})
			local holder = scope:New "Frame" {
				Name = `Tier{index}`,
				BackgroundTransparency = 1,
				LayoutOrder = j,
				Size = UDim2.fromOffset(g.colW, g.maxH),
				[Children] = { card },
			}
			card.AnchorPoint = Vector2.new(0, 1)
			card.Position = UDim2.new(0, 0, 1, 0)
			table.insert(cells, holder)
			index += 1
		end
		table.insert(rows, scope:Row {
			Name = `Row{rowIndex}`,
			LayoutOrder = rowIndex,
			Size = UDim2.new(1, 0, 0, g.maxH),
			Gap = g.gap,
			Align = "Center",
			VAlign = "Top",
			[Children] = cells,
		})
	end
	return scope:Column {
		Name = "Tiers",
		Size = UDim2.new(1, 0, 0, g.height),
		Gap = g.gap,
		[Children] = rows,
	}, g.height
end

Crates.measure.Tiers = function(s, width)
	return tiersGeometry(#s.cases, width).height
end

--------------------------------------------------------------------------
-- Banner: a gacha summon
--------------------------------------------------------------------------

local function bannerGeometry(count: number, width: number): { [string]: any }
	local wide = width >= 760
	local one = if wide then 392 else 580
	return { wide = wide, one = one, height = count * one + math.max(0, count - 1) * U.GAP }
end

local function featuredOf(c): { [string]: any }
	local best = c.pool[1]
	for _, drop in c.pool do
		if best == nil or U.rank(drop.rarity) > U.rank(best.rarity) then
			best = drop
		end
	end
	-- the pool leads with the rate-up unit (Generator.Sections.summon)
	if c.pool[1] and U.rank(c.pool[1].rarity) >= 4 then
		best = c.pool[1]
	end
	return best or { name = c.title, icon = c.icon, rarity = "Legendary", chance = 1 }
end

Crates.build.Banner = function(ctx, scope, s, width, info, H)
	local g = bannerGeometry(#s.cases, width)
	local endsAt = if s.timer then os.time() + s.timer else nil
	local blocks = {}
	for index, c in s.cases do
		local unit = featuredOf(c)
		local rarity = unit.rarity or "Legendary"
		local tone = rarity
		local W, Hh = width, g.one
		local chances = odds(c.pool)
		-- the rarest three
		local shown = {}
		for i = #chances, math.max(1, #chances - 2), -1 do
			table.insert(shown, 1, chances[i])
		end
		local art = if g.wide then math.clamp(math.floor(Hh * 0.56), 150, 230) else 190
		local artPos = if g.wide then UDim2.new(0.25, 0, 0, math.floor(Hh * 0.44)) else UDim2.new(0.5, 0, 0, 150)
		local x0 = if g.wide then math.floor(W * 0.5) else 24
		local colW = if g.wide then W - x0 - 28 else W - 48
		local y0 = if g.wide then 26 else 300
		local chips = {}
		for i, entry in shown do
			table.insert(chips, U.chip(scope, {
				Name = `Odds{i}`,
				Rarity = entry[1],
				Text = `{string.upper(entry[1])} {chanceText(entry[2])}`,
				Height = 30,
				TextSize = 16,
				LayoutOrder = i,
				ZIndex = 6,
			}))
		end
		local x1 = c.price or 0
		local x10 = x1 * 9
		local priceIcon = if x1 > 0 then Common.priceIcon(ctx.ui.currencyDefs, c.currency) else nil
		local buttonW = math.floor((colW - 14) / 2)
		local card
		card = Common.card(scope, {
			Name = c.id,
			Size = UDim2.fromOffset(W, Hh),
			Tone = tone,
			Radius = "L",
			Clip = true,
			Children = {
				Kit.rays(scope, {
					Name = "Rays",
					Size = UDim2.fromOffset(art * 3.2, art * 3.2),
					Position = artPos,
					Color = Color3.new(1, 1, 1),
					Transparency = 0.45,
					ZIndex = 2,
				}),
				scope:Icon {
					Name = "Unit",
					Icon = unit.icon or c.icon,
					Size = art,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = artPos,
					Rotation = -6,
					Color = U.artColor(scope, tone, 0.35),
					StrokeColor = U.edgeColor(scope, tone),
					Stroke = 6,
					Gradient = true,
					Shadow = true,
					ZIndex = 3,
				},
				U.stars(scope, math.max(3, U.rank(rarity)), 30, {
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = if g.wide then UDim2.new(0.25, 0, 1, -38) else UDim2.new(0.5, 0, 0, 268),
					ZIndex = 5,
				}),
				Common.pill(scope, {
					Name = "RateUp",
					Text = "RATE UP",
					Icon = "arrow-large-up",
					Tone = "Red",
					Height = 32,
					TextSize = 19,
					Position = UDim2.fromOffset(x0, y0),
					ZIndex = 6,
				}),
				scope:Text {
					Name = "Unit",
					Text = unit.name or c.title,
					TextSize = 52,
					Font = "Title",
					XAlign = "Left",
					Position = UDim2.fromOffset(x0, y0 + 40),
					Size = UDim2.fromOffset(colW, 58),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = 52,
					StrokeColor = U.edgeColor(scope, tone),
					Stroke = 3,
					ZIndex = 5,
				},
				scope:Text {
					Name = "Banner",
					Text = `{string.upper(rarity)} · {c.title}`,
					TextSize = 19,
					Font = "Heavy",
					XAlign = "Left",
					Position = UDim2.fromOffset(x0, y0 + 102),
					Size = UDim2.fromOffset(colW, 24),
					Truncate = true,
					StrokeColor = U.edgeColor(scope, tone),
					ZIndex = 5,
				},
				if endsAt
					then Common.pill(scope, {
						Name = "Timer",
						Icon = "timer",
						Text = Common.countdown(scope, ctx.clock, endsAt),
						Tone = "Dark",
						Height = 32,
						TextSize = 19,
						Position = UDim2.fromOffset(x0, y0 + 136),
						ZIndex = 6,
					})
					else nil,
				scope:Row {
					Name = "Odds",
					Position = UDim2.fromOffset(x0, y0 + (if endsAt then 180 else 140)),
					Size = UDim2.fromOffset(colW, 30),
					Gap = 8,
					Align = "Left",
					VAlign = "Center",
					ZIndex = 6,
					[Children] = chips,
				},
				ctx.button(scope, {
					Name = "Summon1",
					Text = if x1 > 0 then `x1  {Format.comma(x1)}` else "x1 FREE",
					Icon = priceIcon,
					Tone = "Blue",
					Size = UDim2.fromOffset(buttonW, 60),
					TextSize = 23,
					AnchorPoint = Vector2.new(0, 1),
					Position = UDim2.new(0, x0, 1, -24),
					ZIndex = 6,
				}, { kind = "None" }, nil, function()
					H.openCase(c, card)
				end),
				ctx.button(scope, {
					Name = "Summon10",
					Text = if x10 > 0 then `x10  {Format.comma(x10)}` else "x10 FREE",
					Icon = priceIcon,
					Tone = "Green",
					Shine = true,
					Pulse = true,
					Primary = true,
					Size = UDim2.fromOffset(buttonW, 60),
					TextSize = 23,
					AnchorPoint = Vector2.new(0, 1),
					Position = UDim2.new(0, x0 + buttonW + 14, 1, -24),
					ZIndex = 6,
				}, { kind = "None" }, nil, function()
					-- ten pulls for the price of nine: the game rolls them
					ctx.ui:emit("Summon10", { id = c.id, screen = info.screen, payload = { case = c.id, count = 10, price = x10, currency = c.currency } })
					if ctx.preview then
						ctx.ui:notify("Summoning x10!", "reward")
					end
				end),
				Common.pill(scope, {
					Name = "Deal",
					Text = "-10%",
					Tone = "Red",
					Height = 26,
					TextSize = 16,
					AnchorPoint = Vector2.new(1, 1),
					Position = UDim2.new(0, x0 + buttonW * 2 + 14 + 6, 1, -24 - 60 + 12),
					ZIndex = 8,
				}),
			},
		})
		card.LayoutOrder = index
		table.insert(blocks, card)
	end
	return scope:Column {
		Name = "Banners",
		Size = UDim2.new(1, 0, 0, g.height),
		Gap = U.GAP,
		[Children] = blocks,
	}, g.height
end

Crates.measure.Banner = function(s, width)
	return bannerGeometry(#s.cases, width).height
end

return Crates
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="250">
              <Properties>
                <string name="Name">Rewards</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs.Layouts.Rewards: the v2.4 reward layouts.

	  Daily Path    the login days as stops along a winding trail: claimed
	                stops ticked, today's glowing (click to claim), the grand
	                prize bigger at the end, under the streak strip
	  Gifts Chests  a few chests that unlock with this session's playtime
	                (Blueprint.Rewards state, the same claims as the gift
	                grid): a countdown, OPEN when ready, OPENED after
]]

local Fusion = require(script.Parent.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Parent.Util.Format)
local Common = require(script.Parent.Parent.Parent.Blueprint.Common)
local Rewards = require(script.Parent.Parent.Parent.Blueprint.Rewards)
local U = require(script.Parent.Util)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local RewardsLayouts = { build = {}, measure = {} }

--------------------------------------------------------------------------
-- Daily Path
--------------------------------------------------------------------------

-- A day's amount as the classic daily grid writes it: numbers as "x1.5K",
-- text ("1,500", "x2") as given.
local function amountText(amount: any): string
	if typeof(amount) == "number" then
		return `x{Format.abbrev(amount)}`
	end
	return tostring(amount or "")
end

local function pathGeometry(count: number, width: number, strip: number): { [string]: any }
	local pathH = if width >= 700 then 330 else 300
	local pad = 20
	local spacing = (width - pad * 2) / math.max(1, count)
	local node = math.clamp(math.floor(spacing * 1.45), 72, 108)
	return { pathH = pathH, pad = pad, spacing = spacing, node = node, big = node + 26, top = strip + 16, height = strip + 16 + pathH }
end

RewardsLayouts.build.Path = function(ctx, scope, s, width, info, H)
	local claimed = ctx.ui:state(s.id, "claimed", s.streak or 0)
	local function claim(day: number)
		if day ~= peek(claimed) + 1 then
			return
		end
		claimed:set(day)
		ctx.ui:emit("DailyClaim", { id = s.id, screen = info.screen, payload = day })
		ctx.ui:notify(`Day {day} reward claimed!`, "reward")
	end
	local count = #s.days
	local g = pathGeometry(count, width, H.STREAK_STRIP)
	-- amounts under the stops sit straight on the panel: its own ink
	local ink = U.inkText(scope, H.panelInk(scope, ctx, info))
	-- node centres: alternate lanes, the last stop in the middle
	local centres = {}
	for index = 1, count do
		local size = if index == count then g.big else g.node
		local x = g.pad + g.spacing * (index - 0.5)
		local y
		if index == count then
			y = g.pathH / 2 + 4
		elseif index % 2 == 1 then
			y = g.pathH - 30 - size / 2
		else
			y = 26 + size / 2
		end
		centres[index] = { x = x, y = y, size = size }
	end
	local children = {}
	-- the trail: dots from stop to stop, lit up to the streak
	for index = 1, count - 1 do
		local a, b = centres[index], centres[index + 1]
		local steps = 5
		for k = 1, steps do
			local t = k / (steps + 1)
			local lit = scope:Computed(function(use)
				return use(claimed) > index
			end)
			table.insert(children, scope:New "Frame" {
				Name = `Dot{index}_{k}`,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromOffset(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t),
				Size = UDim2.fromOffset(12, 12),
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Good or Color3.fromRGB(80, 220, 120)
				end),
				BackgroundTransparency = scope:Computed(function(use)
					return if use(lit) then 0 else 0.7
				end),
				ZIndex = 2,
				[Children] = Kit.corner(scope, "Full"),
			})
		end
	end
	for index, d in s.days do
		local c = centres[index]
		local last = index == count
		local done = scope:Computed(function(use)
			return use(claimed) >= d.day
		end)
		local today = scope:Computed(function(use)
			return use(claimed) + 1 == d.day
		end)
		local tone = scope:Computed(function(use)
			if use(done) then
				return "Gray"
			elseif use(today) then
				return "Good"
			end
			return if last then "Accent" else "Primary"
		end)
		table.insert(children, scope:New "TextButton" {
			Name = `Day{d.day}`,
			Text = "",
			AutoButtonColor = false,
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromOffset(c.x, c.y),
			Size = UDim2.fromOffset(c.size, c.size),
			ZIndex = 3,
			[OnEvent "Activated"] = function()
				claim(d.day)
			end,
			[Children] = {
				Common.card(scope, {
					Name = "Stop",
					Size = UDim2.fromScale(1, 1),
					Tone = tone,
					Radius = "Full",
					ZIndex = 3,
					Children = {
						scope:Icon {
							Name = "Art",
							Icon = scope:Computed(function(use)
								return if use(done) then "check" else d.icon
							end),
							Size = math.floor(c.size * 0.52),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = Color3.new(1, 1, 1),
							Gradient = true,
							Shadow = true,
							ZIndex = 5,
						},
					},
				}),
				scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
					Color = Color3.new(1, 1, 1),
					Thickness = 4,
					Enabled = today,
				},
				Kit.corner(scope, "Full"),
				Common.pill(scope, {
					Name = "Label",
					Text = if last then (d.label or `DAY {d.day}`) else `DAY {d.day}`,
					Tone = "Dark",
					Transparency = 0.15,
					Height = 24,
					TextSize = 16,
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 0, -2),
					ZIndex = 6,
				}),
				scope:Text {
					Name = "Amount",
					Text = amountText(d.amount),
					TextSize = if last then 29 else 23,
					Font = "Display",
					Color = ink.Color,
					Stroke = ink.Stroke,
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 1, 2),
					ZIndex = 6,
				},
				-- the CLAIM pill shows on today's stop only
				U.shown(scope, today, Common.pill(scope, {
					Name = "Claim",
					Text = "CLAIM",
					Tone = "Green",
					Height = 26,
					TextSize = 16,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 0, 1, -4),
					ZIndex = 7,
				}), 7),
			},
		})
	end
	local trail = scope:New "Frame" {
		Name = "Trail",
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(0, g.top),
		Size = UDim2.new(1, 0, 0, g.pathH),
		[Children] = children,
	}
	return scope:New "Frame" {
		Name = "Daily",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, g.height),
		[Children] = { H.streakStrip(scope, claimed, H.panelInk(scope, ctx, info)), trail },
	}, g.height
end

RewardsLayouts.measure.Path = function(s, width, info, H)
	return pathGeometry(#s.days, width, H.STREAK_STRIP).height
end

--------------------------------------------------------------------------
-- Gifts Chests
--------------------------------------------------------------------------

local CHEST_HEADER = 60
local CHEST_NAMES = {
	Common = "Wooden", Uncommon = "Iron", Rare = "Silver", Epic = "Magic", Legendary = "Golden", Mythic = "Mythic", Secret = "Secret",
}

local function chestsGeometry(count: number, width: number): { [string]: number }
	local gap = 16
	local perRow = count
	if (width - gap * (count - 1)) / count < 150 then
		perRow = math.ceil(count / 2)
	end
	local colW = math.min(230, math.floor((width - gap * (perRow - 1)) / perRow))
	local cardH = if colW >= 190 then 290 else 270
	local rows = math.ceil(count / perRow)
	return { perRow = perRow, colW = colW, cardH = cardH, gap = gap, height = CHEST_HEADER + 12 + rows * cardH + math.max(0, rows - 1) * gap }
end

RewardsLayouts.build.Chests = function(ctx, scope, s, width, info, H)
	local state = H.rewardState(ctx, s)
	if state == nil or #s.gifts == 0 then
		return H.emptyState(scope, "No chests right now", "gift", 160)
	end
	local rewards = ctx.ui.rewards
	local g = chestsGeometry(#s.gifts, width)
	local total = #s.gifts
	local cards = {}
	local byIndex = {}
	local function opened(data: any)
		for _, index in (typeof(data) == "table" and data.claimed) or {} do
			local gift = s.gifts[index]
			local card = byIndex[index]
			if gift then
				task.delay(0.18, function()
					rewards:payout(gift, card)
				end)
			end
		end
	end
	for index, gift in s.gifts do
		local rarity = gift.rarity or "Common"
		local isOpen = scope:Computed(function(use)
			return Rewards.bitOn(tonumber(use(state.mask)) or 0, index)
		end)
		local left = scope:Computed(function(use)
			return math.max(0, gift.minutes * 60 - use(state.playtime))
		end)
		local ready = scope:Computed(function(use)
			return not use(isOpen) and use(left) <= 0
		end)
		local tone = scope:Computed(function(use)
			return if use(isOpen) then "Gray" else rarity
		end)
		local art = math.clamp(math.floor(g.colW * 0.52), 76, 112)
		local card
		card = Common.card(scope, {
			Name = `Chest{index}`,
			Size = UDim2.fromOffset(g.colW, g.cardH),
			Tone = tone,
			Radius = "L",
			Clip = true,
			Children = {
				Kit.rays(scope, {
					Name = "Rays",
					Size = UDim2.fromOffset(art * 2.6, art * 2.6),
					Position = UDim2.new(0.5, 0, 0, 112),
					Color = Color3.new(1, 1, 1),
					Transparency = 0.5,
					ZIndex = 2,
					Visible = ready,
				}),
				scope:Text {
					Name = "Name",
					Text = `{string.upper(CHEST_NAMES[rarity] or "Mystery")} CHEST`,
					TextSize = 19,
					Font = "Heavy",
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, 14),
					Size = UDim2.new(1, -16, 0, 24),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = 19,
					ZIndex = 5,
				},
				scope:Icon {
					Name = "Art",
					Icon = scope:Computed(function(use)
						return if use(isOpen) then "check" else "gift-box"
					end),
					Size = art,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 0, 0, 112),
					Rotation = scope:Computed(function(use)
						return if use(ready) then -8 else 0
					end),
					Color = Kit.themed(scope, function(theme)
						return Color.lighten(Common.rarityTone(theme, rarity).Top, 0.3)
					end),
					StrokeColor = Kit.themed(scope, function(theme)
						return Common.rarityTone(theme, rarity).Edge
					end),
					Stroke = 4,
					Gradient = true,
					Shadow = true,
					ZIndex = 3,
				},
				scope:Row {
					Name = "Prize",
					Size = UDim2.new(1, -20, 0, 32),
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, 176),
					Gap = 6,
					Align = "Center",
					VAlign = "Center",
					ZIndex = 5,
					[Children] = {
						scope:Icon { Icon = gift.icon, Size = 28, Gradient = true, Shadow = true, LayoutOrder = 1, ZIndex = 5 },
						scope:Text {
							Text = if gift.label then gift.label else `x{gift.amount or "1"}`,
							TextSize = 23,
							Font = "Display",
							LayoutOrder = 2,
							ZIndex = 5,
						},
					},
				},
				-- bottom: OPEN when ready, the countdown until then, OPENED after
				ctx.button(scope, {
					Name = "Open",
					Text = "OPEN",
					Tone = "Green",
					Shine = true,
					Pulse = true,
					Size = UDim2.new(1, -24, 0, 50),
					TextSize = 23,
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -12),
					Visible = ready,
					ZIndex = 6,
				}, { kind = "None" }, nil, function()
					rewards:claim(s.id, index, nil, function(ok, _reason, data)
						if ok then
							opened(data)
						end
					end)
				end),
				U.shown(scope, scope:Computed(function(use)
					return not use(ready)
				end), Common.pill(scope, {
					Name = "Status",
					Icon = scope:Computed(function(use)
						return if use(isOpen) then "check" else "timer"
					end),
					Text = scope:Computed(function(use)
						if use(isOpen) then
							return "OPENED"
						end
						return Rewards.countdown(use(left))
					end),
					Tone = "Dark",
					Transparency = 0.15,
					Height = 40,
					TextSize = 19,
					AnchorPoint = Vector2.new(0.5, 1),
					Position = UDim2.new(0.5, 0, 1, -17),
					ZIndex = 6,
				}), 6),
			},
		})
		byIndex[index] = card
		table.insert(cards, card)
	end
	local openedCount = scope:Computed(function(use)
		local mask = tonumber(use(state.mask)) or 0
		local n = 0
		for index = 1, total do
			if Rewards.bitOn(mask, index) then
				n += 1
			end
		end
		return n
	end)
	local nextChest = scope:Computed(function(use)
		local mask = tonumber(use(state.mask)) or 0
		local played = use(state.playtime)
		for index, gift in s.gifts do
			if not Rewards.bitOn(mask, index) and played < gift.minutes * 60 then
				return gift.minutes * 60 - played
			end
		end
		return nil
	end)
	local note = H.panelMuted(scope, H.panelInk(scope, ctx, info))
	local ink = U.inkText(scope, H.panelInk(scope, ctx, info))
	local header = scope:New "Frame" {
		Name = "Header",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, CHEST_HEADER),
		[Children] = {
			Common.iconTile(scope, {
				Icon = "gift-box",
				Tone = Kit.themed(scope, function(theme)
					return theme.Color.Accent
				end),
				Size = 52,
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 0, 0.5, 0),
				ZIndex = 3,
			}),
			scope:Text {
				Name = "Title",
				Text = scope:Computed(function(use)
					return `CHESTS  {use(openedCount)}/{total}`
				end),
				TextSize = 29,
				Font = "Title",
				XAlign = "Left",
				Position = UDim2.fromOffset(66, 2),
				Size = UDim2.new(1, -80, 0, 32),
				Truncate = true,
				Color = ink.Color,
				Stroke = ink.Stroke,
				ZIndex = 3,
			},
			scope:Text {
				Name = "Next",
				Text = scope:Computed(function(use)
					local wait = use(nextChest)
					if wait == nil then
						return "Every chest is unlocked!"
					end
					return `Keep playing: the next chest unlocks in {Rewards.countdown(wait)}`
				end),
				Case = "Normal",
				TextSize = 19,
				Font = "Body",
				XAlign = "Left",
				Color = note.Color,
				Stroke = note.Stroke,
				StrokeColor = note.StrokeColor,
				Position = UDim2.fromOffset(66, 36),
				Size = UDim2.new(1, -80, 0, 22),
				Truncate = true,
				ZIndex = 3,
			},
		},
	}
	local rows, rowsH = U.fixedRows(scope, cards, g.perRow, g.colW, g.cardH, g.gap, "Chests")
	rows.Position = UDim2.fromOffset(0, CHEST_HEADER + 12)
	local _ = rowsH
	return scope:New "Frame" {
		Name = "Chests",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, g.height),
		[Children] = { header, rows },
	}, g.height
end

RewardsLayouts.measure.Chests = function(s, width, info, H)
	if #s.gifts == 0 then
		return nil
	end
	return chestsGeometry(#s.gifts, width).height
end

return RewardsLayouts
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="251">
              <Properties>
                <string name="Name">Store</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs.Layouts.Store: the v2.4 Products layouts (Blueprint.Schema
	SECTION_LAYOUTS_V24.Products).

	  Grid      item tiles in rarity frames: the item's name on top, a rarity
	            chip and the price button under it
	  Showcase  the selected item big on a light burst with its stat bars and
	            price, the rest in a column to pick from
	  Wall      currency packs as a rising staircase; the top step glows
	  Passes    gamepass medallion cards with their one-line perk
	  Mosaic    one tall featured offer beside a 2 x 2 of the others

	Each layout has a geometry function shared by build and measure, so a
	page's measured height always equals the built one.
]]

local Fusion = require(script.Parent.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Parent.Util.Format)
local Color = require(script.Parent.Parent.Parent.Util.Color)
local Common = require(script.Parent.Parent.Parent.Blueprint.Common)
local U = require(script.Parent.Util)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local Store = { build = {}, measure = {} }

local GAP = U.GAP

--------------------------------------------------------------------------
-- An offer tile (Grid cells and the Mosaic): rarity-framed card, art on a
-- light burst for the rare ones, name, rarity chip, price button.
--------------------------------------------------------------------------

local function offerTile(ctx, scope, s, info, item, w: number, h: number, big: boolean): Instance
	local tone = U.toneOf(item)
	local rarity = U.rarityOf(item)
	local rank = U.rank(rarity)
	local buttonH = if big then 56 else 46
	local nameSize = if big then 38 else 23
	-- the chip names the rarity (or the amount: "15 min"); nothing to say, no chip
	local chipText = if rarity then nil else item.amount
	local hasChip = rarity ~= nil or (chipText ~= nil and chipText ~= "")
	local chipH = if not hasChip then 0 elseif big then 30 else 26
	local bottom = h - 12 - buttonH
	local chipY = bottom - (if hasChip then 10 else 4) - chipH
	local desc = if big and item.description then item.description else nil
	local descY = chipY - (if desc then 30 else 0)
	local nameY = descY - 6 - math.floor(nameSize * 1.2)
	local artTop = if item.tag then 34 else 14
	local art = math.clamp(math.floor(math.min((nameY - artTop) * 0.88, w * 0.62)), 48, if big then 210 else 120)
	local artY = math.floor(artTop + (nameY - artTop) / 2)
	local card
	card = Common.card(scope, {
		Name = item.id,
		Size = UDim2.fromOffset(w, h),
		Tone = tone,
		Radius = "L",
		Clip = true,
		Children = {
			if rank >= 4 or big
				then Kit.rays(scope, {
					Name = "Rays",
					Size = UDim2.fromOffset(art * 2.6, art * 2.6),
					Position = UDim2.new(0.5, 0, 0, artY),
					Color = Color3.new(1, 1, 1),
					Transparency = if rank >= 5 then 0.5 else 0.66,
					ZIndex = 2,
				})
				else nil,
			scope:Icon {
				Name = "Art",
				Icon = item.icon,
				Size = art,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 0, 0, artY),
				Rotation = if big then -8 else 0,
				Color = U.artColor(scope, tone),
				StrokeColor = U.edgeColor(scope, tone),
				Stroke = if big then 5 else 4,
				Gradient = true,
				Shadow = true,
				ZIndex = 3,
			},
			if item.tag and string.upper(item.tag) ~= string.upper(rarity or "")
				then Common.pill(scope, {
					Name = "Tag",
					Text = item.tag,
					Tone = "Red",
					Height = 26,
					TextSize = 16,
					AnchorPoint = Vector2.new(1, 0),
					Position = UDim2.new(1, -8, 0, 8),
					ZIndex = 7,
				})
				else nil,
			scope:Text {
				Name = "Title",
				Text = item.title,
				TextSize = nameSize,
				Font = "Title",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0, nameY),
				Size = UDim2.new(1, -20, 0, math.floor(nameSize * 1.2)),
				Scaled = true,
				Wrapped = false,
				MaxTextSize = nameSize,
				StrokeColor = U.edgeColor(scope, tone),
				ZIndex = 5,
			},
			if desc
				then scope:Text {
					Name = "Description",
					Text = desc,
					TextSize = 19,
					Font = "Body",
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, descY - 2),
					Size = UDim2.new(1, -28, 0, 24),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = 19,
					StrokeColor = U.edgeColor(scope, tone),
					ZIndex = 5,
				}
				else nil,
			if hasChip
				then U.chip(scope, {
					Rarity = rarity,
					Text = chipText,
					Color = if rarity then nil else Kit.themed(scope, function(theme)
						return Theme.tone(theme, tone).Edge
					end),
					TextColor = if rarity then nil else Color3.new(1, 1, 1),
					Height = chipH,
					TextSize = if big then 19 else 16,
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, chipY),
				})
				else nil,
			if item.was
				then scope:Text {
					Name = "Was",
					Text = `<s>{Format.comma(item.was)}</s>`,
					RichText = true,
					Case = "Normal",
					TextSize = 19,
					Font = "Display",
					AnchorPoint = Vector2.new(0, 0),
					Position = UDim2.fromOffset(10, 10),
					ZIndex = 6,
				}
				else nil,
			U.buyButton(ctx, scope, s, info, item, {
				Size = UDim2.new(1, -20, 0, buttonH),
				TextSize = if big then 29 else 23,
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(0.5, 0, 1, -12),
				Shine = big,
			}, function()
				return card
			end),
		},
	})
	return card
end

--------------------------------------------------------------------------
-- Grid
--------------------------------------------------------------------------

local function gridGeometry(count: number, width: number): { [string]: number }
	local gap = 16
	local columns = math.clamp(math.floor((width + gap) / (176 + gap)), 2, 5)
	columns = math.max(1, math.min(columns, count))
	local tileW = math.min(280, math.floor((width - gap * (columns - 1)) / columns))
	local tileH = math.clamp(math.floor(tileW * 1.22), 224, 272)
	local rows = math.ceil(count / columns)
	return { columns = columns, tileW = tileW, tileH = tileH, gap = gap, height = rows * tileH + math.max(0, rows - 1) * gap }
end

Store.build.Grid = function(ctx, scope, s, width, info)
	local g = gridGeometry(#s.items, width)
	local cells = {}
	for _, item in s.items do
		table.insert(cells, offerTile(ctx, scope, s, info, item, g.tileW, g.tileH, false))
	end
	return U.fixedRows(scope, cells, g.columns, g.tileW, g.tileH, g.gap, "Grid")
end

Store.measure.Grid = function(s, width)
	return gridGeometry(#s.items, width).height
end

--------------------------------------------------------------------------
-- Showcase
--------------------------------------------------------------------------

local SHOW_ROW = 60
local SHOW_ROW_GAP = 8

local function showcaseGeometry(count: number, width: number): { [string]: any }
	local listH = count * SHOW_ROW + math.max(0, count - 1) * SHOW_ROW_GAP
	if width >= 760 then
		local previewW = math.floor(width * 0.56)
		local previewH = math.max(400, listH)
		return { wide = true, previewW = previewW, previewH = previewH, listW = width - previewW - GAP, listH = listH, height = previewH }
	end
	local previewH = 430
	return { wide = false, previewW = width, previewH = previewH, listW = width, listH = listH, height = previewH + GAP + listH }
end

Store.build.Showcase = function(ctx, scope, s, width, info, helpers)
	local list = s.items
	local g = showcaseGeometry(#list, width)
	local selectedId = ctx.ui:state(s.id, "selected", list[1].id)
	local selected = scope:Computed(function(use)
		local id = use(selectedId)
		for _, item in list do
			if item.id == id then
				return item
			end
		end
		return list[1]
	end)
	local tone = scope:Computed(function(use)
		return U.toneOf(use(selected))
	end)
	local W, H = g.previewW, g.previewH
	local toneTable = Kit.tone(scope, tone, "Blue")
	local function field(read)
		return scope:Computed(function(use)
			return read(use(selected))
		end)
	end
	-- the stat bars of the selected item (up to four)
	local statCount = 0
	for _, item in list do
		statCount = math.max(statCount, #(item.stats or {}))
	end
	statCount = math.min(statCount, 4)
	local stats = {}
	local statTop, statX, statW, statRowH, columns
	if g.wide then
		statX, statW = math.floor(W * 0.52), math.floor(W * 0.48) - 24
		statTop, statRowH, columns = 132, 40, 1
	else
		statX, statW = 20, W - 40
		statTop, statRowH, columns = 262, 40, 2
	end
	for index = 1, statCount do
		local column = if columns == 2 then (index - 1) % 2 else 0
		local row = if columns == 2 then (index - 1) // 2 else index - 1
		local colW = if columns == 2 then math.floor((statW - 16) / 2) else statW
		local x = statX + column * (colW + 16)
		local y = statTop + row * statRowH
		table.insert(stats, scope:Text {
			Name = `StatLabel{index}`,
			Text = field(function(item)
				local stat = (item.stats or {})[index]
				return if stat then stat.label else ""
			end),
			TextSize = 19,
			Font = "Heavy",
			XAlign = "Left",
			Position = UDim2.fromOffset(x, y),
			Size = UDim2.fromOffset(colW, 20),
			Truncate = true,
			StrokeColor = scope:Computed(function(use)
				return use(toneTable).Edge
			end),
			ZIndex = 5,
		})
		table.insert(stats, U.bar(scope, {
			Name = `Stat{index}`,
			Value = field(function(item)
				local stat = (item.stats or {})[index]
				return if stat then stat.value else 0
			end),
			Tone = "Good",
			Size = UDim2.fromOffset(colW, 12),
			Position = UDim2.fromOffset(x, y + 23),
			ZIndex = 5,
		}))
	end
	local artSize = if g.wide then math.clamp(math.floor(math.min(W * 0.36, H * 0.5)), 120, 210) else 136
	local artCenter = if g.wide then UDim2.new(0.26, 0, 0.44, 0) else UDim2.new(0.5, 0, 0, 96)
	local preview
	preview = Common.card(scope, {
		Name = "Preview",
		Size = UDim2.fromOffset(W, H),
		Tone = tone,
		Radius = "L",
		Clip = true,
		Children = {
			Kit.rays(scope, {
				Name = "Rays",
				Size = UDim2.fromOffset(artSize * 3, artSize * 3),
				Position = artCenter,
				Color = Color3.new(1, 1, 1),
				Transparency = 0.55,
				ZIndex = 2,
			}),
			scope:Icon {
				Name = "Art",
				Icon = field(function(item)
					return item.icon
				end),
				Size = artSize,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = artCenter,
				Rotation = -10,
				Color = scope:Computed(function(use)
					return Color.lighten(use(toneTable).Top, 0.3)
				end),
				StrokeColor = scope:Computed(function(use)
					return use(toneTable).Edge
				end),
				Stroke = 5,
				Gradient = true,
				Shadow = true,
				ZIndex = 3,
			},
			scope:Text {
				Name = "Name",
				Text = field(function(item)
					return item.title
				end),
				TextSize = 38,
				Font = "Title",
				XAlign = if g.wide then "Left" else "Center",
				AnchorPoint = if g.wide then Vector2.zero else Vector2.new(0.5, 0),
				Position = if g.wide then UDim2.fromOffset(statX, 28) else UDim2.new(0.5, 0, 0, 178),
				Size = if g.wide then UDim2.fromOffset(statW, 46) else UDim2.new(1, -40, 0, 46),
				Scaled = true,
				Wrapped = false,
				MaxTextSize = 38,
				StrokeColor = scope:Computed(function(use)
					return use(toneTable).Edge
				end),
				ZIndex = 5,
			},
			scope:Computed(function(use, inner)
				local item = use(selected)
				local rarity = U.rarityOf(item)
				if rarity == nil then
					return nil
				end
				return U.chip(inner, {
					Rarity = rarity,
					Height = 30,
					TextSize = 19,
					AnchorPoint = if g.wide then Vector2.zero else Vector2.new(0.5, 0),
					Position = if g.wide then UDim2.fromOffset(statX, 82) else UDim2.new(0.5, 0, 0, 226),
				})
			end),
			stats,
			ctx.button(scope, {
				Name = "Buy",
				Text = field(function(item)
					return U.priceText(item)
				end),
				Icon = field(function(item)
					return U.priceIcon(ctx, item)
				end),
				Tone = "Green",
				Shine = true,
				Primary = true,
				TextSize = 29,
				Size = if g.wide then UDim2.fromOffset(statW, 56) else UDim2.new(1, -40, 0, 56),
				AnchorPoint = if g.wide then Vector2.new(0, 1) else Vector2.new(0.5, 1),
				Position = if g.wide then UDim2.new(0, statX, 1, -22) else UDim2.new(0.5, 0, 1, -18),
				ZIndex = 6,
			}, { kind = "None" }, nil, function()
				local item = peek(selected)
				ctx.buy(item, { id = item.id, screen = info.screen, section = s.id, source = preview })
			end),
		},
	})
	-- the picks: a compact row each, the selected one outlined (their text sits
	-- on glass: the panel's own ink)
	-- row names and prices sit on plain cards: the card's own text colour
	local rowInk = U.cardText(scope)
	local rows = {}
	for index, item in list do
		local rarity = U.rarityOf(item)
		local isSelected = scope:Computed(function(use)
			return use(selectedId) == item.id
		end)
		table.insert(rows, scope:New "TextButton" {
			Name = item.id,
			Text = "",
			AutoButtonColor = false,
			BackgroundTransparency = 1,
			LayoutOrder = index,
			Size = UDim2.new(1, 0, 0, SHOW_ROW),
			[OnEvent "Activated"] = function()
				selectedId:set(item.id)
			end,
			[Children] = {
				Common.card(scope, {
					Name = "Row",
					Size = UDim2.fromScale(1, 1),
					Highlight = false,
					Children = {
						Common.iconTile(scope, {
							Icon = item.icon,
							Tone = U.toneOf(item),
							Size = SHOW_ROW - 12,
							AnchorPoint = Vector2.new(0, 0.5),
							Position = UDim2.new(0, 6, 0.5, 0),
							ZIndex = 3,
						}),
						scope:Text {
							Name = "Title",
							Text = item.title,
							TextSize = 23,
							Font = "Title",
							XAlign = "Left",
							AnchorPoint = Vector2.new(0, 0.5),
							Position = UDim2.new(0, SHOW_ROW + 8, 0.5, if rarity then -10 else 0),
							Size = UDim2.new(1, -(SHOW_ROW + 8 + 120), 0, 26),
							Truncate = true,
							Color = rowInk.Color,
							Stroke = rowInk.Stroke,
							ZIndex = 3,
						},
						if rarity
							then scope:Text {
								Name = "Rarity",
								Text = string.upper(rarity),
								TextSize = 16,
								Font = "Heavy",
								XAlign = "Left",
								Color = Kit.themed(scope, function(theme)
									return Common.rarityTone(theme, rarity).Top
								end),
								AnchorPoint = Vector2.new(0, 0.5),
								Position = UDim2.new(0, SHOW_ROW + 8, 0.5, 13),
								Size = UDim2.new(1, -(SHOW_ROW + 8 + 120), 0, 18),
								ZIndex = 3,
							}
							else nil,
						scope:Row {
							Name = "Price",
							Size = UDim2.fromOffset(110, 30),
							AnchorPoint = Vector2.new(1, 0.5),
							Position = UDim2.new(1, -12, 0.5, 0),
							Gap = 4,
							Align = "Right",
							VAlign = "Center",
							ZIndex = 3,
							[Children] = {
								if U.priceIcon(ctx, item)
									then scope:Icon { Icon = U.priceIcon(ctx, item) :: string, Size = 22, LayoutOrder = 1, ZIndex = 3 }
									else nil,
								scope:Text { Text = U.priceText(item), TextSize = 23, Font = "Display", Color = rowInk.Color, Stroke = rowInk.Stroke, LayoutOrder = 2, ZIndex = 3 },
							},
						},
						scope:New "UIStroke" {
							ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
							Color = Kit.themed(scope, function(theme)
								return theme.Color.Accent
							end),
							Thickness = 3,
							Enabled = isSelected,
						},
					},
				}),
			},
		})
	end
	local listBlock = scope:Column {
		Name = "Picks",
		Size = UDim2.fromOffset(g.listW, g.listH),
		Gap = SHOW_ROW_GAP,
		[Children] = rows,
	}
	if g.wide then
		listBlock.Position = UDim2.fromOffset(W + GAP, 0)
	else
		listBlock.Position = UDim2.fromOffset(0, H + GAP)
		listBlock.Size = UDim2.new(1, 0, 0, g.listH)
	end
	return scope:New "Frame" {
		Name = "Showcase",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, g.height),
		[Children] = { preview, listBlock },
	}, g.height
end

Store.measure.Showcase = function(s, width)
	return showcaseGeometry(#s.items, width).height
end

--------------------------------------------------------------------------
-- Wall: packs as a rising staircase
--------------------------------------------------------------------------

local function wallGeometry(count: number, width: number): { [string]: number }
	local gap = 16
	local perRow = count
	if (width - gap * (count - 1)) / count < 150 then
		perRow = math.ceil(count / 2)
	end
	local colW = math.floor((width - gap * (perRow - 1)) / perRow)
	local rows = math.ceil(count / perRow)
	local minH, maxH = 262, 352
	if rows > 1 then
		-- two staircases stacked: lower steps so both fit on a page
		minH, maxH = 226, 286
	elseif colW < 170 then
		minH, maxH = 250, 320
	end
	return { perRow = perRow, colW = colW, minH = minH, maxH = maxH, gap = gap, height = rows * maxH + math.max(0, rows - 1) * gap }
end

Store.build.Wall = function(ctx, scope, s, width, info)
	local list = s.items
	local g = wallGeometry(#list, width)
	local rows = {}
	local index = 1
	local rowIndex = 0
	while index <= #list do
		rowIndex += 1
		local cells = {}
		local inRow = math.min(g.perRow, #list - index + 1)
		for j = 1, inRow do
			local item = list[index]
			local isBest = index == #list
			local h = if g.perRow <= 1 then g.maxH else math.floor(g.minH + (g.maxH - g.minH) * (j - 1) / math.max(1, g.perRow - 1))
			local stack = math.clamp(1 + math.floor((index - 1) / math.max(1, #list - 1) * 2 + 0.5), 1, 3)
			local tone = item.tone or "Blue"
			local buttonH = 50
			local amountY = h - 12 - buttonH - 10 - 24 - 4 - 36
			local artTop = if item.tag or item.bonus then 44 else 16
			local art = math.clamp(math.floor(math.min((amountY - artTop) * 0.7, g.colW * 0.46)), 44, 130)
			local artY = math.floor(artTop + (amountY - artTop) / 2)
			local offsets = ({ { 0 }, { -0.14, 0.14 }, { -0.2, 0, 0.2 } })[stack]
			local artIcons = {}
			for k, offset in offsets do
				table.insert(artIcons, scope:Icon {
					Name = `Art{k}`,
					Icon = item.icon,
					Size = if offset == 0 then art else math.floor(art * 0.8),
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5 + offset, 0, 0, artY + (if offset == 0 then 0 else 8)),
					Rotation = offset * 70,
					Color = U.artColor(scope, tone),
					StrokeColor = U.edgeColor(scope, tone),
					Stroke = 4,
					Gradient = true,
					Shadow = true,
					ZIndex = if offset == 0 or #offsets == 2 then 4 else 3,
				})
			end
			local badge = item.tag or (if item.bonus then `+{item.bonus}%` else nil)
			local card
			card = Common.card(scope, {
				Name = item.id,
				Size = UDim2.fromOffset(g.colW, h),
				Tone = tone,
				Radius = "L",
				Clip = true,
				Children = {
					if isBest
						then Kit.rays(scope, {
							Name = "Rays",
							Size = UDim2.fromOffset(art * 3, art * 3),
							Position = UDim2.new(0.5, 0, 0, artY),
							Color = Color3.new(1, 1, 1),
							Transparency = 0.5,
							ZIndex = 2,
						})
						else nil,
					artIcons,
					if badge
						then Common.pill(scope, {
							Name = "Badge",
							Text = badge,
							Tone = if item.tag == "SALE" then "Red" elseif isBest then "Yellow" else "Dark",
							Height = 28,
							TextSize = 16,
							AnchorPoint = Vector2.new(0.5, 0),
							Position = UDim2.new(0.5, 0, 0, 10),
							ZIndex = 7,
						})
						else nil,
					scope:Text {
						Name = "Amount",
						Text = item.amount or (if item.grant then Format.comma(item.grant.amount) else ""),
						TextSize = 38,
						Font = "Display",
						AnchorPoint = Vector2.new(0.5, 0),
						Position = UDim2.new(0.5, 0, 0, amountY),
						Size = UDim2.new(1, -20, 0, 36),
						Scaled = true,
						Wrapped = false,
						MaxTextSize = 38,
						StrokeColor = U.edgeColor(scope, tone),
						Stroke = 3,
						ZIndex = 5,
					},
					scope:Text {
						Name = "Title",
						Text = item.title,
						TextSize = 19,
						Font = "Heavy",
						AnchorPoint = Vector2.new(0.5, 0),
						Position = UDim2.new(0.5, 0, 0, amountY + 40),
						Size = UDim2.new(1, -20, 0, 24),
						Scaled = true,
						Wrapped = false,
						MaxTextSize = 19,
						StrokeColor = U.edgeColor(scope, tone),
						ZIndex = 5,
					},
					U.buyButton(ctx, scope, s, info, item, {
						Size = UDim2.new(1, -20, 0, buttonH),
						TextSize = 23,
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.new(0.5, 0, 1, -12),
						Shine = isBest,
					}, function()
						return card
					end),
					if isBest
						then scope:New "Frame" {
							Name = "BestOutline",
							BackgroundTransparency = 1,
							Position = UDim2.fromOffset(3, 3),
							Size = UDim2.new(1, -6, 1, -6),
							ZIndex = 8,
							[Children] = {
								Kit.corner(scope, "L"),
								Kit.stroke(scope, {
									Color = Kit.themed(scope, function(theme)
										return theme.Color.Light or Color3.new(1, 1, 1)
									end),
									Thickness = 3,
								}),
							},
						}
						else nil,
				},
			})
			-- steps stand on one floor line
			local holder = scope:New "Frame" {
				Name = `Step{index}`,
				BackgroundTransparency = 1,
				LayoutOrder = j,
				Size = UDim2.fromOffset(g.colW, g.maxH),
				[Children] = { card },
			}
			card.AnchorPoint = Vector2.new(0, 1)
			card.Position = UDim2.new(0, 0, 1, 0)
			table.insert(cells, holder)
			index += 1
		end
		table.insert(rows, scope:Row {
			Name = `Row{rowIndex}`,
			LayoutOrder = rowIndex,
			Size = UDim2.new(1, 0, 0, g.maxH),
			Gap = g.gap,
			Align = "Center",
			VAlign = "Top",
			[Children] = cells,
		})
	end
	return scope:Column {
		Name = "Wall",
		Size = UDim2.new(1, 0, 0, g.height),
		Gap = g.gap,
		[Children] = rows,
	}, g.height
end

Store.measure.Wall = function(s, width)
	return wallGeometry(#s.items, width).height
end

--------------------------------------------------------------------------
-- Passes: medallion cards
--------------------------------------------------------------------------

local PASS_H = 136

local function passesGeometry(count: number, width: number): { [string]: number }
	local perRow = if width >= 760 and count >= 2 then 2 else 1
	local rows = math.ceil(count / perRow)
	return { perRow = perRow, cardW = math.floor((width - 16 * (perRow - 1)) / perRow), height = rows * PASS_H + math.max(0, rows - 1) * 16 }
end

Store.build.Passes = function(ctx, scope, s, width, info, H)
	local g = passesGeometry(#s.items, width)
	local cells = {}
	-- names and perks sit on plain cards: the card's own text colours
	local titleInk, perkInk = U.cardText(scope), U.cardText(scope, true)
	for index, item in s.items do
		local tone = item.tone or "Yellow"
		local medal = 104
		local priceW = math.clamp(Common.textWidth(U.priceText(item), 23) + 76, 130, 190)
		local textX = 16 + medal + 18
		local card
		card = Common.card(scope, {
			Name = item.id,
			Size = UDim2.fromOffset(g.cardW, PASS_H),
			Children = {
				scope:New "Frame" {
					Name = "Medal",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 16, 0.5, 0),
					Size = UDim2.fromOffset(medal, medal),
					BackgroundColor3 = Color3.new(1, 1, 1),
					ZIndex = 3,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.gradient(scope, {
							Rotation = 90,
							Color = Kit.themed(scope, function(theme)
								local t = Theme.tone(theme, tone)
								return ColorSequence.new(t.Top, t.Bottom)
							end),
						}),
						Kit.stroke(scope, { Color = U.edgeColor(scope, tone), Thickness = 4 }),
						Kit.rays(scope, {
							Name = "Rays",
							Size = UDim2.fromOffset(medal, medal),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = Color3.new(1, 1, 1),
							Transparency = 0.72,
							ZIndex = 3,
						}),
						scope:Icon {
							Name = "Art",
							Icon = item.icon,
							Size = 56,
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = Color3.new(1, 1, 1),
							StrokeColor = U.edgeColor(scope, tone),
							Stroke = 3,
							Gradient = true,
							Shadow = true,
							ZIndex = 4,
						},
					},
				},
				if item.tag
					then Common.pill(scope, {
						Name = "Tag",
						Text = item.tag,
						Tone = "Red",
						Height = 24,
						TextSize = 16,
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.new(0, 16 + medal / 2, 1, -4),
						ZIndex = 6,
					})
					else nil,
				scope:Text {
					Name = "Title",
					Text = item.title,
					TextSize = 29,
					Font = "Title",
					XAlign = "Left",
					Position = UDim2.fromOffset(textX, if item.description then 20 else 50),
					Size = UDim2.new(1, -(textX + priceW + 28), 0, 34),
					Scaled = true,
					Wrapped = false,
					MaxTextSize = 29,
					Color = titleInk.Color,
					Stroke = titleInk.Stroke,
					ZIndex = 3,
				},
				if item.description
					then scope:Text {
						Name = "Perk",
						Text = item.description,
						TextSize = 19,
						Font = "Body",
						XAlign = "Left",
						YAlign = "Top",
						Wrapped = true,
						-- two lines at most: a long perk shrinks rather than spill
						Scaled = true,
						MaxTextSize = 19,
						Color = perkInk.Color,
						Stroke = false,
						Position = UDim2.fromOffset(textX, 60),
						Size = UDim2.new(1, -(textX + priceW + 28), 0, 52),
						ZIndex = 3,
					}
					else nil,
				U.buyButton(ctx, scope, s, info, item, {
					Size = UDim2.fromOffset(priceW, 54),
					TextSize = 23,
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -16, 0.5, 0),
					Shine = index == 1,
				}, function()
					return card
				end),
			},
		})
		table.insert(cells, card)
	end
	return U.fixedRows(scope, cells, g.perRow, g.cardW, PASS_H, 16, "Passes")
end

Store.measure.Passes = function(s, width)
	return passesGeometry(#s.items, width).height
end

--------------------------------------------------------------------------
-- Mosaic: one tall featured offer beside a 2 x 2
--------------------------------------------------------------------------

local function mosaicGeometry(count: number, width: number): { [string]: any }
	local gap = 16
	local small = math.max(0, math.min(4, count - 1))
	local smallRows = math.ceil(small / 2)
	local smallH = 212
	if width >= 760 then
		local bigW = math.floor(width * 0.4)
		local smallW = math.floor((width - bigW - gap * 2) / 2)
		local bigH = math.max(smallRows, 2) * smallH + gap
		return { wide = true, gap = gap, bigW = bigW, bigH = bigH, smallW = smallW, smallH = smallH, small = small, height = bigH }
	end
	local bigH = 320
	local smallW = math.floor((width - gap) / 2)
	return { wide = false, gap = gap, bigW = width, bigH = bigH, smallW = smallW, smallH = smallH, small = small, height = bigH + (if smallRows > 0 then gap + smallRows * smallH + (smallRows - 1) * gap else 0) }
end

Store.build.Mosaic = function(ctx, scope, s, width, info)
	local list = s.items
	local g = mosaicGeometry(#list, width)
	local big = offerTile(ctx, scope, s, info, list[1], g.bigW, g.bigH, true)
	big.Position = UDim2.fromOffset(0, 0)
	local children = { big }
	for i = 1, g.small do
		local item = list[i + 1]
		local tile = offerTile(ctx, scope, s, info, item, g.smallW, g.smallH, false)
		local column = (i - 1) % 2
		local row = (i - 1) // 2
		if g.wide then
			tile.Position = UDim2.fromOffset(g.bigW + g.gap + column * (g.smallW + g.gap), row * (g.smallH + g.gap))
		else
			tile.Position = UDim2.fromOffset(column * (g.smallW + g.gap), g.bigH + g.gap + row * (g.smallH + g.gap))
		end
		table.insert(children, tile)
	end
	return scope:New "Frame" {
		Name = "Mosaic",
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, g.height),
		[Children] = children,
	}, g.height
end

Store.measure.Mosaic = function(s, width)
	return mosaicGeometry(#s.items, width).height
end

return Store
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="252">
              <Properties>
                <string name="Name">Util</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs.Layouts.Util: small pieces the v2.4 section layouts share (price
	buttons, rarity chips, star rows, bars, fixed-width rows). Everything is
	built from the Kit, Common and the theme's tones, so style families
	restyle it like any other part of the UI.
]]

local Fusion = require(script.Parent.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Parent.Kit)
local Format = require(script.Parent.Parent.Parent.Util.Format)
local Color = require(script.Parent.Parent.Parent.Util.Color)
local Common = require(script.Parent.Parent.Parent.Blueprint.Common)
local Schema = require(script.Parent.Parent.Parent.Blueprint.Schema)

local Children = Fusion.Children

local U = {}

U.Children = Children
U.GAP = 24

-- Rarity rank ("Common" 1 .. "Secret" 7); 0 for anything else.
function U.rank(rarity: string?): number
	return Schema.RARITY_RANK[rarity or ""] or 0
end

-- An offer's rarity: its own, else a rarity-named tag ("EPIC").
function U.rarityOf(item: { [string]: any }): string?
	if typeof(item.rarity) == "string" and Schema.RARITY_RANK[item.rarity] then
		return item.rarity
	end
	local tag = item.tag
	if typeof(tag) == "string" and tag ~= "" then
		local name = string.upper(string.sub(tag, 1, 1)) .. string.lower(string.sub(tag, 2))
		if Schema.RARITY_RANK[name] then
			return name
		end
	end
	return nil
end

-- The tone an offer's card wears: its rarity's, else its own tone.
function U.toneOf(item: { [string]: any }): string
	return U.rarityOf(item) or item.tone or "Blue"
end

function U.priceText(item: { [string]: any }): string
	return if (item.price or 0) > 0 then Format.comma(item.price) else "FREE"
end

function U.priceIcon(ctx, item: { [string]: any }): string?
	return if (item.price or 0) > 0 then Common.priceIcon(ctx.ui.currencyDefs, item.currency) else nil
end

-- The green price button that buys `item` (ctx.buy runs the purchase).
function U.buyButton(ctx, scope, s, info, item, props: { [string]: any }, source: () -> Instance?): Instance
	return ctx.button(scope, {
		Name = props.Name or "Buy",
		Text = props.Text or U.priceText(item),
		Icon = if props.Text then props.Icon else U.priceIcon(ctx, item),
		Tone = props.Tone or "Green",
		Shine = props.Shine,
		Pulse = props.Pulse,
		Primary = props.Primary,
		Size = props.Size,
		TextSize = props.TextSize or 23,
		AnchorPoint = props.AnchorPoint,
		Position = props.Position,
		ZIndex = props.ZIndex or 6,
	}, { kind = "None" }, nil, function()
		ctx.buy(item, { id = item.id, screen = info.screen, section = s.id, source = source() })
	end)
end

-- A small pill naming a rarity (or any short text) in its tone.
function U.chip(scope, props: { [string]: any }): Frame
	local rarity = props.Rarity
	return Common.pill(scope, {
		Name = props.Name or "Chip",
		Text = props.Text or string.upper(rarity or ""),
		Color = if rarity
			then Kit.themed(scope, function(theme)
				return Common.rarityTone(theme, rarity).Deep or Color.darken(Common.rarityTone(theme, rarity).Bottom, 0.5)
			end)
			else props.Color,
		Tone = props.Tone,
		Transparency = props.Transparency or 0.1,
		Height = props.Height or 26,
		TextSize = props.TextSize or 16,
		TextColor = if rarity
			then Kit.themed(scope, function(theme)
				return Color.lighten(Common.rarityTone(theme, rarity).Top, 0.35)
			end)
			else props.TextColor,
		AnchorPoint = props.AnchorPoint,
		Position = props.Position,
		LayoutOrder = props.LayoutOrder,
		ZIndex = props.ZIndex or 5,
	})
end

-- A row of `count` star icons, centred on `position`.
function U.stars(scope, count: number, size: number, props: { [string]: any }): Frame
	local stars = {}
	for index = 1, math.clamp(count, 1, 7) do
		table.insert(stars, scope:Icon {
			Name = `Star{index}`,
			Icon = "star",
			Size = size,
			Color = props.Color or Color3.fromRGB(255, 214, 64),
			Gradient = true,
			Shadow = true,
			LayoutOrder = index,
			ZIndex = props.ZIndex or 5,
		})
	end
	return scope:Row {
		Name = "Stars",
		Size = UDim2.fromOffset(count * (size + 4), size),
		AnchorPoint = props.AnchorPoint or Vector2.new(0.5, 0.5),
		Position = props.Position,
		Gap = 4,
		Align = "Center",
		ZIndex = props.ZIndex or 5,
		[Children] = stars,
	}
end

-- A horizontal bar (0..1, number or state) on a dark track.
function U.bar(scope, props: { [string]: any }): Frame
	local value = props.Value
	return scope:New "Frame" {
		Name = props.Name or "Bar",
		Size = props.Size,
		Position = props.Position or UDim2.new(),
		AnchorPoint = props.AnchorPoint or Vector2.zero,
		BackgroundColor3 = Color3.new(0, 0, 0),
		BackgroundTransparency = 0.55,
		LayoutOrder = props.LayoutOrder or 0,
		ZIndex = props.ZIndex or 4,
		[Children] = {
			Kit.corner(scope, "Full"),
			scope:New "Frame" {
				Name = "Fill",
				Size = scope:Computed(function(use)
					return UDim2.fromScale(math.clamp(tonumber(use(value)) or 0, 0.04, 1), 1)
				end),
				BackgroundColor3 = Color3.new(1, 1, 1),
				ZIndex = (props.ZIndex or 4) + 1,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.gradient(scope, {
						Rotation = 0,
						Color = Kit.themed(scope, function(theme)
							local tone = Theme.tone(theme, props.Tone or "Good")
							return ColorSequence.new(tone.Bottom, tone.Top)
						end),
					}),
				},
			},
		},
	}
end

-- Cells of a fixed width in centred rows (the last row centred too).
-- Returns the block and its height.
function U.fixedRows(scope, cells: { Instance }, perRow: number, cellW: number, cellH: number, gap: number, name: string?): (Frame, number)
	local rows = {}
	local index = 1
	local rowCount = math.ceil(#cells / math.max(1, perRow))
	for r = 1, rowCount do
		local row = {}
		for c = 1, perRow do
			local cell = cells[index]
			if cell == nil then
				break
			end
			index += 1
			cell.Size = UDim2.fromOffset(cellW, cellH)
			cell.LayoutOrder = c
			table.insert(row, cell)
		end
		table.insert(rows, scope:Row {
			Name = `Row{r}`,
			LayoutOrder = r,
			Size = UDim2.new(1, 0, 0, cellH),
			Gap = gap,
			Align = "Center",
			VAlign = "Top",
			[Children] = row,
		})
	end
	local height = rowCount * cellH + math.max(0, rowCount - 1) * gap
	return scope:Column {
		Name = name or "Rows",
		Size = UDim2.new(1, 0, 0, height),
		Gap = gap,
		[Children] = rows,
	}, height
end

-- Text props for text on a glass card or straight on the window panel: dark
-- ink on light (cream / white) panels, the theme's text on dark ones.
-- `ink` is Blueprint.Sections' panelInk state; `muted` picks the softer ink.
function U.inkText(scope, ink: any, muted: boolean?): { [string]: any }
	return {
		Color = scope:Computed(function(use)
			local value = use(ink)
			return if muted then value.muted else value.text
		end),
		Stroke = scope:Computed(function(use)
			local value = use(ink)
			if value.light then
				return false
			end
			return if muted then (if value.stroke then 1.5 else false) else 2
		end),
		StrokeColor = scope:Computed(function(use)
			return use(ink).stroke
		end),
	}
end

-- Text props for text on a plain (untoned) Common.card: the theme's CardText /
-- CardMuted (style families make cards a deep inset that carries light text),
-- else the classic Text / TextMuted used on the dark glass plates.
function U.cardText(scope, muted: boolean?): { [string]: any }
	return {
		Color = Kit.themed(scope, function(theme)
			local c = theme.Color
			return if muted then (c.CardMuted or c.TextMuted) else (c.CardText or c.Text)
		end),
		Stroke = if muted then false else 2,
		StrokeColor = nil,
	}
end

-- `child` inside a transparent full-size holder that shows while `visible`
-- (for pieces like Common.pill that take no Visible of their own).
function U.shown(scope, visible: any, child: Instance, z: number?): Frame
	return scope:New "Frame" {
		Name = child.Name .. "Holder",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		Visible = visible,
		ZIndex = z or 6,
		[Children] = child,
	}
end

-- A lighter / darker shade of a tone's colour for art on a card.
function U.artColor(scope, tone: any, lift: number?)
	return Kit.themed(scope, function(theme)
		return Color.lighten(Theme.tone(theme, tone).Top, lift or 0.3)
	end)
end

function U.edgeColor(scope, tone: any)
	return Kit.themed(scope, function(theme)
		return Theme.tone(theme, tone).Edge
	end)
end

return U
]]></string>
              </Properties>
            </Item>
          </Item>
          <Item class="ModuleScript" referent="253">
            <Properties>
              <string name="Name">LoadingScreen</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs.LoadingScreen: a full-screen loading screen. Animated gradient
	backdrop with slow sun rays, drifting icons and rising sparkles, your logo
	(bobbing), a glossy progress bar, rotating tips, then a fade-out.

	local loading = UI.Prefabs.LoadingScreen(scope, {
		Logo = { Text = "PET QUEST", Subtitle = "SEASON 2", Type = "Wordmark", Tone = "Yellow", Icon = "egg" },
		Tips = { "Eggs hatch faster with the Lucky boost!", "Trade with friends in the Plaza." },
		Duration = 3,            -- seconds for the bar to fill by itself; 0 = you drive Progress
		Status = "Loading",      -- text above the bar (string or state)
		Tone = "Yellow",         -- bar colour (default: the logo tone)
		BackdropTone = "Blue",   -- backdrop colour (default: a colour that makes the logo pop)
		OnDone = function() end, -- after the fade-out
		Destroy = true,          -- clean the screen up after OnDone (default)
	})

	loading.Progress:set(0.5)   -- Value<number> 0..1 (reaching 1 finishes)
	loading.finish()            -- fill, fade out, call OnDone, clean up
	loading.Gui                 -- the ScreenGui
]]

local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Icons = require(script.Parent.Parent.Icons)
local Color = require(script.Parent.Parent.Util.Color)
local Motion = require(script.Parent.Parent.FX.Motion)
local Sound = require(script.Parent.Parent.Util.Sound)
local Screen = require(script.Parent.Parent.Components.Screen)
local Logo = require(script.Parent.Parent.Components.Logo)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local RAY = "rbxasset://textures/ui/Emotes/Large/SelectedGradient.png"
local GLOW = Assets.Glow or "rbxasset://textures/ui/InGameMenu/BackgroundGlow.png"
local CIRCLE = Assets.Circle or "rbxasset://textures/whiteCircle.png"

-- Backdrop colour that makes a logo of each tone pop.
local CONTRAST = {
	Yellow = "Blue",
	Orange = "Blue",
	Green = "Blue",
	Red = "Purple",
	Pink = "Purple",
	Purple = "Blue",
	Blue = "Purple",
	Cyan = "Purple",
	White = "Blue",
	Gray = "Dark",
	Dark = "Dark",
}

local DEFAULT_TIPS = {
	"Tip: invite friends for a bonus!",
	"Tip: check the shop every day for new deals.",
	"Tip: claim your daily reward from the calendar.",
}

return function(outerScope: Scope, config: { [string]: any })
	-- own scope, so finish() can clean up without touching the caller's scope
	local scope = Fusion.innerScope(outerScope, Fusion)
	local logoProps = table.clone(config.Logo or { Text = "MY GAME" })
	local tips = config.Tips or DEFAULT_TIPS
	local duration = if config.Duration == nil then 3 else config.Duration
	local tone = Kit.tone(scope, config.Tone or logoProps.Tone, "Yellow")
	local backTone = scope:Computed(function(use)
		local given = use(config.BackdropTone)
		if given == nil then
			local logoTone = use(logoProps.Tone)
			given = if typeof(logoTone) == "string" then CONTRAST[logoTone] else nil
		end
		return Theme.tone(use(Theme.current), given or "Blue")
	end)

	local alive = true
	table.insert(scope, function()
		alive = false
	end)

	local progress = scope:Value(0)
	local shownProgress = scope:Spring(progress, 9, 1)
	local finishing = false
	local fadeGoal = scope:Value(0)
	local fade = scope:Spring(fadeGoal, 7, 1) -- 0 = visible, 1 = gone
	local introGoal = scope:Value(0)
	local intro = scope:Spring(introGoal, 14, 0.62)
	intro:setPosition(0)

	-- transparency `base` pushed towards 1 while fading out
	local function alpha(base: any): any
		return scope:Computed(function(use)
			local own = use(base) or 0
			return 1 - (1 - own) * (1 - math.clamp(use(fade), 0, 1))
		end)
	end

	local top = scope:Computed(function(use)
		return use(tone).Top
	end)
	local outline = scope:Computed(function(use)
		local t = use(tone)
		return t.Stroke or Color.darken(t.Edge, 0.35)
	end)

	-- backdrop ----------------------------------------------------------------

	local backGradient = scope:New "UIGradient" {
		Rotation = 70,
		Color = scope:Computed(function(use)
			local t = use(backTone)
			return ColorSequence.new({
				ColorSequenceKeypoint.new(0, t.Bottom),
				ColorSequenceKeypoint.new(0.5, t.Edge),
				ColorSequenceKeypoint.new(1, Color.darken(t.Deep, 0.35)),
			})
		end),
	}

	local rays = {}
	local RAY_COUNT = 18
	local RAY_SIZE = 2400
	for i = 1, RAY_COUNT do
		local angle = (i - 1) / RAY_COUNT * 360
		local rad = math.rad(angle)
		local length = RAY_SIZE / 2
		rays[i] = scope:New "ImageLabel" {
			Name = "Ray",
			BackgroundTransparency = 1,
			Image = RAY,
			ImageColor3 = scope:Computed(function(use)
				return Color.lighten(use(backTone).Top, 0.6)
			end),
			ImageTransparency = alpha(0.86),
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5, math.cos(rad) * length / 2, 0.5, math.sin(rad) * length / 2),
			Size = UDim2.fromOffset(length, 2 * length * math.tan(math.rad(180 / RAY_COUNT * 0.5))),
			Rotation = angle,
			ZIndex = 2,
		}
	end
	local rayFrame = scope:New "Frame" {
		Name = "Rays",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.4),
		Size = UDim2.fromOffset(RAY_SIZE, RAY_SIZE),
		ZIndex = 2,
		[Children] = rays,
	}

	-- drifting icon wallpaper
	local kind, glyph = Icons.resolve(logoProps.Icon or "star")
	local pattern = {}
	-- re-resolves if the icon font falls back on this device
	local motifFont = scope:Computed(function(use)
		return Icons.font(true, use)
	end)
	if kind == "glyph" then
		local columns, rows, cell = 11, 7, 230
		for row = 0, rows - 1 do
			for column = 0, columns - 1 do
				table.insert(pattern, scope:New "TextLabel" {
					Name = "Motif",
					BackgroundTransparency = 1,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromOffset(column * cell + (row % 2) * cell / 2, row * cell),
					Size = UDim2.fromOffset(84, 84),
					Rotation = if (row + column) % 2 == 0 then -14 else 12,
					FontFace = motifFont,
					Text = glyph,
					TextScaled = true,
					TextColor3 = scope:Computed(function(use)
						return Color.lighten(use(backTone).Top, 0.4)
					end),
					TextTransparency = alpha(0.9),
					ZIndex = 3,
				})
			end
		end
	end
	local patternFrame = scope:New "Frame" {
		Name = "Pattern",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(11 * 230, 7 * 230),
		ZIndex = 3,
		[Children] = pattern,
	}

	-- rising motes
	local motes = {}
	local moteState = {}
	for i = 1, 16 do
		local x = scope:Value((i - 0.5) / 16 + (math.random() - 0.5) * 0.04)
		local y = scope:Value(math.random())
		local size = 6 + math.random() * 14
		table.insert(moteState, { x = x, y = y, speed = 0.03 + math.random() * 0.05, sway = math.random() * 6 })
		motes[i] = scope:New "ImageLabel" {
			Name = "Mote",
			BackgroundTransparency = 1,
			Image = CIRCLE,
			ImageColor3 = scope:Computed(function(use)
				return Color.lighten(use(backTone).Top, 0.7)
			end),
			ImageTransparency = scope:Computed(function(use)
				local height = use(y)
				local edge = math.min(height, 1 - height) * 5 -- fade at top and bottom
				return 1 - (1 - (0.55 + (i % 3) * 0.12)) * math.clamp(edge, 0, 1) * (1 - math.clamp(use(fade), 0, 1))
			end),
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = scope:Computed(function(use)
				return UDim2.fromScale(use(x), use(y))
			end),
			Size = UDim2.fromOffset(size, size),
			ZIndex = 4,
		}
	end

	local backdrop = scope:New "Frame" {
		Name = "Backdrop",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Color3.new(1, 1, 1),
		BackgroundTransparency = alpha(0),
		ClipsDescendants = true,
		Active = true,
		ZIndex = 1,
		[Children] = {
			-- pushes towards the camera as it fades into the game
			scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					local out = math.clamp(use(fade), 0, 1)
					return 1 + 0.08 * out * out
				end),
			},
			backGradient,
			rayFrame,
			patternFrame,
			motes,
			scope:New "Frame" {
				Name = "CenterGlow",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.4),
				Size = UDim2.fromOffset(1500, 1100),
				ZIndex = 3,
				[Children] = (function()
					local layers = {}
					for i = 1, 3 do
						layers[i] = scope:New "ImageLabel" {
							Name = "Glow",
							BackgroundTransparency = 1,
							Image = GLOW,
							ImageColor3 = scope:Computed(function(use)
								return Color.lighten(use(backTone).Top, 0.35)
							end),
							ImageTransparency = alpha(0),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Size = UDim2.fromScale(1 - (i - 1) * 0.25, 1 - (i - 1) * 0.25),
							ZIndex = 3,
						}
					end
					return layers
				end)(),
			},
			scope:New "ImageLabel" {
				Name = "Vignette",
				BackgroundTransparency = 1,
				Image = Assets.Vignette,
				ImageColor3 = scope:Computed(function(use)
					return Color.darken(use(backTone).Deep, 0.6)
				end),
				ImageTransparency = alpha(0),
				Size = UDim2.fromScale(1, 1),
				ZIndex = 5,
			},
			scope:New "Frame" {
				Name = "BottomShade",
				AnchorPoint = Vector2.new(0, 1),
				Position = UDim2.fromScale(0, 1),
				Size = UDim2.fromScale(1, 0.4),
				BackgroundColor3 = scope:Computed(function(use)
					return Color.darken(use(backTone).Deep, 0.5)
				end),
				BackgroundTransparency = alpha(0),
				ZIndex = 5,
				[Children] = Kit.gradient(scope, {
					Rotation = 90,
					Transparency = NumberSequence.new(1, 0.35),
				}),
			},
		},
	}

	-- logo ------------------------------------------------------------------

	logoProps.Height = logoProps.Height or 230
	logoProps.AnchorPoint = Vector2.new(0.5, 0.5)
	logoProps.Position = UDim2.fromScale(0.5, 0.5)
	logoProps.Transparency = scope:Computed(function(use)
		local shown = math.clamp(use(intro), 0, 1)
		return 1 - shown * (1 - math.clamp(use(fade), 0, 1))
	end)
	-- the "loaded!" beat: the logo punches, the bar flashes, then the whole
	-- screen pushes towards the camera as it fades into the game
	local readyPopGoal = scope:Value(1)
	local readyPop = scope:Spring(readyPopGoal, 26, 0.4)
	local isReady = scope:Value(false)
	local barFlash = scope:Spring(scope:Value(1), 12, 1) -- transparency; setPosition(0) flashes

	local logoHolder = scope:New "Frame" {
		Name = "LogoHolder",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.4),
		Size = UDim2.fromOffset(10, 10),
		ZIndex = 10,
		[Children] = {
			scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					local out = math.clamp(use(fade), 0, 1)
					return (0.7 + 0.3 * use(intro) + 0.3 * out * out) * use(readyPop)
				end),
			},
			Logo(scope, logoProps),
		},
	}

	-- progress bar ------------------------------------------------------------

	local BAR_W, BAR_H = 760, 34
	local percent = scope:Computed(function(use)
		return `{math.floor(math.clamp(use(shownProgress), 0, 1) * 100 + 0.5)}%`
	end)
	local fontHeavy = scope:Computed(function(use)
		return Theme.font(use(Theme.current), "Heavy")
	end)
	local fontBody = scope:Computed(function(use)
		return Theme.font(use(Theme.current), "Body")
	end)
	local textColor = Kit.themed(scope, function(theme)
		return theme.Color.Text
	end)

	local fillShine = scope:New "UIGradient" {
		Rotation = 0,
		Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 1),
			NumberSequenceKeypoint.new(0.4, 1),
			NumberSequenceKeypoint.new(0.5, 0.35),
			NumberSequenceKeypoint.new(0.6, 1),
			NumberSequenceKeypoint.new(1, 1),
		}),
		Offset = Vector2.new(-1, 0),
	}

	local bar = scope:New "Frame" {
		Name = "Bar",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(BAR_W, BAR_H),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Track
		end),
		BackgroundTransparency = alpha(0.15),
		ZIndex = 20,
		[Children] = {
			Kit.corner(scope, "Full"),
			scope:New "UIStroke" {
				ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				Color = outline,
				Thickness = 3,
				Transparency = alpha(0),
			},
			scope:New "Frame" {
				Name = "Fill",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 4, 0.5, 0),
				Size = scope:Computed(function(use)
					local fraction = math.clamp(use(shownProgress), 0, 1)
					local inner = BAR_W - 8
					return UDim2.fromOffset(math.max(BAR_H - 8, inner * fraction), BAR_H - 8)
				end),
				BackgroundColor3 = Color3.new(1, 1, 1),
				BackgroundTransparency = alpha(0),
				ZIndex = 21,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = scope:Computed(function(use)
							local t = use(tone)
							return ColorSequence.new({
								ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.35)),
								ColorSequenceKeypoint.new(0.5, t.Top),
								ColorSequenceKeypoint.new(1, t.Bottom),
							})
						end),
					}),
					scope:New "Frame" {
						Name = "Gloss",
						BackgroundColor3 = Color3.new(1, 1, 1),
						BackgroundTransparency = alpha(0.55),
						Position = UDim2.new(0, 6, 0, 3),
						Size = UDim2.new(1, -12, 0.36, 0),
						ZIndex = 22,
						[Children] = Kit.corner(scope, "Full"),
					},
					scope:New "Frame" {
						Name = "Shine",
						BackgroundColor3 = Color3.new(1, 1, 1),
						BackgroundTransparency = alpha(0),
						Size = UDim2.fromScale(1, 1),
						ZIndex = 23,
						[Children] = { Kit.corner(scope, "Full"), fillShine },
					},
					-- flashes white when loading completes
					scope:New "Frame" {
						Name = "DoneFlash",
						BackgroundColor3 = Color3.new(1, 1, 1),
						BackgroundTransparency = alpha(scope:Computed(function(use)
						return math.clamp(use(barFlash), 0, 1)
					end)),
						Size = UDim2.fromScale(1, 1),
						ZIndex = 24,
						[Children] = Kit.corner(scope, "Full"),
					},
				},
			},
		},
	}

	local status = config.Status or "Loading"
	local dots = scope:Value("")

	local barGroup = scope:New "Frame" {
		Name = "Progress",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = scope:Computed(function(use)
			return UDim2.new(0.5, 0, 0.79, (1 - use(intro)) * 60)
		end),
		Size = UDim2.fromOffset(BAR_W, BAR_H),
		ZIndex = 20,
		[Children] = {
			bar,
			scope:New "TextLabel" {
				Name = "Status",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(0, 1),
				Position = UDim2.new(0, 10, 0, -10),
				Size = UDim2.fromOffset(0, 30),
				AutomaticSize = Enum.AutomaticSize.X,
				Text = scope:Computed(function(use)
					if use(isReady) then
						return config.ReadyText or "Ready!"
					end
					return tostring(use(status)) .. use(dots)
				end),
				TextSize = 26,
				FontFace = fontHeavy,
				TextColor3 = textColor,
				TextTransparency = alpha(0),
				TextXAlignment = Enum.TextXAlignment.Left,
				ZIndex = 20,
				[Children] = scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual,
					LineJoinMode = Enum.LineJoinMode.Round,
					Color = outline,
					Thickness = 2,
					Transparency = alpha(0),
				},
			},
			scope:New "TextLabel" {
				Name = "Percent",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(1, 1),
				Position = UDim2.new(1, -10, 0, -10),
				Size = UDim2.fromOffset(0, 30),
				AutomaticSize = Enum.AutomaticSize.X,
				Text = percent,
				TextSize = 28,
				FontFace = scope:Computed(function(use)
					return Theme.font(use(Theme.current), "Display")
				end),
				TextColor3 = top,
				TextTransparency = alpha(0),
				TextXAlignment = Enum.TextXAlignment.Right,
				ZIndex = 20,
				[Children] = scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual,
					LineJoinMode = Enum.LineJoinMode.Round,
					Color = outline,
					Thickness = 2.5,
					Transparency = alpha(0),
				},
			},
		},
	}

	-- tips --------------------------------------------------------------------

	local tipIndex = scope:Value(1)
	local tipGoal = scope:Value(1)
	local tipShown = scope:Spring(tipGoal, 12, 1)
	local tipAlpha = scope:Computed(function(use)
		return 1 - (1 - (1 - math.clamp(use(tipShown), 0, 1))) * (1 - math.clamp(use(fade), 0, 1)) * math.clamp(use(intro), 0, 1)
	end)
	local tipRow = scope:New "Frame" {
		Name = "Tip",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = scope:Computed(function(use)
			return UDim2.new(0.5, 0, 0.865, (1 - math.clamp(use(tipShown), 0, 1)) * 10)
		end),
		Size = UDim2.fromOffset(0, 40),
		AutomaticSize = Enum.AutomaticSize.X,
		Visible = #tips > 0,
		ZIndex = 20,
		[Children] = {
			Kit.list(scope, { Direction = "X", Padding = 12, VAlign = "Center" }),
			scope:New "Frame" {
				Name = "Chip",
				Size = UDim2.fromOffset(0, 32),
				AutomaticSize = Enum.AutomaticSize.X,
				BackgroundColor3 = top,
				BackgroundTransparency = tipAlpha,
				LayoutOrder = 1,
				ZIndex = 20,
				[Children] = {
					Kit.corner(scope, "Full"),
					Kit.padding(scope, { 0, 12 }),
					scope:New "TextLabel" {
						Name = "Label",
						BackgroundTransparency = 1,
						Size = UDim2.fromScale(0, 1),
						AutomaticSize = Enum.AutomaticSize.X,
						Text = config.TipLabel or "TIP",
						TextSize = 20,
						FontFace = fontHeavy,
						TextColor3 = outline,
						TextTransparency = tipAlpha,
						ZIndex = 21,
					},
				},
			},
			scope:New "TextLabel" {
				Name = "Text",
				BackgroundTransparency = 1,
				Size = UDim2.fromOffset(0, 40),
				AutomaticSize = Enum.AutomaticSize.X,
				Text = scope:Computed(function(use)
					local text = tips[use(tipIndex)] or ""
					return (string.gsub(text, "^[Tt]ip:%s*", ""))
				end),
				TextSize = 26,
				FontFace = fontBody,
				TextColor3 = textColor,
				TextTransparency = tipAlpha,
				LayoutOrder = 2,
				ZIndex = 20,
				[Children] = scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual,
					Color = outline,
					Thickness = 1.5,
					Transparency = tipAlpha,
				},
			},
		},
	}

	-- gui -----------------------------------------------------------------------

	local gui = Screen(scope, {
		Name = config.Name or "BloxUI_Loading",
		DisplayOrder = config.DisplayOrder or 990,
		SafeArea = "None",
		Parent = config.Parent,
		Enabled = scope:Computed(function(use)
			return use(fade) < 0.995
		end),
		[Children] = {
			backdrop,
			logoHolder,
			barGroup,
			tipRow,
		},
	})

	-- motion ------------------------------------------------------------------

	local driftTween = TweenService:Create(
		backGradient,
		TweenInfo.new(7, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true),
		{ Rotation = 110, Offset = Vector2.new(0.12, 0.08) }
	)
	driftTween:Play()
	local patternTween = TweenService:Create(
		patternFrame,
		TweenInfo.new(18, Enum.EasingStyle.Linear, Enum.EasingDirection.In, -1),
		{ Position = UDim2.new(0.5, 230, 0.5, 230) }
	)
	patternTween:Play()
	local shineTween = TweenService:Create(
		fillShine,
		TweenInfo.new(0.9, Enum.EasingStyle.Quad, Enum.EasingDirection.InOut, -1, false, 0.8),
		{ Offset = Vector2.new(1, 0) }
	)
	shineTween:Play()
	-- a finished screen that's kept around (Destroy = false) is disabled:
	-- its loops pause until it's shown again
	local untrackLoops = {
		Motion.track(driftTween, backGradient),
		Motion.track(patternTween, patternFrame),
		Motion.track(shineTween, fillShine),
	}
	table.insert(scope, function()
		for _, untrack in untrackLoops do
			untrack()
		end
		driftTween:Cancel()
		patternTween:Cancel()
		shineTween:Cancel()
	end)
	table.insert(scope, Motion.spin(rayFrame, 60))
	local bobStop = Motion.bob(logoHolder, 8, 2.6)
	table.insert(scope, bobStop)

	-- motes rise and wrap around
	task.spawn(function()
		local last = os.clock()
		while alive do
			if not gui.Enabled then
				-- finished or hidden: nothing to move (checked a few times a
				-- second in case the screen is shown again)
				task.wait(0.25)
				last = os.clock()
				continue
			end
			task.wait()
			local now = os.clock()
			local dt = math.min(0.1, now - last)
			last = now
			for _, mote in moteState do
				local y = peek(mote.y) - mote.speed * dt
				if y < -0.02 then
					y = 1.02
				end
				mote.y:set(y)
			end
		end
	end)

	-- status dots
	task.spawn(function()
		local count = 0
		while alive do
			task.wait(0.4)
			count = (count + 1) % 4
			if alive and gui.Enabled then
				dots:set(string.rep(".", count))
			end
		end
	end)

	-- rotating tips
	if #tips > 1 then
		task.spawn(function()
			local every = config.TipInterval or 3
			while alive do
				task.wait(every)
				if not alive then
					return
				end
				if not gui.Enabled then
					continue
				end
				tipGoal:set(0)
				task.wait(0.3)
				if not alive then
					return
				end
				tipIndex:set(peek(tipIndex) % #tips + 1)
				tipGoal:set(1)
			end
		end)
	end

	-- finish ------------------------------------------------------------------

	local function finish()
		if finishing or not alive then
			return
		end
		finishing = true
		progress:set(1)
		task.spawn(function()
			task.wait(0.4)
			if not alive then
				return
			end
			-- loaded! the bar flashes, the logo punches, "Ready!"
			isReady:set(true)
			barFlash:setPosition(0)
			readyPop:setPosition(1.1)
			Sound.play("Pop")
			task.wait(0.3)
			if not alive then
				return
			end
			fadeGoal:set(1)
			Sound.play("Swoosh")
			task.wait(0.75)
			if not alive then
				return
			end
			if config.OnDone then
				config.OnDone()
			end
			if config.Destroy ~= false then
				task.defer(function()
					if alive then
						Fusion.doCleanup(scope)
					end
				end)
			end
		end)
	end

	Kit.watch(scope, progress, function(value)
		if value >= 1 then
			finish()
		end
	end)

	-- auto fill
	introGoal:set(1)
	if duration and duration > 0 then
		task.spawn(function()
			local start = os.clock()
			while alive and not finishing do
				task.wait()
				local t = (os.clock() - start) / duration
				if t >= 1 then
					finish()
					return
				end
				-- eased with a couple of believable "loading chunk" stalls
				local eased = 1 - (1 - t) ^ 1.7
				local stepped = eased - 0.04 * math.sin(t * math.pi * 3) ^ 2
				local value = math.clamp(stepped, peek(progress), 0.99)
				progress:set(value)
			end
		end)
	end

	return {
		Gui = gui,
		Progress = progress,
		finish = finish,
	}
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="254">
            <Properties>
              <string name="Name">MainMenu</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs.MainMenu: the title screen. Animated backdrop, your logo, one big
	pulsing primary button (the first in Buttons), smaller secondary buttons,
	an optional featured Banner and a version tag. Everything springs in when
	it opens and out when it closes.

	local menu = UI.Prefabs.MainMenu(scope, {
		Logo = { Text = "NEON STRIKE", Subtitle = "SEASON 1", Type = "Wordmark", Tone = "Yellow", Icon = "crosshairs" },
		Buttons = {
			{ Text = "PLAY", Icon = "play", Tone = "Green", OnClick = function() menu.Open:set(false) end },
			{ Text = "Shop", Icon = "shop", OnClick = openShop },
			{ Text = "Settings", Icon = "settings", OnClick = openSettings },
		},
		Banner = { Title = "SEASON 1", Subtitle = "New maps and skins!", Icon = "trophy", Tone = "Purple",
		           ButtonText = "View", EndsAt = os.time() + 86400 * 3 },
		Version = "v1.0.0",
		Backdrop = "Full",        -- "Full" (animated) | "Side" (scrim over your 3D lobby) | "None"
		BackdropTone = "Blue",    -- backdrop colour (default: a colour that makes the logo pop)
		Open = true,              -- start open (default)
	})

	menu.Open:set(false)   -- animate out (Value<boolean>)
	menu.Gui               -- the ScreenGui
	menu.destroy()
]]

local GuiService = game:GetService("GuiService")
local TweenService = game:GetService("TweenService")
local TextService = game:GetService("TextService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Icons = require(script.Parent.Parent.Icons)
local Color = require(script.Parent.Parent.Util.Color)
local Motion = require(script.Parent.Parent.FX.Motion)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Input = require(script.Parent.Parent.Util.Input)
local Sound = require(script.Parent.Parent.Util.Sound)
local Screen = require(script.Parent.Parent.Components.Screen)
local Button = require(script.Parent.Parent.Components.Button)
local Logo = require(script.Parent.Parent.Components.Logo)
local Banner = require(script.Parent.Parent.Components.Banner)

local Children = Fusion.Children
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local RAY = "rbxasset://textures/ui/Emotes/Large/SelectedGradient.png"
local GLOW = Assets.Glow or "rbxasset://textures/ui/InGameMenu/BackgroundGlow.png"

-- Rough advance widths (in em) for bold display fonts, used for an instant
-- first guess before TextService measures the real font.
local function estimateWidth(text: string, upper: boolean): number
	local total = 0
	for _, code in utf8.codes(text) do
		local char = utf8.char(code)
		if char == " " then
			total += 0.27
		elseif upper or string.match(char, "[%uA-Z0-9]") then
			total += if string.match(char, "[MW]") then 0.86 elseif string.match(char, "[IJ1il]") then 0.34 else 0.66
		else
			total += if string.match(char, "[mw]") then 0.8 elseif string.match(char, "[ijlt]") then 0.3 else 0.55
		end
	end
	return total
end

-- A TextSize state that keeps `text` inside `available` px: at most `maxSize`,
-- never below `minSize`. Starts from an estimate, then measures the theme's
-- real font with TextService (and again whenever the theme changes).
local function fitTextSize(scope: Scope, text: string, available: number, maxSize: number, minSize: number, alive: () -> boolean)
	local function upperCase(theme: any): boolean
		return Theme.style(theme, "ButtonCase") == "Upper" or Theme.style(theme, "TextCase") == "Upper"
	end
	local function clampSize(size: number): number
		return math.clamp(math.floor(size), minSize, maxSize)
	end
	local size = scope:Value(clampSize(available / math.max(0.1, estimateWidth(text, upperCase(Theme.get())))))

	local function measure()
		task.spawn(function()
			local theme = Theme.get()
			local shown = if upperCase(theme) then string.upper(text) else text
			local ok, bounds = pcall(function()
				-- Roblox: Instance.new("GetTextBoundsParams"); some hosts expose a .new() global instead
				local factory = GetTextBoundsParams :: any
				local params = if factory and factory.new then factory.new() else Instance.new("GetTextBoundsParams")
				params.Text = shown
				params.Font = Theme.font(theme, "Display")
				params.Size = maxSize
				params.Width = math.huge
				return TextService:GetTextBoundsAsync(params)
			end)
			if ok and typeof(bounds) == "Vector2" and bounds.X > 0 and alive() then
				size:set(clampSize(maxSize * available / bounds.X))
			end
		end)
	end
	measure()
	Kit.watch(scope, Theme.current, measure)
	return size
end

-- Backdrop colour that makes a logo of each tone pop.
local CONTRAST = {
	Yellow = "Blue",
	Orange = "Blue",
	Green = "Blue",
	Red = "Purple",
	Pink = "Purple",
	Purple = "Blue",
	Blue = "Purple",
	Cyan = "Purple",
	White = "Blue",
	Gray = "Dark",
	Dark = "Dark",
}

-- Animated full-screen backdrop: tone gradient, slow rays, icon wallpaper.
local function backdrop(scope: Scope, tone: any, icon: any, mode: string, alpha: (base: number) -> any, zoom: any?): Frame
	local top = scope:Computed(function(use)
		return Color.lighten(use(tone).Top, 0.6)
	end)
	local motif = scope:Computed(function(use)
		return Color.lighten(use(tone).Top, 0.4)
	end)

	if mode == "Side" then
		return scope:New "Frame" {
			Name = "Backdrop",
			Size = UDim2.fromScale(0.62, 1),
			BackgroundColor3 = scope:Computed(function(use)
				return Color.darken(use(tone).Deep, 0.55)
			end),
			BackgroundTransparency = alpha(0),
			ZIndex = 1,
			[Children] = Kit.gradient(scope, {
				Rotation = 0,
				Transparency = NumberSequence.new({
					NumberSequenceKeypoint.new(0, 0.15),
					NumberSequenceKeypoint.new(0.6, 0.45),
					NumberSequenceKeypoint.new(1, 1),
				}),
			}),
		}
	end

	local gradient = scope:New "UIGradient" {
		Rotation = 60,
		Color = scope:Computed(function(use)
			local t = use(tone)
			return ColorSequence.new({
				ColorSequenceKeypoint.new(0, t.Bottom),
				ColorSequenceKeypoint.new(0.5, t.Edge),
				ColorSequenceKeypoint.new(1, Color.darken(t.Deep, 0.35)),
			})
		end),
	}

	local rays = {}
	local count, size = 16, 2600
	for i = 1, count do
		local angle = (i - 1) / count * 360
		local rad = math.rad(angle)
		local length = size / 2
		rays[i] = scope:New "ImageLabel" {
			Name = "Ray",
			BackgroundTransparency = 1,
			Image = RAY,
			ImageColor3 = top,
			ImageTransparency = alpha(0.86),
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(0.5, math.cos(rad) * length / 2, 0.5, math.sin(rad) * length / 2),
			Size = UDim2.fromOffset(length, 2 * length * math.tan(math.rad(180 / count * 0.5))),
			Rotation = angle,
			ZIndex = 2,
		}
	end
	local rayFrame = scope:New "Frame" {
		Name = "Rays",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.3, 0.3),
		Size = UDim2.fromOffset(size, size),
		ZIndex = 2,
		[Children] = rays,
	}

	local kind, glyph = Icons.resolve(icon or "star")
	local motifs = {}
	-- re-resolves if the icon font falls back on this device
	local motifFont = scope:Computed(function(use)
		return Icons.font(true, use)
	end)
	if kind == "glyph" then
		for row = 0, 6 do
			for column = 0, 10 do
				table.insert(motifs, scope:New "TextLabel" {
					Name = "Motif",
					BackgroundTransparency = 1,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromOffset(column * 230 + (row % 2) * 115, row * 230),
					Size = UDim2.fromOffset(84, 84),
					Rotation = if (row + column) % 2 == 0 then -14 else 12,
					FontFace = motifFont,
					Text = glyph,
					TextScaled = true,
					TextColor3 = motif,
					TextTransparency = alpha(0.9),
					ZIndex = 3,
				})
			end
		end
	end
	local pattern = scope:New "Frame" {
		Name = "Pattern",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(11 * 230, 7 * 230),
		ZIndex = 3,
		[Children] = motifs,
	}

	local glowLayers = {}
	for i = 1, 3 do
		glowLayers[i] = scope:New "ImageLabel" {
			Name = "Glow",
			BackgroundTransparency = 1,
			Image = GLOW,
			ImageColor3 = scope:Computed(function(use)
				return Color.lighten(use(tone).Top, 0.35)
			end),
			AnchorPoint = Vector2.new(0.5, 0.5),
			ImageTransparency = alpha(0),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromScale(1 - (i - 1) * 0.25, 1 - (i - 1) * 0.25),
			ZIndex = 3,
		}
	end

	local frame = scope:New "Frame" {
		Name = "Backdrop",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Color3.new(1, 1, 1),
		BackgroundTransparency = alpha(0),
		ClipsDescendants = true,
		Active = true,
		ZIndex = 1,
		[Children] = {
			-- settles in from slightly close and pushes towards the camera on
			-- the way out (into the game)
			if zoom then scope:New "UIScale" { Scale = zoom } else nil,
			gradient,
			rayFrame,
			pattern,
			scope:New "Frame" {
				Name = "Glow",
				BackgroundTransparency = 1,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.3, 0.3),
				Size = UDim2.fromOffset(1400, 1000),
				ZIndex = 3,
				[Children] = glowLayers,
			},
			scope:New "ImageLabel" {
				Name = "Vignette",
				BackgroundTransparency = 1,
				Image = Assets.Vignette,
				ImageColor3 = scope:Computed(function(use)
					return Color.darken(use(tone).Deep, 0.7)
				end),
				ImageTransparency = alpha(0),
				Size = UDim2.fromScale(1, 1),
				ZIndex = 5,
			},
			scope:New "Frame" {
				Name = "LeftShade",
				Size = UDim2.fromScale(0.5, 1),
				BackgroundColor3 = scope:Computed(function(use)
					return Color.darken(use(tone).Deep, 0.6)
				end),
				BackgroundTransparency = alpha(0),
				ZIndex = 5,
				[Children] = Kit.gradient(scope, {
					Rotation = 0,
					Transparency = NumberSequence.new(0.45, 1),
				}),
			},
		},
	}

	local drift = TweenService:Create(
		gradient,
		TweenInfo.new(8, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true),
		{ Rotation = 95, Offset = Vector2.new(0.1, 0.06) }
	)
	drift:Play()
	local slide = TweenService:Create(
		pattern,
		TweenInfo.new(20, Enum.EasingStyle.Linear, Enum.EasingDirection.In, -1),
		{ Position = UDim2.new(0.5, 230, 0.5, 230) }
	)
	slide:Play()
	local untrackDrift = Motion.track(drift, gradient)
	local untrackSlide = Motion.track(slide, pattern)
	table.insert(scope, function()
		untrackDrift()
		untrackSlide()
		drift:Cancel()
		slide:Cancel()
	end)
	table.insert(scope, Motion.spin(rayFrame, 70))
	return frame
end

return function(outerScope: Scope, config: { [string]: any })
	local scope = Fusion.innerScope(outerScope, Fusion)
	local logoProps = table.clone(config.Logo or { Text = "MY GAME" })
	local buttons = config.Buttons or {}
	local bannerProps = config.Banner
	local hasBanner = bannerProps ~= nil
	local tone = Kit.tone(scope, config.Tone or logoProps.Tone, "Yellow")
	local backTone = scope:Computed(function(use)
		local given = use(config.BackdropTone)
		if given == nil then
			local logoTone = use(logoProps.Tone)
			given = if typeof(logoTone) == "string" then CONTRAST[logoTone] else nil
		end
		return Theme.tone(use(Theme.current), given or "Blue")
	end)

	local open = config.Open
	if typeof(open) ~= "table" or open.set == nil then
		open = scope:Value(config.Open ~= false)
	end

	local alive = true
	table.insert(scope, function()
		alive = false
	end)

	-- staggered entrance: each element gets its own 0/1 Value
	local stages: { any } = {}
	local function stage(delay: number)
		local value = scope:Value(0)
		local spring = scope:Spring(value, 16, 0.62)
		spring:setPosition(0)
		table.insert(stages, { value = value, delay = delay })
		return spring
	end

	local logoIn = stage(0)
	local primaryIn = stage(0.22)
	local secondaryIn = {}
	for i = 2, #buttons do
		secondaryIn[i] = stage(0.3 + (i - 2) * 0.07)
	end
	local bannerIn = stage(0.34)
	local footerIn = stage(0.5)

	-- Every open/close bumps `generation`; timers from an older transition
	-- see a newer generation and do nothing, so rapid toggles can't leave an
	-- element stuck half-way (or invisible).
	local generation = 0
	local shownState: boolean? = nil
	local function play(show: boolean)
		generation += 1
		local mine = generation
		for index, entry in stages do
			local delay = if show then entry.delay else (#stages - index) * 0.03
			local target = if show then 1 else 0
			if delay <= 0 then
				entry.value:set(target)
			else
				task.delay(delay, function()
					if alive and generation == mine then
						entry.value:set(target)
					end
				end)
			end
		end
		return mine
	end

	-- layout ----------------------------------------------------------------
	-- Everything on the left (or centre) lives in one column that scales down
	-- to fit short screens (phones in landscape), so nothing is ever clipped.

	local secondaryCount = math.max(0, #buttons - 1)
	local PRIMARY_H, SECONDARY_H, SECONDARY_GAP = 124, 78, 16
	local columnWidth, columnHeight, logoY, primaryY, secondaryY
	if hasBanner then
		columnWidth = 480
		logoY = 110
		primaryY = 250
		secondaryY = primaryY + PRIMARY_H + 28
		columnHeight = if secondaryCount > 0
			then secondaryY + secondaryCount * (SECONDARY_H + SECONDARY_GAP) - SECONDARY_GAP
			else primaryY + PRIMARY_H
	else
		columnWidth = math.max(480, secondaryCount * 280)
		logoY = 140
		primaryY = 300
		secondaryY = primaryY + PRIMARY_H + 32
		columnHeight = if secondaryCount > 0 then secondaryY + SECONDARY_H else primaryY + PRIMARY_H
	end
	local columnScale = scope:Computed(function(use)
		local design = use(Viewport.designSize)
		local fitY = (design.Y - 150) / columnHeight
		local fitX = if hasBanner then 1 else (design.X - 96) / columnWidth
		return math.clamp(math.min(fitY, fitX), 0.45, 1)
	end)

	-- logo ------------------------------------------------------------------

	logoProps.Height = logoProps.Height or (if hasBanner then 200 else 240)
	logoProps.AnchorPoint = if hasBanner then Vector2.new(0, 0.5) else Vector2.new(0.5, 0.5)
	logoProps.Position = if hasBanner then UDim2.fromScale(0, 0.5) else UDim2.fromScale(0.5, 0.5)
	logoProps.Transparency = scope:Computed(function(use)
		return 1 - math.clamp(use(logoIn), 0, 1)
	end)
	local logoHolder = scope:New "Frame" {
		Name = "LogoHolder",
		BackgroundTransparency = 1,
		AnchorPoint = if hasBanner then Vector2.new(0, 0.5) else Vector2.new(0.5, 0.5),
		Position = scope:Computed(function(use)
			local drop = (1 - use(logoIn)) * -90
			return if hasBanner then UDim2.fromOffset(0, logoY + drop) else UDim2.new(0.5, 0, 0, logoY + drop)
		end),
		Size = UDim2.fromOffset(10, 10),
		ZIndex = 10,
		[Children] = {
			scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					return 0.85 + 0.15 * math.clamp(use(logoIn), 0, 1.2)
				end),
			},
			Logo(scope, logoProps),
		},
	}

	-- buttons -----------------------------------------------------------------

	local buttonGuis = {}
	local isAlive = function()
		return alive
	end
	local primary = buttons[1]
	local primaryText = nil
	local primaryButton: GuiObject? = nil
	if primary then
		local width = if hasBanner then 460 else 480
		-- button padding (2 x 12), icon + gap, and a little breathing room
		local room = width - 24 - (if primary.Icon ~= false then 70 else 0) - 28
		primaryText = fitTextSize(scope, tostring(primary.Text or "PLAY"), room, 60, 26, isAlive)
		primaryButton = Button(scope, {
			Name = "Primary",
			Text = primary.Text or "PLAY",
			Icon = if primary.Icon == false then nil else (primary.Icon or "play"),
			Tone = primary.Tone or "Green",
			Primary = true,
			Size = UDim2.fromScale(1, 1),
			TextSize = primaryText,
			IconSize = scope:Computed(function(use)
				return math.clamp(math.floor(use(primaryText) * 1.05), 36, 62)
			end),
			Shine = true,
			Pulse = true,
			Hotkey = primary.Hotkey,
			OnClick = primary.OnClick,
			Disabled = primary.Disabled,
			ZIndex = 21,
		})
		table.insert(buttonGuis, scope:New "Frame" {
			Name = "PrimarySlot",
			BackgroundTransparency = 1,
			AnchorPoint = if hasBanner then Vector2.new(0, 0) else Vector2.new(0.5, 0),
			Position = if hasBanner then UDim2.fromOffset(0, primaryY) else UDim2.new(0.5, 0, 0, primaryY),
			Size = UDim2.fromOffset(width, PRIMARY_H),
			ZIndex = 20,
			[Children] = {
				scope:New "UIScale" {
					Scale = scope:Computed(function(use)
						return math.max(0, use(primaryIn))
					end),
				},
				primaryButton,
			},
		})
	end

	-- secondary buttons share one text size (the smallest that fits them all)
	local secondaryFits = {}
	for i = 2, #buttons do
		local entry = buttons[i]
		local width = if hasBanner then 460 else 260
		table.insert(secondaryFits, fitTextSize(
			scope,
			tostring(entry.Text or ""),
			width - 24 - (if entry.Icon then 46 else 0) - 20,
			32,
			18,
			isAlive
		))
	end
	local secondaryTextSize = scope:Computed(function(use)
		local smallest = 32
		for _, fit in secondaryFits do
			smallest = math.min(smallest, use(fit))
		end
		return smallest
	end)

	for i = 2, #buttons do
		local entry = buttons[i]
		local appear = secondaryIn[i]
		local index = i - 2
		local size: Vector2
		local position: UDim2
		local anchor: Vector2
		if hasBanner then
			size = Vector2.new(460, SECONDARY_H)
			anchor = Vector2.new(0, 0)
			position = UDim2.fromOffset(0, secondaryY + index * (SECONDARY_H + SECONDARY_GAP))
		else
			size = Vector2.new(260, SECONDARY_H)
			anchor = Vector2.new(0.5, 0)
			local offset = (index - (secondaryCount - 1) / 2) * 280
			position = UDim2.new(0.5, offset, 0, secondaryY)
		end
		local secondaryText = secondaryTextSize
		table.insert(buttonGuis, scope:New "Frame" {
			Name = "Slot" .. i,
			BackgroundTransparency = 1,
			AnchorPoint = anchor,
			Position = scope:Computed(function(use)
				local t = use(appear)
				if hasBanner then
					return position + UDim2.fromOffset((1 - t) * -800, 0)
				end
				return position + UDim2.fromOffset(0, (1 - t) * 500)
			end),
			Size = UDim2.fromOffset(size.X, size.Y),
			ZIndex = 20,
			[Children] = Button(scope, {
				Name = entry.Text or ("Button" .. i),
				Text = entry.Text,
				Icon = entry.Icon,
				Tone = entry.Tone or "Dark",
				Size = UDim2.fromScale(1, 1),
				TextSize = secondaryText,
				IconSize = scope:Computed(function(use)
					return math.clamp(math.floor(use(secondaryText) * 1.2), 24, 38)
				end),
				Badge = entry.Badge,
				Hotkey = entry.Hotkey,
				OnClick = entry.OnClick,
				Disabled = entry.Disabled,
				ZIndex = 21,
			}),
		})
	end

	local column = scope:New "Frame" {
		Name = "Column",
		BackgroundTransparency = 1,
		AnchorPoint = if hasBanner then Vector2.new(0, 0.5) else Vector2.new(0.5, 0.5),
		Position = if hasBanner then UDim2.new(0, 120, 0.5, 10) else UDim2.new(0.5, 0, 0.5, 10),
		Size = UDim2.fromOffset(columnWidth, columnHeight),
		ZIndex = 10,
		[Children] = {
			scope:New "UIScale" { Scale = columnScale },
			logoHolder,
			buttonGuis,
		},
	}

	-- banner --------------------------------------------------------------------

	local bannerGui = nil
	if hasBanner then
		local props = table.clone(bannerProps)
		props.Size = props.Size or UDim2.fromOffset(660, 340)
		props.AnchorPoint = Vector2.new(0.5, 0.5)
		props.Position = UDim2.fromScale(0.5, 0.5)
		bannerGui = scope:New "Frame" {
			Name = "Featured",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(1, 0.5),
			Position = scope:Computed(function(use)
				return UDim2.new(1, -120 + (1 - use(bannerIn)) * 900, 0.54, 0)
			end),
			Size = props.Size,
			ZIndex = 15,
			[Children] = {
				scope:New "UIScale" {
					Scale = scope:Computed(function(use)
						local design = use(Viewport.designSize)
						local size = peek(props.Size)
						local w = if typeof(size) == "UDim2" then size.X.Offset elseif typeof(size) == "Vector2" then size.X else 660
						local h = if typeof(size) == "UDim2" then size.Y.Offset elseif typeof(size) == "Vector2" then size.Y else 340
						local fitX = (design.X - 120 - 480 - 200) / math.max(1, w)
						local fitY = (design.Y - 200) / math.max(1, h)
						return math.clamp(math.min(fitX, fitY), 0.5, 1)
					end),
				},
				scope:New "TextLabel" {
					Name = "Header",
					BackgroundTransparency = 1,
					AnchorPoint = Vector2.new(0, 1),
					Position = UDim2.new(0, 8, 0, -14),
					Size = UDim2.fromOffset(0, 30),
					AutomaticSize = Enum.AutomaticSize.X,
					Text = config.BannerHeader or "FEATURED",
					TextSize = 26,
					FontFace = scope:Computed(function(use)
						return Theme.font(use(Theme.current), "Heavy")
					end),
					TextColor3 = scope:Computed(function(use)
						return use(tone).Top
					end),
					ZIndex = 16,
					[Children] = scope:New "UIStroke" {
						ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual,
						LineJoinMode = Enum.LineJoinMode.Round,
						Color = scope:Computed(function(use)
							local t = use(tone)
							return t.Stroke or Color.darken(t.Edge, 0.35)
						end),
						Thickness = 2,
					},
				},
				Banner(scope, props),
			},
		}
	end

	-- footer --------------------------------------------------------------------

	local footer = if config.Version
		then scope:New "TextLabel" {
			Name = "Version",
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, 40, 1, -28),
			Size = UDim2.fromOffset(0, 24),
			AutomaticSize = Enum.AutomaticSize.X,
			Text = config.Version,
			TextSize = 20,
			FontFace = scope:Computed(function(use)
				return Theme.font(use(Theme.current), "Body")
			end),
			TextColor3 = Kit.themed(scope, function(theme)
				return theme.Color.TextMuted
			end),
			TextTransparency = scope:Computed(function(use)
				return 1 - 0.8 * math.clamp(use(footerIn), 0, 1)
			end),
			TextXAlignment = Enum.TextXAlignment.Left,
			ZIndex = 20,
		}
		else nil

	-- gui ---------------------------------------------------------------------

	local visible = scope:Value(peek(open))
	local gui = Screen(scope, {
		Name = config.Name or "BloxUI_MainMenu",
		DisplayOrder = config.DisplayOrder or 800,
		SafeArea = "None",
		Parent = config.Parent,
		Enabled = visible,
		[Children] = {
			if (config.Backdrop or "Full") ~= "None"
				then backdrop(scope, backTone, logoProps.Icon, config.Backdrop or "Full", function(base: number)
					return scope:Computed(function(use)
						return 1 - (1 - base) * math.clamp(use(logoIn), 0, 1)
					end)
				end, scope:Computed(function(use)
					local away = 1 - math.clamp(use(logoIn), 0, 1)
					return 1 + 0.08 * away * away
				end))
				else nil,
			column,
			bannerGui,
			footer,
		},
	})

	local function sync(isOpen: boolean)
		isOpen = isOpen == true
		if shownState == isOpen then
			return -- already there (or on the way): nothing to restart
		end
		shownState = isOpen
		if isOpen then
			visible:set(true)
			play(true)
			-- console players land on PLAY
			if primaryButton and peek(Input.device) == "Gamepad" then
				task.delay(0.25, function()
					if alive and peek(open) and primaryButton then
						pcall(function()
							GuiService.SelectedObject = primaryButton
						end)
					end
				end)
			end
		else
			Sound.play("Swoosh")
			local mine = play(false)
			task.delay(0.6, function()
				if alive and generation == mine and not peek(open) then
					visible:set(false)
				end
			end)
		end
	end
	Kit.watch(scope, open, sync)
	if peek(open) then
		sync(true)
	else
		shownState = false
	end

	return {
		Gui = gui,
		Open = open,
		destroy = function()
			if alive then
				Fusion.doCleanup(scope)
			end
		end,
	}
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="255">
            <Properties>
              <string name="Name">Results</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs.Results: the end-of-match screen. VICTORY (gold), DEFEAT (red) or
	DRAW (grey) slams in with rays, a flash and a camera shake; stat rows
	slide in and count up; reward tiles pop in one by one; then the buttons
	rise. Every time it opens, the whole sequence plays again.

	local results = UI.Prefabs.Results(scope, {
		Result = "Victory",                       -- "Victory" | "Defeat" | "Draw" (or a state)
		Subtitle = "Ranked  -  5 : 3",            -- optional line under the title
		Stats = {
			{ label = "Eliminations", value = 12, icon = "crosshair" },
			{ label = "Damage", value = 2450 },
			{ label = "Accuracy", value = "43%" },           -- text is shown as-is
			{ label = "MVP", value = "Yes", highlight = true },
		},
		Rewards = {
			{ icon = "coin", amount = 250, tone = "Yellow", label = "Coins" },
			{ icon = "sword", amount = 1, rarity = "Epic", label = "Void Blade" },   -- rarity = item tile
		},
		Buttons = {
			{ Text = "Play Again", Icon = "play", Tone = "Green", OnClick = requeue },
			{ Text = "Lobby", Icon = "home", Tone = "Gray", OnClick = toLobby },
		},
		MVP = "Builderman",                       -- optional: stamps a gold MVP medal (true, or a name under it)
		Open = true,                              -- false to build it hidden
		Blur = true,                              -- false: no background blur (never in edit-mode previews)
	})

	A click, Space, Enter or A while the sequence plays jumps it to the end.

	results.Open          Value<bool>
	results.open() / results.close() / results.skip()
	results.show({ Result = "Defeat", Stats = {...}, Rewards = {...} })   -- new data, then open
	results.Gui           the ScreenGui

	The first button is the primary one (big, shining) unless another has
	Primary = true. Buttons close the screen after OnClick unless Close = false.
]]

local Lighting = game:GetService("Lighting")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Assets = require(script.Parent.Parent.Assets)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local Sound = require(script.Parent.Parent.Util.Sound)
local Input = require(script.Parent.Parent.Util.Input)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local FXScreen = require(script.Parent.Parent.FX.Screen)
local Components = script.Parent.Parent.Components
local ScreenComponent = require(Components.Screen)
local Text = require(Components.Text)
local Icon = require(Components.Icon)
local Button = require(Components.Button)

local ItemSlot: any = nil
do
	local module = Components:FindFirstChild("ItemSlot")
	if module then
		local ok, result = pcall(require, module)
		if ok then
			ItemSlot = result
		end
	end
end

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)

-- Soft white radial glow tinted `Color`. The texture is faint, so it can be
-- stacked `Layers` times for a stronger bloom.
local GLOW = (Assets :: any).Glow or "rbxasset://textures/ui/InGameMenu/BackgroundGlow.png"
local function glow(scope: any, props: { [string]: any }): ImageLabel
	local extra = {}
	for _ = 2, props.Layers or 1 do
		table.insert(extra, scope:New "ImageLabel" {
			Name = "Layer",
			BackgroundTransparency = 1,
			Image = GLOW,
			ImageColor3 = props.Color,
			ImageTransparency = props.Transparency or 0,
			Size = UDim2.fromScale(1, 1),
			ZIndex = props.ZIndex or 0,
		})
	end
	return scope:New "ImageLabel" {
		Name = props.Name or "Glow",
		BackgroundTransparency = 1,
		Image = GLOW,
		ImageColor3 = props.Color,
		ImageTransparency = props.Transparency or 0,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position or UDim2.fromScale(0.5, 0.5),
		Size = props.Size,
		Visible = props.Visible,
		ZIndex = props.ZIndex or 0,
		[Fusion.Children] = extra,
	}
end

-- Light rays: rotated copies of Roblox's white emote-wheel wedge (bright at
-- the centre, fading out), so they tint cleanly to any colour. Spin the
-- returned frame with Motion.spin.
local WEDGE = "rbxasset://textures/ui/Emotes/Large/SelectedGradient@2x.png"
local function rays(scope: any, props: { [string]: any }): Frame
	local count = props.Count or 12
	local width = props.Width or 0.36
	local spokes = {}
	for i = 0, count - 1 do
		table.insert(spokes, scope:New "Frame" {
			Name = "Ray",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			Rotation = i * 360 / count,
			ZIndex = props.ZIndex or 0,
			[Fusion.Children] = scope:New "ImageLabel" {
				Name = "Wedge",
				BackgroundTransparency = 1,
				Image = WEDGE,
				ImageColor3 = props.Color,
				ImageTransparency = props.Transparency or 0,
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Size = UDim2.fromScale(0.5, 0.5 * width),
				ZIndex = props.ZIndex or 0,
			},
		})
	end
	return scope:New "Frame" {
		Name = props.Name or "Rays",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = props.Position or UDim2.fromScale(0.5, 0.5),
		Size = props.Size,
		BackgroundTransparency = 1,
		Visible = props.Visible,
		ZIndex = props.ZIndex or 0,
		[Fusion.Children] = spokes,
	}
end

-- Poof (docs/RESEARCH.md 2.10): 7-9 soft circles fly out with drag, growing
-- 0.3 -> 1.3x and fading over ~0.4 s, plus one expanding ring. Drawn in
-- design px inside `parent`, centred on `at` (UDim2). Fire and forget.
local POOF_TWEEN = game:GetService("TweenService")
local function poof(parent: Instance, at: UDim2, options: { [string]: any }?)
	local opts = options or {}
	local count = opts.Count or 8
	local size = opts.Size or 34
	local reachMin, reachMax = opts.Reach and opts.Reach[1] or 40, opts.Reach and opts.Reach[2] or 90
	local colors = opts.Colors or { Color3.new(1, 1, 1) }
	local life = opts.Time or 0.42
	local zIndex = opts.ZIndex or 50
	for i = 1, count do
		local angle = (i / count) * math.pi * 2 + (math.random() - 0.5) * 0.7
		local direction = Vector2.new(math.cos(angle), math.sin(angle))
		local start = direction * math.random() * 12
		local finish = direction * (reachMin + math.random() * (reachMax - reachMin))
		local circle = Instance.new("ImageLabel")
		circle.Name = "Poof"
		circle.BackgroundTransparency = 1
		circle.Image = "rbxasset://textures/whiteCircle.png"
		circle.ImageColor3 = colors[(i - 1) % #colors + 1]
		circle.ImageTransparency = 0.1
		circle.AnchorPoint = Vector2.new(0.5, 0.5)
		circle.Position = at + UDim2.fromOffset(start.X, start.Y)
		local s = size * (0.85 + math.random() * 0.3)
		circle.Size = UDim2.fromOffset(s * 0.3, s * 0.3)
		circle.ZIndex = zIndex
		circle.Parent = parent
		local t = life * (0.85 + math.random() * 0.2)
		POOF_TWEEN:Create(circle, TweenInfo.new(t, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
			Position = at + UDim2.fromOffset(finish.X, finish.Y),
			Size = UDim2.fromOffset(s * 1.3, s * 1.3),
			ImageTransparency = 1,
		}):Play()
		task.delay(t + 0.05, function()
			circle:Destroy()
		end)
	end
	if opts.Ring ~= false then
		local ringSize = opts.RingSize or size * 3
		local ring = Instance.new("Frame")
		ring.Name = "PoofRing"
		ring.BackgroundTransparency = 1
		ring.AnchorPoint = Vector2.new(0.5, 0.5)
		ring.Position = at
		ring.Size = UDim2.fromOffset(ringSize * 0.4, ringSize * 0.4)
		ring.ZIndex = zIndex
		local corner = Instance.new("UICorner")
		corner.CornerRadius = UDim.new(0.5, 0)
		corner.Parent = ring
		local stroke = Instance.new("UIStroke")
		stroke.Thickness = opts.RingThickness or 6
		stroke.Color = opts.RingColor or colors[1]
		stroke.Transparency = 0.2
		stroke.Parent = ring
		ring.Parent = parent
		local ringTime = opts.RingTime or 0.25
		POOF_TWEEN:Create(ring, TweenInfo.new(ringTime, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
			Size = UDim2.fromOffset(ringSize * 1.6, ringSize * 1.6),
		}):Play()
		POOF_TWEEN:Create(stroke, TweenInfo.new(ringTime, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), { Transparency = 1 }):Play()
		task.delay(ringTime + 0.05, function()
			ring:Destroy()
		end)
	end
end

local KINDS = {
	Victory = { tone = "Yellow", title = "VICTORY", icon = "trophy", sounds = { "Victory", "Reward" }, party = true },
	Defeat = { tone = "Red", title = "DEFEAT", icon = nil, sounds = { "Defeat", "Error" }, party = false },
	Draw = { tone = "Gray", title = "DRAW", icon = nil, sounds = { "Draw", "Notify" }, party = false },
}

local DEFAULT_BUTTONS = {
	{ Text = "Play Again", Icon = "play", Tone = "Green" },
	{ Text = "Lobby", Icon = "home", Tone = "Gray" },
}

local function field(item: { [string]: any }, lower: string, upper: string): any
	local value = item[lower]
	if value == nil then
		value = item[upper]
	end
	return value
end

local function themedSound(names: { string }): string?
	local sounds = Theme.get().Sound or {}
	for _, name in names do
		local id = sounds[name]
		if (typeof(id) == "string" and id ~= "") or typeof(id) == "number" then
			return name
		end
	end
	return nil
end

-- Numbers (and plain numeric text like "2,450" or "1.85") count up; anything
-- else is shown as-is. Returns the number and how many decimals to show.
local function parseNumber(value: any): (number?, number)
	if typeof(value) == "number" then
		local decimals = if value % 1 == 0 then 0 else 2
		return value, decimals
	end
	if typeof(value) ~= "string" then
		return nil, 0
	end
	local clean = string.gsub(value, ",", "")
	if string.match(clean, "^%-?%d+$") then
		return tonumber(clean), 0
	end
	local whole, fraction = string.match(clean, "^(%-?%d+)%.(%d+)$")
	if whole then
		return tonumber(clean), #fraction
	end
	return nil, 0
end

local function formatNumber(n: number, decimals: number, original: any): string
	if decimals > 0 then
		return string.format(`%.{decimals}f`, n)
	end
	local text = Format.comma(math.floor(n + 0.5))
	if typeof(original) == "string" and not string.find(original, ",") then
		text = tostring(math.floor(n + 0.5))
	end
	return text
end

local function backOut(t: number): number
	local c1 = 1.70158
	local c3 = c1 + 1
	return 1 + c3 * (t - 1) ^ 3 + c1 * (t - 1) ^ 2
end

return function(scope: any, config: { [string]: any }?)
	local cfg = table.clone(config or {})
	local result = if typeof(cfg.Result) == "table" and cfg.Result.type == "State"
		then cfg.Result
		else scope:Value(cfg.Result or "Victory")
	local open = scope:Value(cfg.Open ~= false)

	local kind = scope:Computed(function(use)
		return KINDS[use(result)] or KINDS.Victory
	end)
	local tone = scope:Computed(function(use)
		return Theme.tone(use(Theme.current), use(kind).tone)
	end)

	local appear = scope:Spring(scope:Computed(function(use)
		return if use(open) then 1 else 0
	end), 18, 0.85)

	-- while the sequence runs, a click / Space / A jumps straight to the end
	local playing = false
	local skipSequence: () -> () = function() end
	Input.bind(scope, { Enum.KeyCode.Space, Enum.KeyCode.Return, Enum.KeyCode.ButtonA }, function()
		if playing and peek(open) then
			skipSequence()
		end
	end)

	-- the whole layout is 1400 x 1000 design px; it shrinks to fit short or
	-- narrow screens (phones in landscape) instead of spilling off them
	local stage = scope:New "Frame" {
		Name = "Stage",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(1400, 1000),
		BackgroundTransparency = 1,
		ZIndex = 2,
		[Children] = scope:New "UIScale" {
			Scale = scope:Computed(function(use)
				local screen = use(Viewport.designSize)
				return math.min(1, (screen.Y - 24) / 960, (screen.X - 48) / 1400)
			end),
		},
	}

	local gui = ScreenComponent(scope, {
		Name = cfg.Name or "Results",
		DisplayOrder = cfg.DisplayOrder or 800,
		SafeArea = "None",
		Parent = cfg.Parent,
		Enabled = scope:Computed(function(use)
			return use(open) or use(appear) > 0.02
		end),
		[Children] = {
			scope:New "TextButton" {
				Name = "Backdrop",
				Size = UDim2.fromScale(1, 1),
				Text = "",
				AutoButtonColor = false,
				Selectable = false,
				Modal = open,
				BackgroundColor3 = Kit.themed(scope, function(theme)
					return theme.Color.Backdrop
				end),
				BackgroundTransparency = scope:Computed(function(use)
					return 1 - 0.72 * math.clamp(use(appear), 0, 1)
				end),
				ZIndex = 1,
				[OnEvent "Activated"] = function()
					if playing then
						skipSequence()
					end
				end,
				[Children] = scope:New "UIGradient" {
					Rotation = 90,
					Transparency = NumberSequence.new({
						NumberSequenceKeypoint.new(0, 0),
						NumberSequenceKeypoint.new(0.5, 0.18),
						NumberSequenceKeypoint.new(1, 0),
					}),
				},
			},
			glow(scope, {
				Name = "Aura",
				Color = scope:Computed(function(use)
					return use(tone).Top
				end),
				Position = UDim2.new(0.5, 0, 0.5, -300),
				Size = UDim2.fromOffset(1700, 800),
				Transparency = scope:Computed(function(use)
					return 1 - 0.6 * math.clamp(use(appear), 0, 1)
				end),
				Layers = 2,
				ZIndex = 1,
			}),
			stage,
		},
	})

	-- Background blur while open. Off with Blur = false, and always off in
	-- edit-mode previews (no LocalPlayer) so the Studio viewport stays sharp.
	if cfg.Blur ~= false and Players.LocalPlayer ~= nil then
		scope:New "BlurEffect" {
			Name = "BloxUIResultsBlur",
			Size = scope:Computed(function(use)
				return math.max(0, use(appear)) * 16
			end),
			Enabled = scope:Computed(function(use)
				return use(appear) > 0.02
			end),
			Parent = Lighting,
		}
	end

	local token = 0
	local contentScope: any = nil
	local play: () -> () = function() end

	local function close()
		if peek(open) then
			open:set(false)
		end
	end

	-- Runs `step(t)` every frame for `duration` seconds (t: 0..1), unless a
	-- newer play/close started meanwhile.
	local function animate(myToken: number, duration: number, step: (number) -> ())
		local start = os.clock()
		while myToken == token do
			local t = math.clamp((os.clock() - start) / duration, 0, 1)
			step(t)
			if t >= 1 then
				return
			end
			RunService.RenderStepped:Wait()
		end
	end

	local function later(myToken: number, delay: number, fn: () -> ())
		task.delay(delay, function()
			if myToken == token then
				fn()
			end
		end)
	end

	local function build(data: { [string]: any })
		if contentScope then
			Fusion.doCleanup(contentScope)
		end
		local s = scope:innerScope()
		contentScope = s

		local stats = data.Stats or {}
		local rewardsPanel: GuiObject? = nil
		local rewards = data.Rewards or {}
		local buttons = data.Buttons or DEFAULT_BUTTONS
		local hasIcon = s:Computed(function(use)
			local custom = data.Icon
			if custom == false then
				return false
			end
			return custom ~= nil or use(kind).icon ~= nil
		end)
		local heroIcon = s:Computed(function(use)
			return data.Icon or use(kind).icon or ""
		end)

		-- timeline state
		local titleScale = s:Value(2.6)
		local titleAlpha = s:Value(1)
		local iconGoal = s:Value(0)
		local iconPop = s:Spring(iconGoal, 22, 0.45)
		local underline = s:Value(0)
		local subtitleGoal = s:Value(0)
		local subtitleIn = s:Spring(subtitleGoal, 14, 1)
		local panelGoals, panelIns = {}, {}
		for i = 1, 2 do
			panelGoals[i] = s:Value(0)
			panelIns[i] = s:Spring(panelGoals[i], 16, 0.8)
		end
		local rowGoals, rowIns, rowCounts = {}, {}, {}
		local valuePunch = {}
		for i = 1, #stats do
			valuePunch[i] = s:Spring(s:Value(1), 35, 0.5)
			rowGoals[i] = s:Value(0)
			rowIns[i] = s:Spring(rowGoals[i], 20, 0.75)
			rowCounts[i] = s:Value(0)
		end
		local rewardGoals, rewardPops = {}, {}
		for i = 1, #rewards do
			rewardGoals[i] = s:Value(0)
			rewardPops[i] = s:Spring(rewardGoals[i], 20, 0.45)
		end
		local buttonsGoal = s:Value(0)
		local buttonsIn = s:Spring(buttonsGoal, 16, 0.75)
		-- MVP medal: stamps down (big -> 1 with a back-out) after the stats
		local mvp = data.MVP
		local mvpScale = s:Value(0)
		local mvpAlpha = s:Value(1)
		local amountLabels = {}
		local highlightRows = {}

		----------------------------------------------------------------
		-- Hero: icon, title, underline, subtitle
		----------------------------------------------------------------

		local titleText = s:Computed(function(use)
			local custom = data.Title
			if custom ~= nil then
				return string.upper(tostring(use(custom)))
			end
			return use(kind).title
		end)

		local heroTop = s:Computed(function(use)
			return if use(hasIcon) then 118 else 0
		end)

		local titleRays = rays(s, {
			Position = s:Computed(function(use)
				return UDim2.new(0.5, 0, 0, 96 + use(heroTop) * 0.5)
			end),
			Size = UDim2.fromOffset(1100, 1100),
			Color = s:Computed(function(use)
				return Color.lighten(use(tone).Top, 0.2)
			end),
			Transparency = s:Computed(function(use)
				local party = use(kind).party
				return 1 - (if party then 0.42 else 0.12) * (1 - use(titleAlpha))
			end),
			Count = 14,
			Width = 0.3,
			ZIndex = 1,
		})
		table.insert(s, Motion.spin(titleRays, 14))

		local slamGradient = s:Computed(function(use)
			local t = use(tone)
			return ColorSequence.new({
				ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.55)),
				ColorSequenceKeypoint.new(0.45, t.Top),
				ColorSequenceKeypoint.new(1, t.Bottom),
			})
		end)

		local hero = s:New "Frame" {
			Name = "Hero",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5, 0),
			Size = UDim2.new(1, 0, 0, 360),
			BackgroundTransparency = 1,
			ZIndex = 2,
			Parent = stage,
			[Children] = {
				titleRays,
				Icon(s, {
					Name = "ResultIcon",
					Icon = heroIcon,
					Size = 120,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 0, 0, 70),
					Color = s:Computed(function(use)
						return use(tone).Top
					end),
					Gradient = true,
					Shadow = true,
					Stroke = 5,
					StrokeColor = s:Computed(function(use)
						local t = use(tone)
						return t.Stroke or t.Edge
					end),
					Visible = hasIcon,
					ZIndex = 4,
					[Children] = s:New "UIScale" {
						Scale = s:Computed(function(use)
							return math.max(0, use(iconPop))
						end),
					},
				}),
				s:New "Frame" {
					Name = "TitleHolder",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = s:Computed(function(use)
						return UDim2.new(0.5, 0, 0, 96 + use(heroTop))
					end),
					Size = UDim2.fromOffset(1200, 170),
					BackgroundTransparency = 1,
					ZIndex = 3,
					[Children] = {
						s:New "UIScale" { Scale = titleScale },
						-- Roblox caps TextSize at 100: 100 x UIScale 1.5 = 150
						Text(s, {
							Name = "TitleShadow",
							Text = titleText,
							TextSize = 100,
							[Children] = s:New "UIScale" { Name = "BigText", Scale = 1.5 },
							Font = "Title",
							Color = Kit.themed(s, function(theme)
								return theme.Color.Outline
							end),
							Stroke = 8 / 1.5,
							Transparency = s:Computed(function(use)
								return 1 - 0.5 * (1 - use(titleAlpha))
							end),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.new(0.5, 0, 0.5, 10),
							ZIndex = 3,
						}),
						Text(s, {
							Name = "Title",
							Text = titleText,
							TextSize = 100,
							[Children] = s:New "UIScale" { Name = "BigText", Scale = 1.5 },
							Font = "Title",
							Gradient = slamGradient,
							Stroke = 8 / 1.5,
							StrokeColor = s:Computed(function(use)
								local t = use(tone)
								return t.Stroke or t.Edge
							end),
							Transparency = titleAlpha,
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							ZIndex = 4,
						}),
					},
				},
				s:New "Frame" {
					Name = "Underline",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = s:Computed(function(use)
						return UDim2.new(0.5, 0, 0, 180 + use(heroTop))
					end),
					Size = s:Computed(function(use)
						return UDim2.fromOffset(math.floor(680 * use(underline)), 6)
					end),
					BackgroundColor3 = s:Computed(function(use)
						return use(tone).Top
					end),
					ZIndex = 3,
					[Children] = {
						Kit.corner(s, "Full"),
						s:New "UIGradient" {
							Transparency = NumberSequence.new({
								NumberSequenceKeypoint.new(0, 1),
								NumberSequenceKeypoint.new(0.25, 0.1),
								NumberSequenceKeypoint.new(0.75, 0.1),
								NumberSequenceKeypoint.new(1, 1),
							}),
						},
					},
				},
				if data.Subtitle
					then Text(s, {
						Name = "Subtitle",
						Text = data.Subtitle,
						TextSize = 28,
						Font = "Heavy",
						Stroke = 2.5,
						Color = Kit.themed(s, function(theme)
							return theme.Color.TextMuted
						end),
						Transparency = s:Computed(function(use)
							return 1 - math.clamp(use(subtitleIn), 0, 1)
						end),
						AnchorPoint = Vector2.new(0.5, 0),
						Position = s:Computed(function(use)
							return UDim2.new(0.5, 0, 0, 200 + use(heroTop) + (1 - use(subtitleIn)) * 12)
						end),
						ZIndex = 3,
					})
					else nil,
				-- the MVP medal, tilted at the title's top-right corner
				if mvp
					then s:New "Frame" {
						Name = "MVP",
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = s:Computed(function(use)
							return UDim2.new(0.5, 470, 0, 60 + use(heroTop))
						end),
						Size = UDim2.fromOffset(128, 128),
						Rotation = -12,
						BackgroundTransparency = 1,
						Visible = s:Computed(function(use)
							return use(mvpScale) > 0.01
						end),
						ZIndex = 6,
						[Children] = {
							s:New "UIScale" { Scale = mvpScale },
							glow(s, {
								Color = Kit.themed(s, function(theme)
									return theme.Tone.Yellow.Top
								end),
								Size = UDim2.fromScale(2, 2),
								Transparency = s:Computed(function(use)
									return 1 - 0.7 * (1 - use(mvpAlpha))
								end),
								Layers = 2,
								ZIndex = 6,
							}),
							s:New "Frame" {
								Name = "Medal",
								Size = UDim2.fromScale(1, 1),
								BackgroundColor3 = WHITE,
								BackgroundTransparency = mvpAlpha,
								ZIndex = 7,
								[Children] = {
									Kit.corner(s, "Full"),
									Kit.gradient(s, {
										Rotation = 90,
										Color = Kit.themed(s, function(theme)
											local t = theme.Tone.Yellow
											return ColorSequence.new({
												ColorSequenceKeypoint.new(0, Color.lighten(t.Top, 0.45)),
												ColorSequenceKeypoint.new(0.5, t.Top),
												ColorSequenceKeypoint.new(1, t.Bottom),
											})
										end),
									}),
									s:New "UIStroke" {
										ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
										Thickness = 5,
										Color = Kit.themed(s, function(theme)
											return theme.Tone.Yellow.Stroke or theme.Tone.Yellow.Edge
										end),
										Transparency = mvpAlpha,
									},
									s:New "Frame" {
										Name = "Inner",
										AnchorPoint = Vector2.new(0.5, 0.5),
										Position = UDim2.fromScale(0.5, 0.5),
										Size = UDim2.new(1, -18, 1, -18),
										BackgroundTransparency = 1,
										ZIndex = 8,
										[Children] = {
											Kit.corner(s, "Full"),
											s:New "UIStroke" {
												ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
												Thickness = 2,
												Color = WHITE,
												Transparency = s:Computed(function(use)
													return 1 - 0.55 * (1 - use(mvpAlpha))
												end),
											},
										},
									},
									Icon(s, {
										Name = "Crown",
										Icon = "crown",
										Size = 40,
										AnchorPoint = Vector2.new(0.5, 0.5),
										Position = UDim2.fromScale(0.5, 0.3),
										Color = WHITE,
										StrokeColor = Kit.themed(s, function(theme)
											return theme.Tone.Yellow.Stroke or theme.Tone.Yellow.Edge
										end),
										Stroke = 2.5,
										Transparency = mvpAlpha,
										ZIndex = 9,
									}),
									Text(s, {
										Name = "MVPLabel",
										Text = "MVP",
										TextSize = 42,
										Font = "Display",
										Case = "Normal",
										Stroke = 3,
										StrokeColor = Kit.themed(s, function(theme)
											return theme.Tone.Yellow.Stroke or theme.Tone.Yellow.Edge
										end),
										Transparency = mvpAlpha,
										AnchorPoint = Vector2.new(0.5, 0.5),
										Position = UDim2.fromScale(0.5, 0.66),
										ZIndex = 9,
									}),
								},
							},
							if typeof(mvp) == "string"
								then Text(s, {
									Name = "MVPName",
									Text = mvp,
									TextSize = 20,
									Font = "Heavy",
									Stroke = 2,
									Transparency = mvpAlpha,
									AnchorPoint = Vector2.new(0.5, 0),
									Position = UDim2.new(0.5, 0, 1, 6),
									ZIndex = 9,
								})
								else nil,
						},
					}
					else nil,
			},
		}

		----------------------------------------------------------------
		-- Panels
		----------------------------------------------------------------

		local panelWidth = 600
		local rowHeight = 54
		local headerHeight = 64
		local tile = 124
		local perRow = 3
		local rewardRows = math.max(1, math.ceil(#rewards / perRow))
		local statsHeight = headerHeight + math.max(1, #stats) * rowHeight + 20
		local rewardPitch = tile + 72 -- tile, amount and label
		local rewardsHeight = headerHeight + 22 + rewardRows * rewardPitch + 4
		local panelHeight = math.max(if #stats > 0 then statsHeight else 0, if #rewards > 0 then rewardsHeight else 0)
		local both = #stats > 0 and #rewards > 0

		local function panel(index: number, name: string, title: string, x: number, body: { any })
			local appearValue = panelIns[index]
			return s:New "CanvasGroup" {
				Name = name,
				AnchorPoint = Vector2.new(0.5, 0),
				Position = s:Computed(function(use)
					return UDim2.new(0.5, x, 0, 0 + (1 - use(appearValue)) * 50)
				end),
				Size = UDim2.fromOffset(panelWidth, panelHeight),
				GroupTransparency = s:Computed(function(use)
					return 1 - math.clamp(use(appearValue), 0, 1)
				end),
				BackgroundColor3 = Kit.themed(s, function(theme)
					return theme.Color.Surface
				end),
				BackgroundTransparency = 0.12,
				ZIndex = 2,
				[Children] = {
					Kit.corner(s, "L"),
					Kit.stroke(s, { Thickness = 3 }),
					Kit.gradient(s, {
						Rotation = 90,
						Color = ColorSequence.new(Color3.fromRGB(255, 255, 255), Color3.fromRGB(200, 200, 212)),
					}),
					s:New "Frame" {
						Name = "Rim",
						Position = UDim2.fromOffset(3, 3),
						Size = UDim2.new(1, -6, 0, 2),
						BackgroundColor3 = WHITE,
						BackgroundTransparency = 0.82,
						ZIndex = 3,
						[Children] = Kit.corner(s, "Full"),
					},
					s:New "Frame" {
						Name = "Header",
						BackgroundTransparency = 1,
						Size = UDim2.new(1, 0, 0, headerHeight),
						ZIndex = 3,
						[Children] = {
							Text(s, {
								Name = "PanelTitle",
								Text = title,
								TextSize = 22,
								Font = "Heavy",
								Stroke = false,
								XAlign = "Left",
								Color = s:Computed(function(use)
									return Color.lighten(use(tone).Top, 0.3)
								end),
								AnchorPoint = Vector2.new(0, 0.5),
								Position = UDim2.new(0, 28, 0.5, 2),
								ZIndex = 4,
							}),
							s:New "Frame" {
								Name = "Divider",
								AnchorPoint = Vector2.new(0.5, 1),
								Position = UDim2.fromScale(0.5, 1),
								Size = UDim2.new(1, -48, 0, 2),
								BackgroundColor3 = WHITE,
								BackgroundTransparency = 0.88,
								ZIndex = 4,
							},
						},
					},
					body,
				},
			}
		end

		-- stat rows
		local statRows = {}
		for i, stat in stats do
			local label = field(stat, "label", "Label") or ""
			local value = field(stat, "value", "Value")
			local highlight = field(stat, "highlight", "Highlight") == true
			local rowIn = rowIns[i]
			local count = rowCounts[i]
			local shown = s:Computed(function(use)
				local raw = use(value)
				local number, decimals = parseNumber(raw)
				if number == nil then
					return tostring(raw or "")
				end
				return formatNumber(number * use(count), decimals, raw)
			end)
			local row = s:New "Frame" {
				Name = `Stat_{label}`,
				Position = s:Computed(function(use)
					return UDim2.fromOffset(24 - (1 - use(rowIn)) * 60, headerHeight + 10 + (i - 1) * rowHeight)
				end),
				Size = UDim2.new(1, -48, 0, rowHeight - 6),
				BackgroundColor3 = if highlight then Color3.fromRGB(255, 236, 170) else WHITE,
				BackgroundTransparency = s:Computed(function(use)
					local base = if highlight then 0.86 elseif i % 2 == 1 then 0.96 else 1
					return 1 - (1 - base) * math.clamp(use(rowIn), 0, 1)
				end),
				ClipsDescendants = highlight,
				ZIndex = 3,
				[Children] = {
					Kit.corner(s, "S"),
					-- the standout stat: a tone-coloured edge (and a shine once in)
					if highlight
						then s:New "Frame" {
							Name = "Accent",
							Size = UDim2.new(0, 4, 1, -12),
							Position = UDim2.fromOffset(3, 6),
							BackgroundColor3 = s:Computed(function(use)
								return use(tone).Top
							end),
							BackgroundTransparency = s:Computed(function(use)
								return 1 - math.clamp(use(rowIn), 0, 1)
							end),
							ZIndex = 4,
							[Children] = Kit.corner(s, "Full"),
						}
						else nil,
					if field(stat, "icon", "Icon")
						then Icon(s, {
							Name = "StatIcon",
							Icon = field(stat, "icon", "Icon"),
							Size = 28,
							AnchorPoint = Vector2.new(0, 0.5),
							Position = UDim2.new(0, 14, 0.5, 0),
							Color = Kit.themed(s, function(theme)
								return theme.Color.TextMuted
							end),
							Stroke = false,
							Transparency = s:Computed(function(use)
								return 1 - math.clamp(use(rowIn), 0, 1)
							end),
							ZIndex = 4,
						})
						else nil,
					Text(s, {
						Name = "Label",
						Text = label,
						TextSize = 24,
						Font = "Body",
						Stroke = false,
						XAlign = "Left",
						Color = Kit.themed(s, function(theme)
							return theme.Color.TextMuted
						end),
						Transparency = s:Computed(function(use)
							return 1 - math.clamp(use(rowIn), 0, 1)
						end),
						AnchorPoint = Vector2.new(0, 0.5),
						Position = UDim2.new(0, if field(stat, "icon", "Icon") then 54 else 16, 0.5, 0),
						ZIndex = 4,
					}),
					Text(s, {
						Name = "Value",
						Text = shown,
						[Children] = s:New "UIScale" { Scale = valuePunch[i] },
						TextSize = 32,
						Font = "Display",
						Stroke = 2.5,
						XAlign = "Right",
						Color = s:Computed(function(use)
							return if highlight then Color.lighten(use(tone).Top, 0.2) else WHITE
						end),
						Transparency = s:Computed(function(use)
							return 1 - math.clamp(use(rowIn), 0, 1)
						end),
						AnchorPoint = Vector2.new(1, 0.5),
						Position = UDim2.new(1, -16, 0.5, 0),
						ZIndex = 4,
					}),
				},
			}
			table.insert(statRows, row)
			if highlight then
				highlightRows[i] = row
			end
		end

		-- reward tiles
		local rewardTiles = {}
		local rewardAt = {}
		local rewardTones = {}
		local rowsUsed = math.max(1, math.ceil(#rewards / perRow))
		for i, reward in rewards do
			local rowIndex = math.ceil(i / perRow)
			local inRow = if rowIndex < rowsUsed then perRow else #rewards - (rowsUsed - 1) * perRow
			local column = (i - 1) % perRow
			local gap = 36
			local rowWidth = inRow * tile + (inRow - 1) * gap
			local x = -rowWidth / 2 + column * (tile + gap) + tile / 2
			local spare = math.max(0, panelHeight - rewardsHeight)
			local y = headerHeight + 22 + spare / 2 + (rowIndex - 1) * rewardPitch + tile / 2
			rewardAt[i] = UDim2.new(0.5, x, 0, y)
			local pop = rewardPops[i]
			local rarity = field(reward, "rarity", "Rarity")
			local rewardTone = s:Computed(function(use)
				local theme = use(Theme.current)
				if rarity then
					local fn = (Theme :: any).rarity
					if fn then
						return fn(theme, rarity)
					end
				end
				return Theme.tone(theme, field(reward, "tone", "Tone") or "Yellow")
			end)
			rewardTones[i] = rewardTone
			local amount = field(reward, "amount", "Amount")
			local amountText = if typeof(amount) == "number"
				then "+" .. Format.comma(amount)
				elseif amount ~= nil then tostring(amount)
				else ""
			local tileBody
			if rarity and ItemSlot then
				tileBody = ItemSlot(s, {
					Icon = field(reward, "icon", "Icon") or "gift",
					Rarity = rarity,
					Size = tile,
				})
			else
				tileBody = s:New "Frame" {
					Name = "Tile",
					Size = UDim2.fromOffset(tile, tile),
					BackgroundColor3 = Kit.themed(s, function(theme)
						return theme.Color.SurfaceLight
					end),
					ZIndex = 4,
					[Children] = {
						Kit.corner(s, "M"),
						Kit.stroke(s, { Thickness = 3 }),
						Kit.gradient(s, {
							Rotation = 90,
							Color = ColorSequence.new(WHITE, Color3.fromRGB(160, 160, 175)),
						}),
						glow(s, {
							Color = s:Computed(function(use)
								return use(rewardTone).Top
							end),
							Size = UDim2.fromScale(1.3, 1.3),
							Layers = 2,
							ZIndex = 4,
						}),
						Icon(s, {
							Name = "RewardIcon",
							Icon = field(reward, "icon", "Icon") or "gift",
							Size = math.floor(tile * 0.58),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = s:Computed(function(use)
								return use(rewardTone).Top
							end),
							Gradient = true,
							Shadow = true,
							Stroke = 3,
							StrokeColor = s:Computed(function(use)
								local t = use(rewardTone)
								return t.Stroke or t.Edge
							end),
							ZIndex = 5,
						}),
					},
				}
			end
			local amountLabel = Text(s, {
				Name = "Amount",
				Text = amountText,
				TextSize = 30,
				Font = "Display",
				Stroke = 3,
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 1, 6),
				ZIndex = 6,
			})
			if typeof(amount) == "number" and amount >= 10 then
				-- bigger numeric rewards count up as their tile pops in
				amountLabels[i] = { label = amountLabel, value = amount }
			end
			table.insert(rewardTiles, s:New "Frame" {
				Name = `Reward{i}`,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, x, 0, y),
				Size = UDim2.fromOffset(tile, tile),
				BackgroundTransparency = 1,
				ZIndex = 4,
				[Children] = {
					s:New "UIScale" {
						Scale = s:Computed(function(use)
							return math.max(0, use(pop))
						end),
					},
					tileBody,
					amountLabel,
					if field(reward, "label", "Label")
						then Text(s, {
							Name = "RewardLabel",
							Text = field(reward, "label", "Label"),
							TextSize = 17,
							Font = "Body",
							Stroke = false,
							Truncate = true,
							Size = UDim2.fromOffset(tile + 30, 20),
							Color = Kit.themed(s, function(theme)
								return theme.Color.TextMuted
							end),
							AnchorPoint = Vector2.new(0.5, 0),
							Position = UDim2.new(0.5, 0, 1, 40),
							ZIndex = 6,
						})
						else nil,
				},
			})
		end

		local panelsTop = 300 + peek(heroTop)
		local statsPanel = if #stats > 0
			then panel(1, "StatsPanel", "MATCH STATS", if both then -(panelWidth / 2 + 16) else 0, statRows)
			else nil
		rewardsPanel = if #rewards > 0
			then panel(2, "RewardsPanel", "REWARDS", if both then panelWidth / 2 + 16 else 0, rewardTiles)
			else nil
		s:New "Frame" {
			Name = "Panels",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(0.5, 0, 0, panelsTop),
			Size = UDim2.fromOffset(panelWidth * 2 + 32, panelHeight),
			BackgroundTransparency = 1,
			ZIndex = 2,
			Parent = stage,
			[Children] = { statsPanel, rewardsPanel },
		}

		----------------------------------------------------------------
		-- Buttons
		----------------------------------------------------------------

		local primaryIndex = 1
		for i, b in buttons do
			if b.Primary then
				primaryIndex = i
			end
		end
		local buttonViews = {}
		for i, b in buttons do
			local primary = i == primaryIndex
			table.insert(buttonViews, Button(s, {
				Name = b.Text or `Button{i}`,
				Text = b.Text,
				Icon = b.Icon,
				Tone = b.Tone or (if primary then "Green" else "Gray"),
				Size = if primary then UDim2.fromOffset(300, 80) else UDim2.fromOffset(230, 80),
				TextSize = if primary then 32 else 28,
				Shine = primary,
				LayoutOrder = if primary then 0 else i,
				OnClick = function()
					if b.OnClick then
						task.spawn(b.OnClick)
					end
					if b.Close ~= false then
						close()
					end
				end,
			}))
		end
		s:New "CanvasGroup" {
			Name = "Buttons",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = s:Computed(function(use)
				return UDim2.new(0.5, 0, 0, panelsTop + panelHeight + 44 + (1 - use(buttonsIn)) * 40)
			end),
			Size = UDim2.fromOffset(1100, 96),
			BackgroundTransparency = 1,
			GroupTransparency = s:Computed(function(use)
				return 1 - math.clamp(use(buttonsIn), 0, 1)
			end),
			Visible = s:Computed(function(use)
				return use(buttonsIn) > 0.01
			end),
			ZIndex = 5,
			Parent = stage,
			[Children] = {
				Kit.list(s, { Direction = "X", Padding = 24, HAlign = "Center", VAlign = "Center" }),
				buttonViews,
			},
		}
		----------------------------------------------------------------
		-- Timeline
		----------------------------------------------------------------

		play = function()
			token += 1
			local myToken = token
			-- reset
			titleScale:set(2.6)
			titleAlpha:set(1)
			iconPop:setPosition(0)
			iconGoal:set(0)
			underline:set(0)
			subtitleIn:setPosition(0)
			subtitleGoal:set(0)
			for i = 1, 2 do
				panelIns[i]:setPosition(0)
				panelGoals[i]:set(0)
			end
			for i = 1, #stats do
				rowIns[i]:setPosition(0)
				rowGoals[i]:set(0)
				rowCounts[i]:set(0)
			end
			for i = 1, #rewards do
				rewardPops[i]:setPosition(0)
				rewardGoals[i]:set(0)
			end
			buttonsIn:setPosition(0)
			buttonsGoal:set(0)
			mvpScale:set(0)
			mvpAlpha:set(1)
			playing = true

			local k = peek(kind)
			local t = peek(tone)

			later(myToken, 0.05, function()
				iconGoal:set(1)
				iconPop:addVelocity(6)
			end)
			later(myToken, 0.15, function()
				task.spawn(animate, myToken, 0.45, function(p)
					titleScale:set(2.6 + (1 - 2.6) * backOut(p))
					titleAlpha:set(1 - math.clamp(p / 0.3, 0, 1))
				end)
			end)
			-- impact
			later(myToken, 0.36, function()
				local soundName = themedSound(k.sounds)
				if soundName then
					Sound.play(soundName)
				end
				-- screen FX draw on the client FX layer: skip them in
				-- edit-mode previews (no LocalPlayer)
				if Players.LocalPlayer == nil then
					return
				end
				FXScreen.flash(t.Top:Lerp(WHITE, 0.35), 0.25, if k.party then 0.35 else 0.2)
				if k.party then
					-- the victory slam: a heavy shake, a burst off the title and
					-- party poppers from both bottom corners
					FXScreen.cameraShake("Heavy", 0.4)
					local colors = { t.Top, t.Bottom, Color.lighten(t.Top, 0.6), WHITE }
					Particles.burst(UDim2.fromScale(0.5, 0.25), { Count = 46, Speed = 1100, Shape = "mix", Colors = colors })
					Particles.burst(UDim2.fromScale(0.5, 0.25), { Count = 16, Speed = 650, Shape = "sparkle", Size = 38, Gravity = 150 })
					Particles.ring(UDim2.fromScale(0.5, 0.25), { Size = 420, From = 0.3, To = 2.4, Time = 0.45, Thickness = 10, Color = t.Top:Lerp(WHITE, 0.4) })
					Particles.confetti({ Style = "Cannons", Count = 90 })
				elseif k.title == "DEFEAT" then
					-- a heavy thud and red edges instead of a party
					FXScreen.cameraShake("Huge", 0.35)
					FXScreen.damage(0.55, t.Bottom)
				else
					FXScreen.cameraShake("Light", 0.3)
				end
			end)
			later(myToken, 0.5, function()
				task.spawn(animate, myToken, 0.45, function(p)
					underline:set(1 - (1 - p) ^ 3)
				end)
				subtitleGoal:set(1)
			end)
			later(myToken, 0.7, function()
				panelGoals[1]:set(1)
			end)
			later(myToken, 0.8, function()
				panelGoals[2]:set(1)
			end)
			-- count-ups (docs/RESEARCH.md 2.9): Quint Out over
			-- clamp(0.3 + 0.12 * log10(value), 0.3, 1.0) s with rate-limited
			-- rising ticks, then a punch
			local rowsStart = 0.85
			local stagger = 0.06
			local lastTick = 0
			local longest = 0
			for i, stat in stats do
				local number = parseNumber(peek(field(stat, "value", "Value")))
				local duration = if number
					then math.clamp(0.3 + 0.12 * math.log10(math.max(1, math.abs(number))), 0.3, 1.0)
					else 0.3
				longest = math.max(longest, (i - 1) * stagger + duration)
				later(myToken, rowsStart + (i - 1) * stagger, function()
					rowGoals[i]:set(1)
					if number == nil then
						rowCounts[i]:set(1)
						return
					end
					task.spawn(function()
						animate(myToken, duration, function(p)
							rowCounts[i]:set(1 - (1 - p) ^ 5)
							local clock = os.clock()
							if p < 1 and clock - lastTick > 0.05 then
								lastTick = clock
								Sound.play("Tick", { Pitch = 1 + 0.3 * p, Vary = 0, Volume = 0.35 })
							end
						end)
						if myToken == token then
							valuePunch[i]:setPosition(1.18)
							local row = highlightRows[i]
							if row and row.Parent then
								table.insert(s, Motion.shine(row, { ZIndex = 5, Transparency = 0.6, Interval = 2.2 }))
							end
						end
					end)
				end)
			end
			-- rewards start popping while the last stats are still counting
			local rewardsStart = rowsStart + longest * 0.55 + 0.1
			for i = 1, #rewards do
				later(myToken, rewardsStart + (i - 1) * 0.18, function()
					rewardPops[i]:setPosition(0.6)
					rewardGoals[i]:set(1)
					local popName = themedSound({ "Pop", "Tick" })
					if popName then
						Sound.play(popName, { Pitch = 1 + (i - 1) * 0.08, Vary = 0, Volume = 0.8 })
					end
					local counted = amountLabels[i]
					if counted and counted.label.Parent then
						table.insert(s, Motion.countTo(counted.label, 0, counted.value, {
							Duration = 0.5,
							Format = "comma",
							Prefix = "+",
							Punch = 0.14,
						}))
					end
					local panel = rewardsPanel
					if panel and panel.Parent and rewardAt[i] then
						local rt = peek(rewardTones[i])
						poof(panel, rewardAt[i], {
							Count = 8,
							Size = 34,
							Reach = { 50, 100 },
							Colors = { rt.Top, WHITE, Color.lighten(rt.Top, 0.5) },
							RingSize = tile * 0.9,
							RingColor = Color.lighten(rt.Top, 0.3),
							ZIndex = 8,
						})
					end
				end)
			end
			-- the MVP medal stamps down once the stats are in
			local mvpAt = rowsStart + longest + 0.1
			if mvp then
				later(myToken, mvpAt, function()
					task.spawn(animate, myToken, 0.32, function(p)
						mvpScale:set(2.4 + (1 - 2.4) * backOut(p))
						mvpAlpha:set(1 - math.clamp(p / 0.2, 0, 1))
					end)
					task.delay(0.14, function()
						if myToken ~= token or Players.LocalPlayer == nil then
							return
						end
						local medal = stage:FindFirstChild("MVP", true)
						local gold = peek(Theme.current).Tone.Yellow.Top
						Sound.play(themedSound({ "LevelUp", "Reward" }) or "Reward", { Pitch = 1.1 })
						if medal then
							Particles.ring(medal, { Size = 200, From = 0.5, To = 1.8, Time = 0.35, Thickness = 8, Color = gold:Lerp(WHITE, 0.4) })
							Particles.burst(medal, { Count = 12, Shape = "sparkle", Speed = 480, Size = 28, Gravity = 0, Drag = 3, Colors = { WHITE, gold } })
						end
						FXScreen.cameraShake("Light", 0.2)
					end)
				end)
			end
			local buttonsAt = math.max(rewardsStart + #rewards * 0.18 + 0.2, if mvp then mvpAt + 0.35 else 0)
			later(myToken, buttonsAt, function()
				buttonsGoal:set(1)
				playing = false
			end)
		end

		-- Jumps the running sequence to its end (a click or Space mid-way).
		skipSequence = function()
			if not playing then
				return
			end
			playing = false
			token += 1 -- cancels every pending step
			titleScale:set(1)
			titleAlpha:set(0)
			iconGoal:set(1)
			underline:set(1)
			subtitleGoal:set(1)
			for i = 1, 2 do
				panelGoals[i]:set(1)
			end
			for i = 1, #stats do
				rowGoals[i]:set(1)
				rowCounts[i]:set(1)
			end
			for i = 1, #rewards do
				rewardGoals[i]:set(1)
				local counted = amountLabels[i]
				if counted and counted.label.Parent then
					counted.label.Text = "+" .. Format.comma(counted.value)
				end
			end
			if mvp then
				mvpScale:set(1)
				mvpAlpha:set(0)
			end
			buttonsGoal:set(1)
		end
	end

	build(cfg)

	local wasOpen = false
	local function onOpen(now: boolean)
		if now and not wasOpen then
			Sound.play("Open")
			play()
		elseif not now and wasOpen then
			token += 1
			playing = false
			Sound.play("Close")
		end
		wasOpen = now
	end
	Kit.watch(scope, open, onOpen)
	onOpen(peek(open))

	-- (the content scope is an inner scope, so it is cleaned with `scope`)
	table.insert(scope, function()
		token += 1
		contentScope = nil
	end)

	return {
		Gui = gui,
		Open = open,
		Result = result,
		open = function()
			open:set(true)
		end,
		close = close,
		-- Jumps the entrance sequence to its end (what a click / Space does).
		skip = function()
			skipSequence()
		end,
		-- Replaces the content (any of Result, Title, Subtitle, Icon, Stats,
		-- Rewards, Buttons) and opens the screen with the full sequence.
		show = function(data: { [string]: any }?)
			for key, value in data or {} do
				cfg[key] = value
			end
			if data and data.Result and result.set then
				result:set(data.Result)
			end
			build(cfg)
			if peek(open) then
				play()
			else
				open:set(true)
			end
		end,
	}
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="256">
            <Properties>
              <string name="Name">Shop</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs.Shop: a complete tabbed shop from one config table (the RIVALS
	Key Bundle shop: dark glass tabs with pop-out icons, a callout bubble,
	full-bleed bundle cards with lime price buttons, a key counter with a
	tooltip, and a red close button).

	local shop = UI.Prefabs.Shop(scope, {
		Currency = { Icon = "key", Amount = keys, Tooltip = "Complete your tasks for keys!", OnAdd = fn },
		Tabs = {
			{ Id = "keys", Text = "Keys", Icon = "key", Products = {
				{ Title = "Key Bundle", Amount = "+ 10", Price = 48, ProductId = 123456 },
				{ Title = "Legendary Key Bundle", Amount = "+ 1,100", Price = 3000, Tone = "Red", Sparkles = true },
			} },
			{ Id = "daily", Text = "Daily", Icon = "daily", Callout = "Claim your FREE reward now!",
			  Content = function(scope) return { ... } end },      -- any custom page
		},
		Selected = "keys",
		Button = { Text = "Shop", Icon = "shop", Hotkey = Enum.KeyCode.B },  -- optional HUD button
		OnPurchase = function(product, tab, card) end,  -- products without ProductId / GamePassId / OnPurchase
		Nav = "Tabs",                              -- or "SideTabs"
		OffsetY = 44,                              -- window offset below centre
	})

	shop.open("keys")   shop.close()   shop.toggle()   shop.Open (Value)   shop.Gui
	shop.reward(card, 40)   the purchase payoff: a poof at the card, keys fly into the
	                        counter and it rolls up as they land (adds to Currency.Amount
	                        when that's a Value)

	Product fields: Title, Amount, Icon, Tone, Price, PriceIcon ("robux" by
	default, false for none), Tag, Subtitle, Sparkles, Owned (bool/state),
	SoldOut (bool/state), Stock (number/state: "3 LEFT", sold out at 0),
	Featured (shine + glow; by default tagged cards shine and sparkle cards glow),
	ButtonTone, and one of ProductId / GamePassId / OnPurchase.
]]

local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local Input = require(script.Parent.Parent.Util.Input)
local Sound = require(script.Parent.Parent.Util.Sound)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Particles = require(script.Parent.Parent.FX.Particles)
local Motion = require(script.Parent.Parent.FX.Motion)

local Children = Fusion.Children
local peek = Fusion.peek

local WHITE = Color3.new(1, 1, 1)

return function(scope: any, config: { [string]: any })
	local tabs = config.Tabs or {}
	local open = scope:Value(config.Open == true)
	local selected = scope:Value(config.Selected or (tabs[1] and tabs[1].Id))
	local size = config.Size or Vector2.new(1180, 700)

	local function purchase(product: { [string]: any }, tab: { [string]: any }, card: Instance?)
		local player = Players.LocalPlayer
		if product.OnPurchase then
			product.OnPurchase(product, tab, card)
		elseif product.ProductId then
			MarketplaceService:PromptProductPurchase(player, product.ProductId)
		elseif product.GamePassId then
			MarketplaceService:PromptGamePassPurchase(player, product.GamePassId)
		elseif config.OnPurchase then
			config.OnPurchase(product, tab, card)
		end
	end

	-- Featured cards' rims breathe between 80% and full strength every 2.4 s
	-- (restrained). Engine tweens on the rim strokes, paused while the shop
	-- can't be seen; a locked (owned / sold out) card's rim is off.
	local BREATH = TweenInfo.new(1.2, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true)
	local REST_STRENGTH = 0.8
	local function breathe(pageScope: any, rim: Instance, locked: any)
		local strokes = {}
		local rest = {}
		for _, d in rim:GetDescendants() do
			if d:IsA("UIStroke") then
				table.insert(strokes, d)
				table.insert(rest, (d :: UIStroke).Transparency)
			end
		end
		local running = {}
		local function stop()
			for _, loop in running do
				loop.untrack()
				loop.tween:Cancel()
			end
			table.clear(running)
		end
		local function update(isLocked: boolean)
			stop()
			for index, stroke in strokes do
				if isLocked then
					stroke.Transparency = 1
				else
					stroke.Transparency = rest[index]
					local full = 1 - (1 - rest[index]) / REST_STRENGTH
					local tween = TweenService:Create(stroke, BREATH, { Transparency = full })
					tween:Play()
					table.insert(running, { tween = tween, untrack = Motion.track(tween, rim) })
				end
			end
		end
		update(peek(locked) == true)
		Kit.watch(pageScope, locked, function(now)
			update(now == true)
		end)
		table.insert(pageScope, stop)
	end

	local function productCard(pageScope: any, product: { [string]: any }, tab: { [string]: any }, cardSize: UDim2, order: number)
		-- sold out: SoldOut = true, or a Stock count that reached 0 (ProductCard
		-- draws the stamp, the "3 LEFT" chip and the disabled price itself)
		local stock = product.Stock
		local soldOut = pageScope:Computed(function(use)
			if use(product.SoldOut) == true then
				return true
			end
			local left = use(stock)
			return typeof(left) == "number" and left <= 0
		end)
		local locked = pageScope:Computed(function(use)
			return use(product.Owned) == true or use(soldOut)
		end)
		-- premium (sparkle) offers glow (QUALITY rule 7)
		local glowing = product.Sparkles == true or product.Featured == true
		local tone = product.Tone or tab.Tone or "Yellow"

		-- a soft rim of the card's own colour that breathes
		local rim = if glowing
			then Kit.rimGlow(pageScope, {
				Color = Kit.themed(pageScope, function(theme)
					return Color.lighten(Theme.tone(theme, tone).Top, 0.1)
				end),
				-- fits inside the page's scroll padding, so it never gets cut off
				Thickness = 16,
				Radius = "M",
				Strength = REST_STRENGTH,
			})
			else nil

		local card
		card = pageScope:ProductCard {
			Title = product.Title,
			Amount = product.Amount,
			Subtitle = product.Subtitle,
			Icon = product.Icon or tab.Icon,
			Tone = tone,
			Price = product.Price,
			PriceIcon = if product.PriceIcon == nil then "robux" elseif product.PriceIcon == false then nil else product.PriceIcon,
			Tag = product.Tag,
			Sparkles = product.Sparkles,
			Owned = product.Owned,
			SoldOut = product.SoldOut,
			Stock = stock,
			ButtonTone = product.ButtonTone,
			-- idle shine: ProductCard's default (tagged / sparkle cards) unless Featured says otherwise
			Shine = product.Featured,
			Size = cardSize,
			LayoutOrder = order,
			OnPurchase = function()
				if not peek(locked) then
					purchase(product, tab, card)
				end
			end,
			[Children] = rim,
		}
		if rim then
			breathe(pageScope, rim, locked)
		end
		return card
	end

	local function productPage(pageScope: any, tab: { [string]: any })
		local products = tab.Products or {}
		local perRow = tab.PerRow or 3
		local cardHeight = tab.CardHeight or 248
		local gap = tab.Gap or 34
		local rows = {}
		local index = 1
		while index <= #products do
			local count = math.min(perRow, #products - index + 1)
			local cards = {}
			for offset = 0, count - 1 do
				local product = products[index + offset]
				local cardSize = UDim2.new(1 / count, -math.ceil(gap * (count - 1) / count), 0, cardHeight)
				table.insert(cards, productCard(pageScope, product, tab, cardSize, offset))
			end
			table.insert(rows, pageScope:Row {
				Name = `Row{#rows + 1}`,
				LayoutOrder = #rows + 1,
				Size = UDim2.new(1, 0, 0, cardHeight),
				Gap = gap,
				[Children] = cards,
			})
			index += count
		end
		return pageScope:Scroll {
			Gap = gap,
			Padding = { 14, 14, 20, 14 },
			[Children] = rows,
		}
	end

	local pages = {}
	local tabDefs = {}
	for _, tab in tabs do
		table.insert(tabDefs, {
			Id = tab.Id,
			Text = tab.Text or tab.Id,
			Icon = tab.Icon,
			IconColor = tab.IconColor,
			Badge = tab.Badge,
			Callout = tab.Callout,
			CalloutTone = tab.CalloutTone,
		})
		if tab.Content then
			pages[tab.Id] = tab.Content
		else
			pages[tab.Id] = function(pageScope)
				return productPage(pageScope, tab)
			end
		end
	end

	local currency = nil
	if config.Currency then
		local c = config.Currency
		currency = scope:Currency {
			Icon = c.Icon,
			IconColor = c.Color or c.IconColor,
			Amount = c.Amount,
			Format = c.Format or "comma",
			OnAdd = c.OnAdd,
			Tooltip = c.Tooltip,
			-- on short screens the tooltip would sit on the tabs' callouts
			TooltipVisible = scope:Computed(function(use)
				return not use(open) or use(Viewport.designSize).Y >= 900
			end),
			-- top-centre like RIVALS; on short (phone) screens the tab callouts
			-- need that space, so the counter docks top-right instead
			AnchorPoint = c.AnchorPoint or scope:Computed(function(use)
				return if use(Viewport.designSize).Y < 900 then Vector2.new(1, 0) else Vector2.new(0.5, 0)
			end),
			Position = c.Position or scope:Computed(function(use)
				return if use(Viewport.designSize).Y < 900 then UDim2.new(1, -28, 0, 14) else UDim2.new(0.5, 0, 0, 18)
			end),
			Size = c.Size,
			ZIndex = 20,
		}
	end

	local function toggle()
		open:set(not peek(open))
	end

	local button = nil
	if config.Button then
		local b = config.Button
		button = scope:Button {
			Text = b.Text,
			Icon = b.Icon or "shop",
			IconColor = b.IconColor,
			RichIcon = b.IconColor ~= nil,
			Tone = b.Tone or "Yellow",
			Size = b.Size or UDim2.fromOffset(170, 64),
			AnchorPoint = b.AnchorPoint or Vector2.new(0, 0.5),
			Position = b.Position or UDim2.new(0, 24, 0.5, 0),
			Badge = b.Badge,
			Shine = true,
			OnClick = toggle,
			-- the window has its own close button; the HUD button would only
			-- sit dimmed behind the backdrop
			Visible = scope:Computed(function(use)
				return not use(open)
			end),
			ZIndex = 5,
			[Children] = if b.Hotkey
				then scope:KeyCap {
					Key = b.Hotkey,
					Size = 30,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(1, -4, 1, -6),
					ZIndex = 12,
				}
				else nil,
		}
		if b.Hotkey then
			Input.bind(scope, b.Hotkey, toggle)
		end
	end

	local gui = scope:Screen {
		Name = config.Name or "Shop",
		DisplayOrder = config.DisplayOrder or 10,
		[Children] = {
			button,
			scope:Window {
				Open = open,
				Size = size,
				Tabs = tabDefs,
				Selected = selected,
				Pages = pages,
				OnSelect = config.OnSelect,
				Nav = config.Nav,
				Transition = config.Transition,
				-- leave room above the tabs for the currency, its tooltip and
				-- callout bubbles
				OffsetY = config.OffsetY or 44,
				ZIndex = 10,
			},
			currency,
		},
	}

	local shop = {
		Gui = gui,
		Open = open,
		Selected = selected,
		Currency = currency,
		Button = button,
		toggle = toggle,
	}

	function shop.open(tabId: string?)
		if tabId then
			selected:set(tabId)
		end
		open:set(true)
	end

	function shop.close()
		open:set(false)
	end

	--[[
		The purchase payoff (docs/RESEARCH.md 2.10): a poof where the player
		clicked, the currency bursts out and homes into the counter with rising
		ticks, the counter rolls up as they land and pops at the end.
		`from` is the card (or any GuiObject / screen point). options:
		{ Count, Icon, Color, OnDone }.
	]]
	function shop.reward(from: any, amount: number, options: { [string]: any }?)
		local opts = options or {}
		local c = config.Currency or {}
		local origin = from
		if typeof(from) == "Instance" then
			origin = from:FindFirstChild("Price", true) or from
		end
		local color = opts.Color or c.Color or c.IconColor or Theme.get().Tone.Yellow.Top
		Particles.poof(origin, { Color = WHITE, Size = 44 })
		Particles.ring(origin, { Color = color, Size = 150, Thickness = 5 })
		if opts.Text then
			Particles.popText(origin, opts.Text, { Color = color, Stroke = Kit.tintedStroke(Theme.get(), color), Size = 38, Rise = 60, Jitter = 0 })
		end
		if currency == nil or amount <= 0 then
			if opts.OnDone then
				opts.OnDone()
			end
			return
		end
		local value = c.Amount
		local settable = typeof(value) == "table" and value.set ~= nil
		local count = opts.Count or math.clamp(math.ceil(math.sqrt(amount) * 1.6), 6, 16)
		local share = amount / count
		local landed = 0
		local start = if settable then peek(value) else 0
		task.delay(0.12, function()
			Particles.flyTo(origin, currency, {
				Icon = opts.Icon or c.Icon or "coin",
				Color = color,
				Count = count,
				Size = 52,
				OnArrive = function()
					landed += 1
					if settable then
						-- whole numbers while rolling; exact total on the last one
						value:set(if landed >= count then start + amount else start + math.floor(share * landed))
					end
					if landed >= count then
						Sound.play("Purchase")
						Motion.pop(currency, 0.16)
						-- "+40" rises beside the counter: the receipt
						if opts.Pop ~= false and currency.Parent then
							local at = currency.AbsolutePosition + Vector2.new(currency.AbsoluteSize.X + 56 * peek(Viewport.scale), currency.AbsoluteSize.Y * 0.55)
							Particles.popText(at, `+{Format.comma(amount)}`, {
								Color = color,
								Stroke = Kit.tintedStroke(Theme.get(), color),
								Size = 40,
								Rise = 34,
								Jitter = 0,
								Lifetime = 1.1,
							})
						end
						if opts.OnDone then
							opts.OnDone()
						end
					end
				end,
			})
		end)
	end

	return shop
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="257">
            <Properties>
              <string name="Name">SimulatorHud</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs.SimulatorHud: a top-simulator HUD (Pet Simulator 99 / Bee Swarm /
	Grow a Garden style). Everything is optional and laid out for you:

	  top-left      big currency counters that roll (numbers line up), level bar
	  left          a grid of big square menu tiles with labels + badges
	                (badged tiles give a little "look at me" wiggle now and then)
	  top-centre    goal progress ("Unlock Candy Land 450 / 1K") with a lock that
	                pops open when it's reached
	  top-right     boost pills with draining timers (they pulse red at the end)
	  right         a column of extra tiles (Rebirth, Teleport ...)
	  bottom-centre the juicy click button: glossy plate, giant coin, light rays,
	                "CLICK!" label, a per-click chip and a combo counter

	On phones the tiles form one row under the counters, so the whole stack
	ends well above the thumbstick; the click button stays between the thumbs
	and the right column stays clear of the jump button.

	local hud = UI.Prefabs.SimulatorHud(scope, {
		Currencies = {
			{ Id = "Coins", Icon = "coin", Amount = coins, OnAdd = openShop },
			{ Id = "Gems", Icon = "gem", Color = Color3.fromHex("5CC8FF") },
		},
		Buttons = {                                   -- left grid
			{ Text = "Shop", Icon = "shop", Color = Color3.fromHex("FFC21F"), OnClick = openShop },
			{ Text = "Pets", Icon = "egg", Color = Color3.fromHex("FF7AB6"), Badge = 2 },
		},
		RightButtons = {                              -- right column (same format)
			{ Text = "Rebirth", Icon = "rebirth", Color = Color3.fromHex("B45CFF") },
		},
		Boosts = { { Icon = "boost", Text = "2x Coins", EndsAt = os.time() + 300, Duration = 300 } },
		Goal = { Text = "Unlock Candy Land", Icon = "world", Value = coins, Max = 1000, OnReached = fn },
		Level = { Level = level, Progress = xp },     -- Progress 0..1
		Clicker = { Text = "CLICK!", Icon = "coin", Per = "+1 / click", OnClick = function() end, Combo = true },
	})

	hud.give("Coins", 250, fromPart)   coins burst from `from` and fly into the counter
	hud.spend("Gems", 10)              false + shake if the player can't afford it
	hud.Currencies.Coins               the Value behind each counter
	hud.addBoost({ Icon = "boost", Text = "2x Luck", EndsAt = os.time() + 60 })
	hud.Combo                          Value: the current click combo

	Menu tiles are named after their Text, so a tile's button is
	`hud.Gui.<...>.<Text>` (a Frame holding the button and its label).
]]

local TweenService = game:GetService("TweenService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Bind = require(script.Parent.Parent.Util.Bind)
local Format = require(script.Parent.Parent.Util.Format)
local Sound = require(script.Parent.Parent.Util.Sound)
local Color = require(script.Parent.Parent.Util.Color)
local Input = require(script.Parent.Parent.Util.Input)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local Layer = require(script.Parent.Parent.FX.Layer)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Popups = require(script.Parent.Parent.Popups)

local Children = Fusion.Children
local peek = Fusion.peek

local TOP = 72 -- below Roblox's top bar at 1080p (grows on small screens)
local EDGE = 24
local WHITE = Color3.new(1, 1, 1)

type Scope = any

local function badgeActive(value: any): boolean
	return value ~= nil and value ~= false and value ~= 0 and value ~= ""
end

-- A big square menu tile: glossy plate, colourful icon, label over the
-- bottom edge, optional badge. Named after its Text. Pops in on spawn.
local function Tile(scope: Scope, def: { [string]: any }, index: number, size: number, delay: number)
	local popGoal = scope:Value(0)
	local pop = scope:Spring(popGoal, 26, 0.55)
	task.delay(delay, function()
		popGoal:set(1)
	end)
	local tile = scope:New "Frame" {
		Name = def.Text or `Tile{index}`,
		LayoutOrder = index,
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(size, size + 14),
		[Children] = {
			scope:New "UIScale" {
				Scale = scope:Computed(function(use)
					return math.max(0, use(pop))
				end),
			},
			scope:Button {
				Name = "Button",
				Icon = def.Icon,
				IconColor = def.Color or Kit.themed(scope, function(theme)
					return theme.Color.Accent
				end),
				RichIcon = true,
				IconSize = math.floor(size * 0.6),
				Tone = def.Tone or "White",
				Size = UDim2.fromOffset(size, size),
				Radius = "L",
				Badge = def.Badge,
				Pulse = def.Pulse,
				Hotkey = def.Hotkey,
				OnClick = def.OnClick,
			},
			scope:Text {
				Name = "Label",
				Text = def.Text or "",
				TextSize = math.floor(size * 0.22),
				Font = "Display",
				Stroke = 3,
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.new(0.5, 0, 1, 0),
				ZIndex = 8,
			},
		},
	}

	-- a badge means "something new": wiggle every few seconds, not constantly
	if def.Badge ~= nil then
		local alive = true
		table.insert(scope, function()
			alive = false
		end)
		task.spawn(function()
			task.wait(1.2 + index * 0.35)
			while alive and tile.Parent ~= nil do
				if badgeActive(peek(def.Badge)) then
					local steps = { -9, 7, -5, 3, 0 }
					for _, angle in steps do
						if not alive then
							return
						end
						TweenService:Create(tile, TweenInfo.new(0.07, Enum.EasingStyle.Sine), { Rotation = angle }):Play()
						task.wait(0.07)
					end
				end
				task.wait(3.4)
			end
		end)
	end
	return tile
end

return function(scope: any, config: { [string]: any }?)
	local cfg = config or {}
	local currencies = {}
	local frames = {}
	local defs = {}

	local onTouch = scope:Computed(function(use)
		return use(Input.device) == "Touch"
	end)

	-- Roblox's top bar is ~58 real px on every device, which is many more
	-- design px on a phone; keep the HUD clear of it everywhere.
	local top = Kit.safeTop(scope, TOP)

	-- entrance: counters slide in, the goal drops, the clicker grows
	local introGoal = scope:Value(0)
	local intro = scope:Spring(introGoal, 14, 0.75)
	task.delay(0.05, function()
		introGoal:set(1)
	end)

	local now = scope:Value(os.time())
	local ticking = true
	table.insert(scope, function()
		ticking = false
	end)
	task.spawn(function()
		while ticking do
			now:set(os.time())
			task.wait(1)
		end
	end)

	--------------------------------------------------------------------
	-- Currencies (top-left) + level
	--------------------------------------------------------------------

	local chipStyle = cfg.ChipStyle or "Glass"
	local currencyWidth = cfg.CurrencyWidth or 264
	local currencyFrames = {}
	for index, def in cfg.Currencies or {} do
		local value = Bind.asValue(scope, def.Amount, 0)
		currencies[def.Id] = value
		defs[def.Id] = def
		local frame = scope:Currency {
			Name = def.Id,
			Icon = def.Icon or "coin",
			IconColor = def.Color,
			IconRotation = def.IconRotation or 0,
			Amount = value,
			Format = def.Format or "abbrev",
			OnAdd = def.OnAdd,
			Style = chipStyle,
			Align = "Left",
			-- a counter without a "+" hugs its number instead of trailing an
			-- empty pill (set Fit = false to keep the stack's full width)
			Fit = if def.Fit ~= nil then def.Fit else def.OnAdd == nil,
			LayoutOrder = index,
			Size = UDim2.fromOffset(currencyWidth, 64),
		}
		frames[def.Id] = frame
		table.insert(currencyFrames, frame)
	end

	local levelBar = nil
	if cfg.Level then
		local lv = cfg.Level
		local levelPopGoal = scope:Value(1)
		local levelPop = scope:Spring(levelPopGoal, 28, 0.4)
		Kit.watch(scope, lv.Level, function()
			levelPop:setPosition(1.35)
			Sound.play("LevelUp")
		end)
		levelBar = scope:New "Frame" {
			Name = "Level",
			LayoutOrder = 2,
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(currencyWidth, 48),
			[Children] = {
				scope:ProgressBar {
					Value = lv.Progress or 0,
					Max = 1,
					Tone = lv.Tone or "Purple",
					Ghost = false,
					Shine = true,
					Label = function(v)
						return `{math.floor(v * 100)}%`
					end,
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, 0, 0.5, 0),
					Size = UDim2.new(1, -56, 0, 28),
				},
				scope:New "Frame" {
					Name = "Badge",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 4, 0.5, 0),
					Size = UDim2.fromOffset(76, 44),
					BackgroundColor3 = WHITE,
					ZIndex = 3,
					[Children] = {
						scope:New "UIScale" { Scale = levelPop },
						Kit.corner(scope, "M"),
						Kit.gradient(scope, {
							Color = Kit.themed(scope, function(theme)
								return Kit.faceColors(Theme.tone(theme, lv.Tone or "Purple"), 0)
							end),
						}),
						Kit.stroke(scope, {
							Color = Kit.themed(scope, function(theme)
								return Theme.tone(theme, lv.Tone or "Purple").Edge
							end),
						}),
						scope:Text {
							Text = scope:Computed(function(use)
								return `Lv {use(lv.Level) or 1}`
							end),
							TextSize = 24,
							Font = "Display",
							StrokeColor = Kit.themed(scope, function(theme)
								local t = Theme.tone(theme, lv.Tone or "Purple")
								return t.Stroke or t.Deep
							end),
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							ZIndex = 4,
						},
					},
				},
			},
		}
	end

	--------------------------------------------------------------------
	-- Menu tiles (left grid + right column)
	--------------------------------------------------------------------

	local tileSize = cfg.TileSize or 104
	local leftTiles = {}
	for index, def in cfg.Buttons or {} do
		table.insert(leftTiles, Tile(scope, def, index, tileSize, 0.15 + index * 0.06))
	end
	local rightTiles = {}
	for index, def in cfg.RightButtons or {} do
		table.insert(rightTiles, Tile(scope, def, index, tileSize, 0.25 + index * 0.06))
	end

	--------------------------------------------------------------------
	-- Boost pills (top-right) with draining timers
	--------------------------------------------------------------------

	local boosts = scope:Value(table.clone(cfg.Boosts or {}))
	local boostPills = scope:ForValues(boosts, function(_, inner, boost)
		local startLeft = math.max(1, (peek(boost.EndsAt) or 0) - os.time())
		local duration = boost.Duration or startLeft
		local remaining = inner:Computed(function(use)
			return math.max(0, (use(boost.EndsAt) or 0) - use(now))
		end)
		local ending = inner:Computed(function(use)
			local r = use(remaining)
			return r > 0 and r <= 10
		end)
		local tone = Kit.tone(inner, boost.Tone, "Green")
		local look, layers = Kit.surface(inner, { Style = "Glass", Radius = "Full" })
		local appearGoal = inner:Value(0)
		local appear = inner:Spring(appearGoal, 24, 0.55)
		task.defer(function()
			appearGoal:set(1)
		end)
		local pill = inner:New "Frame" {
			Name = "Boost",
			BackgroundTransparency = 1,
			Size = UDim2.fromOffset(222, 62),
			Visible = inner:Computed(function(use)
				return use(remaining) > 0
			end),
			[Children] = {
				inner:New "UIScale" {
					Scale = inner:Computed(function(use)
						return math.max(0, use(appear))
					end),
				},
				inner:New "Frame" {
					Name = "Pill",
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, 0, 0.5, 0),
					Size = UDim2.new(1, -18, 0, 52),
					BackgroundColor3 = look.BackgroundColor3,
					BackgroundTransparency = look.BackgroundTransparency,
					[Children] = {
						layers,
						inner:Text {
							Name = "Title",
							Text = boost.Text or boost.Tag or "Boost",
							TextSize = 22,
							Font = "Display",
							XAlign = "Left",
							AnchorPoint = Vector2.new(0, 0),
							Position = UDim2.fromOffset(48, 4),
							Size = UDim2.new(1, -56, 0, 24),
							Truncate = true,
							ZIndex = 3,
						},
						inner:Text {
							Name = "Time",
							Text = inner:Computed(function(use)
								return Format.time(use(remaining))
							end),
							TextSize = 18,
							Font = "Heavy",
							XAlign = "Left",
							Color = inner:Computed(function(use)
								local theme = use(Theme.current)
								return if use(ending) then theme.Color.Bad:Lerp(WHITE, 0.25) else theme.Color.Text
							end),
							AnchorPoint = Vector2.new(0, 0),
							Position = UDim2.fromOffset(48, 26),
							ZIndex = 3,
						},
						-- the time left drains along the bottom
						inner:New "Frame" {
							Name = "Drain",
							AnchorPoint = Vector2.new(0, 1),
							Position = UDim2.new(0, 48, 1, -5),
							Size = UDim2.new(1, -64, 0, 4),
							BackgroundColor3 = Kit.themed(inner, function(theme)
								return theme.Color.Track
							end),
							BackgroundTransparency = 0.3,
							ZIndex = 3,
							[Children] = {
								Kit.corner(inner, "Full"),
								inner:New "Frame" {
									Name = "Fill",
									Size = inner:Computed(function(use)
										return UDim2.fromScale(math.clamp(use(remaining) / duration, 0, 1), 1)
									end),
									BackgroundColor3 = inner:Computed(function(use)
										local theme = use(Theme.current)
										return if use(ending) then theme.Color.Bad else use(tone).Top
									end),
									ZIndex = 4,
									[Children] = Kit.corner(inner, "Full"),
								},
							},
						},
					},
				},
				-- the round tone badge with the icon breaks the pill's left edge
				inner:New "Frame" {
					Name = "IconBadge",
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.fromScale(0, 0.5),
					Size = UDim2.fromOffset(58, 58),
					BackgroundColor3 = WHITE,
					ZIndex = 5,
					[Children] = {
						Kit.corner(inner, "Full"),
						Kit.gradient(inner, {
							Color = inner:Computed(function(use)
								return Kit.faceColors(use(tone), 0.1)
							end),
						}),
						inner:New "UIStroke" {
							Thickness = 3,
							Color = inner:Computed(function(use)
								return use(tone).Edge
							end),
						},
						inner:Icon {
							Icon = boost.Icon or "boost",
							Size = 34,
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = WHITE,
							StrokeColor = inner:Computed(function(use)
								return use(tone).Edge
							end),
							ZIndex = 6,
						},
					},
				},
			},
		}
		-- the last ten seconds pulse so the player knows it's about to run out
		local stopPulse: (() -> ())? = nil
		Kit.watch(inner, ending, function(on)
			if on and stopPulse == nil then
				stopPulse = Motion.pulse(pill, 0.05, 0.8)
			elseif not on and stopPulse then
				stopPulse()
				stopPulse = nil
			end
		end)
		table.insert(inner, function()
			if stopPulse then
				stopPulse()
			end
		end)
		return pill
	end)

	--------------------------------------------------------------------
	-- Goal (top-centre progress) with a lock that opens when reached
	--------------------------------------------------------------------

	local goalFrame = nil
	if cfg.Goal then
		local goal = cfg.Goal
		local max = goal.Max or 100
		local reached = scope:Computed(function(use)
			return (use(goal.Value) or 0) >= use(max)
		end)
		local lockPopGoal = scope:Value(1)
		local lockPop = scope:Spring(lockPopGoal, 26, 0.4)
		local look, layers = Kit.surface(scope, { Style = "Glass", Radius = "Full" })
		goalFrame = scope:New "Frame" {
			Name = "Goal",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = scope:Computed(function(use)
				return UDim2.new(0.5, 0, 0, use(top) - (1 - use(intro)) * 140)
			end),
			Size = UDim2.fromOffset(600, 78),
			BackgroundColor3 = look.BackgroundColor3,
			BackgroundTransparency = look.BackgroundTransparency,
			[Children] = {
				layers,
				scope:Icon {
					Icon = goal.Icon or "flag",
					Size = 86,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0, 30, 0.5, 0),
					Rotation = -8,
					Color = goal.Color or Kit.themed(scope, function(theme)
						return theme.Tone.Blue.Top
					end),
					Gradient = true,
					Shadow = true,
					ZIndex = 4,
				},
				scope:Text {
					Text = goal.Text or "Goal",
					TextSize = 23,
					Font = "Display",
					XAlign = "Left",
					AnchorPoint = Vector2.new(0, 0),
					Position = UDim2.fromOffset(84, 6),
					ZIndex = 3,
				},
				scope:ProgressBar {
					Value = goal.Value or 0,
					Max = max,
					Tone = scope:Computed(function(use)
						return if use(reached) then "Green" else (goal.Tone or "Yellow")
					end),
					Ghost = false,
					Shine = true,
					Label = function(v, m)
						return `{Format.abbrev(math.min(v, m))} / {Format.abbrev(m)}`
					end,
					LabelAlign = "Center",
					AnchorPoint = Vector2.new(0, 1),
					Position = UDim2.new(0, 84, 1, -9),
					Size = UDim2.new(1, -150, 0, 32),
					ZIndex = 3,
				},
				-- end cap: a lock that pops open into a check
				scope:New "Frame" {
					Name = "Lock",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(1, -36, 0.5, 0),
					Size = UDim2.fromOffset(50, 50),
					BackgroundColor3 = WHITE,
					ZIndex = 5,
					[Children] = {
						scope:New "UIScale" { Scale = lockPop },
						Kit.corner(scope, "Full"),
						Kit.gradient(scope, {
							Color = scope:Computed(function(use)
								local theme = use(Theme.current)
								return Kit.faceColors(if use(reached) then theme.Tone.Green else theme.Tone.Gray, 0.15)
							end),
						}),
						scope:New "UIStroke" {
							Thickness = 3,
							Color = scope:Computed(function(use)
								local theme = use(Theme.current)
								return (if use(reached) then theme.Tone.Green else theme.Tone.Gray).Edge
							end),
						},
						scope:Icon {
							Icon = scope:Computed(function(use)
								return if use(reached) then "check" else "lock"
							end),
							Size = 28,
							AnchorPoint = Vector2.new(0.5, 0.5),
							Position = UDim2.fromScale(0.5, 0.5),
							Color = WHITE,
							ZIndex = 6,
						},
					},
				},
			},
		}
		Kit.watch(scope, reached, function(yes)
			if yes then
				lockPop:setPosition(1.5)
				Sound.play("Reward")
				if goalFrame and goalFrame.Parent then
					Particles.burst(goalFrame, { Shape = "sparkle", Count = 14, Size = 30, Speed = 420, Gravity = 300 })
				end
				if goal.OnReached then
					goal.OnReached()
				end
			end
		end)
	end

	--------------------------------------------------------------------
	-- The click button (bottom-centre centrepiece)
	--------------------------------------------------------------------

	local clicker = nil
	local clickerArea = nil
	local combo = scope:Value(0)
	-- rapid clicks add up in one "+N" above the button instead of a pile of texts
	local gain = scope:Value(0)
	local gainColor = scope:Value(WHITE)
	local gainAlphaGoal = scope:Value(1)
	local gainAlpha = scope:Tween(gainAlphaGoal, TweenInfo.new(0.3, Enum.EasingStyle.Quad, Enum.EasingDirection.Out))
	local gainPopGoal = scope:Value(1)
	local gainPop = scope:Spring(gainPopGoal, 34, 0.45)
	local gainRiseGoal = scope:Value(0)
	local gainRise = scope:Spring(gainRiseGoal, 10, 1)
	local gainToken = 0
	local function addGain(amount: number, color: Color3)
		gainToken += 1
		local token = gainToken
		if peek(gainAlphaGoal) >= 1 then
			gain:set(0)
			gainRise:setPosition(0)
			gainRiseGoal:set(0)
		end
		gain:set(peek(gain) + amount)
		gainColor:set(color)
		gainAlphaGoal:set(0)
		gainPop:setPosition(1.3)
		task.delay(0.85, function()
			if token == gainToken then
				gainAlphaGoal:set(1)
				gainRiseGoal:set(-46)
			end
		end)
	end
	if cfg.Clicker then
		local c = cfg.Clicker
		local plate = c.Size or 184
		local raysFlash = scope:Value(0)
		local raysFlashTween = scope:Tween(raysFlash, TweenInfo.new(0.35, Enum.EasingStyle.Quad, Enum.EasingDirection.Out))
		local rays = Kit.rays(scope, {
			Name = "Rays",
			Size = UDim2.fromOffset(plate * 2.1, plate * 2.1),
			Position = UDim2.new(0.5, 0, 0, plate * 0.5),
			Count = 14,
			Transparency = scope:Computed(function(use)
				return 0.45 - 0.25 * use(raysFlashTween)
			end),
			Color = Kit.themed(scope, function(theme)
				return Color.lighten(theme.Tone.Yellow.Top, 0.4)
			end),
			ZIndex = 1,
		})

		-- combo: fast clicks stack a counter above the button
		local comboPopGoal = scope:Value(1)
		local comboPop = scope:Spring(comboPopGoal, 32, 0.45)
		local comboToken = 0
		local function bumpCombo()
			comboToken += 1
			local token = comboToken
			combo:set(peek(combo) + 1)
			comboPop:setPosition(1.3)
			task.delay(0.9, function()
				if token == comboToken then
					combo:set(0)
				end
			end)
		end

		local function onClick()
			if c.Combo ~= false then
				bumpCombo()
			end
			raysFlash:set(1)
			task.defer(function()
				raysFlash:set(0)
			end)
			if clicker and clicker.Parent then
				Particles.ring(clicker, {
					Color = Color.lighten(Theme.get().Tone.Yellow.Top, 0.3),
					Size = plate * 1.15,
					From = 0.8,
					To = 1.35,
					Thickness = 5,
					Time = 0.3,
				})
				if peek(combo) % 3 == 1 then
					Particles.burst(clicker, { Shape = "sparkle", Count = 5, Size = 26, Speed = 380, Gravity = 200, Lifetime = 0.55 })
				end
			end
			if c.OnClick then
				c.OnClick()
			end
		end

		clicker = scope:Button {
			Name = "Clicker",
			Icon = c.Icon or "coin",
			IconColor = c.IconColor or Kit.themed(scope, function(theme)
				return theme.Tone.Yellow.Top
			end),
			RichIcon = true,
			IconSize = math.floor(plate * 0.62),
			Tone = c.Tone or "Green",
			Style = "Glossy3D",
			Radius = "Full",
			Depth = 8,
			Size = UDim2.fromOffset(plate, plate),
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5, 0),
			Shine = true,
			Hotkey = c.Hotkey,
			ClickFx = c.ClickFx or "Squash",
			OnClick = onClick,
			ZIndex = 3,
		}

		local comboColor = scope:Computed(function(use)
			local theme = use(Theme.current)
			local n = use(combo)
			if n >= 50 then
				return theme.Tone.Pink.Top
			elseif n >= 25 then
				return theme.Tone.Orange.Top
			elseif n >= 10 then
				return theme.Tone.Yellow.Top
			end
			return WHITE
		end)

		clickerArea = scope:New "Frame" {
			Name = "ClickerArea",
			AnchorPoint = Vector2.new(0.5, 1),
			Position = c.Position or UDim2.new(0.5, 0, 1, -28),
			Size = UDim2.fromOffset(plate + 120, plate + 70),
			BackgroundTransparency = 1,
			[Children] = {
				scope:New "UIScale" {
					Scale = scope:Computed(function(use)
						return 0.6 + 0.4 * use(intro)
					end),
				},
				rays,
				scope:New "Frame" {
					Name = "Plate",
					BackgroundTransparency = 1,
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.fromScale(0.5, 0),
					Size = UDim2.fromOffset(plate, plate),
					ZIndex = 2,
					[Children] = clicker,
				},
				scope:Text {
					Name = "Label",
					Text = c.Text or "CLICK!",
					TextSize = 46,
					Font = "Title",
					Stroke = 4,
					Rotation = -4,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 0, 0, plate + 2),
					ZIndex = 6,
				},
				scope:Text {
					Name = "Combo",
					Text = scope:Computed(function(use)
						return `x{use(combo)}`
					end),
					TextSize = 40,
					Font = "Title",
					Color = comboColor,
					Stroke = 4,
					-- up and to the right, outside the burst the coins fly out of
					Rotation = 12,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, plate * 0.5 + 64, 0, -14),
					Visible = scope:Computed(function(use)
						return use(combo) >= 3
					end),
					ZIndex = 7,
					[Children] = scope:New "UIScale" { Scale = comboPop },
				},
				scope:Text {
					Name = "Gain",
					Text = scope:Computed(function(use)
						return `+{Format.abbrev(use(gain))}`
					end),
					TextSize = 52,
					Font = "Title",
					Color = gainColor,
					StrokeColor = scope:Computed(function(use)
						return Kit.tintedStroke(use(Theme.current), use(gainColor))
					end),
					Stroke = 4,
					Transparency = gainAlpha,
					AnchorPoint = Vector2.new(0.5, 1),
					Position = scope:Computed(function(use)
						return UDim2.new(0.5, 0, 0, -6 + use(gainRise))
					end),
					Visible = scope:Computed(function(use)
						return use(gainAlpha) < 0.99 and use(gain) > 0
					end),
					ZIndex = 8,
					[Children] = scope:New "UIScale" { Scale = gainPop },
				},
				if c.Per
					then scope:Row {
						Name = "Per",
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.new(0.5, 0, 1, 0),
						Size = UDim2.fromOffset(0, 30),
						AutoSize = "X",
						Gap = 4,
						Align = "Center",
						ZIndex = 6,
						[Children] = {
							scope:Text {
								Text = c.Per,
								TextSize = 22,
								Font = "Heavy",
								Stroke = 2.5,
								LayoutOrder = 1,
								ZIndex = 6,
							},
						},
					}
					else nil,
			},
		}
		table.insert(scope, Motion.spin(rays, 16))
		if c.Pulse ~= false then
			table.insert(scope, Motion.pulse(clicker, 0.035, 1.4))
		end
	end

	--------------------------------------------------------------------
	-- Layout
	--------------------------------------------------------------------

	local columns = cfg.Columns or 2
	local tileCount = #leftTiles
	local gridWidth = scope:Computed(function(use)
		local perRow = if use(onTouch) then math.max(1, tileCount) else columns
		return perRow * tileSize + (perRow - 1) * 14
	end)
	local leftWidth = scope:Computed(function(use)
		return math.max(use(gridWidth), currencyWidth) + 16
	end)

	local gui = scope:Screen {
		Name = cfg.Name or "SimulatorHud",
		DisplayOrder = cfg.DisplayOrder or 5,
		[Children] = {
			scope:New "Frame" {
				Name = "Left",
				BackgroundTransparency = 1,
				Position = scope:Computed(function(use)
					return UDim2.fromOffset(EDGE - (1 - use(intro)) * 320, use(top))
				end),
				Size = scope:Computed(function(use)
					return UDim2.fromOffset(use(leftWidth), 0)
				end),
				AutomaticSize = Enum.AutomaticSize.Y,
				[Children] = {
					scope:New "UIListLayout" {
						SortOrder = Enum.SortOrder.LayoutOrder,
						Padding = UDim.new(0, 14),
						HorizontalAlignment = Enum.HorizontalAlignment.Left,
					},
					-- phones: a little smaller so the stack ends well above the thumbstick
					scope:New "UIScale" {
						Scale = scope:Computed(function(use)
							return if use(onTouch) then 0.9 else 1
						end),
					},
					scope:New "Frame" {
						Name = "Currencies",
						LayoutOrder = 1,
						BackgroundTransparency = 1,
						Size = UDim2.fromOffset(0, 0),
						AutomaticSize = Enum.AutomaticSize.XY,
						[Children] = {
							scope:New "UIPadding" { PaddingTop = UDim.new(0, 4), PaddingLeft = UDim.new(0, 12) },
							scope:New "UIListLayout" {
								SortOrder = Enum.SortOrder.LayoutOrder,
								Padding = UDim.new(0, 16),
							},
							currencyFrames,
						},
					},
					if levelBar
						then scope:New "Frame" {
							Name = "LevelRow",
							LayoutOrder = 2,
							BackgroundTransparency = 1,
							Size = UDim2.fromOffset(currencyWidth + 12, 48),
							[Children] = {
								scope:New "UIPadding" { PaddingLeft = UDim.new(0, 12) },
								levelBar,
							},
						}
						else nil,
					scope:New "Frame" {
						Name = "Spacer",
						LayoutOrder = 3,
						BackgroundTransparency = 1,
						Size = scope:Computed(function(use)
							return UDim2.fromOffset(1, if use(onTouch) then 0 else 12)
						end),
					},
					scope:New "Frame" {
						Name = "Menu",
						LayoutOrder = 4,
						BackgroundTransparency = 1,
						Size = scope:Computed(function(use)
							return UDim2.fromOffset(use(gridWidth), 0)
						end),
						AutomaticSize = Enum.AutomaticSize.Y,
						[Children] = {
							scope:New "UIGridLayout" {
								SortOrder = Enum.SortOrder.LayoutOrder,
								CellSize = UDim2.fromOffset(tileSize, tileSize + 14),
								CellPadding = UDim2.fromOffset(14, 16),
								FillDirectionMaxCells = scope:Computed(function(use)
									return if use(onTouch) then math.max(1, tileCount) else columns
								end),
								HorizontalAlignment = Enum.HorizontalAlignment.Left,
							},
							leftTiles,
						},
					},
				},
			},
			scope:Column {
				Name = "Boosts",
				AnchorPoint = Vector2.new(1, 0),
				Position = scope:Computed(function(use)
					return UDim2.new(1, -EDGE + (1 - use(intro)) * 300, 0, use(top) + 4)
				end),
				Size = UDim2.fromOffset(222, 0),
				AutoSize = "Y",
				Gap = 10,
				Align = "Right",
				[Children] = boostPills,
			},
			if #rightTiles > 0
				then scope:Column {
					Name = "Right",
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -EDGE, 0.5, 30),
					Size = UDim2.fromOffset(tileSize, 0),
					AutoSize = "Y",
					Gap = 16,
					Align = "Right",
					[Children] = rightTiles,
				}
				else nil,
			goalFrame,
			clickerArea,
		},
	}

	-- The click button owns the bottom centre, so toasts go up top (under
	-- the goal bar when there is one).
	if clickerArea then
		-- (toasts live in a "Core" safe-area screen, which already starts below the top bar)
		local below = peek(top) - peek(Kit.topbarHeight) / math.max(0.05, peek(Viewport.scale))
		Popups.configure({ Toasts = "Top", TopOffset = if goalFrame then below + 96 else below + 8 })
	end

	local hud = {
		Gui = gui,
		Currencies = currencies,
		CurrencyFrames = frames,
		Clicker = clicker,
		Combo = combo,
	}

	-- A point just above the click button (screen space), for "+N" text.
	local function aboveClicker(): any
		if clicker == nil or clicker.Parent == nil then
			return UDim2.fromScale(0.5, 0.6)
		end
		local position = clicker.AbsolutePosition
		local size = clicker.AbsoluteSize
		local lift = 18 * Layer.scale()
		return Vector2.new(position.X + size.X / 2, position.Y - lift)
	end

	-- Adds `amount` with coins flying from `from` (a Part, world position,
	-- GuiObject or screen point) into the counter. Numbers tick up as they land.
	function hud.give(id: string, amount: number, from: any?, options: { [string]: any }?)
		local value = currencies[id]
		local frame = frames[id]
		if value == nil or frame == nil then
			warn(`[BloxUI] SimulatorHud: no currency "{id}"`)
			return
		end
		local def = defs[id]
		local opts = options or {}
		local origin = from or clicker or UDim2.fromScale(0.5, 0.6)
		local count = math.clamp(math.ceil(amount / math.max(1, opts.PerCoin or amount / 8)), 1, opts.Max or 10)
		local share = amount / count
		local landed = 0
		local theme = Theme.get()
		local color = def.Color or theme.Tone.Yellow.Top
		local primary = cfg.Currencies and cfg.Currencies[1] and cfg.Currencies[1].Id == id
		if origin == clicker and clicker ~= nil and primary then
			addGain(amount, color)
		else
			local textAt = if origin == clicker then aboveClicker() else origin
			Particles.popText(textAt, `+{Format.abbrev(amount)}`, {
				Color = color,
				Stroke = Kit.tintedStroke(theme, color),
				Size = if amount >= 100 then 48 else 42,
				Rise = 80,
			})
		end
		Particles.flyTo(origin, frame, {
			Icon = def.Icon or "coin",
			Color = def.Color,
			Count = count,
			OnArrive = function()
				landed += 1
				if landed == count then
					value:set(peek(value) + (amount - share * (count - 1)))
				else
					value:set(peek(value) + share)
				end
			end,
		})
	end

	-- Takes `amount` if the player has enough. Otherwise shakes the counter,
	-- shows a warning and returns false.
	function hud.spend(id: string, amount: number): boolean
		local value = currencies[id]
		if value == nil then
			return false
		end
		if peek(value) < amount then
			Motion.shake(frames[id], 10, 0.4)
			Sound.play("Error")
			Popups.notify(`Not enough {id}!`, { Type = "error", Icon = defs[id] and defs[id].Icon })
			return false
		end
		value:set(peek(value) - amount)
		return true
	end

	function hud.addBoost(boost: { [string]: any })
		local list = table.clone(peek(boosts))
		table.insert(list, boost)
		boosts:set(list)
		Sound.play("Reward")
	end

	return hud
end
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="258">
            <Properties>
              <string name="Name">Trade</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prefabs.Trade: a two-sided trade window (Adopt Me / Pet Sim style).

	local trade = UI.Prefabs.Trade(scope, {
		Me = { name = "Builderman" },
		Them = { name = "Noob123" },
		Inventory = myItems,              -- array or state of { id, name, icon, rarity, count? }
		Currency = { Icon = "coin", Max = 50000 },   -- optional coins field (Max: number or state)
		MaxItems = 18,                    -- slots per side (6 x 3)
		Countdown = 3,                    -- seconds of "3, 2, 1" once both are ready
		ReadyDelay = 2,                   -- Ready is locked this long after THEY change their offer
		OnOfferChanged = function(items, coins) end,    -- send to the server
		OnReady = function(ready) end,                  -- you pressed Ready / Unready
		OnAccept = function(mine, theirs, coins) end,   -- countdown finished (coins = { Mine, Theirs })
		OnDecline = function() end,
		Blur = true,                      -- blur the world while open (skipped in edit-mode previews)
		Parent = nil,                     -- optional: where the ScreenGui goes (default PlayerGui)
	})

	trade.open()   trade.close()   trade.Open (Value)   trade.Gui
	trade.setTheirOffer(items)   trade.setTheirReady(true)   trade.setTheirCoins(250)
	trade.addItem(id)   trade.removeItem(id)   trade.setCoins(n)   trade.setReady(bool)
	trade.MyOffer   trade.TheirOffer   trade.MyReady   trade.TheirReady   trade.Phase   (states)

	Rules the window enforces so trades are hard to misread:
	  * any change to either offer (or coins) un-readies both players
	  * both ready -> a big 3-2-1; any change during it cancels it
	  * after they change their offer, your Ready button is locked for
	    ReadyDelay seconds and their new items are tagged NEW
]]

local Players = game:GetService("Players")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Theme = require(script.Parent.Parent.Theme)
local Kit = require(script.Parent.Parent.Kit)
local Color = require(script.Parent.Parent.Util.Color)
local Format = require(script.Parent.Parent.Util.Format)
local Sound = require(script.Parent.Parent.Util.Sound)
local Viewport = require(script.Parent.Parent.Util.Viewport)
local Gamepad = require(script.Parent.Parent.Util.Gamepad)
local Motion = require(script.Parent.Parent.FX.Motion)
local Particles = require(script.Parent.Parent.FX.Particles)
local Components = script.Parent.Parent.Components
local Screen = require(Components.Screen)
local Window = require(Components.Window)
local Text = require(Components.Text)
local Icon = require(Components.Icon)
local Button = require(Components.Button)
local ItemSlot = require(Components.ItemSlot)
local tooltipModule = Components:FindFirstChild("Tooltip")
local Tooltip = if tooltipModule then require(tooltipModule) :: any else nil

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

type Scope = Fusion.Scope<any>

local WHITE = Color3.new(1, 1, 1)

-- content canvas (design px); scaled down on short screens
local CONTENT_W, CONTENT_H = 1272, 760
local WINDOW = Vector2.new(1304, 880)
local PADDING = 16
local PANEL_W, PANEL_H = 562, 372
local MID_W = CONTENT_W - PANEL_W * 2
local COLUMNS = 6
local CELL, CELL_GAP = 76, 10
local HEADER = 60
local STATUS_Y, STATUS_H = 384, 46
local DRAWER_Y, DRAWER_H = 442, 240
local BAR_Y, BAR_H = 696, 64

local function field(item: { [string]: any }, key: string): any
	local value = item[key]
	if value == nil then
		value = item[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
	end
	return value
end

local function normalize(item: { [string]: any }, index: number): { [string]: any }
	return {
		id = field(item, "id") or index,
		name = field(item, "name") or "Item",
		icon = field(item, "icon") or "gift",
		rarity = field(item, "rarity") or "Common",
		count = math.max(1, field(item, "count") or 1),
	}
end

local function normalizeList(list: { any }?): { { [string]: any } }
	local out = {}
	for index, item in list or {} do
		table.insert(out, normalize(item, index))
	end
	return out
end

local function total(items: { { [string]: any } }): number
	local n = 0
	for _, item in items do
		n += item.count or 1
	end
	return n
end

return function(scope: any, config: { [string]: any })
	local me = config.Me or {}
	local them = config.Them or {}
	local myName = tostring(field(me, "name") or "You")
	local theirName = tostring(field(them, "name") or "Player")
	local maxItems = config.MaxItems or COLUMNS * 3
	local countdownFrom = config.Countdown or 3
	local readyDelay = config.ReadyDelay or 2
	local currency = config.Currency
	local rows = math.ceil(maxItems / COLUMNS)

	local alive = true
	table.insert(scope, function()
		alive = false
	end)

	------------------------------------------------------------------
	-- State
	------------------------------------------------------------------

	local open = scope:Value(config.Open == true)
	local inventory = scope:Computed(function(use)
		return normalizeList(use(config.Inventory))
	end)
	local myOffer = scope:Value({} :: { { id: any, count: number } })
	local theirOffer = scope:Value({} :: { { [string]: any } })
	local myCoins = scope:Value(0)
	local theirCoins = scope:Value(0)
	local myReady = scope:Value(false)
	local theirReady = scope:Value(false)
	local phase = scope:Value("edit") -- edit | countdown | done | declined
	local countdown = scope:Value(countdownFrom)
	local lockLeft = scope:Value(0) -- seconds Ready stays locked after their change
	local notice = scope:Value("") -- transient warning line
	local fresh = scope:Value({} :: { [any]: boolean }) -- their newly added item ids
	local theirFlash = scope:Value(false)
	local warnPulse = scope:Value(0) -- 0..1: the warning wash over their offer
	local theirPanel: Frame? = nil
	local warnToken = 0

	-- They changed their offer: their side flashes the warning colour three
	-- times and shudders, so nobody accepts a swapped trade by accident.
	local function flashWarning()
		warnToken += 1
		local mine = warnToken
		if theirPanel then
			Motion.shake(theirPanel, 7, 0.35)
		end
		task.spawn(function()
			local start = os.clock()
			while alive and warnToken == mine do
				local t = (os.clock() - start) / 1.1
				if t >= 1 then
					break
				end
				-- three pulses that fade out
				warnPulse:set(math.abs(math.sin(t * math.pi * 3)) * (1 - t * 0.6))
				task.wait()
			end
			if alive and warnToken == mine then
				warnPulse:set(0)
			end
		end)
	end

	local token = 0 -- bumps cancel the running countdown
	local lockToken = 0
	local noticeToken = 0

	local function inventoryItem(id: any): { [string]: any }?
		for _, item in peek(inventory) do
			if item.id == id then
				return item
			end
		end
		return nil
	end

	-- my offer joined with the inventory: full item tables, count = offered
	local myItems = scope:Computed(function(use)
		local byId = {}
		for _, item in use(inventory) do
			byId[item.id] = item
		end
		local out = {}
		for _, entry in use(myOffer) do
			local item = byId[entry.id]
			if item then
				local copy = table.clone(item)
				copy.count = math.min(entry.count, item.count)
				table.insert(out, copy)
			end
		end
		return out
	end)

	-- inventory minus what's offered; tables are cached so the grid only
	-- rebuilds the slots that actually changed
	local availableCache: { [any]: { [number]: { [string]: any } } } = {}
	local available = scope:Computed(function(use)
		local offered = {}
		for _, entry in use(myOffer) do
			offered[entry.id] = entry.count
		end
		local out = {}
		for _, item in use(inventory) do
			local left = item.count - (offered[item.id] or 0)
			if left > 0 then
				local byCount = availableCache[item.id]
				if byCount == nil then
					byCount = {}
					availableCache[item.id] = byCount
				end
				local cached = byCount[left]
				if cached == nil or cached.icon ~= item.icon or cached.rarity ~= item.rarity or cached.name ~= item.name then
					cached = table.clone(item)
					cached.count = left
					byCount[left] = cached
				end
				table.insert(out, cached)
			end
		end
		return out
	end)

	local myCount = scope:Computed(function(use)
		return total(use(myItems))
	end)
	local theirCount = scope:Computed(function(use)
		return total(use(theirOffer))
	end)
	local isEmpty = scope:Computed(function(use)
		return use(myCount) == 0 and use(theirCount) == 0 and use(myCoins) == 0 and use(theirCoins) == 0
	end)

	------------------------------------------------------------------
	-- Rules
	------------------------------------------------------------------

	local function showNotice(text: string)
		noticeToken += 1
		local mine = noticeToken
		notice:set(text)
		task.delay(2.6, function()
			if alive and noticeToken == mine then
				notice:set("")
			end
		end)
	end

	local function cancelCountdown(reason: string?)
		if peek(phase) == "countdown" then
			token += 1
			phase:set("edit")
			countdown:set(countdownFrom)
			Sound.play("Error")
			showNotice(reason or "Trade cancelled - ready up again.")
		end
	end

	local function unreadyBoth(reason: string)
		local wasReady = peek(myReady) or peek(theirReady)
		if peek(phase) == "countdown" then
			cancelCountdown(reason)
		elseif wasReady then
			showNotice(reason)
		end
		myReady:set(false)
		theirReady:set(false)
	end

	local function editable(): boolean
		local p = peek(phase)
		return alive and (p == "edit" or p == "countdown")
	end

	local function myOfferList(): { { [string]: any } }
		return table.clone(peek(myItems))
	end

	local function myChanged()
		unreadyBoth("You changed your offer - both players must ready up again.")
		if config.OnOfferChanged then
			config.OnOfferChanged(myOfferList(), peek(myCoins))
		end
	end

	local function lockReady()
		if readyDelay <= 0 then
			return
		end
		lockToken += 1
		local mine = lockToken
		task.spawn(function()
			for left = math.ceil(readyDelay), 1, -1 do
				if not alive or lockToken ~= mine then
					return
				end
				lockLeft:set(left)
				task.wait(1)
			end
			if alive and lockToken == mine then
				lockLeft:set(0)
			end
		end)
	end

	local complete: () -> ()
	local tickPulse: () -> () = function() end -- set once the middle circle exists

	local function startCountdown()
		phase:set("countdown")
		token += 1
		local mine = token
		task.spawn(function()
			for n = countdownFrom, 1, -1 do
				if not alive or token ~= mine then
					return
				end
				countdown:set(n)
				Sound.play("Tick", { Pitch = 1 + (countdownFrom - n) * 0.12 })
				tickPulse()
				task.wait(1)
			end
			if alive and token == mine and peek(phase) == "countdown" then
				complete()
			end
		end)
	end

	local function checkBoth()
		if peek(myReady) and peek(theirReady) and peek(phase) == "edit" then
			startCountdown()
		end
	end

	local function addItem(id: any): boolean
		if not editable() then
			return false
		end
		local item = inventoryItem(id)
		if item == nil then
			return false
		end
		local list = table.clone(peek(myOffer))
		for index, entry in list do
			if entry.id == id then
				if entry.count >= item.count then
					Sound.play("Error")
					return false
				end
				list[index] = { id = id, count = entry.count + 1 }
				myOffer:set(list)
				myChanged()
				return true
			end
		end
		if #list >= maxItems then
			Sound.play("Error")
			showNotice(`Your offer is full ({maxItems} items max).`)
			return false
		end
		table.insert(list, { id = id, count = 1 })
		myOffer:set(list)
		Sound.play("Click", { Pitch = 1.15 })
		myChanged()
		return true
	end

	local function removeItem(id: any, all: boolean?): boolean
		if not editable() then
			return false
		end
		local list = table.clone(peek(myOffer))
		for index, entry in list do
			if entry.id == id then
				if entry.count > 1 and not all then
					list[index] = { id = id, count = entry.count - 1 }
				else
					table.remove(list, index)
				end
				myOffer:set(list)
				Sound.play("Click", { Pitch = 0.85 })
				myChanged()
				return true
			end
		end
		return false
	end

	local function maxCoins(): number
		return peek(if currency then currency.Max else 0) or math.huge
	end

	local function setCoins(amount: number): boolean
		if not editable() or currency == nil then
			return false
		end
		local value = math.clamp(math.floor(tonumber(amount) or 0), 0, maxCoins())
		if value == peek(myCoins) then
			return false
		end
		myCoins:set(value)
		myChanged()
		return true
	end

	local function setReady(ready: boolean): boolean
		if not editable() then
			return false
		end
		if ready then
			if peek(lockLeft) > 0 or peek(isEmpty) or peek(myReady) then
				return false
			end
			myReady:set(true)
			Sound.play("Click", { Pitch = 1.2 })
			if config.OnReady then
				config.OnReady(true)
			end
			checkBoth()
		else
			if not peek(myReady) then
				return false
			end
			myReady:set(false)
			cancelCountdown("You cancelled - press Ready when you're sure.")
			if config.OnReady then
				config.OnReady(false)
			end
		end
		return true
	end

	local function setTheirOffer(items: { any })
		if not alive then
			return
		end
		local list = normalizeList(items)
		local before = {}
		for _, item in peek(theirOffer) do
			before[item.id] = item.count
		end
		local newIds = {}
		local changed = #list ~= #peek(theirOffer)
		for _, item in list do
			if before[item.id] == nil or before[item.id] < item.count then
				newIds[item.id] = true
				changed = true
			elseif before[item.id] ~= item.count then
				changed = true
			end
		end
		if not changed then
			return
		end
		theirOffer:set(list)
		if peek(phase) == "done" or peek(phase) == "declined" then
			return
		end
		fresh:set(newIds)
		theirFlash:set(true)
		flashWarning()
		task.delay(3, function()
			if alive then
				fresh:set({})
				theirFlash:set(false)
			end
		end)
		unreadyBoth(`{theirName} changed their offer - check it carefully!`)
		lockReady()
	end

	local function setTheirCoins(amount: number)
		if not alive then
			return
		end
		local value = math.max(0, math.floor(tonumber(amount) or 0))
		if value == peek(theirCoins) then
			return
		end
		theirCoins:set(value)
		if peek(phase) == "edit" or peek(phase) == "countdown" then
			theirFlash:set(true)
			flashWarning()
			task.delay(3, function()
				if alive then
					theirFlash:set(false)
				end
			end)
			unreadyBoth(`{theirName} changed their coins - check it carefully!`)
			lockReady()
		end
	end

	local function setTheirReady(ready: boolean)
		if not alive or not editable() then
			return
		end
		if ready == peek(theirReady) then
			return
		end
		theirReady:set(ready)
		if ready then
			Sound.play("Notify")
			checkBoth()
		else
			cancelCountdown(`{theirName} is no longer ready.`)
		end
	end

	local function close()
		open:set(false)
	end

	local function decline()
		if not alive or peek(phase) == "done" then
			close()
			return
		end
		token += 1
		phase:set("declined")
		myReady:set(false)
		close()
		if config.OnDecline then
			config.OnDecline()
		end
	end

	local function reset()
		token += 1
		lockToken += 1
		myOffer:set({})
		theirOffer:set({})
		myCoins:set(0)
		theirCoins:set(0)
		myReady:set(false)
		theirReady:set(false)
		lockLeft:set(0)
		notice:set("")
		fresh:set({})
		phase:set("edit")
		countdown:set(countdownFrom)
	end

	------------------------------------------------------------------
	-- Pieces
	------------------------------------------------------------------

	local middleCircle: Frame? = nil

	local sideTone = {
		me = config.MyTone or "Blue",
		them = config.TheirTone or "Pink",
	}

	-- one slot of an offer grid: a ghost square with an ItemSlot on top
	local function offerCell(side: string, index: number, items: any): Frame
		local entry = scope:Computed(function(use)
			return use(items)[index] or false
		end)
		local filled = scope:Computed(function(use)
			return use(entry) ~= false
		end)
		local entryId = scope:Computed(function(use)
			local e = use(entry)
			return if e then e.id else false
		end)
		local popGoal = scope:Value(1)
		local pop = scope:Spring(popGoal, 30, 0.45)
		Kit.watch(scope, entryId, function(id)
			if id ~= false then
				pop:setPosition(0.55)
				pop:addVelocity(4)
			end
		end)
		local isFresh = scope:Computed(function(use)
			local id = use(entryId)
			return side == "them" and id ~= false and use(fresh)[id] == true
		end)
		local hovering = scope:Value(false)

		local slot = ItemSlot(scope, {
			Icon = scope:Computed(function(use)
				local e = use(entry)
				return if e then e.icon else "gift"
			end),
			Rarity = scope:Computed(function(use)
				local e = use(entry)
				return if e then e.rarity else "Common"
			end),
			Count = scope:Computed(function(use)
				local e = use(entry)
				return if e then e.count else 0
			end),
			Size = CELL,
			Visible = filled,
			ZIndex = 3,
			OnClick = if side == "me"
				then function()
					local e = peek(entry)
					if e then
						removeItem(e.id)
					end
				end
				else nil,
			[Children] = {
				if side == "me"
					then scope:New "Frame" {
						Name = "Remove",
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.new(1, -6, 0, 6),
						Size = UDim2.fromOffset(26, 26),
						BackgroundColor3 = Kit.themed(scope, function(theme)
							return theme.Tone.Red.Bottom
						end),
						Visible = hovering,
						ZIndex = 20,
						[Children] = {
							Kit.corner(scope, "Full"),
							Kit.stroke(scope, {
								Thickness = 2,
								Color = Kit.themed(scope, function(theme)
									return theme.Color.Light
								end),
							}),
							Icon(scope, {
								Icon = "minus",
								Size = 18,
								AnchorPoint = Vector2.new(0.5, 0.5),
								Position = UDim2.fromScale(0.5, 0.5),
								Color = WHITE,
								Stroke = false,
								ZIndex = 21,
							}),
						},
					}
					else nil,
				scope:New "Frame" {
					Name = "New",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 0, 1, -4),
					Size = UDim2.fromOffset(44, 20),
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Tone.Yellow.Bottom
					end),
					Visible = isFresh,
					ZIndex = 20,
					[Children] = {
						Kit.corner(scope, "Full"),
						Kit.stroke(scope, {
							Thickness = 2,
							Color = Kit.themed(scope, function(theme)
								return theme.Tone.Yellow.Edge
							end),
						}),
						Text(scope, {
							Text = "NEW",
							TextSize = 14,
							Font = "Heavy",
							Size = UDim2.fromScale(1, 1),
							Stroke = Kit.themed(scope, function(theme)
								return if theme.Tone.Yellow.Stroke then 1.5 else false
							end),
							StrokeColor = Kit.themed(scope, function(theme)
								local t = theme.Tone.Yellow
								return t.Stroke or t.Edge
							end),
							Color = Kit.themed(scope, function(theme)
								return theme.Tone.Yellow.Text
							end),
							ZIndex = 21,
						}),
					},
				},
			},
		})
		slot.Name = "OfferItem"
		if Tooltip then
			Tooltip.attach(scope, slot, scope:Computed(function(use)
				local e = use(entry)
				if not e then
					return ""
				end
				local label = `{e.name}  ·  {e.rarity}`
				if e.count > 1 then
					label ..= `  ·  x{e.count}`
				end
				return label
			end))
		end
		if side == "me" then
			table.insert(scope, slot.MouseEnter:Connect(function()
				hovering:set(true)
			end))
			table.insert(scope, slot.MouseLeave:Connect(function()
				hovering:set(false)
			end))
			table.insert(scope, slot.SelectionGained:Connect(function()
				hovering:set(true)
			end))
			table.insert(scope, slot.SelectionLost:Connect(function()
				hovering:set(false)
			end))
		end

		return scope:New "Frame" {
			Name = `Cell{index}`,
			LayoutOrder = index,
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = 0.45,
			ZIndex = 2,
			[Children] = {
				scope:New "UIScale" { Scale = pop },
				Kit.corner(scope, "M"),
				Kit.stroke(scope, {
					Thickness = 1.5,
					Transparency = 0.75,
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Glass or theme.Color.Light
					end),
				}),
				slot,
			},
		}
	end

	local function readyChip(ready: any): Frame
		local popGoal = scope:Value(1)
		local pop = scope:Spring(popGoal, 28, 0.4)
		Kit.watch(scope, ready, function()
			pop:setPosition(0.7)
			pop:addVelocity(4)
		end)
		return scope:New "Frame" {
			Name = "ReadyChip",
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -14, 0.5, 0),
			Size = UDim2.fromOffset(0, 38),
			AutomaticSize = Enum.AutomaticSize.X,
			BackgroundColor3 = WHITE,
			ZIndex = 5,
			[Children] = {
				scope:New "UIScale" { Scale = pop },
				Kit.corner(scope, "Full"),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						local t = if use(ready) then theme.Tone.Green else theme.Tone.Dark
						return ColorSequence.new(t.Top, t.Bottom)
					end),
				}),
				Kit.stroke(scope, {
					Thickness = 2.5,
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						return if use(ready) then theme.Tone.Green.Edge else theme.Color.Outline
					end),
				}),
				Kit.padding(scope, { 0, 14, 0, 10 }),
				Kit.list(scope, { Direction = "X", Padding = 6, VAlign = "Center" }),
				Icon(scope, {
					Icon = scope:Computed(function(use)
						return if use(ready) then "check" else "timer"
					end),
					Size = 22,
					LayoutOrder = 1,
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						return if use(ready) then theme.Tone.Green.Text else theme.Color.TextMuted
					end),
					Stroke = false,
					ZIndex = 6,
				}),
				Text(scope, {
					Name = "Label",
					Text = scope:Computed(function(use)
						return if use(ready) then "READY" else "NOT READY"
					end),
					TextSize = 18,
					Font = "Heavy",
					LayoutOrder = 2,
					Color = scope:Computed(function(use)
						local theme = use(Theme.current)
						return if use(ready) then theme.Tone.Green.Text else theme.Color.TextMuted
					end),
					StrokeColor = Kit.themed(scope, function(theme)
						local t = theme.Tone.Green
						return t.Stroke or t.Edge
					end),
					Stroke = scope:Computed(function(use)
						return if use(ready) then 1.5 else false
					end),
					ZIndex = 6,
				}),
			},
		}
	end

	local function coinPill(amount: any): Frame?
		if currency == nil then
			return nil
		end
		return scope:New "Frame" {
			Name = "Coins",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, 16, 0.5, 0),
			Size = UDim2.fromOffset(0, 36),
			AutomaticSize = Enum.AutomaticSize.X,
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = 0.3,
			ZIndex = 4,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.padding(scope, { 0, 14, 0, 6 }),
				Kit.list(scope, { Direction = "X", Padding = 6, VAlign = "Center" }),
				Icon(scope, {
					Icon = currency.Icon or "coin",
					Size = 28,
					LayoutOrder = 1,
					Color = currency.Color or Kit.themed(scope, function(theme)
						return theme.Tone.Yellow.Top
					end),
					Gradient = true,
					ZIndex = 5,
				}),
				Text(scope, {
					Name = "CoinAmount",
					Text = scope:Computed(function(use)
						return Format.comma(use(amount))
					end),
					TextSize = 22,
					Font = "Display",
					LayoutOrder = 2,
					ZIndex = 5,
				}),
			},
		}
	end

	local function offerPanel(side: string): Frame
		local isMe = side == "me"
		local tone = Kit.tone(scope, sideTone[side], if isMe then "Blue" else "Pink")
		local items = if isMe then myItems else theirOffer
		local ready = if isMe then myReady else theirReady
		local itemCount = if isMe then myCount else theirCount
		local coins = if isMe then myCoins else theirCoins
		local empty = scope:Computed(function(use)
			return #use(items) == 0
		end)

		local cells = {}
		for index = 1, maxItems do
			table.insert(cells, offerCell(side, index, items))
		end

		local gridW = COLUMNS * CELL + (COLUMNS - 1) * CELL_GAP
		local gridH = rows * CELL + (rows - 1) * CELL_GAP

		local strokeColor = scope:Computed(function(use)
			local theme = use(Theme.current)
			if use(phase) == "done" or use(ready) then
				return theme.Color.Good
			elseif not isMe and use(theirFlash) then
				return theme.Color.Warn
			end
			return use(tone).Edge:Lerp(theme.Color.Outline, 0.4)
		end)

		local panel = scope:New "Frame" {
			Name = if isMe then "YourOffer" else "TheirOffer",
			Position = UDim2.fromOffset(if isMe then 0 else PANEL_W + MID_W, 0),
			Size = UDim2.fromOffset(PANEL_W, PANEL_H),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.SurfaceLight
			end),
			ZIndex = 2,
			[Children] = {
				Kit.corner(scope, "L"),
				-- their side washes with the warning colour when they change it
				if isMe
					then nil
					else scope:New "Frame" {
						Name = "ChangeFlash",
						Size = UDim2.fromScale(1, 1),
						BackgroundColor3 = Kit.themed(scope, function(theme)
							return theme.Color.Warn
						end),
						BackgroundTransparency = scope:Computed(function(use)
							return 1 - 0.6 * math.clamp(use(warnPulse), 0, 1)
						end),
						Visible = scope:Computed(function(use)
							return use(warnPulse) > 0.01
						end),
						-- under the item grid (same ZIndex, added first): the panel
						-- flashes, the items and their NEW tags stay readable
						ZIndex = 2,
						[Children] = Kit.corner(scope, "L"),
					},
				Kit.stroke(scope, {
					Color = strokeColor,
					Thickness = scope:Computed(function(use)
						return if use(ready) or (not isMe and use(theirFlash)) then 4 else 2.5
					end),
				}),
				Kit.gradient(scope, {
					Rotation = 90,
					Color = ColorSequence.new(WHITE, Color3.fromRGB(175, 175, 190)),
				}),
				-- header band in the side's colour
				scope:New "Frame" {
					Name = "Header",
					Size = UDim2.new(1, 0, 0, HEADER),
					BackgroundColor3 = WHITE,
					ZIndex = 3,
					[Children] = {
						Kit.corner(scope, "L"),
						Kit.gradient(scope, {
							Rotation = 90,
							Color = scope:Computed(function(use)
								local t = use(tone)
								return ColorSequence.new(Color.lighten(t.Top, 0.05), t.Bottom)
							end),
						}),
						scope:New "Frame" {
							Name = "Square",
							AnchorPoint = Vector2.new(0, 1),
							Position = UDim2.fromScale(0, 1),
							Size = UDim2.new(1, 0, 0, 16),
							BorderSizePixel = 0,
							BackgroundColor3 = scope:Computed(function(use)
								return use(tone).Bottom
							end),
							ZIndex = 3,
						},
						scope:New "Frame" {
							Name = "Edge",
							AnchorPoint = Vector2.new(0, 1),
							Position = UDim2.fromScale(0, 1),
							Size = UDim2.new(1, 0, 0, 3),
							BorderSizePixel = 0,
							BackgroundColor3 = scope:Computed(function(use)
								return use(tone).Edge
							end),
							ZIndex = 4,
						},
						scope:New "Frame" {
							Name = "Avatar",
							AnchorPoint = Vector2.new(0, 0.5),
							Position = UDim2.new(0, 12, 0.5, -1),
							Size = UDim2.fromOffset(42, 42),
							BackgroundColor3 = Kit.themed(scope, function(theme)
								return theme.Color.Light
							end),
							ZIndex = 5,
							[Children] = {
								Kit.corner(scope, "Full"),
								Kit.stroke(scope, {
									Thickness = 2,
									Color = scope:Computed(function(use)
										return use(tone).Edge
									end),
								}),
								Icon(scope, {
									Icon = "person",
									Size = 28,
									AnchorPoint = Vector2.new(0.5, 0.5),
									Position = UDim2.fromScale(0.5, 0.5),
									Color = scope:Computed(function(use)
										return use(tone).Bottom
									end),
									Stroke = false,
									ZIndex = 6,
								}),
							},
						},
						Text(scope, {
							Name = "Who",
							Text = if isMe then "YOU GIVE" else "YOU GET",
							TextSize = 24,
							Font = "Title",
							XAlign = "Left",
							Position = UDim2.fromOffset(64, 7),
							Size = UDim2.fromOffset(PANEL_W - 64 - 170, 28),
							Scaled = true,
							MaxTextSize = 24,
							StrokeColor = scope:Computed(function(use)
								local t = use(tone)
								return t.Stroke or t.Edge
							end),
							ZIndex = 5,
						}),
						Text(scope, {
							Name = "Name",
							Text = if isMe then `{myName} (you)` else `from {theirName}`,
							TextSize = 17,
							Font = "Body",
							XAlign = "Left",
							Position = UDim2.fromOffset(64, 34),
							Size = UDim2.fromOffset(PANEL_W - 64 - 170, 20),
							Truncate = true,
							Stroke = false,
							Color = scope:Computed(function(use)
								return Color.lighten(use(tone).Top, 0.6)
							end),
							ZIndex = 5,
						}),
						readyChip(ready),
					},
				},
				scope:New "Frame" {
					Name = "Grid",
					BackgroundTransparency = 1,
					AnchorPoint = Vector2.new(0.5, 0),
					Position = UDim2.new(0.5, 0, 0, HEADER + 14),
					Size = UDim2.fromOffset(gridW, gridH),
					ZIndex = 2,
					[Children] = {
						scope:New "UIGridLayout" {
							CellSize = UDim2.fromOffset(CELL, CELL),
							CellPadding = UDim2.fromOffset(CELL_GAP, CELL_GAP),
							FillDirection = Enum.FillDirection.Horizontal,
							FillDirectionMaxCells = COLUMNS,
							SortOrder = Enum.SortOrder.LayoutOrder,
							HorizontalAlignment = Enum.HorizontalAlignment.Left,
							VerticalAlignment = Enum.VerticalAlignment.Top,
						},
						cells,
					},
				},
				Text(scope, {
					Name = "EmptyHint",
					Text = if isMe then "Click your items below to add them" else `Waiting for {theirName} to add items…`,
					TextSize = 20,
					Font = "Heavy",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(0.5, 0, 0, HEADER + 14 + gridH / 2),
					Visible = empty,
					Stroke = 2,
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					ZIndex = 4,
				}),
				scope:New "Frame" {
					Name = "Footer",
					BackgroundTransparency = 1,
					AnchorPoint = Vector2.new(0, 1),
					Position = UDim2.fromScale(0, 1),
					Size = UDim2.new(1, 0, 0, 52),
					ZIndex = 3,
					[Children] = {
						coinPill(coins),
						Text(scope, {
							Name = "Summary",
							Text = scope:Computed(function(use)
								local n = use(itemCount)
								return if n == 1 then "1 item" else `{n} items`
							end),
							TextSize = 19,
							Font = "Heavy",
							Stroke = false,
							AnchorPoint = Vector2.new(1, 0.5),
							Position = UDim2.new(1, -18, 0.5, 0),
							Color = Kit.themed(scope, function(theme)
								return theme.Color.TextMuted
							end),
							ZIndex = 4,
						}),
					},
				},
			},
		}
		if not isMe then
			theirPanel = panel
		end
		return panel
	end

	-- centre column: swap icon, then the 3-2-1, then a big tick
	local numberPopGoal = scope:Value(1)
	local numberPop = scope:Spring(numberPopGoal, 26, 0.35)
	Kit.watch(scope, countdown, function()
		if peek(phase) == "countdown" then
			numberPop:setPosition(1.6)
		end
	end)
	Kit.watch(scope, phase, function(now)
		if now == "countdown" or now == "done" then
			numberPop:setPosition(1.5)
		end
	end)

	local middleTone = scope:Computed(function(use)
		local theme = use(Theme.current)
		local p = use(phase)
		if p == "countdown" or p == "done" then
			return theme.Tone.Green
		end
		return theme.Tone.Dark
	end)

	-- success poof ring (animated by complete())
	local poofScale = scope:New "UIScale" { Scale = 0.4 }
	local poofStroke = scope:New "UIStroke" {
		Color = WHITE,
		Thickness = 6,
		Transparency = 1,
	}
	local poofRing = scope:New "Frame" {
		Name = "PoofRing",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.new(0.5, 0, 0.5, -14),
		Size = UDim2.fromOffset(150, 150),
		BackgroundTransparency = 1,
		ZIndex = 6,
		[Children] = {
			poofScale,
			poofStroke,
			scope:New "UICorner" { CornerRadius = UDim.new(0.5, 0) },
		},
	}

	local middle = scope:New "Frame" {
		Name = "Middle",
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(PANEL_W, 0),
		Size = UDim2.fromOffset(MID_W, PANEL_H),
		ZIndex = 3,
		[Children] = {
			poofRing,
			scope:New "Frame" {
				Name = "Circle",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.new(0.5, 0, 0.5, -14),
				Size = UDim2.fromOffset(118, 118),
				BackgroundColor3 = WHITE,
				ZIndex = 4,
				[Children] = {
					scope:New "UIScale" { Scale = numberPop },
					Kit.corner(scope, "Full"),
					Kit.gradient(scope, {
						Rotation = 90,
						Color = scope:Computed(function(use)
							local t = use(middleTone)
							return ColorSequence.new(Color.lighten(t.Top, 0.08), t.Bottom)
						end),
					}),
					Kit.stroke(scope, {
						Thickness = 4,
						Color = scope:Computed(function(use)
							local theme = use(Theme.current)
							local p = use(phase)
							return if p == "countdown" or p == "done" then theme.Color.Light else use(middleTone).Edge
						end),
					}),
					Icon(scope, {
						Name = "Swap",
						Icon = "two-arrows-left-right",
						Size = 60,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = Kit.themed(scope, function(theme)
							return theme.Color.Accent
						end),
						Gradient = true,
						Shadow = true,
						Visible = scope:Computed(function(use)
							local p = use(phase)
							return p == "edit" or p == "declined"
						end),
						ZIndex = 5,
					}),
					Text(scope, {
						Name = "Number",
						Text = scope:Computed(function(use)
							return tostring(use(countdown))
						end),
						TextSize = 76,
						Font = "Display",
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Stroke = 4,
						StrokeColor = Kit.themed(scope, function(theme)
							local t = theme.Tone.Green
							return t.Stroke or t.Edge
						end),
						Visible = scope:Computed(function(use)
							return use(phase) == "countdown"
						end),
						ZIndex = 5,
					}),
					Icon(scope, {
						Name = "Done",
						Icon = "check",
						Size = 70,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = WHITE,
						StrokeColor = Kit.themed(scope, function(theme)
							local t = theme.Tone.Green
							return t.Stroke or t.Edge
						end),
						Stroke = 4,
						Visible = scope:Computed(function(use)
							return use(phase) == "done"
						end),
						ZIndex = 5,
					}),
				},
			},
			Text(scope, {
				Name = "MiddleLabel",
				Text = scope:Computed(function(use)
					local p = use(phase)
					if p == "countdown" then
						return "CONFIRMING"
					elseif p == "done" then
						return "DONE!"
					end
					return "TRADE"
				end),
				TextSize = 20,
				Font = "Heavy",
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.new(0.5, 0, 0.5, 58),
				Color = scope:Computed(function(use)
					local theme = use(Theme.current)
					local p = use(phase)
					return if p == "countdown" or p == "done" then theme.Color.Good else theme.Color.TextMuted
				end),
				ZIndex = 4,
			}),
		},
	}
	middleCircle = middle:FindFirstChild("Circle") :: Frame?

	-- each second of the confirm countdown sends a soft green ring out
	tickPulse = function()
		poofScale.Scale = 0.85
		poofStroke.Color = peek(Theme.current).Color.Good
		poofStroke.Transparency = 0.35
		Motion.tween(poofScale, { Scale = 1.35 }, 0.45, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
		Motion.tween(poofStroke, { Transparency = 1 }, 0.45, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
	end

	-- status line: one sentence that always says what happens next
	local status = scope:Computed(function(use)
		local p = use(phase)
		local mineReady, theirsReady = use(myReady), use(theirReady)
		if p == "done" then
			return { "Trade complete! Enjoy your new items.", "Good", "success" }
		elseif p == "declined" then
			return { "Trade declined.", "Bad", "error" }
		elseif p == "countdown" then
			return { `Trade confirming in {use(countdown)}…  any change cancels it.`, "Good", "timer" }
		end
		local message = use(notice)
		if message ~= "" then
			return { message, "Warn", "warning" }
		end
		local locked = use(lockLeft)
		if locked > 0 then
			return { `{theirName} changed the offer - check it carefully! ({locked})`, "Warn", "warning" }
		elseif use(isEmpty) then
			return { "Add items from your inventory to start trading.", "Info", "info" }
		elseif mineReady and not theirsReady then
			return { `Waiting for {theirName} to ready up…`, "Info", "timer" }
		elseif theirsReady and not mineReady then
			return { `{theirName} is ready! Press Ready to confirm.`, "Good", "success" }
		end
		return { "Check both offers carefully, then press Ready.", "Info", "info" }
	end)
	local statusColor = scope:Computed(function(use)
		return use(Theme.current).Color[use(status)[2]] or use(Theme.current).Color.Info
	end)

	local statusLine = scope:New "Frame" {
		Name = "Status",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, STATUS_Y),
		Size = UDim2.fromOffset(900, STATUS_H),
		-- dark backing so the line reads on light (Bubbly) and dark windows alike
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Surface
		end),
		BackgroundTransparency = 0.08,
		ZIndex = 3,
		[Children] = {
			Kit.corner(scope, "Full"),
			Kit.stroke(scope, { Color = statusColor, Thickness = 2, Transparency = 0.15 }),
			scope:New "Frame" {
				Name = "Tint",
				BackgroundColor3 = statusColor,
				BackgroundTransparency = 0.86,
				Size = UDim2.fromScale(1, 1),
				ZIndex = 3,
				[Children] = Kit.corner(scope, "Full"),
			},
			scope:New "Frame" {
				Name = "Content",
				BackgroundTransparency = 1,
				Size = UDim2.fromScale(1, 1),
				ZIndex = 4,
				[Children] = {
					Kit.list(scope, { Direction = "X", Padding = 10, HAlign = "Center", VAlign = "Center" }),
					Icon(scope, {
						Icon = scope:Computed(function(use)
							return use(status)[3]
						end),
						Size = 26,
						LayoutOrder = 1,
						Color = statusColor,
						Stroke = false,
						ZIndex = 5,
					}),
					Text(scope, {
						Name = "Message",
						Text = scope:Computed(function(use)
							return use(status)[1]
						end),
						TextSize = 22,
						Font = "Heavy",
						LayoutOrder = 2,
						Color = scope:Computed(function(use)
							return Color.lighten(use(statusColor), 0.45)
						end),
						ZIndex = 5,
					}),
				},
			},
		},
	}

	-- your inventory: ItemGrid when the kit has it, else a simple grid
	local gridHolder = Components:FindFirstChild("ItemGrid")
	local drawerGrid
	local gridSelected = scope:Value(nil)
	if gridHolder then
		local ItemGrid = require(gridHolder) :: any
		drawerGrid = ItemGrid(scope, {
			Name = "InventoryGrid",
			Items = available,
			Columns = 13,
			CellSize = 80,
			Gap = 10,
			Selected = gridSelected,
			EmptyText = "Everything is in your offer",
			EmptyHint = "Click an item in your offer to take it back.",
			EmptyIcon = "backpack",
			Position = UDim2.fromOffset(8, 42),
			Size = UDim2.new(1, -16, 1, -48),
			ZIndex = 3,
			OnSelect = function(item)
				gridSelected:set(nil)
				addItem(item.id)
			end,
		})
	else
		drawerGrid = scope:New "ScrollingFrame" {
			Name = "InventoryGrid",
			Position = UDim2.fromOffset(8, 42),
			Size = UDim2.new(1, -16, 1, -48),
			BackgroundTransparency = 1,
			BorderSizePixel = 0,
			ScrollBarThickness = 6,
			CanvasSize = UDim2.fromOffset(0, 0),
			AutomaticCanvasSize = Enum.AutomaticSize.Y,
			ZIndex = 3,
			[Children] = {
				scope:New "UIGridLayout" {
					CellSize = UDim2.fromOffset(80, 80),
					CellPadding = UDim2.fromOffset(10, 10),
					SortOrder = Enum.SortOrder.LayoutOrder,
				},
				Kit.padding(scope, 12),
				scope:ForPairs(available, function(_, inner, index, item)
					return index, ItemSlot(inner, {
						Icon = item.icon,
						Rarity = item.rarity,
						Count = item.count,
						Size = 80,
						LayoutOrder = index,
						OnClick = function()
							addItem(item.id)
						end,
					})
				end),
			},
		}
	end

	local drawer = scope:New "Frame" {
		Name = "Inventory",
		Position = UDim2.fromOffset(0, DRAWER_Y),
		Size = UDim2.fromOffset(CONTENT_W, DRAWER_H),
		BackgroundColor3 = Kit.themed(scope, function(theme)
			return theme.Color.Surface
		end),
		BackgroundTransparency = 0.3,
		ZIndex = 2,
		[Children] = {
			Kit.corner(scope, "L"),
			Kit.stroke(scope, {
				Thickness = 1.5,
				Transparency = 0.75,
				Color = Kit.themed(scope, function(theme)
					return theme.Color.Glass or theme.Color.Light
				end),
			}),
			Icon(scope, {
				Icon = "backpack",
				Size = 26,
				Position = UDim2.fromOffset(18, 10),
				Color = Kit.themed(scope, function(theme)
					return theme.Color.Accent
				end),
				Gradient = true,
				ZIndex = 3,
			}),
			Text(scope, {
				Name = "DrawerTitle",
				Text = "YOUR INVENTORY",
				TextSize = 20,
				Font = "Heavy",
				XAlign = "Left",
				Position = UDim2.fromOffset(52, 12),
				ZIndex = 3,
			}),
			Text(scope, {
				Name = "DrawerHint",
				Text = "Click an item to add it  ·  click it in your offer to take it back",
				TextSize = 17,
				Font = "Body",
				Stroke = false,
				AnchorPoint = Vector2.new(1, 0),
				Position = UDim2.new(1, -20, 0, 14),
				Color = Kit.themed(scope, function(theme)
					return theme.Color.TextMuted
				end),
				ZIndex = 3,
			}),
			drawerGrid,
		},
	}

	-- coins you add (optional)
	local coinField = nil
	if currency then
		local box: TextBox? = nil
		coinField = scope:New "Frame" {
			Name = "CoinField",
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, 0, 0.5, 0),
			Size = UDim2.fromOffset(360, 58),
			BackgroundColor3 = Kit.themed(scope, function(theme)
				return theme.Color.Surface
			end),
			BackgroundTransparency = 0.2,
			ZIndex = 3,
			[Children] = {
				Kit.corner(scope, "Full"),
				Kit.stroke(scope, {
					Thickness = 2,
					Color = Kit.themed(scope, function(theme)
						return theme.Tone.Yellow.Bottom
					end),
					Transparency = 0.35,
				}),
				Icon(scope, {
					Icon = currency.Icon or "coin",
					Size = 36,
					AnchorPoint = Vector2.new(0, 0.5),
					Position = UDim2.new(0, 12, 0.5, 0),
					Color = currency.Color or Kit.themed(scope, function(theme)
						return theme.Tone.Yellow.Top
					end),
					Gradient = true,
					Shadow = true,
					ZIndex = 4,
				}),
				scope:New "TextBox" {
					Name = "CoinBox",
					BackgroundTransparency = 1,
					Position = UDim2.fromOffset(58, 0),
					Size = UDim2.new(1, -170, 1, 0),
					ClearTextOnFocus = false,
					PlaceholderText = `Add {string.lower(currency.Name or "coins")}`,
					PlaceholderColor3 = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					Text = "",
					TextSize = 24,
					FontFace = Kit.themed(scope, function(theme)
						return theme.Font.Display
					end),
					TextColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Text
					end),
					TextXAlignment = Enum.TextXAlignment.Left,
					ZIndex = 4,
					[OnEvent "FocusLost"] = function()
						if box then
							local amount = tonumber((string.gsub(box.Text, "[^%d]", ""))) or 0
							setCoins(amount)
							local now = peek(myCoins)
							box.Text = if now > 0 then Format.comma(now) else ""
						end
					end,
				},
				Text(scope, {
					Name = "Max",
					Text = scope:Computed(function(use)
						local max = use(currency.Max)
						return if max then `MAX {Format.abbrev(max)}` else ""
					end),
					TextSize = 16,
					Font = "Heavy",
					Stroke = false,
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -18, 0.5, 0),
					Color = Kit.themed(scope, function(theme)
						return theme.Color.TextMuted
					end),
					ZIndex = 4,
				}),
			},
		}
		box = coinField:FindFirstChild("CoinBox") :: TextBox?
		-- keep the box in sync when coins change from code (reset / setCoins)
		Kit.watch(scope, myCoins, function(now)
			if box and alive then
				box.Text = if now > 0 then Format.comma(now) else ""
			end
		end)
	end

	local readyText = scope:Computed(function(use)
		local p = use(phase)
		if p == "done" then
			return "Done"
		elseif p == "countdown" then
			return "Cancel"
		elseif use(myReady) then
			return "Unready"
		end
		local locked = use(lockLeft)
		return if locked > 0 then `Ready ({locked})` else "Ready"
	end)

	local readyButton = Button(scope, {
		Name = "Ready",
		Text = readyText,
		Icon = scope:Computed(function(use)
			local p = use(phase)
			if p == "countdown" or (use(myReady) and p == "edit") then
				return "close"
			end
			return "check"
		end),
		Tone = scope:Computed(function(use)
			local p = use(phase)
			if p == "countdown" or use(myReady) then
				return "Gray"
			end
			return "Green"
		end),
		Disabled = scope:Computed(function(use)
			local p = use(phase)
			if p == "done" or p == "declined" then
				return true
			end
			if use(myReady) or p == "countdown" then
				return false
			end
			return use(lockLeft) > 0 or use(isEmpty)
		end),
		Pulse = scope:Computed(function(use)
			return use(phase) == "edit" and not use(myReady) and use(theirReady) and use(lockLeft) == 0
		end),
		Shine = true,
		TextSize = 30,
		Size = UDim2.fromOffset(250, 64),
		AnchorPoint = Vector2.new(1, 0.5),
		Position = UDim2.new(1, 0, 0.5, 0),
		ZIndex = 4,
		OnClick = function()
			local p = peek(phase)
			if p == "countdown" or peek(myReady) then
				setReady(false)
			else
				setReady(true)
			end
		end,
	})

	local declineButton = Button(scope, {
		Name = "Decline",
		Text = "Decline",
		Icon = "close",
		Tone = "Red",
		TextSize = 24,
		Size = UDim2.fromOffset(180, 56),
		AnchorPoint = Vector2.new(1, 0.5),
		Position = UDim2.new(1, -270, 0.5, 0),
		Disabled = scope:Computed(function(use)
			return use(phase) == "done"
		end),
		ZIndex = 4,
		OnClick = decline,
	})

	local bar = scope:New "Frame" {
		Name = "Actions",
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(0, BAR_Y),
		Size = UDim2.fromOffset(CONTENT_W, BAR_H),
		ZIndex = 3,
		[Children] = { coinField, declineButton, readyButton },
	}

	-- fits the content into whatever the window can give it
	local contentScale = scope:Computed(function(use)
		local screen = use(Viewport.designSize)
		local availW = math.min(WINDOW.X, screen.X - 48) - PADDING * 2
		local availH = math.min(WINDOW.Y, screen.Y - 40) - 92 - PADDING * 2
		return math.min(1, availW / CONTENT_W, availH / CONTENT_H)
	end)

	local content = scope:New "Frame" {
		Name = "TradeContent",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(CONTENT_W, CONTENT_H),
		ZIndex = 4,
		[Children] = {
			scope:New "UIScale" { Scale = contentScale },
			offerPanel("me"),
			middle,
			offerPanel("them"),
			statusLine,
			drawer,
			bar,
		},
	}

	local window = Window(scope, {
		Name = "Window",
		Open = open,
		Size = WINDOW,
		Title = `Trading with {theirName}`,
		Icon = "two-arrows-left-right",
		CloseOnBackdrop = false,
		Padding = PADDING,
		-- no blur in edit-mode previews (no LocalPlayer): it would blur the Studio viewport
		Blur = config.Blur ~= false and Players.LocalPlayer ~= nil,
		OnClose = decline,
		ZIndex = 10,
		[Children] = content,
	})

	Gamepad.register(scope, window, {
		Close = decline,
		Open = open,
	})

	local gui = Screen(scope, {
		Name = config.Name or "Trade",
		DisplayOrder = config.DisplayOrder or 20,
		Parent = config.Parent,
		[Children] = window,
	})

	complete = function()
		phase:set("done")
		myReady:set(true)
		theirReady:set(true)
		Sound.play("Reward")
		Sound.play("Purchase")
		if middleCircle then
			local circle = middleCircle
			-- poof: white puffs fly out, one ring expands and fades, then confetti
			pcall(function()
				Particles.burst(circle, {
					Count = 9,
					Shape = "circle",
					Colors = { WHITE },
					Size = 34,
					Speed = 300,
					Gravity = 0,
					Drag = 5,
					Lifetime = 0.42,
					EndScale = 1.3,
				})
				Particles.burst(circle, { Count = 40, Speed = 900, Shape = "confetti" })
				Particles.burst(circle, { Count = 12, Speed = 520, Shape = "sparkle", Size = 30 })
			end)
			poofScale.Scale = 0.4
			poofStroke.Transparency = 0.2
			Motion.tween(poofScale, { Scale = 1.6 }, 0.28, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
			Motion.tween(poofStroke, { Transparency = 1 }, 0.28, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
			Motion.pop(circle, 0.25)
		end
		if config.OnAccept then
			config.OnAccept(myOfferList(), table.clone(peek(theirOffer)), { Mine = peek(myCoins), Theirs = peek(theirCoins) })
		end
		if config.CloseOnComplete ~= false then
			task.delay(config.CloseDelay or 1.8, function()
				if alive and peek(phase) == "done" then
					close()
				end
			end)
		end
	end

	return {
		Gui = gui,
		Window = window,
		Open = open,
		MyOffer = myItems,
		TheirOffer = theirOffer,
		MyCoins = myCoins,
		TheirCoins = theirCoins,
		MyReady = myReady,
		TheirReady = theirReady,
		Phase = phase,
		Countdown = countdown,
		Available = available,

		open = function()
			if peek(phase) ~= "edit" and peek(phase) ~= "countdown" then
				reset()
			end
			open:set(true)
		end,
		close = close,
		decline = decline,
		reset = reset,
		addItem = addItem,
		removeItem = removeItem,
		setCoins = setCoins,
		setReady = setReady,
		setTheirOffer = setTheirOffer,
		setTheirReady = setTheirReady,
		setTheirCoins = setTheirCoins,
	}
end
]]></string>
            </Properties>
          </Item>
        </Item>
        <Item class="ModuleScript" referent="259">
          <Properties>
            <string name="Name">Prompts</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Prompts: restyles Roblox ProximityPrompts ("[E] Open Door") to match your
	BloxUI theme, with hold-progress bars, controller glyphs and tap support.

		UI.Prompts.enable()                       -- every prompt in the game
		UI.Prompts.enable({ Tag = "BloxPrompt" }) -- only prompts with this CollectionService tag

	Prompts keep all their normal behaviour (Triggered, HoldDuration,
	MaxActivationDistance...). Only the look changes:
	  * the pill pops in (and shrinks away when you walk off)
	  * the key cap presses in while you hold; hold prompts fill the pill
	    from left to right in the theme's Good colour, plus a bar under the key
	  * triggering punches the pill with a white flash and a click
	Prompts.disable() removes every restyled prompt it drew.
]]

local CollectionService = game:GetService("CollectionService")
local Players = game:GetService("Players")
local ProximityPromptService = game:GetService("ProximityPromptService")
local TweenService = game:GetService("TweenService")
local Workspace = game:GetService("Workspace")

local Fusion = require(script.Parent.Packages.Fusion)
local Theme = require(script.Parent.Theme)
local Kit = require(script.Parent.Kit)
local Components = require(script.Parent.Components)
local Sound = require(script.Parent.Util.Sound)
local Viewport = require(script.Parent.Util.Viewport)

local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local peek = Fusion.peek

local Prompts = {}

local enabled = false
local connections: { RBXScriptConnection } = {}
-- the billboard currently drawn for each prompt: close(instant?)
local active: { [ProximityPrompt]: (instant: boolean?) -> () } = {}

local function buildPrompt(prompt: ProximityPrompt, inputType: Enum.ProximityPromptInputType)
	local player = Players.LocalPlayer
	if player == nil then
		return
	end
	-- shown again while the last one was still fading out: replace it
	local previous = active[prompt]
	if previous then
		previous(true)
	end

	local scope = Fusion.scoped(Fusion, Components)
	local closed = false
	local shown = scope:Value(false)
	local appear = scope:Spring(scope:Computed(function(use)
		return if use(shown) then 1 else 0
	end), 30, 0.6)
	local hold = scope:Value(0)
	local holdTween: Tween? = nil
	local holdProgress = scope:Value(0)
	local punchGoal = scope:Value(1)
	local punch = scope:Spring(punchGoal, 34, 0.42)
	local flashGoal = scope:Value(1)
	local flash = scope:Spring(flashGoal, 18, 1)
	local isTouch = inputType == Enum.ProximityPromptInputType.Touch
	local isHold = prompt.HoldDuration > 0
	local scale = peek(Viewport.scale)
	local width, height = 420, 104

	local actionText = scope:Value(prompt.ActionText)
	local objectText = scope:Value(prompt.ObjectText)
	table.insert(scope, prompt:GetPropertyChangedSignal("ActionText"):Connect(function()
		actionText:set(prompt.ActionText)
	end))
	table.insert(scope, prompt:GetPropertyChangedSignal("ObjectText"):Connect(function()
		objectText:set(prompt.ObjectText)
	end))

	-- NumberValue so TweenService can drive the hold fill
	local holdValue = Instance.new("NumberValue")
	table.insert(scope, holdValue)
	table.insert(scope, holdValue.Changed:Connect(function(value)
		holdProgress:set(value)
	end))

	local function stopHold()
		if holdTween then
			holdTween:Cancel()
			holdTween = nil
		end
	end

	table.insert(scope, prompt.PromptButtonHoldBegan:Connect(function()
		if prompt.HoldDuration > 0 then
			stopHold()
			holdValue.Value = 0
			holdTween = TweenService:Create(holdValue, TweenInfo.new(prompt.HoldDuration, Enum.EasingStyle.Linear), { Value = 1 })
			;(holdTween :: Tween):Play()
		end
		hold:set(1)
	end))
	table.insert(scope, prompt.PromptButtonHoldEnded:Connect(function()
		stopHold()
		-- let go early: the fill drains back quickly instead of snapping
		if holdValue.Value > 0 then
			holdTween = TweenService:Create(holdValue, TweenInfo.new(0.18, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), { Value = 0 })
			;(holdTween :: Tween):Play()
		end
		hold:set(0)
	end))
	table.insert(scope, prompt.Triggered:Connect(function()
		Sound.play("Click", { Pitch = 1.2 })
		hold:set(0)
		stopHold()
		holdValue.Value = 0
		punch:setPosition(1.14)
		flash:setPosition(0.35)
	end))
	table.insert(scope, stopHold)

	local press = scope:Spring(hold, 40, 0.6)

	local fillColor = Kit.themed(scope, function(theme)
		return theme.Color.Good or theme.Tone.Green.Top
	end)

	scope:New "BillboardGui" {
		Name = "BloxUIPrompt",
		Adornee = prompt.Parent,
		AlwaysOnTop = true,
		Size = UDim2.fromOffset(width * scale, height * scale),
		SizeOffset = Vector2.new(0, 0.6),
		Parent = player:WaitForChild("PlayerGui"),

		[Children] = scope:New "TextButton" {
			Name = "Canvas",
			Text = "",
			AutoButtonColor = false,
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromOffset(width, height),
			Active = isTouch or prompt.ClickablePrompt,

			[OnEvent "InputBegan"] = function(input: InputObject)
				local t = input.UserInputType
				if t == Enum.UserInputType.Touch or t == Enum.UserInputType.MouseButton1 then
					prompt:InputHoldBegin()
				end
			end,
			[OnEvent "InputEnded"] = function(input: InputObject)
				local t = input.UserInputType
				if t == Enum.UserInputType.Touch or t == Enum.UserInputType.MouseButton1 then
					prompt:InputHoldEnd()
				end
			end,

			[Children] = {
				scope:New "UIScale" {
					Scale = scope:Computed(function(use)
						return scale * (0.5 + 0.5 * use(appear)) * (1 - 0.05 * use(press)) * use(punch)
					end),
				},
				scope:New "Frame" {
					Name = "Pill",
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Size = UDim2.fromOffset(0, 72),
					AutomaticSize = Enum.AutomaticSize.X,
					BackgroundColor3 = Kit.themed(scope, function(theme)
						return theme.Color.Surface
					end),
					BackgroundTransparency = scope:Computed(function(use)
						local base = use(Theme.current).Alpha.Surface * 0.6
						return 1 - (1 - base) * math.clamp(use(appear), 0, 1)
					end),
					[Children] = {
						Kit.corner(scope, "M"),
						-- the hold fill sweeps across the whole pill
						scope:New "Frame" {
							Name = "HoldFill",
							AnchorPoint = Vector2.new(0, 0.5),
							Position = UDim2.fromScale(0, 0.5),
							Size = scope:Computed(function(use)
								return UDim2.fromScale(math.clamp(use(holdProgress), 0, 1), 1)
							end),
							BackgroundColor3 = fillColor,
							BackgroundTransparency = scope:Computed(function(use)
								return 1 - 0.35 * math.clamp(use(appear), 0, 1)
							end),
							Visible = scope:Computed(function(use)
								return isHold and use(holdProgress) > 0.005
							end),
							ZIndex = 1,
							[Children] = {
								Kit.corner(scope, "M"),
								Kit.gradient(scope, {
									Transparency = NumberSequence.new({
										NumberSequenceKeypoint.new(0, 0.4),
										NumberSequenceKeypoint.new(0.85, 0),
										NumberSequenceKeypoint.new(1, 0),
									}),
								}),
							},
						},
						-- a white flash when it fires
						scope:New "Frame" {
							Name = "Flash",
							Size = UDim2.fromScale(1, 1),
							BackgroundColor3 = Color3.new(1, 1, 1),
							BackgroundTransparency = scope:Computed(function(use)
								return math.clamp(use(flash), 0, 1)
							end),
							ZIndex = 5,
							[Children] = Kit.corner(scope, "M"),
						},
						scope:New "Frame" {
							Name = "Row",
							BackgroundTransparency = 1,
							Size = UDim2.fromOffset(0, 72),
							AutomaticSize = Enum.AutomaticSize.X,
							ZIndex = 2,
							[Children] = {
								Kit.padding(scope, { 0, 16, 0, 12 }),
								Kit.list(scope, { Direction = "X", Padding = 14, VAlign = "Center" }),
								scope:New "Frame" {
									Name = "Key",
									BackgroundTransparency = 1,
									Size = UDim2.fromOffset(52, 52),
									LayoutOrder = 1,
									ZIndex = 2,
									[Children] = {
										scope:New "UIScale" {
											Scale = scope:Computed(function(use)
												return 1 - 0.12 * use(press)
											end),
										},
										if isTouch
											then scope:Icon {
												Icon = "hand",
												Size = UDim2.fromScale(1, 1),
												ZIndex = 2,
											}
											else scope:KeyCap {
												Key = prompt.KeyboardKeyCode,
												Gamepad = prompt.GamepadKeyCode,
												Size = 48,
												AnchorPoint = Vector2.new(0.5, 0.5),
												Position = UDim2.fromScale(0.5, 0.5),
												ZIndex = 2,
											},
										scope:New "Frame" {
											Name = "HoldBar",
											AnchorPoint = Vector2.new(0.5, 0),
											Position = UDim2.new(0.5, 0, 1, 2),
											Size = UDim2.fromOffset(52, 6),
											BackgroundColor3 = Kit.themed(scope, function(theme)
												return theme.Color.Track
											end),
											Visible = isHold,
											ZIndex = 2,
											[Children] = {
												Kit.corner(scope, "Full"),
												scope:New "Frame" {
													Name = "Fill",
													Size = scope:Computed(function(use)
														return UDim2.fromScale(use(holdProgress), 1)
													end),
													BackgroundColor3 = fillColor,
													ZIndex = 3,
													[Children] = Kit.corner(scope, "Full"),
												},
											},
										},
									},
								},
								scope:Column {
									Name = "Labels",
									Size = UDim2.fromOffset(0, 60),
									AutoSize = "X",
									Gap = 0,
									Align = "Left",
									VAlign = "Center",
									LayoutOrder = 2,
									ZIndex = 2,
									[Children] = {
										scope:Text {
											Text = actionText,
											TextSize = 28,
											Font = "Display",
											LayoutOrder = 1,
											ZIndex = 2,
										},
										scope:Text {
											Text = objectText,
											TextSize = 18,
											Font = "Body",
											Color = Kit.themed(scope, function(theme)
												return theme.Color.TextMuted
											end),
											Stroke = false,
											Visible = scope:Computed(function(use)
												return use(objectText) ~= ""
											end),
											LayoutOrder = 2,
											ZIndex = 2,
										},
									},
								},
							},
						},
					},
				},
			},
		},
	}

	shown:set(true)

	local function close(instant: boolean?)
		if closed then
			return
		end
		closed = true
		if active[prompt] == close then
			active[prompt] = nil
		end
		shown:set(false)
		if instant then
			Fusion.doCleanup(scope)
		else
			task.delay(0.2, function()
				Fusion.doCleanup(scope)
			end)
		end
	end
	active[prompt] = close

	table.insert(scope, prompt.PromptHidden:Connect(function()
		close()
	end))
	-- the prompt (or its part) was destroyed while shown
	table.insert(scope, prompt.AncestryChanged:Connect(function()
		if not prompt:IsDescendantOf(game) then
			close(true)
		end
	end))
end

local function claim(prompt: Instance, tag: string?)
	if prompt:IsA("ProximityPrompt") and (tag == nil or CollectionService:HasTag(prompt, tag)) then
		prompt.Style = Enum.ProximityPromptStyle.Custom
	end
end

export type PromptOptions = {
	Tag: string?, -- only restyle prompts with this CollectionService tag
}

-- Starts restyling ProximityPrompts. Call once from a LocalScript.
function Prompts.enable(options: PromptOptions?)
	if enabled then
		return
	end
	enabled = true
	local tag = options and options.Tag

	for _, descendant in Workspace:GetDescendants() do
		claim(descendant, tag)
	end
	table.insert(connections, Workspace.DescendantAdded:Connect(function(descendant)
		claim(descendant, tag)
	end))
	if tag then
		table.insert(connections, CollectionService:GetInstanceAddedSignal(tag):Connect(function(instance)
			claim(instance, tag)
		end))
	end
	table.insert(connections, ProximityPromptService.PromptShown:Connect(function(prompt, inputType)
		if prompt.Style == Enum.ProximityPromptStyle.Custom and (tag == nil or CollectionService:HasTag(prompt, tag)) then
			buildPrompt(prompt, inputType)
		end
	end))
end

-- Stops restyling new prompts and removes the ones on screen.
function Prompts.disable()
	for _, connection in connections do
		connection:Disconnect()
	end
	table.clear(connections)
	for _, close in active do
		close(true)
	end
	table.clear(active)
	enabled = false
end

return Prompts
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="260">
          <Properties>
            <string name="Name">Server</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	UI.Server: data-driven server glue for blueprint UIs. Put this in a server
	Script (or enable Templates/BlueprintServer):

		local UI = require(game:GetService("ReplicatedStorage"):WaitForChild("BloxUI"))
		UI.Server.start()                              -- reads ReplicatedStorage.BloxUI_Blueprints
		UI.Server.start({ blueprints = { myBlueprint } })   -- or pass tables / JSON / StringValues / a Folder

	What it does:
	- leaderstats: creates leaderstats.<name> for every currency whose source is
	  "leaderstat" (IntValue; { NumberValues = true } for NumberValues) and the
	  attribute for "attribute" currencies, starting at the currency's `start`.
	- remotes: creates ReplicatedStorage.BloxUI_Action. Blueprint "Remote"
	  actions arrive here and go to your handlers:
	      UI.Server.on("RedeemCode", function(player, elementId, payload) end)
	  Arguments are checked first (element id: a string of up to 100
	  characters; payload: numbers without NaN / inf, strings up to 1024
	  characters, tables up to 32 keys and 2 levels deep).
	- server events: "Purchase", "CaseOpened", "Bought:<itemId>" and anything
	  starting with "BloxUI." or containing ":" are raised by the server only
	  (a client can never fire them). They are notifications, sent after the
	  purchase is done: never grant things from them.
	- buying with in-game currency ("BloxUI.Buy"): refused until the player's
	  data has loaded. The price comes from the server's own blueprint copy
	  (rounded up), UI.Server.canBuy(player, sectionId, itemId) may veto it,
	  the currency is taken, any `grant` is given (rounded down; free items
	  can't grant currency), upgrades go up a level (player attribute
	  BloxUI_Upgrade_<id>) and cases are rolled here with the server's Random
	  and handed to UI.Server.onCaseDrop (refunded when it fails). The client
	  gets "BloxUI.BuyResult" (ok, reason, { won }) and only then plays effects.
	- receipts: grants the Products item's `grant`, Robux premium passes and
	  Robux cases (rolled once per purchase, delivered with onCaseDrop, sent
	  to the client as "BloxUI.CaseResult"). PurchaseGranted is only returned
	  once the grant and its receipt id are saved together. onReceipt /
	  onCaseDrop handlers run once per purchase in a server, but a purchase
	  can be retried in another server after a crash: make them idempotent
	  by PurchaseId. ProcessReceipt is only taken over when the blueprints
	  sell developer products, an onReceipt handler exists, or
	  { ProcessReceipt = true }; start with { ProcessReceipt = false } and
	  call UI.Server.handleReceipt(receiptInfo) from your own.
	  { fallbackReceipt = fn(receiptInfo) } handles unknown products.
	- data: currencies (leaderstat / attribute), upgrade levels, Robux premium
	  and processed receipt ids are saved per player in the DataStore
	  "BloxUI_Data" (key u_<userId>) with a session lock, so two servers
	  never own the same player (teleports, rejoins): the lock is claimed on
	  join (waiting up to 30 s for the previous server to let go), renewed by
	  every write, released on leave and expires after 30 minutes. One writer
	  per player; saves on change (every 60 s at most, within the request
	  budget), on leave (retried until shutdown) and on shutdown. A leaderstat
	  that already exists when the player joins belongs to your own data code
	  and is left alone unless { SaveData = true }. With { SaveData = false }
	  receipts are only granted when { saveHook = fn(player) -> boolean }
	  says your data is saved.
	- premium passes: the player attribute BloxUI_Premium_<sectionId> is the
	  truth; game pass ownership is checked (with retries) on join and after
	  a purchase prompt closes.
	- settings: see docs/BLUEPRINT.md v1.3 ("BloxUI_Settings" DataStore and
	  player attribute). Opt out with { SaveSettings = false }.
	- rewards (v1.6, "BloxUI.Claim"): every reward is decided here, never by
	  the client. Spin wheels are rolled with the server's Random (free spins
	  on a cooldown saved with the player, paid spins charged first, Robux
	  spins rolled once per purchase); playtime gifts count this server's own
	  join time; Index milestones count Server.setIndexOwned (or the
	  BloxUI_Index_<section> attribute the game sets); achievements read the
	  BloxUI_Ach_<id> attribute the game sets; a group reward asks
	  IsInGroup; offline earnings come from the saved last-seen time and the
	  blueprint's rate and cap. Claims are saved with the player; rewards with
	  a `grant` are given here, anything else goes to UI.Server.onSpinPrize /
	  UI.Server.onReward (return true once it's given).
	Studio without DataStore access keeps data and settings in memory for the
	session. A live server never does: failed saves are retried instead.

		UI.Server.grant(player, "coins", 100)       -- add to a currency (queued until the data loads)
		UI.Server.getCurrency(player, "coins")      -- current amount
		UI.Server.onReceipt(123456, function(player, receiptInfo) return true end)
		UI.Server.onCaseDrop(function(player, drop, info) return giveItem(player, drop.name) end)
		UI.Server.canBuy = function(player, sectionId, itemId) return true end
		UI.Server.getSettings(player)               -- { music = 50, fov = 70, ... } (defaults filled in)
		UI.Server.flush()                           -- save everything now (BindToClose does this)
		UI.Server.onSpinPrize(function(player, wedge, info) return givePrize(player, wedge) end)
		UI.Server.onReward(function(player, reward, info) return give(player, reward) end)   -- info.kind
		UI.Server.setIndexOwned(player, { "dragon", "unicorn" })   -- what the player has discovered
		UI.Server.offlineRate = function(player, screen) return myIncomePerMinute(player) end
]]

local HttpService = game:GetService("HttpService")
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local Normalize = require(script.Parent.Blueprint.Normalize)
local Settings = require(script.Parent.Blueprint.Settings)
local Schema = require(script.Parent.Blueprint.Schema)

local ATTR = Schema.ATTRIBUTES

local Server = {}

Server.REMOTE_NAME = "BloxUI_Action"
Server.FOLDER = "BloxUI_Blueprints"
Server.RATE = 20 -- remote events per second per player (burst up to 2x)
Server.BUY = "BloxUI.Buy"
Server.BUY_RESULT = "BloxUI.BuyResult"
Server.CASE_RESULT = "BloxUI.CaseResult"
-- v1.6 rewards: a client asks with CLAIM (answered like BUY), the server
-- tells a client which wedge a Robux spin won with SPIN_RESULT and raises
-- REWARD (a server event) after every reward it gave.
Server.CLAIM = "BloxUI.Claim"
Server.SPIN_RESULT = "BloxUI.SpinResult"
Server.REWARD = "BloxUI.Reward"
Server.INT_MAX = 2 ^ 63 - 1024 -- IntValue range (the largest double below 2^63)

-- Tests: force Studio (true) / live server (false) behaviour, or pretend
-- to be another server (JOB_ID). nil = detect.
Server.STUDIO = nil :: boolean?
Server.JOB_ID = nil :: string?

-- Hooks the game may set.
Server.canBuy = nil :: ((Player, string, string) -> boolean)?
Server.fallbackReceipt = nil :: ((any) -> Enum.ProductPurchaseDecision)?
-- v1.6: offline earnings per minute for this player (default: the screen's rate)
Server.offlineRate = nil :: ((Player, any) -> number)?

local handlers: { [string]: { (Player, any, any) -> () } } = {} -- reachable from the remote
local serverHandlers: { [string]: { (Player, any, any) -> () } } = {} -- server events only
local receiptHandlers: { [number]: (Player, any) -> boolean? } = {}
local caseDropHandler: ((Player, any, any) -> boolean)? = nil
local spinPrizeHandler: ((Player, any, any) -> boolean)? = nil -- v1.6
local rewardHandler: ((Player, any, any) -> boolean)? = nil -- v1.6
local connections: { RBXScriptConnection } = {}
local indexConnections: { RBXScriptConnection } = {}
local granted: { [string]: boolean } = {} -- purchase ids granted and saved
local applied: { [string]: boolean } = {} -- purchase ids given to the player, maybe not saved yet
local customDone: { [string]: boolean } = {} -- purchase ids whose onReceipt handler already ran
local drops: { [string]: any } = {} -- purchase id -> the case drop rolled for it (stable across retries)
local delivered: { [string]: boolean } = {} -- purchase ids whose case drop onCaseDrop accepted
local purchaseOwner: { [string]: number } = {} -- purchase id -> userId (pruned on leave)
local processing: { [string]: boolean } = {} -- purchase ids being granted right now
local buckets: { [Player]: { tokens: number, at: number } } = {}
local warnedOnce: { [string]: boolean } = {}
local warnedCount = 0
local rng = Random.new()
local generation = 0 -- bumped by stop(); background loops check it
local startOptions: { [string]: any } = {}
local tookReceipts = false

-- Indexes built from the blueprints.
local currencies: { [string]: any } = {}
local products: { [number]: any } = {}
local settingIndex: { [string]: { any } } = {}
local shopItems: { [string]: any } = {}
local upgrades: { [string]: any } = {} -- upgrade id -> definition
local passes: { [string]: { productId: number?, gamePassId: number? } } = {} -- Pass section id
local gamePasses: { [number]: { string } } = {} -- game pass id -> player attributes set true for owners
-- v1.6 rewards
local rewardSections: { [string]: { kind: string, def: any } } = {} -- section / screen id -> reward system
local achievementIndex: { [string]: { def: any, section: string } } = {} -- achievement id
local offerScreens: { [string]: any } = {} -- Offer screen id -> screen
local offlineScreens: { [string]: any } = {} -- OfflineEarnings screen id -> screen
local paidSpinOff: { [string]: boolean } = {} -- Spin section ids whose paid spin would pay back too much
local giftMemory: { [number]: { [string]: any } } = {} -- SaveData = false: gift cycles by userId (this server only)

Server.blueprints = {} :: { any }
Server.remote = nil :: RemoteEvent?
Server.started = false

local function warnOnce(key: string, message: string)
	if warnedOnce[key] or warnedCount >= 200 then
		return
	end
	warnedOnce[key] = true
	warnedCount += 1
	warn(`[BloxUI] Server: {message}`)
end

local function deepCopy(value: any): any
	if typeof(value) ~= "table" then
		return value
	end
	local out = {}
	for k, v in value do
		out[k] = deepCopy(v)
	end
	return out
end

local function finite(n: any): boolean
	return typeof(n) == "number" and n == n and n ~= math.huge and n ~= -math.huge
end

--------------------------------------------------------------------------
-- Handlers
--------------------------------------------------------------------------

-- Names only the server raises; the remote can never reach their handlers.
function Server.isInternal(name: any): boolean
	if typeof(name) ~= "string" then
		return true
	end
	return name == "Purchase" or name == "CaseOpened" or string.sub(name, 1, 7) == "BloxUI." or string.find(name, ":", 1, true) ~= nil
end

-- Handles a blueprint Remote action by name, or a server event (see the
-- top). Returns a disconnect function.
function Server.on(name: string, fn: (Player, any, any) -> ()): () -> ()
	local registry = if Server.isInternal(name) then serverHandlers else handlers
	local list = registry[name]
	if list == nil then
		list = {}
		registry[name] = list
	end
	table.insert(list, fn)
	return function()
		local index = table.find(list, fn)
		if index then
			table.remove(list, index)
		end
	end
end

local takeReceipts: () -> ()

-- Custom receipt handling for one product. Return false (or error) to retry
-- later. Runs once per purchase in this server; make it idempotent by
-- receiptInfo.PurchaseId.
function Server.onReceipt(productId: number, fn: (Player, any) -> boolean?)
	receiptHandlers[productId] = fn
	if Server.started then
		takeReceipts()
	end
end

-- Delivers case drops: fn(player, drop, { case, section, purchaseId? })
-- must give the item and return true (synchronously). false / an error
-- refunds an in-game buy and retries a Robux purchase later. Without a
-- handler cases are cosmetic (the drop is only announced).
function Server.onCaseDrop(fn: ((Player, any, any) -> boolean)?)
	caseDropHandler = fn
end

-- v1.6: delivers spin wheel prizes that have no `grant` (pets, boosts...):
-- fn(player, wedge, { section, index, spinId, mode, purchaseId? }) must
-- give it and return true (synchronously). `spinId` is unique per spin (the
-- PurchaseId for Robux spins, so a retry in another server can be told
-- apart). false / an error undoes the spin (cooldown or price given back).
function Server.onSpinPrize(fn: ((Player, any, any) -> boolean)?)
	spinPrizeHandler = fn
end

-- v1.6: delivers every other reward without a `grant`: fn(player, reward,
-- { kind = "Gift" | "Index" | "Achievement" | "Social" | "Offer", section,
-- id, claimId, purchaseId? }) must give it and return true. Rewards with a
-- `grant` are given by the server itself. `claimId` is the same every time
-- the same reward is paid out (gift:<section>:<gift>:<cycle end>,
-- index:<section>:<percent>, ach:<id>, social:<section>:<kind>, the
-- PurchaseId for an offer): record it with what you give and return true
-- without giving again when you already have it, so a claim retried after a
-- lost save never pays twice.
function Server.onReward(fn: ((Player, any, any) -> boolean)?)
	rewardHandler = fn
end

local function run(registry: { [string]: { any } }, name: string, player: Player, elementId: any, payload: any): boolean
	local list = registry[name]
	if list == nil or #list == 0 then
		return false
	end
	for _, fn in table.clone(list) do
		task.spawn(fn, player, elementId, payload)
	end
	return true
end

-- Raises a server event (never reachable from a client).
local function emit(name: string, player: Player, elementId: any, payload: any)
	run(serverHandlers, name, player, elementId, payload)
end

local function deliverDrop(player: Player, drop: any, info: { [string]: any }): boolean
	if drop == nil then
		return false
	end
	if caseDropHandler == nil then
		warnOnce("noCaseDrop", "cases are rolled but nothing delivers them: add UI.Server.onCaseDrop(function(player, drop, info) ... return true end)")
		return true
	end
	local ok, result = pcall(caseDropHandler, player, deepCopy(drop), info)
	if not ok then
		warnOnce("caseDrop:" .. tostring(result), `onCaseDrop failed: {result}`)
	end
	return ok and result == true
end

-- v1.6: hands a reward that has no `grant` to the game's handler
-- (onSpinPrize for wheels, onReward for the rest). Without a handler a free
-- reward is only announced (like cases without onCaseDrop); a paid one
-- (info.purchaseId) is not delivered, so its receipt waits.
local function deliverPrize(player: Player, prize: any, info: { [string]: any }): boolean
	local isSpin = info.kind == "Spin"
	local handler = if isSpin then spinPrizeHandler else rewardHandler
	if handler == nil then
		local name = if isSpin then "onSpinPrize" else "onReward"
		if info.purchaseId ~= nil then
			warnOnce("paid:" .. name, `a Robux purchase includes a prize without a grant, but nothing gives it: add UI.Server.{name}(function(player, reward, info) ... return true end). The purchase waits until then`)
			return false
		end
		warnOnce("no:" .. name, `rewards without a grant are announced but nothing gives them: add UI.Server.{name}(function(player, reward, info) ... return true end)`)
		return true
	end
	local ok, result = pcall(handler, player, deepCopy(prize), info)
	if not ok then
		warnOnce("prize:" .. tostring(result), `{if isSpin then "onSpinPrize" else "onReward"} failed: {result}`)
	end
	return ok and result == true
end

--------------------------------------------------------------------------
-- Storage: DataStores with retries (memory only in Studio without access)
--------------------------------------------------------------------------

Server.DATA_STORE = "BloxUI_Data"
Server.SETTINGS_STORE = "BloxUI_Settings"
Server.RETRIES = 3
Server.RETRY_DELAY = 1 -- seconds, doubled every attempt
Server.BUDGET_RESERVE = 10 -- background saves wait while the UpdateAsync budget is below this
Server.storageBackend = "none" -- "datastore" once used, "memory" in Studio without DataStore access

local MEMORY = {} -- sentinel result: use the memory store
local stores: { [string]: any } = {}
local storesDown = false
local memory: { [string]: { [string]: any } } = {} -- store name -> key -> data (lives as long as this server)

local function isStudio(): boolean
	if Server.STUDIO ~= nil then
		return Server.STUDIO
	end
	local ok, studio = pcall(function()
		return RunService:IsStudio()
	end)
	if ok and studio == true then
		return true
	end
	local okPlace, placeId = pcall(function()
		return (game :: any).PlaceId
	end)
	return okPlace and placeId == 0
end

local fallbackJob: string? = nil
local function jobId(): string
	if Server.JOB_ID then
		return Server.JOB_ID
	end
	local ok, id = pcall(function()
		return (game :: any).JobId
	end)
	if ok and typeof(id) == "string" and id ~= "" then
		return id
	end
	if fallbackJob == nil then
		fallbackJob = "local-" .. HttpService:GenerateGUID(false)
	end
	return fallbackJob :: string
end

-- Studio without "Enable Studio Access to API Services", or an unpublished place.
local function storesUnavailable(err: any): boolean
	local message = string.lower(tostring(err))
	for _, needle in { "studio access", "not allowed", "publish this place", "api services" } do
		if string.find(message, needle, 1, true) then
			return true
		end
	end
	return false
end

-- Only Studio falls back to memory; a live server keeps retrying.
local function useMemory(reason: any): boolean
	if not isStudio() then
		return false
	end
	if not storesDown then
		storesDown = true
		Server.storageBackend = "memory"
		print(`[BloxUI] Server: DataStores are unavailable in Studio ({tostring(reason)}), so player data and settings are kept in memory for this session.`)
	end
	return true
end

local function getStore(name: string): (any?, any)
	local store = stores[name]
	if store then
		return store, nil
	end
	local ok, result = pcall(function()
		return game:GetService("DataStoreService"):GetDataStore(name)
	end)
	if not ok or result == nil then
		return nil, result
	end
	stores[name] = result
	Server.storageBackend = "datastore"
	return result, nil
end

-- One request with retries: ok, result (MEMORY in Studio without access).
local function request(name: string, fn: (any) -> any): (boolean, any)
	local lastError = nil
	for attempt = 1, Server.RETRIES do
		if storesDown then
			return false, MEMORY
		end
		local store, err = getStore(name)
		if store then
			local ok, result = pcall(fn, store)
			if ok then
				return true, result
			end
			err = result
		end
		lastError = err
		if storesUnavailable(err) and useMemory(err) then
			return false, MEMORY
		end
		if attempt < Server.RETRIES then
			task.wait(Server.RETRY_DELAY * 2 ^ (attempt - 1))
		end
	end
	return false, lastError
end

-- ok, data (nil for a new player).
local function readKey(name: string, key: string): (boolean, any)
	local ok, result = request(name, function(store)
		return store:GetAsync(key)
	end)
	if not ok and result == MEMORY then
		return true, deepCopy(memory[name] and memory[name][key])
	end
	return ok, result
end

-- UpdateAsync: transform(old) -> new, or nil to leave the data alone.
local function updateKey(name: string, key: string, transform: (any) -> any): (boolean, any)
	local ok, result = request(name, function(store)
		return store:UpdateAsync(key, transform)
	end)
	if not ok and result == MEMORY then
		local bucket = memory[name]
		if bucket == nil then
			bucket = {}
			memory[name] = bucket
		end
		local new = transform(deepCopy(bucket[key]))
		if new ~= nil then
			bucket[key] = deepCopy(new)
		end
		return true, deepCopy(bucket[key])
	end
	return ok, result
end

-- Room in the UpdateAsync budget for a background save (nil check = yes).
local function budgetOk(): boolean
	if storesDown then
		return true
	end
	local ok, budget = pcall(function()
		return game:GetService("DataStoreService"):GetRequestBudgetForRequestType((Enum :: any).DataStoreRequestType.UpdateAsync)
	end)
	return not ok or typeof(budget) ~= "number" or budget >= Server.BUDGET_RESERVE
end

local function playerKey(player: Player): string
	return "u_" .. tostring(player.UserId)
end

local function merge(into: { [string]: any }, changes: { [string]: any }): { [string]: any }
	for id, value in changes do
		into[id] = value
	end
	return into
end

--------------------------------------------------------------------------
-- Currencies
--------------------------------------------------------------------------

local function statFor(player: Player, def: any): ValueBase?
	local stats = player:FindFirstChild("leaderstats")
	return stats and stats:FindFirstChild(def.source.name) :: any
end

-- Current amount of a currency (nil when the player doesn't have it yet).
function Server.getCurrency(player: Player, id: string): number?
	local def = currencies[id]
	if def == nil then
		return nil
	end
	if def.source.kind == "leaderstat" then
		local stat = statFor(player, def)
		return stat and (stat :: any).Value
	end
	local name = if def.source.kind == "attribute" then def.source.name else id
	local value = player:GetAttribute(name)
	return if typeof(value) == "number" then value else nil
end

function Server.setCurrency(player: Player, id: string, amount: number): boolean
	local def = currencies[id]
	if def == nil then
		warnOnce("currency:" .. id, `no currency "{id}" in the blueprints`)
		return false
	end
	if not finite(amount) then
		return false
	end
	if def.source.kind == "leaderstat" then
		local stat = statFor(player, def)
		if stat == nil then
			return false
		end
		if stat:IsA("IntValue") then
			amount = math.clamp(if amount >= 0 then math.floor(amount) else math.ceil(amount), -Server.INT_MAX, Server.INT_MAX)
		end
		(stat :: any).Value = amount
		return true
	end
	if def.source.kind == "none" then
		warnOnce("none:" .. id, `currency "{id}" has source "none": the server can't change it. Use "leaderstat" or "attribute".`)
		return false
	end
	return (pcall(function()
		player:SetAttribute(def.source.name, amount)
	end))
end

local profiles: { [Player]: any } = {}
local saveData = true

-- Adds `amount` (may be negative) to a currency. Returns false when it
-- can't. While the player's saved data is still loading, grants are
-- queued and added once it has.
function Server.grant(player: Player, id: string, amount: number): boolean
	local def = currencies[id]
	if def == nil or def.source.kind == "none" or not finite(amount) then
		if def and def.source.kind == "none" then
			warnOnce("none:" .. id, `currency "{id}" has source "none": the server can't change it. Use "leaderstat" or "attribute".`)
		end
		return false
	end
	local profile = profiles[player]
	if saveData and profile and not profile.loaded and not profile.failed then
		table.insert(profile.pendingGrants, { id = id, amount = amount })
		return true
	end
	local current = Server.getCurrency(player, id)
	if current == nil then
		current = def.start or 0
	end
	return Server.setCurrency(player, id, (current :: number) + amount)
end

-- Makes leaderstats / attributes. Returns the currency ids whose leaderstat
-- already existed (the game's own data code owns those).
local function setupPlayer(player: Player): { [string]: boolean }
	local existing = {}
	local stats = player:FindFirstChild("leaderstats")
	for id, def in currencies do
		if def.source.kind == "leaderstat" and stats and stats:FindFirstChild(def.source.name) then
			existing[id] = true
		end
	end
	if startOptions.Leaderstats == false then
		return existing
	end
	local needsStats = false
	for _, def in currencies do
		if def.source.kind == "leaderstat" then
			needsStats = true
		end
	end
	if needsStats then
		if stats == nil then
			stats = Instance.new("Folder")
			stats.Name = "leaderstats"
			stats.Parent = player
		end
		for _, def in currencies do
			if def.source.kind == "leaderstat" and (stats :: Instance):FindFirstChild(def.source.name) == nil then
				local value = Instance.new(if startOptions.NumberValues then "NumberValue" else "IntValue")
				value.Name = def.source.name
				value.Value = if startOptions.NumberValues then (def.start or 0) else math.floor(def.start or 0)
				value.Parent = stats
			end
		end
	end
	for _, def in currencies do
		if def.source.kind == "attribute" and player:GetAttribute(def.source.name) == nil then
			pcall(function()
				player:SetAttribute(def.source.name, def.start or 0)
			end)
		end
	end
	return existing
end

--------------------------------------------------------------------------
-- Blueprints
--------------------------------------------------------------------------

local function readBlueprint(source: any): any
	if typeof(source) == "Instance" then
		if not source:IsA("StringValue") or source:GetAttribute("Enabled") == false then
			return nil
		end
		source = source.Value
	end
	if typeof(source) == "string" then
		local ok, data = pcall(function()
			return HttpService:JSONDecode(source)
		end)
		if not ok then
			warnOnce("json:" .. string.sub(source, 1, 40), "a blueprint isn't valid JSON, skipped")
			return nil
		end
		source = data
	end
	if typeof(source) ~= "table" then
		return nil
	end
	return (Normalize.run(source))
end

local function allSections(screen: any): { any }
	local list = table.clone(screen.sections or {})
	for _, tab in screen.tabs or {} do
		for _, s in tab.sections do
			table.insert(list, s)
		end
	end
	return list
end

-- A `grant` the server may give: an existing leaderstat / attribute
-- currency and a whole positive amount.
local function checkGrant(grant: any, where: string): any
	if typeof(grant) ~= "table" then
		return nil
	end
	local def = currencies[grant.currency]
	if def == nil or def.source.kind == "none" then
		warnOnce("grant:" .. where, `{where} grants "{tostring(grant.currency)}", which the server can't change (source "none"); the grant is skipped`)
		return nil
	end
	local amount = if finite(grant.amount) then math.floor(grant.amount) else 0
	if amount <= 0 then
		return nil
	end
	return { currency = grant.currency, amount = amount }
end

local function addShopItem(key: string, entry: any)
	if shopItems[key] then
		warnOnce("dup:" .. key, `two items share the id "{key}"; the first one is sold`)
		return
	end
	shopItems[key] = entry
end

local function addProduct(productId: number, entry: any)
	if products[productId] then
		warnOnce("dupProduct:" .. productId, `two items use productId {productId}; the first one is granted`)
		return
	end
	products[productId] = entry
end

-- Owners of `gamePassId` get the player attribute `attribute` = true
-- (checked with UserOwnsGamePassAsync on join and after the prompt closes).
local function addGamePass(gamePassId: number, attribute: string)
	local list = gamePasses[gamePassId]
	if list == nil then
		list = {}
		gamePasses[gamePassId] = list
	end
	if not table.find(list, attribute) then
		table.insert(list, attribute)
	end
end

-- v1.6: a Spin / Gifts / Index / Achievements / Social section.
local function indexReward(s: any)
	if rewardSections[s.id] then
		warnOnce("dupReward:" .. s.id, `two reward sections share the id "{s.id}"; the first one is used`)
		return
	end
	rewardSections[s.id] = { kind = s.kind, def = s }
	-- false: the prize has a grant the server can't pay
	local function check(prize: any, where: string): boolean
		if typeof(prize) == "table" and prize.grant then
			return checkGrant(prize.grant, where) ~= nil
		end
		return true
	end
	if s.kind == "Spin" then
		local payable = true
		for index, wedge in s.wedges do
			payable = check(wedge, `spin "{s.id}" wedge {index}`) and payable
		end
		if s.price and s.currency == "robux" and s.productId then
			-- a Robux spin is only sold when every wedge can really be paid
			if payable then
				addProduct(s.productId, { item = { id = s.id .. "_spin" }, section = s.id, spin = s.id })
			else
				warnOnce("spinUnpaid:" .. s.id, `spin "{s.id}" has a wedge the server can't pay, so its Robux spin (productId {s.productId}) isn't sold`)
			end
		end
		-- a paid spin that pays back more than it costs could be spun for profit
		local payback = Schema.spinReturn(s)
		local limit = Server.SPIN_MAX_RETURN or Schema.SPIN_MAX_RETURN
		if payback and payback > limit then
			paidSpinOff[s.id] = true
			warnOnce("spinReturn:" .. s.id, `spin "{s.id}" pays back {math.floor(payback * 100 + 0.5)}% of its price in {s.currency} on average (at most {math.floor(limit * 100)}%): its paid spins are off, or they could be spun for profit`)
		end
		if s.luckGamePassId then
			addGamePass(s.luckGamePassId, ATTR.spinLuck .. s.id)
		end
	elseif s.kind == "Gifts" then
		for index, gift in s.gifts do
			check(gift, `gifts "{s.id}" gift {index}`)
		end
	elseif s.kind == "Index" then
		for index, milestone in s.milestones do
			check(milestone, `index "{s.id}" milestone {index}`)
		end
	elseif s.kind == "Achievements" then
		for _, a in s.achievements do
			if achievementIndex[a.id] then
				warnOnce("dupAch:" .. a.id, `two achievements share the id "{a.id}"; the first one is used`)
			else
				achievementIndex[a.id] = { def = a, section = s.id }
				check(a.reward, `achievement "{a.id}"`)
			end
		end
	elseif s.kind == "Social" then
		for _, r in s.rewards do
			check(r.reward, `social reward "{r.id}"`)
		end
	end
end

local function indexItems(bp: any)
	for id, defs in Settings.collect(bp) do
		local list = settingIndex[id]
		if list == nil then
			list = {}
			settingIndex[id] = list
		end
		table.move(defs, 1, #defs, #list + 1, list)
	end
	for _, screen in bp.screens do
		for _, s in allSections(screen) do
			if s.kind == "Products" then
				for _, item in s.items do
					local grant = checkGrant(item.grant, `item "{item.id}"`)
					if item.productId then
						addProduct(item.productId, { item = item, grant = grant, section = s.id })
					end
					if item.currency ~= "robux" then
						local price = math.ceil(item.price or 0)
						if price <= 0 and grant then
							warnOnce("free:" .. item.id, `free item "{item.id}" grants currency, so it isn't sold (it could be farmed)`)
						else
							addShopItem(`{s.id}/{item.id}`, { price = price, currency = item.currency, grant = grant, id = item.id })
						end
					end
				end
			elseif s.kind == "Upgrades" then
				for _, u in s.upgrades do
					if u.currency ~= "robux" then
						if upgrades[u.id] and upgrades[u.id] ~= u then
							warnOnce("dupUpgrade:" .. u.id, `two upgrades share the id "{u.id}"; the first one is used`)
						else
							upgrades[u.id] = u
							addShopItem(`{s.id}/{u.id}`, { price = math.ceil(u.cost), currency = u.currency, id = u.id, upgrade = u })
						end
					end
				end
			elseif s.kind == "Cases" then
				for _, c in s.cases do
					if c.currency ~= "robux" then
						addShopItem(`{s.id}/{c.id}`, { price = math.ceil(c.price or 0), currency = c.currency, id = c.id, case = c })
					elseif c.productId then
						addProduct(c.productId, { item = c, section = s.id, case = c })
					end
				end
			elseif s.kind == "Pass" then
				passes[s.id] = { productId = s.premiumProductId, gamePassId = s.premiumGamePassId }
				if s.premiumProductId then
					addProduct(s.premiumProductId, { item = { id = s.id .. "_premium" }, section = s.id, premium = true })
				end
				if s.premiumGamePassId then
					addGamePass(s.premiumGamePassId, "BloxUI_Premium_" .. s.id)
				end
			elseif s.kind == "Spin" or s.kind == "Gifts" or s.kind == "Index" or s.kind == "Achievements" or s.kind == "Social" then
				indexReward(s)
			end
		end
		if screen.kind == "Offer" then
			if offerScreens[screen.id] == nil and rewardSections[screen.id] == nil then
				offerScreens[screen.id] = screen
				if screen.productId then
					-- currency the server pays itself, and the rest (pets, boosts...)
					-- for onReward; an offer the server can't fully pay isn't sold
					local grants, extras, unpaid = {}, {}, false
					for index, item in screen.items do
						local grant = checkGrant(item.grant, `offer "{screen.id}"`)
						if grant then
							table.insert(grants, grant)
						elseif item.grant ~= nil then
							unpaid = true
						else
							table.insert(extras, { index = index, item = item })
						end
					end
					if unpaid then
						warnOnce("offerUnpaid:" .. screen.id, `offer "{screen.id}" grants a currency the server can't pay, so it isn't sold (productId {screen.productId})`)
					else
						addProduct(screen.productId, { item = { id = screen.id }, section = screen.id, grants = grants, extras = extras, offer = screen.id })
					end
				elseif screen.gamePassId then
					addGamePass(screen.gamePassId, ATTR.offerOwned .. screen.id)
				end
			end
		elseif screen.kind == "OfflineEarnings" then
			if offlineScreens[screen.id] == nil and rewardSections[screen.id] == nil then
				offlineScreens[screen.id] = screen
				rewardSections[screen.id] = { kind = "OfflineEarnings", def = screen }
				local def = screen.currency and currencies[screen.currency]
				local payable = def ~= nil and def.source.kind ~= "none"
				if not payable then
					warnOnce("offline:" .. screen.id, `offline earnings "{screen.id}" pay "{tostring(screen.currency)}", which the server can't change (source "none")`)
				end
				if screen.doubleProductId then
					if payable then
						addProduct(screen.doubleProductId, { item = { id = screen.id .. "_x2" }, section = screen.id, offlineDouble = screen.id })
					else
						warnOnce("offlineUnpaid:" .. screen.id, `offline earnings "{screen.id}" can't be paid, so their x2 product ({screen.doubleProductId}) isn't sold`)
					end
				end
			end
		end
	end
end

-- Rebuilds every index from Server.blueprints.
local function reindex(sources: { any })
	for _, t in { currencies, products, shopItems, settingIndex, upgrades, passes, gamePasses, rewardSections, achievementIndex, offerScreens, offlineScreens, paidSpinOff } do
		table.clear(t)
	end
	Server.blueprints = {}
	for _, source in sources do
		local bp = readBlueprint(source)
		if bp then
			table.insert(Server.blueprints, bp)
		end
	end
	-- currencies first, so any blueprint's items can use any blueprint's currency
	for _, bp in Server.blueprints do
		for _, def in bp.currencies do
			if currencies[def.id] == nil then
				currencies[def.id] = def
			end
		end
	end
	for _, bp in Server.blueprints do
		indexItems(bp)
	end
end

--------------------------------------------------------------------------
-- Player data ("BloxUI_Data"): session-locked, one writer per player
--------------------------------------------------------------------------

Server.DATA_AUTOSAVE = 60 -- seconds between saves of changed data
Server.SESSION_RENEW = 300 -- a write at least this often keeps the lock fresh
Server.SESSION_TTL = 30 * 60 -- a lock older than this is abandoned
Server.LOCK_WAIT = 30 -- seconds to wait for another server to release a player
Server.LOCK_RETRY = 5
Server.RECEIPT_HISTORY = 100 -- purchase ids remembered per player

type Profile = {
	player: Player,
	userId: number,
	key: string,
	loaded: boolean,
	failed: boolean, -- could not load: never write, never grant
	lost: boolean, -- another server took the player over: never write again
	leaving: boolean,
	released: boolean,
	final: { [string]: any }?, -- state frozen when the player left
	stored: { currencies: { [string]: number }, upgrades: { [string]: number } },
	external: { [string]: boolean }, -- leaderstats owned by the game's own data code
	premium: { [string]: boolean }, -- Pass sections bought with Robux
	receipts: { [string]: number }, -- purchase ids saved
	pending: { [string]: number }, -- purchase ids given but not saved yet
	pendingGrants: { { id: string, amount: number } },
	writing: boolean,
	lastWrite: number,
	lastJson: string?,
	-- v1.6
	rewards: { [string]: any }, -- claims, cooldowns, gift cycles, offers, offline earnings, rolls, joins (saved)
	lastSeen: number?, -- os.time() of the last save before this session (offline earnings)
	saveQueued: boolean, -- a claim's save is waiting (saveSoon: one at a time)
}

local releasing: { [number]: boolean } = {} -- userIds whose leave-save is still running here

local function upgradeKey(id: string): string
	return `BloxUI_Upgrade_{id}`
end

local function premiumKey(sectionId: string): string
	return `BloxUI_Premium_{sectionId}`
end

-- What the player has right now. Currencies / upgrades that aren't visible
-- (leaderstats already gone) keep their stored values.
local function liveState(profile: Profile): { [string]: any }
	local player = profile.player
	local state = {
		currencies = table.clone(profile.stored.currencies),
		upgrades = table.clone(profile.stored.upgrades),
		premium = {},
		rewards = deepCopy(profile.rewards),
	}
	for id, def in currencies do
		if def.source.kind ~= "none" and not profile.external[id] then
			local amount = Server.getCurrency(player, id)
			if finite(amount) then
				state.currencies[id] = amount
			end
		end
	end
	for id in upgrades do
		local level = player:GetAttribute(upgradeKey(id))
		if finite(level) then
			state.upgrades[id] = level
		end
	end
	for sectionId in profile.premium do
		state.premium[sectionId] = true
	end
	return state
end

local function ownsSession(stored: any): boolean
	local session = if typeof(stored) == "table" then stored.session else nil
	return typeof(session) == "table" and session.job == jobId()
end

-- Stored receipts + ours, newest RECEIPT_HISTORY kept.
local function mergeReceipts(stored: any, ...: { [string]: number }): { [string]: number }
	local all: { [string]: number } = {}
	if typeof(stored) == "table" then
		for id, at in stored do
			if typeof(id) == "string" and typeof(at) == "number" then
				all[id] = at
			end
		end
	end
	for i = 1, select("#", ...) do
		merge(all, (select(i, ...)))
	end
	local list = {}
	for id, at in all do
		table.insert(list, { id = id, at = at })
	end
	if #list <= Server.RECEIPT_HISTORY then
		return all
	end
	table.sort(list, function(a, b)
		return a.at > b.at
	end)
	local kept = {}
	for i = 1, Server.RECEIPT_HISTORY do
		kept[list[i].id] = list[i].at
	end
	return kept
end

-- The one writer: one UpdateAsync per player at a time. The state is read
-- inside the transform, so a write always carries the newest grants
-- together with their receipt ids. Refused unless this server holds the
-- player's session. `release` gives the session up (leaving).
local function writeProfile(profile: Profile, release: boolean?): boolean
	if not saveData or not profile.loaded or profile.failed or profile.lost then
		return false
	end
	while profile.writing do
		task.wait(0.05)
	end
	if profile.lost or (profile.released and not release) then
		return false
	end
	profile.writing = true
	local lost = false
	local written, writtenPending = nil, nil
	local ok, err = updateKey(Server.DATA_STORE, profile.key, function(old)
		if not ownsSession(old) then
			lost = true
			return nil
		end
		lost = false
		local state = profile.final or liveState(profile)
		local pendingNow = table.clone(profile.pending)
		written, writtenPending = state, pendingNow
		return {
			currencies = state.currencies,
			upgrades = state.upgrades,
			premium = state.premium,
			receipts = mergeReceipts(old.receipts, profile.receipts, pendingNow),
			-- v1.6: claimed rewards and cooldowns travel with the grants they paid
			rewards = state.rewards or old.rewards,
			lastSeen = os.time(),
			session = if release then nil else { job = jobId(), at = os.time() },
		}
	end)
	profile.writing = false
	if ok and lost then
		profile.lost = true
		warnOnce("lost:" .. profile.key, `another server took over player {profile.userId}'s data; this server stops saving it`)
		return false
	end
	if not ok then
		warnOnce("dataSave", `could not save player data ({tostring(err)}); will try again`)
		return false
	end
	local rolls = profile.rewards.rolls
	for id, at in writtenPending do
		profile.receipts[id] = at
		if profile.pending[id] == at then
			profile.pending[id] = nil
		end
		-- v1.6: a Robux spin's saved roll is done once its grant is saved
		if typeof(rolls) == "table" then
			rolls[id] = nil
		end
	end
	profile.stored = { currencies = table.clone(written.currencies), upgrades = table.clone(written.upgrades) }
	profile.lastWrite = os.clock()
	profile.lastJson = HttpService:JSONEncode(written)
	if release then
		profile.released = true
	end
	return true
end

-- Background save (autosave): only when something changed or the lock
-- needs renewing, and within the request budget.
local function autosave(profile: Profile)
	if profile.leaving or profile.writing or not profile.loaded or profile.failed or profile.lost then
		return
	end
	local json = HttpService:JSONEncode(liveState(profile))
	local stale = os.clock() - profile.lastWrite >= Server.SESSION_RENEW
	if (json ~= profile.lastJson or next(profile.pending) ~= nil or stale) and budgetOk() then
		writeProfile(profile)
	end
end

-- Reads stored data into the profile (any finite amount is kept).
local function readStored(profile: Profile, data: any)
	if typeof(data) ~= "table" then
		return
	end
	for _, part in { "currencies", "upgrades" } do
		if typeof(data[part]) == "table" then
			for id, amount in data[part] do
				if typeof(id) == "string" and finite(amount) then
					profile.stored[part][id] = amount
				end
			end
		end
	end
	if typeof(data.premium) == "table" then
		for sectionId, on in data.premium do
			if on == true and typeof(sectionId) == "string" then
				profile.premium[sectionId] = true
			end
		end
	end
	if typeof(data.receipts) == "table" then
		for id, at in data.receipts do
			if typeof(id) == "string" and typeof(at) == "number" then
				profile.receipts[id] = at
				granted[id] = true
				purchaseOwner[id] = profile.userId
			end
		end
	end
	-- v1.6: everything the reward systems remember (read with rewardPart)
	if typeof(data.rewards) == "table" then
		for part, value in data.rewards do
			if typeof(part) == "string" and (typeof(value) == "table" or finite(value)) then
				profile.rewards[part] = deepCopy(value)
			end
		end
	end
	if finite(data.lastSeen) then
		profile.lastSeen = data.lastSeen
	end
end

-- Stored data -> the player's leaderstats / attributes.
local function applyProfile(profile: Profile, data: any)
	readStored(profile, data)
	local player = profile.player
	for id, amount in profile.stored.currencies do
		local def = currencies[id]
		if def and def.source.kind ~= "none" and not profile.external[id] then
			Server.setCurrency(player, id, amount)
		end
	end
	for id, level in profile.stored.upgrades do
		local u = upgrades[id]
		if u then
			player:SetAttribute(upgradeKey(id), math.clamp(math.floor(level), 0, u.maxLevel))
		end
	end
	for sectionId in profile.premium do
		if passes[sectionId] then
			player:SetAttribute(premiumKey(sectionId), true)
		end
	end
end

-- Claims the player's session (waiting for another server to let go, up to
-- LOCK_WAIT, then taking over). ok, stored data | false, reason.
local function claim(profile: Profile): (boolean, any)
	local deadline = os.clock() + Server.LOCK_WAIT
	while true do
		local steal = os.clock() >= deadline
		local locked = false
		local ok, result = updateKey(Server.DATA_STORE, profile.key, function(old)
			local data = if typeof(old) == "table" then old else {}
			local session = data.session
			local busyElsewhere = typeof(session) == "table" and session.job ~= jobId() and typeof(session.at) == "number" and os.time() - session.at < Server.SESSION_TTL
			if busyElsewhere and not steal then
				locked = true
				return nil
			end
			locked = false
			data.session = { job = jobId(), at = os.time() }
			return data
		end)
		if not ok then
			return false, result
		end
		if not locked then
			return true, result
		end
		if profile.leaving or profiles[profile.player] ~= profile then
			return false, "left"
		end
		task.wait(Server.LOCK_RETRY)
	end
end

local function hasGamePass(player: Player, gamePassId: number): boolean
	for attempt = 1, 3 do
		local ok, owns = pcall(function()
			return MarketplaceService:UserOwnsGamePassAsync(player.UserId, gamePassId)
		end)
		if ok then
			return owns == true
		end
		if attempt < 3 then
			task.wait(attempt)
		end
	end
	return false
end

-- Game passes (premium, x2 spin luck, offers): the pass is the truth.
local function checkGamePass(player: Player, gamePassId: number)
	local attributes = gamePasses[gamePassId]
	if attributes and hasGamePass(player, gamePassId) and player.Parent then
		for _, name in attributes do
			player:SetAttribute(name, true)
		end
	end
end

local function newProfile(player: Player, external: { [string]: boolean }): Profile
	return {
		player = player,
		userId = player.UserId,
		key = playerKey(player),
		loaded = false,
		failed = false,
		lost = false,
		leaving = false,
		released = false,
		final = nil,
		stored = { currencies = {}, upgrades = {} },
		external = external,
		premium = {},
		receipts = {},
		pending = {},
		pendingGrants = {},
		writing = false,
		lastWrite = 0,
		lastJson = nil,
		rewards = {},
		lastSeen = nil,
		saveQueued = false,
	}
end

-- v1.6: set up once the saved data is in (Rewards, below)
local startRewards: (Profile) -> ()

local function forget(profile: Profile)
	if profiles[profile.player] == profile then
		profiles[profile.player] = nil
	end
	releasing[profile.userId] = nil
	for id, owner in purchaseOwner do
		if owner == profile.userId then
			purchaseOwner[id] = nil
			granted[id] = nil
			customDone[id] = nil
			applied[id] = nil
			drops[id] = nil
			delivered[id] = nil
		end
	end
end

-- SaveData = false: nothing calls forget(), so a leaving player's finished
-- purchases are dropped here (unfinished ones stay, so a retry never gives
-- twice), and gift cycles that have ended are let go.
local function forgetFinished(userId: number)
	for id, owner in purchaseOwner do
		if owner == userId and granted[id] then
			purchaseOwner[id] = nil
			granted[id] = nil
			customDone[id] = nil
			applied[id] = nil
			drops[id] = nil
			delivered[id] = nil
		end
	end
	local now = os.time()
	for id, store in giftMemory do
		local running = false
		for _, cycle in store do
			if typeof(cycle) == "table" and finite(cycle.ends) and now < cycle.ends then
				running = true
			end
		end
		if not running then
			giftMemory[id] = nil
		end
	end
end

-- Leave-save: frozen state, session released; retried until it works or
-- the server shuts down.
local function release(profile: Profile)
	local delay = 2
	while not profile.released and not profile.lost and not profile.failed and profile.loaded and saveData do
		if writeProfile(profile, true) then
			break
		end
		task.wait(delay)
		delay = math.min(delay * 2, 30)
	end
	forget(profile)
end

local function loadProfile(player: Player, external: { [string]: boolean })
	for gamePassId in gamePasses do
		task.spawn(checkGamePass, player, gamePassId)
	end
	if not saveData or profiles[player] ~= nil then
		return
	end
	if startOptions.SaveData ~= true then
		-- leaderstats made by the game's own data code stay its business
		for id in external do
			warnOnce("external:" .. id, `leaderstats.{currencies[id].source.name} already existed when the player joined, so BloxUI doesn't load or save it (start with \{ SaveData = true \} to change that)`)
		end
	else
		external = {}
	end
	local profile = newProfile(player, external)
	profiles[player] = profile
	task.spawn(function()
		-- a quick rejoin: this server's own leave-save goes first
		local deadline = os.clock() + Server.LOCK_WAIT
		while releasing[profile.userId] and os.clock() < deadline do
			task.wait(0.1)
		end
		local ok, data = claim(profile)
		if profiles[player] ~= profile and not profile.leaving then
			return
		end
		if not ok then
			profile.failed = true
			if profile.leaving then
				releasing[profile.userId] = nil
			end
			if data ~= "left" then
				warnOnce("dataLoad", `could not load player data ({tostring(data)}); nothing is bought or saved for this player this session`)
			end
			return
		end
		if profile.leaving then
			-- left while loading: give the session straight back, unchanged
			readStored(profile, data)
			profile.final = {
				currencies = table.clone(profile.stored.currencies),
				upgrades = table.clone(profile.stored.upgrades),
				premium = table.clone(profile.premium),
				rewards = deepCopy(profile.rewards),
			}
			profile.loaded = true
			releasing[profile.userId] = true
			release(profile)
			return
		end
		applyProfile(profile, data)
		profile.lastWrite = os.clock()
		profile.lastJson = HttpService:JSONEncode(liveState(profile))
		profile.loaded = true
		startRewards(profile)
		-- grants made while loading
		local grants = profile.pendingGrants
		profile.pendingGrants = {}
		for _, grant in grants do
			Server.grant(player, grant.id, grant.amount)
		end
		if #grants > 0 then
			task.spawn(writeProfile, profile)
		end
	end)
end

local function unloadProfile(player: Player)
	local profile = profiles[player]
	if profile == nil then
		return
	end
	profile.leaving = true
	profiles[player] = nil
	if not profile.loaded and not profile.failed then
		releasing[profile.userId] = true
		return -- the loader gives the session back when it finishes
	end
	if profile.failed or profile.lost then
		forget(profile)
		return
	end
	-- freeze what the player has now: leaderstats disappear with the player
	profile.final = liveState(profile)
	releasing[profile.userId] = true
	task.spawn(release, profile)
end

-- Waits (up to `timeout` seconds) for a player's data to load.
local function waitForProfile(player: Player, timeout: number): Profile?
	local deadline = os.clock() + timeout
	while true do
		local profile = profiles[player]
		if profile == nil or profile.loaded or profile.failed then
			return profile
		end
		if os.clock() >= deadline then
			return nil
		end
		task.wait(0.1)
	end
end

--------------------------------------------------------------------------
-- Buying with in-game currency
--------------------------------------------------------------------------

local function upgradePrice(u: any, level: number): number
	local price = math.ceil(u.cost)
	for _ = u.level + 1, level do
		price = math.ceil(price * 1.6)
	end
	return price
end

-- A drop from a case pool, weighted by `chance`, with the server's Random.
local function rollCase(c: any): any
	local pool = c.pool or {}
	local total = 0
	for _, drop in pool do
		total += math.max(0, drop.chance or 1)
	end
	local roll = rng:NextNumber() * math.max(total, 1e-6)
	for _, drop in pool do
		roll -= math.max(0, drop.chance or 1)
		if roll <= 0 then
			return drop
		end
	end
	return pool[#pool]
end
Server.rollCase = rollCase

-- ok, reason ("loading" | "unavailable" | "unknown" | "blocked" | "maxed" |
-- "funds" | "failed"), data ({ won } for cases)
local function handleBuy(player: Player, itemId: any, payload: any): (boolean, string?, any)
	if saveData then
		local profile = profiles[player]
		if profile == nil or not profile.loaded then
			return false, "loading"
		end
		if profile.failed or profile.lost or profile.leaving then
			return false, "unavailable"
		end
	end
	if typeof(itemId) ~= "string" then
		return false, "unknown"
	end
	local section = if typeof(payload) == "table" and typeof(payload.section) == "string" then payload.section else ""
	local item = shopItems[`{section}/{itemId}`]
	if item == nil then
		return false, "unknown"
	end
	local canBuy = Server.canBuy
	if typeof(canBuy) == "function" then
		local ok, allowedToBuy = pcall(canBuy, player, section, itemId)
		if not ok or allowedToBuy == false then
			return false, "blocked"
		end
	end
	local price = item.price
	local levelKey = nil
	local level = 0
	if item.upgrade then
		levelKey = upgradeKey(item.id)
		local stored = player:GetAttribute(levelKey)
		level = if finite(stored) then stored else item.upgrade.level
		if level >= item.upgrade.maxLevel then
			return false, "maxed"
		end
		price = upgradePrice(item.upgrade, level)
	end
	local balance = Server.getCurrency(player, item.currency)
	if balance == nil or balance < price then
		return false, "funds"
	end
	if not Server.grant(player, item.currency, -price) then
		return false, "failed"
	end
	local function refund()
		Server.grant(player, item.currency, price)
	end
	if item.grant and not Server.grant(player, item.grant.currency, item.grant.amount) then
		refund()
		return false, "failed"
	end
	local data = nil
	local event = { section = section, item = itemId, price = price }
	if item.case then
		local drop = rollCase(item.case)
		if not deliverDrop(player, drop, { case = item.id, section = section }) then
			if item.grant then
				Server.grant(player, item.grant.currency, -item.grant.amount)
			end
			refund()
			return false, "failed"
		end
		data = { won = drop and drop.name }
		emit("CaseOpened", player, item.id, { case = item.id, section = section, item = deepCopy(drop) })
	end
	if levelKey then
		player:SetAttribute(levelKey, level + 1)
	end
	emit(Server.BUY, player, itemId, event)
	emit("Bought:" .. itemId, player, itemId, event)
	return true, nil, data
end

--------------------------------------------------------------------------
-- Rewards (v1.6): spin wheels, playtime gifts, index milestones,
-- achievements, social rewards, offers and offline earnings. A client only
-- asks ("BloxUI.Claim"); every rule is checked here, with the server's own
-- clock, Random, attributes and saved data.
--------------------------------------------------------------------------

Server.GROUP_RETRIES = 3 -- IsInGroup attempts before a group reward gives up
Server.MAX_OWNED = 5000 -- ids Server.setIndexOwned keeps per player
Server.GIFT_CYCLE = Schema.GIFT_CYCLE_HOURS * 3600 -- claimed gifts stay claimed this long (saved)
Server.SPIN_MAX_RETURN = Schema.SPIN_MAX_RETURN -- a paid spin paying back more is off
Server.INVITE_MIN_AGE = 14 -- days: younger accounts don't pay their inviter
Server.INVITE_MIN_PLAY = 180 -- seconds a friend must play before their invite pays
Server.CLAIM_SAVE_DELAY = 7 -- claims are saved together, at most this often per player

type Session = {
	joined: number, -- os.clock() when this server saw the player join (playtime)
	wall: number, -- the same moment as os.time() (the client's countdowns)
	claiming: boolean, -- one claim at a time (a group check yields)
}

local sessions: { [Player]: Session } = {}
local ownedItems: { [Player]: { [string]: boolean } } = {}

-- This session (made on first use; nil once the player has left).
local function sessionOf(player: Player): Session?
	local session = sessions[player]
	if session == nil then
		if player.Parent == nil then
			return nil
		end
		session = { joined = os.clock(), wall = os.time(), claiming = false }
		sessions[player] = session
	end
	return session
end

-- One part of the saved rewards (made on first use; anything stored that
-- isn't a table is replaced).
local function rewardPart(profile: Profile, name: string): { [string]: any }
	local part = profile.rewards[name]
	if typeof(part) ~= "table" then
		part = {}
		profile.rewards[name] = part
	end
	return part
end

local function setAttribute(player: Player, name: string, value: any)
	pcall(function()
		player:SetAttribute(name, value)
	end)
end

-- Bit masks of claimed rewards (index 1..31).
local function maskOf(value: any): number
	return if finite(value) and value >= 0 and value < 2 ^ 31 then math.floor(value) else 0
end
local function hasBit(mask: number, index: number): boolean
	return bit32.btest(mask, bit32.lshift(1, index - 1))
end
local function withBit(mask: number, index: number): number
	return bit32.bor(mask, bit32.lshift(1, index - 1))
end
local function withoutBit(mask: number, index: number): number
	return bit32.band(mask, bit32.bnot(bit32.lshift(1, index - 1)))
end

-- A claim is saved soon after it's given (the one writer, as ever). Claims
-- close together share one save (at most one waiting per player), so a
-- flood of claims can't pile up saves.
local function saveSoon(profile: Profile?)
	if not saveData or profile == nil or profile.saveQueued then
		return
	end
	profile.saveQueued = true
	local myGeneration = generation
	task.delay(math.max(0, profile.lastWrite + Server.CLAIM_SAVE_DELAY - os.clock()), function()
		profile.saveQueued = false
		-- (leaving: the leave-save carries it; a stopped server saves nothing more)
		if generation == myGeneration and profiles[profile.player] == profile and not profile.released and not profile.lost and not profile.leaving then
			writeProfile(profile)
		end
	end)
end

-- Claims kept by a stable key (a milestone's percent, a social reward's
-- kind), so reordering or adding rewards never opens a claimed one again.
-- The first time, an old bit mask (list positions) is moved over.
local function claimedSet(profile: Profile, part: string, s: any, list: { any }, keyOf: (any) -> string): { [string]: boolean }
	local all = rewardPart(profile, part .. "By")
	local mine = all[s.id]
	if typeof(mine) ~= "table" then
		mine = {}
		local old = maskOf(rewardPart(profile, part)[s.id])
		for index, entry in list do
			if hasBit(old, index) then
				mine[keyOf(entry)] = true
			end
		end
		all[s.id] = mine
	end
	return mine
end

-- The claimed set as the bit mask the client reads (list positions).
local function maskFor(mine: { [string]: boolean }, list: { any }, keyOf: (any) -> string): number
	local mask = 0
	for index, entry in list do
		if mine[keyOf(entry)] then
			mask = withBit(mask, index)
		end
	end
	return mask
end

local function milestoneKey(m: any): string
	return tostring(m.percent)
end
local function socialKey(r: any): string
	return r.kind
end

-- The running gift cycle of a Gifts section ({ mask, ends }): saved with the
-- player (or remembered by this server with SaveData = false). A new one
-- starts on the first claim after the last one ended.
local function giftCycle(player: Player, profile: Profile?, sectionId: string, start: boolean): { [string]: any }?
	local store
	if profile then
		store = rewardPart(profile, "gifts")
	else
		store = giftMemory[player.UserId]
		if store == nil then
			store = {}
			giftMemory[player.UserId] = store
		end
	end
	local cycle = store[sectionId]
	local now = os.time()
	if typeof(cycle) ~= "table" or not finite(cycle.ends) or now >= cycle.ends then
		if not start then
			return nil
		end
		cycle = { mask = 0, ends = now + Server.GIFT_CYCLE }
		store[sectionId] = cycle
	end
	return cycle
end

-- Shows the running gift cycle's claimed boxes, and clears them when it ends.
local function publishGifts(player: Player, profile: Profile?, sectionId: string)
	local cycle = giftCycle(player, profile, sectionId, false)
	setAttribute(player, ATTR.gifts .. sectionId, if cycle then maskOf(cycle.mask) else 0)
	if cycle then
		task.delay(math.max(0, cycle.ends - os.time()) + 1, function()
			if player.Parent ~= nil and sessions[player] ~= nil and giftCycle(player, profile, sectionId, false) == nil then
				setAttribute(player, ATTR.gifts .. sectionId, 0)
			end
		end)
	end
end

-- Gives a reward: its `grant` here (a currency the server owns), anything
-- else through onSpinPrize / onReward. false = nothing was given.
local function givePrize(player: Player, prize: any, info: { [string]: any }): boolean
	if typeof(prize) ~= "table" then
		return true
	end
	if prize.grant ~= nil then
		local grant = checkGrant(prize.grant, `{info.kind} reward in "{tostring(info.section)}"`)
		if grant == nil then
			return false
		end
		return Server.grant(player, grant.currency, grant.amount)
	end
	return deliverPrize(player, prize, info)
end

-- The wedge a spin lands on: weighted by `chance` with the server's Random.
-- The x2 luck pass (BloxUI_SpinLuck_<section>, set here from the game pass)
-- doubles the weight of Epic and rarer wedges.
local function rollSpin(player: Player?, s: any): number
	local luck = player ~= nil and s.luckGamePassId ~= nil and player:GetAttribute(ATTR.spinLuck .. s.id) == true
	local weights = {}
	local total = 0
	for index, wedge in s.wedges do
		local weight = math.max(0, if finite(wedge.chance) then wedge.chance else 0)
		if luck and (Schema.RARITY_RANK[wedge.rarity] or 1) >= 4 then
			weight *= 2
		end
		weights[index] = weight
		total += weight
	end
	if total <= 0 then
		return rng:NextInteger(1, #s.wedges)
	end
	local roll = rng:NextNumber() * total
	local last = 1
	for index, weight in weights do
		if weight > 0 then
			last = index
			roll -= weight
			if roll <= 0 then
				return index
			end
		end
	end
	return last
end
Server.rollSpin = rollSpin

local function publishSpin(profile: Profile, s: any)
	if s.freeHours > 0 then
		local last = rewardPart(profile, "spin")[s.id]
		setAttribute(profile.player, ATTR.spinNext .. s.id, if finite(last) then last + s.freeHours * 3600 else 0)
	end
end

-- Items of an Index section the player has, and how many there are.
local function indexProgress(player: Player, s: any): (number, number)
	local total, found = 0, 0
	local owned = ownedItems[player]
	for _, group in s.groups do
		for _, item in group.items do
			total += 1
			if owned and owned[item.id] then
				found += 1
			end
		end
	end
	if owned == nil then
		-- the game counts discoveries itself: BloxUI_Index_<section> on the server
		local count = player:GetAttribute(ATTR.indexCount .. s.id)
		found = if finite(count) then math.clamp(math.floor(count), 0, total) else 0
	end
	return found, total
end

-- Is the player in the group? true / false, or nil when Roblox couldn't say.
local function inGroup(player: Player, groupId: number): boolean?
	for attempt = 1, Server.GROUP_RETRIES do
		local ok, result = pcall(function()
			return player:IsInGroup(groupId)
		end)
		if ok then
			if result == true then
				return true
			end
			break
		end
		if attempt < Server.GROUP_RETRIES then
			task.wait(attempt)
		end
	end
	-- IsInGroup failed or said no: the rank is a second opinion
	local ok, rank = pcall(function()
		return player:GetRankInGroup(groupId)
	end)
	if ok then
		return typeof(rank) == "number" and rank > 0
	end
	return nil
end

local function stillHere(player: Player, profile: Profile?): boolean
	if player.Parent == nil then
		return false
	end
	if saveData then
		return profile ~= nil and profiles[player] == profile and profile.loaded and not profile.leaving and not profile.lost
	end
	return true
end

-- One handler per reward kind: (player, profile, session, def, payload) ->
-- ok, reason, data. Everything a client sends is checked first.
local CLAIMS: { [string]: (Player, Profile?, Session, any, any) -> (boolean, string?, any) } = {}

CLAIMS.Spin = function(player, profile, _session, s, payload)
	local p = profile :: Profile
	local mode = payload.mode
	local spins = rewardPart(p, "spin")
	local previous = spins[s.id]
	local charged = nil
	if mode == "free" then
		if s.freeHours <= 0 then
			return false, "unknown"
		end
		local nextAt = if finite(previous) then previous + s.freeHours * 3600 else 0
		if os.time() < nextAt then
			return false, "cooldown", { next = nextAt }
		end
		-- taken before anything is given, put back if giving fails
		spins[s.id] = os.time()
	elseif mode == "paid" then
		if s.price == nil or s.currency == nil or s.currency == "robux" then
			return false, "unknown" -- Robux spins go through ProcessReceipt
		end
		if paidSpinOff[s.id] then
			return false, "blocked" -- it would pay back more than it costs
		end
		local canBuy = Server.canBuy
		if typeof(canBuy) == "function" then
			local ok, allowedToBuy = pcall(canBuy, player, s.id, "spin")
			if not ok or allowedToBuy == false then
				return false, "blocked"
			end
		end
		local price = math.ceil(s.price)
		local balance = Server.getCurrency(player, s.currency)
		if balance == nil or balance < price then
			return false, "funds"
		end
		if not Server.grant(player, s.currency, -price) then
			return false, "failed"
		end
		charged = price
	else
		return false, "unknown"
	end
	local index = rollSpin(player, s)
	local wedge = s.wedges[index]
	local spinId = HttpService:GenerateGUID(false)
	if not givePrize(player, wedge, { kind = "Spin", section = s.id, index = index, spinId = spinId, mode = mode }) then
		spins[s.id] = if mode == "free" then previous else spins[s.id]
		if charged then
			Server.grant(player, s.currency, charged)
		end
		return false, "failed"
	end
	publishSpin(p, s)
	saveSoon(p)
	emit(Server.REWARD, player, s.id, { kind = "Spin", section = s.id, index = index, prize = deepCopy(wedge), spinId = spinId, mode = mode })
	local last = spins[s.id]
	return true, nil, { index = index, next = if s.freeHours > 0 and finite(last) then last + s.freeHours * 3600 else nil }
end

CLAIMS.Gifts = function(player, profile, session, s, payload)
	local item = payload.item
	local wanted = {}
	if item == "all" then
		for index in s.gifts do
			table.insert(wanted, index)
		end
	elseif typeof(item) == "number" and item == math.floor(item) and s.gifts[item] then
		wanted = { item }
	else
		return false, "unknown"
	end
	-- the timers count this session's playtime; what was claimed stays
	-- claimed for the whole cycle (saved), so rejoining gives nothing back
	local cycle = giftCycle(player, profile, s.id, true) :: { [string]: any }
	local mask = maskOf(cycle.mask)
	local playtime = os.clock() - session.joined
	local claimed = {}
	local early = false
	for _, index in wanted do
		local gift = s.gifts[index]
		if hasBit(mask, index) then
			continue
		end
		if playtime < gift.minutes * 60 then
			early = true
			continue
		end
		mask = withBit(mask, index) -- first, so a slow handler can't be raced
		cycle.mask = mask
		if givePrize(player, gift, { kind = "Gift", section = s.id, id = gift.id, claimId = `gift:{s.id}:{gift.id}:{cycle.ends}` }) then
			table.insert(claimed, index)
		else
			mask = withoutBit(mask, index)
			cycle.mask = mask
		end
	end
	if #claimed == 0 then
		return false, if early then "early" else "claimed"
	end
	publishGifts(player, profile, s.id)
	saveSoon(profile)
	emit(Server.REWARD, player, s.id, { kind = "Gift", section = s.id, gifts = claimed })
	return true, nil, { claimed = claimed, mask = mask }
end

CLAIMS.Index = function(player, profile, _session, s, payload)
	local p = profile :: Profile
	local index = payload.item
	if typeof(index) ~= "number" or s.milestones[index] == nil then
		return false, "unknown"
	end
	local milestone = s.milestones[index]
	-- claimed by percent, never by list position
	local mine = claimedSet(p, "index", s, s.milestones, milestoneKey)
	local key = milestoneKey(milestone)
	if mine[key] then
		return false, "claimed"
	end
	local found, total = indexProgress(player, s)
	if total == 0 or found * 100 < milestone.percent * total then
		return false, "locked", { found = found, total = total }
	end
	mine[key] = true
	if not givePrize(player, milestone, { kind = "Index", section = s.id, id = milestone.percent, claimId = `index:{s.id}:{milestone.percent}` }) then
		mine[key] = nil
		return false, "failed"
	end
	local mask = maskFor(mine, s.milestones, milestoneKey)
	setAttribute(player, ATTR.indexClaimed .. s.id, mask)
	saveSoon(p)
	emit(Server.REWARD, player, s.id, { kind = "Index", section = s.id, percent = milestone.percent })
	return true, nil, { mask = mask }
end

CLAIMS.Achievements = function(player, profile, _session, s, payload)
	local p = profile :: Profile
	local id = payload.item
	local entry = if typeof(id) == "string" then achievementIndex[id] else nil
	if entry == nil or entry.section ~= s.id then
		return false, "unknown"
	end
	local a = entry.def
	local done = rewardPart(p, "ach")
	if finite(done[id]) then
		return false, "claimed"
	end
	-- the game sets progress on the server; a client's own attribute never counts
	local progress = player:GetAttribute(ATTR.achievement .. id)
	if not finite(progress) or progress < a.goal then
		return false, "locked"
	end
	local now = os.time()
	done[id] = now
	if a.reward and not givePrize(player, a.reward, { kind = "Achievement", section = s.id, id = id, claimId = `ach:{id}` }) then
		done[id] = nil
		return false, "failed"
	end
	setAttribute(player, ATTR.achievementDone .. id, now)
	saveSoon(p)
	emit(Server.REWARD, player, s.id, { kind = "Achievement", section = s.id, id = id, points = a.points })
	return true, nil, { at = now }
end

CLAIMS.Social = function(player, profile, session, s, payload)
	local p = profile :: Profile
	local reward = nil
	for _, r in s.rewards do
		if r.id == payload.item then
			reward = r
		end
	end
	-- invites pay by themselves when a friend joins through one
	if reward == nil or reward.kind == "Invite" then
		return false, "unknown"
	end
	-- claimed by kind, never by list position
	local mine = claimedSet(p, "social", s, s.rewards, socialKey)
	local key = socialKey(reward)
	if mine[key] then
		return false, "claimed"
	end
	if reward.kind == "Group" then
		if s.groupId == nil then
			return false, "unknown"
		end
		local member = inGroup(player, s.groupId)
		if not stillHere(player, profile) then
			return false, "unavailable"
		end
		if member == nil then
			return false, "failed"
		elseif not member then
			return false, "notInGroup"
		end
		if mine[key] then -- re-read after the wait
			return false, "claimed"
		end
	elseif reward.kind == "Like" then
		-- a like can't be verified: a one-time thank-you after some playtime
		local wait = (reward.wait or 60) - (os.clock() - session.joined)
		if wait > 0 then
			return false, "cooldown", { wait = math.ceil(wait) }
		end
	end
	mine[key] = true
	if not givePrize(player, reward.reward, { kind = "Social", section = s.id, id = reward.id, claimId = `social:{s.id}:{reward.kind}` }) then
		mine[key] = nil
		return false, "failed"
	end
	local mask = maskFor(mine, s.rewards, socialKey)
	setAttribute(player, ATTR.social .. s.id, mask)
	saveSoon(p)
	emit(Server.REWARD, player, s.id, { kind = "Social", section = s.id, id = reward.id })
	return true, nil, { mask = mask }
end

-- Offline earnings waiting to be claimed ({ amount, seconds }), saved with
-- the player so leaving without claiming loses nothing.
local function waitingOffline(profile: Profile, id: string): { [string]: any }?
	local pending = rewardPart(profile, "offline")[id]
	if typeof(pending) == "table" and finite(pending.amount) and pending.amount > 0 then
		return pending
	end
	return nil
end

local function publishOffline(profile: Profile, screen: any)
	local pending = waitingOffline(profile, screen.id)
	setAttribute(profile.player, ATTR.offlineAmount .. screen.id, if pending then pending.amount else nil)
	setAttribute(profile.player, ATTR.offlineSeconds .. screen.id, if pending then pending.seconds else nil)
end

-- Offline earnings per minute for this player: Server.offlineRate, else the screen's.
local function offlineRateOf(player: Player, screen: any): number
	local rate = screen.rate
	local hook = Server.offlineRate
	if typeof(hook) == "function" then
		local ok, custom = pcall(hook, player, deepCopy(screen))
		if ok and finite(custom) and custom >= 0 then
			rate = custom
		end
	end
	return rate
end

CLAIMS.OfflineEarnings = function(player, profile, _session, screen, _payload)
	local p = profile :: Profile
	local pending = waitingOffline(p, screen.id)
	if pending == nil then
		return false, "none"
	end
	if screen.currency == nil then
		return false, "failed"
	end
	local waiting = rewardPart(p, "offline")
	waiting[screen.id] = nil
	if not Server.grant(player, screen.currency, pending.amount) then
		waiting[screen.id] = pending
		return false, "failed"
	end
	-- what the x2 product doubles once it's been claimed
	rewardPart(p, "offlineClaimed")[screen.id] = pending.amount
	publishOffline(p, screen)
	saveSoon(p)
	emit(Server.REWARD, player, screen.id, { kind = "OfflineEarnings", section = screen.id, amount = pending.amount })
	return true, nil, { amount = pending.amount }
end

-- ok, reason ("loading" | "unavailable" | "unknown" | "busy" | "cooldown" |
-- "early" | "claimed" | "locked" | "notInGroup" | "funds" | "blocked" |
-- "none" | "failed"), data
local function handleClaim(player: Player, elementId: any, payload: any): (boolean, string?, any)
	local args = if typeof(payload) == "table" then payload else {}
	local id = if typeof(args.section) == "string" then args.section else elementId
	local entry = if typeof(id) == "string" then rewardSections[id] else nil
	if entry == nil then
		return false, "unknown"
	end
	local profile = nil
	if saveData then
		profile = profiles[player]
		if profile == nil or not profile.loaded then
			return false, "loading"
		end
		if profile.failed or profile.lost or profile.leaving then
			return false, "unavailable"
		end
	elseif entry.kind ~= "Gifts" then
		warnOnce("rewardsNoSave", "SaveData = false: only playtime gifts can be claimed; spins, milestones, achievements, social rewards and offline earnings are remembered with the player's data")
		return false, "unavailable"
	end
	local session = sessionOf(player)
	if session == nil then
		return false, "unavailable" -- left while the claim was on its way
	end
	if session.claiming then
		return false, "busy"
	end
	session.claiming = true
	local ok, claimed, reason, data = pcall(CLAIMS[entry.kind], player, profile, session, entry.def, args)
	session.claiming = false
	if not ok then
		warnOnce("claim:" .. tostring(claimed), `a {entry.kind} claim failed: {claimed}`)
		return false, "failed"
	end
	return claimed, reason, data
end

-- The ids a player owns, for Index sections (colour vs silhouette) and their
-- milestones. Call it on join with the game's own data and again whenever
-- something new is found. Returns false for bad arguments.
function Server.setIndexOwned(player: Player, ids: { any }): boolean
	if typeof(player) ~= "Instance" or not player:IsA("Player") or typeof(ids) ~= "table" then
		return false
	end
	if player.Parent == nil or sessions[player] == nil then
		return false -- (left already: nothing kept for them)
	end
	local set: { [string]: boolean } = {}
	local count = 0
	for _, id in ids do
		if typeof(id) == "string" and #id <= 64 and not set[id] then
			set[id] = true
			count += 1
			if count >= Server.MAX_OWNED then
				break
			end
		end
	end
	ownedItems[player] = set
	local shown = {}
	for sectionId, entry in rewardSections do
		if entry.kind == "Index" then
			local found = 0
			for _, group in entry.def.groups do
				for _, item in group.items do
					if set[item.id] then
						found += 1
						shown[item.id] = true
					end
				end
			end
			setAttribute(player, ATTR.indexCount .. sectionId, found)
		end
	end
	-- only ids an Index shows travel to the client
	local list = {}
	for id in shown do
		table.insert(list, id)
	end
	table.sort(list)
	setAttribute(player, ATTR.owned, HttpService:JSONEncode(list))
	return true
end

-- A friend who joined through this player's invite pays the Invite reward,
-- once, when they are: a real friend of the inviter (IsFriendsWith), an
-- account at least INVITE_MIN_AGE days old, here for the first time (and
-- invited by nobody before: saved as `referredBy` in their own data), and
-- still playing after INVITE_MIN_PLAY seconds. Up to the reward's `limit`
-- friends per inviter; one reward per friend whatever the Social sections.
local function payReferral(player: Player)
	local ok, joinData = pcall(function()
		return (player :: any):GetJoinData()
	end)
	local referrerId = if ok and typeof(joinData) == "table" then joinData.ReferredByPlayerId else nil
	if typeof(referrerId) ~= "number" or referrerId <= 0 or referrerId == player.UserId or not saveData then
		return
	end
	-- a fresh alt account pays nothing
	local okAge, age = pcall(function()
		return (player :: any).AccountAge
	end)
	if not okAge or not finite(age) or age < Server.INVITE_MIN_AGE then
		return
	end
	-- only a first visit counts, and only for one inviter ever
	local own = waitForProfile(player, Server.LOCK_WAIT + 15)
	if own == nil or not own.loaded or own.failed or own.lost or own.leaving then
		return
	end
	if own.rewards.referredBy ~= nil or (finite(own.rewards.joins) and own.rewards.joins > 1) then
		return
	end
	own.rewards.referredBy = referrerId
	saveSoon(own)
	-- they have to actually play
	task.wait(Server.INVITE_MIN_PLAY)
	if not stillHere(player, own) then
		return
	end
	local referrer = nil
	for _, p in Players:GetPlayers() do
		if p.UserId == referrerId then
			referrer = p
		end
	end
	if referrer == nil then
		return
	end
	local okFriend, friends = pcall(function()
		return (referrer :: any):IsFriendsWith(player.UserId)
	end)
	if not okFriend or friends ~= true then
		return
	end
	local profile = profiles[referrer]
	if profile == nil or not profile.loaded or profile.failed or profile.lost or profile.leaving then
		return
	end
	local sectionIds = {}
	for sectionId, entry in rewardSections do
		if entry.kind == "Social" then
			table.insert(sectionIds, sectionId)
		end
	end
	table.sort(sectionIds)
	local key = tostring(player.UserId)
	for _, sectionId in sectionIds do
		for _, r in rewardSections[sectionId].def.rewards do
			if r.kind ~= "Invite" then
				continue
			end
			local invites = rewardPart(profile, "invites")
			local record = invites[sectionId]
			if typeof(record) ~= "table" then
				record = {}
				invites[sectionId] = record
			end
			local count = 0
			for _ in record do
				count += 1
			end
			if record[key] ~= nil or count >= (r.limit or 10) then
				return
			end
			record[key] = os.time()
			if givePrize(referrer, r.reward, { kind = "Social", section = sectionId, id = r.id, claimId = `invite:{sectionId}:{key}` }) then
				setAttribute(referrer, ATTR.invites .. sectionId, count + 1)
				saveSoon(profile)
				emit(Server.REWARD, referrer, sectionId, { kind = "Social", section = sectionId, id = r.id, friend = player.UserId })
			else
				record[key] = nil
			end
			return -- one reward per friend
		end
	end
end

-- Once a player's data is in: offline earnings, the offers to show this
-- session, and every claimed reward as the attributes the client reads.
startRewards = function(profile: Profile)
	local player = profile.player
	local now = os.time()
	local joins = (if finite(profile.rewards.joins) then profile.rewards.joins else 0) + 1
	profile.rewards.joins = joins
	-- Robux spin rolls whose purchase never completed (Roblox gave up)
	local rolls = rewardPart(profile, "rolls")
	for purchaseId, roll in rolls do
		if typeof(roll) ~= "table" or not finite(roll.at) or now - roll.at > 30 * 86400 then
			rolls[purchaseId] = nil
		end
	end
	for sectionId, entry in rewardSections do
		local def = entry.def
		if entry.kind == "Spin" then
			publishSpin(profile, def)
		elseif entry.kind == "Gifts" then
			-- the running cycle's claimed boxes (a finished one is dropped)
			local cycles = rewardPart(profile, "gifts")
			if giftCycle(player, profile, sectionId, false) == nil then
				cycles[sectionId] = nil
			end
			publishGifts(player, profile, sectionId)
		elseif entry.kind == "Index" then
			local mask = maskFor(claimedSet(profile, "index", def, def.milestones, milestoneKey), def.milestones, milestoneKey)
			if mask > 0 then
				setAttribute(player, ATTR.indexClaimed .. sectionId, mask)
			end
		elseif entry.kind == "Achievements" then
			local done = rewardPart(profile, "ach")
			for _, a in def.achievements do
				if finite(done[a.id]) then
					setAttribute(player, ATTR.achievementDone .. a.id, done[a.id])
				end
			end
		elseif entry.kind == "Social" then
			local mask = maskFor(claimedSet(profile, "social", def, def.rewards, socialKey), def.rewards, socialKey)
			if mask > 0 then
				setAttribute(player, ATTR.social .. sectionId, mask)
			end
			local record = rewardPart(profile, "invites")[sectionId]
			if typeof(record) == "table" and next(record) ~= nil then
				local count = 0
				for _ in record do
					count += 1
				end
				setAttribute(player, ATTR.invites .. sectionId, count)
			end
		elseif entry.kind == "OfflineEarnings" then
			-- time away since the last save, added to anything still waiting
			-- (never more than capHours' worth in all)
			local last = profile.lastSeen
			local away = if last then now - last else 0
			if away >= def.minMinutes * 60 and def.currency ~= nil then
				local rate = offlineRateOf(player, def)
				local waiting = rewardPart(profile, "offline")
				local before = waitingOffline(profile, sectionId)
				local capSeconds = def.capHours * 3600
				local seconds = math.min((if before and finite(before.seconds) then before.seconds else 0) + away, capSeconds)
				local amount = math.min(
					(if before then before.amount else 0) + math.floor(rate * math.min(away, capSeconds) / 60),
					math.floor(rate * capSeconds / 60)
				)
				if amount > 0 then
					waiting[sectionId] = { amount = amount, seconds = math.floor(seconds) }
					rewardPart(profile, "offlineClaimed")[sectionId] = nil
					saveSoon(profile)
				end
			end
			publishOffline(profile, def)
		end
	end
	-- offers: owned ones never show; the rest by their `show` rule, and at
	-- most one a session (a one-time starter pack first), like top games
	local offers = rewardPart(profile, "offers")
	local candidates = {}
	for id, screen in offerScreens do
		local record = offers[id]
		if typeof(record) ~= "table" then
			record = nil
		end
		local owned = record ~= nil and record.bought == true
		if owned then
			setAttribute(player, ATTR.offerOwned .. id, true)
		end
		local first = if record and finite(record.first) then record.first else nil
		local expired = screen.timer ~= nil and first ~= nil and now >= first + screen.timer
		local show = false
		if not owned and not expired then
			if screen.show == "Session" then
				show = true
			elseif screen.show == "Once" then
				show = not (record and record.seen == true)
			elseif screen.show == "Joins" then
				show = (joins - 1) % (screen.every or 3) == 0
			end
		end
		if show then
			table.insert(candidates, { id = id, once = screen.show == "Once" })
		end
		if record and screen.timer and first then
			setAttribute(player, ATTR.offerEnds .. id, first + screen.timer)
		end
	end
	table.sort(candidates, function(a, b)
		if a.once ~= b.once then
			return a.once
		end
		return a.id < b.id
	end)
	local pick = candidates[1]
	if pick then
		local record = offers[pick.id]
		if typeof(record) ~= "table" then
			record = {}
			offers[pick.id] = record
		end
		record.seen = true
		record.first = if finite(record.first) then record.first else now
		local screen = offerScreens[pick.id]
		if screen.timer then
			setAttribute(player, ATTR.offerEnds .. pick.id, record.first + screen.timer)
		end
	end
	setAttribute(player, ATTR.showOffers, if pick then pick.id else "")
end

--------------------------------------------------------------------------
-- Receipts
--------------------------------------------------------------------------

local function playerFromId(userId: number): Player?
	local ok, player = pcall(function()
		return Players:GetPlayerByUserId(userId)
	end)
	if ok and player then
		return player
	end
	for _, p in Players:GetPlayers() do
		if p.UserId == userId then
			return p
		end
	end
	return nil
end

local function grantReceipt(player: Player, receiptInfo: { [string]: any }): Enum.ProductPurchaseDecision
	local decision = Enum.ProductPurchaseDecision
	local purchaseId = tostring(receiptInfo.PurchaseId)
	if granted[purchaseId] then
		return decision.PurchaseGranted
	end
	local productId = receiptInfo.ProductId
	local custom = receiptHandlers[productId]
	local product = products[productId]
	if custom == nil and product == nil then
		local fallback = Server.fallbackReceipt
		if typeof(fallback) == "function" then
			local ok, result = pcall(fallback, receiptInfo)
			return if ok and result == decision.PurchaseGranted then result else decision.NotYetProcessed
		end
		warnOnce("product:" .. tostring(productId), `no blueprint product with productId {productId}; add UI.Server.onReceipt for it`)
		return decision.NotYetProcessed
	end

	-- the player's data must be loaded, owned by this server and not leaving
	local profile = nil
	if saveData then
		profile = waitForProfile(player, 10)
		if profile == nil or not profile.loaded or profile.failed or profile.lost or profile.leaving then
			return decision.NotYetProcessed
		end
		if profile.receipts[purchaseId] then
			granted[purchaseId] = true
			return decision.PurchaseGranted
		end
	elseif player.Parent == nil then
		return decision.NotYetProcessed
	end

	local firstTime = not applied[purchaseId]
	if firstTime then
		-- Robux case: rolled once per purchase, delivered before anything else
		if product and product.case and not delivered[purchaseId] then
			local drop = drops[purchaseId] or rollCase(product.case)
			drops[purchaseId] = drop
			purchaseOwner[purchaseId] = player.UserId
			if not deliverDrop(player, drop, { case = product.item.id, section = product.section, purchaseId = purchaseId }) then
				return decision.NotYetProcessed
			end
			delivered[purchaseId] = true
		end
		-- v1.6 Robux spin: rolled once per purchase and saved with the player
		-- before anything is delivered, so every retry (after a rejoin, or in
		-- another server) lands on the same wedge; a prize without a grant is
		-- delivered before anything else
		local spin = product and product.spin and rewardSections[product.spin]
		if spin and not delivered[purchaseId] then
			local roll = drops[purchaseId]
			if roll == nil then
				local saved = if profile then rewardPart(profile, "rolls")[purchaseId] else nil
				if typeof(saved) == "table" and finite(saved.spin) and spin.def.wedges[saved.spin] then
					roll = { spin = saved.spin }
				else
					roll = { spin = rollSpin(player, spin.def) }
					if profile then
						rewardPart(profile, "rolls")[purchaseId] = { spin = roll.spin, at = os.time() }
						drops[purchaseId] = roll
						purchaseOwner[purchaseId] = player.UserId
						if not writeProfile(profile) then
							return decision.NotYetProcessed
						end
					end
				end
				drops[purchaseId] = roll
			end
			purchaseOwner[purchaseId] = player.UserId
			local wedge = spin.def.wedges[roll.spin]
			if wedge and wedge.grant == nil then
				if not deliverPrize(player, wedge, { kind = "Spin", section = spin.def.id, index = roll.spin, spinId = purchaseId, mode = "robux", purchaseId = purchaseId }) then
					return decision.NotYetProcessed
				end
			end
			delivered[purchaseId] = true
		end
		-- v1.6 offer: what the server can't pay itself (pets, boosts...) goes
		-- to onReward first; the purchase waits until every item is given
		if product and product.extras and #product.extras > 0 and not delivered[purchaseId] then
			purchaseOwner[purchaseId] = player.UserId
			for _, extra in product.extras do
				local info = { kind = "Offer", section = product.offer, id = extra.index, claimId = `offer:{purchaseId}:{extra.index}`, purchaseId = purchaseId }
				if not deliverPrize(player, extra.item, info) then
					return decision.NotYetProcessed
				end
			end
			delivered[purchaseId] = true
		end
		-- the developer's handler runs once in this server
		if custom and not customDone[purchaseId] then
			local ok, result = pcall(custom, player, receiptInfo)
			if not ok or result == false then
				return decision.NotYetProcessed
			end
			customDone[purchaseId] = true
			purchaseOwner[purchaseId] = player.UserId
		end
		-- the handlers above may have waited: a player who left since (their
		-- leave state is frozen) gets it on their next visit, never half
		if profile and (profile.leaving or profile.final ~= nil or profiles[player] ~= profile) then
			return decision.NotYetProcessed
		elseif profile == nil and player.Parent == nil then
			return decision.NotYetProcessed
		end
		-- every currency this purchase pays must be there to receive it
		local spinWedge = if spin and drops[purchaseId] then spin.def.wedges[drops[purchaseId].spin] else nil
		local offlineScreen = if product and product.offlineDouble then offlineScreens[product.offlineDouble] else nil
		local targets = {}
		if product and product.grant then
			table.insert(targets, product.grant.currency)
		end
		if spinWedge and spinWedge.grant then
			table.insert(targets, spinWedge.grant.currency)
		end
		for _, grant in (product and product.grants) or {} do
			table.insert(targets, grant.currency)
		end
		if offlineScreen and offlineScreen.currency then
			table.insert(targets, offlineScreen.currency)
		end
		for _, currency in targets do
			local def = currencies[currency]
			if def == nil or def.source.kind == "none" or Server.getCurrency(player, currency) == nil then
				warnOnce("receiptTarget:" .. tostring(currency), `a purchase pays "{tostring(currency)}", but the player has no such currency right now; the purchase waits`)
				return decision.NotYetProcessed
			end
		end
		-- give it and mark it pending in the same step: every later write
		-- carries both
		if product and product.grant then
			Server.grant(player, product.grant.currency, product.grant.amount)
		end
		if product and product.premium then
			if profile then
				profile.premium[product.section] = true
			end
			player:SetAttribute(premiumKey(product.section), true)
		end
		-- v1.6: a spin wedge's grant, an offer's contents, doubled offline earnings
		if spinWedge then
			local grant = checkGrant(spinWedge.grant, `spin "{spin.def.id}"`)
			if grant then
				Server.grant(player, grant.currency, grant.amount)
			end
		end
		if product and product.grants then
			-- an offer bought again (a script can prompt it any time) after it
			-- was bought or ended pays its honest value, not the discount
			local scale = 1
			local screen = product.offer and offerScreens[product.offer]
			if screen and profile and screen.was and screen.was > 0 and screen.price > 0 then
				local record = rewardPart(profile, "offers")[product.offer]
				local ended = typeof(record) == "table" and screen.timer ~= nil and finite(record.first) and os.time() >= record.first + screen.timer
				if typeof(record) == "table" and (record.bought == true or ended) then
					scale = math.min(1, screen.price / screen.was)
				end
			end
			for _, grant in product.grants do
				local amount = math.floor(grant.amount * scale)
				if amount > 0 then
					Server.grant(player, grant.currency, amount)
				end
			end
		end
		if product and product.offer then
			if profile then
				local record = rewardPart(profile, "offers")[product.offer]
				if typeof(record) ~= "table" then
					record = {}
					rewardPart(profile, "offers")[product.offer] = record
				end
				record.bought = true
			end
			setAttribute(player, ATTR.offerOwned .. product.offer, true)
		end
		if offlineScreen and offlineScreen.currency then
			-- x2: the waiting amount twice, or once more if it was already
			-- claimed (both saved, so a retry in a later session pays the
			-- same); bought with nothing either way, it's worth an hour
			local screen = offlineScreen
			local amount = nil
			local pending = if profile then waitingOffline(profile, screen.id) else nil
			local claimed = if profile then rewardPart(profile, "offlineClaimed")[screen.id] else nil
			if pending then
				amount = pending.amount * 2
				rewardPart(profile :: Profile, "offline")[screen.id] = nil
				publishOffline(profile :: Profile, screen)
			elseif finite(claimed) and claimed > 0 then
				amount = claimed
			else
				amount = math.floor(offlineRateOf(player, screen) * 60)
			end
			if profile then
				rewardPart(profile, "offlineClaimed")[screen.id] = nil
			end
			Server.grant(player, screen.currency, amount)
		end
		applied[purchaseId] = true
		purchaseOwner[purchaseId] = player.UserId
		if profile then
			profile.pending[purchaseId] = os.time()
		end
	end

	-- saved?
	if profile then
		if not writeProfile(profile) or not profile.receipts[purchaseId] then
			return decision.NotYetProcessed
		end
	else
		local hook = startOptions.saveHook
		if typeof(hook) ~= "function" then
			warnOnce("saveHook", "SaveData = false: pass { saveHook = function(player) return savedOk end } so purchases are only granted once your data is saved")
			return decision.NotYetProcessed
		end
		local ok, saved = pcall(hook, player)
		if not ok or saved ~= true then
			return decision.NotYetProcessed
		end
	end
	granted[purchaseId] = true

	-- notifications (the purchase is done and saved)
	if product then
		if product.case then
			local drop = drops[purchaseId]
			emit("CaseOpened", player, product.item.id, { case = product.item.id, section = product.section, item = deepCopy(drop), productId = productId, purchaseId = purchaseId })
			if Server.remote and drop then
				pcall(function()
					(Server.remote :: RemoteEvent):FireClient(player, Server.CASE_RESULT, product.item.id, drop.name)
				end)
			end
		end
		local roll = product.spin and drops[purchaseId]
		if roll and roll.spin then
			-- the client spins its wheel to the server's wedge
			emit(Server.REWARD, player, product.spin, { kind = "Spin", section = product.spin, index = roll.spin, spinId = purchaseId, mode = "robux" })
			if Server.remote then
				pcall(function()
					(Server.remote :: RemoteEvent):FireClient(player, Server.SPIN_RESULT, product.spin, roll.spin)
				end)
			end
		end
		emit("Purchase", player, product.item.id, { productId = productId, section = product.section, purchaseId = purchaseId })
	end
	return decision.PurchaseGranted
end

-- Grants a developer product purchase. Call this from your own
-- ProcessReceipt if you started the server with { ProcessReceipt = false }.
-- PurchaseGranted only once the grant is saved; one purchase is never
-- processed twice at the same time.
function Server.handleReceipt(receiptInfo: { [string]: any }): Enum.ProductPurchaseDecision
	local decision = Enum.ProductPurchaseDecision
	local player = playerFromId(receiptInfo.PlayerId)
	if player == nil then
		return decision.NotYetProcessed
	end
	local purchaseId = tostring(receiptInfo.PurchaseId)
	if processing[purchaseId] then
		return decision.NotYetProcessed
	end
	processing[purchaseId] = true
	local ok, result = pcall(grantReceipt, player, receiptInfo)
	processing[purchaseId] = nil
	if not ok then
		warnOnce("receipt", `receipt failed: {result}`)
		return decision.NotYetProcessed
	end
	return result
end

--------------------------------------------------------------------------
-- Player settings (Blueprint.Settings)
--------------------------------------------------------------------------

Server.SETTINGS_SAVE_INTERVAL = 7 -- seconds between writes for one player (DataStores allow one per 6 s per key)

type SettingsState = {
	values: { [string]: any }, -- checked values (what the attribute shows)
	unsaved: { [string]: any }, -- accepted changes not written yet
	loaded: boolean,
	saving: boolean,
	scheduled: boolean,
	leaving: boolean,
	lastSave: number,
	backoff: number, -- seconds before retrying a failed save
}

local saveSettings = true
local playerSettings: { [Player]: SettingsState } = {}

local function publishSettings(player: Player, state: SettingsState)
	pcall(function()
		player:SetAttribute(Settings.ATTRIBUTE, Settings.encode(state.values))
	end)
end

local scheduleSave: (Player, number?) -> ()

local function saveNow(player: Player, state: SettingsState)
	if state.saving or next(state.unsaved) == nil then
		return
	end
	state.saving = true
	state.lastSave = os.clock()
	local changes = state.unsaved
	state.unsaved = {}
	local ok, result = updateKey(Server.SETTINGS_STORE, playerKey(player), function(old)
		-- merge, so keys from other places / blueprints survive
		return merge(if typeof(old) == "table" then old else {}, changes)
	end)
	state.saving = false
	if not ok then
		for id, value in changes do
			if state.unsaved[id] == nil then
				state.unsaved[id] = value
			end
		end
		warnOnce("settingsSave", `could not save player settings ({tostring(result)})`)
		if state.leaving then
			-- the player is gone and storage is down: nothing more to do
			if playerSettings[player] == state then
				playerSettings[player] = nil
			end
			return
		end
		state.backoff = math.min(math.max(state.backoff * 2, Server.SETTINGS_SAVE_INTERVAL), 120)
		scheduleSave(player, state.backoff)
		return
	end
	state.backoff = 0
	if next(state.unsaved) ~= nil then
		if state.leaving then
			task.spawn(saveNow, player, state)
		else
			scheduleSave(player)
		end
	elseif state.leaving and playerSettings[player] == state then
		playerSettings[player] = nil
	end
end

scheduleSave = function(player: Player, after: number?)
	local state = playerSettings[player]
	if state == nil or state.scheduled or not saveSettings then
		return
	end
	state.scheduled = true
	local wait = after or (if state.leaving then 0 else math.max(0, state.lastSave + Server.SETTINGS_SAVE_INTERVAL - os.clock()))
	task.delay(wait, function()
		state.scheduled = false
		if playerSettings[player] ~= state then
			return -- reset / restarted meanwhile
		end
		if state.saving then
			return -- saveNow reschedules when it finishes
		end
		if not state.leaving and not budgetOk() then
			scheduleSave(player, 10) -- out of budget: later
			return
		end
		if state.loaded or state.leaving then
			saveNow(player, state)
		end
	end)
end

local function loadSettings(player: Player)
	if not saveSettings or playerSettings[player] ~= nil then
		return
	end
	local state: SettingsState = {
		values = {},
		unsaved = {},
		loaded = false,
		saving = false,
		scheduled = false,
		leaving = false,
		lastSave = 0,
		backoff = 0,
	}
	playerSettings[player] = state
	task.spawn(function()
		local ok, data = readKey(Server.SETTINGS_STORE, playerKey(player))
		if playerSettings[player] ~= state then
			return -- left (or the server restarted) meanwhile
		end
		if not ok then
			warnOnce("settingsLoad", `could not load player settings ({tostring(data)}); new changes are still saved`)
			data = nil
		end
		-- changes the player made while this loaded win
		state.values = merge((Settings.sanitize(settingIndex, data, 4096)) or {}, state.unsaved)
		state.loaded = true
		publishSettings(player, state)
		if next(state.unsaved) ~= nil then
			scheduleSave(player)
		end
	end)
end

local function unloadSettings(player: Player)
	local state = playerSettings[player]
	if state == nil then
		return
	end
	state.leaving = true
	if state.saving then
		return -- saveNow writes what's left, then forgets the player
	end
	if next(state.unsaved) ~= nil then
		task.spawn(saveNow, player, state) -- right away, no throttle
	else
		playerSettings[player] = nil
	end
end

-- "BloxUI.Settings" from a client: { [id] = value }. Only ids from the
-- blueprints, with the right type and range; everything else is dropped.
local function handleSettings(player: Player, payload: any)
	local accepted = Settings.sanitize(settingIndex, payload, Settings.MAX_KEYS)
	if accepted == nil or next(accepted) == nil then
		return
	end
	local state = playerSettings[player]
	if state then
		merge(state.values, accepted)
		merge(state.unsaved, accepted)
		if state.loaded then
			publishSettings(player, state)
			scheduleSave(player)
		end
	end
	emit(Settings.ACTION, player, nil, accepted)
end

-- A player's settings: every setting in the blueprints (default values)
-- plus what the player chose. Empty before UI.Server.start().
function Server.getSettings(player: Player): { [string]: any }
	local out = {}
	for id, defs in settingIndex do
		out[id] = defs[1].default
	end
	local state = playerSettings[player]
	if state then
		merge(out, state.values)
	end
	return out
end

--------------------------------------------------------------------------
-- Saving everything (shutdown)
--------------------------------------------------------------------------

local function busy(): boolean
	for _, state in playerSettings do
		if state.saving or next(state.unsaved) ~= nil then
			return true
		end
	end
	if next(releasing) ~= nil then
		return true
	end
	for _, profile in profiles do
		if profile.writing then
			return true
		end
	end
	return false
end

-- Starts every pending save, then waits (up to `timeout`, default 25 s)
-- until none is running. BindToClose calls this. Players who already left
-- are saved from the state frozen when they left.
function Server.flush(timeout: number?)
	for player, state in playerSettings do
		if next(state.unsaved) ~= nil and not state.saving then
			task.spawn(saveNow, player, state)
		end
	end
	for _, profile in profiles do
		if not profile.leaving then
			task.spawn(writeProfile, profile)
		end
	end
	local deadline = os.clock() + (timeout or 25)
	task.wait()
	while busy() and os.clock() < deadline do
		task.wait(0.25)
	end
end
Server.flushSettings = Server.flush

--------------------------------------------------------------------------
-- Remote arguments
--------------------------------------------------------------------------

Server.MAX_ID = 100
Server.MAX_STRING = 1024
Server.MAX_KEYS = 32
Server.MAX_DEPTH = 2

-- Numbers without NaN / inf, strings up to MAX_STRING, tables up to
-- MAX_KEYS keys and MAX_DEPTH levels, Instances. Nothing else.
local function safeValue(value: any, depth: number): boolean
	local kind = typeof(value)
	if kind == "nil" or kind == "boolean" or kind == "Instance" then
		return true
	elseif kind == "number" then
		return finite(value)
	elseif kind == "string" then
		return #value <= Server.MAX_STRING
	elseif kind == "table" then
		if depth >= Server.MAX_DEPTH then
			return false
		end
		local ok = pcall(function()
			local count = 0
			for key, inner in next, value do
				count += 1
				if count > Server.MAX_KEYS then
					error("too many keys", 0)
				end
				local keyKind = typeof(key)
				if keyKind == "string" then
					if #key > 64 then
						error("long key", 0)
					end
				elseif keyKind ~= "number" or key ~= key then
					error("bad key", 0)
				end
				if not safeValue(inner, depth + 1) then
					error("bad value", 0)
				end
			end
		end)
		return ok
	end
	return false
end
Server.safeValue = function(value: any): boolean
	return safeValue(value, 0)
end

--------------------------------------------------------------------------
-- Start / stop
--------------------------------------------------------------------------

local function allowed(player: Player): boolean
	local now = os.clock()
	local bucket = buckets[player]
	if bucket == nil then
		bucket = { tokens = Server.RATE * 2, at = now }
		buckets[player] = bucket
	end
	bucket.tokens = math.min(Server.RATE * 2, bucket.tokens + (now - bucket.at) * Server.RATE)
	bucket.at = now
	if bucket.tokens < 1 then
		return false
	end
	bucket.tokens -= 1
	return true
end

local function reply(player: Player, payload: any, ok: boolean, reason: string?, data: any)
	local nonce = if typeof(payload) == "table" then payload.nonce else nil
	local remote = Server.remote
	if typeof(nonce) == "number" and remote then
		pcall(function()
			remote:FireClient(player, Server.BUY_RESULT, nonce, ok, reason, data)
		end)
	end
end

local function onRemote(player: Player, name: any, elementId: any, payload: any)
	-- (a player who has left keeps nothing here, not even a rate bucket)
	if typeof(name) ~= "string" or #name > 64 or player.Parent == nil or not allowed(player) then
		return
	end
	if elementId ~= nil and (typeof(elementId) ~= "string" or #elementId > Server.MAX_ID) then
		return
	end
	if name == Settings.ACTION then
		handleSettings(player, payload) -- checked key by key (up to 64)
		return
	end
	if not safeValue(payload, 0) then
		return
	end
	if name == Server.BUY then
		local ok, reason, data = handleBuy(player, elementId, payload)
		reply(player, payload, ok, reason, data)
		return
	end
	if name == Server.CLAIM then
		-- v1.6 rewards (a group check may wait on Roblox: never block the remote)
		task.spawn(function()
			local ok, reason, data = handleClaim(player, elementId, payload)
			reply(player, payload, ok, reason, data)
		end)
		return
	end
	if Server.isInternal(name) then
		return -- server events can't be fired by clients
	end
	if not run(handlers, name, player, elementId, payload) then
		warnOnce("remote:" .. name, `no handler for "{name}"; add UI.Server.on("{name}", function(player, elementId, payload) end)`)
	end
end

-- Takes over MarketplaceService.ProcessReceipt when there is something to
-- grant and the game didn't opt out.
takeReceipts = function()
	if tookReceipts or startOptions.ProcessReceipt == false then
		return
	end
	local count = 0
	for _ in products do
		count += 1
	end
	if startOptions.ProcessReceipt ~= true and count == 0 and next(receiptHandlers) == nil then
		return
	end
	tookReceipts = true
	MarketplaceService.ProcessReceipt = Server.handleReceipt
	print(`[BloxUI] Server: handling ProcessReceipt ({count} blueprint product(s)). Keep your own with UI.Server.start(\{ ProcessReceipt = false \}) and call UI.Server.handleReceipt from it.`)
end

export type StartOptions = {
	blueprints: any?, -- array of tables / JSON strings / StringValues, or a Folder (default: ReplicatedStorage.BloxUI_Blueprints, watched)
	Leaderstats: boolean?,
	NumberValues: boolean?, -- leaderstats as NumberValues (fractions, beyond 2^53) instead of IntValues
	ProcessReceipt: boolean?, -- nil: only when there are developer products / onReceipt handlers
	fallbackReceipt: ((any) -> Enum.ProductPurchaseDecision)?, -- products the blueprints don't know
	SaveData: boolean?, -- false: nothing saved (needs saveHook); true: also leaderstats that already existed
	saveHook: ((Player) -> boolean)?, -- SaveData = false: saves the game's own data, true when it worked
	SaveSettings: boolean?, -- false: settings are checked and dispatched, never stored
}

local function blueprintSources(sources: any): { any }
	local list = {}
	if typeof(sources) == "Instance" then
		for _, child in sources:GetChildren() do
			table.insert(list, child)
		end
	elseif typeof(sources) == "table" then
		if sources.version ~= nil or sources.screens ~= nil or sources.hud ~= nil then
			list = { sources }
		else
			list = sources
		end
	end
	return list
end

-- Folder of StringValues: re-index when blueprints are added, removed,
-- edited or (en/dis)abled at runtime.
local function watchFolder(folder: Instance, myGeneration: number)
	local pending = false
	local function refresh()
		if pending then
			return
		end
		pending = true
		task.defer(function()
			pending = false
			if generation == myGeneration then
				reindex(blueprintSources(folder))
				takeReceipts()
			end
		end)
	end
	local function watch(child: Instance)
		table.insert(indexConnections, child:GetAttributeChangedSignal("Enabled"):Connect(refresh))
		if child:IsA("StringValue") then
			table.insert(indexConnections, child:GetPropertyChangedSignal("Value"):Connect(refresh))
		end
	end
	for _, child in folder:GetChildren() do
		watch(child)
	end
	table.insert(indexConnections, folder.ChildAdded:Connect(function(child)
		watch(child)
		refresh()
	end))
	table.insert(indexConnections, folder.ChildRemoved:Connect(refresh))
end

function Server.start(options: StartOptions?): typeof(Server)
	local opts = options or {}
	if Server.started then
		Server.stop()
	end
	Server.started = true
	generation += 1
	local myGeneration = generation
	startOptions = opts

	-- blueprints
	local sources = opts.blueprints
	local watched = nil
	if sources == nil then
		sources = ReplicatedStorage:FindFirstChild(Server.FOLDER)
		watched = sources
	elseif typeof(sources) == "Instance" and sources:IsA("Folder") then
		watched = sources
	end
	reindex(blueprintSources(sources))
	if watched then
		watchFolder(watched, myGeneration)
	elseif opts.blueprints == nil then
		-- the folder may be inserted later
		table.insert(indexConnections, ReplicatedStorage.ChildAdded:Connect(function(child)
			if child.Name == Server.FOLDER and generation == myGeneration then
				watchFolder(child, myGeneration)
				reindex(blueprintSources(child))
				takeReceipts()
			end
		end))
	end

	-- remote
	local remote = ReplicatedStorage:FindFirstChild(Server.REMOTE_NAME)
	if remote == nil or not remote:IsA("RemoteEvent") then
		remote = Instance.new("RemoteEvent")
		remote.Name = Server.REMOTE_NAME
		remote.Parent = ReplicatedStorage
	end
	Server.remote = remote :: RemoteEvent
	table.insert(connections, (remote :: RemoteEvent).OnServerEvent:Connect(onRemote))

	-- players: leaderstats, then saved data and settings
	saveData = opts.SaveData ~= false
	saveSettings = opts.SaveSettings ~= false
	local function join(player: Player)
		-- playtime starts now (v1.6 gifts); the client's countdowns read the
		-- same moment (display only: claims use the server's own clock)
		local session = sessionOf(player)
		if session then
			setAttribute(player, ATTR.joined, session.wall)
		end
		local existing = setupPlayer(player)
		loadProfile(player, existing)
		loadSettings(player)
		task.spawn(payReferral, player)
	end
	for _, player in Players:GetPlayers() do
		join(player)
	end
	table.insert(connections, Players.PlayerAdded:Connect(join))
	table.insert(connections, Players.PlayerRemoving:Connect(function(player)
		buckets[player] = nil
		sessions[player] = nil
		ownedItems[player] = nil
		unloadProfile(player)
		unloadSettings(player)
		if not saveData then
			forgetFinished(player.UserId)
		end
	end))
	pcall(function()
		-- the prompt's result can be faked by a client: ask Roblox
		table.insert(connections, MarketplaceService.PromptGamePassPurchaseFinished:Connect(function(player, gamePassId, purchased)
			if purchased and typeof(player) == "Instance" and typeof(gamePassId) == "number" and gamePasses[gamePassId] then
				task.spawn(checkGamePass, player, gamePassId)
			end
		end))
	end)
	if saveData then
		task.spawn(function()
			while generation == myGeneration do
				task.wait(Server.DATA_AUTOSAVE)
				if generation ~= myGeneration then
					break
				end
				for _, profile in profiles do
					task.spawn(autosave, profile)
				end
			end
		end)
	end
	pcall(function()
		game:BindToClose(function()
			if generation == myGeneration then
				Server.flush(25)
			end
		end)
	end)

	-- receipts
	Server.fallbackReceipt = opts.fallbackReceipt
	takeReceipts()
	return Server
end

function Server.stop()
	for _, list in { connections, indexConnections } do
		for _, connection in list do
			connection:Disconnect()
		end
		table.clear(list)
	end
	table.clear(buckets)
	generation += 1
	Server.started = false
	if tookReceipts then
		tookReceipts = false
		pcall(function()
			if MarketplaceService.ProcessReceipt == Server.handleReceipt then
				MarketplaceService.ProcessReceipt = nil :: any
			end
		end)
	end
end

-- Tests: sizes of the per-player bookkeeping (nothing should outlive a
-- player who left).
function Server._stats(): { [string]: number }
	local function count(t: { [any]: any }): number
		local n = 0
		for _ in t do
			n += 1
		end
		return n
	end
	return {
		purchases = count(purchaseOwner),
		granted = count(granted),
		sessions = count(sessions),
		owned = count(ownedItems),
		giftMemory = count(giftMemory),
	}
end

-- Clears handlers and per-player state too (tests).
function Server.reset()
	Server.stop()
	for _, t in { handlers, serverHandlers, receiptHandlers, granted, applied, customDone, drops, delivered, purchaseOwner, processing, warnedOnce, playerSettings, profiles, releasing, memory, stores, sessions, ownedItems, giftMemory } do
		table.clear(t)
	end
	warnedCount = 0
	storesDown = false
	caseDropHandler = nil
	spinPrizeHandler = nil
	rewardHandler = nil
	startOptions = {}
	Server.canBuy = nil
	Server.fallbackReceipt = nil
	Server.offlineRate = nil
	Server.storageBackend = "none"
end

return Server
]]></string>
          </Properties>
        </Item>
        <Item class="Folder" referent="261">
          <Properties>
            <string name="Name">Templates</string>
          </Properties>
          <Item class="LocalScript" referent="262">
            <Properties>
              <string name="Name">BlueprintLoader</string>
              <bool name="Disabled">true</bool>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	BloxUI template: Blueprint Loader

	Builds every UI blueprint stored in ReplicatedStorage.BloxUI_Blueprints
	(StringValues holding blueprint JSON, made by the BloxUI plugin) and opens
	each one's start screen. Set a StringValue's "Enabled" attribute to false
	to skip it.

	HOW TO USE
	  The BloxUI plugin inserts this for you. By hand: put it in
	  StarterPlayer > StarterPlayerScripts (Enabled) and press Play.

	TALK TO THE UI FROM YOUR OWN LOCALSCRIPTS
	  local UI = require(game:GetService("ReplicatedStorage"):WaitForChild("BloxUI"))
	  local ui = UI.Blueprint.waitFor("Main")        -- the StringValue's name
	  ui:on("Rebirth", function(context) ... end)    -- Event actions / button ids
	  ui:open("shop")
	  ui:give("coins", 100, somePart)
	  ui.hud.health.Health:set(50)
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local BloxUI = ReplicatedStorage:WaitForChild("BloxUI")
local UI = require(BloxUI)
local Blueprint = UI.Blueprint or require(BloxUI:WaitForChild("Blueprint"))

local folder = ReplicatedStorage:WaitForChild(Blueprint.FOLDER, 10)
if folder == nil then
	warn(`[BloxUI] BlueprintLoader: no ReplicatedStorage.{Blueprint.FOLDER} folder, nothing to build`)
	return
end

local scope = UI.scope()

local function load(value: Instance)
	if not value:IsA("StringValue") or value:GetAttribute("Enabled") == false then
		return
	end
	if Blueprint.get(value.Name) ~= nil then
		return
	end
	local blueprint, problems = Blueprint.fromInstance(value)
	if #problems > 0 then
		print(`[BloxUI] BlueprintLoader: "{value.Name}" was repaired in {#problems} place(s): {problems[1]}`)
	end
	local ui = Blueprint.build(scope, blueprint)
	Blueprint.register(value.Name, ui)
	if blueprint.startScreen then
		ui:open(blueprint.startScreen)
	end
end

for _, child in folder:GetChildren() do
	load(child)
end
folder.ChildAdded:Connect(load)
]]></string>
            </Properties>
          </Item>
          <Item class="Script" referent="263">
            <Properties>
              <string name="Name">BlueprintServer</string>
              <bool name="Disabled">true</bool>
              <token name="RunContext">0</token>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	BloxUI template: Blueprint Server

	The server half of blueprint UIs: leaderstats for the blueprint's
	currencies, the BloxUI_Action remote for "Remote" actions, in-game currency
	purchases, and developer product receipts that grant each product's reward.

	HOW TO USE
	  The BloxUI plugin inserts this for you. By hand: put it in
	  ServerScriptService (Enabled) and press Play.

	HANDLE YOUR OWN ACTIONS (in any server Script)
	  local UI = require(game:GetService("ReplicatedStorage"):WaitForChild("BloxUI"))
	  UI.Server.on("RedeemCode", function(player, elementId, code) ... end)

	If your game already sets MarketplaceService.ProcessReceipt, change the
	start call below to UI.Server.start({ ProcessReceipt = false }) and call
	UI.Server.handleReceipt(receiptInfo) from your own handler.

	UI.Server starts once per game. Hook scripts from the plugin's "Export as
	code" (<Name>_ServerHooks) start it with options picked for their UI:
	when one of them already started it, this script leaves it alone.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local BloxUI = ReplicatedStorage:WaitForChild("BloxUI")
local UI = require(BloxUI)
local Server = UI.Server or require(BloxUI:WaitForChild("Server"))

if not Server.started then
	Server.start()
end
]]></string>
            </Properties>
          </Item>
          <Item class="LocalScript" referent="264">
            <Properties>
              <string name="Name">FPSDemo</string>
              <bool name="Disabled">true</bool>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	BloxUI template: FPS HUD

	The RIVALS-style shooter HUD: health bar with the number inside, weapon
	hotbar with ammo, a crosshair with hit / headshot / kill markers, a
	"Beginner Tasks" tracker, key hints, a kill feed, an elimination banner,
	reload and low-ammo prompts, and red arcs that point at whoever shot you.
	The shooting here is simulated so you can see everything react. Swap the
	fake parts for your real weapon code.

	HOW TO USE
	  Put this LocalScript in StarterPlayer > StarterPlayerScripts (Enabled)
	  and press Play.

	CONTROLS
	  Click / RT: shoot    R / X: reload    1-4 / LB RB: switch weapon
	  Shift + Ctrl: slide    M / F / G: the hint keys
	  Phones get Fire / Reload / Melee buttons around the jump button.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local Players = game:GetService("Players")

local BloxUI = ReplicatedStorage:WaitForChild("BloxUI")
local UI = require(BloxUI)
local peek = UI.peek

-- RIVALS-style HUD: Rubik, near-black glass, faded task panel.
UI.Theme.set("Arena")
-- the bottom centre stays clear for the weapon view; toasts go up top
UI.Popups.configure({ Toasts = "Top" })

local scope = UI.scope()
local player = Players.LocalPlayer

-- Game state (in a real game these come from your weapon system)
local health = scope:Value(150)
local rifleAmmo = scope:Value(13)
local rifleReserve = scope:Value(100)
local pistolAmmo = scope:Value(13)
local pistolReserve = scope:Value(9)
local equipped = scope:Value(1)

-- Task progress
local visitedRange = scope:Value(1)
local targetsBroken = scope:Value(2)
local slid = scope:Value(0)

local weapons = {
	{ Name = "Assault Rifle", Icon = "ammo", Ammo = rifleAmmo, Reserve = rifleReserve, MagSize = 30, Spread = 7 },
	{ Name = "Pistol", Icon = "target", Ammo = pistolAmmo, Reserve = pistolReserve, MagSize = 13, Spread = 12 },
	{ Name = "Knife", Icon = "melee" },
	{ Name = "Grenade", Icon = "grenade" },
}

local hud = UI.Prefabs.FPSHud(scope, {
	Health = health,
	MaxHealth = 150,
	Weapons = weapons,
	Equipped = equipped,
	TasksTitle = "Beginner Tasks",
	Tasks = {
		{ Text = "Go to Shooting Range", Icon = "flag", Progress = visitedRange, Goal = 1 },
		{ Text = "Break targets", Icon = "target", Progress = targetsBroken, Goal = 5, Reward = { Icon = "key", Amount = 1 } },
		{ Text = "Run + crouch to Slide", Icon = "run", Progress = slid, Goal = 1 },
	},
	Hints = {
		{ Text = "Leave Shooting Range", Key = Enum.KeyCode.M, Gamepad = Enum.KeyCode.ButtonSelect },
		{ Text = "Quick Melee", Key = Enum.KeyCode.F, Gamepad = Enum.KeyCode.ButtonB },
		{ Text = "Quick Utility", Key = Enum.KeyCode.G, Gamepad = Enum.KeyCode.ButtonY },
	},
})

-- Celebrate when every task is done
local celebrated = false
scope:Observer(scope:Computed(function(use)
	return use(visitedRange) >= 1 and use(targetsBroken) >= 5 and use(slid) >= 1
end)):onChange(function()
	if celebrated then
		return
	end
	celebrated = true
	task.delay(0.6, function()
		UI.FX.announce("TASKS COMPLETE!", { Subtitle = "You earned a key", Icon = "key", Tone = "Yellow", Burst = true })
	end)
end)

--------------------------------------------------------------------------
-- Fake weapon logic
--------------------------------------------------------------------------

local hitsInARow = 0

local function currentWeapon()
	return weapons[peek(equipped)]
end

local function isReloading(): boolean
	return typeof(peek(hud.Reloading)) == "number"
end

local function reload()
	local weapon = currentWeapon()
	if weapon.Ammo == nil or isReloading() then
		return
	end
	local missing = weapon.MagSize - peek(weapon.Ammo)
	if missing <= 0 or peek(weapon.Reserve) <= 0 then
		return
	end
	hud.reload(1.2, function()
		local moved = math.min(missing, peek(weapon.Reserve))
		weapon.Reserve:set(peek(weapon.Reserve) - moved)
		weapon.Ammo:set(peek(weapon.Ammo) + moved)
	end)
end

local function shoot()
	local weapon = currentWeapon()
	if weapon.Ammo == nil then
		-- melee / utility
		hud.recoil(4)
		UI.FX.cameraShake(0.15, 0.2)
		return
	end
	if isReloading() then
		return
	end
	if peek(weapon.Ammo) <= 0 then
		UI.Sound.play("Error")
		reload()
		return
	end
	weapon.Ammo:set(peek(weapon.Ammo) - 1)
	hud.recoil(weapon.Spread)
	UI.FX.cameraShake(0.08, 0.12)
	if math.random() < 0.45 then
		hitsInARow += 1
		hud.hit(math.random() < 0.2) -- one in five is a headshot
		if peek(targetsBroken) < 5 then
			targetsBroken:set(peek(targetsBroken) + 1)
		end
		if hitsInARow % 4 == 0 then
			hud.kill("Target Dummy", "target")
		end
	end
	if peek(weapon.Ammo) == 0 then
		task.delay(0.25, reload)
	end
end

local function quickMelee()
	hud.recoil(10)
	UI.FX.cameraShake(0.2, 0.2)
end

-- switching weapons cancels a reload, like every shooter
scope:Observer(equipped):onChange(function()
	hud.cancelReload()
end)

table.insert(scope, UserInputService.InputBegan:Connect(function(input, processed)
	if processed then
		return
	end
	if input.UserInputType == Enum.UserInputType.MouseButton1 or input.KeyCode == Enum.KeyCode.ButtonR2 then
		shoot()
	elseif input.KeyCode == Enum.KeyCode.R or input.KeyCode == Enum.KeyCode.ButtonX then
		reload()
	elseif input.KeyCode == Enum.KeyCode.LeftControl or input.KeyCode == Enum.KeyCode.C then
		if UserInputService:IsKeyDown(Enum.KeyCode.LeftShift) and peek(slid) < 1 then
			slid:set(1)
			UI.FX.popText(UDim2.fromScale(0.5, 0.62), "SLIDE!", { Color = Color3.fromHex("5CC8FF") })
		end
	elseif input.KeyCode == Enum.KeyCode.M or input.KeyCode == Enum.KeyCode.ButtonSelect then
		UI.notify("Leaving the shooting range...", { Icon = "door" })
	elseif input.KeyCode == Enum.KeyCode.F or input.KeyCode == Enum.KeyCode.ButtonB then
		quickMelee()
	elseif input.KeyCode == Enum.KeyCode.G or input.KeyCode == Enum.KeyCode.ButtonY then
		UI.FX.flash(Color3.new(1, 1, 1), 0.4, 0.8)
		UI.FX.cameraShake(0.5, 0.5)
	end
end))

-- Phones: big buttons around the jump button (hidden on PC and consoles)
local firing = false
scope:Screen {
	Name = "FPSTouch",
	DisplayOrder = 6,
	[UI.Children] = scope:MobileControls {
		Buttons = {
			{
				id = "fire",
				label = "Fire",
				icon = "crosshair",
				Tone = "Red",
				OnPress = function()
					firing = true
					task.spawn(function()
						while firing do
							shoot()
							task.wait(0.14)
						end
					end)
				end,
				OnRelease = function()
					firing = false
				end,
			},
			{ id = "reload", label = "Reload", icon = "rebirth", OnPress = reload },
			{ id = "melee", label = "Melee", icon = "melee", OnPress = quickMelee },
		},
	},
}

-- Take some damage now and then, from a spot around you, and slowly heal
local alive = true
table.insert(scope, function()
	alive = false -- stops the loop below when the scope is cleaned up
	firing = false
end)
task.spawn(function()
	while alive do
		task.wait(6)
		if not alive then
			break
		end
		local amount = math.random(10, 30)
		local character = player and player.Character
		local root = character and character:FindFirstChild("HumanoidRootPart")
		local from: any = math.random(-180, 180)
		if root and root:IsA("BasePart") then
			-- a world position, so the red arc keeps pointing at it as you turn
			local angle = math.rad(math.random(0, 359))
			from = root.Position + Vector3.new(math.cos(angle), 0, math.sin(angle)) * 30
		end
		hud.damage(amount, from)
	end
end)
table.insert(scope, RunService.Heartbeat:Connect(function(dt)
	local now = peek(health)
	if now > 0 and now < 150 then
		health:set(math.min(150, now + dt * 4))
	end
end))

UI.notify(
	if UI.Input.device and peek(UI.Input.device) == "Touch"
		then "Tap FIRE to shoot, tap a slot to switch"
		else "Click to shoot, R to reload, 1-4 to switch",
	{ Type = "info", Icon = "target" }
)
]]></string>
            </Properties>
          </Item>
          <Item class="LocalScript" referent="265">
            <Properties>
              <string name="Name">Gallery</string>
              <bool name="Disabled">true</bool>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	BloxUI template: Gallery

	Every component, style and effect on one scrolling page, with a theme
	switcher for all eight presets. Click things to see them animate. Handy
	for picking a look before you build your own screens.

	It doubles as a visual regression page: the HUD sections show frozen
	states side by side (crosshair hit / headshot / kill markers, hotbar full
	/ low / reloading / empty, health full / hurt / critical, every rarity),
	so one screenshot per theme shows whether anything drifted.

	HOW TO USE
	  Put this LocalScript in StarterPlayer > StarterPlayerScripts (Enabled)
	  and press Play. Press G to hide/show the gallery.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local UI = require(ReplicatedStorage:WaitForChild("BloxUI"))
local Children = UI.Children
local peek = UI.peek
local FX = UI.FX
local Kit = UI.Kit

local scope = UI.scope()
local visible = scope:Value(true)
UI.Input.bind(scope, Enum.KeyCode.G, function()
	visible:set(not peek(visible))
end)

local TONES = { "Green", "Red", "Yellow", "Blue", "Purple", "Orange", "Pink", "Cyan", "Gray", "White", "Dark" }
local THEMES = {
	{ "Toon", "Yellow" },
	{ "Arena", "Green" },
	{ "Bubbly", "Pink" },
	{ "Adventure", "Orange" },
	{ "Sleek", "Blue" },
	{ "Candy", "Purple" },
	{ "Tactical", "Gray" },
	{ "Horror", "Red" },
}

local top = Kit.safeTop(scope, 72) -- below Roblox's top bar

-- a plain invisible Frame
local function Frame(props)
	props.BackgroundTransparency = 1
	return scope:New "Frame" (props)
end

local function section(title: string, icon: string, order: number, height: number, content: { any })
	return scope:Panel {
		Name = title,
		LayoutOrder = order,
		Style = "Glass",
		Title = title,
		TitleIcon = icon,
		Decoration = "None",
		Size = UDim2.new(1, -16, 0, height),
		Padding = { 8, 24, 16, 24 },
		[Children] = content,
	}
end

local function row(children: { any }, height: number, gap: number?, order: number?)
	return scope:Row {
		LayoutOrder = order or 0,
		Size = UDim2.new(1, 0, 0, height),
		Gap = gap or 16,
		Wraps = true,
		VAlign = "Top",
		[Children] = children,
	}
end

local function button(text: string, tone: string, onClick: () -> (), order: number?)
	return scope:Button {
		Text = text,
		Tone = tone,
		Size = UDim2.fromOffset(170, 56),
		TextSize = 24,
		LayoutOrder = order or 0,
		OnClick = onClick,
	}
end

local function caption(text: any, order: number?)
	return scope:Text {
		Text = text,
		TextSize = 18,
		Font = "Small",
		Stroke = false,
		Case = "Normal",
		LayoutOrder = order or 0,
		Color = Kit.themed(scope, function(theme)
			return theme.Color.TextMuted
		end),
	}
end

-- a labelled cell: content on top, a caption underneath (regression cells)
local function cell(label: string, width: number, height: number, content: any, order: number)
	return scope:Column {
		Name = `Cell_{label}`,
		LayoutOrder = order,
		Size = UDim2.fromOffset(width, height + 30),
		Gap = 6,
		Align = "Center",
		[Children] = {
			Frame {
				Name = "Content",
				LayoutOrder = 1,
				Size = UDim2.fromOffset(width, height),
				[Children] = content,
			},
			caption(label, 2),
		},
	}
end

-- a small "world" backdrop so white HUD bits are judged on a real scene colour
local function backdrop(color: string, content: any)
	return scope:New "Frame" {
		Name = "Backdrop",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Color3.fromHex(color),
		ClipsDescendants = true,
		[Children] = { Kit.corner(scope, "M"), content },
	}
end

--------------------------------------------------------------------------
-- HUD: shooter (frozen states + a live crosshair)
--------------------------------------------------------------------------

local hits = scope:Value(0)
local crits = scope:Value(0)
local kills = scope:Value(0)
local spread = scope:Value(8)
local targets = scope:Value(2)

local crosshairCells = {}
for index, def in { { "Idle", nil }, { "Hit", "Hit" }, { "Headshot", "Crit" }, { "Kill", "Kill" } } do
	table.insert(crosshairCells, cell(def[1], 150, 150, backdrop("8EC7F5", scope:Crosshair { Spread = 7, Show = def[2] }), index))
end
table.insert(crosshairCells, cell("Live (click)", 150, 150, backdrop("7FB069", scope:Crosshair {
	Spread = spread,
	Hits = hits,
	Crits = crits,
	Kills = kills,
}), 5))
table.insert(crosshairCells, scope:Column {
	Name = "CrosshairButtons",
	LayoutOrder = 6,
	Size = UDim2.fromOffset(170, 190),
	Gap = 10,
	[Children] = {
		button("Hit", "Gray", function()
			hits:set(peek(hits) + 1)
			spread:set(20)
			task.delay(0.1, function()
				spread:set(8)
			end)
		end, 1),
		button("Headshot", "Yellow", function()
			crits:set(peek(crits) + 1)
		end, 2),
		button("Kill", "Red", function()
			kills:set(peek(kills) + 1)
		end, 3),
	},
})

local function hotbarState(label: string, ammo: number, reserve: number, reloading: any, order: number)
	return cell(label, 190, 128, scope:Hotbar {
		BindKeys = false,
		SlotSize = 66,
		Reloading = reloading,
		AnchorPoint = Vector2.new(1, 1),
		Position = UDim2.fromScale(1, 1),
		Slots = {
			{ Name = "Rifle", Icon = "ammo", Ammo = ammo, Reserve = reserve, MagSize = 30 },
			{ Name = "Knife", Icon = "melee" },
		},
	}, order)
end

local hudShooter = scope:Column {
	Gap = 18,
	Align = "Left",
	[Children] = {
		row(crosshairCells, 190, 20, 1),
		row({
			hotbarState("Full", 30, 90, nil, 1),
			hotbarState("Low ammo", 5, 90, nil, 2),
			hotbarState("Reloading", 0, 90, 0.6, 3),
			hotbarState("Empty", 0, 0, nil, 4),
			cell("Key hints", 440, 128, scope:KeyHints {
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.fromScale(0.5, 1),
				Hints = {
					{ Text = "Reload", Key = Enum.KeyCode.R, Gamepad = Enum.KeyCode.ButtonX },
					{ Text = "Quick Melee", Key = Enum.KeyCode.F, Gamepad = Enum.KeyCode.ButtonB },
				},
			}, 5),
		}, 160, 24, 2),
		row({
			scope:QuestTracker {
				LayoutOrder = 1,
				Title = "Beginner Tasks",
				Width = 360,
				Tasks = {
					{ Text = "Go to Shooting Range", Icon = "flag", Progress = 1, Goal = 1 },
					{ Text = "Break targets", Icon = "target", Progress = targets, Goal = 5 },
					{ Text = "Run + crouch to Slide", Icon = "run", Progress = 0, Goal = 1 },
				},
			},
			scope:Column {
				LayoutOrder = 2,
				Size = UDim2.fromOffset(200, 200),
				Gap = 10,
				[Children] = {
					button("Break target", "Blue", function()
						targets:set(math.min(5, peek(targets) + 1))
					end, 1),
					button("Reset tasks", "Gray", function()
						targets:set(0)
					end, 2),
				},
			},
		}, 250, 24, 3),
	},
}

--------------------------------------------------------------------------
-- HUD: horror & simulator bits
--------------------------------------------------------------------------

local hudMood = row({
	cell("Vignette", 220, 130, backdrop("5E7E9C", scope:Vignette { Intensity = 0.7 }), 1),
	cell("Tunnel (fear)", 220, 130, backdrop("5E7E9C", scope:Vignette { Intensity = 0.85, Style = "Tunnel", Spread = 1.4 }), 2),
	cell("Heartbeat", 220, 130, backdrop("5E7E9C", scope:Heartbeat { BPM = 110, Intensity = 0.9 }), 3),
	cell("Timer", 220, 130, scope:Timer {
		Seconds = 95,
		Label = "Round ends",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
	}, 4),
	cell("Timer (urgent)", 220, 130, scope:Timer {
		Seconds = 8,
		Urgent = 10,
		Tone = "Red",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
	}, 5),
}, 170, 20)

--------------------------------------------------------------------------
-- Buttons
--------------------------------------------------------------------------

local toneButtons = {}
for index, tone in TONES do
	table.insert(toneButtons, scope:Button {
		Text = tone,
		Tone = tone,
		Size = UDim2.fromOffset(150, 58),
		TextSize = 24,
		LayoutOrder = index,
	})
end

local styleColumns = {}
for index, style in { "Glossy3D", "Flat", "Pill", "Outline", "Soft" } do
	table.insert(styleColumns, scope:Column {
		Name = `Style_{style}`,
		LayoutOrder = index,
		Size = UDim2.fromOffset(200, 250),
		Gap = 14,
		[Children] = {
			caption(style, 0),
			scope:Button { Name = `{style}_Play`, Text = "Play", Icon = "play", Tone = "Green", Style = style, Size = UDim2.fromOffset(190, 60), LayoutOrder = 1 },
			scope:Button { Name = `{style}_Equip`, Text = "Equip", Tone = "Blue", Style = style, Size = UDim2.fromOffset(190, 56), TextSize = 24, LayoutOrder = 2 },
			scope:Button { Name = `{style}_Sell`, Text = "Sell", Icon = "coin", Tone = "Red", Style = style, Size = UDim2.fromOffset(190, 56), TextSize = 24, LayoutOrder = 3 },
		},
	})
end

local fxButtons = {}
for index, kind in { "Squash", "Punch", "Ripple", "Sparkle", "Glow", "Poof", "Shake", "Glitch", "None" } do
	table.insert(fxButtons, scope:Button {
		Name = `Fx_{kind}`,
		Text = kind,
		Tone = ({ "Green", "Yellow", "Blue", "Purple", "Cyan", "Pink", "Orange", "Red", "Gray" })[index],
		ClickFx = kind,
		Size = UDim2.fromOffset(150, 56),
		TextSize = 24,
		LayoutOrder = index,
	})
end

local disabled = scope:Value(true)
local badgeCount = scope:Value(3)
local extraButtons = {
	scope:Button { Name = "CloseDemo", Icon = "close", Tone = "Red", LayoutOrder = 1 },
	scope:Button { Name = "PlusDemo", Icon = "plus", Tone = "Green", LayoutOrder = 2 },
	scope:Button { Name = "SettingsDemo", Icon = "settings", Tone = "Gray", LayoutOrder = 3 },
	scope:Button {
		Text = "Badge",
		Icon = "bell",
		Tone = "Blue",
		Badge = badgeCount,
		Size = UDim2.fromOffset(170, 58),
		LayoutOrder = 4,
		OnClick = function()
			badgeCount:set(peek(badgeCount) + 1)
		end,
	},
	scope:Button { Text = "Shine", Tone = "Yellow", Shine = true, Size = UDim2.fromOffset(170, 58), LayoutOrder = 5 },
	scope:Button { Text = "Pulse", Tone = "Pink", Pulse = true, Size = UDim2.fromOffset(170, 58), LayoutOrder = 6 },
	scope:Button { Text = "Disabled", Tone = "Green", Disabled = disabled, Size = UDim2.fromOffset(170, 58), LayoutOrder = 7 },
}

--------------------------------------------------------------------------
-- Panels, decorations, rarity, items
--------------------------------------------------------------------------

local panelDemos = {}
for index, def in {
	{ "Glass", nil },
	{ "Solid", nil },
	{ "Gradient", "Purple" },
	{ "Tone", "Orange" },
	{ "Outlined", "Blue" },
	{ "Ribbon", "Pink" },
	{ "Fade", nil },
	{ "Light", nil },
} do
	local style, tone = def[1], def[2]
	table.insert(panelDemos, scope:Panel {
		Name = `Panel_{style}`,
		LayoutOrder = index,
		Style = style,
		Tone = tone,
		Title = if style == "Ribbon" then "Ribbon" else nil,
		RibbonWidth = 140,
		RibbonHeight = 44,
		Decoration = "None",
		Size = UDim2.fromOffset(170, 132),
		[Children] = if style ~= "Ribbon"
			then scope:Text {
				Text = style,
				TextSize = 24,
				Font = "Title",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				Color = if style == "Light"
					then Kit.themed(scope, function(theme)
						return theme.Color.TextDark
					end)
					else nil,
				Stroke = if style == "Light" then false else nil,
			}
			else nil,
	})
end

local decorationDemos = {}
for index, decoration in { "Sparkles", "Stripes", "Dots", "Grain" } do
	table.insert(decorationDemos, scope:Panel {
		Name = `Deco_{decoration}`,
		LayoutOrder = index,
		Style = if index % 2 == 1 then "Gradient" else "Solid",
		Tone = ({ "Blue", "Purple", "Pink", "Cyan" })[index],
		Decoration = decoration,
		Size = UDim2.fromOffset(250, 140),
		[Children] = scope:Text {
			Text = decoration,
			TextSize = 26,
			Font = "Title",
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, 18, 1, -14),
		},
	})
end

local rarityChips = {}
for index, name in UI.Theme.RARITY_ORDER do
	local tone = Kit.rarity(scope, name)
	table.insert(rarityChips, scope:New "Frame" {
		Name = `Rarity_{name}`,
		LayoutOrder = index,
		Size = UDim2.fromOffset(190, 64),
		BackgroundColor3 = Color3.new(1, 1, 1),
		[Children] = {
			Kit.corner(scope, "M"),
			Kit.gradient(scope, {
				Rotation = 20,
				Color = scope:Computed(function(use)
					local t = use(tone)
					return t.Rainbow or Kit.faceColors(t, 0)
				end),
			}),
			Kit.stroke(scope, {
				Color = scope:Computed(function(use)
					return use(tone).Edge
				end),
			}),
			scope:Text {
				Text = name,
				TextSize = 24,
				Font = "Title",
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.5),
				StrokeColor = scope:Computed(function(use)
					local t = use(tone)
					return t.Stroke or t.Deep
				end),
				Stroke = 2,
			},
		},
	})
end

local ITEM_ICONS = { "egg", "pet", "gem", "crown", "melee", "star", "trophy" }
local itemSlots = {}
for index, name in UI.Theme.RARITY_ORDER do
	table.insert(itemSlots, scope:ItemSlot {
		Name = name,
		Icon = ITEM_ICONS[(index - 1) % #ITEM_ICONS + 1],
		Rarity = name,
		Count = if index == 2 then 12 else nil,
		Equipped = index == 1,
		Size = 108,
		LayoutOrder = index,
	})
end
table.insert(itemSlots, scope:ItemSlot { Name = "Locked", Icon = "egg", Rarity = "Mythic", Locked = true, Size = 108, LayoutOrder = 20 })
table.insert(itemSlots, scope:ItemSlot { Name = "Selected", Icon = "pet", Rarity = "Rare", Selected = true, Size = 108, LayoutOrder = 21 })

--------------------------------------------------------------------------
-- Icons
--------------------------------------------------------------------------

local iconCells = {}
for index, name in {
	"home", "shop", "cart", "gift", "daily", "key", "coin", "gem", "robux", "star", "heart", "shield",
	"lock", "settings", "trophy", "crown", "fire", "bolt", "timer", "target", "ammo", "melee", "run", "eye",
	"night", "door", "pet", "egg", "rebirth", "boost", "friends", "chat", "music", "quest", "map", "controller",
} do
	table.insert(iconCells, Frame {
		Name = `Icon_{name}`,
		LayoutOrder = index,
		[Children] = {
			scope:Icon {
				Icon = name,
				Size = 54,
				AnchorPoint = Vector2.new(0.5, 0),
				Position = UDim2.fromScale(0.5, 0),
				Color = Kit.themed(scope, function(theme)
					return theme.Tone[TONES[(index - 1) % 8 + 1]].Top
				end),
				Gradient = true,
				Shadow = true,
			},
			scope:Text {
				Text = name,
				TextSize = 16,
				Font = "Small",
				Case = "Normal",
				AnchorPoint = Vector2.new(0.5, 1),
				Position = UDim2.fromScale(0.5, 1),
			},
		},
	})
end

--------------------------------------------------------------------------
-- Bars, currency, cards, boards
--------------------------------------------------------------------------

local health = scope:Value(150)
local xp = scope:Value(35)
local stamina = scope:Value(0.8)
local coins = scope:Value(1250)
local musicOn = scope:Value(true)
local volume = scope:Value(70)
local quality = scope:Value("High")

local currency = scope:Currency {
	Icon = "coin",
	Amount = coins,
	Format = "comma",
	OnAdd = function()
		coins:set(peek(coins) + 100)
	end,
	LayoutOrder = 1,
}

local function healthState(label: string, value: number, order: number)
	return cell(label, 300, 40, scope:ProgressBar {
		Value = value,
		Max = 150,
		Tone = if value / 150 > 0.5 then "Green" elseif value / 150 > 0.25 then "Yellow" else "Red",
		Label = true,
		LabelIcon = "heart",
		Low = 0.3,
		Size = UDim2.fromOffset(300, 40),
	}, order)
end

-- A demo window with side tabs.
local windowOpen = scope:Value(false)
local demoWindow = scope:Window {
	Open = windowOpen,
	Title = "Inventory",
	Icon = "backpack",
	Size = "Medium",
	Nav = "SideTabs",
	Tabs = {
		{ Id = "pets", Text = "Pets", Icon = "egg", IconColor = Color3.fromHex("FF7AB6") },
		{ Id = "items", Text = "Items", Icon = "gift", IconColor = Color3.fromHex("FFB21F"), Badge = 2 },
		{ Id = "codes", Text = "Codes", Icon = "tag", IconColor = Color3.fromHex("45B8FF") },
	},
	Pages = {
		pets = function(inner)
			return {
				inner:Text { Text = "Your pets live here.", TextSize = 28, Font = "Title", Position = UDim2.fromOffset(4, 4) },
			}
		end,
		items = function(inner)
			return {
				inner:Text { Text = "Items and boosts.", TextSize = 28, Font = "Title", Position = UDim2.fromOffset(4, 4) },
			}
		end,
	},
	CloseOnBackdrop = true,
}

local sections = {
	section("HUD: shooter", "target", 1, 720, { hudShooter }),
	section("HUD: horror & simulator", "eye", 2, 250, { hudMood }),
	section("Buttons: every tone", "paint-brush", 3, 250, { row(toneButtons, 150, 14) }),
	section("Button styles", "sparkle", 4, 340, { row(styleColumns, 260, 24) }),
	section("Click effects", "cursor-stars", 5, 160, { row(fxButtons, 70, 12) }),
	section("Buttons: icons, badges, states", "bell", 6, 170, { row(extraButtons, 70, 14) }),
	section("Panels", "four-squares-diamond-grid", 7, 250, { row(panelDemos, 150, 20) }),
	section("Decorations & rarity", "star", 8, 330, {
		scope:Column {
			Gap = 20,
			Align = "Left",
			[Children] = {
				row(decorationDemos, 140, 20, 1),
				row(rarityChips, 64, 14, 2),
			},
		},
	}),
	section("Items", "backpack", 9, 210, { row(itemSlots, 120, 16) }),
	section("Icons (771 built in, no uploads)", "diamond", 10, 400, {
		scope:Grid { CellSize = Vector2.new(96, 84), Gap = 10, Align = "Left", [Children] = iconCells },
	}),
	section("Bars", "chart-line", 11, 400, {
		scope:Column {
			Gap = 16,
			Align = "Left",
			[Children] = {
				row({
					healthState("Health: full", 150, 1),
					healthState("Health: hurt", 70, 2),
					healthState("Health: critical", 28, 3),
				}, 76, 24, 1),
				scope:ProgressBar { Value = health, Max = 150, Label = true, LabelIcon = "heart", Low = 0.3, Size = UDim2.fromOffset(420, 40), LayoutOrder = 2 },
				scope:ProgressBar {
					Value = xp,
					Max = 100,
					Tone = "Purple",
					Shine = true,
					Label = function(v)
						return `Level 7  {math.floor(v)}%`
					end,
					LabelAlign = "Center",
					Size = UDim2.fromOffset(420, 34),
					LayoutOrder = 3,
				},
				scope:ProgressBar { Value = stamina, Max = 1, Tone = "Yellow", Segments = 5, Ghost = false, Size = UDim2.fromOffset(420, 22), LayoutOrder = 4 },
				row({
					button("Damage", "Red", function()
						health:set(math.max(0, peek(health) - 35))
						FX.damage(0.6)
					end, 1),
					button("Heal", "Green", function()
						health:set(150)
					end, 2),
					button("+XP", "Purple", function()
						xp:set((peek(xp) + 20) % 100)
					end, 3),
					button("Stamina", "Yellow", function()
						stamina:set(if peek(stamina) > 0.5 then 0.2 else 1)
					end, 4),
				}, 64, 14, 5),
			},
		},
	}),
	section("Currency, bubbles, badges", "coin", 12, 160, {
		row({
			currency,
			scope:Bubble { Text = "Complete your tasks for keys!", Pointer = "Left", LayoutOrder = 2 },
			scope:Bubble { Text = "Claim your FREE reward now!", Tone = "Red", Pointer = "Bottom", Icon = "gift", LayoutOrder = 3 },
			Frame {
				Name = "BadgeHolder",
				LayoutOrder = 4,
				Size = UDim2.fromOffset(60, 60),
				[Children] = {
					scope:Icon { Icon = "mail", Size = 56 },
					scope:Badge { Value = badgeCount, Position = UDim2.new(1, 0, 0, 4) },
				},
			},
			button("Fly coins", "Yellow", function()
				FX.flyTo(UDim2.fromScale(0.5, 0.6), currency, {
					Icon = "coin",
					Count = 8,
					OnDone = function()
						coins:set(peek(coins) + 80)
					end,
				})
			end, 5),
		}, 80, 24),
	}),
	section("Shop cards", "shop", 13, 340, {
		row({
			scope:ProductCard { Title = "Key Bundle", Amount = "+ 10", Icon = "key", Price = 48, Shine = false, Size = UDim2.fromOffset(340, 230), LayoutOrder = 1 },
			scope:ProductCard { Title = "Gem Chest", Amount = "+ 90", Icon = "gem", Tone = "Blue", Price = 308, Tag = "POPULAR", Size = UDim2.fromOffset(340, 230), LayoutOrder = 2 },
			scope:ProductCard { Title = "Legendary", Amount = "+ 1,100", Icon = "crown", Tone = "Red", Price = 3000, Sparkles = true, Tag = "BEST VALUE", Size = UDim2.fromOffset(340, 230), LayoutOrder = 3 },
			scope:ProductCard { Title = "Starter Pass", Amount = "+ 1", Icon = "premium", Tone = "Purple", Price = 199, Owned = true, Size = UDim2.fromOffset(340, 230), LayoutOrder = 4 },
		}, 250, 26),
	}),
	section("Progress & boards", "trophy", 14, 520, {
		row({
			scope:Column {
				LayoutOrder = 1,
				Size = UDim2.fromOffset(680, 460),
				Gap = 16,
				Align = "Left",
				[Children] = {
					scope:QuestCard {
						Title = "Hatch 5 eggs",
						Description = "Open any egg in the world",
						Icon = "egg",
						Progress = 3,
						Goal = 5,
						Reward = { Icon = "gem", Amount = 250 },
						LayoutOrder = 1,
					},
					scope:UpgradeCard {
						Title = "Coin Magnet",
						Icon = "coin",
						Level = 4,
						MaxLevel = 10,
						Cost = 1250,
						CurrencyIcon = "coin",
						Effect = "+25% coin pickup range",
						Tone = "Yellow",
						LayoutOrder = 2,
					},
					scope:StatRow { Label = "Damage", Icon = "melee", Value = "48", Bar = 0.6, Tone = "Red", LayoutOrder = 3, Size = UDim2.new(1, 0, 0, 56) },
					scope:StatRow { Label = "Speed", Icon = "run", Value = "22", Bar = 0.35, Tone = "Blue", LayoutOrder = 4, Size = UDim2.new(1, 0, 0, 56) },
				},
			},
			scope:Leaderboard {
				LayoutOrder = 2,
				Title = "Top Players",
				Stat = "Coins",
				StatIcon = "coin",
				Highlight = "Builderman",
				Rows = {
					{ rank = 1, name = "xX_Rival_Xx", value = 982340 },
					{ rank = 2, name = "Blitz", value = 811200 },
					{ rank = 3, name = "NoScope_Ninja", value = 640015 },
					{ rank = 4, name = "Builderman", value = 402100 },
					{ rank = 5, name = "Kiwi", value = 350900 },
				},
				Size = UDim2.fromOffset(460, 460),
			},
		}, 470, 26),
	}),
	section("Settings controls", "settings", 15, 300, {
		scope:Column {
			Gap = 12,
			[Children] = {
				scope:SettingRow { Text = "Music", Icon = "music", LayoutOrder = 1, [Children] = scope:Toggle { Value = musicOn } },
				scope:SettingRow { Text = "Volume", Icon = "sound", LayoutOrder = 2, [Children] = scope:Slider { Value = volume, Min = 0, Max = 100, Step = 1, Size = UDim2.fromOffset(360, 30) } },
				scope:SettingRow { Text = "Graphics", Icon = "eye", LayoutOrder = 3, [Children] = scope:Segmented { Options = { "Low", "Medium", "High" }, Value = quality } },
			},
		},
	}),
	section("Effects", "sparkle", 16, 250, {
		row({
			button("Pop", "Blue", function()
				FX.pop(currency, 0.3)
			end, 1),
			button("Shake", "Blue", function()
				FX.shake(currency, 10)
			end, 2),
			button("Burst", "Green", function()
				FX.burst(UDim2.fromScale(0.5, 0.5), { Count = 40 })
			end, 3),
			button("Sparkles", "Green", function()
				FX.burst(UDim2.fromScale(0.5, 0.5), { Shape = "sparkle", Count = 18, Size = 36 })
			end, 4),
			button("Poof", "Green", function()
				FX.poof(UDim2.fromScale(0.5, 0.5))
			end, 5),
			button("Confetti", "Green", function()
				FX.confetti()
			end, 6),
			button("Pop text", "Yellow", function()
				FX.popText(UDim2.fromScale(0.5, 0.5), "+250", { Color = UI.Theme.get().Tone.Yellow.Top })
			end, 7),
			button("Flash", "White", function()
				FX.flash()
			end, 8),
			button("Damage FX", "Red", function()
				FX.damage()
				FX.cameraShake(0.4, 0.3)
				FX.damageNumber(UDim2.fromScale(0.5, 0.45), 42, { Crit = math.random() < 0.3 })
			end, 9),
			button("Flicker", "Dark", function()
				FX.flicker(1.2)
			end, 10),
			button("Announce", "Purple", function()
				FX.announce("LEVEL UP!", { Subtitle = "You reached level 8", Icon = "trophy", Tone = "Yellow", Burst = true })
			end, 11),
			button("Jumpscare", "Red", function()
				FX.jumpscare({})
			end, 12),
		}, 140, 14),
	}),
	section("Popups & windows", "chat", 17, 170, {
		row({
			button("Notify", "Blue", function()
				UI.notify("Saved your progress!", { Type = "success" })
				UI.notify("Daily reward is ready", { Type = "reward" })
			end, 1),
			button("Confirm", "Green", function()
				local yes = UI.confirm({ Title = "Delete save?", Message = "This can't be undone.", Icon = "trash", IconColor = UI.Theme.get().Tone.Red.Top, Confirm = "Delete", Tone = "Red" })
				UI.notify(if yes then "Deleted (not really)" else "Kept it", { Type = if yes then "error" else "info" })
			end, 2),
			button("Reward", "Yellow", function()
				UI.reward({ Title = "LEGENDARY!", Icon = "crown", Amount = "+1 Crown", Tone = "Yellow" })
			end, 3),
			button("Dialogue", "Purple", function()
				local choice = UI.dialogue({
					{ Speaker = "Guide", Text = "Welcome to <b>BloxUI</b>! Everything here is built from Fusion components.", Icon = "person", Tone = "Blue" },
					{ Speaker = "Guide", Text = "Want to see a different theme?", Icon = "person", Tone = "Blue", Choices = { "Horror!", "Candy!" } },
				})
				UI.Theme.set(if choice == 1 then "Horror" else "Candy")
			end, 4),
			button("Window", "Cyan", function()
				windowOpen:set(true)
			end, 5),
		}, 70, 14),
	}),
}

--------------------------------------------------------------------------
-- Layout
--------------------------------------------------------------------------

local themeButtons = {}
for index, def in THEMES do
	local name, tone = def[1], def[2]
	local active = scope:Computed(function(use)
		return use(UI.Theme.current).Name == name
	end)
	table.insert(themeButtons, scope:Button {
		Name = name,
		Text = name,
		-- the theme you're looking at gets a check
		Icon = scope:Computed(function(use)
			return if use(active) then "check" else nil
		end),
		Tone = tone,
		Size = UDim2.new(1, 0, 0, 52),
		TextSize = 24,
		LayoutOrder = index + 10,
		OnClick = function()
			UI.Theme.set(name)
		end,
	})
end

scope:Screen {
	Name = "BloxUIGallery",
	DisplayOrder = 5,
	Enabled = visible,
	[Children] = {
		scope:Panel {
			Name = "Sidebar",
			Style = "Glass",
			Decoration = "None",
			Position = scope:Computed(function(use)
				return UDim2.fromOffset(24, use(top))
			end),
			Size = scope:Computed(function(use)
				return UDim2.new(0, 250, 1, -use(top) - 24)
			end),
			Padding = 16,
			Layout = "Column",
			Gap = 10,
			[Children] = {
				scope:Text { Text = "BloxUI", TextSize = 56, Font = "Title", Gradient = "Yellow", LayoutOrder = 0 },
				caption("Component gallery", 1),
				scope:Text {
					Name = "ThemeName",
					Text = scope:Computed(function(use)
						return `Theme: {use(UI.Theme.current).Name}`
					end),
					TextSize = 22,
					Font = "Title",
					Case = "Normal",
					LayoutOrder = 2,
				},
				caption("Themes", 3),
				themeButtons,
				caption("Press G to hide", 30),
				scope:Button {
					Text = "Toggle disabled",
					Tone = "Gray",
					Style = "Soft",
					Size = UDim2.new(1, 0, 0, 48),
					TextSize = 20,
					LayoutOrder = 31,
					OnClick = function()
						disabled:set(not peek(disabled))
					end,
				},
			},
		},
		scope:Scroll {
			Name = "Sections",
			Position = scope:Computed(function(use)
				return UDim2.fromOffset(294, use(top))
			end),
			Size = scope:Computed(function(use)
				return UDim2.new(1, -318, 1, -use(top) - 24)
			end),
			Gap = 20,
			Padding = { 8, 12, 24, 8 },
			[Children] = sections,
		},
	},
}

scope:Screen {
	Name = "BloxUIGalleryWindow",
	DisplayOrder = 30,
	[Children] = demoWindow,
}
]]></string>
            </Properties>
          </Item>
          <Item class="LocalScript" referent="266">
            <Properties>
              <string name="Name">HorrorDemo</string>
              <bool name="Disabled">true</bool>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	BloxUI template: Horror HUD

	Objective text, sprint stamina, a flashlight with a draining battery,
	and a fear meter that narrows the screen and speeds up a heartbeat.
	Also shows themed ProximityPrompts, typewriter dialogue and a jumpscare.

	HOW TO USE
	  Put this LocalScript in StarterPlayer > StarterPlayerScripts (Enabled)
	  and press Play. A glowing door appears in front of you. Walk up and
	  hold E.

	CONTROLS
	  Shift: sprint    F: flashlight    J: test jumpscare

	For a real jumpscare pass your own image:
	  hud.jumpscare({ Image = "rbxassetid://YOUR_IMAGE", Sound = "rbxassetid://YOUR_SCREAM" })
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local Workspace = game:GetService("Workspace")

local UI = require(ReplicatedStorage:WaitForChild("BloxUI"))
local peek = UI.peek

UI.Theme.set("Horror")
UI.Prompts.enable()

local player = Players.LocalPlayer
local scope = UI.scope()

local stamina = scope:Value(1)
local battery = scope:Value(1)
local fear = scope:Value(0)
local lightOn = scope:Value(false)
local flashlightOn = false
local sprinting = false

local hud = UI.Prefabs.HorrorHud(scope, {
	Objective = "", -- set below with a flicker
	Stamina = stamina,
	Battery = battery,
	Flashlight = lightOn,
	Fear = fear,
	Hints = {
		{ Text = "Sprint", Key = Enum.KeyCode.LeftShift, Gamepad = Enum.KeyCode.ButtonL3 },
		{ Text = "Flashlight", Key = Enum.KeyCode.F, Gamepad = Enum.KeyCode.DPadUp },
	},
})

--------------------------------------------------------------------------
-- Character helpers
--------------------------------------------------------------------------

local function humanoid(): Humanoid?
	local character = player.Character
	return character and character:FindFirstChildOfClass("Humanoid")
end

local light: SpotLight? = nil
local function setFlashlight(on: boolean)
	flashlightOn = on
	lightOn:set(on)
	local character = player.Character
	local head = character and character:FindFirstChild("Head")
	if on and head then
		if light == nil or light.Parent == nil then
			local newLight = Instance.new("SpotLight")
			newLight.Name = "BloxUIFlashlight"
			newLight.Brightness = 3
			newLight.Range = 40
			newLight.Angle = 50
			newLight.Parent = head
			light = newLight
		end
		light.Enabled = true
	elseif light then
		light.Enabled = false
	end
	UI.Sound.play("Click", { Pitch = if on then 1.3 else 0.9 })
end

--------------------------------------------------------------------------
-- Input
--------------------------------------------------------------------------

table.insert(scope, UserInputService.InputBegan:Connect(function(input, processed)
	if processed then
		return
	end
	if input.KeyCode == Enum.KeyCode.LeftShift or input.KeyCode == Enum.KeyCode.ButtonL3 then
		sprinting = true
	elseif input.KeyCode == Enum.KeyCode.F or input.KeyCode == Enum.KeyCode.DPadUp then
		if peek(battery) <= 0 then
			hud.hint("The flashlight is dead.", 3)
		else
			setFlashlight(not flashlightOn)
		end
	elseif input.KeyCode == Enum.KeyCode.J then
		hud.jumpscare({})
	end
end))

table.insert(scope, UserInputService.InputEnded:Connect(function(input)
	if input.KeyCode == Enum.KeyCode.LeftShift or input.KeyCode == Enum.KeyCode.ButtonL3 then
		sprinting = false
	end
end))

-- Phones: sprint (hold) and the flashlight around the jump button
scope:Screen {
	Name = "HorrorTouch",
	DisplayOrder = 6,
	[UI.Children] = scope:MobileControls {
		Buttons = {
			{
				id = "sprint",
				label = "Run",
				icon = "run",
				OnPress = function()
					sprinting = true
				end,
				OnRelease = function()
					sprinting = false
				end,
			},
			{
				id = "light",
				label = "Light",
				icon = "bolt",
				OnPress = function()
					if peek(battery) <= 0 then
						hud.hint("The flashlight is dead.", 3)
					else
						setFlashlight(not flashlightOn)
					end
				end,
			},
		},
	},
}

--------------------------------------------------------------------------
-- Per-frame: stamina, battery, fear
--------------------------------------------------------------------------

local elapsed = 0
table.insert(scope, RunService.Heartbeat:Connect(function(dt)
	elapsed += dt

	-- sprinting drains stamina, resting refills it
	local hum = humanoid()
	local moving = hum ~= nil and hum.MoveDirection.Magnitude > 0.1
	local canSprint = sprinting and moving and peek(stamina) > 0
	stamina:set(math.clamp(peek(stamina) + (if canSprint then -dt / 4 else dt / 6), 0, 1))
	if hum then
		hum.WalkSpeed = if canSprint then 24 else 14
	end

	-- the flashlight eats battery
	if flashlightOn then
		battery:set(math.max(0, peek(battery) - dt / 90))
		if peek(battery) <= 0 then
			setFlashlight(false)
			hud.hint("Your flashlight flickers out...", 4)
			hud.flicker(0.8)
		end
	end

	-- fear comes in slow waves, and it's worse in the dark
	local wave = (math.sin(elapsed / 7) + 1) / 2
	local target = wave * (if flashlightOn then 0.55 else 0.9)
	fear:set(peek(fear) + (target - peek(fear)) * math.min(1, dt * 0.5))
end))

--------------------------------------------------------------------------
-- A door with a themed ProximityPrompt
--------------------------------------------------------------------------

local function spawnDoor()
	local character = player.Character or player.CharacterAdded:Wait()
	local root = character:WaitForChild("HumanoidRootPart", 10) :: BasePart?
	if root == nil then
		return
	end
	local door = Instance.new("Part")
	door.Name = "MysteriousDoor"
	door.Anchored = true
	door.Size = Vector3.new(5, 8, 1)
	door.Color = Color3.fromHex("3B2A20")
	door.Material = Enum.Material.Wood
	door.CFrame = root.CFrame * CFrame.new(0, 1, -14)
	door.Parent = Workspace
	table.insert(scope, door)

	local glow = Instance.new("PointLight")
	glow.Color = Color3.fromHex("B3121C")
	glow.Range = 14
	glow.Parent = door

	local prompt = Instance.new("ProximityPrompt")
	prompt.ActionText = "Open"
	prompt.ObjectText = "Mysterious Door"
	prompt.HoldDuration = 1.2
	prompt.MaxActivationDistance = 12
	prompt.Parent = door

	prompt.Triggered:Connect(function()
		prompt.Enabled = false
		UI.FX.flicker(1.2)
		task.wait(1.2)
		UI.dialogue({
			{ Speaker = "???", Text = "...you shouldn't be here.", Icon = "eye", Tone = "Red" },
			{ Speaker = "???", Text = "The key is in the basement. <i>If</i> you make it.", Icon = "eye", Tone = "Red" },
		})
		hud.objective("Find the basement key")
		fear:set(0.8)
	end)
end

task.spawn(spawnDoor)

hud.objective("Find a way out")
-- no cheerful toasts in a horror game: instructions are a quiet subtitle
hud.hint(
	if peek(UI.Input.device) == "Touch"
		then "Hold RUN to sprint.  LIGHT for the flashlight."
		else "Hold <b>Shift</b> to sprint.  <b>F</b> for the flashlight.",
	6
)
]]></string>
            </Properties>
          </Item>
          <Item class="LocalScript" referent="267">
            <Properties>
              <string name="Name">SelfTest</string>
              <bool name="Disabled">true</bool>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	BloxUI template: Self-Test

	Runs BloxUI in the REAL engine and reports anything that goes wrong. It
	builds the five example blueprints and the ready-made samples
	(Blueprint.Samples: two per genre, made by BloxUI's generator), opens
	every screen, plays the big effects, and checks:
	  * errors and warnings in the Output while each step runs
	  * text that doesn't fit its box (TextFits = false)
	  * that the Rubik and Builder Icons fonts load on this device
	  * how the engine treats TextSize above 100

	HOW TO USE
	  Put this LocalScript in StarterPlayer > StarterPlayerScripts (Enabled)
	  and press Play. It takes about two minutes (set QUICK = true for about
	  one), then prints a report to the Output and shows a summary card.
	  Run it on a phone too with Studio's device emulator or a test server.
	  Remove it when you're done; it's only a check.
]]

local ContentProvider = game:GetService("ContentProvider")
local LogService = game:GetService("LogService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local BloxUI = ReplicatedStorage:WaitForChild("BloxUI")
local UI = require(BloxUI)

local QUICK = false -- true: one sample per genre and shorter waits
local WAIT = if QUICK then 0.35 else 0.6 -- seconds each screen stays open
local MAX_LISTED = 40 -- issues printed in the report

local TAG = "[BloxUI SelfTest]"

local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")

--------------------------------------------------------------------------
-- Recording
--------------------------------------------------------------------------

local issues = {}
local seen = {}
local current = "startup"
local checks = 0
local counts = { error = 0, warning = 0, overflow = 0, asset = 0 }

local function record(kind: string, message: string)
	local key = kind .. "|" .. message
	if seen[key] then
		return
	end
	seen[key] = true
	counts[kind] = (counts[kind] or 0) + 1
	table.insert(issues, { kind = kind, step = current, message = message })
end

local logConnection = LogService.MessageOut:Connect(function(message, messageType)
	if string.sub(message, 1, #TAG) == TAG then
		return
	end
	if messageType == Enum.MessageType.MessageError then
		record("error", message)
	elseif messageType == Enum.MessageType.MessageWarning then
		record("warning", message)
	end
end)

local function step(name: string, fn: () -> ())
	current = name
	checks += 1
	local ok, err = pcall(fn)
	if not ok then
		record("error", tostring(err))
	end
end

--------------------------------------------------------------------------
-- Text overflow: labels the engine says don't fit their box
--------------------------------------------------------------------------

local function shown(gui: Instance): boolean
	local node: Instance? = gui
	while node and node ~= playerGui do
		if node:IsA("GuiObject") and not node.Visible then
			return false
		elseif node:IsA("LayerCollector") and not node.Enabled then
			return false
		end
		node = node.Parent
	end
	return true
end

local function scanText(roots: { Instance }, where: string)
	for _, root in roots do
		for _, item in root:GetDescendants() do
			if
				(item:IsA("TextLabel") or item:IsA("TextButton"))
				and item.Text ~= ""
				and not item.TextScaled
				and item.TextTruncate == Enum.TextTruncate.None
				and item.TextTransparency < 0.95
				and item.AbsoluteSize.X > 1
				and shown(item)
			then
				local ok, fits = pcall(function()
					return item.TextFits
				end)
				if ok and fits == false then
					local text = if #item.Text > 40 then string.sub(item.Text, 1, 40) .. "..." else item.Text
					record("overflow", `{where}: "{text}" doesn't fit {item:GetFullName()}`)
				end
			end
		end
	end
end

local function newGuis(before: { [Instance]: boolean }): { Instance }
	local list = {}
	for _, child in playerGui:GetChildren() do
		if not before[child] then
			table.insert(list, child)
		end
	end
	return list
end

local function snapshot(): { [Instance]: boolean }
	local set = {}
	for _, child in playerGui:GetChildren() do
		set[child] = true
	end
	return set
end

--------------------------------------------------------------------------
-- Checks
--------------------------------------------------------------------------

local function checkFonts()
	local labels = {}
	local names = {}
	local function add(name: string, family: string)
		local label = Instance.new("TextLabel")
		label.FontFace = Font.new(family, Enum.FontWeight.Bold)
		label.Text = "home 0"
		table.insert(labels, label)
		names[family] = name
	end
	add("Rubik (Arena theme)", "rbxassetid://12187365977")
	add("Builder Icons", UI.Assets.IconFont)
	ContentProvider:PreloadAsync(labels, function(assetId, status)
		if status ~= Enum.AssetFetchStatus.Success then
			record("asset", `{assetId} failed to load ({status.Name})`)
		end
	end)
	for _, label in labels do
		label:Destroy()
	end
end

local textSizeNote = ""
local function checkTextSize()
	local probe = Instance.new("TextLabel")
	probe.TextSize = 150
	textSizeNote = `TextSize 150 reads back as {probe.TextSize}`
	probe:Destroy()
end

local function exercise(name: string, blueprint: any)
	local scope = UI.scope()
	local before = snapshot()
	local ui = nil
	step(`{name}: build`, function()
		ui = UI.Blueprint.build(scope, blueprint, { Parent = playerGui })
	end)
	if ui then
		task.wait(WAIT)
		current = `{name}: HUD`
		scanText(newGuis(before), current)
		local screens = if typeof(blueprint) == "table" and typeof(blueprint.screens) == "table" then blueprint.screens else {}
		for _, screen in screens do
			local id = typeof(screen) == "table" and screen.id
			if typeof(id) == "string" then
				step(`{name}: open "{id}"`, function()
					ui:open(id)
					task.wait(WAIT)
					scanText(newGuis(before), current)
					ui:close(id)
					task.wait(0.15)
				end)
			end
		end
		step(`{name}: destroy`, function()
			ui:destroy()
		end)
	end
	UI.doCleanup(scope)
	task.wait(0.2)
end

--------------------------------------------------------------------------
-- Run
--------------------------------------------------------------------------

local started = os.clock()
print(`{TAG} running{if QUICK then " (quick)" else ""}...`)

step("fonts", checkFonts)
step("TextSize", checkTextSize)

for name, blueprint in UI.Blueprint.Examples do
	exercise(`example {name}`, blueprint)
end

-- the ready-made samples (quick: the first one of each genre)
local Samples = UI.Blueprint.Samples
local samples = if Samples then Samples.list() else {}
local sampleNote = `{#samples} samples`
if #samples == 0 then
	sampleNote = "no samples in this copy of BloxUI (click Self-Test in the plugin toolbar to add them)"
end
local genresDone = {}
for _, entry in samples do
	if QUICK and genresDone[entry.genre] then
		continue
	end
	genresDone[entry.genre] = true
	local blueprint = nil
	step(`sample {entry.name}`, function()
		blueprint = Samples.get(entry.name)
		assert(blueprint ~= nil, `sample {entry.name} didn't load`)
	end)
	if blueprint then
		exercise(`{entry.genre} #{entry.seed}`, blueprint)
	end
end

step("effects", function()
	UI.Theme.set("Toon")
	UI.notify("Self-test toast", { Type = "success" })
	UI.FX.confetti()
	UI.FX.announce("SELF-TEST", { Subtitle = "effects", Icon = "trophy", Tone = "Yellow", Burst = true })
	task.wait(2)
	UI.countdown({ From = 3, Text = "GO!" })
	task.wait(1)
end)

current = "report"
logConnection:Disconnect()

--------------------------------------------------------------------------
-- Report
--------------------------------------------------------------------------

local total = #issues
local elapsed = os.clock() - started
local summary = `{checks} checks in {string.format("%.0f", elapsed)} s: {counts.error} errors, {counts.warning} warnings, `
	.. `{counts.overflow} text overflows, {counts.asset} asset failures. {textSizeNote}; {sampleNote}.`

print(`{TAG} {summary}`)
for index, issue in issues do
	if index > MAX_LISTED then
		print(`{TAG} ...and {total - MAX_LISTED} more`)
		break
	end
	print(`{TAG} [{issue.kind}] {issue.step}: {issue.message}`)
end
if total == 0 then
	print(`{TAG} PASSED. Everything built, opened and closed cleanly on this device.`)
end

local lines = {}
for index, issue in issues do
	if index > 8 then
		table.insert(lines, `...and {total - 8} more (see Output)`)
		break
	end
	local message = if #issue.message > 90 then string.sub(issue.message, 1, 90) .. "..." else issue.message
	table.insert(lines, `• [{issue.kind}] {issue.step}: {message}`)
end

pcall(function()
	local Children = UI.Children
	local scope = UI.scope()
	scope:Screen {
		Name = "BloxUI_SelfTest",
		DisplayOrder = 2000,
		[Children] = scope:Panel {
			Style = "Glass",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.5),
			Size = UDim2.fromOffset(980, 0),
			AutoSize = "Y",
			Padding = 28,
			Layout = "Column",
			Gap = 14,
			[Children] = {
				scope:Text {
					Text = if total == 0 then "SELF-TEST PASSED" else `SELF-TEST: {total} ISSUE{if total == 1 then "" else "S"}`,
					TextSize = 56,
					Font = "Title",
					Gradient = if total == 0 then "Green" else "Red",
					Size = UDim2.new(1, 0, 0, 64),
					LayoutOrder = 1,
				},
				scope:Text {
					Text = summary,
					TextSize = 24,
					Wrapped = true,
					Size = UDim2.new(1, 0, 0, 88),
					LayoutOrder = 2,
				},
				scope:Text {
					Text = if total == 0 then "Everything built, opened and closed cleanly on this device." else table.concat(lines, "\n"),
					TextSize = 20,
					Wrapped = true,
					XAlign = "Left",
					Size = UDim2.new(1, 0, 0, 240),
					LayoutOrder = 3,
				},
			},
		},
	}
end)
]]></string>
            </Properties>
          </Item>
          <Item class="LocalScript" referent="268">
            <Properties>
              <string name="Name">ShopDemo</string>
              <bool name="Disabled">true</bool>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	BloxUI template: Key Shop

	A tabbed shop in the RIVALS style: a key counter with a tooltip, dark
	glass tabs with pop-out icons, a red callout, bundle cards with big key
	art and lime price buttons, a daily reward page, and a settings page with
	a live theme switcher. Buying plays the full payoff: confirm, a poof at
	the card, keys fly into the counter and it rolls up. One-time offers sell
	out and skins you own say so.

	HOW TO USE
	  1. Put this LocalScript in StarterPlayer > StarterPlayerScripts and
	     make sure it's Enabled.
	  2. Press Play. Press B (or click the Shop button) to open/close.

	SELLING REAL PRODUCTS
	  Give each bundle a ProductId (Creator Dashboard > Monetization >
	  Developer Products). BloxUI will show Roblox's purchase prompt. Grant the
	  keys on the SERVER in MarketplaceService.ProcessReceipt, never here.
	  Bundles without a ProductId run the demo flow below instead.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local UI = require(ReplicatedStorage:WaitForChild("BloxUI"))
local Children = UI.Children
local peek = UI.peek
local Kit = UI.Kit

UI.Theme.set("Arena")

local scope = UI.scope()
local keys = scope:Value(13)

local KEY_YELLOW = Color3.fromHex("FFD21F")

local shop -- assigned below; pages reference it

--------------------------------------------------------------------------
-- Daily rewards page
--------------------------------------------------------------------------

local DAILY = {
	{ Day = 1, Icon = "key", Amount = 5 },
	{ Day = 2, Icon = "key", Amount = 10 },
	{ Day = 3, Icon = "gem", Amount = 3, Color = Color3.fromHex("45B8FF") },
	{ Day = 4, Icon = "key", Amount = 20 },
	{ Day = 5, Icon = "gift", Amount = 1, Color = Color3.fromHex("FF7AB6") },
	{ Day = 6, Icon = "key", Amount = 40 },
	{ Day = 7, Icon = "crown", Amount = 1, Color = Color3.fromHex("FFD21F") },
}

local today = 1
local claimedDays = scope:Value(0)
local dailyCallout = scope:Value("Claim your FREE reward now!")

local function claimDaily(from)
	local reward = DAILY[today]
	claimedDays:set(today)
	dailyCallout:set("")
	UI.FX.poof(from, { Size = 40 })
	UI.reward({
		Title = `DAY {today} REWARD`,
		Icon = reward.Icon,
		IconColor = reward.Color or KEY_YELLOW,
		Amount = `+{reward.Amount}`,
	})
	if reward.Icon == "key" then
		UI.FX.flyTo(from, shop.Currency, {
			Icon = "key",
			Color = KEY_YELLOW,
			Count = math.min(reward.Amount, 10),
			OnDone = function()
				keys:set(peek(keys) + reward.Amount)
			end,
		})
	end
end

local function dailyPage(pageScope)
	local cards = {}
	for index, reward in DAILY do
		local claimed = pageScope:Computed(function(use)
			return use(claimedDays) >= reward.Day
		end)
		local isToday = reward.Day == today
		local isBig = reward.Day == 7
		local card
		card = pageScope:Panel {
			Name = `Day{reward.Day}`,
			LayoutOrder = index,
			Style = pageScope:Computed(function(use)
				if isBig or (isToday and not use(claimed)) then
					return "Gradient"
				end
				return "Glass"
			end),
			Tone = if isBig then "Purple" else "Yellow",
			Decoration = if isBig then "Sparkles" else "None",
			Shadow = true,
			[Children] = {
				pageScope:Text {
					Text = `Day {reward.Day}`,
					TextSize = 26,
					Font = "Title",
					XAlign = "Left",
					Outline = "Art",
					Position = UDim2.fromOffset(16, 12),
				},
				pageScope:Icon {
					Icon = reward.Icon,
					Size = if isBig then 110 else 92,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.47),
					Color = reward.Color or KEY_YELLOW,
					Gradient = true,
					Shadow = true,
					Glow = isToday or isBig,
					Rotation = -10,
					Transparency = pageScope:Computed(function(use)
						return if use(claimed) and not isToday then 0.5 else 0
					end),
				},
				pageScope:Text {
					Text = `x{reward.Amount}`,
					TextSize = 28,
					Font = "Display",
					Outline = "Art",
					AnchorPoint = Vector2.new(1, 0.5),
					Position = UDim2.new(1, -16, 0.5, 34),
				},
				if isToday
					then pageScope:Button {
						Text = pageScope:Computed(function(use)
							return if use(claimed) then "Claimed" else "Claim!"
						end),
						Tone = "Green",
						Disabled = claimed,
						ClickFx = "Poof",
						Pulse = pageScope:Computed(function(use)
							return not use(claimed)
						end),
						Shine = true,
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.new(0.5, 0, 1, -12),
						Size = UDim2.new(1, -24, 0, 52),
						TextSize = 26,
						OnClick = function()
							claimDaily(card)
						end,
					}
					else pageScope:New "Frame" {
						Name = "State",
						AnchorPoint = Vector2.new(0.5, 1),
						Position = UDim2.new(0.5, 0, 1, -12),
						Size = UDim2.new(1, -24, 0, 44),
						BackgroundColor3 = Kit.themed(pageScope, function(theme)
							return theme.Color.Surface
						end),
						BackgroundTransparency = 0.5,
						[Children] = {
							Kit.corner(pageScope, "M"),
							pageScope:Row {
								Gap = 6,
								Align = "Center",
								[Children] = {
									pageScope:Icon {
										Icon = pageScope:Computed(function(use)
											return if use(claimed) then "check" else "lock"
										end),
										Size = 24,
										LayoutOrder = 1,
									},
									pageScope:Text {
										Text = pageScope:Computed(function(use)
											return if use(claimed) then "Claimed" else "Tomorrow"
										end),
										TextSize = 20,
										Font = "Small",
										LayoutOrder = 2,
									},
								},
							},
						},
					},
			},
		}
		table.insert(cards, card)
	end
	return {
		pageScope:Grid {
			CellSize = Vector2.new(250, 250),
			Gap = 24,
			Columns = 4,
			Padding = 16,
			[Children] = cards,
		},
	}
end

--------------------------------------------------------------------------
-- Settings page: sounds, UI size, theme switcher
--------------------------------------------------------------------------

local function settingsPage(pageScope)
	local sounds = pageScope:Value(UI.Sound.enabled)
	local themeButtons = {}
	for index, def in {
		{ "Toon", "Yellow" },
		{ "Arena", "Green" },
		{ "Bubbly", "Pink" },
		{ "Adventure", "Orange" },
		{ "Sleek", "Blue" },
		{ "Candy", "Purple" },
		{ "Tactical", "Gray" },
		{ "Horror", "Red" },
	} do
		local name, tone = def[1], def[2]
		table.insert(themeButtons, pageScope:Button {
			Text = name,
			Tone = tone,
			Size = UDim2.fromOffset(150, 52),
			TextSize = 22,
			LayoutOrder = index,
			OnClick = function()
				UI.Theme.set(name)
				UI.notify(`Theme: {name}`, { Type = "success", Icon = "paint-brush" })
			end,
		})
	end
	return {
		pageScope:Column {
			Gap = 14,
			Padding = { 16, 60, 12, 60 },
			[Children] = {
				pageScope:SettingRow {
					Text = "Sounds",
					Icon = "sound",
					LayoutOrder = 1,
					[Children] = pageScope:Toggle {
						Value = sounds,
						OnChanged = function(on)
							UI.Sound.enabled = on
						end,
					},
				},
				pageScope:SettingRow {
					Text = "UI Size",
					Icon = "search",
					LayoutOrder = 2,
					[Children] = pageScope:Slider {
						Value = UI.Viewport.userScale,
						Min = 0.75,
						Max = 1.25,
						Step = 0.05,
						Format = function(v)
							return `{math.round(v * 100)}%`
						end,
						Size = UDim2.fromOffset(360, 30),
					},
				},
				pageScope:SettingRow {
					Text = "Theme",
					Icon = "paint-brush",
					LayoutOrder = 3,
					Size = UDim2.new(1, 0, 0, 150),
					[Children] = pageScope:Grid {
						CellSize = Vector2.new(150, 52),
						Gap = Vector2.new(10, 10),
						Columns = 4,
						Align = "Right",
						VAlign = "Center",
						Size = UDim2.fromOffset(630, 120),
						[Children] = themeButtons,
					},
				},
			},
		},
	}
end

--------------------------------------------------------------------------
-- Home page
--------------------------------------------------------------------------

-- a smaller card on the home page that jumps to its tab
local function featured(pageScope, order, title, amount, icon, tone, tab, tag)
	return pageScope:ProductCard {
		Name = `Featured_{title}`,
		Title = title,
		Amount = amount,
		Icon = icon,
		Tone = tone,
		Price = "View",
		PriceIcon = false,
		Tag = tag,
		LayoutOrder = order,
		Size = UDim2.new(1 / 3, -16, 0, 220),
		OnPurchase = function()
			shop.Selected:set(tab)
		end,
	}
end

local function homePage(pageScope)
	return {
		pageScope:Row {
			Name = "Featured",
			Position = UDim2.fromOffset(16, 344),
			Size = UDim2.new(1, -32, 0, 230),
			Gap = 24,
			[Children] = {
				featured(pageScope, 1, "Key Bundles", "+ 1,100", "key", "Yellow", "keys", "HOT"),
				featured(pageScope, 2, "Starter Pack", "+ 25", "gift", "Orange", "bundles", nil),
				featured(pageScope, 3, "Void Wrap", "Legendary", "moon-with-stars", "Legendary", "skins", "NEW"),
			},
		},
		pageScope:Panel {
			Name = "Welcome",
			Style = "Gradient",
			Tone = "Purple",
			Decoration = "Sparkles",
			Shadow = true,
			Clip = true,
			Size = UDim2.new(1, -32, 0, 300),
			Position = UDim2.fromOffset(16, 16),
			[Children] = {
				pageScope:Text {
					Text = "WELCOME TO THE SHOP!",
					TextSize = 60,
					Font = "Title",
					Outline = "Art",
					XAlign = "Left",
					Position = UDim2.fromOffset(40, 38),
				},
				pageScope:Text {
					Text = "Grab keys, claim your daily reward, and try every theme in Settings.",
					TextSize = 24,
					Font = "Small",
					Case = "Normal",
					Outline = "Art",
					XAlign = "Left",
					Position = UDim2.fromOffset(42, 118),
				},
				pageScope:Button {
					Text = "Get Keys",
					Icon = "key",
					IconColor = KEY_YELLOW,
					RichIcon = true,
					Tone = "Green",
					Shine = true,
					Size = UDim2.fromOffset(250, 72),
					TextSize = 32,
					Position = UDim2.fromOffset(42, 186),
					OnClick = function()
						shop.Selected:set("keys")
					end,
				},
				pageScope:Icon {
					Icon = "gift",
					Size = 230,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.new(1, -190, 0.5, 6),
					Rotation = 10,
					Color = Color3.fromHex("FF7AB6"),
					Gradient = true,
					Shadow = true,
					Glow = true,
				},
			},
		},
	}
end

--------------------------------------------------------------------------
-- Purchases (demo flow for bundles without a ProductId)
--------------------------------------------------------------------------

local function demoPurchase(product, _tab, card)
	local keyCount = product.Keys or 0
	local ok = UI.confirm({
		Title = `Buy {product.Title}?`,
		Message = (if keyCount > 0
			then `{keyCount} keys for {product.Price} Robux.`
			else `{product.Title} for {product.Price} Robux.`) .. "\n<i>(Demo: nothing is charged.)</i>",
		Icon = product.Icon or "key",
		IconColor = KEY_YELLOW,
		Confirm = "Buy",
	})
	if not ok then
		return
	end
	-- the payoff happens on the card itself: poof, keys fly, the counter
	-- rolls and a "+40" rises under it (no toast needed on top of the shop)
	if keyCount > 0 then
		shop.reward(card, keyCount, { Icon = "key", Color = KEY_YELLOW })
	else
		shop.reward(card, 0, { Text = "UNLOCKED!", Color = KEY_YELLOW })
	end
	if product.OwnedValue then
		product.OwnedValue:set(true)
	end
	if product.Stock then
		product.Stock:set(math.max(0, peek(product.Stock) - 1))
	end
end

local function bundle(title, amount, keyCount, price, extra)
	local product = { Title = title, Amount = amount, Keys = keyCount, Price = price, Icon = "key" }
	for k, v in extra or {} do
		product[k] = v
	end
	return product
end

-- cosmetics you can own: the card switches to OWNED once bought
local function ownable(product, alreadyOwned)
	product.OwnedValue = scope:Value(alreadyOwned)
	product.Owned = product.OwnedValue
	return product
end

--------------------------------------------------------------------------
-- The shop
--------------------------------------------------------------------------

shop = UI.Prefabs.Shop(scope, {
	Currency = {
		Icon = "key",
		Color = KEY_YELLOW,
		Amount = keys,
		Tooltip = "Complete your tasks for keys!",
		OnAdd = function()
			shop.open("keys")
		end,
	},
	Tabs = {
		{ Id = "home", Text = "Home", Icon = "shop", IconColor = Color3.fromHex("FF4B4B"), Content = homePage },
		{
			Id = "bundles",
			Text = "Bundles",
			Icon = "gift",
			IconColor = Color3.fromHex("FF7A3D"),
			Tone = "Orange",
			Products = {
				-- a one-time offer: buy it once and it sells out
				bundle("Starter Pack", "+ 25", 25, 99, { Icon = "gift", Stock = scope:Value(1) }),
				bundle("Pro Pack", "+ 120", 120, 399, { Icon = "gift", Tone = "Purple" }),
				bundle("Mega Pack", "+ 500", 500, 1499, { Icon = "gift", Tone = "Red", Sparkles = true, Tag = "BEST VALUE" }),
			},
		},
		{
			Id = "skins",
			Text = "Skins",
			Icon = "skins",
			IconColor = Color3.fromHex("4FA8FF"),
			Tone = "Blue",
			Products = {
				ownable(bundle("Neon Wrap", "Rare", 0, 250, { Icon = "paint-brush", Tone = "Rare" }), true),
				ownable(bundle("Gold Wrap", "Mythic", 0, 750, { Icon = "crown", Tone = "Mythic" }), false),
				ownable(bundle("Void Wrap", "Legendary", 0, 1999, { Icon = "moon-with-stars", Tone = "Legendary", Sparkles = true }), false),
			},
		},
		{
			Id = "daily",
			Text = "Daily",
			Icon = "daily",
			IconColor = Color3.fromHex("2FB8FF"),
			Callout = dailyCallout,
			Content = dailyPage,
		},
		{
			Id = "keys",
			Text = "Keys",
			Icon = "key",
			IconColor = KEY_YELLOW,
			Products = {
				bundle("Key Bundle", "+ 10", 10, 48),
				bundle("Mega Key Bundle", "+ 40", 40, 160),
				bundle("Super Key Bundle", "+ 90", 90, 308, { Tag = "POPULAR" }),
				bundle("Ultra Key Bundle", "+ 450", 450, 1320),
				bundle("Legendary Key Bundle", "+ 1,100", 1100, 3000, { Tone = "Red", Tag = "BEST VALUE", Sparkles = true }),
			},
		},
		{ Id = "settings", Text = "Settings", Icon = "settings", IconColor = Color3.fromHex("B9BECC"), Content = settingsPage },
	},
	Selected = "keys",
	Open = true,
	Button = { Text = "Shop", Icon = "shop", Hotkey = Enum.KeyCode.B },
	OnPurchase = demoPurchase,
})
]]></string>
            </Properties>
          </Item>
          <Item class="LocalScript" referent="269">
            <Properties>
              <string name="Name">SimulatorDemo</string>
              <bool name="Disabled">true</bool>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	BloxUI template: Simulator HUD

	A top-simulator layout (Pet Simulator 99 / Grow a Garden style): big
	coin and gem counters, a level bar, a grid of menu tiles, a goal bar,
	boost timers and a juicy click button in the middle. Coins burst out and
	fly into the counter, spinning coins in the world can be collected, and
	the Shop is a side-tab window with upgrades, boosts and gem packs.

	HOW TO USE
	  Put this LocalScript in StarterPlayer > StarterPlayerScripts (Enabled)
	  and press Play.

	NOTE
	  Currency here lives on the client so the demo is self-contained. In a
	  real game keep it on the server (leaderstats or attributes) and show it
	  with UI.leaderstat(scope, "Coins") or UI.fromAttribute(...).
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local UI = require(ReplicatedStorage:WaitForChild("BloxUI"))
local Children = UI.Children
local peek = UI.peek
local Kit = UI.Kit

UI.Theme.set("Bubbly")

local player = Players.LocalPlayer
local scope = UI.scope()

local COIN_GOLD = Color3.fromHex("FFC21F")
local GEM_BLUE = Color3.fromHex("45B8FF")

-- Game state (in a real game these come from the server)
local coins = scope:Value(0)
local gems = scope:Value(5)
local clickPower = scope:Value(1)
local autoLevel = scope:Value(0)
local luckLevel = scope:Value(0)
local rebirths = scope:Value(0)
local clicks = scope:Value(0)
local boostEndsAt = scope:Value(0)
local shopOpen = scope:Value(false)
local shopTab = scope:Value("upgrades")

local hud -- assigned below

local function multiplier(): number
	local boost = if os.time() < peek(boostEndsAt) then 2 else 1
	return (1 + peek(rebirths)) * boost
end

local perClick = scope:Computed(function(use)
	local boost = if os.time() < use(boostEndsAt) then 2 else 1
	return use(clickPower) * (1 + use(rebirths)) * boost
end)

local function onClick()
	clicks:set(peek(clicks) + 1)
	hud.give("Coins", peek(clickPower) * multiplier(), hud.Clicker)
	local gemEvery = math.max(5, 15 - peek(luckLevel) * 2)
	if peek(clicks) % gemEvery == 0 then
		hud.give("Gems", 1, hud.Clicker)
	end
end

local function rebirth()
	local cost = 1000 * (peek(rebirths) + 1)
	if peek(coins) < cost then
		UI.notify(`Rebirth costs <b>{UI.Format.abbrev(cost)}</b> coins`, { Type = "warning", Icon = "rebirth" })
		return
	end
	if not UI.confirm({
		Title = "Rebirth?",
		Message = "Reset your coins and upgrades for a permanent <b>+1x</b> coin multiplier.",
		Icon = "rebirth",
		IconColor = Color3.fromHex("B98CFF"),
		Confirm = "Rebirth!",
		Tone = "Purple",
	}) then
		return
	end
	coins:set(0)
	clickPower:set(1)
	autoLevel:set(0)
	rebirths:set(peek(rebirths) + 1)
	UI.FX.announce("REBIRTH!", { Subtitle = `Multiplier: {1 + peek(rebirths)}x`, Icon = "rebirth", Tone = "Purple", Burst = true })
	UI.FX.confetti()
end

local function claimDaily()
	UI.reward({ Title = "DAILY GIFT", Icon = "gem", IconColor = GEM_BLUE, Amount = "+10 Gems" })
	hud.give("Gems", 10, UDim2.fromScale(0.5, 0.5))
end

local function openShop(tab: string?)
	if tab then
		shopTab:set(tab)
	end
	shopOpen:set(true)
end

hud = UI.Prefabs.SimulatorHud(scope, {
	Currencies = {
		{
			Id = "Coins",
			Icon = "coin",
			Color = COIN_GOLD,
			Amount = coins,
			OnAdd = function()
				openShop("gems")
			end,
		},
		{ Id = "Gems", Icon = "gem", Color = GEM_BLUE, Amount = gems },
	},
	-- dark glass chips match the goal bar and boost pills (one surface family)
	ChipStyle = "Glass",
	Level = {
		Level = scope:Computed(function(use)
			return 1 + math.floor(use(clicks) / 25)
		end),
		Progress = scope:Computed(function(use)
			return (use(clicks) % 25) / 25
		end),
		Tone = "Purple",
	},
	Buttons = {
		{
			Text = "Shop",
			Icon = "shop",
			Color = Color3.fromHex("FFB21F"),
			OnClick = function()
				shopOpen:set(not peek(shopOpen))
			end,
		},
		{
			Text = "Pets",
			Icon = "egg",
			Color = Color3.fromHex("FF7AB6"),
			OnClick = function()
				UI.notify("Pets are coming soon!", { Icon = "egg" })
			end,
		},
		{ Text = "Daily", Icon = "gift", Color = Color3.fromHex("5BD94A"), Badge = 1, OnClick = claimDaily },
		{
			Text = "Quests",
			Icon = "trophy",
			Color = Color3.fromHex("FFC21F"),
			OnClick = function()
				UI.notify("Collect <b>100 coins</b> to finish your first quest!", { Icon = "trophy" })
			end,
		},
	},
	RightButtons = {
		{ Text = "Rebirth", Icon = "rebirth", Color = Color3.fromHex("B98CFF"), OnClick = rebirth },
		{
			Text = "Teleport",
			Icon = "world",
			Color = Color3.fromHex("45B8FF"),
			OnClick = function()
				UI.notify("New worlds unlock at <b>1K</b> coins", { Icon = "world" })
			end,
		},
	},
	Boosts = {
		{ Icon = "boost", Text = "2x Coins", Tone = "Green", EndsAt = boostEndsAt, Duration = 300 },
		{ Icon = "star", Text = "2x Luck", Tone = "Purple", EndsAt = os.time() + 272, Duration = 300 },
	},
	Goal = {
		Text = "Unlock Candy Land",
		Icon = "world",
		Color = Color3.fromHex("FF7AB6"),
		Value = coins,
		Max = 1000,
		OnReached = function()
			UI.FX.announce("CANDY LAND UNLOCKED!", { Subtitle = "Teleport there from the world map", Icon = "world", Tone = "Pink", Burst = true })
		end,
	},
	Clicker = {
		Text = "CLICK!",
		Icon = "coin",
		IconColor = COIN_GOLD,
		Per = scope:Computed(function(use)
			return `+{UI.Format.abbrev(use(perClick))} per click`
		end),
		OnClick = onClick,
	},
})

-- Auto clicker income
local alive = true
table.insert(scope, function()
	alive = false
end)
task.spawn(function()
	while alive do
		task.wait(1)
		if not alive then
			break
		end
		local level = peek(autoLevel)
		if level > 0 then
			coins:set(peek(coins) + level * multiplier())
		end
	end
end)

--------------------------------------------------------------------------
-- The shop window
--------------------------------------------------------------------------

-- One upgrade: icon tile, title, effect, level pips and a buy button.
local function upgradeRow(pageScope, order: number, def)
	local level = def.Level
	local cost = pageScope:Computed(function(use)
		return math.floor(def.BaseCost * def.Growth ^ (use(level) - def.Start))
	end)
	local maxed = pageScope:Computed(function(use)
		return use(level) - def.Start >= def.Max
	end)
	local pips = {}
	for index = 1, def.Max do
		table.insert(pips, pageScope:New "Frame" {
			Name = "Pip",
			LayoutOrder = index,
			Size = UDim2.fromOffset(22, 10),
			BackgroundColor3 = pageScope:Computed(function(use)
				local theme = use(UI.Theme.current)
				local filled = use(level) - def.Start >= index
				return if filled then UI.Theme.tone(theme, def.Tone).Bottom else Color3.fromHex("D9DCE6")
			end),
			[Children] = Kit.corner(pageScope, "Full"),
		})
	end

	local look, layers = Kit.surface(pageScope, { Style = "Light", Radius = "M" })
	return pageScope:New "Frame" {
		Name = def.Title,
		LayoutOrder = order,
		Size = UDim2.new(1, 0, 0, 112),
		BackgroundColor3 = look.BackgroundColor3,
		BackgroundTransparency = look.BackgroundTransparency,
		[Children] = {
			layers,
			pageScope:New "Frame" {
				Name = "IconTile",
				AnchorPoint = Vector2.new(0, 0.5),
				Position = UDim2.new(0, 16, 0.5, 0),
				Size = UDim2.fromOffset(80, 80),
				BackgroundColor3 = Color3.new(1, 1, 1),
				ZIndex = 2,
				[Children] = {
					Kit.corner(pageScope, "M"),
					Kit.gradient(pageScope, {
						Color = Kit.themed(pageScope, function(theme)
							return Kit.faceColors(UI.Theme.tone(theme, def.Tone), 0)
						end),
					}),
					Kit.stroke(pageScope, {
						Color = Kit.themed(pageScope, function(theme)
							return UI.Theme.tone(theme, def.Tone).Edge
						end),
					}),
					pageScope:Icon {
						Icon = def.Icon,
						Size = 50,
						AnchorPoint = Vector2.new(0.5, 0.5),
						Position = UDim2.fromScale(0.5, 0.5),
						Color = Color3.new(1, 1, 1),
						StrokeColor = Kit.themed(pageScope, function(theme)
							return UI.Theme.tone(theme, def.Tone).Edge
						end),
						ZIndex = 3,
					},
				},
			},
			pageScope:Text {
				Name = "Title",
				Text = def.Title,
				TextSize = 28,
				Font = "Title",
				XAlign = "Left",
				Color = Kit.themed(pageScope, function(theme)
					return theme.Color.TextDark
				end),
				Stroke = false,
				Position = UDim2.fromOffset(114, 14),
				ZIndex = 2,
			},
			pageScope:Text {
				Name = "Effect",
				Text = pageScope:Computed(function(use)
					return def.Effect(use(level))
				end),
				TextSize = 19,
				Font = "Small",
				XAlign = "Left",
				Case = "Normal",
				Color = Kit.themed(pageScope, function(theme)
					return theme.Tone.Gray.Edge
				end),
				Stroke = false,
				Position = UDim2.fromOffset(114, 48),
				ZIndex = 2,
			},
			pageScope:Row {
				Name = "Pips",
				Position = UDim2.fromOffset(114, 80),
				Size = UDim2.fromOffset(0, 12),
				AutoSize = "X",
				Gap = 5,
				ZIndex = 2,
				[Children] = pips,
			},
			pageScope:Button {
				Name = "Price",
				Text = pageScope:Computed(function(use)
					return if use(maxed) then "MAX" else UI.Format.abbrev(use(cost))
				end),
				Icon = pageScope:Computed(function(use)
					return if use(maxed) then nil else def.CurrencyIcon
				end),
				IconColor = def.CurrencyColor,
				RichIcon = true,
				Tone = "Green",
				Disabled = maxed,
				AnchorPoint = Vector2.new(1, 0.5),
				Position = UDim2.new(1, -18, 0.5, 0),
				Size = UDim2.fromOffset(180, 64),
				TextSize = 28,
				Sound = "Purchase",
				ZIndex = 3,
				OnClick = function()
					if hud.spend(def.Currency, peek(cost)) then
						level:set(peek(level) + 1)
						UI.FX.poof(UI.FX.Layer.get(), { Count = 0, Ring = false })
						UI.notify(`<b>{def.Title}</b> upgraded!`, { Type = "success", Icon = def.Icon })
					end
				end,
			},
		},
	}
end

local function upgradesPage(pageScope)
	return pageScope:Scroll {
		Gap = 14,
		Padding = { 4, 8, 12, 4 },
		[Children] = {
			upgradeRow(pageScope, 1, {
				Title = "Click Power",
				Icon = "bolt",
				Tone = "Yellow",
				Level = clickPower,
				Start = 1,
				Max = 10,
				BaseCost = 25,
				Growth = 2,
				Currency = "Coins",
				CurrencyIcon = "coin",
				CurrencyColor = COIN_GOLD,
				Effect = function(lv)
					return `+{lv} coins per click  →  +{lv + 1}`
				end,
			}),
			upgradeRow(pageScope, 2, {
				Title = "Auto Clicker",
				Icon = "timer",
				Tone = "Blue",
				Level = autoLevel,
				Start = 0,
				Max = 8,
				BaseCost = 150,
				Growth = 2.2,
				Currency = "Coins",
				CurrencyIcon = "coin",
				CurrencyColor = COIN_GOLD,
				Effect = function(lv)
					return `{lv} coins every second  →  {lv + 1}`
				end,
			}),
			upgradeRow(pageScope, 3, {
				Title = "Gem Finder",
				Icon = "gem",
				Tone = "Purple",
				Level = luckLevel,
				Start = 0,
				Max = 5,
				BaseCost = 10,
				Growth = 1.8,
				Currency = "Gems",
				CurrencyIcon = "gem",
				CurrencyColor = GEM_BLUE,
				Effect = function(lv)
					return `A gem every {math.max(5, 15 - lv * 2)} clicks  →  {math.max(5, 13 - lv * 2)}`
				end,
			}),
		},
	}
end

local function boostCard(pageScope, order, title, amount, icon, tone, price, seconds)
	return pageScope:ProductCard {
		Title = title,
		Amount = amount,
		Icon = icon,
		Tone = tone,
		Price = price,
		PriceIcon = "gem",
		LayoutOrder = order,
		Size = UDim2.new(1 / 3, -11, 0, 230),
		OnPurchase = function()
			if hud.spend("Gems", price) then
				if title == "Coin Rush" then
					-- the "2x Coins" pill is already bound to boostEndsAt; extending it refills the timer
					boostEndsAt:set(math.max(os.time(), peek(boostEndsAt)) + seconds)
				else
					hud.addBoost({ Icon = icon, Text = amount, Tone = tone, EndsAt = os.time() + seconds, Duration = seconds })
				end
				UI.notify(`<b>{title}</b> active!`, { Type = "success", Icon = icon })
			end
		end,
	}
end

local function boostsPage(pageScope)
	return {
		pageScope:Text {
			Text = "Boosts stack with rebirth multipliers",
			TextSize = 20,
			Font = "Small",
			Case = "Normal",
			Stroke = false,
			Color = Kit.themed(pageScope, function(theme)
				return theme.Color.PanelText
			end),
			Position = UDim2.fromOffset(4, 0),
		},
		pageScope:Row {
			Position = UDim2.fromOffset(0, 44),
			Size = UDim2.new(1, 0, 0, 240),
			Gap = 16,
			[Children] = {
				boostCard(pageScope, 1, "Coin Rush", "2x Coins", "boost", "Green", 5, 300),
				boostCard(pageScope, 2, "Lucky Charm", "2x Luck", "star", "Purple", 8, 600),
				boostCard(pageScope, 3, "Mega Rush", "3x Speed", "bolt", "Orange", 12, 120),
			},
		},
	}
end

local function gemPack(pageScope, order, title, amount, gemCount, price, extra)
	return pageScope:ProductCard {
		Title = title,
		Amount = amount,
		Icon = "gem",
		Tone = extra and extra.Tone or "Blue",
		Price = price,
		PriceIcon = "robux",
		Tag = extra and extra.Tag,
		Sparkles = extra and extra.Sparkles,
		LayoutOrder = order,
		Size = UDim2.new(1 / 3, -11, 0, 230),
		OnPurchase = function()
			if UI.confirm({
				Title = `Buy {title}?`,
				Message = `{gemCount} gems for {price} Robux.\n<i>(Demo: nothing is charged.)</i>`,
				Icon = "gem",
				IconColor = GEM_BLUE,
				Confirm = "Buy",
			}) then
				hud.give("Gems", gemCount, UDim2.fromScale(0.5, 0.5))
			end
		end,
	}
end

local function gemsPage(pageScope)
	return pageScope:Row {
		Size = UDim2.new(1, 0, 0, 240),
		Position = UDim2.fromOffset(0, 8),
		Gap = 16,
		[Children] = {
			gemPack(pageScope, 1, "Gem Pouch", "+ 25", 25, 49),
			gemPack(pageScope, 2, "Gem Chest", "+ 150", 150, 249, { Tone = "Purple", Tag = "POPULAR" }),
			gemPack(pageScope, 3, "Gem Vault", "+ 800", 800, 999, { Tone = "Pink", Tag = "BEST VALUE", Sparkles = true }),
		},
	}
end

scope:Screen {
	Name = "SimulatorShop",
	DisplayOrder = 20,
	[Children] = scope:Window {
		Open = shopOpen,
		Title = "Shop",
		Icon = "shop",
		IconColor = Color3.fromHex("FFB21F"),
		Size = "Medium",
		Nav = "SideTabs",
		OffsetY = 44,
		Selected = shopTab,
		Tabs = {
			{ Id = "upgrades", Text = "Upgrades", Icon = "upgrade", IconColor = Color3.fromHex("5BD94A") },
			{ Id = "boosts", Text = "Boosts", Icon = "boost", IconColor = Color3.fromHex("FFB21F") },
			{ Id = "gems", Text = "Gems", Icon = "gem", IconColor = GEM_BLUE, Badge = "!" },
		},
		Pages = {
			upgrades = upgradesPage,
			boosts = boostsPage,
			gems = gemsPage,
		},
		CloseOnBackdrop = true,
	},
}

--------------------------------------------------------------------------
-- Collectible coins in the world
--------------------------------------------------------------------------

local function spawnCoins()
	local character = player.Character or player.CharacterAdded:Wait()
	local root = character:WaitForChild("HumanoidRootPart", 10) :: BasePart?
	if root == nil then
		return
	end
	local origin = root.Position
	for i = 1, 12 do
		local angle = i / 12 * math.pi * 2
		local coin = Instance.new("Part")
		coin.Name = "Coin"
		coin.Shape = Enum.PartType.Cylinder
		coin.Size = Vector3.new(0.4, 2.4, 2.4)
		coin.Color = COIN_GOLD
		coin.Material = Enum.Material.Neon
		coin.Anchored = true
		coin.CanCollide = false
		coin.Position = origin + Vector3.new(math.cos(angle) * 18, 0, math.sin(angle) * 18)
		coin.Parent = Workspace
		table.insert(scope, coin)

		local spin = 0
		local connection
		connection = RunService.Heartbeat:Connect(function(dt)
			if coin.Parent == nil then
				connection:Disconnect()
				return
			end
			spin += dt * 3
			coin.CFrame = CFrame.new(coin.Position) * CFrame.Angles(0, spin, math.pi / 2)
			local current = player.Character and player.Character:FindFirstChild("HumanoidRootPart")
			if current and (current.Position - coin.Position).Magnitude < 5 then
				connection:Disconnect()
				local amount = 10 * multiplier()
				hud.give("Coins", amount, coin.Position)
				coin:Destroy()
			end
		end)
		table.insert(scope, connection)
	end
end

task.spawn(spawnCoins)

UI.notify("Click the big coin, or walk into the coins!", { Type = "info", Icon = "coin" })
]]></string>
            </Properties>
          </Item>
        </Item>
        <Item class="ModuleScript" referent="270">
          <Properties>
            <string name="Name">Theme</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Theme

	One reactive theme drives every BloxUI component. Switch it at runtime and
	all UI on screen restyles itself:

		UI.Theme.set("Arena")      -- Toon (default), Arena, Bubbly, Adventure,
		                           -- Sleek, Candy, Tactical, Horror

	Make your own by extending a preset:

		UI.Theme.register("Lava", UI.Theme.extend("Toon", {
			Color = { Accent = Color3.fromHex("FF5A00") },
			Tone = { Lava = UI.Theme.makeTone(Color3.fromHex("FF5A00")) },
			Style = { ButtonStyle = "Pill", ClickFx = "Ripple" },
		}))
		UI.Theme.set("Lava")

	Or build one from a Blueprint style (docs/BLUEPRINT.md):

		UI.Theme.set(UI.Theme.fromStyle({
			archetype = "Bubbly",
			palette = { primary = "#FF7AB6", accent = "#FFD12E" },
			fonts = { display = "BuilderSans:ExtraBold" },
			corner = "Round", clickFx = "Sparkle",
		}))

	Every theme has: Font, Color, Alpha, Tone, Rarity, Radius, Stroke, Depth,
	Motion, Style, Type, Space, Sound, Blur, Scrim, ScrollBar. See
	Presets/Toon.luau.
]]

local Fusion = require(script.Parent.Packages.Fusion)
local Base = require(script.Base)
local Oklch = require(script.Oklch)
local Families = require(script.Families)

local Theme = {}

local scope = Fusion.scoped(Fusion)

Theme.Base = Base
Theme.RARITY_ORDER = Base.RARITY_ORDER
Theme.STYLE_OPTIONS = Base.STYLE_OPTIONS
-- v2.4: perceptual colour maths and the style families (Theme/Families.luau)
Theme.Oklch = Oklch
Theme.Families = Families
Theme.FAMILIES = Families.IDS
Theme.TYPE_MIN = Base.TYPE_MIN

-- The active family's recipe (theme.Family), or nil for a classic theme.
function Theme.family(theme: { [any]: any }?): { [string]: any }?
	local t = theme or Fusion.peek(Theme.current)
	return if typeof(t) == "table" then t.Family else nil
end

local function deepCopy(value: any): any
	if typeof(value) ~= "table" then
		return value
	end
	local copy = {}
	for key, inner in value do
		copy[key] = deepCopy(inner)
	end
	return copy
end

local function deepMerge(into: { [any]: any }, from: { [any]: any })
	for key, value in from do
		if typeof(value) == "table" and typeof(into[key]) == "table" then
			deepMerge(into[key], value)
		else
			into[key] = deepCopy(value)
		end
	end
end

--------------------------------------------------------------------------
-- Normalising: every theme gets every field, whatever it was built from.
--------------------------------------------------------------------------

local function fillMissing(into: { [any]: any }, defaults: { [any]: any })
	for key, value in defaults do
		if into[key] == nil then
			into[key] = deepCopy(value)
		elseif typeof(value) == "table" and typeof(into[key]) == "table" and #value == 0 then
			fillMissing(into[key], value)
		end
	end
end

-- Fills anything a theme table is missing (Style, Rarity, Type, the newer
-- Color slots, ...) so hand-made themes from v1 keep working. Returns the
-- same table.
function Theme.normalize(theme: { [any]: any }): { [any]: any }
	theme.Color = theme.Color or {}
	local color = theme.Color
	color.Text = color.Text or Color3.new(1, 1, 1)
	color.Outline = color.Outline or Color3.fromHex("15111F")
	color.Surface = color.Surface or Color3.fromHex("0D0B14")
	color.SurfaceLight = color.SurfaceLight or color.Surface:Lerp(Color3.new(1, 1, 1), 0.12)
	color.Light = color.Light or Color3.new(1, 1, 1)
	color.TextDark = color.TextDark or Color3.fromHex("17141F")
	color.TextMuted = color.TextMuted or color.Text:Lerp(color.Surface, 0.35)
	color.Backdrop = color.Backdrop or Color3.new(0, 0, 0)
	color.Track = color.Track or color.Surface
	color.Accent = color.Accent or Color3.fromHex("FFD21F")
	color.Good = color.Good or Color3.fromHex("5BE22B")
	color.Bad = color.Bad or Color3.fromHex("FF3B3B")
	color.Warn = color.Warn or Color3.fromHex("FFB300")
	color.Info = color.Info or Color3.fromHex("3AA8FF")
	color.Panel = color.Panel or color.SurfaceLight
	color.PanelText = color.PanelText or color.Text
	color.Trim = color.Trim or color.Accent
	color.Glass = color.Glass or Color3.new(1, 1, 1)
	color.Focus = color.Focus or color.Accent -- gamepad selection ring
	color.Scroll = color.Scroll or color.Text -- scroll bar thumb
	color.Scrim = color.Scrim or color.Backdrop -- dims the world behind modal windows
	-- the accent for text and small glyphs (Text uses it for accent-coloured
	-- labels): the accent, lifted when it's too dark to read on the glass
	color.AccentText = color.AccentText or Base.readableAccent(color.Accent, color.Surface)

	theme.Alpha = theme.Alpha or {}
	fillMissing(theme.Alpha, { Surface = 0.3, Backdrop = 0.45, Shadow = 0.5, Track = 0.1, Rim = 0.88 })

	theme.Tone = theme.Tone or {}
	for name, hexColor in {
		Green = "5ED422", Red = "E5261F", Yellow = "FFC20A", Blue = "1F84F5", Purple = "8A43F0",
		Orange = "FF7618", Pink = "FF4FAD", Cyan = "1FC6E0", Gray = "6B7084", White = "E4E7F0", Dark = "221F30",
	} do
		if theme.Tone[name] == nil then
			theme.Tone[name] = Base.makeTone(Color3.fromHex(hexColor))
		end
	end
	-- disabled buttons / toggles / sliders (defaults to the Gray tone)
	if theme.Tone.Disabled == nil then
		theme.Tone.Disabled = theme.Tone.Gray
	end
	for _, tone in theme.Tone do
		if typeof(tone) == "table" and tone.Top then
			tone.Bottom = tone.Bottom or tone.Top
			tone.Edge = tone.Edge or tone.Bottom:Lerp(Color3.new(0, 0, 0), 0.4)
			tone.Deep = tone.Deep or tone.Bottom:Lerp(Color3.new(0, 0, 0), 0.7)
			tone.Text = tone.Text or Color3.new(1, 1, 1)
		end
	end

	theme.Style = theme.Style or {}
	fillMissing(theme.Style, Base.style())

	-- Rarity follows Style.RarityScheme unless the theme brings its own set.
	-- RarityMute (0..1, Base.mute) keeps a serious theme's rarity colours
	-- muted whenever the set is rebuilt (Theme.fromStyle, colour-blind modes).
	local scheme = theme.Style.RarityScheme or "Classic"
	if theme.Rarity == nil or (theme.RarityBuiltFor ~= nil and theme.RarityBuiltFor ~= scheme) then
		theme.Rarity = Base.rarities(scheme, theme.RarityMute)
		theme.RarityBuiltFor = scheme
	else
		fillMissing(theme.Rarity, Base.rarities(scheme, theme.RarityMute))
	end

	theme.Radius = theme.Radius or {}
	fillMissing(theme.Radius, { S = 6, M = 10, L = 14 })
	theme.Stroke = theme.Stroke or {}
	fillMissing(theme.Stroke, { Text = 2.25, UI = 2.5, Thin = 1.5 })
	theme.Depth = theme.Depth or 4
	theme.Motion = theme.Motion or {}
	fillMissing(theme.Motion, Base.motion())
	theme.TextStroke = theme.TextStroke or {}
	fillMissing(theme.TextStroke, Base.textStroke())
	theme.Type = theme.Type or {}
	fillMissing(theme.Type, Base.typeScale())
	theme.Space = theme.Space or {}
	fillMissing(theme.Space, Base.space())
	theme.Sound = theme.Sound or {}
	fillMissing(theme.Sound, Base.sounds())
	theme.Blur = theme.Blur or 0
	-- how strongly a modal window dims the world / HUD behind it (0..1 opacity)
	theme.Scrim = theme.Scrim or 0.45
	theme.ScrollBar = theme.ScrollBar or {}
	fillMissing(theme.ScrollBar, { Thickness = 8, Transparency = 0.1 })

	if theme.Font == nil then
		theme.Font = Base.builderFonts()
	else
		theme.Font.Small = theme.Font.Small or theme.Font.Body
		fillMissing(theme.Font, Base.builderFonts())
	end
	return theme
end

Theme.presets = {
	Toon = Theme.normalize(require(script.Presets.Toon)),
	Arena = Theme.normalize(require(script.Presets.Arena)),
	Bubbly = Theme.normalize(require(script.Presets.Bubbly)),
	Adventure = Theme.normalize(require(script.Presets.Adventure)),
	Sleek = Theme.normalize(require(script.Presets.Sleek)),
	Candy = Theme.normalize(require(script.Presets.Candy)),
	Tactical = Theme.normalize(require(script.Presets.Tactical)),
	Horror = Theme.normalize(require(script.Presets.Horror)),
}

-- Preset names in a sensible display order.
Theme.names = { "Toon", "Arena", "Bubbly", "Adventure", "Sleek", "Candy", "Tactical", "Horror" }

-- The active theme table, as a Fusion Value. Components `use()` this.
Theme.current = scope:Value(Theme.presets.Toon)

--[[
	Lite mode for low-end devices: Button, ItemSlot and leaderboard rows skip
	purely decorative layers (the glossy highlight gradients and the rim / lip
	outlines), so each is several instances lighter. Off by default.

	Theme.lite is read when a component is built: set it before building UI
	and the layers are never made.

		if UI.Viewport.isTouch then UI.Theme.lite = true end

	Theme.setLite(on) is the live switch (the Blueprint "lowDetail" setting):
	UI already on screen drops those layers at once (their instances are
	destroyed) and rebuilds them when it's turned off again. Theme.liteMode is
	its Fusion Value; Theme.isLite() is true for either.
]]
Theme.lite = false
Theme.liteMode = scope:Value(false)

function Theme.setLite(on: boolean)
	Theme.liteMode:set(on == true)
end

function Theme.isLite(): boolean
	return Theme.lite == true or Fusion.peek(Theme.liteMode) == true
end

--[[
	Colourblind modes (the Blueprint "colorblind" setting). While one is on,
	every theme Theme.set applies (and the current one, at once) has its
	Good / Bad / Warn colours, its Green and Red tones and its rarity ladder
	swapped for a palette that kind of colour blindness still tells apart
	(Base.COLORBLIND): blue / orange instead of green / red, and so on.
	Components restyle live, like any theme change.

		UI.Theme.setColorblind("Deuteranopia")   -- "Off" | "Deuteranopia" | "Protanopia" | "Tritanopia"
		UI.Theme.getColorblind()                 --> "Deuteranopia"
]]
Theme.COLORBLIND_MODES = { "Off", "Deuteranopia", "Protanopia", "Tritanopia" }
local colorblindMode = "Off"
local unfiltered: { [any]: any } = Theme.presets.Toon -- the theme Theme.set was given

-- A copy of `theme` in the palette for `mode`; "Off" (or an unknown mode)
-- returns `theme` itself.
function Theme.colorblind(theme: { [any]: any }, mode: string?): { [any]: any }
	local palette = Base.COLORBLIND[mode or ""]
	if palette == nil then
		return theme
	end
	local out = deepCopy(theme)
	-- a muted theme (Horror) keeps its restraint in every palette
	local mute = theme.RarityMute
	local good = Base.mute(Color3.fromHex(palette.Good), mute)
	local bad = Base.mute(Color3.fromHex(palette.Bad), mute)
	local warn = Base.mute(Color3.fromHex(palette.Warn), mute)
	out.Color.Good, out.Color.Bad, out.Color.Warn = good, bad, warn
	-- the alert red (kill markers, callout bubbles) is a "bad" colour too
	if out.Color.Callout ~= nil then
		out.Color.Callout = bad
	end
	-- themes whose fixed tones have no text outline keep it that way
	local stroked = theme.Tone.Green ~= nil and theme.Tone.Green.Stroke ~= nil
	local function toneOf(color: Color3)
		return Base.makeTone(color, if stroked then nil else { Stroke = false })
	end
	out.Tone.Green = toneOf(good)
	out.Tone.Red = toneOf(bad)
	out.Tone.Good = toneOf(good)
	out.Tone.Bad = toneOf(bad)
	out.Tone.Warn = toneOf(warn)
	out.Rarity = Base.rarities(palette.Rarity, mute)
	out.Colorblind = mode
	return out
end

function Theme.setColorblind(mode: string?)
	local wanted = if typeof(mode) == "string" and table.find(Theme.COLORBLIND_MODES, mode) then mode else "Off"
	if wanted == colorblindMode then
		return
	end
	colorblindMode = wanted :: string
	Theme.current:set(Theme.colorblind(unfiltered, colorblindMode))
end

function Theme.getColorblind(): string
	return colorblindMode
end

-- Returns a new theme: a copy of `base` (a preset name or theme table) with
-- `overrides` merged over it. Only the keys you give are replaced.
function Theme.extend(base: string | { [any]: any }, overrides: { [any]: any }): { [any]: any }
	local baseTheme = if typeof(base) == "string" then Theme.presets[base] else base
	assert(baseTheme, `[BloxUI] Theme.extend: no theme named "{base}"`)
	local theme = deepCopy(baseTheme)
	deepMerge(theme, overrides)
	local colors = overrides.Color
	if typeof(colors) == "table" and (colors.Accent ~= nil or colors.Surface ~= nil) and colors.AccentText == nil then
		theme.Color.AccentText = nil -- normalize works it out from the new accent
	end
	if overrides.Name == nil then
		theme.Name = `{baseTheme.Name}+`
	end
	return Theme.normalize(theme)
end

-- Adds a theme so Theme.set(name) can find it.
function Theme.register(name: string, theme: { [any]: any })
	theme.Name = name
	Theme.presets[name] = Theme.normalize(theme)
	if not table.find(Theme.names, name) then
		table.insert(Theme.names, name)
	end
end

-- Creator Store fonts (like Arena's Rubik) download on first use; start that
-- as soon as a theme picks them, so the first window doesn't flash the
-- fallback font. Each family is only fetched once.
local preloaded: { [string]: boolean } = {}
function Theme.preload(theme: { [any]: any }?)
	local fonts = (theme or Theme.get()).Font
	if typeof(fonts) ~= "table" then
		return
	end
	local labels = {}
	for _, font in fonts do
		if typeof(font) == "Font" and string.find(font.Family, "^rbxassetid://") and not preloaded[font.Family] then
			preloaded[font.Family] = true
			local label = Instance.new("TextLabel")
			label.FontFace = font
			label.Text = "0"
			table.insert(labels, label)
		end
	end
	if #labels > 0 then
		task.spawn(function()
			pcall(function()
				game:GetService("ContentProvider"):PreloadAsync(labels)
			end)
			for _, label in labels do
				label:Destroy()
			end
		end)
	end
end

-- Switches every BloxUI component to a theme (name or table).
function Theme.set(theme: string | { [any]: any })
	local resolved = if typeof(theme) == "string" then Theme.presets[theme] else theme
	if resolved == nil then
		warn(`[BloxUI] Theme.set: no theme named "{theme}"`)
		return
	end
	local normalized = Theme.normalize(resolved)
	Theme.preload(normalized)
	unfiltered = normalized
	Theme.current:set(Theme.colorblind(normalized, colorblindMode))
end

-- The active theme table (not reactive; use Theme.current inside Computeds).
-- In a colourblind mode this is the recoloured copy.
function Theme.get(): { [any]: any }
	return Fusion.peek(Theme.current)
end

-- Looks up a tone by name ("Green", "Red", ... or a rarity like "Epic") or
-- passes a tone table straight through. Unknown names fall back to Gray.
function Theme.tone(theme: { [any]: any }, tone: any): { [any]: any }
	if typeof(tone) == "table" then
		return tone
	end
	if typeof(tone) == "Color3" then
		return Base.makeTone(tone)
	end
	return theme.Tone[tone] or (theme.Rarity and theme.Rarity[tone]) or theme.Tone.Gray
end

-- Rarity tone by name ("Common" .. "Secret"); unknown names give Common.
function Theme.rarity(theme: { [any]: any }, rarity: any): { [any]: any }
	if typeof(rarity) == "table" then
		return rarity
	end
	local set = theme.Rarity or Base.rarities()
	return set[rarity] or set.Common
end

-- theme.Style[key] with the documented default when a theme leaves it out.
function Theme.style(theme: { [any]: any }, key: string): string
	local style = theme.Style
	local value = style and style[key]
	if value == nil then
		value = Base.style()[key]
	end
	return value
end

-- A text outline recipe for a background context (docs/RESEARCH.md 2.2):
--   "Glass"  HUD text on dark glass (none in Arena)
--   "Art"    titles on busy / colourful art (soft, translucent)
--   "Button" labels on coloured buttons (tinted with the tone)
--   "Solid"  a crisp outline where contrast demands it (white label on white)
--   "None"
-- Returns { Color, Transparency, Thickness }; Color falls back to `tint`
-- (e.g. tone.Stroke / tone.Edge) and then the theme outline.
function Theme.textStroke(theme: { [any]: any }, recipe: string?, tint: Color3?): { [string]: any }
	if recipe == "None" then
		return { Color = theme.Color.Outline, Transparency = 1, Thickness = 0 }
	end
	local set = theme.TextStroke or Base.textStroke()
	local r = set[recipe or "Glass"] or set.Glass
	return {
		Color = r.Color or tint or theme.Color.Outline,
		Transparency = r.Transparency or 0,
		Thickness = r.Thickness or theme.Stroke.Text,
	}
end

-- Looks up a font slot ("Title", "Display", "Body", "Heavy", "Small", "Mono",
-- and the optional "Ornate": a decorative face used only for window titles,
-- nil = Title) or passes a Font straight through. A "Family:Weight" string
-- also works.
function Theme.font(theme: { [any]: any }, font: any): Font
	if typeof(font) == "Font" then
		return font
	end
	local slot = theme.Font[font or "Body"]
	if slot then
		return slot
	end
	-- v2.4 type-role slots fall back to the classic ones
	if font == "Number" then
		return theme.Font.Display or theme.Font.Body
	elseif font == "Label" then
		return theme.Font.Heavy or theme.Font.Body
	end
	if typeof(font) == "string" and string.find(font, ":") then
		return Theme.fontFromRef(font)
	end
	return theme.Font.Body
end

--------------------------------------------------------------------------
-- Tones
--------------------------------------------------------------------------

-- Builds a tone ({ Top, Bottom, Edge, Deep, Text, Stroke }) from one colour.
-- options: { Text: Color3?, Stroke: Color3 | boolean?, Lift: number?, Depth: number? }
function Theme.makeTone(color: Color3, options: { [string]: any }?): { [string]: any }
	return Base.makeTone(color, options)
end

--------------------------------------------------------------------------
-- Fonts
--------------------------------------------------------------------------

-- Every built-in family (content/fonts/families) and the weights it ships.
Theme.FONT_FAMILIES = {
	AccanthisADFStd = { 400 },
	AmaticSC = { 400, 700 },
	Arimo = { 400, 500, 600, 700 },
	Balthazar = { 400 },
	Bangers = { 400 },
	BuilderExtended = { 300, 400, 600, 700, 800 },
	BuilderMono = { 300, 400, 700 },
	BuilderSans = { 100, 300, 400, 500, 600, 700, 800 },
	ComicNeueAngular = { 400 },
	Creepster = { 400 },
	DenkOne = { 400 },
	Fondamento = { 400 },
	FredokaOne = { 400 },
	GrenzeGotisch = { 100, 200, 300, 400, 500, 600, 700, 800, 900 },
	Guru = { 400 },
	HighwayGothic = { 400 },
	Inconsolata = { 200, 300, 400, 500, 600, 700, 800, 900 },
	IndieFlower = { 400 },
	JosefinSans = { 100, 200, 300, 400, 500, 600, 700 },
	Jura = { 300, 400, 500, 600, 700 },
	Kalam = { 300, 400, 700 },
	LuckiestGuy = { 400 },
	Merriweather = { 300, 400, 700, 900 },
	Michroma = { 400 },
	Montserrat = { 100, 200, 300, 400, 500, 600, 700, 800, 900 },
	Nunito = { 200, 300, 400, 600, 700, 800, 900 },
	Oswald = { 200, 300, 400, 500, 600, 700 },
	PatrickHand = { 400 },
	PermanentMarker = { 400 },
	PressStart2P = { 400 },
	Roboto = { 100, 300, 400, 500, 700, 900 },
	RobotoCondensed = { 300, 400, 700 },
	RobotoMono = { 100, 200, 300, 400, 500, 600, 700 },
	RomanAntique = { 400 },
	Sarpanch = { 400, 500, 600, 700, 800, 900 },
	SourceSansPro = { 200, 300, 400, 600, 700, 900 },
	SpecialElite = { 400 },
	TitilliumWeb = { 200, 300, 400, 600, 700, 900 },
	Ubuntu = { 300, 400, 500, 700 },
	Zekton = { 400 },
}

local WEIGHTS = {
	thin = 100,
	hairline = 100,
	extralight = 200,
	ultralight = 200,
	light = 300,
	regular = 400,
	normal = 400,
	book = 400,
	medium = 500,
	semibold = 600,
	demibold = 600,
	bold = 700,
	extrabold = 800,
	ultrabold = 800,
	heavy = 900,
	black = 900,
}

local WEIGHT_ENUMS = {
	[100] = "Thin",
	[200] = "ExtraLight",
	[300] = "Light",
	[400] = "Regular",
	[500] = "Medium",
	[600] = "SemiBold",
	[700] = "Bold",
	[800] = "ExtraBold",
	[900] = "Heavy",
}

-- Free Roblox-published Creator Store families (no upload needed), by
-- asset id, with the weights they ship.
Theme.FONT_ASSETS = {
	Rubik = { id = Base.RUBIK, weights = { 300, 400, 500, 600, 700, 800, 900 } },
}

local familyByLower: { [string]: string } = {}
for name in Theme.FONT_FAMILIES do
	familyByLower[string.lower(name)] = name
end
for name in Theme.FONT_ASSETS do
	familyByLower[string.lower(name)] = name
end

local fontCache: { [string]: Font } = {}

--[[
	"BuilderSans:ExtraBold" -> Font. Also accepts "Builder Sans", "FredokaOne",
	"Montserrat:700", "Rubik:Black" (a Creator Store family by asset id), a
	Font, or nil (Builder Sans Bold).
	Unknown families fall back to Builder Sans, and a weight the family
	doesn't ship snaps to the nearest one it does, so the result always
	renders with the intended font.
]]
function Theme.fontFromRef(ref: any): Font
	if typeof(ref) == "Font" then
		return ref
	end
	local text = if typeof(ref) == "string" then ref else "BuilderSans:Bold"
	local cached = fontCache[text]
	if cached then
		return cached
	end

	local familyPart, weightPart = string.match(text, "^%s*([^:]*):?%s*(.-)%s*$")
	local key = string.lower(string.gsub(familyPart or "", "[%s_%-]", ""))
	local family = familyByLower[key]
	if family == nil and key == "gotham" then
		family = "BuilderSans" -- Gotham was retired; Builder Sans replaced it
	end
	family = family or "BuilderSans"

	local wanted = 700
	if weightPart and weightPart ~= "" then
		local number = tonumber(weightPart)
		if number then
			wanted = number
		else
			wanted = WEIGHTS[string.lower(string.gsub(weightPart, "[%s_%-]", ""))] or 700
		end
	elseif family ~= "BuilderSans" then
		wanted = 400
	end

	local asset = Theme.FONT_ASSETS[family]
	local available = if asset then asset.weights else Theme.FONT_FAMILIES[family]
	local best = available[1]
	for _, weight in available do
		if math.abs(weight - wanted) < math.abs(best - wanted)
			or (math.abs(weight - wanted) == math.abs(best - wanted) and weight > best)
		then
			best = weight
		end
	end

	local weight = Enum.FontWeight[WEIGHT_ENUMS[best]]
	local font = if asset then Font.new(asset.id, weight) else Font.fromName(family, weight)
	fontCache[text] = font
	return font
end

--------------------------------------------------------------------------
-- Blueprint styles -> themes
--------------------------------------------------------------------------

local function parseHex(value: any): Color3?
	if typeof(value) == "Color3" then
		return value
	end
	if typeof(value) ~= "string" then
		return nil
	end
	local digits = string.match(value, "^%s*#?(%x%x%x%x%x%x)%s*$")
	if digits == nil then
		local short = string.match(value, "^%s*#?(%x%x%x)%s*$")
		if short == nil then
			return nil
		end
		digits = string.gsub(short, "%x", "%0%0")
	end
	return Color3.fromHex(digits)
end
Theme.parseHex = parseHex

local function option(key: string, value: any, fallback: string): string
	if typeof(value) == "string" then
		for _, allowed in Base.STYLE_OPTIONS[key] do
			if string.lower(allowed) == string.lower(value) then
				return allowed
			end
		end
	end
	return fallback
end

local CORNERS = {
	Sharp = { S = 3, M = 4, L = 6 },
	Soft = { S = 6, M = 10, L = 14 },
	Round = { S = 10, M = 16, L = 22 },
	Bubbly = { S = 14, M = 24, L = 32 },
}

--[[
	Builds a complete theme from a Blueprint `style` table (docs/BLUEPRINT.md):
	archetype preset + palette + fonts + corner + every Style switch. Missing
	or invalid fields keep the archetype's own choices. Palette colours also
	become tones: Tone.Primary, Tone.Secondary, Tone.Accent, Tone.Good,
	Tone.Bad and Tone.Warn.
]]
function Theme.fromStyle(style: { [string]: any }?): { [any]: any }
	local s = style or {}
	-- v2.4: a style family re-derives the whole theme from the archetype's
	local familyId = Families.resolve(s.family or s.Family)
	local archetype = option("Archetype", s.archetype or s.Archetype, if familyId then Families.DEFS[familyId].base else "Arena")
	if familyId then
		-- the classic theme first (every field filled, the blueprint's own
		-- fonts / corner / switches applied), then the family on top
		local classicStyle = table.clone(s)
		classicStyle.family, classicStyle.Family = nil, nil
		classicStyle.archetype = archetype
		local classic = Theme.fromStyle(classicStyle)
		local themed = Families.apply(classic, familyId, s, Theme.fontFromRef)
		themed.RarityBuiltFor = themed.Style.RarityScheme
		return Theme.normalize(themed)
	end
	local base = Theme.presets[archetype] or Theme.presets.Arena
	local theme = deepCopy(base)
	theme.Name = if typeof(s.name) == "string" then s.name else `{archetype}Custom`

	local palette = s.palette or {}
	local color = theme.Color

	local primary = parseHex(palette.primary)
	local secondary = parseHex(palette.secondary)
	local accent = parseHex(palette.accent)
	local surface = parseHex(palette.surface)
	local text = parseHex(palette.text)
	local good = parseHex(palette.good)
	local bad = parseHex(palette.bad)
	local warn_ = parseHex(palette.warn)

	local stroked = base.Tone.Green and base.Tone.Green.Stroke ~= nil
	local function toneOf(c: Color3)
		return Base.makeTone(c, if stroked then nil else { Stroke = false })
	end

	theme.Tone.Primary = if primary then toneOf(primary) else deepCopy(base.Tone.Blue)
	theme.Tone.Secondary = if secondary then toneOf(secondary) else deepCopy(base.Tone.Purple)
	theme.Tone.Accent = if accent then toneOf(accent) else deepCopy(base.Tone.Yellow)
	theme.Tone.Good = if good then toneOf(good) else deepCopy(base.Tone.Green)
	theme.Tone.Bad = if bad then toneOf(bad) else deepCopy(base.Tone.Red)
	theme.Tone.Warn = if warn_ then toneOf(warn_) else deepCopy(base.Tone.Orange)

	if primary then
		color.Primary = primary
		-- Outlined / Ribbon panels take their border from the primary colour
		-- in light archetypes (Bubbly); dark ones keep their gold/accent trim.
		if archetype == "Bubbly" then
			color.Trim = primary
			-- the focus ring and scroll thumb follow the trim on cream panels
			color.Focus = primary:Lerp(Color3.new(0, 0, 0), 0.12)
			color.Scroll = primary
		end
	end
	if secondary then
		color.Secondary = secondary
	end
	if accent then
		color.Accent = accent
		if archetype ~= "Bubbly" then
			color.Trim = accent
		end
		-- the gamepad ring follows the accent, except where the archetype
		-- deliberately uses white / bone (Arena, Horror) or the trim (Bubbly)
		if archetype ~= "Arena" and archetype ~= "Horror" and archetype ~= "Bubbly" then
			color.Focus = accent
			if archetype == "Adventure" or archetype == "Candy" or archetype == "Tactical" then
				color.Scroll = accent
			end
		end
	end
	if good then
		color.Good = good
	end
	if bad then
		color.Bad = bad
	end
	if warn_ then
		color.Warn = warn_
	end
	if text then
		color.Text = text
		color.Light = if Base.luminance(text) > 0.6 then text else color.Light
	end
	if surface then
		local surfaceTone = Base.makeTone(surface)
		color.Surface = surface
		color.SurfaceLight = surface:Lerp(Color3.new(1, 1, 1), 0.12)
		color.Track = surface:Lerp(Color3.new(0, 0, 0), 0.35)
		color.Backdrop = surface:Lerp(Color3.new(0, 0, 0), 0.7)
		color.Outline = surfaceTone.Deep:Lerp(Color3.new(0, 0, 0), 0.35)
		if archetype ~= "Bubbly" then
			color.Panel = surface:Lerp(Color3.new(1, 1, 1), 0.06)
		end
		theme.Tone.Dark = Base.makeTone(surface:Lerp(Color3.new(1, 1, 1), 0.1))
		color.TextMuted = color.Text:Lerp(surface, 0.35)
	end
	-- accent-coloured text follows the (new) accent, lifted if it's too dark
	color.AccentText = Base.readableAccent(color.Accent, color.Surface)

	local fonts = s.fonts or {}
	if fonts.display ~= nil then
		theme.Font.Display = Theme.fontFromRef(fonts.display)
	end
	if fonts.title ~= nil then
		theme.Font.Title = Theme.fontFromRef(fonts.title)
	elseif fonts.display ~= nil then
		theme.Font.Title = theme.Font.Display
	end
	if fonts.body ~= nil then
		theme.Font.Body = Theme.fontFromRef(fonts.body)
		local body = theme.Font.Body
		local family = string.match(body.Family, "families/([%w]+)%.json$")
		if family == nil and body.Family == Base.RUBIK then
			family = "Rubik"
		end
		theme.Font.Heavy = if family then Theme.fontFromRef(`{family}:ExtraBold`) else body
		theme.Font.Small = if family then Theme.fontFromRef(`{family}:Medium`) else body
	end

	local corner = s.corner or s.Corner
	if typeof(corner) == "string" then
		for name, radius in CORNERS do
			if string.lower(name) == string.lower(corner) then
				theme.Radius = table.clone(radius)
			end
		end
	end

	local st = theme.Style
	st.Archetype = archetype
	st.ButtonStyle = option("ButtonStyle", s.buttonStyle, st.ButtonStyle)
	st.PanelStyle = option("PanelStyle", s.panelStyle, st.PanelStyle)
	st.ClickFx = option("ClickFx", s.clickFx, st.ClickFx)
	st.Transition = option("Transition", s.transition, st.Transition)
	st.IconStyle = option("IconStyle", s.iconStyle, st.IconStyle)
	st.TextCase = option("TextCase", s.textCase, st.TextCase)
	st.Decoration = option("Decoration", s.decoration, st.Decoration)
	st.Shadow = option("Shadow", s.shadow, st.Shadow)
	st.Density = option("Density", s.density, st.Density)
	st.RarityScheme = option("RarityScheme", s.rarityScheme, st.RarityScheme)

	-- the rarity set follows the (possibly new) scheme
	theme.RarityBuiltFor = if theme.RarityBuiltFor then "rebuild" else nil

	if st.ButtonStyle == "Glossy3D" and theme.Depth < 3 then
		theme.Depth = 4
	elseif (st.ButtonStyle == "Pill" or st.ButtonStyle == "Soft" or st.ButtonStyle == "Outline") and theme.Depth > 0 then
		theme.Depth = 0
	end
	if st.Density == "Compact" then
		for key, value in theme.Space do
			theme.Space[key] = math.max(2, math.floor(value * 0.75 + 0.5))
		end
	end

	return Theme.normalize(theme)
end

return Theme
]]></string>
          </Properties>
          <Item class="ModuleScript" referent="271">
            <Properties>
              <string name="Name">Base</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Theme.Base: the maths and shared defaults behind every preset.

		local Base = require(script.Parent.Base)
		local blue = Base.makeTone(Color3.fromHex("2F8BFF"))
		-- { Top, Bottom, Edge, Deep, Text, Stroke }

	Presets require this module (not Theme itself) so there are no require
	cycles. Everything here is also re-exported from Theme.
]]

local Base = {}

local WHITE = Color3.new(1, 1, 1)
local BLACK = Color3.new(0, 0, 0)

local function luminance(color: Color3): number
	return color.R * 0.299 + color.G * 0.587 + color.B * 0.114
end
Base.luminance = luminance

-- Moves hue `h` towards `target` by `amount` (0..1 of the gap), the short way
-- round the colour wheel.
local function shiftHue(h: number, target: number, amount: number): number
	local diff = target - h
	if diff > 0.5 then
		diff -= 1
	elseif diff < -0.5 then
		diff += 1
	end
	return (h + diff * amount) % 1
end

export type Tone = {
	Top: Color3,
	Bottom: Color3,
	Edge: Color3,
	Deep: Color3,
	Text: Color3,
	Stroke: Color3?,
	[string]: any,
}

export type ToneOptions = {
	Text: Color3?, -- label colour (default: white, or a dark tint on light tones)
	Stroke: (Color3 | boolean)?, -- text outline; false = none
	Lift: number?, -- how much brighter Top is than the base (default 0.16)
	Depth: number?, -- how much darker Edge is (default 0.38)
}

--[[
	Builds a full tone from one base colour. The base becomes `Bottom`; `Top`
	is lighter with a warm hue shift, `Edge`/`Deep`/`Stroke` are darker, more
	saturated and shifted towards violet, the way an illustrator shades.
	Text is white unless the tone is very light, then a dark tint of itself.
]]
function Base.makeTone(color: Color3, options: ToneOptions?): Tone
	local opts = options or {}
	local h, s, v = color:ToHSV()
	local grey = s < 0.08
	local lift = opts.Lift or 0.16
	local depth = opts.Depth or 0.38

	local topH = if grey then h else shiftHue(h, 0.14, 0.08)
	local top = Color3.fromHSV(topH, s * 0.82, math.min(1, v + lift))
	if v + lift > 1 then
		top = top:Lerp(WHITE, math.min(0.35, (v + lift - 1) * 1.4))
	end

	local shadowH = if grey then h else shiftHue(h, 0.72, 0.06)
	local edge = Color3.fromHSV(shadowH, math.min(1, s * 1.08 + (if grey then 0 else 0.06)), v * (1 - depth))
	local deep = Color3.fromHSV(shadowH, math.min(1, s * 1.12 + (if grey then 0 else 0.1)), v * 0.3)
	local strokeColor = Color3.fromHSV(shadowH, math.min(1, s * 1.1 + (if grey then 0 else 0.12)), v * 0.42)

	local light = luminance(color) > 0.72
	local text = opts.Text or (if light then Color3.fromHSV(shadowH, math.min(1, s + 0.2), 0.22) else WHITE)
	local stroke: Color3?
	if opts.Stroke == false then
		stroke = nil
	elseif typeof(opts.Stroke) == "Color3" then
		stroke = opts.Stroke
	elseif opts.Stroke == true then
		stroke = strokeColor
	elseif light then
		stroke = nil
	else
		stroke = strokeColor
	end

	return {
		Top = top,
		Bottom = color,
		Edge = edge,
		Deep = deep,
		Text = text,
		Stroke = stroke,
	}
end

-- A tone from hand-picked Top/Bottom plus a shading base for the rest.
function Base.tone(top: string, bottom: string, edge: string, deep: string, stroke: string?, text: Color3?): Tone
	local hex = Color3.fromHex
	return {
		Top = hex(top),
		Bottom = hex(bottom),
		Edge = hex(edge),
		Deep = hex(deep),
		Text = text or WHITE,
		Stroke = if stroke then hex(stroke) else nil,
	}
end

--------------------------------------------------------------------------
-- Rarity
--------------------------------------------------------------------------

Base.RARITY_ORDER = { "Common", "Uncommon", "Rare", "Epic", "Legendary", "Mythic", "Secret" }

Base.RARITY_COLORS = {
	Common = "B0B7C3",
	Uncommon = "5BE22B",
	Rare = "3AA8FF",
	Epic = "B45CFF",
	Legendary = "FFB300",
	Mythic = "FF3B6B",
	Secret = "FFFFFF",
}

-- The Rivals / Treasure Quest ladder (docs/RESEARCH.md 2.8, 3.2): green
-- commons, blue rares, RED legendaries, gold mythics.
Base.RARITY_COLORS_ARENA = {
	Common = "7DEF12",
	Uncommon = "1FD6A8",
	Rare = "01A8FB",
	Epic = "9A1DFF",
	Legendary = "FF2A2A",
	Mythic = "FFBF00",
	Secret = "FFFFFF",
}

--[[
	Colourblind palettes (Theme.setColorblind). Good / Bad / Warn, the Green
	and Red tones (health bars, claim and buy buttons) and the rarity ladder
	move to colours that each kind of colour blindness still tells apart,
	after the Okabe-Ito set: blue / orange instead of green / red for
	deuteranopia and protanopia (protan gets a lighter orange, since red
	looks dark to them), teal / red for tritanopia (blue-yellow). Rarities
	also step in lightness, and rarity pills always carry their name.
]]
Base.COLORBLIND = {
	Deuteranopia = {
		Good = "2F8CFF", Bad = "FF7A14", Warn = "FFDA3D",
		Rarity = { Common = "B0B7C3", Uncommon = "5CC4F2", Rare = "2F6BFF", Epic = "D77BD9", Legendary = "FFD426", Mythic = "FF7A14", Secret = "FFFFFF" },
	},
	Protanopia = {
		Good = "3F9BFF", Bad = "FFA126", Warn = "FFF06E",
		Rarity = { Common = "B0B7C3", Uncommon = "6CCBF5", Rare = "2F6BFF", Epic = "C58AE8", Legendary = "FFE33D", Mythic = "FF9A1F", Secret = "FFFFFF" },
	},
	Tritanopia = {
		Good = "14C7B4", Bad = "F2334F", Warn = "FFE066",
		Rarity = { Common = "B0B7C3", Uncommon = "14C7B4", Rare = "0E8C9C", Epic = "C24FD6", Legendary = "FF5C8A", Mythic = "E0112F", Secret = "FFFFFF" },
	},
}

-- Pulls a colour towards a dark grey: `amount` 0 = unchanged, 1 = 60% less
-- saturated and 45% darker. Horror (and, lightly, Tactical) mute their
-- rarity and colour-blind colours with it (theme.RarityMute), so nothing in
-- a serious game looks like candy.
function Base.mute(color: Color3, amount: number?): Color3
	local mute = amount or 0
	if mute <= 0 then
		return color
	end
	local h, s, v = color:ToHSV()
	return Color3.fromHSV(h, s * (1 - mute * 0.6), v * (1 - mute * 0.45))
end

--[[
	The accent colour to use for text and small glyphs: the accent itself,
	unless it is too dark to read on the theme's dark glass (Horror's blood
	red on near-black), then lifted towards white until it reads.
]]
function Base.readableAccent(accent: Color3, surface: Color3?): Color3
	local floor = 0.42
	local light = luminance(accent)
	if surface ~= nil and luminance(surface) > 0.35 then
		return accent -- a light UI: dark accents read fine
	end
	if light >= floor then
		return accent
	end
	return accent:Lerp(WHITE, (floor - light) / math.max(1e-3, 1 - light))
end

-- The seven rarity tones. Each has the usual tone fields plus `Name`,
-- `Rank` (1 = Common .. 7 = Secret) and, for Secret, `Rainbow`
-- (a ColorSequence to paint it with). scheme: "Classic" (default) | "Arena",
-- or a { [rarity] = "RRGGBB" } table (colourblind palettes).
-- `muted` (0..1) pulls every colour towards a dark grey (Base.mute; horror
-- themes).
function Base.rarities(scheme: any, muted: number?): { [string]: Tone }
	local colors = if typeof(scheme) == "table" then scheme elseif scheme == "Arena" then Base.RARITY_COLORS_ARENA else Base.RARITY_COLORS
	local mute = muted or 0
	local out = {}
	for rank, name in Base.RARITY_ORDER do
		local tone: Tone
		if name == "Secret" then
			tone = {
				Top = WHITE,
				Bottom = Color3.fromHex("EEE8FF"),
				Edge = Color3.fromHex("8B74FF"),
				Deep = Color3.fromHex("33206E"),
				Text = WHITE,
				Stroke = Color3.fromHex("4A2E9E"),
				Rainbow = ColorSequence.new({
					ColorSequenceKeypoint.new(0, Color3.fromHex("FF5E7E")),
					ColorSequenceKeypoint.new(0.2, Color3.fromHex("FFB347")),
					ColorSequenceKeypoint.new(0.4, Color3.fromHex("FFF06A")),
					ColorSequenceKeypoint.new(0.6, Color3.fromHex("6BF28A")),
					ColorSequenceKeypoint.new(0.8, Color3.fromHex("5CC8FF")),
					ColorSequenceKeypoint.new(1, Color3.fromHex("C77DFF")),
				}),
			}
		else
			local base = Base.mute(Color3.fromHex(colors[name]), mute)
			tone = Base.makeTone(base, { Text = WHITE, Stroke = true })
		end
		tone.Name = name
		tone.Rank = rank
		out[name] = tone
	end
	return out
end

--------------------------------------------------------------------------
-- Shared defaults (anything a custom theme leaves out is filled from here)
--------------------------------------------------------------------------

Base.STYLE_OPTIONS = {
	Archetype = { "Arena", "Bubbly", "Adventure", "Sleek", "Candy", "Tactical", "Horror" },
	ButtonStyle = { "Glossy3D", "Flat", "Pill", "Outline", "Soft" },
	PanelStyle = { "Glass", "Solid", "Gradient", "Outlined", "Ribbon", "Fade" },
	ClickFx = { "Squash", "Punch", "Ripple", "Sparkle", "Glow", "Poof", "Shake", "Glitch", "None" },
	Transition = { "Pop", "Rise", "Slide", "Fade", "Drop", "Zoom", "Swap" },
	RarityScheme = { "Classic", "Arena" },
	IconStyle = { "Filled", "Outline" },
	TextCase = { "Normal", "Upper" },
	Decoration = { "None", "Sparkles", "Stripes", "Dots", "Grain" },
	Shadow = { "Soft", "Hard", "None" },
	Density = { "Compact", "Comfy" },
}

function Base.style(overrides: { [string]: string }?): { [string]: string }
	local style = {
		Archetype = "Arena",
		ButtonStyle = "Glossy3D",
		PanelStyle = "Glass",
		ClickFx = "Squash",
		Transition = "Pop",
		IconStyle = "Filled",
		TextCase = "Normal",
		Decoration = "None",
		Shadow = "Soft",
		Density = "Comfy",
		RarityScheme = "Classic",
		ButtonCase = "Normal", -- "Upper" shouts on buttons only (Treasure Quest)
		BarLabel = "Outlined", -- "Inset": bar numbers in a darker shade of the fill (Rivals health)
	}
	for key, value in overrides or {} do
		style[key] = value
	end
	return style
end

-- Type scale in design px (docs/QUALITY.md rule 3). v2.4 adds named roles
-- (Kit.textSize / Text accept them like the classic steps):
--   Display  title-screen headlines        Title    window titles
--   Heading  section / card titles         Body     running text
--   Label    buttons, tabs, chips          Caption  small muted lines
--   Number   currency, counters, prices
-- `scale` (families) multiplies the roles, never below Base.TYPE_MIN.
function Base.typeScale(scale: number?): { [string]: number }
	local k = scale or 1
	local out = {
		Hero = 84, H1 = 52, H2 = 38, H3 = 29, Body = 23, Small = 19, Caption = 16,
		Display = 64, Title = 40, Heading = 30, Label = 22, Number = 32,
	}
	if k ~= 1 then
		for name, size in out do
			out[name] = math.max(Base.TYPE_MIN[name] or 14, math.floor(size * k + 0.5))
		end
	end
	return out
end

-- The smallest size each role may render at (design px at 1920x1080; the
-- phone canvas draws at ~0.5x, so 16 stays ~8 real px there and the HUD's
-- own minimum text rule keeps it readable). Nothing on a v2.4 family HUD
-- goes under Base.TYPE_MIN.Hud.
Base.TYPE_MIN = {
	Hero = 56, H1 = 36, H2 = 28, H3 = 22, Body = 18, Small = 16, Caption = 16,
	Display = 44, Title = 28, Heading = 22, Label = 16, Number = 18, Hud = 14,
}

--[[
	Optical size. Roblox's TextSize is the height of the font's line box, not
	of its letters: a face with tall ascenders and deep descenders (Titillium,
	Oswald, Nunito) draws much smaller letters at the same TextSize than a
	compact one (Luckiest Guy, Bangers). FONT_EM is each built-in family's em
	/ line-box ratio (unitsPerEm / (ascender - descender) of its font file),
	so TextSize * FONT_EM is the size the letters really have. In a v2.4
	style family nothing renders with an em under GLYPH_MIN design px at
	1080p (Kit.opticalMin; the Text component applies it); classic themes are
	untouched.
]]
Base.GLYPH_MIN = 14
Base.FONT_EM = {
	AccanthisADFStd = 0.856, AmaticSC = 0.793, Arimo = 0.895, Balthazar = 0.982, Bangers = 0.94,
	BuilderExtended = 0.794, BuilderMono = 0.794, BuilderSans = 0.794, ComicNeueAngular = 0.827, Creepster = 0.855,
	DenkOne = 0.8, Fondamento = 0.723, FredokaOne = 0.826, GrenzeGotisch = 0.676, Guru = 0.844, HighwayGothic = 0.903,
	IndieFlower = 0.685, Kalam = 0.627, LuckiestGuy = 1, Merriweather = 0.796, Michroma = 0.703, Montserrat = 0.82,
	Nunito = 0.733, Oswald = 0.675, PatrickHand = 0.739, PermanentMarker = 0.701, PressStart2P = 0.976, Roboto = 0.853,
	RobotoMono = 0.758, RomanAntique = 0.953, Sarpanch = 0.714, SourceSansPro = 0.796, SpecialElite = 1,
	TitilliumWeb = 0.657, Zekton = 0.833,
}

-- The em / line-box ratio of a Font (0.8 for families not in FONT_EM).
function Base.emRatio(font: any): number
	local family = if typeof(font) == "Font" then string.match(font.Family, "families/([%w]+)%.json$") else nil
	return (family and Base.FONT_EM[family]) or 0.8
end

-- 8-pt spacing steps (docs/QUALITY.md rule 4).
function Base.space(): { [string]: number }
	return { XS = 4, S = 8, M = 12, L = 16, XL = 24, XXL = 32, XXXL = 48 }
end

-- UI sound slots. Only volume_slider.ogg ships with the client as a usable UI
-- tick, so everything else starts silent: paste verified Creator Store ids
-- (search terms in docs/RESEARCH.md 8.6) into Theme.Sound.
function Base.sounds(): { [string]: string }
	return {
		Click = "rbxasset://sounds/volume_slider.ogg", -- "ui click", "button click short"
		Hover = "", -- "soft tick", "ui hover" (keep very quiet)
		Open = "", -- "menu open", "pop up"
		Close = "", -- "menu close", "swoosh down"
		Swoosh = "", -- "whoosh short", "ui swish" (tabs, pages)
		Pop = "", -- "pop", "bubble pop" (things appearing)
		Purchase = "", -- "purchase", "success chime"
		Reward = "", -- "reward", "win jingle"
		LevelUp = "", -- "level up", "achievement"
		Error = "", -- "error", "denied", "bonk"
		Denied = "", -- falls back to Error when empty
		Notify = "", -- "notification", "ping"
		Tick = "rbxasset://sounds/volume_slider.ogg", -- counters, flyTo arrivals
		Coin = "", -- "coin pickup"; falls back to Tick when empty
		Hit = "", -- "hitmarker", "impact tick"
		Crit = "", -- "hitmarker" + higher layer
		Kill = "", -- "kill confirm", "ding"
	}
end

-- Text outline recipes by background (docs/RESEARCH.md 2.2). Components pick
-- one with Text { Outline = "Art" } etc.; Color nil = a tint of the tone /
-- the theme outline.
function Base.textStroke(overrides: { [string]: any }?): { [string]: any }
	local recipes = {
		Glass = { Thickness = 2.25, Transparency = 0 }, -- HUD text on dark glass
		Art = { Thickness = 2.25, Transparency = 0 }, -- titles on busy / colourful art
		Button = { Thickness = 2, Transparency = 0 }, -- labels on coloured buttons (tone tint)
		Solid = { Thickness = 2, Transparency = 0 }, -- white text on white (the theme's darkest tint)
	}
	for key, value in overrides or {} do
		recipes[key] = value
	end
	return recipes
end

-- Spring and timing tokens (docs/RESEARCH.md 8.1 / 8.2). Speeds are Fusion
-- Spring speeds, dampings 0..1.
function Base.motion(overrides: { [string]: any }?): { [string]: any }
	local motion = {
		Speed = 30,
		Damping = 0.6,
		HoverScale = 1.05,
		HoverSpeed = 36,
		HoverDamping = 0.62,
		PressScale = 0.92,
		PressSpeed = 50,
		PressDamping = 1,
		ReleaseSpeed = 28,
		ReleaseDamping = 0.42,
		OpenSpeed = 24,
		OpenDamping = 0.62,
		CloseTime = 0.1,
		TabSlide = 16,
		Stagger = 0.025,
		PunchSpeed = 35,
		PunchDamping = 0.5,
	}
	for key, value in overrides or {} do
		motion[key] = value
	end
	return motion
end

-- Builder Sans in every slot: the clean SF-Pro-like look.
function Base.builderFonts(weights: { [string]: Enum.FontWeight }?): { [string]: Font }
	local w = weights or {}
	return {
		Title = Font.fromName("BuilderSans", w.Title or Enum.FontWeight.ExtraBold),
		Display = Font.fromName("BuilderSans", w.Display or Enum.FontWeight.ExtraBold),
		Body = Font.fromName("BuilderSans", w.Body or Enum.FontWeight.Bold),
		Heavy = Font.fromName("BuilderSans", w.Heavy or Enum.FontWeight.ExtraBold),
		Small = Font.fromName("BuilderSans", w.Small or Enum.FontWeight.SemiBold),
		Mono = Font.fromName("BuilderMono", w.Mono or Enum.FontWeight.Bold),
	}
end

-- Rubik, the RIVALS typeface: a free Roblox-published Creator Store family
-- (no upload needed). Weights Light (300) .. Black (900).
Base.RUBIK = "rbxassetid://12187365977"

function Base.rubikFonts(): { [string]: Font }
	return {
		Title = Font.new(Base.RUBIK, Enum.FontWeight.ExtraBold),
		Display = Font.new(Base.RUBIK, Enum.FontWeight.Heavy),
		Body = Font.new(Base.RUBIK, Enum.FontWeight.Bold),
		Heavy = Font.new(Base.RUBIK, Enum.FontWeight.Bold),
		Small = Font.new(Base.RUBIK, Enum.FontWeight.Medium),
		Mono = Font.fromName("BuilderMono", Enum.FontWeight.Bold),
	}
end

-- Montserrat, Roblox's Gotham replacement (Treasure Quest used Gotham).
function Base.montserratFonts(): { [string]: Font }
	return {
		Title = Font.fromName("Montserrat", Enum.FontWeight.Heavy),
		Display = Font.fromName("Montserrat", Enum.FontWeight.Heavy),
		Body = Font.fromName("Montserrat", Enum.FontWeight.Bold),
		Heavy = Font.fromName("Montserrat", Enum.FontWeight.ExtraBold),
		Small = Font.fromName("Montserrat", Enum.FontWeight.SemiBold),
		Mono = Font.fromName("BuilderMono", Enum.FontWeight.Bold),
	}
end

Base.WHITE = WHITE
Base.BLACK = BLACK

return Base
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="272">
            <Properties>
              <string name="Name">Families</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Theme.Families: the v2.4 style families (docs/BLUEPRINT.md "style families").

	A family is a complete, coherent look: palette envelope and surface mode,
	radius scale, stroke / outline recipe, layered-shadow recipe, fills,
	texture, fonts, type roles, motion personality and sound set. A blueprint
	picks one with `style.family`; Theme.fromStyle builds the archetype theme
	first (so every field exists) and then Families.apply re-derives it:

		local theme = Families.apply(archetypeTheme, "Toybox", style)

	Colour: every swatch comes from the blueprint palette (primary /
	secondary / accent hex) through 12-step OKLCH ramps (Theme/Oklch), kept
	inside the family's lightness / chroma envelope, with neutrals tinted by
	the primary hue and contrast checked (4.5:1 body text, 3:1 large text and
	icons). The 11 named tones the generator writes ("Blue", "Yellow" ...)
	are rebuilt from the palette colour that falls in their hue bucket, so
	the palette reaches every card and button.

	theme.Family (read by Kit / Components; nil = the classic archetype look):
		Id, Label, Light (window panels are light), Ink (outline ink),
		Fill { Lift, Drop, Gloss, Angle }                       face gradients
		Shadow { Button, Card, Window, Hud, Chip }               UIShadow layers
		  each layer { X, Y, Blur, Spread, T, Color = "deep" | "tone" | "glow" | "ink" | "accent" | Color3 }
		Rim { Top, Width, Bottom, BottomWidth }                  inner top light / bottom shade
		Outline { Width, Color, T, Join, Inner { Width, Color, T, Offset } }
		Texture { Window, Backdrop, T }                         Kit.texture kinds
		Chrome { Header, Close, Tabs, Brackets }                window chrome recipe
		Backdrop { Kind, ... }                                  title screen recipe
		Button { Radius, Depth, Gloss, Outline, Glow, Rim, LabelStroke }
		Chamfer (px)                                            cut corners (per-corner radii)
		MinText                                                 smallest text size, design px
]]

local Base = require(script.Parent.Base)
local Oklch = require(script.Parent.Oklch)

local Families = {}

local hex = Color3.fromHex
local WHITE = Color3.new(1, 1, 1)
local BLACK = Color3.new(0, 0, 0)

Families.IDS = {
	"Toybox", "Studio", "Neon", "Clay", "Sketch", "Royal", "Arcade",
	"Comic", "Dread", "Holo", "Gacha", "Broadcast", "Tavern", "Mochi",
}

local TICK = "rbxasset://sounds/volume_slider.ogg"

--------------------------------------------------------------------------
-- Motion personalities (polish spec 4.3), as Theme.Motion fields
--------------------------------------------------------------------------

local function motion(open: { number }, hover: number, hoverSpring: { number }, press: number, release: { number }, stagger: number, extra: { [string]: any }?)
	local m = {
		Speed = hoverSpring[1],
		Damping = hoverSpring[2],
		HoverScale = hover,
		HoverSpeed = hoverSpring[1],
		HoverDamping = hoverSpring[2],
		PressScale = press,
		PressSpeed = 50,
		PressDamping = 1,
		ReleaseSpeed = release[1],
		ReleaseDamping = release[2],
		OpenSpeed = open[1],
		OpenDamping = open[2],
		CloseTime = 0.1,
		TabSlide = 16,
		Stagger = stagger,
		PunchSpeed = release[1] + 6,
		PunchDamping = math.max(0.3, release[2]),
	}
	for key, value in extra or {} do
		m[key] = value
	end
	return m
end

Families.MOTION = {
	Bouncy = motion({ 22, 0.55 }, 1.06, { 36, 0.6 }, 0.9, { 26, 0.4 }, 0.04, { EnterScale = 0.6 }),
	Squishy = motion({ 18, 0.6 }, 1.04, { 30, 0.6 }, 0.92, { 22, 0.35 }, 0.045, { EnterScale = 0.7, Squash = true }),
	Punchy = motion({ 40, 0.45 }, 1.08, { 45, 0.5 }, 0.88, { 40, 0.45 }, 0.035, { EnterScale = 1.3, Shake = 4 }),
	Snappy = motion({ 32, 0.8 }, 1.03, { 40, 0.8 }, 0.95, { 40, 0.7 }, 0.025, { EnterScale = 0.94, Rise = 16 }),
	Crisp = motion({ 30, 1 }, 1.01, { 61, 1 }, 0.97, { 45, 1 }, 0.022, { EnterScale = 0.97, Rise = 8, PressSpeed = 45 }),
	Broadcast = motion({ 38, 1 }, 1.02, { 50, 1 }, 0.96, { 45, 0.9 }, 0.025, { EnterScale = 1, Rise = 0 }),
	Technical = motion({ 32, 0.9 }, 1.02, { 45, 0.9 }, 0.97, { 40, 0.9 }, 0.02, { EnterScale = 0.98, Rise = 6 }),
	Stately = motion({ 16, 0.85 }, 1.02, { 30, 0.85 }, 0.97, { 24, 0.8 }, 0.06, { EnterScale = 0.96, Rise = 0 }),
	Weighty = motion({ 20, 0.75 }, 1.03, { 34, 0.75 }, 0.95, { 24, 0.6 }, 0.05, { EnterScale = 0.95, Rise = -30 }),
	Wobbly = motion({ 20, 0.65 }, 1.03, { 32, 0.62 }, 0.95, { 26, 0.5 }, 0.05, { EnterScale = 0.9, Wobble = 4 }),
	Stepped = motion({ 60, 1 }, 1, { 80, 1 }, 0.96, { 80, 1 }, 0.016, { EnterScale = 1, Stepped = true }),
	Uneasy = motion({ 10, 1 }, 1.01, { 20, 1 }, 0.98, { 18, 1 }, 0.09, { EnterScale = 0.99, Jitter = 1.5 }),
}

--------------------------------------------------------------------------
-- Sound sets: Roblox's built-in UI tick (the one sound that ships with the
-- client) tuned per family. A slot may be a table: { Id, Pitch, Volume,
-- Notes = { pitch multipliers } (a quick arpeggio), Gap = seconds }.
-- Paste Creator Store ids over any slot with style.sounds / UI.Sound.override.
--------------------------------------------------------------------------

local function sounds(pitch: number, spec: { [string]: any }?): { [string]: any }
	local p = pitch
	local s = {
		Click = { Id = TICK, Pitch = p, Volume = 0.5 },
		Hover = { Id = TICK, Pitch = p * 1.5, Volume = 0.12 },
		Open = { Id = TICK, Pitch = p * 0.8, Volume = 0.45, Notes = { 1, 1.335 }, Gap = 0.05 },
		Close = { Id = TICK, Pitch = p * 0.8, Volume = 0.4, Notes = { 1.335, 1 }, Gap = 0.05 },
		Swoosh = { Id = TICK, Pitch = p * 1.2, Volume = 0.3, Notes = { 1, 1.12, 1.26 }, Gap = 0.025 },
		Pop = { Id = TICK, Pitch = p * 1.35, Volume = 0.4 },
		Purchase = { Id = TICK, Pitch = p, Volume = 0.7, Notes = { 1, 1.26, 1.5, 2 }, Gap = 0.06 },
		Reward = { Id = TICK, Pitch = p, Volume = 0.75, Notes = { 1, 1.26, 1.5, 1.68, 2 }, Gap = 0.07 },
		LevelUp = { Id = TICK, Pitch = p * 0.9, Volume = 0.8, Notes = { 1, 1.26, 1.5, 2, 2.52 }, Gap = 0.08 },
		Error = { Id = TICK, Pitch = p * 0.55, Volume = 0.55, Notes = { 1, 0.94 }, Gap = 0.07 },
		Notify = { Id = TICK, Pitch = p * 1.2, Volume = 0.45, Notes = { 1, 1.5 }, Gap = 0.08 },
		Tick = { Id = TICK, Pitch = p * 1.1, Volume = 0.35 },
		Coin = { Id = TICK, Pitch = p * 1.6, Volume = 0.35 },
		Hit = { Id = TICK, Pitch = p * 1.8, Volume = 0.4 },
		Crit = { Id = TICK, Pitch = p * 1.8, Volume = 0.5, Notes = { 1, 1.26 }, Gap = 0.035 },
		Kill = { Id = TICK, Pitch = p * 1.2, Volume = 0.6, Notes = { 1, 1.5, 2 }, Gap = 0.05 },
		Denied = "",
	}
	for key, value in spec or {} do
		s[key] = value
	end
	return s
end

--------------------------------------------------------------------------
-- The families
--------------------------------------------------------------------------

--[[
	palette:
	  mode        default surface mode ("light" | "dark"); palette.surface overrides
	  solid       { minL, maxL } lightness window for the solid (step 9) colours
	  chroma      { min, max } chroma window for them
	  neutral     chroma of the tinted neutrals (0 = the primary hue at nothing)
	  hue         optional { centre, width }: the primary hue is pulled into it
	  fixed       Color3s that override the palette ({ accent = gold })
	  snap        a list of hex colours every palette colour snaps to (Arcade)
	  panel       { L, C, hue? }: light panel colour (light families)
	  glass       { L, C }: the HUD glass (dark) colour
]]
Families.DEFS = {
	Toybox = {
		label = "Toybox Gloss",
		base = "Candy",
		surface = "light",
		palette = {
			solid = { 0.64, 0.8 }, chroma = { 0.16, 0.24 }, neutral = 0.03,
			panel = { L = 0.975, C = 0.025, hue = 90 }, glass = { L = 0.3, C = 0.1 },
			defaults = { primary = "#2F9BFF", secondary = "#FFC21A", accent = "#5BD62A" },
		},
		fonts = { display = "FredokaOne", title = "FredokaOne", body = "Nunito:ExtraBold", heavy = "Nunito:Black", small = "Nunito:Bold", number = "FredokaOne", mono = "BuilderMono:Bold" },
		radius = { S = 10, M = 16, L = 24 },
		stroke = { Text = 3, UI = 3.5, Thin = 2 },
		textStroke = { Glass = { Thickness = 2.5, Transparency = 0 }, Art = { Thickness = 3, Transparency = 0 }, Button = { Thickness = 2.5, Transparency = 0 }, Solid = { Thickness = 2.5, Transparency = 0 } },
		depth = 5,
		style = { ButtonStyle = "Glossy3D", PanelStyle = "Solid", ClickFx = "Squash", Transition = "Pop", Decoration = "Sparkles", Shadow = "Hard", IconStyle = "Filled", TextCase = "Normal", ButtonCase = "Normal" },
		motion = "Bouncy",
		sound = sounds(1.25),
		blur = 12, scrim = 0.38,
		family = {
			Fill = { Lift = 0.1, Drop = 0.08, Gloss = 0.45, Angle = 90 },
			Shadow = {
				Button = { { Y = 5, Blur = 0, T = 0, Color = "tone" } },
				Card = { { Y = 5, Blur = 0, T = 0.15, Color = "deep" }, { Y = 2, Blur = 10, T = 0.85, Color = "deep" } },
				Window = { { Y = 8, Blur = 0, T = 0.1, Color = "deep" }, { Y = 18, Blur = 40, Spread = -6, T = 0.72, Color = "deep" } },
				Hud = { { Y = 4, Blur = 0, T = 0.2, Color = "deep" } },
				Chip = { { Y = 3, Blur = 0, T = 0.2, Color = "deep" } },
			},
			Rim = { Top = 0.35, Width = 2.5, Bottom = false },
			Outline = { Width = 4, Color = "ink", T = 0, Join = "Round", Inner = { Width = 2, Color = "white", T = 0.4 } },
			Texture = { Window = "None", Backdrop = "Dots", T = 0.9 },
			Chrome = { Header = "Band", Close = "Round", Tabs = "Chip" },
			Backdrop = { Kind = "Sunburst" },
			Button = { Radius = "M", Depth = 5, Gloss = true, Outline = 3, Rim = true, LabelStroke = true },
			MinText = 16,
		},
	},
	Studio = {
		label = "Studio Minimal",
		base = "Sleek",
		surface = "dark",
		palette = {
			solid = { 0.6, 0.7 }, chroma = { 0.12, 0.19 }, neutral = 0.012,
			panel = { L = 0.995, C = 0.004 }, glass = { L = 0.18, C = 0.012 },
			defaults = { primary = "#6E7BFF", secondary = "#8A8A99", accent = "#6E7BFF" },
		},
		fonts = { display = "BuilderSans:Bold", title = "BuilderSans:Bold", body = "BuilderSans:SemiBold", heavy = "BuilderSans:ExtraBold", small = "BuilderSans:Medium", number = "BuilderSans:ExtraBold", mono = "BuilderMono:Bold" },
		radius = { S = 6, M = 10, L = 14 },
		stroke = { Text = 0, UI = 1, Thin = 1 },
		textStroke = { Glass = { Thickness = 0, Transparency = 1 }, Art = { Color = BLACK, Transparency = 0.75, Thickness = 1.5 }, Button = { Thickness = 0, Transparency = 1 }, Solid = { Transparency = 0.2, Thickness = 1 } },
		depth = 0,
		style = { ButtonStyle = "Flat", PanelStyle = "Solid", ClickFx = "Ripple", Transition = "Rise", Decoration = "None", Shadow = "Soft", IconStyle = "Outline", TextCase = "Normal", ButtonCase = "Normal" },
		motion = "Crisp",
		sound = sounds(1, { Hover = "", Swoosh = { Id = TICK, Pitch = 1.3, Volume = 0.18 } }),
		blur = 18, scrim = 0.5,
		family = {
			Fill = { Lift = 0.03, Drop = 0.02, Gloss = false, Angle = 90 },
			Shadow = {
				Button = { { Y = 1, Blur = 2, T = 0.8, Color = "black" }, { Y = 4, Blur = 10, Spread = -2, T = 0.8, Color = "deep" } },
				Card = { { Y = 1, Blur = 2, T = 0.85, Color = "black" }, { Y = 8, Blur = 24, Spread = -4, T = 0.8, Color = "deep" } },
				Window = { { Y = 1, Blur = 3, T = 0.8, Color = "black" }, { Y = 24, Blur = 60, Spread = -8, T = 0.55, Color = "black" } },
				Hud = { { Y = 2, Blur = 8, T = 0.75, Color = "black" } },
				Chip = { { Y = 1, Blur = 3, T = 0.8, Color = "black" } },
			},
			Rim = { Top = 0.86, Width = 1, Bottom = false },
			Outline = { Width = 1, Color = "hairline", T = 0.88, Join = "Round" },
			Texture = { Window = "Noise", Backdrop = "Grid", T = 0.97 },
			Chrome = { Header = "Hairline", Close = "Ghost", Tabs = "Segment" },
			Card = "Tint",
			Backdrop = { Kind = "Aurora" },
			Button = { Radius = "S", Depth = 0, Gloss = false, Outline = false, Rim = true, LabelStroke = false },
			MinText = 15,
		},
	},
	Neon = {
		label = "Neon Synth",
		base = "Arena",
		surface = "dark",
		palette = {
			solid = { 0.66, 0.78 }, chroma = { 0.2, 0.3 }, neutral = 0.035,
			panel = { L = 0.97, C = 0.02 }, glass = { L = 0.13, C = 0.035 },
			defaults = { primary = "#FF2BD6", secondary = "#19E6FF", accent = "#FFE45C" },
			harmony = "split",
		},
		-- Michroma is very wide: window titles and the logo only (the ornate slot)
		fonts = { display = "Sarpanch:ExtraBold", title = "Sarpanch:ExtraBold", body = "TitilliumWeb:SemiBold", heavy = "Sarpanch:Bold", small = "TitilliumWeb:SemiBold", number = "Sarpanch:ExtraBold", mono = "BuilderMono:Bold", ornate = "Michroma" },
		radius = { S = 4, M = 8, L = 12 },
		stroke = { Text = 2, UI = 2, Thin = 1.5 },
		textStroke = { Glass = { Thickness = 1.5, Transparency = 0.45 }, Art = { Thickness = 2.5, Transparency = 0.3 }, Button = { Thickness = 2, Transparency = 0.1 }, Solid = { Thickness = 1.5, Transparency = 0 } },
		depth = 0,
		style = { ButtonStyle = "Outline", PanelStyle = "Solid", ClickFx = "Glow", Transition = "Rise", Decoration = "None", Shadow = "Soft", IconStyle = "Outline", TextCase = "Upper", ButtonCase = "Upper" },
		motion = "Snappy",
		sound = sounds(1.7),
		blur = 16, scrim = 0.55,
		family = {
			Fill = { Lift = 0.06, Drop = 0.06, Gloss = false, Angle = 90 },
			Shadow = {
				Button = { { Y = 0, Blur = 18, Spread = 1, T = 0.5, Color = "glow" } },
				Card = { { Y = 0, Blur = 20, Spread = 2, T = 0.62, Color = "glow" } },
				Window = { { Y = 0, Blur = 36, Spread = 2, T = 0.55, Color = "accent" }, { Y = 20, Blur = 50, T = 0.5, Color = "black" } },
				Hud = { { Y = 0, Blur = 14, T = 0.65, Color = "accent" } },
				Chip = { { Y = 0, Blur = 10, T = 0.7, Color = "glow" } },
			},
			Rim = { Top = 0.8, Width = 1, Bottom = false },
			Outline = { Width = 2, Color = "accent", T = 0, Join = "Miter", Gradient = true },
			Texture = { Window = "Grid", Backdrop = "Grid", T = 0.93 },
			Chrome = { Header = "Neon", Close = "Ghost", Tabs = "Underline" },
			Card = "Tint",
			Backdrop = { Kind = "Synth" },
			-- (no LabelStroke: neon solids deepen until a white label reads on
			-- them by itself, so price pills and PLAY never go pale-on-pastel)
			Button = { Radius = "S", Depth = 0, Gloss = false, Outline = 2, Glow = true, Rim = false, LabelStroke = false },
			Chamfer = 12,
			MinText = 15,
		},
	},
	Clay = {
		label = "Soft Clay",
		base = "Bubbly",
		surface = "light",
		palette = {
			-- (soft, but buttons and icons keep enough colour to read as buttons)
			solid = { 0.67, 0.77 }, chroma = { 0.12, 0.17 }, neutral = 0.02,
			panel = { L = 0.975, C = 0.012 }, glass = { L = 0.34, C = 0.06 },
			defaults = { primary = "#7C8CFF", secondary = "#FF9EC7", accent = "#FFD66B" },
			harmony = "analogous",
		},
		fonts = { display = "Nunito:Black", title = "Nunito:Black", body = "Nunito:Bold", heavy = "Nunito:ExtraBold", small = "Nunito:SemiBold", number = "Nunito:Black", mono = "BuilderMono:Bold" },
		radius = { S = 14, M = 22, L = 32 },
		stroke = { Text = 0, UI = 0, Thin = 0 },
		-- (button labels stay white on pastel fills, so their outline is solid)
		textStroke = { Glass = { Thickness = 0, Transparency = 1 }, Art = { Thickness = 1.5, Transparency = 0.4 }, Button = { Thickness = 2, Transparency = 0 }, Solid = { Thickness = 1.5, Transparency = 0.2 } },
		depth = 0,
		style = { ButtonStyle = "Pill", PanelStyle = "Solid", ClickFx = "Squash", Transition = "Pop", Decoration = "None", Shadow = "Soft", IconStyle = "Filled", TextCase = "Normal", ButtonCase = "Normal" },
		motion = "Squishy",
		sound = sounds(1.05, { Click = { Id = TICK, Pitch = 0.9, Volume = 0.45 } }),
		blur = 12, scrim = 0.3,
		family = {
			Fill = { Lift = 0.06, Drop = 0.04, Gloss = false, Angle = 90 },
			Shadow = {
				Button = { { Y = 8, Blur = 18, Spread = -4, T = 0.6, Color = "tone" }, { Y = 2, Blur = 4, T = 0.85, Color = "tone" } },
				Card = { { Y = 10, Blur = 26, Spread = -6, T = 0.72, Color = "deep" }, { Y = 2, Blur = 6, T = 0.88, Color = "deep" } },
				Window = { { Y = 16, Blur = 44, Spread = -8, T = 0.62, Color = "deep" }, { Y = 3, Blur = 8, T = 0.85, Color = "deep" } },
				Hud = { { Y = 8, Blur = 18, Spread = -4, T = 0.7, Color = "deep" } },
				Chip = { { Y = 4, Blur = 10, Spread = -2, T = 0.75, Color = "deep" } },
			},
			Rim = { Top = 0.3, Width = 3, Bottom = 0.55, BottomWidth = 4 },
			Outline = false,
			Texture = { Window = "None", Backdrop = "None", T = 1 },
			Chrome = { Header = "Soft", Close = "Pill", Tabs = "Pill" },
			Backdrop = { Kind = "Blobs" },
			Button = { Radius = "Full", Depth = 0, Gloss = false, Outline = false, Rim = true, LabelStroke = true },
			MinText = 16,
		},
	},
	Sketch = {
		label = "Sketchbook",
		base = "Bubbly",
		surface = "light",
		palette = {
			solid = { 0.55, 0.68 }, chroma = { 0.12, 0.18 }, neutral = 0.02,
			panel = { L = 0.965, C = 0.025, hue = 85 }, glass = { L = 0.26, C = 0.02 },
			defaults = { primary = "#3C6FD6", secondary = "#E8483A", accent = "#FFE55C" },
			ink = "#2B2A33",
		},
		fonts = { display = "Kalam:Bold", title = "PatrickHand", body = "PatrickHand", heavy = "Kalam:Bold", small = "PatrickHand", number = "Kalam:Bold", mono = "BuilderMono:Bold" },
		radius = { S = 4, M = 8, L = 10 },
		stroke = { Text = 0, UI = 2.5, Thin = 2 },
		textStroke = { Glass = { Thickness = 2, Transparency = 0 }, Art = { Thickness = 2, Transparency = 0 }, Button = { Thickness = 0, Transparency = 1 }, Solid = { Thickness = 1.5, Transparency = 0 } },
		depth = 0,
		style = { ButtonStyle = "Flat", PanelStyle = "Solid", ClickFx = "Poof", Transition = "Drop", Decoration = "None", Shadow = "Hard", IconStyle = "Outline", TextCase = "Normal", ButtonCase = "Normal" },
		motion = "Wobbly",
		sound = sounds(0.95, { Swoosh = { Id = TICK, Pitch = 0.7, Volume = 0.25, Notes = { 1, 1.06, 1.12, 1.18 }, Gap = 0.02 } }),
		blur = 8, scrim = 0.32,
		family = {
			Fill = { Lift = 0.02, Drop = 0.02, Gloss = false, Angle = 90 },
			Shadow = {
				Button = { { X = 3, Y = 4, Blur = 0, T = 0.15, Color = "ink" } },
				Card = { { X = 3, Y = 4, Blur = 0, T = 0.75, Color = "ink" } },
				Window = { { X = 6, Y = 7, Blur = 0, T = 0.7, Color = "ink" } },
				Hud = { { X = 3, Y = 3, Blur = 0, T = 0.3, Color = "ink" } },
				Chip = { { X = 2, Y = 2, Blur = 0, T = 0.3, Color = "ink" } },
			},
			Rim = false,
			Outline = { Width = 2.5, Color = "ink", T = 0, Join = "Round", Inner = { Width = 1, Color = "ink", T = 0.65, Offset = 3 } },
			Texture = { Window = "Paper", Backdrop = "Paper", T = 0.85 },
			Chrome = { Header = "Tape", Close = "Ink", Tabs = "Folder" },
			Backdrop = { Kind = "Paper" },
			Button = { Radius = "S", Depth = 0, Gloss = false, Outline = 2.5, Rim = false, LabelStroke = false },
			Tilt = 1.2,
			MinText = 17,
		},
	},
	Royal = {
		label = "Royal Gold",
		base = "Adventure",
		surface = "dark",
		palette = {
			solid = { 0.4, 0.55 }, chroma = { 0.08, 0.15 }, neutral = 0.04,
			panel = { L = 0.96, C = 0.03, hue = 85 }, glass = { L = 0.16, C = 0.05 },
			defaults = { primary = "#5B2A86", secondary = "#E0245E", accent = "#F5C84C" },
			fixed = { accent = "#F5C84C" },
			hue = { centre = 305, width = 70 },
		},
		fonts = { display = "Merriweather:Black", title = "Merriweather:Black", body = "Montserrat:SemiBold", heavy = "Montserrat:ExtraBold", small = "Montserrat:Medium", number = "Montserrat:ExtraBold", mono = "BuilderMono:Bold" },
		radius = { S = 4, M = 6, L = 10 },
		stroke = { Text = 2, UI = 1.5, Thin = 1 },
		textStroke = { Glass = { Thickness = 1.5, Transparency = 0.2 }, Art = { Thickness = 2, Transparency = 0.1 }, Button = { Thickness = 1.5, Transparency = 0.2 }, Solid = { Thickness = 1.5, Transparency = 0 } },
		depth = 2,
		style = { ButtonStyle = "Flat", PanelStyle = "Solid", ClickFx = "Glow", Transition = "Fade", Decoration = "None", Shadow = "Soft", IconStyle = "Filled", TextCase = "Upper", ButtonCase = "Upper" },
		motion = "Stately",
		sound = sounds(0.8, { Coin = { Id = TICK, Pitch = 1.9, Volume = 0.35, Notes = { 1, 1.5 }, Gap = 0.03 } }),
		blur = 16, scrim = 0.55,
		family = {
			Fill = { Lift = 0.05, Drop = 0.05, Gloss = false, Angle = 90 },
			Shadow = {
				Button = { { Y = 6, Blur = 14, T = 0.6, Color = "black" } },
				Card = { { Y = 10, Blur = 26, T = 0.6, Color = "black" } },
				Window = { { Y = 16, Blur = 40, T = 0.4, Color = "black" }, { Y = 0, Blur = 30, T = 0.85, Color = "accent" } },
				Hud = { { Y = 4, Blur = 12, T = 0.6, Color = "black" } },
				Chip = { { Y = 3, Blur = 8, T = 0.65, Color = "black" } },
			},
			Rim = { Top = 0.8, Width = 1, Bottom = false },
			Outline = { Width = 1.5, Color = "gold", T = 0, Join = "Miter", Gradient = true, Inner = { Width = 1, Color = "gold", T = 0.45, Offset = 5 } },
			Texture = { Window = "Damask", Backdrop = "Damask", T = 0.95 },
			Chrome = { Header = "Rule", Close = "Gold", Tabs = "Underline" },
			Card = "Tint",
			Backdrop = { Kind = "Velvet" },
			Button = { Radius = "S", Depth = 2, Gloss = false, Outline = 1.5, Rim = true, LabelStroke = false, Gold = true },
			MinText = 15,
		},
	},
	Arcade = {
		label = "Arcade Pixel",
		base = "Candy",
		surface = "dark",
		palette = {
			solid = { 0.55, 0.85 }, chroma = { 0.15, 0.3 }, neutral = 0.04,
			panel = { L = 0.96, C = 0.02 }, glass = { L = 0.2, C = 0.06 },
			defaults = { primary = "#FF004D", secondary = "#29ADFF", accent = "#FFEC27" },
			neutralHue = 266,
			snap = { "#FF004D", "#29ADFF", "#FFEC27", "#00E436", "#FFA300", "#FF77A8", "#83769C", "#7E2553", "#008751", "#AB5236", "#1D2B53", "#C2C3C7", "#FFF1E8" },
		},
		fonts = { display = "PressStart2P", title = "PressStart2P", body = "BuilderMono:Bold", heavy = "PressStart2P", small = "BuilderMono:Bold", number = "PressStart2P", mono = "BuilderMono:Bold" },
		radius = { S = 0, M = 0, L = 0 },
		stroke = { Text = 2, UI = 4, Thin = 3 },
		textStroke = { Glass = { Color = BLACK, Thickness = 2, Transparency = 0 }, Art = { Color = BLACK, Thickness = 3, Transparency = 0 }, Button = { Color = BLACK, Thickness = 2, Transparency = 0 }, Solid = { Color = BLACK, Thickness = 2, Transparency = 0 } },
		depth = 4,
		style = { ButtonStyle = "Flat", PanelStyle = "Solid", ClickFx = "None", Transition = "Swap", Decoration = "None", Shadow = "Hard", IconStyle = "Filled", TextCase = "Upper", ButtonCase = "Upper" },
		motion = "Stepped",
		sound = sounds(2),
		blur = 0, scrim = 0.6,
		family = {
			Fill = { Lift = 0.08, Drop = 0.08, Gloss = false, Angle = 90, Hard = true },
			Shadow = {
				Button = { { X = 4, Y = 4, Blur = 0, T = 0, Color = "black" } },
				Card = { { X = 4, Y = 4, Blur = 0, T = 0, Color = "black" } },
				Window = { { X = 8, Y = 8, Blur = 0, T = 0, Color = "black" } },
				Hud = { { X = 4, Y = 4, Blur = 0, T = 0, Color = "black" } },
				Chip = { { X = 3, Y = 3, Blur = 0, T = 0, Color = "black" } },
			},
			Rim = { Top = 0.55, Width = 4, Bottom = 0.45, BottomWidth = 4, Hard = true },
			Outline = { Width = 4, Color = "black", T = 0, Join = "Miter" },
			Texture = { Window = "Scanlines", Backdrop = "Scanlines", T = 0.9 },
			Chrome = { Header = "Pixel", Close = "Pixel", Tabs = "Slab" },
			Card = "Flat",
			Backdrop = { Kind = "Pixel" },
			Button = { Radius = 0, Depth = 4, Gloss = false, Outline = 4, Rim = true, LabelStroke = true },
			MinText = 16,
			Pixel = true,
		},
	},
	Comic = {
		label = "Comic Pop",
		base = "Arena",
		surface = "light",
		palette = {
			solid = { 0.58, 0.86 }, chroma = { 0.2, 0.3 }, neutral = 0.0,
			panel = { L = 1, C = 0 }, glass = { L = 0.2, C = 0.0 },
			defaults = { primary = "#1E6BFF", secondary = "#FF2E4D", accent = "#FFD400" },
			ink = "#111111",
			harmony = "triadic",
		},
		fonts = { display = "Bangers", title = "Bangers", body = "DenkOne", heavy = "LuckiestGuy", small = "DenkOne", number = "LuckiestGuy", mono = "BuilderMono:Bold" },
		radius = { S = 4, M = 6, L = 8 },
		stroke = { Text = 3, UI = 4.5, Thin = 3 },
		textStroke = { Glass = { Color = hex("111111"), Thickness = 3, Transparency = 0 }, Art = { Color = hex("111111"), Thickness = 4, Transparency = 0 }, Button = { Color = hex("111111"), Thickness = 3, Transparency = 0 }, Solid = { Color = hex("111111"), Thickness = 3, Transparency = 0 } },
		depth = 0,
		style = { ButtonStyle = "Flat", PanelStyle = "Solid", ClickFx = "Punch", Transition = "Zoom", Decoration = "None", Shadow = "Hard", IconStyle = "Filled", TextCase = "Upper", ButtonCase = "Upper" },
		motion = "Punchy",
		sound = sounds(1.1, { Click = { Id = TICK, Pitch = 0.85, Volume = 0.6, Notes = { 1, 0.75 }, Gap = 0.02 } }),
		blur = 6, scrim = 0.4,
		family = {
			Fill = { Lift = 0.06, Drop = 0.05, Gloss = false, Angle = 90 },
			Shadow = {
				Button = { { X = 5, Y = 5, Blur = 0, T = 0, Color = "ink" } },
				Card = { { X = 5, Y = 5, Blur = 0, T = 0, Color = "ink" } },
				Window = { { X = 10, Y = 10, Blur = 0, T = 0, Color = "ink" } },
				Hud = { { X = 4, Y = 4, Blur = 0, T = 0, Color = "ink" } },
				Chip = { { X = 3, Y = 3, Blur = 0, T = 0, Color = "ink" } },
			},
			Rim = false,
			Outline = { Width = 4.5, Color = "ink", T = 0, Join = "Miter" },
			Texture = { Window = "Halftone", Backdrop = "Halftone", T = 0.82 },
			Chrome = { Header = "Burst", Close = "Ink", Tabs = "Slab" },
			Card = "Flat",
			Backdrop = { Kind = "Burst" },
			Button = { Radius = "S", Depth = 0, Gloss = false, Outline = 4, Rim = false, LabelStroke = true },
			Tilt = 2,
			MinText = 16,
		},
	},
	Dread = {
		label = "Dread",
		base = "Horror",
		surface = "dark",
		palette = {
			solid = { 0.38, 0.5 }, chroma = { 0.05, 0.16 }, neutral = 0.01,
			panel = { L = 0.9, C = 0.02, hue = 85 }, glass = { L = 0.1, C = 0.008 },
			defaults = { primary = "#B3121B", secondary = "#9FB36A", accent = "#B3121B" },
			hue = { centre = 25, width = 30 },
			muted = 0.45,
		},
		fonts = { display = "SpecialElite", title = "SpecialElite", body = "SpecialElite", heavy = "SpecialElite", small = "SpecialElite", number = "Oswald:SemiBold", mono = "RobotoMono:Medium", ornate = "Creepster" },
		radius = { S = 0, M = 2, L = 2 },
		stroke = { Text = 1.5, UI = 1, Thin = 1 },
		textStroke = { Glass = { Color = BLACK, Thickness = 1.5, Transparency = 0.3 }, Art = { Color = BLACK, Thickness = 2, Transparency = 0.2 }, Button = { Thickness = 0, Transparency = 1 }, Solid = { Color = BLACK, Thickness = 1.5, Transparency = 0 } },
		depth = 0,
		style = { ButtonStyle = "Outline", PanelStyle = "Solid", ClickFx = "Shake", Transition = "Fade", Decoration = "Grain", Shadow = "None", IconStyle = "Outline", TextCase = "Upper", ButtonCase = "Upper" },
		motion = "Uneasy",
		sound = sounds(0.62, { Hover = "", Coin = { Id = TICK, Pitch = 0.9, Volume = 0.3 } }),
		blur = 22, scrim = 0.68,
		family = {
			Fill = { Lift = 0.02, Drop = 0.02, Gloss = false, Angle = 90 },
			Shadow = { Button = {}, Card = {}, Window = { { Y = 0, Blur = 60, Spread = 10, T = 0.35, Color = "black" } }, Hud = {}, Chip = {} },
			Rim = false,
			Outline = { Width = 1, Color = "bone", T = 0.78, Join = "Miter" },
			Texture = { Window = "Grain", Backdrop = "Grain", T = 0.88 },
			Chrome = { Header = "Scratch", Close = "Ghost", Tabs = "Underline" },
			Card = "Tint",
			Backdrop = { Kind = "Dark" },
			Button = { Radius = 0, Depth = 0, Gloss = false, Outline = 1, Rim = false, LabelStroke = false },
			MinText = 16,
		},
	},
	Holo = {
		label = "Holo HUD",
		base = "Tactical",
		surface = "dark",
		palette = {
			solid = { 0.74, 0.84 }, chroma = { 0.12, 0.16 }, neutral = 0.035,
			panel = { L = 0.96, C = 0.02 }, glass = { L = 0.18, C = 0.04 },
			defaults = { primary = "#3FE0FF", secondary = "#7A5CFF", accent = "#FFB020" },
			neutralHue = 232,
			hue = { centre = 215, width = 60 },
		},
		fonts = { display = "Sarpanch:Bold", title = "Sarpanch:SemiBold", body = "TitilliumWeb:SemiBold", heavy = "Sarpanch:Bold", small = "TitilliumWeb:SemiBold", number = "RobotoMono:Bold", mono = "RobotoMono:Bold" },
		radius = { S = 2, M = 4, L = 4 },
		stroke = { Text = 0, UI = 1, Thin = 1 },
		textStroke = { Glass = { Thickness = 0, Transparency = 1 }, Art = { Thickness = 2, Transparency = 0.6 }, Button = { Thickness = 0, Transparency = 1 }, Solid = { Thickness = 1, Transparency = 0.3 } },
		depth = 0,
		style = { ButtonStyle = "Outline", PanelStyle = "Solid", ClickFx = "Glitch", Transition = "Slide", Decoration = "None", Shadow = "Soft", IconStyle = "Outline", TextCase = "Upper", ButtonCase = "Upper" },
		motion = "Technical",
		sound = sounds(1.45, { Hover = { Id = TICK, Pitch = 2.4, Volume = 0.08 } }),
		blur = 14, scrim = 0.5,
		family = {
			Fill = { Lift = 0.04, Drop = 0.04, Gloss = false, Angle = 90, Translucent = 0.35 },
			Shadow = {
				Button = { { Y = 0, Blur = 12, T = 0.72, Color = "glow" } },
				Card = { { Y = 0, Blur = 14, T = 0.8, Color = "accent" } },
				Window = { { Y = 0, Blur = 30, Spread = 1, T = 0.72, Color = "accent" } },
				Hud = { { Y = 0, Blur = 12, T = 0.78, Color = "accent" } },
				Chip = {},
			},
			Rim = false,
			Outline = { Width = 1, Color = "accent", T = 0.45, Join = "Miter" },
			Texture = { Window = "Scanlines", Backdrop = "Grid", T = 0.94 },
			Chrome = { Header = "Brackets", Close = "Bracket", Tabs = "Underline", Brackets = true },
			Card = "Tint",
			Backdrop = { Kind = "Holo" },
			Button = { Radius = "S", Depth = 0, Gloss = false, Outline = 1, Glow = true, Rim = false, LabelStroke = false },
			Chamfer = 14,
			MinText = 15,
		},
	},
	Gacha = {
		label = "Gacha Anime",
		base = "Adventure",
		surface = "dark",
		palette = {
			solid = { 0.62, 0.76 }, chroma = { 0.13, 0.2 }, neutral = 0.05,
			panel = { L = 0.975, C = 0.012 }, glass = { L = 0.22, C = 0.07 },
			defaults = { primary = "#4FA3FF", secondary = "#FF7EB6", accent = "#FFCB5C" },
			neutralHue = 278,
			harmony = "split",
			night = true,
		},
		fonts = { display = "Montserrat:Black", title = "Montserrat:Black", body = "BuilderSans:SemiBold", heavy = "Montserrat:ExtraBold", small = "BuilderSans:Medium", number = "BuilderExtended:Bold", mono = "BuilderMono:Bold" },
		radius = { S = 6, M = 12, L = 18 },
		stroke = { Text = 1.5, UI = 1.5, Thin = 1 },
		textStroke = { Glass = { Thickness = 1.5, Transparency = 0.5 }, Art = { Thickness = 2, Transparency = 0.3 }, Button = { Thickness = 1.5, Transparency = 0.4 }, Solid = { Thickness = 1.5, Transparency = 0 } },
		depth = 0,
		style = { ButtonStyle = "Pill", PanelStyle = "Solid", ClickFx = "Sparkle", Transition = "Slide", Decoration = "Sparkles", Shadow = "Soft", IconStyle = "Filled", TextCase = "Normal", ButtonCase = "Upper" },
		motion = "Snappy",
		sound = sounds(1.5),
		blur = 16, scrim = 0.45,
		family = {
			Fill = { Lift = 0.08, Drop = 0.06, Gloss = 0.7, Angle = 120 },
			Shadow = {
				Button = { { Y = 6, Blur = 16, Spread = -2, T = 0.62, Color = "tone" } },
				Card = { { Y = 10, Blur = 30, Spread = -4, T = 0.7, Color = "deep" } },
				Window = { { Y = 18, Blur = 48, Spread = -6, T = 0.55, Color = "deep" }, { Y = 0, Blur = 24, T = 0.85, Color = "glow" } },
				Hud = { { Y = 6, Blur = 16, T = 0.7, Color = "deep" } },
				Chip = { { Y = 3, Blur = 8, T = 0.75, Color = "deep" } },
			},
			Rim = { Top = 0.45, Width = 1.5, Bottom = false },
			Outline = { Width = 1.5, Color = "white", T = 0.55, Join = "Round" },
			Texture = { Window = "Streaks", Backdrop = "Streaks", T = 0.9 },
			Chrome = { Header = "Slant", Close = "Round", Tabs = "Slant" },
			Backdrop = { Kind = "Night" },
			Button = { Radius = "Full", Depth = 0, Gloss = true, Outline = false, Rim = true, LabelStroke = false },
			MinText = 15,
		},
	},
	Broadcast = {
		label = "Broadcast",
		base = "Arena",
		surface = "dark",
		palette = {
			solid = { 0.56, 0.68 }, chroma = { 0.17, 0.24 }, neutral = 0.03,
			panel = { L = 0.97, C = 0.01 }, glass = { L = 0.2, C = 0.04 },
			defaults = { primary = "#1F7BFF", secondary = "#FF3B30", accent = "#D7FF1F" },
			neutralHue = 258,
			volt = "#D7FF1F",
			harmony = "complementary",
		},
		fonts = { display = "BuilderExtended:ExtraBold", title = "BuilderExtended:ExtraBold", body = "BuilderSans:SemiBold", heavy = "BuilderSans:ExtraBold", small = "BuilderSans:Medium", number = "Oswald:Bold", mono = "BuilderMono:Bold" },
		radius = { S = 2, M = 3, L = 4 },
		stroke = { Text = 0, UI = 0, Thin = 1 },
		textStroke = { Glass = { Thickness = 0, Transparency = 1 }, Art = { Color = BLACK, Thickness = 2, Transparency = 0.6 }, Button = { Thickness = 0, Transparency = 1 }, Solid = { Color = BLACK, Thickness = 1.5, Transparency = 0.2 } },
		depth = 0,
		style = { ButtonStyle = "Flat", PanelStyle = "Solid", ClickFx = "Ripple", Transition = "Slide", Decoration = "None", Shadow = "Hard", IconStyle = "Filled", TextCase = "Upper", ButtonCase = "Upper" },
		motion = "Broadcast",
		sound = sounds(1.15, { Swoosh = { Id = TICK, Pitch = 0.9, Volume = 0.35, Notes = { 1, 1.12, 1.26, 1.41 }, Gap = 0.018 } }),
		blur = 14, scrim = 0.55,
		family = {
			Fill = { Lift = 0.04, Drop = 0.04, Gloss = false, Angle = 90 },
			Shadow = {
				Button = { { Y = 3, Blur = 0, T = 0.5, Color = "black" } },
				Card = { { Y = 4, Blur = 0, T = 0.55, Color = "black" } },
				Window = { { Y = 6, Blur = 0, T = 0.45, Color = "black" }, { Y = 20, Blur = 40, T = 0.6, Color = "black" } },
				Hud = { { Y = 3, Blur = 0, T = 0.5, Color = "black" } },
				Chip = { { Y = 2, Blur = 0, T = 0.55, Color = "black" } },
			},
			Rim = { Top = 0.9, Width = 1, Bottom = false },
			Outline = false,
			Texture = { Window = "Stripes", Backdrop = "Stripes", T = 0.94 },
			Chrome = { Header = "Slab", Close = "Square", Tabs = "Slab", AccentBar = true },
			Card = "Flat",
			Backdrop = { Kind = "Stadium" },
			Button = { Radius = "S", Depth = 0, Gloss = false, Outline = false, Rim = true, LabelStroke = false, AccentBar = true },
			MinText = 15,
		},
	},
	Tavern = {
		label = "Tavern Fantasy",
		base = "Adventure",
		surface = "light",
		palette = {
			solid = { 0.45, 0.58 }, chroma = { 0.1, 0.16 }, neutral = 0.04,
			panel = { L = 0.91, C = 0.045, hue = 80 }, glass = { L = 0.22, C = 0.04 },
			defaults = { primary = "#3F7D3A", secondary = "#B8322E", accent = "#E8B64C" },
			neutralHue = 55,
			fixed = { accent = "#E8B64C" },
			wood = { "#6B4226", "#3E2616", "#2A170C" },
			ink = "#2A1D12",
		},
		-- (serif for titles only: HUD lines, labels and numbers read in a sans)
		fonts = { display = "Merriweather:Black", title = "Merriweather:Black", body = "BuilderSans:Bold", heavy = "BuilderSans:ExtraBold", small = "BuilderSans:SemiBold", number = "BuilderSans:ExtraBold", mono = "BuilderMono:Bold", ornate = "Fondamento" },
		radius = { S = 4, M = 6, L = 10 },
		stroke = { Text = 2, UI = 3, Thin = 1.5 },
		textStroke = { Glass = { Thickness = 2, Transparency = 0 }, Art = { Thickness = 2.5, Transparency = 0 }, Button = { Thickness = 2, Transparency = 0 }, Solid = { Thickness = 2, Transparency = 0 } },
		depth = 3,
		style = { ButtonStyle = "Glossy3D", PanelStyle = "Solid", ClickFx = "Sparkle", Transition = "Drop", Decoration = "None", Shadow = "Soft", IconStyle = "Filled", TextCase = "Normal", ButtonCase = "Normal" },
		motion = "Weighty",
		sound = sounds(0.85, { Swoosh = { Id = TICK, Pitch = 0.6, Volume = 0.3, Notes = { 1, 1.06, 1.12 }, Gap = 0.03 } }),
		blur = 14, scrim = 0.45,
		family = {
			Fill = { Lift = 0.07, Drop = 0.06, Gloss = 0.7, Angle = 90 },
			Shadow = {
				Button = { { Y = 4, Blur = 8, T = 0.55, Color = "wood" } },
				Card = { { Y = 6, Blur = 14, T = 0.65, Color = "wood" } },
				Window = { { Y = 10, Blur = 28, T = 0.45, Color = "wood" }, { Y = 3, Blur = 0, T = 0.3, Color = "wood" } },
				Hud = { { Y = 4, Blur = 10, T = 0.6, Color = "wood" } },
				Chip = { { Y = 2, Blur = 4, T = 0.6, Color = "wood" } },
			},
			Rim = { Top = 0.6, Width = 1.5, Bottom = false },
			Outline = { Width = 4, Color = "wood", T = 0, Join = "Round", Inner = { Width = 1.5, Color = "gold", T = 0.25, Offset = 0 } },
			Texture = { Window = "Parchment", Backdrop = "Wood", T = 0.86 },
			Chrome = { Header = "Plank", Close = "Iron", Tabs = "Folder" },
			Backdrop = { Kind = "Wood" },
			Button = { Radius = "S", Depth = 3, Gloss = true, Outline = 3, Rim = true, LabelStroke = true },
			MinText = 16,
		},
	},
	Mochi = {
		label = "Mochi Kawaii",
		base = "Bubbly",
		surface = "light",
		palette = {
			-- (pastel panels, but buttons and icons stay punchy: the solids sit a
			-- step deeper and more saturated than the panels)
			solid = { 0.68, 0.78 }, chroma = { 0.13, 0.19 }, neutral = 0.02,
			panel = { L = 0.99, C = 0.012 }, glass = { L = 0.36, C = 0.09 },
			defaults = { primary = "#FF8FC0", secondary = "#9FD8FF", accent = "#FFE27A" },
			harmony = "analogous",
		},
		fonts = { display = "FredokaOne", title = "FredokaOne", body = "Nunito:ExtraBold", heavy = "Nunito:Black", small = "Nunito:Bold", number = "FredokaOne", mono = "BuilderMono:Bold" },
		radius = { S = 12, M = 20, L = 28 },
		stroke = { Text = 2.5, UI = 3, Thin = 2 },
		textStroke = { Glass = { Thickness = 2.5, Transparency = 0 }, Art = { Thickness = 3, Transparency = 0 }, Button = { Thickness = 2, Transparency = 0 }, Solid = { Thickness = 2, Transparency = 0 } },
		depth = 0,
		style = { ButtonStyle = "Pill", PanelStyle = "Solid", ClickFx = "Sparkle", Transition = "Pop", Decoration = "Dots", Shadow = "Hard", IconStyle = "Filled", TextCase = "Normal", ButtonCase = "Normal" },
		motion = "Bouncy",
		sound = sounds(1.5, { Coin = { Id = TICK, Pitch = 2.2, Volume = 0.3 } }),
		blur = 10, scrim = 0.3,
		family = {
			Fill = { Lift = 0.06, Drop = 0.05, Gloss = 0.55, Angle = 90 },
			Shadow = {
				Button = { { Y = 5, Blur = 0, T = 0.1, Color = "tone" } },
				Card = { { Y = 5, Blur = 0, T = 0.5, Color = "trim" } },
				Window = { { Y = 8, Blur = 0, T = 0.35, Color = "trim" }, { Y = 18, Blur = 40, Spread = -8, T = 0.75, Color = "trim" } },
				Hud = { { Y = 4, Blur = 0, T = 0.4, Color = "trim" } },
				Chip = { { Y = 3, Blur = 0, T = 0.45, Color = "trim" } },
			},
			Rim = { Top = 0.3, Width = 2, Bottom = false },
			Outline = { Width = 3, Color = "trim", T = 0, Join = "Round", Inner = { Width = 2, Color = "white", T = 0.3 } },
			Texture = { Window = "Dots", Backdrop = "Dots", T = 0.9 },
			Chrome = { Header = "Band", Close = "Pill", Tabs = "Pill" },
			Backdrop = { Kind = "Candy" },
			Button = { Radius = "Full", Depth = 0, Gloss = true, Outline = 3, Rim = true, LabelStroke = true },
			MinText = 16,
		},
	},
}

-- Hue name buckets (HSV degrees), exactly as Generator/Styles.toneName, so a
-- generated tone name finds the palette colour it was made from.
local function hsvBucket(color: Color3): string
	local h, s, v = color:ToHSV()
	h *= 360
	if s < 0.18 then
		if v > 0.85 then
			return "White"
		elseif v < 0.25 then
			return "Dark"
		end
		return "Gray"
	end
	if h < 12 or h >= 345 then
		return "Red"
	elseif h < 38 then
		return "Orange"
	elseif h < 68 then
		return "Yellow"
	elseif h < 160 then
		return "Green"
	elseif h < 198 then
		return "Cyan"
	elseif h < 250 then
		return "Blue"
	elseif h < 292 then
		return "Purple"
	end
	return "Pink"
end
Families.hsvBucket = hsvBucket

-- Canonical OKLCH hues of the named tones.
local NAMED_HUES = {
	Red = 25, Orange = 52, Yellow = 90, Green = 142, Cyan = 205, Blue = 255, Purple = 300, Pink = 350,
}
Families.NAMED_HUES = NAMED_HUES

local function parseHex(value: any): Color3?
	if typeof(value) == "Color3" then
		return value
	end
	if typeof(value) ~= "string" then
		return nil
	end
	local digits = string.match(value, "^%s*#?(%x%x%x%x%x%x)%s*$")
	if digits == nil then
		local short = string.match(value, "^%s*#?(%x%x%x)%s*$")
		if short == nil then
			return nil
		end
		digits = string.gsub(short, "%x", "%0%0")
	end
	return Color3.fromHex(digits)
end
Families.parseHex = parseHex

function Families.get(id: any): { [string]: any }?
	if typeof(id) ~= "string" then
		return nil
	end
	local def = Families.DEFS[id]
	if def then
		return def
	end
	local key = string.lower(string.gsub(id, "[^%w]", ""))
	for name, entry in Families.DEFS do
		if string.lower(name) == key or string.lower(string.gsub(entry.label, "[^%w]", "")) == key then
			return entry
		end
	end
	return nil
end

-- The family id for a name ("toybox gloss" -> "Toybox"), or nil.
function Families.resolve(id: any): string?
	local def = Families.get(id)
	if def == nil then
		return nil
	end
	for name, entry in Families.DEFS do
		if entry == def then
			return name
		end
	end
	return nil
end

--------------------------------------------------------------------------
-- Palette -> colours
--------------------------------------------------------------------------

-- A colour moved into the family's solid lightness / chroma window (and hue
-- window), keeping its hue.
local function envelope(color: Color3, pal: { [string]: any }, bias: number?): Color3
	local L, C, h = Oklch.fromColor(color)
	local minL, maxL = pal.solid[1], pal.solid[2]
	-- yellows and greens sit high, blues / violets low (they're darker at the same chroma)
	local warm = math.cos(math.rad(h - 95)) -- 1 at yellow, -1 at violet
	local targetL = math.clamp(L, minL, maxL)
	targetL = math.clamp(targetL + warm * 0.04 + (bias or 0), minL - 0.02, maxL + 0.06)
	local minC, maxC = pal.chroma[1], pal.chroma[2]
	local targetC = math.clamp(C, minC, maxC)
	if C < 0.03 then
		targetC = C -- greys stay grey
	end
	if pal.hue and C >= 0.03 then
		local centre, width = pal.hue.centre, pal.hue.width
		local d = Oklch.hueDelta(centre, h)
		if math.abs(d) > width / 2 then
			h = (centre + math.sign(d) * width / 2) % 360
		end
	end
	return Oklch.toColor(targetL, targetC, h)
end
Families.envelope = envelope

local function snapTo(color: Color3, list: { string }): Color3
	local L, a, b = Oklch.toLab(color)
	local best, bestD = color, math.huge
	for _, value in list do
		local c = hex((string.gsub(value, "#", "")))
		local L2, a2, b2 = Oklch.toLab(c)
		local d = (L - L2) ^ 2 * 0.5 + (a - a2) ^ 2 + (b - b2) ^ 2
		if d < bestD then
			best, bestD = c, d
		end
	end
	return best
end

export type Fill = { Lift: number, Drop: number, Gloss: any, Angle: number?, Hard: boolean? }

--[[
	A BloxUI tone ({ Top, Mid, Bottom, Edge, Deep, Stroke, Text }) from one
	solid colour, shaded in OKLCH: a lighter, warmer top, the solid in the
	middle, a slightly deeper, cooler bottom, and edge / deep / stroke shades
	that lean violet. `labelStroke` keeps white labels (with a tinted outline)
	even on light tones; otherwise the label is white or a dark tint of the
	tone, whichever passes 3:1 (large bold text).
]]
function Families.tone(color: Color3, fill: Fill?, labelStroke: boolean?, ink: Color3?): { [string]: any }
	local f = fill or { Lift = 0.1, Drop = 0.08 }
	local L, C, h = Oklch.fromColor(color)
	local white = WHITE
	-- Labels must pass 3:1 (large bold text, WCAG). Light yellow / lime /
	-- cyan solids take dark text (Radix); any other solid is deepened a
	-- little until a white label passes, the way Radix picks its step 9.
	-- Outlined families keep white labels: the tinted outline carries them.
	local lightHue = h > 70 and h < 215 and L >= 0.7
	if not labelStroke and not lightHue then
		local tries = 0
		while Oklch.contrast(white, color) < 3 and tries < 16 and L > 0.3 do
			L -= 0.01
			color = Oklch.toColor(L, C, h)
			tries += 1
		end
	end
	local grey = C < 0.03
	local warmH = if grey then h else Oklch.hueToward(h, 100, 10)
	local coolH = if grey then h else Oklch.hueToward(h, 280, 8)
	local deepH = if grey then h else Oklch.hueToward(h, 285, 14)
	local top = Oklch.toColor(L + f.Lift, C * 0.9, warmH)
	local bottom = Oklch.toColor(L - f.Drop, C * 1.04, coolH)
	local edge = Oklch.toColor(math.max(0.2, L - f.Drop - 0.17), C * 1.02, coolH)
	local deep = Oklch.toColor(math.max(0.14, L * 0.34), math.min(C, 0.12) * 0.8, deepH)
	local stroke = ink or Oklch.toColor(math.max(0.18, L * 0.4), math.min(0.16, C * 1.05), deepH)
	local text: Color3
	if labelStroke or Oklch.contrast(white, color) >= 3 then
		text = white
	else
		-- a dark tint of the tone, lightened / darkened until it passes 4.5:1
		text = Oklch.readable(Oklch.toColor(math.max(0.16, L * 0.3), math.min(C, 0.1), deepH), color, 4.5)
	end
	return {
		Top = top,
		Mid = color,
		Bottom = bottom,
		Edge = edge,
		Deep = deep,
		Text = text,
		Stroke = if labelStroke or text == white then stroke else nil,
	}
end

-- The family's colours from a blueprint palette. Returns a table of Color3s.
function Families.colors(def: { [string]: any }, style: { [string]: any }?): { [string]: any }
	local s = style or {}
	local p = s.palette or {}
	local pal = def.palette
	local defaults = pal.defaults
	local mode = def.surface
	local surfaceGiven = p.surface
	local surfaceHex = parseHex(surfaceGiven)
	if surfaceGiven == "light" or surfaceGiven == "dark" then
		mode = surfaceGiven
	elseif surfaceHex then
		-- an explicit colour: its lightness decides the mode
		mode = if Oklch.fromColor(surfaceHex) > 0.6 then "light" else "dark"
	end

	local primaryRaw = parseHex(p.primary) or parseHex(defaults.primary) :: Color3
	local secondaryRaw = parseHex(p.secondary) or parseHex(defaults.secondary) :: Color3
	local accentRaw = parseHex(p.accent) or parseHex(defaults.accent) :: Color3
	if pal.snap then
		primaryRaw = snapTo(primaryRaw, pal.snap)
		secondaryRaw = snapTo(secondaryRaw, pal.snap)
		accentRaw = snapTo(accentRaw, pal.snap)
	end
	local primary = if pal.snap then primaryRaw else envelope(primaryRaw, pal)
	local secondary = if pal.snap then secondaryRaw else envelope(secondaryRaw, pal)
	local accent = if pal.snap then accentRaw else envelope(accentRaw, pal, 0.04)
	if pal.fixed then
		if pal.fixed.accent then
			accent = parseHex(pal.fixed.accent) :: Color3
		end
		if pal.fixed.primary then
			primary = parseHex(pal.fixed.primary) :: Color3
		end
	end
	if pal.volt then
		-- broadcast: the accent is a hot volt / signal colour unless the
		-- palette's is already a bright, saturated one
		local aL, aC = Oklch.fromColor(accent)
		if aL < 0.8 or aC < 0.1 then
			accent = parseHex(pal.volt) :: Color3
		end
	end
	local pL, pC, pH = Oklch.fromColor(primary)
	-- families with a signature surface (broadcast navy, holo blue, gacha
	-- night, pixel navy, tavern wood) keep its hue whatever the palette
	local nH = pal.neutralHue or pH
	if pal.neutralHue == nil and nH > 70 and nH < 118 then
		-- yellow / gold primaries: dark tints of that hue read as olive, so
		-- the neutrals (dark buttons, ink, glass) lean to a warm brown
		nH = 58
	end

	-- tinted neutrals
	local nC = pal.neutral
	local neutralDark = Oklch.neutralRamp(nH, nC, "dark")
	local neutralLight = Oklch.neutralRamp(pal.panel.hue or nH, math.max(nC, pal.panel.C), "light")

	local ink = if pal.ink then parseHex(pal.ink) :: Color3 else Oklch.toColor(0.26, math.min(0.09, pC * 0.5), nH)
	local glassL, glassC = pal.glass.L, pal.glass.C
	local surface = Oklch.toColor(glassL, glassC, nH)
	if surfaceHex and mode == "dark" and Oklch.fromColor(surfaceHex) < 0.4 then
		-- a dark surface colour the blueprint chose keeps its hue, at the family's darkness
		local _, sC, sH = Oklch.fromColor(surfaceHex)
		surface = Oklch.toColor(glassL, math.min(math.max(sC, glassC * 0.5), glassC * 1.5 + 0.02), sH)
	end

	local panel
	if mode == "light" then
		panel = Oklch.toColor(pal.panel.L, pal.panel.C, pal.panel.hue or nH)
		if surfaceHex and Oklch.fromColor(surfaceHex) > 0.6 then
			panel = surfaceHex
		end
	else
		-- dark panels: a raised surface one elevation step above the glass
		panel = Oklch.toColor(glassL + 0.06, glassC * 1.1, nH)
	end

	-- semantic colours, pulled into the envelope (and the palette's own when given)
	local good = envelope(parseHex(p.good) or hex("4FD66A"), pal)
	local bad = envelope(parseHex(p.bad) or hex("FF4A4A"), pal)
	local warn = envelope(parseHex(p.warn) or hex("FFB020"), pal, 0.04)
	local info = envelope(hex("3AA8FF"), pal)
	if pal.muted then
		good = Oklch.adjust(good, nil, Oklch.fromColor(good) and 0.07)
		warn = Oklch.adjust(warn, nil, 0.08)
		info = Oklch.adjust(info, nil, 0.05)
	end

	-- HUD text over the game world is always light; tinted towards the primary
	local text = Oklch.toColor(0.975, math.min(0.02, pC * 0.15), nH)
	if def == Families.DEFS.Dread then
		text = hex("D9D4C7")
	elseif def == Families.DEFS.Royal then
		text = hex("FFF6E0")
	elseif def == Families.DEFS.Holo then
		text = hex("D8F6FF")
	end
	local panelText = if mode == "light" then ink else text
	-- body text on the panel must pass 4.5:1
	panelText = Oklch.readable(panelText, panel, 4.5)
	local muted = if mode == "light"
		then Oklch.readable(Oklch.mix(panelText, panel, 0.35), panel, 4.5)
		else Oklch.readable(Oklch.toColor(0.72, math.min(0.03, pC * 0.2), nH), panel, 4.5)

	-- the trim (panel border) and outline ink
	local trim: Color3
	local outlineKind = def.family.Outline and def.family.Outline.Color
	if outlineKind == "trim" then
		trim = Oklch.toColor(math.max(0.45, pL - 0.28), math.min(0.16, pC * 1.1), pH)
	elseif outlineKind == "gold" then
		trim = accent
	elseif outlineKind == "wood" then
		trim = parseHex(pal.wood and pal.wood[3]) or hex("2A170C")
	elseif outlineKind == "ink" or outlineKind == "black" then
		trim = if outlineKind == "black" then BLACK else ink
	elseif outlineKind == "accent" then
		trim = primary
	elseif outlineKind == "bone" then
		trim = text
	else
		trim = if mode == "light" then Oklch.mix(ink, panel, 0.6) else WHITE
	end

	return {
		mode = mode,
		primary = primary,
		secondary = secondary,
		accent = accent,
		good = good,
		bad = bad,
		warn = warn,
		info = info,
		ink = ink,
		surface = surface,
		panel = panel,
		text = text,
		panelText = panelText,
		muted = muted,
		trim = trim,
		neutralDark = neutralDark,
		neutralLight = neutralLight,
		hue = pH,
		neutralHue = nH,
		chroma = pC,
	}
end

-- The colour a named tone stands for: the palette colour in its hue bucket
-- (the generator quantised it from there), else the canonical hue in the
-- family envelope, leaned 12 degrees towards the primary for harmony.
local function namedColor(name: string, colors: { [string]: any }, def: { [string]: any }): Color3
	for _, key in { "primary", "secondary", "accent" } do
		local c = colors[key]
		if hsvBucket(c) == name then
			return c
		end
	end
	local pal = def.palette
	local hue = NAMED_HUES[name]
	local mid = (pal.solid[1] + pal.solid[2]) / 2
	local chroma = (pal.chroma[1] + pal.chroma[2]) / 2
	local h = Oklch.hueToward(hue, colors.hue, 12)
	local seed = Oklch.toColor(mid, chroma, h)
	if pal.snap then
		return snapTo(seed, pal.snap)
	end
	return envelope(seed, pal)
end

--------------------------------------------------------------------------
-- Apply
--------------------------------------------------------------------------

local function fontRef(ref: string?, fromRef: (string) -> Font): Font?
	if ref == nil then
		return nil
	end
	return fromRef(ref)
end

local function deepCopy(value: any): any
	if typeof(value) ~= "table" then
		return value
	end
	local copy = {}
	for key, inner in value do
		copy[key] = deepCopy(inner)
	end
	return copy
end
Families.deepCopy = deepCopy

-- Type roles (design px at 1080p) and their minimums. Families scale them a
-- little (wide faces smaller, condensed ones bigger).
Families.TYPE_SCALE = {
	Toybox = 1.04, Studio = 0.94, Neon = 0.92, Clay = 1.02, Sketch = 1.08, Royal = 0.96, Arcade = 0.72,
	Comic = 1.1, Dread = 0.98, Holo = 0.94, Gacha = 1, Broadcast = 0.92, Tavern = 0.96, Mochi = 1.04,
}

--[[
	Re-derives `theme` (a deep copy of the archetype theme) for family `id`
	and blueprint `style`. `fromRef` turns "Family:Weight" refs into Fonts
	(Theme.fontFromRef). Keys named in style.custom (the plugin marks what
	you changed by hand) keep the blueprint's value: fonts, corner,
	buttonStyle, panelStyle, clickFx, transition, textCase, decoration,
	shadow, iconStyle, rarityScheme, density.
]]
function Families.apply(theme: { [any]: any }, id: string, style: { [string]: any }?, fromRef: (string) -> Font): { [any]: any }
	local def = Families.DEFS[id]
	assert(def, `[BloxUI] Families.apply: unknown family "{id}"`)
	local s = style or {}
	local custom = {}
	if typeof(s.custom) == "table" then
		for _, key in s.custom do
			if typeof(key) == "string" then
				custom[key] = true
			end
		end
	end
	local fam = deepCopy(def.family)
	local colors = Families.colors(def, s)
	local light = colors.mode == "light"
	fam.Id = id
	fam.Label = def.label
	fam.Light = light
	fam.Ink = colors.ink
	fam.Mode = colors.mode
	theme.Family = fam

	local labelStroke = fam.Button and fam.Button.LabelStroke == true
	local inkStroke = if def.palette.ink and (fam.Outline and fam.Outline.Color == "ink") then colors.ink else nil
	local function toneOf(c: Color3)
		return Families.tone(c, fam.Fill, labelStroke, inkStroke)
	end

	-- colours --------------------------------------------------------------
	local c = theme.Color
	local nd, nl = colors.neutralDark, colors.neutralLight
	c.Primary = colors.primary
	c.Secondary = colors.secondary
	c.Accent = colors.accent
	c.Good, c.Bad, c.Warn, c.Info = colors.good, colors.bad, colors.warn, colors.info
	c.Text = colors.text
	c.Light = if light then WHITE else Oklch.toColor(0.97, 0.01, colors.neutralHue)
	c.TextDark = colors.ink
	c.Surface = colors.surface
	c.SurfaceLight = Oklch.toColor(Oklch.fromColor(colors.surface) + 0.08, 0.03, colors.neutralHue)
	c.Panel = colors.panel
	c.PanelText = colors.panelText
	c.TextMuted = Oklch.readable(Oklch.toColor(0.78, math.min(0.03, colors.chroma * 0.2), colors.neutralHue), colors.surface, 4.5)
	c.PanelMuted = colors.muted
	c.Outline = Oklch.toColor(0.16, math.min(0.06, colors.chroma * 0.4), colors.neutralHue)
	c.Backdrop = nd[1]
	c.Track = if light then Oklch.toColor(0.82, 0.02, colors.neutralHue) else nd[3]
	c.Trim = colors.trim
	c.Glass = WHITE
	c.Focus = if light then Oklch.toColor(0.5, math.min(0.18, colors.chroma + 0.04), colors.hue) else colors.accent
	c.Scroll = if light then colors.trim else WHITE
	c.Scrim = Oklch.toColor(0.12, math.min(0.04, colors.chroma * 0.3), colors.neutralHue)
	c.Ink = colors.ink
	c.Pill = colors.surface
	c.Keycap = Oklch.toColor(0.4, 0.02, colors.neutralHue)
	c.Callout = colors.bad
	c.Health = colors.good
	c.Progress = colors.primary
	-- plain cards on the window panel (Blueprint Common.card rows and plates).
	-- Section code writes light text on them (they were dark glass before
	-- v2.4), so on light panels they are a deep tinted inset, one step above
	-- the HUD glass; on dark panels one step above the panel. CardText /
	-- CardMuted are the readable text colours on them (for new layouts).
	c.Card = if light
		then Oklch.toColor(math.min(0.34, Oklch.fromColor(colors.surface) + 0.04), math.min(0.09, colors.chroma * 0.5 + 0.02), colors.neutralHue)
		else Oklch.toColor(math.min(0.42, Oklch.fromColor(colors.panel) + 0.05), 0.02, colors.neutralHue)
	c.CardEdge = WHITE
	c.CardText = Oklch.readable(colors.text, c.Card, 4.5)
	c.CardMuted = Oklch.readable(Oklch.mix(colors.text, c.Card, 0.3), c.Card, 4.5)
	-- muted text sits on the HUD glass and on cards: readable on the lighter one
	local mutedBase = if Oklch.fromColor(c.Card) > Oklch.fromColor(colors.surface) then c.Card else colors.surface
	c.TextMuted = Oklch.readable(c.TextMuted, mutedBase, 4.5)
	-- the colour shadows are tinted with (never pure black on coloured scenes)
	c.Shadow = Oklch.toColor(0.18, math.min(0.08, colors.chroma * 0.5), (colors.neutralHue + 10) % 360)
	c.AccentText = if light
		then Oklch.readable(Oklch.adjust(colors.accent, 0.5), colors.panel, 3)
		else Oklch.readable(colors.accent, colors.surface, 3)
	if id == "Tavern" then
		local wood = def.palette.wood
		c.Wood = parseHex(wood[1])
		c.WoodDark = parseHex(wood[2])
		c.WoodEdge = parseHex(wood[3])
	end

	-- tones ----------------------------------------------------------------
	local tones = theme.Tone
	for name in NAMED_HUES do
		tones[name] = toneOf(namedColor(name, colors, def))
	end
	-- neutral tones: white, gray and dark follow the tinted neutrals
	tones.White = Families.tone(Oklch.toColor(0.97, 0.012, colors.neutralHue), fam.Fill, false)
	tones.White.Text = colors.ink
	tones.Gray = toneOf(Oklch.toColor(if light then 0.62 else 0.42, 0.025, colors.neutralHue))
	tones.Dark = toneOf(if light then Oklch.toColor(0.36, math.min(0.08, colors.chroma * 0.5), colors.neutralHue) else Oklch.toColor(Oklch.fromColor(colors.panel) + 0.08, 0.03, colors.neutralHue))
	tones.Disabled = Families.tone(Oklch.toColor(if light then 0.78 else 0.4, 0.015, colors.neutralHue), fam.Fill, false)
	tones.Disabled.Text = if light then Oklch.toColor(0.45, 0.02, colors.neutralHue) else Oklch.toColor(0.78, 0.01, colors.neutralHue)
	tones.Primary = toneOf(colors.primary)
	tones.Secondary = toneOf(colors.secondary)
	tones.Accent = toneOf(colors.accent)
	tones.Good = toneOf(colors.good)
	tones.Bad = toneOf(colors.bad)
	tones.Warn = toneOf(colors.warn)
	-- the semantic names the builders use keep their meaning
	tones.Green = toneOf(if hsvBucket(colors.primary) == "Green" then colors.primary else colors.good)
	tones.Red = toneOf(if hsvBucket(colors.primary) == "Red" then colors.primary else colors.bad)

	-- rarity: the classic ladder moved into the family envelope (Secret keeps its rainbow)
	local rarity = Base.rarities("Classic", def.palette.muted)
	for name, tone in rarity do
		if name ~= "Secret" then
			local seed = Families.envelope(tone.Bottom, def.palette)
			if def.palette.snap then
				seed = snapTo(tone.Bottom, def.palette.snap)
			end
			local t = Families.tone(seed, fam.Fill, true)
			t.Name, t.Rank = tone.Name, tone.Rank
			rarity[name] = t
		end
	end
	theme.Rarity = rarity
	theme.RarityBuiltFor = "Family"
	theme.RarityMute = def.palette.muted

	-- shape, stroke, depth ------------------------------------------------
	if not custom.corner then
		theme.Radius = table.clone(def.radius)
	end
	theme.Stroke = table.clone(def.stroke)
	theme.TextStroke = deepCopy(def.textStroke)
	theme.Depth = def.depth

	-- style ------------------------------------------------------------------
	local st = theme.Style
	local keyOf = {
		ButtonStyle = "buttonStyle", PanelStyle = "panelStyle", ClickFx = "clickFx", Transition = "transition",
		Decoration = "decoration", Shadow = "shadow", IconStyle = "iconStyle", TextCase = "textCase",
	}
	for field, value in def.style do
		local key = keyOf[field]
		if key == nil or not custom[key] then
			st[field] = value
		end
	end
	st.Family = id
	st.RarityScheme = "Classic"
	st.BarLabel = if id == "Broadcast" or id == "Studio" then "Inset" else "Outlined"

	-- motion ---------------------------------------------------------------
	theme.Motion = deepCopy(Families.MOTION[def.motion])
	theme.Motion.Personality = def.motion

	-- fonts ------------------------------------------------------------------
	if not custom.fonts then
		local f = def.fonts
		theme.Font = {
			Title = fontRef(f.title, fromRef),
			Display = fontRef(f.display, fromRef),
			Body = fontRef(f.body, fromRef),
			Heavy = fontRef(f.heavy or f.display, fromRef),
			Small = fontRef(f.small or f.body, fromRef),
			Mono = fontRef(f.mono or "BuilderMono:Bold", fromRef),
			Number = fontRef(f.number or f.display, fromRef),
			Label = fontRef(f.heavy or f.body, fromRef),
			Ornate = fontRef(f.ornate, fromRef),
		}
	else
		theme.Font.Number = theme.Font.Number or theme.Font.Display
		theme.Font.Label = theme.Font.Label or theme.Font.Heavy
	end

	-- type roles -----------------------------------------------------------
	theme.Type = Base.typeScale(Families.TYPE_SCALE[id])

	-- spacing (density knob: Compact tightens every space token) ----------
	theme.Space = Base.space()
	if s.density == "Compact" then
		for key, value in theme.Space do
			theme.Space[key] = math.max(2, math.floor(value * 0.75 + 0.5))
		end
	end

	-- the rest -------------------------------------------------------------
	theme.Sound = deepCopy(def.sound)
	theme.Blur = def.blur
	theme.Scrim = def.scrim
	theme.Alpha = {
		-- (light-panel families: glass plates sit on cream / white panels too,
		-- where a see-through dark plate turns into a muddy grey: keep them solid)
		Surface = if light then 0.04 elseif id == "Holo" then 0.35 elseif id == "Neon" then 0.15 elseif id == "Dread" then 0.1 else 0.18,
		Backdrop = 0.45,
		Shadow = 0.5,
		Track = if light then 0 else 0.1,
		Rim = 0.88,
	}
	theme.ScrollBar = { Thickness = if light then 10 else 6, Transparency = if light then 0.1 else 0.4 }
	theme.Name = `{id}Family`
	return theme
end

return Families
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="273">
            <Properties>
              <string name="Name">Oklch</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Theme.Oklch: perceptual colour maths for the v2.4 palette engine.

		local Oklch = require(BloxUI.Theme.Oklch)
		local L, C, h = Oklch.fromColor(Color3.fromHex("2F9BFF"))   -- 0..1, 0..~0.37, degrees
		local c = Oklch.toColor(0.62, 0.18, 250)                    -- gamut-mapped Color3
		local ramp = Oklch.ramp(Color3.fromHex("2F9BFF"), { mode = "dark" })  -- 12 steps (Radix semantics)
		Oklch.contrast(textColor, background)                       -- WCAG 2 ratio, 1..21
		Oklch.readable(text, background, 4.5)                       -- text moved in L until it passes

	Oklab after Björn Ottosson (bottosson.github.io/posts/oklab). Fusion ships
	an Oklab helper too, but its sRGB encode / decode are swapped, so BloxUI
	keeps its own. Everything out of gamut is brought back by lowering the
	chroma (keeping lightness and hue), never by clipping channels (clipping
	shifts the hue).
]]

local Oklch = {}

local function toLinear(c: number): number
	if c <= 0.04045 then
		return c / 12.92
	end
	return ((c + 0.055) / 1.055) ^ 2.4
end

local function toGamma(c: number): number
	if c <= 0.0031308 then
		return 12.92 * c
	end
	return 1.055 * c ^ (1 / 2.4) - 0.055
end

local function cbrt(x: number): number
	if x < 0 then
		return -((-x) ^ (1 / 3))
	end
	return x ^ (1 / 3)
end

-- Color3 -> Oklab L, a, b
function Oklch.toLab(color: Color3): (number, number, number)
	local r, g, b = toLinear(color.R), toLinear(color.G), toLinear(color.B)
	local l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
	local m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
	local s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
	return 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
		1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
		0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
end

-- Oklab -> linear sRGB (unclamped)
local function labToLinear(L: number, a: number, b: number): (number, number, number)
	local l = (L + 0.3963377774 * a + 0.2158037573 * b) ^ 3
	local m = (L - 0.1055613458 * a - 0.0638541728 * b) ^ 3
	local s = (L - 0.0894841775 * a - 1.2914855480 * b) ^ 3
	return 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
		-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
		-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
end

local EPS = 1e-4

local function inGamutLab(L: number, a: number, b: number): boolean
	local r, g, bl = labToLinear(L, a, b)
	return r >= -EPS and r <= 1 + EPS and g >= -EPS and g <= 1 + EPS and bl >= -EPS and bl <= 1 + EPS
end

-- Oklab -> Color3 (channels clamped; use fromLch for gamut mapping)
function Oklch.fromLab(L: number, a: number, b: number): Color3
	local r, g, bl = labToLinear(L, a, b)
	return Color3.new(
		math.clamp(toGamma(math.clamp(r, 0, 1)), 0, 1),
		math.clamp(toGamma(math.clamp(g, 0, 1)), 0, 1),
		math.clamp(toGamma(math.clamp(bl, 0, 1)), 0, 1)
	)
end

-- Color3 -> OKLCH: lightness 0..1, chroma 0..~0.37, hue in degrees 0..360
function Oklch.fromColor(color: Color3): (number, number, number)
	local L, a, b = Oklch.toLab(color)
	local C = math.sqrt(a * a + b * b)
	local h = math.deg(math.atan2(b, a)) % 360
	return L, C, h
end

function Oklch.inGamut(L: number, C: number, h: number): boolean
	local rad = math.rad(h)
	return inGamutLab(L, C * math.cos(rad), C * math.sin(rad))
end

-- The largest chroma <= C that is displayable at (L, h).
function Oklch.maxChroma(L: number, C: number, h: number): number
	if L <= 0 or L >= 1 then
		return 0
	end
	if Oklch.inGamut(L, C, h) then
		return C
	end
	local lo, hi = 0, C
	for _ = 1, 18 do
		local mid = (lo + hi) / 2
		if Oklch.inGamut(L, mid, h) then
			lo = mid
		else
			hi = mid
		end
	end
	return lo
end

-- OKLCH -> Color3, bringing out-of-gamut colours in by lowering chroma.
function Oklch.toColor(L: number, C: number, h: number): Color3
	L = math.clamp(L, 0, 1)
	C = Oklch.maxChroma(L, math.max(0, C), h)
	local rad = math.rad(h)
	return Oklch.fromLab(L, C * math.cos(rad), C * math.sin(rad))
end

-- Mix two colours in Oklab (t = 0 -> a, 1 -> b).
function Oklch.mix(a: Color3, b: Color3, t: number): Color3
	local L1, a1, b1 = Oklch.toLab(a)
	local L2, a2, b2 = Oklch.toLab(b)
	return Oklch.fromLab(L1 + (L2 - L1) * t, a1 + (a2 - a1) * t, b1 + (b2 - b1) * t)
end

-- Hue difference (target - from) the short way round, degrees in -180..180.
function Oklch.hueDelta(from: number, target: number): number
	return ((target - from + 540) % 360) - 180
end

-- Moves hue `h` towards `target` by `amount` degrees at most.
function Oklch.hueToward(h: number, target: number, amount: number): number
	local d = Oklch.hueDelta(h, target)
	if math.abs(d) <= amount then
		return target % 360
	end
	return (h + (if d > 0 then amount else -amount)) % 360
end

-- The same colour with its lightness / chroma / hue changed (nil keeps).
function Oklch.adjust(color: Color3, L: number?, C: number?, h: number?): Color3
	local l0, c0, h0 = Oklch.fromColor(color)
	return Oklch.toColor(L or l0, C or c0, h or h0)
end

--------------------------------------------------------------------------
-- Contrast (WCAG 2.x)
--------------------------------------------------------------------------

function Oklch.luminance(color: Color3): number
	return 0.2126 * toLinear(color.R) + 0.7152 * toLinear(color.G) + 0.0722 * toLinear(color.B)
end

function Oklch.contrast(a: Color3, b: Color3): number
	local la, lb = Oklch.luminance(a), Oklch.luminance(b)
	if la < lb then
		la, lb = lb, la
	end
	return (la + 0.05) / (lb + 0.05)
end

--[[
	`text` moved in lightness (away from `background`, keeping hue and as much
	chroma as fits) until it reaches `ratio` against it. When even black /
	white can't make it, returns whichever of the two reads better.
]]
function Oklch.readable(text: Color3, background: Color3, ratio: number?): Color3
	local want = ratio or 4.5
	if Oklch.contrast(text, background) >= want then
		return text
	end
	local L, C, h = Oklch.fromColor(text)
	local bgL = Oklch.fromColor(background)
	local up = bgL < 0.6
	local lo, hi = L, if up then 1 else 0
	local best = nil
	-- walk the lightness towards the far end in small steps (monotonic contrast)
	for step = 1, 40 do
		local l = lo + (hi - lo) * step / 40
		local candidate = Oklch.toColor(l, C, h)
		if Oklch.contrast(candidate, background) >= want then
			best = candidate
			break
		end
	end
	if best then
		return best
	end
	local white, black = Color3.new(1, 1, 1), Color3.new(0, 0, 0)
	return if Oklch.contrast(white, background) >= Oklch.contrast(black, background) then white else black
end

--------------------------------------------------------------------------
-- Ramps
--------------------------------------------------------------------------

-- Lightness targets per step (1..12), after the Radix semantics (polish spec
-- 4.2): 1 app background, 2 subtle bg, 3-5 component bg (normal / hover /
-- pressed), 6 subtle border, 7 interactive border, 8 strong border / focus,
-- 9 solid (the seed), 10 solid hover, 11 low-contrast text, 12 high-contrast text.
Oklch.LIGHT_STEPS = { 0.985, 0.965, 0.93, 0.89, 0.85, 0.80, 0.73, 0.64, 0.60, 0.55, 0.45, 0.25 }
Oklch.DARK_STEPS = { 0.16, 0.19, 0.23, 0.26, 0.29, 0.33, 0.39, 0.48, 0.60, 0.65, 0.80, 0.94 }

export type RampOptions = {
	mode: ("light" | "dark")?, -- which end of the ramp is the background (default "dark")
	chroma: number?, -- peak chroma (default: the seed's own, capped at 0.26)
	minChroma: number?, -- a floor for the peak (keeps pastels from going grey)
	hueShift: number?, -- degrees the light steps lean warm / the dark ones cool (default 10)
	keepSeed: boolean?, -- step 9 is exactly the seed colour (default true)
	solidL: number?, -- lightness of step 9 when keepSeed is false
}

-- Chroma envelope: a bell over the 12 steps, peaking at steps 8-10.
local ENVELOPE = { 0.10, 0.16, 0.26, 0.36, 0.46, 0.56, 0.70, 0.88, 1.0, 0.98, 0.80, 0.50 }

--[[
	A 12-step ramp from one seed colour. Step 9 is the seed (the brand
	"solid"), the rest follow the light or dark lightness targets, with
	chroma peaking mid-ramp and the hue leaning warm towards the light end
	and cool towards the dark end (illustrator shading). Returns { Color3 x12 }.
]]
function Oklch.ramp(seed: Color3, options: RampOptions?): { Color3 }
	local o = options or {}
	local mode = o.mode or "dark"
	local sL, sC, sH = Oklch.fromColor(seed)
	local peak = o.chroma or math.min(sC, 0.26)
	if o.minChroma then
		peak = math.max(peak, o.minChroma)
	end
	local shift = o.hueShift or 10
	local steps = if mode == "light" then Oklch.LIGHT_STEPS else Oklch.DARK_STEPS
	local grey = sC < 0.02
	local out = {}
	for i = 1, 12 do
		local L = steps[i]
		local C = peak * ENVELOPE[i]
		-- lighter than the seed leans warm (towards yellow, h ~ 100), darker cool (h ~ 280)
		local lighter = L > sL
		local h = sH
		if not grey then
			local amount = shift * math.min(1, math.abs(L - sL) / 0.35)
			h = Oklch.hueToward(sH, if lighter then 100 else 280, amount)
		end
		out[i] = Oklch.toColor(L, C, h)
	end
	if o.keepSeed ~= false then
		out[9] = seed
		-- step 10: the solid's hover, a touch lighter (dark mode) / darker (light mode)
		out[10] = Oklch.toColor(sL + (if mode == "dark" then 0.05 else -0.05), sC, sH)
	elseif o.solidL then
		out[9] = Oklch.toColor(o.solidL, peak, sH)
		out[10] = Oklch.toColor(o.solidL + (if mode == "dark" then 0.05 else -0.05), peak, sH)
	end
	return out
end

--[[
	A tinted neutral ramp (Radix slate / mauve / sage): the hue of `seed` at
	a tiny chroma, so greys are never dead #808080.
]]
function Oklch.neutralRamp(hue: number, chroma: number, mode: ("light" | "dark")?): { Color3 }
	local steps = if mode == "light" then Oklch.LIGHT_STEPS else Oklch.DARK_STEPS
	local out = {}
	for i = 1, 12 do
		-- neutrals keep a little chroma all the way (more in the middle)
		out[i] = Oklch.toColor(steps[i], chroma * (0.6 + 0.4 * ENVELOPE[i]), hue)
	end
	return out
end

return Oklch
]]></string>
            </Properties>
          </Item>
          <Item class="Folder" referent="274">
            <Properties>
              <string name="Name">Presets</string>
            </Properties>
            <Item class="ModuleScript" referent="275">
              <Properties>
                <string name="Name">Adventure</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Adventure: Treasure Quest / fantasy RPG. Montserrat Black/Bold (Roblox's
	Gotham replacement, which Treasure Quest used) with UPPERCASE buttons, deep
	navy panels with gold trim, gem-coloured tones, gold ribbon headers,
	sparkles on rare things, warm ivory type, the Nosniy rarity ladder.
]]

local Base = require(script.Parent.Parent.Base)

local hex = Color3.fromHex
local tone = Base.tone
local IVORY = hex("FFF7E6")

return {
	Name = "Adventure",

	Font = Base.montserratFonts(),

	Color = {
		Text = IVORY,
		TextDark = hex("1B130A"),
		TextMuted = hex("AFC0DC"),
		Outline = hex("0B1430"),
		Surface = hex("0C1633"),
		SurfaceLight = hex("1B2D58"),
		Light = IVORY,
		Backdrop = hex("03081A"),
		Track = hex("081029"),
		Accent = hex("FFC845"),
		Good = hex("4FE07A"),
		Bad = hex("FF4D3D"),
		Warn = hex("FFA81F"),
		Info = hex("4FC3FF"),
		Panel = hex("13254D"),
		PanelText = IVORY,
		Trim = hex("FFC845"),
		Glass = hex("FFE8B0"),
		Focus = hex("FFD760"),
		Scroll = hex("FFC845"),
	},

	Alpha = {
		Surface = 0.16,
		Backdrop = 0.4,
		Shadow = 0.45,
		Track = 0.05,
	},

	Tone = {
		Green = tone("6FF2A0", "1FB45E", "0E7039", "053A1C", "0A5A2E"),
		Red = tone("FF7A6B", "DB2B3A", "8E1222", "47060F", "700D1A"),
		Yellow = tone("FFE58A", "F5AE1E", "A8620A", "5A3004", "7E4506"),
		Blue = tone("6ECFFF", "1F72E8", "0F3F99", "061F4F", "0B3180"),
		Purple = tone("CF9BFF", "8440E6", "4E1C9C", "250B50", "3D1480"),
		Orange = tone("FFBE6B", "EE7A1C", "9E440A", "522003", "7E3507"),
		Pink = tone("FF9FCC", "E2508E", "9A2358", "4E0C2A", "7A1A45"),
		Cyan = tone("6BF7E6", "13ADA2", "0A6B66", "033633", "08524E"),
		Gray = tone("A9B4CA", "66728C", "3A4459", "1B2130", "2C3446"),
		White = tone("FFFBEF", "F2E3C4", "B99E6C", "7A6440", nil, hex("3A2A12")),
		Dark = tone("2A3F70", "162850", "0A1530", "040A1A", "040A1A"),
	},

	Radius = { S = 6, M = 10, L = 16 },
	Stroke = { Text = 2.5, UI = 3, Thin = 1.5 },
	Depth = 5,

	Motion = Base.motion({
		Speed = 26,
		Damping = 0.55,
		HoverScale = 1.05,
		PressScale = 0.92,
		OpenSpeed = 20,
		OpenDamping = 0.5,
	}),

	Style = Base.style({
		Archetype = "Adventure",
		ButtonStyle = "Glossy3D",
		PanelStyle = "Ribbon",
		ClickFx = "Sparkle",
		Transition = "Drop",
		Decoration = "Sparkles",
		ButtonCase = "Upper",
		RarityScheme = "Arena",
	}),

	Type = Base.typeScale(),
	Space = Base.space(),
	Sound = Base.sounds(),
	Blur = 14,
	Scrim = 0.45,
	ScrollBar = { Thickness = 8, Transparency = 0.2 },
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="276">
              <Properties>
                <string name="Name">Arena</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Arena: the RIVALS look, from measured values (docs/RESEARCH.md 2.2-2.9).
	Rubik (Roblox's free Creator Store font) with Bold labels and Black
	numbers, near-black glass plates without strokes, a white selected state,
	one lime "buy" gradient and one alert red, small 4-9 px corners, soft
	text outlines only where the background is busy, fast low-overshoot
	springs, Punch clicks and Rise pages.
]]

local Base = require(script.Parent.Parent.Base)

local hex = Color3.fromHex
local WHITE = hex("FFFFFF")

local function tone(top: string, mid: string?, bottom: string, edge: string, deep: string, stroke: string?, text: Color3?)
	local t = Base.tone(top, bottom, edge, deep, stroke, text)
	if mid then
		t.Mid = hex(mid)
	end
	return t
end

return {
	Name = "Arena",

	Font = Base.rubikFonts(),

	Color = {
		Text = WHITE,
		TextDark = hex("0D0A0E"),
		TextMuted = hex("A7A4B5"),
		Outline = hex("0D0A0E"),
		Surface = hex("0D0A0E"), -- tab plates, currency pill
		SurfaceLight = hex("25232E"),
		Light = WHITE, -- the selected tab
		Backdrop = hex("0E0C0F"),
		Track = hex("373A42"),
		Accent = hex("FFD21F"),
		Good = hex("95FA26"),
		Bad = hex("CA2828"),
		Warn = hex("FFB300"),
		Info = hex("02BEFE"),
		Panel = hex("1B1C20"), -- HUD panels (task tracker)
		PanelText = WHITE,
		Trim = hex("FFD21F"),
		Glass = WHITE,
		Pill = hex("1E1D22"), -- key hints, health frame
		Keycap = hex("484848"),
		Callout = hex("FF3233"),
		Health = hex("65FE32"),
		Progress = hex("02BEFE"),
		Focus = WHITE, -- a crisp white lock-on, like the white selected tab
		Scroll = WHITE,
		Scrim = hex("07060A"),
	},

	Alpha = {
		Surface = 0.25,
		Backdrop = 0.45,
		Shadow = 0.55,
		Track = 0,
		Rim = 1, -- no panel strokes
	},

	Tone = {
		-- measured: buy button #DCFD7B -> #95FA26 -> #79E730
		Green = tone("DCFD7B", "95FA26", "79E730", "4E9A12", "1F4A06", "5E9E1C"),
		-- measured: close button #FF6063 -> #CA2828 -> #9B1F1B, border #56080B
		Red = tone("FF6063", "CA2828", "9B1F1B", "56080B", "2E0405", "56080B"),
		-- measured: offer art #F8BB1E / #EFE519 with shadows #635400
		Yellow = tone("FFF35C", "F3D21A", "F0B81C", "9C7A00", "635400", "7A5C00"),
		Blue = tone("6ADFFF", nil, "02A8F0", "0366A6", "023A60", "045A8C"),
		Purple = tone("C58BFF", nil, "8A43F0", "511C9E", "290B5A", "43137F"),
		Orange = tone("FFB35C", nil, "E78428", "A92320", "4A0F0C", "7A2410"),
		Pink = tone("FF9BD7", nil, "FF4FAD", "A81E6C", "5A0C38", "8A1558"),
		Cyan = tone("7FF4FF", nil, "1FC6E0", "0C7B8D", "043F48", "0A6573"),
		Gray = tone("5E5C68", nil, "3A3844", "222029", "111015", "16151B"),
		White = tone("FFFFFF", nil, "E9E9EE", "A3A3B0", "5C5C68", nil, hex("0D0A0E")),
		Dark = tone("2A2830", nil, "1E1D22", "0D0A0E", "050406", "050406"),
	},

	Radius = { S = 4, M = 7, L = 9 },

	-- No outline on text over dark glass (TextStroke recipes handle the rest).
	Stroke = { Text = 0, UI = 2, Thin = 1.5 },

	-- measured text outlines (docs/RESEARCH.md 2.2)
	TextStroke = {
		Glass = { Thickness = 0, Transparency = 1 },
		Art = { Color = hex("000000"), Transparency = 0.7, Thickness = 2 },
		Button = { Transparency = 0.35, Thickness = 1.5 },
		Solid = { Color = hex("000000"), Transparency = 0, Thickness = 2 },
	},

	Depth = 3,

	Motion = Base.motion({
		Speed = 34,
		Damping = 0.7,
		HoverScale = 1.04,
		HoverSpeed = 40,
		HoverDamping = 0.7,
		PressScale = 0.94,
		PressSpeed = 50,
		PressDamping = 1,
		ReleaseSpeed = 30,
		ReleaseDamping = 0.45,
		OpenSpeed = 26,
		OpenDamping = 0.8,
	}),

	Style = Base.style({
		Archetype = "Arena",
		ButtonStyle = "Flat",
		PanelStyle = "Glass",
		ClickFx = "Punch",
		Transition = "Rise",
		Decoration = "None",
		RarityScheme = "Arena",
		BarLabel = "Inset",
	}),

	Type = Base.typeScale(),
	Space = Base.space(),
	Sound = Base.sounds(),
	Blur = 16,
	Scrim = 0.5, -- pages sit over a dark, blurred lobby
	ScrollBar = { Thickness = 6, Transparency = 0.35 },
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="277">
              <Properties>
                <string name="Name">Bubbly</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Bubbly: Adopt Me / Grow a Garden. Cream panels with thick coloured
	borders and a title ribbon, chunky white type with dark tinted outlines,
	big round corners, glossy 3D buttons, soft bright palette, bouncy motion.
]]

local Base = require(script.Parent.Parent.Base)

local hex = Color3.fromHex
local tone = Base.tone
local WHITE = hex("FFFFFF")

return {
	Name = "Bubbly",

	Font = Base.builderFonts(),

	Color = {
		Text = WHITE,
		TextDark = hex("2E2452"),
		TextMuted = hex("D9D2F5"),
		Outline = hex("2A1F57"),
		Surface = hex("2B2470"),
		SurfaceLight = hex("463C9E"),
		Light = WHITE,
		Backdrop = hex("1A1245"),
		Track = hex("241C5E"),
		Accent = hex("FFD12E"),
		Good = hex("63E03A"),
		Bad = hex("FF5468"),
		Warn = hex("FFB21F"),
		Info = hex("45B4FF"),
		Panel = hex("FFF8EA"),
		PanelText = hex("3B2E63"),
		Trim = hex("3FA2FF"),
		Glass = WHITE,
		Focus = hex("2E8FFF"), -- reads on cream panels and on the purple world
		Scroll = hex("3FA2FF"),
	},

	Alpha = {
		Surface = 0.18,
		Backdrop = 0.4,
		Shadow = 0.4,
		Track = 0.05,
	},

	Tone = {
		Green = tone("8CF26B", "3CC93E", "1F8A28", "0C4513", "17702A"),
		Red = tone("FF8A8A", "F2404F", "B01E30", "5E0A16", "8F1426"),
		Yellow = tone("FFEB6B", "FFBE1F", "C27A00", "6A4200", "915A00"),
		Blue = tone("7CD6FF", "2E8FFF", "1557C2", "0A2B66", "1146A0"),
		Purple = tone("CFA3FF", "8E5CFF", "5A2BC7", "2C0F6A", "4A1FA3"),
		Orange = tone("FFC27A", "FF8A2B", "C2530E", "662805", "9A410A"),
		Pink = tone("FFA6D8", "FF5FAE", "C22E7C", "650F3F", "9E1E63"),
		Cyan = tone("8AF3FF", "25C7EA", "0F86A8", "054557", "0B6C88"),
		Gray = tone("CDD0DE", "9195AD", "5C5F7A", "2D2F42", "474A63"),
		White = tone("FFFFFF", "F2EEFF", "B7AEDB", "7A70A8", nil, hex("2E2452")),
		Dark = tone("4A40A8", "2B2470", "17124A", "0B0826", "0B0826"),
	},

	Radius = { S = 12, M = 18, L = 26 },
	Stroke = { Text = 3, UI = 4, Thin = 2 },
	Depth = 6,

	Motion = Base.motion({
		Speed = 28,
		Damping = 0.48,
		HoverScale = 1.07,
		HoverDamping = 0.5,
		PressScale = 0.9,
		ReleaseSpeed = 26,
		ReleaseDamping = 0.38,
		OpenDamping = 0.55,
	}),

	Style = Base.style({
		Archetype = "Bubbly",
		ButtonStyle = "Glossy3D",
		PanelStyle = "Ribbon",
		ClickFx = "Squash",
		Transition = "Pop",
		Decoration = "Dots",
	}),

	Type = Base.typeScale(),
	Space = Base.space(),
	Sound = Base.sounds(),
	Blur = 12,
	Scrim = 0.38,
	ScrollBar = { Thickness = 10, Transparency = 0.05 },
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="278">
              <Properties>
                <string name="Name">Candy</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Candy: extra-bubbly simulator style. Chunky Builder Sans, pastel gradients,
	deep purple tinted outlines, sparkles and very springy motion.
]]

local Base = require(script.Parent.Parent.Base)

local hex = Color3.fromHex

local WHITE = hex("FFFFFF")
local INK = hex("2A1245")

return {
	Name = "Candy",

	-- Fredoka One (rounded, chunky) for titles and numbers; Builder Sans for
	-- the rest, so small labels stay crisp.
	Font = (function()
		local fonts = Base.builderFonts()
		fonts.Title = Font.fromName("FredokaOne")
		fonts.Display = Font.fromName("FredokaOne")
		return fonts
	end)(),

	Color = {
		Text = WHITE,
		TextDark = INK,
		TextMuted = hex("E2CCFF"),
		Outline = INK,
		Surface = hex("2B1650"),
		SurfaceLight = hex("4A2A80"),
		Light = WHITE,
		Backdrop = hex("12062A"),
		Track = hex("1E0C3A"),
		Accent = hex("FF5FC8"),
		Good = hex("4DF08A"),
		Bad = hex("FF4F78"),
		Warn = hex("FFC23D"),
		Info = hex("4FB8FF"),
		Panel = hex("3A1E6E"),
		PanelText = WHITE,
		Trim = hex("FF8AD8"),
		Glass = WHITE,
		Focus = hex("FF8AD8"),
		Scroll = hex("FF8AD8"),
	},

	Alpha = {
		Surface = 0.2,
		Backdrop = 0.45,
		Shadow = 0.45,
		Track = 0.1,
	},

	Tone = {
		Green = { Top = hex("7DFF8A"), Bottom = hex("22D46B"), Edge = hex("0E8A42"), Deep = hex("054A22"), Text = WHITE, Stroke = hex("0B6632") },
		Red = { Top = hex("FF7D9A"), Bottom = hex("F0325E"), Edge = hex("A0143A"), Deep = hex("54081E"), Text = WHITE, Stroke = hex("7E0E2C") },
		Yellow = { Top = hex("FFEE6B"), Bottom = hex("FFB820"), Edge = hex("C07400"), Deep = hex("6A4000"), Text = WHITE, Stroke = hex("8F5200") },
		Blue = { Top = hex("7ADBFF"), Bottom = hex("2F8BFF"), Edge = hex("1650B8"), Deep = hex("0A2A66"), Text = WHITE, Stroke = hex("123E90") },
		Purple = { Top = hex("DA9BFF"), Bottom = hex("9B4DFF"), Edge = hex("5E1FC0"), Deep = hex("300C6A"), Text = WHITE, Stroke = hex("4A1698") },
		Orange = { Top = hex("FFC07A"), Bottom = hex("FF7A1F"), Edge = hex("B8480A"), Deep = hex("642404"), Text = WHITE, Stroke = hex("8F3806") },
		Pink = { Top = hex("FFB0E6"), Bottom = hex("FF4FB8"), Edge = hex("B81E7A"), Deep = hex("640C40"), Text = WHITE, Stroke = hex("8F165E") },
		Cyan = { Top = hex("8AFFF4"), Bottom = hex("1FD6E0"), Edge = hex("0C8C96"), Deep = hex("044A50"), Text = WHITE, Stroke = hex("0A6A72") },
		Gray = { Top = hex("C2B8D6"), Bottom = hex("8C80A6"), Edge = hex("554A70"), Deep = hex("2A2240"), Text = WHITE, Stroke = hex("3E3456") },
		White = { Top = WHITE, Bottom = hex("F0E4FF"), Edge = hex("B8A2DA"), Deep = hex("7A64A0"), Text = INK, Stroke = nil },
		Dark = { Top = hex("4A2A80"), Bottom = hex("2B1650"), Edge = hex("16082E"), Deep = hex("0A0318"), Text = WHITE, Stroke = INK },
	},

	Radius = { S = 10, M = 16, L = 24 },
	Stroke = { Text = 3, UI = 3.5, Thin = 2 },
	Depth = 6,

	Motion = Base.motion({
		Speed = 30,
		Damping = 0.45,
		HoverScale = 1.08,
		HoverDamping = 0.5,
		PressScale = 0.9,
		ReleaseSpeed = 26,
		ReleaseDamping = 0.36,
		OpenDamping = 0.5,
	}),

	Style = Base.style({
		Archetype = "Candy",
		ButtonStyle = "Glossy3D",
		PanelStyle = "Gradient",
		ClickFx = "Sparkle",
		Transition = "Pop",
		Decoration = "Sparkles",
	}),

	Type = Base.typeScale(),
	Space = Base.space(),
	Sound = Base.sounds(),

	Blur = 14,
	Scrim = 0.4,
	ScrollBar = { Thickness = 10, Transparency = 0.1 },
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="279">
              <Properties>
                <string name="Name">Horror</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Horror: typewriter text, bone-white on near-black, blood red accents and
	slow, heavy motion. Pair it with the Vignette, Heartbeat and flicker FX.
]]

local Base = require(script.Parent.Parent.Base)

local hex = Color3.fromHex

local BONE = hex("E8E1D3")

return {
	Name = "Horror",

	Font = {
		Title = Font.fromName("Creepster"),
		Display = Font.fromName("SpecialElite"),
		Body = Font.fromName("SpecialElite"),
		Heavy = Font.fromName("SpecialElite"),
		Mono = Font.fromName("SpecialElite"),
		Ornate = Font.fromName("Creepster"), -- window titles
	},

	Color = {
		Text = BONE,
		TextDark = hex("140C0C"),
		TextMuted = hex("8E857A"),
		Outline = hex("000000"),
		Surface = hex("070505"),
		SurfaceLight = hex("1E1616"),
		Light = BONE,
		Backdrop = hex("000000"),
		Track = hex("140E0E"),
		Accent = hex("B3121C"),
		Good = hex("6F9A3A"),
		Bad = hex("C0141E"),
		Warn = hex("D9A441"),
		Info = hex("5B7C99"),
		Panel = hex("0E0909"),
		PanelText = BONE,
		Trim = hex("5A1216"),
		Glass = BONE,
		Focus = BONE, -- blood red on near-black would vanish
		Scroll = BONE,
		Scrim = hex("000000"),
	},

	Alpha = {
		Surface = 0.25,
		Backdrop = 0.35,
		Shadow = 0.35,
		Track = 0.1,
	},

	Tone = {
		Green = { Top = hex("7C9C44"), Bottom = hex("4E6A26"), Edge = hex("2C3D14"), Deep = hex("141C08"), Text = BONE, Stroke = hex("1A2408") },
		Red = { Top = hex("C4202A"), Bottom = hex("7E0B12"), Edge = hex("420508"), Deep = hex("1E0204"), Text = BONE, Stroke = hex("2A0306") },
		Yellow = { Top = hex("E0B055"), Bottom = hex("9C6E22"), Edge = hex("573C10"), Deep = hex("261A06"), Text = hex("1A1206"), Stroke = nil },
		Blue = { Top = hex("5E7E9C"), Bottom = hex("33485C"), Edge = hex("1A2632"), Deep = hex("0A1016"), Text = BONE, Stroke = hex("0E151C") },
		Purple = { Top = hex("7A5090"), Bottom = hex("4A2C5A"), Edge = hex("261630"), Deep = hex("100812"), Text = BONE, Stroke = hex("160C1C") },
		Orange = { Top = hex("C8702E"), Bottom = hex("823F14"), Edge = hex("47200A"), Deep = hex("1E0C04"), Text = BONE, Stroke = hex("2A1206") },
		Pink = { Top = hex("A8506E"), Bottom = hex("6A2840"), Edge = hex("3A1222"), Deep = hex("18060E"), Text = BONE, Stroke = hex("220812") },
		Cyan = { Top = hex("4E8C8C"), Bottom = hex("2A5454"), Edge = hex("142C2C"), Deep = hex("081414"), Text = BONE, Stroke = hex("0A1A1A") },
		Gray = { Top = hex("5E5852"), Bottom = hex("38332E"), Edge = hex("1C1916"), Deep = hex("0C0A08"), Text = BONE, Stroke = hex("100E0C") },
		White = { Top = BONE, Bottom = hex("B8AF9E"), Edge = hex("6E6658"), Deep = hex("3A342C"), Text = hex("140C0C"), Stroke = nil },
		Dark = { Top = hex("241C1C"), Bottom = hex("120D0D"), Edge = hex("050303"), Deep = hex("000000"), Text = BONE, Stroke = hex("000000") },
	},

	-- muted rarity colours: nothing in a horror game should look like candy
	-- (RarityMute keeps them muted when a blueprint style or a colour-blind
	-- palette rebuilds the set)
	RarityMute = 0.5,
	Rarity = Base.rarities("Classic", 0.5),
	RarityBuiltFor = "Classic",

	Radius = { S = 3, M = 5, L = 8 },
	Stroke = { Text = 2, UI = 2, Thin = 1 },
	Depth = 3,

	Motion = Base.motion({
		Speed = 16,
		Damping = 0.9,
		HoverScale = 1.03,
		HoverSpeed = 20,
		HoverDamping = 0.9,
		PressScale = 0.96,
		ReleaseSpeed = 18,
		ReleaseDamping = 0.8,
		OpenSpeed = 14,
		OpenDamping = 1,
	}),

	Style = Base.style({
		Archetype = "Horror",
		ButtonStyle = "Outline",
		PanelStyle = "Solid",
		ClickFx = "Shake",
		Transition = "Fade",
		Decoration = "Grain",
		Shadow = "None",
	}),

	Type = Base.typeScale(),
	Space = Base.space(),
	Sound = Base.sounds(),

	Blur = 22,
	Scrim = 0.6,
	ScrollBar = { Thickness = 4, Transparency = 0.5 },
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="280">
              <Properties>
                <string name="Name">Sleek</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Sleek: modern app polish (Pet Simulator 99 menus). Builder Sans with
	lighter body weights, flat tonal fills, no text outlines on buttons, thin
	strokes, pill buttons, soft shadows, calm motion, generous spacing.
]]

local Base = require(script.Parent.Parent.Base)

local hex = Color3.fromHex
local tone = Base.tone
local WHITE = hex("FFFFFF")

return {
	Name = "Sleek",

	Font = Base.builderFonts({
		Display = Enum.FontWeight.Bold,
		Body = Enum.FontWeight.SemiBold,
		Heavy = Enum.FontWeight.Bold,
	}),

	Color = {
		Text = WHITE,
		TextDark = hex("151A26"),
		TextMuted = hex("98A2B8"),
		Outline = hex("0E121C"),
		Surface = hex("121724"),
		SurfaceLight = hex("1E2433"),
		Light = WHITE,
		Backdrop = hex("070A12"),
		Track = hex("0B0F18"),
		Accent = hex("4D8DFF"),
		Good = hex("2FD67B"),
		Bad = hex("FF4D5E"),
		Warn = hex("FFB020"),
		Info = hex("4D9BFF"),
		Panel = hex("1A2030"),
		PanelText = WHITE,
		Trim = hex("2C3448"),
		Glass = WHITE,
		Focus = hex("6FA3FF"),
		Scroll = WHITE,
	},

	Alpha = {
		Surface = 0.12,
		Backdrop = 0.5,
		Shadow = 0.55,
		Track = 0,
	},

	-- Flat, calm tones with no text outline (Stroke = nil).
	Tone = {
		Green = tone("4EE391", "22C26E", "158A4C", "0A4526"),
		Red = tone("FF6B78", "F0424F", "A82530", "540F16"),
		Yellow = tone("FFD560", "FFB81F", "B87C00", "5E3F00", nil, hex("3D2A00")),
		Blue = tone("5E9CFF", "3B7BFF", "2556C7", "112A66"),
		Purple = tone("A98BFF", "7B5CFF", "5236C2", "261766"),
		Orange = tone("FFA366", "FF7A2E", "B84E12", "5E2406"),
		Pink = tone("FF8CC8", "F2549E", "A82E68", "551432"),
		Cyan = tone("5EE6F2", "1CC3D6", "118594", "06434A", nil, hex("04363C")),
		Gray = tone("3A4256", "2A3142", "1B2030", "0E121C"),
		White = tone("FFFFFF", "EEF1F7", "BCC4D4", "7E889C", nil, hex("151A26")),
		Dark = tone("262D3F", "1A2030", "0E121C", "06080E"),
		-- disabled reads as "off", not as the dark secondary Gray
		Disabled = tone("566075", "475063", "313849", "1A1E28", nil, hex("C9CFDA")),
	},

	Radius = { S = 8, M = 14, L = 20 },
	Stroke = { Text = 1.5, UI = 1.5, Thin = 1 },
	TextStroke = {
		Glass = { Thickness = 0, Transparency = 1 },
		Art = { Color = hex("000000"), Transparency = 0.7, Thickness = 2 },
		Button = { Thickness = 0, Transparency = 1 },
		Solid = { Transparency = 0, Thickness = 1.5 },
	},
	Depth = 0,

	Motion = Base.motion({
		Speed = 32,
		Damping = 0.75,
		HoverScale = 1.03,
		HoverSpeed = 40,
		HoverDamping = 0.8,
		PressScale = 0.96,
		ReleaseSpeed = 34,
		ReleaseDamping = 0.7,
		OpenSpeed = 26,
		OpenDamping = 0.85,
	}),

	Style = Base.style({
		Archetype = "Sleek",
		ButtonStyle = "Pill",
		PanelStyle = "Solid",
		ClickFx = "Ripple",
		Transition = "Fade",
		Decoration = "None",
	}),

	Type = Base.typeScale(),
	Space = Base.space(),
	Sound = Base.sounds(),
	Blur = 18,
	Scrim = 0.42,
	ScrollBar = { Thickness = 6, Transparency = 0.45 },
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="281">
              <Properties>
                <string name="Name">Tactical</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Tactical: a sharper shooter look. Uppercase Builder Sans, slate glass,
	amber accents, hazard stripes and quick, snappy motion.
]]

local Base = require(script.Parent.Parent.Base)

local hex = Color3.fromHex

local WHITE = hex("F2F5F8")

return {
	Name = "Tactical",

	-- Builder Extended (wide, technical) for headings; Builder Sans for
	-- numbers, labels and body text.
	Font = (function()
		local fonts = Base.builderFonts({ Display = Enum.FontWeight.Bold })
		fonts.Title = Font.fromName("BuilderExtended", Enum.FontWeight.Bold)
		return fonts
	end)(),

	Color = {
		Text = WHITE,
		TextDark = hex("0B0E12"),
		TextMuted = hex("8C96A3"),
		Outline = hex("05070A"),
		Surface = hex("0C1016"),
		SurfaceLight = hex("1E2630"),
		Light = WHITE,
		Backdrop = hex("000000"),
		Track = hex("0A0D12"),
		Accent = hex("FFB020"),
		Good = hex("3DDC84"),
		Bad = hex("FF4757"),
		Warn = hex("FFB020"),
		Info = hex("2EA8FF"),
		Panel = hex("121820"),
		PanelText = WHITE,
		Trim = hex("FFB020"),
		Glass = WHITE,
		Focus = hex("FFB020"),
		Scroll = hex("FFB020"),
	},

	Alpha = {
		Surface = 0.3,
		Backdrop = 0.45,
		Shadow = 0.45,
		Track = 0.2,
	},

	Tone = {
		Green = { Top = hex("4BE894"), Bottom = hex("20B865"), Edge = hex("0F6E3A"), Deep = hex("06341B"), Text = WHITE, Stroke = hex("0A4A26") },
		Red = { Top = hex("FF5E6C"), Bottom = hex("E0283A"), Edge = hex("85121E"), Deep = hex("40080E"), Text = WHITE, Stroke = hex("5E0C14") },
		Yellow = { Top = hex("FFC24D"), Bottom = hex("F29500"), Edge = hex("8F5800"), Deep = hex("402800"), Text = hex("0B0E12"), Stroke = nil },
		Blue = { Top = hex("4DB8FF"), Bottom = hex("1478D6"), Edge = hex("0A4580"), Deep = hex("04203C"), Text = WHITE, Stroke = hex("06305A") },
		Purple = { Top = hex("A98BFF"), Bottom = hex("6A45E0"), Edge = hex("3A2285"), Deep = hex("1A0E40"), Text = WHITE, Stroke = hex("28165E") },
		Orange = { Top = hex("FF9A4D"), Bottom = hex("E86410"), Edge = hex("8A3606"), Deep = hex("401802"), Text = WHITE, Stroke = hex("5E2404") },
		Pink = { Top = hex("FF7AB8"), Bottom = hex("E0358A"), Edge = hex("851450"), Deep = hex("400826"), Text = WHITE, Stroke = hex("5E0E38") },
		Cyan = { Top = hex("5CF0F0"), Bottom = hex("12B5C8"), Edge = hex("086A78"), Deep = hex("033238"), Text = hex("0B0E12"), Stroke = nil },
		Gray = { Top = hex("6E7885"), Bottom = hex("46505C"), Edge = hex("252B33"), Deep = hex("11151A"), Text = WHITE, Stroke = hex("181D23") },
		White = { Top = WHITE, Bottom = hex("C9D1DA"), Edge = hex("7D8896"), Deep = hex("46505C"), Text = hex("0B0E12"), Stroke = nil },
		Dark = { Top = hex("252D38"), Bottom = hex("141920"), Edge = hex("06080B"), Deep = hex("000000"), Text = WHITE, Stroke = hex("000000") },
	},

	-- rarity colours a shade more military (less candy) than the Classic set
	RarityMute = 0.25,

	Radius = { S = 3, M = 4, L = 6 },
	Stroke = { Text = 1.5, UI = 2, Thin = 1 },
	Depth = 3,

	Motion = Base.motion({
		Speed = 36,
		Damping = 0.85,
		HoverScale = 1.03,
		HoverSpeed = 44,
		HoverDamping = 0.8,
		PressScale = 0.95,
		ReleaseSpeed = 36,
		ReleaseDamping = 0.7,
		OpenSpeed = 30,
		OpenDamping = 0.85,
	}),

	Style = Base.style({
		Archetype = "Tactical",
		ButtonStyle = "Flat",
		PanelStyle = "Solid",
		ClickFx = "Squash",
		Transition = "Slide",
		TextCase = "Upper",
		Decoration = "Stripes",
		Shadow = "Hard",
		Density = "Compact",
	}),

	Type = Base.typeScale(),
	Space = Base.space(),
	Sound = Base.sounds(),

	Blur = 10,
	Scrim = 0.5,
	ScrollBar = { Thickness = 4, Transparency = 0.15 },
}
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="282">
              <Properties>
                <string name="Name">Toon</string>
                <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Toon: the default BloxUI look. Top-tier shop/shooter polish (Rivals'
	palette and proportions) with a friendlier cartoon finish: Builder Sans
	everywhere (Roblox's own SF-Pro-like UI font), dark glass plates, crisp
	white type with tinted outlines, glossy 3D buttons with a small lip,
	soft 8 px corners and bouncy springs.
]]

local Base = require(script.Parent.Parent.Base)

local hex = Color3.fromHex
local WHITE = hex("FFFFFF")

local function tone(top: string, mid: string?, bottom: string, edge: string, deep: string, stroke: string?, text: Color3?)
	local t = Base.tone(top, bottom, edge, deep, stroke, text)
	if mid then
		t.Mid = hex(mid)
	end
	return t
end

return {
	Name = "Toon",

	Font = Base.builderFonts(),

	Color = {
		Text = WHITE,
		TextDark = hex("141119"),
		TextMuted = hex("ABA8BF"),
		Outline = hex("14101C"), -- deep indigo, never pure black
		Surface = hex("0D0A12"),
		SurfaceLight = hex("272338"),
		Light = WHITE,
		Backdrop = hex("06050B"),
		Track = hex("1A1722"),
		Accent = hex("FFD21F"),
		Good = hex("95FA26"),
		Bad = hex("E5261F"),
		Warn = hex("FFB300"),
		Info = hex("3AA8FF"),
		Panel = hex("1B1827"), -- fill for Outlined / Ribbon / Fade panels
		PanelText = WHITE,
		Trim = hex("FFD21F"), -- border for Outlined / Ribbon panels
		Glass = WHITE, -- rim light on glass
		Pill = hex("1E1D22"),
		Keycap = hex("484848"),
		Callout = hex("FF3233"),
		Focus = hex("FFD21F"), -- gamepad selection ring
		Scroll = WHITE,
	},

	-- 0 = solid, 1 = invisible
	Alpha = {
		Surface = 0.28,
		Backdrop = 0.45,
		Shadow = 0.5,
		Track = 0.1,
		Rim = 0.9,
	},

	-- Each tone paints buttons, cards, bars and badges.
	--   Top/(Mid)/Bottom: face gradient   Edge: 3D lip and tinted outline
	--   Deep: darkest shade (card corners)   Text/Stroke: label colour + outline
	Tone = {
		Green = tone("C8FC6A", "95FA26", "5FD41E", "2E9612", "144D07", "27800E"),
		Red = tone("FF6063", "E0302C", "B3221E", "7A0E0B", "400504", "6E0C09"),
		Yellow = tone("FFEE5C", nil, "FFC20A", "B78300", "6A4700", "8C5700"),
		Blue = tone("5CC6FF", nil, "1F84F5", "0E4FA3", "06285C", "0B3F82"),
		Purple = tone("C58BFF", nil, "8A43F0", "511C9E", "290B5A", "43137F"),
		Orange = tone("FFB35C", nil, "FF7618", "A9440A", "5A2204", "8A3406"),
		Pink = tone("FF9BD7", nil, "FF4FAD", "A81E6C", "5A0C38", "8A1558"),
		Cyan = tone("7FF4FF", nil, "1FC6E0", "0C7B8D", "043F48", "0A6573"),
		Gray = tone("9EA3B5", nil, "6B7084", "3E4150", "1F2029", "30323D"),
		White = tone("FFFFFF", nil, "E4E7F0", "A3AABB", "6C7385", nil, hex("141119")),
		Dark = tone("3A3650", nil, "221F30", "110F1A", "07060C", "07060C"),
	},

	Radius = { S = 6, M = 8, L = 12 },

	-- Outline widths in design pixels.
	Stroke = { Text = 2.25, UI = 2.5, Thin = 1.5 },

	TextStroke = {
		Glass = { Thickness = 2, Transparency = 0.1 },
		Art = { Thickness = 2.5, Transparency = 0 },
		Button = { Thickness = 2.25, Transparency = 0 },
		Solid = { Thickness = 2, Transparency = 0 },
	},

	-- Height of the 3D lip under buttons.
	Depth = 4,

	Motion = Base.motion({
		Speed = 30,
		Damping = 0.6,
		HoverScale = 1.05,
		PressScale = 0.92,
	}),

	Style = Base.style({
		Archetype = "Arena",
		ButtonStyle = "Glossy3D",
		PanelStyle = "Glass",
		ClickFx = "Squash",
		Transition = "Pop",
		Decoration = "None",
	}),

	Type = Base.typeScale(),
	Space = Base.space(),

	-- Sound ids for UI events. "" means silent. Paste ids from the Creator Store.
	Sound = Base.sounds(),

	-- Background blur while a window is open (0 turns it off).
	Blur = 14,

	-- How much a modal window dims the world / HUD behind it (0..1).
	Scrim = 0.45,

	-- Scroll bar thumb (colour: Color.Scroll).
	ScrollBar = { Thickness = 8, Transparency = 0.15 },
}
]]></string>
              </Properties>
            </Item>
          </Item>
        </Item>
        <Item class="Folder" referent="283">
          <Properties>
            <string name="Name">Util</string>
          </Properties>
          <Item class="ModuleScript" referent="284">
            <Properties>
              <string name="Name">Bind</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Bind: turn game data into Fusion Values that stay in sync, so your UI
	updates by itself.

		local coins = UI.leaderstat(scope, "Coins")            -- leaderstats.Coins
		local keys = UI.fromAttribute(scope, player, "Keys", 0) -- player:GetAttribute("Keys")
		local health = UI.fromProperty(scope, humanoid, "Health")
		local wave = UI.fromValueObject(scope, workspace.Wave)  -- IntValue/StringValue/...

	Everything disconnects when the scope is cleaned up.
]]

local Players = game:GetService("Players")

local Bind = {}

-- A Value that mirrors `instance[property]`.
function Bind.fromProperty(scope: any, instance: Instance, property: string): any
	local value = scope:Value((instance :: any)[property])
	table.insert(scope, instance:GetPropertyChangedSignal(property):Connect(function()
		value:set((instance :: any)[property])
	end))
	return value
end

-- A Value that mirrors an attribute (with a default while it's missing).
function Bind.fromAttribute(scope: any, instance: Instance, attribute: string, default: any?): any
	local current = instance:GetAttribute(attribute)
	local value = scope:Value(if current == nil then default else current)
	table.insert(scope, instance:GetAttributeChangedSignal(attribute):Connect(function()
		local now = instance:GetAttribute(attribute)
		value:set(if now == nil then default else now)
	end))
	return value
end

-- A Value that mirrors an IntValue / NumberValue / StringValue / BoolValue.
function Bind.fromValueObject(scope: any, object: ValueBase): any
	local value = scope:Value((object :: any).Value)
	table.insert(scope, (object :: any).Changed:Connect(function(now)
		value:set(now)
	end))
	return value
end

-- A Value that mirrors player.leaderstats[statName]. Starts at `default`
-- (0) and hooks up as soon as the stat exists, so it's safe to call early.
function Bind.leaderstat(scope: any, statName: string, player: Player?, default: any?): any
	local target = player or Players.LocalPlayer
	local value = scope:Value(if default == nil then 0 else default)
	if target == nil then
		return value
	end
	-- Waits with ChildAdded connections rather than a WaitForChild that
	-- would keep waiting after the scope is gone.
	local alive = true
	local connections: { RBXScriptConnection } = {}
	table.insert(scope, function()
		alive = false
		for _, connection in connections do
			connection:Disconnect()
		end
		table.clear(connections)
	end)
	local function whenChild(parent: Instance, name: string, callback: (Instance) -> ())
		local child = parent:FindFirstChild(name)
		if child then
			callback(child)
			return
		end
		local connection: RBXScriptConnection
		connection = parent.ChildAdded:Connect(function(added)
			if added.Name == name and alive then
				connection:Disconnect()
				callback(added)
			end
		end)
		table.insert(connections, connection)
	end
	whenChild(target, "leaderstats", function(stats)
		whenChild(stats, statName, function(stat)
			value:set((stat :: any).Value)
			table.insert(connections, stat:GetPropertyChangedSignal("Value"):Connect(function()
				value:set((stat :: any).Value)
			end))
		end)
	end)
	return value
end

-- Health and MaxHealth of the local player's character, following respawns.
function Bind.characterHealth(scope: any): (any, any)
	local health = scope:Value(100)
	local maxHealth = scope:Value(100)
	local player = Players.LocalPlayer
	local humanoidConnections: { RBXScriptConnection } = {}

	local function clear()
		for _, connection in humanoidConnections do
			connection:Disconnect()
		end
		table.clear(humanoidConnections)
	end

	local function watch(character: Model)
		clear()
		local humanoid = character:WaitForChild("Humanoid", 10) :: Humanoid?
		if humanoid == nil then
			return
		end
		health:set(humanoid.Health)
		maxHealth:set(humanoid.MaxHealth)
		table.insert(humanoidConnections, humanoid.HealthChanged:Connect(function(now)
			health:set(now)
		end))
		table.insert(humanoidConnections, humanoid:GetPropertyChangedSignal("MaxHealth"):Connect(function()
			maxHealth:set(humanoid.MaxHealth)
		end))
	end

	if player.Character then
		task.spawn(watch, player.Character)
	end
	table.insert(scope, player.CharacterAdded:Connect(watch))
	table.insert(scope, clear)
	return health, maxHealth
end

-- Turns a plain value into a Value (or returns the state you passed in).
function Bind.asValue(scope: any, given: any, default: any): any
	if typeof(given) == "table" and given.type == "State" then
		return given
	end
	return scope:Value(if given == nil then default else given)
end

return Bind
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="285">
            <Properties>
              <string name="Name">Color</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Color helpers.
]]

local Color = {}

local WHITE = Color3.new(1, 1, 1)
local BLACK = Color3.new(0, 0, 0)

function Color.lighten(color: Color3, amount: number): Color3
	return color:Lerp(WHITE, amount)
end

function Color.darken(color: Color3, amount: number): Color3
	return color:Lerp(BLACK, amount)
end

-- ColorSequence through any number of evenly spaced colours.
function Color.sequence(...: Color3): ColorSequence
	local colors = { ... }
	if #colors == 1 then
		return ColorSequence.new(colors[1])
	end
	local keypoints = {}
	for index, color in colors do
		table.insert(keypoints, ColorSequenceKeypoint.new((index - 1) / (#colors - 1), color))
	end
	return ColorSequence.new(keypoints)
end

-- Perceived brightness 0..1.
function Color.luminance(color: Color3): number
	return color.R * 0.299 + color.G * 0.587 + color.B * 0.114
end

-- White or near-black, whichever reads better on `background`.
function Color.contrast(background: Color3): Color3
	return if Color.luminance(background) > 0.6 then Color3.fromRGB(30, 30, 38) else WHITE
end

return Color
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="286">
            <Properties>
              <string name="Name">Format</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Format: turns numbers into short, readable strings.

		Format.abbrev(15320)    --> "15.3K"
		Format.comma(1320)      --> "1,320"
		Format.time(95)         --> "1:35"
		Format.percent(0.42)    --> "42%"
]]

local Format = {}

local SUFFIXES = { "", "K", "M", "B", "T", "Qa", "Qi", "Sx", "Sp", "Oc", "No", "Dc" }

local function trimZeros(text: string): string
	if string.find(text, "%.") then
		text = string.gsub(text, "0+$", "")
		text = string.gsub(text, "%.$", "")
	end
	return text
end

-- 1234 -> "1.23K", 15320 -> "15.3K", 2500000 -> "2.5M". Rounds down, so a
-- player never sees more than they have.
function Format.abbrev(n: number): string
	if n ~= n then
		return "0"
	end
	local sign = if n < 0 then "-" else ""
	n = math.abs(n)
	if n < 1000 then
		return sign .. tostring(math.floor(n))
	end
	local tier = math.min(math.floor(math.log10(n) / 3), #SUFFIXES - 1)
	local value = n / 10 ^ (tier * 3)
	local decimals = if value >= 100 then 0 elseif value >= 10 then 1 else 2
	local factor = 10 ^ decimals
	local text = string.format(`%.{decimals}f`, math.floor(value * factor + 1e-6) / factor)
	return sign .. trimZeros(text) .. SUFFIXES[tier + 1]
end

-- 1320 -> "1,320"
function Format.comma(n: number): string
	local sign = if n < 0 then "-" else ""
	local whole = tostring(math.floor(math.abs(n)))
	local formatted = string.reverse((string.gsub(string.reverse(whole), "(%d%d%d)", "%1,")))
	formatted = string.gsub(formatted, "^,", "")
	return sign .. formatted
end

-- 95 -> "1:35", 3725 -> "1:02:05"
function Format.time(seconds: number): string
	seconds = math.max(0, math.floor(seconds))
	local hours = seconds // 3600
	local minutes = (seconds % 3600) // 60
	local secs = seconds % 60
	if hours > 0 then
		return string.format("%d:%02d:%02d", hours, minutes, secs)
	end
	return string.format("%d:%02d", minutes, secs)
end

-- 0.42 -> "42%"
function Format.percent(ratio: number, decimals: number?): string
	return trimZeros(string.format(`%.{decimals or 0}f`, ratio * 100)) .. "%"
end

-- Formats with a named style or your own function.
-- style: "abbrev" (default) | "comma" | "plain" | (number) -> string
function Format.number(n: number, style: any?): string
	if typeof(style) == "function" then
		return style(n)
	elseif style == "comma" then
		return Format.comma(n)
	elseif style == "plain" then
		return tostring(math.floor(n))
	end
	return Format.abbrev(n)
end

return Format
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="287">
            <Properties>
              <string name="Name">Gamepad</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Gamepad: console-quality controller navigation for BloxUI screens.

		local Gamepad = require(BloxUI.Util.Gamepad)
		local nav = Gamepad.enable(scope, Players.LocalPlayer.PlayerGui)

	Everything below only happens while the player is actually holding a
	controller (UI.Input.device == "Gamepad"). Mouse, keyboard and touch
	players never see a ring or have focus moved under them.

		- every button under `rootGui` gets a themed selection ring (BloxUI
		  components draw their own, Kit.focusRing, and set
		  SelectionImageObject = Kit.NoSelection; everything else gets the
		  shared ring, which matches the selected button's corners)
		- when a window opens, its first button is selected (content before
		  tabs and close buttons, reading order)
		- if the selected button disappears (tab switched, page closed), focus
		  moves to the first button of the top-most window
		- LB / RB switch tabs in the top-most window
		- B closes the top-most window

	Windows are found by name ("Window", "Dialog") or by the attribute
	BloxUIWindow = true. Tell Gamepad how to close / switch tabs on a window
	by registering it (Window and the Trade prefab do this for you):

		Gamepad.register(scope, windowFrame, {
			Close = function() open:set(false) end,   -- B
			Open = open,                               -- optional: Value<boolean> (faster than watching Visible)
			Tabs = selectedTab,                        -- Value<id> that LB / RB cycle...
			TabIds = { "shop", "daily", "codes" },     -- ...through these ids (or state / tab tables with .Id)
			Default = buyButton,                       -- optional: selected first
		})

	Gamepad.hold() -> release   pause LB / RB / B (KeybindRow does this while listening)

	nav.focus(gui?)   select the first button in `gui` (default: the top window)
	nav.top()         the top-most open window, or nil
	nav.Ring          the selection ring template (restyle it if you like)
	nav.isThemed(b)   true if button `b` shows a themed ring when selected
	nav.disable()     same as cleaning up the scope
]]

local GuiService = game:GetService("GuiService")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local UserInputService = game:GetService("UserInputService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)
local Kit = require(script.Parent.Parent.Kit)
local Input = require(script.Parent.Input)
local Sound = require(script.Parent.Sound)
local Motion = require(script.Parent.Parent.FX.Motion)

local Children = Fusion.Children
local peek = Fusion.peek

local Gamepad = {}

export type Entry = {
	Gui: GuiObject,
	Close: (() -> ())?,
	Open: any?,
	Tabs: any?,
	TabIds: any?,
	Default: GuiObject?,
	OnTab: ((id: any) -> ())?,
}

-- window -> what B / LB / RB do there
local registry: { [Instance]: Entry } = setmetatable({}, { __mode = "k" }) :: any

local WINDOW_NAMES = { Window = true, Dialog = true }
local CHROME_NAMES = { TabBar = true, TitleBar = true, Close = true, Backdrop = true }
local POLL = 0.1

local function isWindow(inst: Instance): boolean
	if not inst:IsA("GuiObject") then
		return false
	end
	if registry[inst] or inst:GetAttribute("BloxUIWindow") == true then
		return true
	end
	return WINDOW_NAMES[inst.Name] == true and inst:IsA("Frame")
end

-- True when `gui` and every ancestor are visible and it sits in an enabled
-- ScreenGui (or other LayerCollector).
local function isShown(gui: Instance?): boolean
	local current = gui
	while current do
		if current:IsA("GuiObject") then
			if not (current :: GuiObject).Visible then
				return false
			end
		elseif current:IsA("LayerCollector") then
			return (current :: any).Enabled == true
		end
		current = current.Parent
	end
	return false
end
Gamepad.isShown = isShown

local function windowOpen(win: GuiObject): boolean
	local entry = registry[win]
	if entry and entry.Open ~= nil and peek(entry.Open) == false then
		return false
	end
	return isShown(win)
end

local function inChrome(gui: Instance, stop: Instance): boolean
	local current: Instance? = gui
	while current and current ~= stop do
		if CHROME_NAMES[current.Name] then
			return true
		end
		current = current.Parent
	end
	return false
end

local function candidate(gui: Instance): boolean
	if not (gui:IsA("GuiButton") or gui:IsA("TextBox")) then
		return false
	end
	local obj = gui :: GuiObject
	if not obj.Selectable or not obj.Active then
		return false
	end
	local size = obj.AbsoluteSize
	return size.X > 1 and size.Y > 1 and isShown(obj)
end

--[[
	The button a controller player should land on first inside `container`:
	the registered Default if shown, otherwise content buttons before chrome
	(tabs, title bar, close), then top-to-bottom, left-to-right.
]]
function Gamepad.firstButton(container: Instance): GuiObject?
	local entry = registry[container]
	if entry and entry.Default and candidate(entry.Default) then
		return entry.Default
	end
	local best: GuiObject? = nil
	local bestKey: { number }? = nil
	for _, d in container:GetDescendants() do
		if candidate(d) then
			local obj = d :: GuiObject
			local pos = obj.AbsolutePosition
			local key = { if inChrome(obj, container) then 1 else 0, math.floor(pos.Y / 24), pos.X }
			local better = bestKey == nil
			if bestKey then
				for i = 1, 3 do
					if key[i] ~= bestKey[i] then
						better = key[i] < bestKey[i]
						break
					end
				end
			end
			if better then
				best, bestKey = obj, key
			end
		end
	end
	return best
end

--[[
	Tells Gamepad what B, LB and RB do in a window. Cleaned up with `scope`.
	See the module comment for the options.
]]
function Gamepad.register(scope: { any }, gui: GuiObject, options: { [string]: any }): Entry
	local entry: Entry = {
		Gui = gui,
		Close = options.Close,
		Open = options.Open,
		Tabs = options.Tabs,
		TabIds = options.TabIds,
		Default = options.Default,
		OnTab = options.OnTab,
	}
	registry[gui] = entry
	gui:SetAttribute("BloxUIWindow", true)
	table.insert(scope, function()
		if registry[gui] == entry then
			registry[gui] = nil
		end
	end)
	return entry
end

-- The registered entry for a window (or nil).
function Gamepad.entry(gui: Instance): Entry?
	return registry[gui]
end

local holds = 0

--[[
	Pauses the LB / RB / B shortcuts (e.g. while a KeybindRow is waiting for
	a button, so binding B doesn't also close the window). Returns a release
	function; call it once.
]]
function Gamepad.hold(): () -> ()
	holds += 1
	local released = false
	return function()
		if not released then
			released = true
			holds -= 1
		end
	end
end

-- True while something holds the shortcuts.
function Gamepad.isHeld(): boolean
	return holds > 0
end

local function tabIds(entry: Entry): { any }
	local list = peek(entry.TabIds) or {}
	local ids = {}
	for _, tab in list do
		if typeof(tab) == "table" then
			table.insert(ids, tab.Id or tab.id)
		else
			table.insert(ids, tab)
		end
	end
	return ids
end

-- Moves a registered window's tab selection by `direction` (-1 / 1), wrapping.
-- Returns the new tab id (nil if the window has no tabs).
function Gamepad.cycleTabs(entry: Entry, direction: number): any
	local tabs = entry.Tabs
	if tabs == nil or typeof(tabs) ~= "table" or tabs.set == nil then
		return nil
	end
	local ids = tabIds(entry)
	if #ids == 0 then
		return nil
	end
	local index = table.find(ids, peek(tabs)) or 1
	local nextId = ids[(index - 1 + direction) % #ids + 1]
	if nextId ~= peek(tabs) then
		tabs:set(nextId)
		Sound.play("Click")
		if entry.OnTab then
			entry.OnTab(nextId)
		end
	end
	return nextId
end

-- The selection ring for buttons that don't draw their own: the same look as
-- Kit.focusRing (theme focus colour, soft halo, a travelling shimmer). Its
-- corner radius follows the selected button (see `matchRadius`).
local function buildRing(scope: any): (Frame, any)
	local radius = scope:Value(UDim.new(0, 12))
	local gradient = scope:New "UIGradient" {
		Rotation = 0,
		Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 0),
			NumberSequenceKeypoint.new(0.5, 0.35),
			NumberSequenceKeypoint.new(1, 0),
		}),
	}
	local function halo(width: number, transparency: number)
		return scope:New "Frame" {
			Name = "Halo",
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
			[Children] = {
				scope:New "UICorner" { CornerRadius = radius },
				scope:New "UIStroke" {
					ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
					LineJoinMode = Enum.LineJoinMode.Round,
					Color = Kit.themed(scope, function(theme)
						return theme.Color.Focus or theme.Color.Accent
					end),
					Thickness = width,
					Transparency = transparency,
				},
			},
		}
	end
	local ring = scope:New "Frame" {
		Name = "BloxUISelectionRing",
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.new(1, 8, 1, 8),
		ZIndex = 100,
		[Children] = {
			scope:New "UICorner" { CornerRadius = radius },
			scope:New "UIStroke" {
				ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
				LineJoinMode = Enum.LineJoinMode.Round,
				Color = Kit.themed(scope, function(theme)
					return theme.Color.Focus or theme.Color.Accent
				end),
				Thickness = 3,
				[Children] = gradient,
			},
			halo(8, 0.78),
			halo(14, 0.9),
		},
	}
	local spin = TweenService:Create(
		gradient,
		TweenInfo.new(2.6, Enum.EasingStyle.Linear, Enum.EasingDirection.In, -1),
		{ Rotation = 360 }
	)
	spin:Play()
	-- the shimmer only runs while the ring can be seen (nothing selected = hidden)
	local untrack = Motion.track(spin, ring)
	table.insert(scope, function()
		untrack()
		spin:Cancel()
	end)
	return ring, radius
end

-- The corner radius the ring should use around `gui`: its own UICorner, or
-- that of the frame painting it (BloxUI buttons paint a child), plus the gap.
local function matchRadius(gui: Instance): UDim
	local corner = gui:FindFirstChildOfClass("UICorner")
	if corner == nil then
		for _, child in gui:GetChildren() do
			if child:IsA("GuiObject") then
				corner = child:FindFirstChildOfClass("UICorner")
				if corner then
					break
				end
			end
		end
	end
	if corner == nil then
		return UDim.new(0, 4)
	end
	local r = (corner :: UICorner).CornerRadius
	if r.Scale > 0 then
		return r
	end
	return UDim.new(0, r.Offset + 4)
end

export type Handle = {
	Ring: Frame,
	focus: (gui: Instance?) -> GuiObject?,
	top: () -> GuiObject?,
	isThemed: (gui: GuiObject) -> boolean,
	disable: () -> (),
}

--[[
	Turns on controller navigation for everything under `rootGui` (a
	ScreenGui, the PlayerGui, or any frame). Cleaned up with `scope`.
]]
function Gamepad.enable(scope: any, rootGui: Instance, options: { [string]: any }?): Handle
	local opts = options or {}
	local alive = true
	local connections: { RBXScriptConnection } = {}
	local ring, ringRadius
	if opts.Ring then
		ring = opts.Ring
	else
		ring, ringRadius = buildRing(scope)
	end
	local ringed: { [GuiObject]: boolean } = setmetatable({}, { __mode = "k" }) :: any
	local known: { [GuiObject]: boolean } = setmetatable({}, { __mode = "k" }) :: any
	local stack: { GuiObject } = {}
	local lastTop: GuiObject? = nil

	local function usingGamepad(): boolean
		return peek(Input.device) == "Gamepad"
	end

	local function ownsSelection(): boolean
		local selected = GuiService.SelectedObject
		return selected ~= nil and (selected == rootGui or selected:IsDescendantOf(rootGui))
	end

	local function consider(inst: Instance)
		if inst:IsA("GuiButton") or inst:IsA("TextBox") then
			local obj = inst :: GuiObject
			if obj.SelectionImageObject == nil then
				obj.SelectionImageObject = ring
				ringed[obj] = true
			end
		end
		if isWindow(inst) then
			known[inst :: GuiObject] = true
		end
	end

	for _, d in rootGui:GetDescendants() do
		consider(d)
	end

	-- the shared ring takes the corner radius of whatever it's around
	if ringRadius then
		table.insert(connections, GuiService:GetPropertyChangedSignal("SelectedObject"):Connect(function()
			local selected = GuiService.SelectedObject
			if alive and selected and selected.SelectionImageObject == ring then
				ringRadius:set(matchRadius(selected))
			end
		end))
	end

	-- True when `gui` shows a themed BloxUI ring when selected: the shared
	-- ring above, or its own Kit.focusRing (BloxUI components).
	local function isThemed(gui: GuiObject): boolean
		local image = gui.SelectionImageObject
		return image == ring or image == Kit.NoSelection
	end
	table.insert(connections, rootGui.DescendantAdded:Connect(function(d)
		if alive then
			consider(d)
		end
	end))

	local function top(): GuiObject?
		for i = #stack, 1, -1 do
			if windowOpen(stack[i]) then
				return stack[i]
			end
		end
		return nil
	end

	-- the selection points at something hidden, or inside a closing window
	local function stale(selected: Instance?): boolean
		if selected == nil or not isShown(selected) then
			return true
		end
		for win in known do
			if selected:IsDescendantOf(win) and not windowOpen(win) then
				return true
			end
		end
		return false
	end

	local function focus(container: Instance?): GuiObject?
		if not alive then
			return nil
		end
		local target = container or top()
		if target == nil or not usingGamepad() then
			if ownsSelection() and stale(GuiService.SelectedObject) then
				GuiService.SelectedObject = nil
			end
			return nil
		end
		local button = Gamepad.firstButton(target)
		if button then
			GuiService.SelectedObject = button
		end
		return button
	end

	local function refresh()
		-- windows registered after they were parented
		for gui in registry do
			if not known[gui] and gui:IsDescendantOf(rootGui) then
				known[gui] = true
			end
		end
		for win in known do
			local open = win.Parent ~= nil and windowOpen(win)
			local index = table.find(stack, win)
			if open and index == nil then
				table.insert(stack, win)
			elseif not open and index then
				table.remove(stack, index)
			end
			if win.Parent == nil then
				known[win] = nil
			end
		end

		local current = top()
		if current ~= lastTop then
			lastTop = current
			focus(current)
			return
		end
		-- the selected button vanished (tab switch, item removed, window
		-- closing): re-focus, or let go of it when nothing is open
		local selected = GuiService.SelectedObject
		if current and usingGamepad() then
			if stale(selected) then
				focus(current)
			end
		elseif selected and ownsSelection() and stale(selected) then
			GuiService.SelectedObject = nil
		end
	end

	local elapsed = 0
	table.insert(connections, RunService.Heartbeat:Connect(function(dt)
		if not alive then
			return
		end
		elapsed += dt
		if elapsed >= POLL then
			elapsed = 0
			refresh()
		end
	end))

	-- switching device: controller players get focus, everyone else loses the ring
	local observer = scope:Observer(Input.device)
	table.insert(scope, observer:onChange(function()
		if not alive then
			return
		end
		if usingGamepad() then
			if stale(GuiService.SelectedObject) then
				focus(top())
			end
		elseif ownsSelection() then
			GuiService.SelectedObject = nil
		end
	end))

	table.insert(connections, UserInputService.InputBegan:Connect(function(input)
		if not alive then
			return
		end
		local key = input.KeyCode
		if key ~= Enum.KeyCode.ButtonL1 and key ~= Enum.KeyCode.ButtonR1 and key ~= Enum.KeyCode.ButtonB then
			return
		end
		if UserInputService:GetFocusedTextBox() or holds > 0 then
			return
		end
		local win = top()
		if win == nil then
			return
		end
		local entry = registry[win]
		if entry == nil then
			return
		end
		if key == Enum.KeyCode.ButtonB then
			if entry.Close then
				Sound.play("Close")
				entry.Close()
				task.delay(POLL * 1.5, function()
					if alive then
						refresh()
					end
				end)
			end
		elseif Gamepad.cycleTabs(entry, if key == Enum.KeyCode.ButtonL1 then -1 else 1) ~= nil then
			-- let the new page appear, then land on its first button
			task.delay(POLL, function()
				if alive then
					focus(win)
				end
			end)
		end
	end))

	local function disable()
		if not alive then
			return
		end
		alive = false
		for _, connection in connections do
			connection:Disconnect()
		end
		for obj in ringed do
			if obj.SelectionImageObject == ring then
				obj.SelectionImageObject = nil
			end
		end
		if ownsSelection() then
			GuiService.SelectedObject = nil
		end
	end
	table.insert(scope, disable)

	-- one pass right away so an already-open window is known
	refresh()

	return {
		Ring = ring,
		focus = focus,
		top = top,
		isThemed = isThemed,
		disable = disable,
	}
end

return Gamepad
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="288">
            <Properties>
              <string name="Name">Input</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Input: which device the player is using, key glyphs, and key bindings.

		UI.Input.device                 -- Value: "Keyboard" | "Gamepad" | "Touch"
		UI.Input.glyph(Enum.KeyCode.E)  --> "text", "E"
		UI.Input.glyph(Enum.KeyCode.ButtonX) --> "icon", "xbox-x" (or "ps-square")
		UI.Input.bind(scope, Enum.KeyCode.R, reload)
]]

local UserInputService = game:GetService("UserInputService")

local Fusion = require(script.Parent.Parent.Packages.Fusion)

local Input = {}

local scope = Fusion.scoped(Fusion)

local function classify(inputType: Enum.UserInputType): string?
	local name = inputType.Name
	if string.find(name, "^Gamepad") then
		return "Gamepad"
	elseif inputType == Enum.UserInputType.Touch then
		return "Touch"
	elseif inputType == Enum.UserInputType.Keyboard or string.find(name, "^Mouse") then
		return "Keyboard"
	end
	return nil
end

local initial = classify(UserInputService:GetLastInputType())
if initial == nil then
	initial = if UserInputService.TouchEnabled and not UserInputService.KeyboardEnabled then "Touch" else "Keyboard"
end

-- The device the player used most recently.
Input.device = scope:Value(initial :: string)

UserInputService.LastInputTypeChanged:Connect(function(inputType)
	local device = classify(inputType)
	if device then
		Input.device:set(device)
	end
end)

-- { xbox glyph, playstation glyph }
local GAMEPAD = {
	ButtonA = { "xbox-a", "ps-x" },
	ButtonB = { "xbox-b", "ps-circle" },
	ButtonX = { "xbox-x", "ps-square" },
	ButtonY = { "xbox-y", "ps-triagle" }, -- (sic) that's the name in the font
	ButtonL1 = { "xbox-lb", "ps-l1" },
	ButtonR1 = { "xbox-rb", "ps-r1" },
	ButtonL2 = { "xbox-lt", "ps-l2" },
	ButtonR2 = { "xbox-rt", "ps-r2" },
	ButtonL3 = { "xbox-stick-left", "ps-l3" },
	ButtonR3 = { "xbox-stick-right", "ps-r3" },
	Thumbstick1 = { "xbox-stick-left", "ps-stick-left" },
	Thumbstick2 = { "xbox-stick-right", "ps-stick-right" },
	DPadUp = { "xbox-dpad-up", "ps-dpad-up" },
	DPadDown = { "xbox-dpad-down", "ps-dpad-down" },
	DPadLeft = { "xbox-dpad-left", "ps-dpad-left" },
	DPadRight = { "xbox-dpad-right", "ps-dpad-right" },
	ButtonStart = { "xbox-menu", "ps5-options" },
	ButtonSelect = { "xbox-view", "ps5-share" },
}

local KEY_ICONS = {
	Up = "key-arrow-up",
	Down = "key-arrow-down",
	Left = "key-arrow-left",
	Right = "key-arrow-right",
}

local KEY_TEXT = {
	Zero = "0", One = "1", Two = "2", Three = "3", Four = "4",
	Five = "5", Six = "6", Seven = "7", Eight = "8", Nine = "9",
	LeftShift = "Shift", RightShift = "Shift",
	LeftControl = "Ctrl", RightControl = "Ctrl",
	LeftAlt = "Alt", RightAlt = "Alt",
	Return = "Enter", Escape = "Esc", Backspace = "Back",
	Space = "Space", Tab = "Tab", CapsLock = "Caps",
	Minus = "-", Equals = "=", Backquote = "`",
}

local MOUSE = {
	MouseButton1 = "mouse-button-left",
	MouseButton2 = "mouse-button-right",
	MouseButton3 = "mouse-button-center",
	MouseWheel = "mouse-scrollwheel",
}

local function isPlayStation(): boolean
	local ok, name = pcall(UserInputService.GetStringForKeyCode, UserInputService, Enum.KeyCode.ButtonA)
	return ok and name == "ButtonCross"
end

-- How to draw a key on screen. Returns ("text", "E") or ("icon", "<icon name>").
-- Accepts a KeyCode, a UserInputType (mouse buttons) or plain text.
function Input.glyph(key: any): (string, string)
	if typeof(key) == "string" then
		return "text", key
	end
	if typeof(key) ~= "EnumItem" then
		return "text", "?"
	end
	local name = key.Name
	if key.EnumType == Enum.UserInputType then
		return if MOUSE[name] then "icon" else "text", MOUSE[name] or name
	end
	if GAMEPAD[name] then
		return "icon", GAMEPAD[name][if isPlayStation() then 2 else 1]
	end
	if KEY_ICONS[name] then
		return "icon", KEY_ICONS[name]
	end
	return "text", KEY_TEXT[name] or name
end

-- Calls `callback(input)` when any of `keys` is pressed. Cleaned up with
-- `bindScope`. Presses while typing in chat/TextBoxes are ignored unless
-- `allowWhileTyping` is true.
function Input.bind(bindScope: { any }, keys: any, callback: (InputObject) -> (), allowWhileTyping: boolean?): RBXScriptConnection
	local list = if typeof(keys) == "table" then keys else { keys }
	local connection = UserInputService.InputBegan:Connect(function(input, processed)
		if processed and not allowWhileTyping then
			return
		end
		for _, key in list do
			if input.KeyCode == key or input.UserInputType == key then
				callback(input)
				return
			end
		end
	end)
	table.insert(bindScope, connection)
	return connection
end

return Input
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="289">
            <Properties>
              <string name="Name">Sound</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Sound: UI sound effects by theme slot, plus a small music player.

		UI.Sound.play("Click")                    -- uses Theme.Sound.Click
		UI.Sound.play("rbxassetid://123", { Volume = 0.8, Pitch = 1.2 })
		UI.Sound.enabled = false                  -- mute all UI sounds
		UI.Sound.volume = 0.5                     -- 0..1 multiplier for UI sounds

		UI.Sound.override({ click = "rbxassetid://1", Purchase = 123 })   -- per game, survives Theme.set
		UI.Sound.rateLimit("Tick", 0.05)          -- at most one Tick every 50 ms

		local stop = UI.Sound.playMusic("rbxassetid://456", { Volume = 0.5, Fade = 1 })
		UI.Sound.setMusicVolume(0.6)              -- 0..1 (the music SoundGroup)
		UI.Sound.muffle(true)                     -- windows do this while open (refcounted)
		UI.Sound.muffleEnabled = false            -- never muffle (muffle() still counts; turning it
		                                          -- off lifts a muffle that's on right now)

	Slots (theme.Sound): Click, Hover, Open, Close, Swoosh, Pop, Purchase,
	Reward, LevelUp, Error, Denied, Notify, Tick, Coin, Hit, Crit, Kill.
	Empty slots play nothing; Coin falls back to Tick and Denied to Error.
	Everything is a safe no-op without a LocalPlayer (edit mode, plugin preview).

	v2.4: a theme slot may also be a tuned sound (the style families use the
	client's built-in UI tick this way, so every slot has a voice without any
	upload): { Id = "rbxasset://...", Pitch = 1.2, Volume = 0.4,
	Notes = { 1, 1.26, 1.5 }, Gap = 0.06 } plays each note (a pitch multiplier)
	`Gap` seconds apart, a tiny arpeggio. Overrides (style.sounds,
	Sound.override) are plain ids and always win.
]]

local Players = game:GetService("Players")
local SoundService = game:GetService("SoundService")
local TweenService = game:GetService("TweenService")

local Theme = require(script.Parent.Parent.Theme)

local Sound = {
	enabled = true,
	volume = 0.5,
	musicVolume = 0.5,
}

-- Default loudness per slot (research: docs/RESEARCH.md 8.6).
local SLOT_VOLUME = {
	click = 0.5,
	hover = 0.15,
	tick = 0.35,
	coin = 0.35,
	purchase = 0.8,
	reward = 0.9,
	levelup = 0.9,
}

-- Slots that fall back to another slot when empty.
local FALLBACK = {
	coin = "Tick",
	denied = "Error",
}

-- Minimum seconds between two plays of a slot.
local limits: { [string]: number } = {
	tick = 0.05,
	coin = 0.05,
}
local lastPlayed: { [string]: number } = {}

-- Game-wide slot overrides (lower-case slot name -> id).
local overrides: { [string]: any } = {}

local templates: { [string]: Sound } = {}
local folder: Folder? = nil

local function hasPlayer(): boolean
	return Players.LocalPlayer ~= nil
end

local function getFolder(): Folder
	if folder == nil or folder.Parent == nil then
		local newFolder = Instance.new("Folder")
		newFolder.Name = "BloxUISounds"
		newFolder.Parent = SoundService
		folder = newFolder
	end
	return folder :: Folder
end

local function getTemplate(id: string): Sound
	local template = templates[id]
	if template == nil or template.Parent == nil then
		template = Instance.new("Sound")
		template.SoundId = id
		template.Parent = getFolder()
		templates[id] = template
	end
	return template :: Sound
end

local function isId(value: any): boolean
	return typeof(value) == "number" or (typeof(value) == "string" and value ~= "")
end

local function asId(value: any): string?
	if typeof(value) == "number" then
		return "rbxassetid://" .. tostring(value)
	elseif typeof(value) == "string" and value ~= "" then
		if string.find(value, "^rbx") or string.find(value, "^https?://") then
			return value
		end
		if tonumber(value) then
			return "rbxassetid://" .. value
		end
	end
	return nil
end

-- A tuned slot ({ Id, Pitch, Volume, Notes, Gap }) with a usable id, or nil.
local function tuned(value: any): { [string]: any }?
	if typeof(value) == "table" and isId(value.Id) then
		return value
	end
	return nil
end
Sound.tuned = tuned

-- The id for a slot name (case-insensitive), following overrides, the
-- active theme and fallbacks, plus its tuning when the theme gives one. nil
-- when the slot is empty.
local function slotId(slot: string, depth: number?): (string?, { [string]: any }?)
	local key = string.lower(slot)
	local over = overrides[key]
	if isId(over) then
		return asId(over), nil
	end
	local sounds = Theme.get().Sound or {}
	for name, value in sounds do
		if string.lower(name) == key then
			if isId(value) then
				return asId(value), nil
			end
			local spec = tuned(value)
			if spec then
				return asId(spec.Id), spec
			end
		end
	end
	local fallback = FALLBACK[key]
	if fallback and (depth or 0) < 2 then
		return slotId(fallback, (depth or 0) + 1)
	end
	return nil, nil
end

local function resolve(sound: any): (string?, string?, { [string]: any }?)
	if typeof(sound) == "number" then
		return asId(sound), nil, nil
	end
	if typeof(sound) ~= "string" or sound == "" then
		return nil, nil, nil
	end
	if string.find(sound, "^rbx") or string.find(sound, "^https?://") then
		return sound, nil, nil
	end
	local id, spec = slotId(sound)
	return id, string.lower(sound), spec
end

-- The id a slot plays right now (nil when silent). For tools.
function Sound.slotId(slot: string): string?
	return (slotId(slot))
end

export type PlayOptions = {
	Volume: number?,
	Pitch: number?,
	Vary: number?,
}

-- Plays a sound once. Does nothing for empty or unknown names, so every UI
-- event can call this safely even when no sound is set up.
function Sound.play(sound: any, options: PlayOptions?)
	if not Sound.enabled or not hasPlayer() then
		return
	end
	local id, slot, spec = resolve(sound)
	if id == nil then
		return
	end
	if slot then
		local limit = limits[slot]
		if limit then
			local now = os.clock()
			if lastPlayed[slot] and now - lastPlayed[slot] < limit then
				return
			end
			lastPlayed[slot] = now
		end
	end
	local opts = options or {}
	local vary = opts.Vary or 0.06
	local volume: number
	local pitch: number
	if spec then
		-- a tuned slot: its own loudness and pitch, scaled by the caller's
		volume = (spec.Volume or 0.5) * (if opts.Volume then math.min(1, opts.Volume / math.max(0.05, (slot and SLOT_VOLUME[slot]) or 0.5)) else 1)
		pitch = (spec.Pitch or 1) * (opts.Pitch or 1)
	else
		volume = opts.Volume or (slot and SLOT_VOLUME[slot]) or 1
		pitch = opts.Pitch or 1
	end
	local notes = if spec and typeof(spec.Notes) == "table" and #spec.Notes > 0 then spec.Notes else { 1 }
	local gap = if spec and typeof(spec.Gap) == "number" then spec.Gap else 0.06
	local function note(multiplier: number)
		pcall(function()
			local instance = getTemplate(id):Clone()
			instance.Volume = volume * Sound.volume
			instance.PlaybackSpeed = pitch * multiplier * (1 + (math.random() - 0.5) * vary)
			instance.Parent = getFolder()
			instance.Ended:Once(function()
				instance:Destroy()
			end)
			instance:Play()
			task.delay(10, function()
				if instance.Parent then
					instance:Destroy()
				end
			end)
		end)
	end
	for index, multiplier in notes do
		if index == 1 then
			note(multiplier)
		else
			task.delay(gap * (index - 1), note, multiplier)
		end
	end
end

-- At most one play of `slot` every `seconds` (0 or nil removes the limit).
function Sound.rateLimit(slot: string, seconds: number?)
	local key = string.lower(slot)
	if seconds == nil or seconds <= 0 then
		limits[key] = nil
	else
		limits[key] = seconds
	end
end

-- Game-wide slot overrides applied on top of any theme (they survive
-- Theme.set). Keys are case-insensitive: { click = "rbxassetid://1",
-- Purchase = 123 }. Sound.override(nil) clears them.
function Sound.override(map: { [string]: any }?)
	table.clear(overrides)
	for key, value in map or {} do
		if typeof(key) == "string" then
			overrides[string.lower(key)] = value
		end
	end
end

--------------------------------------------------------------------------
-- Music
--------------------------------------------------------------------------

local musicGroup: SoundGroup? = nil
local current: Sound? = nil
local muffleCount = 0
local equalizer: Instance? = nil

-- SoundService.BloxUI_Music, created on demand. nil where SoundGroups
-- aren't available.
function Sound.musicGroup(): SoundGroup?
	if musicGroup and musicGroup.Parent then
		return musicGroup
	end
	local ok, group = pcall(function()
		local existing = SoundService:FindFirstChild("BloxUI_Music")
		if existing and existing:IsA("SoundGroup") then
			return existing
		end
		local newGroup = Instance.new("SoundGroup")
		newGroup.Name = "BloxUI_Music"
		newGroup.Volume = Sound.musicVolume
		newGroup.Parent = SoundService
		return newGroup
	end)
	if ok then
		musicGroup = group
		return group
	end
	return nil
end

-- Music volume, 0..1.
function Sound.setMusicVolume(volume: number)
	Sound.musicVolume = math.clamp(volume, 0, 1)
	local group = if musicGroup and musicGroup.Parent then musicGroup else nil
	if group then
		pcall(function()
			(group :: any).Volume = Sound.musicVolume
		end)
	end
end

local function fadeOut(track: Sound, fade: number)
	pcall(function()
		if fade <= 0 then
			track:Destroy()
			return
		end
		local tween = TweenService:Create(track, TweenInfo.new(fade, Enum.EasingStyle.Quad, Enum.EasingDirection.In), { Volume = 0 })
		tween.Completed:Once(function()
			track:Destroy()
		end)
		tween:Play()
	end)
end

export type MusicOptions = {
	Volume: number?, -- this track's loudness (the group adds musicVolume on top)
	Fade: number?, -- seconds to crossfade (default 1)
}

-- Loops `id` in the music group, crossfading from the current track.
-- Returns stop(fade?). A no-op without a LocalPlayer.
function Sound.playMusic(id: any, options: MusicOptions?): (fade: number?) -> ()
	local opts = options or {}
	local soundId = asId(id)
	if soundId == nil or not hasPlayer() then
		return function() end
	end
	local fade = opts.Fade or 1
	local volume = opts.Volume or 0.5
	local group = Sound.musicGroup()
	if current then
		fadeOut(current, fade)
		current = nil
	end
	local ok, track = pcall(function()
		local newTrack = Instance.new("Sound")
		newTrack.Name = "BloxUI_MusicTrack"
		newTrack.SoundId = soundId
		newTrack.Looped = true
		newTrack.Volume = if fade > 0 then 0 else volume
		if group then
			pcall(function()
				(newTrack :: any).SoundGroup = group
			end)
		end
		newTrack.Parent = group or getFolder()
		newTrack:Play()
		if fade > 0 then
			TweenService:Create(newTrack, TweenInfo.new(fade, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), { Volume = volume }):Play()
		end
		return newTrack
	end)
	if not ok then
		return function() end
	end
	current = track
	return function(stopFade: number?)
		if current == track then
			current = nil
		end
		if track.Parent then
			fadeOut(track, stopFade or fade)
		end
	end
end

-- Fades out whatever music is playing.
function Sound.stopMusic(fade: number?)
	if current then
		fadeOut(current, fade or 1)
		current = nil
	end
end

local function setMuffle(on: boolean)
	local group = Sound.musicGroup()
	if group == nil then
		return
	end
	pcall(function()
		if equalizer == nil or equalizer.Parent == nil then
			local eq = Instance.new("EqualizerSoundEffect")
			eq.Name = "BloxUI_Muffle"
			eq.HighGain = 0
			eq.MidGain = 0
			eq.LowGain = 0
			eq.Parent = group
			equalizer = eq
		end
		local goal = if on then { HighGain = -20, MidGain = -8 } else { HighGain = 0, MidGain = 0 }
		TweenService:Create(equalizer :: Instance, TweenInfo.new(0.25, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), goal):Play()
	end)
end

-- Sound.muffleEnabled (true by default) reads and writes like a plain field.
-- It lives behind the metatable below so that switching it off can lift a
-- muffle that's already on (and switching it back on re-applies it).
local muffleEnabled = true

-- Muffles the music (open windows do this, like RIVALS' lobby). Refcounted:
-- every muffle(true) needs a muffle(false). With Sound.muffleEnabled = false
-- the count still runs but the music is never ducked.
function Sound.muffle(on: boolean)
	if not hasPlayer() then
		return
	end
	local before = muffleCount
	muffleCount = math.max(0, muffleCount + (if on then 1 else -1))
	if not muffleEnabled then
		return
	end
	if before == 0 and muffleCount > 0 then
		setMuffle(true)
	elseif before > 0 and muffleCount == 0 then
		setMuffle(false)
	end
end

-- How many things are muffling the music right now.
function Sound.muffled(): number
	return muffleCount
end

-- Turns muffling on / off for the whole game (same as setting
-- Sound.muffleEnabled).
function Sound.setMuffleEnabled(enabled: boolean)
	enabled = enabled ~= false
	if enabled == muffleEnabled then
		return
	end
	muffleEnabled = enabled
	if muffleCount > 0 and hasPlayer() then
		setMuffle(enabled)
	end
end

return setmetatable(Sound, {
	__index = function(_, key)
		if key == "muffleEnabled" then
			return muffleEnabled
		end
		return nil
	end,
	__newindex = function(self, key, value)
		if key == "muffleEnabled" then
			Sound.setMuffleEnabled(value == true)
		else
			rawset(self, key, value)
		end
	end,
})
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="290">
            <Properties>
              <string name="Name">Viewport</string>
              <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
--[[
	Viewport

	BloxUI is laid out in "design pixels" on a 1920x1080 reference screen.
	Every Screen scales its canvas by Viewport.scale so a layout looks the
	same on a 4K monitor, a laptop and a phone. Touch devices get a boost so
	buttons stay thumb-sized.

		UI.Viewport.configure({ Reference = Vector2.new(1600, 900), TouchBoost = 1.5 })
		UI.Viewport.userScale:set(1.2) -- e.g. from a settings slider (Blueprint "uiScale")

	userScale above 1 only grows the UI while the screen still holds at
	least MinDesign design px (default 1366 x 768, the smallest canvas the
	Blueprint HUD layout is built for), so a big UI scale can't push the HUD
	off a phone screen. Below 1 it always applies.
]]

local UserInputService = game:GetService("UserInputService")
local Workspace = game:GetService("Workspace")

local Fusion = require(script.Parent.Parent.Packages.Fusion)

local Viewport = {}

local scope = Fusion.scoped(Fusion)

local config = scope:Value({
	Reference = Vector2.new(1920, 1080),
	TouchBoost = 1.35,
	MinScale = 0.3,
	MaxScale = 3,
	MinDesign = Vector2.new(1366, 768),
})

-- Real screen size in pixels.
Viewport.size = scope:Value(Vector2.new(1920, 1080))

-- Extra multiplier you control (accessibility / settings menus).
Viewport.userScale = scope:Value(1)

local isTouch = UserInputService.TouchEnabled and not UserInputService.KeyboardEnabled

-- Design pixels -> screen pixels.
Viewport.scale = scope:Computed(function(use)
	local settings = use(config)
	local size = use(Viewport.size)
	local fit = math.min(size.X / settings.Reference.X, size.Y / settings.Reference.Y)
	if isTouch then
		fit *= settings.TouchBoost
	end
	local user = use(Viewport.userScale)
	local minDesign = settings.MinDesign
	if user > 1 and typeof(minDesign) == "Vector2" and minDesign.X > 0 and minDesign.Y > 0 then
		-- grow only while the canvas stays at least MinDesign design px
		local room = math.min(size.X / minDesign.X, size.Y / minDesign.Y) / math.max(fit, 1e-6)
		user = math.max(1, math.min(user, room))
	end
	fit *= user
	return math.clamp(fit, settings.MinScale, settings.MaxScale)
end)

-- The screen size measured in design pixels.
Viewport.designSize = scope:Computed(function(use)
	return use(Viewport.size) / use(Viewport.scale)
end)

function Viewport.configure(options: { [string]: any })
	local merged = table.clone(Fusion.peek(config))
	for key, value in options do
		merged[key] = value
	end
	config:set(merged)
end

Viewport.isTouch = isTouch

local cameraConnection: RBXScriptConnection? = nil

local function watchCamera(camera: Camera?)
	if cameraConnection then
		cameraConnection:Disconnect()
		cameraConnection = nil
	end
	if camera == nil then
		return
	end
	local function update()
		local size = camera.ViewportSize
		if size.X > 1 and size.Y > 1 then
			Viewport.size:set(size)
		end
	end
	update()
	cameraConnection = camera:GetPropertyChangedSignal("ViewportSize"):Connect(update)
end

watchCamera(Workspace.CurrentCamera)
Workspace:GetPropertyChangedSignal("CurrentCamera"):Connect(function()
	watchCamera(Workspace.CurrentCamera)
end)

return Viewport
]]></string>
            </Properties>
          </Item>
        </Item>
        <Item class="ModuleScript" referent="291">
          <Properties>
            <string name="Name">Version</string>
            <string name="Source"><![CDATA[-- Copyright (c) 2026 BloxUI. All rights reserved. Licensed under the BloxUI EULA (see LICENSE).
-- Generated by build.ps1 from version.json. Do not edit: change version.json and build.
return {
	runtime = "2.2.0",
	api = 1,
}
]]></string>
          </Properties>
        </Item>
      </Item>
    </Item>
  </Item>
</roblox>