Add character aliases/identity links, move-beats, keyboard help overlay

- Characters can carry aliases and be linked as the same underlying
  person (canonical SameCharacterAsId, optional reveal chapter/note),
  surfaced through the API, MCP tools, agent toolset, and web UI.
- Characters page redesigned as a filterable/sortable table (name+
  aliases, role, importance, occupation, tags) instead of a sidebar
  list, to stay usable as the cast grows.
- Beats can be moved between chapters (BeatService.MoveAsync + MCP/
  agent tool + endpoint).
- Add a keyboard-shortcuts help overlay (HelpButton/HelpOverlayContext)
  wired into the project layout.
- CLAUDE.md: require every frontend component to carry a unique id
  attribute; apply it to CharacterMultiSelect and MarkdownEditor.
This commit is contained in:
James Wampler
2026-08-17 17:26:50 -07:00
parent b4f4b35e3c
commit f124b9b4bb
32 changed files with 3002 additions and 356 deletions
@@ -162,6 +162,56 @@ public class BeatServiceTests : ServiceTestFixture
Assert.That((await Beats.GetAsync(beat.Id))!.Characters, Is.Empty);
}
[Test]
public async Task Moving_beats_appends_them_to_the_end_of_the_target_chapter()
{
var other = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second landfall"));
await Beats.CreateAsync(other.Id, new CreateBeatRequest("Already there"));
var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
var moved = await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(other.Id, [second.Id, first.Id]));
var listed = await Beats.ListAsync(other.Id);
var remaining = await Beats.ListAsync(_chapterId);
Assert.Multiple(() =>
{
Assert.That(moved!.Select(b => b.ChapterId), Is.All.EqualTo(other.Id));
Assert.That(listed.Select(b => b.Title), Is.EqualTo(new[] { "Already there", "Second", "First" }));
Assert.That(remaining, Is.Empty);
});
}
[Test]
public async Task Moving_to_an_unknown_chapter_returns_null()
{
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
Assert.That(await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(Guid.NewGuid(), [beat.Id])), Is.Null);
}
[Test]
public async Task Moving_to_a_chapter_in_another_project_returns_null()
{
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
var otherChapter = await Chapters.CreateAsync(other.Id, new CreateChapterRequest("Elsewhere"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
Assert.That(await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(otherChapter.Id, [beat.Id])), Is.Null);
}
[Test]
public async Task Moving_an_unknown_beat_returns_null_rather_than_partially_applying()
{
var other = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second landfall"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var result = await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(other.Id, [beat.Id, Guid.NewGuid()]));
Assert.That(result, Is.Null);
Assert.That((await Beats.GetAsync(beat.Id))!.ChapterId, Is.EqualTo(_chapterId));
}
[Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{