Skip to content
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ Each `patterns/<name>/` 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-<name>-`. 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`).
Expand Down
31 changes: 27 additions & 4 deletions lib/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <em> upright. */
.lp em { font-synthesis-style: auto; }
.lp > * + *, .lp-stack > * + * { margin-block-start: 16px; }

/* Text roles */
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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); }

Expand Down Expand Up @@ -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; }
}
2 changes: 1 addition & 1 deletion patterns/conversation/pattern.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
5 changes: 3 additions & 2 deletions patterns/dont-know/dont-know.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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*<p>A right answer scores a point\./ : /<\/header>\s*<p>Une bonne r茅ponse rapporte un point\./);
}
});

Expand Down
10 changes: 6 additions & 4 deletions patterns/dont-know/enhance.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}<span>${html(word, root.lang)}</span>`;
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}<span>${html(word, root.lang)}</span>`;
row.append(mark);
});
}
Expand Down
4 changes: 2 additions & 2 deletions patterns/dont-know/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ export function render(content, strings, { id, lang }) {
return `<section class="lp lp-dont-know" data-lp-pattern="dont-know" lang="${html(lang)}">
<header class="lp-scene">
<span class="lp-scene-icon">${icons['list-check']}</span>
<div><h2 class="lp-scene-title">${html(content.title, lang)}</h2>
<p class="lp-scene-sub">${html(scoring, lang)}</p></div>
<div><h2 class="lp-scene-title">${html(content.title, lang)}</h2></div>
</header>
<p>${html(scoring, lang)}</p>
${content.questions.map((q, n) => `<div class="lp-section">
<p class="lp-small lp-dont-know-question-number">${html(format(strings.questionNumber, { number: n + 1, total: content.questions.length }), lang)}</p>
<fieldset class="lp-choices" id="${html(`${id}-question-${n}`)}" tabindex="-1" data-lp-question="${html(q.id)}">
Expand Down
4 changes: 2 additions & 2 deletions patterns/dont-know/strings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion patterns/first-answer/pattern.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
6 changes: 4 additions & 2 deletions patterns/highlight/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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`.

Expand All @@ -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. |
Expand Down
15 changes: 5 additions & 10 deletions patterns/highlight/enhance.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}<span><span class="lp-highlight-word">${html(strings.markWrong, root.lang)}</span>${item.note ? `<span class="lp-visually-hidden">.</span> ${html(item.note, root.lang)}` : ''}</span>`;
mark.hidden = false;
chunk.setAttribute('aria-describedby', `${instructions.id} ${mark.id}`);
});
Expand Down
Loading
Loading