diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e59c68bfa..cb077c13c 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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"], diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx index 845c403b8..4b7ec69f9 100644 --- a/desktop/src/app/AppTopChrome.tsx +++ b/desktop/src/app/AppTopChrome.tsx @@ -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({ +
+ + + +
); } diff --git a/desktop/src/features/wallet/lib/mockWallet.ts b/desktop/src/features/wallet/lib/mockWallet.ts new file mode 100644 index 000000000..15f13fbc4 --- /dev/null +++ b/desktop/src/features/wallet/lib/mockWallet.ts @@ -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", + }, + ], +}; diff --git a/desktop/src/features/wallet/ui/WalletBalanceChip.tsx b/desktop/src/features/wallet/ui/WalletBalanceChip.tsx new file mode 100644 index 000000000..ea5577eeb --- /dev/null +++ b/desktop/src/features/wallet/ui/WalletBalanceChip.tsx @@ -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 ( + + + + + + + + + ); +} diff --git a/desktop/src/features/wallet/ui/WalletPanel.tsx b/desktop/src/features/wallet/ui/WalletPanel.tsx new file mode 100644 index 000000000..dd98c8973 --- /dev/null +++ b/desktop/src/features/wallet/ui/WalletPanel.tsx @@ -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 ( +
+
+ {isReceive ? ( + + ) : ( + + )} +
+
+

{tx.counterparty}

+

{tx.memo}

+
+
+

+ {isReceive ? "+" : "−"} + {formatBtc(tx.amountSats)} +

+

{tx.timeLabel}

+
+
+ ); +} + +function PanelHeader({ + onBack, + title, +}: { + onBack?: () => void; + title: string; +}) { + return ( +
+ {onBack ? ( + + ) : null} +

{title}

+
+ ); +} + +function OverviewView({ + onFund, + onSend, +}: { + onFund: () => void; + onSend: () => void; +}) { + const [seedRevealed, setSeedRevealed] = React.useState(false); + + return ( +
+
+ + + {MOCK_WALLET.providerName} + +
+ +
+

+ {formatBtc(MOCK_WALLET.balanceSats)} +

+

+ Self-custodial · spans all your communities +

+
+ +
+ + +
+ + + +
+
+

+ Recent activity +

+
+
+ {MOCK_WALLET.transactions.map((tx) => ( + + ))} +
+
+ + + +
+
+

+ Recovery phrase +

+ +
+ {seedRevealed ? ( +

+ {MOCK_WALLET.seedPhrase} +

+ ) : ( +

+ Hidden. Anyone with these 12 words can spend your bitcoin. +

+ )} +
+
+ ); +} + +function FundView({ onBack }: { onBack: () => void }) { + return ( +
+ +
+ +
+

+ Scan with any Lightning wallet. Encodes a reusable BOLT12 offer with a + BOLT11 fallback (BIP-321). +

+
+ + +
+
+ ); +} + +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 ( +
+ +
+ + setDestination(event.target.value)} + placeholder="lno1… or lnbc1…" + value={destination} + /> +
+
+ + setAmount(event.target.value)} + placeholder="100" + value={amount} + /> +
+ +

+ Prototype — no funds move. +

+
+ ); +} + +export function WalletPanel() { + const [view, setView] = React.useState("overview"); + const backToOverview = React.useCallback(() => setView("overview"), []); + + return ( +
+ {view === "overview" ? ( + setView("fund")} + onSend={() => setView("send")} + /> + ) : view === "fund" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/desktop/tests/e2e/wallet-screenshots.spec.ts b/desktop/tests/e2e/wallet-screenshots.spec.ts new file mode 100644 index 000000000..18d6bc54b --- /dev/null +++ b/desktop/tests/e2e/wallet-screenshots.spec.ts @@ -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` }); + }); +}); diff --git a/preview-features.json b/preview-features.json index ad8090d5a..87a3aa766 100644 --- a/preview-features.json +++ b/preview-features.json @@ -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" + ] } ] }