@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
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The resolver-side half of catalog curation: a pack that removes or rewrites
3
+ * an entry must change what the id→entry GETTERS return, because those are
4
+ * what turns a picker id into prompt text at run time. Before this overlay
5
+ * existed, a deployment's curation was visible in /v1/catalogs and invisible
6
+ * in every generated prompt.
7
+ *
8
+ * The totality test at the bottom is the one that keeps this fixed: every
9
+ * catalog with a getter must honor a deny pack, so a new catalog cannot ship
10
+ * with a getter that reads its base constant straight.
11
+ */
12
+ import { afterEach, describe, expect, it } from "vitest"
13
+ import {
14
+ registerCatalogPack,
15
+ resetCatalogPacks,
16
+ getPickerCatalog,
17
+ PICKER_CATALOGS,
18
+ getSetting,
19
+ getSettingPromptHint,
20
+ getPose,
21
+ getPosePromptHint,
22
+ getParameterPromptHint,
23
+ renderSubjectHints,
24
+ SETTINGS,
25
+ POSES,
26
+ } from "../index.js"
27
+ import { ANIMALS } from "@nodaro/shared"
28
+
29
+ afterEach(() => resetCatalogPacks())
30
+
31
+ describe("overlayEntry — the four outcomes", () => {
32
+ const settingId = SETTINGS[0].id
33
+
34
+ it("no packs: the base entry, by identity (mainline byte-identical)", () => {
35
+ expect(getSetting(settingId)).toBe(SETTINGS[0])
36
+ })
37
+
38
+ it("deny pack: the id resolves to NOTHING on the prompt path", () => {
39
+ registerCatalogPack({ id: "t-deny", catalogId: "setting", mode: "deny", denyIds: [settingId] })
40
+ expect(getSetting(settingId)).toBeUndefined()
41
+ expect(getSettingPromptHint(settingId)).toBe("")
42
+ // A sibling the pack did not touch still resolves.
43
+ expect(getSetting(SETTINGS[1].id)).toBeDefined()
44
+ })
45
+
46
+ it("replace pack with a REWRITE: the composed wording reaches the getter", () => {
47
+ const base = getPickerCatalog("setting")!
48
+ const rewritten = {
49
+ ...base,
50
+ options: base.options!.map((o) =>
51
+ o.id === settingId ? { ...o, label: "Curated Label", promptHint: "curated prompt text" } : o,
52
+ ),
53
+ }
54
+ registerCatalogPack({ id: "t-replace", catalogId: "setting", mode: "replace", catalog: rewritten })
55
+ const s = getSetting(settingId)!
56
+ expect(s.label).toBe("Curated Label")
57
+ expect(s.promptHint).toBe("curated prompt text")
58
+ expect(getSettingPromptHint(settingId)).toBe("curated prompt text")
59
+ // Fields the composed option does not carry survive from the base entry.
60
+ expect(s.id).toBe(settingId)
61
+ })
62
+
63
+ it("replace pack that OMITS an entry: the omitted id resolves to nothing", () => {
64
+ const base = getPickerCatalog("setting")!
65
+ registerCatalogPack({
66
+ id: "t-replace-omit",
67
+ catalogId: "setting",
68
+ mode: "replace",
69
+ catalog: { ...base, options: base.options!.filter((o) => o.id !== settingId) },
70
+ })
71
+ expect(getSetting(settingId)).toBeUndefined()
72
+ })
73
+
74
+ it("extend pack: a pack-added id resolves to the composed option", () => {
75
+ registerCatalogPack({
76
+ id: "t-extend",
77
+ catalogId: "setting",
78
+ mode: "extend",
79
+ options: [{ id: "curated-only", label: "Curated Only", promptHint: "only in the pack", term: "curated" }],
80
+ })
81
+ expect(getSetting("curated-only")?.promptHint).toBe("only in the pack")
82
+ })
83
+ })
84
+
85
+ describe("the dispatcher honors the overlay end to end", () => {
86
+ it("a denied pose sub-pick contributes nothing to the hint", () => {
87
+ const poseId = POSES[0].id
88
+ const before = getParameterPromptHint({ id: "n", type: "pose", data: { pose: poseId } } as never)
89
+ expect(before).not.toBe("")
90
+ registerCatalogPack({ id: "t-pose", catalogId: "pose", mode: "deny", denyIds: [poseId] })
91
+ expect(getPose(poseId)).toBeUndefined()
92
+ expect(getPosePromptHint(poseId)).toBe("")
93
+ expect(getParameterPromptHint({ id: "n", type: "pose", data: { pose: poseId } } as never)).toBe("")
94
+ })
95
+
96
+ it("the @nodaro/shared-owned catalogs are overlaid at the dispatcher", () => {
97
+ const animalId = ANIMALS[0].id
98
+ const before = getParameterPromptHint({ id: "n", type: "animal", data: { animal: animalId } } as never)
99
+ expect(before).not.toBe("")
100
+ registerCatalogPack({ id: "t-animal", catalogId: "animals", mode: "deny", denyIds: [animalId] })
101
+ expect(getParameterPromptHint({ id: "n", type: "animal", data: { animal: animalId } } as never)).toBe("")
102
+ })
103
+ })
104
+
105
+ describe("TOTALITY — every catalog honors a deny pack ON THE PROMPT PATH", () => {
106
+ // Mechanical, not enumerated: walk every catalog, deny its first id, and
107
+ // assert the dispatcher produces NOTHING for a node carrying that id — the
108
+ // same call the orchestrator makes. A getter that still reads its base
109
+ // constant straight fails here whether or not anyone remembered to list it.
110
+ // The field the id lives in comes from the catalog itself (single-dim
111
+ // valueField / multi-dim dimension field), so a catalog cannot slip past by
112
+ // storing its value under a name this test did not know.
113
+ const cases = PICKER_CATALOGS.map((c) => {
114
+ const dim = c.dimensions?.[0]
115
+ const field = c.valueField ?? dim?.field
116
+ // The first REAL entry: a catalog's "auto"/"none" no-op (term "") injects
117
+ // nothing by design, which would make the pre-deny assertion vacuous.
118
+ const opt = (c.options ?? dim?.options ?? []).find((o) => o.term !== "")
119
+ return { nodeType: c.nodeType, catalogId: c.catalogId, field: field!, id: opt?.id ?? "" }
120
+ })
121
+
122
+ it.each(cases)("$nodeType", ({ nodeType, catalogId, field, id }) => {
123
+ expect(id, `${catalogId}: catalog has no options to deny`).not.toBe("")
124
+ // `instruments` is the one field whose node value is always an array.
125
+ const node = { id: "n", type: nodeType, data: { [field]: field === "instruments" ? [id] : id } } as never
126
+ const before = getParameterPromptHint(node)
127
+ expect(before, `${nodeType}: the stock id must produce SOME hint for the test to mean anything`).not.toBe("")
128
+ registerCatalogPack({ id: `tot-${catalogId}`, catalogId, mode: "deny", denyIds: [id] })
129
+ expect(getParameterPromptHint(node), `${nodeType}: denied "${id}" still reaches the prompt`).toBe("")
130
+ })
131
+
132
+ it("a REWRITE reaches the prompt text end to end, for a prompts-owned and a shared-owned catalog", () => {
133
+ for (const [nodeType, field] of [["setting", "setting"], ["animal", "animal"]] as const) {
134
+ resetCatalogPacks()
135
+ const cat = getPickerCatalog(nodeType)!
136
+ const id = cat.options![0].id
137
+ const stock = getParameterPromptHint({ id: "n", type: nodeType, data: { [field]: id } } as never)
138
+ registerCatalogPack({
139
+ id: `rw-${nodeType}`,
140
+ catalogId: cat.catalogId,
141
+ mode: "replace",
142
+ catalog: { ...cat, options: cat.options!.map((o) => (o.id === id ? { ...o, promptHint: "CURATED WORDING" } : o)) },
143
+ })
144
+ const curated = getParameterPromptHint({ id: "n", type: nodeType, data: { [field]: id } } as never)
145
+ expect(curated, nodeType).toContain("CURATED WORDING")
146
+ expect(curated, nodeType).not.toBe(stock)
147
+ }
148
+ })
149
+
150
+ it("the same animal id renders identically through the parameter node and the subject fold", () => {
151
+ const id = getPickerCatalog("animals")!.options![0].id
152
+ registerCatalogPack({
153
+ id: "rw-animal-subject",
154
+ catalogId: "animals",
155
+ mode: "replace",
156
+ catalog: { ...getPickerCatalog("animals")!, options: getPickerCatalog("animals")!.options!.map((o) => (o.id === id ? { ...o, promptHint: "SUBJECT WORDING" } : o)) },
157
+ })
158
+ const viaNode = getParameterPromptHint({ id: "n", type: "animal", data: { animal: id } } as never)
159
+ expect(viaNode).toContain("SUBJECT WORDING")
160
+ const viaSubject = renderSubjectHints({ animal: id }, "image")
161
+ expect(JSON.stringify(viaSubject)).toContain("SUBJECT WORDING")
162
+ })
163
+ })
@@ -39,3 +39,20 @@ describe("DoD — adopt a vendored replacement pack by registration", () => {
39
39
  expect(JSON.stringify(PICKER_CATALOGS)).toBe(upstreamSnapshot)
40
40
  })
