@nodaro/prompts 1.13.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 (69) hide show
  1. package/dist/index.cjs +719 -255
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +390 -2
  4. package/dist/index.d.ts +390 -2
  5. package/dist/index.js +689 -257
  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__/multi-picker-spec.test.ts +21 -1
  16. package/src/__tests__/person-pack-adult-only.test.ts +45 -0
  17. package/src/__tests__/person-regional-aesthetic.test.ts +2 -1
  18. package/src/__tests__/picker-analyzer-registry.test.ts +14 -0
  19. package/src/__tests__/provider-prompt-doctrine.test.ts +39 -0
  20. package/src/action-fx.ts +2 -1
  21. package/src/aesthetic.ts +2 -1
  22. package/src/age-floor.ts +296 -0
  23. package/src/age-signal.ts +36 -0
  24. package/src/atmosphere.ts +2 -1
  25. package/src/backdrop.ts +2 -1
  26. package/src/camera-format.ts +2 -1
  27. package/src/camera-motions.ts +2 -1
  28. package/src/catalog-id-guard.ts +189 -0
  29. package/src/catalog-overlay.ts +148 -0
  30. package/src/catalog-packs.ts +27 -2
  31. package/src/character-fx.ts +2 -1
  32. package/src/color-look.ts +2 -1
  33. package/src/composition-effects.ts +2 -1
  34. package/src/era.ts +2 -1
  35. package/src/exposure-settings.ts +2 -1
  36. package/src/framing.ts +2 -1
  37. package/src/gemini-omni-inputs.ts +11 -3
  38. package/src/held-prop.ts +3 -1
  39. package/src/index.ts +4 -0
  40. package/src/instrumentation.ts +5 -4
  41. package/src/lens.ts +2 -1
  42. package/src/lighting.ts +2 -1
  43. package/src/loop-subject.ts +3 -1
  44. package/src/materials.ts +2 -1
  45. package/src/mood.ts +16 -5
  46. package/src/music-genre.ts +5 -4
  47. package/src/music-mood.ts +4 -3
  48. package/src/parameter-prompt-hint.ts +13 -57
  49. package/src/person-packs.ts +14 -2
  50. package/src/person.ts +75 -38
  51. package/src/photo-genre.ts +14 -3
  52. package/src/photographer.ts +2 -1
  53. package/src/picker-analyzer-registry.ts +46 -0
  54. package/src/picker-catalogs.ts +11 -0
  55. package/src/pose.ts +17 -6
  56. package/src/post-process-effects.ts +2 -1
  57. package/src/prompt-builder.ts +3 -3
  58. package/src/prompt-wizard-categories.ts +6 -0
  59. package/src/provider-prompt-doctrine.ts +51 -2
  60. package/src/render-quality.ts +2 -1
  61. package/src/setting.ts +3 -1
  62. package/src/shared-catalog-overlay.ts +83 -0
  63. package/src/style.ts +17 -1
  64. package/src/styling.ts +62 -30
  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
@@ -38,10 +38,7 @@ import { composeCameraMotionHintFromConnections } from "./camera-motions.js"
38
38
  import { composeTransitionHintFromConnections, type TransitionDuration, type TransitionIntensity, type TransitionPosition, type TransitionTiming } from "./transitions.js"
39
39
  import { composeCharacterFxHintFromConnections, type CharacterFxDuration, type CharacterFxIntensity, type CharacterFxPosition, type CharacterFxTiming } from "./character-fx.js"
40
40
  import { buildMaterialHints } from "./materials.js"
41
- import { getAnimalPromptHint, getAnimalTerm } from "@nodaro/shared"
42
- import { getVehicle } from "@nodaro/shared"
43
- import { getWeapon } from "@nodaro/shared"
44
- import { getFurniture } from "@nodaro/shared"
41
+ import { curatedAnimalPromptHint, curatedAnimalTerm, curatedVehicleText, curatedWeaponText, curatedFurnitureText } from "./shared-catalog-overlay.js"
45
42
  import { getPhotoGenrePromptHint, getPhotoGenreTerm } from "./photo-genre.js"
46
43
  import { getBackdropPromptHint, getBackdropTerm } from "./backdrop.js"
47
44
  import { buildHeldPropHints } from "./held-prop.js"
@@ -58,7 +55,7 @@ import { buildInstrumentationHints } from "./instrumentation.js"
58
55
  import { buildVoiceCharacterHints } from "./voice-character.js"
59
56
  import { buildVoiceDeliveryHints } from "./voice-delivery.js"
60
57
  import { getPickerCatalog } from "./picker-catalogs.js"
61
- import { deriveTerm, resolveTerm, type PickerHintMode } from "./term.js"
58
+ import { resolveTerm, type PickerHintMode } from "./term.js"
62
59
 
63
60
 
