Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
cc737a7
Add analysis-keyed mutation reducers for the analysis catalog
alex-rawlings-yyc Aug 31, 2026
88aa5a0
Hold the catalog's filters and breakdowns to what the data offers
alex-rawlings-yyc Sep 1, 2026
03586f0
Offer merge peers to an analysis no token has approved
alex-rawlings-yyc Aug 31, 2026
f012d4d
Spend a filter choice the facets have withdrawn
alex-rawlings-yyc Sep 1, 2026
727693a
Wire the analysis catalog's rows to the analysis-keyed reducers
alex-rawlings-yyc Aug 31, 2026
7e83afe
Distinguish a removed analysis from a merged one by its links
alex-rawlings-yyc Sep 1, 2026
2df8968
Report a collapse from the write that performed it
alex-rawlings-yyc Sep 1, 2026
b85291c
Keep a catalog edit's aftermath in reach of the reader
alex-rawlings-yyc Sep 1, 2026
88d05c9
Ask before closing the catalog over an unsaved breakdown
alex-rawlings-yyc Sep 1, 2026
45dd8d2
Ask before a re-split strands a glossed morpheme
alex-rawlings-yyc Sep 17, 2026
8d30328
Ask before a merge or delete strands a breakdown draft
alex-rawlings-yyc Sep 1, 2026
839ee19
Stamp the links a merge moves onto its target
alex-rawlings-yyc Sep 1, 2026
1aa58ce
Honor read-only in the catalog and report stranded drafts
alex-rawlings-yyc Sep 2, 2026
c85a43e
Hold catalog merges and deletes to an outcome that still stands
alex-rawlings-yyc Sep 2, 2026
722b237
Hold the catalog's edit and delete copy to what the reader will see
alex-rawlings-yyc Sep 3, 2026
acb1718
Show a merge's source beside the analyses it may become
alex-rawlings-yyc Sep 4, 2026
7288ddb
Warn when a delete takes assignments no token displays
alex-rawlings-yyc Sep 4, 2026
020be91
Keep the unapplied count when a fallback reports as blank
alex-rawlings-yyc Sep 4, 2026
8892a71
Let the merge picker choose both ends of the merge
alex-rawlings-yyc Sep 4, 2026
8cbcd41
Say what a read-only row shows where it has no breakdown
alex-rawlings-yyc Sep 4, 2026
35a25cb
Size the merge picker to its rows and free both radios
alex-rawlings-yyc Sep 4, 2026
5d81edf
Number a filter choice a broken marking cannot tell apart
alex-rawlings-yyc Sep 8, 2026
713ba9e
Stamp a merged link the collapse raises to approved
alex-rawlings-yyc Sep 8, 2026
2b96f15
Collapse a converged edit's duplicate link onto one token
alex-rawlings-yyc Sep 8, 2026
8361576
Preserve a superseded approval's date and confidence on collapse
alex-rawlings-yyc Sep 8, 2026
18d4d71
Confirm before a breakdown edit drops a morpheme's lexicon links
alex-rawlings-yyc Sep 17, 2026
9ec2f10
Settle a merge's content and its links in one action
alex-rawlings-yyc Sep 10, 2026
92aeeac
Merge a form's analyses through one editable master
alex-rawlings-yyc Sep 10, 2026
61eb067
Settle every field of a merge on the master panel
alex-rawlings-yyc Sep 10, 2026
d7e9dea
Rework the merge master panel's layout and its field controls
alex-rawlings-yyc Sep 10, 2026
085f6b8
Stop a merge discarding content it was meant to carry
alex-rawlings-yyc Sep 10, 2026
dff16f4
Stop a merge losing drafts, glosses and its own reset
alex-rawlings-yyc Sep 10, 2026
1812dbd
Stop a merge dropping the sense and glosses it never settled
alex-rawlings-yyc Sep 10, 2026
b6af972
Stop the analysis catalog losing and misreporting merge content
alex-rawlings-yyc Sep 10, 2026
1e0178c
Stop a merge misplacing confidence, lexicon refs and drops
alex-rawlings-yyc Sep 11, 2026
cfb9255
Stop a merge carrying content off the wrong donor
alex-rawlings-yyc Sep 11, 2026
6502f9b
Drop the delete confirmation's singular message forms
alex-rawlings-yyc Sep 14, 2026
1b56a52
Stop a feature named like an added row taking its identity
alex-rawlings-yyc Sep 16, 2026
c1aa708
Show the merge panel's breakdowns as the catalog does
alex-rawlings-yyc Sep 16, 2026
ee2f271
Judge a deletion's fallback against the live text
alex-rawlings-yyc Sep 16, 2026
98f5d42
Render every breakdown through the morpheme box
alex-rawlings-yyc Sep 22, 2026
eca60b0
Stop the merge dialog authoring a part of speech or features
alex-rawlings-yyc Sep 17, 2026
d1907f3
Keep a whole-word breakdown the catalog editor was discarding
alex-rawlings-yyc Sep 18, 2026
15e2b01
Describe the untouched-breakdown test by what it covers
alex-rawlings-yyc Sep 18, 2026
3648fd3
Name single uses in the singular across the catalog's dialogs
alex-rawlings-yyc Sep 18, 2026
403a6f5
Drop the singular wording the catalog's dialogs had regained
alex-rawlings-yyc Sep 21, 2026
1bab9fa
Fix the catalog's duplicate breakdown, clipping and shared dismissal
alex-rawlings-yyc Sep 21, 2026
7804a23
Stop a re-split moving a merge's morpheme gloss edits
alex-rawlings-yyc Sep 21, 2026
10b914f
Stop a change of merge selection moving morpheme gloss edits
alex-rawlings-yyc Sep 22, 2026
1803d5c
Resolve a merged gloss through the sense that supplied it
alex-rawlings-yyc Sep 22, 2026
ede9b7d
Drop the sense reference a typed gloss no longer answers to
alex-rawlings-yyc Sep 22, 2026
0118061
Keep a cleared gloss's sense reference while another language holds one
alex-rawlings-yyc Sep 22, 2026
8cbe92c
Stop a blank sibling gloss keeping a cleared sense reference
alex-rawlings-yyc Sep 22, 2026
6ebfb3b
Read a blank morpheme gloss as unglossed when merging
alex-rawlings-yyc Sep 22, 2026
675e56c
Drop the stale "master" naming from the merge dialog
alex-rawlings-yyc Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ updates:
- dependency-name: platform-bible-react
allow:
# dependencies
- dependency-name: '@dnd-kit/core'
- dependency-name: '@dnd-kit/sortable'
- dependency-name: '@dnd-kit/utilities'
- dependency-name: '@reduxjs/toolkit'
- dependency-name: react-redux
# devDependencies
Expand All @@ -52,6 +55,7 @@ updates:
applies-to: version-updates
update-types: ['minor', 'patch']
patterns:
- '@dnd-kit/*'
- '@reduxjs/toolkit'
- react-redux
dev-tooling:
Expand Down
19 changes: 19 additions & 0 deletions __mocks__/lucide-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ export function LocateFixed(props: Readonly<{ className?: string }>): ReactEleme
return <svg data-testid="locate-fixed-icon" {...props} />;
}

