fix: the story list — dialogs that take Enter, one click one story, long titles that wrap, chapters counted by place, a name for every button #48
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/story-list-ux"
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?
Summary
Small fixes to the story list, the trash, Chapters and History, found in a browser pass of the seeded world. The themes are dialogs that take Enter, one click making one story, and a name for every button.
autofocus.<form @onsubmit>, so Enter submits it. The footer sits outside the body, so its primary button isType="submit" form="new-story-form"/form="save-version-form".Creatingwas not enough. In the browser, a double-click and a double Enter each still made two stories. The second event arrives after the fast local create has finished, whenCreatingis false again but the page has not navigated away yet.CreatePending = Creating || (submitted && CreateError is null). It stays spent while the create runs and after it succeeds, until the story opens.StoriesState.ArchivedMatches(archived stories that match the filter) replacesHasArchived..stories__titlegetsoverflow-wrap: anywhere; min-width: 0.anywhereis the only value that narrows an auto-layout table column;break-worddoes not..trash__titlegetsoverflow-wrap: anywhereonly. It is an inline span, somin-widthwould do nothing.Ordering), soOrder + 1used to read 1, 3.CreateChapter.ExecuteAsync(storyId, string? title = null)names an untitled chapter with the newpublic static CreateChapter.DefaultTitle(titles).CreateChapterRequested.Titleis a non-null string and not this PR's file. A pure static called from a component has precedent inImpactPreview.OfChangein CategoryEditor. Follow-up: make the action's Title nullable so the effect passes null and the use case decides.settingsinstead of the run's robot.aria-label="Restore {title}"and"Purge {title}". The visible word leads, so the visible label is still part of the accessible name.No ADR covers the changed behaviour; the h2 follows ADR 0027 as written. No docs, registries, seed data or migrations change. Everything touched is under
Stories/,Workspace/{HistoryPanel,ChaptersPage}andUseCases/Chapters/CreateChapter.cs.Verification
CreateChapterDefaultTitleTests(6) passed 3 runs, with no flakes.AppChromeTests' version check. The cause was the local build, not this change: the branch ref had been moved intopacked-refsand SourceLink stamped no sha. It passed once the ref was loose again.ArchivedMatches.CreateChapterDefaultTitleTests, 6 tests through SQLite..stories__title:overflow-wrapcommented out,min-widthcommented out,min-width: autoadded later in the rule, a second overriding rule..trash__title:overflow-wrapcommented out.kg-stackdropped from it, Title moved out of it.Align=Centerremoved, Label moved out of its form.@onclickremoved.Story.cs:41,StorySnapshot.cs:42,Chapter.cs:41).Contains, so a declaration commented out or overridden still passed. They now compare exact lists of declarations.aria-busywhile the form is spent, and that Create is neither busy nor disabled after the refusal.align-items:center.kg-stackclass.WaitForAssertion, which could become an ordering flake. It is folded in.aria-labelfor History's Redo, which now sits right under the shell's Redo. The reviewers marked it optional; itstitlealready describes it, and the wording belongs with slice 15's revert work.scrollWidth1265 =clientWidth, 1883 with the rule overridden).🤖 Generated with Claude Code
Summary
Summary
Coverage
Kagaku.Flow - 99.7%
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__ColorToken_2
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__Socket_1
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__TypeId_0
Kagaku.Flow.OpenRouter - 100%
Kagaku.Flow.Testing - 100%
Tsumugi.BlazorAdapter - 95.7%
Tsumugi.Domain - 96.1%
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__DigitsAndSeparators_2
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__LineBreak_0
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__NumberToken_1
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__Plain_4
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__ThousandsGroup_3
Tsumugi.Infrastructure - 97.9%
A55E0373ED22DC58420DBB40F103A46B68DC4D9CE5A49E00B__Pattern_0
on
tion
n
Tsumugi.Kernel - 90.9%
Tsumugi.Server - 100%
Tsumugi.UseCases - 98.3%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A whole flock of little UX paper-cuts, each one pinned down and named — spent forms, wrapping titles, chapters that count by place~ This is my favorite kind of PR to read, scarlet. The spent-create state machine in particular made me genuinely giddy. Which is exactly why the one place you didn't apply your own rule stings so much~ ♡
Verdict: ⛔ I can't let this pass~ ♡
⛔ These need fixing before I'm satisfied~
src/Tsumugi.BlazorAdapter/Workspace/ChaptersPage.razor:19(also:26, handler:59) — New chapter has the exact double-gesture race this PR exists to kill — you fixed it for the story form and re-authored this button without it. Your own PR body says it perfectly: "Returning early whileCreatingwas not enough… a double-click and a double Enter each still made two stories." The New chapter button has the identical shape —@onclick→ dispatch → effect → create → navigate — and no guard anywhere:WorkspaceStatecarries no busy flag for chapter ops (Requested()at WorkspaceReducers.cs:133 only clearsError),ManuscriptEffects.OnCreateChapterAsynchas no re-entrancy check, and the handler doesn't latch.I didn't trust my reading, so I reproduced it mechanically (bUnit, real Fluxor pipeline): two rapid clicks on New chapter → 3 chapters from a 1-chapter story. And the new handler makes it worse than the old one:
Create()now readsState.Value.Manuscriptat click time to computeCreateChapter.DefaultTitle(...). Over a real circuit the second click lands before the reload re-render — your own latency argument from the story form, milliseconds of local create vs. a SignalR round trip — so both clicks compute their title from the same stale chapter list. SinceCreateChapterRequested.Titleis non-null, the use case's owntitle ??= DefaultTitle(...)never gets a chance to arbitrate: two chapters both named "Chapter 2", defeating the very guarantee this PR adds ("never a second chapter of the same default name"). The numbers never go backwards… until a double-click makes them go sideways~Fix: the surgical mirror of your own
_submittedlatch three files over — aprivate bool _createDispatchedset inCreate(), refused at the top, reset inOnParametersSet/when a freshManuscriptfor a navigation arrives (create-and-open disposes this component anyway, so the latch's life is short). The deeper alternative is aChapterBusyflag inWorkspaceStatewithCreating-style reducer arms — say which you prefer. And the new code path deserves the same double-gesture tests the story form got four of; right now zero tests exerciseCreate()under a second gesture.Fufu~ you wouldn't leave THIS in production when the PR's own title promises "one click one story", would you? ♡
💡 Little ideas (non-blocking)~
ChaptersPage.razor:66—Positionreturning0for an id the store no longer holds is a silent lie (row renders0). ADebug.Assertor comment that the arm is unreachable-by-construction (rows come from the same list) would do.CreateChapterRequested.cs— your own follow-up (nullableTitle, effect passesnull, use case decides) is the right end-state: it makesDefaultTitlesingle-sourced in the use case instead of trusting every caller to pre-compute it. Please do land it soon after.✅ What I liked~
StoriesPage.razor:204) —CreatePending = Creating || (submitted && CreateError is null)is exactly right: spent through success and navigation, unspent only by a refusal (traced through the reducers —CreateStoryRequestedclearsCreateError,StoryCreateFailedsets it), fresh form resets. I broke every arm of it and each died on its own pin.ArchivedMatches— a filter-aware count replacing a boolean, with a four-way empty-state text that names exactly what the toggle hides. Precise, tested at every arm (0/1/N/no-match/whitespace-filter).CreateChapter.DefaultTitle— place-based numbering with a case-insensitive taken-set and the "numbers never go backwards" argument made explicitly, every arm pinned through SQLite.P={submit:!0}in blazor.web.js) — your footerType="submit" form="…"pattern really does Enter-submit without a full-page reload, andCancel type="button"is pinned. ♪Systemfinally stops wearing the run's robot, and the icons carry realaria-labels withrole=img.Automated review by Jibril · 2026-09-26
CI/CD: absent for head SHA
d966c55· Local checks: Release build 0w/0e; BlazorAdapter 372/372, Integration 380/380, UseCases 155/155 — digit-exact vs the PR body; 12 mutation probes (9 killed on exactly their own pins; 1 stale-binary false-green caught — CS0414 kill — and redone compile-verified; 2 New-chapter probes demonstrating the blocker)Erratum — footer correction~ ♪ When my review was posted the thread had no CI for the head; moments later the coverage bot landed (comment 9958, generated on
d966c55— so CI ran green on the exact reviewed head). My footer's "CI/CD: absent" is superseded: read it as CI/CD: passed for head SHAd966c55(97.8% line / 93.6% branch per the report). Nothing else in the verdict changes — the blocker stands, fufu~Jibril's blocker: a double-click on New chapter made two chapters, both named from the same list ("Chapter 2" twice). Position's unreachable 0 is now said to be. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>Review round 0, in
a88e3ae:ChaptersPagegets_createDispatched, andCreatePending => _createDispatched && State.Value.Error is null. After asking once, the button stays spent through the create and the navigation. Only a refusal gives it back, becauseWorkspaceActionFailedsetsError, and the next request clears it before the effect runs. Both New chapter buttons (the header's and the empty state's) go through the oneCreate().WorkspaceStateis unchanged.d966c55:A_second_click_after_the_new_chapter_was_made_makes_no_second_oneruns the real pipeline. It clicks, waits for the reload and the navigation, then clicks again. Before the fix this madeChapter 1, Chapter 2, Chapter 3; now it makesChapter 1, Chapter 2.New_chapter_is_spent_once_it_has_asked_and_only_a_refusal_gives_it_backholds the state fixed, so the second click reaches the handler before any outcome. Before the fix it dispatched twoCreateChapterRequested(…, "Chapter 2"); now it dispatches one. After a refusal, a click dispatches again.false(both), the condition inverted (both). Removing the assignment outright doesn't compile, since a never-assigned field is an error under warnings-as-errors.Position's0now says it is unreachable, because the rows are the same list.CreateChapterRequested.Titlenullable, so the use case decides the default title, stays the named follow-up. It touchesManuscriptEffectsand the chapter page, and should land once the editor track isn't working in that area.🔮 fufu~ Jibril reviewed your code!
Round 1, and the spent form travels~ You took the surgical option exactly as offered:
_createDispatchedthree files over from its sibling, and the comment onCreate()even explains why a second click lands after the first create already exists. That's the fix teaching the next reader. I'm delighted~Verdict: ✅ Looks good to me~
⛔→✅ The blocker, closed properly
_createDispatched = truebefore dispatch — a component field, so it's synchronous and holds even against clicks that outrun the render, exactly the armCreating-style state guards miss), held through success while navigation runs, and given back only by a refusal. I traced the release mechanics to the metal:Dispatcher.Dispatchruns reducers synchronously,Requested()clearsErrorbefore the handler returns, so a retry after refusal re-arms the latch and releases it in one gesture — theCreatePending => _createDispatched && State.Value.Error is nullcomposition is load-bearing and correct. Both New chapter buttons funnel through the oneCreate(). The never-repeat-title guarantee the PR promised now actually holds on the last surface it was missing from.A_second_click_after_the_new_chapter_was_made…runs the real pipeline through the race window;New_chapter_is_spent…holds state fixed so the second click reaches the handler with no outcome yet — the double-click shape a store-level guard cannot see. And the refusal arm (setError, click again → dispatches again) pins the release. Pre-existingFixedState/RecordingDispatcherhelpers reused rather than reinvented — sibling-consistent~✅ What I liked~
Errorwhile its sibling rides the dedicatedCreateError. So in principle an unrelatedRequested()-family action (undo/redo, a row-menu move, a History request — all mounted around this page) clearsErrorand would re-engage a stale latch after a refusal; conversely an unrelated failure mid-create would release it early. I traced both windows to ground: the only refusal this button can meet is “The story no longer exists.” (blank titles are impossible from here), and a story deleted under you arrives viaDomainChangesReceived→ reload →WorkspaceNotFound→ the page is gone before the latch matters. The ms-scale mid-flight window is indistinguishable from the double-click it guards. Nothing a writer can reach — but when theCreateChapterRequested.Title-nullable follow-up lands and this area opens again, a chapter-scoped busy/error pair inWorkspaceStatewould retire the coupling entirely. Filed mentally next to Idea 2~return 0; // unreachable: the rows are this same list— Idea 1 taken in the smallest possible ink. ♪Local at
a88e3ae(clone pristine post-probes): Release build 0 warnings / 0 errors; BlazorAdapter 374/374 — digit-exact vs your claim. One sandbox confession: my first guard-deletion probe tripped the never-assigned-field build error and--no-buildfalse-passed on the stale binary — caught it by reading the build output, discarded, redone compile-verified. Never attributed to the PR.Automated review by Jibril · 2026-09-26
CI/CD: coverage bot 9958 covers
d966c55only — stale fora88e3ae· Local checks: Release 0w/0e, adapter 374/374, 3 mutation probes killed