diff --git a/TODO.md b/TODO.md
index eddcc3a..4bf1256 100644
--- a/TODO.md
+++ b/TODO.md
@@ -45,6 +45,16 @@ but the review is broader than any of them.
# Completed Steps
+- 2026-10-04: The error container on each dApp approval screen keeps its height
+ when an error appears
+ ([#297](https://git.eeqj.de/sneak/AutistMask/issues/297)). `#approve-tx-error`
+ and `#approve-sign-error` reserved 20px, but their border and padding took
+ 10px of it, so a one-line error grew them to 26px and pushed the buttons below
+ down 6px. They now reserve 30px. A new test in `tests/e2e/run.js` shows each
+ of the six password error containers on its own screen, empty and then with an
+ error, and fails if one changes height or the element below it moves. Some of
+ the longer messages these two containers can show still take two lines.
+
- 2026-10-04: A transaction with no `to` says "This transaction creates a new
contract. It has no recipient." on its recipient line and in its transaction
history row ([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The
diff --git a/src/popup/index.html b/src/popup/index.html
index 741bc1f..554f6be 100644
--- a/src/popup/index.html
+++ b/src/popup/index.html
@@ -1633,7 +1633,7 @@
@@ -1710,7 +1710,7 @@
diff --git a/tests/e2e/run.js b/tests/e2e/run.js
index c3b61de..1a256c1 100644
--- a/tests/e2e/run.js
+++ b/tests/e2e/run.js
@@ -1449,6 +1449,81 @@ test("an over-long flash message keeps to one line (#252)", async (env) => {
}
});
+// --------------------------------------- password error containers (#297)
+
+// Every screen that asks for a password reserves room for one line of error.
+// The two on the dApp approval screens also have a border and padding, which
+// that reserved height has to cover too.
+const PASSWORD_ERROR_CONTAINERS = [
+ "approve-tx-error",
+ "approve-sign-error",
+ "export-privkey-flash",
+ "show-phrase-flash",
+ "delete-wallet-flash",
+ "confirm-tx-password-error",
+];
+
+// Shows only the screen holding the container, then measures the container
+// and the element below it empty and again filled the way showError() in
+// src/popup/views/helpers.js fills it. Runs in the page.
+function measurePasswordError(id) {
+ const container = document.getElementById(id);
+ const screen = container.closest(".view");
+ for (const view of document.querySelectorAll(".view")) {
+ view.classList.toggle("hidden", view !== screen);
+ }
+ const below = container.nextElementSibling;
+ const measure = () => ({
+ height: container.getBoundingClientRect().height,
+ belowTop: below.getBoundingClientRect().top + window.scrollY,
+ belowHeight: below.getBoundingClientRect().height,
+ });
+ const empty = measure();
+ container.textContent = "Please enter your password.";
+ container.style.visibility = "visible";
+ const filled = measure();
+ container.textContent = "";
+ container.style.visibility = "hidden";
+ return { empty, filled };
+}
+
+test("a password error moves nothing on any screen (#297)", async (env) => {
+ const page = await openPopup(env.ctx, env.popupUrl);
+ try {
+ await page.setViewportSize(POPUP_VIEWPORT);
+ for (const id of PASSWORD_ERROR_CONTAINERS) {
+ const { empty, filled } = await page.evaluate(
+ measurePasswordError,
+ id,
+ );
+ assert(
+ empty.belowHeight > 0,
+ "nothing is shown below #" + id + ", so nothing was measured",
+ );
+ assert(
+ filled.height === empty.height,
+ "#" +
+ id +
+ " is " +
+ empty.height +
+ "px empty and " +
+ filled.height +
+ "px with an error",
+ );
+ assert(
+ filled.belowTop === empty.belowTop,
+ "the element below #" +
+ id +
+ " moved " +
+ (filled.belowTop - empty.belowTop) +
+ "px when the error appeared",
+ );
+ }
+ } finally {
+ await page.close();
+ }
+});
+
// --------------------------------------------- confirmation screen (#238)
//
// The screen that decides what gets signed. The arithmetic underneath it