@sanity/themer

0.8.10.9.0
dist/tool.js
~dist/tool.jsModified
+649−818
Index: package/dist/tool.js
===================================================================
--- package/dist/tool.js
+++ package/dist/tool.js
@@ -1,206 +1,443 @@
 import "@sanity/themer/bundle.css";
 import { l as isColor, n as buildTheme, o as SCHEMES, t as presets } from "./presets-CqBgS3A-.js";
-import { a as duplicateTitle, i as displayTitle, l as IMAGE_PALETTE_KEYS, o as initialThemerState, r as createCustomTheme, s as resolveThemes, v as ThemerContext, y as useThemer } from "./ResizableSidebar.css.ts.vanilla-M9EuPu-q.js";
-import { blue, cyan, green, magenta, orange, purple, red, yellow } from "@sanity/color";
+import { a as displayTitle, c as resolveThemes, d as viewTransitionClasses, f as NAVBAR_SELECTOR, i as createCustomTheme, l as addThemerTransitionType, n as CONFIG_TITLE, o as duplicateTitle, s as initialThemerState, u as layout } from "./themes-Bxgltjmb.js";
 import { c } from "react/compiler-runtime";
 import { definePlugin, useColorSchemeValue } from "sanity";
-import { Button, Card, Flex, Text, ThemeProvider, useMediaIndex, usePrefersReducedMotion } from "@sanity/ui";
-import { useActor, useSelector } from "@xstate/react";
-import { Activity, ViewTransition, addTransitionType, lazy, startTransition, useDeferredValue, useEffect, useRef, useState, useSyncExternalStore } from "react";
-import { assign, not, or, setup, stateIn } from "xstate";
-import { jsx, jsxs } from "react/jsx-runtime";
 import { ColorWheelIcon } from "@sanity/icons/ColorWheel";
+import { BoundaryElementProvider, Box, Button, Card, Flex, Text, ThemeProvider, useMediaIndex } from "@sanity/ui";
 import { Tooltip } from "@sanity/ui/tooltip";
-import { animate, motion, useMotionValue, useTransform } from "motion/react";
-const STORAGE_KEY = "sanityStudio:themer:state", LEGACY_STORAGE_KEY = "sanityStudio:themer:options", VISITED_STORAGE_KEY = "sanityStudio:themer:visited";
-function sanitizeColor(value) {
-	return typeof value == "string" && isColor(value) ? value.toLowerCase() : null;
+import { Activity, Suspense, ViewTransition, createContext, lazy, startTransition, use, useEffect, useReducer, useRef, useState, useTransition } from "react";
+import { Fragment, jsx, jsxs } from "react/jsx-runtime";
+import { browser } from "react-dom";
+import { dequal } from "dequal/lite";
+import * as v from "valibot";
+import { useActorRef } from "@xstate/react";
+import { assign, not, setup } from "xstate";
+/** @internal */
+const ToolIsOpenContext = createContext(!1), ToolDispatchContext = createContext(() => {
+	throw Error("ToolDispatchContext not initialized");
+}), ToolSplitIsOpenContext = createContext(!1), ToolShouldDetectNavbarHeightContext = createContext(!1), PluginConfigContext = createContext(null), AnimatedColorWheelIcon = lazy(() => import("./AnimatedColorWheelIcon-DEy-cy3X.js"));
+function ThemerNavbarButton() {
+	let $ = c(27), config = use(PluginConfigContext), dispatch = use(ToolDispatchContext), open = use(ToolIsOpenContext), shouldDetectNavbarHeight = use(ToolShouldDetectNavbarHeightContext), split = use(ToolSplitIsOpenContext);
+	if (!config) throw TypeError("PluginConfigContext.Provider is missing");
+	let [busy, startBusyTransition] = useTransition(), iconRef = useRef(null), t0;
+	$[0] !== dispatch || $[1] !== split ? (t0 = () => startBusyTransition(() => {
+		split && addThemerTransitionType("split-screen:open"), addThemerTransitionType("open"), dispatch({ type: "open" });
+	}), $[0] = dispatch, $[1] = split, $[2] = t0) : t0 = $[2];
+	let handleOpen = t0, t1;
+	$[3] !== dispatch || $[4] !== split ? (t1 = () => startBusyTransition(() => {
+		split && addThemerTransitionType("split-screen:close"), addThemerTransitionType("close"), dispatch({ type: "close" });
+	}), $[3] = dispatch, $[4] = split, $[5] = t1) : t1 = $[5];
+	let handleClose = t1, t2;
+	$[6] !== dispatch || $[7] !== open ? (t2 = () => startTransition(() => {
+		dispatch({ type: "prerender" }), open || iconRef.current?.spin();
+	}), $[6] = dispatch, $[7] = open, $[8] = t2) : t2 = $[8];
+	let handlePrerender = t2, t3;
+	$[9] === dispatch ? t3 = $[10] : (t3 = (root) => {
+		let navbar = root?.closest(NAVBAR_SELECTOR);
+		if (!navbar) return;
+		let resizeObserver = new ResizeObserver((entries) => {
+			let height = entries[0]?.borderBoxSize[0]?.blockSize;
+			height && startTransition(() => dispatch({
+				type: "set-navbar-height",
+				height
+			}));
+		});
+		return resizeObserver.observe(navbar), () => resizeObserver.disconnect();
+	}, $[9] = dispatch, $[10] = t3);
+	let observeNavbarHeight = t3, { title } = config, t4;
+	$[11] === title ? t4 = $[12] : (t4 = /* @__PURE__ */ jsx(Text, {
+		size: 1,
+		children: title
+	}), $[11] = title, $[12] = t4);
+	let t5 = shouldDetectNavbarHeight ? observeNavbarHeight : void 0, t6;
+	$[13] === Symbol.for("react.memo_cache_sentinel") ? (t6 = /* @__PURE__ */ jsx(ColorWheelIcon, {}), $[13] = t6) : t6 = $[13];
+	let t7;
+	$[14] === busy ? t7 = $[15] : (t7 = /* @__PURE__ */ jsx(Suspense, {
+		fallback: t6,
+		children: /* @__PURE__ */ jsx(AnimatedColorWheelIcon, {
+			busy,
+			ref: iconRef
+		})
+	}), $[14] = busy, $[15] = t7);
+	let t8 = open ? handleClose : handleOpen, t9 = open || busy, t10;
+	$[16] !== busy || $[17] !== handlePrerender || $[18] !== t5 || $[19] !== t7 || $[20] !== t8 || $[21] !== t9 || $[22] !== title ? (t10 = /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(ViewTransition, {
+		default: "none",
+		update: viewTransitionClasses.navbarButton.update,
+		children: /* @__PURE__ */ jsx(Button, {
+			ref: t5,
+			"aria-busy": busy,
+			"aria-label": title,
+			icon: t7,
+			mode: "bleed",
+			onClick: t8,
+			onFocus: handlePrerender,
+			onMouseEnter: handlePrerender,
+			padding: 2,
+			selected: t9
+		})
+	}) }), $[16] = busy, $[17] = handlePrerender, $[18] = t5, $[19] = t7, $[20] = t8, $[21] = t9, $[22] = title, $[23] = t10) : t10 = $[23];
+	let t11;
+	return $[24] !== t10 || $[25] !== t4 ? (t11 = /* @__PURE__ */ jsx(ViewTransition, {
+		default: "none",
+		update: viewTransitionClasses.navbarButtonTooltip.update,
+		children: /* @__PURE__ */ jsx(Tooltip, {
+			animate: !0,
+			content: t4,
+			portal: !0,
+			children: t10
+		})
+	}), $[24] = t10, $[25] = t4, $[26] = t11) : t11 = $[26], t11;
 }
