Repository: sanity-io/sanity
Pull request #9185: feat(core): add image schema options for hotspot tool configuration
Author: login: jmswrnr
Created: 2025-04-11T10:42:10Z | Merged: 2025-04-16T13:30:41Z

### Description

This PR introduces an image schema option: the `hotspot` object to enable and configure the hotspot tool, this pattern is being mindful of future options for both the hotspot tool and previews.

Mentioned in #957 

### What to review

  - Currently, setting this object in schema options will enable the hotspot tool.
  - **Should we instead have a required `enabled` boolean inside this new options object?**

### Testing

I have verified image fields in the Test Studio:
- [x] The default preview aspect ratios are used if `previews` option is not set.
- [x] Hotspot boolean/undefined option still works as expected.
- [x] When setting an empty `previews` array, the tool does not show any previews.
- [x] A four column layout for previews is enforced to prevent unusually large previews.

### Notes for release

The `image` schema options now supports a `hotspot` object for configuring the image hotspot tool. This now enables custom aspect ratios for the crop previews to be configured:
```js
{
  name: 'hotspotImage',
  title: 'Hotspot image',
  type: 'image',
  options: {
    hotspot: {
      previews: [
        {title: '2:1', aspectRatio: 2 / 1},
        {title: '4:5', aspectRatio: 4 / 5},
        {title: '9:16', aspectRatio: 9 / 16},
      ],
    },
  },
},
```
This `hotspot` option still supports the boolean value to the enable/disable hotspot tool. The image hotspot tool will use default configuration if enabled with a boolean:
```js
{
  name: 'hotspotImage',
  title: 'Hotspot image',
  type: 'image',
  options: {
    hotspot: false
  },
},
```
```js
{
  name: 'hotspotImage',
  title: 'Hotspot image',
  type: 'image',
  options: {
    hotspot: true
  },
},
```

These are additive changes that require an update to the [image schema options spec docs](https://www.sanity.io/docs/image-type#hotspot-3e6da78954a8)
<!--
Engineers do not need to worry about the final copy,
but they must provide the docs team with enough context on:

* What changed
* How does one use it (code snippets, etc)
* Are there limitations we should be aware of

If this is PR is a partial implementation of a feature and is not enabled by default or if
this PR does not contain changes that needs mention in the release notes (tooling chores etc),
please call this out explicitly by writing "Part of feature X" or "Not required" in this section.
-->



Diff:
diff --git a/dev/test-studio/schema/standard/images.ts b/dev/test-studio/schema/standard/images.ts
index aba00a6dd6bb..9fc147bdf8c1 100644
--- a/dev/test-studio/schema/standard/images.ts
+++ b/dev/test-studio/schema/standard/images.ts
@@ -197,5 +197,20 @@ export default defineType({
       title: 'No asset source',
       options: {sources: []},
     },
+    {
+      name: 'hotspotImage',
+      title: 'Hotspot image',
+      type: 'image',
+      description: 'Should have custom hotspot preview aspect ratios',
+      options: {
+        hotspot: {
+          previews: [
+            {title: '2:1', aspectRatio: 2 / 1},
+            {title: '4:5', aspectRatio: 4 / 5},
+            {title: '9:16', aspectRatio: 9 / 16},
+          ],
+        },
+      },
+    },
   ],
 })
diff --git a/packages/@sanity/types/src/schema/definition/type/image.ts b/packages/@sanity/types/src/schema/definition/type/image.ts
index ad1e4e3a44b4..41657020ca18 100644
--- a/packages/@sanity/types/src/schema/definition/type/image.ts
+++ b/packages/@sanity/types/src/schema/definition/type/image.ts
@@ -8,10 +8,21 @@ import {type ObjectDefinition} from './object'
 /** @public */
 export type ImageMetadataType = 'blurhash' | 'lqip' | 'palette' | 'exif' | 'image' | 'location'
 
+/** @public */
+export interface HotspotPreview {
+  title: string
+  aspectRatio: number
+}
+
+/** @public */
+export interface HotspotOptions {
+  previews?: HotspotPreview[]
+}
+
 /** @public */
 export interface ImageOptions extends FileOptions {
   metadata?: ImageMetadataType[]
-  hotspot?: boolean
+  hotspot?: boolean | HotspotOptions
 }
 
 /** @public */
