feat(desktop): Lightning wallet UX prototype behind wallet preview flag

First visual prototype of the Bitcoin-in-Buzz PRD (see #buzz-lightning):

- WalletBalanceChip in the top-right of AppTopChrome showing the user's
  global ₿ balance; opens the wallet panel in a popover.
- WalletPanel with three views: overview (provider badge, prominent
  balance, transaction history, hidden-by-default recovery-phrase
  reveal), fund (BIP-321 QR encoding a BOLT12 offer with BOLT11
  fallback, copy/refresh offer), and send (BOLT12/BOLT11 destination
  plus ₿ amount).
- All data comes from a mock wallet-provider module — no SDK, no keys,
  no network. Pure UX prototype for the wallet-provider abstraction the
  PRD defines (Lexe is the v1 integration target).
- Gated behind a new `wallet` preview feature, off by default.
- Playwright screenshot spec covers the chip and all three panel views.

Co-authored-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
npub1885neck555704ngfny8trcdweaxe6spxjx8uweftd86spn0fz67slrg8q8
2026-07-26 18:13:42 +02:00
co-authored by Thomas Petersen
parent 74b63e1846
commit 0b91a093d4
7 changed files with 553 additions and 0 deletions
+1
View File
@@ -120,6 +120,7 @@ export default defineConfig({
"**/inbox-live-update.spec.ts",
"**/mesh-compute.spec.ts",
"**/observer-archive-policy.spec.ts",
"**/wallet-screenshots.spec.ts",
],
use: {
...devices["Desktop Chrome"],
+7
View File
@@ -11,6 +11,8 @@ import { useIsFullscreen } from "@/shared/lib/useIsFullscreen";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn";
import { topChromeBackdrop } from "@/shared/layout/chromeLayout";
import { FeatureGate } from "@/shared/features";
import { WalletBalanceChip } from "@/features/wallet/ui/WalletBalanceChip";
import { useOptionalSidebar } from "@/shared/ui/sidebar";
type AppTopChromeProps = {
@@ -131,6 +133,11 @@ export function AppTopChrome({
<ChevronRight />
</Button>
</div>
<div className="ml-auto flex items-center">
<FeatureGate feature="wallet">
<WalletBalanceChip />
</FeatureGate>
</div>
</div>
);
}
@@ -0,0 +1,111 @@
// Mock wallet-provider data backing the Lightning wallet UI prototype.
//
// The PRD (see #buzz-lightning) defines a wallet-provider abstraction that
// any conforming Lightning SDK can plug into (Lexe is the v1 target). This
// module stands in for that provider so the UX can be designed and reviewed
// before any SDK is integrated. Everything here is static, deterministic
// fixture data — no keys, no network, no money.
export type WalletTransactionKind = "zap" | "kudos" | "send" | "fund";
export interface WalletTransaction {
id: string;
direction: "receive" | "send";
amountSats: number;
counterparty: string;
memo: string;
/** Pre-rendered relative time label; static so screenshots stay stable. */
timeLabel: string;
kind: WalletTransactionKind;
}
export interface WalletSnapshot {
providerName: string;
balanceSats: number;
/** BOLT12 offer — the core send/receive primitive per the PRD. */
bolt12Offer: string;
/**
* BIP-321 funding URI carrying the amountless BOLT12 offer plus a BOLT11
* fallback so external wallets without BOLT12 support can still fund.
*/
bip321Uri: string;
/** 12-word BIP-39 seed phrase (fixture — never a real key). */
seedPhrase: string;
transactions: WalletTransaction[];
}
/** Format sats with the ₿ symbol, per the PRD ("use ₿ instead of sats"). */
export function formatBtc(sats: number): string {
return `₿${sats.toLocaleString("en-US")}`;
}
const MOCK_BOLT12_OFFER =
"lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcgqgn3qzsyvfkx26qkyypvr5hfx4gdhjy7uq9jjjrrjkflcgwzycqqqqqqqqqqqqqqqqqqqq";
const MOCK_BOLT11_FALLBACK =
"lnbc1pn9qw2app5x7rlq3mmkwvpksct5v4kxz6ryqdq5g9kxy7fqd9h8vmmfvdjsqqqqqqqqqqqq";
export const MOCK_WALLET: WalletSnapshot = {
providerName: "Lexe",
balanceSats: 21_450,
bolt12Offer: MOCK_BOLT12_OFFER,
bip321Uri: `bitcoin:?lno=${MOCK_BOLT12_OFFER}&lightning=${MOCK_BOLT11_FALLBACK}`,
seedPhrase:
"narrow ensure hollow tribe fiscal vault lyrics aunt shrug wagon rocket kite",
transactions: [
{
id: "tx-01",
direction: "receive",
amountSats: 100,
counterparty: "Eva",
memo: "Zap · research synthesis",
timeLabel: "12m ago",
kind: "zap",
},
{
id: "tx-02",
direction: "receive",
amountSats: 500,
counterparty: "Tyler",
memo: "Kudos · shipping the harness fix",
timeLabel: "2h ago",
kind: "kudos",
},
{
id: "tx-03",
direction: "send",
amountSats: 250,
counterparty: "Dawn",
memo: "Shared agent invocation",
timeLabel: "5h ago",
kind: "send",
},
{
id: "tx-04",
direction: "receive",
amountSats: 1_000,
counterparty: "Mat",
memo: "Gifting channel · welcome to #spiral",
timeLabel: "1d ago",
kind: "fund",
},
{
id: "tx-05",
direction: "send",
amountSats: 100,
counterparty: "Wren",
memo: "Zap · rug-test writeup",
timeLabel: "2d ago",
kind: "zap",
},
{
id: "tx-06",
direction: "receive",
amountSats: 20_200,
counterparty: "External wallet",
memo: "Wallet funded via BIP-321",
timeLabel: "3d ago",
kind: "fund",
},
],
};
@@ -0,0 +1,33 @@
import { Zap } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { formatBtc, MOCK_WALLET } from "../lib/mockWallet";
import { WalletPanel } from "./WalletPanel";
// Top-right wallet affordance: the user's global bitcoin balance, always
// visible in the app chrome ("running score" per the PRD's top-level balance
// discussion). Clicking it opens the wallet panel in a popover.
//
// Fixed height on purpose: this chip lives in the top chrome beside the
// px-pinned nav buttons (see `AppTopChrome`), so it follows the same
// deliberate exception to the rem-first rule.
export function WalletBalanceChip() {
return (
<Popover>
<PopoverTrigger asChild>
<button
aria-label={`Wallet balance ${formatBtc(MOCK_WALLET.balanceSats)}`}
className="flex h-[28px] items-center gap-1.5 rounded-full border border-border/60 bg-background/60 px-3 text-xs font-semibold tabular-nums text-sidebar-foreground/90 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
data-testid="wallet-balance-chip"
type="button"
>
<Zap className="size-3.5 text-amber-500" />
{formatBtc(MOCK_WALLET.balanceSats)}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-88" sideOffset={8}>
<WalletPanel />
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,306 @@
import * as React from "react";
import {
ArrowDownLeft,
ArrowUpRight,
ChevronLeft,
Copy,
Eye,
EyeOff,
QrCode,
RefreshCw,
Send,
} from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Separator } from "@/shared/ui/separator";
import { StyledQrCode } from "@/shared/ui/styled-qr-code";
import {
formatBtc,
MOCK_WALLET,
type WalletTransaction,
} from "../lib/mockWallet";
// Wallet panel prototype per the Bitcoin-in-Buzz PRD MVP list: show the
// provider, show the balance prominently, fund via a BIP-321 QR, send to a
// BOLT12/BOLT11/offer string, transaction history, and a hidden-by-default
// seed-phrase reveal. Backed entirely by mock provider data — no funds move.
type WalletView = "overview" | "fund" | "send";
function TransactionRow({ tx }: { tx: WalletTransaction }) {
const isReceive = tx.direction === "receive";
return (
<div
className="flex items-center gap-3 py-2"
data-testid={`wallet-tx-${tx.id}`}
>
<div
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full",
isReceive
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground",
)}
>
{isReceive ? (
<ArrowDownLeft className="size-4" />
) : (
<ArrowUpRight className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{tx.counterparty}</p>
<p className="truncate text-xs text-muted-foreground">{tx.memo}</p>
</div>
<div className="shrink-0 text-right">
<p
className={cn(
"text-sm font-semibold tabular-nums",
isReceive
? "text-emerald-600 dark:text-emerald-400"
: "text-foreground",
)}
>
{isReceive ? "+" : "−"}
{formatBtc(tx.amountSats)}
</p>
<p className="text-xs text-muted-foreground">{tx.timeLabel}</p>
</div>
</div>
);
}
function PanelHeader({
onBack,
title,
}: {
onBack?: () => void;
title: string;
}) {
return (
<div className="flex items-center gap-1">
{onBack ? (
<Button
aria-label="Back to wallet overview"
data-testid="wallet-panel-back"
onClick={onBack}
size="icon-xs"
variant="ghost"
>
<ChevronLeft />
</Button>
) : null}
<h2 className="text-sm font-semibold">{title}</h2>
</div>
);
}
function OverviewView({
onFund,
onSend,
}: {
onFund: () => void;
onSend: () => void;
}) {
const [seedRevealed, setSeedRevealed] = React.useState(false);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<PanelHeader title="Wallet" />
<Badge data-testid="wallet-provider-badge" variant="outline">
{MOCK_WALLET.providerName}
</Badge>
</div>
<div className="flex flex-col items-center gap-1 py-2">
<p
className="text-3xl font-bold tabular-nums tracking-tight"
data-testid="wallet-panel-balance"
>
{formatBtc(MOCK_WALLET.balanceSats)}
</p>
<p className="text-xs text-muted-foreground">
Self-custodial · spans all your communities
</p>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
data-testid="wallet-fund-button"
onClick={onFund}
variant="outline"
>
<QrCode className="size-4" />
Fund
</Button>
<Button data-testid="wallet-send-button" onClick={onSend}>
<Send className="size-4" />
Send
</Button>
</div>
<Separator />
<div>
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Recent activity
</h3>
</div>
<div
className="max-h-64 divide-y divide-border/40 overflow-y-auto"
data-testid="wallet-tx-history"
>
{MOCK_WALLET.transactions.map((tx) => (
<TransactionRow key={tx.id} tx={tx} />
))}
</div>
</div>
<Separator />
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Recovery phrase
</h3>
<Button
data-testid="wallet-seed-toggle"
onClick={() => setSeedRevealed((v) => !v)}
size="xs"
variant="ghost"
>
{seedRevealed ? (
<EyeOff className="size-3.5" />
) : (
<Eye className="size-3.5" />
)}
{seedRevealed ? "Hide" : "Reveal"}
</Button>
</div>
{seedRevealed ? (
<p
className="rounded-lg border border-border/60 bg-muted/40 p-3 font-mono text-xs leading-relaxed"
data-testid="wallet-seed-phrase"
>
{MOCK_WALLET.seedPhrase}
</p>
) : (
<p className="text-xs text-muted-foreground">
Hidden. Anyone with these 12 words can spend your bitcoin.
</p>
)}
</div>
</div>
);
}
function FundView({ onBack }: { onBack: () => void }) {
return (
<div className="flex flex-col gap-4">
<PanelHeader onBack={onBack} title="Fund wallet" />
<div className="mx-auto rounded-lg border border-border/70 bg-white p-3">
<StyledQrCode
data-testid="wallet-fund-qr"
size={208}
title="Fund wallet QR code"
value={MOCK_WALLET.bip321Uri}
/>
</div>
<p className="text-center text-xs text-muted-foreground">
Scan with any Lightning wallet. Encodes a reusable BOLT12 offer with a
BOLT11 fallback (BIP-321).
</p>
<div className="grid grid-cols-2 gap-2">
<Button
data-testid="wallet-copy-offer"
onClick={() => {
void navigator.clipboard.writeText(MOCK_WALLET.bolt12Offer);
}}
variant="outline"
>
<Copy className="size-4" />
Copy offer
</Button>
<Button data-testid="wallet-refresh-offer" variant="outline">
<RefreshCw className="size-4" />
Refresh offer
</Button>
</div>
</div>
);
}
function SendView({ onBack }: { onBack: () => void }) {
const [destination, setDestination] = React.useState("");
const [amount, setAmount] = React.useState("");
const canSend = destination.trim().length > 0 && amount.trim().length > 0;
return (
<div className="flex flex-col gap-4">
<PanelHeader onBack={onBack} title="Send bitcoin" />
<div className="flex flex-col gap-2">
<label
className="text-xs font-medium text-muted-foreground"
htmlFor="wallet-send-destination"
>
To — BOLT12 offer or BOLT11 invoice
</label>
<Input
data-testid="wallet-send-destination"
id="wallet-send-destination"
onChange={(event) => setDestination(event.target.value)}
placeholder="lno1… or lnbc1…"
value={destination}
/>
</div>
<div className="flex flex-col gap-2">
<label
className="text-xs font-medium text-muted-foreground"
htmlFor="wallet-send-amount"
>
Amount (₿)
</label>
<Input
data-testid="wallet-send-amount"
id="wallet-send-amount"
inputMode="numeric"
onChange={(event) => setAmount(event.target.value)}
placeholder="100"
value={amount}
/>
</div>
<Button data-testid="wallet-send-confirm" disabled={!canSend}>
<Send className="size-4" />
Send
</Button>
<p className="text-center text-xs text-muted-foreground">
Prototype — no funds move.
</p>
</div>
);
}
export function WalletPanel() {
const [view, setView] = React.useState<WalletView>("overview");
const backToOverview = React.useCallback(() => setView("overview"), []);
return (
<div data-testid="wallet-panel">
{view === "overview" ? (
<OverviewView
onFund={() => setView("fund")}
onSend={() => setView("send")}
/>
) : view === "fund" ? (
<FundView onBack={backToOverview} />
) : (
<SendView onBack={backToOverview} />
)}
</div>
);
}
@@ -0,0 +1,87 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
const SHOTS = "test-results/wallet";
// Screenshot evidence for the Lightning wallet prototype: the top-chrome
// balance chip and each wallet panel view (overview, fund QR, send form).
// The `wallet` preview feature is seeded on by the mock bridge.
test.describe("wallet prototype screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
async function openApp(page: import("@playwright/test").Page) {
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
test("01 balance chip in top-right chrome", async ({ page }) => {
await openApp(page);
const chip = page.getByTestId("wallet-balance-chip");
await expect(chip).toBeVisible();
await expect(chip).toContainText("₿21,450");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/01-balance-chip.png` });
});
test("02 wallet overview panel", async ({ page }) => {
await openApp(page);
await page.getByTestId("wallet-balance-chip").click();
const panel = page.getByTestId("wallet-panel");
await expect(panel).toBeVisible();
await expect(page.getByTestId("wallet-panel-balance")).toHaveText(
"₿21,450",
);
await expect(page.getByTestId("wallet-provider-badge")).toHaveText("Lexe");
await expect(page.getByTestId("wallet-tx-tx-01")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/02-wallet-overview.png` });
});
test("03 seed phrase reveal", async ({ page }) => {
await openApp(page);
await page.getByTestId("wallet-balance-chip").click();
await expect(page.getByTestId("wallet-seed-phrase")).toHaveCount(0);
await page.getByTestId("wallet-seed-toggle").click();
await expect(page.getByTestId("wallet-seed-phrase")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/03-seed-reveal.png` });
});
test("04 fund wallet QR", async ({ page }) => {
await openApp(page);
await page.getByTestId("wallet-balance-chip").click();
await page.getByTestId("wallet-fund-button").click();
await expect(page.getByTestId("wallet-fund-qr")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-fund-qr.png` });
});
test("05 send bitcoin form", async ({ page }) => {
await openApp(page);
await page.getByTestId("wallet-balance-chip").click();
await page.getByTestId("wallet-send-button").click();
const confirm = page.getByTestId("wallet-send-confirm");
await expect(confirm).toBeDisabled();
await page.getByTestId("wallet-send-destination").fill("lno1qgsq…example");
await page.getByTestId("wallet-send-amount").fill("500");
await expect(confirm).toBeEnabled();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/05-send-form.png` });
});
});
+8
View File
@@ -40,6 +40,14 @@
"platforms": [
"desktop"
]
},
{
"id": "wallet",
"name": "Bitcoin Wallet",
"description": "Self-custodial Lightning wallet prototype: balance in the top chrome, fund, send, and transaction history",
"platforms": [
"desktop"
]
}
]
}