diff --git a/AGENTS.md b/AGENTS.md index 2579689..e0f1f78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,10 @@ Each `patterns//` contains pure `logic.js`, server `render.js`, DOM `enhan `lib/base.css` owns the shared look and the `--lp-*` tokens; `pattern.css` holds only what one pattern needs, with classes prefixed `lp--`. The rules follow Praxity Studio's published-course design: - One bordered box per activity and none inside it. Split sections inside the box with `lp-section` (space and a top rule). A pattern that sits in running text uses `lp-unboxed` on its root and `lp-box` on each activity. -- Text roles: `lp-stem` (question stem, sans 600), `lp-label`, `lp-run-in`, `lp-small`, `lp-quote`. Source Sans 3 throughout, a small set of sizes, weights 400 and 600, sentence case, no uppercase labels. -- Choices are full-width `lp-choice` rows; selection is a 2px accent border (padding 1px smaller so the row keeps its size) with a soft accent tint; no shadow rings, since shadows belong to cards and floating layers only. After submit, mark rows with `data-lp-mark="correct|wrong"` and a `lp-choice-mark` line with an icon and a word. +- Text roles: `lp-stem` (question stem, sans 600), `lp-label`, `lp-run-in`, `lp-small`, `lp-quote`. Source Sans 3 throughout, headings included. Hosts often give h1 to h3 a display face, so `lib/base.css` sets the body face on every heading inside `.lp`. A small set of sizes, weights 400 and 600, sentence case, no uppercase labels. `em` is synthesised italic, since a host may load no italic face. +- The scene header holds one title, the task name or a question, and no subtitle line. Context such as a scoring rule goes below the header. A section right under the header draws no top rule. Conversation's role line under the person's name is pending an owner decision. +- Choices are full-width `lp-choice` rows; selection is a 2px accent border (padding 1px smaller so the row keeps its size) with a soft accent tint; no shadow rings, since shadows belong to cards and floating layers only. After submit, mark rows with `data-lp-mark="correct|wrong|missed"` and a `lp-choice-mark` line with an icon and a word: "Correct", "Incorrect", and "Correct answer" for a correct answer the learner didn't choose (French "Correct", "Incorrect", "Bonne réponse"). The `missed` row is neutral and dashed. Explanations and encouragement go in the feedback text, not the mark. Locked rows show no hover or pointer. +- Keyboard focus is a 3px ring in `--lp-focus` (Highlight in forced colours) on every control. Selects use the arrow `lib/base.css` draws 14px inside the border; forced colours get the native one. - Buttons: `lp-button` (one primary action), `lp-button-secondary`, `lp-button-quiet` (with an icon, for Start over and Try again). Status uses `lp-met`, `lp-missed` and `lp-neutral` with an icon and a word, never colour alone. - A thick single-side border (an accent stripe) only ever sits on a straight edge. If the element has rounded corners, use an even 1px border or none. Most stripes are decoration; leave them out. - Body text and question stems use `text-wrap: pretty`; short headings and titles use `text-wrap: balance` (set in `lib/base.css`). diff --git a/lib/base.css b/lib/base.css index 0a6d47d..88042ac 100644 --- a/lib/base.css +++ b/lib/base.css @@ -55,6 +55,11 @@ /* Body text and question stems avoid a lone last word; short headings and titles balance. */ .lp :where(p, li, dd, figcaption, label, legend), .lp-stem { text-wrap: pretty; } .lp :where(h2, h3, h4), .lp-scene-title, .lp-run-in { text-wrap: balance; } +/* Headings keep the pattern's face. Hosts often give h1 to h3 a display face (praxity.io uses + Bricolage), and a direct heading rule beats the font .lp sets for inheritance. */ +.lp :where(h1, h2, h3, h4, h5, h6), .lp-scene-title, .lp-run-in { font-family: var(--lp-font-heading); } +/* Hosts that load no italic face and set font-synthesis: none would show upright. */ +.lp em { font-synthesis-style: auto; } .lp > * + *, .lp-stack > * + * { margin-block-start: 16px; } /* Text roles */ @@ -85,6 +90,8 @@ /* A section inside the card: space and a hairline, never another card. */ .lp-section { margin-block-start: 24px; padding-block-start: 24px; border-block-start: 1px solid var(--lp-rule); } .lp-section > * + * { margin-block-start: 16px; } +/* The scene header already ends in a rule, so a section right under it would draw a second line. */ +.lp-scene + .lp-section, .lp-scene + :not(.lp-section) > .lp-section:first-child { margin-block-start: 0; padding-block-start: 0; border-block-start: 0; } /* Fields */ .lp-input { @@ -101,8 +108,13 @@ } textarea.lp-input { min-block-size: 8rem; resize: vertical; line-height: 1.6; } .lp-input:hover { border-color: var(--lp-ink-2); } +/* The select arrow sits 14px inside the border in every engine. Padding doesn't move Chromium's + native arrow, so this draws one (stroke is --lp-ink-2, which a data URL can't read). */ +select.lp-input { appearance: none; padding-inline-end: 40px; background: var(--lp-paper) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23555c67' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6l6 -6'/%3E%3C/svg%3E") no-repeat right 14px center / 16px; } .lp-input[readonly] { color: var(--lp-ink); background: var(--lp-paper-2); border-color: var(--lp-line-mid); } .lp-input[aria-invalid="true"] { border: 2px solid var(--lp-error-ink); padding: 10px 13px; } +/* The error border takes 1px of the end padding; the label still clears the drawn arrow. */ +select.lp-input[aria-invalid="true"] { padding-inline-end: 39px; } .lp-error-text { display: flex; gap: 8px; align-items: flex-start; color: var(--lp-error-ink); font-size: var(--lp-type-small); font-weight: 600; } /* Choice rows, after Tally: a light row, a letter key for single choice, a square for @@ -128,7 +140,7 @@ textarea.lp-input { min-block-size: 8rem; resize: vertical; line-height: 1.6; } /* Selection and marks draw a 2px border with padding 1px smaller, so the row keeps its size. No shadow rings: shadows belong to cards and floating layers only (shared with Studio). */ .lp-choice:has(input:checked) { border: 2px solid var(--lp-accent); padding: 9px 13px; background: var(--lp-accent-soft); } -.lp-choice:has(input:focus-visible) { outline: 2px solid var(--lp-focus); outline-offset: 2px; } +.lp-choice:has(input:focus-visible) { outline: 3px solid var(--lp-focus); outline-offset: 2px; } /* Single choice: the radio sits under a letter key. */ .lp-choice:has(> input[type="radio"])::before { /* Empty CSS alt text keeps the visual key out of screen-reader browse text. */ @@ -198,7 +210,12 @@ textarea.lp-input { min-block-size: 8rem; resize: vertical; line-height: 1.6; } .lp-choice-mark.lp-met { color: var(--lp-success-ink); } .lp-choice-mark.lp-missed { color: var(--lp-error-ink); } .lp-choice-mark.lp-neutral { color: var(--lp-ink-2); } -.lp-choice:has(input:disabled) { cursor: default; } +/* A locked row shows no hover or pointer. Marked and chosen rows keep their own styles. */ +.lp-choice:has(input:disabled):not([data-lp-mark], :has(input:checked)):hover { border-color: var(--lp-line-mid); background: var(--lp-paper); } +.lp-choice:has(input:disabled), .lp-choice > input:disabled { cursor: default; } +/* A correct answer the learner didn't choose: neutral, and dashed so it doesn't read as selected. */ +.lp-choice[data-lp-mark="missed"] { border: 2px dashed var(--lp-ink-2); padding: 9px 13px; background: var(--lp-paper-2); } +.lp-choice[data-lp-mark="missed"] > .lp-choice-key, .lp-choice[data-lp-mark="missed"]:has(> input[type="radio"])::before { color: var(--lp-ink); border-color: var(--lp-ink-2); } .lp-choice > input[type="checkbox"]:disabled { opacity: 1; } /* A pattern that sits in running text keeps its root plain and makes each activity a card. */ @@ -267,8 +284,11 @@ textarea.lp-input { min-block-size: 8rem; resize: vertical; line-height: 1.6; } /* Disclosures open at once: a rise after the panel has opened reads as a twitch (owner, 2026-10-07). */ .lp a { color: var(--lp-accent-ink); text-underline-offset: 3px; } +/* A 3px ring in the focus colour: the 2px one, or a host's own ring, faded on white cards. Outlines + follow each control's corners. A native radio outside a choice row still gets a square ring in + Chromium, which ignores border-radius on themed radios; draw the radio in CSS if a pattern needs one. */ .lp :is(a, button, textarea, select, summary, input:not([type="radio"], [type="checkbox"])):focus, -.lp [tabindex="-1"]:focus-visible { outline: 2px solid var(--lp-focus); outline-offset: 2px; } +.lp :is([tabindex="-1"], input[type="radio"], input[type="checkbox"]):not(.lp-choice > input):focus-visible { outline: 3px solid var(--lp-focus); outline-offset: 2px; } .lp-input:focus { border-color: var(--lp-accent); } .lp-input[aria-invalid="true"]:focus { border-color: var(--lp-error-ink); } @@ -309,7 +329,10 @@ textarea.lp-input { min-block-size: 8rem; resize: vertical; line-height: 1.6; } .lp-button { color: ButtonText; background: ButtonFace; border-color: ButtonText; } .lp-button-quiet { border-color: transparent; color: LinkText; } .lp a { color: LinkText; } + .lp-choice[data-lp-mark="missed"] { border: 2px dashed CanvasText; } + /* The native arrow comes back, since the drawn one doesn't take system colours. */ + select.lp-input { appearance: auto; padding-inline-end: 14px; background-image: none; } .lp :is(a, button, textarea, select, summary, input:not([type="radio"], [type="checkbox"])):focus, - .lp [tabindex="-1"]:focus-visible { outline-color: Highlight; } + .lp :is([tabindex="-1"], input[type="radio"], input[type="checkbox"]):not(.lp-choice > input):focus-visible { outline-color: Highlight; } .lp-choice:has(input:focus-visible) { outline-color: Highlight; } } diff --git a/patterns/conversation/pattern.css b/patterns/conversation/pattern.css index 34ba104..ea62f2e 100644 --- a/patterns/conversation/pattern.css +++ b/patterns/conversation/pattern.css @@ -15,7 +15,7 @@ .lp-conversation-composer { overflow: hidden; border: 1px solid var(--lp-line); border-radius: var(--lp-radius-control); } .lp-conversation-composer:has(textarea[aria-invalid="true"]) { border-color: var(--lp-error-ink); } /* The box rings only for the text. The send button keeps its own outline rather than doubling it. */ -.lp-conversation-composer:has(textarea:focus) { outline: 2px solid var(--lp-accent); outline-offset: 2px; } +.lp-conversation-composer:has(textarea:focus) { outline: 3px solid var(--lp-focus); outline-offset: 2px; } .lp-conversation-input.lp-input { padding: 14px 16px; border: 0; border-radius: 0; } .lp-conversation textarea.lp-conversation-input:focus { outline: none; } .lp-conversation-input::placeholder { color: var(--lp-ink-2); opacity: 1; } diff --git a/patterns/dont-know/dont-know.test.js b/patterns/dont-know/dont-know.test.js index e9fcf7f..e0fd520 100644 --- a/patterns/dont-know/dont-know.test.js +++ b/patterns/dont-know/dont-know.test.js @@ -172,10 +172,11 @@ test('quiz scene renders escaped authored titles, bilingual question numbers and } }); -test('owner audit: scene contains only icon and title', () => { +test('owner audit: scene contains only icon and title, with the scoring rule under it', () => { for (const lang of ['en', 'fr']) { const markup = render(content, strings[lang], { id: 'audit', lang }); - assert.doesNotMatch(markup, /lp-scene-label/); + assert.doesNotMatch(markup, /lp-scene-label|lp-scene-sub/); + assert.match(markup, lang === 'en' ? /<\/header>\s*

A right answer scores a point\./ : /<\/header>\s*

Une bonne réponse rapporte un point\./); } }); diff --git a/patterns/dont-know/enhance.js b/patterns/dont-know/enhance.js index 88c30f9..9d407e3 100644 --- a/patterns/dont-know/enhance.js +++ b/patterns/dont-know/enhance.js @@ -97,12 +97,14 @@ export function enhance(root, { content, strings, state }) { const row = rows[index]; const correct = radio.value === q.correct; const unknown = radio.value === DONT_KNOW; + // An unchosen correct answer is "missed": neutral, so it doesn't read as the learner's pick. + const missed = correct && !right; if (unknown) row.classList.add('lp-dont-know-unknown'); - else row.dataset.lpMark = correct ? 'correct' : 'wrong'; + else row.dataset.lpMark = missed ? 'missed' : correct ? 'correct' : 'wrong'; const mark = root.ownerDocument.createElement('span'); - mark.className = `lp-choice-mark ${correct ? 'lp-met' : unknown ? 'lp-neutral' : 'lp-missed'}`; - const word = correct ? right ? strings.markCorrect : strings.markAnswer : unknown ? strings.markUnknown : strings.markWrong; - mark.innerHTML = `${correct ? icons.check : unknown ? icons['question-mark'] : icons.x}${html(word, root.lang)}`; + mark.className = `lp-choice-mark ${missed || unknown ? 'lp-neutral' : correct ? 'lp-met' : 'lp-missed'}`; + const word = missed ? strings.markAnswer : correct ? strings.markCorrect : unknown ? strings.markUnknown : strings.markWrong; + mark.innerHTML = `${missed ? icons['circle-dashed'] : correct ? icons.check : unknown ? icons['question-mark'] : icons.x}${html(word, root.lang)}`; row.append(mark); }); } diff --git a/patterns/dont-know/render.js b/patterns/dont-know/render.js index 7969860..e6e6550 100644 --- a/patterns/dont-know/render.js +++ b/patterns/dont-know/render.js @@ -26,9 +26,9 @@ export function render(content, strings, { id, lang }) { return `

