local cli = require("@luaupkg/batteries/cli")
local fs = require("@std/fs")
local path = require("@std/path")

local findAndReplace = require("@scripts/lib/findAndReplace")
local loomConfig = require("@repo/loom.config")
local semver = require("@scripts/lib/semver")

type VersionComponent = semver.VersionComponent

local function validateVersionBump(versionBump: string): VersionComponent
	versionBump = versionBump:lower()

	if versionBump == "major" or versionBump == "minor" or versionBump == "patch" then
		return versionBump
	else
		error(`invalid version bump, must be "major", "minor", or "patch" (got "{versionBump}")`)
	end
end

local function getNextVersion(currentVersion: string, versionBump: VersionComponent)
	local versionParts = semver.splitVersionComponents(currentVersion)

	if versionBump == "major" then
		versionParts = { versionParts[1] + 1, 0, 0 }
	elseif versionBump == "minor" then
		versionParts = { versionParts[1], versionParts[2] + 1, 0 }
	elseif versionBump == "patch" then
		versionParts = { versionParts[1], versionParts[2], versionParts[3] + 1 }
	end
	return table.concat(versionParts, ".")
end

local args = cli.parser()

args:add("versionBump", "positional", {
	help = `Can be one of "major", "minor", or "patch"`,
	required = true,
})

local argsNoScript = { ... }
table.remove(argsNoScript, 1)
args:parse(argsNoScript)

-- The only package we publish is FlipbookCore, so the following makes no
-- attempt to bump the version of any of our other workspace members
do
	local MANIFEST_PATHS = {
		"wally.toml",
		"loom.config.luau",
		"workspace/flipbook-core/rotriever.toml",
	}

	local bumpedVersionComponent = args:get("versionBump")
	local versionBump: VersionComponent = validateVersionBump(bumpedVersionComponent)

	-- The root Wally manifest is the source of truth for the current version
	local wallyPath = MANIFEST_PATHS[1]
	assert(fs.exists(wallyPath), `no wally.toml found at the root of the repo`)

	local currentVersion = semver.getVersionFromManifest(wallyPath)
	local nextVersion = getNextVersion(currentVersion, versionBump)

	for _, manifestPath in MANIFEST_PATHS do
		local manifestVersion
		if path.extname(manifestPath) == ".toml" then
			manifestVersion = semver.getVersionFromManifest(manifestPath)
		elseif path.basename(manifestPath) == "loom.config.luau" then
			manifestVersion = loomConfig.package.version
		else
			error(`unsupported manifest file type for {manifestPath}`)
		end

		if fs.exists(manifestPath) then
			findAndReplace(manifestPath, `version = "{manifestVersion}"`, `version = "{nextVersion}"`)
			print(`bumped version in {manifestPath} to {nextVersion} (was {manifestVersion})`)
		end
	end
end


local FlipbookBatteries = require("@luaupkg/flipbook-batteries")
local fs = require("@std/fs")
local path = require("@std/path")

local project = require("@repo/project")
local runBuildGroupAsync = require("@scripts/lib/build-system/runBuildGroupAsync")
local types = require("@scripts/lib/build-system/types")

local copyInto = FlipbookBatteries.copyInto
local run = FlipbookBatteries.run

type BuildContext = types.BuildContext

local function compileWorkspaceMemberAsync(memberPath: path.pathlike, context: BuildContext): path.path
	local relativeMemberPath = path.relative(project.REPO_PATH, memberPath)
	local memberBuildPath = path.join(memberPath, "build")

	runBuildGroupAsync({
		name = `📦 build {relativeMemberPath}`,
		paths = {
			memberPath,
		},
		context = context,
		step = function()
			fs.removedirectory(memberBuildPath, {
				recursive = true,
			})
			fs.createdirectory(memberBuildPath, {
				makeparents = true,
			})

			run("darklua", {
				"process",
				path.join(relativeMemberPath, "src"),
				memberBuildPath,
			}, {
				env = context.env,
			})
		end,
	})

	if context.channel == "prod" then
		runBuildGroupAsync({
			name = `✂ prune {relativeMemberPath}`,
			paths = { memberBuildPath },
			context = context,
			step = function()
				for _, pattern in project.PROD_CONFIG.prunedFiles do
					run("find", { memberBuildPath, "-type", "f", "-name", `"{pattern}"`, "-delete" })
				end
			end,
		})
	end

	local dest = path.join(context.dest, relativeMemberPath)

	fs.removedirectory(dest, {
		recursive = true,
	})
	copyInto(memberBuildPath, dest)

	return memberBuildPath
end

return compileWorkspaceMemberAsync


local crypto = require("@lute/crypto")
local fs = require("@std/fs")
local path = require("@std/path")

local HASH_METHOD = crypto.hash.md5

local function bufferToHex(buf: buffer): string
	local hex = ""
	for i = 0, buffer.len(buf) - 1 do
		hex ..= ("%02x"):format(buffer.readu8(buf, i))
	end
	return hex
end

local function hashFile(filePath: path.pathlike): string
	local content = fs.readfiletostring(filePath)
	return bufferToHex(crypto.digest(HASH_METHOD, content))
end

local function hashDir(dirPath: path.pathlike): string
	local hashes = {}

	for _, file in fs.listdirectory(dirPath) do
		local filePath = path.join(dirPath, file.name)

		if file.type == "dir" then
			table.insert(hashes, hashDir(filePath))
		else
			table.insert(hashes, hashFile(filePath))
		end
	end

	local joinedHash = table.concat(hashes, "")

	return bufferToHex(crypto.digest(HASH_METHOD, joinedHash))
end

local function hashPath(filePath: path.pathlike): string
	assert(fs.exists(filePath), `no file found at {filePath}`)

	if fs.type(filePath) == "dir" then
		return hashDir(filePath)
	else
		return hashFile(filePath)
	end
end

return hashPath


local types = require("@self/types")

export type BuildContext = types.BuildContext

return {
	compileAsync = require("@self/compileAsync"),
	compileWorkspaceMemberAsync = require("@self/compileWorkspaceMemberAsync"),
	readBuildCacheAsync = require("@self/readBuildCacheAsync"),
	writeBuildCacheAsync = require("@self/writeBuildCacheAsync"),
	runBuildGroupAsync = require("@self/runBuildGroupAsync"),
}


local fs = require("@std/fs")
local json = require("@std/json")

local project = require("@repo/project")
local types = require("@scripts/lib/build-system/types")

local function readBuildCacheAsync(): types.BuildCache
	if fs.exists(project.BUILD_CACHE_PATH) then
		local content = fs.readfiletostring(project.BUILD_CACHE_PATH)

		if content and content ~= "" then
			return json.deserialize(content) :: any
		end
	end

	return {}
end

return readBuildCacheAsync


local fs = require("@std/fs")
local process = require("@std/process")
local richterm = require("@luaupkg/batteries/richterm")

local hashPath = require("@scripts/lib/build-system/hashPath")
local types = require("@scripts/lib/build-system/types")

type BuildGroup = types.BuildGroup

local function trimDecimals(n: number, decmimalPlaces: number)
	local factor = math.pow(10, decmimalPlaces)
	return math.floor(n * factor) / factor
end

local function runBuildGroupAsync(buildGroup: BuildGroup)
	local prefix = richterm.bold(`[{buildGroup.name}]`)
	local buildCache = buildGroup.context.cache
	local changedPaths = {}

	for _, filePath in buildGroup.paths do
		assert(fs.exists(filePath), `attempt to run build group with invalid path (found nothing at {filePath})`)

		local key = `{buildGroup.context.channel}-{buildGroup.context.target}-{filePath}`
		local hash = hashPath(filePath)

		if hash ~= buildCache[key] or buildGroup.context.shouldRebuild then
			buildCache[key] = hash
			table.insert(changedPaths, filePath)
		end
	end

	if #changedPaths == 0 then
		return
	end

	local processedBuildGroup = table.clone(buildGroup)
	processedBuildGroup.paths = changedPaths

	print(`{prefix} starting build step...`)

	local startTime = os.clock()
	local ok, err = (xpcall :: any)(function()
		buildGroup.step(processedBuildGroup)
	end, debug.traceback)

	if ok then
		local elapsedMs = trimDecimals((os.clock() - startTime) * 1000, 3)
		local message = richterm.cyan(`step completed in {elapsedMs}ms`)
		print(`{prefix} {message}`)
	else
		local message = richterm.red(`step failed: {err}`)
		print(`{prefix} {message}`)
		process.exit(1)
	end
end

return runBuildGroupAsync


local path = require("@std/path")

export type Channel = "prod" | "dev"
export type Target = "roblox" | "rotriever"

export type BuildCache = {
	-- Maps file path to MD5 hash
	[string]: string,
}

export type BuildContext = {
	channel: Channel,
	target: Target,
	dest: path.path,
	env: { [string]: string },
	shouldRebuild: boolean,
	cache: BuildCache,
}

export type BuildGroup = {
	name: string,
	paths: { path.pathlike },
	context: BuildContext,
	step: (self: BuildGroup) -> (),
}

return nil


local fs = require("@std/fs")
local json = require("@std/json")

local project = require("@repo/project")
local types = require("@scripts/lib/build-system/types")

local function writeBuildCacheAsync(buildCache: types.BuildCache)
	local contents = json.serialize(buildCache :: json.object, true)
	fs.writestringtofile(project.BUILD_CACHE_PATH, contents)
end

return writeBuildCacheAsync


local fs = require("@std/fs")
local path = require("@std/path")

local getStudioPluginsPath = require("@scripts/lib/getStudioPluginsPath")
local project = require("@repo/project")

local PATHS_TO_CLEAN: { path.pathlike } = {
	project.BUILD_PATH,
	path.join(getStudioPluginsPath(), project.PLUGIN_FILENAME),
	project.PLUGIN_FILENAME,
}

local function clean()
	for _, pathToClean in PATHS_TO_CLEAN do
		local filePath = path.resolve(pathToClean)

		if fs.exists(filePath) then
			print(`removing {filePath}`)

			if fs.type(filePath) == "dir" then
				fs.removedirectory(filePath, {
					recursive = true,
				})
			else
				fs.remove(filePath)
			end
		end
	end
end

return clean


local fs = require("@std/fs")
local json = require("@std/json")
local path = require("@std/path")

local function createRojoProjectAsync(projectPath: path.pathlike, projectContent: json.value): path.path
	local resolvedProjectPath = path.resolve(projectPath)
	fs.writestringtofile(resolvedProjectPath, json.serialize(projectContent, true))
	return resolvedProjectPath
end

return createRojoProjectAsync


local fs = require("@std/fs")
local path = require("@std/path")

local function findAndReplace(filePath: path.pathlike, match: string, replacement: string)
	local content = fs.readfiletostring(filePath)

	content = content:gsub(match, replacement)

	fs.writestringtofile(filePath, content)
end

return findAndReplace


local fs = require("@std/fs")
local path = require("@std/path")

local function reduce(rootPath: path.path, accumulator: { path.path }): { path.path }
	for _, child in fs.listdirectory(rootPath) do
		local childPath = path.join(rootPath, child.name)
		table.insert(accumulator, childPath)

		if child.type == "dir" then
			reduce(childPath, accumulator)
		end
	end
	return accumulator
end

local function getDescendants(dirPath: path.pathlike): { path.path }
	local rootPath = path.resolve(dirPath)

	if fs.type(rootPath) ~= "dir" then
		return {}
	end

	return reduce(rootPath, {})
end

return getDescendants


local path = require("@std/path")
local process = require("@std/process")
local system = require("@std/system")

local function getStudioPluginPath(): path.path
	if system.os == "Darwin" then
		return path.join(process.homedir(), "Documents/Roblox/Plugins")
	else
		return path.join(process.homedir(), "AppData/Local/Roblox/Plugins")
	end
end

return getStudioPluginPath


local FlipbookBatteries = require("@luaupkg/flipbook-batteries")

local fs = require("@std/fs")
local path = require("@std/path")
local system = require("@std/system")

local createRojoProjectAsync = require("@scripts/lib/createRojoProjectAsync")

local run = FlipbookBatteries.run

local function packToRbxm(filePath: path.pathlike): path.path
	local projectPath = path.join(system.tmpdir(), "default.project.json")
	local resolvedFilePath = path.resolve(filePath)

	createRojoProjectAsync(projectPath, {
		name = path.basename(filePath),
		tree = {
			["$path"] = tostring(resolvedFilePath),
		},
	})
	local outputPath = path.resolve(`{resolvedFilePath}.rbxm`)

	run("rojo", {
		"build",
		tostring(projectPath),
		"-o",
		tostring(outputPath),
	})

	fs.remove(projectPath)

	return outputPath
end

return packToRbxm


local fs = require("@std/fs")
local toml = require("@luaupkg/batteries/toml")

export type VersionComponent = "major" | "minor" | "patch"

local function getVersionFromManifest(manifestPath: string)
	local manifest = toml.deserialize(fs.readfiletostring(manifestPath))

	if manifest.workspace then
		return (manifest.workspace :: any).version
	elseif manifest.package then
		return (manifest.package :: any).version
	else
		error(`Could not find a 'version' field in {manifestPath}`)
	end
end

