☰

PixAI Prompt Generator

Prompt builder overlay. Manual copy only: it never reads from or writes to the site's prompt editor.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         PixAI Prompt Generator
// @namespace    promptgen
// @version      1.7.0
// @description  Prompt builder overlay. Manual copy only: it never reads from or writes to the site's prompt editor.
// @match        https://pixai.art/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_setClipboard
// @license      GNU GPLv3
// ==/UserScript==

(function () {
  // ---------- styles (tool CSS lives inside a closed shadow root) ----------
  const TOOL_CSS = `
:host{--bg:#14141a;--card:#1d1d26;--line:#2e2e3b;--tx:#e6e6ee;--mut:#8a8a9c;--acc:#b08cff;color-scheme:dark}
@media (prefers-color-scheme: light){:host{--bg:#f4f4f8;--card:#fff;--line:#d8d8e2;--tx:#1b1b24;--mut:#6a6a7c;--acc:#6d3fe0;color-scheme:light}}
*{box-sizing:border-box}

.layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,520px);gap:14px;max-width:1300px;margin:auto;align-items:start}
@media (max-width:900px){.layout{grid-template-columns:1fr}.outp{order:-1;position:static!important}}
.mods{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px;align-items:start}
.mod{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px}
.mod h2{font-size:13px;margin:0 0 6px;color:var(--acc);text-transform:uppercase;letter-spacing:.05em}
.sub{font-size:11px;color:var(--mut);margin:8px 0 2px;text-transform:uppercase}
label.o{display:flex;gap:7px;align-items:flex-start;padding:2px 0;cursor:pointer}
label.o input{margin-top:3px}
label.o.none{color:var(--mut)}
.dis{opacity:.4;pointer-events:none}
.gate{margin-top:6px;padding-top:6px;border-top:1px dashed var(--line)}
.gate .inner{margin-left:20px}
.col{display:flex;flex-direction:column;gap:10px;min-width:0}
.subbox{margin:2px 0 4px 22px;padding-left:8px;border-left:2px solid var(--line)}
.subbox.off{opacity:.55}
.side{display:flex;gap:16px}
.hint{font-size:11px;color:var(--mut);margin-left:22px}
.gate .hint{margin-bottom:4px}
.outp{position:sticky;top:12px;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px;max-height:calc(100vh - 56px);display:flex;flex-direction:column}
#outs{overflow:auto;display:flex;flex-direction:column;gap:8px;margin-top:8px}
.res{border:1px solid var(--line);border-radius:6px;padding:6px 8px}
.res .top{display:flex;gap:8px;align-items:center;color:var(--mut);font-size:11px;margin-bottom:4px}
.res .top span{flex:1}
.res pre{margin:0;white-space:pre-wrap;word-break:break-word;font:12px/1.5 ui-monospace,monospace}
.res.locked{border-color:#4ac1ff;box-shadow:0 0 0 1px #4ac1ff}
.res.clip{border-color:#ffc765;box-shadow:0 0 0 1px #ffc765}
.star{background:none;border:none;padding:0 6px 0 0;font-size:16px;line-height:1;color:#8a8a9c;cursor:pointer}
.star.on{color:#4ac1ff}
button{font:inherit;color:var(--tx);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:5px 11px;cursor:pointer}
button.pri{background:var(--acc);border-color:var(--acc);color:#fff}
button.sm{padding:1px 8px;font-size:11px}
.row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}.row small{color:var(--mut);margin-left:auto}
select{font:inherit;color:var(--tx);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:4px 6px;min-width:0;flex:1}
.row select{min-width:120px}
.pset{display:flex;gap:6px;align-items:center}
.txt{width:100%;font:inherit;color:var(--tx);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:5px 8px}
.clist{margin-top:6px}.crow{display:flex;align-items:center;gap:6px}
.crow label.o{flex:1;min-width:0;word-break:break-word}.crow .star{padding:0 2px}
.sym{padding:2px 9px;font-size:15px;line-height:1.3}
.sws{display:flex;gap:6px;margin:6px 0 6px 24px}
.sym.sw{font-size:17px;padding:2px 10px}
.sym.night{color:#8a8a9c}.sym.night.on{color:#fefdb2}
.sym.wx0{color:#ffe400}.sym.wx1{color:#4aa3ff}.sym.wx2{color:#fff;text-shadow:0 0 2px rgba(0,0,0,.65)}
.sym.soft{color:#8a8a9c}.sym.soft.on{color:#42ff00}
.sym.ld{color:#00ff00}.sym.sv{color:#ff0000}.sym.dl{color:#ff0096}.sym.rd{color:#ffc765}.sym.rr{color:#ffe400}

.back{position:fixed;inset:0;background:rgba(0,0,0,.6);overflow:auto;overscroll-behavior:contain;padding:8px;display:flex;align-items:flex-start;justify-content:center}
.panel{position:relative;background:var(--bg);color:var(--tx);font:14px/1.4 system-ui,sans-serif;border:1px solid var(--line);border-radius:12px;padding:12px;width:100%;max-width:1360px}
.layout{grid-template-columns:minmax(0,1fr) minmax(0,520px) 32px}
.xcol{position:relative}
.x{position:sticky;top:8px;width:32px;height:32px;padding:0;font-size:16px;line-height:1}
@media (max-width:900px){.layout{grid-template-columns:1fr}.xcol{order:-2;display:flex;justify-content:flex-end}}
`;
  const BTN_CSS = `
:host{display:block;position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:1}
button{font:500 13px/1 system-ui,sans-serif;height:36px;padding:0 14px;border-radius:9999px;white-space:nowrap;cursor:pointer;
  color:inherit;background:transparent;border:1px solid color-mix(in srgb,currentColor 22%,transparent)}
button:hover{background:color-mix(in srgb,currentColor 8%,transparent)}`;

  const h = (tag, props = {}, ...kids) => {
    const e = document.createElement(tag);
    for (const k in props) e.setAttribute(k, props[k]);
    e.append(...kids);
    return e;
  };

  // ---------- the tool (same logic as index.html) ----------
  function initTool($) {

// ===== always-on, not shown in UI =====
const PREFIX = "uncensored, 1girl, cute";
const SUFFIX = hands => "very aesthetic, absurdres, detailed background, newest, intricate details, scenery, blurry background, masterpiece, best quality, detailed illustration, shiny skin, " + hands + ", detailed eyes, detailed eyelashes, extremely aesthetic, amazing quality";

// ===== modules =====
// radio = pick one (unless noNone) | check = any | gated = checkbox enabling an inner radio
// option = "tags" or {l:label, t:tags, always:true?}. A multi-tag selection (e.g. group sex) = one option with all tags in t.
// ===== Acts (custom module: one radio across everything, sub modules are radio pairs) =====
// sub modules: timing (side by side) x target (stacked) -> tag bundle tags[timing][target]
const ACTS = [
  {k:"none"},
  {k:"solo", t:"solo focus"},
  {k:"pen", l:"penetration", sub:true},
  {k:"titjob", l:"titjob", t:"paizuri"},
  {k:"handjob", t:"handjob"},
  {k:"mast", l:"masturbation", t:"solo focus, masturbation, female masturbation"},
  {k:"dil", l:"dildo play", sub:true},
  {k:"tentacle", t:"tentacle sex, tentacles"},
  {k:"double", t:"double penetration"},
  {k:"yuri", t:"yuri"},
];
const SUBS = {
  pen: {timing:["imminent","sex"], target:["mouth","pussy","ass"], tags:[
    ["imminent fellatio","imminent penetration, imminent vaginal","imminent penetration, imminent anal"],
    ["fellatio","hetero, sex, vaginal","hetero, sex, anal"]]},
  dil: {timing:["imminent","insertion"], target:["pussy","ass"], tags:[
    ["solo focus, imminent object insertion, dildo, imminent vaginal object insertion, sex toy",
     "solo focus, imminent object insertion, dildo, imminent anal object insertion, sex toy"],
    ["solo focus, object insertion, dildo, vaginal object insertion, sex toy",
     "solo focus, object insertion, dildo, anal object insertion, sex toy"]]},
};
const GENITAL_TARGETS = ["pussy","ass"];                // choosing one of these auto-enables "genital detail"
const AUTO_BOY = ["pen","titjob","handjob"]; // selecting these ticks "Faceless nude male"

const MODULES = [
 {name:"Presets", custom:"presets"},
 {name:"Custom tags", custom:"custom"},
 {name:"Characters", groups:[
  {id:"girl_state", sub:"girl", type:"radio", noNone:true, def:0, opts:[
    {l:"fully clothed", t:""},
    {l:"partly exposed", t:"breasts, nipples, nipples, breasts out, open clothes, clothes pull"},
    {l:"nude", t:"completely nude, breasts, nipples"}]},
  {id:"pants", type:"pants"},
  {id:"girl_detail", type:"check", opts:[
    {l:"genital detail", t:"clitoris, pussy, pussy juice, detailed pussy, visible labia clitoris urethra"}]},
  {id:"girl_extra", type:"check", opts:["undressing","wind lift"]},
  {id:"boy", sub:"boy", type:"check", opts:[
    {l:"faceless nude male", t:"1boy, faceless male, nude male, penis"}]},
 ]},
 {name:"Quality", hint:"For users of Perfect Hands LoRA", groups:[
  {id:"ph", type:"check", def:[0], opts:[{l:"perfect hands user", t:""}]}]},
 {name:"Acts", custom:"acts"},
 {name:"Positions", groups:[
  {id:"pos", type:"radio", opts:[
   "standing","sitting","kneeling","lying, on back","straddling","lying, on side","doggystyle","reverse carry congression"]},
  {id:"legs", type:"check", opts:["spread legs"]}]},
 {name:"Arms", extra:{key:"cycle", label:"cycle", hint:"one prompt per arm state"},
  groups:[{id:"arms", type:"radio", disIf:"cycle", opts:[
   "spread pussy","arms behind back","arms behind head","reaching towards viewer"]}]},
 {name:"Sensation", extra:{key:"mix", label:"random mix", hint:"4 prompts: one with everything ticked, rest random subsets"}, groups:[
  {id:"sens", type:"check", def:[0], opts:[
   {t:"aroused, blush", always:true},"moaning","horny","happy","in heat","surprised","wink","excited","v-shaped eyebrows","tears","satisfied","afterglow","saliva",{l:"spoken heart", t:"spoken_heart"}]},
 ]},
 {name:"Cum & Defloration", groups:[
  {id:"defl", type:"check", opts:["defloration"]},
  {id:"cum", type:"gated", gate:"cum", opts:["precum","cum, cum overflow, ejaculation"]},
  {id:"cumx", sub:"extras", type:"check", opts:["cum on body","cum on face","cum on hair","show cum in mouth","cum on ass","cum on back"]},
 ]},
 {name:"Camera", hint:"none ticked = no camera tags", extras:[
   {key:"mClose", label:"multi prompt close-up focus", hint:"4 prompts: pov / pov + dutch angle / close-up / straight-on (each + looking at viewer). Ignores the ticks above except internal."},
   {key:"mAll", label:"multi prompt all", hint:"14 prompts: every angle, plain and with dutch angle. Ignores the ticks above except internal."}],
  groups:[{id:"cam", type:"check", opts:[
   "pov","dutch angle","close-up","straight-on","from side","from below","from behind","from above","looking at viewer","internal","pantyshot"]}]},
 {name:"Location", groups:[
  {id:"loc", type:"select", onKey:"locOn", def:0, opts:[
   {l:"inn", t:"indoors, inn, bedroom, window, (intricate:1.1)"},
   {l:"palace", t:"indoors, palace, (intricate:1.1)"},
   {l:"church", t:"indoors, church, (intricate:1.1)"},
   {l:"ruins", t:"indoors, ruins, mountains, above clouds, (intricate:1.1)"}]},
  {id:"surf", type:"surface", opts:["on bed","on floor","on bench","on couch","on chair","on table"]}]},
];

// column layout: module names per column, top to bottom (edit to rearrange)
const LAYOUT = [
  ["Presets","Custom tags","Characters","Camera"],
  ["Location","Acts","Sensation"],
  ["Positions","Arms","Cum & Defloration","Quality"],
];

// multi-prompt camera sets ("internal" is added to each when it is ticked)
const CLOSE_SETS = [["pov","looking at viewer"],["pov","dutch angle","looking at viewer"],["close-up","looking at viewer"],["straight-on","looking at viewer"]];
const ALL_SETS = [
  ["pov","looking at viewer"],["close-up","looking at viewer"],["from below"],["from behind"],["from above"],["straight-on","looking at viewer"],["from side"],
  ["pov","dutch angle","looking at viewer"],["close-up","dutch angle","looking at viewer"],["from below","dutch angle"],["from behind","dutch angle"],["from above","dutch angle"],["straight-on","dutch angle","looking at viewer"],["from side","dutch angle"]];

// ===== state =====
const KEY = "promptgen.v4", PKEY = "promptgen.presets.v1", LKEY = "promptgen.locksaves.v1";
// KV (Tampermonkey storage)
const kv = {
  get(k, d){ try{ if(typeof GM_getValue==="function"){ const v = GM_getValue(k, null); return v==null ? d : JSON.parse(v); } }catch(e){} return d; },
  set(k, v){ try{ if(typeof GM_setValue==="function") GM_setValue(k, JSON.stringify(v)); }catch(e){} },
};
const GROUPS = MODULES.flatMap(m => m.groups || []), GM = Object.fromEntries(GROUPS.map(g=>[g.id,g]));
let S = {}, PINNED = [], FRESH = [];   // PINNED: locked + clipboard prompts, FRESH: newly generated prompts
const txt = o => typeof o === "string" ? o : o.t;
const lab = o => typeof o === "string" ? o : (o.l || o.t);
function defaults(){
  const d = {cycle:false, mix:false, mClose:false, mAll:false, locOn:true, night:false, wx:0, soft:false, locMem:{}, locv:3, camv:2, sensv:2, custom:{text:"", items:[]}, acts:{kind:"none", pen:{t:0,x:0}, dil:{t:0,x:0}}};
  GROUPS.forEach(g => d[g.id] = (g.type==="radio" || g.type==="select") ? (g.def ?? (g.noNone?0:-1)) : g.type==="check" ? [...(g.def||[])] : g.type==="pants" ? {mode:0,color:0,aside:false} : {on:false,i:0});
  return d;
}
let PRESETS = [], LSAVES = [], loadedPreset = null, loadedLock = null, presetSelId = "", lockSelId = "";
// saved states keep option positions, so shifts caused by inserted options are migrated here
function migrate(sv){
  sv = JSON.parse(JSON.stringify(sv || {}));
  if(!sv.camv && Array.isArray(sv.cam)) sv.cam = sv.cam.map(i => i>=4 ? i+1 : i);        // "from side" inserted at 4
  if(!sv.sensv && Array.isArray(sv.sens)) sv.sens = sv.sens.map(i => i>=8 ? i+1 : i);    // "v-shaped eyebrows" inserted at 8
  if(!sv.locv && typeof sv.loc === "number") sv.loc = sv.loc === 0 ? 0 : sv.loc + 1;       // scenery list changed: bedroom -> inn, church/clouds shifted
  if((sv.locv || 0) < 3 && "locDay" in sv){ sv.night = !sv.locDay; sv.soft = !sv.locDay; delete sv.locDay; }   // old day/night toggle -> night + soft light
  sv.camv = 2; sv.sensv = 2; sv.locv = 3; return sv;
}
const newest = list => list.length ? String(Math.max(...list.map(x=>x.id))) : "";
function load(){
  S = Object.assign(defaults(), migrate(kv.get(KEY, {})));
  if(S.locOn && !S.locMem[String(S.loc)]) saveLive();            // seed the scenery memory from the current settings
  PRESETS = kv.get(PKEY, []); LSAVES = kv.get(LKEY, []);
  presetSelId = newest(PRESETS); lockSelId = newest(LSAVES);
}
function save(){ kv.set(KEY, S); }

// ----- location memory: per scenery, one saved state for day and one for night (weather, soft light, surface) -----
// live settings = S.night / S.wx / S.soft / S.surf; while the scenery checkbox is on they are written through to S.locMem[scenery]
const newSub = night => ({wx: 0, soft: night, surf: {on: false, i: 0}});          // night starts with soft light on (not a lock)
const memoOf = idx => S.locMem[String(idx)] || (S.locMem[String(idx)] = {night: false, d: newSub(false), n: newSub(true)});
function saveLive(){
  const m = memoOf(S.loc); m.night = S.night;
  m[S.night ? "n" : "d"] = {wx: S.wx, soft: S.soft, surf: {on: S.surf.on, i: S.surf.i}};
}
function loadLive(idx){                                                           // restore the full last-used state of a scenery
  const m = memoOf(idx); S.night = m.night;
  const sub = m[S.night ? "n" : "d"]; S.wx = sub.wx; S.soft = sub.soft; S.surf = {on: sub.surf.on, i: sub.surf.i};
}
function commitLoc(){ if(S.locOn) saveLive(); save(); render(); generate(); }
function toggleNight(){
  if(S.locOn){
    saveLive();
    const m = memoOf(S.loc); m.night = !m.night; S.night = m.night;
    const sub = m[S.night ? "n" : "d"]; S.wx = sub.wx; S.soft = sub.soft; S.surf = {on: sub.surf.on, i: sub.surf.i};
  } else { S.night = !S.night; S.soft = S.night; }                               // no scenery: nothing is tracked, soft light just follows night
  save(); render(); generate();
}

// ===== generation =====
const fmt = s => s.split(",").map(x=>x.trim()).filter(Boolean).map(x=>x+",").join(" ");
const rnd = a => a[Math.floor(Math.random()*a.length)];
const shuffle = a => a.map(x=>[Math.random(),x]).sort((p,q)=>p[0]-q[0]).map(p=>p[1]);
const radioText = id => S[id] >= 0 ? txt(GM[id].opts[S[id]]) : "";
const sel = id => GM[id].opts.filter((_,i)=>S[id].includes(i));

// seeding groups: when one member is seeded, the rest of its group follows together with chance p;
// otherwise the rest is left out of that prompt (so the check can never be retriggered by a later reseed).
// A group fully contained in a larger active group is muted: with all three ticked only the 3-tag rule runs.
const AFFINITY = [
  {tags:["surprised","wink"], p:2/3},
  {tags:["surprised","wink","spoken_heart"], p:2/3},
];
function activeGroups(pool){
  const act = AFFINITY.filter(g => g.tags.every(t => pool.includes(t)));
  return act.filter(g => !act.some(h => h !== g && h.tags.length > g.tags.length && g.tags.every(t => h.tags.includes(t))));
}
function randomSubset(pool){ // 1..k-1 seeds (all if k<=1), original order kept
  const k = pool.length; if(k<=1) return pool.slice();
  const size = 1 + Math.floor(Math.random()*(k-1));
  const order = shuffle(pool), groups = activeGroups(pool);
  const out = new Set(), blocked = new Set();
  for(const t of order){
    if(out.size >= size) break;
    if(out.has(t) || blocked.has(t)) continue;
    out.add(t);
    const g = groups.find(x => x.tags.includes(t));
    if(g){
      const rest = g.tags.filter(x => x !== t && !out.has(x));
      if(rest.length){ if(Math.random() < g.p) rest.forEach(x => out.add(x)); else rest.forEach(x => blocked.add(x)); }
    }
  }
  return pool.filter(t => out.has(t));
}
// strict: only distinct results (may return fewer than count); loose: pad with repeats
function subsetVariants(pool, count, includeFull, strict){
  const out=[], seen=new Set(), key=s=>s.join("\u0000");
  const target = includeFull ? count-1 : count;
  if(includeFull) seen.add(key(pool));
  for(let t=0; out.length<target && t<300; t++){
    const x = randomSubset(pool); if(seen.has(key(x))) continue;
    seen.add(key(x)); out.push(x);
  }
  if(!strict) while(out.length<target) out.push(randomSubset(pool));
  if(includeFull) out.splice(Math.floor(Math.random()*(out.length+1)), 0, pool.slice());   // the "everything" prompt can land anywhere
  return out;
}

const PANTS_COLORS = ["white","black"];
function pantsTags(){
  const p = S.pants;
  if(p.mode === 1) return ["no panties, nopan"];      // nopan wipes every pantsu tag, aside included
  const t = ["panties, lace_panties, " + PANTS_COLORS[p.color] + " panties"];
  if(p.aside) t.push("panties aside");
  return t;
}
function actText(){
  const a = S.acts, k = a.kind;
  if(SUBS[k]) return SUBS[k].tags[a[k].t][a[k].x];
  const o = ACTS.find(x=>x.k===k); return o && o.t ? o.t : "";
}
function setAct(k){
  const prev = S.acts.kind; S.acts.kind = k;
  if(prev !== k && AUTO_BOY.includes(k) && !S.boy.includes(0)) S.boy = [0];
  if(prev !== k && SUBS[k] && GENITAL_TARGETS.includes(SUBS[k].target[S.acts[k].x]) && !S.girl_detail.includes(0)) S.girl_detail = [0];
}

function generate(){
  const girl = [radioText("girl_state"), ...pantsTags(), ...sel("girl_detail").map(txt), ...sel("girl_extra").map(txt)];
  const boy = sel("boy").map(txt), pos = radioText("pos"), legs = sel("legs").map(txt), act = actText(), loc = S.locOn ? radioText("loc") : "", surf = S.surf.on ? txt(GM.surf.opts[S.surf.i]) : "";
  const dog = pos === "doggystyle", cycleOn = S.cycle && !dog;   // doggystyle: arms forced to none, cycle ignored
  const armsAll = GM.arms.opts.map(txt), armFixed = dog ? "" : radioText("arms");
  const sensSel = sel("sens"), always = sensSel.find(o=>o.always);
  const pool = sensSel.filter(o=>!o.always).map(txt);
  const alwaysT = always ? [txt(always)] : [];
  const cumTags = [...sel("defl").map(txt), ...(S.cum.on ? [txt(GM.cum.opts[S.cum.i])] : []), ...sel("cumx").map(txt)];
  const camOpts = GM.cam.opts.map(txt);
  const tail = S.cam.includes(camOpts.indexOf("internal")) ? ["internal"] : [];   // the only manual camera tick the multi modes honor
  const multi = S.mClose || S.mAll;
  const cams = multi ? [] : sel("cam").map(txt);                                  // none ticked = no camera tags
  const mixFor = (n, strict) => S.mix ? subsetVariants(pool, n, true, strict) : null;

  const specs = [];
  if(!multi){
    if(cycleOn){
      const subs = mixFor(4, false);
      armsAll.forEach((a,i)=> specs.push({cam:cams, arms:a, sub: subs?subs[i]:pool, mixed:!!subs, armPick:true}));
    } else if(S.mix){
      mixFor(4, true).forEach(x=> specs.push({cam:cams, arms:armFixed, sub:x, mixed:true}));
    } else specs.push({cam:cams, arms:armFixed, sub:pool});
  } else {
    const group = (label, sets) => {
      const n = sets.length, subs = mixFor(n, false);
      let arms = null;
      if(cycleOn){                                   // as even as possible across the 4 states, randomly placed
        const order = shuffle(armsAll); arms = [];
        for(let i=0;i<n;i++) arms.push(order[i % order.length]);
        arms = shuffle(arms);
      }
      sets.forEach((cam,i)=> specs.push({cam:[...cam, ...tail], arms: arms ? arms[i] : armFixed, sub: subs?subs[i]:pool, mixed:!!subs, armPick:cycleOn, group:label}));
    };
    if(S.mClose) group("close-up focus", CLOSE_SETS);
    if(S.mAll) group("all", ALL_SETS);
  }

  FRESH = specs.map((p,i)=>{
    const lines = [
      fmt([PREFIX, ...girl].join(", ")),
      fmt([pos, ...legs, act, p.arms].filter(Boolean).join(", ")),
      fmt([...alwaysT, ...p.sub, ...cumTags].join(", ")),
      fmt(p.cam.join(", ")),
      fmt(boy.join(", ")),
      fmt([loc, S.night ? "night" : "day", ["light rays","rain","snow"][S.wx], S.soft ? "soft light" : "", surf].filter(Boolean).join(", ")),
      fmt(S.custom.items.filter(x => x.on).map(x => x.tag).join(", ")),
      fmt(SUFFIX(S.ph.includes(0) ? "(detailed_hands:1.5)" : "detailed_hands")),
    ].filter(Boolean);
    const meta = [];
    if(p.group) meta.push(p.group + ": " + (p.cam.join(", ") || "no camera"));
    if(p.armPick) meta.push("arms: " + p.arms);
    if(p.mixed) meta.push(`sens ${p.sub.length}/${pool.length}`);
    return {text: lines.join("\n"), meta: meta.join(" · ")};
  });
  renderOut();
}

// ===== output ui =====
function copy(t, btn){
  const done = ()=>{ const o = btn.textContent; btn.textContent = "copied"; setTimeout(()=>btn.textContent = o, 800); };
  try{ if(typeof GM_setClipboard==="function"){ GM_setClipboard(t, "text"); return done(); } }catch(e){}
  if(navigator.clipboard) navigator.clipboard.writeText(t).then(done);
}
const stamp = () => {
  const d = new Date(), z = n => String(n).padStart(2, "0");
  return d.getFullYear() + z(d.getMonth()+1) + z(d.getDate()) + " " + z(d.getHours()) + "-" + z(d.getMinutes()) + "-" + z(d.getSeconds());
};
const canon = v => JSON.stringify(v, (k, x) => (x && typeof x === "object" && !Array.isArray(x)) ? Object.keys(x).sort().reduce((o, k2) => (o[k2] = x[k2], o), {}) : x);
const uid = list => { let id = Date.now(); while(list.some(p => p.id === id)) id++; return id; };

function renderOut(){
  normPinned();
  const box = $("outs"); box.replaceChildren();
  const card = (p, label, pinned, i) => {
    const d = document.createElement("div");
    d.className = "res" + (p.locked ? " locked" : p.src === "clip" ? " clip" : "");
    const top = document.createElement("div"); top.className = "top";
    const st = document.createElement("button"); st.type = "button"; st.className = "star" + (p.locked ? " on" : "");
    st.textContent = "★"; st.title = p.locked ? "unlock" : "lock"; st.onclick = () => toggleLock(p, pinned, i);
    const m = document.createElement("span"); m.textContent = label;
    const b = document.createElement("button"); b.className = "sm"; b.textContent = "copy"; b.onclick = () => copy(p.text, b);
    top.append(st, m, b);
    const pre = document.createElement("pre"); pre.textContent = p.text;
    d.append(top, pre); box.append(d);
  };
  PINNED.forEach((p,i) => card(p, (p.locked ? "locked" : "clipboard") + (p.meta ? " · " + p.meta : ""), true, i));
  FRESH.forEach((p,i) => card(p, "#" + (i+1) + (p.meta ? " · " + p.meta : ""), false, i));
  const n = PINNED.length + FRESH.length, c = $("cnt");
  if(c) c.textContent = n + (n === 1 ? " prompt" : " prompts");
}

// the unlocked clipboard prompt always owns the very top slot; locked prompts follow in the order they were locked
function normPinned(){
  const isClip = p => p.src === "clip" && !p.locked;
  PINNED = [...PINNED.filter(isClip), ...PINNED.filter(p => !isClip(p))];
}

// lock: a locked prompt moves to the top (newest lock below older ones); the fresh set is then rolled again so its size stays the same
function toggleLock(p, pinned, i){
  if(pinned){
    PINNED.splice(i, 1);
    if(p.locked){ p.locked = false; p.src = "gen"; FRESH.unshift(p); }   // unlock: ordinary prompt again until the next roll
    else { p.locked = true; PINNED.push(p); }                            // locking a clipboard prompt frees the clipboard slot
    renderOut();
  } else {
    FRESH.splice(i, 1); p.locked = true; p.src = "gen"; PINNED.push(p);
    generate();
  }
}

// ----- selects / small helpers -----
function fillSel(sel, list, cur){
  if(!sel) return;
  sel.replaceChildren();
  const add = (label, val, on, off) => { const o = document.createElement("option"); o.textContent = label; o.value = val; o.selected = !!on; o.disabled = !!off; sel.append(o); };
  if(!list.length){ add("no saves yet", "", true, true); return; }
  [...list].sort((a,b) => b.id - a.id).forEach(x => add(x.name, String(x.id), String(x.id) === String(cur)));
}
function refreshSelects(){ fillSel($("lockSel"), LSAVES, lockSelId); fillSel($("presetSel"), PRESETS, presetSelId); }
function sym(id, ch, cls, title, fn){
  const b = document.createElement("button"); b.type = "button"; b.id = id; b.className = "sym " + cls; b.textContent = ch; b.title = title; b.onclick = fn; return b;
}

// ----- presets: the whole page = selections + generated prompts -----
function actLabel(){
  const a = S.acts, k = a.kind;
  if(k === "none") return "";
  if(SUBS[k]) return (k === "dil" ? "dildo play " : "") + SUBS[k].timing[a[k].t] + " " + SUBS[k].target[a[k].x];
  const o = ACTS.find(x => x.k === k); return o.l || o.t;
}
function presetName(){
  const pos = S.pos >= 0 ? lab(GM.pos.opts[S.pos]) : "";
  const tail = [pos, actLabel()].filter(Boolean).join(" ").replace(/,/g, "");
  return stamp() + (tail ? " - " + tail : "");
}
const snapshot = () => ({state: JSON.parse(JSON.stringify(S)), prompts: FRESH.map(p => ({text: p.text, meta: p.meta}))});
const sameSnap = (a, b) => canon(a.state) === canon(b.state) && a.prompts.length === b.prompts.length && a.prompts.every((p,i) => p.text === b.prompts[i].text);
function savePreset(){
  const snap = snapshot(), cur = PRESETS.find(p => p.id === loadedPreset);
  if(cur && sameSnap(cur, snap)) return;                       // identical to the loaded preset: silently ignored
  const p = Object.assign({id: uid(PRESETS), name: presetName()}, snap);
  PRESETS.push(p); kv.set(PKEY, PRESETS); loadedPreset = p.id; presetSelId = String(p.id); refreshSelects();
}
function loadPreset(){
  const el = $("presetSel"), id = (el && el.value) || presetSelId;
  const p = PRESETS.find(x => String(x.id) === String(id)); if(!p) return;
  S = Object.assign(defaults(), migrate(p.state)); FRESH = p.prompts.map(x => ({text: x.text, meta: x.meta}));
  loadedPreset = p.id; presetSelId = String(p.id); save(); render(); renderOut();
}

// ----- saves for locked / clipboard prompts -----
const pinSnap = () => PINNED.map(p => ({text: p.text, meta: p.meta, locked: p.locked, src: p.src}));
function saveLocks(){
  if(!PINNED.length) return;
  const items = pinSnap(), cur = LSAVES.find(x => x.id === loadedLock);
  if(cur && canon(cur.items) === canon(items)) return;         // identical to the loaded save: silently ignored
  const x = {id: uid(LSAVES), name: stamp(), items};
  LSAVES.push(x); kv.set(LKEY, LSAVES); loadedLock = x.id; lockSelId = String(x.id); refreshSelects();
}
function loadLocks(){
  const el = $("lockSel"), id = (el && el.value) || lockSelId;
  const x = LSAVES.find(v => String(v.id) === String(id)); if(!x) return;
  PINNED = x.items.map(i => Object.assign({}, i)); loadedLock = x.id; lockSelId = String(x.id); renderOut();
}

function deletePreset(){
  const el = $("presetSel"), id = (el && el.value) || presetSelId;
  const i = PRESETS.findIndex(x => String(x.id) === String(id)); if(i < 0) return;
  const gone = PRESETS.splice(i, 1)[0]; if(loadedPreset === gone.id) loadedPreset = null;
  kv.set(PKEY, PRESETS); presetSelId = newest(PRESETS); refreshSelects();
}
function deleteLocks(){
  const el = $("lockSel"), id = (el && el.value) || lockSelId;
  const i = LSAVES.findIndex(x => String(x.id) === String(id)); if(i < 0) return;
  const gone = LSAVES.splice(i, 1)[0]; if(loadedLock === gone.id) loadedLock = null;
  kv.set(LKEY, LSAVES); lockSelId = newest(LSAVES); refreshSelects();
}

// ----- prompt reader: clipboard text -> interface state + a pinned clipboard prompt -----
const norm = s => s.split(",").map(x => x.trim().toLowerCase()).filter(Boolean);
const hasAll = (T, tags) => { const n = norm(tags); return n.length > 0 && n.every(x => T.has(x)); };
const best = (cands, T) => {                                    // full match with the most distinct tags wins
  let top = null, topN = 0;
  cands.forEach(c => { const n = new Set(norm(c.tags)).size; if(n > topN && hasAll(T, c.tags)){ top = c; topN = n; } });
  return top;
};
function applyTokens(T){
  const radio = (id, fallback) => { const c = best(GM[id].opts.map((o,i) => ({i, tags: txt(o)})), T); S[id] = c ? c.i : fallback; };
  const checks = id => { S[id] = GM[id].opts.map((o,i) => hasAll(T, txt(o)) ? i : -1).filter(i => i >= 0); };
  radio("girl_state", 0); radio("pos", -1);
  const lc = best(GM.loc.opts.map((o,i) => ({i, tags: txt(o)})), T); S.locOn = !!lc; if(lc) S.loc = lc.i;   // no scenery in the prompt -> location off
  ["girl_detail","girl_extra","boy","legs","sens","cam","cumx","defl"].forEach(checks);
  const dog = S.pos >= 0 && txt(GM.pos.opts[S.pos]) === "doggystyle";               // doggystyle keeps forcing arms to none
  const a = best(GM.arms.opts.map((o,i) => ({i, tags: txt(o)})), T); S.arms = dog ? -1 : (a ? a.i : -1);
  // act (one choice incl. sub modules); entering it applies the usual "faceless nude male" auto-enable rule
  const cands = [];
  ACTS.forEach(x => { if(x.sub) SUBS[x.k].tags.forEach((row,t) => row.forEach((tags,xi) => cands.push({k: x.k, t, x: xi, tags}))); else if(x.t) cands.push({k: x.k, tags: x.t}); });
  const pick = best(cands, T);
  S.acts.kind = "none";
  if(pick){ if(pick.t !== undefined){ S.acts[pick.k].t = pick.t; S.acts[pick.k].x = pick.x; } setAct(pick.k); }
  // panties
  const P = S.pants;
  if(T.has("no panties") || T.has("nopan")){ P.mode = 1; P.aside = false; }
  else {
    const w = hasAll(T, "panties, lace_panties, white panties"), b = hasAll(T, "panties, lace_panties, black panties"), as = T.has("panties aside");
    if(w || b || as){ P.mode = 0; if(w) P.color = 0; if(b) P.color = 1; P.aside = as; }
  }
  // surface, cum, quality
  const si = GM.surf.opts.findIndex(o => T.has(txt(o))); S.surf.on = si >= 0; if(si >= 0) S.surf.i = si;
  if(T.has("night")) S.night = true; else if(T.has("day")) S.night = false;
  if(T.has("snow")) S.wx = 2; else if(T.has("rain")) S.wx = 1; else if(T.has("light rays")) S.wx = 0;
  S.soft = T.has("soft light");
  if(S.locOn) saveLive();
  S.cum.on = false;
  if(hasAll(T, "cum, cum overflow, ejaculation")){ S.cum.on = true; S.cum.i = 1; } else if(T.has("precum")){ S.cum.on = true; S.cum.i = 0; }
  if(T.has("(detailed_hands:1.5)")) S.ph = [0]; else if(T.has("detailed_hands")) S.ph = [];
  S.cycle = S.mix = S.mClose = S.mAll = false;                                      // a single prompt: no multi-prompt modes
}
function importPrompt(text){
  applyTokens(new Set(text.split(/[,\n]+/).map(t => t.trim().toLowerCase()).filter(Boolean)));
  const entry = {text, meta: "", locked: false, src: "clip"};
  const k = PINNED.findIndex(p => p.src === "clip" && !p.locked);                  // one clipboard slot: overwrite it
  if(k >= 0) PINNED[k] = entry; else PINNED.push(entry);
  save(); render(); generate();
}
async function readClip(){
  let text = "";
  try{ text = await navigator.clipboard.readText(); }
  catch(e){ text = window.prompt("Could not read the clipboard automatically. Paste the prompt here:") || ""; }
  text = (text || "").trim(); if(text) importPrompt(text);
}

// ----- prompts header -----
function buildHeader(){
  const hdr = $("hdr"); hdr.replaceChildren();
  const sel = document.createElement("select"); sel.id = "lockSel"; sel.title = "saved locked prompts";
  sel.onchange = () => { lockSelId = sel.value; };
  const cnt = document.createElement("small"); cnt.id = "cnt";
  hdr.append(sel,
    sym("lockLoad", "▲", "ld", "load the selected locked-prompt save", loadLocks),
    sym("lockSave", "▼", "sv", "save the locked prompts", saveLocks),
    sym("lockDel", "∅", "dl", "delete the selected locked-prompt save", deleteLocks),
    sym("lockClear", "⚡", "", "clear all locked and clipboard prompts", () => { PINNED = []; renderOut(); }),
    sym("readClip", "☉", "rd", "read a prompt from the clipboard", readClip),
    sym("reset", "❌", "", "reset selections (all location settings stay)", () => { const keep = {loc: S.loc, locOn: S.locOn, night: S.night, wx: S.wx, soft: S.soft, surf: S.surf, locMem: S.locMem}; S = Object.assign(defaults(), keep); save(); render(); generate(); }),
    sym("reroll", "↻", "rr", "reroll", () => generate()),
    cnt);
  fillSel(sel, LSAVES, lockSelId);
}
// ----- custom tags: a tag is committed by a trailing comma; stars keep a tag when it leaves the text line -----
const ckey = t => t.toLowerCase();
function parseCommitted(text){                       // only tags followed by a comma; the tail after the last comma is still being typed
  const i = text.lastIndexOf(",");
  if(i < 0) return [];
  const seen = new Set(), out = [];
  text.slice(0, i).split(",").map(x => x.trim()).filter(Boolean).forEach(t => { const k = ckey(t); if(!seen.has(k)){ seen.add(k); out.push(t); } });
  return out;
}
function syncCustom(){
  const C = S.custom, live = parseCommitted(C.text), liveKeys = new Set(live.map(ckey));
  live.forEach(t => { if(!C.items.some(x => ckey(x.tag) === ckey(t))) C.items.push({tag: t, on: true, star: false}); });
  C.items = C.items.filter(x => x.star || liveKeys.has(ckey(x.tag)));      // unstarred tags vanish with their text
}
function renderCustom(c){
  const input = document.createElement("input"); input.type = "text"; input.id = "customIn"; input.className = "txt";
  input.placeholder = "tag one, tag two,"; input.value = S.custom.text; input.autocomplete = "off"; input.spellcheck = false;
  const list = document.createElement("div"); list.id = "customList"; list.className = "clist";
  const draw = () => {
    list.replaceChildren();
    S.custom.items.forEach((it, i) => {
      const row = document.createElement("div"); row.className = "crow";
      const l = document.createElement("label"); l.className = "o";
      const cb = document.createElement("input"); cb.type = "checkbox"; cb.name = "cust" + i; cb.checked = it.on;
      cb.onchange = () => { it.on = cb.checked; save(); generate(); };
      const sp = document.createElement("span"); sp.textContent = it.tag;
      l.append(cb, sp);
      const st = document.createElement("button"); st.type = "button"; st.className = "star" + (it.star ? " on" : ""); st.textContent = "★";
      st.title = it.star ? "unlock: removed once the tag is gone from the text line" : "lock: keep this tag even if it is removed from the text line";
      st.onclick = () => { it.star = !it.star; syncCustom(); save(); draw(); generate(); };
      row.append(l, st); list.append(row);
    });
  };
  input.oninput = () => { S.custom.text = input.value; syncCustom(); save(); draw(); generate(); };   // no full re-render: typing keeps focus
  c.append(input, note("finish a tag with a comma to add it; ★ keeps it"), list); draw();
}
function renderPresets(c){
  const row = document.createElement("div"); row.className = "pset";
  const sel = document.createElement("select"); sel.id = "presetSel"; sel.title = "saved presets";
  sel.onchange = () => { presetSelId = sel.value; };
  row.append(sel, sym("presetLoad", "▲", "ld", "load the selected preset (selections + prompts)", loadPreset), sym("presetSave", "▼", "sv", "save this page (selections + prompts)", savePreset), sym("presetDel", "∅", "dl", "delete the selected preset", deletePreset));
  c.append(row); fillSel(sel, PRESETS, presetSelId);
}

// ===== module ui =====
function mkOpt(type, name, o, checked, onchange, cls){
  const l = document.createElement("label"); l.className = "o " + (cls||"");
  const i = document.createElement("input"); i.type = type; i.name = name; i.checked = checked; i.onchange = onchange;
  const s = document.createElement("span"); s.textContent = lab(o);
  if(typeof o !== "string" && o.l) l.title = o.t;
  l.append(i, s); return l;
}
function note(t){ const h = document.createElement("div"); h.className = "hint"; h.textContent = t; return h; }
function subBox(id, active){
  const sp = SUBS[id], st = S.acts[id];
  const box = document.createElement("div"); box.className = "subbox" + (active ? "" : " off");
  const set = (key,i)=>{ st[key] = i; setAct(id); if(key === "x" && GENITAL_TARGETS.includes(sp.target[i]) && !S.girl_detail.includes(0)) S.girl_detail = [0]; save(); render(); generate(); };
  const side = document.createElement("div"); side.className = "side";
  const sub = (name, n, checked, fn) => { const l = mkOpt("radio", name, n, checked, null); l.querySelector("input").onclick = fn; return l; };
  sp.timing.forEach((n,i)=> side.append(sub(id+"_t", n, st.t===i, ()=>set("t",i))));
  box.append(side);
  sp.target.forEach((n,i)=> box.append(sub(id+"_x", n, st.x===i, ()=>set("x",i))));
  return box;
}
function renderPants(c){
  const P = S.pants;
  const done = () => { save(); render(); generate(); };
  // click (not change) so re-clicking an already-selected option still enables pantsu
  const radio = (name, label, checked, fn) => { const l = mkOpt("radio", name, label, checked, null); l.querySelector("input").onclick = () => { fn(); done(); }; return l; };
  const wrap = document.createElement("div");
  wrap.append(radio("pants_m", "pantsu", P.mode===0, ()=>{ P.mode = 0; }));
  const inner = document.createElement("div"); inner.className = "subbox" + (P.mode===0 ? "" : " off");
  PANTS_COLORS.forEach((n,i)=> inner.append(radio("pants_c", n, P.color===i, ()=>{ P.color = i; P.mode = 0; })));
  const aside = mkOpt("checkbox", "pants_aside", "aside", P.aside, null);
  aside.querySelector("input").onclick = e => { P.aside = e.target.checked; if(P.aside) P.mode = 0; done(); };
  inner.append(aside); wrap.append(inner);
  wrap.append(radio("pants_m", "nopan", P.mode===1, ()=>{ P.mode = 1; }));
  c.append(wrap);
}
function renderLoc(c, g){                           // scenery dropdown + checkbox, and the three switches underneath
  const row = document.createElement("div"); row.className = "pset";
  const cb = document.createElement("input"); cb.type = "checkbox"; cb.name = "loc_use"; cb.checked = !!S.locOn; cb.title = "use a location";
  cb.onchange = () => { S.locOn = cb.checked; if(S.locOn) loadLive(S.loc); save(); render(); generate(); };   // switching back on restores the last used state
  const sel = document.createElement("select"); sel.id = "locSel"; sel.disabled = !S.locOn;
  g.opts.forEach((o,i) => { const op = document.createElement("option"); op.textContent = lab(o); op.value = String(i); op.selected = S.loc === i; sel.append(op); });
  sel.onchange = () => { if(S.locOn) saveLive(); S.loc = +sel.value; if(S.locOn) loadLive(S.loc); save(); render(); generate(); };
  row.append(cb, sel); c.append(row);
  const mk = (cls, ch, title, fn) => { const b = document.createElement("button"); b.type = "button"; b.className = "sym sw " + cls; b.textContent = ch; b.title = title; b.onclick = fn; return b; };
  const WX = [["☀", "light rays"], ["☂", "rain"], ["❆", "snow"]];
  const sw = document.createElement("div"); sw.className = "sws";
  sw.append(
    mk("night" + (S.night ? " on" : ""), "☾", S.night ? "night (adds night,): click for day" : "day (adds day,): click for night", toggleNight),
    mk("wx" + S.wx, WX[S.wx][0], "weather: " + WX[S.wx][1] + " (click to cycle light rays / rain / snow)", () => { S.wx = (S.wx + 1) % 3; commitLoc(); }),
    mk("soft" + (S.soft ? " on" : ""), "①", S.soft ? "soft light on" : "soft light off", () => { S.soft = !S.soft; commitLoc(); }));
  c.append(sw);
}
function renderSurf(c, g){                          // surface dropdown + checkbox
  const row = document.createElement("div"); row.className = "pset";
  const cb = document.createElement("input"); cb.type = "checkbox"; cb.name = "surf_on"; cb.checked = !!S.surf.on; cb.title = "use a surface";
  cb.onchange = () => { S.surf.on = cb.checked; commitLoc(); };
  const sel = document.createElement("select"); sel.id = "surfSel"; sel.disabled = !S.surf.on;
  g.opts.forEach((o,i) => { const op = document.createElement("option"); op.textContent = lab(o); op.value = String(i); op.selected = S.surf.i === i; sel.append(op); });
  sel.onchange = () => { S.surf.i = +sel.value; if(S.locOn) saveLive(); save(); generate(); };
  row.append(cb, sel); c.append(row);
}
function renderActs(c){
  ACTS.forEach(a=>{
    const o = a.k==="none" ? "none" : a.l ? {l:a.l, t:a.t||""} : a.t;
    c.append(mkOpt("radio", "act", o, S.acts.kind===a.k, ()=>{ setAct(a.k); save(); render(); generate(); }, a.k==="none"?"none":""));
    if(a.sub) c.append(subBox(a.k, S.acts.kind===a.k));
  });
}
function render(){
  const root = $("mods"); root.replaceChildren();
  const cards = {};
  const place = (m, c) => { cards[m.name] = c; };
  MODULES.forEach(m=>{
    const c = document.createElement("div"); c.className = "mod";
    const h = document.createElement("h2"); h.textContent = m.name; c.append(h);
    if(m.custom==="acts"){ renderActs(c); place(m, c); return; }
    if(m.custom==="presets"){ renderPresets(c); place(m, c); return; }
    if(m.custom==="custom"){ renderCustom(c); place(m, c); return; }
    m.groups.forEach(g=>{
      if(g.sub){ const s = document.createElement("div"); s.className = "sub"; s.textContent = g.sub; c.append(s); }
      if(g.type==="pants"){ renderPants(c); return; }
      if(g.type==="select"){ renderLoc(c, g); return; }
      if(g.type==="surface"){ renderSurf(c, g); return; }
      const locked = g.id === "arms" && radioText("pos") === "doggystyle";   // doggystyle forces arms to none
      const v = locked ? -1 : S[g.id], dis = (locked || (g.disIf && S[g.disIf])) ? "dis" : "";
      if(g.type==="radio"){
        if(!g.noNone) c.append(mkOpt("radio", g.id, "none", v===-1, ()=>{S[g.id]=-1;save();render();generate()}, "none "+dis));
        g.opts.forEach((o,i)=> c.append(mkOpt("radio", g.id, o, v===i, ()=>{S[g.id]=i;save();render();generate()}, dis)));
      } else if(g.type==="check"){
        g.opts.forEach((o,i)=> c.append(mkOpt("checkbox", g.id+i, o, v.includes(i), e=>{
          const cur = S[g.id]; S[g.id] = e.target.checked ? [...cur.filter(x=>x!==i), i].sort((a,b)=>a-b) : cur.filter(x=>x!==i); save(); generate(); }, (g.id==="cam" && (S.mClose||S.mAll) && txt(o)!=="internal") ? "dis" : "")));
      } else {
        const box = document.createElement("div"); box.className = "gate";
        box.append(mkOpt("checkbox", g.id+"_on", g.gate, v.on, e=>{ v.on = e.target.checked; save(); render(); generate(); }));
        const inner = document.createElement("div"); inner.className = "inner" + (v.on ? "" : " dis");
        g.opts.forEach((o,i)=> inner.append(mkOpt("radio", g.id, o, v.i===i, ()=>{ v.i = i; save(); generate(); })));
        box.append(inner); c.append(box);
      }
    });
    const extras = m.extras || (m.extra ? [m.extra] : []);
    if(extras.length){
      const lockedX = m.name === "Arms" && radioText("pos") === "doggystyle";
      const box = document.createElement("div"); box.className = "gate" + (lockedX ? " dis" : "");
      extras.forEach(ex=>{
        box.append(mkOpt("checkbox", ex.key, ex.label, S[ex.key], e=>{ S[ex.key] = e.target.checked; save(); render(); generate(); }));
        box.append(note(lockedX ? "locked: doggystyle forces arms to none" : ex.hint));
      });
      c.append(box);
    }
    if(m.hint) c.append(note(m.hint));
    place(m, c);
  });
  const placed = new Set(LAYOUT.flat());
  LAYOUT.forEach((names, k)=>{
    const col = document.createElement("div"); col.className = "col";
    names.forEach(n => cards[n] && col.append(cards[n]));
    if(k === LAYOUT.length-1) MODULES.forEach(m => { if(!placed.has(m.name)) col.append(cards[m.name]); }); // anything not listed goes last
    root.append(col);
  });
}

load(); buildHeader(); render(); generate();

  }

  // ---------- overlay ----------
  let shell = null;
  const isOpen = () => shell && shell.style.display !== "none";
  function close() { if (shell) shell.style.display = "none"; }
  function open() {
    if (!shell) shell = buildOverlay();
    shell.style.display = "block";
  }
  function buildOverlay() {
    const host = document.createElement("div");
    host.style.cssText = "position:fixed;inset:0;z-index:2147483647;display:none";
    const sh = host.attachShadow({ mode: "closed" });
    const style = document.createElement("style"); style.textContent = TOOL_CSS;
    const x = h("button", { class: "x", "aria-label": "Close", type: "button" }, "\u2715");
    const layout = h("div", { class: "layout" },
      h("div", { class: "mods", id: "mods" }),
      h("div", { class: "outp" },
        h("div", { class: "row", id: "hdr" }),
        h("div", { id: "outs" })),
      h("div", { class: "xcol" }, x));
    const panel = h("div", { class: "panel" }, layout);
    const back = h("div", { class: "back" }, panel);
    sh.append(style, back);
    x.onclick = close;
    back.addEventListener("mousedown", e => { if (e.target === back) close(); });
    host.addEventListener("keydown", e => e.stopPropagation());
    document.body.append(host);
    initTool(id => sh.getElementById(id));
    return host;
  }
  document.addEventListener("keydown", e => {
    if (isOpen() && e.key === "Escape") { e.stopPropagation(); e.preventDefault(); close(); }
  }, true);

  // ---------- launcher button inside the prompt box ----------
  // Anchors on stable attributes, not Tailwind classes: the bottom row is the closest
  // common ancestor of the "+" (add image) button and the generate button.
  function findRow() {
    for (const comp of document.querySelectorAll('section[data-share-guide="composer"]')) {
      if (!comp.getClientRects().length) continue;               // skip hidden copies
      const add = comp.querySelector('[data-role="add-image"]');
      const gen = comp.querySelector('[data-share-guide="generate"]');
      if (!add || !gen) continue;
      let el = add.parentElement;
      while (el && el !== comp && !el.contains(gen)) el = el.parentElement;
      if (el && el !== comp) return el;
    }
    return null;
  }
  let btnHost = null;
  function ensureButton() {
    const row = findRow();
    if (!row) return;
    if (!btnHost) {
      btnHost = document.createElement("div");
      const sh = btnHost.attachShadow({ mode: "closed" });
      const st = document.createElement("style"); st.textContent = BTN_CSS;
      const b = h("button", { type: "button" }, "Prompt Gen");
      b.onclick = open;
      sh.append(st, b);
    }
    if (btnHost.parentElement !== row) {
      if (getComputedStyle(row).position === "static") row.style.position = "relative";
      row.append(btnHost);
    }
  }
  let queued = false;
  new MutationObserver(() => {
    if (queued) return;
    queued = true;
    requestAnimationFrame(() => { queued = false; ensureButton(); });
  }).observe(document.body, { childList: true, subtree: true });
  ensureButton();
})();