@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,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|cropped hemline|slightly open|mid-sentence/i)
25
+ // eye-state-staring-camera and texture-baby-soft are NOT flagged — they stay.
26
+ expect(joined).toContain("looking directly into the camera")
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(/close-fitting|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(/close-fitting/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
+ })
@@ -18,7 +18,7 @@ describe("buildPersonHints — body proportion axes", () => {
18
18
  silhouette: "silhouette-hourglass",
19
19
  })
20
20
  expect(hints.join(", ")).toBe(
21
- "slim, lean build, full bust, defined waist, hourglass silhouette",
21
+ "slim, lean build, full bust, a defined waistline, an hourglass figure",
22
22
  )
23
23
  })
24
24
 
@@ -56,7 +56,7 @@ describe("buildPersonHints — body proportion axes", () => {
56
56
  })
57
57
 
58
58
  it("Bust:Very Full emits the distinct very-full token", () => {
59
- expect(buildPersonHints({ bust: "bust-very-full" })).toEqual(["very full bust"])
59
+ expect(buildPersonHints({ bust: "bust-very-full" })).toEqual(["a fuller bust"])
60
60
  })
61
61
 
62
62
  it("ignores stored values for the retired Build / Body Proportions keys (no migration)", () => {
@@ -14,15 +14,15 @@ describe("exposure hint collapsing", () => {
14
14
  const hints = buildPersonHints({
15
15
  distinctiveFeature: ["feature-midriff-visible", "feature-navel-visible"],
16
16
  } as never)
17
- const exposure = hints.filter((h) => /midriff|navel|stomach/i.test(h))
18
- expect(exposure).toEqual(["wearing a cropped style, midriff and navel visible"])
17
+ const exposure = hints.filter((h) => /cropped hemline|navel|stomach/i.test(h))
18
+ expect(exposure).toEqual(["with a cropped hemline and the navel showing"])
19
19
  })
20
20
 
21
21
  it("each alone keeps its own (softened, garment-language) hint", () => {
22
22
  const midriffOnly = buildPersonHints({
23
23
  distinctiveFeature: ["feature-midriff-visible"],
24
24
  } as never)
25
- expect(midriffOnly).toContain("wearing a cropped style with the midriff visible")
25
+ expect(midriffOnly).toContain("with a cropped hemline")
26
26
  expect(midriffOnly.join(" ")).not.toMatch(/bare stomach/i)
27
27
 
28
28
  const navelOnly = buildPersonHints({
@@ -40,7 +40,7 @@ describe("exposure hint collapsing", () => {
40
40
  "feature-freckles",
41
41
  ],
42
42
  } as never)
43
- expect(hints.filter((h) => /midriff/i.test(h))).toHaveLength(1)
43
+ expect(hints.filter((h) => /cropped hemline/i.test(h))).toHaveLength(1)
44
44
  expect(hints.length).toBeGreaterThan(1)
45
45
  })
46
46
  })
@@ -59,3 +59,58 @@ describe("bold-lips cross-catalog dedupe", () => {
59
59
  expect(hints.join(" ")).toMatch(/bold lips/i)
60
60
  })
61
61
  })
