auth-ui: rewrite ui

Change-Id: I6f00867015ec77aa7e336e89da4dc1b081e330c6
diff --git a/core/auth/ui/e2e/README.md b/core/auth/ui/e2e/README.md
index 710061d..fb105bd 100644
--- a/core/auth/ui/e2e/README.md
+++ b/core/auth/ui/e2e/README.md
@@ -59,10 +59,13 @@
 
 ## Commands
 
-Fast, untagged tests and vet (no Ory or browser process startup):
+Fast, untagged source gates (no Ory or browser process startup):
 
 ```sh
 make test
+make vet
+# or run the complete source-only aggregate:
+make check
 ```
 
 Install/check the pinned browser, then run the complete tagged suite:
@@ -173,4 +176,4 @@
 
 ## Intentional non-goals
 
-This suite does not cover or introduce CI, Docker/Compose, PostgreSQL, Windows, system browsers, Firefox/WebKit, parallel stacks, pixel baselines, accessibility audits, recovery, verification, settings UI, MFA, social login, registration-disabled mode, PKCE, refresh tokens, revocation, introspection, consent rejection, Hydra logout, device/client-credentials flows, or a public password-change API. It does not change product handlers, templates, selectors, styles, or static assets.
+This suite does not cover or introduce CI, Docker/Compose, PostgreSQL, Windows, system browsers, Firefox/WebKit, parallel stacks, pixel baselines, accessibility audits, recovery, verification, MFA, social login, registration-disabled mode, PKCE, refresh tokens, revocation, introspection, consent rejection, Hydra logout, device/client-credentials flows, or a public password-change API. It does not change product handlers, templates, selectors, styles, or static assets.
diff --git a/core/auth/ui/e2e/api_password_test.go b/core/auth/ui/e2e/api_password_test.go
index e62c041..1fbf3df 100644
--- a/core/auth/ui/e2e/api_password_test.go
+++ b/core/auth/ui/e2e/api_password_test.go
@@ -94,7 +94,7 @@
 	openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
 	checkpoint(t, session, "api-created-identity-login-form")
 	fillCredentials(t, session.Page, username, password)
-	clickButton(t, session.Page, "login")
+	clickButton(t, session.Page, "Sign in")
 	assertGreeting(t, session.Page, username)
 	checkpoint(t, session, "api-created-identity-greeting")
 	whoami := assertAcceptedKratosSession(t, client, session)
@@ -114,7 +114,8 @@
 		t.Fatal(err)
 	}
 	assertKratosForm(t, session.Page, "/login")
-	if count, err := session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "change password", Exact: playwright.Bool(true)}).Count(); err != nil || count != 0 {
+	assertAuthStateSemantics(t, session.Page, false)
+	if count, err := session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "Change password", Exact: playwright.Bool(true)}).Count(); err != nil || count != 0 {
 		t.Fatalf("unauthenticated change-password guard exposed its form: count=%d err=%v", count, err)
 	}
 	checkpoint(t, session, "unauthenticated-change-password-guard")
@@ -122,45 +123,99 @@
 	registerThroughBrowser(t, session, username, oldPassword, "password-change-registration")
 	original := assertAcceptedKratosSession(t, client, session)
 	openChangePasswordForm(t, session.Page, username)
+	settingsFlow := currentFlowID(t, session.Page)
 
-	if err := session.Page.Locator(`input[name="password"]`).Fill("short"); err != nil {
+	newPasswordField := session.Page.GetByLabel("New password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+	if err := newPasswordField.Focus(); err != nil {
+		t.Fatal("focus new-password field")
+	}
+	assertActiveFocusVisible(t, session.Page)
+	if err := session.Page.Keyboard().Press("Tab"); err != nil {
+		t.Fatal("tab from new password to submit")
+	}
+	assertFocusedElementID(t, session.Page, "change-password-submit")
+	assertActiveFocusVisible(t, session.Page)
+	if err := session.Page.Keyboard().Press("Tab"); err != nil {
+		t.Fatal("tab from password submit to account link")
+	}
+	assertFocusedElementText(t, session.Page, "Back to account")
+	if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+		t.Fatal("shift-tab from account link to password submit")
+	}
+	assertFocusedElementID(t, session.Page, "change-password-submit")
+	if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+		t.Fatal("shift-tab from password submit to new password")
+	}
+	assertFocusedElementID(t, session.Page, "change-password")
+	if err := newPasswordField.Fill("short"); err != nil {
 		t.Fatal("fill invalid replacement password")
 	}
-	clickButton(t, session.Page, "change password")
+	if err := newPasswordField.Press("Enter"); err != nil {
+		t.Fatal("submit invalid password change with Enter")
+	}
 	assertChangePasswordForm(t, session.Page, username)
+	assertAuthStateSemantics(t, session.Page, true, "change-password")
+	if got := currentFlowID(t, session.Page); got != settingsFlow {
+		t.Fatalf("local settings validation replaced flow %q with %q", settingsFlow, got)
+	}
+	assertLatestUIResponseStatus(t, session, http.MethodPost, "/settings", http.StatusUnprocessableEntity)
 	assertVisibleExactText(t, session.Page, passwordLengthMessage)
 	assertVisibleExactText(t, session.Page, passwordCompositionMessage)
+	assertInvalidFieldDescriptions(t, session.Page, "change-password", passwordLengthMessage, passwordCompositionMessage)
+	if value, err := session.Page.GetByLabel("New password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).InputValue(); err != nil || value != "" {
+		t.Fatalf("locally rejected settings password was retained: length=%d err=%v", len(value), err)
+	}
 	checkpoint(t, session, "invalid-password-change-validation")
 
-	logoutThroughBrowser(t, session)
-	checkpoint(t, session, "old-password-login-after-invalid-update")
-	fillCredentials(t, session.Page, username, oldPassword)
-	clickButton(t, session.Page, "login")
-	assertGreeting(t, session.Page, username)
-	oldLogin := assertAcceptedKratosSession(t, client, session)
-	if oldLogin.Identity.ID != original.Identity.ID || oldLogin.Identity.Traits.Username != username {
-		t.Fatal("old password after invalid update did not resolve to the original identity")
-	}
-	checkpoint(t, session, "old-password-accepted-after-invalid-update")
+	t.Run("old password remains valid after local rejection", func(t *testing.T) {
+		verificationSession := newKratosTestSession(t)
+		openKratosForm(t, verificationSession.Page, testStack.UIURL+"/login", "/login")
+		fillCredentials(t, verificationSession.Page, username, oldPassword)
+		clickButton(t, verificationSession.Page, "Sign in")
+		assertGreeting(t, verificationSession.Page, username)
+		oldLogin := assertAcceptedKratosSession(t, client, verificationSession)
+		if oldLogin.Identity.ID != original.Identity.ID || oldLogin.Identity.Traits.Username != username {
+			t.Fatal("old password after invalid update did not resolve to the original identity")
+		}
+		checkpoint(t, verificationSession, "old-password-accepted-after-invalid-update")
+	})
 