-function sanitizeContrast(value) {
-	return typeof value == "number" && Number.isFinite(value) ? Math.min(100, Math.max(15, value)) : null;
+function ThemerNavbar(props) {
+	"use memo";
+	let $ = c(4), t0;
+	if ($[0] !== props) {
+		let button = {
+			location: "topbar",
+			name: "themer-topbar",
+			render: ThemerNavbarButton
+		}, t1;
+		$[2] === props.__internal_actions ? t1 = $[3] : (t1 = props.__internal_actions ?? [], $[2] = props.__internal_actions, $[3] = t1), t0 = props.renderDefault({
+			...props,
+			__internal_actions: [...t1, button]
+		}), $[0] = props, $[1] = t0;
+	} else t0 = $[1];
+	return t0;
 }
-function sanitizeSchemeOptions(value) {
-	let options = {};
-	if (!value || typeof value != "object") return options;
-	let accent = sanitizeColor(Reflect.get(value, "accent")), text = sanitizeColor(Reflect.get(value, "text")), background = sanitizeColor(Reflect.get(value, "background")), contrast = sanitizeContrast(Reflect.get(value, "contrast"));
-	return accent && (options.accent = accent), text && (options.text = text), background && (options.background = background), contrast !== null && (options.contrast = contrast), options;
+function ThemerNavbarProvider(t0) {
+	let $ = c(15), { children, config, dispatch, open, shouldDetectNavbarHeight, split } = t0, t1 = shouldDetectNavbarHeight ?? !1, t2;
+	$[0] !== children || $[1] !== t1 ? (t2 = /* @__PURE__ */ jsx(ToolShouldDetectNavbarHeightContext, {
+		value: t1,
+		children
+	}), $[0] = children, $[1] = t1, $[2] = t2) : t2 = $[2];
+	let t3;
+	$[3] !== split || $[4] !== t2 ? (t3 = /* @__PURE__ */ jsx(ToolSplitIsOpenContext, {
+		value: split,
+		children: t2
+	}), $[3] = split, $[4] = t2, $[5] = t3) : t3 = $[5];
+	let t4;
+	$[6] !== open || $[7] !== t3 ? (t4 = /* @__PURE__ */ jsx(ToolIsOpenContext, {
+		value: open,
+		children: t3
+	}), $[6] = open, $[7] = t3, $[8] = t4) : t4 = $[8];
+	let t5;
+	$[9] !== dispatch || $[10] !== t4 ? (t5 = /* @__PURE__ */ jsx(ToolDispatchContext, {
+		value: dispatch,
+		children: t4
+	}), $[9] = dispatch, $[10] = t4, $[11] = t5) : t5 = $[11];
+	let t6;
+	return $[12] !== config || $[13] !== t5 ? (t6 = /* @__PURE__ */ jsx(PluginConfigContext, {
+		value: config,
+		children: t5
+	}), $[12] = config, $[13] = t5, $[14] = t6) : t6 = $[14], t6;
 }
+function toolReducer(state, action) {
+	switch (action.type) {
+		case "set-navbar-height": return state.navbarHeight === action.height ? state : {
+			...state,
+			navbarHeight: action.height
+		};
+		case "prerender": return state.prerender ? state : {
+			...state,
+			prerender: !0
+		};
+		case "open": return state.open ? state : {
+			...state,
+			open: !0,
+			prerender: !0
+		};
+		case "close": return state.open ? {
+			...state,
+			open: !1
+		} : state;
+		case "split-screen:prerender": return state.prerenderSplitScreen ? state : {
+			...state,
+			prerenderSplitScreen: !0
+		};
+		case "split-screen:open": return state.split ? state : {
+			...state,
+			prerenderSplitScreen: !0,
+			split: !0
+		};
+		case "split-screen:close": return state.split ? {
+			...state,
+			split: !1
+		} : state;
+		case "unset-theme": return state.theme === null ? state : {
+			...state,
+			theme: null
+		};
+		case "set-theme": return dequal(state.theme, action.theme) ? state : {
+			...state,
+			theme: action.theme
+		};
+		default: throw Error(`Unknown themer tool action: ${JSON.stringify(action)}`);
+	}
+}
+const initialToolState = {
+	navbarHeight: null,
+	open: !1,
+	prerender: !1,
+	prerenderSplitScreen: !1,
+	split: !1,
+	theme: null
+};
 /**
-* Options from before they were grouped by scheme: one accent, text color and
-* contrast for both schemes, and a background per scheme. Both schemes get
-* the shared colors, so the theme keeps looking the same.
+* The options of the applied theme — `null` for the configured theme, which
+* applies nothing on top of the Studio's own, and for an applied theme that
+* no longer resolves. Cheaper than resolving the whole list when only the
+* applied theme matters.
+*
+* @internal
 */
-function sanitizeFlatOptions(value) {
-	let accent = sanitizeColor(Reflect.get(value, "accent"));
-	if (!accent) return null;
-	let shared = { accent }, text = sanitizeColor(Reflect.get(value, "text")), contrast = sanitizeContrast(Reflect.get(value, "contrast"));
-	text && (shared.text = text), contrast !== null && (shared.contrast = contrast);
-	let backgrounds = Reflect.get(value, "background"), options = {};
+function resolveActiveThemeOptions(state) {
+	if (state.active === null || state.removed.includes(state.active)) return null;
+	for (let theme of state.custom) if (theme.slug === state.active) return theme.options;
+	for (let preset of presets) if (preset.slug === state.active) return preset.options;
+	return null;
+}
+/** Drops the keys whose value is `undefined`, which a dropped option leaves behind */
+function compact(value) {
+	return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0));
+}
+/** A `#rgb` or `#rrggbb` color, lowercased */
+const color = v.pipe(v.string(), v.check(isColor), v.toLowerCase()), optionalColor = v.fallback(v.optional(color), void 0), optionalContrast = v.fallback(v.optional(v.pipe(v.number(), v.finite(), v.toMinValue(15), v.toMaxValue(100))), void 0), schemeOptionsSchema = v.pipe(v.object({
+	accent: optionalColor,
+	text: optionalColor,
+	background: optionalColor,
+	contrast: optionalContrast
+}), v.transform((options) => compact(options))), groupedOptionsSchema = v.pipe(v.looseObject({
+	light: v.fallback(v.optional(schemeOptionsSchema), void 0),
+	dark: v.fallback(v.optional(schemeOptionsSchema), void 0)
+}), v.check((input) => !("accent" in input), "flat options"), v.transform(({ light, dark }) => {
+	let options = {};
+	return light && Object.keys(light).length > 0 && (options.light = light), dark && Object.keys(dark).length > 0 && (options.dark = dark), options;
+})), flatOptionsSchema = v.pipe(v.object({
+	accent: color,
+	text: optionalColor,
+	contrast: optionalContrast,
+	background: v.fallback(v.optional(v.object({
+		light: optionalColor,
+		dark: optionalColor
+	})), void 0)
+}), v.transform(({ accent, text, contrast, background }) => {
+	let shared = compact({
+		accent,
+		text,
+		contrast
+	}), options = {};
 	for (let scheme of SCHEMES) {
-		let background = backgrounds && typeof backgrounds == "object" ? sanitizeColor(Reflect.get(backgrounds, scheme)) : null;
-		options[scheme] = background ? {
+		let schemeBackground = background?.[scheme];
+		options[scheme] = schemeBackground ? {
 			...shared,
-			background
+			background: schemeBackground
 		} : { ...shared };
 	}
 	return options;
-}
-function sanitizeOptions(value) {
-	if (!value || typeof value != "object") return null;
-	if ("accent" in value) return sanitizeFlatOptions(value);
-	let options = {};
-	for (let scheme of SCHEMES) {
-		let schemeOptions = sanitizeSchemeOptions(Reflect.get(value, scheme));
-		Object.keys(schemeOptions).length > 0 && (options[scheme] = schemeOptions);
-	}
-	return options;
-}
+})), themeOptionsSchema = v.union([flatOptionsSchema, groupedOptionsSchema]), swatch = v.fallback(v.optional(v.nullable(color), null), null), paletteSchema = v.pipe(v.object({
+	vibrant: swatch,
+	lightVibrant: swatch,
+	darkVibrant: swatch,
+	muted: swatch,
+	lightMuted: swatch,
+	darkMuted: swatch,
+	dominant: swatch
+}), v.transform((palette) => Object.values(palette).some((item) => item !== null) ? palette : null));
 function isReservedSlug(slug) {
 	return slug === "config" || presets.some((preset) => preset.slug === slug);
 }
-function sanitizeCustomTheme(value) {
-	if (!value || typeof value != "object") return null;
-	let slug = Reflect.get(value, "slug");
-	if (typeof slug != "string" || !slug || isReservedSlug(slug)) return null;
-	let options = sanitizeOptions(Reflect.get(value, "options"));
-	if (!options) return null;
-	let title = Reflect.get(value, "title"), palette = sanitizePalette(Reflect.get(value, "palette"));
-	return {
-		slug,
-		title: displayTitle(typeof title == "string" ? title : ""),
-		options,
-		...palette ? { palette } : {}
-	};
-}
-function sanitizePalette(value) {
-	if (!value || typeof value != "object") return null;
-	let palette = {}, swatches = 0;
-	for (let key of IMAGE_PALETTE_KEYS) {
-		let swatch = sanitizeColor(Reflect.get(value, key));
-		palette[key] = swatch, swatch && swatches++;
+const customThemeSchema = v.pipe(v.object({
+	slug: v.pipe(v.string(), v.nonEmpty(), v.check((slug) => !isReservedSlug(slug), "reserved slug")),
+	title: v.fallback(v.optional(v.string()), void 0),
+	options: themeOptionsSchema,
+	palette: v.fallback(v.optional(paletteSchema), void 0)
+}), v.transform(({ slug, title, options, palette }) => ({
+	slug,
+	title: displayTitle(title ?? ""),
+	options,
+	...palette ? { palette } : {}
+}))), lenientList = v.fallback(v.optional(v.array(v.unknown()), []), []), themerStateSchema = v.pipe(v.object({
+	active: v.fallback(v.nullable(v.string()), null),
+	custom: lenientList,
+	removed: lenientList,
+	order: lenientList
+}), v.transform((state) => {
+	let custom = [];
+	for (let item of state.custom) {
+		let result = v.safeParse(customThemeSchema, item);
+		result.success && !custom.some((theme) => theme.slug === result.output.slug) && custom.push(result.output);
 	}
-	return swatches > 0 ? palette : null;
-}
-function sanitizeState(value) {
-	if (!value || typeof value != "object") return initialThemerState;
-	let custom = [], rawCustom = Reflect.get(value, "custom");
-	if (Array.isArray(rawCustom)) for (let item of rawCustom) {
-		let theme = sanitizeCustomTheme(item);
-		theme && !custom.some((existing) => existing.slug === theme.slug) && custom.push(theme);
-	}
-	let removed = [], rawRemoved = Reflect.get(value, "removed");
-	if (Array.isArray(rawRemoved)) for (let slug of rawRemoved) typeof slug == "string" && slug !== "config" && (presets.some((preset) => preset.slug === slug) || custom.some((theme) => theme.slug === slug)) && !removed.includes(slug) && removed.push(slug);
-	let order = [], rawOrder = Reflect.get(value, "order");
-	if (Array.isArray(rawOrder)) for (let slug of rawOrder) typeof slug == "string" && (slug === "config" || presets.some((preset) => preset.slug === slug) || custom.some((theme) => theme.slug === slug)) && !order.includes(slug) && order.push(slug);
-	let rawActive = Reflect.get(value, "active");
+	let isCustom = (slug) => custom.some((theme) => theme.slug === slug), isPreset = (slug) => presets.some((preset) => preset.slug === slug), removed = [], order = [];
+	for (let slug of state.removed) typeof slug != "string" || slug === "config" || removed.includes(slug) || (isPreset(slug) || isCustom(slug)) && removed.push(slug);
+	for (let slug of state.order) typeof slug != "string" || order.includes(slug) || (slug === "config" || isPreset(slug) || isCustom(slug)) && order.push(slug);
 	return {
-		active: typeof rawActive == "string" ? rawActive : null,
+		active: state.active,
 		custom,
 		removed,
 		order
 	};
+}));
+/** The persisted themer state, or `null` for anything else @internal */
+function parseThemerState(input) {
+	let result = v.safeParse(themerStateSchema, input);
+	return result.success ? result.output : null;
 }
-function readLegacyState() {
-	let raw = localStorage.getItem(LEGACY_STORAGE_KEY);
-	if (!raw) return null;
-	let options = sanitizeOptions(JSON.parse(raw));
-	if (!options) return null;
-	let theme = createCustomTheme("Draft theme", options);
+/** The machine's state value, as persisted — only what the machine has states for restores @internal */
+const snapshotValueSchema = v.object({
+	flow: v.picklist([
+		"list",
+		"edit",
+		"removed"
+	]),
+	theme: v.picklist(["applied", "switching"])
+});
+/**
+* A snapshot for the machine to start a session from: the given themes, with
+* nothing being switched to, in the list unless a flow and its subject are
+* given. It is how the themes alone — all that earlier versions persisted —
+* are handed to a machine that only restores snapshots, and what a restored
+* snapshot is made fit for this session as.
+*
+* @internal
+*/
+function snapshotFromState(state, editing = null, flow = "list") {
 	return {
-		active: theme.slug,
-		custom: [theme],
-		removed: [],
-		order: []
+		status: "active",
+		output: void 0,
+		error: void 0,
+		value: {
+			flow,
+			theme: "applied"
+		},
+		context: {
+			...state,
+			editing,
+			images: {}
+		},
+		historyValue: {},
+		children: {}
 	};
 }
 /**
-* Restores the themer state from localStorage, so the user's themes survive
-* studio reloads. Falls back to the initial state when nothing usable is
-* stored.
+* The machine's snapshot as the last session persisted it, made fit for this
+* one: the themes are parsed like any persisted state, the flow keeps its
+* subject or falls back to the list, a switch that was under way is over,
+* and the image URLs of the last session are gone with it.
 *
 * @internal
 */
-function readStoredState() {
-	try {
-		if (typeof localStorage > "u") return initialThemerState;
-		let raw = localStorage.getItem(STORAGE_KEY);
-		return raw ? sanitizeState(JSON.parse(raw)) : readLegacyState() ?? initialThemerState;
-	} catch {
-		return initialThemerState;
-	}
+const persistedSnapshotSchema = v.pipe(v.object({
+	status: v.literal("active"),
+	value: snapshotValueSchema,
+	context: v.pipe(v.looseObject({ editing: v.fallback(v.nullable(v.object({
+		slug: v.string(),
+		focusTitle: v.boolean()
+	})), null) }), v.transform(({ editing, ...state }) => ({
+		editing,
+		state: parseThemerState(state)
+	})), v.check(({ state }) => state !== null, "no themes"))
+}), v.transform(({ value, context }) => {
+	let state = context.state ?? {
+		active: null,
+		custom: [],
+		removed: [],
+		order: []
+	}, editing = context.editing && state.custom.some((theme) => theme.slug === context.editing?.slug) && !state.removed.includes(context.editing.slug) ? context.editing : null;
+	return snapshotFromState(state, editing, value.flow === "edit" && editing === null || value.flow === "removed" && state.removed.length === 0 ? "list" : value.flow);
+})), SNAPSHOT_STORAGE_KEY = "sanityStudio:themer:snapshot", STATE_STORAGE_KEY = "sanityStudio:themer:state", LEGACY_STORAGE_KEY = "sanityStudio:themer:options", LEGACY_DRAFT = {
+	slug: "custom-draft",
+	title: "Draft theme"
+};
+function readJson(key) {
+	let raw = localStorage.getItem(key);
+	return raw ? JSON.parse(raw) : void 0;
 }
-/** @internal */
-function writeStoredState(state) {
-	try {
-		if (typeof localStorage > "u") return;
-		localStorage.setItem(STORAGE_KEY, JSON.stringify(state)), localStorage.removeItem(LEGACY_STORAGE_KEY);
-	} catch {}
-}
 /**
-* Whether the themer sidebar has been opened before, in any session — the
-* cue for the navbar to stop introducing the tool.
+* Restores the machine's snapshot from what the last session persisted, for
+* `createActor` to start from, so the user's themes survive Studio reloads:
+* the snapshot itself where there is one that fits, and one made from the
+* themes alone where the snapshot no longer fits the machine or where
+* earlier versions of the tool stored the themes without a snapshot.
+* `undefined` when nothing usable is stored — or storage cannot be read at
+* all — leaves the machine to start from no themes.
 *
+* The parsing is the schemas' (see `schemas.ts`): nothing here or in the
+* machine looks at what is stored beyond handing it over.
+*
 * @internal
 */
-function hasVisited() {
+function readPersistedSnapshot() {
 	try {
-		return typeof localStorage < "u" && localStorage.getItem(VISITED_STORAGE_KEY) !== null;
+		if (typeof localStorage > "u") return;
+		let persisted = readJson(SNAPSHOT_STORAGE_KEY);
+		if (persisted !== void 0) {
+			let snapshot = v.safeParse(persistedSnapshotSchema, persisted);
+			if (snapshot.success) return snapshot.output;
+			let state = parseThemerState(persisted && typeof persisted == "object" ? Reflect.get(persisted, "context") : void 0);
+			if (state) return snapshotFromState(state);
+		}
+		let state = parseThemerState(readJson(STATE_STORAGE_KEY));
+		return state ? snapshotFromState(state) : readLegacyDraft();
 	} catch {
-		return !1;
+		return;
 	}
 }
+function readLegacyDraft() {
+	let options = v.safeParse(themeOptionsSchema, readJson(LEGACY_STORAGE_KEY));
+	if (!options.success) return;
+	let theme = {
+		...LEGACY_DRAFT,
+		options: options.output
+	};
+	return snapshotFromState({
+		active: theme.slug,
+		custom: [theme],
+		removed: [],
+		order: []
+	});
+}
 /**
-* Notes that the themer sidebar has been opened, keeping the time it first
-* was.
+* Persists the machine's snapshot for the next session, and lets go of what
+* earlier versions stored once it is written — which completes their
+* migration.
 *
 * @internal
 */
-function markVisited() {
+function writePersistedSnapshot(snapshot) {
 	try {
-		if (typeof localStorage > "u" || localStorage.getItem(VISITED_STORAGE_KEY) !== null) return;
-		localStorage.setItem(VISITED_STORAGE_KEY, (/* @__PURE__ */ new Date()).toISOString());
+		if (typeof localStorage > "u") return;
+		localStorage.setItem(SNAPSHOT_STORAGE_KEY, JSON.stringify(snapshot)), localStorage.removeItem(STATE_STORAGE_KEY), localStorage.removeItem(LEGACY_STORAGE_KEY);
 	} catch {}
 }
-/**
-* How long the layout's motions — the panel's and the split preview's, and
-* the cross-fade to another theme — are taken to last when the layout never
-* says: it reports every view transition it starts, so this only ends a
-* motion that has none (reduced motion, a browser without view transitions),
-* where the `moving` and `switching` tags have nothing to affect. Generous,
-* so that it never runs out before a slow deferred commit starts the real one.
-*
-* @internal
-*/
-const MOTION_DURATION = 2e3;
 function themesOf(context) {
-	return resolveThemes(context, context.baseOptions);
+	return resolveThemes(context);
 }
 function revokeObjectUrl(url) {
 	typeof URL < "u" && typeof URL.revokeObjectURL == "function" && URL.revokeObjectURL(url);
 }
 /**
-* The state of the themer tool: three parallel regions, whether the
-* `sidebar` is open, which `flow` it is in — picking a theme from the `list`,
-* `edit`ing one of the user's own themes, or restoring `removed` ones — and
-* whether the `preview` shows the Studio once (`single`) or in light and dark
-* side by side (`split`). The context carries the persisted state (the
-* applied theme, the user's themes and what was removed) alongside what the
-* flows need.
+* The themes of the themer tool and the sidebar's way through them: which
+* `flow` the sidebar is in — picking a theme from the `list`, `edit`ing one of
+* the user's own themes, or restoring `removed` ones — and, in a parallel
+* `theme` region, whether another theme was just applied. The context carries
+* the persisted state (the applied theme, the user's themes and what was
+* removed) alongside what the flows need. Whether the sidebar is open and
+* whether the Studio shows twice is not the machine's: the tool reducer in
+* `ThemerProvider` owns that, so that the navbar toggle and the sidebar can
+* change it in transitions of their own. Nor is persisting: the machine
+* never touches storage and takes no input — it starts from no themes, and a
+* session with something to pick up from restores its persisted snapshot
+* instead (see `storage.ts` and `sync.ts`), while the persisted state of
+* other tabs reaches it as a `themes.sync` event like any other.
 *
-* The sidebar and the preview pass through `opening`/`closing` and
-* `splitting`/`unsplitting` on their way, states tagged for the UI: `panel`
-* and `split` say what shows (the panel from `opening` on, the split copy from
-* `splitting` on), `moving` that the Studio is about to give way or take room
-* — which is when, and only when, the layout lets a view transition animate
-* it. A fourth region, `theme`, is `switching` (its tag) right after an event
-* that applies another theme — picking one, adding, duplicating, importing or
-* editing one, removing or deleting the applied one — when the layout
-* cross-fades to it; edits to the applied theme's colors are not switches,
-* they follow the pointer. These states last until the layout reports its
-* transition under way — every boundary reports, the panel's and the copy's
-* entering and leaving as much as the Studio's morphing — or for
-* `MOTION_DURATION` when nothing animates.
+* The `theme` region is `switching` (its tag) right after an event that
+* applies another theme — picking one, adding, duplicating, importing or
+* editing one, removing or deleting the applied one — which is when the
+* layout cross-fades the Studio to it. Edits to the applied theme's colors are
+* not switches, they follow the pointer: the first one ends the switch, and so
+* does `MOTION_DURATION` when none follows.
 *
 * Theme operations are handled in every flow, and the flows leave on their
 * own when they lose their subject: the editor when its theme is removed or
 * deleted, and the removed view when its last theme is restored or deleted.
@@ -209,36 +446,32 @@
 */
 const themerMachine = setup({
 	types: {
 		context: {},
-		events: {},
-		input: {}
+		events: {}
 	},
 	guards: {
 		isCustomTheme: ({ context }, params) => themesOf(context).themes.some((theme) => theme.slug === params.slug && theme.source === "custom"),
 		isEditingListedTheme: ({ context }) => context.editing !== null && themesOf(context).themes.some((theme) => theme.slug === context.editing?.slug && theme.source === "custom"),
 		hasRemovedThemes: ({ context }) => themesOf(context).removed.length > 0,
-		sidebarShown: or([stateIn({ sidebar: "opening" }), stateIn({ sidebar: "open" })]),
 		isAnotherTheme: ({ context }, params) => params.slug !== (context.active ?? "config"),
 		isAppliedTheme: ({ context }, params) => params.slug === context.active,
+		appliesAnotherTheme: ({ context }, params) => params.state.active !== context.active,
 		hasOptions: (_, params) => params.options !== void 0
 	},
 	actions: {
-		persist: ({ context }) => {
-			let { active, custom, removed, order } = context;
-			writeStoredState({
-				active,
-				custom,
-				removed,
-				order
-			});
-		},
+		sync: assign((_, params) => ({
+			active: params.state.active,
+			custom: params.state.custom,
+			removed: params.state.removed,
+			order: params.state.order
+		})),
 		revokeImage: (_, params) => {
 			params.url && revokeObjectUrl(params.url);
 		},
 		pick: assign((_, params) => ({ active: params.slug === "config" ? null : params.slug })),
 		add: assign(({ context }, params) => {
-			let theme = createCustomTheme(params.title ?? "Untitled theme", params.options ?? themesOf(context).active.options, params.palette);
+			let theme = createCustomTheme(params.title ?? "Untitled theme", params.options ?? themesOf(context).active?.options ?? {}, params.palette);
 			return {
 				active: theme.slug,
 				custom: [...context.custom, theme],
 				editing: {
@@ -258,9 +491,13 @@
 				custom: [...context.custom, theme]
 			};
 		}),
 		duplicate: assign(({ context }, params) => {
-			let { themes, removed } = themesOf(context), source = [...themes, ...removed].find((theme) => theme.slug === params.slug);
+			let { themes, removed } = themesOf(context), source = params.slug === "config" ? {
+				title: CONFIG_TITLE,
+				options: {},
+				palette: void 0
+			} : [...themes, ...removed].find((theme) => theme.slug === params.slug);
 			if (!source) return {};
 			let theme = createCustomTheme(duplicateTitle(source.title), source.options, source.palette);
 			return {
 				active: theme.slug,
@@ -312,85 +549,15 @@
 		})
 	}
 }).createMachine({
 	id: "themer",
-	context: ({ input }) => ({
-		...input.stored,
-		baseOptions: input.baseOptions,
+	context: () => ({
+		...initialThemerState,
 		editing: null,
 		images: {}
 	}),
-	entry: "persist",
 	type: "parallel",
 	states: {
-		sidebar: {
-			initial: "closed",
-			states: {
-				closed: { on: { "sidebar.toggle": "opening" } },
-				opening: {
-					tags: ["panel", "moving"],
-					after: { [MOTION_DURATION]: "open" },
-					on: {
-						"layout.transitioned": "open",
-						"sidebar.toggle": "closing",
-						"sidebar.close": "closing"
-					}
-				},
-				open: {
-					tags: ["panel"],
-					on: {
-						"sidebar.toggle": "closing",
-						"sidebar.close": "closing"
-					}
-				},
-				closing: {
-					tags: ["moving"],
-					after: { [MOTION_DURATION]: "closed" },
-					on: {
-						"layout.transitioned": "closed",
-						"sidebar.toggle": "opening"
-					}
-				}
-			}
-		},
-		preview: {
-			initial: "single",
-			states: {
-				single: { on: { "preview.toggle": "splitting" } },
-				splitting: {
-					tags: ["split", "moving"],
-					after: { [MOTION_DURATION]: "split" },
-					on: {
-						"layout.transitioned": "split",
-						"preview.toggle": "unsplitting",
-						"sidebar.close": "unsplitting",
-						"sidebar.toggle": {
-							guard: "sidebarShown",
-							target: "unsplitting"
-						}
-					}
-				},
-				split: {
-					tags: ["split"],
-					on: {
-						"preview.toggle": "unsplitting",
-						"sidebar.close": "unsplitting",
-						"sidebar.toggle": {
-							guard: "sidebarShown",
-							target: "unsplitting"
-						}
-					}
-				},
-				unsplitting: {
-					tags: ["moving"],
-					after: { [MOTION_DURATION]: "single" },
-					on: {
-						"layout.transitioned": "single",
-						"preview.toggle": "splitting"
-					}
-				}
-			}
-		},
 		theme: {
 			initial: "applied",
 			states: {
 				applied: { on: {
@@ -435,15 +602,22 @@
 							params: ({ event }) => ({ options: event.options })
 						},
 						target: "switching"
 					},
-					"theme.import": "switching"
+					"theme.import": "switching",
+					"themes.sync": {
+						guard: {
+							type: "appliesAnotherTheme",
+							params: ({ event }) => ({ state: event.state })
+						},
+						target: "switching"
+					}
 				} },
 				switching: {
 					tags: ["switching"],
-					after: { [MOTION_DURATION]: "applied" },
+					after: { 2e3: "applied" },
 					on: {
-						"layout.transitioned": "applied",
+						"theme.update": "applied",
 						"theme.pick": {
 							guard: {
 								type: "isAnotherTheme",
 								params: ({ event }) => ({ slug: event.slug })
@@ -493,8 +667,16 @@
 						},
 						"theme.import": {
 							target: "switching",
 							reenter: !0
+						},
+						"themes.sync": {
+							guard: {
+								type: "appliesAnotherTheme",
+								params: ({ event }) => ({ state: event.state })
+							},
+							target: "switching",
+							reenter: !0
 						}
 					}
 				}
 			}
@@ -504,9 +686,9 @@
 			on: {
 				"theme.pick": { actions: [{
 					type: "pick",
 					params: ({ event }) => ({ slug: event.slug })
-				}, "persist"] },
+				}] },
 				"theme.add": {
 					target: ".edit",
 					actions: [{
 						type: "add",
@@ -515,16 +697,16 @@
 							options: event.options,
 							palette: event.palette,
 							imageUrl: event.imageUrl
 						})
-					}, "persist"]
+					}]
 				},
 				"theme.duplicate": {
 					target: ".edit",
 					actions: [{
 						type: "duplicate",
 						params: ({ event }) => ({ slug: event.slug })
-					}, "persist"]
+					}]
 				},
 				"theme.edit": {
 					target: ".edit",
 					guard: {
@@ -533,57 +715,53 @@
 					},
 					actions: [{
 						type: "startEditing",
 						params: ({ event }) => ({ slug: event.slug })
-					}, "persist"]
+					}]
 				},
-				"theme.update": { actions: [
-					{
-						type: "revokeImage",
-						params: ({ context, event }) => ({ url: event.imageUrl ? context.images[event.slug] : void 0 })
-					},
-					{
-						type: "update",
-						params: ({ event }) => ({
-							slug: event.slug,
-							title: event.title,
-							options: event.options,
-							palette: event.palette,
-							imageUrl: event.imageUrl
-						})
-					},
-					"persist"
-				] },
+				"theme.update": { actions: [{
+					type: "revokeImage",
+					params: ({ context, event }) => ({ url: event.imageUrl ? context.images[event.slug] : void 0 })
+				}, {
+					type: "update",
+					params: ({ event }) => ({
+						slug: event.slug,
+						title: event.title,
+						options: event.options,
+						palette: event.palette,
+						imageUrl: event.imageUrl
+					})
+				}] },
 				"theme.remove": { actions: [{
 					type: "remove",
 					params: ({ event }) => ({ slug: event.slug })
-				}, "persist"] },
+				}] },
 				"theme.restore": { actions: [{
 					type: "restore",
 					params: ({ event }) => ({ slug: event.slug })
-				}, "persist"] },
-				"theme.delete": { actions: [
-					{
-						type: "revokeImage",
-						params: ({ context, event }) => ({ url: context.images[event.slug] })
-					},
-					{
-						type: "delete",
-						params: ({ event }) => ({ slug: event.slug })
-					},
-					"persist"
-				] },
+				}] },
+				"theme.delete": { actions: [{
+					type: "revokeImage",
+					params: ({ context, event }) => ({ url: context.images[event.slug] })
+				}, {
+					type: "delete",
+					params: ({ event }) => ({ slug: event.slug })
+				}] },
 				"theme.reorder": { actions: [{
 					type: "reorder",
 					params: ({ event }) => ({ order: event.order })
-				}, "persist"] },
+				}] },
 				"theme.import": { actions: [{
 					type: "import",
 					params: ({ event }) => ({
 						title: event.title,
 						options: event.options
 					})
-				}, "persist"] },
+				}] },
+				"themes.sync": { actions: {
+					type: "sync",
+					params: ({ event }) => ({ state: event.state })
+				} },
 				"flow.list": ".list",
 				"flow.removed": {
 					target: ".removed",
 					guard: "hasRemovedThemes"
@@ -605,143 +783,35 @@
 			}
 		}
 	}
 });
