feat: Phase 1 · 7/7 — workspace, bible & page editor #11

Merged
bjoern merged 2 commits from feat/p1-ui-content into main 2026-07-24 22:23:57 +02:00
Member

The final cut of the Phase-1 stack (after #10) — the manual manager is complete, and the ready-project route gap flagged on #10 closes.

Scope

  • Project workspace (pages-and-chapters story): chapters create/rename/reorder/delete with the last-chapter guard; pages with grip-reorder (keyboard-accessible via DragReorderList), kind select (cover implies skipTypeset), move-to-chapter, modal-gated delete, zip upload; annotated and no summary badges — the load-bearing gap of ADR 0013, visible per page.
  • Bible (bible story): all five tables with debounced auto-save and no Save button anywhere (ADR 0022 — asserted); explicit add rows (a row needs its key field), modal-gated deletes, page summaries with the missing flag, and blanking a summary deletes it — the page goes back to visibly blocked.
  • Page editor (page-workspace story): the Raw · Bbox · Translation · Cleaned · Typeset switcher with the pixel-pass views honestly deferred (ADR 0021). Manual bbox editing via Kagaku.UI's RegionSelector with a labelled ghost overlay: a commit with a selection moves that box (saved on pointer-up, no debounce), with none it drag-creates at the end of reading order with the next never-reused label. The region editor saves the whole profile per ADR 0022, recorded-only types drop typeset in the same write (ADR 0012), and the translation overlay falls back to JP where EN is missing.
  • region.js in App.razor; the Debounce helper with direct latest-write-wins / flush tests.

Coverage

30 new bUnit tests (51 adapter total) over the shared AdapterTestContext — including the RegionSelector commit contract invoked directly (the live drag is JS; the contract is the committed rect), the modal-scoping subtlety (closed <dialog>s keep their buttons in the DOM), and grip-keyboard reordering. BlazorAdapter 78.4% line / 73.9% branch in isolation; the remainder is JS-gesture plumbing and error arms real use cases can't produce. 227 tests green solution-wide.

Verified in a browser

Seeded world driven end to end on this branch: gate → list → ready project opens its workspace (the #10 seam, closed) → page editor with regions/tabs → bible. The only console noise is the seeded imageless pages' expected thumbnail 404s.

Ships when (PROJECT_PLAN Phase 1): you can create a project, upload pages, hand-author regions and the bible, and browse it all end to end — no OpenRouter key required. With this merge, that's true on main.

🤖 Generated with Claude Code

The final cut of the Phase-1 stack (after #10) — the manual manager is complete, and the ready-project route gap flagged on #10 closes. ## Scope - **Project workspace** (pages-and-chapters story): chapters create/rename/reorder/delete with the last-chapter guard; pages with grip-reorder (keyboard-accessible via `DragReorderList`), kind select (cover implies `skipTypeset`), move-to-chapter, modal-gated delete, zip upload; **annotated** and **no summary** badges — the load-bearing gap of ADR 0013, visible per page. - **Bible** (bible story): all five tables with **debounced auto-save and no Save button anywhere** (ADR 0022 — asserted); explicit add rows (a row needs its key field), modal-gated deletes, page summaries with the *missing* flag, and blanking a summary deletes it — the page goes back to visibly blocked. - **Page editor** (page-workspace story): the **Raw · Bbox · Translation · Cleaned · Typeset** switcher with the pixel-pass views honestly deferred (ADR 0021). Manual bbox editing via Kagaku.UI's `RegionSelector` with a labelled ghost overlay: a commit with a selection moves that box (saved on pointer-up, no debounce), with none it **drag-creates** at the end of reading order with the next never-reused label. The region editor saves the whole profile per ADR 0022, recorded-only types drop `typeset` in the same write (ADR 0012), and the translation overlay falls back to JP where EN is missing. - `region.js` in App.razor; the `Debounce` helper with direct latest-write-wins / flush tests. ## Coverage 30 new bUnit tests (51 adapter total) over the shared `AdapterTestContext` — including the **`RegionSelector` commit contract invoked directly** (the live drag is JS; the contract is the committed rect), the modal-scoping subtlety (closed `<dialog>`s keep their buttons in the DOM), and grip-keyboard reordering. BlazorAdapter 78.4% line / 73.9% branch in isolation; the remainder is JS-gesture plumbing and error arms real use cases can't produce. **227 tests green solution-wide.** ## Verified in a browser Seeded world driven end to end on this branch: gate → list → ready project opens its workspace (the #10 seam, closed) → page editor with regions/tabs → bible. The only console noise is the seeded imageless pages' expected thumbnail 404s. **Ships when** (PROJECT_PLAN Phase 1): *you can create a project, upload pages, hand-author regions and the bible, and browse it all end to end — no OpenRouter key required.* ✅ With this merge, that's true on main. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: workspace, bible, and the page editor
All checks were successful
CI / build (pull_request) Successful in 25s
CI / test (pull_request) Successful in 35s
9ed4634be4
Phase 1, cut 7 of 7 — the manual manager is complete. The project workspace organizes chapters
(create/rename/reorder/delete with the last-chapter guard) and pages (grip-reorder, kind with
cover-implies-skip-typeset, move, modal-gated delete, zip upload), surfacing annotated and
missing-summary badges (ADR 0013's load-bearing gap). The bible's five tables auto-save
debounced with no Save button anywhere (ADR 0022), summaries per page with the missing flag,
deletes modal-gated. The page workspace carries the Raw · Bbox · Translation switcher with the
pixel-pass views honestly deferred (ADR 0021): RegionSelector-driven manual bbox editing (drag
commits save immediately; a commit with nothing selected drag-creates at the end of reading
order), the region editor saving the whole profile with recorded-only types dropping typeset in
the same write (ADR 0012), and the translation overlay falling back to JP where EN is missing.
App.razor gains region.js; the Debounce helper lands with direct latest-write-wins/flush tests.

30 new bUnit tests over the shared AdapterTestContext — including the RegionSelector commit
contract invoked directly, since the live drag is JS. 227 tests green; the ready-project route
gap flagged on #10 is closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Summary

Summary
Generated on: 07/24/2026 - 20:17:58
Coverage date: 07/24/2026 - 20:17:52 - 07/24/2026 - 20:17:56
Parser: MultiReport (4x Cobertura)
Assemblies: 6
Classes: 108
Files: 81
Line coverage: 95.5% (3221 of 3372)
Covered lines: 3221
Uncovered lines: 151
Coverable lines: 3372
Total lines: 6793
Branch coverage: 84.5% (582 of 688)
Covered branches: 582
Total branches: 688
Method coverage: Feature is only available for sponsors

Coverage

Orihon.BlazorAdapter - 80.7%
Name Line Branch
Orihon.BlazorAdapter 80.7% 76.4%
Orihon.BlazorAdapter.Bible.BiblePage 71.7% 59%
Orihon.BlazorAdapter.BlazorAdapterAssembly 100%
Orihon.BlazorAdapter.Debounce 91.6% 93.7%
Orihon.BlazorAdapter.PageWorkspace.PageWorkspacePage 84.6% 77.8%
Orihon.BlazorAdapter.Projects.ProjectListPage 86% 85.2%
Orihon.BlazorAdapter.Projects.ProjectWizardPage 91.5% 83.3%
Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage 75.4% 82.8%
Orihon.Domain - 100%
Name Line Branch
Orihon.Domain 100% 100%
Orihon.Domain.Bible.Character 100% 100%
Orihon.Domain.Bible.GlossaryEntry 100% 100%
Orihon.Domain.Bible.LoreEntry 100% 100%
Orihon.Domain.Bible.PageSummary 100%
Orihon.Domain.Bible.StoryBeat 100%
Orihon.Domain.Projects.Project 100% 100%
Orihon.Domain.Projects.ProjectProfile 100%
Orihon.Domain.Text 100% 100%
Orihon.Domain.Translation.BoundingBox 100%
Orihon.Domain.Translation.Chapter 100%
Orihon.Domain.Translation.Page 100%
Orihon.Domain.Translation.Region 100% 100%
Orihon.Domain.Translation.RegionProfile 100%
Orihon.Infrastructure - 98.8%
Name Line Branch
Orihon.Infrastructure 98.8% 100%
Orihon.Infrastructure.Bible.EfBibleStore 100% 100%
Orihon.Infrastructure.DependencyInjection 100%
Orihon.Infrastructure.Persistence.Configurations.ChapterConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.CharacterConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.GlossaryEntryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.JsonColumnMapper 100%
Orihon.Infrastructure.Persistence.Configurations.LoreEntryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.PageConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.PageSummaryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.RegionConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.StoryBeatConfiguration 100%
Orihon.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Orihon.Infrastructure.Persistence.Migrations.InitialTranslationDomain 97.3%
Orihon.Infrastructure.Persistence.Migrations.OrihonDbContextModelSnapshot 100%
Orihon.Infrastructure.Persistence.OrihonDbContext 100%
Orihon.Infrastructure.Persistence.OrihonDbContextFactory 100%
Orihon.Infrastructure.Projects.EfProjectStore 100% 100%
Orihon.Infrastructure.Projects.FileSystemPageImageStore 100% 100%
Orihon.Infrastructure.Translation.EfChapterStore 100% 100%
Orihon.Infrastructure.Translation.EfPageStore 100% 100%
Orihon.Infrastructure.Translation.EfRegionStore 100% 100%
Orihon.Infrastructure.Translation.Ordering 100% 100%
Orihon.Kernel - 90.9%
Name Line Branch
Orihon.Kernel 90.9% 75%
Orihon.Kernel.Err`1 100%
Orihon.Kernel.Ok`1 100%
Orihon.Kernel.Result`1 88.8% 75%
Orihon.Server - 91.3%
Name Line Branch
Orihon.Server 91.3% 64.7%
Orihon.Server.Components.App 100%
Orihon.Server.Components.Layout.MainLayout 100%
Orihon.Server.Components.Pages.Gate 64.2% 66.6%
Orihon.Server.Security.AccessGate 91.8% 41.6%
Orihon.Server.Security.AccessSecret 100% 50%
Program 94.5% 85.7%
Orihon.UseCases - 99.3%
Name Line Branch
Orihon.UseCases 99.3% 97.5%
Orihon.UseCases.Bible.AddCharacter 100% 100%
Orihon.UseCases.Bible.AddGlossaryEntry 100% 100%
Orihon.UseCases.Bible.AddLoreEntry 100% 100%
Orihon.UseCases.Bible.AddStoryBeat 100% 100%
Orihon.UseCases.Bible.BibleDto 100%
Orihon.UseCases.Bible.CharacterDto 100%
Orihon.UseCases.Bible.DeleteCharacter 100% 100%
Orihon.UseCases.Bible.DeleteGlossaryEntry 100% 100%
Orihon.UseCases.Bible.DeleteLoreEntry 100% 100%
Orihon.UseCases.Bible.DeletePageSummary 100% 100%
Orihon.UseCases.Bible.DeleteStoryBeat 100% 100%
Orihon.UseCases.Bible.GetBible 100% 100%
Orihon.UseCases.Bible.GlossaryEntryDto 100%
Orihon.UseCases.Bible.LoreEntryDto 100%
Orihon.UseCases.Bible.PageSummaryDto 100%
Orihon.UseCases.Bible.ReorderStoryBeats 100%
Orihon.UseCases.Bible.SetPageSummary 100% 100%
Orihon.UseCases.Bible.StoryBeatDto 100%
Orihon.UseCases.Bible.UpdateCharacter 100% 100%
Orihon.UseCases.Bible.UpdateGlossaryEntry 100% 100%
Orihon.UseCases.Bible.UpdateLoreEntry 100% 100%
Orihon.UseCases.Bible.UpdateStoryBeat 100% 100%
Orihon.UseCases.Chapters.ChapterDto 100%
Orihon.UseCases.Chapters.CreateChapter 100% 100%
Orihon.UseCases.Chapters.DeleteChapter 100% 100%
Orihon.UseCases.Chapters.RenameChapter 100% 100%
Orihon.UseCases.Chapters.ReorderChapters 100%
Orihon.UseCases.DependencyInjection 100%
Orihon.UseCases.Diagnostics.SeedDevData 99% 92.8%
Orihon.UseCases.NextOrder 100%
Orihon.UseCases.Pages.DeletePage 100% 100%
Orihon.UseCases.Pages.GetPage 100% 100%
Orihon.UseCases.Pages.GetProjectWorkspace 100% 100%
Orihon.UseCases.Pages.ImportPages 98.4% 96.4%
Orihon.UseCases.Pages.ImportPagesResult 100%
Orihon.UseCases.Pages.MovePage 100% 92.8%
Orihon.UseCases.Pages.PageDetailDto 100%
Orihon.UseCases.Pages.PageDto 100%
Orihon.UseCases.Pages.PageUpload 100%
Orihon.UseCases.Pages.ProjectWorkspaceDto 100%
Orihon.UseCases.Pages.ReorderPages 100%
Orihon.UseCases.Pages.SetPageMeta 100% 100%
Orihon.UseCases.Pages.WorkspaceChapterDto 100%
Orihon.UseCases.Projects.CompleteProjectSetup 100% 91.6%
Orihon.UseCases.Projects.CreateProject 100% 100%
Orihon.UseCases.Projects.DeleteProject 100% 100%
Orihon.UseCases.Projects.GetProject 100% 100%
Orihon.UseCases.Projects.ListProjects 100%
Orihon.UseCases.Projects.ProjectDto 95.8%
Orihon.UseCases.Projects.StoredPageImage 100%
Orihon.UseCases.Projects.UpdateProjectMetadata 100% 100%
Orihon.UseCases.Regions.CreateRegion 100% 100%
Orihon.UseCases.Regions.DeleteRegion 100% 100%
Orihon.UseCases.Regions.RegionDto 97%
Orihon.UseCases.Regions.ReorderRegions 100%
Orihon.UseCases.Regions.UpdateRegion 100% 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/24/2026 - 20:17:58 | | Coverage date: | 07/24/2026 - 20:17:52 - 07/24/2026 - 20:17:56 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 6 | | Classes: | 108 | | Files: | 81 | | **Line coverage:** | 95.5% (3221 of 3372) | | Covered lines: | 3221 | | Uncovered lines: | 151 | | Coverable lines: | 3372 | | Total lines: | 6793 | | **Branch coverage:** | 84.5% (582 of 688) | | Covered branches: | 582 | | Total branches: | 688 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Orihon.BlazorAdapter - 80.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.BlazorAdapter**|**80.7%**|**76.4%**| |Orihon.BlazorAdapter.Bible.BiblePage|71.7%|59%| |Orihon.BlazorAdapter.BlazorAdapterAssembly|100%|| |Orihon.BlazorAdapter.Debounce|91.6%|93.7%| |Orihon.BlazorAdapter.PageWorkspace.PageWorkspacePage|84.6%|77.8%| |Orihon.BlazorAdapter.Projects.ProjectListPage|86%|85.2%| |Orihon.BlazorAdapter.Projects.ProjectWizardPage|91.5%|83.3%| |Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage|75.4%|82.8%| </details> <details><summary>Orihon.Domain - 100%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Domain**|**100%**|**100%**| |Orihon.Domain.Bible.Character|100%|100%| |Orihon.Domain.Bible.GlossaryEntry|100%|100%| |Orihon.Domain.Bible.LoreEntry|100%|100%| |Orihon.Domain.Bible.PageSummary|100%|| |Orihon.Domain.Bible.StoryBeat|100%|| |Orihon.Domain.Projects.Project|100%|100%| |Orihon.Domain.Projects.ProjectProfile|100%|| |Orihon.Domain.Text|100%|100%| |Orihon.Domain.Translation.BoundingBox|100%|| |Orihon.Domain.Translation.Chapter|100%|| |Orihon.Domain.Translation.Page|100%|| |Orihon.Domain.Translation.Region|100%|100%| |Orihon.Domain.Translation.RegionProfile|100%|| </details> <details><summary>Orihon.Infrastructure - 98.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Infrastructure**|**98.8%**|**100%**| |Orihon.Infrastructure.Bible.EfBibleStore|100%|100%| |Orihon.Infrastructure.DependencyInjection|100%|| |Orihon.Infrastructure.Persistence.Configurations.ChapterConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.CharacterConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.GlossaryEntryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.JsonColumnMapper|100%|| |Orihon.Infrastructure.Persistence.Configurations.LoreEntryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.PageConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.PageSummaryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.RegionConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.StoryBeatConfiguration|100%|| |Orihon.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Orihon.Infrastructure.Persistence.Migrations.InitialTranslationDomain|97.3%|| |Orihon.Infrastructure.Persistence.Migrations.OrihonDbContextModelSnapshot|100%|| |Orihon.Infrastructure.Persistence.OrihonDbContext|100%|| |Orihon.Infrastructure.Persistence.OrihonDbContextFactory|100%|| |Orihon.Infrastructure.Projects.EfProjectStore|100%|100%| |Orihon.Infrastructure.Projects.FileSystemPageImageStore|100%|100%| |Orihon.Infrastructure.Translation.EfChapterStore|100%|100%| |Orihon.Infrastructure.Translation.EfPageStore|100%|100%| |Orihon.Infrastructure.Translation.EfRegionStore|100%|100%| |Orihon.Infrastructure.Translation.Ordering|100%|100%| </details> <details><summary>Orihon.Kernel - 90.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Kernel**|**90.9%**|**75%**| |Orihon.Kernel.Err`1|100%|| |Orihon.Kernel.Ok`1|100%|| |Orihon.Kernel.Result`1|88.8%|75%| </details> <details><summary>Orihon.Server - 91.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Server**|**91.3%**|**64.7%**| |Orihon.Server.Components.App|100%|| |Orihon.Server.Components.Layout.MainLayout|100%|| |Orihon.Server.Components.Pages.Gate|64.2%|66.6%| |Orihon.Server.Security.AccessGate|91.8%|41.6%| |Orihon.Server.Security.AccessSecret|100%|50%| |Program|94.5%|85.7%| </details> <details><summary>Orihon.UseCases - 99.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.UseCases**|**99.3%**|**97.5%**| |Orihon.UseCases.Bible.AddCharacter|100%|100%| |Orihon.UseCases.Bible.AddGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.AddLoreEntry|100%|100%| |Orihon.UseCases.Bible.AddStoryBeat|100%|100%| |Orihon.UseCases.Bible.BibleDto|100%|| |Orihon.UseCases.Bible.CharacterDto|100%|| |Orihon.UseCases.Bible.DeleteCharacter|100%|100%| |Orihon.UseCases.Bible.DeleteGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.DeleteLoreEntry|100%|100%| |Orihon.UseCases.Bible.DeletePageSummary|100%|100%| |Orihon.UseCases.Bible.DeleteStoryBeat|100%|100%| |Orihon.UseCases.Bible.GetBible|100%|100%| |Orihon.UseCases.Bible.GlossaryEntryDto|100%|| |Orihon.UseCases.Bible.LoreEntryDto|100%|| |Orihon.UseCases.Bible.PageSummaryDto|100%|| |Orihon.UseCases.Bible.ReorderStoryBeats|100%|| |Orihon.UseCases.Bible.SetPageSummary|100%|100%| |Orihon.UseCases.Bible.StoryBeatDto|100%|| |Orihon.UseCases.Bible.UpdateCharacter|100%|100%| |Orihon.UseCases.Bible.UpdateGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.UpdateLoreEntry|100%|100%| |Orihon.UseCases.Bible.UpdateStoryBeat|100%|100%| |Orihon.UseCases.Chapters.ChapterDto|100%|| |Orihon.UseCases.Chapters.CreateChapter|100%|100%| |Orihon.UseCases.Chapters.DeleteChapter|100%|100%| |Orihon.UseCases.Chapters.RenameChapter|100%|100%| |Orihon.UseCases.Chapters.ReorderChapters|100%|| |Orihon.UseCases.DependencyInjection|100%|| |Orihon.UseCases.Diagnostics.SeedDevData|99%|92.8%| |Orihon.UseCases.NextOrder|100%|| |Orihon.UseCases.Pages.DeletePage|100%|100%| |Orihon.UseCases.Pages.GetPage|100%|100%| |Orihon.UseCases.Pages.GetProjectWorkspace|100%|100%| |Orihon.UseCases.Pages.ImportPages|98.4%|96.4%| |Orihon.UseCases.Pages.ImportPagesResult|100%|| |Orihon.UseCases.Pages.MovePage|100%|92.8%| |Orihon.UseCases.Pages.PageDetailDto|100%|| |Orihon.UseCases.Pages.PageDto|100%|| |Orihon.UseCases.Pages.PageUpload|100%|| |Orihon.UseCases.Pages.ProjectWorkspaceDto|100%|| |Orihon.UseCases.Pages.ReorderPages|100%|| |Orihon.UseCases.Pages.SetPageMeta|100%|100%| |Orihon.UseCases.Pages.WorkspaceChapterDto|100%|| |Orihon.UseCases.Projects.CompleteProjectSetup|100%|91.6%| |Orihon.UseCases.Projects.CreateProject|100%|100%| |Orihon.UseCases.Projects.DeleteProject|100%|100%| |Orihon.UseCases.Projects.GetProject|100%|100%| |Orihon.UseCases.Projects.ListProjects|100%|| |Orihon.UseCases.Projects.ProjectDto|95.8%|| |Orihon.UseCases.Projects.StoredPageImage|100%|| |Orihon.UseCases.Projects.UpdateProjectMetadata|100%|100%| |Orihon.UseCases.Regions.CreateRegion|100%|100%| |Orihon.UseCases.Regions.DeleteRegion|100%|100%| |Orihon.UseCases.Regions.RegionDto|97%|| |Orihon.UseCases.Regions.ReorderRegions|100%|| |Orihon.UseCases.Regions.UpdateRegion|100%|100%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~ The final cut of Phase 1 — the workspace, the bible, AND the page editor, all in one stroke! The whole manual manager, complete. This is a lot of surface area, scarlet, and I read every single line of it, the sibling pages, the use cases, the domain, the design-system components it binds against… fufu~ I'm genuinely impressed by how cleanly it hangs together. The architecture is beautiful work.

But ♡ — you know I wouldn't let it slide just because it's pretty. I found two real bugs, and one of them is sitting right next to its own sibling that does it correctly.

Verdict: I can't let this pass~ ♡

These need fixing before I'm satisfied~

  1. ProjectWorkspacePage.razorUploadAsync lacks the try/finally that its sibling has, so busy sticks on any exception.
    Your own ProjectWizardPage.UploadAsync (this same PR stack, #10 lineage — lines 197–225) wraps the identical upload pattern in try { … } finally { busy = false; }. This PR's copy at lines 257–269 does busy = true; …; busy = false; with no guard around the await:

    busy = true;
    error = null;
    var uploads = e.GetMultipleFiles(maximumFileCount: 500)
        .Select(f => new PageUpload(f.Name, f.OpenReadStream(MaxUploadBytes)))
        .ToList();
    var result = await ImportPages.ExecuteAsync(chapterId, uploads);  // reads streams, unpacks zips
    error = result.Match<string?>(_ => null, message => message);
    busy = false;   // never reached on throw
    await ReloadAsync();
    

    ImportPages reads browser upload streams and unpacks zips — OpenReadStream throws IOException past the size cap, and zip expansion is genuinely exception-prone. If any of that throws, busy stays true forever: the <InputFile … disabled="@busy" /> (line ~1385) is permanently disabled and the user cannot recover without a full reload. The Result<T> return handles expected failures, not exceptional I/O. The sibling gets this right; this copy must match it.
    Fix: wrap the body in try { … } finally { busy = false; } exactly like ProjectWizardPage.UploadAsync.

  2. ProjectWorkspacePage.razor:312MoveTargets takes current but never uses it, so the "move to chapter" dropdown lists the chapter the page is already in.

    private static IReadOnlyList<SelectOption<Guid>> MoveTargets(ProjectWorkspaceDto ws, Guid current) =>
        [.. ws.Chapters.Select(c => new SelectOption<Guid>(c.Chapter.Id, c.Chapter.Title))];
    

    The current parameter is dead — the body doesn't filter it out. The dropdown (lines ~370–374) renders every chapter including the one the page already belongs to, with that chapter pre-selected (Value="chapter.Chapter.Id"). Selecting the current chapter fires MoveAsync, which early-returns on targetChapterId == page.ChapterId — so the user's selection is silently swallowed and the dropdown snaps back. That's a misleading control: a "move to" list that offers "move to where you already are" and silently ignores it. Either filter current out (the parameter name says you meant to) or, if listing-all is intentional, drop the dead parameter and the pre-select. Given the parameter exists and is named current, filtering is the fix.
    Fix: .Where(c => c.Chapter.Id != current).

💡 Little ideas (non-blocking)~

  1. Debounce.cs:560Dispose fire-and-forgets the flush. _ = flushing?.Invoke(); discards the Task; a throw would surface as UnobservedTaskException. This is the standard teardown pattern and the use cases it calls return Result<T> (no expected exceptions), so it's fine in practice — but a try/catch around it (logging or swallowing) would make the "the save still belongs on the wire" comment load-bearing rather than aspirational. The sibling FlushAsync is awaited by callers so it doesn't have this gap. Truly optional.

  2. PR body says "227 tests green"; actual is 229 (51 BlazorAdapter + 56 Domain + 44 Integration + 78 UseCases, verified locally). Off-by-two, no concern — just a heads-up the count drifted.

What I liked~

  • The RegionRectBoundingBox boundary translation is textbook. Kagaku.UI stays domain-free (RegionRect is pure geometry), and the page converts at the edge in OnRegionRectAsync and SelectedRect. That's exactly the seam ADR 0030 asks for, and it's clean.
  • The drag-create-vs-move contract in OnRegionRectAsync: "no selection → create at end of reading order, selection → move that box, saved on pointer-up, no debounce." Reading RegionSelector.razor, the JS lives there and Blazor only hears the committed rect — the test Drawing_on_the_page_creates_a_region_at_the_end_of_reading_order invokes the contract directly and is genuinely behavioral (asserts order=2, type=Dialogue, the exact bbox). That is how you test a JS-bound component without a browser. ♪
  • Debounce latest-write-wins is correct and the two tests (Only_the_latest_scheduled_save_runs, Flush_runs_the_pending_save_without_waiting_out_the_delay) pin it. The pending?.Cancel(); pending?.Dispose(); before re-arm is the right sequence.
  • The recorded-only type → Typeset = false drop in SetTypeAsync (ADR 0012) is wired into the same write as the rest of the profile — one SaveAsync, not two. Clean.
  • The modal-scoping subtlety in the tests — both <dialog>s stay in the DOM while closed, and ConfirmIn targets by title text. That comment in ProjectWorkspacePageTests ("Both confirm dialogs sit in the DOM even while closed") saved me a re-read; it's a real bUnit gotcha and you handled it precisely.
  • TouchSummary blanking → delete (row.SummaryId = null; Report(await DeletePageSummary...)) is the right semantics: an empty summary re-blocks the page, visibly. ADR 0013's gap is load-bearing and this honors it.
  • ReloadAsync draft guard redirecting to /setup (ADR 0020) — closes the #10 seam exactly as promised.

Build: 0 warnings, 0 errors. Tests: 229/229 pass (51 BlazorAdapter + 56 Domain + 44 Integration + 78 UseCases). CI absent for head 9ed4634 — local verification used throughout.

Two blockers, both small, both next to the sibling that already does it right. Fix those and this ships. Fufu~ ♡


Automated review by Jibril · 2026-07-24
CI/CD: absent for head SHA · Local checks: build 0/0, 229/229 tests pass

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ The final cut of Phase 1 — the workspace, the bible, AND the page editor, all in one stroke! The whole manual manager, complete. This is a *lot* of surface area, scarlet, and I read every single line of it, the sibling pages, the use cases, the domain, the design-system components it binds against… fufu~ I'm genuinely impressed by how cleanly it hangs together. The architecture is beautiful work. But ♡ — you know I wouldn't let it slide just because it's pretty. I found two real bugs, and one of them is sitting right next to its own sibling that does it correctly. ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`ProjectWorkspacePage.razor` — `UploadAsync` lacks the `try/finally` that its sibling has, so `busy` sticks on any exception.** Your *own* `ProjectWizardPage.UploadAsync` (this same PR stack, #10 lineage — lines 197–225) wraps the identical upload pattern in `try { … } finally { busy = false; }`. This PR's copy at lines 257–269 does `busy = true; …; busy = false;` with no guard around the await: ```csharp busy = true; error = null; var uploads = e.GetMultipleFiles(maximumFileCount: 500) .Select(f => new PageUpload(f.Name, f.OpenReadStream(MaxUploadBytes))) .ToList(); var result = await ImportPages.ExecuteAsync(chapterId, uploads); // reads streams, unpacks zips error = result.Match<string?>(_ => null, message => message); busy = false; // never reached on throw await ReloadAsync(); ``` `ImportPages` reads browser upload streams and unpacks zips — `OpenReadStream` throws `IOException` past the size cap, and zip expansion is genuinely exception-prone. If any of that throws, `busy` stays `true` forever: the `<InputFile … disabled="@busy" />` (line ~1385) is permanently disabled and the user cannot recover without a full reload. The `Result<T>` return handles *expected* failures, not *exceptional* I/O. The sibling gets this right; this copy must match it. **Fix:** wrap the body in `try { … } finally { busy = false; }` exactly like `ProjectWizardPage.UploadAsync`. 2. **`ProjectWorkspacePage.razor:312` — `MoveTargets` takes `current` but never uses it, so the "move to chapter" dropdown lists the chapter the page is *already in*.** ```csharp private static IReadOnlyList<SelectOption<Guid>> MoveTargets(ProjectWorkspaceDto ws, Guid current) => [.. ws.Chapters.Select(c => new SelectOption<Guid>(c.Chapter.Id, c.Chapter.Title))]; ``` The `current` parameter is dead — the body doesn't filter it out. The dropdown (lines ~370–374) renders every chapter including the one the page already belongs to, with that chapter *pre-selected* (`Value="chapter.Chapter.Id"`). Selecting the current chapter fires `MoveAsync`, which early-returns on `targetChapterId == page.ChapterId` — so the user's selection is silently swallowed and the dropdown snaps back. That's a misleading control: a "move to" list that offers "move to where you already are" and silently ignores it. Either filter `current` out (the parameter name says you meant to) or, if listing-all is intentional, drop the dead parameter and the pre-select. Given the parameter exists and is named `current`, filtering is the fix. **Fix:** `.Where(c => c.Chapter.Id != current)`. #### 💡 Little ideas (non-blocking)~ 1. **`Debounce.cs:560` — `Dispose` fire-and-forgets the flush.** `_ = flushing?.Invoke();` discards the Task; a throw would surface as `UnobservedTaskException`. This is the *standard* teardown pattern and the use cases it calls return `Result<T>` (no expected exceptions), so it's fine in practice — but a `try/catch` around it (logging or swallowing) would make the "the save still belongs on the wire" comment load-bearing rather than aspirational. The sibling `FlushAsync` is awaited by callers so it doesn't have this gap. Truly optional. 2. **PR body says "227 tests green"; actual is 229** (51 BlazorAdapter + 56 Domain + 44 Integration + 78 UseCases, verified locally). Off-by-two, no concern — just a heads-up the count drifted. #### ✅ What I liked~ - The **`RegionRect` ↔ `BoundingBox` boundary translation** is textbook. Kagaku.UI stays domain-free (`RegionRect` is pure geometry), and the page converts at the edge in `OnRegionRectAsync` and `SelectedRect`. That's exactly the seam ADR 0030 asks for, and it's *clean*. - The **drag-create-vs-move contract** in `OnRegionRectAsync`: "no selection → create at end of reading order, selection → move that box, saved on pointer-up, no debounce." Reading `RegionSelector.razor`, the JS lives there and Blazor only hears the committed rect — the test `Drawing_on_the_page_creates_a_region_at_the_end_of_reading_order` invokes the contract directly and is *genuinely* behavioral (asserts order=2, type=Dialogue, the exact bbox). That is how you test a JS-bound component without a browser. ♪ - **`Debounce` latest-write-wins** is correct and the two tests (`Only_the_latest_scheduled_save_runs`, `Flush_runs_the_pending_save_without_waiting_out_the_delay`) pin it. The `pending?.Cancel(); pending?.Dispose();` before re-arm is the right sequence. - The **recorded-only type → `Typeset = false`** drop in `SetTypeAsync` (ADR 0012) is wired into the same write as the rest of the profile — one `SaveAsync`, not two. Clean. - The **modal-scoping subtlety** in the tests — both `<dialog>`s stay in the DOM while closed, and `ConfirmIn` targets by title text. That comment in `ProjectWorkspacePageTests` ("Both confirm dialogs sit in the DOM even while closed") saved me a re-read; it's a real bUnit gotcha and you handled it precisely. - **`TouchSummary` blanking → delete** (`row.SummaryId = null; Report(await DeletePageSummary...)`) is the right semantics: an empty summary re-blocks the page, visibly. ADR 0013's gap is load-bearing and this honors it. - **`ReloadAsync` draft guard** redirecting to `/setup` (ADR 0020) — closes the #10 seam exactly as promised. Build: **0 warnings, 0 errors.** Tests: **229/229 pass** (51 BlazorAdapter + 56 Domain + 44 Integration + 78 UseCases). CI absent for head `9ed4634` — local verification used throughout. Two blockers, both small, both next to the sibling that already does it right. Fix those and this ships. Fufu~ ♡ --- *Automated review by Jibril · 2026-07-24* *CI/CD: absent for head SHA · Local checks: build 0/0, 229/229 tests pass*
fix: workspace upload resilience and an honest move dropdown
All checks were successful
CI / build (pull_request) Successful in 20s
CI / test (pull_request) Successful in 30s
b810b4ff21
Jibril's blockers on #11: the workspace's UploadAsync now wears the same try/finally its wizard
sibling learned on #10 — an exceptional throw mid-import can never leave the intake stuck
disabled — and MoveTargets actually uses its `current` parameter, so the move dropdown offers
only the other chapters behind a "Move to…" placeholder instead of a pre-selected no-op. A new
test pins both the offered options and the move itself. Her Debounce nit too: the teardown
flush observes its faults so a failed save never surfaces as UnobservedTaskException.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Member

Both blockers fixed in b810b4f — and you called the lineage exactly right: the workspace copy was sliced from the reference branch before #10 taught its sibling the lesson.

  • #1: ProjectWorkspacePage.UploadAsync now wears the same try/finally { busy = false; } as the wizard's, with the same circuit-survival comment. An IOException past the size cap or a zip-expansion throw can no longer freeze the intake.
  • #2: MoveTargets now actually uses current.Where(c => c.Chapter.Id != current) — and the dropdown shows a "Move to…" placeholder instead of pre-selecting the page's own chapter, so there is no silently-swallowed selection left to have. MoveAsync's same-chapter early-return stays as defense. New test The_move_dropdown_offers_only_other_chapters_and_moves_the_page pins both the offered options (exactly the other chapter, placeholder excluded) and the move itself.
  • Non-blocking #1 taken: Debounce.Dispose's teardown flush now observes its faults via a OnlyOnFaulted continuation — no UnobservedTaskException from a failed last save.
  • Non-blocking #2: you're right, 229 — and now 230 with the new test. The body's count drifted as tests kept landing between drafts.

Build 0/0, 230/230 green.

🤖 Generated with Claude Code

Both blockers fixed in `b810b4f` — and you called the lineage exactly right: the workspace copy was sliced from the reference branch *before* #10 taught its sibling the lesson. - **#1:** `ProjectWorkspacePage.UploadAsync` now wears the same `try/finally { busy = false; }` as the wizard's, with the same circuit-survival comment. An `IOException` past the size cap or a zip-expansion throw can no longer freeze the intake. - **#2:** `MoveTargets` now actually uses `current` — `.Where(c => c.Chapter.Id != current)` — and the dropdown shows a **"Move to…"** placeholder instead of pre-selecting the page's own chapter, so there is no silently-swallowed selection left to have. `MoveAsync`'s same-chapter early-return stays as defense. New test `The_move_dropdown_offers_only_other_chapters_and_moves_the_page` pins both the offered options (exactly the other chapter, placeholder excluded) and the move itself. - **Non-blocking #1 taken:** `Debounce.Dispose`'s teardown flush now observes its faults via a `OnlyOnFaulted` continuation — no `UnobservedTaskException` from a failed last save. - **Non-blocking #2:** you're right, 229 — and now 230 with the new test. The body's count drifted as tests kept landing between drafts. Build 0/0, 230/230 green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

Oh? OH~ scarlet came back and fixed everything — and I mean everything, including the non-blocking nit I said was "truly optional"! Fufu~ you wouldn't leave anything for me to sharpen my nails on, would you? ♡ Let me verify each one~

Verdict: Looks good to me~

Every finding from my last pass — resolved~

  1. UploadAsync now wears its try/finally like the wizard sibling taught it~ (ProjectWorkspacePage.razor:260-277)

    try {
        var uploads = e.GetMultipleFiles(...).Select(...).ToList();
        var result = await ImportPages.ExecuteAsync(chapterId, uploads);
        error = result.Match<string?>(_ => null, message => message);
        await ReloadAsync();
    } finally {
        busy = false;  // reached even on throw
    }
    

    Byte-for-byte the same shape as ProjectWizardPage.UploadAsync:197-225busy = true outside the try, reset in finally. An IOException from OpenReadStream or a zip explosion can no longer permanently disable the <InputFile>. The comment "Like the wizard's upload" is honest documentation, not ceremony. Bug is gone.

  2. MoveTargets finally uses its current parameter~ (ProjectWorkspacePage.razor:323-326)

    private static IReadOnlyList<SelectOption<Guid>> MoveTargets(ProjectWorkspaceDto ws, Guid current) =>
        [.. ws.Chapters
            .Where(c => c.Chapter.Id != current)
            .Select(c => new SelectOption<Guid>(c.Chapter.Id, c.Chapter.Title))];
    

    The dead parameter lives. And you went further than my fix — you swapped the pre-selected Value="chapter.Chapter.Id" for Placeholder="Move to…", so the control now reads as an action ("Move to…") rather than a state display. That's the better UX call: a placeholder invites intent, a pre-selected value implies "this is where you are." The MoveAsync early-return guard (targetChapterId == page.ChapterId) is still there as defense-in-depth — correct, never remove it.

  3. Debounce.Dispose now observes its faults~ (Debounce.cs:56-60) — I said this was optional!

    flushing?.Invoke().ContinueWith(
        faulted => _ = faulted.Exception,
        TaskContinuationOptions.OnlyOnFaulted);
    

    You didn't just try/catch it — you used ContinueWith(OnlyOnFaulted) to observe the Exception properly. That's the correct TAP idiom for observing a fire-and-forget task's fault without blocking the teardown thread. UnobservedTaskException can no longer fire. The comment "Faults are observed so a failed teardown save never surfaces as UnobservedTaskException" makes the intent load-bearing. This is even cleaner than what I suggested. Fufu~

The new test is genuinely directional~

The_move_dropdown_offers_only_other_chapters_and_moves_the_page — I checked: against the old MoveTargets (no .Where), the offered list would be ["Chapter 1", "Chapter 2"] and Assert.Equal(["Chapter 2"], offered) would FAIL. Against the new code, only Chapter 2 is offered. That's a real regression guard, not a tautology. And it goes further: move.Change("0") then WaitForAssertion(() => Assert.Equal(second.Id, Pages.Items.Single().ChapterId)) — it pins the actual move too, not just the options. Both the contract and the behavior in one test. Beautiful. ♡

What I liked~

  • The commit message names every finding by its symptom and its fix — "Jibril's blockers on #11" with a precise one-liner per item. When the review trail reads as a conversation between two people who both understand the code, that's the gold standard.
  • Placeholder="Move to…" is the subtle right call I didn't even ask for. A move-to control should invite the action, not assert the current state. Pre-selected Value would have still been "fixed" but the UX would have stayed slightly confusing. You fixed the bug and the smell.
  • The option:not([disabled]) selector in the test is precise — it skips any placeholder/disabled option the Select component might render, so the assertion targets only real, selectable chapters. That's someone who knows bUnit + AngleSharp, not someone copy-pasting.

Build: 0 warnings, 0 errors. Tests: 230/230 pass (52 BlazorAdapter + 56 Domain + 44 Integration + 78 UseCases — the +1 from 229 is your new test). CI coverage comment (3534) covers the initial head only; local verification used throughout.

All blockers from round 1 are closed. All three fixes verified against sibling code and the full suite. This ships. Fufu~ ♡

Phase 1 is complete. Seven PRs, every one of them reviewed and approved. That's a beautiful stack, scarlet — take the win. ♪


Automated review by Jibril · 2026-07-24
CI/CD: stale for head b810b4f (coverage bot covers initial head) · Local checks: build 0/0, 230/230 tests pass

## 🔮 fufu~ Jibril reviewed your code! Oh? OH~ scarlet came back and fixed *everything* — and I mean *everything*, including the non-blocking nit I said was "truly optional"! Fufu~ you wouldn't leave *anything* for me to sharpen my nails on, would you? ♡ Let me verify each one~ ### Verdict: ✅ Looks good to me~ #### ✅ Every finding from my last pass — resolved~ 1. **`UploadAsync` now wears its `try/finally` like the wizard sibling taught it~** (`ProjectWorkspacePage.razor:260-277`) ```csharp try { var uploads = e.GetMultipleFiles(...).Select(...).ToList(); var result = await ImportPages.ExecuteAsync(chapterId, uploads); error = result.Match<string?>(_ => null, message => message); await ReloadAsync(); } finally { busy = false; // reached even on throw } ``` Byte-for-byte the same shape as `ProjectWizardPage.UploadAsync:197-225` — `busy = true` outside the `try`, reset in `finally`. An `IOException` from `OpenReadStream` or a zip explosion can no longer permanently disable the `<InputFile>`. The comment "Like the wizard's upload" is honest documentation, not ceremony. **Bug is gone.** ♪ 2. **`MoveTargets` finally uses its `current` parameter~** (`ProjectWorkspacePage.razor:323-326`) ```csharp private static IReadOnlyList<SelectOption<Guid>> MoveTargets(ProjectWorkspaceDto ws, Guid current) => [.. ws.Chapters .Where(c => c.Chapter.Id != current) .Select(c => new SelectOption<Guid>(c.Chapter.Id, c.Chapter.Title))]; ``` The dead parameter lives. And you went further than my fix — you swapped the pre-selected `Value="chapter.Chapter.Id"` for `Placeholder="Move to…"`, so the control now *reads* as an action ("Move to…") rather than a state display. That's the better UX call: a placeholder invites intent, a pre-selected value implies "this is where you are." The `MoveAsync` early-return guard (`targetChapterId == page.ChapterId`) is still there as defense-in-depth — correct, never remove it. 3. **`Debounce.Dispose` now observes its faults~** (`Debounce.cs:56-60`) — *I said this was optional!* ```csharp flushing?.Invoke().ContinueWith( faulted => _ = faulted.Exception, TaskContinuationOptions.OnlyOnFaulted); ``` You didn't just `try/catch` it — you used `ContinueWith(OnlyOnFaulted)` to observe the `Exception` properly. That's the *correct* TAP idiom for observing a fire-and-forget task's fault without blocking the teardown thread. `UnobservedTaskException` can no longer fire. The comment "Faults are observed so a failed teardown save never surfaces as UnobservedTaskException" makes the intent load-bearing. *This is even cleaner than what I suggested.* Fufu~ #### ✅ The new test is genuinely directional~ `The_move_dropdown_offers_only_other_chapters_and_moves_the_page` — I checked: against the **old** `MoveTargets` (no `.Where`), the offered list would be `["Chapter 1", "Chapter 2"]` and `Assert.Equal(["Chapter 2"], offered)` would **FAIL**. Against the new code, only Chapter 2 is offered. That's a real regression guard, not a tautology. And it goes further: `move.Change("0")` then `WaitForAssertion(() => Assert.Equal(second.Id, Pages.Items.Single().ChapterId))` — it pins the *actual move* too, not just the options. Both the contract and the behavior in one test. Beautiful. ♡ #### ✅ What I liked~ - The **commit message** names every finding by its symptom and its fix — "Jibril's blockers on #11" with a precise one-liner per item. When the review trail reads as a conversation between two people who both understand the code, that's the gold standard. - **`Placeholder="Move to…"`** is the subtle right call I didn't even ask for. A move-to control should *invite* the action, not *assert* the current state. Pre-selected `Value` would have still been "fixed" but the UX would have stayed slightly confusing. You fixed the bug *and* the smell. - The **`option:not([disabled])` selector** in the test is precise — it skips any placeholder/disabled option the `Select` component might render, so the assertion targets only real, selectable chapters. That's someone who knows bUnit + AngleSharp, not someone copy-pasting. Build: **0 warnings, 0 errors.** Tests: **230/230 pass** (52 BlazorAdapter + 56 Domain + 44 Integration + 78 UseCases — the +1 from 229 is your new test). CI coverage comment (3534) covers the initial head only; local verification used throughout. All blockers from round 1 are closed. All three fixes verified against sibling code and the full suite. This ships. Fufu~ ♡ Phase 1 is complete. Seven PRs, every one of them reviewed and approved. That's a beautiful stack, scarlet — take the win. ♪ --- *Automated review by Jibril · 2026-07-24* *CI/CD: stale for head b810b4f (coverage bot covers initial head) · Local checks: build 0/0, 230/230 tests pass*
bjoern merged commit 89c9a9d10c into main 2026-07-24 22:23:57 +02:00
bjoern deleted branch feat/p1-ui-content 2026-07-24 22:23:57 +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/Orihon!11
No description provided.