feat(random): finalize multi-theme telemetry and polish
Some checks failed
Editorial Lint / lint-editorial (push) Has been cancelled

- document random theme exclusions, perf guard tooling, and roadmap completion

- tighten random reroll UX: strict theme persistence, throttle handling, export parity, diagnostics updates

- add regression coverage for telemetry counters, multi-theme flows, and locked rerolls; refresh README and notes

Tests: pytest -q (fast random + telemetry suites)
This commit is contained in:
matt 2025-09-26 18:15:52 -07:00
parent 73685f22c8
commit 49f1f8b2eb
28 changed files with 4888 additions and 251 deletions

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from fastapi import APIRouter, Request, Form, Query
from fastapi.responses import HTMLResponse, JSONResponse
from typing import Any
from ..app import ALLOW_MUST_HAVES # Import feature flag
from ..services.build_utils import (
step5_ctx_from_result,
@ -2859,7 +2860,35 @@ async def build_permalink(request: Request):
rb = sess.get("random_build") or {}
if rb:
# Only include known keys to avoid leaking unrelated session data
inc = {k: rb.get(k) for k in ("seed", "theme", "constraints") if k in rb}
inc: dict[str, Any] = {}
for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"):
if rb.get(key) is not None:
inc[key] = rb.get(key)
resolved_list = rb.get("resolved_themes")
if isinstance(resolved_list, list):
inc["resolved_themes"] = list(resolved_list)
resolved_info = rb.get("resolved_theme_info")
if isinstance(resolved_info, dict):
inc["resolved_theme_info"] = dict(resolved_info)
if rb.get("combo_fallback") is not None:
inc["combo_fallback"] = bool(rb.get("combo_fallback"))
if rb.get("synergy_fallback") is not None:
inc["synergy_fallback"] = bool(rb.get("synergy_fallback"))
if rb.get("fallback_reason") is not None:
inc["fallback_reason"] = rb.get("fallback_reason")
requested = rb.get("requested_themes")
if isinstance(requested, dict):
inc["requested_themes"] = dict(requested)
if rb.get("auto_fill_enabled") is not None:
inc["auto_fill_enabled"] = bool(rb.get("auto_fill_enabled"))
if rb.get("auto_fill_applied") is not None:
inc["auto_fill_applied"] = bool(rb.get("auto_fill_applied"))
auto_filled = rb.get("auto_filled_themes")
if isinstance(auto_filled, list):
inc["auto_filled_themes"] = list(auto_filled)
display = rb.get("display_themes")
if isinstance(display, list):
inc["display_themes"] = list(display)
if inc:
payload["random"] = inc
except Exception:
@ -2914,9 +2943,43 @@ async def build_from(request: Request, state: str | None = None) -> HTMLResponse
try:
r = data.get("random") or {}
if r:
sess["random_build"] = {
k: r.get(k) for k in ("seed", "theme", "constraints") if k in r
}
rb_payload: dict[str, Any] = {}
for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"):
if r.get(key) is not None:
rb_payload[key] = r.get(key)
if isinstance(r.get("resolved_themes"), list):
rb_payload["resolved_themes"] = list(r.get("resolved_themes") or [])
if isinstance(r.get("resolved_theme_info"), dict):
rb_payload["resolved_theme_info"] = dict(r.get("resolved_theme_info"))
if r.get("combo_fallback") is not None:
rb_payload["combo_fallback"] = bool(r.get("combo_fallback"))
if r.get("synergy_fallback") is not None:
rb_payload["synergy_fallback"] = bool(r.get("synergy_fallback"))
if r.get("fallback_reason") is not None:
rb_payload["fallback_reason"] = r.get("fallback_reason")
if isinstance(r.get("requested_themes"), dict):
requested_payload = dict(r.get("requested_themes"))
if "auto_fill_enabled" in requested_payload:
requested_payload["auto_fill_enabled"] = bool(requested_payload.get("auto_fill_enabled"))
rb_payload["requested_themes"] = requested_payload
if r.get("auto_fill_enabled") is not None:
rb_payload["auto_fill_enabled"] = bool(r.get("auto_fill_enabled"))
if r.get("auto_fill_applied") is not None:
rb_payload["auto_fill_applied"] = bool(r.get("auto_fill_applied"))
auto_filled = r.get("auto_filled_themes")
if isinstance(auto_filled, list):
rb_payload["auto_filled_themes"] = list(auto_filled)
display = r.get("display_themes")
if isinstance(display, list):
rb_payload["display_themes"] = list(display)
if "seed" in rb_payload:
try:
seed_int = int(rb_payload["seed"])
rb_payload["seed"] = seed_int
rb_payload.setdefault("recent_seeds", [seed_int])
except Exception:
rb_payload.setdefault("recent_seeds", [])
sess["random_build"] = rb_payload
except Exception:
pass

View file

@ -7,6 +7,7 @@
<h3 style="margin-top:0">System summary</h3>
<div id="sysSummary" class="muted">Loading…</div>
<div id="themeSummary" style="margin-top:.5rem"></div>
<div id="themeTokenStats" class="muted" style="margin-top:.5rem">Loading theme stats…</div>
<div style="margin-top:.35rem">
<button class="btn" id="diag-theme-reset">Reset theme preference</button>
</div>
@ -76,6 +77,121 @@
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
}
load();
var tokenEl = document.getElementById('themeTokenStats');
function renderTokens(payload){
if (!tokenEl) return;
try {
if (!payload || payload.ok !== true) {
tokenEl.textContent = 'Theme stats unavailable';
return;
}
var stats = payload.stats || {};
var top = Array.isArray(stats.top_tokens) ? stats.top_tokens.slice(0, 5) : [];
var html = '';
var commanders = (stats && stats.commanders != null) ? stats.commanders : '0';
var withTags = (stats && stats.with_tags != null) ? stats.with_tags : '0';
var uniqueTokens = (stats && stats.unique_tokens != null) ? stats.unique_tokens : '0';
var assignments = (stats && stats.total_assignments != null) ? stats.total_assignments : '0';
var avgTokens = (stats && stats.avg_tokens_per_commander != null) ? stats.avg_tokens_per_commander : '0';
var medianTokens = (stats && stats.median_tokens_per_commander != null) ? stats.median_tokens_per_commander : '0';
html += '<div><strong>Commanders indexed:</strong> ' + String(commanders) + ' (' + String(withTags) + ' with tags)</div>';
html += '<div><strong>Theme tokens:</strong> ' + String(uniqueTokens) + ' unique; ' + String(assignments) + ' assignments</div>';
html += '<div><strong>Tokens per commander:</strong> avg ' + String(avgTokens) + ', median ' + String(medianTokens) + '</div>';
if (top.length) {
var parts = [];
top.forEach(function(item){
parts.push(String(item.token) + ' (' + String(item.count) + ')');
});
html += '<div><strong>Top tokens:</strong> ' + parts.join(', ') + '</div>';
}
var pool = stats.random_pool || {};
if (pool && typeof pool.size !== 'undefined'){
var coveragePct = null;
if (pool.coverage_ratio != null){
var cov = Number(pool.coverage_ratio);
if (!Number.isNaN(cov)){ coveragePct = (cov * 100).toFixed(1); }
}
html += '<div style="margin-top:0.35rem;"><strong>Curated random pool:</strong> ' + String(pool.size) + ' tokens';
if (coveragePct !== null){ html += ' (' + coveragePct + '% of catalog tokens)'; }
html += '</div>';
var rules = pool.rules || {};
var threshold = rules.overrepresented_share_threshold;
if (threshold != null){
var thrPct = Number(threshold);
if (!Number.isNaN(thrPct)){ html += '<div style="font-size:11px;">Over-represented threshold: ≥ ' + (thrPct * 100).toFixed(1) + '% of commanders</div>'; }
}
var excludedCounts = pool.excluded_counts || {};
var reasonKeys = Object.keys(excludedCounts);
if (reasonKeys.length){
var badges = reasonKeys.map(function(reason){
return reason + ' (' + excludedCounts[reason] + ')';
});
html += '<div style="font-size:11px;">Exclusions: ' + badges.join(', ') + '</div>';
}
var samples = pool.excluded_samples || {};
var sampleKeys = Object.keys(samples);
if (sampleKeys.length){
var sampleLines = [];
sampleKeys.slice(0, 3).forEach(function(reason){
var tokens = samples[reason] || [];
var sampleTokens = (tokens || []).slice(0, 3);
var remainder = Math.max((tokens || []).length - sampleTokens.length, 0);
var tokenLabel = sampleTokens.join(', ');
if (remainder > 0){ tokenLabel += ' +' + remainder; }
sampleLines.push(reason + ': ' + tokenLabel);
});
html += '<div style="font-size:11px; opacity:0.75;">Samples → ' + sampleLines.join(' | ') + '</div>';
}
var manualDetail = pool.manual_exclusion_detail || {};
var manualKeys = Object.keys(manualDetail);
if (manualKeys.length){
var manualSamples = manualKeys.slice(0, 3).map(function(token){
var info = manualDetail[token] || {};
var label = info.display || token;
var cat = info.category ? (' [' + info.category + ']') : '';
return label + cat;
});
var manualRemainder = Math.max(manualKeys.length - manualSamples.length, 0);
var manualLine = manualSamples.join(', ');
if (manualRemainder > 0){ manualLine += ' +' + manualRemainder; }
html += '<div style="font-size:11px;">Manual exclusions: ' + manualLine + '</div>';
}
var manualGroups = Array.isArray(rules.manual_exclusions) ? rules.manual_exclusions : [];
if (manualGroups.length){
var categoryList = manualGroups.map(function(group){ return group.category || 'manual'; });
html += '<div style="font-size:11px; opacity:0.75;">Manual categories: ' + categoryList.join(', ') + '</div>';
}
}
var telemetry = stats.index_telemetry || {};
if (telemetry && typeof telemetry.token_count !== 'undefined'){
var hitRate = telemetry.hit_rate != null ? Number(telemetry.hit_rate) : null;
var hitPct = (hitRate !== null && !Number.isNaN(hitRate)) ? (hitRate * 100).toFixed(1) : null;
var teleLine = '<div style="font-size:11px; margin-top:0.25rem;">Tag index: ' + String(telemetry.token_count || 0) + ' tokens · lookups ' + String(telemetry.lookups || 0);
if (hitPct !== null){ teleLine += ' · hit rate ' + hitPct + '%'; }
if (telemetry.substring_checks){ teleLine += ' · substring checks ' + String(telemetry.substring_checks || 0); }
teleLine += '</div>';
html += teleLine;
}
tokenEl.innerHTML = html;
} catch(_){
tokenEl.textContent = 'Theme stats unavailable';
}
}
function loadTokenStats(){
if (!tokenEl) return;
tokenEl.textContent = 'Loading theme stats…';
fetch('/status/random_theme_stats', { cache: 'no-store' })
.then(function(resp){
if (resp.status === 404) {
tokenEl.textContent = 'Diagnostics disabled (stats unavailable)';
return null;
}
return resp.json();
})
.then(function(data){ if (data) renderTokens(data); })
.catch(function(){ tokenEl.textContent = 'Theme stats unavailable'; });
}
loadTokenStats();
// Theme status and reset
try{
var tEl = document.getElementById('themeSummary');

View file

@ -1,11 +1,19 @@
<div class="random-result" id="random-result">
<style>
.diag-badges{display:inline-flex; gap:4px; margin-left:8px; flex-wrap:wrap;}
.diag-badge{background:var(--panel-alt,#334155); color:#fff; padding:2px 6px; border-radius:12px; font-size:10px; letter-spacing:.5px; line-height:1.2;}
.diag-badge.warn{background:#8a6d3b;}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
.diag-badges{display:inline-flex; gap:4px; margin-left:8px; flex-wrap:wrap; align-items:center;}
.diag-badge{background:var(--panel-alt,#334155); color:#fff; padding:3px 7px; border-radius:12px; font-size:10px; letter-spacing:.5px; line-height:1.3; display:inline-flex; align-items:center; gap:4px;}
.diag-badge.warn{background:#814c14;}
.diag-badge.err{background:#7f1d1d;}
.diag-badge.fallback{background:#4f46e5;}
.diag-badge.fallback{background:#4338ca;}
.diag-badge .badge-icon{font-size:12px; display:inline-block;}
.btn-compact{font-size:11px; padding:2px 6px; line-height:1.2;}
.fallback-notice{margin-top:8px; padding:10px 12px; border-radius:6px; font-size:13px; line-height:1.45; border-left:4px solid currentColor; display:flex; gap:8px; align-items:flex-start;}
.fallback-notice.info{background:rgba(79,70,229,0.18); color:#2c2891; border:1px solid rgba(79,70,229,0.45);}
.fallback-notice.warn{background:#fff0d6; color:#6c4505; border:1px solid #d89b32;}
.fallback-notice.danger{background:#fef2f2; color:#7f1d1d; border:1px solid rgba(127,29,29,0.5);}
.fallback-notice:focus-visible{outline:2px solid currentColor; outline-offset:2px;}
.fallback-notice .notice-icon{font-size:16px; line-height:1; margin-top:2px;}
</style>
<div class="random-meta" style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
<span class="seed">Seed: <strong>{{ seed }}</strong></span>
@ -14,18 +22,90 @@
<button class="btn btn-compact" type="button" aria-label="Copy permalink for this exact build" onclick="(async()=>{try{await navigator.clipboard.writeText(location.origin + '{{ permalink }}');(window.toast&&toast('Permalink copied'))||console.log('Permalink copied');}catch(e){alert('Copy failed');}})()">Copy Permalink</button>
{% endif %}
{% if show_diagnostics and diagnostics %}
<span class="diag-badges" aria-label="Diagnostics" role="group">
<span class="diag-badge" title="Attempts tried before acceptance">Att {{ diagnostics.attempts }}</span>
<span class="diag-badge" title="Elapsed build time in milliseconds">{{ diagnostics.elapsed_ms }}ms</span>
{% if diagnostics.timeout_hit %}<span class="diag-badge warn" title="Generation loop exceeded timeout limit before success">Timeout</span>{% endif %}
{% if diagnostics.retries_exhausted %}<span class="diag-badge warn" title="All allotted attempts were used without an early acceptable candidate">Retries</span>{% endif %}
{% if fallback or diagnostics.fallback %}<span class="diag-badge fallback" title="Original theme produced no candidates; Surprise mode fallback engaged">Fallback</span>{% endif %}
<span class="diag-badges" aria-label="Diagnostics" role="status" aria-live="polite" aria-atomic="true">
<span class="diag-badge" title="Attempts tried before acceptance" aria-label="Attempts tried before acceptance">
<span class="badge-icon" aria-hidden="true"></span>
<span aria-hidden="true">Att {{ diagnostics.attempts }}</span>
</span>
<span class="diag-badge" title="Elapsed build time in milliseconds" aria-label="Elapsed build time in milliseconds">
<span class="badge-icon" aria-hidden="true"></span>
<span aria-hidden="true">{{ diagnostics.elapsed_ms }}ms</span>
</span>
{% if diagnostics.timeout_hit %}
<span class="diag-badge warn" title="Generation loop exceeded timeout limit before success" aria-label="Generation exceeded timeout limit before success">
<span class="badge-icon" aria-hidden="true"></span>
<span aria-hidden="true">Timeout</span>
</span>
{% endif %}
{% if diagnostics.retries_exhausted %}
<span class="diag-badge warn" title="All allotted attempts were used without an early acceptable candidate" aria-label="All attempts were used without an early acceptable candidate">
<span class="badge-icon" aria-hidden="true"></span>
<span aria-hidden="true">Retries</span>
</span>
{% endif %}
{% if fallback or diagnostics.fallback %}
<span class="diag-badge fallback" title="Original theme produced no candidates; Surprise mode fallback engaged" aria-label="Fallback engaged after theme produced no candidates">
<span class="badge-icon" aria-hidden="true"></span>
<span aria-hidden="true">Fallback</span>
</span>
{% endif %}
</span>
{% endif %}
</div>
{% set display_list = display_themes or resolved_themes or [] %}
{% set resolved_list = display_list %}
{% set has_primary = primary_theme or secondary_theme or tertiary_theme %}
{% if resolved_list or has_primary %}
<div class="resolved-themes" style="margin-top:6px; font-size:13px; color:var(--text-muted,#94a3b8);" role="status" aria-live="polite">
{% if resolved_list %}
Resolved themes: <strong>{{ resolved_list|join(' + ') }}</strong>
{% else %}
Resolved themes: <strong>Full pool fallback</strong>
{% endif %}
</div>
{% endif %}
{% if auto_fill_applied and auto_filled_themes %}
<div class="auto-fill-note" style="margin-top:4px; font-size:12px; color:var(--text-muted,#94a3b8);" role="status" aria-live="polite">
Auto-filled: <strong>{{ auto_filled_themes|join(', ') }}</strong>
</div>
{% endif %}
{% if fallback_reason %}
{% if synergy_fallback and (not resolved_list) %}
{% set notice_class = 'danger' %}
{% elif synergy_fallback %}
{% set notice_class = 'warn' %}
{% else %}
{% set notice_class = 'info' %}
{% endif %}
{% if notice_class == 'danger' %}
{% set notice_icon = '⛔' %}
{% elif notice_class == 'warn' %}
{% set notice_icon = '⚠️' %}
{% else %}
{% set notice_icon = '' %}
{% endif %}
<div class="fallback-notice {{ notice_class }}" role="status" aria-live="assertive" aria-atomic="true" tabindex="0">
<span class="notice-icon" aria-hidden="true">{{ notice_icon }}</span>
<span>
<strong>Heads up:</strong>
<span id="fallback-reason-text">{{ fallback_reason }}.</span>
<span class="sr-only">You can tweak secondary or tertiary themes for different mixes, or reroll to explore more options.</span>
<span aria-hidden="true"> Try tweaking Secondary or Tertiary themes for different mixes, or reroll to explore more options.</span>
</span>
</div>
{% endif %}
<!-- Hidden current seed so HTMX reroll button can include it via hx-include -->
<input type="hidden" id="current-seed" name="seed" value="{{ seed }}" />
<input type="hidden" id="current-commander" name="commander" value="{{ commander }}" />
<input type="hidden" id="current-primary-theme" name="primary_theme" value="{{ primary_theme or '' }}" />
<input type="hidden" id="current-secondary-theme" name="secondary_theme" value="{{ secondary_theme or '' }}" />
<input type="hidden" id="current-tertiary-theme" name="tertiary_theme" value="{{ tertiary_theme or '' }}" />
<input type="hidden" id="current-resolved-themes" name="resolved_themes" value="{{ resolved_list|join('||') }}" />
<input type="hidden" id="current-auto-fill-enabled" name="auto_fill_enabled" value="{{ 'true' if auto_fill_enabled else 'false' }}" />
<input type="hidden" id="current-auto-fill-secondary-enabled" name="auto_fill_secondary_enabled" value="{{ 'true' if auto_fill_secondary_enabled else 'false' }}" />
<input type="hidden" id="current-auto-fill-tertiary-enabled" name="auto_fill_tertiary_enabled" value="{{ 'true' if auto_fill_tertiary_enabled else 'false' }}" />
<input type="hidden" id="current-auto-filled-themes" name="auto_filled_themes" value="{{ auto_filled_themes|join('||') if auto_filled_themes else '' }}" />
<input type="hidden" id="current-strict-theme-match" name="strict_theme_match" value="{{ 'true' if strict_theme_match else 'false' }}" />
<div class="commander-block" style="display:flex; gap:14px; align-items:flex-start; margin-top:.75rem;">
<div class="commander-thumb" style="flex:0 0 auto;">
<img

View file

@ -6,25 +6,118 @@
{% if not enable_ui %}
<div class="notice" role="status">Random UI is disabled. Set <code>RANDOM_UI=1</code> to enable.</div>
{% else %}
<div class="controls" role="group" aria-label="Random controls" style="display:flex; gap:8px; align-items:center; flex-wrap: wrap;">
<label for="random-theme" class="field-label" style="margin-right:6px;">Theme</label>
<div style="position:relative;">
<input id="random-theme" name="theme" type="text" placeholder="optional (e.g., Tokens)" aria-label="Theme (optional)" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-owns="theme-suggest-box" aria-haspopup="listbox" />
<div id="theme-suggest-box" role="listbox" style="display:none; position:absolute; top:100%; left:0; right:0; background:var(--panel,#1e293b); border:1px solid var(--border,#334155); z-index:20; max-height:220px; overflow-y:auto; box-shadow:0 4px 10px rgba(0,0,0,.4); font-size:13px;">
<!-- suggestions injected here -->
<style>
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
.theme-tooltip{position:relative; display:inline-flex; align-items:center;}
.theme-tooltip button.help-icon{border:none; background:transparent; color:var(--text-muted,#94a3b8); font-size:14px; cursor:help; border-radius:50%; width:20px; height:20px; line-height:20px; text-align:center; transition:color .2s ease;}
.theme-tooltip button.help-icon:focus-visible{outline:2px solid var(--accent,#6366f1); outline-offset:2px;}
.theme-tooltip .tooltip-panel{position:absolute; top:100%; left:50%; transform:translate(-50%,8px); background:var(--panel,#111827); color:var(--text,#f8fafc); border:1px solid var(--border,#334155); border-radius:8px; padding:10px 12px; font-size:12px; width:220px; box-shadow:0 10px 25px rgba(15,23,42,0.35); opacity:0; pointer-events:none; transition:opacity .2s ease; z-index:30;}
.theme-tooltip[data-open="true"] .tooltip-panel{opacity:1; pointer-events:auto;}
.theme-tooltip .tooltip-panel p{margin:0; line-height:1.4;}
.clear-themes-btn{background:transparent; border:none; color:var(--accent,#6366f1); font-size:12px; padding:2px 8px; border-radius:6px; cursor:pointer; text-decoration:underline; transition:color .15s ease, background .15s ease;}
.clear-themes-btn:hover{color:var(--accent-strong,#818cf8);}
.clear-themes-btn:focus-visible{outline:2px solid var(--accent,#6366f1); outline-offset:2px;}
.strict-toggle{display:flex; align-items:center; gap:8px; font-size:12px; color:var(--text-muted,#94a3b8);}
</style>
<div class="controls" role="group" aria-label="Random controls" style="display:flex; flex-direction:column; gap:12px;">
<fieldset class="theme-section" role="group" aria-labelledby="theme-group-label" style="border:1px solid var(--border,#1f2937); border-radius:10px; padding:12px 16px; display:flex; flex-direction:column; gap:10px; min-width:320px; flex:0 0 auto;">
<legend id="theme-group-label" style="font-size:13px; font-weight:600; letter-spacing:.4px; display:flex; align-items:center; gap:6px; justify-content:space-between; flex-wrap:wrap;">
<span class="theme-legend-label" style="display:inline-flex; align-items:center; gap:6px;">
Themes
<span class="theme-tooltip" data-open="false">
<button type="button" class="help-icon" aria-expanded="false" aria-controls="theme-tooltip-panel" aria-describedby="theme-tooltip-text">?</button>
<span id="theme-tooltip-text" class="sr-only">Explain theme fallback order</span>
<div id="theme-tooltip-panel" class="tooltip-panel" role="dialog" aria-modal="false">
<p>We attempt your Primary + Secondary + Tertiary first. If that has no hits, we relax to Primary + Secondary, then Primary + Tertiary, Primary only, synergy overlap, and finally the full pool.</p>
</div>
</span>
</span>
<button type="button" id="random-clear-themes" class="clear-themes-btn" aria-describedby="theme-group-label">Clear themes</button>
</legend>
<div style="display:flex; flex-direction:column; gap:10px;" aria-describedby="theme-help">
<div class="theme-row" style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<label for="random-primary-theme" class="field-label" style="min-width:110px; font-weight:600;">Primary</label>
<div style="position:relative; flex:1 1 260px; min-width:220px;">
<input id="random-primary-theme" name="primary_theme" type="text" placeholder="e.g. Aggro, Goblin Kindred" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-owns="primary-theme-suggest-box" aria-controls="primary-theme-suggest-box" aria-describedby="theme-help" aria-haspopup="listbox" />
<div id="primary-theme-suggest-box" role="listbox" style="display:none; position:absolute; top:100%; left:0; right:0; background:var(--panel,#1e293b); border:1px solid var(--border,#334155); z-index:22; max-height:220px; overflow-y:auto; box-shadow:0 4px 10px rgba(0,0,0,.4); font-size:13px;">
<!-- suggestions injected here -->
</div>
</div>
</div>
<div class="theme-row" style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<label for="random-secondary-theme" class="field-label" style="min-width:110px; font-weight:600;">Secondary <span style="font-size:11px; color:var(--text-muted,#94a3b8); font-weight:400;">(optional)</span></label>
<div style="position:relative; flex:1 1 260px; min-width:220px;">
<input id="random-secondary-theme" name="secondary_theme" type="text" placeholder="Optional secondary" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-owns="secondary-theme-suggest-box" aria-controls="secondary-theme-suggest-box" aria-describedby="theme-help" aria-haspopup="listbox" />
<div id="secondary-theme-suggest-box" role="listbox" style="display:none; position:absolute; top:100%; left:0; right:0; background:var(--panel,#1e293b); border:1px solid var(--border,#334155); z-index:21; max-height:220px; overflow-y:auto; box-shadow:0 4px 10px rgba(0,0,0,.4); font-size:13px;">
<!-- suggestions injected here -->
</div>
</div>
</div>
<div class="theme-row" style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<label for="random-tertiary-theme" class="field-label" style="min-width:110px; font-weight:600;">Tertiary <span style="font-size:11px; color:var(--text-muted,#94a3b8); font-weight:400;">(optional)</span></label>
<div style="position:relative; flex:1 1 260px; min-width:220px;">
<input id="random-tertiary-theme" name="tertiary_theme" type="text" placeholder="Optional tertiary" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-owns="tertiary-theme-suggest-box" aria-controls="tertiary-theme-suggest-box" aria-describedby="theme-help" aria-haspopup="listbox" />
<div id="tertiary-theme-suggest-box" role="listbox" style="display:none; position:absolute; top:100%; left:0; right:0; background:var(--panel,#1e293b); border:1px solid var(--border,#334155); z-index:20; max-height:220px; overflow-y:auto; box-shadow:0 4px 10px rgba(0,0,0,.4); font-size:13px;">
<!-- suggestions injected here -->
</div>
</div>
</div>
</div>
<div class="auto-fill-toggle" style="display:flex; flex-direction:column; gap:6px; margin-top:4px;">
<input type="hidden" id="random-auto-fill-state" name="auto_fill_enabled" value="false" />
<input type="hidden" id="random-auto-fill-secondary-state" name="auto_fill_secondary_enabled" value="false" />
<input type="hidden" id="random-auto-fill-tertiary-state" name="auto_fill_tertiary_enabled" value="false" />
<div style="display:flex; align-items:flex-start; gap:12px;">
<div style="min-width:110px; font-weight:600; margin-top:2px; display:flex; align-items:center; gap:6px;">
<span>Auto-fill themes</span>
<span class="theme-tooltip auto-fill-tooltip" data-open="false">
<button type="button" class="help-icon" aria-expanded="false" aria-controls="auto-fill-tooltip-panel" aria-describedby="auto-fill-tooltip-text">?</button>
<span id="auto-fill-tooltip-text" class="sr-only">Auto-fill only fills empty slots; manual entries always take priority.</span>
<div id="auto-fill-tooltip-panel" class="tooltip-panel" role="dialog" aria-modal="false">
<p>We only auto-fill the theme slots you leave empty. Manual entries always take priority.</p>
</div>
</span>
</div>
<div style="display:flex; flex-direction:column; gap:4px;">
<div style="display:flex; align-items:center; gap:16px; flex-wrap:wrap;">
<label for="random-auto-fill-secondary" style="display:inline-flex; align-items:center; gap:6px; font-size:12px;">
<input id="random-auto-fill-secondary" type="checkbox" aria-describedby="auto-fill-tooltip-text" />
<span>Fill Secondary</span>
</label>
<label for="random-auto-fill-tertiary" style="display:inline-flex; align-items:center; gap:6px; font-size:12px;">
<input id="random-auto-fill-tertiary" type="checkbox" aria-describedby="auto-fill-tooltip-text" />
<span>Fill Tertiary</span>
</label>
</div>
</div>
</div>
</div>
<div class="strict-toggle" style="margin-top:4px;">
<input type="hidden" id="random-strict-theme-hidden" name="strict_theme_match" value="{{ 'true' if strict_theme_match|default(false) else 'false' }}" />
<label for="random-strict-theme" style="display:inline-flex; align-items:center; gap:6px; cursor:pointer;">
<input id="random-strict-theme" type="checkbox" aria-describedby="random-strict-theme-help" {% if strict_theme_match|default(false) %}checked{% endif %} />
<span>Require exact theme match (no fallbacks)</span>
</label>
<span id="random-strict-theme-help" class="sr-only">When enabled, themes must match exactly. No fallback themes will be used.</span>
</div>
<input type="hidden" id="random-legacy-theme" name="theme" value="" />
</fieldset>
<div class="action-row" style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
{% if show_diagnostics %}
<div class="diagnostics" style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; font-size:11px;">
<label for="rand-attempts" style="font-size:11px;">Attempts</label>
<input id="rand-attempts" name="attempts" type="number" min="1" max="25" value="{{ random_max_attempts }}" style="width:60px; font-size:11px;" title="Override max attempts" />
<label for="rand-timeout" style="font-size:11px;">Timeout(ms)</label>
<input id="rand-timeout" name="timeout_ms" type="number" min="100" max="15000" step="100" value="{{ random_timeout_ms }}" style="width:80px; font-size:11px;" title="Override generation timeout in milliseconds" />
</div>
{% endif %}
<div class="button-row" style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
<!-- Added hx-trigger with delay to provide debounce without custom JS recursion -->
<button id="btn-surprise" class="btn" hx-post="/hx/random_reroll" hx-vals='{"mode":"surprise"}' hx-include="#random-primary-theme,#random-secondary-theme,#random-tertiary-theme,#random-legacy-theme,#random-auto-fill-state,#random-auto-fill-secondary-state,#random-auto-fill-tertiary-state,#random-strict-theme-hidden{% if show_diagnostics %},#rand-attempts,#rand-timeout{% endif %}" hx-target="#random-result" hx-swap="outerHTML" hx-trigger="click" hx-disabled-elt="#btn-surprise,#btn-reroll" aria-label="Surprise me">Surprise me</button>
<button id="btn-reroll" class="btn" hx-post="/hx/random_reroll" hx-vals='{"mode":"reroll_same_commander"}' hx-include="#current-seed,#current-commander,#current-primary-theme,#current-secondary-theme,#current-tertiary-theme,#current-resolved-themes,#current-auto-fill-enabled,#current-auto-fill-secondary-enabled,#current-auto-fill-tertiary-enabled,#current-strict-theme-match,#random-primary-theme,#random-secondary-theme,#random-tertiary-theme,#random-legacy-theme,#random-auto-fill-state,#random-auto-fill-secondary-state,#random-auto-fill-tertiary-state,#random-strict-theme-hidden{% if show_diagnostics %},#rand-attempts,#rand-timeout{% endif %}" hx-target="#random-result" hx-swap="outerHTML" hx-trigger="click" hx-disabled-elt="#btn-surprise,#btn-reroll" aria-label="Reroll" disabled>Reroll</button>
<span id="spinner" role="status" aria-live="polite" style="display:none;">Loading…</span>
</div>
</div>
{% if show_diagnostics %}
<label for="rand-attempts" style="font-size:11px;">Attempts</label>
<input id="rand-attempts" name="attempts" type="number" min="1" max="25" value="{{ random_max_attempts }}" style="width:60px; font-size:11px;" title="Override max attempts" />
<label for="rand-timeout" style="font-size:11px;">Timeout(ms)</label>
<input id="rand-timeout" name="timeout_ms" type="number" min="100" max="15000" step="100" value="{{ random_timeout_ms }}" style="width:80px; font-size:11px;" title="Override generation timeout in milliseconds" />
{% endif %}
<!-- Added hx-trigger with delay to provide debounce without custom JS recursion -->
<button id="btn-surprise" class="btn" hx-post="/hx/random_reroll" hx-vals='{"mode":"surprise"}' hx-include="#random-theme{% if show_diagnostics %},#rand-attempts,#rand-timeout{% endif %}" hx-target="#random-result" hx-swap="outerHTML" hx-trigger="click delay:150ms" hx-disabled-elt="#btn-surprise,#btn-reroll" aria-label="Surprise me">Surprise me</button>
<button id="btn-reroll" class="btn" hx-post="/hx/random_reroll" hx-vals='{"mode":"reroll_same_commander"}' hx-include="#current-seed,#current-commander,#random-theme{% if show_diagnostics %},#rand-attempts,#rand-timeout{% endif %}" hx-target="#random-result" hx-swap="outerHTML" hx-trigger="click delay:150ms" hx-disabled-elt="#btn-surprise,#btn-reroll" aria-label="Reroll" disabled>Reroll</button>
<button id="btn-share" class="btn" type="button" aria-label="Copy permalink" onclick="(async ()=>{try{const r=await fetch('/build/permalink'); const j=await r.json(); const url=(j.permalink? location.origin + j.permalink : location.href); await navigator.clipboard.writeText(url); (window.toast && toast('Permalink copied')) || alert('Permalink copied');}catch(e){console.error(e); alert('Failed to copy permalink');}})()">Share</button>
<span id="spinner" role="status" aria-live="polite" style="display:none; margin-left:8px;">Loading…</span>
</div>
<div id="rate-limit-banner" role="status" aria-live="polite" style="display:none; margin-top:8px; padding:6px 8px; border:1px solid #cc9900; background:#fff8e1; color:#5f4200; border-radius:4px;">
Too many requests. Please wait…
@ -40,78 +133,334 @@
</div>
<script>
(function(){
// Typeahead: simple debounce + /themes/suggest
var input = document.getElementById('random-theme');
var listBox = document.getElementById('theme-suggest-box');
var to = null;
// Typeahead: debounce + /themes/suggest shared across inputs
var legacyInput = document.getElementById('random-legacy-theme');
var AUTO_FILL_KEY = 'random_auto_fill_enabled';
var AUTO_FILL_SECONDARY_KEY = 'random_auto_fill_secondary_enabled';
var AUTO_FILL_TERTIARY_KEY = 'random_auto_fill_tertiary_enabled';
var THEME_KEY = 'random_last_primary_theme';
var LEGACY_KEY = 'random_last_theme';
var SECONDARY_KEY = 'random_last_secondary_theme';
var TERTIARY_KEY = 'random_last_tertiary_theme';
var themeInputs = {
primary: {
input: document.getElementById('random-primary-theme'),
list: document.getElementById('primary-theme-suggest-box'),
syncLegacy: true,
},
secondary: {
input: document.getElementById('random-secondary-theme'),
list: document.getElementById('secondary-theme-suggest-box'),
},
tertiary: {
input: document.getElementById('random-tertiary-theme'),
list: document.getElementById('tertiary-theme-suggest-box'),
}
};
var primaryInput = themeInputs.primary.input;
var secondaryInput = themeInputs.secondary.input;
var tertiaryInput = themeInputs.tertiary.input;
var clearThemesButton = document.getElementById('random-clear-themes');
var autoFillHidden = document.getElementById('random-auto-fill-state');
var autoFillSecondaryHidden = document.getElementById('random-auto-fill-secondary-state');
var autoFillTertiaryHidden = document.getElementById('random-auto-fill-tertiary-state');
var autoFillSecondaryInput = document.getElementById('random-auto-fill-secondary');
var autoFillTertiaryInput = document.getElementById('random-auto-fill-tertiary');
var strictHidden = document.getElementById('random-strict-theme-hidden');
var strictCheckbox = document.getElementById('random-strict-theme');
var STRICT_KEY = 'random_strict_theme_match';
var debounceTimers = new Map();
var cache = new Map(); // simple in-memory cache of q -> [names]
var REROLL_CACHE_STORAGE_KEY = 'random_theme_suggest_cache_v1';
var throttleRaw = "{{ random_reroll_throttle_ms | default(350) }}";
var REROLL_THROTTLE_MS = parseInt(throttleRaw, 10);
if (isNaN(REROLL_THROTTLE_MS) || REROLL_THROTTLE_MS < 0) {
REROLL_THROTTLE_MS = 0;
}
var throttleTimerId = null;
var throttleUnlockAt = 0;
var lastRandomRequestAt = 0;
var rateLimitIntervalId = null;
var activeIndex = -1; // keyboard highlight
function hideList(){ if(listBox){ listBox.style.display='none'; input.setAttribute('aria-expanded','false'); activeIndex=-1; } }
var activeKey = null;
try {
var persistedCache = sessionStorage.getItem(REROLL_CACHE_STORAGE_KEY);
if (persistedCache) {
var parsedCache = JSON.parse(persistedCache);
if (Array.isArray(parsedCache)) {
parsedCache.forEach(function(entry) {
if (!entry || typeof entry.q !== 'string') {
return;
}
var key = entry.q.toLowerCase();
var results = Array.isArray(entry.results) ? entry.results : [];
cache.set(key, results);
});
}
}
} catch (e) { /* ignore storage failures */ }
function enforceTertiaryRequirement(){
if(autoFillTertiaryInput && autoFillSecondaryInput){
if(autoFillTertiaryInput.checked && !autoFillSecondaryInput.checked){
autoFillSecondaryInput.checked = true;
}
}
if(autoFillSecondaryInput && autoFillTertiaryInput){
var secChecked = !!autoFillSecondaryInput.checked;
autoFillTertiaryInput.disabled = !secChecked;
if(!secChecked && autoFillTertiaryInput.checked){
autoFillTertiaryInput.checked = false;
}
} else if(autoFillTertiaryInput){
autoFillTertiaryInput.disabled = false;
}
}
function currentAutoFillFlags(){
enforceTertiaryRequirement();
var secondaryChecked = !!(autoFillSecondaryInput && autoFillSecondaryInput.checked);
var tertiaryChecked = !!(secondaryChecked && autoFillTertiaryInput && autoFillTertiaryInput.checked);
return {
secondary: secondaryChecked,
tertiary: tertiaryChecked,
};
}
function syncAutoFillHidden(){
var flags = currentAutoFillFlags();
if(autoFillSecondaryHidden){ autoFillSecondaryHidden.value = flags.secondary ? 'true' : 'false'; }
if(autoFillTertiaryHidden){ autoFillTertiaryHidden.value = flags.tertiary ? 'true' : 'false'; }
if(autoFillHidden){ autoFillHidden.value = (flags.secondary || flags.tertiary) ? 'true' : 'false'; }
}
function persistAutoFillPreferences(){
try{
var flags = currentAutoFillFlags();
localStorage.setItem(AUTO_FILL_SECONDARY_KEY, flags.secondary ? '1' : '0');
localStorage.setItem(AUTO_FILL_TERTIARY_KEY, flags.tertiary ? '1' : '0');
localStorage.setItem(AUTO_FILL_KEY, (flags.secondary || flags.tertiary) ? '1' : '0');
}catch(e){ /* ignore */ }
}
function syncStrictHidden(){
if(strictHidden){
var checked = !!(strictCheckbox && strictCheckbox.checked);
strictHidden.value = checked ? 'true' : 'false';
}
}
function persistStrictPreference(){
if(!strictCheckbox) return;
try{
localStorage.setItem(STRICT_KEY, strictCheckbox.checked ? '1' : '0');
}catch(e){ /* ignore */ }
}
function hideAllLists(){
Object.keys(themeInputs).forEach(function(key){
var cfg = themeInputs[key];
if(cfg.list){ cfg.list.style.display='none'; }
if(cfg.input){
cfg.input.setAttribute('aria-expanded','false');
cfg.input.removeAttribute('aria-activedescendant');
}
});
activeIndex = -1;
activeKey = null;
}
function syncLegacy(){
if(!legacyInput) return;
legacyInput.value = primaryInput && primaryInput.value ? primaryInput.value : '';
}
function clearThemeInputs(){
if(primaryInput){ primaryInput.value = ''; }
if(secondaryInput){ secondaryInput.value = ''; }
if(tertiaryInput){ tertiaryInput.value = ''; }
hideAllLists();
syncLegacy();
try{
localStorage.setItem(THEME_KEY, '');
localStorage.setItem(LEGACY_KEY, '');
localStorage.setItem(SECONDARY_KEY, '');
localStorage.setItem(TERTIARY_KEY, '');
}catch(e){ /* ignore */ }
if(primaryInput){ primaryInput.dispatchEvent(new Event('change')); }
if(secondaryInput){ secondaryInput.dispatchEvent(new Event('change')); }
if(tertiaryInput){ tertiaryInput.dispatchEvent(new Event('change')); }
if(primaryInput){ primaryInput.focus(); }
}
function collectThemePayload(extra){
syncAutoFillHidden();
syncStrictHidden();
var payload = Object.assign({}, extra || {});
var primaryVal = primaryInput && primaryInput.value ? primaryInput.value.trim() : null;
var secondaryVal = secondaryInput && secondaryInput.value ? secondaryInput.value.trim() : null;
var tertiaryVal = tertiaryInput && tertiaryInput.value ? tertiaryInput.value.trim() : null;
payload.theme = primaryVal;
payload.primary_theme = primaryVal;
payload.secondary_theme = secondaryVal || null;
payload.tertiary_theme = tertiaryVal || null;
var flags = currentAutoFillFlags();
if(!autoFillSecondaryInput && autoFillSecondaryHidden){
var secVal = (autoFillSecondaryHidden.value || '').toLowerCase();
flags.secondary = (secVal === 'true' || secVal === '1' || secVal === 'on');
}
if(!autoFillTertiaryInput && autoFillTertiaryHidden){
var terVal = (autoFillTertiaryHidden.value || '').toLowerCase();
flags.tertiary = (terVal === 'true' || terVal === '1' || terVal === 'on');
}
if(flags.tertiary && !flags.secondary){
flags.secondary = true;
}
if(!flags.secondary){
flags.tertiary = false;
}
payload.auto_fill_secondary_enabled = flags.secondary;
payload.auto_fill_tertiary_enabled = flags.tertiary;
payload.auto_fill_enabled = flags.secondary || flags.tertiary;
var strictFlag = false;
if(strictCheckbox){
strictFlag = !!strictCheckbox.checked;
} else if(strictHidden){
var strictVal = (strictHidden.value || '').toLowerCase();
strictFlag = (strictVal === 'true' || strictVal === '1' || strictVal === 'on');
}
payload.strict_theme_match = strictFlag;
return payload;
}
async function submitRandomPayload(payload){
if(!payload) payload = {};
if(!canTriggerRandomRequest()){
return;
}
markRandomRequestStarted();
var spinner = document.getElementById('spinner');
if(spinner){ spinner.style.display = 'inline-block'; }
try{
var res = await fetch('/hx/random_reroll', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload),
credentials: 'same-origin'
});
if(res.status === 429){
var ra = res.headers ? res.headers.get('Retry-After') : null;
var secs = ra ? parseInt(ra, 10) : null;
if(window.toast){ try{ toast('Too many requests'); }catch(e){} }
if(secs && !isNaN(secs)){ applyRetryAfterSeconds(secs); }
else { showRateLimitBanner(null, 'Too many requests'); }
return;
}
if(!res.ok){
if(window.toast){ try{ toast('Failed to load random build'); }catch(e){} }
return;
}
var html = await res.text();
var target = document.getElementById('random-result');
if(target){ target.outerHTML = html; }
hideRateLimitBanner();
}catch(err){
if(window.toast){ try{ toast('Error contacting server'); }catch(e){} }
}finally{
if(spinner){ spinner.style.display = 'none'; }
enableRandomButtonsIfReady();
}
}
function highlight(text, q){
try{ if(!q) return text; var i=text.toLowerCase().indexOf(q.toLowerCase()); if(i===-1) return text; return text.substring(0,i)+'<mark style="background:#4f46e5; color:#fff; padding:0 2px; border-radius:2px;">'+text.substring(i,i+q.length)+'</mark>'+text.substring(i+q.length);}catch(e){return text;}}
function renderList(items, q){
if(!listBox) return; listBox.innerHTML=''; activeIndex=-1;
if(!items || !items.length){ hideList(); return; }
function renderList(key, items, q){
var cfg = themeInputs[key];
if(!cfg || !cfg.list){ return; }
var list = cfg.list;
list.innerHTML='';
activeIndex = -1;
if(!items || !items.length){
list.style.display='none';
if(cfg.input){
cfg.input.setAttribute('aria-expanded','false');
cfg.input.removeAttribute('aria-activedescendant');
}
activeKey = null;
return;
}
items.slice(0,50).forEach(function(it, idx){
var div=document.createElement('div');
div.setAttribute('role','option');
div.setAttribute('data-value', it);
var optionId = key + '-theme-option-' + idx;
div.id = optionId;
div.innerHTML=highlight(it, q);
div.style.cssText='padding:4px 8px; cursor:pointer;';
div.addEventListener('mouseenter', function(){ setActive(idx); });
div.addEventListener('mousedown', function(ev){ ev.preventDefault(); pick(it); });
listBox.appendChild(div);
div.addEventListener('mousedown', function(ev){ ev.preventDefault(); pick(key, it); });
list.appendChild(div);
});
listBox.style.display='block';
input.setAttribute('aria-expanded','true');
list.style.display='block';
if(cfg.input){
cfg.input.setAttribute('aria-expanded','true');
cfg.input.removeAttribute('aria-activedescendant');
}
activeKey = key;
}
function currentList(){
if(!activeKey) return null;
var cfg = themeInputs[activeKey];
return cfg ? cfg.list : null;
}
function setActive(idx){
if(!listBox) return; var children=[...listBox.children];
var list = currentList();
if(!list) return;
var children=[...list.children];
children.forEach(function(c,i){ c.style.background = (i===idx) ? 'rgba(99,102,241,0.35)' : 'transparent'; });
var cfg = activeKey ? themeInputs[activeKey] : null;
if(cfg && cfg.input){
if(idx >= 0 && children[idx]){
cfg.input.setAttribute('aria-activedescendant', children[idx].id || '');
} else {
cfg.input.removeAttribute('aria-activedescendant');
}
}
activeIndex = idx;
}
function move(delta){
if(!listBox || listBox.style.display==='none'){ return; }
var children=[...listBox.children]; if(!children.length) return;
var list = currentList();
if(!list || list.style.display==='none'){ return; }
var children=[...list.children]; if(!children.length) return;
var next = activeIndex + delta; if(next < 0) next = children.length -1; if(next >= children.length) next = 0;
setActive(next);
var el = children[next]; if(el && el.scrollIntoView){ el.scrollIntoView({block:'nearest'}); }
}
function pick(value){ input.value = value; hideList(); input.dispatchEvent(new Event('change')); }
function updateList(items, q){ renderList(items, q); }
function showRateLimitBanner(seconds){
var b = document.getElementById('rate-limit-banner');
var btn1 = document.getElementById('btn-surprise');
var btn2 = document.getElementById('btn-reroll');
if(!b){ return; }
var secs = (typeof seconds === 'number' && !isNaN(seconds) && seconds > 0) ? Math.floor(seconds) : null;
var base = 'Too many requests';
var update = function(){
if(secs !== null){ b.textContent = base + ' — try again in ' + secs + 's'; }
else { b.textContent = base + ' — please try again shortly'; }
};
update();
b.style.display = 'block';
if(btn1) btn1.disabled = true; if(btn2) btn2.disabled = true;
if(secs !== null){
var t = setInterval(function(){
secs -= 1; update();
if(secs <= 0){ clearInterval(t); b.style.display = 'none'; if(btn1) btn1.disabled = false; if(btn2) btn2.disabled = false; }
}, 1000);
}
function pick(key, value){
var cfg = themeInputs[key];
if(!cfg || !cfg.input) return;
cfg.input.value = value;
if(key === 'primary'){ syncLegacy(); }
hideAllLists();
cfg.input.dispatchEvent(new Event('change'));
cfg.input.removeAttribute('aria-activedescendant');
cfg.input.focus();
}
function persistSuggestCache(){
try{
if(typeof sessionStorage === 'undefined'){ return; }
var entries = [];
cache.forEach(function(results, q){
entries.push({ q: q, results: Array.isArray(results) ? results.slice(0, 40) : [] });
});
if(entries.length > 75){
entries = entries.slice(entries.length - 75);
}
sessionStorage.setItem(REROLL_CACHE_STORAGE_KEY, JSON.stringify(entries));
}catch(e){ /* ignore */ }
}
function highlightMatch(item, q){
try{
var idx = item.toLowerCase().indexOf(q.toLowerCase());
if(idx === -1) return item;
return item.substring(0,idx) + '[[' + item.substring(idx, idx+q.length) + ']]' + item.substring(idx+q.length);
}catch(e){ return item; }
}
async function fetchSuggest(q){
async function fetchSuggest(q, key){
try{
var cachedKey = (q || '').toLowerCase();
var u = '/themes/api/suggest' + (q? ('?q=' + encodeURIComponent(q)) : '');
if(cache.has(q)) { updateList(cache.get(q)); return; }
if(cache.has(cachedKey)) { renderList(key, cache.get(cachedKey), q); return; }
var r = await fetch(u);
if(r.status === 429){
var ra = r.headers.get('Retry-After');
@ -120,45 +469,244 @@
if(secs && !isNaN(secs)) msg += ' — retry in ' + secs + 's';
if(window.toast) { toast(msg); } else { console.warn(msg); }
showRateLimitBanner(secs);
return updateList([]);
hideAllLists();
return;
}
if(!r.ok) return updateList([]);
if(!r.ok){ renderList(key, [], q); return; }
var j = await r.json();
var items = (j && j.themes) || [];
cache.set(q, items);
// cap cache size to 50
if(cache.size > 50){
cache.set(cachedKey, items);
// cap cache size to 60
if(cache.size > 60){
var firstKey = cache.keys().next().value; cache.delete(firstKey);
}
updateList(items, q);
persistSuggestCache();
renderList(key, items, q);
}catch(e){ /* no-op */ }
}
if(input){
input.addEventListener('input', function(){
var q = input.value || '';
if(to) clearTimeout(to);
if(!q || q.length < 2){ hideList(); return; }
to = setTimeout(function(){ fetchSuggest(q); }, 150);
function attachInput(key){
var cfg = themeInputs[key];
if(!cfg || !cfg.input) return;
cfg.input.addEventListener('input', function(){
if(key === 'primary'){ syncLegacy(); }
var q = cfg.input.value || '';
var existingTimer = debounceTimers.get(key);
if(existingTimer) clearTimeout(existingTimer);
if(!q || q.length < 2){ hideAllLists(); return; }
var timerId = setTimeout(function(){ fetchSuggest(q, key); }, 150);
debounceTimers.set(key, timerId);
});
input.addEventListener('keydown', function(ev){
cfg.input.addEventListener('focus', function(){
var q = cfg.input.value || '';
if(q && q.length >= 2){ fetchSuggest(q, key); }
});
cfg.input.addEventListener('keydown', function(ev){
if(ev.key === 'ArrowDown'){ ev.preventDefault(); move(1); }
else if(ev.key === 'ArrowUp'){ ev.preventDefault(); move(-1); }
else if(ev.key === 'Enter'){ if(activeIndex >=0 && listBox && listBox.children[activeIndex]){ ev.preventDefault(); pick(listBox.children[activeIndex].getAttribute('data-value')); } }
else if(ev.key === 'Escape'){ hideList(); }
else if(ev.key === 'Enter'){
var list = currentList();
if(list && activeKey && activeIndex >= 0 && list.children[activeIndex]){
ev.preventDefault();
pick(activeKey, list.children[activeIndex].getAttribute('data-value'));
} else {
hideAllLists();
}
}
else if(ev.key === 'Escape'){ hideAllLists(); }
});
document.addEventListener('click', function(ev){ if(!listBox) return; if(ev.target === input || listBox.contains(ev.target)){ return; } hideList(); });
}
// Relying on hx-trigger delay (150ms) for soft debounce. Added hx-disabled-elt to avoid rapid spamming.
Object.keys(themeInputs).forEach(attachInput);
document.addEventListener('click', function(ev){
var target = ev.target;
var insideInput = Object.keys(themeInputs).some(function(key){ var cfg = themeInputs[key]; return cfg.input && cfg.input === target; });
var insideList = Object.keys(themeInputs).some(function(key){ var cfg = themeInputs[key]; return cfg.list && cfg.list.contains(target); });
if(!insideInput && !insideList){ hideAllLists(); }
});
if(clearThemesButton){
clearThemesButton.addEventListener('click', function(){
clearThemeInputs();
if(window.toast){ try{ toast('Themes cleared'); }catch(e){ /* ignore */ } }
});
}
if(primaryInput){ primaryInput.addEventListener('change', syncLegacy); syncLegacy(); }
function disableRandomButtons(){
var btn1 = document.getElementById('btn-surprise');
var btn2 = document.getElementById('btn-reroll');
if(btn1) btn1.disabled = true;
if(btn2) btn2.disabled = true;
}
function enableRandomButtonsIfReady(){
var btn1 = document.getElementById('btn-surprise');
var btn2 = document.getElementById('btn-reroll');
var now = Date.now();
if(REROLL_THROTTLE_MS > 0 && now < throttleUnlockAt){
scheduleThrottleUnlock();
return;
}
if(throttleTimerId){
clearTimeout(throttleTimerId);
throttleTimerId = null;
}
if(btn1) btn1.disabled = false;
if(btn2){
btn2.disabled = !document.getElementById('current-seed');
}
hideRateLimitBanner();
}
function scheduleThrottleUnlock(){
if(REROLL_THROTTLE_MS <= 0){ return; }
if(throttleTimerId){ clearTimeout(throttleTimerId); }
var delay = Math.max(0, throttleUnlockAt - Date.now());
if(delay === 0){
enableRandomButtonsIfReady();
return;
}
throttleTimerId = setTimeout(function(){ enableRandomButtonsIfReady(); }, delay);
}
function showRateLimitBanner(seconds, message){
var b = document.getElementById('rate-limit-banner');
if(!b){ return; }
var secs = (typeof seconds === 'number' && !isNaN(seconds) && seconds > 0) ? Math.floor(seconds) : null;
var base = message || 'Please wait before trying again';
var update = function(){
if(secs !== null){ b.textContent = base + ' — try again in ' + secs + 's'; }
else { b.textContent = base + ' — please try again shortly'; }
};
update();
b.style.display = 'block';
disableRandomButtons();
if(rateLimitIntervalId){
clearInterval(rateLimitIntervalId);
rateLimitIntervalId = null;
}
if(secs !== null){
rateLimitIntervalId = setInterval(function(){
secs -= 1; update();
if(secs <= 0){
if(rateLimitIntervalId){
clearInterval(rateLimitIntervalId);
rateLimitIntervalId = null;
}
hideRateLimitBanner();
enableRandomButtonsIfReady();
}
}, 1000);
}
}
function hideRateLimitBanner(){
var b = document.getElementById('rate-limit-banner');
if(b){ b.style.display = 'none'; }
if(rateLimitIntervalId){
clearInterval(rateLimitIntervalId);
rateLimitIntervalId = null;
}
}
function remainingThrottleMs(){
if(REROLL_THROTTLE_MS <= 0) return 0;
return Math.max(0, throttleUnlockAt - Date.now());
}
function canTriggerRandomRequest(){
if(REROLL_THROTTLE_MS <= 0) return true;
var remaining = remainingThrottleMs();
if(remaining <= 0){ return true; }
showRateLimitBanner(Math.ceil(remaining / 1000), 'Hold up — reroll throttle active');
scheduleThrottleUnlock();
return false;
}
function markRandomRequestStarted(){
lastRandomRequestAt = Date.now();
if(REROLL_THROTTLE_MS <= 0){ return; }
throttleUnlockAt = lastRandomRequestAt + REROLL_THROTTLE_MS;
disableRandomButtons();
scheduleThrottleUnlock();
}
function applyRetryAfterSeconds(seconds){
if(!seconds || isNaN(seconds) || seconds <= 0){ return; }
throttleUnlockAt = Date.now() + (seconds * 1000);
disableRandomButtons();
scheduleThrottleUnlock();
showRateLimitBanner(seconds, 'Too many requests');
}
document.addEventListener('htmx:configRequest', function(ev){
var detail = ev && ev.detail;
if(!detail) return;
var path = detail.path || '';
if(typeof path !== 'string') return;
if(path.indexOf('/random_reroll') === -1){ return; }
if(!canTriggerRandomRequest()){
ev.preventDefault();
return;
}
markRandomRequestStarted();
});
document.addEventListener('htmx:afterRequest', function(){
// Safety: ensure buttons are always re-enabled after request completes
var b1=document.getElementById('btn-surprise'); var b2=document.getElementById('btn-reroll');
if(b1) b1.disabled=false; if(b2 && document.getElementById('current-seed')) b2.disabled=false;
enableRandomButtonsIfReady();
});
// (No configRequest hook needed; using hx-vals + hx-include for simple form-style submission.)
// Enable reroll once a result exists
document.addEventListener('htmx:afterSwap', function(ev){
if (ev && ev.detail && ev.detail.target && ev.detail.target.id === 'random-result'){
var rr = document.getElementById('btn-reroll'); if (rr) rr.disabled = false;
var rr = document.getElementById('btn-reroll');
if(rr){
if(remainingThrottleMs() > 0){
rr.disabled = true;
scheduleThrottleUnlock();
} else {
rr.disabled = false;
}
}
var hiddenAuto = document.getElementById('current-auto-fill-enabled');
var aggregated = null;
if(hiddenAuto){
var aggVal = (hiddenAuto.value || '').toLowerCase();
if(aggVal){ aggregated = (aggVal === 'true' || aggVal === '1' || aggVal === 'on'); }
}
var hiddenSecondary = document.getElementById('current-auto-fill-secondary-enabled');
var hiddenTertiary = document.getElementById('current-auto-fill-tertiary-enabled');
if(autoFillSecondaryInput){
var secNext = null;
if(hiddenSecondary){
var secVal = (hiddenSecondary.value || '').toLowerCase();
secNext = (secVal === 'true' || secVal === '1' || secVal === 'on');
} else if(aggregated !== null){
secNext = aggregated;
}
if(secNext !== null){ autoFillSecondaryInput.checked = !!secNext; }
}
if(autoFillTertiaryInput){
var terNext = null;
if(hiddenTertiary){
var terVal = (hiddenTertiary.value || '').toLowerCase();
terNext = (terVal === 'true' || terVal === '1' || terVal === 'on');
} else if(aggregated !== null){
terNext = aggregated;
}
if(terNext !== null){ autoFillTertiaryInput.checked = !!terNext; }
}
enforceTertiaryRequirement();
var currentStrict = document.getElementById('current-strict-theme-match');
if(strictCheckbox && currentStrict){
var strictVal = (currentStrict.value || '').toLowerCase();
strictCheckbox.checked = (strictVal === 'true' || strictVal === '1' || strictVal === 'on');
} else if(strictHidden && currentStrict){
var hiddenVal = (currentStrict.value || '').toLowerCase();
strictHidden.value = (hiddenVal === 'true' || hiddenVal === '1' || hiddenVal === 'on') ? 'true' : 'false';
}
syncStrictHidden();
persistStrictPreference();
syncAutoFillHidden();
persistAutoFillPreferences();
hideRateLimitBanner();
// Refresh recent seeds asynchronously
fetch('/api/random/seeds').then(r=>r.json()).then(function(j){
try{
@ -181,7 +729,8 @@
var msg = 'Too many requests';
if(secs && !isNaN(secs)) msg += ' — try again in ' + secs + 's';
if(window.toast) { toast(msg); } else { alert(msg); }
showRateLimitBanner(secs);
if(secs && !isNaN(secs)) applyRetryAfterSeconds(secs);
else showRateLimitBanner(null, 'Too many requests');
}
}catch(e){/* no-op */}
});
@ -210,8 +759,8 @@
favorites.forEach(function(s){
var btn=document.createElement('button'); btn.type='button'; btn.className='btn'; btn.textContent=s; btn.style.cssText='font-size:10px; margin-right:4px; padding:2px 5px;';
btn.addEventListener('click', function(){
fetch('/hx/random_reroll', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ seed: s-1, theme: document.getElementById('random-theme').value || null }) })
.then(r=>r.text()).then(html=>{ var target=document.getElementById('random-result'); if(target){ target.outerHTML=html; } });
var payload = collectThemePayload({ seed: s-1 });
submitRandomPayload(payload);
});
container.appendChild(btn);
});
@ -229,10 +778,8 @@
b.setAttribute('aria-label','Rebuild using seed '+s);
b.addEventListener('click', function(){
// Post to reroll endpoint but treat as explicit seed build
fetch('/hx/random_reroll', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ seed: s-1, theme: document.getElementById('random-theme').value || null }) })
.then(r=> r.text())
.then(html=>{ var target=document.getElementById('random-result'); if(target){ target.outerHTML=html; } })
.catch(()=>{});
var payload = collectThemePayload({ seed: s-1 });
submitRandomPayload(payload);
});
span.appendChild(b);
span.appendChild(favoriteButton(s, favorites || []));
@ -260,13 +807,114 @@
// Persist last used theme in localStorage
try {
var THEME_KEY='random_last_theme';
if(input){
var prev = localStorage.getItem(THEME_KEY);
if(prev && !input.value){ input.value = prev; }
input.addEventListener('change', function(){ localStorage.setItem(THEME_KEY, input.value || ''); });
var THEME_KEY='random_last_primary_theme';
var LEGACY_KEY='random_last_theme';
var SECONDARY_KEY='random_last_secondary_theme';
var TERTIARY_KEY='random_last_tertiary_theme';
if(primaryInput){
var prev = localStorage.getItem(THEME_KEY) || localStorage.getItem(LEGACY_KEY);
if(prev && !primaryInput.value){ primaryInput.value = prev; }
primaryInput.addEventListener('change', function(){
localStorage.setItem(THEME_KEY, primaryInput.value || '');
localStorage.setItem(LEGACY_KEY, primaryInput.value || '');
syncLegacy();
});
syncLegacy();
}
} catch(e) { /* ignore */ }
if(secondaryInput){
var prevSecondary = localStorage.getItem(SECONDARY_KEY);
if(prevSecondary && !secondaryInput.value){ secondaryInput.value = prevSecondary; }
secondaryInput.addEventListener('change', function(){
localStorage.setItem(SECONDARY_KEY, secondaryInput.value || '');
});
}
if(tertiaryInput){
var prevTertiary = localStorage.getItem(TERTIARY_KEY);
if(prevTertiary && !tertiaryInput.value){ tertiaryInput.value = prevTertiary; }
tertiaryInput.addEventListener('change', function(){
localStorage.setItem(TERTIARY_KEY, tertiaryInput.value || '');
});
}
var legacyAutoFill = null;
try { legacyAutoFill = localStorage.getItem(AUTO_FILL_KEY); } catch(e){ legacyAutoFill = null; }
function restoreAutoFillCheckbox(input, storedValue){
if(!input) return;
if(storedValue !== null){
input.checked = (storedValue === '1' || storedValue === 'true' || storedValue === 'on');
}
input.addEventListener('change', function(){
enforceTertiaryRequirement();
syncAutoFillHidden();
persistAutoFillPreferences();
});
}
var storedSecondary = null;
try { storedSecondary = localStorage.getItem(AUTO_FILL_SECONDARY_KEY); } catch(e){ storedSecondary = null; }
if(storedSecondary === null){ storedSecondary = legacyAutoFill; }
restoreAutoFillCheckbox(autoFillSecondaryInput, storedSecondary);
var storedTertiary = null;
try { storedTertiary = localStorage.getItem(AUTO_FILL_TERTIARY_KEY); } catch(e){ storedTertiary = null; }
if(storedTertiary === null){ storedTertiary = legacyAutoFill; }
restoreAutoFillCheckbox(autoFillTertiaryInput, storedTertiary);
enforceTertiaryRequirement();
syncAutoFillHidden();
persistAutoFillPreferences();
if(strictCheckbox){
var storedStrict = null;
try { storedStrict = localStorage.getItem(STRICT_KEY); } catch(e){ storedStrict = null; }
if(storedStrict !== null){
strictCheckbox.checked = (storedStrict === '1' || storedStrict === 'true' || storedStrict === 'on');
}
syncStrictHidden();
strictCheckbox.addEventListener('change', function(){
syncStrictHidden();
persistStrictPreference();
});
persistStrictPreference();
} else {
syncStrictHidden();
}
} catch(e) { /* ignore */ }
syncStrictHidden();
(function(){
var tooltipWrappers = document.querySelectorAll('.theme-tooltip');
if(!tooltipWrappers.length) return;
tooltipWrappers.forEach(function(tooltipWrapper){
var button = tooltipWrapper.querySelector('button.help-icon');
var panel = tooltipWrapper.querySelector('.tooltip-panel');
if(!button || !panel) return;
function setOpen(state){
tooltipWrapper.dataset.open = state ? 'true' : 'false';
button.setAttribute('aria-expanded', state ? 'true' : 'false');
}
function handleDocumentClick(ev){
if(!tooltipWrapper.contains(ev.target)){ setOpen(false); }
}
button.addEventListener('click', function(){
var isOpen = tooltipWrapper.dataset.open === 'true';
setOpen(!isOpen);
if(!isOpen){ panel.focus({preventScroll:true}); }
});
button.addEventListener('keypress', function(ev){
if(ev.key === 'Enter' || ev.key === ' '){ ev.preventDefault(); button.click(); }
});
tooltipWrapper.addEventListener('mouseenter', function(){ setOpen(true); });
tooltipWrapper.addEventListener('mouseleave', function(){ setOpen(false); });
button.addEventListener('focus', function(){ setOpen(true); });
button.addEventListener('blur', function(){
setTimeout(function(){
if(!tooltipWrapper.contains(document.activeElement)){ setOpen(false); }
}, 0);
});
panel.setAttribute('tabindex','-1');
panel.addEventListener('keydown', function(ev){ if(ev.key === 'Escape'){ setOpen(false); button.focus(); } });
document.addEventListener('click', handleDocumentClick);
});
})();
})();
</script>
{% endif %}