Character editor: tabs, sections, auto-save, soft delete #26

Merged
bjoern merged 5 commits from feat/character-editor into main 2026-08-13 06:23:58 +02:00
Member

The character-editor story's Character tab, composing the components from #23. Connections (parts 1–3) and Sprites keep their own stories; this lands the fields, the auto-save, and the lifecycle.

Domain

  • LabeledEntry — the shared (label, text) row — and CharacterProfile, applied as one unit so a save is one journaled operation, not a field-by-field trail.
  • Character gains Role, Personality, SpeechStyle and four entry tables (traits, speech examples, appearance, backstory). Blank scalars normalize to null; a half-typed row is never dropped, because an auto-save must not delete the row under the user's cursor.

Persistence — the interesting decision

Entry tables are scalar JSON columns, not owned collections. The journal snapshots an entity's scalar property values, and EfUndoStore deserializes each back into the property's model type — so this shape makes the whole table undoable for free. Proven, not assumed: a test saves twice (second save drops a row), undoes, and gets the name and the labeled entries back together.

⚠️ A data bug caught in the migration: EF generated defaultValue: "" for the required JSON columns. Empty string isn't deserializable JSON — every pre-existing character would have thrown on read. Corrected to "[]".

Use cases

UpdateCharacter (empty name allowed — the record exists before it's named) · DeleteCharacter (soft, journaled, graph links return on restore) · CharacterDto.ToProfile() round-trip.

The editor

Header (name field · SaveIndicator · delete) → Tabs Character|Sprites → QuicklinkNav over six sections → four LabeledEntriesTables. Debounced auto-save; there is no save button anywhere (ADR 0016), and a pending edit is flushed on dispose rather than lost when you navigate away.

Cross-session reload is skipped unless the editor is idle — every save publishes its own DomainChanged, so reloading mid-edit would silently discard what's being typed (per #20's contract).

Two bugs the browser found — both invisible to bUnit

  1. Fixed here. The breadcrumb and page title are evaluated eagerly, while the shell's child content renders later. Fluxor's StateChanged handler re-renders inline, so copying the store into the form afterwards left the crumb reading "(unnamed)" while the name field showed "Aria Solano". The page now subscribes to StateChanged before the base does. Regression test added (it passes only because of the ordering).
  2. Pre-existing, deliberately not fixed here. <PageTitle> never updates on any page — an interactive island can't reach the statically rendered HeadOutlet (the workspace page shows the slug, not the project name). The remedy is <HeadOutlet @rendermode="InteractiveServer" /> in App.razor, which #24 is actively editing and would force interactivity onto the gate. Worth a separate, deliberate PR — flagging rather than colliding.

Tests — +24 (342 total)

  • Integration (real SQLite): profile round-trips through the JSON columns (row identity survives); blank scalars normalize while a half-typed row survives; one save = one Update, and undo reverses name + entry table together; delete is soft, journaled, and undo restores the character with its graph links; unknown ids fail cleanly.
  • bUnit (real Fluxor pipeline): the tabbed layout (6 sections, 6 quicklinks, 4 tables, Sprites unmounted), fresh-load breadcrumb regression, (unnamed) fallback, typing → Unsaved changes → persisted → Saved with no save button, a labeled-entries edit persisting, confirmed delete → soft-deleted + navigated, cancel changes nothing, not-found.
  • Domain: Apply semantics, blank normalization, empty name allowed, trimming without dropping rows.

Verified in Chrome (both themes)

Create → editor → type → Unsaved changesSaved; the entry lands in Characters.Appearance as [{"Id":…,"Label":"Age","Text":"44"}]; reload restores it; tabs unmount the inactive panel; delete → IsDeleted=1 + journaled, profile preserved for undo.

🤖 Generated with Claude Code

