From f2eb567a9f847a17f7d0615b35b8e42fc4e6bd3c Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Sat, 26 Oct 2024 20:05:02 -0700
Subject: [PATCH 01/79] Switch to Storyteller for all story handling

Buggy, but it's progress
---
 .vscode/settings.json                     |   3 -
 example/ReactCounter.story.luau           |   4 +
 src/Plugin/PluginApp.luau                 |   4 +-
 src/Storybook/StoryMeta.story.luau        |   4 +
 src/Storybook/StoryPreview.luau           |  25 +++--
 src/Storybook/StoryView.luau              |   4 +-
 src/Storybook/createStoryNodes.luau       |   7 +-
 src/Storybook/isStoryModule.luau          |  10 --
 src/Storybook/isStoryModule.spec.luau     |  25 -----
 src/Storybook/isStorybookModule.luau      |  11 --
 src/Storybook/isStorybookModule.spec.luau |  45 --------
 src/Storybook/loadStoryModule.luau        |  67 ------------
 src/Storybook/mountStory.luau             |  66 ------------
 src/Storybook/types.luau                  | 121 ++--------------------
 src/Storybook/useStory.luau               |  43 --------
 src/Storybook/useStorybooks.luau          |  65 ------------
 src/init.storybook.luau                   |   6 +-
 src/stories.spec.luau                     |  61 ++++++-----
 wally.toml                                |   3 +-
 19 files changed, 74 insertions(+), 500 deletions(-)
 delete mode 100644 src/Storybook/isStoryModule.luau
 delete mode 100644 src/Storybook/isStoryModule.spec.luau
 delete mode 100644 src/Storybook/isStorybookModule.luau
 delete mode 100644 src/Storybook/isStorybookModule.spec.luau
 delete mode 100644 src/Storybook/loadStoryModule.luau
 delete mode 100644 src/Storybook/mountStory.luau
 delete mode 100644 src/Storybook/useStory.luau
 delete mode 100644 src/Storybook/useStorybooks.luau

diff --git a/.vscode/settings.json b/.vscode/settings.json
index c82dc676..3d62bfd0 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -9,8 +9,5 @@
     "@pkg": "./Packages",
     "@root": "./src",
     "@lune/": "~/.lune/.typedefs/0.8.3/"
-  },
-  "files.associations": {
-    "*.luau": "lua"
   }
 }
\ No newline at end of file
diff --git a/example/ReactCounter.story.luau b/example/ReactCounter.story.luau
index 7fbe9dd7..ceace6fd 100644
--- a/example/ReactCounter.story.luau
+++ b/example/ReactCounter.story.luau
@@ -16,6 +16,10 @@ return {
 	controls = controls,
 	react = React,
 	reactRoblox = ReactRoblox,
+	packages = {
+		React = React,
+		ReactRoblox = ReactRoblox,
+	},
 	story = function(props: Props)
 		return React.createElement(ReactCounter, {
 			increment = props.controls.increment,
diff --git a/src/Plugin/PluginApp.luau b/src/Plugin/PluginApp.luau
index aa345cba..c494317d 100644
--- a/src/Plugin/PluginApp.luau
+++ b/src/Plugin/PluginApp.luau
@@ -1,5 +1,6 @@
 local ModuleLoader = require("@pkg/ModuleLoader")
 local React = require("@pkg/React")
+local Storyteller = require("@pkg/Storyteller")
 
 local NavigationContext = require("@root/Navigation/NavigationContext")
 local ResizablePanel = require("@root/Panels/ResizablePanel")
@@ -9,7 +10,6 @@ local Sidebar = require("@root/Panels/Sidebar")
 local Topbar = require("@root/Panels/Topbar")
 local constants = require("@root/constants")
 local nextLayoutOrder = require("@root/Common/nextLayoutOrder")
-local useStorybooks = require("@root/Storybook/useStorybooks")
 local useTheme = require("@root/Common/useTheme")
 
 local TOPBAR_HEIGHT_PX = 32
@@ -21,7 +21,7 @@ export type Props = {
 local function App(props: Props)
 	local theme = useTheme()
 	local settingsContext = SettingsContext.use()
-	local storybooks = useStorybooks(game, props.loader)
+	local storybooks = Storyteller.useStorybooks(game, props.loader)
 	local story: ModuleScript?, setStory = React.useState(nil :: ModuleScript?)
 	local storybook, selectStorybook = React.useState(nil :: ModuleScript?)
 	local initialSidebarWidth = settingsContext.getSetting("sidebarWidth")
diff --git a/src/Storybook/StoryMeta.story.luau b/src/Storybook/StoryMeta.story.luau
index cbd5566c..54d28fa3 100644
--- a/src/Storybook/StoryMeta.story.luau
+++ b/src/Storybook/StoryMeta.story.luau
@@ -12,6 +12,10 @@ return {
 			story = {
 				name = "Story",
 				summary = "Story summary",
+				source = Instance.new("ModuleScript"),
+				storybook = {
+					storyRoots = {},
+				},
 			},
 		}),
 	}),
diff --git a/src/Storybook/StoryPreview.luau b/src/Storybook/StoryPreview.luau
index 34d9551f..3d196c19 100644
--- a/src/Storybook/StoryPreview.luau
+++ b/src/Storybook/StoryPreview.luau
@@ -2,10 +2,11 @@ local CoreGui = game:GetService("CoreGui")
 
 local React = require("@pkg/React")
 local ReactRoblox = require("@pkg/ReactRoblox")
-local ScrollingFrame = require("@root/Common/ScrollingFrame")
 local Sift = require("@pkg/Sift")
+local Storyteller = require("@pkg/Storyteller")
+
+local ScrollingFrame = require("@root/Common/ScrollingFrame")
 local StoryError = require("@root/Storybook/StoryError")
-local mountStory = require("@root/Storybook/mountStory")
 local types = require("@root/Storybook/types")
 
 local e = React.createElement
@@ -20,6 +21,7 @@ export type Props = {
 	story: types.Story,
 	ref: any,
 	controls: { [string]: any },
+	changedControls: { [string]: any },
 	storyModule: ModuleScript,
 }
 
@@ -34,20 +36,25 @@ local StoryPreview = React.forwardRef(function(providedProps: Props, ref: any)
 		setErr(nil)
 	end, { props.story, ref })
 
-	React.useEffect(function()
+	React.useEffect(function(): (() -> ())?
 		if props.story and ref.current then
+			local renderer = Storyteller.createRendererForStory(props.story)
+			local lifecycle
+
 			local success, result = xpcall(function()
-				return mountStory(props.story, props.controls, ref.current)
+				lifecycle = Storyteller.render(renderer, ref.current, props.story, props.controls)
 			end, debug.traceback)
 
-			if success then
-				return result
-			else
+			if not success then
 				setErr(result)
-				return nil
 			end
-		end
 
+			if lifecycle then
+				return function()
+					lifecycle.unmount()
+				end
+			end
+		end
 		return nil
 	end, { props.story, props.controls, props.isMountedInViewport, ref.current } :: { unknown })
 
diff --git a/src/Storybook/StoryView.luau b/src/Storybook/StoryView.luau
index 2e79c932..27280785 100644
--- a/src/Storybook/StoryView.luau
+++ b/src/Storybook/StoryView.luau
@@ -3,6 +3,7 @@ local Selection = game:GetService("Selection")
 local ModuleLoader = require("@pkg/ModuleLoader")
 local React = require("@pkg/React")
 local Sift = require("@pkg/Sift")
+local Storyteller = require("@pkg/Storyteller")
 
 local PluginContext = require("@root/Plugin/PluginContext")
 local ResizablePanel = require("@root/Panels/ResizablePanel")
@@ -15,7 +16,6 @@ local StoryPreview = require("@root/Storybook/StoryPreview")
 local StoryViewNavbar = require("@root/Storybook/StoryViewNavbar")
 local constants = require("@root/constants")
 local types = require("@root/Storybook/types")
-local useStory = require("@root/Storybook/useStory")
 local useTheme = require("@root/Common/useTheme")
 local useZoom = require("@root/Common/useZoom")
 
@@ -30,7 +30,7 @@ type Props = {
 local function StoryView(props: Props)
 	local theme = useTheme()
 	local settingsContext = SettingsContext.use()
-	local story, storyErr = useStory(props.story, props.storybook, props.loader)
+	local story, storyErr = Storyteller.useStory(props.story, props.storybook, props.loader)
 	local zoom = useZoom(props.story)
 	local plugin = React.useContext(PluginContext.Context)
 	local extraControls, setExtraControls = React.useState({})
diff --git a/src/Storybook/createStoryNodes.luau b/src/Storybook/createStoryNodes.luau
index bf7b9ce8..c71d7cd6 100644
--- a/src/Storybook/createStoryNodes.luau
+++ b/src/Storybook/createStoryNodes.luau
@@ -1,5 +1,6 @@
+local Storyteller = require("@pkg/Storyteller")
+
 local explorerTypes = require("@root/Explorer/types")
-local isStoryModule = require("@root/Storybook/isStoryModule")
 local storybookTypes = require("@root/Storybook/types")
 
 type Storybook = storybookTypes.Storybook
@@ -7,7 +8,7 @@ type ComponentTreeNode = explorerTypes.ComponentTreeNode
 
 local function hasStories(instance: Instance): boolean
 	for _, descendant in ipairs(instance:GetDescendants()) do
-		if isStoryModule(descendant) then
+		if Storyteller.isStoryModule(descendant) then
 			return true
 		end
 	end
@@ -16,7 +17,7 @@ end
 
 local function createChildNodes(parent: ComponentTreeNode, instance: Instance, storybook: Storybook)
 	for _, child in ipairs(instance:GetChildren()) do
-		local isStory = isStoryModule(child)
+		local isStory = Storyteller.isStoryModule(child)
 		local isContainer = hasStories(child)
 
 		if isStory or isContainer then
diff --git a/src/Storybook/isStoryModule.luau b/src/Storybook/isStoryModule.luau
deleted file mode 100644
index 963821dc..00000000
--- a/src/Storybook/isStoryModule.luau
+++ /dev/null
@@ -1,10 +0,0 @@
-local constants = require("@root/constants")
-
-local function isStoryModule(instance: Instance)
-	if instance:IsA("ModuleScript") and instance.Name:match(constants.STORY_NAME_PATTERN) then
-		return true
-	end
-	return false
-end
-
-return isStoryModule
diff --git a/src/Storybook/isStoryModule.spec.luau b/src/Storybook/isStoryModule.spec.luau
deleted file mode 100644
index 8ebc1b74..00000000
--- a/src/Storybook/isStoryModule.spec.luau
+++ /dev/null
@@ -1,25 +0,0 @@
-local JestGlobals = require("@pkg/JestGlobals")
-local isStoryModule = require("./isStoryModule")
-
-local expect = JestGlobals.expect
-local test = JestGlobals.test
-
-test("return `true` for a ModuleScript with .story in the name", function()
-	local module = Instance.new("ModuleScript")
-	module.Name = "Foo.story"
-
-	expect(isStoryModule(module)).toBe(true)
-end)
-
-test("return `false` if the given instance is not a ModuleScript", function()
-	local folder = Instance.new("Folder")
-	folder.Name = "Folder.story"
-
-	expect(isStoryModule(folder)).toBe(false)
-end)
-
-test("return `false` if a ModuleScript does not have .story in the name", function()
-	local module = Instance.new("ModuleScript")
-
-	expect(isStoryModule(module)).toBe(false)
-end)
diff --git a/src/Storybook/isStorybookModule.luau b/src/Storybook/isStorybookModule.luau
deleted file mode 100644
index 974c2954..00000000
--- a/src/Storybook/isStorybookModule.luau
+++ /dev/null
@@ -1,11 +0,0 @@
-local CoreGui = game:GetService("CoreGui")
-
-local constants = require("@root/constants")
-
-local function isStorybookModule(instance: Instance): boolean
-	return instance:IsA("ModuleScript")
-		and instance.Name:match(constants.STORYBOOK_NAME_PATTERN) ~= nil
-		and not instance:IsDescendantOf(CoreGui)
-end
-
-return isStorybookModule
diff --git a/src/Storybook/isStorybookModule.spec.luau b/src/Storybook/isStorybookModule.spec.luau
deleted file mode 100644
index 3a893cdf..00000000
--- a/src/Storybook/isStorybookModule.spec.luau
+++ /dev/null
@@ -1,45 +0,0 @@
-local CoreGui = game:GetService("CoreGui")
-
-local JestGlobals = require("@pkg/JestGlobals")
-local isStorybookModule = require("./isStorybookModule")
-
-local expect = JestGlobals.expect
-local test = JestGlobals.test
-
-test("return true for ModuleScripts with the .storybook extension", function()
-	local storybook = Instance.new("ModuleScript")
-	storybook.Name = "Foo.storybook"
-
-	expect(isStorybookModule(storybook)).toBe(true)
-end)
-
-test("return false for non-ModuleScript instances", function()
-	local storybook = Instance.new("Folder")
-	storybook.Name = "Foo.storybook"
-
-	expect(isStorybookModule(storybook)).toBe(false)
-end)
-
-test("return false if .storybook is not part of the name", function()
-	local storybook = Instance.new("ModuleScript")
-	storybook.Name = "Foo"
-
-	expect(isStorybookModule(storybook)).toBe(false)
-end)
-
-test("return false if .storybook is in the wrong place", function()
-	local storybook = Instance.new("ModuleScript")
-	storybook.Name = "Foo.storybook.extra"
-
-	expect(isStorybookModule(storybook)).toBe(false)
-end)
-
-test("return false for storybooks in CoreGui", function()
-	local storybook = Instance.new("ModuleScript")
-	storybook.Name = "Foo.storybook"
-	storybook.Parent = CoreGui
-
-	expect(isStorybookModule(storybook)).toBe(false)
-
-	storybook:Destroy()
-end)
diff --git a/src/Storybook/loadStoryModule.luau b/src/Storybook/loadStoryModule.luau
deleted file mode 100644
index a7c3368a..00000000
--- a/src/Storybook/loadStoryModule.luau
+++ /dev/null
@@ -1,67 +0,0 @@
-local ModuleLoader = require("@pkg/ModuleLoader")
-local Sift = require("@pkg/Sift")
-
-local types = require("@root/Storybook/types")
-
-local Errors = {
-	MalformedStory = "Story is malformed. Check the source of %q and make sure its properties are correct",
-	Generic = "Failed to load story %q. Error: %s",
-}
-
-local function loadStoryModule(
-	loader: ModuleLoader.ModuleLoader,
-	module: ModuleScript,
-	storybook: types.Storybook
-): (types.Story?, string?)
-	if not module then
-		return nil, "Did not receive a module to load"
-	end
-
-	local success, result = pcall(function()
-		return loader:require(module)
-	end)
-
-	if not success then
-		return nil, Errors.Generic:format(module:GetFullName(), tostring(result))
-	end
-
-	local story: types.Story
-	if typeof(result) == "function" then
-		story = {
-			name = module.Name,
-			story = result,
-		}
-	else
-		local isValid, message = types.StoryMeta(result)
-
-		if isValid then
-			local extraProps = {}
-			if types.ReactStorybook(storybook) then
-				local reactStorybook = storybook :: types.ReactStorybook
-				extraProps = {
-					react = reactStorybook.react,
-					reactRoblox = reactStorybook.reactRoblox,
-				}
-			elseif types.RoactStorybook(storybook) then
-				local roactStorybook = storybook :: types.RoactStorybook
-				extraProps = {
-					roact = roactStorybook.roact,
-				}
-			end
-
-			story = Sift.Dictionary.merge({
-				name = module.Name,
-			}, extraProps, result)
-		else
-			return nil, Errors.Generic:format(module:GetFullName(), message)
-		end
-	end
-
-	if story then
-		return story, nil
-	else
-		return nil, Errors.MalformedStory:format(module:GetFullName())
-	end
-end
-
-return loadStoryModule
diff --git a/src/Storybook/mountStory.luau b/src/Storybook/mountStory.luau
deleted file mode 100644
index 74935ca1..00000000
--- a/src/Storybook/mountStory.luau
+++ /dev/null
@@ -1,66 +0,0 @@
-local types = require("@root/Storybook/types")
-
-local function mountFunctionalStory(story: types.FunctionalStory, props: types.StoryProps, parent: GuiObject)
-	local cleanup = story.story(parent, props)
-
-	return function()
-		if typeof(cleanup) == "function" then
-			cleanup()
-		end
-	end
-end
-
-local function mountRoactStory(story: types.RoactStory, props: types.StoryProps, parent: GuiObject)
-	local Roact = story.roact
-
-	local element
-	if typeof(story.story) == "function" then
-		element = Roact.createElement(story.story, props)
-	else
-		element = story.story
-	end
-
-	local handle = Roact.mount(element, parent, story.name)
-
-	return function()
-		Roact.unmount(handle)
-	end
-end
-
-local function mountReactStory(story: types.ReactStory, props: types.StoryProps, parent: GuiObject)
-	local React = story.react
-	local ReactRoblox = story.reactRoblox
-
-	local root = ReactRoblox.createRoot(parent)
-
-	local element
-	if typeof(story.story) == "function" then
-		element = React.createElement(story.story, props)
-	else
-		element = story.story
-	end
-
-	root:render(element)
-
-	return function()
-		root:unmount()
-	end
-end
-
-local function mountStory(story: types.Story, controls: types.Controls, parent: GuiObject): (() -> ())?
-	local props: types.StoryProps = {
-		controls = controls,
-	}
-
-	if story.roact then
-		return mountRoactStory(story :: types.RoactStory, props, parent)
-	elseif story.react and story.reactRoblox then
-		return mountReactStory(story :: types.ReactStory, props, parent)
-	elseif typeof(story.story) == "function" then
-		return mountFunctionalStory(story :: types.FunctionalStory, props, parent)
-	else
-		return nil
-	end
-end
-
-return mountStory
diff --git a/src/Storybook/types.luau b/src/Storybook/types.luau
index 201d7c43..24080fc7 100644
--- a/src/Storybook/types.luau
+++ b/src/Storybook/types.luau
@@ -1,117 +1,8 @@
-local t = require("@pkg/t")
+local Storyteller = require("@pkg/Storyteller")
 
-local types = {}
+export type Controls = Storyteller.StoryControls
+export type StoryProps = Storyteller.StoryProps<unknown>
+export type Storybook = Storyteller.Storybook
+export type Story = Storyteller.Story<unknown>
 
-export type RoactElement = { [string]: any }
-export type Roact = {
-	createElement: (...any) -> any,
-	mount: (...any) -> any,
-	unmount: (...any) -> (),
-}
-types.Roact = t.interface({
-	createElement = t.callback,
-	mount = t.callback,
-	unmount = t.callback,
-})
-
-type ReactElement = { [string]: any }
-
-type React = {
-	createElement: (...any) -> any,
-}
-types.React = t.interface({
-	createElement = t.callback,
-})
-
-type ReactRoblox = {
-	createRoot: (Instance) -> {
-		render: (any, any) -> (),
-		unmount: (any) -> (),
-	},
-}
-types.ReactRoblox = t.interface({
-	createRoot = t.callback,
-})
-
-export type Controls = {
-	[string]: string | number | boolean,
-}
-types.Controls = t.map(t.string, t.union(t.string, t.number, t.boolean, t.map(t.number, t.any)))
-
-export type StoryProps = {
-	controls: Controls,
-}
-
-export type StorybookMeta = {
-	storyRoots: { Instance },
-	name: string?,
-}
-types.Storybook = t.interface({
-	storyRoots = t.array(t.Instance),
-
-	name = t.optional(t.string),
-	roact = t.optional(types.Roact),
-	react = t.optional(types.React),
-	reactRoblox = t.optional(types.ReactRoblox),
-})
-
-export type RoactStorybook = StorybookMeta & {
-	roact: Roact,
-}
-types.RoactStorybook = t.union(
-	types.Storybook,
-	t.interface({
-		roact = t.optional(types.Roact),
-	})
-)
-
-export type ReactStorybook = StorybookMeta & {
-	react: React,
-	reactRoblox: ReactRoblox,
-}
-types.ReactStorybook = t.union(
-	types.Storybook,
-	t.interface({
-		react = t.optional(types.React),
-		reactRoblox = t.optional(types.ReactRoblox),
-	})
-)
-
-export type Storybook = RoactStorybook | ReactStorybook | StorybookMeta
-
-export type StoryMeta = {
-	name: string,
-	story: any,
-	summary: string?,
-	controls: Controls?,
-	roact: Roact?,
-	react: React?,
-	reactRoblox: ReactRoblox?,
-}
-types.StoryMeta = t.interface({
-	name = t.optional(t.string),
-	summary = t.optional(t.string),
-	controls = t.optional(types.Controls),
-	roact = t.optional(types.Roact),
-	react = t.optional(types.React),
-	reactRoblox = t.optional(types.ReactRoblox),
-})
-
-export type RoactStory = StoryMeta & {
-	story: RoactElement | (props: StoryProps) -> RoactElement,
-	roact: Roact,
-}
-
-export type ReactStory = StoryMeta & {
-	story: ReactElement | (props: StoryProps) -> ReactElement,
-	react: React,
-	reactRoblox: ReactRoblox,
-}
-
-export type FunctionalStory = StoryMeta & {
-	story: (target: GuiObject, props: StoryProps) -> (() -> ())?,
-}
-
-export type Story = FunctionalStory | RoactStory | ReactStory | StoryMeta
-
-return types
+return nil
diff --git a/src/Storybook/useStory.luau b/src/Storybook/useStory.luau
deleted file mode 100644
index 577931b4..00000000
--- a/src/Storybook/useStory.luau
+++ /dev/null
@@ -1,43 +0,0 @@
-local ModuleLoader = require("@pkg/ModuleLoader")
-local React = require("@pkg/React")
-
-local loadStoryModule = require("@root/Storybook/loadStoryModule")
-local types = require("@root/Storybook/types")
-
-local function useStory(
-	module: ModuleScript,
-	storybook: types.Storybook,
-	loader: ModuleLoader.ModuleLoader
-): (types.Story?, string?)
-	local state, setState = React.useState({} :: {
-		story: types.Story?,
-		err: string?,
-	})
-
-	local loadStory = React.useCallback(function()
-		local story, err = loadStoryModule(loader, module, storybook)
-
-		setState({
-			story = story,
-			err = err,
-		})
-	end, { loader, module, storybook } :: { unknown })
-
-	React.useEffect(function()
-		local conn = loader.loadedModuleChanged:Connect(function(other)
-			if other == module then
-				loadStory()
-			end
-		end)
-
-		loadStory()
-
-		return function()
-			conn:Disconnect()
-		end
-	end, { module, loadStory, loader } :: { unknown })
-
-	return state.story, state.err
-end
-
-return useStory
diff --git a/src/Storybook/useStorybooks.luau b/src/Storybook/useStorybooks.luau
deleted file mode 100644
index 98d7833f..00000000
--- a/src/Storybook/useStorybooks.luau
+++ /dev/null
@@ -1,65 +0,0 @@
-local ModuleLoader = require("@pkg/ModuleLoader")
-local React = require("@pkg/React")
-
-local constants = require("@root/constants")
-local isStorybookModule = require("@root/Storybook/isStorybookModule")
-local types = require("@root/Storybook/types")
-local useDescendants = require("@root/Common/useDescendants")
-
-local function hasPermission(instance: Instance)
-	local success = pcall(function()
-		return instance.Name
-	end)
-	return success
-end
-
-local function useStorybooks(parent: Instance, loader: ModuleLoader.ModuleLoader)
-	local storybooks, set = React.useState({})
-	local modules = useDescendants(game, function(descendant)
-		return hasPermission(descendant) and isStorybookModule(descendant)
-	end)
-
-	local loadStorybooks = React.useCallback(function()
-		local newStorybooks = {}
-
-		for _, module in modules do
-			local wasRequired, result = pcall(function()
-				return loader:require(module :: ModuleScript)
-			end)
-
-			if wasRequired then
-				local success, message = types.Storybook(result)
-
-				if success then
-					result.name = if result.name
-						then result.name
-						else module.Name:gsub(constants.STORYBOOK_NAME_PATTERN, "")
-
-					table.insert(newStorybooks, result)
-				else
-					warn(("Failed to load storybook %s. Error: %s"):format(module:GetFullName(), message))
-				end
-			end
-		end
-
-		set(newStorybooks)
-	end, { set, parent, loader, modules } :: { unknown })
-
-	React.useEffect(function()
-		local conn = loader.loadedModuleChanged:Connect(function(other)
-			if types.Storybook(other) then
-				loadStorybooks()
-			end
-		end)
-
-		loadStorybooks()
-
-		return function()
-			conn:Disconnect()
-		end
-	end, { loadStorybooks, loader } :: { unknown })
-
-	return storybooks
-end
-
-return useStorybooks
diff --git a/src/init.storybook.luau b/src/init.storybook.luau
index af5486e7..5210a7e9 100644
--- a/src/init.storybook.luau
+++ b/src/init.storybook.luau
@@ -6,6 +6,8 @@ return {
 	storyRoots = {
 		script.Parent,
 	},
-	react = React,
-	reactRoblox = ReactRoblox,
+	packages = {
+		React = React,
+		ReactRoblox = ReactRoblox,
+	},
 }
diff --git a/src/stories.spec.luau b/src/stories.spec.luau
index 4e7fe90d..6e47e527 100644
--- a/src/stories.spec.luau
+++ b/src/stories.spec.luau
@@ -3,39 +3,38 @@ local CoreGui = game:GetService("CoreGui")
 local JestGlobals = require("@pkg/JestGlobals")
 local React = require("@pkg/React")
 local ReactRoblox = require("@pkg/ReactRoblox")
-local isStoryModule = require("@root/Storybook/isStoryModule")
-local mountStory = require("@root/Storybook/mountStory")
+local Sift = require("@pkg/Sift")
+local Storyteller = require("@pkg/Storyteller")
 
 local expect = JestGlobals.expect
 local test = JestGlobals.test
+local testEach = test.each :: any
 
-local storyModules: { ModuleScript } = {}
-for _, descendant in ipairs(script.Parent:GetDescendants()) do
-	if isStoryModule(descendant) then
-		table.insert(storyModules, descendant)
+testEach({
+	Storyteller.findStoryModules(script.Parent),
+})("mount/unmount %s", function(storyModule)
+	local story = (require :: any)(storyModule)
+
+	if typeof(story) == "function" then
+		story = {
+			name = storyModule.Name,
+			story = story,
+		}
+	end
+
+	if story.packages then
+		story.packages = Sift.Dictionary.join(story.packages or {}, {
+			React = React,
+			ReactRoblox = ReactRoblox,
+		})
 	end
-end
-
-for _, storyModule in storyModules do
-	test(`mount/unmount {storyModule:GetFullName()}`, function()
-		local story = (require :: any)(storyModule)
-		if typeof(story) == "function" then
-			story = {
-				name = storyModule.Name,
-				story = story,
-			}
-		end
-
-		story.react = React
-		story.reactRoblox = ReactRoblox
-
-		local cleanup
-		expect(function()
-			cleanup = mountStory(story, story.controls, CoreGui)
-		end).never.toThrow()
-
-		if cleanup then
-			expect(cleanup).never.toThrow()
-		end
-	end)
-end
+
+	local renderer = Storyteller.createRendererForStory(story)
+	local lifecycle
+
+	expect(function()
+		lifecycle = Storyteller.render(renderer, CoreGui, story, story.controls)
+	end).never.toThrow()
+
+	expect(lifecycle.unmount).never.toThrow()
+end)
diff --git a/wally.toml b/wally.toml
index e6900a4e..c0f1b6be 100644
--- a/wally.toml
+++ b/wally.toml
@@ -8,10 +8,11 @@ exclude = ["*"]
 
 [dependencies]
 ModuleLoader = "flipbook-labs/module-loader@0.6.2"
+Storyteller = "flipbook-labs/storyteller@0.1.1"
 React = "jsdotlua/react@17.0.2"
 ReactRoblox = "jsdotlua/react-roblox@17.0.2"
 ReactSpring = "chriscerie/react-spring@2.0.0"
-Sift = "csqrl/sift@0.0.4"
+Sift = "csqrl/sift@0.0.8"
 t = "osyrisrblx/t@3.0.0"
 
 # dev dependencies

From 45b13ff27b859cf646032ca766a90463182ae43c Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 31 Oct 2024 16:32:04 -0700
Subject: [PATCH 02/79] Bump Storyteller to 0.2.0

---
 wally.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/wally.toml b/wally.toml
index c0f1b6be..69ae4dbf 100644
--- a/wally.toml
+++ b/wally.toml
@@ -8,7 +8,7 @@ exclude = ["*"]
 
 [dependencies]
 ModuleLoader = "flipbook-labs/module-loader@0.6.2"
-Storyteller = "flipbook-labs/storyteller@0.1.1"
+Storyteller = "flipbook-labs/storyteller@0.2.0"
 React = "jsdotlua/react@17.0.2"
 ReactRoblox = "jsdotlua/react-roblox@17.0.2"
 ReactSpring = "chriscerie/react-spring@2.0.0"

From 407301000704147ebf5cf443327c74ad1c67437b Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 31 Oct 2024 16:32:51 -0700
Subject: [PATCH 03/79] Update stories.spec to be more like the e2e tests in
 Storyteller

---
 src/stories.spec.luau | 85 ++++++++++++++++++++++++++++---------------
 1 file changed, 56 insertions(+), 29 deletions(-)

diff --git a/src/stories.spec.luau b/src/stories.spec.luau
index 6e47e527..d500ba68 100644
--- a/src/stories.spec.luau
+++ b/src/stories.spec.luau
@@ -1,40 +1,67 @@
 local CoreGui = game:GetService("CoreGui")
 
 local JestGlobals = require("@pkg/JestGlobals")
+local ModuleLoader = require("@pkg/ModuleLoader")
 local React = require("@pkg/React")
 local ReactRoblox = require("@pkg/ReactRoblox")
 local Sift = require("@pkg/Sift")
 local Storyteller = require("@pkg/Storyteller")
 
+local afterEach = JestGlobals.afterEach
+local beforeEach = JestGlobals.beforeEach
+local describe = JestGlobals.describe
+local describeEach = describe.each :: any
 local expect = JestGlobals.expect
 local test = JestGlobals.test
-local testEach = test.each :: any
-
-testEach({
-	Storyteller.findStoryModules(script.Parent),
-})("mount/unmount %s", function(storyModule)
-	local story = (require :: any)(storyModule)
-
-	if typeof(story) == "function" then
-		story = {
-			name = storyModule.Name,
-			story = story,
-		}
-	end
-
-	if story.packages then
-		story.packages = Sift.Dictionary.join(story.packages or {}, {
-			React = React,
-			ReactRoblox = ReactRoblox,
-		})
-	end
-
-	local renderer = Storyteller.createRendererForStory(story)
-	local lifecycle
-
-	expect(function()
-		lifecycle = Storyteller.render(renderer, CoreGui, story, story.controls)
-	end).never.toThrow()
-
-	expect(lifecycle.unmount).never.toThrow()
+
+local container
+
+beforeEach(function()
+	container = Instance.new("Folder")
+	container.Parent = CoreGui
+end)
+
+afterEach(function()
+	container:Destroy()
+end)
+
+describeEach({
+	Storyteller.findStorybookModules(script.Parent),
+})("%s", function(storybookModule)
+	-- FIXME: This is needed to get around a bug with React renders. I'm hoping
+	-- to keep this for now, but in the future this should really be a
+	-- ModuleLoader instance
+	local mockModuleLoader = (
+		{
+			require = function(_self, path)
+				return (require :: any)(path)
+			end,
+		} :: any
+	) :: ModuleLoader.ModuleLoader
+
+	local storybook = Storyteller.loadStorybookModule(mockModuleLoader, storybookModule)
+
+	describeEach({
+		Storyteller.findStoryModulesForStorybook(storybook),
+	})("%s", function(storyModule)
+		test("basic mount/unmount lifecycle", function()
+			local story = Storyteller.loadStoryModule(mockModuleLoader, storyModule, storybook)
+			local renderer = Storyteller.createRendererForStory(story)
+
+			if story.packages then
+				story.packages = Sift.Dictionary.join(story.packages, {
+					React = React,
+					ReactRoblox = ReactRoblox,
+				})
+			end
+
+			local lifecycle = Storyteller.render(renderer, container, story)
+
+			expect(#container:GetChildren()).toBe(1)
+
+			lifecycle.unmount()
+
+			expect(#container:GetChildren()).toBe(0)
+		end)
+	end)
 end)

From a6d84e0d9fddb7fbe38cceca6f52fdd43477335a Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 31 Oct 2024 16:52:07 -0700
Subject: [PATCH 04/79] Storyteller 0.2.1

---
 wally.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/wally.toml b/wally.toml
index 69ae4dbf..9f0246a2 100644
--- a/wally.toml
+++ b/wally.toml
@@ -8,7 +8,7 @@ exclude = ["*"]
 
 [dependencies]
 ModuleLoader = "flipbook-labs/module-loader@0.6.2"
-Storyteller = "flipbook-labs/storyteller@0.2.0"
+Storyteller = "flipbook-labs/storyteller@0.2.1"
 React = "jsdotlua/react@17.0.2"
 ReactRoblox = "jsdotlua/react-roblox@17.0.2"
 ReactSpring = "chriscerie/react-spring@2.0.0"

From 0adbbd23c1e4bfd2018a4755aa5060c38c8cca21 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 31 Oct 2024 17:03:07 -0700
Subject: [PATCH 05/79] Fix analysis errors

---
 foreman.toml                    | 2 +-
 src/Storybook/StoryPreview.luau | 2 +-
 src/Storybook/types.luau        | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/foreman.toml b/foreman.toml
index 2b14f229..53389e03 100644
--- a/foreman.toml
+++ b/foreman.toml
@@ -7,5 +7,5 @@ selene = { source = "Kampfkarren/selene", version = "0.27.1" }
 stylua = { source = "JohnnyMorganz/StyLua", version = "0.20.0" }
 tarmac = { source = "Roblox/tarmac", version = "0.7.0" }
 wally = { source = "UpliftGames/wally", version = "0.3.2" }
-luau-lsp = { source = "JohnnyMorganz/luau-lsp", version = "1.32.3" }
+luau-lsp = { source = "JohnnyMorganz/luau-lsp", version = "1.34.0" }
 wally-package-types = { source = "JohnnyMorganz/wally-package-types", version = "1.3.2" }
diff --git a/src/Storybook/StoryPreview.luau b/src/Storybook/StoryPreview.luau
index 3d196c19..fe2d91b0 100644
--- a/src/Storybook/StoryPreview.luau
+++ b/src/Storybook/StoryPreview.luau
@@ -42,7 +42,7 @@ local StoryPreview = React.forwardRef(function(providedProps: Props, ref: any)
 			local lifecycle
 
 			local success, result = xpcall(function()
-				lifecycle = Storyteller.render(renderer, ref.current, props.story, props.controls)
+				lifecycle = Storyteller.render(renderer, ref.current, props.story)
 			end, debug.traceback)
 
 			if not success then
diff --git a/src/Storybook/types.luau b/src/Storybook/types.luau
index 24080fc7..5f654549 100644
--- a/src/Storybook/types.luau
+++ b/src/Storybook/types.luau
@@ -1,7 +1,7 @@
 local Storyteller = require("@pkg/Storyteller")
 
 export type Controls = Storyteller.StoryControls
-export type StoryProps = Storyteller.StoryProps<unknown>
+export type StoryProps = Storyteller.StoryProps
 export type Storybook = Storyteller.Storybook
 export type Story = Storyteller.Story<unknown>
 

From c640e6dc5ae1aa46fdb8d53691ae00885aa25d69 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Sat, 2 Nov 2024 11:54:29 -0700
Subject: [PATCH 06/79] Fix story names getting cut off

---
 src/Storybook/StoryMeta.luau | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Storybook/StoryMeta.luau b/src/Storybook/StoryMeta.luau
index 42207a0e..f58783bd 100644
--- a/src/Storybook/StoryMeta.luau
+++ b/src/Storybook/StoryMeta.luau
@@ -31,7 +31,7 @@ local function StoryMeta(props: Props)
 			BackgroundTransparency = 1,
 			Font = theme.headerFont,
 			Size = UDim2.fromScale(0, 0),
-			Text = props.story.name:sub(1, #props.story.name - 6),
+			Text = props.story.name,
 			TextColor3 = theme.text,
 			TextSize = theme.headerTextSize,
 		}),

From 39679509cc0810bfa22383e05aab292991f8eb89 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Sat, 2 Nov 2024 13:19:56 -0700
Subject: [PATCH 07/79] Get controls largely working

---
 src/Forms/InputField.luau        |  5 +++--
 src/Storybook/StoryControls.luau |  1 -
 src/Storybook/StoryPreview.luau  | 31 +++++++++++++++++--------------
 src/Storybook/StoryView.luau     | 26 ++++++++------------------
 4 files changed, 28 insertions(+), 35 deletions(-)

diff --git a/src/Forms/InputField.luau b/src/Forms/InputField.luau
index 8ecb47b1..cb71b0fd 100644
--- a/src/Forms/InputField.luau
+++ b/src/Forms/InputField.luau
@@ -14,7 +14,7 @@ export type Props = {
 	layoutOrder: number?,
 	onSubmit: (text: string, isValid: boolean) -> (),
 	onFocus: (() -> ())?,
-	onFocusLost: (() -> ())?,
+	onFocusLost: (text: string, isValid: boolean) -> (),
 	onTextChange: ((new: string, old: string) -> ())?,
 	validate: ((text: string) -> boolean)?,
 	transform: ((newText: string, oldText: string) -> string)?,
@@ -33,7 +33,7 @@ local function InputField(providedProps: Props)
 	local onFocusLost = React.useCallback(
 		function(_rbx: TextBox, enterPressed: boolean)
 			if props.onFocusLost then
-				props.onFocusLost()
+				props.onFocusLost(text, isValid)
 			end
 
 			if enterPressed and props.onSubmit then
@@ -41,6 +41,7 @@ local function InputField(providedProps: Props)
 			end
 		end,
 		{
+			text,
 			isValid,
 			props.onSubmit,
 		} :: { unknown }
diff --git a/src/Storybook/StoryControls.luau b/src/Storybook/StoryControls.luau
index d30d51cf..9c373ade 100644
--- a/src/Storybook/StoryControls.luau
+++ b/src/Storybook/StoryControls.luau
@@ -37,7 +37,6 @@ local function StoryControls(props: Props)
 		else
 			option = React.createElement(InputField, {
 				placeholder = value,
-				onTextChange = setControl,
 				onSubmit = setControl,
 			})
 		end
diff --git a/src/Storybook/StoryPreview.luau b/src/Storybook/StoryPreview.luau
index fe2d91b0..94c7d878 100644
--- a/src/Storybook/StoryPreview.luau
+++ b/src/Storybook/StoryPreview.luau
@@ -21,42 +21,45 @@ export type Props = {
 	story: types.Story,
 	ref: any,
 	controls: { [string]: any },
-	changedControls: { [string]: any },
-	storyModule: ModuleScript,
 }
 
 type InternalProps = Props & typeof(defaultProps)
 
 local StoryPreview = React.forwardRef(function(providedProps: Props, ref: any)
 	local props: InternalProps = Sift.Dictionary.merge(defaultProps, providedProps)
-
-	local err, setErr = React.useState(nil)
+	local lifecycle = React.useRef(nil :: Storyteller.RenderLifecycle?)
+	local err, setErr = React.useState(nil :: string?)
 
 	React.useEffect(function()
 		setErr(nil)
 	end, { props.story, ref })
 
+	React.useEffect(function()
+		local areControlsDifferent = props.story.controls
+			and not Sift.Dictionary.equals(props.controls, props.story.controls)
+
+		if lifecycle.current and areControlsDifferent then
+			lifecycle.current.update(props.controls)
+		end
+	end, { props.controls, props.story })
+
 	React.useEffect(function(): (() -> ())?
 		if props.story and ref.current then
-			local renderer = Storyteller.createRendererForStory(props.story)
-			local lifecycle
-
 			local success, result = xpcall(function()
-				lifecycle = Storyteller.render(renderer, ref.current, props.story)
+				lifecycle.current = Storyteller.render(ref.current, props.story)
 			end, debug.traceback)
 
 			if not success then
 				setErr(result)
 			end
+		end
 
-			if lifecycle then
-				return function()
-					lifecycle.unmount()
-				end
+		return function()
+			if lifecycle.current then
+				lifecycle.current.unmount()
 			end
 		end
-		return nil
-	end, { props.story, props.controls, props.isMountedInViewport, ref.current } :: { unknown })
+	end, { props.story, props.isMountedInViewport } :: { unknown })
 
 	if err then
 		return e(StoryError, {
diff --git a/src/Storybook/StoryView.luau b/src/Storybook/StoryView.luau
index 27280785..f7fb2c62 100644
--- a/src/Storybook/StoryView.luau
+++ b/src/Storybook/StoryView.luau
@@ -38,23 +38,9 @@ local function StoryView(props: Props)
 	local controlsHeight, setControlsHeight = React.useState(initialControlsHeight)
 	local topbarHeight, setTopbarHeight = React.useState(0)
 	local storyParentRef = React.useRef(nil :: GuiObject?)
-	local controls
+	local controlsWithUserOverrides = Sift.Dictionary.join(if story then story.controls else nil, extraControls)
+	local showControls = controlsWithUserOverrides and not Sift.isEmpty(controlsWithUserOverrides)
 
-	if story and story.controls then
-		controls = {}
-
-		for key, value in story.controls do
-			local override = extraControls[key]
-
-			if override ~= nil and typeof(value) ~= "table" then
-				controls[key] = override
-			else
-				controls[key] = value
-			end
-		end
-	end
-
-	local showControls = controls and not Sift.isEmpty(controls)
 	local setControl = React.useCallback(function(control: string, newValue: any)
 		setExtraControls(function(prev)
 			return Sift.Dictionary.merge(prev, {
@@ -92,6 +78,10 @@ local function StoryView(props: Props)
 		setTopbarHeight(rbx.AbsoluteSize.Y)
 	end, {})
 
+	React.useEffect(function()
+		setExtraControls({})
+	end, { story })
+
 	return e("Frame", {
 		Size = UDim2.fromScale(1, 1),
 		BackgroundTransparency = 1,
@@ -164,7 +154,7 @@ local function StoryView(props: Props)
 				StoryPreview = e(StoryPreview, {
 					zoom = zoom.value,
 					story = story,
-					controls = Sift.Dictionary.merge(controls, extraControls),
+					controls = controlsWithUserOverrides,
 					storyModule = props.story,
 					isMountedInViewport = isMountedInViewport,
 					ref = storyParentRef,
@@ -195,7 +185,7 @@ local function StoryView(props: Props)
 					}),
 
 					StoryControls = e(StoryControls, {
-						controls = controls,
+						controls = controlsWithUserOverrides,
 						setControl = setControl,
 					}),
 				}),

From c35c5882b14915af7934b6ba3dc834afc48ecd55 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Sat, 2 Nov 2024 13:21:33 -0700
Subject: [PATCH 08/79] Revert some minor changes

---
 src/Forms/InputField.luau | 4 ++--
 src/init.storybook.luau   | 6 ++----
 2 files changed, 4 insertions(+), 6 deletions(-)

diff --git a/src/Forms/InputField.luau b/src/Forms/InputField.luau
index cb71b0fd..945b5bd4 100644
--- a/src/Forms/InputField.luau
+++ b/src/Forms/InputField.luau
@@ -14,7 +14,7 @@ export type Props = {
 	layoutOrder: number?,
 	onSubmit: (text: string, isValid: boolean) -> (),
 	onFocus: (() -> ())?,
-	onFocusLost: (text: string, isValid: boolean) -> (),
+	onFocusLost: (() -> ())?,
 	onTextChange: ((new: string, old: string) -> ())?,
 	validate: ((text: string) -> boolean)?,
 	transform: ((newText: string, oldText: string) -> string)?,
@@ -33,7 +33,7 @@ local function InputField(providedProps: Props)
 	local onFocusLost = React.useCallback(
 		function(_rbx: TextBox, enterPressed: boolean)
 			if props.onFocusLost then
-				props.onFocusLost(text, isValid)
+				props.onFocusLost()
 			end
 
 			if enterPressed and props.onSubmit then
diff --git a/src/init.storybook.luau b/src/init.storybook.luau
index 5210a7e9..af5486e7 100644
--- a/src/init.storybook.luau
+++ b/src/init.storybook.luau
@@ -6,8 +6,6 @@ return {
 	storyRoots = {
 		script.Parent,
 	},
-	packages = {
-		React = React,
-		ReactRoblox = ReactRoblox,
-	},
+	react = React,
+	reactRoblox = ReactRoblox,
 }

From 1af8cd897027a9fb7ec2ba37a3adf70e37422e06 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Sat, 2 Nov 2024 18:08:50 -0700
Subject: [PATCH 09/79] Bump to Storyteller 0.3.0

---
 wally.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/wally.toml b/wally.toml
index 9f0246a2..1c2eee74 100644
--- a/wally.toml
+++ b/wally.toml
@@ -8,7 +8,7 @@ exclude = ["*"]
 
 [dependencies]
 ModuleLoader = "flipbook-labs/module-loader@0.6.2"
-Storyteller = "flipbook-labs/storyteller@0.2.1"
+Storyteller = "flipbook-labs/storyteller@0.3.0"
 React = "jsdotlua/react@17.0.2"
 ReactRoblox = "jsdotlua/react-roblox@17.0.2"
 ReactSpring = "chriscerie/react-spring@2.0.0"

From 92f432cbc9f0af0e4f4a68561083324b606844c4 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Sat, 2 Nov 2024 21:53:27 -0700
Subject: [PATCH 10/79] Implicitly install packages on first build

---
 .lune/build.luau | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/.lune/build.luau b/.lune/build.luau
index ded02cad..7d78c2e1 100644
--- a/.lune/build.luau
+++ b/.lune/build.luau
@@ -1,9 +1,11 @@
+local fs = require("@lune/fs")
+local process = require("@lune/process")
+
 local clean = require("./lib/clean")
 local compile = require("./lib/compile")
 local constants = require("./lib/constants")
 local getPluginsPath = require("./lib/getPluginsPath")
 local parseArgs = require("./lib/parseArgs")
-local process = require("@lune/process")
 local run = require("./lib/run")
 local watch = require("./lib/watcher/watch")
 
@@ -16,6 +18,10 @@ local output = if args.output then args.output else `{getPluginsPath(process.os)
 assert(typeof(output) == "string", `bad value for output (string expected, got {typeof(output)})`)
 
 local function build()
+	if not fs.isDir("Packages") then
+		run("lune", { "run", "wally-install" })
+	end
+
 	clean()
 	compile(target)
 

From 38ca781386191098320dff9afdbf05fefc7b73ef Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Sat, 2 Nov 2024 21:53:44 -0700
Subject: [PATCH 11/79] Fix giant gray box in story preview area

---
 src/Plugin/PluginApp.luau | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/Plugin/PluginApp.luau b/src/Plugin/PluginApp.luau
index c494317d..7081df03 100644
--- a/src/Plugin/PluginApp.luau
+++ b/src/Plugin/PluginApp.luau
@@ -71,6 +71,7 @@ local function App(props: Props)
 		MainWrapper = React.createElement("Frame", {
 			LayoutOrder = nextLayoutOrder(),
 			Size = UDim2.fromScale(1, 1) - UDim2.fromOffset(sidebarWidth, 0),
+			BackgroundTransparency = 1,
 		}, {
 			Layout = React.createElement("UIListLayout", {
 				SortOrder = Enum.SortOrder.LayoutOrder,

From 7e612d398cb866d9168ff27592a666bcbce000bc Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Sat, 2 Nov 2024 22:28:45 -0700
Subject: [PATCH 12/79] Remove unused hook

---
 src/Common/useDescendants.luau      |  62 ----------------
 src/Common/useDescendants.spec.luau | 108 ----------------------------
 2 files changed, 170 deletions(-)
 delete mode 100644 src/Common/useDescendants.luau
 delete mode 100644 src/Common/useDescendants.spec.luau

diff --git a/src/Common/useDescendants.luau b/src/Common/useDescendants.luau
deleted file mode 100644
index b0d8dd17..00000000
--- a/src/Common/useDescendants.luau
+++ /dev/null
@@ -1,62 +0,0 @@
-local React = require("@pkg/React")
-local Sift = require("@pkg/Sift")
-
-local function useDescendants(parent: Instance, predicate: (descendant: Instance) -> boolean): { Instance }
-	local descendants: { Instance }, setDescendants = React.useState({})
-
-	local onDescendantChanged = React.useCallback(function(descendant: Instance)
-		setDescendants(function(prev)
-			local exists = table.find(prev, descendant)
-
-			if predicate(descendant) then
-				if exists then
-					-- Force a re-render. Nothing about the state changed, but the
-					-- module uses a new name now
-					return table.clone(prev)
-				else
-					return Sift.Array.push(prev, descendant)
-				end
-			else
-				if exists then
-					return Sift.Array.filter(prev, function(other: Instance)
-						return descendant ~= other
-					end)
-				end
-			end
-
-			return prev
-		end)
-	end, { predicate, descendants } :: { unknown })
-
-	-- Setup the initial list of descendants for the current parent
-	React.useEffect(function()
-		setDescendants(Sift.Array.filter(parent:GetDescendants(), predicate))
-	end, { parent })
-
-	React.useEffect(function()
-		local connections = {
-			parent.DescendantAdded:Connect(onDescendantChanged),
-			parent.DescendantRemoving:Connect(onDescendantChanged),
-		}
-
-		-- Listen for name changes and update the list of descendants
-		for _, descendant in parent:GetDescendants() do
-			table.insert(
-				connections,
-				descendant:GetPropertyChangedSignal("Name"):Connect(function()
-					onDescendantChanged(descendant)
-				end)
-			)
-		end
-
-		return function()
-			for _, conn in connections do
-				conn:Disconnect()
-			end
-		end
-	end, { parent, onDescendantChanged } :: { unknown })
-
-	return descendants
-end
-
-return useDescendants
diff --git a/src/Common/useDescendants.spec.luau b/src/Common/useDescendants.spec.luau
deleted file mode 100644
index 336a8aa2..00000000
--- a/src/Common/useDescendants.spec.luau
+++ /dev/null
@@ -1,108 +0,0 @@
-local JestGlobals = require("@pkg/JestGlobals")
-local React = require("@pkg/React")
-local ReactRoblox = require("@pkg/ReactRoblox")
-local newFolder = require("@root/Testing/newFolder")
-local useDescendants = require("./useDescendants")
-
-local afterEach = JestGlobals.afterEach
-local expect = JestGlobals.expect
-local test = JestGlobals.test
-
-local container = Instance.new("ScreenGui")
-local root = ReactRoblox.createRoot(container)
-
-afterEach(function()
-	ReactRoblox.act(function()
-		root:unmount()
-	end)
-end)
-
-test("return an initial list of descendants that match the predicate", function()
-	local tree = newFolder({
-		Match = Instance.new("Part"),
-		Foo = Instance.new("Part"),
-	})
-
-	local descendants
-	local function HookTester()
-		descendants = useDescendants(tree, function(descendant)
-			return descendant.Name == "Match"
-		end)
-
-		return nil
-	end
-
-	ReactRoblox.act(function()
-		root:render(React.createElement(HookTester))
-	end)
-
-	expect(descendants).toBeDefined()
-	expect(#descendants).toBe(1)
-	expect(descendants[1]).toBe(tree:FindFirstChild("Match"))
-end)
-
-test("respond to changes in descendants that match the predicate", function()
-	local tree = newFolder({
-		Match = Instance.new("Part"),
-		Foo = Instance.new("Part"),
-	})
-
-	local descendants
-	local function HookTester()
-		descendants = useDescendants(tree, function(descendant)
-			return descendant.Name == "Match"
-		end)
-
-		return nil
-	end
-
-	ReactRoblox.act(function()
-		root:render(React.createElement(HookTester))
-	end)
-
-	expect(descendants).toBeDefined()
-	expect(#descendants).toBe(1)
-
-	local folder = newFolder({
-		Match = Instance.new("Part"),
-	})
-
-	ReactRoblox.act(function()
-		folder.Parent = tree
-	end)
-
-	expect(#descendants).toBe(2)
-end)
-
-test("force an update when a matching descendant's name changes", function()
-	local descendants
-
-	local tree = newFolder({
-		Match = Instance.new("Part"),
-	})
-
-	local function HookTester()
-		descendants = useDescendants(tree, function(descendant)
-			return descendant:IsA("Part")
-		end)
-
-		return nil
-	end
-
-	ReactRoblox.act(function()
-		root:render(React.createElement(HookTester))
-	end)
-
-	expect(descendants).toBeDefined()
-	expect(#descendants).toBe(1)
-
-	local prev = descendants
-	local match = tree:FindFirstChild("Match") :: Instance
-
-	ReactRoblox.act(function()
-		match.Name = "Changed"
-	end)
-
-	expect(descendants).never.toBe(prev)
-	expect(descendants[1]).toBe(match)
-end)

From d42689dfea6fac6d9ebed0bb75f7a48793e297ae Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Sat, 2 Nov 2024 22:42:17 -0700
Subject: [PATCH 13/79] Bump Storyteller to 0.4.0

---
 wally.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/wally.toml b/wally.toml
index 1c2eee74..f1f4c128 100644
--- a/wally.toml
+++ b/wally.toml
@@ -8,7 +8,7 @@ exclude = ["*"]
 
 [dependencies]
 ModuleLoader = "flipbook-labs/module-loader@0.6.2"
-Storyteller = "flipbook-labs/storyteller@0.3.0"
+Storyteller = "flipbook-labs/storyteller@0.4.0"
 React = "jsdotlua/react@17.0.2"
 ReactRoblox = "jsdotlua/react-roblox@17.0.2"
 ReactSpring = "chriscerie/react-spring@2.0.0"

From a20077b59853c27487f93ecd3346cbc9473368b6 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Sat, 2 Nov 2024 22:47:37 -0700
Subject: [PATCH 14/79] Revert a story change

---
 example/ReactCounter.story.luau | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/example/ReactCounter.story.luau b/example/ReactCounter.story.luau
index ceace6fd..7fbe9dd7 100644
--- a/example/ReactCounter.story.luau
+++ b/example/ReactCounter.story.luau
@@ -16,10 +16,6 @@ return {
 	controls = controls,
 	react = React,
 	reactRoblox = ReactRoblox,
-	packages = {
-		React = React,
-		ReactRoblox = ReactRoblox,
-	},
 	story = function(props: Props)
 		return React.createElement(ReactCounter, {
 			increment = props.controls.increment,

From fcd240017982e68f60472b9eec213b724b314d5e Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Sat, 2 Nov 2024 22:53:37 -0700
Subject: [PATCH 15/79] Use Storyteller type sdirectly

---
 src/Explorer/types.luau                  | 4 ++--
 src/Navigation/Screen.luau               | 8 ++++----
 src/Panels/Sidebar.luau                  | 7 ++++---
 src/Storybook/StoryCanvas.luau           | 9 ++++++---
 src/Storybook/StoryMeta.luau             | 7 +++++--
 src/Storybook/StoryPreview.luau          | 7 ++++---
 src/Storybook/StoryView.luau             | 8 +++++---
 src/Storybook/createStoryNodes.luau      | 3 +--
 src/Storybook/createStoryNodes.spec.luau | 5 +++--
 src/Storybook/types.luau                 | 8 --------
 10 files changed, 34 insertions(+), 32 deletions(-)
 delete mode 100644 src/Storybook/types.luau

diff --git a/src/Explorer/types.luau b/src/Explorer/types.luau
index 68b9535d..9b49b05b 100644
--- a/src/Explorer/types.luau
+++ b/src/Explorer/types.luau
@@ -1,6 +1,6 @@
-local storybookTypes = require("@root/Storybook/types")
+local Storyteller = require("@pkg/Storyteller")
 
-type Storybook = storybookTypes.Storybook
+type Storybook = Storyteller.Storybook
 
 export type ComponentTreeNode = {
 	name: string,
diff --git a/src/Navigation/Screen.luau b/src/Navigation/Screen.luau
index 70ee4a45..c9d39c08 100644
--- a/src/Navigation/Screen.luau
+++ b/src/Navigation/Screen.luau
@@ -1,18 +1,18 @@
 local ModuleLoader = require("@pkg/ModuleLoader")
 local React = require("@pkg/React")
+local Storyteller = require("@pkg/Storyteller")
 
 local NavigationContext = require("@root/Navigation/NavigationContext")
 local SettingsView = require("@root/UserSettings/SettingsView")
 local StoryCanvas = require("@root/Storybook/StoryCanvas")
-local storybookTypes = require("@root/Storybook/types")
 
 local useMemo = React.useMemo
 
-type Story = storybookTypes.Story
-type Storybook = storybookTypes.Storybook
+type ModuleLoader = ModuleLoader.ModuleLoader
+type Storybook = Storyteller.Storybook
 
 export type Props = {
-	loader: ModuleLoader.ModuleLoader,
+	loader: ModuleLoader,
 	story: ModuleScript?,
 	storybook: Storybook?,
 }
diff --git a/src/Panels/Sidebar.luau b/src/Panels/Sidebar.luau
index f805b6be..6e8930d9 100644
--- a/src/Panels/Sidebar.luau
+++ b/src/Panels/Sidebar.luau
@@ -1,15 +1,16 @@
+local React = require("@pkg/React")
+local Storyteller = require("@pkg/Storyteller")
+
 local Branding = require("@root/Common/Branding")
 local ComponentTree = require("@root/Explorer")
-local React = require("@pkg/React")
 local ScrollingFrame = require("@root/Common/ScrollingFrame")
 local Searchbar = require("@root/Forms/Searchbar")
 local constants = require("@root/constants")
 local createStoryNodes = require("@root/Storybook/createStoryNodes")
 local explorerTypes = require("@root/Explorer/types")
-local storybookTypes = require("@root/Storybook/types")
 local useTheme = require("@root/Common/useTheme")
 
-type Storybook = storybookTypes.Storybook
+type Storybook = Storyteller.Storybook
 type ComponentTreeNode = explorerTypes.ComponentTreeNode
 
 local e = React.createElement
diff --git a/src/Storybook/StoryCanvas.luau b/src/Storybook/StoryCanvas.luau
index 06c5cf03..389ccdbe 100644
--- a/src/Storybook/StoryCanvas.luau
+++ b/src/Storybook/StoryCanvas.luau
@@ -1,17 +1,20 @@
 local ModuleLoader = require("@pkg/ModuleLoader")
 local React = require("@pkg/React")
+local Storyteller = require("@pkg/Storyteller")
 
 local NoStorySelected = require("@root/Storybook/NoStorySelected")
 local StoryView = require("@root/Storybook/StoryView")
-local types = require("@root/Storybook/types")
 local useTheme = require("@root/Common/useTheme")
 
 local e = React.createElement
 
+type ModuleLoader = ModuleLoader.ModuleLoader
+type Storybook = Storyteller.Storybook
+
 type Props = {
 	story: ModuleScript,
-	loader: ModuleLoader.ModuleLoader,
-	storybook: types.Storybook,
+	loader: ModuleLoader,
+	storybook: Storybook,
 	layoutOrder: number?,
 }
 
diff --git a/src/Storybook/StoryMeta.luau b/src/Storybook/StoryMeta.luau
index f58783bd..ca611ce0 100644
--- a/src/Storybook/StoryMeta.luau
+++ b/src/Storybook/StoryMeta.luau
@@ -1,13 +1,16 @@
 local React = require("@pkg/React")
-local types = require("@root/Storybook/types")
+local Storyteller = require("@pkg/Storyteller")
+
 local useTheme = require("@root/Common/useTheme")
 
 local MAX_SUMMARY_SIZE = 600
 
 local e = React.createElement
 
+type Story = Storyteller.Story<unknown>
+
 export type Props = {
-	story: types.Story,
+	story: Story,
 	layoutOrder: number?,
 }
 
diff --git a/src/Storybook/StoryPreview.luau b/src/Storybook/StoryPreview.luau
index 94c7d878..9b0ce885 100644
--- a/src/Storybook/StoryPreview.luau
+++ b/src/Storybook/StoryPreview.luau
@@ -7,7 +7,6 @@ local Storyteller = require("@pkg/Storyteller")
 
 local ScrollingFrame = require("@root/Common/ScrollingFrame")
 local StoryError = require("@root/Storybook/StoryError")
-local types = require("@root/Storybook/types")
 
 local e = React.createElement
 
@@ -16,9 +15,11 @@ local defaultProps = {
 	zoom = 0,
 }
 
+type Story = Storyteller.Story<unknown>
+
 export type Props = {
-	layoutOrder: number,
-	story: types.Story,
+	layoutOrder: number?,
+	story: Story,
 	ref: any,
 	controls: { [string]: any },
 }
diff --git a/src/Storybook/StoryView.luau b/src/Storybook/StoryView.luau
index f7fb2c62..c8c83e84 100644
--- a/src/Storybook/StoryView.luau
+++ b/src/Storybook/StoryView.luau
@@ -15,16 +15,18 @@ local StoryMeta = require("@root/Storybook/StoryMeta")
 local StoryPreview = require("@root/Storybook/StoryPreview")
 local StoryViewNavbar = require("@root/Storybook/StoryViewNavbar")
 local constants = require("@root/constants")
-local types = require("@root/Storybook/types")
 local useTheme = require("@root/Common/useTheme")
 local useZoom = require("@root/Common/useZoom")
 
 local e = React.createElement
 
+type ModuleLoader = ModuleLoader.ModuleLoader
+type Storybook = Storyteller.Storybook
+
 type Props = {
-	loader: ModuleLoader.ModuleLoader,
+	loader: ModuleLoader,
 	story: ModuleScript,
-	storybook: types.Storybook,
+	storybook: Storybook,
 }
 
 local function StoryView(props: Props)
diff --git a/src/Storybook/createStoryNodes.luau b/src/Storybook/createStoryNodes.luau
index c71d7cd6..a4741ccb 100644
--- a/src/Storybook/createStoryNodes.luau
+++ b/src/Storybook/createStoryNodes.luau
@@ -1,9 +1,8 @@
 local Storyteller = require("@pkg/Storyteller")
 
 local explorerTypes = require("@root/Explorer/types")
-local storybookTypes = require("@root/Storybook/types")
 
-type Storybook = storybookTypes.Storybook
+type Storybook = Storyteller.Storybook
 type ComponentTreeNode = explorerTypes.ComponentTreeNode
 
 local function hasStories(instance: Instance): boolean
diff --git a/src/Storybook/createStoryNodes.spec.luau b/src/Storybook/createStoryNodes.spec.luau
index ec7f365d..fd6f6092 100644
--- a/src/Storybook/createStoryNodes.spec.luau
+++ b/src/Storybook/createStoryNodes.spec.luau
@@ -1,7 +1,8 @@
 local JestGlobals = require("@pkg/JestGlobals")
+local Storyteller = require("@pkg/Storyteller")
+
 local createStoryNodes = require("./createStoryNodes")
 local newFolder = require("@root/Testing/newFolder")
-local types = require("@root/Storybook/types")
 
 local expect = JestGlobals.expect
 local test = JestGlobals.test
@@ -15,7 +16,7 @@ local mockStoryRoot = newFolder({
 	}),
 })
 
-local mockStorybook: types.Storybook = {
+local mockStorybook: Storyteller.Storybook = {
 	name = "MockStorybook",
 	storyRoots = { mockStoryRoot },
 }
diff --git a/src/Storybook/types.luau b/src/Storybook/types.luau
deleted file mode 100644
index 5f654549..00000000
--- a/src/Storybook/types.luau
+++ /dev/null
@@ -1,8 +0,0 @@
-local Storyteller = require("@pkg/Storyteller")
-
-export type Controls = Storyteller.StoryControls
-export type StoryProps = Storyteller.StoryProps
-export type Storybook = Storyteller.Storybook
-export type Story = Storyteller.Story<unknown>
-
-return nil

From bd6c8f27ebe139f1e9f1a796fa774887f5b985b5 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Sat, 2 Nov 2024 22:53:50 -0700
Subject: [PATCH 16/79] Fix analysis errors

---
 src/init.storybook.luau | 6 ++++--
 src/stories.spec.luau   | 3 +--
 2 files changed, 5 insertions(+), 4 deletions(-)

diff --git a/src/init.storybook.luau b/src/init.storybook.luau
index af5486e7..5210a7e9 100644
--- a/src/init.storybook.luau
+++ b/src/init.storybook.luau
@@ -6,6 +6,8 @@ return {
 	storyRoots = {
 		script.Parent,
 	},
-	react = React,
-	reactRoblox = ReactRoblox,
+	packages = {
+		React = React,
+		ReactRoblox = ReactRoblox,
+	},
 }
diff --git a/src/stories.spec.luau b/src/stories.spec.luau
index d500ba68..0d26cc17 100644
--- a/src/stories.spec.luau
+++ b/src/stories.spec.luau
@@ -46,7 +46,6 @@ describeEach({
 	})("%s", function(storyModule)
 		test("basic mount/unmount lifecycle", function()
 			local story = Storyteller.loadStoryModule(mockModuleLoader, storyModule, storybook)
-			local renderer = Storyteller.createRendererForStory(story)
 
 			if story.packages then
 				story.packages = Sift.Dictionary.join(story.packages, {
@@ -55,7 +54,7 @@ describeEach({
 				})
 			end
 
-			local lifecycle = Storyteller.render(renderer, container, story)
+			local lifecycle = Storyteller.render(container, story)
 
 			expect(#container:GetChildren()).toBe(1)
 

From 5834eba3fd0feb988f7d601964e16d965085171d Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Tue, 5 Nov 2024 07:29:40 -0800
Subject: [PATCH 17/79] WIP changes for getting controls working consistently

---
 src/Storybook/StoryPreview.luau | 21 ++++++++++++++++-----
 src/Storybook/StoryView.luau    | 20 ++++++++++++++++++--
 2 files changed, 34 insertions(+), 7 deletions(-)

diff --git a/src/Storybook/StoryPreview.luau b/src/Storybook/StoryPreview.luau
index 699088cf..611aca2d 100644
--- a/src/Storybook/StoryPreview.luau
+++ b/src/Storybook/StoryPreview.luau
@@ -7,6 +7,7 @@ local Storyteller = require("@pkg/Storyteller")
 
 local ScrollingFrame = require("@root/Common/ScrollingFrame")
 local StoryError = require("@root/Storybook/StoryError")
+local usePrevious = require("@root/Common/usePrevious")
 
 local e = React.createElement
 
@@ -33,22 +34,32 @@ local StoryPreview = React.forwardRef(function(providedProps: Props, ref: any)
 	local props: InternalProps = Sift.Dictionary.merge(defaultProps, providedProps)
 	local lifecycle = React.useRef(nil :: Storyteller.RenderLifecycle?)
 	local err, setErr = React.useState(nil :: string?)
+	local prevControls = usePrevious(props.controls)
+	local prevStory = usePrevious(props.story)
 
 	React.useEffect(function()
 		setErr(nil)
 	end, { props.story, ref })
 
 	React.useEffect(function()
-		local areControlsDifferent = props.story.controls
-			and not Sift.Dictionary.equals(props.controls, props.story.controls)
+		if props.story == prevStory then
+			local areControlsDifferent = prevControls and not Sift.Dictionary.equals(props.controls, prevControls)
 
-		if lifecycle.current and areControlsDifferent then
-			lifecycle.current.update(props.controls)
+			if lifecycle.current and areControlsDifferent then
+				local success, result = xpcall(function()
+					lifecycle.current.update(props.controls)
+				end, debug.traceback)
+
+				if not success then
+					setErr(result)
+				end
+			end
 		end
-	end, { props.controls, props.story })
+	end, { props.controls, prevControls, props.story, prevStory } :: { unknown })
 
 	React.useEffect(function(): (() -> ())?
 		if props.story and ref.current then
+			-- TODO: Rendering before controls are applied
 			local success, result = xpcall(function()
 				lifecycle.current = Storyteller.render(ref.current, props.story)
 			end, debug.traceback)
diff --git a/src/Storybook/StoryView.luau b/src/Storybook/StoryView.luau
index 60ba6732..b8bcc400 100644
--- a/src/Storybook/StoryView.luau
+++ b/src/Storybook/StoryView.luau
@@ -40,7 +40,23 @@ local function StoryView(props: Props)
 	local controlsHeight, setControlsHeight = React.useState(initialControlsHeight)
 	local topbarHeight, setTopbarHeight = React.useState(0)
 	local storyParentRef = React.useRef(nil :: GuiObject?)
-	local controlsWithUserOverrides = Sift.Dictionary.join(if story then story.controls else nil, extraControls)
+
+	local controlsWithUserOverrides = React.useMemo(function()
+		local controls = {}
+		if story and story.controls then
+			for key, value in story.controls do
+				local override = extraControls[key]
+
+				if override ~= nil and typeof(value) ~= "table" then
+					controls[key] = override
+				else
+					controls[key] = value
+				end
+			end
+		end
+		return controls
+	end, { story, extraControls } :: { unknown })
+
 	local showControls = controlsWithUserOverrides and not Sift.isEmpty(controlsWithUserOverrides)
 
 	local setControl = React.useCallback(function(control: string, newValue: any)
@@ -156,7 +172,7 @@ local function StoryView(props: Props)
 				StoryPreview = e(StoryPreview, {
 					zoom = zoom.value,
 					story = story,
-					controls = controlsWithUserOverrides,
+					controls = Sift.Dictionary.merge(controlsWithUserOverrides, extraControls),
 					storyModule = props.story,
 					isMountedInViewport = isMountedInViewport,
 					ref = storyParentRef,

From 50e78f5d3ecf051bd234277ce4456a5399fa0b28 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Tue, 5 Nov 2024 16:55:54 -0800
Subject: [PATCH 18/79] Install local Storyteller for convenience

---
 .lune/wally-install.luau | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)

diff --git a/.lune/wally-install.luau b/.lune/wally-install.luau
index de9144de..dde93e0e 100644
--- a/.lune/wally-install.luau
+++ b/.lune/wally-install.luau
@@ -1,5 +1,23 @@
 local run = require("./lib/run")
 
 run("wally", { "install" })
+
+do
+	local process = require("@lune/process")
+
+	local homePath = process.env.HOME
+	assert(homePath, "no $HOME env var")
+
+	local storytellerPath = run("realpath", { "../storyteller" })
+
+	run("rm", { `Packages/Storyteller.lua` })
+
+	run("lune", { "run", "build" }, {
+		cwd = storytellerPath,
+	})
+
+	run("ln", { "-s", `{storytellerPath}/dist`, `Packages/Storyteller` })
+end
+
 run("rojo", { "sourcemap", "build.project.json", "-o", "sourcemap.json" })
 run("wally-package-types", { "--sourcemap", "sourcemap.json", "Packages" })

From e72d9f367f5a47c98efadbd38678d9dc70d0bf7f Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 6 Nov 2024 15:04:39 -0800
Subject: [PATCH 19/79] Install local ModuleLoader

---
 .lune/wally-install.luau | 21 ++++++++++++++-------
 wally.toml               |  3 +++
 2 files changed, 17 insertions(+), 7 deletions(-)

diff --git a/.lune/wally-install.luau b/.lune/wally-install.luau
index dde93e0e..a21df008 100644
--- a/.lune/wally-install.luau
+++ b/.lune/wally-install.luau
@@ -1,23 +1,30 @@
+local process = require("@lune/process")
+
 local run = require("./lib/run")
 
 run("wally", { "install" })
 
-do
-	local process = require("@lune/process")
-
+local function installPackageFromDisk(packageName: string, packagePath: string, build: ((packagePath: string) -> ())?)
 	local homePath = process.env.HOME
 	assert(homePath, "no $HOME env var")
 
-	local storytellerPath = run("realpath", { "../storyteller" })
+	local absPackagePath = run("realpath", { packagePath })
 
-	run("rm", { `Packages/Storyteller.lua` })
+	run("rm", { `Packages/{packageName}.lua` })
+
+	run("lune", { "run", "wally-install" }, {
+		cwd = absPackagePath,
+	})
 
 	run("lune", { "run", "build" }, {
-		cwd = storytellerPath,
+		cwd = absPackagePath,
 	})
 
-	run("ln", { "-s", `{storytellerPath}/dist`, `Packages/Storyteller` })
+	run("ln", { "-s", `{absPackagePath}/dist`, `Packages/{packageName}` })
 end
 
+installPackageFromDisk("Storyteller", "../storyteller")
+installPackageFromDisk("ModuleLoader", "../module-loader")
+
 run("rojo", { "sourcemap", "build.project.json", "-o", "sourcemap.json" })
 run("wally-package-types", { "--sourcemap", "sourcemap.json", "Packages" })
diff --git a/wally.toml b/wally.toml
index e927405e..42f02198 100644
--- a/wally.toml
+++ b/wally.toml
@@ -19,3 +19,6 @@ t = "osyrisrblx/t@3.0.0"
 Roact = "roblox/roact@1.4.4"
 Jest = "jsdotlua/jest@3.6.1-rc.2"
 JestGlobals = "jsdotlua/jest-globals@3.6.1-rc.2"
+
+# ModuleLoader dependencies
+Janitor = "howmanysmall/janitor@1.13.15"

From 595699422307a96b4a28a2376e2f7f6a768a5159 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 6 Nov 2024 16:37:56 -0800
Subject: [PATCH 20/79] Don't need the `build` arg

---
 .lune/wally-install.luau | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.lune/wally-install.luau b/.lune/wally-install.luau
index a21df008..42ce7ecb 100644
--- a/.lune/wally-install.luau
+++ b/.lune/wally-install.luau
@@ -4,7 +4,7 @@ local run = require("./lib/run")
 
 run("wally", { "install" })
 
-local function installPackageFromDisk(packageName: string, packagePath: string, build: ((packagePath: string) -> ())?)
+local function installPackageFromDisk(packageName: string, packagePath: string)
 	local homePath = process.env.HOME
 	assert(homePath, "no $HOME env var")
 

From d860d4d9c8e056a86d1e66174bfe60dfef1d994f Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 7 Nov 2024 13:42:15 -0800
Subject: [PATCH 21/79] Add support to build to engine

---
 .lune/build.luau | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

diff --git a/.lune/build.luau b/.lune/build.luau
index 7d78c2e1..513c6b7f 100644
--- a/.lune/build.luau
+++ b/.lune/build.luau
@@ -17,6 +17,12 @@ assert(target == "dev" or target == "prod", `bad value for target (must be one o
 local output = if args.output then args.output else `{getPluginsPath(process.os)}/{constants.PLUGIN_FILENAME}`
 assert(typeof(output) == "string", `bad value for output (string expected, got {typeof(output)})`)
 
+local gameEnginePath = args.gameEnginePath
+assert(
+	not gameEnginePath or typeof(gameEnginePath) == "string",
+	`bad value for gameEnginePath (string expected, got {typeof(output)}`
+)
+
 local function build()
 	if not fs.isDir("Packages") then
 		run("lune", { "run", "wally-install" })
@@ -31,6 +37,16 @@ local function build()
 		run("rm", { "-rf", `{constants.BUILD_PATH}/**/*.storybook.luau` })
 	end
 
+	if gameEnginePath then
+		local engineBuildPath = run("find", { `{gameEnginePath}/build`, "-name", "optimized" })
+		assert(fs.isDir(engineBuildPath), `failed to find optimized engine build under {gameEnginePath}`)
+
+		local builtInPlugins = run("find", { engineBuildPath, "-name", "BuiltInPlugins", "-print", "-quit" })
+		assert(fs.isDir(builtInPlugins), `failed to find built-in plugins under {engineBuildPath}`)
+
+		output = `{builtInPlugins}/{constants.PLUGIN_FILENAME}`
+	end
+
 	run("rojo", { "build", "-o", output })
 end
 

From d4b3142f26cd38e47474625247cddb9e84fa3683 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 7 Nov 2024 13:42:25 -0800
Subject: [PATCH 22/79] Arg can be nil

---
 .lune/lib/parseArgs.luau | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.lune/lib/parseArgs.luau b/.lune/lib/parseArgs.luau
index a506f990..522f8f6c 100644
--- a/.lune/lib/parseArgs.luau
+++ b/.lune/lib/parseArgs.luau
@@ -2,7 +2,7 @@ local FLAG_PATTERN = "%-%-(%w+)"
 local FLAG_ALL_IN_ONE_PATTERN = `{FLAG_PATTERN}=(%w+)`
 
 local function parseArgs(args: { string })
-	local parsedArgs: { [string]: string | boolean | number } = {}
+	local parsedArgs: { [string]: string | boolean | number | nil } = {}
 
 	local skipNextToken = false
 

From 2f6c38823510bfc340d336cad9e73ce8d231308b Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 7 Nov 2024 16:07:04 -0800
Subject: [PATCH 23/79] Don't build the packages, just link

---
 .lune/wally-install.luau | 9 ---------
 1 file changed, 9 deletions(-)

diff --git a/.lune/wally-install.luau b/.lune/wally-install.luau
index 42ce7ecb..d1aab840 100644
--- a/.lune/wally-install.luau
+++ b/.lune/wally-install.luau
@@ -11,15 +11,6 @@ local function installPackageFromDisk(packageName: string, packagePath: string)
 	local absPackagePath = run("realpath", { packagePath })
 
 	run("rm", { `Packages/{packageName}.lua` })
-
-	run("lune", { "run", "wally-install" }, {
-		cwd = absPackagePath,
-	})
-
-	run("lune", { "run", "build" }, {
-		cwd = absPackagePath,
-	})
-
 	run("ln", { "-s", `{absPackagePath}/dist`, `Packages/{packageName}` })
 end
 

From e686723159fb12cd3ea597180b4b408772736f11 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 7 Nov 2024 16:19:56 -0800
Subject: [PATCH 24/79] Output to the right place

---
 .lune/build.luau | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.lune/build.luau b/.lune/build.luau
index 513c6b7f..d025145b 100644
--- a/.lune/build.luau
+++ b/.lune/build.luau
@@ -44,7 +44,7 @@ local function build()
 		local builtInPlugins = run("find", { engineBuildPath, "-name", "BuiltInPlugins", "-print", "-quit" })
 		assert(fs.isDir(builtInPlugins), `failed to find built-in plugins under {engineBuildPath}`)
 
-		output = `{builtInPlugins}/{constants.PLUGIN_FILENAME}`
+		output = `{builtInPlugins}/Optimized_Embedded_Signature/{constants.PLUGIN_FILENAME}`
 	end
 
 	run("rojo", { "build", "-o", output })

From 75eb005b90ed9ba6f8608ff3f0e0ed7f8df58f67 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Mon, 11 Nov 2024 09:09:42 -0800
Subject: [PATCH 25/79] Add padding to StoryError

---
 src/Storybook/StoryError.luau | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/src/Storybook/StoryError.luau b/src/Storybook/StoryError.luau
index 97878ad7..e4012bd1 100644
--- a/src/Storybook/StoryError.luau
+++ b/src/Storybook/StoryError.luau
@@ -14,6 +14,13 @@ local function StoryError(props: Props)
 		LayoutOrder = props.layoutOrder,
 		Text = props.err,
 		TextColor3 = theme.alert,
+	}, {
+		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
 

From 9f4c68f2b6e7065b3030b1e1b73ef3139c32e0da Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 11 Dec 2024 16:37:31 -0800
Subject: [PATCH 26/79] Add Storyteller dependency

---
 wally.toml | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/wally.toml b/wally.toml
index 6c96e6da..d3b3b9d6 100644
--- a/wally.toml
+++ b/wally.toml
@@ -22,3 +22,6 @@ JestGlobals = "jsdotlua/jest-globals@3.6.1-rc.2"
 
 # ModuleLoader dependencies
 Janitor = "howmanysmall/janitor@1.13.15"
+
+# Storyteller dependencies
+Prospector = "egomoose/prospector@1.1.0"

From 0d3d197a0d8127edc9c1437dc3a6f75d28ce169b Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 11 Dec 2024 16:47:12 -0800
Subject: [PATCH 27/79] Fix storybooks not appearing in tree view

---
 src/Plugin/PluginApp.luau | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Plugin/PluginApp.luau b/src/Plugin/PluginApp.luau
index 7081df03..85b6cae5 100644
--- a/src/Plugin/PluginApp.luau
+++ b/src/Plugin/PluginApp.luau
@@ -64,7 +64,7 @@ local function App(props: Props)
 			Sidebar = React.createElement(Sidebar, {
 				selectStory = selectStory,
 				selectStorybook = selectStorybook,
-				storybooks = storybooks,
+				storybooks = storybooks.available,
 			}),
 		}),
 

From 8e9907047eb8d71b5752590841efba2434200dea Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 11 Dec 2024 16:47:23 -0800
Subject: [PATCH 28/79] Don't worry about ModuleLoader for now

---
 .lune/wally-install.luau | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.lune/wally-install.luau b/.lune/wally-install.luau
index 04d05e79..1798f17b 100644
--- a/.lune/wally-install.luau
+++ b/.lune/wally-install.luau
@@ -16,7 +16,7 @@ do
 	run("wally", { "install" })
 
 	installPackageFromDisk("Storyteller", "../storyteller")
-	installPackageFromDisk("ModuleLoader", "../module-loader")
+	-- installPackageFromDisk("ModuleLoader", "../module-loader")
 
 	run("rojo", { "sourcemap", "build.project.json", "-o", "sourcemap.json" })
 	run("wally-package-types", { "--sourcemap", "sourcemap.json", "Packages" })

From af73515d634925edb1498f3aa5a1f97cfcb7b66c Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 12 Dec 2024 13:18:20 -0800
Subject: [PATCH 29/79] Allow the plugin to be constructed by a wrapper

---
 src/Plugin/createFlipbookPlugin.luau | 113 +++++++++++++++++++++++++++
 src/Plugin/createToggleButton.luau   |  26 ------
 src/Plugin/createWidget.luau         |  12 ---
 src/init.server.luau                 |  44 +----------
 4 files changed, 116 insertions(+), 79 deletions(-)
 create mode 100644 src/Plugin/createFlipbookPlugin.luau
 delete mode 100644 src/Plugin/createToggleButton.luau
 delete mode 100644 src/Plugin/createWidget.luau

diff --git a/src/Plugin/createFlipbookPlugin.luau b/src/Plugin/createFlipbookPlugin.luau
new file mode 100644
index 00000000..80af5315
--- /dev/null
+++ b/src/Plugin/createFlipbookPlugin.luau
@@ -0,0 +1,113 @@
+local RunService = game:GetService("RunService")
+
+if RunService:IsRunning() or not RunService:IsEdit() then
+	return
+end
+
+local ModuleLoader = require("@pkg/ModuleLoader")
+local React = require("@pkg/React")
+local ReactRoblox = require("@pkg/ReactRoblox")
+
+local ContextProviders = require("@root/Common/ContextProviders")
+local PluginApp = require("@root/Plugin/PluginApp")
+
+local function createFlipbookPlugin(
+	name: string,
+	plugin: Plugin,
+	toolbar: PluginToolbar
+): {
+	mount: () -> (),
+	unmount: () -> (),
+	destroy: () -> (),
+}
+	local isDestroyed = false
+
+	local connections: { RBXScriptConnection } = {}
+
+	local info = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Top, true)
+
+	local widget = plugin:CreateDockWidgetPluginGui(name, info)
+	widget.Name = name
+	widget.Title = name
+	widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
+
+	local root = ReactRoblox.createRoot(widget)
+	local loader = ModuleLoader.new()
+	local button = toolbar:CreateButton(name, "Open story view", "rbxassetid://10277153751")
+
+	local function unmount()
+		assert(not isDestroyed, "cannot call unmount (Flipbook plugin was destroyed)")
+
+		root:unmount()
+		loader:clear()
+
+		for _, connection in connections do
+			connection:Disconnect()
+		end
+	end
+
+	local function mount()
+		assert(not isDestroyed, "cannot call mount (Flipbook plugin was destroyed)")
+
+		local app = React.createElement(ContextProviders, {
+			plugin = plugin,
+		}, {
+			PluginApp = React.createElement(PluginApp, {
+				loader = loader,
+			}),
+		})
+
+		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)
+		)
+
+		table.insert(
+			connections,
+			widget:GetPropertyChangedSignal("Enabled"):Connect(function()
+				if widget.Enabled then
+					root:render(app)
+				else
+					unmount()
+				end
+			end)
+		)
+
+		table.insert(connections, plugin.Unloading:Connect(unmount))
+
+		if widget.Enabled then
+			root:render(app)
+		end
+	end
+
+	local function destroy()
+		assert(not isDestroyed, "cannot call destroy (Flipbook plugin was already destroyed)")
+
+		unmount()
+
+		isDestroyed = true
+
+		widget:Destroy()
+		toolbar:Destroy()
+		button:Destroy()
+		root = nil :: any
+		loader = nil :: any
+	end
+
+	return {
+		mount = mount,
+		unmount = unmount,
+		destroy = destroy,
+	}
+end
+
+return createFlipbookPlugin
diff --git a/src/Plugin/createToggleButton.luau b/src/Plugin/createToggleButton.luau
deleted file mode 100644
index 5519493a..00000000
--- a/src/Plugin/createToggleButton.luau
+++ /dev/null
@@ -1,26 +0,0 @@
---[[
-    Creates the button to toggle the plugin widget.
-
-    This function also sets up some events to toggle the widget when the button
-    is clicked, and to sync up the button's "active" state with the widget.
-
-	@return () -> () -- Returns a callback for disconnecting button events
-]]
-local function createToggleButton(toolbar: PluginToolbar, widget: DockWidgetPluginGui)
-	local button = toolbar:CreateButton(widget.Name, "Open story view", "rbxassetid://10277153751")
-
-	local click = button.Click:Connect(function()
-		widget.Enabled = not widget.Enabled
-	end)
-
-	local enabled = widget:GetPropertyChangedSignal("Enabled"):Connect(function()
-		button:SetActive(widget.Enabled)
-	end)
-
-	return function()
-		click:Disconnect()
-		enabled:Disconnect()
-	end
-end
-
-return createToggleButton
diff --git a/src/Plugin/createWidget.luau b/src/Plugin/createWidget.luau
deleted file mode 100644
index d4e8e1a6..00000000
--- a/src/Plugin/createWidget.luau
+++ /dev/null
@@ -1,12 +0,0 @@
-local function createWidget(plugin: Plugin, name: string): DockWidgetPluginGui
-	local info = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Top, true)
-
-	local widget = plugin:CreateDockWidgetPluginGui(name, info)
-	widget.Name = name
-	widget.Title = name
-	widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
-
-	return widget
-end
-
-return createWidget
diff --git a/src/init.server.luau b/src/init.server.luau
index 3cbe3155..a667783b 100644
--- a/src/init.server.luau
+++ b/src/init.server.luau
@@ -4,49 +4,11 @@ if RunService:IsRunning() or not RunService:IsEdit() then
 	return
 end
 
-local ModuleLoader = require("@pkg/ModuleLoader")
-local React = require("@pkg/React")
-local ReactRoblox = require("@pkg/ReactRoblox")
-
-local ContextProviders = require("@root/Common/ContextProviders")
-local PluginApp = require("@root/Plugin/PluginApp")
-local createToggleButton = require("@root/Plugin/createToggleButton")
-local createWidget = require("@root/Plugin/createWidget")
+local createFlipbookPlugin = require("@root/Plugin/createFlipbookPlugin")
 
 local PLUGIN_NAME = "flipbook"
 
 local toolbar = plugin:CreateToolbar(PLUGIN_NAME)
-local widget = createWidget(plugin, PLUGIN_NAME)
-local root = ReactRoblox.createRoot(widget)
-local disconnectButton = createToggleButton(toolbar, widget)
-
-local loader = ModuleLoader.new()
-
-local app = React.createElement(ContextProviders, {
-	plugin = plugin,
-}, {
-	PluginApp = React.createElement(PluginApp, {
-		loader = loader,
-	}),
-})
-
-local widgetConn = widget:GetPropertyChangedSignal("Enabled"):Connect(function()
-	if widget.Enabled then
-		root:render(app)
-	else
-		root:unmount()
-		loader:clear()
-	end
-end)
-
-if widget.Enabled then
-	root:render(app)
-end
-
-plugin.Unloading:Connect(function()
-	disconnectButton()
-	widgetConn:Disconnect()
+local flipbookPlugin = createFlipbookPlugin(PLUGIN_NAME, plugin, toolbar)
 
-	root:unmount()
-	loader:clear()
-end)
+flipbookPlugin.mount()

From ac95f13a88cc17fc291ea38ee20e32867450ed63 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 12 Dec 2024 15:16:01 -0800
Subject: [PATCH 30/79] Support reloading with the wrapper

---
 src/Plugin/createFlipbookPlugin.luau | 81 ++++++++--------------------
 src/init.server.luau                 |  2 +-
 2 files changed, 23 insertions(+), 60 deletions(-)

diff --git a/src/Plugin/createFlipbookPlugin.luau b/src/Plugin/createFlipbookPlugin.luau
index 80af5315..e72b1989 100644
--- a/src/Plugin/createFlipbookPlugin.luau
+++ b/src/Plugin/createFlipbookPlugin.luau
@@ -18,12 +18,7 @@ local function createFlipbookPlugin(
 ): {
 	mount: () -> (),
 	unmount: () -> (),
-	destroy: () -> (),
 }
-	local isDestroyed = false
-
-	local connections: { RBXScriptConnection } = {}
-
 	local info = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Top, true)
 
 	local widget = plugin:CreateDockWidgetPluginGui(name, info)
@@ -35,78 +30,46 @@ local function createFlipbookPlugin(
 	local loader = ModuleLoader.new()
 	local button = toolbar:CreateButton(name, "Open story view", "rbxassetid://10277153751")
 
-	local function unmount()
-		assert(not isDestroyed, "cannot call unmount (Flipbook plugin was destroyed)")
+	local app = React.createElement(ContextProviders, {
+		plugin = plugin,
+	}, {
+		PluginApp = React.createElement(PluginApp, {
+			loader = loader,
+		}),
+	})
 
+	local function unmount()
 		root:unmount()
 		loader:clear()
-
-		for _, connection in connections do
-			connection:Disconnect()
-		end
 	end
 
 	local function mount()
-		assert(not isDestroyed, "cannot call mount (Flipbook plugin was destroyed)")
-
-		local app = React.createElement(ContextProviders, {
-			plugin = plugin,
-		}, {
-			PluginApp = React.createElement(PluginApp, {
-				loader = loader,
-			}),
-		})
-
-		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)
-		)
+		root:render(app)
+	end
 
-		table.insert(
-			connections,
-			widget:GetPropertyChangedSignal("Enabled"):Connect(function()
-				if widget.Enabled then
-					root:render(app)
-				else
-					unmount()
-				end
-			end)
-		)
+	button.Click:Connect(function()
+		widget.Enabled = not widget.Enabled
+	end)
 
-		table.insert(connections, plugin.Unloading:Connect(unmount))
+	widget:GetPropertyChangedSignal("Enabled"):Connect(function()
+		button:SetActive(widget.Enabled)
+	end)
 
+	widget:GetPropertyChangedSignal("Enabled"):Connect(function()
 		if widget.Enabled then
 			root:render(app)
+		else
+			unmount()
 		end
-	end
-
-	local function destroy()
-		assert(not isDestroyed, "cannot call destroy (Flipbook plugin was already destroyed)")
-
-		unmount()
-
-		isDestroyed = true
+	end)
 
-		widget:Destroy()
-		toolbar:Destroy()
-		button:Destroy()
-		root = nil :: any
-		loader = nil :: any
+	if widget.Enabled then
+		mount()
 	end
 
 	return {
 		mount = mount,
 		unmount = unmount,
-		destroy = destroy,
 	}
 end
 
diff --git a/src/init.server.luau b/src/init.server.luau
index a667783b..35df6a1d 100644
--- a/src/init.server.luau
+++ b/src/init.server.luau
@@ -11,4 +11,4 @@ local PLUGIN_NAME = "flipbook"
 local toolbar = plugin:CreateToolbar(PLUGIN_NAME)
 local flipbookPlugin = createFlipbookPlugin(PLUGIN_NAME, plugin, toolbar)
 
-flipbookPlugin.mount()
+plugin.Unloading:Connect(flipbookPlugin.unmount)

From 8eba4a3ee90c2ddf804910123010c8577cd5087a Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 12 Dec 2024 16:41:45 -0800
Subject: [PATCH 31/79] Fix Flipbook crashing when attempting to open to a
 story in a restricted service

---
 src/Common/getInstanceFromFullName.luau | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/src/Common/getInstanceFromFullName.luau b/src/Common/getInstanceFromFullName.luau
index e66aa8e6..a0e0d31a 100644
--- a/src/Common/getInstanceFromFullName.luau
+++ b/src/Common/getInstanceFromFullName.luau
@@ -10,12 +10,20 @@ local Sift = require("@pkg/Sift")
 
 local PATH_SEPERATOR = "."
 
+local function canAccess(instance: Instance): boolean
+	local success = pcall(function()
+		return instance.Name
+	end)
+
+	return success
+end
+
 local function maybeGetService(serviceName: string): Instance?
 	local success, current: any = pcall(function()
 		return game:GetService(serviceName)
 	end)
 
-	if success and current and current:IsA("Instance") then
+	if success and current and canAccess(current) and current:IsA("Instance") then
 		return current
 	else
 		return nil

From 5f08f2b3b47b2967ab8cea91383b0d6bd6968d86 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Mon, 16 Dec 2024 10:54:19 -0800
Subject: [PATCH 32/79] Fix infinite loop

---
 src/Common/getInstanceFromFullName.luau | 17 +++++++++++++++--
 1 file changed, 15 insertions(+), 2 deletions(-)

diff --git a/src/Common/getInstanceFromFullName.luau b/src/Common/getInstanceFromFullName.luau
index a0e0d31a..1574c4d6 100644
--- a/src/Common/getInstanceFromFullName.luau
+++ b/src/Common/getInstanceFromFullName.luau
@@ -41,6 +41,11 @@ local function getInstanceFromFullName(fullName: string): Instance?
 		local current = maybeGetService(serviceName)
 
 		if current then
+			-- TODO: Verify this isn't also needed with the below TODO
+			-- if #parts == 1 then
+			-- 	return current
+			-- end
+
 			while #parts > 0 do
 				-- Keep around a copy of the `parts` array. We are going to concat this
 				-- into new paths, and incrementally remove from the right to narrow
@@ -60,8 +65,16 @@ local function getInstanceFromFullName(fullName: string): Instance?
 						parts = Sift.List.shift(parts, #name:split(PATH_SEPERATOR))
 						break
 					else
-						-- Reduce from the right until we find the next instance
-						tempParts = Sift.List.pop(tempParts)
+						if #tempParts > 1 then
+							-- TODO: Need this early exit to handle cases with instances not existing later on. Verify
+							-- if this can be effectively repro'd by calling this function on an instance that doesn't
+							-- exist. Particularly make sure to test look-aheads for `Foo.story`, it seemed like the
+							-- loop got stuck looking for "story" when "ChromeWindow.story" didn't exist
+							return nil
+						else
+							-- Reduce from the right until we find the next instance
+							tempParts = Sift.List.pop(tempParts)
+						end
 					end
 				end
 			end

From 0d1acbd9d6749982495e06b5d12de661d8f03db4 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Mon, 16 Dec 2024 11:11:50 -0800
Subject: [PATCH 33/79] Manually build Plugin instances to handle reloading
 better

---
 src/Plugin/createFlipbookPlugin.luau | 57 +++++++++++++++++-----------
 src/init.server.luau                 | 12 +++++-
 2 files changed, 45 insertions(+), 24 deletions(-)

diff --git a/src/Plugin/createFlipbookPlugin.luau b/src/Plugin/createFlipbookPlugin.luau
index e72b1989..d19ba68f 100644
--- a/src/Plugin/createFlipbookPlugin.luau
+++ b/src/Plugin/createFlipbookPlugin.luau
@@ -12,23 +12,16 @@ local ContextProviders = require("@root/Common/ContextProviders")
 local PluginApp = require("@root/Plugin/PluginApp")
 
 local function createFlipbookPlugin(
-	name: string,
 	plugin: Plugin,
-	toolbar: PluginToolbar
+	widget: DockWidgetPluginGui,
+	button: PluginToolbarButton
 ): {
 	mount: () -> (),
 	unmount: () -> (),
 }
-	local info = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Top, true)
-
-	local widget = plugin:CreateDockWidgetPluginGui(name, info)
-	widget.Name = name
-	widget.Title = name
-	widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
-
+	local connections: { RBXScriptConnection } = {}
 	local root = ReactRoblox.createRoot(widget)
 	local loader = ModuleLoader.new()
-	local button = toolbar:CreateButton(name, "Open story view", "rbxassetid://10277153751")
 
 	local app = React.createElement(ContextProviders, {
 		plugin = plugin,
@@ -47,29 +40,47 @@ local function createFlipbookPlugin(
 		root:render(app)
 	end
 
-	button.Click:Connect(function()
-		widget.Enabled = not widget.Enabled
-	end)
+	table.insert(
+		connections,
+		button.Click:Connect(function()
+			widget.Enabled = not widget.Enabled
+		end)
+	)
 
-	widget:GetPropertyChangedSignal("Enabled"):Connect(function()
-		button:SetActive(widget.Enabled)
-	end)
+	table.insert(
+		connections,
+		widget:GetPropertyChangedSignal("Enabled"):Connect(function()
+			button:SetActive(widget.Enabled)
+		end)
+	)
 
-	widget:GetPropertyChangedSignal("Enabled"):Connect(function()
-		if widget.Enabled then
-			root:render(app)
-		else
-			unmount()
-		end
-	end)
+	table.insert(
+		connections,
+		widget:GetPropertyChangedSignal("Enabled"):Connect(function()
+			if widget.Enabled then
+				root:render(app)
+			else
+				unmount()
+			end
+		end)
+	)
 
 	if widget.Enabled then
 		mount()
 	end
 
+	local function destroy()
+		print("destroy")
+		unmount()
+		for _, connection in connections do
+			connection:Disconnect()
+		end
+	end
+
 	return {
 		mount = mount,
 		unmount = unmount,
+		destroy = destroy,
 	}
 end
 
diff --git a/src/init.server.luau b/src/init.server.luau
index 35df6a1d..9e44fd69 100644
--- a/src/init.server.luau
+++ b/src/init.server.luau
@@ -9,6 +9,16 @@ local createFlipbookPlugin = require("@root/Plugin/createFlipbookPlugin")
 local PLUGIN_NAME = "flipbook"
 
 local toolbar = plugin:CreateToolbar(PLUGIN_NAME)
-local flipbookPlugin = createFlipbookPlugin(PLUGIN_NAME, plugin, toolbar)
+
+local info = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Top, true)
+
+local widget = plugin:CreateDockWidgetPluginGui(PLUGIN_NAME, info)
+widget.Name = PLUGIN_NAME
+widget.Title = PLUGIN_NAME
+widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
+
+local button = toolbar:CreateButton(PLUGIN_NAME, "Open story view", "rbxassetid://10277153751")
+
+local flipbookPlugin = createFlipbookPlugin(plugin, widget, button)
 
 plugin.Unloading:Connect(flipbookPlugin.unmount)

From 471b80f92958ac43b686b06743983e7e1c0e7e26 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Mon, 16 Dec 2024 13:16:40 -0800
Subject: [PATCH 34/79] Full error logging with line numbers

---
 src/Storybook/StoryError.luau | 50 ++++++++++++++++++++++++++++++-----
 1 file changed, 43 insertions(+), 7 deletions(-)

diff --git a/src/Storybook/StoryError.luau b/src/Storybook/StoryError.luau
index e4012bd1..75a15d86 100644
--- a/src/Storybook/StoryError.luau
+++ b/src/Storybook/StoryError.luau
@@ -1,4 +1,7 @@
 local React = require("@pkg/React")
+local Sift = require("@pkg/Sift")
+
+local ScrollingFrame = require("@root/Common/ScrollingFrame")
 local SelectableTextLabel = require("@root/Forms/SelectableTextLabel")
 local useTheme = require("@root/Common/useTheme")
 
@@ -10,16 +13,49 @@ export type Props = {
 local function StoryError(props: Props)
 	local theme = useTheme()
 
-	return React.createElement(SelectableTextLabel, {
+	local lineNumbers = Sift.List.reduce(props.err:split("\n"), function(accumulator, _item, index)
+		return if index == 1 then tostring(index) else `{accumulator}\n{index}`
+	end, "")
+
+	return React.createElement(ScrollingFrame, {
+		ScrollingDirection = Enum.ScrollingDirection.XY,
 		LayoutOrder = props.layoutOrder,
-		Text = props.err,
-		TextColor3 = theme.alert,
 	}, {
+		Layout = React.createElement("UIListLayout", {
+			SortOrder = Enum.SortOrder.LayoutOrder,
+			FillDirection = Enum.FillDirection.Horizontal,
+			Padding = theme.padding,
+		}),
+
 		Padding = React.createElement("UIPadding", {
-			PaddingTop = UDim.new(0, 8),
-			PaddingRight = UDim.new(0, 8),
-			PaddingBottom = UDim.new(0, 8),
-			PaddingLeft = UDim.new(0, 8),
+			PaddingTop = theme.paddingSmall,
+			PaddingRight = theme.paddingSmall,
+			PaddingBottom = theme.paddingSmall,
+			PaddingLeft = theme.paddingSmall,
+		}),
+
+		LineNumbers = React.createElement("TextLabel", {
+			LayoutOrder = 1,
+			AutomaticSize = Enum.AutomaticSize.XY,
+			Text = lineNumbers,
+			TextSize = theme.textSize,
+			LineHeight = 1,
+			BackgroundTransparency = 1,
+			Font = Enum.Font.RobotoMono,
+			TextColor3 = theme.textFaded,
+			TextXAlignment = Enum.TextXAlignment.Right,
+		}),
+
+		ErrorMessage = React.createElement(SelectableTextLabel, {
+			LayoutOrder = 2,
+			Size = UDim2.fromScale(1, 0),
+			AutomaticSize = Enum.AutomaticSize.Y,
+			Text = props.err,
+			TextColor3 = theme.alert,
+			TextSize = theme.textSize,
+			TextWrapped = false,
+			LineHeight = 1,
+			Font = Enum.Font.RobotoMono,
 		}),
 	})
 end

From 2a460caeb8fcfe8f515c5116261546a299d85a3d Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Mon, 16 Dec 2024 13:21:23 -0800
Subject: [PATCH 35/79] Last opened story gets remembered again

---
 src/Common/getInstanceFromFullName.luau      | 2 +-
 src/Common/getInstanceFromFullName.spec.luau | 5 +++++
 2 files changed, 6 insertions(+), 1 deletion(-)

diff --git a/src/Common/getInstanceFromFullName.luau b/src/Common/getInstanceFromFullName.luau
index 1574c4d6..1486fe32 100644
--- a/src/Common/getInstanceFromFullName.luau
+++ b/src/Common/getInstanceFromFullName.luau
@@ -65,7 +65,7 @@ local function getInstanceFromFullName(fullName: string): Instance?
 						parts = Sift.List.shift(parts, #name:split(PATH_SEPERATOR))
 						break
 					else
-						if #tempParts > 1 then
+						if #tempParts == 1 then
 							-- TODO: Need this early exit to handle cases with instances not existing later on. Verify
 							-- if this can be effectively repro'd by calling this function on an instance that doesn't
 							-- exist. Particularly make sure to test look-aheads for `Foo.story`, it seemed like the
diff --git a/src/Common/getInstanceFromFullName.spec.luau b/src/Common/getInstanceFromFullName.spec.luau
index d8f9d153..930fa8df 100644
--- a/src/Common/getInstanceFromFullName.spec.luau
+++ b/src/Common/getInstanceFromFullName.spec.luau
@@ -68,3 +68,8 @@ end)
 test("returns nil if the first part of the path is not a service", function()
 	expect(getInstanceFromFullName("Part")).toBeUndefined()
 end)
+
+test("returns nil when instance with an extension does not exist", function()
+	expect(getInstanceFromFullName("foo.story")).toBeUndefined()
+	expect(getInstanceFromFullName("Path.To.Foo.story")).toBeUndefined()
+end)

From e1678054bed6580e0d908c2d16366d9174a4c6bc Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Mon, 16 Dec 2024 13:40:51 -0800
Subject: [PATCH 36/79] Fix dropdown controls not changing visually

---
 src/Storybook/StoryControls.luau | 10 ++++++----
 src/Storybook/StoryView.luau     | 23 +++++------------------
 2 files changed, 11 insertions(+), 22 deletions(-)

diff --git a/src/Storybook/StoryControls.luau b/src/Storybook/StoryControls.luau
index 213c0912..49b17fcb 100644
--- a/src/Storybook/StoryControls.luau
+++ b/src/Storybook/StoryControls.luau
@@ -10,7 +10,8 @@ local useMemo = React.useMemo
 local e = React.createElement
 
 type Props = {
-	controls: { [string]: any },
+	controlsSchema: { [string]: any },
+	changedControls: { [string]: any },
 	setControl: (key: string, value: any) -> (),
 	layoutOrder: number?,
 }
@@ -21,7 +22,7 @@ local function StoryControls(props: Props)
 	local sortedControls: { { name: string, value: any } } = useMemo(function()
 		local result = {}
 
-		for _, entry in Sift.Dictionary.entries(props.controls) do
+		for _, entry in Sift.Dictionary.entries(props.controlsSchema) do
 			table.insert(result, {
 				name = entry[1],
 				value = entry[2],
@@ -31,7 +32,7 @@ local function StoryControls(props: Props)
 		return Sift.List.sort(result, function(a, b)
 			return a.name < b.name
 		end)
-	end, { props.controls })
+	end, { props.controlsSchema })
 
 	local controlElements: { [string]: React.Node } = {}
 	for index, control in sortedControls do
@@ -52,8 +53,9 @@ local function StoryControls(props: Props)
 				onStateChange = setControl,
 			})
 		elseif controlType == "table" then
+			local default = props.changedControls[control.name]
 			option = React.createElement(Dropdown, {
-				default = control.value[1],
+				default = if default then default else control.value[1],
 				options = control.value,
 				onOptionChange = setControl,
 			})
diff --git a/src/Storybook/StoryView.luau b/src/Storybook/StoryView.luau
index 0f0ab3d1..6f4db7a8 100644
--- a/src/Storybook/StoryView.luau
+++ b/src/Storybook/StoryView.luau
@@ -45,23 +45,9 @@ local function StoryView(props: Props)
 		setChangedControls({})
 	end, { story })
 
-	local controlsWithUserOverrides = React.useMemo(function()
-		local controls = {}
-		if story and story.controls then
-			for key, value in story.controls do
-				local override = changedControls[key]
-
-				if override ~= nil and typeof(value) ~= "table" then
-					controls[key] = override
-				else
-					controls[key] = value
-				end
-			end
-		end
-		return controls
-	end, { story, changedControls } :: { unknown })
+	local controlsSchema = if story then story.controls else nil
 
-	local showControls = controlsWithUserOverrides and not Sift.isEmpty(controlsWithUserOverrides)
+	local showControls = controlsSchema and not Sift.isEmpty(controlsSchema)
 
 	local setControl = React.useCallback(function(control: string, newValue: any)
 		setChangedControls(function(prev)
@@ -176,7 +162,7 @@ local function StoryView(props: Props)
 				StoryPreview = e(StoryPreview, {
 					zoom = zoom.value,
 					story = story,
-					controls = Sift.Dictionary.merge(controlsWithUserOverrides, changedControls),
+					controls = Sift.Dictionary.merge(controlsSchema, changedControls),
 					storyModule = props.story,
 					isMountedInViewport = isMountedInViewport,
 					ref = storyParentRef,
@@ -199,7 +185,8 @@ local function StoryView(props: Props)
 					BackgroundColor3 = theme.sidebar,
 				}, {
 					StoryControls = e(StoryControls, {
-						controls = controlsWithUserOverrides,
+						controlsSchema = controlsSchema,
+						changedControls = changedControls,
 						setControl = setControl,
 					}),
 				}),

From 829b34c738466dc3d3e08f8d286b701976720672 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Mon, 16 Dec 2024 14:36:34 -0800
Subject: [PATCH 37/79] Display unavailable storybooks

---
 src/Panels/Sidebar.luau              |  5 ++++-
 src/Plugin/PluginApp.luau            |  2 +-
 src/Storybook/StorybookTreeView.luau | 27 ++++++++++++++++++++++++---
 3 files changed, 29 insertions(+), 5 deletions(-)

diff --git a/src/Panels/Sidebar.luau b/src/Panels/Sidebar.luau
index f58b76b3..857b10f7 100644
--- a/src/Panels/Sidebar.luau
+++ b/src/Panels/Sidebar.luau
@@ -14,7 +14,10 @@ local e = React.createElement
 type Props = {
 	layoutOrder: number?,
 	onStoryChanged: (storyModule: ModuleScript?, storybook: LoadedStorybook?) -> (),
-	storybooks: { LoadedStorybook },
+	storybooks: {
+		avialable: { LoadedStorybook },
+		unavailable: { UnavailableStorybook },
+	},
 }
 
 local function Sidebar(props: Props)
diff --git a/src/Plugin/PluginApp.luau b/src/Plugin/PluginApp.luau
index 53877344..22a5ff67 100644
--- a/src/Plugin/PluginApp.luau
+++ b/src/Plugin/PluginApp.luau
@@ -67,7 +67,7 @@ local function App(props: Props)
 		}, {
 			Sidebar = React.createElement(Sidebar, {
 				onStoryChanged = onStoryChanged,
-				storybooks = storybooks.available,
+				storybooks = storybooks,
 			}),
 		}),
 
diff --git a/src/Storybook/StorybookTreeView.luau b/src/Storybook/StorybookTreeView.luau
index 13a3c4e5..87d48d37 100644
--- a/src/Storybook/StorybookTreeView.luau
+++ b/src/Storybook/StorybookTreeView.luau
@@ -1,3 +1,5 @@
+local HttpService = game:GetService("HttpService")
+
 local React = require("@pkg/React")
 local Storyteller = require("@pkg/Storyteller")
 local TreeView = require("@root/TreeView")
@@ -8,13 +10,17 @@ local usePrevious = require("@root/Common/usePrevious")
 
 type TreeNode = TreeView.TreeNode
 type LoadedStorybook = Storyteller.LoadedStorybook
+type UnavailableStorybook = Storyteller.UnavailableStorybook
 
 local useEffect = React.useEffect
 local useRef = React.useRef
 
 export type Props = {
 	searchTerm: string?,
-	storybooks: { LoadedStorybook },
+	storybooks: {
+		avialable: { LoadedStorybook },
+		unavailable: { UnavailableStorybook },
+	},
 	onStoryChanged: ((storyModule: ModuleScript?, storybook: LoadedStorybook?) -> ())?,
 	layoutOrder: number?,
 }
@@ -30,17 +36,32 @@ local function StorybookTreeView(props: Props)
 	useEffect(function()
 		storybookByNodeId.current = {}
 		local roots: { TreeNode } = {}
-		for _, storybook in props.storybooks do
+		for _, storybook in props.storybooks.available do
 			local root = createTreeNodesForStorybook(storybook)
 			table.insert(roots, root)
 			storybookByNodeId.current[root.id] = storybook
 		end
+
+		local unavailableStorybooks: TreeNode = {
+			id = HttpService:GenerateGUID(),
+			label = "Unavailable Storybooks",
+			icon = "folder",
+			isExpanded = false,
+			children = {},
+		}
+		for _, unavailableStorybook in props.storybooks.unavailable do
+			local root = createTreeNodesForStorybook(unavailableStorybook.storybook)
+			table.insert(unavailableStorybooks.children, root)
+			storybookByNodeId.current[root.id] = unavailableStorybook.storybook
+		end
+		table.insert(roots, unavailableStorybooks)
+
 		treeViewContext.setRoots(roots)
 
 		return function()
 			treeViewContext.setRoots({})
 		end
-	end, { props.storybooks, treeViewContext.setRoots } :: { unknown })
+	end, { props.storybooks.available, props.storybooks.unavailable, treeViewContext.setRoots } :: { unknown })
 
 	useEffect(function()
 		treeViewContext.search(props.searchTerm)

From eb0230d13c0464d94ea00f0e3ac4a36a7ccbf5bb Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Mon, 16 Dec 2024 15:02:53 -0800
Subject: [PATCH 38/79] Only show unavailable storybooks if there are any

---
 src/Storybook/StorybookTreeView.luau | 26 ++++++++++++++------------
 1 file changed, 14 insertions(+), 12 deletions(-)

diff --git a/src/Storybook/StorybookTreeView.luau b/src/Storybook/StorybookTreeView.luau
index 87d48d37..ad11aad4 100644
--- a/src/Storybook/StorybookTreeView.luau
+++ b/src/Storybook/StorybookTreeView.luau
@@ -42,19 +42,21 @@ local function StorybookTreeView(props: Props)
 			storybookByNodeId.current[root.id] = storybook
 		end
 
-		local unavailableStorybooks: TreeNode = {
-			id = HttpService:GenerateGUID(),
-			label = "Unavailable Storybooks",
-			icon = "folder",
-			isExpanded = false,
-			children = {},
-		}
-		for _, unavailableStorybook in props.storybooks.unavailable do
-			local root = createTreeNodesForStorybook(unavailableStorybook.storybook)
-			table.insert(unavailableStorybooks.children, root)
-			storybookByNodeId.current[root.id] = unavailableStorybook.storybook
+		if #props.storybooks.unavailable > 0 then
+			local unavailableStorybooks: TreeNode = {
+				id = HttpService:GenerateGUID(),
+				label = "Unavailable Storybooks",
+				icon = "folder",
+				isExpanded = false,
+				children = {},
+			}
+			for _, unavailableStorybook in props.storybooks.unavailable do
+				local root = createTreeNodesForStorybook(unavailableStorybook.storybook)
+				table.insert(unavailableStorybooks.children, root)
+				storybookByNodeId.current[root.id] = unavailableStorybook.storybook
+			end
+			table.insert(roots, unavailableStorybooks)
 		end
-		table.insert(roots, unavailableStorybooks)
 
 		treeViewContext.setRoots(roots)
 

From dc72958bec8dbe38c99ca8cacd11b478fda0482c Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Mon, 16 Dec 2024 16:26:03 -0800
Subject: [PATCH 39/79] Add a button to create the first storybook, story, and
 component

---
 src/Panels/Sidebar.luau                       | 17 +++++++++++--
 .../OnboardingTemplate/ComponentTemplate.luau | 17 +++++++++++++
 .../OnboardingTemplate/StoryTemplate.luau     | 18 ++++++++++++++
 .../OnboardingTemplate/StorybookTemplate.luau |  5 ++++
 src/Storybook/createOnboardingStorybook.luau  | 24 +++++++++++++++++++
 5 files changed, 79 insertions(+), 2 deletions(-)
 create mode 100644 src/Storybook/OnboardingTemplate/ComponentTemplate.luau
 create mode 100644 src/Storybook/OnboardingTemplate/StoryTemplate.luau
 create mode 100644 src/Storybook/OnboardingTemplate/StorybookTemplate.luau
 create mode 100644 src/Storybook/createOnboardingStorybook.luau

diff --git a/src/Panels/Sidebar.luau b/src/Panels/Sidebar.luau
index 857b10f7..f8f2d900 100644
--- a/src/Panels/Sidebar.luau
+++ b/src/Panels/Sidebar.luau
@@ -2,9 +2,12 @@ local React = require("@pkg/React")
 local Storyteller = require("@pkg/Storyteller")
 
 local Branding = require("@root/Common/Branding")
+local Button = require("@root/Forms/Button")
 local ScrollingFrame = require("@root/Common/ScrollingFrame")
 local Searchbar = require("@root/Forms/Searchbar")
 local StorybookTreeView = require("@root/Storybook/StorybookTreeView")
+local createOnboardingStorybook = require("@root/Storybook/createOnboardingStorybook")
+local nextLayoutOrder = require("@root/Common/nextLayoutOrder")
 local useTheme = require("@root/Common/useTheme")
 
 type LoadedStorybook = Storyteller.LoadedStorybook
@@ -58,7 +61,7 @@ local function Sidebar(props: Props)
 		Header = e("Frame", {
 			AutomaticSize = Enum.AutomaticSize.Y,
 			BackgroundTransparency = 1,
-			LayoutOrder = 0,
+			LayoutOrder = nextLayoutOrder(),
 			Size = UDim2.fromScale(1, 0),
 			[React.Change.AbsoluteSize] = onHeaderSizeChanged,
 		}, {
@@ -77,8 +80,18 @@ local function Sidebar(props: Props)
 			}),
 		}),
 
+		CreateStorybook = if #props.storybooks.available == 0
+			then React.createElement(Button, {
+				layoutOrder = nextLayoutOrder(),
+				text = "Create Storybook",
+				onClick = function()
+					createOnboardingStorybook(game.ReplicatedStorage)
+				end,
+			})
+			else nil,
+
 		ScrollingFrame = e(ScrollingFrame, {
-			LayoutOrder = 1,
+			LayoutOrder = nextLayoutOrder(),
 			Size = UDim2.fromScale(1, 1) - UDim2.fromOffset(0, headerHeight),
 		}, {
 			StorybookTreeView = e(StorybookTreeView, {
diff --git a/src/Storybook/OnboardingTemplate/ComponentTemplate.luau b/src/Storybook/OnboardingTemplate/ComponentTemplate.luau
new file mode 100644
index 00000000..c820ce8e
--- /dev/null
+++ b/src/Storybook/OnboardingTemplate/ComponentTemplate.luau
@@ -0,0 +1,17 @@
+export type Props = {
+	nameToGreet: string,
+}
+
+local function HelloWorld(props: Props)
+	local label = Instance.new("TextLabel")
+	label.Text = `Hello, {props.nameToGreet}!`
+	label.AutomaticSize = Enum.AutomaticSize.XY
+	label.BackgroundTransparency = 1
+	label.TextColor3 = Color3.fromRGB(255, 255, 255)
+	label.TextSize = 24
+	label.Font = Enum.Font.BuilderSansMedium
+
+	return label
+end
+
+return HelloWorld
diff --git a/src/Storybook/OnboardingTemplate/StoryTemplate.luau b/src/Storybook/OnboardingTemplate/StoryTemplate.luau
new file mode 100644
index 00000000..aa9a465e
--- /dev/null
+++ b/src/Storybook/OnboardingTemplate/StoryTemplate.luau
@@ -0,0 +1,18 @@
+local HelloWorld = require(script.Parent.HelloWorld)
+
+local controls = {
+	nameToGreet = "World",
+}
+
+type Props = {
+	controls: typeof(controls),
+}
+
+return {
+	controls = controls,
+	story = function(props: Props)
+		return HelloWorld({
+			nameToGreet = props.controls.nameToGreet,
+		})
+	end,
+}
diff --git a/src/Storybook/OnboardingTemplate/StorybookTemplate.luau b/src/Storybook/OnboardingTemplate/StorybookTemplate.luau
new file mode 100644
index 00000000..d42a9f59
--- /dev/null
+++ b/src/Storybook/OnboardingTemplate/StorybookTemplate.luau
@@ -0,0 +1,5 @@
+return {
+	storyRoots = {
+		script.Parent.Components,
+	},
+}
diff --git a/src/Storybook/createOnboardingStorybook.luau b/src/Storybook/createOnboardingStorybook.luau
new file mode 100644
index 00000000..4cc4393c
--- /dev/null
+++ b/src/Storybook/createOnboardingStorybook.luau
@@ -0,0 +1,24 @@
+local STORYBOOK_TEMPLATE = script.Parent.OnboardingTemplate["StorybookTemplate"]
+local STORY_TEMPLATE = script.Parent.OnboardingTemplate["StoryTemplate"]
+local COMPONENT_TEMPLATE = script.Parent.OnboardingTemplate["ComponentTemplate"]
+
+local function createOnboardingStorybook(parent: Instance)
+	local components = Instance.new("Folder")
+	components.Name = "Components"
+
+	local component = COMPONENT_TEMPLATE:Clone()
+	component.Name = "HelloWorld"
+	component.Parent = components
+
+	local story = STORY_TEMPLATE:Clone()
+	story.Name = "HelloWorld.story"
+	story.Parent = components
+
+	local storybookModule = STORYBOOK_TEMPLATE:Clone()
+	storybookModule.Name = `{string.gsub(game.Name, "%.", "_")}.storybook`
+
+	components.Parent = parent
+	storybookModule.Parent = parent
+end
+
+return createOnboardingStorybook

From 08329ff3fd63938e95de6a04af9228d5c975b807 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Tue, 17 Dec 2024 11:01:20 -0800
Subject: [PATCH 40/79] Story pinning almost works, gonna come back to this
 later

---
 src/Storybook/StorybookTreeView.luau          | 38 +++++++-
 .../createTreeNodesForStorybook.luau          |  1 +
 src/TreeView/TreeNode.luau                    | 23 ++++-
 src/TreeView/TreeViewContext.luau             |  2 +-
 src/TreeView/usePinnedInstances.luau          | 93 +++++++++++++++++++
 5 files changed, 154 insertions(+), 3 deletions(-)
 create mode 100644 src/TreeView/usePinnedInstances.luau

diff --git a/src/Storybook/StorybookTreeView.luau b/src/Storybook/StorybookTreeView.luau
index ad11aad4..2ff00dd9 100644
--- a/src/Storybook/StorybookTreeView.luau
+++ b/src/Storybook/StorybookTreeView.luau
@@ -6,6 +6,7 @@ local TreeView = require("@root/TreeView")
 
 local createTreeNodesForStorybook = require("@root/Storybook/createTreeNodesForStorybook")
 local useLastOpenedStory = require("@root/Storybook/useLastOpenedStory")
+local usePinnedInstances = require("@root/TreeView/usePinnedInstances")
 local usePrevious = require("@root/Common/usePrevious")
 
 type TreeNode = TreeView.TreeNode
@@ -32,10 +33,43 @@ local function StorybookTreeView(props: Props)
 	local prevSelectedNode = usePrevious(selectedNode)
 	local storybookByNodeId = useRef({} :: { [string]: LoadedStorybook })
 	local lastOpenedStory, setLastOpenedStory = useLastOpenedStory()
+	local pinning = usePinnedInstances()
 
 	useEffect(function()
-		storybookByNodeId.current = {}
 		local roots: { TreeNode } = {}
+
+		local pinnedInstances = pinning.getPinnedInstances()
+		if #pinnedInstances > 0 then
+			local pins: TreeNode = {
+				id = HttpService:GenerateGUID(),
+				label = "Starred",
+				icon = "folder",
+				isExpanded = false,
+				children = {},
+			}
+
+			for _, pinnedInstance in pinnedInstances do
+				local node: { TreeNode }
+				if pinnedInstance.instance then
+					node = treeViewContext.getNodeByInstance(pinnedInstance.instance)
+				end
+
+				if not node then
+					node = {
+						id = HttpService:GenerateGUID(),
+						label = `ERR: {pinnedInstance.path}`,
+						icon = "folder", -- TODO: Use an error icon
+						isExpanded = true,
+						children = {},
+					}
+				end
+
+				table.insert(pins.children, node)
+			end
+
+			table.insert(roots, pins)
+		end
+
 		for _, storybook in props.storybooks.available do
 			local root = createTreeNodesForStorybook(storybook)
 			table.insert(roots, root)
@@ -50,11 +84,13 @@ local function StorybookTreeView(props: Props)
 				isExpanded = false,
 				children = {},
 			}
+
 			for _, unavailableStorybook in props.storybooks.unavailable do
 				local root = createTreeNodesForStorybook(unavailableStorybook.storybook)
 				table.insert(unavailableStorybooks.children, root)
 				storybookByNodeId.current[root.id] = unavailableStorybook.storybook
 			end
+
 			table.insert(roots, unavailableStorybooks)
 		end
 
diff --git a/src/Storybook/createTreeNodesForStorybook.luau b/src/Storybook/createTreeNodesForStorybook.luau
index ed061634..49968f23 100644
--- a/src/Storybook/createTreeNodesForStorybook.luau
+++ b/src/Storybook/createTreeNodesForStorybook.luau
@@ -46,6 +46,7 @@ local function createTreeNodesForStorybook(storybook: LoadedStorybook): TreeNode
 				local parentNode: TreeNode = {
 					id = HttpService:GenerateGUID(),
 					label = parentInstance.Name,
+					instance = parentInstance,
 					icon = "folder",
 					isExpanded = false,
 					children = { currentNode },
diff --git a/src/TreeView/TreeNode.luau b/src/TreeView/TreeNode.luau
index 0842f63f..10136ac7 100644
--- a/src/TreeView/TreeNode.luau
+++ b/src/TreeView/TreeNode.luau
@@ -6,6 +6,7 @@ local TreeViewContext = require("@root/TreeView/TreeViewContext")
 local assets = require("@root/assets")
 local constants = require("@root/constants")
 local types = require("@root/TreeView/types")
+local usePinnedInstances = require("@root/TreeView/usePinnedInstances")
 local useTheme = require("@root/Common/useTheme")
 local useTreeNodeIcon = require("@root/TreeView/useTreeNodeIcon")
 
@@ -39,6 +40,7 @@ local function TreeNode(props: Props)
 	local treeViewContext = TreeViewContext.use()
 	local isExpanded = treeViewContext.isExpanded(props.node)
 	local isSelected = treeViewContext.isSelected(props.node)
+	local pinning = usePinnedInstances()
 
 	local styles = useSpring({
 		hover = if isHovered or isSelected then 0 else 1,
@@ -79,6 +81,12 @@ local function TreeNode(props: Props)
 		treeViewContext.activateNode(props.node)
 	end, { props.onActivated, treeViewContext, props.node } :: { unknown })
 
+	local onTogglePin = useCallback(function()
+		if props.node.instance then
+			pinning.togglePin(props.node.instance)
+		end
+	end, { pinning, props.node })
+
 	local backgroundColor = useMemo(function(): Color3?
 		if isSelected then
 			return theme.selection
@@ -150,9 +158,22 @@ local function TreeNode(props: Props)
 				}),
 			}),
 
+			Pin = React.createElement("ImageButton", {
+				LayoutOrder = 3,
+				BackgroundTransparency = 1,
+				AutomaticSize = Enum.AutomaticSize.XY,
+				[React.Event.Activated] = onTogglePin,
+			}, {
+				Icon = React.createElement(Sprite, {
+					image = assets.Magnify, -- TODO: Use a new icon for pinning
+					color = theme.text,
+					size = UDim2.fromOffset(16, 16),
+				}),
+			}),
+
 			Toggle = if #props.node.children > 0
 				then React.createElement("Frame", {
-					LayoutOrder = 3,
+					LayoutOrder = 4,
 					BackgroundTransparency = 1,
 					AutomaticSize = Enum.AutomaticSize.XY,
 				}, {
diff --git a/src/TreeView/TreeViewContext.luau b/src/TreeView/TreeViewContext.luau
index e89b824b..93d18f45 100644
--- a/src/TreeView/TreeViewContext.luau
+++ b/src/TreeView/TreeViewContext.luau
@@ -110,7 +110,7 @@ local function TreeNodeProvider(props: {
 
 	local getNodeByInstance = useCallback(function(instance: Instance)
 		return nodes.byInstance[instance]
-	end, { nodes.byId })
+	end, { nodes.byInstance })
 
 	local getSelectedNode = useCallback(function()
 		return selectedNode
diff --git a/src/TreeView/usePinnedInstances.luau b/src/TreeView/usePinnedInstances.luau
new file mode 100644
index 00000000..f0d9349a
--- /dev/null
+++ b/src/TreeView/usePinnedInstances.luau
@@ -0,0 +1,93 @@
+local HttpService = game:GetService("HttpService")
+
+local React = require("@pkg/React")
+
+local PluginContext = require("@root/Plugin/PluginContext")
+local getInstanceFromFullName = require("@root/Common/getInstanceFromFullName")
+
+local useContext = React.useContext
+local useCallback = React.useCallback
+
+local PINNED_INSTANCES_KEY = "pinnedInstancePaths"
+
+export type PinnedInstance = {
+	path: string,
+	instance: Instance?,
+}
+
+local function usePinnedInstances(): (ModuleScript?, (storyModule: ModuleScript?) -> ())
+	local plugin = useContext(PluginContext.Context)
+
+	local readPinnedPathsFromDisk = useCallback(function(): { string }
+		local data = plugin:GetSetting(PINNED_INSTANCES_KEY)
+		if data then
+			local json = HttpService:JSONDecode(data)
+			if json then
+				return json
+			end
+		end
+		return {}
+	end, { plugin })
+
+	local writePinnedPathsToDisk = useCallback(function(pins: { string })
+		local data = HttpService:JSONEncode(pins)
+		plugin:SetSetting(PINNED_INSTANCES_KEY, data)
+	end, { plugin })
+
+	local pin = useCallback(function(instance: Instance)
+		local pinnedPaths = readPinnedPathsFromDisk()
+
+		table.insert(pinnedPaths, instance:GetFullName())
+
+		writePinnedPathsToDisk(pinnedPaths)
+	end, { readPinnedPathsFromDisk, writePinnedPathsToDisk })
+
+	local unpin = useCallback(function(instance: Instance)
+		local pinnedPaths = readPinnedPathsFromDisk()
+
+		local index = table.find(pinnedPaths, instance:GetFullName())
+		if index then
+			table.remove(pinnedPaths, index)
+		end
+
+		writePinnedPathsToDisk(pinnedPaths)
+	end, { readPinnedPathsFromDisk, writePinnedPathsToDisk })
+
+	local getPinnedInstances = useCallback(function(): { PinnedInstance }
+		local pinnedPaths = readPinnedPathsFromDisk()
+		local pinnedInstances: { PinnedInstance } = {}
+
+		for _, pinnedPath in pinnedPaths do
+			table.insert(pinnedInstances, {
+				path = pinnedPath,
+				instance = getInstanceFromFullName(pinnedPath),
+			})
+		end
+
+		return pinnedInstances
+	end, { readPinnedPathsFromDisk })
+
+	local togglePin = useCallback(function(instance: Instance)
+		local pinnedPaths = readPinnedPathsFromDisk()
+		local path = instance:GetFullName()
+
+		local index = table.find(pinnedPaths, path)
+
+		if index then
+			table.remove(pinnedPaths, index)
+		else
+			table.insert(pinnedPaths, path)
+		end
+
+		writePinnedPathsToDisk(pinnedPaths)
+	end, { readPinnedPathsFromDisk, writePinnedPathsToDisk })
+
+	return {
+		pin = pin,
+		unpin = unpin,
+		togglePin = togglePin,
+		getPinnedInstances = getPinnedInstances,
+	}
+end
+
+return usePinnedInstances

From 88e2d8e757a400d8ed8d839068bc236d13db70d9 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Tue, 17 Dec 2024 13:08:57 -0800
Subject: [PATCH 41/79] Sync onboarding storybook to filesystem

---
 src/Panels/Sidebar.luau                       | 26 +++++++++-----
 src/RobloxInternal/getInternalSyncItems.luau  | 22 ++++++++++++
 .../getMostLikelyProjectSources.luau          | 35 +++++++++++++++++++
 src/RobloxInternal/tryGetService.luau         | 21 +++++++++++
 src/Storybook/createOnboardingStorybook.luau  | 11 +++---
 5 files changed, 102 insertions(+), 13 deletions(-)
 create mode 100644 src/RobloxInternal/getInternalSyncItems.luau
 create mode 100644 src/RobloxInternal/getMostLikelyProjectSources.luau
 create mode 100644 src/RobloxInternal/tryGetService.luau

diff --git a/src/Panels/Sidebar.luau b/src/Panels/Sidebar.luau
index f8f2d900..0c9856e7 100644
--- a/src/Panels/Sidebar.luau
+++ b/src/Panels/Sidebar.luau
@@ -1,3 +1,5 @@
+local ReplicatedStorage = game:GetService("ReplicatedStorage")
+
 local React = require("@pkg/React")
 local Storyteller = require("@pkg/Storyteller")
 
@@ -7,6 +9,7 @@ local ScrollingFrame = require("@root/Common/ScrollingFrame")
 local Searchbar = require("@root/Forms/Searchbar")
 local StorybookTreeView = require("@root/Storybook/StorybookTreeView")
 local createOnboardingStorybook = require("@root/Storybook/createOnboardingStorybook")
+local getMostLikelyProjectSources = require("@root/RobloxInternal/getMostLikelyProjectSources")
 local nextLayoutOrder = require("@root/Common/nextLayoutOrder")
 local useTheme = require("@root/Common/useTheme")
 
@@ -80,15 +83,20 @@ local function Sidebar(props: Props)
 			}),
 		}),
 
-		CreateStorybook = if #props.storybooks.available == 0
-			then React.createElement(Button, {
-				layoutOrder = nextLayoutOrder(),
-				text = "Create Storybook",
-				onClick = function()
-					createOnboardingStorybook(game.ReplicatedStorage)
-				end,
-			})
-			else nil,
+		--if #props.storybooks.available == 0
+		CreateStorybook = React.createElement(Button, {
+			layoutOrder = nextLayoutOrder(),
+			text = "Create Storybook",
+			onClick = function()
+				local source = getMostLikelyProjectSources()[1]
+
+				if source then
+					createOnboardingStorybook(source.Name, source)
+				else
+					createOnboardingStorybook(string.gsub(game.Name, "%.", "_"), ReplicatedStorage)
+				end
+			end,
+		}),
 
 		ScrollingFrame = e(ScrollingFrame, {
 			LayoutOrder = nextLayoutOrder(),
diff --git a/src/RobloxInternal/getInternalSyncItems.luau b/src/RobloxInternal/getInternalSyncItems.luau
new file mode 100644
index 00000000..3751541a
--- /dev/null
+++ b/src/RobloxInternal/getInternalSyncItems.luau
@@ -0,0 +1,22 @@
+local tryGetService = require("@root/RobloxInternal/tryGetService")
+
+-- selene: allow(incorrect_standard_library_use)
+type FileSyncService = typeof(game:GetService("FileSyncService"))
+
+local FileSyncService: FileSyncService? = tryGetService("FileSyncService")
+
+local function getInternalSyncItems(): { InternalSyncItem }
+	local internalSyncItems: { InternalSyncItem } = {}
+
+	if FileSyncService then
+		for _, child in FileSyncService:GetChildren() do
+			if child:IsA("InternalSyncItem") then
+				table.insert(internalSyncItems, child)
+			end
+		end
+	end
+
+	return internalSyncItems
+end
+
+return getInternalSyncItems
diff --git a/src/RobloxInternal/getMostLikelyProjectSources.luau b/src/RobloxInternal/getMostLikelyProjectSources.luau
new file mode 100644
index 00000000..d8419cc0
--- /dev/null
+++ b/src/RobloxInternal/getMostLikelyProjectSources.luau
@@ -0,0 +1,35 @@
+local Sift = require("@pkg/Sift")
+
+local getInternalSyncItems = require("@root/RobloxInternal/getInternalSyncItems")
+
+local PATTERNS = {
+	"src.*$",
+	"dist.*$",
+	"modules.*$",
+}
+
+local function getMostLikelyProjectSources(): { Instance }
+	local internalSyncItems = getInternalSyncItems()
+
+	if #internalSyncItems > 0 then
+		local sorted = Sift.List.sort(internalSyncItems, function(a: InternalSyncItem, b: InternalSyncItem)
+			for _, pattern in PATTERNS do
+				local aMatches = a.Path:match(pattern)
+				local bMatches = b.Path:match(pattern)
+
+				if aMatches and not bMatches then
+					return true
+				elseif bMatches and not aMatches then
+					return false
+				end
+			end
+		end)
+
+		return Sift.List.map(sorted, function(internalSyncItem)
+			return internalSyncItem.Target
+		end)
+	end
+	return {}
+end
+
+return getMostLikelyProjectSources
diff --git a/src/RobloxInternal/tryGetService.luau b/src/RobloxInternal/tryGetService.luau
new file mode 100644
index 00000000..d2dda643
--- /dev/null
+++ b/src/RobloxInternal/tryGetService.luau
@@ -0,0 +1,21 @@
+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
diff --git a/src/Storybook/createOnboardingStorybook.luau b/src/Storybook/createOnboardingStorybook.luau
index 4cc4393c..fea9989f 100644
--- a/src/Storybook/createOnboardingStorybook.luau
+++ b/src/Storybook/createOnboardingStorybook.luau
@@ -2,9 +2,12 @@ local STORYBOOK_TEMPLATE = script.Parent.OnboardingTemplate["StorybookTemplate"]
 local STORY_TEMPLATE = script.Parent.OnboardingTemplate["StoryTemplate"]
 local COMPONENT_TEMPLATE = script.Parent.OnboardingTemplate["ComponentTemplate"]
 
-local function createOnboardingStorybook(parent: Instance)
-	local components = Instance.new("Folder")
-	components.Name = "Components"
+local function createOnboardingStorybook(storybookName: string, parent: Instance)
+	local components = parent:FindFirstChild("Components")
+	if not components then
+		components = Instance.new("Folder")
+		components.Name = "Components"
+	end
 
 	local component = COMPONENT_TEMPLATE:Clone()
 	component.Name = "HelloWorld"
@@ -15,7 +18,7 @@ local function createOnboardingStorybook(parent: Instance)
 	story.Parent = components
 
 	local storybookModule = STORYBOOK_TEMPLATE:Clone()
-	storybookModule.Name = `{string.gsub(game.Name, "%.", "_")}.storybook`
+	storybookModule.Name = `{storybookName}.storybook`
 
 	components.Parent = parent
 	storybookModule.Parent = parent

From ffc379ddcb06dc290582b90dab7c751a65cccc27 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Tue, 17 Dec 2024 13:49:39 -0800
Subject: [PATCH 42/79] Watch Packages folder for changes

---
 .lune/build.luau | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.lune/build.luau b/.lune/build.luau
index c34d257b..85d23d7b 100644
--- a/.lune/build.luau
+++ b/.lune/build.luau
@@ -51,6 +51,7 @@ if args.watch then
 		filePatterns = {
 			"src/.*%.luau",
 			"example/.*%.luau",
+			"Packages/.*%.luau",
 		},
 		onChanged = build,
 	})

From ed5215388f0d3bfe82da37b7620c803d7d94f2e8 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Tue, 17 Dec 2024 13:49:46 -0800
Subject: [PATCH 43/79] Install ModuleLoader from disk

---
 .lune/wally-install.luau | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.lune/wally-install.luau b/.lune/wally-install.luau
index 1798f17b..04d05e79 100644
--- a/.lune/wally-install.luau
+++ b/.lune/wally-install.luau
@@ -16,7 +16,7 @@ do
 	run("wally", { "install" })
 
 	installPackageFromDisk("Storyteller", "../storyteller")
-	-- installPackageFromDisk("ModuleLoader", "../module-loader")
+	installPackageFromDisk("ModuleLoader", "../module-loader")
 
 	run("rojo", { "sourcemap", "build.project.json", "-o", "sourcemap.json" })
 	run("wally-package-types", { "--sourcemap", "sourcemap.json", "Packages" })

From 58dd60e3465304f319694c010c9c5c5afb2e98b3 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Tue, 17 Dec 2024 15:05:19 -0800
Subject: [PATCH 44/79] Only allow pinning stories and storybooks for right now

---
 src/TreeView/TreeNode.luau | 26 ++++++++++++++------------
 1 file changed, 14 insertions(+), 12 deletions(-)

diff --git a/src/TreeView/TreeNode.luau b/src/TreeView/TreeNode.luau
index 10136ac7..59303997 100644
--- a/src/TreeView/TreeNode.luau
+++ b/src/TreeView/TreeNode.luau
@@ -158,18 +158,20 @@ local function TreeNode(props: Props)
 				}),
 			}),
 
-			Pin = React.createElement("ImageButton", {
-				LayoutOrder = 3,
-				BackgroundTransparency = 1,
-				AutomaticSize = Enum.AutomaticSize.XY,
-				[React.Event.Activated] = onTogglePin,
-			}, {
-				Icon = React.createElement(Sprite, {
-					image = assets.Magnify, -- TODO: Use a new icon for pinning
-					color = theme.text,
-					size = UDim2.fromOffset(16, 16),
-				}),
-			}),
+			Pin = if props.node.icon == "story" or props.node.icon == "storybook"
+				then React.createElement("ImageButton", {
+					LayoutOrder = 3,
+					BackgroundTransparency = 1,
+					AutomaticSize = Enum.AutomaticSize.XY,
+					[React.Event.Activated] = onTogglePin,
+				}, {
+					Icon = React.createElement(Sprite, {
+						image = assets.Magnify, -- TODO: Use a new icon for pinning
+						color = theme.text,
+						size = UDim2.fromOffset(16, 16),
+					}),
+				})
+				else nil,
 
 			Toggle = if #props.node.children > 0
 				then React.createElement("Frame", {

From 9136d1690405dd72e79f5645981b9ed714ccce76 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Tue, 17 Dec 2024 15:13:18 -0800
Subject: [PATCH 45/79] Remove todo

---
 src/Common/getInstanceFromFullName.luau | 5 -----
 1 file changed, 5 deletions(-)

diff --git a/src/Common/getInstanceFromFullName.luau b/src/Common/getInstanceFromFullName.luau
index 1486fe32..0b9fe2e9 100644
--- a/src/Common/getInstanceFromFullName.luau
+++ b/src/Common/getInstanceFromFullName.luau
@@ -41,11 +41,6 @@ local function getInstanceFromFullName(fullName: string): Instance?
 		local current = maybeGetService(serviceName)
 
 		if current then
-			-- TODO: Verify this isn't also needed with the below TODO
-			-- if #parts == 1 then
-			-- 	return current
-			-- end
-
 			while #parts > 0 do
 				-- Keep around a copy of the `parts` array. We are going to concat this
 				-- into new paths, and incrementally remove from the right to narrow

From 0d135a2c25c0119cc78b1f84cb4d06c9d9e0041e Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Tue, 17 Dec 2024 15:16:56 -0800
Subject: [PATCH 46/79] Add new icons

---
 img/Star.png          | Bin 0 -> 453 bytes
 img/Star@2x.png       | Bin 0 -> 857 bytes
 img/Star@3x.png       | Bin 0 -> 1192 bytes
 img/StarFilled.png    | Bin 0 -> 336 bytes
 img/StarFilled@2x.png | Bin 0 -> 578 bytes
 img/StarFilled@3x.png | Bin 0 -> 808 bytes
 img/Warning.png       | Bin 0 -> 409 bytes
 img/Warning@2x.png    | Bin 0 -> 659 bytes
 img/Warning@3x.png    | Bin 0 -> 937 bytes
 src/assets.luau       |  85 ++++++++++++++++++++++++++++++++++++------
 tarmac-manifest.toml  |  74 +++++++++++++++++++++++++++++++-----
 11 files changed, 138 insertions(+), 21 deletions(-)
 create mode 100644 img/Star.png
 create mode 100644 img/Star@2x.png
 create mode 100644 img/Star@3x.png
 create mode 100644 img/StarFilled.png
 create mode 100644 img/StarFilled@2x.png
 create mode 100644 img/StarFilled@3x.png
 create mode 100644 img/Warning.png
 create mode 100644 img/Warning@2x.png
 create mode 100644 img/Warning@3x.png

diff --git a/img/Star.png b/img/Star.png
new file mode 100644
index 0000000000000000000000000000000000000000..e1bba5dac9494753ae04e9f298d7e9714d126b21
GIT binary patch
literal 453
zcmV;$0XqJPP)<h;3K|Lk000e1NJLTq000>P000;W1^@s654Bdt00009a7bBm000XU
z000XU0RWnu7ytkO0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH0a{5!K~#7F#Z-Z9
zgD?<$XMwUo*`Q>C(hV{K8>Ac5BhU%R2F(VrLDC6GCJ3Ft{Z}s1$2HU`R&&w`u<!qT
z7B<m8z{Cojb2&fO_<T3U94IBkfY~!opuA_465OzHXXK#3QYxsXjT+k^QJOS5MZnn7
zT!d1nxl5Y-w?4)zl=ubs$XqdHZ0Y|^Xue|(%q{cC+zVMdP+(OVvvt=vVXUC(q%E`>
z&5wj?-z{?$vjrmyEzv_vYhUoe+)*MRUF#24Nb*KAAwAHm3QgL%M#{iWiT-R9We_kb
zDH3YCZEOwU?`Uq&*HSY!znG}~q8s5x?y^NdSkn2FcNC8fHAR$(67D_o40|6stesao
z4IIqg8(X{UtE2bN0milznM5$4u*Au&k?e)J#CwCrC29`L2Z~H$)oL9QQ~_P3G?(XV
vsHYh*67duBler0A-O7v~&&(t1EZqy=tghL9YCytM00000NkvXXu0mjfB@w&9

literal 0
HcmV?d00001

diff --git a/img/Star@2x.png b/img/Star@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..61f03b207e1d2665015b9b9f5a3f01de9cdf492c
GIT binary patch
literal 857
zcmV-f1E&0mP)<h;3K|Lk000e1NJLTq001!n001xu1^@s6xWJOR00009a7bBm000&x
z000&x0ZCFM@Bjb+0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH0`5sfK~#7F?OKg>
zn=lYQGJ!im=>&Brh&w^DLA^n}LD`_$z<7eZOn^H9WCF+pbc3t(bhw8QaM)5TznAa5
zd&o$-AKeL^Bm;aP4kQo=h`3UZ4gIW=Bx&H5pbYq_sKq{h!L2|mi{tnWu|p}jwUjOt
zmDam0#0;fi&6Is+@1NNFn&wM<u)LrZO<CQ_nj;y+2+IqZqdsHxZFJt4@&aoUw>Rdz
zz}m&>4T%?6+c>=;^#W@jmp9O?%pxzaKG*jKDY3!f%A76Wky(BB#Pck#Bol%(!!^-o
zR(1($6n#@k-d_-aco)RahUQTby>(?N?@mCf=oyCz+!zW;7>&p&P>#Jwj%u_PD6i|w
zE(G90C?1cBaxzFVa6fDw31g;RTq;UgedpF!Fd-9sU!VsPOvvizJ?X>4PKVVOXL!+w
zbf|rdvM-<sy_)H_<Mnr}%&mP<mL4GzkoOPlRqx}%jc8c;9^%6TD>u#-I9!CH*@fY7
zO-iR4@2tjG-xuRKiz<EYqA<i^K)$5&)eX7G0LJ8MhIlc6F?)AIyc)om+J@{P5O8?p
zlY=i<hO`g}ICA9Ui@CjqtidzTq!y=v!UdV}oum;ZTNQg1VoRB_@}fjjWCS7fUbtap
z@1E|$=>!M_D`Zn?KdC1+tbBxUs77Q?;v6GhAsk*<SsYSQBfhfo5yF9c;z8I)cHL#o
zgHLlO4`_CUdM~U;Q@3pt9p*M#Xtv1h5?LakIS=Yk&a~}LO2)9Co3Qx*QbtemM-V^~
zPAMzDfm^Wtdg;7M8#yI-tEk559PAH0SC;HiEH$FCD=Boe@)`8kl3dYuqJz~fqZDHc
zL-6qNvo>KrDdSgII?M`3$Q0-}DZ$~1#fc~OZ<Y3}&QG$5=0MFBS~`?zB|>RXYDfPP
zhx2IzzJT+`jrw9y?0Pg_d79x_gHNEDxUR2Gami?rG5HAJRLW=3-y+bY;3@q)dijP!
j?*u*tlfhQ-fg8j>4`TF){03tu00000NkvXXu0mjf@a2R3

literal 0
HcmV?d00001

diff --git a/img/Star@3x.png b/img/Star@3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..b155368ec472383391e79bc81e106936a9428949
GIT binary patch
literal 1192
zcmV;Z1XufsP)<h;3K|Lk000e1NJLTq002n<002k`1^@s6x-Zyy00009a7bBm001F4
z001F40Y#QEU;qFB0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH1U*SaK~#7F?VO8s
z+aMH%uX8$pH>fs<H>kQnx`8u6+zFCSkYob66SSQ`?gVirkUD|0!R@8sM#dKeLPpQ;
z9FFzkq2E6U0XD$Q%*@Qpe-Lp=;hw?^z9Y8pC?qg-NQ4!I0?mkXE0`+6B@_-=&tZ~6
zDS--f=P<ddM>m`q#CW2xHJr;~Dkv9mth8&ms+htQFt}*LT(_B|n+z^0EZ66gbCbbE
zYs>ZNq}=4|qDs7cGH&v9Q6=6q+|<-XmH7GS-PF`YmH6X!Q>cq7>ExqzQ>cq7>5S7&
zZMmqDUOqZE(GC1)!$p<!I5&y0rzH#xl;cEsn=Y!P&8CYN98Ot40O}iDsqm3PqP7!5
zl-tE(v4yZaQ~EHqMQ;@LD*QQaQ=V@SfU`*In@T~7aTLbK=Fe2v4YUB2<$*r#J))e6
zM`hVT8>oi&5#2@*Z9GBC2%#_xaZeeB4CbL6hz}K~pl|~hg3?~2-w3A(t1B0V%tfRJ
z@IzR-1ZnA4Xw%X)plqi^xQ6$3(U&ci_m(@cU@~0OM+RneG5&2+1|{?elx=7NQZQHX
zzS4cvHw}0?|8+!EJJALtFmO<IPfXSH;F7?Si8##8nghspcF){)rR{Ted=Bkr^qB9=
zu`@&HB9$(YMntoF!}G^}G2-f0Fx9R8UQtdE>SQo7P%hHYaT|I;^W>q=Ye26t2*ftp
zwnR2#70M!z|Em+4=i~<~ltrKyXadSU)5}<dvI&GC52mpQbuob|84OyeiwjiAXpllh
z6R48mpoEGpP$grVP^}EjwgJkw{P+S@3SbiIfIWGALH>0k?r4TZsLtJFa8WnIJxVD3
zW%ws_4D!?J2?THj4N+rEzqG^G&^?K^zY85&ZY+|b)b<nTo<!R(A&^j!K56?UbdNrY
z+GOVBgiw+60=z=^{MPnYp^bK9kx-7k=$Y@Jd-lw+AUB0LiOYBo-LqqkrO*Z3qZWDs
z(=`cN5!a@ZcF-U6u1WmrraEVRY_yN))qaTqi@ZKSGq?<cdfQ6)r3z(W2XMz6OCe^P
ziTt#a_hS~{e}7vr=@l5Teu!z8;!ca<`MO2#fpm&<gKle!#5HU1gehb2dQIN~VAF32
z&5KT5#7aE!X~pdV?}pn@(Aq{DWz~A5P2su}{7EPXp)yCY_J%aP34JX&@@6|pH+4rJ
z!tNVO{BIP#Q1}dTwMS{Vc1<D2`mw8$ITmM33HfR8wc*;-p=#xvGgLlXtgnJMmb`UR
zhyn5oy`JFw1~aU`!PFtYx<6PzWU3(Y69K^^0?rr;Eea}@Gv{dMxU|sA`slXOHdkff
zmQ_c8x;0%xEtcOU&=7R1f+nK(lSGv(JPIfV;!|h|^#6wnacu&zaV)5?{KaNa(5(uJ
z^YPjurjWu>8$6v}5{1ez;_uK<KFPLBIlSAxnVFfHzVjFN36T_;y(ka>0000<MNUMn
GLSTZjX)R~~

literal 0
HcmV?d00001

diff --git a/img/StarFilled.png b/img/StarFilled.png
new file mode 100644
index 0000000000000000000000000000000000000000..fd0fbb122a9f443edb0397a278a63efff0773472
GIT binary patch
literal 336
zcmeAS@N?(olHy`uVBq!ia0vp^l0YoM!3HGxw}u@9Qk(@Ik;M!Q+`=Ht$S`Y;1W=H%
zILO_JVcj{Imp~3nx}&cn1H;CC?mvmFK>kxt7srqa#-~#r@-`U=w95Nj5Zu+6qo8cb
zl=pybM`PXrj#n%@7?tO&U#PWGWJkdN7t{A#*mC^t-Xg{y)-FeacuNbjYnav6J@swA
zVvwmhe~L%v{$&$N@|5&0?MxJM-YV=W#_K3ncGlf7_s``Zw$CvutxJnv>zqE<QBpgp
z#ALBw#Rc6rRe~j-PtE23_T9tXM*MEbrg>b&YjjNF69Qf+RApsM_?Nz7X~)yk%D=^`
zdbW9NS<QLHZ`+07tfsr>neOlIynVap*g5OmiFrRZeM!!3nxWMpobiD(>ZSWlzau*}
gZS0aiezsw_dpw$9?|!=pKo2u`y85}Sb4q9e0O5j$Q2+n{

literal 0
HcmV?d00001

diff --git a/img/StarFilled@2x.png b/img/StarFilled@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..3962cfa47418793c796feb2a02ce55d03f7875e8
GIT binary patch
literal 578
zcmV-I0=@l-P)<h;3K|Lk000e1NJLTq001!n001xu1^@s6xWJOR00009a7bBm000&x
z000&x0ZCFM@Bjb+0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH0oO@HK~#7F?U{{j
zgFp<0A5jNLH@FRwPLSK6>IT`M*&uX-c7ik$WQ1gcW`ocPnhji^AgUBD;S3y@ichjY
zV2mF&KX(8lBO{$aL`$}PVM`1RPI^sc)))$$tfAXj3;|Abb4SD;+Nr3*Fse~e1^v|s
ztb*=p1Xe+BHH1~rSq)(o^i{)MQL2wc-*6JIY(G>VckBucNkW|XL{nZEwmI9ZShs9b
zw8NS0SlIVvJxP*t$iZF_Mbb;Q1@goV`r#sO%XVN<na`;tf&~~}xSP&E7a7|N3prlz
z@QH8&EOg>p?CE-=PXs|RETWY2#N&5>UNYZCpNKc;B|JCosFd`PLJH{;lA<o8Lr99I
zP`^S_v_(%sQnXFSLQ=$n@`<q6oFHT(^xq;r9}prHks&@up}$x}TjPg&7zmMyu!zrG
z=r0yALwuegBxrbg!96<A4UeHiQ%7(msN0zdD{+>zlekwKU?t8E0^bE>xOO-of1(-4
zo;q>vJ#MG3z&Ea&OLCtm)2riG?rj_z12G6Z`Hu40l3Ov{ob6{wAjF{JD!P4E5X)4=
zi&hEWS8StcNXq}Po;-0khy@3Kr~Ka7k*Du##x(TzyZBU`ql?d*k&%DPFY>?v2Hd=1
QjsO4v07*qoM6N<$f+TS8y8r+H

literal 0
HcmV?d00001

diff --git a/img/StarFilled@3x.png b/img/StarFilled@3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..952b430d1ae9ae7a7c0b349117c7692eb934f96f
GIT binary patch
literal 808
zcmV+@1K0eCP)<h;3K|Lk000e1NJLTq002n<002k`1^@s6x-Zyy00009a7bBm001F4
z001F40Y#QEU;qFB0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH0=-E@K~#7F?VOEm
z+b|G?pF2UifjxoK391d+4eAZjPLN~+cLFODq?y3k!07~bCvZ1-JysN2AQ3HzCy65Y
z0S5^-1e_lpY5p7qWHOmdCKDSA1OUk^x!g!xOPKoyi64u_;x{sX1zJiR`5N=t5~*Vx
z)F9CRLR}$cM1UPH^N3Wj&8#g2QlzDeYW7HtjxI{mO&wj7o|}5QC@nYjbWu8P@^n!e
zZt`?dO}uWRj*Dsv#0Yg<R1=?@sOzGd0x>~d7uCe$CiyY99FAMe8CFef&*9l~;B_aJ
zl}VeVSyeRGwY|T_xV%W5B!1TSva<OC1v-h;AcnaQd11_3yo(}PVZKOwMc86=;Uw`a
zaW8_M;DZQsBXMTjT<rW84uL+)C6%SqZ{nzJ)0Zxh%s8Y#8kl}+)%2xHq%-2_gDxT>
zHEQwB@m$^*w75iiLOM8qtE;&!<#Iq8O>X5H3N$*&K6H=xQ5h@UF<we&!8nB~p$+2_
zs>Dz*4xvg64d*RXiLu~3g(@*NoR?4~rh@Yjs>IYt5?h9g%2JQ_8aJz&#v{U<zO1}Q
z({N94TVczH9}pf-8Uh)-MY5>uN+_e2la@#)gvSX=Xo)oT9$OpK`baI2)(DSJP$Fh0
zspaG?694V$hK4zkp^}$|lR^}zrhoz(A{7W1mA+C#q!2spngR-FOiPHc@t&G^If?VA
zTP)y-Z*vQ|)2{K((O0C8@W)J$&Hmm8iT8F--rRqO&eTF9$NorjJJ63uI_e!+)m@hK
z<BTbx`~0knBRlkGY(C7~3QS3l#7))Q9eN_qZu*FYDPgw~<Q)}GX0v+}JYY)LZX;mN
z0fq`ztTuh(4pYMFTz^xdM~MvvhVCY4K3~EUtz!2LsxoHtcm502D>R-lBG#>@7<O&*
mFm>D8{3?%3CX>lz-r^6>amv0XDkiG{0000<MNUMnLSTZN99}d4

literal 0
HcmV?d00001

diff --git a/img/Warning.png b/img/Warning.png
new file mode 100644
index 0000000000000000000000000000000000000000..23743d46efa0e16ce0ab6fe7a7df028d3aab857d
GIT binary patch
literal 409
zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL
zyA#8@b22Z19F}xPUq=Rpjs4tz5?O(AdY&$hAr*{QLj!paIq<yY_&=w`GC@d<k^Ky#
z1p|;+kg$M(lZDlTf&F;4#**l=^4+P*4^{QuUu0KLiSsrwnxv<cz4qWnCgTbA@{?68
zF9<k%V6E7mAJZDPPkV*_gE`IOoX67dZMZvCx0f^Lz+4Oa16$prSGfF6P~w;-G-cz|
zTkSPL8$wc}ceO;WpC9A@<!AjTGe=c(gLw)uyCxscTf!87*5zjKhqiCklcet~GoBt;
zbN;~|UB>%=9GR1ylpp^W_R-{f?ok`VRO0D5u|sPj&pua&W4V8Fiq}1v_(M)ATdV)>
ziDcWYlAjsfU0(^Uy;^do)Kg{qy%jH`TV#Hyv75?r_m?eQmY-?ub3aXK?_Q(SV!g!~
zH+}_Wt~(RpUK;&tzKcc0%mVIv2aF6V>zl<FInDiIe|(D%FeDi~UHx3vIVCg!031M_
Ad;kCd

literal 0
HcmV?d00001

diff --git a/img/Warning@2x.png b/img/Warning@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..57e462fba3654901c1114b306b4fb6cb1328135f
GIT binary patch
literal 659
zcmV;E0&M+>P)<h;3K|Lk000e1NJLTq001xm001xu1^@s6R|5Hm00009a7bBm000&x
z000&x0ZCFM@Bjb+0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH0w_sDK~#7F?V5p6
zgD@0^A3X<fglr%qFhWOQ1Kj{Oup6`+v`)Y|L3IMypl<M+9H|C)K|%<r9^c(Nb4Zf+
zk{9ItNVF1{gaQEIUjEoh6Z+D-vLs1rx@Dx}LRtwsdvy=p8fwL*6&O#UcPT#7kw<A4
z#>rCJPTDj0U8$ty6bm_dW^qhi%b5FxcA5)B&{AkejuuC@Bl_o-K=wfc!B3ud)8(Sm
z#3`qBA24GTG&gExEvt6)Sv9?z2k^U@lP|D87S*+kW&+K5s%BjvJt&thPZS97=R?hz
zx(*~5=biI>3VQAx6NGRXq(nO($9J9y^dGzB*p6u@dO;H~9H>0UFv+_2Y?yxLv;5%k
z0+v(rGADG~89n#OxYRofTmZsozfpW%jrK$LmmB)ytO3Mk4Inma0I^vEh)u`<YD;@o
z?kEuQJw{bp-)#y+umBVx9lx@kfg#W+89}SKU;*eNrjmAcu4&s0E&vxmZM1zR&h_Mi
z+C`EpW)1N1>)mM7h-t-idz@64lRjf~OUiKzI#*Hea|R|C^}2J4zN}JU0d)erRPVlv
zXyn9h5ETMS;^3uvcVWvIP;KKGeJc1pS-SHKN%ew!K_E#So>Kjk2{}nz;UtM;W~tr@
zcuL}k^?`$NB(*ql<{W|JC#l64SkY3a!;8i73u1ocGeMJ9w)4R`zKB%s4D9E_+!aAH
t66#+K1T`;$*S{6Dw394wK56Bj@dag9G%G1HUfBQu002ovPDHLkV1hdw6>|Up

literal 0
HcmV?d00001

diff --git a/img/Warning@3x.png b/img/Warning@3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..27b1425459404fcf960df555439a8f29f6deda9c
GIT binary patch
literal 937
zcmV;a16KTrP)<h;3K|Lk000e1NJLTq002k;002k`1^@s6RqeA!00009a7bBm001F4
z001F40Y#QEU;qFB0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH13pPaK~#7F?VN#i
z+At7?kG>Aj5j+Cjz$0XXutBl`*`Q$pk_qSr?gs4!W&_yZuM~*~39?Q$I&%hn@A(Bp
zu%zF*v$1}es8A>fG9x7bsLY?rzunJfvww8&7*Q5xYs|L8IXnN^g6<8|>Dp{9(rNEP
zDAUQg9~DE{z=h&%VLJ80Y`>6?_00%nI`V{Yjya_<+cR9~)&BK{`A&-|haJ-LOcNUk
z_OnEx9%w4~PBjUgs(b8n0bcAiG>08ro>?-Tu%Y%s1F_!)AFfN=7SaUL^2}?FhQp1}
zH>R!@8uAldo@toI+Rtmsj2Eg+5UQfFfuiIIP5BAOJQEA2k355s^MJ1tOuLpO1=5D}
z8dp4%AU!`-loMZZY}7Om$TLF$e+`yTB*aIK6>ULEK|DX%^2^fzT-b!ja@v;0AK=%H
zJ~VU*a6>gjO%ub9ro4SY_!{S#1i<lPvxU&v2JT02o>2sMO3$-1zOE`zmq_?_9Q4k*
zzD4#`2g=?_hAL1tCmG)R)D;t`3Y7h(>GLO0Hb|heVP|F<_B3%!AGU$wK_OP4A`nXv
zh@l9?Py}Kq0x=YU7>Ym)MIeSE5JM4&p$NoK1Y*!4&_1zjbW{lB4V$ZzqLmgC=XX+q
z5yajve)F%@I9$~TWYK8-ElHZ82*gkXVo;NV;LHS1{_U#c=KAQw1UkD8u@h{9GaGhN
zvhuGVOkF=E>N5KcP4RTr6AQ6zpiIOu=I6(&lcI{k^Kv-|3Dp1WsbK24A^{M#!wj3|
z?wH4ZBmwZ*Lia)fz!@aIGJLieVf)JtLiQIwYNbpIj|fxKC7|2pyTk+*lpwy4W=)rZ
zxCPIaFGwkf8{AuD^eN5}H*wm^r0goa0dH{6C(cQwQ?UVa2KT?BZ3J)Fw#XT_!)?-K
zaEGD=CD6Q73NANsjT0HtxZ*;Z-i&z^;lgRn)TPhhj<{j_K#PGkVklln(+ddXCuCUv
zst#lbJEX0%w>hq+sRHM2Z<$VRaK0?OVZLL-NyPO(I!Ft<K1^aEO>dC4WIRTLTYNOd
zSCE#UPO<-6)4hS?<8eY99*=X?rz&WAz>>WiaL6lLEwdVhLP3=OB*Gr1)1G@a00000
LNkvXXu0mjf`){K0

literal 0
HcmV?d00001

diff --git a/src/assets.luau b/src/assets.luau
index d00fe303..f65f006b 100644
--- a/src/assets.luau
+++ b/src/assets.luau
@@ -1,53 +1,116 @@
 -- This file was @generated by Tarmac. It is not intended for manual editing.
 return {
 	ChevronRight = {
-		Image = "rbxassetid://18940815650",
+		Image = "rbxassetid://128765597212202",
 		ImageRectOffset = Vector2.new(49, 226),
 		ImageRectSize = Vector2.new(32, 32),
 	},
 	Component = {
-		Image = "rbxassetid://18940815650",
+		Image = "rbxassetid://128765597212202",
 		ImageRectOffset = Vector2.new(0, 275),
 		ImageRectSize = Vector2.new(32, 32),
 	},
 	Folder = {
-		Image = "rbxassetid://18940815650",
+		Image = "rbxassetid://128765597212202",
 		ImageRectOffset = Vector2.new(345, 0),
 		ImageRectSize = Vector2.new(32, 32),
 	},
 	GitHubMark = {
-		Image = "rbxassetid://18940815650",
+		Image = "rbxassetid://128765597212202",
 		ImageRectOffset = Vector2.new(0, 0),
 		ImageRectSize = Vector2.new(230, 225),
 	},
 	IconLight = {
-		Image = "rbxassetid://18940815650",
+		Image = "rbxassetid://128765597212202",
 		ImageRectOffset = Vector2.new(231, 65),
 		ImageRectSize = Vector2.new(42, 42),
 	},
 	Magnify = {
-		Image = "rbxassetid://18940815650",
+		Image = "rbxassetid://128765597212202",
 		ImageRectOffset = Vector2.new(0, 226),
 		ImageRectSize = Vector2.new(48, 48),
 	},
 	Minify = {
-		Image = "rbxassetid://18940815650",
+		Image = "rbxassetid://128765597212202",
 		ImageRectOffset = Vector2.new(296, 0),
 		ImageRectSize = Vector2.new(48, 48),
 	},
 	Search = {
-		Image = "rbxassetid://18940815650",
+		Image = "rbxassetid://128765597212202",
 		ImageRectOffset = Vector2.new(296, 49),
 		ImageRectSize = Vector2.new(32, 32),
 	},
+	Star = function(dpiScale)
+		if dpiScale >= 3 then
+			return {
+				Image = "rbxassetid://123049343108173",
+				ImageRectOffset = Vector2.new(0, 0),
+				ImageRectSize = Vector2.new(73, 72),
+			}
+		elseif dpiScale >= 2 then
+			return {
+				Image = "rbxassetid://118393146115398",
+				ImageRectOffset = Vector2.new(0, 0),
+				ImageRectSize = Vector2.new(49, 48),
+			}
+		else
+			return {
+				Image = "rbxassetid://128765597212202",
+				ImageRectOffset = Vector2.new(82, 226),
+				ImageRectSize = Vector2.new(25, 24),
+			}
+		end
+	end,
+	StarFilled = function(dpiScale)
+		if dpiScale >= 3 then
+			return {
+				Image = "rbxassetid://123049343108173",
+				ImageRectOffset = Vector2.new(74, 0),
+				ImageRectSize = Vector2.new(73, 72),
+			}
+		elseif dpiScale >= 2 then
+			return {
+				Image = "rbxassetid://118393146115398",
+				ImageRectOffset = Vector2.new(50, 0),
+				ImageRectSize = Vector2.new(49, 48),
+			}
+		else
+			return {
+				Image = "rbxassetid://128765597212202",
+				ImageRectOffset = Vector2.new(49, 259),
+				ImageRectSize = Vector2.new(25, 24),
+			}
+		end
+	end,
 	Storybook = {
-		Image = "rbxassetid://18940815650",
+		Image = "rbxassetid://128765597212202",
 		ImageRectOffset = Vector2.new(231, 108),
 		ImageRectSize = Vector2.new(32, 32),
 	},
+	Warning = function(dpiScale)
+		if dpiScale >= 3 then
+			return {
+				Image = "rbxassetid://123049343108173",
+				ImageRectOffset = Vector2.new(0, 73),
+				ImageRectSize = Vector2.new(72, 72),
+			}
+		elseif dpiScale >= 2 then
+			return {
+				Image = "rbxassetid://118393146115398",
+				ImageRectOffset = Vector2.new(0, 49),
+				ImageRectSize = Vector2.new(48, 48),
+			}
+		else
+			return {
+				Image = "rbxassetid://128765597212202",
+				ImageRectOffset = Vector2.new(0, 308),
+				ImageRectSize = Vector2.new(24, 24),
+			}
+		end
+	end,
 	flipbook = {
-		Image = "rbxassetid://18940815650",
+		Image = "rbxassetid://128765597212202",
 		ImageRectOffset = Vector2.new(231, 0),
 		ImageRectSize = Vector2.new(64, 64),
 	},
-}
+}
\ No newline at end of file
diff --git a/tarmac-manifest.toml b/tarmac-manifest.toml
index 9dd6cda0..31a88cd8 100644
--- a/tarmac-manifest.toml
+++ b/tarmac-manifest.toml
@@ -1,59 +1,113 @@
 [inputs."img/ChevronRight.png"]
 hash = "4c91853652b4e0a6317804c54db6397ac0c710c819b282f7be2179e3abdc879c"
-id = 18940815650
+id = 128765597212202
 slice = [[49, 226], [81, 258]]
 packable = true
 
 [inputs."img/Component.png"]
 hash = "d51b19d5f35ac87fd8156500828f3e217b6fc420b70fee5d97b3577e4e6a8843"
-id = 18940815650
+id = 128765597212202
 slice = [[0, 275], [32, 307]]
 packable = true
 
 [inputs."img/Folder.png"]
 hash = "4c0e80a363d36a4d127d410795cb13eec74d55871b70174636c0c962c242835c"
-id = 18940815650
+id = 128765597212202
 slice = [[345, 0], [377, 32]]
 packable = true
 
 [inputs."img/GitHubMark.png"]
 hash = "a0fb973cd9bf0c1123dbe783f7a4093dbcf9b4910d6e385c16a837a04a02306c"
-id = 18940815650
+id = 128765597212202
 slice = [[0, 0], [230, 225]]
 packable = true
 
 [inputs."img/IconLight.png"]
 hash = "237c66813c2b5a58ae9c46166f72db8698fbb046922e2473ebaca8f7d7376ce1"
-id = 18940815650
+id = 128765597212202
 slice = [[231, 65], [273, 107]]
 packable = true
 
 [inputs."img/Magnify.png"]
 hash = "351db4b6132a9e02d01bc4cdc3c850099dce358673daaa0da66745b675ad38b1"
-id = 18940815650
+id = 128765597212202
 slice = [[0, 226], [48, 274]]
 packable = true
 
 [inputs."img/Minify.png"]
 hash = "694db8ce141475fc0f42980d2a6dfd058dfa155348a579fab29ca6dd5684ec14"
-id = 18940815650
+id = 128765597212202
 slice = [[296, 0], [344, 48]]
 packable = true
 
 [inputs."img/Search.png"]
 hash = "0ab89c0309f577f3ccae0e03b45292f6e2bca51cc9b8250f56ad756e04231c57"
-id = 18940815650
+id = 128765597212202
 slice = [[296, 49], [328, 81]]
 packable = true
 
+[inputs."img/Star.png"]
+hash = "ae3c74c88729a8600531b2ade6cbaaa091033fffd0de5145b8462d3a15d0a510"
+id = 128765597212202
+slice = [[82, 226], [107, 250]]
+packable = true
+
+[inputs."img/Star@2x.png"]
+hash = "ebaeeb5c227223841d5bd5c001f58044d1995e5258374305032611a75a9070eb"
+id = 118393146115398
+slice = [[0, 0], [49, 48]]
+packable = true
+
+[inputs."img/Star@3x.png"]
+hash = "34cc831f584d6b54d60f5fe77f480e053e2e0091676f446aa0b9f92cc25f6a4f"
+id = 123049343108173
+slice = [[0, 0], [73, 72]]
+packable = true
+
+[inputs."img/StarFilled.png"]
+hash = "d958666976bb5a1ab5672e5a79d393b28fca26fb348f9b07ceecbcaf059e78f3"
+id = 128765597212202
+slice = [[49, 259], [74, 283]]
+packable = true
+
+[inputs."img/StarFilled@2x.png"]
+hash = "99216f340f265dcf7fa40c941427d9f09efeddc60ea5fd6a502f288e6d268e63"
+id = 118393146115398
+slice = [[50, 0], [99, 48]]
+packable = true
+
+[inputs."img/StarFilled@3x.png"]
+hash = "b7d7defc50fb8d0dcc92327406e9eb2e8fe379656239424fab6982d449248ba5"
+id = 123049343108173
+slice = [[74, 0], [147, 72]]
+packable = true
+
 [inputs."img/Storybook.png"]
 hash = "2a4b8bd513a8fa7eebae482943ab3435a659359bd94b24eff1c3dcc6f1244799"
-id = 18940815650
+id = 128765597212202
 slice = [[231, 108], [263, 140]]
 packable = true
 
+[inputs."img/Warning.png"]
+hash = "e2f8c263a469a521d876de814fb5e3251084dbce9d41e06bf9037ccfe4710fd3"
+id = 128765597212202
+slice = [[0, 308], [24, 332]]
+packable = true
+
+[inputs."img/Warning@2x.png"]
+hash = "ecec999c799a628e0672fe1dd4d2c16df9f27caeb66b82a7bd5103326c6463ff"
+id = 118393146115398
+slice = [[0, 49], [48, 97]]
+packable = true
+
+[inputs."img/Warning@3x.png"]
+hash = "9524173aa67880639e5d09b02925e631e1766336bff06ddff2bdf5d834822464"
+id = 123049343108173
+slice = [[0, 73], [72, 145]]
+packable = true
+
 [inputs."img/flipbook.png"]
 hash = "423e3dcff54fce8824f14d7cadcfb90eef2abecd817df2bee13951bd2674f79a"
-id = 18940815650
+id = 128765597212202
 slice = [[231, 0], [295, 64]]
 packable = true

From de6a3cc0c5e259198512b99771cd78ec879081a6 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Tue, 17 Dec 2024 15:28:31 -0800
Subject: [PATCH 47/79] Redo icons without dpi scaling

---
 img/{Warning.png => Alert.png} | Bin
 img/Star@2x.png                | Bin 857 -> 0 bytes
 img/Star@3x.png                | Bin 1192 -> 0 bytes
 img/StarFilled@2x.png          | Bin 578 -> 0 bytes
 img/StarFilled@3x.png          | Bin 808 -> 0 bytes
 img/Warning@2x.png             | Bin 659 -> 0 bytes
 img/Warning@3x.png             | Bin 937 -> 0 bytes
 src/assets.luau                |  98 +++++++++------------------------
 tarmac-manifest.toml           |  72 ++++++------------------
 9 files changed, 43 insertions(+), 127 deletions(-)
 rename img/{Warning.png => Alert.png} (100%)
 delete mode 100644 img/Star@2x.png
 delete mode 100644 img/Star@3x.png
 delete mode 100644 img/StarFilled@2x.png
 delete mode 100644 img/StarFilled@3x.png
 delete mode 100644 img/Warning@2x.png
 delete mode 100644 img/Warning@3x.png

diff --git a/img/Warning.png b/img/Alert.png
similarity index 100%
rename from img/Warning.png
rename to img/Alert.png
diff --git a/img/Star@2x.png b/img/Star@2x.png
deleted file mode 100644
index 61f03b207e1d2665015b9b9f5a3f01de9cdf492c..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 857
zcmV-f1E&0mP)<h;3K|Lk000e1NJLTq001!n001xu1^@s6xWJOR00009a7bBm000&x
z000&x0ZCFM@Bjb+0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH0`5sfK~#7F?OKg>
zn=lYQGJ!im=>&Brh&w^DLA^n}LD`_$z<7eZOn^H9WCF+pbc3t(bhw8QaM)5TznAa5
zd&o$-AKeL^Bm;aP4kQo=h`3UZ4gIW=Bx&H5pbYq_sKq{h!L2|mi{tnWu|p}jwUjOt
zmDam0#0;fi&6Is+@1NNFn&wM<u)LrZO<CQ_nj;y+2+IqZqdsHxZFJt4@&aoUw>Rdz
zz}m&>4T%?6+c>=;^#W@jmp9O?%pxzaKG*jKDY3!f%A76Wky(BB#Pck#Bol%(!!^-o
zR(1($6n#@k-d_-aco)RahUQTby>(?N?@mCf=oyCz+!zW;7>&p&P>#Jwj%u_PD6i|w
zE(G90C?1cBaxzFVa6fDw31g;RTq;UgedpF!Fd-9sU!VsPOvvizJ?X>4PKVVOXL!+w
zbf|rdvM-<sy_)H_<Mnr}%&mP<mL4GzkoOPlRqx}%jc8c;9^%6TD>u#-I9!CH*@fY7
zO-iR4@2tjG-xuRKiz<EYqA<i^K)$5&)eX7G0LJ8MhIlc6F?)AIyc)om+J@{P5O8?p
zlY=i<hO`g}ICA9Ui@CjqtidzTq!y=v!UdV}oum;ZTNQg1VoRB_@}fjjWCS7fUbtap
z@1E|$=>!M_D`Zn?KdC1+tbBxUs77Q?;v6GhAsk*<SsYSQBfhfo5yF9c;z8I)cHL#o
zgHLlO4`_CUdM~U;Q@3pt9p*M#Xtv1h5?LakIS=Yk&a~}LO2)9Co3Qx*QbtemM-V^~
zPAMzDfm^Wtdg;7M8#yI-tEk559PAH0SC;HiEH$FCD=Boe@)`8kl3dYuqJz~fqZDHc
zL-6qNvo>KrDdSgII?M`3$Q0-}DZ$~1#fc~OZ<Y3}&QG$5=0MFBS~`?zB|>RXYDfPP
zhx2IzzJT+`jrw9y?0Pg_d79x_gHNEDxUR2Gami?rG5HAJRLW=3-y+bY;3@q)dijP!
j?*u*tlfhQ-fg8j>4`TF){03tu00000NkvXXu0mjf@a2R3

diff --git a/img/Star@3x.png b/img/Star@3x.png
deleted file mode 100644
index b155368ec472383391e79bc81e106936a9428949..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 1192
zcmV;Z1XufsP)<h;3K|Lk000e1NJLTq002n<002k`1^@s6x-Zyy00009a7bBm001F4
z001F40Y#QEU;qFB0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH1U*SaK~#7F?VO8s
z+aMH%uX8$pH>fs<H>kQnx`8u6+zFCSkYob66SSQ`?gVirkUD|0!R@8sM#dKeLPpQ;
z9FFzkq2E6U0XD$Q%*@Qpe-Lp=;hw?^z9Y8pC?qg-NQ4!I0?mkXE0`+6B@_-=&tZ~6
zDS--f=P<ddM>m`q#CW2xHJr;~Dkv9mth8&ms+htQFt}*LT(_B|n+z^0EZ66gbCbbE
zYs>ZNq}=4|qDs7cGH&v9Q6=6q+|<-XmH7GS-PF`YmH6X!Q>cq7>ExqzQ>cq7>5S7&
zZMmqDUOqZE(GC1)!$p<!I5&y0rzH#xl;cEsn=Y!P&8CYN98Ot40O}iDsqm3PqP7!5
zl-tE(v4yZaQ~EHqMQ;@LD*QQaQ=V@SfU`*In@T~7aTLbK=Fe2v4YUB2<$*r#J))e6
zM`hVT8>oi&5#2@*Z9GBC2%#_xaZeeB4CbL6hz}K~pl|~hg3?~2-w3A(t1B0V%tfRJ
z@IzR-1ZnA4Xw%X)plqi^xQ6$3(U&ci_m(@cU@~0OM+RneG5&2+1|{?elx=7NQZQHX
zzS4cvHw}0?|8+!EJJALtFmO<IPfXSH;F7?Si8##8nghspcF){)rR{Ted=Bkr^qB9=
zu`@&HB9$(YMntoF!}G^}G2-f0Fx9R8UQtdE>SQo7P%hHYaT|I;^W>q=Ye26t2*ftp
zwnR2#70M!z|Em+4=i~<~ltrKyXadSU)5}<dvI&GC52mpQbuob|84OyeiwjiAXpllh
z6R48mpoEGpP$grVP^}EjwgJkw{P+S@3SbiIfIWGALH>0k?r4TZsLtJFa8WnIJxVD3
zW%ws_4D!?J2?THj4N+rEzqG^G&^?K^zY85&ZY+|b)b<nTo<!R(A&^j!K56?UbdNrY
z+GOVBgiw+60=z=^{MPnYp^bK9kx-7k=$Y@Jd-lw+AUB0LiOYBo-LqqkrO*Z3qZWDs
z(=`cN5!a@ZcF-U6u1WmrraEVRY_yN))qaTqi@ZKSGq?<cdfQ6)r3z(W2XMz6OCe^P
ziTt#a_hS~{e}7vr=@l5Teu!z8;!ca<`MO2#fpm&<gKle!#5HU1gehb2dQIN~VAF32
z&5KT5#7aE!X~pdV?}pn@(Aq{DWz~A5P2su}{7EPXp)yCY_J%aP34JX&@@6|pH+4rJ
z!tNVO{BIP#Q1}dTwMS{Vc1<D2`mw8$ITmM33HfR8wc*;-p=#xvGgLlXtgnJMmb`UR
zhyn5oy`JFw1~aU`!PFtYx<6PzWU3(Y69K^^0?rr;Eea}@Gv{dMxU|sA`slXOHdkff
zmQ_c8x;0%xEtcOU&=7R1f+nK(lSGv(JPIfV;!|h|^#6wnacu&zaV)5?{KaNa(5(uJ
z^YPjurjWu>8$6v}5{1ez;_uK<KFPLBIlSAxnVFfHzVjFN36T_;y(ka>0000<MNUMn
GLSTZjX)R~~

diff --git a/img/StarFilled@2x.png b/img/StarFilled@2x.png
deleted file mode 100644
index 3962cfa47418793c796feb2a02ce55d03f7875e8..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 578
zcmV-I0=@l-P)<h;3K|Lk000e1NJLTq001!n001xu1^@s6xWJOR00009a7bBm000&x
z000&x0ZCFM@Bjb+0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH0oO@HK~#7F?U{{j
zgFp<0A5jNLH@FRwPLSK6>IT`M*&uX-c7ik$WQ1gcW`ocPnhji^AgUBD;S3y@ichjY
zV2mF&KX(8lBO{$aL`$}PVM`1RPI^sc)))$$tfAXj3;|Abb4SD;+Nr3*Fse~e1^v|s
ztb*=p1Xe+BHH1~rSq)(o^i{)MQL2wc-*6JIY(G>VckBucNkW|XL{nZEwmI9ZShs9b
zw8NS0SlIVvJxP*t$iZF_Mbb;Q1@goV`r#sO%XVN<na`;tf&~~}xSP&E7a7|N3prlz
z@QH8&EOg>p?CE-=PXs|RETWY2#N&5>UNYZCpNKc;B|JCosFd`PLJH{;lA<o8Lr99I
zP`^S_v_(%sQnXFSLQ=$n@`<q6oFHT(^xq;r9}prHks&@up}$x}TjPg&7zmMyu!zrG
z=r0yALwuegBxrbg!96<A4UeHiQ%7(msN0zdD{+>zlekwKU?t8E0^bE>xOO-of1(-4
zo;q>vJ#MG3z&Ea&OLCtm)2riG?rj_z12G6Z`Hu40l3Ov{ob6{wAjF{JD!P4E5X)4=
zi&hEWS8StcNXq}Po;-0khy@3Kr~Ka7k*Du##x(TzyZBU`ql?d*k&%DPFY>?v2Hd=1
QjsO4v07*qoM6N<$f+TS8y8r+H

diff --git a/img/StarFilled@3x.png b/img/StarFilled@3x.png
deleted file mode 100644
index 952b430d1ae9ae7a7c0b349117c7692eb934f96f..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 808
zcmV+@1K0eCP)<h;3K|Lk000e1NJLTq002n<002k`1^@s6x-Zyy00009a7bBm001F4
z001F40Y#QEU;qFB0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH0=-E@K~#7F?VOEm
z+b|G?pF2UifjxoK391d+4eAZjPLN~+cLFODq?y3k!07~bCvZ1-JysN2AQ3HzCy65Y
z0S5^-1e_lpY5p7qWHOmdCKDSA1OUk^x!g!xOPKoyi64u_;x{sX1zJiR`5N=t5~*Vx
z)F9CRLR}$cM1UPH^N3Wj&8#g2QlzDeYW7HtjxI{mO&wj7o|}5QC@nYjbWu8P@^n!e
zZt`?dO}uWRj*Dsv#0Yg<R1=?@sOzGd0x>~d7uCe$CiyY99FAMe8CFef&*9l~;B_aJ
zl}VeVSyeRGwY|T_xV%W5B!1TSva<OC1v-h;AcnaQd11_3yo(}PVZKOwMc86=;Uw`a
zaW8_M;DZQsBXMTjT<rW84uL+)C6%SqZ{nzJ)0Zxh%s8Y#8kl}+)%2xHq%-2_gDxT>
zHEQwB@m$^*w75iiLOM8qtE;&!<#Iq8O>X5H3N$*&K6H=xQ5h@UF<we&!8nB~p$+2_
zs>Dz*4xvg64d*RXiLu~3g(@*NoR?4~rh@Yjs>IYt5?h9g%2JQ_8aJz&#v{U<zO1}Q
z({N94TVczH9}pf-8Uh)-MY5>uN+_e2la@#)gvSX=Xo)oT9$OpK`baI2)(DSJP$Fh0
zspaG?694V$hK4zkp^}$|lR^}zrhoz(A{7W1mA+C#q!2spngR-FOiPHc@t&G^If?VA
zTP)y-Z*vQ|)2{K((O0C8@W)J$&Hmm8iT8F--rRqO&eTF9$NorjJJ63uI_e!+)m@hK
z<BTbx`~0knBRlkGY(C7~3QS3l#7))Q9eN_qZu*FYDPgw~<Q)}GX0v+}JYY)LZX;mN
z0fq`ztTuh(4pYMFTz^xdM~MvvhVCY4K3~EUtz!2LsxoHtcm502D>R-lBG#>@7<O&*
mFm>D8{3?%3CX>lz-r^6>amv0XDkiG{0000<MNUMnLSTZN99}d4

diff --git a/img/Warning@2x.png b/img/Warning@2x.png
deleted file mode 100644
index 57e462fba3654901c1114b306b4fb6cb1328135f..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 659
zcmV;E0&M+>P)<h;3K|Lk000e1NJLTq001xm001xu1^@s6R|5Hm00009a7bBm000&x
z000&x0ZCFM@Bjb+0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH0w_sDK~#7F?V5p6
zgD@0^A3X<fglr%qFhWOQ1Kj{Oup6`+v`)Y|L3IMypl<M+9H|C)K|%<r9^c(Nb4Zf+
zk{9ItNVF1{gaQEIUjEoh6Z+D-vLs1rx@Dx}LRtwsdvy=p8fwL*6&O#UcPT#7kw<A4
z#>rCJPTDj0U8$ty6bm_dW^qhi%b5FxcA5)B&{AkejuuC@Bl_o-K=wfc!B3ud)8(Sm
z#3`qBA24GTG&gExEvt6)Sv9?z2k^U@lP|D87S*+kW&+K5s%BjvJt&thPZS97=R?hz
zx(*~5=biI>3VQAx6NGRXq(nO($9J9y^dGzB*p6u@dO;H~9H>0UFv+_2Y?yxLv;5%k
z0+v(rGADG~89n#OxYRofTmZsozfpW%jrK$LmmB)ytO3Mk4Inma0I^vEh)u`<YD;@o
z?kEuQJw{bp-)#y+umBVx9lx@kfg#W+89}SKU;*eNrjmAcu4&s0E&vxmZM1zR&h_Mi
z+C`EpW)1N1>)mM7h-t-idz@64lRjf~OUiKzI#*Hea|R|C^}2J4zN}JU0d)erRPVlv
zXyn9h5ETMS;^3uvcVWvIP;KKGeJc1pS-SHKN%ew!K_E#So>Kjk2{}nz;UtM;W~tr@
zcuL}k^?`$NB(*ql<{W|JC#l64SkY3a!;8i73u1ocGeMJ9w)4R`zKB%s4D9E_+!aAH
t66#+K1T`;$*S{6Dw394wK56Bj@dag9G%G1HUfBQu002ovPDHLkV1hdw6>|Up

diff --git a/img/Warning@3x.png b/img/Warning@3x.png
deleted file mode 100644
index 27b1425459404fcf960df555439a8f29f6deda9c..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 937
zcmV;a16KTrP)<h;3K|Lk000e1NJLTq002k;002k`1^@s6RqeA!00009a7bBm001F4
z001F40Y#QEU;qFB0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH13pPaK~#7F?VN#i
z+At7?kG>Aj5j+Cjz$0XXutBl`*`Q$pk_qSr?gs4!W&_yZuM~*~39?Q$I&%hn@A(Bp
zu%zF*v$1}es8A>fG9x7bsLY?rzunJfvww8&7*Q5xYs|L8IXnN^g6<8|>Dp{9(rNEP
zDAUQg9~DE{z=h&%VLJ80Y`>6?_00%nI`V{Yjya_<+cR9~)&BK{`A&-|haJ-LOcNUk
z_OnEx9%w4~PBjUgs(b8n0bcAiG>08ro>?-Tu%Y%s1F_!)AFfN=7SaUL^2}?FhQp1}
zH>R!@8uAldo@toI+Rtmsj2Eg+5UQfFfuiIIP5BAOJQEA2k355s^MJ1tOuLpO1=5D}
z8dp4%AU!`-loMZZY}7Om$TLF$e+`yTB*aIK6>ULEK|DX%^2^fzT-b!ja@v;0AK=%H
zJ~VU*a6>gjO%ub9ro4SY_!{S#1i<lPvxU&v2JT02o>2sMO3$-1zOE`zmq_?_9Q4k*
zzD4#`2g=?_hAL1tCmG)R)D;t`3Y7h(>GLO0Hb|heVP|F<_B3%!AGU$wK_OP4A`nXv
zh@l9?Py}Kq0x=YU7>Ym)MIeSE5JM4&p$NoK1Y*!4&_1zjbW{lB4V$ZzqLmgC=XX+q
z5yajve)F%@I9$~TWYK8-ElHZ82*gkXVo;NV;LHS1{_U#c=KAQw1UkD8u@h{9GaGhN
zvhuGVOkF=E>N5KcP4RTr6AQ6zpiIOu=I6(&lcI{k^Kv-|3Dp1WsbK24A^{M#!wj3|
z?wH4ZBmwZ*Lia)fz!@aIGJLieVf)JtLiQIwYNbpIj|fxKC7|2pyTk+*lpwy4W=)rZ
zxCPIaFGwkf8{AuD^eN5}H*wm^r0goa0dH{6C(cQwQ?UVa2KT?BZ3J)Fw#XT_!)?-K
zaEGD=CD6Q73NANsjT0HtxZ*;Z-i&z^;lgRn)TPhhj<{j_K#PGkVklln(+ddXCuCUv
zst#lbJEX0%w>hq+sRHM2Z<$VRaK0?OVZLL-NyPO(I!Ft<K1^aEO>dC4WIRTLTYNOd
zSCE#UPO<-6)4hS?<8eY99*=X?rz&WAz>>WiaL6lLEwdVhLP3=OB*Gr1)1G@a00000
LNkvXXu0mjf`){K0

diff --git a/src/assets.luau b/src/assets.luau
index f65f006b..10c4e9ea 100644
--- a/src/assets.luau
+++ b/src/assets.luau
@@ -1,115 +1,67 @@
 -- This file was @generated by Tarmac. It is not intended for manual editing.
 return {
+	Alert = {
+		Image = "rbxassetid://74272179538762",
+		ImageRectOffset = Vector2.new(0, 308),
+		ImageRectSize = Vector2.new(24, 24),
+	},
 	ChevronRight = {
-		Image = "rbxassetid://128765597212202",
+		Image = "rbxassetid://74272179538762",
 		ImageRectOffset = Vector2.new(49, 226),
 		ImageRectSize = Vector2.new(32, 32),
 	},
 	Component = {
-		Image = "rbxassetid://128765597212202",
+		Image = "rbxassetid://74272179538762",
 		ImageRectOffset = Vector2.new(0, 275),
 		ImageRectSize = Vector2.new(32, 32),
 	},
 	Folder = {
-		Image = "rbxassetid://128765597212202",
+		Image = "rbxassetid://74272179538762",
 		ImageRectOffset = Vector2.new(345, 0),
 		ImageRectSize = Vector2.new(32, 32),
 	},
 	GitHubMark = {
-		Image = "rbxassetid://128765597212202",
+		Image = "rbxassetid://74272179538762",
 		ImageRectOffset = Vector2.new(0, 0),
 		ImageRectSize = Vector2.new(230, 225),
 	},
 	IconLight = {
-		Image = "rbxassetid://128765597212202",
+		Image = "rbxassetid://74272179538762",
 		ImageRectOffset = Vector2.new(231, 65),
 		ImageRectSize = Vector2.new(42, 42),
 	},
 	Magnify = {
-		Image = "rbxassetid://128765597212202",
+		Image = "rbxassetid://74272179538762",
 		ImageRectOffset = Vector2.new(0, 226),
 		ImageRectSize = Vector2.new(48, 48),
 	},
 	Minify = {
-		Image = "rbxassetid://128765597212202",
+		Image = "rbxassetid://74272179538762",
 		ImageRectOffset = Vector2.new(296, 0),
 		ImageRectSize = Vector2.new(48, 48),
 	},
 	Search = {
-		Image = "rbxassetid://128765597212202",
+		Image = "rbxassetid://74272179538762",
 		ImageRectOffset = Vector2.new(296, 49),
 		ImageRectSize = Vector2.new(32, 32),
 	},
-	Star = function(dpiScale)
-		if dpiScale >= 3 then
-			return {
-				Image = "rbxassetid://123049343108173",
-				ImageRectOffset = Vector2.new(0, 0),
-				ImageRectSize = Vector2.new(73, 72),
-			}
-		elseif dpiScale >= 2 then
-			return {
-				Image = "rbxassetid://118393146115398",
-				ImageRectOffset = Vector2.new(0, 0),
-				ImageRectSize = Vector2.new(49, 48),
-			}
-		else
-			return {
-				Image = "rbxassetid://128765597212202",
-				ImageRectOffset = Vector2.new(82, 226),
-				ImageRectSize = Vector2.new(25, 24),
-			}
-		end
-	end,
-	StarFilled = function(dpiScale)
-		if dpiScale >= 3 then
-			return {
-				Image = "rbxassetid://123049343108173",
-				ImageRectOffset = Vector2.new(74, 0),
-				ImageRectSize = Vector2.new(73, 72),
-			}
-		elseif dpiScale >= 2 then
-			return {
-				Image = "rbxassetid://118393146115398",
-				ImageRectOffset = Vector2.new(50, 0),
-				ImageRectSize = Vector2.new(49, 48),
-			}
-		else
-			return {
-				Image = "rbxassetid://128765597212202",
-				ImageRectOffset = Vector2.new(49, 259),
-				ImageRectSize = Vector2.new(25, 24),
-			}
-		end
-	end,
+	Star = {
+		Image = "rbxassetid://74272179538762",
+		ImageRectOffset = Vector2.new(82, 226),
+		ImageRectSize = Vector2.new(25, 24),
+	},
+	StarFilled = {
+		Image = "rbxassetid://74272179538762",
+		ImageRectOffset = Vector2.new(49, 259),
+		ImageRectSize = Vector2.new(25, 24),
+	},
 	Storybook = {
-		Image = "rbxassetid://128765597212202",
+		Image = "rbxassetid://74272179538762",
 		ImageRectOffset = Vector2.new(231, 108),
 		ImageRectSize = Vector2.new(32, 32),
 	},
-	Warning = function(dpiScale)
-		if dpiScale >= 3 then
-			return {
-				Image = "rbxassetid://123049343108173",
-				ImageRectOffset = Vector2.new(0, 73),
-				ImageRectSize = Vector2.new(72, 72),
-			}
-		elseif dpiScale >= 2 then
-			return {
-				Image = "rbxassetid://118393146115398",
-				ImageRectOffset = Vector2.new(0, 49),
-				ImageRectSize = Vector2.new(48, 48),
-			}
-		else
-			return {
-				Image = "rbxassetid://128765597212202",
-				ImageRectOffset = Vector2.new(0, 308),
-				ImageRectSize = Vector2.new(24, 24),
-			}
-		end
-	end,
 	flipbook = {
-		Image = "rbxassetid://128765597212202",
+		Image = "rbxassetid://74272179538762",
 		ImageRectOffset = Vector2.new(231, 0),
 		ImageRectSize = Vector2.new(64, 64),
 	},
diff --git a/tarmac-manifest.toml b/tarmac-manifest.toml
index 31a88cd8..06d90870 100644
--- a/tarmac-manifest.toml
+++ b/tarmac-manifest.toml
@@ -1,113 +1,77 @@
+[inputs."img/Alert.png"]
+hash = "e2f8c263a469a521d876de814fb5e3251084dbce9d41e06bf9037ccfe4710fd3"
+id = 74272179538762
+slice = [[0, 308], [24, 332]]
+packable = true
+
 [inputs."img/ChevronRight.png"]
 hash = "4c91853652b4e0a6317804c54db6397ac0c710c819b282f7be2179e3abdc879c"
-id = 128765597212202
+id = 74272179538762
 slice = [[49, 226], [81, 258]]
 packable = true
 
 [inputs."img/Component.png"]
 hash = "d51b19d5f35ac87fd8156500828f3e217b6fc420b70fee5d97b3577e4e6a8843"
-id = 128765597212202
+id = 74272179538762
 slice = [[0, 275], [32, 307]]
 packable = true
 
 [inputs."img/Folder.png"]
 hash = "4c0e80a363d36a4d127d410795cb13eec74d55871b70174636c0c962c242835c"
-id = 128765597212202
+id = 74272179538762
 slice = [[345, 0], [377, 32]]
 packable = true
 
 [inputs."img/GitHubMark.png"]
 hash = "a0fb973cd9bf0c1123dbe783f7a4093dbcf9b4910d6e385c16a837a04a02306c"
-id = 128765597212202
+id = 74272179538762
 slice = [[0, 0], [230, 225]]
 packable = true
 
 [inputs."img/IconLight.png"]
 hash = "237c66813c2b5a58ae9c46166f72db8698fbb046922e2473ebaca8f7d7376ce1"
-id = 128765597212202
+id = 74272179538762
 slice = [[231, 65], [273, 107]]
 packable = true
 
 [inputs."img/Magnify.png"]
 hash = "351db4b6132a9e02d01bc4cdc3c850099dce358673daaa0da66745b675ad38b1"
-id = 128765597212202
+id = 74272179538762
 slice = [[0, 226], [48, 274]]
 packable = true
 
 [inputs."img/Minify.png"]
 hash = "694db8ce141475fc0f42980d2a6dfd058dfa155348a579fab29ca6dd5684ec14"
-id = 128765597212202
+id = 74272179538762
 slice = [[296, 0], [344, 48]]
 packable = true
 
 [inputs."img/Search.png"]
 hash = "0ab89c0309f577f3ccae0e03b45292f6e2bca51cc9b8250f56ad756e04231c57"
-id = 128765597212202
+id = 74272179538762
 slice = [[296, 49], [328, 81]]
 packable = true
 
 [inputs."img/Star.png"]
 hash = "ae3c74c88729a8600531b2ade6cbaaa091033fffd0de5145b8462d3a15d0a510"
-id = 128765597212202
+id = 74272179538762
 slice = [[82, 226], [107, 250]]
 packable = true
 
-[inputs."img/Star@2x.png"]
-hash = "ebaeeb5c227223841d5bd5c001f58044d1995e5258374305032611a75a9070eb"
-id = 118393146115398
-slice = [[0, 0], [49, 48]]
-packable = true
-
-[inputs."img/Star@3x.png"]
-hash = "34cc831f584d6b54d60f5fe77f480e053e2e0091676f446aa0b9f92cc25f6a4f"
-id = 123049343108173
-slice = [[0, 0], [73, 72]]
-packable = true
-
 [inputs."img/StarFilled.png"]
 hash = "d958666976bb5a1ab5672e5a79d393b28fca26fb348f9b07ceecbcaf059e78f3"
-id = 128765597212202
+id = 74272179538762
 slice = [[49, 259], [74, 283]]
 packable = true
 
-[inputs."img/StarFilled@2x.png"]
-hash = "99216f340f265dcf7fa40c941427d9f09efeddc60ea5fd6a502f288e6d268e63"
-id = 118393146115398
-slice = [[50, 0], [99, 48]]
-packable = true
-
-[inputs."img/StarFilled@3x.png"]
-hash = "b7d7defc50fb8d0dcc92327406e9eb2e8fe379656239424fab6982d449248ba5"
-id = 123049343108173
-slice = [[74, 0], [147, 72]]
-packable = true
-
 [inputs."img/Storybook.png"]
 hash = "2a4b8bd513a8fa7eebae482943ab3435a659359bd94b24eff1c3dcc6f1244799"
-id = 128765597212202
+id = 74272179538762
 slice = [[231, 108], [263, 140]]
 packable = true
 
-[inputs."img/Warning.png"]
-hash = "e2f8c263a469a521d876de814fb5e3251084dbce9d41e06bf9037ccfe4710fd3"
-id = 128765597212202
-slice = [[0, 308], [24, 332]]
-packable = true
-
-[inputs."img/Warning@2x.png"]
-hash = "ecec999c799a628e0672fe1dd4d2c16df9f27caeb66b82a7bd5103326c6463ff"
-id = 118393146115398
-slice = [[0, 49], [48, 97]]
-packable = true
-
-[inputs."img/Warning@3x.png"]
-hash = "9524173aa67880639e5d09b02925e631e1766336bff06ddff2bdf5d834822464"
-id = 123049343108173
-slice = [[0, 73], [72, 145]]
-packable = true
-
 [inputs."img/flipbook.png"]
 hash = "423e3dcff54fce8824f14d7cadcfb90eef2abecd817df2bee13951bd2674f79a"
-id = 128765597212202
+id = 74272179538762
 slice = [[231, 0], [295, 64]]
 packable = true

From 078849fd8ac127a8ed6c26b3ba2bae924403c0ed Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Tue, 17 Dec 2024 15:33:01 -0800
Subject: [PATCH 48/79] Star icon for pinned nodes

---
 src/Storybook/StorybookTreeView.luau | 2 +-
 src/TreeView/TreeNode.luau           | 4 ++--
 src/TreeView/types.luau              | 3 ++-
 src/TreeView/usePinnedInstances.luau | 6 ++++++
 src/TreeView/useTreeNodeIcon.luau    | 4 +++-
 src/themes.luau                      | 3 +++
 6 files changed, 17 insertions(+), 5 deletions(-)

diff --git a/src/Storybook/StorybookTreeView.luau b/src/Storybook/StorybookTreeView.luau
index 2ff00dd9..b818406b 100644
--- a/src/Storybook/StorybookTreeView.luau
+++ b/src/Storybook/StorybookTreeView.luau
@@ -43,7 +43,7 @@ local function StorybookTreeView(props: Props)
 			local pins: TreeNode = {
 				id = HttpService:GenerateGUID(),
 				label = "Starred",
-				icon = "folder",
+				icon = "star",
 				isExpanded = false,
 				children = {},
 			}
diff --git a/src/TreeView/TreeNode.luau b/src/TreeView/TreeNode.luau
index 59303997..afdefb85 100644
--- a/src/TreeView/TreeNode.luau
+++ b/src/TreeView/TreeNode.luau
@@ -158,7 +158,7 @@ local function TreeNode(props: Props)
 				}),
 			}),
 
-			Pin = if props.node.icon == "story" or props.node.icon == "storybook"
+				Pin = if props.node.instance and (props.node.icon == "story" or props.node.icon == "storybook")
 				then React.createElement("ImageButton", {
 					LayoutOrder = 3,
 					BackgroundTransparency = 1,
@@ -166,7 +166,7 @@ local function TreeNode(props: Props)
 					[React.Event.Activated] = onTogglePin,
 				}, {
 					Icon = React.createElement(Sprite, {
-						image = assets.Magnify, -- TODO: Use a new icon for pinning
+							image = if pinning.isPinned(props.node.instance) then assets.StarFilled else assets.Star,
 						color = theme.text,
 						size = UDim2.fromOffset(16, 16),
 					}),
diff --git a/src/TreeView/types.luau b/src/TreeView/types.luau
index f84293fc..7f94bd48 100644
--- a/src/TreeView/types.luau
+++ b/src/TreeView/types.luau
@@ -1,12 +1,13 @@
 local types = {}
 
-export type TreeNodeIcon = "none" | "story" | "storybook" | "folder"
+export type TreeNodeIcon = "none" | "story" | "storybook" | "folder" | "star"
 
 types.TreeNodeIcon = {
 	None = "none" :: "none",
 	Story = "story" :: "story",
 	Storybook = "storybook" :: "storybook",
 	Folder = "folder" :: "folder",
+	Star = "star" :: "star",
 }
 
 export type PartialTreeNode = {
diff --git a/src/TreeView/usePinnedInstances.luau b/src/TreeView/usePinnedInstances.luau
index f0d9349a..fed3a253 100644
--- a/src/TreeView/usePinnedInstances.luau
+++ b/src/TreeView/usePinnedInstances.luau
@@ -82,9 +82,15 @@ local function usePinnedInstances(): (ModuleScript?, (storyModule: ModuleScript?
 		writePinnedPathsToDisk(pinnedPaths)
 	end, { readPinnedPathsFromDisk, writePinnedPathsToDisk })
 
+	local isPinned = useCallback(function(instance: Instance)
+		local pinnedPaths = readPinnedPathsFromDisk()
+		return table.find(pinnedPaths, instance:GetFullName()) ~= nil
+	end, { readPinnedPathsFromDisk })
+
 	return {
 		pin = pin,
 		unpin = unpin,
+		isPinned = isPinned,
 		togglePin = togglePin,
 		getPinnedInstances = getPinnedInstances,
 	}
diff --git a/src/TreeView/useTreeNodeIcon.luau b/src/TreeView/useTreeNodeIcon.luau
index 92525320..a2fc1da7 100644
--- a/src/TreeView/useTreeNodeIcon.luau
+++ b/src/TreeView/useTreeNodeIcon.luau
@@ -1,6 +1,6 @@
 local assets = require("@root/assets")
-local useTheme = require("@root/Common/useTheme")
 local types = require("./types")
+local useTheme = require("@root/Common/useTheme")
 
 type TreeNodeIcon = types.TreeNodeIcon
 
@@ -19,6 +19,8 @@ local function useTreeNodeIcon(icon: TreeNodeIcon): (Sprite, Color3)
 		return assets.Storybook, theme.textFaded
 	elseif icon == types.TreeNodeIcon.Folder then
 		return assets.Folder, theme.directory
+	elseif icon == types.TreeNodeIcon.Star then
+		return assets.Star, theme.star
 	else
 		return assets.Folder, theme.textFaded
 	end
diff --git a/src/themes.luau b/src/themes.luau
index a2b57a8d..b8f6bb72 100644
--- a/src/themes.luau
+++ b/src/themes.luau
@@ -22,6 +22,7 @@ export type Theme = {
 	story: Color3,
 	directory: Color3,
 	alert: Color3,
+	star: Color3,
 
 	github: Color3,
 
@@ -54,6 +55,7 @@ local Light: Theme = {
 	story = tailwind.green500,
 	directory = tailwind.purple500,
 	alert = tailwind.rose500,
+	star = tailwind.yellow400,
 
 	github = Color3.fromHex("#333333"),
 
@@ -86,6 +88,7 @@ local Dark: Theme = {
 	story = tailwind.green500,
 	directory = tailwind.purple500,
 	alert = tailwind.rose500,
+	star = tailwind.yellow400,
 
 	github = Color3.fromHex("#ffffff"),
 

From 8bdce5ee4b3f0239bf950fb22f736f730ceac32e Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Tue, 17 Dec 2024 15:33:41 -0800
Subject: [PATCH 49/79] Remove another todo

---
 src/Storybook/StoryPreview.luau | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/Storybook/StoryPreview.luau b/src/Storybook/StoryPreview.luau
index bcaecfc8..2e3c3cbd 100644
--- a/src/Storybook/StoryPreview.luau
+++ b/src/Storybook/StoryPreview.luau
@@ -59,7 +59,6 @@ local StoryPreview = React.forwardRef(function(providedProps: Props, ref: any)
 
 	React.useEffect(function(): (() -> ())?
 		if props.story and ref.current then
-			-- TODO: Rendering before controls are applied
 			local success, result = xpcall(function()
 				lifecycle.current = Storyteller.render(ref.current, props.story)
 			end, debug.traceback)

From 6614d871b4377b3e8bda3ff9567e08c3400f993e Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Tue, 17 Dec 2024 15:45:55 -0800
Subject: [PATCH 50/79] Adjust star colors

---
 src/themes.luau | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/themes.luau b/src/themes.luau
index b8f6bb72..db5a6325 100644
--- a/src/themes.luau
+++ b/src/themes.luau
@@ -55,7 +55,7 @@ local Light: Theme = {
 	story = tailwind.green500,
 	directory = tailwind.purple500,
 	alert = tailwind.rose500,
-	star = tailwind.yellow400,
+	star = tailwind.amber600,
 
 	github = Color3.fromHex("#333333"),
 
@@ -88,7 +88,7 @@ local Dark: Theme = {
 	story = tailwind.green500,
 	directory = tailwind.purple500,
 	alert = tailwind.rose500,
-	star = tailwind.yellow400,
+	star = tailwind.amber400,
 
 	github = Color3.fromHex("#ffffff"),
 

From 85e877bcb97c1d6a765032413a5ad3d4dad3231b Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 09:06:16 -0800
Subject: [PATCH 51/79] Remove engine building step

---
 .lune/build.luau | 22 ----------------------
 1 file changed, 22 deletions(-)

diff --git a/.lune/build.luau b/.lune/build.luau
index 85d23d7b..3ed3cb56 100644
--- a/.lune/build.luau
+++ b/.lune/build.luau
@@ -1,4 +1,3 @@
-local fs = require("@lune/fs")
 local process = require("@lune/process")
 
 local clean = require("./lib/clean")
@@ -17,30 +16,10 @@ assert(target == "dev" or target == "prod", `bad value for target (must be one o
 local output = if args.output then args.output else `{getPluginsPath(process.os)}/{constants.PLUGIN_FILENAME}`
 assert(typeof(output) == "string", `bad value for output (string expected, got {typeof(output)})`)
 
-local gameEnginePath = args.gameEnginePath
-assert(
-	not gameEnginePath or typeof(gameEnginePath) == "string",
-	`bad value for gameEnginePath (string expected, got {typeof(output)}`
-)
-
 local function build()
-	if not fs.isDir("Packages") then
-		run("lune", { "run", "wally-install" })
-	end
-
 	clean()
 	compile(target)
 
-	if gameEnginePath then
-		local engineBuildPath = run("find", { `{gameEnginePath}/build`, "-name", "optimized" })
-		assert(fs.isDir(engineBuildPath), `failed to find optimized engine build under {gameEnginePath}`)
-
-		local builtInPlugins = run("find", { engineBuildPath, "-name", "BuiltInPlugins", "-print", "-quit" })
-		assert(fs.isDir(builtInPlugins), `failed to find built-in plugins under {engineBuildPath}`)
-
-		output = `{builtInPlugins}/Optimized_Embedded_Signature/{constants.PLUGIN_FILENAME}`
-	end
-
 	run("rojo", { "build", "-o", output })
 end
 
@@ -51,7 +30,6 @@ if args.watch then
 		filePatterns = {
 			"src/.*%.luau",
 			"example/.*%.luau",
-			"Packages/.*%.luau",
 		},
 		onChanged = build,
 	})

From 4d1e48f374f854005decab70370f8d2dbaf0ae1d Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 10:43:48 -0800
Subject: [PATCH 52/79] Fix stories spec

---
 src/stories.spec.luau | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/stories.spec.luau b/src/stories.spec.luau
index 0d26cc17..bb2055b9 100644
--- a/src/stories.spec.luau
+++ b/src/stories.spec.luau
@@ -39,13 +39,13 @@ describeEach({
 		} :: any
 	) :: ModuleLoader.ModuleLoader
 
-	local storybook = Storyteller.loadStorybookModule(mockModuleLoader, storybookModule)
+	local storybook = Storyteller.loadStorybookModule(storybookModule, mockModuleLoader)
 
 	describeEach({
 		Storyteller.findStoryModulesForStorybook(storybook),
 	})("%s", function(storyModule)
 		test("basic mount/unmount lifecycle", function()
-			local story = Storyteller.loadStoryModule(mockModuleLoader, storyModule, storybook)
+			local story = Storyteller.loadStoryModule(storyModule, storybook)
 
 			if story.packages then
 				story.packages = Sift.Dictionary.join(story.packages, {

From 6ee4084af6d621ade14fd30c7371425d72cb8b28 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 10:52:06 -0800
Subject: [PATCH 53/79] Make sure community Flipbook doesn't crash

---
 src/Permissions/canAccess.luau     | 11 +++++++++++
 src/Permissions/tryGetService.luau |  8 +++++++-
 2 files changed, 18 insertions(+), 1 deletion(-)
 create mode 100644 src/Permissions/canAccess.luau

diff --git a/src/Permissions/canAccess.luau b/src/Permissions/canAccess.luau
new file mode 100644
index 00000000..75f98107
--- /dev/null
+++ b/src/Permissions/canAccess.luau
@@ -0,0 +1,11 @@
+local function canAccess(instance: Instance)
+	local success = pcall(function()
+		-- Attempt any use of the instance. If it throws an error, we assume
+		-- that the current script context cannot access it
+		return instance.Name
+	end)
+
+	return success
+end
+
+return canAccess
diff --git a/src/Permissions/tryGetService.luau b/src/Permissions/tryGetService.luau
index 565fa95e..f6c050a3 100644
--- a/src/Permissions/tryGetService.luau
+++ b/src/Permissions/tryGetService.luau
@@ -1,3 +1,5 @@
+local canAccess = require("@root/Permissions/canAccess")
+
 local function tryGetService(serviceName: string): Instance?
 	local service
 
@@ -15,7 +17,11 @@ local function tryGetService(serviceName: string): Instance?
 		service = game:FindFirstChild(serviceName)
 	end)
 
-	return service
+	if canAccess(service) then
+		return service
+	end
+
+	return nil
 end
 
 return tryGetService

From 47b69fe153a512cc5e393812daf50aba00fe3389 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 10:52:17 -0800
Subject: [PATCH 54/79] Add back Packages watching

---
 .lune/build.luau | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.lune/build.luau b/.lune/build.luau
index 3ed3cb56..1e356620 100644
--- a/.lune/build.luau
+++ b/.lune/build.luau
@@ -30,6 +30,7 @@ if args.watch then
 		filePatterns = {
 			"src/.*%.luau",
 			"example/.*%.luau",
+			"Packages/.*%.luau",
 		},
 		onChanged = build,
 	})

From 01051111aa49ebaa4c0289bedb9bee001a03ec17 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 10:59:24 -0800
Subject: [PATCH 55/79] Also install Storyteller to CodeSamples to handle
 analysis errors

---
 .lune/wally-install.luau | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/.lune/wally-install.luau b/.lune/wally-install.luau
index 04d05e79..3871877d 100644
--- a/.lune/wally-install.luau
+++ b/.lune/wally-install.luau
@@ -2,14 +2,14 @@ local process = require("@lune/process")
 
 local run = require("./lib/run")
 
-local function installPackageFromDisk(packageName: string, packagePath: string)
+local function installPackageFromDisk(packageName: string, packagePath: string, runOptions: { [string]: any }?)
 	local homePath = process.env.HOME
 	assert(homePath, "no $HOME env var")
 
 	local absPackagePath = run("realpath", { packagePath })
 
-	run("rm", { `Packages/{packageName}.lua` })
-	run("ln", { "-s", `{absPackagePath}/dist`, `Packages/{packageName}` })
+	run("rm", { `Packages/{packageName}.lua` }, runOptions)
+	run("ln", { "-s", `{absPackagePath}/dist`, `Packages/{packageName}` }, runOptions)
 end
 
 do
@@ -26,6 +26,11 @@ do
 	run("wally", { "install" }, {
 		cwd = "code-samples",
 	})
+
+	installPackageFromDisk("Storyteller", "../storyteller", {
+		cwd = "code-samples",
+	})
+
 	run("rojo", { "sourcemap", "default.project.json", "-o", "sourcemap.json" }, {
 		cwd = "code-samples",
 	})

From 305b41d7363487fa8f573c60a49f7dae62de71e8 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 12:02:34 -0800
Subject: [PATCH 56/79] Remove `loader` prop. Storyteller handles this for us
 now

---
 .lune/wally-install.luau                   |  1 +
 src/Navigation/Screen.luau                 |  4 ----
 src/Plugin/PluginApp.luau                  | 10 ++--------
 src/Plugin/PluginApp.story.luau            |  6 +-----
 src/Plugin/createFlipbookPlugin.luau       |  7 +------
 src/Storybook/StoryCanvas.luau             |  4 ----
 src/Storybook/StoryView.luau               |  6 +-----
 src/Storybook/StorybookTreeView.story.luau |  5 +----
 src/stories.spec.luau                      | 13 +++++--------
 wally.toml                                 |  5 +----
 10 files changed, 13 insertions(+), 48 deletions(-)

diff --git a/.lune/wally-install.luau b/.lune/wally-install.luau
index 3871877d..4f5ae971 100644
--- a/.lune/wally-install.luau
+++ b/.lune/wally-install.luau
@@ -16,6 +16,7 @@ do
 	run("wally", { "install" })
 
 	installPackageFromDisk("Storyteller", "../storyteller")
+	-- ModuleLoader is a dependency of Storyteller, make sure to install both.
 	installPackageFromDisk("ModuleLoader", "../module-loader")
 
 	run("rojo", { "sourcemap", "build.project.json", "-o", "sourcemap.json" })
diff --git a/src/Navigation/Screen.luau b/src/Navigation/Screen.luau
index ac3909dd..4060a0ce 100644
--- a/src/Navigation/Screen.luau
+++ b/src/Navigation/Screen.luau
@@ -1,4 +1,3 @@
-local ModuleLoader = require("@pkg/ModuleLoader")
 local React = require("@pkg/React")
 local Storyteller = require("@pkg/Storyteller")
 
@@ -10,11 +9,9 @@ local StoryCanvas = require("@root/Storybook/StoryCanvas")
 
 local useMemo = React.useMemo
 
-type ModuleLoader = ModuleLoader.ModuleLoader
 type LoadedStorybook = Storyteller.LoadedStorybook
 
 export type Props = {
-	loader: ModuleLoader,
 	story: ModuleScript?,
 	storybook: LoadedStorybook?,
 }
@@ -27,7 +24,6 @@ local function Screen(props: Props)
 		if currentScreen == "Home" then
 			if props.story and props.storybook then
 				return React.createElement(StoryCanvas, {
-					loader = props.loader,
 					story = props.story,
 					storybook = props.storybook,
 				})
diff --git a/src/Plugin/PluginApp.luau b/src/Plugin/PluginApp.luau
index 22a5ff67..8af915ef 100644
--- a/src/Plugin/PluginApp.luau
+++ b/src/Plugin/PluginApp.luau
@@ -1,4 +1,3 @@
-local ModuleLoader = require("@pkg/ModuleLoader")
 local React = require("@pkg/React")
 local Storyteller = require("@pkg/Storyteller")
 
@@ -16,14 +15,10 @@ local TOPBAR_HEIGHT_PX = 32
 
 type LoadedStorybook = Storyteller.LoadedStorybook
 
-export type Props = {
-	loader: ModuleLoader.ModuleLoader,
-}
-
-local function App(props: Props)
+local function App()
 	local theme = useTheme()
 	local settingsContext = SettingsContext.use()
-	local storybooks = Storyteller.useStorybooks(game, props.loader)
+	local storybooks = Storyteller.useStorybooks(game)
 	local storyModule: ModuleScript?, setStoryModule = React.useState(nil :: ModuleScript?)
 	local storybook, setStorybook = React.useState(nil :: LoadedStorybook?)
 	local initialSidebarWidth = settingsContext.getSetting("sidebarWidth")
@@ -91,7 +86,6 @@ local function App(props: Props)
 				BackgroundTransparency = 1,
 			}, {
 				Screen = React.createElement(Screen, {
-					loader = props.loader,
 					story = storyModule,
 					storybook = storybook,
 				}),
diff --git a/src/Plugin/PluginApp.story.luau b/src/Plugin/PluginApp.story.luau
index 858937b7..e62cede1 100644
--- a/src/Plugin/PluginApp.story.luau
+++ b/src/Plugin/PluginApp.story.luau
@@ -1,4 +1,3 @@
-local ModuleLoader = require("@pkg/ModuleLoader")
 local React = require("@pkg/React")
 
 local ContextProviders = require("@root/Common/ContextProviders")
@@ -11,9 +10,6 @@ return {
 	story = React.createElement(ContextProviders, {
 		plugin = MockPlugin.new() :: any,
 	}, {
-		PluginApp = React.createElement(PluginApp, {
-			loader = ModuleLoader.new(),
-			plugin = plugin,
-		}),
+		PluginApp = React.createElement(PluginApp),
 	}),
 }
diff --git a/src/Plugin/createFlipbookPlugin.luau b/src/Plugin/createFlipbookPlugin.luau
index d19ba68f..62db0e5b 100644
--- a/src/Plugin/createFlipbookPlugin.luau
+++ b/src/Plugin/createFlipbookPlugin.luau
@@ -4,7 +4,6 @@ if RunService:IsRunning() or not RunService:IsEdit() then
 	return
 end
 
-local ModuleLoader = require("@pkg/ModuleLoader")
 local React = require("@pkg/React")
 local ReactRoblox = require("@pkg/ReactRoblox")
 
@@ -21,19 +20,15 @@ local function createFlipbookPlugin(
 }
 	local connections: { RBXScriptConnection } = {}
 	local root = ReactRoblox.createRoot(widget)
-	local loader = ModuleLoader.new()
 
 	local app = React.createElement(ContextProviders, {
 		plugin = plugin,
 	}, {
-		PluginApp = React.createElement(PluginApp, {
-			loader = loader,
-		}),
+		PluginApp = React.createElement(PluginApp),
 	})
 
 	local function unmount()
 		root:unmount()
-		loader:clear()
 	end
 
 	local function mount()
diff --git a/src/Storybook/StoryCanvas.luau b/src/Storybook/StoryCanvas.luau
index a1927253..f8888ae7 100644
--- a/src/Storybook/StoryCanvas.luau
+++ b/src/Storybook/StoryCanvas.luau
@@ -1,4 +1,3 @@
-local ModuleLoader = require("@pkg/ModuleLoader")
 local React = require("@pkg/React")
 local Storyteller = require("@pkg/Storyteller")
 
@@ -7,12 +6,10 @@ local useTheme = require("@root/Common/useTheme")
 
 local e = React.createElement
 
-type ModuleLoader = ModuleLoader.ModuleLoader
 type LoadedStorybook = Storyteller.LoadedStorybook
 
 type Props = {
 	story: ModuleScript,
-	loader: ModuleLoader,
 	storybook: LoadedStorybook,
 	layoutOrder: number?,
 }
@@ -43,7 +40,6 @@ local function Canvas(props: Props)
 			}),
 
 			StoryView = props.story and e(StoryView, {
-				loader = props.loader,
 				story = props.story,
 				storybook = props.storybook,
 			}),
diff --git a/src/Storybook/StoryView.luau b/src/Storybook/StoryView.luau
index 6f4db7a8..6f8c6af1 100644
--- a/src/Storybook/StoryView.luau
+++ b/src/Storybook/StoryView.luau
@@ -1,6 +1,5 @@
 local Selection = game:GetService("Selection")
 
-local ModuleLoader = require("@pkg/ModuleLoader")
 local React = require("@pkg/React")
 local Sift = require("@pkg/Sift")
 local Storyteller = require("@pkg/Storyteller")
@@ -20,11 +19,9 @@ local useZoom = require("@root/Common/useZoom")
 
 local e = React.createElement
 
-type ModuleLoader = ModuleLoader.ModuleLoader
 type LoadedStorybook = Storyteller.LoadedStorybook
 
 type Props = {
-	loader: ModuleLoader,
 	story: ModuleScript,
 	storybook: LoadedStorybook,
 }
@@ -32,7 +29,7 @@ type Props = {
 local function StoryView(props: Props)
 	local theme = useTheme()
 	local settingsContext = SettingsContext.use()
-	local story, storyErr = Storyteller.useStory(props.story, props.storybook, props.loader)
+	local story, storyErr = Storyteller.useStory(props.story, props.storybook)
 	local zoom = useZoom(props.story)
 	local plugin = React.useContext(PluginContext.Context)
 	local changedControls, setChangedControls = React.useState({})
@@ -46,7 +43,6 @@ local function StoryView(props: Props)
 	end, { story })
 
 	local controlsSchema = if story then story.controls else nil
-
 	local showControls = controlsSchema and not Sift.isEmpty(controlsSchema)
 
 	local setControl = React.useCallback(function(control: string, newValue: any)
diff --git a/src/Storybook/StorybookTreeView.story.luau b/src/Storybook/StorybookTreeView.story.luau
index fc31a4d3..6903f111 100644
--- a/src/Storybook/StorybookTreeView.story.luau
+++ b/src/Storybook/StorybookTreeView.story.luau
@@ -1,4 +1,3 @@
-local ModuleLoader = require("@pkg/ModuleLoader")
 local React = require("@pkg/React")
 local Storyteller = require("@pkg/Storyteller")
 
@@ -6,10 +5,8 @@ local ContextProviders = require("@root/Common/ContextProviders")
 local MockPlugin = require("@root/Testing/MockPlugin")
 local StorybookTreeView = require("./StorybookTreeView")
 
-local loader = ModuleLoader.new()
-
 local function Story()
-	local storybooks = Storyteller.useStorybooks(game, loader)
+	local storybooks = Storyteller.useStorybooks(game)
 
 	return React.createElement("Frame", {
 		Size = UDim2.fromOffset(300, 0),
diff --git a/src/stories.spec.luau b/src/stories.spec.luau
index bb2055b9..a8a0bcf7 100644
--- a/src/stories.spec.luau
+++ b/src/stories.spec.luau
@@ -1,7 +1,6 @@
 local CoreGui = game:GetService("CoreGui")
 
 local JestGlobals = require("@pkg/JestGlobals")
-local ModuleLoader = require("@pkg/ModuleLoader")
 local React = require("@pkg/React")
 local ReactRoblox = require("@pkg/ReactRoblox")
 local Sift = require("@pkg/Sift")
@@ -31,13 +30,11 @@ describeEach({
 	-- FIXME: This is needed to get around a bug with React renders. I'm hoping
 	-- to keep this for now, but in the future this should really be a
 	-- ModuleLoader instance
-	local mockModuleLoader = (
-		{
-			require = function(_self, path)
-				return (require :: any)(path)
-			end,
-		} :: any
-	) :: ModuleLoader.ModuleLoader
+	local mockModuleLoader = {
+		require = function(_self, path)
+			return (require :: any)(path)
+		end,
+	} :: any
 
 	local storybook = Storyteller.loadStorybookModule(storybookModule, mockModuleLoader)
 
diff --git a/wally.toml b/wally.toml
index 8a954484..8189576d 100644
--- a/wally.toml
+++ b/wally.toml
@@ -7,7 +7,6 @@ realm = "shared"
 exclude = ["*"]
 
 [dependencies]
-ModuleLoader = "flipbook-labs/module-loader@0.6.2"
 Storyteller = "flipbook-labs/storyteller@0.6.0"
 React = "jsdotlua/react@17.0.2"
 ReactRoblox = "jsdotlua/react-roblox@17.0.2"
@@ -20,8 +19,6 @@ Roact = "roblox/roact@1.4.4"
 Jest = "jsdotlua/jest@3.6.1-rc.2"
 JestGlobals = "jsdotlua/jest-globals@3.6.1-rc.2"
 
-# ModuleLoader dependencies
-Janitor = "howmanysmall/janitor@1.13.15"
-
 # Storyteller dependencies
 Prospector = "egomoose/prospector@1.1.0"
+Janitor = "howmanysmall/janitor@1.13.15"

From 6c3538b55592571a8ed55e098b156cb7a327c3fb Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 12:02:43 -0800
Subject: [PATCH 57/79] Couple small fixes

---
 src/RobloxInternal/getMostLikelyProjectSources.luau | 1 +
 src/TreeView/TreeNode.luau                          | 4 ++--
 2 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/src/RobloxInternal/getMostLikelyProjectSources.luau b/src/RobloxInternal/getMostLikelyProjectSources.luau
index d8419cc0..57a73f6c 100644
--- a/src/RobloxInternal/getMostLikelyProjectSources.luau
+++ b/src/RobloxInternal/getMostLikelyProjectSources.luau
@@ -23,6 +23,7 @@ local function getMostLikelyProjectSources(): { Instance }
 					return false
 				end
 			end
+			return nil
 		end)
 
 		return Sift.List.map(sorted, function(internalSyncItem)
diff --git a/src/TreeView/TreeNode.luau b/src/TreeView/TreeNode.luau
index afdefb85..f6cb4ba2 100644
--- a/src/TreeView/TreeNode.luau
+++ b/src/TreeView/TreeNode.luau
@@ -158,7 +158,7 @@ local function TreeNode(props: Props)
 				}),
 			}),
 
-				Pin = if props.node.instance and (props.node.icon == "story" or props.node.icon == "storybook")
+			Pin = if props.node.instance and (props.node.icon == "story" or props.node.icon == "storybook")
 				then React.createElement("ImageButton", {
 					LayoutOrder = 3,
 					BackgroundTransparency = 1,
@@ -166,7 +166,7 @@ local function TreeNode(props: Props)
 					[React.Event.Activated] = onTogglePin,
 				}, {
 					Icon = React.createElement(Sprite, {
-							image = if pinning.isPinned(props.node.instance) then assets.StarFilled else assets.Star,
+						image = if pinning.isPinned(props.node.instance) then assets.StarFilled else assets.Star,
 						color = theme.text,
 						size = UDim2.fromOffset(16, 16),
 					}),

From 8e4b58ed7ab127f8798fc8c504e495774a34beea Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 12:50:46 -0800
Subject: [PATCH 58/79] Only read from disk once for pinned instances

---
 src/Common/ContextProviders.luau     |  2 +
 src/Plugin/LocalStorageContext.luau  | 99 ++++++++++++++++++++++++++++
 src/Plugin/createFlipbookPlugin.luau |  1 -
 src/TreeView/usePinnedInstances.luau | 84 +++++++++--------------
 4 files changed, 132 insertions(+), 54 deletions(-)
 create mode 100644 src/Plugin/LocalStorageContext.luau

diff --git a/src/Common/ContextProviders.luau b/src/Common/ContextProviders.luau
index 430571ed..4d862c5e 100644
--- a/src/Common/ContextProviders.luau
+++ b/src/Common/ContextProviders.luau
@@ -2,6 +2,7 @@ local React = require("@pkg/React")
 local TreeView = require("@root/TreeView")
 
 local ContextStack = require("@root/Common/ContextStack")
+local LocalStorageContext = require("@root/Plugin/LocalStorageContext")
 local NavigationContext = require("@root/Navigation/NavigationContext")
 local PluginContext = require("@root/Plugin/PluginContext")
 local SettingsContext = require("@root/UserSettings/SettingsContext")
@@ -20,6 +21,7 @@ local function ContextProviders(props: Props)
 			React.createElement(NavigationContext.Provider, {
 				defaultScreen = "Home",
 			}),
+			React.createElement(LocalStorageContext.Provider),
 			React.createElement(SettingsContext.Provider),
 			React.createElement(TreeView.TreeViewProvider),
 		},
diff --git a/src/Plugin/LocalStorageContext.luau b/src/Plugin/LocalStorageContext.luau
new file mode 100644
index 00000000..ddd9b9f0
--- /dev/null
+++ b/src/Plugin/LocalStorageContext.luau
@@ -0,0 +1,99 @@
+local HttpService = game:GetService("HttpService")
+
+local React = require("@pkg/React")
+local Sift = require("@pkg/Sift")
+
+local PluginContext = require("@root/Plugin/PluginContext")
+local usePrevious = require("@root/Common/usePrevious")
+
+local useCallback = React.useCallback
+local useContext = React.useContext
+local useEffect = React.useEffect
+local useMemo = React.useMemo
+local useState = React.useState
+
+export type LocalStorage = {
+	[string]: unknown,
+}
+
+export type LocalStorageContext = {
+	get: (key: string) -> unknown,
+	set: (key: string, value: unknown) -> (),
+}
+
+local LocalStorageContext = React.createContext({})
+
+export type Props = {
+	storageKey: string?,
+	children: React.Node,
+}
+
+local function LocalStorageProvider(props: Props)
+	local plugin = useContext(PluginContext.Context)
+
+	local storageKey = useMemo(function()
+		return if props.storageKey then props.storageKey else `{plugin.Name}LocalStorage`
+	end, { props.storageKey, plugin })
+
+	local loadFromDisk = useCallback(function(): LocalStorage
+		local data = plugin:GetSetting(storageKey)
+		if data then
+			local json = HttpService:JSONDecode(data)
+			if json then
+				return json
+			end
+		end
+		return {}
+	end, { plugin, storageKey })
+
+	local storage, setStorage = useState(loadFromDisk)
+	local prevStorage = usePrevious(storage)
+
+	local saveToDisk = useCallback(function()
+		local data = HttpService:JSONEncode(storage)
+		if data then
+			plugin:SetSetting(storageKey, data)
+		end
+	end, { plugin, storageKey, storage })
+
+	local get = useCallback(function(key: string)
+		return storage[key]
+	end, { storage })
+
+	local set = useCallback(function(key: string, value: unknown)
+		setStorage(function(prev)
+			return Sift.Dictionary.join(prev, {
+				[key] = if typeof(value) == "function" then value(prev[key]) else value,
+			})
+		end)
+	end, { storage })
+
+	useEffect(function()
+		if storage and storage ~= prevStorage then
+			saveToDisk()
+		end
+	end, { storage, prevStorage, saveToDisk })
+
+	local context: LocalStorageContext = {
+		get = get,
+		set = set,
+	}
+
+	return React.createElement(LocalStorageContext.Provider, {
+		value = context,
+	}, props.children)
+end
+
+local function useLocalStorage(): TreeViewContext
+	local context = useContext(LocalStorageContext)
+	if not context then
+		local contextName = script.Name
+		error(`failed to use {contextName}, is \`{contextName}.Provider\` defined in the React hierarchy?`)
+	end
+	return context
+end
+
+return {
+	Provider = LocalStorageProvider,
+	use = useLocalStorage,
+}
diff --git a/src/Plugin/createFlipbookPlugin.luau b/src/Plugin/createFlipbookPlugin.luau
index 62db0e5b..2d8cd79f 100644
--- a/src/Plugin/createFlipbookPlugin.luau
+++ b/src/Plugin/createFlipbookPlugin.luau
@@ -65,7 +65,6 @@ local function createFlipbookPlugin(
 	end
 
 	local function destroy()
-		print("destroy")
 		unmount()
 		for _, connection in connections do
 			connection:Disconnect()
diff --git a/src/TreeView/usePinnedInstances.luau b/src/TreeView/usePinnedInstances.luau
index fed3a253..0ca670dd 100644
--- a/src/TreeView/usePinnedInstances.luau
+++ b/src/TreeView/usePinnedInstances.luau
@@ -1,12 +1,12 @@
-local HttpService = game:GetService("HttpService")
-
 local React = require("@pkg/React")
+local Sift = require("@pkg/Sift")
 
-local PluginContext = require("@root/Plugin/PluginContext")
+local LocalStorageContext = require("@root/Plugin/LocalStorageContext")
 local getInstanceFromFullName = require("@root/Common/getInstanceFromFullName")
 
-local useContext = React.useContext
 local useCallback = React.useCallback
+local useState = React.useState
+local useEffect = React.useEffect
 
 local PINNED_INSTANCES_KEY = "pinnedInstancePaths"
 
@@ -16,45 +16,31 @@ export type PinnedInstance = {
 }
 
 local function usePinnedInstances(): (ModuleScript?, (storyModule: ModuleScript?) -> ())
-	local plugin = useContext(PluginContext.Context)
-
-	local readPinnedPathsFromDisk = useCallback(function(): { string }
-		local data = plugin:GetSetting(PINNED_INSTANCES_KEY)
-		if data then
-			local json = HttpService:JSONDecode(data)
-			if json then
-				return json
-			end
-		end
-		return {}
-	end, { plugin })
+	local localStorage = LocalStorageContext.use()
 
-	local writePinnedPathsToDisk = useCallback(function(pins: { string })
-		local data = HttpService:JSONEncode(pins)
-		plugin:SetSetting(PINNED_INSTANCES_KEY, data)
-	end, { plugin })
+	local pinnedPaths, setPinnedPaths = useState(function()
+		return localStorage.get(PINNED_INSTANCES_KEY) or {}
+	end)
 
-	local pin = useCallback(function(instance: Instance)
-		local pinnedPaths = readPinnedPathsFromDisk()
+	useEffect(function()
+		localStorage.set(PINNED_INSTANCES_KEY, pinnedPaths)
+	end, { pinnedPaths })
 
-		table.insert(pinnedPaths, instance:GetFullName())
-
-		writePinnedPathsToDisk(pinnedPaths)
-	end, { readPinnedPathsFromDisk, writePinnedPathsToDisk })
+	local pin = useCallback(function(instance: Instance)
+		setPinnedPaths(function(prev)
+			return Sift.List.append(prev, instance:GetFullName())
+		end)
+	end, {})
 
 	local unpin = useCallback(function(instance: Instance)
-		local pinnedPaths = readPinnedPathsFromDisk()
-
-		local index = table.find(pinnedPaths, instance:GetFullName())
-		if index then
-			table.remove(pinnedPaths, index)
-		end
-
-		writePinnedPathsToDisk(pinnedPaths)
-	end, { readPinnedPathsFromDisk, writePinnedPathsToDisk })
+		setPinnedPaths(function(prev)
+			return Sift.List.filter(prev, function(pinnedPath)
+				return pinnedPath ~= instance:GetFullName()
+			end)
+		end)
+	end, {})
 
 	local getPinnedInstances = useCallback(function(): { PinnedInstance }
-		local pinnedPaths = readPinnedPathsFromDisk()
 		local pinnedInstances: { PinnedInstance } = {}
 
 		for _, pinnedPath in pinnedPaths do
@@ -65,27 +51,19 @@ local function usePinnedInstances(): (ModuleScript?, (storyModule: ModuleScript?
 		end
 
 		return pinnedInstances
-	end, { readPinnedPathsFromDisk })
+	end, { pinnedPaths })
 
-	local togglePin = useCallback(function(instance: Instance)
-		local pinnedPaths = readPinnedPathsFromDisk()
-		local path = instance:GetFullName()
-
-		local index = table.find(pinnedPaths, path)
+	local isPinned = useCallback(function(instance: Instance)
+		return table.find(pinnedPaths, instance:GetFullName()) ~= nil
+	end, { pinnedPaths })
 
-		if index then
-			table.remove(pinnedPaths, index)
+	local togglePin = useCallback(function(instance: Instance)
+		if isPinned(instance) then
+			unpin(instance)
 		else
-			table.insert(pinnedPaths, path)
+			pin(instance)
 		end
-
-		writePinnedPathsToDisk(pinnedPaths)
-	end, { readPinnedPathsFromDisk, writePinnedPathsToDisk })
-
-	local isPinned = useCallback(function(instance: Instance)
-		local pinnedPaths = readPinnedPathsFromDisk()
-		return table.find(pinnedPaths, instance:GetFullName()) ~= nil
-	end, { readPinnedPathsFromDisk })
+	end, { isPinned, unpin, pin })
 
 	return {
 		pin = pin,

From e370b09a7f9f33c4ddbeaf582fb69caa2bdfebec Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 13:20:19 -0800
Subject: [PATCH 59/79] Use LocalStorageContext for useLastOpenedStory

---
 src/Storybook/useLastOpenedStory.luau | 20 +++++++++++---------
 1 file changed, 11 insertions(+), 9 deletions(-)

diff --git a/src/Storybook/useLastOpenedStory.luau b/src/Storybook/useLastOpenedStory.luau
index bd9e1caa..b59457f5 100644
--- a/src/Storybook/useLastOpenedStory.luau
+++ b/src/Storybook/useLastOpenedStory.luau
@@ -1,29 +1,31 @@
 local React = require("@pkg/React")
 
-local PluginContext = require("@root/Plugin/PluginContext")
+local LocalStorageContext = require("@root/Plugin/LocalStorageContext")
 local SettingsContext = require("@root/UserSettings/SettingsContext")
 local getInstanceFromFullName = require("@root/Common/getInstanceFromFullName")
 
-local useContext = React.useContext
 local useCallback = React.useCallback
 local useMemo = React.useMemo
 
-local function useLastOpenedStory(): (ModuleScript?, (storyModule: ModuleScript?) -> ())
-	local plugin = useContext(PluginContext.Context)
+local REMEMBER_LAST_OPENED_STORY_KEY = "rememberLastOpenedStory"
+local LAST_OPENED_STORY_PATH_KEY = "lastOpenedStoryPath"
 
+local function useLastOpenedStory(): (ModuleScript?, (storyModule: ModuleScript?) -> ())
+	local localStorage = LocalStorageContext.use()
 	local settingsContext = SettingsContext.use()
-	local rememberLastOpenedStory = settingsContext.getSetting("rememberLastOpenedStory")
 
 	local setLastOpenedStory = useCallback(function(storyModule: ModuleScript?)
-		plugin:SetSetting("lastOpenedStoryPath", if storyModule then storyModule:GetFullName() else nil)
-	end, { plugin })
+		localStorage.set(LAST_OPENED_STORY_PATH_KEY, if storyModule then storyModule:GetFullName() else nil)
+	end, { localStorage })
 
 	local lastOpenedStory = useMemo(function(): ModuleScript?
+		local rememberLastOpenedStory = settingsContext.getSetting(REMEMBER_LAST_OPENED_STORY_KEY)
+
 		if not rememberLastOpenedStory then
 			return nil
 		end
 
-		local lastOpenedStoryPath = plugin:GetSetting("lastOpenedStoryPath")
+		local lastOpenedStoryPath = localStorage.get(LAST_OPENED_STORY_PATH_KEY)
 
 		if lastOpenedStoryPath then
 			local instance = getInstanceFromFullName(lastOpenedStoryPath)
@@ -34,7 +36,7 @@ local function useLastOpenedStory(): (ModuleScript?, (storyModule: ModuleScript?
 		end
 
 		return nil
-	end, { rememberLastOpenedStory, plugin })
+	end, { settingsContext, localStorage })
 
 	return lastOpenedStory, setLastOpenedStory
 end

From 4c76445e1599c6fbe9a7dfcc3567f45236bbb3d7 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 13:37:11 -0800
Subject: [PATCH 60/79] Fix dropdown controls when the user passes a dictionary

---
 src/Storybook/StoryControls.luau | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/src/Storybook/StoryControls.luau b/src/Storybook/StoryControls.luau
index 49b17fcb..23f6eab4 100644
--- a/src/Storybook/StoryControls.luau
+++ b/src/Storybook/StoryControls.luau
@@ -9,6 +9,10 @@ local useTheme = require("@root/Common/useTheme")
 local useMemo = React.useMemo
 local e = React.createElement
 
+local function isArray(obj: { [any]: any }): boolean
+	return #obj > 0 and next(obj, #obj) == nil
+end
+
 type Props = {
 	controlsSchema: { [string]: any },
 	changedControls: { [string]: any },
@@ -52,7 +56,7 @@ local function StoryControls(props: Props)
 				initialState = control.value,
 				onStateChange = setControl,
 			})
-		elseif controlType == "table" then
+		elseif controlType == "table" and isArray(control.value) then
 			local default = props.changedControls[control.name]
 			option = React.createElement(Dropdown, {
 				default = if default then default else control.value[1],

From b1ae5550e7093a809b81fa869b3e03020b57ff0e Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 14:16:01 -0800
Subject: [PATCH 61/79] Render pages for unavialable storybooks

---
 src/Navigation/Screen.luau           |  5 +++
 src/Panels/Sidebar.luau              |  3 ++
 src/Plugin/PluginApp.luau            | 10 +++++
 src/Storybook/StorybookTreeView.luau | 64 +++++++++++++++++++---------
 src/TreeView/types.luau              |  3 +-
 src/TreeView/useTreeNodeIcon.luau    |  2 +
 6 files changed, 65 insertions(+), 22 deletions(-)

diff --git a/src/Navigation/Screen.luau b/src/Navigation/Screen.luau
index 4060a0ce..682a72b3 100644
--- a/src/Navigation/Screen.luau
+++ b/src/Navigation/Screen.luau
@@ -6,6 +6,7 @@ local NavigationContext = require("@root/Navigation/NavigationContext")
 local NoStorySelected = require("@root/Storybook/NoStorySelected")
 local SettingsView = require("@root/UserSettings/SettingsView")
 local StoryCanvas = require("@root/Storybook/StoryCanvas")
+local StoryError = require("@root/Storybook/StoryError")
 
 local useMemo = React.useMemo
 
@@ -27,6 +28,10 @@ local function Screen(props: Props)
 					story = props.story,
 					storybook = props.storybook,
 				})
+			elseif props.unavailableStorybook then
+				return React.createElement(StoryError, {
+					err = `Failed to load {props.unavailableStorybook.storybook.name}\n\n{props.unavailableStorybook.problem}`,
+				})
 			else
 				return React.createElement(NoStorySelected)
 			end
diff --git a/src/Panels/Sidebar.luau b/src/Panels/Sidebar.luau
index 0c9856e7..c9dead18 100644
--- a/src/Panels/Sidebar.luau
+++ b/src/Panels/Sidebar.luau
@@ -14,12 +14,14 @@ local nextLayoutOrder = require("@root/Common/nextLayoutOrder")
 local useTheme = require("@root/Common/useTheme")
 
 type LoadedStorybook = Storyteller.LoadedStorybook
+type UnavailableStorybook = Storyteller.UnavailableStorybook
 
 local e = React.createElement
 
 type Props = {
 	layoutOrder: number?,
 	onStoryChanged: (storyModule: ModuleScript?, storybook: LoadedStorybook?) -> (),
+	onShowErrorPage: (unavailableStorybook: UnavailableStorybook) -> (),
 	storybooks: {
 		avialable: { LoadedStorybook },
 		unavailable: { UnavailableStorybook },
@@ -106,6 +108,7 @@ local function Sidebar(props: Props)
 				searchTerm = search,
 				storybooks = props.storybooks,
 				onStoryChanged = props.onStoryChanged,
+				onShowErrorPage = props.onShowErrorPage,
 			}),
 		}),
 	})
diff --git a/src/Plugin/PluginApp.luau b/src/Plugin/PluginApp.luau
index 8af915ef..a7c4cf91 100644
--- a/src/Plugin/PluginApp.luau
+++ b/src/Plugin/PluginApp.luau
@@ -14,6 +14,7 @@ local useTheme = require("@root/Common/useTheme")
 local TOPBAR_HEIGHT_PX = 32
 
 type LoadedStorybook = Storyteller.LoadedStorybook
+type UnavailableStorybook = Storyteller.UnavailableStorybook
 
 local function App()
 	local theme = useTheme()
@@ -21,6 +22,7 @@ local function App()
 	local storybooks = Storyteller.useStorybooks(game)
 	local storyModule: ModuleScript?, setStoryModule = React.useState(nil :: ModuleScript?)
 	local storybook, setStorybook = React.useState(nil :: LoadedStorybook?)
+	local unavailableStorybook, setUnavailableStorybook = React.useState(nil :: LoadedStorybook?)
 	local initialSidebarWidth = settingsContext.getSetting("sidebarWidth")
 	local sidebarWidth, setSidebarWidth = React.useState(initialSidebarWidth)
 	local navigation = NavigationContext.use()
@@ -35,6 +37,12 @@ local function App()
 		setStorybook(newStorybook)
 	end, { navigation.navigateTo } :: { unknown })
 
+	local onShowErrorPage = React.useCallback(function(newUnavailableStorybook: UnavailableStorybook)
+		setStoryModule(nil)
+		setStorybook(nil)
+		setUnavailableStorybook(newUnavailableStorybook)
+	end, {})
+
 	local onSidebarResized = React.useCallback(function(newSize: Vector2)
 		setSidebarWidth(newSize.X)
 	end, {})
@@ -62,6 +70,7 @@ local function App()
 		}, {
 			Sidebar = React.createElement(Sidebar, {
 				onStoryChanged = onStoryChanged,
+				onShowErrorPage = onShowErrorPage,
 				storybooks = storybooks,
 			}),
 		}),
@@ -88,6 +97,7 @@ local function App()
 				Screen = React.createElement(Screen, {
 					story = storyModule,
 					storybook = storybook,
+					unavailableStorybook = unavailableStorybook,
 				}),
 			}),
 		}),
diff --git a/src/Storybook/StorybookTreeView.luau b/src/Storybook/StorybookTreeView.luau
index b818406b..49d9ea06 100644
--- a/src/Storybook/StorybookTreeView.luau
+++ b/src/Storybook/StorybookTreeView.luau
@@ -23,6 +23,7 @@ export type Props = {
 		unavailable: { UnavailableStorybook },
 	},
 	onStoryChanged: ((storyModule: ModuleScript?, storybook: LoadedStorybook?) -> ())?,
+	onShowErrorPage: ((unavailableStorybook: UnavailableStorybook) -> ())?,
 	layoutOrder: number?,
 }
 
@@ -32,6 +33,7 @@ local function StorybookTreeView(props: Props)
 	local selectedNode = treeViewContext.getSelectedNode()
 	local prevSelectedNode = usePrevious(selectedNode)
 	local storybookByNodeId = useRef({} :: { [string]: LoadedStorybook })
+	local unavailableStorybookByNodeId = useRef({} :: { [string]: UnavailableStorybook })
 	local lastOpenedStory, setLastOpenedStory = useLastOpenedStory()
 	local pinning = usePinnedInstances()
 
@@ -86,9 +88,15 @@ local function StorybookTreeView(props: Props)
 			}
 
 			for _, unavailableStorybook in props.storybooks.unavailable do
-				local root = createTreeNodesForStorybook(unavailableStorybook.storybook)
+				local root = {
+					id = HttpService:GenerateGUID(),
+					label = unavailableStorybook.storybook.name,
+					icon = "alert",
+					isExpanded = false,
+					children = {},
+				}
 				table.insert(unavailableStorybooks.children, root)
-				storybookByNodeId.current[root.id] = unavailableStorybook.storybook
+				unavailableStorybookByNodeId.current[root.id] = unavailableStorybook
 			end
 
 			table.insert(roots, unavailableStorybooks)
@@ -121,28 +129,42 @@ local function StorybookTreeView(props: Props)
 		end
 	end, { lastOpenedStory, treeViewContext.getNodeByInstance, treeViewContext.activateNode } :: { unknown })
 
-	useEffect(function()
-		if props.onStoryChanged and selectedNode ~= prevSelectedNode then
-			if selectedNode then
-				if
-					selectedNode.icon == TreeView.TreeNodeIcon.Story
-					and selectedNode.instance
-					and selectedNode.instance:IsA("ModuleScript")
-				then
-					local ancestry = TreeView.getAncestry(selectedNode)
-					local root = ancestry[#ancestry]
-					local storybook = storybookByNodeId.current[root.id]
-
-					if storybook then
-						props.onStoryChanged(selectedNode.instance, storybook)
-						setLastOpenedStory(selectedNode.instance)
+	useEffect(
+		function()
+			if selectedNode and selectedNode ~= prevSelectedNode then
+				if props.onStoryChanged then
+					if selectedNode then
+						if
+							selectedNode.icon == TreeView.TreeNodeIcon.Story
+							and selectedNode.instance
+							and selectedNode.instance:IsA("ModuleScript")
+						then
+							local ancestry = TreeView.getAncestry(selectedNode)
+							local root = ancestry[#ancestry]
+							local storybook = storybookByNodeId.current[root.id]
+
+							if storybook then
+								props.onStoryChanged(selectedNode.instance, storybook)
+								setLastOpenedStory(selectedNode.instance)
+							end
+						end
+					else
+						props.onStoryChanged(nil, nil)
+					end
+				end
+
+				if props.onShowErrorPage then
+					if selectedNode.icon == TreeView.TreeNodeIcon.Alert then
+						local unavailableStorybook = unavailableStorybookByNodeId.current[selectedNode.id]
+						if unavailableStorybook then
+							props.onShowErrorPage(unavailableStorybook)
+						end
 					end
 				end
-			else
-				props.onStoryChanged(nil, nil)
 			end
-		end
-	end, { props.onStoryChanged, selectedNode, prevSelectedNode, setLastOpenedStory } :: { unknown })
+		end,
+		{ props.onShowErrorPage, props.onStoryChanged, selectedNode, prevSelectedNode, setLastOpenedStory } :: { unknown }
+	)
 
 	return React.createElement(TreeView.TreeView, {
 		layoutOrder = props.layoutOrder,
diff --git a/src/TreeView/types.luau b/src/TreeView/types.luau
index 7f94bd48..460db899 100644
--- a/src/TreeView/types.luau
+++ b/src/TreeView/types.luau
@@ -1,6 +1,6 @@
 local types = {}
 
-export type TreeNodeIcon = "none" | "story" | "storybook" | "folder" | "star"
+export type TreeNodeIcon = "none" | "story" | "storybook" | "folder" | "star" | "alert"
 
 types.TreeNodeIcon = {
 	None = "none" :: "none",
@@ -8,6 +8,7 @@ types.TreeNodeIcon = {
 	Storybook = "storybook" :: "storybook",
 	Folder = "folder" :: "folder",
 	Star = "star" :: "star",
+	Alert = "alert" :: "alert",
 }
 
 export type PartialTreeNode = {
diff --git a/src/TreeView/useTreeNodeIcon.luau b/src/TreeView/useTreeNodeIcon.luau
index a2fc1da7..487ce428 100644
--- a/src/TreeView/useTreeNodeIcon.luau
+++ b/src/TreeView/useTreeNodeIcon.luau
@@ -21,6 +21,8 @@ local function useTreeNodeIcon(icon: TreeNodeIcon): (Sprite, Color3)
 		return assets.Folder, theme.directory
 	elseif icon == types.TreeNodeIcon.Star then
 		return assets.Star, theme.star
+	elseif icon == types.TreeNodeIcon.Alert then
+		return assets.Alert, theme.alert
 	else
 		return assets.Folder, theme.textFaded
 	end

From f58271e1a8ee5f80342394651f49fb49080c7285 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 14:28:26 -0800
Subject: [PATCH 62/79] Flexy sidebar

---
 src/Panels/Sidebar.luau | 49 ++++++++++++++++++++++-------------------
 1 file changed, 26 insertions(+), 23 deletions(-)

diff --git a/src/Panels/Sidebar.luau b/src/Panels/Sidebar.luau
index c9dead18..b622e5d5 100644
--- a/src/Panels/Sidebar.luau
+++ b/src/Panels/Sidebar.luau
@@ -31,11 +31,6 @@ type Props = {
 local function Sidebar(props: Props)
 	local theme = useTheme()
 
-	local headerHeight, setHeaderHeight = React.useState(0)
-	local onHeaderSizeChanged = React.useCallback(function(rbx: Frame)
-		setHeaderHeight(rbx.AbsoluteSize.Y)
-	end, { setHeaderHeight })
-
 	local search: string?, setSearch = React.useState(nil :: string?)
 	local onSearchChanged = React.useCallback(function(value: string)
 		if value == "" then
@@ -53,6 +48,7 @@ local function Sidebar(props: Props)
 	}, {
 		UIListLayout = e("UIListLayout", {
 			Padding = theme.padding,
+			VerticalFlex = Enum.UIFlexAlignment.Fill,
 			SortOrder = Enum.SortOrder.LayoutOrder,
 		}),
 
@@ -68,42 +64,49 @@ local function Sidebar(props: Props)
 			BackgroundTransparency = 1,
 			LayoutOrder = nextLayoutOrder(),
 			Size = UDim2.fromScale(1, 0),
-			[React.Change.AbsoluteSize] = onHeaderSizeChanged,
 		}, {
+			FlexItem = React.createElement("UIFlexItem", {
+				FlexMode = Enum.UIFlexMode.Grow,
+			}),
+
 			UIListLayout = e("UIListLayout", {
 				Padding = theme.paddingLarge,
 				SortOrder = Enum.SortOrder.LayoutOrder,
 			}),
 
 			Branding = e(Branding, {
-				layoutOrder = 0,
+				layoutOrder = nextLayoutOrder(),
 			}),
 
 			Searchbar = e(Searchbar, {
-				layoutOrder = 1,
+				layoutOrder = nextLayoutOrder(),
 				onSearchChanged = onSearchChanged,
 			}),
-		}),
 
-		--if #props.storybooks.available == 0
-		CreateStorybook = React.createElement(Button, {
-			layoutOrder = nextLayoutOrder(),
-			text = "Create Storybook",
-			onClick = function()
-				local source = getMostLikelyProjectSources()[1]
-
-				if source then
-					createOnboardingStorybook(source.Name, source)
-				else
-					createOnboardingStorybook(string.gsub(game.Name, "%.", "_"), ReplicatedStorage)
-				end
-			end,
+			--if #props.storybooks.available == 0
+			CreateStorybook = React.createElement(Button, {
+				layoutOrder = nextLayoutOrder(),
+				text = "Create Storybook",
+				onClick = function()
+					local source = getMostLikelyProjectSources()[1]
+
+					if source then
+						createOnboardingStorybook(source.Name, source)
+					else
+						createOnboardingStorybook(string.gsub(game.Name, "%.", "_"), ReplicatedStorage)
+					end
+				end,
+			}),
 		}),
 
 		ScrollingFrame = e(ScrollingFrame, {
 			LayoutOrder = nextLayoutOrder(),
-			Size = UDim2.fromScale(1, 1) - UDim2.fromOffset(0, headerHeight),
+			Size = UDim2.fromScale(1, 1),
 		}, {
+			FlexItem = React.createElement("UIFlexItem", {
+				FlexMode = Enum.UIFlexMode.Shrink,
+			}),
+
 			StorybookTreeView = e(StorybookTreeView, {
 				searchTerm = search,
 				storybooks = props.storybooks,

From 7185df800e457979f2c2608ede0d026dbb9ef40c Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 14:32:03 -0800
Subject: [PATCH 63/79] Fix stories not toggling

---
 src/Storybook/StorybookTreeView.luau | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/Storybook/StorybookTreeView.luau b/src/Storybook/StorybookTreeView.luau
index 49d9ea06..e9bd4bfe 100644
--- a/src/Storybook/StorybookTreeView.luau
+++ b/src/Storybook/StorybookTreeView.luau
@@ -131,7 +131,7 @@ local function StorybookTreeView(props: Props)
 
 	useEffect(
 		function()
-			if selectedNode and selectedNode ~= prevSelectedNode then
+			if selectedNode ~= prevSelectedNode then
 				if props.onStoryChanged then
 					if selectedNode then
 						if
@@ -154,7 +154,7 @@ local function StorybookTreeView(props: Props)
 				end
 
 				if props.onShowErrorPage then
-					if selectedNode.icon == TreeView.TreeNodeIcon.Alert then
+					if selectedNode and selectedNode.icon == TreeView.TreeNodeIcon.Alert then
 						local unavailableStorybook = unavailableStorybookByNodeId.current[selectedNode.id]
 						if unavailableStorybook then
 							props.onShowErrorPage(unavailableStorybook)

From dba040164d9b3f7efcd5dc022ab11b69a2c44789 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 16:12:49 -0800
Subject: [PATCH 64/79] Show more comprehensive details for storybook errors

---
 src/Common/CodeBlock.luau               |  79 +++++++++++++++++
 src/Navigation/Screen.luau              |   6 +-
 src/Storybook/StoryError.luau           |  32 +------
 src/Storybook/StorybookError.luau       | 112 ++++++++++++++++++++++++
 src/Storybook/StorybookError.story.luau |  32 +++++++
 5 files changed, 230 insertions(+), 31 deletions(-)
 create mode 100644 src/Common/CodeBlock.luau
 create mode 100644 src/Storybook/StorybookError.luau
 create mode 100644 src/Storybook/StorybookError.story.luau

diff --git a/src/Common/CodeBlock.luau b/src/Common/CodeBlock.luau
new file mode 100644
index 00000000..5172303b
--- /dev/null
+++ b/src/Common/CodeBlock.luau
@@ -0,0 +1,79 @@
+local React = require("@pkg/React")
+local Sift = require("@pkg/Sift")
+
+local SelectableTextLabel = require("@root/Forms/SelectableTextLabel")
+local nextLayoutOrder = require("@root/Common/nextLayoutOrder")
+local useTheme = require("@root/Common/useTheme")
+
+local useMemo = React.useMemo
+
+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 StoryError(props: Props)
+	local theme = useTheme()
+
+	local sourceColor = useMemo(function()
+		return if props.sourceColor then props.sourceColor else theme.text
+	end, { props.sourceColor })
+
+	return React.createElement("Frame", {
+		AutomaticSize = Enum.AutomaticSize.XY,
+		BackgroundTransparency = 0.5,
+		BorderSizePixel = 0,
+		BackgroundColor3 = theme.sidebar,
+		LayoutOrder = props.layoutOrder,
+	}, {
+		Layout = React.createElement("UIListLayout", {
+			SortOrder = Enum.SortOrder.LayoutOrder,
+			FillDirection = Enum.FillDirection.Horizontal,
+			Padding = theme.padding,
+		}),
+
+		BorderRadius = React.createElement("UICorner", {
+			CornerRadius = theme.corner,
+		}),
+
+		Padding = React.createElement("UIPadding", {
+			PaddingTop = theme.padding,
+			PaddingRight = theme.padding,
+			PaddingBottom = theme.padding,
+			PaddingLeft = theme.padding,
+		}),
+
+		LineNumbers = React.createElement("TextLabel", {
+			LayoutOrder = nextLayoutOrder(),
+			AutomaticSize = Enum.AutomaticSize.XY,
+			Text = getLineNumbers(props.source),
+			TextSize = theme.textSize,
+			LineHeight = 1,
+			BackgroundTransparency = 1,
+			Font = Enum.Font.RobotoMono,
+			TextColor3 = theme.textFaded,
+			TextXAlignment = Enum.TextXAlignment.Right,
+		}),
+
+		SourceCode = React.createElement(SelectableTextLabel, {
+			LayoutOrder = nextLayoutOrder(),
+			Size = UDim2.fromScale(1, 0),
+			AutomaticSize = Enum.AutomaticSize.Y,
+			Text = props.source,
+			TextColor3 = sourceColor,
+			TextSize = theme.textSize,
+			TextWrapped = false,
+			LineHeight = 1,
+			Font = Enum.Font.RobotoMono,
+		}),
+	})
+end
+
+return StoryError
diff --git a/src/Navigation/Screen.luau b/src/Navigation/Screen.luau
index 682a72b3..e792b1ae 100644
--- a/src/Navigation/Screen.luau
+++ b/src/Navigation/Screen.luau
@@ -6,7 +6,7 @@ local NavigationContext = require("@root/Navigation/NavigationContext")
 local NoStorySelected = require("@root/Storybook/NoStorySelected")
 local SettingsView = require("@root/UserSettings/SettingsView")
 local StoryCanvas = require("@root/Storybook/StoryCanvas")
-local StoryError = require("@root/Storybook/StoryError")
+local StorybookError = require("@root/Storybook/StorybookError")
 
 local useMemo = React.useMemo
 
@@ -29,8 +29,8 @@ local function Screen(props: Props)
 					storybook = props.storybook,
 				})
 			elseif props.unavailableStorybook then
-				return React.createElement(StoryError, {
-					err = `Failed to load {props.unavailableStorybook.storybook.name}\n\n{props.unavailableStorybook.problem}`,
+				return React.createElement(StorybookError, {
+					unavailableStorybook = props.unavailableStorybook,
 				})
 			else
 				return React.createElement(NoStorySelected)
diff --git a/src/Storybook/StoryError.luau b/src/Storybook/StoryError.luau
index 75a15d86..646c5213 100644
--- a/src/Storybook/StoryError.luau
+++ b/src/Storybook/StoryError.luau
@@ -1,8 +1,7 @@
 local React = require("@pkg/React")
-local Sift = require("@pkg/Sift")
 
+local CodeBlock = require("@root/Common/CodeBlock")
 local ScrollingFrame = require("@root/Common/ScrollingFrame")
-local SelectableTextLabel = require("@root/Forms/SelectableTextLabel")
 local useTheme = require("@root/Common/useTheme")
 
 export type Props = {
@@ -13,10 +12,6 @@ export type Props = {
 local function StoryError(props: Props)
 	local theme = useTheme()
 
-	local lineNumbers = Sift.List.reduce(props.err:split("\n"), function(accumulator, _item, index)
-		return if index == 1 then tostring(index) else `{accumulator}\n{index}`
-	end, "")
-
 	return React.createElement(ScrollingFrame, {
 		ScrollingDirection = Enum.ScrollingDirection.XY,
 		LayoutOrder = props.layoutOrder,
@@ -34,28 +29,9 @@ local function StoryError(props: Props)
 			PaddingLeft = theme.paddingSmall,
 		}),
 
-		LineNumbers = React.createElement("TextLabel", {
-			LayoutOrder = 1,
-			AutomaticSize = Enum.AutomaticSize.XY,
-			Text = lineNumbers,
-			TextSize = theme.textSize,
-			LineHeight = 1,
-			BackgroundTransparency = 1,
-			Font = Enum.Font.RobotoMono,
-			TextColor3 = theme.textFaded,
-			TextXAlignment = Enum.TextXAlignment.Right,
-		}),
-
-		ErrorMessage = React.createElement(SelectableTextLabel, {
-			LayoutOrder = 2,
-			Size = UDim2.fromScale(1, 0),
-			AutomaticSize = Enum.AutomaticSize.Y,
-			Text = props.err,
-			TextColor3 = theme.alert,
-			TextSize = theme.textSize,
-			TextWrapped = false,
-			LineHeight = 1,
-			Font = Enum.Font.RobotoMono,
+		CodeBlock = React.createElement(CodeBlock, {
+			source = props.err,
+			sourceColor = theme.alert,
 		}),
 	})
 end
diff --git a/src/Storybook/StorybookError.luau b/src/Storybook/StorybookError.luau
new file mode 100644
index 00000000..952ef40f
--- /dev/null
+++ b/src/Storybook/StorybookError.luau
@@ -0,0 +1,112 @@
+local React = require("@pkg/React")
+local Sift = require("@pkg/Sift")
+local Storyteller = require("@pkg/Storyteller")
+
+local CodeBlock = require("@root/Common/CodeBlock")
+local ScrollingFrame = require("@root/Common/ScrollingFrame")
+local nextLayoutOrder = require("@root/Common/nextLayoutOrder")
+local useTheme = require("@root/Common/useTheme")
+
+type UnavailableStorybook = Storyteller.UnavailableStorybook
+
+export type Props = {
+	unavailableStorybook: UnavailableStorybook,
+	layoutOrder: number?,
+}
+
+local function StoryError(props: Props)
+	local theme = useTheme()
+
+	local storybookSource = props.unavailableStorybook.storybook.source.Source
+
+	return React.createElement(ScrollingFrame, {
+		ScrollingDirection = Enum.ScrollingDirection.XY,
+		LayoutOrder = props.layoutOrder,
+	}, {
+		Layout = React.createElement("UIListLayout", {
+			SortOrder = Enum.SortOrder.LayoutOrder,
+			FillDirection = Enum.FillDirection.Vertical,
+			Padding = theme.paddingLarge,
+		}),
+
+		Padding = React.createElement("UIPadding", {
+			PaddingTop = theme.paddingLarge,
+			PaddingRight = theme.paddingLarge,
+			PaddingBottom = theme.paddingLarge,
+			PaddingLeft = theme.paddingLarge,
+		}),
+
+		MainText = React.createElement("TextLabel", {
+			LayoutOrder = nextLayoutOrder(),
+			Text = `Failed to load {props.unavailableStorybook.storybook.name}`,
+			AutomaticSize = Enum.AutomaticSize.XY,
+			BackgroundTransparency = 1,
+			Font = theme.font,
+			TextColor3 = theme.text,
+			TextSize = theme.textSize,
+			TextXAlignment = Enum.TextXAlignment.Left,
+			TextYAlignment = Enum.TextYAlignment.Center,
+		}),
+
+		Problem = React.createElement("Frame", {
+			AutomaticSize = Enum.AutomaticSize.XY,
+			BackgroundTransparency = 1,
+			LayoutOrder = nextLayoutOrder(),
+		}, {
+			Layout = React.createElement("UIListLayout", {
+				SortOrder = Enum.SortOrder.LayoutOrder,
+				FillDirection = Enum.FillDirection.Vertical,
+				Padding = theme.padding,
+			}),
+
+			Title = React.createElement("TextLabel", {
+				LayoutOrder = nextLayoutOrder(),
+				Text = "Error",
+				AutomaticSize = Enum.AutomaticSize.XY,
+				BackgroundTransparency = 1,
+				Font = theme.headerFont,
+				TextColor3 = theme.text,
+				TextSize = theme.headerTextSize,
+				TextXAlignment = Enum.TextXAlignment.Left,
+				TextYAlignment = Enum.TextYAlignment.Center,
+			}),
+
+			CodeBlock = React.createElement(CodeBlock, {
+				source = props.unavailableStorybook.problem,
+				sourceColor = theme.alert,
+				layoutOrder = nextLayoutOrder(),
+			}),
+		}),
+
+		StorybookSource = React.createElement("Frame", {
+			AutomaticSize = Enum.AutomaticSize.XY,
+			BackgroundTransparency = 1,
+			LayoutOrder = nextLayoutOrder(),
+		}, {
+			Layout = React.createElement("UIListLayout", {
+				SortOrder = Enum.SortOrder.LayoutOrder,
+				FillDirection = Enum.FillDirection.Vertical,
+				Padding = theme.padding,
+			}),
+
+			Title = React.createElement("TextLabel", {
+				LayoutOrder = nextLayoutOrder(),
+				Text = "Storybook Source",
+				AutomaticSize = Enum.AutomaticSize.XY,
+				BackgroundTransparency = 1,
+				Font = theme.headerFont,
+				TextColor3 = theme.text,
+				TextSize = theme.headerTextSize,
+				TextXAlignment = Enum.TextXAlignment.Left,
+				TextYAlignment = Enum.TextYAlignment.Center,
+			}),
+
+			CodeBlock = React.createElement(CodeBlock, {
+				source = storybookSource,
+				layoutOrder = nextLayoutOrder(),
+			}),
+		}),
+	})
+end
+
+return StoryError
diff --git a/src/Storybook/StorybookError.story.luau b/src/Storybook/StorybookError.story.luau
new file mode 100644
index 00000000..e1361fd7
--- /dev/null
+++ b/src/Storybook/StorybookError.story.luau
@@ -0,0 +1,32 @@
+local React = require("@pkg/React")
+local Storyteller = require("@pkg/Storyteller")
+
+local ContextProviders = require("@root/Common/ContextProviders")
+local MockPlugin = require("@root/Testing/MockPlugin")
+local StorybookError = require("@root/Storybook/StorybookError")
+
+type UnavailableStorybook = Storyteller.UnavailableStorybook
+
+return {
+	summary = "Component for displaying error messages to the user",
+	story = function()
+
+		local storybookModule = script.Parent.Parent["init.storybook"]
+		local unavailableStorybook: UnavailableStorybook = {
+			problem = "Something went wrong!",
+			storybook = {
+				name = storybookModule.Name,
+				source = storybookModule,
+				loader = {} :: any,
+			}
+		}
+
+		return React.createElement(ContextProviders, {
+			plugin = MockPlugin.new() :: ,
+		}, {
+			StorybookError = React.createElement(StorybookError, {
+				unavailableStorybook =unavailableStorybook
+			}),
+		})
+	end,
+}

From 1d024a8dbd20971702af02c4acf10faf24303265 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 16:19:17 -0800
Subject: [PATCH 65/79] Reset the unavailable story between nodes

---
 src/Plugin/PluginApp.luau | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/src/Plugin/PluginApp.luau b/src/Plugin/PluginApp.luau
index a7c4cf91..db4bb876 100644
--- a/src/Plugin/PluginApp.luau
+++ b/src/Plugin/PluginApp.luau
@@ -30,10 +30,8 @@ local function App()
 	local onStoryChanged = React.useCallback(function(newStoryModule: ModuleScript?, newStorybook: LoadedStorybook?)
 		navigation.navigateTo("Home")
 
-		setStoryModule(function(prev: ModuleScript?)
-			return if prev ~= newStoryModule then newStoryModule else nil
-		end)
-
+		setUnavailableStorybook(nil)
+		setStoryModule(newStoryModule)
 		setStorybook(newStorybook)
 	end, { navigation.navigateTo } :: { unknown })
 

From eb8262476e4f618ceb7cc0affb0cf3e1d750accd Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 16:25:37 -0800
Subject: [PATCH 66/79] Display a highlight when hovering a drag handle

---
 src/Panels/DragHandle.luau | 27 ++++++++++++++++++++-------
 1 file changed, 20 insertions(+), 7 deletions(-)

diff --git a/src/Panels/DragHandle.luau b/src/Panels/DragHandle.luau
index f8765ff4..19c6b2ee 100644
--- a/src/Panels/DragHandle.luau
+++ b/src/Panels/DragHandle.luau
@@ -1,9 +1,11 @@
 local RunService = game:GetService("RunService")
 
-local PluginContext = require("@root/Plugin/PluginContext")
 local React = require("@pkg/React")
 local Sift = require("@pkg/Sift")
+
+local PluginContext = require("@root/Plugin/PluginContext")
 local types = require("@root/Panels/types")
+local useTheme = require("@root/Common/useTheme")
 
 local defaultProps = {
 	size = 8, -- px
@@ -21,6 +23,7 @@ type InternalProps = Props & typeof(defaultProps)
 
 local function DragHandle(providedProps: Props)
 	local props: InternalProps = Sift.Dictionary.merge(defaultProps, providedProps)
+	local theme = useTheme()
 
 	local plugin = React.useContext(PluginContext.Context)
 	local isDragging, setIsDragging = React.useState(false)
@@ -28,12 +31,14 @@ local function DragHandle(providedProps: Props)
 	local mouseInput: InputObject?, setMouseInput = React.useState(nil :: InputObject?)
 
 	local getHandleProperties = React.useCallback(function()
-		local size: UDim2
+		local hitboxSize: UDim2
+		local highlightSize: UDim2
 		local position: UDim2
 		local anchorPoint: Vector2
 
 		if props.handle == "Right" or props.handle == "Left" then
-			size = UDim2.new(0, props.size, 1, 0)
+			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)
@@ -43,7 +48,8 @@ local function DragHandle(providedProps: Props)
 				anchorPoint = Vector2.new(0, 0)
 			end
 		elseif props.handle == "Top" or props.handle == "Bottom" then
-			size = UDim2.new(1, 0, 0, props.size)
+			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)
@@ -54,7 +60,7 @@ local function DragHandle(providedProps: Props)
 			end
 		end
 
-		return size, position, anchorPoint
+		return hitboxSize, highlightSize, position, anchorPoint
 	end, { props.handle, props.size } :: { unknown })
 
 	local onInputBegan = React.useCallback(function(_rbx, input: InputObject)
@@ -85,7 +91,7 @@ local function DragHandle(providedProps: Props)
 		setIsHovered(false)
 	end, {})
 
-	local size, position, anchorPoint = getHandleProperties()
+	local hitboxSize, highlightSize, position, anchorPoint = getHandleProperties()
 
 	React.useEffect(function(): any
 		if mouseInput and isDragging then
@@ -125,7 +131,7 @@ local function DragHandle(providedProps: Props)
 	end, { plugin, isDragging, isHovered } :: { unknown })
 
 	return React.createElement("ImageButton", {
-		Size = size,
+		Size = hitboxSize,
 		Position = position,
 		AnchorPoint = anchorPoint,
 		BackgroundTransparency = 1,
@@ -133,6 +139,13 @@ local function DragHandle(providedProps: Props)
 		[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 0 else 1,
+			BackgroundColor3 = theme.selection,
+		}),
 	})
 end
 

From 3e391ca1ed31b3dc430a693f00e52a188ff817f4 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 19 Dec 2024 09:18:32 -0800
Subject: [PATCH 67/79] FileSyncService doesn't exist but InternalSyncService
 does

---
 src/RobloxInternal/getInternalSyncItems.luau | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/RobloxInternal/getInternalSyncItems.luau b/src/RobloxInternal/getInternalSyncItems.luau
index 3751541a..a93e1099 100644
--- a/src/RobloxInternal/getInternalSyncItems.luau
+++ b/src/RobloxInternal/getInternalSyncItems.luau
@@ -1,15 +1,15 @@
 local tryGetService = require("@root/RobloxInternal/tryGetService")
 
 -- selene: allow(incorrect_standard_library_use)
-type FileSyncService = typeof(game:GetService("FileSyncService"))
+type InternalSyncService = typeof(game:GetService("InternalSyncService"))
 
-local FileSyncService: FileSyncService? = tryGetService("FileSyncService")
+local InternalSyncService: InternalSyncService? = tryGetService("InternalSyncService")
 
 local function getInternalSyncItems(): { InternalSyncItem }
 	local internalSyncItems: { InternalSyncItem } = {}
 
-	if FileSyncService then
-		for _, child in FileSyncService:GetChildren() do
+	if InternalSyncService then
+		for _, child in InternalSyncService:GetChildren() do
 			if child:IsA("InternalSyncItem") then
 				table.insert(internalSyncItems, child)
 			end

From 5823ece29cb3b3394024ed3560292f3fd6f7d237 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 19 Dec 2024 09:21:47 -0800
Subject: [PATCH 68/79] Consolidate modules for Roblox internal work

---
 src/Common/getInstanceFromFullName.luau       |  2 +-
 src/Permissions/tryGetService.luau            | 27 -------------------
 .../canAccess.luau                            |  0
 src/RobloxInternal/tryGetService.luau         | 10 +++++--
 4 files changed, 9 insertions(+), 30 deletions(-)
 delete mode 100644 src/Permissions/tryGetService.luau
 rename src/{Permissions => RobloxInternal}/canAccess.luau (100%)

diff --git a/src/Common/getInstanceFromFullName.luau b/src/Common/getInstanceFromFullName.luau
index e52e9cbb..d11dd606 100644
--- a/src/Common/getInstanceFromFullName.luau
+++ b/src/Common/getInstanceFromFullName.luau
@@ -8,7 +8,7 @@
 
 local Sift = require("@pkg/Sift")
 
-local tryGetService = require("@root/Permissions/tryGetService")
+local tryGetService = require("@root/RobloxInternal/tryGetService")
 
 local PATH_SEPERATOR = "."
 
diff --git a/src/Permissions/tryGetService.luau b/src/Permissions/tryGetService.luau
deleted file mode 100644
index f6c050a3..00000000
--- a/src/Permissions/tryGetService.luau
+++ /dev/null
@@ -1,27 +0,0 @@
-local canAccess = require("@root/Permissions/canAccess")
-
-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)
-
-	if canAccess(service) then
-		return service
-	end
-
-	return nil
-end
-
-return tryGetService
diff --git a/src/Permissions/canAccess.luau b/src/RobloxInternal/canAccess.luau
similarity index 100%
rename from src/Permissions/canAccess.luau
rename to src/RobloxInternal/canAccess.luau
diff --git a/src/RobloxInternal/tryGetService.luau b/src/RobloxInternal/tryGetService.luau
index d2dda643..10d0f873 100644
--- a/src/RobloxInternal/tryGetService.luau
+++ b/src/RobloxInternal/tryGetService.luau
@@ -1,4 +1,6 @@
-local function tryGetService(serviceName: string): Instance
+local canAccess = require("@root/RobloxInternal/canAccess")
+
+local function tryGetService(serviceName: string): Instance?
 	local service
 
 	pcall(function()
@@ -15,7 +17,11 @@ local function tryGetService(serviceName: string): Instance
 		service = game:FindFirstChild(serviceName)
 	end)
 
-	return service
+	if canAccess(service) then
+		return service
+	end
+
+	return nil
 end
 
 return tryGetService

From cb7932044e17aeaf8db34bb3eb185bf325e2a2fe Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 19 Dec 2024 09:28:34 -0800
Subject: [PATCH 69/79] Fix LocalStorageContext types and use a constant
 storage key

---
 src/Common/ContextProviders.luau    |  4 +++-
 src/Plugin/LocalStorageContext.luau | 22 +++++++++-------------
 2 files changed, 12 insertions(+), 14 deletions(-)

diff --git a/src/Common/ContextProviders.luau b/src/Common/ContextProviders.luau
index 4d862c5e..90115356 100644
--- a/src/Common/ContextProviders.luau
+++ b/src/Common/ContextProviders.luau
@@ -21,7 +21,9 @@ local function ContextProviders(props: Props)
 			React.createElement(NavigationContext.Provider, {
 				defaultScreen = "Home",
 			}),
-			React.createElement(LocalStorageContext.Provider),
+			React.createElement(LocalStorageContext.Provider, {
+				storageKey = "FlipbookInternal",
+			}),
 			React.createElement(SettingsContext.Provider),
 			React.createElement(TreeView.TreeViewProvider),
 		},
diff --git a/src/Plugin/LocalStorageContext.luau b/src/Plugin/LocalStorageContext.luau
index ddd9b9f0..7c5deeae 100644
--- a/src/Plugin/LocalStorageContext.luau
+++ b/src/Plugin/LocalStorageContext.luau
@@ -21,22 +21,18 @@ export type LocalStorageContext = {
 	set: (key: string, value: unknown) -> (),
 }
 
-local LocalStorageContext = React.createContext({})
+local LocalStorageContext = React.createContext(nil :: LocalStorageContext?)
 
 export type Props = {
-	storageKey: string?,
+	storageKey: string,
 	children: React.Node,
 }
 
 local function LocalStorageProvider(props: Props)
 	local plugin = useContext(PluginContext.Context)
 
-	local storageKey = useMemo(function()
-		return if props.storageKey then props.storageKey else `{plugin.Name}LocalStorage`
-	end, { props.storageKey, plugin })
-
 	local loadFromDisk = useCallback(function(): LocalStorage
-		local data = plugin:GetSetting(storageKey)
+		local data = plugin:GetSetting(props.storageKey)
 		if data then
 			local json = HttpService:JSONDecode(data)
 			if json then
@@ -44,7 +40,7 @@ local function LocalStorageProvider(props: Props)
 			end
 		end
 		return {}
-	end, { plugin, storageKey })
+	end, { plugin, props.storageKey } :: { unknown })
 
 	local storage, setStorage = useState(loadFromDisk)
 	local prevStorage = usePrevious(storage)
@@ -52,15 +48,15 @@ local function LocalStorageProvider(props: Props)
 	local saveToDisk = useCallback(function()
 		local data = HttpService:JSONEncode(storage)
 		if data then
-			plugin:SetSetting(storageKey, data)
+			plugin:SetSetting(props.storageKey, data)
 		end
-	end, { plugin, storageKey, storage })
+	end, { plugin, props.storageKey, storage } :: { unknown })
 
 	local get = useCallback(function(key: string)
 		return storage[key]
 	end, { storage })
 
-	local set = useCallback(function(key: string, value: unknown)
+	local set = useCallback(function(key: string, value: any)
 		setStorage(function(prev)
 			return Sift.Dictionary.join(prev, {
 				[key] = if typeof(value) == "function" then value(prev[key]) else value,
@@ -72,7 +68,7 @@ local function LocalStorageProvider(props: Props)
 		if storage and storage ~= prevStorage then
 			saveToDisk()
 		end
-	end, { storage, prevStorage, saveToDisk })
+	end, { storage, prevStorage, saveToDisk } :: { unknown })
 
 	local context: LocalStorageContext = {
 		get = get,
@@ -84,7 +80,7 @@ local function LocalStorageProvider(props: Props)
 	}, props.children)
 end
 
-local function useLocalStorage(): TreeViewContext
+local function useLocalStorage(): LocalStorageContext
 	local context = useContext(LocalStorageContext)
 	if not context then
 		local contextName = script.Name

From d3e143aac83baf1a9f4bcf9dc066ad73115dafb3 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 19 Dec 2024 09:44:03 -0800
Subject: [PATCH 70/79] Fix unnecessary re-renders for pinned instances

---
 src/Plugin/LocalStorageContext.luau  | 12 ++++++------
 src/TreeView/usePinnedInstances.luau | 12 ++++++++----
 2 files changed, 14 insertions(+), 10 deletions(-)

diff --git a/src/Plugin/LocalStorageContext.luau b/src/Plugin/LocalStorageContext.luau
index 7c5deeae..bd049b0d 100644
--- a/src/Plugin/LocalStorageContext.luau
+++ b/src/Plugin/LocalStorageContext.luau
@@ -9,7 +9,7 @@ local usePrevious = require("@root/Common/usePrevious")
 local useCallback = React.useCallback
 local useContext = React.useContext
 local useEffect = React.useEffect
-local useMemo = React.useMemo
+local useRef = React.useRef
 local useState = React.useState
 
 export type LocalStorage = {
@@ -56,10 +56,10 @@ local function LocalStorageProvider(props: Props)
 		return storage[key]
 	end, { storage })
 
-	local set = useCallback(function(key: string, value: any)
+	local set = useCallback(function(key: string, value: unknown?)
 		setStorage(function(prev)
 			return Sift.Dictionary.join(prev, {
-				[key] = if typeof(value) == "function" then value(prev[key]) else value,
+				[key] = if value == nil then Sift.None else value,
 			})
 		end)
 	end, { storage })
@@ -70,13 +70,13 @@ local function LocalStorageProvider(props: Props)
 		end
 	end, { storage, prevStorage, saveToDisk } :: { unknown })
 
-	local context: LocalStorageContext = {
+	local context: LocalStorageContext = useRef({
 		get = get,
 		set = set,
-	}
+	})
 
 	return React.createElement(LocalStorageContext.Provider, {
-		value = context,
+		value = context.current,
 	}, props.children)
 end
 
diff --git a/src/TreeView/usePinnedInstances.luau b/src/TreeView/usePinnedInstances.luau
index 0ca670dd..22b8a74b 100644
--- a/src/TreeView/usePinnedInstances.luau
+++ b/src/TreeView/usePinnedInstances.luau
@@ -3,6 +3,7 @@ local Sift = require("@pkg/Sift")
 
 local LocalStorageContext = require("@root/Plugin/LocalStorageContext")
 local getInstanceFromFullName = require("@root/Common/getInstanceFromFullName")
+local usePrevious = require("@root/Common/usePrevious")
 
 local useCallback = React.useCallback
 local useState = React.useState
@@ -21,10 +22,7 @@ local function usePinnedInstances(): (ModuleScript?, (storyModule: ModuleScript?
 	local pinnedPaths, setPinnedPaths = useState(function()
 		return localStorage.get(PINNED_INSTANCES_KEY) or {}
 	end)
-
-	useEffect(function()
-		localStorage.set(PINNED_INSTANCES_KEY, pinnedPaths)
-	end, { pinnedPaths })
+	local prevPinnedPaths = usePrevious(pinnedPaths)
 
 	local pin = useCallback(function(instance: Instance)
 		setPinnedPaths(function(prev)
@@ -65,6 +63,12 @@ local function usePinnedInstances(): (ModuleScript?, (storyModule: ModuleScript?
 		end
 	end, { isPinned, unpin, pin })
 
+	useEffect(function()
+		if prevPinnedPaths and pinnedPaths ~= prevPinnedPaths then
+			localStorage.set(PINNED_INSTANCES_KEY, pinnedPaths)
+		end
+	end, { localStorage, pinnedPaths, prevPinnedPaths })
+
 	return {
 		pin = pin,
 		unpin = unpin,

From 914535b01233c1c6faf2b791ec1e776520572021 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 19 Dec 2024 09:44:17 -0800
Subject: [PATCH 71/79] When no story has been opened, first click won't
 actually open it

---
 src/Storybook/StorybookTreeView.luau | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/Storybook/StorybookTreeView.luau b/src/Storybook/StorybookTreeView.luau
index e9bd4bfe..de85104f 100644
--- a/src/Storybook/StorybookTreeView.luau
+++ b/src/Storybook/StorybookTreeView.luau
@@ -150,6 +150,7 @@ local function StorybookTreeView(props: Props)
 						end
 					else
 						props.onStoryChanged(nil, nil)
+						setLastOpenedStory(nil)
 					end
 				end
 

From d130e11962998f5dd69d5a5fd479dcfdce64e7e4 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Wed, 18 Dec 2024 20:13:47 -0800
Subject: [PATCH 72/79] Add untested code block highlighting

---
 src/Common/CodeBlock.luau | 13 +++++++++++++
 wally.toml                |  1 +
 2 files changed, 14 insertions(+)

diff --git a/src/Common/CodeBlock.luau b/src/Common/CodeBlock.luau
index 5172303b..7ad00dab 100644
--- a/src/Common/CodeBlock.luau
+++ b/src/Common/CodeBlock.luau
@@ -1,3 +1,4 @@
+local Highlighter = require("@pkg/Highlighter")
 local React = require("@pkg/React")
 local Sift = require("@pkg/Sift")
 
@@ -6,6 +7,8 @@ local nextLayoutOrder = require("@root/Common/nextLayoutOrder")
 local useTheme = require("@root/Common/useTheme")
 
 local useMemo = React.useMemo
+local useRef = React.useRef
+local useEffect = React.useEffect
 
 local function getLineNumbers(str: string): string
 	return Sift.List.reduce(str:split("\n"), function(accumulator, _item, index)
@@ -21,11 +24,20 @@ export type Props = {
 
 local function StoryError(props: Props)
 	local theme = useTheme()
+	local ref = useRef(nil :: TextBox?)
 
 	local sourceColor = useMemo(function()
 		return if props.sourceColor then props.sourceColor else theme.text
 	end, { props.sourceColor })
 
+	useEffect(function()
+		Highlighter.matchStudioSettings()
+
+		Highlighter.highlight({
+			textObject = ref.current,
+		})
+	end, {})
+
 	return React.createElement("Frame", {
 		AutomaticSize = Enum.AutomaticSize.XY,
 		BackgroundTransparency = 0.5,
@@ -72,6 +84,7 @@ local function StoryError(props: Props)
 			TextWrapped = false,
 			LineHeight = 1,
 			Font = Enum.Font.RobotoMono,
+			ref = ref,
 		}),
 	})
 end
diff --git a/wally.toml b/wally.toml
index 8189576d..96fa8332 100644
--- a/wally.toml
+++ b/wally.toml
@@ -13,6 +13,7 @@ ReactRoblox = "jsdotlua/react-roblox@17.0.2"
 ReactSpring = "chriscerie/react-spring@2.0.0"
 Sift = "csqrl/sift@0.0.8"
 t = "osyrisrblx/t@3.0.0"
+Highlighter = "boatbomber/highlighter@0.9.0"
 
 # dev dependencies
 Roact = "roblox/roact@1.4.4"

From 30f2f1a216057d9112bc360efe61859a981a7660 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Thu, 19 Dec 2024 11:12:42 -0800
Subject: [PATCH 73/79] Syntax highlighting works!

---
 src/Common/CodeBlock.luau         | 32 ++++++++++++++++---------------
 src/Storybook/StorybookError.luau |  5 ++---
 2 files changed, 19 insertions(+), 18 deletions(-)

diff --git a/src/Common/CodeBlock.luau b/src/Common/CodeBlock.luau
index 7ad00dab..63fd5263 100644
--- a/src/Common/CodeBlock.luau
+++ b/src/Common/CodeBlock.luau
@@ -7,8 +7,6 @@ local nextLayoutOrder = require("@root/Common/nextLayoutOrder")
 local useTheme = require("@root/Common/useTheme")
 
 local useMemo = React.useMemo
-local useRef = React.useRef
-local useEffect = React.useEffect
 
 local function getLineNumbers(str: string): string
 	return Sift.List.reduce(str:split("\n"), function(accumulator, _item, index)
@@ -22,21 +20,25 @@ export type Props = {
 	layoutOrder: number?,
 }
 
-local function StoryError(props: Props)
+local function CodeBlock(props: Props)
 	local theme = useTheme()
-	local ref = useRef(nil :: TextBox?)
 
 	local sourceColor = useMemo(function()
 		return if props.sourceColor then props.sourceColor else theme.text
 	end, { props.sourceColor })
 
-	useEffect(function()
-		Highlighter.matchStudioSettings()
-
-		Highlighter.highlight({
-			textObject = ref.current,
-		})
-	end, {})
+	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("Frame", {
 		AutomaticSize = Enum.AutomaticSize.XY,
@@ -65,7 +67,7 @@ local function StoryError(props: Props)
 		LineNumbers = React.createElement("TextLabel", {
 			LayoutOrder = nextLayoutOrder(),
 			AutomaticSize = Enum.AutomaticSize.XY,
-			Text = getLineNumbers(props.source),
+			Text = getLineNumbers(source),
 			TextSize = theme.textSize,
 			LineHeight = 1,
 			BackgroundTransparency = 1,
@@ -75,18 +77,18 @@ local function StoryError(props: Props)
 		}),
 
 		SourceCode = React.createElement(SelectableTextLabel, {
+			RichText = true,
 			LayoutOrder = nextLayoutOrder(),
 			Size = UDim2.fromScale(1, 0),
 			AutomaticSize = Enum.AutomaticSize.Y,
-			Text = props.source,
+			Text = source,
 			TextColor3 = sourceColor,
 			TextSize = theme.textSize,
 			TextWrapped = false,
 			LineHeight = 1,
 			Font = Enum.Font.RobotoMono,
-			ref = ref,
 		}),
 	})
 end
 
-return StoryError
+return CodeBlock
diff --git a/src/Storybook/StorybookError.luau b/src/Storybook/StorybookError.luau
index 952ef40f..c2ea1b6e 100644
--- a/src/Storybook/StorybookError.luau
+++ b/src/Storybook/StorybookError.luau
@@ -1,5 +1,4 @@
 local React = require("@pkg/React")
-local Sift = require("@pkg/Sift")
 local Storyteller = require("@pkg/Storyteller")
 
 local CodeBlock = require("@root/Common/CodeBlock")
@@ -14,7 +13,7 @@ export type Props = {
 	layoutOrder: number?,
 }
 
-local function StoryError(props: Props)
+local function StorybookError(props: Props)
 	local theme = useTheme()
 
 	local storybookSource = props.unavailableStorybook.storybook.source.Source
@@ -109,4 +108,4 @@ local function StoryError(props: Props)
 	})
 end
 
-return StoryError
+return StorybookError

From 4f91e5209a22eb94dfa36d4b77f218b4e4c546dc Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Fri, 20 Dec 2024 12:28:05 -0800
Subject: [PATCH 74/79] Better errors for stories

---
 src/Storybook/StoryError.luau   | 93 +++++++++++++++++++++++++++++----
 src/Storybook/StoryPreview.luau |  3 +-
 src/Storybook/StoryView.luau    |  1 +
 3 files changed, 87 insertions(+), 10 deletions(-)

diff --git a/src/Storybook/StoryError.luau b/src/Storybook/StoryError.luau
index 646c5213..19596cd7 100644
--- a/src/Storybook/StoryError.luau
+++ b/src/Storybook/StoryError.luau
@@ -1,38 +1,113 @@
 local React = require("@pkg/React")
+local Storyteller = require("@pkg/Storyteller")
 
 local CodeBlock = require("@root/Common/CodeBlock")
 local ScrollingFrame = require("@root/Common/ScrollingFrame")
+local nextLayoutOrder = require("@root/Common/nextLayoutOrder")
 local useTheme = require("@root/Common/useTheme")
 
+type LoadedStory<T> = Storyteller.LoadedStory<T>
+
 export type Props = {
 	err: string,
+	storyModule: ModuleScript,
 	layoutOrder: number?,
 }
 
 local function StoryError(props: Props)
 	local theme = useTheme()
 
+	local storySource = if props.storyModule then props.storyModule.Source else nil
+
 	return React.createElement(ScrollingFrame, {
 		ScrollingDirection = Enum.ScrollingDirection.XY,
 		LayoutOrder = props.layoutOrder,
 	}, {
 		Layout = React.createElement("UIListLayout", {
 			SortOrder = Enum.SortOrder.LayoutOrder,
-			FillDirection = Enum.FillDirection.Horizontal,
-			Padding = theme.padding,
+			FillDirection = Enum.FillDirection.Vertical,
+			Padding = theme.paddingLarge,
 		}),
 
 		Padding = React.createElement("UIPadding", {
-			PaddingTop = theme.paddingSmall,
-			PaddingRight = theme.paddingSmall,
-			PaddingBottom = theme.paddingSmall,
-			PaddingLeft = theme.paddingSmall,
+			PaddingTop = theme.paddingLarge,
+			PaddingRight = theme.paddingLarge,
+			PaddingBottom = theme.paddingLarge,
+			PaddingLeft = theme.paddingLarge,
 		}),
 
-		CodeBlock = React.createElement(CodeBlock, {
-			source = props.err,
-			sourceColor = theme.alert,
+		MainText = React.createElement("TextLabel", {
+			LayoutOrder = nextLayoutOrder(),
+			Text = `Failed to load {props.storyModule.Name}`,
+			AutomaticSize = Enum.AutomaticSize.XY,
+			BackgroundTransparency = 1,
+			Font = theme.font,
+			TextColor3 = theme.text,
+			TextSize = theme.textSize,
+			TextXAlignment = Enum.TextXAlignment.Left,
+			TextYAlignment = Enum.TextYAlignment.Center,
 		}),
+
+		Problem = React.createElement("Frame", {
+			AutomaticSize = Enum.AutomaticSize.XY,
+			BackgroundTransparency = 1,
+			LayoutOrder = nextLayoutOrder(),
+		}, {
+			Layout = React.createElement("UIListLayout", {
+				SortOrder = Enum.SortOrder.LayoutOrder,
+				FillDirection = Enum.FillDirection.Vertical,
+				Padding = theme.padding,
+			}),
+
+			Title = React.createElement("TextLabel", {
+				LayoutOrder = nextLayoutOrder(),
+				Text = "Error",
+				AutomaticSize = Enum.AutomaticSize.XY,
+				BackgroundTransparency = 1,
+				Font = theme.headerFont,
+				TextColor3 = theme.text,
+				TextSize = theme.headerTextSize,
+				TextXAlignment = Enum.TextXAlignment.Left,
+				TextYAlignment = Enum.TextYAlignment.Center,
+			}),
+
+			CodeBlock = React.createElement(CodeBlock, {
+				source = props.err,
+				sourceColor = theme.alert,
+				layoutOrder = nextLayoutOrder(),
+			}),
+		}),
+
+		StorySource = if storySource
+			then React.createElement("Frame", {
+				AutomaticSize = Enum.AutomaticSize.XY,
+				BackgroundTransparency = 1,
+				LayoutOrder = nextLayoutOrder(),
+			}, {
+				Layout = React.createElement("UIListLayout", {
+					SortOrder = Enum.SortOrder.LayoutOrder,
+					FillDirection = Enum.FillDirection.Vertical,
+					Padding = theme.padding,
+				}),
+
+				Title = React.createElement("TextLabel", {
+					LayoutOrder = nextLayoutOrder(),
+					Text = "Story Source",
+					AutomaticSize = Enum.AutomaticSize.XY,
+					BackgroundTransparency = 1,
+					Font = theme.headerFont,
+					TextColor3 = theme.text,
+					TextSize = theme.headerTextSize,
+					TextXAlignment = Enum.TextXAlignment.Left,
+					TextYAlignment = Enum.TextYAlignment.Center,
+				}),
+
+				CodeBlock = React.createElement(CodeBlock, {
+					source = storySource,
+					layoutOrder = nextLayoutOrder(),
+				}),
+			})
+			else nil,
 	})
 end
 
diff --git a/src/Storybook/StoryPreview.luau b/src/Storybook/StoryPreview.luau
index 2e3c3cbd..db32a160 100644
--- a/src/Storybook/StoryPreview.luau
+++ b/src/Storybook/StoryPreview.luau
@@ -76,9 +76,10 @@ local StoryPreview = React.forwardRef(function(providedProps: Props, ref: any)
 		end
 	end, { props.story, props.isMountedInViewport } :: { unknown })
 
-	if err then
+	if err and props.story then
 		return e(StoryError, {
 			layoutOrder = props.layoutOrder,
+			storyModule = props.story.source,
 			err = err,
 		})
 	else
diff --git a/src/Storybook/StoryView.luau b/src/Storybook/StoryView.luau
index 6f8c6af1..e7e74340 100644
--- a/src/Storybook/StoryView.luau
+++ b/src/Storybook/StoryView.luau
@@ -91,6 +91,7 @@ local function StoryView(props: Props)
 		BackgroundTransparency = 1,
 	}, {
 		Error = storyErr and e(StoryError, {
+			storyModule = props.story,
 			err = storyErr,
 		}),
 

From 984e87e8e68e66bb1b0f196a52e43c2a88c40ca2 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Fri, 20 Dec 2024 12:32:43 -0800
Subject: [PATCH 75/79] Make sure we have access to services

---
 src/RobloxInternal/tryGetService.luau | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/RobloxInternal/tryGetService.luau b/src/RobloxInternal/tryGetService.luau
index 10d0f873..23a4e7cb 100644
--- a/src/RobloxInternal/tryGetService.luau
+++ b/src/RobloxInternal/tryGetService.luau
@@ -1,13 +1,13 @@
 local canAccess = require("@root/RobloxInternal/canAccess")
 
 local function tryGetService(serviceName: string): Instance?
-	local service
+	local service: Instance?
 
 	pcall(function()
 		service = game:GetService(serviceName)
 	end)
 
-	if service then
+	if service and canAccess(service) then
 		return service
 	end
 
@@ -17,7 +17,7 @@ local function tryGetService(serviceName: string): Instance?
 		service = game:FindFirstChild(serviceName)
 	end)
 
-	if canAccess(service) then
+	if service and canAccess(service) then
 		return service
 	end
 

From 72ed25339d633e995cc9f5fe69a83e797e90420e Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Fri, 20 Dec 2024 12:33:29 -0800
Subject: [PATCH 76/79] I think I was mistaken about the second case

---
 src/RobloxInternal/tryGetService.luau | 14 ++------------
 1 file changed, 2 insertions(+), 12 deletions(-)

diff --git a/src/RobloxInternal/tryGetService.luau b/src/RobloxInternal/tryGetService.luau
index 23a4e7cb..3f0ed92e 100644
--- a/src/RobloxInternal/tryGetService.luau
+++ b/src/RobloxInternal/tryGetService.luau
@@ -9,19 +9,9 @@ local function tryGetService(serviceName: string): Instance?
 
 	if service and canAccess(service) then
 		return service
+	else
+		return nil
 	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)
-
-	if service and canAccess(service) then
-		return service
-	end
-
-	return nil
 end
 
 return tryGetService

From 6caae9eccc8acfad378e5c75628016973b4902cd Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Fri, 20 Dec 2024 12:44:29 -0800
Subject: [PATCH 77/79] Use a new function pair for saving and loading paths

---
 src/Common/getInstanceFromFullName.luau       | 64 -------------------
 src/Common/getInstanceFromPath.luau           | 44 +++++++++++++
 ...pec.luau => getInstanceFromPath.spec.luau} | 25 ++++----
 src/Common/getInstancePath.luau               | 18 ++++++
 src/Common/getInstancePath.spec.luau          | 48 ++++++++++++++
 src/Plugin/LocalStorageContext.luau           | 34 +++++-----
 src/Storybook/useLastOpenedStory.luau         | 13 ++--
 src/TreeView/usePinnedInstances.luau          | 11 ++--
 8 files changed, 150 insertions(+), 107 deletions(-)
 delete mode 100644 src/Common/getInstanceFromFullName.luau
 create mode 100644 src/Common/getInstanceFromPath.luau
 rename src/Common/{getInstanceFromFullName.spec.luau => getInstanceFromPath.spec.luau} (62%)
 create mode 100644 src/Common/getInstancePath.luau
 create mode 100644 src/Common/getInstancePath.spec.luau

diff --git a/src/Common/getInstanceFromFullName.luau b/src/Common/getInstanceFromFullName.luau
deleted file mode 100644
index d11dd606..00000000
--- a/src/Common/getInstanceFromFullName.luau
+++ /dev/null
@@ -1,64 +0,0 @@
---[[
-	Gets an instance based off the result of GetFullName().
-
-	This is used in conjunction with debug.info() to locate the calling script.
-
-	Returns nil if the instance is outside the DataModel.
-]]
-
-local Sift = require("@pkg/Sift")
-
-local tryGetService = require("@root/RobloxInternal/tryGetService")
-
-local PATH_SEPERATOR = "."
-
-local function getInstanceFromFullName(fullName: string): Instance?
-	local parts = fullName:split(PATH_SEPERATOR)
-	local serviceName = table.remove(parts, 1)
-
-	if serviceName then
-		-- This function only works for instances in the DataModel. As such, the
-		-- first part of the path will always be a service, so if we can't find
-		-- one we exit out and return nil
-		local current = tryGetService(serviceName)
-
-		if current then
-			while #parts > 0 do
-				-- Keep around a copy of the `parts` array. We are going to concat this
-				-- into new paths, and incrementally remove from the right to narrow
-				-- down the file path.
-				local tempParts = Sift.Array.copy(parts)
-
-				-- The result of GetFullName() uses dots to separate paths, but we also
-				-- use dots in our file names (e.g. with spec and story files). As such,
-				-- this block will look ahead to see if multiple parts are actually a
-				-- single filename.
-				for _ = 1, #tempParts do
-					local name = table.concat(tempParts, PATH_SEPERATOR)
-					local found = current:FindFirstChild(name)
-
-					if found then
-						current = found
-						parts = Sift.List.shift(parts, #name:split(PATH_SEPERATOR))
-						break
-					else
-						if #tempParts == 1 then
-							-- This fixes a crash when searching for paths that
-							-- no longer exist
-							return nil
-						else
-							-- Reduce from the right until we find the next instance
-							tempParts = Sift.List.pop(tempParts)
-						end
-					end
-				end
-			end
-
-			return current
-		end
-	end
-
-	return nil
-end
-
-return getInstanceFromFullName
diff --git a/src/Common/getInstanceFromPath.luau b/src/Common/getInstanceFromPath.luau
new file mode 100644
index 00000000..a28c0d7d
--- /dev/null
+++ b/src/Common/getInstanceFromPath.luau
@@ -0,0 +1,44 @@
+--[[
+	Gets an instance based off the result of getInstancePath.
+
+	The reason we use this over GetFullName is because it uses a dot (.) as the
+	path separator which makes it difficult to disambiguate instances stories
+	and test files (Foo.story and Foo.spec, respectively)
+
+	Returns `nil` if the instance is outside the DataModel or otherwise cannot
+	be found.
+]]
+
+local tryGetService = require("@root/RobloxInternal/tryGetService")
+
+local PATH_SEPERATOR = "/"
+
+local function getInstanceFromPath(path: string): Instance?
+	local parts = path:split(PATH_SEPERATOR)
+	local serviceName = parts[1]
+
+	if serviceName then
+		-- This function only works for instances in the DataModel. As such, the
+		-- first part of the path will always be a service, so if we can't find
+		-- one we exit out and return nil
+		local current = tryGetService(serviceName)
+
+		if current then
+			for i = 2, #parts do
+				local found = current:FindFirstChild(parts[i])
+
+				if found then
+					current = found
+				else
+					return nil
+				end
+			end
+
+			return current
+		end
+	end
+
+	return nil
+end
+
+return getInstanceFromPath
diff --git a/src/Common/getInstanceFromFullName.spec.luau b/src/Common/getInstanceFromPath.spec.luau
similarity index 62%
rename from src/Common/getInstanceFromFullName.spec.luau
rename to src/Common/getInstanceFromPath.spec.luau
index d110cbda..a343e4a4 100644
--- a/src/Common/getInstanceFromFullName.spec.luau
+++ b/src/Common/getInstanceFromPath.spec.luau
@@ -3,7 +3,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
 local JestGlobals = require("@pkg/JestGlobals")
 local newFolder = require("@root/Testing/newFolder")
 
-local getInstanceFromFullName = require("./getInstanceFromFullName")
+local getInstanceFromPath = require("./getInstanceFromPath")
+local getInstancePath = require("./getInstancePath")
 
 local expect = JestGlobals.expect
 local test = JestGlobals.test
@@ -18,7 +19,7 @@ afterEach(function()
 end)
 
 test("gets services", function()
-	local path = getInstanceFromFullName("ReplicatedStorage")
+	local path = getInstanceFromPath("ReplicatedStorage")
 	expect(path).toBe(ReplicatedStorage)
 end)
 
@@ -32,8 +33,7 @@ test("works on nested instances", function()
 	})
 	folder.Parent = ReplicatedStorage
 
-	local path = getInstanceFromFullName(module:GetFullName())
-	expect(path).toBe(module)
+	expect(getInstanceFromPath(getInstancePath(module))).toBe(module)
 end)
 
 test("works with spec files", function()
@@ -46,7 +46,7 @@ test("works with spec files", function()
 	})
 	folder.Parent = ReplicatedStorage
 
-	local path = getInstanceFromFullName(module:GetFullName())
+	local path = getInstanceFromPath(module:GetFullName())
 	expect(path).toBe(module)
 end)
 
@@ -61,18 +61,17 @@ test("finds spec files BEFORE the module it is associated with", function()
 	})
 	folder.Parent = ReplicatedStorage
 
-	local path = getInstanceFromFullName(module:GetFullName())
-	expect(path).toBe(module)
+	expect(getInstanceFromPath(getInstancePath(module))).toBe(module)
 end)
 
 test("returns nil if the first part of the path is not a service", function()
-	expect(getInstanceFromFullName("Part")).toBeUndefined()
+	expect(getInstanceFromPath("Part")).toBeUndefined()
 end)
 
 test("returns nil if the path does not exist", function()
-	expect(getInstanceFromFullName("Foo.story")).toBeUndefined()
-	expect(getInstanceFromFullName("Path.To.Foo.story")).toBeUndefined()
-	expect(getInstanceFromFullName("ReplicatedStorage.Foo.Bar.Baz")).toBeUndefined()
-	expect(getInstanceFromFullName("ReplicatedStorage.Sample.story")).toBeUndefined()
-	expect(getInstanceFromFullName("ReplicatedStorage.Sample.spec")).toBeUndefined()
+	expect(getInstanceFromPath("Foo.story")).toBeUndefined()
+	expect(getInstanceFromPath("Path/To/Foo.story")).toBeUndefined()
+	expect(getInstanceFromPath("ReplicatedStorage/Foo/Bar/Baz")).toBeUndefined()
+	expect(getInstanceFromPath("ReplicatedStorage/Sample.story")).toBeUndefined()
+	expect(getInstanceFromPath("ReplicatedStorage/Sample.spec")).toBeUndefined()
 end)
diff --git a/src/Common/getInstancePath.luau b/src/Common/getInstancePath.luau
new file mode 100644
index 00000000..082df3e6
--- /dev/null
+++ b/src/Common/getInstancePath.luau
@@ -0,0 +1,18 @@
+local PATH_SEPARATOR = "/"
+
+local function getInstancePath(instance: Instance, pathSeparator: string?): string
+	pathSeparator = if pathSeparator then pathSeparator else PATH_SEPARATOR
+	assert(pathSeparator, "Luau")
+
+	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, "/")
+end
+
+return getInstancePath
diff --git a/src/Common/getInstancePath.spec.luau b/src/Common/getInstancePath.spec.luau
new file mode 100644
index 00000000..1ee401ba
--- /dev/null
+++ b/src/Common/getInstancePath.spec.luau
@@ -0,0 +1,48 @@
+local ReplicatedStorage = game:GetService("ReplicatedStorage")
+
+local JestGlobals = require("@pkg/JestGlobals")
+local newFolder = require("@root/Testing/newFolder")
+
+local getInstancePath = require("./getInstancePath")
+
+local expect = JestGlobals.expect
+local test = JestGlobals.test
+local afterEach = JestGlobals.afterEach
+
+local folder: Folder
+
+afterEach(function()
+	if folder then
+		folder:Destroy()
+	end
+end)
+
+test("services are treated as the root", function()
+	expect(getInstancePath(ReplicatedStorage)).toBe("ReplicatedStorage")
+end)
+
+test("works on nested instances", function()
+	local module = Instance.new("ModuleScript")
+
+	folder = newFolder({
+		foo = newFolder({
+			bar = module,
+		}),
+	})
+	folder.Parent = ReplicatedStorage
+
+	expect(getInstancePath(module)).toBe("ReplicatedStorage/Folder/foo/bar")
+end)
+
+test("works with spec files", function()
+	local module = Instance.new("ModuleScript")
+
+	folder = newFolder({
+		foo = newFolder({
+			["bar.spec"] = module,
+		}),
+	})
+	folder.Parent = ReplicatedStorage
+
+	expect(getInstancePath(module)).toBe("ReplicatedStorage/Folder/foo/bar.spec")
+end)
diff --git a/src/Plugin/LocalStorageContext.luau b/src/Plugin/LocalStorageContext.luau
index bd049b0d..0f705cb6 100644
--- a/src/Plugin/LocalStorageContext.luau
+++ b/src/Plugin/LocalStorageContext.luau
@@ -4,12 +4,9 @@ local React = require("@pkg/React")
 local Sift = require("@pkg/Sift")
 
 local PluginContext = require("@root/Plugin/PluginContext")
-local usePrevious = require("@root/Common/usePrevious")
 
 local useCallback = React.useCallback
 local useContext = React.useContext
-local useEffect = React.useEffect
-local useRef = React.useRef
 local useState = React.useState
 
 export type LocalStorage = {
@@ -42,15 +39,16 @@ local function LocalStorageProvider(props: Props)
 		return {}
 	end, { plugin, props.storageKey } :: { unknown })
 
-	local storage, setStorage = useState(loadFromDisk)
-	local prevStorage = usePrevious(storage)
-
-	local saveToDisk = useCallback(function()
-		local data = HttpService:JSONEncode(storage)
+	local saveToDisk = useCallback(function(newStorage: { [any]: any })
+		local data = HttpService:JSONEncode(newStorage)
 		if data then
 			plugin:SetSetting(props.storageKey, data)
 		end
-	end, { plugin, props.storageKey, storage } :: { unknown })
+	end, { plugin, props.storageKey } :: { unknown })
+
+	local storage, setStorage = useState(function()
+		return loadFromDisk()
+	end)
 
 	local get = useCallback(function(key: string)
 		return storage[key]
@@ -58,25 +56,23 @@ local function LocalStorageProvider(props: Props)
 
 	local set = useCallback(function(key: string, value: unknown?)
 		setStorage(function(prev)
-			return Sift.Dictionary.join(prev, {
+			local new = Sift.Dictionary.join(prev, {
 				[key] = if value == nil then Sift.None else value,
 			})
+
+			saveToDisk(new)
+
+			return new
 		end)
 	end, { storage })
 
-	useEffect(function()
-		if storage and storage ~= prevStorage then
-			saveToDisk()
-		end
-	end, { storage, prevStorage, saveToDisk } :: { unknown })
-
-	local context: LocalStorageContext = useRef({
+	local context: LocalStorageContext = {
 		get = get,
 		set = set,
-	})
+	}
 
 	return React.createElement(LocalStorageContext.Provider, {
-		value = context.current,
+		value = context,
 	}, props.children)
 end
 
diff --git a/src/Storybook/useLastOpenedStory.luau b/src/Storybook/useLastOpenedStory.luau
index b59457f5..8165ca0f 100644
--- a/src/Storybook/useLastOpenedStory.luau
+++ b/src/Storybook/useLastOpenedStory.luau
@@ -2,7 +2,8 @@ local React = require("@pkg/React")
 
 local LocalStorageContext = require("@root/Plugin/LocalStorageContext")
 local SettingsContext = require("@root/UserSettings/SettingsContext")
-local getInstanceFromFullName = require("@root/Common/getInstanceFromFullName")
+local getInstanceFromPath = require("@root/Common/getInstanceFromPath")
+local getInstancePath = require("@root/Common/getInstancePath")
 
 local useCallback = React.useCallback
 local useMemo = React.useMemo
@@ -15,8 +16,8 @@ local function useLastOpenedStory(): (ModuleScript?, (storyModule: ModuleScript?
 	local settingsContext = SettingsContext.use()
 
 	local setLastOpenedStory = useCallback(function(storyModule: ModuleScript?)
-		localStorage.set(LAST_OPENED_STORY_PATH_KEY, if storyModule then storyModule:GetFullName() else nil)
-	end, { localStorage })
+		localStorage.set(LAST_OPENED_STORY_PATH_KEY, if storyModule then getInstancePath(storyModule) else nil)
+	end, { localStorage.set })
 
 	local lastOpenedStory = useMemo(function(): ModuleScript?
 		local rememberLastOpenedStory = settingsContext.getSetting(REMEMBER_LAST_OPENED_STORY_KEY)
@@ -27,8 +28,8 @@ local function useLastOpenedStory(): (ModuleScript?, (storyModule: ModuleScript?
 
 		local lastOpenedStoryPath = localStorage.get(LAST_OPENED_STORY_PATH_KEY)
 
-		if lastOpenedStoryPath then
-			local instance = getInstanceFromFullName(lastOpenedStoryPath)
+		if lastOpenedStoryPath and typeof(lastOpenedStoryPath) == "string" then
+			local instance = getInstanceFromPath(lastOpenedStoryPath)
 
 			if instance and instance:IsA("ModuleScript") then
 				return instance
@@ -36,7 +37,7 @@ local function useLastOpenedStory(): (ModuleScript?, (storyModule: ModuleScript?
 		end
 
 		return nil
-	end, { settingsContext, localStorage })
+	end, { settingsContext, localStorage.get })
 
 	return lastOpenedStory, setLastOpenedStory
 end
diff --git a/src/TreeView/usePinnedInstances.luau b/src/TreeView/usePinnedInstances.luau
index 22b8a74b..6644329d 100644
--- a/src/TreeView/usePinnedInstances.luau
+++ b/src/TreeView/usePinnedInstances.luau
@@ -2,7 +2,8 @@ local React = require("@pkg/React")
 local Sift = require("@pkg/Sift")
 
 local LocalStorageContext = require("@root/Plugin/LocalStorageContext")
-local getInstanceFromFullName = require("@root/Common/getInstanceFromFullName")
+local getInstanceFromPath = require("@root/Common/getInstanceFromPath")
+local getInstancePath = require("@root/Common/getInstancePath")
 local usePrevious = require("@root/Common/usePrevious")
 
 local useCallback = React.useCallback
@@ -26,14 +27,14 @@ local function usePinnedInstances(): (ModuleScript?, (storyModule: ModuleScript?
 
 	local pin = useCallback(function(instance: Instance)
 		setPinnedPaths(function(prev)
-			return Sift.List.append(prev, instance:GetFullName())
+			return Sift.List.append(prev, getInstancePath(instance))
 		end)
 	end, {})
 
 	local unpin = useCallback(function(instance: Instance)
 		setPinnedPaths(function(prev)
 			return Sift.List.filter(prev, function(pinnedPath)
-				return pinnedPath ~= instance:GetFullName()
+				return pinnedPath ~= getInstancePath(instance)
 			end)
 		end)
 	end, {})
@@ -44,7 +45,7 @@ local function usePinnedInstances(): (ModuleScript?, (storyModule: ModuleScript?
 		for _, pinnedPath in pinnedPaths do
 			table.insert(pinnedInstances, {
 				path = pinnedPath,
-				instance = getInstanceFromFullName(pinnedPath),
+				instance = getInstanceFromPath(pinnedPath),
 			})
 		end
 
@@ -67,7 +68,7 @@ local function usePinnedInstances(): (ModuleScript?, (storyModule: ModuleScript?
 		if prevPinnedPaths and pinnedPaths ~= prevPinnedPaths then
 			localStorage.set(PINNED_INSTANCES_KEY, pinnedPaths)
 		end
-	end, { localStorage, pinnedPaths, prevPinnedPaths })
+	end, { localStorage.set, pinnedPaths, prevPinnedPaths })
 
 	return {
 		pin = pin,

From d76e4aaf2e682693a2d86a623e1dd46f7587af7a Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Fri, 20 Dec 2024 16:35:18 -0800
Subject: [PATCH 78/79] Add LuauPolyfill to fix build issues

---
 wally.toml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/wally.toml b/wally.toml
index 96fa8332..a73e9495 100644
--- a/wally.toml
+++ b/wally.toml
@@ -23,3 +23,4 @@ JestGlobals = "jsdotlua/jest-globals@3.6.1-rc.2"
 # Storyteller dependencies
 Prospector = "egomoose/prospector@1.1.0"
 Janitor = "howmanysmall/janitor@1.13.15"
+LuauPolyfill = "jsdotlua/luau-polyfill@1.2.7"

From b8e5bc9b0903e19f4feef6d4ccd4caf057d60860 Mon Sep 17 00:00:00 2001
From: Marin Minnerly <me@vocksel.com>
Date: Sun, 29 Dec 2024 18:56:41 -0800
Subject: [PATCH 79/79] Take a pass over instance path functions

---
 src/Common/getInstanceFromPath.luau      |  8 ++++----
 src/Common/getInstanceFromPath.spec.luau |  2 +-
 src/Common/getInstancePath.luau          |  5 ++---
 src/Common/getInstancePath.spec.luau     | 17 +++++++++++++++--
 4 files changed, 22 insertions(+), 10 deletions(-)

diff --git a/src/Common/getInstanceFromPath.luau b/src/Common/getInstanceFromPath.luau
index a28c0d7d..65e7969f 100644
--- a/src/Common/getInstanceFromPath.luau
+++ b/src/Common/getInstanceFromPath.luau
@@ -1,9 +1,9 @@
 --[[
-	Gets an instance based off the result of getInstancePath.
+	Gets an instance based off the result of `getInstancePath`.
 
-	The reason we use this over GetFullName is because it uses a dot (.) as the
-	path separator which makes it difficult to disambiguate instances stories
-	and test files (Foo.story and Foo.spec, respectively)
+	The reason we don't use GetFullName is because it uses a dot (.) as the path
+	separator which makes it difficult to disambiguate stories and test files
+	(Foo.story and Foo.spec, respectively)
 
 	Returns `nil` if the instance is outside the DataModel or otherwise cannot
 	be found.
diff --git a/src/Common/getInstanceFromPath.spec.luau b/src/Common/getInstanceFromPath.spec.luau
index a343e4a4..d1f835a3 100644
--- a/src/Common/getInstanceFromPath.spec.luau
+++ b/src/Common/getInstanceFromPath.spec.luau
@@ -46,7 +46,7 @@ test("works with spec files", function()
 	})
 	folder.Parent = ReplicatedStorage
 
-	local path = getInstanceFromPath(module:GetFullName())
+	local path = getInstanceFromPath(getInstancePath(module))
 	expect(path).toBe(module)
 end)
 
diff --git a/src/Common/getInstancePath.luau b/src/Common/getInstancePath.luau
index 082df3e6..e071b7c0 100644
--- a/src/Common/getInstancePath.luau
+++ b/src/Common/getInstancePath.luau
@@ -1,8 +1,7 @@
 local PATH_SEPARATOR = "/"
 
 local function getInstancePath(instance: Instance, pathSeparator: string?): string
-	pathSeparator = if pathSeparator then pathSeparator else PATH_SEPARATOR
-	assert(pathSeparator, "Luau")
+	local separator = if pathSeparator then pathSeparator else PATH_SEPARATOR
 
 	local path = {}
 	local current = instance
@@ -12,7 +11,7 @@ local function getInstancePath(instance: Instance, pathSeparator: string?): stri
 		current = current.Parent
 	end
 
-	return table.concat(path, "/")
+	return table.concat(path, separator)
 end
 
 return getInstancePath
diff --git a/src/Common/getInstancePath.spec.luau b/src/Common/getInstancePath.spec.luau
index 1ee401ba..77b93f9f 100644
--- a/src/Common/getInstancePath.spec.luau
+++ b/src/Common/getInstancePath.spec.luau
@@ -31,7 +31,7 @@ test("works on nested instances", function()
 	})
 	folder.Parent = ReplicatedStorage
 
-	expect(getInstancePath(module)).toBe("ReplicatedStorage/Folder/foo/bar")
+	expect(getInstancePath(module)).toBe("ReplicatedStorage/Root/foo/bar")
 end)
 
 test("works with spec files", function()
@@ -44,5 +44,18 @@ test("works with spec files", function()
 	})
 	folder.Parent = ReplicatedStorage
 
-	expect(getInstancePath(module)).toBe("ReplicatedStorage/Folder/foo/bar.spec")
+	expect(getInstancePath(module)).toBe("ReplicatedStorage/Root/foo/bar.spec")
+end)
+
+test("path separator can be changed", function()
+	local module = Instance.new("ModuleScript")
+
+	folder = newFolder({
+		foo = newFolder({
+			bar = module,
+		}),
+	})
+	folder.Parent = ReplicatedStorage
+
+	expect(getInstancePath(module, " > ")).toBe("ReplicatedStorage > Root > foo > bar")
 end)