/**
* Stub for the ArrowUpToLine icon.
*/
export function ArrowUpToLine(props: Readonly<{ className?: string }>): ReactElement {
return <svg data-testid="arrow-up-to-line-icon" {...props} />;
}

/**
* Stub for the GripVertical icon.
*/
export function GripVertical(props: Readonly<{ className?: string }>): ReactElement {
return <svg data-testid="grip-vertical-icon" {...props} />;
}

/**
* Stub for the Info icon.
*/
Expand Down Expand Up @@ -77,6 +91,11 @@ export function Merge(props: Readonly<{ className?: string }>): ReactElement {
return <svg data-testid="merge-icon" {...props} />;
}

/** Stub for the ArrowRight icon. */
export function ArrowRight(props: Readonly<{ className?: string }>): ReactElement {
return <svg data-testid="arrow-right-icon" {...props} />;
}

/**
* Stub for the Split icon: one stroke diverging into two, the mirror of the {@link Merge} glyph's
* join.
Expand Down
150 changes: 118 additions & 32 deletions __mocks__/platform-bible-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from 'react';
import type {
ChangeEventHandler,
FocusEventHandler,
CSSProperties,
KeyboardEventHandler,
MouseEventHandler,
Expand Down Expand Up @@ -420,6 +421,7 @@ export const Input = forwardRef<
className?: string;
style?: CSSProperties;
disabled?: boolean;
onBlur?: FocusEventHandler<HTMLInputElement>;
onChange?: ChangeEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
'aria-label'?: string;
Expand All @@ -434,6 +436,7 @@ export const Input = forwardRef<
className,
style,
disabled,
onBlur,
onChange,
onKeyDown,
'aria-label': ariaLabel,
Expand All @@ -451,6 +454,7 @@ export const Input = forwardRef<
className={className}
style={style}
disabled={disabled}
onBlur={onBlur}
onChange={onChange}
onKeyDown={onKeyDown}
aria-label={ariaLabel}
Expand Down Expand Up @@ -610,6 +614,42 @@ export function Switch({
);
}

/**
* Stub checkbox rendered as a native `<input type="checkbox">` so `toBeChecked`, `toBeDisabled`, and
* click interactions work in tests. (The real component renders a `<button role="checkbox">`; a
* native checkbox plays nicely with jest-dom's checked/disabled matchers.)
*/
export function Checkbox({
'aria-label': ariaLabel,
checked,
className,
'data-testid': testId,
disabled,
id,
onCheckedChange,
}: Readonly<{
'aria-label'?: string;
checked?: boolean;
className?: string;
'data-testid'?: string;
disabled?: boolean;
id?: string;
onCheckedChange?: (checked: boolean) => void;
}>): ReactElement {
return (
<input
aria-label={ariaLabel}
checked={checked ?? false}
className={className}
data-testid={testId}
disabled={disabled}
id={id}
onChange={(e) => onCheckedChange?.(e.target.checked)}
type="checkbox"
/>
);
}

