Replace NPC alive_status/disposition dropdowns with color-coded status buttons

Card and modal both use clickable button rows instead of a select and
free-text input, so changing alive_status or disposition no longer
requires the full edit form. The Status section is moved to the top
of both views for quicker access, and disposition buttons (plus the
collapsed card's badge) are color-coded per value, reusing the same
CSS classes in both places.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
claudecode
2026-07-01 19:51:30 -04:00
parent e61328aa4e
commit ea8bd6dc64
2 changed files with 277 additions and 21 deletions
+74
View File
@@ -1516,6 +1516,80 @@ h1, h2, h3, h4, h5, h6 {
white-space: pre-wrap; white-space: pre-wrap;
} }
.npc-status-buttons,
.npc-disposition-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 2px;
}
.npc-status-btn,
.npc-disposition-btn {
padding: 6px 14px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
color: var(--text-secondary);
font-family: var(--font-heading);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease, background var(--transition-speed) ease;
}
.npc-status-btn:hover,
.npc-disposition-btn:hover {
color: var(--text-primary);
border-color: var(--accent-hover);
}
.npc-status-btn.active,
.npc-disposition-btn.active {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
/* Disposition color coding — button active state, and the collapsed
card badge (reuses the same rules). May be revised in Phase 11. */
.npc-disposition-btn-unknown.active,
.npc-disposition-badge.npc-disposition-btn-unknown {
border-color: var(--text-muted);
color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
}
.npc-disposition-btn-hostile.active,
.npc-disposition-badge.npc-disposition-btn-hostile {
border-color: var(--danger);
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
}
.npc-disposition-btn-indifferent.active,
.npc-disposition-badge.npc-disposition-btn-indifferent {
border-color: var(--info);
color: var(--info);
background: color-mix(in srgb, var(--info) 12%, transparent);
}
.npc-disposition-btn-neutral.active,
.npc-disposition-badge.npc-disposition-btn-neutral {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.npc-disposition-btn-friendly.active,
.npc-disposition-badge.npc-disposition-btn-friendly {
border-color: var(--success);
color: var(--success);
background: color-mix(in srgb, var(--success) 12%, transparent);
}
.npc-card-actions { .npc-card-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
+203 -21
View File
@@ -5,6 +5,7 @@ import { getNpcs, createNpc, updateNpc, deleteNpc } from './api.js';
const NO_CAMPAIGN_MESSAGE = 'Select or create a campaign to use the NPC tracker.'; const NO_CAMPAIGN_MESSAGE = 'Select or create a campaign to use the NPC tracker.';
const ALIVE_STATUSES = ['alive', 'dead', 'unknown', 'missing']; const ALIVE_STATUSES = ['alive', 'dead', 'unknown', 'missing'];
const DISPOSITIONS = ['unknown', 'hostile', 'indifferent', 'neutral', 'friendly'];
const SECTIONS = [ const SECTIONS = [
{ {
@@ -54,7 +55,7 @@ const SECTIONS = [
title: 'Status', title: 'Status',
fields: [ fields: [
{ key: 'alive_status', label: 'Alive Status', type: 'select' }, { key: 'alive_status', label: 'Alive Status', type: 'select' },
{ key: 'disposition', label: 'Disposition', type: 'text' }, { key: 'disposition', label: 'Disposition', type: 'disposition' },
], ],
}, },
]; ];
@@ -79,6 +80,10 @@ function dispositionDisplay(npc) {
return npc.disposition || 'Unknown'; return npc.disposition || 'Unknown';
} }
function dispositionClass(npc) {
return `npc-disposition-badge npc-disposition-btn-${npc.disposition || 'unknown'}`;
}
function renderNoCampaign() { function renderNoCampaign() {
container.innerHTML = ''; container.innerHTML = '';
const msg = document.createElement('div'); const msg = document.createElement('div');
@@ -223,7 +228,7 @@ function buildNpcCard(npc) {
badges.className = 'npc-card-badges'; badges.className = 'npc-card-badges';
const dispositionBadge = document.createElement('span'); const dispositionBadge = document.createElement('span');
dispositionBadge.className = 'npc-disposition-badge'; dispositionBadge.className = dispositionClass(npc);
dispositionBadge.textContent = dispositionDisplay(npc); dispositionBadge.textContent = dispositionDisplay(npc);
const aliveBadge = document.createElement('span'); const aliveBadge = document.createElement('span');
@@ -240,17 +245,20 @@ function buildNpcCard(npc) {
if (expandedId === npc.id) { if (expandedId === npc.id) {
card.classList.add('expanded'); card.classList.add('expanded');
card.appendChild(buildNpcDetail(npc)); card.appendChild(buildNpcDetail(npc, card));
} }
return card; return card;
} }
function buildNpcDetail(npc) { function buildNpcDetail(npc, card) {
const detail = document.createElement('div'); const detail = document.createElement('div');
detail.className = 'npc-card-detail'; detail.className = 'npc-card-detail';
SECTIONS.forEach((section) => { const statusSection = SECTIONS.find((section) => section.title === 'Status');
const orderedSections = [statusSection, ...SECTIONS.filter((section) => section !== statusSection)];
orderedSections.forEach((section) => {
const sectionEl = document.createElement('div'); const sectionEl = document.createElement('div');
sectionEl.className = 'npc-section'; sectionEl.className = 'npc-section';
@@ -260,7 +268,15 @@ function buildNpcDetail(npc) {
sectionEl.appendChild(titleEl); sectionEl.appendChild(titleEl);
section.fields.forEach(({ key, label }) => { section.fields.forEach(({ key, label }) => {
sectionEl.appendChild(buildFieldRow(label, key === 'alive_status' ? statusLabel(npc.alive_status) : npc[key])); if (key === 'alive_status') {
sectionEl.appendChild(buildAliveStatusButtonRow(npc, card));
return;
}
if (key === 'disposition') {
sectionEl.appendChild(buildDispositionButtonRow(npc, card));
return;
}
sectionEl.appendChild(buildFieldRow(label, npc[key]));
}); });
detail.appendChild(sectionEl); detail.appendChild(sectionEl);
@@ -315,11 +331,145 @@ function buildFieldRow(label, value, valueOnly = false) {
return row; return row;
} }
function buildAliveStatusButtonRow(npc, card) {
const row = document.createElement('div');
row.className = 'npc-field';
const labelEl = document.createElement('span');
labelEl.className = 'npc-field-label';
labelEl.textContent = 'Alive Status';
row.appendChild(labelEl);
const buttonRow = document.createElement('div');
buttonRow.className = 'npc-status-buttons';
ALIVE_STATUSES.forEach((status) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'npc-status-btn';
btn.dataset.value = status;
btn.classList.toggle('active', status === npc.alive_status);
btn.textContent = statusLabel(status);
btn.addEventListener('click', () => handleAliveStatusChange(npc, status, card));
buttonRow.appendChild(btn);
});
row.appendChild(buttonRow);
return row;
}
function buildDispositionButtonRow(npc, card) {
const row = document.createElement('div');
row.className = 'npc-field';
const labelEl = document.createElement('span');
labelEl.className = 'npc-field-label';
labelEl.textContent = 'Disposition';
row.appendChild(labelEl);
const buttonRow = document.createElement('div');
buttonRow.className = 'npc-disposition-buttons';
DISPOSITIONS.forEach((value) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `npc-disposition-btn npc-disposition-btn-${value}`;
btn.dataset.value = value;
btn.classList.toggle('active', value === npc.disposition);
btn.textContent = statusLabel(value);
btn.addEventListener('click', () => handleDispositionChange(npc, value, card));
buttonRow.appendChild(btn);
});
row.appendChild(buttonRow);
return row;
}
async function handleAliveStatusChange(npc, newStatus, card) {
if (newStatus === npc.alive_status) return;
const campaign = getActiveCampaign();
if (!campaign) return;
const payload = {};
ALL_FIELDS.forEach(({ key }) => {
payload[key] = npc[key];
});
payload.alive_status = newStatus;
try {
const updated = await updateNpc(campaign.id, npc.id, payload);
Object.assign(npc, updated);
updateCardStatusUI(card, npc);
} catch (err) {
alert(err.message);
}
}
async function handleDispositionChange(npc, newDisposition, card) {
if (newDisposition === npc.disposition) return;
const campaign = getActiveCampaign();
if (!campaign) return;
const payload = {};
ALL_FIELDS.forEach(({ key }) => {
payload[key] = npc[key];
});
payload.disposition = newDisposition;
try {
const updated = await updateNpc(campaign.id, npc.id, payload);
Object.assign(npc, updated);
updateCardStatusUI(card, npc);
} catch (err) {
alert(err.message);
}
}
function updateCardStatusUI(card, npc) {
const aliveBadge = card.querySelector('.npc-status-badge');
if (aliveBadge) {
aliveBadge.className = `npc-status-badge npc-status-${npc.alive_status}`;
aliveBadge.textContent = statusLabel(npc.alive_status);
}
const dispositionBadge = card.querySelector('.npc-disposition-badge');
if (dispositionBadge) {
dispositionBadge.className = dispositionClass(npc);
dispositionBadge.textContent = dispositionDisplay(npc);
}
card.querySelectorAll('.npc-status-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.value === npc.alive_status);
});
card.querySelectorAll('.npc-disposition-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.value === npc.disposition);
});
}
function buildModalForm(values) { function buildModalForm(values) {
const body = container.querySelector('#npcModalBody'); const body = container.querySelector('#npcModalBody');
body.innerHTML = ''; body.innerHTML = '';
SECTIONS.forEach((section) => { const identitySection = SECTIONS.find((section) => section.title === 'Identity');
const statusSection = SECTIONS.find((section) => section.title === 'Status');
const [nameField, ...restOfIdentityFields] = identitySection.fields;
body.appendChild(buildModalField(nameField, values));
body.appendChild(buildModalSectionTitle(statusSection.title));
statusSection.fields.forEach((field) => {
body.appendChild(buildModalField(field, values));
});
body.appendChild(buildModalSectionTitle(identitySection.title));
restOfIdentityFields.forEach((field) => {
body.appendChild(buildModalField(field, values));
});
SECTIONS.filter((section) => section !== identitySection && section !== statusSection).forEach((section) => {
body.appendChild(buildModalSectionTitle(section.title)); body.appendChild(buildModalSectionTitle(section.title));
section.fields.forEach((field) => { section.fields.forEach((field) => {
body.appendChild(buildModalField(field, values)); body.appendChild(buildModalField(field, values));
@@ -341,26 +491,29 @@ function buildModalField({ key, label, type }, values, hideLabel = false) {
const group = document.createElement('div'); const group = document.createElement('div');
group.className = 'modal-field'; group.className = 'modal-field';
if (!hideLabel) { const labelEl = hideLabel ? null : document.createElement('label');
const labelEl = document.createElement('label'); if (labelEl) {
labelEl.className = 'modal-field-label'; labelEl.className = 'modal-field-label';
labelEl.textContent = label; labelEl.textContent = label;
labelEl.setAttribute('for', `npc-field-${key}`);
group.appendChild(labelEl); group.appendChild(labelEl);
} }
let input;
if (type === 'select') { if (type === 'select') {
input = document.createElement('select'); group.appendChild(buildModalButtonGroup('npc-field-alive_status', ALIVE_STATUSES, values.alive_status || 'alive'));
input.className = 'modal-select'; return group;
ALIVE_STATUSES.forEach((status) => { }
const option = document.createElement('option');
option.value = status; if (type === 'disposition') {
option.textContent = statusLabel(status); group.appendChild(
input.appendChild(option); buildModalButtonGroup('npc-field-disposition', DISPOSITIONS, values.disposition || 'unknown', 'npc-disposition-btn')
}); );
input.value = values.alive_status || 'alive'; return group;
} else if (type === 'textarea') { }
if (labelEl) labelEl.setAttribute('for', `npc-field-${key}`);
let input;
if (type === 'textarea') {
input = document.createElement('textarea'); input = document.createElement('textarea');
input.className = 'modal-textarea'; input.className = 'modal-textarea';
input.rows = 3; input.rows = 3;
@@ -377,6 +530,35 @@ function buildModalField({ key, label, type }, values, hideLabel = false) {
return group; return group;
} }
function buildModalButtonGroup(inputId, options, currentValue, valueClassPrefix) {
const wrapper = document.createElement('div');
wrapper.className = 'modal-status-buttons';
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.id = inputId;
hiddenInput.value = currentValue;
wrapper.appendChild(hiddenInput);
options.forEach((value) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = valueClassPrefix ? `modal-status-btn ${valueClassPrefix}-${value}` : 'modal-status-btn';
btn.dataset.value = value;
btn.classList.toggle('active', value === currentValue);
btn.textContent = statusLabel(value);
btn.addEventListener('click', () => {
hiddenInput.value = value;
wrapper.querySelectorAll('.modal-status-btn').forEach((b) => {
b.classList.toggle('active', b.dataset.value === value);
});
});
wrapper.appendChild(btn);
});
return wrapper;
}
function collectFormData() { function collectFormData() {
const data = {}; const data = {};
ALL_FIELDS.forEach(({ key }) => { ALL_FIELDS.forEach(({ key }) => {