@nodaro/prompts 1.14.0 → 1.15.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 (63) hide show
  1. package/dist/index.cjs +633 -248
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +379 -1
  4. package/dist/index.d.ts +379 -1
  5. package/dist/index.js +603 -250
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/adult-only-ratchet.test.ts +110 -0
  9. package/src/__tests__/age-floor.test.ts +347 -0
  10. package/src/__tests__/catalog-id-guard.test.ts +146 -0
  11. package/src/__tests__/catalog-overlay.test.ts +163 -0
  12. package/src/__tests__/dod-replace-pack-acceptance.test.ts +17 -0
  13. package/src/__tests__/fixtures/parameter-hint-golden.json +2 -2
  14. package/src/__tests__/minor-age-floor.test.ts +136 -0
  15. package/src/__tests__/person-pack-adult-only.test.ts +45 -0
  16. package/src/__tests__/picker-analyzer-registry.test.ts +14 -0
  17. package/src/action-fx.ts +2 -1
  18. package/src/aesthetic.ts +2 -1
  19. package/src/age-floor.ts +296 -0
  20. package/src/age-signal.ts +36 -0
  21. package/src/atmosphere.ts +2 -1
  22. package/src/backdrop.ts +2 -1
  23. package/src/camera-format.ts +2 -1
  24. package/src/camera-motions.ts +2 -1
  25. package/src/catalog-id-guard.ts +189 -0
  26. package/src/catalog-overlay.ts +148 -0
  27. package/src/catalog-packs.ts +27 -2
  28. package/src/character-fx.ts +2 -1
  29. package/src/color-look.ts +2 -1
  30. package/src/composition-effects.ts +2 -1
  31. package/src/era.ts +2 -1
  32. package/src/exposure-settings.ts +2 -1
  33. package/src/framing.ts +2 -1
  34. package/src/held-prop.ts +2 -1
  35. package/src/index.ts +4 -0
  36. package/src/instrumentation.ts +5 -4
  37. package/src/lens.ts +2 -1
  38. package/src/lighting.ts +2 -1
  39. package/src/loop-subject.ts +3 -1
  40. package/src/materials.ts +2 -1
  41. package/src/mood.ts +16 -5
  42. package/src/music-genre.ts +5 -4
  43. package/src/music-mood.ts +4 -3
  44. package/src/parameter-prompt-hint.ts +13 -57
  45. package/src/person-packs.ts +14 -2
  46. package/src/person.ts +71 -38
  47. package/src/photo-genre.ts +14 -3
  48. package/src/photographer.ts +2 -1
  49. package/src/picker-analyzer-registry.ts +9 -0
  50. package/src/picker-catalogs.ts +11 -0
  51. package/src/pose.ts +17 -6
  52. package/src/post-process-effects.ts +2 -1
  53. package/src/prompt-builder.ts +3 -3
  54. package/src/render-quality.ts +2 -1
  55. package/src/setting.ts +2 -1
  56. package/src/shared-catalog-overlay.ts +83 -0
  57. package/src/style.ts +16 -1
  58. package/src/styling.ts +57 -29
  59. package/src/subject-registry.ts +2 -2
  60. package/src/temporal.ts +2 -1
  61. package/src/transitions.ts +2 -1
  62. package/src/voice-character.ts +6 -5
  63. package/src/voice-delivery.ts +4 -3
