From 2ed169fc78652e666d102773238c654ad9bda903 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 6 Sep 2026 19:29:52 +0200 Subject: [PATCH] Tracker: unfold a combatant's stat block inline - extract the 5e stat-block markup into enemy/_statblock.html.twig (sb.block(enemy) macro); the enemy detail page now uses it too - each tracker combatant gets a "stat block" toggle that expands the block in place (Bootstrap collapse), so you don't have to leave the encounter to check AC / attacks - open blocks are remembered in localStorage and re-opened after each damage/heal reload, and the caret rotates when expanded Co-Authored-By: Claude Sonnet 5 --- assets/app.js | 40 +++++++++++++++++ assets/styles/app.css | 12 +++++ templates/enemy/_statblock.html.twig | 66 ++++++++++++++++++++++++++++ templates/enemy/show.html.twig | 59 +------------------------ templates/tracker/show.html.twig | 19 +++++++- 5 files changed, 137 insertions(+), 59 deletions(-) create mode 100644 templates/enemy/_statblock.html.twig diff --git a/assets/app.js b/assets/app.js index b9b938e..6641d89 100644 --- a/assets/app.js +++ b/assets/app.js @@ -105,3 +105,43 @@ document.addEventListener('DOMContentLoaded', () => initDataTables()); document.addEventListener('turbo:load', () => initDataTables()); // Restore the plain before Turbo snapshots the page for its cache. document.addEventListener('turbo:before-cache', destroyDataTables); + +// --- Remember unfolded stat blocks on the tracker ----------------------- +// Every damage/heal is a POST + redirect, so without this a stat block the +// DM has open snaps shut on each hit. Keyed by the collapse element id. +const SB_KEY = 'dmtools.statblocks'; + +function readOpenSet() { + try { + return new Set(JSON.parse(localStorage.getItem(SB_KEY) || '[]')); + } catch (e) { + return new Set(); + } +} + +function restoreStatblocks() { + const open = readOpenSet(); + document.querySelectorAll('.statblock-collapse').forEach((el) => { + const on = open.has(el.id); + el.classList.toggle('show', on); + document + .querySelectorAll(`[data-bs-toggle="collapse"][data-bs-target="#${el.id}"]`) + .forEach((btn) => btn.setAttribute('aria-expanded', on ? 'true' : 'false')); + }); +} + +function rememberStatblock(el, isOpen) { + if (!el.classList.contains('statblock-collapse')) return; + const open = readOpenSet(); + isOpen ? open.add(el.id) : open.delete(el.id); + try { + localStorage.setItem(SB_KEY, JSON.stringify([...open])); + } catch (e) { + /* private mode - ignore */ + } +} + +document.addEventListener('shown.bs.collapse', (e) => rememberStatblock(e.target, true)); +document.addEventListener('hidden.bs.collapse', (e) => rememberStatblock(e.target, false)); +document.addEventListener('DOMContentLoaded', restoreStatblocks); +document.addEventListener('turbo:load', restoreStatblocks); diff --git a/assets/styles/app.css b/assets/styles/app.css index 039661e..ba7e05b 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -112,3 +112,15 @@ table.dataTable { max-width: 100%; height: auto; } + +/* --- Tracker: unfoldable stat blocks --------------------------------- */ +[data-bs-toggle="collapse"] .sb-caret { + display: inline-block; + transition: transform 0.15s ease; +} +[data-bs-toggle="collapse"][aria-expanded="true"] .sb-caret { + transform: rotate(90deg); +} +.statblock-collapse h3 { + margin-top: 0.75rem; +} diff --git a/templates/enemy/_statblock.html.twig b/templates/enemy/_statblock.html.twig new file mode 100644 index 0000000..66015a3 --- /dev/null +++ b/templates/enemy/_statblock.html.twig @@ -0,0 +1,66 @@ +{# + # Shared 5e stat-block rendering. Used on the enemy detail page and, folded + # up, inside each tracker combatant. + # {% import 'enemy/_statblock.html.twig' as sb %} + # {{ sb.block(enemy) }} + #} + +{% macro mod(n) %}{{ n >= 0 ? '+' ~ n : n }}{% endmacro %} + +{% macro _stat(label, score, m) %} +
+
{{ label }}
+
{{ score }} ({{ m }})
+
+{% endmacro %} + +{% macro _line(label, value) %} + {% if value %} +
{{ label }}. {{ value }}
+ {% endif %} +{% endmacro %} + +{% macro _section(title, body) %} + {% if body %} +

{{ title }}

