Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/themer-legacy-url-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@sanity/themer': minor
---

author: @stipsan

The themer tool imports themes from themer.sanity.build URLs. Paste a URL anywhere except a text field while the sidebar is open, or choose the new "Add from URL" in the list's menu. The tool converts the theme to `buildTheme` options, adds it to your themes and opens it in the editor.
8 changes: 8 additions & 0 deletions plugins/@sanity/themer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ Try the swatch variants to preview different starting colors, or select **I'm fe
- **Reorder:** drag cards, or move them from their menus. The order is saved between sessions.
- **Share:** copy a theme's short code from its menu.
- **Import:** paste a code into the list, or use the paste button—even in another Studio.
- **Convert a hosted theme:** paste a themer.sanity.build URL. See [Migrating from themer.sanity.build](#migrating-from-themersanitybuild).
- **Remove or restore:** themes can be removed and brought back.

<details>
Expand Down Expand Up @@ -219,6 +220,13 @@ With `prefers-reduced-motion`, both the sidebar and split preview change layouts
+const theme = buildThemeFromUrl('https://themer.sanity.build/api/hues?preset=verdant&primary=22fca8')
```

**Want a modern `buildTheme` theme instead?** Convert it in the [Studio tool](#studio-tool):

1. Open **Themer** and paste the hosted URL, or the whole import line, anywhere except a text field. You can also choose **Add from URL** in the list's menu. The tool adds the converted theme and opens it in the editor.
2. Back in the list, choose **Show theme code** and use the snippet in place of the URL import.

The converted colors come close to the hosted ones, but they don't match exactly.

**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`.

## License
Expand Down
8 changes: 7 additions & 1 deletion plugins/@sanity/themer/src/theme/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,13 @@ export function deriveTextColor(accent: string): string {
})
}

