From 97abf4a521dc46331905158898e952af36f7ee96 Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Sat, 13 Jun 2026 09:37:16 -0500 Subject: [PATCH 1/7] Add dev-only test harness route for the wallet chooser. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `/test/wallet-chooser`, registered only outside production builds, that mounts the presentational `MediatorWizard` with fake state driven by URL query params (`hints`, `qr`, `type`). This exercises the first party dialog's layout and CSS across wallet counts, the cross-device QR section, and request types without any CHAPI registration, iframes, or popups — the slow, flaky parts of testing this dialog by hand. It backs the automated visual/layout suite added in following commits. Co-Authored-By: Claude Fable 5 --- web/router.js | 11 +++- web/routes/TestWalletChooser.vue | 103 +++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 web/routes/TestWalletChooser.vue diff --git a/web/router.js b/web/router.js index 11a3deab..2ed0ed58 100644 --- a/web/router.js +++ b/web/router.js @@ -30,6 +30,15 @@ export async function createRouter() { /* webpackChunkName: "FirstPartyMediatorPage" */ './routes/FirstPartyMediatorPage.vue'), meta: {title: 'Allow Wallet'} - }] + }, + // dev-only harness for exercising the wallet chooser dialog layout + // without CHAPI; excluded from production builds + ...(process.env.NODE_ENV !== 'production' ? [{ + path: '/test/wallet-chooser', + component: () => import( + /* webpackChunkName: "TestWalletChooser" */ + './routes/TestWalletChooser.vue'), + meta: {title: 'Test Wallet Chooser'} + }] : [])] }); } diff --git a/web/routes/TestWalletChooser.vue b/web/routes/TestWalletChooser.vue new file mode 100644 index 00000000..46106dcf --- /dev/null +++ b/web/routes/TestWalletChooser.vue @@ -0,0 +1,103 @@ + + + + + From 826bd2c2578a3807674a433123f0b1245439dd54 Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Sat, 13 Jun 2026 10:05:53 -0500 Subject: [PATCH 2/7] Add Playwright geometric-invariant tests for the 1p dialog. Add a Playwright suite that drives the `/test/wallet-chooser` harness across wallet counts and the cross-device QR section on Chromium, WebKit, and Firefox, asserting structural invariants rather than pixels: the expected hints render, the popup window does not scroll, no horizontal scrollbar appears, the header stays pinned while the list scrolls, the panel fills the popup width, and the QR section/expander behave per wallet count. `npm run test:e2e` starts the dev server automatically and runs all three engines. A stricter "no horizontal overflow" assertion is included but skipped: it detects the overflow condition the 7.4.1 `overflow-x: hidden` only clips, and is meant to be enabled alongside the root-cause fix. Co-Authored-By: Claude Fable 5 --- .gitignore | 6 ++ package-lock.json | 64 ++++++++++++++++ package.json | 4 +- playwright.config.js | 47 ++++++++++++ test/e2e/wallet-chooser.spec.js | 129 ++++++++++++++++++++++++++++++++ 5 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 playwright.config.js create mode 100644 test/e2e/wallet-chooser.spec.js diff --git a/.gitignore b/.gitignore index 37876f84..b8941325 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,9 @@ authn.localhost-access.log authn.localhost-access1.log bedrock static/images/logo-*.png + +# Playwright +/test-results +/playwright-report +/blob-report +/test/e2e/gallery diff --git a/package-lock.json b/package-lock.json index e38e10b9..19e04bc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@digitalbazaar/eslint-config": "^8.0.1", + "@playwright/test": "^1.60.0", "eslint": "^9.39.4" }, "engines": { @@ -2624,6 +2625,22 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sindresorhus/base62": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz", @@ -5195,6 +5212,21 @@ "node": ">= 0.6" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6763,6 +6795,38 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/package.json b/package.json index 9daf9986..0c4a9dac 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ }, "scripts": { "start": "node authn.localhost.js", - "lint": "eslint" + "lint": "eslint", + "test:e2e": "playwright test wallet-chooser" }, "repository": { "type": "git", @@ -37,6 +38,7 @@ }, "devDependencies": { "@digitalbazaar/eslint-config": "^8.0.1", + "@playwright/test": "^1.60.0", "eslint": "^9.39.4" }, "engines": { diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 00000000..d8c2334e --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,47 @@ +/*! + * New BSD License (3-clause) + * Copyright (c) 2026, Digital Bazaar, Inc. + * All rights reserved. + */ +import {defineConfig, devices} from '@playwright/test'; + +const BASE_URL = 'https://authn.localhost:33443'; + +export default defineConfig({ + testDir: './test/e2e', + // the dialog suite is layout-only and deterministic; fail fast in CI + forbidOnly: !!process.env.CI, + retries: 0, + reporter: 'list', + use: { + baseURL: BASE_URL, + // the dev server uses a self-signed localhost certificate + ignoreHTTPSErrors: true, + screenshot: 'off', + trace: 'on-first-retry' + }, + // the wallet chooser popups are 500px wide; emulate that as the default + // viewport so layout matches the real popup + projects: [ + { + name: 'chromium', + use: {...devices['Desktop Chrome'], viewport: {width: 500, height: 640}} + }, + { + name: 'webkit', + use: {...devices['Desktop Safari'], viewport: {width: 500, height: 640}} + }, + { + name: 'firefox', + use: {...devices['Desktop Firefox'], viewport: {width: 500, height: 640}} + } + ], + // start the authn.io dev server automatically; reuse one already running + webServer: { + command: 'node authn.localhost.js', + url: `${BASE_URL}/test/wallet-chooser?hints=1`, + ignoreHTTPSErrors: true, + reuseExistingServer: true, + timeout: 120 * 1000 + } +}); diff --git a/test/e2e/wallet-chooser.spec.js b/test/e2e/wallet-chooser.spec.js new file mode 100644 index 00000000..ff197486 --- /dev/null +++ b/test/e2e/wallet-chooser.spec.js @@ -0,0 +1,129 @@ +/*! + * New BSD License (3-clause) + * Copyright (c) 2026, Digital Bazaar, Inc. + * All rights reserved. + */ +import {expect, test} from '@playwright/test'; + +/* Geometric-invariant tests for the first party wallet chooser dialog, +exercised via the dev-only `/test/wallet-chooser` harness route. These assert +structural properties (no horizontal scroll, header pinned, panel fills the +popup) rather than pixels, so they catch the layout regressions seen in the +7.4.x series without screenshot-diff noise. */ + +// the states the dialog must handle, keyed for readable test titles +const STATES = [ + {name: '0 wallets + QR', query: 'hints=0&qr=1', hints: 0, qr: true}, + {name: '1 wallet + QR', query: 'hints=1&qr=1', hints: 1, qr: true}, + {name: '5 wallets + QR', query: 'hints=5&qr=1', hints: 5, qr: true}, + {name: '5 wallets, no QR', query: 'hints=5&qr=0', hints: 5, qr: false}, + {name: '15 wallets + QR', query: 'hints=15&qr=1', hints: 15, qr: true} +]; + +async function gotoChooser(page, query) { + await page.goto(`/test/wallet-chooser?${query}`); + // wait for the dialog content to render + await page.locator('.wrm-modal-1p .wrm-modal-content').waitFor(); +} + +for(const state of STATES) { + test.describe(state.name, () => { + test.beforeEach(async ({page}) => gotoChooser(page, state.query)); + + test('renders the expected number of wallet hints', async ({page}) => { + await expect(page.locator('.wrm-hint-list .wrm-selectable')) + .toHaveCount(state.hints); + }); + + test('shows no horizontal scrollbar', async ({page}) => { + // the 7.4.1 regression produced a horizontal scrollbar on the 1p + // content. Assert the document does not scroll horizontally. + const scrolls = await page.evaluate(() => + document.documentElement.scrollWidth > window.innerWidth); + expect(scrolls).toBe(false); + }); + + // NOTE: the stricter assertion below detects horizontal OVERFLOW + // itself (content wider than its box), not just the scrollbar. On + // current `main` it FAILS because 7.4.1 only clips the overflow with + // `overflow-x: hidden` rather than fixing the root cause (the header's + // `margin: -15px` overhang). It is skipped here and should be enabled + // together with the root-cause fix on `fix-1p-flex-sizing-rootcause`. + test.skip('has no horizontal overflow (enable with root-cause fix)', + async ({page}) => { + const offenders = await page.evaluate(() => { + const out = []; + for(const el of document.querySelectorAll('.wrm-modal-1p *')) { + if(el.scrollWidth - el.clientWidth > 1) { + out.push(`${el.className || el.tagName} ` + + `(scrollW ${el.scrollWidth} > clientW ${el.clientWidth})`); + } + } + return out; + }); + expect(offenders, offenders.join('; ')).toEqual([]); + }); + + test('does not scroll the popup window', async ({page}) => { + // only an inner region may scroll; the window itself must not (that + // scrolled the header/Close button away and stacked scrollbars) + const windowScrolls = await page.evaluate(() => + document.documentElement.scrollHeight > window.innerHeight + 1); + expect(windowScrolls).toBe(false); + }); + + test('keeps the header pinned while the list scrolls', async ({page}) => { + const header = page.locator('.wrm-modal-1p .wrm-modal-content-header') + .first(); + const before = await header.boundingBox(); + // scroll the wallet list to its end, if it scrolls at all + await page.evaluate(() => { + const list = document.querySelector('.wrm-hint-list'); + if(list) { + list.scrollTop = list.scrollHeight; + } + }); + const after = await header.boundingBox(); + expect(after.y).toBeCloseTo(before.y, 0); + }); + + test('panel fills the popup width (no background bleed)', + async ({page}) => { + // the content panel must span the full popup width; gaps showed the + // page background as dark bands + const fills = await page.evaluate(() => { + const panel = document.querySelector( + '.wrm-modal-1p .wrm-modal-content'); + return Math.abs(panel.getBoundingClientRect().width - + window.innerWidth) <= 1; + }); + expect(fills).toBe(true); + }); + + if(state.qr && state.hints > 0) { + test('shows the cross-device expander, collapsed', async ({page}) => { + await expect(page.locator('.cross-device-toggle')).toBeVisible(); + await expect(page.locator('img[alt*="QR"]')).toHaveCount(0); + }); + + test('expands the QR code when the prompt is clicked', async ({page}) => { + await page.locator('.cross-device-toggle').click(); + await expect(page.locator('img[alt*="QR"]')).toBeVisible(); + }); + } + + if(state.qr && state.hints === 0) { + test('shows the QR code immediately, no expander', async ({page}) => { + await expect(page.locator('.cross-device-toggle')).toHaveCount(0); + await expect(page.locator('img[alt*="QR"]')).toBeVisible(); + }); + } + + if(!state.qr) { + test('shows no cross-device section', async ({page}) => { + await expect(page.locator('.cross-device-toggle')).toHaveCount(0); + await expect(page.locator('img[alt*="QR"]')).toHaveCount(0); + }); + } + }); +} From 3ed6b33550d825241030f377d6c147bf3af6cd4e Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Sat, 13 Jun 2026 10:09:46 -0500 Subject: [PATCH 3/7] Add wallet chooser screenshot gallery and document testing. Add `npm run gallery`, which screenshots the first party wallet chooser across wallet counts, themes, and engines (collapsed and expanded) via the harness route, then builds an `index.html` contact sheet for quick visual review. Output lands in the git-ignored `test/e2e/gallery/`. Document both the regression suite and the gallery in the README, including the Brave/mobile coverage caveats. Also git-ignore the local `scratchpad/` working directory. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 ++ README.md | 34 ++++++++++++++++ package.json | 3 +- test/e2e/build-gallery-index.js | 71 +++++++++++++++++++++++++++++++++ test/e2e/gallery.spec.js | 59 +++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 test/e2e/build-gallery-index.js create mode 100644 test/e2e/gallery.spec.js diff --git a/.gitignore b/.gitignore index b8941325..983fb768 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ static/images/logo-*.png /playwright-report /blob-report /test/e2e/gallery + +# local working notes / manual-test artifacts +scratchpad diff --git a/README.md b/README.md index 30f8fcc4..e5b63666 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,40 @@ Access the server at the following URL: * https://authn.localhost:33443/ +## Testing + +The first party wallet chooser dialog has an automated visual/layout test +suite. It drives a dev-only harness route +(`/test/wallet-chooser?hints=N&qr=1`) that renders the dialog with fake state, +so the layout can be exercised across wallet counts (0, 1, 5, 15) and the +cross-device QR section without any CHAPI registration, wallets, or popups. The +harness route is excluded from production builds. + +Install the browser engines once: + + npx playwright install chromium webkit firefox + +Run the layout regression suite (Chromium, WebKit, Firefox): + + npm run test:e2e + +This asserts structural invariants — the expected wallets render, no horizontal +or window scrollbar appears, the header stays pinned while the list scrolls, and +the panel fills the popup — rather than comparing pixels. It starts the dev +server automatically. + +Generate a browsable screenshot gallery of every state (wallet count × theme × +engine, collapsed and expanded), with an `index.html` contact sheet: + + npm run gallery + +Output is written to `test/e2e/gallery/` (git-ignored). + +> **Brave:** the Chromium engine covers Brave's rendering (Brave is Chromium +> plus "shields", which do not affect the dialog CSS). Brave's storage/shields +> behavior is a CHAPI plumbing concern, verified in the manual mobile pass, not +> by this layout suite. Real mobile/Safari-on-iOS is likewise a manual pass. + ## Production Full instructions for running this code in production are beyond the scope of diff --git a/package.json b/package.json index 0c4a9dac..1a025b9d 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "scripts": { "start": "node authn.localhost.js", "lint": "eslint", - "test:e2e": "playwright test wallet-chooser" + "test:e2e": "playwright test wallet-chooser", + "gallery": "playwright test gallery && node test/e2e/build-gallery-index.js" }, "repository": { "type": "git", diff --git a/test/e2e/build-gallery-index.js b/test/e2e/build-gallery-index.js new file mode 100644 index 00000000..3262d947 --- /dev/null +++ b/test/e2e/build-gallery-index.js @@ -0,0 +1,71 @@ +/*! + * New BSD License (3-clause) + * Copyright (c) 2026, Digital Bazaar, Inc. + * All rights reserved. + */ +import {fileURLToPath} from 'node:url'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/* Builds an `index.html` contact sheet from the PNGs the gallery spec wrote +to `test/e2e/gallery///`. Run after `playwright test gallery` +(the `gallery` npm script chains them). Filesystem-driven so it sees every +engine's output regardless of which Playwright worker produced it. */ + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const GALLERY_DIR = path.join(__dirname, 'gallery'); + +async function findPngs(dir, base = dir) { + const out = []; + let entries; + try { + entries = await fs.readdir(dir, {withFileTypes: true}); + } catch { + return out; + } + for(const entry of entries) { + const full = path.join(dir, entry.name); + if(entry.isDirectory()) { + out.push(...await findPngs(full, base)); + } else if(entry.name.endsWith('.png')) { + out.push(path.relative(base, full)); + } + } + return out; +} + +const pngs = (await findPngs(GALLERY_DIR)).sort((a, b) => a.localeCompare(b)); +if(pngs.length === 0) { + console.error('No gallery PNGs found. Run `playwright test gallery` first.'); + process.exit(1); +} + +const cards = pngs.map(rel => { + // rel = //.png + const [engine, theme, file] = rel.split(path.sep); + const label = file.replace(/\.png$/, '').replace(/-/g, ' '); + return ` +
+ ${label} +
${engine} / ${theme} — ${label}
+
`; +}).join(''); + +const html = ` + +authn.io wallet chooser gallery + +

