Storyline: unfoldable section list per chapter in the sidebar

Each chapter in the right-hand menu gets a caret that unfolds its
numbered sections (e.g. the prologue's 28 scenes); clicking one jumps
straight to it. The chapter you're reading starts unfolded, other
chapters' sections link across (/storyline/overview#section-…), and the
section at the top of the pane is highlighted as you scroll.

- ChapterOutline service parses each rendered chapter for its
  <h2 class="storyline-section"> headings, adds anchor ids and returns the
  outline; the controller renders all chapters once to feed the sidebar.
- storyline-toc Stimulus controller: fold/unfold, in-pane jump, scroll-spy
  (works in the split view and in the small-screen window-scroll fallback).
- Tests for the outline parser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-21 18:44:54 +02:00
co-authored by Claude Sonnet 5
parent 8b8f67c1d0
commit b77e5eaa4f
6 changed files with 229 additions and 8 deletions
+27 -7
View File
@@ -12,13 +12,33 @@
<div class="storyline-toc">
<a href="{{ path('app_storyline') }}" class="text-decoration-none small d-inline-block mb-2">&larr; All chapters</a>
<div class="text-uppercase small text-body-secondary fw-semibold mb-2" style="letter-spacing:.08em">Chapters</div>
<ol class="list-unstyled mb-0">
<ol class="list-unstyled mb-0" data-controller="storyline-toc">
{% for s, c in chapters %}
<li>
<a href="{{ path('app_storyline_chapter', {slug: s}) }}"
class="storyline-toc-link{{ s == slug ? ' is-active' }}">
<span class="storyline-toc-num">{{ c.tag }}</span>{{ c.title }}
</a>
{% set open = s == slug %}
<li class="storyline-toc-item{{ open ? ' is-open' }}">
<div class="storyline-toc-row">
{% if sections[s] is not empty %}
<button type="button" class="storyline-toc-fold" data-action="storyline-toc#fold"
aria-expanded="{{ open ? 'true' : 'false' }}"
aria-label="Show sections of {{ c.title }}">&#9656;</button>
{% else %}<span class="storyline-toc-fold"></span>{% endif %}
<a href="{{ path('app_storyline_chapter', {slug: s}) }}"
class="storyline-toc-link{{ s == slug ? ' is-active' }}">
<span class="storyline-toc-num">{{ c.tag }}</span>{{ c.title }}
</a>
</div>
{% if sections[s] is not empty %}
<ol class="storyline-toc-sections list-unstyled">
{% for sec in sections[s] %}
<li>
<a href="{{ s == slug ? '#' ~ sec.id : path('app_storyline_chapter', {slug: s}) ~ '#' ~ sec.id }}"
class="storyline-toc-sub" {% if s == slug %}data-storyline-toc-target="link"{% endif %}>
<span class="storyline-toc-num">{{ sec.num }}</span>{{ sec.title }}
</a>
</li>
{% endfor %}
</ol>
{% endif %}
</li>
{% endfor %}
</ol>
@@ -33,7 +53,7 @@
<p class="storyline-standfirst">{{ chapter.summary }}</p>
</header>
{{ include(chapter.template) }}
{{ body|raw }}
</article>
<nav class="storyline-pager">