@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.
- package/dist/index.cjs +64 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -2
- package/dist/index.d.ts +29 -2
- package/dist/index.js +64 -21
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/doctrine-roster-completeness.test.ts +1 -0
- package/src/__tests__/fixtures/parameter-hint-golden.json +313 -35
- package/src/__tests__/minor-age-floor.test.ts +4 -4
- package/src/__tests__/person-body-axes.test.ts +2 -2
- package/src/__tests__/person-exposure-hints.test.ts +59 -4
- package/src/__tests__/retired-hint-strings.test.ts +68 -0
- package/src/__tests__/subject-registry.test.ts +1 -1
- package/src/__tests__/w1b-rephrase.test.ts +119 -0
- package/src/age-floor.ts +65 -2
- package/src/person.ts +18 -17
- package/src/pose.ts +1 -1
- package/src/styling.ts +38 -2
|
@@ -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) => /
|
|
18
|
-
expect(exposure).toEqual(["
|
|
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("
|
|
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) => /
|
|
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(["
|
|
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: "
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
560
|
-
{ id: "lip-state-parted", label: "Parted", dimension: "lip-state", description: "Lips slightly parted, breath of air", promptHint: "with lips slightly
|
|
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
|
|
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: "
|
|
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,
|
|
701
|
-
{ id: "texture-glistening", label: "Glistening", dimension: "skin-texture", description: "Sweat or oil sheen", promptHint: "with
|
|
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
|
|
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
|
|
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
|
|
745
|
-
{ id: "feature-collarbone-visible", label: "Collarbone Visible", dimension: "distinctive-features", description: "Prominent collarbone catching light", promptHint: "with
|
|
746
|
-
{ id: "feature-midriff-visible", label: "Midriff Visible", dimension: "distinctive-features", description: "Exposed midriff between top and bottom", promptHint: "
|
|
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("
|
|
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
|
|
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: "
|
|
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
|
|
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
|
}
|