<roblox xmlns:xmime="http://www.w3.org/2005/05/xmlmime" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://www.roblox.com/roblox.xsd" version="4">
	<External>null</External>
	<External>nil</External>
	<Item class="Folder" referent="RBX5FF0EB5EA7A64A2A8747129C6CE15BD9">
		<Properties>
			<BinaryString name="AttributesSerialize"></BinaryString>
			<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
			<bool name="DefinesCapabilities">false</bool>
			<string name="Name">KeyCapperPlugin</string>
			<int64 name="SourceAssetId">-1</int64>
			<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
		</Properties>
		<Item class="Script" referent="RBX065AA82AC7C540858878107CAD5F11C3">
			<Properties>
				<ProtectedString name="Source"><![CDATA[--!strict
-- KeyCapper — Copyright (c) 2026 sebattfg. All rights reserved.
-- Proprietary. Redistribution, reupload and resale are prohibited; sebattfg
-- is the sole authorised distributor. Full terms in the sibling `License`
-- module, and in LICENSE / CREDITS.md at the project root.
-- Panel icons: Font Awesome 5 Free (CC BY 4.0) — https://fontawesome.com/
--
-- Plugin entry point. Creates the toolbar button, owns the Panel and the
-- Brush, and wires them together. Contains NO business logic: everything is
-- delegated (Installer, Brush, Panel, GroupRegistry).
--
-- Only exists and runs once this folder is saved as a Local Plugin (right
-- click > Save as Local Plugin): `plugin` is only defined in that context.

local RunService = game:GetService("RunService")

local Constants = require(script.Parent.Constants)
local Installer = require(script.Parent.Core.Installer)
local Brush = require(script.Parent.Placement.Brush)
local Panel = require(script.Parent.UI.Panel)
local History = require(script.Parent.Core.History)
local GroupRegistry = require(script.Parent.Core.GroupRegistry)
local RestyleQueue = require(script.Parent.Edit.RestyleQueue)
local Settings = require(script.Parent.RuntimeSource.Settings)

local toolbar = plugin:CreateToolbar("KeyCapper")
local toolbarButton = toolbar:CreateButton(
	"KeyCapper",
	"Place functional keycaps",
	"rbxassetid://91265798541583"
)
toolbarButton.ClickableWhenViewportHidden = true

local panel = Panel.new(plugin)
local brush = Brush.new(plugin)

-- currentGroup and showGroup are declared before any callback that closes
-- over them: a local declared later would leave those closures reading a
-- nil global instead (bit us twice already).
local currentGroup = Constants.DEFAULT_GROUP

-- Every attribute a group carries (mirrors GroupRegistry's DEFAULTS schema).
-- Kept as an explicit list rather than iterating the Configuration's own
-- attributes: a freshly-picked group that doesn't exist yet has none at all,
-- and the Panel still needs every field populated with its default.
local GROUP_ATTRIBUTE_KEYS = {
	"CapColorMode", "Color", "GradientAxis", "GradientColor",
	"CapRandomHue", "CapRandomSat", "CapRandomValue",
	"SoundIds", "PitchJitter", "Volume",
	"TextColorMode", "TextColor", "TextGradientAxis", "TextGradientColor",
	"TextRandomHue", "TextRandomSat", "TextRandomValue",
	"FontName", "StrokeEnabled", "StrokeColor", "StrokeSize", "LabelMaxDistance",
	"AnimationStyle", "PressTime", "ReleaseTime",
}

-- The dropdown shows each group's key count and a colour chip: past a couple
-- of groups the names alone stop being enough to tell them apart.
--
-- One bucketed walk of the board, not Members() per group: this runs at
-- plugin load and on every group change, and asking each group separately
-- rescanned every key on the board once per group.
local function groupEntries(): { any }
	local buckets = GroupRegistry.MembersByGroup()
	local entries = {}
	for _, name in ipairs(GroupRegistry.List()) do
		local members = buckets[name]
		table.insert(entries, {
			Value = name,
			Detail = tostring(members and #members or 0),
			-- The colour the group paints its caps: with half a dozen groups
			-- the names alone stop being enough to tell them apart.
			Color = GroupRegistry.GetValue(name, "Color"),
			-- The default group is where the brush falls back to and where
			-- orphaned keys are read from, so it must always exist. No × is
			-- drawn for it rather than drawing one that refuses.
			Deletable = name ~= Constants.DEFAULT_GROUP,
		})
	end
	return entries
end

-- The Others section is the ONLY part of the panel whose real state lives
-- game-side (RuntimeSource.Settings) with defaults of TRUE, while its Toggles
-- start at FALSE like every other Toggle. So the panel MUST be told the real
-- values before it is ever looked at, not just when something else happens to
-- push them. It used to be pushed from one place only — brush activation —
-- and until the user turned the brush on, Others showed Points as OFF while
-- the points system was actually running, and hid the AutoUI switch entirely
-- (that row only shows when Points is known to be on). Worse, the first click
-- on the desynced Points toggle wrote back `true`, i.e. no change: the user
-- had to click it twice to turn a running system off, which read as "the
-- switch does nothing / isn't there".
local function showSettings()
	panel:SetOthers({
		PointsEnabled = Settings.GetValue("PointsEnabled"),
		AutoUIEnabled = Settings.GetValue("AutoUIEnabled"),
	})
end

local function showGroup(name: string)
	panel:SetGroups(groupEntries(), name)

	local values = {}
	for _, key in ipairs(GROUP_ATTRIBUTE_KEYS) do
		values[key] = GroupRegistry.GetValue(name, key)
	end
	panel:ShowGroupValues(values)
end

-- Declared here, above every callback that closes over them, for the reason
-- given at the top of this file.
--
-- A slider fires a value change on every frame of a drag. Three things have
-- to be kept apart there, and used to be done together on every one of them:
--
--   * the attribute write, cheap, and immediate so the panel stays live;
--   * the restyle, O(keys) and what actually froze Studio — now coalesced and
--     time-sliced by RestyleQueue, so only the last value is ever painted;
--   * the undo entry, which has to cover the WHOLE gesture. One recording per
--     change was both slow and useless: a hundred waypoints to undo one drag.
--
-- So the recording opens on the first change of a gesture and closes once the
-- values have settled AND the queued restyle has caught up — committing while
-- the restyle is still running would leave the colours it writes outside the
-- entry that is supposed to own them.
local SETTLE = 0.25
local gestureRecording: History.Recording = nil
local gestureGroup: string? = nil
local lastChange = 0
local settling = false

local function flushGesture()
	if not gestureGroup then return end
	History.Commit(gestureRecording)
	gestureRecording, gestureGroup = nil, nil
end

local function settleGesture()
	if settling then return end
	settling = true

	task.spawn(function()
		repeat
			task.wait(SETTLE)
		until os.clock() - lastChange >= SETTLE and not RestyleQueue.IsBusy()
		flushGesture()
		settling = false
	end)
end

toolbarButton.Click:Connect(function()
	panel:Toggle()
	toolbarButton:SetActive(panel.widget.Enabled)
end)

panel.OnBrushToggled = function(active: boolean)
	-- A stroke needs its own recording, and one already open would refuse it.
	-- Reaching for the brush is also exactly when panel fiddling has stopped.
	flushGesture()
	if active then
		-- Install on first real use rather than on plugin load: we never touch
		-- the user's game until they actually ask for a key.
		Installer.EnsureRuntime()
		GroupRegistry.Ensure(currentGroup)
		showSettings()
		showGroup(currentGroup)
		brush:Activate()
		panel:SetStatus("Click or drag in the viewport. R to rotate.")
	else
		brush:Deactivate()
		panel:SetStatus("")
	end
end

panel.OnToolChanged = function(tool: string)
	brush:SetTool(tool)
	if not panel.brushActive then return end
	panel:SetStatus(tool == "Erase"
		and "Drag to erase. The eraser does not snap to the grid."
		or "Click or drag in the viewport. R to rotate.")
end

panel.OnEraseScopeChanged = function(scope: string)
	brush:SetEraseScope(scope)
end

panel.OnLabelChanged = function(label: string)
	brush:SetLetter(label)
end

panel.OnLabelModeChanged = function(mode: string)
	brush:SetLabelMode(mode)
end

panel.OnCharsetChanged = function(name: string)
	brush:SetLabelCharset(name)
end

panel.OnGroupChanged = function(name: string)
	currentGroup = name
	brush:SetGroup(name)
	-- Picking or creating a group is an explicit act, so it may exist right
	-- away: that is what puts it in the list.
	GroupRegistry.Ensure(name)
	-- The fields must follow the group, otherwise the panel would show the
	-- previous group's colour and the next edit would silently overwrite it.
	showGroup(name)
end

panel.OnGroupDeleted = function(name: string)
	-- The UI draws no × for it, so this only fires if something else ever
	-- calls in — the invariant is enforced here, not just hidden in the view.
	if name == Constants.DEFAULT_GROUP then return end
	-- There must always be somewhere for the brush to paint into.
	if #GroupRegistry.List() <= 1 then
		panel:SetStatus("Can't delete the only group.")
		return
	end
	-- Recorded like every other mutation the plugin makes. It was the one
	-- destructive action that wasn't: the group's whole style vanished and
	-- Ctrl+Z did nothing, on the only operation in the plugin with no other
	-- way back. A panel edit in progress must not be swallowed into this
	-- entry, hence the flush first.
	flushGesture()
	local recording = History.Begin("KeyCapper: delete group " .. name)
	-- The keys go WITH the group, they are not left behind. Keeping them was
	-- the worse outcome: a key whose group no longer exists cannot be selected
	-- in the panel, so the group eraser can never reach it either — the only
	-- way to remove it was to switch the erase scope to All and go over it by
	-- hand. Orphaning keys made them practically permanent, which is not
	-- something a delete button should be able to do. Undo brings both the
	-- style and the keys back in one step, since both happen inside this
	-- recording.
	for _, key in ipairs(GroupRegistry.Members(name)) do
		key:Destroy()
	end
	GroupRegistry.Delete(name)
	History.Commit(recording)

	if name == currentGroup then
		currentGroup = GroupRegistry.List()[1]
		brush:SetGroup(currentGroup)
	end
	showGroup(currentGroup)
end

panel.OnGroupValueChanged = function(key: string, value: any)
	-- An edit to another group must never land in the previous one's entry.
	if gestureGroup and gestureGroup ~= currentGroup then
		flushGesture()
	end
	if not gestureGroup then
		gestureRecording = History.Begin("KeyCapper: edit group " .. currentGroup)
		gestureGroup = currentGroup
	end

	lastChange = os.clock()
	GroupRegistry.Set(currentGroup, key, value)
	RestyleQueue.Request(currentGroup)
	settleGesture()
end

panel.OnSizeChanged = function(size: number)
	brush:SetSize(size)
end

panel.OnRotationChanged = function(value: string)
	if value == "Random" then
		brush:SetRotationRandom()
	else
		brush:SetRotation(tonumber(value) // 90)
	end
end

brush.OnRotated = function(steps: number)
	panel:SetRotation(steps)
end

-- Only the group list, never showGroup(): the counts are the sole thing a
-- stroke can invalidate. showGroup would also reload every style field from
-- the registry, overwriting whatever the user is part-way through editing.
brush.OnStrokeEnded = function()
	panel:SetGroups(groupEntries(), currentGroup)
end

panel.OnSettingChanged = function(key: string, value: any)
	Settings.Set(key, value)
end

-- Studio revokes the mouse focus as soon as another tool takes over, or Play
-- starts: the panel must not keep claiming the brush is ON, and the ghost
-- must not be left sitting in the place (it would otherwise persist into
-- Play/publish, since it's just an ordinary part parented to Workspace).
local function forceBrushOff()
	if panel.brushActive then
		panel:SetBrushActive(false)
		brush:Deactivate()
		panel:SetStatus("")
	end
end

plugin.Deactivation:Connect(forceBrushOff)

-- RunService.RunStateChanged is not reliably present across Studio API
-- versions (errors with "not a valid member" on some), so play-mode start is
-- detected by polling RunService:IsRunning() instead, which is always there.
do
	local wasRunning = RunService:IsRunning()
	RunService.Heartbeat:Connect(function()
		local running = RunService:IsRunning()
		if running ~= wasRunning then
			wasRunning = running
			if running then
				forceBrushOff()
			end
		end
	end)
end

plugin.Unloading:Connect(function()
	brush:Deactivate()
end)

-- Re-read on every open: the settings folder is a plain Configuration in
-- ReplicatedStorage, so it can also be edited by hand, undone, or changed by
-- another Studio window while the widget sits closed.
panel.widget:GetPropertyChangedSignal("Enabled"):Connect(function()
	if panel.widget.Enabled then
		showSettings()
	end
end)

-- Both at load, so the panel can never be looked at before it has been told
-- the truth. Settings.GetValue only ever reads (it never creates the folder),
-- so this keeps the "we never touch the user's game until they ask for a key"
-- rule that keeps Installer.EnsureRuntime out of plugin load.
showSettings()
showGroup(currentGroup)

-- The one exception to that rule, and it doesn't really break it: keys in the
-- Workspace mean the user has already asked for the runtime, so this puts
-- nothing in the place that isn't meant to be there. It repairs a place SAVED
-- in the broken state — keys, no runtime — which is otherwise indistinguishable
-- from a working one right up until you press a key and nothing happens.
if workspace:FindFirstChild(Constants.KEYS_FOLDER) then
	Installer.EnsureRuntime()
end
]]></ProtectedString>
				<bool name="Disabled">false</bool>
				<Content name="LinkedSource"><null></null></Content>
				<token name="RunContext">0</token>
				<string name="ScriptGuid">{3B669D06-9EF2-407E-9722-F13F83CF6C34}</string>
				<BinaryString name="AttributesSerialize"></BinaryString>
				<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
				<bool name="DefinesCapabilities">false</bool>
				<string name="Name">Main</string>
				<int64 name="SourceAssetId">-1</int64>
				<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
			</Properties>
		</Item>
		<Item class="ModuleScript" referent="RBX8842BD7713364CB2B191D7B0116406EE">
			<Properties>
				<Content name="LinkedSource"><null></null></Content>
				<ProtectedString name="Source"><![CDATA[--!strict
-- PLUGIN-side constants (Studio only).
-- Not to be confused with RuntimeSource.Config, which is GAME-side.

return {
	-- Bumped whenever RuntimeSource changes: the Installer re-clones the game
	-- side only on a version difference.
	VERSION = "0.6.0", -- R6 rigs detected (feet measured from the legs, not from HipHeight), and a per-group sound Volume

	-- Shown by the panel's Discord button for the user to copy.
	--
	-- Deliberately an INDIRECTION on our own domain, not the invite itself.
	-- A downloaded .rbxmx never updates, so an invite hard-coded here is
	-- final: revoke it or move server, and the button is dead forever for
	-- every existing user, with no way to repair it. This URL is a 302 on the
	-- site (temporary on purpose, so it stays changeable), which moves the
	-- choice of destination out of the distributed binary and onto the site.
	-- Same rule for anything else that ships to the user.
	--
	-- Copies of 0.5.17 and earlier still carry https://discord.gg/3XsMR7z5yf
	-- directly: that one must stay alive for them.
	DISCORD_URL = "https://zerodev.tools/discord",

	-- Tags. TAG_HITBOX must stay identical to RuntimeSource.Config.TAG_HITBOX.
	TAG_HITBOX = "KeyCapper_Hitbox",
	TAG_KEY = "KeyCapper_Key",

	-- The mesh republished under the ZeroDev account, public.
	MESH_ID = "rbxassetid://8837613273",
	KEY_SIZE = Vector3.new(3, 1.368114709854126, 3),

	-- The SurfaceGui is on the Top face: the mesh needs this base yaw for
	-- the text to read right-side up. Determined by hand on the prototype,
	-- don't change without re-validating visually.
	MESH_YAW_OFFSET = math.rad(-90),

	-- Grid: pitch of 3.15 = 3-stud key + a 0.15 gap.
	GRID_PITCH = 3.15,
	ROTATION_STEP = math.rad(90), -- the "90-degree grid"

	-- Each footprint cell is re-probed along the surface normal from this far
	-- out, so a cell hanging over a ledge finds nothing and is dropped instead
	-- of being placed mid-air. Also the tolerance for following a bumpy
	-- surface: beyond this, the cell is considered off-surface.
	CELL_PROBE_REACH = 4,

	-- Where keys are stored in the Workspace.
	KEYS_FOLDER = "KeyCapperKeys",

	-- Group storage. Kept OUTSIDE the installed runtime folder on purpose:
	-- the Installer wipes that one on a version bump. Must stay identical to
	-- RuntimeSource.Config.GROUPS_FOLDER.
	GROUPS_FOLDER = "KeyCapperGroups",
	ATTR_GROUP = "Group", -- attribute carried by each key Model
	DEFAULT_GROUP = "Default",

	-- Where the runtime is installed in the user's game.
	RUNTIME_PARENT = "ReplicatedStorage",
	RUNTIME_NAME = "KeyCapper",

	-- Must stay identical to RuntimeSource.Config.SETTINGS_FOLDER. Kept
	-- outside the versioned runtime folder for the same reason GROUPS_FOLDER
	-- is: the Installer wipes that one on a version bump.
	SETTINGS_FOLDER = "KeyCapperSettings",
}
]]></ProtectedString>
				<string name="ScriptGuid">{A9B82CE1-B041-4464-B5B2-19E0AD45EC94}</string>
				<BinaryString name="AttributesSerialize"></BinaryString>
				<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
				<bool name="DefinesCapabilities">false</bool>
				<string name="Name">Constants</string>
				<int64 name="SourceAssetId">-1</int64>
				<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
			</Properties>
		</Item>
		<Item class="ModuleScript" referent="RBX453CC42F587A40FA8F89A95DB3FC43AE">
			<Properties>
				<Content name="LinkedSource"><null></null></Content>
				<ProtectedString name="Source"><![CDATA[--!strict
-- KeyCapper — Proprietary License
-- Copyright (c) 2026 sebattfg. All rights reserved.
--
-- This module exists so the licence travels INSIDE the plugin. The LICENSE
-- and CREDITS.md files at the repo root are not part of the .rbxmx that gets
-- installed and passed around — this is, and it is the copy that matters if
-- the plugin ever turns up somewhere it should not be.
--
-- Nothing requires this module and nothing should: it is a notice, not
-- behaviour. Removing it, or the notices in the other files, is itself a
-- breach of the licence it states (see RESTRICTIONS f).
--
-- =====================================================================
--
-- KeyCapper is NOT open source. It may be used inside Roblox Studio to
-- build keycaps, and the keycaps you build with it are yours. The plugin
-- itself is not.
--
-- sebattfg is the ONLY party permitted to distribute, publish, sell or
-- otherwise make KeyCapper available, through any medium. Any copy from
-- any other source is unauthorised: do not use it, delete it.
--
-- You may NOT redistribute, reupload, resell, share, sublicense or gift
-- this plugin, modified or not, free or not — including to the Roblox
-- Creator Store, asset libraries, marketplaces, file hosts, Discord
-- servers or repositories. You may NOT copy its source into another
-- plugin, tool or model, create derivative works from it, claim
-- authorship of it, or remove or alter this notice.
--
-- Provided "as is", without warranty of any kind. The author is not
-- liable for any claim, damages or other liability arising from it.
--
-- Full terms: LICENSE at the project root.
-- Third-party attribution: CREDITS.md at the project root.
--   Panel icons are Font Awesome 5 Free, CC BY 4.0 — https://fontawesome.com/
--   The Discord button uses Discord's own official logo, used for
--   identification only. Discord is a trademark of Discord Inc.; this
--   plugin is not affiliated with or endorsed by Discord Inc.
--
-- Licensing requests, permissions, or to report unauthorised
-- distribution: contact sebattfg.
--
-- =====================================================================

return {
	SOFTWARE = "KeyCapper",
	AUTHOR = "sebattfg",
	COPYRIGHT = "Copyright (c) 2026 sebattfg. All rights reserved.",
	LICENSE = "Proprietary. Redistribution prohibited. sebattfg is the sole authorised distributor.",
	NOTICE = "This plugin may not be redistributed, reuploaded, resold or shared. "
		.. "If you did not obtain it from sebattfg, your copy is unauthorised.",
	CREDITS = {
		Icons = "Font Awesome 5 Free (CC BY 4.0) — https://fontawesome.com/",
		DiscordLogo = "Official Discord logo, used for identification only. "
			.. "Discord is a trademark of Discord Inc. Not affiliated with or endorsed by Discord Inc.",
	},
}
]]></ProtectedString>
				<string name="ScriptGuid">{0A73AD01-DB9B-4BA5-ADFF-716D30662261}</string>
				<BinaryString name="AttributesSerialize"></BinaryString>
				<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
				<bool name="DefinesCapabilities">false</bool>
				<string name="Name">License</string>
				<int64 name="SourceAssetId">-1</int64>
				<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
			</Properties>
		</Item>
		<Item class="Folder" referent="RBX1427FB4BE6774A0F80D497E529020575">
			<Properties>
				<BinaryString name="AttributesSerialize"></BinaryString>
				<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
				<bool name="DefinesCapabilities">false</bool>
				<string name="Name">Edit</string>
				<int64 name="SourceAssetId">-1</int64>
				<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
			</Properties>
			<Item class="ModuleScript" referent="RBXF229F1F466B14249B5C74CDF5AC1C259">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: push a group's LOOK onto its members' caps.
-- Reads the group (GroupRegistry), computes the colours (GradientSolver),
-- assigns. Decides nothing itself.
--
-- Called after every edit AND after every stroke: a gradient depends on the
-- whole membership, so adding one key restyles the group.

local CollectionService = game:GetService("CollectionService")

local Constants = require(script.Parent.Parent.Constants)
local Chunker = require(script.Parent.Parent.Core.Chunker)
local GroupRegistry = require(script.Parent.Parent.Core.GroupRegistry)
local GradientSolver = require(script.Parent.GradientSolver)
local RandomColor = require(script.Parent.RandomColor)
local FontPresets = require(script.Parent.Parent.RuntimeSource.Style.FontPresets)

local GroupStyler = {}

-- Salts keep the cap and text channels from landing on the same hue in
-- Random mode when they're fed the exact same positions.
local CAP_RANDOM_SALT = 0
local TEXT_RANDOM_SALT = 1000

-- One colour per position, per the channel's own mode. Solid/Gradient/Random
-- are the only three GroupStyler knows about — an unrecognised mode (stale
-- data) is treated as Solid rather than erroring.
--
-- Random reads the SAME base colour as Solid, plus three variation
-- amplitudes: one base colour, three knobs, everything from "shades of blue"
-- to "confetti". See Edit.RandomColor.
local function resolveChannelColors(
	mode: string,
	positions: { Vector3 },
	solidColor: Color3,
	axis: string,
	gradientColor: Color3,
	random: { hue: number, sat: number, value: number },
	randomSalt: number
): { Color3 }
	if mode == "Gradient" then
		return GradientSolver.Colors(positions, axis, solidColor, gradientColor)
	elseif mode == "Random" then
		return RandomColor.Colors(positions, solidColor, random.hue, random.sat, random.value, randomSalt)
	end

	local colors: { Color3 } = {}
	for index = 1, #positions do
		colors[index] = solidColor
	end
	return colors
end

local function capOf(key: Model): BasePart?
	local cap = key:FindFirstChild("Cap")
	return cap and cap:IsA("BasePart") and cap or nil
end

-- The label/stroke are always built together by GuiFactory, so either both
-- are found or neither is: a nil here means the GUI hasn't been built yet
-- (streaming, or a placement mid-flight), not a broken key.
local function guiLabelAndStrokeOf(cap: BasePart): (SurfaceGui?, TextLabel?, UIStroke?)
	local gui = cap:FindFirstChild("SurfaceGui")
	if not (gui and gui:IsA("SurfaceGui")) then return nil, nil, nil end
	local label = gui:FindFirstChild("TextLabel")
	if not (label and label:IsA("TextLabel")) then return gui, nil, nil end
	local stroke = label:FindFirstChild("UIStroke")
	return gui, label, (stroke and stroke:IsA("UIStroke") and stroke or nil)
end

-- The core pass. `candidates` are walked in chunks; when `filterGroup` is
-- given, the ones that don't belong to it are skipped as part of that same
-- walk rather than being filtered out beforehand — resolving membership up
-- front meant one unchunked O(keys) scan (~15ms on a 9k board) running before
-- the first yield, which was most of the hitch that survived chunking.
--
-- shouldAbort is polled at each yield and is what lets a superseded pass stop
-- mid-list; the return value says whether it ran to the end.
local function styleMembers(
	name: string,
	candidates: { Instance },
	filterGroup: string?,
	shouldAbort: (() -> boolean)?
): boolean
	if #candidates == 0 then return true end

	local caps: { BasePart } = {}
	local positions: { Vector3 } = {}
	local gathered = Chunker.Each(candidates, function(member)
		if filterGroup then
			if not member:IsA("Model") then return end
			if member:GetAttribute(Constants.ATTR_GROUP) ~= filterGroup then return end
		end
		local cap = capOf(member :: Model)
		if cap then
			table.insert(caps, cap)
			table.insert(positions, cap.Position)
		end
	end, shouldAbort)
	if not gathered then return false end
	if #caps == 0 then return true end

	local capMode = GroupRegistry.GetValue(name, "CapColorMode") :: string
	local textMode = GroupRegistry.GetValue(name, "TextColorMode") :: string

	local capColors = resolveChannelColors(
		capMode,
		positions,
		GroupRegistry.GetValue(name, "Color") :: Color3,
		GroupRegistry.GetValue(name, "GradientAxis") :: string,
		GroupRegistry.GetValue(name, "GradientColor") :: Color3,
		{
			hue = GroupRegistry.GetValue(name, "CapRandomHue") :: number,
			sat = GroupRegistry.GetValue(name, "CapRandomSat") :: number,
			value = GroupRegistry.GetValue(name, "CapRandomValue") :: number,
		},
		CAP_RANDOM_SALT
	)

	local textColors = resolveChannelColors(
		textMode,
		positions,
		GroupRegistry.GetValue(name, "TextColor") :: Color3,
		GroupRegistry.GetValue(name, "TextGradientAxis") :: string,
		GroupRegistry.GetValue(name, "TextGradientColor") :: Color3,
		{
			hue = GroupRegistry.GetValue(name, "TextRandomHue") :: number,
			sat = GroupRegistry.GetValue(name, "TextRandomSat") :: number,
			value = GroupRegistry.GetValue(name, "TextRandomValue") :: number,
		},
		TEXT_RANDOM_SALT
	)

	-- Font/stroke don't depend on membership like a gradient does: same value
	-- for every member, computed once outside the loop.
	local font = FontPresets.Resolve(GroupRegistry.GetValue(name, "FontName") :: string)
	local strokeEnabled = GroupRegistry.GetValue(name, "StrokeEnabled") :: boolean
	local strokeColor = GroupRegistry.GetValue(name, "StrokeColor") :: Color3
	local strokeSize = GroupRegistry.GetValue(name, "StrokeSize") :: number
	local maxDistance = GroupRegistry.GetValue(name, "LabelMaxDistance") :: number

	-- Cap colour, label and outline in ONE walk: this used to be two passes
	-- over the same list, which is two chunked walks for no benefit.
	return Chunker.Each(caps, function(cap, index)
		cap.Color = capColors[index]

		local gui, label, stroke = guiLabelAndStrokeOf(cap)
		if gui then
			gui.MaxDistance = maxDistance
		end
		if label then
			label.TextColor3 = textColors[index]
			label.FontFace = font
		end
		if stroke then
			stroke.Enabled = strokeEnabled
			stroke.Color = strokeColor
			stroke.Thickness = strokeSize
		end
	end, shouldAbort)
end

-- onlyKeys: restyle just those members instead of the whole group. The caller
-- passes it after a paint stroke, when recolouring everything would be
-- wasted work — a full pass is O(members), so a one-key stroke on a large
-- group cost as much as rebuilding the group.
--
-- It is only honoured when the group's look does NOT depend on its
-- membership. A gradient spans the bounding box of its members, so adding a
-- key moves the ends and every member has to be recomputed; that case
-- silently upgrades back to the full pass rather than leaving a stale
-- gradient behind.
function GroupStyler.Apply(name: string, onlyKeys: { Model }?, shouldAbort: (() -> boolean)?): boolean
	local capMode = GroupRegistry.GetValue(name, "CapColorMode") :: string
	local textMode = GroupRegistry.GetValue(name, "TextColorMode") :: string
	local membershipMatters = capMode == "Gradient" or textMode == "Gradient"

	if onlyKeys and not membershipMatters then
		return styleMembers(name, onlyKeys, nil, shouldAbort)
	end

	-- Full pass: hand the raw tagged list over and let the chunked gather do
	-- the group filter, rather than building the member array first.
	return styleMembers(name, CollectionService:GetTagged(Constants.TAG_KEY), name, shouldAbort)
end

-- Restyles every group at once. Used after a bulk change or an erase stroke, when
-- we do not know which groups were touched.
--
-- Goes through one bucketed walk rather than Apply per group: resolving each
-- group's membership separately rescanned the whole board once per group.
function GroupStyler.ApplyAll(shouldAbort: (() -> boolean)?): boolean
	for name, members in pairs(GroupRegistry.MembersByGroup()) do
		if not styleMembers(name, members, nil, shouldAbort) then return false end
	end
	return true
end

return GroupStyler
]]></ProtectedString>
					<string name="ScriptGuid">{694D3E9D-60E0-4BF5-8082-51964CE4ECAD}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">GroupStyler</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBX5FB0BA446AC243CC9630EB4891FFBB1A">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: given a set of positions and an axis, say what colour
-- each one takes. Pure maths: no Instance touched, no group read.
--
-- The anchors are DERIVED from the members' bounding box, never stored. That's
-- what makes the gradient survive adding keys to the group later: the ends
-- simply move, and everything is recomputed. Baking the colour once would
-- freeze the gradient the day the keyboard grows.

local GradientSolver = {}

local AXES: { [string]: Vector3 } = {
	X = Vector3.xAxis,
	Z = Vector3.zAxis,
}

function GradientSolver.IsAxis(axis: string): boolean
	return AXES[axis] ~= nil
end

-- Returns the 0..1 position of each entry along the axis, in the same order.
-- A group whose members are all at the same coordinate gets 0 everywhere
-- rather than a division by zero.
function GradientSolver.Ratios(positions: { Vector3 }, axis: string): { number }
	local direction = AXES[axis]
	local ratios: { number } = {}
	if not direction or #positions == 0 then
		for index = 1, #positions do
			ratios[index] = 0
		end
		return ratios
	end

	local minimum = math.huge
	local maximum = -math.huge
	local projections: { number } = {}

	for index, position in ipairs(positions) do
		local projection = position:Dot(direction)
		projections[index] = projection
		minimum = math.min(minimum, projection)
		maximum = math.max(maximum, projection)
	end

	local span = maximum - minimum
	for index, projection in ipairs(projections) do
		ratios[index] = span > 1e-4 and (projection - minimum) / span or 0
	end

	return ratios
end

function GradientSolver.Colors(positions: { Vector3 }, axis: string, from: Color3, to: Color3): { Color3 }
	local colors: { Color3 } = {}
	for index, ratio in ipairs(GradientSolver.Ratios(positions, axis)) do
		colors[index] = from:Lerp(to, ratio)
	end
	return colors
end

return GradientSolver
]]></ProtectedString>
					<string name="ScriptGuid">{17B051CF-103C-4BC6-B4E0-711F9BA30F7B}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">GradientSolver</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBX04483486B21D4F3E8723E7027DD40FB1">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: the list of valid color modes, shared between the
-- Panel (cycling button) and GroupStyler (branching on the stored value) so
-- neither hardcodes the names independently.
--
-- A mode is reusable across color CHANNELS (cap, text, later others): each
-- channel picks its own mode independently.

local ColorModes = {}

local MODES = { "Solid", "Gradient", "Random" }

ColorModes.DEFAULT_NAME = MODES[1]

function ColorModes.List(): { string }
	return MODES
end

return ColorModes
]]></ProtectedString>
					<string name="ScriptGuid">{07FBE979-A58F-4846-93FA-297EF67F128A}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">ColorModes</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBX85810BCC201F4F758884673067A3F04B">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: turn a set of positions into per-key colour
-- VARIATIONS around a base colour. Pure maths, no Instance touched.
--
-- Replaces the old "random hue, fixed saturation, one brightness knob": that
-- could only produce confetti. The model here is a base colour plus three
-- amplitudes (hue / saturation / value), which covers the whole spread with
-- one set of controls:
--   * small hue range          -> shades of one colour
--   * full hue range           -> fully multicoloured
--   * pastel base colour       -> pastel keys, whatever the ranges
-- So there is deliberately NO dedicated "pastel" setting: a pastel result is
-- a pastel base, picked in the colour picker like any other.
--
-- The variation is DETERMINISTIC (hashed from position), not math.random():
-- GroupStyler.Apply runs again every time a single key joins the group, and
-- re-rolling would reshuffle every OTHER member's colour on each stroke.

local RandomColor = {}

local function hash01(x: number): number
	local n = math.sin(x) * 43758.5453
	return n - math.floor(n)
end

-- Reflects off the 0..1 borders instead of clamping. Clamping would pile
-- every out-of-range key onto pure black or pure white, so a light base with
-- a wide value range came out as a block of identical white caps.
local function fold(value: number): number
	value = math.abs(value)
	value = value % 2
	if value > 1 then
		value = 2 - value
	end
	return value
end

-- hueRange/satRange/valueRange: amplitudes in 0..1. Hue is a full turn at 1
-- (so 1 means "any colour"), saturation and value are +/- that much around
-- the base. salt shifts the hash so two channels (cap vs text) fed the SAME
-- positions don't land on the same variation.
function RandomColor.Colors(
	positions: { Vector3 },
	base: Color3,
	hueRange: number,
	satRange: number,
	valueRange: number,
	salt: number
): { Color3 }
	local baseHue, baseSat, baseValue = base:ToHSV()
	local colors: { Color3 } = {}

	for index, position in ipairs(positions) do
		local seed = position.X * 12.9898 + position.Y * 78.233 + position.Z * 37.719 + salt

		local hue = (baseHue + (hash01(seed) - 0.5) * hueRange) % 1
		local saturation = fold(baseSat + (hash01(seed + 91.7) - 0.5) * 2 * satRange)
		-- Value floored just above black: a key that renders pure black reads
		-- as a bug rather than as a dark variation.
		local value = math.max(fold(baseValue + (hash01(seed + 157.3) - 0.5) * 2 * valueRange), 0.04)

		colors[index] = Color3.fromHSV(hue, saturation, value)
	end

	return colors
end

return RandomColor
]]></ProtectedString>
					<string name="ScriptGuid">{83654BF8-EECB-491D-A795-ED5F7D51DA33}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">RandomColor</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBXF23F3A54AB514A9F9B13E830C8DAC1D5">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: make sure a group gets restyled soon, once, and
-- never twice over the same keys at the same time.
--
-- Exists because a slider fires a value change on every frame of a drag, and
-- each one invalidates the whole group's look. Running GroupStyler on every
-- one of them was both the freeze and wasted work: of the hundred values a
-- drag passes through, only the last is ever seen. Requests coalesce into one
-- pending pass per group, and a pass already in flight is abandoned the
-- moment its own group is requested again.

local GroupStyler = require(script.Parent.GroupStyler)

local RestyleQueue = {}

local pending: { [string]: true } = {}
local running = false

local function pump()
	if running then return end
	running = true

	task.spawn(function()
		-- Drains rather than iterating once: a request that lands mid-pass
		-- (the usual case during a drag) has to be picked up without waiting
		-- for another caller to restart the pump.
		while next(pending) do
			local names = {}
			for name in pairs(pending) do
				table.insert(names, name)
			end
			pending = {}

			for _, name in ipairs(names) do
				-- Deliberately NOT aborted when the group goes dirty again
				-- mid-pass, which is the normal case during a drag. Restarting
				-- on every new value livelocks: a pass opens with an
				-- unchunkable GetTagged (~27ms on a 9k board), gets abandoned a
				-- chunk later, and pays that cost again having painted almost
				-- nothing — so the keys never visibly move while the slider is
				-- held. Letting the pass finish and running exactly one more
				-- lap afterwards repaints at a steady few frames per second and
				-- wastes no work, and the coalescing above still collapses the
				-- hundred values in between into that single lap.
				GroupStyler.Apply(name)
			end
		end
		running = false
	end)
end

function RestyleQueue.Request(name: string)
	pending[name] = true
	pump()
end

-- Whether anything is still queued or in flight. Callers that have to close
-- an undo recording need it: committing before the restyle lands would leave
-- the colours it writes outside the entry that owns them.
function RestyleQueue.IsBusy(): boolean
	return running or next(pending) ~= nil
end

return RestyleQueue
]]></ProtectedString>
					<string name="ScriptGuid">{F420AFBA-982C-470F-9A58-9D980BAEA547}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">RestyleQueue</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
		</Item>
		<Item class="Folder" referent="RBXC48AFEF9FA894F0DA96B65FBE3704E50">
			<Properties>
				<BinaryString name="AttributesSerialize"></BinaryString>
				<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
				<bool name="DefinesCapabilities">false</bool>
				<string name="Name">UI</string>
				<int64 name="SourceAssetId">-1</int64>
				<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
			</Properties>
			<Item class="ModuleScript" referent="RBXB5681CE3ECA441F6A83965B05F4CE479">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: the plugin's dock widget — its shell, its layout,
-- and the composition of the sections that fill it. Knows nothing about
-- placement, groups or colours: it exposes callbacks that Main wires up.
--
-- Layout, in order of how often a control is touched:
--   * BRUSH ON/OFF, pinned at the top, never scrolls. It is the feature.
--   * the group selector, pinned right under it: everything below is scoped
--     to it, and that has to be visible without scrolling back up.
--   * the tool settings, then the group's style in collapsible sections.
--   * a status line pinned at the bottom.
--
-- Anything that floats (dropdown lists, colour pickers) lives in a single
-- overlay frame so only one can be open and an outside click dismisses it.

local Components = script.Parent.Components
local Theme = require(Components.Theme)
local Row = require(Components.Row)
local Section = require(Components.Section)
local Popup = require(Components.Popup)
local LinkButton = require(Components.LinkButton)
local DragCapture = require(Components.DragCapture)

