@liiift-studio/sanity-font-manager 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/UploadModal-ADNRGQUI.mjs +6 -0
  2. package/dist/UploadModal-WPK2CXLR.js +6 -0
  3. package/dist/chunk-JCDZ7SWZ.js +7711 -0
  4. package/dist/chunk-TMDE4A54.mjs +7711 -0
  5. package/dist/index.js +666 -1647
  6. package/dist/index.mjs +319 -1209
  7. package/package.json +5 -5
  8. package/src/components/BatchUploadFonts.jsx +57 -44
  9. package/src/components/BulkActions.jsx +99 -0
  10. package/src/components/ExistingDocumentResolver.jsx +152 -0
  11. package/src/components/FontReviewCard.jsx +455 -0
  12. package/src/components/SingleUploaderTool.jsx +3 -4
  13. package/src/components/UploadModal.jsx +304 -0
  14. package/src/components/UploadScriptsComponent.jsx +23 -21
  15. package/src/components/UploadStep1Settings.jsx +272 -0
  16. package/src/components/UploadStep2Review.jsx +474 -0
  17. package/src/components/UploadStep3Execute.jsx +234 -0
  18. package/src/components/UploadStep3bInstances.jsx +396 -0
  19. package/src/components/UploadSummary.jsx +196 -0
  20. package/src/index.js +46 -0
  21. package/src/utils/buildUploadPlan.js +326 -0
  22. package/src/utils/executeUploadPlan.js +430 -0
  23. package/src/utils/executionReducer.js +56 -0
  24. package/src/utils/fontHelpers.js +267 -0
  25. package/src/utils/generateCssFile.js +79 -77
  26. package/src/utils/generateFontData.js +47 -94
  27. package/src/utils/getEmptyFontKit.js +19 -17
  28. package/src/utils/parseFont.js +55 -0
  29. package/src/utils/parseVariableFontInstances.js +237 -147
  30. package/src/utils/planReducer.js +517 -0
  31. package/src/utils/planTypes.js +183 -0
  32. package/src/utils/processFontFiles.js +121 -78
  33. package/src/utils/regenerateFontData.js +2 -2
  34. package/src/utils/resolveExistingFont.js +87 -0
  35. package/src/utils/updateTypefaceDocument.js +15 -2
  36. package/src/utils/uploadFontFiles.js +405 -405