64
61
  function asStr(v: unknown): string {
@@ -109,28 +106,6 @@ function byMode<T>(mode: PickerHintMode, full: T, compact: T): T {
109
106
  return mode === "compact" ? compact : full
110
107
  }
111
108
 
112
- /**
113
- * The compact fragment for an OBJECT-entity entry (animal / vehicle / weapon /
114
- * furniture). Those catalogs carry no `promptHint` of their own — the full
115
- * fragment is synthesized as "featuring a {label}, {description}" — so the
116
- * compact form is the authored `term` when there is one and the derived label
117
- * otherwise (a concrete object's label IS its trade term). The framing verb
118
- * ("featuring a", "with a") belongs to the HINT; a term drops bare into
119
- * whatever sentence the consumer is building, and "the object is in the scene"
120
- * is precisely what these four nodes mean.
121
- *
122
- * The fallback MUST be `deriveTerm` and not a bare `toLowerCase()`: it is the
123
- * same fallback `objectOptions` uses to build the `/v1/catalogs` projection,
124
- * so a parenthetical label ("Rifle (bolt-action)") must strip identically here
125
- * or the injected fragment and the projected term would disagree.
126
- *
127
- * `term` is read structurally because it is being added to the shared entity
128
- * interfaces separately; this stays correct before and after that lands.
129
- */
130
- function objectEntityTerm(entry: { readonly label: string }): string {
131
- return (entry as { term?: string }).term ?? deriveTerm(entry.label)
132
- }
133
-
134
109
  /**
135
110
  * Dispatch by parameter-node type to its prompt-hint string. For camera-motion,
136
111
  * pass `ctx` to include the composed start/end clauses; otherwise only the
@@ -322,42 +297,23 @@ function resolveBaseHint(
322
297
  return withCustomText(data, byMode(mode, getLoopSubjectPromptHint, getLoopSubjectTerm)(asStr(data.loopSubject)))
323
298
  case "material":
324
299
  return withCustomText(data, buildMaterialHints(data.material, mode))
325
- // Animal is the one Object-entity catalog whose phrasing has a single
326
- // owner: `@nodaro/shared`'s `getAnimalPromptHint` / `getAnimalTerm`, which
327
- // the picker-catalog funnel calls too. Both getters already return "" on a
328
- // miss, so the entry lookup and the `animal ? … : ""` guard are the
329
- // getters' job now, not this switch's.
300
+ // The four object-entity catalogs are owned by @nodaro/shared and overlaid
301
+ // in shared-catalog-overlay.ts — the ONE module every prompt-text read of
302
+ // them goes through (the subject fold uses it too). Each returns "" on a
303
+ // miss, so the entry lookup is the module's job, not this switch's.
330
304
  case "animal":
331
- return withCustomText(
332
- data,
333
- byMode(mode, getAnimalPromptHint, getAnimalTerm)(asStr(data.animal)),
334
- )
305
+ return withCustomText(data, byMode(mode, curatedAnimalPromptHint, curatedAnimalTerm)(asStr(data.animal)))
335
306
  case "vehicle": {
336
- const vehicle = getVehicle(asStr(data.vehicle))
337
- return withCustomText(
338
- data,
339
- vehicle
340
- ? byMode(mode, `featuring a ${vehicle.label.toLowerCase()}, ${vehicle.description}`, objectEntityTerm(vehicle))
341
- : "",
342
- )
307
+ const t = curatedVehicleText(asStr(data.vehicle))
308
+ return withCustomText(data, byMode(mode, t.hint, t.term))
343
309
  }
344
310
  case "weapon": {
345
- const weapon = getWeapon(asStr(data.weapon))
346
- return withCustomText(
347
- data,
348
- weapon
349
- ? byMode(mode, `with a ${weapon.label.toLowerCase()}, ${weapon.description}`, objectEntityTerm(weapon))
350
- : "",
351
- )
311
+ const t = curatedWeaponText(asStr(data.weapon))
312
+ return withCustomText(data, byMode(mode, t.hint, t.term))
352
313
  }
353
314
  case "furniture": {
354
- const furniture = getFurniture(asStr(data.furniture))
355
- return withCustomText(
356
- data,
357
- furniture
358
- ? byMode(mode, `including a ${furniture.label.toLowerCase()}, ${furniture.description}`, objectEntityTerm(furniture))
359
- : "",
360
- )
315
+ const t = curatedFurnitureText(asStr(data.furniture))
316
+ return withCustomText(data, byMode(mode, t.hint, t.term))
361
317
  }
362
318
  case "photo-genre":
363
319
  return withCustomText(data, byMode(mode, getPhotoGenrePromptHint, getPhotoGenreTerm)(asStr(data.photoGenre)))
@@ -5,7 +5,7 @@ import {
5
5
  PERSON_FIELD_BY_DIMENSION,
6
6
  type Person,
7
7
  } from "./person.js"
8
- import { registerCatalogPack, getRegisteredCatalogPacks } from "./catalog-packs.js"
8
+ import { registerCatalogPack, getRegisteredCatalogPacks, catalogPacksVersion } from "./catalog-packs.js"
9
9
  import type { PickerDimension, PickerOption } from "./picker-catalogs.js"
10
10
  import { resolveTerm } from "./term.js"
11
11
  import { setRegisteredPersonPackFields } from "@nodaro/shared"
@@ -64,6 +64,7 @@ export function registerPersonPack(pack: PersonPack): void {
64
64
  category: e.group,
65
65
  promptHint: e.promptHint,
66
66
  term: resolveTerm(e),
67
+ adultOnly: e.adultOnly,
67
68
  }
68
69
  const existing = byField.get(dim.field)
69
70
  if (existing) existing.options.push(opt)
@@ -85,8 +86,17 @@ export function resetPersonPacks(): void {
85
86
  version++
86
87
  syncPersonPackFields()
87
88
  }
89
+ /**
90
+ * The version every person memo keys on. It moves when a PERSON pack registers
91
+ * AND when a CATALOG pack for `person` does: `getRegisteredPeople()` folds
92
+ * both, so a memo keyed on the person-pack counter alone kept serving a
93
+ * denied id after a `deny` catalog pack for person landed — correct on the
94
+ * server only because the Haredi person pack happened to register afterwards
95
+ * and bump this, and wrong in the browser, where only catalog packs arrive.
96
+ * Folding the catalog version in makes the memo honest regardless of order.
97
+ */
88
98
  export function personPacksVersion(): number {
89
- return version
99
+ return version * 1_000_003 + catalogPacksVersion()
90
100
  }
91
101
 
92
102
  /** Reverse of the registered dimension→field map, for reconstructing entries
@@ -110,6 +120,7 @@ function personEntriesFromDims(
110
120
  description?: string
111
121
  category?: string
112
122
  promptHint: string
123
+ adultOnly?: true
113
124
  }>
114
125
  }>,
115
126
  ): RegisteredPersonEntry[] {
@@ -125,6 +136,7 @@ function personEntriesFromDims(
125
136
  group: o.category,
126
137
  promptHint: o.promptHint,
127
138
  dimension,
139
+ adultOnly: o.adultOnly,
128
140
  } as RegisteredPersonEntry)
129
141
  }
130
142
  }
package/src/person.ts CHANGED
@@ -37,6 +37,12 @@ import {
37
37
  personPacksVersion,
38
38
  } from "./person-packs.js"
39
39
  import { resolveTerm, type PickerHintMode } from "./term.js"
40
+ // W1-a minor-age floor: `age-signal.ts` is an import-free leaf (no runtime
41
+ // deps at all), so this import is cycle-safe unconditionally — unlike
42
+ // `age-floor.js`, which pulls in the catalog funnel (`person-packs.js` /
43
+ // `picker-catalogs.js`) and would reopen the person → age-floor →
44
+ // picker-catalogs load-time cycle if imported from here.
45
+ import { isMinorAge } from "./age-signal.js"
40
46
 
41
47
  export type PersonDimension =
42
48
  | "type"
@@ -79,6 +85,16 @@ export interface Person {
79
85
  readonly dimension: PersonDimension
80
86
  readonly description: string
81
87
  readonly promptHint: string
88
+ /**
89
+ * W1-a minor-age floor (spec 2026-09-01 §3.3). `true` marks an entry whose
90
+ * hint describes body exposure, sheer/wet clothing, swimwear/lingerie, a
91
+ * seductive expression or gaze, or a body-placed tattoo. The fragment
92
+ * collectors DROP flagged entries for a minor subject (`isMinorAge`), the
93
+ * picker hides their tiles, the analyzer never emits them, and the backend
94
+ * policy strips their wording from free text. Hand-curated; the
95
+ * `adult-only-ratchet` test only ratchets. Never set on neutral defaults.
96
+ */
97
+ readonly adultOnly?: true
82
98
  /** Optional sub-grouping within a dimension. Used by the picker to render
83
99
  * two-level selection (e.g. ethnicity grouped by region → specific). */
84
100
  readonly group?: string
@@ -138,7 +154,7 @@ export const PEOPLE: ReadonlyArray<Person> = [
138
154
  { id: "stylish-influencer", label: "Stylish Influencer", group: "Realistic — Style", dimension: "type", description: "Polished social-media tastemaker", promptHint: "a stylish social-media influencer — polished personal-brand styling, trend-forward outfit, expressive natural posing for the camera, the curated put-together look of a content creator" },
139
155
  { id: "stunning-model", label: "Stunning Model", group: "Realistic — Style", dimension: "type", description: "Fashion-model aesthetic", promptHint: "a stunning fashion model with editorial poise, refined features, and high-fashion presence" },
140
156
  { id: "supermodel", label: "Supermodel", group: "Realistic — Style", dimension: "type", description: "Iconic, top-tier runway and cover star", promptHint: "a supermodel — iconic top-tier presence with magnetic statement features, a striking silhouette and the unmistakable runway-and-cover aura that commands attention" },
141
- { id: "femme-fatale", label: "Femme Fatale", group: "Realistic — Style", dimension: "type", description: "Alluring, dangerous noir seductress", promptHint: "a femme fatale — alluring, mysterious, and dangerous with classic noir seduction" },
157
+ { id: "femme-fatale", label: "Femme Fatale", group: "Realistic — Style", dimension: "type", description: "Alluring, dangerous noir seductress", promptHint: "a femme fatale — alluring, mysterious, and dangerous with classic noir seduction" , adultOnly: true },
142
158
  { id: "tough-guy", label: "Tough Guy", group: "Realistic — Style", dimension: "type", description: "Hardened, tough man", promptHint: "a tough, hardened man" },
143
159
  // Aesthetic archetypes that traditionally read older — hints kept gender +
144
160
  // look-focused so the Age dimension stays in control of the literal age.
@@ -149,7 +165,7 @@ export const PEOPLE: ReadonlyArray<Person> = [
149
165
  { id: "twink", label: "Twink", group: "Realistic — Style", dimension: "type", description: "Slim, smooth, soft gay-male type", promptHint: "a twink — slim, smooth, soft masculine presence with a delicate, gender-fluid vibe" },
150
166
  { id: "soft-butch", label: "Soft Butch", group: "Realistic — Style", dimension: "type", description: "Gentle masculine-of-center woman", promptHint: "a soft-butch woman — gentle masculine-of-center styling and energy" },
151
167
  { id: "tomboy", label: "Tomboy", group: "Realistic — Style", dimension: "type", description: "Boyish styling and energy on a feminine body", promptHint: "a tomboy — boyish styling and energy on a feminine body" },
152
- { id: "thick", label: "Thick", group: "Realistic — Style", dimension: "type", description: "Voluptuous body-positive figure with curves", promptHint: "a thick, voluptuous body-positive figure with full curves", term: "thick voluptuous figure" },
168
+ { id: "thick", label: "Thick", group: "Realistic — Style", dimension: "type", description: "Voluptuous body-positive figure with curves", promptHint: "a thick, voluptuous body-positive figure with full curves", term: "thick voluptuous figure" , adultOnly: true },
153
169
  { id: "androgynous", label: "Androgynous", group: "Realistic — Style", dimension: "type", description: "Deliberately blended masculine + feminine cues", promptHint: "an androgynous figure with deliberately blended masculine and feminine cues", term: "androgynous figure" },
154
170
  { id: "goth", label: "Goth", group: "Realistic — Style", dimension: "type", description: "Goth subculture aesthetic — dark, romantic, dramatic", promptHint: "a goth figure with a dark romantic dramatic subculture aesthetic" },
155
171
  { id: "bear", label: "Bear", group: "Realistic — Style", dimension: "type", description: "Big, bearded, hairy masculine type", promptHint: "a bear — a big, bearded, hairy man with a warm masculine presence", term: "big bearded hairy man" },
@@ -158,7 +174,7 @@ export const PEOPLE: ReadonlyArray<Person> = [
158
174
  { id: "caveman", label: "Caveman", group: "Primitive / Wild", dimension: "type", description: "Stone Age primitive man", promptHint: "a caveman — Stone Age primitive in animal furs, with shaggy hair, a heavy brow and a sturdy frame" },
159
175
  { id: "cavewoman", label: "Cavewoman", group: "Primitive / Wild", dimension: "type", description: "Stone Age primitive woman", promptHint: "a cavewoman — Stone Age primitive in animal furs, with shaggy hair and a sturdy frame" },
160
176
  { id: "apeman", label: "Apeman", group: "Primitive / Wild", dimension: "type", description: "Half-human half-ape hybrid", promptHint: "an apeman — half-human half-ape hybrid with simian features, broad shoulders and a heavy stooped build" },
161
- { id: "feral-wildman", label: "Feral Wild-Human", group: "Primitive / Wild", dimension: "type", description: "Overgrown, primal feral human", promptHint: "a feral wild-human — overgrown matted hair, primal eyes and a near-naked, bestial bearing" },
177
+ { id: "feral-wildman", label: "Feral Wild-Human", group: "Primitive / Wild", dimension: "type", description: "Overgrown, primal feral human", promptHint: "a feral wild-human — overgrown matted hair, primal eyes and a near-naked, bestial bearing" , adultOnly: true },
162
178
  { id: "neanderthal", label: "Neanderthal", group: "Primitive / Wild", dimension: "type", description: "Pre-modern human species", promptHint: "a neanderthal — heavy brow, broad nose, stocky pre-modern human build with weather-toughened skin" },
163
179
 
164
180
  // ----- Fantasy (humanoid) -----
@@ -173,7 +189,7 @@ export const PEOPLE: ReadonlyArray<Person> = [
173
189
  { id: "faun", label: "Faun / Satyr", group: "Fantasy", dimension: "type", description: "Goat-legged horned humanoid", promptHint: "a faun — humanoid with goat legs, small curling horns and a woodland presence", term: "faun" },
174
190
  { id: "centaur", label: "Centaur", group: "Fantasy", dimension: "type", description: "Human torso on a horse body", promptHint: "a centaur — human torso fused to the body of a horse" },
175
191
  { id: "vampire-man", label: "Vampire (Male)", group: "Fantasy", dimension: "type", description: "Masculine fanged immortal", promptHint: "a male vampire — pale, fanged immortal man in formal evening wear with gothic elegance and a quiet predatory poise", term: "male vampire" },
176
- { id: "vampire-woman", label: "Vampire (Female)", group: "Fantasy", dimension: "type", description: "Feminine fanged immortal", promptHint: "a female vampire — pale, fanged immortal woman in flowing gothic attire with seductive elegance and a quiet predatory poise", term: "female vampire" },
192
+ { id: "vampire-woman", label: "Vampire (Female)", group: "Fantasy", dimension: "type", description: "Feminine fanged immortal", promptHint: "a female vampire — pale, fanged immortal woman in flowing gothic attire with seductive elegance and a quiet predatory poise", term: "female vampire" , adultOnly: true },
177
193
  { id: "vampire", label: "Vampire", group: "Fantasy", dimension: "type", description: "Pale, fanged immortal", promptHint: "a vampire — pale, fanged immortal with a pristine gothic elegance and a quiet predatory poise" },
178
194
  { id: "werewolf", label: "Werewolf", group: "Fantasy", dimension: "type", description: "Partially transformed lycanthrope", promptHint: "a werewolf — partially transformed humanoid with wolfish snout, fur, claws and a feral musculature" },
179
195
  { id: "mermaid", label: "Mermaid", group: "Fantasy", dimension: "type", description: "Female humanoid with a fish tail", promptHint: "a mermaid — humanoid woman from the waist up with a long iridescent fish tail below" },
@@ -418,25 +434,25 @@ export const PEOPLE: ReadonlyArray<Person> = [
418
434
  { id: "mass-heavy", label: "Heavy", dimension: "body-mass", description: "Heavy-set, large build", promptHint: "heavy-set build", term: "heavy-set build" },
419
435
 
420
436
  // ----- Bust (applicable subjects; ungated like Facial Hair, neutral = no-op) -----
421
- { id: "bust-small", label: "Small", dimension: "bust", description: "Small bust", promptHint: "small bust", term: "small bust" },
437
+ { id: "bust-small", label: "Small", dimension: "bust", description: "Small bust", promptHint: "small bust", term: "small bust" , adultOnly: true },
422
438
  { id: "bust-average", label: "Average", dimension: "bust", description: "Average bust", promptHint: "" },
423
- { id: "bust-full", label: "Full", dimension: "bust", description: "Full bust", promptHint: "full bust", term: "full bust" },
424
- { id: "bust-very-full", label: "Very Full", dimension: "bust", description: "Very full bust", promptHint: "very full bust", term: "very full bust" },
439
+ { id: "bust-full", label: "Full", dimension: "bust", description: "Full bust", promptHint: "full bust", term: "full bust" , adultOnly: true },
440
+ { id: "bust-very-full", label: "Very Full", dimension: "bust", description: "Very full bust", promptHint: "very full bust", term: "very full bust" , adultOnly: true },
425
441
 
426
442
  // ----- Waist -----
427
- { id: "waist-defined", label: "Defined", dimension: "waist", description: "Defined, cinched waist", promptHint: "defined waist", term: "defined waist" },
443
+ { id: "waist-defined", label: "Defined", dimension: "waist", description: "Defined, cinched waist", promptHint: "defined waist", term: "defined waist" , adultOnly: true },
428
444
  { id: "waist-average", label: "Average", dimension: "waist", description: "Average waist", promptHint: "" },
429
- { id: "waist-straight", label: "Straight", dimension: "waist", description: "Straight, undefined waist", promptHint: "straight waistline", term: "straight waistline" },
445
+ { id: "waist-straight", label: "Straight", dimension: "waist", description: "Straight, undefined waist", promptHint: "straight waistline", term: "straight waistline" , adultOnly: true },
430
446
 
431
447
  // ----- Hips -----
432
- { id: "hips-narrow", label: "Narrow", dimension: "hips", description: "Narrow hips", promptHint: "narrow hips", term: "narrow hips" },
448
+ { id: "hips-narrow", label: "Narrow", dimension: "hips", description: "Narrow hips", promptHint: "narrow hips", term: "narrow hips" , adultOnly: true },
433
449
  { id: "hips-balanced", label: "Balanced", dimension: "hips", description: "Balanced hips", promptHint: "" },
434
- { id: "hips-wide", label: "Wide", dimension: "hips", description: "Wide hips", promptHint: "wide hips", term: "wide hips" },
450
+ { id: "hips-wide", label: "Wide", dimension: "hips", description: "Wide hips", promptHint: "wide hips", term: "wide hips" , adultOnly: true },
435
451
 
436
452
  // ----- Silhouette (overall body shape; optional, no neutral) -----
437
- { id: "silhouette-hourglass", label: "Hourglass", dimension: "silhouette", description: "Balanced bust + hips, defined waist", promptHint: "hourglass silhouette", term: "hourglass silhouette" },
453
+ { id: "silhouette-hourglass", label: "Hourglass", dimension: "silhouette", description: "Balanced bust + hips, defined waist", promptHint: "hourglass silhouette", term: "hourglass silhouette" , adultOnly: true },
438
454
  { id: "silhouette-rectangular", label: "Rectangular", dimension: "silhouette", description: "Straight up-and-down silhouette", promptHint: "rectangular silhouette", term: "rectangular silhouette" },
439
- { id: "silhouette-pear", label: "Pear", dimension: "silhouette", description: "Hips wider than shoulders", promptHint: "pear-shaped silhouette", term: "pear-shaped silhouette" },
455
+ { id: "silhouette-pear", label: "Pear", dimension: "silhouette", description: "Hips wider than shoulders", promptHint: "pear-shaped silhouette", term: "pear-shaped silhouette" , adultOnly: true },
440
456
  { id: "silhouette-inverted", label: "Inverted Triangle", dimension: "silhouette", description: "Broad shoulders, narrow hips", promptHint: "inverted-triangle silhouette", term: "inverted-triangle silhouette" },
441
457
  { id: "silhouette-athletic", label: "Athletic", dimension: "silhouette", description: "Lean, toned, V-taper", promptHint: "athletic silhouette", term: "athletic silhouette" },
442
458
 
@@ -537,17 +553,17 @@ export const PEOPLE: ReadonlyArray<Person> = [
537
553
 
538
554
  // -------------------- Lip State (what the lips are doing / wearing) --------------------
539
555
  { id: "lip-state-chapped", label: "Chapped", dimension: "lip-state", description: "Cracked, dry, weather-worn lips", promptHint: "with chapped, cracked, weather-worn dry lips", term: "chapped dry lips" },
540
- { id: "lip-state-glossy", label: "Glossy", dimension: "lip-state", description: "High-shine, wet-look lips", promptHint: "with high-shine glossy wet-look lips", term: "glossy wet-look lips" },
556
+ { id: "lip-state-glossy", label: "Glossy", dimension: "lip-state", description: "High-shine, wet-look lips", promptHint: "with high-shine glossy wet-look lips", term: "glossy wet-look lips" , adultOnly: true },
541
557
  { id: "lip-state-bare", label: "Bare", dimension: "lip-state", description: "Natural, untreated, no makeup", promptHint: "with bare, natural, untreated lips", term: "bare natural lips" },
542
558
  { id: "lip-state-bold-red", label: "Bold Red", dimension: "lip-state", description: "Saturated red lipstick statement", promptHint: "with a bold, saturated red lipstick statement", term: "bold red lipstick" },
543
- { id: "lip-state-bitten", label: "Bitten", dimension: "lip-state", description: "Slight playful lip-bite, lower lip caught", promptHint: "playfully biting the lower lip", term: "biting the lower lip" },
544
- { id: "lip-state-parted", label: "Parted", dimension: "lip-state", description: "Lips slightly parted, breath of air", promptHint: "with lips slightly parted, taking a soft breath", term: "lips slightly parted" },
559
+ { id: "lip-state-bitten", label: "Bitten", dimension: "lip-state", description: "Slight playful lip-bite, lower lip caught", promptHint: "playfully biting the lower lip", term: "biting the lower lip" , adultOnly: true },
560
+ { id: "lip-state-parted", label: "Parted", dimension: "lip-state", description: "Lips slightly parted, breath of air", promptHint: "with lips slightly parted, taking a soft breath", term: "lips slightly parted" , adultOnly: true },
545
561
  { id: "lip-state-pursed", label: "Pursed", dimension: "lip-state", description: "Lips pressed and pushed forward", promptHint: "with lips pursed, pressed and pushed forward", term: "pursed lips" },
546
562
  { id: "lip-state-bold-black", label: "Bold Black", dimension: "lip-state", description: "Saturated black lipstick statement (goth / avant-garde)", promptHint: "with a bold, saturated black lipstick statement, goth / avant-garde", term: "bold black lipstick" },
547
563
  { id: "lip-state-burgundy", label: "Burgundy", dimension: "lip-state", description: "Deep wine-red lipstick", promptHint: "with deep wine-red burgundy lipstick", term: "burgundy lipstick" },
548
564
  { id: "lip-state-plum", label: "Plum", dimension: "lip-state", description: "Dark purple-toned lipstick", promptHint: "with dark purple-toned plum lipstick", term: "plum lipstick" },
549
565
  { id: "lip-state-mauve", label: "Mauve", dimension: "lip-state", description: "Muted mauve / dusty-pink lipstick", promptHint: "with muted mauve, dusty-pink lipstick", term: "mauve lipstick" },
550
- { id: "lip-state-pouting", label: "Pouting", dimension: "lip-state", description: "Full pout", promptHint: "with a full pouting expression", term: "full pouting expression" },
566
+ { id: "lip-state-pouting", label: "Pouting", dimension: "lip-state", description: "Full pout", promptHint: "with a full pouting expression", term: "full pouting expression" , adultOnly: true },
551
567
 
552
568
  // -------------------- Hair Color --------------------
553
569
  // Blonde family (light → warm → cool)
@@ -660,7 +676,7 @@ export const PEOPLE: ReadonlyArray<Person> = [
660
676
 
661
677
  // -------------------- Eye State (what the eyes are doing / where they look) --------------------
662
678
  { id: "eye-state-closed", label: "Closed", dimension: "eye-state", description: "Eyes fully closed, peaceful", promptHint: "with eyes fully closed in a peaceful expression", term: "eyes fully closed" },
663
- { id: "eye-state-half-lidded", label: "Half-lidded", dimension: "eye-state", description: "Heavy-lidded sleepy gaze", promptHint: "with heavy half-lidded sleepy eyes", term: "half-lidded sleepy eyes" },
679
+ { id: "eye-state-half-lidded", label: "Half-lidded", dimension: "eye-state", description: "Heavy-lidded sleepy gaze", promptHint: "with heavy half-lidded sleepy eyes", term: "half-lidded sleepy eyes" , adultOnly: true },
664
680
  { id: "eye-state-wide-eyed", label: "Wide-eyed", dimension: "eye-state", description: "Eyes wide open, alert / surprised", promptHint: "with eyes wide open, alert and surprised" },
665
681
  { id: "eye-state-staring-camera", label: "Staring at Camera", dimension: "eye-state", description: "Direct unbroken eye contact with the lens", promptHint: "staring directly at the camera with unbroken eye contact" },
666
682
  { id: "eye-state-gazing-away", label: "Gazing Away", dimension: "eye-state", description: "Looking off-camera, contemplative", promptHint: "gazing off-camera with a contemplative expression", term: "gazing off-camera" },
@@ -682,20 +698,20 @@ export const PEOPLE: ReadonlyArray<Person> = [
682
698
  { id: "texture-wrinkled", label: "Wrinkled", dimension: "skin-texture", description: "Aged, deeply lined skin", promptHint: "with deep wrinkles and aged skin texture", term: "deeply wrinkled aged skin" },
683
699
  { id: "texture-goosebumps", label: "Goosebumps", dimension: "skin-texture", description: "Raised goosebumps on skin", promptHint: "with goosebumps raised on the skin" },
684
700
  { id: "texture-dewy", label: "Dewy", dimension: "skin-texture", description: "Glowing, dewy fresh skin", promptHint: "with dewy, glowing skin and a fresh sheen", term: "dewy glowing skin" },
685
- { id: "texture-glistening", label: "Glistening", dimension: "skin-texture", description: "Sweat or oil sheen", promptHint: "with glistening skin, sweat or oil catching the light", term: "glistening skin" },
701
+ { id: "texture-glistening", label: "Glistening", dimension: "skin-texture", description: "Sweat or oil sheen", promptHint: "with glistening skin, sweat or oil catching the light", term: "glistening skin" , adultOnly: true },
686
702
  { id: "texture-weathered", label: "Weathered", dimension: "skin-texture", description: "Sun-aged rough skin", promptHint: "with weathered, sun-worn rough skin", term: "weathered sun-worn skin" },
687
703
  { id: "texture-porcelain", label: "Porcelain", dimension: "skin-texture", description: "Flawless near-translucent porcelain", promptHint: "with flawless, near-translucent porcelain skin", term: "porcelain skin" },
688
704
  { id: "texture-sun-kissed", label: "Sun-kissed", dimension: "skin-texture", description: "Warm tan with healthy glow", promptHint: "with sun-kissed skin, warmly tanned with a healthy glow", term: "sun-kissed skin" },
689
705
  { id: "texture-tanned", label: "Tanned", dimension: "skin-texture", description: "Deeply, evenly tanned skin", promptHint: "with deeply, evenly bronzed tanned skin", term: "deeply tanned skin" },
690
- { id: "texture-tan-lines", label: "Tan Lines", dimension: "skin-texture", description: "Visible tan lines from swimwear", promptHint: "with visible swimwear tan lines on the skin", term: "visible swimwear tan lines" },
706
+ { id: "texture-tan-lines", label: "Tan Lines", dimension: "skin-texture", description: "Visible tan lines from swimwear", promptHint: "with visible swimwear tan lines on the skin", term: "visible swimwear tan lines" , adultOnly: true },
691
707
  { id: "texture-freckled", label: "Freckled", dimension: "skin-texture", description: "Freckles across cheeks and nose", promptHint: "with visible freckles scattered across cheeks and the bridge of the nose", term: "freckled skin" },
692
708
  { id: "texture-ruddy", label: "Ruddy", dimension: "skin-texture", description: "Pink-flushed cheeks and nose", promptHint: "with ruddy, warm pink-flushed cheeks and nose", term: "ruddy flushed complexion" },
693
709
  { id: "texture-ashen", label: "Ashen / Pale", dimension: "skin-texture", description: "Cool pale, near-bloodless undertone", promptHint: "with ashen, cool pale, near-bloodless skin", term: "ashen pale skin" },
694
- { id: "texture-oily", label: "Oily / Shiny", dimension: "skin-texture", description: "Slight oil sheen on T-zone", promptHint: "with a slight oily sheen across the T-zone", term: "oily shiny skin" },
710
+ { id: "texture-oily", label: "Oily / Shiny", dimension: "skin-texture", description: "Slight oil sheen on T-zone", promptHint: "with a slight oily sheen across the T-zone", term: "oily shiny skin" , adultOnly: true },
695
711
  { id: "texture-matte", label: "Matte", dimension: "skin-texture", description: "Poreless matte finish", promptHint: "with a poreless, matte skin finish", term: "matte skin finish" },
696
712
  { id: "texture-blemished", label: "Blemished", dimension: "skin-texture", description: "Visible blemishes, real-skin imperfections", promptHint: "with visible blemishes and natural real-skin imperfections", term: "blemished skin" },
697
713
  { id: "texture-baby-soft", label: "Baby-soft", dimension: "skin-texture", description: "Smooth, fine-pored youthful skin", promptHint: "with baby-soft, fine-pored, youthful smooth skin", term: "baby-soft skin" },
698
- { id: "texture-shower-fresh-wet", label: "Shower-Fresh Wet", dimension: "skin-texture", description: "Just-out-of-shower wet skin with water beads", promptHint: "with just-out-of-the-shower wet skin, water beading on the surface and rolling in slow droplets down the curves of the body", term: "shower-fresh wet skin" },
714
+ { id: "texture-shower-fresh-wet", label: "Shower-Fresh Wet", dimension: "skin-texture", description: "Just-out-of-shower wet skin with water beads", promptHint: "with just-out-of-the-shower wet skin, water beading on the surface and rolling in slow droplets down the curves of the body", term: "shower-fresh wet skin" , adultOnly: true },
699
715
  { id: "texture-acne-scarred", label: "Acne-scarred", dimension: "skin-texture", description: "Visible acne scarring (distinct from blemished — healed scar pattern)", promptHint: "with visible acne scarring, healed pitted-skin texture and uneven surface from past breakouts", term: "acne-scarred skin" },
700
716
 
701
717
  // -------------------- Distinctive Features --------------------
@@ -717,18 +733,18 @@ export const PEOPLE: ReadonlyArray<Person> = [
717
733
  { id: "feature-sleeve-tattoo", label: "Sleeve Tattoo", dimension: "distinctive-features", description: "Full one-arm tattoo sleeve", promptHint: "with a full tattoo sleeve covering one arm in intricate ink" },
718
734
  { id: "feature-face-tattoo", label: "Face Tattoo", dimension: "distinctive-features", description: "Small face tattoo near eye or cheekbone", promptHint: "with a small face tattoo near the eye or cheekbone" },
719
735
  { id: "feature-hand-tattoos", label: "Hand Tattoos", dimension: "distinctive-features", description: "Knuckle / finger tattoos", promptHint: "with hand tattoos across knuckles and fingers" },
720
- { id: "feature-back-tattoo", label: "Back Tattoo", dimension: "distinctive-features", description: "Large piece across the back", promptHint: "with a large back-piece tattoo across the upper back" },
721
- { id: "feature-chest-tattoo", label: "Chest Tattoo", dimension: "distinctive-features", description: "Tattoo across chest / décolletage", promptHint: "with a tattoo across the chest and décolletage" },
722
- { id: "feature-leg-tattoo", label: "Leg Tattoo", dimension: "distinctive-features", description: "Visible tattoo on leg / calf / thigh", promptHint: "with a visible tattoo on the leg, running across the calf or thigh" },
736
+ { id: "feature-back-tattoo", label: "Back Tattoo", dimension: "distinctive-features", description: "Large piece across the back", promptHint: "with a large back-piece tattoo across the upper back" , adultOnly: true },
737
+ { id: "feature-chest-tattoo", label: "Chest Tattoo", dimension: "distinctive-features", description: "Tattoo across chest / décolletage", promptHint: "with a tattoo across the chest and décolletage" , adultOnly: true },
738
+ { id: "feature-leg-tattoo", label: "Leg Tattoo", dimension: "distinctive-features", description: "Visible tattoo on leg / calf / thigh", promptHint: "with a visible tattoo on the leg, running across the calf or thigh" , adultOnly: true },
723
739
  { id: "feature-forearm-tattoo", label: "Forearm Tattoo", dimension: "distinctive-features", description: "Tattoo on inner or outer forearm", promptHint: "with a tattoo on the forearm, visible on the inner or outer side" },
724
740
  { id: "feature-visible-piercings", label: "Visible Piercings", dimension: "distinctive-features", description: "Multiple visible piercings (septum, nose, lip, multi-ear)", promptHint: "with multiple visible piercings — septum, nose, lip, and multi-ear", term: "multiple visible piercings" },
725
741
  { id: "feature-ear-piercings", label: "Ear Piercings", dimension: "distinctive-features", description: "Multiple stacked ear piercings (cartilage, helix, conch)", promptHint: "with multiple stacked ear piercings — cartilage, helix, and conch", term: "multiple stacked ear piercings" },
726
742
  { id: "feature-lip-piercing", label: "Lip Piercing", dimension: "distinctive-features", description: "Lip ring / labret stud", promptHint: "with a lip piercing, a small ring or labret stud at the lip" },
727
743
  { id: "feature-nostril-piercing", label: "Nostril Piercing", dimension: "distinctive-features", description: "Single nostril stud or ring", promptHint: "with a single nostril piercing, a small stud or ring at the nostril" },
728
- { id: "feature-bare-shoulders", label: "Bare Shoulders", dimension: "distinctive-features", description: "Bare shoulders exposed", promptHint: "with bare shoulders exposed, the line of the collarbone and shoulder muscles uncovered" },
729
- { id: "feature-collarbone-visible", label: "Collarbone Visible", dimension: "distinctive-features", description: "Prominent collarbone catching light", promptHint: "with a prominent collarbone clearly defined and catching the light", term: "visible collarbone" },
730
- { id: "feature-midriff-visible", label: "Midriff Visible", dimension: "distinctive-features", description: "Exposed midriff between top and bottom", promptHint: "wearing a cropped style with the midriff visible", term: "visible midriff" },
731
- { id: "feature-navel-visible", label: "Navel Visible", dimension: "distinctive-features", description: "Visible navel on bare stomach", promptHint: "with the navel visible", term: "visible navel" },
744
+ { id: "feature-bare-shoulders", label: "Bare Shoulders", dimension: "distinctive-features", description: "Bare shoulders exposed", promptHint: "with bare shoulders exposed, the line of the collarbone and shoulder muscles uncovered" , adultOnly: true },
745
+ { id: "feature-collarbone-visible", label: "Collarbone Visible", dimension: "distinctive-features", description: "Prominent collarbone catching light", promptHint: "with a prominent collarbone clearly defined and catching the light", term: "visible collarbone" , adultOnly: true },
746
+ { id: "feature-midriff-visible", label: "Midriff Visible", dimension: "distinctive-features", description: "Exposed midriff between top and bottom", promptHint: "wearing a cropped style with the midriff visible", term: "visible midriff" , adultOnly: true },
747
+ { id: "feature-navel-visible", label: "Navel Visible", dimension: "distinctive-features", description: "Visible navel on bare stomach", promptHint: "with the navel visible", term: "visible navel" , adultOnly: true },
732
748
  { id: "feature-elongated-neck", label: "Elongated Neck", dimension: "distinctive-features", description: "Long swan-like neck", promptHint: "with an elongated, swan-like neck, long and gracefully extended", term: "elongated swan-like neck" },
733
749
  { id: "feature-under-eye-circles", label: "Under-Eye Circles", dimension: "distinctive-features", description: "Subtle dark circles under the eyes (distinct from puffy eye-bags)", promptHint: "with subtle dark circles under the eyes", term: "dark under-eye circles" },
734
750
  { id: "feature-fangs", label: "Fangs", dimension: "distinctive-features", description: "Visible fangs (vampire / character archetype)", promptHint: "with visible fangs at the canines, character / vampire archetype", term: "visible fangs at the canines" },
@@ -809,10 +825,14 @@ export const PEOPLE: ReadonlyArray<Person> = [
809
825
  { id: "south-india-traditional", label: "South India Traditional", group: "Asia", dimension: "regional-aesthetic", description: "Tamil / Kerala temple-town classical aesthetic", promptHint: "a South Indian traditional aesthetic — Tamil / Kerala temple-town vibe, classical refinement", term: "south indian traditional aesthetic" },
810
826
  { id: "bangkok-street", label: "Bangkok Street", group: "Asia", dimension: "regional-aesthetic", description: "Bangkok Thai night-market neon-urban aesthetic", promptHint: "a Bangkok street aesthetic — Thai night-market energy, neon-and-warmth urban vibe", term: "bangkok street aesthetic" },
811
827
 
828
+ // ----- Central Asia -----
829
+ { id: "samarkand-silk-road", label: "Samarkand Silk Road", group: "Central Asia", dimension: "regional-aesthetic", description: "Uzbek Silk Road bazaar aesthetic (Samarkand / Bukhara)", promptHint: "a Central Asian Silk Road aesthetic — Samarkand / Bukhara bazaar vibe, ikat-and-suzani textiles, sun-baked adobe and blue-tiled madrasa mood", term: "samarkand silk road aesthetic" },
830
+ { id: "tashkent-modern", label: "Tashkent Modern", group: "Central Asia", dimension: "regional-aesthetic", description: "Contemporary Uzbek metropolitan Central Asian aesthetic", promptHint: "a modern Tashkent aesthetic — contemporary Uzbek metropolitan vibe, Soviet-modern-meets-Silk-Road blend, warm steppe-city confidence", term: "tashkent modern aesthetic" },
831
+
812
832
  // ----- Latin America -----
813
833
  { id: "carioca-rio", label: "Carioca (Rio)", group: "Latin America", dimension: "regional-aesthetic", description: "Rio de Janeiro beach-and-favela-music Brazilian aesthetic", promptHint: "a Carioca aesthetic — Rio de Janeiro beach-and-favela-music vibe, sun-warmed Brazilian energy", term: "carioca aesthetic" },
814
834
  { id: "paulista", label: "Paulista (São Paulo)", group: "Latin America", dimension: "regional-aesthetic", description: "São Paulo metropolitan Brazilian creative-class aesthetic", promptHint: "a Paulista aesthetic — São Paulo metropolitan vibe, urban-Brazilian creative-class polish", term: "paulista aesthetic" },
815
- { id: "buenos-aires-tango", label: "Buenos Aires Tango", group: "Latin America", dimension: "regional-aesthetic", description: "Buenos Aires Recoleta / San Telmo tango aesthetic (feminine)", promptHint: "a Buenos Aires tango aesthetic — Recoleta / San Telmo vibe, sultry porteña confidence", term: "buenos aires tango aesthetic" },
835
+ { id: "buenos-aires-tango", label: "Buenos Aires Tango", group: "Latin America", dimension: "regional-aesthetic", description: "Buenos Aires Recoleta / San Telmo tango aesthetic (feminine)", promptHint: "a Buenos Aires tango aesthetic — Recoleta / San Telmo vibe, sultry porteña confidence", term: "buenos aires tango aesthetic" , adultOnly: true },
816
836
  { id: "porteño", label: "Porteño", group: "Latin America", dimension: "regional-aesthetic", description: "Buenos Aires refined milonguero aesthetic (masculine)", promptHint: "a Porteño aesthetic — Buenos Aires vibe, refined milonguero presence", term: "porteño aesthetic" },
817
837
  { id: "mexico-city-chic", label: "Mexico City Chic", group: "Latin America", dimension: "regional-aesthetic", description: "CDMX Roma / Polanco modern-Mexican aesthetic", promptHint: "a Mexico City chic aesthetic — CDMX Roma / Polanco vibe, modern-Mexican polish", term: "mexico city chic aesthetic" },
818
838
  { id: "chilango-traditional", label: "Chilango Traditional", group: "Latin America", dimension: "regional-aesthetic", description: "Mexico City warm folk-and-feast aesthetic", promptHint: "a Chilango traditional aesthetic — Mexico City vibe, warm folk-and-feast mood", term: "chilango traditional aesthetic" },
@@ -1318,7 +1338,11 @@ function emitIndependentFragments(value: unknown, fragmentFor: PersonFragmentFor
1318
1338
  const navel = ids.includes("feature-navel-visible")
1319
1339
  for (const id of ids) {
1320
1340
  if (midriff && navel && (id === "feature-midriff-visible" || id === "feature-navel-visible")) {
1321
- if (id === "feature-midriff-visible") out.push("wearing a cropped style, midriff and navel visible")
1341
+ // The fold pushes a literal string without consulting `fragmentFor` — a
1342
+ // floored `fragmentFor` (see `emit` in collectPersonFragments) returns
1343
+ // "" for a flagged id, so gate the fold on that instead of hardcoding
1344
+ // the minor check here.
1345
+ if (id === "feature-midriff-visible" && fragmentFor(id) !== "") out.push("wearing a cropped style, midriff and navel visible")
1322
1346
  continue
1323
1347
  }
1324
1348
  const fragment = fragmentFor(id)
@@ -1344,6 +1368,19 @@ function collectPersonFragments(
1344
1368
  const pre = typeof data.preText === "string" ? data.preText.trim() : ""
1345
1369
  if (pre) hints.push(pre)
1346
1370
 
1371
+ // W1-a minor-age floor (Layer 1): a minor subject never emits an adultOnly
1372
+ // entry — drop, never soften. Adults are untouched (the golden fixture pins
1373
+ // that). The drop is checked on the ENTRY the collector itself resolves for
1374
+ // that id (`getPerson`, the same pack-composed lookup `fragmentFor` already
1375
+ // reads through) — not a separately-sourced id set — so pack-registered and
1376
+ // built-in entries are both covered by construction. `fragmentFor` is
1377
+ // wrapped so every branch below (independent fragments, the midriff+navel
1378
+ // fold, custom text) sees the same rule.
1379
+ const floored = isMinorAge(data)
1380
+ const emit: PersonFragmentFor = floored
1381
+ ? (id) => (typeof id === "string" && getPerson(id)?.adultOnly === true ? "" : fragmentFor(id))
1382
+ : fragmentFor
1383
+
1347
1384
  const fieldByDimension = getRegisteredPersonFieldByDimension()
1348
1385
  for (const dimension of getRegisteredPersonDimensionOrder()) {
1349
1386
  const field = fieldByDimension[dimension]
@@ -1352,22 +1389,22 @@ function collectPersonFragments(
1352
1389
  if (dimension === "age") {
1353
1390
  const ageId = typeof raw === "string" ? raw : undefined
1354
1391
  const customAge = typeof data.customAge === "number" ? data.customAge : undefined
1355
- const h = buildAgeFragment(ageId, customAge, fragmentFor)
1392
+ const h = buildAgeFragment(ageId, customAge, emit)
1356
1393
  if (h) hints.push(h)
1357
1394
  continue
1358
1395
  }
1359
1396
  if (dimension === "ethnicity") {
1360
- const h = buildEthnicityFragment(raw, fragmentFor)
1397
+ const h = buildEthnicityFragment(raw, emit)
1361
1398
  if (h) hints.push(h)
1362
1399
  continue
1363
1400
  }
1364
1401
  if (dimension === "hair-color") {
1365
- const h = buildHairColorFragment(raw, fragmentFor)
1402
+ const h = buildHairColorFragment(raw, emit)
1366
1403
  if (h) hints.push(h)
1367
1404
  continue
1368
1405
  }
1369
1406
  if (dimension === "eye-color") {
1370
- for (const h of buildEyeColorFragments(raw, fragmentFor)) hints.push(h)
1407
+ for (const h of buildEyeColorFragments(raw, emit)) hints.push(h)
1371
1408
  continue
1372
1409
  }
1373
1410
  if (
@@ -1377,11 +1414,11 @@ function collectPersonFragments(
1377
1414
  dimension === "skin-texture" ||
1378
1415
  dimension === "regional-aesthetic"
1379
1416
  ) {
1380
- for (const h of emitIndependentFragments(raw, fragmentFor)) hints.push(h)
1417
+ for (const h of emitIndependentFragments(raw, emit)) hints.push(h)
1381
1418
  continue
1382
1419
  }
1383
1420
  if (typeof raw !== "string" || raw.length === 0) continue
1384
- const hint = fragmentFor(raw)
1421
+ const hint = emit(raw)
1385
1422
  if (hint) hints.push(hint)
1386
1423
  }
1387
1424
 
@@ -1396,7 +1433,7 @@ function collectPersonFragments(
1396
1433
  !data.lipFullness &&
1397
1434
  !data.lipShape
1398
1435
  ) {
1399
- const legacyLips = fragmentFor(data.lips)
1436
+ const legacyLips = emit(data.lips)
1400
1437
  if (legacyLips) hints.push(legacyLips)
1401
1438
  }
1402
1439
 
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { resolveTerm } from "./term.js"
31
+ import { overlayEntry } from "./catalog-overlay.js"
31
32
 
32
33
  export type PhotoGenreCategory =
33
34
  | "editorial"
@@ -44,6 +45,16 @@ export interface PhotoGenre {
44
45
  readonly category: PhotoGenreCategory
45
46
  readonly description: string
46
47
  readonly promptHint: string
48
+ /**
49
+ * W1-a minor-age floor (spec 2026-09-01 §3.3). `true` marks an entry whose
50
+ * hint describes body exposure, sheer/wet clothing, swimwear/lingerie, a
51
+ * seductive expression or gaze, or a body-placed tattoo. The fragment
52
+ * collectors DROP flagged entries for a minor subject (`isMinorAge`), the
53
+ * picker hides their tiles, the analyzer never emits them, and the backend
54
+ * policy strips their wording from free text. Hand-curated; the
55
+ * `adult-only-ratchet` test only ratchets. Never set on neutral defaults.
56
+ */
57
+ readonly adultOnly?: true
47
58
  /**
48
59
  * Compact professional term (see `term.ts`). Authored only where the
49
60
  * lowercased label is not what a photographer would write in a prompt —
@@ -70,7 +81,7 @@ export const PHOTO_GENRES: ReadonlyArray<PhotoGenre> = [
70
81
  { id: "brand-jil-sander", label: "Jil Sander Minimalism", category: "editorial", description: "Jil Sander — minimalist architectural muted", promptHint: "shot in the language of a Jil Sander editorial, minimalist composition, muted ecru and stone palette, architectural wardrobe with clean unbroken lines and the brand's quiet austere mood" },
71
82
  { id: "brand-vivienne-tam", label: "Vivienne Tam Style", category: "editorial", description: "Vivienne Tam — orientalist ornate fashion", promptHint: "shot in the language of a Vivienne Tam editorial, orientalist motifs and dragon embroidery, ornate East-Asian fashion detailing and a saturated jewel-toned palette" },
72
83
  { id: "brand-jacquemus", label: "Jacquemus Style", category: "editorial", description: "Jacquemus — sun-soaked surrealist playful", promptHint: "shot in the language of a Jacquemus campaign, sun-soaked Mediterranean light, surrealist scale play with oversized hats and miniature bags, terracotta and ochre palette and a playful French Riviera mood" },
73
- { id: "brand-helmut-newton", label: "Helmut Newton Style", category: "editorial", description: "Helmut Newton — high-contrast B&W provocation", promptHint: "shot in the signature language of Helmut Newton, high-contrast black-and-white, hard direct flash carving every line, statuesque pose, glamorous wardrobe and the cool confrontational eroticism of his 1970s editorial work" },
84
+ { id: "brand-helmut-newton", label: "Helmut Newton Style", category: "editorial", description: "Helmut Newton — high-contrast B&W provocation", promptHint: "shot in the signature language of Helmut Newton, high-contrast black-and-white, hard direct flash carving every line, statuesque pose, glamorous wardrobe and the cool confrontational eroticism of his 1970s editorial work" , adultOnly: true },
74
85
  { id: "brand-harpers-bazaar", label: "Harper's Bazaar Style", category: "editorial", description: "Harper's Bazaar — high-fashion glossy", promptHint: "shot in the signature language of a Harper's Bazaar editorial, high-fashion glossy production, bold graphic backdrops, dramatic single-source key light and the brand's distinctively confident composition" },
75
86
 
76
87
  // -------------------- Documentary / Candid --------------------
@@ -89,7 +100,7 @@ export const PHOTO_GENRES: ReadonlyArray<PhotoGenre> = [
89
100
  { id: "mugshot", label: "Mugshot", category: "studio-formal", description: "Police booking-style portrait", promptHint: "shot as a police booking mugshot, harsh fluorescent overhead lighting, height chart on the wall behind, dead-square frontal framing and a flat institutional color cast" },
90
101
  { id: "wedding-portrait", label: "Wedding Portrait", category: "studio-formal", description: "Romantic bridal-style portrait", promptHint: "shot as a wedding portrait, soft golden-hour backlight, romantic shallow depth of field, dreamy pastel color grade and an intimate posed embrace or solo bridal stance" },
91
102
  { id: "family-portrait", label: "Family Portrait", category: "studio-formal", description: "Posed family group shot", promptHint: "shot as a posed family portrait, warm even studio lighting, neutral backdrop, coordinated wardrobe and the group arranged in a balanced pyramid composition" },
92
- { id: "glamour-portrait", label: "Glamour Portrait", category: "studio-formal", description: "Soft-focus glamour portrait", promptHint: "shot as a glamour portrait with soft diffused beauty lighting, a high-key warm tone, gentle skin retouch, voluminous hair and a sultry over-the-shoulder pose" },
103
+ { id: "glamour-portrait", label: "Glamour Portrait", category: "studio-formal", description: "Soft-focus glamour portrait", promptHint: "shot as a glamour portrait with soft diffused beauty lighting, a high-key warm tone, gentle skin retouch, voluminous hair and a sultry over-the-shoulder pose" , adultOnly: true },
93
104
  { id: "film-noir", label: "Film Noir", category: "studio-formal", description: "Hard-shadow noir portrait", promptHint: "shot in a film-noir portrait idiom, hard single-source key light casting deep shadows, venetian-blind shadow patterns, monochrome high-contrast palette and a brooding closed-off pose" },
94
105
 
95
106
  // -------------------- Selfie sub-types --------------------
@@ -124,7 +135,7 @@ const photoGenreById = new Map<string, PhotoGenre>(
124
135
 
125
136
  export function getPhotoGenre(id: string | undefined | null): PhotoGenre | undefined {
126
137
  if (!id) return undefined
127
- return photoGenreById.get(id)
138
+ return overlayEntry("photo-genre", id, photoGenreById.get(id))
128
139
  }
129
140
 
130
141
  export function getPhotoGenreLabel(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 PhotographerCategory =
25
26
  | "editorial"
@@ -642,7 +643,7 @@ const photographerById = new Map<string, Photographer>(
642
643
 
643
644
  export function getPhotographer(id: string | undefined | null): Photographer | undefined {
644
645
  if (!id) return undefined
645
- return photographerById.get(id)
646
+ return overlayEntry("photographer", id, photographerById.get(id))
646
647
  }
647
648
 
648
649
  export function getPhotographerLabel(id: string | undefined | null, fallback?: string): string {