From 93041873e3da1fef308067e69c4161caac769d2a Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:15:57 -0300 Subject: [PATCH 1/4] fix(a11y): meet WCAG contrast, motion and target-size rules A quality audit of the popup found colour, motion and focus problems. This fixes the ones that were real failures. Contrast (WCAG 1.4.3 text, 1.4.11 controls): - `--color-border-strong` draws the input and secondary-button outline, and at 1.59:1 in light and 2.74:1 in dark it was almost invisible. Now 3.09:1 and 3.28:1. - `--color-form-placeholder` and the light `--color-soft` sat just under 4.5:1 on some backgrounds. Both now clear it everywhere they are used. - The Activity tab count badge put white on `bg-accent` at 3.07:1. It uses `bg-primary` now. - New `--color-primary-text` for primary used as text or an icon. In dark, no single purple can both hold a white button label and read at 4.5:1 on the surface, so the two roles need separate values. Light is unchanged. Motion: - `prefers-reduced-motion` covered 3 of about 24 animations, so a reduced-motion user still got every sheet slide and an endless pulse on the endpoint rows. It now cuts all of them to one instant pass and keeps only the progress spinners turning. Keyboard and screen reader: - Views render as `
`, so the popup has a landmark. - The file dropzone shows a focus ring; its input is visually hidden but still tabbable, so focus used to land on nothing visible. - The wrong-password message in `ConfirmPasswordForm` is announced, and its id comes from `useId` instead of a constant that two open forms would duplicate. - The footer disconnect button was an 18px target, below the 24px minimum. The glyph stays the same size. Also adds a page description and theme colour to `index.html`, and states the extension page CSP in the manifest instead of relying on the Chrome default. --- index.html | 3 ++ public/manifest.json | 3 ++ src/App.tsx | 7 +++- src/components/AmountField.tsx | 2 +- src/components/ConfirmPasswordForm.tsx | 8 ++-- src/components/ConnectionFooter.tsx | 4 +- src/components/EndpointListRow.tsx | 2 +- src/components/HomeTabs.tsx | 2 +- src/components/PasswordStrengthIndicator.tsx | 2 +- src/components/SendTokenForm.tsx | 2 +- src/components/WelcomeHero.tsx | 2 +- src/components/ui/AddRowButton.tsx | 2 +- src/components/ui/Button.tsx | 8 ++-- src/components/ui/FileDropInput.tsx | 6 ++- src/components/ui/OptionList.tsx | 2 +- src/components/ui/Select.tsx | 2 +- src/components/ui/Stepper.tsx | 2 +- src/components/ui/ToastProvider.tsx | 6 ++- src/components/utils/UtilsList.tsx | 3 +- src/index.css | 41 +++++++++++++++----- src/views/AddNetworkAccount.tsx | 4 +- src/views/HomeView.tsx | 4 +- src/views/UnlockView.tsx | 4 +- src/views/onboarding/OnboardingFlow.tsx | 4 +- 24 files changed, 84 insertions(+), 41 deletions(-) diff --git a/index.html b/index.html index 9b3f9b4..831987d 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,9 @@ + + + Carpincho Wallet diff --git a/public/manifest.json b/public/manifest.json index 416ccac..0c8caa0 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -4,6 +4,9 @@ "description": "Local Canton wallet for development.", "version": "0.1.0", "permissions": ["storage", "activeTab", "favicon", "system.display"], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'" + }, "icons": { "16": "icons/carpincho-16.png", "32": "icons/carpincho-32.png", diff --git a/src/App.tsx b/src/App.tsx index cb3eb0d..a58ee28 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,10 +48,13 @@ const Shell = (): JSX.Element => { }, [showHeader]) if (view === 'loading') { return ( -
+
{SPINNER_ICON}

Loading...

