@sanity/themer 0.8.1 → 0.10.0

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/README.md CHANGED
@@ -2,13 +2,28 @@
2
2
 
3
3
  Generate [Sanity Studio](https://www.sanity.io/studio) themes from a handful of colors.
4
4
 
5
+ **Pick your starting point:**
6
+
7
+ - **I want to pick colors visually** → [Use the Studio tool](#studio-tool).
8
+ - **I want to configure a theme in code** → [Build a theme](#usage).
9
+ - **I want a ready-made theme** → [Use a preset](#use-a-preset).
10
+ - **I use the hosted Themer service** → [Migrate an existing theme](#migrating-from-themersanitybuild).
11
+
12
+ ## Install
13
+
5
14
  ```sh
6
15
  npm install @sanity/themer
7
16
  ```
8
17
 
18
+ **Using the Studio tool?** It requires React and React DOM **19.3 or newer** within the supported React 19 range.
19
+
9
20
  ## Usage
10
21
 
11
- `buildTheme` builds the same type of theme as `buildTheme` from `@sanity/ui/theme` — ready for the `theme` property of a Studio config — but takes a handful of colors per color scheme instead of design tokens. Under the hood it only swaps out the color palette that `@sanity/ui/theme` otherwise fills with [`@sanity/color`](https://www.sanity.io/docs/color), once for each scheme:
22
+ ### Build a theme
23
+
24
+ Call `buildTheme` with your colors, then pass the result to your Studio config's `theme` property.
25
+
26
+ Light and dark mode have separate settings. You can customize either or both.
12
27
 
13
28
  ```ts
14
29
  import {buildTheme} from '@sanity/themer'
@@ -33,27 +48,84 @@ export default defineConfig({
33
48
  })
34
49
  ```
35
50
 
36
- Everything is optional — both schemes, and every color in them. Whatever is omitted falls back to the stock Studio colors, so `buildTheme({})` matches `buildTheme()` from `@sanity/ui/theme` with no options exactly, and the two schemes are free to differ in every color rather than only their background:
51
+ **You don't need to set everything.** Both schemes and every option are optional.
52
+
53
+ - Leave out a scheme to keep its default Studio colors.
54
+ - Leave out `accent` or `background` to use the Studio default.
55
+ - Leave out `text` to derive a subtle tint from the accent.
56
+ - Use `buildTheme({})` to get the default Studio theme.
57
+
58
+ ### What each option does
37
59
 
38
- - `accent` replaces the `blue` scale of the scheme's palette, which Sanity UI uses for primary buttons, focus rings and links.
39
- - `text` replaces the `gray` scale — text, icons, borders and neutral surfaces. When omitted it is derived from `accent`: a mostly desaturated version of it, the way the stock gray carries a hint of the stock blue.
40
- - `background` replaces `black` in the dark scheme and `white` in the light scheme — the background that every other color of the scheme blends onto.
41
- - `contrast` controls how strongly text and borders separate from the accent. The default `85` uses the text color as-is; `100` removes its tint entirely (a high contrast scheme with no mixing of text and accent), and lower values blend more and more of the accent into the text scale, giving text and borders more color.
60
+ Colors are hex values, such as `#f00` or `#ff0000`.
42
61
 
43
- A few ground rules keep the generated palettes usable: the accent and text colors cannot be too dark or too light (they would mess with the rest of their scales), the dark background is made darker until it has enough contrast with both of them (it can never be lighter than either), and the light background can never be darker than either.
62
+ | Option | What it changes | Default |
63
+ | ------------ | ------------------------------------------ | -------------------------------------------- |
64
+ | `accent` | Primary buttons, focus rings, and links | Studio blue |
65
+ | `text` | Text, icons, borders, and neutral surfaces | A mostly desaturated accent |
66
+ | `background` | The background other colors blend onto | White in light mode; near-black in dark mode |
67
+ | `contrast` | How much accent tint text and borders have | `85` |
44
68
 
45
- `buildPalette` returns the generated `@sanity/color`-shaped palettes (`{light, dark}`) without building a theme from them, and `presets` ships the hosted Themer service presets translated to `buildTheme` options:
69
+ **The contrast slider runs from `15` to `100`:**
70
+
71
+ - **`85`** — keeps the text color's tint as-is.
72
+ - **`100`** — removes the tint for neutral text and borders.
73
+ - **Below `85`** — blends more accent color into text and borders.
74
+
75
+ Themer may adjust your colors to keep the palette usable:
76
+
77
+ - Very dark or very light accent and text colors are brought into a usable range.
78
+ - Dark backgrounds are darkened until they have enough contrast with text and accent colors.
79
+ - Light backgrounds are kept lighter than text and accent colors.
80
+
81
+ ### Use a preset
82
+
83
+ `presets` includes the hosted Themer service's presets, ready to use with `buildTheme`.
46
84
 
47
85
  ```ts
48
86
  import {buildTheme, presets} from '@sanity/themer'
49
87
 
50
88
  const verdant = presets.find((preset) => preset.slug === 'verdant')
51
- const theme = buildTheme(verdant.options)
89
+ const theme = buildTheme(verdant?.options)
52
90
  ```
53
91
 
92
+ ### Get palettes without building a theme
93
+
94
+ Use `buildPalette` if you only need the generated colors. It returns `{light, dark}` palettes in the `@sanity/color` shape.
95
+
96
+ ```ts
97
+ import {buildPalette} from '@sanity/themer'
98
+
99
+ const {light, dark} = buildPalette({
100
+ light: {accent: '#f00'},
101
+ dark: {accent: '#f66'},
102
+ })
103
+ ```
104
+
105
+ <details>
106
+ <summary>How this relates to Sanity UI themes</summary>
107
+
108
+ `buildTheme` returns the same theme type as `buildTheme` from `@sanity/ui/theme`. Instead of design tokens, you provide a few colors per scheme.
109
+
110
+ Under the hood, Themer replaces parts of the default [`@sanity/color`](https://www.sanity.io/docs/color) palette:
111
+
112
+ - `accent` replaces the `blue` scale.
113
+ - `text` replaces the `gray` scale.
114
+ - `background` replaces `black` for dark mode and `white` for light mode.
115
+
116
+ The other color scales keep their defaults. With no options, the result matches `buildTheme()` from `@sanity/ui/theme` exactly.
117
+
118
+ </details>
119
+
54
120
  ## Studio tool
55
121
 
56
- `@sanity/themer/tool` adds a themer sidebar to the Studio for these themes. It lists the configured theme, the presets and your own themes, each previewed as a tiny Studio in both color schemes, and picking one applies it live to the whole Studio while you browse around — in the appearance the Studio is set to or, with the split-screen toggle in the sidebar header, twice: the Studio next to the sidebar keeps the appearance it is set to, and a copy in the opposite scheme slides in from off screen on the far side (or on top, on small screens, where the sidebar covers the Studio instead of standing next to it), through React's `ViewTransition` — which is why the tool requires React 19.3. The cards drag into any order (or move from their menu), which sticks between sessions, and a theme's menu copies it as a short code to share — paste a code into the list, or add it through the paste button, to get the theme in another Studio. Your own themes are edited scheme by scheme — a light mode and a dark mode card with accent/text/background pickers and a contrast slider each, the one the Studio is showing marked as active — add one from scratch, duplicate a preset to start from it, or take the colors from an image: its palette is read on device (the image is drawn onto a canvas and its vibrant and muted colors extracted right there, nothing is uploaded), the vibrant color becomes the accent (adjusted until a button label reads on it), the muted one the text color and the light and dark muted colors tint the backgrounds, and a row of variants previews the theme built around each swatch instead — with an "I'm feeling lucky" button that picks an interesting one. Themes can be removed and restored, and a dialog shows the `buildTheme` snippet that makes the applied theme permanent:
122
+ **Pick, edit, and preview themes without writing color values by hand.**
123
+
124
+ The tool adds a sidebar to your Studio. Selecting a theme previews it live across the Studio while you browse.
125
+
126
+ ### 1. Add the tool
127
+
128
+ Add `themerTool()` to your Studio's plugins:
57
129
 
58
130
  ```ts
59
131
  import {themerTool} from '@sanity/themer/tool'
@@ -65,21 +137,89 @@ export default defineConfig({
65
137
  })
66
138
  ```
67
139
 
68
- If the Studio already uses a `buildTheme` theme, pass the same options so the tool starts editing from them: `themerTool({config: {light: {accent: '#1cb485'}}})`.
140
+ The navbar toggle is called **Themer**. To rename it, pass a title: `themerTool({title: 'Appearance'})`.
69
141
 
70
- The tool's styles are a stylesheet, `@sanity/themer/bundle.css`, that `@sanity/themer/tool` imports itself — the Studio's bundler picks it up, nothing to add to the config. The sidebar's code loads the first time it's needed, starting as the pointer moves onto the navbar toggle — pressed before the code has arrived, the toggle's color wheel goes round until it has — so the tool adds little to the Studio's startup. The sidebar slides in from the edge (and closing it ends the split preview), its header takes the height of the Studio navbar so the two line up, and with `prefers-reduced-motion` the sidebar and the split preview switch layouts without animating.
142
+ **No CSS setup needed.** The tool imports `@sanity/themer/bundle.css` automatically.
143
+
144
+ ### 2. Try a theme
145
+
146
+ Open **Themer** in the navbar. The sidebar shows:
147
+
148
+ - Your configured Studio theme.
149
+ - Built-in presets.
150
+ - Your own themes.
151
+
152
+ Each card previews a tiny Studio in both light and dark mode. Select a card to apply its theme live.
153
+
154
+ **Want to compare modes?** Use the split-screen toggle in the sidebar header. Your Studio keeps its current appearance, and a second view shows the opposite mode beside it.
155
+
156
+ ### 3. Make it yours
157
+
158
+ Start from scratch, duplicate a preset, or [use colors from an image](#use-colors-from-an-image).
159
+
160
+ Your custom theme has separate **light** and **dark** mode cards. Each has:
161
+
162
+ - Accent, text, and background color pickers.
163
+ - A contrast slider.
164
+ - An active marker when the Studio is showing that mode.
165
+
166
+ ### 4. Make it permanent
167
+
168
+ **Previewing a theme is not the same as configuring it.**
169
+
170
+ Use the tool's code dialog to get the `buildTheme` snippet, then add it to your Studio config's `theme` property.
171
+
172
+ ### Use colors from an image
173
+
174
+ Choose an image to generate a starting palette.
175
+
176
+ **Your image stays on your device. Nothing is uploaded.** The tool reads its colors locally using a canvas.
177
+
178
+ - A vibrant color becomes the accent, adjusted so button labels remain readable.
179
+ - A muted color becomes the text color.
180
+ - Light and dark muted colors tint the backgrounds.
181
+
182
+ Try the swatch variants to preview different starting colors, or select **I'm feeling lucky** to let the tool pick one.
183
+
184
+ ### Organize and share themes
185
+
186
+ - **Reorder:** drag cards, or move them from their menus. The order is saved between sessions.
187
+ - **Share:** copy a theme's short code from its menu.
188
+ - **Import:** paste a code into the list, or use the paste button—even in another Studio.
189
+ - **Remove or restore:** themes can be removed and brought back.
190
+
191
+ <details>
192
+ <summary>Loading, animation, and reduced motion</summary>
193
+
194
+ The sidebar code loads only when needed. Loading starts when your pointer moves onto the navbar toggle. If you open it before loading finishes, the color wheel spins while you wait.
195
+
196
+ The sidebar slides in from the edge, with its header aligned to the Studio navbar. Closing it also closes the split preview.
197
+
198
+ The split preview uses React's `ViewTransition`, which is why the tool requires React 19.3.
199
+
200
+ With `prefers-reduced-motion`, both the sidebar and split preview change layouts without animation.
201
+
202
+ </details>
71
203
 
72
204
  ## Migrating from themer.sanity.build
73
205
 
74
- The npm migration path off the hosted Themer service ([themer.sanity.build](https://themer.sanity.build)) is [`@sanity/themer-legacy`](https://www.npmjs.com/package/@sanity/themer-legacy): the exact same generator, running locally.
206
+ **Want to keep your existing hosted theme?** Use [`@sanity/themer-legacy`](https://www.npmjs.com/package/@sanity/themer-legacy). It runs the same generator as [themer.sanity.build](https://themer.sanity.build), but locally.
75
207
 
76
- `@sanity/themer/legacy` still works as a deprecated re-export of that package until `@sanity/themer@1.0` removes it; import from `@sanity/themer-legacy` instead. Migrating a hosted URL import looks like this:
208
+ 1. Install the legacy package:
77
209
 
78
- ```diff
79
- -import {theme} from 'https://themer.sanity.build/api/hues?preset=verdant&primary=22fca8'
80
- +import {buildThemeFromUrl} from '@sanity/themer-legacy'
81
- +const theme = buildThemeFromUrl('https://themer.sanity.build/api/hues?preset=verdant&primary=22fca8')
82
- ```
210
+ ```sh
211
+ npm install @sanity/themer-legacy
212
+ ```
213
+
214
+ 2. Replace your hosted URL import:
215
+
216
+ ```diff
217
+ -import {theme} from 'https://themer.sanity.build/api/hues?preset=verdant&primary=22fca8'
218
+ +import {buildThemeFromUrl} from '@sanity/themer-legacy'
219
+ +const theme = buildThemeFromUrl('https://themer.sanity.build/api/hues?preset=verdant&primary=22fca8')
220
+ ```
221
+
222
+ **Already importing from `@sanity/themer/legacy`?** Switch to `@sanity/themer-legacy`. The old import is deprecated and will be removed in `@sanity/themer@1.0`.
83
223
 
84
224
  ## License
85
225
 
@@ -0,0 +1,171 @@
1
+ import { blue, cyan, green, magenta, orange, purple, red, yellow } from "@sanity/color";
2
+ import { c } from "react/compiler-runtime";
3
+ import { useEffect, useImperativeHandle } from "react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { animate, motion, useMotionValue, useTransform } from "motion/react";
6
+ /**
7
+ * The colors the slices fill with, clockwise from 12 o'clock: the palette's
8
+ * hues in rainbow order, like a real color swatch
9
+ */
10
+ const SLICE_COLORS = [
11
+ red[500].hex,
12
+ orange[500].hex,
13
+ yellow[400].hex,
14
+ green[500].hex,
15
+ cyan[500].hex,
16
+ blue[500].hex,
17
+ purple[500].hex,
18
+ magenta[500].hex
19
+ ], TOTAL_ACTS = 2 + (1 - Math.cbrt(1 / 8)), ANIMATION_DURATION = .55 * TOTAL_ACTS;
20
+ function actAt(progress) {
21
+ return progress * TOTAL_ACTS;
22
+ }
23
+ /**
24
+ * How far the wheel has turned at `progress` (0–1), in degrees.
25
+ *
26
+ * It stands still while the top half of the slices pop in. As the 6 o'clock
27
+ * slice pops in it starts spinning — one full turn with a cubic ease-in, from
28
+ * rest to top speed as the bottom half pop in — and it stands still again the
29
+ * moment it is back upright: the clearing of {@link isSliceFilled} takes off
30
+ * at the speed the spin reached, as if the wheel kept turning and eased out
31
+ * over a second turn, so the two read as one movement.
32
+ */
33
+ function wheelRotation(progress) {
34
+ let spin = actAt(progress) - 1;
35
+ return spin <= 0 || spin >= 1 ? 0 : 360 * spin ** 3;
36
+ }
37
+ /**
38
+ * Whether slice `index` (clockwise from 12 o'clock) shows its color at
39
+ * `progress` (0–1). A slice pops in whole as the fill reaches its leading
40
+ * edge, and pops out whole as the clearing does — stepped, never partial.
41
+ *
42
+ * The fill goes clockwise at a steady pace over the first two acts, half of
43
+ * the slices in each. The clearing follows in the third act, the same way
44
+ * round, on a cubic ease-out that starts at the speed the spin of
45
+ * {@link wheelRotation} ends with: the slices at first pop out as quickly as
46
+ * they were passing by, then ever more slowly.
47
+ */
48
+ function isSliceFilled(progress, index) {
49
+ let act = actAt(progress);
50
+ if (act * 8 / 2 <= index) return !1;
51
+ let clearing = act - 2;
52
+ return clearing <= 0 || (1 - (1 - clearing) ** 3) * 8 < index;
53
+ }
54
+ /**
55
+ * The geometry of `ColorWheelIcon` from `@sanity/icons`: a ring between two
56
+ * circles around the center of a 25×25 viewBox, cut into eight slices by the
57
+ * spokes of its outline path
58
+ */
59
+ const CENTER = 12.5, INNER_RADIUS = 3.5;
60
+ /** A point on a circle around the center, at `degrees` clockwise from 3 o'clock */
61
+ function point(radius, degrees) {
62
+ let radians = degrees * Math.PI / 180;
63
+ return `${CENTER + radius * Math.cos(radians)} ${CENTER + radius * Math.sin(radians)}`;
64
+ }
65
+ /**
66
+ * The part of the ring a slice colors in: between its two spokes, from the
67
+ * inner circle out to the outer. The 12 o'clock slice is centered on 12, so
68
+ * it starts half a slice before it.
69
+ */
70
+ function slicePath(index) {
71
+ let start = index * 45 - 45 / 2 - 90, end = start + 45;
72
+ return [
73
+ `M${point(8, start)}`,
74
+ `A8 8 0 0 1 ${point(8, end)}`,
75
+ `L${point(INNER_RADIUS, end)}`,
76
+ `A${INNER_RADIUS} ${INNER_RADIUS} 0 0 0 ${point(INNER_RADIUS, start)}`,
77
+ "Z"
78
+ ].join("");
79
+ }
80
+ const SLICE_PATHS = SLICE_COLORS.map((_, index) => slicePath(index));
81
+ /**
82
+ * A filled shape, not a stroked one: the icon styles of `@sanity/ui` force
83
+ * `vector-effect: non-scaling-stroke` on every path in an icon, which fills
84
+ * are none the wiser about
85
+ */
86
+ function Slice(props) {
87
+ let $ = c(8), { index, progress } = props, t0;
88
+ $[0] === index ? t0 = $[1] : (t0 = (value) => +!!isSliceFilled(value, index), $[0] = index, $[1] = t0);
89
+ let opacity = useTransform(progress, t0), t1 = SLICE_PATHS[index], t2 = SLICE_COLORS[index], t3;
90
+ $[2] === opacity ? t3 = $[3] : (t3 = { opacity }, $[2] = opacity, $[3] = t3);
91
+ let t4;
92
+ return $[4] !== t1 || $[5] !== t2 || $[6] !== t3 ? (t4 = /* @__PURE__ */ jsx(motion.path, {
93
+ d: t1,
94
+ fill: t2,
95
+ style: t3
96
+ }), $[4] = t1, $[5] = t2, $[6] = t3, $[7] = t4) : t4 = $[7], t4;
97
+ }
98
+ /**
99
+ * The `ColorWheelIcon` of `@sanity/icons`, with an animation driven by
100
+ * `progress` (0–1): the slices pop in with color one at a time, clockwise
101
+ * from 12 o'clock like the segments of a classic spinner, the wheel spins once
102
+ * as the 6 o'clock slice pops in, and the colors pop out the same way round,
103
+ * carrying the spin's speed to a rest (see `colorWheel.ts`). At rest — at
104
+ * either end — it looks exactly like the icon it stands in for. With
105
+ * `prefers-reduced-motion: reduce` the navbar renders that icon instead of
106
+ * this one, so nothing here moves.
107
+ *
108
+ * @internal
109
+ */
110
+ function AnimatedColorWheelIcon(t0) {
111
+ let $ = c(18), { busy, ref } = t0, progress = useMotionValue(0), rotate = useTransform(progress, _temp), t1, t2;
112
+ $[0] === progress ? (t1 = $[1], t2 = $[2]) : (t1 = () => ({ spin: () => {
113
+ progress.isAnimating() || animate(progress, [0, 1], {
114
+ duration: ANIMATION_DURATION,
115
+ ease: "linear"
116
+ });
117
+ } }), t2 = [progress], $[0] = progress, $[1] = t1, $[2] = t2), useImperativeHandle(ref, t1, t2);
118
+ let t3, t4;
119
+ $[3] !== busy || $[4] !== progress ? (t3 = () => {
120
+ if (!busy) return;
121
+ let looping = !1, raf = setTimeout(() => {
122
+ looping = !0;
123
+ let lap = (from) => {
124
+ animate(progress, [from, 1], {
125
+ duration: (1 - from) * ANIMATION_DURATION,
126
+ ease: "linear",
127
+ onComplete: () => {
128
+ looping && lap(0);
129
+ }
130
+ });
131
+ };
132
+ lap(progress.get());
133
+ }, 160);
134
+ return () => {
135
+ clearTimeout(raf), looping = !1;
136
+ };
137
+ }, t4 = [busy, progress], $[3] = busy, $[4] = progress, $[5] = t3, $[6] = t4) : (t3 = $[5], t4 = $[6]), useEffect(t3, t4);
138
+ let t5, t6;
139
+ $[7] === progress ? (t5 = $[8], t6 = $[9]) : (t5 = () => () => progress.stop(), t6 = [progress], $[7] = progress, $[8] = t5, $[9] = t6), useEffect(t5, t6);
140
+ let t7;
141
+ $[10] === rotate ? t7 = $[11] : (t7 = { rotate }, $[10] = rotate, $[11] = t7);
142
+ let t8;
143
+ $[12] === progress ? t8 = $[13] : (t8 = SLICE_COLORS.map((color, index) => /* @__PURE__ */ jsx(Slice, {
144
+ index,
145
+ progress
146
+ }, color)), $[12] = progress, $[13] = t8);
147
+ let t9;
148
+ $[14] === Symbol.for("react.memo_cache_sentinel") ? (t9 = /* @__PURE__ */ jsx("path", {
149
+ d: "M9.43853 5.10896L11.1606 9.26642M13.8394 15.7336L15.5615 19.891M15.7336 11.1606L19.891 9.43853M9.26642 13.8394L5.10896 15.5615M5.3139 9.52342L9.23359 11.147M15.7664 13.853L19.6861 15.4766M13.853 9.23359L15.4766 5.3139M9.52342 19.6861L11.147 15.7664M20.5 12.5C20.5 16.9183 16.9183 20.5 12.5 20.5C8.08172 20.5 4.5 16.9183 4.5 12.5C4.5 8.08172 8.08172 4.5 12.5 4.5C16.9183 4.5 20.5 8.08172 20.5 12.5ZM16 12.5C16 14.433 14.433 16 12.5 16C10.567 16 9 14.433 9 12.5C9 10.567 10.567 9 12.5 9C14.433 9 16 10.567 16 12.5Z",
150
+ stroke: "currentColor",
151
+ strokeLinejoin: "round",
152
+ strokeWidth: 1.2
153
+ }), $[14] = t9) : t9 = $[14];
154
+ let t10;
155
+ return $[15] !== t7 || $[16] !== t8 ? (t10 = /* @__PURE__ */ jsxs(motion.svg, {
156
+ "data-sanity-icon": "color-wheel",
157
+ fill: "none",
158
+ height: "1em",
159
+ style: t7,
160
+ viewBox: "0 0 25 25",
161
+ width: "1em",
162
+ xmlns: "http://www.w3.org/2000/svg",
163
+ children: [t8, t9]
164
+ }), $[15] = t7, $[16] = t8, $[17] = t10) : t10 = $[17], t10;
165
+ }
166
+ function _temp(value) {
167
+ return wheelRotation(value);
168
+ }
169
+ export { AnimatedColorWheelIcon as default };
170
+
171
+ //# sourceMappingURL=AnimatedColorWheelIcon-CwWOVAsw.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AnimatedColorWheelIcon-CwWOVAsw.js","names":["(value) => (isSliceFilled(value, index) ? 1 : 0)","{opacity}","<motion.path d={SLICE_PATHS[index]} fill={SLICE_COLORS[index]} style={{opacity}} />","SLICE_PATHS[index]","SLICE_COLORS[index]","motion.path","{\n busy,\n ref,\n}: {\n busy: boolean\n ref: React.Ref<{spin: () => void}>\n}","(value) => wheelRotation(value)","() => ({\n spin: () => {\n // A run in progress plays out — hovering again does not cut it short\n if (progress.isAnimating()) return\n\n animate(progress, [0, 1], {duration: ANIMATION_DURATION, ease: 'linear'})\n },\n })","[progress]","() => {\n if (!busy) return undefined\n\n let looping = false\n const raf = setTimeout(() => {\n looping = true\n const lap = (from: number) => {\n animate(progress, [from, 1], {\n duration: (1 - from) * ANIMATION_DURATION,\n ease: 'linear',\n onComplete: () => {\n if (looping) lap(0)\n },\n })\n }\n\n lap(progress.get())\n }, 160)\n\n return () => {\n clearTimeout(raf)\n looping = false\n }\n }","[busy, progress]","() => () => progress.stop()","{rotate}","SLICE_COLORS.map((color, index) => (\n <Slice key={color} index={index} progress={progress} />\n ))","<path d={OUTLINE} stroke=\"currentColor\" strokeLinejoin=\"round\" strokeWidth={1.2} />","<motion.svg\n data-sanity-icon=\"color-wheel\"\n fill=\"none\"\n height=\"1em\"\n style={{rotate}}\n viewBox=\"0 0 25 25\"\n width=\"1em\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n {SLICE_COLORS.map((color, index) => (\n <Slice key={color} index={index} progress={progress} />\n ))}\n <path d={OUTLINE} stroke=\"currentColor\" strokeLinejoin=\"round\" strokeWidth={1.2} />\n </motion.svg>","motion.svg","_c"],"sources":["../src/tool/colorWheel.ts","../src/tool/AnimatedColorWheelIcon.tsx"],"sourcesContent":["import {blue, cyan, green, magenta, orange, purple, red, yellow} from '@sanity/color'\n\n/**\n * The spokes of the color wheel icon (`ColorWheelIcon` from `@sanity/icons`)\n * cut its ring into this many slices, one of them centered on 12 o'clock\n */\nexport const SLICE_COUNT = 8\n\n/**\n * The colors the slices fill with, clockwise from 12 o'clock: the palette's\n * hues in rainbow order, like a real color swatch\n */\nexport const SLICE_COLORS: readonly string[] = [\n red[500].hex,\n orange[500].hex,\n yellow[400].hex,\n green[500].hex,\n cyan[500].hex,\n blue[500].hex,\n purple[500].hex,\n magenta[500].hex,\n]\n\n/**\n * The animation plays in acts of this length, in seconds. In the first act\n * the top half of the slices pop in, one at a time like the segments of a\n * classic spinner; in the second the wheel spins up while the bottom half pop\n * in; in the third the colors pop out again as the spin's speed carries over\n * into the clearing.\n */\nconst ACT_DURATION = 0.55\n\n/**\n * The clearing would take a whole act to come to rest, but its last slice\n * pops out as the clearing reaches that slice's leading edge — with nothing\n * left to show, the animation ends there\n */\nconst CLEARING_ACTS = 1 - Math.cbrt(1 / SLICE_COUNT)\n\nconst TOTAL_ACTS = 2 + CLEARING_ACTS\n\n/** How long the whole animation takes, in seconds */\nexport const ANIMATION_DURATION = ACT_DURATION * TOTAL_ACTS\n\nfunction actAt(progress: number): number {\n return progress * TOTAL_ACTS\n}\n\n/**\n * How far the wheel has turned at `progress` (0–1), in degrees.\n *\n * It stands still while the top half of the slices pop in. As the 6 o'clock\n * slice pops in it starts spinning — one full turn with a cubic ease-in, from\n * rest to top speed as the bottom half pop in — and it stands still again the\n * moment it is back upright: the clearing of {@link isSliceFilled} takes off\n * at the speed the spin reached, as if the wheel kept turning and eased out\n * over a second turn, so the two read as one movement.\n */\nexport function wheelRotation(progress: number): number {\n const spin = actAt(progress) - 1\n\n if (spin <= 0 || spin >= 1) return 0\n\n return 360 * spin ** 3\n}\n\n/**\n * Whether slice `index` (clockwise from 12 o'clock) shows its color at\n * `progress` (0–1). A slice pops in whole as the fill reaches its leading\n * edge, and pops out whole as the clearing does — stepped, never partial.\n *\n * The fill goes clockwise at a steady pace over the first two acts, half of\n * the slices in each. The clearing follows in the third act, the same way\n * round, on a cubic ease-out that starts at the speed the spin of\n * {@link wheelRotation} ends with: the slices at first pop out as quickly as\n * they were passing by, then ever more slowly.\n */\nexport function isSliceFilled(progress: number, index: number): boolean {\n const act = actAt(progress)\n const filling = (act * SLICE_COUNT) / 2\n\n if (filling <= index) return false\n\n const clearing = act - 2\n\n if (clearing <= 0) return true\n\n return (1 - (1 - clearing) ** 3) * SLICE_COUNT < index\n}\n","import {animate, motion, type MotionValue, useMotionValue, useTransform} from 'motion/react'\nimport {useEffect, useImperativeHandle} from 'react'\n\nimport {\n isSliceFilled,\n SLICE_COLORS,\n SLICE_COUNT,\n wheelRotation,\n ANIMATION_DURATION,\n} from './colorWheel'\n\n/**\n * The geometry of `ColorWheelIcon` from `@sanity/icons`: a ring between two\n * circles around the center of a 25×25 viewBox, cut into eight slices by the\n * spokes of its outline path\n */\nconst CENTER = 12.5\nconst OUTER_RADIUS = 8\nconst INNER_RADIUS = 3.5\nconst OUTLINE =\n 'M9.43853 5.10896L11.1606 9.26642M13.8394 15.7336L15.5615 19.891M15.7336 11.1606L19.891 9.43853M9.26642 13.8394L5.10896 15.5615M5.3139 9.52342L9.23359 11.147M15.7664 13.853L19.6861 15.4766M13.853 9.23359L15.4766 5.3139M9.52342 19.6861L11.147 15.7664M20.5 12.5C20.5 16.9183 16.9183 20.5 12.5 20.5C8.08172 20.5 4.5 16.9183 4.5 12.5C4.5 8.08172 8.08172 4.5 12.5 4.5C16.9183 4.5 20.5 8.08172 20.5 12.5ZM16 12.5C16 14.433 14.433 16 12.5 16C10.567 16 9 14.433 9 12.5C9 10.567 10.567 9 12.5 9C14.433 9 16 10.567 16 12.5Z'\n\nconst SLICE_ANGLE = 360 / SLICE_COUNT\n\n/** A point on a circle around the center, at `degrees` clockwise from 3 o'clock */\nfunction point(radius: number, degrees: number): string {\n const radians = (degrees * Math.PI) / 180\n\n return `${CENTER + radius * Math.cos(radians)} ${CENTER + radius * Math.sin(radians)}`\n}\n\n/**\n * The part of the ring a slice colors in: between its two spokes, from the\n * inner circle out to the outer. The 12 o'clock slice is centered on 12, so\n * it starts half a slice before it.\n */\nfunction slicePath(index: number): string {\n const start = index * SLICE_ANGLE - SLICE_ANGLE / 2 - 90\n const end = start + SLICE_ANGLE\n\n return [\n `M${point(OUTER_RADIUS, start)}`,\n `A${OUTER_RADIUS} ${OUTER_RADIUS} 0 0 1 ${point(OUTER_RADIUS, end)}`,\n `L${point(INNER_RADIUS, end)}`,\n `A${INNER_RADIUS} ${INNER_RADIUS} 0 0 0 ${point(INNER_RADIUS, start)}`,\n 'Z',\n ].join('')\n}\n\nconst SLICE_PATHS = SLICE_COLORS.map((_, index) => slicePath(index))\n\n/**\n * A filled shape, not a stroked one: the icon styles of `@sanity/ui` force\n * `vector-effect: non-scaling-stroke` on every path in an icon, which fills\n * are none the wiser about\n */\nfunction Slice(props: {index: number; progress: MotionValue<number>}) {\n const {index, progress} = props\n const opacity = useTransform(progress, (value) => (isSliceFilled(value, index) ? 1 : 0))\n\n return <motion.path d={SLICE_PATHS[index]} fill={SLICE_COLORS[index]} style={{opacity}} />\n}\n\n/**\n * The `ColorWheelIcon` of `@sanity/icons`, with an animation driven by\n * `progress` (0–1): the slices pop in with color one at a time, clockwise\n * from 12 o'clock like the segments of a classic spinner, the wheel spins once\n * as the 6 o'clock slice pops in, and the colors pop out the same way round,\n * carrying the spin's speed to a rest (see `colorWheel.ts`). At rest — at\n * either end — it looks exactly like the icon it stands in for. With\n * `prefers-reduced-motion: reduce` the navbar renders that icon instead of\n * this one, so nothing here moves.\n *\n * @internal\n */\nexport default function AnimatedColorWheelIcon({\n busy,\n ref,\n}: {\n busy: boolean\n ref: React.Ref<{spin: () => void}>\n}) {\n const progress = useMotionValue(0)\n const rotate = useTransform(progress, (value) => wheelRotation(value))\n\n useImperativeHandle(\n ref,\n () => ({\n spin: () => {\n // A run in progress plays out — hovering again does not cut it short\n if (progress.isAnimating()) return\n\n animate(progress, [0, 1], {duration: ANIMATION_DURATION, ease: 'linear'})\n },\n }),\n [progress],\n )\n\n // Laps from wherever a hover's run has got to, so the wheel never jumps,\n // and the lap in progress as the code arrives plays out: at rest, the wheel\n // looks like the icon again\n useEffect(() => {\n if (!busy) return undefined\n\n let looping = false\n const raf = setTimeout(() => {\n looping = true\n const lap = (from: number) => {\n animate(progress, [from, 1], {\n duration: (1 - from) * ANIMATION_DURATION,\n ease: 'linear',\n onComplete: () => {\n if (looping) lap(0)\n },\n })\n }\n\n lap(progress.get())\n }, 160)\n\n return () => {\n clearTimeout(raf)\n looping = false\n }\n }, [busy, progress])\n\n useEffect(() => () => progress.stop(), [progress])\n\n return (\n <motion.svg\n data-sanity-icon=\"color-wheel\"\n fill=\"none\"\n height=\"1em\"\n style={{rotate}}\n viewBox=\"0 0 25 25\"\n width=\"1em\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n {SLICE_COLORS.map((color, index) => (\n <Slice key={color} index={index} progress={progress} />\n ))}\n <path d={OUTLINE} stroke=\"currentColor\" strokeLinejoin=\"round\" strokeWidth={1.2} />\n </motion.svg>\n )\n}\n"],"mappings":"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"}