local function splitVersionComponents(version: string): { number }
	local stringVersionParts = version:split(".")

	local versionParts: { number } = {}
	for _, stringVersionPart in stringVersionParts do
		local versionPart = tonumber(stringVersionPart)
		assert(
			versionPart,
			`Invalid version part '{stringVersionPart}' in version '{version}' (could not convert to number)`
		)
		table.insert(versionParts, versionPart)
	end

	return versionParts
end

return {
	getVersionFromManifest = getVersionFromManifest,
	splitVersionComponents = splitVersionComponents,
}


local task = require("@lute/task")

local function waitForTasks(tasks: { thread })
	local running = table.clone(tasks)
	while #running > 0 do
		for index, job in running do
			if coroutine.status(job) == "dead" then
				table.remove(running, index)
			end
		end
		task.wait()
	end
end

return waitForTasks


local FlipbookBatteries = require("@luaupkg/flipbook-batteries")
local path = require("@std/path")
local process = require("@std/process")

local project = require("@repo/project")

local run = FlipbookBatteries.run
local findWhere = FlipbookBatteries.findWhere

local function findLuaFiles()
	local result = {}

	for _, folder in project.FOLDERS_TO_LINT do
		local matches = findWhere(folder, function(filePath)
			return path.extname(filePath) == ".lua"
		end)

		for _, match in matches do
			table.insert(result, match)
		end
	end

	return result
end

run("selene", { table.unpack(project.FOLDERS_TO_LINT) })

run("stylua", {
	"--check",
	table.unpack(project.FOLDERS_TO_LINT),
})

local files = findLuaFiles()
if #files > 0 then
	print("[err] the following file(s) are using the '.lua' extension. Please change to '.luau' and try again")
	print(`{table.concat(files, "\n")}`)
	process.exit(1)
end


local FlipbookBatteries = require("@luaupkg/flipbook-batteries")

local run = FlipbookBatteries.run

run("npm", {
	"install",
}, {
	cwd = "docs",
})

run("npm", {
	"start",
}, {
	cwd = "docs",
})


local ReplicatedStorage = game:GetService("ReplicatedStorage")

-- This path is defined in tests.project.json
local TestRunner = (require :: any)(ReplicatedStorage.Flipbook.workspace["test-runner"])

TestRunner.runTests()

return nil


local FlipbookBatteries = require("@luaupkg/flipbook-batteries")
local cli = require("@luaupkg/batteries/cli")
local path = require("@std/path")
local process = require("@std/process")
local system = require("@std/system")

local project = require("@repo/project")

local dotenv = FlipbookBatteries.dotenv
local run = FlipbookBatteries.run

dotenv()

local args = cli.parser()

args:add("apiKey", "option", {
	help = "Open Cloud API key used for running Luau Execution. Can also be "
		.. "supplied by the ROBLOX_API_KEY environment variable",
	aliases = { "k" },
})

args:add("filter", "option", {
	help = "Specify a filepath pattern to match for when running tests",
})

args:parse({ ... })

local apiKey = process.env.ROBLOX_API_KEY or args:get("apiKey")
if not apiKey then
	error(
		"A valid Open Cloud API key must be supplied with either the --apiKey flag or the ROBLOX_API_KEY environment variable"
	)
end

local filter = args:get("filter")

-- This gets picked up by the build step which uses Darklua to inject globals
process.env.JEST_TEST_PATH_PATTERN = if filter then filter else ""

run("lute", { "run", "build", "plugin", "--channel", "dev", "--clean", "--skip-reload" })

run("rocale", {
	"run",
	"--script",
	".lute/tasks/run-tests.luau",
	"--load.project",
	project.ROJO_TESTS_PROJECT,
	"--universeId",
	process.env.ROBLOX_UNIT_TESTING_UNIVERSE_ID,
	"--placeId",
	process.env.ROBLOX_UNIT_TESTING_PLACE_ID,
	"--output",
	tostring(path.join(system.tmpdir(), "tests.rbxl")),
}, {
	env = {
		ROBLOX_API_KEY = apiKey,
	},
})


local path = require("@std/path")

return {
	REPO_PATH = path.resolve("."),
	SOURCE_PATH = path.resolve("./src"),
	BUILD_PATH = path.resolve("./build"),
	BUILD_CACHE_PATH = path.resolve("./build/build-cache.json"),
	PACKAGES_PATH = path.resolve("./Packages"),
	ROBLOX_PACKAGES_PATH = path.resolve("./RobloxPackages"),
	ROBLOX_PACKAGES_VERSION = "0.715.0.7151115",
	SCRIPTS_PATH = path.resolve("./.lute"),
	EXAMPLE_PATH = path.resolve("./example"),
	WORKSPACE_PATH = path.resolve("./workspace"),
	LUAU_PACKAGES_PATH = path.resolve("./LuauPackages"),

	PLUGIN_FILENAME = "Flipbook.rbxm",

	SOURCEMAP_PATH = path.resolve("./sourcemap.json"),
	DARKLUA_SOURCEMAP_PATH = path.resolve("./sourcemap-darklua.json"),

	ROJO_BUILD_PROJECT = path.resolve("./build.project.json"),
	ROJO_TESTS_PROJECT = path.resolve("./tests.project.json"),

	FOLDERS_TO_LINT = {
		"src",
		"workspace",
		".lute",
	},

	PROD_CONFIG = {
		prunedDirs = {
			path.resolve("./workspace/code-samples"),
			path.resolve("./workspace/example"),
			path.resolve("./workspace/template"),
			path.resolve("./workspace/test-runner"),
		},
		prunedFiles = {
			"*.spec.lua*",
			"*.story.lua*",
			"*.storybook.lua*",
			"jest.config.lua*",
		},
	},
}


return {
	story = function()
		local button = Instance.new("TextButton")
		button.Text = "Button"
		button.TextSize = 16
		button.Font = Enum.Font.BuilderSansExtraBold
		button.TextColor3 = Color3.fromRGB(50, 50, 50)
		button.BackgroundColor3 = Color3.fromRGB(255, 255, 255)
		button.BorderSizePixel = 0
		button.Size = UDim2.fromOffset(200, 40)

		button.Activated:Connect(function()
			print("clicked")
		end)

		return button
	end,
}


local Fusion = require("@pkg/Fusion")

return {
	name = "Fusion",
	storyRoots = {
		script.Parent,
	},
	packages = {
		Fusion = Fusion,
	},
}


local Fusion = require("@pkg/Fusion")

local New = Fusion.New
local OnEvent = Fusion.OnEvent

local function FusionButton(props: {
	text: string,
	onActivated: () -> (),
})
	return New("TextButton")({
		Text = props.text,
		TextSize = 16,
		Font = Enum.Font.BuilderSansExtraBold,
		TextColor3 = Color3.fromRGB(50, 50, 50),
		BackgroundColor3 = Color3.fromRGB(255, 255, 255),
		BorderSizePixel = 0,
		Size = UDim2.fromOffset(200, 40),
		[OnEvent("Activated")] = props.onActivated,
	})
end

return FusionButton


local FusionButton = require("./FusionButton")

return {
	story = function()
		return FusionButton({
			text = "Click Me",
			onActivated = function()
				print("click")
			end,
		})
	end,
}


local Fusion = require("@pkg/Fusion")
local Storyteller = require("@pkg/Storyteller")

local storybook: Storyteller.Storybook = {
	name = "Fusion (Storyteller)",
	storyRoots = {
		script.Parent,
	},
	packages = {
		Fusion = Fusion,
	},
}

return storybook


local Storyteller = require("@pkg/Storyteller")

local FusionButton = require("./FusionButton")

local story: Storyteller.Story<any> = {
	story = function()
		return FusionButton({
			text = "Click Me",
			onActivated = function()
				print("click")
			end,
		})
	end,
}

return story


local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

return {
	name = "React",
	storyRoots = {
		script.Parent,
	},
	packages = {
		React = React,
		ReactRoblox = ReactRoblox,
	},
}


local React = require("@pkg/React")

local function ReactButton(props: {
	text: string,
	onActivated: () -> (),
})
	return React.createElement("TextButton", {
		Text = props.text,
		TextSize = 16,
		Font = Enum.Font.BuilderSansExtraBold,
		TextColor3 = Color3.fromRGB(50, 50, 50),
		BackgroundColor3 = Color3.fromRGB(255, 255, 255),
		BorderSizePixel = 0,
		Size = UDim2.fromOffset(200, 40),
		[React.Event.Activated] = props.onActivated,
	})
end

return ReactButton


local React = require("@pkg/React")

local ReactButton = require("./ReactButton")

return {
	story = function()
		return React.createElement(ReactButton, {
			text = "Click Me",
			onActivated = function()
				print("click")
			end,
		})
	end,
}


local React = require("@pkg/React")

local function ReactButton(props: {
	text: string,
	isDisabled: boolean,
	onActivated: () -> (),
})
	return React.createElement("TextButton", {
		Text = props.text,
		TextSize = 16,
		Font = Enum.Font.BuilderSansExtraBold,
		TextColor3 = Color3.fromRGB(50, 50, 50),
		BackgroundColor3 = Color3.fromRGB(255, 255, 255),
		AutoButtonColor = if props.isDisabled then false else true,
		BorderSizePixel = 0,
		Size = UDim2.fromOffset(200, 40),
		[React.Event.Activated] = if props.isDisabled then nil else props.onActivated,
	}, {
		Overlay = if props.isDisabled
			then React.createElement("Frame", {
				Size = UDim2.fromScale(1, 1),
				BackgroundColor3 = Color3.fromRGB(0, 0, 0),
				BackgroundTransparency = 0.6,
			})
			else nil,
	})
end

return ReactButton


local React = require("@pkg/React")

local ReactButtonControls = require("./ReactButtonControls")

local controls = {
	text = "Click Me",
	isDisabled = false,
}

type Props = {
	controls: typeof(controls),
}

return {
	controls = controls,
	story = function(props: Props)
		return React.createElement(ReactButtonControls, {
			text = props.controls.text,
			isDisabled = props.controls.isDisabled,
			onActivated = function()
				print("click")
			end,
		})
	end,
}


local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

return {
	story = function()
		return React.createElement("TextButton", {
			Text = "Click Me",
			TextSize = 16,
			Font = Enum.Font.BuilderSansExtraBold,
			TextColor3 = Color3.fromRGB(50, 50, 50),
			BackgroundColor3 = Color3.fromRGB(255, 255, 255),
			BorderSizePixel = 0,
			Size = UDim2.fromOffset(200, 40),
			[React.Event.Activated] = function()
				print("clicked")
			end,
		})
	end,
	packages = {
		React = React,
		ReactRoblox = ReactRoblox,
	},
}


local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")
local Storyteller = require("@pkg/Storyteller")

local storybook: Storyteller.Storybook = {
	name = "React (Storyteller)",
	storyRoots = {
		script.Parent,
	},
	packages = {
		React = React,
		ReactRoblox = ReactRoblox,
	},
}

return storybook


local React = require("@pkg/React")
local Storyteller = require("@pkg/Storyteller")

local ReactButton = require("./ReactButton")

local story: Storyteller.Story<any> = {
	story = function()
		return React.createElement(ReactButton, {
			text = "Click Me",
			onActivated = function()
				print("click")
			end,
		})
	end,
}

return story


local React = require("@pkg/React")
local Storyteller = require("@pkg/Storyteller")

local ReactButton = require("./ReactButton")

local controls: Storyteller.StoryControlsSchema = {
	text = Storyteller.createStringControl("Click Me"),
	isDisabled = Storyteller.createBooleanControl(false),
}

local story: Storyteller.Story<any> = {
	controls = controls,
	story = function(props)
		return React.createElement(ReactButton, {
			text = props.controls.text,
			isDisabled = props.controls.isDisabled,
			onActivated = function()
				print("click")
			end,
		})
	end,
}

return story


local Roact = require("@pkg/Roact")

return {
	name = "Roact",
	storyRoots = {
		script.Parent,
	},
	packages = {
		Roact = Roact,
	},
}


local Roact = require("@pkg/Roact")

local RoactButton = Roact.Component:extend("RoactButton")

function RoactButton:render()
	return Roact.createElement("TextButton", {
		Text = self.props.text,
		TextSize = 16,
		Font = Enum.Font.BuilderSansExtraBold,
		TextColor3 = Color3.fromRGB(50, 50, 50),
		BackgroundColor3 = Color3.fromRGB(255, 255, 255),
		BorderSizePixel = 0,
		Size = UDim2.fromOffset(200, 40),
		[Roact.Event.Activated] = self.props.onActivated,
	})
end

return RoactButton


local Roact = require("@pkg/Roact")

local RoactButton = require("./RoactButton")

return {
	story = function()
		return Roact.createElement(RoactButton, {
			text = "Click Me",
			onActivated = function()
				print("click")
			end,
		})
	end,
}


local Roact = require("@pkg/Roact")
local Storyteller = require("@pkg/Storyteller")

local storybook: Storyteller.Storybook = {
	name = "Roact (Storyteller)",
	storyRoots = {
		script.Parent,
	},
	packages = {
		Roact = Roact,
	},
}

return storybook


local Roact = require("@pkg/Roact")
local Storyteller = require("@pkg/Storyteller")