local Sections = script.Parent.Sections
local GroupSection = require(Sections.GroupSection)
local ToolSection = require(Sections.ToolSection)
local ColorChannel = require(Sections.ColorChannel)
local TextStyleSection = require(Sections.TextStyleSection)
local SoundSection = require(Sections.SoundSection)
local AnimationSection = require(Sections.AnimationSection)
local OthersSection = require(Sections.OthersSection)

local Panel = {}
Panel.__index = Panel

local Constants = require(script.Parent.Parent.Constants)

local WIDGET_ID = "KeyCapperPanel"
-- The brush bar shares its line with the Discord button now, so it gives up
-- both a little height and the width of that square. It stays by far the
-- largest control in the header, which is the point — it just no longer
-- needs to be the ONLY thing on its line to say so.
local BRUSH_HEIGHT = 34
local LINK_SIZE = BRUSH_HEIGHT
local STATUS_HEIGHT = 18
-- Brush bar + group row + the padding around them: where the scroll starts.
local HEADER_HEIGHT = Theme.PADDING + BRUSH_HEIGHT + Theme.GAP + Theme.ROW_HEIGHT + Theme.PADDING

function Panel.new(pluginInstance: Plugin)
	local self = setmetatable({}, Panel)

	-- Callbacks. Main assigns these; the Panel never calls into logic itself.
	self.OnBrushToggled = nil :: ((boolean) -> ())?
	self.OnToolChanged = nil :: ((string) -> ())?
	self.OnSizeChanged = nil :: ((number) -> ())?
	self.OnRotationChanged = nil :: ((number) -> ())?
	self.OnEraseScopeChanged = nil :: ((string) -> ())?
	self.OnLabelModeChanged = nil :: ((string) -> ())?
	self.OnLabelChanged = nil :: ((string) -> ())?
	self.OnCharsetChanged = nil :: ((string) -> ())?
	self.OnGroupChanged = nil :: ((string) -> ())?
	self.OnGroupDeleted = nil :: ((string) -> ())?
	-- One callback for every group attribute: the Panel does not know what a
	-- Color or a SoundIds means, it just forwards the edit.
	self.OnGroupValueChanged = nil :: ((string, any) -> ())?
	-- Not a group edit: these flip game-side settings shared by every group
	-- (RuntimeSource.Settings), not a group's own Configuration.
	self.OnSettingChanged = nil :: ((string, any) -> ())?

	self.brushActive = false
	self.brushHovered = false

	-- 320 wide: at 240 the two-column rows truncate their labels and a
	-- three-option segmented control stops being readable.
	local info = DockWidgetPluginGuiInfo.new(
		Enum.InitialDockState.Float,
		false, -- not enabled on creation
		false, -- don't override the restored state
		320, 560, -- floating size
		300, 420 -- minimum size
	)

	local widget = pluginInstance:CreateDockWidgetPluginGui(WIDGET_ID, info)
	widget.Title = "KeyCapper"
	widget.Name = WIDGET_ID
	widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
	self.widget = widget

	local background = Instance.new("Frame")
	background.Size = UDim2.fromScale(1, 1)
	background.BackgroundColor3 = Theme.Color(Enum.StudioStyleGuideColor.MainBackground)
	background.BorderSizePixel = 0
	background.Parent = widget

	-- Built first so every control that opens a popup gets it at construction.
	-- Order of creation does not affect stacking: the widget is in Sibling
	-- ZIndex mode and the overlay sits at ZIndex 10.
	self:_buildOverlay(background)
	self:_buildHeader(background)
	self:_buildBody(background)
	self:_buildStatus(background)

	-- Closing the widget must never leave the brush running in the background.
	widget:GetPropertyChangedSignal("Enabled"):Connect(function()
		if not widget.Enabled then
			DragCapture.Release()
			-- Forced: a persistent popup (the colour picker) ignores an outside
			-- click, but the widget closing is a tear-down, not a dismissal.
			Popup.CloseAny(true)
		end
		if not widget.Enabled and self.brushActive then
			self:SetBrushActive(false)
			if self.OnBrushToggled then
				self.OnBrushToggled(false)
			end
		end
	end)

	return self
end

function Panel:_buildHeader(background: Frame)
	local header = Instance.new("Frame")
	header.Size = UDim2.new(1, 0, 0, HEADER_HEIGHT)
	-- A faintly raised card rather than flat-on-background: separates the
	-- "always visible" controls from the scrolling body below, on top of
	-- the hairline added at the end of this function.
	-- Same card treatment as a Section, for the same reason: in the light
	-- theme Studio's Item IS the page white, so a translucent header sat on
	-- an identical white and the pinned controls read as loose text at the
	-- top of the scroll rather than as a bar of their own.
	local headerFill, headerTransparency = Theme.CardFill()
	header.BackgroundColor3 = headerFill
	header.BackgroundTransparency = headerTransparency
	header.BorderSizePixel = 0
	header.ZIndex = 2
	header.Parent = background

	local pad = Instance.new("UIPadding")
	pad.PaddingTop = UDim.new(0, Theme.PADDING)
	pad.PaddingLeft = UDim.new(0, Theme.PADDING)
	pad.PaddingRight = UDim.new(0, Theme.PADDING)
	pad.Parent = header

	local layout = Instance.new("UIListLayout")
	layout.Padding = UDim.new(0, Theme.GAP)
	layout.SortOrder = Enum.SortOrder.LayoutOrder
	layout.Parent = header

	-- Brush and Discord share one line: [ BRUSH: OFF ][D]
	local topRow = Instance.new("Frame")
	topRow.Name = "TopRow"
	topRow.Size = UDim2.new(1, 0, 0, BRUSH_HEIGHT)
	topRow.BackgroundTransparency = 1
	topRow.LayoutOrder = 1
	topRow.Parent = header

	-- Taller than anything else, colour-coded, and with a subtle vertical
	-- gradient + white icon chip: this is the one control the user reaches
	-- for constantly, it must not read as just another button.
	local brushButton = Theme.Button("", topRow)
	brushButton.Size = UDim2.new(1, -(LINK_SIZE + Theme.GAP), 1, 0)
	brushButton.LayoutOrder = 1
	-- THE reason every previous attempt to colour this button failed.
	-- AutoButtonColor caches BackgroundColor3 when the cursor ENTERS the
	-- button, and writes that cached value back when it leaves. The toggle is
	-- clicked with the cursor on the button, so SetBrushActive's fill was
	-- being applied mid-hover and then reverted to Studio's default button
	-- grey the moment the mouse moved away — a grey that is within a few
	-- percent of the header card behind it. The colour in the source was
	-- always correct; it just never survived the first hover.
	-- Hover feedback is done by hand below instead, on the stroke, which
	-- AutoButtonColor never touched anyway.
	brushButton.AutoButtonColor = false
	brushButton.MouseEnter:Connect(function()
		self.brushHovered = true
		self:_refreshBrushGradient(self.brushActive)
	end)
	brushButton.MouseLeave:Connect(function()
		self.brushHovered = false
		self:_refreshBrushGradient(self.brushActive)
	end)
	brushButton.Activated:Connect(function()
		self:SetBrushActive(not self.brushActive)
		if self.OnBrushToggled then
			self.OnBrushToggled(self.brushActive)
		end
	end)
	self.brushButton = brushButton

	local brushGradient = Instance.new("UIGradient")
	brushGradient.Rotation = 90
	brushGradient.Parent = brushButton
	self.brushGradient = brushGradient

	-- Kept visible (not just a faint highlight) when OFF: it is the only
	-- thing that reads as "button" once the fill matches the header card.
	local brushStroke = Instance.new("UIStroke")
	brushStroke.Color = Color3.new(1, 1, 1)
	brushStroke.Transparency = 0.85
	brushStroke.Thickness = 1
	brushStroke.Parent = brushButton
	self.brushStroke = brushStroke

	-- Icon + text, centred as a pair rather than the button's own centred
	-- text: this is the one control every other row in the panel takes its
	-- cue from, it earns a slightly custom layout.
	local brushInner = Instance.new("Frame")
	brushInner.Size = UDim2.fromScale(1, 1)
	brushInner.BackgroundTransparency = 1
	brushInner.Parent = brushButton
	local brushLayout = Instance.new("UIListLayout")
	brushLayout.FillDirection = Enum.FillDirection.Horizontal
	brushLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
	brushLayout.VerticalAlignment = Enum.VerticalAlignment.Center
	brushLayout.Padding = UDim.new(0, 8)
	brushLayout.Parent = brushInner

	-- White chip (not the accent-tinted one Section/Row use): the button's
	-- own fill IS the accent colour here, so a same-colour chip would
	-- disappear into it.
	local brushChip = Instance.new("Frame")
	brushChip.Size = UDim2.fromOffset(26, 26)
	brushChip.BackgroundColor3 = Color3.new(1, 1, 1)
	brushChip.BackgroundTransparency = 0.82
	brushChip.BorderSizePixel = 0
	brushChip.LayoutOrder = 1
	brushChip.Parent = brushInner
	Theme.Corner(brushChip, 13)

	-- Opts out of Theme.IconTint: this glyph sits on the button's own red/green
	-- fill in either theme, where white is the only colour that reads.
	local brushIcon = Theme.Icon(brushChip, "Brush", 15)
	brushIcon.ImageColor3 = Color3.new(1, 1, 1)
	brushIcon.AnchorPoint = Vector2.new(0.5, 0.5)
	brushIcon.Position = UDim2.fromScale(0.5, 0.5)
	self.brushIcon = brushIcon

	local brushLabel = Theme.Text("BRUSH: OFF", brushInner)
	brushLabel.Size = UDim2.new(0, 0, 1, 0)
	brushLabel.AutomaticSize = Enum.AutomaticSize.X
	brushLabel.LayoutOrder = 2
	brushLabel.Font = Theme.FONT_BOLD
	brushLabel.TextSize = Theme.TEXT_SIZE + 3
	self.brushLabel = brushLabel

	-- In Discord's own brand colour rather than the panel's accent: it points
	-- somewhere outside the plugin, and reading as "not one of the tools" is
	-- exactly what stops it competing with the brush beside it.
	local discord = LinkButton.new(topRow, {
		Icon = "Discord",
		Url = Constants.DISCORD_URL,
		Overlay = self.overlay,
		Color = Theme.DISCORD,
		Tooltip = "KeyCapper Discord",
	})
	discord.Frame.Size = UDim2.fromOffset(LINK_SIZE, LINK_SIZE)
	discord.Frame.Position = UDim2.new(1, -LINK_SIZE, 0, 0)

	-- Owns the whole group line (label, selector, create mode, delete) and
	-- keeps it exactly one row tall in every state, which is what lets
	-- HEADER_HEIGHT above stay a constant.
	self.groupSection = GroupSection.new(header, 2, self.overlay)
	self.groupSection.OnChanged = function(name)
		if self.OnGroupChanged then self.OnGroupChanged(name) end
	end
	-- Creating and picking land on the same callback: Main treats a group as
	-- existing the moment it is named (GroupRegistry.Ensure), so "selected
	-- this group" is the whole of what either gesture means here.
	self.groupSection.OnCreated = function(name)
		if self.OnGroupChanged then self.OnGroupChanged(name) end
	end
	self.groupSection.OnDeleted = function(name)
		if self.OnGroupDeleted then self.OnGroupDeleted(name) end
	end

	-- Hairline between the pinned header and the scrolling body: without it
	-- the two blend into the same flat background.
	--
	-- Parented to the BACKGROUND, not to the header. The header runs a
	-- UIListLayout, which lays out every GuiObject child it has — including
	-- this one, at the LayoutOrder 0 it never set, i.e. FIRST. Its Position of
	-- (0,0,1,0) was overridden and it ate 1px plus a full 8px gap at the top of
	-- the stack, pushing the brush bar and the group row 9px down: the group
	-- row ended at y=85 in an 84px header, hanging a pixel below the header's
	-- own bottom edge. Measured, and the reason "the group row sits too low"
	-- survived every attempt to fix it inside the row itself.
	local separator = Instance.new("Frame")
	separator.Size = UDim2.new(1, 0, 0, 1)
	separator.Position = UDim2.fromOffset(0, HEADER_HEIGHT)
	separator.BackgroundColor3 = Theme.Color(Enum.StudioStyleGuideColor.Separator)
	separator.BorderSizePixel = 0
	separator.ZIndex = 2
	separator.Parent = background

	-- Forces the initial fill/stroke/label to the OFF state: Theme.Button()
	-- leaves brushButton at Studio's default button colour otherwise, and
	-- SetBrushActive is only ever called from a toggle or a deactivation
	-- event afterwards — never on first build. Without this call the button
	-- was invisible against the header on every plugin reload until clicked.
	self:SetBrushActive(false)
end

function Panel:_buildBody(background: Frame)
	local list = Instance.new("ScrollingFrame")
	-- The 2px the padding below takes back on each side is given back here, so
	-- the cards keep the same margin against the panel edge as before.
	list.Size = UDim2.new(1, -(Theme.PADDING - 2) * 2, 1, -HEADER_HEIGHT - STATUS_HEIGHT - Theme.PADDING)
	list.Position = UDim2.fromOffset(Theme.PADDING - 2, HEADER_HEIGHT)
	list.BackgroundTransparency = 1
	list.BorderSizePixel = 0
	list.CanvasSize = UDim2.new()
	list.AutomaticCanvasSize = Enum.AutomaticSize.Y
	list.ScrollBarThickness = 6
	-- The scrollbar must take its 6px OUT of the layout instead of floating on
	-- top of it. Left alone (the default, ScrollBarInset.None) it overlaps the
	-- rightmost 6px of every row, which is exactly where the cards' right-hand
	-- outline is — so that edge still read as cut even once the clipping on the
	-- left was fixed. It was being painted over, not clipped.
	list.VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar
	list.Parent = background

	-- A ScrollingFrame ALWAYS clips its descendants, and a UIStroke is drawn
	-- outside the frame it belongs to. The section cards are a full 1.0 of this
	-- frame's width, so their outline was being shaved off flush against both
	-- edges: the cards looked like they had a top and a bottom line and no
	-- sides. This is the room that outline needs to exist — it is not padding
	-- for looks, and taking it out brings the cut edges straight back.
	local inset = Instance.new("UIPadding")
	inset.PaddingLeft = UDim.new(0, 2)
	inset.PaddingRight = UDim.new(0, 2)
	-- Top is not the 2px the other three sides need for the outline: the first
	-- section sat almost flush against the pinned group row above it, with no
	-- room to read as a separate block. This is that breathing space.
	inset.PaddingTop = UDim.new(0, 10)
	inset.PaddingBottom = UDim.new(0, 2)
	inset.Parent = list

	local layout = Instance.new("UIListLayout")
	layout.Padding = UDim.new(0, Theme.GAP + 1)
	layout.SortOrder = Enum.SortOrder.LayoutOrder
	layout.Parent = list

	local toolBlock = Section.new(list, 1, "Tool")
	self.tool = ToolSection.new(toolBlock.Content, 1)

	-- Says out loud what the sections below are: edits to a GROUP, not to a
	-- key. The whole plugin rests on that distinction.
	local caption = Row.new(list, 2, nil, 16)
	local captionText = Theme.Text("", caption.Content)
	captionText.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)
	captionText.Font = Theme.FONT_BOLD
	self.captionText = captionText

	local capBlock = Section.new(list, 3, "Cap color")
	self.capChannel = ColorChannel.new(capBlock.Content, 1, self.overlay, {
		ModeKey = "CapColorMode",
		SolidKey = "Color",
		AxisKey = "GradientAxis",
		GradientKey = "GradientColor",
		HueKey = "CapRandomHue",
		SatKey = "CapRandomSat",
		ValueKey = "CapRandomValue",
	})

	local textBlock = Section.new(list, 4, "Label", false)
	self.textChannel = ColorChannel.new(textBlock.Content, 1, self.overlay, {
		ModeKey = "TextColorMode",
		SolidKey = "TextColor",
		AxisKey = "TextGradientAxis",
		GradientKey = "TextGradientColor",
		HueKey = "TextRandomHue",
		SatKey = "TextRandomSat",
		ValueKey = "TextRandomValue",
	})
	self.textStyle = TextStyleSection.new(textBlock.Content, ColorChannel.ROW_COUNT + 1, self.overlay)

	local soundBlock = Section.new(list, 5, "Sound", false)
	self.sound = SoundSection.new(soundBlock.Content, 1)

	local feelBlock = Section.new(list, 6, "Feel", false)
	self.animation = AnimationSection.new(feelBlock.Content, 1)

	-- Not scoped to "STYLE OF <group>" above: it lives outside the group
	-- system entirely, so it sits in its own section rather than under it.
	local othersBlock = Section.new(list, 7, "Others", false)
	self.others = OthersSection.new(othersBlock.Content, 1)
	self.others.OnValueChanged = function(key, value)
		if self.OnSettingChanged then
			self.OnSettingChanged(key, value)
		end
	end

	self:_wireSections()
end

-- Every style section speaks the same (attributeKey, value) language, so they
-- all forward to the same callback. Adding a section is one line here.
function Panel:_wireSections()
	local function forward(key: string, value: any)
		if self.OnGroupValueChanged then
			self.OnGroupValueChanged(key, value)
		end
	end

	for _, section in ipairs({ self.capChannel, self.textChannel, self.textStyle, self.sound, self.animation }) do
		section.OnValueChanged = forward
	end

	self.tool.OnToolChanged = function(value)
		if self.OnToolChanged then self.OnToolChanged(value) end
	end
	self.tool.OnSizeChanged = function(value)
		if self.OnSizeChanged then self.OnSizeChanged(value) end
	end
	self.tool.OnRotationChanged = function(steps)
		if self.OnRotationChanged then self.OnRotationChanged(steps) end
	end
	self.tool.OnEraseScopeChanged = function(value)
		if self.OnEraseScopeChanged then self.OnEraseScopeChanged(value) end
	end
	self.tool.OnLabelModeChanged = function(value)
		if self.OnLabelModeChanged then self.OnLabelModeChanged(value) end
	end
	self.tool.OnLabelChanged = function(value)
		if self.OnLabelChanged then self.OnLabelChanged(value) end
	end
	self.tool.OnCharsetChanged = function(value)
		if self.OnCharsetChanged then self.OnCharsetChanged(value) end
	end
end

function Panel:_buildStatus(background: Frame)
	local holder = Instance.new("Frame")
	holder.Size = UDim2.new(1, -Theme.PADDING * 2, 0, STATUS_HEIGHT)
	holder.Position = UDim2.new(0, Theme.PADDING, 1, -STATUS_HEIGHT)
	holder.BackgroundTransparency = 1
	holder.Parent = background

	local status = Theme.Text("", holder)
	status.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)
	status.TextSize = Theme.TEXT_SIZE - 1
	self.status = status
end

-- One shared floating layer: a transparent backdrop that swallows the click
-- that dismisses a popup. DragCapture no longer needs a layer registered
-- here — it tracks drags through UserInputService now, see that module.
function Panel:_buildOverlay(background: Frame)
	local overlay = Instance.new("Frame")
	overlay.Name = "Overlay"
	overlay.Size = UDim2.fromScale(1, 1)
	overlay.BackgroundTransparency = 1
	overlay.Visible = false
	overlay.ZIndex = 10
	overlay.Parent = background
	self.overlay = overlay

	local backdrop = Instance.new("TextButton")
	backdrop.Size = UDim2.fromScale(1, 1)
	backdrop.BackgroundTransparency = 1
	backdrop.Text = ""
	backdrop.AutoButtonColor = false
	backdrop.ZIndex = 10
	backdrop.Parent = overlay
	backdrop.Activated:Connect(function()
		Popup.CloseAny()
	end)
end

-- The OFF state is deliberately a COLOUR of its own, not the Studio button
-- colour. That default sits within a few percent of the header card behind
-- it, so the control disappeared into the panel exactly when it most needed
-- to be found — it is how the plugin is switched on, and it spends most of
-- its life OFF. Red (Theme.DANGER) reads unmistakably as "off/inactive" and
-- stays unmistakably distinct from the green ON, unlike the earlier slate
-- which was too close to the header card's own tint to notice.
local BRUSH_OFF_FILL = Theme.DANGER

function Panel:_refreshBrushGradient(active: boolean)
	-- UIGradient.Transparency is the BUTTON's transparency along the gradient,
	-- NOT "how strongly the gradient tints". Setting it to 1 for the OFF state
	-- — meaning to say "no gradient here" — rendered the fill fully see-through
	-- instead, so OFF showed the panel behind it whatever BRUSH_OFF_FILL was
	-- set to. That is the bug that made this button look uncoloured through
	-- every attempt to recolour it: the colour was applied, then erased.
	--
	-- It stays at 0 in both states now. "No gradient" is expressed the only
	-- way it can be: a Color of pure white, which multiplies BackgroundColor3
	-- by 1 and leaves the fill exactly as SetBrushActive set it.
	self.brushGradient.Transparency = NumberSequence.new(0)
	self.brushGradient.Color = active
		and ColorSequence.new(Color3.fromRGB(88, 198, 138), Color3.fromRGB(48, 148, 98))
		-- Same subtle top-lit shading as ON, expressed as a multiplier so it
		-- rides on top of the red rather than replacing it.
		or ColorSequence.new(Color3.new(1, 1, 1), Color3.fromRGB(215, 215, 215))
	-- A bright accent border on top of the fill: two independent cues that
	-- this is a control, so it survives whatever the user's Studio theme does.
	self.brushStroke.Color = Color3.new(1, 1, 1)
	self.brushStroke.Transparency = active and 0.85 or 0.1
	self.brushStroke.Thickness = active and 1 or 2
	-- Hover: brighten the outline rather than the fill. The fill is the state
	-- (red = off, green = on) and must not wobble.
	if self.brushHovered then
		self.brushStroke.Transparency = math.max(self.brushStroke.Transparency - 0.35, 0)
	end
end

function Panel:SetBrushActive(active: boolean)
	self.brushActive = active
	self.brushLabel.Text = active and "BRUSH: ON" or "BRUSH: OFF"
	self.brushButton.BackgroundColor3 = active and Theme.OK or BRUSH_OFF_FILL
	-- White in both states: ButtonText is a dimmed grey meant for flat Studio
	-- buttons and reads as disabled on a filled one.
	self.brushLabel.TextColor3 = Theme.ACCENT_TEXT
	self:_refreshBrushGradient(active)
end

-- entries: { { Value, Detail, Color } }, built by Main from the registry —
-- the Panel never reads the registry itself, it only renders what it is given.
function Panel:SetGroups(entries: { any }, current: string)
	self.groupSection:SetEntries(entries, current)
	self.captionText.Text = string.format("STYLE OF \u{00AB} %s \u{00BB}", current)
end

-- Loads a group's stored values into the fields. Called when the current group
-- changes, so the panel always shows the group being edited, never stale
-- values. Takes a plain {attributeKey -> value} table (built by Main straight
-- from GroupRegistry) rather than positional args: the attribute set keeps
-- growing, and a 12+ argument positional list was already unreadable.
function Panel:ShowGroupValues(values: { [string]: any })
	self.capChannel:Show(values)
	self.textChannel:Show(values)
	self.textStyle:Show(values)
	self.sound:Show(values)
	self.animation:Show(values)
end

-- Not part of ShowGroupValues: this reflects plugin-wide settings, not
-- something that changes when the current group does.
function Panel:SetOthers(values: { [string]: any })
	self.others:Show(values)
end

-- Rotation can come from the R key, not just from the panel.
function Panel:SetRotation(steps: number)
	self.tool:SetRotation(steps)
end

function Panel:SetStatus(text: string)
	self.status.Text = text
end

function Panel:Toggle()
	self.widget.Enabled = not self.widget.Enabled
end

return Panel
]]></ProtectedString>
					<string name="ScriptGuid">{EF7430A2-5AFE-4C67-9460-0437DD29ABB0}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">Panel</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="Folder" referent="RBX733F194E85964621994DBAF2E8693E53">
				<Properties>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">Components</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
				<Item class="ModuleScript" referent="RBXFD5CD0AA23D4490885CD2C9F6FA19E41">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: the shared look. Every metric, colour and
-- "make a styled primitive" helper the other components build on.
-- No layout logic, no behaviour, no state.
--
-- Colours come from the Studio theme wherever one fits, so the panel follows
-- the user's dark/light setting instead of hardcoding a palette.

local Theme = {}

Theme.ROW_HEIGHT = 26
Theme.PADDING = 8
Theme.GAP = 8
-- Rows are two columns (label | control). Anything below ~0.40 truncates the
-- longer labels at the panel's 320px width.
Theme.LABEL_WIDTH = 0.42
Theme.CORNER = 8
Theme.TEXT_SIZE = 14
Theme.FONT = Enum.Font.Gotham
Theme.FONT_BOLD = Enum.Font.GothamBold

Theme.ACCENT = Color3.fromRGB(64, 150, 255)
Theme.ACCENT_TEXT = Color3.new(1, 1, 1)
Theme.DANGER = Color3.fromRGB(205, 78, 66)
Theme.OK = Color3.fromRGB(58, 168, 110)

function Theme.Color(item: Enum.StudioStyleGuideColor, modifier: Enum.StudioStyleGuideModifier?): Color3
	return settings().Studio.Theme:GetColor(item, modifier)
end

-- Studio's own Border colour sits within a few percent of MainBackground in
-- the dark theme, so anything outlined with it has no visible edge at all.
-- Deriving the outline from the surface's OWN fill instead guarantees
-- contrast in either Studio theme: lighten a dark fill, darken a light one.
function Theme.Outline(fill: Color3): Color3
	local _, _, value = fill:ToHSV()
	return value < 0.5
		and fill:Lerp(Color3.new(1, 1, 1), 0.45)
		or fill:Lerp(Color3.new(0, 0, 0), 0.35)
end

-- Nudges a fill away from the surface behind it, in whichever direction the
-- theme makes visible. Used for floating surfaces, which must never read as
-- part of the panel they cover.
function Theme.Elevate(fill: Color3, amount: number?): Color3
	return fill:Lerp(Theme.Outline(fill), amount or 0.18)
end

-- Studio's LIGHT theme returns pure white (255,255,255) for MainBackground,
-- Item, Button AND InputFieldBackground alike — measured, not guessed. Every
-- surface in the panel therefore came out the same white as the page behind
-- it: no card edges, no field wells, and controls that read as plain text
-- rather than as things you can click. The DARK theme does separate those
-- four (25/25/53/40), so it needs none of this.
--
-- Hence the ladder below: in the light theme the panel provides its own
-- surface tones, and in the dark theme every helper hands back exactly the
-- Studio colour the code used before, so the dark look is untouched.
function Theme.IsLight(): boolean
	local _, _, value = Theme.Color(Enum.StudioStyleGuideColor.MainBackground):ToHSV()
	return value > 0.5
end

-- A raised card (Section). Returns fill AND transparency: the dark theme's
-- card is a translucent Item, the light theme's is an opaque off-white.
function Theme.CardFill(): (Color3, number)
	if Theme.IsLight() then
		return Color3.fromRGB(246, 247, 250), 0
	end
	return Theme.Color(Enum.StudioStyleGuideColor.Item), 0.55
end

-- A pressable surface (dropdowns, small action buttons).
function Theme.ButtonFill(): Color3
	if Theme.IsLight() then
		return Color3.fromRGB(237, 239, 245)
	end
	return Theme.Color(Enum.StudioStyleGuideColor.Button)
end

-- An input well (text boxes, slider tracks, the segmented pill, toggles):
-- deliberately a step deeper than ButtonFill, so "type/drag in me" and
-- "press me" don't read as the same surface.
function Theme.FieldFill(): Color3
	if Theme.IsLight() then
		return Color3.fromRGB(226, 229, 237)
	end
	return Theme.Color(Enum.StudioStyleGuideColor.InputFieldBackground)
end

-- The hairline that makes a surface an object. Studio's Border is a usable
-- grey in the light theme (188,190,200) and near-invisible in the dark one,
-- so the light theme leans on it and the dark theme keeps its old faintness.
function Theme.Edge(gui: GuiObject, darkTransparency: number?): UIStroke
	local stroke = Instance.new("UIStroke")
	stroke.Color = Theme.Color(Enum.StudioStyleGuideColor.Border)
	stroke.Thickness = 1
	-- Border is a strong enough grey in the light theme that a fully opaque
	-- hairline reads as a heavy box; softened so it delimits without shouting.
	stroke.Transparency = Theme.IsLight() and 0.3 or (darkTransparency or 0.6)
	stroke.Parent = gui
	return stroke
end

-- Reuses an existing UICorner rather than adding a second one. A GuiObject
-- accepts any number of them but obeys only the first, so calling this twice
-- on the same object silently kept the FIRST radius and dropped the caller's.
-- That is why the selected segment of a Segmented control looked squarer than
-- the pill around it: Theme.Button had already cornered it at 8, and the 11 it
-- asked for afterwards was never applied.
function Theme.Corner(gui: GuiObject, radius: number?): UICorner
	local corner = gui:FindFirstChildOfClass("UICorner") or Instance.new("UICorner")
	corner.CornerRadius = UDim.new(0, radius or Theme.CORNER)
	corner.Parent = gui
	return corner
end

-- A plain text line. Used for row labels, section titles and the status line.
function Theme.Text(text: string, parent: Instance): TextLabel
	local label = Instance.new("TextLabel")
	label.Text = text
	label.BackgroundTransparency = 1
	label.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.MainText)
	label.TextXAlignment = Enum.TextXAlignment.Left
	label.TextYAlignment = Enum.TextYAlignment.Center
	label.Font = Theme.FONT
	label.TextSize = Theme.TEXT_SIZE
	label.TextTruncate = Enum.TextTruncate.AtEnd
	label.Size = UDim2.fromScale(1, 1)
	label.Parent = parent
	return label
end

function Theme.Button(text: string, parent: Instance): TextButton
	local button = Instance.new("TextButton")
	button.Text = text
	button.Size = UDim2.new(1, 0, 1, 0)
	button.BackgroundColor3 = Theme.ButtonFill()
	button.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.ButtonText)
	button.BorderSizePixel = 0
	button.AutoButtonColor = true
	button.Font = Theme.FONT
	button.TextSize = Theme.TEXT_SIZE
	button.Parent = parent
	Theme.Corner(button)
	return button
end

-- A bare container that grows with its children. The building block of every
-- vertical stack in the panel (sections, popups, the root list).
function Theme.Stack(parent: Instance, order: number?): (Frame, UIListLayout)
	local frame = Instance.new("Frame")
	frame.BackgroundTransparency = 1
	frame.BorderSizePixel = 0
	frame.Size = UDim2.new(1, 0, 0, 0)
	frame.AutomaticSize = Enum.AutomaticSize.Y
	frame.LayoutOrder = order or 1
	frame.Parent = parent

	local layout = Instance.new("UIListLayout")
	layout.Padding = UDim.new(0, Theme.GAP)
	layout.SortOrder = Enum.SortOrder.LayoutOrder
	layout.Parent = frame

	return frame, layout
end

-- Walks up to the widget's root frame. Components that track a drag need a
-- surface that keeps receiving mouse movement once the cursor leaves the
-- small control it started on.
function Theme.SurfaceOf(gui: Instance): GuiObject?
	local node: Instance? = gui
	while node do
		local parent = node.Parent
		if parent and parent:IsA("PluginGui") and node:IsA("GuiObject") then
			return node
		end
		node = parent
	end
	return nil
end

function Theme.TextBox(text: string, parent: Instance): TextBox
	local box = Instance.new("TextBox")
	box.Text = text
	box.ClearTextOnFocus = false
	box.Size = UDim2.fromScale(1, 1)
	box.BackgroundColor3 = Theme.FieldFill()
	box.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.MainText)
	box.BorderSizePixel = 0
	box.Font = Theme.FONT
	box.TextSize = Theme.TEXT_SIZE
	-- Left-aligned with a small inset: a centred value in a full-width field
	-- reads as a button label rather than as something editable.
	box.TextXAlignment = Enum.TextXAlignment.Left
	box.Parent = parent
	Theme.Corner(box)
	Theme.Edge(box, 1)

	local pad = Instance.new("UIPadding")
	pad.PaddingLeft = UDim.new(0, 6)
	pad.PaddingRight = UDim.new(0, 6)
	pad.Parent = box
	return box
end

-- Brand colours, for the handful of controls that represent something
-- outside the plugin and so cannot take their colour from the Studio theme.
Theme.DISCORD = Color3.fromRGB(88, 101, 242)

-- Icon slugs picked by Seb, one per icon slot in the panel. Chosen in
-- StarterGui.KeyCapperUIPreview (a throwaway instance tree used only to try
-- icons live) and copied here once locked in; that preview is not read at
-- runtime, this table is the only source of truth.
--
-- Sourced from Font Awesome 5 (see CREDITS.md at the repo root).
--
-- Discord's own logo, imported by Seb from the official brand assets
-- (converted from webp to preserve transparency — see CREDITS.md).
-- NOT Font Awesome like the rest of this table: everything else is chosen
-- glyph-by-glyph to fit the panel's own vocabulary, but a brand mark has to
-- be the real logo or it does not read as "Discord" at all.
local ICONS: { [string]: string } = {
	Brush = "rbxassetid://109497141679277",
	Group = "rbxassetid://79689480999850",
	Tool = "rbxassetid://116809567292796",
	Paint = "rbxassetid://119512564599460",
	Erase = "rbxassetid://106185541901173",
	["Cap color"] = "rbxassetid://105253450798448",
	Label = "rbxassetid://86256816009801",
	Sound = "rbxassetid://88707291849063",
	AddSound = "rbxassetid://137367508279349",
	Feel = "rbxassetid://138515805840658",
	Others = "rbxassetid://106070793531050",
	Discord = "rbxassetid://137626841590880",
}
-- Roblox's built-in "missing image" checker: shown for any slug not yet in
-- ICONS above, so a forgotten icon is obviously wrong rather than invisible.
local PLACEHOLDER = "rbxasset://textures/ui/GuiImagePlaceholder.png"

-- The glyphs are white artwork, which only works on a dark panel. In the
-- light theme they have to be inked the other way round or they wash out
-- against the pale chips they sit in. White in the dark theme is a no-op tint
-- (a multiply by 1), so this leaves that theme exactly as it was.
--
-- Two callers deliberately opt out by overriding ImageColor3 afterwards: the
-- brush icon (white on its own red/green fill) and the Discord mark (a real
-- brand logo, which must not be re-inked at all).
function Theme.IconTint(): Color3
	if Theme.IsLight() then
		return Color3.fromRGB(38, 40, 48)
	end
	return Color3.new(1, 1, 1)
end

-- name is a slug into ICONS (e.g. "Brush", "Cap color"), not a file path.
function Theme.Icon(parent: Instance, name: string, size: number?): ImageLabel
	local icon = Instance.new("ImageLabel")
	icon.Name = "Icon_" .. name
	icon.Image = ICONS[name] or PLACEHOLDER
	icon.ImageColor3 = Theme.IconTint()
	icon.ScaleType = Enum.ScaleType.Fit
	icon.BackgroundTransparency = 1
	icon.Size = UDim2.fromOffset(size or 16, size or 16)
	icon.Parent = parent
	return icon
end

-- Icon inside a soft rounded, accent-tinted "chip" rather than a bare
-- floating square: reads as a deliberate badge (section/group identity)
-- instead of a stray icon. Segmented options (Paint/Erase) intentionally
-- use plain Theme.Icon, not this — a chip per segment would be noisy.
function Theme.IconChip(parent: Instance, name: string, chipSize: number, iconSize: number): Frame
	local chip = Instance.new("Frame")
	chip.Name = "Chip_" .. name
	chip.Size = UDim2.fromOffset(chipSize, chipSize)
	chip.BackgroundColor3 = Theme.ACCENT
	chip.BackgroundTransparency = 0.82
	chip.BorderSizePixel = 0
	chip.Parent = parent
	Theme.Corner(chip, chipSize / 2)

	local icon = Theme.Icon(chip, name, iconSize)
	icon.AnchorPoint = Vector2.new(0.5, 0.5)
	icon.Position = UDim2.fromScale(0.5, 0.5)
	return chip
end

return Theme
]]></ProtectedString>
						<string name="ScriptGuid">{6F6C6DEB-20BF-409E-AE49-5C1A439C5A59}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">Theme</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBXE3E06D10BAA34155A2091AEEB0EE389F">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: one line of the panel, laid out as
-- [ label | control ]. Owns no control of its own — it hands back an empty
-- Content frame the caller fills.
--
-- Two columns rather than label-above-field: halves the panel's height, which
-- is what makes the group style readable without endless scrolling.

local Theme = require(script.Parent.Theme)

local Row = {}
Row.__index = Row

-- labelText nil => the control spans the full width (used for buttons and
-- segmented controls that read as their own label). withIcon adds an icon
-- chip before the label, for the handful of rows worth calling out visually
-- (Group, mainly) — most rows don't need one.
function Row.new(parent: Instance, order: number, labelText: string?, height: number?, withIcon: boolean?)
	local self = setmetatable({}, Row)

	local frame = Instance.new("Frame")
	frame.Size = UDim2.new(1, 0, 0, height or Theme.ROW_HEIGHT)
	frame.BackgroundTransparency = 1
	frame.BorderSizePixel = 0
	frame.LayoutOrder = order
	frame.Parent = parent
	self.Frame = frame

	local content = Instance.new("Frame")
	content.BackgroundTransparency = 1
	content.BorderSizePixel = 0
	content.Parent = frame
	self.Content = content

	if labelText then
		local holder = Instance.new("Frame")
		holder.BackgroundTransparency = 1
		holder.Size = UDim2.new(Theme.LABEL_WIDTH, -Theme.GAP, 1, 0)
		holder.Parent = frame

		if withIcon then
			local holderLayout = Instance.new("UIListLayout")
			holderLayout.FillDirection = Enum.FillDirection.Horizontal
			holderLayout.VerticalAlignment = Enum.VerticalAlignment.Center
			holderLayout.Padding = UDim.new(0, 6)
			holderLayout.SortOrder = Enum.SortOrder.LayoutOrder
			holderLayout.Parent = holder

			local chip = Theme.IconChip(holder, labelText, 20, 12)
			chip.LayoutOrder = 1
			self.Icon = chip

			local label = Theme.Text(labelText, holder)
			label.Size = UDim2.new(1, -26, 1, 0)
			label.LayoutOrder = 2
			label.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)
			self.Label = label
		else
			local label = Theme.Text(labelText, holder)
			label.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)
			self.Label = label
		end

		content.Position = UDim2.fromScale(Theme.LABEL_WIDTH, 0)
		content.Size = UDim2.new(1 - Theme.LABEL_WIDTH, 0, 1, 0)
	else
		content.Size = UDim2.fromScale(1, 1)
	end

	return self