+
{{ body|nl2br }}
+ {% endif %} +{% endmacro %} + +{% macro block(e) %} + {% import _self as sb %} + {{ sb._line('Armor Class', e.armorClass ~ (e.armorClassNote ? ' (' ~ e.armorClassNote ~ ')' : '')) }} + {{ sb._line('Hit Points', e.hitPoints ~ (e.hitDice ? ' (' ~ e.hitDice ~ ')' : '')) }} + {{ sb._line('Speed', e.speed) }} + +
+ {{ sb._stat('Str', e.strength, sb.mod(e.abilityModifier(e.strength))) }} + {{ sb._stat('Dex', e.dexterity, sb.mod(e.abilityModifier(e.dexterity))) }} + {{ sb._stat('Con', e.constitution, sb.mod(e.abilityModifier(e.constitution))) }} + {{ sb._stat('Int', e.intelligence, sb.mod(e.abilityModifier(e.intelligence))) }} + {{ sb._stat('Wis', e.wisdom, sb.mod(e.abilityModifier(e.wisdom))) }} + {{ sb._stat('Cha', e.charisma, sb.mod(e.abilityModifier(e.charisma))) }} +
+ + {{ sb._line('Saving Throws', e.savingThrows) }} + {{ sb._line('Skills', e.skills) }} + {{ sb._line('Damage Vulnerabilities', e.damageVulnerabilities) }} + {{ sb._line('Damage Resistances', e.damageResistances) }} + {{ sb._line('Damage Immunities', e.damageImmunities) }} + {{ sb._line('Condition Immunities', e.conditionImmunities) }} + {{ sb._line('Senses', e.senses) }} + {{ sb._line('Languages', e.languages ?? '—') }} + {{ sb._line('Challenge', e.challengeRating ? e.challengeRating ~ (e.experiencePoints ? ' (' ~ e.experiencePoints ~ ' XP)' : '') : null) }} + {{ sb._line('Proficiency Bonus', e.proficiencyBonus ? sb.mod(e.proficiencyBonus) : null) }} + + {{ sb._section('Traits', e.traits) }} + {{ sb._section('Actions', e.actions) }} + {{ sb._section('Bonus Actions', e.bonusActions) }} + {{ sb._section('Reactions', e.reactions) }} + {{ sb._section('Legendary Actions', e.legendaryActions) }} + {{ sb._section('Description', e.description) }} + + {% if e.source %} +

Source: {{ e.source }}

+ {% endif %} +{% endmacro %} diff --git a/templates/enemy/show.html.twig b/templates/enemy/show.html.twig index b360a38..b294cf7 100644 --- a/templates/enemy/show.html.twig +++ b/templates/enemy/show.html.twig @@ -2,29 +2,7 @@ {% block title %}{{ enemy.name }} · Enemies · dmtools{% endblock %} -{% macro mod(n) %}{{ n >= 0 ? '+' ~ n : n }}{% endmacro %} - -{% macro stat(label, score, m) %} -
-
{{ label }}
-
{{ score }} ({{ m }})
-
-{% endmacro %} - -{% macro line(label, value) %} - {% if value %} -
{{ label }}. {{ value }}
- {% endif %} -{% endmacro %} - -{% macro section(title, body) %} - {% if body %} -

{{ title }}

-
{{ body|nl2br }}
- {% endif %} -{% endmacro %} - -{% import _self as sb %} +{% import 'enemy/_statblock.html.twig' as sb %} {% block body %}
@@ -40,40 +18,7 @@
- {{ sb.line('Armor Class', enemy.armorClass ~ (enemy.armorClassNote ? ' (' ~ enemy.armorClassNote ~ ')' : '')) }} - {{ sb.line('Hit Points', enemy.hitPoints ~ (enemy.hitDice ? ' (' ~ enemy.hitDice ~ ')' : '')) }} - {{ sb.line('Speed', enemy.speed) }} - -
- {{ sb.stat('Str', enemy.strength, sb.mod(enemy.abilityModifier(enemy.strength))) }} - {{ sb.stat('Dex', enemy.dexterity, sb.mod(enemy.abilityModifier(enemy.dexterity))) }} - {{ sb.stat('Con', enemy.constitution, sb.mod(enemy.abilityModifier(enemy.constitution))) }} - {{ sb.stat('Int', enemy.intelligence, sb.mod(enemy.abilityModifier(enemy.intelligence))) }} - {{ sb.stat('Wis', enemy.wisdom, sb.mod(enemy.abilityModifier(enemy.wisdom))) }} - {{ sb.stat('Cha', enemy.charisma, sb.mod(enemy.abilityModifier(enemy.charisma))) }} -
- - {{ sb.line('Saving Throws', enemy.savingThrows) }} - {{ sb.line('Skills', enemy.skills) }} - {{ sb.line('Damage Vulnerabilities', enemy.damageVulnerabilities) }} - {{ sb.line('Damage Resistances', enemy.damageResistances) }} - {{ sb.line('Damage Immunities', enemy.damageImmunities) }} - {{ sb.line('Condition Immunities', enemy.conditionImmunities) }} - {{ sb.line('Senses', enemy.senses) }} - {{ sb.line('Languages', enemy.languages ?? '—') }} - {{ sb.line('Challenge', enemy.challengeRating ? enemy.challengeRating ~ (enemy.experiencePoints ? ' (' ~ enemy.experiencePoints ~ ' XP)' : '') : null) }} - {{ sb.line('Proficiency Bonus', enemy.proficiencyBonus ? sb.mod(enemy.proficiencyBonus) : null) }} - - {{ sb.section('Traits', enemy.traits) }} - {{ sb.section('Actions', enemy.actions) }} - {{ sb.section('Bonus Actions', enemy.bonusActions) }} - {{ sb.section('Reactions', enemy.reactions) }} - {{ sb.section('Legendary Actions', enemy.legendaryActions) }} - {{ sb.section('Description', enemy.description) }} - - {% if enemy.source %} -

Source: {{ enemy.source }}

- {% endif %} + {{ sb.block(enemy) }}
diff --git a/templates/tracker/show.html.twig b/templates/tracker/show.html.twig index bc51e2b..26b2730 100644 --- a/templates/tracker/show.html.twig +++ b/templates/tracker/show.html.twig @@ -2,6 +2,8 @@ {% block title %}{{ encounter.name }} · Tracker · dmtools{% endblock %} +{% import 'enemy/_statblock.html.twig' as sb %} + {% block body %}
@@ -52,8 +54,12 @@ {% if c.isDown %}Down{% endif %}
{% if c.enemy %} - stat block + {% endif %}
@@ -85,6 +91,15 @@
+ + {% if c.enemy %} +
+

{{ c.enemy.metaLine }}

+
{{ sb.block(c.enemy) }}
+ Open full stat block → +
+ {% endif %} {% endfor %}