local RoactButton = require("./RoactButton")

local story: Storyteller.Story<any> = {
	story = function()
		return Roact.createElement(RoactButton, {
			text = "Click Me",
			onActivated = function()
				print("click")
			end,
		})
	end,
}

return story


local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")
local Sift = require("@pkg/Sift")

local fonts = Sift.Array.sort(Enum.Font:GetEnumItems(), function(a: Enum.Font, z: Enum.Font)
	return a.Name < z.Name
end)
fonts = Sift.Array.unshift(fonts, Enum.Font.Gotham)

local controls = {
	font = fonts,
}

type Props = {
	controls: {
		font: Enum.Font,
	},
}

return {
	summary = "Example of using array controls to set the font for a TextLabel",
	controls = controls,
	react = React,
	reactRoblox = ReactRoblox,
	story = function(props: Props)
		return React.createElement("TextLabel", {
			Text = props.controls.font.Name,
			Font = props.controls.font,
			TextScaled = true,
			TextColor3 = Color3.fromRGB(0, 0, 0),
			BackgroundColor3 = Color3.fromRGB(255, 255, 255),
			Size = UDim2.fromOffset(300, 100),
		})
	end,
}


local Roact = require("@pkg/Roact")

export type Props = {
	text: string,
	onActivated: () -> (),
}

local function Button(props: Props)
	return Roact.createElement("TextButton", {
		Text = props.text,
		TextSize = 16,
		Font = Enum.Font.GothamBold,
		TextColor3 = Color3.fromRGB(255, 255, 255),
		BackgroundColor3 = Color3.fromRGB(239, 31, 90),
		BorderSizePixel = 0,
		AutomaticSize = Enum.AutomaticSize.XY,
		[Roact.Event.Activated] = props.onActivated,
	}, {
		Padding = Roact.createElement("UIPadding", {
			PaddingTop = UDim.new(0, 8),
			PaddingRight = UDim.new(0, 8),
			PaddingBottom = UDim.new(0, 8),
			PaddingLeft = UDim.new(0, 8),
		}),
	})
end

return Button


local Button = require("./Button")
local Roact = require("@pkg/Roact")

return {
	summary = "A generic button component that can be used anywhere",
	roact = Roact,
	story = Roact.createElement(Button, {
		text = "Click me",
		onActivated = function()
			print("click")
		end,
	}),
}


local Roact = require("@pkg/Roact")

export type Props = {
	text: string,
	isDisabled: boolean?,
	onActivated: (() -> ())?,
}

local function ButtonWithControls(props)
	local color = if props.isDisabled then Color3.fromRGB(82, 82, 82) else Color3.fromRGB(239, 31, 90)

	return Roact.createElement("TextButton", {
		Text = props.text,
		TextSize = 16,
		Font = Enum.Font.GothamBold,
		TextColor3 = Color3.fromRGB(255, 255, 255),
		BackgroundColor3 = color,
		BorderSizePixel = 0,
		AutomaticSize = Enum.AutomaticSize.XY,
		[Roact.Event.Activated] = if props.isDisabled then nil else props.onActivated,
	}, {
		Padding = Roact.createElement("UIPadding", {
			PaddingTop = UDim.new(0, 8),
			PaddingRight = UDim.new(0, 8),
			PaddingBottom = UDim.new(0, 8),
			PaddingLeft = UDim.new(0, 8),
		}),
	})
end

return ButtonWithControls


local ButtonWithControls = require("./ButtonWithControls")
local Roact = require("@pkg/Roact")

local controls = {
	isDisabled = false,
	text = "Click me",
}

type Props = {
	controls: typeof(controls),
}

return {
	summary = "A generic button component that can be used anywhere",
	controls = controls,
	roact = Roact,
	story = function(props: Props)
		return Roact.createElement(ButtonWithControls, {
			text = props.controls.text,
			isDisabled = props.controls.isDisabled,
			onActivated = function()
				print("click")
			end,
		})
	end,
}


local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