end

function Row:SetVisible(visible: boolean)
	self.Frame.Visible = visible
end

return Row
]]></ProtectedString>
						<string name="ScriptGuid">{C49C6884-C38F-4067-B384-D18A296C4393}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">Row</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX3B81DE06B8004E33AB7455CC90C1968E">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: a titled, collapsible "card". Groups the rows that
-- belong together (Cap / Text / Sound / Animation) so the panel can be
-- skimmed instead of read.
--
-- Styled as a card (subtle stroke + raised background) rather than a flat
-- unbordered block: on a single dark background colour, a plain block reads
-- as "part of the page", not as a distinct group of controls.

local Theme = require(script.Parent.Theme)

local Section = {}
Section.__index = Section

function Section.new(parent: Instance, order: number, title: string, openByDefault: boolean?)
	local self = setmetatable({}, Section)

	local frame = Instance.new("Frame")
	frame.Size = UDim2.new(1, 0, 0, 0)
	frame.AutomaticSize = Enum.AutomaticSize.Y
	-- Fill and transparency together: in the light theme Studio's Item IS the
	-- page white, so a translucent card over it has no edge at all and the
	-- panel read as one undifferentiated sheet. See Theme.CardFill.
	local cardFill, cardTransparency = Theme.CardFill()
	frame.BackgroundColor3 = cardFill
	frame.BackgroundTransparency = cardTransparency
	frame.BorderSizePixel = 0
	frame.LayoutOrder = order
	frame.Parent = parent
	Theme.Corner(frame, 10)
	self.Frame = frame

	Theme.Edge(frame, 0.6)

	local layout = Instance.new("UIListLayout")
	layout.Padding = UDim.new(0, Theme.GAP - 1)
	layout.SortOrder = Enum.SortOrder.LayoutOrder
	layout.Parent = frame

	local pad = Instance.new("UIPadding")
	pad.PaddingTop = UDim.new(0, 8)
	pad.PaddingBottom = UDim.new(0, 8)
	pad.PaddingLeft = UDim.new(0, 10)
	pad.PaddingRight = UDim.new(0, 10)
	pad.Parent = frame

	local header = Instance.new("TextButton")
	header.Size = UDim2.new(1, 0, 0, 22)
	header.BackgroundTransparency = 1
	header.Text = ""
	header.LayoutOrder = 0
	header.AutoButtonColor = false
	header.Parent = frame

	local headerLayout = Instance.new("UIListLayout")
	headerLayout.FillDirection = Enum.FillDirection.Horizontal
	headerLayout.VerticalAlignment = Enum.VerticalAlignment.Center
	headerLayout.Padding = UDim.new(0, 7)
	headerLayout.SortOrder = Enum.SortOrder.LayoutOrder
	headerLayout.Parent = header

	local chevron = Theme.Text("", header)
	chevron.Size = UDim2.fromOffset(10, 20)
	chevron.LayoutOrder = 1
	chevron.TextSize = 11
	chevron.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)
	self.chevron = chevron

	-- Placeholder icon slot for this section's title. Swap Icon_<title>'s
	-- Image property for a real asset later; the Name makes it easy to find.
	local chip = Theme.IconChip(header, title, 22, 13)
	chip.LayoutOrder = 2

	local caption = Theme.Text(title, header)
	caption.Size = UDim2.new(1, -40, 1, 0)
	caption.LayoutOrder = 3
	caption.Font = Theme.FONT_BOLD
	self.caption = caption
	self.title = title

	-- Children live in their own stack so collapsing is one Visible flip
	-- instead of hiding every row one by one.
	local body = Theme.Stack(frame, 1)
	self.Content = body

	self.open = openByDefault ~= false
	header.Activated:Connect(function()
		self:SetOpen(not self.open)
	end)
	self:SetOpen(self.open)

	return self
end

function Section:SetOpen(open: boolean)
	self.open = open
	self.Content.Visible = open
	-- 25BC/25B6 and not the small 25BE/25B8 variants: those render as tofu
	-- in the Studio UI font.
	self.chevron.Text = open and "\u{25BC}" or "\u{25B6}"
end

function Section:SetVisible(visible: boolean)
	self.Frame.Visible = visible
end

return Section
]]></ProtectedString>
						<string name="ScriptGuid">{506EF894-DC3D-4865-BA81-D16DEB778A6E}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">Section</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX334A8D137915455DA594844CFC6312B7">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: pick a number by dragging. Stepped, clamped, and
-- deterministic — no free text to mistype.
--
-- Reused everywhere a number is edited (brush size, colour variation ranges,
-- press/release times, outline size), which is why it takes a formatter
-- rather than assuming a unit.

local Theme = require(script.Parent.Theme)
local DragCapture = require(script.Parent.DragCapture)

local Slider = {}
Slider.__index = Slider

local TRACK_HEIGHT = 6
local KNOB = 12

-- spec: Min, Max, Step, Value, Format (number -> string), LayoutOrder.
function Slider.new(parent: Instance, spec: { [string]: any })
	local self = setmetatable({}, Slider)

	self.min = spec.Min or 0
	self.max = spec.Max or 1
	self.step = spec.Step or 0
	self.format = spec.Format or function(value: number) return string.format("%.2f", value) end
	self.value = self.min
	-- Assigned by the caller. Only fired on a user drag, never by :Set, so
	-- reflecting a stored value can't echo back as an edit.
	self.OnChanged = nil :: ((number) -> ())?

	local frame = Instance.new("Frame")
	frame.Size = UDim2.fromScale(1, 1)
	frame.BackgroundTransparency = 1
	frame.LayoutOrder = spec.LayoutOrder or 1
	frame.Parent = parent
	self.Frame = frame

	-- The readout sits on the right of the track: the row's left column is
	-- already the name, and a value floating elsewhere would be hard to pair.
	local readout = Instance.new("TextLabel")
	readout.Size = UDim2.new(0, 46, 1, 0)
	readout.Position = UDim2.new(1, -46, 0, 0)
	readout.BackgroundTransparency = 1
	readout.Font = Theme.FONT
	readout.TextSize = Theme.TEXT_SIZE
	readout.TextXAlignment = Enum.TextXAlignment.Right
	readout.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.MainText)
	readout.Parent = frame
	self.readout = readout

	local track = Instance.new("Frame")
	track.Size = UDim2.new(1, -52, 0, TRACK_HEIGHT)
	track.Position = UDim2.new(0, 0, 0.5, -TRACK_HEIGHT / 2)
	-- White-on-white in the light theme before this: the track vanished and a
	-- slider showed up as a lone floating knob with no scale under it.
	track.BackgroundColor3 = Theme.FieldFill()
	track.BorderSizePixel = 0
	track.Parent = frame
	Theme.Corner(track, TRACK_HEIGHT / 2)
	Theme.Edge(track, 1)
	self.track = track

	local fill = Instance.new("Frame")
	fill.Size = UDim2.fromScale(0, 1)
	fill.BackgroundColor3 = Theme.ACCENT
	fill.BorderSizePixel = 0
	fill.Parent = track
	Theme.Corner(fill, TRACK_HEIGHT / 2)
	self.fill = fill

	local knob = Instance.new("Frame")
	knob.Size = UDim2.fromOffset(KNOB, KNOB)
	knob.AnchorPoint = Vector2.new(0.5, 0.5)
	knob.Position = UDim2.new(0, 0, 0.5, 0)
	knob.BackgroundColor3 = Theme.Color(Enum.StudioStyleGuideColor.MainText)
	knob.BorderSizePixel = 0
	knob.ZIndex = 2
	knob.Parent = track
	Theme.Corner(knob, KNOB / 2)
	self.knob = knob

	-- Takes a position, not an InputObject: during the drag it comes from
	-- DragCapture's poll rather than from an input event. Both are in the
	-- widget's coordinate space, the same one AbsolutePosition is in.
	local function apply(position: Vector2)
		local width = track.AbsoluteSize.X
		if width <= 0 then return end
		local ratio = math.clamp((position.X - track.AbsolutePosition.X) / width, 0, 1)
		local raw = self.min + ratio * (self.max - self.min)
		self:_set(self:_quantize(raw), true)
	end

	-- Pressing anywhere on the row starts a drag, not just a jump-to-click:
	-- the whole row height is a hit target (a 6px track is not), and
	-- DragCapture then follows the cursor even off the control. Frame,
	-- track AND knob all need Active=true — a plain (non-button) GuiObject
	-- does not receive InputBegan at all unless Active is explicitly set,
	-- transparent or not.
	local function beginDrag(input: InputObject)
		if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end
		apply(Vector2.new(input.Position.X, input.Position.Y))
		DragCapture.Begin(frame, input, apply)
	end

	frame.Active = true
	track.Active = true
	knob.Active = true
	frame.InputBegan:Connect(beginDrag)
	track.InputBegan:Connect(beginDrag)
	knob.InputBegan:Connect(beginDrag)

	self:Set(spec.Value or self.min)
	return self
end

function Slider:_quantize(value: number): number
	if self.step and self.step > 0 then
		value = self.min + math.round((value - self.min) / self.step) * self.step
	end
	return math.clamp(value, self.min, self.max)
end

function Slider:_set(value: number, fromUser: boolean)
	local changed = value ~= self.value
	self.value = value

	local span = self.max - self.min
	local ratio = span > 0 and (value - self.min) / span or 0
	self.fill.Size = UDim2.fromScale(ratio, 1)
	self.knob.Position = UDim2.new(ratio, 0, 0.5, 0)
	self.readout.Text = self.format(value)

	if fromUser and changed and self.OnChanged then
		self.OnChanged(value)
	end
end

-- Reflects a stored value. Silent on purpose: see OnChanged.
function Slider:Set(value: number)
	self:_set(self:_quantize(value), false)
end

return Slider
]]></ProtectedString>
						<string name="ScriptGuid">{8232A356-DD14-4E25-9338-12CF4FC007A4}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">Slider</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBXE6730AB44FC44F10841DEB57E5AF7C7A">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: pick exactly one option among a small fixed set,
-- with every option visible at once.
--
-- Replaces the cycling buttons the panel used to have: a button reading
-- "Cap color: Solid" never told you what the other choices were, or that
-- there were any. Also the natural host for mode switching, since the caller
-- can show/hide fields from OnChanged.

local Theme = require(script.Parent.Theme)

local Segmented = {}
Segmented.__index = Segmented

-- spec: Options ({string}), Value, LayoutOrder, Labels ({[string]: string})
-- for a display text that differs from the stored value, Icons ({string})
-- for the options that should also get a placeholder icon (the plugin's
-- main tool switch, mostly — a segmented row of plain words doesn't need
-- one everywhere).
function Segmented.new(parent: Instance, spec: { [string]: any })
	local self = setmetatable({}, Segmented)

	self.options = spec.Options
	self.value = spec.Value or self.options[1]
	self.buttons = {} :: { [string]: TextButton }
	self.labels = {} :: { [string]: TextLabel }
	-- Only the options that asked for one, so _refresh has to tolerate a hole.
	self.icons = {} :: { [string]: ImageLabel }
	self.OnChanged = nil :: ((string) -> ())?

	local frame = Instance.new("Frame")
	frame.Size = UDim2.fromScale(1, 1)
	frame.BackgroundColor3 = Theme.FieldFill()
	frame.BorderSizePixel = 0
	frame.LayoutOrder = spec.LayoutOrder or 1
	frame.Parent = parent
	-- Pill shape, not the flat card corner: a segmented control reads as a
	-- toggle switch, and a toggle switch is round.
	Theme.Corner(frame, Theme.ROW_HEIGHT / 2)
	-- The pill's own outline. Without it the unselected half of the control is
	-- just floating words in the light theme — nothing says the other options
	-- are part of one switch, or that they can be clicked at all.
	Theme.Edge(frame, 1)
	self.Frame = frame

	local pad = Instance.new("UIPadding")
	pad.PaddingTop = UDim.new(0, 2)
	pad.PaddingBottom = UDim.new(0, 2)
	pad.PaddingLeft = UDim.new(0, 2)
	pad.PaddingRight = UDim.new(0, 2)
	pad.Parent = frame

	local layout = Instance.new("UIListLayout")
	layout.FillDirection = Enum.FillDirection.Horizontal
	layout.Padding = UDim.new(0, 2)
	layout.SortOrder = Enum.SortOrder.LayoutOrder
	layout.Parent = frame

	local iconOptions = {}
	for _, option in ipairs(spec.Icons or {}) do
		iconOptions[option] = true
	end

	local count = #self.options
	for index, option in ipairs(self.options) do
		local button = Theme.Button("", frame)
		Theme.Corner(button, Theme.ROW_HEIGHT / 2 - 2)
		button.LayoutOrder = index
		-- The 2px gaps between segments are taken out of each share so the
		-- last one still lands inside the frame.
		button.Size = UDim2.new(1 / count, -2 * (count - 1) / count, 1, 0)
		button.BackgroundTransparency = 1
		button.AutoButtonColor = false

		local inner = Instance.new("Frame")
		inner.Size = UDim2.fromScale(1, 1)
		inner.BackgroundTransparency = 1
		inner.Parent = button
		local innerLayout = Instance.new("UIListLayout")
		innerLayout.FillDirection = Enum.FillDirection.Horizontal
		innerLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
		innerLayout.VerticalAlignment = Enum.VerticalAlignment.Center
		innerLayout.Padding = UDim.new(0, 4)
		innerLayout.SortOrder = Enum.SortOrder.LayoutOrder
		innerLayout.Parent = inner

		if iconOptions[option] then
			local icon = Theme.Icon(inner, option, 14)
			icon.LayoutOrder = 1
			self.icons[option] = icon
		end

		local label = Theme.Text((spec.Labels and spec.Labels[option]) or option, inner)
		label.Size = UDim2.fromOffset(0, 20)
		label.AutomaticSize = Enum.AutomaticSize.X
		label.LayoutOrder = 2
		label.TextSize = Theme.TEXT_SIZE - 1
		label.TextXAlignment = Enum.TextXAlignment.Center

		button.Activated:Connect(function()
			self:_set(option, true)
		end)
		self.buttons[option] = button
		self.labels[option] = label
	end

	self:_refresh()
	return self
end

function Segmented:_refresh()
	for option, button in pairs(self.buttons) do
		local selected = option == self.value
		button.BackgroundTransparency = selected and 0 or 1
		button.BackgroundColor3 = Theme.ACCENT
		local label = self.labels[option]
		label.TextColor3 = selected
			and Theme.ACCENT_TEXT
			or Theme.Color(Enum.StudioStyleGuideColor.DimmedText)
		label.Font = selected and Theme.FONT_BOLD or Theme.FONT
		-- Follows the label: the selected segment is an accent fill, so its glyph
		-- needs the same white the text uses, not the panel's icon colour.
		local icon = self.icons[option]
		if icon then
			icon.ImageColor3 = selected and Theme.ACCENT_TEXT or Theme.IconTint()
		end
	end
end

function Segmented:_set(value: string, fromUser: boolean)
	if value == self.value then return end
	self.value = value
	self:_refresh()
	if fromUser and self.OnChanged then
		self.OnChanged(value)
	end
end

-- Reflects a stored value without firing OnChanged. Falls back to the first
-- option on unknown data rather than showing nothing selected.
function Segmented:Set(value: string?)
	-- "" is a deliberate deselect (see ColorChannel's preset row): only
	-- unrecognised, non-empty data falls back to the first option.
	--
	-- nil is checked before table.find rather than handed to it: find errors
	-- on a nil needle, and that error came out of the middle of a
	-- ShowGroupValues pass — taking every control after it down with it, so a
	-- whole run of rows kept showing the PREVIOUS group's selection.
	if value == nil or (value ~= "" and not table.find(self.options, value)) then
		value = self.options[1]
	end
	self.value = value
	self:_refresh()
end

return Segmented
]]></ProtectedString>
						<string name="ScriptGuid">{AE3D8FBD-93B9-4C2C-8335-1FF35360B8FE}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">Segmented</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX9FCD80A77F9D46D6A5BC586778D0893F">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: an on/off switch. Used where the state is a plain
-- boolean with no sub-options (outline on/off) — anything with sub-options
-- belongs in a Segmented instead.

local Theme = require(script.Parent.Theme)

local Toggle = {}
Toggle.__index = Toggle

local WIDTH = 34
local HEIGHT = 18
local KNOB = 14

function Toggle.new(parent: Instance, spec: { [string]: any })
	local self = setmetatable({}, Toggle)
	self.value = spec.Value == true
	self.OnChanged = nil :: ((boolean) -> ())?

	local button = Instance.new("TextButton")
	button.Size = UDim2.fromOffset(WIDTH, HEIGHT)
	button.Position = UDim2.new(0, 0, 0.5, -HEIGHT / 2)
	button.Text = ""
	button.AutoButtonColor = false
	button.BorderSizePixel = 0
	button.LayoutOrder = spec.LayoutOrder or 1
	button.Parent = parent
	Theme.Corner(button, HEIGHT / 2)
	self.Frame = button

	-- THE reason this control was reported as "there is no switch". The OFF
	-- track is InputFieldBackground, which in the LIGHT Studio theme is very
	-- nearly the panel's own white — and the knob was hardcoded white on top
	-- of it. An OFF toggle was therefore white-on-white with no edge: totally
	-- invisible, and only findable by clicking blind until it turned green.
	-- Never let this control rely on its fill alone to be seen: the outline is
	-- derived from the fill (Theme.Outline lightens a dark one, darkens a light
	-- one), so it has a visible edge in either theme.
	local stroke = Instance.new("UIStroke")
	stroke.Thickness = 1
	stroke.Transparency = 0.35
	stroke.Parent = button
	self.stroke = stroke

	-- White in BOTH states, as a switch knob is everywhere else: it is what
	-- makes the control read as a switch rather than as a coloured pill. It
	-- only survives on a light track because of the outline below — without
	-- one, the light theme's knob was white on near-white and the whole
	-- control disappeared.
	local knob = Instance.new("Frame")
	knob.Size = UDim2.fromOffset(KNOB, KNOB)
	knob.AnchorPoint = Vector2.new(0.5, 0.5)
	knob.BackgroundColor3 = Color3.new(1, 1, 1)
	knob.BorderSizePixel = 0
	knob.Parent = button
	Theme.Corner(knob, KNOB / 2)
	self.knob = knob

	local knobStroke = Instance.new("UIStroke")
	knobStroke.Thickness = 1
	knobStroke.Transparency = 0.55
	knobStroke.Parent = knob
	self.knobStroke = knobStroke

	button.Activated:Connect(function()
		self.value = not self.value
		self:_refresh()
		if self.OnChanged then
			self.OnChanged(self.value)
		end
	end)

	self:_refresh()
	return self
end

function Toggle:_refresh()
	-- Theme.FieldFill, not InputFieldBackground: that Studio colour is pure
	-- white in the light theme, i.e. the same white as the panel behind it,
	-- which is what made an OFF switch invisible. Unchanged in the dark theme.
	local fill = self.value and Theme.OK or Theme.FieldFill()
	self.Frame.BackgroundColor3 = fill
	self.stroke.Color = Theme.Outline(fill)
	-- Only the OFF knob needs an edge: on the green ON track white already has
	-- all the contrast it needs, and an outline there just muddies it.
	self.knobStroke.Transparency = self.value and 1 or 0.55
	self.knobStroke.Color = Theme.Outline(fill)
	self.knob.Position = self.value
		and UDim2.new(1, -KNOB / 2 - 2, 0.5, 0)
		or UDim2.new(0, KNOB / 2 + 2, 0.5, 0)
end

-- Coerced, not stored raw: a caller reading a missing attribute passes nil,
-- and a nil here made the NEXT click compute `not nil` = true — so a switch
-- whose real state was already on took two clicks to turn off.
function Toggle:Set(value: boolean)
	self.value = value == true
	self:_refresh()
end

return Toggle
]]></ProtectedString>
						<string name="ScriptGuid">{B989D494-89B7-4FDE-B2F7-EFC58A448946}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">Toggle</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX2C6822E4286641E58A2549A902AE02D9">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: float a panel over the rest of the UI, anchored to
-- the control that opened it, and close it on an outside click.
--
-- Exists because the group list used to expand INSIDE the layout, shoving
-- every row below it down the page. A popup keeps the panel still.
-- Everything floating lives in one overlay frame (created by the Panel and
-- passed in), so only one popup can be open at a time.

local Theme = require(script.Parent.Theme)
local DragCapture = require(script.Parent.DragCapture)

local Popup = {}
Popup.__index = Popup

local openPopup: any = nil

function Popup.new(overlay: GuiObject)
	local self = setmetatable({}, Popup)
	self.overlay = overlay
	-- When true, an outside click no longer dismisses this popup — only its
	-- own control can close it. For popups you WORK inside: a drag that ends
	-- outside the popup's bounds is indistinguishable from a click meant to
	-- dismiss it, so a colour picker you drag in kept closing itself the
	-- moment you released the mouse. See ColorPicker.
	self.persistent = false

	-- Sits BEHIND the popup (lower ZIndex, inset a couple of pixels on every
	-- side so it only shows as a halo). A popup floating over rows that are
	-- nearly its own colour needs depth, not just an outline, to read as
	-- floating rather than as one more section of the panel.
	local shadow = Instance.new("Frame")
	shadow.Name = "Shadow"
	shadow.Visible = false
	shadow.BackgroundColor3 = Color3.new(0, 0, 0)
	shadow.BackgroundTransparency = 0.55
	shadow.BorderSizePixel = 0
	shadow.ZIndex = 19
	shadow.Parent = overlay
	Theme.Corner(shadow, 8)
	self.shadow = shadow

	local frame = Instance.new("Frame")
	frame.Visible = false
	-- Lifted off Studio's Dropdown colour: unlifted it lands on the panel's
	-- own background in the dark theme and the list bled into the page.
	frame.BackgroundColor3 = Theme.Elevate(Theme.Color(Enum.StudioStyleGuideColor.Dropdown))
	frame.BorderSizePixel = 0
	frame.ZIndex = 20
	frame.Parent = overlay
	Theme.Corner(frame, 6)
	self.Frame = frame

	-- Derived from the fill rather than taken from StudioStyleGuideColor.Border,
	-- which is itself near-invisible against a dark panel. See Theme.Outline.
	local stroke = Instance.new("UIStroke")
	stroke.Color = Theme.Outline(frame.BackgroundColor3)
	stroke.Thickness = 1
	stroke.Parent = frame

	return self
end

-- Anchors under the control, flipping above it when there isn't room below:
-- a popup half off the bottom of a docked widget is unusable.
function Popup:OpenAt(anchor: GuiObject, width: number, height: number)
	if openPopup and openPopup ~= self then
		openPopup:Close()
	end
	openPopup = self

	local overlayPos = self.overlay.AbsolutePosition
	local x = anchor.AbsolutePosition.X - overlayPos.X
	local y = anchor.AbsolutePosition.Y - overlayPos.Y + anchor.AbsoluteSize.Y + 4

	x = math.clamp(x, 0, math.max(self.overlay.AbsoluteSize.X - width, 0))
	if y + height > self.overlay.AbsoluteSize.Y then
		local above = anchor.AbsolutePosition.Y - overlayPos.Y - height - 4
		y = above >= 0 and above or math.max(self.overlay.AbsoluteSize.Y - height, 0)
	end

	self.Frame.Size = UDim2.fromOffset(width, height)
	self.Frame.Position = UDim2.fromOffset(x, y)
	self.Frame.Visible = true

	-- Offset down-right by 3px and grown by 2 on the other sides: a soft edge
	-- all round, heaviest where a light from above would put it.
	self.shadow.Size = UDim2.fromOffset(width + 4, height + 4)
	self.shadow.Position = UDim2.fromOffset(x - 1, y + 1)
	self.shadow.Visible = true

	self.overlay.Visible = true
end

function Popup:Close()
	-- Closing while a drag is still latched (picker dismissed mid-drag) would
	-- leave the capture layer live over a hidden popup.
	DragCapture.Release()
	self.Frame.Visible = false
	self.shadow.Visible = false
	if openPopup == self then
		openPopup = nil
		self.overlay.Visible = false
	end
end

function Popup:IsOpen(): boolean
	return self.Frame.Visible
end

-- Called by the Panel's backdrop button: one click anywhere else dismisses
-- whatever is open, whichever control owns it.
-- force is for tear-downs (the widget being closed), not for dismissals: a
-- persistent popup ignores the backdrop, but it must not survive the panel
-- going away, or the overlay would come back still covering everything.
function Popup.CloseAny(force: boolean?)
	if openPopup and (force or not openPopup.persistent) then
		openPopup:Close()
	end
end

return Popup
]]></ProtectedString>
						<string name="ScriptGuid">{75C88F85-18C4-4516-96E1-5EF23B23B213}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">Popup</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX94ADBA3CB226450D806918A715DC0115">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: pick a colour. Shows as a swatch in a row; clicking
-- it opens an HSV picker (saturation/value square + hue strip), with a hex
-- field kept as a fallback and a row of recently used colours.
--
-- Replaces the hex-only text fields: five colour settings in this panel, and
-- typing "E6F0FA" is not how anyone chooses a colour.

local Theme = require(script.Parent.Theme)
local Popup = require(script.Parent.Popup)
local DragCapture = require(script.Parent.DragCapture)

local ColorPicker = {}
ColorPicker.__index = ColorPicker

local WIDTH = 190
-- Square + hex row + Done row + padding. No recent-swatches row: the square
-- reaches any colour in one gesture, so a history of previous ones was a row
-- of shortcuts to something that was never far away.
local POPUP_HEIGHT = 8 + 120 + 8 + 20 + 6 + 22 + 8
local SQUARE = 120
local STRIP = 14

function ColorPicker.new(parent: Instance, spec: { [string]: any })
	local self = setmetatable({}, ColorPicker)
	self.overlay = spec.Overlay
	self.hue, self.saturation, self.value = 0, 0, 1
	self.OnChanged = nil :: ((Color3) -> ())?

	-- A fixed-width chip plus its hex, not a full-width block of colour.
	-- Full width read as an empty input field, and a dark colour on the dark
	-- panel was invisible — you could not tell a black swatch from a hole.
	-- The chip's own outline is what guarantees the edge is always visible,
	-- whatever colour sits inside it.
	local button = Instance.new("TextButton")
	button.Size = UDim2.new(0, 48, 1, -4)
	button.Position = UDim2.fromOffset(0, 2)
	button.Text = ""
	button.AutoButtonColor = false
	button.BorderSizePixel = 0
	button.BackgroundColor3 = Color3.new(1, 1, 1)
	button.LayoutOrder = spec.LayoutOrder or 1
	button.Parent = parent
	Theme.Corner(button, 5)
	self.Frame = button

	local stroke = Instance.new("UIStroke")
	stroke.Color = Color3.fromRGB(150, 154, 162)
	stroke.Transparency = 0.25
	stroke.Thickness = 1.5
	stroke.Parent = button

	local hexLabel = Theme.Text("", parent)
	hexLabel.Size = UDim2.new(1, -56, 1, 0)
	hexLabel.Position = UDim2.fromOffset(56, 0)
	hexLabel.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)
	hexLabel.TextSize = Theme.TEXT_SIZE - 1
	self.hexLabel = hexLabel

	button.Activated:Connect(function()
		if self.popup and self.popup:IsOpen() then
			self.popup:Close()
		else
			self:_open()
		end
	end)

	self:Set(spec.Value or Color3.new(1, 1, 1))
	return self
end

function ColorPicker:_build()
	self.popup = Popup.new(self.overlay)
	local frame = self.popup.Frame

	-- Saturation/value square: a white-to-hue horizontal gradient with a
	-- transparent-to-black vertical one on top. Same trick every colour
	-- picker uses, and it needs no image asset.
	local square = Instance.new("Frame")
	square.Size = UDim2.fromOffset(SQUARE, SQUARE)
	square.Position = UDim2.fromOffset(8, 8)
	square.BorderSizePixel = 0
	square.ZIndex = 21
	square.Parent = frame
	Theme.Corner(square, 4)
	self.square = square

	local hueGradient = Instance.new("UIGradient")
	hueGradient.Parent = square
	self.hueGradient = hueGradient

	local shade = Instance.new("Frame")
	shade.Size = UDim2.fromScale(1, 1)
	shade.BackgroundColor3 = Color3.new(0, 0, 0)
	shade.BorderSizePixel = 0
	shade.ZIndex = 22
	shade.Parent = square
	Theme.Corner(shade, 4)

	local shadeGradient = Instance.new("UIGradient")
	shadeGradient.Rotation = 90
	shadeGradient.Transparency = NumberSequence.new({
		NumberSequenceKeypoint.new(0, 1),
		NumberSequenceKeypoint.new(1, 0),
	})
	shadeGradient.Parent = shade

	local cursor = Instance.new("Frame")
	cursor.Size = UDim2.fromOffset(8, 8)
	cursor.AnchorPoint = Vector2.new(0.5, 0.5)
	cursor.BackgroundTransparency = 1
	cursor.ZIndex = 23
	cursor.Parent = square
	Theme.Corner(cursor, 4)
	local cursorStroke = Instance.new("UIStroke")
	cursorStroke.Color = Color3.new(1, 1, 1)
	cursorStroke.Thickness = 2
	cursorStroke.Parent = cursor
	self.cursor = cursor

	local strip = Instance.new("Frame")
	strip.Size = UDim2.fromOffset(STRIP, SQUARE)
	strip.Position = UDim2.fromOffset(8 + SQUARE + 8, 8)
	strip.BorderSizePixel = 0
	strip.ZIndex = 21
	strip.Parent = frame
	Theme.Corner(strip, 4)
	self.strip = strip

	local stripGradient = Instance.new("UIGradient")
	stripGradient.Rotation = 90
	stripGradient.Color = ColorSequence.new({
		ColorSequenceKeypoint.new(0.00, Color3.fromHSV(0, 1, 1)),
		ColorSequenceKeypoint.new(0.17, Color3.fromHSV(0.17, 1, 1)),
		ColorSequenceKeypoint.new(0.33, Color3.fromHSV(0.33, 1, 1)),
		ColorSequenceKeypoint.new(0.50, Color3.fromHSV(0.50, 1, 1)),
		ColorSequenceKeypoint.new(0.67, Color3.fromHSV(0.67, 1, 1)),
		ColorSequenceKeypoint.new(0.83, Color3.fromHSV(0.83, 1, 1)),
		ColorSequenceKeypoint.new(1.00, Color3.fromHSV(1, 1, 1)),
	})
	stripGradient.Parent = strip

	local stripCursor = Instance.new("Frame")
	stripCursor.Size = UDim2.new(1, 4, 0, 3)
	stripCursor.AnchorPoint = Vector2.new(0.5, 0.5)
	stripCursor.Position = UDim2.fromScale(0.5, 0)
	stripCursor.BackgroundColor3 = Color3.new(1, 1, 1)
	stripCursor.BorderSizePixel = 0
	stripCursor.ZIndex = 23
	stripCursor.Parent = strip
	self.stripCursor = stripCursor

	local hexBox = Instance.new("TextBox")
	hexBox.Size = UDim2.new(1, -16, 0, 20)
	hexBox.Position = UDim2.fromOffset(8, 8 + SQUARE + 8)
	hexBox.BackgroundColor3 = Theme.FieldFill()
	hexBox.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.MainText)
	hexBox.BorderSizePixel = 0
	hexBox.Font = Theme.FONT
	hexBox.TextSize = Theme.TEXT_SIZE
	hexBox.ClearTextOnFocus = false
	hexBox.ZIndex = 21
	hexBox.Parent = frame
	Theme.Corner(hexBox)
	Theme.Edge(hexBox, 1)
	self.hexBox = hexBox

	hexBox.FocusLost:Connect(function()
		local ok, color = pcall(Color3.fromHex, hexBox.Text)
		if ok then
			self:_setColor(color, true)
		else
			-- A half-typed hex is not an edit: put the real value back.
			hexBox.Text = self:Color():ToHex()
		end
	end)

	-- The ONLY way out of this popup, because it is persistent (see below).
	-- The colour is already applied live, so this confirms nothing — it is
	-- purely the dismissal, and it has to exist precisely because clicking
	-- outside no longer performs one.
	local doneButton = Theme.Button("Done", frame)
	doneButton.Size = UDim2.new(1, -16, 0, 22)
	doneButton.Position = UDim2.fromOffset(8, 8 + SQUARE + 8 + 20 + 6)
	doneButton.BackgroundColor3 = Theme.ACCENT
	doneButton.TextColor3 = Theme.ACCENT_TEXT
	doneButton.Font = Theme.FONT_BOLD
	doneButton.ZIndex = 21
	-- See Panel: AutoButtonColor would restore the fill it cached on hover.
	doneButton.AutoButtonColor = false
	doneButton.MouseEnter:Connect(function()
		doneButton.BackgroundColor3 = Theme.ACCENT:Lerp(Color3.new(1, 1, 1), 0.15)
	end)
	doneButton.MouseLeave:Connect(function()
		doneButton.BackgroundColor3 = Theme.ACCENT
	end)
	doneButton.Activated:Connect(function()
		self.popup:Close()
	end)

	-- Dragging is the whole point of this popup, and a drag that ends outside
	-- its bounds — which is most of them, the hue strip is 14px wide — read as
	-- an outside click and dismissed it. Picking a hue on the right closed the
	-- picker before you could go back to the square on the left and refine it.
	-- So this one does not dismiss on an outside click at all: Done closes it.
	self.popup.persistent = true

	self:_bindDrag(square, function(rx, ry)
		self.saturation = rx
		self.value = 1 - ry
		self:_pushColor()
	end)
	self:_bindDrag(strip, function(_, ry)
		self.hue = ry
		self:_pushColor()
	end)
end

-- Both the square and the strip drag the same way, so the geometry is written
-- once and the caller only interprets the normalised coordinates.
--
-- The drag runs through DragCapture rather than being tracked here: see that
-- module for why an in-place drag left the picker latched and un-escapable.
function ColorPicker:_bindDrag(target: GuiObject, handler: (number, number) -> ())
	-- Takes a position, not an InputObject: during the drag it comes from
	-- DragCapture's poll rather than from an input event.
	local function apply(position: Vector2)
		local size = target.AbsoluteSize
		if size.X <= 0 or size.Y <= 0 then return end
		handler(
			math.clamp((position.X - target.AbsolutePosition.X) / size.X, 0, 1),
			math.clamp((position.Y - target.AbsolutePosition.Y) / size.Y, 0, 1)
		)
	end

	target.InputBegan:Connect(function(input)
		if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end
		apply(Vector2.new(input.Position.X, input.Position.Y))
		-- No release callback: releasing the mouse ends the drag and that is
		-- the whole of it. The colour was already applied on every frame of it.
		DragCapture.Begin(target, input, apply)
	end)
end

function ColorPicker:_open()
	if not self.popup then
		self:_build()
	end
	self:_refreshFields()
	self.popup:OpenAt(self.Frame, WIDTH, POPUP_HEIGHT)
end

function ColorPicker:Color(): Color3
	return Color3.fromHSV(self.hue, self.saturation, self.value)
end

function ColorPicker:_pushColor()
	self:_refreshFields()
	self.Frame.BackgroundColor3 = self:Color()
	self.hexLabel.Text = "#" .. self:Color():ToHex():upper()
	if self.OnChanged then
		self.OnChanged(self:Color())
	end
end

function ColorPicker:_setColor(color: Color3, fromUser: boolean)
	self.hue, self.saturation, self.value = color:ToHSV()
	self.Frame.BackgroundColor3 = color
	self.hexLabel.Text = "#" .. color:ToHex():upper()
	self:_refreshFields()
	if fromUser and self.OnChanged then
		self.OnChanged(color)
	end
end

function ColorPicker:_refreshFields()
	if not self.popup then return end
	self.hueGradient.Color = ColorSequence.new(Color3.new(1, 1, 1), Color3.fromHSV(self.hue, 1, 1))
	self.cursor.Position = UDim2.fromScale(self.saturation, 1 - self.value)
	self.stripCursor.Position = UDim2.fromScale(0.5, self.hue)
	if not self.hexBox:IsFocused() then
		self.hexBox.Text = self:Color():ToHex()
	end
end

-- Reflects a stored colour. Silent, like every other component's :Set.
function ColorPicker:Set(color: Color3)
	self:_setColor(color, false)
end

return ColorPicker
]]></ProtectedString>
						<string name="ScriptGuid">{C6C3C1B3-8FAD-4852-88EF-BACF217582A4}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">ColorPicker</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX9DB2E9150D414D48ACB383BA14B4D820">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: pick one entry from a list, and optionally remove
-- one. Nothing else — in particular it does NOT know how to create an entry.
--
-- It used to. The group selector needed a way to name a new group, so a text
-- field and a "+ New group" row were bolted inside the popup, which left this
-- module owning a create flow that had nothing to do with picking from a
-- list. That flow now lives in UI.Sections.GroupSection, which is also the
-- only thing that ever needed it.

local Theme = require(script.Parent.Theme)
local Popup = require(script.Parent.Popup)

local Dropdown = {}
Dropdown.__index = Dropdown

local ENTRY_HEIGHT = 22

