@nodaro/prompts 1.15.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.
@@ -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,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/age-floor.ts CHANGED
@@ -65,10 +65,73 @@ export function getAdultOnlyIds(): ReadonlySet<string> {
65
65
  return new Set(getAdultOnlyEntries().map((e) => e.id))
66
66
  }
67
67
 
68
+ /**
69
+ * The pre-rework `promptHint` of every `adultOnly` entry the spec
70
+ * (2026-09-01-app-reports-triage-design §3.3) listed for rewording — including
71
+ * `eye-state-half-lidded` and `feature-collarbone-visible`, which the W1-b
72
+ * harness initially left byte-identical (0/10 and 0/22 render rate under
73
+ * either wording) and a 2026-09-03 replay then found renderable wordings for
74
+ * — plus the hard-coded midriff+navel fold clause. These strings are permanently part of the strip
75
+ * set: a consumer that has not bumped `@nodaro/prompts` still emits them, and
76
+ * the client-assembled `seedPrompt` path is exactly how the 2026-07-30
77
+ * minor-age prompts reached a provider. Retiring can only ever cause an EXTRA
78
+ * strip, and only for a subject `isMinorAge` has already judged a minor — so
79
+ * the list only grows, never shrinks.
80
+ *
81
+ * `promptHint` ONLY, for the same reason `getAdultOnlyHintStrings` gives:
82
+ * `term` is short display vocabulary and the composed-catalog projection
83
+ * back-fills a derived `term` for every option, so terms collide with benign
84
+ * everyday text. Assembled prompts and client seedPrompts are built from
85
+ * hints, never from terms. The three W1-b entries that are NOT flagged —
86
+ * `texture-dewy`, `texture-baby-soft`, `eye-state-staring-camera` (spec
87
+ * §3.3's "deliberately not flagged" list) — are absent: their old wording
88
+ * never belonged to the floor.
89
+ *
90
+ * 15 flagged hints + the fold literal = 16.
91
+ */
92
+ export const RETIRED_ADULT_ONLY_HINT_STRINGS: ReadonlyArray<string> = [
93
+ // bust-very-full
94
+ "very full bust",
95
+ // silhouette-hourglass
96
+ "hourglass silhouette",
97
+ // waist-defined
98
+ "defined waist",
99
+ // lip-state-glossy
100
+ "with high-shine glossy wet-look lips",
101
+ // lip-state-parted
102
+ "with lips slightly parted, taking a soft breath",
103
+ // lip-state-bitten
104
+ "playfully biting the lower lip",
105
+ // eye-state-half-lidded
106
+ "with heavy half-lidded sleepy eyes",
107
+ // texture-glistening
108
+ "with glistening skin, sweat or oil catching the light",
109
+ // texture-shower-fresh-wet
110
+ "with just-out-of-the-shower wet skin, water beading on the surface and rolling in slow droplets down the curves of the body",
111
+ // feature-bare-shoulders
112
+ "with bare shoulders exposed, the line of the collarbone and shoulder muscles uncovered",
113
+ // feature-collarbone-visible
114
+ "with a prominent collarbone clearly defined and catching the light",
115
+ // feature-midriff-visible
116
+ "wearing a cropped style with the midriff visible",
117
+ // state-fitted — the 2026-07-30 incident clause
118
+ "the clothing fitted and form-conscious, hugging the contours of the body",
119
+ // state-wet
120
+ "the clothing soaked and wet, the fabric clinging to the body and dripping water",
121
+ // pose `biting-lip` (the lip-state-bitten twin)
122
+ "biting the lower lip with a subtle playful expression",
123
+ // the hard-coded midriff+navel fold in emitIndependentFragments — not any
124
+ // entry's hint, so retiring it ADDS a needle for a clause that really is
125
+ // emitted verbatim.
126
+ "wearing a cropped style, midriff and navel visible",
127
+ ]
128
+
68
129
  /** Every full prompt-hint string a flagged entry can inject, lower-cased,
69
130
  * longest first — the backend policy strips text that contains any of them
70
131
  * (Layer 2), which is how flagged wording arriving inside free text (a
71
- * client-assembled seedPrompt) is caught.
132
+ * client-assembled seedPrompt) is caught. Seeded with
133
+ * `RETIRED_ADULT_ONLY_HINT_STRINGS` so a catalog rewording can never narrow
134
+ * the strip set for a consumer still shipping the pre-rephrase wording.
72
135
  *
73
136
  * `promptHint` ONLY, deliberately — `term` is analyzer/UI vocabulary
74
137
  * (compact-mode display), and `picker-catalogs.ts`'s composed-catalog
@@ -81,7 +144,7 @@ export function getAdultOnlyIds(): ReadonlySet<string> {
81
144
  * can actually arrive verbatim in free text — terms don't need to be (and
82
145
  * must not be) swept here. */
