tag-builder Wave A: backend stability + cleanup + customizer/timer frontend fixes

Backend:
- H4 get_db reconnect storm: gate the one-time schema/normalize/reconcile helpers
  behind a process-level flag so the ~11,529-row reconcile stops re-running on the
  asyncio loop on every spurious mtime reopen.
- H5 tag_search write-on-read: cache last-good fingerprint (return it on transient
  lock instead of zeros -> no spurious 5-10 min re-embed) + mtime-gate the query.
- Remove dead /clothing/assemble + /furniture/assemble routes.
- Escape LIKE %/_ in 3 character-prefix lookups; NULL-guard /props/assemble.
- New POST /tag-builder/maintenance (idempotent orphan cleanup + alias dedup).

Frontend:
- Customizer output-mode: thread real isNaturalMode into the 3 customizers; preview
  matches the actual emit per mode; focus checkbox disabled (not silent no-op) in
  tag mode.
- Event-driven timers: drop 3 setTimeout(0) click-outside deferrals + the 1100ms
  flash timer (animationend instead).
This commit is contained in:
chrmc40
2026-06-28 22:44:30 -07:00
parent 4d8539ad92
commit f0b47753fa
8 changed files with 255 additions and 204 deletions
+106 -143
View File
@@ -23,6 +23,10 @@ _dbg = logging.getLogger("promptchain.ai.debug")
DB_PATH = Path(__file__).parent.parent / "data" / "tag-builder" / "tag-builder.db"
_local = threading.local()
# True once the one-time schema/normalize/reconcile helpers have run this
# process. Gates get_db's mtime-triggered reconnects so the expensive
# ~11,529-row reconcile doesn't re-run on every spurious reopen.
_schema_ready = False
def get_db() -> sqlite3.Connection:
@@ -50,11 +54,28 @@ def get_db() -> sqlite3.Connection:
# FK enforcement is off by default in sqlite, so CASCADE deletes were
# inert and orphan preset rows accumulated. Per-connection pragma.
conn.execute("PRAGMA foreign_keys=ON")
_ensure_generic_outfits_schema(conn)
_normalize_full_outfits_sort_order(conn)
_normalize_scene_locations_sort_order(conn)
_ensure_character_appearance_chip_columns(conn)
_reconcile_character_base_tags(conn)
# These schema/normalize/reconcile helpers are idempotent but
# expensive — the reconcile walks ~11,529 character rows. get_db
# reconnects on every DB-file mtime change, and sqlite's delete
# journal self-bumps that mtime on internal commits, so without
# gating they re-ran on the asyncio loop on roughly every other
# request while editing. Run them once per process; the request-
# time appearance-chip fallback covers a char added externally
# mid-session, and a restart re-runs them to refresh the cache.
global _schema_ready
if not _schema_ready:
_ensure_generic_outfits_schema(conn)
_normalize_full_outfits_sort_order(conn)
_normalize_scene_locations_sort_order(conn)
_ensure_character_appearance_chip_columns(conn)
_reconcile_character_base_tags(conn)
_schema_ready = True
# The helpers commit, bumping the watched mtime; re-read it so
# their own write doesn't immediately trigger another reconnect.
try:
current_mtime = DB_PATH.stat().st_mtime_ns
except OSError:
pass
_local.conn = conn
_local.mtime = current_mtime
return _local.conn
@@ -3110,6 +3131,62 @@ def _assert_allowed_table(name: str) -> str:
return name
def _run_data_health(db: sqlite3.Connection) -> dict:
"""Idempotent maintenance: drop orphaned preset rows (FK enforcement
was historically off) and de-duplicate tag_aliases. Returns per-step
removal counts. Each step is independent and guarded so a missing
table or schema drift skips that step instead of failing the run."""
cleaned: dict[str, int] = {}
def _exists(table: str) -> bool:
return db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
).fetchone() is not None
# (child, fk_col, parent, parent_key) — parents first so a parent
# delete's now-stranded children are caught in the same pass.
orphan_rules = [
("outfits", "character_tag", "characters", "tag"),
("poses", "character_tag", "characters", "tag"),
("character_chip_overrides", "character_tag", "characters", "tag"),
("outfit_tag_slots", "outfit_id", "outfits", "id"),
("outfit_chip_overrides", "outfit_id", "outfits", "id"),
("pose_chip_overrides", "pose_id", "poses", "id"),
]
for child, fk, parent, key in orphan_rules:
if not (_exists(child) and _exists(parent)):
continue
try:
cur = db.execute(
f"DELETE FROM {child} WHERE {fk} IS NOT NULL "
f"AND {fk} NOT IN (SELECT {key} FROM {parent})"
)
cleaned[f"orphan_{child}"] = cur.rowcount
except sqlite3.OperationalError:
pass
# tag_aliases has a (tag, alias) primary key so exact dupes can't exist,
# but keep the dedup as a cheap self-heal in case the PK was ever absent.
if _exists("tag_aliases"):
try:
cur = db.execute(
"DELETE FROM tag_aliases WHERE rowid NOT IN "
"(SELECT MIN(rowid) FROM tag_aliases GROUP BY tag, alias)"
)
cleaned["tag_alias_duplicates"] = cur.rowcount
except sqlite3.OperationalError:
pass
db.commit()
return cleaned
@routes.post("/promptchain/tag-builder/maintenance")
async def _api_tag_builder_maintenance(request):
"""Run idempotent data-health maintenance and report what was removed."""
return web.json_response({"cleaned": _run_data_health(get_db())})
@routes.get("/promptchain/tag-builder/buckets")
async def _api_buckets(request):
db = get_db()
@@ -4795,7 +4872,9 @@ def match_characters_inner(
name_prefix_candidates.setdefault(token.lower(), original)
if name_prefix_candidates:
for prefix, original in name_prefix_candidates.items():
like_pattern = prefix + r"\_%"
# Escape LIKE metacharacters so a name with % or _ doesn't wildcard-match.
esc = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
like_pattern = esc + r"\_%"
prefix_rows = db.execute(
"SELECT tag, display, series, base_tags, base_natlang FROM characters "
"WHERE LOWER(tag) LIKE ? ESCAPE '\\' "
@@ -4845,7 +4924,9 @@ def match_characters_inner(
paren_suffix_candidates.setdefault(bare_name.lower(), original)
if paren_suffix_candidates:
for prefix, original in paren_suffix_candidates.items():
like_pattern = prefix + r"\_%"
# Escape LIKE metacharacters so a name with % or _ doesn't wildcard-match.
esc = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
like_pattern = esc + r"\_%"
prefix_rows = db.execute(
"SELECT tag, display, series, base_tags, base_natlang FROM characters "
"WHERE LOWER(tag) = ? OR LOWER(tag) LIKE ? ESCAPE '\\' "
@@ -4889,7 +4970,9 @@ def match_characters_inner(
first_segment_candidates.setdefault(bare_name.lower(), original)
if first_segment_candidates:
for prefix, original in first_segment_candidates.items():
like_pattern = prefix + r"\_%"
# Escape LIKE metacharacters so a name with % or _ doesn't wildcard-match.
esc = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
like_pattern = esc + r"\_%"
prefix_rows = db.execute(
"SELECT tag, display, series, base_tags, base_natlang FROM characters "
"WHERE LOWER(tag) = ? OR LOWER(tag) LIKE ? ESCAPE '\\' "
@@ -5374,28 +5457,34 @@ async def _api_props_assemble(request):
tags_parts = []
natlang_parts = []
if color:
if color and color.get("tag"):
tags_parts.append(color["tag"])
if color.get("prefix"):
natlang_parts.append(color["prefix"])
if pattern and pattern["tag"] != "solid":
if pattern and pattern.get("tag") and pattern["tag"] != "solid":
tags_parts.append(pattern["tag"])
if pattern.get("prefix"):
natlang_parts.append(pattern["prefix"])
if material:
if material and material.get("tag"):
tags_parts.append(material["tag"])
if material.get("prefix"):
natlang_parts.append(material["prefix"])
tags_parts.append(prop["prop_tag"])
natlang_parts.append(prop.get("base_natlang") or prop["display_name"].lower())
if prop.get("prop_tag"):
tags_parts.append(prop["prop_tag"])
_display = prop.get("display_name")
_phrase = prop.get("base_natlang") or (_display.lower() if isinstance(_display, str) else None)
if _phrase:
natlang_parts.append(_phrase)
prop_tags = " ".join(tags_parts)
prop_natlang = " ".join(natlang_parts)
prop_tags = " ".join(str(p) for p in tags_parts if p)
prop_natlang = " ".join(str(p) for p in natlang_parts if p)
if action:
final_tags = f"{action['action_prefix_tags']} {prop_tags}"
final_natlang = f"{action['action_prefix_natlang']} {prop_natlang}"
prefix_tags = action.get("action_prefix_tags") or ""
prefix_natlang = action.get("action_prefix_natlang") or ""
final_tags = f"{prefix_tags} {prop_tags}".strip()
final_natlang = f"{prefix_natlang} {prop_natlang}".strip()
else:
final_tags = prop_tags
final_natlang = prop_natlang
@@ -5534,74 +5623,6 @@ async def _api_clothing_customizer_data(request):
})
@routes.post("/promptchain/clothing/assemble")
async def _api_clothing_assemble(request):
"""Build tag and natlang strings from selected clothing parts."""
data, err = await parse_json(request)
if err: return err
item_tag = data.get("item_tag", "")
color_tag = data.get("color_tag", "")
material_tag = data.get("material_tag", "")
pattern_tag = data.get("pattern_tag", "")
condition_tag = data.get("condition_tag", "")
db = get_db()
tag_parts = []
natlang_parts = []
# Color
if color_tag:
color = db.execute(
"SELECT * FROM clothing_colors WHERE tag = ?", (color_tag,)
).fetchone()
if color:
tag_parts.append(color["prefix"])
natlang_parts.append(color["prefix"])
# Material
if material_tag:
material = db.execute(
"SELECT * FROM clothing_materials WHERE tag = ?", (material_tag,)
).fetchone()
if material:
tag_parts.append(material["prefix"])
natlang_parts.append(material["prefix"])
# Pattern
if pattern_tag:
pattern = db.execute(
"SELECT * FROM clothing_patterns WHERE tag = ?", (pattern_tag,)
).fetchone()
if pattern:
tag_parts.append(pattern["prefix"])
natlang_parts.append(pattern["prefix"])
# Condition
if condition_tag:
condition = db.execute(
"SELECT * FROM clothing_conditions WHERE tag = ?", (condition_tag,)
).fetchone()
if condition:
tag_parts.append(condition["prefix"])
natlang_parts.append(condition["prefix"])
# Clothing item itself
if item_tag:
item = db.execute(
"SELECT * FROM clothing_items WHERE item_tag = ?", (item_tag,)
).fetchone()
if item:
tag_parts.append(item["base_tags"])
natlang_parts.append(item["base_natlang"])
tag = ", ".join(p for p in tag_parts if p)
natlang = " ".join(p for p in natlang_parts if p)
return web.json_response({"tag": tag, "natlang": natlang})
@routes.get("/promptchain/clothing/customizable-groups")
async def _api_clothing_customizable_groups(request):
"""Return clothing groups and items for customizable groups only."""
@@ -5821,61 +5842,3 @@ async def _api_furniture_all(request):
"patterns": patterns,
"colors": colors,
})
@routes.post("/promptchain/furniture/assemble")
async def _api_furniture_assemble(request):
"""Build tag and natlang strings from selected furniture parts."""
data, err = await parse_json(request)
if err: return err
furniture_tag = data.get("furniture_tag", "")
material_tag = data.get("material_tag", "")
pattern_tag = data.get("pattern_tag", "")
color_tag = data.get("color_tag", "")
db = get_db()
tag_parts = []
natlang_parts = []
# Color
if color_tag:
color = db.execute(
"SELECT * FROM furniture_colors WHERE tag = ?", (color_tag,)
).fetchone()
if color:
tag_parts.append(color["prefix"])
natlang_parts.append(color["prefix"])
# Material
if material_tag:
material = db.execute(
"SELECT * FROM furniture_materials WHERE tag = ?", (material_tag,)
).fetchone()
if material:
tag_parts.append(material["prefix"])
natlang_parts.append(material["prefix"])
# Pattern
if pattern_tag:
pattern = db.execute(
"SELECT * FROM furniture_patterns WHERE tag = ?", (pattern_tag,)
).fetchone()
if pattern:
tag_parts.append(pattern["prefix"])
natlang_parts.append(pattern["prefix"])
# Furniture item itself
if furniture_tag:
item = db.execute(
"SELECT * FROM furniture WHERE tag = ?", (furniture_tag,)
).fetchone()
if item:
tag_parts.append(item["base_tags"])
natlang_parts.append(item["base_natlang"])
tag = ", ".join(p for p in tag_parts if p)
natlang = " ".join(p for p in natlang_parts if p)
return web.json_response({"tag": tag, "natlang": natlang})
+47 -5
View File
@@ -68,6 +68,14 @@ _state: dict[str, Any] = {
"fingerprint": None,
}
# A transient DB lock made _fingerprint() return zeros, which read as
# "table changed" and kicked off a 5–10 min CPU re-embed. Cache the last
# good fingerprint + the DB mtime it was read at: return the cached value
# on error (never treat a read failure as a change), and skip the recompute
# entirely while the file is unchanged.
_last_successful_fingerprint: tuple | None = None
_last_db_mtime: float | None = None
def _open_db() -> sqlite3.Connection:
conn = sqlite3.connect(str(DB_PATH))
@@ -151,7 +159,12 @@ def _fingerprint() -> tuple:
table state. Changes when wiki rows are added/removed/refreshed
(body_hash sum) OR when aliases are added/removed/edited. Either
forces a rebuild — alias changes shift the embedded body_full
text so cached embeddings need to be regenerated."""
text so cached embeddings need to be regenerated.
On a transient DB lock / read error, returns the last successful
fingerprint instead of zeros, so a momentary lock can't masquerade
as a table change and trigger a spurious rebuild."""
global _last_successful_fingerprint
try:
conn = _open_db()
_ensure_aliases_schema(conn)
@@ -167,11 +180,15 @@ def _fingerprint() -> tuple:
"FROM tag_aliases"
).fetchone()
conn.close()
return (
result = (
wiki_row["m"], wiki_row["c"], wiki_row["h"],
alias_row["c"], alias_row["h"],
)
_last_successful_fingerprint = result
return result
except Exception:
if _last_successful_fingerprint is not None:
return _last_successful_fingerprint
return (0, 0, 0, 0, 0)
@@ -292,7 +309,7 @@ def _load_index_from_disk() -> bool:
logger.info("tag_search: cache model mismatch — rebuilding")
return False
live_fp = _fingerprint()
live_fp = _get_fingerprint_cached()
cached_fp = tuple(manifest.get("fingerprint") or ())
if cached_fp != live_fp:
logger.info(
@@ -365,10 +382,26 @@ def _rebuild_index(on_status: Callable[[str], None] | None = None) -> None:
_persist_index_to_disk()
def _get_fingerprint_cached() -> tuple:
"""Gate the expensive _fingerprint() (DDL + alias seed-sync + two
aggregate scans) on the DB file mtime. The fingerprint only changes
when the file changes, so while mtime is steady — the common case for
a search — return the cached value and skip the query entirely."""
global _last_db_mtime
try:
current_mtime = DB_PATH.stat().st_mtime
except Exception:
return _fingerprint()
if _last_db_mtime == current_mtime and _last_successful_fingerprint is not None:
return _last_successful_fingerprint
_last_db_mtime = current_mtime
return _fingerprint()
def _ensure_index_fresh(on_status: Callable[[str], None] | None = None) -> None:
"""Try disk first, then rebuild. Disk load is fast; rebuild is the
minutes-long path."""
if _state["embeddings"] is not None and _state["fingerprint"] == _fingerprint():
if _state["embeddings"] is not None and _state["fingerprint"] == _get_fingerprint_cached():
return
if _load_index_from_disk():
return
@@ -501,8 +534,17 @@ def _row_for_tag(tag: str) -> dict | None:
def warmup() -> None:
"""Best-effort eager load on server boot. Disk-cache hit is < 1s;
cold rebuild is ~5–10 min on CPU and is the only thing that takes
real time here."""
real time here.
Seeds the alias lookup cache up front so the literal alias_scan path
doesn't hit the DB on every search, and warms the fingerprint cache so
early searches can't trip a spurious rebuild on a transient lock."""
import time
try:
with _lock:
_alias_lookup_list()
except Exception:
logger.warning("tag_search: alias cache seed failed", exc_info=True)
for attempt in range(3):
with _lock:
if _embed_model.get() is not None:
@@ -5458,6 +5458,7 @@
cursor: pointer;
}
.pcr-atb2-cust-focus.svelte-1mwxie1 input:where(.svelte-1mwxie1) { accent-color: #7c3aed; }
.pcr-atb2-cust-focus-disabled.svelte-1mwxie1 { opacity: 0.5; cursor: default; }
.pcr-atb2-cust-focus-hint.svelte-1mwxie1 {
font-size: 11px; color: #888; margin-left: auto;
}
+61 -28
View File
@@ -21,7 +21,7 @@ var root_14$3 = from_html(`<button class="pcr-atb2-cust-rowclear svelte-1mwxie1"
var root_17$2 = from_html(`<div><span class="pcr-atb2-cust-icon-bullet svelte-1mwxie1"></span> <span> </span></div>`);
var root_16$2 = from_html(`<div class="pcr-atb2-cust-popgroup svelte-1mwxie1"> </div> <!>`, 1);
var root_15$3 = from_html(`<div class="pcr-atb2-cust-popover svelte-1mwxie1"></div>`);
var root_2$3 = from_html(`<div class="pcr-atb2-cust-body svelte-1mwxie1"><div><button class="pcr-atb2-cust-rowbtn svelte-1mwxie1" type="button"><span class="pcr-atb2-cust-icon pcr-atb2-cust-icon-color svelte-1mwxie1"></span> <span class="pcr-atb2-cust-rowlabel svelte-1mwxie1"> </span> <!> <span class="pcr-atb2-cust-chev svelte-1mwxie1">▾</span></button> <!></div> <div><button class="pcr-atb2-cust-rowbtn svelte-1mwxie1" type="button"><span class="pcr-atb2-cust-icon pcr-atb2-cust-icon-pattern svelte-1mwxie1" aria-hidden="true"><svg viewBox="0 0 16 16" width="16" height="16"><rect x="0" y="0" width="4" height="4" fill="currentColor"></rect><rect x="8" y="0" width="4" height="4" fill="currentColor"></rect><rect x="4" y="4" width="4" height="4" fill="currentColor"></rect><rect x="12" y="4" width="4" height="4" fill="currentColor"></rect><rect x="0" y="8" width="4" height="4" fill="currentColor"></rect><rect x="8" y="8" width="4" height="4" fill="currentColor"></rect><rect x="4" y="12" width="4" height="4" fill="currentColor"></rect><rect x="12" y="12" width="4" height="4" fill="currentColor"></rect></svg></span> <span class="pcr-atb2-cust-rowlabel svelte-1mwxie1"> </span> <!> <span class="pcr-atb2-cust-chev svelte-1mwxie1">▾</span></button> <!></div> <div><button class="pcr-atb2-cust-rowbtn svelte-1mwxie1" type="button"><span class="pcr-atb2-cust-icon pcr-atb2-cust-icon-material svelte-1mwxie1" aria-hidden="true"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M1 5 Q 4 2, 8 5 T 15 5"></path><path d="M1 11 Q 4 8, 8 11 T 15 11"></path></svg></span> <span class="pcr-atb2-cust-rowlabel svelte-1mwxie1"> </span> <!> <span class="pcr-atb2-cust-chev svelte-1mwxie1">▾</span></button> <!></div> <div><button class="pcr-atb2-cust-rowbtn svelte-1mwxie1" type="button"><span class="pcr-atb2-cust-icon pcr-atb2-cust-icon-condition svelte-1mwxie1" aria-hidden="true"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><polyline points="2,4 5,7 3,9 6,12 4,14"></polyline><polyline points="10,2 13,5 11,8 14,11 12,14"></polyline></svg></span> <span class="pcr-atb2-cust-rowlabel svelte-1mwxie1"> </span> <!> <span class="pcr-atb2-cust-chev svelte-1mwxie1">▾</span></button> <!></div> <label class="pcr-atb2-cust-focus svelte-1mwxie1"><input type="checkbox" class="svelte-1mwxie1"/> <span>Add focus tags</span> <span class="pcr-atb2-cust-focus-hint svelte-1mwxie1">Camera focuses on this item</span></label> <div class="pcr-atb2-cust-preview svelte-1mwxie1"><div class="pcr-atb2-cust-preview-label svelte-1mwxie1">Preview</div> <div class="pcr-atb2-cust-preview-text svelte-1mwxie1"> </div></div></div> <div class="pcr-atb2-cust-footer svelte-1mwxie1"><button class="pcr-atb2-cust-btn pcr-atb2-cust-cancel svelte-1mwxie1">Cancel</button> <button class="pcr-atb2-cust-btn pcr-atb2-cust-ok svelte-1mwxie1">Apply</button></div>`, 1);
var root_2$3 = from_html(`<div class="pcr-atb2-cust-body svelte-1mwxie1"><div><button class="pcr-atb2-cust-rowbtn svelte-1mwxie1" type="button"><span class="pcr-atb2-cust-icon pcr-atb2-cust-icon-color svelte-1mwxie1"></span> <span class="pcr-atb2-cust-rowlabel svelte-1mwxie1"> </span> <!> <span class="pcr-atb2-cust-chev svelte-1mwxie1">▾</span></button> <!></div> <div><button class="pcr-atb2-cust-rowbtn svelte-1mwxie1" type="button"><span class="pcr-atb2-cust-icon pcr-atb2-cust-icon-pattern svelte-1mwxie1" aria-hidden="true"><svg viewBox="0 0 16 16" width="16" height="16"><rect x="0" y="0" width="4" height="4" fill="currentColor"></rect><rect x="8" y="0" width="4" height="4" fill="currentColor"></rect><rect x="4" y="4" width="4" height="4" fill="currentColor"></rect><rect x="12" y="4" width="4" height="4" fill="currentColor"></rect><rect x="0" y="8" width="4" height="4" fill="currentColor"></rect><rect x="8" y="8" width="4" height="4" fill="currentColor"></rect><rect x="4" y="12" width="4" height="4" fill="currentColor"></rect><rect x="12" y="12" width="4" height="4" fill="currentColor"></rect></svg></span> <span class="pcr-atb2-cust-rowlabel svelte-1mwxie1"> </span> <!> <span class="pcr-atb2-cust-chev svelte-1mwxie1">▾</span></button> <!></div> <div><button class="pcr-atb2-cust-rowbtn svelte-1mwxie1" type="button"><span class="pcr-atb2-cust-icon pcr-atb2-cust-icon-material svelte-1mwxie1" aria-hidden="true"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M1 5 Q 4 2, 8 5 T 15 5"></path><path d="M1 11 Q 4 8, 8 11 T 15 11"></path></svg></span> <span class="pcr-atb2-cust-rowlabel svelte-1mwxie1"> </span> <!> <span class="pcr-atb2-cust-chev svelte-1mwxie1">▾</span></button> <!></div> <div><button class="pcr-atb2-cust-rowbtn svelte-1mwxie1" type="button"><span class="pcr-atb2-cust-icon pcr-atb2-cust-icon-condition svelte-1mwxie1" aria-hidden="true"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><polyline points="2,4 5,7 3,9 6,12 4,14"></polyline><polyline points="10,2 13,5 11,8 14,11 12,14"></polyline></svg></span> <span class="pcr-atb2-cust-rowlabel svelte-1mwxie1"> </span> <!> <span class="pcr-atb2-cust-chev svelte-1mwxie1">▾</span></button> <!></div> <label><input type="checkbox" class="svelte-1mwxie1"/> <span>Add focus tags</span> <span class="pcr-atb2-cust-focus-hint svelte-1mwxie1"> </span></label> <div class="pcr-atb2-cust-preview svelte-1mwxie1"><div class="pcr-atb2-cust-preview-label svelte-1mwxie1">Preview</div> <div class="pcr-atb2-cust-preview-text svelte-1mwxie1"> </div></div></div> <div class="pcr-atb2-cust-footer svelte-1mwxie1"><button class="pcr-atb2-cust-btn pcr-atb2-cust-cancel svelte-1mwxie1">Cancel</button> <button class="pcr-atb2-cust-btn pcr-atb2-cust-ok svelte-1mwxie1">Apply</button></div>`, 1);
var root$2 = from_html(`<div class="pcr-atb2-cust-overlay svelte-1mwxie1"><div class="pcr-atb2-cust-modal svelte-1mwxie1"><div class="pcr-atb2-cust-header svelte-1mwxie1"><span class="pcr-atb2-cust-title svelte-1mwxie1">Customize <strong class="svelte-1mwxie1"> </strong></span> <button class="pcr-atb2-cust-close svelte-1mwxie1" aria-label="Close">&times;</button></div> <!></div></div>`);
function Customizer($$anchor, $$props) {
var _a, _b, _c, _d, _e;
@@ -32,6 +32,12 @@ function Customizer($$anchor, $$props) {
3,
null
// { color, pattern, material, condition, focus } — pre-fill when editing
), isNaturalMode = prop(
$$props,
"isNaturalMode",
3,
false
// current output mode, so the preview + focus match what the parent emits
), onConfirm = prop($$props, "onConfirm", 3, () => {
}), onCancel = prop($$props, "onCancel", 3, () => {
});
@@ -157,13 +163,20 @@ function Customizer($$anchor, $$props) {
let preview = user_derived(() => {
var _a2, _b2, _c2, _d2;
if (!get(data)) return ($$props.item.display_name || $$props.item.item_tag).toLowerCase();
const parts = [];
if ((_a2 = get(conditionOpt)) == null ? void 0 : _a2.prefix) parts.push(get(conditionOpt).prefix);
if ((_b2 = get(colorOpt)) == null ? void 0 : _b2.prefix) parts.push(get(colorOpt).prefix);
if ((_c2 = get(patternOpt)) == null ? void 0 : _c2.prefix) parts.push(get(patternOpt).prefix);
if ((_d2 = get(materialOpt)) == null ? void 0 : _d2.prefix) parts.push(get(materialOpt).prefix);
parts.push(($$props.item.display_name || $$props.item.item_tag).toLowerCase());
const phrase = parts.join(" ");
const prefixes = [
(_a2 = get(conditionOpt)) == null ? void 0 : _a2.prefix,
(_b2 = get(colorOpt)) == null ? void 0 : _b2.prefix,
(_c2 = get(patternOpt)) == null ? void 0 : _c2.prefix,
(_d2 = get(materialOpt)) == null ? void 0 : _d2.prefix
].filter(Boolean);
if (!isNaturalMode()) {
const slug = (s) => s.replace(/\s+/g, "_");
return [...prefixes.map(slug), $$props.item.item_tag || ""].filter(Boolean).join("_");
}
const phrase = [
...prefixes,
($$props.item.display_name || $$props.item.item_tag).toLowerCase()
].join(" ");
return get(focus) ? `${phrase}, presenting ${phrase} to viewer, ${phrase} focus` : phrase;
});
let isCustomized = user_derived(() => !!get(color) || get(pattern) && get(pattern) !== "solid" || !!get(material) || get(condition) && get(condition) !== "default" || get(focus));
@@ -436,10 +449,13 @@ function Customizer($$anchor, $$props) {
});
}
var label = sibling(div_18, 2);
let classes_10;
var input = child(label);
var span_11 = sibling(input, 4);
var text_12 = child(span_11);
var div_22 = sibling(label, 2);
var div_23 = sibling(child(div_22), 2);
var text_12 = child(div_23);
var text_13 = child(div_23);
var div_24 = sibling(div_4, 2);
var button_9 = child(div_24);
var button_10 = sibling(button_9, 2);
@@ -455,7 +471,10 @@ function Customizer($$anchor, $$props) {
set_text(text_7, ((_c2 = get(materialOpt)) == null ? void 0 : _c2.display) || "Material");
classes_8 = set_class(div_18, 1, "pcr-atb2-cust-row svelte-1mwxie1", null, classes_8, { open: get(openRow) === "condition" });
set_text(text_9, ((_d2 = get(conditionOpt)) == null ? void 0 : _d2.display) || "Default");
set_text(text_12, get(preview));
classes_10 = set_class(label, 1, "pcr-atb2-cust-focus svelte-1mwxie1", null, classes_10, { "pcr-atb2-cust-focus-disabled": !isNaturalMode() });
input.disabled = !isNaturalMode();
set_text(text_12, isNaturalMode() ? "Camera focuses on this item" : "Natural-language mode only");
set_text(text_13, get(preview));
},
[() => colorSwatch(get(color))]
);
@@ -521,6 +540,12 @@ function FurnitureCustomizer($$anchor, $$props) {
3,
null
// { material, pattern, color, action } when editing
), isNaturalMode = prop(
$$props,
"isNaturalMode",
3,
false
// current output mode — preview shows tag vs natlang form to match emit
), onConfirm = prop($$props, "onConfirm", 3, () => {
}), onCancel = prop($$props, "onCancel", 3, () => {
});
@@ -979,7 +1004,7 @@ function FurnitureCustomizer($$anchor, $$props) {
set_text(text_12, contextSubject() && get(action2) ? "Add Interaction" : "Add to Scene");
},
[
() => get(previewNat) || get(previewTag) || ($$props.item.display_name || $$props.item.item_tag).toLowerCase()
() => isNaturalMode() ? get(previewNat) || get(previewTag) || ($$props.item.display_name || $$props.item.item_tag).toLowerCase() : get(previewTag) || get(previewNat) || ($$props.item.display_name || $$props.item.item_tag).toLowerCase()
]
);
delegated("click", div, handleOverlayClick);
@@ -1029,7 +1054,15 @@ function FantasyCustomizer($$anchor, $$props) {
3,
null
// { shape, color, type } when re-editing
), onConfirm = prop($$props, "onConfirm", 3, () => {
);
prop(
$$props,
"isNaturalMode",
3,
false
// current output mode (declared so the parent can pass it through)
);
let onConfirm = prop($$props, "onConfirm", 3, () => {
}), onCancel = prop($$props, "onCancel", 3, () => {
});
let fetched = state(null);
@@ -3142,11 +3175,8 @@ function TagBuilder2($$anchor, $$props) {
if (e.target.closest(".pcr-atb2-identity-trigger")) return;
closeIdentityPicker();
}
const id = setTimeout(() => document.addEventListener("click", onDocClick, true), 0);
return () => {
clearTimeout(id);
document.removeEventListener("click", onDocClick, true);
};
document.addEventListener("click", onDocClick, true);
return () => document.removeEventListener("click", onDocClick, true);
});
function togglePresetPicker(kind, subjId, triggerEl) {
var _a2, _b;
@@ -3256,11 +3286,8 @@ function TagBuilder2($$anchor, $$props) {
if (e.target.closest(".pcr-atb2-preset-trigger")) return;
closePresetPicker();
}
const id = setTimeout(() => document.addEventListener("click", onDocClick, true), 0);
return () => {
clearTimeout(id);
document.removeEventListener("click", onDocClick, true);
};
document.addEventListener("click", onDocClick, true);
return () => document.removeEventListener("click", onDocClick, true);
});
function removePreset(subjId, kind) {
set(
@@ -4387,11 +4414,8 @@ function TagBuilder2($$anchor, $$props) {
if (e.target.closest(".pcr-atb2-modifier-trigger")) return;
closeModifierPicker();
}
const id = setTimeout(() => document.addEventListener("click", onDocClick, true), 0);
return () => {
clearTimeout(id);
document.removeEventListener("click", onDocClick, true);
};
document.addEventListener("click", onDocClick, true);
return () => document.removeEventListener("click", onDocClick, true);
});
function identityMatchToken(option) {
if ((option == null ? void 0 : option.kind) === "cast") {
@@ -4469,7 +4493,7 @@ function TagBuilder2($$anchor, $$props) {
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("pcr-atb2-flash");
setTimeout(() => el.classList.remove("pcr-atb2-flash"), 1100);
el.addEventListener("animationend", () => el.classList.remove("pcr-atb2-flash"), { once: true });
}
});
});
@@ -7545,6 +7569,9 @@ ${filtered}` : filtered;
get initial() {
return get(customizerOpen).initial;
},
get isNaturalMode() {
return get(isNaturalMode);
},
onConfirm: commitCustomizer,
onCancel: cancelCustomizer
});
@@ -7584,6 +7611,9 @@ ${filtered}` : filtered;
get initial() {
return get(furnitureCustomizerOpen).initial;
},
get isNaturalMode() {
return get(isNaturalMode);
},
onConfirm: commitFurnitureCustomizer,
onCancel: cancelFurnitureCustomizer
});
@@ -7607,6 +7637,9 @@ ${filtered}` : filtered;
get initial() {
return get(fantasyCustomizerOpen).initial;
},
get isNaturalMode() {
return get(isNaturalMode);
},
onConfirm: commitFantasyCustomizer,
onCancel: cancelFantasyCustomizer
});
@@ -8,6 +8,7 @@
let {
item, // chip the user is customizing: { item_tag, display_name, base_tags, base_natlang, item_group }
initial = null, // { color, pattern, material, condition, focus } — pre-fill when editing
isNaturalMode = false, // current output mode, so the preview + focus match what the parent emits
onConfirm = () => {},
onCancel = () => {},
} = $props();
@@ -124,13 +125,16 @@
let preview = $derived.by(() => {
if (!data) return (item.display_name || item.item_tag).toLowerCase();
const parts = [];
if (conditionOpt?.prefix) parts.push(conditionOpt.prefix);
if (colorOpt?.prefix) parts.push(colorOpt.prefix);
if (patternOpt?.prefix) parts.push(patternOpt.prefix);
if (materialOpt?.prefix) parts.push(materialOpt.prefix);
parts.push((item.display_name || item.item_tag).toLowerCase());
const phrase = parts.join(" ");
// Mirror the parent's buildModifiedPhrase so the preview matches what is
// actually emitted: condition, color, pattern, material, then the item.
const prefixes = [conditionOpt?.prefix, colorOpt?.prefix, patternOpt?.prefix, materialOpt?.prefix].filter(Boolean);
if (!isNaturalMode) {
// Tag mode: underscore-joined prefix slugs + item_tag. The focus
// phrase is natural-language only and isn't part of the tag emit.
const slug = (s) => s.replace(/\s+/g, "_");
return [...prefixes.map(slug), item.item_tag || ""].filter(Boolean).join("_");
}
const phrase = [...prefixes, (item.display_name || item.item_tag).toLowerCase()].join(" ");
return focus ? `${phrase}, presenting ${phrase} to viewer, ${phrase} focus` : phrase;
});
@@ -322,11 +326,11 @@
{/if}
</div>
<!-- FOCUS -->
<label class="pcr-atb2-cust-focus">
<input type="checkbox" bind:checked={focus} />
<!-- FOCUS — natural-language only; the tag-mode emit has no focus phrase -->
<label class="pcr-atb2-cust-focus" class:pcr-atb2-cust-focus-disabled={!isNaturalMode}>
<input type="checkbox" bind:checked={focus} disabled={!isNaturalMode} />
<span>Add focus tags</span>
<span class="pcr-atb2-cust-focus-hint">Camera focuses on this item</span>
<span class="pcr-atb2-cust-focus-hint">{isNaturalMode ? "Camera focuses on this item" : "Natural-language mode only"}</span>
</label>
<div class="pcr-atb2-cust-preview">
@@ -458,6 +462,7 @@
cursor: pointer;
}
.pcr-atb2-cust-focus input { accent-color: #7c3aed; }
.pcr-atb2-cust-focus-disabled { opacity: 0.5; cursor: default; }
.pcr-atb2-cust-focus-hint {
font-size: 11px; color: #888; margin-left: auto;
}
@@ -10,6 +10,7 @@
item, // { item_tag, display_name, base_tags, base_natlang, item_group }
data = null, // { shapes, colors, types } — when null, self-fetches
initial = null, // { shape, color, type } when re-editing
isNaturalMode = false, // current output mode (declared so the parent can pass it through)
onConfirm = () => {},
onCancel = () => {},
} = $props();
@@ -24,6 +24,7 @@
actionOverrides = {}, // { prop_tag: [action_tag, ...] } — narrows verbs when present
contextSubject = null, // null = scene scope; truthy = subject interaction
initial = null, // { material, pattern, color, action } when editing
isNaturalMode = false, // current output mode — preview shows tag vs natlang form to match emit
onConfirm = () => {},
onCancel = () => {},
} = $props();
@@ -319,7 +320,7 @@
<div class="pcr-atb2-cust-preview">
<div class="pcr-atb2-cust-preview-label">Preview</div>
<div class="pcr-atb2-cust-preview-text">{previewNat || previewTag || (item.display_name || item.item_tag).toLowerCase()}</div>
<div class="pcr-atb2-cust-preview-text">{isNaturalMode ? (previewNat || previewTag || (item.display_name || item.item_tag).toLowerCase()) : (previewTag || previewNat || (item.display_name || item.item_tag).toLowerCase())}</div>
</div>
</div>
@@ -1672,11 +1672,11 @@
if (e.target.closest('.pcr-atb2-identity-trigger')) return;
closeIdentityPicker();
}
const id = setTimeout(() => document.addEventListener('click', onDocClick, true), 0);
return () => {
clearTimeout(id);
document.removeEventListener('click', onDocClick, true);
};
// Add directly — this $effect runs after the opening click has finished
// dispatching, so the capture-phase listener can't catch that same click
// (no setTimeout(0) deferral needed).
document.addEventListener('click', onDocClick, true);
return () => document.removeEventListener('click', onDocClick, true);
});
// ----------------------------------------------------------------------
@@ -1800,11 +1800,11 @@
if (e.target.closest('.pcr-atb2-preset-trigger')) return;
closePresetPicker();
}
const id = setTimeout(() => document.addEventListener('click', onDocClick, true), 0);
return () => {
clearTimeout(id);
document.removeEventListener('click', onDocClick, true);
};
// Add directly — this $effect runs after the opening click has finished
// dispatching, so the capture-phase listener can't catch that same click
// (no setTimeout(0) deferral needed).
document.addEventListener('click', onDocClick, true);
return () => document.removeEventListener('click', onDocClick, true);
});
// Remove an active outfit or pose: subtract the snapshot's contribution
@@ -3306,11 +3306,11 @@
if (e.target.closest('.pcr-atb2-modifier-trigger')) return;
closeModifierPicker();
}
const id = setTimeout(() => document.addEventListener('click', onDocClick, true), 0);
return () => {
clearTimeout(id);
document.removeEventListener('click', onDocClick, true);
};
// Add directly — this $effect runs after the opening click has finished
// dispatching, so the capture-phase listener can't catch that same click
// (no setTimeout(0) deferral needed).
document.addEventListener('click', onDocClick, true);
return () => document.removeEventListener('click', onDocClick, true);
});
// What token should partitionTokens treat as the identity assertion in
@@ -3416,7 +3416,9 @@
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("pcr-atb2-flash");
setTimeout(() => el.classList.remove("pcr-atb2-flash"), 1100);
// Remove on animationend (the .pcr-atb2-flash rule runs pcrAtb2Flash
// 1s once) so re-jumping to the same card re-triggers it — no timer.
el.addEventListener("animationend", () => el.classList.remove("pcr-atb2-flash"), { once: true });
}
});
});
@@ -5986,6 +5988,7 @@
<Customizer
item={customizerOpen.item}
initial={customizerOpen.initial}
isNaturalMode={isNaturalMode}
onConfirm={commitCustomizer}
onCancel={cancelCustomizer}
/>
@@ -6004,6 +6007,7 @@
? subjects.find(s => s.id === furnitureCustomizerOpen.subjId)
: null}
initial={furnitureCustomizerOpen.initial}
isNaturalMode={isNaturalMode}
onConfirm={commitFurnitureCustomizer}
onCancel={cancelFurnitureCustomizer}
/>
@@ -6015,6 +6019,7 @@
item={fantasyCustomizerOpen.item}
data={fantasyModData}
initial={fantasyCustomizerOpen.initial}
isNaturalMode={isNaturalMode}
onConfirm={commitFantasyCustomizer}
onCancel={cancelFantasyCustomizer}
/>