-	openChangePasswordForm(t, session.Page, username)
-	if err := session.Page.Locator(`input[name="password"]`).Fill(newPassword); err != nil {
+	assertChangePasswordForm(t, session.Page, username)
+	assertAuthStateSemantics(t, session.Page, true, "change-password")
+	if got := currentFlowID(t, session.Page); got != settingsFlow {
+		t.Fatalf("rejected settings form flow changed before correction: got %q, want %q", got, settingsFlow)
+	}
+	newPasswordField = session.Page.GetByLabel("New password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+	if err := newPasswordField.Fill(newPassword); err != nil {
 		t.Fatal("fill valid replacement password")
 	}
-	clickButton(t, session.Page, "change password")
+	if err := newPasswordField.Press("Enter"); err != nil {
+		t.Fatal("submit password change with Enter")
+	}
 	assertVisibleExactText(t, session.Page, passwordChangedMessage)
+	assertLatestUIResponseStatus(t, session, http.MethodPost, "/settings", http.StatusOK)
+	if count, err := session.Page.GetByRole("status").Count(); err != nil || count != 1 {
+		t.Fatalf("password-change success status count=%d err=%v", count, err)
+	}
+	if count, err := session.Page.GetByRole("heading", playwright.PageGetByRoleOptions{Name: "Password changed", Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+		t.Fatalf("password-change success heading count=%d err=%v", count, err)
+	}
+	assertAuthDocumentSemantics(t, session.Page)
+	assertNoFormOrPasswordControls(t, session.Page, "password-change success")
 	checkpoint(t, session, "password-change-success")
+	clickLink(t, session.Page, "Back to account")
+	assertGreeting(t, session.Page, username)
 
 	logoutThroughBrowser(t, session)
 	checkpoint(t, session, "old-password-login-after-replacement")
 	fillCredentials(t, session.Page, username, oldPassword)
-	clickButton(t, session.Page, "login")
-	assertKratosForm(t, session.Page, "/login")
+	clickButton(t, session.Page, "Sign in")
+	assertInvalidLoginFeedback(t, session)
 	assertNoAcceptedKratosSession(t, client, session)
 	checkpoint(t, session, "old-password-rejected")
 
 	fillCredentials(t, session.Page, username, newPassword)
-	clickButton(t, session.Page, "login")
+	clickButton(t, session.Page, "Sign in")
 	assertGreeting(t, session.Page, username)
 	accepted := assertAcceptedKratosSession(t, client, session)
 	if accepted.Identity.ID != original.Identity.ID || accepted.Identity.Traits.Username != username {
@@ -216,11 +271,11 @@
 
 func openChangePasswordForm(t *testing.T, page playwright.Page, username string) {
 	t.Helper()
-	link := page.Locator(`a[href="/settings"]`)
-	if err := link.Click(); err != nil {
+	if err := page.GetByRole("link", playwright.PageGetByRoleOptions{Name: "Change password", Exact: playwright.Bool(true)}).Click(); err != nil {
 		t.Fatal("open change-password flow from the logged-in user page")
 	}
 	assertChangePasswordForm(t, page, username)
+	assertAuthStateSemantics(t, page, false)
 }
 
 func assertChangePasswordForm(t *testing.T, page playwright.Page, username string) {
@@ -229,30 +284,40 @@
 	if err != nil || u.Scheme+"://"+u.Host != testStack.UIURL || u.Path != "/settings" || u.Query().Get("flow") == "" {
 		t.Fatal("authenticated password form was not rendered through a Kratos settings flow")
 	}
-	password := page.Locator(`input[name="password"]`)
+	assertAuthDocumentSemantics(t, page)
+	assertCurrentAuthFormState(t, page, "/settings")
+	assertFieldContract(t, page, "change-password", "password", "password", "new-password", "New password")
+	password := page.GetByLabel("New password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
 	if err := password.WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
 		t.Fatalf("change-password field did not become visible: %v", err)
 	}
-	usernameInput := page.Locator(`input[name="username"]`)
-	if count, err := usernameInput.Count(); err != nil || count != 1 {
-		t.Fatalf("change-password form username field count=%d err=%v", count, err)
+	if count, err := password.Count(); err != nil || count != 1 {
+		t.Fatalf("change-password field count=%d err=%v", count, err)
 	}
-	if value, err := usernameInput.InputValue(); err != nil || value != username {
-		t.Fatal("change-password form did not retain the authenticated username")
+	if count, err := page.Locator("form").Count(); err != nil || count != 1 {
+		t.Fatalf("change-password form count=%d err=%v, want 1", count, err)
 	}
+	if count, err := page.Locator(`input[type="password"]`).Count(); err != nil || count != 1 {
+		t.Fatalf("change-password password input count=%d err=%v, want 1", count, err)
+	}
+	for _, name := range []string{"username", "method"} {
+		if count, err := page.Locator(`input[name="` + name + `"]`).Count(); err != nil || count != 0 {
+			t.Fatalf("change-password form input %q count=%d err=%v, want 0", name, count, err)
+		}
+	}
+	assertVisibleExactText(t, page, username)
 }
 
 func assertVisibleExactText(t *testing.T, page playwright.Page, text string) {
 	t.Helper()
-	if err := page.GetByText(text, playwright.PageGetByTextOptions{Exact: playwright.Bool(true)}).WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
+	if err := page.GetByText(text, playwright.PageGetByTextOptions{Exact: playwright.Bool(true)}).First().WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
 		t.Fatalf("expected product message was not visible: %v", err)
 	}
 }
 
 func logoutThroughBrowser(t *testing.T, session *browserSession) {
 	t.Helper()
-	if _, err := session.Page.Goto(testStack.UIURL + "/logout"); err != nil {
-		t.Fatal(err)
-	}
+	clickLink(t, session.Page, "Log out")
 	assertKratosForm(t, session.Page, "/login")
+	assertAuthStateSemantics(t, session.Page, false)
 }
diff --git a/core/auth/ui/e2e/browser.go b/core/auth/ui/e2e/browser.go
index 19949f5..a21753a 100644
--- a/core/auth/ui/e2e/browser.go
+++ b/core/auth/ui/e2e/browser.go
@@ -99,6 +99,34 @@
 	Cleanup(func())
 }
 
+type browserSize struct {
+	Width  int `json:"width"`
+	Height int `json:"height"`
+}
+
+type browserSessionOptions struct {
+	Viewport      browserSize
+	VideoSize     browserSize
+	ReducedMotion bool
+}
+
+func defaultBrowserSessionOptions() browserSessionOptions {
+	return browserSessionOptions{
+		Viewport:  browserSize{Width: 1280, Height: 720},
+		VideoSize: browserSize{Width: 1280, Height: 720},
+	}
+}
+
+func validateBrowserSessionOptions(options browserSessionOptions) error {
+	if options.Viewport.Width <= 0 || options.Viewport.Height <= 0 {
+		return fmt.Errorf("browser viewport must have positive dimensions")
+	}
+	if options.VideoSize.Width <= 0 || options.VideoSize.Height <= 0 {
+		return fmt.Errorf("browser video must have positive dimensions")
+	}
+	return nil
+}
+
 type browserSession struct {
 	Page           playwright.Page
 	Context        playwright.BrowserContext
@@ -106,6 +134,8 @@
 	dir            string
 	video          playwright.Video
 	started        time.Time
+	viewport       browserSize
+	videoSize      browserSize
 	screenshots    []string
 	checkpoint     int
 	finalize       sync.Once
@@ -117,6 +147,8 @@
 	blocked        []string
 	requestsMu     sync.Mutex
 	requests       []requestMetadata
+	diagnosticsMu  sync.Mutex
+	diagnostics    []string
 	tracingStarted bool
 	screenshotOp   func(string) error
 	stopTraceOp    func(string) error
@@ -126,10 +158,18 @@
 }
 
 func newBrowserSession(t testReporter, browser playwright.Browser, root string, allowedOrigins []string) (*browserSession, error) {
+	return newBrowserSessionWithOptions(t, browser, root, allowedOrigins, defaultBrowserSessionOptions())
+}
+
+func newBrowserSessionWithOptions(t testReporter, browser playwright.Browser, root string, allowedOrigins []string, options browserSessionOptions) (*browserSession, error) {
+	if err := validateBrowserSessionOptions(options); err != nil {
+		return nil, err
+	}
 	session, err := newBrowserSessionOwner(t, root, browser.Version())
 	if err != nil {
 		return nil, err
 	}
+	assignBrowserSessionMetadata(session, options)
 	// Register while holding the construction/finalization lock. A watchdog
 	// either snapshots this ownership and waits here, or rejects construction
 	// before any external Playwright context exists.
@@ -144,11 +184,15 @@
 	}
 	defer session.lifecycleMu.Unlock()
 	videoDir := filepath.Join(session.dir, ".video")
-	context, err := browser.NewContext(playwright.BrowserNewContextOptions{
-		Viewport:       &playwright.Size{Width: 1280, Height: 720},
-		RecordVideo:    &playwright.RecordVideo{Dir: playwright.String(videoDir), Size: &playwright.Size{Width: 1280, Height: 720}},
+	contextOptions := playwright.BrowserNewContextOptions{
+		Viewport:       &playwright.Size{Width: options.Viewport.Width, Height: options.Viewport.Height},
+		RecordVideo:    &playwright.RecordVideo{Dir: playwright.String(videoDir), Size: &playwright.Size{Width: options.VideoSize.Width, Height: options.VideoSize.Height}},
 		ServiceWorkers: playwright.ServiceWorkerPolicyBlock,
-	})
+	}
+	if options.ReducedMotion {
+		contextOptions.ReducedMotion = playwright.ReducedMotionReduce
+	}
+	context, err := browser.NewContext(contextOptions)
 	if err != nil {
 		return nil, err
 	}
@@ -169,6 +213,14 @@
 	page.OnResponse(func(response playwright.Response) {
 		session.recordRequest(response.Request().Method(), response.Status(), response.Request().URL())
 	})
+	page.OnConsole(func(message playwright.ConsoleMessage) {
+		if message.Type() == "error" && !isExpectedFormStatusConsoleError(message.Text()) {
+			session.recordDiagnostic("console error: " + message.Text())
+		}
+	})
+	page.OnPageError(func(err error) {
+		session.recordDiagnostic("page error: " + err.Error())
+	})
 	s := session
 	s.Page = page
 	s.video = page.Video()
@@ -189,6 +241,11 @@
 	return s, nil
 }
 
+func assignBrowserSessionMetadata(session *browserSession, options browserSessionOptions) {
+	session.viewport = options.Viewport
+	session.videoSize = options.VideoSize
+}
+
 func newBrowserSessionOwner(t testReporter, root, browserVersion string) (*browserSession, error) {
 	dir := filepath.Join(root, sanitizeName(t.Name()))
 	if err := os.MkdirAll(filepath.Join(dir, "screenshots"), 0o700); err != nil {
@@ -197,7 +254,8 @@
 	if err := os.MkdirAll(filepath.Join(dir, ".video"), 0o700); err != nil {
 		return nil, err
 	}
-	session := &browserSession{t: t, dir: dir, started: time.Now().UTC(), browserVer: browserVersion}
+	defaults := defaultBrowserSessionOptions()
+	session := &browserSession{t: t, dir: dir, started: time.Now().UTC(), browserVer: browserVersion, viewport: defaults.Viewport, videoSize: defaults.VideoSize}
 	session.installDefaultArtifactOps()
 	// Own metadata and all available partial artifacts before Playwright context
 	// construction. If Playwright cannot create a context/page/trace, cleanup
@@ -275,6 +333,28 @@
 	return sortedStrings(s.blocked)
 }
 
+func isExpectedFormStatusConsoleError(message string) bool {
+	switch message {
+	case "Failed to load resource: the server responded with a status of 409 (Conflict)",
+		"Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)":
+		return true
+	default:
+		return false
+	}
+}
+
+func (s *browserSession) recordDiagnostic(message string) {
+	s.diagnosticsMu.Lock()
+	s.diagnostics = append(s.diagnostics, message)
+	s.diagnosticsMu.Unlock()
+}
+
+func (s *browserSession) BrowserDiagnostics() []string {
+	s.diagnosticsMu.Lock()
+	defer s.diagnosticsMu.Unlock()
+	return append([]string(nil), s.diagnostics...)
+}
+
 func (s *browserSession) Checkpoint(name string) error {
 	s.lifecycleMu.Lock()
 	defer s.lifecycleMu.Unlock()
@@ -369,7 +449,7 @@
 		if s.t.Failed() || s.forcedFailure || len(errs) > 0 {
 			outcome = "failed"
 		}
-		metadata := sessionMetadata{TestName: s.t.Name(), StartedAt: s.started, FinishedAt: time.Now().UTC(), Outcome: outcome, BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: s.browserVer, Screenshots: s.screenshots, FinalURL: finalURL}
+		metadata := sessionMetadata{TestName: s.t.Name(), StartedAt: s.started, FinishedAt: time.Now().UTC(), Outcome: outcome, BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: s.browserVer, Viewport: s.viewport, VideoSize: s.videoSize, Screenshots: s.screenshots, FinalURL: finalURL}
 		data, err := json.MarshalIndent(metadata, "", "  ")
 		if err == nil {
 			err = os.WriteFile(filepath.Join(s.dir, "session.json"), append(data, '\n'), 0o600)
@@ -394,23 +474,19 @@
 	return "passed", false
 }
 
-const expectedExternalFontRequest = "https://cdnjs.cloudflare.com/ajax/libs/hack-font/3.3.0/web/hack.min.css"
-
-func isExpectedBlockedBrowserRequest(request string) bool {
-	return request == expectedExternalFontRequest
-}
-
 type sessionMetadata struct {
-	TestName         string    `json:"test_name"`
-	StartedAt        time.Time `json:"started_at"`
-	FinishedAt       time.Time `json:"finished_at"`
-	Outcome          string    `json:"outcome"`
-	BindingVersion   string    `json:"binding_version"`
-	CLIVersion       string    `json:"playwright_cli_version"`
-	ChromiumRevision string    `json:"chromium_revision"`
-	BrowserVersion   string    `json:"browser_version"`
-	Screenshots      []string  `json:"screenshots"`
-	FinalURL         string    `json:"final_url"`
+	TestName         string      `json:"test_name"`
+	StartedAt        time.Time   `json:"started_at"`
+	FinishedAt       time.Time   `json:"finished_at"`
+	Outcome          string      `json:"outcome"`
+	BindingVersion   string      `json:"binding_version"`
+	CLIVersion       string      `json:"playwright_cli_version"`
+	ChromiumRevision string      `json:"chromium_revision"`
+	BrowserVersion   string      `json:"browser_version"`
+	Viewport         browserSize `json:"viewport"`
+	VideoSize        browserSize `json:"video_size"`
+	Screenshots      []string    `json:"screenshots"`
+	FinalURL         string      `json:"final_url"`
 }
 
 var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
diff --git a/core/auth/ui/e2e/browser_artifacts_test.go b/core/auth/ui/e2e/browser_artifacts_test.go
index c51766b..12ef1ec 100644
--- a/core/auth/ui/e2e/browser_artifacts_test.go
+++ b/core/auth/ui/e2e/browser_artifacts_test.go
@@ -72,15 +72,11 @@
 	if err := session.screenshot("00-login.png"); err != nil {
 		t.Fatal(err)
 	}
-	blocked := session.BlockedRequests()
-	foundCDN := false
-	for _, request := range blocked {
-		if strings.HasPrefix(request, "https://cdnjs.cloudflare.com/") {
-			foundCDN = true
-		}
+	if blocked := session.BlockedRequests(); len(blocked) != 0 {
+		t.Fatalf("self-contained login page made blocked requests: %v", blocked)
 	}
-	if !foundCDN {
-		t.Fatalf("expected external CDN font request to be aborted, blocked=%v", blocked)
+	if diagnostics := session.BrowserDiagnostics(); len(diagnostics) != 0 {
+		t.Fatalf("login page emitted browser diagnostics: %v", diagnostics)
 	}
 	policy := newRoutePolicy(testStack.allowedOrigins())
 	for _, request := range session.RequestMetadata() {
@@ -109,6 +105,11 @@
 
 func assertArtifactSet(t *testing.T, dir string, failedBeforeFinalize bool) {
 	t.Helper()
+	assertArtifactSetWithOptions(t, dir, failedBeforeFinalize, defaultBrowserSessionOptions())
+}
+
+func assertArtifactSetWithOptions(t *testing.T, dir string, failedBeforeFinalize bool, expected browserSessionOptions) {
+	t.Helper()
 	wantOutcome, requireFailureScreenshot := artifactOutcomeExpectation(failedBeforeFinalize, t.Failed())
 	required := []string{"screenshots/00-initial.png", "screenshots/99-final.png", "video.webm", "trace.zip", "session.json"}
 	if requireFailureScreenshot {
@@ -140,4 +141,7 @@
 	if metadata.BindingVersion != playwrightVersion || metadata.CLIVersion != playwrightCLIVersion || metadata.ChromiumRevision != chromiumRevision || metadata.BrowserVersion != chromiumVersion {
 		t.Fatalf("unexpected Playwright metadata: %+v", metadata)
 	}
+	if metadata.Viewport != expected.Viewport || metadata.VideoSize != expected.VideoSize {
+		t.Fatalf("session dimensions viewport=%+v video=%+v, want viewport=%+v video=%+v", metadata.Viewport, metadata.VideoSize, expected.Viewport, expected.VideoSize)
+	}
 }
diff --git a/core/auth/ui/e2e/browser_test.go b/core/auth/ui/e2e/browser_test.go
index 9422096..4397a48 100644
--- a/core/auth/ui/e2e/browser_test.go
+++ b/core/auth/ui/e2e/browser_test.go
@@ -149,18 +149,45 @@
 	}
 }
 
-func TestExpectedBlockedBrowserRequest(t *testing.T) {
-	if !isExpectedBlockedBrowserRequest(expectedExternalFontRequest) {
-		t.Fatal("known external font request was not recognized")
+func TestBrowserSessionOptions(t *testing.T) {
+	defaults := defaultBrowserSessionOptions()
+	if defaults.Viewport != (browserSize{Width: 1280, Height: 720}) || defaults.VideoSize != defaults.Viewport || defaults.ReducedMotion {
+		t.Fatalf("default browser options=%+v", defaults)
 	}
-	for _, request := range []string{
-		"https://cdnjs.cloudflare.com/other.css",
-		"https://example.test/unexpected.js",
-		"http://127.0.0.1:1234/unowned",
-		expectedExternalFontRequest + "?token=secret",
+	if err := validateBrowserSessionOptions(defaults); err != nil {
+		t.Fatalf("default browser options rejected: %v", err)
+	}
+	for _, options := range []browserSessionOptions{
+		{Viewport: browserSize{Width: 0, Height: 720}, VideoSize: defaults.VideoSize},
+		{Viewport: defaults.Viewport, VideoSize: browserSize{Width: 1280, Height: -1}},
 	} {
-		if isExpectedBlockedBrowserRequest(request) {
-			t.Fatalf("unexpected blocked request was accepted: %s", sanitizeFinalURL(request))
+		if err := validateBrowserSessionOptions(options); err == nil {
+			t.Fatalf("invalid browser options accepted: %+v", options)
+		}
+	}
+}
+
+func TestExpectedFormStatusConsoleError(t *testing.T) {
+	canonical409 := "Failed to load resource: the server responded with a status of 409 (Conflict)"
+	canonical422 := "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)"
+	for _, message := range []string{canonical409, canonical422} {
+		if !isExpectedFormStatusConsoleError(message) {
+			t.Fatalf("expected form-status diagnostic was not recognized: %q", message)
+		}
+	}
+	for _, message := range []string{
+		"product console error",
+		"prefix " + canonical409,
+		canonical409 + " suffix",
+		" " + canonical422,
+		canonical422 + " ",
+		"Failed to load resource: the server responded with a status of 404 (Not Found)",
+		"Failed to load resource: the server responded with a status of 409 (Unprocessable Entity)",
+		"Failed to load resource: the server responded with a status of 422 (Conflict)",
+		"Failed to load resource: the server responded with a status of 500 (Internal Server Error)",
+	} {
+		if isExpectedFormStatusConsoleError(message) {
+			t.Fatalf("unexpected diagnostic was suppressed: %q", message)
 		}
 	}
 }
@@ -189,13 +216,13 @@
 	if got := sanitizeFinalURL("http://127.0.0.1:1234/login?flow=sensitive#fragment"); got != "http://127.0.0.1:1234/login" {
 		t.Fatalf("sanitized URL=%q", got)
 	}
-	metadata := sessionMetadata{TestName: "TestFailure", StartedAt: time.Unix(1, 0).UTC(), FinishedAt: time.Unix(2, 0).UTC(), Outcome: "failed", BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: chromiumVersion, Screenshots: []string{"00-initial.png", "99-final.png", "failure.png"}, FinalURL: "http://127.0.0.1:1/login"}
+	metadata := sessionMetadata{TestName: "TestFailure", StartedAt: time.Unix(1, 0).UTC(), FinishedAt: time.Unix(2, 0).UTC(), Outcome: "failed", BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: chromiumVersion, Viewport: browserSize{Width: 1280, Height: 720}, VideoSize: browserSize{Width: 1280, Height: 720}, Screenshots: []string{"00-initial.png", "99-final.png", "failure.png"}, FinalURL: "http://127.0.0.1:1/login"}
 	data, err := json.Marshal(metadata)
 	if err != nil {
 		t.Fatal(err)
 	}
 	text := string(data)
-	for _, required := range []string{`"outcome":"failed"`, `"failure.png"`, `"binding_version":"v0.6100.0"`, `"playwright_cli_version":"1.61.1"`, `"chromium_revision":"1228"`, `"browser_version":"149.0.7827.55"`} {
+	for _, required := range []string{`"outcome":"failed"`, `"failure.png"`, `"binding_version":"v0.6100.0"`, `"playwright_cli_version":"1.61.1"`, `"chromium_revision":"1228"`, `"browser_version":"149.0.7827.55"`, `"viewport":{"width":1280,"height":720}`, `"video_size":{"width":1280,"height":720}`} {
 		if !strings.Contains(text, required) {
 			t.Errorf("metadata missing %s: %s", required, text)
 		}
@@ -294,13 +321,18 @@
 	}
 }
 
-func TestPartialBrowserOwnerRecordsMetadataAndCleanupError(t *testing.T) {
+func TestPartialBrowserOwnerRecordsRequestedMetadataAndCleanupError(t *testing.T) {
 	reporter := &fakeReporter{name: "TestContextConstructionFailure", failed: true}
 	root := t.TempDir()
 	s, err := newBrowserSessionOwner(reporter, root, chromiumVersion)
 	if err != nil {
 		t.Fatal(err)
 	}
+	requested := browserSessionOptions{
+		Viewport:  browserSize{Width: 390, Height: 844},
+		VideoSize: browserSize{Width: 640, Height: 360},
+	}
+	assignBrowserSessionMetadata(s, requested)
 	raw := filepath.Join(s.dir, ".video", "partial.data")
 	if err := os.WriteFile(raw, []byte("partial Playwright data"), 0o600); err != nil {
 		t.Fatal(err)
@@ -323,6 +355,9 @@
 	if metadata.Outcome != "failed" {
 		t.Fatalf("metadata outcome=%q", metadata.Outcome)
 	}
+	if metadata.Viewport != requested.Viewport || metadata.VideoSize != requested.VideoSize {
+		t.Fatalf("partial metadata viewport=%+v video=%+v, want viewport=%+v video=%+v", metadata.Viewport, metadata.VideoSize, requested.Viewport, requested.VideoSize)
+	}
 	for _, unavailable := range []string{"screenshots/99-final.png", "screenshots/failure.png", "trace.zip", "video.webm"} {
 		if _, err := os.Stat(filepath.Join(s.dir, unavailable)); !os.IsNotExist(err) {
 			t.Fatalf("unavailable artifact %s was fabricated: %v", unavailable, err)
diff --git a/core/auth/ui/e2e/hydra_test.go b/core/auth/ui/e2e/hydra_test.go
index 1b78c8c..012bdf2 100644
--- a/core/auth/ui/e2e/hydra_test.go
+++ b/core/auth/ui/e2e/hydra_test.go
@@ -11,6 +11,8 @@
 	"strings"
 	"testing"
 	"time"
+
+	playwright "github.com/mxschmitt/playwright-go"
 )
 
 type workflowStep struct {
@@ -37,7 +39,7 @@
 	assertKratosForm(t, session.Page, "/login")
 	checkpoint(t, session, "authorization-login-form")
 	fillCredentials(t, session.Page, username, password)
-	clickButton(t, session.Page, "login")
+	clickButton(t, session.Page, "Sign in")
 	checkpoint(t, session, "post-login-automatic-consent")
 
 	firstCallback, err := firstAttempt.Wait(context.Background())
@@ -49,6 +51,7 @@
 		t.Fatal(err)
 	}
 	assertCallbackPage(t, session, callbacks)
+	assertConsentUIAbsent(t, session)
 	checkpoint(t, session, "callback-completion")
 	if err := firstAttempt.Finish(context.Background()); err != nil {
 		t.Fatal(err)
@@ -88,7 +91,7 @@
 		t.Fatal(err)
 	}
 	assertCallbackPage(t, session, callbacks)
-	if count, err := session.Page.Locator(`input[name="password"]`).Count(); err != nil || count != 0 {
+	if count, err := session.Page.GetByLabel("Password").Count(); err != nil || count != 0 {
 		t.Fatalf("second authorization rendered another password form: count=%d", count)
 	}
 	checkpoint(t, session, "second-authenticated-callback")
@@ -99,6 +102,74 @@
 	_ = exchangeAndValidateHydraCode(t, client, clientID, clientSecret, callbacks.URI(), secondCode, username, secondNonce)
 }
 
+func TestHydraFailedLoginCorrection(t *testing.T) {
+	tests := []struct {
+		name       string
+		credential func(t *testing.T, username, password string) (string, string)
+	}{
+		{
+			name: "wrong password",
+			credential: func(t *testing.T, username, _ string) (string, string) {
+				_, wrongPassword := uniqueKratosCredentials(t)
+				return username, wrongPassword
+			},
+		},
+		{
+			name: "unknown username",
+			credential: func(t *testing.T, _ string, password string) (string, string) {
+				unknownUsername, _ := uniqueKratosCredentials(t)
+				return unknownUsername, password
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			session, callbacks := newHydraTestSession(t)
+			client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+			defer client.close()
+			username, password, clientID, clientSecret := createHydraBrowserUserAndClient(t, client, callbacks.URI())
+			state := uniqueOAuthValue(t, "state")
+			nonce := uniqueOAuthValue(t, "nonce")
+			attempt, err := callbacks.Begin()
+			if err != nil {
+				t.Fatal(err)
+			}
+			if _, err := session.Page.Goto(hydraAuthorizationURL(testStack.HydraURL, clientID, callbacks.URI(), state, nonce)); err != nil {
+				t.Fatal("navigate to Hydra authorization request")
+			}
+			assertKratosForm(t, session.Page, "/login")
+			attemptedUsername, attemptedPassword := tt.credential(t, username, password)
+			fillCredentials(t, session.Page, attemptedUsername, attemptedPassword)
+			clickButton(t, session.Page, "Sign in")
+			assertInvalidLoginFeedback(t, session)
+			assertNoAcceptedKratosSession(t, client, session)
+			assertNoOAuthCallbackYet(t, attempt)
+			assertHydraLoginChallengePending(t, client, session)
+			checkpoint(t, session, "challenged-login-rejection")
+
+			fillCredentials(t, session.Page, username, password)
+			clickButton(t, session.Page, "Sign in")
+			callback, err := attempt.Wait(context.Background())
+			if err != nil {
+				t.Fatal(err)
+			}
+			code, err := validateOAuthCallback(callback, state)
+			if err != nil {
+				t.Fatal(err)
+			}
+			assertCallbackPage(t, session, callbacks)
+			if err := attempt.Finish(context.Background()); err != nil {
+				t.Fatal(err)
+			}
+			assertAcceptedKratosSession(t, client, session)
+			assertLoginChallengeCookieCleared(t, session)
+			assertUIRequestCount(t, session, http.MethodPost, "/login", 2)
+			_ = exchangeAndValidateHydraCode(t, client, clientID, clientSecret, callbacks.URI(), code, username, nonce)
+			checkpoint(t, session, "challenged-login-corrected-callback")
+		})
+	}
+}
+
 func TestHydraStaleLoginChallengeDoesNotBreakDirectLogin(t *testing.T) {
 	session, callbacks := newHydraTestSession(t)
 	client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
@@ -116,7 +187,7 @@
 	}
 	assertKratosForm(t, session.Page, "/login")
 	fillCredentials(t, session.Page, username, password)
-	clickButton(t, session.Page, "login")
+	clickButton(t, session.Page, "Sign in")
 	callback, err := authAttempt.Wait(context.Background())
 	if err != nil {
 		t.Fatal(err)
@@ -139,7 +210,7 @@
 	assertNoAcceptedKratosSession(t, client, session)
 
 	fillCredentials(t, session.Page, username, password)
-	clickButton(t, session.Page, "login")
+	clickButton(t, session.Page, "Sign in")
 	assertAcceptedKratosSession(t, client, session)
 
 	current, err := url.Parse(session.Page.URL())
@@ -153,6 +224,67 @@
 	assertGreeting(t, session.Page, username)
 }
 
+func assertNoOAuthCallbackYet(t *testing.T, attempt *callbackAttempt) {
+	t.Helper()
+	attempt.capture.mu.Lock()
+	defer attempt.capture.mu.Unlock()
+	if attempt.count != 0 || attempt.done {
+		t.Fatalf("failed login produced callback count=%d done=%v", attempt.count, attempt.done)
+	}
+}
+
+func loginChallengeCookie(t *testing.T, session *browserSession) string {
+	t.Helper()
+	cookies, err := session.Context.Cookies(testStack.UIURL)
+	if err != nil {
+		t.Fatal("read auth-ui cookies")
+	}
+	for _, cookie := range cookies {
+		if cookie.Name == "login_challenge" && cookie.Value != "" {
+			return cookie.Value
+		}
+	}
+	return ""
+}
+
+func assertHydraLoginChallengePending(t *testing.T, client *directAPIClient, session *browserSession) {
+	t.Helper()
+	challenge := loginChallengeCookie(t, session)
+	if challenge == "" {
+		t.Fatal("failed challenged login did not preserve its challenge cookie")
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	request, err := http.NewRequestWithContext(ctx, http.MethodGet, testStack.HydraAdmin+"/admin/oauth2/auth/requests/login?login_challenge="+url.QueryEscape(challenge), nil)
+	if err != nil {
+		t.Fatal("construct pending Hydra login request check")
+	}
+	status, _, err := client.do(ctx, request)
+	if err != nil || status != http.StatusOK {
+		t.Fatalf("Hydra login challenge was not pending: status=%d err=%v", status, err)
+	}
+}
+
+func assertLoginChallengeCookieCleared(t *testing.T, session *browserSession) {
+	t.Helper()
+	if challenge := loginChallengeCookie(t, session); challenge != "" {
+		t.Fatal("successful challenged retry did not clear the challenge cookie")
+	}
+}
+
+func assertUIRequestCount(t *testing.T, session *browserSession, method, path string, want int) {
+	t.Helper()
+	count := 0
+	for _, request := range session.RequestMetadata() {
+		if request.Status != 0 && request.Origin == testStack.UIURL && request.Method == method && request.Path == path {
+			count++
+		}
+	}
+	if count != want {
+		t.Fatalf("completed UI %s %s request count=%d, want %d", method, path, count, want)
+	}
+}
+
 func createHydraBrowserUserAndClient(t *testing.T, client *directAPIClient, callbackURI string) (username, password, clientID, clientSecret string) {
 	t.Helper()
 	username, password = uniqueKratosCredentials(t)
@@ -320,6 +452,34 @@
 	}
 }
 
+func assertConsentUIAbsent(t *testing.T, session *browserSession) {
+	t.Helper()
+	consentRedirects := 0
+	for _, request := range session.RequestMetadata() {
+		if request.Origin != testStack.UIURL || request.Path != "/consent" || request.Status == 0 {
+			continue
+		}
+		if request.Method != http.MethodGet || request.Status < http.StatusMultipleChoices || request.Status >= http.StatusBadRequest {
+			t.Fatalf("automatic consent produced an unexpected browser response: %+v", request)
+		}
+		consentRedirects++
+	}
+	if consentRedirects != 1 {
+		t.Fatalf("automatic consent redirect count=%d, want 1", consentRedirects)
+	}
+	for name, locator := range map[string]playwright.Locator{
+		"consent form":    session.Page.Locator(`form[action*="consent"]`),
+		"scope control":   session.Page.Locator(`[name="scope"]`),
+		"allow action":    session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "Allow", Exact: playwright.Bool(true)}),
+		"reject action":   session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "Reject", Exact: playwright.Bool(true)}),
+		"consent heading": session.Page.GetByRole("heading", playwright.PageGetByRoleOptions{Name: "Consent", Exact: playwright.Bool(true)}),
+	} {
+		if count, err := locator.Count(); err != nil || count != 0 {
+			t.Fatalf("%s count=%d err=%v, want 0", name, count, err)
+		}
+	}
+}
+
 func assertSecondAuthorizationUsesExistingSession(t *testing.T, metadata []requestMetadata) {
 	t.Helper()
 	observedUILoginGET := false
@@ -360,9 +520,10 @@
 			t.Errorf("browser did not receive an expected successful OAuth workflow response from %s", origin)
 		}
 	}
-	for _, request := range session.BlockedRequests() {
-		if !isExpectedBlockedBrowserRequest(request) {
-			t.Errorf("browser blocked an unexpected query-free target %s", request)
-		}
+	if blocked := session.BlockedRequests(); len(blocked) != 0 {
+		t.Errorf("browser blocked requests during self-contained OAuth UI: %v", blocked)
+	}
+	if diagnostics := session.BrowserDiagnostics(); len(diagnostics) != 0 {
+		t.Errorf("browser reported page/console errors during OAuth: %v", diagnostics)
 	}
 }
diff --git a/core/auth/ui/e2e/kratos_test.go b/core/auth/ui/e2e/kratos_test.go
index d909b1d..b7d739d 100644
--- a/core/auth/ui/e2e/kratos_test.go
+++ b/core/auth/ui/e2e/kratos_test.go
@@ -46,6 +46,7 @@
 	client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
 	defer client.close()
 	username, password := uniqueKratosCredentials(t)
+	correctedUsername, _ := uniqueKratosCredentials(t)
 
 	registerThroughBrowser(t, session, username, password, "initial-registration")
 	first := assertAcceptedKratosSession(t, client, session)
@@ -57,20 +58,93 @@
 	openKratosForm(t, session.Page, testStack.UIURL+"/register", "/register")
 	checkpoint(t, session, "duplicate-registration-form")
 	fillCredentials(t, session.Page, username, password)
-	clickButton(t, session.Page, "Create Account")
-	assertKratosForm(t, session.Page, "/login")
-	checkpoint(t, session, "duplicate-registration-return-login")
+	clickButton(t, session.Page, "Create account")
+	assertKratosForm(t, session.Page, "/register")
+	assertAuthStateSemantics(t, session.Page, true)
+	assertLatestUIResponseStatus(t, session, http.MethodPost, "/register", http.StatusConflict)
+	assertVisibleExactText(t, session.Page, usernameUnavailableMessage)
+	assertCredentialValues(t, session.Page, username, "")
+	checkpoint(t, session, "duplicate-registration-feedback")
 	assertNoAcceptedKratosSession(t, client, session)
 
 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-	defer cancel()
 	identities, err := client.kratosIdentitiesByUsername(ctx, username)
+	cancel()
 	if err != nil {
 		t.Fatal(err)
 	}
 	if len(identities) != 1 || identities[0].ID != first.Identity.ID {
 		t.Fatalf("duplicate registration identity count=%d, want exactly one unchanged identity", len(identities))
 	}
+
+	fillCredentials(t, session.Page, correctedUsername, password)
+	clickButton(t, session.Page, "Create account")
+	assertGreeting(t, session.Page, correctedUsername)
+	checkpoint(t, session, "corrected-registration-greeting")
+	corrected := assertAcceptedKratosSession(t, client, session)
+	ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
+	identities, err = client.kratosIdentitiesByUsername(ctx, correctedUsername)
+	cancel()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(identities) != 1 || identities[0].ID != corrected.Identity.ID {
+		t.Fatalf("corrected registration identity count=%d, want exactly one new identity", len(identities))
+	}
+}
+
+func TestKratosLocalRegistrationValidationAndCorrection(t *testing.T) {
+	session := newKratosTestSession(t)
+	client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+	defer client.close()
+	username, password := uniqueKratosCredentials(t)
+
+	openKratosForm(t, session.Page, testStack.UIURL+"/register", "/register")
+	originalFlow := currentFlowID(t, session.Page)
+	fillCredentials(t, session.Page, "ab", "short-secret")
+	usernameField := session.Page.GetByLabel("Username", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+	if err := usernameField.Focus(); err != nil {
+		t.Fatal("focus registration username")
+	}
+	assertActiveFocusVisible(t, session.Page)
+	if err := session.Page.Keyboard().Press("Tab"); err != nil {
+		t.Fatal("tab from registration username to password")
+	}
+	assertFocusedElementID(t, session.Page, "register-password")
+	assertActiveFocusVisible(t, session.Page)
+	if err := session.Page.Keyboard().Press("Tab"); err != nil {
+		t.Fatal("tab from registration password to submit")
+	}
+	assertFocusedElementID(t, session.Page, "register-submit")
+	assertActiveFocusVisible(t, session.Page)
+	if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+		t.Fatal("shift-tab from registration submit to password")
+	}
+	assertFocusedElementID(t, session.Page, "register-password")
+	if err := session.Page.Keyboard().Press("Enter"); err != nil {
+		t.Fatal("submit registration with Enter from password")
+	}
+	assertVisibleExactText(t, session.Page, passwordLengthMessage)
+	assertKratosForm(t, session.Page, "/register")
+	assertAuthStateSemantics(t, session.Page, true, "register-username", "register-password")
+	if got := currentFlowID(t, session.Page); got != originalFlow {
+		t.Fatal("local validation replaced the current registration flow")
+	}
+	assertLatestUIResponseStatus(t, session, http.MethodPost, "/register", http.StatusUnprocessableEntity)
+	assertVisibleExactText(t, session.Page, usernameLengthMessage)
+	assertVisibleExactText(t, session.Page, passwordLengthMessage)
+	assertVisibleExactText(t, session.Page, passwordCompositionMessage)
+	assertInvalidFieldDescriptions(t, session.Page, "register-username", usernameLengthMessage)
+	assertInvalidFieldDescriptions(t, session.Page, "register-password", passwordLengthMessage, passwordCompositionMessage)
+	assertCredentialValues(t, session.Page, "ab", "")
+	assertNoAcceptedKratosSession(t, client, session)
+	checkpoint(t, session, "local-registration-validation")
+
+	fillCredentials(t, session.Page, username, password)
+	clickButton(t, session.Page, "Create account")
+	assertGreeting(t, session.Page, username)
+	assertAcceptedKratosSession(t, client, session)
+	checkpoint(t, session, "corrected-registration-success")
 }
 
 func TestKratosLogoutInvalidatesSession(t *testing.T) {
@@ -84,8 +158,23 @@
 	if _, accepted, err := client.kratosWhoAmI(context.Background(), acceptedCookies); err != nil || !accepted {
 		t.Fatalf("registered Kratos session was not accepted before logout: accepted=%v err=%v", accepted, err)
 	}
-	clickLink(t, session.Page, "logout")
+	if err := session.Page.Keyboard().Press("Tab"); err != nil {
+		t.Fatal("tab to first account action")
+	}
+	assertFocusedElementText(t, session.Page, "Change password")
+	assertActiveFocusVisible(t, session.Page)
+	if err := session.Page.Keyboard().Press("Tab"); err != nil {
+		t.Fatal("tab to logout action")
+	}
+	assertFocusedElementText(t, session.Page, "Log out")
+	assertActiveFocusVisible(t, session.Page)
+	if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+		t.Fatal("shift-tab to change-password action")
+	}
+	assertFocusedElementText(t, session.Page, "Change password")
+	clickLink(t, session.Page, "Log out")
 	assertKratosForm(t, session.Page, "/login")
+	assertAuthStateSemantics(t, session.Page, false)
 	checkpoint(t, session, "logout-return-login")
 
 	if _, accepted, err := client.kratosWhoAmI(context.Background(), acceptedCookies); err != nil {
@@ -104,11 +193,38 @@
 
 	registerThroughBrowser(t, session, username, password, "registration-before-login")
 	registered := assertAcceptedKratosSession(t, client, session)
-	clickLink(t, session.Page, "logout")
+	clickLink(t, session.Page, "Log out")
 	assertKratosForm(t, session.Page, "/login")
 	checkpoint(t, session, "later-login-form")
-	fillCredentials(t, session.Page, username, password)
-	clickButton(t, session.Page, "login")
+	usernameField := session.Page.GetByLabel("Username", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+	passwordField := session.Page.GetByLabel("Password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+	if err := usernameField.Focus(); err != nil {
+		t.Fatal("focus username for keyboard login")
+	}
+	if err := usernameField.Fill(username); err != nil {
+		t.Fatal("fill username for keyboard login")
+	}
+	if err := session.Page.Keyboard().Press("Tab"); err != nil {
+		t.Fatal("tab from username to password")
+	}
+	assertFocusedElementID(t, session.Page, "login-password")
+	if err := passwordField.Fill(password); err != nil {
+		t.Fatal("fill password for keyboard login")
+	}
+	if err := session.Page.Keyboard().Press("Tab"); err != nil {
+		t.Fatal("tab from password to submit")
+	}
+	assertFocusedElementID(t, session.Page, "login-submit")
+	if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+		t.Fatal("shift-tab from submit to password")
+	}
+	assertFocusedElementID(t, session.Page, "login-password")
+	if err := session.Page.Keyboard().Press("Enter"); err != nil {
+		t.Fatal("submit login with Enter from password")
+	}
+	if err := session.Page.WaitForURL(testStack.UIURL + "/"); err != nil {
+		t.Fatalf("wait for keyboard login navigation: %v", err)
+	}
 	assertGreeting(t, session.Page, username)
 	checkpoint(t, session, "later-login-greeting")
 	loggedIn := assertAcceptedKratosSession(t, client, session)
@@ -125,12 +241,12 @@
 	_, wrongPassword := uniqueKratosCredentials(t)
 
 	registerThroughBrowser(t, session, username, password, "registration-before-wrong-password")
-	clickLink(t, session.Page, "logout")
+	clickLink(t, session.Page, "Log out")
 	assertKratosForm(t, session.Page, "/login")
 	checkpoint(t, session, "wrong-password-login-form")
 	fillCredentials(t, session.Page, username, wrongPassword)
-	clickButton(t, session.Page, "login")
-	assertKratosForm(t, session.Page, "/login")
+	clickButton(t, session.Page, "Sign in")
+	assertInvalidLoginFeedback(t, session)
 	checkpoint(t, session, "wrong-password-return-login")
 	assertNoAcceptedKratosSession(t, client, session)
 }
@@ -144,8 +260,8 @@
 	openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
 	checkpoint(t, session, "unknown-username-login-form")
 	fillCredentials(t, session.Page, username, password)
-	clickButton(t, session.Page, "login")
-	assertKratosForm(t, session.Page, "/login")
+	clickButton(t, session.Page, "Sign in")
+	assertInvalidLoginFeedback(t, session)
 	checkpoint(t, session, "unknown-username-return-login")
 	assertNoAcceptedKratosSession(t, client, session)
 
@@ -162,10 +278,15 @@
 
 func newKratosTestSession(t *testing.T) *browserSession {
 	t.Helper()
+	return newKratosTestSessionWithOptions(t, defaultBrowserSessionOptions())
+}
+
+func newKratosTestSessionWithOptions(t *testing.T, options browserSessionOptions) *browserSession {
+	t.Helper()
 	dir := filepath.Join(testStack.ArtifactDir, sanitizeName(t.Name()))
 	failedBeforeFinalize := false
-	t.Cleanup(func() { assertArtifactSet(t, dir, failedBeforeFinalize) })
-	session, err := newBrowserSession(t, testBrowser.Browser, testStack.ArtifactDir, []string{testStack.UIURL, testStack.KratosURL})
+	t.Cleanup(func() { assertArtifactSetWithOptions(t, dir, failedBeforeFinalize, options) })
+	session, err := newBrowserSessionWithOptions(t, testBrowser.Browser, testStack.ArtifactDir, []string{testStack.UIURL, testStack.KratosURL}, options)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -192,7 +313,7 @@
 	openKratosForm(t, session.Page, testStack.UIURL+"/register", "/register")
 	checkpoint(t, session, checkpointPrefix+"-form")
 	fillCredentials(t, session.Page, username, password)
-	clickButton(t, session.Page, "Create Account")
+	clickButton(t, session.Page, "Create account")
 	assertGreeting(t, session.Page, username)
 	checkpoint(t, session, checkpointPrefix+"-greeting")
 }
@@ -203,6 +324,7 @@
 		t.Fatal(err)
 	}
 	assertKratosForm(t, page, route)
+	assertAuthStateSemantics(t, page, false)
 }
 
 func assertKratosForm(t *testing.T, page playwright.Page, route string) {
@@ -211,10 +333,28 @@
 	if err != nil || u.Scheme+"://"+u.Host != testStack.UIURL || u.Path != route || u.Query().Get("flow") == "" {
 		t.Fatalf("expected rendered %s flow at the UI origin", route)
 	}
-	for _, selector := range []string{`input[name="username"]`, `input[name="password"]`} {
-		if err := page.Locator(selector).WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
-			t.Fatalf("required credential field %s did not become visible: %v", selector, err)
+	assertAuthDocumentSemantics(t, page)
+	assertCurrentAuthFormState(t, page, route)
+	prefix, passwordAutocomplete := "login", "current-password"
+	if route == "/register" {
+		prefix, passwordAutocomplete = "register", "new-password"
+	}
+	assertFieldContract(t, page, prefix+"-username", "username", "text", "username", "Username")
+	assertFieldContract(t, page, prefix+"-password", "password", "password", passwordAutocomplete, "Password")
+	for _, label := range []string{"Username", "Password"} {
+		field := page.GetByLabel(label, playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+		if err := field.WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
+			t.Fatalf("required credential field %q did not become visible: %v", label, err)
 		}
+		if count, err := field.Count(); err != nil || count != 1 {
+			t.Fatalf("credential field %q count=%d err=%v", label, count, err)
+		}
+	}
+	if count, err := page.Locator("form").Count(); err != nil || count != 1 {
+		t.Fatalf("credential form count=%d err=%v, want 1", count, err)
+	}
+	if count, err := page.Locator(`input[type="password"]`).Count(); err != nil || count != 1 {
+		t.Fatalf("password input count=%d err=%v, want 1", count, err)
 	}
 	csrf := page.Locator(`input[name="csrf_token"]`)
 	if err := csrf.WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateAttached, Timeout: playwright.Float(10_000)}); err != nil {
@@ -226,12 +366,80 @@
 	}
 }
 
+func currentFlowID(t *testing.T, page playwright.Page) string {
+	t.Helper()
+	current, err := url.Parse(page.URL())
+	if err != nil || current.Query().Get("flow") == "" {
+		t.Fatal("current browser URL does not contain a flow id")
+	}
+	return current.Query().Get("flow")
+}
+
+func assertCredentialValues(t *testing.T, page playwright.Page, username, password string) {
+	t.Helper()
+	gotUsername, err := page.GetByLabel("Username", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).InputValue()
+	if err != nil || gotUsername != username {
+		t.Fatalf("username value=%q err=%v, want %q", gotUsername, err, username)
+	}
+	gotPassword, err := page.GetByLabel("Password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).InputValue()
+	if err != nil || gotPassword != password {
+		t.Fatalf("password value length=%d err=%v, want length=%d", len(gotPassword), err, len(password))
+	}
+}
+
+func assertLatestUIResponseStatus(t *testing.T, session *browserSession, method, path string, want int) {
+	t.Helper()
+	metadata := session.RequestMetadata()
+	for i := len(metadata) - 1; i >= 0; i-- {
+		request := metadata[i]
+		if request.Method == method && request.Origin == testStack.UIURL && request.Path == path && request.Status != 0 {
+			if request.Status != want {
+				t.Fatalf("latest %s %s status=%d, want %d", method, path, request.Status, want)
+			}
+			return
+		}
+	}
+	t.Fatalf("no completed UI response for %s %s", method, path)
+}
+
+func assertInvalidLoginFeedback(t *testing.T, session *browserSession) {
+	t.Helper()
+	assertKratosForm(t, session.Page, "/login")
+	assertAuthStateSemantics(t, session.Page, true)
+	assertLatestUIResponseStatus(t, session, http.MethodGet, "/login", http.StatusOK)
+	assertVisibleExactText(t, session.Page, "Username or password is incorrect.")
+	form := session.Page.Locator("form")
+	if count, err := form.Count(); err != nil || count != 1 {
+		t.Fatalf("invalid-login form count=%d err=%v", count, err)
+	}
+	if count, err := form.Locator(`[role="alert"]`).Count(); err != nil || count != 1 {
+		t.Fatalf("invalid-login in-form alert count=%d err=%v", count, err)
+	}
+	formHTML, err := form.InnerHTML()
+	if err != nil {
+		t.Fatalf("read invalid-login form source: %v", err)
+	}
+	alertIndex := strings.Index(formHTML, `role="alert"`)
+	usernameIndex := strings.Index(formHTML, `name="username"`)
+	passwordIndex := strings.Index(formHTML, `name="password"`)
+	if alertIndex < 0 || usernameIndex < 0 || passwordIndex < 0 || !(alertIndex < usernameIndex && usernameIndex < passwordIndex) {
+		t.Fatalf("invalid-login source order alert=%d username=%d password=%d", alertIndex, usernameIndex, passwordIndex)
+	}
+	assertCredentialValues(t, session.Page, "", "")
+	if count, err := session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "Sign in", Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+		t.Fatalf("invalid-login submit action count=%d err=%v", count, err)
+	}
+	if count, err := session.Page.GetByRole("link", playwright.PageGetByRoleOptions{Name: "Create account", Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+		t.Fatalf("invalid-login exact registration link count=%d err=%v", count, err)
+	}
+}
+
 func fillCredentials(t *testing.T, page playwright.Page, username, password string) {
 	t.Helper()
-	if err := page.Locator(`input[name="username"]`).Fill(username); err != nil {
+	if err := page.GetByLabel("Username", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).Fill(username); err != nil {
 		t.Fatal("fill username field")
 	}
-	if err := page.Locator(`input[name="password"]`).Fill(password); err != nil {
+	if err := page.GetByLabel("Password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).Fill(password); err != nil {
 		t.Fatal("fill password field")
 	}
 }
@@ -243,9 +451,33 @@
 	}
 }
 
+func assertFocusedElementID(t *testing.T, page playwright.Page, want string) {
+	t.Helper()
+	value, err := page.Evaluate(`document.activeElement && document.activeElement.id`)
+	if err != nil {
+		t.Fatalf("read focused element: %v", err)
+	}
+	got, _ := value.(string)
+	if got != want {
+		t.Fatalf("focused element id=%q, want %q", got, want)
+	}
+}
+
+func assertFocusedElementText(t *testing.T, page playwright.Page, want string) {
+	t.Helper()
+	value, err := page.Evaluate(`document.activeElement && document.activeElement.textContent.trim()`)
+	if err != nil {
+		t.Fatalf("read focused element text: %v", err)
+	}
+	got, _ := value.(string)
+	if got != want {
+		t.Fatalf("focused element text=%q, want %q", got, want)
+	}
+}
+
 func clickLink(t *testing.T, page playwright.Page, name string) {
 	t.Helper()
-	if err := page.GetByText(name, playwright.PageGetByTextOptions{Exact: playwright.Bool(true)}).Click(); err != nil {
+	if err := page.GetByRole("link", playwright.PageGetByRoleOptions{Name: name, Exact: playwright.Bool(true)}).Click(); err != nil {
 		t.Fatalf("click %s link: %v", name, err)
 	}
 }
@@ -256,10 +488,30 @@
 	if err != nil || u.Scheme+"://"+u.Host != testStack.UIURL || u.Path != "/" {
 		t.Fatal("successful authentication did not return to the UI landing route")
 	}
+	assertAuthDocumentSemantics(t, page)
+	if count, err := page.GetByRole("heading", playwright.PageGetByRoleOptions{Name: "Account", Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+		t.Fatalf("account heading count=%d err=%v", count, err)
+	}
 	body, err := page.Locator("body").InnerText()
 	if err != nil || !strings.Contains(body, fmt.Sprintf("Hello %s!", username)) {
 		t.Fatalf("authenticated greeting is not visible: %v", err)
 	}
+	for _, name := range []string{"Change password", "Log out"} {
+		if count, err := page.GetByRole("link", playwright.PageGetByRoleOptions{Name: name, Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+			t.Fatalf("account link %q count=%d err=%v", name, count, err)
+		}
+	}
+	assertNoFormOrPasswordControls(t, page, "account")
+}
+
+func assertNoFormOrPasswordControls(t *testing.T, page playwright.Page, context string) {
+	t.Helper()
+	if count, err := page.Locator("form").Count(); err != nil || count != 0 {
+		t.Fatalf("%s form count=%d err=%v, want 0", context, count, err)
+	}
+	if count, err := page.Locator(`input[type="password"]`).Count(); err != nil || count != 0 {
+		t.Fatalf("%s password input count=%d err=%v, want 0", context, count, err)
+	}
 }
 
 func checkpoint(t *testing.T, session *browserSession, name string) {
@@ -327,13 +579,10 @@
 			t.Errorf("browser did not receive an expected successful workflow response from %s", origin)
 		}
 	}
-	blocked := session.BlockedRequests()
-	if len(blocked) == 0 {
-		t.Error("browser did not block the expected external CDN font request")
+	if blocked := session.BlockedRequests(); len(blocked) != 0 {
+		t.Errorf("browser blocked requests from self-contained UI: %v", blocked)
 	}
-	for _, request := range blocked {
-		if !isExpectedBlockedBrowserRequest(request) {
-			t.Errorf("browser blocked an unexpected query-free target %s", request)
-		}
+	if diagnostics := session.BrowserDiagnostics(); len(diagnostics) != 0 {
+		t.Errorf("browser reported page/console errors: %v", diagnostics)
 	}
 }
diff --git a/core/auth/ui/e2e/oauth_helpers_test.go b/core/auth/ui/e2e/oauth_helpers_test.go
index 5f62b6f..df64cc0 100644
--- a/core/auth/ui/e2e/oauth_helpers_test.go
+++ b/core/auth/ui/e2e/oauth_helpers_test.go
@@ -9,6 +9,7 @@
 	"net/http/httptest"
 	"net/url"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"strings"
 	"sync"
@@ -362,6 +363,31 @@
 	}
 }
 
+func TestFormatCheckPropagatesFormatterFailure(t *testing.T) {
+	repo, err := repositoryDir()
+	if err != nil {
+		t.Fatal(err)
+	}
+	failingFormatter := filepath.Join(t.TempDir(), "failing-gofmt")
+	if err := os.WriteFile(failingFormatter, []byte("#!/bin/sh\nexit 23\n"), 0o700); err != nil {
+		t.Fatal(err)
+	}
+	for _, formatter := range []string{
+		filepath.Join(t.TempDir(), "missing-gofmt"),
+		failingFormatter,
+	} {
+		command := exec.Command("make", "format-check", "GOFMT="+formatter)
+		command.Dir = repo
+		output, err := command.CombinedOutput()
+		if err == nil {
+			t.Fatalf("format-check false-passed for unavailable formatter %q: %s", formatter, output)
+		}
+		if !strings.Contains(string(output), "gofmt check failed") {
+			t.Fatalf("format-check failure for %q omitted diagnostic: %s", formatter, output)
+		}
+	}
+}
+
 func TestFinalMakeTargetsRemainOptIn(t *testing.T) {
 	repo, err := repositoryDir()
 	if err != nil {
@@ -373,16 +399,22 @@
 	}
 	makefile := string(data)
 	for _, required := range []string{
-		"test:\n\tgo test ./...\n\tgo vet ./...",
-		"test-e2e:\n\tgo test -tags=e2e -count=1 -timeout=10m -v ./e2e",
-		"test-e2e-offline:\n\tAUTH_UI_E2E_OFFLINE=1 go test -tags=e2e -count=1 -timeout=10m -v ./e2e",
+		"GO ?= go",
+		"GOFMT ?= gofmt",
+		"format:\n\t$(GOFMT) -w $(GO_FILES)",
+		"test:\n\t$(GO) test ./...",
+		"test-race:\n\t$(GO) test -race -count=1 ./...",
+		"vet:\n\t$(GO) vet ./...",
+		"check: format-check test test-race vet\n\t$(GO) build ./...",
+		"test-e2e:\n\t$(GO) test -tags=e2e -count=1 -timeout=10m -v ./e2e",
+		"test-e2e-offline:\n\tAUTH_UI_E2E_OFFLINE=1 $(GO) test -tags=e2e -count=1 -timeout=10m -v ./e2e",
 		"clean-e2e-artifacts:\n\trm -rf -- e2e/artifacts",
 	} {
 		if !strings.Contains(makefile, required) {
 			t.Fatalf("Makefile omitted required opt-in target contract %q", required)
 		}
 	}
-	if strings.Contains(makefile, "test: test-e2e") || strings.Contains(makefile, "clean: clean-e2e-artifacts") {
+	if strings.Contains(makefile, "test-e2e-offline: install-e2e-browser") || strings.Contains(makefile, "test: test-e2e") || strings.Contains(makefile, "clean: clean-e2e-artifacts") {
 		t.Fatal("Makefile made an ordinary or offline target depend on an online/destructive E2E target")
 	}
 }
diff --git a/core/auth/ui/e2e/ux_test.go b/core/auth/ui/e2e/ux_test.go
new file mode 100644
index 0000000..33c9b99
--- /dev/null
+++ b/core/auth/ui/e2e/ux_test.go
@@ -0,0 +1,511 @@
+//go:build e2e
+
+package e2e
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"testing"
+
+	playwright "github.com/mxschmitt/playwright-go"
+)
+
+type responsiveMetrics struct {
+	ViewportWidth int      `json:"viewportWidth"`
+	ScrollWidth   int      `json:"scrollWidth"`
+	ScrollX       float64  `json:"scrollX"`
+	ScrollY       float64  `json:"scrollY"`
+	TerminalTop   float64  `json:"terminalTop"`
+	TerminalRight float64  `json:"terminalRight"`
+	TargetCount   int      `json:"targetCount"`
+	SmallTargets  []string `json:"smallTargets"`
+}
+
+type reachabilityMetrics struct {
+	FocusOutlineVisible bool     `json:"focusOutlineVisible"`
+	CheckedCount        int      `json:"checkedCount"`
+	OutsideViewport     []string `json:"outsideViewport"`
+}
+
+type semanticMetrics struct {
+	MainCount               int      `json:"mainCount"`
+	HeadingCount            int      `json:"headingCount"`
+	EmptyIDs                []string `json:"emptyIds"`
+	DuplicateIDs            []string `json:"duplicateIds"`
+	UnnamedControls         []string `json:"unnamedControls"`
+	BrokenDescriptions      []string `json:"brokenDescriptions"`
+	InvalidAriaValues       []string `json:"invalidAriaValues"`
+	AlertCount              int      `json:"alertCount"`
+	AlertBeforeFirstControl bool     `json:"alertBeforeFirstControl"`
+	ForbiddenAttributes     []string `json:"forbiddenAttributes"`
+}
+
+type formStateMetrics struct {
+	Action             string   `json:"action"`
+	HiddenCount        int      `json:"hiddenCount"`
+	HiddenNames        []string `json:"hiddenNames"`
+	EmptyHiddenValues  []string `json:"emptyHiddenValues"`
+	PasswordCount      int      `json:"passwordCount"`
+	PasswordValueAttrs int      `json:"passwordValueAttrs"`
+}
+
+type fieldContractMetrics struct {
+	ID             string `json:"id"`
+	Name           string `json:"name"`
+	Type           string `json:"type"`
+	Autocomplete   string `json:"autocomplete"`
+	Autocapitalize string `json:"autocapitalize"`
+	Spellcheck     string `json:"spellcheck"`
+	Required       bool   `json:"required"`
+	Label          string `json:"label"`
+}
+
+type invalidFieldMetrics struct {
+	AriaInvalid     string   `json:"ariaInvalid"`
+	DescriptionIDs  []string `json:"descriptionIds"`
+	DescriptionText []string `json:"descriptionText"`
+}
+
+type authStateMetrics struct {
+	AlertCount                int      `json:"alertCount"`
+	AlertBeforeFirstControl   bool     `json:"alertBeforeFirstControl"`
+	InvalidFieldIDs           []string `json:"invalidFieldIds"`
+	InvalidWithoutDescription []string `json:"invalidWithoutDescription"`
+}
+
+func TestAuthResponsiveMatrix(t *testing.T) {
+	viewports := []browserSize{
+		{Width: 1280, Height: 720},
+		{Width: 390, Height: 844},
+		{Width: 320, Height: 568},
+		{Width: 844, Height: 390},
+		{Width: 320, Height: 240},
+		{Width: 640, Height: 360},
+	}
+	for _, viewport := range viewports {
+		viewport := viewport
+		t.Run(fmt.Sprintf("%dx%d", viewport.Width, viewport.Height), func(t *testing.T) {
+			options := browserSessionOptions{Viewport: viewport, VideoSize: viewport}
+			session := newKratosTestSessionWithOptions(t, options)
+			openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
+			assertResponsiveLayout(t, session.Page)
+			assertFocusedFieldAndActionsReachable(t, session.Page, "Password")
+			assertLocalStylesLoaded(t, session)
+			checkpoint(t, session, fmt.Sprintf("responsive-%dx%d", viewport.Width, viewport.Height))
+		})
+	}
+}
+
+func TestAuthLongUsernameAndValidationWrapping(t *testing.T) {
+	viewport := browserSize{Width: 320, Height: 568}
+	session := newKratosTestSessionWithOptions(t, browserSessionOptions{Viewport: viewport, VideoSize: viewport})
+	client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+	defer client.close()
+	_, password := uniqueKratosCredentials(t)
+	username := "ux-" + strings.Repeat("terminalwrap", 14)
+	status, body := postIdentityJSON(t, client, username, password)
+	if status != http.StatusOK {
+		t.Fatalf("create long-username identity status=%d: %s", status, sanitizedResponseDiagnostic(body))
+	}
+
+	openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
+	fillCredentials(t, session.Page, username, password)
+	clickButton(t, session.Page, "Sign in")
+	assertGreeting(t, session.Page, username)
+	assertWrappedWithinTerminal(t, session.Page, "strong")
+	assertResponsiveLayout(t, session.Page)
+	checkpoint(t, session, "long-account-username-wrap")
+
+	if err := session.Context.ClearCookies(); err != nil {
+		t.Fatal(err)
+	}
+	openKratosForm(t, session.Page, testStack.UIURL+"/register", "/register")
+	fillCredentials(t, session.Page, "ab", "short")
+	clickButton(t, session.Page, "Create account")
+	assertVisibleExactText(t, session.Page, passwordCompositionMessage)
+	assertWrappedWithinTerminal(t, session.Page, `[role="alert"]`)
+	assertResponsiveLayout(t, session.Page)
+	checkpoint(t, session, "fixed-validation-message-wrap")
+}
+
+func TestAuthReducedMotionAndLocalAssets(t *testing.T) {
+	viewport := browserSize{Width: 390, Height: 844}
+	session := newKratosTestSessionWithOptions(t, browserSessionOptions{Viewport: viewport, VideoSize: viewport, ReducedMotion: true})
+	openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
+	motion := evaluateJSON[struct {
+		Matches            bool   `json:"matches"`
+		ScrollBehavior     string `json:"scrollBehavior"`
+		TransitionDuration string `json:"transitionDuration"`
+		AnimationDuration  string `json:"animationDuration"`
+	}](t, session.Page, `() => {
+		const style = getComputedStyle(document.querySelector('input'));
+		return {
+			matches: matchMedia('(prefers-reduced-motion: reduce)').matches,
+			scrollBehavior: getComputedStyle(document.documentElement).scrollBehavior,
+			transitionDuration: style.transitionDuration,
+			animationDuration: style.animationDuration
+		};
+	}`)
+	if !motion.Matches || motion.ScrollBehavior != "auto" || motion.TransitionDuration != "0s" || motion.AnimationDuration != "0s" {
+		t.Fatalf("reduced-motion styles=%+v", motion)
+	}
+	assertLocalStylesLoaded(t, session)
+	if scripts, err := session.Page.Locator("script").Count(); err != nil || scripts != 0 {
+		t.Fatalf("product script count=%d err=%v", scripts, err)
+	}
+	checkpoint(t, session, "reduced-motion-local-assets")
+}
+
+func assertAuthDocumentSemantics(t *testing.T, page playwright.Page) {
+	t.Helper()
+	metrics := evaluateJSON[semanticMetrics](t, page, `() => {
+		const visible = (element) => {
+			const style = getComputedStyle(element);
+			const bounds = element.getBoundingClientRect();
+			return !element.hidden && style.display !== 'none' && style.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0;
+		};
+		const identify = (element) => element.id || element.getAttribute('name') || element.textContent.trim() || element.tagName.toLowerCase();
+		const ids = [...document.querySelectorAll('[id]')].map((element) => element.id);
+		const seen = new Set();
+		const duplicates = new Set();
+		for (const id of ids) {
+			if (seen.has(id)) duplicates.add(id);
+			seen.add(id);
+		}
+		const controls = [...document.querySelectorAll('input:not([type="hidden"]), button, a')].filter(visible);
+		const unnamedControls = controls.filter((element) => {
+			if (element.matches('input')) {
+				return !element.labels || element.labels.length === 0 || ![...element.labels].some((label) => label.textContent.trim());
+			}
+			return !(element.getAttribute('aria-label') || element.textContent).trim();
+		}).map(identify);
+		const brokenDescriptions = [];
+		for (const element of document.querySelectorAll('[aria-describedby]')) {
+			const references = element.getAttribute('aria-describedby').trim().split(/\s+/).filter(Boolean);
+			if (references.length === 0 || references.some((id) => {
+				const target = document.getElementById(id);
+				return !target || !target.textContent.trim();
+			})) brokenDescriptions.push(identify(element));
+		}
+		const invalidAriaValues = [...document.querySelectorAll('[aria-invalid]')]
+			.filter((element) => element.getAttribute('aria-invalid') !== 'true').map(identify);
+		const alerts = [...document.querySelectorAll('[role="alert"]')];
+		const form = document.querySelector('form');
+		const firstControl = form && form.querySelector('input:not([type="hidden"]), select, textarea, button');
+		const alertBeforeFirstControl = alerts.length === 0 || (alerts.length === 1 && firstControl && Boolean(alerts[0].compareDocumentPosition(firstControl) & Node.DOCUMENT_POSITION_FOLLOWING));
+		const forbiddenAttributes = [];
+		for (const element of document.querySelectorAll('input')) {
+			for (const name of ['autofocus', 'minlength', 'maxlength', 'pattern']) {
+				if (element.hasAttribute(name)) forbiddenAttributes.push(identify(element) + ':' + name);
+			}
+		}
+		return {
+			mainCount: document.querySelectorAll('main').length,
+			headingCount: document.querySelectorAll('h1').length,
+			emptyIds: ids.filter((id) => !id),
+			duplicateIds: [...duplicates],
+			unnamedControls,
+			brokenDescriptions,
+			invalidAriaValues,
+			alertCount: alerts.length,
+			alertBeforeFirstControl,
+			forbiddenAttributes
+		};
+	}`)
+	if metrics.MainCount != 1 || metrics.HeadingCount != 1 || len(metrics.EmptyIDs) != 0 || len(metrics.DuplicateIDs) != 0 || len(metrics.UnnamedControls) != 0 || len(metrics.BrokenDescriptions) != 0 || len(metrics.InvalidAriaValues) != 0 || metrics.AlertCount > 1 || !metrics.AlertBeforeFirstControl || len(metrics.ForbiddenAttributes) != 0 {
+		t.Fatalf("auth document semantic metrics=%+v", metrics)
+	}
+}
+
+func assertAuthStateSemantics(t *testing.T, page playwright.Page, expectAlert bool, invalidFieldIDs ...string) {
+	t.Helper()
+	metrics := evaluateJSON[authStateMetrics](t, page, `() => {
+		const alerts = [...document.querySelectorAll('[role="alert"]')];
+		const form = document.querySelector('form');
+		const firstControl = form && form.querySelector('input:not([type="hidden"]), select, textarea, button');
+		const invalidFields = [...document.querySelectorAll('[aria-invalid]')];
+		const invalidWithoutDescription = invalidFields.filter((element) => {
+			if (element.getAttribute('aria-invalid') !== 'true') return true;
+			const references = (element.getAttribute('aria-describedby') || '').trim().split(/\s+/).filter(Boolean);
+			return references.length === 0 || references.some((id) => {
+				const target = document.getElementById(id);
+				return !target || !target.textContent.trim();
+			});
+		});
+		return {
+			alertCount: alerts.length,
+			alertBeforeFirstControl: alerts.length === 1 && firstControl && Boolean(alerts[0].compareDocumentPosition(firstControl) & Node.DOCUMENT_POSITION_FOLLOWING),
+			invalidFieldIds: invalidFields.map((element) => element.id),
+			invalidWithoutDescription: invalidWithoutDescription.map((element) => element.id || element.getAttribute('name') || element.tagName.toLowerCase())
+		};
+	}`)
+	wantAlerts := 0
+	if expectAlert {
+		wantAlerts = 1
+	}
+	if metrics.AlertCount != wantAlerts || (expectAlert && !metrics.AlertBeforeFirstControl) {
+		t.Fatalf("auth alert state=%+v, want count=%d and source ordering", metrics, wantAlerts)
+	}
+	expectedInvalid := make(map[string]bool, len(invalidFieldIDs))
+	for _, id := range invalidFieldIDs {
+		expectedInvalid[id] = true
+	}
+	if len(metrics.InvalidFieldIDs) != len(expectedInvalid) {
+		t.Fatalf("invalid fields=%v, want exactly %v", metrics.InvalidFieldIDs, invalidFieldIDs)
+	}
+	for _, id := range metrics.InvalidFieldIDs {
+		if id == "" || !expectedInvalid[id] {
+			t.Fatalf("invalid fields=%v, want exactly %v", metrics.InvalidFieldIDs, invalidFieldIDs)
+		}
+	}
+	if len(metrics.InvalidWithoutDescription) != 0 {
+		t.Fatalf("invalid fields lack valid descriptions: %v", metrics.InvalidWithoutDescription)
+	}
+}
+
+func assertCurrentAuthFormState(t *testing.T, page playwright.Page, route string) {
+	t.Helper()
+	current, err := url.Parse(page.URL())
+	if err != nil || current.Scheme+"://"+current.Host != testStack.UIURL || current.Path != route || current.Query().Get("flow") == "" {
+		t.Fatalf("current auth form URL is not a valid %s flow", route)
+	}
+	metrics := evaluateJSON[formStateMetrics](t, page, `() => {
+		const form = document.querySelector('form');
+		const hidden = [...form.querySelectorAll('input[type="hidden"]')];
+		const passwords = [...form.querySelectorAll('input[type="password"]')];
+		return {
+			action: form.getAttribute('action'),
+			hiddenCount: hidden.length,
+			hiddenNames: hidden.map((element) => element.getAttribute('name') || ''),
+			emptyHiddenValues: hidden.filter((element) => !element.value).map((element) => element.getAttribute('name') || ''),
+			passwordCount: passwords.length,
+			passwordValueAttrs: passwords.filter((element) => element.hasAttribute('value')).length
+		};
+	}`)
+	action, err := url.Parse(metrics.Action)
+	if err != nil || action.IsAbs() || action.Path != route || action.Query().Get("flow") != current.Query().Get("flow") || len(action.Query()) != 1 {
+		t.Fatalf("auth form action=%q does not contain only the current %s flow", metrics.Action, route)
+	}
+	if metrics.HiddenCount != 1 || len(metrics.HiddenNames) != 1 || metrics.HiddenNames[0] != "csrf_token" || len(metrics.EmptyHiddenValues) != 0 || metrics.PasswordCount != 1 || metrics.PasswordValueAttrs != 0 {
+		t.Fatalf("auth form protocol/password metrics=%+v", metrics)
+	}
+}
+
+func assertFieldContract(t *testing.T, page playwright.Page, id, name, fieldType, autocomplete, label string) {
+	t.Helper()
+	metrics := evaluateJSON[fieldContractMetrics](t, page, `(id) => {
+		const element = document.getElementById(id);
+		return {
+			id: element && element.id,
+			name: element && element.getAttribute('name'),
+			type: element && element.getAttribute('type'),
+			autocomplete: element && element.getAttribute('autocomplete'),
+			autocapitalize: element && element.getAttribute('autocapitalize'),
+			spellcheck: element && element.getAttribute('spellcheck'),
+			required: Boolean(element && element.required),
+			label: element && element.labels ? [...element.labels].map((item) => item.textContent.trim()).join(' ') : ''
+		};
+	}`, id)
+	if metrics.ID != id || metrics.Name != name || metrics.Type != fieldType || metrics.Autocomplete != autocomplete || !metrics.Required || metrics.Label != label {
+		t.Fatalf("field %s semantic contract=%+v", id, metrics)
+	}
+	if name == "username" && (metrics.Autocapitalize != "none" || metrics.Spellcheck != "false") {
+		t.Fatalf("username field %s input-assistance contract=%+v", id, metrics)
+	}
+}
+
+func assertInvalidFieldDescriptions(t *testing.T, page playwright.Page, id string, expected ...string) {
+	t.Helper()
+	metrics := evaluateJSON[invalidFieldMetrics](t, page, `(id) => {
+		const element = document.getElementById(id);
+		const references = (element.getAttribute('aria-describedby') || '').trim().split(/\s+/).filter(Boolean);
+		return {
+			ariaInvalid: element.getAttribute('aria-invalid') || '',
+			descriptionIds: references,
+			descriptionText: references.map((reference) => document.getElementById(reference).textContent.trim())
+		};
+	}`, id)
+	if metrics.AriaInvalid != "true" || len(metrics.DescriptionIDs) == 0 {
+		t.Fatalf("invalid field %s association metrics=%+v", id, metrics)
+	}
+	for _, want := range expected {
+		found := false
+		for _, text := range metrics.DescriptionText {
+			if text == want {
+				found = true
+				break
+			}
+		}
+		if !found {
+			t.Fatalf("invalid field %s descriptions=%q, want %q", id, metrics.DescriptionText, want)
+		}
+	}
+}
+
+func assertActiveFocusVisible(t *testing.T, page playwright.Page) {
+	t.Helper()
+	focus := evaluateJSON[struct {
+		ID           string  `json:"id"`
+		OutlineStyle string  `json:"outlineStyle"`
+		OutlineWidth float64 `json:"outlineWidth"`
+	}](t, page, `() => {
+		const element = document.activeElement;
+		const style = getComputedStyle(element);
+		return {id: element && (element.id || element.textContent.trim()), outlineStyle: style.outlineStyle, outlineWidth: parseFloat(style.outlineWidth) || 0};
+	}`)
+	if focus.ID == "" || focus.OutlineStyle == "none" || focus.OutlineWidth < 2 {
+		t.Fatalf("active element does not have visible focus: %+v", focus)
+	}
+}
+
+func assertResponsiveLayout(t *testing.T, page playwright.Page) {
+	t.Helper()
+	metrics := evaluateJSON[responsiveMetrics](t, page, `() => {
+		window.scrollTo({left: 0, top: 0, behavior: 'instant'});
+		const terminal = document.querySelector('.terminal');
+		const rect = terminal.getBoundingClientRect();
+		const visible = (element) => {
+			const style = getComputedStyle(element);
+			const bounds = element.getBoundingClientRect();
+			return !element.hidden && style.display !== 'none' && style.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0;
+		};
+		const label = (element) => element.id || element.getAttribute('name') || element.textContent.trim() || element.tagName.toLowerCase();
+		const targets = [...terminal.querySelectorAll('input:not([type="hidden"]), select, textarea, button, a')].filter(visible);
+		return {
+			viewportWidth: document.documentElement.clientWidth,
+			scrollWidth: document.documentElement.scrollWidth,
+			scrollX,
+			scrollY,
+			terminalTop: rect.top,
+			terminalRight: rect.right,
+			targetCount: targets.length,
+			smallTargets: targets.filter((element) => {
+				const target = element.getBoundingClientRect();
+				return target.width < 43.5 || target.height < 43.5;
+			}).map(label)
+		};
+	}`)
+	if metrics.ScrollX < -1 || metrics.ScrollX > 1 || metrics.ScrollY < -1 || metrics.ScrollY > 1 {
+		t.Fatalf("page did not reset to the scroll origin: scrollX=%v scrollY=%v", metrics.ScrollX, metrics.ScrollY)
+	}
+	if metrics.ScrollWidth > metrics.ViewportWidth+1 {
+		t.Fatalf("page horizontal overflow: scrollWidth=%d viewport=%d", metrics.ScrollWidth, metrics.ViewportWidth)
+	}
+	if metrics.TerminalTop < -1 || metrics.TerminalRight > float64(metrics.ViewportWidth)+1 {
+		t.Fatalf("terminal outside document origin/viewport: %+v", metrics)
+	}
+	if metrics.TargetCount == 0 || len(metrics.SmallTargets) != 0 {
+		t.Fatalf("activation target metrics=%+v", metrics)
+	}
+}
+
+func assertFocusedFieldAndActionsReachable(t *testing.T, page playwright.Page, label string) {
+	t.Helper()
+	field := page.GetByLabel(label, playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+	if err := field.Focus(); err != nil {
+		t.Fatalf("focus %s: %v", label, err)
+	}
+	metrics := evaluateJSON[reachabilityMetrics](t, page, `() => {
+		const field = document.activeElement;
+		const style = getComputedStyle(field);
+		const visible = (element) => {
+			const computed = getComputedStyle(element);
+			const bounds = element.getBoundingClientRect();
+			return !element.hidden && computed.display !== 'none' && computed.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0;
+		};
+		const label = (element) => element.id || element.getAttribute('name') || element.textContent.trim() || element.tagName.toLowerCase();
+		const targets = [...document.querySelectorAll('.terminal input:not([type="hidden"]), .terminal select, .terminal textarea, .terminal button, .terminal a')].filter(visible);
+		const candidates = [field, ...targets.filter((element) => element !== field)];
+		const outsideViewport = [];
+		for (const element of candidates) {
+			element.scrollIntoView({block: 'nearest', inline: 'nearest', behavior: 'instant'});
+			const bounds = element.getBoundingClientRect();
+			if (bounds.top < -1 || bounds.left < -1 || bounds.bottom > innerHeight + 1 || bounds.right > innerWidth + 1) {
+				outsideViewport.push(label(element));
+			}
+		}
+		return {
+			focusOutlineVisible: style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) >= 2,
+			checkedCount: candidates.length,
+			outsideViewport
+		};
+	}`)
+	if !metrics.FocusOutlineVisible || metrics.CheckedCount == 0 || len(metrics.OutsideViewport) != 0 {
+		t.Fatalf("focus/reachability metrics=%+v", metrics)
+	}
+}
+
+func assertWrappedWithinTerminal(t *testing.T, page playwright.Page, selector string) {
+	t.Helper()
+	metrics := evaluateJSON[struct {
+		ElementRight  float64 `json:"elementRight"`
+		TerminalRight float64 `json:"terminalRight"`
+		ScrollWidth   int     `json:"scrollWidth"`
+		ClientWidth   int     `json:"clientWidth"`
+	}](t, page, `(selector) => {
+		const element = document.querySelector(selector);
+		const terminal = document.querySelector('.terminal');
+		return {
+			elementRight: element.getBoundingClientRect().right,
+			terminalRight: terminal.getBoundingClientRect().right,
+			scrollWidth: document.documentElement.scrollWidth,
+			clientWidth: document.documentElement.clientWidth
+		};
+	}`, selector)
+	if metrics.ElementRight > metrics.TerminalRight+1 || metrics.ScrollWidth > metrics.ClientWidth+1 {
+		t.Fatalf("content did not wrap within terminal: %+v", metrics)
+	}
+}
+
+func assertLocalStylesLoaded(t *testing.T, session *browserSession) {
+	t.Helper()
+	styles := map[string]bool{"/static/base.css": false, "/static/main.css": false}
+	for _, request := range session.RequestMetadata() {
+		if request.Status == 0 {
+			continue
+		}
+		if request.Origin != testStack.UIURL && request.Origin != testStack.KratosURL {
+			t.Fatalf("browser received response from external origin: %+v", request)
+		}
+		if _, ok := styles[request.Path]; ok && request.Origin == testStack.UIURL && request.Method == http.MethodGet && request.Status == http.StatusOK {
+			styles[request.Path] = true
+		}
+	}
+	for path, loaded := range styles {
+		if !loaded {
+			t.Fatalf("local stylesheet did not load with 200: %s", path)
+		}
+	}
+	if blocked := session.BlockedRequests(); len(blocked) != 0 {
+		t.Fatalf("self-contained page made blocked requests: %v", blocked)
+	}
+	if diagnostics := session.BrowserDiagnostics(); len(diagnostics) != 0 {
+		t.Fatalf("page emitted browser diagnostics: %v", diagnostics)
+	}
+}
+
+func evaluateJSON[T any](t *testing.T, page playwright.Page, expression string, args ...any) T {
+	t.Helper()
+	var value any
+	var err error
+	if len(args) == 0 {
+		value, err = page.Evaluate(expression)
+	} else {
+		value, err = page.Evaluate(expression, args[0])
+	}
+	if err != nil {
+		t.Fatalf("evaluate browser metrics: %v", err)
+	}
+	encoded, err := json.Marshal(value)
+	if err != nil {
+		t.Fatalf("encode browser metrics: %v", err)
+	}
+	var result T
+	if err := json.Unmarshal(encoded, &result); err != nil {
+		t.Fatalf("decode browser metrics: %v", err)
+	}
+	return result
+}