@liiift-studio/deploy-vercel-from-sanity 1.1.0 → 1.2.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.
@@ -0,0 +1,78 @@
1
+ // Hover/focus hint — Studio's Tooltip where available, an accessible local tooltip otherwise
2
+ import { useId, useState } from 'react'
3
+ import type { ReactNode } from 'react'
4
+ import { UI, resolveExport } from './resolve'
5
+ import { Box, Card, Text } from './primitives'
6
+ import type { ComponentType } from 'react'
7
+
8
+ /** Props for the compat Tooltip — plain text rather than @sanity/ui's ReactNode `content`. */
9
+ export type TooltipProps = {
10
+ /** Hint text. Also used as the accessible description of the wrapped control. */
11
+ text: string
12
+ children: ReactNode
13
+ }
14
+
15
+ /** The real Tooltip when the installed @sanity/ui still exports it, otherwise undefined. */
16
+ const InstalledTooltip = resolveExport<ComponentType<{
17
+ content: ReactNode
18
+ portal?: boolean
19
+ children: ReactNode
20
+ }>>(UI, 'Tooltip')
21
+
22
+ /**
23
+ * Hint shown on hover and on keyboard focus.
24
+ *
25
+ * The fallback deliberately does not use the native `title` attribute: `title`
26
+ * never appears on keyboard focus, never appears on touch, cannot be dismissed,
27
+ * and browsers will not re-read it while the pointer is stationary — which
28
+ * silently broke the copy button's "Copied!" confirmation. This renders a real
29
+ * element instead and wires it to the wrapped control via `aria-describedby`,
30
+ * so the text is announced and updates when it changes.
31
+ */
32
+ export function Tooltip({ text, children }: TooltipProps): React.JSX.Element {
33
+ const id = useId()
34
+ const [visible, setVisible] = useState(false)
35
+
36
+ if (InstalledTooltip) {
37
+ return (
38
+ <InstalledTooltip content={<Box padding={2}><Text size={1}>{text}</Text></Box>} portal>
39
+ {children}
40
+ </InstalledTooltip>
41
+ )
42
+ }
43
+
44
+ return (
45
+ <span
46
+ style={{ position: 'relative', display: 'inline-flex' }}
47
+ aria-describedby={id}
48
+ onMouseEnter={() => setVisible(true)}
49
+ onMouseLeave={() => setVisible(false)}
50
+ onFocusCapture={() => setVisible(true)}
51
+ onBlurCapture={() => setVisible(false)}
52
+ onKeyDown={e => { if (e.key === 'Escape') setVisible(false) }}
53
+ >
54
+ {children}
55
+ <Card
56
+ id={id}
57
+ role="tooltip"
58
+ radius={2}
59
+ shadow={2}
60
+ padding={2}
61
+ style={{
62
+ position: 'absolute',
63
+ bottom: '100%',
64
+ left: '50%',
65
+ transform: 'translateX(-50%)',
66
+ marginBottom: 4,
67
+ whiteSpace: 'nowrap',
68
+ pointerEvents: 'none',
69
+ zIndex: 1000,
70
+ // Kept in the accessibility tree when hidden so `aria-describedby` still resolves.
71
+ visibility: visible ? 'visible' : 'hidden',
72
+ }}
73
+ >
74
+ <Text size={1}>{text}</Text>
75
+ </Card>
76
+ </span>
77
+ )
78
+ }
@@ -1,13 +1,10 @@
1
1
  // Deployment history modal — shows last 10 deployments for a target
2
2
  import { useEffect, useState, useCallback } from 'react'
3
- import {
4
- Dialog, Card, Box, Flex, Text, Badge, Spinner, Button,
5
- } from '@sanity/ui'
6
- import { Stack } from '../ui'
7
3
  import { LaunchIcon, CloseIcon } from '../icons'
8
4
  import { listDeployments } from '../lib/api'
9
- import { parseHookUrl, stateLabel, timeAgo, shortSha, safeHref } from '../lib/helpers'
5
+ import { parseHookUrl, stateLabel, timeAgo, shortSha, safeHref, deploymentHref } from '../lib/helpers'
10
6
  import type { DeployTarget, VercelDeployment } from '../types'
7
+ import { Badge, Box, Button, Card, Dialog, Flex, Spinner, Stack, Text } from '../compat'
11
8
 