function normalizeColor(value: string, name: string): string {
/**
* Validates a hex color and normalizes it to lowercase `#rrggbb`, naming the
* option it came from when it is invalid.
*
* @internal
*/
export function normalizeColor(value: string, name: string): string {
if (typeof value !== 'string' || !isColor(value)) {
throw new TypeError(
`Invalid \`${name}\` color: ${JSON.stringify(value)} — expected a hex color like #556bfc`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/* The converted theme's thumbnail, about as big as a theme card in the sidebar */
.preview {
width: 160px;
max-width: 100%;
}
134 changes: 134 additions & 0 deletions plugins/@sanity/themer/src/tool/ImportThemeDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import {Box, Button, Card, Dialog, Flex, Stack, Text, TextArea} from '@sanity/ui'
import {useEffect, useMemo, useRef, useState} from 'react'

import type {BuildThemeOptions} from '../theme/options'
import {convertLegacyTheme, type LegacyThemeConversion} from './legacyTheme'
import {ThemeThumbnail} from './ThemeThumbnail'

import * as styles from './ImportThemeDialog.module.css'

/**
* A place to paste the themer.sanity.build URL that a Studio config imports
* its theme from — or the whole import line — for touch screens and anyone
* who would not think of pasting into the sidebar. It previews the converted
* theme before importing it as one of the user's own themes.
*
* @internal
*/
export function ImportThemeDialog(props: {
onClose: () => void
onImport: (theme: {title: string; options: BuildThemeOptions}) => void
}) {
const {onClose, onImport} = props
const [text, setText] = useState('')
const textAreaRef = useRef<HTMLTextAreaElement | null>(null)
const conversion = useMemo(() => convertLegacyTheme(text), [text])

// The menu the dialog opens from focuses its item again while it animates
// closed, after the field's `autoFocus`
useEffect(() => {
const frame = requestAnimationFrame(() => textAreaRef.current?.focus())

return () => cancelAnimationFrame(frame)
}, [])

return (
<Dialog
// The field takes the focus instead of the close button, ready for a paste
__unstable_autoFocus={false}
animate
footer={
<Flex justify="flex-end" padding={3}>
<Button
disabled={conversion.status !== 'converted'}
onClick={() => {
if (conversion.status === 'converted') onImport(conversion)
}}
text="Import"
tone="primary"
/>
</Flex>
}
header="Import from themer.sanity.build"
id="themer-import"
onClickOutside={onClose}
onClose={onClose}
onPaste={(event) => {
// A paste anywhere in the dialog lands in the field, rather than
// importing right away the way it does elsewhere in the themer
if (event.target === textAreaRef.current) return

event.preventDefault()
setText(event.clipboardData.getData('text/plain'))
textAreaRef.current?.focus()
}}
width={1}
>
<Box padding={4}>
<Stack gap={4}>
<Text muted size={1}>
Paste the themer.sanity.build URL that your Studio config imports its theme from — the
whole import line works too.
</Text>
<TextArea
autoFocus
fontSize={1}
onChange={(event) => setText(event.currentTarget.value)}
placeholder="import {theme} from 'https://themer.sanity.build/api/hues?…'"
ref={textAreaRef}
rows={3}
spellCheck={false}
value={text}
/>
{text.trim() !== '' && <ConversionPreview conversion={conversion} />}
</Stack>
</Box>
</Dialog>
)
}

function ConversionPreview(props: {conversion: LegacyThemeConversion}) {
const {conversion} = props

switch (conversion.status) {
case 'missing':
return (
<Text muted size={1}>
There is no themer.sanity.build URL in there yet — look for the one with{' '}
<code>/api/hues</code> in it.
</Text>
)
case 'invalid':
return (
<Card padding={3} radius={2} tone="critical">
<Text size={1}>{conversion.message}</Text>
</Card>
)
case 'converted':
return (
<Flex
align={['flex-start', 'flex-start', 'center']}
direction={['column', 'column', 'row']}
gap={3}
>
<Box className={styles.preview} flex="none">
<ThemeThumbnail options={conversion.options} />
</Box>
<Stack flex={1} gap={2}>
<Text size={1} textOverflow="ellipsis" weight="medium">
{conversion.title}
</Text>
<Text muted size={1}>
Its primary and default hues become the accent and text colors, and its lightest and
darkest colors the backgrounds — the other hues keep the Studio defaults.
</Text>
</Stack>
</Flex>
)
default: {
const exhaustive: never = conversion

return exhaustive
}
}
}
17 changes: 16 additions & 1 deletion plugins/@sanity/themer/src/tool/ThemeList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {CopyIcon} from '@sanity/icons/Copy'
import {EditIcon} from '@sanity/icons/Edit'
import {EllipsisHorizontalIcon} from '@sanity/icons/EllipsisHorizontal'
import {ImageIcon} from '@sanity/icons/Image'
import {LinkIcon} from '@sanity/icons/Link'
import {RestoreIcon} from '@sanity/icons/Restore'
import {Button, Card, Flex, Stack} from '@sanity/ui'
import {Menu, MenuButton, MenuDivider, MenuItem} from '@sanity/ui/menu'
Expand All @@ -16,6 +17,7 @@ import type {ThemerProps} from '#types'

import {ImageFileInput} from './ImageFileButton'
import {optionsFromImagePalette, titleFromFileName} from './imagePalette'
import {ImportThemeDialog} from './ImportThemeDialog'
import type {ThemerSnapshot} from './machine'
import {PasteThemeDialog} from './PasteThemeDialog'
import {ScrollArea} from './ScrollArea'
Expand Down Expand Up @@ -174,6 +176,11 @@ export function ThemeList({
}}
text="Add from code"
/>
<MenuItem
icon={LinkIcon}
onClick={() => actorRef.send({type: 'dialog.import'})}
text="Add from URL"
/>
{active && (
<>
<MenuDivider />
Expand All @@ -199,6 +206,13 @@ export function ThemeList({
onClose={() => actorRef.send({type: 'dialog.close'})}
/>
)}
{dialog === 'import' && (
<ImportThemeDialog
onClose={() => actorRef.send({type: 'dialog.close'})}
// Adding the theme opens the editor, which leaves the list and closes the dialog
onImport={({title, options}) => actorRef.send({type: 'theme.add', title, options})}
/>
)}
{dialog === 'snippet' && active && (
<ThemeSnippetDialog onClose={() => actorRef.send({type: 'dialog.close'})} theme={active} />
)}
Expand All @@ -207,8 +221,9 @@ export function ThemeList({
}

/** The dialog the list has open, if any — a state of the machine's list flow */
function selectDialog(snapshot: ThemerSnapshot): 'paste' | 'snippet' | null {
function selectDialog(snapshot: ThemerSnapshot): 'paste' | 'import' | 'snippet' | null {
if (snapshot.matches({flow: {list: 'pasting'}})) return 'paste'
if (snapshot.matches({flow: {list: 'importing'}})) return 'import'
if (snapshot.matches({flow: {list: 'snippet'}})) return 'snippet'

return null
Expand Down
6 changes: 6 additions & 0 deletions plugins/@sanity/themer/src/tool/ThemerSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {ThemeList} from './ThemeList'
import {resolveThemes} from './themes'
import {TooltipButton} from './TooltipButton'
import {useIsTooSmallForSplitScreen} from './useIsTooSmallForSplitScreen'
import {useLegacyThemePaste} from './useLegacyThemePaste'

import * as styles from './ThemerSidebar.module.css'

Expand All @@ -32,6 +33,9 @@ const VIEW_TITLES = {
* Studio's document footer: one primary action, with the rest of its actions
* behind a menu button, and the way back to the list where the flow is not
* the list.
* While it shows, pasting a themer.sanity.build URL imports its theme: a
* closed sidebar is hidden rather than unmounted, which tears down the paste
* listener along with its other effects.
*
* @internal
*/
Expand All @@ -50,6 +54,8 @@ export function ThemerSidebar({
dequal,
)

useLegacyThemePaste({actorRef})

return (
<Card height="fill">
<Flex direction="column" height="fill">
Expand Down
Loading
Loading