Web + backend: propagate tag_mode (AND/OR) end-to-end; AND-mode overlap prioritization for creatures and theme spells; headless configs support tag_mode; add Scryfall attribution footer and configs UI indicators; minor polish. (#and-overlap-pass)

This commit is contained in:
mwisnowski 2025-08-26 11:34:42 -07:00
parent 0f73a85a4e
commit fd7fc01071
15 changed files with 1339 additions and 75 deletions

View file

@ -1,28 +1,44 @@
<section>
<h3>Step 1: Choose a Commander</h3>
<form id="cmdr-search-form" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML">
<label>Search by name</label>
<input id="cmdr-search" type="text" name="query" value="{{ query or '' }}" autocomplete="off" />
<form id="cmdr-search-form" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML" aria-label="Commander search form" role="search">
<label for="cmdr-search">Search by name</label>
<span class="input-wrap">
<input id="cmdr-search" type="text" name="query" value="{{ query or '' }}" autocomplete="off" aria-describedby="cmdr-help" aria-controls="candidate-grid" placeholder="Type a commander name…" />
<button id="cmdr-clear" type="button" class="clear-btn" title="Clear search" aria-label="Clear search" hidden>×</button>
</span>
<input id="active-name" type="hidden" name="active" value="{{ active or '' }}" />
<button type="submit">Search</button>
<label style="margin-left:.5rem; font-weight:normal;">
<input type="checkbox" name="auto" value="1" {% if auto %}checked{% endif %} /> Auto-select top match (very confident)
</label>
<span id="search-spinner" class="spinner" aria-hidden="true" hidden style="display:none;"></span>
</form>
<div class="muted" style="margin:.35rem 0 .5rem 0; font-size:.9rem;">
Tip: Press Enter to select the highlighted result, or use Up/Down to navigate. If your query is a full first word (e.g., "vivi"), exact first-word matches are prioritized.
<div id="cmdr-help" class="muted" style="margin:.35rem 0 .5rem 0; font-size:.9rem;">
Tip: Press Enter to select the highlighted result, or use arrow keys to navigate. If your query is a full first word (e.g., "vivi"), exact first-word matches are prioritized.
</div>
<div id="selection-live" class="sr-only" aria-live="polite" role="status"></div>
<div id="results-live" class="sr-only" aria-live="polite" role="status"></div>
<div id="kbd-hint" class="hint" hidden>
<span class="hint-text">Use
<span class="keys"><kbd></kbd><kbd></kbd></span> to navigate, <kbd>Enter</kbd> to select
</span>
<button type="button" class="hint-close" title="Dismiss keyboard hint" aria-label="Dismiss">×</button>
</div>
{% if candidates %}
<h4>Top matches</h4>
<div class="candidate-grid" id="candidate-grid">
<h4 style="display:flex; align-items:center; gap:.5rem;">
Top matches
<small class="muted" aria-live="polite">{% if count is defined %}{{ count }} result{% if count != 1 %}s{% endif %}{% else %}{{ (candidates|length) if candidates else 0 }} results{% endif %}</small>
</h4>
<div class="candidate-grid" id="candidate-grid" role="list">
{% for name, score, colors in candidates %}
<div class="candidate-tile" data-card-name="{{ name }}">
<div class="candidate-tile{% if active and active == name %} active{% endif %}" data-card-name="{{ name }}" role="listitem" aria-selected="{% if active and active == name %}true{% else %}false{% endif %}">
<form hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
<input type="hidden" name="name" value="{{ name }}" />
<button class="img-btn" type="submit" title="Select {{ name }} (score {{ score }})">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ name|urlencode }}&format=image&version=normal"
alt="{{ name }}" />
alt="{{ name }}" loading="lazy" decoding="async" />
</button>
</form>
<div class="meta">
@ -47,6 +63,12 @@
</div>
{% endif %}
{% if (query is defined and query and (not candidates or (candidates|length == 0))) and not inspect %}
<div id="candidate-grid" class="muted" style="margin-top:.5rem;" aria-live="polite">
No results for “{{ query }}”. Try a shorter name or a different spelling.
</div>
{% endif %}
{% if inspect and inspect.ok %}
<div class="two-col two-col-left-rail">
<aside class="card-preview card-sm" data-card-name="{{ selected }}">
@ -94,7 +116,58 @@
var input = document.getElementById('cmdr-search');
var form = document.getElementById('cmdr-search-form');
var grid = document.getElementById('candidate-grid');
var spinner = document.getElementById('search-spinner');
var activeField = document.getElementById('active-name');
var selLive = document.getElementById('selection-live');
var resultsLive = document.getElementById('results-live');
var hint = document.getElementById('kbd-hint');
var defaultPlaceholder = (input && input.placeholder) ? input.placeholder : 'Type a commander name…';
var clearBtn = document.getElementById('cmdr-clear');
var initialDescribedBy = (input && input.getAttribute('aria-describedby')) || '';
// Persist auto-select preference
try {
var autoCb = document.querySelector('input[name="auto"][type="checkbox"]');
if (autoCb) {
var saved = localStorage.getItem('step1-auto');
if (saved === '1' || saved === '0') autoCb.checked = (saved === '1');
autoCb.addEventListener('change', function(){ localStorage.setItem('step1-auto', autoCb.checked ? '1' : '0'); });
}
} catch(_){ }
if (!input || !form) return;
// Show keyboard hint only when candidates exist and user hasn't dismissed it
function showHintIfNeeded() {
try {
if (!hint) return;
var dismissed = localStorage.getItem('step1-hint-dismissed') === '1';
var hasTiles = !!(document.getElementById('candidate-grid') && document.getElementById('candidate-grid').querySelector('.candidate-tile'));
var shouldShow = !(dismissed || !hasTiles);
hint.hidden = !shouldShow;
// Link hint to input a11y description only when visible
if (input) {
var base = initialDescribedBy.trim();
var parts = base ? base.split(/\s+/) : [];
var idx = parts.indexOf('kbd-hint');
if (shouldShow) {
if (idx === -1) parts.push('kbd-hint');
} else {
if (idx !== -1) parts.splice(idx, 1);
}
if (parts.length) input.setAttribute('aria-describedby', parts.join(' '));
else input.removeAttribute('aria-describedby');
}
} catch(_) { /* noop */ }
}
showHintIfNeeded();
// Close button for hint
try {
var closeBtn = hint ? hint.querySelector('.hint-close') : null;
if (closeBtn) {
closeBtn.addEventListener('click', function(){
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
if (hint) hint.hidden = true;
});
}
} catch(_){ }
// Debounce live search
var t = null;
function submit(){
@ -106,15 +179,48 @@
input.addEventListener('input', function(){
if (t) clearTimeout(t);
t = setTimeout(submit, 250);
try { if (clearBtn) clearBtn.hidden = !(input && input.value && input.value.length); } catch(_){ }
});
// Initialize clear visibility
try { if (clearBtn) clearBtn.hidden = !(input && input.value && input.value.length); } catch(_){ }
if (clearBtn) clearBtn.addEventListener('click', function(){
if (!input) return;
input.value = '';
try { clearBtn.hidden = true; } catch(_){ }
if (t) clearTimeout(t);
t = setTimeout(submit, 0);
try { input.focus(); } catch(_){}
});
// Focus the search box on load if nothing else is focused
try {
var ae = document.activeElement;
if (input && (!ae || ae === document.body)) { input.focus(); input.select && input.select(); }
} catch(_){}
// Quick focus: press "/" to focus the search input (unless already typing)
document.addEventListener('keydown', function(e){
if (e.key !== '/') return;
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
if (isEditable) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault();
if (input) { input.focus(); try { input.select(); } catch(_){} }
});
// Keyboard navigation: up/down to move selection, Enter to choose/inspect
document.addEventListener('keydown', function(e){
// Dismiss hint on first keyboard navigation
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter') {
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
if (hint) hint.hidden = true;
}
if (!grid || !grid.children || grid.children.length === 0) return;
var tiles = Array.prototype.slice.call(grid.querySelectorAll('.candidate-tile'));
// Ensure something is selected by default
var idx = tiles.findIndex(function(el){ return el.classList.contains('active'); });
if (idx < 0 && tiles.length > 0) {
tiles[0].classList.add('active');
try { if (activeField) activeField.value = tiles[0].dataset.cardName || ''; } catch(_){}
try { if (selLive) selLive.textContent = 'Selected ' + (tiles[0].dataset.cardName || ''); } catch(_){}
idx = 0;
}
@ -129,8 +235,11 @@
function setActive(newIdx) {
// Clamp to bounds; wrapping handled by callers
newIdx = Math.max(0, Math.min(tiles.length - 1, newIdx));
tiles.forEach(function(el){ el.classList.remove('active'); });
tiles[newIdx].classList.add('active');
tiles.forEach(function(el){ el.classList.remove('active'); el.setAttribute('aria-selected', 'false'); });
tiles[newIdx].classList.add('active');
tiles[newIdx].setAttribute('aria-selected', 'true');
try { if (activeField) activeField.value = tiles[newIdx].dataset.cardName || ''; } catch(_){}
try { if (selLive) selLive.textContent = 'Selected ' + (tiles[newIdx].dataset.cardName || ''); } catch(_){}
tiles[newIdx].scrollIntoView({ block: 'nearest', inline: 'nearest' });
return newIdx;
}
@ -178,8 +287,30 @@
if (btn) btn.click();
}
}
} else if (e.key === 'Escape') {
// ESC clears the search field and triggers a refresh
if (input && input.value) {
input.value = '';
if (t) clearTimeout(t);
t = setTimeout(submit, 0);
}
}
});
// Persist current active on click selection movement too
if (grid) {
grid.addEventListener('click', function(e){
// Dismiss hint on interaction
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
if (hint) hint.hidden = true;
var tile = e.target.closest('.candidate-tile');
if (!tile) return;
grid.querySelectorAll('.candidate-tile').forEach(function(el){ el.classList.remove('active'); el.setAttribute('aria-selected', 'false'); });
tile.classList.add('active');
tile.setAttribute('aria-selected', 'true');
try { if (activeField) activeField.value = tile.dataset.cardName || ''; } catch(_){}
try { if (selLive) selLive.textContent = 'Selected ' + (tile.dataset.cardName || ''); } catch(_){}
});
}
// Highlight matched text
try {
var q = (input.value || '').trim().toLowerCase();
@ -194,6 +325,44 @@
});
}
} catch(_){}
// HTMX spinner binding for this form — only show if no results are currently displayed
if (window.htmx && form) {
form.addEventListener('htmx:beforeRequest', function(){
var hasTiles = false;
try { hasTiles = !!(grid && grid.querySelector('.candidate-tile')); } catch(_){}
if (spinner) spinner.hidden = hasTiles ? true : false;
if (!hasTiles && input) input.placeholder = 'Searching…';
try { form.setAttribute('aria-busy', 'true'); } catch(_){ }
if (resultsLive) resultsLive.textContent = 'Searching…';
});
form.addEventListener('htmx:afterSwap', function(){
if (spinner) spinner.hidden = true; if (input) input.placeholder = defaultPlaceholder;
// After swap, if there are no candidate tiles, clear active selection and live text
try {
var grid2 = document.getElementById('candidate-grid');
var hasAny = !!(grid2 && grid2.querySelector('.candidate-tile'));
if (!hasAny) {
if (activeField) activeField.value = '';
if (selLive) selLive.textContent = '';
}
// Re-evaluate hint visibility post-swap
showHintIfNeeded();
// Announce results count
try {
var qNow = (input && input.value) ? input.value.trim() : '';
var cnt = 0;
if (grid2) cnt = grid2.querySelectorAll('.candidate-tile').length;
if (resultsLive) {
if (cnt > 0) resultsLive.textContent = cnt + (cnt === 1 ? ' result' : ' results');
else if (qNow) resultsLive.textContent = 'No results for "' + qNow + '"';
else resultsLive.textContent = '';
}
} catch(_){ }
try { form.removeAttribute('aria-busy'); } catch(_){ }
} catch(_){ }
});
form.addEventListener('htmx:responseError', function(){ if (spinner) spinner.hidden = true; if (input) input.placeholder = defaultPlaceholder; });
}
})();
</script>
<style>
@ -211,4 +380,15 @@
.chip-c { background:#f3f4f6; color:#111827; border-color:#e5e7eb; }
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
.candidate-tile { cursor: pointer; }
.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; }
.spinner { display:inline-block; width:16px; height:16px; border:2px solid #93c5fd; border-top-color: transparent; border-radius:50%; animation: spin 0.8s linear infinite; vertical-align:middle; margin-left:.4rem; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Ensure hidden attribute always hides spinner within this fragment */
.spinner[hidden] { display: none !important; }
.hint { display:flex; align-items:center; gap:.5rem; background:#0b1220; border:1px solid var(--border); color:#cbd5e1; padding:.4rem .6rem; border-radius:8px; margin:.4rem 0 .6rem; }
.hint .hint-close { background:transparent; border:0; color:#9aa4b2; font-size:1rem; line-height:1; cursor:pointer; }
.hint .keys kbd { background:#1f2937; color:#e5e7eb; padding:.1rem .3rem; border-radius:4px; margin:0 .1rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size:.85em; }
.input-wrap { position: relative; display:inline-flex; align-items:center; }
.clear-btn { position:absolute; right:.35rem; background:transparent; color:#9aa4b2; border:0; cursor:pointer; font-size:1.1rem; line-height:1; padding:.1rem .2rem; }
.clear-btn:hover { color:#cbd5e1; }
</style>

View file

@ -18,30 +18,55 @@
<fieldset>
<legend>Theme Tags</legend>
{% if tags %}
<label>Primary
<select name="primary_tag">
<option value="">-- none --</option>
{% for t in tags %}
<option value="{{ t }}" {% if t == primary_tag %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</label>
<label>Secondary
<select name="secondary_tag">
<option value="">-- none --</option>
{% for t in tags %}
<option value="{{ t }}" {% if t == secondary_tag %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</label>
<label>Tertiary
<select name="tertiary_tag">
<option value="">-- none --</option>
{% for t in tags %}
<option value="{{ t }}" {% if t == tertiary_tag %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</label>
<input type="hidden" name="primary_tag" id="primary_tag" value="{{ primary_tag or '' }}" />
<input type="hidden" name="secondary_tag" id="secondary_tag" value="{{ secondary_tag or '' }}" />
<input type="hidden" name="tertiary_tag" id="tertiary_tag" value="{{ tertiary_tag or '' }}" />
<input type="hidden" name="tag_mode" id="tag_mode" value="{{ tag_mode or 'AND' }}" />
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">Pick up to three themes. Toggle AND/OR to control how themes combine.</div>
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin-bottom:.35rem;">
<span class="muted" style="font-size:12px;">Combine</span>
<div role="group" aria-label="Combine mode" aria-describedby="combine-help-tip">
<label style="margin-right:.35rem;" title="AND prioritizes cards that match multiple of your themes (tighter synergy, smaller pool).">
<input type="radio" name="combine_mode_radio" value="AND" {% if (tag_mode or 'AND') == 'AND' %}checked{% endif %} /> AND
</label>
<label title="OR treats your themes as a union (broader pool, fills easier).">
<input type="radio" name="combine_mode_radio" value="OR" {% if tag_mode == 'OR' %}checked{% endif %} /> OR
</label>
</div>
<button type="button" id="reset-tags" class="chip" style="margin-left:.35rem;">Reset themes</button>
<span id="tag-count" class="muted" style="font-size:12px;"></span>
</div>
<div id="combine-help-tip" class="muted" style="font-size:12px; margin:-.15rem 0 .5rem 0;">Tip: Choose OR for a stronger initial theme pool; switch to AND to tighten synergy.</div>
<div id="tag-order" class="muted" style="font-size:12px; margin-bottom:.4rem;"></div>
{% if recommended and recommended|length %}
<div style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
<div class="muted" style="font-size:12px;">Recommended</div>
<button type="button" id="reco-why" class="chip" aria-expanded="false" aria-controls="reco-why-panel" title="Why these are recommended?">Why?</button>
</div>
<div id="reco-why-panel" role="group" aria-label="Why Recommended" aria-hidden="true" style="display:none; border:1px solid #e2e2e2; border-radius:8px; padding:.75rem; margin:-.15rem 0 .5rem 0; background:#f7f7f7; box-shadow:0 2px 8px rgba(0,0,0,.06);">
<div style="font-size:12px; color:#222; margin-bottom:.5rem;">Why these themes? <span class="muted" style="color:#555;">Signals from oracle text, color identity, and your local build history.</span></div>
<ul style="margin:.25rem 0; padding-left:1.1rem;">
{% for r in recommended %}
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'From this commander\'s theme list') %}
<li style="font-size:12px; color:#222; line-height:1.35;"><strong>{{ r }}</strong>: <span style="color:#333;">{{ tip }}</span></li>
{% endfor %}
</ul>
</div>
<div id="tag-reco-list" aria-label="Recommended themes" style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
{% for r in recommended %}
{% set is_sel_r = (r == (primary_tag or '')) or (r == (secondary_tag or '')) or (r == (tertiary_tag or '')) %}
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
<button type="button" class="chip chip-reco{% if is_sel_r %} active{% endif %}" data-tag="{{ r }}" aria-pressed="{% if is_sel_r %}true{% else %}false{% endif %}" title="{{ tip }}">★ {{ r }}</button>
{% endfor %}
<button type="button" id="reco-select-all" class="chip" title="Add recommended up to 3">Select all</button>
</div>
{% endif %}
<div id="tag-chip-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for t in tags %}
{% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %}
<button type="button" class="chip{% if is_sel %} active{% endif %}" data-tag="{{ t }}" aria-pressed="{% if is_sel %}true{% else %}false{% endif %}">{{ t }}</button>
{% endfor %}
</div>
{% else %}
<p>No theme tags available for this commander.</p>
{% endif %}
@ -75,3 +100,213 @@
</div>
</div>
</section>
<script>
(function(){
var chipHost = document.getElementById('tag-chip-list');
var recoHost = document.getElementById('tag-reco-list');
var selAll = document.getElementById('reco-select-all');
var resetBtn = document.getElementById('reset-tags');
var primary = document.getElementById('primary_tag');
var secondary = document.getElementById('secondary_tag');
var tertiary = document.getElementById('tertiary_tag');
var tagMode = document.getElementById('tag_mode');
var countEl = document.getElementById('tag-count');
var orderEl = document.getElementById('tag-order');
var commander = '{{ commander.name|e }}';
if (!chipHost) return;
function storageKey(suffix){ return 'step2-' + (commander || 'unknown') + '-' + suffix; }
function getSelected(){
var arr = [];
if (primary && primary.value) arr.push(primary.value);
if (secondary && secondary.value) arr.push(secondary.value);
if (tertiary && tertiary.value) arr.push(tertiary.value);
return arr;
}
function setSelected(arr){
arr = Array.from(new Set(arr || [])).filter(Boolean).slice(0,3);
if (primary) primary.value = arr[0] || '';
if (secondary) secondary.value = arr[1] || '';
if (tertiary) tertiary.value = arr[2] || '';
updateCount();
persist();
updateOrderUI();
}
function toggleTag(t){
var cur = getSelected();
var idx = cur.indexOf(t);
if (idx >= 0) { cur.splice(idx, 1); }
else {
if (cur.length >= 3) { cur = cur.slice(1); }
cur.push(t);
}
setSelected(cur);
updateChipsState();
}
function updateCount(){
try { if (countEl) countEl.textContent = getSelected().length + ' / 3 selected'; } catch(_){}
}
function persist(){
try {
localStorage.setItem(storageKey('tags'), JSON.stringify(getSelected()));
if (tagMode) localStorage.setItem(storageKey('mode'), tagMode.value || 'AND');
} catch(_){}
}
function loadPersisted(){
try {
var savedTags = JSON.parse(localStorage.getItem(storageKey('tags')) || '[]');
var savedMode = localStorage.getItem(storageKey('mode')) || (tagMode && tagMode.value) || 'AND';
if ((!primary.value && !secondary.value && !tertiary.value) && Array.isArray(savedTags) && savedTags.length){ setSelected(savedTags); }
if (tagMode) { tagMode.value = (savedMode === 'OR' ? 'OR' : 'AND'); }
// sync radios
syncModeRadios();
} catch(_){}
}
function syncModeRadios(){
try {
var radios = document.querySelectorAll('input[name="combine_mode_radio"]');
Array.prototype.forEach.call(radios, function(r){ r.checked = (r.value === (tagMode && tagMode.value || 'AND')); });
} catch(_){}
}
function updateChipsState(){
var sel = getSelected();
function applyToContainer(container){
if (!container) return;
var chips = Array.prototype.slice.call(container.querySelectorAll('button.chip'));
chips.forEach(function(btn){
var t = btn.dataset.tag || '';
var active = sel.indexOf(t) >= 0;
btn.classList.toggle('active', active);
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
// update numeric badge for order
var old = btn.querySelector('sup.tag-order');
if (old) { try { old.remove(); } catch(_){} }
if (active){
var idx = sel.indexOf(t);
if (idx >= 0){
var sup = document.createElement('sup');
sup.className = 'tag-order';
sup.style.marginLeft = '.25rem';
sup.style.opacity = '.75';
sup.textContent = String(idx + 1);
btn.appendChild(sup);
}
}
});
}
applyToContainer(chipHost);
applyToContainer(recoHost);
updateCount();
updateOrderUI();
updateSelectAllState();
}
function updateOrderUI(){
if (!orderEl) return;
var sel = getSelected();
if (!sel.length){ orderEl.textContent = ''; return; }
try {
var parts = sel.map(function(t, i){ return (i+1) + '. ' + t; });
orderEl.textContent = 'Selected order: ' + parts.join(' • ');
} catch(_){ orderEl.textContent = ''; }
}
// bind mode radios
Array.prototype.forEach.call(document.querySelectorAll('input[name="combine_mode_radio"]'), function(r){
r.addEventListener('change', function(){ if (tagMode) { tagMode.value = r.value; persist(); } });
});
if (resetBtn) resetBtn.addEventListener('click', function(){ setSelected([]); updateChipsState(); });
// attach handlers to existing chips
Array.prototype.forEach.call(chipHost.querySelectorAll('button.chip'), function(btn){
var t = btn.dataset.tag || '';
btn.addEventListener('click', function(){ toggleTag(t); });
btn.addEventListener('keydown', function(e){
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleTag(t); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
var chips = Array.prototype.slice.call(chipHost.querySelectorAll('button.chip'));
var ix = chips.indexOf(e.currentTarget);
var next = (e.key === 'ArrowRight') ? chips[Math.min(ix+1, chips.length-1)] : chips[Math.max(ix-1, 0)];
if (next) { try { next.focus(); } catch(_){ } }
}
});
});
// attach handlers to recommended chips and select-all
if (recoHost){
Array.prototype.forEach.call(recoHost.querySelectorAll('button.chip-reco'), function(btn){
var t = btn.dataset.tag || '';
btn.addEventListener('click', function(){ toggleTag(t); });
});
if (selAll){
selAll.addEventListener('click', function(){
try {
var sel = getSelected();
var recs = Array.prototype.slice.call(recoHost.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean);
var combined = sel.slice();
recs.forEach(function(t){ if (combined.indexOf(t) === -1) combined.push(t); });
combined = combined.slice(-3); // keep last 3
setSelected(combined);
updateChipsState();
updateSelectAllState();
} catch(_){ }
});
}
// Why recommended panel toggle
var whyBtn = document.getElementById('reco-why');
var whyPanel = document.getElementById('reco-why-panel');
function setWhy(open){
if (!whyBtn || !whyPanel) return;
whyBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
whyPanel.style.display = open ? 'block' : 'none';
whyPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
}
if (whyBtn && whyPanel){
whyBtn.addEventListener('click', function(e){
e.stopPropagation();
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
setWhy(!isOpen);
if (!isOpen){ try { whyPanel.focus && whyPanel.focus(); } catch(_){} }
});
document.addEventListener('click', function(e){
try {
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
if (!isOpen) return;
if (whyPanel.contains(e.target) || whyBtn.contains(e.target)) return;
setWhy(false);
} catch(_){}
});
document.addEventListener('keydown', function(e){
if (e.key === 'Escape'){ setWhy(false); }
});
}
}
function updateSelectAllState(){
try {
if (!selAll) return;
var sel = getSelected();
var recs = recoHost ? Array.prototype.slice.call(recoHost.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean) : [];
var unselected = recs.filter(function(t){ return sel.indexOf(t) === -1; });
var atCap = sel.length >= 3;
var noNew = unselected.length === 0;
var disable = atCap || noNew;
selAll.disabled = disable;
selAll.setAttribute('aria-disabled', disable ? 'true' : 'false');
if (disable){
selAll.title = atCap ? 'Already have 3 themes selected' : 'All recommended already selected';
} else {
selAll.title = 'Add recommended up to 3';
}
} catch(_){ }
}
// initial: set from template-selected values, then maybe load persisted if none
updateChipsState();
loadPersisted();
updateChipsState();
})();
</script>