diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 7aef2897..655473c3 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -65,6 +65,7 @@ "%interlinearizer_tokenChip_addToPhrase%": "Add \"{token}\" to phrase", "%interlinearizer_suggestion_accept%": "Accept suggestion {gloss} for {token}", "%interlinearizer_suggestion_promote%": "Promote {gloss} for {token}", + "%interlinearizer_suggestion_breakdown%": "broken down as {breakdown}", "%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Words in different segments can't be linked. Join the segments first to link across this boundary.", "%interlinearizer_linkButton_link%": "Link words", "%interlinearizer_linkButton_unlink%": "Unlink words", diff --git a/src/__tests__/components/TokenChip.suggestions.test.tsx b/src/__tests__/components/TokenChip.suggestions.test.tsx index 51af43dc..b927c9d0 100644 --- a/src/__tests__/components/TokenChip.suggestions.test.tsx +++ b/src/__tests__/components/TokenChip.suggestions.test.tsx @@ -85,6 +85,55 @@ function homographBankPool(financeGloss: string | undefined): TextAnalysis { return { ...emptyAnalysis(), tokenAnalyses: [river, fin], tokenAnalysisLinks: links }; } +/** + * Builds a pool where 'ran' carries two approved analyses glossed alike, the more frequent parsed + * as `run PST` and the other broken down into `rivalForms`. + */ +function sameGlossParsePool(rivalForms: readonly string[]): TextAnalysis { + const pastTense: TokenAnalysis = { + ...FIXTURE_STAMPS, + id: 'ta-past', + surfaceText: 'ran', + gloss: { en: 'ran' }, + morphemes: [ + { id: 'm-run', form: 'run', writingSystem: 'und' }, + { id: 'm-pst', form: 'PST', writingSystem: 'und' }, + ], + }; + const rival: TokenAnalysis = { + ...FIXTURE_STAMPS, + id: 'ta-rival', + surfaceText: 'ran', + gloss: { en: 'ran' }, + morphemes: rivalForms.map((form, i) => ({ + id: `m-rival-${i}`, + form, + writingSystem: 'und', + })), + }; + const links: TokenAnalysisLink[] = [ + { + ...FIXTURE_STAMPS, + analysisId: 'ta-past', + status: 'approved', + token: { tokenRef: 'p1', surfaceText: 'ran' }, + }, + { + ...FIXTURE_STAMPS, + analysisId: 'ta-past', + status: 'approved', + token: { tokenRef: 'p2', surfaceText: 'ran' }, + }, + { + ...FIXTURE_STAMPS, + analysisId: 'ta-rival', + status: 'approved', + token: { tokenRef: 'v1', surfaceText: 'ran' }, + }, + ]; + return { ...emptyAnalysis(), tokenAnalyses: [pastTense, rival], tokenAnalysisLinks: links }; +} + /** * Builds the homograph 'bank' where the MOST-frequent analysis has no active-language (English) * gloss — only French — and a lower-frequency one carries `en:'finance'`. Exercises falling through @@ -409,6 +458,32 @@ describe('TokenChip suggestion dropdown', () => { expect(link?.status).toBe('approved'); }); + it('shows each breakdown when two suggestions share one gloss', async () => { + renderChip(makeWordToken('tok-new', 'ran'), { + initialAnalysis: sameGlossParsePool(['ran']), + }); + + await focusGloss(); + + expect(screen.getByTestId('suggestion-accept')).toHaveTextContent('run PST'); + expect(screen.getByTestId('suggestion-candidate')).toHaveTextContent('ran'); + expect(screen.getAllByTestId('suggestion-breakdown').map((el) => el.textContent)).toEqual([ + 'run PST', + 'ran', + ]); + }); + + it('omits the breakdown on a suggestion with no morphological breakdown', async () => { + // The 'bank' pool analyses carry no morphemes, so there is no breakdown for either row to show. + renderChip(makeWordToken('tok-new', 'bank'), { + initialAnalysis: homographBankPool('finance'), + }); + + await focusGloss(); + + expect(screen.queryByTestId('suggestion-breakdown')).not.toBeInTheDocument(); + }); + it('omits a candidate that has no gloss in the active language', async () => { renderChip(makeWordToken('tok-new', 'bank'), { initialAnalysis: homographBankPool(undefined) }); diff --git a/src/__tests__/utils/suggestion-engine.test.ts b/src/__tests__/utils/suggestion-engine.test.ts index 0e15925c..0cc71af5 100644 --- a/src/__tests__/utils/suggestion-engine.test.ts +++ b/src/__tests__/utils/suggestion-engine.test.ts @@ -6,6 +6,7 @@ import type { ResolvedTokenAnalysis } from '../../utils/suggestion-engine'; import { buildPoolIndex, deriveTokenSuggestion, + glossedSuggestionEntries, resolvedTokenAnalysisEqual, } from '../../utils/suggestion-engine'; @@ -335,3 +336,83 @@ describe('resolvedTokenAnalysisEqual', () => { ).toBe(false); }); }); + +describe('glossedSuggestionEntries breakdowns', () => { + /** Builds an analysis of 'ran' glossed as `gloss` and broken down into `forms`. */ + function parsed(id: string, gloss: string, forms: readonly string[]): TokenAnalysis { + return { + ...FIXTURE_STAMPS, + id, + surfaceText: 'ran', + gloss: { en: gloss }, + morphemes: forms.map((form, i) => ({ id: `${id}-m${i}`, form, writingSystem: 'und' })), + }; + } + + it('carries each row its own breakdown, which is what tells same-gloss rows apart', () => { + const pastTense = parsed('p1', 'ran', ['run', 'PST']); + const bareRoot = parsed('p2', 'ran', ['ran']); + + const entries = glossedSuggestionEntries( + { status: 'suggested', suggested: pastTense, candidates: [bareRoot] }, + 'en', + ); + + expect(entries).toEqual([ + { id: 'p1', gloss: 'ran', status: 'suggested', breakdown: 'run PST' }, + { id: 'p2', gloss: 'ran', status: 'candidate', breakdown: 'ran' }, + ]); + }); + + it('omits the breakdown on a row whose payload carries no morphemes at all', () => { + // Printing a blank annotation would read as missing data rather than as "nothing to show". + const unparsed: TokenAnalysis = { + ...FIXTURE_STAMPS, + id: 'w1', + surfaceText: 'ran', + gloss: { en: 'ran' }, + }; + const parsedRival = parsed('w2', 'ran', ['run', 'PST']); + + const entries = glossedSuggestionEntries( + { status: 'suggested', suggested: unparsed, candidates: [parsedRival] }, + 'en', + ); + + expect(entries).toEqual([ + { id: 'w1', gloss: 'ran', status: 'suggested' }, + { id: 'w2', gloss: 'ran', status: 'candidate', breakdown: 'run PST' }, + ]); + }); + + it('omits the breakdown on a row whose morpheme list is empty', () => { + const emptyParse: TokenAnalysis = { ...parsed('e1', 'ran', []), morphemes: [] }; + + const entries = glossedSuggestionEntries( + { status: 'suggested', suggested: emptyParse, candidates: [] }, + 'en', + ); + + expect(entries).toEqual([{ id: 'e1', gloss: 'ran', status: 'suggested' }]); + }); + + it('carries breakdowns on an approved token, which offers only promotions', () => { + const approved = parsed('a1', 'went', ['go', 'PST']); + const first = parsed('a2', 'ran', ['run', 'PST']); + const second = parsed('a3', 'ran', ['ran']); + + const entries = glossedSuggestionEntries( + { + status: 'approved', + analysis: approved, + poolSuggestion: { suggested: approved, candidates: [first, second] }, + }, + 'en', + ); + + expect(entries).toEqual([ + { id: 'a2', gloss: 'ran', status: 'candidate', breakdown: 'run PST' }, + { id: 'a3', gloss: 'ran', status: 'candidate', breakdown: 'ran' }, + ]); + }); +}); diff --git a/src/components/PhraseStripContext.tsx b/src/components/PhraseStripContext.tsx index 7fd6a03d..8ade2b2e 100644 --- a/src/components/PhraseStripContext.tsx +++ b/src/components/PhraseStripContext.tsx @@ -25,6 +25,8 @@ export type TokenChipLabels = Readonly<{ acceptSuggestion: string; /** Accessible label for a dropdown row that promotes a candidate gloss. */ promoteSuggestion: string; + /** Accessible suffix naming the morpheme breakdown a suggestion dropdown row is showing. */ + suggestionBreakdown: string; }>; /** @@ -41,6 +43,7 @@ export const TOKEN_CHIP_LABEL_KEYS = { morphemeGloss: '%interlinearizer_morphemeGloss_label%', acceptSuggestion: '%interlinearizer_suggestion_accept%', promoteSuggestion: '%interlinearizer_suggestion_promote%', + suggestionBreakdown: '%interlinearizer_suggestion_breakdown%', } as const satisfies Record; /** diff --git a/src/components/SuggestionDropdown.tsx b/src/components/SuggestionDropdown.tsx index 596e90aa..48b48dee 100644 --- a/src/components/SuggestionDropdown.tsx +++ b/src/components/SuggestionDropdown.tsx @@ -24,7 +24,12 @@ type SuggestionDropdownProps = Readonly<{ acceptLabelTemplate: string; /** Same as {@link acceptLabelTemplate}, for a "promote this candidate gloss" row. */ promoteLabelTemplate: string; - /** Surface form of the token being glossed, filling the `{token}` placeholder in both templates. */ + /** + * Accessible suffix naming a row's morpheme breakdown, with `{breakdown}` still to fill in. + * Appended to the label of each row carrying one, so same-gloss rows do not sound identical. + */ + breakdownLabelTemplate: string; + /** Surface form of the token being glossed, filling `{token}` in the accept and promote templates. */ tokenSurfaceText: string; /** Called with a row index when the pointer enters it, so hover and keyboard share one highlight. */ onActiveIndexChange: (index: number) => void; @@ -43,6 +48,9 @@ type SuggestionDropdownProps = Readonly<{ * Each row is colored and labeled by its own `status` — `'suggested'` (blue, "accept") or * `'candidate'` (gray, "promote") — carried on the entry rather than inferred from position, so a * dropped blank-in-language pick can never leave a candidate masquerading as the accept row. + * + * A row also renders its `breakdown` when it carries one, so two analyses glossed alike are never + * offered as identical choices. */ export default function SuggestionDropdown({ listboxId, @@ -51,6 +59,7 @@ export default function SuggestionDropdown({ activeIndex, acceptLabelTemplate, promoteLabelTemplate, + breakdownLabelTemplate, tokenSurfaceText, onActiveIndexChange, onSelect, @@ -99,10 +108,17 @@ export default function SuggestionDropdown({ {entries.map((entry, index) => (
onActiveIndexChange(index)} > {entry.gloss} + {entry.breakdown !== undefined && ( + // Hidden from assistive tech because the row's own label already speaks the breakdown, + // which would otherwise be announced twice. + + {entry.breakdown} + + )}
))} diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx index 12c7be55..499391cd 100644 --- a/src/components/TokenChip.tsx +++ b/src/components/TokenChip.tsx @@ -576,6 +576,7 @@ export function TokenChip({ listboxId={listboxId} optionId={optionId} acceptLabelTemplate={labels.acceptSuggestion} + breakdownLabelTemplate={labels.suggestionBreakdown} promoteLabelTemplate={labels.promoteSuggestion} tokenSurfaceText={token.surfaceText} onActiveIndexChange={setActiveIndex} diff --git a/src/hooks/usePhraseStripSetup.ts b/src/hooks/usePhraseStripSetup.ts index 303f418e..64d4eaed 100644 --- a/src/hooks/usePhraseStripSetup.ts +++ b/src/hooks/usePhraseStripSetup.ts @@ -103,6 +103,7 @@ function useTokenChipLabels(): TokenChipLabels { const morphemeGloss = strings[TOKEN_CHIP_LABEL_KEYS.morphemeGloss]; const acceptSuggestion = strings[TOKEN_CHIP_LABEL_KEYS.acceptSuggestion]; const promoteSuggestion = strings[TOKEN_CHIP_LABEL_KEYS.promoteSuggestion]; + const suggestionBreakdown = strings[TOKEN_CHIP_LABEL_KEYS.suggestionBreakdown]; return useMemo( () => ({ @@ -113,6 +114,7 @@ function useTokenChipLabels(): TokenChipLabels { morphemeGloss, acceptSuggestion, promoteSuggestion, + suggestionBreakdown, }), [ tokenGloss, @@ -122,6 +124,7 @@ function useTokenChipLabels(): TokenChipLabels { morphemeGloss, acceptSuggestion, promoteSuggestion, + suggestionBreakdown, ], ); } diff --git a/src/utils/suggestion-engine.ts b/src/utils/suggestion-engine.ts index e5f98b8e..e88fcef8 100644 --- a/src/utils/suggestion-engine.ts +++ b/src/utils/suggestion-engine.ts @@ -157,10 +157,7 @@ export function deriveTokenSuggestion( }; } -/** - * One renderable suggestion entry: a payload id, its gloss in the active language, and the - * assignment status the UI colors and labels it by. - */ +/** One renderable suggestion entry: a matching payload reduced to what the gloss UI shows of it. */ export interface GlossedSuggestionEntry { /** The matching payload's id — the approve/promote target and the React key. */ id: string; @@ -173,29 +170,40 @@ export interface GlossedSuggestionEntry { * masquerading as the accept row. */ status: Extract; + /** + * The payload's morpheme forms, rendered beside the gloss as context for the choice and to tell + * this row from another sharing its gloss. Absent when the payload has no morphological + * breakdown. + */ + breakdown?: string; +} + +/** `undefined` when the payload has no morphological breakdown, so there are no forms to show. */ +function breakdownOf(analysis: TokenAnalysis): string | undefined { + const { morphemes } = analysis; + if (!morphemes || morphemes.length === 0) return undefined; + return morphemes.map((morpheme) => morpheme.form).join(' '); } /** * Flattens the merged per-token read into the entries the gloss UI renders, in rank order, keeping * only those with a non-blank gloss in the active language. * - * This is the single home of suggestion-presentation policy — which matches are renderable, how a - * blank-in-active-language pick falls through, the approved payload's exclusion from its own - * promote list, and each row's assignment status — so every surface ranks, colors, and labels - * suggestions identically instead of re-deriving any of it from row position. + * Single home of suggestion-presentation policy, so every surface offers the same rows rather than + * re-deriving any of it from row position; {@link GlossedSuggestionEntry} documents what each row + * carries. At most one entry is `'suggested'`, and often none (an approved token offers only + * promotions), so read `status` rather than assuming the first row is the accept row. * * Status is assigned _after_ blank picks are dropped. So when the engine's top pick has no gloss in * the active language, the next-ranked glossed match becomes the accept row rather than the whole - * suggestion vanishing. An already-approved token has no accept row at all: every pool peer is a - * promotion, so even the top row reads as one. + * suggestion vanishing. An approved token's own payload is excluded from its promote list, leaving + * only genuine alternatives. */ export function glossedSuggestionEntries( resolved: ResolvedTokenAnalysis | undefined, analysisLanguage: string, ): GlossedSuggestionEntry[] { if (!resolved) return []; - // The ranked payloads to offer, best-first. For an approved token its own payload is excluded so - // only genuine alternatives remain; for an un-approved token the engine's pick leads. let ranked: readonly TokenAnalysis[]; if (resolved.status === 'suggested') { ranked = [resolved.suggested, ...resolved.candidates]; @@ -205,16 +213,18 @@ export function glossedSuggestionEntries( ranked = [pool.suggested, ...pool.candidates].filter((a) => a.id !== resolved.analysis.id); } const glossed = ranked - .map((analysis) => ({ id: analysis.id, gloss: analysis.gloss?.[analysisLanguage] ?? '' })) + .map((analysis) => ({ + id: analysis.id, + gloss: analysis.gloss?.[analysisLanguage] ?? '', + breakdown: breakdownOf(analysis), + })) .filter((entry) => entry.gloss !== ''); - // Assign status by post-filter rank: only an un-approved token has an "accept" row (its top - // renderable match); an approved token offers only promotions. Done after the blank filter so a - // dropped top pick promotes the next-ranked glossed match to the accept row rather than leaving a - // candidate masquerading as it. const hasAccept = resolved.status === 'suggested'; return glossed.map((entry, index) => ({ - ...entry, + id: entry.id, + gloss: entry.gloss, status: hasAccept && index === 0 ? 'suggested' : 'candidate', + ...(entry.breakdown === undefined ? {} : { breakdown: entry.breakdown }), })); }