Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ 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.
- Space with the `--lp-gap-*` tokens. Wrap each field's label, hint, field and error in one element. `base.css` spaces the stem, label, hint, field and error pairs; don't restate those gaps in `pattern.css`. A legend sets its own bottom margin.
- 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.
- The scene header holds one title, the task name or a question, and no subtitle line. Exception: conversation keeps the role under the person's name because who the learner is talking to changes what a good reply is. Context such as a scoring rule goes below the header. A section right under the header draws no top rule.
- 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.
Expand Down
51 changes: 37 additions & 14 deletions lib/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@
--lp-radius-control: 10px;
--lp-shadow: 0 1px 2px rgb(22 24 29 / .05), 0 16px 40px -20px rgb(22 34 70 / .28);
--lp-ease: cubic-bezier(.2, .8, .2, 1);
--lp-space-1: .25rem;
--lp-space-2: .5rem;
--lp-space-3: .75rem;
--lp-space-4: 1rem;
--lp-space-5: 1.5rem;
/* Keep related text closer than the groups around it. */
--lp-gap-hint: var(--lp-space-1);
--lp-gap-label: var(--lp-space-2);
--lp-gap-choice: var(--lp-space-2);
--lp-gap-stem: var(--lp-space-3);
--lp-gap-flow: var(--lp-space-4);
--lp-gap-group: var(--lp-space-5);
--lp-gap-section: var(--lp-space-5);

box-sizing: border-box;
container-type: inline-size;
Expand Down Expand Up @@ -60,22 +73,34 @@
.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; }
.lp > * + *, .lp :where(.lp-stack, .lp-section, .lp-box) > * + * { margin-block-start: var(--lp-gap-flow); }
.lp > * + :where(.lp-choices, :has(> :is(.lp-input, .lp-choices))),
.lp :where(.lp-stack, .lp-section, .lp-box) > * + :where(.lp-choices, :has(> :is(.lp-input, .lp-choices))) { margin-block-start: var(--lp-gap-group); }
/* A hidden notice or result does not separate a visible pair. Legends own their bottom gap. */
.lp :where(.lp-stem:not(legend, [hidden]), .lp-run-in:not(legend, [hidden])) + :where(*),
.lp :where(.lp-stem:not(legend, [hidden]), .lp-run-in:not(legend, [hidden])) + :where([hidden]) + :where(*) { margin-block-start: var(--lp-gap-stem); }
.lp :where(.lp-label:not(legend, [hidden])) + :where(*),
.lp :where(.lp-label:not(legend, [hidden])) + :where([hidden]) + :where(*) { margin-block-start: var(--lp-gap-label); }
.lp :where(.lp-stem:not(legend, [hidden]), .lp-label:not(legend, [hidden])) + :where(.lp-small),
.lp :where(.lp-stem:not(legend, [hidden]), .lp-label:not(legend, [hidden])) + :where([hidden]) + :where(.lp-small) { margin-block-start: var(--lp-gap-hint); }
.lp :where(.lp-small:not([hidden])) + :where(.lp-input),
.lp :where(.lp-small:not([hidden])) + :where([hidden]) + :where(.lp-input) { margin-block-start: var(--lp-gap-label); }
.lp :where(.lp-input:not([hidden]), .lp-choices:not([hidden])) + :where(.lp-small, .lp-error-text),
.lp :where(.lp-input:not([hidden]), .lp-choices:not([hidden])) + :where([hidden]) + :where(.lp-small, .lp-error-text) { margin-block-start: var(--lp-gap-label); }

/* Text roles */
.lp-stem { font-family: var(--lp-font-heading); font-size: var(--lp-type-h3); font-weight: 600; line-height: 1.3; letter-spacing: -.005em; }
.lp-label { display: block; font-size: var(--lp-type-small); font-weight: 600; color: var(--lp-ink-2); }
.lp-label + .lp-input { margin-block-start: 6px; }
.lp-small { color: var(--lp-ink-2); font-size: var(--lp-type-small); line-height: 1.45; }
.lp-run-in { font-size: var(--lp-type-body); font-weight: 600; line-height: 1.3; }
.lp-run-in { font-size: var(--lp-type-body); font-weight: 600; line-height: 1.4; }
.lp-quote { padding: 12px 16px; border-inline-start: 3px solid var(--lp-line-mid); border-radius: 0 8px 8px 0; background: var(--lp-paper-2); }

