feat: the Discord Add panel's artist field autocompletes against FabledCurator's artists (milestone 429)
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m23s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 2m56s
CI and images / build-web (push) Successful in 1m37s
CI and images / smoke-web (push) Successful in 56s
CI and images / promote (push) Successful in 1s

Operator, after the first live run: "I need the extension to offer an
autofill search function so it's easier to match an entry with an existing
artist."

- The field searches as soon as the panel opens (the server name, usually)
  and on every keystroke; matches list under it, with ↑/↓, Enter/Tab to pick,
  Esc to close, and a last "+ New artist" row.
- Inline autofill: the rest of the top match is filled in and selected, so
  typing on replaces it and Tab/Enter accepts it. Backspacing never refills.
- A result whose name IS the text, spacing and case aside, is picked on its
  own; the hint says in green which existing artist the source will join.
- /api/artists/autocomplete also matches ignoring spacing and punctuation
  ("Tamada Heijun" finds "TamadaHeijun"), ranked just below an exact match.
  The web UI's artist search gets it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-25 07:45:51 -04:00
co-authored by Claude Opus 5.5
parent 4c75dd0f88
commit eeb9263125
6 changed files with 262 additions and 33 deletions
+135 -26
View File
@@ -157,7 +157,6 @@
closePanel();
const d = probe.discord || {};
const choice = discordPanelDefaults(probe);
let searchSeq = 0;
const scopeRow = (value, label, disabled) => {
const input = el('input', {
@@ -168,12 +167,21 @@
return el('label', { class: 'fc-panel__radio' }, [input, el('span', { text: label })]);
};
// The artist field is an autocomplete over FC's artists: it searches as
// soon as the panel opens (with the prefilled name) and on every keystroke,
// lists the matches under the field, fills in the rest of the top match as
// you type (Tab or Enter accepts it, typing on replaces it), and picks an
// artist whose name IS the text, spacing and case aside, without being
// asked. ↑/↓ walk the list; the last row creates a new artist instead.
const nameInput = el('input', {
type: 'text', class: 'fc-panel__input', value: choice.artistName,
placeholder: 'Artist name — search or type a new one',
placeholder: 'Search artists or type a new name',
autocomplete: 'off', spellcheck: false,
role: 'combobox',
});
const results = el('div', { class: 'fc-panel__results' });
nameInput.setAttribute('aria-autocomplete', 'both');
nameInput.setAttribute('aria-expanded', 'false');
const results = el('div', { class: 'fc-panel__results', role: 'listbox' });
const hint = el('div', { class: 'fc-panel__hint' });
const addBtn = el('button', { class: 'fc-panel__btn fc-panel__btn--primary', text: 'Add' });
const cancelBtn = el('button', { class: 'fc-panel__btn', text: 'Cancel' });
@@ -185,41 +193,83 @@
scopeRow('channel', d.channel_id ? channelLabel(d) : 'this channel', !d.channel_id),
scopeRow('server', `Every channel in ${serverLabel(d)}`, false),
el('div', { class: 'fc-panel__label', text: 'Artist' }),
nameInput,
results,
el('div', { class: 'fc-panel__combo' }, [nameInput, results]),
hint,
el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]),
]);
// Search state: the rows on screen, which one ↑/↓ has highlighted (-1 =
// none), and whether the list is open.
let rows = [];
let active = -1;
let listOpen = false;
function refresh() {
const req = discordAddRequest(choice);
addBtn.disabled = !req;
if (!req) hint.textContent = 'Pick an artist or type a name.';
else if (req.artistId != null) hint.textContent = `Connects to ${choice.artist.name} in FabledCurator.`;
else hint.textContent = `Adds to “${req.artistName}” — created if FabledCurator has no artist by that name.`;
else if (req.artistId != null) hint.textContent = `✓ Connects to ${choice.artist.name}, already in FabledCurator.`;
else hint.textContent = `Creates a new artist “${req.artistName}”.`;
hint.classList.toggle('fc-panel__hint--match', !!req && req.artistId != null);
}
function showResults(rows) {
results.replaceChildren(...rows.map((a) => {
const row = el('button', { class: 'fc-panel__result', text: a.name });
row.addEventListener('click', () => {
choice.artist = { id: a.id, name: a.name };
choice.artistName = a.name;
nameInput.value = a.name;
results.replaceChildren();
refresh();
});
function pick(artist) {
choice.artist = artist ? { id: artist.id, name: artist.name } : null;
if (artist) {
choice.artistName = artist.name;
nameInput.value = artist.name;
}
closeList();
refresh();
}
function closeList() {
listOpen = false;
active = -1;
results.replaceChildren();
nameInput.setAttribute('aria-expanded', 'false');
}
function renderList() {
const typed = nameInput.value.trim();
const exact = exactArtistMatch(typed, rows);
const items = rows.map((a, i) => {
const row = el('button', { type: 'button', class: 'fc-panel__result', role: 'option' }, [
el('span', { text: a.name }),
]);
if (choice.artist && choice.artist.id === a.id) {
row.appendChild(el('span', { class: 'fc-panel__result-tag', text: 'selected' }));
}
row.classList.toggle('fc-panel__result--active', i === active);
// mousedown, not click: it fires before the input's blur closes the list.
row.addEventListener('mousedown', (e) => { e.preventDefault(); pick(a); });
return row;
}));
});
if (typed && !exact) {
const i = rows.length;
const create = el('button', { type: 'button', class: 'fc-panel__result fc-panel__result--new', role: 'option',
text: `+ New artist “${typed}”` });
create.classList.toggle('fc-panel__result--active', i === active);
create.addEventListener('mousedown', (e) => { e.preventDefault(); pick(null); choice.artistName = typed; refresh(); });
items.push(create);
}
if (typed && !rows.length) {
items.unshift(el('div', { class: 'fc-panel__empty', text: 'No FabledCurator artist matches.' }));
}
results.replaceChildren(...items);
listOpen = items.length > 0;
nameInput.setAttribute('aria-expanded', String(listOpen));
results.querySelector('.fc-panel__result--active')?.scrollIntoView({ block: 'nearest' });
}
let debounce = null;
nameInput.addEventListener('input', () => {
choice.artistName = nameInput.value;
refresh();
let searchSeq = 0;
// `autofill` is false for deletions: filling the name back in as you
// backspace would make it impossible to delete.
function search(autofill) {
clearTimeout(debounce);
const q = nameInput.value.trim();
if (!q) { results.replaceChildren(); return; }
if (!q) { rows = []; closeList(); return; }
debounce = setTimeout(async () => {
const mine = ++searchSeq;
let r;
@@ -228,14 +278,69 @@
} catch {
return;
}
if (mine !== searchSeq || r?.error) return;
showResults(r.artists || []);
}, 200);
// Stale: a later keystroke has its own search coming.
if (mine !== searchSeq || r?.error || nameInput.value.trim() !== q) return;
rows = r.artists || [];
active = -1;
const exact = exactArtistMatch(q, rows);
if (exact && !choice.artist) {
choice.artist = { id: exact.id, name: exact.name };
} else if (autofill && document.activeElement === nameInput) {
const hit = inlineCompletion(nameInput.value, rows);
const caret = nameInput.value.length;
if (hit && nameInput.selectionStart === caret) {
nameInput.value = nameInput.value + hit.name.slice(caret);
nameInput.setSelectionRange(caret, hit.name.length);
choice.artist = { id: hit.id, name: hit.name };
choice.artistName = hit.name;
}
}
renderList();
refresh();
}, 150);
}
nameInput.addEventListener('input', (e) => {
choice.artistName = nameInput.value;
// Any edit un-picks: the artist is whatever the field now says.
choice.artist = null;
refresh();
search(!String(e.inputType || '').startsWith('delete'));
});
nameInput.addEventListener('focus', () => { if (rows.length) renderList(); });
nameInput.addEventListener('blur', () => closeList());
// Keep Discord's global shortcuts from eating keystrokes meant for us.
panel.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Escape') closePanel();
const count = results.querySelectorAll('.fc-panel__result').length;
if (e.target === nameInput && listOpen && count) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
active = e.key === 'ArrowDown' ? (active + 1) % count : (active <= 0 ? count - 1 : active - 1);
renderList();
return;
}
if ((e.key === 'Enter' || e.key === 'Tab') && active >= 0) {
e.preventDefault();
results.querySelectorAll('.fc-panel__result')[active]
.dispatchEvent(new MouseEvent('mousedown', { cancelable: true }));
return;
}
if ((e.key === 'Tab' || e.key === 'Enter') && choice.artist
&& nameInput.selectionEnd > nameInput.selectionStart) {
// Accept the inline autofill — Enter too, but only to accept: the
// add itself takes a second Enter, once the hint names the artist.
e.preventDefault();
pick(choice.artist);
return;
}
}
if (e.key === 'Escape') {
if (listOpen) closeList();
else closePanel();
return;
}
if (e.key === 'Enter' && e.target === nameInput && !addBtn.disabled) addBtn.click();
});
@@ -253,6 +358,10 @@
document.body.appendChild(panel);
refresh();
nameInput.focus();
nameInput.select();
// Search what the field opens with — the server's name, usually — so an
// artist it already matches is picked before the operator types anything.
if (!choice.artist && nameInput.value.trim()) search(false);
}
function showToast(text, kind) {