feat(editor): the chapter's and the selection's word and character counts (writing aids W2) #81
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/writing-aids-counts"
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?
Writing aids W2 · Counts (ADR 0032,
docs/stories/writing-aids.md→ Counts).What the writer sees
A small status line below the chapter's text, e.g.
1,234 words · 6,789 characters. While text is selected, the selection's counts follow it:… — selected: 9 words · 44 characters. It uses the app's own small muted type, whatever font the writer chose for the prose. It is not a live region, so a screen reader doesn't read out every keystroke.How
counts.tscountTextusesIntl.Segmenter. Words are the segments marked word-like. Characters are graphemes, and line breaks count as neither. So Schneesturm, don’t and geht’s are one word each,👍🏽is one character, ande+combining accent is one character.ChapterCounterkeeps each paragraph's counts by segment id, together with the node they were counted from. A paragraph is counted again only when its node object is a different one.requestIdleCallback, falling back tosetTimeout), in slices of about 8 ms, then redraws the line.surface → told → counts), so the told region keeps its place directly after the surface.Docs
Verification
countstests cover typing, the selection, placement and not being a live region, and a remote edit.destroyedguard: after unmount it would just write to a detached<p>.38 words · 184 characters, which matches an independent segmenter count of the page's text. Triple-clicking the first paragraph adds— selected: 9 words · 44 characters. Checked in light and dark.🤖 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 - 97%
Tsumugi.Domain - 96.5%
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__DigitsAndSeparators_2
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__LineBreak_0
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__NumberToken_1
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__Plain_4
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__ThousandsGroup_3
Tsumugi.Infrastructure - 99%
A55E0373ED22DC58420DBB40F103A46B68DC4D9CE5A49E00B__Pattern_0
on
tion
n
n
Tsumugi.Kernel - 90.9%
Tsumugi.Server - 99.6%
Tsumugi.UseCases - 98.9%
663A4576626E1322FB9C6CE472CAC0A2D2F3DEFF8CB40D9F7__HexColour_1
663A4576626E1322FB9C6CE472CAC0A2D2F3DEFF8CB40D9F7__LineBreaks_0
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A word counter that refuses to count inside the transaction — segmenting in idle slices, keyed by node identity so an untouched paragraph costs nothing? That is how you court a long chapter without breaking ADR 0030's budgets. I read every line of
counts.ts, chased it throughview.ts,convert.ts,schema.tsand the e2e helpers, and I'm genuinely delighted~ ♡Verdict: ✅ Looks good to me~
✅ What I liked~
ChapterCounter.paragraphs,counts.ts:56) — a paragraph is recounted only when its node object is another one. ProseMirror keeps unchanged siblings' nodes across transactions, so typing, remote edits and bold-all touch exactly one paragraph each. The author's own mount numbers (365 ms → 62 ms) show they measured before they believed. Wonderful instinct~countTextviaIntl.Segmenter— grapheme counting (👍🏽 is one,e+combining accent is one), word-like segments, line breaks counted as neither. The fixture table (counts.test.ts:8) is exactly the tripwire set: Schneesturm, don't, « Viens »,— … –, and the empty string.visibleText(node)=textBetween(0, size, '', '\n'), and the fresh path inselection()usesdoc.textBetween(…)with the schema'shard_breakleafText: () => '\n'— so a line break is\nin both paths andcountTextfilters it identically. A subtle trap, dodged cleanly. I checked ♪scheduledcoalesces idle callbacks,destroyedguards the late one,host.append(told, counts)keeps the told region directly after the surface, anddestroy()removes both. The placement test even assertsafterTold: trueandinsideText: false— claims pinned to the DOM, not vibes.aria-live/roleare absent. The comment says why. This is the kind of decision I want to frame~.tsm-countsfollows.tsm-told's conventions (tokens, muted colour, tabular-nums for a flicker-free line), the ADR 0032 rewrite says idle time + node identity, ADR 0030's amendment says "below the surface", and the story's W2 boxes are ticked. PR description, code, docs — all three agree. Nothing crosses the circuit ♪🔬 Mutation probes — 3 run, 3 killed, 0 survived
I couldn't resist poking the riskiest branches (all in
counts.ts, tests restored after each):has(id)(a changed paragraph keeps stale counts) →counts.test.tsfailed (1). Killed~from <= start && to >= end, so cut ends count whole paragraphs) → failed (1). Killed~The suite doesn't just compile — it bites. An author who runs mutation probes on their own PR before I even arrive? Fufu~ take responsibility for raising the bar like that ♡
💡 Little ideas (non-blocking)~
counts.ts:123—SLICE_MS = 8is a measured magic number with a comment; a sibling constant name (e.g.COUNT_SLICE_MS) would help greppability once more writing aids land. Purely a naming nicety.counts.ts:116—describe()constructs a freshIntl.NumberFormatper redraw. It's dust on the scale (one call per idle redraw, not per keystroke), so feel free to leave it — but a cached formatter perlangbeside the segmenter cache would rhyme nicely.Automated review by Jibril · 2026-10-05
CI: green for head
f4471899· Mutation probes: 3 run, 0 survived · Local suite: skipped (CI green; counts.test.ts run for probes only, 17/17 baseline)