41
41
  })
42
+
43
+ describe("adultOnly survives packs (W1-a)", () => {
44
+ it("a replace pack keeps the flag on its options; an extend pack adds a flagged option", () => {
45
+ const upstreamMood = PICKER_CATALOGS.find((c) => c.catalogId === "mood")!
46
+ const vendored: PickerCatalog = {
47
+ ...upstreamMood,
48
+ options: [
49
+ { id: "calm", label: "Calm", promptHint: "with a calm mood", term: "calm", category: upstreamMood.options![0].category },
50
+ { id: "sultry", label: "Sultry", promptHint: "with a sultry expression", term: "sultry", category: upstreamMood.options![0].category, adultOnly: true },
51
+ ],
52
+ }
53
+ registerCatalogPack({ id: "sai/mood", catalogId: "mood", mode: "replace", catalog: vendored })
54
+ const out = getRegisteredPickerCatalogs().find((c) => c.catalogId === "mood")!.options!
55
+ expect(out.find((o) => o.id === "sultry")!.adultOnly).toBe(true)
56
+ expect(out.find((o) => o.id === "calm")!.adultOnly).toBeUndefined()
57
+ })
58
+ })
@@ -1703,7 +1703,7 @@
1703
1703
  }
1704
1704
  },
1705
1705
  "ctx": null,
