PT-4026: conflict-note card (Accept/Reject with dynamic Result preview)#2484
Conversation
c7eba56 to
eb52a1a
Compare
e84e01c to
6ef73e5
Compare
rolfheij-sil
left a comment
There was a problem hiding this comment.
Automated code review — /code-review max + /review-paratext
Reviewed 2026-07-03 with a max-effort multi-agent /code-review (32 agents, 36 verified findings → 15 distinct) and the four /review-paratext analyzers (api / style / compliance / ux). Where I1/I2/I3 hinged on PT9 behavior, a two-agent spike established ground truth against PT9 source (ParatextBase/ParatextData) and the PT10 converter before any behavior change.
Everything found is listed below — including items dismissed or deferred — with how each was handled. All Critical and Important findings were fixed in-review (commit 9f0c52a). Branch is green: typecheck · eslint · format:base:check · Vitest unit 230/230 · Storybook (chromium) 3/3 · C# PlatformCommentConverterTests 23/23. The tracked dist/ and themes.data.json were regenerated/synced.
Critical
| # | Finding | Handling |
|---|---|---|
| C1 | Stale tracked dist/ — new exports were added to src/index.ts but the git-tracked build output consumers resolve was not rebuilt, so PT-4030's import { ConflictNoteCard } would fail TS2305 / crash at runtime; no CI step catches it. |
Fixed — regenerated platform-bible-react dist; exports now present in dist/index.d.ts/index.js. |
Important
| # | Finding | Handling |
|---|---|---|
| I1 | !!resultText gate hid the entire resolution UI when the merged result verse is blank, diverging from PT9 (AppendConflictNoteDetails gates only on isFirstComment && Contents?.FirstChild != null, never on result-emptiness). |
Fixed — gate on conflictType === 'verseText' alone. Spike finding: the trigger state is not producible from real merge data (a deleted verse creates no note — BookFileMerger.cs:441-445; an emptied verse keeps its \v N marker so Comment.Verse is non-blank). So this was a defensive-but-wrong gate, not a live stranding bug — but the fix restores PT9 parity and hardens the card. |
| I2 | Rejected region heading rendered unconditionally → orphan "Rejected" heading over an empty body when rejectedText is absent. |
Fixed — gated on !!rejectedText, symmetric with the Accepted region (matches PT9, which prints the reject label only when the rejected side is non-null). |
| I3 | Blank <p> in the Result preview when Reject is selected and rejectedResultText is absent. |
Fixed — neutral empty-state string ("The verse will be empty.") instead of an empty paragraph. |
| I4 | Deletion text below AA contrast in Platform dark mode — <s> used background-grade --destructive (~2–2.5:1), while insertions used the text-grade --success-foreground. |
Fixed — added a per-scheme text-grade --diff-deleted token (red-600 light / red-400 dark) in index.css; themes.data.json auto-synced; <s> now uses it. |
| I5 | CI prettier/format gate would fail on the component + sample data (root --check misses it via .prettierignorerun; the lib workspace check exits 1). |
Fixed — reformatted; whole-lib format:base:check passes. |
| I6a | Localization contract deviated from the Localization-Guide — mutable LocalizeKey[] + required localizedStrings prop (guide/newer components use a frozen tuple + derived type + optional prop). |
Fixed — Object.freeze([...] as const), derived ConflictNoteCardLocalizedStrings, optional prop. |
| I6b | Snake_case key prefix %conflict_note_*% (guide prefers camelCase; keys are immutable once shipped). |
Fixed — renamed to %conflictNote_*% before PT-4030 contributes values. |
| I6c | ?? 'English' fallbacks are dead through the documented hook path (useLocalizedStrings returns missing keys as the key itself). |
Addressed by the contract change; the fallbacks remain a defensive default. Contributing string values is PT-4030's job. |
| I7 | Fallback path skipped trimDiffSpanWhitespace while styling with diff classes → potential dangling strikethrough. |
Obsoleted by the I1 fix — the fallback now renders only non-verseText conflicts, which carry message-only contents with no <s>/<u> markup. |
| I8 | Result preview not announced to assistive tech — no aria-live; screen-reader users don't hear the Accept↔Reject swap. |
Fixed — aria-live="polite" region + aria-labelledby to the Result label, keyed with useId for list uniqueness. |
| I9 | DIFF_HTML_CLASSES copy-pasted CommentItem's prose/blockquote stack — silent drift risk. |
Fixed — extracted COMMENT_BODY_PROSE_CLASSES; both consume it. |
| I10 | Confusing C# fixture naming — new CreateVerseTextConflictCommentWithReplacement vs base's …Replacement; TS sample named after the wrong fixture. |
Fixed — renamed the fixture, its test, and the sample to …ReplacementBothSides. |
| I11 | Missing edge-case tests for the new gating/absent-field logic. | Fixed — added tests for verseText-without-resultText, absent-rejectedText, absent-rejectedResultText-on-reject, aria-live, and the controlled-mode state-leak (11 unit tests total). |
Minor
| # | Finding | Handling |
|---|---|---|
| M1 | Whitespace-only diff invisible — trimDiffSpanWhitespace collapses <s> </s> to an empty element. |
Dismissed — a whitespace-only conflict is degenerate; the trim is deliberate for the common case. |
| M2 | Controlled-mode internal-state leak — handleChange wrote internalResolution even when controlled, so a declined choice could resurface if the parent later stopped controlling. |
Fixed — write internal state only when uncontrolled; test added. |
| M3 | Uncontrolled state not reset on comment change — a reused instance leaks the prior conflict's choice. |
Deferred to PT-4030 — the list integration should key the card by comment id (not a presentational-card concern). |
| M4 | w-32 clips long localized selector values (no tooltip fallback). |
Fixed — min-w-32 keeps the stable width without clipping. |
| M5 | Missing pr-twp on the root element. |
Dismissed — the whole comment-list family omits it and the card only renders inside CommentList; matching the family is correct. |
| M6 | 4th verbatim copy of the jsdom NoopResizeObserver/pointer stubs across test files. |
Deferred to a follow-up — a shared setupFiles touches 4 test files + vitest config, out of scope here. |
| M7 | "captured verbatim / do not hand-edit" JSDoc contradicted by hand-renamed id/thread. |
Fixed — JSDoc now scopes the verbatim claim to the HTML/USFM fields and notes the readability rename. |
The workflow also surfaced ~6 additional lowest-severity cleanup candidates that fell under its 15-decision cap; the ones that mattered are captured above (I9, M6).
Positive observations
PT9 anchoring verified against real PT9 source (region order, accepted-region gating, default 'accept', permission gating); purely additive public API with all referenced types exported; DOMPurify on every HTML injection; RTL-safe logical properties; redundant color encoding (semibold/strikethrough) for color-blind users; strong verified-green tests including a real Radix Select interaction; sample data cross-anchored to a matching C# fixture + converter test.
Suggested reviewer focus
- The I1 gate change (drop
&& !!resultText) — confirm you agree it's the right PT9-parity call; the spike concluded the blank-resultTextstate is unproducible from real merges. - The new
--diff-deletedtheme token — verify the red-600/red-400 values and that nothing else expected<s>to use--destructive. - The localization key rename (
%conflictNote_*%, camelCase) — effectively immutable once PT-4030 ships values.
Automated review by Claude Fable 5 (analysis) and Claude Opus 4.8 (spike + fixes).
9f0c52a to
af2987e
Compare
…PT9) Gate the structured verseText render on resultText presence (not acceptedText), matching PT9's per-block conditional. When acceptedText is absent (no common ancestor conflict), the Accepted region is omitted but the selector, Rejected region, and Result preview all remain functional. Adds a test for this case. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ey9TG13YAyRhgqmKEk8BC
…oose aria-label Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ey9TG13YAyRhgqmKEk8BC
…estor, restricted) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ey9TG13YAyRhgqmKEk8BC
Address final-review minors: - Export ConflictNoteCard, CONFLICT_NOTE_STRING_KEYS, and its prop types from platform-bible-react's index.ts so the extension can consume the card and pass the string keys to useLocalizedStrings (unblocks PT-4030 integration). - Gate the Accepted region on `!!acceptedText` (was `!== undefined`) for consistency with the resultText gate and empty-string safety. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Diff highlight now matches PT9's default RedGreen style (by insertion/deletion,
same in both regions): inserted text green (text-success-foreground) + bold,
deleted text red (text-destructive) + strikethrough. Theme tokens only.
- Result preview rendered in normal text-foreground (was muted) — it's the
most important line ('how the verse will read'), so no longer de-emphasized.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add CreateVerseTextConflictCommentWithReplacement fixture (town→village/city), a converter test that asserts both <s> (deletion) and <u> (insertion) appear in rejectedText/acceptedText, and the verbatim-captured verseTextConflictReplacementSample for the ConflictNoteCard Default story — making struck-red text visible in Storybook. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ey9TG13YAyRhgqmKEk8BC
…tyle comment - trimDiffSpanWhitespace: move each diff span's trailing space outside </s>/</u> so the strikethrough/color stops at the word instead of dangling into the inter-word gap. PT9 keeps the space inside the diff token and masks it with a background highlight; our text-only decoration would otherwise show a stray strike over the space. + regression test. - Correct the code comment: our green/red is a text-color treatment, NOT PT9's default (BlueGray) or RedGreen (both use background highlights; RedGreen has no strikethrough). Insertions now semibold (was medium, while the comment said bold). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rder) PT9's AppendConflictNoteDetails renders the accepted/current text first, then the rejected side. Leading with the winning text reads more naturally given Accept is the default resolution. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves the /code-review max + /review-paratext findings on PT-4026: - Gate the resolution UI on conflictType alone (PT9 parity) and degrade gracefully for absent result/rejected fields (empty-state, gated Rejected region) instead of hiding the whole card. - Add a per-scheme --diff-deleted theme token so deletion (<s>) text meets contrast in the Platform dark theme; sync themes.data.json. - Align the localization contract to the guide (frozen tuple, derived type, optional prop) and rename keys to camelCase %conflictNote_*%. - Announce the Result preview via aria-live; share prose classes with CommentItem; guard controlled-mode internal state; min-w selector. - Rename the both-sides C# replacement fixture/test and the TS sample. - Regenerate platform-bible-react dist; add edge-case + regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JudLsqrCKzyxk2a3GmrG3A
…d contract Folded here from the PT-4028 stack base (#2482, now merged) so that PR could merge un-rebased; these correct code #2482 introduced. - perf: PlatformCommentThreadWrapper.RootCommentId is read once per comment (and several more times per conflict root), so recomputing its scan on every read made serializing an n-comment thread O(n^2). Memoize it once per thread wrapper (invalidated on dedup merge) and use MinBy instead of OrderBy().First() (O(n), identical first-on-ties tie-break). - test: Serialize_VerseTextConflict_IncludesRejectedAcceptedResultText asserted key PRESENCE only and read values through the wrapper getters (bypassing the converter), so a converter key<->value mis-binding would ship green. Assert each serialized value equals its getter. - docs: LegacyComment TSDoc said these fields live on the "first comment"; after thread-fragment dedup the root is not necessarily comments[0]. Document that they live on the root (earliest-date) comment and how to locate it. Skipped the fourth follow-up finding (verse/resultText blank divergence): verified unreachable for genuine merges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014CKj4FJgk9NrFtPioQbiGD
…eld doc updates Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014CKj4FJgk9NrFtPioQbiGD
Pre-existing line over the 100-col limit at the CreateVerseTextConflictCommentReplacementBothSides call site; surfaced while folding the review fixes onto this branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014CKj4FJgk9NrFtPioQbiGD
…to main The rebase auto-resolved the generated dist bundles by taking the branch side; rebuilt platform-bible-utils + -react so dist reflects the merged source (main + ConflictNoteCard). Source (src/index.ts barrel) merged cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ZurpFvP3QbVr71jzdtwKA
af2987e to
063d203
Compare
lyonsil
left a comment
There was a problem hiding this comment.
Follow-up review pass (automated multi-agent review, human-curated). A few non-blocking notes inline — one correctness edge case around the Result empty-state, the rest test-coverage / cleanup / consistency. None block merge for this PR's presentation-only scope.
lyonsil
left a comment
There was a problem hiding this comment.
Claude found a few things, and I didn't see anything else in my scan of the changes. I ignored some of the Claude findings and only posted the ones that seemed potentially valid. None of them are really serious, but the ones I posted seem considering at least.
@lyonsil reviewed 22 files and all commit messages, and made 1 comment.
Reviewable status: all files reviewed, 6 unresolved discussions (waiting on rolfheij-sil).
Follow-up review pass on PR #2484. Non-blocking items: - Result empty-state no longer asserts "The verse will be empty." It now reads a neutral "No result preview available." The chosen side's result USFM can be absent for two indistinguishable causes (the outcome decodes to an empty verse, or the note carries no decodable diff, per the rejectedResultText TSDoc), so the card must not assert an emptiness it cannot confirm. Renamed the key %conflictNote_resultEmpty% to %conflictNote_resultUnavailable% to match; values are not yet shipped (PT-4030 contributes them). - Extracted DiffRegion, collapsing the three duplicated dangerouslySetInnerHTML + eslint-disable blocks into one audited sanitize+inject sink. Sanitize/trim now run inside it, so a card only parses the regions it actually renders (no discarded DOMPurify parse). - Applied ConflictNoteCardLocalizedStrings to the localizedStrings prop and re-exported it from index.ts, so consumers get key-level type safety instead of the loose LanguageStrings. - Gave the description key a sentinel test value plus an assertion, so a dropped or mistyped localization key fails the test instead of silently falling through to the fallback and staying green. Declined the symmetric --diff-inserted token (defensible asymmetry, cosmetic). typecheck / eslint / prettier / unit 11-11 all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjmXr4Qf6zHm37WCVCFQNj
Rebuild after the review-feedback source changes: adds the ConflictNoteCardLocalizedStrings export and the renamed %conflictNote_resultUnavailable% key to the tracked build output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjmXr4Qf6zHm37WCVCFQNj
…d type Follow-up to the review note that CommentListLocalizedStrings was defined but inert. COMMENT_LIST_STRING_KEYS was typed LocalizeKey[], so (typeof KEYS)[number] collapsed to LocalizeKey and the mapped type was effectively LanguageStrings (no key-level safety), and the type was not re-exported, so consumers could not import it. - Froze the keys as a const tuple (Object.freeze([...] as const)), matching the sibling components, so the derived type carries the literal key union. - Re-exported CommentListLocalizedStrings from index.ts. Left the three localizedStrings props as LanguageStrings on purpose: the CommentList family indexes keys without fallbacks, so a required mapped type breaks internal access, while an optional one rejects the index-signature LanguageStrings that useLocalizedStrings returns (TS2740). The sibling components (e.g. comment-editor) resolve this the same way: export the type for consumers, keep the permissive prop. Both consumers of the keys (legacy-comment-manager) funnel through Array.from, so the readonly tuple is transparent to them. lib typecheck / eslint / prettier / comment unit tests (17) all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjmXr4Qf6zHm37WCVCFQNj
Rebuild after making CommentListLocalizedStrings a real exported type: adds the export and the readonly-tuple COMMENT_LIST_STRING_KEYS to the tracked build output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjmXr4Qf6zHm37WCVCFQNj
lyonsil
left a comment
There was a problem hiding this comment.
@lyonsil reviewed 13 files and all commit messages, made 1 comment, and resolved 6 discussions.
Reviewable status:complete! all files reviewed, all discussions resolved.
Brings the updated pt-4029 (now merged with main, which includes PT-4026 #2484's original conflict-note card) into pt-4030. Conflict resolution — pt-4030's conflict-note-card is the later iteration (Accept/Reject/Merge + live resolve) and supersedes #2484's, so pt-4030's version was kept for the card component/types/stories/tests, comment-item, sample data, and the C# test fixtures. What #2484 had that was dropped (nothing lost silently): - CreateVerseTextConflictCommentReplacementBothSides + its converter test have identical pt-4030 equivalents under a different name (...WithReplacement) — no coverage lost. - ConflictNoteCardLocalizedStrings — superseded by pt-4030's generic LanguageStrings. - COMMENT_BODY_PROSE_CLASSES — main's shared prose-class constant is left unused for now (pt-4030 keeps inline classes; identical rendering). Adopting it resolves the M8 prose-class duplication — tracked as a follow-up. - Both libraries' dist regenerated from the merged source. Verified: platform-bible-react tsc/build clean; comment-list tests 66/66; c-sharp-tests 1382 pass. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_015hfnmegSuB5vVmfNCvAtqk
What & why
Part of epic PT-4024 (Donna resolves merge conflicts), stacked on #2482 (the PT-4028 read-contract — this PR's base is
pt-4028-conflict-note-fields). It adds the presentational conflict-resolution card so a translator can see what is rejected, what is accepted, and how the verse will read after their choice (NN3), and accept or reject it (NN4).ConflictNoteCard— a body fragment in the sharedcomment-listlib — renders:conflictType-keyed description,<u>/<s>markup, colored via theme tokens),resultTexton accept,rejectedResultTexton reject.PT9-anchored behavior
Matches PT9's
AppendConflictNoteDetails: the structured card renders whenconflictType === 'verseText' && !!resultText; the Accepted region is shown only whenacceptedTextis present. So no-common-ancestor conflicts — two translators independently drafting a previously-absent verse, which have no accepted-side diff — still get the full resolution UI, just without the Accepted panel (the selector + dynamic Result preview still work). Genuinely non-verseTextnotes fall back to the existingcontentsrender.The dynamic Result preview relies on
rejectedResultText(the loser's plain USFM via PT9's publicGetDiffVerseUsfm), which was added to the read-contract in the base PR #2482 (along with a no-ancestor converter test and softenedacceptedTextdocs).Scope
onResolutionChange;canAcceptRejectgates the selector.verseTextconflicts only (v1).ConflictNoteCard+CONFLICT_NOTE_STRING_KEYSare exported fromplatform-bible-reactfor that ticket.Testing
Selectinteraction firingonResolutionChange), the no-ancestor case (Accepted omitted, selector + Result still work), non-verseTextfallback, disabled selector, and read-only Result.Advanced/ConflictNoteCard): Default, NoAncestor, RestrictedPermissions. Sample field values are captured verbatim from the PT-4028 C# converter — not hand-written.🤖 Generated with Claude Code
This change is
Review summary (
/code-review max+/review-paratext, 2026-07-03)Reviewed with a max-effort multi-agent
/code-review(32 agents → 15 distinct defects) plus the four/review-paratextanalyzers. All Critical and Important findings were fixed in-review (commit9f0c52a). Branch is green: typecheck, lint,format:base:check, unit 230/230, Storybook 3/3, C#23/23. Full findings (incl. dismissed/deferred) are in the GitHub review on this PR.
Key changes made in review:
conflictTypealone; gate the Rejected region on!!rejectedText(like Accepted); neutral empty-state in the Result preview when the chosen side hasno result USFM.
platform-bible-reactdist/(new exports were missing);fixed the prettier failure that would have red-CI'd the branch.
--diff-deletedtheme token (deletion text wasbackground-grade
--destructive, failing AA in Platform dark).as constkey tuple + derived type + optionallocalizedStringsprop(guide-conforming); keys renamed to camelCase
%conflictNote_*%before PT-4030 locks values in.aria-liveon the Result preview; shared prose classes withCommentItem;controlled-mode state-leak guard;
min-wselector; renamed the both-sides C# fixture/test/sample;added edge-case + regression tests (11 unit tests).
Dismissed: whitespace-only-diff invisibility (degenerate conflict); missing
pr-twp(familyconsistency). Deferred to PT-4030: reset uncontrolled state on
commentchange (key the card bycomment id) + contributing the 9
%conflictNote_*%string values. Follow-up: shared jsdom testsetup (4th copy).