@@ -0,0 +1,234 @@
1
+ // Step 3 — Upload execution with per-font progress
2
+
3
+ import React, { useEffect, useReducer, useRef, useState, useMemo } from 'react';
4
+ import { Box, Stack, Flex, Text, Card, Spinner, Badge } from '@sanity/ui';
5
+ import { WarningOutlineIcon, CheckmarkCircleIcon } from '@sanity/icons';
6
+ import { executeUploadPlan } from '../utils/executeUploadPlan';
7
+ import { executionReducer, createInitialExecutionState } from '../utils/executionReducer';
8
+ import { EXECUTION_STATUS } from '../utils/planTypes';
9
+
10
+ /** Formats elapsed seconds as "Xm Ys" or "Ys" */
11
+ const formatElapsed = (s) => {
12
+ const m = Math.floor(s / 60);
13
+ const sec = s % 60;
14
+ return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
15
+ };
16
+
17
+ /**
18
+ * Step 3 — executes the upload plan, showing per-font progress.
19
+ * Uses a separate executionReducer to avoid re-rendering the review UI.
20
+ */
21
+ export default function UploadStep3Execute({
22
+ plan,
23
+ client,
24
+ docId,
25
+ stylesObject,
26
+ preferredStyleRef,
27
+ retryTempIds,
28
+ onComplete,
29
+ }) {
30
+ const [execState, execDispatch] = useReducer(executionReducer, null, createInitialExecutionState);
31
+ const [result, setResult] = useState(null);
32
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
33
+ const startedRef = useRef(false);
34
+ const timerRef = useRef(null);
35
+ const wakeLockRef = useRef(null);
36
+
37
+ // Build the execution plan — filter to only failed fonts when retrying
38
+ const executionPlan = useMemo(() => {
39
+ if (!retryTempIds) return plan;
40
+ return {
41
+ ...plan,
42
+ fonts: Object.fromEntries(
43
+ Object.entries(plan.fonts).filter(([tempId]) => retryTempIds.includes(tempId))
44
+ ),
45
+ };
46
+ }, [plan, retryTempIds]);
47
+
48
+ const fontEntries = useMemo(() =>
49
+ Object.values(executionPlan.fonts).filter(f => f.status !== 'error'),
50
+ [executionPlan]
51
+ );
52
+
53
+ // Start execution once on mount
54
+ useEffect(() => {
55
+ if (startedRef.current) return;
56
+ startedRef.current = true;
57
+
58
+ // Start elapsed timer
59
+ timerRef.current = setInterval(() => setElapsedSeconds(s => s + 1), 1000);
60
+
61
+ // Request wake lock
62
+ navigator.wakeLock?.request('screen')
63
+ .then(lock => { wakeLockRef.current = lock; })
64
+ .catch(() => {});
65
+
66
+ execDispatch({ type: 'SET_EXECUTION_STATUS', status: 'uploading' });
67
+
68
+ executeUploadPlan({
69
+ plan: executionPlan,
70
+ client,
71
+ docId,
72
+ stylesObject,
73
+ preferredStyleRef,
74
+ onProgress: (event) => {
75
+ if (event.type === 'font-upload-start' || event.type === 'file-uploaded' ||
76
+ event.type === 'css-generated' || event.type === 'metadata-generated' ||
77
+ event.type === 'document-created') {
78
+ if (event.fontProgress) {
79
+ execDispatch({
80
+ type: 'SET_FONT_EXECUTION_PROGRESS',
81
+ tempId: event.tempId,
82
+ progress: event.fontProgress,
83
+ });
84
+ }
85
+ } else if (event.type === 'typeface-patching') {
86
+ execDispatch({ type: 'SET_EXECUTION_STATUS', status: 'patching-typeface' });
87
+ } else if (event.type === 'typeface-error') {
88
+ execDispatch({ type: 'SET_EXECUTION_ERROR', error: event.error });
89
+ }
90
+ },
91
+ }).then((executionResult) => {
92
+ setResult(executionResult);
93
+ clearInterval(timerRef.current);
94
+ wakeLockRef.current?.release().catch(() => {});
95
+ execDispatch({ type: 'SET_EXECUTION_STATUS', status: executionResult.success ? 'complete' : 'error' });
96
+ onComplete(executionResult);
97
+ }).catch((err) => {
98
+ clearInterval(timerRef.current);
99
+ wakeLockRef.current?.release().catch(() => {});
100
+ execDispatch({ type: 'SET_EXECUTION_ERROR', error: err.message });
101
+ const errorResult = {
102
+ success: false, created: 0, updated: 0, failed: 1, skipped: 0,
103
+ failedFonts: [{ title: 'Unknown', tempId: 'unknown', error: err.message, failedAt: 'unknown' }],
104
+ fontRefs: [], variableRefs: [], typefacePatchError: err.message,
105
+ };
106
+ setResult(errorResult);
107
+ onComplete(errorResult);
108
+ });
109
+
110
+ return () => {
111
+ clearInterval(timerRef.current);
112
+ wakeLockRef.current?.release().catch(() => {});
113
+ };
114
+ }, []);
115
+
116
+ const completedCount = Object.values(execState.progress).filter(p =>
117
+ p.status === EXECUTION_STATUS.COMPLETE || p.status === EXECUTION_STATUS.ERROR
118
+ ).length;
119
+
120
+ return (
121
+ <Stack space={4}>
122
+ {/* Global progress */}
123
+ <Card border padding={3} radius={2}>
124
+ <Stack space={3}>
125
+ <Flex align="center" gap={3}>
126
+ {execState.status === 'complete'
127
+ ? <CheckmarkCircleIcon style={{ color: '#43b649', fontSize: 20 }} />
128
+ : execState.status === 'error'
129
+ ? <WarningOutlineIcon style={{ color: 'var(--card-badge-critical-bg-color)', fontSize: 20 }} />
130
+ : <Spinner />
131
+ }
132
+ <Text size={1} weight="semibold">
133
+ {execState.status === 'patching-typeface'
134
+ ? 'Updating typeface document...'
135
+ : execState.status === 'complete'
136
+ ? 'Upload complete'
137
+ : execState.status === 'error'
138
+ ? 'Upload failed'
139
+ : `Uploading ${completedCount} of ${fontEntries.length} fonts...`
140
+ }
141
+ </Text>
142
+ <Text size={1} muted style={{ marginLeft: 'auto' }}>{formatElapsed(elapsedSeconds)}</Text>
143
+ </Flex>
144
+
145
+ {/* Progress bar */}
146
+ <Box style={{ height: 4, background: 'var(--card-border-color)', borderRadius: 2, overflow: 'hidden' }}>
147
+ <Box
148
+ style={{
149
+ height: '100%',
150
+ width: '100%',
151
+ transformOrigin: 'left',
152
+ transform: `scaleX(${fontEntries.length > 0 ? completedCount / fontEntries.length : 0})`,
153
+ background: execState.status === 'error'
154
+ ? 'var(--card-badge-critical-bg-color)'
155
+ : 'var(--card-badge-positive-bg-color)',
156
+ transition: 'transform 0.3s ease-out',
157
+ borderRadius: 2,
158
+ }}
159
+ />
160
+ </Box>
161
+ </Stack>
162
+ </Card>
163
+
164
+ {/* Do-not-close warning */}
165
+ {execState.status !== 'complete' && execState.status !== 'error' && (
166
+ <Card tone="caution" border radius={2} padding={2}>
167
+ <Flex align="center" gap={2}>
168
+ <WarningOutlineIcon style={{ flexShrink: 0 }} />
169
+ <Text size={1} weight="semibold">Do not close or reload this tab</Text>
170
+ </Flex>
171
+ </Card>
172
+ )}
173
+
174
+ {/* Per-font progress list */}
175
+ <Box style={{ maxHeight: 400, overflowY: 'auto' }}>
176
+ <Stack space={1}>
177
+ {fontEntries.map(entry => {
178
+ const progress = execState.progress[entry.tempId];
179
+ const status = progress?.status || EXECUTION_STATUS.QUEUED;
180
+
181
+ return (
182
+ <Card key={entry.tempId} border radius={1} padding={2}>
183
+ <Flex align="center" gap={2}>
184
+ <Text size={1} style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
185
+ {entry.title}
186
+ </Text>
187
+ <Box style={{ width: 120, flexShrink: 0, textAlign: 'right' }}>
188
+ {status === EXECUTION_STATUS.QUEUED && (
189
+ <Badge mode="outline" fontSize={0}>Queued</Badge>
190
+ )}
191
+ {status === EXECUTION_STATUS.UPLOADING_ASSETS && (
192
+ <Flex gap={1} align="center" justify="flex-end">
193
+ <Text size={0} muted>{progress?.currentFile || 'Uploading...'}</Text>
194
+ <Spinner style={{ width: 12, height: 12 }} />
195
+ </Flex>
196
+ )}
197
+ {(status === EXECUTION_STATUS.GENERATING_CSS || status === EXECUTION_STATUS.GENERATING_METADATA) && (
198
+ <Flex gap={1} align="center" justify="flex-end">
199
+ <Text size={0} muted>{status === EXECUTION_STATUS.GENERATING_CSS ? 'CSS' : 'Metadata'}</Text>
200
+ <Spinner style={{ width: 12, height: 12 }} />
201
+ </Flex>
202
+ )}
203
+ {status === EXECUTION_STATUS.CREATING_DOCUMENT && (
204
+ <Flex gap={1} align="center" justify="flex-end">
205
+ <Text size={0} muted>Creating doc</Text>
206
+ <Spinner style={{ width: 12, height: 12 }} />
207
+ </Flex>
208
+ )}
209
+ {status === EXECUTION_STATUS.COMPLETE && (
210
+ <Badge tone="positive" fontSize={0}>Done</Badge>
211
+ )}
212
+ {status === EXECUTION_STATUS.ERROR && (
213
+ <Badge tone="critical" fontSize={0}>Failed</Badge>
214
+ )}
215
+ </Box>
216
+ </Flex>
217
+ {status === EXECUTION_STATUS.ERROR && progress?.error && (
218
+ <Text size={0} muted style={{ marginTop: 4 }}>{progress.error}</Text>
219
+ )}
220
+ </Card>
221
+ );
222
+ })}
223
+ </Stack>
224
+ </Box>
225
+
226
+ {/* Error details */}
227
+ {execState.error && (
228
+ <Card tone="critical" border padding={3} radius={2}>
229
+ <Text size={1}>{execState.error}</Text>
230
+ </Card>
231
+ )}
232
+ </Stack>
233
+ );
234
+ }
@@ -0,0 +1,396 @@
1
+ // Step 3.5 — Variable font instance mapping using the production parseVariableFontInstances matcher
2
+
3
+ import React, { useState, useEffect, useCallback, useMemo } from 'react';
4
+ import { Box, Stack, Flex, Text, Card, Badge, Button, Spinner, Autocomplete } from '@sanity/ui';
5
+ import { CheckmarkCircleIcon, CloseCircleIcon, SearchIcon } from '@sanity/icons';
6
+ import { nanoid } from 'nanoid';
7
+ import { parseVariableFontInstances } from '../utils/parseVariableFontInstances';
8
+
9
+ /**
10
+ * Step 3.5 — Variable font instance mapping.
11
+ * After upload, fetches the VF documents from Sanity and uses the production
12
+ * parseVariableFontInstances matcher to auto-match instances to static fonts.
13
+ * User can review, override via searchable autocomplete, and save.
14
+ */
15
+ export default function UploadStep3bInstances({
16
+ plan,
17
+ executionResult,
18
+ client,
19
+ typefaceTitle,
20
+ onComplete,
21
+ }) {
22
+ const [loading, setLoading] = useState(true);
23
+ const [saving, setSaving] = useState(false);
24
+ const [vfMappings, setVfMappings] = useState({});
25
+ const [allStaticFonts, setAllStaticFonts] = useState([]);
26
+ const [filterUnmatched, setFilterUnmatched] = useState(false);
27
+
28
+ // Identify VF entries from the plan
29
+ const vfEntries = useMemo(() =>
30
+ Object.values(plan.fonts).filter(f => f.variableFont && f.status !== 'error'),
31
+ [plan.fonts]
32
+ );
33
+
34
+ /** Run the production matcher for all VFs — callable on mount and via re-run button */
35
+ const runMatching = useCallback(async () => {
36
+ setLoading(true);
37
+ const mappings = {};
38
+
39
+ // Fetch all static fonts for the autocomplete picker
40
+ let staticFonts = [];
41
+ try {
42
+ const typeface = await client.fetch(
43
+ `*[_type == 'typeface' && title == $typefaceTitle][0]{
44
+ 'fonts': styles.fonts[]-> {
45
+ _id, title, subfamily, style, weight, weightName, metaData, variableFont
46
+ }
47
+ }`,
48
+ { typefaceTitle }
49
+ );
50
+ if (typeface?.fonts?.length > 0) {
51
+ staticFonts = typeface.fonts.filter(f => !f.variableFont);
52
+ }
53
+ if (staticFonts.length === 0) {
54
+ staticFonts = await client.fetch(
55
+ `*[_type == 'font' && typefaceName == $typefaceTitle && variableFont != true]{
56
+ _id, title, subfamily, style, weight, weightName, metaData
57
+ }`,
58
+ { typefaceTitle }
59
+ );
60
+ }
61
+ } catch (err) {
62
+ console.error('[InstanceMapper] Failed to fetch static fonts:', err);
63
+ }
64
+
65
+ // Deduplicate by _id (draft + published versions can cause duplicates)
66
+ const deduped = new Map();
67
+ staticFonts.forEach(f => { if (f._id && !deduped.has(f._id)) deduped.set(f._id, f); });
68
+ staticFonts = [...deduped.values()];
69
+
70
+ console.log(`[InstanceMapper] Found ${staticFonts.length} static fonts for "${typefaceTitle}"`);
71
+ setAllStaticFonts(staticFonts);
72
+
73
+ for (const vf of vfEntries) {
74
+ try {
75
+ const vfDoc = await client.fetch(
76
+ `*[_id == $id][0]{
77
+ _id, title, typefaceName, variableFont, variableInstances, metaData
78
+ }`,
79
+ { id: vf.documentId }
80
+ );
81
+
82
+ if (!vfDoc) {
83
+ console.warn(`[InstanceMapper] VF document not found: ${vf.documentId}`);
84
+ mappings[vf.tempId] = [];
85
+ continue;
86
+ }
87
+
88
+ console.log(`[InstanceMapper] Running matcher for VF: ${vfDoc.title}, variableInstances: ${vfDoc.variableInstances ? 'present' : 'missing'}`);
89
+
90
+ const instanceMappings = await parseVariableFontInstances(vfDoc, client);
91
+
92
+ console.log(`[InstanceMapper] Matched ${instanceMappings.filter(m => m.value).length}/${instanceMappings.length} instances for ${vfDoc.title}`);
93
+
94
+ mappings[vf.tempId] = instanceMappings.map(m => ({
95
+ instanceName: m.key,
96
+ matchedFontId: m.value?._ref || '',
97
+ matchedFontTitle: '',
98
+ _key: m._key || nanoid(),
99
+ }));
100
+ } catch (err) {
101
+ console.error(`[InstanceMapper] Error matching VF ${vf.documentId}:`, err);
102
+ mappings[vf.tempId] = [];
103
+ }
104
+ }
105
+
106
+ // Resolve matched font titles
107
+ const allMatchedIds = new Set();
108
+ Object.values(mappings).forEach(m => m.forEach(i => { if (i.matchedFontId) allMatchedIds.add(i.matchedFontId); }));
109
+ if (allMatchedIds.size > 0) {
110
+ try {
111
+ const titles = await client.fetch(`*[_id in $ids]{ _id, title }`, { ids: [...allMatchedIds] });
112
+ const titleMap = {};
113
+ titles.forEach(t => { titleMap[t._id] = t.title; });
114
+ Object.values(mappings).forEach(m => {
115
+ m.forEach(i => { if (i.matchedFontId) i.matchedFontTitle = titleMap[i.matchedFontId] || i.matchedFontId; });
116
+ });
117
+ } catch (err) {
118
+ console.warn('[InstanceMapper] Failed to resolve font titles:', err);
119
+ }
120
+ }
121
+
122
+ setVfMappings(mappings);
123
+ setLoading(false);
124
+ }, [vfEntries, client, typefaceTitle]);
125
+
126
+ // Run on mount
127
+ useEffect(() => { runMatching(); }, [runMatching]);
128
+
129
+ // Track claimed font IDs to prevent duplicates in manual selection
130
+ const claimedFontIds = useMemo(() => {
131
+ const claimed = new Set();
132
+ Object.values(vfMappings).forEach(mappings => {
133
+ mappings.forEach(m => { if (m.matchedFontId) claimed.add(m.matchedFontId); });
134
+ });
135
+ return claimed;
136
+ }, [vfMappings]);
137
+
138
+ /** Update a single instance mapping */
139
+ const handleMappingChange = useCallback((vfTempId, instanceKey, fontId) => {
140
+ const font = allStaticFonts.find(sf => sf._id === fontId);
141
+ setVfMappings(prev => ({
142
+ ...prev,
143
+ [vfTempId]: prev[vfTempId].map(m =>
144
+ m._key === instanceKey
145
+ ? { ...m, matchedFontId: fontId, matchedFontTitle: font?.title || fontId }
146
+ : m
147
+ ),
148
+ }));
149
+ }, [allStaticFonts]);
150
+
151
+ /** Save all mappings — patch each VF document */
152
+ const handleSave = useCallback(async () => {
153
+ setSaving(true);
154
+ const errors = [];
155
+
156
+ for (const vf of vfEntries) {
157
+ const mappings = vfMappings[vf.tempId] || [];
158
+ const references = mappings
159
+ .filter(m => m.matchedFontId)
160
+ .map(m => ({
161
+ _key: nanoid(),
162
+ _type: 'object',
163
+ key: m.instanceName,
164
+ value: {
165
+ _type: 'reference',
166
+ _ref: m.matchedFontId,
167
+ _weak: true,
168
+ },
169
+ }));
170
+
171
+ try {
172
+ await client.patch(vf.documentId).set({
173
+ variableInstanceReferences: references,
174
+ }).commit();
175
+ console.log(`Patched VF instance mappings: ${vf.documentId} (${references.length} instances)`);
176
+ } catch (err) {
177
+ console.error(`Failed to patch VF ${vf.documentId}:`, err);
178
+ errors.push({ vfTitle: vf.title, error: err.message });
179
+ }
180
+ }
181
+
182
+ setSaving(false);
183
+ onComplete({ success: errors.length === 0, errors });
184
+ }, [vfEntries, vfMappings, client, onComplete]);
185
+
186
+ // Stats
187
+ const totalInstances = Object.values(vfMappings).reduce((sum, m) => sum + m.length, 0);
188
+ const matchedInstances = Object.values(vfMappings).reduce(
189
+ (sum, m) => sum + m.filter(i => i.matchedFontId).length, 0
190
+ );
191
+ const unmatchedInstances = totalInstances - matchedInstances;
192
+
193
+ // Autocomplete options — exclude already-claimed fonts
194
+ const getAutocompleteOptions = useCallback((currentFontId) => {
195
+ return allStaticFonts
196
+ .filter(sf => !claimedFontIds.has(sf._id) || sf._id === currentFontId)
197
+ .map(sf => ({
198
+ value: sf._id,
199
+ payload: sf,
200
+ }));
201
+ }, [allStaticFonts, claimedFontIds]);
202
+
203
+ if (loading) {
204
+ return (
205
+ <Card border padding={4} radius={2}>
206
+ <Flex align="center" gap={3} justify="center">
207
+ <Spinner />
208
+ <Text size={1}>Matching variable font instances to static fonts...</Text>
209
+ </Flex>
210
+ </Card>
211
+ );
212
+ }
213
+
214
+ if (vfEntries.length === 0) {
215
+ onComplete({ success: true, errors: [] });
216
+ return null;
217
+ }
218
+
219
+ return (
220
+ <Stack space={4}>
221
+ {/* Header */}
222
+ <Stack space={2}>
223
+ <Text size={2} weight="semibold">Map Variable Font Instances</Text>
224
+ <Text size={1} muted>
225
+ Review the auto-matched instances below. Each named instance should map to its corresponding static font document.
226
+ </Text>
227
+ </Stack>
228
+
229
+ {/* Stats */}
230
+ <Flex gap={2} align="center">
231
+ <Button
232
+ mode="ghost"
233
+ tone="primary"
234
+ icon={SearchIcon}
235
+ text="Re-run Matching"
236
+ fontSize={0}
237
+ padding={2}
238
+ onClick={runMatching}
239
+ disabled={loading}
240
+ style={{ cursor: 'pointer' }}
241
+ />
242
+ <Badge tone="positive" fontSize={0}>{matchedInstances} matched</Badge>
243
+ {unmatchedInstances > 0 && (
244
+ <Badge
245
+ tone="critical"
246
+ fontSize={0}
247
+ style={{ cursor: 'pointer' }}
248
+ onClick={() => setFilterUnmatched(v => !v)}
249
+ >
250
+ {unmatchedInstances} unmatched {filterUnmatched ? '(showing)' : ''}
251
+ </Badge>
252
+ )}
253
+ {filterUnmatched && (
254
+ <Badge
255
+ mode="outline"
256
+ fontSize={0}
257
+ style={{ cursor: 'pointer' }}
258
+ onClick={() => setFilterUnmatched(false)}
259
+ >
260
+ Clear filter
261
+ </Badge>
262
+ )}
263
+ <Badge mode="outline" fontSize={0}>{allStaticFonts.length} fonts available</Badge>
264
+ </Flex>
265
+
266
+ {/* Per-VF mapping sections */}
267
+ {vfEntries.map(vf => {
268
+ const mappings = vfMappings[vf.tempId] || [];
269
+ const displayMappings = filterUnmatched
270
+ ? mappings.filter(m => !m.matchedFontId)
271
+ : mappings;
272
+ const vfMatched = mappings.filter(m => m.matchedFontId).length;
273
+
274
+ return (
275
+ <Card key={vf.tempId} border padding={3} radius={2}>
276
+ <Stack space={3}>
277
+ {/* VF header */}
278
+ <Flex align="center" gap={2}>
279
+ <Badge tone="primary" fontSize={0}>VF</Badge>
280
+ <Text size={1} weight="semibold">{vf.title}</Text>
281
+ <Text size={0} muted style={{ marginLeft: 'auto' }}>
282
+ {vfMatched}/{mappings.length} matched
283
+ </Text>
284
+ </Flex>
285
+
286
+ {/* Column headers */}
287
+ <Flex align="center" gap={2} paddingY={1} style={{ borderBottom: '1px solid var(--card-border-color)' }}>
288
+ <Box style={{ width: 20 }} />
289
+ <Text size={0} weight="semibold" muted style={{ flex: 1 }}>Instance</Text>
290
+ <Text size={0} weight="semibold" muted style={{ flex: 2 }}>Static Font Document</Text>
291
+ </Flex>
292
+
293
+ {/* Instance rows */}
294
+ <Stack space={1}>
295
+ {displayMappings.map(mapping => {
296
+ const isMatched = !!mapping.matchedFontId;
297
+ const options = getAutocompleteOptions(mapping.matchedFontId);
298
+
299
+ return (
300
+ <Flex
301
+ key={mapping._key}
302
+ align="center"
303
+ gap={2}
304
+ paddingY={2}
305
+ style={{ borderBottom: '1px solid var(--card-border-color)' }}
306
+ >
307
+ <Box style={{ width: 20, flexShrink: 0 }}>
308
+ {isMatched
309
+ ? <CheckmarkCircleIcon style={{ color: '#43b649', fontSize: 16 }} />
310
+ : <CloseCircleIcon style={{ color: '#f03e2f', fontSize: 16 }} />
311
+ }
312
+ </Box>
313
+
314
+ <Text size={1} style={{ flex: 1, whiteSpace: 'nowrap' }}>{mapping.instanceName}</Text>
315
+
316
+ <Box style={{ flex: 2 }}>
317
+ <Autocomplete
318
+ id={`instance-${mapping._key}`}
319
+ options={options}
320
+ value={mapping.matchedFontId}
321
+ placeholder="Search for a font..."
322
+ icon={SearchIcon}
323
+ fontSize={1}
324
+ filterOption={(query, option) => {
325
+ const sf = option.payload;
326
+ const q = query.toLowerCase();
327
+ return (
328
+ sf.title?.toLowerCase().includes(q) ||
329
+ sf._id?.toLowerCase().includes(q) ||
330
+ sf.weightName?.toLowerCase().includes(q) ||
331
+ String(sf.weight).includes(q) ||
332
+ sf.subfamily?.toLowerCase().includes(q)
333
+ );
334
+ }}
335
+ renderOption={(option) => {
336
+ const sf = option.payload;
337
+ const isClaimed = claimedFontIds.has(sf._id) && sf._id !== mapping.matchedFontId;
338
+ return (
339
+ <Card as="button" padding={2} style={{ opacity: isClaimed ? 0.4 : 1 }}>
340
+ <Flex align="center" gap={2}>
341
+ <Text size={1}>{sf.title}</Text>
342
+ <Text size={0} muted>{sf.weight} {sf.style}</Text>
343
+ {sf.subfamily && sf.subfamily !== 'Regular' && (
344
+ <Badge mode="outline" fontSize={0}>{sf.subfamily}</Badge>
345
+ )}
346
+ </Flex>
347
+ </Card>
348
+ );
349
+ }}
350
+ renderValue={(value, option) => {
351
+ if (option?.payload) return option.payload.title;
352
+ if (mapping.matchedFontTitle) return mapping.matchedFontTitle;
353
+ const font = allStaticFonts.find(sf => sf._id === value);
354
+ return font?.title || value;
355
+ }}
356
+ onSelect={(value) => handleMappingChange(vf.tempId, mapping._key, value)}
357
+ openButton
358
+ />
359
+ </Box>
360
+ </Flex>
361
+ );
362
+ })}
363
+ </Stack>
364
+
365
+ {mappings.length === 0 && (
366
+ <Text size={1} muted>No named instances found in this variable font.</Text>
367
+ )}
368
+ </Stack>
369
+ </Card>
370
+ );
371
+ })}
372
+
373
+ {/* Actions */}
374
+ <Flex justify="flex-end" gap={2} style={{ position: 'sticky', bottom: 0, background: 'var(--card-bg-color)', paddingTop: 8, paddingBottom: 4 }}>
375
+ <Button
376
+ mode="ghost"
377
+ text="Skip — I'll map instances later"
378
+ fontSize={1}
379
+ padding={3}
380
+ onClick={() => onComplete({ success: true, errors: [], skipped: true })}
381
+ style={{ cursor: 'pointer' }}
382
+ />
383
+ <Button
384
+ mode="default"
385
+ tone="positive"
386
+ text={saving ? 'Saving...' : `Save Mappings (${matchedInstances}/${totalInstances})`}
387
+ fontSize={1}
388
+ padding={3}
389
+ disabled={saving}
390
+ onClick={handleSave}
391
+ style={{ cursor: 'pointer' }}
392
+ />
393
+ </Flex>
394
+ </Stack>
395
+ );
396
+ }