@@ -0,0 +1,148 @@
1
+ import type { PickerCatalog, PickerOption } from "./picker-catalogs.js"
2
+ import { catalogPacksVersion, hasCatalogPacksFor } from "./catalog-packs.js"
3
+
4
+ /**
5
+ * THE RESOLVER-SIDE HALF OF CATALOG CURATION.
6
+ *
7
+ * Every per-catalog module builds an index from its own base constant at load
8
+ * (`settingById = new Map(SETTINGS…)`) and its `getSetting(id)` reads that. A
9
+ * deployment's catalog packs compose a DIFFERENT catalog — entries removed,
10
+ * some rewritten at the same id — and until this module existed nothing on
11
+ * the resolve path read it. So on a curated deployment a denied id still
12
+ * turned into its full stock prompt text at run time, and a rewritten entry
13
+ * injected the stock wording instead of the curated one. The curation was
14
+ * real in `/v1/catalogs` and invisible in every generated prompt.
15
+ *
16
+ * `overlayEntry` sits inside each getter, between the base lookup and the
17
+ * return:
18
+ * - no pack targets the catalog → the base entry, untouched. This is the
19
+ * mainline path and it is byte-identical by construction; a deployment
20
+ * that curates nothing pays one Set probe per lookup.
21
+ * - a pack targets it, id absent → undefined. Denied means denied on the
22
+ * prompt path too, not only in the picker.
23
+ * - a pack targets it, id present → the base entry with the composed
24
+ * option's label / description / promptHint / term / category on top,
25
+ * so a rewrite reaches the prompt. Fields the composed option does not
26
+ * carry (typed extras a specific builder reads) survive from the base.
27
+ * - pack-ADDED id (no base entry) → the composed option itself.
28
+ *
29
+ * LATE-BOUND RESOLVER, ON PURPOSE. The catalog modules cannot import
30
+ * `getPickerCatalog`: picker-catalogs.ts imports every one of them to build
31
+ * `PICKER_CATALOGS` at module scope, and a module-scope cycle is a TDZ error
32
+ * under some load orders. picker-catalogs.ts instead installs its resolver
33
+ * here when IT loads (`setComposedCatalogResolver`), and this module imports
34
+ * only the pack registry, which imports nothing of the catalogs.
35
+ *
36
+ * FAIL CLOSED. If a pack targets a catalog and the resolver is somehow not
37
+ * installed, every lookup on that catalog throws rather than silently
38
+ * returning the base entry — a curated deployment answering with stock text
39
+ * is the failure this whole module exists to prevent.
40
+ *
41
+ * SCOPE BOUNDARY, on purpose: the transition / character-fx TIMING dimensions
42
+ * (position start/middle/end, duration, intensity) are structural knobs the
43
+ * catalogs expose for thin clients, not content — no curation removes "end".
44
+ * Their composers index total clause records by construction, and threading
45
+ * the overlay through that would trade a guaranteed-total prompt for a hole
46
+ * nothing needs. They stay uncurated; the wall still checks their ids.
47
+ */
48
+
49
+ type Resolver = (catalogIdOrNodeType: string) => PickerCatalog | undefined
50
+ let resolver: Resolver | null = null
51
+
52
+ /** Installed by picker-catalogs.ts at load. */
53
+ export function setComposedCatalogResolver(fn: Resolver): void {
54
+ resolver = fn
55
+ }
56
+
57
+ let memo: { v: number; byCatalog: Map<string, ReadonlyMap<string, PickerOption>> } | null = null
58
+
59
+ /** id → composed option for one catalog, memoized on the pack version. */
60
+ export function composedOptionIndex(catalogId: string): ReadonlyMap<string, PickerOption> {
61
+ const v = catalogPacksVersion()
62
+ if (!memo || memo.v !== v) memo = { v, byCatalog: new Map() }
63
+ const hit = memo.byCatalog.get(catalogId)
64
+ if (hit) return hit
65
+ if (!resolver) {
66
+ throw new Error(
67
+ `[catalog-overlay] catalog "${catalogId}" is curated by a pack but the composed-catalog resolver is not installed — refusing to resolve against the stock catalog`,
68
+ )
69
+ }
70
+ const cat = resolver(catalogId)
71
+ const index = new Map<string, PickerOption>()
72
+ if (cat) {
73
+ for (const o of cat.options ?? []) index.set(o.id, o)
74
+ for (const d of cat.dimensions ?? []) for (const o of d.options) if (!index.has(o.id)) index.set(o.id, o)
75
+ }
76
+ memo.byCatalog.set(catalogId, index)
77
+ return index
78
+ }
79
+
80
+ /**
81
+ * Overlay the composed catalog on a base lookup. See the module doc for the
82
+ * four outcomes. `T` is the module's own entry type; the composed option's
83
+ * common fields are spread over it, so callers keep their typed result.
84
+ */
85
+ export function overlayEntry<T extends { readonly id: string }>(
86
+ catalogId: string,
87
+ id: string,
88
+ base: T | undefined,
89
+ ): T | undefined {
90
+ if (!hasCatalogPacksFor(catalogId)) return base
91
+ const opt = composedOptionIndex(catalogId).get(id)
92
+ if (!opt) return undefined
93
+ if (!base) return opt as unknown as T
94
+ return {
95
+ ...base,
96
+ label: opt.label,
97
+ promptHint: opt.promptHint,
98
+ term: opt.term,
99
+ ...(opt.description !== undefined ? { description: opt.description } : {}),
100
+ ...(opt.category !== undefined ? { category: opt.category } : {}),
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Membership only — for id-carrying fields whose entries live outside this
106
+ * package (the animal / vehicle / weapon / furniture catalogs resolve through
107
+ * `@nodaro/shared`, which cannot depend on this package). The dispatcher asks
108
+ * this before calling those getters.
109
+ */
110
+ export function composedHas(catalogId: string, id: string): boolean {
111
+ if (!hasCatalogPacksFor(catalogId)) return true
112
+ return composedOptionIndex(catalogId).has(id)
113
+ }
114
+
115
+ /** The composed option, or undefined — for callers that render from it directly. */
116
+ export function composedOption(catalogId: string, id: string): PickerOption | undefined {
117
+ if (!hasCatalogPacksFor(catalogId)) return undefined
118
+ return composedOptionIndex(catalogId).get(id)
119
+ }
120
+
121
+ /**
122
+ * A base entry LIST filtered to ids the composed catalog offers and relabelled
123
+ * where a pack rewrote an entry — the list-shaped twin of `overlayEntry`, for
124
+ * anything that renders a whole catalog (pickers, dropdowns, studio browsers).
125
+ * Returns the base array BY IDENTITY when no pack targets the catalog, so
126
+ * memo deps and equality checks on mainline are unchanged. Pack-ADDED entries
127
+ * are not surfaced here: the caller's typed entry shape may carry fields a
128
+ * PickerOption cannot supply.
129
+ */
130
+ export function curateEntries<T extends { readonly id: string; readonly label: string }>(
131
+ catalogId: string,
132
+ base: readonly T[],
133
+ ): readonly T[] {
134
+ if (!hasCatalogPacksFor(catalogId)) return base
135
+ const index = composedOptionIndex(catalogId)
136
+ const out: T[] = []
137
+ for (const e of base) {
138
+ const opt = index.get(e.id)
139
+ if (!opt) continue
140
+ out.push({
141
+ ...e,
142
+ label: opt.label,
143
+ ...(opt.description !== undefined ? { description: opt.description } : {}),
144
+ ...("promptHint" in e && opt.promptHint !== undefined ? { promptHint: opt.promptHint } : {}),
145
+ })
146
+ }
147
+ return out
148
+ }
@@ -59,20 +59,45 @@ export interface CatalogPack {
59
59
 
60
60
  let packs: CatalogPack[] = []
61
61
  let version = 0
62
+ // Registration can land AFTER a consumer first read (the browser fetches a
63
+ // deployment's composed catalogs at boot; a picker may already be mounted).
64
+ // A read-only memo keyed on `version` sees the change on its NEXT read, but
65
+ // nothing re-runs a React render for it — that gap is how a deployment's
66
+ // curation once rendered as the stock catalogs for a whole session. Listeners
67
+ // let a UI hook subscribe to the version instead of polling it.
68
+ const listeners = new Set<() => void>()
69
+ function bump(): void {
70
+ version++
71
+ for (const l of listeners) l()
72
+ }
62
73
 
63
74
  export function registerCatalogPack(pack: CatalogPack): void {
64
75
  if (packs.some((p) => p.id === pack.id)) throw new Error(`duplicate catalog pack id "${pack.id}"`)
65
76
  packs = [...packs, pack]
66
- version++
67
77
  // Push this pack's localized sidecars into the shared app-UI localizer (G10)
68
78
  // so pack-added entries resolve in `resolveLabel`/`resolveDescription`/search.
69
79
  // One generic point covers every pack, incl. the person extend fan-out.
70
80
  registerCatalogSidecars(pack.catalogId, pack.sidecars)
81
+ bump()
71
82
  }
72
83
  export function getRegisteredCatalogPacks(): readonly CatalogPack[] { return packs }
73
- export function resetCatalogPacks(): void { packs = []; version++; resetCatalogSidecars() }
84
+ export function resetCatalogPacks(): void { packs = []; resetCatalogSidecars(); bump() }
74
85
  export function catalogPacksVersion(): number { return version }
75
86
 
87
+ /** True when at least one pack targets this catalog — i.e. this deployment
88
+ * CURATES it and the base constants are no longer the truth for it. */
89
+ export function hasCatalogPacksFor(catalogId: string): boolean {
90
+ return packs.some((p) => p.catalogId === catalogId)
91
+ }
92
+
93
+ /** Subscribe to registrations/resets. Returns the unsubscribe. */
94
+ export function subscribeCatalogPacks(listener: () => void): () => void {
95
+ listeners.add(listener)
96
+ return () => {
97
+ listeners.delete(listener)
98
+ }
99
+ }
100
+
76
101
  function cloneCatalog(c: PickerCatalogInput): PickerCatalog {
77
102
  return {
78
103
  ...c,
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { resolveTerm, type PickerHintMode } from "./term.js"
22
+ import { overlayEntry } from "./catalog-overlay.js"
22
23
 
23
24
  export type CharacterFxCategory =
24
25
  | "transformation"
@@ -249,7 +250,7 @@ const characterFxById = new Map<string, CharacterFx>(
249
250
 
250
251
  export function getCharacterFx(id: string | undefined | null): CharacterFx | undefined {
251
252
  if (!id) return undefined
252
- return characterFxById.get(id)
253
+ return overlayEntry("character-fx", id, characterFxById.get(id))
253
254
  }
254
255
 
255
256
  export function getCharacterFxLabel(id: string | undefined | null, fallback?: string): string {
package/src/color-look.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { resolveTerm } from "./term.js"
15
+ import { overlayEntry } from "./catalog-overlay.js"
15
16
 
16
17
  export type ColorLookCategory = "palette" | "film-emulation" | "social-preset"
17
18
 
@@ -98,7 +99,7 @@ const colorLookById = new Map<string, ColorLook>(COLOR_LOOKS.map((c) => [c.id, c
98
99
 
99
100
  export function getColorLook(id: string | undefined | null): ColorLook | undefined {
100
101
  if (!id) return undefined
101
- return colorLookById.get(id)
102
+ return overlayEntry("color-look", id, colorLookById.get(id))
102
103
  }
103
104
 
104
105
  export function getColorLookLabel(id: string | undefined | null, fallback?: string): string {
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { resolveTerm } from "./term.js"
19
+ import { overlayEntry } from "./catalog-overlay.js"
19
20
 
20
21
  export interface CompositionEffect {
21
22
  readonly id: string
@@ -64,7 +65,7 @@ const compositionEffectById = new Map<string, CompositionEffect>(
64
65
 
65
66
  export function getCompositionEffect(id: string | undefined | null): CompositionEffect | undefined {
66
67
  if (!id) return undefined
67
- return compositionEffectById.get(id)
68
+ return overlayEntry("composition-effects", id, compositionEffectById.get(id))
68
69
  }
69
70
 
70
71
  export function getCompositionEffectLabel(id: string | undefined | null, fallback?: string): string {
package/src/era.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { resolveTerm } from "./term.js"
21
+ import { overlayEntry } from "./catalog-overlay.js"
21
22
 
22
23
  export type EraCategory = "decade-20c" | "pre-modern" | "speculative"
23
24
 
@@ -282,7 +283,7 @@ const eraById = new Map<string, Era>(ERAS.map((e) => [e.id, e]))
282
283
 
283
284
  export function getEra(id: string | undefined | null): Era | undefined {
284
285
  if (!id) return undefined
285
- return eraById.get(id)
286
+ return overlayEntry("era", id, eraById.get(id))
286
287
  }
287
288
 
288
289
  export function getEraLabel(id: string | undefined | null, fallback?: string): string {
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import { resolveTerm, type PickerHintMode } from "./term.js"
23
+ import { overlayEntry } from "./catalog-overlay.js"
23
24
 
24
25
  export type ExposureCategory = "aperture" | "shutter-speed" | "iso"
25
26
 
@@ -97,7 +98,7 @@ const exposureById = new Map<string, ExposureSettings>(
97
98
 
98
99
  export function getExposure(id: string | undefined | null): ExposureSettings | undefined {
99
100
  if (!id) return undefined
100
- return exposureById.get(id)
101
+ return overlayEntry("exposure-settings", id, exposureById.get(id))
101
102
  }
102
103
 
103
104
  export function getExposureLabel(id: string | undefined | null, fallback?: string): string {
package/src/framing.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { resolveTerm, type PickerHintMode } from "./term.js"
13
+ import { overlayEntry } from "./catalog-overlay.js"
13
14
 
14
15
  export type FramingCategory =
15
16
  | "shot-size"
@@ -568,7 +569,7 @@ const framingById = new Map<string, Framing>(FRAMINGS.map((f) => [f.id, f]))
568
569
 
569
570
  export function getFraming(id: string | undefined | null): Framing | undefined {
570
571
  if (!id) return undefined
571
- return framingById.get(id)
572
+ return overlayEntry("framing", id, framingById.get(id))
572
573
  }
573
574
 
574
575
  export function getFramingLabel(id: string | undefined | null, fallback?: string): string {
package/src/held-prop.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import { resolveTerm, type PickerHintMode } from "./term.js"
26
+ import { overlayEntry } from "./catalog-overlay.js"
26
27
 
27
28
  export type HeldPropCategory =
28
29
  | "device"
@@ -142,7 +143,7 @@ const heldPropById = new Map<string, HeldProp>(HELD_PROPS.map((p) => [p.id, p]))
142
143
 
143
144
  export function getHeldProp(id: string | undefined | null): HeldProp | undefined {
144
145
  if (!id) return undefined
145
- return heldPropById.get(id)
146
+ return overlayEntry("held-prop", id, heldPropById.get(id))
146
147
  }
147
148
 
148
149
  export function getHeldPropLabel(id: string | undefined | null, fallback?: string): string {
package/src/index.ts CHANGED
@@ -35,8 +35,12 @@ export * from "./veo-i2v-inputs.js"
35
35
  export * from "./person.js"
36
36
  export * from "./picker-catalogs.js"
37
37
  export * from "./catalog-packs.js"
38
+ export * from "./catalog-overlay.js"
39
+ export * from "./shared-catalog-overlay.js"
40
+ export * from "./catalog-id-guard.js"
38
41
  export * from "./catalog-sidecar-coverage.js"
39
42
  export * from "./person-packs.js"
43
+ export * from "./age-floor.js"
40
44
  export * from "./picker-analyzer-registry.js"
41
45
  export * from "./action-fx.js"
42
46
  export * from "./aesthetic.js"
@@ -273,16 +273,16 @@ const VOCAL_BY_ID = new Map(VOCAL_PRESENCE.map((x) => [x.id, x]))
273
273
  const SINGING_STYLE_BY_ID = new Map(SINGING_STYLES.map((x) => [x.id, x]))
274
274
 
275
275
  export function getInstrument(id: string | undefined): CategorizedInstrument | undefined {
276
- return id ? INSTRUMENT_BY_ID.get(id) : undefined
276
+ return id ? overlayEntry("instrumentation", id, INSTRUMENT_BY_ID.get(id)) : undefined
277
277
  }
278
278
  export function getProductionStyle(id: string | undefined): InstrumentationEntry | undefined {
279
- return id ? PRODUCTION_BY_ID.get(id) : undefined
279
+ return id ? overlayEntry("instrumentation", id, PRODUCTION_BY_ID.get(id)) : undefined
280
280
  }
281
281
  export function getVocalPresence(id: string | undefined): InstrumentationEntry | undefined {
282
- return id ? VOCAL_BY_ID.get(id) : undefined
282
+ return id ? overlayEntry("instrumentation", id, VOCAL_BY_ID.get(id)) : undefined
283
283
  }
284
284
  export function getSingingStyle(id: string | undefined): InstrumentationEntry | undefined {
285
- return id ? SINGING_STYLE_BY_ID.get(id) : undefined
285
+ return id ? overlayEntry("instrumentation", id, SINGING_STYLE_BY_ID.get(id)) : undefined
286
286
  }
287
287
 
288
288
  /**
@@ -306,6 +306,7 @@ export function getSingingStyleTerm(id: string | undefined | null): string {
306
306
  }
307
307
 
308
308
  import { pickIds } from "@nodaro/shared"
309
+ import { overlayEntry } from "./catalog-overlay.js"
309
310
 
310
311
  export function buildInstrumentationHints(
311
312
  data: {
package/src/lens.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { resolveTerm } from "./term.js"
14
+ import { overlayEntry } from "./catalog-overlay.js"
14
15
 
15
16
  export interface Lens {
16
17
  readonly id: string
@@ -53,7 +54,7 @@ const lensById = new Map<string, Lens>(LENSES.map((l) => [l.id, l]))
53
54
 
54
55
  export function getLens(id: string | undefined | null): Lens | undefined {
55
56
  if (!id) return undefined
56
- return lensById.get(id)
57
+ return overlayEntry("lens", id, lensById.get(id))
57
58
  }
58
59
 
59
60
  export function getLensLabel(id: string | undefined | null, fallback?: string): string {
package/src/lighting.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { resolveTerm, type PickerHintMode } from "./term.js"
14
+ import { overlayEntry } from "./catalog-overlay.js"
14
15
 
15
16
  export type LightingCategory =
16
17
  | "time-of-day"
@@ -143,7 +144,7 @@ const lightingById = new Map<string, Lighting>(LIGHTINGS.map((l) => [l.id, l]))
143
144
 
144
145
  export function getLighting(id: string | undefined | null): Lighting | undefined {
145
146
  if (!id) return undefined
146
- return lightingById.get(id)
147
+ return overlayEntry("lighting", id, lightingById.get(id))
147
148
  }
148
149
 
149
150
  export function getLightingLabel(id: string | undefined | null, fallback?: string): string {
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { resolveTerm } from "./term.js"
22
+ import { overlayEntry } from "./catalog-overlay.js"
22
23
 
23
24
  export type LoopSubjectCategory = "realistic" | "abstract"
24
25
 
@@ -280,7 +281,8 @@ export const LOOP_SUBJECTS: ReadonlyArray<LoopSubject> = [
280
281
  ]
281
282
 
282
283
  export function getLoopSubject(id: string | undefined | null): LoopSubject | undefined {
283
- return LOOP_SUBJECTS.find((s) => s.id === id)
284
+ if (!id) return undefined
285
+ return overlayEntry("loop-subject", id, LOOP_SUBJECTS.find((s) => s.id === id))
284
286
  }
285
287
 
286
288
  export function getLoopSubjectLabel(id: string): string {
package/src/materials.ts CHANGED
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { resolveTerm, type PickerHintMode } from "./term.js"
27
+ import { overlayEntry } from "./catalog-overlay.js"
27
28
 
28
29
  export type MaterialCategory =
29
30
  | "fabric"
@@ -139,7 +140,7 @@ const materialById = new Map<string, Material>(MATERIALS.map((m) => [m.id, m]))
139
140
 
140
141
  export function getMaterial(id: string | undefined | null): Material | undefined {
141
142
  if (!id) return undefined
142
- return materialById.get(id)
143
+ return overlayEntry("materials", id, materialById.get(id))
143
144
  }
144
145
 
145
146
  export function getMaterialLabel(id: string | undefined | null, fallback?: string): string {
package/src/mood.ts CHANGED
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { resolveTerm, type PickerHintMode } from "./term.js"
27
+ import { overlayEntry } from "./catalog-overlay.js"
27
28
 
28
29
  export type MoodCategory = "positive" | "negative" | "neutral" | "intense"
29
30
 
@@ -33,6 +34,16 @@ export interface Mood {
33
34
  readonly category: MoodCategory
34
35
  readonly description: string
35
36
  readonly promptHint: string
37
+ /**
38
+ * W1-a minor-age floor (spec 2026-09-01 §3.3). `true` marks an entry whose
39
+ * hint describes body exposure, sheer/wet clothing, swimwear/lingerie, a
40
+ * seductive expression or gaze, or a body-placed tattoo. The fragment
41
+ * collectors DROP flagged entries for a minor subject (`isMinorAge`), the
42
+ * picker hides their tiles, the analyzer never emits them, and the backend
43
+ * policy strips their wording from free text. Hand-curated; the
44
+ * `adult-only-ratchet` test only ratchets. Never set on neutral defaults.
45
+ */
46
+ readonly adultOnly?: true
36
47
  /**
37
48
  * Compact professional term injected in compact hint mode (see `term.ts`).
38
49
  *
@@ -91,10 +102,10 @@ export const MOODS: ReadonlyArray<Mood> = [
91
102
  { id: "determined", label: "Determined", category: "intense", description: "Resolute, focused will", promptHint: "with a determined, resolute expression and iron focus", term: "determined, resolute expression" },
92
103
  { id: "passionate", label: "Passionate", category: "intense", description: "Burning passion", promptHint: "with a passionate, burning expression full of conviction", term: "passionate expression" },
93
104
  { id: "brooding", label: "Brooding", category: "intense", description: "Dark, brooding melancholy", promptHint: "with a brooding, dark expression and moody introspection", term: "brooding expression" },
94
- { id: "seductive", label: "Seductive", category: "intense", description: "Alluring, seductive", promptHint: "with a seductive, alluring expression and lidded gaze", term: "seductive expression" },
105
+ { id: "seductive", label: "Seductive", category: "intense", description: "Alluring, seductive", promptHint: "with a seductive, alluring expression and lidded gaze", term: "seductive expression" , adultOnly: true },
95
106
  { id: "defiant", label: "Defiant", category: "intense", description: "Defiant, unyielding", promptHint: "with a defiant, unyielding expression and challenging stance", term: "defiant expression" },
96
- { id: "sultry", label: "Sultry", category: "intense", description: "Smoldering, heavy-lidded", promptHint: "with a sultry, smoldering expression, heavy-lidded gaze and lips softly parted", term: "sultry expression" },
97
- { id: "smoldering", label: "Smoldering", category: "intense", description: "Coiled, slow-burning intensity", promptHint: "with a smoldering, coiled expression, slow-burning intensity behind half-closed eyes and a still, predatory calm", term: "smoldering intensity" },
107
+ { id: "sultry", label: "Sultry", category: "intense", description: "Smoldering, heavy-lidded", promptHint: "with a sultry, smoldering expression, heavy-lidded gaze and lips softly parted", term: "sultry expression" , adultOnly: true },
108
+ { id: "smoldering", label: "Smoldering", category: "intense", description: "Coiled, slow-burning intensity", promptHint: "with a smoldering, coiled expression, slow-burning intensity behind half-closed eyes and a still, predatory calm", term: "smoldering intensity" , adultOnly: true },
98
109
  { id: "sinister", label: "Sinister", category: "intense", description: "Dark, malicious, threatening", promptHint: "with a sinister expression, a slow crooked smile and eyes glinting with dark malicious intent", term: "sinister expression" },
99
110
  { id: "wiccan-mystical", label: "Wiccan / Mystical", category: "intense", description: "Quietly otherworldly, occult", promptHint: "with a quietly mystical, otherworldly expression, eyes distant and knowing as if reading something the camera cannot see", term: "occult, otherworldly mystical expression" },
100
111
  { id: "lazy-shy", label: "Lazy Shy", category: "neutral", description: "Drowsy, soft, half-shy", promptHint: "with a soft, drowsy half-shy expression, eyes lowered and barely-there smile, languid and unbothered", term: "drowsy, half-shy expression" },
@@ -102,7 +113,7 @@ export const MOODS: ReadonlyArray<Mood> = [
102
113
  { id: "shocked", label: "Shocked", category: "intense", description: "Surprised, mouth open", promptHint: "with a shocked, surprised expression, eyes wide, brows raised and mouth open", term: "shocked expression" },
103
114
 
104
115
  // -------------------- Micro-emotions --------------------
105
- { id: "flirty", label: "Flirty", category: "positive", description: "Playful flirtation, lingering smile, sustained eye contact", promptHint: "with a flirty expression, a lingering playful smile and sustained, knowing eye contact with the camera", term: "flirty expression" },
116
+ { id: "flirty", label: "Flirty", category: "positive", description: "Playful flirtation, lingering smile, sustained eye contact", promptHint: "with a flirty expression, a lingering playful smile and sustained, knowing eye contact with the camera", term: "flirty expression" , adultOnly: true },
106
117
  { id: "suspicious", label: "Suspicious", category: "negative", description: "Wary distrust, narrowed eyes, side-eye", promptHint: "with a suspicious, wary expression, eyes narrowed in distrust and a sidelong side-eye glance", term: "suspicious, wary expression" },
107
118
  { id: "resigned", label: "Resigned", category: "neutral", description: "Quiet acceptance of an unpleasant situation, sigh", promptHint: "with a resigned expression of quiet acceptance, shoulders softly dropped mid-sigh and a tired half-closed gaze", term: "resigned expression of quiet acceptance" },
108
119
  { id: "conflicted", label: "Conflicted", category: "neutral", description: "Visible internal struggle, brow furrowed, gaze unfocused", promptHint: "with a conflicted expression of visible internal struggle, brow furrowed and gaze unfocused, caught between two impulses", term: "conflicted, torn expression" },
@@ -112,7 +123,7 @@ const moodById = new Map<string, Mood>(MOODS.map((m) => [m.id, m]))
112
123
 
113
124
  export function getMood(id: string | undefined | null): Mood | undefined {
114
125
  if (!id) return undefined
115
- return moodById.get(id)
126
+ return overlayEntry("mood", id, moodById.get(id))
116
127
  }
117
128
 
118
129
  export function getMoodLabel(id: string | undefined | null, fallback?: string): string {
@@ -15,6 +15,7 @@
15
15
  import { pickIds } from "@nodaro/shared"
16
16
 
17
17
  import { resolveTerm, type PickerHintMode } from "./term.js"
18
+ import { overlayEntry } from "./catalog-overlay.js"
18
19
 
19
20
  export type MusicGenreCategory =
20
21
  | "hip-hop-rnb"
@@ -610,12 +611,12 @@ const ERA_BY_ID = new Map(MUSIC_ERAS.map((e) => [e.id, e]))
610
611
 
611
612
  export function getMusicGenre(id: string | undefined): MusicGenre | undefined {
612
613
  if (!id) return undefined
613
- return GENRE_BY_ID.get(id)
614
+ return overlayEntry("music-genre", id, GENRE_BY_ID.get(id))
614
615
  }
615
616
 
616
617
  export function getMusicGenreLabel(id: string | undefined): string {
617
618
  if (!id) return ""
618
- return GENRE_BY_ID.get(id)?.label ?? id
619
+ return getMusicGenre(id)?.label ?? id
619
620
  }
620
621
 
621
622
  export function getMusicSubgenre(
@@ -624,12 +625,12 @@ export function getMusicSubgenre(
624
625
  ): MusicSubgenre | undefined {
625
626
  if (!genreId || !subgenreId) return undefined
626
627
  const genre = GENRE_BY_ID.get(genreId)
627
- return genre?.subgenres.find((s) => s.id === subgenreId)
628
+ return overlayEntry("music-genre", subgenreId, genre?.subgenres.find((s) => s.id === subgenreId))
628
629
  }
629
630
 
630
631
  export function getMusicEra(id: string | undefined): MusicEra | undefined {
631
632
  if (!id) return undefined
632
- return ERA_BY_ID.get(id)
633
+ return overlayEntry("music-genre", id, ERA_BY_ID.get(id))
633
634
  }
634
635
 
635
636
  /**
package/src/music-mood.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import { pickIds } from "@nodaro/shared"
7
7
 
8
8
  import { resolveTerm, type PickerHintMode } from "./term.js"
9
+ import { overlayEntry } from "./catalog-overlay.js"
9
10
 
10
11
  export interface MusicMoodEntry {
11
12
  readonly id: string
@@ -102,13 +103,13 @@ const EMOTION_BY_ID = new Map(MUSIC_EMOTIONS.map((x) => [x.id, x]))
102
103
  const VIBE_BY_ID = new Map(MUSIC_VIBES.map((x) => [x.id, x]))
103
104
 
104
105
  export function getMusicEnergy(id: string | undefined): MusicMoodEntry | undefined {
105
- return id ? ENERGY_BY_ID.get(id) : undefined
106
+ return id ? overlayEntry("music-mood", id, ENERGY_BY_ID.get(id)) : undefined
106
107
  }
107
108
  export function getMusicEmotion(id: string | undefined): MusicMoodEntry | undefined {
108
- return id ? EMOTION_BY_ID.get(id) : undefined
109
+ return id ? overlayEntry("music-mood", id, EMOTION_BY_ID.get(id)) : undefined
109
110
  }
110
111
  export function getMusicVibe(id: string | undefined): MusicMoodEntry | undefined {
111
- return id ? VIBE_BY_ID.get(id) : undefined
112
+ return id ? overlayEntry("music-mood", id, VIBE_BY_ID.get(id)) : undefined
112
113
  }
113
114
 
114
115
  /**