1706
- "expected": "a man, a baby under 1 year old, of Asian descent, a California beach aesthetic — laid-back beach-town vibe, golden-hour sun-warmed energy, easy carefree mood, petite, lean build, small bust, defined waist, narrow hips, hourglass silhouette, an oval face shape, a strong, sharply defined jawline, almond-shaped eyes, a straight nose, thin lips, with chapped, cracked, weather-worn dry lips, a shaved bald head, platinum blonde hair, very pale and icy, natural eyebrows, very fair pale skin, with flawless, silky smooth skin, brown eyes, with eyes fully closed in a peaceful expression, clean-shaven, wearing glasses"
1706
+ "expected": "a man, a baby under 1 year old, of Asian descent, a California beach aesthetic — laid-back beach-town vibe, golden-hour sun-warmed energy, easy carefree mood, petite, lean build, an oval face shape, a strong, sharply defined jawline, almond-shaped eyes, a straight nose, thin lips, with chapped, cracked, weather-worn dry lips, a shaved bald head, platinum blonde hair, very pale and icy, natural eyebrows, very fair pale skin, with flawless, silky smooth skin, brown eyes, with eyes fully closed in a peaceful expression, clean-shaven, wearing glasses"
1707
1707
  },
1708
1708
  {
1709
1709
  "key": "person/last-of-each",
@@ -1748,7 +1748,7 @@
1748
1748
  }
1749
1749
  },
1750
1750
  "ctx": null,