${icons['list-check']} -

${html(content.title, lang)}

-

${html(scoring, lang)}

+

${html(content.title, lang)}

+

${html(scoring, lang)}

${content.questions.map((q, n) => `

${html(format(strings.questionNumber, { number: n + 1, total: content.questions.length }), lang)}

diff --git a/patterns/dont-know/strings.js b/patterns/dont-know/strings.js index 0d0a5bc..2e3fd97 100644 --- a/patterns/dont-know/strings.js +++ b/patterns/dont-know/strings.js @@ -26,7 +26,7 @@ export const strings = { countUnknownOne: '{count} "I don\'t know"', countUnknownMany: '{count} "I don\'t know"', markCorrect: 'Correct', - markWrong: 'Not quite', + markWrong: 'Incorrect', markAnswer: 'Correct answer', markUnknown: "You chose \"I don't know\"", review: 'Review', @@ -62,7 +62,7 @@ export const strings = { countUnknownOne: '{count} « Je ne sais pas »', countUnknownMany: '{count} « Je ne sais pas »', markCorrect: 'Correct', - markWrong: 'Pas tout à fait', + markWrong: 'Incorrect', markAnswer: 'Bonne réponse', markUnknown: 'Vous avez choisi « Je ne sais pas »', review: 'À revoir', diff --git a/patterns/first-answer/pattern.css b/patterns/first-answer/pattern.css index 33a9489..3198b71 100644 --- a/patterns/first-answer/pattern.css +++ b/patterns/first-answer/pattern.css @@ -19,7 +19,7 @@ .lp-first-answer-check-text { min-inline-size: 0; } /* base.css covers these targets only on :focus-visible. Pointer actions focus them too. Move this programmatic-focus rule to the shared stylesheet when its focus selector expands. */ -.lp-first-answer-heading:focus, .lp-first-answer-saved:focus { outline: 2px solid var(--lp-focus); outline-offset: 2px; } +.lp-first-answer-heading:focus, .lp-first-answer-saved:focus { outline: 3px solid var(--lp-focus); outline-offset: 2px; } @media (forced-colors: active) { .lp-first-answer-heading:focus, .lp-first-answer-saved:focus { outline-color: Highlight; } .lp-first-answer-timeline::before { border-color: CanvasText; } diff --git a/patterns/highlight/README.md b/patterns/highlight/README.md index eed3510..f93399c 100644 --- a/patterns/highlight/README.md +++ b/patterns/highlight/README.md @@ -40,7 +40,7 @@ Follows the shared baseline in the root README. - Arrow shortcuts work when the screen reader passes them to the page. Left or Up moves back; Right or Down moves forward. They wrap at the ends. Home and End jump to the first and last chunks. Each chunk exposes its pressed state and links to keyboard instructions. - Checked chunks stay readable and navigable, with feedback beside correct marks, missed targets and other selections. - At the limit, selecting another chunk shows and announces a message below the passage. Removing a mark clears it. -- Found targets have green highlights and solid underlines; missed targets have dashed underlines and no highlight. Other selections stay yellow, with readable notes in neutral pills. Icons and words identify outcomes, including in forced colours. +- Found targets have green highlights and solid underlines; missed targets have grey dashed underlines and no highlight. Other selections stay yellow, with an "Incorrect" mark and the author's note in a neutral pill. The marks read "Correct", "Correct answer" for a missed target, and "Incorrect" (French "Correct", "Bonne réponse", "Incorrect"). Icons and words identify outcomes, including in forced colours. - Check announces the target count. If Check has focus when it hides, focus moves to Start over. Reset focuses the first chunk silently. - Known screen reader behaviour: when NVDA reads line by line and a chunk wraps onto the next line, it says "toggle button, not pressed" again at the start of that line. NVDA does the same for any link that wraps. The chunks stay inline so the passage reads as paragraphs. @@ -58,7 +58,7 @@ Authors write plain text. Rendering escapes HTML characters. | `paragraphs[][].id` | Unique across the passage. Letters, digits, underscores and hyphens only. | | `paragraphs[][].text` | Text without leading or trailing whitespace. One space joins adjacent chunks. | | `paragraphs[][].key` | Optional boolean. `true` marks a target. At least one target is required. | -| `paragraphs[][].note` | Optional explanation for a selected non-target. Follows "Not a key idea" in key mode; replaces default wrong-selection feedback in evidence mode. Also appears in the native answer. | +| `paragraphs[][].note` | Optional explanation for a selected non-target. Follows its "Incorrect" mark in both modes. Also appears in the native answer. | Text fields must contain non-whitespace text. Unknown fields and duplicate ids are rejected. The schema describes the shape, question requirement and required target. Its `x-uniqueChunkIds` annotation needs the global id check in `validateContent`. @@ -71,6 +71,8 @@ Authored text is capped at 120 characters for titles and labels, 400 for questio `logic.js`, `strings.js` and `content.schema.json` have no DOM dependencies. Praxity Studio can use them with its own interface. +`strings.js` marks answers with `markCorrect`, `markWrong` and `markAnswer`. The older keys `correctKey`, `correctEvidence`, `wrongKey`, `wrongEvidence` and `missed` are deprecated. They hold the same words for hosts that still read them; move to the new keys. + | Function | Returns | | --- | --- | | `validateContent(content)` | Nothing. Throws an `Error` naming the first bad field. | diff --git a/patterns/highlight/enhance.js b/patterns/highlight/enhance.js index 3dca1a1..e962ae9 100644 --- a/patterns/highlight/enhance.js +++ b/patterns/highlight/enhance.js @@ -90,16 +90,11 @@ export function enhance(root, { content, strings, state }) { if (item.outcome === 'unmarked') return; chunk.setAttribute('data-lp-outcome', item.outcome); mark.setAttribute('data-lp-outcome', item.outcome); - let label; let icon; - if (item.outcome === 'correct') { - label = content.mode === 'key' ? strings.correctKey : strings.correctEvidence; icon = icons.check; - } else if (item.outcome === 'missed') { - label = strings.missed; icon = icons['circle-plus']; - } else { - label = content.mode === 'key' ? `${strings.wrongKey}${item.note ? ` ${item.note}` : ''}` : (item.note ?? strings.wrongEvidence); - icon = icons['info-circle']; - } - mark.innerHTML = `${icon}${html(label, root.lang)}`; + if (item.outcome === 'correct') mark.innerHTML = `${icons.check}${html(strings.markCorrect, root.lang)}`; + else if (item.outcome === 'missed') mark.innerHTML = `${icons['circle-dashed']}${html(strings.markAnswer, root.lang)}`; + // The mark word stays short; the author's note carries the explanation after it. A hidden + // full stop gives screen readers a pause between the word and the note. + else mark.innerHTML = `${icons.x}${html(strings.markWrong, root.lang)}${item.note ? `. ${html(item.note, root.lang)}` : ''}`; mark.hidden = false; chunk.setAttribute('aria-describedby', `${instructions.id} ${mark.id}`); }); diff --git a/patterns/highlight/highlight.test.js b/patterns/highlight/highlight.test.js index 72280dd..988a9ae 100644 --- a/patterns/highlight/highlight.test.js +++ b/patterns/highlight/highlight.test.js @@ -177,6 +177,9 @@ test('render has a plain passage, authored fallback, hidden controls and one emp assert.match(html, /data-lp-flow hidden/); assert.match(html, /data-lp-fallback/); assert.ok(html.includes('This explains your feelings.')); + // The native answer labels targets and noted distractors with the shared mark words. + assert.match(html, /Correct answer<\/span> You withdraw\./); + assert.match(html, /Incorrect<\/span> You feel overwhelmed\./); assert.equal((html.match(/role="status"/g) || []).length, 1); assert.match(html, /role="status" aria-atomic="true"><\/p>/); assert.ok(html.includes('data-lp-count')); @@ -214,3 +217,15 @@ test('English and French UI keys and placeholders match, and all four examples v assert.equal(check(key, []).total, 4); } }); + +test('deprecated mark keys stay for hosts that still read them, in both languages', () => { + // Praxity Studio reads these until it moves to markCorrect, markWrong and markAnswer. + for (const lang of ['en', 'fr']) { + for (const key of ['correctKey', 'correctEvidence', 'wrongKey', 'wrongEvidence', 'missed']) { + assert.equal(typeof strings[lang][key], 'string', `${lang}.${key}`); + assert.ok(strings[lang][key].trim(), `${lang}.${key} is empty`); + } + assert.equal(strings[lang].missed, strings[lang].markAnswer); + assert.equal(strings[lang].wrongKey, strings[lang].markWrong); + } +}); diff --git a/patterns/highlight/pattern.css b/patterns/highlight/pattern.css index 9b612a5..1e218dc 100644 --- a/patterns/highlight/pattern.css +++ b/patterns/highlight/pattern.css @@ -3,11 +3,12 @@ .lp-highlight-chunk { padding-block: 2px; border-radius: 3px; -webkit-box-decoration-break: clone; box-decoration-break: clone; } .lp-highlight-chunk[role="button"] { cursor: pointer; } .lp-highlight-chunk[role="button"]:not([aria-disabled="true"]):hover { text-decoration: underline; text-underline-offset: 4px; } -.lp-highlight-chunk[role="button"]:focus { outline: 2px solid var(--lp-focus); outline-offset: 2px; } +.lp-highlight-chunk[role="button"]:focus { outline: 3px solid var(--lp-focus); outline-offset: 2px; } /* The paper highlighter is local to this reading activity; shared tokens supply result inks. */ .lp-highlight-chunk[aria-pressed="true"] { background: #ffe49a; } .lp-highlight-chunk[data-lp-outcome="correct"] { background: var(--lp-success-soft); text-decoration: underline solid var(--lp-success-ink) 2px; text-underline-offset: 4px; } -.lp-highlight-chunk[data-lp-outcome="missed"] { text-decoration: underline dashed var(--lp-warning-ink) 2px; text-underline-offset: 4px; } +/* A target the learner didn't mark is neutral: it shows the answer without judging the learner. */ +.lp-highlight-chunk[data-lp-outcome="missed"] { text-decoration: underline dashed var(--lp-ink-2) 2px; text-underline-offset: 4px; } .lp-highlight-chunk[data-lp-outcome="wrong"] { text-decoration: none; } .lp-highlight-chunk[aria-disabled="true"] { cursor: default; } /* The gap sits on the label, not the chunk: a margin at the end of a chunk that ends a line overflows the paragraph. */ @@ -16,7 +17,7 @@ the label's capitals (Source Sans 3 cap height; measured within 0.2px at 15px). */ .lp-highlight-feedback > .lp-icon { display: inline-block; block-size: 1.15em; vertical-align: -.23em; margin-inline-end: 3px; } .lp-highlight-feedback[data-lp-outcome="correct"] { color: var(--lp-success-ink); } -.lp-highlight-feedback[data-lp-outcome="missed"] { color: var(--lp-warning-ink); } +.lp-highlight-feedback[data-lp-outcome="missed"] { color: var(--lp-ink-2); } /* A short label never wraps between its icon and its word. A leading word joiner (silent: empty alt text) also keeps it on the line of its chunk's last word. Wrapped to the next line, it read as a verdict on the sentence after it. A no-wrap wrapper doesn't do this: Chromium and WebKit still break before it. */ @@ -25,6 +26,9 @@ /* A note on a wrong answer is a pill on its own line, so the passage text after it starts a new line. Its icon fills the first line-high box, so wrapped text hangs under the words, not the icon. */ .lp-highlight-feedback[data-lp-outcome="wrong"] { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 3px; align-items: start; inline-size: fit-content; max-inline-size: 100%; margin: 8px 0 10px; padding: 3px 10px; border-radius: 12px; color: var(--lp-ink); background: var(--lp-paper-2); font-size: var(--lp-type-body); font-weight: 400; text-wrap: pretty; } +/* The mark word and its icon carry the colour; the author's note after it stays in ink. */ +.lp-highlight-feedback[data-lp-outcome="wrong"] > .lp-icon, .lp-highlight-word { color: var(--lp-error-ink); } +.lp-highlight-word { font-weight: 600; } /* Same optical lift as the date icon: the line-box centre sits about 1.3px low at 17px. */ .lp-highlight-feedback[data-lp-outcome="wrong"] > .lp-icon { block-size: 1lh; margin: 0; translate: 0 -.06em; } .lp-highlight-count { font-variant-numeric: tabular-nums; } diff --git a/patterns/highlight/render.js b/patterns/highlight/render.js index a80db28..93923a5 100644 --- a/patterns/highlight/render.js +++ b/patterns/highlight/render.js @@ -28,7 +28,7 @@ export function render(content, strings, { id, lang }) {
${html(strings.answer, lang)} -
    ${chunks.filter(chunk => chunk.key === true || chunk.note).map(chunk => `
  • ${html(chunk.key === true ? (content.mode === 'key' ? strings.correctKey : strings.correctEvidence) : (content.mode === 'key' ? strings.wrongKey : strings.wrongEvidence), lang)} ${html(chunk.text, lang)}

    ${chunk.note ? `

    ${html(chunk.note, lang)}

    ` : ''}
  • `).join('')}
+
    ${chunks.filter(chunk => chunk.key === true || chunk.note).map(chunk => `
  • ${html(chunk.key === true ? strings.markAnswer : strings.markWrong, lang)} ${html(chunk.text, lang)}

    ${chunk.note ? `

    ${html(chunk.note, lang)}

    ` : ''}
  • `).join('')}

`; diff --git a/patterns/highlight/strings.js b/patterns/highlight/strings.js index 49d73a6..f087179 100644 --- a/patterns/highlight/strings.js +++ b/patterns/highlight/strings.js @@ -9,11 +9,16 @@ export const strings = { check: 'Check', restart: 'Start over', answer: 'Answer', - correctKey: 'Key idea', - correctEvidence: 'Answers the question', - missed: 'Missed', - wrongKey: 'Not a key idea', - wrongEvidence: "This doesn't answer the question.", + markCorrect: 'Correct', + markWrong: 'Incorrect', + markAnswer: 'Correct answer', + // Deprecated: kept for hosts that read the old keys (Praxity Studio). Use markCorrect for + // correctKey and correctEvidence, markWrong for wrongKey and wrongEvidence, markAnswer for missed. + correctKey: 'Correct', + correctEvidence: 'Correct', + wrongKey: 'Incorrect', + wrongEvidence: 'Incorrect', + missed: 'Correct answer', keySummary: 'You found {n} of {total} key ideas.', evidenceSummary: 'You found {n} of {total} passages that answer the question.' }, @@ -27,11 +32,16 @@ export const strings = { check: 'Vérifier', restart: 'Recommencer', answer: 'Réponse', - correctKey: 'Idée clé', - correctEvidence: 'Répond à la question', - missed: 'Manqué', - wrongKey: 'Ce n\'est pas une idée clé', - wrongEvidence: 'Ce passage ne répond pas à la question.', + markCorrect: 'Correct', + markWrong: 'Incorrect', + markAnswer: 'Bonne réponse', + // Deprecated: kept for hosts that read the old keys (Praxity Studio). Use markCorrect for + // correctKey and correctEvidence, markWrong for wrongKey and wrongEvidence, markAnswer for missed. + correctKey: 'Correct', + correctEvidence: 'Correct', + wrongKey: 'Incorrect', + wrongEvidence: 'Incorrect', + missed: 'Bonne réponse', keySummary: 'Idées clés trouvées : {n} sur {total}.', evidenceSummary: 'Passages trouvés pour cette question : {n} sur {total}.' } diff --git a/patterns/self-check/pattern.css b/patterns/self-check/pattern.css index 2ca106b..fe61e22 100644 --- a/patterns/self-check/pattern.css +++ b/patterns/self-check/pattern.css @@ -2,7 +2,7 @@ .lp-self-check { overflow: hidden; } .lp-self-check > * + * { margin-block-start: 0; } .lp-self-check-composer { margin: 0 0 24px; border: 1px solid var(--lp-line); border-radius: var(--lp-radius-control); } -.lp-self-check-composer:focus-within { outline: 2px solid var(--lp-accent); outline-offset: 2px; } +.lp-self-check-composer:focus-within { outline: 3px solid var(--lp-focus); outline-offset: 2px; } .lp-self-check-meta-list { display: grid; grid-template-columns: max-content minmax(0, 1fr); column-gap: 12px; } .lp-self-check-meta { display: grid; grid-template-columns: subgrid; grid-column: 1 / -1; align-items: start; min-block-size: 44px; padding: 8px 16px; border-block-end: 1px solid var(--lp-rule); font-size: var(--lp-type-small); } .lp-self-check-meta-key { white-space: nowrap; color: var(--lp-ink-2); } diff --git a/patterns/test-out/enhance.js b/patterns/test-out/enhance.js index a3b26eb..16d540c 100644 --- a/patterns/test-out/enhance.js +++ b/patterns/test-out/enhance.js @@ -147,10 +147,12 @@ export function enhance(root, { content, strings, state }) { radio.disabled = true; if (!radio.checked && radio.value !== q.correct) return; const correct = radio.value === q.correct; - rows[i].dataset.lpMark = correct ? 'correct' : 'wrong'; + // An unchosen correct answer is "missed": neutral, so it doesn't read as the learner's pick. + const missed = correct && !right; + rows[i].dataset.lpMark = missed ? 'missed' : correct ? 'correct' : 'wrong'; const mark = root.ownerDocument.createElement('span'); - mark.className = `lp-choice-mark ${correct ? 'lp-met' : 'lp-missed'}`; - mark.innerHTML = `${correct ? icons.check : icons.x}${html(correct ? right ? strings.markCorrect : strings.markAnswer : strings.markWrong, root.lang)}`; + mark.className = `lp-choice-mark ${missed ? 'lp-neutral' : correct ? 'lp-met' : 'lp-missed'}`; + mark.innerHTML = `${missed ? icons['circle-dashed'] : correct ? icons.check : icons.x}${html(missed ? strings.markAnswer : correct ? strings.markCorrect : strings.markWrong, root.lang)}`; rows[i].append(mark); }); } diff --git a/patterns/test-out/strings.js b/patterns/test-out/strings.js index 9e0d7d9..6028d75 100644 --- a/patterns/test-out/strings.js +++ b/patterns/test-out/strings.js @@ -20,7 +20,7 @@ export const strings = { summaryOne: 'You can skip this section.', summaryOneRequired: 'You need to take this section.', markCorrect: 'Correct', - markWrong: 'Not quite', + markWrong: 'Incorrect', markAnswer: 'Correct answer', policy: 'Some courses require you to take every section.', required: 'You need to take every section in this course.', @@ -50,7 +50,7 @@ export const strings = { summaryOne: 'Vous pouvez passer cette section.', summaryOneRequired: 'Vous devez suivre cette section.', markCorrect: 'Correct', - markWrong: 'Pas tout à fait', + markWrong: 'Incorrect', markAnswer: 'Bonne réponse', policy: "Certains cours exigent que vous suiviez chaque section.", required: "Vous devez suivre chaque section de ce cours.", diff --git a/patterns/write-distractors/pattern.css b/patterns/write-distractors/pattern.css index 0a8dcc8..bd3cf46 100644 --- a/patterns/write-distractors/pattern.css +++ b/patterns/write-distractors/pattern.css @@ -24,8 +24,8 @@ textarea.lp-write-distractors-answer { min-block-size: 0; } .lp-write-distractors-option { min-inline-size: 0; padding: 0; border: 0; } .lp-write-distractors-option > legend { max-inline-size: 100%; padding: 0; margin-block-end: 16px; } .lp-write-distractors-option > div + div { margin-block-start: 16px; } -/* Contain WebKit's closed select overflow, with room for its 2px focus ring and offset. */ -.lp-write-distractors-option > div:has(select) { overflow: clip; margin-inline: -4px; padding: 4px; } +/* Contain WebKit's closed select overflow, with room for its 3px focus ring and 2px offset. */ +.lp-write-distractors-option > div:has(select) { overflow: clip; margin-inline: -5px; padding: 5px; } .lp-write-distractors-option .lp-error-text { margin-block-start: 8px; } @container (min-width: 40rem) { .lp-write-distractors-comparison { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/tests/browser/conversation.spec.js b/tests/browser/conversation.spec.js index 5094301..726d18c 100644 --- a/tests/browser/conversation.spec.js +++ b/tests/browser/conversation.spec.js @@ -448,7 +448,7 @@ test('forced colours preserve bubbles and visible keyboard focus', async ({ page await expect(page.locator('[data-lp-michel]').last()).toBeFocused(); await page.getByRole('textbox').focus(); await expect(page.locator('.lp-conversation-composer')).toHaveCSS('outline-style', 'solid'); - await expect(page.locator('.lp-conversation-composer')).toHaveCSS('outline-width', '2px'); + await expect(page.locator('.lp-conversation-composer')).toHaveCSS('outline-width', '3px'); }); test('two instances have independent turns and unique prefixed IDs', async ({ page }) => { diff --git a/tests/browser/dont-know.spec.js b/tests/browser/dont-know.spec.js index ed9f246..76166b8 100644 --- a/tests/browser/dont-know.spec.js +++ b/tests/browser/dont-know.spec.js @@ -24,10 +24,12 @@ for (const [lang, expected] of [['en', 'Score 0.33 out of 1.'], ['fr', 'Score de }); } -test('shared scene spans the card and centres its tile on title and scoring', async ({ page }) => { +test('shared scene spans the card and centres its tile on the title', async ({ page }) => { await open(page); await expect(page.locator('.lp-scene-title')).toHaveText(english.title); - await expect(page.locator('.lp-scene-sub')).toContainText("I don't know"); + // The header holds the title only; the scoring rule is the first line under it. + await expect(page.locator('.lp-scene-sub')).toHaveCount(0); + await expect(page.locator('.lp-scene + p')).toContainText("I don't know"); for (const width of [1280, 390, 320]) { await page.setViewportSize({ width, height: 900 }); const geometry = await page.locator('.lp-scene').evaluate(el => { @@ -172,8 +174,8 @@ test('score focus supplies one result announcement without a duplicate status up }); for (const [lang, content, correct, wrong, answer, unknown, counts] of [ - ['en', english, 'Correct', 'Not quite', 'Correct answer', "You chose \"I don't know\"", '1 right, 1 wrong, 1 "I don\'t know"'], - ['fr', french, 'Correct', 'Pas tout à fait', 'Bonne réponse', 'Vous avez choisi « Je ne sais pas »', '1 bonne réponse, 1 mauvaise réponse, 1 « Je ne sais pas »'] + ['en', english, 'Correct', 'Incorrect', 'Correct answer', "You chose \"I don't know\"", '1 right, 1 wrong, 1 "I don\'t know"'], + ['fr', french, 'Correct', 'Incorrect', 'Bonne réponse', 'Vous avez choisi « Je ne sais pas »', '1 bonne réponse, 1 mauvaise réponse, 1 « Je ne sais pas »'] ]) { test(`results mark choices in place, explain only gaps and link back to questions (${lang})`, async ({ page }) => { await open(page, `/dont-know/${lang}.html`); await observe(page); @@ -198,8 +200,10 @@ for (const [lang, content, correct, wrong, answer, unknown, counts] of [ await expect(questions.nth(2).locator('label:has(input:checked) .lp-choice-mark.lp-neutral')).toHaveText(unknown); await expect(questions.nth(2).locator('label:has(input:checked)')).not.toHaveAttribute('data-lp-mark'); for (const index of [1, 2]) { - await expect(questions.nth(index).locator(`label:has(input[value="${content.questions[index].correct}"])`)).toHaveAttribute('data-lp-mark', 'correct'); - await expect(questions.nth(index).locator(`label:has(input[value="${content.questions[index].correct}"]) .lp-choice-mark`)).toHaveText(answer); + // The correct answer the learner didn't choose is neutral and dashed, not a green pick. + await expect(questions.nth(index).locator(`label:has(input[value="${content.questions[index].correct}"])`)).toHaveAttribute('data-lp-mark', 'missed'); + await expect(questions.nth(index).locator(`label:has(input[value="${content.questions[index].correct}"]) .lp-choice-mark.lp-neutral`)).toHaveText(answer); + await expect(questions.nth(index).locator(`label:has(input[value="${content.questions[index].correct}"])`)).toHaveCSS('border-top-style', 'dashed'); await expect(questions.nth(index).locator('[data-lp-explanation]')).toHaveText(content.questions[index].explanation); } await expect(page.locator('.lp-choice-mark')).toHaveCount(5); @@ -269,7 +273,7 @@ for (const path of ['/dont-know/en.html', '/dont-know/fr.html', '/dont-know/two. test('all wrong, all unknown and all right omit zero counts and unnecessary review links', async ({ page }) => { await open(page); - await expect(page.locator('.lp-scene-sub')).toHaveText('A right answer scores a point. A wrong answer costs a point. "I don\'t know" costs nothing.'); + await expect(page.locator('.lp-scene + p')).toHaveText('A right answer scores a point. A wrong answer costs a point. "I don\'t know" costs nothing.'); for (const [values, summary, counts, reviews, explanations, offset] of [ [english.questions.map(q => q.options.find(o => o.id !== q.correct).id), 'Score −3 out of 3.', '3 wrong', 3, 3, '100'], [english.questions.map(() => 'dont-know'), 'Score 0 out of 3.', '3 "I don\'t know"', 3, 3, '100'], @@ -299,7 +303,7 @@ test('authored fractional points reach the rule and total; feedback preserves ho document.querySelector('main').innerHTML = render(content, strings.en, { id: 'authored', lang: 'en' }); enhance(document.querySelector('[data-lp-pattern]'), { content, strings: strings.en }); }, english); - await expect(page.locator('.lp-scene-sub')).toHaveText('A right answer scores 2 points. A wrong answer costs 0.5 points. "I don\'t know" scores 0.25 points.'); + await expect(page.locator('.lp-scene + p')).toHaveText('A right answer scores 2 points. A wrong answer costs 0.5 points. "I don\'t know" scores 0.25 points.'); await pick(page); await page.locator('[data-lp-check]').click(); await expect(page.locator('[data-lp-score]').first()).toHaveText('Score 1.75 out of 6.'); await expect(page.locator('.lp-dont-know-ring-fill')).toHaveAttribute('stroke-dashoffset', '70.83333333333333'); @@ -321,7 +325,7 @@ test('reset uses a quiet button and theme tokens reach controls and focus', asyn await restart.focus(); await expect(restart).toHaveCSS('outline-color', 'rgb(101, 67, 33)'); await expect(restart).toHaveCSS('outline-style', 'solid'); - await expect(restart).toHaveCSS('outline-width', '2px'); + await expect(restart).toHaveCSS('outline-width', '3px'); await restart.click(); await page.keyboard.press('Tab'); for (const control of [page.locator('input').first(), page.locator('[data-lp-check]')]) { @@ -329,7 +333,7 @@ test('reset uses a quiet button and theme tokens reach controls and focus', asyn const target = await control.getAttribute('type') === 'radio' ? control.locator('..') : control; await expect(target).toHaveCSS('outline-color', 'rgb(101, 67, 33)'); await expect(target).toHaveCSS('outline-style', 'solid'); - await expect(target).toHaveCSS('outline-width', '2px'); + await expect(target).toHaveCSS('outline-width', '3px'); } }); @@ -467,6 +471,8 @@ test('quiet reset and forced colours preserve focus rings and button distinction test.skip(browserName !== 'chromium', 'Forced colours emulation checked in Chromium.'); await page.emulateMedia({ forcedColors: 'active' }); await open(page); await pick(page); await page.locator('[data-lp-check]').click(); await expect(page.locator('[data-lp-mark]').first()).toHaveCSS('border-style', 'double'); + // The unchosen correct answer stays dashed, so it doesn't read as a pick. + await expect(page.locator('[data-lp-mark="missed"]').first()).toHaveCSS('border-top-style', 'dashed'); await page.locator('[data-lp-restart]').focus(); await expect(page.locator('[data-lp-restart]')).toHaveCSS('text-decoration-line', 'underline'); await page.locator('[data-lp-restart]').click(); @@ -476,12 +482,12 @@ test('quiet reset and forced colours preserve focus rings and button distinction await locator.focus(); const target = await locator.getAttribute('type') === 'radio' ? locator.locator('..') : locator; const style = await target.evaluate(el => { const css = getComputedStyle(el); return [css.outlineWidth, css.outlineStyle, css.outlineOffset, css.outlineColor]; }); - expect(style.slice(0, 3)).toEqual(['2px', 'solid', '2px']); expect(style[3]).not.toBe('rgba(0, 0, 0, 0)'); + expect(style.slice(0, 3)).toEqual(['3px', 'solid', '2px']); expect(style[3]).not.toBe('rgba(0, 0, 0, 0)'); } await expect(page.locator('[data-lp-check]')).toHaveCSS('border-style', 'solid'); await pick(page); await page.locator('[data-lp-check]').click(); await page.locator('[data-lp-restart]').focus(); - await expect(page.locator('[data-lp-restart]')).toHaveCSS('outline-width', '2px'); + await expect(page.locator('[data-lp-restart]')).toHaveCSS('outline-width', '3px'); }); test('one unanswered question uses the singular message in English and French', async ({ page }) => { diff --git a/tests/browser/explain-back.spec.js b/tests/browser/explain-back.spec.js index fa75d09..84eadfc 100644 --- a/tests/browser/explain-back.spec.js +++ b/tests/browser/explain-back.spec.js @@ -389,7 +389,7 @@ test('forced colours preserve row marks and keyboard focus', async ({ page, brow await expect(page.locator('[data-lp-mark="correct"]')).toHaveCSS('border-style', 'double'); await expect(page.locator('[data-lp-mark="missing"]')).toHaveCSS('border-style', 'double'); for (const target of [page.locator('[data-lp-check]'), page.locator('[data-lp-reread]').first()]) { - await target.focus(); await expect(target).toHaveCSS('outline-width', '2px'); await expect(target).toHaveCSS('outline-style', 'solid'); + await target.focus(); await expect(target).toHaveCSS('outline-width', '3px'); await expect(target).toHaveCSS('outline-style', 'solid'); } }); diff --git a/tests/browser/first-answer.spec.js b/tests/browser/first-answer.spec.js index a5cfad5..d4d03ea 100644 --- a/tests/browser/first-answer.spec.js +++ b/tests/browser/first-answer.spec.js @@ -500,7 +500,7 @@ test('Chromium forced colours keeps focus rings, borders and quiet link actions' test.skip(browserName !== 'chromium', 'Forced-colour emulation is checked in Chromium.'); await page.emulateMedia({ forcedColors: 'active' }); await open(page); await page.locator('[data-lp-save-first]').focus(); - expect(await page.locator('[data-lp-save-first]').evaluate(el => [getComputedStyle(el).outlineWidth, getComputedStyle(el).outlineStyle, getComputedStyle(el).outlineOffset])).toEqual(['2px', 'solid', '2px']); + expect(await page.locator('[data-lp-save-first]').evaluate(el => [getComputedStyle(el).outlineWidth, getComputedStyle(el).outlineStyle, getComputedStyle(el).outlineOffset])).toEqual(['3px', 'solid', '2px']); await saveFirst(page); await expect(page.locator('[data-lp-course] p')).toHaveText('Take the course, then answer again.'); await page.addStyleTag({ content: '.lp-first-answer-timeline::before{display:none}' }); @@ -508,10 +508,10 @@ test('Chromium forced colours keeps focus rings, borders and quiet link actions' await expect(page.locator('[data-lp-skip]')).toHaveText('Skip to the end of the course'); for (const selector of ['[data-lp-skip]', '[data-lp-restart]']) { await page.locator(selector).focus(); - expect(await page.locator(selector).evaluate(el => [getComputedStyle(el).outlineWidth, getComputedStyle(el).outlineStyle, getComputedStyle(el).outlineOffset])).toEqual(['2px', 'solid', '2px']); + expect(await page.locator(selector).evaluate(el => [getComputedStyle(el).outlineWidth, getComputedStyle(el).outlineStyle, getComputedStyle(el).outlineOffset])).toEqual(['3px', 'solid', '2px']); } await page.locator('[data-lp-skip]').click(); await expect(page.locator('[data-lp-end-heading]')).toBeFocused(); - expect(await page.locator('[data-lp-end-heading]').evaluate(el => getComputedStyle(el).outlineWidth)).toBe('2px'); + expect(await page.locator('[data-lp-end-heading]').evaluate(el => getComputedStyle(el).outlineWidth)).toBe('3px'); const colors = await page.evaluate(() => { const span = document.createElement('span'); document.body.append(span); const result = {}; for (const name of ['ButtonText', 'ButtonFace', 'Highlight', 'LinkText']) { span.style.color = name; result[name] = getComputedStyle(span).color; } @@ -526,7 +526,7 @@ test('Chromium forced colours keeps focus rings, borders and quiet link actions' await compare(page); for (const selector of ['[data-lp-try-again]', '[data-lp-restart]']) { await page.locator(selector).focus(); - expect(await page.locator(selector).evaluate(el => [getComputedStyle(el).color, getComputedStyle(el).outlineColor, getComputedStyle(el).outlineWidth])).toEqual([colors.LinkText, colors.Highlight, '2px']); + expect(await page.locator(selector).evaluate(el => [getComputedStyle(el).color, getComputedStyle(el).outlineColor, getComputedStyle(el).outlineWidth])).toEqual([colors.LinkText, colors.Highlight, '3px']); } }); diff --git a/tests/browser/highlight.spec.js b/tests/browser/highlight.spec.js index 2926734..50ad294 100644 --- a/tests/browser/highlight.spec.js +++ b/tests/browser/highlight.spec.js @@ -194,7 +194,7 @@ test('passage buttons support Tab through every chunk, arrow shortcuts and silen const outline = await chunks.first().evaluate(el => { const css = getComputedStyle(el); return [css.outlineWidth, css.outlineStyle, css.outlineOffset]; }); - expect(outline).toEqual(['2px', 'solid', '2px']); + expect(outline).toEqual(['3px', 'solid', '2px']); for (const [key, index] of [['ArrowRight', 1], ['ArrowDown', 2], ['ArrowLeft', 1], ['ArrowUp', 0], ['ArrowLeft', english.paragraphs.flat().length - 1], ['Home', 0], ['End', english.paragraphs.flat().length - 1], ['ArrowRight', 0]]) { await page.keyboard.press(key); await expect(chunks.nth(index)).toBeFocused(); await expect(page.locator('[data-lp-chunk][tabindex="-1"]')).toHaveCount(0); @@ -213,9 +213,9 @@ test('evidence check puts authored and default feedback in place, locks marks, a await chunk(page, 'actions').click(); await chunk(page, 'withdraw').click(); await page.locator('[data-lp-check]').focus(); await page.keyboard.press('Enter'); await expect(page.locator('[data-lp-check]')).toBeHidden(); await expect(page.locator('[data-lp-restart]')).toBeFocused(); - await expect(feedback(page, 'actions')).toHaveText(english.paragraphs.flat().find(c => c.id === 'actions').note); - await expect(feedback(page, 'withdraw')).toHaveText("This doesn't answer the question."); - await expect(feedback(page, 'ignored')).toHaveText('Missed'); + await expect(feedback(page, 'actions')).toHaveText(`Incorrect. ${english.paragraphs.flat().find(c => c.id === 'actions').note}`); + await expect(feedback(page, 'withdraw')).toHaveText('Incorrect'); + await expect(feedback(page, 'ignored')).toHaveText('Correct answer'); await expect(chunk(page, 'ignored')).toHaveAttribute('data-lp-outcome', 'missed'); await expect(feedback(page, 'overwhelmed')).toBeHidden(); await expect(page.locator('[data-lp-summary]')).toHaveText('You found 0 of 1 passages that answer the question.'); @@ -238,7 +238,7 @@ test('evidence check puts authored and default feedback in place, locks marks, a await expect(page.getByRole('status')).toHaveText(''); expect(await page.evaluate(() => window.lpAnnouncements.filter(Boolean))).toEqual(['You found 0 of 1 passages that answer the question.']); await chunk(page, 'ignored').click(); await page.locator('[data-lp-check]').click(); - await expect(feedback(page, 'ignored')).toHaveText('Answers the question'); + await expect(feedback(page, 'ignored')).toHaveText('Correct'); await expect(chunk(page, 'ignored')).toHaveAttribute('data-lp-outcome', 'correct'); expect(await page.evaluate(() => window.lpAnnouncements.filter(Boolean))).toEqual(['You found 0 of 1 passages that answer the question.', 'You found 1 of 1 passages that answer the question.']); }); @@ -334,20 +334,21 @@ test('graded marks keep yellow non-targets, tint found targets and leave missed const style = id => chunk(page, id).evaluate(el => { const css = getComputedStyle(el); return [css.backgroundColor, css.textDecorationLine, css.textDecorationStyle, css.textDecorationColor]; }); expect(await style('overwhelmed')).toEqual(['rgb(255, 228, 154)', 'none', 'solid', 'rgb(22, 24, 29)']); expect(await style('withdraw')).toEqual(['rgb(230, 244, 238)', 'underline', 'solid', 'rgb(18, 112, 79)']); - expect(await style('ignored')).toEqual(['rgba(0, 0, 0, 0)', 'underline', 'dashed', 'rgb(162, 74, 7)']); + // A missed target is neutral grey, not a warning (owner, 2026-10-22). + expect(await style('ignored')).toEqual(['rgba(0, 0, 0, 0)', 'underline', 'dashed', 'rgb(85, 92, 103)']); expect(await feedback(page, 'overwhelmed').evaluate(el => { const css = getComputedStyle(el); return [css.color, css.backgroundColor, css.fontSize]; })).toEqual(['rgb(22, 24, 29)', 'rgb(247, 248, 250)', '17px']); await expect(feedback(page, 'overwhelmed').locator('svg')).toHaveAttribute('aria-hidden', 'true'); }); for (const [lang, content, correct, wrong, missed, summary] of [ - ['en', englishKey, 'Key idea', 'Not a key idea', 'Missed', 'You found 1 of 4 key ideas.'], - ['fr', frenchKey, 'Idée clé', "Ce n'est pas une idée clé", 'Manqué', 'Idées clés trouvées : 1 sur 4.'] + ['en', englishKey, 'Correct', 'Incorrect', 'Correct answer', 'You found 1 of 4 key ideas.'], + ['fr', frenchKey, 'Correct', 'Incorrect', 'Bonne réponse', 'Idées clés trouvées : 1 sur 4.'] ]) { test(`key mode has inline success, neutral authored notes and dashed missed targets (${lang})`, async ({ page }) => { await open(page); await mount(page, content, lang); await chunk(page, 'withdraw').click(); await chunk(page, 'overwhelmed').click(); await page.locator('[data-lp-check]').click(); await expect(feedback(page, 'withdraw')).toHaveText(correct); - await expect(feedback(page, 'overwhelmed')).toHaveText(`${wrong} ${content.paragraphs.flat().find(c => c.id === 'overwhelmed').note}`); + await expect(feedback(page, 'overwhelmed')).toHaveText(`${wrong}. ${content.paragraphs.flat().find(c => c.id === 'overwhelmed').note}`); await expect(feedback(page, 'ignored')).toHaveText(missed); await expect(page.locator('[data-lp-summary]')).toHaveText(summary); expect(await chunk(page, 'ignored').evaluate(el => getComputedStyle(el).textDecorationStyle)).toBe('dashed'); @@ -387,8 +388,8 @@ test('French language and evidence feedback use French words and authored notes' await expect(page.locator('[data-lp-pattern]')).toHaveAttribute('lang', 'fr'); await expect(page.locator('.lp-stem')).toContainText(french.question); await chunk(page, 'ignored').click(); await chunk(page, 'overwhelmed').click(); await page.locator('[data-lp-check]').click(); - await expect(feedback(page, 'ignored')).toHaveText('Répond à la question'); - await expect(feedback(page, 'overwhelmed')).toHaveText(french.paragraphs.flat().find(c => c.id === 'overwhelmed').note); + await expect(feedback(page, 'ignored')).toHaveText('Correct'); + await expect(feedback(page, 'overwhelmed')).toHaveText(`Incorrect. ${french.paragraphs.flat().find(c => c.id === 'overwhelmed').note}`); await expect(page.locator('[data-lp-summary]')).toHaveText('Passages trouvés pour cette question : 1 sur 1.'); }); @@ -398,7 +399,7 @@ for (const shown of [false, true]) { await open(page); await expect(chunk(page, 'ignored')).toHaveAttribute('aria-pressed', 'true'); await expect(page.locator('[data-lp-count]')).toHaveText('1 of 1 marked'); - if (shown) { await expect(feedback(page, 'ignored')).toHaveText('Answers the question'); await expect(page.locator('[data-lp-summary]')).toBeVisible(); } + if (shown) { await expect(feedback(page, 'ignored')).toHaveText('Correct'); await expect(page.locator('[data-lp-summary]')).toBeVisible(); } else await expect(page.locator('[data-lp-summary]')).toBeHidden(); await expect(page.locator('[role="status"]')).toHaveText(''); expect(await page.evaluate(() => document.activeElement.tagName)).toBe('BODY'); @@ -485,15 +486,15 @@ test('forced colours distinguishes marks, missed targets and visible keyboard fo expect(await chunk(page, 'withdraw').evaluate(el => getComputedStyle(el).borderStyle)).toBe('solid'); expect(await chunk(page, 'withdraw').evaluate(el => getComputedStyle(el).borderWidth)).toBe('1px'); await chunk(page, 'withdraw').focus(); - expect(await chunk(page, 'withdraw').evaluate(el => getComputedStyle(el).outlineWidth)).toBe('2px'); + expect(await chunk(page, 'withdraw').evaluate(el => getComputedStyle(el).outlineWidth)).toBe('3px'); await page.locator('[data-lp-check]').click(); expect(await chunk(page, 'withdraw').evaluate(el => getComputedStyle(el).textDecorationLine)).toBe('none'); await expect(feedback(page, 'withdraw').locator('svg')).toHaveAttribute('aria-hidden', 'true'); expect(await chunk(page, 'ignored').evaluate(el => getComputedStyle(el).textDecorationStyle)).toBe('dashed'); - await expect(feedback(page, 'ignored')).toHaveText('Missed'); + await expect(feedback(page, 'ignored')).toHaveText('Correct answer'); await page.locator('[data-lp-restart]').focus(); - expect(await page.locator('[data-lp-restart]').evaluate(el => getComputedStyle(el).outlineWidth)).toBe('2px'); + expect(await page.locator('[data-lp-restart]').evaluate(el => getComputedStyle(el).outlineWidth)).toBe('3px'); await page.locator('[data-lp-restart]').click(); await chunk(page, 'ignored').click(); await page.locator('[data-lp-check]').click(); expect(await chunk(page, 'ignored').evaluate(el => getComputedStyle(el).textDecorationStyle)).toBe('solid'); - await expect(feedback(page, 'ignored')).toHaveText('Answers the question'); + await expect(feedback(page, 'ignored')).toHaveText('Correct'); }); diff --git a/tests/browser/journal.spec.js b/tests/browser/journal.spec.js index eda9b29..11093c7 100644 --- a/tests/browser/journal.spec.js +++ b/tests/browser/journal.spec.js @@ -441,7 +441,7 @@ test('forced colours preserve the soft card, controls and focus', async ({ page, await page.emulateMedia({ forcedColors: 'active' }); await open(page); await submit(page); await expect(page.locator('[data-lp-result]')).toHaveCSS('border-style', 'solid'); for (const target of [page.getByRole('textbox'), page.locator('[data-lp-save]'), page.locator('[data-lp-suggest]')]) { - await target.focus(); await expect(target).toHaveCSS('outline-width', '2px'); await expect(target).toHaveCSS('outline-style', 'solid'); + await target.focus(); await expect(target).toHaveCSS('outline-width', '3px'); await expect(target).toHaveCSS('outline-style', 'solid'); } }); diff --git a/tests/browser/live-feedback.spec.js b/tests/browser/live-feedback.spec.js index 593da75..5374289 100644 --- a/tests/browser/live-feedback.spec.js +++ b/tests/browser/live-feedback.spec.js @@ -565,7 +565,7 @@ test('forced colours keep marks and visible keyboard focus', async ({ page, brow await expect(page.locator('[data-lp-mark="todo"] .lp-live-feedback-bullet .lp-icon').first()).toBeVisible(); await page.getByRole('textbox').focus(); await expect(page.getByRole('textbox')).toHaveCSS('outline-style', 'solid'); - await expect(page.getByRole('textbox')).toHaveCSS('outline-width', '2px'); + await expect(page.getByRole('textbox')).toHaveCSS('outline-width', '3px'); }); for (const lang of ['en', 'fr']) for (const reason of ['ip_daily', 'budget']) test(`daily cap shows and announces once, keeps focus and a usable fallback (${lang}, ${reason})`, async ({ page }) => { diff --git a/tests/browser/misconception.spec.js b/tests/browser/misconception.spec.js index 10f5117..923f219 100644 --- a/tests/browser/misconception.spec.js +++ b/tests/browser/misconception.spec.js @@ -300,7 +300,7 @@ test('forced colours preserve icon text, native ticks and focus', async ({ page, await page.emulateMedia({ forcedColors: 'active' }); await open(page, 'en', 'rereading'); await submit(page); await expect(page.locator('[data-lp-result] h3')).toHaveText(examples.en.misconceptions[0].label); for (const target of [page.locator('[data-lp-check]'), page.locator('[data-lp-model] summary')]) { - await target.focus(); await expect(target).toHaveCSS('outline-width', '2px'); await expect(target).toHaveCSS('outline-style', 'solid'); + await target.focus(); await expect(target).toHaveCSS('outline-width', '3px'); await expect(target).toHaveCSS('outline-style', 'solid'); } await open(page, 'en', 'correct', 1, 'missing'); await submit(page); await page.getByRole('checkbox').first().check(); diff --git a/tests/browser/review-prompts.spec.js b/tests/browser/review-prompts.spec.js index 7d432cd..292f197 100644 --- a/tests/browser/review-prompts.spec.js +++ b/tests/browser/review-prompts.spec.js @@ -395,13 +395,13 @@ test('forced colours preserves focus outlines and a visible pressed state', asyn await page.emulateMedia({ forcedColors: 'active' }); await open(page); const commit = part(page).locator('[data-lp-commit]'); await commit.focus(); - await expect(commit).toHaveCSS('outline-width', '2px'); + await expect(commit).toHaveCSS('outline-width', '3px'); await commit.press('Enter'); await part(page).getByRole('button', { name: 'I remembered' }).focus(); await part(page).getByRole('button', { name: 'I remembered' }).press('Enter'); for (const target of [part(page).locator('[data-lp-answer]'), ...await part(page).getByRole('button').all()]) { await target.focus(); - expect(await target.evaluate(el => { const css = getComputedStyle(el); return [css.outlineWidth, css.outlineStyle, css.outlineOffset]; })).toEqual(['2px', 'solid', '2px']); + expect(await target.evaluate(el => { const css = getComputedStyle(el); return [css.outlineWidth, css.outlineStyle, css.outlineOffset]; })).toEqual(['3px', 'solid', '2px']); expect(await target.evaluate(el => getComputedStyle(el).outlineColor)).not.toBe('rgba(0, 0, 0, 0)'); } expect(await part(page).getByRole('button', { name: 'I remembered' }).evaluate(el => [getComputedStyle(el).borderWidth, getComputedStyle(el).borderStyle])).toEqual(['2px', 'solid']); diff --git a/tests/browser/section-test-out.spec.js b/tests/browser/section-test-out.spec.js index 169371e..dc0b27e 100644 --- a/tests/browser/section-test-out.spec.js +++ b/tests/browser/section-test-out.spec.js @@ -156,8 +156,8 @@ test('keyboard validates only this panel, Back keeps picks, changes announce onc }); for (const [lang, content, correct, wrong, answer, summary, statuses] of [ - ['en', english, 'Correct', 'Not quite', 'Correct answer', 'You can skip 1 of 3 sections.', ['Take it', 'Take it', 'Skip']], - ['fr', french, 'Correct', 'Pas tout à fait', 'Bonne réponse', 'Sections que vous pouvez passer : 1 sur 3.', ['À suivre', 'À suivre', 'Passer']] + ['en', english, 'Correct', 'Incorrect', 'Correct answer', 'You can skip 1 of 3 sections.', ['Take it', 'Take it', 'Skip']], + ['fr', french, 'Correct', 'Incorrect', 'Bonne réponse', 'Sections que vous pouvez passer : 1 sur 3.', ['À suivre', 'À suivre', 'Passer']] ]) { test(`in-place marks, explanations and credited outline (${lang})`, async ({ page }) => { await open(page, `/test-out/${lang}.html`); await observe(page); await pick(page); await page.locator('[data-lp-check]').click(); @@ -179,7 +179,10 @@ for (const [lang, content, correct, wrong, answer, summary, statuses] of [ for (const n of [0, 1]) { await expect(qs.nth(n).locator('label:has(input:checked)')).toHaveAttribute('data-lp-mark', 'wrong'); await expect(qs.nth(n).locator('label:has(input:checked) .lp-choice-mark')).toHaveText(wrong); - await expect(qs.nth(n).locator(`label:has(input[value="${content.questions[n].correct}"]) .lp-choice-mark`)).toHaveText(answer); + // The correct answer the learner didn't choose is neutral and dashed, not a green pick. + await expect(qs.nth(n).locator(`label:has(input[value="${content.questions[n].correct}"])`)).toHaveAttribute('data-lp-mark', 'missed'); + await expect(qs.nth(n).locator(`label:has(input[value="${content.questions[n].correct}"]) .lp-choice-mark.lp-neutral`)).toHaveText(answer); + await expect(qs.nth(n).locator(`label:has(input[value="${content.questions[n].correct}"])`)).toHaveCSS('border-top-style', 'dashed'); await expect(qs.nth(n).locator('[data-lp-explanation]')).toHaveText(content.questions[n].explanation); } expect(await page.evaluate(() => window.lpAnnouncements)).toEqual([summary]); @@ -384,9 +387,9 @@ test('forced colours keep marks, keyboard focus and quiet reset visible', async await review(page); await expect(page.locator('[data-lp-mark]').first()).toHaveCSS('border-style', 'double'); await page.locator('[data-lp-restart]').press('Tab'); await page.locator('[data-lp-restart]').focus(); - await expect(page.locator('[data-lp-restart]')).toHaveCSS('outline-width', '2px'); + await expect(page.locator('[data-lp-restart]')).toHaveCSS('outline-width', '3px'); await page.locator('[data-lp-restart]').click(); await page.locator('[data-lp-start]').click(); await page.keyboard.press('Tab'); await page.locator('input').first().focus(); - await expect(page.locator('label').first()).toHaveCSS('outline-width', '2px'); + await expect(page.locator('label').first()).toHaveCSS('outline-width', '3px'); }); for (const width of [1280, 390, 320]) { diff --git a/tests/browser/self-check.spec.js b/tests/browser/self-check.spec.js index df2396e..ce7a9bc 100644 --- a/tests/browser/self-check.spec.js +++ b/tests/browser/self-check.spec.js @@ -130,7 +130,7 @@ for (const forcedColors of ['none', 'active']) { const highlight = getComputedStyle(probe).color; probe.remove(); return { width: css.outlineWidth, style: css.outlineStyle, offset: css.outlineOffset, color: css.outlineColor, radius: css.borderRadius, fieldStyle: textarea.outlineStyle, highlight }; }); - expect(styles.width).toBe('2px'); expect(styles.style).toBe('solid'); expect(styles.offset).toBe('2px'); + expect(styles.width).toBe('3px'); expect(styles.style).toBe('solid'); expect(styles.offset).toBe('2px'); expect(styles.radius).toBe('10px'); expect(styles.fieldStyle).toBe('none'); expect(styles.color).toBe(forcedColors === 'active' ? styles.highlight : 'rgb(44, 85, 201)'); await page.getByRole('textbox').blur(); @@ -393,7 +393,7 @@ test('Start over is a quiet text button with an icon, the same target size and f } await button.focus(); const outline = await button.evaluate(el => { const css = getComputedStyle(el); return [css.outlineWidth, css.outlineStyle, css.outlineOffset, css.outlineColor]; }); - expect(outline).toEqual(['2px', 'solid', '2px', 'rgb(44, 85, 201)']); + expect(outline).toEqual(['3px', 'solid', '2px', 'rgb(44, 85, 201)']); } // Theme overrides reach the quiet button too. await page.locator('[data-lp-pattern]').evaluate(el => el.style.setProperty('--lp-ink-2', '#123456')); @@ -546,14 +546,14 @@ test('missing or mismatched markup throws a useful error', async ({ page }) => { await expect(page.evaluate(() => window.lpEnhance())).rejects.toThrow('Invalid self-check parts markup'); }); -test('forced colours keeps a 2px focus outline on buttons and checkboxes', async ({ page, browserName }) => { +test('forced colours keeps a 3px focus outline on buttons and checkboxes', async ({ page, browserName }) => { test.skip(browserName !== 'chromium', 'Forced colours emulation checked in Chromium.'); await page.emulateMedia({ forcedColors: 'active' }); await open(page); await ticks(page); await page.keyboard.press('Tab'); for (const locator of [page.getByRole('checkbox').first(), page.locator('[data-lp-check]'), page.locator('[data-lp-show]'), page.locator('[data-lp-restart]')]) { await locator.focus(); const outline = await locator.evaluate(el => { const css = getComputedStyle(el.matches('input') ? el.closest('.lp-choice') : el); return { width: css.outlineWidth, style: css.outlineStyle, offset: css.outlineOffset, color: css.outlineColor }; }); - expect(outline.width).toBe('2px'); expect(outline.style).toBe('solid'); expect(outline.offset).toBe('2px'); expect(outline.color).not.toBe('rgba(0, 0, 0, 0)'); + expect(outline.width).toBe('3px'); expect(outline.style).toBe('solid'); expect(outline.offset).toBe('2px'); expect(outline.color).not.toBe('rgba(0, 0, 0, 0)'); } const styles = await page.locator('[data-lp-pattern] button').evaluateAll(buttons => buttons.map(el => { const css = getComputedStyle(el); diff --git a/tests/browser/shared-look.spec.js b/tests/browser/shared-look.spec.js new file mode 100644 index 0000000..4d49899 --- /dev/null +++ b/tests/browser/shared-look.spec.js @@ -0,0 +1,90 @@ +import { test, expect } from '@playwright/test'; + +// Shared look (lib/base.css) under a host page like praxity.io: headings in a display face, no +// font synthesis and no italic face loaded. +const HOST = 'h1, h2, h3 { font-family: Georgia, serif; } html { font-synthesis: none; }'; + +async function open(page, path, host = false) { + await page.goto(path); + await page.waitForFunction(() => window.lpReady); + if (host) await page.addStyleTag({ content: HOST }); +} + +for (const name of ['dont-know', 'first-answer', 'highlight', 'retrieval-sheet', 'test-out', 'write-distractors']) { + test(`headings inside the pattern keep the body face when the host styles headings (${name})`, async ({ page }) => { + await open(page, `/${name}/en.html`, true); + const families = await page.locator('.lp').evaluateAll(roots => roots.flatMap(root => [...root.querySelectorAll('h1, h2, h3, h4, h5, h6, .lp-scene-title, .lp-run-in')].map(el => getComputedStyle(el).fontFamily))); + expect(families.length).toBeGreaterThan(0); + for (const family of families) expect(family).toMatch(/^"Source Sans 3"/); + }); +} + +test('emphasis is synthesised when the host turns font synthesis off', async ({ page }) => { + await open(page, '/dont-know/en.html', true); + const synthesis = await page.locator('.lp-stem').first().evaluate(stem => { + const em = stem.ownerDocument.createElement('em'); + em.textContent = 'really'; + stem.append(em); + return getComputedStyle(em).fontSynthesisStyle; + }); + expect(synthesis).toBe('auto'); +}); + +test('a section straight under the scene header draws no second rule', async ({ page }) => { + await open(page, '/highlight/en.html'); + const borders = await page.locator('.lp-scene').evaluate(scene => ['

Step

', '

Step

'].map(markup => { + scene.insertAdjacentHTML('afterend', markup); + const added = scene.nextElementSibling; + const width = getComputedStyle(added.matches('.lp-section') ? added : added.firstElementChild).borderTopWidth; + added.remove(); + return width; + })); + expect(borders).toEqual(['0px', '0px']); +}); + +test('the select arrow is drawn 14px inside the border', async ({ page }) => { + await open(page, '/write-distractors/en.html'); + const style = await page.locator('select.lp-input').first().evaluate(el => { + const css = getComputedStyle(el); + return [css.appearance, css.paddingRight, css.backgroundImage.startsWith('url("data:image/svg+xml')]; + }); + expect(style).toEqual(['none', '40px', true]); +}); + +test('an invalid select keeps room for the drawn arrow', async ({ page }) => { + await open(page, '/write-distractors/en.html'); + // The 2px error border takes 1px of the 40px end padding, as the choice rows do. + expect(await page.locator('select.lp-input').first().evaluate(el => { el.setAttribute('aria-invalid', 'true'); return getComputedStyle(el).paddingRight; })).toBe('39px'); +}); + +test('forced colours bring back the native select arrow', async ({ page, browserName }) => { + test.skip(browserName !== 'chromium', 'Only Chromium emulates forced colours.'); + await page.emulateMedia({ forcedColors: 'active' }); + await open(page, '/write-distractors/en.html'); + expect(await page.locator('select.lp-input').first().evaluate(el => [getComputedStyle(el).appearance, getComputedStyle(el).backgroundImage])).toEqual(['auto', 'none']); +}); + +test('a locked choice row shows no hover or pointer', async ({ page }) => { + // Reduced motion removes the colour transition, so styles read straight after the hover are final. + await page.emulateMedia({ reducedMotion: 'reduce' }); + await open(page, '/dont-know/en.html'); + for (const [index, value] of ['unexpected-expenses', 'no-interest', 'dont-know'].entries()) await page.locator('fieldset').nth(index).locator(`input[value="${value}"]`).check(); + await page.locator('[data-lp-check]').click(); + const row = page.locator('fieldset').first().locator('label:has(input[value="planned-holidays"])'); + await row.hover(); + expect(await row.evaluate(el => [getComputedStyle(el).backgroundColor, getComputedStyle(el).borderTopColor, getComputedStyle(el).cursor, getComputedStyle(el.querySelector('input')).cursor])).toEqual(['rgb(255, 255, 255)', 'rgb(213, 217, 223)', 'default', 'default']); + // A locked row the learner chose, with no mark ("I don't know"), keeps its selection under the pointer. + const chosen = page.locator('fieldset').nth(2).locator('label:has(input[value="dont-know"])'); + await chosen.hover(); + expect(await chosen.evaluate(el => [getComputedStyle(el).backgroundColor, getComputedStyle(el).borderTopColor])).toEqual(['rgb(238, 242, 253)', 'rgb(44, 85, 201)']); +}); + +test('a checkbox outside a choice row gets the 3px focus ring', async ({ page }) => { + await open(page, '/dont-know/en.html'); + await page.locator('.lp-stem').first().evaluate(stem => stem.insertAdjacentHTML('afterend', '')); + await page.locator('[data-before]').focus(); + await page.keyboard.press('Tab'); + const bare = page.locator('[data-bare]'); + await expect(bare).toBeFocused(); + expect(await bare.evaluate(el => { const css = getComputedStyle(el); return [css.outlineWidth, css.outlineStyle, css.outlineColor]; })).toEqual(['3px', 'solid', 'rgb(44, 85, 201)']); +}); diff --git a/tests/browser/write-distractors.spec.js b/tests/browser/write-distractors.spec.js index 78c3d0e..acf7764 100644 --- a/tests/browser/write-distractors.spec.js +++ b/tests/browser/write-distractors.spec.js @@ -752,7 +752,7 @@ test('forced colours keeps focus rings and quiet Start over', async ({ page, bro for (const field of [page.locator('[data-lp-text]').first(), page.locator('select').first(), page.locator('input').nth(1), page.locator('[data-lp-compare]'), page.locator('[data-lp-clear]')]) { await field.focus(); const style = await field.evaluate(el => { const css = getComputedStyle(el); return [css.outlineWidth, css.outlineStyle, css.outlineOffset, css.outlineColor]; }); - expect(style.slice(0, 3)).toEqual(['2px', 'solid', '2px']); expect(style[3]).not.toBe('rgba(0, 0, 0, 0)'); + expect(style.slice(0, 3)).toEqual(['3px', 'solid', '2px']); expect(style[3]).not.toBe('rgba(0, 0, 0, 0)'); } await page.locator('[data-lp-compare]').click(); await expect(page.locator('[data-lp-option-summary]').nth(1)).toContainText('Breaks disrupt focus'); diff --git a/tests/mark-words.test.js b/tests/mark-words.test.js new file mode 100644 index 0000000..438fcf8 --- /dev/null +++ b/tests/mark-words.test.js @@ -0,0 +1,20 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// One set of answer marks across patterns (owner, 2026-10-22): a chosen correct answer, a chosen +// incorrect answer, and a correct answer the learner didn't choose. Explanations go in the feedback +// text, never in the mark. +const MARKS = { + en: { markCorrect: 'Correct', markWrong: 'Incorrect', markAnswer: 'Correct answer' }, + fr: { markCorrect: 'Correct', markWrong: 'Incorrect', markAnswer: 'Bonne réponse' } +}; + +for (const pattern of ['dont-know', 'highlight', 'test-out']) { + test(`${pattern} marks answers with the shared words in English and French`, async () => { + const { strings } = await import(`../patterns/${pattern}/strings.js`); + for (const lang of ['en', 'fr']) { + const marks = Object.fromEntries(Object.keys(MARKS[lang]).map(key => [key, strings[lang][key]])); + assert.deepEqual(marks, MARKS[lang]); + } + }); +} diff --git a/types/patterns/highlight/strings.d.ts b/types/patterns/highlight/strings.d.ts index b7fa850..8fbae3f 100644 --- a/types/patterns/highlight/strings.d.ts +++ b/types/patterns/highlight/strings.d.ts @@ -9,11 +9,14 @@ export namespace strings { let check: string; let restart: string; let answer: string; + let markCorrect: string; + let markWrong: string; + let markAnswer: string; let correctKey: string; let correctEvidence: string; - let missed: string; let wrongKey: string; let wrongEvidence: string; + let missed: string; let keySummary: string; let evidenceSummary: string; } @@ -36,16 +39,22 @@ export namespace strings { export { restart_1 as restart }; let answer_1: string; export { answer_1 as answer }; + let markCorrect_1: string; + export { markCorrect_1 as markCorrect }; + let markWrong_1: string; + export { markWrong_1 as markWrong }; + let markAnswer_1: string; + export { markAnswer_1 as markAnswer }; let correctKey_1: string; export { correctKey_1 as correctKey }; let correctEvidence_1: string; export { correctEvidence_1 as correctEvidence }; - let missed_1: string; - export { missed_1 as missed }; let wrongKey_1: string; export { wrongKey_1 as wrongKey }; let wrongEvidence_1: string; export { wrongEvidence_1 as wrongEvidence }; + let missed_1: string; + export { missed_1 as missed }; let keySummary_1: string; export { keySummary_1 as keySummary }; let evidenceSummary_1: string; diff --git a/types/proxy/logic/07-explain-back.d.ts b/types/proxy/logic/07-explain-back.d.ts index 93b9a18..0106704 100644 --- a/types/proxy/logic/07-explain-back.d.ts +++ b/types/proxy/logic/07-explain-back.d.ts @@ -4,7 +4,7 @@ export function explainFeedback(answers: Record): { items: { key: string; - state: "unsure" | "met" | "missed"; + state: "unsure" | "missed" | "met"; }[]; met: number; allMet: boolean;