@nodaro/prompts 1.7.2 → 1.8.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.
Files changed (76) hide show
  1. package/dist/index.cjs +5453 -4271
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1221 -63
  4. package/dist/index.d.ts +1221 -63
  5. package/dist/index.js +5366 -4273
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/b7-person-pack-e2e.test.ts +37 -0
  9. package/src/__tests__/catalog-funnel-ratchet.test.ts +97 -0
  10. package/src/__tests__/catalog-packs.test.ts +130 -0
  11. package/src/__tests__/catalog-sidecar-coverage.test.ts +27 -0
  12. package/src/__tests__/catalog-terms.test.ts +162 -0
  13. package/src/__tests__/character-default-role.test.ts +3 -2
  14. package/src/__tests__/content-free-contract.test.ts +45 -0
  15. package/src/__tests__/dod-replace-pack-acceptance.test.ts +41 -0
  16. package/src/__tests__/fixtures/parameter-hint-golden.json +2959 -0
  17. package/src/__tests__/fixtures/person-sector-pack.ts +27 -0
  18. package/src/__tests__/i18n-entry-completeness.test.ts +5 -2
  19. package/src/__tests__/parameter-hint-mode.test.ts +385 -0
  20. package/src/__tests__/parameter-prompt-hint-pack-fallback.test.ts +25 -0
  21. package/src/__tests__/person-packs.test.ts +166 -0
  22. package/src/__tests__/project-all-catalogs.test.ts +26 -0
  23. package/src/__tests__/prompt-builder.test.ts +53 -0
  24. package/src/__tests__/registered-catalogs-funnel.test.ts +30 -0
  25. package/src/__tests__/registered-catalogs-guard.test.ts +11 -0
  26. package/src/__tests__/term.test.ts +104 -0
  27. package/src/__tests__/transitions.test.ts +8 -5
  28. package/src/__tests__/upstream-immutability.test.ts +23 -0
  29. package/src/action-fx.ts +67 -17
  30. package/src/aesthetic.ts +54 -6
  31. package/src/atmosphere.ts +64 -23
  32. package/src/backdrop.ts +44 -30
  33. package/src/camera-format.ts +33 -11
  34. package/src/camera-motions.ts +89 -0
  35. package/src/catalog-packs.ts +125 -0
  36. package/src/catalog-sidecar-coverage.ts +36 -0
  37. package/src/character-fx.ts +112 -39
  38. package/src/color-look.ts +44 -27
  39. package/src/composition-effects.ts +21 -7
  40. package/src/era.ts +24 -0
  41. package/src/exposure-settings.ts +76 -18
  42. package/src/framing.ts +100 -0
  43. package/src/held-prop.ts +125 -63
  44. package/src/identity-lock.ts +12 -5
  45. package/src/image-reference-doctrine.ts +55 -0
  46. package/src/index.ts +6 -0
  47. package/src/instrumentation.ts +148 -57
  48. package/src/lens.ts +31 -15
  49. package/src/lighting.ts +120 -59
  50. package/src/loop-subject.ts +27 -1
  51. package/src/materials.ts +123 -69
  52. package/src/mood.ts +123 -51
  53. package/src/music-genre.ts +171 -67
  54. package/src/music-mood.ts +85 -17
  55. package/src/parameter-prompt-hint.ts +168 -56
  56. package/src/person-packs.ts +182 -0
  57. package/src/person.ts +506 -408
  58. package/src/photo-genre.ts +37 -22
  59. package/src/photographer.ts +138 -1
  60. package/src/picker-catalogs.ts +86 -40
  61. package/src/picker-wiring.ts +1 -1
  62. package/src/pose.ts +105 -40
  63. package/src/post-process-effects.ts +51 -8
  64. package/src/prompt-builder.ts +16 -1
  65. package/src/provider-prompt-doctrine.ts +1 -2
  66. package/src/render-quality.ts +25 -7
  67. package/src/setting.ts +30 -14
  68. package/src/style-presets.ts +1 -1
  69. package/src/style.ts +33 -15
  70. package/src/styling.ts +155 -94
  71. package/src/surround-fill.ts +67 -0
  72. package/src/temporal.ts +80 -18
  73. package/src/term.ts +155 -0
  74. package/src/transitions.ts +73 -26
  75. package/src/voice-character.ts +190 -90
  76. package/src/voice-delivery.ts +83 -12