1751
- "expected": "the Grim Reaper — tall hooded skeletal figure in a tattered black robe, bony hands gripping a long wooden scythe, face hidden in shadow, of mixed heritage, an Auckland coastal aesthetic — New Zealand harbor-city vibe, considered Pacific polish, broad-framed, heavy-set build, very full bust, straight waistline, wide hips, athletic silhouette, a triangular face shape with a narrow forehead and wide jaw, a visible double chin, wide, prominent cheekbones, a round face with soft, rounded cheeks, narrow, elongated eyes, deep-set eyes, downturned eyes with the outer corners angled down, negative canthal tilt, wide-set eyes spaced widely apart, eyes set well below the brow with generous brow-to-eye distance, doe eyes, a hooked nose with a strongly curved tip, a slightly drooping, downward-pointing nose tip, full lips with a noticeably fuller lower lip, a small petite mouth, with a full pouting expression, wet, slick hair, rainbow hair with streaks of multiple bright dyed colors, a connected unibrow, very dark ebony skin, the deepest skin tone, with visible acne scarring, healed pitted-skin texture and uneven surface from past breakouts, jet-black eyes with a truly black iris, deeper than brown, with glassy, tear-glazed wet eyes, a five-o'clock shadow, daily growth heavier than light stubble, with visible fangs at the canines, character / vampire archetype"
1751
+ "expected": "the Grim Reaper — tall hooded skeletal figure in a tattered black robe, bony hands gripping a long wooden scythe, face hidden in shadow, of mixed heritage, an Auckland coastal aesthetic — New Zealand harbor-city vibe, considered Pacific polish, broad-framed, heavy-set build, athletic silhouette, a triangular face shape with a narrow forehead and wide jaw, a visible double chin, wide, prominent cheekbones, a round face with soft, rounded cheeks, narrow, elongated eyes, deep-set eyes, downturned eyes with the outer corners angled down, negative canthal tilt, wide-set eyes spaced widely apart, eyes set well below the brow with generous brow-to-eye distance, doe eyes, a hooked nose with a strongly curved tip, a slightly drooping, downward-pointing nose tip, full lips with a noticeably fuller lower lip, a small petite mouth, wet, slick hair, rainbow hair with streaks of multiple bright dyed colors, a connected unibrow, very dark ebony skin, the deepest skin tone, with visible acne scarring, healed pitted-skin texture and uneven surface from past breakouts, jet-black eyes with a truly black iris, deeper than brown, with glassy, tear-glazed wet eyes, a five-o'clock shadow, daily growth heavier than light stubble, with visible fangs at the canines, character / vampire archetype"
1752
1752
  },
