feat: add supplemental theme catalog tooling, additional theme selection, and custom theme selection

This commit is contained in:
matt 2025-10-03 10:43:24 -07:00
parent 3a1b011dbc
commit 9428e09cef
39 changed files with 3643 additions and 198 deletions

View file

@ -0,0 +1,136 @@
{% set state = theme_state or {} %}
{% set resolution = state.get('resolution', {}) %}
{% set matches = resolution.get('matches', []) or [] %}
{% set unresolved = resolution.get('unresolved', []) or [] %}
{% set resolved_labels = resolution.get('resolved', []) or [] %}
{% set limit = state.get('limit', 8) %}
{% set remaining = state.get('remaining', limit) %}
{% set disable_add = remaining <= 0 %}
<fieldset id="custom-theme-root" style="margin-top:1rem; border:1px solid var(--border); border-radius:8px; padding:0.75rem;" hx-on::afterSwap="const field=this.querySelector('[data-theme-input]'); if(field){field.value=''; field.focus();}">
<legend style="font-weight:600;">Additional Themes</legend>
<p class="muted" style="margin:0 0 .5rem 0; font-size:12px;">
Add up to {{ limit }} supplemental themes to guide the build.
<span{% if disable_add %} style="color:#fca5a5;"{% endif %}> {{ remaining }} slot{% if remaining != 1 %}s{% endif %} remaining.</span>
</p>
<div id="custom-theme-live" role="status" aria-live="polite" class="sr-only">{{ theme_message or '' }}</div>
{% if theme_message %}
{% if theme_message_level == 'success' %}
<div class="theme-alert" data-level="success" style="margin-bottom:.5rem; padding:.5rem; border-radius:6px; font-size:12px; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.4); color:#bbf7d0;">{{ theme_message }}</div>
{% elif theme_message_level == 'warning' %}
<div class="theme-alert" data-level="warning" style="margin-bottom:.5rem; padding:.5rem; border-radius:6px; font-size:12px; background:rgba(250,204,21,0.15); border:1px solid rgba(250,204,21,0.45); color:#facc15;">{{ theme_message }}</div>
{% elif theme_message_level == 'error' %}
<div class="theme-alert" data-level="error" style="margin-bottom:.5rem; padding:.5rem; border-radius:6px; font-size:12px; background:rgba(248,113,113,0.12); border:1px solid rgba(248,113,113,0.45); color:#fca5a5;">{{ theme_message }}</div>
{% else %}
<div class="theme-alert" data-level="info" style="margin-bottom:.5rem; padding:.5rem; border-radius:6px; font-size:12px; background:rgba(59,130,246,0.1); border:1px solid rgba(59,130,246,0.35); color:#cbd5f5;">{{ theme_message }}</div>
{% endif %}
{% endif %}
<div data-theme-add-container
hx-post="/build/themes/add"
hx-target="#custom-theme-root"
hx-swap="outerHTML"
hx-trigger="click from:button[data-theme-add-btn]"
hx-include="[data-theme-input]"
style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
<label style="flex:1; min-width:220px;">
<span class="sr-only">Theme name</span>
<input type="text" name="theme" data-theme-input placeholder="e.g., Lifegain" maxlength="60" autocomplete="off" autocapitalize="off" spellcheck="false" style="width:100%; padding:.5rem; border-radius:6px; border:1px solid var(--border); background:var(--input-bg, #161921); color:var(--text-color, #f9fafb);" {% if disable_add %}disabled aria-disabled="true"{% endif %} />
</label>
<button type="button" data-theme-add-btn class="btn" style="padding:.45rem 1rem;" {% if disable_add %}disabled aria-disabled="true"{% endif %}>Add theme</button>
</div>
<div style="margin-top:.75rem; display:flex; gap:1rem; flex-wrap:wrap; font-size:12px; align-items:center;">
<span class="muted">Matching mode:</span>
<label style="display:inline-flex; align-items:center; gap:.35rem;">
<input type="radio" name="mode" value="permissive" {% if state.get('mode') == 'permissive' %}checked{% endif %}
hx-trigger="change"
hx-post="/build/themes/mode"
hx-target="#custom-theme-root"
hx-swap="outerHTML"
hx-vals='{"mode":"permissive"}'
/> Permissive
</label>
<label style="display:inline-flex; align-items:center; gap:.35rem;">
<input type="radio" name="mode" value="strict" {% if state.get('mode') == 'strict' %}checked{% endif %}
hx-trigger="change"
hx-post="/build/themes/mode"
hx-target="#custom-theme-root"
hx-swap="outerHTML"
hx-vals='{"mode":"strict"}'
/> Strict
</label>
</div>
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:.75rem; margin-top:1rem;">
<div>
<h4 style="font-size:12px; text-transform:uppercase; letter-spacing:.05em; margin:0 0 .5rem 0; color:var(--text-muted, #9ca3af);">Resolved</h4>
{% if resolved_labels %}
<div style="display:flex; flex-wrap:wrap; gap:.5rem;">
{% for match in matches %}
{% set matched = match.matched if match.matched is defined else match['matched'] %}
{% set original = match.input if match.input is defined else match['input'] %}
{% set score_val = match.score if match.score is defined else match['score'] %}
{% set score_pct = score_val if score_val is not none else None %}
{% set reason_code = match.reason if match.reason is defined else match['reason'] %}
<div class="theme-chip" style="display:inline-flex; align-items:center; gap:.35rem; padding:.35rem .6rem; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.35); border-radius:999px; font-size:12px;" title="{{ matched }}{% if score_pct is not none %} · {{ '%.0f'|format(score_pct) }}% confidence{% endif %}{% if reason_code %} ({{ reason_code|replace('_',' ')|title }}){% endif %}">
<span>{{ matched }}</span>
{% if original and original.casefold() != matched.casefold() %}
<span class="muted" style="font-size:11px;">(from “{{ original }}”)</span>
{% endif %}
<button type="button" hx-post="/build/themes/remove" hx-target="#custom-theme-root" hx-swap="outerHTML" hx-vals="{{ {'theme': original}|tojson }}" title="Remove theme" style="background:none; border:none; color:inherit; font-weight:bold; cursor:pointer;">×</button>
</div>
{% endfor %}
{% if not matches and resolved_labels %}
{% for label in resolved_labels %}
<div class="theme-chip" style="display:inline-flex; align-items:center; gap:.35rem; padding:.35rem .6rem; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.35); border-radius:999px; font-size:12px;">
<span>{{ label }}</span>
</div>
{% endfor %}
{% endif %}
</div>
{% else %}
<div class="muted" style="font-size:12px;">No supplemental themes yet.</div>
{% endif %}
</div>
<div>
<h4 style="font-size:12px; text-transform:uppercase; letter-spacing:.05em; margin:0 0 .5rem 0; color:var(--text-muted, #fbbf24);">Needs attention</h4>
{% if unresolved %}
<div style="display:flex; flex-direction:column; gap:.5rem;">
{% for item in unresolved %}
<div style="border:1px solid rgba(234,179,8,0.4); background:rgba(250,204,21,0.08); border-radius:8px; padding:.5rem; font-size:12px;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:.5rem;">
<strong>{{ item.input }}</strong>
<button type="button" class="btn" hx-post="/build/themes/remove" hx-target="#custom-theme-root" hx-swap="outerHTML" hx-vals="{{ {'theme': item.input}|tojson }}" style="padding:.25rem .6rem; font-size:11px; background:#7f1d1d; border-color:#dc2626;">Remove</button>
</div>
{% if item.reason %}
<div class="muted" style="margin-top:.35rem; font-size:11px;">Reason: {{ item.reason|replace('_',' ')|title }}</div>
{% endif %}
{% if item.suggestions %}
<div style="margin-top:.5rem; display:flex; flex-wrap:wrap; gap:.35rem;">
{% for suggestion in item.suggestions[:3] %}
{% set suggestion_theme = suggestion.theme if suggestion.theme is defined else suggestion.get('theme') %}
{% set suggestion_score = suggestion.score if suggestion.score is defined else suggestion.get('score') %}
{% if suggestion_theme %}
<button type="button" class="btn" hx-post="/build/themes/choose" hx-target="#custom-theme-root" hx-swap="outerHTML" hx-vals="{{ {'original': item.input, 'choice': suggestion_theme}|tojson }}" style="padding:.25rem .5rem; font-size:11px; background:#1d4ed8; border-color:#2563eb;">
Use {{ suggestion_theme }}{% if suggestion_score is not none %} ({{ '%.0f'|format(suggestion_score) }}%){% endif %}
</button>
{% endif %}
{% endfor %}
</div>
{% else %}
<div class="muted" style="margin-top:.35rem; font-size:11px;">No close matches found.</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="muted" style="font-size:12px;">All themes resolved.</div>
{% endif %}
</div>
</div>
<div class="muted" style="margin-top:.75rem; font-size:11px;">
Catalog version: {{ resolution.get('catalog_version', 'unknown') }} · Mode: {{ state.get('mode', 'permissive')|title }}
</div>
</fieldset>

