From 2c84838ba312ce28d11b2efc9e6e7896abb2996a Mon Sep 17 00:00:00 2001 From: Joshua Lansford Date: Tue, 7 Jul 2026 15:41:15 -0400 Subject: [PATCH 1/5] docs(flags): correct fetchFeatureFlags comment to reflect login-gated endpoint --- src/features/flags/useFeatureFlags.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/features/flags/useFeatureFlags.ts b/src/features/flags/useFeatureFlags.ts index abc3414..87bf36f 100644 --- a/src/features/flags/useFeatureFlags.ts +++ b/src/features/flags/useFeatureFlags.ts @@ -26,8 +26,10 @@ const FLAGS_STALE_TIME_MS = 5 * 60 * 1000; // 5 minutes /** GET the published feature map from fluent-api. Follows the fetch pattern in * `features/bible/hooks/useBibleTarget.ts` (plain fetch + credentials). The - * endpoint is unauthenticated (proposal Q1), but we still send credentials for - * consistency and so it keeps working if the reviewer decides to gate it. */ + * endpoint is login-gated (proposal Q1, reviewer-confirmed 2026-07-07), so + * `credentials: 'include'` sends the session cookie; a not-yet-authenticated + * request gets a 401, which throws below → the query fails closed (every flag + * off) until the session + flags resolve, keeping gated UI hidden. */ export const fetchFeatureFlags = async (): Promise => { const res = await fetch(`${config.api.url}/config/features`, { method: 'GET', From 49a17a10a4fe6caff221b2a6c9f3b89096147ef2 Mon Sep 17 00:00:00 2001 From: Joshua Lansford Date: Tue, 7 Jul 2026 15:41:17 -0400 Subject: [PATCH 2/5] feat(checks): add pure verse-window util for in-card repeated-word highlight Task 1 of the highlight-repeated-word-in-verse follow-on. Adds a pure, framework-free windowing util plus its single constants module and unit tests. No call sites changed. - verse-window.constants.ts: single frozen VERSE_WINDOW config (context chars before/after + bidirectional maxSpaceSearchDistance). - verse-window.ts: buildVerseWindow(verseText, matchStart, matchLength) returns { before, match, after, truncatedStart, truncatedEnd }. Snaps each cut to the nearest whitespace/verse-boundary within the +/- radius, strips whole whitespace runs, and hard-cuts when no candidate is in range (space-less-script safe). Surf-agnostic: match is always derived from verseText.slice. - verse-window.test.ts: 24 cases covering short/long verses, nearest-snap and tie-breaks, boundary candidates, whitespace-run stripping, match at verse edges, surf-agnostic slicing, and defensive clamps. --- .../highlight/verse-window.constants.ts | 21 ++ .../checks/highlight/verse-window.test.ts | 349 ++++++++++++++++++ src/features/checks/highlight/verse-window.ts | 180 +++++++++ 3 files changed, 550 insertions(+) create mode 100644 src/features/checks/highlight/verse-window.constants.ts create mode 100644 src/features/checks/highlight/verse-window.test.ts create mode 100644 src/features/checks/highlight/verse-window.ts diff --git a/src/features/checks/highlight/verse-window.constants.ts b/src/features/checks/highlight/verse-window.constants.ts new file mode 100644 index 0000000..c638976 --- /dev/null +++ b/src/features/checks/highlight/verse-window.constants.ts @@ -0,0 +1,21 @@ +/** + * Tunables for the in-card verse-context window around a repeated-word match + * (Task 1). Centralized by requirement: there must be exactly ONE place these + * live — no magic numbers scattered in the util or the card. Not user-facing in + * v1; a future change is a single edit here. + */ +export const VERSE_WINDOW = Object.freeze({ + /** Characters of context to include BEFORE the match start (raw cut, before snapping). */ + contextCharsBefore: 26, + /** Characters of context to include AFTER the match end (raw cut, before snapping). */ + contextCharsAfter: 26, + /** + * Radius (in characters) to search in BOTH directions (±) around a raw char cut + * when snapping to the nearest whitespace OR verse boundary (position 0 / length). + * If no candidate falls within this radius, hard-cut at the raw char offset + * (handles space-less scripts gracefully). MUST stay strictly smaller than both + * contextCharsBefore and contextCharsAfter so a snap can never cross into the + * match (this is what lets buildVerseWindow skip a match-crossing fallback). + */ + maxSpaceSearchDistance: 10, +} as const); diff --git a/src/features/checks/highlight/verse-window.test.ts b/src/features/checks/highlight/verse-window.test.ts new file mode 100644 index 0000000..c1205a2 --- /dev/null +++ b/src/features/checks/highlight/verse-window.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, it } from 'vitest'; + +import { buildVerseWindow } from './verse-window'; +import { VERSE_WINDOW } from './verse-window.constants'; + +const { contextCharsBefore, contextCharsAfter, maxSpaceSearchDistance } = VERSE_WINDOW; + +/** + * Tests are written against the *centralized* constants (imported above) rather + * than hardcoded 26/26/10, so tuning the numbers in one place does not break the + * suite — matching the spec's "the numbers are a starting point; the point of the + * module is centralization" note. A few structural sanity checks below assert the + * load-bearing invariant (radius strictly < both context budgets). + */ + +describe('VERSE_WINDOW constants invariant', () => { + it('keeps the ± search radius strictly smaller than both context budgets', () => { + // This is the property that lets buildVerseWindow skip a match-crossing guard. + expect(maxSpaceSearchDistance).toBeLessThan(contextCharsBefore); + expect(maxSpaceSearchDistance).toBeLessThan(contextCharsAfter); + }); + + it('is frozen (single source of truth, not mutable at runtime)', () => { + expect(Object.isFrozen(VERSE_WINDOW)).toBe(true); + }); +}); + +describe('buildVerseWindow — short verse fits entirely', () => { + it('reconstructs the whole verse with no truncation on either side', () => { + // Verse shorter than the budget on both sides of the match. + const verse = 'a the the b'; + const matchStart = verse.indexOf('the the'); // 2 + const result = buildVerseWindow(verse, matchStart, 'the the'.length); + + expect(result.match).toBe('the the'); + expect(result.truncatedStart).toBe(false); + expect(result.truncatedEnd).toBe(false); + // Whole verse reconstructs (nothing was cut, so no boundary whitespace stripped). + expect(result.before + result.match + result.after).toBe(verse); + }); +}); + +describe('buildVerseWindow — long verse, cut both ends', () => { + it('truncates both ends; match is exact; before/after within budget (+ radius)', () => { + // Build a long verse of single-char words separated by spaces so there is + // always a nearby space to snap to, guaranteeing real cuts on both ends. + const filler = Array.from({ length: 60 }, (_, i) => (i % 2 === 0 ? 'x' : 'y')).join(' '); + const verse = `${filler} the the ${filler}`; + const matchStart = verse.indexOf('the the'); + const result = buildVerseWindow(verse, matchStart, 'the the'.length); + + expect(result.truncatedStart).toBe(true); + expect(result.truncatedEnd).toBe(true); + expect(result.match).toBe('the the'); + expect(result.match).toBe(verse.slice(matchStart, matchStart + 'the the'.length)); + // before/after are within budget plus the outward snap radius. + expect(result.before.length).toBeLessThanOrEqual(contextCharsBefore + maxSpaceSearchDistance); + expect(result.after.length).toBeLessThanOrEqual(contextCharsAfter + maxSpaceSearchDistance); + // No leading/trailing whitespace after snapping. + expect(result.before).toBe(result.before.replace(/^\s+/, '')); + expect(result.after).toBe(result.after.replace(/\s+$/, '')); + }); +}); + +describe('buildVerseWindow — snap to nearest space', () => { + it('before starts at a word boundary and after ends at a word boundary', () => { + const filler = Array.from({ length: 60 }, () => 'word').join(' '); + const verse = `${filler} the the ${filler}`; + const matchStart = verse.indexOf('the the'); + const result = buildVerseWindow(verse, matchStart, 'the the'.length); + + // No partial leading/trailing word fragments. + expect(/^\s/.test(result.before)).toBe(false); + expect(/\s$/.test(result.after)).toBe(false); + expect(result.before.startsWith('word')).toBe(true); + expect(result.after.endsWith('word')).toBe(true); + }); + + it('picks the NEARER space when spaces sit on both sides of the raw cut', () => { + // Place the match far enough that rawStart lands inside a controlled region. + // Construct: [prefix of length matchStart][MATCH]. rawStart = matchStart - contextCharsBefore. + // Put a space 1 char before rawStart and another 3 chars after it → nearer is the -1 one. + const matchStart = contextCharsBefore + 20; // comfortably interior + const rawStart = matchStart - contextCharsBefore; // = 20 + const prefixChars = Array.from({ length: matchStart }, () => 'a'); + prefixChars[rawStart - 1] = ' '; // distance 1 (before rawStart) + prefixChars[rawStart + 3] = ' '; // distance 3 (after rawStart) + const verse = `${prefixChars.join('')}the the${'b'.repeat(60)}`; + const result = buildVerseWindow(verse, matchStart, 'the the'.length); + + // Nearer space is at rawStart-1 → after stripping that single-space run, + // windowStart = rawStart, so before begins at index rawStart. + expect(result.truncatedStart).toBe(true); + expect(verse.startsWith(result.before, rawStart)).toBe(true); + // The chosen (nearer) space is the one at rawStart-1, not rawStart+3. + expect(result.before[0]).toBe('a'); // char at rawStart is 'a', not a space + }); + + it('breaks an equidistant START tie toward the outward/earlier space', () => { + const matchStart = contextCharsBefore + 20; + const rawStart = matchStart - contextCharsBefore; // 20 + const d = 2; // equidistant spaces at rawStart-2 and rawStart+2 + const prefixChars = Array.from({ length: matchStart }, () => 'a'); + prefixChars[rawStart - d] = ' '; + prefixChars[rawStart + d] = ' '; + const verse = `${prefixChars.join('')}the the${'b'.repeat(60)}`; + const result = buildVerseWindow(verse, matchStart, 'the the'.length); + + // Tie → outward/earlier = the space at rawStart-d. Its single-space run is + // stripped, so windowStart = rawStart - d + 1. + const expectedWindowStart = rawStart - d + 1; + expect(verse.startsWith(result.before, expectedWindowStart)).toBe(true); + expect(result.before.length).toBe(matchStart - expectedWindowStart); + }); + + it('breaks an equidistant END tie toward the outward/later space', () => { + const matchStart = 60; + const matchLen = 'the the'.length; + const matchEnd = matchStart + matchLen; + const rawEnd = matchEnd + contextCharsAfter; + const d = 2; + const suffixLen = contextCharsAfter + 40; + const suffixChars = Array.from({ length: suffixLen }, () => 'a'); + // Indices in suffixChars are offset by matchEnd in the full verse. + suffixChars[rawEnd - matchEnd - d] = ' '; + suffixChars[rawEnd - matchEnd + d] = ' '; + const verse = `${'b'.repeat(matchStart)}the the${suffixChars.join('')}`; + const result = buildVerseWindow(verse, matchStart, matchLen); + + // Tie → outward/later = the space at rawEnd+d. Its single-space run is + // stripped, so windowEnd = rawEnd + d (exclusive), giving after up to that. + const expectedWindowEnd = rawEnd + d; + expect(result.after).toBe(verse.slice(matchEnd, expectedWindowEnd)); + expect(/\s$/.test(result.after)).toBe(false); + }); +}); + +describe('buildVerseWindow — no space within radius (space-less script sim)', () => { + it('hard-cuts at the raw offset; both ends truncated; window == budget', () => { + // A long run of non-space chars around the match, far from any boundary. + const matchStart = 200; + const matchLen = 4; + const verse = 'x'.repeat(matchStart) + 'MMMM' + 'y'.repeat(200); + const result = buildVerseWindow(verse, matchStart, matchLen); + + expect(result.match).toBe('MMMM'); + expect(result.truncatedStart).toBe(true); + expect(result.truncatedEnd).toBe(true); + // Hard cut → exactly the budget on each side. + expect(result.before.length).toBe(contextCharsBefore); + expect(result.after.length).toBe(contextCharsAfter); + }); +}); + +describe('buildVerseWindow — verse boundary as an in-range candidate', () => { + it('snaps to verse START (0) with no ellipsis when 0 is nearest and no closer space', () => { + // rawStart within radius of 0 and no whitespace nearer than the boundary. + const matchStart = contextCharsBefore - Math.floor(maxSpaceSearchDistance / 2); + // Ensure matchStart is positive and rawStart in [-radius, 0]-ish window. + expect(matchStart).toBeGreaterThan(0); + const verse = 'z'.repeat(matchStart) + 'the the' + 'z'.repeat(80); + const result = buildVerseWindow(verse, matchStart, 'the the'.length); + + expect(result.truncatedStart).toBe(false); + // before begins at the verse start. + expect(result.before).toBe(verse.slice(0, matchStart)); + }); + + it('snaps to verse END (length) with no ellipsis when end is nearest and no closer space', () => { + const matchLen = 'the the'.length; + const tailLen = contextCharsAfter - Math.floor(maxSpaceSearchDistance / 2); + expect(tailLen).toBeGreaterThan(0); + const matchStart = 80; + const verse = 'z'.repeat(matchStart) + 'the the' + 'z'.repeat(tailLen); + const result = buildVerseWindow(verse, matchStart, matchLen); + + expect(result.truncatedEnd).toBe(false); + expect(result.after).toBe(verse.slice(matchStart + matchLen)); + }); +}); + +describe('buildVerseWindow — whitespace-run stripping', () => { + it('strips an entire multi-char whitespace run at the START cut', () => { + const matchStart = contextCharsBefore + 20; + const rawStart = matchStart - contextCharsBefore; // 20 + const prefixChars = Array.from({ length: matchStart }, () => 'a'); + // A two-char run " " (space+space) right at rawStart. + prefixChars[rawStart] = ' '; + prefixChars[rawStart + 1] = ' '; + const verse = `${prefixChars.join('')}the the${'b'.repeat(60)}`; + const result = buildVerseWindow(verse, matchStart, 'the the'.length); + + expect(result.truncatedStart).toBe(true); + // Entire run stripped → before begins after both spaces (index rawStart+2). + expect(/^\s/.test(result.before)).toBe(false); + expect(result.before).toBe(verse.slice(rawStart + 2, matchStart)); + }); + + it('strips an entire space+tab whitespace run at the END cut', () => { + const matchStart = 60; + const matchLen = 'the the'.length; + const matchEnd = matchStart + matchLen; + const rawEnd = matchEnd + contextCharsAfter; + const suffixLen = contextCharsAfter + 40; + const suffixChars = Array.from({ length: suffixLen }, () => 'a'); + const off = rawEnd - matchEnd; + // A two-char run " \t" ending at the raw cut region. + suffixChars[off] = ' '; + suffixChars[off + 1] = '\t'; + const verse = `${'b'.repeat(matchStart)}the the${suffixChars.join('')}`; + const result = buildVerseWindow(verse, matchStart, matchLen); + + expect(result.truncatedEnd).toBe(true); + expect(/\s$/.test(result.after)).toBe(false); + // after ends before the whitespace run began (index matchEnd+off). + expect(result.after).toBe(verse.slice(matchEnd, matchEnd + off)); + }); +}); + +describe('buildVerseWindow — match at verse boundaries', () => { + it('match at verse start → truncatedStart false, before empty', () => { + const verse = 'the the' + ' rest of a much longer verse '.repeat(5); + const result = buildVerseWindow(verse, 0, 'the the'.length); + expect(result.truncatedStart).toBe(false); + expect(result.before).toBe(''); + expect(result.match).toBe('the the'); + }); + + it('match at verse end → truncatedEnd false, after empty', () => { + const prefix = 'a much longer verse leading up to the pair '.repeat(3); + const verse = `${prefix}the the`; + const matchStart = verse.length - 'the the'.length; + const result = buildVerseWindow(verse, matchStart, 'the the'.length); + expect(result.truncatedEnd).toBe(false); + expect(result.after).toBe(''); + expect(result.match).toBe('the the'); + }); +}); + +describe('buildVerseWindow — surf-agnostic match slice', () => { + it('match is always verseText.slice(matchStart, matchStart + matchLength) regardless of casing', () => { + // The verse has lowercase "the the"; a caller could pass a Title-cased surf + // length — the util never consults surf text, only its length. + const verse = 'context before the the context after here padding padding'; + const matchStart = verse.indexOf('the the'); + const surfLen = 'The The'.length; // same length, different casing — irrelevant to the util + const result = buildVerseWindow(verse, matchStart, surfLen); + expect(result.match).toBe(verse.slice(matchStart, matchStart + surfLen)); + expect(result.match).toBe('the the'); + }); + + it('matchLength === 0 → empty match; windowing still behaves around the point', () => { + const filler = Array.from({ length: 60 }, () => 'word').join(' '); + const verse = `${filler} PIVOT ${filler}`; + const matchStart = verse.indexOf('PIVOT'); + const result = buildVerseWindow(verse, matchStart, 0); + expect(result.match).toBe(''); + // Collapsed point: before ends and after begins at the same index. + expect(verse.startsWith(result.before, matchStart - result.before.length)).toBe(true); + expect(result.after).toBe(verse.slice(matchStart, matchStart + result.after.length)); + expect(result.truncatedStart).toBe(true); + expect(result.truncatedEnd).toBe(true); + }); +}); + +describe('buildVerseWindow — defensive clamps', () => { + it('matchStart beyond length does not throw; collapses to end', () => { + const verse = 'a short verse'; + const result = buildVerseWindow(verse, 9999, 5); + expect(result.match).toBe(''); + expect(result.match).toBe(verse.slice(verse.length, verse.length)); + // Reached both boundaries (start budget spans whole short verse). + expect(result.after).toBe(''); + }); + + it('negative matchStart is treated as 0', () => { + const verse = 'the the and more text follows here for context padding'; + const result = buildVerseWindow(verse, -50, 'the the'.length); + expect(result.match).toBe(verse.slice(0, 'the the'.length)); + expect(result.match).toBe('the the'); + expect(result.truncatedStart).toBe(false); + expect(result.before).toBe(''); + }); + + it('matchLength overshooting the end clamps to verseText.length', () => { + const verse = 'lead in text then the the'; + const matchStart = verse.indexOf('the the'); + const result = buildVerseWindow(verse, matchStart, 9999); + // matchEnd clamped to length → match is the rest of the verse. + expect(result.match).toBe(verse.slice(matchStart)); + expect(result.after).toBe(''); + expect(result.truncatedEnd).toBe(false); + }); + + it('empty verseText returns all-empty, no truncation', () => { + const result = buildVerseWindow('', 0, 5); + expect(result).toEqual({ + before: '', + match: '', + after: '', + truncatedStart: false, + truncatedEnd: false, + }); + }); + + it('non-finite offsets do not throw; both are treated as 0 per spec', () => { + const verse = 'the the with plenty of trailing context to look at here'; + // Per the algorithm, a non-finite matchStart AND matchLength both become 0, + // so the match collapses to empty at position 0 (not "fills the verse"). + const result = buildVerseWindow(verse, Number.NaN, Number.POSITIVE_INFINITY); + expect(result.match).toBe(''); + expect(result.before).toBe(''); // matchStart 0 → reached verse start, no cut + expect(result.truncatedStart).toBe(false); + }); + + it('a finite over-long matchLength from position 0 fills the whole verse', () => { + const verse = 'the the with plenty of trailing context to look at here'; + const result = buildVerseWindow(verse, 0, verse.length + 100); + expect(result.match).toBe(verse); + expect(result.before).toBe(''); + expect(result.after).toBe(''); + expect(result.truncatedStart).toBe(false); + expect(result.truncatedEnd).toBe(false); + }); +}); + +describe('buildVerseWindow — relaxed invariant (post-whitespace-strip)', () => { + it('match is the exact slice and before/after are in-verse substrings within budget', () => { + const filler = Array.from({ length: 80 }, () => 'lorem').join(' '); + const verse = `${filler} the the ${filler}`; + const matchStart = verse.indexOf('the the'); + const matchLen = 'the the'.length; + const result = buildVerseWindow(verse, matchStart, matchLen); + + // Exact match slice. + expect(result.match).toBe(verse.slice(matchStart, matchStart + matchLen)); + // before/after are substrings of the verse. + expect(verse.includes(result.before)).toBe(true); + expect(verse.includes(result.after)).toBe(true); + // Within budget (+ radius). + expect(result.before.length).toBeLessThanOrEqual(contextCharsBefore + maxSpaceSearchDistance); + expect(result.after.length).toBeLessThanOrEqual(contextCharsAfter + maxSpaceSearchDistance); + // The window never crosses the match (defensive-clamp invariant). + expect(verse.slice(matchStart - result.before.length, matchStart)).toBe(result.before); + expect(verse.slice(matchStart + matchLen, matchStart + matchLen + result.after.length)).toBe( + result.after + ); + }); +}); diff --git a/src/features/checks/highlight/verse-window.ts b/src/features/checks/highlight/verse-window.ts new file mode 100644 index 0000000..f4d8905 --- /dev/null +++ b/src/features/checks/highlight/verse-window.ts @@ -0,0 +1,180 @@ +import { VERSE_WINDOW } from './verse-window.constants'; + +export interface VerseWindow { + /** + * Text shown before the highlighted match (already windowed/snapped; any + * whitespace run at the snapped left cut is stripped, so no leading whitespace). + */ + before: string; + /** + * The highlighted match text itself — ALWAYS `verseText.slice(matchStart, matchEnd)`. + * Derived from the verse text, never from any `surf` string (surf is passed only as a + * length by the caller in Task 2). + */ + match: string; + /** + * Text shown after the highlighted match (already windowed/snapped; any + * whitespace run at the snapped right cut is stripped, so no trailing whitespace). + */ + after: string; + /** True iff a real (non-boundary) cut happened on the LEFT end (caller renders a leading `…`). */ + truncatedStart: boolean; + /** True iff a real (non-boundary) cut happened on the RIGHT end (caller renders a trailing `…`). */ + truncatedEnd: boolean; +} + +/** Simple whitespace test (tabs/NBSP/etc.), per spec — not only U+0020. */ +const isWhitespace = (ch: string): boolean => /\s/.test(ch); + +/** + * Clamp a possibly-hostile numeric offset from an upstream service into + * `[0, max]`. Non-finite / negative → `0`. + */ +const clampOffset = (value: number, max: number): number => { + if (!Number.isFinite(value) || value < 0) return 0; + if (value > max) return max; + return value; +}; + +/** + * A snap candidate: an index within the ± radius of a raw cut, plus its + * distance from that raw cut. `isBoundary` marks the verse-start/verse-end + * candidate (position 0 / length) — reaching it is NOT a real cut. + */ +interface SnapCandidate { + index: number; + distance: number; + isBoundary: boolean; +} + +/** + * Pick the nearest candidate to `rawCut` (smallest `|index - rawCut|`), + * breaking ties toward the outward side. For the START cut the outward side is + * the smaller index (`preferSmaller = true`); for the END cut it is the larger + * index (`preferSmaller = false`). Returns `null` when no candidate exists. + */ +const pickNearest = (candidates: SnapCandidate[], preferSmaller: boolean): SnapCandidate | null => + candidates.reduce((best, c) => { + if (best === null) return c; + if (c.distance < best.distance) return c; + if (c.distance > best.distance) return best; + // Tie on distance → break toward the outward side. + return preferSmaller ? (c.index < best.index ? c : best) : c.index > best.index ? c : best; + }, null); + +/** + * Build a windowed, highlight-split view of `verseText` around the match at + * [matchStart, matchStart + matchLength). Pure. See verse-window.constants.ts + * for the tunables. Never throws; clamps/degrades on out-of-range input. + */ +export function buildVerseWindow( + verseText: string, + matchStart: number, + matchLength: number +): VerseWindow { + const { contextCharsBefore, contextCharsAfter, maxSpaceSearchDistance } = VERSE_WINDOW; + + // Step 1 — clamp inputs defensively; short-circuit empty verse. + if (verseText.length === 0) { + return { before: '', match: '', after: '', truncatedStart: false, truncatedEnd: false }; + } + const len = verseText.length; + const safeStart = clampOffset(matchStart, len); + const safeLength = clampOffset(matchLength, len - safeStart); + const matchEnd = safeStart + safeLength; + + // Step 2 — match slice always comes from the verse text (surf-agnostic). + const match = verseText.slice(safeStart, matchEnd); + + // Step 3 — raw window bounds. + const rawStart = safeStart - contextCharsBefore; + const rawEnd = matchEnd + contextCharsAfter; + + // Step 4 — snap the START cut. + let windowStart: number; + let truncatedStart: boolean; + if (rawStart <= 0) { + windowStart = 0; + truncatedStart = false; + } else { + const candidates: SnapCandidate[] = []; + // Whitespace indices within ± radius of rawStart (clamped to the verse). + const lo = Math.max(0, rawStart - maxSpaceSearchDistance); + const hi = Math.min(len - 1, rawStart + maxSpaceSearchDistance); + for (let i = lo; i <= hi; i++) { + if (isWhitespace(verseText[i])) { + candidates.push({ index: i, distance: Math.abs(i - rawStart), isBoundary: false }); + } + } + // Verse start (0) is a candidate iff it falls within the radius. + if (rawStart <= maxSpaceSearchDistance) { + candidates.push({ index: 0, distance: Math.abs(0 - rawStart), isBoundary: true }); + } + + const chosen = pickNearest(candidates, /* preferSmaller (outward) */ true); + if (chosen === null) { + // No candidate in radius → hard-cut at the raw offset. + windowStart = rawStart; + truncatedStart = true; + } else if (chosen.isBoundary) { + windowStart = 0; + truncatedStart = false; + } else { + // Whitespace candidate: skip the ENTIRE run starting there so `before` + // has no leading whitespace. + let ws = chosen.index; + while (ws < len && isWhitespace(verseText[ws])) ws++; + windowStart = ws; + truncatedStart = true; + } + } + + // Step 5 — snap the END cut (mirror of step 4). + let windowEnd: number; + let truncatedEnd: boolean; + if (rawEnd >= len) { + windowEnd = len; + truncatedEnd = false; + } else { + const candidates: SnapCandidate[] = []; + const lo = Math.max(0, rawEnd - maxSpaceSearchDistance); + const hi = Math.min(len - 1, rawEnd + maxSpaceSearchDistance); + for (let i = lo; i <= hi; i++) { + if (isWhitespace(verseText[i])) { + candidates.push({ index: i, distance: Math.abs(i - rawEnd), isBoundary: false }); + } + } + // Verse end (len) is a candidate iff it falls within the radius. + if (len - rawEnd <= maxSpaceSearchDistance) { + candidates.push({ index: len, distance: Math.abs(len - rawEnd), isBoundary: true }); + } + + const chosen = pickNearest(candidates, /* preferSmaller: outward is LARGER */ false); + if (chosen === null) { + windowEnd = rawEnd; + truncatedEnd = true; + } else if (chosen.isBoundary) { + windowEnd = len; + truncatedEnd = false; + } else { + // Whitespace candidate: skip the ENTIRE run ending there so `after` has + // no trailing whitespace. `windowEnd` becomes the index just before the + // run (exclusive end of the kept text). + let ws = chosen.index; + while (ws > 0 && isWhitespace(verseText[ws - 1])) ws--; + windowEnd = ws; + truncatedEnd = true; + } + } + + // Step 7 — defensive clamp (should never fire given contextChars* > radius): + // never let a snap cross into the match. + if (windowStart > safeStart) windowStart = safeStart; + if (windowEnd < matchEnd) windowEnd = matchEnd; + + // Step 6 — assemble. + const before = verseText.slice(windowStart, safeStart); + const after = verseText.slice(matchEnd, windowEnd); + + return { before, match, after, truncatedStart, truncatedEnd }; +} From 8ae957b85392c5b63a1f8b6770b55dd6399c2e5d Mon Sep 17 00:00:00 2001 From: Joshua Lansford Date: Tue, 7 Jul 2026 16:55:04 -0400 Subject: [PATCH 3/5] feat(checks): show the repeated word highlighted in its verse window on each check card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread an as-checked snt_id → verseText snapshot (hydrated onto the check result in useRepeatedWordsCheck, keyed by the shared buildSntId) through DraftingUI → ChecksPanel → FindingRow, and render the verse text windowed around the match via buildVerseWindow with the matched slice emphasized in place: red + bold on active cards, bold only on dimmed/ignored cards. Falls back to the bare finding.surf when the snapshot has no entry. Wire types untouched (web-only post-hydration layer). --- src/features/bible/components/DraftingUI.tsx | 17 ++ .../checks/components/ChecksPanel.test.tsx | 165 ++++++++++++++++-- .../checks/components/ChecksPanel.tsx | 16 +- .../checks/components/FindingRow.test.tsx | 136 ++++++++++++++- src/features/checks/components/FindingRow.tsx | 81 ++++++++- .../hooks/useRepeatedWordsCheck.test.ts | 33 ++++ .../checks/hooks/useRepeatedWordsCheck.ts | 37 +++- 7 files changed, 458 insertions(+), 27 deletions(-) diff --git a/src/features/bible/components/DraftingUI.tsx b/src/features/bible/components/DraftingUI.tsx index d34dc73..053dfed 100644 --- a/src/features/bible/components/DraftingUI.tsx +++ b/src/features/bible/components/DraftingUI.tsx @@ -37,6 +37,14 @@ import { DraftingGridVerse, DraftingTargetColumn } from './DraftingGridVerse'; import { DraftingHeader } from './DraftingHeader'; import { DraftingResourceSidebar } from './DraftingResourceSidebar'; +/** + * Stable empty snapshot for renders before the first check result settles — + * a module-level constant so the `ChecksPanel` prop reference doesn't change + * (and re-render) on every DraftingUI render. FindingRow falls back to + * `finding.surf` on a miss, so "empty" is always safe. + */ +const EMPTY_VERSE_TEXT_SNAPSHOT: ReadonlyMap = new Map(); + const RESOURCE_NAMES: ResourceName[] = [ { id: 'UWTranslationNotes', name: 'TN' }, { id: 'Images', name: 'Images & Maps' }, @@ -253,6 +261,14 @@ export const DraftingUI: React.FC = ({ globalRules, }); + // As-checked verse-text snapshot, hydrated onto the settled check result by + // useRepeatedWordsCheck (NOT derived from live `verses` — the findings' + // offsets are relative to the text as it was checked, so the two must travel + // together and update only when a new result arrives). `checkQuery.data` is + // referentially stable per settled result, so this reference only changes + // when new findings do. + const verseTextBySntId = checkQuery.data?.verseTextBySntId ?? EMPTY_VERSE_TEXT_SNAPSHOT; + // Active-finding count drives both notification dots; computed once here and // threaded to the tab and the closed-panel toggle button (S5, §6.4). When the // Checks feature is disabled it is forced to 0 so neither dot can flash while @@ -634,6 +650,7 @@ export const DraftingUI: React.FC = ({ globalIgnoresAvailable={globalIgnoresAvailable} isError={checkQuery.isError} resolved={resolved} + verseTextBySntId={verseTextBySntId} onIgnoreEverywhere={ignoreEverywhere} onIgnoreHere={ignoreHere} onStopIgnoringEverywhere={stopIgnoringEverywhere} diff --git a/src/features/checks/components/ChecksPanel.test.tsx b/src/features/checks/components/ChecksPanel.test.tsx index 8e5a404..cdd7df7 100644 --- a/src/features/checks/components/ChecksPanel.test.tsx +++ b/src/features/checks/components/ChecksPanel.test.tsx @@ -38,12 +38,23 @@ const handlers = () => ({ onStopIgnoringEverywhere: vi.fn(), }); -const renderPanel = (resolved: ResolvedFindings, over: Partial<{ isError: boolean }> = {}) => +/** + * Empty as-checked snapshot: the pre-existing grouping/toggle/wiring tests + * exercise the fallback path (rows render `finding.surf` bare), which keeps + * their text assertions unchanged. + */ +const emptySnapshot: ReadonlyMap = new Map(); + +const renderPanel = ( + resolved: ResolvedFindings, + over: Partial<{ isError: boolean; verseTextBySntId: ReadonlyMap }> = {} +) => renderWithProviders( ); @@ -105,7 +116,13 @@ describe('ChecksPanel — Show Ignored toggle', () => { inactive: [inactive('JDG 4:1', 'and and', 'occurrence')], }; const { user } = renderWithProviders( - + ); // Default OFF: the inactive snippet is not shown. @@ -127,7 +144,13 @@ describe('ChecksPanel — Show Ignored toggle', () => { inactive: [inactive('JDG 4:2', 'and and', 'occurrence')], }; const { user } = renderWithProviders( - + ); await user.click(screen.getByRole('switch', { name: 'Show Ignored' })); @@ -150,7 +173,13 @@ describe('ChecksPanel — Show Ignored toggle', () => { inactive: [inactive('JDG 4:1', 'and and', 'global')], }; const { user, unmount } = renderWithProviders( - + ); await user.click(screen.getByRole('switch', { name: 'Show Ignored' })); expect(screen.getByText('and and (in JDG 4:1)')).toBeInTheDocument(); @@ -158,7 +187,13 @@ describe('ChecksPanel — Show Ignored toggle', () => { // Fresh mount = fresh session: toggle is OFF again. renderWithProviders( - + ); expect(screen.queryByText('and and (in JDG 4:1)')).not.toBeInTheDocument(); }); @@ -188,7 +223,13 @@ describe('ChecksPanel — "Show Ignored" toggle visibility', () => { inactive: [inactive('JDG 4:1', 'the the', 'legitimate')], }; renderWithProviders( - + ); expect(screen.queryByTestId('ignored-section')).not.toBeInTheDocument(); }); @@ -201,7 +242,13 @@ describe('ChecksPanel — "Show Ignored" toggle visibility', () => { inactive: [inactive('JDG 4:1', 'the the', 'occurrence')], }; renderWithProviders( - + ); expect(screen.getByText('No issues found')).toBeInTheDocument(); expect(screen.queryByTestId('ignored-section')).not.toBeInTheDocument(); @@ -213,7 +260,13 @@ describe('ChecksPanel — "Show Ignored" toggle visibility', () => { inactive: [inactive('JDG 4:1', 'the the', 'occurrence')], }; const { user } = renderWithProviders( - + ); // Toggle is OFF: zero state is visible. expect(screen.getByText('No issues found')).toBeInTheDocument(); @@ -235,7 +288,13 @@ describe('ChecksPanel — "Show Ignored" toggle visibility', () => { ], }; const { user } = renderWithProviders( - + ); await user.click(screen.getByRole('switch', { name: 'Show Ignored' })); @@ -254,7 +313,13 @@ describe('ChecksPanel — callback wiring', () => { inactive: [], }; const { user } = renderWithProviders( - + ); await user.click(screen.getByRole('button', { name: 'Ignore Here' })); expect(h.onIgnoreHere).toHaveBeenCalledWith('JDG 4:1|the the|0'); @@ -268,10 +333,88 @@ describe('ChecksPanel — callback wiring', () => { inactive: [inactiveFinding], }; const { user } = renderWithProviders( - + ); await user.click(screen.getByRole('switch', { name: 'Show Ignored' })); await user.click(screen.getByRole('button', { name: 'Undo Ignore' })); expect(h.onUndo).toHaveBeenCalledWith(inactiveFinding); }); }); + +describe('ChecksPanel — verse-context snapshot threading', () => { + it('threads verseTextBySntId down so rows render the windowed highlight', () => { + // 'the the' at [4, 11) in the as-checked verse text for JDG 4:1. + const verse = 'And the the light was made'; + const finding: ResolvedFinding = { + ...active('JDG 4:1', 'the the'), + finding: { ...active('JDG 4:1', 'the the').finding, surf: 'the the', start_position: 4 }, + }; + renderPanel( + { active: [finding], inactive: [] }, + { verseTextBySntId: new Map([['JDG 4:1', verse]]) } + ); + const highlight = screen.getByTestId('verse-highlight'); + expect(highlight.textContent).toBe('the the'); + expect(highlight.parentElement?.textContent).toBe('And the the light was made'); + }); + + it('overlapping triple: two findings in one verse each render their own window', () => { + // "the the the" produces TWO findings (ordinals 0 and 1) with their own + // start_positions; each card highlights its own span independently. + const verse = 'And the the the light'; + const base = active('JDG 4:1', 'the the'); + const first: ResolvedFinding = { + ...base, + finding: { ...base.finding, surf: 'the the', start_position: 4 }, + ordinal: 0, + occurrenceKey: 'JDG 4:1|the the|0', + }; + const second: ResolvedFinding = { + ...base, + finding: { ...base.finding, surf: 'the the', start_position: 8 }, + ordinal: 1, + occurrenceKey: 'JDG 4:1|the the|1', + }; + renderPanel( + { active: [first, second], inactive: [] }, + { verseTextBySntId: new Map([['JDG 4:1', verse]]) } + ); + const highlights = screen.getAllByTestId('verse-highlight'); + expect(highlights).toHaveLength(2); + // Both highlight a 'the the' span; the windows around them differ by offset. + const rows = highlights.map(h => h.parentElement?.textContent); + expect(rows).toEqual(['And the the the light', 'And the the the light']); + expect(highlights[0].textContent).toBe('the the'); + expect(highlights[1].textContent).toBe('the the'); + }); + + it('ignoring one occurrence of an overlapping pair leaves the other card active', async () => { + // Reuses the cascade contract: "Ignore Here" is keyed by the FULL + // occurrence key including the ordinal, so ignoring card 0 must not ignore + // card 1 (the panel simply forwards the row's own occurrenceKey). + const base = active('JDG 4:1', 'the the'); + const first: ResolvedFinding = { ...base, ordinal: 0, occurrenceKey: 'JDG 4:1|the the|0' }; + const second: ResolvedFinding = { ...base, ordinal: 1, occurrenceKey: 'JDG 4:1|the the|1' }; + const h = handlers(); + const { user } = renderWithProviders( + + ); + const buttons = screen.getAllByRole('button', { name: 'Ignore Here' }); + expect(buttons).toHaveLength(2); + await user.click(buttons[0]); + expect(h.onIgnoreHere).toHaveBeenCalledTimes(1); + expect(h.onIgnoreHere).toHaveBeenCalledWith('JDG 4:1|the the|0'); + }); +}); diff --git a/src/features/checks/components/ChecksPanel.tsx b/src/features/checks/components/ChecksPanel.tsx index 66affa3..62b65a3 100644 --- a/src/features/checks/components/ChecksPanel.tsx +++ b/src/features/checks/components/ChecksPanel.tsx @@ -17,6 +17,13 @@ import { FindingRow } from './FindingRow'; export interface ChecksPanelProps { /** Cascade output: `{ active[], inactive[] }` from `useResolvedFindings`. */ resolved: ResolvedFindings; + /** + * As-checked verse text keyed by `snt_id` — the hydrated snapshot that + * travels with the check result (captured in `useRepeatedWordsCheck`), NOT + * the live drafting text. Each row windows its finding against this so the + * offsets always line up and the cards hold still between checks. + */ + verseTextBySntId: ReadonlyMap; /** The check query errored — show the inline red line (W9). */ isError: boolean; /** Hide the global "Ignore Everywhere" affordance when unavailable (W8). */ @@ -97,6 +104,7 @@ const groupByVerse = (findings: ResolvedFinding[]): VerseGroup[] => { const renderGroups = ( groups: VerseGroup[], globalIgnoresAvailable: boolean, + verseTextBySntId: ReadonlyMap, handlers: Pick< ChecksPanelProps, 'onIgnoreHere' | 'onIgnoreEverywhere' | 'onUndo' | 'onStopIgnoringEverywhere' @@ -111,6 +119,7 @@ const renderGroups = ( key={row.occurrenceKey} globalIgnoresAvailable={globalIgnoresAvailable} resolved={row} + verseTextBySntId={verseTextBySntId} onIgnoreEverywhere={handlers.onIgnoreEverywhere} onIgnoreHere={handlers.onIgnoreHere} onStopIgnoringEverywhere={handlers.onStopIgnoringEverywhere} @@ -135,6 +144,7 @@ const renderGroups = ( */ export const ChecksPanel: React.FC = ({ resolved, + verseTextBySntId, isError, globalIgnoresAvailable, onIgnoreHere, @@ -171,7 +181,9 @@ export const ChecksPanel: React.FC = ({ {showZeroState ? (