62
+
63
+ describe("cropped-clause de-stack across the person↔styling boundary (W1-b)", () => {
64
+ it("the midriff person clause suppresses BOTH styling cropped twins on the shared bag", () => {
65
+ const hints = buildStylingHints({
66
+ distinctiveFeature: ["feature-midriff-visible"],
67
+ top: "top-crop-top",
68
+ wardrobeState: ["state-cropped"],
69
+ } as never)
70
+ const joined = hints.join(", ")
71
+ expect(joined).not.toMatch(/cropped top|cropped above the midriff/i)
72
+ })
73
+
74
+ it("without the person clause, the garment wins and the modifier yields", () => {
75
+ const hints = buildStylingHints({
76
+ top: "top-crop-top",
77
+ wardrobeState: ["state-cropped"],
78
+ } as never)
79
+ const joined = hints.join(", ")
80
+ expect(joined).toMatch(/wearing a cropped top that ends above the midriff/i)
81
+ expect(joined).not.toMatch(/the top cropped above the midriff with the stomach visible/i)
82
+ })
83
+
84
+ it("a MINOR who picks midriff + crop-top still gets exactly one cropped clause", () => {
85
+ // The person clause is dropped by the floor (feature-midriff-visible is
86
+ // adultOnly), so the styling twin must NOT be suppressed — otherwise the
87
+ // floor silently deletes a garment detail instead of a body detail.
88
+ const hints = buildStylingHints({
89
+ age: "age-child",
90
+ distinctiveFeature: ["feature-midriff-visible"],
91
+ top: "top-crop-top",
92
+ } as never)
93
+ expect(hints.join(", ")).toMatch(/wearing a cropped top that ends above the midriff/i)
94
+ })
95
+
96
+ it("either styling id alone is untouched (no person clause, no twin)", () => {
97
+ expect(buildStylingHints({ wardrobeState: ["state-cropped"] } as never).join(", "))
98
+ .toMatch(/the top cropped above the midriff/i)
99
+ expect(buildStylingHints({ top: "top-crop-top" } as never).join(", "))
100
+ .toMatch(/wearing a cropped top/i)
101
+ })
102
+
103
+ it("de-stacks a BARE-STRING wardrobeState too, not just the array form", () => {
104
+ // `wardrobeState` is a single-pick-or-multi field (string | string[]), and
105
+ // `normalizeSubjectFields` unwraps a lone array pick to a bare string
106
+ // before the styling collector ever sees it — the single-pick case is the
107
+ // common one, not an edge case, so the suppression must not be array-only.
108
+ const hints = buildStylingHints({
109
+ distinctiveFeature: "feature-midriff-visible",
110
+ top: "top-crop-top",
111
+ wardrobeState: "state-cropped",
112
+ } as never)
113
+ const joined = hints.join(", ")
114
+ expect(joined).not.toMatch(/cropped top|cropped above the midriff/i)
115
+ })
116
+ })
@@ -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
+ })
@@ -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
+ })
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { getAdultOnlyHintStrings, RETIRED_ADULT_ONLY_HINT_STRINGS } from "../age-floor.js"
3
+
4
+ /**
5
+ * W1-b (spec 2026-09-01 §3.3) rewords 17 catalog hints that W1-a (PR 2) had
6
+ * flagged `adultOnly`. The backend `minor-age-floor` policy strips sentences
7
+ * by STRING, so a rewording silently narrows the floor for every consumer
8
+ * still on the pre-rephrase `@nodaro/prompts` — including person.nodaro.ai,
9
+ * whose client-assembled `seedPrompt` is precisely how the 2026-07-30 P0
10
+ * prompts carried `state-fitted`'s clause to the provider.
11
+ *
12
+ * The retired strings therefore stay in the strip set forever. A retired
13
+ * string can only over-strip, and only for a subject already judged a minor.
14
+ */
15
+ describe("retired adult-only wording stays inside the floor", () => {
16
+ it("every retired string is still returned by getAdultOnlyHintStrings()", () => {
17
+ const live = new Set(getAdultOnlyHintStrings())
18
+ const missing = RETIRED_ADULT_ONLY_HINT_STRINGS.filter((s) => !live.has(s.toLowerCase()))
19
+ expect(missing, "retired strings dropped out of the floor").toEqual([])
20
+ })
21
+
22
+ it("the 2026-07-30 incident clause is in the set by name", () => {
23
+ expect(getAdultOnlyHintStrings()).toContain(
24
+ "the clothing fitted and form-conscious, hugging the contours of the body",
25
+ )
26
+ expect(getAdultOnlyHintStrings()).toContain("wearing a cropped style, midriff and navel visible")
27
+ expect(getAdultOnlyHintStrings()).toContain("with lips slightly parted, taking a soft breath")
28
+ })
29
+
30
+ it("the NEW wording is a live needle too — the floor tracks the rephrase forward", () => {
31
+ // The derivation makes this true by construction, but nothing else states
32
+ // it: a future edit that drops a hint under the `length >= 8` filter, or
33
+ // an entry quietly losing `adultOnly`, would narrow the floor silently.
34
+ // (Green only after Task 5; see that task's Step 7 run set.)
35
+ const strings = getAdultOnlyHintStrings()
36
+ expect(strings).toContain("tailored, close-fitting clothing")
37
+ expect(strings).toContain("with a cropped hemline")
38
+ })
39
+
40
+ it("no bare derived TERM leaked into the needle set (the promptHint-only contract)", () => {
41
+ const strings = getAdultOnlyHintStrings()
42
+ for (const t of ["lounging", "cropped top", "school uniform", "lying down", "wet clothing", "bare shoulders"]) {
43
+ expect(strings, `term "${t}" must never be a needle`).not.toContain(t)
44
+ }
45
+ })
46
+
47
+ it("every retired string is lower-case and long enough to survive the length filter", () => {
48
+ for (const s of RETIRED_ADULT_ONLY_HINT_STRINGS) {
49
+ expect(s, s).toBe(s.toLowerCase())
50
+ expect(s.length, s).toBeGreaterThanOrEqual(8)
51
+ }
52
+ })
53
+
54
+ it("the set covers all 15 flagged rephrased entries plus the fold literal", () => {
55
+ // 15 flagged promptHints (the 14 flagged person/styling entries plus the
56
+ // pose twin) + the hard-coded fold literal = 16. `promptHint`s only —
57
+ // never terms. A change to the count must be a deliberate edit here, not
58
+ // a silent drift.
59
+ expect(RETIRED_ADULT_ONLY_HINT_STRINGS.length).toBe(16)
60
+ })
61
+
62
+ it("the list is longest-first, matching getAdultOnlyHintStrings' contract", () => {
63
+ const strings = getAdultOnlyHintStrings()
64
+ for (let i = 1; i < strings.length; i++) {
65
+ expect(strings[i - 1].length).toBeGreaterThanOrEqual(strings[i].length)
66
+ }
67
+ })
68
+ })
@@ -218,7 +218,7 @@ describe("renderSubjectHints — the flat-bag behaviors", () => {
218
218
  { distinctiveFeature: ["feature-midriff-visible", "feature-navel-visible"] },
219
219
  IMAGE,
220
220
  )
221
- expect(out).toEqual(["wearing a cropped style, midriff and navel visible"])
221
+ expect(out).toEqual(["with a cropped hemline and the navel showing"])
222
222
  })
