@liiift-studio/sanity-font-manager 2.7.0 → 2.7.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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +695 -437
  3. package/dist/index.js +1634 -17839
  4. package/dist/index.mjs +1551 -17745
  5. package/package.json +83 -83
  6. package/src/components/BatchUploadFonts.jsx +655 -655
  7. package/src/components/BulkActions.jsx +99 -99
  8. package/src/components/ExistingDocumentResolver.jsx +152 -152
  9. package/src/components/FontReviewCard.jsx +455 -455
  10. package/src/components/FontScriptUploaderComponent.jsx +463 -463
  11. package/src/components/GenerateCollectionsPairsComponent.jsx +259 -259
  12. package/src/components/KeyValueInput.jsx +95 -95
  13. package/src/components/KeyValueReferenceInput.jsx +267 -267
  14. package/src/components/NestedObjectArraySelector.jsx +146 -146
  15. package/src/components/PriceInput.jsx +26 -26
  16. package/src/components/PrimaryCollectionGeneratorTypeface.jsx +116 -116
  17. package/src/components/RegenerateSubfamiliesComponent.jsx +185 -185
  18. package/src/components/SetOTF.jsx +87 -87
  19. package/src/components/SingleUploaderTool.jsx +674 -674
  20. package/src/components/StatusDisplay.jsx +26 -26
  21. package/src/components/StyleCountInput.jsx +16 -16
  22. package/src/components/UpdateScriptsComponent.jsx +76 -76
  23. package/src/components/UploadButton.jsx +43 -43
  24. package/src/components/UploadModal.jsx +309 -309
  25. package/src/components/UploadScriptsComponent.jsx +539 -539
  26. package/src/components/UploadStep1Settings.jsx +272 -272
  27. package/src/components/UploadStep2Review.jsx +478 -478
  28. package/src/components/UploadStep3Execute.jsx +234 -234
  29. package/src/components/UploadStep3bInstances.jsx +396 -396
  30. package/src/components/UploadSummary.jsx +196 -196
  31. package/src/components/VariableInstanceReferencesInput.jsx +190 -190
  32. package/src/hooks/useNestedObjects.js +92 -92
  33. package/src/hooks/useSanityClient.js +9 -9
  34. package/src/index.js +120 -120
  35. package/src/schema/openTypeField.js +1995 -1995
  36. package/src/schema/styleCountField.js +12 -12
  37. package/src/schema/stylesField.js +302 -302
  38. package/src/schema/stylisticSetField.js +301 -301
  39. package/src/utils/buildUploadPlan.js +326 -326
  40. package/src/utils/executeUploadPlan.js +430 -430
  41. package/src/utils/executionReducer.js +56 -56
  42. package/src/utils/fontHelpers.js +281 -281
  43. package/src/utils/generateCssFile.js +207 -207
  44. package/src/utils/generateFontData.js +98 -98
  45. package/src/utils/generateFontFile.js +38 -38
  46. package/src/utils/generateKeywords.js +185 -185
  47. package/src/utils/generateSubset.js +45 -45
  48. package/src/utils/getEmptyFontKit.js +101 -101
  49. package/src/utils/parseFont.js +56 -56
  50. package/src/utils/parseVariableFontInstances.js +301 -301
  51. package/src/utils/planReducer.js +531 -531
  52. package/src/utils/planTypes.js +183 -183
  53. package/src/utils/processFontFiles.js +530 -530
  54. package/src/utils/regenerateFontData.js +146 -146
  55. package/src/utils/resolveExistingFont.js +87 -87
  56. package/src/utils/retitleFontEntries.js +154 -154
  57. package/src/utils/sanitizeForSanityId.js +65 -65
  58. package/src/utils/setupDecompressors.js +27 -27
  59. package/src/utils/updateFontPrices.js +94 -94
  60. package/src/utils/updateTypefaceDocument.js +162 -162
  61. package/src/utils/uploadFontFiles.js +405 -405
  62. package/src/utils/utils.js +24 -24
