From 4e31e48e3da1272f766118b764a0cd1038106936 Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:45:01 -0800 Subject: [PATCH] Keep each Details mark beside its value, with wrapped lines under the text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each Details row's value starts with a small mark (✓, ⓘ, ✕). It was laid out inline, as part of the text, so a word too long to fit beside it, such as an issuer's did:key, left the mark alone on the line above, and a wrapped value's next line started back under the mark rather than under the first word. The mark is now a bullet: a flex item beside the words, on their first line, with every line of the words starting just after it. Stacked, the value still starts under its label. Side by side, the mark and words still sit at the row's end, but a value that wraps is start-aligned inside that, so its lines start under its first word rather than being pushed away from the mark. Right-to-left pages mirror the layout, now exactly: the old 2px margin was on the mark's right in either direction. From review: the words may shrink beside the mark (min-width: 0), so words that cannot break, in a browser without overflow-wrap: anywhere or under a host page's white-space: nowrap, no longer push the mark back over the label. The rows also set text-align themselves, so a host page that centres its text no longer centres the labels. Screen readers are given the same words as before, and the mark stays hidden from them. The did:key test expected the mark above the identifier; it now expects it beside it. The wide-row test measured a value's last line, which no longer reaches the row's end once a value wraps; it measures one-line values by their text and wrapped ones by their box. Closes #44 Co-Authored-By: Claude Opus 5.5 --- src/verifier-credential.ts | 41 ++++--- test/browser/states.spec.ts | 224 ++++++++++++++++++++++++++++++------ 2 files changed, 217 insertions(+), 48 deletions(-) diff --git a/src/verifier-credential.ts b/src/verifier-credential.ts index 0bad935..6eb554e 100644 --- a/src/verifier-credential.ts +++ b/src/verifier-credential.ts @@ -177,27 +177,40 @@ const styles = ` .problem-detail { color: var(--vp-ink-2, #47536a); margin-top: 3px; } .json { margin: 0; padding: 14px; background: var(--vp-surface-2, #f7f9fb); font-size: .76rem; line-height: 1.55; color: var(--vp-ink-2, #47536a); overflow: auto; max-height: 460px; white-space: pre; } - .check { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 16px; + /* text-align is set, not inherited, so a host page's alignment does not + reach the rows: a centred page would centre each label away from its value. */ + .check { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 16px; text-align: start; padding: 7px 0; font-size: .9rem; border-bottom: 1px solid var(--vp-rule, #d9dee7); } .check:last-child { border-bottom: 0; } .check .k { color: var(--vp-ink-2, #47536a); } - /* The mark sits inline, at the start of the value's first line. As a flex - item beside the text, it stayed at the left edge once the text wrapped, - a column away from the words it belongs to. A word too long for the row, - such as an issuer's did:key, breaks rather than running off the card; the - mark then sits just above it. Start and end, not left and right, so a - right-to-left page mirrors the layout rather than breaking it. */ - .check .v { text-align: end; font-weight: 500; overflow-wrap: anywhere; } + /* The mark is a bullet: a flex item beside the words, on their first line, + with every line of the words starting just after it. Laid out inline, as + part of the text, it was left alone on a line above a word too long to + fit beside it, such as an issuer's did:key, and a wrapped value's next + line started back under it. The words break within their own lines rather + than running off the card. + Side by side, the mark and words together sit at the row's end, but the + words still start from the mark: aligned to the end, a wrapped value's + shorter first line was pushed away from it. Start and end, not left and + right, so a right-to-left page mirrors the layout rather than breaking it. */ + .check .v { display: flex; align-items: baseline; justify-content: flex-end; gap: 6px; + font-weight: 500; overflow-wrap: anywhere; } + /* Without this, words that cannot break (a browser without overflow-wrap: + anywhere, such as Safari before 15.4, or a host page setting white-space: + nowrap) keep their full width, and flex-end pushes the mark and words + back over the label and off the card's start edge. */ + .check .v > .mark + span { min-width: 0; } /* A narrow card, as on a phone, stacks each row: the label, then its value - on the line under it, starting where the label does. Aligned to the - end, a short value sat at the far edge, away from its label, and a long one - wrapped ragged. After .check .v, so its text-align wins. */ + on the line under it, starting where the label does. At the row's end, a + short value sat at the far edge, away from its label. After .check .v, + so its alignment wins. */ @container (max-width: 380px) { .check { grid-template-columns: 1fr; } - .check .v { text-align: start; } + .check .v { justify-content: flex-start; } } - .mark { width: 15px; height: 15px; border-radius: 50%; display: inline-grid; place-items: center; - font-size: 10px; font-weight: 700; color: #fff; vertical-align: 2px; margin-right: 2px; } + /* Raised 2px off the words' baseline, as it was when it sat in the text. */ + .mark { flex: none; width: 15px; height: 15px; border-radius: 50%; display: grid; place-items: center; + font-size: 10px; font-weight: 700; color: #fff; position: relative; top: -2px; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } @media (prefers-reduced-motion: reduce) { * { transition: none !important; } } diff --git a/test/browser/states.spec.ts b/test/browser/states.spec.ts index c181afd..a0cad78 100644 --- a/test/browser/states.spec.ts +++ b/test/browser/states.spec.ts @@ -214,9 +214,9 @@ test('an unreachable registry reads differently from an unlisted issuer', async }); /** - * On a phone the Details rows stack and long values wrap. The mark beside a - * value used to stay at the row's left edge once its text wrapped, a column - * away from the right-aligned words it belongs to. + * On a phone the Details rows stack and long values wrap. When the words were + * still right-aligned there, the mark beside a value stayed at the row's left + * edge once its text wrapped, a column away from the words it belongs to. */ const rowLayout = (page: Page) => page.evaluate(() => @@ -251,34 +251,103 @@ for (const situation of ['Registry offline', 'Not their seal']) { }); } -test("a value too long for the row, such as an issuer's did:key, stays inside the card", async ({ page }) => { - await page.setViewportSize({ width: 360, height: 900 }); - await pick(page, 'Issuer unknown'); - const layout = await page.evaluate(() => { - const root = document.getElementById('vc')!.shadowRoot!; - // An issuer with no name is shown by its identifier, which no demo - // credential has; the Issuer row is given one to lay out. - const row = [...root.querySelectorAll('.check')].find((r) => r.querySelector('.k')!.textContent === 'Issuer')!; - const text = row.querySelector('.mark + span')!.lastChild!; - text.textContent = 'did:key:z6MknNQD1WHLGGraFi6zcbGevuAgkVfdyCdtZnQTGWVVvR5Q — not on our list of known issuers'; - const card = root.querySelector('.card')!; - const mark = row.querySelector('.mark')!.getBoundingClientRect(); - const range = document.createRange(); - range.selectNodeContents(text); - const first = range.getClientRects()[0]!; - return { - fits: card.scrollWidth <= card.clientWidth, - // Too long to sit beside it, so on the line just above, at the left - // where the identifier starts (the rows stack at this width) - above: first.top - mark.bottom, - fromLeft: Math.abs(mark.left - first.left), - }; +/** + * The mark is a bullet: it stays on its value's first line, and every line of + * the value starts just after it. As an inline box in the text, it was left + * alone on a line above a word too long to fit beside it, such as an issuer's + * did:key, and a wrapped value's next line started back under it. + */ +for (const [width, rows] of [ + [360, 'stacked'], + [1024, 'side by side'], +] as const) { + test(`an issuer's did:key starts beside its mark and stays inside the card (${rows})`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await pick(page, 'Issuer unknown'); + const layout = await page.evaluate(() => { + const root = document.getElementById('vc')!.shadowRoot!; + // An issuer with no name is shown by its identifier, which no demo + // credential has; the Issuer row is given one to lay out. + const row = [...root.querySelectorAll('.check')].find((r) => r.querySelector('.k')!.textContent === 'Issuer')!; + const text = row.querySelector('.mark + span')!.lastChild!; + text.textContent = 'did:key:z6MknNQD1WHLGGraFi6zcbGevuAgkVfdyCdtZnQTGWVVvR5Q — not on our list of known issuers'; + const card = root.querySelector('.card')!; + const mark = row.querySelector('.mark')!.getBoundingClientRect(); + const range = document.createRange(); + range.selectNodeContents(text); + const rects = [...range.getClientRects()]; + const first = rects[0]!; + return { + wrapped: rects.some((r) => r.top > first.bottom - 2), + stacked: mark.top >= row.querySelector('.k')!.getBoundingClientRect().bottom - 1, + fits: card.scrollWidth <= card.clientWidth, + sameLine: Math.abs(mark.top + mark.height / 2 - (first.top + first.height / 2)) < 6, + gap: first.left - mark.right, + squashed: Math.abs(mark.width - mark.height) > 0.5, + }; + }); + expect(layout.stacked, 'not the layout this case is for').toBe(rows === 'stacked'); + expect(layout.wrapped, 'the identifier did not wrap').toBe(true); + expect(layout.fits, 'the identifier runs off the card').toBe(true); + expect(layout.sameLine, 'the mark is on a line of its own').toBe(true); + expect(layout.gap, 'gap between the mark and the identifier').toBeGreaterThanOrEqual(0); + expect(layout.gap, 'gap between the mark and the identifier').toBeLessThan(10); + // Beside words that fill the line, a flex item may shrink; the mark must not + expect(layout.squashed, 'the mark is squashed out of round').toBe(false); }); - expect(layout.fits, 'the identifier runs off the card').toBe(true); - expect(layout.above, 'the mark is not just above the identifier').toBeGreaterThanOrEqual(-2); - expect(layout.above, 'the mark is not just above the identifier').toBeLessThan(10); - expect(layout.fromLeft, 'the mark is not at the left, over the identifier').toBeLessThan(10); -}); +} + +// Side by side at 480px: the element is about 440px wide (the demo page pads +// it), over the 380px stacking width, yet the Issuer value still wraps. +for (const [width, rows, dir] of [ + [360, 'stacked', 'ltr'], + [360, 'stacked', 'rtl'], + [480, 'side by side', 'ltr'], +] as const) { + test(`a wrapped value's lines all start where its first line does, just after the mark (${rows}, ${dir})`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.evaluate((d) => document.documentElement.setAttribute('dir', d), dir); + await pick(page, 'Issuer unknown'); + const found = await page.evaluate( + (rtl) => + [...document.getElementById('vc')!.shadowRoot!.querySelectorAll('.check')].map((row) => { + const label = row.querySelector('.k')!.getBoundingClientRect(); + const mark = row.querySelector('.mark')!.getBoundingClientRect(); + const range = document.createRange(); + range.selectNodeContents(row.querySelector('.mark + span')!.lastChild!); + // Where each line starts: its left edge, or its right in a right-to-left + // page. A line can come back as several rectangles, one per run of + // text in one direction, so they are grouped by line first. + const lines: { top: number; start: number }[] = []; + for (const r of [...range.getClientRects()].sort((a, b) => a.top - b.top)) { + const start = rtl ? r.right : r.left; + const line = lines.find((l) => Math.abs(l.top - r.top) < 2); + if (!line) lines.push({ top: r.top, start }); + else line.start = rtl ? Math.max(line.start, start) : Math.min(line.start, start); + } + const starts = lines.map((l) => l.start); + return { + label: row.querySelector('.k')!.textContent, + stacked: mark.top >= label.bottom - 1, + squashed: Math.abs(mark.width - mark.height) > 0.5, + gap: rtl ? mark.left - starts[0]! : starts[0]! - mark.right, + starts, + }; + }), + dir === 'rtl', + ); + expect(found.find((r) => r.label === 'Issuer')!.starts.length, 'the Issuer value did not wrap').toBeGreaterThan(1); + for (const r of found) { + expect(r.stacked, `${r.label}: not the layout this case is for`).toBe(rows === 'stacked'); + expect(r.squashed, `${r.label}: the mark is squashed out of round`).toBe(false); + expect(r.gap, `${r.label}: gap between the mark and its first line`).toBeGreaterThanOrEqual(0); + expect(r.gap, `${r.label}: gap between the mark and its first line`).toBeLessThan(10); + for (const start of r.starts.slice(1)) { + expect(Math.abs(start - r.starts[0]!), `${r.label}: a wrapped line starts elsewhere`).toBeLessThanOrEqual(1); + } + } + }); +} /** * Where each value starts, against its label and its row. In a narrow card @@ -292,26 +361,37 @@ const valueEdges = (page: Page) => const mark = row.querySelector('.mark')!.getBoundingClientRect(); const range = document.createRange(); range.selectNodeContents(row.querySelector('.mark + span')!.lastChild!); - const lines = [...range.getClientRects()]; + const rects = [...range.getClientRects()]; + const wrapped = rects.some((r) => r.top > rects[0]!.bottom - 2); + // The value's far edge: its text, while it is one line; once it wraps, + // its lines start after the mark and end raggedly, so the words' box, + // which then reaches across to the row's end + const box = row.querySelector('.mark + span')!.getBoundingClientRect(); + const far = wrapped + ? { left: box.left, right: box.right } + : { left: Math.min(...rects.map((r) => r.left)), right: Math.max(...rects.map((r) => r.right)) }; + const rowBox = row.getBoundingClientRect(); return { label: row.querySelector('.k')!.textContent, below: mark.top >= label.bottom - 1, fromLabelLeft: Math.abs(mark.left - label.left), fromLabelRight: Math.abs(mark.right - label.right), - fromRowRight: Math.abs(row.getBoundingClientRect().right - lines[lines.length - 1]!.right), + fromRowRight: Math.abs(rowBox.right - far.right), + fromRowLeft: Math.abs(rowBox.left - far.left), }; }), ); test('stacked in a right-to-left page, each value starts under its label, at the right', async ({ page }) => { // The alignment is start and end, not left and right, so it follows the - // page's direction. The mark's 2px margin is on its right whichever way. + // page's direction. So is the gap after the mark, so the mark starts + // exactly under its label, as it does left to right. await page.setViewportSize({ width: 360, height: 900 }); await page.evaluate(() => document.documentElement.setAttribute('dir', 'rtl')); await pick(page, 'Verified'); for (const r of await valueEdges(page)) { expect(r.below, `${r.label}: the value is not under its label`).toBe(true); - expect(r.fromLabelRight, `${r.label}: the value does not start under its label`).toBeLessThan(3); + expect(r.fromLabelRight, `${r.label}: the value does not start under its label`).toBeLessThan(1); } }); @@ -337,6 +417,63 @@ for (const situation of ['Verified', 'Registry offline']) { }); } +test("a host page that centres its text doesn't move the rows", async ({ page }) => { + // text-align is inherited into the card; centred, a stacked label sat in + // the middle of the row while its value started at the edge + await page.setViewportSize({ width: 360, height: 900 }); + await page.evaluate(() => (document.getElementById('vc')!.style.textAlign = 'center')); + await pick(page, 'Verified'); + const rows = await page.evaluate(() => + [...document.getElementById('vc')!.shadowRoot!.querySelectorAll('.check')].map((row) => { + // The label's words, not its box, which spans the row however its + // words are aligned + const range = document.createRange(); + range.selectNodeContents(row.querySelector('.k')!); + return { + label: row.querySelector('.k')!.textContent, + fromLabelText: Math.abs(row.querySelector('.mark')!.getBoundingClientRect().left - range.getBoundingClientRect().left), + }; + }), + ); + for (const r of rows) expect(r.fromLabelText, `${r.label}: the value does not start under its label`).toBeLessThan(1); +}); + +test('side by side in a right-to-left page, each value keeps to the left', async ({ page }) => { + // The row's end, mirrored: the value is placed by start and end, not by + // left and right + await page.setViewportSize({ width: 1024, height: 900 }); + await page.evaluate(() => document.documentElement.setAttribute('dir', 'rtl')); + await pick(page, 'Registry offline'); + for (const r of await valueEdges(page)) { + expect(r.below, `${r.label}: the value went under its label`).toBe(false); + expect(r.fromRowLeft, `${r.label}: the value does not end at the left`).toBeLessThan(1); + } +}); + +for (const width of [1024, 480]) { + test(`words that cannot break never push the mark over the label (${width}px)`, async ({ page }) => { + // A host page that sets white-space: nowrap, which the card inherits, + // stands in for any words that cannot break, as in a browser without + // overflow-wrap: anywhere. They may run past the row's end, as before + // this layout; they must not run back over the label (or, further, off + // the card's start). + await page.setViewportSize({ width, height: 900 }); + await pick(page, 'Issuer unknown'); + const layout = await page.evaluate(() => { + const host = document.getElementById('vc')!; + host.style.whiteSpace = 'nowrap'; + const root = host.shadowRoot!; + const row = [...root.querySelectorAll('.check')].find((r) => r.querySelector('.k')!.textContent === 'Issuer')!; + row.querySelector('.mark + span')!.lastChild!.textContent = + 'did:key:z6MknNQD1WHLGGraFi6zcbGevuAgkVfdyCdtZnQTGWVVvR5Q — not on our list of known issuers'; + const label = row.querySelector('.k')!.getBoundingClientRect(); + const mark = row.querySelector('.mark')!.getBoundingClientRect(); + return { overLabel: mark.left < label.right }; + }); + expect(layout.overLabel, 'the mark is over the label').toBe(false); + }); +} + /** * Found in review. The spoken headline was HTML-escaped on its way into a * `textContent` assignment, so a screen reader on the unconfirmed-issuer card @@ -743,6 +880,25 @@ test.describe('lifecycle, from review', () => { expect(spoken).not.toContain('Verified:'); }); + test('each detail row is read out as its label, its status word and its value, without the mark', async ({ page }) => { + // The mark is laid out beside the words, not among them; how it is laid + // out must not run the words together or have the mark read out. + for (const situation of ['Issuer unknown', 'Not their seal']) { + await pick(page, situation); + const rows = page.locator('#vc .check'); + for (let i = 0; i < (await rows.count()); i++) { + const row = rows.nth(i); + const snapshot = (await row.ariaSnapshot()).replace(/^- text: /, ''); + const spoken = snapshot.startsWith('"') ? (JSON.parse(snapshot) as string) : snapshot; + const label = await row.locator('.k').textContent(); + const words = (await row.locator('.mark + span').textContent())!.replace(/\s+/g, ' ').trim(); + expect(words, `${situation}, ${label}: no status word`).toMatch(/^[A-Z][^:]*: \S/); + expect(spoken, `${situation}, ${label}`).toBe(`${label} ${words}`); + await expect(row.locator('.mark')).toHaveAttribute('aria-hidden', 'true'); + } + } + }); + test('the verdict is written into a live region that was already on the page', async ({ page }) => { // An innerHTML rebuild that creates the region already populated may never // be announced. It has to exist first and be written to afterwards.