diff --git a/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx b/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx
index 1182bdd5a3b1..2ce276729525 100644
--- a/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx
+++ b/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx
@@ -95,10 +95,10 @@ function BaseImageInputComponent(props: BaseImageInputProps): React.JSX.Element
     value?._upload,
     value?.asset,
   ])
-  const isImageToolEnabled = useCallback(
-    () => get(schemaType, 'options.hotspot') === true,
-    [schemaType],
-  )
+  const isImageToolEnabled = useCallback(() => {
+    const hotspotOptions = get(schemaType, 'options.hotspot')
+    return typeof hotspotOptions === 'object' || hotspotOptions === true
+  }, [schemaType])
   const valueIsArrayElement = useCallback(() => {
     const parentPathSegment = path.slice(-1)[0]
 
diff --git a/packages/sanity/src/core/form/inputs/files/ImageToolInput/ImageToolInput.tsx b/packages/sanity/src/core/form/inputs/files/ImageToolInput/ImageToolInput.tsx
index 54f6f338aa02..73d3517750b3 100644
--- a/packages/sanity/src/core/form/inputs/files/ImageToolInput/ImageToolInput.tsx
+++ b/packages/sanity/src/core/form/inputs/files/ImageToolInput/ImageToolInput.tsx
@@ -1,4 +1,4 @@
-import {type Image, type ImageSchemaType} from '@sanity/types'
+import {type HotspotPreview, type Image, type ImageSchemaType} from '@sanity/types'
 import {Box, Card, Flex, Grid, Heading, Stack, Text} from '@sanity/ui'
 import {type ReactNode, useCallback, useEffect, useMemo, useState} from 'react'
 import {styled} from 'styled-components'
@@ -23,11 +23,11 @@ export interface ImageToolInputProps
 
 const HOTSPOT_PATH = ['hotspot']
 
-const PREVIEW_ASPECT_RATIOS = [
-  ['3:4', 3 / 4],
-  ['Square', 1 / 1],
-  ['16:9', 16 / 9],
-  ['Panorama', 4 / 1],
+const DEFAULT_PREVIEWS: HotspotPreview[] = [
+  {title: '3:4', aspectRatio: 3 / 4},
+  {title: 'Square', aspectRatio: 1 / 1},
+  {title: '16:9', aspectRatio: 16 / 9},
+  {title: 'Panorama', aspectRatio: 4 / 1},
 ] as const
 
 const DEFAULT_VALUE: Partial<Image> = {
@@ -77,6 +77,10 @@ export function ImageToolInput(props: ImageToolInputProps) {
 
   const hasFocus = focusPath[0] === 'hotspot'
 
+  const hotspotPreviews =
+    (typeof schemaType.options?.hotspot === 'object' && schemaType.options.hotspot.previews) ||
+    DEFAULT_PREVIEWS
+
   useDidUpdate(hasFocus, (hadFocus) => {
     if (!hadFocus && hasFocus) {
       elementProps.ref.current?.focus()
@@ -197,34 +201,37 @@ export function ImageToolInput(props: ImageToolInputProps) {
             </RatioBox>
           </ChangeIndicator>
         </Card>
-        <Box marginTop={3}>
-          <Grid columns={PREVIEW_ASPECT_RATIOS.length} gap={1}>
-            {PREVIEW_ASPECT_RATIOS.map(([title, ratio]) => (
-              <div key={ratio}>
-                <Heading as="h4" size={0}>
-                  {title}
-                </Heading>
-                <Box marginTop={2}>
-                  <RatioBox ratio={ratio}>
-                    <Card __unstable_checkered>
-                      {!isImageLoading && image ? (
-                        <HotspotImage
-                          aspectRatio={ratio}
-                          src={image.src}
-                          srcAspectRatio={image.width / image.height}
-                          hotspot={localValue.hotspot || DEFAULT_HOTSPOT}
-                          crop={localValue.crop || DEFAULT_CROP}
-                        />
-                      ) : (
-                        <Placeholder />
-                      )}
-                    </Card>
-                  </RatioBox>
+
+        {hotspotPreviews.length > 0 ? (
+          <Box marginTop={2}>
+            <Grid columns={4} gap={1}>
+              {hotspotPreviews.map(({title, aspectRatio}) => (
+                <Box marginTop={2} key={title}>
+                  <Heading as="h4" size={0}>
+                    {title}
+                  </Heading>
+                  <Box marginTop={2}>
+                    <RatioBox ratio={aspectRatio}>
+                      <Card __unstable_checkered border>
+                        {!isImageLoading && image ? (
+                          <HotspotImage
+                            aspectRatio={aspectRatio}
+                            src={image.src}
+                            srcAspectRatio={image.width / image.height}
+                            hotspot={localValue.hotspot || DEFAULT_HOTSPOT}
+                            crop={localValue.crop || DEFAULT_CROP}
+                          />
+                        ) : (
+                          <Placeholder />
+                        )}
+                      </Card>
+                    </RatioBox>
+                  </Box>
                 </Box>
-              </div>
-            ))}
-          </Grid>
-        </Box>
+              ))}
+            </Grid>
+          </Box>
+        ) : null}
       </div>
     </FormField>
   )


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T10:46:53Z
user: login: stipsan
body: I never thought I'd see the day 🥹🤌💖

>Should we instead have a required enabled boolean inside this new options object?
Nah, if you have custom previews defined for a hotspot but also want it disabled the only scenario I can think about is a temporary test. Maybe to test the image picker without hotspots vs enabled. :) 
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-11T12:52:07Z
user: login: snorrees
body: This is very nice!

A couple of things I noticed while testing (non-blocking):
* When I drag the hotspot around with the mouse, I get intermittent lag-spikes, possibly because of GC? Is there room for update throttling here?
* Rows of previews could need some spacing (see the 1:900)
![image](https://github.com/user-attachments/assets/6676ea2e-8d69-491b-9cdd-a466ec73e356)
* There is an argument to be made that the previews should be capped at some max height. (1:900 works fine btw, but there is a bit of scroll required ;) ). 
* 999:1 is capped nicely :) ![image](https://github.com/user-attachments/assets/138ffeb2-2ec9-404d-90ea-17d313eda659) 
 

state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T18:23:29Z
user: login: pedrobonamin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T09:15:55Z
user: login: pedrobonamin
body: Thank you for building this, regarding the `enabled` property I think it's ok not to have it, if users are adding the hotspot previews property they will want this to be shown. 
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T09:18:12Z
user: login: jmswrnr
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T11:22:43Z
user: login: snorrees
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T11:59:41Z
user: login: pedrobonamin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T12:24:01Z
user: login: pedrobonamin
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T12:24:04Z
user: login: jmswrnr
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T12:43:16Z

Review comments:
diff_hunk: @@ -23,11 +23,11 @@ export interface ImageToolInputProps
 
 const HOTSPOT_PATH = ['hotspot']
 
-const PREVIEW_ASPECT_RATIOS = [
-  ['3:4', 3 / 4],
-  ['Square', 1 / 1],
-  ['16:9', 16 / 9],
-  ['Panorama', 4 / 1],
+const DEFAULT_PREVIEWS: HotspotPreview[] = [
path: packages/sanity/src/core/form/inputs/files/ImageToolInput/ImageToolInput.tsx
user: login: pedrobonamin
body: Wdyt if we export this as DEFAULT_HOTSPOT_PREVIEWS? So if users want to add a new aspect ratio they can reuse the default ones?

created_at: 2025-04-14T09:15:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,11 +23,11 @@ export interface ImageToolInputProps
 
 const HOTSPOT_PATH = ['hotspot']
 
-const PREVIEW_ASPECT_RATIOS = [
-  ['3:4', 3 / 4],
-  ['Square', 1 / 1],
-  ['16:9', 16 / 9],
-  ['Panorama', 4 / 1],
+const DEFAULT_PREVIEWS: HotspotPreview[] = [
path: packages/sanity/src/core/form/inputs/files/ImageToolInput/ImageToolInput.tsx
user: login: jmswrnr
body: I'm not sure if that would be a common use case but didn't see an issue with exporting it in case! However, we might consider sharing this `imagetool` with other repos. Perhaps it's best to bring this in with those exports/package instead of creating a new `sanity` export now
created_at: 2025-04-14T11:22:43Z
author_association: MEMBER
diff_hunk: @@ -23,11 +23,11 @@ export interface ImageToolInputProps
 
 const HOTSPOT_PATH = ['hotspot']
 
-const PREVIEW_ASPECT_RATIOS = [
-  ['3:4', 3 / 4],
-  ['Square', 1 / 1],
-  ['16:9', 16 / 9],
-  ['Panorama', 4 / 1],
+const DEFAULT_PREVIEWS: HotspotPreview[] = [
path: packages/sanity/src/core/form/inputs/files/ImageToolInput/ImageToolInput.tsx
user: login: snorrees
body: Remember that this then is available in the global sanity export namespace, so the constant should use a fairly verbose name.
created_at: 2025-04-14T11:59:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,11 +23,11 @@ export interface ImageToolInputProps
 
 const HOTSPOT_PATH = ['hotspot']
 
-const PREVIEW_ASPECT_RATIOS = [
-  ['3:4', 3 / 4],
-  ['Square', 1 / 1],
-  ['16:9', 16 / 9],
-  ['Panorama', 4 / 1],
+const DEFAULT_PREVIEWS: HotspotPreview[] = [
path: packages/sanity/src/core/form/inputs/files/ImageToolInput/ImageToolInput.tsx
user: login: pedrobonamin
body: >Perhaps it's best to bring this in with those exports/package instead of creating a new sanity export now

Not sure how you will be adding those packages, but probably you won't be able to use exports from other packages in core, and rather you will want to export it from `core` and consume in other places. As there are some limitations to what you can import into `core`

If you add this new export you will need to update the exports.test snapshot, which was added [here ](https://github.com/sanity-io/sanity/issues/9006) as a guard to avoid adding unnoticed exports, but I think this will be a nice to have. However this is a nice to have wouldn't consider this blocking for the PR
created_at: 2025-04-14T12:24:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,11 +23,11 @@ export interface ImageToolInputProps
 
 const HOTSPOT_PATH = ['hotspot']
 
-const PREVIEW_ASPECT_RATIOS = [
-  ['3:4', 3 / 4],
-  ['Square', 1 / 1],
-  ['16:9', 16 / 9],
-  ['Panorama', 4 / 1],
+const DEFAULT_PREVIEWS: HotspotPreview[] = [
path: packages/sanity/src/core/form/inputs/files/ImageToolInput/ImageToolInput.tsx
user: login: jmswrnr
body: Thanks both, @pedrobonamin appreciate the detailed context 🙌  I agree let's bring this in as-is and tackle those exports separately 
created_at: 2025-04-14T12:43:16Z
author_association: MEMBER

PR comments:
user: login: vercel[bot]
created_at: 2025-04-11T10:42:16Z
body: [vc]: #0r9uoABeY292zPx/Ia0eplwFfzVdTtGkE+/78hbfAZY=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ0ZXN0LXN0dWRpbyIsInJvb3REaXJlY3RvcnkiOm51bGwsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJ0ZXN0LXN0dWRpby1naXQtZmVhdC1ob3RzcG90LXByZXZpZXctb3B0aW9ucy5zYW5pdHkuZGV2In0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9zYW5pdHktc2FuZGJveC90ZXN0LXN0dWRpby9BbUNWalJwWFYybnpxWjd2QXZ0NkJUaEp2WVVyIiwicHJldmlld1VybCI6InRlc3Qtc3R1ZGlvLWdpdC1mZWF0LWhvdHNwb3QtcHJldmlldy1vcHRpb25zLnNhbml0eS5kZXYiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQifSx7Im5hbWUiOiJzdHVkaW8td29ya3Nob3AiLCJyb290RGlyZWN0b3J5IjpudWxsLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9zYW5pdHktc2FuZGJveC9zdHVkaW8td29ya3Nob3AvQUFVek5WWURyUXUyVnVUd0RSNkJQdWRyNGlTVyIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwicHJldmlld1VybCI6InN0dWRpby13b3Jrc2hvcC1naXQtZmVhdC1ob3RzcG90LXByZXZpZXctb3B0aW9ucy5zYW5pdHkuZGV2In0seyJuYW1lIjoicGVyZm9ybWFuY2Utc3R1ZGlvIiwicm9vdERpcmVjdG9yeSI6bnVsbCwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6InBlcmZvcm1hbmNlLXN0dWRpby1naXQtZmVhdC1ob3RzcG90LXByZXZpZXctb3B0aW9ucy5zYW5pdHkuZGV2In0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9zYW5pdHktc2FuZGJveC9wZXJmb3JtYW5jZS1zdHVkaW8vRGQ5akQ3RU5YaVFmaEJqdndpUFhyQ1BRRHJuTSIsInByZXZpZXdVcmwiOiJwZXJmb3JtYW5jZS1zdHVkaW8tZ2l0LWZlYXQtaG90c3BvdC1wcmV2aWV3LW9wdGlvbnMuc2FuaXR5LmRldiIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCJ9LHsibmFtZSI6InRlc3QtbmV4dC1zdHVkaW8iLCJyb290RGlyZWN0b3J5IjoiZGV2L3Rlc3QtbmV4dC1zdHVkaW8iLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9zYW5pdHktc2FuZGJveC90ZXN0LW5leHQtc3R1ZGlvLzh2c1VLR1lqUDRBRkdwZkxkZlVNc21pWE5qWjYiLCJuZXh0Q29tbWl0U3RhdHVzIjoiSUdOT1JFRCJ9LHsibmFtZSI6InBhZ2UtYnVpbGRpbmctc3R1ZGlvIiwicm9vdERpcmVjdG9yeSI6ImRldi9wYWdlLWJ1aWxkaW5nLXN0dWRpbyIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJwYWdlLWJ1aWxkaW5nLXN0dWRpby1naXQtZmVhdC1ob3RzcG90LXByZXZpZXctb3B0aW9ucy5zYW5pdHkuZGV2In0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9zYW5pdHktc2FuZGJveC9wYWdlLWJ1aWxkaW5nLXN0dWRpby83N2ZwV3lxSDJRbzh6SFMxdDVtWWZuZmg5Y1pZIiwicHJldmlld1VybCI6InBhZ2UtYnVpbGRpbmctc3R1ZGlvLWdpdC1mZWF0LWhvdHNwb3QtcHJldmlldy1vcHRpb25zLnNhbml0eS5kZXYiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQifSx7Im5hbWUiOiJlMmUtc3R1ZGlvIiwicm9vdERpcmVjdG9yeSI6bnVsbCwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vc2FuaXR5LXNhbmRib3gvZTJlLXN0dWRpby9GRnk4ZUdCTUpWY1h5WVFZdTFXd0xQVnkxdzRhIiwibmV4dENvbW1pdFN0YXR1cyI6IklHTk9SRUQifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **page-building-studio** | ✅ Ready ([Inspect](https://vercel.com/sanity-sandbox/page-building-studio/77fpWyqH2Qo8zHS1t5mYfnfh9cZY)) | [Visit Preview](https://page-building-studio-git-feat-hotspot-preview-options.sanity.dev) | 💬 [**Add feedback**](https://vercel.live/open-feedback/page-building-studio-git-feat-hotspot-preview-options.sanity.dev?via=pr-comment-feedback-link) | Apr 14, 2025 11:21am |
| **performance-studio** | ✅ Ready ([Inspect](https://vercel.com/sanity-sandbox/performance-studio/Dd9jD7ENXiQfhBjvwiPXrCPQDrnM)) | [Visit Preview](https://performance-studio-git-feat-hotspot-preview-options.sanity.dev) | 💬 [**Add feedback**](https://vercel.live/open-feedback/performance-studio-git-feat-hotspot-preview-options.sanity.dev?via=pr-comment-feedback-link) | Apr 14, 2025 11:21am |
| **test-studio** | ✅ Ready ([Inspect](https://vercel.com/sanity-sandbox/test-studio/AmCVjRpXV2nzqZ7vAvt6BThJvYUr)) | [Visit Preview](https://test-studio-git-feat-hotspot-preview-options.sanity.dev) | 💬 [**Add feedback**](https://vercel.live/open-feedback/test-studio-git-feat-hotspot-preview-options.sanity.dev?via=pr-comment-feedback-link) | Apr 14, 2025 11:21am |

<details><summary>3 Skipped Deployments</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **e2e-studio** | ⬜️ Ignored ([Inspect](https://vercel.com/sanity-sandbox/e2e-studio/FFy8eGBMJVcXyYQYu1WwLPVy1w4a)) |  |  | Apr 14, 2025 11:21am |
| **studio-workshop** | ⬜️ Ignored ([Inspect](https://vercel.com/sanity-sandbox/studio-workshop/AAUzNVYDrQu2VuTwDR6BPudr4iSW)) | [Visit Preview](https://studio-workshop-git-feat-hotspot-preview-options.sanity.dev) |  | Apr 14, 2025 11:21am |
| **test-next-studio** | ⬜️ Ignored ([Inspect](https://vercel.com/sanity-sandbox/test-next-studio/8vsUKGYjP4AFGpfLdfUMsmiXNjZ6)) |  |  | Apr 14, 2025 11:21am |
</details>







author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-11T10:46:40Z
body: No changes to documentation
<!-- thollander/actions-comment-pull-request "docs-report" -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-11T10:47:42Z
body: <h2>Coverage Report</h2>
<table> <thead> <tr> <th align="center">Status</th> <th align="left">Category</th> <th align="right">Percentage</th> <th align="right">Covered / Total</th> </tr> </thead> <tbody> <tr> <td align="center">🔵</td> <td align="left">Lines</td> <td align="right">43.26%</td> <td align="right">56482 / 130535</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Statements</td> <td align="right">43.26%</td> <td align="right">56482 / 130535</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Functions</td> <td align="right">47.43%</td> <td align="right">2799 / 5901</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Branches</td> <td align="right">79.61%</td> <td align="right">10509 / 13199</td> </tr> </tbody> </table><details><summary>File Coverage</summary><table> <thead> <tr> <th align="left">File</th> <th align="right">Stmts</th> <th align="right">Branches</th> <th align="right">Functions</th> <th align="right">Lines</th> <th align="left">Uncovered Lines</th> </tr> </thead> <tbody> <tr> <td align="left" colspan="6"><b>Changed Files</b></td> </tr> <tr> <td align="left"><a href="https://github.com/sanity-io/sanity/blob/a5b6efc862472b5e0debf9eb611a2bdf645f76d9/packages/@sanity/types/src/schema/definition/type/image.ts">packages/@sanity/types/src/schema/definition/type/image.ts</a></td> <td align="right">0%</td> <td align="right">0%</td> <td align="right">0%</td> <td align="right">0%</td> <td align="left"></td> </tr> <tr> <td align="left"><a href="https://github.com/sanity-io/sanity/blob/a5b6efc862472b5e0debf9eb611a2bdf645f76d9/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx">packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx</a></td> <td align="right">4.26%</td> <td align="right">100%</td> <td align="right">0%</td> <td align="right">4.26%</td> <td align="left"><a href="https://github.com/sanity-io/sanity/blob/a5b6efc862472b5e0debf9eb611a2bdf645f76d9/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx#L29-L528" class="text-red">29-528</a>, <a href="https://github.com/sanity-io/sanity/blob/a5b6efc862472b5e0debf9eb611a2bdf645f76d9/packages/sanity/src/core/form/inputs/files/ImageInput/ImageInput.tsx#L533-L535" class="text-red">533-535</a></td> </tr> <tr> <td align="left"><a href="https://github.com/sanity-io/sanity/blob/a5b6efc862472b5e0debf9eb611a2bdf645f76d9/packages/sanity/src/core/form/inputs/files/ImageToolInput/ImageToolInput.tsx">packages/sanity/src/core/form/inputs/files/ImageToolInput/ImageToolInput.tsx</a></td> <td align="right">15.66%</td> <td align="right">100%</td> <td align="right">0%</td> <td align="right">15.66%</td> <td align="left"><a href="https://github.com/sanity-io/sanity/blob/a5b6efc862472b5e0debf9eb611a2bdf645f76d9/packages/sanity/src/core/form/inputs/files/ImageToolInput/ImageToolInput.tsx#L42-L46" class="text-red">42-46</a>, <a href="https://github.com/sanity-io/sanity/blob/a5b6efc862472b5e0debf9eb611a2bdf645f76d9/packages/sanity/src/core/form/inputs/files/ImageToolInput/ImageToolInput.tsx#L51-L236" class="text-red">51-236</a></td> </tr> </tbody> </table></details>
<em>Generated in workflow <a href=https://github.com/sanity-io/sanity/actions/runs/14444246410>#33658</a> for commit <a href="https://github.com/sanity-io/sanity/commit/a5b6efc862472b5e0debf9eb611a2bdf645f76d9">a5b6efc</a> by the <a href="https://github.com/davelosert/vitest-coverage-report-action">Vitest Coverage Report Action</a></em>

<!-- vitest-coverage-report-marker-root -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-11T10:54:36Z
body: ### ⚡️ Editor Performance Report

Updated Mon, 14 Apr 2025 11:27:30 GMT


| Benchmark | reference<br/><sup>latency of `sanity@latest`</sup> | experiment<br/><sup>latency of this branch</sup> | Δ (%)<br/><sup>latency difference</sup> | |
| :-- | :-- | :-- | :-- | --- |
| article (title) | 26.7 efps (38ms) | 26.7 efps (38ms) | +0ms (-/-%) | ✅ |
| article (body) | 65.6 efps (15ms) | 72.5 efps (14ms) | -1ms (-/-%) | ✅ |
| article (string inside object) | 29.0 efps (35ms) | 27.8 efps (36ms) | +2ms (+4.3%) | ✅ |
| article (string inside array) | 25.6 efps (39ms) | 24.4 efps (41ms) | +2ms (+5.1%) | ✅ |
| recipe (name) | 43.5 efps (23ms) | 43.5 efps (23ms) | +0ms (-/-%) | ✅ |
| recipe (description) | 47.6 efps (21ms) | 47.6 efps (21ms) | +0ms (-/-%) | ✅ |
| recipe (instructions) | 99.9+ efps (6ms) | 99.9+ efps (6ms) | +0ms (-/-%) | ✅ |
| synthetic (title) | 18.5 efps (54ms) | 14.2 efps (71ms) | +17ms (+30.6%) | 🔴 |
| synthetic (string inside object) | 18.5 efps (54ms) | 15.2 efps (66ms) | +12ms (+22.2%) | 🔴 |


> **efps** — editor "frames per second". The number of updates assumed to be possible within a second.
>
> Derived from input latency. `efps = 1000 / input_latency`

<details>

<summary><strong>Detailed information</strong></summary>

### 🏠 Reference result

The performance result of `sanity@latest`



| Benchmark | latency | p75 | p90 | p99 | blocking time | test duration |
| --------- | ------: | --: | --: | --: | ------------: | ------------: |
| article (title) | 38ms | 41ms | 47ms | 79ms | 100ms | 10.7s |
| article (body) | 15ms | 18ms | 28ms | 758ms | 1472ms | 7.2s |
| article (string inside object) | 35ms | 36ms | 39ms | 40ms | 9ms | 6.5s |
| article (string inside array) | 39ms | 41ms | 43ms | 56ms | 38ms | 6.5s |
| recipe (name) | 23ms | 24ms | 27ms | 48ms | 7ms | 8.1s |
| recipe (description) | 21ms | 25ms | 28ms | 43ms | 0ms | 5.0s |
| recipe (instructions) | 6ms | 7ms | 9ms | 11ms | 0ms | 3.3s |
| synthetic (title) | 54ms | 57ms | 60ms | 323ms | 1134ms | 14.6s |
| synthetic (string inside object) | 54ms | 57ms | 62ms | 93ms | 833ms | 8.1s |


### 🧪 Experiment result

The performance result of this branch


| Benchmark | latency | p75 | p90 | p99 | blocking time | test duration |
| --------- | ------: | --: | --: | --: | ------------: | ------------: |
| article (title) | 38ms | 41ms | 42ms | 51ms | 45ms | 10.2s |
| article (body) | 14ms | 15ms | 17ms | 44ms | 43ms | 4.7s |
| article (string inside object) | 36ms | 39ms | 43ms | 143ms | 165ms | 6.9s |
| article (string inside array) | 41ms | 43ms | 51ms | 152ms | 223ms | 6.9s |
| recipe (name) | 23ms | 26ms | 30ms | 60ms | 11ms | 7.5s |
| recipe (description) | 21ms | 23ms | 24ms | 192ms | 0ms | 5.4s |
| recipe (instructions) | 6ms | 8ms | 9ms | 50ms | 6ms | 3.3s |
| synthetic (title) | 71ms | 73ms | 82ms | 92ms | 2093ms | 20.4s |
| synthetic (string inside object) | 66ms | 67ms | 76ms | 247ms | 2283ms | 9.7s |


### 📚 Glossary

> #### column definitions
>
> - **benchmark** — the name of the test, e.g. "article", followed by the label of the field being measured, e.g. "(title)".
> - **latency** — the time between when a key was pressed and when it was rendered. derived from a set of samples. the median (p50) is shown to show the most common latency.
> - **p75** — the 75th percentile of the input latency in the test run. 75% of the sampled inputs in this benchmark were processed faster than this value. this provides insight into the upper range of typical performance.
> - **p90** — the 90th percentile of the input latency in the test run. 90% of the sampled inputs were faster than this. this metric helps identify slower interactions that occurred less frequently during the benchmark.
> - **p99** — the 99th percentile of the input latency in the test run. only 1% of sampled inputs were slower than this. this represents the worst-case scenarios encountered during the benchmark, useful for identifying potential performance outliers.
> - **blocking time** — the total time during which the main thread was blocked, preventing user input and UI updates. this metric helps identify performance bottlenecks that may cause the interface to feel unresponsive.
> - **test duration** — how long the test run took to complete.

</details>

<!-- thollander/actions-comment-pull-request "efps-report" -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-11T10:55:47Z
body: ### 📊 Playwright Test Report

**[Download Full E2E Report](https://github.com/sanity-io/sanity/actions/runs/14444246419/artifacts/2939214718)**

This report contains test results, including videos of failing tests.
<!-- thollander/actions-comment-pull-request "playwright-e2e-report" -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-14T11:10:32Z
body: ### 🧪 [E2E Preview environment](https://e2e-studio-mwuilc0dv.sanity.dev) 

<details>
<summary> 🔑 Environment Variables for Local Testing</summary>

This is the preview URL for the E2E tests: **https://e2e-studio-mwuilc0dv.sanity.dev**

To run the E2E tests locally, you can use the following environment variables, then run `pnpm test:e2e --ui` to open the Playwright test runner.

💬 Remember to build the project first with `pnpm build:e2e`.

```
  SANITY_E2E_PROJECT_ID=kin4wkua
  SANITY_E2E_BASE_URL=https://e2e-studio-mwuilc0dv.sanity.dev
  SANITY_E2E_DATASET="update depending the project you want to test (pr-chromium-9185 || pr-firefox-9185 )"
  SANITY_E2E_DATASET_CHROMIUM=pr-chromium-9185
  SANITY_E2E_DATASET_FIREFOX=pr-firefox-9185
```

</details>
<!-- thollander/actions-comment-pull-request "e2e-preview-url" -->
author_association: CONTRIBUTOR
user: login: jmswrnr
created_at: 2025-04-14T11:31:18Z
body: Thanks for the reviews 🙌  

@snorrees good catches there - I've fixed the vertical grid spacing here, and also happy to leave the others to be tackled separately:

![Screenshot 2025-04-14 at 11 57 21](https://github.com/user-attachments/assets/8ef606ef-7cb4-442c-9935-37c900edcd35)

author_association: MEMBER