83
146
  export function getAdultOnlyHintStrings(): ReadonlyArray<string> {
84
- const out = new Set<string>()
147
+ const out = new Set<string>(RETIRED_ADULT_ONLY_HINT_STRINGS)
85
148
  for (const e of getAdultOnlyEntries()) {
86
149
  if (e.promptHint) out.add(e.promptHint.toLowerCase())
87
150
  }
package/src/person.ts CHANGED
@@ -437,10 +437,10 @@ export const PEOPLE: ReadonlyArray<Person> = [
437
437
  { id: "bust-small", label: "Small", dimension: "bust", description: "Small bust", promptHint: "small bust", term: "small bust" , adultOnly: true },
438
438
  { id: "bust-average", label: "Average", dimension: "bust", description: "Average bust", promptHint: "" },
439
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 },
440
+ { id: "bust-very-full", label: "Very Full", dimension: "bust", description: "Very full bust", promptHint: "a fuller bust", term: "fuller bust" , adultOnly: true },
441
441
 
442
442
  // ----- Waist -----
443
- { id: "waist-defined", label: "Defined", dimension: "waist", description: "Defined, cinched waist", promptHint: "defined waist", term: "defined waist" , adultOnly: true },
443
+ { id: "waist-defined", label: "Defined", dimension: "waist", description: "Defined, cinched waist", promptHint: "a defined waistline", term: "defined waistline" , adultOnly: true },
444
444
  { id: "waist-average", label: "Average", dimension: "waist", description: "Average waist", promptHint: "" },
445
445
  { id: "waist-straight", label: "Straight", dimension: "waist", description: "Straight, undefined waist", promptHint: "straight waistline", term: "straight waistline" , adultOnly: true },
446
446
 
@@ -450,7 +450,7 @@ export const PEOPLE: ReadonlyArray<Person> = [
450
450
  { id: "hips-wide", label: "Wide", dimension: "hips", description: "Wide hips", promptHint: "wide hips", term: "wide hips" , adultOnly: true },
451
451
 
452
452
  // ----- Silhouette (overall body shape; optional, no neutral) -----
453
- { id: "silhouette-hourglass", label: "Hourglass", dimension: "silhouette", description: "Balanced bust + hips, defined waist", promptHint: "hourglass silhouette", term: "hourglass silhouette" , adultOnly: true },
453
+ { id: "silhouette-hourglass", label: "Hourglass", dimension: "silhouette", description: "Balanced bust + hips, defined waist", promptHint: "an hourglass figure", term: "hourglass figure" , adultOnly: true },
454
454
  { id: "silhouette-rectangular", label: "Rectangular", dimension: "silhouette", description: "Straight up-and-down silhouette", promptHint: "rectangular silhouette", term: "rectangular silhouette" },
455
455
  { id: "silhouette-pear", label: "Pear", dimension: "silhouette", description: "Hips wider than shoulders", promptHint: "pear-shaped silhouette", term: "pear-shaped silhouette" , adultOnly: true },
456
456
  { id: "silhouette-inverted", label: "Inverted Triangle", dimension: "silhouette", description: "Broad shoulders, narrow hips", promptHint: "inverted-triangle silhouette", term: "inverted-triangle silhouette" },
@@ -553,11 +553,11 @@ export const PEOPLE: ReadonlyArray<Person> = [
553
553
 
554
554
  // -------------------- Lip State (what the lips are doing / wearing) --------------------
555
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" },
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 },
556
+ { id: "lip-state-glossy", label: "Glossy", dimension: "lip-state", description: "High-shine, wet-look lips", promptHint: "with a glossy lip finish", term: "glossy lips" , adultOnly: true },
557
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" },
558
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" },
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 },
559
+ { id: "lip-state-bitten", label: "Bitten", dimension: "lip-state", description: "Slight playful lip-bite, lower lip caught", promptHint: "lightly biting the lower lip", term: "biting lower lip" , adultOnly: true },
560
+ { id: "lip-state-parted", label: "Parted", dimension: "lip-state", description: "Lips slightly parted, breath of air", promptHint: "with lips relaxed and slightly open, as if mid-sentence", term: "lips slightly open" , adultOnly: true },
561
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" },
562
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" },
563
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" },
@@ -676,9 +676,9 @@ export const PEOPLE: ReadonlyArray<Person> = [
676
676
 
677
677
  // -------------------- Eye State (what the eyes are doing / where they look) --------------------
678
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" },
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 },
679
+ { id: "eye-state-half-lidded", label: "Half-lidded", dimension: "eye-state", description: "Heavy-lidded sleepy gaze", promptHint: "with drowsy, partly closed eyes, the lids sitting low over the iris", term: "drowsy, partly closed eyes" , adultOnly: true },
680
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" },
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" },
681
+ { id: "eye-state-staring-camera", label: "Staring at Camera", dimension: "eye-state", description: "Direct unbroken eye contact with the lens", promptHint: "looking directly into the camera", term: "direct gaze to camera" },
682
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" },
683
683
  { id: "eye-state-gazing-up", label: "Gazing Up", dimension: "eye-state", description: "Eyes raised toward something above", promptHint: "with eyes gazing upward toward something above" },
684
684
  { id: "eye-state-gazing-down", label: "Gazing Down", dimension: "eye-state", description: "Eyes downcast", promptHint: "with eyes downcast, gazing softly downward" },
@@ -697,8 +697,8 @@ export const PEOPLE: ReadonlyArray<Person> = [
697
697
  { id: "texture-smooth", label: "Smooth", dimension: "skin-texture", description: "Flawless, silky smooth skin", promptHint: "with flawless, silky smooth skin", term: "flawless smooth skin" },
698
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" },
699
699
  { id: "texture-goosebumps", label: "Goosebumps", dimension: "skin-texture", description: "Raised goosebumps on skin", promptHint: "with goosebumps raised on the skin" },
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" },
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 },
700
+ { id: "texture-dewy", label: "Dewy", dimension: "skin-texture", description: "Glowing, dewy fresh skin", promptHint: "with dewy, luminous skin", term: "dewy skin" },
701
+ { id: "texture-glistening", label: "Glistening", dimension: "skin-texture", description: "Sweat or oil sheen", promptHint: "with a light glistening sheen on the skin", term: "glistening sheen" , adultOnly: true },
702
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" },
703
703
  { id: "texture-porcelain", label: "Porcelain", dimension: "skin-texture", description: "Flawless near-translucent porcelain", promptHint: "with flawless, near-translucent porcelain skin", term: "porcelain skin" },
704
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" },
@@ -710,8 +710,8 @@ export const PEOPLE: ReadonlyArray<Person> = [
710
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 },
711
711
  { id: "texture-matte", label: "Matte", dimension: "skin-texture", description: "Poreless matte finish", promptHint: "with a poreless, matte skin finish", term: "matte skin finish" },
712
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" },
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" },
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 },
713
+ { id: "texture-baby-soft", label: "Baby-soft", dimension: "skin-texture", description: "Smooth, fine-pored youthful skin", promptHint: "with soft, fine-pored skin", term: "soft fine-pored 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 fresh, water-dappled skin as if just out of the shower", term: "water-dappled skin" , adultOnly: true },
715
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" },
716
716
 