-- spec: Entries ({ {Value, Text, Detail, Color, Deletable} }), Value,
-- Overlay, OnDelete (nil disables the per-entry × everywhere; an entry with
-- Deletable = false opts out on its own).
function Dropdown.new(parent: Instance, spec: { [string]: any })
	local self = setmetatable({}, Dropdown)
	self.overlay = spec.Overlay
	self.entries = spec.Entries or {}
	-- Falls back to the first entry rather than nil, for the same reason
	-- Segmented does: a control whose stored value matches no entry drew an
	-- open list with NOTHING highlighted, so there was no way to tell what was
	-- currently selected.
	self.value = spec.Value or (self.entries[1] and self.entries[1].Value)
	self.OnChanged = nil :: ((string) -> ())?
	-- Present only when the caller wants entries deletable (the group list);
	-- absent, no × is drawn at all.
	self.OnDelete = spec.OnDelete :: ((string) -> ())?

	local button = Theme.Button("", parent)
	-- Reads as a field you can open, not as a label: in the light theme the
	-- fill alone is a couple of percent off the page white.
	Theme.Edge(button, 1)
	button.TextXAlignment = Enum.TextXAlignment.Left
	button.LayoutOrder = spec.LayoutOrder or 1
	self.Frame = button

	local pad = Instance.new("UIPadding")
	pad.PaddingLeft = UDim.new(0, 8)
	pad.PaddingRight = UDim.new(0, 8)
	-- Not symmetry, and not a nudge for its own sake: Gotham's line box carries
	-- a deep descent, so text the engine has centred vertically still sits low
	-- by about a pixel. Taking that pixel off the BOTTOM of the content area is
	-- what puts the cap-height block on the field's real middle. Compared at
	-- 4.5x against 0/2/4/6; 2 is the value that lands.
	pad.PaddingBottom = UDim.new(0, 2)
	pad.Parent = button

	local arrow = Theme.Text("\u{25BC}", button)
	arrow.Size = UDim2.new(0, 12, 1, 0)
	-- Lifted 2px in total, and only 1 of them is written here: the padding
	-- above already shortens this label's parent area and carries the other.
	-- TextYAlignment.Center centres the LINE BOX, not the glyph's ink, and
	-- 25BC sits low in its em square in Gotham, so the triangle read as
	-- sagging below the middle of the field. Measured at 4x, not guessed.
	arrow.Position = UDim2.new(1, -12, 0, -1)
	arrow.TextSize = Theme.TEXT_SIZE - 3
	arrow.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)

	button.Activated:Connect(function()
		if self.popup and self.popup:IsOpen() then
			self.popup:Close()
		else
			self:_open()
		end
	end)

	self:_refreshButton()
	return self
end

function Dropdown:_refreshButton()
	local text = self.value or ""
	for _, entry in ipairs(self.entries) do
		if entry.Value == self.value then
			text = entry.Text or entry.Value
			break
		end
	end
	self.Frame.Text = text
end

function Dropdown:_open()
	if not self.popup then
		self.popup = Popup.new(self.overlay)
		self.list = Instance.new("Frame")
		self.list.Size = UDim2.new(1, -8, 1, -8)
		self.list.Position = UDim2.fromOffset(4, 4)
		self.list.BackgroundTransparency = 1
		self.list.ZIndex = 21
		self.list.Parent = self.popup.Frame

		local layout = Instance.new("UIListLayout")
		layout.Padding = UDim.new(0, 2)
		layout.SortOrder = Enum.SortOrder.LayoutOrder
		layout.Parent = self.list
	end

	self:_fill()
	self.popup:OpenAt(self.Frame, self.Frame.AbsoluteSize.X, #self.entries * (ENTRY_HEIGHT + 2) + 8)
end

function Dropdown:_fill()
	for _, child in ipairs(self.list:GetChildren()) do
		if child:IsA("GuiObject") then
			child:Destroy()
		end
	end

	local function addEntry(order: number, text: string, color: Color3?): TextButton
		local entry = Theme.Button(text, self.list)
		entry.Size = UDim2.new(1, 0, 0, ENTRY_HEIGHT)
		entry.LayoutOrder = order
		entry.TextXAlignment = Enum.TextXAlignment.Left
		entry.ZIndex = 22
		entry.BackgroundTransparency = 1
		-- Not AutoButtonColor: it caches the fill on mouse-enter and writes it
		-- back on leave, which turns the selected entry's accent fill into
		-- Studio grey the first time the cursor crosses it. Hover is drawn by
		-- hand below so the selection keeps its colour.
		entry.AutoButtonColor = false

		local pad = Instance.new("UIPadding")
		pad.PaddingLeft = UDim.new(0, color and 22 or 8)
		pad.PaddingRight = UDim.new(0, 8)
		pad.Parent = entry

		if color then
			-- A colour chip per group: with half a dozen groups the names
			-- alone stop being enough to tell them apart at a glance.
			local chip = Instance.new("Frame")
			chip.Size = UDim2.fromOffset(10, 10)
			chip.Position = UDim2.new(0, -16, 0.5, -5)
			chip.BackgroundColor3 = color
			chip.BorderSizePixel = 0
			chip.ZIndex = 23
			chip.Parent = entry
			Theme.Corner(chip, 2)
		end

		return entry
	end

	for index, spec in ipairs(self.entries) do
		local entry = addEntry(index, spec.Text or spec.Value, spec.Color)
		-- Per entry, not per list: an undeletable entry has no × to leave room
		-- for, so its count sits where every other row's would look ragged
		-- against — the counts stay in one column either way.
		local deletable = self.OnDelete ~= nil and spec.Deletable ~= false
		local detailRight = self.OnDelete and -58 or -40
		if spec.Detail then
			local detail = Theme.Text(spec.Detail, entry)
			detail.Size = UDim2.new(0, 40, 1, 0)
			detail.Position = UDim2.new(1, detailRight, 0, 0)
			detail.TextXAlignment = Enum.TextXAlignment.Right
			detail.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)
			detail.ZIndex = 23
		end
		local selected = spec.Value == self.value
		if selected then
			entry.BackgroundTransparency = 0
			entry.BackgroundColor3 = Theme.ACCENT
			entry.TextColor3 = Theme.ACCENT_TEXT
		else
			-- Unselected rows are transparent, so hover is the only thing that
			-- tells the user which one they are about to pick.
			entry.BackgroundColor3 = Theme.Outline(self.popup.Frame.BackgroundColor3)
			entry.MouseEnter:Connect(function()
				entry.BackgroundTransparency = 0.75
			end)
			entry.MouseLeave:Connect(function()
				entry.BackgroundTransparency = 1
			end)
		end
		entry.Activated:Connect(function()
			self.popup:Close()
			self:Set(spec.Value)
			if self.OnChanged then
				self.OnChanged(spec.Value)
			end
		end)

		if deletable then
			local delete = Instance.new("TextButton")
			delete.Size = UDim2.fromOffset(18, ENTRY_HEIGHT)
			delete.Position = UDim2.new(1, -18, 0, 0)
			delete.Text = "\u{00D7}"
			delete.Font = Theme.FONT_BOLD
			delete.TextSize = Theme.TEXT_SIZE + 2
			delete.TextColor3 = Theme.DANGER
			delete.BackgroundTransparency = 1
			delete.AutoButtonColor = false
			delete.ZIndex = 23
			delete.Parent = entry
			delete.Activated:Connect(function()
				if self.OnDelete then
					self.OnDelete(spec.Value)
				end
			end)
		end
	end
end

function Dropdown:SetEntries(entries: { any }, value: string?)
	self.entries = entries
	if value then self.value = value end
	self:_refreshButton()
	if self.popup and self.popup:IsOpen() then
		self:_fill()
	end
end

-- Unknown data (a preset renamed since the group was styled, a hand-edited
-- attribute) falls back to the first entry rather than leaving the list with
-- no highlighted row at all — see the note in Dropdown.new.
function Dropdown:Set(value: string?)
	self.value = value
	if not self:_isKnown(value) then
		self.value = self.entries[1] and self.entries[1].Value
	end
	self:_refreshButton()
	if self.popup and self.popup:IsOpen() then
		self:_fill()
	end
end

function Dropdown:_isKnown(value: any): boolean
	for _, entry in ipairs(self.entries) do
		if entry.Value == value then return true end
	end
	return false
end

return Dropdown
]]></ProtectedString>
						<string name="ScriptGuid">{A985C535-111F-42E7-8816-782FACC1C956}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">Dropdown</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX8E38964318E0450F85DE2DA9D37DE441">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: own a mouse drag once it has started, until the
-- button is released — wherever the cursor wanders in the meantime.
--
-- Why this does NOT use UserInputService, having twice tried to:
-- UserInputService is deaf inside a plugin widget. It only reports input
-- while the main Studio window has focus, so the instant the cursor is over a
-- DockWidgetPluginGui its InputChanged stops firing entirely. That is exactly
-- the "click sets the value, holding does nothing" symptom: InputBegan on the
-- control started the drag (GuiObject signals DO work in a widget), and the
-- UserInputService follow-up that was supposed to continue it never arrived.
--
-- The only API that reports the cursor inside a plugin widget is
-- PluginGui:GetRelativeMousePosition(), and it is a poll, not an event — so
-- the drag is driven from a Heartbeat loop instead. It returns a position
-- relative to the widget's top-left, which is the same space as the
-- AbsolutePosition of anything inside that widget, so callers can subtract
-- one from the other directly. It keeps reporting once the cursor leaves the
-- control, which is the whole point.
--
-- Ending the drag is the part that took three tries. What works is the
-- ORIGINATING InputObject: it reports its own end through Changed, whatever
-- the cursor happens to be over by then. That signal is carried by the input
-- itself instead of being routed, which is why it survives in here when the
-- other two candidates do not:
--   * GuiObject.InputEnded is delivered by hit-testing, so it goes to
--     whatever sits under the cursor at release time, not to the control the
--     drag started on;
--   * UserInputService.InputEnded is simply never delivered over the widget,
--     for the same reason InputChanged is not.
-- Both are still connected as belt-and-braces, and IsMouseButtonPressed is
-- kept as a last-resort latch-breaker — but only believed once it has been
-- seen to report a pressed button at least once during this drag, since a
-- service that cannot see the button would otherwise cancel on frame one.
-- Relying on those three alone was the "the slider keeps following the mouse
-- until I click again" bug: nothing ever fired, and the next InputBegan was
-- what finally released the previous drag.

local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")

local DragCapture = {}

local SOAK_NAME = "KeyCapperDragSoak"

local onMove: ((Vector2) -> ())? = nil
local onEnd: (() -> ())? = nil
local connections: { RBXScriptConnection } = {}
local soak: Frame? = nil
-- See the header: the button state only becomes an authority once it has
-- proven it can see the button at all.
local sawPressed = false

local function pluginGuiOf(gui: Instance): PluginGui?
	local node: Instance? = gui
	while node do
		if node:IsA("PluginGui") then return node :: PluginGui end
		node = node.Parent
	end
	return nil
end

-- One reusable full-widget frame, hidden between drags. Active is what makes
-- a fully transparent frame hit-testable at all; ZIndex puts it above the
-- popup overlay so it is what sits under the cursor for the whole drag.
local function ensureSoak(pluginGui: PluginGui): Frame
	local existing = pluginGui:FindFirstChild(SOAK_NAME)
	if existing then return existing :: Frame end

	local frame = Instance.new("Frame")
	frame.Name = SOAK_NAME
	frame.Size = UDim2.fromScale(1, 1)
	frame.BackgroundTransparency = 1
	frame.Visible = false
	frame.Active = true
	frame.ZIndex = 100
	frame.Parent = pluginGui
	return frame
end

local function disconnectAll()
	for _, connection in ipairs(connections) do
		connection:Disconnect()
	end
	connections = {}
end

function DragCapture.Release()
	if not onMove and not onEnd then return end
	disconnectAll()

	if soak then
		soak.Visible = false
		soak = nil
	end
	sawPressed = false

	local finish = onEnd
	onMove, onEnd = nil, nil
	if finish then finish() end
end

function DragCapture.IsActive(): boolean
	return onMove ~= nil
end

-- anchor: any GuiObject inside the widget the drag belongs to — used only to
-- find the PluginGui to poll. input: the InputObject from the InputBegan that
-- started the drag, which is what actually reports the release. move: called
-- every frame with the cursor position relative to that widget. finish:
-- called once on release.
function DragCapture.Begin(
	anchor: GuiObject,
	input: InputObject,
	move: (Vector2) -> (),
	finish: (() -> ())?
)
	if onMove then
		DragCapture.Release()
	end

	local pluginGui = pluginGuiOf(anchor)
	if not pluginGui then return end

	onMove, onEnd = move, finish
	sawPressed = false

	local layer = ensureSoak(pluginGui)
	layer.Visible = true
	soak = layer

	table.insert(connections, RunService.Heartbeat:Connect(function()
		if not onMove then return end

		if UserInputService:IsMouseButtonPressed(Enum.UserInputType.MouseButton1) then
			sawPressed = true
		elseif sawPressed then
			-- It saw the press and now sees the release: trustworthy here.
			DragCapture.Release()
			return
		end

		onMove(pluginGui:GetRelativeMousePosition())
	end))

	-- The one that actually fires in a widget. See the header.
	table.insert(connections, input.Changed:Connect(function()
		if input.UserInputState == Enum.UserInputState.End then
			DragCapture.Release()
		end
	end))

	table.insert(connections, layer.InputEnded:Connect(function(ended)
		if ended.UserInputType == Enum.UserInputType.MouseButton1 then
			DragCapture.Release()
		end
	end))

	table.insert(connections, UserInputService.InputEnded:Connect(function(ended)
		if ended.UserInputType == Enum.UserInputType.MouseButton1 then
			DragCapture.Release()
		end
	end))
end

return DragCapture
]]></ProtectedString>
						<string name="ScriptGuid">{DA5EB340-3581-44EE-AAD6-8C37C7F07722}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">DragCapture</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX9261B1F450524A89B80705CA284AA860">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: an icon button that reveals a URL ready to be copied.
--
-- It shows the link rather than copying it because a Studio plugin has no
-- clipboard API at all — there is no plugin:SetClipboard(), and no permission
-- that would grant one. So the next best thing is to remove every step
-- between the click and Ctrl+C: the popup opens with the field already
-- focused and the whole URL already selected, so the copy is two keys and
-- never a drag-select.

local Theme = require(script.Parent.Theme)
local Popup = require(script.Parent.Popup)

local LinkButton = {}
LinkButton.__index = LinkButton

local POPUP_WIDTH = 250
local HINT_HEIGHT = 16
local FIELD_HEIGHT = 26
local PAD = 6

-- spec: Icon (Theme.Icon slug), Url, Overlay, Color (button fill), Tooltip.
function LinkButton.new(parent: Instance, spec: { [string]: any })
	local self = setmetatable({}, LinkButton)
	self.overlay = spec.Overlay
	self.url = spec.Url

	local button = Theme.Button("", parent)
	button.Name = spec.Icon .. "Link"
	button.BackgroundColor3 = spec.Color or Theme.ACCENT
	-- Mandatory whenever a Theme.Button's fill is overridden: AutoButtonColor
	-- restores the colour it cached on mouse-enter when the cursor leaves, so
	-- a brand fill set at build time survives only until the first hover and
	-- then silently becomes Studio's default grey. Same failure the brush bar
	-- had (see Panel).
	button.AutoButtonColor = false
	local fill = button.BackgroundColor3
	button.MouseEnter:Connect(function()
		button.BackgroundColor3 = fill:Lerp(Color3.new(1, 1, 1), 0.15)
	end)
	button.MouseLeave:Connect(function()
		button.BackgroundColor3 = fill
	end)

	-- Theme.Button defaults to filling its parent (Size = 1,0,1,0), which on
	-- the header's full-width TopRow rendered as a long horizontal pill for
	-- the one frame before the caller's own Size override landed. Locking
	-- the aspect ratio to 1:1 HERE means this button is square no matter what
	-- Size the caller sets it to (width or height, whichever is smaller
	-- wins), so there is no longer a caller-ordering dependency to get wrong.
	local square = Instance.new("UIAspectRatioConstraint")
	square.AspectRatio = 1
	square.Parent = button
	if spec.Tooltip then
		-- Studio renders a GuiObject's tooltip on hover for free; cheaper and
		-- less intrusive than a label that would need room in the header.
		button.AutoLocalize = false
		pcall(function() (button :: any).Tooltip = spec.Tooltip end)
	end
	self.Frame = button

	local icon = Theme.Icon(button, spec.Icon, 18)
	icon.AnchorPoint = Vector2.new(0.5, 0.5)
	icon.Position = UDim2.fromScale(0.5, 0.5)
	-- Belt and suspenders: Theme.Icon already sets Fit, restated here because
	-- a stretched (non-uniformly scaled) glyph is exactly the failure mode
	-- this button must never have, brand mark or not.
	icon.ScaleType = Enum.ScaleType.Fit
	-- Opts out of Theme.IconTint: this is a real brand logo on its own brand
	-- fill, so it must not be re-inked with the panel's icon colour.
	icon.ImageColor3 = Color3.new(1, 1, 1)

	-- The button itself is fixed at LINK_SIZE (34px, see Panel) to match the
	-- brush bar's height, but an 18px glyph inside it reads as lost in the
	-- square. Scaling the icon rather than raising its base size keeps
	-- Theme.Icon's default meaningful for every OTHER caller.
	local iconScale = Instance.new("UIScale")
	iconScale.Scale = 1.45
	iconScale.Parent = icon

	button.Activated:Connect(function()
		if self.popup and self.popup:IsOpen() then
			self.popup:Close()
		else
			self:_open()
		end
	end)

	return self
end

function LinkButton:_open()
	if not self.popup then
		self.popup = Popup.new(self.overlay)

		local hint = Theme.Text("Ctrl+C to copy", self.popup.Frame)
		hint.Position = UDim2.fromOffset(PAD, PAD)
		hint.Size = UDim2.new(1, -PAD * 2, 0, HINT_HEIGHT)
		hint.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)
		hint.TextSize = Theme.TEXT_SIZE - 2
		hint.ZIndex = 21

		local field = Theme.TextBox(self.url, self.popup.Frame)
		field.Position = UDim2.fromOffset(PAD, PAD + HINT_HEIGHT)
		field.Size = UDim2.new(1, -PAD * 2, 0, FIELD_HEIGHT)
		field.ZIndex = 21
		self.field = field
	end

	-- Reset every open: the field stays editable (a non-editable TextBox is
	-- not reliably selectable across Studio versions, and selectable is the
	-- whole point here), so anything typed into it last time is discarded
	-- rather than handed out as the link.
	self.field.Text = self.url

	self.popup:OpenAt(self.Frame, POPUP_WIDTH, PAD * 2 + HINT_HEIGHT + FIELD_HEIGHT)

	-- Deferred: CaptureFocus on a frame that was Visible = false this same
	-- frame silently does nothing.
	task.defer(function()
		if not self.popup:IsOpen() then return end
		self.field:CaptureFocus()
		self.field.SelectionStart = 1
		self.field.CursorPosition = #self.url + 1
	end)
end

return LinkButton
]]></ProtectedString>
						<string name="ScriptGuid">{57EC3500-5EE9-46F6-9F80-2C89F9C86CC7}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">LinkButton</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX1BE2B7768C86441EB2034829C3A3858B">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: ask a yes/no question about a destructive action and
-- report the answer. It does NOT know what is being destroyed, and it never
-- performs anything itself — it takes a callback and calls it on confirm.
--
-- Built on Popup like every other floating surface, which also gives it the
-- "only one thing floats at a time" rule for free: opening the confirmation
-- closes the dropdown the × was clicked in, so the question is never buried
-- under the list it came from.
--
-- Deliberately anchored rather than centred on the widget: a modal in the
-- middle of a 320px dock widget covers the very row it is asking about.

local Theme = require(script.Parent.Theme)
local Popup = require(script.Parent.Popup)

local Confirm = {}
Confirm.__index = Confirm

local PAD = 10
local TITLE_HEIGHT = 18
local BODY_HEIGHT = 46
local BUTTON_HEIGHT = 26
local BUTTON_WIDTH = 76
local GAP = 6
local WIDTH = 244
local HEIGHT = PAD * 2 + TITLE_HEIGHT + BODY_HEIGHT + BUTTON_HEIGHT + GAP

function Confirm.new(overlay: GuiObject)
	local self = setmetatable({}, Confirm)
	self.popup = Popup.new(overlay)
	-- Set fresh on every Ask, and cleared as soon as it fires: a stale
	-- callback surviving a cancelled question is exactly how a confirmation
	-- dialog ends up deleting the wrong thing.
	self.onConfirm = nil :: (() -> ())?

	local frame = self.popup.Frame

	local title = Theme.Text("", frame)
	title.Position = UDim2.fromOffset(PAD, PAD)
	title.Size = UDim2.new(1, -PAD * 2, 0, TITLE_HEIGHT)
	title.Font = Theme.FONT_BOLD
	title.ZIndex = 21
	self.title = title

	local body = Theme.Text("", frame)
	body.Position = UDim2.fromOffset(PAD, PAD + TITLE_HEIGHT)
	body.Size = UDim2.new(1, -PAD * 2, 0, BODY_HEIGHT)
	body.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)
	body.TextSize = Theme.TEXT_SIZE - 1
	body.TextYAlignment = Enum.TextYAlignment.Top
	-- The body is the only variable-length text here, and truncating the
	-- consequences of a destructive action would defeat the point.
	body.TextWrapped = true
	body.TextTruncate = Enum.TextTruncate.None
	body.ZIndex = 21
	self.body = body

	local confirmButton = Theme.Button("Delete", frame)
	confirmButton.Size = UDim2.fromOffset(BUTTON_WIDTH, BUTTON_HEIGHT)
	confirmButton.Position = UDim2.new(1, -(PAD + BUTTON_WIDTH), 1, -(PAD + BUTTON_HEIGHT))
	confirmButton.BackgroundColor3 = Theme.DANGER
	confirmButton.TextColor3 = Theme.ACCENT_TEXT
	confirmButton.Font = Theme.FONT_BOLD
	confirmButton.ZIndex = 21
	-- See Panel: AutoButtonColor restores the fill it cached on mouse-enter,
	-- which would turn this red into Studio grey after the first hover.
	confirmButton.AutoButtonColor = false
	confirmButton.MouseEnter:Connect(function()
		confirmButton.BackgroundColor3 = Theme.DANGER:Lerp(Color3.new(1, 1, 1), 0.15)
	end)
	confirmButton.MouseLeave:Connect(function()
		confirmButton.BackgroundColor3 = Theme.DANGER
	end)
	self.confirmButton = confirmButton

	local cancel = Theme.Button("Cancel", frame)
	cancel.Size = UDim2.fromOffset(BUTTON_WIDTH, BUTTON_HEIGHT)
	cancel.Position = UDim2.new(1, -(PAD + BUTTON_WIDTH * 2 + GAP), 1, -(PAD + BUTTON_HEIGHT))
	cancel.ZIndex = 21

	confirmButton.Activated:Connect(function()
		-- Read and cleared BEFORE the popup closes: Close() is also what a
		-- cancel runs, and the callback must not be reachable twice.
		local callback = self.onConfirm
		self.onConfirm = nil
		self.popup:Close()
		if callback then callback() end
	end)
	cancel.Activated:Connect(function()
		self.onConfirm = nil
		self.popup:Close()
	end)

	return self
end

-- spec: Title, Body, ConfirmText (defaults to "Delete").
-- Clicking outside dismisses it through Popup's shared backdrop, which counts
-- as a cancel: nothing is destroyed unless the red button is pressed.
function Confirm:Ask(anchor: GuiObject, spec: { [string]: any }, onConfirm: () -> ())
	self.title.Text = spec.Title or "Are you sure?"
	self.body.Text = spec.Body or ""
	self.confirmButton.Text = spec.ConfirmText or "Delete"
	self.onConfirm = onConfirm
	self.popup:OpenAt(anchor, WIDTH, HEIGHT)
end

return Confirm
]]></ProtectedString>
						<string name="ScriptGuid">{A2AF2D6B-ADEF-43E8-BBB1-47A71B352BB0}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">Confirm</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
			</Item>
			<Item class="Folder" referent="RBX66075B7338CF40908F650547B842E073">
				<Properties>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">Sections</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
				<Item class="ModuleScript" referent="RBX0B67389F679541EFA7A00D7BE1AD159B">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: the controls for ONE colour channel (the cap's, the
-- text's, later others). A mode picker plus the fields that mode actually
-- uses — and only those: switching to Solid hides the gradient and variation
-- rows entirely.
--
-- That hiding IS the feature. The old panel showed all three modes' fields at
-- once, so nine rows were on screen and six of them did nothing.
--
-- Knows no attribute names of its own: it is handed a key mapping, and
-- forwards edits as (attributeKey, value) exactly like every other section.

local Components = script.Parent.Parent.Components
local Theme = require(Components.Theme)
local Row = require(Components.Row)
local Slider = require(Components.Slider)
local Segmented = require(Components.Segmented)
local ColorPicker = require(Components.ColorPicker)
local ColorModes = require(script.Parent.Parent.Parent.Edit.ColorModes)

local ColorChannel = {}
ColorChannel.__index = ColorChannel

local AXES = { "X", "Z" }

-- Named starting points for the variation sliders. They only move the
-- controls — every value stays visible and editable afterwards, so a preset
-- is a shortcut, never a mode.
-- Pastel also desaturates the BASE colour, because that is where pastel
-- actually lives (see Edit.RandomColor): the ranges alone cannot make one.
local PRESETS = {
	{ Name = "Shades", Hue = 0.04, Sat = 0.10, Value = 0.18 },
	{ Name = "Pastel", Hue = 0.35, Sat = 0.08, Value = 0.10, BaseSaturation = 0.30, BaseValue = 0.95 },
	{ Name = "Multicolor", Hue = 1.00, Sat = 0.15, Value = 0.15 },
}

local function degrees(value: number): string
	return string.format("%d\u{00B0}", math.round(value * 180))
end

local function percent(value: number): string
	return string.format("%d%%", math.round(value * 100))
end

-- spec: ModeKey, SolidKey, AxisKey, GradientKey, HueKey, SatKey, ValueKey.
function ColorChannel.new(parent: Instance, order: number, overlay: GuiObject, spec: { [string]: string })
	local self = setmetatable({}, ColorChannel)
	self.spec = spec
	self.OnValueChanged = nil :: ((string, any) -> ())?

	local function emit(key: string, value: any)
		if self.OnValueChanged then
			self.OnValueChanged(key, value)
		end
	end

	local modeRow = Row.new(parent, order, nil)
	self.mode = Segmented.new(modeRow.Content, { Options = ColorModes.List() })

	-- The base colour is shared by all three modes: Solid paints it, Gradient
	-- starts from it, Random varies around it. Keeping one control (with a
	-- label that says which role it plays) means switching modes never loses
	-- the colour you already chose.
	local baseRow = Row.new(parent, order + 1, "Color")
	self.baseRow = baseRow
	self.base = ColorPicker.new(baseRow.Content, { Overlay = overlay })
	self.base.OnChanged = function(color)
		emit(spec.SolidKey, color)
	end

	local endRow = Row.new(parent, order + 2, "To")
	self.endRow = endRow
	self.gradientEnd = ColorPicker.new(endRow.Content, { Overlay = overlay })
	self.gradientEnd.OnChanged = function(color)
		emit(spec.GradientKey, color)
	end

	local axisRow = Row.new(parent, order + 3, "Axis")
	self.axisRow = axisRow
	self.axis = Segmented.new(axisRow.Content, { Options = AXES })
	self.axis.OnChanged = function(value)
		emit(spec.AxisKey, value)
	end

	local presetRow = Row.new(parent, order + 4, nil)
	self.presetRow = presetRow
	local presetNames = {}
	for _, preset in ipairs(PRESETS) do
		table.insert(presetNames, preset.Name)
	end
	-- Stays lit on the preset you picked, so the row reflects what's applied.
	-- Note this can drift from the sliders if you drag one afterwards — the
	-- sliders remain the actual state, the highlight is just a shortcut hint.
	self.presets = Segmented.new(presetRow.Content, { Options = presetNames, Value = "" })
	self.presets.OnChanged = function(name)
		self:_applyPreset(name)
	end

	local hueRow = Row.new(parent, order + 5, "Hue range")
	self.hueRow = hueRow
	self.hue = Slider.new(hueRow.Content, { Min = 0, Max = 1, Step = 0.01, Format = degrees })
	self.hue.OnChanged = function(value) emit(spec.HueKey, value) end

	local satRow = Row.new(parent, order + 6, "Saturation")
	self.satRow = satRow
	self.sat = Slider.new(satRow.Content, { Min = 0, Max = 1, Step = 0.01, Format = percent })
	self.sat.OnChanged = function(value) emit(spec.SatKey, value) end

	local valueRow = Row.new(parent, order + 7, "Brightness")
	self.valueRow = valueRow
	self.value = Slider.new(valueRow.Content, { Min = 0, Max = 1, Step = 0.01, Format = percent })
	self.value.OnChanged = function(value) emit(spec.ValueKey, value) end

	self.mode.OnChanged = function(value)
		self:_refreshVisibility(value)
		emit(spec.ModeKey, value)
	end

	self:_refreshVisibility(ColorModes.DEFAULT_NAME)
	return self
end

-- Returns the LayoutOrder slots this channel occupies, so the caller can lay
-- out what comes after it without counting rows by hand.
ColorChannel.ROW_COUNT = 8

-- Finds the preset (if any) that matches the given hue/sat/value, so the
-- highlighted button always reflects what's actually loaded.
--
-- Compared with a tolerance, not with ==. The sliders step by 0.01 and the
-- values make a round trip through an attribute, so a value that IS the
-- preset's comes back as 0.15000000000000002 often enough to matter: exact
-- equality left the row with nothing lit and no way to tell what was applied.
-- Half a step is well inside the gap between any two presets.
local MATCH_EPSILON = 0.005

local function near(a: number?, b: number): boolean
	return type(a) == "number" and math.abs(a - b) < MATCH_EPSILON
end

local function matchPreset(hue: number?, sat: number?, value: number?): string
	for _, preset in ipairs(PRESETS) do
		if near(hue, preset.Hue) and near(sat, preset.Sat) and near(value, preset.Value) then
			return preset.Name
		end
	end
	return ""
end

function ColorChannel:_applyPreset(name: string)
	for _, preset in ipairs(PRESETS) do
		if preset.Name == name then
			self.hue:Set(preset.Hue)
			self.sat:Set(preset.Sat)
			self.value:Set(preset.Value)
			if self.OnValueChanged then
				self.OnValueChanged(self.spec.HueKey, preset.Hue)
				self.OnValueChanged(self.spec.SatKey, preset.Sat)
				self.OnValueChanged(self.spec.ValueKey, preset.Value)
			end
			if preset.BaseSaturation then
				local hue = select(1, self.base:Color():ToHSV())
				local pastel = Color3.fromHSV(hue, preset.BaseSaturation, preset.BaseValue)
				self.base:Set(pastel)
				if self.OnValueChanged then
					self.OnValueChanged(self.spec.SolidKey, pastel)
				end
			end
			break
		end
	end
end

function ColorChannel:_refreshVisibility(mode: string)
	local gradient = mode == "Gradient"
	local random = mode == "Random"

	self.baseRow.Label.Text = gradient and "From" or (random and "Base color" or "Color")
	self.endRow:SetVisible(gradient)
	self.axisRow:SetVisible(gradient)
	self.presetRow:SetVisible(random)
	self.hueRow:SetVisible(random)
	self.satRow:SetVisible(random)
	self.valueRow:SetVisible(random)
end

-- Reflects the group's stored values. Silent by construction: every component
-- :Set is silent, so loading a group can never echo back as an edit.
function ColorChannel:Show(values: { [string]: any })
	local spec = self.spec
	local mode = values[spec.ModeKey]
	self.mode:Set(mode)
	self:_refreshVisibility(self.mode.value)
	self.base:Set(values[spec.SolidKey])
	self.gradientEnd:Set(values[spec.GradientKey])
	self.axis:Set(values[spec.AxisKey])
	self.hue:Set(values[spec.HueKey])
	self.sat:Set(values[spec.SatKey])
	self.value:Set(values[spec.ValueKey])
	self.presets:Set(matchPreset(values[spec.HueKey], values[spec.SatKey], values[spec.ValueKey]))
end

return ColorChannel
]]></ProtectedString>
						<string name="ScriptGuid">{9A9A4972-261A-42E6-A9EE-AF2B67CD0EEC}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">ColorChannel</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX71E595997A054572815FFEE3E1D4B6E6">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: everything about the BRUSH itself — which tool,
-- how big, which way round, and what the keys it paints are labelled.
--
-- Kept apart from the style sections because these settings belong to the
-- gesture, not to the group: they change several times a minute, and the user
-- has to be able to find them without scrolling past twenty colour rows.

local Components = script.Parent.Parent.Components
local Row = require(Components.Row)
local Slider = require(Components.Slider)
local Segmented = require(Components.Segmented)
local Theme = require(Components.Theme)
local LabelSource = require(script.Parent.Parent.Parent.Placement.LabelSource)
local Eraser = require(script.Parent.Parent.Parent.Placement.Eraser)

local ToolSection = {}
ToolSection.__index = ToolSection

local ROTATIONS = { "0", "90", "180", "270", "Random" }
local MAX_BRUSH_SIZE = 16

function ToolSection.new(parent: Instance, order: number)
	local self = setmetatable({}, ToolSection)

	self.OnToolChanged = nil :: ((string) -> ())?
	self.OnSizeChanged = nil :: ((number) -> ())?
	self.OnRotationChanged = nil :: ((string) -> ())?
	self.OnEraseScopeChanged = nil :: ((string) -> ())?
	self.OnLabelModeChanged = nil :: ((string) -> ())?
	self.OnLabelChanged = nil :: ((string) -> ())?
	self.OnCharsetChanged = nil :: ((string) -> ())?

	local toolRow = Row.new(parent, order, nil)
	self.tool = Segmented.new(toolRow.Content, {
		Options = { "Paint", "Erase" },
		Icons = { "Paint", "Erase" },
	})

	local sizeRow = Row.new(parent, order + 1, "Brush size")
	self.size = Slider.new(sizeRow.Content, {
		Min = 1, Max = MAX_BRUSH_SIZE, Step = 1, Value = 1,
		Format = function(value: number) return string.format("%d\u{00D7}%d", value, value) end,
	})
	self.size.OnChanged = function(value)
		if self.OnSizeChanged then self.OnSizeChanged(value) end
	end

	-- "Random" is a 5th option, not a numeric step: rather than force it into
	-- the 0-3 range the callback used to carry, the raw string goes out and
	-- Main decides what it means (Brush.SetRotation vs SetRotationRandom).
	local rotationRow = Row.new(parent, order + 2, "Rotation (R)")
	self.rotation = Segmented.new(rotationRow.Content, { Options = ROTATIONS })
	self.rotation.OnChanged = function(value)
		if self.OnRotationChanged then
			self.OnRotationChanged(value)
		end
	end

	-- Erase only. Scoping to the current group is the default because the
	-- accident this guards against — wiping a whole keyboard while cleaning
	-- up one row — is not one you notice until several strokes later.
	local scopeRow = Row.new(parent, order + 3, "Erase")
	self.scopeRow = scopeRow
	self.scope = Segmented.new(scopeRow.Content, {
		Options = Eraser.SCOPES,
		Labels = { Group = "This group", All = "Any key" },
	})
	self.scope.OnChanged = function(value)
		if self.OnEraseScopeChanged then self.OnEraseScopeChanged(value) end
	end

	local labelRow = Row.new(parent, order + 4, "Label")
	self.labelRow = labelRow
	self.labelMode = Segmented.new(labelRow.Content, { Options = LabelSource.MODES, Value = "Random" })

	local textRow = Row.new(parent, order + 5, "Character")
	self.textRow = textRow
	local labelBox = Theme.TextBox("A", textRow.Content)
	self.labelBox = labelBox
	labelBox.FocusLost:Connect(function()
		local value = labelBox.Text:gsub("^%s+", ""):gsub("%s+$", "")
		if value == "" then value = "A" end
		value = value:upper()
		labelBox.Text = value
		if self.OnLabelChanged then self.OnLabelChanged(value) end
	end)

	local charsetRow = Row.new(parent, order + 6, "Characters")
	self.charsetRow = charsetRow
	self.charset = Segmented.new(charsetRow.Content, { Options = LabelSource.CharsetNames(), Value = LabelSource.DEFAULT_CHARSET })
	self.charset.OnChanged = function(value)
		if self.OnCharsetChanged then self.OnCharsetChanged(value) end
	end

	self.labelMode.OnChanged = function(value)
		self:_refresh()
		if self.OnLabelModeChanged then self.OnLabelModeChanged(value) end
	end
	self.tool.OnChanged = function(value)
		self:_refresh()
		if self.OnToolChanged then self.OnToolChanged(value) end
	end

	self:_refresh()
	return self
end

ToolSection.ROW_COUNT = 7

-- Only the rows the current tool actually uses. Erasing has nothing to do
-- with what a key is labelled, so those rows go away entirely.
function ToolSection:_refresh()
	local erasing = self.tool.value == "Erase"
	self.scopeRow:SetVisible(erasing)
	self.labelRow:SetVisible(not erasing)
	self.textRow:SetVisible(not erasing and self.labelMode.value == "Fixed")
	self.charsetRow:SetVisible(not erasing and self.labelMode.value == "Random")
end

-- Rotation can change from the R key, so the section has to be able to follow
-- a value it did not originate.
function ToolSection:SetRotation(steps: number)
	-- "Random" is a MODE, not a rotation the brush can report back. Following
	-- the brush there would move the highlight onto whatever angle the last
	-- key happened to get, so the row would claim a fixed rotation was
	-- selected while the brush was still randomising.
	if self.rotation.value == "Random" then return end
	self.rotation:Set(ROTATIONS[(steps % 4) + 1])
end

return ToolSection
]]></ProtectedString>
						<string name="ScriptGuid">{74AE42DF-CCB3-405B-8DBB-C4AD57FF65F4}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">ToolSection</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBXF4B6EC1F7914443AB2C3A2CD5A7E55A4">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: edit the group's sound list.
