feat: story sections and navigation — General · Documents · Chapters · History, one URL per chapter #14
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/story-sections"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
The second half of Björn's staging review, after #13 made documents generic:
Confirmed shape: a left link list beside the content — General · Documents · Chapters · History — each its own route under
stories/{id}/…; one URL per chapter, the chapters listed as the third level under Chapters; History its own section. And the width rule from the same read: no page narrower than the stories list.The routes
stories/{id}stories/{id}/documentsstories/{id}/documents/{documentId}stories/{id}/documents/categories/{categoryId}stories/{id}/chaptersstories/{id}/chapters/{chapterId}stories/{id}/historyThe single
/stories/{id}workspace with its chapterSelectand the 24 remTabspane is gone, and with it the 48 rem reading cap. Every href is base-relative (ADR 0007);NavItemcompares the base-relative path, so a root-absolute link would never light and would escape a sub-path. The?tab=deep links die without a redirect — nothing in the repo linked to them.Design (ADR 0027, new)
StoryShellis a wrapping component, not a@layout.App.razorrenders<Routes />statically and each page opts intoInteractiveServer; a layout would run in static SSR with no store, no route parameters, no hotkeys (ADR 0002 says "Interactive Server render mode globally" — amended with a clause: Server over Auto/WebAssembly, opted in per page).MainLayout'sCircuitBridgesisland keeps one circuit alive across pages, and links between interactive pages are enhanced navigations that swap the page root inside that circuit — so the Fluxor store and the loaded manuscript survive a click. The shell is a fresh instance on every navigation and checks the store before dispatchingLoadWorkspace: one load per story, never per section. Prerendered from a fresh request scope it shows the frame (nav, "Story", "Loading the story…") until the circuit activates the page root — the same brief skeleton Kagura's shell shows; accepted.The shell owns everything the sections share: breadcrumb, the one
h1(FocusOnNavigatetargets it; sections useh2, neverPageShellunder the shell), the window title, the save indicator, Undo/Redo, the one pair of Ctrl+Z/Ctrl+Y hotkeys, the structural alert, the not-found/loading/trashed states, and the section navigation with every chapter nested under Chapters (NavItem.Nested, Kagaku.UI #21, merged; submodule bumped to 691a176 in the first commit). It takes the content region whole and splits it into a fixed head row, the navigation and the section's pane (ADR 0010) — inside the same--shell-max-widthbox as the stories list, so the story pages and the list share one width (the browser pass measured both at 1440 px). The content pane is adiv; the layout already has themainlandmark.The indicator's priority is one rule,
SaveStateLadder.Combine(review round 1): a pending pause anywhere under the shell beats a refusal, a refusal beats a save in flight, a save in flight beats saved — on either slice. A table test pins every arm and a shell test shows a refusal from either slice giving way to a pending pause and returning once it is flushed.The chapter is the route.
WorkspaceStatekeeps no current chapter;ChapterPagederives it and shows "Chapter not found" with a link back when the manuscript no longer holds it (deleted or merged, here or in another session). Create, merge and delete navigate from the effects, as the story list's create already does: a new chapter or document opens its page from anywhere; a merge or delete leaves the page only when the URL names the chapter or record that went — from the list or from another page, nothing moves (TestNavigationManagergainedVisit,Resetand a liveUriso the effect tests can put the effect on a page first). The twoLeaveIfOnhelpers (chapters, documents) stay separate until a third "the record went, its URL goes" site appears. A caret token in the store may predate a chapter page's column (a split on another chapter); the column applies only tokens that arrive after its mount.SaveScope— the flush across sections. The shell cannot@refinto its child content, so it cascades aSaveScopeobject every editor registers with while mounted (the column once for all its rows, the chapter header, the story details, the document and category editors). The indicator readsIsDirty; Undo, Redo, a version, a revert and its redo awaitFlushAsyncfirst. Registration is silent, and a disposing editor leaves the scope before its teardown flush — the one thing that bit during the port: a save dispatched from a disposing editor that notified the shell re-entered the shell's render mid-disposal and deferred the rest of the teardown. A pending pause survives a navigation two ways: the paragraph rows and document editors flush on blur, so a sidebar click usually saves first; the chapter header and the story details have no blur flush and rely on the editor's dispose, where the debounce runs the save fire-and-forget — the reducer marks the slice Saving, the next page's shell shows it, Saved lands with the echo (one test per path; the browser pass typed a title, a summary and a paragraph within one pause, clicked away, and found all three saved).LocationChanginghandlers are not consulted for link navigation under a static router and only for programmaticNavigateTo; nothing here is built on them.Documents as a page. Two columns at full width: the list (categories, rows as links, filter, New document, New category, the category menu with Edit category as a link) and the open record, the record in the URL — a row is a deep link and a browser-back target; Close is a link; create-and-open navigates from the effect; a record the URL names that the view no longer holds says so and points back. The delete-category dialog lives on the page, whichever side raised it.
DocumentsPanelbecameDocumentsList;OpenDocument/OpenCategoryand the open ids left the slice;CategoryCreatedlost its now-unreadOpenEditorflag.Open: the run pane. ADR 0024's strip stays the cross-page surface; when the engine lands, the chapter page splits its pane or a Run section joins the nav.
One consequence named in the ADR: a refused save on a record deleted elsewhere keeps its message in the indicator until the next save request in the slice — the slice no longer knows when an editor closed; the page's "gone" state says the rest.
Docs
ADR 0027 (Proposed until you read this; flips to Accepted in the same PR — say the word and I push the flip), ADR 0010 amended (the example page is the shell), ADR 0002 one clause, ADR 0009 (
NavListwhere it listed aDrawerthat never existed; "story pages"), one-liners in ADR 0012/0020/0022/0024,docs/adr/README.md, REQUIREMENTS §2/§5/§11, PROJECT_PLAN, ARCHITECTURE,stories/story-workspace.mdrewritten as Story pages (General, Chapters, the chapter page; epigraph no longer says "one screen … not three pages"),stories/documents.md,story-list.md,human-input.md,run-monitor-and-inspector.md, README, AGENTS.md.Tests
WorkspacePageTests(31) split intoStoryShellTests(9),ChapterPageTests(24),ChaptersPageTests(4),StoryGeneralPageTests(4),HistoryPageTests(1);DocumentsPanelTests(16) →DocumentsPageTests(20) with the URL semantics (a row's href is asserted, then followed by hand — bUnit never re-routes a rendered page; an effect's navigation is asserted on the manager'sUri);SaveStateLadderTests(Theory × 15, round 1). Every page test sets the URL before rendering, or no sidebar entry lights.HistoryPanelTestscounts flushes through a cascaded scope;WorkspaceReducersTests12 → 11 (the pin is gone);ManuscriptEffectsTestsre-indexed and asserting navigation;DocumentsReducers/EffectsTestslikewise. Integration +5: the story route prerenders the nav and base-relative hrefs, every section sits behind the gate and prerenders the shell (Theory × 4), every shell link is base-relative.Coverage (coverlet, adapter report,
branch="True"): every changed file fully line-covered except the Chapters page's empty state (a story always keeps one chapter — unreachable by design), the column's JS-disconnected catch and null-module return (pre-existing), the list's move-bounds guard (the menu disables the edges, pre-existing). Partials are the?? "",?.and disabled-at-the-edge arms.Mutation probes, each restored and
touched, all killed: (a)MatchExactoff on General → two entries lit; (b) the shell's load guard dropped → the silent-edit test re-queries; (c) the delete effect navigating unconditionally → "stays on the list" fails in both suites; (d)SaveScope.FlushAsync→ no-op → the indicator/undo tests; (e) the caret first-render guard removed → the stale-caret test; (f) a secondh1on the chapter page → the one-h1 assert; (g, round 1) Jibril's two ladder swaps → 5 and 4 red, plus a control that has the shell bypass the ladder → the shell test.Browser pass (Playwright, Release build, fresh seed, light and dark)
Open → General: sidebar General lit, facts line, Archive/Trash; the shell's box equals the stories list's (1440/1440). Chapters: three rows with summary and paragraph counts; a chapter's page lights Chapters and its entry, crumb
Stories › Mira's Winter › Chapters › The Sister's House, the prose fills the pane. Typed a paragraph and clicked another chapter within the pause → Saved on the next page, text there on return; typed title, summary and a paragraph, left → all three landed. Ctrl+Z on the chapter page saves the pause first and takes it back. New chapter from the toolbar → its page; rename → sidebar and crumb follow; Merge into previous → lands on the target; Delete from its page → the list; Delete from the list → stays. Documents: list | "Pick a document"; open Mira →/documents/{guid}, list still beside; typed, switched to Snow within the pause → Saved, body kept; Close →/documents; New document → opens; New category →/documents/categories/{guid}; delete from its editor →/documents; reload on a document URL → the same document open. A second tab renamed the chapter the first tab showed → its crumb followed; deleted it → "Chapter not found". History as its own section: revert, redo. Unknown chapter / story / section → the three not-found states. Ctrl+Z fires on every section. Dark: the active entry reads in accent on its tint, the head border visible. No page or server errors.🤖 Generated with Claude Code
The story is four sections behind a left link list (ADR 0027): General, Documents, Chapters with every chapter listed under it, History — each its own route under stories/{id}/…, one URL per chapter, the open document or category in the Documents page's URL. The single workspace route with its chapter switcher and the 24 rem tab pane is gone, and with it the 48 rem reading cap. - StoryShell wraps every section page (a component, not a layout — the router is static): one load per story, breadcrumb, the one h1, window title, save indicator, Undo/Redo, the one pair of Ctrl+Z/Ctrl+Y hotkeys, the structural alert, not-found/loading/trashed, the section navigation (NavItem.Nested for the chapters), two panes inside the stories list's outer box (ADR 0010). - SaveScope: cascaded by the shell; every editor registers while mounted, the indicator reads it, Undo/Redo/History flush it. Registration is silent and a disposing editor leaves the scope before its teardown flush, so no save re-enters the shell mid-disposal. - The chapter is the route: WorkspaceState loses its current chapter; ChapterPage derives it and says so when it is gone; create/merge/delete navigate from the effects, leaving a page only when the URL names the chapter that went. A stale caret token is not applied on a fresh column. - Pages: StoryGeneralPage (details unfolded, facts, archive, trash), ChaptersPage (table, New chapter, ChapterMenu), ChapterPage (toolbar, header, column), HistoryPage, DocumentsPage (list beside editor, deep links, gone states, the delete-category dialog); DocumentsPanel becomes DocumentsList with rows and Edit category as links; Close is a link. - Tests: the workspace suite split per page and shell (StoryShell, ChapterPage, ChaptersPage, StoryGeneralPage, HistoryPage, DocumentsPage), TestNavigationManager gains Visit/Reset and a live Uri, effects tests assert navigation, AppChromeTests prerender every section behind the gate with base-relative links. 399 tests (Domain 37 · UseCases 12 · BlazorAdapter 195 · Integration 155). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>Summary
Summary
Coverage
Tsumugi.BlazorAdapter - 96.9%
Tsumugi.Domain - 99.3%
Tsumugi.Infrastructure - 97.1%
A55E0373ED22DC58420DBB40F103A46B68DC4D9CE5A49E00B__Pattern_0
on
tion
n
Tsumugi.Kernel - 90.9%
Tsumugi.Server - 100%
Tsumugi.UseCases - 98%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! Seventy-five files, the whole workspace taken apart and rebuilt as routes, and every seam I pulled on held — the leave-before-flush ordering, the caret token guard, base-relative navigation from the effects… you even pre-answered my questions in ADR 0027 before I asked them. I had such a good time reading this one~ ♡
But I mutated things anyway. Of course I did. And one little rule survived~
Verdict: ⛔ I can't let this pass~ ♡
⛔ These need fixing before I'm satisfied~
DisplayedSaveStatepriority rule is new behavior in this PR, and nothing pins it. The comment makes a precise contract — "a pending pause anywhere under the shell beats every stored state; a refusal beats a save in flight" — but in a scratch clone I inverted the ordering twice and the adapter suite stayed green both times:A control mutant in the same file (
UndoTitlegutted) went 4-red under the identical build pipeline, so the probes were live binaries, not the stale-dll mirage. And this isn't theoretical: your own ADR consequence — "a refused save keeps its message in the indicator until the next save request in the slice" — meansSaveErrorlingers by design; the writer then types in any editor under the shell, and the mutated indicator keeps showing the stale refusal where the shipped one must say Unsaved changes. The suite cannot see that regression. Fufu~ you wouldn't leave a documented rule unpinned in production, would you? ♡-gripFix: one test asserting the ordering where it bites — seed
DocumentsState(or the workspace slice) withSave = Error/Saving, register a dirtyFakeEditoron the shell's scope, assert the indicator reads Unsaved changes over both.StoryShellTests.The_indicator_reads_a_registered_editor_and_undo_flushes_it_firstalready holds the scope handle and the fake — this is a small extension of it.💡 Little ideas (non-blocking)~
LeaveIfOnhelpers are shape-twins. Today two is fine (the repo holds the same line onSize()until a third consumer); when a third "the record went, its URL goes" site appears, extract the comparison. Not before~✅ What I liked~
_registration?.Dispose(); Scope = null; _debounce.Dispose();) — the re-entrancy trap it kills (a disposing editor's save re-entering the shell's render mid-disposal) is documented and tested per path. That is discipline, darling~_appliedCaret = Caret?.Token ?? 0on first render — the store-predates-the-column race closed in one line, pinned by a test that mounts a second chapter's column and asserts the stale token never focuses.DoesNotContain("href=\"/stories")— the sub-path escape hatch is welded shut.CurrentChapterId,SelectChapter,OpenDocument/OpenCategoryall gone; the URL is the truth now. My dead-vocabulary sweep over src+tests found zero survivors.TestNavigationManagergrowing exactlyVisit/Reset/liveUri— minimal test infra that makes the effects' page-awareness testable. Nothing speculative in it~Everything else I verified locally, since the test job was still queued when I started: build 0 warnings 0 errors under
-warnaserror, 399/399 (Domain 37 + UseCases 12 + Adapter 195 + Integration 155 — your table's arithmetic is exact), all four of my probes restored with the tree pristine at9a4f121afterwards. The Kagaku.UI bump is exactly #21 (NavItem.Nested, indent-only — active matching untouched). CI finished green for the head while I read (build + test), and the coverage bot confirms the new files at 100% line — which is precisely why the unpinned ordering is worth a test: coverage lights arms, it cannot see priority~ fufu ♪Automated review by Jibril · 2026-09-23
CI/CD: passed (build + test) for head
9a4f121· Local checks: build 0w/0e, 399/399, mutation probes 5 (2 killed, 2 survived → the blocker, 1 control killed)Round 1 addressed in
60d3fe2(test(workspace): pin the indicator's priority ladder). CI green on the head; 415/415 locally (Domain 37 · UseCases 12 · Adapter 211 · Integration 155).⛔ 1 — the
DisplayedSaveStateordering was unpinned. Confirmed: both of your swaps stayed green on9a4f121here too. Fixed two ways, so the rule has one home and the mutants have nowhere to hide:SaveStateLadder.Combine(dirty, workspace, documents)(src/Tsumugi.BlazorAdapter/Workspace/SaveStateLadder.cs); the shell delegates to it.SaveStateLadderTestsis a Theory over 15 cases covering every arm on either slice — including your two:(dirty, Saving, Saved) → Dirtyand(dirty, Error, Saved) → Dirty, plus(clean, Error, Saving) → Errorin both slice orders.StoryShellTests.A_pending_pause_shows_over_a_refusal_from_either_slice_and_the_refusal_returns_once_it_is_flusheddoes it through the UI, exactly the scenario you named:AutoSaveFailedputs The paragraph no longer exists. in the indicator, a dirtyFakeEditoron the cascaded scope turns it into Unsaved changes, flushing brings the refusal back; then the same withDocumentAutoSaveFailedafter the documents slice holds the story (a refusal for an unloaded story is dropped by the staleness rule, which the test now states).Re-ran your two mutants against the extracted ladder plus a control that has the shell bypass it (
Error ? Error : dirty ? Dirty : …): swap 1 → 5 red, swap 2 → 4 red (the table and the shell test), control → the shell test red. Tree restored andtouched; the suite is green at the head.💡 1 — the two
LeaveIfOntwins. Declined for now, on your own terms: two consumers, the same line the repo holds onSize(); the third "the record went, its URL goes" site extracts the comparison. Noted in the PR body under the design section so it is not forgotten.The PR body's test table and probe list are updated for the round.
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! You didn't just pin the rule — you gave it a name~
SaveStateLadderextracted into its own file, the contract written down in XML docs where it can't drift, a 15-arm Theory pinning every rung, and an end-to-end Fact that walks both slices through the exact ADR consequence I flagged (the refusal that lingers until the next save request). And then you went further: the Fact proves the refusal returns once the pause flushes — "it was never answered." That's the detail I didn't even ask for, and it's the right semantics~ ♡Of course I re-ran my surviving mutants. They died screaming~
Verdict: ✅ Looks good to me~
✅ What I liked~
StoryShell.razornow reads one line (SaveStateLadder.Combine(...)), the rule lives in a static class with the priority spelled out in docs where the next reader finds it before they touch it. One public class, one file, XML docs — exactly the house style.(false, Dirty, Saved, Saved)— pinning the invariant that a slice never says Dirty; only the scope does. I checked everySave = SaveState.*write in both reducers: none writesDirty. That arm is a contract on the reducers, not filler~AutoSaveFailed, types over it (Unsaved changes), flushes, and watches the refusal come back — then repeats the whole dance on the documents slice, including loading it first so its storyId gate doesn't silently drop the refusal. Mutation-killed twice over:Both mutants produced live red under the identical build pipeline — the round-1 escape is closed mechanically, not decoratively. ♪
Everything else re-verified at
60d3fe2: tree pristine, build 0 warnings 0 errors under-warnaserror, full suite 415/415 (Domain 37 + UseCases 12 + Adapter 211 + Integration 155 — +16 over round 1, exactly the 15 arms + 1 Fact), CI green for the head (build + test). The round-1 non-blocking idea (the twinLeaveIfOnhelpers) stands as agreed — fine at two, extract on a third.The ladder is pinned, darling. Merge it~ fufu ♡
Automated review by Jibril · 2026-09-23
CI/CD: passed (build + test) for head
60d3fe2· Local checks: build 0w/0e, 415/415, mutation probes 2/2 killed (ladder order swaps), tree restored pristine