@nodaro/prompts 1.14.0 → 1.16.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 (69) hide show
  1. package/dist/index.cjs +680 -251
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +406 -1
  4. package/dist/index.d.ts +406 -1
  5. package/dist/index.js +649 -253
  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__/doctrine-roster-completeness.test.ts +1 -0
  13. package/src/__tests__/dod-replace-pack-acceptance.test.ts +17 -0
  14. package/src/__tests__/fixtures/parameter-hint-golden.json +315 -37
  15. package/src/__tests__/minor-age-floor.test.ts +136 -0
  16. package/src/__tests__/person-body-axes.test.ts +2 -2
  17. package/src/__tests__/person-exposure-hints.test.ts +59 -4
  18. package/src/__tests__/person-pack-adult-only.test.ts +45 -0
  19. package/src/__tests__/picker-analyzer-registry.test.ts +14 -0
  20. package/src/__tests__/retired-hint-strings.test.ts +68 -0
  21. package/src/__tests__/subject-registry.test.ts +1 -1
  22. package/src/__tests__/w1b-rephrase.test.ts +119 -0
  23. package/src/action-fx.ts +2 -1
  24. package/src/aesthetic.ts +2 -1
  25. package/src/age-floor.ts +359 -0
  26. package/src/age-signal.ts +36 -0
  27. package/src/atmosphere.ts +2 -1
  28. package/src/backdrop.ts +2 -1
  29. package/src/camera-format.ts +2 -1
  30. package/src/camera-motions.ts +2 -1
  31. package/src/catalog-id-guard.ts +189 -0
  32. package/src/catalog-overlay.ts +148 -0
  33. package/src/catalog-packs.ts +27 -2
  34. package/src/character-fx.ts +2 -1
  35. package/src/color-look.ts +2 -1
  36. package/src/composition-effects.ts +2 -1
  37. package/src/era.ts +2 -1
  38. package/src/exposure-settings.ts +2 -1
  39. package/src/framing.ts +2 -1
  40. package/src/held-prop.ts +2 -1
  41. package/src/index.ts +4 -0
  42. package/src/instrumentation.ts +5 -4
  43. package/src/lens.ts +2 -1
  44. package/src/lighting.ts +2 -1
  45. package/src/loop-subject.ts +3 -1
  46. package/src/materials.ts +2 -1
  47. package/src/mood.ts +16 -5
  48. package/src/music-genre.ts +5 -4
  49. package/src/music-mood.ts +4 -3
  50. package/src/parameter-prompt-hint.ts +13 -57
  51. package/src/person-packs.ts +14 -2
  52. package/src/person.ts +76 -42
  53. package/src/photo-genre.ts +14 -3
  54. package/src/photographer.ts +2 -1
  55. package/src/picker-analyzer-registry.ts +9 -0
  56. package/src/picker-catalogs.ts +11 -0
  57. package/src/pose.ts +17 -6
  58. package/src/post-process-effects.ts +2 -1
  59. package/src/prompt-builder.ts +3 -3
  60. package/src/render-quality.ts +2 -1
  61. package/src/setting.ts +2 -1
  62. package/src/shared-catalog-overlay.ts +83 -0
  63. package/src/style.ts +16 -1
  64. package/src/styling.ts +93 -29
  65. package/src/subject-registry.ts +2 -2
  66. package/src/temporal.ts +2 -1
  67. package/src/transitions.ts +2 -1
  68. package/src/voice-character.ts +6 -5
  69. package/src/voice-delivery.ts +4 -3
