First Commit
This commit is contained in:
+803
@@ -0,0 +1,803 @@
|
||||
// Frontend logic only. No RQ3 rules calculations happen here - everything goes through /api/*.
|
||||
|
||||
const SPELL_MECHANIC_IDS = ['bladesharp', 'protection', 'heal', 'disruption', 'demoralize', 'coordination'];
|
||||
|
||||
const state = {
|
||||
view: 'tables',
|
||||
tablesTree: [],
|
||||
expandedNodes: new Set(),
|
||||
selectedTableId: null,
|
||||
selectedTable: null,
|
||||
lastRoll: null,
|
||||
manualSelectOpen: false,
|
||||
|
||||
npcs: [],
|
||||
selectedNpcId: null,
|
||||
|
||||
enemies: [],
|
||||
selectedEnemyId: null,
|
||||
|
||||
combat: null,
|
||||
reactionType: 'none',
|
||||
|
||||
spellMappings: [],
|
||||
logEntries: [],
|
||||
dirty: false,
|
||||
|
||||
attackModifiers: [],
|
||||
armorTable: [],
|
||||
selectedModifierIds: new Set(),
|
||||
};
|
||||
|
||||
// ---------- low-level helpers ----------
|
||||
|
||||
function qs(sel, root = document) { return root.querySelector(sel); }
|
||||
|
||||
function el(html) {
|
||||
const t = document.createElement('template');
|
||||
t.innerHTML = html.trim();
|
||||
return t.content.firstElementChild;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
function md(text) {
|
||||
if (!text) return '';
|
||||
// parseInline (not parse) - these are short flavor-text fragments rendered inside our
|
||||
// own wrapper elements; parse()'s block-level <p> wrapping causes nested-<p> auto-close
|
||||
// bugs when our wrapper is also a <p>.
|
||||
return window.marked ? window.marked.parseInline(text) : escapeHtml(text);
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: {} };
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, opts);
|
||||
if (res.status === 204) return null;
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (e) { /* no body */ }
|
||||
if (!res.ok) throw new Error((data && data.error) || `Request failed (${res.status})`);
|
||||
state.dirty = true;
|
||||
return data;
|
||||
}
|
||||
|
||||
function tierTag(tier) {
|
||||
return `<span class="tag tier-${tier}">${tier}</span>`;
|
||||
}
|
||||
|
||||
// ---------- load ----------
|
||||
|
||||
async function loadTablesTree() { state.tablesTree = await api('GET', '/api/tables/tree'); }
|
||||
async function loadNpcs() { state.npcs = await api('GET', '/api/npcs'); }
|
||||
async function loadEnemies() { state.enemies = await api('GET', '/api/enemies'); }
|
||||
async function loadCombat() { state.combat = await api('GET', '/api/combat'); }
|
||||
async function loadSpellMappings() { state.spellMappings = await api('GET', '/api/spell-mappings'); }
|
||||
async function loadLog(search = '') {
|
||||
state.logEntries = await api('GET', `/api/log${search ? `?search=${encodeURIComponent(search)}` : ''}`);
|
||||
}
|
||||
async function loadAttackModifiers() { state.attackModifiers = await api('GET', '/api/rules/attack-modifiers'); }
|
||||
async function loadArmorTable() { state.armorTable = await api('GET', '/api/rules/armor-table'); }
|
||||
|
||||
// ---------- top-level render ----------
|
||||
|
||||
function setView(view) {
|
||||
state.view = view;
|
||||
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));
|
||||
renderSidebar();
|
||||
renderMain();
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
const root = qs('#sidebar-content');
|
||||
root.innerHTML = '';
|
||||
if (state.view === 'tables') root.appendChild(renderTablesSidebar());
|
||||
if (state.view === 'npcs') root.appendChild(renderNpcsSidebar());
|
||||
if (state.view === 'enemies') root.appendChild(renderEnemiesSidebar());
|
||||
if (state.view === 'combat') root.appendChild(renderCombatSidebar());
|
||||
}
|
||||
|
||||
function renderMain() {
|
||||
const root = qs('#main-panel');
|
||||
root.innerHTML = '';
|
||||
if (state.view === 'tables') root.appendChild(renderTablesMain());
|
||||
if (state.view === 'npcs') root.appendChild(renderNpcsMain());
|
||||
if (state.view === 'enemies') root.appendChild(renderEnemiesMain());
|
||||
if (state.view === 'combat') root.appendChild(renderCombatMain());
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// TABLES
|
||||
// ======================================================================
|
||||
|
||||
function renderTreeNode(node) {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const expanded = state.expandedNodes.has(node.id);
|
||||
const wrap = el(`<div class="tree-node"></div>`);
|
||||
const heading = el(`
|
||||
<div class="tree-heading ${node.table && state.selectedTableId === node.table.id ? 'selected' : ''}" data-node-id="${node.id}">
|
||||
<span class="tree-toggle">${hasChildren ? (expanded ? '▾' : '▸') : ''}</span>
|
||||
<span>${escapeHtml(node.heading_text)}</span>
|
||||
${node.table ? `<span class="tree-table-icon">[${node.table.dice_notation || '?'}]</span>` : ''}
|
||||
</div>
|
||||
`);
|
||||
heading.addEventListener('click', () => {
|
||||
if (node.table) selectTable(node.table.id);
|
||||
if (hasChildren) {
|
||||
if (expanded) state.expandedNodes.delete(node.id); else state.expandedNodes.add(node.id);
|
||||
renderSidebar();
|
||||
}
|
||||
});
|
||||
wrap.appendChild(heading);
|
||||
if (hasChildren && expanded) {
|
||||
const childWrap = el(`<div class="tree-children"></div>`);
|
||||
node.children.forEach((c) => childWrap.appendChild(renderTreeNode(c)));
|
||||
wrap.appendChild(childWrap);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderTablesSidebar() {
|
||||
const wrap = el(`<div></div>`);
|
||||
if (!state.tablesTree.length) {
|
||||
wrap.appendChild(el(`<p class="empty-state">No tables imported.</p>`));
|
||||
return wrap;
|
||||
}
|
||||
state.tablesTree.forEach((root) => wrap.appendChild(renderTreeNode(root)));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function selectTable(tableId) {
|
||||
state.selectedTableId = tableId;
|
||||
state.selectedTable = await api('GET', `/api/tables/${tableId}`);
|
||||
state.lastRoll = null;
|
||||
state.manualSelectOpen = false;
|
||||
renderSidebar();
|
||||
renderMain();
|
||||
}
|
||||
|
||||
async function rollSelectedTable() {
|
||||
if (!state.selectedTableId) return;
|
||||
const result = await api('POST', `/api/tables/${state.selectedTableId}/roll`);
|
||||
state.lastRoll = result;
|
||||
state.manualSelectOpen = false;
|
||||
await loadLog();
|
||||
renderMain();
|
||||
renderLog();
|
||||
}
|
||||
|
||||
async function manualSelectRow(rowId) {
|
||||
const table = state.selectedTable;
|
||||
const row = table.rows.find((r) => r.id === rowId);
|
||||
if (!row) return;
|
||||
await api('POST', '/api/log', {
|
||||
type: 'roll',
|
||||
summary: `Manually selected on "${table.name}": ${row.cells.join(' / ')}`,
|
||||
details: { table_id: table.id, row },
|
||||
});
|
||||
state.lastRoll = { table, row, links: [] };
|
||||
state.manualSelectOpen = false;
|
||||
await loadLog();
|
||||
renderMain();
|
||||
renderLog();
|
||||
}
|
||||
|
||||
function renderRollResult(result) {
|
||||
const box = el(`<div class="roll-result"></div>`);
|
||||
box.appendChild(el(`<div class="roll-table-name">${escapeHtml(result.table.name)}</div>`));
|
||||
const cellsWrap = el(`<div class="roll-cells"></div>`);
|
||||
result.row.cells.forEach((cell, idx) => {
|
||||
const colName = result.table.columns[idx] ? result.table.columns[idx].column_name : '';
|
||||
cellsWrap.appendChild(el(`<p>${colName ? `<strong>${escapeHtml(colName)}:</strong> ` : ''}${md(cell)}</p>`));
|
||||
});
|
||||
box.appendChild(cellsWrap);
|
||||
if (result.links && result.links.length) {
|
||||
const linkWrap = el(`<div class="button-row"></div>`);
|
||||
result.links.forEach((link) => {
|
||||
const btn = el(`<button class="button">Roll Linked Table</button>`);
|
||||
btn.addEventListener('click', () => selectTable(link.target_table_id).then(rollSelectedTable));
|
||||
linkWrap.appendChild(btn);
|
||||
});
|
||||
box.appendChild(linkWrap);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function renderTablesMain() {
|
||||
const wrap = el(`<div></div>`);
|
||||
if (!state.selectedTable) {
|
||||
wrap.appendChild(el(`<p class="empty-state">Select a table from the tree to roll on it.</p>`));
|
||||
return wrap;
|
||||
}
|
||||
const table = state.selectedTable;
|
||||
wrap.appendChild(el(`<h2>${escapeHtml(table.name)}</h2>`));
|
||||
|
||||
const actions = el(`<div class="button-row"></div>`);
|
||||
const rollBtn = el(`<button class="button primary">Roll</button>`);
|
||||
rollBtn.addEventListener('click', rollSelectedTable);
|
||||
const rerollBtn = el(`<button class="button">Re-roll</button>`);
|
||||
rerollBtn.addEventListener('click', rollSelectedTable);
|
||||
const manualBtn = el(`<button class="button">Select Manually</button>`);
|
||||
manualBtn.addEventListener('click', () => { state.manualSelectOpen = !state.manualSelectOpen; renderMain(); });
|
||||
actions.append(rollBtn, rerollBtn, manualBtn);
|
||||
wrap.appendChild(actions);
|
||||
|
||||
if (state.lastRoll) wrap.appendChild(renderRollResult(state.lastRoll));
|
||||
|
||||
if (state.manualSelectOpen) {
|
||||
const list = el(`<div class="card"><h3>All entries</h3></div>`);
|
||||
table.rows.forEach((row) => {
|
||||
const item = el(`<div class="weapon-row" style="cursor:pointer"><span>${escapeHtml(row.roll_min === row.roll_max ? String(row.roll_min) : `${row.roll_min}-${row.roll_max}`)}</span><span>${md(row.cells.join(' / '))}</span></div>`);
|
||||
item.addEventListener('click', () => manualSelectRow(row.id));
|
||||
list.appendChild(item);
|
||||
});
|
||||
wrap.appendChild(list);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// NPCS
|
||||
// ======================================================================
|
||||
|
||||
function renderNpcsSidebar() {
|
||||
const wrap = el(`<div></div>`);
|
||||
const actions = el(`<div class="button-row"></div>`);
|
||||
const fullBtn = el(`<button class="button primary">+ Full NPC</button>`);
|
||||
fullBtn.addEventListener('click', () => generateNpc('full'));
|
||||
const fillerBtn = el(`<button class="button">+ Filler NPC</button>`);
|
||||
fillerBtn.addEventListener('click', () => generateNpc('filler'));
|
||||
actions.append(fullBtn, fillerBtn);
|
||||
wrap.appendChild(actions);
|
||||
|
||||
const list = el(`<ul class="entity-list"></ul>`);
|
||||
state.npcs.forEach((npc) => {
|
||||
const name = `${npc.first_name || '(unnamed)'} ${npc.last_name || ''}`.trim();
|
||||
const li = el(`<li class="${state.selectedNpcId === npc.id ? 'selected' : ''} status-${npc.status}"><span>${escapeHtml(name)}</span><span class="tag">${npc.status}</span></li>`);
|
||||
li.addEventListener('click', () => { state.selectedNpcId = npc.id; renderSidebar(); renderMain(); });
|
||||
list.appendChild(li);
|
||||
});
|
||||
wrap.appendChild(list);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function generateNpc(npcType) {
|
||||
const npc = await api('POST', '/api/npcs/generate', { npc_type: npcType });
|
||||
await loadNpcs();
|
||||
state.selectedNpcId = npc.id;
|
||||
await loadLog();
|
||||
renderSidebar();
|
||||
renderMain();
|
||||
renderLog();
|
||||
}
|
||||
|
||||
async function rerollNpcField(field) {
|
||||
await api('POST', `/api/npcs/${state.selectedNpcId}/reroll-field`, { field });
|
||||
await loadNpcs();
|
||||
await loadLog();
|
||||
renderSidebar();
|
||||
renderMain();
|
||||
renderLog();
|
||||
}
|
||||
|
||||
async function updateNpcField(field, value) {
|
||||
await api('PUT', `/api/npcs/${state.selectedNpcId}`, { [field]: value });
|
||||
await loadNpcs();
|
||||
renderSidebar();
|
||||
}
|
||||
|
||||
async function setNpcStatus(status) {
|
||||
await api('PUT', `/api/npcs/${state.selectedNpcId}`, { status });
|
||||
await loadNpcs();
|
||||
renderSidebar();
|
||||
renderMain();
|
||||
}
|
||||
|
||||
async function deleteSelectedNpc() {
|
||||
if (!confirm('Delete this NPC? This cannot be undone.')) return;
|
||||
await api('DELETE', `/api/npcs/${state.selectedNpcId}`);
|
||||
state.selectedNpcId = null;
|
||||
await loadNpcs();
|
||||
await loadLog();
|
||||
renderSidebar();
|
||||
renderMain();
|
||||
renderLog();
|
||||
}
|
||||
|
||||
async function attachNpcStatBlock() {
|
||||
await api('POST', `/api/npcs/${state.selectedNpcId}/generate-stat-block`);
|
||||
await loadNpcs();
|
||||
renderMain();
|
||||
}
|
||||
|
||||
const NPC_CORE_FIELDS = [
|
||||
['first_name', 'First Name'], ['last_name', 'Last Name'], ['brief_description', 'Brief Description'],
|
||||
['wants_needs', 'Wants and Needs'], ['secret_obstacle', 'Secret or Obstacle'], ['also_carrying', 'Also Carrying'],
|
||||
];
|
||||
const NPC_ATTR_FIELDS = [
|
||||
['race', 'Race'], ['pronouns', 'Pronouns'], ['age', 'Age'], ['intelligence', 'Intelligence'], ['hair', 'Hair'], ['build', 'Build'],
|
||||
];
|
||||
|
||||
function renderNpcField(npc, key, label, rerollable) {
|
||||
const row = el(`<div class="field-row"><label>${label}</label></div>`);
|
||||
const valueWrap = el(`<div class="field-value"></div>`);
|
||||
const span = el(`<span>${md(npc[key]) || '<em>—</em>'}</span>`);
|
||||
span.contentEditable = 'true';
|
||||
span.addEventListener('blur', () => {
|
||||
const text = span.innerText.trim();
|
||||
if (text !== (npc[key] || '')) updateNpcField(key, text);
|
||||
});
|
||||
valueWrap.appendChild(span);
|
||||
if (rerollable) {
|
||||
const btn = el(`<button class="button" title="Re-roll">🎲</button>`);
|
||||
btn.addEventListener('click', () => rerollNpcField(key));
|
||||
valueWrap.appendChild(btn);
|
||||
}
|
||||
row.appendChild(valueWrap);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderNpcsMain() {
|
||||
const wrap = el(`<div></div>`);
|
||||
const npc = state.npcs.find((n) => n.id === state.selectedNpcId);
|
||||
if (!npc) {
|
||||
wrap.appendChild(el(`<p class="empty-state">Generate or select an NPC.</p>`));
|
||||
return wrap;
|
||||
}
|
||||
const card = el(`<div class="card"></div>`);
|
||||
card.appendChild(el(`<h2>${escapeHtml(`${npc.first_name || ''} ${npc.last_name || ''}`.trim() || '(unnamed)')} <span class="tag">${npc.npc_type}</span></h2>`));
|
||||
|
||||
const statusRow = el(`<div class="button-row"></div>`);
|
||||
['active', 'dead', 'inactive'].forEach((s) => {
|
||||
const b = el(`<button class="button ${npc.status === s ? 'primary' : ''}">${s}</button>`);
|
||||
b.addEventListener('click', () => setNpcStatus(s));
|
||||
statusRow.appendChild(b);
|
||||
});
|
||||
const delBtn = el(`<button class="button danger">Delete</button>`);
|
||||
delBtn.addEventListener('click', deleteSelectedNpc);
|
||||
statusRow.appendChild(delBtn);
|
||||
card.appendChild(statusRow);
|
||||
|
||||
NPC_CORE_FIELDS.forEach(([key, label]) => card.appendChild(renderNpcField(npc, key, label, true)));
|
||||
if (npc.npc_type === 'full') {
|
||||
NPC_ATTR_FIELDS.forEach(([key, label]) => card.appendChild(renderNpcField(npc, key, label, true)));
|
||||
}
|
||||
wrap.appendChild(card);
|
||||
|
||||
const sbCard = el(`<div class="card"><h3>Stat Block</h3></div>`);
|
||||
if (!npc.stat_block) {
|
||||
const attachBtn = el(`<button class="button">Attach Random Stat Block</button>`);
|
||||
attachBtn.addEventListener('click', attachNpcStatBlock);
|
||||
sbCard.appendChild(attachBtn);
|
||||
} else {
|
||||
sbCard.appendChild(renderStatBlockReadout(npc.stat_block));
|
||||
}
|
||||
wrap.appendChild(sbCard);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// shared stat block readout (used by NPC + enemy detail views)
|
||||
// ======================================================================
|
||||
|
||||
function renderStatBlockReadout(sb) {
|
||||
const wrap = el(`<div></div>`);
|
||||
const grid = el(`<div class="char-grid"></div>`);
|
||||
['str', 'con', 'siz', 'int', 'pow', 'dex', 'app'].forEach((c) => {
|
||||
grid.appendChild(el(`<div><div class="char-label">${c}</div><div class="char-value">${sb[c]}</div></div>`));
|
||||
});
|
||||
wrap.appendChild(grid);
|
||||
wrap.appendChild(el(`<p>HP ${sb.current_hp}/${sb.max_hp} MP ${sb.magic_points_current}/${sb.magic_points_max} Move ${sb.move}${sb.culture ? ` <span class="tag">${escapeHtml(sb.culture)}</span>` : ''}</p>`));
|
||||
|
||||
const table = el(`<table class="hit-loc-table"><thead><tr><th>Location</th><th>HP</th><th>AP</th></tr></thead></table>`);
|
||||
const tbody = el(`<tbody></tbody>`);
|
||||
(sb.hit_locations || []).forEach((loc) => {
|
||||
tbody.appendChild(el(`<tr class="${loc.disabled ? 'disabled' : ''}"><td>${loc.location_name}</td><td>${loc.current_hp}/${loc.max_hp}</td><td>${loc.armor_ap}</td></tr>`));
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
wrap.appendChild(table);
|
||||
|
||||
if ((sb.weapons || []).length) {
|
||||
sb.weapons.forEach((w) => {
|
||||
wrap.appendChild(el(`<div class="weapon-row"><span>${escapeHtml(w.weapon_name)}</span><span class="tag">${w.skill_percent}%</span>${w.mode ? `<span class="tag">${w.mode}</span>` : ''}</div>`));
|
||||
});
|
||||
}
|
||||
if ((sb.spells || []).length) {
|
||||
sb.spells.forEach((s) => {
|
||||
wrap.appendChild(el(`<div class="spell-row"><span>${escapeHtml(s.custom_name)}</span><span class="tag">${s.mechanic_id}</span></div>`));
|
||||
});
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// ENEMIES
|
||||
// ======================================================================
|
||||
|
||||
function renderEnemiesSidebar() {
|
||||
const wrap = el(`<div></div>`);
|
||||
const genBtn = el(`<button class="button primary">+ Generate Enemy</button>`);
|
||||
genBtn.addEventListener('click', async () => {
|
||||
const name = prompt('Enemy name?', 'New Enemy');
|
||||
if (name === null) return;
|
||||
const enemy = await api('POST', '/api/enemies/generate', { name, category: 'Humanoid' });
|
||||
await loadEnemies();
|
||||
state.selectedEnemyId = enemy.id;
|
||||
await loadLog();
|
||||
renderSidebar(); renderMain(); renderLog();
|
||||
});
|
||||
wrap.appendChild(genBtn);
|
||||
|
||||
const list = el(`<ul class="entity-list"></ul>`);
|
||||
state.enemies.forEach((enemy) => {
|
||||
const li = el(`<li class="${state.selectedEnemyId === enemy.id ? 'selected' : ''} status-${enemy.status}"><span>${escapeHtml(enemy.name)}</span><span class="tag">${enemy.stat_block.current_hp}/${enemy.stat_block.max_hp}</span></li>`);
|
||||
li.addEventListener('click', () => { state.selectedEnemyId = enemy.id; renderSidebar(); renderMain(); });
|
||||
list.appendChild(li);
|
||||
});
|
||||
wrap.appendChild(list);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function updateEnemyStatBlock(patch) {
|
||||
await api('PUT', `/api/enemies/${state.selectedEnemyId}`, { stat_block: patch });
|
||||
await loadEnemies();
|
||||
renderSidebar();
|
||||
renderMain();
|
||||
}
|
||||
|
||||
async function deleteSelectedEnemy() {
|
||||
if (!confirm('Delete this enemy? This cannot be undone.')) return;
|
||||
await api('DELETE', `/api/enemies/${state.selectedEnemyId}`);
|
||||
state.selectedEnemyId = null;
|
||||
await loadEnemies();
|
||||
await loadLog();
|
||||
renderSidebar(); renderMain(); renderLog();
|
||||
}
|
||||
|
||||
function renderEnemiesMain() {
|
||||
const wrap = el(`<div></div>`);
|
||||
const enemy = state.enemies.find((e) => e.id === state.selectedEnemyId);
|
||||
if (!enemy) {
|
||||
wrap.appendChild(el(`<p class="empty-state">Generate or select an enemy.</p>`));
|
||||
return wrap;
|
||||
}
|
||||
const card = el(`<div class="card"></div>`);
|
||||
card.appendChild(el(`<h2>${escapeHtml(enemy.name)} <span class="tag">${enemy.category || ''}</span></h2>`));
|
||||
const delBtn = el(`<button class="button danger">Delete</button>`);
|
||||
delBtn.addEventListener('click', deleteSelectedEnemy);
|
||||
card.appendChild(delBtn);
|
||||
card.appendChild(renderStatBlockReadout(enemy.stat_block));
|
||||
|
||||
const weaponForm = el(`<div class="card"><h3>Add Weapon</h3></div>`);
|
||||
const wName = el(`<input placeholder="Weapon name (e.g. Broadsword)">`);
|
||||
const wCat = el(`<input placeholder="Category (e.g. Sword, 1H)">`);
|
||||
const wSkill = el(`<input type="number" placeholder="Skill %" value="50">`);
|
||||
const wAdd = el(`<button class="button">Add</button>`);
|
||||
const wBonusBtn = el(`<button class="button">Check Cultural Bonus</button>`);
|
||||
const wBonusResult = el(`<span class="tag"></span>`);
|
||||
wBonusBtn.addEventListener('click', async () => {
|
||||
if (!enemy.stat_block.culture || !wCat.value.trim()) {
|
||||
wBonusResult.textContent = 'need culture + category';
|
||||
return;
|
||||
}
|
||||
const bonus = await api('GET', `/api/rules/cultural-bonus?culture=${encodeURIComponent(enemy.stat_block.culture)}&category=${encodeURIComponent(wCat.value.trim())}&weapon=${encodeURIComponent(wName.value.trim())}`);
|
||||
wBonusResult.textContent = `+${bonus.attack} atk / +${bonus.parry} parry (${enemy.stat_block.culture})`;
|
||||
});
|
||||
wAdd.addEventListener('click', async () => {
|
||||
if (!wName.value.trim()) return;
|
||||
const weapons = enemy.stat_block.weapons.map((w) => ({ weapon_name: w.weapon_name, category: w.category, skill_percent: w.skill_percent, mode: w.mode }));
|
||||
weapons.push({ weapon_name: wName.value.trim(), category: wCat.value.trim() || null, skill_percent: Number(wSkill.value) || 0 });
|
||||
await updateEnemyStatBlock({ weapons });
|
||||
});
|
||||
weaponForm.append(wName, wCat, wSkill, wAdd, wBonusBtn, wBonusResult);
|
||||
wrap.append(card, weaponForm);
|
||||
|
||||
const armorForm = el(`<div class="card"><h3>Apply Armor Type</h3></div>`);
|
||||
const armorSelect = el(`<select>${state.armorTable.map((a) => `<option value="${escapeHtml(a.name)}">${escapeHtml(a.name)} (AP ${a.ap})</option>`).join('')}</select>`);
|
||||
const armorApply = el(`<button class="button">Apply to All Locations</button>`);
|
||||
armorApply.addEventListener('click', async () => {
|
||||
const armor = state.armorTable.find((a) => a.name === armorSelect.value);
|
||||
if (!armor) return;
|
||||
const hit_locations = enemy.stat_block.hit_locations.map((loc) => ({ ...loc, armor_ap: armor.ap }));
|
||||
await updateEnemyStatBlock({ hit_locations });
|
||||
});
|
||||
armorForm.append(armorSelect, armorApply);
|
||||
if (state.armorTable.length) wrap.appendChild(armorForm);
|
||||
|
||||
const spellForm = el(`<div class="card"><h3>Add Known Spell</h3></div>`);
|
||||
const spellSelect = el(`<select></select>`);
|
||||
state.spellMappings.forEach((sm) => spellSelect.appendChild(el(`<option value="${sm.id}">${escapeHtml(sm.custom_name)} (${sm.mechanic_id})</option>`)));
|
||||
const spellAdd = el(`<button class="button">Add</button>`);
|
||||
spellAdd.addEventListener('click', async () => {
|
||||
if (!state.spellMappings.length) return;
|
||||
const spells = enemy.stat_block.spells.map((s) => ({ spell_mapping_id: s.id }));
|
||||
spells.push({ spell_mapping_id: Number(spellSelect.value) });
|
||||
await updateEnemyStatBlock({ spells });
|
||||
});
|
||||
spellForm.append(spellSelect, spellAdd);
|
||||
if (state.spellMappings.length) wrap.appendChild(spellForm);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// COMBAT
|
||||
// ======================================================================
|
||||
|
||||
function combatants() { return (state.combat && state.combat.state.combatants) || []; }
|
||||
|
||||
function renderCombatSidebar() {
|
||||
const wrap = el(`<div></div>`);
|
||||
const addWrap = el(`<div class="field-row"><label>Add Combatant</label></div>`);
|
||||
const select = el(`<select></select>`);
|
||||
select.appendChild(el(`<option value="">Choose...</option>`));
|
||||
state.npcs.filter((n) => n.stat_block).forEach((n) => select.appendChild(el(`<option value="npc:${n.id}">${escapeHtml(`${n.first_name || ''} ${n.last_name || ''}`.trim())}</option>`)));
|
||||
state.enemies.forEach((e) => select.appendChild(el(`<option value="enemy:${e.id}">${escapeHtml(e.name)}</option>`)));
|
||||
const addBtn = el(`<button class="button">Add</button>`);
|
||||
addBtn.addEventListener('click', async () => {
|
||||
if (!select.value) return;
|
||||
const [type, id] = select.value.split(':');
|
||||
await api('POST', '/api/combat/add-combatant', { type, id: Number(id) });
|
||||
await loadCombat();
|
||||
await loadLog();
|
||||
renderSidebar(); renderMain(); renderLog();
|
||||
});
|
||||
addWrap.append(select, addBtn);
|
||||
wrap.appendChild(addWrap);
|
||||
|
||||
const list = el(`<ul class="entity-list"></ul>`);
|
||||
combatants().forEach((c) => {
|
||||
list.appendChild(el(`<li><span>${escapeHtml(c.name)} (SR ${c.strikeRank})</span><span class="tag">${c.currentHp}/${c.maxHp} ${c.status}</span></li>`));
|
||||
});
|
||||
wrap.appendChild(list);
|
||||
|
||||
if (combatants().length) {
|
||||
const endBtn = el(`<button class="button danger">End Combat</button>`);
|
||||
endBtn.addEventListener('click', async () => {
|
||||
if (!confirm('End combat?')) return;
|
||||
await api('DELETE', '/api/combat');
|
||||
await loadCombat();
|
||||
await loadLog();
|
||||
renderSidebar(); renderMain(); renderLog();
|
||||
});
|
||||
wrap.appendChild(endBtn);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function combatantOptions(selectedId) {
|
||||
return combatants().map((c) => `<option value="${c.id}" ${c.id === selectedId ? 'selected' : ''}>${escapeHtml(c.name)}</option>`).join('');
|
||||
}
|
||||
|
||||
function renderCombatMain() {
|
||||
const wrap = el(`<div></div>`);
|
||||
if (combatants().length < 1) {
|
||||
wrap.appendChild(el(`<p class="empty-state">Add combatants from the sidebar to begin.</p>`));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
wrap.appendChild(el(`<h2>Attack</h2>`));
|
||||
const form = el(`<div class="card"></div>`);
|
||||
const attackerSel = el(`<select>${combatantOptions()}</select>`);
|
||||
const defenderSel = el(`<select>${combatantOptions()}</select>`);
|
||||
const weaponSel = el(`<select></select>`);
|
||||
const modeSel = el(`<select><option value="">(mode if dual)</option><option value="impale">impale</option><option value="slash">slash</option></select>`);
|
||||
const kindSel = el(`<select><option value="melee">melee</option><option value="missile">missile</option></select>`);
|
||||
const thrownChk = el(`<span class="checkbox-field"><input type="checkbox"> thrown</span>`);
|
||||
|
||||
function refreshWeapons() {
|
||||
const attacker = combatants().find((c) => c.id === attackerSel.value) || combatants()[0];
|
||||
weaponSel.innerHTML = '';
|
||||
(attacker ? attacker.weapons : []).forEach((w) => weaponSel.appendChild(el(`<option>${escapeHtml(w.weapon_name)}</option>`)));
|
||||
}
|
||||
attackerSel.addEventListener('change', refreshWeapons);
|
||||
refreshWeapons();
|
||||
|
||||
const reactionType = el(`<select><option value="">no reaction</option><option value="parry">parry</option><option value="dodge">dodge</option></select>`);
|
||||
const reactionSkill = el(`<input type="number" placeholder="Reaction skill %">`);
|
||||
const reactionWeapon = el(`<input placeholder="Parrying weapon name (for parry)">`);
|
||||
|
||||
const modifiersWrap = el(`<div class="field-row"><label>Situational Modifiers</label></div>`);
|
||||
const modifierChecks = state.attackModifiers.map((mod) => el(`
|
||||
<div class="checkbox-field-row">
|
||||
<span class="checkbox-field"><input type="checkbox" data-mod-id="${mod.id}"> ${escapeHtml(mod.description)} (${mod.modifier > 0 ? '+' : ''}${mod.modifier}${mod.perSiz ? ` per ${mod.perSiz} SIZ` : ''})</span>
|
||||
</div>
|
||||
`));
|
||||
modifierChecks.forEach((f) => modifiersWrap.appendChild(f));
|
||||
|
||||
const resolveBtn = el(`<button class="button primary">Resolve Attack</button>`);
|
||||
const resultBox = el(`<div></div>`);
|
||||
|
||||
resolveBtn.addEventListener('click', async () => {
|
||||
const modifierIds = modifierChecks
|
||||
.filter((f) => f.querySelector('input').checked)
|
||||
.map((f) => f.querySelector('input').dataset.modId);
|
||||
const body = {
|
||||
attackerCombatantId: attackerSel.value,
|
||||
defenderCombatantId: defenderSel.value,
|
||||
weaponName: weaponSel.value,
|
||||
declaredMode: modeSel.value || undefined,
|
||||
attackKind: kindSel.value,
|
||||
thrown: thrownChk.querySelector('input').checked,
|
||||
modifierIds,
|
||||
};
|
||||
if (reactionType.value) {
|
||||
body.reaction = { type: reactionType.value, skillPercent: Number(reactionSkill.value) || 0, weaponName: reactionWeapon.value };
|
||||
}
|
||||
try {
|
||||
const res = await api('POST', '/api/combat/attack', body);
|
||||
state.combat = res.combatState;
|
||||
resultBox.innerHTML = `
|
||||
<p>${tierTag(res.result.attackCheck.tier)} roll ${res.result.attackCheck.roll} vs ${res.result.effectiveSkillPercent}% (base ${res.result.effectiveSkillPercent - res.result.modifierTotal}${res.result.modifierTotal ? `, modifiers ${res.result.modifierTotal > 0 ? '+' : ''}${res.result.modifierTotal}` : ''})</p>
|
||||
${res.result.hitLocationRoll ? `<p>Hit location: ${res.result.hitLocationRoll.location}</p>` : ''}
|
||||
${res.result.damageThrough != null ? `<p>Damage through: ${res.result.damageThrough}</p>` : ''}
|
||||
${res.result.fumble ? `<p>Fumble: ${res.result.fumble.results.map((r) => r.effect).join('; ')}</p>` : ''}
|
||||
`;
|
||||
await loadLog();
|
||||
renderSidebar();
|
||||
renderLog();
|
||||
} catch (err) {
|
||||
resultBox.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
[
|
||||
['Attacker', attackerSel], ['Defender', defenderSel], ['Weapon', weaponSel], ['Declared Mode', modeSel],
|
||||
['Attack Kind', kindSel], [null, thrownChk], ['Defender Reaction', reactionType], ['Reaction Skill %', reactionSkill], [null, reactionWeapon],
|
||||
].forEach(([label, input]) => {
|
||||
const row = el(`<div class="field-row"></div>`);
|
||||
if (label) row.appendChild(el(`<label>${label}</label>`));
|
||||
row.appendChild(input);
|
||||
form.appendChild(row);
|
||||
});
|
||||
form.appendChild(modifiersWrap);
|
||||
form.appendChild(resolveBtn);
|
||||
form.appendChild(resultBox);
|
||||
wrap.appendChild(form);
|
||||
|
||||
wrap.appendChild(el(`<h2>Cast Spell</h2>`));
|
||||
const spellForm = el(`<div class="card"></div>`);
|
||||
const casterSel = el(`<select>${combatantOptions()}</select>`);
|
||||
const targetSel = el(`<select><option value="">(none)</option>${combatantOptions()}</select>`);
|
||||
const mechanicSel = el(`<select>${SPELL_MECHANIC_IDS.map((m) => `<option value="${m}">${m}</option>`).join('')}</select>`);
|
||||
const mpInput = el(`<input type="number" value="1" min="1">`);
|
||||
const castBtn = el(`<button class="button primary">Cast</button>`);
|
||||
const castResult = el(`<div></div>`);
|
||||
castBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await api('POST', '/api/combat/cast-spell', {
|
||||
casterCombatantId: casterSel.value,
|
||||
targetCombatantId: targetSel.value || undefined,
|
||||
mechanicId: mechanicSel.value,
|
||||
mpSpent: Number(mpInput.value) || 1,
|
||||
});
|
||||
state.combat = res.combatState;
|
||||
castResult.innerHTML = `<p>${escapeHtml(JSON.stringify(res.result))}</p>`;
|
||||
await loadLog();
|
||||
renderSidebar();
|
||||
renderLog();
|
||||
} catch (err) {
|
||||
castResult.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
});
|
||||
[['Caster', casterSel], ['Target', targetSel], ['Spell Mechanic', mechanicSel], ['MP Spent', mpInput]].forEach(([label, input]) => {
|
||||
const row = el(`<div class="field-row"><label>${label}</label></div>`);
|
||||
row.appendChild(input);
|
||||
spellForm.appendChild(row);
|
||||
});
|
||||
spellForm.append(castBtn, castResult);
|
||||
wrap.appendChild(spellForm);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// LOG
|
||||
// ======================================================================
|
||||
|
||||
function renderLog() {
|
||||
const root = qs('#log-entries');
|
||||
root.innerHTML = '';
|
||||
[...state.logEntries].forEach((entry) => {
|
||||
root.appendChild(el(`
|
||||
<div class="log-entry">
|
||||
<div class="log-meta"><span class="log-type">${entry.type}</span> · ${entry.created_at}</div>
|
||||
<div>${escapeHtml(entry.summary)}</div>
|
||||
</div>
|
||||
`));
|
||||
});
|
||||
}
|
||||
|
||||
async function searchLogAndRender(q) {
|
||||
await loadLog(q);
|
||||
renderLog();
|
||||
}
|
||||
|
||||
async function addNote() {
|
||||
const input = qs('#note-input');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
await api('POST', '/api/log', { type: 'note', summary: text });
|
||||
input.value = '';
|
||||
await loadLog();
|
||||
renderLog();
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// IMPORT / EXPORT
|
||||
// ======================================================================
|
||||
|
||||
async function exportAllData() {
|
||||
const res = await fetch('/api/export');
|
||||
const blob = await res.blob();
|
||||
downloadBlob(blob, `story-tool-export-${Date.now()}.json`);
|
||||
state.dirty = false;
|
||||
}
|
||||
|
||||
async function exportLogData() {
|
||||
const res = await fetch('/api/export/log');
|
||||
const blob = await res.blob();
|
||||
downloadBlob(blob, `session-log-${Date.now()}.md`);
|
||||
}
|
||||
|
||||
function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function importFile(file) {
|
||||
const text = await file.text();
|
||||
const dump = JSON.parse(text);
|
||||
await api('POST', '/api/import', dump);
|
||||
await Promise.all([loadNpcs(), loadEnemies(), loadCombat(), loadLog(), loadSpellMappings()]);
|
||||
renderSidebar();
|
||||
renderMain();
|
||||
renderLog();
|
||||
state.dirty = false;
|
||||
}
|
||||
|
||||
// ---------- export reminder modal ----------
|
||||
|
||||
let pendingLeaveConfirmed = false;
|
||||
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (state.dirty && !pendingLeaveConfirmed) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- init ----------
|
||||
|
||||
async function init() {
|
||||
document.querySelectorAll('.tab-btn').forEach((btn) => btn.addEventListener('click', () => setView(btn.dataset.view)));
|
||||
|
||||
qs('#export-all-btn').addEventListener('click', exportAllData);
|
||||
qs('#export-log-btn').addEventListener('click', exportLogData);
|
||||
qs('#import-file').addEventListener('change', (e) => {
|
||||
if (e.target.files[0]) importFile(e.target.files[0]);
|
||||
e.target.value = '';
|
||||
});
|
||||
qs('#log-search').addEventListener('input', (e) => searchLogAndRender(e.target.value));
|
||||
qs('#note-submit').addEventListener('click', addNote);
|
||||
|
||||
await Promise.all([
|
||||
loadTablesTree(), loadNpcs(), loadEnemies(), loadCombat(), loadLog(), loadSpellMappings(),
|
||||
loadAttackModifiers(), loadArmorTable(),
|
||||
]);
|
||||
setView('tables');
|
||||
renderLog();
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RQ3 Story Tool</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<h1>RQ3 Story Tool</h1>
|
||||
<div class="header-actions">
|
||||
<label class="button" for="import-file">Import</label>
|
||||
<input type="file" id="import-file" accept="application/json" hidden>
|
||||
<button id="export-all-btn" class="button">Export All</button>
|
||||
<button id="export-log-btn" class="button">Export Log</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<nav class="view-tabs">
|
||||
<button class="tab-btn" data-view="tables">Tables</button>
|
||||
<button class="tab-btn" data-view="npcs">NPCs</button>
|
||||
<button class="tab-btn" data-view="enemies">Enemies</button>
|
||||
<button class="tab-btn" data-view="combat">Combat</button>
|
||||
</nav>
|
||||
<div id="sidebar-content"></div>
|
||||
</aside>
|
||||
|
||||
<main id="main-panel"></main>
|
||||
|
||||
<aside class="log-panel">
|
||||
<h2>Session Log</h2>
|
||||
<input type="search" id="log-search" placeholder="Search log...">
|
||||
<div class="note-entry">
|
||||
<textarea id="note-input" placeholder="Add a note..."></textarea>
|
||||
<button id="note-submit" class="button">Add Note</button>
|
||||
</div>
|
||||
<div id="log-entries"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div id="export-reminder-modal" class="modal hidden">
|
||||
<div class="modal-box">
|
||||
<p>You have unsaved changes. Export before leaving?</p>
|
||||
<button id="reminder-export" class="button">Export Now</button>
|
||||
<button id="reminder-leave" class="button">Leave Anyway</button>
|
||||
<button id="reminder-cancel" class="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="vendor/marked.umd.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,197 @@
|
||||
:root {
|
||||
--bg: #1b1b1f;
|
||||
--panel: #25252b;
|
||||
--panel-alt: #2d2d34;
|
||||
--border: #3a3a42;
|
||||
--text: #e8e6e3;
|
||||
--text-dim: #9a98a3;
|
||||
--accent: #8b6f4e;
|
||||
--accent-bright: #c9a06a;
|
||||
--danger: #b5524a;
|
||||
--ok: #5d8a5e;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button, .button {
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
display: inline-block;
|
||||
}
|
||||
button:hover, .button:hover { border-color: var(--accent-bright); color: var(--accent-bright); }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #1b1b1f; }
|
||||
button.danger { border-color: var(--danger); color: var(--danger); }
|
||||
|
||||
input, textarea, select {
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.app-header h1 { font-size: 1.1rem; margin: 0; letter-spacing: 0.03em; }
|
||||
.header-actions { display: flex; gap: 8px; }
|
||||
|
||||
.layout {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 320px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar, .log-panel {
|
||||
background: var(--panel);
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.log-panel { border-right: none; border-left: 1px solid var(--border); }
|
||||
|
||||
.view-tabs { display: flex; gap: 4px; margin-bottom: 10px; }
|
||||
.view-tabs .tab-btn { flex: 1; padding: 6px 4px; font-size: 0.8rem; }
|
||||
.view-tabs .tab-btn.active { background: var(--accent); color: #1b1b1f; border-color: var(--accent); }
|
||||
|
||||
main#main-panel {
|
||||
overflow-y: auto;
|
||||
padding: 18px 24px;
|
||||
}
|
||||
|
||||
/* Table tree */
|
||||
.tree-node { margin-left: 0; }
|
||||
.tree-children { margin-left: 14px; }
|
||||
.tree-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.tree-heading:hover { background: var(--panel-alt); }
|
||||
.tree-heading.selected { background: var(--accent); color: #1b1b1f; }
|
||||
.tree-toggle { width: 12px; display: inline-block; color: var(--text-dim); }
|
||||
.tree-table-icon { color: var(--accent-bright); font-size: 0.75rem; }
|
||||
|
||||
/* Lists (npcs/enemies/combatants) */
|
||||
.entity-list { list-style: none; padding: 0; margin: 0; }
|
||||
.entity-list li {
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
.entity-list li:hover { background: var(--panel-alt); }
|
||||
.entity-list li.selected { background: var(--accent); color: #1b1b1f; }
|
||||
.entity-list li.status-dead, .entity-list li.status-inactive { opacity: 0.5; text-decoration: line-through; }
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card h2 { margin-top: 0; }
|
||||
.field-row { margin-bottom: 10px; }
|
||||
.field-row label { display: block; font-size: 0.78rem; color: var(--text-dim); margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.field-row .field-value { display: flex; align-items: baseline; gap: 8px; }
|
||||
.field-row .field-value span { flex: 1; }
|
||||
.field-row textarea, .field-row input { width: 100%; }
|
||||
.checkbox-field { display: inline-flex; align-items: center; gap: 6px; width: auto; }
|
||||
.checkbox-field input { width: auto; }
|
||||
.checkbox-field-row { margin-bottom: 4px; font-size: 0.85rem; }
|
||||
.button-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
|
||||
.char-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px; margin-bottom: 12px; }
|
||||
.char-grid div { text-align: center; background: var(--panel-alt); border-radius: 4px; padding: 6px 2px; }
|
||||
.char-grid .char-label { font-size: 0.7rem; color: var(--text-dim); text-transform: uppercase; }
|
||||
.char-grid .char-value { font-size: 1.1rem; }
|
||||
|
||||
.hit-loc-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin-bottom: 12px; }
|
||||
.hit-loc-table th, .hit-loc-table td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; }
|
||||
.hit-loc-table tr.disabled { color: var(--danger); }
|
||||
|
||||
.weapon-row, .spell-row { display: flex; gap: 8px; align-items: center; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 0.85rem; }
|
||||
|
||||
.roll-result {
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.roll-result .roll-table-name { color: var(--text-dim); font-size: 0.8rem; }
|
||||
.roll-result .roll-cells { font-size: 1.05rem; margin-top: 6px; }
|
||||
.roll-result .roll-cells p { margin: 4px 0; }
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tag.tier-critical { background: var(--accent); color: #1b1b1f; border-color: var(--accent); }
|
||||
.tag.tier-special { background: var(--accent-bright); color: #1b1b1f; border-color: var(--accent-bright); }
|
||||
.tag.tier-fumble, .tag.tier-failure { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
|
||||
/* Log */
|
||||
.log-panel h2 { font-size: 0.95rem; margin: 0 0 8px; }
|
||||
#log-search { width: 100%; margin-bottom: 8px; }
|
||||
.note-entry { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.note-entry textarea { height: 50px; resize: vertical; }
|
||||
#log-entries { display: flex; flex-direction: column-reverse; gap: 8px; }
|
||||
.log-entry { font-size: 0.8rem; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||
.log-entry .log-meta { color: var(--text-dim); font-size: 0.72rem; }
|
||||
.log-entry .log-type { text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
|
||||
.modal {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal.hidden { display: none; }
|
||||
.modal-box { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 20px; display: flex; flex-direction: column; gap: 10px; max-width: 360px; }
|
||||
|
||||
.empty-state { color: var(--text-dim); font-style: italic; padding: 20px 0; }
|
||||
.error-message { color: var(--danger); border: 1px solid var(--danger); border-radius: 4px; padding: 8px 10px; background: rgba(181, 82, 74, 0.12); }
|
||||
|
||||
.combat-round-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.sr-track { display: flex; flex-direction: column; gap: 6px; }
|
||||
.sr-slot { display: flex; gap: 8px; align-items: center; }
|
||||
.sr-slot .sr-num { width: 28px; text-align: center; font-weight: bold; color: var(--accent-bright); }
|
||||
Vendored
+2735
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user