@@ -7,6 +7,8 @@
7
7
  * motion enabled.
8
8
  */
9
9
 
10
+ import { resolveTerm, type PickerHintMode } from "./term.js"
11
+
10
12
  export type CameraMotionCategory =
11
13
  | "default"
12
14
  | "pan"
@@ -27,6 +29,12 @@ export interface CameraMotion {
27
29
  readonly category: CameraMotionCategory
28
30
  readonly description: string
29
31
  readonly promptHint: string
32
+ /**
33
+ * Compact professional term for this move ("whip pan left", "push-pull
34
+ * swing") — authored only where the lowercased label is not what a
35
+ * cinematographer would write in a prompt. See `term.ts`.
36
+ */
37
+ readonly term?: string
30
38
  }
31
39
 
32
40
  export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
@@ -44,6 +52,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
44
52
  category: "default",
45
53
  description: "Fixed camera, no movement",
46
54
  promptHint: "locked off static camera, no camera movement",
55
+ term: "locked-off static camera",
47
56
  },
48
57
  {
49
58
  id: "handheld",
@@ -51,6 +60,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
51
60
  category: "default",
52
61
  description: "Natural handheld shake",
53
62
  promptHint: "handheld camera with subtle natural shake and micro movements",
63
+ term: "handheld camera",
54
64
  },
55
65
  {
56
66
  id: "steadicam",
@@ -58,6 +68,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
58
68
  category: "default",
59
69
  description: "Smooth stabilized walking shot",
60
70
  promptHint: "smooth steadicam shot, gliding stabilized movement through the scene",
71
+ term: "smooth steadicam shot",
61
72
  },
62
73
 
63
74
  // Pan — rotation on vertical axis
@@ -164,6 +175,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
164
175
  category: "dolly",
165
176
  description: "Slow subtle push toward subject",
166
177
  promptHint: "slow push in, gentle forward movement toward the subject",
178
+ term: "slow push-in",
167
179
  },
168
180
  {
169
181
  id: "pull-out",
@@ -171,6 +183,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
171
183
  category: "dolly",
172
184
  description: "Slow subtle pull back from subject",
173
185
  promptHint: "slow pull out, gentle backward movement away from the subject",
186
+ term: "slow pull-out",
174
187
  },
175
188
  {
176
189
  id: "breathing",
@@ -185,6 +198,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
185
198
  category: "dolly",
186
199
  description: "Camera swings toward subject then away",
187
200
  promptHint: "push-pull swing, the camera moves toward the subject then back away in a single move, a swinging approach-and-retreat that creates emphasis or hesitation",
201
+ term: "push-pull swing",
188
202
  },
189
203
  {
190
204
  id: "creep-in",
@@ -192,6 +206,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
192
206
  category: "dolly",
193
207
  description: "Imperceptibly slow push-in over time",
194
208
  promptHint: "creep-in, imperceptibly slow push-in over many seconds, building dread or tension",
209
+ term: "slow creeping push-in",
195
210
  },
196
211
  {
197
212
  id: "creep-out",
@@ -199,6 +214,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
199
214
  category: "dolly",
200
215
  description: "Imperceptibly slow pull-out over time",
201
216
  promptHint: "creep-out, imperceptibly slow pull-out over many seconds, isolating the subject in space",
217
+ term: "slow creeping pull-out",
202
218
  },
203
219
 
204
220
  // Truck — lateral slide
@@ -261,6 +277,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
261
277
  category: "roll",
262
278
  description: "Camera rotates a full 360° on its axis",
263
279
  promptHint: "full 360 degree spin, the camera rotates a complete revolution on its own lens axis",
280
+ term: "full 360 degree camera roll",
264
281
  },
265
282
 
266
283
  // Orbit / Arc
@@ -298,6 +315,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
298
315
  category: "orbit",
299
316
  description: "Full circular arc around subject",
300
317
  promptHint: "full 360 degree orbit, the camera arcs a complete revolution around the subject",
318
+ term: "full 360 degree orbit",
301
319
  },
302
320
 
303
321
  // Crane / Jib
@@ -344,6 +362,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
344
362
  category: "tracking",
345
363
  description: "Follow subject from behind",
346
364
  promptHint: "follow shot, camera trails the subject from behind at a constant distance",
365
+ term: "follow shot from behind",
347
366
  },