--
-- Stored as a comma-separated string (that is the attribute's shape), but
-- never SHOWN as one: a text field holding "123,456,789" is unreadable and
-- one stray comma silently drops a sound. Here each id is its own row with
-- its own remove button.

local Components = script.Parent.Parent.Components
local Theme = require(Components.Theme)
local Row = require(Components.Row)
local Slider = require(Components.Slider)
local SoundId = require(script.Parent.Parent.Parent.RuntimeSource.Style.SoundId)

local SoundSection = {}
SoundSection.__index = SoundSection

local KEY = "SoundIds"

-- Kept RAW on purpose: an entry the game will reject still has to come back
-- into the field so it can be corrected. Judging it is SoundId's job, and it
-- happens per row at build time, not here.
local function split(csv: string): { string }
	local ids: { string } = {}
	for piece in tostring(csv):gmatch("[^,]+") do
		local trimmed = SoundId.Trim(piece)
		if trimmed ~= "" then
			table.insert(ids, trimmed)
		end
	end
	return ids
end

function SoundSection.new(parent: Instance, order: number)
	local self = setmetatable({}, SoundSection)
	self.ids = {} :: { string }
	self.OnValueChanged = nil :: ((string, any) -> ())?

	-- The id rows are rebuilt on every change, so they live in their own
	-- stack: rebuilding must not take the pitch row with it.
	local stack = Theme.Stack(parent, order)
	self.stack = stack

	local addRow = Row.new(parent, order + 1, nil)
	local add = Theme.Button("", addRow.Content)
	add.Activated:Connect(function()
		table.insert(self.ids, "")
		self:_rebuild()
	end)

	local addInner = Instance.new("Frame")
	addInner.Size = UDim2.fromScale(1, 1)
	addInner.BackgroundTransparency = 1
	addInner.Parent = add
	local addLayout = Instance.new("UIListLayout")
	addLayout.FillDirection = Enum.FillDirection.Horizontal
	addLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
	addLayout.VerticalAlignment = Enum.VerticalAlignment.Center
	addLayout.Padding = UDim.new(0, 5)
	addLayout.Parent = addInner

	local addIcon = Theme.Icon(addInner, "AddSound", 14)
	addIcon.LayoutOrder = 1

	local addLabel = Theme.Text("Add sound", addInner)
	addLabel.Size = UDim2.fromOffset(0, 20)
	addLabel.AutomaticSize = Enum.AutomaticSize.X
	addLabel.LayoutOrder = 2
	addLabel.TextColor3 = Theme.ACCENT

	local volumeRow = Row.new(parent, order + 2, "Volume")
	-- Max 5, not 1: 1 is only Roblox's DEFAULT Sound.Volume, not its ceiling
	-- (the property goes to 10). A board in a noisy scene needs the headroom.
	self.volume = Slider.new(volumeRow.Content, {
		Min = 0, Max = 5, Step = 0.05,
		Format = function(value: number) return string.format("%d%%", math.round(value * 100)) end,
	})
	self.volume.OnChanged = function(value)
		if self.OnValueChanged then self.OnValueChanged("Volume", value) end
	end

	local pitchRow = Row.new(parent, order + 3, "Pitch jitter")
	self.pitch = Slider.new(pitchRow.Content, {
		Min = 0, Max = 0.5, Step = 0.01,
		Format = function(value: number) return string.format("%d%%", math.round(value * 100)) end,
	})
	self.pitch.OnChanged = function(value)
		if self.OnValueChanged then self.OnValueChanged("PitchJitter", value) end
	end

	self:_rebuild()
	return self
end

SoundSection.ROW_COUNT = 4

function SoundSection:_commit()
	if self.OnValueChanged then
		self.OnValueChanged(KEY, table.concat(self.ids, ","))
	end
end

function SoundSection:_rebuild()
	for _, child in ipairs(self.stack:GetChildren()) do
		if child:IsA("GuiObject") then child:Destroy() end
	end

	if #self.ids == 0 then
		local empty = Row.new(self.stack, 1, nil)
		local hint = Theme.Text("Default sound", empty.Content)
		hint.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)
		return
	end

	-- Two rows per id at most, so the order has to leave room: an id's field
	-- and the message that may sit under it are one unit.
	for index, id in ipairs(self.ids) do
		local row = Row.new(self.stack, index * 2, nil)

		local box = Theme.TextBox(id, row.Content)
		box.Size = UDim2.new(1, -26, 1, 0)
		-- Says both accepted forms rather than only the canonical one: the
		-- number alone is what the toolbox gives you, and it works.
		box.PlaceholderText = "number or rbxassetid://"
		box.FocusLost:Connect(function()
			-- The canonical form is stored when there is one, so typing "123"
			-- and seeing it become "rbxassetid://123" is itself the
			-- confirmation that it was understood. Unusable text is kept
			-- verbatim so it can be fixed rather than silently swallowed.
			local parsed = SoundId.Parse(box.Text)
			self.ids[index] = parsed or SoundId.Trim(box.Text)
			self:_rebuild()
			self:_commit()
		end)

		local _, problem = SoundId.Parse(id)
		if problem then
			-- Border, not the default Contextual: on a TextBox, Contextual
			-- strokes the GLYPHS. Same reason GroupSection's name field does it.
			local stroke = Instance.new("UIStroke")
			stroke.ApplyStrokeMode = Enum.ApplyStrokeMode.Border
			stroke.Color = Theme.DANGER
			stroke.Thickness = 1
			stroke.Parent = box

			-- The reason goes UNDER the field it belongs to, not in the status
			-- line: with several ids listed there would be no way to tell which
			-- one a single shared message was about.
			local messageRow = Row.new(self.stack, index * 2 + 1, nil, 28)
			local message = Theme.Text(problem, messageRow.Content)
			message.TextColor3 = Theme.DANGER
			message.TextSize = Theme.TEXT_SIZE - 2
			-- Wrapped, not truncated: these run past the panel's width and the
			-- end of the sentence is the half that says what to do about it.
			message.TextWrapped = true
			message.TextTruncate = Enum.TextTruncate.None
			message.TextYAlignment = Enum.TextYAlignment.Top
		end

		local remove = Theme.Button("\u{00D7}", row.Content)
		remove.Size = UDim2.new(0, 22, 1, 0)
		remove.Position = UDim2.new(1, -22, 0, 0)
		remove.TextColor3 = Theme.DANGER
		remove.Activated:Connect(function()
			table.remove(self.ids, index)
			self:_rebuild()
			self:_commit()
		end)
	end
end

function SoundSection:Show(values: { [string]: any })
	self.ids = split(values[KEY] or "")
	self:_rebuild()
	self.volume:Set(values.Volume)
	self.pitch:Set(values.PitchJitter)
end

return SoundSection
]]></ProtectedString>
						<string name="ScriptGuid">{8ADFFE91-2929-42D3-9EB4-579DB8B389DE}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">SoundSection</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBXCE76A297449C4664809653589E501700">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: the label's typography — font and outline. The
-- label's COLOUR is a ColorChannel like the cap's; this is what is left once
-- colour is handled elsewhere.
--
-- The outline colour and thickness only exist while the outline is on, so
-- they are hidden when it is off rather than sitting there doing nothing.

local Components = script.Parent.Parent.Components
local Row = require(Components.Row)
local Slider = require(Components.Slider)
local Toggle = require(Components.Toggle)
local Dropdown = require(Components.Dropdown)
local ColorPicker = require(Components.ColorPicker)
local FontPresets = require(script.Parent.Parent.Parent.RuntimeSource.Style.FontPresets)

local TextStyleSection = {}
TextStyleSection.__index = TextStyleSection

function TextStyleSection.new(parent: Instance, order: number, overlay: GuiObject)
	local self = setmetatable({}, TextStyleSection)
	self.OnValueChanged = nil :: ((string, any) -> ())?

	local function emit(key: string, value: any)
		if self.OnValueChanged then
			self.OnValueChanged(key, value)
		end
	end

	-- A dropdown rather than a segmented control: the font list is the one
	-- set here that is expected to keep growing.
	local fontRow = Row.new(parent, order, "Font")
	local entries = {}
	for _, name in ipairs(FontPresets.List()) do
		table.insert(entries, { Value = name })
	end
	self.font = Dropdown.new(fontRow.Content, { Entries = entries, Overlay = overlay })
	self.font.OnChanged = function(value) emit("FontName", value) end

	local outlineRow = Row.new(parent, order + 1, "Outline")
	self.outline = Toggle.new(outlineRow.Content, {})

	local colorRow = Row.new(parent, order + 2, "Outline color")
	self.colorRow = colorRow
	self.outlineColor = ColorPicker.new(colorRow.Content, { Overlay = overlay })
	self.outlineColor.OnChanged = function(color) emit("StrokeColor", color) end

	local sizeRow = Row.new(parent, order + 3, "Outline size")
	self.sizeRow = sizeRow
	self.outlineSize = Slider.new(sizeRow.Content, {
		Min = 0, Max = 6, Step = 0.5,
		Format = function(value: number) return string.format("%.1f", value) end,
	})
	self.outlineSize.OnChanged = function(value) emit("StrokeSize", value) end

	-- SurfaceGui.MaxDistance: how far the label stays readable. 100-250, a
	-- narrower range than a raw stud value would allow — below 100 the label
	-- starts disappearing well within normal viewing range, above 250 it never
	-- fades and the range stops meaning anything.
	local distanceRow = Row.new(parent, order + 4, "Label distance")
	self.maxDistance = Slider.new(distanceRow.Content, {
		Min = 100, Max = 250, Step = 10,
		Format = function(value: number) return string.format("%d", value) end,
	})
	self.maxDistance.OnChanged = function(value) emit("LabelMaxDistance", value) end

	self.outline.OnChanged = function(enabled)
		self:_refresh(enabled)
		emit("StrokeEnabled", enabled)
	end

	self:_refresh(false)
	return self
end

TextStyleSection.ROW_COUNT = 5

function TextStyleSection:_refresh(enabled: boolean)
	self.colorRow:SetVisible(enabled)
	self.sizeRow:SetVisible(enabled)
end

function TextStyleSection:Show(values: { [string]: any })
	self.font:Set(values.FontName)
	self.outline:Set(values.StrokeEnabled)
	self:_refresh(values.StrokeEnabled)
	self.outlineColor:Set(values.StrokeColor)
	self.outlineSize:Set(values.StrokeSize)
	self.maxDistance:Set(values.LabelMaxDistance)
end

return TextStyleSection
]]></ProtectedString>
						<string name="ScriptGuid">{8E088500-CD1C-4F1F-BE5A-B4BD7EBE5E5D}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">TextStyleSection</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBXE8C20EF874204C82BB27483B241ECA90">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: how a key behaves when it is pressed — which
-- animation preset, and the two timings around it.
--
-- The timings were free-text seconds before. As sliders they are bounded to a
-- range that still reads as a keypress: 0.4s to go down is not a slow key,
-- it is a broken one.

local Components = script.Parent.Parent.Components
local Row = require(Components.Row)
local Slider = require(Components.Slider)
local Segmented = require(Components.Segmented)
local AnimationPresets = require(script.Parent.Parent.Parent.RuntimeSource.Style.AnimationPresets)

local AnimationSection = {}
AnimationSection.__index = AnimationSection

local function seconds(value: number): string
	return string.format("%.3fs", value)
end

function AnimationSection.new(parent: Instance, order: number)
	local self = setmetatable({}, AnimationSection)
	self.OnValueChanged = nil :: ((string, any) -> ())?

	local function emit(key: string, value: any)
		if self.OnValueChanged then
			self.OnValueChanged(key, value)
		end
	end

	local styleRow = Row.new(parent, order, nil)
	self.style = Segmented.new(styleRow.Content, { Options = AnimationPresets.List() })
	self.style.OnChanged = function(value) emit("AnimationStyle", value) end

	local pressRow = Row.new(parent, order + 1, "Press")
	self.press = Slider.new(pressRow.Content, { Min = 0.01, Max = 0.2, Step = 0.005, Format = seconds })
	self.press.OnChanged = function(value) emit("PressTime", value) end

	local releaseRow = Row.new(parent, order + 2, "Release")
	self.release = Slider.new(releaseRow.Content, { Min = 0.02, Max = 0.4, Step = 0.005, Format = seconds })
	self.release.OnChanged = function(value) emit("ReleaseTime", value) end

	return self
end

AnimationSection.ROW_COUNT = 3

function AnimationSection:Show(values: { [string]: any })
	self.style:Set(values.AnimationStyle)
	self.press:Set(values.PressTime)
	self.release:Set(values.ReleaseTime)
end

return AnimationSection
]]></ProtectedString>
						<string name="ScriptGuid">{53EE7F2B-1175-49B3-8260-8C0A614215B6}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">AnimationSection</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX9FEE1B2E99D14AB38C3CEB3965FA1FA0">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: plugin-wide settings that are NOT per-group — right
-- now just whether the points/DataStore system runs at all. Talks straight
-- to RuntimeSource.Settings (a game-side attribute), unlike every other
-- section here which forwards to the CURRENT GROUP's Configuration.

local Components = script.Parent.Parent.Components
local Row = require(Components.Row)
local Theme = require(Components.Theme)
local Toggle = require(Components.Toggle)

local OthersSection = {}
OthersSection.__index = OthersSection

function OthersSection.new(parent: Instance, order: number)
	local self = setmetatable({}, OthersSection)
	-- Same (key, value) shape as every group section's OnValueChanged, even
	-- though these two keys land in Settings instead of a group's
	-- Configuration — Panel/Main don't need a second callback shape for it.
	self.OnValueChanged = nil :: ((string, any) -> ())?

	local function emit(key: string, value: any)
		if self.OnValueChanged then self.OnValueChanged(key, value) end
	end

	local pointsRow = Row.new(parent, order, "Points + DataStore")
	self.points = Toggle.new(pointsRow.Content, {})

	local hintRow = Row.new(parent, order + 1, nil, 32)
	local hint = Theme.Text("Counts key presses per player, saved via DataStore.", hintRow.Content)
	hint.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)
	hint.TextWrapped = true
	hint.TextYAlignment = Enum.TextYAlignment.Top
	hint.TextSize = Theme.TEXT_SIZE - 2

	-- Meaningless with Points off (there is nothing for it to display), so it
	-- only ever shows up once Points itself is on — see the points toggle
	-- handler below, which is the only thing that flips this row's visibility.
	local autoUIRow = Row.new(parent, order + 2, "AutoUI")
	self.autoUIRow = autoUIRow
	self.autoUI = Toggle.new(autoUIRow.Content, {})
	self.autoUI.OnChanged = function(value) emit("AutoUIEnabled", value) end

	local autoUIHintRow = Row.new(parent, order + 3, nil, 32)
	self.autoUIHintRow = autoUIHintRow
	local autoUIHint = Theme.Text("Builds the points counter and \"+1\" popups automatically.", autoUIHintRow.Content)
	autoUIHint.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)
	autoUIHint.TextWrapped = true
	autoUIHint.TextYAlignment = Enum.TextYAlignment.Top
	autoUIHint.TextSize = Theme.TEXT_SIZE - 2

	self.points.OnChanged = function(value)
		self:_refreshAutoUIVisibility(value)
		emit("PointsEnabled", value)
	end

	-- Hidden until Panel:SetOthers (Main.lua, at plugin load) proves Points is
	-- actually on: a Toggle defaults to false internally (see Toggle.new) but
	-- a Row defaults to VISIBLE, so without this the row showed up for one
	-- frame — or longer, if that first SetOthers call was ever delayed or
	-- skipped — regardless of the real persisted setting.
	--
	-- "Or longer" is exactly what happened: SetOthers used to be called ONLY
	-- when the brush was switched on, so a user who just opened the panel saw
	-- Points reading OFF while it was really on, and no AutoUI switch at all.
	-- Main now pushes the real values at load and on every widget open; do not
	-- go back to a single conditional call site.
	self:_refreshAutoUIVisibility(false)

	return self
end

function OthersSection:_refreshAutoUIVisibility(pointsEnabled: boolean)
	self.autoUIRow:SetVisible(pointsEnabled)
	self.autoUIHintRow:SetVisible(pointsEnabled)
end

OthersSection.ROW_COUNT = 4

function OthersSection:Show(values: { [string]: any })
	self.points:Set(values.PointsEnabled)
	self.autoUI:Set(values.AutoUIEnabled)
	-- Coerced to a real boolean: SetVisible errors on nil, which a missing
	-- attribute (typeof(nil)) would otherwise pass straight through as a
	-- silent failure that left this row stuck visible.
	self:_refreshAutoUIVisibility(values.PointsEnabled == true)
end

return OthersSection
]]></ProtectedString>
						<string name="ScriptGuid">{606C6630-9BE0-482E-86F2-9008F657E31B}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">OthersSection</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX450F76BC44AA45E1BEBAA9FE17D80DCF">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: the group control at the top of the panel — picking
-- a group, creating one, deleting one. One row tall, always, in both of its
-- states, because the Panel's pinned header reserves exactly that much and a
-- control that changed height would shove the whole body around.
--
-- It has two faces, swapped in place:
--
--   BROWSE   [◉ Group] [ Default          12 ▾] [+]
--   CREATE   [ name…                ] [Create] [×]
--
-- Creating used to live INSIDE the dropdown's popup, which had it backwards:
-- you opened a *selector* in order to *create*, the field appeared under a
-- row that still read "+ New group" (so the obvious thing to click was the
-- label, not the field), and there were two competing entry points into the
-- same flow. Now creation is its own mode, it owns the full row width instead
-- of being squeezed into a popup, and there is exactly one way in: the [+].
--
-- The one rule that matters here: losing focus NEVER commits or cancels.
-- Clicking [Create] necessarily blurs the text field first, so a field that
-- acted on FocusLost would tear its own UI down before the click it was
-- waiting for could land. Only Enter, [Create] and [×] change state.

local Components = script.Parent.Parent.Components
local Theme = require(Components.Theme)
local Dropdown = require(Components.Dropdown)
local Confirm = require(Components.Confirm)

local GroupSection = {}
GroupSection.__index = GroupSection

local ADD_WIDTH = 26
local CANCEL_WIDTH = 24
local CREATE_WIDTH = 62
local GAP = 6

local function trim(text: string): string
	return (text:gsub("^%s+", ""):gsub("%s+$", ""))
end

function GroupSection.new(parent: Instance, order: number, overlay: GuiObject)
	local self = setmetatable({}, GroupSection)
	self.OnChanged = nil :: ((string) -> ())?
	self.OnCreated = nil :: ((string) -> ())?
	self.OnDeleted = nil :: ((string) -> ())?
	self.names = {} :: { [string]: boolean }
	-- Kept alongside `names` because the delete confirmation needs the key
	-- COUNT, not just whether the name exists. Main already computes it for
	-- the dropdown's Detail column; re-deriving it here would mean the view
	-- walking the board itself, which is not its job.
	self.entries = {} :: { [string]: any }

	local frame = Instance.new("Frame")
	frame.Name = "GroupSection"
	frame.Size = UDim2.new(1, 0, 0, Theme.ROW_HEIGHT)
	frame.BackgroundTransparency = 1
	frame.BorderSizePixel = 0
	frame.LayoutOrder = order
	frame.Parent = parent
	self.Frame = frame

	self:_buildBrowse(frame, overlay)
	self:_buildCreate(frame)
	self:_setCreating(false)

	return self
end

function GroupSection:_buildBrowse(parent: Frame, overlay: GuiObject)
	local face = Instance.new("Frame")
	face.Name = "Browse"
	face.Size = UDim2.fromScale(1, 1)
	face.BackgroundTransparency = 1
	face.Parent = parent
	self.browseFace = face

	-- Same icon-chip + dimmed-label treatment Row gives its labelled rows.
	-- Built by hand rather than through Row because the CREATE face has to be
	-- able to take over the label's half of the line too — 168px of field is
	-- the difference between comfortably naming a group and not.
	local holder = Instance.new("Frame")
	holder.BackgroundTransparency = 1
	holder.Size = UDim2.new(Theme.LABEL_WIDTH, -Theme.GAP, 1, 0)
	holder.Parent = face

	local holderLayout = Instance.new("UIListLayout")
	holderLayout.FillDirection = Enum.FillDirection.Horizontal
	holderLayout.VerticalAlignment = Enum.VerticalAlignment.Center
	holderLayout.Padding = UDim.new(0, 6)
	holderLayout.SortOrder = Enum.SortOrder.LayoutOrder
	holderLayout.Parent = holder

	local chip = Theme.IconChip(holder, "Group", 20, 12)
	chip.LayoutOrder = 1

	local label = Theme.Text("Group", holder)
	label.Size = UDim2.new(1, -26, 1, 0)
	label.LayoutOrder = 2
	label.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)

	local content = Instance.new("Frame")
	content.BackgroundTransparency = 1
	content.Position = UDim2.fromScale(Theme.LABEL_WIDTH, 0)
	content.Size = UDim2.new(1 - Theme.LABEL_WIDTH, 0, 1, 0)
	content.Parent = face

	self.dropdown = Dropdown.new(content, {
		Entries = {},
		Overlay = overlay,
		OnDelete = function(name: string)
			self:_askDelete(name)
		end,
	})
	self.dropdown.Frame.Size = UDim2.new(1, -(ADD_WIDTH + GAP), 1, 0)
	self.dropdown.OnChanged = function(name: string)
		if self.OnChanged then self.OnChanged(name) end
	end

	-- One instance reused for every question: Popup already enforces that only
	-- one floating surface is open, so a second one would only ever be dead
	-- weight in the overlay.
	self.confirm = Confirm.new(overlay)

	-- Accent-FILLED, not an accent-coloured glyph on the default button fill:
	-- this is the only creative action in the header and it has to read as
	-- the primary affordance next to a dropdown, not as a third piece of
	-- dropdown furniture.
	local add = Theme.Button("+", content)
	add.Name = "Add"
	add.Size = UDim2.new(0, ADD_WIDTH, 1, 0)
	add.Position = UDim2.new(1, -ADD_WIDTH, 0, 0)
	add.BackgroundColor3 = Theme.ACCENT
	add.TextColor3 = Theme.ACCENT_TEXT
	-- Required for any button with an overridden fill: AutoButtonColor writes
	-- back the colour it cached on mouse-enter when the cursor leaves, which
	-- turns this accent into Studio's default grey after the first hover.
	-- Same failure the brush bar had — see Panel.
	add.AutoButtonColor = false
	add.MouseEnter:Connect(function()
		add.BackgroundColor3 = Theme.ACCENT:Lerp(Color3.new(1, 1, 1), 0.15)
	end)
	add.MouseLeave:Connect(function()
		add.BackgroundColor3 = Theme.ACCENT
	end)
	add.Font = Theme.FONT_BOLD
	add.TextSize = Theme.TEXT_SIZE + 3
	-- Same one-pixel lift the dropdown beside it needs, for the same reason:
	-- Gotham's descent leaves engine-centred text sitting low, and a [+] that
	-- is off-centre inside its own square is the kind of thing you feel before
	-- you can name it. See the note in Dropdown.new.
	local addPad = Instance.new("UIPadding")
	addPad.PaddingBottom = UDim.new(0, 2)
	addPad.Parent = add
	add.Activated:Connect(function()
		self:_setCreating(true)
	end)
end

function GroupSection:_buildCreate(parent: Frame)
	local face = Instance.new("Frame")
	face.Name = "Create"
	face.Size = UDim2.fromScale(1, 1)
	face.BackgroundTransparency = 1
	face.Parent = parent
	self.createFace = face

	local reserved = CREATE_WIDTH + CANCEL_WIDTH + GAP * 2

	local box = Theme.TextBox("", face)
	box.Name = "Name"
	box.Size = UDim2.new(1, -reserved, 1, 0)
	box.PlaceholderText = "Group name…"
	box.ClearTextOnFocus = false
	self.nameBox = box

	-- Border, NOT the default Contextual: on a TextBox, Contextual strokes
	-- the GLYPHS, so this outlined the typed text itself instead of the
	-- field. And it only ever appears for a collision — a permanent accent
	-- ring around a field that is merely focused is noise, the caret already
	-- says where you are typing.
	local boxStroke = Instance.new("UIStroke")
	boxStroke.ApplyStrokeMode = Enum.ApplyStrokeMode.Border
	boxStroke.Color = Theme.DANGER
	boxStroke.Thickness = 1
	boxStroke.Enabled = false
	boxStroke.Parent = box
	self.boxStroke = boxStroke

	local create = Theme.Button("Create", face)
	create.Name = "Confirm"
	create.Size = UDim2.new(0, CREATE_WIDTH, 1, 0)
	create.Position = UDim2.new(1, -(CREATE_WIDTH + CANCEL_WIDTH + GAP), 0, 0)
	create.Font = Theme.FONT_BOLD
	self.createButton = create

	local cancel = Theme.Button("\u{00D7}", face)
	cancel.Name = "Cancel"
	cancel.Size = UDim2.new(0, CANCEL_WIDTH, 1, 0)
	cancel.Position = UDim2.new(1, -CANCEL_WIDTH, 0, 0)
	cancel.BackgroundTransparency = 1
	cancel.AutoButtonColor = false
	cancel.Font = Theme.FONT_BOLD
	cancel.TextSize = Theme.TEXT_SIZE + 2
	cancel.TextColor3 = Theme.Color(Enum.StudioStyleGuideColor.DimmedText)

	box:GetPropertyChangedSignal("Text"):Connect(function()
		self:_refreshValidity()
	end)

	-- Enter commits, and only when the name is actually usable. An unusable
	-- name keeps the field open with its reason showing rather than silently
	-- throwing away what was typed.
	box.FocusLost:Connect(function(enterPressed: boolean)
		if enterPressed then
			self:_commit()
		end
	end)

	create.Activated:Connect(function()
		self:_commit()
	end)
	cancel.Activated:Connect(function()
		self:_setCreating(false)
	end)
end

-- Empty is "not ready yet" and says nothing; a collision is a real error and
-- says so on the button itself, where the eye already is when it finds the
-- button greyed out.
function GroupSection:_validity(): (boolean, string)
	local name = trim(self.nameBox.Text)
	if name == "" then return false, "Create" end
	if self.names[name] then return false, "Exists" end
	return true, "Create"
end

function GroupSection:_refreshValidity()
	local valid, caption = self:_validity()
	local collision = caption == "Exists"

	self.createButton.Text = caption
	-- Never AutoButtonColor here either (see the [+] above): it would cache
	-- the accent fill on hover and restore it over the disabled grey the
	-- moment the name became invalid under the cursor.
	self.createButton.AutoButtonColor = false
	self.createButton.BackgroundColor3 = valid
		and Theme.ACCENT
		or Theme.ButtonFill()
	self.createButton.TextColor3 = valid
		and Theme.ACCENT_TEXT
		or Theme.Color(Enum.StudioStyleGuideColor.DimmedText)

	self.boxStroke.Enabled = collision
end

function GroupSection:_commit()
	local valid = self:_validity()
	if not valid then
		-- Nothing usable to create, but the user is clearly still working on
		-- it: keep the mode and put the caret back where they left it.
		if trim(self.nameBox.Text) ~= "" then
			self.nameBox:CaptureFocus()
		end
		return
	end

	local name = trim(self.nameBox.Text)
	self:_setCreating(false)
	if self.OnCreated then self.OnCreated(name) end
end

function GroupSection:_setCreating(creating: boolean)
	self.creating = creating
	self.browseFace.Visible = not creating
	self.createFace.Visible = creating

	if creating then
		-- Always from empty: an abandoned name must not be waiting in the
		-- field the next time the [+] is pressed.
		self.nameBox.Text = ""
		self:_refreshValidity()
		self.nameBox:CaptureFocus()
	else
		self.nameBox:ReleaseFocus()
	end
end

-- Deleting a group takes its keys with it (see Main for why), which is the
-- kind of thing a × must never do silently. The count is the whole point of
-- the question: "delete a group" and "delete 22 keys off the board" are very
-- different decisions, and the × alone looks like the first one.
function GroupSection:_askDelete(name: string)
	local entry = self.entries[name]
	local count = tonumber(entry and entry.Detail) or 0

	local body
	if count == 0 then
		body = "This group has no keys. Its style will be discarded."
	else
		body = string.format(
			"Its %d key%s will be deleted from the place along with it. Ctrl+Z brings the group and its keys back.",
			count,
			count == 1 and "" or "s"
		)
	end

	self.confirm:Ask(self.dropdown.Frame, {
		Title = string.format("Delete « %s »?", name),
		Body = body,
	}, function()
		if self.OnDeleted then self.OnDeleted(name) end
	end)
end

-- entries: { { Value, Detail, Color } }, built by Main from the registry.
function GroupSection:SetEntries(entries: { any }, current: string)
	self.names = {}
	self.entries = {}
	for _, entry in ipairs(entries) do
		self.names[entry.Value] = true
		self.entries[entry.Value] = entry
	end
	self.dropdown:SetEntries(entries, current)

	-- A name can become taken while the field is open (a group deleted or
	-- added from elsewhere), so the verdict has to be re-read, not cached
	-- from the keystroke that produced it.
	if self.creating then
		self:_refreshValidity()
	end
end

return GroupSection
]]></ProtectedString>
						<string name="ScriptGuid">{F72C0080-7E4E-472D-9897-8E84E7B80B20}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">GroupSection</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
			</Item>
		</Item>
		<Item class="Model" referent="RBX538C04D6DB1448FFA0A7E29C102D09AD">
			<Properties>
				<token name="LevelOfDetail">0</token>
				<CoordinateFrame name="ModelMeshCFrame">
					<X>-1.20651782</X>
					<Y>0.684057355</Y>
					<Z>-11.7966919</Z>
					<R00>-0.173538104</R00>
					<R01>0</R01>
					<R02>-0.984827161</R02>
					<R10>0</R10>
					<R11>1</R11>
					<R12>0</R12>
					<R20>0.984827161</R20>
					<R21>0</R21>
					<R22>-0.173538104</R22>
				</CoordinateFrame>
				<SharedString name="ModelMeshData">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				<Vector3 name="ModelMeshSize">
					<X>3</X>
					<Y>1.36811471</Y>
					<Z>3</Z>
				</Vector3>
				<token name="ModelStreamingMode">0</token>
				<bool name="NeedsPivotMigration">false</bool>
				<Ref name="PrimaryPart">null</Ref>
				<float name="ScaleFactor">1</float>
				<SharedString name="SlimHash">N1XlNMb4t91Rf/q68/LVMQ==</SharedString>
				<OptionalCoordinateFrame name="WorldPivotData">
					<CFrame>
						<X>-1.20651793</X>
						<Y>0.684057355</Y>
						<Z>-11.7966919</Z>
						<R00>0.984827161</R00>
						<R01>0</R01>
						<R02>-0.173538059</R02>
						<R10>0</R10>
						<R11>1</R11>
						<R12>0</R12>
						<R20>0.173538059</R20>
						<R21>0</R21>
						<R22>0.984827161</R22>
					</CFrame>
				</OptionalCoordinateFrame>
				<BinaryString name="AttributesSerialize"></BinaryString>
				<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
				<bool name="DefinesCapabilities">false</bool>
				<string name="Name">MeshTemplateSource</string>
				<int64 name="SourceAssetId">74895834682366</int64>
				<SharedString name="Tags">ab2bVmJluRl5Bh3mAMD3KQ==</SharedString>
			</Properties>
			<Item class="MeshPart" referent="RBXEE33A67420E44105BACE54FEB393BDC6">
				<Properties>
					<bool name="DoubleSided">false</bool>
					<bool name="HasJointOffset">false</bool>
					<bool name="HasSkinnedMesh">false</bool>
					<Vector3 name="InitialSize">
						<X>10.553092</X>
						<Y>6.38806343</Y>
						<Z>10.553092</Z>
					</Vector3>
					<Vector3 name="JointOffset">
						<X>0</X>
						<Y>0</Y>
						<Z>0</Z>
					</Vector3>
					<Content name="MeshId"><url>rbxassetid://8837613273</url></Content>
					<BinaryString name="PhysicsData"></BinaryString>
					<token name="RenderFidelity">2</token>
					<Content name="TextureID"><null></null></Content>
					<int name="VertexCount">0</int>
					<SharedString name="AeroMeshData">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					<token name="FluidFidelityInternal">0</token>
					<bool name="InertiaMigrated">true</bool>
					<SharedString name="PhysicalConfigData">+qv2o0HSW+htH+ALwYQpiw==</SharedString>
					<Vector3 name="UnscaledCofm">
						<X>0</X>
						<Y>0</Y>
						<Z>0</Z>
					</Vector3>
					<Vector3 name="UnscaledVolInertiaDiags">
						<X>9021.75488</X>
						<Y>13204.9521</Y>
						<Z>9021.75488</Z>
					</Vector3>
					<Vector3 name="UnscaledVolInertiaOffDiags">
						<X>0</X>
						<Y>0</Y>
						<Z>0</Z>
					</Vector3>
					<float name="UnscaledVolume">711.424255</float>
					<bool name="Anchored">true</bool>
					<bool name="AudioCanCollide">true</bool>
					<float name="BackParamA">-0.5</float>
					<float name="BackParamB">0.5</float>
					<token name="BackSurface">0</token>
					<token name="BackSurfaceInput">0</token>
					<float name="BottomParamA">-0.5</float>
					<float name="BottomParamB">0.5</float>
					<token name="BottomSurface">0</token>
					<token name="BottomSurfaceInput">0</token>
					<CoordinateFrame name="CFrame">
						<X>-1.20651793</X>
						<Y>0.684057355</Y>
						<Z>-11.7966919</Z>
						<R00>-0.173538104</R00>
						<R01>0</R01>
						<R02>-0.984827161</R02>
						<R10>0</R10>
						<R11>1</R11>
						<R12>0</R12>
						<R20>0.984827161</R20>
						<R21>0</R21>
						<R22>-0.173538104</R22>
					</CoordinateFrame>
					<bool name="CanCollide">false</bool>
					<bool name="CanQuery">false</bool>
					<bool name="CanTouch">false</bool>
					<bool name="CastShadow">false</bool>
					<string name="CollisionGroup">Default</string>
					<int name="CollisionGroupId">0</int>
					<Color3uint8 name="Color3uint8">4292343039</Color3uint8>
					<PhysicalProperties name="CustomPhysicalProperties">
						<CustomPhysics>false</CustomPhysics>
					</PhysicalProperties>
					<bool name="EnableFluidForces">true</bool>
					<float name="FrontParamA">-0.5</float>
					<float name="FrontParamB">0.5</float>
					<token name="FrontSurface">0</token>
					<token name="FrontSurfaceInput">0</token>
					<float name="LeftParamA">-0.5</float>
					<float name="LeftParamB">0.5</float>
					<token name="LeftSurface">0</token>
					<token name="LeftSurfaceInput">0</token>
					<bool name="Locked">false</bool>
					<bool name="Massless">false</bool>
					<token name="Material">272</token>
					<string name="MaterialVariantSerialized"></string>
					<CoordinateFrame name="PivotOffset">
						<X>0</X>
						<Y>0</Y>
						<Z>0</Z>
						<R00>1</R00>
						<R01>0</R01>
						<R02>0</R02>
						<R10>0</R10>
						<R11>1</R11>
						<R12>0</R12>
						<R20>0</R20>
						<R21>0</R21>
						<R22>1</R22>
					</CoordinateFrame>
					<float name="Reflectance">0</float>
					<float name="RightParamA">-0.5</float>
					<float name="RightParamB">0.5</float>
					<token name="RightSurface">0</token>
					<token name="RightSurfaceInput">0</token>
					<int name="RootPriority">0</int>
					<Vector3 name="RotVelocity">
						<X>0</X>
						<Y>0</Y>
						<Z>0</Z>
					</Vector3>
					<float name="TopParamA">-0.5</float>
					<float name="TopParamB">0.5</float>
					<token name="TopSurface">0</token>
					<token name="TopSurfaceInput">0</token>
					<float name="Transparency">0</float>
					<Vector3 name="Velocity">
						<X>0</X>
						<Y>0</Y>
						<Z>0</Z>
					</Vector3>
					<Vector3 name="size">
						<X>3</X>
						<Y>1.36811471</Y>
						<Z>3</Z>
					</Vector3>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">Cap</string>
					<int64 name="SourceAssetId">8837619246</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
		</Item>
		<Item class="Folder" referent="RBX6FBED0AF018A441F8B26D038C4871C06">
			<Properties>
				<BinaryString name="AttributesSerialize"></BinaryString>
				<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
				<bool name="DefinesCapabilities">false</bool>
				<string name="Name">RuntimeSource</string>
				<int64 name="SourceAssetId">-1</int64>
				<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
			</Properties>
			<Item class="ModuleScript" referent="RBX3D1A06AD27EC42B7B08E05ADC4D28CE5">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- KeyCapper runtime — Copyright (c) 2026 sebattfg. All rights reserved.
-- Proprietary; installed by the KeyCapper plugin. Redistribution of the
-- plugin or of this runtime is prohibited. sebattfg is the sole authorised
-- distributor. Icons: Font Awesome 5 Free (CC BY 4.0), fontawesome.com
--
-- Centralized settings. Single source of truth for the whole prototype.

