Inline mailbox creation on domain page (htmx)

closed
#4f7a69b opened by agent Sep 11

Problem

Adding a mailbox POSTs and redirects straight to the account setup screen. The common flow is a tenant owner provisioning for someone else in the org — they are NOT setting up an IMAP client and should stay on the domain page; the account owner logs in separately (see 3726817) to set up their own client.

Proposal (depends on d656998 HTMX foundation)

  • createAccount with HX-Request returns a fragment (new mailbox-list <li> or re-rendered list section) and stays on the domain page; without JS it keeps today’s redirect (no-JS fallback).
  • After create, show guidance: “Mailbox created — the account owner logs in with their ATProto account to set up their own client.”
  • Explicit Manage/Set up link navigates to the account page for app passwords / IMAP setup.

Files

  • internal/appview/ui/handler.go (createAccount), domain.templ (form hx-post/hx-swap), ui_test.go

Acceptance

  • htmx create appends the row in place, no navigation; plain POST still redirects correctly.

3 Comments

agent 4bf377a Sep 11

Inline Mailbox + Account Page UX Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Stay-on-page inline mailbox creation (domain screen) plus secret-placement, row-cleanup, and confirm-delete (account screen), using the d656998 HTMX foundation with no-JS fallbacks intact. (Combined plan for 4f7a69b + 6663bc8; same text posted on both.)

Architecture: HX success paths return 200 fragments (full list re-render + OOB notice/secret) instead of redirects; validation errors keep the existing 422 + formError path. New POST …/app-passwords/{pid}/delete handler calls appwd.Revoke, returns empty 200 for HX row-removal, 303 otherwise. All dismiss/confirm is inline onclick/onsubmit so swapped-in fragments work with zero new JS listeners.

Tech Stack: Go 1.26.5, a-h/templ v0.3.1020, HTMX 4.0.0 vendored (hx.go: isHXRequest/writeHXError/writeHXFragment, fragments.templ: formError), httptest + seedStore/newTestServer patterns.

File map: - Modify: internal/appview/ui/domain.templ, account.templ, handler.go (routes + 3 handlers), hx.go (1 helper) - Create: fragments in existing internal/appview/ui/fragments.templ (mailboxList, mailboxRow, passwordList, passwordRow, appPasswordSecret) - Test: extend internal/appview/ui/hx_handler_test.go, create internal/appview/ui/fragments_test.go


Task 1: Domain fragments (list + rows, Manage link)

Files: - Modify: internal/appview/ui/fragments.templ, internal/appview/ui/domain.templ:42-55 - Test: internal/appview/ui/fragments_test.go

  • [ ] Step 1: Write the failing test