348
367
  {
349
368
  id: "lead",
@@ -351,6 +370,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
351
370
  category: "tracking",
352
371
  description: "Move ahead of advancing subject",
353
372
  promptHint: "lead shot, camera moves backward ahead of the advancing subject",
373
+ term: "leading tracking shot",
354
374
  },
355
375
  {
356
376
  id: "drone-follow",
@@ -358,6 +378,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
358
378
  category: "tracking",
359
379
  description: "Elevated drone tracking subject",
360
380
  promptHint: "aerial drone follow shot, elevated camera smoothly tracking the subject from above and behind",
381
+ term: "aerial drone follow shot",
361
382
  },
362
383
  {
363
384
  id: "dolly-track",
@@ -365,6 +386,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
365
386
  category: "tracking",
366
387
  description: "Dolly on parallel track alongside subject",
367
388
  promptHint: "dolly on a parallel track alongside the subject, smooth lateral tracking with strong foreground parallax",
389
+ term: "parallel dolly tracking shot",
368
390
  },
369
391
  {
370
392
  id: "gimbal-walk",
@@ -372,6 +394,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
372
394
  category: "tracking",
373
395
  description: "Smooth walking shot on a 3-axis gimbal",
374
396
  promptHint: "gimbal walk, smooth walking shot on a 3-axis gimbal with floating steady forward motion",
397
+ term: "smooth gimbal walking shot",
375
398
  },
376
399
  {
377
400
  id: "ronin-glide",
@@ -379,6 +402,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
379
402
  category: "tracking",
380
403
  description: "Slow gliding move on a Ronin/Movi gimbal",
381
404
  promptHint: "ronin glide, slow gliding move on a Ronin or Movi gimbal, cinematic float without any shake",
405
+ term: "slow gliding gimbal move",
382
406
  },
383
407
  {
384
408
  id: "serpentine",
@@ -386,6 +410,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
386
410
  category: "tracking",
387
411
  description: "Camera weaves through obstacles in S-curves",
388
412
  promptHint: "serpentine track, the camera weaves through obstacles in S-curves, snaking forward along a winding path",
413
+ term: "serpentine tracking shot",
389
414
  },
390
415
 
391
416
  // Special angles / rigs
@@ -395,6 +420,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
395
420
  category: "special",
396
421
  description: "First person point of view",
397
422
  promptHint: "POV shot, first person perspective as seen through the subject's eyes",
423
+ term: "first person pov shot",
398
424
  },
399
425
  {
400
426
  id: "over-the-shoulder",
@@ -402,6 +428,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
402
428
  category: "special",
403
429
  description: "Frame past a character's shoulder",
404
430
  promptHint: "over the shoulder shot, framing past one character's shoulder onto another",
431
+ term: "over-the-shoulder shot",
405
432
  },
406
433
  {
407
434
  id: "birds-eye",
@@ -409,6 +436,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
409
436
  category: "special",
410
437
  description: "Direct top-down overhead view",
411
438
  promptHint: "bird's eye view, direct overhead top-down shot looking straight down",
439
+ term: "bird's eye view",
412
440
  },
413
441
  {
414
442
  id: "worms-eye",
@@ -416,6 +444,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
416
444
  category: "special",
417
445
  description: "Extreme low angle looking up",
418
446
  promptHint: "worm's eye view, extreme low angle looking up at the subject",
447
+ term: "worm's eye view",
419
448
  },
420
449
  {
421
450
  id: "aerial",
@@ -423,6 +452,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
423
452
  category: "special",
424
453
  description: "High altitude drone-style shot",
425
454
  promptHint: "aerial drone shot, high altitude slow forward movement over the landscape",
455
+ term: "high altitude aerial drone shot",
426
456
  },
427
457
  {
428
458
  id: "helicopter",
@@ -430,6 +460,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
430
460
  category: "special",
431
461
  description: "Wide high-altitude sweeping aerial",
432
462
  promptHint: "helicopter shot, high altitude wide sweeping aerial pass with strong lateral movement",
463
+ term: "sweeping helicopter aerial shot",
433
464
  },
434
465
  {
435
466
  id: "fly-over",
@@ -437,6 +468,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
437
468
  category: "special",
438
469
  description: "Low fast aerial pass over the scene",
439
470
  promptHint: "fly over shot, low altitude drone passing quickly over the scene with strong forward motion",
471
+ term: "low aerial fly-over pass",
440
472
  },
