diff --git a/CHANGELOG.md b/CHANGELOG.md
index d112b01..fb34731 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,27 @@
# authn.io ChangeLog
+## 7.6.1 - 2026-07-dd
+
+### Fixed
+- Stop clipping the first party "Allow Wallet" permission dialog. The
+ popup height was a fixed guess (230px, plus a 30px URL bar allowance
+ applied only when the user agent looked like Firefox), so the wallet
+ row was cut off mid-line whenever the browser's popup chrome or the
+ greeting's wrapping exceeded that guess. The dialog fills the popup and
+ scrolls internally, so this surfaced as clipped content rather than a
+ scrollable window. The allowance now applies to every browser (Chrome
+ and Safari also show a URL bar in popups, and user agent sniffing
+ cannot predict the real chrome height), and the popup measures its own
+ content once loaded and resizes to fit, replacing both guesses with a
+ measurement.
+
+### Added
+- Non-API change: Add unit tests for the popup self-sizing logic and a
+ geometric-invariant test suite for the "Allow Wallet" dialog.
+- Non-API change: Let the dev-only wallet chooser test harness render an
+ origin manifest (name and icon), so it reproduces the taller greeting
+ the real dialog shows for a site with a web app manifest.
+
## 7.6.0 - 2026-07-23
### Changed
diff --git a/package.json b/package.json
index 030b423..60845b2 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"start": "node authn.localhost.js",
"lint": "eslint",
"test:e2e": "playwright test wallet-chooser",
+ "test:unit": "node --test \"test/unit/*.spec.js\"",
"gallery": "playwright test gallery && node test/e2e/build-gallery-index.js"
},
"repository": {
diff --git a/test/e2e/allow-wallet.spec.js b/test/e2e/allow-wallet.spec.js
new file mode 100644
index 0000000..034b591
--- /dev/null
+++ b/test/e2e/allow-wallet.spec.js
@@ -0,0 +1,136 @@
+/*!
+ * New BSD License (3-clause)
+ * Copyright (c) 2026, Digital Bazaar, Inc.
+ */
+import {expect, test} from '@playwright/test';
+
+/* Geometric-invariant tests for the first party "Allow Wallet" permission
+dialog, exercised via the dev-only `/test/wallet-chooser` harness route with
+`type=permissionRequest`. The bug these guard: the popup height was a fixed
+guess (230px + a Firefox-only 30px URL-bar allowance), so on any browser whose
+popup chrome or content wrapping exceeded that guess, the wallet row and the
+Block/Allow footer were clipped off the bottom of the window.
+
+These assert the dialog fits — and that its interactive footer is reachable —
+at the popup viewport heights the mediator actually requests, rather than
+diffing pixels. */
+
+/* Popup viewport heights to exercise. `DEFAULT_ALLOW_WALLET_POPUP_HEIGHT` is
+290 (260 + a 30px chrome allowance), so the viewport the mediator actually
+gets is 290 minus whatever chrome the browser adds. These bracket that range
+down to the point where the content genuinely cannot fit and the popup relies
+on `fitWindowToContent` growing it instead.
+
+Playwright pins the viewport, so `resizeBy` is inert here by design: these
+tests assert the CSS/content invariant at a given height. The resize logic
+itself is unit-tested via `getWindowHeightAdjustment` (see
+`test/unit/helpers.spec.js`). */
+const HEIGHTS = [
+ {name: 'requested height', height: 290},
+ {name: 'chrome-reduced height', height: 260}
+];
+
+// A long origin/name exercises the text-wrapping cause of the clipping,
+// independent of the popup-chrome cause.
+const LONG_ORIGIN =
+ 'https://wallet-with-a-very-long-hostname.example-organization.test';
+
+async function gotoAllowWallet(page, {origin} = {}) {
+ const query = new URLSearchParams({
+ hints: '1',
+ type: 'permissionRequest',
+ qr: '0'
+ });
+ if(origin) {
+ query.set('origin', origin);
+ }
+ await page.goto(`/test/wallet-chooser?${query}`);
+ await page.locator('.wrm-modal-1p .wrm-modal-content').waitFor();
+}
+
+for(const {name, height} of HEIGHTS) {
+ test.describe(`allow-wallet dialog @ ${name} (${height}px)`, () => {
+ test.use({viewport: {width: 500, height}});
+
+ test.beforeEach(async ({page}) => gotoAllowWallet(page));
+
+ test('shows the Block and Allow buttons', async ({page}) => {
+ await expect(page.getByRole('button', {name: 'Block'})).toBeVisible();
+ await expect(page.getByRole('button', {name: 'Allow'})).toBeVisible();
+ });
+
+ // The footer is pinned outside the scrolling body, so it stays in the
+ // viewport at every height; this guards that pinning (a regression that
+ // let it scroll away would be caught here), not the clipping bug.
+ test('keeps the Block/Allow footer within the viewport',
+ async ({page}) => {
+ for(const label of ['Block', 'Allow']) {
+ const box = await page.getByRole('button', {name: label})
+ .boundingBox();
+ expect(box, `${label} button has no box`).not.toBeNull();
+ expect(box.y + box.height,
+ `${label} button bottom (${box.y + box.height}) exceeds ` +
+ `viewport height (${height})`).toBeLessThanOrEqual(height + 1);
+ }
+ });
+
+ /* The real clipping condition. The 1p panel is sized to exactly fill the
+ popup and its body region scrolls internally, so the *document* never
+ overflows and the pinned footer is always inside the viewport — neither a
+ document-level scroll check nor a footer-position check can detect this
+ bug. What the user sees instead is the greeting's wallet row cut off
+ mid-line inside that scrolling body. Assert the body does not need to
+ scroll: the popup must be tall enough for the greeting to fit whole. */
+ test('does not clip the greeting inside the scrolling body',
+ async ({page}) => {
+ const overflow = await page.evaluate(() => {
+ const body =
+ document.querySelector('.wrm-modal-content-header + div');
+ return body ? body.scrollHeight - body.clientHeight : null;
+ });
+ expect(overflow, 'dialog body region not found').not.toBeNull();
+ expect(overflow,
+ `dialog body overflows its box by ${overflow}px, clipping the ` +
+ 'wallet row').toBeLessThanOrEqual(1);
+ });
+
+ // the greeting's origin card must be fully within the scrolling body's
+ // visible box, not cut off at its bottom edge
+ test('shows the whole origin card', async ({page}) => {
+ const cut = await page.evaluate(() => {
+ const body = document.querySelector('.wrm-modal-content-header + div');
+ const card = [...document.querySelectorAll('.wrm-flex-row')]
+ .find(el => !el.classList.contains('wrm-modal-content-header'));
+ if(!body || !card) {
+ return null;
+ }
+ return Math.round(card.getBoundingClientRect().bottom -
+ body.getBoundingClientRect().bottom);
+ });
+ expect(cut, 'origin card or body not found').not.toBeNull();
+ expect(cut, `origin card is cut off by ${cut}px`)
+ .toBeLessThanOrEqual(1);
+ });
+
+ test('keeps the origin row on one line', async ({page}) => {
+ // the origin/name must ellipsize rather than wrap; wrapping is what
+ // pushed the footer out of the popup
+ const wraps = await page.evaluate(() => {
+ const el = document.querySelector('.wrm-ellipsis');
+ if(!el) {
+ return 'missing';
+ }
+ return el.scrollHeight > el.clientHeight + 1;
+ });
+ expect(wraps).toBe(false);
+ });
+
+ test('with a long origin, still fits', async ({page}) => {
+ await gotoAllowWallet(page, {origin: LONG_ORIGIN});
+ const box = await page.getByRole('button', {name: 'Allow'})
+ .boundingBox();
+ expect(box).not.toBeNull();
+ expect(box.y + box.height).toBeLessThanOrEqual(height + 1);
+ });
+ });
+}
diff --git a/test/unit/helpers.spec.js b/test/unit/helpers.spec.js
new file mode 100644
index 0000000..75f9098
--- /dev/null
+++ b/test/unit/helpers.spec.js
@@ -0,0 +1,130 @@
+/*!
+ * New BSD License (3-clause)
+ * Copyright (c) 2026, Digital Bazaar, Inc.
+ */
+import assert from 'node:assert/strict';
+import {getWindowHeightAdjustment} from '../../web/mediator/helpers.js';
+import {test} from 'node:test';
+
+/* Unit tests for the pure core of the popup self-sizing fix. The mediator
+opens the allow-wallet popup at a fixed height, but the browser subtracts its
+own chrome by an unpredictable amount and the greeting reflows with the
+wallet's name and origin, so the popup measures itself once loaded and
+corrects. `getWindowHeightAdjustment` is that decision, isolated from the DOM:
+given what the content needs and what the window has, how much should the
+window grow or shrink?
+
+Run with: node --test test/unit/ */
+
+// a typical popup: 290px requested, ~60px of browser chrome
+const TYPICAL = {
+ viewportHeight: 230,
+ outerHeight: 290,
+ availableHeight: 1000
+};
+
+test('grows the window when content overflows', () => {
+ // the shipped bug: 263px of content in a 230px viewport
+ const adjustment = getWindowHeightAdjustment({
+ ...TYPICAL, contentHeight: 263
+ });
+ assert.equal(adjustment, 33);
+});
+
+test('shrinks the window when it is taller than the content', () => {
+ const adjustment = getWindowHeightAdjustment({
+ ...TYPICAL, contentHeight: 180
+ });
+ assert.equal(adjustment, -50);
+});
+
+test('does nothing when the content already fits exactly', () => {
+ const adjustment = getWindowHeightAdjustment({
+ ...TYPICAL, contentHeight: 230
+ });
+ assert.equal(adjustment, 0);
+});
+
+test('ignores sub-pixel differences', () => {
+ // fractional layout metrics must not trigger a pointless resize
+ for(const contentHeight of [230.4, 229.6, 231, 229]) {
+ assert.equal(getWindowHeightAdjustment({...TYPICAL, contentHeight}), 0,
+ `contentHeight ${contentHeight} should not resize`);
+ }
+});
+
+test('never grows the viewport beyond the available screen height', () => {
+ /* 60px of chrome on a 700px screen leaves a 640px viewport, so a window
+ with a 230px viewport can grow by at most 410 -- but the per-call cap of
+ 400 binds first. */
+ const adjustment = getWindowHeightAdjustment({
+ viewportHeight: 230,
+ outerHeight: 290,
+ availableHeight: 700,
+ contentHeight: 5000
+ });
+ assert.equal(adjustment, 400);
+});
+
+test('respects the screen limit when it is tighter than the cap', () => {
+ // 40px chrome on a 400px screen => max viewport 360, so at most +130
+ const adjustment = getWindowHeightAdjustment({
+ viewportHeight: 230,
+ outerHeight: 270,
+ availableHeight: 400,
+ contentHeight: 5000
+ });
+ assert.equal(adjustment, 130);
+});
+
+test('never shrinks below the minimum usable height', () => {
+ // content claims almost nothing; the window must stay >= 160px
+ const adjustment = getWindowHeightAdjustment({
+ ...TYPICAL, contentHeight: 10
+ });
+ assert.equal(adjustment, -70);
+ assert.equal(TYPICAL.viewportHeight + adjustment, 160);
+});
+
+test('bounds a single correction to the maximum adjustment', () => {
+ const adjustment = getWindowHeightAdjustment({
+ viewportHeight: 800,
+ outerHeight: 860,
+ availableHeight: 4000,
+ contentHeight: 8000
+ });
+ assert.equal(adjustment, 400);
+});
+
+test('returns 0 when measurements are missing or unusable', () => {
+ const cases = [
+ undefined,
+ {},
+ {contentHeight: 300, viewportHeight: 0, outerHeight: 290},
+ {contentHeight: 0, viewportHeight: 230, outerHeight: 290},
+ {contentHeight: 300, viewportHeight: 230, outerHeight: 0}
+ ];
+ for(const input of cases) {
+ assert.equal(getWindowHeightAdjustment(input), 0,
+ `expected 0 for ${JSON.stringify(input)}`);
+ }
+});
+
+test('tolerates a missing screen height', () => {
+ // `availableHeight` unknown: still grows, bounded by the per-call cap
+ const adjustment = getWindowHeightAdjustment({
+ contentHeight: 263, viewportHeight: 230, outerHeight: 290
+ });
+ assert.equal(adjustment, 33);
+});
+
+test('handles a window reporting no chrome', () => {
+ // `outerHeight === innerHeight` (some embedded/headless contexts)
+ const adjustment = getWindowHeightAdjustment({
+ contentHeight: 300,
+ viewportHeight: 230,
+ outerHeight: 230,
+ availableHeight: 1000
+ });
+ assert.equal(adjustment, 70);
+});
diff --git a/web/components/FirstPartyMediatorWizard.vue b/web/components/FirstPartyMediatorWizard.vue
index cd9220f..3f0b959 100644
--- a/web/components/FirstPartyMediatorWizard.vue
+++ b/web/components/FirstPartyMediatorWizard.vue
@@ -28,10 +28,39 @@
* New BSD License (3-clause)
* Copyright (c) 2017-2026, Digital Bazaar, Inc.
*/
-import {computed, onMounted, ref, toRaw} from 'vue';
+import {computed, nextTick, onMounted, ref, toRaw} from 'vue';
import {FirstPartyMediator} from '../mediator/FirstPartyMediator.js';
+import {fitWindowToContent} from '../mediator/helpers.js';
import MediatorWizard from './MediatorWizard.vue';
+// give up waiting on a slow wallet icon rather than delay the resize; a late
+// image just means the popup is corrected without its height
+const IMAGE_LOAD_TIMEOUT = 1000;
+
+/* Resolves once every in the document has settled (loaded or errored),
+or the timeout elapses. An icon that has not loaded reports no height, so
+measuring before it settles under-reports the content. */
+async function _waitForImages() {
+ const images = [...document.querySelectorAll('img')]
+ .filter(img => !img.complete);
+ if(images.length === 0) {
+ return;
+ }
+ const settled = images.map(img => new Promise(resolve => {
+ img.addEventListener('load', resolve, {once: true});
+ img.addEventListener('error', resolve, {once: true});
+ }));
+ let timer;
+ const timeout = new Promise(resolve => {
+ timer = setTimeout(resolve, IMAGE_LOAD_TIMEOUT);
+ });
+ try {
+ await Promise.race([Promise.all(settled), timeout]);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
export default {
name: 'FirstPartyMediatorWizard',
components: {MediatorWizard},
@@ -118,6 +147,17 @@ export default {
await mediator.credentialRequestOriginManifestPromise;
credentialRequestOrigin.value = mediator.credentialRequestOrigin;
loading.value = false;
+
+ /* Correct the popup height now that the real content is known.
+ The height requested when opening the popup is a guess: the
+ browser subtracts its own chrome by an unpredictable amount and
+ the greeting reflows with the wallet's name and origin. Measuring
+ here replaces both guesses. Waits for the DOM update and for the
+ wallet icon to load, since an image that has not loaded yet does
+ not contribute its height. */
+ await nextTick();
+ await _waitForImages();
+ fitWindowToContent();
}
});
} catch(e) {
diff --git a/web/components/MediatorGreeting.vue b/web/components/MediatorGreeting.vue
index 3782582..d69c7e2 100644
--- a/web/components/MediatorGreeting.vue
+++ b/web/components/MediatorGreeting.vue
@@ -5,7 +5,7 @@