/**
* Context carrying the {@link RadioGroup}'s selected value and change handler down to each
* {@link RadioGroupItem}, mirroring how the real Radix-based component coordinates its items.
Expand Down Expand Up @@ -651,27 +691,33 @@ export function RadioGroup({
* plays nicely with jest-dom's checked/disabled matchers.)
*/
export function RadioGroupItem({
'aria-label': ariaLabel,
className,
'data-testid': testId,
disabled,
id,
style,
value,
}: Readonly<{
'aria-label'?: string;
className?: string;
'data-testid'?: string;
disabled?: boolean;
id?: string;
style?: CSSProperties;
value: string;
}>): ReactElement {
const { onValueChange, value: groupValue } = useContext(RadioGroupContext);
return (
<input
aria-label={ariaLabel}
checked={groupValue === value}
className={className}
data-testid={testId}
disabled={disabled}
id={id}
onChange={() => onValueChange?.(value)}
style={style}
type="radio"
/>
);
Expand Down Expand Up @@ -918,25 +964,26 @@ const mountedDialogs: { current?: (open: boolean) => void }[] = [];
/**
* Stub dialog surface rendered as a `<div role="dialog" data-slot="dialog-content">` — the slot
* being what tells a modal apart from a popover, since both carry the dialog role — that reports
* Escape back through the root's change handler, which is the one dismissal path the extension's
* own code implements. The
* real component additionally traps focus, locks scrolling, and restores focus on close; those are
* behaviors of the platform package rather than of this extension, so they are left to end-to-end
* coverage rather than faked here.
* Escape and outside clicks back through the root's change handler, those being the dismissal
* paths the extension's own code implements. The real component additionally traps focus, locks
* scrolling, and restores focus on close; those are behaviors of the platform package rather than
* of this extension, so they are left to end-to-end coverage rather than faked here.
*
* `onInteractOutside` is accepted and ignored — there is no outside region to click in this stub.
* A close button is never rendered because the extension always suppresses it.
* A sentinel `data-testid="dialog-outside"` button stands in for the region outside the surface:
* clicking it invokes `onInteractOutside` and, unless that prevents the event, reports the
* dismissal. The ref lands on the surface, as the real component's does. A close button is never
* rendered because the extension always suppresses it.
*/
export function DialogContent({
children,
className,
}: Readonly<{
'aria-describedby'?: undefined;
children?: ReactNode;
className?: string;
onInteractOutside?: (event: { preventDefault: () => void }) => void;
showCloseButton?: boolean;
}>): ReactElement {
export const DialogContent = forwardRef<
HTMLDivElement,
Readonly<{
'aria-describedby'?: undefined;
children?: ReactNode;
className?: string;
onInteractOutside?: (event: { preventDefault: () => void }) => void;
showCloseButton?: boolean;
}>
>(function DialogContent({ children, className, onInteractOutside }, ref): ReactElement {
const { onOpenChange, titleId } = useContext(DialogContext);
const onOpenChangeRef = useRef(onOpenChange);
useEffect(() => {
Expand All @@ -959,17 +1006,38 @@ export function DialogContent({
}, []);

return (
<div
aria-labelledby={titleId}
aria-modal="true"
className={className}
data-slot="dialog-content"
role="dialog"
>
{children}
</div>
<>
<div
ref={ref}
aria-labelledby={titleId}
aria-modal="true"
className={className}
data-slot="dialog-content"
role="dialog"
>
{children}
</div>
{/* Hidden from the accessibility tree: scaffolding standing in for the region outside the
surface is no control of the real component's. */}
<button
aria-hidden="true"
data-testid="dialog-outside"
type="button"
onClick={() => {
let prevented = false;
onInteractOutside?.({
preventDefault: () => {
prevented = true;
},
});
if (!prevented) onOpenChangeRef.current?.(false);
}}
>
outside
</button>
</>
);
}
});

/**
* Stub dialog title rendered as the `<h2>` the real component produces, keeping the heading role and
Expand Down Expand Up @@ -997,10 +1065,14 @@ export function DialogTitle({
* Context carrying the {@link Popover}'s open state and change handler down to
* {@link PopoverTrigger}, mirroring how the real Radix-based component coordinates the two.
*/
const PopoverContext = createContext<{
onOpenChange?: (open: boolean) => void;
open?: boolean;
}>({});
// Undefined outside a Popover, so PopoverAnchor can refuse as the real component does.
const PopoverContext = createContext<
| {
onOpenChange?: (open: boolean) => void;
open?: boolean;
}
| undefined
>(undefined);

/**
* Stub popover root that renders its children unconditionally. The extension conditionally mounts
Expand Down Expand Up @@ -1048,14 +1120,27 @@ export function PopoverTrigger({
* Stub popover anchor that renders its children as-is, matching the real component's `asChild`
* pass-through behavior.
*/
/**
* Stub portal-container provider, rendering its children where they stand — jsdom has no portal
* behavior worth reproducing.
*/
export function PopoverPortalContainerProvider({
children,
}: Readonly<{ children?: ReactNode; container?: HTMLElement | null }>): ReactElement {
return <>{children}</>;
}

export function PopoverAnchor({
children,
}: Readonly<{ children?: ReactNode; asChild?: boolean }>): ReactElement {
// Throws outside a Popover as the real component does.
if (useContext(PopoverContext) === undefined)
throw new Error('`PopoverAnchor` must be used within `Popover`');
return <>{children}</>;
}

/**
* Stub popover content rendered as a `<div role="dialog" data-testid="popover-content">` — the role
* Stub popover content rendered as a `<div role="dialog" data-slot="popover-content">` — the role
* matching the real component, which is what makes its `aria-label` meaningful, and which is why a
* test that must reach a modal instead selects on `[data-slot="dialog-content"]`. The real component
* implements positioning, portaling, and dismissal internally; this stub exposes the dismissal
Expand Down Expand Up @@ -1140,6 +1225,7 @@ export function PopoverContent({
ref={contentRef}
aria-label={ariaLabel}
className={className}
data-slot="popover-content"
data-testid={testId}
id={id}
role={role}
Expand Down
58 changes: 57 additions & 1 deletion contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,65 @@
"%interlinearizer_analysisCatalog_confidence_low%": "Low",
"%interlinearizer_analysisCatalog_confidence_guess%": "Guess",
"%interlinearizer_analysisCatalog_noGloss%": "(no gloss)",
"%interlinearizer_analysisCatalog_morphemeNoGloss%": "(no gloss)",
"%interlinearizer_analysisCatalog_noBreakdown%": "(not split into morphemes)",
"%interlinearizer_analysisCatalog_usageCount%": "Uses in the whole draft",
"%interlinearizer_analysisCatalog_usageCountInBook%": "Uses in {book}",
"%interlinearizer_analysisCatalog_noUsages%": "Not used anywhere",
"%interlinearizer_analysisCatalog_showAllUsages%": "Show {count} more",

"%interlinearizer_analysisCatalog_editGloss%": "Gloss",
"%interlinearizer_analysisCatalog_editMorphemes%": "Split into morphemes",
"%interlinearizer_analysisCatalog_editMorphemesHint%": "Enter morpheme forms separated by spaces",
"%interlinearizer_analysisCatalog_editMorphemesSave%": "Save breakdown",
"%interlinearizer_analysisCatalog_editMorphemesCancel%": "Cancel",
"%interlinearizer_analysisCatalog_editMorphemesReset%": "Discard breakdown",
"%interlinearizer_analysisCatalog_editMorphemesOpen%": "Edit breakdown for {form}",
"%interlinearizer_analysisCatalog_confirmResetPrompt%": "Discard this breakdown, with the glosses and lexicon links on it, everywhere {form} is used?",
"%interlinearizer_analysisCatalog_confirmResetAction%": "Discard breakdown",
"%interlinearizer_analysisCatalog_confirmResplitPrompt%": "This breakdown drops {forms}, discarding the glosses and lexicon links on them everywhere this analysis is used. Save anyway?",
"%interlinearizer_analysisCatalog_confirmResplitAction%": "Save and discard",
"%interlinearizer_analysisCatalog_morphemeGloss%": "Gloss for morpheme {form}",
"%interlinearizer_analysisCatalog_appliesToAll%": "Edits here apply to all {count} uses of this analysis.",
"%interlinearizer_analysisCatalog_merge%": "Merge…",
"%interlinearizer_analysisCatalog_mergeTitle%": "Merge an analysis",
"%interlinearizer_analysisCatalog_mergeForm%": "Form",
"%interlinearizer_analysisCatalog_mergeUsageCount%": "{count} uses",
"%interlinearizer_analysisCatalog_mergePromote%": "Merge the others into {gloss}",
"%interlinearizer_analysisCatalog_mergeReorder%": "Reorder {gloss}",
"%interlinearizer_analysisCatalog_mergeRevertField%": "Undo this edit",
"%interlinearizer_analysisCatalog_mergeMorphemeGlosses%": "Morpheme glosses",
"%interlinearizer_analysisCatalog_mergeClearMorphemeGloss%": "Clear the gloss for {form}",
"%interlinearizer_analysisCatalog_mergeConfidence%": "Confidence",
"%interlinearizer_analysisCatalog_mergeConfidenceNone%": "Not recorded",
"%interlinearizer_analysisCatalog_mergeReset%": "Undo all edits",
"%interlinearizer_analysisCatalog_mergeWillCollapse%": "This will also absorb the identical analysis {gloss}, which is not selected.",
"%interlinearizer_analysisCatalog_mergeCancel%": "Cancel",
"%interlinearizer_analysisCatalog_mergeConfirm%": "Merge",
"%interlinearizer_analysisCatalog_merged%": "Merged into {gloss} — now {count} uses.",
"%interlinearizer_analysisCatalog_mergedNoGloss%": "Merged into {form} — now {count} uses.",
"%interlinearizer_analysisCatalog_mergedDismiss%": "Dismiss",
"%interlinearizer_analysisCatalog_draftStranded%": "An edit elsewhere removed the analysis of {form}, discarding the breakdown you were typing for it.",
"%interlinearizer_analysisCatalog_delete%": "Delete",
"%interlinearizer_analysisCatalog_deleteTitle%": "Delete the analysis of {form}?",
"%interlinearizer_analysisCatalog_deleteBlank%": "{count} uses will be left with no analysis.",
"%interlinearizer_analysisCatalog_deleteBlankNone%": "No token is analyzed as this, so nothing on screen changes.",
"%interlinearizer_analysisCatalog_deleteFallback%": "{count} uses will fall back to {gloss}.",
"%interlinearizer_analysisCatalog_deleteFallbackNoGloss%": "{count} uses will fall back to another analysis of the same form.",
"%interlinearizer_analysisCatalog_deleteFallbackDrifted%": "{count} uses will fall back to another analysis, which one depending on the text beneath each of them.",
"%interlinearizer_analysisCatalog_deleteUnapplied%": "{count} tokens also record this analysis without using it; those records are deleted too.",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"%interlinearizer_analysisCatalog_deleteUndoWarning%": "This cannot be undone.",
"%interlinearizer_analysisCatalog_deleteCancel%": "Cancel",
"%interlinearizer_analysisCatalog_deleteConfirm%": "Delete",
"%interlinearizer_analysisCatalog_closeConfirmTitle%": "Discard the unsaved breakdown?",
"%interlinearizer_analysisCatalog_closeConfirmPrompt%": "You have typed a morpheme breakdown but not saved it. Closing the catalog discards it.",
"%interlinearizer_analysisCatalog_closeConfirmCancel%": "Keep editing",
"%interlinearizer_analysisCatalog_closeConfirmDiscard%": "Discard and close",
"%interlinearizer_analysisCatalog_discardForDeletePrompt%": "You have typed a morpheme breakdown but not saved it. Deleting this analysis discards it.",
"%interlinearizer_analysisCatalog_discardForDeleteConfirm%": "Discard and delete",
"%interlinearizer_analysisCatalog_discardForMergePrompt%": "You have typed a morpheme breakdown but not saved it. Merging this analysis discards it.",
"%interlinearizer_analysisCatalog_discardForMergeConfirm%": "Discard and merge",

"%interlinearizer_projectSettings_title%": "Interlinearizer",
"%interlinearizer_projectSettings_continuousScroll%": "Continuous Scroll",
"%interlinearizer_projectSettings_continuousScrollDescription%": "Display words in a continuous horizontal scroll strip instead of chapter-segmented rows",
Expand Down Expand Up @@ -83,8 +137,10 @@
"%interlinearizer_morphemeEditor_cancel%": "Cancel",
"%interlinearizer_morphemeEditor_done%": "Done",
"%interlinearizer_morphemeEditor_emptyHint%": "Enter morpheme forms separated by spaces",
"%interlinearizer_morphemeEditor_confirmResetPrompt%": "Discard this breakdown and its glosses?",
"%interlinearizer_morphemeEditor_confirmResetPrompt%": "Discard this breakdown, with the glosses and lexicon links on it?",
"%interlinearizer_morphemeEditor_confirmResetAction%": "Reset",
"%interlinearizer_morphemeEditor_confirmResplitPrompt%": "This breakdown drops {forms}, discarding the glosses and lexicon links on them. Save anyway?",
"%interlinearizer_morphemeEditor_confirmResplitAction%": "Save and discard",
"%interlinearizer_morphemeGloss_label%": "Gloss for morpheme {form}",
"%interlinearizer_tokenChip_editMorphemes%": "Edit morpheme breakdown for {token}",
"%interlinearizer_tokenChip_defineMorphemes%": "Define morpheme breakdown for {token}",
Expand Down
Loading
Loading