diff --git a/AGENTS.md b/AGENTS.md index e0f1f78..b5a6b4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,9 @@ 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. +- 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. diff --git a/lib/base.css b/lib/base.css index 88042ac..d22f998 100644 --- a/lib/base.css +++ b/lib/base.css @@ -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; @@ -60,14 +73,26 @@ .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; } +.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, @@ -75,7 +100,7 @@ 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; } @@ -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 { @@ -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; @@ -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; @@ -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; } @@ -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; @@ -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; } diff --git a/patterns/conversation/pattern.css b/patterns/conversation/pattern.css index ea62f2e..2eeb9b8 100644 --- a/patterns/conversation/pattern.css +++ b/patterns/conversation/pattern.css @@ -1,6 +1,6 @@ /* 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 @@ -8,7 +8,7 @@ .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. */ @@ -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; } diff --git a/patterns/course-lookup/course-lookup.test.js b/patterns/course-lookup/course-lookup.test.js index c55bd3c..95d98c8 100644 --- a/patterns/course-lookup/course-lookup.test.js +++ b/patterns/course-lookup/course-lookup.test.js @@ -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*]*data-lp-checking[^>]*>[^<]*<\/p>\s*]*data-lp-paused[^>]*>[^<]*<\/p>\s*
{ 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]; diff --git a/patterns/course-lookup/pattern.css b/patterns/course-lookup/pattern.css index 4f45529..a11be9e 100644 --- a/patterns/course-lookup/pattern.css +++ b/patterns/course-lookup/pattern.css @@ -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)); } diff --git a/patterns/course-lookup/render.js b/patterns/course-lookup/render.js index af6bf0e..a021c10 100644 --- a/patterns/course-lookup/render.js +++ b/patterns/course-lookup/render.js @@ -13,9 +13,9 @@ export function render(content, strings, { id, lang }) {

${html(content.prompt, lang).replaceAll('{course}', () => `${html(content.course ?? '', lang)}`)}