diff --git a/extension/src/shared/form-affordances/__tests__/password-tools.test.ts b/extension/src/shared/form-affordances/__tests__/password-tools.test.ts
new file mode 100644
index 0000000..168acc4
--- /dev/null
+++ b/extension/src/shared/form-affordances/__tests__/password-tools.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { wirePasswordReveal } from '../password-tools';
+
+describe('wirePasswordReveal', () => {
+ let form: HTMLElement;
+
+ beforeEach(() => {
+ form = document.createElement('div');
+ form.innerHTML = `
+
+
+ `;
+ document.body.appendChild(form);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(form);
+ });
+
+ it('flips input.type and glyph on click', () => {
+ wirePasswordReveal(form);
+ const btn = form.querySelector('#reveal-password-btn') as HTMLButtonElement;
+ const input = form.querySelector('#f-password') as HTMLInputElement;
+ expect(input.type).toBe('password');
+ expect(btn.textContent).toBe('⊙');
+
+ btn.click();
+ expect(input.type).toBe('text');
+ expect(btn.textContent).toBe('⊘');
+ expect(btn.title).toBe('hide');
+
+ btn.click();
+ expect(input.type).toBe('password');
+ expect(btn.textContent).toBe('⊙');
+ expect(btn.title).toBe('reveal');
+ });
+
+ it('teardown returned by wirePasswordReveal resets to password', () => {
+ const teardown = wirePasswordReveal(form);
+ const btn = form.querySelector('#reveal-password-btn') as HTMLButtonElement;
+ const input = form.querySelector('#f-password') as HTMLInputElement;
+ btn.click(); // now revealed
+ expect(input.type).toBe('text');
+ teardown();
+ expect(input.type).toBe('password');
+ expect(btn.textContent).toBe('⊙');
+ expect(btn.title).toBe('reveal');
+ });
+});
diff --git a/extension/src/shared/form-affordances/password-tools.ts b/extension/src/shared/form-affordances/password-tools.ts
new file mode 100644
index 0000000..dacf023
--- /dev/null
+++ b/extension/src/shared/form-affordances/password-tools.ts
@@ -0,0 +1,28 @@
+import { GLYPH_REVEAL, GLYPH_HIDE } from '../glyphs';
+
+/// Returns a teardown fn the caller must invoke on unmount.
+export function wirePasswordReveal(form: HTMLElement): () => void {
+ const btn = form.querySelector('#reveal-password-btn');
+ const input = form.querySelector('#f-password');
+ if (!btn || !input) return () => {};
+
+ const handler = () => {
+ if (input.type === 'password') {
+ input.type = 'text';
+ btn.textContent = GLYPH_HIDE;
+ btn.title = 'hide';
+ } else {
+ input.type = 'password';
+ btn.textContent = GLYPH_REVEAL;
+ btn.title = 'reveal';
+ }
+ };
+ btn.addEventListener('click', handler);
+
+ return () => {
+ btn.removeEventListener('click', handler);
+ input.type = 'password';
+ btn.textContent = GLYPH_REVEAL;
+ btn.title = 'reveal';
+ };
+}