@@ -1,531 +1,531 @@
1
- // Plan reducer — manages UploadPlan state for the upload modal review UI
2
-
3
- import { PLAN_PHASE, FONT_STATUS, PROCESSING_OWNED_FIELDS } from './planTypes';
4
- import { sanitizeForSanityId } from './sanitizeForSanityId';
5
- import { retitleAllFonts } from './retitleFontEntries';
6
-
7
- /** Valid phase transitions — any phase can transition to 'idle' (reset) */
8
- const VALID_TRANSITIONS = {
9
- [PLAN_PHASE.IDLE]: [PLAN_PHASE.PROCESSING],
10
- [PLAN_PHASE.PROCESSING]: [PLAN_PHASE.REVIEWING],
11
- [PLAN_PHASE.REVIEWING]: [PLAN_PHASE.READY, PLAN_PHASE.EXECUTING],
12
- [PLAN_PHASE.READY]: [PLAN_PHASE.EXECUTING],
13
- [PLAN_PHASE.EXECUTING]: [PLAN_PHASE.COMPLETE, PLAN_PHASE.ERROR],
14
- [PLAN_PHASE.COMPLETE]: [PLAN_PHASE.EXECUTING],
15
- [PLAN_PHASE.ERROR]: [PLAN_PHASE.EXECUTING],
16
- };
17
-
18
- /**
19
- * Plan reducer for useReducer. Manages the UploadPlan state.
20
- * Write domain isolation: processing actions only write processing-owned fields;
21
- * user edit actions only write decisions.*.userOverride.
22
- *
23
- * @param {object} state - Current UploadPlan
24
- * @param {object} action - Dispatched action
25
- * @returns {object} New UploadPlan
26
- */
27
- export function planReducer(state, action) {
28
- switch (action.type) {
29
- // ---------------------------------------------------------------
30
- // Phase / Settings
31
- // ---------------------------------------------------------------
32
-
33
- case 'SET_PHASE': {
34
- if (action.phase === PLAN_PHASE.IDLE) {
35
- return { ...state, phase: PLAN_PHASE.IDLE };
36
- }
37
- const validNext = VALID_TRANSITIONS[state.phase] || [];
38
- if (!validNext.includes(action.phase)) {
39
- console.warn(`Invalid phase transition: ${state.phase} → ${action.phase}`);
40
- return state;
41
- }
42
- const nextState = { ...state, phase: action.phase };
43
- // Allow setting total file count when entering PROCESSING phase
44
- if (typeof action.totalFiles === 'number') {
45
- nextState.processingProgress = {
46
- ...state.processingProgress,
47
- total: action.totalFiles,
48
- completed: 0,
49
- failed: 0,
50
- currentFile: null,
51
- };
52
- }
53
- return nextState;
54
- }
55
-
56
- case 'SET_SETTINGS': {
57
- if (state.phase !== PLAN_PHASE.IDLE && state.phase !== PLAN_PHASE.REVIEWING && state.phase !== PLAN_PHASE.READY) {
58
- console.warn('SET_SETTINGS blocked — settings locked during processing/execution');
59
- return state;
60
- }
61
- const newSettings = { ...state.settings, ...action.settings };
62
- let newState = { ...state, settings: newSettings };
63
-
64
- // Retitle fonts when preserveShortenedNames changes during review
65
- const shortenedChanged = 'preserveShortenedNames' in action.settings
66
- && action.settings.preserveShortenedNames !== state.settings.preserveShortenedNames;
67
- if (shortenedChanged && Object.keys(state.fonts).length > 0) {
68
- const typefaceTitle = action.typefaceTitle || state.settings.typefaceTitle || '';
69
- const retitled = retitleAllFonts(state.fonts, newSettings.preserveShortenedNames, typefaceTitle);
70
- const subfamilyGroups = rebuildSubfamilyGroups(retitled);
71
- newState = { ...newState, fonts: retitled, subfamilyGroups };
72
- }
73
-
74
- return newState;
75
- }
76
-
77
- // ---------------------------------------------------------------
78
- // Processing (Phase 1) — dispatched by buildUploadPlan callbacks
79
- // ---------------------------------------------------------------
80
-
81
- case 'UPDATE_PROCESSING_PROGRESS': {
82
- return {
83
- ...state,
84
- processingProgress: { ...state.processingProgress, ...action.progress },
85
- };
86
- }
87
-
88
- case 'ADD_PROCESSED_FONT': {
89
- const { tempId, fontEntry } = action;
90
- const fonts = { ...state.fonts };
91
-
92
- if (fonts[tempId]) {
93
- // Font already exists (user may have edited) — merge processing-owned fields only
94
- const existing = fonts[tempId];
95
- const merged = { ...existing };
96
- for (const field of PROCESSING_OWNED_FIELDS) {
97
- merged[field] = fontEntry[field];
98
- }
99
- fonts[tempId] = merged;
100
- } else {
101
- fonts[tempId] = fontEntry;
102
- }
103
-
104
- // Update subfamily groups
105
- const subfamilyGroups = { ...state.subfamilyGroups };
106
- const sfName = fontEntry.subfamily || 'default';
107
- if (!fontEntry.variableFont || fontEntry.subfamily) {
108
- if (!subfamilyGroups[sfName]) {
109
- subfamilyGroups[sfName] = { title: sfName, fontIds: [] };
110
- }
111
- // Guard against duplicate tempId in fontIds
112
- if (!subfamilyGroups[sfName].fontIds.includes(tempId)) {
113
- subfamilyGroups[sfName] = {
114
- ...subfamilyGroups[sfName],
115
- fontIds: [...subfamilyGroups[sfName].fontIds, tempId],
116
- };
117
- }
118
- }
119
-
120
- return {
121
- ...state,
122
- fonts,
123
- subfamilyGroups,
124
- processingProgress: {
125
- ...state.processingProgress,
126
- completed: state.processingProgress.completed + 1,
127
- },
128
- };
129
- }
130
-
131
- case 'SET_PROCESSING_ERROR': {
132
- const { tempId, error } = action;
133
- if (!state.fonts[tempId]) return state;
134
- return {
135
- ...state,
136
- fonts: {
137
- ...state.fonts,
138
- [tempId]: { ...state.fonts[tempId], status: FONT_STATUS.ERROR, error },
139
- },
140
- processingProgress: {
141
- ...state.processingProgress,
142
- failed: state.processingProgress.failed + 1,
143
- },
144
- };
145
- }
146
-
147
- // ---------------------------------------------------------------
148
- // User Edits (Review Step)
149
- // ---------------------------------------------------------------
150
-
151
- case 'SET_FONT_TITLE': {
152
- const { tempId, title, source: titleSource } = action;
153
- const font = state.fonts[tempId];
154
- if (!font) return state;
155
-
156
- // When source is provided (e.g. from clicking a name table suggestion), preserve it.
157
- // The display layer appends "(user override)" — don't bake it into the source string.
158
- const newSource = titleSource || 'user-override';
159
-
160
- const updated = {
161
- ...font,
162
- title,
163
- decisions: {
164
- ...font.decisions,
165
- title: { ...font.decisions.title, userOverride: title, source: newSource },
166
- },
167
- };
168
-
169
- // Auto-derive documentId unless it has its own override
170
- if (!font.decisions.documentId.userOverride) {
171
- const newDocId = sanitizeForSanityId(title);
172
- updated.documentId = newDocId;
173
- updated.decisions = {
174
- ...updated.decisions,
175
- documentId: { ...updated.decisions.documentId, generated: newDocId },
176
- };
177
- }
178
-
179
- return updateFontAndCheckConflicts(state, tempId, updated);
180
- }
181
-
182
- case 'SET_FONT_DOCUMENT_ID': {
183
- const { tempId, documentId } = action;
184
- const font = state.fonts[tempId];
185
- if (!font) return state;
186
-
187
- const sanitized = sanitizeForSanityId(documentId);
188
- const updated = {
189
- ...font,
190
- documentId: sanitized,
191
- decisions: {
192
- ...font.decisions,
193
- documentId: { ...font.decisions.documentId, userOverride: documentId },
194
- },
195
- };
196
-
197
- return updateFontAndCheckConflicts(state, tempId, updated);
198
- }
199
-
200
- case 'SET_FONT_WEIGHT': {
201
- const { tempId, weight } = action;
202
- const font = state.fonts[tempId];
203
- if (!font) return state;
204
-
205
- const clamped = Math.max(1, Math.min(1000, weight));
206
- return {
207
- ...state,
208
- fonts: {
209
- ...state.fonts,
210
- [tempId]: {
211
- ...font,
212
- weight: clamped,
213
- decisions: {
214
- ...font.decisions,
215
- weight: { ...font.decisions.weight, userOverride: clamped },
216
- },
217
- },
218
- },
219
- };
220
- }
221
-
222
- case 'SET_FONT_WEIGHT_NAME': {
223
- const { tempId, weightName } = action;
224
- const font = state.fonts[tempId];
225
- if (!font) return state;
226
-
227
- return {
228
- ...state,
229
- fonts: {
230
- ...state.fonts,
231
- [tempId]: {
232
- ...font,
233
- weightName,
234
- decisions: {
235
- ...font.decisions,
236
- weightName: { ...font.decisions.weightName, userOverride: weightName },
237
- },
238
- },
239
- },
240
- };
241
- }
242
-
243
- case 'SET_FONT_STYLE': {
244
- const { tempId, style } = action;
245
- const font = state.fonts[tempId];
246
- if (!font) return state;
247
-
248
- return {
249
- ...state,
250
- fonts: {
251
- ...state.fonts,
252
- [tempId]: {
253
- ...font,
254
- style,
255
- decisions: {
256
- ...font.decisions,
257
- style: { ...font.decisions.style, userOverride: style },
258
- },
259
- },
260
- },
261
- };
262
- }
263
-
264
- case 'SET_FONT_SUBFAMILY': {
265
- const { tempId, subfamily } = action;
266
- const font = state.fonts[tempId];
267
- if (!font) return state;
268
-
269
- const oldSubfamily = font.subfamily || 'default';
270
- const newSubfamily = subfamily || 'default';
271
-
272
- const updated = {
273
- ...font,
274
- subfamily,
275
- decisions: {
276
- ...font.decisions,
277
- subfamily: { ...font.decisions.subfamily, userOverride: subfamily },
278
- },
279
- };
280
-
281
- let newState = { ...state, fonts: { ...state.fonts, [tempId]: updated } };
282
-
283
- // Move between subfamily groups
284
- if (oldSubfamily !== newSubfamily) {
285
- newState = moveFontBetweenGroups(newState, tempId, oldSubfamily, newSubfamily);
286
- }
287
-
288
- return newState;
289
- }
290
-
291
- case 'SET_FONT_ACTION': {
292
- const { tempId, decision } = action;
293
- const font = state.fonts[tempId];
294
- if (!font) return state;
295
-
296
- const existingDoc = { ...font.decisions.existingDocument, userChoice: decision };
297
- if (decision === 'create') {
298
- existingDoc.selectedCandidate = null;
299
- }
300
-
301
- return {
302
- ...state,
303
- fonts: {
304
- ...state.fonts,
305
- [tempId]: {
306
- ...font,
307
- decisions: { ...font.decisions, existingDocument: existingDoc },
308
- },
309
- },
310
- };
311
- }
312
-
313
- case 'SET_FONT_CANDIDATE': {
314
- const { tempId, candidate } = action;
315
- const font = state.fonts[tempId];
316
- if (!font) return state;
317
-
318
- return {
319
- ...state,
320
- fonts: {
321
- ...state.fonts,
322
- [tempId]: {
323
- ...font,
324
- documentId: candidate._id,
325
- decisions: {
326
- ...font.decisions,
327
- existingDocument: {
328
- ...font.decisions.existingDocument,
329
- selectedCandidate: candidate,
330
- userChoice: 'update',
331
- },
332
- },
333
- },
334
- },
335
- };
336
- }
337
-
338
- // ---------------------------------------------------------------
339
- // Subfamily Organization
340
- // ---------------------------------------------------------------
341
-
342
- case 'MOVE_FONT_TO_SUBFAMILY': {
343
- const { tempId, fromSubfamily, toSubfamily } = action;
344
- const font = state.fonts[tempId];
345
- if (!font) return state;
346
-
347
- let newState = {
348
- ...state,
349
- fonts: {
350
- ...state.fonts,
351
- [tempId]: { ...font, subfamily: toSubfamily },
352
- },
353
- };
354
- return moveFontBetweenGroups(newState, tempId, fromSubfamily, toSubfamily);
355
- }
356
-
357
- case 'CREATE_SUBFAMILY_GROUP': {
358
- const { title } = action;
359
- if (state.subfamilyGroups[title]) return state;
360
- return {
361
- ...state,
362
- subfamilyGroups: {
363
- ...state.subfamilyGroups,
364
- [title]: { title, fontIds: [] },
365
- },
366
- };
367
- }
368
-
369
- case 'REMOVE_SUBFAMILY_GROUP': {
370
- const { title } = action;
371
- const group = state.subfamilyGroups[title];
372
- if (!group) return state;
373
- if (group.fontIds.length > 0) {
374
- console.warn('Cannot remove subfamily group with fonts — reassign fonts first');
375
- return state;
376
- }
377
- const { [title]: _, ...remaining } = state.subfamilyGroups;
378
- return { ...state, subfamilyGroups: remaining };
379
- }
380
-
381
- // ---------------------------------------------------------------
382
- // Bulk Actions
383
- // ---------------------------------------------------------------
384
-
385
- case 'ACCEPT_ALL_SUGGESTIONS': {
386
- const scope = action.scope || Object.keys(state.fonts);
387
- const fonts = { ...state.fonts };
388
-
389
- for (const tempId of scope) {
390
- if (!fonts[tempId]) continue;
391
- fonts[tempId] = resetFontToSuggestions(fonts[tempId]);
392
- }
393
-
394
- // Rebuild subfamily groups after resetting
395
- const subfamilyGroups = rebuildSubfamilyGroups(fonts);
396
- return { ...state, fonts, subfamilyGroups };
397
- }
398
-
399
- case 'RESET_FONT_TO_SUGGESTIONS': {
400
- const { tempId } = action;
401
- const font = state.fonts[tempId];
402
- if (!font) return state;
403
-
404
- const reset = resetFontToSuggestions(font);
405
- const fonts = { ...state.fonts, [tempId]: reset };
406
- const subfamilyGroups = rebuildSubfamilyGroups(fonts);
407
- return { ...state, fonts, subfamilyGroups };
408
- }
409
-
410
- case 'REMOVE_FONT': {
411
- const { tempId } = action;
412
- if (!state.fonts[tempId]) return state;
413
-
414
- const { [tempId]: removed, ...remainingFonts } = state.fonts;
415
-
416
- // Remove from subfamily groups
417
- const subfamilyGroups = {};
418
- for (const [key, group] of Object.entries(state.subfamilyGroups)) {
419
- const filtered = group.fontIds.filter(id => id !== tempId);
420
- if (filtered.length > 0) {
421
- subfamilyGroups[key] = { ...group, fontIds: filtered };
422
- }
423
- }
424
-
425
- return { ...state, fonts: remainingFonts, subfamilyGroups };
426
- }
427
-
428
- default:
429
- return state;
430
- }
431
- }
432
-
433
- // -----------------------------------------------------------------------
434
- // Helpers
435
- // -----------------------------------------------------------------------
436
-
437
- /** Resets a font entry's overrides back to system-detected values */
438
- function resetFontToSuggestions(font) {
439
- const d = font.decisions;
440
- return {
441
- ...font,
442
- title: d.title.processed,
443
- documentId: d.documentId.generated,
444
- weight: d.weight.detected,
445
- weightName: d.weightName.detected,
446
- style: d.style.detected,
447
- subfamily: d.subfamily.detected,
448
- _idConflict: false,
449
- decisions: {
450
- ...d,
451
- title: { ...d.title, userOverride: null, source: d.title.original ? d.title.source : d.title.source },
452
- documentId: { ...d.documentId, userOverride: null },
453
- weight: { ...d.weight, userOverride: null },
454
- weightName: { ...d.weightName, userOverride: null },
455
- style: { ...d.style, userOverride: null },
456
- subfamily: { ...d.subfamily, userOverride: null },
457
- existingDocument: { ...d.existingDocument, userChoice: null, selectedCandidate: null },
458
- },
459
- };
460
- }
461
-
462
- /** Moves a font between subfamily groups, creating/removing groups as needed */
463
- function moveFontBetweenGroups(state, tempId, fromKey, toKey) {
464
- const groups = { ...state.subfamilyGroups };
465
-
466
- // Remove from old group
467
- if (groups[fromKey]) {
468
- const filtered = groups[fromKey].fontIds.filter(id => id !== tempId);
469
- if (filtered.length === 0) {
470
- delete groups[fromKey];
471
- } else {
472
- groups[fromKey] = { ...groups[fromKey], fontIds: filtered };
473
- }
474
- }
475
-
476
- // Add to new group (guard against duplicates)
477
- if (!groups[toKey]) {
478
- groups[toKey] = { title: toKey, fontIds: [] };
479
- }
480
- if (!groups[toKey].fontIds.includes(tempId)) {
481
- groups[toKey] = { ...groups[toKey], fontIds: [...groups[toKey].fontIds, tempId] };
482
- }
483
-
484
- return { ...state, subfamilyGroups: groups };
485
- }
486
-
487
- /** Updates a font and checks for documentId collisions across all fonts */
488
- function updateFontAndCheckConflicts(state, tempId, updatedFont) {
489
- const fonts = { ...state.fonts, [tempId]: updatedFont };
490
-
491
- // Clear old conflicts and detect new ones
492
- const idMap = {};
493
- for (const [id, font] of Object.entries(fonts)) {
494
- fonts[id] = { ...font, _idConflict: false };
495
- const docId = font.documentId;
496
- if (!idMap[docId]) {
497
- idMap[docId] = [id];
498
- } else {
499
- idMap[docId].push(id);
500
- }
501
- }
502
-
503
- // Mark conflicts
504
- for (const ids of Object.values(idMap)) {
505
- if (ids.length > 1) {
506
- for (const id of ids) {
507
- fonts[id] = { ...fonts[id], _idConflict: true };
508
- }
509
- }
510
- }
511
-
512
- return { ...state, fonts };
513
- }
514
-
515
- /** Rebuilds subfamily groups from the fonts map */
516
- function rebuildSubfamilyGroups(fonts) {
517
- const groups = {};
518
- for (const [tempId, font] of Object.entries(fonts)) {
519
- if (font.status === FONT_STATUS.ERROR) continue;
520
- const sfName = font.subfamily || 'default';
521
- if (!font.variableFont || font.subfamily) {
522
- if (!groups[sfName]) {
523
- groups[sfName] = { title: sfName, fontIds: [] };
524
- }
525
- if (!groups[sfName].fontIds.includes(tempId)) {
526
- groups[sfName].fontIds.push(tempId);
527
- }
528
- }
529
- }
530
- return groups;
531
- }
1
+ // Plan reducer — manages UploadPlan state for the upload modal review UI
2
+
3
+ import { PLAN_PHASE, FONT_STATUS, PROCESSING_OWNED_FIELDS } from './planTypes';
4
+ import { sanitizeForSanityId } from './sanitizeForSanityId';
5
+ import { retitleAllFonts } from './retitleFontEntries';
6
+
7
+ /** Valid phase transitions — any phase can transition to 'idle' (reset) */
8
+ const VALID_TRANSITIONS = {
9
+ [PLAN_PHASE.IDLE]: [PLAN_PHASE.PROCESSING],
10
+ [PLAN_PHASE.PROCESSING]: [PLAN_PHASE.REVIEWING],
11
+ [PLAN_PHASE.REVIEWING]: [PLAN_PHASE.READY, PLAN_PHASE.EXECUTING],
12
+ [PLAN_PHASE.READY]: [PLAN_PHASE.EXECUTING],
13
+ [PLAN_PHASE.EXECUTING]: [PLAN_PHASE.COMPLETE, PLAN_PHASE.ERROR],
14
+ [PLAN_PHASE.COMPLETE]: [PLAN_PHASE.EXECUTING],
15
+ [PLAN_PHASE.ERROR]: [PLAN_PHASE.EXECUTING],
16
+ };
17
+
18
+ /**
19
+ * Plan reducer for useReducer. Manages the UploadPlan state.
20
+ * Write domain isolation: processing actions only write processing-owned fields;
21
+ * user edit actions only write decisions.*.userOverride.
22
+ *
23
+ * @param {object} state - Current UploadPlan
24
+ * @param {object} action - Dispatched action
25
+ * @returns {object} New UploadPlan
26
+ */
27
+ export function planReducer(state, action) {
28
+ switch (action.type) {
29
+ // ---------------------------------------------------------------
30
+ // Phase / Settings
31
+ // ---------------------------------------------------------------
32
+
33
+ case 'SET_PHASE': {
34
+ if (action.phase === PLAN_PHASE.IDLE) {
35
+ return { ...state, phase: PLAN_PHASE.IDLE };
36
+ }
37
+ const validNext = VALID_TRANSITIONS[state.phase] || [];
38
+ if (!validNext.includes(action.phase)) {
39
+ console.warn(`Invalid phase transition: ${state.phase} → ${action.phase}`);
40
+ return state;
41
+ }
42
+ const nextState = { ...state, phase: action.phase };
43
+ // Allow setting total file count when entering PROCESSING phase
44
+ if (typeof action.totalFiles === 'number') {
45
+ nextState.processingProgress = {
46
+ ...state.processingProgress,
47
+ total: action.totalFiles,
48
+ completed: 0,
49
+ failed: 0,
50
+ currentFile: null,
51
+ };
52
+ }
53
+ return nextState;
54
+ }
55
+
56
+ case 'SET_SETTINGS': {
57
+ if (state.phase !== PLAN_PHASE.IDLE && state.phase !== PLAN_PHASE.REVIEWING && state.phase !== PLAN_PHASE.READY) {
58
+ console.warn('SET_SETTINGS blocked — settings locked during processing/execution');
59
+ return state;
60
+ }
61
+ const newSettings = { ...state.settings, ...action.settings };
62
+ let newState = { ...state, settings: newSettings };
63
+
64
+ // Retitle fonts when preserveShortenedNames changes during review
65
+ const shortenedChanged = 'preserveShortenedNames' in action.settings
66
+ && action.settings.preserveShortenedNames !== state.settings.preserveShortenedNames;
67
+ if (shortenedChanged && Object.keys(state.fonts).length > 0) {
68
+ const typefaceTitle = action.typefaceTitle || state.settings.typefaceTitle || '';
69
+ const retitled = retitleAllFonts(state.fonts, newSettings.preserveShortenedNames, typefaceTitle);
70
+ const subfamilyGroups = rebuildSubfamilyGroups(retitled);
71
+ newState = { ...newState, fonts: retitled, subfamilyGroups };
72
+ }
73
+
74
+ return newState;
75
+ }
76
+
77
+ // ---------------------------------------------------------------
78
+ // Processing (Phase 1) — dispatched by buildUploadPlan callbacks
79
+ // ---------------------------------------------------------------
80
+
81
+ case 'UPDATE_PROCESSING_PROGRESS': {
82
+ return {
83
+ ...state,
84
+ processingProgress: { ...state.processingProgress, ...action.progress },
85
+ };
86
+ }
87
+
88
+ case 'ADD_PROCESSED_FONT': {
89
+ const { tempId, fontEntry } = action;
90
+ const fonts = { ...state.fonts };
91
+
92
+ if (fonts[tempId]) {
93
+ // Font already exists (user may have edited) — merge processing-owned fields only
94
+ const existing = fonts[tempId];
95
+ const merged = { ...existing };
96
+ for (const field of PROCESSING_OWNED_FIELDS) {
97
+ merged[field] = fontEntry[field];
98
+ }
99
+ fonts[tempId] = merged;
100
+ } else {
101
+ fonts[tempId] = fontEntry;
102
+ }
103
+
104
+ // Update subfamily groups
105
+ const subfamilyGroups = { ...state.subfamilyGroups };
106
+ const sfName = fontEntry.subfamily || 'default';
107
+ if (!fontEntry.variableFont || fontEntry.subfamily) {
108
+ if (!subfamilyGroups[sfName]) {
109
+ subfamilyGroups[sfName] = { title: sfName, fontIds: [] };
110
+ }
111
+ // Guard against duplicate tempId in fontIds
112
+ if (!subfamilyGroups[sfName].fontIds.includes(tempId)) {
113
+ subfamilyGroups[sfName] = {
114
+ ...subfamilyGroups[sfName],
115
+ fontIds: [...subfamilyGroups[sfName].fontIds, tempId],
116
+ };
117
+ }
118
+ }
119
+
120
+ return {
121
+ ...state,
122
+ fonts,
123
+ subfamilyGroups,
124
+ processingProgress: {
125
+ ...state.processingProgress,
126
+ completed: state.processingProgress.completed + 1,
127
+ },
128
+ };
129
+ }
130
+
131
+ case 'SET_PROCESSING_ERROR': {
132
+ const { tempId, error } = action;
133
+ if (!state.fonts[tempId]) return state;
134
+ return {
135
+ ...state,
136
+ fonts: {
137
+ ...state.fonts,
138
+ [tempId]: { ...state.fonts[tempId], status: FONT_STATUS.ERROR, error },
139
+ },
140
+ processingProgress: {
141
+ ...state.processingProgress,
142
+ failed: state.processingProgress.failed + 1,
143
+ },
144
+ };
145
+ }
146
+
147
+ // ---------------------------------------------------------------
148
+ // User Edits (Review Step)
149
+ // ---------------------------------------------------------------
150
+
151
+ case 'SET_FONT_TITLE': {
152
+ const { tempId, title, source: titleSource } = action;
153
+ const font = state.fonts[tempId];
154
+ if (!font) return state;
155
+
156
+ // When source is provided (e.g. from clicking a name table suggestion), preserve it.
157
+ // The display layer appends "(user override)" — don't bake it into the source string.
158
+ const newSource = titleSource || 'user-override';
159
+
160
+ const updated = {
161
+ ...font,
162
+ title,
163
+ decisions: {
164
+ ...font.decisions,
165
+ title: { ...font.decisions.title, userOverride: title, source: newSource },
166
+ },
167
+ };
168
+
169
+ // Auto-derive documentId unless it has its own override
170
+ if (!font.decisions.documentId.userOverride) {
171
+ const newDocId = sanitizeForSanityId(title);
172
+ updated.documentId = newDocId;
173
+ updated.decisions = {
174
+ ...updated.decisions,
175
+ documentId: { ...updated.decisions.documentId, generated: newDocId },
176
+ };
177
+ }
178
+
179
+ return updateFontAndCheckConflicts(state, tempId, updated);
180
+ }
181
+
182
+ case 'SET_FONT_DOCUMENT_ID': {
183
+ const { tempId, documentId } = action;
184
+ const font = state.fonts[tempId];
185
+ if (!font) return state;
186
+
187
+ const sanitized = sanitizeForSanityId(documentId);
188
+ const updated = {
189
+ ...font,
190
+ documentId: sanitized,
191
+ decisions: {
192
+ ...font.decisions,
193
+ documentId: { ...font.decisions.documentId, userOverride: documentId },
194
+ },
195
+ };
196
+
197
+ return updateFontAndCheckConflicts(state, tempId, updated);
198
+ }
199
+
200
+ case 'SET_FONT_WEIGHT': {
201
+ const { tempId, weight } = action;
202
+ const font = state.fonts[tempId];
203
+ if (!font) return state;
204
+
205
+ const clamped = Math.max(1, Math.min(1000, weight));
206
+ return {
207
+ ...state,
208
+ fonts: {
209
+ ...state.fonts,
210
+ [tempId]: {
211
+ ...font,
212
+ weight: clamped,
213
+ decisions: {
214
+ ...font.decisions,
215
+ weight: { ...font.decisions.weight, userOverride: clamped },
216
+ },
217
+ },
218
+ },
219
+ };
220
+ }
221
+
222
+ case 'SET_FONT_WEIGHT_NAME': {
223
+ const { tempId, weightName } = action;
224
+ const font = state.fonts[tempId];
225
+ if (!font) return state;
226
+
227
+ return {
228
+ ...state,
229
+ fonts: {
230
+ ...state.fonts,
231
+ [tempId]: {
232
+ ...font,
233
+ weightName,
234
+ decisions: {
235
+ ...font.decisions,
236
+ weightName: { ...font.decisions.weightName, userOverride: weightName },
237
+ },
238
+ },
239
+ },
240
+ };
241
+ }
242
+
243
+ case 'SET_FONT_STYLE': {
244
+ const { tempId, style } = action;
245
+ const font = state.fonts[tempId];
246
+ if (!font) return state;
247
+
248
+ return {
249
+ ...state,
250
+ fonts: {
251
+ ...state.fonts,
252
+ [tempId]: {
253
+ ...font,
254
+ style,
255
+ decisions: {
256
+ ...font.decisions,
257
+ style: { ...font.decisions.style, userOverride: style },
258
+ },
259
+ },
260
+ },
261
+ };
262
+ }
263
+
264
+ case 'SET_FONT_SUBFAMILY': {
265
+ const { tempId, subfamily } = action;
266
+ const font = state.fonts[tempId];
267
+ if (!font) return state;
268
+
269
+ const oldSubfamily = font.subfamily || 'default';
270
+ const newSubfamily = subfamily || 'default';
271
+
272
+ const updated = {
273
+ ...font,
274
+ subfamily,
275
+ decisions: {
276
+ ...font.decisions,
277
+ subfamily: { ...font.decisions.subfamily, userOverride: subfamily },
278
+ },
279
+ };
280
+
281
+ let newState = { ...state, fonts: { ...state.fonts, [tempId]: updated } };
282
+
283
+ // Move between subfamily groups
284
+ if (oldSubfamily !== newSubfamily) {
285
+ newState = moveFontBetweenGroups(newState, tempId, oldSubfamily, newSubfamily);
286
+ }
287
+
288
+ return newState;
289
+ }
290
+
291
+ case 'SET_FONT_ACTION': {
292
+ const { tempId, decision } = action;
293
+ const font = state.fonts[tempId];
294
+ if (!font) return state;
295
+
296
+ const existingDoc = { ...font.decisions.existingDocument, userChoice: decision };
297
+ if (decision === 'create') {
298
+ existingDoc.selectedCandidate = null;
299
+ }
300
+
301
+ return {
302
+ ...state,
303
+ fonts: {
304
+ ...state.fonts,
305
+ [tempId]: {
306
+ ...font,
307
+ decisions: { ...font.decisions, existingDocument: existingDoc },
308
+ },
309
+ },
310
+ };
311
+ }
312
+
313
+ case 'SET_FONT_CANDIDATE': {
314
+ const { tempId, candidate } = action;
315
+ const font = state.fonts[tempId];
316
+ if (!font) return state;
317
+
318
+ return {
319
+ ...state,
320
+ fonts: {
321
+ ...state.fonts,
322
+ [tempId]: {
323
+ ...font,
324
+ documentId: candidate._id,
325
+ decisions: {
326
+ ...font.decisions,
327
+ existingDocument: {
328
+ ...font.decisions.existingDocument,
329
+ selectedCandidate: candidate,
330
+ userChoice: 'update',
331
+ },
332
+ },
333
+ },
334
+ },
335
+ };
336
+ }
337
+
338
+ // ---------------------------------------------------------------
339
+ // Subfamily Organization
340
+ // ---------------------------------------------------------------
341
+
342
+ case 'MOVE_FONT_TO_SUBFAMILY': {
343
+ const { tempId, fromSubfamily, toSubfamily } = action;
344
+ const font = state.fonts[tempId];
345
+ if (!font) return state;
346
+
347
+ let newState = {
348
+ ...state,
349
+ fonts: {
350
+ ...state.fonts,
351
+ [tempId]: { ...font, subfamily: toSubfamily },
352
+ },
353
+ };
354
+ return moveFontBetweenGroups(newState, tempId, fromSubfamily, toSubfamily);
355
+ }
356
+
357
+ case 'CREATE_SUBFAMILY_GROUP': {
358
+ const { title } = action;
359
+ if (state.subfamilyGroups[title]) return state;
360
+ return {
361
+ ...state,
362
+ subfamilyGroups: {
363
+ ...state.subfamilyGroups,
364
+ [title]: { title, fontIds: [] },
365
+ },
366
+ };
367
+ }
368
+
369
+ case 'REMOVE_SUBFAMILY_GROUP': {
370
+ const { title } = action;
371
+ const group = state.subfamilyGroups[title];
372
+ if (!group) return state;
373
+ if (group.fontIds.length > 0) {
374
+ console.warn('Cannot remove subfamily group with fonts — reassign fonts first');
375
+ return state;
376
+ }
377
+ const { [title]: _, ...remaining } = state.subfamilyGroups;
378
+ return { ...state, subfamilyGroups: remaining };
379
+ }
380
+
381
+ // ---------------------------------------------------------------
382
+ // Bulk Actions
383
+ // ---------------------------------------------------------------
384
+
385
+ case 'ACCEPT_ALL_SUGGESTIONS': {
386
+ const scope = action.scope || Object.keys(state.fonts);
387
+ const fonts = { ...state.fonts };
388
+
389
+ for (const tempId of scope) {
390
+ if (!fonts[tempId]) continue;
391
+ fonts[tempId] = resetFontToSuggestions(fonts[tempId]);
392
+ }
393
+
394
+ // Rebuild subfamily groups after resetting
395
+ const subfamilyGroups = rebuildSubfamilyGroups(fonts);
396
+ return { ...state, fonts, subfamilyGroups };
397
+ }
398
+
399
+ case 'RESET_FONT_TO_SUGGESTIONS': {
400
+ const { tempId } = action;
401
+ const font = state.fonts[tempId];
402
+ if (!font) return state;
403
+
404
+ const reset = resetFontToSuggestions(font);
405
+ const fonts = { ...state.fonts, [tempId]: reset };
406
+ const subfamilyGroups = rebuildSubfamilyGroups(fonts);
407
+ return { ...state, fonts, subfamilyGroups };
408
+ }
409
+
410
+ case 'REMOVE_FONT': {
411
+ const { tempId } = action;
412
+ if (!state.fonts[tempId]) return state;
413
+
414
+ const { [tempId]: removed, ...remainingFonts } = state.fonts;
415
+
416
+ // Remove from subfamily groups
417
+ const subfamilyGroups = {};
418
+ for (const [key, group] of Object.entries(state.subfamilyGroups)) {
419
+ const filtered = group.fontIds.filter(id => id !== tempId);
420
+ if (filtered.length > 0) {
421
+ subfamilyGroups[key] = { ...group, fontIds: filtered };
422
+ }
423
+ }
424
+
425
+ return { ...state, fonts: remainingFonts, subfamilyGroups };
426
+ }
427
+
428
+ default:
429
+ return state;
430
+ }
431
+ }
432
+
433
+ // -----------------------------------------------------------------------
434
+ // Helpers
435
+ // -----------------------------------------------------------------------
436
+
437
+ /** Resets a font entry's overrides back to system-detected values */
438
+ function resetFontToSuggestions(font) {
439
+ const d = font.decisions;
440
+ return {
441
+ ...font,
442
+ title: d.title.processed,
443
+ documentId: d.documentId.generated,
444
+ weight: d.weight.detected,
445
+ weightName: d.weightName.detected,
446
+ style: d.style.detected,
447
+ subfamily: d.subfamily.detected,
448
+ _idConflict: false,
449
+ decisions: {
450
+ ...d,
451
+ title: { ...d.title, userOverride: null, source: d.title.original ? d.title.source : d.title.source },
452
+ documentId: { ...d.documentId, userOverride: null },
453
+ weight: { ...d.weight, userOverride: null },
454
+ weightName: { ...d.weightName, userOverride: null },
455
+ style: { ...d.style, userOverride: null },
456
+ subfamily: { ...d.subfamily, userOverride: null },
457
+ existingDocument: { ...d.existingDocument, userChoice: null, selectedCandidate: null },
458
+ },
459
+ };
460
+ }
461
+
462
+ /** Moves a font between subfamily groups, creating/removing groups as needed */
463
+ function moveFontBetweenGroups(state, tempId, fromKey, toKey) {
464
+ const groups = { ...state.subfamilyGroups };
465
+
466
+ // Remove from old group
467
+ if (groups[fromKey]) {
468
+ const filtered = groups[fromKey].fontIds.filter(id => id !== tempId);
469
+ if (filtered.length === 0) {
470
+ delete groups[fromKey];
471
+ } else {
472
+ groups[fromKey] = { ...groups[fromKey], fontIds: filtered };
473
+ }
474
+ }
475
+
476
+ // Add to new group (guard against duplicates)
477
+ if (!groups[toKey]) {
478
+ groups[toKey] = { title: toKey, fontIds: [] };
479
+ }
480
+ if (!groups[toKey].fontIds.includes(tempId)) {
481
+ groups[toKey] = { ...groups[toKey], fontIds: [...groups[toKey].fontIds, tempId] };
482
+ }
483
+
484
+ return { ...state, subfamilyGroups: groups };
485
+ }
486
+
487
+ /** Updates a font and checks for documentId collisions across all fonts */
488
+ function updateFontAndCheckConflicts(state, tempId, updatedFont) {
489
+ const fonts = { ...state.fonts, [tempId]: updatedFont };
490
+
491
+ // Clear old conflicts and detect new ones
492
+ const idMap = {};
493
+ for (const [id, font] of Object.entries(fonts)) {
494
+ fonts[id] = { ...font, _idConflict: false };
495
+ const docId = font.documentId;
496
+ if (!idMap[docId]) {
497
+ idMap[docId] = [id];
498
+ } else {
499
+ idMap[docId].push(id);
500
+ }
501
+ }
502
+
503
+ // Mark conflicts
504
+ for (const ids of Object.values(idMap)) {
505
+ if (ids.length > 1) {
506
+ for (const id of ids) {
507
+ fonts[id] = { ...fonts[id], _idConflict: true };
508
+ }
509
+ }
510
+ }
511
+
512
+ return { ...state, fonts };
513
+ }
514
+
515
+ /** Rebuilds subfamily groups from the fonts map */
516
+ function rebuildSubfamilyGroups(fonts) {
517
+ const groups = {};
518
+ for (const [tempId, font] of Object.entries(fonts)) {
519
+ if (font.status === FONT_STATUS.ERROR) continue;
520
+ const sfName = font.subfamily || 'default';
521
+ if (!font.variableFont || font.subfamily) {
522
+ if (!groups[sfName]) {
523
+ groups[sfName] = { title: sfName, fontIds: [] };
524
+ }
525
+ if (!groups[sfName].fontIds.includes(tempId)) {
526
+ groups[sfName].fontIds.push(tempId);
527
+ }
528
+ }
529
+ }
530
+ return groups;
531
+ }