441
473
  {
442
474
  id: "flythrough",
@@ -444,6 +476,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
444
476
  category: "special",
445
477
  description: "Camera flies through space",
446
478
  promptHint: "flythrough shot, camera moving forward through the environment, weaving through obstacles",
479
+ term: "flythrough shot",
447
480
  },
448
481
  {
449
482
  id: "reveal",
@@ -451,6 +484,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
451
484
  category: "special",
452
485
  description: "Gradually reveal wider scene",
453
486
  promptHint: "reveal shot, camera rises and tilts to gradually reveal the subject and wider scene",
487
+ term: "rising reveal shot",
454
488
  },
455
489
  {
456
490
  id: "snorricam",
@@ -458,6 +492,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
458
492
  category: "special",
459
493
  description: "Body-mounted camera (subject locked to frame)",
460
494
  promptHint: "snorricam body-mounted shot, subject locked in frame while the world moves around them",
495
+ term: "body-mounted snorricam shot",
461
496
  },
462
497
  {
463
498
  id: "rack-focus",
@@ -474,6 +509,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
474
509
  category: "default",
475
510
  description: "Casual vlog-style handheld",
476
511
  promptHint: "casual handheld vlog-style camera, slight wandering and natural shake, talking-to-camera framing",
512
+ term: "handheld vlog-style camera",
477
513
  },
478
514
  {
479
515
  id: "pov-walk",
@@ -481,6 +517,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
481
517
  category: "tracking",
482
518
  description: "First-person walking POV",
483
519
  promptHint: "first-person POV walking camera, GoPro-style head-mounted perspective with natural footstep movement",
520
+ term: "first person walking pov",
484
521
  },
485
522
  {
486
523
  id: "velocity-edit",
@@ -488,6 +525,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
488
525
  category: "special",
489
526
  description: "TikTok speed-ramp pacing",
490
527
  promptHint: "rapid speed-ramped camera move with characteristic TikTok velocity-edit pacing, dynamic acceleration into and out of the shot",
528
+ term: "rapid speed-ramped camera move",
491
529
  },
492
530
  {
493
531
  id: "match-cut-zoom",
@@ -495,6 +533,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
495
533
  category: "zoom",
496
534
  description: "Beat-timed zoom for cuts",
497
535
  promptHint: "rapid zoom timed to a beat, suggesting a match cut to the next shot",
536
+ term: "rapid beat-timed zoom",
498
537
  },
499
538
  {
500
539
  id: "screen-tap",
@@ -502,6 +541,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
502
541
  category: "special",
503
542
  description: "On-screen finger-tap transition",
504
543
  promptHint: "camera transition triggered by an on-screen finger tap, TikTok-native pacing with snap to the next subject",
544
+ term: "on-screen finger-tap transition",
505
545
  },
506
546
  {
507
547
  id: "phone-flip",
@@ -509,6 +549,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
509
549
  category: "special",
510
550
  description: "Front/rear camera flip",
511
551
  promptHint: "camera-flip transition where the phone visibly rotates between the front and rear sensors, brief blur during the swap",
552
+ term: "front-to-rear phone camera flip",
512
553
  },
513
554
  {
514
555
  id: "gentle-drift",
@@ -516,6 +557,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
516
557
  category: "default",
517
558
  description: "Slow ambient floating motion",
518
559
  promptHint: "gentle camera drift, a slow ambient floating motion with no specific direction, the camera barely moves but never sits perfectly still, evocative of contemplative atmospheric shots",
560
+ term: "slow gentle camera drift",
519
561
  },
520
562
  {
521
563
  id: "parallax",
@@ -523,6 +565,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
523
565
  category: "default",
524
566
  description: "Lateral motion with foreground/background depth separation",
525
567
  promptHint: "parallax camera motion, lateral movement that emphasizes the depth separation between foreground and background elements, foreground objects appearing to move faster than distant ones",
568
+ term: "lateral parallax camera move",
526
569
  },
527
570
  ]
528
571
 
@@ -578,6 +621,18 @@ export function getCameraMotionPromptHint(id: string | undefined | null): string
578
621
  return getCameraMotion(id)?.promptHint ?? ""
579
622
  }
580
623
 
