From ea8bd6dc64c2e6d33c07cfe81cddbd2f9c265206 Mon Sep 17 00:00:00 2001 From: claudecode Date: Wed, 1 Jul 2026 19:51:30 -0400 Subject: [PATCH] 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 --- public/css/styles.css | 74 ++++++++++++++ public/js/npcs.js | 224 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 277 insertions(+), 21 deletions(-) diff --git a/public/css/styles.css b/public/css/styles.css index 3705dbe..720c451 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1516,6 +1516,80 @@ h1, h2, h3, h4, h5, h6 { 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 { display: flex; gap: 10px; diff --git a/public/js/npcs.js b/public/js/npcs.js index abe1907..8095884 100644 --- a/public/js/npcs.js +++ b/public/js/npcs.js @@ -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 ALIVE_STATUSES = ['alive', 'dead', 'unknown', 'missing']; +const DISPOSITIONS = ['unknown', 'hostile', 'indifferent', 'neutral', 'friendly']; const SECTIONS = [ { @@ -54,7 +55,7 @@ const SECTIONS = [ title: 'Status', fields: [ { 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'; } +function dispositionClass(npc) { + return `npc-disposition-badge npc-disposition-btn-${npc.disposition || 'unknown'}`; +} + function renderNoCampaign() { container.innerHTML = ''; const msg = document.createElement('div'); @@ -223,7 +228,7 @@ function buildNpcCard(npc) { badges.className = 'npc-card-badges'; const dispositionBadge = document.createElement('span'); - dispositionBadge.className = 'npc-disposition-badge'; + dispositionBadge.className = dispositionClass(npc); dispositionBadge.textContent = dispositionDisplay(npc); const aliveBadge = document.createElement('span'); @@ -240,17 +245,20 @@ function buildNpcCard(npc) { if (expandedId === npc.id) { card.classList.add('expanded'); - card.appendChild(buildNpcDetail(npc)); + card.appendChild(buildNpcDetail(npc, card)); } return card; } -function buildNpcDetail(npc) { +function buildNpcDetail(npc, card) { const detail = document.createElement('div'); 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'); sectionEl.className = 'npc-section'; @@ -260,7 +268,15 @@ function buildNpcDetail(npc) { sectionEl.appendChild(titleEl); 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); @@ -315,11 +331,145 @@ function buildFieldRow(label, value, valueOnly = false) { 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) { const body = container.querySelector('#npcModalBody'); 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)); section.fields.forEach((field) => { body.appendChild(buildModalField(field, values)); @@ -341,26 +491,29 @@ function buildModalField({ key, label, type }, values, hideLabel = false) { const group = document.createElement('div'); group.className = 'modal-field'; - if (!hideLabel) { - const labelEl = document.createElement('label'); + const labelEl = hideLabel ? null : document.createElement('label'); + if (labelEl) { labelEl.className = 'modal-field-label'; labelEl.textContent = label; - labelEl.setAttribute('for', `npc-field-${key}`); group.appendChild(labelEl); } - let input; if (type === 'select') { - input = document.createElement('select'); - input.className = 'modal-select'; - ALIVE_STATUSES.forEach((status) => { - const option = document.createElement('option'); - option.value = status; - option.textContent = statusLabel(status); - input.appendChild(option); - }); - input.value = values.alive_status || 'alive'; - } else if (type === 'textarea') { + group.appendChild(buildModalButtonGroup('npc-field-alive_status', ALIVE_STATUSES, values.alive_status || 'alive')); + return group; + } + + if (type === 'disposition') { + group.appendChild( + buildModalButtonGroup('npc-field-disposition', DISPOSITIONS, values.disposition || 'unknown', 'npc-disposition-btn') + ); + return group; + } + + if (labelEl) labelEl.setAttribute('for', `npc-field-${key}`); + + let input; + if (type === 'textarea') { input = document.createElement('textarea'); input.className = 'modal-textarea'; input.rows = 3; @@ -377,6 +530,35 @@ function buildModalField({ key, label, type }, values, hideLabel = false) { 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() { const data = {}; ALL_FIELDS.forEach(({ key }) => {