bae5bc004f31

feat: add adoption receiver onboarding flow (v4.0.17)

time
2026-06-19 20:04 UTC · 4 months ago
machine
koh2
agent
gpt-5
session
vault adoption integration
parent
c902e41fa3da
10 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/App.tsx+56 −23src/popup/components/RuneBackground.tsx+1 −1src/popup/styles/popup.css+123 −0src/popup/views/AdoptVaultView.test.tsx+126 −0src/popup/views/AdoptVaultView.tsx+202 −0src/popup/views/Onboarding.tsx+27 −1src/popup/views/view-test-utils.tsx+2 −2
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "4.0.16",5+  "version": "4.0.17",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.16",4+  "version": "4.0.17",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.0.16",3+  "version": "4.0.17",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/popup/App.tsx+56 −23
@@ -39,6 +39,7 @@3939 const ChirpSyncView   = lazy(() => import("./views/ChirpSync").then(m => ({ default: m.ChirpSyncView })));4040 const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));4141 const CodexRestoreView  = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));42+const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));4243 4344 type View =4445   | "lock"@@ -53,6 +54,7 @@5354   | "health"5455   | "trash"5556   | "panic"57+  | "adopting"5658   | "codex-generate"5759   | "codex-restore";5860 type BootState = "loading" | "ready";@@ -1157,8 +1159,16 @@11571159     setView("add");11581160   }11591161 1160-  const isLockedView = () => view() === "lock";1162+  const isLockedView = () => view() === "lock" || view() === "adopting";11611163 1164+  async function handleAdoptionDone() {1165+    await storageSet("peach_onboarded", "true").catch(() => {});1166+    setOnboarded(true);1167+    setHasVault(true);1168+    await handleUnlock();1169+    void sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "foreground" } });1170+  }1171+11621172   async function handleApprovePendingPasskey() {11631173     const pending = pendingPasskey();11641174     if (!pending) return;@@ -1218,32 +1228,55 @@12181228             when={!isLockedView()}12191229             fallback={12201230               <Show1221-                when={onboarded() || hasVault()}1231+                when={view() !== "adopting"}12221232                 fallback={1223-                  <Onboarding1224-                    onComplete={() => { setOnboarded(true); }}1225-                  />1233+                  <Suspense fallback={<div class="peach-loading">loading...</div>}>1234+                    <AdoptVaultView1235+                      onCancel={() => {1236+                        setOnboarded(false);1237+                        setView("lock");1238+                      }}1239+                      onDone={handleAdoptionDone}1240+                      startAdoption={async () => {1241+                        throw new Error("adoption pairing is not available yet");1242+                      }}1243+                    />1244+                  </Suspense>12261245                 }12271246               >1228-                <LockView1229-                  hasVault={hasVault()}1230-                  ready={bootState() === "ready"}1231-                  isPro={proStatus()}1232-                  forceRecoveryWords={forceRecoveryWords()}1233-                  onUnlock={() => {1234-                    // RecoveryWordDisplay clears the storage flag before1235-                    // calling onDone, so handleUnlock will see no pending1236-                    // confirmation and proceed through the normal path.1237-                    if (forceRecoveryWords()) setForceRecoveryWords(false);1238-                    return handleUnlock();1239-                  }}1240-                  onBackToOnboarding={1241-                    !hasVault()1242-                      ? () => setOnboarded(false)1243-                      : undefined1247+                <Show1248+                  when={onboarded() || hasVault()}1249+                  fallback={1250+                    <Onboarding1251+                      onComplete={() => { setOnboarded(true); }}1252+                      onRestore={() => { setOnboarded(true); }}1253+                      onAdopt={() => {1254+                        setOnboarded(true);1255+                        setView("adopting");1256+                      }}1257+                    />12441258                   }1245-                  onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1246-                />1259+                >1260+                  <LockView1261+                    hasVault={hasVault()}1262+                    ready={bootState() === "ready"}1263+                    isPro={proStatus()}1264+                    forceRecoveryWords={forceRecoveryWords()}1265+                    onUnlock={() => {1266+                      // RecoveryWordDisplay clears the storage flag before1267+                      // calling onDone, so handleUnlock will see no pending1268+                      // confirmation and proceed through the normal path.1269+                      if (forceRecoveryWords()) setForceRecoveryWords(false);1270+                      return handleUnlock();1271+                    }}1272+                    onBackToOnboarding={1273+                      !hasVault()1274+                        ? () => setOnboarded(false)1275+                        : undefined1276+                    }1277+                    onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1278+                  />1279+                </Show>12471280               </Show>12481281             }12491282           >

view file

src/popup/components/RuneBackground.tsx+1 −1
@@ -111,7 +111,7 @@111111 112112   return (113113     <canvas114-      ref={canvas}114+      ref={(el) => { canvas = el; }}115115       width={320}116116       height={520}117117       class="peach-rune-background"

view file

src/popup/styles/popup.css+123 −0
@@ -7055,6 +7055,129 @@70557055   width: 44px;70567056 }70577057 7058+.peach-onboarding-choice-row {7059+  display: grid;7060+  grid-template-columns: 1fr;7061+  gap: 8px;7062+  margin-top: 14px;7063+}7064+7065+.peach-onboarding-choice {7066+  min-height: 34px;7067+  border: 1px solid color-mix(in srgb, var(--peach-t0) 16%, transparent);7068+  border-radius: 8px;7069+  padding: 8px 10px;7070+  background: color-mix(in srgb, var(--peach-t0) 7%, transparent);7071+  color: var(--peach-t0);7072+  font-size: 0.76rem;7073+  font-weight: 700;7074+  text-align: left;7075+}7076+7077+.peach-onboarding-choice--adopt {7078+  border-color: color-mix(in srgb, var(--peach-accent) 34%, transparent);7079+  background: color-mix(in srgb, var(--peach-accent) 13%, transparent);7080+}7081+7082+.peach-adopt-vault {7083+  position: relative;7084+  min-height: 100%;7085+  padding: 22px;7086+  display: grid;7087+  place-items: center;7088+}7089+7090+.peach-adopt-card {7091+  position: relative;7092+  z-index: 1;7093+  width: min(100%, 330px);7094+  padding: 20px;7095+  border-radius: 8px;7096+  border: 1px solid color-mix(in srgb, var(--peach-t0) 13%, transparent);7097+  background: color-mix(in srgb, var(--peach-surface) 90%, transparent);7098+  box-shadow: 0 16px 48px rgba(0, 0, 0, 0.28);7099+}7100+7101+.peach-adopt-card h1 {7102+  margin: 0 0 10px;7103+  font-size: 1.28rem;7104+  line-height: 1.15;7105+}7106+7107+.peach-adopt-card p {7108+  margin: 0 0 14px;7109+  color: var(--peach-t1);7110+  line-height: 1.35;7111+}7112+7113+.peach-adopt-input {7114+  margin-bottom: 12px;7115+}7116+7117+.peach-adopt-actions {7118+  display: flex;7119+  gap: 10px;7120+  margin-top: 14px;7121+}7122+7123+.peach-adopt-actions > button {7124+  flex: 1;7125+}7126+7127+.peach-adopt-birds {7128+  width: 120px;7129+  height: 54px;7130+  margin-bottom: 14px;7131+  position: relative;7132+}7133+7134+.peach-adopt-birds::before {7135+  content: "";7136+  position: absolute;7137+  left: 29px;7138+  right: 29px;7139+  top: 25px;7140+  height: 1px;7141+  background: color-mix(in srgb, var(--peach-accent) 50%, transparent);7142+}7143+7144+.peach-adopt-birds span {7145+  position: absolute;7146+  top: 11px;7147+  width: 34px;7148+  height: 28px;7149+  border-radius: 50% 50% 46% 46%;7150+  background: color-mix(in srgb, var(--peach-accent) 45%, var(--peach-t0));7151+  box-shadow: 0 0 18px color-mix(in srgb, var(--peach-accent) 24%, transparent);7152+  animation: peach-adopt-bob 1.8s ease-in-out infinite;7153+}7154+7155+.peach-adopt-birds span:first-child {7156+  left: 12px;7157+}7158+7159+.peach-adopt-birds span:last-child {7160+  right: 12px;7161+  animation-delay: 0.25s;7162+}7163+7164+.peach-adopt-sas {7165+  margin: 18px 0;7166+  padding: 14px;7167+  border-radius: 8px;7168+  background: color-mix(in srgb, var(--peach-accent) 18%, transparent);7169+  color: var(--peach-t0);7170+  font-size: 1.35rem;7171+  font-weight: 800;7172+  font-variant-numeric: tabular-nums;7173+  text-align: center;7174+}7175+7176+@keyframes peach-adopt-bob {7177+  0%, 100% { transform: translateY(0); }7178+  50% { transform: translateY(-5px); }7179+}7180+70587181 .peach-onboarding-mobile-card-buttons {70597182   display: flex;70607183   flex-direction: column;

view file

src/popup/views/AdoptVaultView.test.tsx+126 −0
@@ -0,0 +1,126 @@1+// @vitest-environment jsdom2+3+import type { JSX } from "solid-js";4+import { afterEach, describe, expect, it, vi } from "vitest";5+import { Onboarding } from "./Onboarding";6+import { AdoptVaultView } from "./AdoptVaultView";7+import { flushView, inputText, renderView } from "./view-test-utils";8+9+function button(container: HTMLElement, text: string): HTMLButtonElement {10+  const found = Array.from(container.querySelectorAll("button")).find((b) =>11+    (b.textContent ?? "").includes(text),12+  );13+  if (!found) throw new Error(`button not found: ${text}`);14+  return found as HTMLButtonElement;15+}16+17+async function enterStrongPassword(container: HTMLElement) {18+  inputText(container.querySelector("#adopt-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn");19+  inputText(container.querySelector("#adopt-confirm-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn");20+  await flushView();21+}22+23+describe("vault adoption onboarding", () => {24+  const cleanups: Array<() => void> = [];25+26+  afterEach(() => {27+    vi.useRealTimers();28+    while (cleanups.length) cleanups.pop()!();29+  });30+31+  function mounted(view: () => JSX.Element) {32+    const rendered = renderView(view);33+    cleanups.push(rendered.dispose);34+    return rendered;35+  }36+37+  it("T-A21: Onboarding offers Create vault, Restore from backup, and I already have Peach", () => {38+    vi.useFakeTimers();39+    const onComplete = vi.fn();40+    const onRestore = vi.fn();41+    const onAdopt = vi.fn();42+    const view = mounted(() => <Onboarding onComplete={onComplete} onRestore={onRestore} onAdopt={onAdopt} />);43+44+    button(view.container, "next").click();45+    vi.advanceTimersByTime(220);46+    button(view.container, "next").click();47+    vi.advanceTimersByTime(220);48+49+    expect(view.container.textContent).toContain("Create vault");50+    expect(view.container.textContent).toContain("Restore from backup");51+    expect(view.container.textContent).toContain("I already have Peach");52+    button(view.container, "I already have Peach").click();53+    expect(onAdopt).toHaveBeenCalledTimes(1);54+  });55+56+  it("T-A22: password screen uses the same minimum-length gate as create-vault", () => {57+    const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={vi.fn()} />);58+    const continueButton = button(view.container, "Continue");59+    expect(continueButton.disabled).toBe(true);60+    inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "short");61+    inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "short");62+    expect(view.container.textContent).toContain("need 7 more characters");63+    expect(continueButton.disabled).toBe(true);64+  });65+66+  it("T-A23: adoption flow reaches the SAS screen", async () => {67+    const startAdoption = vi.fn(async ({ confirmSAS }: { confirmSAS(sas: string): Promise<boolean> }) => {68+      await confirmSAS("895245");69+    });70+    const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={startAdoption} />);71+    await enterStrongPassword(view.container);72+    button(view.container, "Continue").click();73+    button(view.container, "Pair a device").click();74+    await flushView();75+76+    expect(view.container.textContent).toContain("Confirm this code matches");77+    expect(view.container.textContent).toContain("8 9 5 2 4 5");78+  });79+80+  it("T-A24: success leaves a vault and opens Peach", async () => {81+    const onDone = vi.fn();82+    const view = mounted(83+      () => (84+        <AdoptVaultView85+          onCancel={() => {}}86+          onDone={onDone}87+          startAdoption={vi.fn(async () => {})}88+        />89+      ),90+    );91+    await enterStrongPassword(view.container);92+    button(view.container, "Continue").click();93+    button(view.container, "Pair a device").click();94+    await flushView();95+96+    expect(view.container.textContent).toContain("Joined your vault.");97+    button(view.container, "Open Peach").click();98+    expect(onDone).toHaveBeenCalledTimes(1);99+  });100+101+  it("T-A25: SAS cancel keeps receiver vault-less and returns to the password step", async () => {102+    const createVault = vi.fn();103+    const view = mounted(104+      () => (105+        <AdoptVaultView106+          onCancel={() => {}}107+          onDone={() => {}}108+          startAdoption={vi.fn(async ({ confirmSAS }) => {109+            const ok = await confirmSAS("895245");110+            if (ok) await createVault();111+            else throw new Error("sas_rejected");112+          })}113+        />114+      ),115+    );116+    await enterStrongPassword(view.container);117+    button(view.container, "Continue").click();118+    button(view.container, "Pair a device").click();119+    await flushView();120+    button(view.container, "Cancel").click();121+    await flushView();122+123+    expect(createVault).not.toHaveBeenCalled();124+    expect(view.container.textContent).toContain("Set a password for this device.");125+  });126+});

view file

src/popup/views/AdoptVaultView.tsx+202 −0
@@ -0,0 +1,202 @@1+import { Match, Show, Switch, createMemo, createSignal } from "solid-js";2+import { sendMessage } from "../App";3+import { RuneBackground } from "../components/RuneBackground";4+5+type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";6+7+export interface AdoptVaultViewProps {8+  onCancel(): void;9+  onDone(): void | Promise<void>;10+  startAdoption(args: {11+    password: string;12+    confirmSAS(sas: string): Promise<boolean>;13+    createVault(rootKeyHex: string): Promise<{ vaultId: string }>;14+  }): Promise<void>;15+}16+17+const MIN_LENGTH = 12;18+const MIN_ZXCVBN_SCORE = 2;19+20+let zxcvbnLoader: ((password: string) => { score: number }) | null = null;21+async function getZxcvbn() {22+  if (zxcvbnLoader) return zxcvbnLoader;23+  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([24+    import("@zxcvbn-ts/core"),25+    import("@zxcvbn-ts/language-common"),26+    import("@zxcvbn-ts/language-en"),27+  ]);28+  zxcvbnOptions.setOptions({29+    translations: en.translations,30+    graphs: common.adjacencyGraphs,31+    dictionary: { ...common.dictionary, ...en.dictionary },32+  });33+  zxcvbnLoader = (password: string) => zxcvbn(password);34+  return zxcvbnLoader;35+}36+37+export function AdoptVaultView(props: AdoptVaultViewProps) {38+  const [phase, setPhase] = createSignal<AdoptPhase>("password");39+  const [password, setPassword] = createSignal("");40+  const [confirmPassword, setConfirmPassword] = createSignal("");41+  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);42+  const [sas, setSas] = createSignal("");43+  const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);44+  const [error, setError] = createSignal("");45+46+  createMemo(() => {47+    const value = password();48+    if (!value) {49+      setStrengthScore(null);50+      return;51+    }52+    getZxcvbn().then((zxcvbn) => {53+      if (password() === value) setStrengthScore(zxcvbn(value).score);54+    }).catch(() => {55+      if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);56+    });57+  });58+59+  const canContinue = createMemo(() =>60+    password().length >= MIN_LENGTH &&61+    confirmPassword().length >= MIN_LENGTH &&62+    password() === confirmPassword() &&63+    strengthScore() !== null &&64+    strengthScore()! >= MIN_ZXCVBN_SCORE65+  );66+67+  const createBlocker = createMemo(() => {68+    if (!password()) return null;69+    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;70+    if (!confirmPassword()) return "confirm your password";71+    if (password() !== confirmPassword()) return "passwords don't match";72+    if (strengthScore() === null) return "checking strength...";73+    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";74+    return null;75+  });76+77+  async function beginPairing() {78+    setPhase("pair");79+    setError("");80+    try {81+      await props.startAdoption({82+        password: password(),83+        confirmSAS: (code) => new Promise<boolean>((resolve) => {84+          setSas(code);85+          setSasResolve(() => resolve);86+          setPhase("sas");87+        }),88+        createVault: async (rootKeyHex) => {89+          const res = await sendMessage({90+            type: "CREATE_VAULT_FROM_ROOT_KEY",91+            payload: { password: password(), rootKeyHex },92+          }) as { ok: boolean; vaultId?: string; error?: string };93+          if (!res.ok || !res.vaultId) throw new Error(res.error ?? "adoption_create_failed");94+          return { vaultId: res.vaultId };95+        },96+      });97+      setPhase("success");98+    } catch (err) {99+      if (err instanceof Error && err.message === "sas_rejected") return;100+      setError(err instanceof Error ? err.message : String(err));101+      setPhase("error");102+    }103+  }104+105+  function confirmSas(ok: boolean) {106+    const resolve = sasResolve();107+    setSasResolve(null);108+    resolve?.(ok);109+    if (!ok) {110+      setPhase("password");111+      setPassword("");112+      setConfirmPassword("");113+      setStrengthScore(null);114+    }115+  }116+117+  return (118+    <section class="peach-screen peach-theme peach-adopt-vault">119+      <RuneBackground opacity={0.07} />120+      <div class="peach-adopt-card">121+        <Switch>122+          <Match when={phase() === "password"}>123+            <h1>Set a password for this device.</h1>124+            <p>This protects your vault on this device only. It can be different from your other devices.</p>125+            <label class="peach-label" for="adopt-password">password</label>126+            <input127+              id="adopt-password"128+              class="peach-underline-input peach-adopt-input"129+              type="password"130+              autocomplete="new-password"131+              value={password()}132+              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}133+            />134+            <label class="peach-label" for="adopt-confirm-password">confirm password</label>135+            <input136+              id="adopt-confirm-password"137+              class="peach-underline-input peach-adopt-input"138+              type="password"139+              autocomplete="new-password"140+              value={confirmPassword()}141+              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}142+            />143+            <Show when={createBlocker()}>144+              <p class="peach-hint">{createBlocker()}</p>145+            </Show>146+            <div class="peach-adopt-actions">147+              <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>148+                Continue149+              </button>150+              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>151+            </div>152+          </Match>153+154+          <Match when={phase() === "pair"}>155+            <div class="peach-adopt-birds" aria-hidden="true">156+              <span />157+              <span />158+            </div>159+            <h1>Pair with your other device.</h1>160+            <p>Open Peach on the device that has your vault. Bring both devices close together.</p>161+            <div class="peach-adopt-actions">162+              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void beginPairing()}>163+                Pair a device164+              </button>165+              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>166+            </div>167+          </Match>168+169+          <Match when={phase() === "sas"}>170+            <h1>Confirm this code matches on your other device.</h1>171+            <div class="peach-adopt-sas">{sas().replace(/(\d)(?=\d)/g, "$1 ")}</div>172+            <div class="peach-adopt-actions">173+              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => confirmSas(true)}>Confirm</button>174+              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => confirmSas(false)}>Cancel</button>175+            </div>176+          </Match>177+178+          <Match when={phase() === "success"}>179+            <div class="peach-adopt-birds" aria-hidden="true">180+              <span />181+              <span />182+            </div>183+            <h1>Joined your vault.</h1>184+            <p>Your passwords will sync shortly.</p>185+            <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>186+              Open Peach187+            </button>188+          </Match>189+190+          <Match when={phase() === "error"}>191+            <h1>Could not join your vault.</h1>192+            <p class="peach-hint peach-hint-error">{error()}</p>193+            <div class="peach-adopt-actions">194+              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => setPhase("password")}>Try again</button>195+              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>196+            </div>197+          </Match>198+        </Switch>199+      </div>200+    </section>201+  );202+}

view file

src/popup/views/Onboarding.tsx+27 −1
@@ -42,6 +42,8 @@4242 4343 interface OnboardingProps {4444   onComplete: () => void | Promise<void>;45+  onRestore?: () => void | Promise<void>;46+  onAdopt?: () => void | Promise<void>;4547 }4648 4749 export function Onboarding(props: OnboardingProps) {@@ -116,6 +118,30 @@116118             <span>{CARDS[card()]!.warning}</span>117119           </p>118120         </Show>121+        <Show when={card() === CARDS.length - 1}>122+          <div class="peach-onboarding-choice-row">123+            <button124+              type="button"125+              class="peach-onboarding-choice"126+              onClick={(e) => {127+                e.stopPropagation();128+                void props.onRestore?.();129+              }}130+            >131+              Restore from backup132+            </button>133+            <button134+              type="button"135+              class="peach-onboarding-choice peach-onboarding-choice--adopt"136+              onClick={(e) => {137+                e.stopPropagation();138+                void props.onAdopt?.();139+              }}140+            >141+              I already have Peach142+            </button>143+          </div>144+        </Show>119145       </div>120146 121147       <div class="peach-onboarding-nav">@@ -149,7 +175,7 @@149175           }}150176           disabled={completing()}151177         >152-          {card() === CARDS.length - 1 ? "Get started →" : "next →"}178+          {card() === CARDS.length - 1 ? "Create vault" : "next →"}153179         </button>154180       </div>155181     </section>

view file

src/popup/views/view-test-utils.tsx+2 −2
@@ -1,10 +1,10 @@11 import type { JSX } from "solid-js";22 import { render } from "solid-js/web";33 4-export function renderView(view: JSX.Element) {4+export function renderView(view: JSX.Element | (() => JSX.Element)) {55   const container = document.createElement("div");66   document.body.appendChild(container);7-  const dispose = render(() => view, container);7+  const dispose = render(() => (typeof view === "function" ? view() : view), container);88   return {99     container,1010     dispose: () => {

view file