624
+ /**
625
+ * Compact counterpart of `getCameraMotionPromptHint`: the short professional
626
+ * term a cinematographer would write for this move ("whip pan left",
627
+ * "push-pull swing") where the hint is the full mechanism sentence. Same
628
+ * lookup and same empty-string-on-miss behavior, so the two getters can never
629
+ * disagree about which motion they describe; "auto" (which injects nothing)
630
+ * resolves to "" here too.
631
+ */
632
+ export function getCameraMotionTerm(id: string | undefined | null): string {
633
+ return resolveTerm(getCameraMotion(id))
634
+ }
635
+
581
636
  export const CAMERA_MOTION_IDS: ReadonlyArray<string> = CAMERA_MOTIONS.map((m) => m.id)
582
637
 
583
638
  // ---------------------------------------------------------------------------
@@ -598,12 +653,21 @@ export const CAMERA_MOTION_IDS: ReadonlyArray<string> = CAMERA_MOTIONS.map((m) =
598
653
  * Hints within each side are joined with " and " for grammatical flow.
599
654
  * If multiple nodes are connected (e.g. Framing + Lighting + Tone), all
600
655
  * three contribute their hint to the clause.
656
+ *
657
+ * @param mode `"compact"` delegates to `composeCameraMotionTermFromConnections`
658
+ * — the same start/end structure built from the motion's short professional
659
+ * `term`. The caller is expected to have resolved the connected nodes to
660
+ * THEIR terms too, so the whole fragment stays at one level of detail.
601
661
  */
602
662
  export function composeCameraMotionHintFromConnections(
603
663
  motionId: string | undefined,
604
664
  startHints: ReadonlyArray<string>,
605
665
  endHints: ReadonlyArray<string>,
666
+ mode: PickerHintMode = "full",
606
667
  ): string {
668
+ if (mode === "compact") {
669
+ return composeCameraMotionTermFromConnections(motionId, startHints, endHints)
670
+ }
607
671
  const base = getCameraMotionPromptHint(motionId)
608
672
  if (!base) return ""
609
673
  const parts: string[] = [base]
@@ -613,3 +677,28 @@ export function composeCameraMotionHintFromConnections(
613
677
  if (endClause) parts.push(`ending with ${endClause}`)
614
678
  return parts.join(", ")
615
679
  }
680
+
681
+ /**
682
+ * Compact-mode mirror of `composeCameraMotionHintFromConnections`: the same
683
+ * start/end structure, built from the motion's short `term` and the connected
684
+ * parameter nodes' terms instead of their full promptHints
685
+ * ("dolly in, beginning with wide shot, ending with medium close-up").
686
+ *
687
+ * Same shape in every respect — empty base (an "auto" or unknown motion) still
688
+ * yields "", each side is joined with " and ", and the clauses are appended in
689
+ * start-then-end order.
690
+ */
691
+ export function composeCameraMotionTermFromConnections(
692
+ motionId: string | undefined,
693
+ startTerms: ReadonlyArray<string>,
694
+ endTerms: ReadonlyArray<string>,
695
+ ): string {
696
+ const base = getCameraMotionTerm(motionId)
697
+ if (!base) return ""
698
+ const parts: string[] = [base]
699
+ const startClause = startTerms.filter((t) => t && t.length > 0).join(" and ")
700
+ const endClause = endTerms.filter((t) => t && t.length > 0).join(" and ")
701
+ if (startClause) parts.push(`beginning with ${startClause}`)
702
+ if (endClause) parts.push(`ending with ${endClause}`)
703
+ return parts.join(", ")
704
+ }
@@ -0,0 +1,125 @@
1
+ import type { PickerCatalog, PickerOption, PickerDimension } from "./picker-catalogs.js"
2
+ import type { LocaleId, LocaleCatalogMap } from "@nodaro/shared"
3
+ import { registerCatalogSidecars, resetCatalogSidecars } from "@nodaro/shared"
4
+ import { resolveTerm } from "./term.js"
5
+
6
+ /**
7
+ * Every option that leaves this module carries a RESOLVED `term`, exactly like
8
+ * the base registry's own options do.
9
+ *
10
+ * `PickerOption.term` is required at the type level, but a pack arrives from a
11
+ * separately-compiled bundle that may have been built against a `@nodaro/prompts`
12
+ * where the field did not exist yet — so at runtime a pack option can simply
13
+ * not have one. Resolving at COMPOSITION (rather than at each read) is what
14
+ * keeps the `/v1/catalogs` projection, the compact-hint read path and the
15
+ * `replace`-mode packs agreeing; a pack-added value would otherwise inject its
16
+ * full hint in full mode and NOTHING in compact.
17
+ */
18
+ /** Pack-author input shapes: identical to the registry's own types except that
19
+ * `term` is OPTIONAL — a vendored pack may predate the field, and a new
20
+ * required field on a published input type would be a breaking change. The
21
+ * composition root resolves it (`withTerm`), so everything that LEAVES this
22
+ * module still satisfies `PickerOption` with a resolved `term`. */
23
+ export type PickerOptionInput = Omit<PickerOption, "term"> & { readonly term?: string }
24
+ export type PickerDimensionInput = Omit<PickerDimension, "options"> & { readonly options: readonly PickerOptionInput[] }
25
+ export type PickerCatalogInput = Omit<PickerCatalog, "options" | "dimensions"> & {
26
+ readonly options?: readonly PickerOptionInput[]
27
+ readonly dimensions?: readonly PickerDimensionInput[]
28
+ }
29
+
30
+ function withTerm(o: PickerOptionInput): PickerOption {
31
+ return { ...o, term: resolveTerm(o) }
32
+ }
33
+
34
+ export type CatalogPackMode = "replace" | "extend" | "deny"
35
+
36
+ /**
37
+ * A vendored curation pack applied at the picker-catalog composition root.
38
+ * `replace` swaps a full vendored copy; `extend` appends options (single-dim)
39
+ * or merges dimensions by field (multi-dim); `deny` removes entry ids from the
40
+ * copy. Packs target EXISTING catalog ids only — a new catalog id is out of
41
+ * Phase-0 scope (it drags the 5-registry parameter-picker checklist).
42
+ *
43
+ * DEFERRED (do not build here): a `CatalogPolicy` (tags / deny-by-tag /
44
+ * override) plugs in downstream of this compose, at `getRegisteredPickerCatalogs`.
45
+ */
46
+ export interface CatalogPack {
47
+ readonly id: string
48
+ readonly catalogId: string
49
+ readonly mode: CatalogPackMode
50
+ readonly catalog?: PickerCatalogInput
51
+ readonly options?: readonly PickerOptionInput[]
52
+ readonly dimensions?: readonly PickerDimensionInput[]
53
+ readonly denyIds?: readonly string[]
54
+ /** Localized strings for this pack's added option ids, keyed by locale → id. */
55
+ readonly sidecars?: Partial<Record<LocaleId, LocaleCatalogMap>>
56
+ /** Locales deliberately not translated for this pack (reported, never failed). */
57
+ readonly exemptSidecarLocales?: readonly LocaleId[]
58
+ }
59
+
60
+ let packs: CatalogPack[] = []
61
+ let version = 0
62
+
63
+ export function registerCatalogPack(pack: CatalogPack): void {
64
+ if (packs.some((p) => p.id === pack.id)) throw new Error(`duplicate catalog pack id "${pack.id}"`)
65
+ packs = [...packs, pack]
66
+ version++
67
+ // Push this pack's localized sidecars into the shared app-UI localizer (G10)
68
+ // so pack-added entries resolve in `resolveLabel`/`resolveDescription`/search.
69
+ // One generic point covers every pack, incl. the person extend fan-out.
70
+ registerCatalogSidecars(pack.catalogId, pack.sidecars)
71
+ }
72
+ export function getRegisteredCatalogPacks(): readonly CatalogPack[] { return packs }
73
+ export function resetCatalogPacks(): void { packs = []; version++; resetCatalogSidecars() }
74
+ export function catalogPacksVersion(): number { return version }
75
+
76
+ function cloneCatalog(c: PickerCatalogInput): PickerCatalog {
77
+ return {
78
+ ...c,
79
+ options: c.options ? c.options.map(withTerm) : undefined,
80
+ dimensions: c.dimensions
81
+ ? c.dimensions.map((d) => ({ ...d, options: d.options.map(withTerm) }))
82
+ : undefined,
83
+ }
84
+ }
85
+
86
+ function applyExtend(c: PickerCatalog, pack: CatalogPack): PickerCatalog {
87
+ if (c.kind === "single") {
88
+ return { ...c, options: [...(c.options ?? []), ...(pack.options ?? []).map(withTerm)] }
89
+ }
90
+ const byField = new Map((c.dimensions ?? []).map((d) => [d.field, { ...d, options: [...d.options] }]))
91
+ for (const dim of pack.dimensions ?? []) {
92
+ const added = dim.options.map(withTerm)
93
+ const existing = byField.get(dim.field)
94
+ if (existing) existing.options = [...existing.options, ...added]
95
+ else byField.set(dim.field, { ...dim, options: added })
96
+ }
97
+ const dims = [...byField.values()]
98
+ return { ...c, dimensions: dims, fields: dims.map((d) => d.field) }
99
+ }
100
+
101
+ function applyDeny(c: PickerCatalog, denyIds: readonly string[]): PickerCatalog {
102
+ const deny = new Set(denyIds)
103
+ if (c.kind === "single") return { ...c, options: (c.options ?? []).filter((o) => !deny.has(o.id)) }
104
+ return { ...c, dimensions: (c.dimensions ?? []).map((d) => ({ ...d, options: d.options.filter((o) => !deny.has(o.id)) })) }
105
+ }
106
+
107
+ export function composePickerCatalogs(
108
+ base: readonly PickerCatalog[],
109
+ activePacks: readonly CatalogPack[],
110
+ ): readonly PickerCatalog[] {
111
+ const known = new Set(base.map((c) => c.catalogId))
112
+ for (const pack of activePacks) {
113
+ if (!known.has(pack.catalogId)) throw new Error(`catalog pack "${pack.id}" targets unknown catalog id "${pack.catalogId}"`)
114
+ }
115
+ return base.map((c) => {
116
+ let out = cloneCatalog(c)
117
+ for (const pack of activePacks) {
118
+ if (pack.catalogId !== c.catalogId) continue
119
+ if (pack.mode === "replace" && pack.catalog) out = cloneCatalog(pack.catalog)
120
+ else if (pack.mode === "extend") out = applyExtend(out, pack)
121
+ else if (pack.mode === "deny") out = applyDeny(out, pack.denyIds ?? [])
122
+ }
123
+ return out
124
+ })
125
+ }
@@ -0,0 +1,36 @@
1
+ import { NON_EN_LOCALE_IDS, type LocaleId } from "@nodaro/shared"
2
+ import type { CatalogPack } from "./catalog-packs.js"
3
+
4
+ /**
5
+ * Sidecar-localization coverage for a curation pack's added option ids. For
6
+ * every pack-added id, each non-English locale must either have a sidecar entry
7
+ * or be declared exempt (`pack.exemptSidecarLocales`). Exemptions are REPORTED,
8
+ * never treated as failures — a deployment may knowingly ship a pack with only
9
+ * a subset of the 11 locales translated.
10
+ */
11
+ export interface SidecarCoverageReport {
12
+ readonly total: number
13
+ readonly missing: ReadonlyArray<{ catalogId: string; locale: LocaleId; id: string }>
14
+ readonly exempted: ReadonlyArray<{ catalogId: string; locale: LocaleId }>
15
+ }
16
+
17
+ function packOptionIds(pack: CatalogPack): string[] {
18
+ if (pack.options) return pack.options.map((o) => o.id)
19
+ if (pack.dimensions) return pack.dimensions.flatMap((d) => d.options.map((o) => o.id))
20
+ if (pack.catalog?.options) return pack.catalog.options.map((o) => o.id)
21
+ if (pack.catalog?.dimensions) return pack.catalog.dimensions.flatMap((d) => d.options.map((o) => o.id))
22
+ return []
23
+ }
24
+
25
+ export function computePackSidecarCoverage(pack: CatalogPack): SidecarCoverageReport {
26
+ const ids = packOptionIds(pack)
27
+ const exempt = new Set<LocaleId>(pack.exemptSidecarLocales ?? [])
28
+ const missing: Array<{ catalogId: string; locale: LocaleId; id: string }> = []
29
+ const exempted: Array<{ catalogId: string; locale: LocaleId }> = []
30
+ for (const locale of NON_EN_LOCALE_IDS) {
31
+ if (exempt.has(locale)) { exempted.push({ catalogId: pack.catalogId, locale }); continue }
32
+ const map = pack.sidecars?.[locale] ?? {}
33
+ for (const id of ids) if (!map[id]) missing.push({ catalogId: pack.catalogId, locale, id })
34
+ }
35
+ return { total: ids.length * NON_EN_LOCALE_IDS.length, missing, exempted }
36
+ }