-/** The part of the machine context that is persisted between sessions @internal */
-function selectStoredState(snapshot) {
-	let { active, custom, removed, order } = snapshot.context;
-	return {
-		active,
-		custom,
-		removed,
-		order
-	};
-}
-const QUERY = "(prefers-reduced-motion: reduce)";
-function subscribe(onChange) {
-	let media = matchMedia(QUERY);
-	return media.addEventListener("change", onChange), () => media.removeEventListener("change", onChange);
-}
-function getSnapshot() {
-	return matchMedia(QUERY).matches;
-}
-function getServerSnapshot() {
-	return !1;
-}
-/**
-* Whether the user prefers reduced motion — live, so that turning the
-* setting on while the Studio is open takes effect right away.
-*
-* @internal
-*/
-function usePrefersReducedMotion$1() {
-	return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
-}
-/**
-* The Studio navbar's root, the `Card` that draws its bottom border — its
-* test id is stable and unique to the navbar, unlike its `data-ui` name
-*
-* @internal
-*/
-const NAVBAR_SELECTOR = "[data-testid=\"studio-navbar\"]";
-/**
-* The height of the Studio navbar inside the given element, in pixels — kept
-* up to date as the navbar renders, remounts and resizes, and `null` until
-* there is one. The themer's header takes this height, so that its bottom
-* border lines up with the navbar's whatever height the Studio version, its
-* breakpoint or a custom navbar gives it.
-*
-* @internal
-*/
-function useStudioNavbarHeight(ref) {
-	let $ = c(3), [height, setHeight] = useState(null), t0, t1;
-	return $[0] === ref ? (t0 = $[1], t1 = $[2]) : (t0 = () => {
-		let root = ref.current;
-		if (!root) return;
-		let navbar = null, resizeObserver = new ResizeObserver(() => {
-			navbar && setHeight(navbar.getBoundingClientRect().height);
-		}), observeNavbar = () => {
-			let next = root.querySelector(NAVBAR_SELECTOR);
-			next !== navbar && (navbar && resizeObserver.unobserve(navbar), navbar = next, navbar ? (resizeObserver.observe(navbar), setHeight(navbar.getBoundingClientRect().height)) : setHeight(null));
-		}, mutationObserver = new MutationObserver(observeNavbar);
-		return mutationObserver.observe(root, {
-			childList: !0,
-			subtree: !0
-		}), observeNavbar(), () => {
-			mutationObserver.disconnect(), resizeObserver.disconnect();
+function PersistActorSnapshot(t0) {
+	let $ = c(3), { actorRef } = t0, t1, t2;
+	return $[0] === actorRef ? (t1 = $[1], t2 = $[2]) : (t1 = () => {
+		let cancel, cancelOrClear = function cancelOrClear() {
+			"cancelIdleCallback" in window ? cancelIdleCallback(cancel) : clearTimeout(cancel);
+		}, subscription = actorRef.subscribe(() => {
+			cancelOrClear();
+			let callback = () => {
+				writePersistedSnapshot(actorRef.getPersistedSnapshot());
+			};
+			cancel = "cancelIdleCallback" in window ? requestIdleCallback(callback) : setTimeout(callback, 1e3);
+		});
+		return () => {
+			subscription.unsubscribe(), cancelOrClear();
 		};
-	}, t1 = [ref], $[0] = ref, $[1] = t0, $[2] = t1), useEffect(t0, t1), height;
+	}, t2 = [actorRef], $[0] = actorRef, $[1] = t1, $[2] = t2), useEffect(t1, t2), null;
 }
 /**
-* `useDeferredValue` with a transition type: the returned value lags behind
-* `value` like a deferred one — rendered in a transition, which React
-* animates — and the view transition React starts for it carries the given
-* type (`document.startViewTransition({types})`), so a stylesheet tells it
-* from any other transition on the page through
-* `:active-view-transition-type()`.
-*
-* React only takes types from `startTransition`, and a deferred render is
-* not one. So while the deferred value lags, a transition of the type joins
-* it, with a state update of its own for the type to belong to: React renders
-* every pending transition in one go, and the commit — the view transition
-* — carries the types of all of them. The value itself stays with
-* `useDeferredValue`, which reads the latest `value` as the deferred render
-* runs: should `value` change back before that render commits, nothing
-* changes and nothing animates — a queued state update would commit the
-* stale value and animate it, only to animate back.
-*
-* @internal
+* The sidebar — everything in it, from the theme list to the snippet dialog —
+* loads the first time it opens. There is no `Suspense` boundary around it,
+* so it must only ever mount from a deferred render: that keeps the Studio as
+* it was while the code loads, with the navbar toggle showing the deferred
+* value as pending, where an urgent render would suspend up to the Studio's
+* own boundary and swap the whole Studio for its loading screen.
 */
-function useTypedDeferredValue(value, type) {
-	let $ = c(5), deferred = useDeferredValue(value), [, setTransitions] = useState(0), t0, t1;
-	return $[0] !== deferred || $[1] !== type || $[2] !== value ? (t0 = () => {
-		Object.is(deferred, value) || startTransition(() => {
-			addTransitionType(type), setTransitions(_temp);
-		});
-	}, t1 = [
-		deferred,
-		type,
-		value
-	], $[0] = deferred, $[1] = type, $[2] = value, $[3] = t0, $[4] = t1) : (t0 = $[3], t1 = $[4]), useEffect(t0, t1), deferred;
-}
-function _temp(count) {
-	return count + 1;
-}
-var layout = "_1yq4ar78", layoutTransitionType = "sanity-themer", panelTransitionClasses = {
-	slideIn: "_1yq4ar76",
-	slideOut: "_1yq4ar77"
-}, splitTransitionClasses = {
-	resize: "_1yq4ar70",
-	crossfade: "_1yq4ar71",
-	slideIn: "_1yq4ar72",
-	slideOut: "_1yq4ar73",
-	dropIn: "_1yq4ar74",
-	dropOut: "_1yq4ar75"
-}, studioScheme = {
-	light: "_1yq4ar79",
-	dark: "_1yq4ar7a"
-};
+const ResizableSidebar = lazy(() => import("./ResizableSidebar-BpgLA4XQ.js"));
 /**
-* Below this media index the Studio collapses its navbar into a drawer, and
-* the themer follows suit: the sidebar covers the Studio instead of standing
-* next to it, and the split preview stacks instead of sitting side by side.
-*/
-const ResizableSidebar = lazy(() => import("./ResizableSidebar-DD787kQr.js"));
-function sameStoredState(a, b) {
-	return a.active === b.active && a.custom === b.custom && a.removed === b.removed && a.order === b.order;
-}
-function selectView(snapshot) {
-	let { editing } = snapshot.context;
-	return snapshot.matches({ flow: "edit" }) && editing ? {
-		name: "edit",
-		slug: editing.slug,
-		focusTitle: editing.focusTitle
-	} : snapshot.matches({ flow: "removed" }) ? { name: "removed" } : { name: "list" };
-}
-function sameView(a, b) {
-	return a.name === b.name ? a.name !== "edit" || b.name !== "edit" || a.slug === b.slug && a.focusTitle === b.focusTitle : !1;
-}
-/**
 * Wraps the whole Studio so that the theme picked in the themer sidebar
 * applies everywhere while the user browses around, runs the themer machine
 * that the navbar toggle and the sidebar share — the user's themes, which one
 * is applied, which flow the sidebar is in and how the Studio is previewed —
@@ -767,148 +837,96 @@
 * `ThemerLayout.css.ts`).
 *
 * @internal
 */
-function ThemerLayout(props) {
-	let $ = c(87), { baseOptions, ...layoutProps } = props, t0;
-	$[0] === baseOptions ? t0 = $[1] : (t0 = () => ({
-		baseOptions,
-		stored: readStoredState()
-	}), $[0] = baseOptions, $[1] = t0);
-	let [input] = useState(t0), t1;
-	$[2] === input ? t1 = $[3] : (t1 = { input }, $[2] = input, $[3] = t1);
-	let [snapshot, send, actorRef] = useActor(themerMachine, t1), stored = useSelector(actorRef, selectStoredState, sameStoredState), view = useSelector(actorRef, selectView, sameView), t2;
-	$[4] === snapshot ? t2 = $[5] : (t2 = snapshot.hasTag("panel"), $[4] = snapshot, $[5] = t2);
-	let open = t2, t3;
-	$[6] === snapshot ? t3 = $[7] : (t3 = snapshot.hasTag("split"), $[6] = snapshot, $[7] = t3);
-	let split = t3, t4;
-	$[8] === snapshot ? t4 = $[9] : (t4 = snapshot.hasTag("moving"), $[8] = snapshot, $[9] = t4);
-	let moving = t4, t5;
-	$[10] === snapshot ? t5 = $[11] : (t5 = snapshot.hasTag("switching"), $[10] = snapshot, $[11] = t5);
-	let switching = t5, { images } = snapshot.context, mobile = useMediaIndex() <= 1, studioRef = useRef(null), navbarHeight = useStudioNavbarHeight(studioRef), reduceMotion = usePrefersReducedMotion$1(), deferredOpen = useTypedDeferredValue(reduceMotion ? null : open, layoutTransitionType), deferredSplit = useTypedDeferredValue(reduceMotion ? null : split, layoutTransitionType), shownOpen = deferredOpen ?? open, shownSplit = deferredSplit ?? split, [panelShown, setPanelShown] = useState(!1), panelWanted = open || panelShown, panelMounted = useTypedDeferredValue(panelWanted, layoutTransitionType);
-	panelMounted && !panelShown && setPanelShown(!0);
-	let loading = panelWanted !== panelMounted, t6;
-	$[12] !== baseOptions || $[13] !== stored ? (t6 = resolveThemes(stored, baseOptions), $[12] = baseOptions, $[13] = stored, $[14] = t6) : t6 = $[14];
-	let { themes, removed, active } = t6, activeOptions = active.source === "config" ? null : active.options, t7;
-	$[15] === activeOptions ? t7 = $[16] : (t7 = activeOptions === null ? null : buildTheme(activeOptions), $[15] = activeOptions, $[16] = t7);
-	let theme = t7, deferredTheme = useTypedDeferredValue(reduceMotion ? void 0 : theme, layoutTransitionType), shownTheme = deferredTheme === void 0 ? theme : deferredTheme, scheme = useColorSchemeValue(), oppositeScheme = scheme === "dark" ? "light" : "dark", t8;
-	$[17] !== scheme || $[18] !== shownTheme?.v2?.color ? (t8 = () => {
-		let background = shownTheme?.v2?.color[scheme].default.bg;
-		if (background === void 0) return;
-		let { style } = document.body, previous = style.backgroundColor;
-		return style.backgroundColor = background, () => {
-			style.backgroundColor = previous;
-		};
-	}, $[17] = scheme, $[18] = shownTheme?.v2?.color, $[19] = t8) : t8 = $[19];
-	let t9;
-	$[20] !== scheme || $[21] !== shownTheme ? (t9 = [scheme, shownTheme], $[20] = scheme, $[21] = shownTheme, $[22] = t9) : t9 = $[22], useEffect(t8, t9);
-	let t10, t11;
-	$[23] === actorRef ? (t10 = $[24], t11 = $[25]) : (t10 = () => () => {
-		for (let url of Object.values(actorRef.getSnapshot().context.images)) URL.revokeObjectURL(url);
-	}, t11 = [actorRef], $[23] = actorRef, $[24] = t10, $[25] = t11), useEffect(t10, t11);
-	let t12;
-	$[26] !== active || $[27] !== baseOptions || $[28] !== images || $[29] !== loading || $[30] !== mobile || $[31] !== navbarHeight || $[32] !== open || $[33] !== removed || $[34] !== send || $[35] !== split || $[36] !== themes || $[37] !== view ? (t12 = {
-		baseOptions,
-		themes,
-		removed,
-		active,
-		images,
-		view,
+function ThemerLayout(t0) {
+	let $ = c(50), { children, backgroundColor, config, dispatch, navbarHeight, open, persistedSnapshot, prerender, prerenderSplitScreen, scheme, split } = t0, t1;
+	$[0] === persistedSnapshot ? t1 = $[1] : (t1 = { snapshot: persistedSnapshot }, $[0] = persistedSnapshot, $[1] = t1);
+	let actorRef = useActorRef(themerMachine, t1), oppositeScheme = scheme === "dark" ? "light" : "dark", t2;
+	$[2] === backgroundColor ? t2 = $[3] : (t2 = { backgroundColor }, $[2] = backgroundColor, $[3] = t2);
+	let t3 = split && open ? "visible" : "hidden", t4;
+	$[4] !== children || $[5] !== config || $[6] !== dispatch || $[7] !== open || $[8] !== oppositeScheme || $[9] !== prerenderSplitScreen || $[10] !== split ? (t4 = prerenderSplitScreen && /* @__PURE__ */ jsx(StudioPreview, {
+		borderRight: !0,
+		config,
+		dispatch,
 		open,
-		loading,
+		scheme: oppositeScheme,
 		split,
-		mobile,
+		children
+	}), $[4] = children, $[5] = config, $[6] = dispatch, $[7] = open, $[8] = oppositeScheme, $[9] = prerenderSplitScreen, $[10] = split, $[11] = t4) : t4 = $[11];
+	let t5;
+	$[12] === t4 ? t5 = $[13] : (t5 = /* @__PURE__ */ jsx(ViewTransition, {
+		default: "none",
+		enter: viewTransitionClasses.splitscreen.enter,
+		update: viewTransitionClasses.splitscreen.update,
+		exit: viewTransitionClasses.splitscreen.exit,
+		children: t4
+	}), $[12] = t4, $[13] = t5);
+	let t6;
+	$[14] !== t3 || $[15] !== t5 ? (t6 = /* @__PURE__ */ jsx(Activity, {
+		mode: t3,
+		children: t5
+	}), $[14] = t3, $[15] = t5, $[16] = t6) : t6 = $[16];
+	let t7;
+	$[17] !== children || $[18] !== config || $[19] !== dispatch || $[20] !== open || $[21] !== split ? (t7 = /* @__PURE__ */ jsx(ViewTransition, {
+		default: "none",
+		update: viewTransitionClasses.studio.update,
+		children: /* @__PURE__ */ jsx(StudioPreview, {
+			config,
+			dispatch,
+			open,
+			shouldDetectNavbarHeight: !0,
+			split,
+			children
+		})
+	}), $[17] = children, $[18] = config, $[19] = dispatch, $[20] = open, $[21] = split, $[22] = t7) : t7 = $[22];
+	let t8 = open ? "visible" : "hidden", t9;
+	$[23] !== actorRef || $[24] !== dispatch || $[25] !== navbarHeight || $[26] !== prerender || $[27] !== split ? (t9 = prerender && /* @__PURE__ */ jsx(ResizableSidebar, {
+		actorRef,
+		dispatch,
 		navbarHeight,
-		send
-	}, $[26] = active, $[27] = baseOptions, $[28] = images, $[29] = loading, $[30] = mobile, $[31] = navbarHeight, $[32] = open, $[33] = removed, $[34] = send, $[35] = split, $[36] = themes, $[37] = view, $[38] = t12) : t12 = $[38];
-	let context = t12, studio = layoutProps.renderDefault(layoutProps), t13;
-	$[39] === send ? t13 = $[40] : (t13 = () => send({ type: "layout.transitioned" }), $[39] = send, $[40] = t13);
-	let transitioned = t13, t14;
-	$[41] !== mobile || $[42] !== moving || $[43] !== reduceMotion || $[44] !== switching ? (t14 = reduceMotion ? {
-		copyEnter: "none",
-		copyExit: "none",
-		panelEnter: "none",
-		panelExit: "none",
-		update: "none",
-		studioUpdate: "none"
-	} : {
-		copyEnter: mobile ? splitTransitionClasses.dropIn : splitTransitionClasses.slideIn,
-		copyExit: mobile ? splitTransitionClasses.dropOut : splitTransitionClasses.slideOut,
-		panelEnter: panelTransitionClasses.slideIn,
-		panelExit: panelTransitionClasses.slideOut,
-		update: switching ? splitTransitionClasses.crossfade : "none",
-		studioUpdate: moving ? splitTransitionClasses.resize : switching ? splitTransitionClasses.crossfade : "none"
-	}, $[41] = mobile, $[42] = moving, $[43] = reduceMotion, $[44] = switching, $[45] = t14) : t14 = $[45];
-	let classes = t14, t15 = mobile ? "column" : "row", t16;
-	$[46] !== classes.copyEnter || $[47] !== classes.copyExit || $[48] !== classes.update || $[49] !== mobile || $[50] !== oppositeScheme || $[51] !== shownOpen || $[52] !== shownSplit || $[53] !== shownTheme || $[54] !== studio || $[55] !== transitioned ? (t16 = shownOpen && /* @__PURE__ */ jsx(Activity, {
-		mode: shownSplit ? "visible" : "hidden",
-		children: /* @__PURE__ */ jsx(ViewTransition, {
-			enter: classes.copyEnter,
-			exit: classes.copyExit,
-			onEnter: transitioned,
-			onExit: transitioned,
-			onUpdate: transitioned,
-			update: classes.update,
-			children: /* @__PURE__ */ jsx(StudioPreview, {
-				borderBottom: mobile,
-				borderRight: !mobile,
-				scheme: oppositeScheme,
-				theme: shownTheme,
-				children: studio
-			})
-		}, "opposite")
-	}), $[46] = classes.copyEnter, $[47] = classes.copyExit, $[48] = classes.update, $[49] = mobile, $[50] = oppositeScheme, $[51] = shownOpen, $[52] = shownSplit, $[53] = shownTheme, $[54] = studio, $[55] = transitioned, $[56] = t16) : t16 = $[56];
-	let t17;
-	$[57] !== shownTheme || $[58] !== studio ? (t17 = /* @__PURE__ */ jsx(StudioPreview, {
-		ref: studioRef,
-		theme: shownTheme,
-		children: studio
-	}), $[57] = shownTheme, $[58] = studio, $[59] = t17) : t17 = $[59];
-	let t18;
-	$[60] !== classes.studioUpdate || $[61] !== t17 || $[62] !== transitioned ? (t18 = /* @__PURE__ */ jsx(ViewTransition, {
-		onUpdate: transitioned,
-		update: classes.studioUpdate,
-		children: t17
-	}, "primary"), $[60] = classes.studioUpdate, $[61] = t17, $[62] = transitioned, $[63] = t18) : t18 = $[63];
-	let t19 = shownOpen ? "visible" : "hidden", t20 = shownTheme ?? void 0, t21;
-	$[64] !== mobile || $[65] !== panelMounted ? (t21 = panelMounted && /* @__PURE__ */ jsx(ResizableSidebar, { overlay: mobile }), $[64] = mobile, $[65] = panelMounted, $[66] = t21) : t21 = $[66];
-	let t22;
-	$[67] !== t20 || $[68] !== t21 ? (t22 = /* @__PURE__ */ jsx(ThemeProvider, {
-		theme: t20,
-		children: t21
-	}), $[67] = t20, $[68] = t21, $[69] = t22) : t22 = $[69];
-	let t23;
-	$[70] !== classes.panelEnter || $[71] !== classes.panelExit || $[72] !== classes.update || $[73] !== t22 || $[74] !== transitioned ? (t23 = /* @__PURE__ */ jsx(ViewTransition, {
-		enter: classes.panelEnter,
-		exit: classes.panelExit,
-		onEnter: transitioned,
-		onExit: transitioned,
-		onUpdate: transitioned,
-		update: classes.update,
-		children: t22
-	}, "panel"), $[70] = classes.panelEnter, $[71] = classes.panelExit, $[72] = classes.update, $[73] = t22, $[74] = transitioned, $[75] = t23) : t23 = $[75];
-	let t24;
-	$[76] !== t19 || $[77] !== t23 ? (t24 = /* @__PURE__ */ jsx(Activity, {
-		mode: t19,
-		children: t23
-	}), $[76] = t19, $[77] = t23, $[78] = t24) : t24 = $[78];
-	let t25;
-	$[79] !== t15 || $[80] !== t16 || $[81] !== t18 || $[82] !== t24 ? (t25 = /* @__PURE__ */ jsxs(Flex, {
+		split
+	}), $[23] = actorRef, $[24] = dispatch, $[25] = navbarHeight, $[26] = prerender, $[27] = split, $[28] = t9) : t9 = $[28];
+	let t10;
+	$[29] === t9 ? t10 = $[30] : (t10 = /* @__PURE__ */ jsx(ViewTransition, {
+		default: "none",
+		enter: viewTransitionClasses.sidebar.enter,
+		update: viewTransitionClasses.sidebar.update,
+		exit: viewTransitionClasses.sidebar.exit,
+		children: t9
+	}), $[29] = t9, $[30] = t10);
+	let t11;
+	$[31] !== t10 || $[32] !== t8 ? (t11 = /* @__PURE__ */ jsx(Activity, {
+		mode: t8,
+		children: t10
+	}), $[31] = t10, $[32] = t8, $[33] = t11) : t11 = $[33];
+	let t12;
+	$[34] !== t11 || $[35] !== t2 || $[36] !== t6 || $[37] !== t7 ? (t12 = /* @__PURE__ */ jsxs(Flex, {
 		className: layout,
-		direction: t15,
+		direction: "row",
 		height: "fill",
 		sizing: "border",
+		style: t2,
 		children: [
-			t16,
-			t18,
-			t24
+			t6,
+			t7,
+			t11
 		]
-	}), $[79] = t15, $[80] = t16, $[81] = t18, $[82] = t24, $[83] = t25) : t25 = $[83];
-	let t26;
-	return $[84] !== context || $[85] !== t25 ? (t26 = /* @__PURE__ */ jsx(ThemerContext.Provider, {
-		value: context,
-		children: t25
-	}), $[84] = context, $[85] = t25, $[86] = t26) : t26 = $[86], t26;
+	}), $[34] = t11, $[35] = t2, $[36] = t6, $[37] = t7, $[38] = t12) : t12 = $[38];
+	let t13;
+	$[39] !== actorRef || $[40] !== dispatch ? (t13 = /* @__PURE__ */ jsx(ThemerThemeCrossfader, {
+		actorRef,
+		dispatch
+	}), $[39] = actorRef, $[40] = dispatch, $[41] = t13) : t13 = $[41];
+	let t14, t15;
+	$[42] === actorRef ? (t14 = $[43], t15 = $[44]) : (t14 = /* @__PURE__ */ jsx(RevokeImageUrls, { actorRef }), t15 = /* @__PURE__ */ jsx(PersistActorSnapshot, { actorRef }), $[42] = actorRef, $[43] = t14, $[44] = t15);
+	let t16;
+	return $[45] !== t12 || $[46] !== t13 || $[47] !== t14 || $[48] !== t15 ? (t16 = /* @__PURE__ */ jsxs(Fragment, { children: [
+		t12,
+		t13,
+		t14,
+		t15
+	] }), $[45] = t12, $[46] = t13, $[47] = t14, $[48] = t15, $[49] = t16) : t16 = $[49], t16;
 }
 /**
 * One copy of the Studio, in the given color scheme — or, without one, in the
 * scheme the Studio is showing. The configured theme (`null`) goes through
@@ -919,296 +937,104 @@
 * and every other provider above the layout — only the scheme differs — so
 * they stay in sync while navigating. The `color-scheme` of a forced scheme
 * keeps native form controls and scrollbars in step with it.
 */
-function StudioPreview(props) {
-	let $ = c(10), { borderBottom, borderRight, children, ref, scheme, theme } = props, t0 = theme ?? void 0, t1 = scheme ? studioScheme[scheme] : void 0, t2;
-	$[0] !== borderBottom || $[1] !== borderRight || $[2] !== children || $[3] !== ref || $[4] !== t1 ? (t2 = /* @__PURE__ */ jsx(Card, {
-		borderBottom,
+function StudioPreview(t0) {
+	let $ = c(17), { borderRight, children, config, dispatch, open, scheme, shouldDetectNavbarHeight, split } = t0, [boundaryElement, setBoundaryElement] = useState(null), t1;
+	$[0] === Symbol.for("react.memo_cache_sentinel") ? (t1 = (boundaryElement_0) => (startTransition(() => setBoundaryElement(boundaryElement_0)), _temp), $[0] = t1) : t1 = $[0];
+	let t2;
+	$[1] === children ? t2 = $[2] : (t2 = /* @__PURE__ */ jsx(ViewTransition, {
+		update: "none",
+		children
+	}), $[1] = children, $[2] = t2);
+	let t3;
+	$[3] !== config || $[4] !== dispatch || $[5] !== open || $[6] !== shouldDetectNavbarHeight || $[7] !== split || $[8] !== t2 ? (t3 = /* @__PURE__ */ jsx(ThemerNavbarProvider, {
+		config,
+		dispatch,
+		open,
+		shouldDetectNavbarHeight,
+		split,
+		children: t2
+	}), $[3] = config, $[4] = dispatch, $[5] = open, $[6] = shouldDetectNavbarHeight, $[7] = split, $[8] = t2, $[9] = t3) : t3 = $[9];
+	let t4;
+	$[10] !== boundaryElement || $[11] !== t3 ? (t4 = /* @__PURE__ */ jsx(BoundaryElementProvider, {
+		element: boundaryElement,
+		children: t3
+	}), $[10] = boundaryElement, $[11] = t3, $[12] = t4) : t4 = $[12];
+	let t5;
+	return $[13] !== borderRight || $[14] !== scheme || $[15] !== t4 ? (t5 = /* @__PURE__ */ jsx(Card, {
 		borderRight,
-		className: t1,
 		flex: 1,
 		height: "fill",
 		overflow: "hidden",
-		ref,
-		children
-	}), $[0] = borderBottom, $[1] = borderRight, $[2] = children, $[3] = ref, $[4] = t1, $[5] = t2) : t2 = $[5];
-	let t3;
-	return $[6] !== scheme || $[7] !== t0 || $[8] !== t2 ? (t3 = /* @__PURE__ */ jsx(ThemeProvider, {
 		scheme,
-		theme: t0,
-		children: t2
-	}), $[6] = scheme, $[7] = t0, $[8] = t2, $[9] = t3) : t3 = $[9], t3;
+		ref: t1,
+		children: t4
+	}), $[13] = borderRight, $[14] = scheme, $[15] = t4, $[16] = t5) : t5 = $[16], t5;
 }
-/**
-* The colors the slices fill with, clockwise from 12 o'clock: the palette's
-* hues in rainbow order, like a real color swatch
-*/
-const SLICE_COLORS = [
-	red[500].hex,
-	orange[500].hex,
-	yellow[400].hex,
-	green[500].hex,
-	cyan[500].hex,
-	blue[500].hex,
-	purple[500].hex,
-	magenta[500].hex
-], TOTAL_ACTS = 2 + (1 - Math.cbrt(1 / 8)), ANIMATION_DURATION = .55 * TOTAL_ACTS;
-function actAt(progress) {
-	return progress * TOTAL_ACTS;
+function _temp() {}
+function ThemerThemeCrossfader(t0) {
+	let $ = c(4), { actorRef, dispatch } = t0, t1, t2;
+	return $[0] !== actorRef || $[1] !== dispatch ? (t1 = () => {
+		let subscription = actorRef.subscribe((snapshot) => {
+			let switching = snapshot.hasTag("switching"), theme = resolveActiveThemeOptions(snapshot.context);
+			startTransition(() => {
+				switching && addThemerTransitionType("crossfade"), dispatch(theme ? {
+					type: "set-theme",
+					theme
+				} : { type: "unset-theme" });
+			});
+		});
+		return () => subscription.unsubscribe();
+	}, t2 = [actorRef, dispatch], $[0] = actorRef, $[1] = dispatch, $[2] = t1, $[3] = t2) : (t1 = $[2], t2 = $[3]), useEffect(t1, t2), null;
 }
-/**
-* How far the wheel has turned at `progress` (0–1), in degrees.
-*
-* It stands still while the top half of the slices pop in. As the 6 o'clock
-* slice pops in it starts spinning — one full turn with a cubic ease-in, from
-* rest to top speed as the bottom half pop in — and it stands still again the
-* moment it is back upright: the clearing of {@link isSliceFilled} takes off
-* at the speed the spin reached, as if the wheel kept turning and eased out
-* over a second turn, so the two read as one movement.
-*/
-function wheelRotation(progress) {
-	let spin = actAt(progress) - 1;
-	return spin <= 0 || spin >= 1 ? 0 : 360 * spin ** 3;
+function RevokeImageUrls(t0) {
+	let $ = c(3), { actorRef } = t0, t1, t2;
+	return $[0] === actorRef ? (t1 = $[1], t2 = $[2]) : (t1 = () => () => {
+		for (let url of Object.values(actorRef.getSnapshot().context.images)) URL.revokeObjectURL(url);
+	}, t2 = [actorRef], $[0] = actorRef, $[1] = t1, $[2] = t2), useEffect(t1, t2), null;
 }
 /**
-* Whether slice `index` (clockwise from 12 o'clock) shows its color at
-* `progress` (0–1). A slice pops in whole as the fill reaches its leading
-* edge, and pops out whole as the clearing does — stepped, never partial.
-*
-* The fill goes clockwise at a steady pace over the first two acts, half of
-* the slices in each. The clearing follows in the third act, the same way
-* round, on a cubic ease-out that starts at the speed the spin of
-* {@link wheelRotation} ends with: the slices at first pop out as quickly as
-* they were passing by, then ever more slowly.
+* Keep this component extremely lightweight, and only include state that are needed by both
+*  `studio.components.layout` and `studio.components.navbar`, where it's not possible to prop-drill into `studio.components.navbar`,
+* and that need to survive open/close of the tool
+* @TODO will mount on the future `studio.components.provider` entrypoint to ensure other plugins using `studio.components.layout` is correctly wrapped in the top-level theme regardless of plugin ordering
 */
-function isSliceFilled(progress, index) {
-	let act = actAt(progress);
-	if (act * 8 / 2 <= index) return !1;
-	let clearing = act - 2;
-	return clearing <= 0 || (1 - (1 - clearing) ** 3) * 8 < index;
+function ThemerProvider(t0) {
+	let $ = c(18), { children, config } = t0;
+	use(browser("The current theme is stored in localStorage."));
+	let [persistedSnapshot] = useState(readPersistedSnapshot), t1;
+	$[0] === persistedSnapshot ? t1 = $[1] : (t1 = (initialState) => {
+		if (!persistedSnapshot) return initialState;
+		let theme = resolveActiveThemeOptions(persistedSnapshot.context);
+		return theme ? {
+			...initialState,
+			theme
+		} : initialState;
+	}, $[0] = persistedSnapshot, $[1] = t1);
+	let [state, dispatch] = useReducer(toolReducer, initialToolState, t1), scheme = useColorSchemeValue(), index = useMediaIndex(), isMobile = index <= 2, isTooSmallForSplitScreen = index <= 3, open = state.open && !isMobile, split = state.split && !isTooSmallForSplitScreen, t2;
+	$[2] === state.theme ? t2 = $[3] : (t2 = (state.theme === null ? null : buildTheme(state.theme)) ?? void 0, $[2] = state.theme, $[3] = t2);
+	let t3 = state.theme?.[scheme]?.background, t4;
+	$[4] !== children || $[5] !== config || $[6] !== open || $[7] !== persistedSnapshot || $[8] !== scheme || $[9] !== split || $[10] !== state.navbarHeight || $[11] !== state.prerender || $[12] !== state.prerenderSplitScreen || $[13] !== t3 ? (t4 = /* @__PURE__ */ jsx(ThemerLayout, {
+		config,
+		backgroundColor: t3,
+		dispatch,
+		navbarHeight: state.navbarHeight,
+		open,
+		persistedSnapshot,
+		prerender: state.prerender,
+		prerenderSplitScreen: state.prerenderSplitScreen,
+		scheme,
+		split,
+		children
+	}), $[4] = children, $[5] = config, $[6] = open, $[7] = persistedSnapshot, $[8] = scheme, $[9] = split, $[10] = state.navbarHeight, $[11] = state.prerender, $[12] = state.prerenderSplitScreen, $[13] = t3, $[14] = t4) : t4 = $[14];
+	let t5;
+	return $[15] !== t2 || $[16] !== t4 ? (t5 = /* @__PURE__ */ jsx(ThemeProvider, {
+		theme: t2,
+		children: t4
+	}), $[15] = t2, $[16] = t4, $[17] = t5) : t5 = $[17], t5;
 }
 /**
-* The geometry of `ColorWheelIcon` from `@sanity/icons`: a ring between two
-* circles around the center of a 25×25 viewBox, cut into eight slices by the
-* spokes of its outline path
-*/
-const CENTER = 12.5, INNER_RADIUS = 3.5;
-/** A point on a circle around the center, at `degrees` clockwise from 3 o'clock */
-function point(radius, degrees) {
-	let radians = degrees * Math.PI / 180;
-	return `${CENTER + radius * Math.cos(radians)} ${CENTER + radius * Math.sin(radians)}`;
-}
-/**
-* The part of the ring a slice colors in: between its two spokes, from the
-* inner circle out to the outer. The 12 o'clock slice is centered on 12, so
-* it starts half a slice before it.
-*/
-function slicePath(index) {
-	let start = index * 45 - 45 / 2 - 90, end = start + 45;
-	return [
-		`M${point(8, start)}`,
-		`A8 8 0 0 1 ${point(8, end)}`,
-		`L${point(INNER_RADIUS, end)}`,
-		`A${INNER_RADIUS} ${INNER_RADIUS} 0 0 0 ${point(INNER_RADIUS, start)}`,
-		"Z"
-	].join("");
-}
-const SLICE_PATHS = SLICE_COLORS.map((_, index) => slicePath(index));
-/**
-* A filled shape, not a stroked one: the icon styles of `@sanity/ui` force
-* `vector-effect: non-scaling-stroke` on every path in an icon, which fills
-* are none the wiser about
-*/
-function Slice(props) {
-	let $ = c(8), { index, progress } = props, t0;
-	$[0] === index ? t0 = $[1] : (t0 = (value) => +!!isSliceFilled(value, index), $[0] = index, $[1] = t0);
-	let opacity = useTransform(progress, t0), t1 = SLICE_PATHS[index], t2 = SLICE_COLORS[index], t3;
-	$[2] === opacity ? t3 = $[3] : (t3 = { opacity }, $[2] = opacity, $[3] = t3);
-	let t4;
-	return $[4] !== t1 || $[5] !== t2 || $[6] !== t3 ? (t4 = /* @__PURE__ */ jsx(motion.path, {
-		d: t1,
-		fill: t2,
-		style: t3
-	}), $[4] = t1, $[5] = t2, $[6] = t3, $[7] = t4) : t4 = $[7], t4;
-}
-/**
-* The `ColorWheelIcon` of `@sanity/icons`, with an animation driven by
-* `progress` (0–1): the slices pop in with color one at a time, clockwise
-* from 12 o'clock like the segments of a classic spinner, the wheel spins once
-* as the 6 o'clock slice pops in, and the colors pop out the same way round,
-* carrying the spin's speed to a rest (see `colorWheel.ts`). At rest — at
-* either end — it looks exactly like the icon it stands in for. With
-* `prefers-reduced-motion: reduce` the wheel keeps still; only the colors come
-* and go.
-*
-* @internal
-*/
-function AnimatedColorWheelIcon(props) {
-	let $ = c(10), { progress } = props, prefersReducedMotion = usePrefersReducedMotion(), t0;
-	$[0] === prefersReducedMotion ? t0 = $[1] : (t0 = (value) => prefersReducedMotion ? 0 : wheelRotation(value), $[0] = prefersReducedMotion, $[1] = t0);
-	let rotate = useTransform(progress, t0), t1;
-	$[2] === rotate ? t1 = $[3] : (t1 = { rotate }, $[2] = rotate, $[3] = t1);
-	let t2;
-	$[4] === progress ? t2 = $[5] : (t2 = SLICE_COLORS.map((color, index) => /* @__PURE__ */ jsx(Slice, {
-		index,
-		progress
-	}, color)), $[4] = progress, $[5] = t2);
-	let t3;
-	$[6] === Symbol.for("react.memo_cache_sentinel") ? (t3 = /* @__PURE__ */ jsx("path", {
-		d: "M9.43853 5.10896L11.1606 9.26642M13.8394 15.7336L15.5615 19.891M15.7336 11.1606L19.891 9.43853M9.26642 13.8394L5.10896 15.5615M5.3139 9.52342L9.23359 11.147M15.7664 13.853L19.6861 15.4766M13.853 9.23359L15.4766 5.3139M9.52342 19.6861L11.147 15.7664M20.5 12.5C20.5 16.9183 16.9183 20.5 12.5 20.5C8.08172 20.5 4.5 16.9183 4.5 12.5C4.5 8.08172 8.08172 4.5 12.5 4.5C16.9183 4.5 20.5 8.08172 20.5 12.5ZM16 12.5C16 14.433 14.433 16 12.5 16C10.567 16 9 14.433 9 12.5C9 10.567 10.567 9 12.5 9C14.433 9 16 10.567 16 12.5Z",
-		stroke: "currentColor",
-		strokeLinejoin: "round",
-		strokeWidth: 1.2
-	}), $[6] = t3) : t3 = $[6];
-	let t4;
-	return $[7] !== t1 || $[8] !== t2 ? (t4 = /* @__PURE__ */ jsxs(motion.svg, {
-		"data-sanity-icon": "color-wheel",
-		fill: "none",
-		height: "1em",
-		style: t1,
-		viewBox: "0 0 25 25",
-		width: "1em",
-		xmlns: "http://www.w3.org/2000/svg",
-		children: [t2, t3]
-	}), $[7] = t1, $[8] = t2, $[9] = t4) : t4 = $[9], t4;
-}
-/**
-* Starts loading the sidebar's code as the toggle is about to be pressed —
-* the same import `React.lazy` makes, which then waits for this one
-*/
-function preloadSidebar() {
-	import("./ResizableSidebar-DD787kQr.js").catch(() => {});
-}
-/**
-* Opens or closes the sidebar — and notes the visit, which is what stops the
-* navbar from introducing the tool
-*/
-function toggleSidebar(send) {
-	markVisited(), send({ type: "sidebar.toggle" });
-}
-/**
-* Whether the navbar has had its one chance per page load to introduce the
-* tool. Module state rather than component state on purpose: the button
-* remounts with the navbar (switching workspaces, crossing the narrow-screen
-* breakpoint) and renders once per Studio copy in the split preview, and none
-* of those should earn another introduction — only loading the page again does.
-*/
-let introduced = !1;
-/**
-* The topbar toggle, with an icon that plays its color wheel animation: on
-* hovering the button, every time; on hovering the navbar it sits in, once —
-* a "hey, look, new tool!" for anyone who has never opened the sidebar, which
-* the visit noted by {@link toggleSidebar} ends for good; and lap after lap
-* while the sidebar's code loads, the way a spinner would.
-*/
-function ThemerNavbarButton() {
-	let $ = c(26), { open, loading, send } = useThemer(), progress = useMotionValue(0), [button, setButton] = useState(null), t0;
-	$[0] === progress ? t0 = $[1] : (t0 = () => {
-		progress.isAnimating() || animate(progress, [0, 1], {
-			duration: ANIMATION_DURATION,
-			ease: "linear"
-		});
-	}, $[0] = progress, $[1] = t0);
-	let play = t0, t1, t2;
-	$[2] !== loading || $[3] !== progress ? (t1 = () => {
-		if (!loading) return;
-		let looping = !0, lap = (from) => {
-			animate(progress, [from, 1], {
-				duration: (1 - from) * ANIMATION_DURATION,
-				ease: "linear",
-				onComplete: () => {
-					looping && lap(0);
-				}
-			});
-		};
-		return lap(progress.get()), () => {
-			looping = !1;
-		};
-	}, t2 = [loading, progress], $[2] = loading, $[3] = progress, $[4] = t1, $[5] = t2) : (t1 = $[4], t2 = $[5]), useEffect(t1, t2);
-	let t3, t4;
-	$[6] === progress ? (t3 = $[7], t4 = $[8]) : (t3 = () => () => progress.stop(), t4 = [progress], $[6] = progress, $[7] = t3, $[8] = t4), useEffect(t3, t4);
-	let t5, t6;
-	$[9] !== button || $[10] !== play ? (t5 = () => {
-		let navbar = button?.closest(NAVBAR_SELECTOR);
-		if (!navbar || introduced) return;
-		let introduce = () => {
-			introduced || (introduced = !0, hasVisited() || play());
-		};
-		return navbar.addEventListener("mouseenter", introduce, { once: !0 }), () => navbar.removeEventListener("mouseenter", introduce);
-	}, t6 = [button, play], $[9] = button, $[10] = play, $[11] = t5, $[12] = t6) : (t5 = $[11], t6 = $[12]), useEffect(t5, t6);
-	let t7;
-	$[13] === Symbol.for("react.memo_cache_sentinel") ? (t7 = /* @__PURE__ */ jsx(Text, {
-		size: 1,
-		children: "Themer"
-	}), $[13] = t7) : t7 = $[13];
-	let t8;
-	$[14] === progress ? t8 = $[15] : (t8 = /* @__PURE__ */ jsx(AnimatedColorWheelIcon, { progress }), $[14] = progress, $[15] = t8);
-	let t9;
-	$[16] === send ? t9 = $[17] : (t9 = () => toggleSidebar(send), $[16] = send, $[17] = t9);
-	let t10;
-	$[18] === play ? t10 = $[19] : (t10 = () => {
-		preloadSidebar(), play();
-	}, $[18] = play, $[19] = t10);
-	let t11;
-	return $[20] !== loading || $[21] !== open || $[22] !== t10 || $[23] !== t8 || $[24] !== t9 ? (t11 = /* @__PURE__ */ jsx(Tooltip, {
-		animate: !0,
-		content: t7,
-		portal: !0,
-		children: /* @__PURE__ */ jsx(Button, {
-			"aria-busy": loading,
-			"aria-label": "Themer",
-			disabled: loading,
-			icon: t8,
-			mode: "bleed",
-			onClick: t9,
-			onFocus: preloadSidebar,
-			onMouseEnter: t10,
-			padding: 2,
-			ref: setButton,
-			selected: open
-		})
-	}), $[20] = loading, $[21] = open, $[22] = t10, $[23] = t8, $[24] = t9, $[25] = t11) : t11 = $[25], t11;
-}
-/**
-* Adds the toggle that opens and closes the themer sidebar to the Studio
-* navbar — an icon button with a tooltip in the top bar (like the Tasks
-* toggle), and a regular titled action in the narrow-screen sidebar menu.
-*
-* In the split preview every Studio copy renders its own navbar, so the
-* toggle shows up in both — they drive the same sidebar.
-*
-* @internal
-*/
-function ThemerNavbar(props) {
-	let $ = c(9), { open, send } = useThemer(), t0;
-	$[0] === props.__internal_actions ? t0 = $[1] : (t0 = props.__internal_actions ?? [], $[0] = props.__internal_actions, $[1] = t0);
-	let t1;
-	if ($[2] !== open || $[3] !== props || $[4] !== send || $[5] !== t0) {
-		let t2;
-		$[7] === send ? t2 = $[8] : (t2 = () => toggleSidebar(send), $[7] = send, $[8] = t2), t1 = props.renderDefault({
-			...props,
-			__internal_actions: [
-				...t0,
-				{
-					location: "topbar",
-					name: "themer-topbar",
-					render: ThemerNavbarButton
-				},
-				{
-					icon: ColorWheelIcon,
-					location: "sidebar",
-					name: "themer-sidebar",
-					onAction: t2,
-					selected: open,
-					title: "Themer"
-				}
-			]
-		}), $[2] = open, $[3] = props, $[4] = send, $[5] = t0, $[6] = t1;
-	} else t1 = $[6];
-	return t1;
-}
-/**
 * A Studio plugin that adds a themer sidebar for `buildTheme` themes: a
 * navbar toggle opens the sidebar next to the Studio, with a list of themes —
 * the configured theme, the presets and your own — each previewed as a tiny
 * Studio in both color schemes. Picking one applies it live to the whole
@@ -1235,23 +1061,28 @@
 *
 * @alpha
 */
 const themerTool = definePlugin((options) => {
-	let baseOptions = options?.config ?? {};
-	function ThemerLayoutWithOptions(props) {
-		let $ = c(2), t0;
-		return $[0] === props ? t0 = $[1] : (t0 = /* @__PURE__ */ jsx(ThemerLayout, {
-			...props,
-			baseOptions
-		}), $[0] = props, $[1] = t0), t0;
-	}
+	let { title = "Themer" } = options ?? {};
 	return {
 		name: "@sanity/themer/tool",
 		studio: { components: {
-			layout: ThemerLayoutWithOptions,
+			layout: defineThemerLayout({ title }),
 			navbar: ThemerNavbar
 		} }
 	};
 });
+function defineThemerLayout(config) {
+	return function DefinedThemerLayoutProvider(props) {
+		"use memo";
+		let $ = c(4), t0;
+		$[0] === props ? t0 = $[1] : (t0 = props.renderDefault(props), $[0] = props, $[1] = t0);
+		let t1;
+		return $[2] === t0 ? t1 = $[3] : (t1 = /* @__PURE__ */ jsx(ThemerProvider, {
+			config,
+			children: t0
+		}), $[2] = t0, $[3] = t1), t1;
+	};
+}
 export { themerTool };
 
 //# sourceMappingURL=tool.js.map
\ No newline at end of file