/* The scene header: what this activity is, shown as the real task (an icon tile, a short label,
the title). It spans the top of the card. The tile centres on the text block while the block is
up to two title lines tall, so one-line and two-line titles line up. A taller block keeps the tile
level with its first two lines instead of floating mid-block. The icon column stretches to the
text; its top spacer stops at the two-line offset and the bottom spacer takes the rest. */
.lp-scene { display: flex; gap: 14px; align-items: center; margin: -28px -28px 24px; padding: 20px 28px; border-block-end: 1px solid var(--lp-rule); border-radius: var(--lp-radius-box) var(--lp-radius-box) 0 0; background: var(--lp-paper-2); }
.lp-scene { display: flex; gap: 14px; align-items: center; margin: -28px -28px var(--lp-gap-group); padding: 20px 28px; border-block-end: 1px solid var(--lp-rule); border-radius: var(--lp-radius-box) var(--lp-radius-box) 0 0; background: var(--lp-paper-2); }
.lp-scene > div { min-inline-size: 0; }
.lp-scene-icon { display: flex; flex: none; flex-direction: column; align-self: stretch; color: var(--lp-accent-ink); }
.lp-scene-icon::before, .lp-scene-icon::after { content: ""; flex: 1 1 0; }
Expand All @@ -88,10 +113,9 @@
.lp-scene-sub { margin-block-start: 4px; color: var(--lp-ink-2); font-size: var(--lp-type-small); line-height: 1.45; }