223
223
 
224
224
  it("de-duplicates an exact repeated clause, first occurrence winning", () => {
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { PEOPLE } from "../person.js"
3
+ import { STYLINGS } from "../styling.js"
4
+ import { POSES } from "../pose.js"
5
+ import { PICKER_CATALOGS } from "../picker-catalogs.js"
6
+ import { RETIRED_ADULT_ONLY_HINT_STRINGS } from "../age-floor.js"
7
+
8
+ /**
9
+ * W1-b — spec 2026-09-01-app-reports-triage-design.md §3.3, wording approved
10
+ * by §10 decisions 1 and 6.
11
+ *
12
+ * 12 adult `generate-character` prompts were refused by the provider's safety
13
+ * filter because stacked picker fragments read as sexualized. The principle:
14
+ * describe the garment or the look, not exposed skin; drop
15
+ * "wet / sheen / exposed / bare / hugging / soft breath"; keep the attribute
16
+ * renderable.
17
+ *
18
+ * SCOPE RULING, LATER RESOLVED (replay-harness verdict, internal validation
19
+ * results, 2026-09-02): the go/no-go criterion initially failed for TWO
20
+ * entries — `eye-state-half-lidded` and `feature-collarbone-visible`
21
+ * rendered their attribute 0/10 and 0/22 times under either wording, so
22
+ * their approved rewording could not be validated at the time and both
23
+ * shipped byte-identical in the original W1-b PR. A follow-up replay on
24
+ * staging found wordings that DO render, approved 2026-09-03. Both are now
25
+ * included in PERSON_EXPECTED below with BOTH `promptHint` and `term`
26
+ * reworded, like every other entry — the compact-mode term is not left
27
+ * carrying the old wording.
28
+ *
29
+ * This file pins BOTH fields of every reworded entry — `promptHint` (verbose
30
+ * mode) and `term` (compact mode) — because a rephrase that touched only one
31
+ * of them would leave the old wording reachable through the other. The last
32
+ * test is the ratchet: no retired string may reappear anywhere in the
33
+ * catalog corpus.
34
+ */
35
+
36
+ interface Expected {
37
+ readonly id: string
38
+ readonly promptHint: string
39
+ readonly term: string
40
+ }
41
+
42
+ const PERSON_EXPECTED: ReadonlyArray<Expected> = [
43
+ { id: "bust-very-full", promptHint: "a fuller bust", term: "fuller bust" },
44
+ { id: "silhouette-hourglass", promptHint: "an hourglass figure", term: "hourglass figure" },
45
+ { id: "waist-defined", promptHint: "a defined waistline", term: "defined waistline" },
46
+ { id: "lip-state-glossy", promptHint: "with a glossy lip finish", term: "glossy lips" },
47
+ { id: "lip-state-parted", promptHint: "with lips relaxed and slightly open, as if mid-sentence", term: "lips slightly open" },
48
+ { id: "lip-state-bitten", promptHint: "lightly biting the lower lip", term: "biting lower lip" },
49
+ { id: "eye-state-staring-camera", promptHint: "looking directly into the camera", term: "direct gaze to camera" },
50
+ { id: "eye-state-half-lidded", promptHint: "with drowsy, partly closed eyes, the lids sitting low over the iris", term: "drowsy, partly closed eyes" },
51
+ { id: "texture-dewy", promptHint: "with dewy, luminous skin", term: "dewy skin" },
52
+ { id: "texture-glistening", promptHint: "with a light glistening sheen on the skin", term: "glistening sheen" },
53
+ { id: "texture-baby-soft", promptHint: "with soft, fine-pored skin", term: "soft fine-pored skin" },
54
+ { id: "texture-shower-fresh-wet", promptHint: "with fresh, water-dappled skin as if just out of the shower", term: "water-dappled skin" },
55
+ { id: "feature-bare-shoulders", promptHint: "with the shoulders uncovered", term: "shoulders uncovered" },
56
+ { id: "feature-collarbone-visible", promptHint: "with an open neckline that leaves the collarbones uncovered", term: "open neckline, collarbones uncovered" },
57
+ { id: "feature-midriff-visible", promptHint: "with a cropped hemline", term: "cropped hemline" },
58
+ ]
59
+
60
+ const STYLING_EXPECTED: ReadonlyArray<Expected> = [
61
+ { id: "state-fitted", promptHint: "tailored, close-fitting clothing", term: "close-fitting clothing" },
62
+ { id: "state-wet", promptHint: "the clothing soaked through and dripping", term: "soaked clothing" },
63
+ ]
64
+
65
+ const POSE_EXPECTED: ReadonlyArray<Expected> = [
66
+ { id: "biting-lip", promptHint: "lightly biting the lower lip, a subtle playful expression", term: "biting lower lip" },
67
+ ]
68
+
69
+ function check(catalog: ReadonlyArray<{ id: string; promptHint: string; term?: string }>, expected: ReadonlyArray<Expected>) {
70
+ for (const want of expected) {
71
+ const entry = catalog.find((e) => e.id === want.id)
72
+ expect(entry, `missing entry ${want.id}`).toBeDefined()
73
+ expect(entry!.promptHint, `${want.id}.promptHint`).toBe(want.promptHint)
74
+ expect(entry!.term, `${want.id}.term (authored, not label-derived)`).toBe(want.term)
75
+ }
76
+ }
77
+
78
+ describe("W1-b rephrase — the 17 reworded entries plus the pose twin", () => {
79
+ it("person entries carry the approved promptHint and term", () => {
80
+ check(PEOPLE, PERSON_EXPECTED)
81
+ })
82
+
83
+ it("eye-state-half-lidded and feature-collarbone-visible keep adultOnly through the rework", () => {
84
+ const halfLidded = PEOPLE.find((e) => e.id === "eye-state-half-lidded")
85
+ expect(halfLidded?.adultOnly).toBe(true)
86
+
87
+ const collarbone = PEOPLE.find((e) => e.id === "feature-collarbone-visible")
88
+ expect(collarbone?.adultOnly).toBe(true)
89
+ })
90
+
91
+ it("styling entries carry the approved promptHint and term", () => {
92
+ check(STYLINGS, STYLING_EXPECTED)
93
+ })
94
+
95
+ it("the pose twin of lip-state-bitten is reworded to match", () => {
96
+ check(POSES, POSE_EXPECTED)
97
+ })
98
+
99
+ it("all 18 ids are covered — the count is deliberate, not incidental", () => {
100
+ expect(PERSON_EXPECTED.length + STYLING_EXPECTED.length + POSE_EXPECTED.length).toBe(18)
101
+ })
102
+
103
+ it("RATCHET: no retired string survives anywhere in the catalog corpus", () => {
104
+ const offenders: string[] = []
105
+ for (const cat of PICKER_CATALOGS) {
106
+ const options = [...(cat.options ?? []), ...(cat.dimensions ?? []).flatMap((d) => d.options)]
107
+ for (const o of options) {
108
+ for (const retired of RETIRED_ADULT_ONLY_HINT_STRINGS) {
109
+ // The fold literal is not an entry field; it is pinned separately in
110
+ // person-exposure-hints.test.ts.
111
+ if (o.promptHint === retired || o.term === retired) {
112
+ offenders.push(`${cat.catalogId} • ${o.id} • "${retired}"`)
113
+ }
114
+ }
115
+ }
116
+ }
117
+ expect(offenders, `retired wording is still live:\n${offenders.join("\n")}`).toEqual([])
118
+ })
119
+ })
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 {