From 0c11b8c0895d849343c4140dd0e29d8a2d863737 Mon Sep 17 00:00:00 2001 From: Russ deForest Date: Sat, 25 Jul 2026 22:37:49 -0400 Subject: [PATCH 1/2] Improve matching component look and feel Visual redesign of the matching component: - Draw connections as S-curved bezier paths with rounded caps instead of straight lines, and mark the attachment points with port dots on the inner edge of each box so the connect-a-line affordance is visible. - Tighten the workspace (780px) so the two columns sit closer together; boxes in a column share a uniform width and premises are left-aligned. - Softer box styling (theme background, 1px border, radius, subtle shadow) with hover states, replacing the gray heavy-border look. - Show graded results on the boxes themselves (green/red fill via the parsons theme variables) in addition to coloring the lines. - Replace the debug-style score readout with a score badge plus a 'N correct / N incorrect / N missing' summary line, and only render the feedback block when the author actually provided feedback text. - Give the empty connections panel a helpful hint instead of a bare 'Connections:' header. Interaction fixes made along the way: - Releasing a drag on empty space no longer throws (the old code dereferenced endBox.dataset unconditionally), which also leaked the document-level pointer listeners it failed to remove. - matching_connection events are only logged when a connection is actually created, and the leftover console.log is gone. - Connecting two boxes in the same column flashes the boxes and reports the problem via the aria-live region instead of a blocking alert(). - Stale correct/incorrect marks are cleared when the user changes their connections after grading. - Removed the always-true (e.ctrlKey || e.metaKey || true) condition and the CSS rules that leaked out of the component (bare button, body, h2, and * selectors). Co-Authored-By: Claude Fable 5 --- .../runestone/matching/css/matching.css | 247 ++++++++++++------ .../runestone/matching/js/matching.js | 160 +++++++----- 2 files changed, 262 insertions(+), 145 deletions(-) diff --git a/bases/rsptx/interactives/runestone/matching/css/matching.css b/bases/rsptx/interactives/runestone/matching/css/matching.css index a232bfa41..748938cfc 100644 --- a/bases/rsptx/interactives/runestone/matching/css/matching.css +++ b/bases/rsptx/interactives/runestone/matching/css/matching.css @@ -1,31 +1,26 @@ -* { - box-sizing: border-box; -} - -body { - font-family: sans-serif; - padding: 20px; - margin: 0; -} +/* Matching component styles. Everything here is scoped under + .ptx-runestone-container / .runestone-sphinx by matching.less, so avoid + bare element selectors that would leak into the page. Theme variables + come from common/css/variables.less. */ -h2 { - margin-bottom: 0.5em; -} - -/* Main wrapper for each matching component */ [data-component="matching"] { margin: 2rem 0; - font-family: sans-serif; +} + +[data-component="matching"] * { + box-sizing: border-box; } .matching-workspace { display: flex; justify-content: space-between; position: relative; - max-width: 1000px; + max-width: 780px; width: 100%; margin: 0 auto 1rem; - background: var(--componentBgColor); + padding: 1rem 8px; + user-select: none; + -webkit-user-select: none; } .left-column, @@ -33,48 +28,82 @@ h2 { display: flex; flex-direction: column; justify-content: center; - align-items: center; + align-items: stretch; gap: 12px; - padding: 10px; position: relative; z-index: 1; - max-width: 25vw; + flex: 0 1 38%; + min-width: 0; } - .left-column img, .right-column img { - max-width: 25vw; - /* Ensure images do not exceed 25% of the viewport width */ + max-width: 100%; + height: auto; } .box { - display: inline-flex; + position: relative; + display: flex; align-items: center; justify-content: center; - min-width: 120px; padding: 10px 14px; - min-height: 50px; - max-width: 100%; - background: var(--dropableBgColor, #f9f9f9); - border: 2px solid var(--boxBorderColor); + min-height: 48px; + background: var(--background, #ffffff); + border: 1px solid var(--componentBorderColor, #555555); border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); text-align: center; - font-weight: bold; + font-weight: 500; user-select: none; - cursor: grab; - text-align: center; + -webkit-user-select: none; + cursor: pointer; touch-action: none; - /* Prevents scrolling during tap+drag */ + /* touch-action: none prevents scrolling during tap+drag */ + transition: + border-color 0.15s ease, + background-color 0.15s ease, + box-shadow 0.15s ease; } -.matching-workspace, -.left-column, -.right-column, -.box, -body { - user-select: none; - -webkit-user-select: none; /* for iOS/Safari */ +/* Premises (left column) tend to be sentences; ragged-right reads better. */ +.box[data-role="drag"] { + justify-content: flex-start; + text-align: left; +} + +/* Connection ports: a small dot on the edge where lines attach, so the + affordance of "draw a line from here to there" is visible. */ +.box::after { + content: ""; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--background, #ffffff); + border: 2px solid var(--dragLineColor, #555555); + transition: + background-color 0.15s ease, + border-color 0.15s ease; +} + +.box[data-role="drag"]::after { + right: -6px; +} + +.box[data-role="drop"]::after { + left: -6px; +} + +.box:hover { + border-color: var(--dragLineColor, #000000); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); +} + +.box:hover::after { + background: var(--dragLineColor, #555555); } .box mjx-container { @@ -83,10 +112,29 @@ body { .box:focus { outline: 3px solid #3498db; + outline-offset: 1px; } .box.selected { outline: 3px dashed #27ae60; + outline-offset: 1px; +} + +/* Flash shown when the user tries to connect two boxes in the same column. */ +.box.invalid { + border-color: var(--draggableIncorrectBorder, red); + background: var(--draggableIncorrectBg, #f2dede); +} + +/* Graded state, applied to the boxes at each end of a connection. */ +.box.match-correct { + border-color: var(--parsonsCorrectBorderColor, #ade595); + background: var(--parsonsCorrectBgColor, #dff0d8); +} + +.box.match-incorrect { + border-color: var(--parsonsIncorrectBorderColor, #f2b6b6); + background: var(--parsonsIncorrectBgColor, #f2dede); } .connector-svg { @@ -97,76 +145,95 @@ body { height: 100%; pointer-events: none; z-index: 0; - background: var(--componentBgColor); } .line { - stroke: var(--dragLineColor); - ; - stroke-width: 3; + stroke: var(--dragLineColor, #333333); + stroke-opacity: 0.75; + stroke-width: 2.5; + fill: none; + stroke-linecap: round; cursor: pointer; - pointer-events: auto; - transition: stroke 0.2s ease, stroke-width 0.2s ease; + pointer-events: stroke; + transition: + stroke 0.15s ease, + stroke-width 0.15s ease, + stroke-opacity 0.15s ease; +} + +/* The in-progress line that follows the pointer during a drag. */ +.line.temp { + stroke-dasharray: 5 5; + stroke-opacity: 0.5; + pointer-events: none; } .line.correct { - stroke: green; + stroke: #2e8540; + stroke-opacity: 1; stroke-width: 3; } .line.incorrect { - stroke: #bf1521; - stroke-width: 2; - stroke-dasharray: 5 5; + stroke: #c62828; + stroke-opacity: 1; + stroke-dasharray: 6 5; } .line.highlighted { - stroke: #3cd6e7; - stroke-width: 3; + stroke-opacity: 1; + stroke-width: 4; } .line.faded { - stroke: #ccc; - stroke-width: 1; + stroke-opacity: 0.15; } .line:focus, .line:hover { stroke: #3498db; - stroke-dasharray: 6 4; - /* 6px dash, 4px gap */ - stroke-width: 5; + stroke-opacity: 1; + stroke-width: 4; outline: none; } .conn-list { - max-width: 800px; + max-width: 780px; margin: 0 auto 1rem; - padding: 10px; - background: var(--componentBgColor); - border: 1px solid #ccc; - border-radius: 6px; - font-size: 14px; + padding: 10px 14px; + background: var(--background, #ffffff); + border: 1px solid var(--componentBorderColor, #cccccc); + border-radius: 8px; + font-size: 0.9rem; } .conn-entry { margin: 4px 0; - font-family: monospace; } -button { - display: inline-block; - margin: 0.5rem; - font-size: 1rem; - padding: 8px 16px; - border: none; - border-radius: 6px; - cursor: grab; - transition: background 0.2s ease-in-out; +.conn-empty { + font-style: italic; + opacity: 0.75; } -button:hover { - background: #2980b9; +.match-results { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.match-score-badge { + font-weight: bold; + padding: 2px 12px; + border-radius: 999px; + border: 1px solid var(--parsonsIncorrectBorderColor, #f2b6b6); + background: var(--parsonsIncorrectBgColor, #f2dede); +} + +.match-score-badge.match-score-perfect { + border-color: var(--parsonsCorrectBorderColor, #ade595); + background: var(--parsonsCorrectBgColor, #dff0d8); } .aria-live { @@ -186,17 +253,27 @@ button:hover { margin-top: 1rem; } +.control-div button { + display: inline-block; + margin: 0; + font-size: 1rem; + padding: 8px 16px; + border: none; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s ease-in-out; +} + .control-div .help-button { background: #2c3e50; color: #ecf0f1; - width: 32px; - height: 32px; + width: 36px; + height: 36px; padding: 0; border-radius: 50%; font-size: 1.2rem; - line-height: 32px; + line-height: 36px; text-align: center; - cursor: pointer; } .control-div .help-button:hover { @@ -219,10 +296,9 @@ button:hover { /* modal content box */ .help-modal-content { - background-color: var(--questionBgColor); padding: 1rem 1.5rem; - border-radius: 6px; + border-radius: 8px; max-width: 80%; max-height: 80%; overflow: auto; @@ -260,4 +336,11 @@ button:hover { margin-top: 10px; margin-bottom: 10px; border-radius: 5px; -} \ No newline at end of file +} + +@media (max-width: 600px) { + .left-column, + .right-column { + flex-basis: 42%; + } +} diff --git a/bases/rsptx/interactives/runestone/matching/js/matching.js b/bases/rsptx/interactives/runestone/matching/js/matching.js index 04fe34ced..1aada545c 100644 --- a/bases/rsptx/interactives/runestone/matching/js/matching.js +++ b/bases/rsptx/interactives/runestone/matching/js/matching.js @@ -130,6 +130,9 @@ export class MatchingProblem extends RunestoneBase { } renderFeedback() { + this.allBoxes.forEach((box) => + box.classList.remove("match-correct", "match-incorrect"), + ); this.connections.forEach((conn) => { const idPair = [conn.fromBox.dataset.id, conn.toBox.dataset.id]; const isCorrect = this.boxData.correctAnswers.some( @@ -138,13 +141,24 @@ export class MatchingProblem extends RunestoneBase { ); conn.line.classList.remove("correct", "incorrect"); conn.line.classList.add(isCorrect ? "correct" : "incorrect"); + [conn.fromBox, conn.toBox].forEach((box) => { + if (!isCorrect) { + box.classList.remove("match-correct"); + box.classList.add("match-incorrect"); + } else if (!box.classList.contains("match-incorrect")) { + box.classList.add("match-correct"); + } + }); }); - this.connList.innerHTML = `Score: ${this.scorePercent}%
`; - this.connList.innerHTML += `
Correct: ${this.correctCount}`; - this.connList.innerHTML += `
Incorrect: ${this.incorrectCount}`; - this.connList.innerHTML += `
Missing: ${this.missingCount}`; - if (this.scorePercent !== 100) { + const badgeClass = + this.scorePercent === 100 ? " match-score-perfect" : ""; + this.connList.innerHTML = `
Score: ${this.scorePercent}%${this.correctCount} correct · ${this.incorrectCount} incorrect · ${this.missingCount} missing
`; + if ( + this.scorePercent !== 100 && + this.boxData.feedback && + this.boxData.feedback.trim() + ) { this.connList.innerHTML += `
Feedback: ${this.boxData.feedback}
`; } this.queueMathJax(this.connList); @@ -175,7 +189,7 @@ export class MatchingProblem extends RunestoneBase { this.connections.forEach((conn) => { const from = this.getRightBoxCenter(conn.fromBox); const to = this.getLeftBoxCenter(conn.toBox); - const line = this.createLineElement(from.x, from.y, to.x, to.y); + const line = this.createLineElement(from, to); line.fromBox = conn.fromBox; line.toBox = conn.toBox; this.svg.appendChild(line); @@ -257,7 +271,8 @@ export class MatchingProblem extends RunestoneBase { createConnList(container) { const connList = document.createElement("div"); connList.className = "conn-list"; - connList.innerHTML = "Connections:
"; + connList.innerHTML = + 'Connections:
No connections yet. Drag between boxes to connect them.
'; container.appendChild(connList); return connList; } @@ -416,15 +431,25 @@ export class MatchingProblem extends RunestoneBase { }; } - createLineElement(x1, y1, x2, y2) { + /* + * Connection lines are drawn as gentle S-curves (cubic beziers) whose + * control points pull horizontally out of each port, so lines leave and + * enter boxes perpendicular to the column edges. + */ + setLineEndpoints(line, from, to) { + const pull = Math.max(30, Math.abs(to.x - from.x) / 2); + line.setAttribute( + "d", + `M ${from.x} ${from.y} C ${from.x + pull} ${from.y}, ${to.x - pull} ${to.y}, ${to.x} ${to.y}`, + ); + } + + createLineElement(from, to) { const line = document.createElementNS( "http://www.w3.org/2000/svg", - "line", + "path", ); - line.setAttribute("x1", x1); - line.setAttribute("y1", y1); - line.setAttribute("x2", x2); - line.setAttribute("y2", y2); + this.setLineEndpoints(line, from, to); line.setAttribute("class", "line"); line.setAttribute("tabindex", "0"); // Make the line focusable line.setAttribute("focusable", "true"); // Make the line focusable @@ -472,8 +497,12 @@ export class MatchingProblem extends RunestoneBase { const toRole = toBox.dataset.role; if (fromRole === toRole) { - alert("You can only connect a draggable to a droppable."); - return; + this.flashInvalid(fromBox, toBox); + if (this.ariaLive) { + this.ariaLive.textContent = + "Connections must go between the left column and the right column."; + } + return false; } // we should always store connections as drag to drop @@ -481,11 +510,11 @@ export class MatchingProblem extends RunestoneBase { if (fromBox.dataset.role === "drop") { [fromBox, toBox] = [toBox, fromBox]; } - if (this.isConnected(fromBox, toBox)) return; + if (this.isConnected(fromBox, toBox)) return false; const from = this.getRightBoxCenter(fromBox); const to = this.getLeftBoxCenter(toBox); - const line = this.createLineElement(from.x, from.y, to.x, to.y); + const line = this.createLineElement(from, to); line.fromBox = fromBox; line.toBox = toBox; @@ -498,11 +527,35 @@ export class MatchingProblem extends RunestoneBase { if (this.ariaLive) { this.ariaLive.textContent = `Connected ${fromBox.textContent} to ${toBox.textContent}`; } + return true; + } + + flashInvalid(...boxes) { + boxes.forEach((box) => { + box.classList.add("invalid"); + setTimeout(() => box.classList.remove("invalid"), 500); + }); } updateConnectionModel() { - this.connList.innerHTML = "Connections:
"; + // Any change to the connections invalidates previously rendered + // grading marks, so clear them along with rebuilding the list. + this.allBoxes.forEach((box) => + box.classList.remove("match-correct", "match-incorrect"), + ); + this.connList.innerHTML = "Connections:"; + if (this.connections.length === 0) { + const empty = document.createElement("div"); + empty.className = "conn-entry conn-empty"; + empty.textContent = + "No connections yet. Drag between boxes to connect them."; + this.connList.appendChild(empty); + return; + } this.connections.forEach((conn) => { + if (conn.line) { + conn.line.classList.remove("correct", "incorrect"); + } const fromLabel = conn.fromBox.textContent; let toLabel = conn.toBox.textContent; if (!toLabel) { @@ -549,29 +602,15 @@ export class MatchingProblem extends RunestoneBase { attachEvents() { this.allBoxes.forEach((box) => { box.addEventListener("pointerdown", (e) => { - if (e.ctrlKey || e.metaKey || true) { - e.preventDefault(); - this.startBox = box; - const from = this.getRightBoxCenter(this.startBox); - this.tempLine = this.createLineElement( - from.x, - from.y, - from.x, - from.y, - ); - this.tempLine.setAttribute("stroke", "gray"); - this.tempLine.setAttribute("stroke-dasharray", "4"); - this.svg.appendChild(this.tempLine); - - document.addEventListener( - "pointermove", - this.updateTempLine, - ); - document.addEventListener( - "pointerup", - this.finishConnection, - ); - } + e.preventDefault(); + this.startBox = box; + const from = this.getRightBoxCenter(this.startBox); + this.tempLine = this.createLineElement(from, from); + this.tempLine.classList.add("temp"); + this.svg.appendChild(this.tempLine); + + document.addEventListener("pointermove", this.updateTempLine); + document.addEventListener("pointerup", this.finishConnection); }); box.addEventListener("keydown", (e) => { @@ -616,10 +655,7 @@ export class MatchingProblem extends RunestoneBase { this.connections.forEach((conn) => { const from = this.getRightBoxCenter(conn.fromBox); const to = this.getLeftBoxCenter(conn.toBox); - conn.line.setAttribute("x1", from.x); - conn.line.setAttribute("y1", from.y); - conn.line.setAttribute("x2", to.x); - conn.line.setAttribute("y2", to.y); + this.setLineEndpoints(conn.line, from, to); }); }); } @@ -628,14 +664,12 @@ export class MatchingProblem extends RunestoneBase { e.preventDefault(); if (!this.startBox || !this.tempLine) return; const from = this.getRightBoxCenter(this.startBox); - this.tempLine.setAttribute("x1", from.x); - this.tempLine.setAttribute("y1", from.y); const containerRect = this.workspace.getBoundingClientRect(); - const x = e.clientX - containerRect.left; - const y = e.clientY - containerRect.top; - - this.tempLine.setAttribute("x2", x); - this.tempLine.setAttribute("y2", y); + const to = { + x: e.clientX - containerRect.left, + y: e.clientY - containerRect.top, + }; + this.setLineEndpoints(this.tempLine, from, to); }; finishConnection = (e) => { @@ -660,17 +694,17 @@ export class MatchingProblem extends RunestoneBase { (box) => box.contains(targetElement) && box !== this.startBox, ); - if (this.startBox && endBox) - this.createPermanentLine(this.startBox, endBox); - // - console.log( - `connected ${this.startBox.dataset.id ? this.startBox.dataset.id : "null"} to ${endBox.dataset.id ? endBox.dataset.id : "null"}`, - ); - this.logBookEvent({ - event: "matching_connection", - div_id: this.divid, - act: `connected ${this.startBox.dataset.id ? this.startBox.dataset.id : "null"} to ${endBox.dataset.id ? endBox.dataset.id : "null"}`, - }); + if ( + this.startBox && + endBox && + this.createPermanentLine(this.startBox, endBox) + ) { + this.logBookEvent({ + event: "matching_connection", + div_id: this.divid, + act: `connected ${this.startBox.dataset.id} to ${endBox.dataset.id}`, + }); + } this.startBox = null; document.removeEventListener("pointermove", this.updateTempLine); document.removeEventListener("pointerup", this.finishConnection); From ae2d79179d60e5d0ca2bc1f6574ab7ddfb19e6c7 Mon Sep 17 00:00:00 2001 From: Russ deForest Date: Sun, 26 Jul 2026 11:23:43 -0400 Subject: [PATCH 2/2] Address PR feedback: drag-from-right anchoring and keyboard tab flow - Anchor the temporary drag line to the box's port edge (left edge for right-column boxes) via a new getPortCenter helper, instead of always using the right edge. The bezier control points now also pull toward the other endpoint, so a line dragged leftward curves correctly. - When a box is selected with Enter, move focus to the top box of the opposite column so the user doesn't tab through the rest of the column and every connection line to reach a match; with nothing selected, natural tab order still visits the lines so they can be deleted. Escape cancels a selection and returns focus to the selected box. - Update the help modal and aria-live announcements to describe the new keyboard flow. Co-Authored-By: Claude Code --- .../runestone/matching/js/matching.js | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/bases/rsptx/interactives/runestone/matching/js/matching.js b/bases/rsptx/interactives/runestone/matching/js/matching.js index 1aada545c..62a09bbf5 100644 --- a/bases/rsptx/interactives/runestone/matching/js/matching.js +++ b/bases/rsptx/interactives/runestone/matching/js/matching.js @@ -321,7 +321,7 @@ export class MatchingProblem extends RunestoneBase { this.helpModal = document.createElement("div"); this.helpModal.className = "help-modal"; const text = `

Click and drag between boxes to create connections.

-

Use the tab key to navigate to a box and press Enter to select the box. Then tab to the connecting box and press Enter to create a connection between the two selected boxes.

+

Use the tab key to navigate to a box and press Enter to select it. Focus then jumps to the other column; tab to the box you want to connect and press Enter. Press Escape to cancel a selection.

Click on a connection line to remove it. You can also use the tab key to select lines. Press the delete key to remove a selected line.

Click the "Check Me" button to check your connections, and save your work.

Click the "Reset" button to clear all connections.

`; @@ -431,6 +431,15 @@ export class MatchingProblem extends RunestoneBase { }; } + // The port (where lines attach) is on the inner edge of the box: the + // right edge for left-column (drag) boxes, the left edge for + // right-column (drop) boxes. + getPortCenter(el) { + return el.dataset.role === "drop" + ? this.getLeftBoxCenter(el) + : this.getRightBoxCenter(el); + } + /* * Connection lines are drawn as gentle S-curves (cubic beziers) whose * control points pull horizontally out of each port, so lines leave and @@ -438,9 +447,13 @@ export class MatchingProblem extends RunestoneBase { */ setLineEndpoints(line, from, to) { const pull = Math.max(30, Math.abs(to.x - from.x) / 2); + // Pull the control points toward the other endpoint so the curve + // leaves the port heading in the right direction; sign handles a + // temp line dragged leftward from a right-column port. + const sign = to.x >= from.x ? 1 : -1; line.setAttribute( "d", - `M ${from.x} ${from.y} C ${from.x + pull} ${from.y}, ${to.x - pull} ${to.y}, ${to.x} ${to.y}`, + `M ${from.x} ${from.y} C ${from.x + sign * pull} ${from.y}, ${to.x - sign * pull} ${to.y}, ${to.x} ${to.y}`, ); } @@ -604,7 +617,7 @@ export class MatchingProblem extends RunestoneBase { box.addEventListener("pointerdown", (e) => { e.preventDefault(); this.startBox = box; - const from = this.getRightBoxCenter(this.startBox); + const from = this.getPortCenter(this.startBox); this.tempLine = this.createLineElement(from, from); this.tempLine.classList.add("temp"); this.svg.appendChild(this.tempLine); @@ -619,6 +632,18 @@ export class MatchingProblem extends RunestoneBase { if (!this.selectedBox) { this.selectedBox = box; box.classList.add("selected"); + // Jump focus to the top of the opposite column so + // the user doesn't have to tab through the rest of + // this column and every connection line to get + // there. (With nothing selected, natural tab order + // still visits the lines so they can be deleted.) + const opposite = this.allBoxes.find( + (b) => b.dataset.role !== box.dataset.role, + ); + if (opposite) opposite.focus(); + if (this.ariaLive) { + this.ariaLive.textContent = `Selected ${box.textContent}. Tab to a box in the other column and press Enter to connect, or press Escape to cancel.`; + } } else { if (box !== this.selectedBox) this.createPermanentLine(this.selectedBox, box); @@ -629,6 +654,15 @@ export class MatchingProblem extends RunestoneBase { if (next) next.focus(); else this.allBoxes[0].focus(); } + } else if (e.key === "Escape" && this.selectedBox) { + e.preventDefault(); + const selected = this.selectedBox; + selected.classList.remove("selected"); + this.selectedBox = null; + selected.focus(); + if (this.ariaLive) { + this.ariaLive.textContent = "Selection cancelled."; + } } }); @@ -663,7 +697,7 @@ export class MatchingProblem extends RunestoneBase { updateTempLine = (e) => { e.preventDefault(); if (!this.startBox || !this.tempLine) return; - const from = this.getRightBoxCenter(this.startBox); + const from = this.getPortCenter(this.startBox); const containerRect = this.workspace.getBoundingClientRect(); const to = { x: e.clientX - containerRect.left,