return {
	summary = "AutoamticSize test for the story preview",
	react = React,
	reactRoblox = ReactRoblox,
	story = function()
		return React.createElement("TextLabel", {
			Size = UDim2.new(0, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.XY,

			TextSize = 24,
			Text = script.Name,
			Font = Enum.Font.GothamBold,
		})
	end,
}


local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

return {
	summary = "AutoamticSize test using a height that exceeds the story preview",
	react = React,
	reactRoblox = ReactRoblox,
	story = function()
		return React.createElement("TextLabel", {
			Size = UDim2.fromScale(1, 0),
			AutomaticSize = Enum.AutomaticSize.Y,

			TextSize = 24,
			Text = script.Name .. string.rep("\nLine", 100),
			Font = Enum.Font.GothamBold,
		})
	end,
}


local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

return {
	summary = "Offset test for the story preview",
	react = React,
	reactRoblox = ReactRoblox,
	story = function()
		return React.createElement("TextLabel", {
			Size = UDim2.fromOffset(2000, 2000),
			AutomaticSize = Enum.AutomaticSize.None,

			TextSize = 24,
			Text = script.Name,
			Font = Enum.Font.GothamBold,
		})
	end,
}


local RunService = game:GetService("RunService")

local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

local RESIZE_DURATION = 3 -- seconds
local MAX_SIZE = 2000 -- px

local function Story()
	local alpha, setAlpha = React.useState(0)

	React.useEffect(function()
		local conn = RunService.Heartbeat:Connect(function(dt: number)
			setAlpha(function(prev)
				local newAlpha = prev + (dt / RESIZE_DURATION)
				return if newAlpha > 1 then 0 else newAlpha
			end)
		end)

		return function()
			conn:Disconnect()
		end
	end, {})

	return React.createElement("TextLabel", {
		Size = UDim2.fromOffset(MAX_SIZE * alpha, MAX_SIZE * alpha),
		AutomaticSize = Enum.AutomaticSize.None,

		TextSize = 24,
		Text = script.Name,
		Font = Enum.Font.GothamBold,
	})
end

return {
	summary = "Resizing test for the story preview",
	react = React,
	reactRoblox = ReactRoblox,
	story = function()
		return React.createElement(Story)
	end,
}


local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

return {
	summary = "Scale test for the story preview",
	react = React,
	reactRoblox = ReactRoblox,
	story = function()
		return React.createElement("TextLabel", {
			Size = UDim2.fromScale(1, 1),
			AutomaticSize = Enum.AutomaticSize.None,

			TextSize = 24,
			Text = script.Name,
			Font = Enum.Font.GothamBold,
		})
	end,
}


local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

return {
	summary = "ScrollingFrame test for the story preview",
	react = React,
	reactRoblox = ReactRoblox,
	story = function()
		return React.createElement("ScrollingFrame", {
			Size = UDim2.fromScale(1, 1),
		}, {
			Label = React.createElement("TextLabel", {
				Size = UDim2.fromScale(1, 1),
				AutomaticSize = Enum.AutomaticSize.None,

				TextSize = 24,
				Text = script.Name,
				Font = Enum.Font.GothamBold,
			}),
		})
	end,
}


local Roact = require("@pkg/Roact")

local Counter = Roact.Component:extend("Counter")

export type Props = {
	increment: number,
	waitTime: number,
}

export type State = {
	count: number,
}

function Counter:init()
	self:setState({
		count = 0,
	})
end

function Counter:render()
	return Roact.createElement("TextLabel", {
		Text = self.state.count,
		TextScaled = true,
		Font = Enum.Font.Gotham,
		TextColor3 = Color3.fromRGB(0, 0, 0),
		BackgroundColor3 = Color3.fromRGB(255, 255, 255),
		Size = UDim2.fromOffset(300, 100),
	}, {
		Padding = Roact.createElement("UIPadding", {
			PaddingTop = UDim.new(0, 8),
			PaddingRight = UDim.new(0, 8),
			PaddingBottom = UDim.new(0, 8),
			PaddingLeft = UDim.new(0, 8),
		}),
	})
end

function Counter:didMount()
	local props: Props = self.props

	self.isMounted = true

	task.spawn(function()
		while self.isMounted do
			self:setState(function(prev: State)
				return {
					count = prev.count + props.increment,
				}
			end)

			task.wait(props.waitTime)
		end
	end)
end

function Counter:willUnmount()
	self.isMounted = false
end

return Counter


local Counter = require("./Counter")
local Roact = require("@pkg/Roact")

local controls = {
	increment = 1,
	waitTime = 1,
}

type Props = {
	controls: typeof(controls),
}

return {
	summary = "A simple counter that increments every second",
	controls = controls,
	roact = Roact,
	story = function(props: Props)
		return Roact.createElement(Counter, {
			increment = props.controls.increment,
			waitTime = props.controls.waitTime,
		})
	end,
}


return {
	name = "Example Storybook",
	storyRoots = {
		script.Parent,
	},
}


local controls = {
	text = "Functional Story",
}

type Props = {
	controls: typeof(controls),
}

return {
	summary = "This story uses a function with a cleanup callback to create and mount the gui elements. This works similarly to Hoarcekat stories but also supports controls and other metadata. Check out the source to learn more",
	controls = controls,
	story = function(parent: GuiObject, props: Props)
		local label = Instance.new("TextLabel")
		label.Text = props.controls.text
		label.Font = Enum.Font.Gotham
		label.TextColor3 = Color3.fromRGB(0, 0, 0)
		label.BackgroundColor3 = Color3.fromRGB(255, 255, 255)
		label.TextSize = 16
		label.AutomaticSize = Enum.AutomaticSize.XY

		local padding = Instance.new("UIPadding")
		padding.PaddingTop = UDim.new(0, 8)
		padding.PaddingRight = padding.PaddingTop
		padding.PaddingBottom = padding.PaddingTop
		padding.PaddingLeft = padding.PaddingTop
		padding.Parent = label

		label.Parent = parent

		return function()
			label:Destroy()
		end
	end,
}


local Roact = require("@pkg/Roact")

return function(target: Instance)
	local root = Roact.createElement("TextLabel", {
		Text = "Hoarcekat Story",
		TextScaled = true,
		TextColor3 = Color3.fromRGB(255, 255, 255),
		BackgroundColor3 = Color3.fromRGB(0, 0, 0),
		Size = UDim2.fromOffset(300, 100),
	}, {
		Padding = Roact.createElement("UIPadding", {
			PaddingTop = UDim.new(0, 8),
			PaddingRight = UDim.new(0, 8),
			PaddingBottom = UDim.new(0, 8),
			PaddingLeft = UDim.new(0, 8),
		}),
	})

	local tree = Roact.mount(root, target)

	return function()
		Roact.unmount(tree)
	end
end


local React = require("@pkg/React")

export type Props = {
	increment: number,
	waitTime: number,
}

local function ReactCounter(props: Props)
	local count, setCount = React.useState(0)

	React.useEffect(function()
		local isMounted = true

		task.spawn(function()
			while isMounted do
				setCount(function(prev)
					return prev + props.increment
				end)

				task.wait(props.waitTime)
			end
		end)

		return function()
			isMounted = false
		end
	end, { props })

	return React.createElement("TextLabel", {
		Text = count,
		TextScaled = true,
		Font = Enum.Font.Gotham,
		TextColor3 = Color3.fromRGB(0, 0, 0),
		BackgroundColor3 = Color3.fromRGB(255, 255, 255),
		Size = UDim2.fromOffset(300, 100),
	}, {
		Padding = React.createElement("UIPadding", {
			PaddingTop = UDim.new(0, 8),
			PaddingRight = UDim.new(0, 8),
			PaddingBottom = UDim.new(0, 8),
			PaddingLeft = UDim.new(0, 8),
		}),
	})
end

return ReactCounter


local React = require("@pkg/React")
local ReactCounter = require("./ReactCounter")
local ReactRoblox = require("@pkg/ReactRoblox")

local controls = {
	increment = 1,
	waitTime = 1,
}

type Props = {
	controls: typeof(controls),
}

return {
	summary = "A simple counter that increments every second. This is a copy of the Counter component, but written with React",
	controls = controls,
	react = React,
	reactRoblox = ReactRoblox,
	story = function(props: Props)
		return React.createElement(ReactCounter, {
			increment = props.controls.increment,
			waitTime = props.controls.waitTime,
		})
	end,
}


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")
local Storyteller = require("@pkg/Storyteller")

return {
	story = function(props: Storyteller.StoryProps)
		local children: { [string]: React.Node } = {}

		for key, value in props do
			children[key] = React.createElement(Foundation.Text, {
				tag = "auto-xy content-default text-body-large",
				Text = `- {key}: {value}`,
			})
		end

		return React.createElement(Foundation.View, {
			tag = "col gap-small",
		}, children)
	end,
	packages = {
		React = React,
		ReactRoblox = ReactRoblox,
	},
}


local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

return {
	story = function()
		return React.createElement("TextLabel", {
			Text = "Free from the shackles of storybooks",
			TextSize = 16,
			Font = Enum.Font.Gotham,
			TextColor3 = Color3.fromRGB(255, 255, 255),
			BackgroundTransparency = 1,
			AutomaticSize = Enum.AutomaticSize.XY,
		})
	end,
	packages = {
		React = React,
		ReactRoblox = ReactRoblox,
	},
}


error("This storybook has an error!")

return {
	name = "Unavailable Storybook",
	storyRoots = {
		script.Parent,
	},
}


local React = require("@pkg/React")

local AboutView = require("./AboutView")

return {
	story = function()
		return React.createElement(AboutView)
	end,
}


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")

local nextLayoutOrder = require("@root/Common/nextLayoutOrder")

local BUILD_INFO = {
	{ label = "Version", value = _G.BUILD_VERSION },
	{ label = "Channel", value = _G.BUILD_CHANNEL },
	{ label = "Hash", value = _G.BUILD_HASH },
}

export type Props = {
	layoutOrder: number?,
}

local function BuildInfo(props: Props)
	local children: { [string]: React.Node } = {}
	for _, info in BUILD_INFO do
		children[info.label] = React.createElement(Foundation.Text, {
			tag = "auto-xy text-body-medium content-muted",
			LayoutOrder = nextLayoutOrder(),
			Text = `{info.label}: {info.value}`,
		})
	end

	return React.createElement(Foundation.View, {
		tag = "auto-xy col gap-medium align-x-center",
		LayoutOrder = props.layoutOrder,
	}, children)
end

return BuildInfo


local React = require("@pkg/React")

local RobloxProfile = require("./RobloxProfile")

local controls = {
	userId = 1,
}

type Props = {
	controls: typeof(controls),
}
return {
	controls = controls,
	story = function(props: Props)
		local userId = assert(tonumber(props.controls.userId))

		return React.createElement(RobloxProfile, {
			userId = userId,
		})
	end,
}


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")

local constants = require("@root/constants")
local nextLayoutOrder = require("@root/Common/nextLayoutOrder")

local useTokens = Foundation.Hooks.useTokens

local e = React.createElement

type Props = {
	layoutOrder: number?,
}

local function Branding(props: Props)
	local tokens = useTokens()

	return e(Foundation.View, {
		tag = "auto-xy row gap-small align-y-center",
		LayoutOrder = props.layoutOrder,
	}, {
		Icon = e(Foundation.Image, {
			LayoutOrder = nextLayoutOrder(),
			Image = constants.FLIPBOOK_LOGO,
			Size = UDim2.fromOffset(tokens.Size.Size_800, tokens.Size.Size_800),
		}),

		Typography = e(Foundation.Text, {
			tag = "auto-xy text-heading-medium",
			LayoutOrder = nextLayoutOrder(),
			Text = "Flipbook",
		}),
	})
end

return Branding


local React = require("@pkg/React")

local Branding = require("./Branding")

return {
	summary = "Icon and Typography for Flipbook",
	controls = {},
	story = function()
		return React.createElement(Branding)
	end,
}


local CharmTypes = {}

export type Getter<T> = () -> T
export type Update<T> = ((T) -> T) | T
export type Setter<T> = (Update<T>) -> T

return CharmTypes


local Foundation = require("@rbxpkg/Foundation")
local Highlighter = require("@pkg/Highlighter")
local React = require("@pkg/React")
local Sift = require("@pkg/Sift")

local SelectableTextLabel = require("@root/Common/SelectableTextLabel")
local nextLayoutOrder = require("@root/Common/nextLayoutOrder")

local useMemo = React.useMemo
local useTokens = Foundation.Hooks.useTokens

local function getLineNumbers(str: string): string
	return Sift.List.reduce(str:split("\n"), function(accumulator, _item, index)
		return if index == 1 then tostring(index) else `{accumulator}\n{index}`
	end, "")
end

export type Props = {
	source: string,
	sourceColor: Color3?,
	layoutOrder: number?,
}

local function CodeBlock(props: Props)
	local tokens = useTokens()

	local isFocused, setIsFocused = React.useBinding(false)

	local sourceColor = useMemo(function()
		return if props.sourceColor then props.sourceColor else tokens.Color.Content.Default.Color3
	end, { props.sourceColor, tokens } :: { unknown })

	local source = useMemo(function()
		if props.sourceColor then
			return props.source
		else
			return table.concat(
				Highlighter.buildRichTextLines({
					src = props.source,
				}),
				"\n"
			)
		end
	end, { props.source })

	return React.createElement(Foundation.View, {
		tag = "size-full-0 auto-y bg-surface-200 gap-medium row radius-medium padding-medium",
		LayoutOrder = props.layoutOrder,
	}, {
		LineNumbers = React.createElement("TextLabel", {
			LayoutOrder = nextLayoutOrder(),
			AutomaticSize = Enum.AutomaticSize.XY,
			Text = getLineNumbers(source),
			TextSize = tokens.Typography.BodyMedium.FontSize,
			LineHeight = 1,
			BackgroundTransparency = 1,
			Font = Enum.Font.RobotoMono,
			TextColor3 = tokens.Color.Content.Muted.Color3,
			TextXAlignment = Enum.TextXAlignment.Right,
		}),

		SourceCodeWrapper = React.createElement(Foundation.ScrollView, {
			tag = "size-full-0 auto-y shrink",
			scroll = {
				AutomaticSize = Enum.AutomaticSize.Y,
				AutomaticCanvasSize = Enum.AutomaticSize.XY,
				CanvasSize = UDim2.fromScale(0, 0),
				ScrollingDirection = Enum.ScrollingDirection.X,
				HorizontalScrollBarInset = Enum.ScrollBarInset.Always,
			},
			layout = {
				FillDirection = Enum.FillDirection.Vertical,
			},
			LayoutOrder = nextLayoutOrder(),
		}, {
			SourceCode = React.createElement(SelectableTextLabel, {
				RichText = true,
				LayoutOrder = nextLayoutOrder(),
				AutomaticSize = Enum.AutomaticSize.XY,
				Text = isFocused:map(function(value: boolean)
					return if value == true then props.source else source
				end),
				TextColor3 = sourceColor,
				TextSize = tokens.Typography.BodyMedium.FontSize,
				TextWrapped = false,
				LineHeight = 1,
				Font = Enum.Font.RobotoMono,
				[React.Event.Focused] = function()
					setIsFocused(true)
				end,
				[React.Event.FocusLost] = function()
					setIsFocused(false)
				end,
			}),
		}),
	})
end

return CodeBlock


local React = require("@pkg/React")

local CodeBlock = require("./CodeBlock")

return {
	summary = "Previews a block of code",
	story = function()
		return React.createElement(CodeBlock, {
			source = "local function add(a: number, b: number): number\n\treturn a + b\nend",
		})
	end,
}


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local Storyteller = require("@pkg/Storyteller")

local ContextStack = require("@root/Common/ContextStack")
local NavigationContext = require("@root/Navigation/NavigationContext")
local PluginStore = require("@root/Plugin/PluginStore")
local useThemeName = require("@root/Common/useThemeName")

local useEffect = React.useEffect

export type Props = {
	plugin: Plugin,
	overlayGui: GuiBase2d?,
	children: React.Node?,
}

local function ContextProviders(props: Props)
	local themeName: "Dark" | "Light" = useThemeName()

	useEffect(function()
		PluginStore.get().setPlugin(props.plugin)
	end, { props.plugin })

	return React.createElement(ContextStack, {
		providers = {
			React.createElement(Foundation.FoundationProvider, {
				theme = themeName,
				overlayGui = props.overlayGui,
			}),
			React.createElement(NavigationContext.Provider, {
				defaultScreen = "Home",
			}),
			React.createElement(Storyteller.StorytellerProvider),
		},
	}, props.children)
end

return ContextProviders


local React = require("@pkg/React")

export type Props = {
	providers: { React.ReactElement<any, any> },
	children: React.ReactNode,
}

local function ContextStack(props: Props)
	local mostRecent = props.children

	for providerIndex = #props.providers, 1, -1 do
		local providerElement = props.providers[providerIndex]
		mostRecent = React.cloneElement(providerElement, nil, mostRecent)
	end

	return mostRecent
end

return ContextStack


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local Sift = require("@pkg/Sift")

local useTokens = Foundation.Hooks.useTokens

local defaultProps = {
	AutomaticSize = Enum.AutomaticSize.XY,
	BackgroundTransparency = 1,
	ClearTextOnFocus = false,
	ClipsDescendants = true,
	TextEditable = false,
	TextWrapped = true,
	TextXAlignment = Enum.TextXAlignment.Left,
	TextYAlignment = Enum.TextYAlignment.Top,
}

local function SelectableTextLabel(providedProps)
	local tokens = useTokens()

	local font = tokens.Typography.BodyMedium
	local color = tokens.Color.Content.Default

	local props = Sift.Dictionary.merge(defaultProps, {
		TextSize = font.FontSize,
		Font = font.Font,
		TextColor3 = color.Color3,
		TextTransparency = color.Transparency,
	}, providedProps)

	return React.createElement("TextBox", props)
end

return SelectableTextLabel


local React = require("@pkg/React")

local SelectableTextLabel = require("./SelectableTextLabel")

local controls = {
	text = "Ad proident sit nulla incididunt do nisi amet velit velit...",
}

type Props = {
	controls: typeof(controls),
}

return {
	summary = "A styled TextLabel with selectable text. Click and drag with the mouse to select content",
	controls = controls,
	story = function(props: Props)
		return React.createElement(SelectableTextLabel, {
			Text = props.controls.text,
		})
	end,
}


local function getAncestors(instance: Instance): { Instance }
	local ancestors = {}
	local parent = instance.Parent

	while parent do
		table.insert(ancestors, parent)
		parent = parent.Parent
	end

	return ancestors
end

return getAncestors


local function getBuildInfo(): { version: string, channel: string, hash: string }
	return {
		version = _G.BUILD_VERSION,
		channel = _G.BUILD_CHANNEL,
		hash = _G.BUILD_HASH,
	}
end

return getBuildInfo


local PATH_SEPARATOR = "/"

local function getInstancePath(instance: Instance, pathSeparator: string?): string
	local separator = if pathSeparator then pathSeparator else PATH_SEPARATOR

	local path = {}
	local current = instance

	while current and current.Parent ~= nil do
		table.insert(path, 1, current.Name)
		current = current.Parent
	end

	return table.concat(path, separator)
end

return getInstancePath


local function mapRanges(num: number, min0: number, max0: number, min1: number, max1: number): number
	if max0 == min0 then
		error("Range of zero")
	end

	return (((num - min0) * (max1 - min1)) / (max0 - min0)) + min1
end

return mapRanges


local layoutOrder = 0

local function nextLayoutOrder()
	layoutOrder += 1
	return layoutOrder
end

return nextLayoutOrder


local React = require("@pkg/React")

local function useEvent(event: RBXScriptSignal, callback: (...any) -> ())
	React.useEffect(function()
		local conn = event:Connect(callback)

		return function()
			conn:Disconnect()
		end
	end)
end

return useEvent


local React = require("@pkg/React")

local function usePrevious(value: any)
	local previous = React.useRef(nil)

	React.useEffect(function()
		previous.current = value
	end, { value })

	return previous.current
end

return usePrevious


local React = require("@pkg/React")
local ReactCharm = require("@pkg/ReactCharm")

local UserSettingsStore = require("@root/UserSettings/UserSettingsStore")

local useMemo = React.useMemo
local useState = React.useState
local useEffect = React.useEffect
local useSignalState = ReactCharm.useSignalState

export type ThemeName = "Dark" | "Light"

local MOCK_STUDIO = {
	ThemeChanged = Instance.new("BindableEvent").Event,
	Theme = {
		Name = "Light",
	},
}

local function useThemeName(): ThemeName
	local userSettingsStore = useSignalState(UserSettingsStore.get)
	local userSettings = useSignalState(userSettingsStore.getStorage)

	local studio: Studio = useMemo(function()
		local success, result = pcall(function()
			return (settings() :: any).Studio
		end)

		return if success then result else MOCK_STUDIO
	end, {})

	local studioTheme, setStudioTheme = useState(studio.Theme.Name)

	local theme = useMemo(function(): ThemeName
		if userSettings.theme ~= "system" then
			if userSettings.theme == "dark" then
				return "Dark"
			elseif userSettings.theme == "light" then
				return "Light"
			end
		else
			if studioTheme == "Dark" or studioTheme == "Light" then
				return studioTheme
			end
		end
		return "Dark"
	end, { userSettings.theme, studioTheme } :: { unknown })

	useEffect(function(): any
		local conn = studio.ThemeChanged:Connect(function()
			setStudioTheme(studio.Theme.Name)
		end)

		return function()
			conn:Disconnect()
		end
	end, { studio })

	return theme :: ThemeName
end

return useThemeName


local React = require("@pkg/React")

local ZOOM_INCREMENT = 0.25

local function useZoom(story: ModuleScript?)
	local value, setValue = React.useState(0)

	local zoomIn = React.useCallback(function()
		setValue(value + ZOOM_INCREMENT)
	end, { value, setValue } :: { unknown })

	local zoomOut = React.useCallback(function()
		setValue(value - ZOOM_INCREMENT)
	end, { value, setValue } :: { unknown })

	React.useEffect(function()
		setValue(0)
	end, { story })

	return {
		value = value,
		zoomIn = zoomIn,
		zoomOut = zoomOut,
	}
end

return useZoom


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")

local nextLayoutOrder = require("@root/Common/nextLayoutOrder")

export type Props = {
	onKeepEditing: () -> (),
	onDiscard: () -> (),
}

local function DiscardChangesDialog(props: Props)
	return React.createElement(Foundation.Dialog.Root, {
		disablePortal = false,
		hasBackdrop = true,
	}, {
		Title = React.createElement(Foundation.Dialog.Title, {
			text = "Discard feedback",
		}),

		Content = React.createElement(Foundation.Dialog.Content, nil, {
			Message = React.createElement(Foundation.Text, {
				tag = "auto-xy padding-top-medium",
				Text = "Are you sure you want to discard your changes?",
				LayoutOrder = nextLayoutOrder(),
			}),
		}),

		Actions = React.createElement(Foundation.Dialog.Actions, {
			actions = {
				{
					variant = Foundation.Enums.ButtonVariant.Standard :: Foundation.ButtonVariant,
					text = "Keep editing",
					onActivated = props.onKeepEditing,
				},
				{
					variant = Foundation.Enums.ButtonVariant.Emphasis,
					text = "Discard",
					onActivated = props.onDiscard,
				},
			},
			LayoutOrder = nextLayoutOrder(),
		}),
	})
end

return DiscardChangesDialog


local React = require("@pkg/React")

local DiscardChangesDialog = require("./DiscardChangesDialog")

return {
	story = function()
		return React.createElement(DiscardChangesDialog, {
			onKeepEditing = function()
				print("onKeepEditing")
			end,
			onDiscard = function()
				print("onDiscard")
			end,
		})
	end,
}


local React = require("@pkg/React")

local FeedbackDialog = require("./FeedbackDialog")

return {
	story = function()
		return React.createElement(FeedbackDialog, {
			onClose = function()
				print("onClose")
			end,
		})
	end,
}


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")

local nextLayoutOrder = require("@root/Common/nextLayoutOrder")

export type Props = {
	onClose: () -> (),
}

local function SuccessDialog(props: Props)
	return React.createElement(Foundation.Dialog.Root, {
		disablePortal = false,
		hasBackdrop = true,
	}, {
		Title = React.createElement(Foundation.Dialog.Title, {
			text = "Feedback posted",
			LayoutOrder = nextLayoutOrder(),
		}),

		Content = React.createElement(Foundation.Dialog.Content, {
			LayoutOrder = nextLayoutOrder(),
		}, {
			Message = React.createElement(Foundation.Text, {
				tag = "auto-xy text-wrap text-align-x-left text-align-y-top padding-top-medium",
				Text = "Your feedback has been submitted! Thank you for helping us to improve Flipbook",
			}),
		}),

		Actions = React.createElement(Foundation.Dialog.Actions, {
			actions = {
				{
					variant = Foundation.Enums.ButtonVariant.Emphasis,
					text = "Close",
					onActivated = props.onClose,
				},
			},
			LayoutOrder = nextLayoutOrder(),
		}),
	})
end

return SuccessDialog


local React = require("@pkg/React")

local SuccessDialog = require("./SuccessDialog")

return {
	story = function()
		return React.createElement(SuccessDialog, {
			onClose = function()
				print("onClose")
			end,
		})
	end,
}


local getBuildInfo = require("@root/Common/getBuildInfo")
local getLocalUserId = require("@root/Telemetry/getLocalUserId")
local requestAsync = require("@root/Http/requestAsync")

local function postFeedbackAsync(title: string, body: string)
	local buildInfo = getBuildInfo()

	return requestAsync(`{_G.BASE_URL}/feedback`, {
		method = "POST",
		body = {
			title = title,
			body = body,
			userId = tostring(getLocalUserId()),
			buildVersion = buildInfo.version,
			buildChannel = buildInfo.channel,
			buildHash = buildInfo.hash,
		},
	})
end

return postFeedbackAsync


local HttpService = game:GetService("HttpService")

local Sift = require("@pkg/Sift")
local logger = require("@root/logger")

local function requestAsync(
	url: string,
	options: {
		method: string?,
		headers: { [string]: string | number | boolean }?,
		body: { [any]: any }?,
	}?
)
	local requestOptions = Sift.Dictionary.join({
		method = "GET",
		headers = {},
	}, options)

	local body
	if requestOptions and requestOptions.body then
		if typeof(requestOptions.body) == "table" then
			requestOptions.headers["Content-Type"] = "application/json"
			body = HttpService:JSONEncode(requestOptions.body)
		else
			body = tostring(requestOptions.body)
		end
	end

	logger:Debug(`{requestOptions.method} {url}`)
	local res
	local success, err = pcall(function()
		res = HttpService:RequestAsync({
			Url = url,
			Method = requestOptions.method,
			Headers = requestOptions.headers,
			Body = body,
		})
	end)

	if not success then
		logger:Warn("failed to communicate with backend:", err)
	end
	if res and not res.Success then
		logger:Warn("failed sending event to backend:", res.StatusMessage)
	end

	return res
end

return requestAsync


local HttpService = game:GetService("HttpService")

local Foundation = require("@rbxpkg/Foundation")
local Log = require("@pkg/Log")
local React = require("@pkg/React")

local useMemo = React.useMemo
local useTokens = Foundation.Hooks.useTokens

type LogEvent = typeof(Log.LogEvent.new(Log.LogLevel.Information, {}, {}))

local function isLogLevel(str: string)
	local lower = str:lower()
	return lower == "debug" or lower == "warn" or lower == "info" or lower == "err"
end

local function getLogLevelColor(tokens: Foundation.Tokens, logLevel: string): Color3
	local lower = logLevel:lower()
	if lower == "debug" then
		return tokens.Color.Content.Muted.Color3
	elseif lower == "warn" then
		return tokens.Color.System.Warning.Color3
	elseif lower == "err" then
		return tokens.Color.System.Alert.Color3
	elseif lower == "info" then
		return tokens.Color.System.Success.Color3
	else
		return tokens.Color.Content.Muted.Color3
	end
end

export type Props = {
	event: LogEvent,
}

local function LogLine(props: Props)
	local tokens = useTokens()
	local fontStyle = tokens.Typography.BodyMedium

	local info = useMemo(function()
		local text = ""

		local timestamp = DateTime.fromUnixTimestamp(props.event.Timestamp)
			:FormatLocalTime("YYYY-MM-DD hh:mm:ss A", "en-us")
		text ..= timestamp

		if #props.event.Prefixes > 0 then
			for _, prefix in props.event.Prefixes do
				if isLogLevel(prefix) then
					local color = getLogLevelColor(tokens, prefix):ToHex()
					text = `{text} <font color="#{color}">{prefix}</font>`
				else
					text = `{text} {prefix}`
				end
			end
		end

		local color = tokens.Color.Content.Muted.Color3:ToHex()
		text = `<font color="#{color}">{text}</font>`

		return text
	end, { props.event })

	local message = ""
	for _, arg in props.event.Args do
		if typeof(arg) == "table" then
			message ..= HttpService:JSONEncode(arg)
		else
			message ..= tostring(arg)
		end
		message ..= " "
	end

	return React.createElement(Foundation.Text, {
		tag = "auto-xy text-wrap text-align-x-left text-align-y-top content-emphasis",
		Text = `{info}: {message}`,
		RichText = true,
		fontStyle = {
			Font = Enum.Font.RobotoMono,
			FontSize = fontStyle.FontSize,
			LineHeight = fontStyle.LineHeight,
		},
	})
end

return LogLine


local Charm = require("@pkg/Charm")
local Log = require("@pkg/Log")
local Sift = require("@pkg/Sift")

type LogEvent = typeof(Log.LogEvent.new(Log.LogLevel.Information, {}, {}))

local function createLogsStore()
	local getHistory, setHistory = Charm.signal({})

	local function addLine(event: LogEvent)
		setHistory(function(prev)
			return Sift.List.append(prev, event)
		end)
	end

	return {
		getHistory = getHistory,

		addLine = addLine,
	}
end

return createLogsStore


local Charm = require("@pkg/Charm")

local createLogsStore = require("@self/createLogsStore")

return {
	get = Charm.computed(createLogsStore),
}


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local ReactCharm = require("@pkg/ReactCharm")

local LogLine = require("./LogLine")
local LogsStore = require("./LogsStore")

local useState = React.useState
local useSignalState = ReactCharm.useSignalState

local function LogsView()
	local logsStore = useSignalState(LogsStore.get)
	local history = useSignalState(logsStore.getHistory)

	local maxLines, _setMaxLine = useState(100)

	local children: { [string]: React.Node } = {}
	for i = math.min(#history, maxLines), 1, -1 do
		children[`Line{i}`] = React.createElement(LogLine, {
			event = history[i],
		})
	end

	return React.createElement(Foundation.ScrollView, {
		tag = "size-full col gap-xsmall padding-medium",
		scroll = {
			AutomaticCanvasSize = Enum.AutomaticSize.Y,
			CanvasSize = UDim2.fromScale(1, 0),
			ScrollingDirection = Enum.ScrollingDirection.Y,
		},
		layout = {
			FillDirection = Enum.FillDirection.Vertical,
		},
	}, children)
end

return LogsView


local React = require("@pkg/React")

local LogsView = require("./LogsView")

return {
	story = function()
		return React.createElement(LogsView)
	end,
}


local React = require("@pkg/React")

local fireEventAsync = require("@root/Telemetry/fireEventAsync")
local navigationEnums = require("@root/Navigation/enums")
local usePrevious = require("@root/Common/usePrevious")

local useCallback = React.useCallback
local useContext = React.useContext
local useMemo = React.useMemo
local useState = React.useState
local useEffect = React.useEffect

type Screen = navigationEnums.Screen

export type NavigationContext = {
	navigateTo: (newScreen: Screen) -> (),
	goBack: () -> (),
	getBreadcrumbs: () -> { string },
	canGoBack: () -> boolean,
	currentScreen: Screen,
}

local Context = React.createContext({} :: NavigationContext)

export type Props = {
	defaultScreen: Screen,
	children: React.Node?,
}

local function Provider(props: Props)
	local stack: { Screen }, setStack = useState({ props.defaultScreen })
	local prevStack = usePrevious(stack)

	local navigateTo = useCallback(function(newScreen: Screen)
		setStack(function(prev)
			local new = table.clone(prev)
			table.insert(new, newScreen)
			return new
		end)
	end, {})

	local currentScreen = useMemo(function()
		return stack[#stack]
	end, { stack })

	local canGoBack = useMemo(function()
		return #stack > 1
	end, { stack })

	local goBack = useCallback(function()
		if canGoBack then
			setStack(function(prev)
				local new = table.clone(prev)
				table.remove(new)
				return new
			end)
		end
	end, { canGoBack })

	useEffect(function()
		if stack ~= prevStack then
			task.spawn(function()
				fireEventAsync({
					eventName = "PageChanged",
					properties = {
						page = stack[#stack],
					},
				})
			end)
		end
	end, { stack })

	return React.createElement(Context.Provider, {
		value = {
			navigateTo = navigateTo,
			goBack = goBack,
			breadcrumbs = stack,
			currentScreen = currentScreen,
		},
	}, props.children)
end

local function use(): NavigationContext
	return useContext(Context)
end

return {
	Context = Context,
	Provider = Provider,
	use = use,
}


local React = require("@pkg/React")
local Storyteller = require("@pkg/Storyteller")

local AboutView = require("@root/About/AboutView")
local LogsView = require("@root/Logs/LogsView")
local NavigationContext = require("@root/Navigation/NavigationContext")
local NoStorySelected = require("@root/Storybook/NoStorySelected")
local SettingsView = require("@root/UserSettings/SettingsView")
local StoryView = require("@root/Storybook/StoryView")
local StorybookError = require("@root/Storybook/StorybookError")

local useMemo = React.useMemo

type LoadedStorybook = Storyteller.LoadedStorybook
type UnavailableStorybook = Storyteller.UnavailableStorybook

export type Props = {
	story: ModuleScript?,
	storybook: LoadedStorybook?,
	unavailableStorybook: UnavailableStorybook?,
}

local function Screen(props: Props)
	local navigation = NavigationContext.use()
	local currentScreen = navigation.currentScreen

	local screenElement = useMemo(function(): React.Node
		if currentScreen == "Home" then
			if props.story and props.storybook then
				return React.createElement(StoryView, {
					storyModule = props.story,
					storybook = props.storybook,
				})
			elseif props.unavailableStorybook then
				return React.createElement(StorybookError, {
					unavailableStorybook = props.unavailableStorybook,
				})
			else
				return React.createElement(NoStorySelected)
			end
		elseif currentScreen == "Settings" then
			return React.createElement(SettingsView)
		elseif currentScreen == "About" then
			return React.createElement(AboutView)
		elseif currentScreen == "Logs" then
			return React.createElement(LogsView)
		end
		return nil :: never
	end, { props, currentScreen } :: { unknown })

	return screenElement
end

return Screen


local enums = {}

export type Screen = "Home" | "Settings" | "About" | "Logs"
enums.Screen = {
	Home = "Home" :: "Home",
	Settings = "Settings" :: "Settings",
	About = "About" :: "About",
	Logs = "Logs" :: "Logs",
}

return enums


local RunService = game:GetService("RunService")

local React = require("@pkg/React")
local ReactCharm = require("@pkg/ReactCharm")
local Sift = require("@pkg/Sift")

local PluginStore = require("@root/Plugin/PluginStore")
local types = require("@root/Panels/types")
local useColors = require("@root/Common/useColors")

local useSignalState = ReactCharm.useSignalState

local defaultProps = {
	size = 8, -- px
	hoverIconX = "rbxasset://textures/StudioUIEditor/icon_resize2.png",
	hoverIconY = "rbxasset://textures/StudioUIEditor/icon_resize4.png",
}

local useCallback = React.useCallback
local useEffect = React.useEffect
local useState = React.useState

export type Props = {
	handle: types.DragHandle,
	onDrag: (delta: Vector2) -> (),
	onDragEnd: (() -> ())?,
}

type InternalProps = Props & typeof(defaultProps)

local function DragHandle(providedProps: Props)
	local props: InternalProps = Sift.Dictionary.merge(defaultProps, providedProps)
	local colors = useColors()

	local pluginStore = useSignalState(PluginStore.get)
	local plugin = useSignalState(pluginStore.getPlugin)

	local isDragging, setIsDragging = useState(false)
	local isHovered, setIsHovered = useState(false)
	local mouseInput: InputObject?, setMouseInput = useState(nil :: InputObject?)

	local getHandleProperties = useCallback(function()
		local hitboxSize: UDim2
		local highlightSize: UDim2
		local position: UDim2
		local anchorPoint: Vector2

		if props.handle == "Right" or props.handle == "Left" then
			hitboxSize = UDim2.new(0, props.size, 1, 0)
			highlightSize = UDim2.new(0, props.size / 4, 1, 0)

			if props.handle == "Right" then
				position = UDim2.fromScale(1, 0)
				anchorPoint = Vector2.new(0.5, 0)
			else
				position = UDim2.fromScale(0, 0)
				anchorPoint = Vector2.new(0, 0)
			end
		elseif props.handle == "Top" or props.handle == "Bottom" then
			hitboxSize = UDim2.new(1, 0, 0, props.size)
			highlightSize = UDim2.new(1, 0, 0, props.size / 4)

			if props.handle == "Bottom" then
				position = UDim2.fromScale(0, 1)
				anchorPoint = Vector2.new(0, 0.5)
			else
				position = UDim2.fromScale(0, 0)
				anchorPoint = Vector2.new(0, 0)
			end
		end

		return hitboxSize, highlightSize, position, anchorPoint
	end, { props.handle, props.size } :: { unknown })

	local onInputBegan = useCallback(function(_rbx, input: InputObject)
		if input.UserInputType == Enum.UserInputType.MouseButton1 then
			setIsDragging(true)
		elseif input.UserInputType == Enum.UserInputType.MouseMovement then
			setIsHovered(true)
			setMouseInput(input)
		end
	end, { isDragging })

	local onInputEnded = useCallback(function(_rbx, input: InputObject)
		if input.UserInputType == Enum.UserInputType.MouseButton1 then
			setIsDragging(false)
			setMouseInput(nil)

			if props.onDragEnd then
				props.onDragEnd()
			end
		end
	end, { props.onDragEnd })

	local onMouseEnter = useCallback(function()
		setIsHovered(true)
	end, {})

	local onMouseLeave = useCallback(function()
		setIsHovered(false)
	end, {})

	local hitboxSize, highlightSize, position, anchorPoint = getHandleProperties()

	useEffect(function(): any
		if mouseInput and isDragging then
			local lastPosition = mouseInput.Position
			local conn = RunService.Heartbeat:Connect(function()
				local delta = mouseInput.Position - lastPosition

				if props.onDrag and delta ~= Vector3.zero then
					props.onDrag(Vector2.new(delta.X, delta.Y))
				end

				lastPosition = mouseInput.Position
			end)

			return function()
				conn:Disconnect()
			end
		else
			return nil
		end
	end, { mouseInput, isDragging } :: { unknown })

	useEffect(function()
		if plugin then
			local mouse = plugin:GetMouse()

			if isHovered or isDragging then
				if props.handle == "Left" or props.handle == "Right" then
					mouse.Icon = props.hoverIconX
				elseif props.handle == "Top" or props.handle == "Bottom" then
					mouse.Icon = props.hoverIconY
				end
			else
				mouse.Icon = ""
			end
		end
	end, { plugin, isDragging, isHovered } :: { unknown })

	return React.createElement("ImageButton", {
		Size = hitboxSize,
		Position = position,
		AnchorPoint = anchorPoint,
		BackgroundTransparency = 1,
		[React.Event.InputBegan] = onInputBegan,
		[React.Event.InputEnded] = onInputEnded,
		[React.Event.MouseEnter] = onMouseEnter :: any,
		[React.Event.MouseLeave] = onMouseLeave :: any,
	}, {
		Highlght = React.createElement("Frame", {
			Size = highlightSize,
			BorderSizePixel = 0,
			BackgroundTransparency = if isHovered or isDragging then colors.accent.Transparency else 1,
			BackgroundColor3 = colors.accent.Color3,
		}),
	})
end

return DragHandle


local DragHandle = require("@root/Panels/DragHandle")
local React = require("@pkg/React")
local Sift = require("@pkg/Sift")
local types = require("@root/Panels/types")

local defaultProps = {
	dragHandleSize = 8, -- px
	minSize = Vector2.new(0, 0),
	maxSize = Vector2.new(math.huge, math.huge),
}

export type Props = {
	LayoutOrder: number?,
	children: { [string]: React.Node }?,
	dragHandles: { types.DragHandle }?,
	hoverIconX: string?,
	hoverIconY: string?,
	initialSize: UDim2,
	onResize: ((newSize: Vector2) -> ())?,
}

type InternalProps = Props & typeof(defaultProps)

local function ResizablePanel(providedProps: Props)
	local props: InternalProps = Sift.Dictionary.merge(defaultProps, providedProps)

	local absoluteSize, setAbsoluteSize = React.useState(nil :: Vector2?)

	local clampedAbsoluteSize = React.useMemo(function()
		return if absoluteSize
			then Vector2.new(
				math.clamp(absoluteSize.X, props.minSize.X, props.maxSize.X),
				math.clamp(absoluteSize.Y, props.minSize.Y, props.maxSize.Y)
			)
			else nil
	end, { absoluteSize })

	local isWidthResizable = React.useMemo(function()
		if props.dragHandles then
			return Sift.Array.includes(props.dragHandles, "Left") or Sift.Array.includes(props.dragHandles, "Right")
		end
		return false
	end, { props.dragHandles })

	local isHeightResizable = React.useMemo(function()
		if props.dragHandles then
			return Sift.Array.includes(props.dragHandles, "Top") or Sift.Array.includes(props.dragHandles, "Bottom")
		end
		return false
	end, { props.dragHandles })

	local width = React.useMemo(function()
		return if clampedAbsoluteSize and isWidthResizable
			then UDim.new(0, clampedAbsoluteSize.X)
			else props.initialSize.Width
	end, { clampedAbsoluteSize })

	local height = React.useMemo(function()
		return if clampedAbsoluteSize and isHeightResizable
			then UDim.new(0, clampedAbsoluteSize.Y)
			else props.initialSize.Height
	end, { clampedAbsoluteSize, props.initialSize } :: { unknown })

	local onAbsoluteSizeChanged = React.useCallback(function(rbx: Frame)
		setAbsoluteSize(rbx.AbsoluteSize)
	end, {})

	local onHandleDragged = React.useCallback(function(handle: types.DragHandle, delta: Vector2)
		setAbsoluteSize(function(prev: Vector2)
			local x = prev.X + delta.X
			local y = prev.Y - delta.Y

			if handle == "Top" or handle == "Bottom" then
				x = prev.X
			elseif handle == "Right" or handle == "Left" then
				y = prev.Y
			end

			return Vector2.new(x, y)
		end)
	end, { props.minSize, props.maxSize })

	React.useEffect(function()
		if clampedAbsoluteSize and props.onResize then
			props.onResize(clampedAbsoluteSize)
		end
	end, { clampedAbsoluteSize })

	local dragHandles: { [string]: React.Node } = {}
	if props.dragHandles then
		for _, handle: types.DragHandle in props.dragHandles do
			dragHandles[handle] = React.createElement(DragHandle, {

				handle = handle,
				hoverIconX = props.hoverIconX,
				hoverIconY = props.hoverIconY,
				onDrag = function(delta: Vector2)
					onHandleDragged(handle, delta)
				end,
				onDragEnd = function()
					if clampedAbsoluteSize then
						setAbsoluteSize(clampedAbsoluteSize)
					end
				end,
			})
		end
	end

	return React.createElement("Frame", {
		LayoutOrder = props.LayoutOrder,
		Size = UDim2.new(width, height),
		BackgroundTransparency = 1,
		[React.Change.AbsoluteSize] = onAbsoluteSizeChanged,
	}, {
		DragHandles = React.createElement(React.Fragment, nil, dragHandles),

		Children = React.createElement("Frame", {
			BackgroundTransparency = 1,
			Size = UDim2.fromScale(1, 1),
		}, props.children),
	})
end

return ResizablePanel


local React = require("@pkg/React")
local ResizablePanel = require("./ResizablePanel")

local controls = {
	minWidth = 200,
	maxWidth = 500,
	minHeight = 200,
	maxHeight = 500,
}

type Props = {
	controls: typeof(controls),
}

return {
	controls = controls,
	story = function(props: Props)
		return React.createElement(ResizablePanel, {
			initialSize = UDim2.fromOffset(props.controls.maxWidth - props.controls.minWidth, 300),
			maxSize = Vector2.new(props.controls.maxWidth, props.controls.maxHeight),
			minSize = Vector2.new(props.controls.minWidth, props.controls.minHeight),
			dragHandles = {
				-- Luau FIXME: Type '{string}' could not be converted into '{"Bottom" | "Left" | "Right" | "Top"}'
				"Right" :: any,
				"Bottom" :: any,
			},
		}, {
			Content = React.createElement("Frame", {
				Size = UDim2.fromScale(1, 1),
			}),
		})
	end,
}


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local StorybookTreeView = require("@root/Storybook/StorybookTreeView")
local Storyteller = require("@pkg/Storyteller")

local IconName = Foundation.Enums.IconName
local InputSize = Foundation.Enums.InputSize
local ScrollView = Foundation.ScrollView
local TextInput = Foundation.TextInput
local View = Foundation.View
local withCommonProps = Foundation.Utility.withCommonProps

local e = React.createElement
local useCallback = React.useCallback
local useState = React.useState

type LoadedStorybook = Storyteller.LoadedStorybook
type UnavailableStorybook = Storyteller.UnavailableStorybook

type SidebarProps = {
	onStoryChanged: (storyModule: ModuleScript?, storybook: LoadedStorybook?) -> (),
	onShowErrorPage: (unavailableStorybook: UnavailableStorybook) -> (),
} & Foundation.CommonProps

local function Sidebar(props: SidebarProps)
	local searchTerm: string?, setSearchTerm = useState(nil :: string?)
	local onSearchTermChanged = useCallback(function(newSearchTerm: string)
		setSearchTerm(if newSearchTerm == "" then nil else newSearchTerm)
	end, {})

	return e(
		View,
		withCommonProps(props, {
			tag = "bg-surface-0 col size-full",
		}),
		{
			Search = e(View, {
				LayoutOrder = 1,
				tag = "auto-y padding-medium size-full-0",
			}, {
				SearchBar = e(TextInput, {
					label = "",
					leadingIcon = IconName.MagnifyingGlass,
					onChanged = onSearchTermChanged,
					placeholder = "Search...",
					size = InputSize.Small,
					text = "",
					width = UDim.new(1, 0),
				}),
			}),

			Content = e(ScrollView, {
				LayoutOrder = 2,
				scroll = {
					AutomaticCanvasSize = Enum.AutomaticSize.Y,
					CanvasSize = UDim2.fromScale(0, 0),
					ScrollingDirection = Enum.ScrollingDirection.Y,
				},
				layout = {
					FillDirection = Enum.FillDirection.Vertical,
				},
				tag = "shrink size-full",
			}, {
				StorybookTreeView = e(StorybookTreeView, {
					onShowErrorPage = props.onShowErrorPage,
					onStoryChanged = props.onStoryChanged,
					searchTerm = searchTerm,
				}),
			}),
		}
	)
end

return React.memo(Sidebar)


local React = require("@pkg/React")
local Storyteller = require("@pkg/Storyteller")

local Sidebar = require("./Sidebar")

return {
	summary = "Sidebar containing brand, searchbar, and component tree",
	controls = {},
	story = function()
		local storybooks = Storyteller.useStorybooks(game)

		return React.createElement(Sidebar, {
			storybooks = storybooks,
			onStoryChanged = function(storyModule, storybook)
				print("storyModule", storyModule)
				print("storybook", storybook)
			end,
			onShowErrorPage = function(unavailableStorybook)
				print("unavailableStorybook", unavailableStorybook)
			end,
		})
	end,
}


local Foundation = require("@rbxpkg/Foundation")
local LuauPolyfill = require("@pkg/LuauPolyfill")
local React = require("@pkg/React")
local ReactSignals = require("@pkg/ReactCharm")

local AboutView = require("@root/About/AboutView")
local FeedbackDialog = require("@root/Feedback/FeedbackDialog")
local NavigationContext = require("@root/Navigation/NavigationContext")
local StoryActionsContext = require("@root/Storybook/StoryActionsContext")
local UserSettingsStore = require("@root/UserSettings/UserSettingsStore")

local Object = LuauPolyfill.Object

local Dialog = Foundation.Dialog
local Divider = Foundation.Divider
local Dropdown = Foundation.Dropdown
local IconButton = Foundation.IconButton
local IconName = Foundation.Enums.IconName
local IconVariant = Foundation.Enums.IconVariant
local InputSize = Foundation.Enums.InputSize
local Menu = Foundation.Menu
local Orientation = Foundation.Enums.Orientation
local PopoverAlign = Foundation.Enums.PopoverAlign
local PopoverSide = Foundation.Enums.PopoverSide
local Tooltip = Foundation.Tooltip
local View = Foundation.View
local ScrollView = Foundation.ScrollView
local withCommonProps = Foundation.Utility.withCommonProps

local useTokens = Foundation.Hooks.useTokens

local e = React.createElement
local useCallback = React.useCallback
local useRef = React.useRef
local useState = React.useState

local useSignalState = ReactSignals.useSignalState

type ItemId = number | string

type TopbarProps = {} & Foundation.CommonProps

local function Topbar(props: TopbarProps)
	local helpButtonRef = useRef(nil :: GuiObject?)

	local tokens = useTokens()

	local isAboutMenuOpen, setIsAboutMenuOpen = useState(false)
	local isHelpMenuOpen, setIsHelpMenuOpen = useState(false)
	local isFeedbackDialogOpen, setIsFeedbackDialogOpen = useState(false)

	local navigation = NavigationContext.use()
	local storyActions = StoryActionsContext.useStoryActions()
	local userSettingsStore = useSignalState(UserSettingsStore.get)
	local userSettings = useSignalState(userSettingsStore.getStorage)

	local onThemeChanged = useCallback(function(itemId: ItemId)
		userSettingsStore.setStorage(function(currentValue)
			return Object.assign({}, currentValue, {
				theme = itemId,
			})
		end)
	end, { userSettingsStore.setStorage })

	return e(
		View,
		withCommonProps(props, {
			tag = "align-y-center bg-surface-0 flex-x-between padding-right-medium padding-y-medium row gap-large size-full-1400",
		}),
		{
			Actions = e(ScrollView, {
				LayoutOrder = 1,
				tag = "size-full shrink",
				scroll = {
					ScrollingDirection = Enum.ScrollingDirection.X,
					AutomaticCanvasSize = Enum.AutomaticSize.X,
					CanvasSize = UDim2.new(0, 0),
				},
				layout = {
					FillDirection = Enum.FillDirection.Horizontal,
					Padding = UDim.new(0, tokens.Padding.Medium),
				},
			}, {
				Zoom = e(View, {
					LayoutOrder = 1,
					tag = "auto-x row size-0-full",
				}, {
					ZoomIn = e(Tooltip, {
						LayoutOrder = 1,
						align = PopoverAlign.Center,
						side = PopoverSide.Bottom,
						title = "Zoom In",
					}, {
						Trigger = e(IconButton, {
							icon = IconName.MagnifyingGlassPlus,
							isDisabled = storyActions.isDisabled,
							onActivated = storyActions.zoom.zoomIn,
							size = InputSize.Small,
						}),
					}),

					ZoomOut = e(Tooltip, {
						LayoutOrder = 2,
						align = PopoverAlign.Center,
						side = PopoverSide.Bottom,
						title = "Zoom Out",
					}, {
						Trigger = e(IconButton, {
							icon = IconName.MagnifyingGlassMinus,
							isDisabled = storyActions.isDisabled,
							onActivated = storyActions.zoom.zoomOut,
							size = InputSize.Small,
						}),
					}),
				}),

				ZoomDivider = e(Divider, {
					LayoutOrder = 2,
					orientation = Orientation.Vertical,
				}),

				Views = e(View, {
					LayoutOrder = 3,
					tag = "auto-x row size-0-full",
				}, {
					Source = e(Tooltip, {
						LayoutOrder = 1,
						align = PopoverAlign.Center,
						side = PopoverSide.Bottom,
						title = "View Source Code",
					}, {
						Trigger = e(IconButton, {
							icon = IconName.Code,
							isDisabled = storyActions.isDisabled,
							onActivated = storyActions.viewCode,
							size = InputSize.Small,
						}),
					}),

					Viewport = e(Tooltip, {
						LayoutOrder = 2,
						align = PopoverAlign.Center,
						side = PopoverSide.Bottom,
						title = "Preview in Viewport",
					}, {
						Trigger = e(IconButton, {
							icon = IconName.Eye,
							isDisabled = storyActions.isDisabled,
							onActivated = storyActions.viewportPreview,
							size = InputSize.Small,
						}),
					}),

					Explorer = e(Tooltip, {
						LayoutOrder = 3,
						align = PopoverAlign.Center,
						side = PopoverSide.Bottom,
						title = "View in Explorer",
					}, {
						Trigger = e(IconButton, {
							icon = IconName.MagnifyingGlass,
							isDisabled = storyActions.isDisabled,
							onActivated = storyActions.viewExplorer,
							size = InputSize.Small,
						}),
					}),
				}),

				ViewsDivider = e(Divider, {
					LayoutOrder = 4,
					orientation = Orientation.Vertical,
				}),

				Theme = e(Dropdown.Root, {
					LayoutOrder = 5,
					items = {
						{ id = "system", text = "System" },
						{ id = "dark", text = "Dark" },
						{ id = "light", text = "Light" },
					},
					label = "",
					onItemChanged = onThemeChanged,
					placeholder = "Theme",
					size = InputSize.Small,
					value = userSettings.theme,
					width = UDim.new(0, 160),
				}),
			}),

			Support = e(View, {
				LayoutOrder = 2,
				tag = "auto-x row size-0-full",
			}, {
				Help = e(Tooltip, {
					LayoutOrder = 1,
					align = PopoverAlign.Center,
					side = PopoverSide.Bottom,
					title = "Help",
				}, {
					Trigger = e(IconButton, {
						icon = IconName.CircleQuestion,
						onActivated = function()
							setIsHelpMenuOpen(true)
						end,
						ref = helpButtonRef,
						size = InputSize.Small,
					}),
				}),

				Settings = e(Tooltip, {
					LayoutOrder = 2,
					align = PopoverAlign.End,
					side = PopoverSide.Bottom,
					title = "Settings",
				}, {
					Trigger = e(IconButton, {
						icon = {
							name = IconName.Gear,
							variant = if navigation.currentScreen == "Settings"
								then IconVariant.Filled
								else IconVariant.Regular,
						},
						onActivated = function()
							if navigation.currentScreen ~= "Settings" then
								navigation.navigateTo("Settings")
							else
								navigation.navigateTo("Home")
							end
						end,
						size = InputSize.Small,
					}),
				}),
			}),

			AboutMenu = isAboutMenuOpen and e(Dialog.Root, {
				disablePortal = false,
				hasBackdrop = true,
				onClose = function()
					setIsAboutMenuOpen(false)
				end,
			}, {
				Title = e(Dialog.Title, {
					text = "About",
				}),

				Content = e(Dialog.Content, nil, {
					View = e(AboutView),
				}),
			}),

			FeedbackDialog = if isFeedbackDialogOpen
				then React.createElement(FeedbackDialog, {
					onClose = function()
						setIsFeedbackDialogOpen(false)
					end,
				})
				else nil,

			HelpMenu = e(Menu, {
				anchorRef = helpButtonRef,
				isOpen = isHelpMenuOpen,
				items = {
					{
						id = "About",
						text = "About",
						onActivated = function()
							setIsAboutMenuOpen(true)
							setIsHelpMenuOpen(false)
						end,
					},
					{
						id = "Feedback",
						text = "Send feedback",
						onActivated = function()
							setIsFeedbackDialogOpen(true)
							setIsHelpMenuOpen(false)
						end,
					},
					{
						id = "Logs",
						text = "Logs",
						onActivated = function()
							if navigation.currentScreen ~= "Settings" then
								navigation.navigateTo("Logs")
							else
								navigation.navigateTo("Home")
							end
							setIsHelpMenuOpen(false)
						end,
					},
				},
				onPressedOutside = function()
					setIsHelpMenuOpen(function(currentValue)
						return not currentValue
					end)
				end,
			}),
		}
	)
end

return React.memo(Topbar)


local function tryGetService(serviceName: string): Instance?
	local service

	pcall(function()
		service = game:GetService(serviceName)
	end)

	if service then
		return service
	end

	-- Some services cannot be retrieved by GetService but still exist in the DM
	-- and can be retrieved by name.
	pcall(function()
		service = game:FindFirstChild(serviceName)
	end)

	return service
end

return tryGetService


local Charm = require("@pkg/Charm")
local t = require("@pkg/t")

local createPluginSettingsStore = require("@root/Plugin/createPluginSettingsStore")

export type LocalStorageStore = {
	lastOpenedStoryPath: string?,
	pinnedInstancePaths: { string }?,
	wasUserPromptedForTelemetry: boolean,
}

local defaultValue: LocalStorageStore = {
	wasUserPromptedForTelemetry = false,
}

local validate = t.interface({
	lastOpenedStoryPath = t.optional(t.string),
	pinnedInstancePaths = t.optional(t.array(t.string)),
	wasUserPromptedForTelemetry = t.boolean,
})

-- Initialize the store eagerly at module level so createPluginSettingsStore's
-- internal Charm.effect is a top-level effect, not one nested inside a computed.
local get = Charm.signal(createPluginSettingsStore("FlipbookLocalStorage", defaultValue, validate))

return {
	get = get,
}


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local ReactCharm = require("@pkg/ReactCharm")
local Storyteller = require("@pkg/Storyteller")

local NavigationContext = require("@root/Navigation/NavigationContext")
local ResizablePanel = require("@root/Panels/ResizablePanel")
local Screen = require("@root/Navigation/Screen")
local Sidebar = require("@root/Panels/Sidebar")
local StoryActionsContext = require("@root/Storybook/StoryActionsContext")
local TelemetryOptOutDialog = require("@root/Telemetry/TelemetryOptOutDialog")
local Topbar = require("@root/Panels/Topbar")
local UserSettingsStore = require("@root/UserSettings/UserSettingsStore")
local constants = require("@root/constants")
local createLoadedStorybook = require("@root/Storybook/createLoadedStorybook")
local fireEventAsync = require("@root/Telemetry/fireEventAsync")
local nextLayoutOrder = require("@root/Common/nextLayoutOrder")

local useSignalState = ReactCharm.useSignalState

type LoadedStorybook = Storyteller.LoadedStorybook
type UnavailableStorybook = Storyteller.UnavailableStorybook

local defaultStorybook = createLoadedStorybook()

local function App()
	local userSettingsStore = useSignalState(UserSettingsStore.get)
	local userSettings = useSignalState(userSettingsStore.getStorage)

	local storyModule: ModuleScript?, setStoryModule = React.useState(nil :: ModuleScript?)
	local storybook, setStorybook = React.useState(nil :: LoadedStorybook?)
	local unavailableStorybook: UnavailableStorybook?, setUnavailableStorybook =
		React.useState(nil :: UnavailableStorybook?)
	local navigation = NavigationContext.use()

	local onStoryChanged = React.useCallback(function(newStoryModule: ModuleScript?, newStorybook: LoadedStorybook?)
		navigation.navigateTo("Home")

		setUnavailableStorybook(nil)

		if newStoryModule and not newStorybook then
			newStorybook = defaultStorybook
		end

		task.spawn(function()
			fireEventAsync({
				eventName = "StoryOpened",
			})
		end)

		setStoryModule(newStoryModule)
		setStorybook(newStorybook)
	end, { navigation.navigateTo } :: { unknown })

	local onShowErrorPage = React.useCallback(function(newUnavailableStorybook: UnavailableStorybook)
		setStoryModule(nil)
		setStorybook(nil)
		setUnavailableStorybook(newUnavailableStorybook)
	end, {})

	return React.createElement(Foundation.View, {
		tag = "size-full row align-y-center flex-between",
	}, {
		StoryActionsProvider = React.createElement(StoryActionsContext.Provider, {
			story = storyModule,
		}, {
			SidebarWrapper = React.createElement(ResizablePanel, {
				LayoutOrder = nextLayoutOrder(),
				initialSize = UDim2.new(0, userSettings.sidebarWidth, 1, 0),
				dragHandles = { "Right" :: "Right" },
				minSize = Vector2.new(constants.SIDEBAR_MIN_WIDTH, 0),
				maxSize = Vector2.new(constants.SIDEBAR_MAX_WIDTH, math.huge),
			}, {
				Sidebar = React.createElement(Sidebar, {
					onStoryChanged = onStoryChanged,
					onShowErrorPage = onShowErrorPage,
				}),
			}),

			MainWrapper = React.createElement(Foundation.View, {
				tag = "size-full col shrink",
				LayoutOrder = nextLayoutOrder(),
			}, {
				Topbar = React.createElement(Topbar, {
					LayoutOrder = nextLayoutOrder(),
				}),

				ScreenWrapper = React.createElement(Foundation.View, {
					LayoutOrder = nextLayoutOrder(),
					tag = "size-full shrink",
				}, {
					Screen = React.createElement(Screen, {
						story = storyModule,
						storybook = storybook,
						unavailableStorybook = unavailableStorybook,
					}),
				}),
			}),
		}),

		TelemetryOptOutDialog = React.createElement(TelemetryOptOutDialog),
	})
end

return App


local React = require("@pkg/React")

local PluginApp = require("./PluginApp")

return {
	summary = "The main component that handles the entire plugin",
	controls = {},
	story = function()
		return React.createElement(PluginApp)
	end,
}


local Charm = require("@pkg/Charm")

local CharmTypes = require("@root/Common/CharmTypes")

export type PluginStore = {
	getPlugin: CharmTypes.Getter<Plugin?>,
	setPlugin: CharmTypes.Setter<Plugin?>,
}

--[[
	This is a basic store to keep track of the Plugin instance.

	A quirk of the `plugin` keyword is that it's only defined in top-level
	scripts, like `init.server.luau`. It must be passed along by reference after
	that point, as attempting to use `plugin` in a ModuleScript will result in
	nil value. To work around this, we just toss the plugin instance into this
	store to share it around other parts of the app
]]
local function createPluginStore(): PluginStore
	local getPlugin, setPlugin = Charm.signal(nil :: Plugin?)

	return {
		getPlugin = getPlugin,
		setPlugin = setPlugin,
	}
end

return createPluginStore


local Charm = require("@pkg/Charm")

local createPluginStore = require("@self/createPluginStore")

return {
	get = Charm.computed(createPluginStore),
}


local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

local ContextProviders = require("@root/Common/ContextProviders")
local PluginApp = require("@root/Plugin/PluginApp")
local fireEventAsync = require("@root/Telemetry/fireEventAsync")
local logger = require("@root/logger")

local function stopwatch(callback: () -> ()): string
	local start = os.clock()
	callback()
	return ("%.2fms"):format((os.clock() - start) * 1000)
end

local function createFlipbookPlugin(plugin: Plugin, widget: DockWidgetPluginGui, button: PluginToolbarButton?)
	local connections: { RBXScriptConnection } = {}
	local root = ReactRoblox.createRoot(widget)

	-- FIXME: ContextProviders having an error won't fail tests. We really need
	-- a smoketest for this file
	local app = React.createElement(ContextProviders, {
		plugin = plugin,
		overlayGui = widget :: GuiBase2d,
	}, {
		PluginApp = React.createElement(PluginApp),
	})

	local function unmount()
		logger:Info("unmounting app...")
		local elapsedMs = stopwatch(function()
			root:unmount()
		end)
		logger:Info(`unmounted app in {elapsedMs}`)

		task.spawn(function()
			fireEventAsync({
				eventName = "AppClosed",
			})
		end)
	end

	local function mount()
		logger:Info("mounting app...")
		local elapsedMs = stopwatch(function()
			root:render(app)
		end)
		logger:Info(`mounted app in {elapsedMs}`)

		task.spawn(function()
			fireEventAsync({
				eventName = "AppOpened",
			})
		end)
	end

	if button ~= nil then
		table.insert(
			connections,
			button.Click:Connect(function()
				widget.Enabled = not widget.Enabled
			end)
		)

		table.insert(
			connections,
			widget:GetPropertyChangedSignal("Enabled"):Connect(function()
				button:SetActive(widget.Enabled)
			end)
		)
	end

	table.insert(
		connections,
		widget:GetPropertyChangedSignal("Enabled"):Connect(function()
			if widget.Enabled then
				mount()
			else
				unmount()
			end
		end)
	)

	if widget.Enabled then
		mount()
	end

	local function destroy()
		logger:Info("destroying app...")
		local elapsedMs = stopwatch(function()
			unmount()

			for _, connection in connections do
				connection:Disconnect()
			end
		end)
		logger:Info(`destroyed app in {elapsedMs}`)
	end

	return {
		mount = mount,
		unmount = unmount,
		destroy = destroy,
	}
end

return createFlipbookPlugin


local HttpService = game:GetService("HttpService")

local Charm = require("@pkg/Charm")

local CharmTypes = require("@root/Common/CharmTypes")
local PluginStore = require("@root/Plugin/PluginStore")
local logger = require("@root/logger")

export type PluginSettingsStore<T> = {
	getStorage: CharmTypes.Getter<T>,
	setStorage: CharmTypes.Setter<T>,

	isSettingDefault: (settingName: string) -> boolean,

	getIsLoading: CharmTypes.Getter<boolean>,
	getErr: CharmTypes.Getter<string?>,

	waitForLoaded: () -> (),
}

--[[
	Creates a store to manage plugin settings.

	Settings are persisted using Plugin:SetSetting and Plugin:GetSetting, and are
	stored as JSON strings. The settings are loaded once when the store is first
	created, and any changes to the settings are automatically saved.
]]
local function createPluginSettingsStore<T>(
	storageKey: string,
	defaultValue: T,
	validate: (value: any) -> (boolean, string)
): PluginSettingsStore<T>
	local getStorage, setStorageSignal = Charm.signal(defaultValue)
	local getIsLoading, setIsLoading = Charm.signal(true)
	local getErr, setErr = Charm.signal(nil :: string?)
	local getIsInitialized, setIsInitialized = Charm.signal(false)
	local prevStorage: T?

	local function readPluginSettingsAsync(plugin: Plugin): T?
		logger:Info(`reading plugin settings for {storageKey} from disk...`)

		local data
		local success, err = pcall(function()
			data = plugin:GetSetting(storageKey)
		end)

		if success then
			logger:Info(`read raw plugin settings for {storageKey}:`, data)
		else
			logger:Warn(`failed to read plugin settings for {storageKey}: {err}`)
			setErr(err)
			return nil
		end

		if typeof(data) == "string" then
			logger:Info(`deserializing plugin settings for {storageKey} to object...`)

			local json
			success, err = pcall(function()
				json = HttpService:JSONDecode(data)
			end)

			if not success then
				logger:Warn(`failed to load plugin settings for {storageKey}: {err}`)
				setErr(err)
				return nil
			end

			success, err = validate(json)

			if not success then
				logger:Warn(`plugin settings are malformed for {storageKey}: {err}`)
				setErr(err)
				return nil
			end

			logger:Info(`got plugin settings for {storageKey}:`, json)

			return json
		end

		logger:Info("no plugin settings to read")

		return nil
	end

	local function writePluginSettingsAsync(plugin: Plugin, storage: T)
		logger:Info("writing plugin settings to disk...")
		local data
		local success, err = pcall(function()
			data = HttpService:JSONEncode(storage)
		end)

		if success then
			logger:Info(`parsed storage value into JSON: {data}`)
		else
			logger:Warn(`failed to parse storage value into JSON: {err}`)
			setErr(err)
			return
		end

		if data then
			success, err = pcall(function()
				plugin:SetSetting(storageKey, data)
			end)

			if success then
				logger:Info(`wrote plugin settings to disk: {data}`)
			else
				logger:Warn(`failed to write plugin settings to disk: {err}`)
			end
		end
	end

	local function setStorageInternal(newValue: CharmTypes.Update<T>): T
		setStorageSignal(newValue)

		local newStorage = Charm.untracked(getStorage)

		if newStorage ~= prevStorage then
			local plugin = Charm.untracked(function()
				return PluginStore.get().getPlugin()
			end)

			if plugin then
				task.spawn(function()
					writePluginSettingsAsync(plugin, newStorage)
				end)
			end
		else
			logger:Debug("skipping disk write since storage value did not change")
		end

		prevStorage = newStorage

		return newStorage
	end

	local function setStorage(newValue: CharmTypes.Update<T>): T
		assert(
			not Charm.untracked(getIsLoading),
			"attempt to set storage while PluginSettingsStore is still loading. Call `PluginSettingsStore.waitForLoaded()` before setting storage"
		)

		return setStorageInternal(newValue)
	end

	local function waitForLoaded()
		while getIsLoading() do
			task.wait()
		end
	end

	local function loadInitialSettings(plugin: Plugin)
		if not getIsLoading() then
			logger:Warn("attempt to load initial plugin settings after loading has already finished")
			return
		end

		if getIsInitialized() then
			logger:Warn("attempt to load initial settings after they've already been initialized")
			return
		end

		logger:Debug("loading initial plugin settings")

		task.spawn(function()
			local data = readPluginSettingsAsync(plugin)

			if data then
				setStorageInternal(data)
				logger:Debug("finished loading plugin settings")
			else
				logger:Debug("no data on disk")
			end

			setIsLoading(false)
			setIsInitialized(true)
		end)
	end

	local function isSettingDefault(settingName: string): boolean
		local storage = Charm.untracked(getStorage)
		if typeof(defaultValue) ~= "table" or typeof(storage) ~= "table" then
			return true
		end

		local settingDefaultValue = defaultValue[settingName]
		local storedValue = storage[settingName]

		return storedValue == nil or storedValue == settingDefaultValue
	end

	Charm.effect(function()
		local plugin = PluginStore.get().getPlugin()

		if plugin and not getIsInitialized() then
			loadInitialSettings(plugin)
		end
	end)

	return {
		getIsLoading = getIsLoading,
		getErr = getErr,
		getStorage = getStorage,
		setStorage = setStorage,
		isSettingDefault = isSettingDefault,
		waitForLoaded = waitForLoaded,
	}
end

return createPluginSettingsStore


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")

local nextLayoutOrder = require("@root/Common/nextLayoutOrder")

local useTokens = Foundation.Hooks.useTokens

local e = React.createElement

local function NoStorySelected()
	local tokens = useTokens()

	return e(Foundation.View, {
		tag = "size-full col bg-surface-200 gap-medium align-y-center align-x-center",
	}, {
		Icon = e(Foundation.Icon, {
			name = Foundation.Enums.IconName.SquareBooks,
			style = tokens.Color.ActionStandard.Foreground,
			size = Foundation.Enums.IconSize.Large,
			LayoutOrder = nextLayoutOrder(),
		}),

		Message = e(Foundation.Text, {
			tag = "auto-xy text-heading-medium",
			Text = "Select a story to preview it",
			LayoutOrder = nextLayoutOrder(),
		}),
	})
end

return NoStorySelected


local React = require("@pkg/React")

local NoStorySelected = require("./NoStorySelected")

return {
	story = function()
		return React.createElement(NoStorySelected)
	end,
}


local Charm = require("@pkg/Charm")
local Sift = require("@pkg/Sift")

local LocalStorageStore = require("@root/Plugin/LocalStorageStore")
local fireEventAsync = require("@root/Telemetry/fireEventAsync")
local getInstanceFromPath = require("@root/Common/getInstanceFromPath")
local getInstancePath = require("@root/Common/getInstancePath")

export type PinnedInstance = {
	path: string,
	instance: Instance?,
}

export type PinnedInstanceStore = {
	getPinnedInstances: () -> { PinnedInstance },
	isPinned: (instance: Instance) -> boolean,
	pin: (instance: Instance) -> (),
	unpin: (instance: Instance) -> (),
	waitForLoaded: () -> (),
}

local function createPinnedInstanceStore(): PinnedInstanceStore
	local localStorageStore = LocalStorageStore.get()

	local getPinnedPaths = Charm.computed(function()
		local storage = localStorageStore.getStorage()
		return storage.pinnedInstancePaths or {}
	end)

	local function setPinnedPaths(update: (prev: { string }) -> { string })
		localStorageStore.setStorage(function(prev)
			return Sift.Dictionary.join(prev, {
				pinnedInstancePaths = update(prev.pinnedInstancePaths or {}),
			})
		end)
	end

	local getPinnedInstances = Charm.computed(function()
		local pinnedPaths = getPinnedPaths()

		return Sift.List.map(pinnedPaths, function(pinnedPath)
			return {
				path = pinnedPath,
				instance = getInstanceFromPath(pinnedPath),
			} :: PinnedInstance
		end)
	end)

	local function pin(instance: Instance)
		task.spawn(function()
			fireEventAsync({
				eventName = "NodePinned",
			})
		end)

		setPinnedPaths(function(prev)
			return Sift.List.append(prev, getInstancePath(instance))
		end)
	end

	local function unpin(instance: Instance)
		task.spawn(function()
			fireEventAsync({
				eventName = "NodeUnpinned",
			})
		end)

		setPinnedPaths(function(prev)
			return Sift.List.removeValue(prev, getInstancePath(instance))
		end)
	end

	local function isPinned(instance: Instance)
		return Sift.List.has(getPinnedPaths(), getInstancePath(instance))
	end

	local function waitForLoaded()
		return localStorageStore.waitForLoaded()
	end

	return {
		pin = pin,
		unpin = unpin,
		isPinned = isPinned,
		getPinnedInstances = getPinnedInstances,
		waitForLoaded = waitForLoaded,
	}
end

return createPinnedInstanceStore


local Charm = require("@pkg/Charm")

local createPinnedInstanceStore = require("@self/createPinnedInstanceStore")

return {
	get = Charm.computed(createPinnedInstanceStore),
}