@@ -0,0 +1,359 @@
1
+ /**
2
+ * The minor-age floor — Layer 1 of spec 2026-09-01-app-reports-triage-design.md
3
+ * §3.3 (W1-a). Content-free: this module decides WHICH catalog entries a minor
4
+ * subject may not carry; the clause a deployment appends lives in its
5
+ * registered PromptPolicy (backend/src/lib/prompt-policies/), never here.
6
+ *
7
+ * `ADULT_ONLY_FLAG`, `ADULT_AGE_IDS`, `MINOR_IMPLYING_TYPE_IDS` and
8
+ * `isMinorAge` live in the import-free leaf `age-signal.ts` and are
9
+ * re-exported here unchanged — this module's own catalog-funnel imports
10
+ * (`person-packs.js`, `picker-catalogs.js`) are exactly what `person.ts` /
11
+ * `styling.ts` must NOT reach through, on pain of reopening the
12
+ * person → age-floor → picker-catalogs load-time cycle (see `age-signal.ts`'s
13
+ * header). `person.ts` / `styling.ts` import `isMinorAge` from
14
+ * `age-signal.ts` directly instead of from here.
15
+ */
16
+
17
+ import { getRegisteredPeople } from "./person-packs.js"
18
+ import { getRegisteredPickerCatalogs, type PickerCatalog, type PickerOption } from "./picker-catalogs.js"
19
+ import { isMinorAge, ADULT_AGE_IDS, MINOR_IMPLYING_TYPE_IDS } from "./age-signal.js"
20
+
21
+ export { ADULT_ONLY_FLAG, ADULT_AGE_IDS, MINOR_IMPLYING_TYPE_IDS, isMinorAge } from "./age-signal.js"
22
+
23
+ /** Shape shared by every catalog entry the floor reads. */
24
+ export interface AdultOnlyEntry {
25
+ readonly id: string
26
+ readonly promptHint: string
27
+ readonly term?: string
28
+ readonly adultOnly?: true
29
+ }
30
+
31
+ /** The picker catalogs (by `catalogId`) this floor sweeps beside person.
32
+ * Exported because it is the SINGLE source of truth for two things that used
33
+ * to be maintained by hand and drifted: which catalogs `getAdultOnlyEntries`
34
+ * sweeps for the flag, and which analyzer keys `FLOORED_PICKER_KEYS` strips
35
+ * flagged ids out of (a `photo-genre` swept for the flag but absent from the
36
+ * strip list let `glamour-portrait` survive on a minor). */
37
+ export const ADULT_SWEPT_CATALOG_IDS = ["styling", "mood", "pose", "photo-genre"] as const
38
+
39
+ /** Membership form of the list above (the sweep does one lookup per catalog). */
40
+ const ADULT_SWEPT_CATALOG_ID_SET: ReadonlySet<string> = new Set(ADULT_SWEPT_CATALOG_IDS)
41
+
42
+ /** Flatten a catalog's options regardless of shape: single-dim catalogs carry
43
+ * `options` directly, multi-dim ones carry `dimensions[].options` (mirrors
44
+ * `applyDeny` in catalog-packs.ts, the other consumer that has to handle
45
+ * both shapes). */
46
+ function optionsOf(catalog: PickerCatalog): ReadonlyArray<PickerOption> {
47
+ if (catalog.kind === "single") return catalog.options ?? []
48
+ return (catalog.dimensions ?? []).flatMap((d) => d.options)
49
+ }
50
+
51
+ /** Every entry, across the swept catalogs, that carries the flag. Reads every
52
+ * catalog through the pack-composed funnel — `getRegisteredPeople()` for
53
+ * person, `getRegisteredPickerCatalogs()` for styling/mood/pose/photo-genre —
54
+ * so a pack-added or pack-replaced entry is included. */
55
+ export function getAdultOnlyEntries(): ReadonlyArray<AdultOnlyEntry> {
56
+ const catalogEntries = getRegisteredPickerCatalogs()
57
+ .filter((c) => ADULT_SWEPT_CATALOG_ID_SET.has(c.catalogId))
58
+ .flatMap(optionsOf)
59
+ const all: ReadonlyArray<AdultOnlyEntry> = [...getRegisteredPeople(), ...catalogEntries]
60
+ return all.filter((e) => e.adultOnly === true)
61
+ }
62
+
63
+ /** The set of flagged ids (fast membership for the collectors). */
64
+ export function getAdultOnlyIds(): ReadonlySet<string> {
65
+ return new Set(getAdultOnlyEntries().map((e) => e.id))
66
+ }
67
+
68
+ /**
69
+ * The pre-rework `promptHint` of every `adultOnly` entry the spec
70
+ * (2026-09-01-app-reports-triage-design §3.3) listed for rewording — including
71
+ * `eye-state-half-lidded` and `feature-collarbone-visible`, which the W1-b
72
+ * harness initially left byte-identical (0/10 and 0/22 render rate under
73
+ * either wording) and a 2026-09-03 replay then found renderable wordings for
74
+ * — plus the hard-coded midriff+navel fold clause. These strings are permanently part of the strip
75
+ * set: a consumer that has not bumped `@nodaro/prompts` still emits them, and
76
+ * the client-assembled `seedPrompt` path is exactly how the 2026-07-30
77
+ * minor-age prompts reached a provider. Retiring can only ever cause an EXTRA
78
+ * strip, and only for a subject `isMinorAge` has already judged a minor — so
79
+ * the list only grows, never shrinks.
80
+ *
81
+ * `promptHint` ONLY, for the same reason `getAdultOnlyHintStrings` gives:
82
+ * `term` is short display vocabulary and the composed-catalog projection
83
+ * back-fills a derived `term` for every option, so terms collide with benign
84
+ * everyday text. Assembled prompts and client seedPrompts are built from
85
+ * hints, never from terms. The three W1-b entries that are NOT flagged —
86
+ * `texture-dewy`, `texture-baby-soft`, `eye-state-staring-camera` (spec
87
+ * §3.3's "deliberately not flagged" list) — are absent: their old wording
88
+ * never belonged to the floor.
89
+ *
90
+ * 15 flagged hints + the fold literal = 16.
91
+ */
92
+ export const RETIRED_ADULT_ONLY_HINT_STRINGS: ReadonlyArray<string> = [
93
+ // bust-very-full
94
+ "very full bust",
95
+ // silhouette-hourglass
96
+ "hourglass silhouette",
97
+ // waist-defined
98
+ "defined waist",
99
+ // lip-state-glossy
100
+ "with high-shine glossy wet-look lips",
101
+ // lip-state-parted
102
+ "with lips slightly parted, taking a soft breath",
103
+ // lip-state-bitten
104
+ "playfully biting the lower lip",
105
+ // eye-state-half-lidded
106
+ "with heavy half-lidded sleepy eyes",
107
+ // texture-glistening
108
+ "with glistening skin, sweat or oil catching the light",
109
+ // texture-shower-fresh-wet
110
+ "with just-out-of-the-shower wet skin, water beading on the surface and rolling in slow droplets down the curves of the body",
111
+ // feature-bare-shoulders
112
+ "with bare shoulders exposed, the line of the collarbone and shoulder muscles uncovered",
113
+ // feature-collarbone-visible
114
+ "with a prominent collarbone clearly defined and catching the light",
115
+ // feature-midriff-visible
116
+ "wearing a cropped style with the midriff visible",
117
+ // state-fitted — the 2026-07-30 incident clause
118
+ "the clothing fitted and form-conscious, hugging the contours of the body",
119
+ // state-wet
120
+ "the clothing soaked and wet, the fabric clinging to the body and dripping water",
121
+ // pose `biting-lip` (the lip-state-bitten twin)
122
+ "biting the lower lip with a subtle playful expression",
123
+ // the hard-coded midriff+navel fold in emitIndependentFragments — not any
124
+ // entry's hint, so retiring it ADDS a needle for a clause that really is
125
+ // emitted verbatim.
126
+ "wearing a cropped style, midriff and navel visible",
127
+ ]
128
+
129
+ /** Every full prompt-hint string a flagged entry can inject, lower-cased,
130
+ * longest first — the backend policy strips text that contains any of them
131
+ * (Layer 2), which is how flagged wording arriving inside free text (a
132
+ * client-assembled seedPrompt) is caught. Seeded with
133
+ * `RETIRED_ADULT_ONLY_HINT_STRINGS` so a catalog rewording can never narrow
134
+ * the strip set for a consumer still shipping the pre-rephrase wording.
135
+ *
136
+ * `promptHint` ONLY, deliberately — `term` is analyzer/UI vocabulary
137
+ * (compact-mode display), and `picker-catalogs.ts`'s composed-catalog
138
+ * projection back-fills a `term` for EVERY option via `deriveTerm(label)`
139
+ * when no explicit term was authored (e.g. "lounging", "cropped top",
140
+ * "school uniform", "lying down"). Those short, generic derived terms are
141
+ * exactly the kind of everyday word that collides with unrelated, benign
142
+ * text and over-strips it. Assembled prompts and client seedPrompts are
143
+ * built from HINTS, never from terms, so hints are the only strings that
144
+ * can actually arrive verbatim in free text — terms don't need to be (and
145
+ * must not be) swept here. */
146
+ export function getAdultOnlyHintStrings(): ReadonlyArray<string> {
147
+ const out = new Set<string>(RETIRED_ADULT_ONLY_HINT_STRINGS)
148
+ for (const e of getAdultOnlyEntries()) {
149
+ if (e.promptHint) out.add(e.promptHint.toLowerCase())
150
+ }
151
+ return [...out].filter((s) => s.length >= 8).sort((a, b) => b.length - a.length)
152
+ }
153
+
154
+ /**
155
+ * Every full prompt-hint string that DESCRIBES A MINOR — the mirror of
156
+ * `getAdultOnlyHintStrings`, and the needle list for the TEXT signal below.
157
+ *
158
+ * Why a text signal exists at all: `isMinorAge` reads the structured picker
159
+ * value, and the P0 arrival path does not have one. A thin client can create a
160
+ * character row carrying only `{nodeId, name, projectId}` and send the picker
161
+ * selection as an already-assembled `seedPrompt`, so `row.person === null`
162
+ * while the prompt itself says "a young child around 5 years old". That client
163
+ * assembles the text FROM THESE HINTS, which is exactly why the catalog is the
164
+ * drift-proof needle list: a new minor age entry is swept the day it is added,
165
+ * with no second list to remember.
166
+ *
167
+ * Selection is DERIVED, never hand-listed: an `age` entry is a minor unless its
168
+ * id is in the `ADULT_AGE_IDS` allow-list (so a new age id is inside the floor
169
+ * by default — same ratchet as `isMinorAge`), plus every `type` entry named by
170
+ * `MINOR_IMPLYING_TYPE_IDS`. Read through the pack-composed funnel
171
+ * (`getRegisteredPeople()`), so a deployment pack's entries are swept too.
172
+ *
173
+ * `promptHint` ONLY — never `term` and never `label`, for the same reason
174
+ * `getAdultOnlyHintStrings` gives: `term` is short display vocabulary
175
+ * ("in their teens" as a compact chip) and the composed-catalog projection
176
+ * back-fills a derived `term` for every option, so terms collide with benign
177
+ * text. The `>= 8` filter drops `age-custom`'s empty hint (`""`), which would
178
+ * otherwise match every string ever written.
179
+ */
180
+ let cachedMinorHints: ReadonlyArray<string> | null = null
181
+ let cachedMinorHintSourceCount = -1
182
+ export function getMinorAgeHintStrings(): ReadonlyArray<string> {
183
+ const people = getRegisteredPeople()
184
+ if (cachedMinorHints && cachedMinorHintSourceCount === people.length) return cachedMinorHints
185
+ const out = new Set<string>()
186
+ for (const e of people) {
187
+ const minorAge = e.dimension === "age" && !ADULT_AGE_IDS.has(e.id)
188
+ const minorType = e.dimension === "type" && MINOR_IMPLYING_TYPE_IDS.has(e.id)
189
+ if (!minorAge && !minorType) continue
190
+ if (e.promptHint) out.add(e.promptHint.toLowerCase())
191
+ }
192
+ cachedMinorHintSourceCount = people.length
193
+ cachedMinorHints = [...out].filter((s) => s.length >= 8).sort((a, b) => b.length - a.length)
194
+ return cachedMinorHints
195
+ }
196
+
197
+ function escapeReSource(s: string): string {
198
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
199
+ }
200
+
201
+ /** One needle's regex source: the phrase's tokens — split on whitespace AND
202
+ * hyphens — escaped and rejoined by `[\s-]+`, so it matches however the text
203
+ * that carries it happened to be spaced or hyphenated. */
204
+ function needleTokenSource(needle: string): string {
205
+ return needle
206
+ .trim()
207
+ .split(/[\s-]+/)
208
+ .filter((t) => t.length > 0)
209
+ .map(escapeReSource)
210
+ .join("[\\s-]+")
211
+ }
212
+
213
+ /**
214
+ * The word-bounded alternation SOURCE for a needle list — the one place both
215
+ * layers of the floor build their matcher, so they can never disagree about
216
+ * what "the same phrase" means.
217
+ *
218
+ * Two properties, both load-bearing:
219
+ * - **Separator tolerance.** A catalog hint is authored with single spaces
220
+ * ("very full bust"), but the free text that carries it is written by a
221
+ * human or an LLM and arrives as "very-full bust" or "very full bust".
222
+ * A literal alternation misses those, and a miss is a flagged phrase
223
+ * reaching the provider on a minor's prompt. Each needle's tokens are
224
+ * joined by `[\s-]+`, which requires AT LEAST one separator — so
225
+ * "very fullbust" (no separator at all) is still not a match, and the
226
+ * phrase can't silently widen into a substring rule.
227
+ * - **Word boundaries.** `(?<![\w-])` / `(?![\w-])` keep a needle from
228
+ * gluing onto an adjacent word's characters ("mesh tops" must survive a
229
+ * "mesh top" needle).
230
+ *
231
+ * Ordering is the CALLER's: pass needles longest-first so the alternation
232
+ * consumes the longest match at a given position instead of leaving debris.
233
+ * Returns `null` when there is nothing to match (an empty list would otherwise
234
+ * compile to `(?:)`, which matches everywhere). Flags are the caller's too —
235
+ * `"i"` for a `.test()` instance, `"gi"` for one used with `String.replace`.
236
+ */
237
+ export function buildNeedleAlternationSource(needles: ReadonlyArray<string>): string | null {
238
+ const alts = needles.map(needleTokenSource).filter((s) => s.length > 0)
239
+ if (alts.length === 0) return null
240
+ return `(?<![\\w-])(?:${alts.join("|")})(?![\\w-])`
241
+ }
242
+
243
+ /** Needle alternation over `getMinorAgeHintStrings()`, word-bounded on both
244
+ * sides so a hint can only match a whole phrase. Deliberately NOT global:
245
+ * this instance is only ever `.test()`ed, and a `/g` regex would carry
246
+ * `lastIndex` across calls. Cached by needle count (packs are registered
247
+ * before the first prompt is assembled). */
248
+ let cachedMinorNeedleRe: RegExp | null = null
249
+ let cachedMinorNeedleCount = -1
250
+ function minorNeedleRegex(): RegExp | null {
251
+ const needles = getMinorAgeHintStrings()
252
+ if (cachedMinorNeedleRe && cachedMinorNeedleCount === needles.length) return cachedMinorNeedleRe
253
+ cachedMinorNeedleCount = needles.length
254
+ const src = buildNeedleAlternationSource(needles)
255
+ cachedMinorNeedleRe = src === null ? null : new RegExp(src, "i")
256
+ return cachedMinorNeedleRe
257
+ }
258
+
259
+ /**
260
+ * NUMBER-FIRST age shapes: the ones `buildAgeFragment` (person.ts) emits for a
261
+ * CUSTOM age ("N years old", "N year old", "N-year-old") and the catalog's own
262
+ * "2-3 years old" range (the FIRST number decides), plus the colloquial tails a
263
+ * human or an LLM writes instead — "12yo", "12 yo", "12 y.o.", "12 y/o",
264
+ * "12 yr old", "12-yr-old", "12yrs old", "12 years of age". The cut is `< 20`,
265
+ * the same boundary `isMinorAge` uses for `customAge` and the same one
266
+ * `buildAgeFragment` switches to "in their teens" at.
267
+ *
268
+ * Both boundaries are load-bearing. The LEADING one stops a match inside a
269
+ * larger token; the TRAILING one is what makes "a 5 year older sibling" a
270
+ * non-hit ("old" may not glue onto "older") and what stops "yo" from firing
271
+ * inside "12 young" or "12 yoga". Used via `matchAll`, which clones the regex
272
+ * rather than advancing this instance's `lastIndex`.
273
+ */
274
+ const NUMERIC_AGE_RE =
275
+ /(?<![\w-])(\d{1,3})(?:\s*-\s*\d{1,3})?\s*-?\s*(?:years?\s*-?\s*old|yrs?\s*-?\s*old|y(?:\.\s*o\.?|\/o|o)|years?\s+of\s+age)(?![\w-])/gi
276
+
277
+ /**
278
+ * AGE-FIRST shapes, where the number trails the word instead of leading it:
279
+ * "age 12", "aged 12", "at the age of 12". The leading boundary is what keeps
280
+ * "image 12" / "page 12" out (the `age` there is glued to a preceding word
281
+ * character), and the trailing one keeps "aged 12th" out.
282
+ */
283
+ const PREFIXED_AGE_RE = /(?<![\w-])aged?\s+(?:of\s+)?(\d{1,3})(?![\w-])/gi
284
+
285
+ /** Every rule that reads a NUMBER out of the text, each capturing the age in
286
+ * group 1. Iterated together below so a new shape is one array entry. */
287
+ const AGE_NUMBER_RULES: ReadonlyArray<RegExp> = [NUMERIC_AGE_RE, PREFIXED_AGE_RE]
288
+
289
+ /** The one NON-numeric shape `buildAgeFragment` emits below 20 (`${n} years
290
+ * old, in their teens`). Kept as its own rule rather than as a catalog term.
291
+ * Separator-tolerant on the same terms as the needle alternation, so
292
+ * "in their teens" and "in-their-teens" are the same phrase. */
293
+ const IN_THEIR_TEENS_RE = /(?<![\w-])in[\s-]+their[\s-]+teens(?![\w-])/i
294
+
295
+ /**
296
+ * True when free text describes a MINOR subject. Three rules, in order:
297
+ * 1. any minor-age / minor-implying-type prompt hint, word-bounded;
298
+ * 2. a numeric age below 20 in any shape `buildAgeFragment` emits;
299
+ * 3. the literal "in their teens".
300
+ *
301
+ * Deliberately NOT a bare-word check: "child", "teen", "kid" alone must never
302
+ * fire, or an adult prompt that merely MENTIONS a child ("a mother holding her
303
+ * child") would be floored — the spec's "adults are byte-identical" is a hard
304
+ * requirement, so every rule here is a full phrase or a bounded number.
305
+ *
306
+ * Pairs with `isMinorAge`, never replaces it: the structured picker value is
307
+ * the primary signal; this catches the same subject when only the assembled
308
+ * text survives.
309
+ */
310
+ export function containsMinorAgeHint(text: string | null | undefined): boolean {
311
+ if (typeof text !== "string" || text.trim().length === 0) return false
312
+ const re = minorNeedleRegex()
313
+ if (re && re.test(text)) return true
314
+ for (const rule of AGE_NUMBER_RULES) {
315
+ for (const m of text.matchAll(rule)) {
316
+ const n = Number(m[1])
317
+ if (Number.isFinite(n) && n < 20) return true
318
+ }
319
+ }
320
+ return IN_THEIR_TEENS_RE.test(text)
321
+ }
322
+
323
+ /** The analyzer/import value keys the floor strips flagged ids out of:
324
+ * `person` plus EVERY catalog the flag sweep reads. DERIVED, never
325
+ * hand-listed — the hand-written version omitted `photo-genre` while the
326
+ * sweep included it, so a minor kept `glamour-portrait`. The analyzer JSON is
327
+ * keyed by picker type id (`describe-to-picker.ts`), which is the same string
328
+ * as the catalog id, so this list IS the set of keys to visit. */
329
+ export const FLOORED_PICKER_KEYS = ["person", ...ADULT_SWEPT_CATALOG_IDS] as const
330
+
331
+ function stripIds(obj: Record<string, unknown>, drop: ReadonlySet<string>): Record<string, unknown> {
332
+ const out: Record<string, unknown> = {}
333
+ for (const [k, v] of Object.entries(obj)) {
334
+ if (typeof v === "string") {
335
+ if (!drop.has(v)) out[k] = v
336
+ } else if (Array.isArray(v)) {
337
+ const kept = v.filter((x) => !(typeof x === "string" && drop.has(x)))
338
+ if (kept.length > 0) out[k] = kept
339
+ } else {
340
+ out[k] = v
341
+ }
342
+ }
343
+ return out
344
+ }
345
+
346
+ /** Analyzer / import post-filter: when the person value describes a minor,
347
+ * remove every flagged id from the person, styling, pose and mood values.
348
+ * Identity (same reference) for an adult. */
349
+ export function applyMinorAgeFloorToPickerValues<T extends Record<string, unknown>>(values: T): T {
350
+ const person = values.person
351
+ if (!person || typeof person !== "object" || !isMinorAge(person as { age?: string; customAge?: number; type?: string })) return values
352
+ const drop = getAdultOnlyIds()
353
+ const out: Record<string, unknown> = { ...values }
354
+ for (const key of FLOORED_PICKER_KEYS) {
355
+ const v = out[key]
356
+ if (v && typeof v === "object" && !Array.isArray(v)) out[key] = stripIds(v as Record<string, unknown>, drop)
357
+ }
358
+ return out as T
359
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * LEAF MODULE — no runtime imports. person.ts and styling.ts import this;
3
+ * any catalog import here reopens the person → age-floor → picker-catalogs
4
+ * load-time cycle.
5
+ *
6
+ * `isMinorAge` is an ADULT ALLOW-LIST on purpose: a new age id added to the
7
+ * catalog is inside the floor until someone lists it here as adult. Custom
8
+ * numeric ages use the same boundary as `buildAgeFragment` in person.ts
9
+ * ("in their teens" below 20).
10
+ */
11
+
12
+ export const ADULT_ONLY_FLAG = "adultOnly" as const
13
+
14
+ /** Catalog age ids that describe an ADULT. Everything else is floored. */
15
+ export const ADULT_AGE_IDS: ReadonlySet<string> = new Set([
16
+ "age-early-20s", "age-late-20s", "age-20s", "age-30s", "age-40s", "age-50s", "age-60s", "age-elderly",
17
+ ])
18
+
19
+ /** Type entries whose hint reads as a child even with no age selected. */
20
+ export const MINOR_IMPLYING_TYPE_IDS: ReadonlySet<string> = new Set([
21
+ "alice-wonderland", "dorothy-oz", "peter-pan", "magical-girl", "prince", "princess",
22
+ ])
23
+
24
+ /** True when the picker value describes someone under 20, or a child-typed
25
+ * subject with no age. An explicit adult age always wins. */
26
+ export function isMinorAge(
27
+ value: { readonly age?: string; readonly customAge?: number; readonly type?: string } | null | undefined,
28
+ ): boolean {
29
+ if (!value) return false
30
+ const { age, customAge, type } = value
31
+ if (typeof age === "string" && age.length > 0) {
32
+ if (age === "age-custom") return !(typeof customAge === "number" && Number.isFinite(customAge) && customAge >= 20)
33
+ return !ADULT_AGE_IDS.has(age)
34
+ }
35
+ return typeof type === "string" && MINOR_IMPLYING_TYPE_IDS.has(type)
36
+ }
package/src/atmosphere.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { resolveTerm, type PickerHintMode } from "./term.js"
15
+ import { overlayEntry } from "./catalog-overlay.js"
15
16
 
16
17
  export interface Atmosphere {
17
18
  readonly id: string
@@ -73,7 +74,7 @@ const atmosphereById = new Map<string, Atmosphere>(ATMOSPHERES.map((a) => [a.id,
73
74
 
74
75
  export function getAtmosphere(id: string | undefined | null): Atmosphere | undefined {
75
76
  if (!id) return undefined
76
- return atmosphereById.get(id)
77
+ return overlayEntry("atmosphere", id, atmosphereById.get(id))
77
78
  }
78
79
 
79
80
  export function getAtmosphereLabel(id: string | undefined | null, fallback?: string): string {
package/src/backdrop.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import { resolveTerm } from "./term.js"
26
+ import { overlayEntry } from "./catalog-overlay.js"
26
27
 
27
28
  export type BackdropCategory =
28
29
  | "solid"
@@ -105,7 +106,7 @@ const backdropById = new Map<string, Backdrop>(BACKDROPS.map((b) => [b.id, b]))
105
106
 
106
107
  export function getBackdrop(id: string | undefined | null): Backdrop | undefined {
107
108
  if (!id) return undefined
108
- return backdropById.get(id)
109
+ return overlayEntry("backdrop", id, backdropById.get(id))
109
110
  }
110
111
 
111
112
  export function getBackdropLabel(id: string | undefined | null, fallback?: string): string {
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { resolveTerm } from "./term.js"
13
+ import { overlayEntry } from "./catalog-overlay.js"
13
14
 
14
15
  export interface CameraFormat {
15
16
  readonly id: string
@@ -76,7 +77,7 @@ const formatById = new Map<string, CameraFormat>(CAMERA_FORMATS.map((f) => [f.id
76
77
 
77
78
  export function getCameraFormat(id: string | undefined | null): CameraFormat | undefined {
78
79
  if (!id) return undefined
79
- return formatById.get(id)
80
+ return overlayEntry("camera-format", id, formatById.get(id))
80
81
  }
81
82
 
82
83
  export function getCameraFormatLabel(id: string | undefined | null, fallback?: string): string {
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { resolveTerm, type PickerHintMode } from "./term.js"
11
+ import { overlayEntry } from "./catalog-overlay.js"
11
12
 
12
13
  export type CameraMotionCategory =
13
14
  | "default"
@@ -605,7 +606,7 @@ const motionById = new Map<string, CameraMotion>(
605
606
 
606
607
  export function getCameraMotion(id: string | undefined | null): CameraMotion | undefined {
607
608
  if (!id) return undefined
608
- return motionById.get(id)
609
+ return overlayEntry("camera-motions", id, motionById.get(id))
609
610
  }
610
611
 
611
612
  /** Human-readable label for the given motion id. Falls back to the id if unknown. */
@@ -0,0 +1,189 @@
1
+ import { PICKER_CATALOGS, getPickerCatalog } from "./picker-catalogs.js"
2
+ import { getRegisteredCatalogPacks, catalogPacksVersion } from "./catalog-packs.js"
3
+
4
+ /**
5
+ * THE WALL: refuse work that names a catalog id this deployment does not
6
+ * offer.
7
+ *
8
+ * A curated deployment composes its picker catalogs from packs — entries
9
+ * removed, some rewritten. The browser is supposed to show only the composed
10
+ * catalogs and the resolvers are supposed to honor them, but neither of those
11
+ * is a SAFETY property: a stale tab, an imported workflow, a hand-made request
12
+ * or a bug in either layer can still hand the run path an id the deployment
13
+ * never offered. This module is what makes curation hold regardless — it is
14
+ * consulted at the one place every run passes (the orchestrator, after input
15
+ * overrides are merged), inside nested sub-workflows, and on the single-node
16
+ * routes that carry ids on the wire.
17
+ *
18
+ * INERT WITHOUT PACKS. A deployment that registers no catalog packs offers
19
+ * every base id, so there is nothing to refuse; the guard returns [] without
20
+ * walking. Mainline behavior is unchanged by construction.
21
+ *
22
+ * WHAT COUNTS AS AN ID FIELD. Every field a picker catalog declares — a
23
+ * single-dim catalog's `valueField`, a multi-dim catalog's `dimensions[].field`
24
+ * — plus the few resolver side-fields the catalogs do not declare (the pose
25
+ * sub-picks, the legacy direction keys). A field NOT in that table is never
26
+ * validated: `preText`, `customText`, `customAge`, free-text notes and the
27
+ * like are not ids and must not be refused. The table is derived from the
28
+ * BASE catalogs, so a field a `replace` pack dropped is still recognised as an
29
+ * id field and any value in it fails membership — which is the point.
30
+ */
31
+
32
+ export interface ForeignCatalogId {
33
+ /** The node carrying the id (undefined for a request body). */
34
+ readonly nodeId?: string
35
+ readonly nodeType: string
36
+ readonly field: string
37
+ readonly id: string
38
+ readonly catalogId: string
39
+ }
40
+
41
+ /** Node-data fields that resolve against a catalog but are not declared by
42
+ * it. Kept beside the guard so a new one is a one-line, reviewed addition. */
43
+ const SIDE_FIELDS: ReadonlyArray<readonly [nodeType: string, field: string, catalogId: string]> = [
44
+ // pose.ts resolves all four sub-picks through getPosePromptHint
45
+ ["pose", "handPosition", "pose"],
46
+ ["pose", "bodyLean", "pose"],
47
+ ["pose", "headTilt", "pose"],
48
+ ["pose", "activity", "pose"],
49
+ ]
50
+
51
+ /** Legacy `direction` wire keys still accepted by direction-registry.ts. */
52
+ const DIRECTION_ALIASES: Readonly<Record<string, string>> = {
53
+ framingId: "framing",
54
+ framingAngleId: "framing",
55
+ lightingId: "lighting",
56
+ lensId: "lens",
57
+ cameraFormatId: "camera-format",
58
+ }
59
+
60
+ interface FieldTable {
61
+ /** nodeType → (field → catalogId) */
62
+ readonly byNodeType: ReadonlyMap<string, ReadonlyMap<string, string>>
63
+ /** field → catalogId, across every catalog (for direction/subject bodies) */
64
+ readonly byField: ReadonlyMap<string, string>
65
+ }
66
+
67
+ let tableMemo: FieldTable | null = null
68
+ /** Derived from the BASE catalogs once — the field contract is code, not packs. */
69
+ function fieldTable(): FieldTable {
70
+ if (tableMemo) return tableMemo
71
+ const byNodeType = new Map<string, Map<string, string>>()
72
+ const byField = new Map<string, string>()
73
+ const put = (nodeType: string, field: string, catalogId: string) => {
74
+ let m = byNodeType.get(nodeType)
75
+ if (!m) byNodeType.set(nodeType, (m = new Map()))
76
+ m.set(field, catalogId)
77
+ if (!byField.has(field)) byField.set(field, catalogId)
78
+ }
79
+ for (const c of PICKER_CATALOGS) {
80
+ if (c.valueField) put(c.nodeType, c.valueField, c.catalogId)
81
+ for (const d of c.dimensions ?? []) put(c.nodeType, d.field, c.catalogId)
82
+ for (const f of c.fields ?? []) put(c.nodeType, f, c.catalogId)
83
+ }
84
+ for (const [nodeType, field, catalogId] of SIDE_FIELDS) put(nodeType, field, catalogId)
85
+ for (const [alias, catalogId] of Object.entries(DIRECTION_ALIASES)) if (!byField.has(alias)) byField.set(alias, catalogId)
86
+ tableMemo = { byNodeType, byField }
87
+ return tableMemo
88
+ }
89
+
90
+ let idsMemo: { v: number; byCatalog: Map<string, ReadonlySet<string>> } | null = null
91
+ /** Every id the COMPOSED catalog offers, across options and all dimensions. */
92
+ function composedIds(catalogId: string): ReadonlySet<string> {
93
+ const v = catalogPacksVersion()
94
+ if (!idsMemo || idsMemo.v !== v) idsMemo = { v, byCatalog: new Map() }
95
+ const hit = idsMemo.byCatalog.get(catalogId)
96
+ if (hit) return hit
97
+ const cat = getPickerCatalog(catalogId)
98
+ const ids = new Set<string>()
99
+ for (const o of cat?.options ?? []) ids.add(o.id)
100
+ for (const d of cat?.dimensions ?? []) for (const o of d.options) ids.add(o.id)
101
+ idsMemo.byCatalog.set(catalogId, ids)
102
+ return ids
103
+ }
104
+
105
+ function values(v: unknown): string[] {
106
+ if (typeof v === "string") return v ? [v] : []
107
+ if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string" && x.length > 0)
108
+ return []
109
+ }
110
+
111
+ /** The guard is only meaningful once something curates. */
112
+ export function catalogGuardActive(): boolean {
113
+ return getRegisteredCatalogPacks().length > 0
114
+ }
115
+
116
+ function checkRecord(
117
+ nodeType: string,
118
+ nodeId: string | undefined,
119
+ data: Record<string, unknown>,
120
+ fields: ReadonlyMap<string, string>,
121
+ out: ForeignCatalogId[],
122
+ ): void {
123
+ for (const [field, catalogId] of fields) {
124
+ const raw = data[field]
125
+ if (raw === undefined || raw === null) continue
126
+ const offered = composedIds(catalogId)
127
+ for (const id of values(raw)) {
128
+ if (!offered.has(id)) out.push({ nodeId, nodeType, field, id, catalogId })
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Walk a graph. Parameter nodes are checked against their own catalog's
135
+ * fields; consumer nodes (generate-image, image-to-video, …) carry folded
136
+ * `direction` / `subject` records whose keys are the same field names, so
137
+ * those are checked against the global field table.
138
+ */
139
+ export function findForeignCatalogIds(
140
+ nodes: ReadonlyArray<{ id?: unknown; type?: unknown; data?: unknown }> | undefined,
141
+ ): ForeignCatalogId[] {
142
+ if (!catalogGuardActive() || !nodes) return []
143
+ const table = fieldTable()
144
+ const out: ForeignCatalogId[] = []
145
+ for (const node of nodes) {
146
+ const type = typeof node.type === "string" ? node.type : ""
147
+ const nodeId = typeof node.id === "string" ? node.id : undefined
148
+ const data = node.data && typeof node.data === "object" ? (node.data as Record<string, unknown>) : null
149
+ if (!type || !data) continue
150
+ const own = table.byNodeType.get(type)
151
+ if (own) checkRecord(type, nodeId, data, own, out)
152
+ for (const key of ["direction", "subject"] as const) {
153
+ const rec = data[key]
154
+ if (rec && typeof rec === "object" && !Array.isArray(rec)) {
155
+ checkRecord(type, nodeId, rec as Record<string, unknown>, table.byField, out)
156
+ }
157
+ }
158
+ }
159
+ return out
160
+ }
161
+
162
+ /** A request body's `direction` / `subject` records (the single-node routes). */
163
+ export function findForeignCatalogIdsInBody(
164
+ nodeType: string,
165
+ body: { direction?: unknown; subject?: unknown } | undefined,
166
+ ): ForeignCatalogId[] {
167
+ if (!catalogGuardActive() || !body) return []
168
+ const table = fieldTable()
169
+ const out: ForeignCatalogId[] = []
170
+ for (const key of ["direction", "subject"] as const) {
171
+ const rec = body[key]
172
+ if (rec && typeof rec === "object" && !Array.isArray(rec)) {
173
+ checkRecord(nodeType, undefined, rec as Record<string, unknown>, table.byField, out)
174
+ }
175
+ }
176
+ return out
177
+ }
178
+
179
+ /** One line, naming every offender — the audit trail and the user message. */
180
+ export function foreignCatalogIdMessage(found: readonly ForeignCatalogId[]): string {
181
+ const parts = [...new Set(found.map((f) => `${f.field}="${f.id}"`))]
182
+ return `This deployment does not offer the following picker value${parts.length === 1 ? "" : "s"}: ${parts.join(", ")}. Choose from the options shown in the picker.`
183
+ }
184
+
185
+ /** Test hook. */
186
+ export function __resetCatalogIdGuardForTests(): void {
187
+ tableMemo = null
188
+ idsMemo = null
189
+ }