1753
1753
  {
1754
1754
  "key": "person/mid-of-each",
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildPersonHints } from "../person.js"
3
+ import { buildStylingHints } from "../styling.js"
4
+ import { getAdultOnlyEntries, applyMinorAgeFloorToPickerValues, getAdultOnlyHintStrings, ADULT_SWEPT_CATALOG_IDS, FLOORED_PICKER_KEYS } from "../age-floor.js"
5
+
6
+ /** The seven P0 prompts' shape (2026-07-30): a 5-year-old + fitted clothing +
7
+ * parted lips + baby-soft skin + direct gaze. Assembled from picker values —
8
+ * the stored prompts are never replayed. */
9
+ const CHILD = {
10
+ type: "boy-next-door",
11
+ age: "age-young-child",
12
+ frame: "frame-slim",
13
+ lipState: ["lip-state-parted"],
14
+ eyeState: ["eye-state-staring-camera"],
15
+ skinTexture: ["texture-baby-soft"],
16
+ } as const
17
+
18
+ describe("Layer 1 — the collectors drop adultOnly entries for a minor", () => {
19
+ it("a child with flagged lip/eye/skin picks emits none of their hints, keeps the rest", () => {
20
+ const hints = buildPersonHints({ ...CHILD, bust: "bust-very-full", distinctiveFeature: ["feature-midriff-visible"] } as never)
21
+ const joined = hints.join(", ")
22
+ expect(joined).toContain("a young child around 5 years old")
23
+ expect(joined).toContain("slim")
24
+ expect(joined).not.toMatch(/bust|midriff|lips slightly parted|soft breath/i)
25
+ // eye-state-staring-camera and texture-baby-soft are NOT flagged — they stay.
26
+ expect(joined).toContain("staring directly at the camera with unbroken eye contact")
27
+ })
28
+
29
+ it("compact mode drops the same ids", () => {
30
+ const terms = buildPersonHints({ ...CHILD, bust: "bust-very-full", lipState: ["lip-state-glossy"] } as never, "compact")
31
+ expect(terms.join(", ")).not.toMatch(/bust|glossy/i)
32
+ })
33
+
34
+ it("styling on the flat subject bag drops flagged garments when the bag says minor", () => {
35
+ const hints = buildStylingHints({ age: "age-child", wardrobeState: ["state-fitted"], top: "top-crop-top", outfit: "outfit-bikini" } as never)
36
+ const joined = hints.join(", ")
37
+ expect(joined).not.toMatch(/hugging the contours|bikini/i)
38
+ expect(joined).toMatch(/cropped top/i) // top-crop-top is deliberately NOT flagged
39
+ })
40
+
41
+ it("styling with no age signal on the bag is untouched (separate-node consumers; Layer 2 covers them)", () => {
42
+ const hints = buildStylingHints({ wardrobeState: ["state-fitted"] } as never)
43
+ expect(hints.join(", ")).toMatch(/hugging the contours/i)
44
+ })
45
+
46
+ it("adults are byte-identical to before: every flagged entry still emits for age-30s", () => {
47
+ for (const e of getAdultOnlyEntries().filter((x) => x.id.startsWith("bust-") || x.id.startsWith("lip-state-"))) {
48
+ const field = e.id.startsWith("bust-") ? "bust" : "lipState"
49
+ const value = field === "bust" ? e.id : [e.id]
50
+ const hints = buildPersonHints({ age: "age-30s", [field]: value } as never)
51
+ expect(hints.join(" "), e.id).toContain(e.promptHint)
52
+ }
53
+ })
54
+
55
+ it("a minor-implying type with no age is floored too", () => {
56
+ const hints = buildPersonHints({ type: "alice-wonderland", bust: "bust-full" } as never)
57
+ expect(hints.join(", ")).not.toMatch(/bust/i)
58
+ })
59
+ })
60
+
61
+ describe("applyMinorAgeFloorToPickerValues", () => {
62
+ it("strips flagged ids from person/styling/pose/mood when the person is a minor", () => {
63
+ const out = applyMinorAgeFloorToPickerValues({
64
+ person: { age: "age-early-teen", bust: "bust-full", lipState: ["lip-state-glossy", "lip-state-bold-red"] },
65
+ styling: { top: "top-bra-top", wardrobeState: ["state-fitted", "state-layered"] },
66
+ pose: { pose: "arched-back" },
67
+ mood: { mood: ["sultry", "calm"] },
68
+ })
69
+ expect(out.person).toEqual({ age: "age-early-teen", lipState: ["lip-state-bold-red"] })
70
+ expect(out.styling).toEqual({ wardrobeState: ["state-layered"] })
71
+ expect(out.pose).toEqual({})
72
+ expect(out.mood).toEqual({ mood: ["calm"] })
73
+ })
74
+
75
+ it("is the identity for an adult", () => {
76
+ const values = { person: { age: "age-30s", bust: "bust-full" }, styling: { top: "top-bra-top" } }
77
+ expect(applyMinorAgeFloorToPickerValues(values)).toBe(values)
78
+ })
79
+
80
+ // The final-review gap: `photo-genre` is swept for the flag
81
+ // (ADULT_SWEPT_CATALOG_IDS) but the hand-written strip list omitted it, so a
82
+ // minor kept `glamour-portrait` / `brand-helmut-newton`. FLOORED_PICKER_KEYS
83
+ // is derived from the sweep list now, which is what makes this pass.
84
+ it("strips a flagged photo-genre id from a minor's values", () => {
85
+ // The real analyzer field for this picker is `photoGenre`
86
+ // (picker-analyzer-registry.ts) — `stripIds` drops by VALUE, so the key
87
+ // name is immaterial; both shapes are pinned so neither can regress.
88
+ const out = applyMinorAgeFloorToPickerValues({
89
+ person: { age: "age-child" },
90
+ "photo-genre": { photoGenre: "glamour-portrait" },
91
+ })
92
+ expect(out["photo-genre"]).toEqual({})
93
+
94
+ const coordinatorShape = applyMinorAgeFloorToPickerValues({
95
+ person: { age: "age-child" },
96
+ "photo-genre": { genre: "glamour-portrait" },
97
+ })
98
+ expect(coordinatorShape["photo-genre"]).toEqual({})
99
+ })
100
+
101
+ it("an adult keeps the same photo-genre id (identity)", () => {
102
+ const values = { person: { age: "age-30s" }, "photo-genre": { photoGenre: "glamour-portrait" } }
103
+ expect(applyMinorAgeFloorToPickerValues(values)).toBe(values)
104
+ })
105
+
106
+ // Totality: the strip list can never again be a subset of the sweep list.
107
+ it("every swept catalog is a floored picker key, and person leads the list", () => {
108
+ for (const id of ADULT_SWEPT_CATALOG_IDS) {
109
+ expect(FLOORED_PICKER_KEYS as ReadonlyArray<string>, id).toContain(id)
110
+ }
111
+ expect(FLOORED_PICKER_KEYS[0]).toBe("person")
112
+ expect(FLOORED_PICKER_KEYS).toHaveLength(ADULT_SWEPT_CATALOG_IDS.length + 1)
113
+ })
114
+ })
115
+
116
+ describe("getAdultOnlyHintStrings", () => {
117
+ it("contains the seven-prompt trigger clause and is longest-first", () => {
118
+ const strings = getAdultOnlyHintStrings()
119
+ expect(strings).toContain("the clothing fitted and form-conscious, hugging the contours of the body")
120
+ expect(strings.length).toBeGreaterThan(0)
121
+ for (let i = 1; i < strings.length; i++) expect(strings[i - 1].length).toBeGreaterThanOrEqual(strings[i].length)
122
+ })
123
+
124
+ it("excludes bare derived terms that are absent from every promptHint (fix round 1: promptHint-only)", () => {
125
+ const strings = getAdultOnlyHintStrings()
126
+ // "lounging" (pose id/label, no explicit term — deriveTerm(label) would
127
+ // back-fill it in the composed catalog) and "cropped top" (state-cropped's
128
+ // explicit `term`, distinct from its promptHint "the top cropped above the
129
+ // midriff with the stomach visible") are exactly the short, generic strings
130
+ // that over-strip unrelated benign text when swept as needles.
131
+ expect(strings).not.toContain("lounging")
132
+ expect(strings).not.toContain("cropped top")
133
+ expect(strings).not.toContain("school uniform")
134
+ expect(strings).not.toContain("lying down")
135
+ })
136
+ })
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest"
2
- import { buildMultiPickerAnalyzerSpec, buildPickerAnalyzerSpec, pickerFanoutTargets } from "../index.js"
2
+ import { buildMultiPickerAnalyzerSpec, buildPickerAnalyzerSpec, pickerFanoutTargets, PICKER_TYPES } from "../index.js"
3
3
 