return {
	TAG_HITBOX = "KeyCapper_Hitbox",

	-- Written by the plugin, read here. Must stay identical to
	-- Constants.GROUPS_FOLDER / Constants.ATTR_GROUP on the plugin side.
	GROUPS_FOLDER = "KeyCapperGroups",
	ATTR_GROUP = "Group",

	-- Detection
	TICK_RATE = 30, -- Hz. 30 is plenty, 60 is wasteful.
	FEET_BOX_SIZE = Vector3.new(2.2, 1.2, 2.2), -- box tested at the player's feet

	-- Fallback press timing when a group doesn't override its own (see
	-- RuntimeSource.GroupConfig / AnimationPresets for the per-group style
	-- and depth).
	PRESS_TIME = 0.045,
	RELEASE_TIME = 0.13,

	-- Sound
	SOUND_ID = "rbxassetid://76552892647565",
	SOUND_VOLUME = 0.5,
	SOUND_PITCH_JITTER = 0.06, -- +/- 6% to avoid a repetitive feel
	SOUND_MAX_DISTANCE = 120, -- was 60: too easy to walk out of hearing range on a normal-sized board
	-- Minimum gap between two starts of the SAME sound asset (see
	-- Style.SoundGate). Kept DELIBERATELY under one detection tick
	-- (1/TICK_RATE = ~33ms): most groups share Config.SOUND_ID as their
	-- default, so this gate sees the whole board as one asset, and any value
	-- close to or above the tick period started swallowing ordinary fast
	-- typing across DIFFERENT keys landing on consecutive ticks — that was
	-- the "barely hear it" regression, not a volume problem. 15ms only merges
	-- presses detected in the SAME tick (overlapping hitboxes, a foot
	-- covering two caps at once), which is the one case this exists for.
	SOUND_MIN_INTERVAL = 0.015,

	-- Logs of keys arriving/leaving (streaming). Off by default: the per-key
	-- log calls GetFullName() and prints once per key, which on a board of
	-- several thousand is seconds of startup on its own. Turn on to diagnose
	-- streaming, not to leave on.
	DEBUG = false,

	-- Points system. Toggled from the plugin panel's "Others" section, stored
	-- in RuntimeSource.Settings ("PointsEnabled"), read by both Client and
	-- Server here.
	SETTINGS_FOLDER = "KeyCapperSettings",
	POINTS_REMOTE = "PointsRemote",
	POINTS_DATASTORE = "KeyCapperPoints_v1",
	POINTS_DATA_FOLDER = "KeyCapperData",
	POINTS_VALUE_NAME = "Points",
	-- How often the client flushes its pending point count to the server.
	-- Batched rather than fired per key press: at TICK_RATE=30 a fast typist
	-- could otherwise fire dozens of remote events a second.
	POINTS_BATCH_INTERVAL = 1,
	-- We trust the client's reported delta (no server-side proof a key was
	-- actually pressed) — this clamp is the only guard against an obviously
	-- spoofed value: generously above what TICK_RATE could produce in one
	-- batch interval, but far below anything worth spoofing for.
	POINTS_MAX_PER_BATCH = 60,

	-- Played by PointsUI when a "+1" popup lands in the counter. A plain
	-- Config field (not baked into PointsUI) so a technical dev can swap the
	-- asset without touching the module.
	POINTS_LAND_SOUND_ID = "rbxassetid://128538570525273",
	POINTS_LAND_SOUND_VOLUME = 0.4,

	-- Public event name: fired by Client.lua on every local key press,
	-- regardless of the points system being on. A technical dev listens with
	-- ReplicatedStorage.KeyCapper.KeyPressed.Event:Connect(function(model, letter) ... end).
	KEY_PRESSED_EVENT = "KeyPressed",
}
]]></ProtectedString>
					<string name="ScriptGuid">{30B79548-283B-4F76-8FBA-C542A2F26FBB}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">Config</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBXA52FB6F519B041C78DDA01DB6FFAD5BD">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: own plugin-wide, non-per-group settings — currently
-- just whether the points/DataStore system runs at all. Same "kept outside
-- the versioned runtime folder" pattern as GroupRegistry.GROUPS_FOLDER: the
-- Installer wipes the RuntimeSource clone on a version bump, and a toggle the
-- user set once must survive that.

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local FOLDER_NAME = "KeyCapperSettings"

local DEFAULTS = {
	PointsEnabled = true,
	-- Whether the runtime builds the points counter + "+1" popups itself.
	-- Off lets a game with its own HUD use the points value without the
	-- built-in display fighting it.
	AutoUIEnabled = true,
}

local Settings = {}

function Settings.EnsureFolder(): Configuration
	local existing = ReplicatedStorage:FindFirstChild(FOLDER_NAME)
	if existing then return existing :: Configuration end

	local config = Instance.new("Configuration")
	config.Name = FOLDER_NAME
	for key, value in pairs(DEFAULTS) do
		config:SetAttribute(key, value)
	end
	config.Parent = ReplicatedStorage
	return config
end

-- Read-only lookup: never creates the folder. The game-side runtime (Server,
-- Client) only ever reads here; only the plugin panel writes, via Set below.
function Settings.GetValue(key: string): any
	local folder = ReplicatedStorage:FindFirstChild(FOLDER_NAME)
	if not folder then return DEFAULTS[key] end
	local value = folder:GetAttribute(key)
	if value == nil then return DEFAULTS[key] end
	return value
end

function Settings.Set(key: string, value: any)
	Settings.EnsureFolder():SetAttribute(key, value)
end

return Settings
]]></ProtectedString>
					<string name="ScriptGuid">{9601C213-4144-4734-BCDF-FE93342355BA}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">Settings</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="BindableEvent" referent="RBXE36A9D1D05424AC2BE5409AD992D32D7">
				<Properties>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">KeyPressed</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="Folder" referent="RBX3FABCE099F0640E48F537E6EC93108F1">
				<Properties>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">Detection</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
				<Item class="ModuleScript" referent="RBX4760FCD268254CA99091E02BB03875C5">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- One key. Single responsibility: its pressed/released state, its
-- animation, its sound. Knows NOTHING about detection: it's just told
-- Press() / Release(), that's it.

local TweenService = game:GetService("TweenService")
local Config = require(script.Parent.Parent.Config)
local GroupConfig = require(script.Parent.Parent.Style.GroupConfig)
local AnimationPresets = require(script.Parent.Parent.Style.AnimationPresets)
local SoundGate = require(script.Parent.Parent.Style.SoundGate)

local Keycap = {}
Keycap.__index = Keycap

function Keycap.new(hitbox: BasePart)
	local model = hitbox.Parent
	local cap = model and model:FindFirstChild("Cap")
	if not (cap and cap:IsA("BasePart")) then
		-- Under streaming, the hitbox can arrive before the Cap: this isn't
		-- an error, the registry will retry once the model is complete.
		return nil
	end

	local self = setmetatable({}, Keycap)
	self.hitbox = hitbox
	self.cap = cap
	self.group = model:GetAttribute(Config.ATTR_GROUP)
	-- Resolved once at build time: a key does not change group while the game
	-- runs, and re-reading the folder on every press would be wasteful.
	self.soundIds = GroupConfig.SoundIds(self.group)
	self.pitchJitter = GroupConfig.PitchJitter(self.group)
	self.volume = GroupConfig.Volume(self.group)
	self.restCFrame = cap.CFrame -- rest position, captured once
	self.restSize = cap.Size -- rest size, captured once
	self.pressed = false
	self.tween = nil
	self.sound = self:_buildSound()

	-- Style and durations are resolved once, like sound: a key does not
	-- change group mid-game, so there is nothing to gain re-reading the
	-- group's attributes on every press.
	local style = AnimationPresets.Resolve(GroupConfig.AnimationStyle(self.group))
	self.style = style
	self.pressInfo = TweenInfo.new(
		GroupConfig.PressTime(self.group),
		style.PressEasingStyle,
		style.PressEasingDirection
	)
	self.releaseInfo = TweenInfo.new(
		GroupConfig.ReleaseTime(self.group),
		style.ReleaseEasingStyle,
		style.ReleaseEasingDirection
	)

	return self
end

function Keycap:_buildSound(): Sound
	local sound = Instance.new("Sound")
	sound.Name = "KeyCapperSound"
	-- A group with no sound of its own falls back to the global default.
	sound.SoundId = self.soundIds[1] or Config.SOUND_ID
	sound.Volume = self.volume
	sound.RollOffMaxDistance = Config.SOUND_MAX_DISTANCE
	sound.Parent = self.hitbox -- 3D sound, emitted from the key
	return sound
end

function Keycap:_tweenTo(targetCFrame: CFrame, targetSize: Vector3, info: TweenInfo)
	if self.tween then
		self.tween:Cancel()
	end
	self.tween = TweenService:Create(self.cap, info, { CFrame = targetCFrame, Size = targetSize })
	self.tween:Play()
end

function Keycap:Press()
	if self.pressed then return end
	self.pressed = true

	local style = self.style
	-- CFrame.new(0, -depth, 0) applied AFTER restCFrame = translation in the
	-- cap's local frame. This is what makes the press correct on a slope.
	-- The tilt multiplies on the RIGHT of the sink, so it too is expressed in
	-- the cap's local frame: a preset that rocks the cap rocks it about the
	-- cap's own axis, which is what keeps it correct on a sloped surface.
	-- Presets with no tilt pass 0 and this is an identity rotation.
	local targetCFrame = self.restCFrame
		* CFrame.new(0, -style.PressDepth, 0)
		* CFrame.Angles(math.rad(style.PressTilt or 0), 0, 0)
	local targetSize = Vector3.new(
		self.restSize.X * style.PressSizeScale.X,
		self.restSize.Y * style.PressSizeScale.Y,
		self.restSize.Z * style.PressSizeScale.Z
	)
	self:_tweenTo(targetCFrame, targetSize, self.pressInfo)

	-- Picked per press, not per key: that's what keeps a long typing run from
	-- sounding like a loop.
	if #self.soundIds > 1 then
		self.sound.SoundId = self.soundIds[math.random(#self.soundIds)]
	end

	-- Checked AFTER the animation is under way, and it only skips the sound:
	-- a key whose sound was merged into one already playing still goes down.
	-- Asked after the random pick too, since which asset this press landed on
	-- is exactly what the gate is deciding about.
	if not SoundGate.Allow(self.sound.SoundId) then return end

	self.sound.PlaybackSpeed = 1 + (math.random() * 2 - 1) * self.pitchJitter
	self.sound:Play()
end

function Keycap:Release()
	if not self.pressed then return end
	self.pressed = false
	self:_tweenTo(self.restCFrame, self.restSize, self.releaseInfo)
end

function Keycap:Destroy()
	if self.tween then self.tween:Cancel() end
	if self.sound then self.sound:Destroy() end
	-- The key may have been streamed out: the Cap no longer exists then.
	if self.cap and self.cap.Parent then
		self.cap.CFrame = self.restCFrame
		self.cap.Size = self.restSize
	end
end

return Keycap
]]></ProtectedString>
						<string name="ScriptGuid">{68F3D188-6E07-4F1C-A5D3-558DCA9F1D42}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">Keycap</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX0D955CAC5062401A992BFF34872271DE">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: know which keys exist.
-- Follows the tag, instantiates/destroys Keycap, and keeps the filter list
-- for overlap checks up to date (recomputed only when it changes, not every frame).

local CollectionService = game:GetService("CollectionService")

local Config = require(script.Parent.Parent.Config)
local Keycap = require(script.Parent.Keycap)

local KeycapRegistry = {}
KeycapRegistry.__index = KeycapRegistry

function KeycapRegistry.new()
	local self = setmetatable({}, KeycapRegistry)
	self.byHitbox = {}
	-- Kept incrementally rather than counted on demand: Count() used to walk
	-- the whole table, and the DEBUG log below calls it once PER key arriving.
	-- On a board of several thousand that made startup quadratic.
	self.count = 0
	self.pending = {} -- hitbox that arrived without its Cap (streaming in progress)
	self.connections = {}
	self._filterDirty = true
	self._filterList = {}
	return self
end

function KeycapRegistry:Start()
	for _, hitbox in ipairs(CollectionService:GetTagged(Config.TAG_HITBOX)) do
		self:_add(hitbox)
	end
	table.insert(self.connections, CollectionService:GetInstanceAddedSignal(Config.TAG_HITBOX):Connect(function(h)
		self:_add(h)
	end))
	table.insert(self.connections, CollectionService:GetInstanceRemovedSignal(Config.TAG_HITBOX):Connect(function(h)
		self:_remove(h)
	end))
end

function KeycapRegistry:_add(hitbox: Instance)
	if not hitbox:IsA("BasePart") or self.byHitbox[hitbox] then return end

	local keycap = Keycap.new(hitbox)
	if keycap then
		self.byHitbox[hitbox] = keycap
		self.count += 1
		self._filterDirty = true
		if Config.DEBUG then
			print("[KeyCapper] + key", hitbox:GetFullName(), "total:", self.count)
		end
		return
	end

	-- The Cap hasn't streamed in yet: retry as soon as it arrives.
	self:_waitForCap(hitbox)
end

function KeycapRegistry:_waitForCap(hitbox: BasePart)
	if self.pending[hitbox] then return end
	local model = hitbox.Parent
	if not model then return end

	local connection
	connection = model.ChildAdded:Connect(function(child)
		if child.Name ~= "Cap" then return end
		connection:Disconnect()
		self.pending[hitbox] = nil
		if hitbox.Parent then
			self:_add(hitbox)
		end
	end)
	self.pending[hitbox] = connection
end

function KeycapRegistry:_remove(hitbox: Instance)
	local waiting = self.pending[hitbox]
	if waiting then
		waiting:Disconnect()
		self.pending[hitbox] = nil
	end

	local keycap = self.byHitbox[hitbox]
	if not keycap then return end

	keycap:Destroy()
	self.byHitbox[hitbox] = nil
	self.count -= 1
	self._filterDirty = true
	if Config.DEBUG then
		print("[KeyCapper] - key (streamed out), total:", self.count)
	end
end

-- List of hitboxes to pass as the OverlapParams whitelist.
function KeycapRegistry:GetFilterList(): { BasePart }
	if self._filterDirty then
		local list = {}
		for hitbox in pairs(self.byHitbox) do
			table.insert(list, hitbox)
		end
		self._filterList = list
		self._filterDirty = false
	end
	return self._filterList
end

function KeycapRegistry:Get(hitbox: BasePart)
	return self.byHitbox[hitbox]
end

function KeycapRegistry:All()
	return self.byHitbox
end

function KeycapRegistry:Count(): number
	return self.count
end

return KeycapRegistry
]]></ProtectedString>
						<string name="ScriptGuid">{D3F56A35-8F1A-4173-A370-096FA285D827}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">KeycapRegistry</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="Script" referent="RBXF382AA8F580A49D09B1DF1D76F76BB73">
					<Properties>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: DETECTION.
-- Ticks at a fixed rate, tests a box at the player's feet against the
-- hitboxes, and drives the Keycap objects. Knows nothing about animation or sound.
--
-- RunContext = Client: placed directly under ReplicatedStorage.KeyCapper,
-- the whole module lives in one place, no need for StarterPlayerScripts.

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

-- This script's own folder (Detection): where its sibling KeycapRegistry
-- lives. KeyCapperFolder is one level up — the installed root
-- (ReplicatedStorage.KeyCapper) — where the shared singletons and the
-- Points subfolder live.
local DetectionFolder = script.Parent
local KeyCapperFolder = DetectionFolder.Parent
local Config = require(KeyCapperFolder:WaitForChild("Config"))
local KeycapRegistry = require(DetectionFolder:WaitForChild("KeycapRegistry"))
local Settings = require(KeyCapperFolder:WaitForChild("Settings"))

-- A RunContext = Client script starts as soon as IT replicates, which is not
-- the same moment the client is fully set up: LocalPlayer can still be nil,
-- and this folder's siblings may not have arrived yet. Both were assumed
-- present below, and either one missing killed this script outright — no
-- animation, no sound, nothing, for the whole session. That is the "it just
-- does nothing until I touch something in the panel" report: the panel edit
-- was never the fix, reloading into a luckier ordering was.
while not Players.LocalPlayer do
	Players:GetPropertyChangedSignal("LocalPlayer"):Wait()
end
local player = Players.LocalPlayer

-- Public: a technical dev listens with
-- ReplicatedStorage.KeyCapper.KeyPressed.Event:Connect(function(model, letter) ... end).
-- Fires on every local press regardless of the points system being on —
-- it is not part of the points feature, just piggybacking on the same
-- detection loop that already knows a press just happened.
local keyPressedEvent = KeyCapperFolder:WaitForChild(Config.KEY_PRESSED_EVENT) :: BindableEvent

-- Read once at startup: these are Studio-side toggles the developer flips
-- rarely, not something that needs to react mid-session.
local pointsEnabled = Settings.GetValue("PointsEnabled")
local autoUIEnabled = pointsEnabled and Settings.GetValue("AutoUIEnabled")
local pendingPoints = 0
-- Both are filled in by the points bootstrap at the bottom of this file, which
-- runs in its own thread. Detection must never wait on them: the points system
-- depends on the SERVER having built the player's data folder, and anything
-- that stops it doing so (DataStore trouble, the toggle read differently on
-- the two sides) used to hang this script before the first key was ever
-- registered. Presses are the feature; points are an extra.
local remote: RemoteEvent? = nil
local PointsUI: any = nil

local registry = KeycapRegistry.new()
registry:Start()

local overlapParams = OverlapParams.new()
overlapParams.FilterType = Enum.RaycastFilterType.Include

-- The parts whose bottom face IS the sole, in both rigs. R15 names first,
-- R6's two legs after: a character only ever has one set.
local LEG_PARTS = { "LeftFoot", "RightFoot", "Left Leg", "Right Leg" }

-- Feet position, measured from the legs themselves rather than derived from
-- the root.
--
-- The old form was root.Position - (root.Size.Y/2 + HipHeight), which is only
-- correct on R15. On R6 the root sits at the TORSO and HipHeight is 0, so that
-- landed roughly two studs above the ground: the test box floated at knee
-- height and never touched a single cap. That is the whole of "it doesn't work
-- on R6": detection, and therefore animation, sound and points, all silently
-- off for anyone using the R6 rig.
--
-- Taking the lowest leg bottom needs no per-rig arithmetic at all, and stays
-- right while walking or on a slope. HipHeight is kept only as a fallback for
-- a character whose limbs haven't replicated yet.
local function getFeetCFrame(): CFrame?
	local character = player.Character
	if not character then return nil end

	local root = character:FindFirstChild("HumanoidRootPart") :: BasePart?
	if not root then return nil end

	local sole: number? = nil
	for _, name in ipairs(LEG_PARTS) do
		local part = character:FindFirstChild(name)
		if part and part:IsA("BasePart") then
			local bottom = part.Position.Y - part.Size.Y / 2
			if not sole or bottom < sole then
				sole = bottom
			end
		end
	end

	if sole then
		return CFrame.new(root.Position.X, sole, root.Position.Z)
	end

	local humanoid = character:FindFirstChildOfClass("Humanoid")
	if not humanoid then return nil end
	return CFrame.new(root.Position - Vector3.new(0, root.Size.Y / 2 + humanoid.HipHeight, 0))
end

local STEP = 1 / Config.TICK_RATE
local accumulator = 0
local touchingLastTick: { [BasePart]: boolean } = {}

RunService.Heartbeat:Connect(function(deltaTime: number)
	accumulator += deltaTime
	if accumulator < STEP then return end
	accumulator -= STEP

	local touchingNow: { [BasePart]: boolean } = {}

	local feet = getFeetCFrame()
	if feet then
		overlapParams.FilterDescendantsInstances = registry:GetFilterList()
		local hits = workspace:GetPartBoundsInBox(feet, Config.FEET_BOX_SIZE, overlapParams)
		for _, part in ipairs(hits) do
			touchingNow[part] = true
		end
	end

	-- Diff against the previous tick. We only iterate over the keys involved
	-- (a handful), never over every loaded key: the cost stays constant
	-- even with a keyboard of several hundred keys.
	for hitbox in pairs(touchingNow) do
		if not touchingLastTick[hitbox] then
			local keycap = registry:Get(hitbox)
			if keycap then
				keycap:Press()
				keyPressedEvent:Fire(hitbox.Parent, hitbox.Parent and hitbox.Parent:GetAttribute("Letter"))
				if pointsEnabled then
					pendingPoints += 1
					-- PointsUI is nil until the bootstrap below has it: presses
					-- before that still count, they just don't pop a "+1".
					if PointsUI then
						PointsUI.Notify(keycap.cap.Position)
					end
				end
			end
		end
	end

	for hitbox in pairs(touchingLastTick) do
		if not touchingNow[hitbox] then
			local keycap = registry:Get(hitbox)
			if keycap then keycap:Release() end
		end
	end

	touchingLastTick = touchingNow
end)

-- Everything points-related, off the critical path — see the note next to the
-- `remote` declaration. The flush loop batches the pending count instead of
-- firing per press: at TICK_RATE=30 a fast typist could otherwise fire dozens
-- of remote events a second. Only the running total since the last flush is
-- ever sent.
if pointsEnabled then
	task.spawn(function()
		local pointsFolder = KeyCapperFolder:WaitForChild("Points")
		remote = pointsFolder:WaitForChild("PointsRemote") :: RemoteEvent

		if autoUIEnabled then
			local ui = require(pointsFolder:WaitForChild("PointsUI"))
			-- Server.lua builds this under the Player as soon as it joins.
			-- Waited on HERE rather than at the top of the file: if the server
			-- never builds it, this thread is the only thing that stalls.
			local dataFolder = player:WaitForChild(Config.POINTS_DATA_FOLDER)
			local pointsValue = dataFolder:WaitForChild(Config.POINTS_VALUE_NAME) :: IntValue
			ui.Init(pointsValue)
			PointsUI = ui
		end

		while true do
			task.wait(Config.POINTS_BATCH_INTERVAL)
			local target = remote
			if pendingPoints > 0 and target then
				local toSend = pendingPoints
				pendingPoints = 0
				target:FireServer(toSend)
			end
		end
	end)
end

-- Under StreamingEnabled this count is normally 0 at startup: keys register
-- themselves as they stream in.
if Config.DEBUG then
	print(string.format("[KeyCapper] client started - %d key(s) initially", registry:Count()))
end
]]></ProtectedString>
						<bool name="Disabled">true</bool>
						<Content name="LinkedSource"><null></null></Content>
						<token name="RunContext">2</token>
						<string name="ScriptGuid">{0FB6BD81-401E-4D2E-B60C-5E3ADB623AAB}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">Client</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
			</Item>
			<Item class="Folder" referent="RBXAE5FA7FE7B0E42D7A8CBCF4A954CEC35">
				<Properties>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">Style</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
				<Item class="ModuleScript" referent="RBX1F0217AD89D24748ACA6B698918BA7A4">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: turn whatever a user pasted into a sound asset id —
-- or say why it cannot be one. No storage, no UI, no Sound instances.
--
-- Lives in RuntimeSource because BOTH sides need the exact same verdict:
-- GroupConfig parses stored ids at runtime, and the panel's SoundSection
-- validates them as they are typed. Two implementations of "is this an id?"
-- would eventually disagree, and the failure mode of that disagreement is a
-- field the panel accepts and the game silently ignores — which is the very
-- thing this module exists to prevent.

local SoundId = {}

-- The forms people actually arrive with: the bare number from the toolbox,
-- the canonical asset url, and the two roblox.com links you get from the
-- website's address bar or a Share button.
local FORMS = {
	"^(%d+)$",
	"^rbxassetid://(%d+)$",
	"^https?://[%w%.]*roblox%.com/asset/%?id=(%d+)",
	"^https?://[%w%.]*roblox%.com/library/(%d+)",
}

function SoundId.Trim(raw: string): string
	return (tostring(raw):gsub("^%s+", ""):gsub("%s+$", ""))
end

-- Returns the canonical "rbxassetid://N" form, or nil plus a message written
-- for the person who typed it. An empty string is neither: it is a row not
-- filled in yet, so it returns nil with no complaint.
function SoundId.Parse(raw: string): (string?, string?)
	local text = SoundId.Trim(raw)
	if text == "" then return nil, nil end

	for _, form in ipairs(FORMS) do
		local id = text:match(form)
		if id then return "rbxassetid://" .. id, nil end
	end

	-- Everything below is a rejection. The message names what is actually
	-- wrong rather than restating the rule, because the overwhelmingly common
	-- case here is a mistyped scheme ("rbxassetid" is a mouthful) where the
	-- number itself is perfectly fine and the user cannot see the difference.
	local scheme = text:match("^(%a[%w%+%-%.]*)://")
	if scheme and scheme:lower() ~= "rbxassetid" then
		return nil, string.format("“%s://” is not valid — did you mean rbxassetid:// ?", scheme)
	end
	if text:match("%d") then
		return nil, "Not recognised — use the number alone, or rbxassetid://number"
	end
	return nil, "No asset id here — it must contain the sound's number"
end

return SoundId
]]></ProtectedString>
						<string name="ScriptGuid">{EF3974D5-883D-4B56-A758-3B55CB60675B}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">SoundId</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX62B1E067E4744DA18AA90855AE203003">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: stop the SAME sound asset from starting twice within
-- a few milliseconds. Nothing else — it owns no Sound and plays nothing, it
-- only answers yes or no.
--
-- Two copies of one sample a few ms apart do not sound like two taps, they
-- sound like one wrong tap: the offset copies comb-filter against each other
-- and the result is a hollow, flanged version of the sound. Two keys landing
-- together is completely normal (a chord, a foot covering two caps, one press
-- detected on overlapping hitboxes), so this happens constantly.
--
-- Config.SOUND_MIN_INTERVAL has to stay well under one detection tick: see
-- that constant for why a value near the tick period silences ordinary fast
-- typing across different keys instead of only same-tick overlaps.
--
-- Gated per ASSET, not per key: two different sounds at the same instant is a
-- chord and must stay one, and a single key retriggering fast is the same
-- asset so it is covered by the same rule.

local Config = require(script.Parent.Parent.Config)

local SoundGate = {}

-- Last start time per asset id. Bounded by the number of distinct sounds in
-- the place, which is the number of ids across all groups — small, and it
-- never grows with the number of keys or presses.
local lastPlay: { [string]: number } = {}

-- True if this asset may start now, and records it as started. Callers that
-- get false must skip the :Play() but everything else (the animation) still
-- runs — the key was pressed, only its sound is being merged into the one
-- already sounding.
function SoundGate.Allow(soundId: string): boolean
	local now = os.clock()
	local previous = lastPlay[soundId]
	if previous and now - previous < Config.SOUND_MIN_INTERVAL then
		return false
	end
	lastPlay[soundId] = now
	return true
end

return SoundGate
]]></ProtectedString>
						<string name="ScriptGuid">{3212FF15-7DFA-4771-973B-FE7F152055E6}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">SoundGate</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBXDD0ABF88718D461AAB6CE64E06C4C3BB">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: build a key's UI by code.
-- The published mesh is now BARE (no SurfaceGui baked in): it's the
-- plugin/runtime that must build it dynamically at placement time. This
-- module is the single reference for what a key's UI should look like.
--
-- Spec taken as-is from the original prototype (inspected by hand):
-- SurfaceGui Face=Top, CanvasSize 150x150, PixelsPerStud=70.
-- Centered TextLabel, TextScaled, FredokaOne font, black outline.
-- The UIStroke is created DISABLED: it's a lever reserved for the future
-- "GUI customization" phase (see docs/PLAN.md), not enabled here.

local GuiFactory = {}

function GuiFactory.BuildLabel(cap: BasePart, text: string, maxDistance: number?): SurfaceGui
	local existing = cap:FindFirstChild("SurfaceGui")
	if existing then existing:Destroy() end

	local gui = Instance.new("SurfaceGui")
	gui.Name = "SurfaceGui"
	gui.Face = Enum.NormalId.Top
	gui.SizingMode = Enum.SurfaceGuiSizingMode.FixedSize
	gui.CanvasSize = Vector2.new(150, 150)
	gui.PixelsPerStud = 70
	gui.LightInfluence = 1
	gui.Brightness = 1
	gui.ClipsDescendants = true
	gui.MaxDistance = maxDistance or 150

	local label = Instance.new("TextLabel")
	label.Name = "TextLabel"
	label.AnchorPoint = Vector2.new(0.5, 0.5)
	label.Position = UDim2.new(0.5, 0, 0.5, 0)
	label.Size = UDim2.new(0.5, 0, 0.5, 0)
	label.BackgroundTransparency = 1
	label.BorderSizePixel = 0
	label.TextScaled = true
	label.TextWrapped = true
	label.TextXAlignment = Enum.TextXAlignment.Center
	label.TextYAlignment = Enum.TextYAlignment.Center
	label.TextColor3 = Color3.new(0, 0, 0)
	label.TextStrokeColor3 = Color3.new(0, 0, 0)
	label.TextStrokeTransparency = 1
	label.FontFace = Font.new("rbxasset://fonts/families/FredokaOne.json", Enum.FontWeight.Regular, Enum.FontStyle.Normal)
	label.Text = text
	label.Parent = gui

	-- Reserved for later (GUI customization): created but disabled, same as
	-- in the original prototype. Not enabled until that phase.
	local stroke = Instance.new("UIStroke")
	stroke.Name = "UIStroke"
	stroke.Enabled = false
	stroke.Color = Color3.new(0, 0, 0)
	stroke.Thickness = 1
	stroke.LineJoinMode = Enum.LineJoinMode.Round
	stroke.ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual
	stroke.Parent = label

	gui.Parent = cap
	return gui
end

return GuiFactory
]]></ProtectedString>
						<string name="ScriptGuid">{C07D247E-174B-4DB2-91B4-D2637FFD2F58}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">GuiFactory</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBXFAB3A9F258794770B9EB2EB7D938DF41">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: name <-> press-animation shape lookup. Pure data,
-- no tweening logic here — Keycap reads a preset and drives TweenService
-- itself, this module only says WHAT the press should look like.
--
-- Lives inside RuntimeSource: consumed by Keycap at runtime, so it must ship
-- with the game like GuiFactory/FontPresets do.

export type Preset = {
	Name: string,
	-- How far the cap sinks along its own local Y on press, in studs.
	--
	-- Reference for reading these numbers: the cap is Constants.KEY_SIZE.Y =
	-- 1.368 studs tall and rests ON the surface, so a depth of D leaves
	-- 1.368 - D standing proud of it. Under about 0.4 of travel the key does
	-- not look pressed next to its unpressed neighbours, it looks slightly
	-- lower — which is what these presets were all guilty of.
	PressDepth: number,
	-- Per-axis multiplier on the cap's rest Size while pressed.
	--
	-- CEILING on X/Z: Constants.GRID_PITCH / Constants.KEY_SIZE.X, i.e.
	-- 3.15 / 3 = 1.05. At exactly 1.05 a pressed cap fills its cell and its
	-- edges meet its neighbours'; anything above that overlaps them, and on a
	-- full keyboard — where every cell IS occupied — that reads as the key
	-- growing through the ones beside it rather than squashing.
	PressSizeScale: Vector3,
	-- Degrees the cap rocks about its own local X on press. 0 for a pure
	-- vertical press. Applied after the sink, in the cap's local frame, so it
	-- stays correct on a sloped surface like everything else here.
	PressTilt: number,
	PressEasingStyle: Enum.EasingStyle,
	PressEasingDirection: Enum.EasingDirection,
	-- Release always returns to rest CFrame/Size: only the easing differs.
	ReleaseEasingStyle: Enum.EasingStyle,
	ReleaseEasingDirection: Enum.EasingDirection,
}

local AnimationPresets = {}

local PRESETS: { Preset } = {
	{
		Name = "Default",
		-- The original prototype's feel: a plain dip, no shape change.
		-- 1.15 of a 1.368-stud cap leaves ~0.22 standing: the key bottoms out
		-- against the plate instead of hovering part-way down.
		PressDepth = 1.15,
		PressSizeScale = Vector3.new(1, 1, 1),
		PressTilt = 0,
		PressEasingStyle = Enum.EasingStyle.Quad,
		PressEasingDirection = Enum.EasingDirection.Out,
		ReleaseEasingStyle = Enum.EasingStyle.Back,
		ReleaseEasingDirection = Enum.EasingDirection.Out,
	},
	{
		Name = "Slime",
		-- Squash (flatten Y) and spread (widen X/Z) on press, ball-bounce back.
		--
		-- The spread sits ON the 1.05 ceiling documented above, not past it: at
		-- the old 1.28 a pressed cap was 3.84 studs across in a 3.15 cell and
		-- visibly swallowed its neighbours. Filling the cell exactly is what the
		-- squash was ever meant to read as — the cap spreading until it meets
		-- what is next to it, then stopping.
		--
		-- Slime goes DOWN by squashing rather than by travelling: the Y scale is
		-- what makes it look pressed, so it is the number that was raised. At
		-- 0.34 the cap flattens to ~0.47 studs. The depth is not free either —
		-- it has to cover the half-height the squash removes, 0.684 x (1 - 0.34)
		-- = 0.45, or the cap would shrink about its centre and lift off the
		-- surface. 0.55 covers that and sinks the last 0.1 on top.
		PressDepth = 0.55,
		PressSizeScale = Vector3.new(1.05, 0.34, 1.05),
		PressTilt = 0,
		PressEasingStyle = Enum.EasingStyle.Quad,
		PressEasingDirection = Enum.EasingDirection.Out,
		ReleaseEasingStyle = Enum.EasingStyle.Bounce,
		ReleaseEasingDirection = Enum.EasingDirection.Out,
	},
	{
		Name = "Typewriter",
		-- The cap ROCKS forward as it sinks, pivoting about its own local X the
		-- way a typebar key does, instead of dropping flat. Chosen because it is
		-- the one thing neither other preset does: Default changes position and
		-- Slime changes shape, this one changes ORIENTATION, so it stays legible
		-- next to them at a glance rather than reading as "Default but more".
		--
		-- It is also the preset that gains the most from a full keyboard: a
		-- single key rocking is a small motion, a row of them rocking in
		-- sequence as you type is the whole effect.
		--
		-- Size is untouched (1,1,1), so it cannot overlap a neighbour whatever
		-- the grid does. 9 degrees over a 3-stud cap lifts the trailing edge by
		-- ~0.23 studs — clearly readable, and well under the 1.368-stud cap
		-- height, so the cap never looks like it is coming out of its socket.
		PressDepth = 0.85,
		PressSizeScale = Vector3.new(1, 1, 1),
		PressTilt = 9,
		-- Quart Out front-loads the travel: the cap snaps down and settles,
		-- which is what makes it read as mechanical rather than soft.
		PressEasingStyle = Enum.EasingStyle.Quart,
		PressEasingDirection = Enum.EasingDirection.Out,
		-- Back Out overshoots slightly on the way up, so the cap rocks BACK past
		-- level for a moment before settling — the return half of the same
		-- pivot, and the reason the release is worth watching at all.
		ReleaseEasingStyle = Enum.EasingStyle.Back,
		ReleaseEasingDirection = Enum.EasingDirection.Out,
	},
}

AnimationPresets.DEFAULT_NAME = PRESETS[1].Name

function AnimationPresets.List(): { string }
	local names = {}
	for _, preset in ipairs(PRESETS) do
		table.insert(names, preset.Name)
	end
	return names
end

-- Falls back to Default rather than erroring: a stale/unknown name attribute
-- must not break a key's press animation.
function AnimationPresets.Resolve(name: string?): Preset
	for _, preset in ipairs(PRESETS) do
		if preset.Name == name then
			return preset
		end
	end
	return PRESETS[1]
end

return AnimationPresets
]]></ProtectedString>
						<string name="ScriptGuid">{E3C166A7-7247-4DFE-B560-1FDB56FA3C71}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">AnimationPresets</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX1E0546913C194B4E8E5A18DDA9C6574D">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: name <-> Font lookup for the label's typeface.
-- A curated, short list on purpose: the group panel exposes it as a cycling
-- button, not a free picker, so every name here must be a Font Studio can
-- always resolve (no dependency on an uploaded asset being available).
--
-- Lives inside RuntimeSource (not Core/Edit) because GuiFactory, which is
-- cloned into the shipped game, is conceptually the same layer.

local FontPresets = {}

local PRESETS: { { Name: string, Font: Font } } = {
	{ Name = "Fredoka", Font = Font.new("rbxasset://fonts/families/FredokaOne.json", Enum.FontWeight.Regular, Enum.FontStyle.Normal) },
	{ Name = "Gotham", Font = Font.fromEnum(Enum.Font.GothamBlack) },
	{ Name = "Bangers", Font = Font.fromEnum(Enum.Font.Bangers) },
	{ Name = "Arial", Font = Font.fromEnum(Enum.Font.Arial) },
	{ Name = "Code", Font = Font.fromEnum(Enum.Font.Code) },
}

FontPresets.DEFAULT_NAME = PRESETS[1].Name

function FontPresets.List(): { string }
	local names = {}
	for _, preset in ipairs(PRESETS) do
		table.insert(names, preset.Name)
	end
	return names
end

-- Falls back to the default rather than erroring: a stale/unknown name
-- attribute (renamed preset, hand-edited data) must not break a key's GUI.
function FontPresets.Resolve(name: string?): Font
	for _, preset in ipairs(PRESETS) do
		if preset.Name == name then
			return preset.Font
		end
	end
	return PRESETS[1].Font
end

return FontPresets
]]></ProtectedString>
						<string name="ScriptGuid">{D81CF62F-0F72-4802-956E-1744D6C5C239}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">FontPresets</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBXD46D0695CC0E4F70A4CC454A002C2F3D">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- GAME-side counterpart of the plugin's GroupRegistry: reads the group data
-- the plugin wrote, nothing more. Read-only on purpose — the runtime must
-- never edit what the plugin owns.
--
-- Tolerates the folder being absent entirely: a game whose keys were placed
-- before groups existed keeps working on the Config defaults.

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Config = require(script.Parent.Parent.Config)
local SoundId = require(script.Parent.SoundId)

local GroupConfig = {}

local function groupOf(name: string?): Configuration?
	if not name then return nil end
	local folder = ReplicatedStorage:FindFirstChild(Config.GROUPS_FOLDER)
	if not folder then return nil end
	local group = folder:FindFirstChild(name)
	return group and group :: Configuration or nil
end

-- Splits the comma-separated list the plugin stores. Empty list = the caller
-- falls back to the global default sound.
--
-- Every entry goes through SoundId.Parse, so a bare number stored by an older
-- version (or typed before the panel started normalising) still plays, and
-- anything unusable is dropped here rather than assigned to a Sound where it
-- would fail silently at press time.
function GroupConfig.SoundIds(name: string?): { string }
	local group = groupOf(name)
	if not group then return {} end

	local raw = group:GetAttribute("SoundIds")
	if type(raw) ~= "string" or raw == "" then return {} end

	local ids: { string } = {}
	for piece in string.gmatch(raw, "[^,]+") do
		local id = SoundId.Parse(piece)
		if id then
			table.insert(ids, id)
		end
	end
	return ids
end

function GroupConfig.PitchJitter(name: string?): number
	local group = groupOf(name)
	local jitter = group and group:GetAttribute("PitchJitter")
	if type(jitter) ~= "number" then return Config.SOUND_PITCH_JITTER end
	return jitter
end

function GroupConfig.Volume(name: string?): number
	local group = groupOf(name)
	local volume = group and group:GetAttribute("Volume")
	if type(volume) ~= "number" then return Config.SOUND_VOLUME end
	return volume
end

function GroupConfig.AnimationStyle(name: string?): string?
	local group = groupOf(name)
	local style = group and group:GetAttribute("AnimationStyle")
	return type(style) == "string" and style or nil
end

function GroupConfig.PressTime(name: string?): number
	local group = groupOf(name)
	local time = group and group:GetAttribute("PressTime")
	if type(time) ~= "number" then return Config.PRESS_TIME end
	return time
end