authn.io wallet chooser — ${pngs.length} shots

+
${cards}
`; + +const indexPath = path.join(GALLERY_DIR, 'index.html'); +await fs.writeFile(indexPath, html); +console.log(`Gallery contact sheet: ${indexPath}`); diff --git a/test/e2e/gallery.spec.js b/test/e2e/gallery.spec.js new file mode 100644 index 00000000..12e9a295 --- /dev/null +++ b/test/e2e/gallery.spec.js @@ -0,0 +1,59 @@ +/*! + * New BSD License (3-clause) + * Copyright (c) 2026, Digital Bazaar, Inc. + * All rights reserved. + */ +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'node:url'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/* Generates a labelled, human-viewable gallery of the first party wallet +chooser dialog across wallet counts, the cross-device QR section, themes, and +viewports — the screenshots we otherwise capture by hand for review. Output +goes to `test/e2e/gallery///.png` plus an `index.html` +contact sheet. This is NOT a regression gate (see `wallet-chooser.spec.js` for +that); run it with `npm run gallery`. */ + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const GALLERY_DIR = path.join(__dirname, 'gallery'); + +const STATES = [ + {name: 'hints-0-qr', query: 'hints=0&qr=1', label: '0 wallets + QR'}, + {name: 'hints-1-qr', query: 'hints=1&qr=1', label: '1 wallet + QR'}, + {name: 'hints-5-qr', query: 'hints=5&qr=1', label: '5 wallets + QR'}, + {name: 'hints-5-noqr', query: 'hints=5&qr=0', label: '5 wallets, no QR'}, + {name: 'hints-15-qr', query: 'hints=15&qr=1', label: '15 wallets + QR'} +]; +const THEMES = ['light', 'dark']; + +test.describe('gallery', () => { + for(const theme of THEMES) { + test.describe(theme, () => { + test.use({colorScheme: theme}); + + for(const state of STATES) { + test(`${state.label}`, async ({page}, testInfo) => { + const engine = testInfo.project.name; + await page.goto(`/test/wallet-chooser?${state.query}`); + await page.locator('.wrm-modal-1p .wrm-modal-content').waitFor(); + + const dir = path.join(GALLERY_DIR, engine, theme); + await fs.mkdir(dir, {recursive: true}); + + // collapsed (default) shot + await page.screenshot({path: path.join(dir, `${state.name}.png`)}); + + // for states with the expander, also capture it expanded + const toggle = page.locator('.cross-device-toggle'); + if(await toggle.count() > 0) { + await toggle.click(); + await expect(page.locator('img[alt*="QR"]')).toBeVisible(); + await page.screenshot( + {path: path.join(dir, `${state.name}-expanded.png`)}); + } + }); + } + }); + } +}); From f27243e2c2687dcf330a39a518d60fe5cae4230d Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Sat, 13 Jun 2026 10:26:23 -0500 Subject: [PATCH 4/7] Fix the root cause of 1p dialog horizontal overflow. The first party dialog headers and separators bleed their border edge-to-edge using a negative side-margin (`margin: -15px`) that cancels the panel's padding. Under the dialog's border-box flex layout that overhang was measured as horizontal overflow, which 7.4.1 only hid with `overflow-x: hidden`. Neutralize the side margins and recreate the full-bleed border with padding instead, so nothing overhangs, then remove the `overflow-x: hidden` workaround. Also target the wallet hint list by its own `.wrm-hint-list` class for the flex sizing rather than the generic `.wrm-flex-column-stretch`, which upstream also applies to a wrapper div around the integrated list; styling that class reached the wrong element. Enable the previously skipped "no horizontal overflow" test, which now passes on all three engines and guards this fix. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 23 +++++++++++++ test/e2e/wallet-chooser.spec.js | 34 +++++++++---------- web/app.less | 59 ++++++++++++++++++++++----------- 3 files changed, 78 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78fc0cc5..f3e00249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # authn.io ChangeLog +## 7.4.4 - 2026-06-dd + +### Fixed +- Fix the root cause of the first party dialog's horizontal overflow + instead of clipping it. The dialog headers and separators use a + negative side-margin (`margin: -15px`) to bleed their border + edge-to-edge; under the dialog's border-box flex layout that overhang + was measured as horizontal overflow. Neutralize the side margins and + recreate the full-bleed border with padding, then remove the + temporary `overflow-x: hidden` workaround. +- Target the wallet hint list by its own class for the 1p flex sizing, + rather than the generic `.wrm-flex-column-stretch` (which upstream + also applies to a wrapper around the integrated list), so the sizing + and scrolling apply only to the intended element. + +### Added +- Add an automated visual/layout test suite for the first party wallet + chooser dialog. A dev-only `/test/wallet-chooser` harness route + renders the dialog with fake state across wallet counts and the + cross-device QR section; `npm run test:e2e` asserts layout invariants + on Chromium, WebKit, and Firefox, and `npm run gallery` produces a + browsable screenshot gallery. + ## 7.4.3 - 2026-06-12 ### Fixed diff --git a/test/e2e/wallet-chooser.spec.js b/test/e2e/wallet-chooser.spec.js index ff197486..7af71676 100644 --- a/test/e2e/wallet-chooser.spec.js +++ b/test/e2e/wallet-chooser.spec.js @@ -43,26 +43,24 @@ for(const state of STATES) { expect(scrolls).toBe(false); }); - // NOTE: the stricter assertion below detects horizontal OVERFLOW - // itself (content wider than its box), not just the scrollbar. On - // current `main` it FAILS because 7.4.1 only clips the overflow with - // `overflow-x: hidden` rather than fixing the root cause (the header's - // `margin: -15px` overhang). It is skipped here and should be enabled - // together with the root-cause fix on `fix-1p-flex-sizing-rootcause`. - test.skip('has no horizontal overflow (enable with root-cause fix)', - async ({page}) => { - const offenders = await page.evaluate(() => { - const out = []; - for(const el of document.querySelectorAll('.wrm-modal-1p *')) { - if(el.scrollWidth - el.clientWidth > 1) { - out.push(`${el.className || el.tagName} ` + - `(scrollW ${el.scrollWidth} > clientW ${el.clientWidth})`); - } + // Stricter than the scrollbar check: detects the horizontal OVERFLOW + // condition itself (an element whose content is wider than its box), + // not just the resulting scrollbar — so an `overflow-x: hidden` + // band-aid cannot make it a false pass. This guards the root-cause + // fix that neutralizes the header's `margin: -15px` overhang. + test('has no horizontal overflow', async ({page}) => { + const offenders = await page.evaluate(() => { + const out = []; + for(const el of document.querySelectorAll('.wrm-modal-1p *')) { + if(el.scrollWidth - el.clientWidth > 1) { + out.push(`${el.className || el.tagName} ` + + `(scrollW ${el.scrollWidth} > clientW ${el.clientWidth})`); } - return out; - }); - expect(offenders, offenders.join('; ')).toEqual([]); + } + return out; }); + expect(offenders, offenders.join('; ')).toEqual([]); + }); test('does not scroll the popup window', async ({page}) => { // only an inner region may scroll; the window itself must not (that diff --git a/web/app.less b/web/app.less index a661c1db..d5126378 100644 --- a/web/app.less +++ b/web/app.less @@ -85,6 +85,35 @@ stacked a second scrollbar on top of the hint list's own) and keeps the QR section visible as an affordance at any popup height */ .wrm-modal.wrm-modal-1p { + /* Upstream gives every `.wrm-modal-content-header` (the real header AND + the empty separator div between the greeting and the chooser) + `margin: -15px -15px 0` so its bottom border bleeds edge-to-edge + across the panel's 15px padding. Under this dialog's border-box flex + layout those negative side margins measure 15px wider than the + wrapper on each side — real horizontal overflow (previously only + hidden with `overflow-x: hidden`). Neutralize the side margins and + recreate the full-bleed separator with negative-margin-free padding: + the header keeps the panel padding (content stays aligned) while its + border still reaches both edges, with nothing overhanging. Applies to + both the direct-child header and the nested separator. */ + .wrm-modal-content-header { + margin-left: 0; + margin-right: 0; + padding-left: 15px; + padding-right: 15px; + } + + /* the cross-device / web-share separators use the same negative + side-margin full-bleed trick (inline `margin: 15px -15px 0`) and + overflow the border-box layout the same way. A separator is just a + 1px top border with no content, so it can fill the full width with + no negative margin: keep the vertical margin, drop the horizontal + overhang. `!important` overrides the element's inline style. */ + .wrm-slide .wrm-separator { + margin-left: 0 !important; + margin-right: 0 !important; + } + /* make the content panel exactly fill the popup — never grow with content and never come up short (the page background would show through as black bands in dark mode); `border-box` + @@ -100,21 +129,20 @@ width: 100%; } - /* the header never scrolls or shrinks */ + /* the header never scrolls or shrinks (side-margin overhang is + neutralized by the `.wrm-modal-content-header` rule above) */ > .wrm-modal-content-header { flex-shrink: 0; } /* the body slot wrapper (the header's sibling) absorbs the - remaining height as a flex column; `overflow-y: auto` is only a - fallback for windows too short to fit even the fixed parts */ + remaining height as a flex column */ > .wrm-modal-content-header + div { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow-y: auto; - overflow-x: hidden; /* fixed (non-scrolling) parts: greeting, separator, etc. */ > * { @@ -139,25 +167,16 @@ flex-shrink: 0; } - /* the wallet list region is the only shrinkable part, down - to ~one wallet row (below that, the body fallback scroll - takes over); the list itself scrolls — never a wrapper - around it, which would nest two scrollbars */ - > .wrm-flex-column-stretch { - flex-shrink: 1; - min-height: 62px; - - &:not(.wrm-hint-list) { - overflow-y: hidden; - } - } - + /* the wallet list is the only shrinkable part and the only + scroller; it grows with available space and shrinks to ~one + wallet row. Target it by its own class — NOT the generic + `.wrm-flex-column-stretch`, which upstream also applies to a + wrapper div around the integrated list, so styling that + class reached the wrong element and nested scrollers. */ .wrm-hint-list { flex: 1 1 auto; - /* always show at least ~one wallet row */ min-height: 62px; - /* grow with available space instead of the fixed 200px - cap used in the 3p dialog */ + /* grow instead of the fixed 200px cap used in the 3p dialog */ max-height: none; } } From b3263d02d6d0cfa838f5f44bf9716d145b0d9af1 Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Sat, 13 Jun 2026 10:34:32 -0500 Subject: [PATCH 5/7] Add phone-size test projects and a scrolled-QR gallery shot. Add emulated iPhone 15 (`iphone`) and Pixel 7 (`android-pixel`) Playwright projects. On a phone the popup is clamped to the screen width and crosses the dialog's 430px "small screen" CSS breakpoint, so these exercise layout branches the 500px desktop projects do not; all invariants hold there too. Also capture an `-expanded-scrolled` gallery shot when the expanded QR code sits below the fold (many wallets on a short/phone viewport), so the gallery shows the QR is reachable by scrolling. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 +++-- README.md | 12 ++++++++++-- playwright.config.js | 13 +++++++++++++ test/e2e/gallery.spec.js | 17 ++++++++++++++++- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e00249..f30bafd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,9 @@ chooser dialog. A dev-only `/test/wallet-chooser` harness route renders the dialog with fake state across wallet counts and the cross-device QR section; `npm run test:e2e` asserts layout invariants - on Chromium, WebKit, and Firefox, and `npm run gallery` produces a - browsable screenshot gallery. + on desktop Chromium, WebKit, and Firefox plus emulated iPhone and + Pixel phone sizes, and `npm run gallery` produces a browsable + screenshot gallery. ## 7.4.3 - 2026-06-12 diff --git a/README.md b/README.md index e5b63666..06f244d0 100644 --- a/README.md +++ b/README.md @@ -96,14 +96,22 @@ Install the browser engines once: npx playwright install chromium webkit firefox -Run the layout regression suite (Chromium, WebKit, Firefox): +Run the layout regression suite: npm run test:e2e This asserts structural invariants — the expected wallets render, no horizontal or window scrollbar appears, the header stays pinned while the list scrolls, and the panel fills the popup — rather than comparing pixels. It starts the dev -server automatically. +server automatically and runs five projects: desktop Chromium, WebKit, and +Firefox at the 500px popup width, plus emulated **iPhone 15** and **Pixel 7**. +The phone projects matter because on a phone the popup is clamped to the screen +width and crosses the dialog's 430px "small screen" CSS breakpoint, exercising +layout branches the desktop width does not. + +Run a single project with, e.g., `npm run test:e2e -- --project=iphone`. The +project names are `chromium`, `webkit`, `firefox`, `iphone`, and +`android-pixel`. Generate a browsable screenshot gallery of every state (wallet count × theme × engine, collapsed and expanded), with an `index.html` contact sheet: diff --git a/playwright.config.js b/playwright.config.js index d8c2334e..3a05242b 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -34,6 +34,19 @@ export default defineConfig({ { name: 'firefox', use: {...devices['Desktop Firefox'], viewport: {width: 500, height: 640}} + }, + // phone-sized projects: on a phone the popup is clamped to the screen + // width (narrower than the 500px desktop popup) and crosses the + // dialog's 430px "small screen" CSS breakpoint, so these exercise + // layout branches the desktop projects do not. Device descriptors also + // set a touch-capable, mobile-UA context. + { + name: 'iphone', + use: {...devices['iPhone 15']} + }, + { + name: 'android-pixel', + use: {...devices['Pixel 7']} } ], // start the authn.io dev server automatically; reuse one already running diff --git a/test/e2e/gallery.spec.js b/test/e2e/gallery.spec.js index 12e9a295..44265761 100644 --- a/test/e2e/gallery.spec.js +++ b/test/e2e/gallery.spec.js @@ -48,9 +48,24 @@ test.describe('gallery', () => { const toggle = page.locator('.cross-device-toggle'); if(await toggle.count() > 0) { await toggle.click(); - await expect(page.locator('img[alt*="QR"]')).toBeVisible(); + const qr = page.locator('img[alt*="QR"]'); + await expect(qr).toBeVisible(); await page.screenshot( {path: path.join(dir, `${state.name}-expanded.png`)}); + + // when the expanded QR sits below the fold (many wallets on a + // short/phone viewport), also capture a shot scrolled to the + // QR, so the gallery shows it is reachable + const qrInView = await qr.evaluate(el => { + const r = el.getBoundingClientRect(); + return r.bottom <= window.innerHeight && r.top >= 0; + }); + if(!qrInView) { + await qr.scrollIntoViewIfNeeded(); + await expect(qr).toBeInViewport(); + await page.screenshot( + {path: path.join(dir, `${state.name}-expanded-scrolled.png`)}); + } } }); } From b5ffb1530dc97412e279d4480434ab2f55cafddf Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Sat, 13 Jun 2026 11:12:41 -0500 Subject: [PATCH 6/7] Fix false-positive overflow check on inline elements. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The horizontal-overflow assertion compared `scrollWidth` to `clientWidth` for every descendant. Inline elements (e.g. , ) report `clientWidth: 0` with a nonzero `scrollWidth`, which Firefox surfaces and Chromium does not — a cross-engine difference that is not overflow. Skip inline elements and any element with a zero content box, so the check only flags block/flex elements whose content genuinely exceeds their width. Verified it still catches a real overhang via the header margin. Co-Authored-By: Claude Fable 5 --- test/e2e/wallet-chooser.spec.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/e2e/wallet-chooser.spec.js b/test/e2e/wallet-chooser.spec.js index 7af71676..15c399a2 100644 --- a/test/e2e/wallet-chooser.spec.js +++ b/test/e2e/wallet-chooser.spec.js @@ -52,6 +52,14 @@ for(const state of STATES) { const offenders = await page.evaluate(() => { const out = []; for(const el of document.querySelectorAll('.wrm-modal-1p *')) { + // only inspect elements with a real content box. Inline + // elements (e.g. , ) report `clientWidth: 0` but + // a nonzero `scrollWidth`, which Firefox surfaces and Chromium + // does not; that difference is not overflow, so skip them. + const cs = getComputedStyle(el); + if(cs.display === 'inline' || el.clientWidth === 0) { + continue; + } if(el.scrollWidth - el.clientWidth > 1) { out.push(`${el.className || el.tagName} ` + `(scrollW ${el.scrollWidth} > clientW ${el.clientWidth})`); From c8359077d62ffa62ccf0272549fc447c3185549c Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Sat, 13 Jun 2026 16:12:21 -0500 Subject: [PATCH 7/7] Restore the full-bleed 1p header border without the overflow hack. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous root-cause attempt removed the dialog header's negative side margins to kill the horizontal overflow, which also removed the header's full-bleed border: its bottom rule stopped at the panel padding and read like an inset separator instead of spanning the popup edge-to-edge. The header itself never overflowed — as a direct child of the padded panel its negative margins cancel the padding exactly. The 15px overflow came from the in-flow body dividers (the `.wrm-separator` rows and the empty `.wrm-modal-content-header` divider), which reuse the same negative side-margin but sit inside a wrapper 15px narrower than the panel on each side, so the margin pushed past its right edge. Restore the header's full-bleed margins and neutralize the side margins on the body dividers only; they read as inset rules with no overhang. This removes the real overflow at its source, so the temporary `overflow-x: hidden` workaround stays gone with the header appearance matching the deployed dialog. Add a layout test asserting the header border spans the full panel width, so a future change cannot silently shrink the bleed again (the geometric-overflow checks could not catch it). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 15 ++++++----- test/e2e/wallet-chooser.spec.js | 22 ++++++++++++++++ web/app.less | 45 +++++++++++++-------------------- 3 files changed, 49 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f30bafd5..957ba9b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,15 @@ ### Fixed - Fix the root cause of the first party dialog's horizontal overflow - instead of clipping it. The dialog headers and separators use a - negative side-margin (`margin: -15px`) to bleed their border - edge-to-edge; under the dialog's border-box flex layout that overhang - was measured as horizontal overflow. Neutralize the side margins and - recreate the full-bleed border with padding, then remove the - temporary `overflow-x: hidden` workaround. + instead of clipping it. The dialog header keeps its full-bleed + edge-to-edge border (a negative side-margin that cancels the panel + padding, with no overflow). The overflow came from the in-flow body + dividers, which reuse the same negative side-margin but sit inside a + wrapper narrower than the panel, so the margin pushed past its edge. + Neutralize the side margins on those body dividers only — they read as + inset rules with no overhang — and remove the temporary + `overflow-x: hidden` workaround. Adds a layout test asserting the + header border bleeds edge-to-edge so the bleed cannot regress silently. - Target the wallet hint list by its own class for the 1p flex sizing, rather than the generic `.wrm-flex-column-stretch` (which upstream also applies to a wrapper around the integrated list), so the sizing diff --git a/test/e2e/wallet-chooser.spec.js b/test/e2e/wallet-chooser.spec.js index 15c399a2..7cfef742 100644 --- a/test/e2e/wallet-chooser.spec.js +++ b/test/e2e/wallet-chooser.spec.js @@ -106,6 +106,28 @@ for(const state of STATES) { expect(fills).toBe(true); }); + test('header border bleeds edge to edge', async ({page}) => { + // the dialog header ("Choose a Wallet") is full-bleed by design: its + // bottom border spans the popup edge to edge, distinguishing it from + // an inset in-page separator. A regression once removed the + // negative side margins that produce the bleed, shrinking the + // border to the padded content width; the geometric-overflow checks + // did not catch it because no overflow remained. This guards the + // bleed directly. The header is the panel's direct-child header (not + // the empty separator div the body reuses the class for). + const flush = await page.evaluate(() => { + const header = document.querySelector( + '.wrm-modal-1p .wrm-modal-content > .wrm-modal-content-header'); + const panel = document.querySelector( + '.wrm-modal-1p .wrm-modal-content'); + const h = header.getBoundingClientRect(); + const p = panel.getBoundingClientRect(); + return Math.abs(h.left - p.left) <= 1 && + Math.abs(h.right - p.right) <= 1; + }); + expect(flush).toBe(true); + }); + if(state.qr && state.hints > 0) { test('shows the cross-device expander, collapsed', async ({page}) => { await expect(page.locator('.cross-device-toggle')).toBeVisible(); diff --git a/web/app.less b/web/app.less index d5126378..a0f37cce 100644 --- a/web/app.less +++ b/web/app.less @@ -85,31 +85,23 @@ stacked a second scrollbar on top of the hint list's own) and keeps the QR section visible as an affordance at any popup height */ .wrm-modal.wrm-modal-1p { - /* Upstream gives every `.wrm-modal-content-header` (the real header AND - the empty separator div between the greeting and the chooser) - `margin: -15px -15px 0` so its bottom border bleeds edge-to-edge - across the panel's 15px padding. Under this dialog's border-box flex - layout those negative side margins measure 15px wider than the - wrapper on each side — real horizontal overflow (previously only - hidden with `overflow-x: hidden`). Neutralize the side margins and - recreate the full-bleed separator with negative-margin-free padding: - the header keeps the panel padding (content stays aligned) while its - border still reaches both edges, with nothing overhanging. Applies to - both the direct-child header and the nested separator. */ - .wrm-modal-content-header { - margin-left: 0; - margin-right: 0; - padding-left: 15px; - padding-right: 15px; - } - - /* the cross-device / web-share separators use the same negative - side-margin full-bleed trick (inline `margin: 15px -15px 0`) and - overflow the border-box layout the same way. A separator is just a - 1px top border with no content, so it can fill the full width with - no negative margin: keep the vertical margin, drop the horizontal - overhang. `!important` overrides the element's inline style. */ - .wrm-slide .wrm-separator { + /* The dialog header (a direct child of the padded `.wrm-modal-content`) + keeps upstream's `margin: -15px` full-bleed trick: its negative side + margins exactly cancel the panel's 15px padding, so its bottom border + spans the popup edge-to-edge with no overflow. The body's in-flow + dividers reuse the same trick — the `.wrm-separator` rows (inline + `margin: 15px -15px 0`) and the empty `.wrm-modal-content-header` div + the greeting uses as a divider — but they live inside the body + wrapper, a content-box flex column 15px narrower than the panel on + each side, so their negative side margins push 15px past the + wrapper's right edge: real horizontal overflow (the 7.4.1 + `overflow-x: hidden` band-aid only hid it). Each is a full-width 1px + rule that already stretches to fill its box, so dropping the side + overhang leaves it ending at the panel padding edge — reading as an + inset divider, with no overflow and no hack. `!important` beats the + element's inline margin. */ + .wrm-modal-content-header + div .wrm-separator, + .wrm-modal-content-header + div .wrm-modal-content-header { margin-left: 0 !important; margin-right: 0 !important; } @@ -129,8 +121,7 @@ width: 100%; } - /* the header never scrolls or shrinks (side-margin overhang is - neutralized by the `.wrm-modal-content-header` rule above) */ + /* the header never scrolls or shrinks */ > .wrm-modal-content-header { flex-shrink: 0; }