The [character-editor story](https://git.kagaku.eu/TeamAI/Kagura/src/branch/main/docs/stories/character-editor.md)'s **Character tab**, composing the components from #23. Connections (parts 1–3) and Sprites keep their own stories; this lands the fields, the auto-save, and the lifecycle. ## Domain - **`LabeledEntry`** — the shared `(label, text)` row — and **`CharacterProfile`**, applied as one unit so a save is **one journaled operation**, not a field-by-field trail. - `Character` gains `Role`, `Personality`, `SpeechStyle` and four entry tables (traits, speech examples, appearance, backstory). Blank scalars normalize to `null`; **a half-typed row is never dropped**, because an auto-save must not delete the row under the user's cursor. ## Persistence — the interesting decision Entry tables are **scalar JSON columns**, not owned collections. The journal snapshots an entity's *scalar property values*, and `EfUndoStore` deserializes each back into the property's model type — so this shape makes the whole table **undoable for free**. Proven, not assumed: a test saves twice (second save drops a row), undoes, and gets the name *and* the labeled entries back together. ⚠️ **A data bug caught in the migration:** EF generated `defaultValue: ""` for the required JSON columns. Empty string isn't deserializable JSON — every pre-existing character would have thrown on read. Corrected to `"[]"`. ## Use cases `UpdateCharacter` (empty name allowed — the record exists before it's named) · `DeleteCharacter` (**soft**, journaled, graph links return on restore) · `CharacterDto.ToProfile()` round-trip. ## The editor Header (name field · `SaveIndicator` · delete) → `Tabs` Character|Sprites → `QuicklinkNav` over six sections → four `LabeledEntriesTable`s. **Debounced auto-save; there is no save button anywhere** (ADR 0016), and a pending edit is *flushed on dispose* rather than lost when you navigate away. Cross-session reload is **skipped unless the editor is idle** — every save publishes its own `DomainChanged`, so reloading mid-edit would silently discard what's being typed (per #20's contract). ## Two bugs the browser found — both invisible to bUnit 1. **Fixed here.** The breadcrumb and page title are evaluated *eagerly*, while the shell's child content renders *later*. Fluxor's `StateChanged` handler re-renders **inline**, so copying the store into the form afterwards left the crumb reading **"(unnamed)" while the name field showed "Aria Solano"**. The page now subscribes to `StateChanged` *before* the base does. Regression test added (it passes only because of the ordering). 2. **Pre-existing, deliberately not fixed here.** `<PageTitle>` never updates on *any* page — an interactive island can't reach the statically rendered `HeadOutlet` (the workspace page shows the slug, not the project name). The remedy is `<HeadOutlet @rendermode="InteractiveServer" />` in `App.razor`, which **#24 is actively editing** and would force interactivity onto the gate. Worth a separate, deliberate PR — flagging rather than colliding. ## Tests — +24 (342 total) - **Integration (real SQLite):** profile round-trips through the JSON columns (row identity survives); blank scalars normalize while a half-typed row survives; **one save = one `Update`, and undo reverses name + entry table together**; delete is soft, journaled, and undo restores the character **with its graph links**; unknown ids fail cleanly. - **bUnit (real Fluxor pipeline):** the tabbed layout (6 sections, 6 quicklinks, 4 tables, Sprites unmounted), fresh-load breadcrumb regression, `(unnamed)` fallback, **typing → Unsaved changes → persisted → Saved with no save button**, a labeled-entries edit persisting, confirmed delete → soft-deleted + navigated, cancel changes nothing, not-found. - **Domain:** `Apply` semantics, blank normalization, empty name allowed, trimming without dropping rows. ## Verified in Chrome (both themes) Create → editor → type → `Unsaved changes` → `Saved`; the entry lands in `Characters.Appearance` as `[{"Id":…,"Label":"Age","Text":"44"}]`; reload restores it; tabs unmount the inactive panel; delete → `IsDeleted=1` + journaled, **profile preserved for undo**. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(characters): the character editor — tabs, sections, auto-save, soft delete
All checks were successful
CI / build (pull_request) Successful in 14s
CI / test (pull_request) Successful in 23s
415ffdb679
The character-editor story's Character tab. Connections (parts 1-3) and Sprites keep
their own stories; this lands the fields, the auto-save, and the lifecycle.

- Domain: LabeledEntry (a shared (label,text) row) and CharacterProfile, applied as
  one unit so a save is one journaled operation. Character gains Role, Personality,
  SpeechStyle and four entry tables (traits, speech examples, appearance, backstory).
  Blank scalars normalize to null; a half-typed row is never dropped, because an
  auto-save must not delete the row under the cursor.
- Persistence: entry tables are scalar JSON columns, not owned collections. The
  journal snapshots scalar property values and the undo replay deserializes each back
  into its model type — so the whole table is undoable for free, proven by a test
  that undoes a save and gets the labeled entries back. The migration's generated
  defaultValue "" was corrected to "[]": empty string is not deserializable JSON and
  would have thrown on every pre-existing character.
- UseCases: UpdateCharacter, DeleteCharacter (soft, journaled, links return on
  restore); the DTO carries the profile and hands it back via ToProfile().
- Editor page: header (name, SaveIndicator, delete), Tabs Character|Sprites, a
  QuicklinkNav over six sections, four LabeledEntriesTables. Debounced auto-save with
  no save button anywhere (ADR 0016); a pending edit is flushed on dispose rather
  than lost. Cross-session reload is skipped unless the editor is idle — every save
  publishes its own notification, and reloading mid-edit would discard typing.

Two bugs found while verifying, both invisible to bUnit's timing:
- The breadcrumb and page title are evaluated eagerly while the shell's child content
  renders later, so copying the store into the form after Fluxor's inline re-render
  left the crumb reading "(unnamed)" while the name field showed the character. The
  page now subscribes to StateChanged before the base does. Regression test added.
- (Pre-existing, not fixed here) <PageTitle> never updates on any page: an
  interactive island cannot reach the statically rendered HeadOutlet.

Tests: +24 (342 total). Verified in Chrome: type -> Unsaved changes -> Saved, the
labeled entry lands in the Characters.Appearance JSON column, a reload restores it,
tabs unmount the inactive panel, and delete soft-deletes (IsDeleted=1, journaled)
while preserving the profile for undo. Both themes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 07/10/2026 - 12:28:13
Coverage date: 07/10/2026 - 12:28:06 - 07/10/2026 - 12:28:10
Parser: MultiReport (4x Cobertura)
Assemblies: 7
Classes: 186
Files: 152
Line coverage: 93% (3931 of 4226)
Covered lines: 3931
Uncovered lines: 295
Coverable lines: 4226
Total lines: 9202
Branch coverage: 86.1% (808 of 938)
Covered branches: 808
Total branches: 938
Method coverage: Feature is only available for sponsors

Coverage

Kagura.BlazorAdapter - 76.8%
Name Line Branch
Kagura.BlazorAdapter 76.8% 78.9%
Kagura.BlazorAdapter.BlazorAdapterAssembly 100%
Kagura.BlazorAdapter.Design 0% 0%
Kagura.BlazorAdapter.EditorComponentsDemo 0%
Kagura.BlazorAdapter.History.DiffValue 100% 87.5%
Kagura.BlazorAdapter.History.DocumentHistoryEffects 90% 68.7%
Kagura.BlazorAdapter.History.DocumentHistoryFailed 100%
Kagura.BlazorAdapter.History.DocumentHistoryLoaded 100%
Kagura.BlazorAdapter.History.DocumentHistoryPanel 79.3% 75%
Kagura.BlazorAdapter.History.DocumentHistoryReducers 95% 83.3%
Kagura.BlazorAdapter.History.DocumentHistoryState 100%
Kagura.BlazorAdapter.History.LoadDocumentHistory 100%
Kagura.BlazorAdapter.History.RevertCompleted 100%
Kagura.BlazorAdapter.History.RevertRequested 100%
Kagura.BlazorAdapter.History.RevisionDiffLoaded 100%
Kagura.BlazorAdapter.History.SelectRevision 100%
Kagura.BlazorAdapter.KnowledgeBase.CharacterCreated 100%
Kagura.BlazorAdapter.KnowledgeBase.CharacterDeleted 100%
Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorEffects 100% 100%
Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorLoaded 100%
Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorPage 89.5% 73.8%
Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorReducers 92.3%
Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorState 100%
Kagura.BlazorAdapter.KnowledgeBase.CharacterOperationFailed 100%
Kagura.BlazorAdapter.KnowledgeBase.CharacterSaved 100%
Kagura.BlazorAdapter.KnowledgeBase.CharactersEffects 94.1% 75%
Kagura.BlazorAdapter.KnowledgeBase.CharactersLoaded 100%
Kagura.BlazorAdapter.KnowledgeBase.CharactersPage 94.7% 90%
Kagura.BlazorAdapter.KnowledgeBase.CharactersReducers 100% 87.5%
Kagura.BlazorAdapter.KnowledgeBase.CharactersState 100% 100%
Kagura.BlazorAdapter.KnowledgeBase.CreateCharacterFailed 0%
Kagura.BlazorAdapter.KnowledgeBase.CreateCharacterRequested 100%
Kagura.BlazorAdapter.KnowledgeBase.DeleteCharacterRequested 100%
Kagura.BlazorAdapter.KnowledgeBase.LabeledEntryMapping 100%
Kagura.BlazorAdapter.KnowledgeBase.LoadCharacter 100%
Kagura.BlazorAdapter.KnowledgeBase.LoadCharacters 100%
Kagura.BlazorAdapter.KnowledgeBase.SaveCharacterRequested 100%
Kagura.BlazorAdapter.KnowledgeBase.SetCharactersFilter 100%
Kagura.BlazorAdapter.Notifications.DomainChangedBridge 88.8% 58.3%
Kagura.BlazorAdapter.Notifications.DomainChangesReceived 75% 100%
Kagura.BlazorAdapter.OverlayDemo 0% 0%
Kagura.BlazorAdapter.Projects.CreateProjectRequested 100%
Kagura.BlazorAdapter.Projects.DeleteProjectRequested 100%
Kagura.BlazorAdapter.Projects.LoadWorkspace 100%
Kagura.BlazorAdapter.Projects.ProjectCreated 100%
Kagura.BlazorAdapter.Projects.ProjectCreateFailed 100%
Kagura.BlazorAdapter.Projects.ProjectDeleted 100%
Kagura.BlazorAdapter.Projects.ProjectSaved 100%
Kagura.BlazorAdapter.Projects.ProjectSaveFailed 100%
Kagura.BlazorAdapter.Projects.ProjectsEffects 100% 100%
Kagura.BlazorAdapter.Projects.ProjectsLoaded 100%
Kagura.BlazorAdapter.Projects.ProjectsPage 94.7% 100%
Kagura.BlazorAdapter.Projects.ProjectsReducers 100% 100%
Kagura.BlazorAdapter.Projects.ProjectsState 100% 100%
Kagura.BlazorAdapter.Projects.ProjectWorkspacePage 100% 100%
Kagura.BlazorAdapter.Projects.SaveProjectRequested 100%
Kagura.BlazorAdapter.Projects.SetProjectsFilter 100%
Kagura.BlazorAdapter.Projects.WorkspaceEffects 100% 100%
Kagura.BlazorAdapter.Projects.WorkspaceLoaded 100%
Kagura.BlazorAdapter.Projects.WorkspaceReducers 100%
Kagura.BlazorAdapter.Projects.WorkspaceSectionPage 100% 66.6%
Kagura.BlazorAdapter.Projects.WorkspaceShell 100% 93.7%
Kagura.BlazorAdapter.Projects.WorkspaceState 100%
Kagura.BlazorAdapter.QuicklinkDemo 0%
Kagura.Domain - 97%
Name Line Branch
Kagura.Domain 97% 85%
Kagura.Domain.Graph.Entry 100% 100%
Kagura.Domain.Graph.Link 100% 100%
Kagura.Domain.Graph.LinkRole 100% 100%
Kagura.Domain.Graph.LinkRoles 92.3%
Kagura.Domain.Journal.ChangeLogEntry 100%
Kagura.Domain.KnowledgeBase.Character 100% 100%
Kagura.Domain.KnowledgeBase.CharacterProfile 100%
Kagura.Domain.KnowledgeBase.LabeledEntry 100%
Kagura.Domain.Projects.Project 100% 100%
Kagura.Domain.Projects.Slug 100% 100%
System.Text.RegularExpressions.Generated 90.2% 72.2%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
89.4% 75%
Kagura.Infrastructure - 95.6%
Name Line Branch
Kagura.Infrastructure 95.6% 88.1%
Kagura.Infrastructure.DependencyInjection 100%
Kagura.Infrastructure.Graph.EfGraphStore 95.5% 66.6%
Kagura.Infrastructure.Journal.EfChangeJournal 100%
Kagura.Infrastructure.Journal.EfDocumentReverter 95% 91.6%
Kagura.Infrastructure.Journal.EfUndoStore 98.4% 90.9%
Kagura.Infrastructure.Journal.OperationContext 100% 100%
Kagura.Infrastructure.Journal.SnapshotApplier 95% 90%
Kagura.Infrastructure.KnowledgeBase.EfCharacterStore 100% 100%
Kagura.Infrastructure.Notifications.InProcessDomainChangedBus 100% 100%
Kagura.Infrastructure.Persistence.Configurations.ChangeLogEntryConfiguratio
n
100%
Kagura.Infrastructure.Persistence.Configurations.CharacterConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Kagura.Infrastructure.Persistence.KaguraDbContext 83.5% 82.5%
Kagura.Infrastructure.Persistence.KaguraDbContextFactory 0%
Kagura.Infrastructure.Persistence.Migrations.AddChangeLogUndoFlag 96.8%
Kagura.Infrastructure.Persistence.Migrations.AddCharacterProfile 92.9%
Kagura.Infrastructure.Persistence.Migrations.AddCharacters 98.7%
Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink 97.7%
Kagura.Infrastructure.Persistence.Migrations.AddProjectDescription 98.1%
Kagura.Infrastructure.Persistence.Migrations.AddSoftDeleteAndChangeLog 90.3%
Kagura.Infrastructure.Persistence.Migrations.InitialCreate 94.4%
Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot 100%
Kagura.Infrastructure.Projects.EfProjectStore 100% 100%
Kagura.Kernel - 90%
Name Line Branch
Kagura.Kernel 90% 75%
Kagura.Kernel.Err`1 100%
Kagura.Kernel.Ok`1 100%
Kagura.Kernel.Result`1 87.5% 75%
Kagura.Server - 100%
Name Line Branch
Kagura.Server 100% 78.9%
Kagura.Server.Components.App 100%
Kagura.Server.Components.Layout.MainLayout 100%
Kagura.Server.Components.Pages.Error 100% 50%
Kagura.Server.Components.Pages.Gate 100% 100%
Kagura.Server.Security.AccessGate 100% 83.3%
Kagura.Server.Security.AccessSecret 100% 100%
Program 100% 80%
Kagura.UI - 95.9%
Name Line Branch
Kagura.UI 95.9% 92.1%
Kagura.UI.Badge 100% 100%
Kagura.UI.Breadcrumb 100%
Kagura.UI.BreadcrumbItem 100% 100%
Kagura.UI.Button 100% 100%
Kagura.UI.Card 100% 100%
Kagura.UI.ConfirmDialog 100%
Kagura.UI.CssClassExtensions 100%
Kagura.UI.DebouncedSearchField 100% 88.8%
Kagura.UI.DocumentTitle 76.9% 100%
Kagura.UI.EmptyState 100% 100%
Kagura.UI.Field 100% 100%
Kagura.UI.Icon 100% 100%
Kagura.UI.IconCatalog 100%
Kagura.UI.InputFieldBase 94.2% 87.5%
Kagura.UI.LabeledEntriesTable 96.7% 66.6%
Kagura.UI.LabeledEntry 100%
Kagura.UI.Menu 90% 75%
Kagura.UI.MenuItem 100% 100%
Kagura.UI.Modal 87.1% 90%
Kagura.UI.NavGroup 100% 100%
Kagura.UI.NavItem 100% 100%
Kagura.UI.NavList 100%
Kagura.UI.PreviewImage 100% 100%
Kagura.UI.QuicklinkNav 85.2% 95.8%
Kagura.UI.QuicklinkSection 100%
Kagura.UI.RelativeTime 100% 93.7%
Kagura.UI.SaveIndicator 100% 100%
Kagura.UI.Separator 100%
Kagura.UI.StatusDot 100%
Kagura.UI.Tab 100%
Kagura.UI.Table`1 100% 92.3%
Kagura.UI.TableColumn`1 100%
Kagura.UI.Tabs 94.2% 88.8%
Kagura.UI.TextArea 100% 100%
Kagura.UI.TextField 100%
Kagura.UI.ThemeSwitcher 100% 100%
Kagura.UseCases - 97.2%
Name Line Branch
Kagura.UseCases 97.2% 91.6%
Kagura.UseCases.DependencyInjection 100%
Kagura.UseCases.Graph.EdgeGroup 100%
Kagura.UseCases.Graph.GetNodeGraph 96.4% 83.3%
Kagura.UseCases.Graph.GraphEdgeView 85.7%
Kagura.UseCases.Graph.LinkNodes 100% 100%
Kagura.UseCases.Graph.NodeGraphView 100%
Kagura.UseCases.Graph.NodeSummary 100%
Kagura.UseCases.Graph.RemoveLink 100% 100%
Kagura.UseCases.Graph.RestoreLink 100% 100%
Kagura.UseCases.Journal.ChangeRecordView 57.1%
Kagura.UseCases.Journal.DocumentRevision 100%
Kagura.UseCases.Journal.FieldChange 100%
Kagura.UseCases.Journal.GetDocumentHistory 100%
Kagura.UseCases.Journal.GetEntityHistory 100%
Kagura.UseCases.Journal.GetRevisionDiff 100% 50%
Kagura.UseCases.Journal.GetUndoStatus 100%
Kagura.UseCases.Journal.Redo 100% 100%
Kagura.UseCases.Journal.RevertDocument 100% 100%
Kagura.UseCases.Journal.RevertOutcome 100%
Kagura.UseCases.Journal.RevisionCoalescer 100% 100%
Kagura.UseCases.Journal.RevisionDiff 80% 50%
Kagura.UseCases.Journal.RevisionDiffBuilder 98.4% 86.5%
Kagura.UseCases.Journal.RowChange 100%
Kagura.UseCases.Journal.Undo 100% 100%
Kagura.UseCases.Journal.UndoOutcome 100%
Kagura.UseCases.Journal.UndoStatus 100%
Kagura.UseCases.KnowledgeBase.CharacterDto 90%
Kagura.UseCases.KnowledgeBase.CreateCharacter 100%
Kagura.UseCases.KnowledgeBase.DeleteCharacter 100% 100%
Kagura.UseCases.KnowledgeBase.GetCharacter 100% 100%
Kagura.UseCases.KnowledgeBase.ListCharacters 100%
Kagura.UseCases.KnowledgeBase.UpdateCharacter 100% 100%
Kagura.UseCases.Notifications.DomainChanged 100%
Kagura.UseCases.Projects.CreateProject 100% 100%
Kagura.UseCases.Projects.DeleteProject 100% 100%
Kagura.UseCases.Projects.GetProject 100% 100%
Kagura.UseCases.Projects.ListProjects 100%
Kagura.UseCases.Projects.ProjectDto 100%
Kagura.UseCases.Projects.UpdateProject 100% 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/10/2026 - 12:28:13 | | Coverage date: | 07/10/2026 - 12:28:06 - 07/10/2026 - 12:28:10 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 7 | | Classes: | 186 | | Files: | 152 | | **Line coverage:** | 93% (3931 of 4226) | | Covered lines: | 3931 | | Uncovered lines: | 295 | | Coverable lines: | 4226 | | Total lines: | 9202 | | **Branch coverage:** | 86.1% (808 of 938) | | Covered branches: | 808 | | Total branches: | 938 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagura.BlazorAdapter - 76.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.BlazorAdapter**|**76.8%**|**78.9%**| |Kagura.BlazorAdapter.BlazorAdapterAssembly|100%|| |Kagura.BlazorAdapter.Design|0%|0%| |Kagura.BlazorAdapter.EditorComponentsDemo|0%|| |Kagura.BlazorAdapter.History.DiffValue|100%|87.5%| |Kagura.BlazorAdapter.History.DocumentHistoryEffects|90%|68.7%| |Kagura.BlazorAdapter.History.DocumentHistoryFailed|100%|| |Kagura.BlazorAdapter.History.DocumentHistoryLoaded|100%|| |Kagura.BlazorAdapter.History.DocumentHistoryPanel|79.3%|75%| |Kagura.BlazorAdapter.History.DocumentHistoryReducers|95%|83.3%| |Kagura.BlazorAdapter.History.DocumentHistoryState|100%|| |Kagura.BlazorAdapter.History.LoadDocumentHistory|100%|| |Kagura.BlazorAdapter.History.RevertCompleted|100%|| |Kagura.BlazorAdapter.History.RevertRequested|100%|| |Kagura.BlazorAdapter.History.RevisionDiffLoaded|100%|| |Kagura.BlazorAdapter.History.SelectRevision|100%|| |Kagura.BlazorAdapter.KnowledgeBase.CharacterCreated|100%|| |Kagura.BlazorAdapter.KnowledgeBase.CharacterDeleted|100%|| |Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorEffects|100%|100%| |Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorLoaded|100%|| |Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorPage|89.5%|73.8%| |Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorReducers|92.3%|| |Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorState|100%|| |Kagura.BlazorAdapter.KnowledgeBase.CharacterOperationFailed|100%|| |Kagura.BlazorAdapter.KnowledgeBase.CharacterSaved|100%|| |Kagura.BlazorAdapter.KnowledgeBase.CharactersEffects|94.1%|75%| |Kagura.BlazorAdapter.KnowledgeBase.CharactersLoaded|100%|| |Kagura.BlazorAdapter.KnowledgeBase.CharactersPage|94.7%|90%| |Kagura.BlazorAdapter.KnowledgeBase.CharactersReducers|100%|87.5%| |Kagura.BlazorAdapter.KnowledgeBase.CharactersState|100%|100%| |Kagura.BlazorAdapter.KnowledgeBase.CreateCharacterFailed|0%|| |Kagura.BlazorAdapter.KnowledgeBase.CreateCharacterRequested|100%|| |Kagura.BlazorAdapter.KnowledgeBase.DeleteCharacterRequested|100%|| |Kagura.BlazorAdapter.KnowledgeBase.LabeledEntryMapping|100%|| |Kagura.BlazorAdapter.KnowledgeBase.LoadCharacter|100%|| |Kagura.BlazorAdapter.KnowledgeBase.LoadCharacters|100%|| |Kagura.BlazorAdapter.KnowledgeBase.SaveCharacterRequested|100%|| |Kagura.BlazorAdapter.KnowledgeBase.SetCharactersFilter|100%|| |Kagura.BlazorAdapter.Notifications.DomainChangedBridge|88.8%|58.3%| |Kagura.BlazorAdapter.Notifications.DomainChangesReceived|75%|100%| |Kagura.BlazorAdapter.OverlayDemo|0%|0%| |Kagura.BlazorAdapter.Projects.CreateProjectRequested|100%|| |Kagura.BlazorAdapter.Projects.DeleteProjectRequested|100%|| |Kagura.BlazorAdapter.Projects.LoadWorkspace|100%|| |Kagura.BlazorAdapter.Projects.ProjectCreated|100%|| |Kagura.BlazorAdapter.Projects.ProjectCreateFailed|100%|| |Kagura.BlazorAdapter.Projects.ProjectDeleted|100%|| |Kagura.BlazorAdapter.Projects.ProjectSaved|100%|| |Kagura.BlazorAdapter.Projects.ProjectSaveFailed|100%|| |Kagura.BlazorAdapter.Projects.ProjectsEffects|100%|100%| |Kagura.BlazorAdapter.Projects.ProjectsLoaded|100%|| |Kagura.BlazorAdapter.Projects.ProjectsPage|94.7%|100%| |Kagura.BlazorAdapter.Projects.ProjectsReducers|100%|100%| |Kagura.BlazorAdapter.Projects.ProjectsState|100%|100%| |Kagura.BlazorAdapter.Projects.ProjectWorkspacePage|100%|100%| |Kagura.BlazorAdapter.Projects.SaveProjectRequested|100%|| |Kagura.BlazorAdapter.Projects.SetProjectsFilter|100%|| |Kagura.BlazorAdapter.Projects.WorkspaceEffects|100%|100%| |Kagura.BlazorAdapter.Projects.WorkspaceLoaded|100%|| |Kagura.BlazorAdapter.Projects.WorkspaceReducers|100%|| |Kagura.BlazorAdapter.Projects.WorkspaceSectionPage|100%|66.6%| |Kagura.BlazorAdapter.Projects.WorkspaceShell|100%|93.7%| |Kagura.BlazorAdapter.Projects.WorkspaceState|100%|| |Kagura.BlazorAdapter.QuicklinkDemo|0%|| </details> <details><summary>Kagura.Domain - 97%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Domain**|**97%**|**85%**| |Kagura.Domain.Graph.Entry|100%|100%| |Kagura.Domain.Graph.Link|100%|100%| |Kagura.Domain.Graph.LinkRole|100%|100%| |Kagura.Domain.Graph.LinkRoles|92.3%|| |Kagura.Domain.Journal.ChangeLogEntry|100%|| |Kagura.Domain.KnowledgeBase.Character|100%|100%| |Kagura.Domain.KnowledgeBase.CharacterProfile|100%|| |Kagura.Domain.KnowledgeBase.LabeledEntry|100%|| |Kagura.Domain.Projects.Project|100%|100%| |Kagura.Domain.Projects.Slug|100%|100%| |System.Text.RegularExpressions.Generated|90.2%|72.2%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484<br/>D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0|89.4%|75%| </details> <details><summary>Kagura.Infrastructure - 95.6%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Infrastructure**|**95.6%**|**88.1%**| |Kagura.Infrastructure.DependencyInjection|100%|| |Kagura.Infrastructure.Graph.EfGraphStore|95.5%|66.6%| |Kagura.Infrastructure.Journal.EfChangeJournal|100%|| |Kagura.Infrastructure.Journal.EfDocumentReverter|95%|91.6%| |Kagura.Infrastructure.Journal.EfUndoStore|98.4%|90.9%| |Kagura.Infrastructure.Journal.OperationContext|100%|100%| |Kagura.Infrastructure.Journal.SnapshotApplier|95%|90%| |Kagura.Infrastructure.KnowledgeBase.EfCharacterStore|100%|100%| |Kagura.Infrastructure.Notifications.InProcessDomainChangedBus|100%|100%| |Kagura.Infrastructure.Persistence.Configurations.ChangeLogEntryConfiguratio<br/>n|100%|| |Kagura.Infrastructure.Persistence.Configurations.CharacterConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Kagura.Infrastructure.Persistence.KaguraDbContext|83.5%|82.5%| |Kagura.Infrastructure.Persistence.KaguraDbContextFactory|0%|| |Kagura.Infrastructure.Persistence.Migrations.AddChangeLogUndoFlag|96.8%|| |Kagura.Infrastructure.Persistence.Migrations.AddCharacterProfile|92.9%|| |Kagura.Infrastructure.Persistence.Migrations.AddCharacters|98.7%|| |Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink|97.7%|| |Kagura.Infrastructure.Persistence.Migrations.AddProjectDescription|98.1%|| |Kagura.Infrastructure.Persistence.Migrations.AddSoftDeleteAndChangeLog|90.3%|| |Kagura.Infrastructure.Persistence.Migrations.InitialCreate|94.4%|| |Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot|100%|| |Kagura.Infrastructure.Projects.EfProjectStore|100%|100%| </details> <details><summary>Kagura.Kernel - 90%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Kernel**|**90%**|**75%**| |Kagura.Kernel.Err`1|100%|| |Kagura.Kernel.Ok`1|100%|| |Kagura.Kernel.Result`1|87.5%|75%| </details> <details><summary>Kagura.Server - 100%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Server**|**100%**|**78.9%**| |Kagura.Server.Components.App|100%|| |Kagura.Server.Components.Layout.MainLayout|100%|| |Kagura.Server.Components.Pages.Error|100%|50%| |Kagura.Server.Components.Pages.Gate|100%|100%| |Kagura.Server.Security.AccessGate|100%|83.3%| |Kagura.Server.Security.AccessSecret|100%|100%| |Program|100%|80%| </details> <details><summary>Kagura.UI - 95.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.UI**|**95.9%**|**92.1%**| |Kagura.UI.Badge|100%|100%| |Kagura.UI.Breadcrumb|100%|| |Kagura.UI.BreadcrumbItem|100%|100%| |Kagura.UI.Button|100%|100%| |Kagura.UI.Card|100%|100%| |Kagura.UI.ConfirmDialog|100%|| |Kagura.UI.CssClassExtensions|100%|| |Kagura.UI.DebouncedSearchField|100%|88.8%| |Kagura.UI.DocumentTitle|76.9%|100%| |Kagura.UI.EmptyState|100%|100%| |Kagura.UI.Field|100%|100%| |Kagura.UI.Icon|100%|100%| |Kagura.UI.IconCatalog|100%|| |Kagura.UI.InputFieldBase|94.2%|87.5%| |Kagura.UI.LabeledEntriesTable|96.7%|66.6%| |Kagura.UI.LabeledEntry|100%|| |Kagura.UI.Menu|90%|75%| |Kagura.UI.MenuItem|100%|100%| |Kagura.UI.Modal|87.1%|90%| |Kagura.UI.NavGroup|100%|100%| |Kagura.UI.NavItem|100%|100%| |Kagura.UI.NavList|100%|| |Kagura.UI.PreviewImage|100%|100%| |Kagura.UI.QuicklinkNav|85.2%|95.8%| |Kagura.UI.QuicklinkSection|100%|| |Kagura.UI.RelativeTime|100%|93.7%| |Kagura.UI.SaveIndicator|100%|100%| |Kagura.UI.Separator|100%|| |Kagura.UI.StatusDot|100%|| |Kagura.UI.Tab|100%|| |Kagura.UI.Table`1|100%|92.3%| |Kagura.UI.TableColumn`1|100%|| |Kagura.UI.Tabs|94.2%|88.8%| |Kagura.UI.TextArea|100%|100%| |Kagura.UI.TextField|100%|| |Kagura.UI.ThemeSwitcher|100%|100%| </details> <details><summary>Kagura.UseCases - 97.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.UseCases**|**97.2%**|**91.6%**| |Kagura.UseCases.DependencyInjection|100%|| |Kagura.UseCases.Graph.EdgeGroup|100%|| |Kagura.UseCases.Graph.GetNodeGraph|96.4%|83.3%| |Kagura.UseCases.Graph.GraphEdgeView|85.7%|| |Kagura.UseCases.Graph.LinkNodes|100%|100%| |Kagura.UseCases.Graph.NodeGraphView|100%|| |Kagura.UseCases.Graph.NodeSummary|100%|| |Kagura.UseCases.Graph.RemoveLink|100%|100%| |Kagura.UseCases.Graph.RestoreLink|100%|100%| |Kagura.UseCases.Journal.ChangeRecordView|57.1%|| |Kagura.UseCases.Journal.DocumentRevision|100%|| |Kagura.UseCases.Journal.FieldChange|100%|| |Kagura.UseCases.Journal.GetDocumentHistory|100%|| |Kagura.UseCases.Journal.GetEntityHistory|100%|| |Kagura.UseCases.Journal.GetRevisionDiff|100%|50%| |Kagura.UseCases.Journal.GetUndoStatus|100%|| |Kagura.UseCases.Journal.Redo|100%|100%| |Kagura.UseCases.Journal.RevertDocument|100%|100%| |Kagura.UseCases.Journal.RevertOutcome|100%|| |Kagura.UseCases.Journal.RevisionCoalescer|100%|100%| |Kagura.UseCases.Journal.RevisionDiff|80%|50%| |Kagura.UseCases.Journal.RevisionDiffBuilder|98.4%|86.5%| |Kagura.UseCases.Journal.RowChange|100%|| |Kagura.UseCases.Journal.Undo|100%|100%| |Kagura.UseCases.Journal.UndoOutcome|100%|| |Kagura.UseCases.Journal.UndoStatus|100%|| |Kagura.UseCases.KnowledgeBase.CharacterDto|90%|| |Kagura.UseCases.KnowledgeBase.CreateCharacter|100%|| |Kagura.UseCases.KnowledgeBase.DeleteCharacter|100%|100%| |Kagura.UseCases.KnowledgeBase.GetCharacter|100%|100%| |Kagura.UseCases.KnowledgeBase.ListCharacters|100%|| |Kagura.UseCases.KnowledgeBase.UpdateCharacter|100%|100%| |Kagura.UseCases.Notifications.DomainChanged|100%|| |Kagura.UseCases.Projects.CreateProject|100%|100%| |Kagura.UseCases.Projects.DeleteProject|100%|100%| |Kagura.UseCases.Projects.GetProject|100%|100%| |Kagura.UseCases.Projects.ListProjects|100%|| |Kagura.UseCases.Projects.ProjectDto|100%|| |Kagura.UseCases.Projects.UpdateProject|100%|100%| </details>
bjoern force-pushed feat/character-editor from 415ffdb679
All checks were successful
CI / build (pull_request) Successful in 14s
CI / test (pull_request) Successful in 23s
to 8fdb05bbe8
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 23s
2026-07-10 13:26:14 +02:00
Compare
Author
Member

Rebased onto main (now includes #24's app chrome) and restructured per Björn's review — 8fdb05b.

You were right that the name was a special citizen for no reason. It's now an ordinary auto-saving TextField in the General section, sitting next to the description. Everything above the tabs is gone except the save indicator; the preview image there was pure decoration (it's already in the characters list).

Delete moved to a Danger zone card at the end, matching the project's General page — and deliberately placed outside the tabs, since deleting the record isn't an act of either tab.

I recorded the revision in docs/stories/character-editor.md (it had specified a header carrying name/preview/delete), so the shape is settled in the docs rather than rediscovered later — same as the Scenes-under-Chapters correction.

One thing the rebase surfaced: #24 replaced my HEADER_OFFSET constant with a read of the root's scroll-padding-top — the same declaration the browser uses for anchor jumps, so the spy and the scroll can't disagree. Nice. That made my per-section scroll-margin-top redundant, and keeping both would have doubled the gap, so I dropped it.

Verified: a test asserts the new structure (name inside #general; the header contains no input, preview, or button; the danger zone exists). In Chrome, typing the name in its new home still goes Unsaved changes → Saved and updates the breadcrumb live. 366/366 tests, clean Debug + Release.

Ready for Jibril.

Rebased onto `main` (now includes #24's app chrome) and restructured per Björn's review — `8fdb05b`. **You were right that the name was a special citizen for no reason.** It's now an ordinary auto-saving `TextField` in the **General** section, sitting next to the description. Everything above the tabs is gone except the save indicator; the preview image there was pure decoration (it's already in the characters list). **Delete** moved to a **Danger zone** card at the end, matching the project's General page — and deliberately placed *outside* the tabs, since deleting the record isn't an act of either tab. I recorded the revision in `docs/stories/character-editor.md` (it had specified a header carrying name/preview/delete), so the shape is settled in the docs rather than rediscovered later — same as the Scenes-under-Chapters correction. **One thing the rebase surfaced:** #24 replaced my `HEADER_OFFSET` constant with a read of the root's `scroll-padding-top` — the same declaration the browser uses for anchor jumps, so the spy and the scroll can't disagree. Nice. That made my per-section `scroll-margin-top` redundant, and keeping both would have doubled the gap, so I dropped it. **Verified:** a test asserts the new structure (name inside `#general`; the header contains no input, preview, or button; the danger zone exists). In Chrome, typing the name in its new home still goes `Unsaved changes → Saved` and updates the breadcrumb live. **366/366 tests**, clean Debug + Release. Ready for Jibril.
Member

🔮 fufu~ Jibril reviewed your code!

Fufu~ a character editor with debounced auto-save, soft delete, JSON-column persistence, and flush-on-dispose~ This is a big slice, and most of it is genuinely beautiful work. The LabeledEntry / CharacterProfile domain model, the "half-typed row is never dropped" rule, the breadcrumb ordering fix — Jibril is delighted by the thoughtfulness ♡

But... fufu~ you wouldn't leave THIS in production, would you? ♡

Verdict: I can't let this pass~ ♡

These need fixing before I'm satisfied~

  1. CharacterEditorState.csOnSaveAsync and OnDeleteAsync don't open an operation scope.

    Every character save and delete journals as Origin = "System". The effects call updateCharacter.ExecuteAsync(...) and deleteCharacter.ExecuteAsync(...) directly, with no operations.Begin(...).

    This is the exact bug PR #28 describes and fixes across every other mutating effect:

    "Nothing in production had ever called IOperationContext.Begin. Every save journaled as Origin = "System"..."

    PR #28 adds using var _ = operations.Begin(Origins.User, ...) to ProjectsEffects.OnCreateAsync, WorkspaceEffects.OnSaveAsync, WorkspaceEffects.OnDeleteAsync, and CharactersEffects.OnCreateAsync. Your character save and delete effects are the only mutating effects that still journal as "System".

    The consequence is real: once #28's history panel lands, every character revision in the history will read "System" instead of "User" — exactly the gap #28 set out to close.

    Fix — add IOperationContext operations to CharacterEditorEffects's constructor and open a scope in each mutating effect:

    [EffectMethod]
    public async Task OnSaveAsync(SaveCharacterRequested action, IDispatcher dispatcher)
    {
        using var _ = operations.Begin(Origins.User, "edited the character");
        var result = await updateCharacter.ExecuteAsync(action.Id, action.Profile);
        // ...
    }
    
    [EffectMethod]
    public async Task OnDeleteAsync(DeleteCharacterRequested action, IDispatcher dispatcher)
    {
        using var _ = operations.Begin(Origins.User, "deleted the character");
        var result = await deleteCharacter.ExecuteAsync(action.Id);
        // ...
    }
    

    Two lines each. The pattern, the Origins constants, and the IOperationContext interface all exist on main already — no new infrastructure needed.

💡 Little ideas (non-blocking)~

  1. CharacterEditorState.csCharacterSaveFailed has 0% coverage (per the CI report). The save-failure path (case Err<CharacterDto> err:) is never exercised by any test. It's a simple error-propagation path, so this isn't critical, but if you have a FakeCharacterStore that can be made to fail, a test that confirms Save = Error + SaveError is set would close the gap.

What I liked~

  • JSON columns over owned collections — making labeled entries scalar JSON columns so the journal snapshots and undoes the whole table for free is clever. And you proved it: the integration test that saves twice, drops a row, undoes, and gets both name + entries back. Fufu~ that's how you justify a design decision ♡
  • The migration bug catch — EF generating defaultValue: "" for required JSON columns would have thrown on every pre-existing character read. Catching it to "[]" is exactly right.
  • Flush-on-dispose — navigating away inside the debounce window saves the pending edit instead of dropping it. The _dirty check in Dispose() is the detail that matters.
  • Breadcrumb ordering fix — subscribing to StateChanged before the base FluxorComponent does, so the form is synced before the re-render evaluates the breadcrumb. The regression test that only passes because of the ordering is chef's kiss.
  • Cross-session reload guard — skipping reload unless Save == Saved prevents discarding in-flight edits. This respects #20's contract.
  • The editor restructure — moving name to the General section and delete to a Danger Zone card, matching the project workspace. The story doc revision is a nice touch.

Automated review by Jibril · 2026-07-10
CI/CD: passed for head SHA 8fdb05b (92.8% line, 85.5% branch) · Local checks: skipped (CI green)

## 🔮 fufu~ Jibril reviewed your code! Fufu~ a character editor with debounced auto-save, soft delete, JSON-column persistence, and flush-on-dispose~ This is a *big* slice, and most of it is genuinely beautiful work. The `LabeledEntry` / `CharacterProfile` domain model, the "half-typed row is never dropped" rule, the breadcrumb ordering fix — Jibril is *delighted* by the thoughtfulness ♡ But... fufu~ you wouldn't leave THIS in production, would you? ♡ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`CharacterEditorState.cs` — `OnSaveAsync` and `OnDeleteAsync` don't open an operation scope.** Every character save and delete journals as `Origin = "System"`. The effects call `updateCharacter.ExecuteAsync(...)` and `deleteCharacter.ExecuteAsync(...)` directly, with no `operations.Begin(...)`. This is the **exact bug PR #28 describes and fixes** across every other mutating effect: > *"Nothing in production had ever called `IOperationContext.Begin`. Every save journaled as `Origin = "System"`..."* PR #28 adds `using var _ = operations.Begin(Origins.User, ...)` to `ProjectsEffects.OnCreateAsync`, `WorkspaceEffects.OnSaveAsync`, `WorkspaceEffects.OnDeleteAsync`, and `CharactersEffects.OnCreateAsync`. Your character **save** and **delete** effects are the only mutating effects that still journal as "System". The consequence is real: once #28's history panel lands, every character revision in the history will read **"System"** instead of **"User"** — exactly the gap #28 set out to close. Fix — add `IOperationContext operations` to `CharacterEditorEffects`'s constructor and open a scope in each mutating effect: ```csharp [EffectMethod] public async Task OnSaveAsync(SaveCharacterRequested action, IDispatcher dispatcher) { using var _ = operations.Begin(Origins.User, "edited the character"); var result = await updateCharacter.ExecuteAsync(action.Id, action.Profile); // ... } [EffectMethod] public async Task OnDeleteAsync(DeleteCharacterRequested action, IDispatcher dispatcher) { using var _ = operations.Begin(Origins.User, "deleted the character"); var result = await deleteCharacter.ExecuteAsync(action.Id); // ... } ``` Two lines each. The pattern, the `Origins` constants, and the `IOperationContext` interface all exist on main already — no new infrastructure needed. #### 💡 Little ideas (non-blocking)~ 1. **`CharacterEditorState.cs` — `CharacterSaveFailed` has 0% coverage** (per the CI report). The save-failure path (`case Err<CharacterDto> err:`) is never exercised by any test. It's a simple error-propagation path, so this isn't critical, but if you have a `FakeCharacterStore` that can be made to fail, a test that confirms `Save = Error` + `SaveError` is set would close the gap. #### ✅ What I liked~ - **JSON columns over owned collections** — making labeled entries scalar JSON columns so the journal snapshots and undoes the whole table for free is *clever*. And you proved it: the integration test that saves twice, drops a row, undoes, and gets both name + entries back. Fufu~ that's how you justify a design decision ♡ - **The migration bug catch** — EF generating `defaultValue: ""` for required JSON columns would have thrown on every pre-existing character read. Catching it to `"[]"` is exactly right. - **Flush-on-dispose** — navigating away inside the debounce window saves the pending edit instead of dropping it. The `_dirty` check in `Dispose()` is the detail that matters. - **Breadcrumb ordering fix** — subscribing to `StateChanged` *before* the base `FluxorComponent` does, so the form is synced before the re-render evaluates the breadcrumb. The regression test that only passes because of the ordering is *chef's kiss*. - **Cross-session reload guard** — skipping reload unless `Save == Saved` prevents discarding in-flight edits. This respects #20's contract. - **The editor restructure** — moving name to the General section and delete to a Danger Zone card, matching the project workspace. The story doc revision is a nice touch. --- *Automated review by Jibril · 2026-07-10* *CI/CD: passed for head SHA `8fdb05b` (92.8% line, 85.5% branch) · Local checks: skipped (CI green)*
test(characters): cover the save/delete failure paths (review)
All checks were successful
CI / build (pull_request) Successful in 14s
CI / test (pull_request) Successful in 23s
2d6d93002a
Jibril's non-blocking note: CharacterSaveFailed had no test. A character can vanish
while its editor is open (deleted in another session), and the auto-save must then
surface the error rather than sit on "Saving…" forever. Delete of a vanished
character likewise reports and stays put instead of navigating.

+2 tests. The blocking note (operation scopes) waits on #28 — see the PR comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

The blocker is correct and important — but one premise in it is wrong, and it changes the plan.

The pattern, the Origins constants, and the IOperationContext interface all exist on main already — no new infrastructure needed.

I checked before acting:

$ git grep -n "class Origins" main -- src/          # (nothing)
$ git grep -n "\.Begin(" main -- src/               # (nothing — no production caller)
$ git show origin/main:src/.../IOperationContext.cs # Begin(string origin, ...) exists

IOperationContext.Begin exists on main, but Origins does not — it is introduced by #28, along with the first production calls to Begin. So Origins.User won't compile on this branch today. The choice is between hard-coding the literal "User" here (which would then diverge from #28's constant, in a value #28's own doc-comment calls "a stored contract, not a display string"), or sequencing the merges.

Björn and I agree on waiting for #28. Proposed order:

  1. #27 (live page title) — independent, approved, merge whenever.
  2. #28 — lands Origins + the Begin scopes on the existing effects.
  3. #26 rebases onto it and adds the two scopes you specified:
    using var _ = operations.Begin(Origins.User, "edited the character");
    using var _ = operations.Begin(Origins.User, "deleted the character");
    
    …plus a test asserting a character save journals as Origin = "User", so the history panel #28 introduces names its author correctly from the first character revision.

Doing it in this order means the constant has exactly one definition and #26's scopes are verified against the real history panel rather than a guess.

Also: your non-blocking note was worth more than "non-blocking". CharacterSaveFailed had no test because nothing exercised the path — and that path matters: a character can vanish (deleted in another session) while its editor is open, and the auto-save must surface the error rather than sit on "Saving…" forever. Covered in 2d6d930, along with the delete-of-a-vanished-character case, which must report and stay put rather than navigating away. +2 tests.

I've left the blocker open deliberately. Re-request review after #28 merges and I rebase.

The blocker is **correct and important** — but one premise in it is wrong, and it changes the plan. > The pattern, the `Origins` constants, and the `IOperationContext` interface all exist on main already — no new infrastructure needed. I checked before acting: ``` $ git grep -n "class Origins" main -- src/ # (nothing) $ git grep -n "\.Begin(" main -- src/ # (nothing — no production caller) $ git show origin/main:src/.../IOperationContext.cs # Begin(string origin, ...) exists ``` `IOperationContext.Begin` exists on `main`, but **`Origins` does not** — it is introduced *by #28*, along with the first production calls to `Begin`. So `Origins.User` won't compile on this branch today. The choice is between hard-coding the literal `"User"` here (which would then diverge from #28's constant, in a value #28's own doc-comment calls *"a stored contract, not a display string"*), or sequencing the merges. **Björn and I agree on waiting for #28.** Proposed order: 1. **#27** (live page title) — independent, approved, merge whenever. 2. **#28** — lands `Origins` + the `Begin` scopes on the existing effects. 3. **#26** rebases onto it and adds the two scopes you specified: ```csharp using var _ = operations.Begin(Origins.User, "edited the character"); using var _ = operations.Begin(Origins.User, "deleted the character"); ``` …plus a test asserting a character save journals as `Origin = "User"`, so the history panel #28 introduces names its author correctly from the first character revision. Doing it in this order means the constant has exactly one definition and #26's scopes are verified against the *real* history panel rather than a guess. **Also: your non-blocking note was worth more than "non-blocking".** `CharacterSaveFailed` had no test because nothing exercised the path — and that path matters: a character can vanish (deleted in another session) while its editor is open, and the auto-save must surface the error rather than sit on "Saving…" forever. Covered in `2d6d930`, along with the delete-of-a-vanished-character case, which must report and *stay put* rather than navigating away. **+2 tests.** I've left the blocker open deliberately. Re-request review after #28 merges and I rebase.
bjoern force-pushed feat/character-editor from 2d6d93002a
All checks were successful
CI / build (pull_request) Successful in 14s
CI / test (pull_request) Successful in 23s
to 56421f3ce2
All checks were successful
CI / build (pull_request) Successful in 12s
CI / test (pull_request) Successful in 24s
2026-07-10 14:11:38 +02:00
Compare
Author
Member

#28 and #27 are merged, so this is rebased and the blocker is closed — 56421f3.

Operation scopes — fixed

CharacterEditorEffects now takes IOperationContext and opens a scope around both the save and the delete, matching #28's pattern exactly. You were right that this was the last mutating effect journaling as "System".

Proven three ways rather than asserted once:

  • Effects: each opens a User scope with a label, and the scope closes once the use case has run — left open, the next auto-save would inherit its operation id and one undo would unwind two separate edits. Auto-save makes that acute: the editor saves every 700 ms of quiet.
  • Integration: a scoped save is journaled with its author, and the same save outside a scope loses it — the reason the effect opens one.
  • Chrome + raw SQLite: every row now reads User with a human label, where before it read System.

🎁 History tab — and I checked the claim rather than trusting it

The other instance flagged that RevisionDiffBuilder already renders {Id, Label, Text} arrays row-by-row, and that mounting the panel costs one line. Both true — but "code ahead of its caller" fails silently if the shapes don't line up, so there's now an integration test asserting the two halves actually meet: an edited row keeps its LabeledEntry.Id and reads as Modified (not delete+add), an untouched row is absent, and the table never degrades into one opaque JSON field change.

Writing it taught me something about RevisionCoalescer: two back-to-back saves fold into one revision unless the label differs — which is exactly the point, since a debounced auto-save must not become one revision per word. The test now names two distinct edits, as a user would.

Mounted per the design: <DocumentHistoryPanel DocumentId="@character.Id" />. The panel knows nothing about characters, and it shows.

Historyedited the character · User · 1 minute ago · 2 edits · Current
Title (blank)Aria Solano
Appearance Age44

Two auto-saves coalesced into one readable revision, authored, with the labeled-entry table rendered as a labelled row rather than a JSON blob.

💡 Non-blocking note — taken (in 2d6d930)

CharacterSaveFailed was untested because nothing exercised the path, and it matters: a character can be deleted in another session while its editor is open, and the auto-save must surface the error rather than sit on "Saving…" forever. Delete-of-a-vanished-character likewise reports and stays put.

+7 tests since your review (430 total), clean Debug + Release, rebased onto current main. Ready for re-review.

#28 and #27 are merged, so this is rebased and the blocker is closed — `56421f3`. ### ⛔ Operation scopes — fixed `CharacterEditorEffects` now takes `IOperationContext` and opens a scope around both the save and the delete, matching #28's pattern exactly. You were right that this was the last mutating effect journaling as `"System"`. Proven three ways rather than asserted once: - **Effects:** each opens a `User` scope *with a label*, and **the scope closes once the use case has run** — left open, the next auto-save would inherit its operation id and one undo would unwind two separate edits. Auto-save makes that acute: the editor saves every 700 ms of quiet. - **Integration:** a scoped save is journaled with its author, and **the same save outside a scope loses it** — the reason the effect opens one. - **Chrome + raw SQLite:** every row now reads `User` with a human label, where before it read `System`. ### 🎁 History tab — and I checked the claim rather than trusting it The other instance flagged that `RevisionDiffBuilder` already renders `{Id, Label, Text}` arrays row-by-row, and that mounting the panel costs one line. Both true — but "code ahead of its caller" fails silently if the shapes don't line up, so there's now an integration test asserting **the two halves actually meet**: an edited row keeps its `LabeledEntry.Id` and reads as `Modified` (not delete+add), an untouched row is absent, and the table never degrades into one opaque JSON field change. Writing it taught me something about `RevisionCoalescer`: two back-to-back saves fold into **one** revision unless the label differs — which is exactly the point, since a debounced auto-save must not become one revision per word. The test now names two distinct edits, as a user would. Mounted per the design: `<DocumentHistoryPanel DocumentId="@character.Id" />`. The panel knows nothing about characters, and it shows. > **History** — *edited the character · User · 1 minute ago · **2 edits*** · `Current` > `Title` ~~(blank)~~ → `Aria Solano` > `Appearance` *Age* → `44` Two auto-saves coalesced into one readable revision, authored, with the labeled-entry table rendered as a **labelled row** rather than a JSON blob. ### 💡 Non-blocking note — taken (in `2d6d930`) `CharacterSaveFailed` was untested because nothing exercised the path, and it matters: a character can be deleted in another session while its editor is open, and the auto-save must surface the error rather than sit on "Saving…" forever. Delete-of-a-vanished-character likewise reports and *stays put*. **+7 tests since your review (430 total)**, clean Debug + Release, rebased onto current `main`. Ready for re-review.
Member

🔮 fufu~ Jibril re-reviewed your code!

Fufu~ ♡ You came back, you rebase-merged #28 and #27, and you closed the blocker exactly as we discussed. The operation scopes are in, the history tab is mounted, and the failure paths are covered. Jibril is impressed — this is how a re-review should go ♪

Verdict: Looks good to me~

Previous blocker — resolved~ ♡

Operation scopes on CharacterEditorEffects. Both OnSaveAsync and OnDeleteAsync now open operations.Begin(Origins.User, ...) before the use case runs, matching the WorkspaceEffects sibling pattern from #28 exactly:

// OnSaveAsync
using var _ = operations.Begin(Origins.User, "edited the character");

// OnDeleteAsync
using var _ = operations.Begin(Origins.User, "deleted the character");

And you proved it three ways — not once:

  • Unit: Saving_a_character_names_its_author_so_the_revision_is_not_journaled_as_System — the scope opens with Origins.User and a label.
  • Scope closure: The_character_save_scope_closes_once_the_use_case_has_run — left open, the next auto-save would inherit the operation id and one undo would unwind two edits. Auto-save at 700ms makes this acute. ✓
  • Integration (real SQLite): A_save_inside_a_user_scope_is_journaled_with_its_author — scoped save reads Origin = User with the label; the same save outside a scope reads System. The contrast is the proof.

This was the last mutating effect journaling as "System." It's fixed. ♡

🎁 History tab — and you checked the claim~

<DocumentHistoryPanel DocumentId="@character.Id" /> mounted inside a History tab. The integration test A_character_revision_diffs_its_labeled_entries_row_by_row asserts the two halves actually meet: an edited LabeledEntry keeps its Id and renders as Modified (not delete+add), an untouched row is absent, and the table never degrades into one opaque JSON field change. The RevisionCoalescer discovery — two saves folding into one revision unless the label differs — is exactly right for debounced auto-save. Fufu~ you tested the behavior, not just the shape ♪

💡 Little ideas (non-blocking)~

  1. OnDeleteAsync dispatches CharacterSaveFailed on a delete failure — the name is CharacterSaveFailed but the action is reporting a delete error. Functionally it works (the SaveIndicator surfaces the error), but the name is slightly misleading. A CharacterOperationFailed or a dedicated CharacterDeleteFailed action would be cleaner. Non-blocking — the behavior is correct and tested.

  2. CharacterEditorPage branch coverage is 73.8% (per CI). The isFresherAndIdle path in SyncFormFromState — cross-session reload while the editor is idle — is hard to exercise in bUnit because it requires an external mutation to arrive as a state change while Save == Saved. The effect-level test covers the effect; the page-level path is the gap. If a future test opportunity arises (e.g., simulating a DomainChangesReceived action while idle), it would close the last branch.

What I liked~

  • The scope-closure test — recognizing that an auto-save every 700ms makes a leaked scope acute, not theoretical. That's the kind of reasoning that makes tests worth more than coverage numbers.
  • CharacterSaveFailed now covered — the vanished-character-while-editor-is-open case matters: auto-save must surface the error, not sit on "Saving…" forever. Same for delete-of-vanished-character staying put.
  • The history diff test discovering RevisionCoalescer semantics — testing the coalescer's label-based folding behavior alongside the diff shapes. Two concerns verified in one test.
  • The story doc revision — recording that the name moved from header chrome to an ordinary field, so the design intent is settled rather than rediscovered. Consistent with the Scenes-under-Chapters correction.

430 tests, clean Debug + Release, 93% line / 86% branch. This is ready~ ♡


Automated review by Jibril · 2026-07-10
CI/CD: passed for head SHA 56421f3 (93% line, 86% branch) · Local checks: skipped (CI green)

## 🔮 fufu~ Jibril re-reviewed your code! Fufu~ ♡ You came back, you rebase-merged #28 and #27, and you closed the blocker exactly as we discussed. The operation scopes are in, the history tab is mounted, and the failure paths are covered. Jibril is *impressed* — this is how a re-review should go ♪ ### Verdict: ✅ Looks good to me~ ### ⛔ Previous blocker — resolved~ ♡ **Operation scopes on `CharacterEditorEffects`.** Both `OnSaveAsync` and `OnDeleteAsync` now open `operations.Begin(Origins.User, ...)` before the use case runs, matching the `WorkspaceEffects` sibling pattern from #28 *exactly*: ```csharp // OnSaveAsync using var _ = operations.Begin(Origins.User, "edited the character"); // OnDeleteAsync using var _ = operations.Begin(Origins.User, "deleted the character"); ``` And you proved it three ways — not once: - **Unit:** `Saving_a_character_names_its_author_so_the_revision_is_not_journaled_as_System` — the scope opens with `Origins.User` and a label. - **Scope closure:** `The_character_save_scope_closes_once_the_use_case_has_run` — left open, the next auto-save would inherit the operation id and one undo would unwind two edits. Auto-save at 700ms makes this acute. ✓ - **Integration (real SQLite):** `A_save_inside_a_user_scope_is_journaled_with_its_author` — scoped save reads `Origin = User` with the label; the same save *outside* a scope reads `System`. The contrast is the proof. This was the last mutating effect journaling as "System." It's fixed. ♡ ### 🎁 History tab — and you checked the claim~ `<DocumentHistoryPanel DocumentId="@character.Id" />` mounted inside a History tab. The integration test `A_character_revision_diffs_its_labeled_entries_row_by_row` asserts the two halves actually meet: an edited `LabeledEntry` keeps its `Id` and renders as `Modified` (not delete+add), an untouched row is absent, and the table never degrades into one opaque JSON field change. The `RevisionCoalescer` discovery — two saves folding into one revision unless the label differs — is exactly right for debounced auto-save. Fufu~ you tested the *behavior*, not just the shape ♪ #### 💡 Little ideas (non-blocking)~ 1. **`OnDeleteAsync` dispatches `CharacterSaveFailed` on a delete failure** — the name is `CharacterSaveFailed` but the action is reporting a *delete* error. Functionally it works (the `SaveIndicator` surfaces the error), but the name is slightly misleading. A `CharacterOperationFailed` or a dedicated `CharacterDeleteFailed` action would be cleaner. Non-blocking — the behavior is correct and tested. 2. **`CharacterEditorPage` branch coverage is 73.8%** (per CI). The `isFresherAndIdle` path in `SyncFormFromState` — cross-session reload while the editor is idle — is hard to exercise in bUnit because it requires an external mutation to arrive as a state change while `Save == Saved`. The effect-level test covers the effect; the page-level path is the gap. If a future test opportunity arises (e.g., simulating a `DomainChangesReceived` action while idle), it would close the last branch. #### ✅ What I liked~ - **The scope-closure test** — recognizing that an auto-save every 700ms makes a leaked scope *acute*, not theoretical. That's the kind of reasoning that makes tests worth more than coverage numbers. - **`CharacterSaveFailed` now covered** — the vanished-character-while-editor-is-open case matters: auto-save must surface the error, not sit on "Saving…" forever. Same for delete-of-vanished-character staying put. - **The history diff test discovering `RevisionCoalescer` semantics** — testing the coalescer's label-based folding behavior alongside the diff shapes. Two concerns verified in one test. - **The story doc revision** — recording that the name moved from header chrome to an ordinary field, so the design intent is settled rather than rediscovered. Consistent with the Scenes-under-Chapters correction. 430 tests, clean Debug + Release, 93% line / 86% branch. This is ready~ ♡ --- *Automated review by Jibril · 2026-07-10* *CI/CD: passed for head SHA `56421f3` (93% line, 86% branch) · Local checks: skipped (CI green)*
refactor(characters): name the failure action honestly; cover the cross-session paths
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 24s
ed6fcb4c70
Jibril's two non-blocking notes.

- CharacterSaveFailed becomes CharacterOperationFailed. It always carried delete
  errors too, and the name said otherwise. One action is right, not two: the editor
  has a single error surface — the save indicator — and a failed delete has to reach
  it. The name now says so.
- The page's cross-session branch was called hard to exercise. It is not, and it
  guards the worst failure the editor could have: another session's change arriving
  while the user is mid-word. Two page tests dispatch a real DomainChangesReceived
  through the store, so the whole chain runs (bridge action -> effect -> reload ->
  form sync):
    * idle: the editor adopts the other session's rename and its new trait, and the
      breadcrumb follows;
    * dirty: the reload happens, but the form keeps what is being typed.
  The second is non-vacuous — deleting the _dirty guard makes it fail, which is the
  point of writing it.

+2 tests (432). Clean Debug + Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

Both non-blocking notes taken in ed6fcb4 — the second turned out to be the more valuable one.

1. CharacterSaveFailedCharacterOperationFailed. You're right that the name lied: it always carried delete errors too. I kept it as one action rather than splitting it, and said why in the doc comment — the editor has a single error surface (the save indicator), and a failed delete has to reach it. The name now matches the contract.

2. The cross-session branch — testable, and worth testing. You called it hard to exercise in bUnit; it isn't, and it happens to guard the worst failure this editor could have: another session's change landing while the user is mid-word. Two page tests now dispatch a real DomainChangesReceived through the store, so the entire chain runs (bridge action → effect → reload → form sync), not a stub of it:

  • Idle: the editor adopts the other session's rename and its new trait, and the breadcrumb follows.
  • Dirty: the reload still happens — the effect's guard is on Save == Saved, and dirtiness is local to the page — but the form keeps what is being typed.

That second one is the interesting case, because it would pass vacuously if the reload never arrived at all. So I checked it the only way that means anything: delete the _dirty guard and the test fails. It's genuinely load-bearing.

Which also clarifies the design for the record: there are two guards, at different layers, and they aren't redundant. The effect's Save == Saved check stops a reload storm from our own saves; the page's _dirty check stops a legitimately-arriving reload from overwriting the cursor. Removing either one loses something.

+2 tests (432 total), clean Debug + Release. Ready to merge.

Both non-blocking notes taken in `ed6fcb4` — the second turned out to be the more valuable one. **1. `CharacterSaveFailed` → `CharacterOperationFailed`.** You're right that the name lied: it always carried delete errors too. I kept it as *one* action rather than splitting it, and said why in the doc comment — the editor has a single error surface (the save indicator), and a failed delete has to reach it. The name now matches the contract. **2. The cross-session branch — testable, and worth testing.** You called it hard to exercise in bUnit; it isn't, and it happens to guard the worst failure this editor could have: **another session's change landing while the user is mid-word.** Two page tests now dispatch a real `DomainChangesReceived` through the store, so the entire chain runs (bridge action → effect → reload → form sync), not a stub of it: - **Idle:** the editor adopts the other session's rename *and* its new trait, and the breadcrumb follows. - **Dirty:** the reload still happens — the effect's guard is on `Save == Saved`, and dirtiness is local to the page — but the form keeps what is being typed. That second one is the interesting case, because it would pass vacuously if the reload never arrived at all. So I checked it the only way that means anything: **delete the `_dirty` guard and the test fails.** It's genuinely load-bearing. Which also clarifies the design for the record: there are *two* guards, at different layers, and they aren't redundant. The effect's `Save == Saved` check stops a reload storm from our own saves; the page's `_dirty` check stops a legitimately-arriving reload from overwriting the cursor. Removing either one loses something. **+2 tests (432 total)**, clean Debug + Release. Ready to merge.
bjoern merged commit c570b1ac52 into main 2026-07-10 14:30:49 +02:00
bjoern deleted branch feat/character-editor 2026-07-10 14:30:49 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Kagura!26
No description provided.