Skip to content

Add the gap match editor - #6354

Open
akolson wants to merge 49 commits into
learningequality:unstablefrom
akolson:gap-match-editor
Open

akolson wants to merge 49 commits into
learningequality:unstablefrom
akolson:gap-match-editor

Conversation

@akolson

@akolson akolson commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

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

  • The question type selector offers Gap match, and gap match items open for editing.
  • Insert in the passage toolbar adds a blank gap at the cursor, or at the end of the passage before it is focused. Over highlighted text on one line, it turns that text into the gap's answer and keeps its inline formatting. It is disabled across lines, inside a gap, and over a selection that holds a gap.
  • A gap's answer is typed straight into the gap. A gap with an answer shows as a solid chip, and a blank gap as a dashed one. Each gap is named by its answer and position, for example "winter, gap 1" or "Empty gap 2". A formula in an answer reads as MathLive speaks it, for example "half" for ½. The passage announces the gaps that Insert or an undo adds, that the caret enters, or that a deletion removes.
  • The gap node handles Chrome's caret behavior at the edges of an editable inline element. That covers typing into a blank gap, including composed input from an IME or a mobile keyboard, any delete that empties an answer, Backspace and Delete at its edges, and the arrow keys, which follow the direction of the text around the gap and move visually through an answer that runs the other way. The editor keeps the browser caret on the correct side of each gap edge, outlines the gap that holds the caret, and draws the caret where no text is beside it. A click at a gap's edge puts the caret on the side that was clicked. A copied, pasted or split gap gets a fresh identifier. Content pasted into a gap becomes one line of its answer, with its inline formatting and formulas.
  • Distractors are added, edited and removed as chips, and each is edited in the inline-only mode. EditableChipList gets an inlineOnly prop, which is off by default.
  • Validation errors show on the passage (no gap, or a gap with no answer) and under each distractor (blank, or repeating another item), and they mark the question as incomplete.
  • View mode shows the question, and the passage under Answers, following Show answers.
  • Associate and match now share their distractor edits with gap match through useDistractors. Their behavior does not change.

Known limits:

  • Composed input (IME) is tested with Chrome's IME emulation, and the caret behavior in Chrome and Firefox. Safari is not tested.
  • The toolbar's Paste button inserts without the editor's paste handling, so pasting several paragraphs into a gap with it still splits the gap. Each part keeps a unique identifier.
  • The toolbar's Code block button drops every inline node in its paragraph. This includes gaps, inline choice chips and math, so it is a separate problem.

Demo

gap-match-editor-demo.mp4

References

Closes #6204

Reviewer guidance

  1. Open a channel you can edit, and add #/qti-demo to its URL. Add a new question and choose Gap match. The question, the passage editor and the distractors show with their helper text.
  2. Click the passage, type a sentence and press Insert. A dashed gap appears with the cursor inside it. Type an answer, and the gap turns solid.
  3. Make a word bold, highlight it and press Insert. The word becomes a gap's answer and stays bold. Highlight text across two lines, and Insert is disabled.
  4. Clear a gap's answer with Backspace, and the gap stays, blank. Press Backspace again, and the gap is removed. Undo restores it with its answer.
  5. Copy text that holds a gap and paste it elsewhere. The copy is a second gap with the same answer.
  6. Add, edit and remove a distractor. Its editor takes a single line.
  7. Leave a gap blank, or remove every gap. An error shows above the passage, and the card is marked incomplete.
  8. Close the card and switch Show answers on and off. The gaps show or hide their answers.
  9. Open the page under /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.
  10. With a Japanese or Chinese IME, or on an Android phone, type into a blank gap. The text goes into the gap.

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.

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.
@akolson
akolson marked this pull request as ready for review October 28, 2026 14:43
@akolson
akolson requested review from a team, leq-bot and rtibblesbot October 28, 2026 14:43
@rtibblesbot

rtibblesbot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

🟡 Waiting for changes

Outcome: reviewed 76d230e

Last updated: 2026-10-29 14:35 UTC

@akolson akolson changed the title Add the gap match editor [WIP] Add the gap match editor Oct 28, 2026

@rtibblesbot rtibblesbot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 inlineOnly EditableChipList 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 filled before switch
Gap match new card Gap match new card
Type dropdown gap match Type dropdown gap match
Type selector 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 Answered gap solid
Blank gap after insert Blank gap after insert
Closed card after save Closed card after save
Exercise reopened after full reload Exercise reopened after full reload
Reopened after 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 Bold word selected
Closed card formatted gaps Closed card formatted gaps
Formula selected Formula selected
Insert disabled caret in gap Insert disabled caret in gap
Insert disabled two lines Insert disabled two lines
Three formatted gaps 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 Caret before gap
Gap emptied stays blank Gap emptied stays blank
Gap filled with trailing text 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 Copy paste gap duplicate
Passage after paste and enter Passage after paste and enter
Rich paste into gap 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 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 open
Blank error Blank error
Duplicate errors Duplicate errors
Inline editor open 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 closed
Blank gap passage open Blank gap passage open
Card closed blank gap Card closed blank gap
Fixed closed Fixed closed
No gap closed No gap closed
One of two gaps blank 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 no question
Closed show answers on Closed show answers on
Closed with question answers hidden Closed with question answers hidden
No question reopened No question reopened
No question show answers on 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 Closed cards before edit
Edited before close Edited before close
Reopened after reload Reopened after reload
Seeded item opens editable 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 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.
@akolson
akolson requested a review from rtibblesbot October 29, 2026 06:27