12
9
  interface DeployHistoryProps {
13
10
  target: DeployTarget
@@ -92,9 +89,9 @@ export function DeployHistory({ target, token, onClose }: DeployHistoryProps) {
92
89
  <Flex gap={3} align="center">
93
90
  {/* Preview URL */}
94
91
  <Box flex={2} style={{ overflow: 'hidden' }}>
95
- {d.url ? (
92
+ {deploymentHref(d.url) ? (
96
93
  <a
97
- href={`https://${d.url}`}
94
+ href={deploymentHref(d.url)}
98
95
  target="_blank"
99
96
  rel="noreferrer"
100
97
  style={{ color: 'inherit' }}
@@ -130,18 +127,23 @@ export function DeployHistory({ target, token, onClose }: DeployHistoryProps) {
130
127
  <Text size={1} muted>{timeAgo(d.created)}</Text>
131
128
  </Box>
132
129
 
133
- {/* Build logs link */}
130
+ {/* Build logs link. Rendered as a link, not a Button inside an anchor:
131
+ nesting interactive content in <a> gives two focus stops and Enter
132
+ activates the button, which has no handler, so the link never opens
133
+ for keyboard users. */}
134
134
  <Box style={{ width: 64 }}>
135
135
  {safeHref(d.inspectorUrl) ? (
136
- <a href={safeHref(d.inspectorUrl)} target="_blank" rel="noreferrer">
137
- <Button
138
- text="Logs"
139
- mode="ghost"
140
- tone="default"
141
- icon={LaunchIcon}
142
- style={{ fontSize: '12px' }}
143
- />
144
- </a>
136
+ <Button
137
+ as="a"
138
+ href={safeHref(d.inspectorUrl)}
139
+ target="_blank"
140
+ rel="noreferrer"
141
+ text="Logs"
142
+ mode="ghost"
143
+ tone="default"
144
+ icon={LaunchIcon}
145
+ style={{ fontSize: '12px' }}
146
+ />
145
147
  ) : (
146
148
  <Text size={1} muted>—</Text>
147
149
  )}
@@ -1,19 +1,16 @@
1
1
  // Per-deploy-target card — shows status, build timer, history, cancel, deploy, copy URL, and error logs
2
2
  import { useState, useEffect, useCallback, useRef } from 'react'
3
3
  import { flushSync } from 'react-dom'
4
- import {
5
- Card, Box, Flex, Text, Button, Badge, Spinner,
6
- } from '@sanity/ui'
7
- import { Stack, useToast, Tooltip, ActionMenu, Code } from '../ui'
4
+ import { ActionMenu, Badge, Box, Button, Card, Code, Flex, Spinner, Stack, Text, Tooltip, useToast } from '../compat'
8
5
  import {
9
6
  ClockIcon, TrashIcon, EllipsisVerticalIcon, LaunchIcon,
10
- CopyIcon, CheckmarkIcon, WarningOutlineIcon, ChevronDownIcon, ChevronUpIcon, EditIcon, SchemaIcon
7
+ CopyIcon, CheckmarkIcon, WarningOutlineIcon, ChevronDownIcon, ChevronUpIcon, EditIcon,
11
8
  } from '../icons'
12
9
  import { listDeployments, cancelDeployment, triggerDeploy, getDeploymentEvents } from '../lib/api'
13
- import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref, projectHref, githubCommitHref } from '../lib/helpers'
10
+ import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref, projectHref, githubCommitHref, deploymentHref } from '../lib/helpers'
14
11
  import { StatusBadge } from './StatusBadge'
15
12
  import { DeployHistory } from './DeployHistory'
16
- import type { DeployTarget, VercelDeployment } from '../types'
13
+ import type { DeployTarget, VercelDeployment, VercelDeployState } from '../types'
17
14
 
18
15
  const POLL_INTERVAL_MS = 5_000
19
16
  const LABEL_WIDTH = 64
@@ -44,9 +41,15 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
44
41
  const [errorLines, setErrorLines] = useState<string[]>([])
45
42
  const [loadingLogs, setLoadingLogs] = useState(false)
46
43
  const [logError, setLogError] = useState<string | null>(null)
44
+ const [pollError, setPollError] = useState<string | null>(null)
47
45
 
48
46
  /** uid of the deployment that was latest when Deploy was clicked — lets us tell the optimistic state apart from a genuinely new deployment */
49
47
  const triggeredFromUidRef = useRef<string | undefined>(undefined)
48
+ /** Monotonic request id — a slower earlier poll must not overwrite a newer response. */
49
+ const requestSeqRef = useRef(0)
50
+ /** False once the card unmounts, so in-flight responses stop updating state. */
51
+ const mountedRef = useRef(true)
52
+ useEffect(() => () => { mountedRef.current = false }, [])
50
53
 
51
54
  const latest = deployments[0]
52
55
  /** True between the click and the API returning a new deployment — drives the optimistic "Queued" state */
@@ -56,11 +59,19 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
56
59
  // ── Fetch deployments ──────────────────────────────────────────────────────
57
60
  const fetchDeployments = useCallback(async () => {
58
61
  if (!projectId || !hookId || !token) return
62
+ const seq = ++requestSeqRef.current
59
63
  try {
60
64
  const data = await listDeployments({ projectId, hookId, token, teamId: target.teamId })
65
+ // Drop the response if a newer request has since been issued, or the card unmounted.
66
+ if (seq !== requestSeqRef.current || !mountedRef.current) return
61
67
  setDeployments(data)
68
+ setPollError(null)
62
69
  } catch (err) {
63
- console.error('deploy-vercel-from-sanity: fetch error', err)
70
+ if (seq !== requestSeqRef.current || !mountedRef.current) return
71
+ // Surfaced in the card rather than only logged — the README documents rate-limit
72
+ // and auth failures as visible, and a silently frozen card looks identical to an idle one.
73
+ setPollError(err instanceof Error ? err.message : 'Could not reach the Vercel API')
74
+ console.error('Deploy-vercel-from-sanity: fetch error', err)
64
75
  }
65
76
  }, [projectId, hookId, token, target.teamId])
66
77
 
@@ -90,11 +101,11 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
90
101
  }, [isPending])
91
102
 
92
103
  //── Deploy-complete toast ─────────────────────────────────────────────────
93
- const prevStateRef = useRef<string | undefined>(undefined)
104
+ const prevStateRef = useRef<VercelDeployState | undefined>(undefined)
94
105
  useEffect(() => {
95
106
  const current = latest?.state
96
107
  const prev = prevStateRef.current
97
- if (prev && isActiveState(prev as never) && current && !isActiveState(current as never)) {
108
+ if (prev && isActiveState(prev) && current && !isActiveState(current)) {
98
109
  if (current === 'READY') {
99
110
  toast.push({ status: 'success', title: `${target.name} deployed`, description: 'Build completed successfully' })
100
111
  } else if (current === 'ERROR') {
@@ -164,7 +175,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
164
175
 
165
176
  const copyUrl = useCallback(() => {
166
177
  if (!latest?.url) return
167
- const fullUrl = `https://${latest.url}`
178
+ const fullUrl = deploymentHref(latest.url) ?? ''
168
179
  navigator.clipboard.writeText(fullUrl).then(() => {
169
180
  setCopied(true)
170
181
  setTimeout(() => setCopied(false), 2000)
@@ -208,7 +219,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
208
219
  const commitMsg = latest?.meta?.githubCommitMessage?.split('\n')[0]
209
220
  const sha = shortSha(latest?.meta?.githubCommitSha)
210
221
  const fullSha = latest?.meta?.githubCommitSha
211
- const commitHref = githubCommitHref(latest?.meta)
222
+ const commitHref = safeHref(githubCommitHref(latest?.meta) ?? undefined)
212
223
  const creator = latest?.creator?.username
213
224
  const deployedAt = latest?.created ? timeAgo(latest.created) : null
214
225
  const vercelProjectUrl = projectHref(latest?.inspectorUrl)
@@ -272,6 +283,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
272
283
  </Flex>
273
284
  <ActionMenu
274
285
  id={`menu-${target._id}`}
286
+ label={`Actions for ${target.name}`}
275
287
  buttonIcon={EllipsisVerticalIcon}
276
288
  items={[
277
289
  { text: 'Edit target', icon: EditIcon, onClick: () => onEdit(target) },
@@ -310,7 +322,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
310
322
  {latest?.url && latest.state === 'READY' && (
311
323
  <>
312
324
  <a
313
- href={`https://${latest.url}`}
325
+ href={deploymentHref(latest.url)}
314
326
  target="_blank"
315
327
  rel="noreferrer"
316
328
  style={{ color: 'inherit' }}
@@ -385,6 +397,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
385
397
  {isError && (
386
398
  <Stack space={2}>
387
399
  <Button
400
+ aria-expanded={showErrorLogs}
388
401
  text={showErrorLogs ? 'Hide error details' : 'Show error details'}
389
402
  mode="ghost"
390
403
  tone="critical"
@@ -449,6 +462,17 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
449
462
  </Stack>
450
463
  )}
451
464
 
465
+ {/* Polling failures were previously logged only, so a card that had stopped
466
+ updating looked identical to an idle one. */}
467
+ {pollError && (
468
+ <Card tone="caution" padding={3} radius={2} role="status">
469
+ <Flex align="center" gap={2}>
470
+ <WarningOutlineIcon aria-hidden="true" />
471
+ <Text size={1}>Status updates paused — {pollError}</Text>
472
+ </Flex>
473
+ </Card>
474
+ )}
475
+
452
476
  </Stack>
453
477
 
454
478
  {/* ── Details accordion — flush to left/bottom/right ─────── */}
@@ -456,6 +480,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
456
480
  <Button
457
481
  mode="ghost"
458
482
  iconRight={showDetails ? ChevronUpIcon : ChevronDownIcon}
483
+ aria-expanded={showDetails}
459
484
  text="Details"
460
485
  fontSize={0}
461
486
  padding={3}
@@ -1,12 +1,9 @@
1
1
  // Dialog form for creating and editing vercel_deploy documents
2
- import { useState, useCallback } from 'react'
2
+ import { useId, useState, useCallback } from 'react'
3
3
  import { useClient } from 'sanity'
4
- import {
5
- Dialog, Box, Flex, Text, TextInput, Button, Switch, Label, Card,
6
- } from '@sanity/ui'
7
- import { Stack } from '../ui'
8
4
  import { CheckmarkCircleIcon } from '../icons'
9
5
  import type { DeployTarget } from '../types'
6
+ import { Box, Button, Card, Dialog, Flex, Label, Stack, Switch, Text, TextInput } from '../compat'
10
7
 
11
8
  const VERCEL_HOOK_RE = /^https:\/\/api\.vercel\.com\/v1\/integrations\/deploy\//
12
9
 
@@ -21,6 +18,16 @@ export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetForm
21
18
  const client = useClient({ apiVersion: '2025-01-01' })
22
19
  const isEdit = Boolean(initial)
23
20
 
21
+ // Ids for label/description association. useId keeps them unique inside a published
22
+ // plugin, where a hardcoded id can collide with anything the host Studio renders.
23
+ const nameId = useId()
24
+ const urlId = useId()
25
+ const urlErrorId = useId()
26
+ const urlHelpId = useId()
27
+ const teamId_ = useId()
28
+ const teamHelpId = useId()
29
+ const disableId = useId()
30
+
24
31
  const [name, setName] = useState(initial?.name ?? '')
25
32
  const [url, setUrl] = useState(initial?.url ?? '')
26
33
  const [teamId, setTeamId] = useState(initial?.teamId ?? '')
@@ -79,10 +86,16 @@ export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetForm
79
86
  <Box padding={4}>
80
87
  <Stack space={4}>
81
88
 
82
- {/* Name */}
89
+ {/* Name. Label carries `as="label"` — @sanity/ui's Label renders a div otherwise,
90
+ so the input would have no accessible name. */}
83
91
  <Stack space={2}>
84
- <Label size={1}>Name <span style={{ color: 'var(--card-fg-color)' }}>*</span></Label>
92
+ <Label as="label" size={1} htmlFor={nameId}>
93
+ Name <span aria-hidden="true">*</span>
94
+ </Label>
85
95
  <TextInput
96
+ id={nameId}
97
+ required
98
+ aria-required="true"
86
99
  value={name}
87
100
  onChange={e => setName((e.target as HTMLInputElement).value)}
88
101
  placeholder="Production"
@@ -91,31 +104,44 @@ export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetForm
91
104
 
92
105
  {/* Deploy hook URL */}
93
106
  <Stack space={2}>
94
- <Label size={1}>Deploy hook URL <span style={{ color: 'var(--card-fg-color)' }}>*</span></Label>
107
+ <Label as="label" size={1} htmlFor={urlId}>
108
+ Deploy hook URL <span aria-hidden="true">*</span>
109
+ </Label>
95
110
  <TextInput
111
+ id={urlId}
112
+ required
113
+ aria-required="true"
114
+ aria-invalid={Boolean(url) && !urlValid}
115
+ aria-describedby={`${urlHelpId}${url && !urlValid ? ` ${urlErrorId}` : ''}`}
96
116
  value={url}
97
117
  onChange={e => setUrl((e.target as HTMLInputElement).value)}
98
118
  placeholder="https://api.vercel.com/v1/integrations/deploy/…"
99
119
  />
100
120
  {url && !urlValid && (
101
- <Text size={1} style={{ color: 'var(--card-critical-fg-color, red)' }}>
102
- Must be a Vercel deploy hook URL (api.vercel.com/v1/integrations/deploy/…)
103
- </Text>
121
+ <Card tone="critical" padding={2} radius={2} role="alert">
122
+ <Text id={urlErrorId} size={1}>
123
+ Must be a Vercel deploy hook URL (api.vercel.com/v1/integrations/deploy/…)
124
+ </Text>
125
+ </Card>
104
126
  )}
105
- <Text size={0} muted>
127
+ <Text id={urlHelpId} size={0} muted>
106
128
  Vercel dashboard → Project → Settings → Git → Deploy Hooks
107
129
  </Text>
108
130
  </Stack>
109
131
 
110
132
  {/* Team ID */}
111
133
  <Stack space={2}>
112
- <Label size={1}>Team ID <span style={{ opacity: 0.5, fontWeight: 'normal' }}>— optional</span></Label>
134
+ <Label as="label" size={1} htmlFor={teamId_}>
135
+ Team ID <span style={{ opacity: 0.5, fontWeight: 'normal' }}>— optional</span>
136
+ </Label>
113
137
  <TextInput
138
+ id={teamId_}
139
+ aria-describedby={teamHelpId}
114
140
  value={teamId}
115
141
  onChange={e => setTeamId((e.target as HTMLInputElement).value)}
116
142
  placeholder="team_xxxxxxxx"
117
143
  />
118
- <Text size={0} muted>
144
+ <Text id={teamHelpId} size={0} muted>
119
145
  Required for team-owned Vercel projects. Find it at Vercel → Settings → General → Team ID (starts with <code>team_</code>).
120
146
  </Text>
121
147
  </Stack>
@@ -125,10 +151,10 @@ export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetForm
125
151
  <Switch
126
152
  checked={disableDelete}
127
153
  onChange={e => setDisableDelete((e.target as HTMLInputElement).checked)}
128
- id="disable-delete"
154
+ id={disableId}
129
155
  />
130
156
  <Stack space={1}>
131
- <Label as="label" size={1} htmlFor="disable-delete">Disable delete action</Label>
157
+ <Label as="label" size={1} htmlFor={disableId}>Disable delete action</Label>
132
158
  <Text size={0} muted>Hides the delete button for this target in the studio.</Text>
133
159
  </Stack>
134
160
  </Flex>
@@ -1,19 +1,19 @@
1
1
  // Main deploy tool — fetches targets + token, renders per-project cards
2
2
  import { useState, useEffect, useCallback } from 'react'
3
3
  import { useClient } from 'sanity'
4
- import {
5
- Card, Box, Flex, Text, Heading, Spinner, Button, Dialog,
6
- } from '@sanity/ui'
7
- import { Stack, useToast, ToastViewport } from '../ui'
8
4
  import { TokenIcon, TrashIcon, WarningOutlineIcon, AddIcon } from '../icons'
9
5
  import { DeployItem } from './DeployItem'
10
6
  import { TokenSetup } from './TokenSetup'
11
7
  import { DeployTargetForm } from './DeployTargetForm'
12
8
  import { VERSION } from '../version'
13
9
  import type { DeployTarget } from '../types'
10
+ import { Box, Button, Card, Dialog, Flex, Heading, Spinner, Stack, Text, ToastViewport, useToast } from '../compat'
14
11
 
15
12
  const TOKEN_QUERY = `*[_id == "config.vercelDeploy"][0].accessToken`
16
- const TARGETS_QUERY = `*[_type == "vercel_deploy"] | order(_createdAt asc)`
13
+ // `useClient` hands back a raw-perspective client, so drafts are returned alongside published
14
+ // documents. `vercel_deploy` is a registered type and therefore editable from Structure, which
15
+ // would otherwise render two cards for the same target.
16
+ const TARGETS_QUERY = `*[_type == "vercel_deploy" && !(_id in path("drafts.**"))] | order(_createdAt asc)`
17
17
 
18
18
  export function DeployTool() {
19
19
  const client = useClient({ apiVersion: '2025-01-01' })
@@ -49,7 +49,18 @@ export function DeployTool() {
49
49
 
50
50
  // Inject responsive styles once on mount
51
51
  useEffect(() => {
52
- if (document.getElementById('dvfs-styles')) return
52
+ // Reference-counted: with two tools mounted, the one that skipped creation must not
53
+ // remove the stylesheet the other is still using.
54
+ const existing = document.getElementById('dvfs-styles')
55
+ if (existing) {
56
+ const n = Number(existing.dataset.refs ?? '1') + 1
57
+ existing.dataset.refs = String(n)
58
+ return () => {
59
+ const left = Number(existing.dataset.refs ?? '1') - 1
60
+ existing.dataset.refs = String(left)
61
+ if (left <= 0) existing.remove()
62
+ }
63
+ }
53
64
  const style = document.createElement('style')
54
65
  style.id = 'dvfs-styles'
55
66
  style.textContent = `
@@ -70,7 +81,12 @@ export function DeployTool() {
70
81
  }
71
82
  `
72
83
  document.head.appendChild(style)
73
- return () => { document.getElementById('dvfs-styles')?.remove() }
84
+ style.dataset.refs = '1'
85
+ return () => {
86
+ const left = Number(style.dataset.refs ?? '1') - 1
87
+ style.dataset.refs = String(left)
88
+ if (left <= 0) style.remove()
89
+ }
74
90
  }, [])
75
91
 
76
92
  // Live subscription — update targets when documents change
@@ -90,7 +106,10 @@ export function DeployTool() {
90
106
  if (!pendingDelete) return
91
107
  setDeleting(true)
92
108
  try {
109
+ // Remove the draft counterpart as well; deleting only the published id leaves the
110
+ // target behind and it reappears on the next load.
93
111
  await client.delete(pendingDelete._id)
112
+ await client.delete(`drafts.${pendingDelete._id}`).catch(() => {})
94
113
  setTargets(prev => prev.filter(t => t._id !== pendingDelete._id))
95
114
  toast.push({ status: 'success', title: `Deleted "${pendingDelete.name}"` })
96
115
  } catch (err) {
@@ -223,10 +242,13 @@ export function DeployTool() {
223
242
  </Box>
224
243
 
225
244
  {/* ── Token setup dialog ──────────────────────────────────────────── */}
245
+ {/* The token dialog is always dismissable. Withholding Cancel on first run —
246
+ exactly when the user may not have a token to hand — made it a keyboard trap,
247
+ since Sanity's Dialog holds focus and Escape was wired to the absent handler. */}
226
248
  {showTokenSetup && (
227
249
  <TokenSetup
228
250
  onSaved={() => { setShowTokenSetup(false); load() }}
229
- onCancel={token ? () => setShowTokenSetup(false) : undefined}
251
+ onCancel={() => setShowTokenSetup(false)}
230
252
  />
231
253
  )}
232
254
 
@@ -1,7 +1,7 @@
1
1
  // Status badge for a Vercel deployment state
2
- import { Badge, Spinner, Flex } from '@sanity/ui'
3
2
  import { stateLabel } from '../lib/helpers'
4
3
  import type { VercelDeployState } from '../types'
4
+ import { Badge, Flex, Spinner } from '../compat'
5
5
 
6
6
  interface StatusBadgeProps {
7
7
  state: VercelDeployState | undefined
@@ -1,11 +1,8 @@
1
1
  // Vercel API token form — rendered inside a Dialog by DeployTool
2
- import { useState, useCallback } from 'react'
2
+ import { useId, useState, useCallback } from 'react'
3
3
  import { useClient } from 'sanity'
4
- import {
5
- Text, TextInput, Button, Card, Dialog, Flex,
6
- } from '@sanity/ui'
7
- import { Stack } from '../ui'
8
4
  import { CheckmarkCircleIcon } from '../icons'
5
+ import { Button, Card, Dialog, Flex, Label, Stack, Text, TextInput } from '../compat'
9
6
 
10
7
  interface TokenSetupProps {
11
8
  /** Called after the token is successfully saved */
@@ -17,6 +14,7 @@ interface TokenSetupProps {
17
14
  const TOKEN_DOC_ID = 'config.vercelDeploy'
18
15
 
19
16
  export function TokenSetup({ onSaved, onCancel }: TokenSetupProps) {
17
+ const tokenId = useId()
20
18
  const client = useClient({ apiVersion: '2025-01-01' })
21
19
  const [token, setToken] = useState('')
22
20
  const [saving, setSaving] = useState(false)
@@ -78,8 +76,10 @@ export function TokenSetup({ onSaved, onCancel }: TokenSetupProps) {
78
76
  </Stack>
79
77
 
80
78
  <Stack space={2}>
81
- <Text size={1} weight="semibold">Vercel API Token</Text>
79
+ {/* A plain Text is not a label — @sanity/ui's Label needs `as="label"` to render one. */}
80
+ <Label as="label" size={1} htmlFor={tokenId}>Vercel API Token</Label>
82
81
  <TextInput
82
+ id={tokenId}
83
83
  value={token}
84
84
  onChange={e => setToken((e.target as HTMLInputElement).value)}
85
85
  placeholder="xxxxxxxxxxxxxxxxxxxxxxxx"
@@ -88,7 +88,7 @@ export function TokenSetup({ onSaved, onCancel }: TokenSetupProps) {
88
88
  </Stack>
89
89
 
90
90
  {error && (
91
- <Card tone="critical" padding={3} radius={2}>
91
+ <Card tone="critical" padding={3} radius={2} role="alert">
92
92
  <Text size={1}>{error}</Text>
93
93
  </Card>
94
94
  )}
package/src/icons.tsx CHANGED
@@ -1,7 +1,7 @@
1
- // Version-agnostic access to @sanity/icons — resolves named exports (icons v3/v4) or <Icon symbol> (icons v5+)
1
+ // Version-agnostic access to @sanity/icons — named exports on v2/v3/v4, <Icon symbol> on v5+
2
2
  import { forwardRef } from 'react'
3
3
  import type { ComponentType, SVGProps } from 'react'
4
- import * as sanityIcons from '@sanity/icons'
4
+ import { ICONS, resolveExport } from './compat/resolve'
5
5
 
6
6
  /** Props every Sanity icon accepts — it renders a plain sized SVG. */
7
7
  export type IconProps = SVGProps<SVGSVGElement>
@@ -9,48 +9,56 @@ export type IconProps = SVGProps<SVGSVGElement>
9
9
  /** An icon component, whichever shape the installed @sanity/icons exposes it in. */
10
10
  export type IconComponent = ComponentType<IconProps>
11
11
 
12
- /**
13
- * The installed @sanity/icons namespace, read through an index signature.
14
- *
15
- * icons v3 and v4 export one named component per glyph (`RocketIcon`). v5.0.0
16
- * dropped those from the barrel and replaced them with a single `<Icon symbol>`
17
- * that lazy-loads from an internal map. Its `index.d.ts` still declares the old
18
- * named exports, so the mismatch is invisible to TypeScript and only surfaces at
19
- * runtime — reading the namespace dynamically lets one build serve every major.
20
- */
21
- const INSTALLED = sanityIcons as unknown as Record<string, unknown>
22
-
23
12
  /** Sizing of a Sanity icon glyph — 1em square on a 25-unit viewBox, matching @sanity/icons. */
24
13
  const GLYPH = { width: '1em', height: '1em', viewBox: '0 0 25 25', fill: 'none' } as const
25
14
 
26
- /** Last-resort placeholder when the installed @sanity/icons exposes neither shape. Holds layout, draws nothing. */
15
+ /**
16
+ * Placeholder for when the installed @sanity/icons exposes neither shape. Holds
17
+ * layout, draws nothing, and is hidden from assistive tech since every icon in
18
+ * this plugin is decorative.
19
+ */
27
20
  const MissingIcon = forwardRef<SVGSVGElement, IconProps>(function MissingIcon(props, ref) {
28
- return <svg {...GLYPH} xmlns="http://www.w3.org/2000/svg" {...props} ref={ref} />
21
+ return <svg {...GLYPH} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" {...props} ref={ref} />
29
22
  })
30
23
 
31
- /** The v5+ `<Icon>` component, absent on icons v3 and v4. */
24
+ /** The v5+ `<Icon>` component, absent on icons v2, v3 and v4. */
32
25
  type SymbolIcon = ComponentType<IconProps & { symbol: string }>
33
26
 
27
+ /**
28
+ * The symbol map v5+ exposes alongside `Icon`. Used to check a symbol exists
29
+ * before relying on it: `<Icon>` renders `null` for an unrecognised symbol, so
30
+ * a renamed glyph would otherwise vanish silently rather than reaching
31
+ * {@link MissingIcon}.
32
+ */
33
+ const SYMBOL_MAP = resolveExport<Record<string, unknown>>(ICONS, 'icons')
34
+
34
35
  /**
35
36
  * Resolve one glyph against whichever @sanity/icons the host Studio installed.
36
- * Always reads from the host package, so new and revised artwork is picked up
37
- * on the consumer's next `@sanity/icons` update without a release here.
37
+ * Always reads from the host package, so new and revised artwork is picked up on
38
+ * the consumer's next @sanity/icons update without a release here.
39
+ *
40
+ * Note the named exports are not merely absent on v5 — they are declared `never`
41
+ * with a deprecation note pointing at per-icon subpaths, so a static named import
42
+ * is a type error as well as a runtime one. Subpaths only exist from v4.1.0,
43
+ * which is why this resolves at runtime instead.
38
44
  *
39
- * @param name Named export used by icons v3 and v4, e.g. `RocketIcon`.
45
+ * @param name Named export used by icons v2, v3 and v4, e.g. `RocketIcon`.
40
46
  * @param symbol Kebab-case symbol used by the v5+ `<Icon>` component, e.g. `rocket`.
41
47
  */
42
48
  function resolveIcon(name: string, symbol: string): IconComponent {
43
- const named = INSTALLED[name] as IconComponent | undefined
49
+ const named = resolveExport<IconComponent>(ICONS, name)
44
50
  if (named) return named
45
51
 
46
- const Icon = INSTALLED.Icon as SymbolIcon | undefined
52
+ const Icon = resolveExport<SymbolIcon>(ICONS, 'Icon')
47
53
  if (!Icon) return MissingIcon
54
+ // An unknown symbol would render nothing at all; fall back to the sized placeholder instead.
55
+ if (SYMBOL_MAP && !(symbol in SYMBOL_MAP)) return MissingIcon
48
56
 
49
57
  const Resolved = forwardRef<SVGSVGElement, IconProps>(function SanityIcon(props, ref) {
50
- return <Icon symbol={symbol} {...props} ref={ref as never} />
58
+ return <Icon symbol={symbol} {...props} ref={ref} />
51
59
  })
52
60
  Resolved.displayName = name
53
- return Resolved as IconComponent
61
+ return Resolved
54
62
  }
55
63
 
56
64
  export const AddIcon = resolveIcon('AddIcon', 'add')
@@ -65,7 +73,6 @@ export const EditIcon = resolveIcon('EditIcon', 'edit')
65
73
  export const EllipsisVerticalIcon = resolveIcon('EllipsisVerticalIcon', 'ellipsis-vertical')
66
74
  export const LaunchIcon = resolveIcon('LaunchIcon', 'launch')
67
75
  export const RocketIcon = resolveIcon('RocketIcon', 'rocket')
68
- export const SchemaIcon = resolveIcon('SchemaIcon', 'schema')
69
76
  export const TokenIcon = resolveIcon('TokenIcon', 'token')
70
77
  export const TrashIcon = resolveIcon('TrashIcon', 'trash')
71
78
  export const WarningOutlineIcon = resolveIcon('WarningOutlineIcon', 'warning-outline')
package/src/index.ts CHANGED
@@ -1,15 +1,20 @@
1
- // deploy-vercel-from-sanity — Sanity Studio v5 plugin for Vercel deployments
1
+ // deploy-vercel-from-sanity — Sanity Studio plugin for Vercel deployments (Studio v3.30 through v6)
2
2
  import { definePlugin } from 'sanity'
3
3
  import { RocketIcon } from './icons'
4
4
  import { DeployTool } from './components/DeployTool'
5
5
  import { vercelDeploySchema } from './schema/vercelDeploy'
6
+ import { vercelConfigSchema } from './schema/vercelConfig'
6
7
  import type { VercelDeployPluginConfig } from './types'
7
8
 
8
9
  export { vercelDeploySchema } from './schema/vercelDeploy'
10
+ export { vercelConfigSchema } from './schema/vercelConfig'
9
11
  export type { VercelDeployPluginConfig, DeployTarget, VercelDeployment, VercelDeployState } from './types'
10
12
 
11
13
  /**
12
- * Sanity Studio v5 plugin — trigger and monitor Vercel deployments.
14
+ * Sanity Studio plugin — trigger and monitor Vercel deployments.
15
+ *
16
+ * Supports Studio v3.30 through v6 from a single build; see the compatibility
17
+ * table in the README for how @sanity/ui and @sanity/icons are resolved.
13
18
  *
14
19
  * @example
15
20
  * // sanity.config.ts
@@ -28,7 +33,7 @@ export const vercelDeploy = definePlugin<VercelDeployPluginConfig | void>(option
28
33
  return {
29
34
  name: 'deploy-vercel-from-sanity',
30
35
  schema: {
31
- types: [vercelDeploySchema],
36
+ types: [vercelDeploySchema, vercelConfigSchema],
32
37
  },
33
38
  tools: [
34
39
  {