Tutorial: 2026-06-20

2026-06-20

2026-06-20

Newest entry on top.

SPA Lists panel: folder categories + alphabetical (2.1.0)

Owner: group the SPA "Lists" cloud by folder with a category pill before each folder's entries (categories + entries alphabetical). The pill label is the folder's LAST segment; tooltip is the folder's <folder>.json description. When the folder is an implied group, the pill is itself clickable and inserts the whole-folder group ({word}, {d}, …) — merging the header and the group chip. Categories are inline pills (no line breaks; one wrapping array). Implemented in promptEngine.getBlocks (Lists items now carry {category:true,...} markers; folder-group names removed as separate entries), Home.jsx (category-aware render + filter + count), and .cat-pill CSS. Also removed scene/_force-prefix per owner (scene basenames don't collide, so no prefix needed). npm test + vite build green.

Markers: dotfiles → _-prefixed regular files (drop the Vite plugin) (2.1.0)

Owner's better idea: instead of working around Vite's dotfile-blindness with a plugin, rename the markers from dotfiles to _-prefixed REGULAR files — import.meta.glob sees those fine. Established the convention: any file starting with _ is internal/config, never a list (loaders skip _-prefixed names). Renamed .force-prefix_force-prefix (artist, danbooru/d, lore, name, scene, style) and .disable-group-list_disable-group-list (place). Reverted the list-markers Vite plugin + virtual:list-markers import; browserLoader globs **/_force-prefix etc. again. Gotcha: marker files must be EMPTY (an eager glob parses each matched file as a JS module; an extension-less file with a # comment is a parse error, but an empty file is a valid empty module) — emptied all markers. Loaders skip _-prefixed files in the list walks/globs. Verified: SPA bundle carries the 6 _force-prefix dirs; node forcedPrefixDirs/groupListDirs correct (place excluded); npm test + vite build green.

Fix: force-prefix/group markers invisible in the SPA (Vite skips dotfiles) (2.1.0)

Owner reported force-prefix not working on the UI buttons. Root cause: Vite's import.meta.glob silently SKIPS dotfiles, so the browserLoader's **/.force-prefix (and .enable/.disable-group-list) globs matched NOTHING — forcedPrefixDirs() was [] in the browser (worked in node, masking it). Confirmed: the built bundle had zero marker path strings. Fix: a list-markers Vite plugin in web-app/vite.config.js fs-scans data/lists for the three dotfile markers and exposes them as a virtual:list-markers module (forcePrefixDirs/enableGroupDirs/disableGroupDirs), with dev-server reload on marker add/unlink. browserLoader now imports that instead of globbing dotfiles. Verified the bundle now carries ["artist","danbooru/d","lore","name","scene","style"]; npm test + vite build green. (node loaders were always fine — they fs-walk.)

Implied groups go automatic (2+ files); enable/disable markers (2.1.0)

Owner changed the group-list rule: a folder with 2+ direct list files is automatically an implied group (no marker). Markers become OVERRIDES — .enable-group-list (force on) / .disable-group-list (force off); .force-group-list is retired and all three markers removed (artist/, danbooru/d/, name/). Does NOT stack: only a folder's own direct lists count, never subfolders. New autoGroupListDirs(listNames, enable, disable) in listManifest (counts distinct base lists per dir, variants collapsed); impliedGroupMembers now direct- children-only; all three loaders compute group dirs from .txt-only logical names + the marker overrides. Owner also added .force-prefix to lore/ and name/. Implied groups now: artist, danbooru/d, look, lore, name, nature, place, scene, style, word (brand has 1 list → not a group). The auto-prefixer (computeButtonNames) already disambiguates the group folder-names against same-named lists (e.g. {place} group vs {place/place} list → list shows place/place). Added group-level description JSON for the new implied groups (86 meta files total). Verified via nodeLoader: 10 group dirs, 0 round-trip failures of 88, {word}/{place}/etc. resolve. npm test + vite build green.

JSON list metadata sidecars (descriptions / tooltips) (2.1.0)

Added optional <list>.json sidecars ({ description }) for editor button tooltips (owner's ask). Loaders read them: readListMeta(name) on nodeLoader (fs) + browserLoader (import.meta.glob **/*.json); the SPA's getBlocks attaches item.description (fallback namename-sfw for implicit bases / mixed lists) and Home.jsx shows it in the chip title. Authored 79 concise descriptions for every project list, subset group, and implied group via scripts/list-cleanup/write-list-meta.mjs (re-runnable). JSON files are ignored as lists (loaders only read .txt/.group). npm test + vite build (342 modules) green.

Verify owner's list refactor + .force-group-list implied groups (2.1.0)

Owner manually refactored lists (uncommitted): dropped redundant folder-name suffixes (general-stylegeneral, anime-nameanime, shed-typeshed, artist-digipadigipa, etc.), added .force-prefix to artist/, scene/, style/, and moved the "all artists" group up to a root artist.group. Reviewed it and FIXED the breakage it introduced: 18 dynamic-prompt files still referenced the old tokens ({building-style}{style/building}, {construct-style}{style/construct}, {general-style}{style/general}, {ship-type}{scene/ship}, {vehicle-type}{scene/vehicle}, {store-type}{scene/store}); fixed via a node pass (PowerShell Set-Content silently no-op'd). Updated listTags keys (name/anime-namename/anime, name/given-namename/given).

Then implemented .force-group-list (owner's idea): a folder with that empty marker becomes an implied group ({folder} = union of its lists, mode-aware) — no .group file needed. markedDirs/groupListDirs() in all three loaders; resolveListLines synthesizes members via impliedGroupMembers; loader listNames() registers the dir names. Added markers to artist/, danbooru/d/, name/ and REMOVED the now-redundant artist.group, danbooru/d.group, name/name.group. Kept the genuine SUBSET groups artist/digipa.group, danbooru/d/character.group, danbooru/d/keyword.group. Verified via nodeLoader: groupListDirs= [artist, danbooru/d, name]; {artist} 3243/3294 (nudity gated), {d} 12399/12938, {name} 3544, {digipa} 897, renamed style/scene lists resolve, 0 round-trip failures of 81, buttons correct. npm test + vite build green.

Move d.group up to danbooru/d.group → buttons as {d} not {d/d} (2.1.0)

Owner: d.group doesn't need the forced d/ prefix — move it out of the danbooru/d/ folder up to danbooru/d.group so it auto-names to just {d} instead of {d/d}. git mv + updated the listTags key danbooru/d/ddanbooru/d. Verified: button for danbooru/d = d, {d} resolves to danbooru/d (12,393 off / 12,932 on), 0 round-trip failures; danbooru buttons now d, d/artist, d/character, d/general, …. npm test + vite build green.

Editor button names: filename-only by default + .force-prefix (2.1.0)

Owner: editor list buttons should show just the filename unless there's a conflict or a folder is marked required-prefix via an empty .force-prefix file (manual prefix happens first and is excluded from the auto-prefix table; auto starts at the bare filename and steps out folder-by-folder on conflict; the marker inherits to all descendants). Implemented computeButtonNames(names, forcedDirs) in listManifest.js (manual+auto stages + a resolveName round-trip guarantee), added forcedPrefixDirs() to nodeLoader (fs walk) and browserLoader (import.meta.glob("**/.force-prefix") — Vite bundles the dotfile, confirmed in dist), created data/lists/danbooru/d/.force-prefix, and wired the SPA's getBlocks "Lists" cloud to the display tokens. Verified via nodeLoader: forcedPrefixDirs=["danbooru/d"], 0 round-trip failures of 81, danbooru → d/general etc., all others → bare filename (no conflicts). npm test + vite build green. (Note: the whole-danbooru group then showed as {d/d} — later moved up to danbooru/d.group so it buttons as {d}, see the newer entry above.)

keyword becomes a reserved wildcard (any-loaded-word) (2.1.0)

Owner: keyword shouldn't be a file — make it a reserved word (like the nsfw filename token) that supersedes any file named keyword (silently, no error) and resolves to a random word from ANY loaded list, mode-aware. Implemented RESERVED_WILDCARD="keyword" + isReservedWildcard in listManifest.js: resolveName short-circuits it (never suffix-matches danbooru/d/keyword); resolveListLines unions all non-artist, non-danbooru list vocabulary per variant — {keyword} = SFW off / +NSFW on, {keyword-sfw} = SFW always, {keyword-nsfw} = full (gated, hidden off). pickerListNames offers the reserved buttons (mode-aware). Relocated the keyword/ files first: 20 languages → word/language.txt; keyword-nsfw.txt adult vocab merged into word/adult-nsfw.txt (→88); deleted the keyword/ folder. Verified: {keyword} 74,584 off / 74,725 on, {keyword-sfw} 74,584, {keyword-nsfw} 0 off / 74,725 on; languages present; danbooru/artist excluded; keyword still the default keywordsFilename. npm test green.

Second keyword pass: relocate leftovers, drop junk (593 -> 20) (2.1.0)

Owner flagged keyword as a junk drawer (cities, gods, breeds, etc. in the wrong place). Did a second hand-classification of the 593-entry remainder via scripts/list-cleanup/reclassify-keyword.mjs (explicit decision per entry, coverage assertion: 593 = 354 relocated + 20 kept + 219 dropped). Relocated to proper lists (animal +25, word/noun +183→+111 after dedup, word/adjective +43, name/person +22, lore/history +16, place/place +12, lore/religion +11, look/time +15 [months/weekdays], style/art-movement +5, lore/mythology +7, nature/animal breeds & dinosaurs, etc.); dropped 219 junk (chemical symbols, 2-4 letter abbreviations, inflected artifacts like TELNETTed/Photostatting, fragments, units); kept 20 languages/scripts (Sanskrit, Urdu, Yiddish, …) with no dedicated list. npm test green. OPEN: keyword is the default keywordsFilename / {keyword} alias — now thin (20 langs); needs repointing to a richer source (asked owner).

Fix incompatible base.txt+base-nsfw.txt pairs (keyword, clothes) (2.1.0)

Found two lists still stored as a plain base.txt next to base-nsfw.txt, which the safety rule renders broken: the plain file is ignored, the implicit {base} button disappears, and — for keyword — the DEFAULT {keyword} alias was silently resolving to danbooru/d/keyword (shallower match won once keyword/keyword was dropped from the index). Fixed by renaming to the explicit SFW name: keyword/keyword.txtkeyword-sfw.txt, look/clothes.txtclothes-sfw.txt. Now {keyword} = 594 off / 653 on (correct leftover list, not danbooru), {clothes} = 972 off / 1030 on, and the picker shows the implicit base button in both modes (plus -sfw/-nsfw when adult on). Updated fix-leaks.mjs route source (look/clotheslook/clothes-sfw). npm test green.

Move danbooru groups into d/ folder ({d}, {d/keyword}, {d/character}) (2.1.0)

Owner: keep the danbooru group files within the {d/...} typed convention. Moved danbooru/danbooru.groupdanbooru/d/d.group (ref {d}), d-character.groupd/character.group ({d/character}), d-keyword.groupd/keyword.group ({d/keyword}). The bare {danbooru} reference is retired in favor of {d}. Updated all active refs: {d-character}{d/character} in danbooru.js, entity.js and the 7 v1 prompts; applyArgs anime keywordsFilename d-keywordd/keyword; the anime-detection in prompt-modules/dynamic-prompt.js + core/stages/dynamicPrompt.js now tests startsWith("d/") (was d-); web-app settings migration matches both legacy d-keyword and new d/keyword; listTags keys re-canonicalized (danbooru/d/d, danbooru/d/keyword, danbooru/d/character). README + list-architecture updated. Verified via nodeLoader: {d} 12393 off / 12932 on, {d-nsfw} 0/12932, {d/keyword} 11193/11732, {d/character} 3840, legacy {danbooru} now 0. npm test green. (Historical one-off cleanup scripts under scripts/list-cleanup/ still name the old flat lists; left as-is.)

SFW/NSFW safety rule: plain name.txt ignored when name-nsfw.txt exists (2.1.0)

Owner added a safety precaution: if <name>-nsfw.txt exists, a plain <name>.txt is flat ignored (never loaded, never listed) — the SFW half must be <name>-sfw.txt. A lone <name>.txt next to a -nsfw file is therefore treated as NSFW-only (no accompanying SFW). Enforced in both readSfwBase (reads <base>-sfw only when a <base>-nsfw sibling exists, never the plain file) and logicalListNames (a plain file with a -nsfw sibling is dropped from the reference set; only <base>-sfw counts as an SFW source). Verified with a synthetic harness: plain foo.txt + foo-nsfw.txt → logical ["foo-nsfw"], {foo} off="" / on=NSFW-only (plain content never leaks). npm test green.

SFW/NSFW redesign: filename-token gating + mode-aware auto-combine (2.1.0)

Owner redesigned the whole SFW/NSFW model (supersedes the group-file scheme from earlier today). New rules: (1) NSFW is keyed off the filename — any name with an nsfw token (word delimited by / - . _ or string ends) is adult; while adult is off the app acts as if it doesn't exist (not listed, not suggested, resolves to nothing). (2) A mixed list is two files <name>-sfw.txt + <name>-nsfw.txt with no <name>.txt — the bare {name} is implicit. (3) The resolver auto-combines by mode, no .group files for the split: {name} = SFW off / SFW+NSFW on; {name-sfw} = SFW only; {name-nsfw} = nothing off / SFW+NSFW on (SFW base auto-tacked on — confirmed: no NSFW-exclusive list for now). Variant suffixes also work on groups ({danbooru-sfw}/{danbooru-nsfw}).

Implemented: gatedLists.js → automatic isGatedList via NSFW_TOKEN regex (dropped the hardcoded array; gatedDynPrompts emptied, #danbooru ungated since it now draws the mode-aware d/general). listManifest.jsresolveListLines(name, readers, includeAdult, forced) with variant propagation into group members + base re-resolution (so {danbooru-sfw} maps to the group path); new logicalListNames() derives the implicit bare name + -sfw/-nsfw refs from the physical -sfw/-nsfw files; hasVariantSuffix. Threaded includeAdult through nodeLoader/browserLoader/listStore/listFiles readListLines. promptFilesAndSuggestions.js → random pool draws only bare bases (skips variant suffixes); new pickerListNames() hides NSFW (and redundant -sfw) when off, shows all three when on; /api/files/lists uses it. Files: renamed d/general.txtgeneral-sfw.txt, general-nsfw-only.txtgeneral-nsfw.txt; deleted the three split .group files; renamed the 4 standalone adult lists to -nsfw (artist/nudity-nsfw, keyword/keyword-nsfw, look/clothes-nsfw, word/adult-nsfw) + updated artist.group, both CSV build scripts, and fix-leaks.mjs routes. Verified via loader harness across both modes (e.g. {d/general} 7205 off / 7744 on; {d/general-nsfw} 0 off / 7744 on gated; {danbooru-sfw} 12393; standalone adult lists 0 off / content on, all gated) and npm test green.

SFW/NSFW naming FINAL: name=SFW, name-nsfw-only, name-nsfw=group(both) (2.1.0)

Owner reversed the previous convention (good reasoning: forcing SFW-only users to type -sfw is bad UX / a footgun). FINAL scheme: plain <name>.txt = SFW (so {name} is safe with no typing), <name>-nsfw-only.txt = NSFW-only, <name>-nsfw.group = imports both. Renamed general-sfw.txtgeneral.txt, general-nsfw.txtgeneral-nsfw-only.txt, general.groupgeneral-nsfw.group. Rebuilt the danbooru groups around it: danbooru.group (SFW), danbooru-nsfw.group (both), d-keyword.group (SFW), d-keyword-nsfw.group (both), d/general-nsfw.group (both). #danbooru (gated) now uses d/general-nsfw. gatedLists: gate the NSFW-bearing names (danbooru-nsfw, d-keyword-nsfw, d/general-nsfw, d/general-nsfw-only); the plain SFW names are ungated. Build script writes general.txt + general-nsfw-only.txt. listTags/headers/README/list-architecture updated. Verified via a temp loader harness: {d/general}=7205 ungated, {d/general-nsfw}=7742 gated(both), {d/general-nsfw-only}=539 gated, {danbooru}=12393 ungated, {danbooru-nsfw}=12932 gated, {d-keyword}=11193 ungated, {d-keyword-nsfw}= 11732 gated. npm test green.

SFW/NSFW naming: name=group(both), name-sfw, name-nsfw (2.1.0)

Owner corrected the convention: <name>-sfw.txt (SFW-only) + <name>-nsfw.txt (NSFW-only) + <name>.group importing both, so plain {name} = BOTH, {name-sfw} is the only SFW-only ref, {name-nsfw} = NSFW-only. Renamed danbooru/d/general.txtgeneral-sfw.txt, replaced general-all.groupgeneral.group (imports general-sfw + general-nsfw). Rewired: danbooru-sfw→general-sfw; danbooru(full)/d-keyword/#danbooru→general. gatedLists: general (group, both) + general-nsfw gated; general-sfw ungated. Build script now writes general-sfw/general-nsfw. Verified: {d/general}=7742 gated(both), {d/general-sfw}=7206 ungated, {d/general-nsfw}=540 gated, danbooru=12932 gated, danbooru-sfw=12393 ungated. npm test green.

Exclusive SFW/NSFW lists + full-as-group (supersedes d-sfw) (2.1.0)

Owner rejected the earlier d/ + d-sfw/ duplication (confusing, bad UX). Correct model: SFW and NSFW are EXCLUSIVE lists; the full/NSFW-inclusive version is a GROUP importing both — and only split files that genuinely contain both (otherwise overkill). Only danbooru/d/general truly mixes (7205 sfw / 539 nsfw); the 1-2 isNsfw hits in artist/character-c/meta are FPs or trivial, left whole. Did: deleted d-sfw/; split general into d/general (SFW-only) + d/general-nsfw (NSFW-only); added d/general-all.group (imports both). Rewired groups: danbooru-sfw = sfw exclusives; danbooru (full) + d-keyword use d/general-all. #danbooru (gated) now uses d/general-all. gatedLists: d/general ungated (clean), d/general-nsfw + d/general-all gated. Build script splits general on regenerate. Verified: danbooru 12932 (gated), danbooru-sfw 12393 (ungated), d/general 7206 (ungated), d/general-nsfw 540 (gated). npm test green.

Parallel audit of all risk-area lists + fixes (2.1.0)

Owner worried (after repeated spot-checks finding wrong words) that many lists still had bad entries; asked to use "separate api calls" to review thoroughly. Dispatched 4 parallel review subagents (Agent tool, fresh context each) to read the hand-curated descriptor lists (look/, style/, scene/, nature/) and AI-classified proper-noun lists (name/, lore/, brand/*) in full and report misfits. Biggest find: name/given-name was full of surnames (Einstein, Mozart, Gandhi…), title abbreviations (Dr, Mr, Sgt…), and fictional names. Applied all findings via fix-audit.mjs: 17 typos fixed (Aardwark→Aardvark, Vingette→Vignette, Linen Coset→Closet, …), 47 garbage/fragments/abbreviations removed, 229 misfits moved to the correct list (surnames given→person, bare first-names person→given, fictional→work, culture-tagged gods mythological-creature→mythology, an activity emotion→action, etc.). The big WordNet POS lists and city.txt were excluded (authoritative / pure geography). npm test green; leak scan = only intentional false positives.

Preprocess danbooru SFW (no runtime filtering) (2.1.0)

Owner objected (rightly) to the @filter sfw directive — it stripped adult words at RUNTIME (incl. in the browser). Replaced with preprocessed files: scripts/list-cleanup/split-danbooru-nsfw.mjs writes SFW-only copies of each danbooru/d/* to danbooru/d-sfw/* (full lists left intact + gated). danbooru-sfw.group now reads d-sfw/* directly (pure union, no filter). Removed @filter parsing from resolveListLines (and the now-unused isNsfw import). Wired the SFW split into process-danbooru-csv.js so regeneration keeps it. Verified: danbooru 12932 (full, gated), danbooru-sfw 12389 (preprocessed, ungated). npm test green.

Group files replace hardcoded virtual lists (2.1.0)

Owner: do composites "proper" as .group files instead of the hardcoded virtualLists object — a .group file lives anywhere, is referenced like any list, and each line is a list reference (resolved by the same suffix lookup); groups may import groups with a recursion cutoff (~3 levels). Implemented: removed virtualLists/isVirtualList; resolveListLines(name, {names, readListFile, readGroupFile}) now reads a <name>.group file, unions its member refs (recursively, MAX_GROUP_DEPTH=3 + cycle guard, dedup), and applies an optional @filter sfw|nsfw directive. All three loaders walk .txt + .group and inject the readers. Created group files (danbooru, danbooru-sfw, d-character, d-keyword, artist, artist-digipa, name) and — per follow-up — organized them into their folders (danbooru/, artist/, name/); updated gatedLists to the new canonical paths (danbooru/danbooru, danbooru/d-keyword). Verified all groups resolve + gate correctly ({danbooru}=12932 gated, {danbooru-sfw}=12389, etc.); npm test green; 0 lint errors.

Word lists: strict WordNet POS pass + collapse dict into one list per POS (2.1.0)

Owner: relocate misfits in the curated word lists, and collapse the dict-* lists into the main ones (one file per POS — "we don't need to be exclusive"); danbooru stays separate (genuinely its own anime thing). Did both. reclassify-words.mjs validated every curated adjective/noun/verb/adverb against WordNet: kept WordNet-confirmed in place, routed 602 action gerunds (nail biting, dual wielding, celebrating) to a new look/action, moved 450 cross-POS words to the correct list (the curated noun list was full of adjectives), kept 36 WordNet-unknown in place (no loss). Then merge-dict.mjs merged dict-adjective→ adjective, dict-noun→noun, dict-verb→verb, dict-adverb→adverb (dict-misc→word/misc) and deleted the dict-* files; removed the now-redundant *-all virtual lists. Final word/: adjective 8166, noun 22979, verb 6182, adverb 1053, misc 5451 (+ adult gated, preposition, interjection). Full spot-check wave: read samples from every folder (all fit), leak scan = only intentional false positives, 0 stale dict-/*-all refs, npm test green.

Spot-check: fix NSFW/misfit leaks across the lists (2.1.0)

Owner reported wrong content in lists (NSFW words in word/, "nipple" seen, anime terms). Built scan-leaks.mjs (flags isNsfw/classifyRemoval hits in supposedly-clean lists) and reviewed every hit line-by-line. Fixes: relocated 93 NSFW terms into gated look/clothes-adult (lingerie/fetish from look/clothes) and word/adult (sexual terms from word/); removed 1 extreme ("rape face"); pulled 26 danbooru face/expression/pose tags (torogao, doyagao, evil grin, naughty face, smirk, v-shaped eyebrows, goldfish scooping, finger biting, ...) out of word/adjective into a new look/expression list; removed 3 typos (faceing, operateing, gnawling); moved "Sailor Moon" from artist/anime to name/anime-name. Added the gated lists to gatedLists + listManifest tags. Kept genuine false positives (real places Coon Rapids/Gore/Intercourse/Dyke, "Naked mole rat", "X-ray", "presenting", and legit words breast/butt/ass/ sex/oral/facial/naked/explicit/sexual). Final leak scan = only those intentional keeps. npm test green. NOTE: the curated word/noun + word/verb still mix in action/pose gerunds (shushing, nose picking) and a few WordNet edge nouns in dict-verb (whelk, bur) — a stricter POS re-derivation is offered as a follow-up.

Organize lists into folders + path-suffix name resolution (2.1.0)

Owner: organize all lists into folders (e.g. d-* → a danbooru folder, drop prefix), add a README, reference via /. Then refined it: resolve by cached path-suffix lookup so a bare filename, partial path, or full path all work (deep folders, short refs); and bring back short codes as subfolders (danbooru/d/general → {d/general}, easy translation from old d-general); and guarantee a natural load order (symbols, numeric numbers, letters) so naming can force defaults.

Built: resolveName() + compareNames() in listManifest; all three loaders (nodeLoader, browserLoader, helpers/listFiles) now walk data/lists recursively and resolve via suffix. Moved 73 files into 12 folders (move-to-folders.mjs), keeping basenames UNIQUE so the ~78 existing {name} refs still resolve (verified) — only danbooru d-* string refs updated (danbooru.js, applyArgs.js, both dynamic-prompt person-replacers). danbooru files nested under danbooru/d/. Updated gatedLists, listManifest unions + tags, and both CSV build scripts to the new paths (+ mkdir). Added data/lists/README.md. npm test green; verified {d/general}, {danbooru/d/general}, {general}, {color}, virtuals, etc. all resolve.

Proper-noun categorization: keyword.txt 8,859 -> 593 (2.1.0)

Owner: keyword.txt was still a big bag of proper nouns with "garbage" in it — collapse more into other files, no deleting without review, and they asked whether AI could classify individually. Key insight (theirs): for proper nouns, AI world-knowledge IS the right tool (no dictionary knows Achernar is a star). Did it: automatic first pass (split-proper.mjs) pulled given-name/place/organization + de-duped confirmed cities into city.txt; then hand-classified the remaining ~4,422 individually in 9 batches (scripts/list-cleanup/cat/batch-00*.json) into person/place/organization/mythology/astronomy/ people-group/religion/history/work, distributed by an idempotent build-categories.mjs with a coverage check (nothing lost; unclassified stays in keyword.txt). Result: keyword.txt 8,859 -> 593, plus person 1810, given-name 1328, place 743, organization 551, people-group 298, mythology 218, religion 174, astronomy 133, work 150, demonym 124, history 87. New lists tagged in listManifest; added a name virtual (given-name + person). Slurs found mid-pass (Jap/Negress/Negroid) added to contentSafety + purged. npm test green, safety scan 0. NOTE: superseded surname.txt (came out empty) removed.

Keyword-list content-safety purge + full reorganization (2.1.0)

Owner asked for a big one: the keyword lists were a poorly-structured mess — outright slurs (e.g. the n-word), a blend of "adult-but-ok" and "not-ok-at-all" content, a giant unsorted dictionary, and clunky duplicate lists. Decisions taken with the owner: do it in one pass; remove slurs + illegal + extreme shock/gore/non-con while keeping ordinary adult content (NSFW-gated, not deleted); drop obvious junk (possessives/inflected fragments) during the POS sort; build "virtual lists" to collapse duplicates and compose curated+dictionary.

Built three things. Content safetysrc/contentSafety.js (browser-safe): curated blocklist with whole-word matching, list-type-aware (proper-noun lists match EXACT whole-entry so "Coon Rapids"/the hamlet "Dyke"/"Rio Negro" survive while bare slur words don't; city.txt fully exempt). Scanned all lists + danbooru.csv/artists.csv, reviewed the 81 matches (caught the place-name + artist-handle false positives), purged them, and wired the filter into both CSV build scripts so regeneration stays clean. Dictionary reorgclassify-pos.mjs sorted the 48,750-line keyword.txt (a SCOWL dump) via compromise into dict-* POS lists, dropping possessives/redundant inflections/junk; keyword.txt is now ~10k proper nouns. Math conserved exactly (46,500 sorted + 2,250 dropped). Virtual listssrc/listManifest.js resolves composite lists (union + de-dup + optional sfw/nsfw filter); collapsed the duplicated danbooru/d-keyword/d-character/artist/artist-digipa files into virtual definitions (deleted the physical files, preserved 6 artist orphans), added danbooru-sfw and the *-all curated+dict composites. Wired into nodeLoader, browserLoader, and helpers/listFiles.js.

Verification: npm test green (0 lint errors, 167 pre-existing warnings; smoke loaded the whole graph and happened to expand a virtual {adverb-all}). All on branch cleanup/list-reorg; tooling + audit logs under scripts/list-cleanup/. NOTE for follow-up: danbooru-sfw uses the NSFW lexicon (543 tags filtered) — it's a strong first pass but not a curated guarantee; the lexicon in contentSafety.js is the knob to tighten it.

Follow-up: redid the POS sort with WordNet (authoritative)

Owner pushed back (rightly) that keyword.txt still held common words and lost confidence in the guess-from-spelling scripts. Key correction: a real dictionary states a word's part(s) of speech — that's not a guess. Switched to WordNet (wordpos / wordnet-db, added as a dev dependency, index files read directly) and rebuilt the dict-* lists from the original slur-free dump (scripts/list-cleanup/pos-dictionary.mjs): each word → the POS list(s) WordNet assigns (bond → noun+verb), capitalized noun-only words kept as proper nouns (America/Paris/December stay), capitalized adj/verb/adverb words moved out, demonyms → demonym.txt, junk/redundant-inflections dropped. Result: keyword.txt 8,859 proper nouns; dict-noun 22,748 / dict-adjective 7,947 / dict-verb 6,171 / dict-adverb 1,050 / dict-misc 5,451 (real words WordNet doesn't index, incl. -ally adverbs). Conservation 48,750 exact. Deleted the superseded heuristic scripts (classify-pos/refine/analyze-proper/measure-keyword). Verified specific words (bond→noun+verb, holly→noun not adverb, communist→adj+noun, America→proper). npm test green, safety re-scan 0. NOTE: quickly/really/suddenly aren't in the lists because they're absent from the SCOWL source, not a bug.

Follow-up: keyword.txt second pass + dict-adverb fix

Owner noticed keyword.txt still held common words. Two fixes: (1) the first POS sort had a bug — a bare -ly ending was treated as an adverb, polluting dict-adverb with -ly nouns/names (holly, lily, assembly, Billy/Holly/Sally); re-routed 63 of them to adjective/verb/noun and fixed the rule in classify-pos.mjs (require the #Adverb tag). (2) Second pass on keyword.txt: moved the 1,232 entries whose lowercase form is a real dictionary word into the dict-* lists (precise set-membership test, not the unreliable proper-noun tagger), and extracted 127 demonyms to a new demonym.txt. keyword.txt is now 8,817 genuine proper nouns. Caveat noted: a few name/common-word collisions (Bond, Amber, Holly) move to common lists — acceptable for prompt generation, reversible on the branch. demonym added to listManifest (tags + adjective-all/noun-all). npm test green.

Composer: four icon-only field actions (2.0.8)

Owner wanted Save + Share as icon-only buttons in the field next to Random/Generate (Save = disk, Share = share glyph), not the merged text button. Split them back apart and made all four .field-act buttons a cohesive inline-SVG icon set (currentColor): Save = floppy disk, Share = three-node share, Random = shuffle, Generate = sparkle (primary green). Added module-scope icon components (SaveIcon, ShareIcon, ShuffleIcon, SparkleIcon). State back to panel: ""|"save"|"share"; Save/Share each toggle their own one-row .action-panel. Dropped .field-util CSS. Lint 0 errors, vite build green. (If owner meant the iOS box-and-up-arrow share glyph rather than the three-node one, easy swap.)

Composer: chat-style field, docked actions (2.0.7)

Owner still felt it didn't read as an app. Turned the composer into a chat-style input: a bordered .composer-field (lights up via :focus-within) wrapping a borderless textarea with a .field-bar docked along the bottom. Actions moved into the field — Generate = icon-only primary round button (✦, bottom-right), Random = matching round icon (🎲) beside it, and Save + Share merged into one .field-util button (bottom-left) that opens a single .action-panel with both a save-as-expansion row and a share-link row. State went from panel: ""|"save"|"share" to a single panelOpen bool; opening it pre-builds the share link. Dropped the old .composer-toolbar, .generate-btn, .tool-btn, .editor, and the redundant close ✕. Lint 0 errors, vite build green.

Composer follow-up: size the prompt box (2.0.6)

Owner: the editor-fill layout made the textarea take the whole pane height — "looks silly, no need for that much space." Reverted the fill behaviour: composer/results now size to content (flex: none), the prompt box is a comfortable min-height: 8.5rem, resize: vertical, capped at 50vh, and .main-col scrolls on overflow. CSS-only (styles.css). vite build green.

Composer redesign + anime/adult toggle removed (2.0.5)

Owner feedback: the SPA middle still felt "clunky/like a dev tool, not an app." Three threads, all SPA-only (web-app/):

  1. Anime/adult content. Confirmed the owner's suspicion: the Style toggle's "Anime" option swaps in data/lists/d-keyword.txt — a raw Danbooru tag dump that mixes SFW tags with explicit adult ones (nude, sex, cum, penis, pussy, nipples, …). There was no way to get anime without adult, and the separate keyword-adult.txt isn't even wired into the SPA. A clean SFW/adult split is a big, careful job across ~100k+ lines of lists (the owner flagged this), so for now we removed the Style toggle from Home.jsx and added a migration in lib/settings.js#loadSettings that pulls any browser stuck on d-keyword/d-artist back to the safe keyword/artist defaults. Logged in plans/removed-pending-readd.md with re-add guidance (Style SFW-only + a separate off-by-default "Allow adult content" switch, once the lists are gated). Data files left untouched.

  2. Compact toolbar (owner chose this layout). Rebuilt the composer actions into one toolbar attached to the prompt box: prominent Generate, 🎲 Random, and smaller ghost Save / Share. Save-expansion moved off its own card into an inline panel (same pattern as Share); the separate save card is gone.

  3. Editor-fill layout. The textarea now stretches to fill the composer card (fixes the "big empty gap after it"); a hover clears the box. The composer flexes to fill the column; when prompts exist, the results card splits the space and scrolls internally. Mobile stacks back to content-height.

Noticed, left alone: the working tree already has uncommitted, unrelated in-progress work on list gating — src/gatedLists.js (untracked), data/lists/keyword-adult.txt (untracked), and edits to data/lists/{d-general,d-keyword,danbooru,keyword}.txt, src/applyArgs.js, src/core/listStore.js, src/helpers/listFiles.js, src/promptFilesAndSuggestions.js, src/settings.js. This looks like the owner's own start on the SFW/adult separation. Not committed here — only the SPA files for this pass were staged. Surfaced to the owner.

Verification: npm run lint → 0 errors (165 pre-existing warnings). web-app vite build → green. Files: web-app/src/components/Home.jsx, web-app/src/lib/settings.js, web-app/src/styles.css, VERSION + package.json + web-app/package.json (→ 2.0.5), and the notes above.