Repository navigation
Conversation
Associate and match each held the same three distractor edits, and gap match needs them as well.
Chrome mishandles the caret at the edges of an editable inline element. It puts typed text outside a blank gap, removes a gap whose last character is deleted, and skips the positions at a gap's edges. So the node makes those edits and caret moves itself. A block inserted inside a gap splits it, so a split or copied gap gets a fresh identifier.
The lint fix rewrote these assertions to toHaveTextContent, which only reads DOM elements, so they failed on the editor's nodes.
Closing the card hid the empty question, and Vue reused the question's editor for the passage. That editor had no gap node, so the gaps showed as plain text. Keying the sections keeps each editor in its place.
An empty inline-block has no line box, so a blank gap collapsed to its top and bottom borders.
A formula has no text of its own, so a gap whose answer was only a formula was named and announced as empty. It now reads as its LaTeX. A gap holding only spaces now shows as blank, as validation treats it.
Pasting several paragraphs into a gap split the gap and its paragraph. Pasted content now becomes text and formulas on one line, with a space for each break.
The chip editor scrolls to its cursor when it opens, and jsdom has no layout to measure, so the test failed at random in a full run.
🟡 Waiting for changesOutcome: reviewed Last updated: 2026-10-29 14:35 UTC |
There was a problem hiding this comment.
PR #6354: two editing bugs block this. Otherwise gap match works end to end in QA. CI passes; axe found nothing new.
- blocking: select-all + Insert deletes existing gaps (
useGapMatchPassage.js:176). - blocking: RTL arrow keys get stuck at blank, digit or Latin gaps (
GapNode.js:176). - suggestion: word-delete removes the gap; IME input; unused
gaps; Insert not announced; LaTeX gap names; four tests that don't pin their AC. - pre-existing: closing a card while the question editor is focused discards the edit (
QTIItemEditor/index.vue:263). Multiple Choice has it too. File it separately. - QA once saw passage text copied into the question, not reproducible. Can a passage transaction reach the question editor?
Next step: Needs a full code review by a human engineer, then manual QA once that reviewer sets Needs QA.
- User-visible files changed:
contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/index.vue,contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useDistractors.js,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useGapMatchInteraction.js…. - Whether QTIItemEditor's one-line change and the new
inlineOnlyEditableChipList mode are patterns the other interaction editors should copy. - After the fixes, retest RTL arrow navigation across mismatched-direction gaps and select-all + Insert on a passage that already holds gaps.
- Try typing into blank gaps with an IME or on Android Chrome.
QA captures (50)
Add a new question in #/qti-demo and choose Gap match from the question type selector — option label and description, question/passage/distractor sections with helper text, empty-state errors, the card marked incomplete, then switch to another type and back
| Step | Screenshot |
|---|---|
| Gap match filled before switch | ![]() |
| Gap match new card | ![]() |
| Type dropdown gap match | ![]() |
| Type selector | ![]() |
1-select-gap-match-and-switch.webm
Build a gap match passage: type a sentence, press Insert for a blank gap, type its answer, insert a second gap before the passage is focused (end of passage) — dashed vs solid chip, caret lands inside the gap, saved item reopens with both gaps and answers intact
| Step | Screenshot |
|---|---|
| Answered gap solid | ![]() |
| Blank gap after insert | ![]() |
| Closed card after save | ![]() |
| Exercise reopened after full reload | ![]() |
| Reopened after reload | ![]() |
2-build-passage-two-gaps.webm
Turn highlighted text into a gap — bold/italic word and a formula keep their formatting and the formula names the gap by its LaTeX; Insert disabled for a selection across two lines and with the caret inside a gap
| Step | Screenshot |
|---|---|
| Bold word selected | ![]() |
| Closed card formatted gaps | ![]() |
| Formula selected | ![]() |
| Insert disabled caret in gap | ![]() |
| Insert disabled two lines | ![]() |
| Three formatted gaps | ![]() |
Edit inside and around gaps with the keyboard — type into a blank gap, Backspace/Delete to empty a gap (it stays blank), Backspace again removes it, Delete at the gap's far edge, undo/redo restore the gap with its answer, arrow keys in and out of a gap
| Step | Screenshot |
|---|---|
| Caret before gap | ![]() |
| Gap emptied stays blank | ![]() |
| Gap filled with trailing text | ![]() |
4-keyboard-gap-editing.webm
Copy text holding a gap and paste it elsewhere in the passage, cut and paste a gap, split a paragraph with Enter inside or next to a gap, and paste multi-line rich content into a gap — each copy a separate gap with the same answer, pasted content becomes one line of the answer, saved XML has unique gap identifiers
| Step | Screenshot |
|---|---|
| Copy paste gap duplicate | ![]() |
| Passage after paste and enter | ![]() |
| Rich paste into gap | ![]() |
5-copy-cut-paste-enter-gaps.webm
Screen reader announcements in the passage — gap accessible names ("winter, gap 1", "Empty gap 2"), live announcement when the caret enters a gap and when a deletion removes one or several gaps, passage aria-describedby helper text
| Step | Screenshot |
|---|---|
| Passage two gaps open | ![]() |
Add, edit and remove gap match distractors as chips — the inline-only editor rejects Enter/line breaks/images, blank and duplicate-of-answer or duplicate-of-distractor errors under the chip, opening a distractor closes the passage/question editors and commits their edits
| Step | Screenshot |
|---|---|
| Blank error open | ![]() |
| Blank error | ![]() |
| Duplicate errors | ![]() |
| Inline editor open | ![]() |
7-distractor-chips-flow.webm
Validation on a gap match item — leave a gap blank, remove every gap, then fix each — passage error messages and red border on the closed passage, the erroring gap chip highlighted, card incomplete/complete status follows
| Step | Screenshot |
|---|---|
| Blank gap passage closed | ![]() |
| Blank gap passage open | ![]() |
| Card closed blank gap | ![]() |
| Fixed closed | ![]() |
| No gap closed | ![]() |
| One of two gaps blank closed | ![]() |
8-validation-break-and-fix.webm
Close the gap match card and view it — question shown only when it has content, passage under Answers, Show answers on and off reveals and hides gap answers, a gap match item with no question keeps its gaps after closing and reopening
| Step | Screenshot |
|---|---|
| Closed no question | ![]() |
| Closed show answers on | ![]() |
| Closed with question answers hidden | ![]() |
| No question reopened | ![]() |
| No question show answers on | ![]() |
9-close-view-reopen.webm
Open an existing gap match item (saved earlier or seeded through the sync endpoint) — it opens editable rather than read-only, edit a gap answer and a distractor, save, reopen — answers, distractors and response declaration round-trip
| Step | Screenshot |
|---|---|
| Closed cards before edit | ![]() |
| Edited before close | ![]() |
| Reopened after reload | ![]() |
| Seeded item opens editable | ![]() |
10-edit-save-reopen-roundtrip.webm
Open the passage editor under /ar/ (RTL) — arrow keys move into and out of a gap following text direction, chip layout and Insert work in RTL
| Step | Screenshot |
|---|---|
| Rtl blank gap and ltr answer | ![]() |
Written by rtibblesbot, an LLM-based coding agent.
Insert turned a select-all into one gap without the checks that a normal selection gets. So it dropped the gaps and line breaks in the paragraph.
Inside a gap, the arrows read the direction of the answer, which has dir="auto". A blank, digit or Latin answer in right-to-left text reads as left-to-right, so the caret went back and forth at the gap's edge.
Chrome removes a gap when a delete empties its answer. The handler only caught single-character deletes, so a word delete such as Ctrl+Backspace removed the gap. Now any delete that empties the answer goes through ProseMirror and leaves the gap blank.
The passage pointed to its helper text in every mode, but the helper text shows only while the card is in edit mode.
Some tests passed with the behavior removed. They now check the chip that shows an error, scope each check to its section or distractor, and check that Backspace and Delete are handled at the edges of an answer.
Text typed with an IME or a mobile keyboard is composed by the browser, and the page cannot cancel it. At the edge of a gap, Chrome composed on the wrong side of it, and in a blank gap the text was lost. A placeholder at the caret now gives Chrome text to compose into, and it is removed when the composition ends.
There was a problem hiding this comment.
PR #6354: two new blocking caret bugs at gaps; all prior findings addressed.
CI passes. Chromium QA (LTR, /ar/ RTL, emulated IME, blocked MathLive) found:
- blocking: Backspace/Delete ending after a gap moves the caret into it.
- blocking: ArrowUp from a drawn caret leaves the editor; typing is lost.
- suggestion: keyboard-pressed toolbar Undo/Redo announce nothing.
- suggestion: clicks and arrows skip the outside spot beside a line-edge gap.
- suggestion: a failed MathLive load retries only when that gap's answer changes.
Prior-finding status
RESOLVED — useGapMatchPassage.js — Select-all + Insert deletes gaps and joins hard-broken lines
RESOLVED — GapNode.js — RTL arrow keys stuck at a gap
RESOLVED — GapNode.js — Ctrl+Backspace/Delete emptying an answer removes the gap
RESOLVED — GapNode.js — IME text in a blank gap lands outside it
RESOLVED — useGapMatchInteraction.js — gaps has no production reader
RESOLVED — useGapMatchPassage.js:114 — Insert announces nothing
RESOLVED — passageGaps.js — formula gap named by raw LaTeX
RESOLVED — Editor.spec.js:170 — empty-gap test doesn't check the chip
RESOLVED — Editor.spec.js:199 — distractor-error tests pass on the wrong chip
RESOLVED — GapNode.spec.js:238 — tests pass with stayInGap returning false
RESOLVED — Editor.spec.js — getByText prompt assertion not scoped
RESOLVED — GapChip.spec.js — doc.content.size - 3
RESOLVED — useGapMatchPassage.spec.js — wrong comment about press
ACKNOWLEDGED — EditableChipListInlineOnly.spec.js:32 — merge into EditableChipList.spec.js (whole-file jest.mock reason stands)
RESOLVED — qtiEditorStrings.js:646 — {number} contexts
RESOLVED — useGapMatchPassage.js — aria-describedby dangles after Close
RESOLVED — GapChip/index.vue — adjacent gaps join into one pill
RESOLVED — GapNode.js — restating comments
RESOLVED — useGapMatchPassage.js — keyboard Insert announces nothing
RESOLVED — GapNode.js — Shift+Ctrl+Backspace over a whole answer removes the gap
RESOLVED — GapNode.js:122 — IME text before a trailing blank gap never reaches the model
RESOLVED — GapNode.js:221 — drawn caret differs from where typing lands
RESOLVED — GapNode.js:231 — Ctrl+Backspace in a blank gap does nothing
RESOLVED — GapNode.js — raw U+200B PLACEHOLDER
RESOLVED — passageGaps.js — failed MathLive import never retried
RESOLVED — GapNode.js — transformPasted comment contradicts the code
Next step: Needs a full code review by a human engineer, then manual QA once that reviewer sets Needs QA.
- User-visible files changed:
contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/index.vue,contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useDistractors.js,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useGapMatchInteraction.js…. - Whether a contenteditable editor that redraws the caret and redirects input at gap edges is a pattern worth maintaining, against a simpler gap model.
- Real IME and Android keyboard input in and beside gaps, including Korean syllable chaining, which QA could only emulate.
- Mouse clicks at a gap's exact edges, and gap editing in Firefox and Safari.
QA captures (28)
Insert a gap from the keyboard in a gap match passage (Tab to the toolbar's Insert, press Enter/Space, both before the passage was ever focused and after typing in it) and with the mouse — the live region announces the new gap by name each time, the caret lands inside the blank gap, and no announcement fires twice or for an unfocused editor
| Step | Screenshot |
|---|---|
| Keyboard insert after typing | ![]() |
| Keyboard insert before typing | ![]() |
1-keyboard-and-mouse-insert.webm
Select a gap's whole answer (double-click, Shift+arrows, Ctrl+A inside the gap) and remove it with Backspace, Delete, Cut and typing over it, then select only part of an answer or a range spanning gap and surrounding text and delete — the gap stays blank only when its whole answer was selected, partial deletes edit the answer, cross-boundary deletes behave normally, Undo restores each
| Step | Screenshot |
|---|---|
| Partial delete edits answer | ![]() |
| Whole answer deleted gap blank | ![]() |
2-whole-and-partial-deletes.webm
Clear a gap's answer, then press Ctrl+Backspace and Ctrl+Shift+Backspace (and Ctrl+Backspace inside a gap that still has an answer, and in plain text beside a gap) — a blank gap is removed with its announcement, a non-blank gap only loses text word-wise, plain text word-deletion still works, Undo restores the gap
| Step | Screenshot |
|---|---|
| Blank gap removed ctrl backspace | ![]() |
| Caret in blank gap | ![]() |
| Nonblank gap word deleted | ![]() |
| Plain text word deleted | ![]() |
3-ctrl-backspace-removes-blank-gap.webm
Put the caret at each edge of a gap (click, arrow keys, Home/End) where text sits beside it, then type, paste and delete — characters land on the side the caret is drawn (inside vs outside the gap), the gap shows a focus outline only while it holds the caret and the editor is focused, and the outline disappears on blur
| Step | Screenshot |
|---|---|
| After backspace caret in gap | ![]() |
| Blurred no outline | ![]() |
| Caret after gap no outline | ![]() |
| Caret inside gap end outline | ![]() |
Build passages where a gap has no text beside it (gap at the start or end of a paragraph, two gaps adjacent, a gap alone in a line) and move the caret beside it with arrows and clicks — a blinking drawn caret shows in the right spot, the browser's own caret is hidden there and only there, typing inserts outside the gap at that spot, and the drawn caret vanishes on blur and when a non-empty selection is made
| Step | Screenshot |
|---|---|
| Drawn caret after lone gap | ![]() |
| Drawn caret between adjacent gaps | ![]() |
| Drawn caret paragraph end after gap | ![]() |
| Drawn caret paragraph start before gap | ![]() |
Repeat the gap-edge caret, outline and drawn-caret checks under /ar/ (RTL) with LTR and RTL text around gaps — arrows still move in the direction of the text, the drawn caret sits on the correct visual side, and typing lands where the caret shows
| Step | Screenshot |
|---|---|
| Rtl drawn caret between gaps | ![]() |
| Rtl outline latin gap | ![]() |
| Rtl passage caret in arabic gap | ![]() |
| Rtl passage open | ![]() |
6-rtl-arrows-across-gaps.webm
Type into a blank gap and at a gap's edges with emulated IME composition (CDP Input.imeSetComposition / insertText, e.g. Japanese kana to kanji) — composed text commits inside the gap or beside it as the caret showed, no zero-width placeholder character remains in the answer or saved XML, and the gap name updates to the committed answer
| Step | Screenshot |
|---|---|
| Composed kanji in and beside gaps | ![]() |
7-ime-compose-in-and-beside-gap.webm
Block the mathlive chunk with request interception, put a formula (e.g. ½) in a gap's answer and check the gap's accessible name, then unblock and trigger renaming again (edit the answer, add another gap) — no unhandled error on failure, and after unblocking MathLive loads and the gap is named with the spoken formula ("half")
| Step | Screenshot |
|---|---|
| Blocked gap named latex | ![]() |
| Unblocked gap named half | ![]() |
Written by rtibblesbot, an LLM-based coding agent.
After Chrome deleted text next to a gap, its caret read back as inside the gap, so the next character went into the answer. A collapsed delete that ends beside a gap now goes through ProseMirror.
The editor is not focused yet when the toolbar runs the command, so a keyboard press on Undo or Redo announced nothing.
After a failed load, a formula gap kept its LaTeX name until its own answer changed. Gaps that are only read kept it until a reload.
From a gap at the end of one paragraph, the arrows went straight into a gap at the start of the next, and back. They skipped the spots outside both.
In Chrome, ArrowUp beside the drawn caret moved the browser's selection out of the editor, so the next keys typed were lost. The editor now finds the line above or below itself while the drawn caret shows.
A click in the blank space past a gap at the end of a line, or before one at its start, put the caret inside the gap, so typing went into the answer.
There was a problem hiding this comment.
PR #6354: all 31 prior findings resolved; 6 new findings on the delta.
CI passing. Manual QA ran in Chrome, LTR and RTL. Delete beside gaps, arrows across paragraph breaks and RTL clicks all pass.
- blocking: a click on a gap's leading edge edits the previous gap (
gapCaret.js:131). - suggestion: arrows skip a block image (
GapNode.js:231); Up/Down to an off-screen line loses the column (gapCaret.js:78); Undo of a range deletion doesn't announce the gaps it restores (useGapMatchPassage.js:139); a reopened passage doesn't retry MathLive (useGapMatchPassage.js:115). - nitpick: comments that restate code (
passageGaps.js:30).
Prior-finding status
RESOLVED — useGapMatchPassage.js — Select-all + Insert deletes existing gaps and joins hard-broken lines
RESOLVED — GapNode.js — RTL arrow keys stuck at a gap whose answer direction differs
RESOLVED — GapNode.js — Ctrl+Backspace/Ctrl+Delete that empties an answer removes the gap
RESOLVED — GapNode.js — IME/Android text in a blank gap lands outside it
RESOLVED — useGapMatchInteraction.js — gaps has no production reader
RESOLVED — useGapMatchPassage.js:118 — Insert announces nothing
RESOLVED — passageGaps.js — formula gap named by raw LaTeX
RESOLVED — Editor.spec.js:170 — empty-gap test doesn't check the chip's error
RESOLVED — Editor.spec.js:199 — distractor-error tests pass with the message on the wrong chip
RESOLVED — GapNode.spec.js:268 — tests pass with stayInGap returning false
RESOLVED — Editor.spec.js — getByText unscoped
RESOLVED — GapChip.spec.js — doc.content.size - 3 relies on the fixture
RESOLVED — useGapMatchPassage.spec.js — wrong comment about press
RESOLVED — EditableChipListInlineOnly.spec.js:32 — could be a describe block (the other file mocks TipTapEditor)
RESOLVED — qtiEditorStrings.js:646 — {number} contexts missing
RESOLVED — useGapMatchPassage.js — aria-describedby dangles after Close
RESOLVED — GapChip/index.vue — adjacent gaps render as one pill
RESOLVED — GapNode.js — comments restate names
RESOLVED — useGapMatchPassage.js — keyboard Insert announces nothing
RESOLVED — GapNode.js — Shift+Ctrl+Backspace over a whole answer removes the gap
RESOLVED — GapNode.js:126 — IME text before a trailing blank gap never reaches the model
RESOLVED — GapNode.js:225 — drawn caret isn't where typing lands
RESOLVED — GapNode.js:239 — Ctrl+Backspace in a blank gap does nothing
RESOLVED — GapNode.js — PLACEHOLDER is an invisible raw U+200B
RESOLVED — passageGaps.js — failed MathLive import never retried
RESOLVED — GapNode.js — comment contradicts the drop check
RESOLVED — GapNode.js:275 — delete beside a gap moves the caret into the gap
RESOLVED — gapCaret.js:20 — ArrowUp from a drawn caret leaves the editor
RESOLVED — gapCaret.js:15 — outside spot beside a gap at a line's edge hard to reach
RESOLVED — useGapMatchPassage.js — toolbar Undo/Redo announce nothing
RESOLVED — passageGaps.js:17 — formula gap keeps its LaTeX name after a failed load
Next step: Needs a light-touch code review by a human engineer, then manual QA.
- User-visible files changed:
contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/index.vue,contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useDistractors.js,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useGapMatchInteraction.js…. - Click on each edge of adjacent gaps and type, and check the text lands in the gap that was clicked.
- With a real IME or Android keyboard, type at every gap edge and in a blank gap.
- With a screen reader, check that Undo and Redo announce every gap they remove or restore.
QA captures (18)
In a gap match passage, put the caret just after a gap and press Backspace, then just before a gap and press Delete, in plain text and at a paragraph start/end — the neighbouring character goes, the gap and its answer stay intact, the caret is drawn outside the gap and typing next lands outside it
| Step | Screenshot |
|---|---|
| Ctrl backspace from gap start removes gaps | ![]() |
| Paragraph end backspace drawn caret | ![]() |
| Paragraph start delete drawn caret | ![]() |
1-backspace-delete-beside-gaps.webm
In a gap match passage spanning several paragraphs, use Left/Right arrows from a gap at the end of one paragraph and the start of the next — the caret crosses the paragraph break, stops once on each side of the gap, and never skips a position or gets stuck
| Step | Screenshot |
|---|---|
| Caret after gap paragraph end | ![]() |
| Caret before gap paragraph start | ![]() |
| Rtl caret after gap paragraph end | ![]() |
2-arrows-across-paragraph-breaks.webm
In a multi-line gap match passage, place the caret beside a gap (drawn caret showing) and press ArrowUp and ArrowDown repeatedly, including from the first and last lines and with Shift held — the caret moves to the line above/below at roughly the same column, stays inside the editor, scrolls into view, and Shift still extends a selection
| Step | Screenshot |
|---|---|
| Drawn caret after owl | ![]() |
| Onscreen up from owl | ![]() |
3-arrow-up-down-from-drawn-caret.webm
Repeat the click-past-gap and ArrowUp/ArrowDown checks in an RTL-language passage (e.g. Arabic content) — the caret lands outside the gap on the correct visual side and line movement still works
| Step | Screenshot |
|---|---|
| Rtl arrowdown drawn caret | ![]() |
| Rtl click past gap caret outside | ![]() |
| Rtl passage setup | ![]() |
Insert, delete and edit a gap in a passage, then use the toolbar Undo and Redo buttons by keyboard (Tab to the button, Enter/Space) and by mouse — each undo/redo restores the gaps and a screen-reader live-region announcement describes the gap change; keyboard shortcut undo still announces once
| Step | Screenshot |
|---|---|
| Passage after insert delete edit | ![]() |
6-toolbar-undo-redo-gaps.webm
Open an existing gap match question whose gaps hold formula answers, with the MathLive chunk request blocked so the first load fails, then unblock it and change or add a gap, and separately reopen the question — gap names in the answer list and gap aria-labels switch from raw LaTeX to spoken formula text without editing that gap's own answer
| Step | Screenshot |
|---|---|
| After unblock other gap edit spoken labels | ![]() |
| Reopened exercise spoken labels | ![]() |
Written by rtibblesbot, an LLM-based coding agent.
Tiptap's node view kept ProseMirror from handling a click on a gap's padding, and Chrome put the caret at the end of the node before the gap. After another gap, typing went into that gap's answer.
Crossing a paragraph break next to a gap skipped a block image between the paragraphs. The arrows now cross only into the next paragraph.
The lint autofix turned the assertion into toHaveTextContent on a ProseMirror node, which only takes a DOM node, so the test failed.
The line above or below was found with posAtCoords, which finds nothing outside the viewport, so the caret often went to the line's start. The line is now found from the coordinates of the positions toward it.
A restored gap was announced only when the caret ended up inside it, so undoing a range delete said nothing. Every added gap is now announced: one by its name, several by their count.
Reopening a passage reuses its editor, so the load ran again only when the gaps changed, and formula gaps kept their LaTeX names after a failed load.
Alt+Backspace is the word delete on macOS, and it did nothing in a blank gap.
In Firefox the caret got stuck at the start of an answer whose direction differs from the passage, for example digits or Latin text in an Arabic passage. The arrows now enter such an answer at its visual edge and cross it in its own direction, in every browser.
There was a problem hiding this comment.
PR #6354: all prior findings resolved or acknowledged. One new suggestion, inline.
CI passing. Manual QA did not complete, so the UI is untested.
Prior-finding status
RESOLVED — useGapMatchPassage.js — Select-all + Insert deletes gaps and joins hard-broken lines
RESOLVED — GapNode.js — RTL arrow keys stuck at a gap with a different answer direction
RESOLVED — GapNode.js — Ctrl+Backspace/Delete that empties an answer removes the gap
RESOLVED — GapNode.js — IME/Android text typed into a blank gap lands outside it
RESOLVED — useGapMatchInteraction.js — gaps has no production reader
RESOLVED — useGapMatchPassage.js:118 — Insert announces nothing
RESOLVED — passageGaps.js — formula gap named by raw LaTeX
RESOLVED — Editor.spec.js:170 — empty-gap test doesn't check the chip
RESOLVED — Editor.spec.js:199 — distractor-error tests pass with the message on the wrong chip
RESOLVED — GapNode.spec.js:269 — tests pass with stayInGap returning false
RESOLVED — Editor.spec.js — getByText prompt assertion scope
RESOLVED — GapChip.spec.js — doc.content.size - 3 fixture dependence
RESOLVED — useGapMatchPassage.spec.js — wrong comment about press
ACKNOWLEDGED — EditableChipListInlineOnly.spec.js:32 — could be a describe block
RESOLVED — qtiEditorStrings.js:646 — {number} contexts
RESOLVED — useGapMatchPassage.js — aria-describedby dangles after Close
RESOLVED — GapChip/index.vue — adjacent gaps render as one pill
RESOLVED — GapNode.js — restating comments
RESOLVED — useDistractors.js:8 — praise
RESOLVED — useGapMatchPassage.js — keyboard Insert announces nothing
RESOLVED — GapNode.js — Shift+Ctrl+Backspace over the whole answer removes the gap
RESOLVED — GapNode.js:163 — IME text before a trailing blank gap never reaches the model
RESOLVED — GapNode.js — drawn caret at gap edges isn't where typing lands
RESOLVED — GapNode.js:267 — Ctrl+Backspace in a blank gap does nothing
RESOLVED — GapNode.js — PLACEHOLDER invisible raw U+200B
RESOLVED — passageGaps.js — failed MathLive import never retried
RESOLVED — GapNode.js — transformPasted comment contradicts the code
RESOLVED — GapNode.js:306 — Backspace/Delete leaves the caret inside the gap
RESOLVED — gapCaret.js:20 — ArrowUp from a drawn caret leaves the editor
RESOLVED — gapCaret.js:15 — outside spot beside a line-edge gap is hard to reach
RESOLVED — useGapMatchPassage.js — toolbar Undo/Redo announce nothing
RESOLVED — passageGaps.js:17 — formula gap keeps its LaTeX name after a failed load
RESOLVED — gapCaret.js — click on a gap's leading edge sends typing outside it
RESOLVED — GapNode.js — arrows skip a block image between paragraphs
RESOLVED — gapCaret.js — off-screen target line loses the caret's column
RESOLVED — useGapMatchPassage.js:138 — undoing a range deletion restores gaps silently
RESOLVED — useGapMatchPassage.js:115 — reopening the passage doesn't retry the MathLive load
RESOLVED — passageGaps.js — loadFormulaSpeech docstring repeats the body
Next step: Needs a light-touch code review by a human engineer, then manual QA.
- User-visible files changed:
contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/index.vue,contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useDistractors.js,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useGapMatchInteraction.js…. - Use a real IME and an Android keyboard to type into blank gaps and at gap edges, and check the text lands in the model.
- With Arabic text, check that arrows, clicks and Backspace at gap edges put the caret where typing lands.
- With a screen reader, check the announcements for Insert, undo/redo and formula gaps.
QA captures (50)
Write a gap match passage with two adjacent gaps and click each gap's leading edge, padding and trailing edge, then type — the caret lands inside the clicked gap, typing goes into that gap's answer and never into the gap before it, clicks past a gap at a line's start or end put the caret outside it
1-adjacent-gap-clicks.webm
Arrow Left/Right through an LTR passage across text, a gap's edges, two adjacent gaps and a paragraph break beside a gap with a block image between the paragraphs — the caret enters and leaves each gap one step at a time, stops at the block image instead of skipping it, and typing at each stop lands where the drawn caret shows
2-ltr-arrow-walk.webm
Arrow Left/Right through an RTL passage (Arabic text) with gaps holding Arabic answers and gaps holding English answers that run the other way — the caret moves visually in the arrow's direction inside and outside every gap in both Chrome and Firefox, enters a reversed gap at the visually near end, and never gets stuck at a gap edge
| Step | Screenshot |
|---|---|
| Chrome arrowleft paris contact sheet | ![]() |
3-chrome-rtl-arrow-walk-paris.webm
Arrow Up/Down with the drawn caret beside or inside a gap in a passage long enough to scroll, starting mid-line next to a gap — the caret keeps its column on the next line, including when that line is scrolled off-screen, and reaches lines several lines away without jumping to a line's start
4-arrow-up-down-walk-offscreen-and-image-skip.webm
Remove blank gaps with Backspace, Delete, Ctrl/Cmd+Backspace, Shift+Ctrl/Cmd+Backspace and Alt+Backspace from inside and beside a gap, then try Alt+Backspace in a gap holding an answer — blank gaps go with a single key press, a filled gap's answer is word-deleted rather than the gap removed, and the screen-reader live region announces each removal
| Step | Screenshot |
|---|---|
| After ctrl backspace answers lost | ![]() |
| After ctrl backspace france emptied | ![]() |
| Before ctrl backspace mid word | ![]() |
| Before ctrl backspace | ![]() |
| Blank gap inserted | ![]() |
| Blank gap removed alt backspace | ![]() |
5-ctrl-backspace-beside-blank-gap.webm
Delete one gap, then several gaps in one selection, then undo each with Ctrl/Cmd+Z and redo — the live region announces the removed gap by name, "N gaps removed", then on undo the restored gap's name or "N gaps added", the gap numbering and answer list match the restored passage
| Step | Screenshot |
|---|---|
| Deleted one | ![]() |
| Deleted several | ![]() |
| Initial | ![]() |
| Undo one | ![]() |
| Undo several | ![]() |
6-delete-undo-redo-ctrl-y.webm
Add gaps whose answers are formulas, close the passage editor, reopen it and edit text without touching the gaps — MathLive loads again, formula gaps are announced by spoken formula names rather than raw LaTeX, and the question still saves and reopens with the formulas intact
| Step | Screenshot |
|---|---|
| Blocked mathlive latex names | ![]() |
| Closed card formula gaps | ![]() |
| First formula gap insert | ![]() |
| Formula gaps added | ![]() |
| Reloaded formulas intact | ![]() |
| Reopened spoken names | ![]() |
7-failed-load-then-reopen-spoken-names.webm
Written by rtibblesbot, an LLM-based coding agent.
Superseded by my review of 75d80b6: no blocking findings remain.
After a failure, every keystroke in a passage with a formula gap sent a new chunk request. Reopening the passage now retries only when it becomes editable, and no retry starts within 10 seconds of a failure.
There was a problem hiding this comment.
PR #6354: 37 of 38 prior findings are resolved. The author's rebuttal on the contested one stands. No new findings. CI is passing. Manual QA confirmed the MathLive retry wait and spoken gap names.
Prior-finding status
RESOLVED — useGapMatchPassage.js — Select-all + Insert deletes gaps and joins hard-broken lines
RESOLVED — GapNode.js — RTL arrows stuck at a gap whose answer direction differs
RESOLVED — GapNode.js — Ctrl+Backspace/Delete that empties an answer removes the gap
RESOLVED — GapNode.js — IME/Android text in a blank gap lands outside it
RESOLVED — useGapMatchInteraction.js — gaps has no production reader
RESOLVED — useGapMatchPassage.js:118 — Insert announces nothing
RESOLVED — passageGaps.js — formula gap named by raw LaTeX
RESOLVED — Editor.spec.js:170 — empty-gap test doesn't check the chip
RESOLVED — Editor.spec.js:199 — distractor-error tests too weak
RESOLVED — GapNode.spec.js:269 — tests pass with stayInGap returning false
RESOLVED — Editor.spec.js — unscoped getByText for the prompt
RESOLVED — GapChip.spec.js — doc.content.size - 3 relies on fixture
RESOLVED — useGapMatchPassage.spec.js — wrong comment about press
CONTESTED — EditableChipListInlineOnly.spec.js:32 — merge into EditableChipList.spec.js (rebuttal stands: file-wide jest.mock of TipTapEditor)
RESOLVED — qtiEditorStrings.js:646 — {number} contexts
RESOLVED — useGapMatchPassage.js — aria-describedby dangles after Close
RESOLVED — GapChip/index.vue — adjacent gaps render as one pill
RESOLVED — GapNode.js — comments restate code
RESOLVED — useGapMatchPassage.js — keyboard Insert announces nothing
RESOLVED — GapNode.js — Shift+Ctrl+Backspace over whole answer removes the gap
RESOLVED — GapNode.js:163 — IME text before trailing blank gap never reaches the model
RESOLVED — GapNode.js — drawn caret isn't where typing lands at gap edges
RESOLVED — GapNode.js:267 — Ctrl+Backspace in a blank gap does nothing
RESOLVED — GapNode.js — invisible raw U+200B PLACEHOLDER
RESOLVED — passageGaps.js — failed MathLive import never retried
RESOLVED — GapNode.js — transformPasted drop comment contradicts code
RESOLVED — GapNode.js:306 — Backspace/Delete moves caret into the gap
RESOLVED — gapCaret.js:20 — ArrowUp moves selection out of the editor
RESOLVED — gapCaret.js:15 — outside spot at a line's edge hard to reach
RESOLVED — useGapMatchPassage.js — toolbar Undo/Redo announce nothing
RESOLVED — passageGaps.js:20 — formula gap keeps LaTeX name after failed load
RESOLVED — gapCaret.js — click on a gap's leading edge types outside it
RESOLVED — GapNode.js — arrows skip a block image between paragraphs
RESOLVED — gapCaret.js — off-screen target line loses the column
RESOLVED — useGapMatchPassage.js:138 — undoing a range deletion announces nothing
RESOLVED — useGapMatchPassage.js:115 — reopening doesn't retry MathLive
RESOLVED — passageGaps.js — docstring repeats the body
RESOLVED — useGapMatchPassage.js — failed MathLive load retries on every keystroke
Next step: The code needs no human code review; it needs manual QA to confirm the behaviour.
- User-visible files changed:
contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/index.vue,contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useDistractors.js,contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useGapMatchInteraction.js…. - In an exercise created in the same page load, type
The sky is, Insert,clear, ArrowRight, thenand; QA gotThe sky is [clear]nd[a], with an extra unnumbered gap (./.reviews/qa-evidence/3-space-after-final-gap.webm). - Check gap-edge typing with a real IME and an Android keyboard; the author tested only Chrome's DevTools IME emulation.
QA captures (17)
Add a gap whose answer holds a formula to a gap-match passage, close the passage, then reopen it — the gap's accessible name and live-region announcement read the formula as spoken text (not raw LaTeX) while editing, after closing, and after reopening; the mathlive chunk is fetched once
| Step | Screenshot |
|---|---|
| Formula gaps after closing | ![]() |
| Formula gaps reopened after reload | ![]() |
| Formula gaps while editing | ![]() |
| Passage after reload | ![]() |
1-formula-gap-edit-close-reopen.webm
Load the editor on an existing gap-match question whose gaps hold formulas, with the passage read-only, then open it for editing and type ordinary text in it — names switch from LaTeX to spoken text once mathlive loads; typing text and toggling edit mode cause no repeated mathlive requests or console errors
| Step | Screenshot |
|---|---|
| Editing before mathlive latex | ![]() |
| Passage editing text typed | ![]() |
| Readonly gaps latex before mathlive | ![]() |
| Readonly gaps spoken after mathlive | ![]() |
2-latex-to-spoken-edit-toggle.webm
Edit a gap-match passage with no formulas, type text and add plain-text gaps, then add a formula to one gap — no mathlive request until the first formula exists, then one request and the gap's name becomes spoken text
| Step | Screenshot |
|---|---|
| Formula added to gap | ![]() |
| Plain gaps before formula | ![]() |
| Space after final gap | ![]() |
3-space-after-final-gap.webm
Block the mathlive chunk with request interception, open a passage with formula gaps, then unblock it and close/reopen the passage both within 10 seconds and after 10 seconds — names fall back to LaTeX with no uncaught errors; no refetch inside the wait; after the wait, reopening the passage (or adding a formula gap) refetches and names become spoken text
| Step | Screenshot |
|---|---|
| After wait spoken names | ![]() |
| Within wait latex names | ![]() |
4-mathlive-retry-wait.webm
Written by rtibblesbot, an LLM-based coding agent.







































































































































Summary
Gap match items could be parsed and saved (#6203), but not edited: they opened read-only, and the question type selector did not offer gap match. This adds the gap match editor and turns the question type on.
The change
EditableChipListgets aninlineOnlyprop, which is off by default.useDistractors. Their behavior does not change.Known limits:
Demo
gap-match-editor-demo.mp4
References
Closes #6204
Reviewer guidance
#/qti-demoto its URL. Add a new question and choose Gap match. The question, the passage editor and the distractors show with their helper text./ar/. In the passage, the arrow keys move into and out of a gap in the direction of the text. Next to a gap whose answer is in Latin letters or digits, the caret crosses the answer in its own direction.AI usage
I used Claude Code to prototype the editable gap node, write the code and tests, and check the behavior in the browser. I reviewed and verified the changes.