717
717
  // -------------------- Distinctive Features --------------------
@@ -741,9 +741,9 @@ export const PEOPLE: ReadonlyArray<Person> = [
741
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" },
742
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" },
743
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" },
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 },
744
+ { id: "feature-bare-shoulders", label: "Bare Shoulders", dimension: "distinctive-features", description: "Bare shoulders exposed", promptHint: "with the shoulders uncovered", term: "shoulders uncovered" , adultOnly: true },
745
+ { id: "feature-collarbone-visible", label: "Collarbone Visible", dimension: "distinctive-features", description: "Prominent collarbone catching light", promptHint: "with an open neckline that leaves the collarbones uncovered", term: "open neckline, collarbones uncovered" , adultOnly: true },
746
+ { id: "feature-midriff-visible", label: "Midriff Visible", dimension: "distinctive-features", description: "Exposed midriff between top and bottom", promptHint: "with a cropped hemline", term: "cropped hemline" , adultOnly: true },
747
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 },
748
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" },
749
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" },
@@ -1333,7 +1333,8 @@ function emitIndependentFragments(value: unknown, fragmentFor: PersonFragmentFor
1333
1333
  // "midriff visible" + "navel visible" as separate clauses reads twice as
1334
1334
  // exposed as it is, and stacked with glamour hints it tips borderline draws
1335
1335
  // into content blocks (observed on kie-standard, 2026-07-18 app_reports).
1336
- // When both are picked, fold them into ONE neutral clause.
1336
+ // When both are picked, fold them into ONE neutral clause
1337
+ // ("with a cropped hemline and the navel showing").
1337
1338
  const midriff = ids.includes("feature-midriff-visible")
1338
1339
  const navel = ids.includes("feature-navel-visible")
1339
1340
  for (const id of ids) {
@@ -1342,7 +1343,7 @@ function emitIndependentFragments(value: unknown, fragmentFor: PersonFragmentFor
1342
1343
  // floored `fragmentFor` (see `emit` in collectPersonFragments) returns
1343
1344
  // "" for a flagged id, so gate the fold on that instead of hardcoding
1344
1345
  // the minor check here.
1345
- if (id === "feature-midriff-visible" && fragmentFor(id) !== "") out.push("wearing a cropped style, midriff and navel visible")
1346
+ if (id === "feature-midriff-visible" && fragmentFor(id) !== "") out.push("with a cropped hemline and the navel showing")
1346
1347
  continue
1347
1348
  }
1348
1349
  const fragment = fragmentFor(id)
package/src/pose.ts CHANGED
@@ -105,7 +105,7 @@ export const POSES: ReadonlyArray<Pose> = [
105
105
  { id: "throwing", label: "Throwing", category: "action", description: "Mid-throw motion", promptHint: "caught mid-throw, body coiled and releasing", term: "caught mid-throw" },
106
106
  { id: "leaping", label: "Leaping", category: "action", description: "Leaping forward dynamically", promptHint: "leaping forward dynamically with body extended" },
107
107
  { id: "dramatic-action", label: "Dramatic Action", category: "action", description: "Exaggerated action pose", promptHint: "in a dramatic, exaggerated action pose full of motion", term: "dramatic exaggerated action pose" },
108
- { id: "biting-lip", label: "Biting Lip", category: "action", description: "Slight playful lip-bite", promptHint: "biting the lower lip with a subtle playful expression", term: "biting the lower lip" , adultOnly: true },
108
+ { id: "biting-lip", label: "Biting Lip", category: "action", description: "Slight playful lip-bite", promptHint: "lightly biting the lower lip, a subtle playful expression", term: "biting lower lip" , adultOnly: true },
109
109
  { id: "mid-laugh", label: "Mid-Laugh", category: "action", description: "Caught mid-laugh, head back", promptHint: "caught mid-laugh with head tipped back, eyes crinkled" },
110
110
  { id: "pointing-at-camera", label: "Pointing at Camera", category: "action", description: "Pointing directly at camera", promptHint: "pointing one finger directly at the camera, arm extended", term: "pointing directly at the camera" },
111
111
  { id: "tongue-out", label: "Sticking Tongue Out", category: "action", description: "Playful tongue-out expression", promptHint: "sticking the tongue out playfully" },
package/src/styling.ts CHANGED
@@ -424,10 +424,10 @@ export const STYLINGS: ReadonlyArray<Styling> = [
424
424
 
425
425
  // -------------------- Wardrobe State (modifier — composes with any garment) --------------------
426
426
  { id: "state-oversized", label: "Oversized", dimension: "wardrobe-state", description: "Loose, oversized fit", promptHint: "the clothing loose and oversized, draping freely well past the body", term: "oversized fit" },
427
- { id: "state-fitted", label: "Fitted", dimension: "wardrobe-state", description: "Form-fitting silhouette", promptHint: "the clothing fitted and form-conscious, hugging the contours of the body", term: "form-fitting clothing" , adultOnly: true },
427
+ { id: "state-fitted", label: "Fitted", dimension: "wardrobe-state", description: "Form-fitting silhouette", promptHint: "tailored, close-fitting clothing", term: "close-fitting clothing" , adultOnly: true },
428
428
  { id: "state-cropped", label: "Cropped", dimension: "wardrobe-state", description: "Top cropped at midriff", promptHint: "the top cropped above the midriff with the stomach visible", term: "cropped top" , adultOnly: true },
429
429
  { id: "state-sheer", label: "Sheer", dimension: "wardrobe-state", description: "Translucent fabric", promptHint: "the fabric translucent and sheer, hinting at the silhouette underneath", term: "sheer clothing" , adultOnly: true },
430
- { id: "state-wet", label: "Wet", dimension: "wardrobe-state", description: "Soaked, water-clinging", promptHint: "the clothing soaked and wet, the fabric clinging to the body and dripping water", term: "wet clothing" , adultOnly: true },
430
+ { id: "state-wet", label: "Wet", dimension: "wardrobe-state", description: "Soaked, water-clinging", promptHint: "the clothing soaked through and dripping", term: "soaked clothing" , adultOnly: true },
431
431
  { id: "state-ripped", label: "Ripped", dimension: "wardrobe-state", description: "Torn and frayed", promptHint: "the fabric torn and ripped at the seams, with frayed edges", term: "ripped clothing" },
432
432
  { id: "state-distressed", label: "Distressed", dimension: "wardrobe-state", description: "Worn, faded, weathered", promptHint: "the clothing distressed and weathered, with faded color and worn-down edges", term: "distressed clothing" },
433
433
  { id: "state-vintage", label: "Vintage", dimension: "wardrobe-state", description: "Worn, retro character", promptHint: "the clothing carrying vintage character — softened color, broken-in fit, retro silhouette", term: "vintage clothing" },
@@ -651,6 +651,40 @@ function collectStylingFragments(
651
651
  const floored = isMinorAge(data as { age?: string; customAge?: number; type?: string })
652
652
  const emit = floored ? (id: string) => (getStyling(id)?.adultOnly === true ? "" : fragmentFor(id)) : fragmentFor
653
653
 
654
+ // W1-b (spec 2026-09-01 §3.3): three ids say "cropped" — the Person
655
+ // catalog's `feature-midriff-visible` and this catalog's `top-crop-top` and
656
+ // `state-cropped`. Stacked they read as three separate statements about a
657
+ // bare stomach, which is one of the compounds the safety classifiers flag.
658
+ // Same mechanism and same caveat as the bold-lips dedupe above: the subject
659
+ // fold hands BOTH builders one flat bag, so the person pick is readable
660
+ // here; a separate styling node carries no `distinctiveFeature` and this
661
+ // no-ops. Person leads the fold order, so the person clause wins; with no
662
+ // person clause the garment wins over the modifier.
663
+ const featureRaw = (data as Record<string, unknown>).distinctiveFeature
664
+ const midriffPicked = Array.isArray(featureRaw)
665
+ ? featureRaw.includes("feature-midriff-visible")
666
+ : featureRaw === "feature-midriff-visible"
667
+ // Gate on pick-minus-floor (`midriffPicked && !floored`), which equals EMISSION
668
+ // unless an overlay pack drops the entry — the same rule the midriff+navel fold
669
+ // uses (`fragmentFor(id) !== ""` inside `emitIndependentFragments`).
670
+ // `feature-midriff-visible` is `adultOnly`, so for a minor the person
671
+ // collector drops it; suppressing the styling twins as well would leave a
672
+ // minor who picked midriff AND crop-top with NO cropped clause at all.
673
+ // `floored` is the local computed just above from the same shared bag, so
674
+ // the two collectors cannot disagree.
675
+ const midriffAlready = midriffPicked && !floored
676
+ const cropTopPicked = (data as Record<string, unknown>).top === "top-crop-top"
677
+ // `top` is single-pick (always a bare string), but `wardrobeState` is
678
+ // declared `string | ReadonlyArray<string>` AND the subject-fold door
679
+ // (`normalizeSubjectFields`) unwraps a lone array pick down to a bare
680
+ // string — so a single "state-cropped" selection, the common case, arrives
681
+ // here as a plain string, not an array. The suppression must therefore be
682
+ // checked at BOTH emission sites below (the `typeof raw === "string"`
683
+ // branch and the `Array.isArray(raw)` branch), not just the array one.
684
+ const croppedSuppressed = (id: string): boolean =>
685
+ (id === "top-crop-top" && midriffAlready) ||
686
+ (id === "state-cropped" && (midriffAlready || cropTopPicked))
687
+
654
688
  for (const dimension of STYLING_DIMENSION_ORDER) {
655
689
  const field = STYLING_FIELD_BY_DIMENSION[dimension]
656
690
  const raw = data[field]
@@ -658,11 +692,13 @@ function collectStylingFragments(
658
692
  // jewelry / wardrobe-state / hair-state are multi-pick (string | string[]);
659
693
  // emit each id's fragment independently and let the comma-join compose.
660
694
  if (typeof raw === "string" && raw.length > 0) {
695
+ if (croppedSuppressed(raw)) continue
661
696
  const fragment = emit(raw)
662
697
  if (fragment) out.push(fragment)
663
698
  } else if (Array.isArray(raw)) {
664
699
  for (const item of raw) {
665
700
  if (typeof item !== "string" || item.length === 0) continue
701
+ if (croppedSuppressed(item)) continue
666
702
  const fragment = emit(item)
667
703
  if (fragment) out.push(fragment)
668
704
  }