@rtibblesbot rtibblesbot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 after typing
Keyboard insert before 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 Partial delete edits answer
Whole answer deleted gap blank 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 Blank gap removed ctrl backspace
Caret in blank gap Caret in blank gap
Nonblank gap word deleted Nonblank gap word deleted
Plain text 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 After backspace caret in gap
Blurred no outline Blurred no outline
Caret after gap no outline Caret after gap no outline
Caret inside gap end 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 after lone gap
Drawn caret between adjacent gaps Drawn caret between adjacent gaps
Drawn caret paragraph end after gap Drawn caret paragraph end after gap
Drawn caret paragraph start before 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 drawn caret between gaps
Rtl outline latin gap Rtl outline latin gap
Rtl passage caret in arabic gap Rtl passage caret in arabic gap
Rtl passage open 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 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 Blocked gap named latex
Unblocked gap named half 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.
@akolson
akolson requested a review from rtibblesbot October 29, 2026 09:01

@rtibblesbot rtibblesbot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Ctrl backspace from gap start removes gaps
Paragraph end backspace drawn caret Paragraph end backspace drawn caret
Paragraph start delete 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 after gap paragraph end
Caret before gap paragraph start Caret before gap paragraph start
Rtl caret after gap paragraph end 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 Drawn caret after owl
Onscreen up from 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 arrowdown drawn caret
Rtl click past gap caret outside Rtl click past gap caret outside
Rtl passage setup 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 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 After unblock other gap edit spoken labels
Reopened exercise 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.
@akolson
akolson requested a review from rtibblesbot October 29, 2026 11:26

@rtibblesbot rtibblesbot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Step Screenshot
Adjacent gaps after typing Adjacent gaps after typing
Before line start gap caret Before line start gap caret
Click gap2 leading edge Click gap2 leading edge
Click past line end gap Click past line end gap
Line start gap padding caret Line start gap padding caret
Passage ar Passage ar
Passage ltr rtl Passage ltr rtl
Past line end gap caret Past line end gap caret
Rtl answer left padding click Rtl answer left padding click
Rtl answer left padding typed Rtl answer left padding typed
Typed gap2 leading edge Typed gap2 leading edge
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

Step Screenshot
Block image selected Block image selected
Caret after europe Caret after europe
Caret before france Caret before france
Caret inside europe end Caret inside europe end
Caret inside europe start Caret inside europe start
Caret inside france start Caret inside france start
Drawn caret between adjacent gaps Drawn caret between adjacent gaps
Drawn caret paragraph start before gap Drawn caret paragraph start before gap
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 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

Step Screenshot
Arrowdown scrolls in next line same column Arrowdown scrolls in next line same column
Arrowup from asia skips image Arrowup from asia skips image
Arrowup from mid text selects image Arrowup from mid text selects image
Drawn caret aa0 bb0 at scroller bottom Drawn caret aa0 bb0 at scroller bottom
Drawn caret before asia below image Drawn caret before asia below image
Second arrowdown lands before aa9 gap Second arrowdown lands before aa9 gap
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 answers lost
After ctrl backspace france emptied After ctrl backspace france emptied
Before ctrl backspace mid word Before ctrl backspace mid word
Before ctrl backspace Before ctrl backspace
Blank gap inserted Blank gap inserted
Blank gap removed alt backspace 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 one
Deleted several Deleted several
Initial Initial
Undo one Undo one
Undo several 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 Blocked mathlive latex names
Closed card formula gaps Closed card formula gaps
First formula gap insert First formula gap insert
Formula gaps added Formula gaps added
Reloaded formulas intact Reloaded formulas intact
Reopened spoken names Reopened spoken names
7-failed-load-then-reopen-spoken-names.webm

Written by rtibblesbot, an LLM-based coding agent.

@rtibblesbot
rtibblesbot dismissed stale reviews from themself October 29, 2026 11:53

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.
@akolson
akolson requested a review from rtibblesbot October 29, 2026 12:06
@akolson akolson changed the title [WIP] Add the gap match editor Add the gap match editor Oct 29, 2026

@rtibblesbot rtibblesbot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, then and; QA got The 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 after closing
Formula gaps reopened after reload Formula gaps reopened after reload
Formula gaps while editing Formula gaps while editing
Passage after reload 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 Editing before mathlive latex
Passage editing text typed Passage editing text typed
Readonly gaps latex before mathlive Readonly gaps latex before mathlive
Readonly gaps spoken after 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 Formula added to gap
Plain gaps before formula Plain gaps before formula
Space after final gap 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 After wait spoken names
Within wait latex names Within wait latex names
4-mathlive-retry-wait.webm

Written by rtibblesbot, an LLM-based coding agent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[QTI] Gap match interaction editor, with the gap node and Insert action

3 participants