func TestMailboxListRendersRowAndManageLink(t *testing.T) {
    var buf bytes.Buffer
    accs := []store.Account{{ID: "a9", DomainID: "d1", Address: "[email protected]"}}
    if err := mailboxList("d1", accs).Render(t.Context(), &buf); err != nil {
        t.Fatalf("render: %v", err)
    }
    body := buf.String()
    for _, want := range []string{`id="mailbox-list"`, "[email protected]", "/domains/d1/accounts/a9", ">Manage<"} {
        if !strings.Contains(body, want) {
            t.Errorf("mailboxList missing %q:\n%s", want, body)
        }
    }
    if strings.Contains(body, ">Set up<") {
        t.Errorf("old link text still present:\n%s", body)
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/appview/ui/ -run TestMailboxList -v Expected: FAIL undefined: mailboxList.

  • [ ] Step 3: Add fragments to fragments.templ
templ mailboxRow(domainID string, a store.Account) {
	<li id={ "mailbox-" + a.ID } class="flex items-center justify-between px-4 py-3">
		<span class="font-medium">{ a.Address }</span>
		<a href={ templ.URL("/domains/" + domainID + "/accounts/" + a.ID) } class="text-sm font-medium text-blue-600 hover:text-blue-700">Manage</a>
	</li>
}

templ mailboxList(domainID string, accs []store.Account) {
	<ul id="mailbox-list" class="mt-4 divide-y divide-slate-200 rounded-lg border border-slate-200 bg-white">
		for _, a := range accs {
			@mailboxRow(domainID, a)
		}
	</ul>
}

Link text is “Manage” (renamed from “Set up” per review; navigates to the account page for app passwords / IMAP setup). fragments.templ needs import "git.kilimanjaro.io/sovrn/internal/store" header.

  • [ ] Step 4: Refactor domain.templ:42-55 to use them
<section class="mt-8">
    <h2 class="text-lg font-semibold">Mailboxes</h2>
    <div id="mailbox-form-notice"></div>
    if len(d.Accounts) == 0 {
        <p id="mailbox-empty" class="mt-1 text-sm text-slate-500">No mailboxes yet.</p>
        <ul id="mailbox-list" class="hidden"></ul>
    } else {
        @mailboxList(d.Domain.ID, d.Accounts)
    }

Also update the existing inline row link text domain.templ:51 from “Set up” to “Manage” (or replace the inline list with @mailboxList — prefer replacing so there is exactly one row template). Always render an (empty, hidden) #mailbox-list in the empty case so the HX outerHTML target always exists; the success OOB removes #mailbox-empty (Task 2).

  • [ ] Step 5: Regenerate and verify it passes

Run: templ generate ./internal/appview/ui/ && go test ./internal/appview/ui/ -run TestMailboxList -v Expected: PASS.

Task 2: createAccount HX success stays on page

Files: - Modify: internal/appview/ui/handler.go:145-171 (createAccount), internal/appview/ui/domain.templ:56-63 (form) - Test: extend internal/appview/ui/hx_handler_test.go

  • [ ] Step 1: Write the failing tests
func TestCreateAccountNoJSKeepsRedirect(t *testing.T) {
    srv := newTestServer(t)
    client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
    resp, err := client.Post(srv.URL+"/domains/d1/accounts", "application/x-www-form-urlencoded", strings.NewReader("address="))
    if err != nil { t.Fatalf("POST: %v", err) }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusSeeOther { t.Fatalf("status = %d, want 303", resp.StatusCode) }
}

func TestCreateAccountHXEmptyIs422(t *testing.T) {
    srv := newTestServer(t)
    req, _ := http.NewRequest(http.MethodPost, srv.URL+"/domains/d1/accounts", strings.NewReader("address="))
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    req.Header.Set("HX-Request", "true")
    resp, err := http.DefaultClient.Do(req)
    if err != nil { t.Fatalf("POST: %v", err) }
    defer resp.Body.Close()
    body, _ := io.ReadAll(resp.Body)
    if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("status = %d, want 422: %s", resp.StatusCode, body) }
    if !strings.Contains(string(body), `id="form-error"`) { t.Errorf("missing fragment: %s", body) }
}
  • [ ] Step 2: Run to verify behavior baseline

Run: go test ./internal/appview/ui/ -run 'TestCreateAccount' -v Expected: PASS already (fallback + 422 paths exist from d656998); these lock the fallback while the success branch is added below.

  • [ ] Step 3: Add HX success branch (insert after ProvisionAccount success, before the 303)
if isHXRequest(r) {
    acc, err := h.d.Store.GetAccount(ctx, id)
    if err != nil {
        writeHXError(w, r, http.StatusUnprocessableEntity, userFacingError(err))
        return
    }
    _ = acc
    accounts, err := h.d.Store.ListAccounts(ctx, domainID)
    if err != nil {
        writeHXError(w, r, http.StatusUnprocessableEntity, userFacingError(err))
        return
    }
    var buf bytes.Buffer
    if err := mailboxList(domainID, accounts).Render(r.Context(), &buf); err != nil {
        writeHXError(w, r, http.StatusUnprocessableEntity, userFacingError(err))
        return
    }
    notice := `<div id="mailbox-form-notice" hx-swap-oob="outerHTML"><div class="mb-4 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800">Mailbox created — the account owner logs in with their ATProto account to set up their own client.</div></div>`
    empty := `<p id="mailbox-empty" hx-swap-oob="delete"></p>`
    writeHXFragment(w, http.StatusOK, buf.String()+notice+empty)
    return
}
http.Redirect(w, r, "/domains/"+domainID+"/accounts/"+id, http.StatusSeeOther)

Main swap replaces #mailbox-list (full re-render handles empty→1 and N→N+1 uniformly); the notice and empty-state removal ride along as OOB swaps. Add "bytes" to handler.go imports. Success path needs Stalwart so live verification is via smoke; unit tests cover fallback + validation + fragment rendering.

  • [ ] Step 4: Update the form (domain.templ)
<form action={ templ.URL("/domains/" + d.Domain.ID + "/accounts") } method="post" hx-post={ string(templ.URL("/domains/" + d.Domain.ID + "/accounts")) } hx-target="#mailbox-list" hx-swap="outerHTML" hx-on::after-request="this.reset()" class="mt-3 flex gap-2">
  • [ ] Step 5: Regenerate and run

Run: templ generate ./internal/appview/ui/ && go test ./internal/appview/ui/ -run 'TestCreateAccount' -v Expected: PASS.

Task 3: Account secret under App Passwords + OK dismiss

Files: - Modify: internal/appview/ui/fragments.templ, internal/appview/ui/account.templ:3-10, 43-65 - Test: internal/appview/ui/fragments_test.go

  • [ ] Step 1: Write the failing test
func TestAppPasswordSecretRendersUnderHeadingWithOK(t *testing.T) {
    var buf bytes.Buffer
    if err := appPasswordSecret("s3cr3t").Render(t.Context(), &buf); err != nil {
        t.Fatalf("render: %v", err)
    }
    body := buf.String()
    for _, want := range []string{`id="new-secret"`, "s3cr3t", "copy it now", ">OK<"} {
        if !strings.Contains(body, want) { t.Errorf("secret missing %q:\n%s", want, body) }
    }
}
  • [ ] Step 2: Run to verify it fails

Run: go test ./internal/appview/ui/ -run TestAppPasswordSecret -v Expected: FAIL undefined: appPasswordSecret.

  • [ ] Step 3: Add fragment
templ appPasswordSecret(secret string) {
	<div id="new-secret" class="mb-4 rounded-md border border-green-200 bg-green-50 px-4 py-3">
		<div class="text-sm font-medium text-green-900">App password created — copy it now; it will not be shown again.</div>
		<code class="mt-2 block break-all font-mono text-sm text-green-900">{ secret }</code>
		<button type="button" onclick="this.closest('#new-secret').remove()" class="mt-2 rounded-md bg-green-700 px-3 py-1 text-xs font-semibold text-white hover:bg-green-800">OK</button>
	</div>
}

Inline onclick (not an app.js listener): swapped-in HX fragments work with no event rebinding. No server state — the banner exists only in the issue-response HTML; accountDetail never sets NewSecret, so refresh never reshows it (unchanged semantics, new placement).

  • [ ] Step 4: Move placement in account.templ — delete the top-of-page if d.NewSecret block (:5-10), render under the App Passwords heading instead:
<section class="mt-8">
    <h2 class="text-lg font-semibold">App passwords</h2>
    <div id="app-password-result">
        if d.NewSecret != "" {
            @appPasswordSecret(d.NewSecret)
        }
    </div>

No-JS issue flow re-renders the full page with the secret in the new spot (same AccountPageData.NewSecret field, zero view.go changes).

  • [ ] Step 5: Regenerate and verify

Run: templ generate ./internal/appview/ui/ && go test ./internal/appview/ui/ -run TestAppPasswordSecret -v Expected: PASS.

Task 4: Password rows (expiry + delete) + passwordExpiry helper

Files: - Modify: internal/appview/ui/fragments.templ, internal/appview/ui/account.templ:48-56, internal/appview/ui/hx.go - Test: internal/appview/ui/fragments_test.go

  • [ ] Step 1: Write the failing tests
func TestPasswordExpiryNeverExpiresWhenZero(t *testing.T) {
    if got := passwordExpiry(appwd.AppPassword{Description: "x"}); got != "never expires" {
        t.Fatalf("got %q", got)
    }
}

func TestPasswordRowShowsExpiryAndDeleteNotID(t *testing.T) {
    var buf bytes.Buffer
    ap := appwd.AppPassword{ID: "cred-9", Description: "iPhone"}
    if err := passwordRow("d1", "a1", ap).Render(t.Context(), &buf); err != nil {
        t.Fatalf("render: %v", err)
    }
    body := buf.String()
    for _, want := range []string{"iPhone", "never expires", "Delete", "/domains/d1/accounts/a1/app-passwords/cred-9/delete", "confirm('Delete this app password?')"} {
        if !strings.Contains(body, want) { t.Errorf("row missing %q:\n%s", want, body) }
    }
    if strings.Contains(body, ">cred-9<") { t.Errorf("raw credential ID visible:\n%s", body) }
}
  • [ ] Step 2: Run to verify they fail

Run: go test ./internal/appview/ui/ -run 'TestPasswordExpiry|TestPasswordRow' -v Expected: FAIL undefined: passwordExpiry, undefined: passwordRow.

  • [ ] Step 3: Implement helper (hx.go) + fragments
// hx.go — appwd.Issue is always called with ttl 0, so ExpiresAt is zero
// ("never expires"); the date branch covers future TTL use.
func passwordExpiry(ap appwd.AppPassword) string {
    if ap.ExpiresAt.IsZero() { return "never expires" }
    return "expires " + ap.ExpiresAt.Format("2006-01-02")
}

(hx.go needs import "git.kilimanjaro.io/sovrn/internal/appwd".)

templ passwordRow(domainID, accountID string, ap appwd.AppPassword) {
	<li id={ "app-password-" + ap.ID } class="flex items-center justify-between px-4 py-3">
		<span class="text-sm">{ ap.Description }</span>
		<span class="flex items-center gap-3">
			<span class="text-xs text-slate-500">{ passwordExpiry(ap) }</span>
			<form action={ templ.URL("/domains/" + domainID + "/accounts/" + accountID + "/app-passwords/" + ap.ID + "/delete") } method="post" onsubmit="return confirm('Delete this app password?')" hx-post={ string(templ.URL("/domains/" + domainID + "/accounts/" + accountID + "/app-passwords/" + ap.ID + "/delete")) } hx-target="closest li" hx-swap="outerHTML">
				<button type="submit" class="text-xs font-medium text-red-600 hover:text-red-700">Delete</button>
			</form>
		</span>
	</li>
}

templ passwordList(domainID, accountID string, aps []appwd.AppPassword) {
	<ul id="password-list" class="mt-4 divide-y divide-slate-200 rounded-lg border border-slate-200 bg-white">
		for _, ap := range aps {
			@passwordRow(domainID, accountID, ap)
		}
	</ul>
}

Raw Stalwart credential ID leaves the visible row (dropped per issue default; it remains in the delete URL + li id for targeting). Native confirm() via onsubmit covers both HX and no-JS. fragments.templ needs import "git.kilimanjaro.io/sovrn/internal/appwd".

  • [ ] Step 4: Replace account.templ:48-56 inline list with @passwordList(d.Account.DomainID, d.Account.ID, d.Passwords) (keep the len == 0 empty-state paragraph).

  • [ ] Step 5: Regenerate and verify

Run: templ generate ./internal/appview/ui/ && go test ./internal/appview/ui/ -run 'TestPasswordExpiry|TestPasswordRow' -v Expected: PASS.

Task 5: issueAppPassword HX success (secret + list, no navigation)

Files: - Modify: internal/appview/ui/handler.go:192-229 (issueAppPassword), internal/appview/ui/account.templ:57-64 (form) - Test: internal/appview/ui/hx_handler_test.go

  • [ ] Step 1: Write the failing tests (Stalwart-free branches)
func TestIssueAppPasswordUnknownAccount404(t *testing.T) {
    srv := newTestServer(t)
    for _, hx := range []bool{false, true} {
        req, _ := http.NewRequest(http.MethodPost, srv.URL+"/domains/d1/accounts/nope/app-passwords", strings.NewReader("description=x"))
        req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
        if hx { req.Header.Set("HX-Request", "true") }
        resp, err := http.DefaultClient.Do(req)
        if err != nil { t.Fatalf("POST: %v", err) }
        resp.Body.Close()
        if resp.StatusCode != http.StatusNotFound { t.Errorf("hx=%v status = %d, want 404", hx, resp.StatusCode) }
    }
}
  • [ ] Step 2: Run to verify it passes already (locks current notFound behavior for both paths).

  • [ ] Step 3: Add HX success branch after the second appwd.List (before the full-page render)

if isHXRequest(r) {
    var sbuf, lbuf bytes.Buffer
    if err := appPasswordSecret(secret).Render(r.Context(), &sbuf); err != nil {
        writeHXError(w, r, http.StatusUnprocessableEntity, userFacingError(err))
        return
    }
    if err := passwordListOOB(r.PathValue("id"), aid, aps).Render(r.Context(), &lbuf); err != nil {
        writeHXError(w, r, http.StatusUnprocessableEntity, userFacingError(err))
        return
    }
    writeHXFragment(w, http.StatusOK, sbuf.String()+lbuf.String())
    return
}

Add passwordListOOB to fragments.templ — identical rows to passwordList but the root carries the OOB attr:

templ passwordListOOB(domainID, accountID string, aps []appwd.AppPassword) {
	<ul id="password-list" hx-swap-oob="outerHTML" class="mt-4 divide-y divide-slate-200 rounded-lg border border-slate-200 bg-white">
		for _, ap := range aps {
			@passwordRow(domainID, accountID, ap)
		}
	</ul>
}

Main swap target is #app-password-result (secret); the fresh list rides along as OOB. Form change:

<form ... hx-target="#app-password-result" hx-swap="innerHTML" hx-on::after-request="this.reset()" class="mt-3 flex gap-2">

No-JS keeps the full-page render (Task 3 placement). Success path needs Stalwart; unit tests cover fragments + 404s, smoke covers live issue.

  • [ ] Step 4: Regenerate and run

Run: templ generate ./internal/appview/ui/ && go test ./internal/appview/ui/ -v Expected: PASS.

Task 6: deleteAppPassword handler + route

Files: - Modify: internal/appview/ui/handler.go:38-49 (route), new deleteAppPassword func - Test: internal/appview/ui/hx_handler_test.go

  • [ ] Step 1: Write the failing tests
func TestDeleteAppPasswordUnknownAccount404(t *testing.T) {
    srv := newTestServer(t)
    client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
    for _, hx := range []bool{false, true} {
        req, _ := http.NewRequest(http.MethodPost, srv.URL+"/domains/d1/accounts/nope/app-passwords/cred-9/delete", nil)
        var resp *http.Response
        var err error
        if hx {
            req.Header.Set("HX-Request", "true")
            resp, err = http.DefaultClient.Do(req)
        } else {
            resp, err = client.Do(req)
        }
        if err != nil { t.Fatalf("POST: %v", err) }
        resp.Body.Close()
        if resp.StatusCode != http.StatusNotFound { t.Errorf("hx=%v status = %d, want 404", hx, resp.StatusCode) }
    }
}
  • [ ] Step 2: Run to verify it fails

Run: go test ./internal/appview/ui/ -run TestDeleteAppPassword -v Expected: FAIL (404 from mux — no route; *http.ServeMux returns 404 with empty body, status matches but the handler is missing; the test locks the shape, implementation adds the explicit notFound + Revoke logic). More precisely: it may PASS with mux-404 — either way proceed; the route-registration assertion below is the real gate.

  • [ ] Step 3: Register route + implement handler
mux.HandleFunc("POST /domains/{id}/accounts/{aid}/app-passwords/{pid}/delete", h.deleteAppPassword)
func (h *Handler) deleteAppPassword(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    aid := r.PathValue("aid")
    pid := r.PathValue("pid")
    a, err := h.d.Store.GetAccount(ctx, aid)
    if err != nil {
        notFound(w)
        return
    }
    if err := appwd.Revoke(ctx, h.d.Stalwart, a.StalwartRef, pid); err != nil {
        if isHXRequest(r) {
            writeHXError(w, r, http.StatusUnprocessableEntity, userFacingError(err))
            return
        }
        serverError(w, r, "delete app password", err)
        return
    }
    if isHXRequest(r) {
        // Empty 200 + hx-swap="outerHTML" on closest li removes the row in place.
        w.Header().Set("Vary", "HX-Request")
        w.WriteHeader(http.StatusOK)
        return
    }
    http.Redirect(w, r, "/domains/"+r.PathValue("id")+"/accounts/"+aid, http.StatusSeeOther)
}
  • [ ] Step 4: Run and verify

Run: go test ./internal/appview/ui/ -run TestDeleteAppPassword -v Expected: PASS.

Task 7: Verify + report

  • [ ] Step 1: Full verification

Run:

templ generate ./internal/appview/ui/
gofmt -l internal/appview/ui/
go vet ./...
SOVRN_INTEGRATION=0 go test ./...

Expected: gofmt empty, vet clean, all tests PASS.

  • [ ] Step 2: Comment progress on 4f7a69b and 6663bc8; do not close either until reviewed (per f4092c9 per-session checklist).
agent 4af67fa Sep 11

Task 7 (final) verification + combined fix report for 4f7a69b (inline-mailbox) + 6663bc8 (account-page).

What landed - Inline mailbox creation stays on the domain page: HX fragment + OOB notice, rows carry Manage links. - App-password secret renders under the App Passwords heading with OK dismiss. - Password rows show description + expiry + Delete with native confirm. - Issue flow is inline (secret + OOB list update). - New delete route returns empty-200 for HX row removal. - No-JS fallbacks preserved everywhere (non-HX POST/redirect paths untouched).

Verification evidence (run in order, 2026-09-11) - templ generate ./internal/appview/ui/ — clean, updates=0, EXIT 0. - gofmt -l internal/appview/ui/ — prints NOTHING, EXIT 0 (one pre-existing alignment drift in fragments_test.go fixed and committed as style(ui): gofmt fragments_test alignment). - go vet ./... — clean, EXIT 0. - SOVRN_INTEGRATION=0 go test ./... -count=1 — ALL PASS, EXIT 0: 16 packages ok, 0 failures (5 with no test files: api/sovrn, cmd/gensecrets, cmd/sovrn-lexgen, cmd/sovrnd, internal/appwd, internal/xrpc). - No-CDN sweep rg -n 'src="https?://|href="https?://.*htmx' internal/appview/ui/ — prints nothing (EXIT 1 = no matches). The only “cdn” string in the tree is inside hx_handler_test.go test literals (the vendored-only assertion itself).

Session commits (jj log, newest first, excl. empty @) - pkkzvryx style(ui): gofmt fragments_test alignment - vstxwpwy feat(ui): delete app password with confirm - wklnqqpm fix(ui): first app-password issue swaps list in place - kvpnytzp feat(ui): issue app password inline with secret and list - qtlmvzpo test(ui): cover password expiry date branch and row attrs - mqzkkvwt feat(ui): password rows with expiry and delete - pqyktmop test(ui): pin account secret placement - ynyyyyzz feat(ui): app-password secret under heading with OK dismiss - yknpkuqm fix(ui): HX mailbox errors preserve list via OOB, keep input on failure - ywsyqoqx feat(ui): inline mailbox creation stays on domain page - lumpspsu test(ui): assert mailbox row id and escaping - skosyupt Extract mailbox list/row fragments with Manage link

Deviations from the posted plan (honest) - (a) HX mailbox 422s now preserve the list via OOB formErrorOOB + writeMailboxError (the plan had the error replacing the list), and form reset is guarded by event.detail.successful. - (b) Empty states always render hidden #mailbox-list / #password-list shells + #mailbox-empty / #password-empty, so first-create / first-issue OOB swaps land on empty accounts.

Known minors (follow-ups, NOT blockers) - A stale green success notice can linger next to a later red error. - Deleting the LAST password leaves an empty list until reload (no empty-state restore on the delete path). - HX delete failure replaces the row with the error (refresh restores). - Stalwart-backed success paths (create/issue/delete) need live smoke since unit Stalwart is nil.

NOT closing — awaiting review (per f4092c9 per-session checklist).

agent 4ef577a Sep 11

Implementation complete — ready for review (NOT closing)

Combined 4f7a69b + 6663bc8 work is done across 14 commits (skosyupt..rxoxkxlt), all in internal/appview/ui/.

What landed

Domain screen (4f7a69b): - mailboxList/mailboxRow fragments; row link renamed “Set up” → “Manage”. - createAccount HX success returns 200 full-list re-render + OOB success notice (“the account owner logs in with their ATProto account to set up their own client”) + OOB empty-state removal. Stays on page; form resets only on success (event.detail.successful guard). - HX 422s preserve the list via OOB formErrorOOB + writeMailboxError (error rides OOB into #form-error, list is the main swap). No-JS keeps the 303 redirect.

Account screen (6663bc8): - One-time secret moved from page top to under the App Passwords heading (#app-password-result slot), with inline-onclick OK dismiss. No server state; refresh never reshows. - Rows: description + expiry (passwordExpiry: “never expires” for zero time, else “expires YYYY-MM-DD”) + Delete with native confirm(). Raw Stalwart credential ID dropped from visible text. - issueAppPassword HX returns secret + OOB list refresh; empty states always render hidden #password-list shell + #password-empty so first-issue OOB lands. - New POST …/app-passwords/{pid}/delete → appwd.Revoke: empty 200 removes the row in place (HX), 303 fallback (no-JS). - HX password errors (issue/list/delete failures) retarget into page-level #form-error via HX-Retarget/HX-Reswap (writeHXSlotError), leaving the secret/list slots untouched. Verified the vendored htmx 4.0.0 honors e.hx.retarget/e.hx.reswap. - Success paths clear stale #form-error via OOB.

Verification evidence (fresh, this session)

  • gofmt -l internal/appview/ui/ → empty, exit 0
  • go vet ./... → clean, exit 0
  • SOVRN_INTEGRATION=0 go test ./... -count=1 → all 17 test packages ok, exit 0 (UI package: 36 tests pass, 0 fail)
  • No-CDN sweep (src="https?:// / htmx href) → no matches; vendored-only

Deviations from the posted plan (honest)

(a) HX mailbox 422s preserve the list via OOB instead of replacing it, and form reset is success-guarded (both lessons applied forward to the password forms). (b) Empty states always render hidden list shells + empty-state ids so first-create/first-issue OOB swaps land. © Password-issue/delete HX errors use HX-Retarget header retargeting instead of the planned OOB-list main swap (the planned shape would have duplicated #password-list into the wrong slot — caught in final review, fixed in rxoxkxlt).

Known minors (follow-ups, NOT blockers)

  • Stale green success notice can linger next to a later red error (no cross-clearing).
  • Deleting the LAST password leaves an empty list until reload (no empty-state restore on the delete path — explicitly deferred).
  • HX delete failure surfaces the error in #form-error (row untouched); refresh restores either way.
  • Stalwart-backed success paths (create/issue/delete) need live smoke — unit Stalwart is nil; unit tests cover fallbacks, validation, 404s, fragments, headers.

NOT closing — awaiting review (per f4092c9 per-session checklist).