function GroupConfig.ReleaseTime(name: string?): number
	local group = groupOf(name)
	local time = group and group:GetAttribute("ReleaseTime")
	if type(time) ~= "number" then return Config.RELEASE_TIME end
	return time
end

return GroupConfig
]]></ProtectedString>
						<string name="ScriptGuid">{50296E8E-70F9-4EA5-9683-B52EA6B66BD2}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">GroupConfig</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
			</Item>
			<Item class="Folder" referent="RBX9295E8F19C0C42C88A8817D724459527">
				<Properties>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">Points</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
				<Item class="ModuleScript" referent="RBXC8AEB2F2C1CA44508972F5D0AAD5FCF7">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: the top-center points number, and how it REACTS when
-- a point lands. Owns nothing but its own pixels: it is told a value and told
-- "a point just landed", and never decides what the score is.
--
-- The resting state is deliberately bare — a white outlined number, top
-- center, no card, no background, no border. Everything else in this module
-- is TRANSIENT: every effect returns the counter to exactly that resting
-- state, so the HUD is only ever "just the number" when nothing is happening.
--
-- The feel is built from four things landing together, which is what makes an
-- impact read as one event rather than four effects:
--   * a punch — fast scale up, slow elastic settle (see PUNCH_* below);
--   * a wobble — a few degrees of rotation, unwinding elastically;
--   * a shockwave — a ring that expands out of the number and fades;
--   * a rising pitch on the landing sound as presses chain into a combo.

local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")

local Config = require(script.Parent.Parent:WaitForChild("Config"))

local Counter = {}
Counter.__index = Counter

-- One knob for the whole HUD's size. Every pixel metric in this module and in
-- PointPopup is expressed against it, so the counter and its "+1"s cannot
-- drift out of proportion with each other — change this alone to rescale the
-- entire HUD.
Counter.SCALE = 2.5

-- Exported: PointPopup sizes itself against this so a "+1" can never read as
-- bigger or louder than the total it is feeding.
Counter.TEXT_SIZE = 34 * Counter.SCALE

-- Exported so PointPopup outlines its "+1" identically: the counter and the
-- popups have to read as the same typeface at the same weight, and a stroke
-- that differs between them is immediately visible as two different fonts.
-- 2.1 rather than 3 — at SCALE 2.5 a 3px base stroke was thick enough to eat
-- into the counters of the glyphs and blunt them.
Counter.STROKE_THICKNESS = 2.1 * Counter.SCALE

local FONT = Font.new("rbxasset://fonts/families/FredokaOne.json", Enum.FontWeight.Regular, Enum.FontStyle.Normal)
local TOP_MARGIN = 20 * Counter.SCALE

-- The punch. Asymmetric on purpose and this is the whole trick: a scale-up
-- that is over almost before it is seen (PUNCH_UP), then a long elastic
-- return that does the actual selling (PUNCH_DOWN). Symmetric easing on both
-- halves reads as a gentle pulse; this reads as an impact.
local PUNCH_SCALE = 1.30
local PUNCH_UP = TweenInfo.new(0.08, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
local PUNCH_DOWN = TweenInfo.new(0.45, Enum.EasingStyle.Elastic, Enum.EasingDirection.Out)
-- Combo adds to the punch on top of PUNCH_SCALE. Kept small: the counter has
-- to stay readable at the top of a streak, and a number that doubles in size
-- stops reading as a number.
local PUNCH_COMBO_BONUS = 0.12

local WOBBLE_DEGREES = 5
local WOBBLE_INFO = TweenInfo.new(0.55, Enum.EasingStyle.Elastic, Enum.EasingDirection.Out)

local RING_TIME = 0.4
local RING_START_PADDING = 14 * Counter.SCALE -- how far outside the digits the ring is born
local RING_GROWTH = 46 * Counter.SCALE -- how much further it travels before dying
local RING_THICKNESS = 2 * Counter.SCALE

-- Presses closer together than this chain into a combo; a gap resets it.
-- 0.6s is a little above a fast typist's inter-key gap, so ordinary typing
-- sustains a combo and pausing visibly drops it.
local COMBO_WINDOW = 0.6
local COMBO_MAX = 8
-- A rising pitch across a streak is doing most of the audible work here — the
-- same trick as a coin run in a platformer. The jitter on top keeps a
-- sustained streak from sounding like a loop.
local PITCH_COMBO_RANGE = 0.5
local PITCH_JITTER = 0.06

-- Numbers only roll when they jump by more than one. A single press must
-- change the digits on the SAME frame the key went down — rolling a +1 would
-- put the number behind the punch that is meant to be celebrating it. A jump
-- (the server reconciling a rejoin with a higher persisted total) is the case
-- worth animating, and there the roll is what makes it read as earned.
local ROLL_TIME = 0.45

-- The counter growing in from nothing when the HUD is first built.
local INTRO_TIME = 0.5

function Counter.new(gui: ScreenGui)
	local self = setmetatable({}, Counter)
	self.gui = gui
	self.shown = 0 -- what the digits currently read
	self.target = 0 -- what they are heading toward
	self.combo = 0
	self.lastLand = 0
	self.wobbleSide = 1
	self.activeScale = nil :: Tween?
	self.activeRotation = nil :: Tween?
	self.rollConnection = nil :: RBXScriptConnection?

	-- AnchorPoint is centered on BOTH axes so the punch and the wobble pivot
	-- around the middle of the digits. Anchored at the top (the obvious
	-- choice for a top-margin HUD) the number would visibly grow downward and
	-- swing from its top edge, which reads as a hinge rather than an impact.
	local holder = Instance.new("Frame")
	holder.Name = "Counter"
	holder.AnchorPoint = Vector2.new(0.5, 0.5)
	holder.Position = UDim2.new(0.5, 0, 0, TOP_MARGIN + Counter.TEXT_SIZE / 2)
	holder.Size = UDim2.fromOffset(0, Counter.TEXT_SIZE)
	holder.AutomaticSize = Enum.AutomaticSize.X
	holder.BackgroundTransparency = 1
	-- Above the shockwave rings, which are siblings (see _shockwave) at
	-- ZIndex 1. Equal ZIndex would fall back to child order, and a ring
	-- created later would paint over the digits.
	holder.ZIndex = 2
	holder.Parent = gui
	self.holder = holder

	-- Fixed TextSize + AutomaticSize, NOT TextScaled: TextScaled recomputes
	-- the glyph layout against the label's OWN size every frame, which fought
	-- with the UIScale punch — the digits would grow past whatever bounding
	-- box the label had a moment before ("les chiffres dépassent le
	-- compteur"). A fixed size that the box grows to fit is stable no matter
	-- how many digits or how hard the punch is currently stretching them.
	local label = Instance.new("TextLabel")
	label.Name = "Value"
	label.BackgroundTransparency = 1
	label.Size = UDim2.new(0, 0, 1, 0)
	label.AutomaticSize = Enum.AutomaticSize.X
	label.TextSize = Counter.TEXT_SIZE
	label.FontFace = FONT
	label.TextColor3 = Color3.new(1, 1, 1)
	label.TextStrokeTransparency = 1
	label.Text = "0"
	label.Parent = holder
	self.label = label

	local stroke = Instance.new("UIStroke")
	stroke.Color = Color3.new(0, 0, 0)
	stroke.Thickness = Counter.STROKE_THICKNESS
	stroke.ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual
	stroke.LineJoinMode = Enum.LineJoinMode.Round
	stroke.Parent = label

	local scale = Instance.new("UIScale")
	scale.Parent = holder
	self.scale = scale

	local sound = Instance.new("Sound")
	sound.Name = "PointsLand"
	sound.SoundId = Config.POINTS_LAND_SOUND_ID
	sound.Volume = Config.POINTS_LAND_SOUND_VOLUME
	sound.Parent = gui
	self.sound = sound

	self:_intro()

	return self
end

-- The digits grow out of nothing when the HUD first appears, rather than
-- being there already on frame one. Back/Out overshoots past full size and
-- settles, so the arrival uses the same vocabulary as the punch every point
-- landing will use — the counter introduces itself by doing a small version
-- of the thing it does.
--
-- Held in activeScale like any other scale animation so a point landing
-- during the intro cancels it cleanly instead of fighting it.
function Counter:_intro()
	self.scale.Scale = 0
	local grow = TweenService:Create(
		self.scale,
		TweenInfo.new(INTRO_TIME, Enum.EasingStyle.Back, Enum.EasingDirection.Out),
		{ Scale = 1 }
	)
	self.activeScale = grow
	grow:Play()
end

-- Screen-space center of the digits. Popups aim here, and it has to be read
-- fresh every time rather than cached: the counter widens as it gains digits,
-- so a target cached at "9" would miss by half a glyph at "1000".
function Counter:Center(): Vector2
	return self.holder.AbsolutePosition + self.holder.AbsoluteSize / 2
end

function Counter:_render()
	self.label.Text = tostring(math.floor(self.shown))
end

-- Sets the value the digits should read. A step of one lands instantly; a
-- larger jump rolls (see ROLL_TIME above).
function Counter:Set(value: number)
	self.target = value

	if self.rollConnection then
		self.rollConnection:Disconnect()
		self.rollConnection = nil
	end

	if math.abs(value - self.shown) <= 1 then
		self.shown = value
		self:_render()
		return
	end

	local from = self.shown
	local elapsed = 0
	self.rollConnection = RunService.Heartbeat:Connect(function(dt: number)
		elapsed += dt
		local t = math.clamp(elapsed / ROLL_TIME, 0, 1)
		-- Ease out: the roll should sprint through the middle digits and coast
		-- onto the final one, not crawl to it at a constant rate.
		local eased = 1 - (1 - t) ^ 3
		self.shown = from + (self.target - from) * eased
		self:_render()

		if t >= 1 then
			self.shown = self.target
			self:_render()
			if self.rollConnection then
				self.rollConnection:Disconnect()
				self.rollConnection = nil
			end
		end
	end)
end

-- A ring born at the edge of the digits and expanding outward. Parented to
-- the ScreenGui rather than to the counter on purpose: the holder is
-- AutomaticSize.X, so a child wider than the digits would stretch the counter
-- itself and shove the number off center for as long as the ring lived.
function Counter:_shockwave()
	local center = self:Center()
	local startSize = self.holder.AbsoluteSize.X + RING_START_PADDING

	local ring = Instance.new("Frame")
	ring.AnchorPoint = Vector2.new(0.5, 0.5)
	ring.Position = UDim2.fromOffset(center.X, center.Y)
	ring.Size = UDim2.fromOffset(startSize, startSize)
	ring.BackgroundTransparency = 1
	ring.ZIndex = 1 -- under the number: the impact radiates from behind it
	ring.Parent = self.gui

	local corner = Instance.new("UICorner")
	corner.CornerRadius = UDim.new(1, 0)
	corner.Parent = ring

	local stroke = Instance.new("UIStroke")
	stroke.Color = Color3.new(1, 1, 1)
	stroke.Thickness = RING_THICKNESS
	stroke.Transparency = 0.35
	stroke.Parent = ring

	-- Quart Out on both: the ring should be almost fully grown and almost
	-- gone within the first third of its life, so it registers as a flash of
	-- impact rather than as an expanding circle the eye can follow.
	local info = TweenInfo.new(RING_TIME, Enum.EasingStyle.Quart, Enum.EasingDirection.Out)
	local grown = startSize + RING_GROWTH
	TweenService:Create(ring, info, { Size = UDim2.fromOffset(grown, grown) }):Play()

	local fade = TweenService:Create(stroke, info, { Transparency = 1, Thickness = 0 })
	fade:Play()
	fade.Completed:Once(function()
		ring:Destroy()
	end)
end

function Counter:_punch(intensity: number)
	local scale = self.scale

	-- Cancel rather than stack. Two tweens driving the same property fight
	-- for it every frame, which is what made a fast typist's counter look
	-- broken instead of energetic.
	if self.activeScale then
		self.activeScale:Cancel()
	end
	if self.activeRotation then
		self.activeRotation:Cancel()
	end

	scale.Scale = 1
	local peak = PUNCH_SCALE + PUNCH_COMBO_BONUS * intensity
	local up = TweenService:Create(scale, PUNCH_UP, { Scale = peak })
	self.activeScale = up
	up:Play()
	up.Completed:Once(function(state: Enum.PlaybackState)
		if state ~= Enum.PlaybackState.Completed or not scale.Parent then return end
		local down = TweenService:Create(scale, PUNCH_DOWN, { Scale = 1 })
		self.activeScale = down
		down:Play()
	end)

	-- Alternating sides rather than a random one: a streak that tips the same
	-- way twice reads as a drift, and one that picks randomly reads as noise.
	-- Strict alternation reads as recoil.
	self.wobbleSide = -self.wobbleSide
	self.holder.Rotation = WOBBLE_DEGREES * self.wobbleSide * (0.6 + 0.4 * intensity)
	local unwind = TweenService:Create(self.holder, WOBBLE_INFO, { Rotation = 0 })
	self.activeRotation = unwind
	unwind:Play()
end

function Counter:_playSound(intensity: number)
	local sound = self.sound
	if sound.SoundId == "" then return end
	sound.PlaybackSpeed = 1
		+ intensity * PITCH_COMBO_RANGE
		+ (math.random() * 2 - 1) * PITCH_JITTER
	sound:Play()
end

-- Called the instant a "+1" reaches the counter. Everything fires together:
-- that simultaneity is what makes it land as a single impact.
function Counter:Land()
	local now = os.clock()
	self.combo = (now - self.lastLand <= COMBO_WINDOW) and math.min(self.combo + 1, COMBO_MAX) or 0
	self.lastLand = now
	local intensity = self.combo / COMBO_MAX

	self:_punch(intensity)
	self:_shockwave()
	self:_playSound(intensity)
end

return Counter
]]></ProtectedString>
						<string name="ScriptGuid">{CAFE8F18-2DC7-4AE0-9C18-40DFC9B490DF}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">Counter</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBXC87DB430388A48B7A6F5D162FB32C42E">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: one "+1" flying from a keycap into the counter.
-- Fire and forget — Spawn() builds it, animates it, cleans it up, and calls
-- back once at the moment it is absorbed. Owns no state between popups, so
-- any number of them can be in the air at once without interfering.
--
-- The whole flight is hand-driven from a single Heartbeat rather than
-- assembled from TweenService tweens. Three reasons, all of them things that
-- were wrong when it was tweens:
--   * the arc cannot be expressed as a position tween at all;
--   * the target MOVES (the counter widens as it gains digits), so the
--     destination has to be re-read every frame, not baked in at spawn;
--   * scale, rotation, position and fade have to stay in lockstep. As
--     separate tweens on separate clocks they drifted, and the pop-in was
--     still finishing while the travel had already started — which read as
--     two animations fighting instead of one object moving.

local RunService = game:GetService("RunService")

-- WaitForChild, not a plain index: this whole folder is cloned into the
-- user's game by the plugin's Installer and replicated to the client, and a
-- sibling is not guaranteed to have arrived when this module first runs.
local Counter = require(script.Parent:WaitForChild("Counter"))

local PointPopup = {}

local FONT = Font.new("rbxasset://fonts/families/FredokaOne.json", Enum.FontWeight.Regular, Enum.FontStyle.Normal)

-- Every pixel metric here is scaled by Counter.SCALE, the HUD's single size
-- knob, so the popup always stays in proportion with the counter it feeds.
local SCALE = Counter.SCALE

-- Strictly below the counter's own size, and the peak of the pop-in overshoot
-- below is applied on top of THIS — so a "+1" never renders larger than the
-- total it is feeding, at any point in its flight. The gap is scaled too:
-- a fixed 8px gap would vanish at a large SCALE and the two would read as
-- the same size.
local TEXT_SIZE = Counter.TEXT_SIZE - 8 * SCALE
local DURATION = 0.55

-- How far above the straight line the arc's midpoint sits, and how far to
-- either side: a straight shot from cap to counter reads as the label sliding
-- rather than popping, and always bowing the same way reads as mechanical
-- once a few have landed in a row.
local ARC_HEIGHT = 90 * SCALE
local ARC_SPREAD = 70 * SCALE

-- Phase boundaries, as fractions of DURATION.
local POP_UNTIL = 0.28 -- scaling up out of nothing
local ABSORB_FROM = 0.72 -- shrinking into the counter
local FADE_FROM = 0.78 -- fading, deliberately AFTER the shrink starts so it
                       -- visibly shrinks before it vanishes: fading first
                       -- just deletes it, fading second reads as swallowed
local ABSORB_SCALE = 0.45
-- Not scaled: a tilt is an angle, and angles do not have a pixel size.
local TILT_DEGREES = 12

-- Overshoot curve: 0 -> past 1 -> settles at exactly 1. Does the pop-in and
-- its settle in ONE continuous curve, which is why the pop needs no separate
-- settle tween chasing it.
local BACK_C1 = 1.70158
local BACK_C3 = BACK_C1 + 1
local function backOut(x: number): number
	local p = x - 1
	return 1 + BACK_C3 * p * p * p + BACK_C1 * p * p
end

-- startPosition: screen-space pixels. targetOf: called every frame for the
-- counter's current center. onLand: fired once, at absorption.
function PointPopup.Spawn(
	gui: ScreenGui,
	startPosition: Vector2,
	targetOf: () -> Vector2,
	onLand: () -> ()
)
	local popup = Instance.new("TextLabel")
	popup.Name = "Popup"
	popup.AnchorPoint = Vector2.new(0.5, 0.5)
	popup.Position = UDim2.fromOffset(startPosition.X, startPosition.Y)
	popup.Size = UDim2.new(0, 0, 0, TEXT_SIZE)
	popup.AutomaticSize = Enum.AutomaticSize.X
	popup.BackgroundTransparency = 1
	popup.TextSize = TEXT_SIZE
	popup.FontFace = FONT
	popup.TextColor3 = Color3.new(1, 1, 1)
	popup.TextStrokeTransparency = 1
	popup.Text = "+1"
	popup.ZIndex = 5
	popup.Parent = gui

	local stroke = Instance.new("UIStroke")
	stroke.Color = Color3.new(0, 0, 0)
	stroke.Thickness = Counter.STROKE_THICKNESS
	stroke.ApplyStrokeMode = Enum.ApplyStrokeMode.Contextual
	stroke.LineJoinMode = Enum.LineJoinMode.Round
	stroke.Parent = popup

	local scale = Instance.new("UIScale")
	scale.Scale = 0
	scale.Parent = popup

	-- Which way this one bows. Random per popup so a run of them fans out
	-- instead of retracing one path.
	local side = math.random() < 0.5 and -1 or 1

	local elapsed = 0
	local connection: RBXScriptConnection
	connection = RunService.Heartbeat:Connect(function(dt: number)
		elapsed += dt
		local t = math.clamp(elapsed / DURATION, 0, 1)

		-- Re-read the destination every frame, and rebuild the arc around it:
		-- the counter drifts sideways as it gains a digit, and a popup already
		-- in the air has to follow it there rather than land where the counter
		-- used to be.
		local target = targetOf()
		local mid = (startPosition + target) / 2 + Vector2.new(side * ARC_SPREAD, -ARC_HEIGHT)

		-- Eased travel parameter: hangs by the cap while it pops in, then
		-- accelerates into the counter. A linear parameter made the popup
		-- leave before the eye had registered it and coast in at the end —
		-- exactly backwards from what should feel urgent.
		local travel = t ^ 1.7
		local a = startPosition:Lerp(mid, travel)
		local b = mid:Lerp(target, travel)
		local position = a:Lerp(b, travel)
		popup.Position = UDim2.fromOffset(position.X, position.Y)

		if t < POP_UNTIL then
			scale.Scale = backOut(t / POP_UNTIL)
		elseif t >= ABSORB_FROM then
			local k = (t - ABSORB_FROM) / (1 - ABSORB_FROM)
			scale.Scale = 1 - (1 - ABSORB_SCALE) * k
		else
			scale.Scale = 1
		end

		-- Unwinds as it flies: most tilted at launch, upright by arrival, so
		-- it settles into the counter's own orientation instead of hitting it
		-- askew.
		popup.Rotation = side * TILT_DEGREES * (1 - travel)

		local fade = t > FADE_FROM and (t - FADE_FROM) / (1 - FADE_FROM) or 0
		popup.TextTransparency = fade
		stroke.Transparency = fade

		if t >= 1 then
			connection:Disconnect()
			popup:Destroy()
			onLand()
		end
	end)
end

return PointPopup
]]></ProtectedString>
						<string name="ScriptGuid">{6B141636-6DC9-4032-AAA1-4EB2BC39432F}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">PointPopup</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="ModuleScript" referent="RBX72F4773CAA384AE7A5131FBA95B56F43">
					<Properties>
						<Content name="LinkedSource"><null></null></Content>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: own the points HUD as a whole — create it, keep the
-- number it should be showing, and turn a local key press into a "+1" in
-- flight. The pixels and the motion belong to Counter and PointPopup; this
-- module is the orchestrator and holds the only public API (Init/Notify).
--
-- Purely cosmetic and purely local: never the source of truth for the count
-- (that's Player.KeyCapperData.Points, owned by Server.lua and replicated
-- automatically since it lives under the Player instance), only a display
-- of it.
--
-- Local display is OPTIMISTIC: every press bumps it immediately, before the
-- batched RemoteEvent (see Client.lua) has even reached the server. Mirroring
-- points.Value directly would make the counter visibly stutter — it would sit
-- still for up to POINTS_BATCH_INTERVAL, then jump. Instead the server value
-- is only ever allowed to push the display UP, never down: our own presses
-- are already reflected the instant they happen, so the server catching up a
-- second later is not new information and must not un-count anything. It can
-- still only push it up, e.g. a client rejoining with a higher persisted
-- total than whatever it predicted this session.
--
-- Note the two clocks that follow from that. `displayed` moves the moment a
-- key goes down; the COUNTER only reacts (punch, shockwave, sound) when the
-- "+1" actually arrives, ~0.55s later. Landing the impact on key-down instead
-- would fire it while the popup is still mid-flight, and the popup would then
-- fly into a counter that had already finished celebrating it.

local Players = game:GetService("Players")

local PointsFolder = script.Parent
local Counter = require(PointsFolder:WaitForChild("Counter"))
local PointPopup = require(PointsFolder:WaitForChild("PointPopup"))

local PointsUI = {}

local GUI_NAME = "KeyCapperPointsUI"

local player = Players.LocalPlayer

local displayed = 0
local counter = nil :: any
local screenGui: ScreenGui? = nil

-- pointsValue: the player's Player.KeyCapperData.Points IntValue. Owned and
-- written by Server.lua; this module only ever reads it.
function PointsUI.Init(pointsValue: IntValue)
	local gui = Instance.new("ScreenGui")
	gui.Name = GUI_NAME
	gui.ResetOnSpawn = false
	gui.IgnoreGuiInset = true
	gui.Parent = player:WaitForChild("PlayerGui")
	screenGui = gui

	counter = Counter.new(gui)
	displayed = pointsValue.Value
	counter:Set(displayed)

	pointsValue.Changed:Connect(function(newValue: number)
		if newValue > displayed then
			displayed = newValue
			-- Rolls rather than snaps, because this is always a jump of more
			-- than one (see Counter:Set). No impact is played: nothing was
			-- pressed, the server merely told us we own more than we thought.
			counter:Set(displayed)
		end
	end)
end

-- Called once per local press, optimistically — before the batch that press
-- belongs to has even gone out, let alone come back confirmed.
function PointsUI.Notify(worldPosition: Vector3)
	local gui = screenGui
	if not (gui and counter) then return end

	displayed += 1
	counter:Set(displayed)

	local camera = workspace.CurrentCamera
	if not camera then return end

	-- Lifted a stud so the "+1" is born above the cap rather than inside it.
	local screenPoint, onScreen = camera:WorldToViewportPoint(worldPosition + Vector3.new(0, 1, 0))
	-- Off-screen presses still count (the number above already moved), they
	-- just get no popup: one flying in from behind the camera would enter the
	-- frame from an arbitrary edge with no visible origin.
	if not onScreen then return end

	PointPopup.Spawn(
		gui,
		Vector2.new(screenPoint.X, screenPoint.Y),
		function()
			return counter:Center()
		end,
		function()
			counter:Land()
		end
	)
end

return PointsUI
]]></ProtectedString>
						<string name="ScriptGuid">{02A5BD50-1237-456E-9737-E046F96A4020}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">PointsUI</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="Script" referent="RBXAB0302596E3E4341AFF62E892E1AEFD6">
					<Properties>
						<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: points bookkeeping. Creates each player's
-- KeyCapperData/Points IntValue, loads/saves it via DataStore, and applies
-- the batched deltas the client reports over PointsRemote.
--
-- We trust the client's delta here — there is no server-side proof a key was
-- actually pressed, only a clamp against an obviously spoofed value (see
-- Config.POINTS_MAX_PER_BATCH). Good enough for a prototype scoreboard, not
-- for anything competitive.
--
-- RunContext = Server: lives under ReplicatedStorage.KeyCapper alongside
-- Client, same reason Client gives for living there instead of somewhere
-- under ServerScriptService.

local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")

-- This script's own folder (Points): where its sibling PointsRemote lives.
-- KeyCapperFolder is one level up — the installed root — where Config and
-- Settings live.
local PointsFolder = script.Parent
local KeyCapperFolder = PointsFolder.Parent
local Config = require(KeyCapperFolder:WaitForChild("Config"))
local Settings = require(KeyCapperFolder:WaitForChild("Settings"))

local remote = PointsFolder:WaitForChild("PointsRemote") :: RemoteEvent

-- GetDataStore itself throws if API access to Studio/DataStores is disabled
-- (Game Settings > Security, or datastores simply unavailable in this
-- context) — a pcall here keeps the rest of the script (HUD, remote, points
-- in-session) working with in-memory-only points instead of erroring out
-- before a single connection is even set up.
local storeOk, store = pcall(function()
	return DataStoreService:GetDataStore(Config.POINTS_DATASTORE)
end)
if not storeOk then
	warn("[KeyCapper] DataStore unavailable, points will not persist across sessions:", store)
	store = nil
end

-- Which players actually got a Points value built, so PlayerRemoving and
-- BindToClose know who to save without re-checking Settings — a mid-session
-- toggle flip must not lose data for a player already being tracked.
local tracked: { [Player]: IntValue } = {}

local function loadPoints(player: Player): number
	if not store then return 0 end
	local ok, result = pcall(function()
		return store:GetAsync(tostring(player.UserId))
	end)
	if ok and typeof(result) == "number" then
		return result
	end
	return 0
end

local function savePoints(player: Player, points: IntValue)
	if not store then return end
	local ok, err = pcall(function()
		store:SetAsync(tostring(player.UserId), points.Value)
	end)
	if not ok then
		warn("[KeyCapper] failed to save points for", player.Name, ":", err)
	end
end

local function onPlayerAdded(player: Player)
	-- Checked once, at join: a toggle flipped mid-session shouldn't yank the
	-- value out from under players already tracked (see `tracked` above).
	if not Settings.GetValue("PointsEnabled") then return end

	local folder = Instance.new("Folder")
	folder.Name = Config.POINTS_DATA_FOLDER
	folder.Parent = player

	local points = Instance.new("IntValue")
	points.Name = Config.POINTS_VALUE_NAME
	points.Value = loadPoints(player)
	points.Parent = folder

	tracked[player] = points
end

local function onPlayerRemoving(player: Player)
	local points = tracked[player]
	if not points then return end
	tracked[player] = nil
	savePoints(player, points)
end

Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(onPlayerRemoving)
for _, player in ipairs(Players:GetPlayers()) do
	task.spawn(onPlayerAdded, player)
end

remote.OnServerEvent:Connect(function(player: Player, delta: any)
	if not Settings.GetValue("PointsEnabled") then return end
	local points = tracked[player]
	if not points then return end
	if typeof(delta) ~= "number" then return end

	delta = math.floor(delta)
	if delta <= 0 then return end
	delta = math.min(delta, Config.POINTS_MAX_PER_BATCH)

	points.Value += delta
end)

-- Best-effort: Studio's own shutdown budget is short, this is a courtesy
-- save on top of the per-player one in PlayerRemoving, not a replacement.
game:BindToClose(function()
	for player, points in pairs(tracked) do
		savePoints(player, points)
	end
end)
]]></ProtectedString>
						<bool name="Disabled">true</bool>
						<Content name="LinkedSource"><null></null></Content>
						<token name="RunContext">1</token>
						<string name="ScriptGuid">{ECC97261-B8CE-45CD-B033-8041CEFDBBA3}</string>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">Server</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
				<Item class="RemoteEvent" referent="RBX960F780A37BE4A47AC532D80D594C3A6">
					<Properties>
						<BinaryString name="AttributesSerialize"></BinaryString>
						<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
						<bool name="DefinesCapabilities">false</bool>
						<string name="Name">PointsRemote</string>
						<int64 name="SourceAssetId">-1</int64>
						<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
					</Properties>
				</Item>
			</Item>
		</Item>
		<Item class="Folder" referent="RBX511EF9B5239246EEA2998CEADD2A0BC5">
			<Properties>
				<BinaryString name="AttributesSerialize"></BinaryString>
				<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
				<bool name="DefinesCapabilities">false</bool>
				<string name="Name">Placement</string>
				<int64 name="SourceAssetId">-1</int64>
				<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
			</Properties>
			<Item class="ModuleScript" referent="RBXCEA1D6F6D61446F686660F4FA13A8F5C">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: display the translucent preview of what the brush is
-- about to place. Owns a pool of parts (one per footprint cell) so a brush
-- resize does not churn instances every mouse move.
--
-- Knows nothing about grids or raycasts: it receives ready-made CFrames.

local GhostPreview = {}
GhostPreview.__index = GhostPreview

local CONTAINER_NAME = "KeyCapperGhost"
local DEFAULT_COLOR = Color3.fromRGB(90, 200, 255)
local TRANSPARENCY = 0.4
-- The ghost sits exactly where the key will rest, which puts its bottom face
-- coplanar with the floor: the two surfaces then z-fight and the preview
-- flickers as the camera moves. Lifting it a hair along its own up axis (not
-- world Y, so it still works on a slope) kills the flicker. Placement itself
-- is untouched — this offset only ever applies to the preview.
local LIFT = 0.03

function GhostPreview.new(size: Vector3)
	local self = setmetatable({}, GhostPreview)
	self.size = size
	self.parts = {} :: { BasePart }
	self.color = DEFAULT_COLOR

	-- A single container: the Brush passes it whole to the raycast filter, so
	-- the ghost never blocks its own probe however many cells it has.
	local container = Instance.new("Folder")
	container.Name = CONTAINER_NAME
	-- The ghost is scratch state that belongs to the plugin, but it lives in
	-- the Workspace like any ordinary part — so entering Play, saving or
	-- publishing with the brush on used to carry it along, and it turned up
	-- floating in the running game. Archivable = false excludes the whole
	-- subtree from every copy Studio makes of the datamodel (play solo,
	-- save, publish), which is a guarantee rather than a race with whatever
	-- event we could try to deactivate the brush on.
	container.Archivable = false
	container.Parent = workspace
	self.container = container

	return self
end

function GhostPreview:_partAt(index: number): BasePart
	local existing = self.parts[index]
	if existing then return existing end

	local part = Instance.new("Part")
	part.Name = "Cell" .. index
	part.Size = self.size
	part.Anchored = true
	part.CanCollide = false
	part.CanQuery = false
	part.CanTouch = false
	part.Material = Enum.Material.ForceField
	part.Color = self.color
	part.Transparency = TRANSPARENCY
	part.Parent = self.container

	self.parts[index] = part
	return part
end

-- Shows exactly one cell per CFrame; surplus pooled parts are just hidden.
function GhostPreview:Show(cframes: { CFrame })
	for index, cframe in ipairs(cframes) do
		local part = self:_partAt(index)
		part.CFrame = cframe + cframe.UpVector * LIFT
		part.Transparency = TRANSPARENCY
	end

	for index = #cframes + 1, #self.parts do
		self.parts[index].Transparency = 1
	end
end

-- The eraser reuses the same preview in red: one ghost, and its colour is the
-- only thing telling you whether the next click adds or removes.
function GhostPreview:SetColor(color: Color3)
	self.color = color
	for _, part in ipairs(self.parts) do
		part.Color = color
	end
end

function GhostPreview:Hide()
	for _, part in ipairs(self.parts) do
		part.Transparency = 1
	end
end

function GhostPreview:Destroy()
	self.container:Destroy()
	self.parts = {}
end

return GhostPreview
]]></ProtectedString>
					<string name="ScriptGuid">{94000C23-E3DE-4F64-9234-4190823D5F29}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">GhostPreview</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBX972A56FF1E934D92981F15C416C0639D">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: the mouse loop. Translates mouse movement/clicks
-- into calls to SurfaceProbe / GridSolver / OverlapGuard / KeyPlacer / Eraser.
-- Knows nothing about how a key is built (delegated to KeyPlacer) nor about
-- what it looks like (GroupStyler).
--
-- One brush, two tools: Paint and Erase share the cursor, the footprint size
-- and the stroke/undo machinery. Only the per-cell action and the ghost's
-- colour differ.
--
-- Cost note: solving the footprint costs one raycast PER CELL, so an 8x8
-- brush is 64 raycasts. That budget is spent at most once per mouse move —
-- see _onMove.

local Constants = require(script.Parent.Parent.Constants)
local History = require(script.Parent.Parent.Core.History)
local SurfaceProbe = require(script.Parent.SurfaceProbe)
local GridSolver = require(script.Parent.GridSolver)
local OverlapGuard = require(script.Parent.OverlapGuard)
local KeyPlacer = require(script.Parent.KeyPlacer)
local GhostPreview = require(script.Parent.GhostPreview)
local LabelSource = require(script.Parent.LabelSource)
local Eraser = require(script.Parent.Eraser)
local GroupRegistry = require(script.Parent.Parent.Core.GroupRegistry)
local GroupStyler = require(script.Parent.Parent.Edit.GroupStyler)

local Brush = {}
Brush.__index = Brush

Brush.TOOLS = { "Paint", "Erase" }

local PAINT_GHOST_COLOR = Color3.fromRGB(90, 200, 255)
local ERASE_GHOST_COLOR = Color3.fromRGB(255, 90, 90)

function Brush.new(pluginInstance: Plugin)
	local self = setmetatable({}, Brush)
	-- We keep the plugin itself: without plugin:Activate(true) the PluginMouse
	-- never fires Move/Button1Down/KeyDown.
	self.plugin = pluginInstance
	self.mouse = pluginInstance:GetMouse()
	self.rotationSteps = 0
	-- When true, each CELL gets its own independent random rotation at
	-- placement time (see _cellFrames), instead of every key in the stroke
	-- sharing self.rotationSteps.
	self.rotationRandom = false
	self.currentLetter = "A"
	-- Default to Random + the full A-Z 0-9 charset: a fresh brush should
	-- immediately paint varied labels rather than a wall of the same letter.
	self.labelMode = "Random"
	self.labelCharset = LabelSource.DEFAULT_CHARSET
	self.brushSize = 1
	self.currentGroup = Constants.DEFAULT_GROUP
	self.tool = "Paint"
	self.eraseScope = "Group"
	self.ghost = nil :: any
	self.connections = {}

	-- Stroke state. A stroke = one held click. Keys it places are recorded so
	-- the same stroke never overwrites its own work when the footprints of two
	-- consecutive mouse positions overlap.
	self.painting = false
	-- Throttles how often a mouse move re-solves the footprint (see Activate):
	-- Mouse.Move can fire far faster than the raycasts need to be redone.
	self._lastSolve = 0
	self.strokeKeys = {} :: { [Instance]: true }
	-- Keys painted by this stroke, in order, so the end of the stroke can
	-- style just those instead of the whole group when the group's look does
	-- not depend on its membership.
	self.strokeNewKeys = {} :: { Model }
	-- Groups the stroke changed the membership of, painted or erased: their
	-- gradients are derived from the members, so they need a restyle at the
	-- end of the stroke (once, not per key).
	self.strokeGroups = {} :: { [string]: true }
	self.strokeErased = 0
	self.recording = nil :: History.Recording
	-- Fired whenever rotation changes, so the UI can stay in sync even when
	-- the user rotates with the R key rather than the panel button.
	self.OnRotated = nil :: ((number) -> ())?
	-- Fired at the end of any stroke that actually changed the board, painted
	-- or erased. The panel's per-group key counts are derived from the board,
	-- so this is the one moment they can go stale without anything else in the
	-- UI moving. Carries nothing: the listener re-reads the counts itself.
	self.OnStrokeEnded = nil :: (() -> ())?
	return self
end

function Brush:_ensureGhost()
	if self.ghost then return end
	self.ghost = GhostPreview.new(Constants.KEY_SIZE)
	self.ghost:SetColor(self.tool == "Erase" and ERASE_GHOST_COLOR or PAINT_GHOST_COLOR)
end

-- What the brush's rays must not hit: every existing key, plus the ghost.
--
-- Filtering on the keys FOLDER rather than on CollectionService:GetTagged is
-- what keeps this cheap. GetTagged allocates an array of every key on the
-- board and hands the engine a filter list that grows with the build — at a
-- couple of raycasts per cell per mouse move, that was the whole reason
-- painting got slower the more keys existed. The folder is one instance, and
-- KeyPlacer parents every key into it.
function Brush:_rayFilter(): { Instance }
	local filter: { Instance } = {}

	local folder = workspace:FindFirstChild(Constants.KEYS_FOLDER)
	if folder then table.insert(filter, folder) end
	if self.ghost then table.insert(filter, self.ghost.container) end

	return filter
end

