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
↓ peach-browser-bae5bc00.tar.gzatlas at this save
files changed10 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 −2manifest.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": {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",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",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 >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"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;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+});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+}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>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: () => {