No issues found

) : ( -
{renderGroups(activeGroups, globalIgnoresAvailable, handlers)}
+
+ {renderGroups(activeGroups, globalIgnoresAvailable, verseTextBySntId, handlers)} +
)} {hasIgnored && ( @@ -189,7 +201,7 @@ export const ChecksPanel: React.FC = ({ {showingIgnored && (
- {renderGroups(ignoredGroups, globalIgnoresAvailable, handlers)} + {renderGroups(ignoredGroups, globalIgnoresAvailable, verseTextBySntId, handlers)}
)} diff --git a/src/features/checks/components/FindingRow.test.tsx b/src/features/checks/components/FindingRow.test.tsx index bb7a222..9e15cf8 100644 --- a/src/features/checks/components/FindingRow.test.tsx +++ b/src/features/checks/components/FindingRow.test.tsx @@ -28,10 +28,22 @@ const handlers = () => ({ onStopIgnoringEverywhere: vi.fn(), }); +/** + * Empty as-checked snapshot: exercises the fallback path (render `finding.surf` + * bare) in the pre-existing action/label tests, which don't care about the + * verse window. + */ +const emptySnapshot: ReadonlyMap = new Map(); + describe('FindingRow — active', () => { it('renders the surface snippet and both ignore buttons', () => { renderWithProviders( - + ); expect(screen.getByText('and The the made')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Ignore Here' })).toBeInTheDocument(); @@ -41,7 +53,12 @@ describe('FindingRow — active', () => { it('fires onIgnoreHere with the occurrence key', async () => { const h = handlers(); const { user } = renderWithProviders( - + ); await user.click(screen.getByRole('button', { name: 'Ignore Here' })); expect(h.onIgnoreHere).toHaveBeenCalledWith('JDG 4:3|the the|0'); @@ -49,7 +66,12 @@ describe('FindingRow — active', () => { it('hides Ignore Everywhere when global ignores are unavailable', () => { renderWithProviders( - + ); expect(screen.queryByRole('button', { name: 'Ignore Everywhere' })).not.toBeInTheDocument(); }); @@ -57,7 +79,12 @@ describe('FindingRow — active', () => { it('Ignore Everywhere opens a confirm dialog and only fires on confirm', async () => { const h = handlers(); const { user } = renderWithProviders( - + ); await user.click(screen.getByRole('button', { name: 'Ignore Everywhere' })); @@ -74,7 +101,12 @@ describe('FindingRow — active', () => { it('Cancel in the confirm dialog does not fire onIgnoreEverywhere', async () => { const h = handlers(); const { user } = renderWithProviders( - + ); await user.click(screen.getByRole('button', { name: 'Ignore Everywhere' })); await user.click(screen.getByRole('button', { name: 'Cancel' })); @@ -94,6 +126,7 @@ describe('FindingRow — inactive', () => { ); @@ -108,7 +141,12 @@ describe('FindingRow — inactive', () => { const h = handlers(); const resolved = makeResolved({ isActive: false, inactiveReason: 'occurrence' }); const { user } = renderWithProviders( - + ); await user.click(screen.getByRole('button', { name: 'Undo Ignore' })); expect(h.onUndo).toHaveBeenCalledWith(resolved); @@ -120,6 +158,7 @@ describe('FindingRow — inactive', () => { ); @@ -133,6 +172,7 @@ describe('FindingRow — inactive', () => { ); @@ -141,3 +181,87 @@ describe('FindingRow — inactive', () => { expect(screen.getByRole('button', { name: 'Undo Ignore' })).toBeInTheDocument(); }); }); + +describe('FindingRow — verse-context window', () => { + // The as-checked verse text for the default finding: the highlighted slice at + // [4, 4 + surf.length) is 'The the' — deliberately DIFFERENT casing from + // `finding.surf` ('and The the made' would not match anywhere) to lock in the + // surf-agnostic contract: the card must highlight the verse slice, never surf. + const verse = 'And The the made light'; + const snapshot: ReadonlyMap = new Map([['JDG 4:3', verse]]); + // surf's length (7) is all that matters for the window; its text is unused. + const windowed = makeResolved({ + finding: { ...makeResolved().finding, surf: 'the the', start_position: 4 }, + }); + + it('active card renders the verse window with the VERSE slice highlighted (red + bold)', () => { + renderWithProviders( + + ); + const highlight = screen.getByTestId('verse-highlight'); + // The verse slice ('The the'), NOT finding.surf ('the the'). + expect(highlight).toHaveTextContent('The the'); + expect(highlight.textContent).toBe('The the'); + expect(highlight).toHaveClass('text-red-600', 'font-semibold'); + // before + match + after reassemble the (short, untruncated) verse — no ellipses. + expect(highlight.parentElement?.textContent).toBe('And The the made light'); + }); + + it('renders leading and trailing ellipses when the window cuts a long verse on both ends', () => { + // Match at [34, 41): 26 chars of raw context on each side cut inside the + // verse, snapping to whitespace (constants: 26/26, radius 10). + const longVerse = + 'one two three four five six seven the the eight nine ten eleven twelve thirteen'; + renderWithProviders( + + ); + const highlight = screen.getByTestId('verse-highlight'); + expect(highlight.textContent).toBe('the the'); + expect(highlight.parentElement?.textContent).toBe( + '… three four five six seven the the eight nine ten eleven twelve …' + ); + }); + + it('dimmed/ignored card renders the same window but with bold-only highlight (no red)', () => { + renderWithProviders( + + ); + const highlight = screen.getByTestId('verse-highlight'); + expect(highlight.textContent).toBe('The the'); + expect(highlight).toHaveClass('font-semibold'); + expect(highlight).not.toHaveClass('text-red-600'); + // The window renders alongside the ignore-type label, not instead of it. + expect(screen.getByTestId('inactive-label')).toHaveTextContent('Ignore Here'); + }); + + it('falls back to finding.surf when the snapshot has no text for the snt_id', () => { + renderWithProviders( + + ); + // Today's behavior: the bare surface text, no highlight span, no crash. + expect(screen.getByText('and The the made')).toBeInTheDocument(); + expect(screen.queryByTestId('verse-highlight')).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/checks/components/FindingRow.tsx b/src/features/checks/components/FindingRow.tsx index 883f6da..5919dd0 100644 --- a/src/features/checks/components/FindingRow.tsx +++ b/src/features/checks/components/FindingRow.tsx @@ -19,7 +19,12 @@ import { } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; -import { type InactiveReason, type ResolvedFinding } from '../checks.types'; +import { + type InactiveReason, + type RepeatedWordsFinding, + type ResolvedFinding, +} from '../checks.types'; +import { buildVerseWindow } from '../highlight/verse-window'; /** * Human-readable ignore-type label for an inactive finding (§6.4, revised #278). @@ -38,6 +43,13 @@ const INACTIVE_LABEL: Record = { export interface FindingRowProps { /** The cascade-resolved finding to render. */ resolved: ResolvedFinding; + /** + * As-checked verse text keyed by `snt_id` (the hydrated snapshot captured by + * `useRepeatedWordsCheck` — the exact text the finding's offsets are relative + * to, NOT the live drafting text). Used to render the verse window around + * the repeated word; a missing entry falls back to `finding.surf`. + */ + verseTextBySntId: ReadonlyMap; /** Hide the global "Ignore Everywhere" affordance when the backend half is * absent (feature detection, W8). */ globalIgnoresAvailable: boolean; @@ -53,6 +65,65 @@ export interface FindingRowProps { onStopIgnoringEverywhere: (repeatedWord: string) => void; } +interface VerseContextProps { + /** The raw wire finding (offsets are relative to the snapshot text). */ + finding: RepeatedWordsFinding; + /** As-checked `snt_id → verseText` snapshot (see FindingRowProps). */ + verseTextBySntId: ReadonlyMap; + /** Active rows flag the word in red + bold; dimmed rows use bold alone. */ + isActive?: boolean; +} + +/** + * The verse-context snippet shared by BOTH the active and the inactive row + * branches: the verse text windowed around the match (Task 1's + * `buildVerseWindow`), with the repeated word emphasized in place. + * + * - Highlights `w.match` — the actual slice of the verse text — NOT + * `finding.surf` (the util is surf-agnostic; `surf` contributes only its + * length, so casing/whitespace always render as they appear in the verse). + * - Active rows: `text-red-600 font-semibold` (red flags "this is the flagged + * repeated word"). Dimmed/ignored rows: `font-semibold` only — ignored means + * "designated not a problem", so the alarm-red is deliberately dropped. + * NOTE: the dimmed card's `opacity-50` wrapper creates a stacking context + * descendants cannot escape (a child `opacity-100`/near-black is still + * washed to 50%), so bold — not color — is the reliable distinguisher there. + * - A leading/trailing `…` renders only for a real (non-boundary) cut; the + * spaces around the `…` are purely presentational (Task 1 already strips + * boundary whitespace from `before`/`after`). + */ +const VerseContext: React.FC = ({ + finding, + verseTextBySntId, + isActive = false, +}) => { + const verseText = verseTextBySntId.get(finding.snt_id); + + // Fallback: no snapshot text for this snt_id (should be rare — findings and + // their verse text are hydrated together, so a miss signals a malformed or + // absent snapshot rather than a routine race). Render the bare surface text + // exactly as before this feature: never crash, never render an empty card. + if (verseText === undefined) { + return

{finding.surf}

; + } + + const w = buildVerseWindow(verseText, finding.start_position, finding.surf.length); + const highlightClass = isActive ? 'text-red-600 font-semibold' : 'font-semibold'; + + return ( +

+ {w.truncatedStart && '… '} + {w.before} + {/* highlight w.match — the actual verse slice — NOT finding.surf */} + + {w.match} + + {w.after} + {w.truncatedEnd && ' …'} +

+ ); +}; + /** * One repeated-word finding row (§5.1, §6.4–§6.5). * @@ -70,6 +141,7 @@ export interface FindingRowProps { */ export const FindingRow: React.FC = ({ resolved, + verseTextBySntId, globalIgnoresAvailable, onIgnoreHere, onIgnoreEverywhere, @@ -78,14 +150,11 @@ export const FindingRow: React.FC = ({ }) => { const [confirmOpen, setConfirmOpen] = useState(false); const { finding, isActive, inactiveReason, occurrenceKey } = resolved; - // Display the original surface text (preserves casing); never compare on it — - // all matching already happened in the cascade (conventions §C). - const snippet = finding.surf; if (isActive) { return (
-

{snippet}

+