/* 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; }
.lp-section { margin-block-start: var(--lp-gap-section); padding-block-start: var(--lp-gap-section); border-block-start: 1px solid var(--lp-rule); }
/* 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; }
.lp-scene + .lp-section, .lp-scene + * > .lp-section:first-child { margin-block-start: 0; padding-block-start: 0; border-block-start: 0; }

/* Fields */
.lp-input {
Expand Down Expand Up @@ -119,8 +143,8 @@ select.lp-input[aria-invalid="true"] { padding-inline-end: 39px; }

/* Choice rows, after Tally: a light row, a letter key for single choice, a square for
multiple choice. The native input stays in the row for keyboards and screen readers. */
.lp-choices { display: grid; gap: 8px; padding: 0; border: 0; min-inline-size: 0; counter-reset: lp-key; }
.lp-choices > legend { padding: 0; margin-block-end: 12px; }
.lp-choices { display: grid; gap: var(--lp-gap-choice); padding: 0; border: 0; min-inline-size: 0; counter-reset: lp-key; }
.lp-choices > legend { padding: 0; margin-block-end: var(--lp-gap-stem); }
.lp-choice {
position: relative;
display: flex;
Expand All @@ -131,7 +155,7 @@ select.lp-input[aria-invalid="true"] { padding-inline-end: 39px; }
border: 1px solid var(--lp-line-mid);
border-radius: var(--lp-radius-control);
background: var(--lp-paper);
line-height: 1.35;
line-height: 1.4;
cursor: pointer;
counter-increment: lp-key;
transition: border-color .15s, background .15s;
Expand Down Expand Up @@ -183,7 +207,7 @@ select.lp-input[aria-invalid="true"] { padding-inline-end: 39px; }
height and x-height on the box's centre. Centring the line box looked high, because lowercase
text sits below the line's centre (owner, 2026-10-07); aligning to the 13px key letter's baseline
still left the 17px text about 2px high (owner, 2026-10-10). The .125em top margin (2px at body
size) closes that gap; later lines and feedback in the text column keep no margin. */
size) is retained with the 1.4 line height; later lines and feedback in the text column keep no margin. */
.lp-choice:has(> input[type="radio"])::before,
.lp-choice > .lp-choice-key,
.lp-choice > :not(input, .lp-choice-mark, .lp-choice-key) { align-self: start; }
Expand Down Expand Up @@ -221,10 +245,9 @@ select.lp-input[aria-invalid="true"] { padding-inline-end: 39px; }
/* A pattern that sits in running text keeps its root plain and makes each activity a card. */
.lp.lp-unboxed { padding: 0; border: 0; background: transparent; box-shadow: none; }
.lp-box { padding: 24px; border: 1px solid var(--lp-rule); border-radius: var(--lp-radius-box); background: var(--lp-paper); box-shadow: var(--lp-shadow); }
.lp-box > * + * { margin-block-start: 16px; }

/* Buttons */
.lp-actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-block-start: 24px; }
.lp-actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-block-start: var(--lp-gap-group); }
.lp-actions:not(:has(> :not([hidden]))) { display: none; }
.lp-button {
display: inline-flex;
Expand Down Expand Up @@ -280,7 +303,7 @@ select.lp-input[aria-invalid="true"] { padding-inline-end: 39px; }
on the centre of the first text line. */
.lp-details > summary::before { content: ""; flex: none; inline-size: .45em; block-size: .45em; margin-block: calc((1lh - .45em) / 2); border-block-end: 2px solid; border-inline-end: 2px solid; transform: rotate(-45deg); transition: transform .15s var(--lp-ease); }
.lp-details[open] > summary::before { transform: translate(0, -.16em) rotate(45deg); }
.lp-details > :not(summary) { margin-block-start: 12px; }
.lp-details > :not(summary) { margin-block-start: var(--lp-gap-stem); }
/* 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; }
Expand Down
10 changes: 5 additions & 5 deletions patterns/conversation/pattern.css
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
/* Chat bubbles and a reply composer, using the shared course controls. */
.lp-conversation-avatar { display: grid; place-items: center; flex: none; inline-size: 36px; block-size: 36px; border-radius: 50%; color: var(--lp-on-accent); background: var(--lp-accent); font-weight: 600; }
.lp-conversation-chat { display: grid; gap: 16px; padding: 0; list-style: none; }
.lp-conversation-chat { display: grid; gap: var(--lp-gap-flow); padding: 0; list-style: none; }
.lp-conversation-turn { display: flex; gap: 10px; align-items: flex-start; min-inline-size: 0; }
.lp-conversation-bubble { max-inline-size: 85%; min-inline-size: 0; padding: 12px 16px; border-radius: var(--lp-radius-control); background: var(--lp-paper-2); }
/* The avatar's centre sits on the first line of its bubble: the bubble's top padding, plus half the
difference between one line box (1lh, the same font and line height as the bubble) and the avatar. */
.lp-conversation-chat .lp-conversation-avatar { margin-block-start: calc(12px + (1lh - 36px) / 2); }
.lp-conversation-turn-you { justify-content: flex-end; }
.lp-conversation-turn-you .lp-conversation-bubble { background: var(--lp-accent-soft); }
.lp-conversation-speaker { display: block; margin-block-end: 4px; color: var(--lp-ink-2); font-size: var(--lp-type-small); font-weight: 600; }
.lp-conversation-speaker { display: block; margin-block-end: var(--lp-gap-hint); color: var(--lp-ink-2); font-size: var(--lp-type-small); font-weight: 600; }
/* Notes start where Michel's bubbles start, after the avatar and its gap. */
.lp-conversation-note { padding-inline-start: 46px; color: var(--lp-ink-2); font-style: italic; }
/* Clipping keeps the textarea's square corners from covering the rounded border. */
Expand All @@ -23,16 +23,16 @@
.lp-conversation-foot { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; align-items: center; padding: 10px 16px; border-block-start: 1px solid var(--lp-rule); }
.lp-conversation-foot .lp-error-text { margin-inline-end: auto; }
/* The restart button's 44px target already adds space above its label, so it sits closer to the choices. */
.lp-conversation .lp-actions { margin-block-start: 8px; }
.lp-conversation .lp-actions { margin-block-start: var(--lp-gap-label); }
/* The avatar lines up with the name, not with the name and role together. The name is as tall as the
avatar and centres its line in that height, so the avatar's centre is the name's centre. */
.lp-conversation .lp-scene { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 0 14px; }
.lp-conversation .lp-scene > div { display: contents; }
.lp-conversation .lp-scene-title { display: flex; align-items: center; min-block-size: 36px; min-inline-size: 0; margin: 0; }
.lp-conversation .lp-scene-sub { grid-column: 2; min-inline-size: 0; margin: 0; }
.lp-conversation [data-lp-flow] > * + *, .lp-conversation [data-lp-composer] > * + * { margin-block-start: 12px; }
.lp-conversation [data-lp-flow] > * + *, .lp-conversation [data-lp-composer] > * + * { margin-block-start: var(--lp-gap-stem); }
.lp-conversation-choice { inline-size: 100%; font: inherit; text-align: start; color: inherit; }
.lp-conversation-debrief { display: grid; gap: 16px; padding-inline-start: 24px; }
.lp-conversation-debrief { display: grid; gap: var(--lp-gap-flow); padding-inline-start: 24px; }
.lp-conversation [data-lp-choices] > summary::before { transition: none; }
@container (max-width: 25rem) {
.lp-conversation-bubble { max-inline-size: 100%; padding: 10px 12px; }
Expand Down
5 changes: 5 additions & 0 deletions patterns/course-lookup/course-lookup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { ENTRY_IDS, MATCH_GATE, faqLookup } from '../../proxy/logic/20-faq.js';
import { ENTRY_IDS as SECTION_IDS, sectionLookup } from '../../proxy/logic/21-sections.js';

const example = JSON.parse(await readFile(new URL('./examples/en.json', import.meta.url)));

test('lookup notes precede the data notice directly after the field', () => {
const markup = render(example, strings.en, { id: 'spacing', lang: 'en' });
assert.match(markup, /<\/textarea>\s*<p[^>]*data-lp-checking[^>]*>[^<]*<\/p>\s*<p[^>]*data-lp-paused[^>]*>[^<]*<\/p>\s*<div data-lp-notice/);
});
const answer = (scores, ids = ENTRY_IDS, confidence = 1) => {
const probabilities = Object.fromEntries([...ids, 'none'].map(id => [id, scores[id] ?? 0]));
const choice = Object.keys(probabilities).sort((a, b) => probabilities[b] - probabilities[a])[0];
Expand Down
11 changes: 5 additions & 6 deletions patterns/course-lookup/pattern.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@
.lp-course-lookup-faq + .lp-course-lookup-faq { margin-block-start: 0; }
/* The answer starts under the question text: past the chevron (.45em), the mark and both gaps.
It sits closer to its own question than to the next one. */
.lp-course-lookup-faq > p { margin-block: 4px 12px; margin-inline-start: calc(.45em + 1.25rem + 16px); }
.lp-course-lookup-faq > p { margin-block: var(--lp-gap-hint) var(--lp-gap-stem); margin-inline-start: calc(.45em + 1.25rem + 16px); }
.lp-course-lookup-outline, .lp-course-lookup-bank { padding: 0; list-style: none; }
.lp-course-lookup-outline > li + li, .lp-course-lookup-bank > li + li { margin-block-start: 20px; }
.lp-course-lookup-bank li > * + * { margin-block-start: 6px; }
.lp-course-lookup-outline > li + li, .lp-course-lookup-bank > li + li { margin-block-start: var(--lp-gap-group); }
.lp-course-lookup-bank li > * + * { margin-block-start: var(--lp-gap-label); }
/* Pull the × glyph onto the text column: remove the button's 10px padding and the icon's inset (a quarter of 1.15em). */
.lp-course-lookup-bank .lp-button-quiet { margin-block-start: 10px; margin-inline-start: calc(1.25rem - 2px - .2875em); }
.lp-course-lookup [data-lp-result] > article > * + * { margin-block-start: 8px; }
.lp-course-lookup-q { display: flex; align-items: flex-start; gap: 8px; line-height: 1.3; }
.lp-course-lookup-q { display: flex; align-items: flex-start; gap: var(--lp-gap-label); line-height: 1.3; }
.lp-course-lookup-q > .lp-icon { inline-size: 1.25rem; color: var(--lp-ink-2); }
/* A pasted file name or URL breaks instead of widening the column to its length. */
.lp-course-lookup-q > span { min-inline-size: 0; overflow-wrap: anywhere; }
/* Text under a question starts under the question text, not under the mark. */
.lp-course-lookup-q ~ * { margin-inline-start: calc(1.25rem + 8px); }
.lp-course-lookup-q ~ * { margin-inline-start: calc(1.25rem + var(--lp-gap-label)); }
2 changes: 1 addition & 1 deletion patterns/course-lookup/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ export function render(content, strings, { id, lang }) {
<h2 class="lp-stem" id="${html(`${id}-prompt`)}">${html(content.prompt, lang).replaceAll('{course}', () => `<em>${html(content.course ?? '', lang)}</em>`)}</h2>
<div data-lp-controls hidden class="lp-stack">
<textarea class="lp-input" rows="2" maxlength="${QUESTION_LIMIT}" id="${html(`${id}-question`)}" aria-labelledby="${html(`${id}-prompt`)}" placeholder="${html(strings.placeholder, lang)}"></textarea>
<div data-lp-notice id="${html(`${id}-notice`)}" hidden></div>
<p class="lp-small" data-lp-checking hidden>${html(strings.checking, lang)}</p>
<p class="lp-small" data-lp-paused hidden>${html(strings.paused, lang)}</p>
<div data-lp-notice id="${html(`${id}-notice`)}" hidden></div>
<div class="lp-section lp-stack" data-lp-result aria-live="off" hidden></div>
<button type="button" class="lp-button lp-button-secondary" data-lp-add hidden>${html(strings.add, lang)}</button>
<p class="lp-small" data-lp-bank-message hidden></p>
Expand Down
5 changes: 5 additions & 0 deletions patterns/explain-back/explain-back.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { render } from './render.js';
import { strings } from './strings.js';

const content = JSON.parse(await readFile(new URL('./examples/en.json', import.meta.url)));

test('field error precedes the data notice in reading order', () => {
const markup = render(content, strings.en, { id: 'spacing', lang: 'en' });
assert.match(markup, /<\/textarea>\s*<p[^>]*data-lp-error[^>]*>[\s\S]*?<\/p>\s*<div data-lp-notice/);
});
const answers = value => Object.fromEntries(['stonewalling', 'pause', 'return'].map(id => [id, { noul: value }]));

test('each idea uses the owning gate, including both boundaries', () => {
Expand Down
Loading
Loading