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
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Tests\Storyline;
use App\Storyline\ChapterOutline;
use PHPUnit\Framework\TestCase;
class ChapterOutlineTest extends TestCase
{
public function testFindsSectionsAndAddsAnchors(): void
{
$html = '<p>intro</p>'
.'<h2 class="storyline-section"><span>21</span> Finding the Hearthburrow</h2><p>x</p>'
.'<h2 class="storyline-section"><span>&plus;</span> Appendix &mdash; Timing</h2>';
$out = (new ChapterOutline())->process($html);
self::assertSame('21', $out['sections'][0]['num']);
self::assertSame('Finding the Hearthburrow', $out['sections'][0]['title']);
self::assertSame('section-1-finding-the-hearthburrow', $out['sections'][0]['id']);
self::assertSame('+', $out['sections'][1]['num']);
self::assertSame('Appendix — Timing', $out['sections'][1]['title']);
self::assertStringContainsString('<h2 class="storyline-section" id="section-1-finding-the-hearthburrow">', $out['html']);
self::assertStringContainsString('<p>intro</p>', $out['html']);
}
public function testChapterWithoutSectionsIsUntouched(): void
{
$out = (new ChapterOutline())->process('<p>plain</p>');
self::assertSame([], $out['sections']);
self::assertSame('<p>plain</p>', $out['html']);
}
}