-- Every CFrame the brush covers right now, one per footprint cell.
--
-- snap=false is the eraser: it deletes whatever its box touches, wherever
-- that key was placed from. Snapping would make erasing a key that belongs to
-- another surface's grid a game of alignment.
function Brush:_cellFrames(snap: boolean): { CFrame }
	local unitRay = self.mouse.UnitRay

	-- Existing keys are transparent to the brush: aiming at one targets the
	-- surface underneath, so the new key lands exactly where the old one is
	-- and simply replaces it at placement time. The eraser wants the same
	-- thing for the opposite reason: it needs the surface, not the key, to
	-- lay its footprint out.
	local ignore = self:_rayFilter()

	local hit = SurfaceProbe.Probe(unitRay.Origin, unitRay.Direction * 1000, ignore)
	if not hit then return {} end

	local anchor = snap
		and GridSolver.Snap(hit.position, hit.origin, Constants.GRID_PITCH)
		or hit.position

	local reach = Constants.CELL_PROBE_REACH
	local frames: { CFrame } = {}

	for _, cell in ipairs(GridSolver.Footprint(self.brushSize)) do
		local cellPosition = GridSolver.OffsetByCell(anchor, hit.frame, cell, Constants.GRID_PITCH)

		-- Every cell gets its own probe, not just the aimed one: extrapolating
		-- the centre cell's plane would float keys over a ledge or a hole. No
		-- surface under the cell means no key there.
		local cellHit = SurfaceProbe.Probe(
			cellPosition + hit.normal * reach,
			-hit.normal * (reach * 2),
			ignore
		)
		if cellHit then
			-- Everything below comes from the CELL's own hit, never the aimed
			-- one: a footprint spilling off a plate onto the ground must lay
			-- those keys flat on the ground instead of keeping the plate's tilt.
			-- Re-snapping here for the same reason: a key belongs to the grid of
			-- the surface it actually landed on.
			local cellAnchor = snap
				and GridSolver.Snap(cellHit.position, cellHit.origin, Constants.GRID_PITCH)
				or cellPosition
			-- The cap rests on the surface: raise it by half its height along
			-- the local normal (local Y of the surface frame).
			local resting = cellAnchor + cellHit.normal * (Constants.KEY_SIZE.Y / 2)
			-- Rolled once per cell, at solve time, same as everything else in this
			-- loop: since placement uses the exact frames the ghost just showed,
			-- the key that lands is the rotation the player saw about to land.
			local steps = self.rotationRandom and math.random(0, 3) or self.rotationSteps
			table.insert(frames, GridSolver.BuildCFrame(resting, cellHit.frame, steps))
		end
	end

	return frames
end

-- Solve the footprint, show it, and act on it if a stroke is running — all
-- from ONE solve. Painting used to solve the footprint twice per mouse move
-- (once for the ghost, once to place), doubling an already per-cell raycast
-- cost for no reason.
function Brush:_refresh(applyIfPainting: boolean)
	self:_ensureGhost()
	local frames = self:_cellFrames(self.tool ~= "Erase")
	self.ghost:Show(frames)

	if applyIfPainting and self.painting then
		self:_applyTool(frames)
	end
end

function Brush:_updateGhost()
	self:_refresh(false)
end

function Brush:Rotate()
	self:SetRotation(self.rotationSteps + 1)
end

function Brush:SetRotation(steps: number)
	self.rotationRandom = false
	self.rotationSteps = steps % 4
	if self.ghost then
		self:_updateGhost()
	end
	if self.OnRotated then
		self.OnRotated(self.rotationSteps)
	end
end

function Brush:SetRotationRandom()
	self.rotationRandom = true
	if self.ghost then
		self:_updateGhost()
	end
end

function Brush:SetLetter(letter: string)
	self.currentLetter = letter
end

function Brush:SetLabelMode(mode: string)
	self.labelMode = mode
end

function Brush:SetLabelCharset(name: string)
	self.labelCharset = name
end

function Brush:SetTool(tool: string)
	self.tool = tool
	if self.ghost then
		self.ghost:SetColor(tool == "Erase" and ERASE_GHOST_COLOR or PAINT_GHOST_COLOR)
		self:_updateGhost()
	end
end

function Brush:SetEraseScope(scope: string)
	self.eraseScope = scope
end

function Brush:SetGroup(name: string)
	-- No Ensure here on purpose: the group is created at the first stroke, so
	-- merely picking a name never writes into the user's game.
	self.currentGroup = name
end

function Brush:SetSize(size: number)
	self.brushSize = math.max(1, math.floor(size))
	-- Only refresh an existing preview: resizing while the brush is off must
	-- not spawn a ghost in the user's Workspace.
	if self.ghost then
		self:_updateGhost()
	end
end

-- Places one cell. Returns the new key, or nil if the cell was left untouched.
function Brush:_placeCell(cframe: CFrame): Model?
	local overlapping = OverlapGuard.FindOverlapping(cframe, Constants.KEY_SIZE)

	for _, existing in ipairs(overlapping) do
		-- Placed by the stroke in progress: the user is dragging over ground
		-- they just covered, not asking to redo it. Leave the cell alone.
		if self.strokeKeys[existing] then return nil end
	end

	-- Only older keys here: a taken spot is not a refusal, they give way.
	for _, existing in ipairs(overlapping) do
		local group = existing:GetAttribute(Constants.ATTR_GROUP)
		if typeof(group) == "string" then
			self.strokeGroups[group] = true
		end
		existing:Destroy()
	end

	local label = LabelSource.Pick(self.labelMode, self.currentLetter, self.labelCharset)
	local key = KeyPlacer.Place(cframe, label, self.currentGroup)
	self.strokeKeys[key] = true
	table.insert(self.strokeNewKeys, key)
	return key
end

function Brush:_applyTool(frames: { CFrame })
	if self.tool == "Erase" then
		local touched, removed = Eraser.Erase(
			frames,
			Constants.KEY_SIZE,
			self.eraseScope,
			self.currentGroup
		)
		for group in pairs(touched) do
			self.strokeGroups[group] = true
		end
		self.strokeErased += removed
		return
	end

	self.strokeGroups[self.currentGroup] = true
	for _, cframe in ipairs(frames) do
		self:_placeCell(cframe)
	end
end

-- A whole stroke is ONE undo entry: undoing a drag of fifty keys must not
-- cost fifty Ctrl+Z. Same for erasing.
function Brush:_beginStroke()
	self.painting = true
	self.strokeKeys = {}
	self.strokeNewKeys = {}
	self.strokeGroups = {}
	self.strokeErased = 0
	self.recording = History.Begin(
		self.tool == "Erase" and "KeyCapper: erase keys" or "KeyCapper: place keys"
	)
	if self.tool ~= "Erase" then
		GroupRegistry.Ensure(self.currentGroup)
	end
	self:_refresh(true)
end

function Brush:_endStroke()
	if not self.painting then return end
	self.painting = false

	if next(self.strokeKeys) or self.strokeErased > 0 then
		for group in pairs(self.strokeGroups) do
			-- Styling only the keys this stroke added, when the group's look
			-- allows it. A full Apply walks and recolours every member, so a
			-- one-key stroke on a 500-key group used to cost 500 recolours —
			-- that is what made painting slow down as the build grew.
			-- GroupStyler decides: a gradient depends on the whole membership
			-- and still forces the full pass, an erase always does.
			local incremental = (self.strokeErased == 0) and group == self.currentGroup
			GroupStyler.Apply(group, incremental and self.strokeNewKeys or nil)
		end
		History.Commit(self.recording)
		-- After the commit, not before: the counts must describe the board as
		-- the undo entry leaves it.
		if self.OnStrokeEnded then
			self.OnStrokeEnded()
		end
	else
		History.Cancel(self.recording)
	end

	self.recording = nil
	self.strokeKeys = {}
	self.strokeNewKeys = {}
	self.strokeGroups = {}
end

function Brush:Activate()
	-- Takes the mouse focus: mandatory for the events below to fire at all.
	self.plugin:Activate(true)
	self:_ensureGhost()
	table.insert(self.connections, self.mouse.Move:Connect(function()
		-- Mouse.Move fires far more often than the footprint needs
		-- re-solving (one raycast per cell — 257 of them on a 16x16 brush).
		-- Capping to ~120Hz is well above what's visible but cuts a large
		-- share of redundant raycasts during a fast paint drag.
		local now = os.clock()
		if now - self._lastSolve < (1 / 120) then return end
		self._lastSolve = now
		self:_refresh(true)
	end))
	table.insert(self.connections, self.mouse.Button1Down:Connect(function()
		self:_beginStroke()
	end))
	table.insert(self.connections, self.mouse.Button1Up:Connect(function()
		self:_endStroke()
	end))
	table.insert(self.connections, self.mouse.KeyDown:Connect(function(key)
		if key:lower() == "r" then
			self:Rotate()
		end
	end))
end

function Brush:Deactivate()
	-- Never leave a recording open: it would swallow every later edit.
	self:_endStroke()
	self.plugin:Deactivate()
	for _, c in ipairs(self.connections) do
		c:Disconnect()
	end
	self.connections = {}
	if self.ghost then
		self.ghost:Destroy()
		self.ghost = nil
	end
end

return Brush
]]></ProtectedString>
					<string name="ScriptGuid">{0E52D14D-4B16-4100-8334-818CC156955F}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">Brush</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBX13226A38DEA04B509FC19CBEC53A40ED">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: build ONE key instance (Model + Hitbox + Cap + GUI)
-- at a given CFrame, and parent it into the Workspace.
-- Decides neither where nor whether it's allowed: that's GridSolver/OverlapGuard.

local CollectionService = game:GetService("CollectionService")

local Constants = require(script.Parent.Parent.Constants)
local GuiFactory = require(script.Parent.Parent.RuntimeSource.Style.GuiFactory)
local GroupRegistry = require(script.Parent.Parent.Core.GroupRegistry)

local KeyPlacer = {}

-- Shrinks the touch hitbox's footprint below the visual cap: a hitbox exactly
-- matching the mesh reads as slightly wider than the cap at the corners
-- (the mesh isn't a perfect box), which felt like pressing a neighbouring key.
-- Tuned by eye by Seb against a scratch prop in Workspace, kept as a ratio
-- rather than a fixed stud value so it still tracks KEY_SIZE if that changes.
local HITBOX_XZ_RATIO = 0.8123

local function getKeysFolder(): Folder
	local folder = workspace:FindFirstChild(Constants.KEYS_FOLDER)
	if not folder then
		folder = Instance.new("Folder")
		folder.Name = Constants.KEYS_FOLDER
		folder.Parent = workspace
	end
	return folder :: Folder
end

-- The mesh ships inside the plugin (ServerStorage.KeyCapperPlugin.MeshTemplateSource),
-- not re-downloaded on every placement: faster, and avoids permission
-- propagation issues with InsertService:LoadAsset at runtime.
local meshTemplate: MeshPart? = nil
local function getMeshTemplate(): MeshPart
	if meshTemplate then return meshTemplate end

	local source = script.Parent.Parent:FindFirstChild("MeshTemplateSource")
	assert(source, "KeyCapper: MeshTemplateSource not found in the plugin")
	local mesh = source:FindFirstChildWhichIsA("MeshPart", true)
	assert(mesh, "KeyCapper: no MeshPart in MeshTemplateSource")

	meshTemplate = mesh
	return mesh
end

-- cframe: the final CFrame of the CAP (at rest). label: the displayed text.
-- group: the group the key is born into; its look is applied by GroupStyler,
-- not here, because a gradient depends on the whole group.
function KeyPlacer.Place(cframe: CFrame, label: string, group: string): Model
	local model = Instance.new("Model")
	model.Name = "Key_" .. label

	local hitbox = Instance.new("Part")
	hitbox.Name = "Hitbox"
	-- Y stays the cap's real height, no padding. X/Z shrunk by HITBOX_XZ_RATIO
	-- — see its comment.
	hitbox.Size = Vector3.new(
		Constants.KEY_SIZE.X * HITBOX_XZ_RATIO,
		Constants.KEY_SIZE.Y,
		Constants.KEY_SIZE.Z * HITBOX_XZ_RATIO
	)
	hitbox.CFrame = cframe
	hitbox.Anchored = true
	hitbox.CanCollide = false
	hitbox.CanTouch = false
	hitbox.CanQuery = true
	hitbox.Transparency = 1
	hitbox.Parent = model

	local cap = getMeshTemplate():Clone()
	cap.Name = "Cap"
	cap.CFrame = cframe
	cap.Anchored = true
	cap.CanCollide = false
	cap.CanTouch = false
	cap.CanQuery = false
	cap.Parent = model

	GuiFactory.BuildLabel(cap, label, GroupRegistry.GetValue(group, "LabelMaxDistance"))

	model.PrimaryPart = hitbox
	model:SetAttribute("Letter", label)
	model:SetAttribute(Constants.ATTR_GROUP, group)
	model.Parent = getKeysFolder()

	CollectionService:AddTag(hitbox, Constants.TAG_HITBOX)
	CollectionService:AddTag(model, Constants.TAG_KEY)

	-- No undo waypoint here on purpose: the caller groups a whole stroke into
	-- a single recording (see Core.History).
	return model
end


return KeyPlacer
]]></ProtectedString>
					<string name="ScriptGuid">{C4D6AC06-B974-4C8D-A6F1-DDF9D818DC19}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">KeyPlacer</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBXF517AD1D767D43C0A0F29BA52B3AB3E2">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: list the existing keys occupying a given spot.
-- Fully replaces a grid-occupancy check: this is what allows floors/overlaps
-- on Y without keeping anything up to date besides the KeyCapper_Key tag
-- (CollectionService already tracks that for us).
--
-- It only reports: deciding what to do with those keys (replace them) is the
-- Brush's call.

local CollectionService = game:GetService("CollectionService")
local Constants = require(script.Parent.Parent.Constants)

local OverlapGuard = {}

-- Negative margin: two keys placed perfectly side by side must NOT count as
-- overlapping each other. Only a real overlap is reported.
local MARGIN = 0.05

-- Walks up to the Model carrying the key tag. Written by hand because
-- FindFirstAncestorWhich does not exist (only ...WhichIsA, on class names).
local function taggedKeyAncestor(part: BasePart): Instance?
	local node: Instance? = part
	while node and node ~= workspace do
		if CollectionService:HasTag(node, Constants.TAG_KEY) then
			return node
		end
		node = node.Parent
	end
	return nil
end

-- Returns the distinct key models overlapping the box (empty table if free).
function OverlapGuard.FindOverlapping(cframe: CFrame, size: Vector3, excluding: Instance?): { Instance }
	-- Scoped to the keys FOLDER, not to CollectionService:GetTagged. This runs
	-- once per footprint cell per placement, and handing the engine a filter
	-- list containing every key on the board is what made painting get slower
	-- as the build grew. KeyPlacer parents every key here, and the tag is
	-- still verified per hit below, so nothing untagged sneaks through.
	local folder = workspace:FindFirstChild(Constants.KEYS_FOLDER)
	if not folder then return {} end

	local params = OverlapParams.new()
	params.FilterType = Enum.RaycastFilterType.Include
	params.FilterDescendantsInstances = { folder }

	local shrunk = Vector3.new(
		math.max(size.X - MARGIN * 2, 0.05),
		math.max(size.Y - MARGIN * 2, 0.05),
		math.max(size.Z - MARGIN * 2, 0.05)
	)

	local found: { Instance } = {}
	local seen: { [Instance]: true } = {}

	for _, part in ipairs(workspace:GetPartBoundsInBox(cframe, shrunk, params)) do
		local model = taggedKeyAncestor(part)
		if model and model ~= excluding and not seen[model] then
			seen[model] = true
			table.insert(found, model)
		end
	end

	return found
end

return OverlapGuard
]]></ProtectedString>
					<string name="ScriptGuid">{F0C0F037-2F53-40B9-A507-64108E680D6D}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">OverlapGuard</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBXE8BEDC7887F84B35A763161CA17B617A">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: snap a position WITHIN a surface frame (provided by
-- SurfaceProbe), and handle rotation in 90-degree steps.
--
-- Knows nothing about raycasting or Parts: pure 2D geometry in a plane. This
-- is what lets this module be tested without a Workspace.

local Constants = require(script.Parent.Parent.Constants)

local GridSolver = {}

-- hitPosition/gridOrigin come from the same Probe.Probe(...): gridOrigin's
-- position is the grid anchor (the Part's center), hitPosition the aimed point.
function GridSolver.Snap(hitPosition: Vector3, gridOrigin: CFrame, pitch: number): Vector3
	-- Express the aimed point in the grid's local frame: snapping only
	-- happens on the surface's two axes (local X/Z), never on local Y
	-- (height follows the raycast, not a grid).
	local relative = gridOrigin:PointToObjectSpace(hitPosition)
	local snappedX = math.round(relative.X / pitch) * pitch
	local snappedZ = math.round(relative.Z / pitch) * pitch

	-- relative.Y is kept as-is: the height comes from the raycast, the grid
	-- anchor sits at the Part's center and must not drag the key down into it.
	return gridOrigin:PointToWorldSpace(Vector3.new(snappedX, relative.Y, snappedZ))
end

-- Cell offsets of an N×N footprint around the aimed cell. Offsets are WHOLE
-- cells, always: an even N therefore sits slightly off-centre rather than on
-- half-steps. Half-steps would centre it prettily under the cursor but put its
-- keys half a pitch off the grid, so a 2x2 would no longer line up with a 1x1.
-- Alignment wins over centring.
function GridSolver.Footprint(size: number): { Vector2 }
	local cells: { Vector2 } = {}
	local half = math.floor((size - 1) / 2)

	for x = 0, size - 1 do
		for z = 0, size - 1 do
			table.insert(cells, Vector2.new(x - half, z - half))
		end
	end

	return cells
end

-- Moves a snapped point by a whole number of cells WITHIN the surface plane.
function GridSolver.OffsetByCell(position: Vector3, surfaceFrame: CFrame, cell: Vector2, pitch: number): Vector3
	return position
		+ surfaceFrame.RightVector * (cell.X * pitch)
		+ surfaceFrame.LookVector * (cell.Y * pitch)
end

-- rotationSteps: number of 90-degree steps (0-3), provided by the Brush (scroll wheel / key).
function GridSolver.BuildCFrame(snappedPosition: Vector3, surfaceFrame: CFrame, rotationSteps: number): CFrame
	local yaw = Constants.ROTATION_STEP * (rotationSteps % 4) + Constants.MESH_YAW_OFFSET
	-- surfaceFrame gives the orientation (up = normal); we only keep its
	-- rotation, replacing its position with the snapped point.
	return CFrame.new(snappedPosition) * (surfaceFrame - surfaceFrame.Position) * CFrame.Angles(0, yaw, 0)
end

return GridSolver
]]></ProtectedString>
					<string name="ScriptGuid">{067B60D7-1BA5-4A49-9352-431E6075D2CC}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">GridSolver</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBX27378AA2AB7C45E1AD33A8B71E5478FE">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: turn a ray into a SURFACE FRAME.
--
-- This is the piece that makes keys follow terrain slope and rotation
-- without any other module having to care: everything else then works in
-- this frame as if it were flat ground.
--
-- UpVector = the surface normal.
-- LookVector = a reference direction of the hit surface, projected onto the
-- plane. Two keys placed on the same Part therefore share the same frame,
-- hence the same grid: they are forced to align.

local SurfaceProbe = {}

export type Hit = {
	instance: Instance,
	position: Vector3,
	normal: Vector3,
	frame: CFrame, -- surface frame, origin at the impact point
	origin: CFrame, -- surface frame, origin at the grid anchor
}

local function referenceVectors(instance: Instance, up: Vector3): (Vector3, Vector3)
	-- Look for a stable reference direction carried by the surface.
	local candidates: { Vector3 }
	if instance:IsA("BasePart") then
		candidates = { instance.CFrame.LookVector, instance.CFrame.RightVector, instance.CFrame.UpVector }
	else
		-- Terrain: no own CFrame, fall back to world axes.
		candidates = { Vector3.new(0, 0, -1), Vector3.new(1, 0, 0) }
	end

	for _, candidate in ipairs(candidates) do
		-- Projection onto the surface plane.
		local projected = candidate - up * candidate:Dot(up)
		if projected.Magnitude > 1e-3 then
			local forward = projected.Unit
			return forward, forward:Cross(up).Unit
		end
	end

	-- Should never happen: at least one of the axes is never collinear with up.
	local fallback = Vector3.new(1, 0, 0)
	local forward = (fallback - up * fallback:Dot(up)).Unit
	return forward, forward:Cross(up).Unit
end

-- Where to anchor the grid. For a Part, its center: every key placed on that
-- Part then lands on the same grid, regardless of placement order.
local function gridAnchor(instance: Instance): Vector3
	if instance:IsA("BasePart") then
		return instance.Position
	end
	return Vector3.zero -- Terrain: grid anchored on the world origin
end

function SurfaceProbe.Probe(origin: Vector3, direction: Vector3, ignore: { Instance }?): Hit?
	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.FilterDescendantsInstances = ignore or {}
	params.IgnoreWater = true

	local result = workspace:Raycast(origin, direction, params)
	if not result then return nil end

	local up = result.Normal
	local forward, right = referenceVectors(result.Instance, up)

	return {
		instance = result.Instance,
		position = result.Position,
		normal = up,
		frame = CFrame.fromMatrix(result.Position, right, up, -forward),
		origin = CFrame.fromMatrix(gridAnchor(result.Instance), right, up, -forward),
	}
end

return SurfaceProbe
]]></ProtectedString>
					<string name="ScriptGuid">{4EE2EE9B-F7B9-4D10-8861-1ABA409B8878}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">SurfaceProbe</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBX08A984FB80A54759BFA70DC632FC4DB1">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: decide what text a key being placed carries.
-- Either a fixed character typed by the user, or a random one drawn from a
-- named character set.
--
-- math.random is fine here (unlike colours): the label is drawn ONCE at
-- placement and baked into the key's attribute, never recomputed, so it
-- cannot flicker on a later restyle.

local LabelSource = {}

local CHARSETS = {
	{ Name = "A-Z", Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" },
	{ Name = "0-9", Chars = "0123456789" },
	{ Name = "A-Z 0-9", Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" },
}

LabelSource.MODES = { "Fixed", "Random" }
LabelSource.DEFAULT_CHARSET = CHARSETS[3].Name -- "A-Z 0-9": default charset covers letters+numbers

function LabelSource.CharsetNames(): { string }
	local names: { string } = {}
	for _, set in ipairs(CHARSETS) do
		table.insert(names, set.Name)
	end
	return names
end

local function charsOf(name: string): string
	for _, set in ipairs(CHARSETS) do
		if set.Name == name then return set.Chars end
	end
	return CHARSETS[1].Chars
end

function LabelSource.Pick(mode: string, fixed: string, charsetName: string): string
	if mode ~= "Random" then
		return fixed
	end
	local chars = charsOf(charsetName)
	local index = math.random(1, #chars)
	return chars:sub(index, index)
end

return LabelSource
]]></ProtectedString>
					<string name="ScriptGuid">{06EDEC56-5D83-489A-B637-A72A64B12FE1}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">LabelSource</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBX666D0005B2E6474AA5512C221BF89F2F">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: remove the keys under a set of boxes.
--
-- Deliberately grid-free: the eraser does NOT snap. It deletes any key whose
-- box it touches, so a key placed against another surface (a different plate,
-- another floor) can still be wiped without lining the cursor up with its
-- grid first. That is the opposite trade-off from placement, and on purpose.
--
-- Scope guards the common accident: cleaning up one row of a keyboard must
-- not be able to eat the rest of it.

local Constants = require(script.Parent.Parent.Constants)
local OverlapGuard = require(script.Parent.OverlapGuard)

local Eraser = {}

Eraser.SCOPES = { "Group", "All" }

-- Returns the set of group names that lost at least one key: the caller
-- restyles those, because a gradient is derived from the members that remain.
function Eraser.Erase(cframes: { CFrame }, size: Vector3, scope: string, group: string): ({ [string]: true }, number)
	local touchedGroups: { [string]: true } = {}
	local removed = 0

	for _, cframe in ipairs(cframes) do
		for _, key in ipairs(OverlapGuard.FindOverlapping(cframe, size)) do
			local keyGroup = key:GetAttribute(Constants.ATTR_GROUP)
			if scope == "All" or keyGroup == group then
				if typeof(keyGroup) == "string" then
					touchedGroups[keyGroup] = true
				end
				key:Destroy()
				removed += 1
			end
		end
	end

	return touchedGroups, removed
end

return Eraser
]]></ProtectedString>
					<string name="ScriptGuid">{B0E905F0-1396-436F-A430-EE9E902BC9C3}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">Eraser</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
		</Item>
		<Item class="Folder" referent="RBXF8B3A064052445D7A0210267A93CF821">
			<Properties>
				<BinaryString name="AttributesSerialize"></BinaryString>
				<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
				<bool name="DefinesCapabilities">false</bool>
				<string name="Name">Core</string>
				<int64 name="SourceAssetId">-1</int64>
				<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
			</Properties>
			<Item class="ModuleScript" referent="RBXE01D2D0F0154420EA7C2D4325CFF465A">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: own the group DATA. Which groups exist, what each
-- one is worth, and which keys belong to one.
--
-- A group is what makes the plugin usable at all: you never edit 87 keys, you
-- edit the group they were painted into. Membership is a plain attribute on
-- the key Model, so a key carries its own identity and nothing has to be kept
-- in sync on the side.
--
-- Storage lives in ReplicatedStorage.KeyCapperGroups, deliberately OUTSIDE
-- ReplicatedStorage.KeyCapper: the Installer wipes and re-clones the runtime
-- folder on a version bump, and that must never take the user's groups with it.

local CollectionService = game:GetService("CollectionService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Constants = require(script.Parent.Parent.Constants)

local GroupRegistry = {}

-- Defaults applied to a group the first time it is created. Also the schema:
-- these keys are the only attributes a group carries.
-- Text/stroke defaults mirror GuiFactory's own build-time defaults, so a
-- freshly placed key and a freshly created group agree before any edit.
local DEFAULTS = {
	-- Cap colour channel. Mode picks which of the three fields below is live
	-- — see Edit.ColorModes / GroupStyler.
	CapColorMode = "Solid", -- "Solid" | "Gradient" | "Random"
	Color = Color3.fromRGB(230, 240, 250), -- Solid value, and Gradient's start
	GradientAxis = "X", -- "X" | "Z", only read when CapColorMode == "Gradient"
	GradientColor = Color3.fromRGB(90, 140, 220), -- Gradient's end
	-- Random mode: variation AMPLITUDES around Color (the same base the Solid
	-- mode uses). Hue at 1 is a full turn, i.e. "any colour"; a small hue
	-- range gives shades of one colour. See Edit.RandomColor.
	CapRandomHue = 1.00,
	CapRandomSat = 0.15,
	CapRandomValue = 0.15,

	SoundIds = "", -- comma-separated; empty = runtime default sound
	PitchJitter = 0.06,
	-- Mirrors RuntimeSource.Config.SOUND_VOLUME: the group's value and the
	-- runtime's fallback must agree, or a group left untouched would sound
	-- different from one that was.
	Volume = 0.5,

	-- Text colour channel: same three-mode shape as the cap's, independent.
	TextColorMode = "Solid",
	TextColor = Color3.new(0, 0, 0),
	TextGradientAxis = "X",
	TextGradientColor = Color3.fromRGB(255, 255, 255),
	TextRandomHue = 1.00,
	TextRandomSat = 0.15,
	TextRandomValue = 0.15,

	FontName = "Fredoka", -- see RuntimeSource.FontPresets
	StrokeEnabled = false,
	StrokeColor = Color3.new(0, 0, 0),
	StrokeSize = 1,
	-- SurfaceGui.MaxDistance for the label: how far away it stays readable.
	-- 150 studs by default, adjustable between 100-250 in the panel.
	LabelMaxDistance = 150,
	AnimationStyle = "Default", -- see RuntimeSource.AnimationPresets
	PressTime = 0.045,
	ReleaseTime = 0.13,
}

function GroupRegistry.EnsureFolder(): Folder
	local existing = ReplicatedStorage:FindFirstChild(Constants.GROUPS_FOLDER)
	if existing then return existing :: Folder end

	local folder = Instance.new("Folder")
	folder.Name = Constants.GROUPS_FOLDER
	folder.Parent = ReplicatedStorage
	return folder
end

-- Creates the group on first mention. Painting into a group name that does not
-- exist yet is the normal way to create one: no "new group" ceremony.
function GroupRegistry.Ensure(name: string): Configuration
	local folder = GroupRegistry.EnsureFolder()
	local existing = folder:FindFirstChild(name)
	if existing then return existing :: Configuration end

	local group = Instance.new("Configuration")
	group.Name = name
	for key, value in pairs(DEFAULTS) do
		group:SetAttribute(key, value)
	end
	group.Parent = folder
	return group
end

function GroupRegistry.Get(name: string): Configuration?
	local folder = ReplicatedStorage:FindFirstChild(Constants.GROUPS_FOLDER)
	if not folder then return nil end
	local group = folder:FindFirstChild(name)
	return group and group :: Configuration or nil
end

function GroupRegistry.List(): { string }
	local names: { string } = {}
	local folder = ReplicatedStorage:FindFirstChild(Constants.GROUPS_FOLDER)
	if not folder then return names end

	for _, child in ipairs(folder:GetChildren()) do
		table.insert(names, child.Name)
	end
	table.sort(names)
	return names
end

-- Removes the group's stored style. Members keep their Group attribute, so
-- they simply fall back to DEFAULTS (via GetValue) until repainted or
-- reassigned — deleting a group never touches placed keys.
function GroupRegistry.Delete(name: string)
	local group = GroupRegistry.Get(name)
	if group then
		group:Destroy()
	end
end

function GroupRegistry.Set(name: string, key: string, value: any)
	GroupRegistry.Ensure(name):SetAttribute(key, value)
end

function GroupRegistry.GetValue(name: string, key: string): any
	local group = GroupRegistry.Get(name)
	if not group then return DEFAULTS[key] end

	local value = group:GetAttribute(key)
	if value == nil then return DEFAULTS[key] end
	return value
end

-- Walks the tagged keys rather than keeping a member list: CollectionService is
-- already the source of truth, and a list would drift on every undo/delete.
function GroupRegistry.Members(name: string): { Model }
	local members: { Model } = {}
	for _, key in ipairs(CollectionService:GetTagged(Constants.TAG_KEY)) do
		if key:IsA("Model") and key:GetAttribute(Constants.ATTR_GROUP) == name then
			table.insert(members, key)
		end
	end
	return members
end

-- Every group's members from ONE walk of the tagged keys. Members() above
-- scans the whole board to answer for a single group, so anything touching
-- several groups through it was O(groups x keys) — quadratic on exactly the
-- big builds where it hurts. Callers that need more than one group use this.
function GroupRegistry.MembersByGroup(): { [string]: { Model } }
	local buckets: { [string]: { Model } } = {}
	for _, key in ipairs(CollectionService:GetTagged(Constants.TAG_KEY)) do
		if key:IsA("Model") then
			local group = key:GetAttribute(Constants.ATTR_GROUP)
			if typeof(group) == "string" then
				local bucket = buckets[group]
				if not bucket then
					bucket = {}
					buckets[group] = bucket
				end
				table.insert(bucket, key)
			end
		end
	end
	return buckets
end

return GroupRegistry
]]></ProtectedString>
					<string name="ScriptGuid">{E6034803-B825-45E2-8369-AE15D8A20BC3}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">GroupRegistry</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBX585845D0055D49F79DCFDAD95E492E1F">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: group a set of Workspace mutations into ONE undo
-- entry. Without this, a held-brush stroke would push one waypoint per key and
-- the user would have to Ctrl+Z a hundred times to undo one drag.
--
-- Recording can legitimately fail (another recording already open, Studio
-- busy): callers must stay correct with a nil id, hence the nil-tolerant API.

local ChangeHistoryService = game:GetService("ChangeHistoryService")

local History = {}

export type Recording = string?

function History.Begin(name: string): Recording
	return ChangeHistoryService:TryBeginRecording(name, name)
end

function History.Commit(recording: Recording)
	if not recording then return end
	ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
end

-- For a stroke that ended up placing nothing: leaves no undo entry behind.
function History.Cancel(recording: Recording)
	if not recording then return end
	ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
end

return History
]]></ProtectedString>
					<string name="ScriptGuid">{BF74D0FE-DC57-40E7-A270-47E2D3977850}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">History</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBX5ABD067212344B688E27D438E9C4F1A3">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: guarantee that ReplicatedStorage.KeyCapper exists and
-- is up to date in the USER's game. Idempotent: safe to call on every plugin
-- activation.

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Constants = require(script.Parent.Parent.Constants)

local Installer = {}

local watchdog: RBXScriptConnection? = nil

local function install(parentService: Instance, existing: Instance?): Folder
	local source = script.Parent.Parent:FindFirstChild("RuntimeSource")
	assert(source, "KeyCapper: RuntimeSource not found in the plugin")

	local installed = source:Clone()
	installed.Name = Constants.RUNTIME_NAME
	installed:SetAttribute("Version", Constants.VERSION)

	local detection = installed:FindFirstChild("Detection")
	local client = detection and detection:FindFirstChild("Client")
	if client then (client :: Script).Disabled = false end

	local points = installed:FindFirstChild("Points")
	local server = points and points:FindFirstChild("Server")
	if server then (server :: Script).Disabled = false end

	-- Parent the replacement BEFORE dropping the old one. On a version bump the
	-- two orders look equivalent, but they are not: destroying first means that
	-- anything throwing in between (a RuntimeSource the plugin can't see, most
	-- obviously) leaves the game with NO runtime at all, and the only symptom of
	-- that is keys which silently stop working.
	installed.Parent = parentService
	if existing then existing:Destroy() end
	return installed
end

-- The install is deliberately made outside any ChangeHistory recording, so it
-- is not itself undoable. That is not enough: undo restores a whole DataModel
-- state, so a Ctrl+Z reaching back past the install wipes the folder anyway.
-- It happens on the most ordinary gesture there is — place a first stroke, undo
-- it, place another. The keys come back, the runtime does not, and the board is
-- dead for the rest of the session with nothing in the Output to say so.
-- Nothing ever looks at the folder again, so re-installing it when it goes
-- missing is the only repair.
local function watch(installed: Folder)
	if watchdog then watchdog:Disconnect() end
	watchdog = installed.AncestryChanged:Connect(function(_, parent)
		if parent then return end
		-- Deferred: a redo can put this very instance back, and re-installing
		-- ahead of it would leave a duplicate behind.
		task.defer(function()
			if installed.Parent then return end
			Installer.EnsureRuntime()
		end)
	end)
end

function Installer.EnsureRuntime(): Folder
	local parentService = game:GetService(Constants.RUNTIME_PARENT :: any) :: Instance
	local existing = parentService:FindFirstChild(Constants.RUNTIME_NAME)

	if existing and existing:GetAttribute("Version") == Constants.VERSION then
		-- Re-armed even on the no-op path: the folder may have been restored by
		-- a redo, or be a leftover from a previous session that no live
		-- connection is watching.
		watch(existing :: Folder)
		return existing :: Folder
	end

	-- Different version (or nothing there): replace cleanly instead of letting
	-- two copies of the runtime coexist.
	local installed = install(parentService, existing)
	watch(installed)
	return installed
end

return Installer
]]></ProtectedString>
					<string name="ScriptGuid">{6F7A9F56-FD7A-4D4B-B025-375745A5D9F1}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">Installer</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
			<Item class="ModuleScript" referent="RBXE185E0F4CB804E10AE21BB214C0845DD">
				<Properties>
					<Content name="LinkedSource"><null></null></Content>
					<ProtectedString name="Source"><![CDATA[--!strict
-- Single responsibility: run a per-item job over a list without holding the
-- frame. Spends a fixed slice of each frame on the work and yields the rest.
--
-- Every heavy pass in the plugin is O(keys) — restyling a group, recolouring
-- after a stroke — and a board is expected to reach thousands of keys. Done
-- as one synchronous loop that is a visible Studio freeze; done a slice at a
-- time it is a progress bar nobody has to look at.
--
-- Note that this is adaptive by construction: a list that fits inside the
-- budget never yields at all. A ten-key stroke stays exactly as synchronous
-- (and as undo-correct) as it was before — only a pass big enough to actually
-- freeze ever pays the cost of spreading itself out.

local RunService = game:GetService("RunService")

local Chunker = {}

-- Measured on a 9k-key board, because both obvious instincts here are wrong:
--
--   * the dominant cost is the engine's own property writes (~80ms of genuine
--     cap.Color sets), and guarding them with a read-and-compare is measurably
--     SLOWER than just writing — the engine already skips no-op sets;
--   * shrinking the budget does NOT buy smoothness. Every yield costs a whole
--     frame, so halving it to 2ms doubled the wall time of a full pass
--     (410ms -> 1000ms) while the worst frame hold did not move at all: on a
--     board that size the frame was already ~18ms before we did anything.
--
-- So the budget buys nothing below roughly half a frame, and costs a lot. 8ms
-- keeps the pass clearly interactive while spending as few whole frames as it
-- can on getting there.
Chunker.BUDGET = 0.008

-- Runs `perItem` over every item. `shouldAbort` is polled at each yield:
-- returning true stops the run and makes Each return false, which is how a
-- superseded pass is dropped instead of finishing work nobody will look at.
function Chunker.Each<T>(
	items: { T },
	perItem: (T, number) -> (),
	shouldAbort: (() -> boolean)?
): boolean
	local deadline = os.clock() + Chunker.BUDGET
	for index, item in ipairs(items) do
		perItem(item, index)
		if os.clock() >= deadline then
			RunService.Heartbeat:Wait()
			if shouldAbort and shouldAbort() then return false end
			deadline = os.clock() + Chunker.BUDGET
		end
	end
	return true
end

return Chunker
]]></ProtectedString>
					<string name="ScriptGuid">{85DFBBD4-174E-4B67-BE64-769AB845B4FD}</string>
					<BinaryString name="AttributesSerialize"></BinaryString>
					<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
					<bool name="DefinesCapabilities">false</bool>
					<string name="Name">Chunker</string>
					<int64 name="SourceAssetId">-1</int64>
					<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>
				</Properties>
			</Item>
		</Item>
	</Item>
	<SharedStrings>
		<SharedString md5="N1XlNMb4t91Rf/q68/LVMQ==">Yzc4ZjY1YTg0NTMyZjRiYjNlZWJmODlmNWQzZTQxODc=</SharedString>
		<SharedString md5="ab2bVmJluRl5Bh3mAMD3KQ==">QXNzaXN0YW50OjQxNjE0M2JlLTU4NTgtNDEwNy1iYjhjLThhMWY1OTUxZTVlMQ==</SharedString>
		<SharedString md5="yuZpQdnvvUBOTYh1jqZ2cA=="></SharedString>
		<SharedString md5="+qv2o0HSW+htH+ALwYQpiw==">Q1NHUEhTAAAAAEJMT0NL</SharedString>
	</SharedStrings>
</roblox>