-
+
) } return ( diff --git a/src/components/AmountField.tsx b/src/components/AmountField.tsx index 84e7eef..df5d511 100644 --- a/src/components/AmountField.tsx +++ b/src/components/AmountField.tsx @@ -49,7 +49,7 @@ export const AmountField = ({ type="button" data-testid={testId === undefined ? undefined : `${testId}-max`} onClick={onMax} - className="shrink-0 rounded-sm px-2 py-1 text-[0.72rem] font-bold uppercase tracking-wide text-primary transition-colors hover:bg-primary-soft focus-visible:outline-none focus-visible:shadow-focus" + className="shrink-0 rounded-sm px-2 py-1 text-[0.72rem] font-bold uppercase tracking-wide text-primary-text transition-colors hover:bg-primary-soft focus-visible:outline-none focus-visible:shadow-focus" > Max diff --git a/src/components/ConfirmPasswordForm.tsx b/src/components/ConfirmPasswordForm.tsx index f3c47eb..b821486 100644 --- a/src/components/ConfirmPasswordForm.tsx +++ b/src/components/ConfirmPasswordForm.tsx @@ -1,4 +1,4 @@ -import { type FormEvent, type ReactNode, useState } from 'react' +import { type FormEvent, type ReactNode, useId, useState } from 'react' import { PrimaryButton } from '@/components/ui/Button' import { PasswordInput } from '@/components/ui/PasswordInput' import { useVault } from '@/vault/useVault' @@ -28,6 +28,7 @@ export const ConfirmPasswordForm = ({ submitTestId, }: ConfirmPasswordFormProps): JSX.Element => { const v = useVault() + const errorId = useId() const [current, setCurrent] = useState('') const [error, setError] = useState(initialError) const hasError = error !== null @@ -52,7 +53,7 @@ export const ConfirmPasswordForm = ({ {hasError && (
+
{ onOpenChange={setConnectionOpen} /> )} -
+
) } diff --git a/src/views/HomeView.tsx b/src/views/HomeView.tsx index af59a81..c5bd4cd 100644 --- a/src/views/HomeView.tsx +++ b/src/views/HomeView.tsx @@ -140,7 +140,7 @@ export const HomeView = (): JSX.Element => { const connectedOrigin = dapp.kind === 'connected' ? dapp.origin : '' return ( -
+
@@ -213,6 +213,6 @@ export const HomeView = (): JSX.Element => { } onOpenSettings={() => setSettingsOpen(true)} /> -
+ ) } diff --git a/src/views/UnlockView.tsx b/src/views/UnlockView.tsx index 7a61bf5..a128abc 100644 --- a/src/views/UnlockView.tsx +++ b/src/views/UnlockView.tsx @@ -35,7 +35,7 @@ export const UnlockView = (): JSX.Element => { } return ( -
+
{ onConfirm={() => v.destroyVault()} /> -
+ ) } diff --git a/src/views/onboarding/OnboardingFlow.tsx b/src/views/onboarding/OnboardingFlow.tsx index a5690b8..567cc83 100644 --- a/src/views/onboarding/OnboardingFlow.tsx +++ b/src/views/onboarding/OnboardingFlow.tsx @@ -15,7 +15,7 @@ export const OnboardingFlow = (): JSX.Element => { const step = !v.hasVault ? 1 : !rpcConfirmed ? 2 : 3 return ( -
+
{ {step === 2 && setRpcConfirmed(true)} />} {step === 3 && }
-
+ ) } From 8648958102aa1bc652eb68cc0fc424b0ecfb0c23 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:21:17 -0300 Subject: [PATCH 2/4] perf(popup): load the json tree on demand `@uiw/react-json-view` is the heaviest import in the popup and only ever renders inside a sheet the user opened: contract arguments, a transfer payload, the send request, an approval prompt. It was in the main chunk, so every popup open paid to parse it. `JsonView` now imports it through `lazy` behind a `Suspense` fallback. Callers are untouched. The main chunk drops from 1,038 kB to 1,004 kB and the tree becomes a 34 kB chunk fetched on first use. The bordered container renders straight away, so the box does not resize when the tree arrives. The `JsonView` test now awaits the tree instead of reading the first frame. --- src/components/ui/JsonView.tsx | 27 +++++++++++++++++---------- test/components/JsonView.test.tsx | 5 +++-- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/components/ui/JsonView.tsx b/src/components/ui/JsonView.tsx index 99462af..c644fcc 100644 --- a/src/components/ui/JsonView.tsx +++ b/src/components/ui/JsonView.tsx @@ -1,7 +1,12 @@ -import { JsonView as RjvJsonView } from '@uiw/react-json-view' -import type { CSSProperties } from 'react' +import { type CSSProperties, lazy, Suspense } from 'react' import { cn } from '@/utils/cn' +// The json tree is the heaviest thing the popup imports and only ever renders inside a +// sheet the user opened, so it loads on demand instead of on every popup open. +const RjvJsonView = lazy(async () => ({ + default: (await import('@uiw/react-json-view')).JsonView, +})) + // Map the json tree's CSS variables onto the wallet's existing token palette. const THEME_STYLE: CSSProperties = { fontSize: '0.74rem', @@ -40,14 +45,16 @@ export const JsonView = ({ value, className }: JsonViewProps): JSX.Element => { )} > {isObject ? ( - + Loading...}> + + ) : (
           {value == null ? '' : String(value)}
diff --git a/test/components/JsonView.test.tsx b/test/components/JsonView.test.tsx
index 3f9f3b2..ba6858d 100644
--- a/test/components/JsonView.test.tsx
+++ b/test/components/JsonView.test.tsx
@@ -6,10 +6,11 @@ import { JsonView } from '@/components/ui/JsonView'
 describe('JsonView', () => {
   afterEach(cleanup)
 
-  it('renders object keys and values', () => {
+  it('renders object keys and values', async () => {
     render()
+    // The json tree is imported on demand, so the first frame is the Suspense fallback.
     // key renders in .w-rjv-object-key; value renders in .w-rjv-value — both are leaf spans
-    assert.ok(screen.getByText(/admin/))
+    assert.ok(await screen.findByText(/admin/))
     assert.ok(
       screen.getAllByText((_t, node) => node?.textContent?.includes('alice::party') ?? false)
         .length > 0,

From 2bde792b958e691bc59ffcb1d2ab363c440eb2ba Mon Sep 17 00:00:00 2001
From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com>
Date: Wed, 9 Sep 2026 14:23:34 -0300
Subject: [PATCH 3/4] docs: record the primary text token and reduced-motion
 rule

Two things a later change would otherwise trip over:

- `text-primary` no longer exists. Primary as a fill and primary as text
  need different values in dark, so they are separate tokens now.
- The reduced-motion block has to stay outside `@layer`. That is what
  beats the Tailwind animate-* utilities without `!important`, which
  Biome rejects.
---
 CLAUDE.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a80bd0d..2ca83e3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,7 +13,7 @@ This is the canonical agent configuration for the `carpincho-wallet` repository
 | Language | TypeScript (strict mode) | |
 | Framework | Vite 6 + React 18 | SPA; also builds as a Chrome extension |
 | Styling | Tailwind CSS v4 | `@tailwindcss/vite` plugin; utility classes inline in JSX; `src/index.css` declares semantic CSS variables on `:root` / `[data-theme='dark']`, rebinds the `dark:` variant via `@custom-variant`, exposes tokens to Tailwind through `@theme inline`, and holds `@layer base` resets plus named keyframes (`fade-in`, `slide-down-and-fade`, `slide-up-and-fade`, `sheet-up`, `sheet-slide-right`, `slide-in-right`, `slide-in-left`, `soft-pulse`, `drift`, `spin-fast`). A brand-tinted radial top-glow (`--bg-radial`) sits behind the page; there is no paper-grain overlay |
-| Theming | Light / Dark / System selector in the drawer menu (dappbooster brand palette) | `src/theme/ThemeProvider.tsx` owns a persisted `mode` (`light` \| `dark` \| `system`, default `system`), exposes `{ mode, setMode }` via `useTheme()`, resolves `system` against `prefers-color-scheme` (and re-resolves on media changes while in `system`), and writes the resolved `data-theme` on `` after mount. The selector lives in the drawer menu under Theme (`src/components/menu/ThemeMenu.tsx`); there is no header toggle. Use semantic colour utilities (`bg-surface`, `text-foreground`, `border-border`, `bg-primary-soft`, `bg-scrim`, etc.) so styles flip automatically. The dark theme is cool navy (`#14152b`) matching `dappbooster-canton-landing`; the light theme is neutral grey (`#f7f7f7`, primary `#692581`) matching the `dAppBooster` boilerplate. A shared purple→pink brand accent — `--bg-gradient-brand` (`linear-gradient(135deg, #c670e5, #e71d73)`) plus `--shadow-glow` — reveals on primary-button hover and tints the hero wordmark. `--color-success` is green (used for the connected-state indicator); `--color-scrim` is the theme-aware overlay tint used by modal/sheet backdrops |
+| Theming | Light / Dark / System selector in the drawer menu (dappbooster brand palette) | `src/theme/ThemeProvider.tsx` owns a persisted `mode` (`light` \| `dark` \| `system`, default `system`), exposes `{ mode, setMode }` via `useTheme()`, resolves `system` against `prefers-color-scheme` (and re-resolves on media changes while in `system`), and writes the resolved `data-theme` on `` after mount. The selector lives in the drawer menu under Theme (`src/components/menu/ThemeMenu.tsx`); there is no header toggle. Use semantic colour utilities (`bg-surface`, `text-foreground`, `border-border`, `bg-primary-soft`, `bg-scrim`, etc.) so styles flip automatically. The dark theme is cool navy (`#14152b`) matching `dappbooster-canton-landing`; the light theme is neutral grey (`#f7f7f7`, primary `#692581`) matching the `dAppBooster` boilerplate. A shared purple→pink brand accent — `--bg-gradient-brand` (`linear-gradient(135deg, #c670e5, #e71d73)`) plus `--shadow-glow` — reveals on primary-button hover and tints the hero wordmark. `--color-success` is green (used for the connected-state indicator); `--color-scrim` is the theme-aware overlay tint used by modal/sheet backdrops. **Primary has two tokens, and the split matters: `--color-primary` fills a surface (the primary button, the Switch, the Stepper dot) and `--color-primary-text` colours text and icons (`text-primary-text`). In dark, no single purple can both carry a white button label and read at 4.5:1 on the surface, so a bare `text-primary` utility no longer exists — reach for `text-primary-text`.** Colour changes are checked against WCAG AA: 4.5:1 for text, 3:1 for a control outline. `prefers-reduced-motion: reduce` cuts every animation to one instant pass from an unlayered block in `src/index.css`, which is what lets it beat the Tailwind `animate-*` utilities without `!important` (Biome rejects `!important`); the progress spinners are the one exemption |
 | Fonts | Self-hosted via `@fontsource-variable/manrope`, `@fontsource-variable/jetbrains-mono` | Imported once in `src/main.tsx`; works offline in the extension popup. Manrope is the entire UI: `font-display` for hero wordmarks, view-level headings, section markers (heavier weight for hierarchy) and `font-sans` for UI chrome / body / labels / buttons. `font-mono` (JetBrains Mono) is for party IDs, hashes, RPC URLs, JSON payloads, status eyebrows |
 | UI primitives | Radix UI | `@radix-ui/react-avatar`, `@radix-ui/react-collapsible`, `@radix-ui/react-dialog`, `@radix-ui/react-select`, `@radix-ui/react-switch`, `@radix-ui/react-tabs`, `@radix-ui/react-toast`, `@radix-ui/react-tooltip` for avatars (`DappIcon`, the dApp favicon with monogram fallback, used by `ConnectionFooter` and `ConnectedDappsMenu`), modals (incl. bottom sheets), the deadline dropdown, on/off toggles, tabs, toasts, and tooltips (auto-positioning with collision detection; `TooltipProvider` mounted once in `App.tsx`); styled with Tailwind via `data-[state=...]` and `data-[highlighted]` variants and animated through the `animate-*` tokens above. **When adding a new interactive component, check the [Radix UI primitives catalogue](https://www.radix-ui.com/primitives) first — prefer a Radix primitive over a hand-rolled implementation.** Local primitives in `src/components/ui/` (Button family, TextInput, PasswordInput, Alert (info / error / warning / success variants), Badge, Card, AccountAvatar, DappIcon, PendingActionCard, LoadingState, SectionLabel, Sheet, Tabs, Switch, Select, OptionList, Stepper, DangerConfirm, MenuRow, AddRowButton (the dashed "+ Add …" row that ends a list), SelectableListRow (row where the whole row selects and the trailing icon buttons stay clickable — accounts and endpoints), DetailRow, FileDropInput, Collapsible, Copyable, CopyableLabel, JsonView, ToastProvider, Tooltip) compose Tailwind utilities + Radix where applicable; reuse them before writing one-off styling. `TextInput` and `PasswordInput` accept an `error?: boolean` prop that applies a danger border + persistent focus ring (`--shadow-focus-danger`) and sets `aria-invalid`; use this for field-level error state instead of wrapping in an `Alert`. Icon SVG literals live in [`src/components/ui/icons.tsx`](src/components/ui/icons.tsx) (`X_ICON`, `BACK_ICON`, `MENU_ICON`, `CHECK_ICON`, `COPY_ICON`, `EYE_ICON`, `EYE_OFF_ICON`, `GLOBE_ICON`, `INFO_ICON`, `ALERT_TRIANGLE_ICON`, `ALERT_CIRCLE_ICON`, `SPINNER_ICON`, `REFRESH_ICON`, `SEARCH_ICON`, `DISCONNECT_ICON`, `CHEVRON_DOWN_ICON`, `CHEVRON_RIGHT_ICON`, `SEND_ICON`, `RECEIVE_ICON`, `RECEIPT_ICON`, `LOCK_ICON`, `PENCIL_ICON`, `TRASH_ICON`, `WALLET_CONNECT_ICON`, `CONTACTS_ICON`, `CREATE_ICON`, `EXERCISE_ICON`, `CONTRACTS_ICON`, `UPLOAD_ICON`, `DROPLET_ICON`); add new icons there instead of inlining ``. Custom width/height tokens (`w-popup`, `w-drawer`, `max-h-sheet`) are declared as `@utility` in `src/index.css`. `Sheet` wraps Radix Dialog with the shared overlay, title, and close-button chrome and takes `side: 'bottom' | 'right' | 'center'` (default `'bottom'`); the bottom variant uses `animate-sheet-up`, the right variant uses `animate-sheet-slide-right` (caps at 400px wide clamped by `100vw`, full-height, top-aligned content), and the center variant renders a centered modal dialog (used for account management, token detail, approval prompts, and danger confirmations) -- always use `Sheet` for sheet-style flows. `Button.tsx` also exports `GHOST_BUTTON_CLASS` and `ICON_BUTTON_CLASS` for places that need the interactive base without the button element. Larger shared building blocks live one level up in `src/components/` (`WelcomeHero` for the Setup/Unlock hero; `AccountCard`, `HomeTabs`, `ConnectionFooter` compose the Home view. The Home view is a fixed-height (`h-screen`) flex shell: the account selector and footer (no longer `fixed` — it lives in the column flow, bled full-width with `-mx-3`) stay pinned while only the active tab body scrolls. `HomeTabs` (the `Tabs` underline bar acts as the view title — no heading) holds three tabs, in order: `AssetsPanel` (token balances/holdings, with the `AutoAcceptSetting` auto-accept-incoming-transfers toggle pinned above balance-first token rows — each token row opens a centered `TokenDetailSheet` modal showing the balance, Send/Receive actions, and a per-UTXO holdings list; Send is a multi-step flow inside that same `TokenDetailSheet`: a controlled `SendTokenForm` (recipient field with an icon-only contacts button, an `AmountField` with a MAX button and spendable-balance line, a Radix `Select` deadline, a memo field with an info tooltip, and a Review action) → a `ContactsPicker` screen (the user's other accounts as a fixed 4-row list) → a `SendConfirm` screen (To/Amount/Expires/Memo summary, a collapsible "View data" raw-request payload, and Cancel/Confirm; confirming submits then closes the whole sheet), and Receive shows a party-id QR via `TokenReceive` / `react-qr-code`), `ActivityPanel` (pending CIP-56 transfers split by direction through `transferDirection`, pinned above the executed-transaction history: incoming transfers the active party can Accept under a "Needs action" group, and the party's own outgoing transfers shown read-only under "Awaiting acceptance" — a transfer to oneself stays incoming so it remains acceptable; each transfer's full metadata opens in a centered `TransferDetailsSheet` via an eye button, and accepting optimistically hides the card while a progress toast tracks the result; beneath the pending groups, `ActivityList` renders executed transactions as MetaMask-style rows grouped by day, each opening a centered detail `Sheet`), and `UtilsPanel` (Utils — developer ledger tools rendered as a scannable list where each item opens in a centered modal `Sheet`: Create contract, Exercise choice, Active contracts, and Upload DAR, plus a Tap Amulet faucet action row that requests 100 AMT and reports progress via toast — the faucet moved here from the Assets tab); Assets is the default landing tab. The Activity tab is force-mounted so its React Query polling keeps the incoming-transfer count badge live while hidden; the Assets tab is force-mounted too so the auto-accept toggle's optimistic state survives tab switches. WalletConnect URI pairing lives in the drawer via `WalletConnectMenu`, the first root menu screen, web-only; `ConnectedDappsMenu` is the extension-only root row that sits after Vault Management and before Lock Wallet, and lists every connected dApp origin with a per-row Disconnect behind a confirmation, which is the only way to disconnect a dApp whose tab is not the active one (the footer speaks for the active tab alone); both surfaces disconnect through `disconnectOrigin` in `src/extension/runtimeClient.ts`, which owns the failure toast; `PasswordStrengthIndicator` renders the live strength meter; `NewPasswordFields` owns the "new password + confirm + strength meter + validity callback" trio used by both the Setup form and the change-password form -- callers control the visible-vs-aria label mode and receive validity via `onValidityChange`). `ConnectionSettingsSheet` renders as a bottom `Sheet` from `HomeView` (and from `AddNetworkAccount`, which replaces Home when the network in use hosts no account and keeps the footer so switching endpoint is the way out) and owns the wallet-service endpoint list: rows (`EndpointListRow`) with a reachability dot, the name and the URL, where tapping a row makes that endpoint the one in use and closes the sheet, plus in-place add and edit screens (`EndpointForm`, with a Test button) and a centered remove confirmation; account management (add / remove / switch) lives in `AccountsDialog`, a centered `Sheet` opened from `AccountCard` (the active account is omitted from the switch list, since switching to the account you are already on is a no-op; the list holds only the accounts the network in use hosts). Neither is a full-view replacement. `MenuSheet` (the burger-button drawer, right-anchored, 400px wide capped at `100vw`) drives multi-screen flows by toggling an internal `Screen` state inside one `Sheet`; every new sub-section in the drawer must be added as another `Screen` entry (drill-down with title + back + close + `animate-slide-in-*` transitions) — no accordion-style expansion |
 | Feedback | Toast vs inline Alert vs error prop | Transient feedback from something the user just did (write results, async errors, network failures, copy confirmations) renders via `toast.*` from [`@/components/ui/toast.ts`](src/components/ui/toast.ts), never as inline `Alert`. A failed **read** is the exception: a `useQuery` error renders inline, where its data would have gone (`AssetsPanel`, `ActivityPanel`, `TokenDetailSheet`, `ActiveContractsUtil`), because React Query v5 gives a query no error callback. The only way to toast one is a `useEffect` on the derived message, and that effect stays silent when the same failure repeats and re-fires a cached error when the panel remounts. Mount the `ToastProvider` once in `App.tsx`. Field-level error state (mismatched passwords, wrong current password on a verify step, invalid input) uses the `error` prop on `TextInput` / `PasswordInput` (danger border + `aria-invalid`); pair it with `aria-errormessage` pointing at a sibling caption `

` when there is a human-readable reason to render. Inline `Alert` is reserved for form-level errors that cannot be attributed to a single field, for a failed read as above, and for persistent contextual hints inside cards. All callers (React components, WalletConnect handlers in `src/wc/`, vault errors, extension bridge) use the imperative `toast` import directly. Variant durations: non-critical variants (`info` / `success` / `warning`) auto-dismiss after 5 s; only `error` (must-read) persists until dismissed. `ToastProvider` owns that timer and passes Radix an infinite `duration`, because Radix's own timer pauses while the window is blurred or the pointer sits over the viewport band — in a popup this narrow that band overlaps the content, and a paused toast never left. Identical messages collapse — emitting a string message that matches a visible toast of the same variant replaces it with a fresh entry (so rapid repeats like copy clicks collapse to a single toast and restart its timer); distinct messages of the same variant (a copy confirmation vs an RPC test result) stack separately. Toasts render as elevated `bg-surface` cards (border + `shadow-popover`) with a variant-tinted icon badge and a left accent rail, sized to the app content width (`w-popup`). The message body caps at `max-h-40` and scrolls when longer; `error` toasts (only) carry a copy-icon button that copies the rendered message text. Max 3 visible, top-center, newest on top | From 15930cff8b48d6575d681aab5db2013501aea846 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:38:19 -0300 Subject: [PATCH 4/4] perf(popup): load the WalletConnect SDK on demand The WalletConnect SDK is the heaviest thing the wallet depends on, and pairing over it is web-only: in the extension a dApp talks to the wallet through the runtime bridge, and all three lifecycle effects return early. It still sat in the popup's startup chunk, so every popup open paid to parse about 400 kB it would never call. `src/wc/client.ts` is the only module that reaches the SDK, and every export there already awaits `getSignClient()`, so the packages move to `await import(...)` inside the function bodies. The type-only imports stay as `import type`, which the build erases. The extension's main chunk drops from 996 kB to 588 kB (313 kB to 191 kB gzipped). The SDK becomes on-demand chunks fetched the first time something pairs. Verified on the built bundle: nothing WalletConnect loads at popup open, the main chunk reaches the SDK only through a dynamic import, and the loaded chunk still hands back a working `Core`, a `SignClient` with `init`, `getSdkError` and the two json-rpc formatters. --- CLAUDE.md | 1 + src/wc/client.ts | 24 ++++++++++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2ca83e3..34f0261 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,7 @@ Every interactive or assertion-worthy element ships a stable `data-testid` so en - Dev server: `http://localhost:3011` - The extension/manifest version comes from this repo's `package.json` (single source of truth), injected at build time via the `__APP_VERSION__` Vite define and a manifest-rewrite plugin; never hardcode versions in `manifest.json` or source - The dApp wire format is typed by `@canton-network/core-types`. `src/extension/messages.ts` derives the page-facing frames from its `SpliceMessage` schema and pins the event names to its `WalletEvent` enum with `satisfies`; never hand-declare a message type the SDK already publishes. Import the SDK's *types* only — a runtime import drags zod into the content script, which loads on every page +- `src/wc/client.ts` imports every `@walletconnect` package dynamically (`await import(...)`), and the type-only lines use `import type`. The SDK is the heaviest dependency here and pairing over it is web-only, so a plain `import` of any `@walletconnect` package drags ~400 kB back into the popup's startup chunk. Every export in that module is already async, so a new one needs no new plumbing — keep the imports inside the function bodies - Chrome loads the content script (`contentScript.ts`) as a classic file, so it may not contain an `import` after bundling. `STANDALONE_SCRIPTS` in `vite.config.ts` builds each such script on its own as a single-entry IIFE, which is what lets it import shared modules. A new content script goes in that list and in `knip.json`'s entries, not in the main build's `rollupOptions.input` - The wallet sets no `window.canton` and declares no `world: "MAIN"` content script. That global is the dApp SDK's fast `detect()` path, and it was removed on purpose: it re-exposes wallet-installed to any script enumerating `window`, which is what dropping the load-time announce was for, and it only shortens the case that is already fast (an absent wallet sets no global and still costs the SDK's 2 s timeout). See `architecture.md`; `test/extensionManifest.test.ts` enforces it - `bin/` holds the gitleaks binary and is gitignored. Never commit it; `scripts/install-gitleaks.sh` puts it there on demand diff --git a/src/wc/client.ts b/src/wc/client.ts index ac07d5c..2ed8a9b 100644 --- a/src/wc/client.ts +++ b/src/wc/client.ts @@ -1,8 +1,11 @@ -import { Core } from '@walletconnect/core' -import { formatJsonRpcError, formatJsonRpcResult } from '@walletconnect/jsonrpc-utils' -import SignClient from '@walletconnect/sign-client' +// The WalletConnect SDK is the heaviest dependency the wallet has, and pairing over it is +// web-only: the extension talks to a dApp through the runtime bridge and never touches it. +// So every import of it below is dynamic and every export here is already async, which +// keeps the SDK out of the popup's startup path. `import type` is erased at build time, +// so the type-only lines cost nothing. Keep it that way — a plain `import` of any +// @walletconnect package puts the whole SDK back into the main chunk. +import type SignClient from '@walletconnect/sign-client' import type { SignClientTypes } from '@walletconnect/types' -import { getSdkError } from '@walletconnect/utils' import { getWalletServiceNetworkId } from '@/api/walletService' import { CIP103_EVENTS } from '@/provider/events' import type { ProviderResponder } from '@/provider/types' @@ -66,8 +69,12 @@ export const getSignClient = async (): Promise> } signClientProjectId = projectId signClientPromise = (async () => { + const [{ Core }, { default: SignClientCtor }] = await Promise.all([ + import('@walletconnect/core'), + import('@walletconnect/sign-client'), + ]) const core = new Core({ projectId, customStoragePrefix: 'carpincho-wallet' }) - return await SignClient.init({ + return await SignClientCtor.init({ core, metadata: { name: 'Carpincho Wallet', @@ -133,7 +140,10 @@ export const approveProposal = async (args: { } export const rejectProposal = async (proposalId: number): Promise => { - const client = await getSignClient() + const [client, { getSdkError }] = await Promise.all([ + getSignClient(), + import('@walletconnect/utils'), + ]) await client.reject({ id: proposalId, reason: getSdkError('USER_REJECTED') }) } @@ -142,6 +152,7 @@ export const respondWithResult = async ( requestId: number, result: T, ): Promise => { + const { formatJsonRpcResult } = await import('@walletconnect/jsonrpc-utils') await respond({ topic, response: formatJsonRpcResult(requestId, result) }) } @@ -151,6 +162,7 @@ export const respondWithError = async ( code: number, message: string, ): Promise => { + const { formatJsonRpcError } = await import('@walletconnect/jsonrpc-utils') await respond({ topic, response: formatJsonRpcError(requestId, { code, message }) }) }