View file

@ -40,6 +40,9 @@
<input type="hidden" name="tag_mode" value="AND" />
</div>
<div id="newdeck-multicopy-slot" class="muted" style="margin-top:.5rem; min-height:1rem;"></div>
{% if enable_custom_themes %}
{% include "build/_new_deck_additional_themes.html" %}
{% endif %}
<div style="margin-top:.5rem;" id="newdeck-bracket-slot">
<label>Bracket
<select name="bracket">

View file

@ -6,6 +6,8 @@
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">System summary</h3>
<div id="sysSummary" class="muted">Loading…</div>
<div id="envFlags" style="margin-top:.5rem"></div>
<div id="themeSuppMetrics" class="muted" style="margin-top:.5rem">Loading theme metrics…</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">
@ -15,7 +17,7 @@
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Multi-face merge snapshot</h3>
<div class="muted" style="margin-bottom:.35rem">Pulls from <code>logs/dfc_merge_summary.json</code> to verify merge coverage.</div>
{% set colors = merge_summary.get('colors') if merge_summary else {} %}
{% set colors = (merge_summary.colors if merge_summary else {}) | default({}) %}
{% if colors %}
<div class="muted" style="margin-bottom:.35rem">Last updated: {{ merge_summary.updated_at or 'unknown' }}</div>
<div style="overflow-x:auto">
@ -30,28 +32,29 @@
</tr>
</thead>
<tbody>
{% for color, payload in colors.items()|dictsort %}
{% for item in colors|dictsort %}
{% set color = item[0] %}
{% set payload = item[1] | default({}) %}
<tr style="border-bottom:1px solid rgba(148,163,184,0.2);">
<td style="padding:.35rem .25rem; font-weight:600;">{{ color|title }}</td>
<td style="padding:.35rem .25rem;">{{ payload.group_count or 0 }}</td>
<td style="padding:.35rem .25rem;">{{ payload.faces_dropped or 0 }}</td>
<td style="padding:.35rem .25rem;">{{ payload.multi_face_rows or 0 }}</td>
<td style="padding:.35rem .25rem;">
{% set entries = payload.entries or [] %}
{% set entries = (payload.entries | default([])) %}
{% if entries %}
<details>
<summary style="cursor:pointer;">{{ entries|length }} recorded</summary>
{% set preview = entries[:5] %}
<ul style="margin:.35rem 0 0 .75rem; padding:0; list-style:disc; max-height:180px; overflow:auto;">
{% for entry in entries %}
{% if loop.index0 < 5 %}
<li style="margin-bottom:.25rem;">
<strong>{{ entry.name }}</strong> — {{ entry.total_faces }} faces (dropped {{ entry.dropped_faces }})
</li>
{% elif loop.index0 == 5 %}
<li style="font-size:11px; opacity:.75;">… {{ entries|length - 5 }} more entries</li>
{% break %}
{% endif %}
{% for entry in preview %}
<li style="margin-bottom:.25rem;">
<strong>{{ entry.name }}</strong> — {{ entry.total_faces }} faces (dropped {{ entry.dropped_faces }})
</li>
{% endfor %}
{% if entries|length > preview|length %}
<li style="font-size:11px; opacity:.75;">… {{ entries|length - preview|length }} more entries</li>
{% endif %}
</ul>
</details>
{% else %}
@ -134,6 +137,125 @@
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();
// Environment flags card
(function(){
var target = document.getElementById('envFlags');
if (!target) return;
function renderEnv(data){
if (!data || !data.flags) { target.textContent = 'Flags unavailable'; return; }
var f = data.flags;
function as01(v){ return (v ? '1' : '0'); }
var lines = [];
lines.push('<div><strong>Homepage & UI:</strong> '
+ 'SHOW_SETUP=' + as01(f.SHOW_SETUP)
+ ', SHOW_LOGS=' + as01(f.SHOW_LOGS)
+ ', SHOW_DIAGNOSTICS=' + as01(f.SHOW_DIAGNOSTICS)
+ ', SHOW_COMMANDERS=' + as01(f.SHOW_COMMANDERS)
+ ', ENABLE_THEMES=' + as01(f.ENABLE_THEMES)
+ ', ENABLE_CUSTOM_THEMES=' + as01(f.ENABLE_CUSTOM_THEMES)
+ ', ALLOW_MUST_HAVES=' + as01(f.ALLOW_MUST_HAVES)
+ ', THEME=' + String(f.DEFAULT_THEME || '')
+ ', THEME_MATCH_MODE=' + String(f.THEME_MATCH_MODE || '')
+ ', USER_THEME_LIMIT=' + String(f.USER_THEME_LIMIT || '')
+ '</div>');
lines.push('<div><strong>Random:</strong> '
+ 'RANDOM_MODES=' + as01(f.RANDOM_MODES)
+ ', RANDOM_UI=' + as01(f.RANDOM_UI)
+ ', RANDOM_MAX_ATTEMPTS=' + String(f.RANDOM_MAX_ATTEMPTS || '')
+ ', RANDOM_TIMEOUT_MS=' + String(f.RANDOM_TIMEOUT_MS || '')
+ ', RANDOM_REROLL_THROTTLE_MS=' + String(f.RANDOM_REROLL_THROTTLE_MS || '')
+ ', RANDOM_TELEMETRY=' + as01(f.RANDOM_TELEMETRY)
+ ', RANDOM_STRUCTURED_LOGS=' + as01(f.RANDOM_STRUCTURED_LOGS)
+ '</div>');
lines.push('<div><strong>Rate limiting (random):</strong> '
+ 'RATE_LIMIT_ENABLED=' + as01(f.RATE_LIMIT_ENABLED)
+ ', WINDOW_S=' + String(f.RATE_LIMIT_WINDOW_S || '')
+ ', RANDOM=' + String(f.RANDOM_RATE_LIMIT_RANDOM || '')
+ ', BUILD=' + String(f.RANDOM_RATE_LIMIT_BUILD || '')
+ ', SUGGEST=' + String(f.RANDOM_RATE_LIMIT_SUGGEST || '')
+ '</div>');
target.innerHTML = lines.join('');
}
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(renderEnv).catch(function(){ target.textContent='Flags unavailable'; }); } catch(_){ target.textContent='Flags unavailable'; }
})();
var themeSuppEl = document.getElementById('themeSuppMetrics');
function renderThemeSupp(payload){
if (!themeSuppEl) return;
try {
if (!payload || payload.ok !== true) {
themeSuppEl.textContent = 'Theme metrics unavailable';
return;
}
var metrics = payload.metrics || {};
var total = metrics.total_builds != null ? Number(metrics.total_builds) : 0;
if (!total) {
themeSuppEl.textContent = 'No deck builds recorded yet.';
return;
}
var withUser = metrics.with_user_themes != null ? Number(metrics.with_user_themes) : 0;
var share = metrics.user_theme_share != null ? Number(metrics.user_theme_share) : 0;
var sharePct = !Number.isNaN(share) ? (share * 100).toFixed(1) + '%' : '0%';
var summary = metrics.last_summary || {};
var commander = Array.isArray(summary.commanderThemes) ? summary.commanderThemes : [];
var user = Array.isArray(summary.userThemes) ? summary.userThemes : [];
var merged = Array.isArray(summary.mergedThemes) ? summary.mergedThemes : [];
var unresolvedCount = summary.unresolvedCount != null ? Number(summary.unresolvedCount) : 0;
var unresolved = Array.isArray(summary.unresolved) ? summary.unresolved : [];
var mode = summary.mode || 'AND';
var weight = summary.weight != null ? Number(summary.weight) : 1;
var updated = metrics.last_updated || '';
var topUser = Array.isArray(metrics.top_user_themes) ? metrics.top_user_themes : [];
function joinList(arr){
if (!arr || !arr.length) return '—';
return arr.join(', ');
}
var html = '';
html += '<div><strong>Total builds:</strong> ' + String(total) + ' (user themes ' + String(withUser) + '\u00a0| ' + sharePct + ')</div>';
if (updated) {
html += '<div style="font-size:11px;">Last updated: ' + String(updated) + '</div>';
}
html += '<div><strong>Commander themes:</strong> ' + joinList(commander) + '</div>';
html += '<div><strong>User themes:</strong> ' + joinList(user) + '</div>';
html += '<div><strong>Merged:</strong> ' + joinList(merged) + '</div>';
var unresolvedLabel = '0';
if (unresolvedCount > 0) {
unresolvedLabel = String(unresolvedCount) + ' (' + joinList(unresolved) + ')';
} else {
unresolvedLabel = '0';
}
html += '<div><strong>Unresolved:</strong> ' + unresolvedLabel + '</div>';
html += '<div style="font-size:11px;">Mode ' + String(mode) + ' · Weight ' + weight.toFixed(2) + '</div>';
if (topUser.length) {
var topLine = topUser.slice(0, 5).map(function(item){
if (!item) return '';
var t = item.theme != null ? String(item.theme) : '';
var c = item.count != null ? String(item.count) : '0';
return t + ' (' + c + ')';
}).filter(Boolean);
if (topLine.length) {
html += '<div style="font-size:11px; opacity:0.75;">Top user themes: ' + topLine.join(', ') + '</div>';
}
}
themeSuppEl.innerHTML = html;
} catch (_){
themeSuppEl.textContent = 'Theme metrics unavailable';
}
}
function loadThemeSupp(){
if (!themeSuppEl) return;
themeSuppEl.textContent = 'Loading theme metrics…';
fetch('/status/theme_metrics', { cache: 'no-store' })
.then(function(resp){
if (resp.status === 404) {
themeSuppEl.textContent = 'Diagnostics disabled (metrics unavailable)';
return null;
}
return resp.json();
})
.then(function(data){ if (data) renderThemeSupp(data); })
.catch(function(){ themeSuppEl.textContent = 'Theme metrics unavailable'; });
}
loadThemeSupp();
var tokenEl = document.getElementById('themeTokenStats');
function renderTokens(payload){
if (!tokenEl) return;