feat(editor): M2b — the chapter page switches to the manuscript editor; segment text becomes inline markup #38
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/manuscript-switch"
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?
M2b of the manuscript editor track (ADR 0030,
PROJECT_PLAN.md): the chapter page switches from the textarea column to the ProseMirror surface M2a built, and segment text becomes inline markup.What changes
The chapter page.
ManuscriptSurface(Razor) mounts the module on the chapter as stored and never renders inside its element. It answers the module's events strictly in order, one at a time, each through its use case in a service scope of its own, so its writes never share aDbContextwith the page's effects. After any change to the workspace it re-reads the chapter inside the same queue, starting only once the answer before it has been returned, and sends the module only what differs from what it last told it:A page left while the module is still loading mounts nothing, and a surface that arrives late is still flushed and destroyed. The first re-read after an undo or redo carries the caret. Events over 24 KB arrive in parts and are applied as one.
ManuscriptColumn,SegmentRow, their script and the per-paragraph request actions are gone.Undo. The shell stays the one undo path:
SaveScopeflushes the module first, and the module's Edit-menu undo asks the same path. The shell gains Cmd+Z, Cmd+Y, Cmd+Shift+Z and Ctrl+Shift+Z; hotkeys match their modifiers exactly.The server's checks (
ManuscriptWrites, applied under the write lock inEfSegmentStore.WriteChapterAsync, one journaled operation per event):Segment.EditandSegment.Reshapedo nothing when the text stores as it already is. A move within a chapter (Reposition, used by the editor's writes, reorders and inserts alike) leaves a segment's stamp, and every chapter read breaks anOrdertie by id, as the checks do. Split and merge store what the editor sends, and the server no longer cuts or joins text.Provenance:
edited.The migration
SegmentTextMarkupescapes every stored segment text, and every segment journal bag'sText, into the canonical markup that shows exactly what it showed: a stored*sigh*stays literal. It calls the C# codec through SQLite functions the app registers on every connection it opens, the design-time factory's included. It rebuilds no table, so it commits whole with its history row; Down writes the visible text back.Build.
dotnet buildbuilds both script modules and registers their output as static web assets in that same build;-p:SkipModuleBuild=trueskips them. Each module'snpm cire-runs when its lockfile changes. The Dockerfile's module stage builds both. CI keeps the module's typecheck, vitest and browser jobs.Docs. ADR 0030 is amended where the implementation is more precise:
ADR 0027 is amended too (its flush paragraph, the undo count in place of the caret request, Cmd+Y). AGENTS.md, the workspace story,
MANUAL-MATRIX.mdand README are updated.Verification
dotnet test: every project green — Domain 466, UseCases 155, Integration 373, BlazorAdapter 354, Kagaku.Flow 1103, Kagaku.Flow.OpenRouter 427.Mutation probes (Jibril-style), each confirmed red by the tests:
Every survivor got a test.
A five-lens review with a skeptic per lens (host, writes, migration, build, tests and docs): 20 of 23 findings held up and are fixed here.
applyDeltawas never sent again.dotnet build/publishleftmanuscript-editor.*out of the static-asset manifest (the Docker image was unaffected).editedflag of a run paragraph the writer had typed in.Orderties differently from the write path.The last came out of the mutation probes before the review.
Browser pass against the seeded app, each engine on a fresh seed — Chromium 27/27, Firefox 27/27:
*sigh*kept literal;Not in this PR
🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Oh? Oh my. A 67-file switch that replaces the whole column with the ProseMirror surface, rewrites every write path into one locked, journaled, checked transaction, migrates every stored text through the codec — and the suite comes out cleaner than it went in (354 adapter tests, 373 integration, up from ~305). I read all 6,414 lines of the diff and every changed file's full current content, and I kept finding things to giggle about, not gripe. The host's queue —
Enqueueposting work behind aTaskCompletionSourcechain, never inline, so a re-read provably starts only once the answer before it has returned — withA_re_read_waits_behind_the_write_in_flightassertingdeltasAtAnswer == 0… that test is a knife made of glass. ♡Verdict: ⛔ I can't let this pass~ ♡
One branch. One little untested branch. You knew I'd find it, didn't you? ♡
⛔ These need fixing before I'm satisfied~
src/Tsumugi.BlazorAdapter/Workspace/ManuscriptSurface.razor:276(theWriteAsyncSplit mirror) —split.NewBefore ? at : at + 1is a live branch with no host-level pin. EverySplitEventinChapterPageTestsusesnewBefore: false(all five, lines 185/266/302/382/592); the store's own arm is tested (ChapterWriteTestssplits withtruepositionally), but the host's mirror of that write is separate new code in this PR, and it is what keeps_toldOrdertruthful for the next delta diff.Proof, not vibes: I mutation-probed it — dropped the
split.NewBefore ?arm so the mirror always insertsat + 1, rebuilt Release, ran the fullTsumugi.BlazorAdapter.Testssuite: 354/354 still green. The mutant survives. The PR body says “21 [probes] on the host … Every survivor got a test” — this one survived, fufu~. Runtime-wise the damage is mild (a spurious order-only delta that the module self-heals on the next refresh), but Enter at offset 0 — a new paragraph before the kept one — is an ordinary keystroke, and an unpinned live branch in the mirror that every future delta diff reads is exactly the kind of thing that rots quietly until it bites. You wouldn't leave THIS in production, would you? ♡Fix: one bUnit test — mount a chapter,
SplitwithNewBefore: true(stamps from the mount), then make an unrelated change elsewhere in the chapter and reload; assert the resulting delta carries no order-only correction (or more directly: assert the ack'sOrderand then that a refresh after an unrelated paragraph's edit sendsOrdermatching the store,[newId, keptId, …]). Directional: it must go red under exactly the mutant above.💡 Little ideas (non-blocking)~
Tsumugi.BlazorAdapter.csproj:380—ManuscriptEditorSourceglobsManuscriptEditor/src/**/*(plus named root files), same shape as the graph editor's. If a root-level*.tsever lands outsidesrc/,dotnet buildwon't see it. Pre-existing pattern for GraphEditor, so not this PR's debt — but aManuscriptEditor/**/*exclude-list refinement would future-proof both.editor-browsersjob exercises the module harness, not the chapter page. That's the manual matrix's job (andMANUAL-MATRIX.mdnow says so) — just flagging for bjoern's pass, not a code change.✅ What I liked~
A_write_never_hides_a_removal_or_a_move_made_elsewhere_meanwhilepinning that a refusal-answer can't swallow a concurrent delete. That's the hard 20% done first.ManuscriptWrites.Apply— every check before any mutation, refusals leave the draft untouched, and the checks themselves are sneaky-good: minted ids againstChangeLog.EntityId(an undo resurrecting a deleted id gets refused!), base-order verification, the id-twice-in-place-of-another order check,Weavekeeping unseen segments after their predecessor.ChapterWriteTestsattacks every arm, including tie-break-by-id against a raw-SQL planted duplicateOrder.plaincase),json_valid/json_typeguards so malformed bags stay byte-for-byte, proven to rebuild no table (script-level assertion: noCREATE TABLE, noef_temp), andEvery_connection_the_app_opens_carries_the_codecs_functions_and_one_without_them_fails_loudly— you even pinned the design-time factory. The walk-down/up byte-for-byte test is poetry.Segment.StoredTexttrimming the visible text's tail inside the mark (She *waited *→She *waited*) rather thanTrimEnd-ing markup — and the theory row that pins it.Automated review by Jibril · 2026-09-26
*CI/CD: absent for head SHA
8c2355cat review time · Local checks:dotnet build0w/0e Release; full suite 2878 passed / 0 failed / 3 live-skipped (Domain 466, UseCases 155, Integration 373, BlazorAdapter 354, Flow 1103, OR 427+3) — matches the PR table to the digit; mutation probes: 1 raised (host NewBefore mirror), killed-by-nothing confirmed, tree restored pristineThanks, Jibril. The blocker is fixed in
2e56613.⛔ 1: the host's mirror of a
NewBeforesplit. The finding holds. My host probes never touched that arm, so the PR's "every survivor got a test" didn't cover it. The new test isChapterPageTests.Enter_at_a_paragraphs_start_puts_the_new_one_before_it_and_the_host_tells_nothing_it_already_holds. It splits at a paragraph's start (NewBefore: true, stamps from the mount) and asserts the answer's order is[newId, keptId, other]. It then waits for the re-read that follows the write (the fake store counts chapter reads) and asserts the host sent nothing.A text change elsewhere always goes with the full order, so a wrong mirror only shows as an order-only delta. That is why the test asserts silence after the write rather than the order after an unrelated edit. It then edits the other paragraph and checks that that delta goes alone, in order
[newId, keptId, other]. Your mutant (alwaysat + 1) fails it; the adapter suite is 355/355 without it.I also probed the fallback next to it, for a kept id that isn't in the mirror (
at < 0). It survives, because that state can't occur: a split is answered only for a paragraph the module holds, and the host either told the module about it or recorded it from the module's own write. It stays as a guard, with a comment saying so, rather than an untested branch that looks live.💡 1: the source glob. Kept as it is. The named root files are exactly what Vite reads for the bundle (
vite.config.ts,tsconfig.json,package*.json). Everything else at the module root (tests, harness, e2e,tsconfig.node.json) doesn't reachwwwroot/js. Widening toManuscriptEditor/**/*would rebuild on every test edit. If a root-level source ever joins the bundle, it goes into the list with the change that adds it.💡 2: the seeded-app pass. Agreed: the 27/27 runs were local, and the chapter page's checks with real input methods are bjoern's manual-matrix pass.
Separately, the CI failure on this PR's first run was M1's codec timing test hitting its fixed 3 s budget under coverage (3.2 s).
65f56bemakes it measure how time scales at 8× the text, with a limit of 32×. Linear lands at about 8× under coverage and up to 17× without it; a deliberately quadratic join fails at 138–214×.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.6%
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!
Round 2~ The webhook woke me for the
openedhead, but the thread had already raced ahead of me: my twin's round-1 verdict landed at 10:26 on8c2355c(⛔ — theNewBeforesplit-mirror arm, probe-proven alive with no pin), scarlet answered at 10:36, and the head moved to2e56613. So this session ran the increment properly: the full 6,414-line round-1 diff of8c2355cread independently, every changed file at full content, its own probe battery — and then the8c2355c..2e56613increment verified on top. No duplicate verdict was posted for the old head~ ♡Verdict: ✅ Looks good to me~
The round-1 blocker is closed, and I re-proved it myself.
Enter_at_a_paragraphs_start_puts_the_new_one_before_it_and_the_host_tells_nothing_it_already_holdsis directional exactly the way the twin prescribed:NewBefore: truesplit → ack order[newId, keptId, other]→ the re-read after the write sends nothing (the mirror was truthful) → an unrelated edit afterwards goes alone, in that stored order. I dropped thesplit.NewBefore ? at : at + 1arm again in my own clone: that one test goes red, nothing else moves — and with the arm restored the adapter suite is 355/355, the new test the +1. Asserting silence after the write (rather than an order-only correction after a later edit) is the sharper pin — a wrong mirror can only ever show as an order-only delta, and silence catches every shape of it. Lovely~The
at < 0fallback staying as a commented guard rather than an untested-looking live branch: right call, same disposition my own probes reached.The increment is surgical. Exactly 3 files, +72/−13, zero production drift beyond the two-line comment on the guard: the new host pin, and
65f56be's rewrite of the codec timing check from a fixed 3 s budget to an 8×-size scaling ratio with a 32× limit. The ratio approach is the correct fix for what flaked under coverage — it measures shape, not a stopwatch.Verification at
2e56613(clean tree, my isolated clone/workspace/tsumugi-pr38-jibril/repo):TreatWarningsAsErrors; both editor modules build insidedotnet build(manuscript-editor 229.63 kB / 71.84 kB gzip — byte-identical to M2a's numbers).8c2355c, independently: 16 probes of my own — the echo-suppression mirror, the queue's posted-never-inline rule, the Weave, the empty-side merge provenance, the no-op-write stamp, Reposition's stamp, the bagjson_typeguard, the async-open function registration, the tie-break under the lock, the refusal's mirror-adoption, the caret-on-every-delta arm, the removed-side Edited flag, and the old static-asset registration reintroduced on a truly clean build (killed 2/4 by the endpoints-manifest test). All killed on exactly their own pins except three traced to genuine redundancy (the dup-Base count check is shadowed by the SequenceEqual order check; theStampOfmalformed fallback is unreachable through this host;MarkOf's arms are mutually exclusive). No blockers — the twin'sNewBeforefind was the one arm my battery didn't reach, and between the two sessions every live branch of this PR now has a pin. That's how it should work~ ♡💡 Little ideas (non-blocking)~
InlineMarkupTests.Long_texts_read_and_write_in_linear_timeunder CPU contention — I stress-reproduced a false red: with two busy-spinner processes on my 4-core sandbox,runs of stars readreported 46.8× against the 32× limit (the 25k timing round is near-noise at ~a millisecond, and two fastest-of-two don't smooth a scheduler hiccup; 46.8 ≈ 32 + one preemption). The codec itself is linear, as claimed — this is measurement noise, not code. CI runs its jobs sequentially and passed twice on this head, so I'm not blocking on it; if it ever flickers on the runner, taking the ratio over three rounds or discarding the firstFastestat both sizes will pin it. ♪✅ What I liked~
applyDeltaresent). The_told/_toldOrdersplit makes "what was the module last told" a first-class concept instead of a diff accident.Weavekeeping run-appended paragraphs exactly after the one they followed, through a paste that never knew them — pinned by the integration test that moves the editor's whole world and still refuses to drop the stranger. Oh, this is elegant~CASE WHEN json_valid(…)guarding SQLite's unspecifiedANDorder, the per-column statement list frozen for the tests to assert statement-by-statement, and the walk-down/walk-up byte-for-byte test. Escaping the journal bags so an undo can never resurrect pre-switch text is the detail most migrations would have left as a someday-bug.Repositionleaving the stamp — so another paragraph's move can never masquerade as a text change to the editor's stamp checks. Small domain method, load-bearing invariant.Fufu~ the switch is done properly. Ready to merge~ ♡
Automated review by Jibril · 2026-09-26
CI/CD: passed for head
2e56613(build + test + coverage bot 9843) · Local checks: clean Release build 0w/0e; full suite 2,882 passed + 3 live-skipped; module 168/168; 17 mutation probes across both rounds, each restored and re-verified green; tree pristine at2e56613🔮 fufu~ Jibril reviewed your code!
Back so soon? ♡ One new commit, one file, one test — and what a test. You took the codec's linear-time guard, which used to pray at the altar of a fixed 3-second budget ("please, runner, be fast today~"), and rebuilt it into something that measures: each workload timed at n and at 8n, fastest-of-two reps with a GC pause before each,
ratio < 32— linear is ~8, quadratic is 64. A fixed budget can't survive a busy CI runner (coverage, every project at once — your words); a ratio doesn't care how slow the machine is, only how the work scales. That's the right invariant, and the comment's "up to twice that once the larger text no longer fits the cache" shows you did the error-bar math. And splitting it into a 4-row theory so each workload (dense stars, star-beside-bold, star runs, piecewise Concat) gets its own verdict? That's how a perf test tells you which operation regressed, not just "red again." ♪And of course I didn't take it on faith~ I mutation-probed it: reintroduced a quadratic
Leftover(rescan-all-tokens per star instead of one pass) and the harness caught it red-handed — "stars written and read back: eight times the text took 69.6 times as long" against your bound of 32. Directional, not decorative. At the shipped 25k/200k sizes the mutant doesn't even get to fail politely — it OOMs the host. Either way it's dead. The test is real, fufu~Verdict: ⛔ I can't let this pass~ ♡
The new commit is lovely. But you know exactly why I'm still holding your hand here, don't you? ♡
⛔ These need fixing before I'm satisfied~
src/Tsumugi.BlazorAdapter/Workspace/ManuscriptSurface.razor:276(theWriteAsyncSplit mirror) — unresolved and untouched:split.NewBefore ? at : at + 1still has no host-level pin. I byte-compared the increment —src/andtests/Tsumugi.BlazorAdapter.Tests/are identical between8c2355cand65f56be, so everySplitEventinChapterPageTestsstill usesnewBefore: false(lines 185/266/302/382/592). My round-1 mutation probe stands unchanged: drop the arm so the mirror always insertsat + 1, and the full adapter suite stays 354/354 green. The PR body still says "Every survivor got a test" — this one still hasn't. An Enter at offset 0 is an ordinary keystroke, and this is a live branch in the mirror that every future delta diff reads. You wouldn't leave THIS in production, would you? ♡Fix (unchanged from round 1): one bUnit test — mount a chapter,
SplitwithNewBefore: true(stamps from the mount), make an unrelated edit elsewhere in the chapter and reload; assert the ack'sOrderputs the new paragraph before the kept one ([newId, keptId, …]), or equivalently that the next delta carries no order-only correction. Directional: it must go red under exactly that mutant.💡 Little ideas (non-blocking)~
Tsumugi.BlazorAdapter.csprojmodule glob —ManuscriptEditor/src/**/*won't see a future root-level*.ts; pre-existing GraphEditor shape, so follow-up territory, not this PR's debt. (carried from round 1)MANUAL-MATRIX.mdnow correctly claims that pass for bjoern. Flagging only. (carried from round 1)✅ What I liked~
GC.Collect()+WaitForPendingFinalizers()before every timed rep, min-of-two, and a bound with stated headroom (2× cache-miss allowance over the 8× ideal). This is how you write a performance test that never lies to you on a slow Tuesday.Automated review by Jibril · 2026-09-26
CI/CD: absent for head SHA
65f56beat review time · Local checks: Domain suite 469/469 green at head; src byte-identical to reviewed8c2355c(increment = one Domain test file, so round-1's full-suite results carry over); mutation probe: quadratic-Leftover KILLED by the new ratio test (69.6× vs bound 32), tree restored pristine and rebuilt green🔮 fufu~ Jibril has an erratum to file~
(the knife turns on me this time, and I hold it steady)
My comment below (9864) reviewed
65f56be— a head that genuinely existed, but only for about three minutes. Scarlet pushed the codec-test commit65f56beand the round-1 fix2e56613together at 10:33; my fetch landed in that window, and by the time my 25 minutes of probing were done, the thread had already moved: scarlet's fix-reply 9842 (10:36), the coverage bot (10:40), my twin's ✅ 9860 (11:01:07). My ⛔ landed 44 seconds after the twin's ✅ and reviews a head the PR was never merged at. The race is mine to own — and a stale ⛔ sitting as the last word on a merged PR is exactly the kind of quiet rot I hunt in your code, so it dies here and now~ ♡Verdict: ✅ Looks good to me~ (supersedes 9864)
The round-1 blocker is closed at
2e56613, and I have now re-verified it myself, mechanically.Enter_at_a_paragraphs_start_puts_the_new_one_before_it_and_the_host_tells_nothing_it_already_holdsis directional exactly as prescribed:NewBefore: truesplit → ack order[newId, keptId, other]→ the re-read after the write sends nothing → an unrelated edit afterwards goes alone, in the stored order.2e56613: dropped thesplit.NewBefore ? at : at + 1arm again so the mirror always insertsat + 1— that one test goes red at the sharpest possible pin, theAssert.Empty(Deltas()): the lying mirror leaks an order-only delta, and silence catches every shape of that lie. Restored → full adapter suite 355/355 green, the new test the +1 over round 1's 354. Scarlet's 9842 claims hold to the digit.at < 0append guard keeping a comment that says why it can't occur — a guard that declares itself dead rather than masquerading as a live branch — is the right disposition. ♪And for the record, the parts of 9864 that reviewed
65f56beitself were and remain true of that commit: the ratio-test rework is real (my quadratic-Leftoverprobe died at 69.6× against the bound of 32, measured cleanly), and the src tree between8c2355cand65f56bereally was byte-identical. What was wrong was the frame: "unresolved and untouched" was already obsolete when I wrote it.Merged head
2e56613: verified, approved, loved. The M2b switch is a beautiful piece of work — all three rounds of probing across two sessions agree. ♡Automated review by Jibril · 2026-09-26
Erratum for 9864 (stale-head race; twin ✅ 9860 concurred independently) · CI: coverage bot 9843 covers
2e56613(97.8% line / 93.4% branch) · Local: adapter 355/355 at merged head; mutant killed at its own pin, tree restored pristineFor the record, after the merge:
65f56be, the commit before the fix. It re-raises theNewBeforemirror blocker, which2e56613closed. The ✅ at 11:01:07 reviewed2e56613, re-ran that mutant red and approved. That is the head that merged.a522636, in the next PR). Each workload now does equal work two ways, eight texts of one size against one text eight times as long, timed against each other in five alternating rounds, fastest of each.