4
4
  describe("buildMultiPickerAnalyzerSpec", () => {
5
5
  it("composes one section per wired picker plus gaps, order-independent", () => {
@@ -23,6 +23,26 @@ describe("buildMultiPickerAnalyzerSpec", () => {
23
23
  expect(withGaps.gaps.missingItems).toHaveLength(1)
24
24
  })
25
25
 
26
+ it("otherPickersLegend names the non-wired pickers by key, excluding the wired ones", () => {
27
+ const { otherPickersLegend } = buildMultiPickerAnalyzerSpec(["person", "styling"])
28
+ expect(otherPickersLegend.length).toBeGreaterThan(0)
29
+ // non-wired pickers are attributable by their exact key
30
+ expect(otherPickersLegend).toContain("- era:")
31
+ expect(otherPickersLegend).toContain("- setting:")
32
+ expect(otherPickersLegend).toContain("- held-prop:")
33
+ // wired pickers must NOT appear as their own bullet lines
34
+ expect(otherPickersLegend).not.toContain("- person:")
35
+ expect(otherPickersLegend).not.toContain("- styling:")
36
+ // discriminated pickers carry their dimension labels; flat ones carry the label
37
+ expect(otherPickersLegend).toContain("Era / Period") // flat `era` label
38
+ expect(otherPickersLegend).toMatch(/- exposure-settings: Exposure Settings — .*Aperture/) // title-cased key + dims
39
+ })
40
+
41
+ it("otherPickersLegend is empty when every picker is wired", () => {
42
+ const { otherPickersLegend } = buildMultiPickerAnalyzerSpec(PICKER_TYPES)
43
+ expect(otherPickersLegend).toBe("")
44
+ })
45
+
26
46
  it("FUZZ: arbitrary gaps content never alters the enum-validated picker sections", () => {
27
47
  const { schema } = buildMultiPickerAnalyzerSpec(["person"])
28
48
  const personSpec = buildPickerAnalyzerSpec("person")
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, beforeEach } from "vitest"
2
+ import { registerPersonPack, resetPersonPacks, getRegisteredPeople } from "../person-packs.js"
3
+ import { registerCatalogPack, resetCatalogPacks } from "../catalog-packs.js"
4
+
5
+ beforeEach(() => {
6
+ resetPersonPacks()
7
+ resetCatalogPacks()
8
+ })
9
+
10
+ describe("person packs carry adultOnly", () => {
11
+ it("an extend person pack entry keeps its flag in getRegisteredPeople()", () => {
12
+ registerPersonPack({
13
+ id: "test/person-extend",
14
+ dimensions: [{ dimension: "vibe", field: "vibe", label: "Vibe" }],
15
+ entries: [
16
+ { id: "vibe-boudoir", label: "Boudoir", dimension: "vibe", description: "Boudoir styling", promptHint: "in a boudoir setting", adultOnly: true },
17
+ { id: "vibe-picnic", label: "Picnic", dimension: "vibe", description: "Picnic styling", promptHint: "at a picnic" },
18
+ ],
19
+ })
20
+ const people = getRegisteredPeople()
21
+ expect(people.find((p) => p.id === "vibe-boudoir")?.adultOnly).toBe(true)
22
+ expect(people.find((p) => p.id === "vibe-picnic")?.adultOnly).toBeUndefined()
23
+ })
24
+
25
+ it("a replace catalog pack for person keeps the flag through personEntriesFromDims", () => {
26
+ registerCatalogPack({
27
+ id: "test/person-replace",
28
+ catalogId: "person",
29
+ mode: "replace",
30
+ catalog: {
31
+ catalogId: "person",
32
+ kind: "multi",
33
+ label: "Person",
34
+ dimensions: [
35
+ {
36
+ field: "bust",
37
+ label: "Bust",
38
+ options: [{ id: "bust-very-full", label: "Very Full", promptHint: "very full bust", adultOnly: true }],
39
+ },
40
+ ],
41
+ } as never,
42
+ })
43
+ expect(getRegisteredPeople().find((p) => p.id === "bust-very-full")?.adultOnly).toBe(true)
44
+ })
45
+ })
@@ -26,7 +26,7 @@ describe("regional-aesthetic dimension — wiring", () => {
26
26
 
27
27
  describe("regional-aesthetic catalog — coverage and structure", () => {
28
28
  it("ships the expected total entry count", () => {
29
- expect(REGIONAL_ENTRIES.length).toBe(86)
29
+ expect(REGIONAL_ENTRIES.length).toBe(88)
30
30
  })
31
31
 
32
32
  it.each([
@@ -34,6 +34,7 @@ describe("regional-aesthetic catalog — coverage and structure", () => {
34
34
  ["USA — African-American", 6],
35
35
  ["Europe", 16],
36
36
  ["Asia", 12],
37
+ ["Central Asia", 2],
37
38
  ["Latin America", 7],
38
39
  ["Middle East", 7],
39
40
  ["North Africa", 3],
@@ -7,6 +7,7 @@ import {
7
7
  import {
8
8
  buildPickerAnalyzerSpec,
9
9
  buildPickerZodSchema,
10
+ PICKER_ANALYZER_REGISTRY,
10
11
  } from "../picker-analyzer-registry.js"
11
12
  // NOTE: the existing test file already imports `buildPickerAnalyzerSpec` from
12
13
  // "../picker-analyzer-registry.js" — do NOT re-import it here (duplicate-identifier
@@ -116,3 +117,16 @@ describe("registry invariants (all analyzable pickers)", () => {
116
117
  expect(buildPickerAnalyzerSpec("lens").dimensions[0].entryIds).toEqual(LENSES.map((l) => l.id))
117
118
  })
118
119
  })
120
+
121
+ describe("personCleanup (W1-a: minor-age floor)", () => {
122
+ it("drops flagged ids from the patch when the patch itself is a minor", () => {
123
+ const patch: Record<string, unknown> = {
124
+ age: "age-child",
125
+ bust: "bust-full",
126
+ lipState: ["lip-state-glossy", "lip-state-bold-red"],
127
+ }
128
+ PICKER_ANALYZER_REGISTRY.person.cleanup?.(patch, "fill-empty")
129
+ expect(patch.bust).toBeUndefined()
130
+ expect(patch.lipState).toEqual(["lip-state-bold-red"])
131
+ })
132
+ })
@@ -66,4 +66,43 @@ describe("PROVIDER_PROMPT_DOCTRINES", () => {
66
66
  expect(tips).toMatch(/2\.0 SKUs ignore timestamps/)
67
67
  expect(tips).toMatch(/seedance-2-5 honours integer-second timestamps/)
68
68
  })
69
+
70
+ it("Wan 3.0 has its OWN doctrine — the 2.x token format must not leak onto it", () => {
71
+ // WAN_DOCTRINE teaches "Image 1" / "Video 1" (capitalised, WITH a space),
72
+ // which is the Wan 2.x format. The Wan 3.0 KIE contract binds Image1 /
73
+ // Video1 / Audio1 (no space). Folding 3.0 into the 2.x entry would ship a
74
+ // token format the model does not use — a prompt-quality regression that
75
+ // costs real generations, so the split is the point of this test.
76
+ const d = getPromptDoctrine("wan-3")!
77
+ expect(d.providers).toEqual(["wan-3", "wan-3-prime"])
78
+ expect(getPromptDoctrine("wan-3-prime")).toBe(d)
79
+ expect(d.doctrine).toContain("Image1")
80
+ expect(d.doctrine).toContain("Audio1")
81
+ // The 2.x spelling may appear ONLY as an explicit contrast, never as the
82
+ // instruction — so the body must say the format has no space.
83
+ expect(d.doctrine).toMatch(/NO space/)
84
+ expect(d.doctrine).toMatch(/Wan 2\.x's "Image 1"/)
85
+
86
+ // The 2.x doctrine must stay the 2.x doctrine.
87
+ const wan2 = getPromptDoctrine("wan-i2v")!
88
+ expect(wan2).not.toBe(d)
89
+ expect(wan2.providers).not.toContain("wan-3")
90
+ expect(wan2.doctrine).toContain("Image 1")
91
+
92
+ // KIE contract facts the body is required to carry.
93
+ expect(d.doctrine).toMatch(/mutually exclusive|CANNOT be combined/i)
94
+ expect(d.doctrine).toMatch(/20,000 characters/)
95
+ expect(d.doctrine).toMatch(/≤ 30 seconds/)
96
+ // Prime is the SPEED tier — never described as higher quality.
97
+ expect(d.doctrine).toMatch(/faster turnaround/i)
98
+ expect(d.doctrine).not.toMatch(/higher quality/i)
99
+ })
100
+
101
+ it("Gemini Omni Flash rides the sibling's doctrine (identical request surface)", () => {
102
+ const d = getPromptDoctrine("gemini-omni-flash")!
103
+ expect(d).toBe(getPromptDoctrine("gemini-omni-video"))
104
+ expect(d.providers).toEqual(["gemini-omni-video", "gemini-omni-flash"])
105
+ expect(d.heading).toContain("gemini-omni-flash")
106
+ expect(d.doctrine).toMatch(/gemini-omni-flash is the faster\/cheaper tier/)
107
+ })
69
108
  })
package/src/action-fx.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { resolveTerm, type PickerHintMode } from "./term.js"
21
+ import { overlayEntry } from "./catalog-overlay.js"
21
22
 
22
23
  export type ActionFxCategory =
23
24
  | "disaster"
@@ -224,7 +225,7 @@ const actionFxById = new Map<string, ActionFx>(ACTION_FX.map((fx) => [fx.id, fx]
224
225
 
225
226
  export function getActionFx(id: string | undefined | null): ActionFx | undefined {
226
227
  if (!id) return undefined
227
- return actionFxById.get(id)
228
+ return overlayEntry("action-fx", id, actionFxById.get(id))
228
229
  }
229
230
 
230
231
  export function getActionFxLabel(id: string | undefined | null, fallback?: string): string {
package/src/aesthetic.ts CHANGED
@@ -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 AestheticCategory = "mainstream" | "niche" | "era" | "mood"
25
26
 
@@ -400,7 +401,7 @@ const aestheticById = new Map<string, Aesthetic>(
400
401
 
401
402
  export function getAesthetic(id: string | undefined | null): Aesthetic | undefined {
402
403
  if (!id) return undefined
403
- return aestheticById.get(id)
404
+ return overlayEntry("aesthetic", id, aestheticById.get(id))
404
405
  }
405
406
 
406
407
  export function getAestheticLabel(id: string | undefined | null, fallback?: string): string {