@sanity/themer 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tool.js CHANGED
@@ -4,8 +4,8 @@ import { c } from "react/compiler-runtime";
4
4
  import { definePlugin, useColorSchemeValue } from "sanity";
5
5
  import { Badge, Box, Button, Card, Dialog, Flex, Layer, Stack, Text, TextInput, ThemeProvider, useMediaIndex, useRootTheme, useTheme_v2 } from "@sanity/ui";
6
6
  import { useActor, useSelector } from "@xstate/react";
7
- import { Activity, ViewTransition, addTransitionType, createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
8
- import { assign, not, setup, stateIn } from "xstate";
7
+ import { Activity, ViewTransition, addTransitionType, createContext, startTransition, useContext, useDeferredValue, useEffect, useRef, useState, useSyncExternalStore } from "react";
8
+ import { assign, not, or, setup, stateIn } from "xstate";
9
9
  import { ArrowLeftIcon } from "@sanity/icons/ArrowLeft";
10
10
  import { CloseIcon } from "@sanity/icons/Close";
11
11
  import { CodeBlockIcon } from "@sanity/icons/CodeBlock";
@@ -19,6 +19,7 @@ import { Tooltip } from "@sanity/ui/tooltip";
19
19
  import { ImageIcon } from "@sanity/icons/Image";
20
20
  import { useToast } from "@sanity/ui/toast";
21
21
  import { AddIcon } from "@sanity/icons/Add";
22
+ import { ClipboardIcon } from "@sanity/icons/Clipboard";
22
23
  import { RestoreIcon } from "@sanity/icons/Restore";
23
24
  import { MotionConfig, Reorder, useDragControls } from "motion/react";
24
25
  import { ArrowDownIcon } from "@sanity/icons/ArrowDown";
@@ -26,9 +27,9 @@ import { ArrowUpIcon } from "@sanity/icons/ArrowUp";
26
27
  import { CopyIcon } from "@sanity/icons/Copy";
27
28
  import { EditIcon } from "@sanity/icons/Edit";
28
29
  import { EllipsisHorizontalIcon } from "@sanity/icons/EllipsisHorizontal";
30
+ import { ShareIcon } from "@sanity/icons/Share";
29
31
  import { Menu, MenuButton, MenuDivider, MenuItem } from "@sanity/ui/menu";
30
32
  import scrollIntoView from "scroll-into-view-if-needed";
31
- import { ClipboardIcon } from "@sanity/icons/Clipboard";
32
33
  import { Code } from "@sanity/ui/code";
33
34
  import { registerLanguage } from "react-refractor";
34
35
  import typescript from "refractor/typescript";
@@ -41,951 +42,1300 @@ function useThemer() {
41
42
  if (!context) throw Error("useThemer must be used within the `themerTool` plugin");
42
43
  return context;
43
44
  }
45
+ /** @internal */
46
+ const IMAGE_PALETTE_KEYS = [
47
+ "vibrant",
48
+ "lightVibrant",
49
+ "darkVibrant",
50
+ "muted",
51
+ "lightMuted",
52
+ "darkMuted",
53
+ "dominant"
54
+ ], IMAGE_PALETTE_VARIANTS = [
55
+ "muted",
56
+ "vibrant",
57
+ "lightMuted",
58
+ "lightVibrant",
59
+ "darkMuted",
60
+ "darkVibrant"
61
+ ], IMAGE_PALETTE_TITLES = {
62
+ dominant: "Dominant",
63
+ vibrant: "Vibrant",
64
+ lightVibrant: "Light vibrant",
65
+ darkVibrant: "Dark vibrant",
66
+ muted: "Muted",
67
+ lightMuted: "Light muted",
68
+ darkMuted: "Dark muted"
69
+ }, TARGETS = [
70
+ ["vibrant", {
71
+ lightness: [
72
+ .5,
73
+ .3,
74
+ .7
75
+ ],
76
+ saturation: [
77
+ 1,
78
+ .35,
79
+ 1
80
+ ]
81
+ }],
82
+ ["lightVibrant", {
83
+ lightness: [
84
+ .74,
85
+ .55,
86
+ 1
87
+ ],
88
+ saturation: [
89
+ 1,
90
+ .35,
91
+ 1
92
+ ]
93
+ }],
94
+ ["darkVibrant", {
95
+ lightness: [
96
+ .26,
97
+ 0,
98
+ .45
99
+ ],
100
+ saturation: [
101
+ 1,
102
+ .35,
103
+ 1
104
+ ]
105
+ }],
106
+ ["muted", {
107
+ lightness: [
108
+ .5,
109
+ .3,
110
+ .7
111
+ ],
112
+ saturation: [
113
+ .3,
114
+ 0,
115
+ .4
116
+ ]
117
+ }],
118
+ ["lightMuted", {
119
+ lightness: [
120
+ .74,
121
+ .55,
122
+ 1
123
+ ],
124
+ saturation: [
125
+ .3,
126
+ 0,
127
+ .4
128
+ ]
129
+ }],
130
+ ["darkMuted", {
131
+ lightness: [
132
+ .26,
133
+ 0,
134
+ .45
135
+ ],
136
+ saturation: [
137
+ .3,
138
+ 0,
139
+ .4
140
+ ]
141
+ }]
142
+ ];
44
143
  /**
45
- * Reduces theme options to the minimal object that recreates them through
46
- * `buildTheme`: in each scheme the accent is dropped when it is the stock
47
- * one, the text color when it matches the one derived from the accent, and
48
- * the background and contrast when they match the defaults — and a scheme
49
- * with nothing left is dropped altogether. Options that boil down to the
50
- * stock Studio theme reduce to `null` — they need nothing from this package.
144
+ * Extracts the color palette of an image from its pixels, the way the Vibrant
145
+ * algorithm does: the colors are quantized into a coarse histogram, and each
146
+ * swatch claims the color that comes closest to its target lightness and
147
+ * saturation — weighted by how much of the image it covers — that no other
148
+ * swatch has claimed yet. The dominant swatch is simply the most common
149
+ * color. Runs anywhere pixels come from, so it can run on device.
51
150
  *
52
151
  * @internal
53
152
  */
54
- function minimizeOptions(options) {
55
- let resolved = resolveThemeOptions(options), minimized = {};
56
- for (let scheme of SCHEMES) {
57
- let values = resolved[scheme], kept = {};
58
- values.accent !== DEFAULT_ACCENT && (kept.accent = values.accent), values.text !== deriveTextColor(values.accent) && (kept.text = values.text), values.background !== DEFAULT_BACKGROUND[scheme] && (kept.background = values.background), values.contrast !== 85 && (kept.contrast = values.contrast), Object.keys(kept).length > 0 && (minimized[scheme] = kept);
153
+ function extractImagePalette(pixels) {
154
+ let candidates = quantize(pixels), palette = {
155
+ dominant: null,
156
+ vibrant: null,
157
+ lightVibrant: null,
158
+ darkVibrant: null,
159
+ muted: null,
160
+ lightMuted: null,
161
+ darkMuted: null
162
+ };
163
+ if (candidates.length === 0) return palette;
164
+ let maximumPopulation = Math.max(...candidates.map((candidate) => candidate.population)), claimed = /* @__PURE__ */ new Set();
165
+ for (let [key, target] of TARGETS) {
166
+ let best = null, bestScore = -Infinity;
167
+ for (let candidate of candidates) {
168
+ if (claimed.has(candidate.hex) || !matchesTarget(candidate, target)) continue;
169
+ let score = scoreCandidate(candidate, target, maximumPopulation);
170
+ score > bestScore && (best = candidate, bestScore = score);
171
+ }
172
+ best && (palette[key] = best.hex, claimed.add(best.hex));
59
173
  }
60
- return Object.keys(minimized).length === 0 ? null : minimized;
174
+ return palette.dominant = candidates.reduce((most, candidate) => candidate.population > most.population ? candidate : most).hex, palette;
61
175
  }
62
176
  /**
63
- * Whether two sets of theme options generate the same theme, comparing their
64
- * resolved forms so that derived and explicit values (and hex casing) don't
65
- * matter.
66
- *
67
- * @internal
177
+ * Turns the pixels into a histogram of quantized colors, each averaged over
178
+ * the pixels it stands for, leaving out transparent pixels and the near-black
179
+ * and near-white ones that are backdrop rather than color.
68
180
  */
69
- function sameOptions(a, b) {
70
- let left = resolveThemeOptions(a), right = resolveThemeOptions(b);
71
- return SCHEMES.every((scheme) => sameScheme(left[scheme], right[scheme]));
181
+ function quantize(pixels) {
182
+ let { data } = pixels, buckets = /* @__PURE__ */ new Map(), length = Math.min(data.length, pixels.width * pixels.height * 4);
183
+ for (let offset = 0; offset + 3 < length; offset += 4) {
184
+ let r = data[offset], g = data[offset + 1], b = data[offset + 2];
185
+ if (data[offset + 3] < 125) continue;
186
+ let lightness = (Math.max(r, g, b) + Math.min(r, g, b)) / 510;
187
+ if (lightness < .05 || lightness > .95) continue;
188
+ let key = r >> 3 << 10 | g >> 3 << 5 | b >> 3, bucket = buckets.get(key);
189
+ bucket ? (bucket.count++, bucket.r += r, bucket.g += g, bucket.b += b) : buckets.set(key, {
190
+ count: 1,
191
+ r,
192
+ g,
193
+ b
194
+ });
195
+ }
196
+ let candidates = [];
197
+ for (let bucket of buckets.values()) {
198
+ let r = Math.round(bucket.r / bucket.count), g = Math.round(bucket.g / bucket.count), b = Math.round(bucket.b / bucket.count), { saturation, lightness } = saturationAndLightness(r, g, b);
199
+ candidates.push({
200
+ hex: rgbToHex(r, g, b),
201
+ population: bucket.count,
202
+ saturation,
203
+ lightness
204
+ });
205
+ }
206
+ return candidates;
72
207
  }
73
- function sameScheme(a, b) {
74
- return a.accent === b.accent && a.text === b.text && a.background === b.background && a.contrast === b.contrast;
208
+ function matchesTarget(candidate, target) {
209
+ return candidate.lightness >= target.lightness[1] && candidate.lightness <= target.lightness[2] && candidate.saturation >= target.saturation[1] && candidate.saturation <= target.saturation[2];
75
210
  }
76
- /** The slug of the theme the Studio config was generated from @internal */
77
- const initialThemerState = {
78
- active: null,
79
- custom: [],
80
- removed: [],
81
- order: []
82
- };
83
- /**
84
- * Resolves the list of themes: the configured theme first, then the presets
85
- * (minus the ones that would only repeat the configured theme), then the
86
- * user's own themes — rearranged into the order the user dragged them into,
87
- * with themes that order does not know about kept in that default order after
88
- * the ones it does. Removed themes are set aside so they can be restored, and
89
- * the active theme falls back to the configured one when its slug no longer
90
- * resolves.
91
- *
92
- * @internal
93
- */
94
- function resolveThemes(state, baseOptions) {
95
- let config = {
96
- slug: "config",
97
- title: "Studio config",
98
- options: baseOptions,
99
- source: "config"
100
- }, all = [config];
101
- for (let preset of presets) sameOptions(preset.options, baseOptions) || all.push({
102
- slug: preset.slug,
103
- title: preset.title,
104
- options: preset.options,
105
- source: "preset"
106
- });
107
- for (let theme of state.custom) all.push({
108
- slug: theme.slug,
109
- title: theme.title,
110
- options: theme.options,
111
- source: "custom",
112
- ...theme.palette ? { palette: theme.palette } : {}
113
- });
114
- let position = new Map(state.order.map((slug, index) => [slug, index])), rank = (theme) => position.get(theme.slug) ?? state.order.length;
115
- all.sort((a, b) => rank(a) - rank(b));
116
- let removedSlugs = new Set(state.removed), themes = all.filter((theme) => !removedSlugs.has(theme.slug));
117
- return {
118
- themes,
119
- removed: all.filter((theme) => removedSlugs.has(theme.slug)),
120
- active: themes.find((theme) => theme.slug === state.active) ?? config
121
- };
211
+ function scoreCandidate(candidate, target, maximumPopulation) {
212
+ return 3 * (1 - Math.abs(candidate.saturation - target.saturation[0])) + 6.5 * (1 - Math.abs(candidate.lightness - target.lightness[0])) + .5 * (candidate.population / maximumPopulation);
122
213
  }
123
- /**
124
- * Creates a custom theme with a slug that cannot collide with the presets or
125
- * the configured theme.
126
- *
127
- * @internal
128
- */
129
- function createCustomTheme(title, options, palette) {
214
+ function saturationAndLightness(r, g, b) {
215
+ let max = Math.max(r, g, b) / 255, min = Math.min(r, g, b) / 255, lightness = (max + min) / 2, delta = max - min;
130
216
  return {
131
- slug: `custom-${randomId()}`,
132
- title,
133
- options,
134
- ...palette ? { palette } : {}
217
+ saturation: delta === 0 ? 0 : lightness > .5 ? delta / (2 - max - min) : delta / (max + min),
218
+ lightness
135
219
  };
136
220
  }
137
- /** The title of a duplicated theme @internal */
138
- function duplicateTitle(title) {
139
- return `${displayTitle(title)} copy`;
140
- }
141
- /** The title to show for a theme, falling back when the user cleared it @internal */
142
- function displayTitle(title) {
143
- return title.trim() || "Untitled theme";
221
+ function rgbToHex(r, g, b) {
222
+ return `#${[
223
+ r,
224
+ g,
225
+ b
226
+ ].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
144
227
  }
145
- function randomId() {
146
- return typeof crypto < "u" && typeof crypto.randomUUID == "function" ? crypto.randomUUID() : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
228
+ /** Reads `#rgb` and `#rrggbb` alike — stored palettes may hold either */
229
+ function hexToRgb(hex) {
230
+ let expanded = hex.length === 4 ? `${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex.slice(1);
231
+ return [
232
+ parseInt(expanded.slice(0, 2), 16),
233
+ parseInt(expanded.slice(2, 4), 16),
234
+ parseInt(expanded.slice(4, 6), 16)
235
+ ];
147
236
  }
148
- function themesOf(context) {
149
- return resolveThemes(context, context.baseOptions);
237
+ /** Mixes `amount` of `color` into `base` */
238
+ function tint(base, color, amount) {
239
+ let [r1, g1, b1] = hexToRgb(base), [r2, g2, b2] = hexToRgb(color);
240
+ return rgbToHex(Math.round(r1 + (r2 - r1) * amount), Math.round(g1 + (g2 - g1) * amount), Math.round(b1 + (b2 - b1) * amount));
241
+ }
242
+ /** How much of the palette tints each scheme's stock background */
243
+ const BACKGROUND_TINT = {
244
+ light: .08,
245
+ dark: .3
246
+ }, BUTTON_LABEL = {
247
+ light: "#ffffff",
248
+ dark: "#000000"
249
+ }, ACCENT_LIGHTNESS_STEP = .01;
250
+ function contrastRatio(a, b) {
251
+ let [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
252
+ return (lighter + .05) / (darker + .05);
150
253
  }
151
254
  /**
152
- * The state of the themer tool: three parallel regions, whether the
153
- * `sidebar` is open, which `flow` it is in — picking a theme from the `list`,
154
- * `edit`ing one of the user's own themes, or restoring `removed` ones — and
155
- * whether the `preview` shows the Studio once (`single`) or in light and dark
156
- * side by side (`split`). The context carries the persisted state (the
157
- * applied theme, the user's themes and what was removed) alongside what the
158
- * flows need.
159
- *
160
- * Theme operations are handled in every flow, and the flows leave on their
161
- * own when they lose their subject: the editor when its theme is removed or
162
- * deleted, and the removed view when its last theme is restored or deleted.
255
+ * Makes an accent readable as a button in the given scheme: a light scheme
256
+ * accent is darkened until white text on it reaches AA contrast, a dark
257
+ * scheme accent lightened until black text does — as far as the accent's
258
+ * hue and saturation allow. Accents that already pass come back untouched.
163
259
  *
164
260
  * @internal
165
261
  */
166
- const themerMachine = setup({
167
- types: {
168
- context: {},
169
- events: {},
170
- input: {}
171
- },
172
- guards: {
173
- isCustomTheme: ({ context }, params) => themesOf(context).themes.some((theme) => theme.slug === params.slug && theme.source === "custom"),
174
- isEditingListedTheme: ({ context }) => context.editing !== null && themesOf(context).themes.some((theme) => theme.slug === context.editing?.slug && theme.source === "custom"),
175
- hasRemovedThemes: ({ context }) => themesOf(context).removed.length > 0
176
- },
177
- actions: {
178
- pick: assign((_, params) => ({ active: params.slug === "config" ? null : params.slug })),
179
- add: assign(({ context }, params) => {
180
- let theme = createCustomTheme(params.title ?? "Untitled theme", params.options ?? themesOf(context).active.options, params.palette);
181
- return {
182
- active: theme.slug,
183
- custom: [...context.custom, theme],
184
- editing: {
185
- slug: theme.slug,
186
- focusTitle: params.title === void 0
187
- },
188
- images: params.imageUrl ? {
189
- ...context.images,
190
- [theme.slug]: params.imageUrl
191
- } : context.images
192
- };
193
- }),
194
- duplicate: assign(({ context }, params) => {
195
- let { themes, removed } = themesOf(context), source = [...themes, ...removed].find((theme) => theme.slug === params.slug);
196
- if (!source) return {};
197
- let theme = createCustomTheme(duplicateTitle(source.title), source.options, source.palette);
198
- return {
199
- active: theme.slug,
200
- custom: [...context.custom, theme],
201
- editing: {
202
- slug: theme.slug,
203
- focusTitle: !0
204
- }
205
- };
206
- }),
207
- startEditing: assign((_, params) => ({
208
- active: params.slug,
209
- editing: {
210
- slug: params.slug,
211
- focusTitle: !1
212
- }
213
- })),
214
- stopEditing: assign({ editing: null }),
215
- update: assign(({ context }, params) => ({
216
- custom: context.custom.map((theme) => theme.slug === params.slug ? {
217
- ...theme,
218
- title: params.title ?? theme.title,
219
- options: params.options ?? theme.options,
220
- ...params.palette ? { palette: params.palette } : {}
221
- } : theme),
222
- images: params.imageUrl && context.custom.some((theme) => theme.slug === params.slug) ? {
223
- ...context.images,
224
- [params.slug]: params.imageUrl
225
- } : context.images
226
- })),
227
- remove: assign(({ context }, params) => params.slug === "config" || context.removed.includes(params.slug) ? {} : {
228
- active: context.active === params.slug ? null : context.active,
229
- removed: [...context.removed, params.slug]
230
- }),
231
- restore: assign(({ context }, params) => ({ removed: context.removed.filter((slug) => slug !== params.slug) })),
232
- reorder: assign(({ context }, params) => {
233
- let listed = new Set(themesOf(context).themes.map((theme) => theme.slug)), order = params.order.filter((slug) => listed.has(slug));
234
- return { order: [...order, ...context.order.filter((slug) => !order.includes(slug))] };
235
- }),
236
- delete: assign(({ context }, params) => {
237
- if (!context.custom.some((theme) => theme.slug === params.slug)) return {};
238
- let { [params.slug]: _deleted, ...images } = context.images;
239
- return {
240
- active: context.active === params.slug ? null : context.active,
241
- custom: context.custom.filter((theme) => theme.slug !== params.slug),
242
- removed: context.removed.filter((slug) => slug !== params.slug),
243
- images
244
- };
245
- })
246
- }
247
- }).createMachine({
248
- id: "themer",
249
- context: ({ input }) => ({
250
- ...input.stored,
251
- baseOptions: input.baseOptions,
252
- editing: null,
253
- images: {}
254
- }),
255
- type: "parallel",
256
- states: {
257
- sidebar: {
258
- initial: "closed",
259
- states: {
260
- closed: { on: { "sidebar.toggle": "open" } },
261
- open: { on: {
262
- "sidebar.toggle": "closed",
263
- "sidebar.close": "closed"
264
- } }
265
- }
266
- },
267
- preview: {
268
- initial: "single",
269
- states: {
270
- single: { on: { "preview.toggle": "split" } },
271
- split: { on: {
272
- "preview.toggle": "single",
273
- "sidebar.close": "single",
274
- "sidebar.toggle": {
275
- guard: stateIn({ sidebar: "open" }),
276
- target: "single"
277
- }
278
- } }
279
- }
280
- },
281
- flow: {
282
- initial: "list",
283
- on: {
284
- "theme.pick": { actions: {
285
- type: "pick",
286
- params: ({ event }) => ({ slug: event.slug })
287
- } },
288
- "theme.add": {
289
- target: ".edit",
290
- actions: {
291
- type: "add",
292
- params: ({ event }) => ({
293
- title: event.title,
294
- options: event.options,
295
- palette: event.palette,
296
- imageUrl: event.imageUrl
297
- })
298
- }
299
- },
300
- "theme.duplicate": {
301
- target: ".edit",
302
- actions: {
303
- type: "duplicate",
304
- params: ({ event }) => ({ slug: event.slug })
305
- }
306
- },
307
- "theme.edit": {
308
- target: ".edit",
309
- guard: {
310
- type: "isCustomTheme",
311
- params: ({ event }) => ({ slug: event.slug })
312
- },
313
- actions: {
314
- type: "startEditing",
315
- params: ({ event }) => ({ slug: event.slug })
316
- }
317
- },
318
- "theme.update": { actions: {
319
- type: "update",
320
- params: ({ event }) => ({
321
- slug: event.slug,
322
- title: event.title,
323
- options: event.options,
324
- palette: event.palette,
325
- imageUrl: event.imageUrl
326
- })
327
- } },
328
- "theme.remove": { actions: {
329
- type: "remove",
330
- params: ({ event }) => ({ slug: event.slug })
331
- } },
332
- "theme.restore": { actions: {
333
- type: "restore",
334
- params: ({ event }) => ({ slug: event.slug })
335
- } },
336
- "theme.delete": { actions: {
337
- type: "delete",
338
- params: ({ event }) => ({ slug: event.slug })
339
- } },
340
- "theme.reorder": { actions: {
341
- type: "reorder",
342
- params: ({ event }) => ({ order: event.order })
343
- } },
344
- "flow.list": ".list",
345
- "flow.removed": {
346
- target: ".removed",
347
- guard: "hasRemovedThemes"
348
- }
349
- },
350
- states: {
351
- list: {},
352
- edit: {
353
- exit: "stopEditing",
354
- always: {
355
- guard: not("isEditingListedTheme"),
356
- target: "list"
357
- }
358
- },
359
- removed: { always: {
360
- guard: not("hasRemovedThemes"),
361
- target: "list"
362
- } }
363
- }
364
- }
262
+ function readableAccent(hex, scheme) {
263
+ let label = BUTTON_LABEL[scheme], { h, s } = hexToHsl(hex), { l } = hexToHsl(hex), current = hex;
264
+ for (; contrastRatio(current, label) < 4.5;) {
265
+ let next = scheme === "light" ? l - ACCENT_LIGHTNESS_STEP : l + ACCENT_LIGHTNESS_STEP;
266
+ if (next < 0 || next > 1) break;
267
+ l = next, current = hslToHex({
268
+ h,
269
+ s,
270
+ l
271
+ });
365
272
  }
366
- });
367
- /** The part of the machine context that is persisted between sessions @internal */
368
- function selectStoredState(snapshot) {
369
- let { active, custom, removed, order } = snapshot.context;
370
- return {
371
- active,
372
- custom,
373
- removed,
374
- order
375
- };
273
+ return current;
274
+ }
275
+ /** The accent a variant gives a scheme, made readable on a button */
276
+ function variantAccent(palette, variant, scheme) {
277
+ let swatch = (variant ? palette[variant] : null) ?? palette.vibrant ?? (scheme === "light" ? palette.darkVibrant : palette.lightVibrant) ?? palette.dominant;
278
+ return swatch === null ? null : readableAccent(swatch, scheme);
376
279
  }
377
- var root$2 = "_1o50s7t0";
378
- const ScrollAreaInsetContext = createContext({
379
- left: 0,
380
- right: 0,
381
- bottom: 0
382
- });
383
- /**
384
- * The gutter the last scroll area measured. It is the same for every scroll
385
- * area (the reserved gutter does not depend on the content), so the next one
386
- * starts out with the right padding instead of shifting after its first
387
- * measurement — which would also throw off anything scrolling into view as
388
- * the content mounts.
389
- */
390
- let lastGutter = 0;
391
280
  /**
392
- * The scrolling body of a sidebar flow. It always reserves the scrollbar's
393
- * space, so the content does not shift when the list grows or shrinks past
394
- * the point where it needs to scroll — and it takes that space out of its
395
- * right padding, so the content stays centered rather than hugging the left.
396
- * With overlay scrollbars there is no gutter and the padding stays even.
281
+ * Derives theme options from an image palette: the accent of both schemes is
282
+ * the swatch of the chosen variant — or, without one, the vibrant swatch,
283
+ * each scheme falling back to the vibrant swatch of its own end of the scale —
284
+ * adjusted until it is readable as a button. The muted swatch becomes the text color, and the light and dark muted
285
+ * swatches tint the stock backgrounds of their scheme — enough to carry the
286
+ * image's tone without giving up a usable canvas. Whatever the palette lacks
287
+ * stays at the theme defaults.
397
288
  *
398
289
  * @internal
399
290
  */
400
- function ScrollArea(props) {
401
- let $ = c(15), { children, padding } = props, { space } = useTheme_v2(), ref = useRef(null), [gutter, setGutter] = useState(_temp$7), t0, t1;
402
- $[0] === Symbol.for("react.memo_cache_sentinel") ? (t0 = () => {
403
- let element = ref.current;
404
- if (!element) return;
405
- let measure = () => {
406
- lastGutter = element.offsetWidth - element.clientWidth, setGutter(lastGutter);
407
- };
408
- measure();
409
- let observer = new ResizeObserver(measure);
410
- return observer.observe(element), () => observer.disconnect();
411
- }, t1 = [], $[0] = t0, $[1] = t1) : (t0 = $[0], t1 = $[1]), useEffect(t0, t1);
412
- let t2 = space[padding], t3 = Math.max(0, space[padding] - gutter), t4 = space[padding], t5;
413
- $[2] !== t2 || $[3] !== t3 || $[4] !== t4 ? (t5 = {
414
- left: t2,
415
- right: t3,
416
- bottom: t4
417
- }, $[2] = t2, $[3] = t3, $[4] = t4, $[5] = t5) : t5 = $[5];
418
- let inset = t5, t6;
419
- $[6] === inset.right ? t6 = $[7] : (t6 = { paddingRight: inset.right }, $[6] = inset.right, $[7] = t6);
420
- let t7;
421
- $[8] !== children || $[9] !== inset ? (t7 = /* @__PURE__ */ jsx(ScrollAreaInsetContext.Provider, {
422
- value: inset,
423
- children
424
- }), $[8] = children, $[9] = inset, $[10] = t7) : t7 = $[10];
425
- let t8;
426
- return $[11] !== padding || $[12] !== t6 || $[13] !== t7 ? (t8 = /* @__PURE__ */ jsx(Box, {
427
- className: root$2,
428
- flex: 1,
429
- overflow: "auto",
430
- padding,
431
- ref,
432
- style: t6,
433
- children: t7
434
- }), $[11] = padding, $[12] = t6, $[13] = t7, $[14] = t8) : t8 = $[14], t8;
291
+ function optionsFromImagePalette(palette, variant) {
292
+ let options = {}, light = schemeFromPalette(variantAccent(palette, variant ?? null, "light"), palette.muted ?? palette.darkMuted, palette.lightMuted ?? palette.muted ?? palette.lightVibrant, "light"), dark = schemeFromPalette(variantAccent(palette, variant ?? null, "dark"), palette.muted ?? palette.lightMuted, palette.darkMuted ?? palette.muted ?? palette.darkVibrant, "dark");
293
+ return light && (options.light = light), dark && (options.dark = dark), options;
435
294
  }
436
- function _temp$7() {
437
- return lastGutter;
295
+ function schemeFromPalette(accent, text, backgroundTint, scheme) {
296
+ let options = {};
297
+ return accent && (options.accent = accent), text && (options.text = text), backgroundTint && (options.background = tint(DEFAULT_BACKGROUND[scheme], backgroundTint, BACKGROUND_TINT[scheme])), Object.keys(options).length > 0 ? options : null;
438
298
  }
439
299
  /**
440
- * Content that runs edge to edge in a scroll area — up to the scrollbar —
441
- * instead of sitting inside its padding. It goes last: it runs down to the
442
- * bottom edge too.
300
+ * Applies an image palette to existing theme options: the colors the palette
301
+ * provides replace the current ones scheme by scheme, and whatever it does
302
+ * not touch — like the contrast — stays as it is.
443
303
  *
444
304
  * @internal
445
305
  */
446
- function ScrollAreaBleed(props) {
447
- let $ = c(7), { left, right, bottom } = useContext(ScrollAreaInsetContext), t0 = -left, t1 = -right, t2 = -bottom, t3;
448
- $[0] !== t0 || $[1] !== t1 || $[2] !== t2 ? (t3 = {
449
- marginLeft: t0,
450
- marginRight: t1,
451
- marginBottom: t2
452
- }, $[0] = t0, $[1] = t1, $[2] = t2, $[3] = t3) : t3 = $[3];
453
- let t4;
454
- return $[4] !== props.children || $[5] !== t3 ? (t4 = /* @__PURE__ */ jsx("div", {
455
- style: t3,
456
- children: props.children
457
- }), $[4] = props.children, $[5] = t3, $[6] = t4) : t4 = $[6], t4;
306
+ function applyImagePalette(options, palette, variant) {
307
+ let derived = optionsFromImagePalette(palette, variant), next = { ...options };
308
+ for (let scheme of SCHEMES) derived[scheme] && (next[scheme] = {
309
+ ...options[scheme],
310
+ ...derived[scheme]
311
+ });
312
+ return next;
458
313
  }
459
- var avatar = "_1aa9huuh", body = "_1aa9huui", button = "_1aa9huuq", colors = {
460
- bg: "var(--_1aa9huu0)",
461
- fg: "var(--_1aa9huu1)",
462
- mutedFg: "var(--_1aa9huu2)",
463
- border: "var(--_1aa9huu3)",
464
- accent: "var(--_1aa9huu4)",
465
- avatar: "var(--_1aa9huu5)",
466
- selectedBg: "var(--_1aa9huu6)",
467
- selectedFg: "var(--_1aa9huu7)",
468
- inputBg: "var(--_1aa9huu8)",
469
- inputBorder: "var(--_1aa9huu9)"
470
- }, field = "_1aa9huun", footer = "_1aa9huup", form = "_1aa9huum", input = "_1aa9huuo", line = {
471
- fg: "_1aa9huus _1aa9huur",
472
- muted: "_1aa9huut _1aa9huur",
473
- selected: "_1aa9huuu _1aa9huur"
474
- }, list = "_1aa9huuj", listRow = "_1aa9huuk", logo = "_1aa9huuf", navbar = "_1aa9huue", pane = "_1aa9huul", root$1 = "_1aa9huua", spacer = "_1aa9huug", studio = {
475
- light: "_1aa9huub",
476
- dark: "_1aa9huud _1aa9huub"
477
- };
478
- /** The document rows in the list pane — the widths of their title lines */
479
- const LIST_ROWS = [
480
- "62%",
481
- "46%",
482
- "70%",
483
- "52%"
484
- ], FIELDS = ["32%", "24%"];
485
314
  /**
486
- * A tiny, lo-fi mockup of a Studio in the given theme — the navbar over the
487
- * structure tool with a list of documents on the left and a document form on
488
- * the right — in both color schemes at once: the light scheme on the left and
489
- * the dark scheme on the right, split diagonally. It only paints colors the
490
- * theme options change (backgrounds, text, borders, the accent) so that a
491
- * glance shows what the theme does to the Studio — with few and bold enough
492
- * elements to still read when scaled down to a thumbnail.
315
+ * The variant a theme is built around: the one whose swatch is the theme's
316
+ * accent, or `null` when the accent comes from somewhere else.
493
317
  *
494
318
  * @internal
495
319
  */
496
- function ThemePreview(props) {
497
- let $ = c(11), { options } = props, t0;
498
- $[0] === options ? t0 = $[1] : (t0 = buildTheme(options), $[0] = options, $[1] = t0);
499
- let theme = t0, t1;
500
- $[2] === Symbol.for("react.memo_cache_sentinel") ? (t1 = /* @__PURE__ */ jsx(MockStudio, { scheme: "light" }), $[2] = t1) : t1 = $[2];
501
- let t2;
502
- $[3] === theme ? t2 = $[4] : (t2 = /* @__PURE__ */ jsx(ThemeProvider, {
503
- scheme: "light",
504
- theme,
505
- tone: "default",
506
- children: t1
507
- }), $[3] = theme, $[4] = t2);
508
- let t3;
509
- $[5] === Symbol.for("react.memo_cache_sentinel") ? (t3 = /* @__PURE__ */ jsx(MockStudio, { scheme: "dark" }), $[5] = t3) : t3 = $[5];
510
- let t4;
511
- $[6] === theme ? t4 = $[7] : (t4 = /* @__PURE__ */ jsx(ThemeProvider, {
512
- scheme: "dark",
513
- theme,
514
- tone: "default",
515
- children: t3
516
- }), $[6] = theme, $[7] = t4);
517
- let t5;
518
- return $[8] !== t2 || $[9] !== t4 ? (t5 = /* @__PURE__ */ jsxs("div", {
519
- "aria-hidden": !0,
520
- className: root$1,
521
- children: [t2, t4]
522
- }), $[8] = t2, $[9] = t4, $[10] = t5) : t5 = $[10], t5;
523
- }
524
- function MockStudio(props) {
525
- let $ = c(19), { scheme } = props, { color } = useTheme_v2(), selected = color.selectable.default.selected, inputColor = color.input.default.enabled, t0 = studio[scheme], t1;
526
- $[0] !== color.avatar.blue.bg || $[1] !== color.bg || $[2] !== color.border || $[3] !== color.button.default.primary.enabled.bg || $[4] !== color.fg || $[5] !== color.muted.fg || $[6] !== inputColor.bg || $[7] !== inputColor.border || $[8] !== selected.bg || $[9] !== selected.fg ? (t1 = assignInlineVars({
527
- [colors.bg]: color.bg,
528
- [colors.fg]: color.fg,
529
- [colors.mutedFg]: color.muted.fg,
530
- [colors.border]: color.border,
531
- [colors.accent]: color.button.default.primary.enabled.bg,
532
- [colors.avatar]: color.avatar.blue.bg,
533
- [colors.selectedBg]: selected.bg,
534
- [colors.selectedFg]: selected.fg,
535
- [colors.inputBg]: inputColor.bg,
536
- [colors.inputBorder]: inputColor.border
537
- }), $[0] = color.avatar.blue.bg, $[1] = color.bg, $[2] = color.border, $[3] = color.button.default.primary.enabled.bg, $[4] = color.fg, $[5] = color.muted.fg, $[6] = inputColor.bg, $[7] = inputColor.border, $[8] = selected.bg, $[9] = selected.fg, $[10] = t1) : t1 = $[10];
538
- let t2;
539
- $[11] === Symbol.for("react.memo_cache_sentinel") ? (t2 = /* @__PURE__ */ jsx("div", { className: logo }), $[11] = t2) : t2 = $[11];
540
- let t3;
541
- $[12] === Symbol.for("react.memo_cache_sentinel") ? (t3 = /* @__PURE__ */ jsxs("div", {
542
- className: navbar,
543
- children: [
544
- t2,
545
- /* @__PURE__ */ jsx("div", {
546
- className: line.fg,
547
- style: { width: "22%" }
548
- }),
549
- /* @__PURE__ */ jsx("div", { className: spacer }),
550
- /* @__PURE__ */ jsx("div", { className: avatar })
551
- ]
552
- }), $[12] = t3) : t3 = $[12];
553
- let t4;
554
- $[13] === Symbol.for("react.memo_cache_sentinel") ? (t4 = /* @__PURE__ */ jsx("div", {
555
- className: list,
556
- children: LIST_ROWS.map(_temp$6)
557
- }), $[13] = t4) : t4 = $[13];
558
- let t5;
559
- $[14] === Symbol.for("react.memo_cache_sentinel") ? (t5 = /* @__PURE__ */ jsxs("div", {
560
- className: form,
561
- children: [/* @__PURE__ */ jsx("div", {
562
- className: line.fg,
563
- style: { width: "45%" }
564
- }), FIELDS.map(_temp2)]
565
- }), $[14] = t5) : t5 = $[14];
566
- let t6;
567
- $[15] === Symbol.for("react.memo_cache_sentinel") ? (t6 = /* @__PURE__ */ jsxs("div", {
568
- className: body,
569
- children: [t4, /* @__PURE__ */ jsxs("div", {
570
- className: pane,
571
- children: [t5, /* @__PURE__ */ jsx("div", {
572
- className: footer,
573
- children: /* @__PURE__ */ jsx("div", { className: button })
574
- })]
575
- })]
576
- }), $[15] = t6) : t6 = $[15];
577
- let t7;
578
- return $[16] !== t0 || $[17] !== t1 ? (t7 = /* @__PURE__ */ jsxs("div", {
579
- className: t0,
580
- style: t1,
581
- children: [t3, t6]
582
- }), $[16] = t0, $[17] = t1, $[18] = t7) : t7 = $[18], t7;
583
- }
584
- function _temp2(width_0) {
585
- return /* @__PURE__ */ jsxs("div", {
586
- className: field,
587
- children: [/* @__PURE__ */ jsx("div", {
588
- className: line.muted,
589
- style: { width: width_0 }
590
- }), /* @__PURE__ */ jsx("div", { className: input })]
591
- }, width_0);
592
- }
593
- function _temp$6(width, index) {
594
- let isSelected = index === 1;
595
- return /* @__PURE__ */ jsx("div", {
596
- className: listRow,
597
- "data-selected": isSelected,
598
- children: /* @__PURE__ */ jsx("div", {
599
- className: isSelected ? line.selected : line.muted,
600
- style: { width }
601
- })
602
- }, width);
320
+ function currentImageVariant(options, palette) {
321
+ let accent = options.light?.accent;
322
+ return accent ? IMAGE_PALETTE_VARIANTS.find((variant) => palette[variant] !== null && variantAccent(palette, variant, "light") === accent) ?? null : null;
603
323
  }
604
324
  /**
605
- * The floating preview card of a theme: the lo-fi studio mockup on a raised,
606
- * rounded card, like the appearance previews in macOS System Settings.
607
- *
608
- * @internal
325
+ * How much a swatch has going for it as an accent: saturation first, then
326
+ * closeness to mid lightness — the range where an accent reads best.
609
327
  */
610
- function ThemeThumbnail(props) {
611
- let $ = c(2), t0;
612
- return $[0] === props.options ? t0 = $[1] : (t0 = /* @__PURE__ */ jsx(Card, {
613
- overflow: "hidden",
614
- radius: 3,
615
- shadow: 1,
616
- children: /* @__PURE__ */ jsx(ThemePreview, { options: props.options })
617
- }), $[0] = props.options, $[1] = t0), t0;
328
+ function interest(hex) {
329
+ let { s, l } = hexToHsl(hex);
330
+ return .1 + .55 * s + .35 * (1 - Math.abs(l - .5) * 2);
618
331
  }
619
- var removedThumbnail = "_1vdkp0v0";
620
332
  /**
621
- * The flow for restoring removed themes: the presets and custom themes the
622
- * user took out of the list, each with a button to put it back — and, for
623
- * custom themes, one to delete it for good. The machine leaves the flow once
624
- * the last one is gone.
333
+ * Picks a variant for the "I'm feeling lucky" button: a random one, weighted
334
+ * by how interesting its swatch is as an accent, and never the one the theme
335
+ * already uses while there are others to pick from.
625
336
  *
626
337
  * @internal
627
338
  */
628
- function RemovedThemes() {
629
- let $ = c(7), { removed, send } = useThemer(), t0;
630
- if ($[0] !== removed || $[1] !== send) {
631
- let t1;
632
- $[3] === send ? t1 = $[4] : (t1 = (theme) => /* @__PURE__ */ jsxs(Stack, {
633
- gap: 3,
634
- children: [
635
- /* @__PURE__ */ jsx(Box, {
636
- className: removedThumbnail,
637
- children: /* @__PURE__ */ jsx(ThemeThumbnail, { options: theme.options })
638
- }),
639
- /* @__PURE__ */ jsx(Text, {
640
- align: "center",
641
- size: 1,
642
- textOverflow: "ellipsis",
643
- children: displayTitle(theme.title)
644
- }),
645
- /* @__PURE__ */ jsxs(Flex, {
646
- gap: 2,
647
- justify: "center",
648
- children: [/* @__PURE__ */ jsx(Button, {
649
- fontSize: 1,
650
- mode: "ghost",
651
- onClick: () => send({
652
- type: "theme.restore",
653
- slug: theme.slug
654
- }),
655
- padding: 2,
656
- text: "Restore"
657
- }), theme.source === "custom" && /* @__PURE__ */ jsx(Button, {
658
- fontSize: 1,
659
- mode: "ghost",
660
- onClick: () => send({
661
- type: "theme.delete",
662
- slug: theme.slug
663
- }),
664
- padding: 2,
665
- text: "Delete",
666
- tone: "critical"
667
- })]
668
- })
669
- ]
670
- }, theme.slug), $[3] = send, $[4] = t1), t0 = removed.map(t1), $[0] = removed, $[1] = send, $[2] = t0;
671
- } else t0 = $[2];
672
- let t1;
673
- return $[5] === t0 ? t1 = $[6] : (t1 = /* @__PURE__ */ jsx(ScrollArea, {
674
- padding: 3,
675
- children: /* @__PURE__ */ jsx(Stack, {
676
- gap: 5,
677
- children: t0
678
- })
679
- }), $[5] = t0, $[6] = t1), t1;
339
+ function pickLuckyVariant(palette, options = {}) {
340
+ let { exclude = null, random = Math.random } = options, candidates = IMAGE_PALETTE_VARIANTS.filter((variant) => palette[variant] !== null);
341
+ if (candidates.length > 1 && (candidates = candidates.filter((variant) => variant !== exclude)), candidates.length === 0) return null;
342
+ let weights = candidates.map((variant) => interest(palette[variant])), total = weights.reduce((sum, weight) => sum + weight, 0), pick = random() * total;
343
+ for (let [index, variant] of candidates.entries()) if (pick -= weights[index], pick < 0) return variant;
344
+ return candidates.at(-1) ?? null;
345
+ }
346
+ /** A theme title for the image a palette came from, e.g. `sunset-beach.jpg` → `sunset beach` @internal */
347
+ function titleFromFileName(name) {
348
+ return name.replace(/\.[^.]+$/, "").replace(/[-_]+/g, " ").trim() || "Image theme";
680
349
  }
681
- /** @internal */
682
- const IMAGE_PALETTE_KEYS = [
683
- "vibrant",
684
- "lightVibrant",
685
- "darkVibrant",
686
- "muted",
687
- "lightMuted",
688
- "darkMuted",
689
- "dominant"
690
- ], IMAGE_PALETTE_VARIANTS = [
691
- "muted",
692
- "vibrant",
693
- "lightMuted",
694
- "lightVibrant",
695
- "darkMuted",
696
- "darkVibrant"
697
- ], IMAGE_PALETTE_TITLES = {
698
- dominant: "Dominant",
699
- vibrant: "Vibrant",
700
- lightVibrant: "Light vibrant",
701
- darkVibrant: "Dark vibrant",
702
- muted: "Muted",
703
- lightMuted: "Light muted",
704
- darkMuted: "Dark muted"
705
- }, TARGETS = [
706
- ["vibrant", {
707
- lightness: [
708
- .5,
709
- .3,
710
- .7
711
- ],
712
- saturation: [
713
- 1,
714
- .35,
715
- 1
716
- ]
717
- }],
718
- ["lightVibrant", {
719
- lightness: [
720
- .74,
721
- .55,
722
- 1
723
- ],
724
- saturation: [
725
- 1,
726
- .35,
727
- 1
728
- ]
729
- }],
730
- ["darkVibrant", {
731
- lightness: [
732
- .26,
733
- 0,
734
- .45
735
- ],
736
- saturation: [
737
- 1,
738
- .35,
739
- 1
740
- ]
741
- }],
742
- ["muted", {
743
- lightness: [
744
- .5,
745
- .3,
746
- .7
747
- ],
748
- saturation: [
749
- .3,
750
- 0,
751
- .4
752
- ]
753
- }],
754
- ["lightMuted", {
755
- lightness: [
756
- .74,
757
- .55,
758
- 1
759
- ],
760
- saturation: [
761
- .3,
762
- 0,
763
- .4
764
- ]
765
- }],
766
- ["darkMuted", {
767
- lightness: [
768
- .26,
769
- 0,
770
- .45
771
- ],
772
- saturation: [
773
- .3,
774
- 0,
775
- .4
776
- ]
777
- }]
778
- ];
779
350
  /**
780
- * Extracts the color palette of an image from its pixels, the way the Vibrant
781
- * algorithm does: the colors are quantized into a coarse histogram, and each
782
- * swatch claims the color that comes closest to its target lightness and
783
- * saturation — weighted by how much of the image it covers — that no other
784
- * swatch has claimed yet. The dominant swatch is simply the most common
785
- * color. Runs anywhere pixels come from, so it can run on device.
351
+ * Reduces theme options to the minimal object that recreates them through
352
+ * `buildTheme`: in each scheme the accent is dropped when it is the stock
353
+ * one, the text color when it matches the one derived from the accent, and
354
+ * the background and contrast when they match the defaults — and a scheme
355
+ * with nothing left is dropped altogether. Options that boil down to the
356
+ * stock Studio theme reduce to `null` — they need nothing from this package.
786
357
  *
787
358
  * @internal
788
359
  */
789
- function extractImagePalette(pixels) {
790
- let candidates = quantize(pixels), palette = {
791
- dominant: null,
792
- vibrant: null,
793
- lightVibrant: null,
794
- darkVibrant: null,
795
- muted: null,
796
- lightMuted: null,
797
- darkMuted: null
798
- };
799
- if (candidates.length === 0) return palette;
800
- let maximumPopulation = Math.max(...candidates.map((candidate) => candidate.population)), claimed = /* @__PURE__ */ new Set();
801
- for (let [key, target] of TARGETS) {
802
- let best = null, bestScore = -Infinity;
803
- for (let candidate of candidates) {
804
- if (claimed.has(candidate.hex) || !matchesTarget(candidate, target)) continue;
805
- let score = scoreCandidate(candidate, target, maximumPopulation);
806
- score > bestScore && (best = candidate, bestScore = score);
807
- }
808
- best && (palette[key] = best.hex, claimed.add(best.hex));
360
+ function minimizeOptions(options) {
361
+ let resolved = resolveThemeOptions(options), minimized = {};
362
+ for (let scheme of SCHEMES) {
363
+ let values = resolved[scheme], kept = {};
364
+ values.accent !== DEFAULT_ACCENT && (kept.accent = values.accent), values.text !== deriveTextColor(values.accent) && (kept.text = values.text), values.background !== DEFAULT_BACKGROUND[scheme] && (kept.background = values.background), values.contrast !== 85 && (kept.contrast = values.contrast), Object.keys(kept).length > 0 && (minimized[scheme] = kept);
809
365
  }
810
- return palette.dominant = candidates.reduce((most, candidate) => candidate.population > most.population ? candidate : most).hex, palette;
366
+ return Object.keys(minimized).length === 0 ? null : minimized;
811
367
  }
812
368
  /**
813
- * Turns the pixels into a histogram of quantized colors, each averaged over
814
- * the pixels it stands for, leaving out transparent pixels and the near-black
815
- * and near-white ones that are backdrop rather than color.
369
+ * Whether two sets of theme options generate the same theme, comparing their
370
+ * resolved forms so that derived and explicit values (and hex casing) don't
371
+ * matter.
372
+ *
373
+ * @internal
816
374
  */
817
- function quantize(pixels) {
818
- let { data } = pixels, buckets = /* @__PURE__ */ new Map(), length = Math.min(data.length, pixels.width * pixels.height * 4);
819
- for (let offset = 0; offset + 3 < length; offset += 4) {
820
- let r = data[offset], g = data[offset + 1], b = data[offset + 2];
821
- if (data[offset + 3] < 125) continue;
822
- let lightness = (Math.max(r, g, b) + Math.min(r, g, b)) / 510;
823
- if (lightness < .05 || lightness > .95) continue;
824
- let key = r >> 3 << 10 | g >> 3 << 5 | b >> 3, bucket = buckets.get(key);
825
- bucket ? (bucket.count++, bucket.r += r, bucket.g += g, bucket.b += b) : buckets.set(key, {
826
- count: 1,
827
- r,
828
- g,
829
- b
830
- });
831
- }
832
- let candidates = [];
833
- for (let bucket of buckets.values()) {
834
- let r = Math.round(bucket.r / bucket.count), g = Math.round(bucket.g / bucket.count), b = Math.round(bucket.b / bucket.count), { saturation, lightness } = saturationAndLightness(r, g, b);
835
- candidates.push({
836
- hex: rgbToHex(r, g, b),
837
- population: bucket.count,
838
- saturation,
839
- lightness
840
- });
841
- }
842
- return candidates;
843
- }
844
- function matchesTarget(candidate, target) {
845
- return candidate.lightness >= target.lightness[1] && candidate.lightness <= target.lightness[2] && candidate.saturation >= target.saturation[1] && candidate.saturation <= target.saturation[2];
375
+ function sameOptions(a, b) {
376
+ let left = resolveThemeOptions(a), right = resolveThemeOptions(b);
377
+ return SCHEMES.every((scheme) => sameScheme(left[scheme], right[scheme]));
846
378
  }
847
- function scoreCandidate(candidate, target, maximumPopulation) {
848
- return 3 * (1 - Math.abs(candidate.saturation - target.saturation[0])) + 6.5 * (1 - Math.abs(candidate.lightness - target.lightness[0])) + .5 * (candidate.population / maximumPopulation);
379
+ function sameScheme(a, b) {
380
+ return a.accent === b.accent && a.text === b.text && a.background === b.background && a.contrast === b.contrast;
849
381
  }
850
- function saturationAndLightness(r, g, b) {
851
- let max = Math.max(r, g, b) / 255, min = Math.min(r, g, b) / 255, lightness = (max + min) / 2, delta = max - min;
382
+ /** The slug of the theme the Studio config was generated from @internal */
383
+ const initialThemerState = {
384
+ active: null,
385
+ custom: [],
386
+ removed: [],
387
+ order: []
388
+ };
389
+ /**
390
+ * Resolves the list of themes: the configured theme first, then the presets
391
+ * (minus the ones that would only repeat the configured theme), then the
392
+ * user's own themes — rearranged into the order the user dragged them into,
393
+ * with themes that order does not know about kept in that default order after
394
+ * the ones it does. Removed themes are set aside so they can be restored, and
395
+ * the active theme falls back to the configured one when its slug no longer
396
+ * resolves.
397
+ *
398
+ * @internal
399
+ */
400
+ function resolveThemes(state, baseOptions) {
401
+ let config = {
402
+ slug: "config",
403
+ title: "Studio config",
404
+ options: baseOptions,
405
+ source: "config"
406
+ }, all = [config];
407
+ for (let preset of presets) sameOptions(preset.options, baseOptions) || all.push({
408
+ slug: preset.slug,
409
+ title: preset.title,
410
+ options: preset.options,
411
+ source: "preset"
412
+ });
413
+ for (let theme of state.custom) all.push({
414
+ slug: theme.slug,
415
+ title: theme.title,
416
+ options: theme.options,
417
+ source: "custom",
418
+ ...theme.palette ? { palette: theme.palette } : {}
419
+ });
420
+ let position = new Map(state.order.map((slug, index) => [slug, index])), rank = (theme) => position.get(theme.slug) ?? state.order.length;
421
+ all.sort((a, b) => rank(a) - rank(b));
422
+ let removedSlugs = new Set(state.removed), themes = all.filter((theme) => !removedSlugs.has(theme.slug));
423
+ return {
424
+ themes,
425
+ removed: all.filter((theme) => removedSlugs.has(theme.slug)),
426
+ active: themes.find((theme) => theme.slug === state.active) ?? config
427
+ };
428
+ }
429
+ /**
430
+ * Creates a custom theme with a slug that cannot collide with the presets or
431
+ * the configured theme.
432
+ *
433
+ * @internal
434
+ */
435
+ function createCustomTheme(title, options, palette) {
436
+ return {
437
+ slug: `custom-${randomId()}`,
438
+ title,
439
+ options,
440
+ ...palette ? { palette } : {}
441
+ };
442
+ }
443
+ /** The title of a duplicated theme @internal */
444
+ function duplicateTitle(title) {
445
+ return `${displayTitle(title)} copy`;
446
+ }
447
+ /** The title to show for a theme, falling back when the user cleared it @internal */
448
+ function displayTitle(title) {
449
+ return title.trim() || "Untitled theme";
450
+ }
451
+ function randomId() {
452
+ return typeof crypto < "u" && typeof crypto.randomUUID == "function" ? crypto.randomUUID() : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
453
+ }
454
+ const STORAGE_KEY = "sanityStudio:themer:state", LEGACY_STORAGE_KEY = "sanityStudio:themer:options";
455
+ function sanitizeColor(value) {
456
+ return typeof value == "string" && isColor(value) ? value.toLowerCase() : null;
457
+ }
458
+ function sanitizeContrast(value) {
459
+ return typeof value == "number" && Number.isFinite(value) ? Math.min(100, Math.max(15, value)) : null;
460
+ }
461
+ function sanitizeSchemeOptions(value) {
462
+ let options = {};
463
+ if (!value || typeof value != "object") return options;
464
+ 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"));
465
+ return accent && (options.accent = accent), text && (options.text = text), background && (options.background = background), contrast !== null && (options.contrast = contrast), options;
466
+ }
467
+ /**
468
+ * Options from before they were grouped by scheme: one accent, text color and
469
+ * contrast for both schemes, and a background per scheme. Both schemes get
470
+ * the shared colors, so the theme keeps looking the same.
471
+ */
472
+ function sanitizeFlatOptions(value) {
473
+ let accent = sanitizeColor(Reflect.get(value, "accent"));
474
+ if (!accent) return null;
475
+ let shared = { accent }, text = sanitizeColor(Reflect.get(value, "text")), contrast = sanitizeContrast(Reflect.get(value, "contrast"));
476
+ text && (shared.text = text), contrast !== null && (shared.contrast = contrast);
477
+ let backgrounds = Reflect.get(value, "background"), options = {};
478
+ for (let scheme of SCHEMES) {
479
+ let background = backgrounds && typeof backgrounds == "object" ? sanitizeColor(Reflect.get(backgrounds, scheme)) : null;
480
+ options[scheme] = background ? {
481
+ ...shared,
482
+ background
483
+ } : { ...shared };
484
+ }
485
+ return options;
486
+ }
487
+ function sanitizeOptions(value) {
488
+ if (!value || typeof value != "object") return null;
489
+ if ("accent" in value) return sanitizeFlatOptions(value);
490
+ let options = {};
491
+ for (let scheme of SCHEMES) {
492
+ let schemeOptions = sanitizeSchemeOptions(Reflect.get(value, scheme));
493
+ Object.keys(schemeOptions).length > 0 && (options[scheme] = schemeOptions);
494
+ }
495
+ return options;
496
+ }
497
+ function isReservedSlug(slug) {
498
+ return slug === "config" || presets.some((preset) => preset.slug === slug);
499
+ }
500
+ function sanitizeCustomTheme(value) {
501
+ if (!value || typeof value != "object") return null;
502
+ let slug = Reflect.get(value, "slug");
503
+ if (typeof slug != "string" || !slug || isReservedSlug(slug)) return null;
504
+ let options = sanitizeOptions(Reflect.get(value, "options"));
505
+ if (!options) return null;
506
+ let title = Reflect.get(value, "title"), palette = sanitizePalette(Reflect.get(value, "palette"));
507
+ return {
508
+ slug,
509
+ title: displayTitle(typeof title == "string" ? title : ""),
510
+ options,
511
+ ...palette ? { palette } : {}
512
+ };
513
+ }
514
+ function sanitizePalette(value) {
515
+ if (!value || typeof value != "object") return null;
516
+ let palette = {}, swatches = 0;
517
+ for (let key of IMAGE_PALETTE_KEYS) {
518
+ let swatch = sanitizeColor(Reflect.get(value, key));
519
+ palette[key] = swatch, swatch && swatches++;
520
+ }
521
+ return swatches > 0 ? palette : null;
522
+ }
523
+ function sanitizeState(value) {
524
+ if (!value || typeof value != "object") return initialThemerState;
525
+ let custom = [], rawCustom = Reflect.get(value, "custom");
526
+ if (Array.isArray(rawCustom)) for (let item of rawCustom) {
527
+ let theme = sanitizeCustomTheme(item);
528
+ theme && !custom.some((existing) => existing.slug === theme.slug) && custom.push(theme);
529
+ }
530
+ let removed = [], rawRemoved = Reflect.get(value, "removed");
531
+ 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);
532
+ let order = [], rawOrder = Reflect.get(value, "order");
533
+ 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);
534
+ let rawActive = Reflect.get(value, "active");
535
+ return {
536
+ active: typeof rawActive == "string" ? rawActive : null,
537
+ custom,
538
+ removed,
539
+ order
540
+ };
541
+ }
542
+ function readLegacyState() {
543
+ let raw = localStorage.getItem(LEGACY_STORAGE_KEY);
544
+ if (!raw) return null;
545
+ let options = sanitizeOptions(JSON.parse(raw));
546
+ if (!options) return null;
547
+ let theme = createCustomTheme("Draft theme", options);
548
+ return {
549
+ active: theme.slug,
550
+ custom: [theme],
551
+ removed: [],
552
+ order: []
553
+ };
554
+ }
555
+ /**
556
+ * Restores the themer state from localStorage, so the user's themes survive
557
+ * studio reloads. Falls back to the initial state when nothing usable is
558
+ * stored.
559
+ *
560
+ * @internal
561
+ */
562
+ function readStoredState() {
563
+ try {
564
+ if (typeof localStorage > "u") return initialThemerState;
565
+ let raw = localStorage.getItem(STORAGE_KEY);
566
+ return raw ? sanitizeState(JSON.parse(raw)) : readLegacyState() ?? initialThemerState;
567
+ } catch {
568
+ return initialThemerState;
569
+ }
570
+ }
571
+ /** @internal */
572
+ function writeStoredState(state) {
573
+ try {
574
+ if (typeof localStorage > "u") return;
575
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state)), localStorage.removeItem(LEGACY_STORAGE_KEY);
576
+ } catch {}
577
+ }
578
+ /**
579
+ * How long the layout's motions — the panel's and the split preview's, and
580
+ * the cross-fade to another theme — are taken to last when the layout never
581
+ * says: it reports every view transition it starts, so this only ends a
582
+ * motion that has none (reduced motion, a browser without view transitions),
583
+ * where the `moving` and `switching` tags have nothing to affect. Generous,
584
+ * so that it never runs out before a slow deferred commit starts the real one.
585
+ *
586
+ * @internal
587
+ */
588
+ const MOTION_DURATION = 2e3;
589
+ function themesOf(context) {
590
+ return resolveThemes(context, context.baseOptions);
591
+ }
592
+ function revokeObjectUrl(url) {
593
+ typeof URL < "u" && typeof URL.revokeObjectURL == "function" && URL.revokeObjectURL(url);
594
+ }
595
+ /**
596
+ * The state of the themer tool: three parallel regions, whether the
597
+ * `sidebar` is open, which `flow` it is in — picking a theme from the `list`,
598
+ * `edit`ing one of the user's own themes, or restoring `removed` ones — and
599
+ * whether the `preview` shows the Studio once (`single`) or in light and dark
600
+ * side by side (`split`). The context carries the persisted state (the
601
+ * applied theme, the user's themes and what was removed) alongside what the
602
+ * flows need.
603
+ *
604
+ * The sidebar and the preview pass through `opening`/`closing` and
605
+ * `splitting`/`unsplitting` on their way, states tagged for the UI: `panel`
606
+ * and `split` say what shows (the panel from `opening` on, the split copy from
607
+ * `splitting` on), `moving` that the Studio is about to give way or take room
608
+ * — which is when, and only when, the layout lets a view transition animate
609
+ * it. A fourth region, `theme`, is `switching` (its tag) right after an event
610
+ * that applies another theme — picking one, adding, duplicating, importing or
611
+ * editing one, removing or deleting the applied one — when the layout
612
+ * cross-fades to it; edits to the applied theme's colors are not switches,
613
+ * they follow the pointer. These states last until the layout reports its
614
+ * transition under way — every boundary reports, the panel's and the copy's
615
+ * entering and leaving as much as the Studio's morphing — or for
616
+ * `MOTION_DURATION` when nothing animates.
617
+ *
618
+ * Theme operations are handled in every flow, and the flows leave on their
619
+ * own when they lose their subject: the editor when its theme is removed or
620
+ * deleted, and the removed view when its last theme is restored or deleted.
621
+ *
622
+ * @internal
623
+ */
624
+ const themerMachine = setup({
625
+ types: {
626
+ context: {},
627
+ events: {},
628
+ input: {}
629
+ },
630
+ guards: {
631
+ isCustomTheme: ({ context }, params) => themesOf(context).themes.some((theme) => theme.slug === params.slug && theme.source === "custom"),
632
+ isEditingListedTheme: ({ context }) => context.editing !== null && themesOf(context).themes.some((theme) => theme.slug === context.editing?.slug && theme.source === "custom"),
633
+ hasRemovedThemes: ({ context }) => themesOf(context).removed.length > 0,
634
+ sidebarShown: or([stateIn({ sidebar: "opening" }), stateIn({ sidebar: "open" })]),
635
+ isAnotherTheme: ({ context }, params) => params.slug !== (context.active ?? "config"),
636
+ isAppliedTheme: ({ context }, params) => params.slug === context.active,
637
+ hasOptions: (_, params) => params.options !== void 0
638
+ },
639
+ actions: {
640
+ persist: ({ context }) => {
641
+ let { active, custom, removed, order } = context;
642
+ writeStoredState({
643
+ active,
644
+ custom,
645
+ removed,
646
+ order
647
+ });
648
+ },
649
+ revokeImage: (_, params) => {
650
+ params.url && revokeObjectUrl(params.url);
651
+ },
652
+ pick: assign((_, params) => ({ active: params.slug === "config" ? null : params.slug })),
653
+ add: assign(({ context }, params) => {
654
+ let theme = createCustomTheme(params.title ?? "Untitled theme", params.options ?? themesOf(context).active.options, params.palette);
655
+ return {
656
+ active: theme.slug,
657
+ custom: [...context.custom, theme],
658
+ editing: {
659
+ slug: theme.slug,
660
+ focusTitle: params.title === void 0
661
+ },
662
+ images: params.imageUrl ? {
663
+ ...context.images,
664
+ [theme.slug]: params.imageUrl
665
+ } : context.images
666
+ };
667
+ }),
668
+ import: assign(({ context }, params) => {
669
+ let theme = createCustomTheme(params.title, params.options);
670
+ return {
671
+ active: theme.slug,
672
+ custom: [...context.custom, theme]
673
+ };
674
+ }),
675
+ duplicate: assign(({ context }, params) => {
676
+ let { themes, removed } = themesOf(context), source = [...themes, ...removed].find((theme) => theme.slug === params.slug);
677
+ if (!source) return {};
678
+ let theme = createCustomTheme(duplicateTitle(source.title), source.options, source.palette);
679
+ return {
680
+ active: theme.slug,
681
+ custom: [...context.custom, theme],
682
+ editing: {
683
+ slug: theme.slug,
684
+ focusTitle: !0
685
+ }
686
+ };
687
+ }),
688
+ startEditing: assign((_, params) => ({
689
+ active: params.slug,
690
+ editing: {
691
+ slug: params.slug,
692
+ focusTitle: !1
693
+ }
694
+ })),
695
+ stopEditing: assign({ editing: null }),
696
+ update: assign(({ context }, params) => ({
697
+ custom: context.custom.map((theme) => theme.slug === params.slug ? {
698
+ ...theme,
699
+ title: params.title ?? theme.title,
700
+ options: params.options ?? theme.options,
701
+ ...params.palette ? { palette: params.palette } : {}
702
+ } : theme),
703
+ images: params.imageUrl && context.custom.some((theme) => theme.slug === params.slug) ? {
704
+ ...context.images,
705
+ [params.slug]: params.imageUrl
706
+ } : context.images
707
+ })),
708
+ remove: assign(({ context }, params) => params.slug === "config" || context.removed.includes(params.slug) ? {} : {
709
+ active: context.active === params.slug ? null : context.active,
710
+ removed: [...context.removed, params.slug]
711
+ }),
712
+ restore: assign(({ context }, params) => ({ removed: context.removed.filter((slug) => slug !== params.slug) })),
713
+ reorder: assign(({ context }, params) => {
714
+ let listed = new Set(themesOf(context).themes.map((theme) => theme.slug)), order = params.order.filter((slug) => listed.has(slug));
715
+ return { order: [...order, ...context.order.filter((slug) => !order.includes(slug))] };
716
+ }),
717
+ delete: assign(({ context }, params) => {
718
+ if (!context.custom.some((theme) => theme.slug === params.slug)) return {};
719
+ let { [params.slug]: _deleted, ...images } = context.images;
720
+ return {
721
+ active: context.active === params.slug ? null : context.active,
722
+ custom: context.custom.filter((theme) => theme.slug !== params.slug),
723
+ removed: context.removed.filter((slug) => slug !== params.slug),
724
+ images
725
+ };
726
+ })
727
+ }
728
+ }).createMachine({
729
+ id: "themer",
730
+ context: ({ input }) => ({
731
+ ...input.stored,
732
+ baseOptions: input.baseOptions,
733
+ editing: null,
734
+ images: {}
735
+ }),
736
+ entry: "persist",
737
+ type: "parallel",
738
+ states: {
739
+ sidebar: {
740
+ initial: "closed",
741
+ states: {
742
+ closed: { on: { "sidebar.toggle": "opening" } },
743
+ opening: {
744
+ tags: ["panel", "moving"],
745
+ after: { [MOTION_DURATION]: "open" },
746
+ on: {
747
+ "layout.transitioned": "open",
748
+ "sidebar.toggle": "closing",
749
+ "sidebar.close": "closing"
750
+ }
751
+ },
752
+ open: {
753
+ tags: ["panel"],
754
+ on: {
755
+ "sidebar.toggle": "closing",
756
+ "sidebar.close": "closing"
757
+ }
758
+ },
759
+ closing: {
760
+ tags: ["moving"],
761
+ after: { [MOTION_DURATION]: "closed" },
762
+ on: {
763
+ "layout.transitioned": "closed",
764
+ "sidebar.toggle": "opening"
765
+ }
766
+ }
767
+ }
768
+ },
769
+ preview: {
770
+ initial: "single",
771
+ states: {
772
+ single: { on: { "preview.toggle": "splitting" } },
773
+ splitting: {
774
+ tags: ["split", "moving"],
775
+ after: { [MOTION_DURATION]: "split" },
776
+ on: {
777
+ "layout.transitioned": "split",
778
+ "preview.toggle": "unsplitting",
779
+ "sidebar.close": "unsplitting",
780
+ "sidebar.toggle": {
781
+ guard: "sidebarShown",
782
+ target: "unsplitting"
783
+ }
784
+ }
785
+ },
786
+ split: {
787
+ tags: ["split"],
788
+ on: {
789
+ "preview.toggle": "unsplitting",
790
+ "sidebar.close": "unsplitting",
791
+ "sidebar.toggle": {
792
+ guard: "sidebarShown",
793
+ target: "unsplitting"
794
+ }
795
+ }
796
+ },
797
+ unsplitting: {
798
+ tags: ["moving"],
799
+ after: { [MOTION_DURATION]: "single" },
800
+ on: {
801
+ "layout.transitioned": "single",
802
+ "preview.toggle": "splitting"
803
+ }
804
+ }
805
+ }
806
+ },
807
+ theme: {
808
+ initial: "applied",
809
+ states: {
810
+ applied: { on: {
811
+ "theme.pick": {
812
+ guard: {
813
+ type: "isAnotherTheme",
814
+ params: ({ event }) => ({ slug: event.slug })
815
+ },
816
+ target: "switching"
817
+ },
818
+ "theme.edit": {
819
+ guard: {
820
+ type: "isAnotherTheme",
821
+ params: ({ event }) => ({ slug: event.slug })
822
+ },
823
+ target: "switching"
824
+ },
825
+ "theme.duplicate": {
826
+ guard: {
827
+ type: "isAnotherTheme",
828
+ params: ({ event }) => ({ slug: event.slug })
829
+ },
830
+ target: "switching"
831
+ },
832
+ "theme.remove": {
833
+ guard: {
834
+ type: "isAppliedTheme",
835
+ params: ({ event }) => ({ slug: event.slug })
836
+ },
837
+ target: "switching"
838
+ },
839
+ "theme.delete": {
840
+ guard: {
841
+ type: "isAppliedTheme",
842
+ params: ({ event }) => ({ slug: event.slug })
843
+ },
844
+ target: "switching"
845
+ },
846
+ "theme.add": {
847
+ guard: {
848
+ type: "hasOptions",
849
+ params: ({ event }) => ({ options: event.options })
850
+ },
851
+ target: "switching"
852
+ },
853
+ "theme.import": "switching"
854
+ } },
855
+ switching: {
856
+ tags: ["switching"],
857
+ after: { [MOTION_DURATION]: "applied" },
858
+ on: {
859
+ "layout.transitioned": "applied",
860
+ "theme.pick": {
861
+ guard: {
862
+ type: "isAnotherTheme",
863
+ params: ({ event }) => ({ slug: event.slug })
864
+ },
865
+ target: "switching",
866
+ reenter: !0
867
+ },
868
+ "theme.edit": {
869
+ guard: {
870
+ type: "isAnotherTheme",
871
+ params: ({ event }) => ({ slug: event.slug })
872
+ },
873
+ target: "switching",
874
+ reenter: !0
875
+ },
876
+ "theme.duplicate": {
877
+ guard: {
878
+ type: "isAnotherTheme",
879
+ params: ({ event }) => ({ slug: event.slug })
880
+ },
881
+ target: "switching",
882
+ reenter: !0
883
+ },
884
+ "theme.remove": {
885
+ guard: {
886
+ type: "isAppliedTheme",
887
+ params: ({ event }) => ({ slug: event.slug })
888
+ },
889
+ target: "switching",
890
+ reenter: !0
891
+ },
892
+ "theme.delete": {
893
+ guard: {
894
+ type: "isAppliedTheme",
895
+ params: ({ event }) => ({ slug: event.slug })
896
+ },
897
+ target: "switching",
898
+ reenter: !0
899
+ },
900
+ "theme.add": {
901
+ guard: {
902
+ type: "hasOptions",
903
+ params: ({ event }) => ({ options: event.options })
904
+ },
905
+ target: "switching",
906
+ reenter: !0
907
+ },
908
+ "theme.import": {
909
+ target: "switching",
910
+ reenter: !0
911
+ }
912
+ }
913
+ }
914
+ }
915
+ },
916
+ flow: {
917
+ initial: "list",
918
+ on: {
919
+ "theme.pick": { actions: [{
920
+ type: "pick",
921
+ params: ({ event }) => ({ slug: event.slug })
922
+ }, "persist"] },
923
+ "theme.add": {
924
+ target: ".edit",
925
+ actions: [{
926
+ type: "add",
927
+ params: ({ event }) => ({
928
+ title: event.title,
929
+ options: event.options,
930
+ palette: event.palette,
931
+ imageUrl: event.imageUrl
932
+ })
933
+ }, "persist"]
934
+ },
935
+ "theme.duplicate": {
936
+ target: ".edit",
937
+ actions: [{
938
+ type: "duplicate",
939
+ params: ({ event }) => ({ slug: event.slug })
940
+ }, "persist"]
941
+ },
942
+ "theme.edit": {
943
+ target: ".edit",
944
+ guard: {
945
+ type: "isCustomTheme",
946
+ params: ({ event }) => ({ slug: event.slug })
947
+ },
948
+ actions: [{
949
+ type: "startEditing",
950
+ params: ({ event }) => ({ slug: event.slug })
951
+ }, "persist"]
952
+ },
953
+ "theme.update": { actions: [
954
+ {
955
+ type: "revokeImage",
956
+ params: ({ context, event }) => ({ url: event.imageUrl ? context.images[event.slug] : void 0 })
957
+ },
958
+ {
959
+ type: "update",
960
+ params: ({ event }) => ({
961
+ slug: event.slug,
962
+ title: event.title,
963
+ options: event.options,
964
+ palette: event.palette,
965
+ imageUrl: event.imageUrl
966
+ })
967
+ },
968
+ "persist"
969
+ ] },
970
+ "theme.remove": { actions: [{
971
+ type: "remove",
972
+ params: ({ event }) => ({ slug: event.slug })
973
+ }, "persist"] },
974
+ "theme.restore": { actions: [{
975
+ type: "restore",
976
+ params: ({ event }) => ({ slug: event.slug })
977
+ }, "persist"] },
978
+ "theme.delete": { actions: [
979
+ {
980
+ type: "revokeImage",
981
+ params: ({ context, event }) => ({ url: context.images[event.slug] })
982
+ },
983
+ {
984
+ type: "delete",
985
+ params: ({ event }) => ({ slug: event.slug })
986
+ },
987
+ "persist"
988
+ ] },
989
+ "theme.reorder": { actions: [{
990
+ type: "reorder",
991
+ params: ({ event }) => ({ order: event.order })
992
+ }, "persist"] },
993
+ "theme.import": { actions: [{
994
+ type: "import",
995
+ params: ({ event }) => ({
996
+ title: event.title,
997
+ options: event.options
998
+ })
999
+ }, "persist"] },
1000
+ "flow.list": ".list",
1001
+ "flow.removed": {
1002
+ target: ".removed",
1003
+ guard: "hasRemovedThemes"
1004
+ }
1005
+ },
1006
+ states: {
1007
+ list: {},
1008
+ edit: {
1009
+ exit: "stopEditing",
1010
+ always: {
1011
+ guard: not("isEditingListedTheme"),
1012
+ target: "list"
1013
+ }
1014
+ },
1015
+ removed: { always: {
1016
+ guard: not("hasRemovedThemes"),
1017
+ target: "list"
1018
+ } }
1019
+ }
1020
+ }
1021
+ }
1022
+ });
1023
+ /** The part of the machine context that is persisted between sessions @internal */
1024
+ function selectStoredState(snapshot) {
1025
+ let { active, custom, removed, order } = snapshot.context;
852
1026
  return {
853
- saturation: delta === 0 ? 0 : lightness > .5 ? delta / (2 - max - min) : delta / (max + min),
854
- lightness
1027
+ active,
1028
+ custom,
1029
+ removed,
1030
+ order
855
1031
  };
856
1032
  }
857
- function rgbToHex(r, g, b) {
858
- return `#${[
859
- r,
860
- g,
861
- b
862
- ].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
863
- }
864
- /** Reads `#rgb` and `#rrggbb` alike — stored palettes may hold either */
865
- function hexToRgb(hex) {
866
- let expanded = hex.length === 4 ? `${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex.slice(1);
867
- return [
868
- parseInt(expanded.slice(0, 2), 16),
869
- parseInt(expanded.slice(2, 4), 16),
870
- parseInt(expanded.slice(4, 6), 16)
871
- ];
872
- }
873
- /** Mixes `amount` of `color` into `base` */
874
- function tint(base, color, amount) {
875
- let [r1, g1, b1] = hexToRgb(base), [r2, g2, b2] = hexToRgb(color);
876
- return rgbToHex(Math.round(r1 + (r2 - r1) * amount), Math.round(g1 + (g2 - g1) * amount), Math.round(b1 + (b2 - b1) * amount));
1033
+ var root$2 = "_1o50s7t0";
1034
+ const ScrollAreaInsetContext = createContext({
1035
+ left: 0,
1036
+ right: 0,
1037
+ bottom: 0
1038
+ });
1039
+ /**
1040
+ * The gutter the last scroll area measured. It is the same for every scroll
1041
+ * area (the reserved gutter does not depend on the content), so the next one
1042
+ * starts out with the right padding instead of shifting after its first
1043
+ * measurement — which would also throw off anything scrolling into view as
1044
+ * the content mounts.
1045
+ */
1046
+ let lastGutter = 0;
1047
+ /**
1048
+ * The scrolling body of a sidebar flow. It always reserves the scrollbar's
1049
+ * space, so the content does not shift when the list grows or shrinks past
1050
+ * the point where it needs to scroll — and it takes that space out of its
1051
+ * right padding, so the content stays centered rather than hugging the left.
1052
+ * With overlay scrollbars there is no gutter and the padding stays even.
1053
+ *
1054
+ * @internal
1055
+ */
1056
+ function ScrollArea(props) {
1057
+ let $ = c(15), { children, padding } = props, { space } = useTheme_v2(), ref = useRef(null), [gutter, setGutter] = useState(_temp$8), t0, t1;
1058
+ $[0] === Symbol.for("react.memo_cache_sentinel") ? (t0 = () => {
1059
+ let element = ref.current;
1060
+ if (!element) return;
1061
+ let measure = () => {
1062
+ lastGutter = element.offsetWidth - element.clientWidth, setGutter(lastGutter);
1063
+ };
1064
+ measure();
1065
+ let observer = new ResizeObserver(measure);
1066
+ return observer.observe(element), () => observer.disconnect();
1067
+ }, t1 = [], $[0] = t0, $[1] = t1) : (t0 = $[0], t1 = $[1]), useEffect(t0, t1);
1068
+ let t2 = space[padding], t3 = Math.max(0, space[padding] - gutter), t4 = space[padding], t5;
1069
+ $[2] !== t2 || $[3] !== t3 || $[4] !== t4 ? (t5 = {
1070
+ left: t2,
1071
+ right: t3,
1072
+ bottom: t4
1073
+ }, $[2] = t2, $[3] = t3, $[4] = t4, $[5] = t5) : t5 = $[5];
1074
+ let inset = t5, t6;
1075
+ $[6] === inset.right ? t6 = $[7] : (t6 = { paddingRight: inset.right }, $[6] = inset.right, $[7] = t6);
1076
+ let t7;
1077
+ $[8] !== children || $[9] !== inset ? (t7 = /* @__PURE__ */ jsx(ScrollAreaInsetContext.Provider, {
1078
+ value: inset,
1079
+ children
1080
+ }), $[8] = children, $[9] = inset, $[10] = t7) : t7 = $[10];
1081
+ let t8;
1082
+ return $[11] !== padding || $[12] !== t6 || $[13] !== t7 ? (t8 = /* @__PURE__ */ jsx(Box, {
1083
+ className: root$2,
1084
+ flex: 1,
1085
+ overflow: "auto",
1086
+ padding,
1087
+ ref,
1088
+ style: t6,
1089
+ children: t7
1090
+ }), $[11] = padding, $[12] = t6, $[13] = t7, $[14] = t8) : t8 = $[14], t8;
877
1091
  }
878
- /** How much of the palette tints each scheme's stock background */
879
- const BACKGROUND_TINT = {
880
- light: .08,
881
- dark: .3
882
- }, BUTTON_LABEL = {
883
- light: "#ffffff",
884
- dark: "#000000"
885
- }, ACCENT_LIGHTNESS_STEP = .01;
886
- function contrastRatio(a, b) {
887
- let [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
888
- return (lighter + .05) / (darker + .05);
1092
+ function _temp$8() {
1093
+ return lastGutter;
889
1094
  }
890
1095
  /**
891
- * Makes an accent readable as a button in the given scheme: a light scheme
892
- * accent is darkened until white text on it reaches AA contrast, a dark
893
- * scheme accent lightened until black text does — as far as the accent's
894
- * hue and saturation allow. Accents that already pass come back untouched.
1096
+ * Content that runs edge to edge in a scroll area — up to the scrollbar —
1097
+ * instead of sitting inside its padding. It goes last: it runs down to the
1098
+ * bottom edge too.
895
1099
  *
896
1100
  * @internal
897
1101
  */
898
- function readableAccent(hex, scheme) {
899
- let label = BUTTON_LABEL[scheme], { h, s } = hexToHsl(hex), { l } = hexToHsl(hex), current = hex;
900
- for (; contrastRatio(current, label) < 4.5;) {
901
- let next = scheme === "light" ? l - ACCENT_LIGHTNESS_STEP : l + ACCENT_LIGHTNESS_STEP;
902
- if (next < 0 || next > 1) break;
903
- l = next, current = hslToHex({
904
- h,
905
- s,
906
- l
907
- });
908
- }
909
- return current;
910
- }
911
- /** The accent a variant gives a scheme, made readable on a button */
912
- function variantAccent(palette, variant, scheme) {
913
- let swatch = (variant ? palette[variant] : null) ?? palette.vibrant ?? (scheme === "light" ? palette.darkVibrant : palette.lightVibrant) ?? palette.dominant;
914
- return swatch === null ? null : readableAccent(swatch, scheme);
1102
+ function ScrollAreaBleed(props) {
1103
+ let $ = c(7), { left, right, bottom } = useContext(ScrollAreaInsetContext), t0 = -left, t1 = -right, t2 = -bottom, t3;
1104
+ $[0] !== t0 || $[1] !== t1 || $[2] !== t2 ? (t3 = {
1105
+ marginLeft: t0,
1106
+ marginRight: t1,
1107
+ marginBottom: t2
1108
+ }, $[0] = t0, $[1] = t1, $[2] = t2, $[3] = t3) : t3 = $[3];
1109
+ let t4;
1110
+ return $[4] !== props.children || $[5] !== t3 ? (t4 = /* @__PURE__ */ jsx("div", {
1111
+ style: t3,
1112
+ children: props.children
1113
+ }), $[4] = props.children, $[5] = t3, $[6] = t4) : t4 = $[6], t4;
915
1114
  }
1115
+ var avatar = "_1aa9huuh", body = "_1aa9huui", button = "_1aa9huuq", colors = {
1116
+ bg: "var(--_1aa9huu0)",
1117
+ fg: "var(--_1aa9huu1)",
1118
+ mutedFg: "var(--_1aa9huu2)",
1119
+ border: "var(--_1aa9huu3)",
1120
+ accent: "var(--_1aa9huu4)",
1121
+ avatar: "var(--_1aa9huu5)",
1122
+ selectedBg: "var(--_1aa9huu6)",
1123
+ selectedFg: "var(--_1aa9huu7)",
1124
+ inputBg: "var(--_1aa9huu8)",
1125
+ inputBorder: "var(--_1aa9huu9)"
1126
+ }, field = "_1aa9huun", footer = "_1aa9huup", form = "_1aa9huum", input = "_1aa9huuo", line = {
1127
+ fg: "_1aa9huus _1aa9huur",
1128
+ muted: "_1aa9huut _1aa9huur",
1129
+ selected: "_1aa9huuu _1aa9huur"
1130
+ }, list = "_1aa9huuj", listRow = "_1aa9huuk", logo = "_1aa9huuf", navbar = "_1aa9huue", pane = "_1aa9huul", root$1 = "_1aa9huua", spacer = "_1aa9huug", studio = {
1131
+ light: "_1aa9huub",
1132
+ dark: "_1aa9huud _1aa9huub"
1133
+ };
1134
+ /** The document rows in the list pane — the widths of their title lines */
1135
+ const LIST_ROWS = [
1136
+ "62%",
1137
+ "46%",
1138
+ "70%",
1139
+ "52%"
1140
+ ], FIELDS$1 = ["32%", "24%"];
916
1141
  /**
917
- * Derives theme options from an image palette: the accent of both schemes is
918
- * the swatch of the chosen variant — or, without one, the vibrant swatch,
919
- * each scheme falling back to the vibrant swatch of its own end of the scale —
920
- * adjusted until it is readable as a button. The muted swatch becomes the text color, and the light and dark muted
921
- * swatches tint the stock backgrounds of their scheme — enough to carry the
922
- * image's tone without giving up a usable canvas. Whatever the palette lacks
923
- * stays at the theme defaults.
1142
+ * A tiny, lo-fi mockup of a Studio in the given theme — the navbar over the
1143
+ * structure tool with a list of documents on the left and a document form on
1144
+ * the right — in both color schemes at once: the light scheme on the left and
1145
+ * the dark scheme on the right, split diagonally. It only paints colors the
1146
+ * theme options change (backgrounds, text, borders, the accent) so that a
1147
+ * glance shows what the theme does to the Studio — with few and bold enough
1148
+ * elements to still read when scaled down to a thumbnail.
924
1149
  *
925
1150
  * @internal
926
1151
  */
927
- function optionsFromImagePalette(palette, variant) {
928
- let options = {}, light = schemeFromPalette(variantAccent(palette, variant ?? null, "light"), palette.muted ?? palette.darkMuted, palette.lightMuted ?? palette.muted ?? palette.lightVibrant, "light"), dark = schemeFromPalette(variantAccent(palette, variant ?? null, "dark"), palette.muted ?? palette.lightMuted, palette.darkMuted ?? palette.muted ?? palette.darkVibrant, "dark");
929
- return light && (options.light = light), dark && (options.dark = dark), options;
1152
+ function ThemePreview(props) {
1153
+ let $ = c(11), { options } = props, t0;
1154
+ $[0] === options ? t0 = $[1] : (t0 = buildTheme(options), $[0] = options, $[1] = t0);
1155
+ let theme = t0, t1;
1156
+ $[2] === Symbol.for("react.memo_cache_sentinel") ? (t1 = /* @__PURE__ */ jsx(MockStudio, { scheme: "light" }), $[2] = t1) : t1 = $[2];
1157
+ let t2;
1158
+ $[3] === theme ? t2 = $[4] : (t2 = /* @__PURE__ */ jsx(ThemeProvider, {
1159
+ scheme: "light",
1160
+ theme,
1161
+ tone: "default",
1162
+ children: t1
1163
+ }), $[3] = theme, $[4] = t2);
1164
+ let t3;
1165
+ $[5] === Symbol.for("react.memo_cache_sentinel") ? (t3 = /* @__PURE__ */ jsx(MockStudio, { scheme: "dark" }), $[5] = t3) : t3 = $[5];
1166
+ let t4;
1167
+ $[6] === theme ? t4 = $[7] : (t4 = /* @__PURE__ */ jsx(ThemeProvider, {
1168
+ scheme: "dark",
1169
+ theme,
1170
+ tone: "default",
1171
+ children: t3
1172
+ }), $[6] = theme, $[7] = t4);
1173
+ let t5;
1174
+ return $[8] !== t2 || $[9] !== t4 ? (t5 = /* @__PURE__ */ jsxs("div", {
1175
+ "aria-hidden": !0,
1176
+ className: root$1,
1177
+ children: [t2, t4]
1178
+ }), $[8] = t2, $[9] = t4, $[10] = t5) : t5 = $[10], t5;
1179
+ }
1180
+ function MockStudio(props) {
1181
+ let $ = c(19), { scheme } = props, { color } = useTheme_v2(), selected = color.selectable.default.selected, inputColor = color.input.default.enabled, t0 = studio[scheme], t1;
1182
+ $[0] !== color.avatar.blue.bg || $[1] !== color.bg || $[2] !== color.border || $[3] !== color.button.default.primary.enabled.bg || $[4] !== color.fg || $[5] !== color.muted.fg || $[6] !== inputColor.bg || $[7] !== inputColor.border || $[8] !== selected.bg || $[9] !== selected.fg ? (t1 = assignInlineVars({
1183
+ [colors.bg]: color.bg,
1184
+ [colors.fg]: color.fg,
1185
+ [colors.mutedFg]: color.muted.fg,
1186
+ [colors.border]: color.border,
1187
+ [colors.accent]: color.button.default.primary.enabled.bg,
1188
+ [colors.avatar]: color.avatar.blue.bg,
1189
+ [colors.selectedBg]: selected.bg,
1190
+ [colors.selectedFg]: selected.fg,
1191
+ [colors.inputBg]: inputColor.bg,
1192
+ [colors.inputBorder]: inputColor.border
1193
+ }), $[0] = color.avatar.blue.bg, $[1] = color.bg, $[2] = color.border, $[3] = color.button.default.primary.enabled.bg, $[4] = color.fg, $[5] = color.muted.fg, $[6] = inputColor.bg, $[7] = inputColor.border, $[8] = selected.bg, $[9] = selected.fg, $[10] = t1) : t1 = $[10];
1194
+ let t2;
1195
+ $[11] === Symbol.for("react.memo_cache_sentinel") ? (t2 = /* @__PURE__ */ jsx("div", { className: logo }), $[11] = t2) : t2 = $[11];
1196
+ let t3;
1197
+ $[12] === Symbol.for("react.memo_cache_sentinel") ? (t3 = /* @__PURE__ */ jsxs("div", {
1198
+ className: navbar,
1199
+ children: [
1200
+ t2,
1201
+ /* @__PURE__ */ jsx("div", {
1202
+ className: line.fg,
1203
+ style: { width: "22%" }
1204
+ }),
1205
+ /* @__PURE__ */ jsx("div", { className: spacer }),
1206
+ /* @__PURE__ */ jsx("div", { className: avatar })
1207
+ ]
1208
+ }), $[12] = t3) : t3 = $[12];
1209
+ let t4;
1210
+ $[13] === Symbol.for("react.memo_cache_sentinel") ? (t4 = /* @__PURE__ */ jsx("div", {
1211
+ className: list,
1212
+ children: LIST_ROWS.map(_temp$7)
1213
+ }), $[13] = t4) : t4 = $[13];
1214
+ let t5;
1215
+ $[14] === Symbol.for("react.memo_cache_sentinel") ? (t5 = /* @__PURE__ */ jsxs("div", {
1216
+ className: form,
1217
+ children: [/* @__PURE__ */ jsx("div", {
1218
+ className: line.fg,
1219
+ style: { width: "45%" }
1220
+ }), FIELDS$1.map(_temp2)]
1221
+ }), $[14] = t5) : t5 = $[14];
1222
+ let t6;
1223
+ $[15] === Symbol.for("react.memo_cache_sentinel") ? (t6 = /* @__PURE__ */ jsxs("div", {
1224
+ className: body,
1225
+ children: [t4, /* @__PURE__ */ jsxs("div", {
1226
+ className: pane,
1227
+ children: [t5, /* @__PURE__ */ jsx("div", {
1228
+ className: footer,
1229
+ children: /* @__PURE__ */ jsx("div", { className: button })
1230
+ })]
1231
+ })]
1232
+ }), $[15] = t6) : t6 = $[15];
1233
+ let t7;
1234
+ return $[16] !== t0 || $[17] !== t1 ? (t7 = /* @__PURE__ */ jsxs("div", {
1235
+ className: t0,
1236
+ style: t1,
1237
+ children: [t3, t6]
1238
+ }), $[16] = t0, $[17] = t1, $[18] = t7) : t7 = $[18], t7;
930
1239
  }
931
- function schemeFromPalette(accent, text, backgroundTint, scheme) {
932
- let options = {};
933
- return accent && (options.accent = accent), text && (options.text = text), backgroundTint && (options.background = tint(DEFAULT_BACKGROUND[scheme], backgroundTint, BACKGROUND_TINT[scheme])), Object.keys(options).length > 0 ? options : null;
1240
+ function _temp2(width_0) {
1241
+ return /* @__PURE__ */ jsxs("div", {
1242
+ className: field,
1243
+ children: [/* @__PURE__ */ jsx("div", {
1244
+ className: line.muted,
1245
+ style: { width: width_0 }
1246
+ }), /* @__PURE__ */ jsx("div", { className: input })]
1247
+ }, width_0);
934
1248
  }
935
- /**
936
- * Applies an image palette to existing theme options: the colors the palette
937
- * provides replace the current ones scheme by scheme, and whatever it does
938
- * not touch — like the contrast — stays as it is.
939
- *
940
- * @internal
941
- */
942
- function applyImagePalette(options, palette, variant) {
943
- let derived = optionsFromImagePalette(palette, variant), next = { ...options };
944
- for (let scheme of SCHEMES) derived[scheme] && (next[scheme] = {
945
- ...options[scheme],
946
- ...derived[scheme]
947
- });
948
- return next;
1249
+ function _temp$7(width, index) {
1250
+ let isSelected = index === 1;
1251
+ return /* @__PURE__ */ jsx("div", {
1252
+ className: listRow,
1253
+ "data-selected": isSelected,
1254
+ children: /* @__PURE__ */ jsx("div", {
1255
+ className: isSelected ? line.selected : line.muted,
1256
+ style: { width }
1257
+ })
1258
+ }, width);
949
1259
  }
950
1260
  /**
951
- * The variant a theme is built around: the one whose swatch is the theme's
952
- * accent, or `null` when the accent comes from somewhere else.
1261
+ * The floating preview card of a theme: the lo-fi studio mockup on a raised,
1262
+ * rounded card, like the appearance previews in macOS System Settings.
953
1263
  *
954
1264
  * @internal
955
1265
  */
956
- function currentImageVariant(options, palette) {
957
- let accent = options.light?.accent;
958
- return accent ? IMAGE_PALETTE_VARIANTS.find((variant) => palette[variant] !== null && variantAccent(palette, variant, "light") === accent) ?? null : null;
959
- }
960
- /**
961
- * How much a swatch has going for it as an accent: saturation first, then
962
- * closeness to mid lightness — the range where an accent reads best.
963
- */
964
- function interest(hex) {
965
- let { s, l } = hexToHsl(hex);
966
- return .1 + .55 * s + .35 * (1 - Math.abs(l - .5) * 2);
1266
+ function ThemeThumbnail(props) {
1267
+ let $ = c(2), t0;
1268
+ return $[0] === props.options ? t0 = $[1] : (t0 = /* @__PURE__ */ jsx(Card, {
1269
+ overflow: "hidden",
1270
+ radius: 3,
1271
+ shadow: 1,
1272
+ children: /* @__PURE__ */ jsx(ThemePreview, { options: props.options })
1273
+ }), $[0] = props.options, $[1] = t0), t0;
967
1274
  }
1275
+ var removedThumbnail = "_1vdkp0v0";
968
1276
  /**
969
- * Picks a variant for the "I'm feeling lucky" button: a random one, weighted
970
- * by how interesting its swatch is as an accent, and never the one the theme
971
- * already uses while there are others to pick from.
1277
+ * The flow for restoring removed themes: the presets and custom themes the
1278
+ * user took out of the list, each with a button to put it back — and, for
1279
+ * custom themes, one to delete it for good. The machine leaves the flow once
1280
+ * the last one is gone.
972
1281
  *
973
1282
  * @internal
974
1283
  */
975
- function pickLuckyVariant(palette, options = {}) {
976
- let { exclude = null, random = Math.random } = options, candidates = IMAGE_PALETTE_VARIANTS.filter((variant) => palette[variant] !== null);
977
- if (candidates.length > 1 && (candidates = candidates.filter((variant) => variant !== exclude)), candidates.length === 0) return null;
978
- let weights = candidates.map((variant) => interest(palette[variant])), total = weights.reduce((sum, weight) => sum + weight, 0), pick = random() * total;
979
- for (let [index, variant] of candidates.entries()) if (pick -= weights[index], pick < 0) return variant;
980
- return candidates.at(-1) ?? null;
981
- }
982
- /** A theme title for the image a palette came from, e.g. `sunset-beach.jpg` → `sunset beach` @internal */
983
- function titleFromFileName(name) {
984
- return name.replace(/\.[^.]+$/, "").replace(/[-_]+/g, " ").trim() || "Image theme";
1284
+ function RemovedThemes() {
1285
+ let $ = c(7), { removed, send } = useThemer(), t0;
1286
+ if ($[0] !== removed || $[1] !== send) {
1287
+ let t1;
1288
+ $[3] === send ? t1 = $[4] : (t1 = (theme) => /* @__PURE__ */ jsxs(Stack, {
1289
+ gap: 3,
1290
+ children: [
1291
+ /* @__PURE__ */ jsx(Box, {
1292
+ className: removedThumbnail,
1293
+ children: /* @__PURE__ */ jsx(ThemeThumbnail, { options: theme.options })
1294
+ }),
1295
+ /* @__PURE__ */ jsx(Text, {
1296
+ align: "center",
1297
+ size: 1,
1298
+ textOverflow: "ellipsis",
1299
+ children: displayTitle(theme.title)
1300
+ }),
1301
+ /* @__PURE__ */ jsxs(Flex, {
1302
+ gap: 2,
1303
+ justify: "center",
1304
+ children: [/* @__PURE__ */ jsx(Button, {
1305
+ mode: "ghost",
1306
+ onClick: () => send({
1307
+ type: "theme.restore",
1308
+ slug: theme.slug
1309
+ }),
1310
+ padding: 2,
1311
+ text: "Restore"
1312
+ }), theme.source === "custom" && /* @__PURE__ */ jsx(Button, {
1313
+ mode: "ghost",
1314
+ onClick: () => send({
1315
+ type: "theme.delete",
1316
+ slug: theme.slug
1317
+ }),
1318
+ padding: 2,
1319
+ text: "Delete",
1320
+ tone: "critical"
1321
+ })]
1322
+ })
1323
+ ]
1324
+ }, theme.slug), $[3] = send, $[4] = t1), t0 = removed.map(t1), $[0] = removed, $[1] = send, $[2] = t0;
1325
+ } else t0 = $[2];
1326
+ let t1;
1327
+ return $[5] === t0 ? t1 = $[6] : (t1 = /* @__PURE__ */ jsx(ScrollArea, {
1328
+ padding: 3,
1329
+ children: /* @__PURE__ */ jsx(Stack, {
1330
+ gap: 5,
1331
+ children: t0
1332
+ })
1333
+ }), $[5] = t0, $[6] = t1), t1;
985
1334
  }
986
1335
  /**
987
- * A button with an animated tooltip instead of a `title` attribute. Icon-only
988
- * buttons take the tooltip as their accessible name.
1336
+ * A button with an animated tooltip instead of a `title` attribute, sized like
1337
+ * the Studio's own buttons (`padding` and `gap` of 2). Icon-only buttons take
1338
+ * the tooltip as their accessible name.
989
1339
  *
990
1340
  * @internal
991
1341
  */
@@ -1000,6 +1350,8 @@ function TooltipButton(props) {
1000
1350
  let t1 = buttonProps.text === void 0 ? tooltip : void 0, t2;
1001
1351
  $[5] !== buttonProps || $[6] !== t1 ? (t2 = /* @__PURE__ */ jsx(Button, {
1002
1352
  "aria-label": t1,
1353
+ gap: 2,
1354
+ padding: 2,
1003
1355
  ...buttonProps
1004
1356
  }), $[5] = buttonProps, $[6] = t1, $[7] = t2) : t2 = $[7];
1005
1357
  let t3;
@@ -1099,7 +1451,7 @@ function useImagePalette(onPalette) {
1099
1451
  setBusy(!0);
1100
1452
  let isCurrent = () => mountedRef.current && latestPickRef.current === pick, extract = async () => {
1101
1453
  let palette = extractImagePalette(await readImagePixels(file));
1102
- isCurrent() && (Object.values(palette).every(_temp$5) ? toast.push({
1454
+ isCurrent() && (Object.values(palette).every(_temp$6) ? toast.push({
1103
1455
  status: "warning",
1104
1456
  title: "Found no colors in that image"
1105
1457
  }) : onPaletteRef.current(palette, file));
@@ -1119,7 +1471,7 @@ function useImagePalette(onPalette) {
1119
1471
  pickImage
1120
1472
  }, $[7] = busy, $[8] = pickImage, $[9] = t5) : t5 = $[9], t5;
1121
1473
  }
1122
- function _temp$5(swatch) {
1474
+ function _temp$6(swatch) {
1123
1475
  return swatch === null;
1124
1476
  }
1125
1477
  var paletteGrid = "_17s80v42", tile = "_17s80v44", variantButton = "_17s80v46", variantFrame = "_17s80v47", variantRow = "_17s80v45", variantScale = "var(--_17s80v41)", variantScaleBox = "_17s80v48", variantWidth = "var(--_17s80v40)";
@@ -1310,7 +1662,7 @@ function ImagePaletteSection(props) {
1310
1662
  border: !0,
1311
1663
  onDragEnter: t4,
1312
1664
  onDragLeave: t5,
1313
- onDragOver: _temp$4,
1665
+ onDragOver: _temp$5,
1314
1666
  onDrop: t6,
1315
1667
  padding: 3,
1316
1668
  radius: 3,
@@ -1318,7 +1670,7 @@ function ImagePaletteSection(props) {
1318
1670
  children: t12
1319
1671
  }), $[34] = t12, $[35] = t6, $[36] = t7, $[37] = t13) : t13 = $[37], t13;
1320
1672
  }
1321
- function _temp$4(event_0) {
1673
+ function _temp$5(event_0) {
1322
1674
  event_0.preventDefault();
1323
1675
  }
1324
1676
  var colorRowText = "os3rj2", range = "os3rj1", swatch = "os3rj0";
@@ -1442,7 +1794,7 @@ function ThemeEditorForm(props) {
1442
1794
  }), $[9] = t4) : t4 = $[9];
1443
1795
  let t5;
1444
1796
  $[10] === onTitleChange ? t5 = $[11] : (t5 = (event) => onTitleChange(event.currentTarget.value), $[10] = onTitleChange, $[11] = t5);
1445
- let t6 = focusTitle ? _temp$3 : void 0, t7;
1797
+ let t6 = focusTitle ? _temp$4 : void 0, t7;
1446
1798
  $[12] !== focusTitle || $[13] !== t5 || $[14] !== t6 || $[15] !== title ? (t7 = /* @__PURE__ */ jsxs(Stack, {
1447
1799
  gap: 2,
1448
1800
  children: [t4, /* @__PURE__ */ jsx(TextInput, {
@@ -1505,6 +1857,7 @@ function ThemeEditorForm(props) {
1505
1857
  $[42] === onDone ? t14 = $[43] : (t14 = /* @__PURE__ */ jsx(Button, {
1506
1858
  mode: "ghost",
1507
1859
  onClick: onDone,
1860
+ padding: 2,
1508
1861
  text: "Done"
1509
1862
  }), $[42] = onDone, $[43] = t14);
1510
1863
  let t15;
@@ -1523,7 +1876,7 @@ function ThemeEditorForm(props) {
1523
1876
  let t16;
1524
1877
  return $[47] !== t11 || $[48] !== t15 ? (t16 = /* @__PURE__ */ jsxs(Fragment, { children: [t11, t15] }), $[47] = t11, $[48] = t15, $[49] = t16) : t16 = $[49], t16;
1525
1878
  }
1526
- function _temp$3(event_0) {
1879
+ function _temp$4(event_0) {
1527
1880
  return event_0.currentTarget.select();
1528
1881
  }
1529
1882
  /**
@@ -1675,59 +2028,350 @@ function SchemeCard(props) {
1675
2028
  })
1676
2029
  }), $[60] = t10, $[61] = t15, $[62] = t21, $[63] = t31, $[64] = t5, $[65] = t32) : t32 = $[65], t32;
1677
2030
  }
1678
- /** One color of a scheme: a swatch with its value and an optional reset button */
1679
- function ColorRow(props) {
1680
- let $ = c(23), { adjusted, auto, autoLabel: t0, label, onChange, onClear, title, value } = props, autoLabel = t0 === void 0 ? "auto" : t0, t1;
1681
- $[0] === onChange ? t1 = $[1] : (t1 = (event) => onChange(event.currentTarget.value), $[0] = onChange, $[1] = t1);
2031
+ /** One color of a scheme: a swatch with its value and an optional reset button */
2032
+ function ColorRow(props) {
2033
+ let $ = c(23), { adjusted, auto, autoLabel: t0, label, onChange, onClear, title, value } = props, autoLabel = t0 === void 0 ? "auto" : t0, t1;
2034
+ $[0] === onChange ? t1 = $[1] : (t1 = (event) => onChange(event.currentTarget.value), $[0] = onChange, $[1] = t1);
2035
+ let t2;
2036
+ $[2] !== label || $[3] !== t1 || $[4] !== value ? (t2 = /* @__PURE__ */ jsx("input", {
2037
+ "aria-label": label,
2038
+ className: swatch,
2039
+ onChange: t1,
2040
+ type: "color",
2041
+ value
2042
+ }), $[2] = label, $[3] = t1, $[4] = value, $[5] = t2) : t2 = $[5];
2043
+ let t3;
2044
+ $[6] === title ? t3 = $[7] : (t3 = /* @__PURE__ */ jsx(Text, {
2045
+ size: 1,
2046
+ children: title
2047
+ }), $[6] = title, $[7] = t3);
2048
+ let t4 = adjusted !== void 0 && adjusted !== value ? ` → ${adjusted}` : "", t5 = auto ? ` · ${autoLabel}` : "", t6;
2049
+ $[8] !== t4 || $[9] !== t5 || $[10] !== value ? (t6 = /* @__PURE__ */ jsxs(Text, {
2050
+ muted: !0,
2051
+ size: 0,
2052
+ textOverflow: "ellipsis",
2053
+ children: [
2054
+ value,
2055
+ t4,
2056
+ t5
2057
+ ]
2058
+ }), $[8] = t4, $[9] = t5, $[10] = value, $[11] = t6) : t6 = $[11];
2059
+ let t7;
2060
+ $[12] !== t3 || $[13] !== t6 ? (t7 = /* @__PURE__ */ jsxs(Stack, {
2061
+ className: colorRowText,
2062
+ flex: 1,
2063
+ gap: 2,
2064
+ children: [t3, t6]
2065
+ }), $[12] = t3, $[13] = t6, $[14] = t7) : t7 = $[14];
2066
+ let t8;
2067
+ $[15] !== auto || $[16] !== autoLabel || $[17] !== onClear ? (t8 = onClear && !auto && /* @__PURE__ */ jsx(TooltipButton, {
2068
+ icon: ResetIcon,
2069
+ mode: "bleed",
2070
+ onClick: onClear,
2071
+ padding: 2,
2072
+ tooltip: `Reset to ${autoLabel}`
2073
+ }), $[15] = auto, $[16] = autoLabel, $[17] = onClear, $[18] = t8) : t8 = $[18];
2074
+ let t9;
2075
+ return $[19] !== t2 || $[20] !== t7 || $[21] !== t8 ? (t9 = /* @__PURE__ */ jsxs(Flex, {
2076
+ align: "center",
2077
+ gap: 2,
2078
+ children: [
2079
+ t2,
2080
+ t7,
2081
+ t8
2082
+ ]
2083
+ }), $[19] = t2, $[20] = t7, $[21] = t8, $[22] = t9) : t9 = $[22], t9;
2084
+ }
2085
+ /**
2086
+ * The version of the code format, the first byte of every code — bumped if
2087
+ * the layout below ever changes, so older codes can still be told apart
2088
+ */
2089
+ const FIELDS = [
2090
+ "accent",
2091
+ "text",
2092
+ "background",
2093
+ "contrast"
2094
+ ];
2095
+ /**
2096
+ * Turns a theme into a short code to share in a message: `title`, and the
2097
+ * colors and contrasts of both schemes, packed into bytes — one flag byte says
2098
+ * which of the eight fields are set, each color is its three RGB bytes and a
2099
+ * contrast one byte, and the title takes up the rest — then base64url'd, so
2100
+ * it is one word without padding, safe in URLs and chat. A theme setting
2101
+ * everything comes to about 30 bytes plus its title.
2102
+ *
2103
+ * @internal
2104
+ */
2105
+ function encodeTheme(theme) {
2106
+ let bytes = [1, 0], flags = 0;
2107
+ return SCHEMES.forEach((scheme, schemeIndex) => {
2108
+ let options = theme.options[scheme] ?? {};
2109
+ FIELDS.forEach((field, fieldIndex) => {
2110
+ let value = options[field];
2111
+ if (value !== void 0) {
2112
+ if (field === "contrast") {
2113
+ if (typeof value != "number" || !Number.isFinite(value)) return;
2114
+ bytes.push(Math.round(Math.min(100, Math.max(15, value))));
2115
+ } else {
2116
+ if (typeof value != "string" || !isColor(value)) return;
2117
+ bytes.push(...rgbBytes(value));
2118
+ }
2119
+ flags |= 1 << schemeIndex * FIELDS.length + fieldIndex;
2120
+ }
2121
+ });
2122
+ }), bytes[1] = flags, bytes.push(...new TextEncoder().encode(theme.title.trim())), toBase64Url(Uint8Array.from(bytes));
2123
+ }
2124
+ /**
2125
+ * Reads a theme back out of a code — as pasted, so surrounding whitespace and
2126
+ * words around the code are fine: the first word that is a code counts. `null`
2127
+ * when nothing in the text is one.
2128
+ *
2129
+ * @internal
2130
+ */
2131
+ function decodeTheme(text) {
2132
+ for (let word of text.trim().split(/\s+/)) {
2133
+ let theme = decodeCode(word);
2134
+ if (theme) return theme;
2135
+ }
2136
+ return null;
2137
+ }
2138
+ function decodeCode(code) {
2139
+ let bytes = fromBase64Url(code);
2140
+ if (!bytes || bytes.length < 2 || bytes[0] !== 1) return null;
2141
+ let flags = bytes[1], options = {}, offset = 2;
2142
+ for (let [schemeIndex, scheme] of SCHEMES.entries()) {
2143
+ let schemeOptions = {};
2144
+ for (let [fieldIndex, field] of FIELDS.entries()) if (flags & 1 << schemeIndex * FIELDS.length + fieldIndex) {
2145
+ if (field === "contrast") {
2146
+ if (offset + 1 > bytes.length) return null;
2147
+ schemeOptions.contrast = Math.min(100, Math.max(15, bytes[offset])), offset += 1;
2148
+ } else {
2149
+ if (offset + 3 > bytes.length) return null;
2150
+ schemeOptions[field] = hexColor(bytes.subarray(offset, offset + 3)), offset += 3;
2151
+ }
2152
+ }
2153
+ Object.keys(schemeOptions).length > 0 && (options[scheme] = schemeOptions);
2154
+ }
2155
+ let title;
2156
+ try {
2157
+ title = new TextDecoder("utf-8", { fatal: !0 }).decode(bytes.subarray(offset));
2158
+ } catch {
2159
+ return null;
2160
+ }
2161
+ return {
2162
+ title: displayTitle(title),
2163
+ options
2164
+ };
2165
+ }
2166
+ /** The three RGB bytes of a `#rgb` or `#rrggbb` color */
2167
+ function rgbBytes(color) {
2168
+ let hex = color.length === 4 ? color.slice(1).replaceAll(/./g, "$&$&") : color.slice(1);
2169
+ return [
2170
+ 0,
2171
+ 2,
2172
+ 4
2173
+ ].map((start) => parseInt(hex.slice(start, start + 2), 16));
2174
+ }
2175
+ function hexColor(rgb) {
2176
+ return `#${[...rgb].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
2177
+ }
2178
+ function toBase64Url(bytes) {
2179
+ let binary = "";
2180
+ for (let byte of bytes) binary += String.fromCharCode(byte);
2181
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
2182
+ }
2183
+ function fromBase64Url(code) {
2184
+ if (!/^[A-Za-z0-9_-]+$/.test(code)) return null;
2185
+ let base64 = code.replaceAll("-", "+").replaceAll("_", "/");
2186
+ try {
2187
+ return Uint8Array.from(atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")), (char) => char.charCodeAt(0));
2188
+ } catch {
2189
+ return null;
2190
+ }
2191
+ }
2192
+ /**
2193
+ * Copying a theme as a code to share, and adding a theme from a code someone
2194
+ * shared — with toasts telling how it went.
2195
+ *
2196
+ * @internal
2197
+ */
2198
+ function useThemeCodes() {
2199
+ let $ = c(11), { send } = useThemer(), toast = useToast(), t0;
2200
+ $[0] === toast ? t0 = $[1] : (t0 = async (theme) => {
2201
+ let title = displayTitle(theme.title);
2202
+ try {
2203
+ await navigator.clipboard.writeText(encodeTheme(theme)), toast.push({
2204
+ status: "success",
2205
+ title: `Copied ${title} as a code`,
2206
+ description: "Paste it in the themer of another Studio to add the theme there"
2207
+ });
2208
+ } catch {
2209
+ toast.push({
2210
+ status: "error",
2211
+ title: `Could not copy ${title}`
2212
+ });
2213
+ }
2214
+ }, $[0] = toast, $[1] = t0);
2215
+ let copyTheme = t0, t1;
2216
+ $[2] !== send || $[3] !== toast ? (t1 = (text) => {
2217
+ let theme_0 = decodeTheme(text);
2218
+ return theme_0 ? (send({
2219
+ type: "theme.import",
2220
+ title: theme_0.title,
2221
+ options: theme_0.options
2222
+ }), toast.push({
2223
+ status: "success",
2224
+ title: `Added ${theme_0.title}`
2225
+ }), !0) : !1;
2226
+ }, $[2] = send, $[3] = toast, $[4] = t1) : t1 = $[4];
2227
+ let addThemeFromText = t1, t2;
2228
+ $[5] === addThemeFromText ? t2 = $[6] : (t2 = async () => {
2229
+ let text_0;
2230
+ try {
2231
+ text_0 = await navigator.clipboard.readText();
2232
+ } catch {
2233
+ return "no-clipboard";
2234
+ }
2235
+ return addThemeFromText(text_0) ? "added" : "no-theme";
2236
+ }, $[5] = addThemeFromText, $[6] = t2);
2237
+ let addThemeFromClipboard = t2, t3;
2238
+ return $[7] !== addThemeFromClipboard || $[8] !== addThemeFromText || $[9] !== copyTheme ? (t3 = {
2239
+ copyTheme,
2240
+ addThemeFromText,
2241
+ addThemeFromClipboard
2242
+ }, $[7] = addThemeFromClipboard, $[8] = addThemeFromText, $[9] = copyTheme, $[10] = t3) : t3 = $[10], t3;
2243
+ }
2244
+ /**
2245
+ * Adds a theme pasted anywhere in the Studio while the themer's list is
2246
+ * showing — pastes into inputs and other editable things are theirs.
2247
+ *
2248
+ * @internal
2249
+ */
2250
+ function usePasteThemeCodes() {
2251
+ let $ = c(5), { open, send } = useThemer(), toast = useToast(), t0, t1;
2252
+ $[0] !== open || $[1] !== send || $[2] !== toast ? (t0 = () => {
2253
+ if (!open) return;
2254
+ let handlePaste = (event) => {
2255
+ if (isEditable(event.target)) return;
2256
+ let theme = decodeTheme(event.clipboardData?.getData("text/plain") ?? "");
2257
+ theme && (event.preventDefault(), send({
2258
+ type: "theme.import",
2259
+ title: theme.title,
2260
+ options: theme.options
2261
+ }), toast.push({
2262
+ status: "success",
2263
+ title: `Added ${theme.title}`
2264
+ }));
2265
+ };
2266
+ return document.addEventListener("paste", handlePaste), () => document.removeEventListener("paste", handlePaste);
2267
+ }, t1 = [
2268
+ open,
2269
+ send,
2270
+ toast
2271
+ ], $[0] = open, $[1] = send, $[2] = toast, $[3] = t0, $[4] = t1) : (t0 = $[3], t1 = $[4]), useEffect(t0, t1);
2272
+ }
2273
+ function isEditable(target) {
2274
+ return target instanceof HTMLElement && (target.isContentEditable || target.matches("input, textarea, select"));
2275
+ }
2276
+ /**
2277
+ * Where a theme code someone shared gets pasted by hand — for browsers that
2278
+ * do not hand the clipboard over, and for codes that are not on it.
2279
+ *
2280
+ * @internal
2281
+ */
2282
+ function PasteThemeDialog(props) {
2283
+ let $ = c(35), { onClose } = props, { addThemeFromText } = useThemeCodes(), [text, setText] = useState(""), [invalid, setInvalid] = useState(!1), t0;
2284
+ $[0] !== addThemeFromText || $[1] !== onClose || $[2] !== text ? (t0 = () => {
2285
+ addThemeFromText(text) ? onClose() : setInvalid(!0);
2286
+ }, $[0] = addThemeFromText, $[1] = onClose, $[2] = text, $[3] = t0) : t0 = $[3];
2287
+ let add = t0, t1;
2288
+ $[4] === onClose ? t1 = $[5] : (t1 = /* @__PURE__ */ jsx(Button, {
2289
+ mode: "ghost",
2290
+ onClick: onClose,
2291
+ padding: 2,
2292
+ text: "Cancel"
2293
+ }), $[4] = onClose, $[5] = t1);
1682
2294
  let t2;
1683
- $[2] !== label || $[3] !== t1 || $[4] !== value ? (t2 = /* @__PURE__ */ jsx("input", {
1684
- "aria-label": label,
1685
- className: swatch,
1686
- onChange: t1,
1687
- type: "color",
1688
- value
1689
- }), $[2] = label, $[3] = t1, $[4] = value, $[5] = t2) : t2 = $[5];
1690
- let t3;
1691
- $[6] === title ? t3 = $[7] : (t3 = /* @__PURE__ */ jsx(Text, {
1692
- size: 1,
1693
- children: title
1694
- }), $[6] = title, $[7] = t3);
1695
- let t4 = adjusted !== void 0 && adjusted !== value ? ` → ${adjusted}` : "", t5 = auto ? ` · ${autoLabel}` : "", t6;
1696
- $[8] !== t4 || $[9] !== t5 || $[10] !== value ? (t6 = /* @__PURE__ */ jsxs(Text, {
1697
- muted: !0,
1698
- size: 0,
1699
- textOverflow: "ellipsis",
1700
- children: [
1701
- value,
1702
- t4,
1703
- t5
1704
- ]
1705
- }), $[8] = t4, $[9] = t5, $[10] = value, $[11] = t6) : t6 = $[11];
1706
- let t7;
1707
- $[12] !== t3 || $[13] !== t6 ? (t7 = /* @__PURE__ */ jsxs(Stack, {
1708
- className: colorRowText,
1709
- flex: 1,
1710
- gap: 2,
1711
- children: [t3, t6]
1712
- }), $[12] = t3, $[13] = t6, $[14] = t7) : t7 = $[14];
1713
- let t8;
1714
- $[15] !== auto || $[16] !== autoLabel || $[17] !== onClear ? (t8 = onClear && !auto && /* @__PURE__ */ jsx(TooltipButton, {
1715
- icon: ResetIcon,
1716
- mode: "bleed",
1717
- onClick: onClear,
2295
+ $[6] === text ? t2 = $[7] : (t2 = text.trim(), $[6] = text, $[7] = t2);
2296
+ let t3 = t2 === "", t4;
2297
+ $[8] !== add || $[9] !== t3 ? (t4 = /* @__PURE__ */ jsx(Button, {
2298
+ disabled: t3,
2299
+ onClick: add,
1718
2300
  padding: 2,
1719
- tooltip: `Reset to ${autoLabel}`
1720
- }), $[15] = auto, $[16] = autoLabel, $[17] = onClear, $[18] = t8) : t8 = $[18];
1721
- let t9;
1722
- return $[19] !== t2 || $[20] !== t7 || $[21] !== t8 ? (t9 = /* @__PURE__ */ jsxs(Flex, {
1723
- align: "center",
2301
+ text: "Add theme",
2302
+ tone: "primary"
2303
+ }), $[8] = add, $[9] = t3, $[10] = t4) : t4 = $[10];
2304
+ let t5;
2305
+ $[11] !== t1 || $[12] !== t4 ? (t5 = /* @__PURE__ */ jsxs(Flex, {
1724
2306
  gap: 2,
2307
+ justify: "flex-end",
2308
+ padding: 3,
2309
+ children: [t1, t4]
2310
+ }), $[11] = t1, $[12] = t4, $[13] = t5) : t5 = $[13];
2311
+ let t6;
2312
+ $[14] === add ? t6 = $[15] : (t6 = (event) => {
2313
+ event.preventDefault(), add();
2314
+ }, $[14] = add, $[15] = t6);
2315
+ let t7;
2316
+ $[16] === Symbol.for("react.memo_cache_sentinel") ? (t7 = /* @__PURE__ */ jsx(Text, {
2317
+ muted: !0,
2318
+ size: 1,
2319
+ children: "Paste the code someone copied from their themer — the theme is added to your list and applied."
2320
+ }), $[16] = t7) : t7 = $[16];
2321
+ let t8 = invalid ? "themer-paste-error" : void 0, t9 = invalid ? "That is not a theme code" : void 0, t10;
2322
+ $[17] === Symbol.for("react.memo_cache_sentinel") ? (t10 = (event_0) => {
2323
+ setText(event_0.currentTarget.value), setInvalid(!1);
2324
+ }, $[17] = t10) : t10 = $[17];
2325
+ let t11;
2326
+ $[18] !== invalid || $[19] !== t8 || $[20] !== t9 || $[21] !== text ? (t11 = /* @__PURE__ */ jsx(TextInput, {
2327
+ "aria-describedby": t8,
2328
+ "aria-invalid": invalid,
2329
+ "aria-label": "Theme code",
2330
+ autoFocus: !0,
2331
+ customValidity: t9,
2332
+ fontSize: 1,
2333
+ onChange: t10,
2334
+ placeholder: "AQ…",
2335
+ value: text
2336
+ }), $[18] = invalid, $[19] = t8, $[20] = t9, $[21] = text, $[22] = t11) : t11 = $[22];
2337
+ let t12;
2338
+ $[23] === invalid ? t12 = $[24] : (t12 = invalid && /* @__PURE__ */ jsx(Card, {
2339
+ id: "themer-paste-error",
2340
+ padding: 3,
2341
+ radius: 2,
2342
+ role: "alert",
2343
+ tone: "critical",
2344
+ children: /* @__PURE__ */ jsx(Text, {
2345
+ size: 1,
2346
+ children: "That is not a theme code — codes are one word, copied from a theme's menu."
2347
+ })
2348
+ }), $[23] = invalid, $[24] = t12);
2349
+ let t13;
2350
+ $[25] !== t11 || $[26] !== t12 ? (t13 = /* @__PURE__ */ jsxs(Stack, {
2351
+ gap: 3,
2352
+ padding: 4,
1725
2353
  children: [
1726
- t2,
1727
2354
  t7,
1728
- t8
2355
+ t11,
2356
+ t12
1729
2357
  ]
1730
- }), $[19] = t2, $[20] = t7, $[21] = t8, $[22] = t9) : t9 = $[22], t9;
2358
+ }), $[25] = t11, $[26] = t12, $[27] = t13) : t13 = $[27];
2359
+ let t14;
2360
+ $[28] !== t13 || $[29] !== t6 ? (t14 = /* @__PURE__ */ jsx("form", {
2361
+ onSubmit: t6,
2362
+ children: t13
2363
+ }), $[28] = t13, $[29] = t6, $[30] = t14) : t14 = $[30];
2364
+ let t15;
2365
+ return $[31] !== onClose || $[32] !== t14 || $[33] !== t5 ? (t15 = /* @__PURE__ */ jsx(Dialog, {
2366
+ animate: !0,
2367
+ footer: t5,
2368
+ header: "Add a shared theme",
2369
+ id: "themer-paste",
2370
+ onClickOutside: onClose,
2371
+ onClose,
2372
+ width: 0,
2373
+ children: t14
2374
+ }), $[31] = onClose, $[32] = t14, $[33] = t5, $[34] = t15) : t15 = $[34], t15;
1731
2375
  }
1732
2376
  var frame$1 = "_1wsn7882", menuCard = "_1wsn7884", menuSlot = "_1wsn7883", pickButton = "_1wsn7881", root = "_1wsn7880";
1733
2377
  /**
@@ -1751,13 +2395,13 @@ function scrollRefIntoViewIfNeeded(node) {
1751
2395
  * @internal
1752
2396
  */
1753
2397
  function ThemeCard(props) {
1754
- let $ = c(81), { active, theme } = props, { themes, send } = useThemer(), { scheme } = useRootTheme(), [menuOpen, setMenuOpen] = useState(!1), dragControls = useDragControls(), dragged = useRef(!1), t0;
2398
+ let $ = c(85), { active, theme } = props, { themes, send } = useThemer(), { copyTheme } = useThemeCodes(), { scheme } = useRootTheme(), [menuOpen, setMenuOpen] = useState(!1), dragControls = useDragControls(), dragged = useRef(!1), t0;
1755
2399
  $[0] === theme.title ? t0 = $[1] : (t0 = displayTitle(theme.title), $[0] = theme.title, $[1] = t0);
1756
2400
  let title = t0, t1;
1757
2401
  $[2] === theme.slug ? t1 = $[3] : (t1 = (candidate) => candidate.slug === theme.slug, $[2] = theme.slug, $[3] = t1);
1758
2402
  let index = themes.findIndex(t1), t2;
1759
2403
  $[4] !== index || $[5] !== send || $[6] !== themes ? (t2 = (to) => {
1760
- let order = themes.map(_temp$2);
2404
+ let order = themes.map(_temp$3);
1761
2405
  order.splice(to, 0, ...order.splice(index, 1)), send({
1762
2406
  type: "theme.reorder",
1763
2407
  order
@@ -1868,7 +2512,13 @@ function ThemeCard(props) {
1868
2512
  text: t26
1869
2513
  }), $[50] = t25, $[51] = t26, $[52] = t27) : t27 = $[52];
1870
2514
  let t28;
1871
- $[53] !== send || $[54] !== theme.slug || $[55] !== theme.source ? (t28 = theme.source !== "config" && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(MenuDivider, {}), /* @__PURE__ */ jsx(MenuItem, {
2515
+ $[53] !== copyTheme || $[54] !== theme ? (t28 = /* @__PURE__ */ jsx(MenuItem, {
2516
+ icon: ShareIcon,
2517
+ onClick: () => void copyTheme(theme),
2518
+ text: "Copy to share"
2519
+ }), $[53] = copyTheme, $[54] = theme, $[55] = t28) : t28 = $[55];
2520
+ let t29;
2521
+ $[56] !== send || $[57] !== theme.slug || $[58] !== theme.source ? (t29 = theme.source !== "config" && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(MenuDivider, {}), /* @__PURE__ */ jsx(MenuItem, {
1872
2522
  icon: TrashIcon,
1873
2523
  onClick: () => send({
1874
2524
  type: "theme.remove",
@@ -1876,27 +2526,28 @@ function ThemeCard(props) {
1876
2526
  }),
1877
2527
  text: "Remove",
1878
2528
  tone: "critical"
1879
- })] }), $[53] = send, $[54] = theme.slug, $[55] = theme.source, $[56] = t28) : t28 = $[56];
1880
- let t29;
1881
- $[57] !== t19 || $[58] !== t22 || $[59] !== t24 || $[60] !== t27 || $[61] !== t28 ? (t29 = /* @__PURE__ */ jsxs(Menu, { children: [
2529
+ })] }), $[56] = send, $[57] = theme.slug, $[58] = theme.source, $[59] = t29) : t29 = $[59];
2530
+ let t30;
2531
+ $[60] !== t19 || $[61] !== t22 || $[62] !== t24 || $[63] !== t27 || $[64] !== t28 || $[65] !== t29 ? (t30 = /* @__PURE__ */ jsxs(Menu, { children: [
1882
2532
  t19,
1883
2533
  t22,
1884
2534
  t23,
1885
2535
  t24,
1886
2536
  t27,
1887
- t28
1888
- ] }), $[57] = t19, $[58] = t22, $[59] = t24, $[60] = t27, $[61] = t28, $[62] = t29) : t29 = $[62];
1889
- let t30, t31;
1890
- $[63] === Symbol.for("react.memo_cache_sentinel") ? (t30 = () => setMenuOpen(!1), t31 = () => setMenuOpen(!0), $[63] = t30, $[64] = t31) : (t30 = $[63], t31 = $[64]);
1891
- let t32;
1892
- $[65] === scheme ? t32 = $[66] : (t32 = {
2537
+ t28,
2538
+ t29
2539
+ ] }), $[60] = t19, $[61] = t22, $[62] = t24, $[63] = t27, $[64] = t28, $[65] = t29, $[66] = t30) : t30 = $[66];
2540
+ let t31, t32;
2541
+ $[67] === Symbol.for("react.memo_cache_sentinel") ? (t31 = () => setMenuOpen(!1), t32 = () => setMenuOpen(!0), $[67] = t31, $[68] = t32) : (t31 = $[67], t32 = $[68]);
2542
+ let t33;
2543
+ $[69] === scheme ? t33 = $[70] : (t33 = {
1893
2544
  animate: !0,
1894
2545
  placement: "bottom-end",
1895
2546
  portal: !0,
1896
2547
  scheme
1897
- }, $[65] = scheme, $[66] = t32);
1898
- let t33;
1899
- $[67] !== t15 || $[68] !== t16 || $[69] !== t29 || $[70] !== t32 ? (t33 = /* @__PURE__ */ jsx(Tooltip, {
2548
+ }, $[69] = scheme, $[70] = t33);
2549
+ let t34;
2550
+ $[71] !== t15 || $[72] !== t16 || $[73] !== t30 || $[74] !== t33 ? (t34 = /* @__PURE__ */ jsx(Tooltip, {
1900
2551
  animate: !0,
1901
2552
  content: t13,
1902
2553
  placement: "bottom",
@@ -1908,21 +2559,21 @@ function ThemeCard(props) {
1908
2559
  children: /* @__PURE__ */ jsx(MenuButton, {
1909
2560
  button: t15,
1910
2561
  id: t16,
1911
- menu: t29,
1912
- onClose: t30,
1913
- onOpen: t31,
1914
- popover: t32
2562
+ menu: t30,
2563
+ onClose: t31,
2564
+ onOpen: t32,
2565
+ popover: t33
1915
2566
  })
1916
2567
  })
1917
- }), $[67] = t15, $[68] = t16, $[69] = t29, $[70] = t32, $[71] = t33) : t33 = $[71];
1918
- let t34;
1919
- $[72] !== menuOpen || $[73] !== t33 ? (t34 = /* @__PURE__ */ jsx("div", {
2568
+ }), $[71] = t15, $[72] = t16, $[73] = t30, $[74] = t33, $[75] = t34) : t34 = $[75];
2569
+ let t35;
2570
+ $[76] !== menuOpen || $[77] !== t34 ? (t35 = /* @__PURE__ */ jsx("div", {
1920
2571
  className: menuSlot,
1921
2572
  "data-visible": menuOpen,
1922
- children: t33
1923
- }), $[72] = menuOpen, $[73] = t33, $[74] = t34) : t34 = $[74];
1924
- let t35;
1925
- return $[75] !== dragControls || $[76] !== t12 || $[77] !== t34 || $[78] !== t5 || $[79] !== theme.slug ? (t35 = /* @__PURE__ */ jsxs(Reorder.Item, {
2573
+ children: t34
2574
+ }), $[76] = menuOpen, $[77] = t34, $[78] = t35) : t35 = $[78];
2575
+ let t36;
2576
+ return $[79] !== dragControls || $[80] !== t12 || $[81] !== t35 || $[82] !== t5 || $[83] !== theme.slug ? (t36 = /* @__PURE__ */ jsxs(Reorder.Item, {
1926
2577
  as: "div",
1927
2578
  className: root,
1928
2579
  dragControls,
@@ -1932,10 +2583,10 @@ function ThemeCard(props) {
1932
2583
  ref: t5,
1933
2584
  value: theme.slug,
1934
2585
  whileDrag: t6,
1935
- children: [t12, t34]
1936
- }), $[75] = dragControls, $[76] = t12, $[77] = t34, $[78] = t5, $[79] = theme.slug, $[80] = t35) : t35 = $[80], t35;
2586
+ children: [t12, t35]
2587
+ }), $[79] = dragControls, $[80] = t12, $[81] = t35, $[82] = t5, $[83] = theme.slug, $[84] = t36) : t36 = $[84], t36;
1937
2588
  }
1938
- function _temp$2(candidate_0) {
2589
+ function _temp$3(candidate_0) {
1939
2590
  return candidate_0.slug;
1940
2591
  }
1941
2592
  var cardGrid = "twjqgh0";
@@ -1946,12 +2597,15 @@ var cardGrid = "twjqgh0";
1946
2597
  /**
1947
2598
  * The flow for picking a theme: a grid of theme cards — the configured theme,
1948
2599
  * the presets and the user's own themes — that drag into the order the user
1949
- * wants, with the entry points to the add and restore flows below.
2600
+ * wants, with the entry points to the add and restore flows below. A theme
2601
+ * code someone shared pastes right into the list, or through the paste button.
1950
2602
  *
1951
2603
  * @internal
1952
2604
  */
1953
2605
  function ThemeList() {
1954
- let $ = c(32), { themes, removed, active, send } = useThemer(), t0;
2606
+ let $ = c(38), { themes, removed, active, send } = useThemer(), { addThemeFromClipboard } = useThemeCodes(), [pasting, setPasting] = useState(!1);
2607
+ usePasteThemeCodes();
2608
+ let t0;
1955
2609
  $[0] === send ? t0 = $[1] : (t0 = (palette, file) => send({
1956
2610
  type: "theme.add",
1957
2611
  title: titleFromFileName(file.name),
@@ -1965,7 +2619,7 @@ function ThemeList() {
1965
2619
  order
1966
2620
  }), $[2] = send, $[3] = t1);
1967
2621
  let t2;
1968
- $[4] === themes ? t2 = $[5] : (t2 = themes.map(_temp$1), $[4] = themes, $[5] = t2);
2622
+ $[4] === themes ? t2 = $[5] : (t2 = themes.map(_temp$2), $[4] = themes, $[5] = t2);
1969
2623
  let t3;
1970
2624
  if ($[6] !== active || $[7] !== themes) {
1971
2625
  let t4;
@@ -1987,9 +2641,11 @@ function ThemeList() {
1987
2641
  }), $[11] = t1, $[12] = t2, $[13] = t3, $[14] = t4) : t4 = $[14];
1988
2642
  let t5;
1989
2643
  $[15] !== removed.length || $[16] !== send ? (t5 = removed.length > 0 && /* @__PURE__ */ jsx(Button, {
2644
+ gap: 2,
1990
2645
  icon: RestoreIcon,
1991
2646
  mode: "bleed",
1992
2647
  onClick: () => send({ type: "flow.removed" }),
2648
+ padding: 2,
1993
2649
  text: `Show removed (${removed.length})`,
1994
2650
  width: "fill"
1995
2651
  }), $[15] = removed.length, $[16] = send, $[17] = t5) : t5 = $[17];
@@ -2021,18 +2677,37 @@ function ThemeList() {
2021
2677
  tooltip: "Add a theme from the colors of an image"
2022
2678
  }), $[23] = busy, $[24] = pickImage, $[25] = t8) : t8 = $[25];
2023
2679
  let t9;
2024
- $[26] !== t7 || $[27] !== t8 ? (t9 = /* @__PURE__ */ jsx(Card, {
2680
+ $[26] === addThemeFromClipboard ? t9 = $[27] : (t9 = /* @__PURE__ */ jsx(TooltipButton, {
2681
+ icon: ClipboardIcon,
2682
+ mode: "ghost",
2683
+ onClick: async () => {
2684
+ await addThemeFromClipboard() !== "added" && setPasting(!0);
2685
+ },
2686
+ tooltip: "Add a theme from a code someone shared"
2687
+ }), $[26] = addThemeFromClipboard, $[27] = t9);
2688
+ let t10;
2689
+ $[28] !== t7 || $[29] !== t8 || $[30] !== t9 ? (t10 = /* @__PURE__ */ jsx(Card, {
2025
2690
  borderTop: !0,
2026
2691
  padding: 3,
2027
2692
  children: /* @__PURE__ */ jsxs(Flex, {
2028
2693
  gap: 2,
2029
- children: [t7, t8]
2694
+ children: [
2695
+ t7,
2696
+ t8,
2697
+ t9
2698
+ ]
2030
2699
  })
2031
- }), $[26] = t7, $[27] = t8, $[28] = t9) : t9 = $[28];
2032
- let t10;
2033
- return $[29] !== t6 || $[30] !== t9 ? (t10 = /* @__PURE__ */ jsxs(Fragment, { children: [t6, t9] }), $[29] = t6, $[30] = t9, $[31] = t10) : t10 = $[31], t10;
2700
+ }), $[28] = t7, $[29] = t8, $[30] = t9, $[31] = t10) : t10 = $[31];
2701
+ let t11;
2702
+ $[32] === pasting ? t11 = $[33] : (t11 = pasting && /* @__PURE__ */ jsx(PasteThemeDialog, { onClose: () => setPasting(!1) }), $[32] = pasting, $[33] = t11);
2703
+ let t12;
2704
+ return $[34] !== t10 || $[35] !== t11 || $[36] !== t6 ? (t12 = /* @__PURE__ */ jsxs(Fragment, { children: [
2705
+ t6,
2706
+ t10,
2707
+ t11
2708
+ ] }), $[34] = t10, $[35] = t11, $[36] = t6, $[37] = t12) : t12 = $[37], t12;
2034
2709
  }
2035
- function _temp$1(theme) {
2710
+ function _temp$2(theme) {
2036
2711
  return theme.slug;
2037
2712
  }
2038
2713
  /**
@@ -2092,9 +2767,11 @@ function ThemeSnippetDialog(props) {
2092
2767
  justify: "flex-end",
2093
2768
  padding: 3,
2094
2769
  children: /* @__PURE__ */ jsx(Button, {
2770
+ gap: 2,
2095
2771
  icon: ClipboardIcon,
2096
2772
  mode: "ghost",
2097
2773
  onClick: () => void handleCopy(),
2774
+ padding: 2,
2098
2775
  text: "Copy"
2099
2776
  })
2100
2777
  }), $[5] = handleCopy, $[6] = t2);
@@ -2367,129 +3044,25 @@ function ResizableSidebar(props) {
2367
3044
  children: t10
2368
3045
  }), $[20] = overlay, $[21] = t10, $[22] = t6, $[23] = t11) : t11 = $[23], t11;
2369
3046
  }
2370
- const STORAGE_KEY = "sanityStudio:themer:state", LEGACY_STORAGE_KEY = "sanityStudio:themer:options";
2371
- function sanitizeColor(value) {
2372
- return typeof value == "string" && isColor(value) ? value.toLowerCase() : null;
2373
- }
2374
- function sanitizeContrast(value) {
2375
- return typeof value == "number" && Number.isFinite(value) ? Math.min(100, Math.max(15, value)) : null;
2376
- }
2377
- function sanitizeSchemeOptions(value) {
2378
- let options = {};
2379
- if (!value || typeof value != "object") return options;
2380
- 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"));
2381
- return accent && (options.accent = accent), text && (options.text = text), background && (options.background = background), contrast !== null && (options.contrast = contrast), options;
2382
- }
2383
- /**
2384
- * Options from before they were grouped by scheme: one accent, text color and
2385
- * contrast for both schemes, and a background per scheme. Both schemes get
2386
- * the shared colors, so the theme keeps looking the same.
2387
- */
2388
- function sanitizeFlatOptions(value) {
2389
- let accent = sanitizeColor(Reflect.get(value, "accent"));
2390
- if (!accent) return null;
2391
- let shared = { accent }, text = sanitizeColor(Reflect.get(value, "text")), contrast = sanitizeContrast(Reflect.get(value, "contrast"));
2392
- text && (shared.text = text), contrast !== null && (shared.contrast = contrast);
2393
- let backgrounds = Reflect.get(value, "background"), options = {};
2394
- for (let scheme of SCHEMES) {
2395
- let background = backgrounds && typeof backgrounds == "object" ? sanitizeColor(Reflect.get(backgrounds, scheme)) : null;
2396
- options[scheme] = background ? {
2397
- ...shared,
2398
- background
2399
- } : { ...shared };
2400
- }
2401
- return options;
2402
- }
2403
- function sanitizeOptions(value) {
2404
- if (!value || typeof value != "object") return null;
2405
- if ("accent" in value) return sanitizeFlatOptions(value);
2406
- let options = {};
2407
- for (let scheme of SCHEMES) {
2408
- let schemeOptions = sanitizeSchemeOptions(Reflect.get(value, scheme));
2409
- Object.keys(schemeOptions).length > 0 && (options[scheme] = schemeOptions);
2410
- }
2411
- return options;
2412
- }
2413
- function isReservedSlug(slug) {
2414
- return slug === "config" || presets.some((preset) => preset.slug === slug);
2415
- }
2416
- function sanitizeCustomTheme(value) {
2417
- if (!value || typeof value != "object") return null;
2418
- let slug = Reflect.get(value, "slug");
2419
- if (typeof slug != "string" || !slug || isReservedSlug(slug)) return null;
2420
- let options = sanitizeOptions(Reflect.get(value, "options"));
2421
- if (!options) return null;
2422
- let title = Reflect.get(value, "title"), palette = sanitizePalette(Reflect.get(value, "palette"));
2423
- return {
2424
- slug,
2425
- title: displayTitle(typeof title == "string" ? title : ""),
2426
- options,
2427
- ...palette ? { palette } : {}
2428
- };
2429
- }
2430
- function sanitizePalette(value) {
2431
- if (!value || typeof value != "object") return null;
2432
- let palette = {}, swatches = 0;
2433
- for (let key of IMAGE_PALETTE_KEYS) {
2434
- let swatch = sanitizeColor(Reflect.get(value, key));
2435
- palette[key] = swatch, swatch && swatches++;
2436
- }
2437
- return swatches > 0 ? palette : null;
3047
+ const QUERY = "(prefers-reduced-motion: reduce)";
3048
+ function subscribe(onChange) {
3049
+ let media = matchMedia(QUERY);
3050
+ return media.addEventListener("change", onChange), () => media.removeEventListener("change", onChange);
2438
3051
  }
2439
- function sanitizeState(value) {
2440
- if (!value || typeof value != "object") return initialThemerState;
2441
- let custom = [], rawCustom = Reflect.get(value, "custom");
2442
- if (Array.isArray(rawCustom)) for (let item of rawCustom) {
2443
- let theme = sanitizeCustomTheme(item);
2444
- theme && !custom.some((existing) => existing.slug === theme.slug) && custom.push(theme);
2445
- }
2446
- let removed = [], rawRemoved = Reflect.get(value, "removed");
2447
- 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);
2448
- let order = [], rawOrder = Reflect.get(value, "order");
2449
- 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);
2450
- let rawActive = Reflect.get(value, "active");
2451
- return {
2452
- active: typeof rawActive == "string" ? rawActive : null,
2453
- custom,
2454
- removed,
2455
- order
2456
- };
3052
+ function getSnapshot() {
3053
+ return matchMedia(QUERY).matches;
2457
3054
  }
2458
- function readLegacyState() {
2459
- let raw = localStorage.getItem(LEGACY_STORAGE_KEY);
2460
- if (!raw) return null;
2461
- let options = sanitizeOptions(JSON.parse(raw));
2462
- if (!options) return null;
2463
- let theme = createCustomTheme("Draft theme", options);
2464
- return {
2465
- active: theme.slug,
2466
- custom: [theme],
2467
- removed: [],
2468
- order: []
2469
- };
3055
+ function getServerSnapshot() {
3056
+ return !1;
2470
3057
  }
2471
3058
  /**
2472
- * Restores the themer state from localStorage, so the user's themes survive
2473
- * studio reloads. Falls back to the initial state when nothing usable is
2474
- * stored.
3059
+ * Whether the user prefers reduced motion — live, so that turning the
3060
+ * setting on while the Studio is open takes effect right away.
2475
3061
  *
2476
3062
  * @internal
2477
3063
  */
2478
- function readStoredState() {
2479
- try {
2480
- if (typeof localStorage > "u") return initialThemerState;
2481
- let raw = localStorage.getItem(STORAGE_KEY);
2482
- return raw ? sanitizeState(JSON.parse(raw)) : readLegacyState() ?? initialThemerState;
2483
- } catch {
2484
- return initialThemerState;
2485
- }
2486
- }
2487
- /** @internal */
2488
- function writeStoredState(state) {
2489
- try {
2490
- if (typeof localStorage > "u") return;
2491
- localStorage.setItem(STORAGE_KEY, JSON.stringify(state)), localStorage.removeItem(LEGACY_STORAGE_KEY);
2492
- } catch {}
3064
+ function usePrefersReducedMotion() {
3065
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
2493
3066
  }
2494
3067
  /**
2495
3068
  * The height of the Studio navbar inside the given element, in pixels — kept
@@ -2519,18 +3092,54 @@ function useStudioNavbarHeight(ref) {
2519
3092
  };
2520
3093
  }, t1 = [ref], $[0] = ref, $[1] = t0, $[2] = t1), useEffect(t0, t1), height;
2521
3094
  }
2522
- var PANEL_TRANSITION = "_1yq4ar71", SPLIT_TRANSITION = "_1yq4ar70", layout = "_1yq4ar79", panelTransitionClasses = {
2523
- slideIn: "_1yq4ar77",
2524
- slideOut: "_1yq4ar78"
3095
+ /**
3096
+ * `useDeferredValue` with a transition type: the returned value lags behind
3097
+ * `value` like a deferred one — rendered in a transition, which React
3098
+ * animates — and the view transition React starts for it carries the given
3099
+ * type (`document.startViewTransition({types})`), so a stylesheet tells it
3100
+ * from any other transition on the page through
3101
+ * `:active-view-transition-type()`.
3102
+ *
3103
+ * React only takes types from `startTransition`, and a deferred render is
3104
+ * not one. So while the deferred value lags, a transition of the type joins
3105
+ * it, with a state update of its own for the type to belong to: React renders
3106
+ * every pending transition in one go, and the commit — the view transition
3107
+ * — carries the types of all of them. The value itself stays with
3108
+ * `useDeferredValue`, which reads the latest `value` as the deferred render
3109
+ * runs: should `value` change back before that render commits, nothing
3110
+ * changes and nothing animates — a queued state update would commit the
3111
+ * stale value and animate it, only to animate back.
3112
+ *
3113
+ * @internal
3114
+ */
3115
+ function useTypedDeferredValue(value, type) {
3116
+ let $ = c(5), deferred = useDeferredValue(value), [, setTransitions] = useState(0), t0, t1;
3117
+ return $[0] !== deferred || $[1] !== type || $[2] !== value ? (t0 = () => {
3118
+ Object.is(deferred, value) || startTransition(() => {
3119
+ addTransitionType(type), setTransitions(_temp$1);
3120
+ });
3121
+ }, t1 = [
3122
+ deferred,
3123
+ type,
3124
+ value
3125
+ ], $[0] = deferred, $[1] = type, $[2] = value, $[3] = t0, $[4] = t1) : (t0 = $[3], t1 = $[4]), useEffect(t0, t1), deferred;
3126
+ }
3127
+ function _temp$1(count) {
3128
+ return count + 1;
3129
+ }
3130
+ var layout = "_1yq4ar78", layoutTransitionType = "sanity-themer", panelTransitionClasses = {
3131
+ slideIn: "_1yq4ar76",
3132
+ slideOut: "_1yq4ar77"
2525
3133
  }, splitTransitionClasses = {
2526
- resize: "_1yq4ar72",
2527
- slideIn: "_1yq4ar73",
2528
- slideOut: "_1yq4ar74",
2529
- dropIn: "_1yq4ar75",
2530
- dropOut: "_1yq4ar76"
3134
+ resize: "_1yq4ar70",
3135
+ crossfade: "_1yq4ar71",
3136
+ slideIn: "_1yq4ar72",
3137
+ slideOut: "_1yq4ar73",
3138
+ dropIn: "_1yq4ar74",
3139
+ dropOut: "_1yq4ar75"
2531
3140
  }, studioScheme = {
2532
- light: "_1yq4ar7a",
2533
- dark: "_1yq4ar7b"
3141
+ light: "_1yq4ar79",
3142
+ dark: "_1yq4ar7a"
2534
3143
  };
2535
3144
  function sameStoredState(a, b) {
2536
3145
  return a.active === b.active && a.custom === b.custom && a.removed === b.removed && a.order === b.order;
@@ -2561,10 +3170,21 @@ function sameView(a, b) {
2561
3170
  * or on top, on small screens — through React's view transitions (React
2562
3171
  * 19.3), styled in `ThemerLayout.css.ts`.
2563
3172
  *
3173
+ * The machine says what shows and when the layout is in motion (its `panel`,
3174
+ * `split` and `moving` tags); the layout defers what shows, which puts the
3175
+ * panel's and the copy's mounts in a transition — what lets React animate
3176
+ * them — and picks the view transition classes from the tags.
3177
+ *
3178
+ * Whatever else on the page has a `view-transition-name` — an avatar the
3179
+ * Studio names so it moves as one piece — comes along in step: every
3180
+ * transition the layout starts carries `layoutTransitionType`, which the
3181
+ * stylesheet keys on to move every group in the layout's time (see
3182
+ * `ThemerLayout.css.ts`).
3183
+ *
2564
3184
  * @internal
2565
3185
  */
2566
3186
  function ThemerLayout(props) {
2567
- let $ = c(87), { baseOptions, ...layoutProps } = props, t0;
3187
+ let $ = c(85), { baseOptions, ...layoutProps } = props, t0;
2568
3188
  $[0] === baseOptions ? t0 = $[1] : (t0 = () => ({
2569
3189
  baseOptions,
2570
3190
  stored: readStoredState()
@@ -2572,68 +3192,34 @@ function ThemerLayout(props) {
2572
3192
  let [input] = useState(t0), t1;
2573
3193
  $[2] === input ? t1 = $[3] : (t1 = { input }, $[2] = input, $[3] = t1);
2574
3194
  let [snapshot, send, actorRef] = useActor(themerMachine, t1), stored = useSelector(actorRef, selectStoredState, sameStoredState), view = useSelector(actorRef, selectView, sameView), t2;
2575
- $[4] === snapshot ? t2 = $[5] : (t2 = snapshot.matches({ sidebar: "open" }), $[4] = snapshot, $[5] = t2);
3195
+ $[4] === snapshot ? t2 = $[5] : (t2 = snapshot.hasTag("panel"), $[4] = snapshot, $[5] = t2);
2576
3196
  let open = t2, t3;
2577
- $[6] === snapshot ? t3 = $[7] : (t3 = snapshot.matches({ preview: "split" }), $[6] = snapshot, $[7] = t3);
2578
- let split = t3, { images } = snapshot.context, mobile = useMediaIndex() <= 1, studioRef = useRef(null), navbarHeight = useStudioNavbarHeight(studioRef), t4;
2579
- $[8] !== open || $[9] !== split ? (t4 = {
2580
- open,
2581
- split
2582
- }, $[8] = open, $[9] = split, $[10] = t4) : t4 = $[10];
2583
- let [shown, setShown] = useState(t4), [resizing, setResizing] = useState(!1), t5;
2584
- $[11] === Symbol.for("react.memo_cache_sentinel") ? (t5 = () => setResizing(!1), $[11] = t5) : t5 = $[11];
2585
- let stopResizing = t5, t6;
2586
- $[12] !== open || $[13] !== shown.open || $[14] !== shown.split || $[15] !== split ? (t6 = () => {
2587
- (shown.open !== open || shown.split !== split) && startTransition(() => {
2588
- shown.open !== open && addTransitionType(PANEL_TRANSITION), shown.split !== split && addTransitionType(SPLIT_TRANSITION), setShown({
2589
- open,
2590
- split
2591
- }), setResizing(!0);
2592
- });
2593
- }, $[12] = open, $[13] = shown.open, $[14] = shown.split, $[15] = split, $[16] = t6) : t6 = $[16];
2594
- let t7;
2595
- $[17] !== open || $[18] !== shown || $[19] !== split ? (t7 = [
2596
- open,
2597
- shown,
2598
- split
2599
- ], $[17] = open, $[18] = shown, $[19] = split, $[20] = t7) : t7 = $[20], useEffect(t6, t7);
2600
- let t8;
2601
- $[21] === resizing ? t8 = $[22] : (t8 = () => {
2602
- if (!resizing) return;
2603
- let timer = setTimeout(() => setResizing(!1), 500);
2604
- return () => clearTimeout(timer);
2605
- }, $[21] = resizing, $[22] = t8);
2606
- let t9;
2607
- $[23] !== resizing || $[24] !== shown ? (t9 = [resizing, shown], $[23] = resizing, $[24] = shown, $[25] = t9) : t9 = $[25], useEffect(t8, t9);
2608
- let t10, t11;
2609
- $[26] === stored ? (t10 = $[27], t11 = $[28]) : (t10 = () => writeStoredState(stored), t11 = [stored], $[26] = stored, $[27] = t10, $[28] = t11), useEffect(t10, t11);
2610
- let previousImages = useRef(images), t12, t13;
2611
- $[29] === images ? (t12 = $[30], t13 = $[31]) : (t12 = () => {
2612
- let current = new Set(Object.values(images));
2613
- for (let url of Object.values(previousImages.current)) current.has(url) || URL.revokeObjectURL(url);
2614
- previousImages.current = images;
2615
- }, t13 = [images], $[29] = images, $[30] = t12, $[31] = t13), useEffect(t12, t13);
2616
- let t14, t15;
2617
- $[32] === Symbol.for("react.memo_cache_sentinel") ? (t14 = () => () => {
2618
- for (let url_0 of Object.values(previousImages.current)) URL.revokeObjectURL(url_0);
2619
- }, t15 = [], $[32] = t14, $[33] = t15) : (t14 = $[32], t15 = $[33]), useEffect(t14, t15);
2620
- let t16;
2621
- $[34] !== baseOptions || $[35] !== stored ? (t16 = resolveThemes(stored, baseOptions), $[34] = baseOptions, $[35] = stored, $[36] = t16) : t16 = $[36];
2622
- let { themes, removed, active } = t16, activeOptions = active.source === "config" ? null : active.options, t17;
2623
- $[37] === activeOptions ? t17 = $[38] : (t17 = activeOptions === null ? null : buildTheme(activeOptions), $[37] = activeOptions, $[38] = t17);
2624
- let theme = t17, scheme = useColorSchemeValue(), oppositeScheme = scheme === "dark" ? "light" : "dark", t18;
2625
- $[39] !== scheme || $[40] !== theme?.v2?.color ? (t18 = () => {
2626
- let background = theme?.v2?.color[scheme].default.bg;
3197
+ $[6] === snapshot ? t3 = $[7] : (t3 = snapshot.hasTag("split"), $[6] = snapshot, $[7] = t3);
3198
+ let split = t3, t4;
3199
+ $[8] === snapshot ? t4 = $[9] : (t4 = snapshot.hasTag("moving"), $[8] = snapshot, $[9] = t4);
3200
+ let moving = t4, t5;
3201
+ $[10] === snapshot ? t5 = $[11] : (t5 = snapshot.hasTag("switching"), $[10] = snapshot, $[11] = t5);
3202
+ let switching = t5, { images } = snapshot.context, mobile = useMediaIndex() <= 1, studioRef = useRef(null), navbarHeight = useStudioNavbarHeight(studioRef), reduceMotion = usePrefersReducedMotion(), deferredOpen = useTypedDeferredValue(reduceMotion ? null : open, layoutTransitionType), deferredSplit = useTypedDeferredValue(reduceMotion ? null : split, layoutTransitionType), shownOpen = deferredOpen ?? open, shownSplit = deferredSplit ?? split, t6;
3203
+ $[12] !== baseOptions || $[13] !== stored ? (t6 = resolveThemes(stored, baseOptions), $[12] = baseOptions, $[13] = stored, $[14] = t6) : t6 = $[14];
3204
+ let { themes, removed, active } = t6, activeOptions = active.source === "config" ? null : active.options, t7;
3205
+ $[15] === activeOptions ? t7 = $[16] : (t7 = activeOptions === null ? null : buildTheme(activeOptions), $[15] = activeOptions, $[16] = t7);
3206
+ let theme = t7, deferredTheme = useTypedDeferredValue(reduceMotion ? void 0 : theme, layoutTransitionType), shownTheme = deferredTheme === void 0 ? theme : deferredTheme, scheme = useColorSchemeValue(), oppositeScheme = scheme === "dark" ? "light" : "dark", t8;
3207
+ $[17] !== scheme || $[18] !== shownTheme?.v2?.color ? (t8 = () => {
3208
+ let background = shownTheme?.v2?.color[scheme].default.bg;
2627
3209
  if (background === void 0) return;
2628
3210
  let { style } = document.body, previous = style.backgroundColor;
2629
3211
  return style.backgroundColor = background, () => {
2630
3212
  style.backgroundColor = previous;
2631
3213
  };
2632
- }, $[39] = scheme, $[40] = theme?.v2?.color, $[41] = t18) : t18 = $[41];
2633
- let t19;
2634
- $[42] !== scheme || $[43] !== theme ? (t19 = [scheme, theme], $[42] = scheme, $[43] = theme, $[44] = t19) : t19 = $[44], useEffect(t18, t19);
2635
- let t20;
2636
- $[45] !== active || $[46] !== baseOptions || $[47] !== images || $[48] !== mobile || $[49] !== navbarHeight || $[50] !== open || $[51] !== removed || $[52] !== send || $[53] !== split || $[54] !== themes || $[55] !== view ? (t20 = {
3214
+ }, $[17] = scheme, $[18] = shownTheme?.v2?.color, $[19] = t8) : t8 = $[19];
3215
+ let t9;
3216
+ $[20] !== scheme || $[21] !== shownTheme ? (t9 = [scheme, shownTheme], $[20] = scheme, $[21] = shownTheme, $[22] = t9) : t9 = $[22], useEffect(t8, t9);
3217
+ let t10, t11;
3218
+ $[23] === actorRef ? (t10 = $[24], t11 = $[25]) : (t10 = () => () => {
3219
+ for (let url of Object.values(actorRef.getSnapshot().context.images)) URL.revokeObjectURL(url);
3220
+ }, t11 = [actorRef], $[23] = actorRef, $[24] = t10, $[25] = t11), useEffect(t10, t11);
3221
+ let t12;
3222
+ $[26] !== active || $[27] !== baseOptions || $[28] !== images || $[29] !== mobile || $[30] !== navbarHeight || $[31] !== open || $[32] !== removed || $[33] !== send || $[34] !== split || $[35] !== themes || $[36] !== view ? (t12 = {
2637
3223
  baseOptions,
2638
3224
  themes,
2639
3225
  removed,
@@ -2645,69 +3231,95 @@ function ThemerLayout(props) {
2645
3231
  mobile,
2646
3232
  navbarHeight,
2647
3233
  send
2648
- }, $[45] = active, $[46] = baseOptions, $[47] = images, $[48] = mobile, $[49] = navbarHeight, $[50] = open, $[51] = removed, $[52] = send, $[53] = split, $[54] = themes, $[55] = view, $[56] = t20) : t20 = $[56];
2649
- let context = t20, studio = layoutProps.renderDefault(layoutProps), t21 = mobile ? "column" : "row", t22;
2650
- $[57] !== mobile || $[58] !== oppositeScheme || $[59] !== shown.open || $[60] !== shown.split || $[61] !== studio || $[62] !== theme ? (t22 = shown.open && /* @__PURE__ */ jsx(Activity, {
2651
- mode: shown.split ? "visible" : "hidden",
3234
+ }, $[26] = active, $[27] = baseOptions, $[28] = images, $[29] = mobile, $[30] = navbarHeight, $[31] = open, $[32] = removed, $[33] = send, $[34] = split, $[35] = themes, $[36] = view, $[37] = t12) : t12 = $[37];
3235
+ let context = t12, studio = layoutProps.renderDefault(layoutProps), t13;
3236
+ $[38] === send ? t13 = $[39] : (t13 = () => send({ type: "layout.transitioned" }), $[38] = send, $[39] = t13);
3237
+ let transitioned = t13, t14;
3238
+ $[40] !== mobile || $[41] !== moving || $[42] !== reduceMotion || $[43] !== switching ? (t14 = reduceMotion ? {
3239
+ copyEnter: "none",
3240
+ copyExit: "none",
3241
+ panelEnter: "none",
3242
+ panelExit: "none",
3243
+ update: "none",
3244
+ studioUpdate: "none"
3245
+ } : {
3246
+ copyEnter: mobile ? splitTransitionClasses.dropIn : splitTransitionClasses.slideIn,
3247
+ copyExit: mobile ? splitTransitionClasses.dropOut : splitTransitionClasses.slideOut,
3248
+ panelEnter: panelTransitionClasses.slideIn,
3249
+ panelExit: panelTransitionClasses.slideOut,
3250
+ update: switching ? splitTransitionClasses.crossfade : "none",
3251
+ studioUpdate: moving ? splitTransitionClasses.resize : switching ? splitTransitionClasses.crossfade : "none"
3252
+ }, $[40] = mobile, $[41] = moving, $[42] = reduceMotion, $[43] = switching, $[44] = t14) : t14 = $[44];
3253
+ let classes = t14, t15 = mobile ? "column" : "row", t16;
3254
+ $[45] !== classes.copyEnter || $[46] !== classes.copyExit || $[47] !== classes.update || $[48] !== mobile || $[49] !== oppositeScheme || $[50] !== shownOpen || $[51] !== shownSplit || $[52] !== shownTheme || $[53] !== studio || $[54] !== transitioned ? (t16 = shownOpen && /* @__PURE__ */ jsx(Activity, {
3255
+ mode: shownSplit ? "visible" : "hidden",
2652
3256
  children: /* @__PURE__ */ jsx(ViewTransition, {
2653
- enter: mobile ? splitTransitionClasses.dropIn : splitTransitionClasses.slideIn,
2654
- exit: mobile ? splitTransitionClasses.dropOut : splitTransitionClasses.slideOut,
2655
- update: "none",
3257
+ enter: classes.copyEnter,
3258
+ exit: classes.copyExit,
3259
+ onEnter: transitioned,
3260
+ onExit: transitioned,
3261
+ onUpdate: transitioned,
3262
+ update: classes.update,
2656
3263
  children: /* @__PURE__ */ jsx(StudioPreview, {
2657
3264
  borderBottom: mobile,
2658
3265
  borderRight: !mobile,
2659
3266
  scheme: oppositeScheme,
2660
- theme,
3267
+ theme: shownTheme,
2661
3268
  children: studio
2662
3269
  })
2663
3270
  }, "opposite")
2664
- }), $[57] = mobile, $[58] = oppositeScheme, $[59] = shown.open, $[60] = shown.split, $[61] = studio, $[62] = theme, $[63] = t22) : t22 = $[63];
2665
- let t23 = resizing ? splitTransitionClasses.resize : "none", t24;
2666
- $[64] !== studio || $[65] !== theme ? (t24 = /* @__PURE__ */ jsx(StudioPreview, {
3271
+ }), $[45] = classes.copyEnter, $[46] = classes.copyExit, $[47] = classes.update, $[48] = mobile, $[49] = oppositeScheme, $[50] = shownOpen, $[51] = shownSplit, $[52] = shownTheme, $[53] = studio, $[54] = transitioned, $[55] = t16) : t16 = $[55];
3272
+ let t17;
3273
+ $[56] !== shownTheme || $[57] !== studio ? (t17 = /* @__PURE__ */ jsx(StudioPreview, {
2667
3274
  ref: studioRef,
2668
- theme,
3275
+ theme: shownTheme,
2669
3276
  children: studio
2670
- }), $[64] = studio, $[65] = theme, $[66] = t24) : t24 = $[66];
3277
+ }), $[56] = shownTheme, $[57] = studio, $[58] = t17) : t17 = $[58];
3278
+ let t18;
3279
+ $[59] !== classes.studioUpdate || $[60] !== t17 || $[61] !== transitioned ? (t18 = /* @__PURE__ */ jsx(ViewTransition, {
3280
+ onUpdate: transitioned,
3281
+ update: classes.studioUpdate,
3282
+ children: t17
3283
+ }, "primary"), $[59] = classes.studioUpdate, $[60] = t17, $[61] = transitioned, $[62] = t18) : t18 = $[62];
3284
+ let t19 = shownOpen ? "visible" : "hidden", t20 = shownTheme ?? void 0, t21;
3285
+ $[63] === mobile ? t21 = $[64] : (t21 = /* @__PURE__ */ jsx(ResizableSidebar, { overlay: mobile }), $[63] = mobile, $[64] = t21);
3286
+ let t22;
3287
+ $[65] !== t20 || $[66] !== t21 ? (t22 = /* @__PURE__ */ jsx(ThemeProvider, {
3288
+ theme: t20,
3289
+ children: t21
3290
+ }), $[65] = t20, $[66] = t21, $[67] = t22) : t22 = $[67];
3291
+ let t23;
3292
+ $[68] !== classes.panelEnter || $[69] !== classes.panelExit || $[70] !== classes.update || $[71] !== t22 || $[72] !== transitioned ? (t23 = /* @__PURE__ */ jsx(ViewTransition, {
3293
+ enter: classes.panelEnter,
3294
+ exit: classes.panelExit,
3295
+ onEnter: transitioned,
3296
+ onExit: transitioned,
3297
+ onUpdate: transitioned,
3298
+ update: classes.update,
3299
+ children: t22
3300
+ }, "panel"), $[68] = classes.panelEnter, $[69] = classes.panelExit, $[70] = classes.update, $[71] = t22, $[72] = transitioned, $[73] = t23) : t23 = $[73];
3301
+ let t24;
3302
+ $[74] !== t19 || $[75] !== t23 ? (t24 = /* @__PURE__ */ jsx(Activity, {
3303
+ mode: t19,
3304
+ children: t23
3305
+ }), $[74] = t19, $[75] = t23, $[76] = t24) : t24 = $[76];
2671
3306
  let t25;
2672
- $[67] !== stopResizing || $[68] !== t23 || $[69] !== t24 ? (t25 = /* @__PURE__ */ jsx(ViewTransition, {
2673
- onUpdate: stopResizing,
2674
- update: t23,
2675
- children: t24
2676
- }, "primary"), $[67] = stopResizing, $[68] = t23, $[69] = t24, $[70] = t25) : t25 = $[70];
2677
- let t26 = shown.open ? "visible" : "hidden", t27 = theme ?? void 0, t28;
2678
- $[71] === mobile ? t28 = $[72] : (t28 = /* @__PURE__ */ jsx(ResizableSidebar, { overlay: mobile }), $[71] = mobile, $[72] = t28);
2679
- let t29;
2680
- $[73] !== t27 || $[74] !== t28 ? (t29 = /* @__PURE__ */ jsx(ViewTransition, {
2681
- enter: panelTransitionClasses.slideIn,
2682
- exit: panelTransitionClasses.slideOut,
2683
- update: "none",
2684
- children: /* @__PURE__ */ jsx(ThemeProvider, {
2685
- theme: t27,
2686
- children: t28
2687
- })
2688
- }, "panel"), $[73] = t27, $[74] = t28, $[75] = t29) : t29 = $[75];
2689
- let t30;
2690
- $[76] !== t26 || $[77] !== t29 ? (t30 = /* @__PURE__ */ jsx(Activity, {
2691
- mode: t26,
2692
- children: t29
2693
- }), $[76] = t26, $[77] = t29, $[78] = t30) : t30 = $[78];
2694
- let t31;
2695
- $[79] !== t21 || $[80] !== t22 || $[81] !== t25 || $[82] !== t30 ? (t31 = /* @__PURE__ */ jsxs(Flex, {
3307
+ $[77] !== t15 || $[78] !== t16 || $[79] !== t18 || $[80] !== t24 ? (t25 = /* @__PURE__ */ jsxs(Flex, {
2696
3308
  className: layout,
2697
- direction: t21,
3309
+ direction: t15,
2698
3310
  height: "fill",
2699
3311
  sizing: "border",
2700
3312
  children: [
2701
- t22,
2702
- t25,
2703
- t30
3313
+ t16,
3314
+ t18,
3315
+ t24
2704
3316
  ]
2705
- }), $[79] = t21, $[80] = t22, $[81] = t25, $[82] = t30, $[83] = t31) : t31 = $[83];
2706
- let t32;
2707
- return $[84] !== context || $[85] !== t31 ? (t32 = /* @__PURE__ */ jsx(ThemerContext.Provider, {
3317
+ }), $[77] = t15, $[78] = t16, $[79] = t18, $[80] = t24, $[81] = t25) : t25 = $[81];
3318
+ let t26;
3319
+ return $[82] !== context || $[83] !== t25 ? (t26 = /* @__PURE__ */ jsx(ThemerContext.Provider, {
2708
3320
  value: context,
2709
- children: t31
2710
- }), $[84] = context, $[85] = t31, $[86] = t32) : t32 = $[86], t32;
3321
+ children: t25
3322
+ }), $[82] = context, $[83] = t25, $[84] = t26) : t26 = $[84], t26;
2711
3323
  }
2712
3324
  /**
2713
3325
  * One copy of the Studio, in the given color scheme — or, without one, in the