From 926f88f3da772d34debc7166200042a875f8cc61 Mon Sep 17 00:00:00 2001 From: Ariel Harlap <3870784+aharlap@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:35:24 -0400 Subject: [PATCH 1/8] Apply shared spacing relationships --- lib/base.css | 43 +++++++--- patterns/retrieval-sheet/pattern.css | 1 - patterns/test-out/pattern.css | 4 +- patterns/test-out/render.js | 4 +- patterns/write-distractors/pattern.css | 2 - tests/browser/spacing.spec.js | 106 +++++++++++++++++++++++++ 6 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 tests/browser/spacing.spec.js diff --git a/lib/base.css b/lib/base.css index 88042ac..d8ce4c2 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,20 @@ .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); } +.lp :where(.lp-stem:not(legend)) + :where(*) { margin-block-start: var(--lp-gap-stem); } +.lp :where(.lp-label, .lp-run-in) + :where(*) { margin-block-start: var(--lp-gap-label); } +.lp :where(.lp-stem:not(legend), .lp-label) + :where(.lp-small) { margin-block-start: var(--lp-gap-hint); } +.lp :where(.lp-small) + :where(.lp-input) { margin-block-start: var(--lp-gap-label); } +.lp :where(.lp-input, .lp-choices) + :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 +94,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 +107,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-section:first-child { margin-block-start: 0; padding-block-start: 0; border-block-start: 0; } /* Fields */ .lp-input { @@ -119,8 +137,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 +149,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; @@ -221,10 +239,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 +297,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/retrieval-sheet/pattern.css b/patterns/retrieval-sheet/pattern.css index b2e2dce..b90f631 100644 --- a/patterns/retrieval-sheet/pattern.css +++ b/patterns/retrieval-sheet/pattern.css @@ -3,7 +3,6 @@ .lp-retrieval-sheet-choice-date { display: block; margin-block-start: 4px; } .lp-retrieval-sheet-date-field { min-inline-size: 0; } .lp-retrieval-sheet-date-field .lp-input { inline-size: 12rem; max-inline-size: 100%; } -.lp-retrieval-sheet-date-field .lp-error-text { margin-block-start: 8px; } .lp-retrieval-sheet-tabs { display: flex; flex-wrap: wrap; gap: 4px; } .lp-retrieval-sheet-tab { min-block-size: 44px; diff --git a/patterns/test-out/pattern.css b/patterns/test-out/pattern.css index fec347e..8479c48 100644 --- a/patterns/test-out/pattern.css +++ b/patterns/test-out/pattern.css @@ -1,9 +1,10 @@ /* Test-out specifics. Link lib/base.css first. */ -.lp-test-out-panel > * + * { margin-block-start: 16px; } .lp-test-out-outline-list { display: grid; gap: 20px; padding-inline-start: 24px; } .lp-test-out-outline-list li::marker { color: var(--lp-ink-2); font-size: var(--lp-type-small); } .lp-test-out-outline-row { display: flex; gap: 16px; align-items: flex-start; } .lp-test-out-outline-row > .lp-run-in { flex: 1; min-inline-size: 0; } +/* Outline titles and statuses share a row. */ +.lp-test-out-outline-row > .lp-test-out-status { margin-block-start: 0; } .lp-test-out-status { display: inline-flex; align-items: center; gap: 4px; flex: none; white-space: nowrap; font-weight: 600; } /* Back and Next share one row at every width. Next keeps its size; base.css would fill the card. */ .lp-test-out-navigation { justify-content: space-between; } @@ -16,7 +17,6 @@ @keyframes lp-test-out-slide { from { opacity: 0; transform: translateX(var(--lp-test-out-enter-x, 16px)); } } /* Without JavaScript both panels show; the questions start 24px below the intro. */ .lp-test-out-questions { margin-block-start: 24px; } -.lp-test-out-questions > .lp-test-out-panel:first-child { margin-block-start: 0; padding-block-start: 0; border-block-start: 0; } .lp-test-out .lp-choice > span:not(.lp-choice-mark) { white-space: pre-line; } .lp-test-out .lp-choice-mark { white-space: normal; } .lp-test-out-fallback-list { display: grid; gap: 20px; padding-inline-start: 24px; } diff --git a/patterns/test-out/render.js b/patterns/test-out/render.js index 05f07df..5fb0205 100644 --- a/patterns/test-out/render.js +++ b/patterns/test-out/render.js @@ -15,7 +15,7 @@ export function render(content, strings, { id, lang }) {

${html(content.title, lang)}

-
+

${html(strings.outline, lang)}

    ${content.sections.map(s => `
  1. @@ -30,7 +30,7 @@ export function render(content, strings, { id, lang }) {
- ${questions.map((q, index) => `
+ ${questions.map((q, index) => `

${html(format(strings.question, { number: index + 1, total: questions.length, section: content.sections.find(s => s.id === q.section)?.title ?? '' }))}

${html(q.text, lang)} diff --git a/patterns/write-distractors/pattern.css b/patterns/write-distractors/pattern.css index bd3cf46..97fce95 100644 --- a/patterns/write-distractors/pattern.css +++ b/patterns/write-distractors/pattern.css @@ -4,7 +4,6 @@ textarea.lp-write-distractors-answer { min-block-size: 0; } /* Swap disabled ink and background together; reset must retain contrast on every frame. */ .lp-write-distractors-body [data-lp-check] { transition: none; } -.lp-write-distractors-body .lp-error-text { margin-block-start: 8px; } .lp-write-distractors-right { display: flex; gap: 12px; align-items: flex-start; padding: 16px; border-radius: var(--lp-radius-control); background: var(--lp-success-soft); } /* At the heading's line height, the shared one-line icon box centres the icon on the heading's first line. */ .lp-write-distractors-right > .lp-icon { line-height: 1.3; color: var(--lp-success-ink); } @@ -26,7 +25,6 @@ textarea.lp-write-distractors-answer { min-block-size: 0; } .lp-write-distractors-option > div + div { margin-block-start: 16px; } /* 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/spacing.spec.js b/tests/browser/spacing.spec.js new file mode 100644 index 0000000..2347d70 --- /dev/null +++ b/tests/browser/spacing.spec.js @@ -0,0 +1,106 @@ +import { test, expect } from '@playwright/test'; + +const TEXT_SPACING = '.lp, .lp * { line-height: 1.5 !important; letter-spacing: .12em !important; word-spacing: .16em !important; } .lp p { margin-bottom: 2em !important; }'; + +async function open(page, name, lang = 'en') { + await page.goto(`/${name}/${lang}.html`); + await page.waitForFunction(() => window.lpReady); + await page.evaluate(() => document.fonts.ready); +} + +async function gap(before, after) { + const a = await before.boundingBox(), b = await after.boundingBox(); + expect(a).not.toBeNull(); + expect(b).not.toBeNull(); + return b.y - a.y - a.height; +} + +async function compareAnswers(page, lang = 'en') { + await open(page, 'first-answer', lang); + await page.locator('[data-lp-first-input]').fill('Please let me finish this point.'); + await page.locator('[data-lp-save-first]').click(); + await page.locator('[data-lp-skip]').click(); + await page.locator('[data-lp-now-input]').fill('I lose my place when you interrupt. How do you see it?'); + await page.locator('[data-lp-compare]').click(); +} + +for (const width of [1280, 390]) { + test(`question to textarea is 12px at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await open(page, 'journal'); + expect(await gap(page.locator('.lp-stem'), page.locator('textarea.lp-input'))).toBeCloseTo(12, 1); + expect(await gap(page.locator('textarea.lp-input'), page.locator('.lp-actions'))).toBeCloseTo(24, 1); + }); + + test(`label to input and input to error are 8px at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await open(page, 'retrieval-sheet'); + await page.locator('input[value="custom"]').check(); + const input = page.locator('[data-lp-date]'); + await input.fill(''); + await input.dispatchEvent('change'); + await expect(page.locator('[data-lp-date-error]')).toBeVisible(); + expect(await gap(page.locator('.lp-retrieval-sheet-date-field .lp-label'), input)).toBeCloseTo(8, 1); + expect(await gap(input, page.locator('[data-lp-date-error]'))).toBeCloseTo(8, 1); + }); + + test(`an unpatched field error stays with its field at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await open(page, 'first-answer'); + await page.locator('[data-lp-save-first]').click(); + await expect(page.locator('[data-lp-first-error]')).toBeVisible(); + expect(await gap(page.locator('[data-lp-first-input]'), page.locator('[data-lp-first-error]'))).toBeCloseTo(8, 1); + }); + + test(`answer and choice groups are 24px apart at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await compareAnswers(page); + expect(await gap(page.locator('[data-lp-panel-first-card]'), page.locator('.lp-choices'))).toBeCloseTo(24, 1); + expect(await page.locator('.lp-run-in:visible').first().evaluate(el => parseFloat(getComputedStyle(el).lineHeight) / parseFloat(getComputedStyle(el).fontSize))).toBeCloseTo(1.4, 2); + }); + + test(`legends keep their own 12px gap at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await open(page, 'dont-know'); + const groups = page.locator('.lp-choices'); + expect(await gap(groups.first().locator('legend'), groups.first().locator('.lp-choice').first())).toBeCloseTo(12, 1); + expect(await groups.first().locator('.lp-choice').first().evaluate(el => parseFloat(getComputedStyle(el).lineHeight) / parseFloat(getComputedStyle(el).fontSize))).toBeCloseTo(1.4, 2); + }); +} + +// 320 CSS px represents reflow at 400% zoom from a 1280px viewport. +for (const lang of ['en', 'fr']) for (const textSpacing of [false, true]) { + test(`400% reflow preserves fields and choices (${lang}, text spacing ${textSpacing})`, async ({ page }) => { + await page.setViewportSize({ width: 320, height: 900 }); + for (const name of ['journal', 'first-answer', 'retrieval-sheet', 'dont-know', 'write-distractors', 'self-check', 'test-out']) { + await open(page, name, lang); + if (textSpacing) await page.addStyleTag({ content: TEXT_SPACING }); + if (name === 'first-answer') await page.locator('[data-lp-save-first]').click(); + if (name === 'retrieval-sheet') { + await page.locator('input[value="custom"]').check(); + await page.locator('[data-lp-date]').fill(''); + await page.locator('[data-lp-date]').dispatchEvent('change'); + } + if (name === 'test-out') await page.locator('[data-lp-start]').click(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), name).toBe(true); + const clipped = await page.locator('.lp :is(label, legend, button, textarea, select, input, .lp-error-text):visible').evaluateAll(elements => elements.filter(el => { + const rect = el.getBoundingClientRect(); + return rect.left < 0 || rect.right > innerWidth + 1 || (!(el instanceof HTMLTextAreaElement) && el.scrollWidth > el.clientWidth + 2) || (!(el instanceof HTMLTextAreaElement) && el.scrollHeight > el.clientHeight + 2); + }).map(el => el.outerHTML)); + expect(clipped, name).toEqual([]); + if (name === 'journal') expect(await gap(page.locator('.lp-stem'), page.locator('textarea'))).toBeGreaterThanOrEqual(12); + if (name === 'first-answer') expect(await gap(page.locator('[data-lp-first-input]'), page.locator('[data-lp-first-error]'))).toBeGreaterThanOrEqual(8); + if (name === 'retrieval-sheet') expect(await gap(page.locator('.lp-retrieval-sheet-date-field .lp-label'), page.locator('[data-lp-date]'))).toBeGreaterThanOrEqual(8); + } + await compareAnswers(page, lang); + if (textSpacing) await page.addStyleTag({ content: TEXT_SPACING }); + expect(await gap(page.locator('[data-lp-panel-first-card]'), page.locator('.lp-choices'))).toBeGreaterThanOrEqual(24); + }); +} + +test('forced colours preserve proximity between choice groups', async ({ page, browserName }) => { + test.skip(browserName !== 'chromium', 'Only Chromium emulates forced colours.'); + await page.emulateMedia({ forcedColors: 'active' }); + await compareAnswers(page, 'fr'); + expect(await gap(page.locator('[data-lp-panel-first-card]'), page.locator('.lp-choices'))).toBeCloseTo(24, 1); +}); From 620a079da56ed83917ae616b3cd5f5b6ccb62bfc Mon Sep 17 00:00:00 2001 From: Ariel Harlap <3870784+aharlap@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:35:24 -0400 Subject: [PATCH 2/8] Document the conversation role header exception --- AGENTS.md | 2 +- tests/browser/shared-look.spec.js | 37 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e0f1f78..b76f69c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ Each `patterns//` contains pure `logic.js`, server `render.js`, DOM `enhan - 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, 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. Context such as a scoring rule goes below the header. A section right under the header draws no top rule. Exception: conversation keeps the role under the person's name because who the learner is talking to changes what a good reply is. - 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/tests/browser/shared-look.spec.js b/tests/browser/shared-look.spec.js index 4d49899..e822d43 100644 --- a/tests/browser/shared-look.spec.js +++ b/tests/browser/shared-look.spec.js @@ -4,12 +4,49 @@ import { test, expect } from '@playwright/test'; // font synthesis and no italic face loaded. const HOST = 'h1, h2, h3 { font-family: Georgia, serif; } html { font-synthesis: none; }'; +// Conversation's role is scenario content: it changes what a good reply is. +const HEADER_EXCEPTIONS = { conversation: '.lp-scene-sub' }; + +async function checkHeader(page, name) { + const scene = page.locator('[data-lp-pattern] .lp-scene'); + await expect(scene.locator('.lp-scene-title')).toHaveCount(1); + const title = await scene.locator('.lp-scene-title').innerText(); + const exception = HEADER_EXCEPTIONS[name]; + if (exception) { + await expect(scene.locator(exception)).toHaveCount(1); + await expect(scene.locator(exception)).toBeVisible(); + const role = await scene.locator(exception).innerText(); + expect(role.trim()).not.toBe(''); + await expect(scene.locator(':scope > div > *')).toHaveText([title, role]); + } else { + await expect(scene.locator(':scope > div > *')).toHaveText([title]); + await expect(scene.locator('.lp-scene-sub')).toHaveCount(0); + } +} + 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 lang of ['en', 'fr']) test(`scene headers hold one title, with the named conversation role exception (${lang})`, async ({ page }) => { + for (const name of ['conversation', 'dont-know', 'first-answer', 'highlight', 'retrieval-sheet', 'self-check', 'test-out', 'write-distractors']) { + await open(page, `/${name}/${lang}.html`); + await checkHeader(page, name); + if (name === 'conversation') await expect(page.locator('.lp-scene-sub')).toHaveText(lang === 'en' ? 'Your colleague' : 'Votre collègue'); + } +}); + +test('the header guard rejects added subtitles, including beyond the conversation exception', async ({ page }) => { + test.setTimeout(15000); + for (const name of ['dont-know', 'conversation']) { + await open(page, `/${name}/en.html`); + await page.locator('[data-lp-pattern] .lp-scene > div').evaluate(div => div.insertAdjacentHTML('beforeend', '

Extra subtitle

')); + await expect(checkHeader(page, name)).rejects.toThrow(); + } +}); + 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); From 84757e434552f12ab1f868b920fee79321c60faf Mon Sep 17 00:00:00 2001 From: Ariel Harlap <3870784+aharlap@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:50:50 -0400 Subject: [PATCH 3/8] Fix spacing around legends and hidden siblings --- lib/base.css | 20 +-- patterns/conversation/pattern.css | 10 +- patterns/course-lookup/pattern.css | 11 +- patterns/explain-back/render.js | 6 +- patterns/first-answer/pattern.css | 6 +- patterns/test-out/pattern.css | 6 +- patterns/write-distractors/pattern.css | 17 ++- tests/browser/spacing.spec.js | 165 +++++++++++++++++++++++-- 8 files changed, 198 insertions(+), 43 deletions(-) diff --git a/lib/base.css b/lib/base.css index d8ce4c2..bee2589 100644 --- a/lib/base.css +++ b/lib/base.css @@ -76,11 +76,17 @@ .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); } -.lp :where(.lp-stem:not(legend)) + :where(*) { margin-block-start: var(--lp-gap-stem); } -.lp :where(.lp-label, .lp-run-in) + :where(*) { margin-block-start: var(--lp-gap-label); } -.lp :where(.lp-stem:not(legend), .lp-label) + :where(.lp-small) { margin-block-start: var(--lp-gap-hint); } -.lp :where(.lp-small) + :where(.lp-input) { margin-block-start: var(--lp-gap-label); } -.lp :where(.lp-input, .lp-choices) + :where(.lp-small, .lp-error-text) { margin-block-start: var(--lp-gap-label); } +/* A hidden notice or result does not separate a visible pair. Legends own their bottom gap. */ +.lp :where(.lp-stem:not(legend), .lp-run-in:not(legend)) + :where(*), +.lp :where(.lp-stem:not(legend), .lp-run-in:not(legend)) + :where([hidden]) + :where(*) { margin-block-start: var(--lp-gap-stem); } +.lp :where(.lp-label:not(legend)) + :where(*), +.lp :where(.lp-label:not(legend)) + :where([hidden]) + :where(*) { margin-block-start: var(--lp-gap-label); } +.lp :where(.lp-stem:not(legend), .lp-label:not(legend)) + :where(.lp-small), +.lp :where(.lp-stem:not(legend), .lp-label:not(legend)) + :where([hidden]) + :where(.lp-small) { margin-block-start: var(--lp-gap-hint); } +.lp :where(.lp-small) + :where(.lp-input), +.lp :where(.lp-small) + :where([hidden]) + :where(.lp-input) { margin-block-start: var(--lp-gap-label); } +.lp :where(.lp-input, .lp-choices) + :where(.lp-small, .lp-error-text), +.lp :where(.lp-input, .lp-choices) + :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; } @@ -109,7 +115,7 @@ /* A section inside the card: space and a hairline, never another card. */ .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-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 { @@ -201,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; } 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/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/explain-back/render.js b/patterns/explain-back/render.js index 8150907..3b64c40 100644 --- a/patterns/explain-back/render.js +++ b/patterns/explain-back/render.js @@ -11,11 +11,11 @@ export function render(content, strings, { id, lang }) {
${content.ideas.map(idea => `

${html(idea.heading, lang)}

${html(idea.body, lang)}

`).join('\n')}

${html(content.task, lang)}

-
+
+ +
- -
diff --git a/patterns/first-answer/pattern.css b/patterns/first-answer/pattern.css index 3198b71..4d20144 100644 --- a/patterns/first-answer/pattern.css +++ b/patterns/first-answer/pattern.css @@ -2,7 +2,7 @@ .lp-first-answer .lp-first-answer-page { padding: 16px 18px; line-height: 1.65; } .lp-first-answer .lp-first-answer-page[aria-invalid="true"] { padding: 15px 17px; } .lp-first-answer-card { padding: 18px; border-radius: var(--lp-radius-control); background: var(--lp-paper-2); } -.lp-first-answer-card > * + * { margin-block-start: 12px; } +.lp-first-answer-card > * + * { margin-block-start: var(--lp-gap-stem); } .lp-first-answer-date { display: flex; gap: 6px; align-items: flex-start; } /* The icon fills the first line-high box, so it centres on the first line of the date and wrapped dates hang under the words. Line-box centre sits about 0.9px under the capitals' midline at 15px, so lift it optically. */ @@ -10,12 +10,12 @@ .lp-first-answer-date:has(> span:empty) { display: none; } .lp-first-answer-quote { margin: 0; white-space: pre-wrap; line-height: 1.65; } /* The connector runs from the icon's top edge to the last control, with no overhang past the content. */ -.lp-first-answer-timeline { position: relative; display: flex; gap: 14px; align-items: flex-start; margin-block: 24px; } +.lp-first-answer-timeline { position: relative; display: flex; gap: 14px; align-items: flex-start; margin-block: var(--lp-gap-group); } .lp-first-answer-timeline::before { content: ""; position: absolute; inset-block: 0; inset-inline-start: 19px; border-inline-start: 2px dashed var(--lp-line-mid); } .lp-first-answer-history { z-index: 1; display: grid; place-items: center; flex: none; inline-size: 40px; block-size: 40px; color: var(--lp-ink-2); background: var(--lp-paper); } /* 9px puts the first line of the note (15px, line-height 1.45) on the 40px icon's centre. */ .lp-first-answer-timeline > div { min-inline-size: 0; padding-block-start: 9px; } -.lp-first-answer-timeline .lp-actions { margin-block-start: 12px; } +.lp-first-answer-timeline .lp-actions { margin-block-start: var(--lp-gap-stem); } .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. */ diff --git a/patterns/test-out/pattern.css b/patterns/test-out/pattern.css index 8479c48..1d27d3d 100644 --- a/patterns/test-out/pattern.css +++ b/patterns/test-out/pattern.css @@ -10,17 +10,17 @@ .lp-test-out-navigation { justify-content: space-between; } .lp-test-out-navigation > .lp-button:not(.lp-button-quiet) { flex: none; } /* The review section starts 24px below the outline, as the policy note does, not 16px. */ -.lp-test-out-panel > .lp-section { margin-block-start: 24px; } +.lp-test-out-panel > .lp-section { margin-block-start: var(--lp-gap-section); } /* The shared scene and motion guards live in base.css. This entrance is specific to the stepper. A view change eases in and out (AGENTS.md motion rule), so the panel does not decelerate. */ .lp-test-out-enter { animation: lp-test-out-slide 240ms cubic-bezier(.65, 0, .35, 1); } @keyframes lp-test-out-slide { from { opacity: 0; transform: translateX(var(--lp-test-out-enter-x, 16px)); } } /* Without JavaScript both panels show; the questions start 24px below the intro. */ -.lp-test-out-questions { margin-block-start: 24px; } +.lp-test-out-questions { margin-block-start: var(--lp-gap-group); } .lp-test-out .lp-choice > span:not(.lp-choice-mark) { white-space: pre-line; } .lp-test-out .lp-choice-mark { white-space: normal; } .lp-test-out-fallback-list { display: grid; gap: 20px; padding-inline-start: 24px; } -.lp-test-out-fallback-list li > * + * { margin-block-start: 8px; } +.lp-test-out-fallback-list li > * + * { margin-block-start: var(--lp-gap-label); } @media (prefers-reduced-motion: reduce) { .lp-test-out-enter { animation: none; } } diff --git a/patterns/write-distractors/pattern.css b/patterns/write-distractors/pattern.css index 97fce95..cc09f89 100644 --- a/patterns/write-distractors/pattern.css +++ b/patterns/write-distractors/pattern.css @@ -1,28 +1,27 @@ /* Shared styles and tokens live in lib/base.css; link it first. */ .lp-write-distractors-body { margin-block-start: 0; } -.lp-write-distractors-body > * + * { margin-block-start: 24px; } +.lp-write-distractors-body > * + * { margin-block-start: var(--lp-gap-group); } textarea.lp-write-distractors-answer { min-block-size: 0; } /* Swap disabled ink and background together; reset must retain contrast on every frame. */ .lp-write-distractors-body [data-lp-check] { transition: none; } .lp-write-distractors-right { display: flex; gap: 12px; align-items: flex-start; padding: 16px; border-radius: var(--lp-radius-control); background: var(--lp-success-soft); } /* At the heading's line height, the shared one-line icon box centres the icon on the heading's first line. */ -.lp-write-distractors-right > .lp-icon { line-height: 1.3; color: var(--lp-success-ink); } -.lp-write-distractors-right p + p { margin-block-start: 4px; } -.lp-write-distractors-toggles { display: flex; flex-wrap: wrap; gap: 8px; } -.lp-write-distractors-comparison { display: grid; gap: 24px; } +.lp-write-distractors-right > .lp-icon { line-height: 1.4; color: var(--lp-success-ink); } +.lp-write-distractors-toggles { display: flex; flex-wrap: wrap; gap: var(--lp-gap-choice); } +.lp-write-distractors-comparison { display: grid; gap: var(--lp-gap-group); } /* Each question gives the shared choice grid its own available width. */ .lp-write-distractors-question { container-type: inline-size; min-inline-size: 0; } -.lp-write-distractors-preview { display: grid; gap: 8px; padding: 0; list-style: none; } +.lp-write-distractors-preview { display: grid; gap: var(--lp-gap-choice); padding: 0; list-style: none; } /* Key alignment follows the shared choice rows (baseline, owner 2026-10-07). */ .lp-write-distractors-option-content > span { display: block; padding-block-start: 2px; } -.lp-write-distractors-option-content > p { margin-block-start: 6px; } +.lp-write-distractors-option-content > p { margin-block-start: var(--lp-gap-hint); } .lp-write-distractors-preview-row { cursor: default; } .lp-write-distractors-preview-row:hover { background: var(--lp-paper); border-color: var(--lp-line-mid); } .lp-write-distractors-preview-row[data-lp-mark="correct"]:hover { background: var(--lp-success-soft); border-color: var(--lp-success-ink); } .lp-write-distractors-preview-row .lp-choice-mark { white-space: normal; } .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; } +.lp-write-distractors-option > legend { max-inline-size: 100%; padding: 0; margin-block-end: var(--lp-gap-flow); } +.lp-write-distractors-option > div + div { margin-block-start: var(--lp-gap-flow); } /* 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; } @container (min-width: 40rem) { diff --git a/tests/browser/spacing.spec.js b/tests/browser/spacing.spec.js index 2347d70..2a55515 100644 --- a/tests/browser/spacing.spec.js +++ b/tests/browser/spacing.spec.js @@ -66,8 +66,166 @@ for (const width of [1280, 390]) { expect(await gap(groups.first().locator('legend'), groups.first().locator('.lp-choice').first())).toBeCloseTo(12, 1); expect(await groups.first().locator('.lp-choice').first().evaluate(el => parseFloat(getComputedStyle(el).lineHeight) / parseFloat(getComputedStyle(el).fontSize))).toBeCloseTo(1.4, 2); }); + + test(`self-check ticks share a row and keep the step rule at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await open(page, 'self-check'); + await page.locator('textarea').fill('My answer'); + await page.locator('[data-lp-check]').click(); + const ticks = page.locator('[data-lp-ticks]'); + await expect(ticks).toBeVisible(); + await expect(ticks).toHaveCSS('border-top-width', '1px'); + const choices = ticks.locator('.lp-choice'); + expect(await gap(ticks.locator('legend'), choices.first())).toBeCloseTo(12, 1); + if (width === 1280) { + const a = await choices.nth(0).boundingBox(), b = await choices.nth(1).boundingBox(); + expect(b.x).toBeGreaterThan(a.x); + expect(b.y).toBeCloseTo(a.y, 1); + } + }); + + test(`run-in fallback legends own their 12px gap at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + for (const name of ['live-feedback', 'explain-back']) { + await open(page, name); + await page.evaluate(() => window.lpInstances.forEach(instance => instance.destroy())); + const fallback = page.locator('[data-lp-fallback]'); + expect(await gap(fallback.locator('legend'), fallback.locator('label').first()), name).toBeCloseTo(12, 1); + } + }); + + test(`write-distractors legend and field groups keep equal 16px gaps at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await open(page, 'write-distractors'); + await page.locator('[data-lp-answer]').fill('Breaks help.'); + await page.locator('[data-lp-check]').click(); + await page.locator('[data-lp-had-it]').click(); + const option = page.locator('[data-lp-option]').first(); + expect(await gap(option.locator('legend'), option.locator('textarea'))).toBeCloseTo(16, 1); + expect(await gap(option.locator(':scope > div').nth(0), option.locator(':scope > div').nth(1))).toBeCloseTo(16, 1); + const icon = page.locator('[data-lp-right] > .lp-icon'); + expect(await icon.evaluate(el => parseFloat(getComputedStyle(el).lineHeight) / parseFloat(getComputedStyle(el).fontSize))).toBeCloseTo(1.4, 2); + }); + + test(`first-answer headings stay 12px above field labels through completion and reset at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await open(page, 'first-answer'); + for (let attempt = 0; attempt < 2; attempt++) { + const first = page.locator('[data-lp-first-step]'); + expect(await gap(first.locator('h3'), first.locator('.lp-label'))).toBeCloseTo(12, 1); + expect(await gap(first.locator('.lp-label'), first.locator('textarea'))).toBeCloseTo(8, 1); + await page.locator('[data-lp-first-input]').fill('Let me finish.'); + await page.locator('[data-lp-save-first]').click(); + await page.locator('[data-lp-skip]').click(); + const end = page.locator('[data-lp-end-step]'); + await expect(end.locator('[data-lp-missing]')).toBeHidden(); + expect(await gap(end.locator('h3'), end.locator('label'))).toBeCloseTo(12, 1); + await page.locator('[data-lp-now-input]').fill('When you interrupt I lose my place.'); + await page.locator('[data-lp-compare]').click(); + await expect(end.locator('[data-lp-result]')).toBeVisible(); + expect(await gap(end.locator('h3'), end.locator('label'))).toBeCloseTo(12, 1); + expect(await gap(end.locator('label'), end.locator('textarea'))).toBeCloseTo(8, 1); + await page.locator('[data-lp-restart]').click(); + } + }); + + test(`test-out outline pairs stay 12px apart through completion and reset at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await open(page, 'test-out'); + const heading = page.locator('[data-lp-outline-heading]'); + const list = page.locator('.lp-test-out-outline-list'); + const result = page.locator('[data-lp-result]'); + for (let attempt = 0; attempt < 2; attempt++) { + await expect(result).toBeHidden(); + expect(await gap(heading, list)).toBeCloseTo(12, 1); + await page.locator('[data-lp-start]').click(); + for (const [index, value] of ['outcomes', 'invite', 'actions'].entries()) { + await page.locator('fieldset').nth(index).locator(`input[value="${value}"]`).check(); + if (index < 2) await page.locator('[data-lp-next]:visible').click(); + } + await page.locator('[data-lp-check]').click(); + await expect(result).toBeVisible(); + expect(await gap(heading, result)).toBeCloseTo(12, 1); + expect(await gap(result, list)).toBeCloseTo(12, 1); + await page.locator('[data-lp-restart]').click(); + } + expect(await gap(heading, list)).toBeCloseTo(12, 1); + }); + + test(`hidden notices do not separate fields from errors at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + for (const name of ['misconception', 'explain-back']) { + await open(page, name); + // Exercise the rendered field/error pair with its optional notice absent, then present. + await page.evaluate(() => { + window.lpInstances.forEach(instance => instance.destroy()); + document.querySelector('[data-lp-error]').hidden = false; + }); + await expect(page.locator('[data-lp-notice]')).toBeHidden(); + await expect(page.locator('[data-lp-error]')).toBeVisible(); + expect(await gap(page.locator('textarea'), page.locator('[data-lp-error]')), name).toBeCloseTo(8, 1); + await page.locator('[data-lp-notice]').evaluate(el => { el.textContent = 'Data notice'; el.hidden = false; }); + expect(await gap(page.locator('[data-lp-notice]'), page.locator('[data-lp-error]')), name).toBeCloseTo(16, 1); + } + }); + + for (const role of ['stem', 'label']) test(`${role} to hint is 4px and hint to field is 8px at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await open(page, role === 'stem' ? 'journal' : 'first-answer'); + const label = page.locator(role === 'stem' ? '.lp-stem' : '[data-lp-first-editor] .lp-label'); + // Hosts can supply a hint in the rendered field group without pattern-specific CSS. + await label.evaluate(el => el.insertAdjacentHTML('afterend', '

Give one example.

')); + const hint = page.locator('[data-spacing-hint]'); + const field = page.locator(role === 'stem' ? 'textarea' : '[data-lp-first-input]'); + expect(await gap(label, hint)).toBeCloseTo(4, 1); + expect(await gap(hint, field)).toBeCloseTo(8, 1); + }); } +test('pattern relationships scale with a 20px default font', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 1000 }); + const enlarge = () => page.addStyleTag({ content: 'html { font-size: 20px; }' }); + + await open(page, 'write-distractors'); + await enlarge(); + await page.locator('[data-lp-answer]').fill('Breaks help.'); + await page.locator('[data-lp-check]').click(); + await page.locator('[data-lp-had-it]').click(); + const option = page.locator('[data-lp-option]').first(); + expect(await gap(option.locator('legend'), option.locator('textarea'))).toBeCloseTo(20, 1); + expect(await gap(option.locator(':scope > div').nth(0), option.locator(':scope > div').nth(1))).toBeCloseTo(20, 1); + expect(await gap(option.locator('.lp-label').first(), option.locator('select'))).toBeCloseTo(10, 1); + await expect(page.locator('[data-lp-retrieval]')).toHaveCSS('margin-top', '30px'); + + await compareAnswers(page); + await enlarge(); + const card = page.locator('[data-lp-panel-first-card]'); + expect(await gap(card.locator('h4'), card.locator('.lp-first-answer-date'))).toBeCloseTo(15, 1); + expect(await gap(card, page.locator('.lp-choices'))).toBeCloseTo(30, 1); + + await open(page, 'test-out'); + await enlarge(); + await page.locator('[data-lp-start]').click(); + await expect(page.locator('[data-lp-questions]')).toHaveCSS('margin-top', '30px'); + + await open(page, 'conversation'); + await enlarge(); + const flow = page.locator('[data-lp-flow]'); + await expect(flow.locator('[data-lp-choices]')).toHaveCSS('margin-top', '15px'); + await expect(page.locator('[data-lp-restart]').locator('..')).toHaveCSS('margin-top', '10px'); + + await open(page, 'course-lookup'); + await enlarge(); + const bank = page.locator('[data-lp-kind="faq"] .lp-course-lookup-bank li').first(); + expect(await gap(bank.locator('.lp-small'), bank.locator('p').last())).toBeCloseTo(10, 1); + await page.evaluate(() => window.lpInstances.forEach(instance => instance.destroy())); + const outline = page.locator('.lp-course-lookup-outline'); + expect(await gap(outline.locator('li').nth(0), outline.locator('li').nth(1))).toBeCloseTo(30, 1); + const heading = page.locator('[data-lp-kind="sections"] [data-lp-fallback] > h3'); + expect(await gap(heading, outline)).toBeCloseTo(15, 1); +}); + // 320 CSS px represents reflow at 400% zoom from a 1280px viewport. for (const lang of ['en', 'fr']) for (const textSpacing of [false, true]) { test(`400% reflow preserves fields and choices (${lang}, text spacing ${textSpacing})`, async ({ page }) => { @@ -97,10 +255,3 @@ for (const lang of ['en', 'fr']) for (const textSpacing of [false, true]) { expect(await gap(page.locator('[data-lp-panel-first-card]'), page.locator('.lp-choices'))).toBeGreaterThanOrEqual(24); }); } - -test('forced colours preserve proximity between choice groups', async ({ page, browserName }) => { - test.skip(browserName !== 'chromium', 'Only Chromium emulates forced colours.'); - await page.emulateMedia({ forcedColors: 'active' }); - await compareAnswers(page, 'fr'); - expect(await gap(page.locator('[data-lp-panel-first-card]'), page.locator('.lp-choices'))).toBeCloseTo(24, 1); -}); From 825bc1f46f7afab042bc692c39e169d45e2a9ca7 Mon Sep 17 00:00:00 2001 From: Ariel Harlap <3870784+aharlap@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:50:53 -0400 Subject: [PATCH 4/8] Guard scene text and document spacing rules --- AGENTS.md | 3 ++- tests/browser/shared-look.spec.js | 22 +++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b76f69c..a655c55 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. +- Use the `--lp-gap-*` tokens; a field group wraps its label, hint, field and error; legends own their gap; never add pair margins in `pattern.css`. - 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. Exception: conversation keeps the role under the person's name because who the learner is talking to changes what a good reply is. +- 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/tests/browser/shared-look.spec.js b/tests/browser/shared-look.spec.js index e822d43..9a6e167 100644 --- a/tests/browser/shared-look.spec.js +++ b/tests/browser/shared-look.spec.js @@ -10,18 +10,27 @@ const HEADER_EXCEPTIONS = { conversation: '.lp-scene-sub' }; async function checkHeader(page, name) { const scene = page.locator('[data-lp-pattern] .lp-scene'); await expect(scene.locator('.lp-scene-title')).toHaveCount(1); - const title = await scene.locator('.lp-scene-title').innerText(); const exception = HEADER_EXCEPTIONS[name]; if (exception) { await expect(scene.locator(exception)).toHaveCount(1); await expect(scene.locator(exception)).toBeVisible(); const role = await scene.locator(exception).innerText(); expect(role.trim()).not.toBe(''); - await expect(scene.locator(':scope > div > *')).toHaveText([title, role]); } else { - await expect(scene.locator(':scope > div > *')).toHaveText([title]); await expect(scene.locator('.lp-scene-sub')).toHaveCount(0); } + const extraText = await scene.evaluate((header, exception) => { + const allowed = [header.querySelector('.lp-scene-title'), exception && header.querySelector(exception)].filter(Boolean); + const walker = document.createTreeWalker(header, NodeFilter.SHOW_TEXT); + const extra = []; + while (walker.nextNode()) { + const node = walker.currentNode; + // Decorative icons and conversation's avatar are hidden from the reading order. + if (node.textContent.trim() && !node.parentElement.closest('[aria-hidden="true"]') && !allowed.some(element => element.contains(node))) extra.push(node.textContent.trim()); + } + return extra; + }, exception); + expect(extraText).toEqual([]); } async function open(page, path, host = false) { @@ -40,9 +49,12 @@ for (const lang of ['en', 'fr']) test(`scene headers hold one title, with the na test('the header guard rejects added subtitles, including beyond the conversation exception', async ({ page }) => { test.setTimeout(15000); - for (const name of ['dont-know', 'conversation']) { + for (const name of ['dont-know', 'conversation']) for (const location of ['nested', 'direct', 'text']) { await open(page, `/${name}/en.html`); - await page.locator('[data-lp-pattern] .lp-scene > div').evaluate(div => div.insertAdjacentHTML('beforeend', '

Extra subtitle

')); + await page.locator('[data-lp-pattern] .lp-scene').evaluate((scene, location) => { + if (location === 'text') scene.append('Extra subtitle'); + else (location === 'nested' ? scene.querySelector('div') : scene).insertAdjacentHTML('beforeend', '

Extra subtitle

'); + }, location); await expect(checkHeader(page, name)).rejects.toThrow(); } }); From 8fba23dc4d3ab1ecc76bc2cc0be676ec0617cadb Mon Sep 17 00:00:00 2001 From: Ariel Harlap <3870784+aharlap@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:52:49 -0400 Subject: [PATCH 5/8] Ignore hidden roles when spacing visible pairs --- lib/base.css | 20 ++++++++++---------- tests/browser/spacing.spec.js | 2 ++ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/base.css b/lib/base.css index bee2589..d22f998 100644 --- a/lib/base.css +++ b/lib/base.css @@ -77,16 +77,16 @@ .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), .lp-run-in:not(legend)) + :where(*), -.lp :where(.lp-stem:not(legend), .lp-run-in:not(legend)) + :where([hidden]) + :where(*) { margin-block-start: var(--lp-gap-stem); } -.lp :where(.lp-label:not(legend)) + :where(*), -.lp :where(.lp-label:not(legend)) + :where([hidden]) + :where(*) { margin-block-start: var(--lp-gap-label); } -.lp :where(.lp-stem:not(legend), .lp-label:not(legend)) + :where(.lp-small), -.lp :where(.lp-stem:not(legend), .lp-label:not(legend)) + :where([hidden]) + :where(.lp-small) { margin-block-start: var(--lp-gap-hint); } -.lp :where(.lp-small) + :where(.lp-input), -.lp :where(.lp-small) + :where([hidden]) + :where(.lp-input) { margin-block-start: var(--lp-gap-label); } -.lp :where(.lp-input, .lp-choices) + :where(.lp-small, .lp-error-text), -.lp :where(.lp-input, .lp-choices) + :where([hidden]) + :where(.lp-small, .lp-error-text) { margin-block-start: var(--lp-gap-label); } +.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; } diff --git a/tests/browser/spacing.spec.js b/tests/browser/spacing.spec.js index 2a55515..c0571e2 100644 --- a/tests/browser/spacing.spec.js +++ b/tests/browser/spacing.spec.js @@ -180,6 +180,8 @@ for (const width of [1280, 390]) { const field = page.locator(role === 'stem' ? 'textarea' : '[data-lp-first-input]'); expect(await gap(label, hint)).toBeCloseTo(4, 1); expect(await gap(hint, field)).toBeCloseTo(8, 1); + await hint.evaluate(el => { el.hidden = true; }); + expect(await gap(label, field)).toBeCloseTo(role === 'stem' ? 12 : 8, 1); }); } From c1ebaeb4e92a590fe338b0fcec6e5d75e13172e0 Mon Sep 17 00:00:00 2001 From: Ariel Harlap <3870784+aharlap@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:05:16 -0400 Subject: [PATCH 6/8] Fix question and field group spacing --- patterns/review-prompts/render.js | 16 +++--- .../review-prompts/review-prompts.test.js | 6 +++ patterns/test-out/pattern.css | 5 +- patterns/write-distractors/pattern.css | 4 +- tests/browser/review-prompts.spec.js | 2 +- tests/browser/spacing.spec.js | 52 +++++++++++++++++-- 6 files changed, 68 insertions(+), 17 deletions(-) diff --git a/patterns/review-prompts/render.js b/patterns/review-prompts/render.js index 4b203db..a2f9b09 100644 --- a/patterns/review-prompts/render.js +++ b/patterns/review-prompts/render.js @@ -22,13 +22,15 @@ export function render(content, strings, { id, lang }) { ${part.paragraphs.map(text => `

${html(text, lang)}

`).join('\n ')}

${html(part.question, lang)}

-
- ${html(strings.show, lang)} -

${html(part.answer, lang)}

-
- -