(initialError)
const hasError = error !== null
@@ -52,7 +53,7 @@ export const ConfirmPasswordForm = ({
{hasError && (
{error}
diff --git a/src/components/ConnectionFooter.tsx b/src/components/ConnectionFooter.tsx
index c2ac230..9f1c115 100644
--- a/src/components/ConnectionFooter.tsx
+++ b/src/components/ConnectionFooter.tsx
@@ -82,7 +82,9 @@ export const ConnectionFooter = ({
title="Disconnect"
className={cn(
PLAIN_ICON_BUTTON_CLASS,
- 'ml-0.5 size-[18px] text-soft hover:bg-danger/10 hover:text-danger [&>svg]:size-[13px]',
+ // size-6 is the 24px WCAG 2.5.8 floor; the glyph stays small so the
+ // footer row does not grow around it.
+ '-my-1 ml-0.5 size-6 text-soft hover:bg-danger/10 hover:text-danger [&>svg]:size-[13px]',
)}
>
{DISCONNECT_ICON}
diff --git a/src/components/EndpointListRow.tsx b/src/components/EndpointListRow.tsx
index 4f1233e..f6121db 100644
--- a/src/components/EndpointListRow.tsx
+++ b/src/components/EndpointListRow.tsx
@@ -46,7 +46,7 @@ export const EndpointListRow = ({
actions={
<>
{active && (
-
+
{CHECK_ICON}
)}
diff --git a/src/components/HomeTabs.tsx b/src/components/HomeTabs.tsx
index 58de935..5091071 100644
--- a/src/components/HomeTabs.tsx
+++ b/src/components/HomeTabs.tsx
@@ -71,7 +71,7 @@ export const HomeTabs = ({
>
Activity
{pendingTransferCount > 0 ? (
-
+
{pendingTransferCount}
) : null}
diff --git a/src/components/PasswordStrengthIndicator.tsx b/src/components/PasswordStrengthIndicator.tsx
index bd4ee44..437bcb2 100644
--- a/src/components/PasswordStrengthIndicator.tsx
+++ b/src/components/PasswordStrengthIndicator.tsx
@@ -7,7 +7,7 @@ const FAIR: Tier = { segmentColor: 'bg-warning', labelColor: 'text-warning', lab
const STRONG: Tier = { segmentColor: 'bg-success', labelColor: 'text-success', label: 'Strong' }
const EXCELLENT: Tier = {
segmentColor: 'bg-primary',
- labelColor: 'text-primary',
+ labelColor: 'text-primary-text',
label: 'Excellent',
}
diff --git a/src/components/SendTokenForm.tsx b/src/components/SendTokenForm.tsx
index 2b6ff71..c8856e8 100644
--- a/src/components/SendTokenForm.tsx
+++ b/src/components/SendTokenForm.tsx
@@ -106,7 +106,7 @@ export const SendTokenForm = ({
data-testid="send-contacts"
aria-label="Contacts"
onClick={onOpenContacts}
- className="inline-flex items-center justify-center rounded-sm text-primary transition-colors hover:text-primary-hover focus-visible:outline-none focus-visible:shadow-focus"
+ className="inline-flex items-center justify-center rounded-sm text-primary-text transition-colors hover:text-primary-hover focus-visible:outline-none focus-visible:shadow-focus"
>
{CONTACTS_ICON}
diff --git a/src/components/WelcomeHero.tsx b/src/components/WelcomeHero.tsx
index b09f0f4..4516054 100644
--- a/src/components/WelcomeHero.tsx
+++ b/src/components/WelcomeHero.tsx
@@ -10,7 +10,7 @@ interface WelcomeHeroProps {
}
const WORDMARK_BASE =
- 'font-display font-semibold text-primary dark:bg-[image:var(--bg-gradient-brand)] dark:bg-clip-text dark:text-transparent leading-[1.02] tracking-[-0.03em] lowercase'
+ 'font-display font-semibold text-primary-text dark:bg-[image:var(--bg-gradient-brand)] dark:bg-clip-text dark:text-transparent leading-[1.02] tracking-[-0.03em] lowercase'
const DEFAULT_LOGO_SIZE: Record = {
stacked: 132,
diff --git a/src/components/ui/AddRowButton.tsx b/src/components/ui/AddRowButton.tsx
index 480fa7d..e282230 100644
--- a/src/components/ui/AddRowButton.tsx
+++ b/src/components/ui/AddRowButton.tsx
@@ -10,7 +10,7 @@ export const AddRowButton = ({ label, onClick, testId }: AddRowButtonProps): JSX
type="button"
data-testid={testId}
onClick={onClick}
- className="mt-1 flex w-full items-center justify-center gap-2 rounded-sm border border-dashed border-border p-2 font-semibold text-primary transition-colors hover:border-primary/40 hover:bg-primary-soft"
+ className="mt-1 flex w-full items-center justify-center gap-2 rounded-sm border border-dashed border-border p-2 font-semibold text-primary-text transition-colors hover:border-primary/40 hover:bg-primary-soft"
>
+ {label}
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
index f089165..5fe47e6 100644
--- a/src/components/ui/Button.tsx
+++ b/src/components/ui/Button.tsx
@@ -8,17 +8,17 @@ const BASE_INTERACTIVE =
'duration-200 ease-out active:scale-[0.98] disabled:active:scale-100 ' +
'focus-visible:outline-none focus-visible:shadow-focus'
-export const GHOST_BUTTON_CLASS = `${BASE_INTERACTIVE} border-0 bg-transparent text-primary enabled:hover:text-primary-hover text-[0.95rem] font-semibold p-0`
+export const GHOST_BUTTON_CLASS = `${BASE_INTERACTIVE} border-0 bg-transparent text-primary-text enabled:hover:text-primary-hover text-[0.95rem] font-semibold p-0`
export const ICON_BUTTON_CLASS =
'inline-grid place-items-center text-muted-foreground transition-colors ' +
- 'enabled:hover:text-primary enabled:hover:bg-primary-soft ' +
+ 'enabled:hover:text-primary-text enabled:hover:bg-primary-soft ' +
'focus-visible:outline-none focus-visible:shadow-focus'
// Like ICON_BUTTON_CLASS but no hover background fill — for icons inline beside text.
export const PLAIN_ICON_BUTTON_CLASS =
'inline-grid place-items-center rounded-sm text-muted-foreground transition-colors ' +
- 'hover:text-primary focus-visible:outline-none focus-visible:shadow-focus'
+ 'hover:text-primary-text focus-visible:outline-none focus-visible:shadow-focus'
// Primary chrome recoloured for destructive actions; layout (e.g. w-full) is left to the caller.
const DANGER_OVERRIDE =
@@ -32,7 +32,7 @@ const VARIANT_CLASS = {
'before:opacity-0 before:transition-opacity before:duration-200 enabled:hover:before:opacity-100',
secondary:
`${BASE_INTERACTIVE} py-2.5 px-4 leading-none rounded-md font-semibold text-[0.94rem] text-foreground ` +
- 'bg-surface border border-border-strong enabled:hover:bg-muted enabled:hover:text-primary',
+ 'bg-surface border border-border-strong enabled:hover:bg-muted enabled:hover:text-primary-text',
ghost: GHOST_BUTTON_CLASS,
} as const
diff --git a/src/components/ui/FileDropInput.tsx b/src/components/ui/FileDropInput.tsx
index 7afd066..1d29957 100644
--- a/src/components/ui/FileDropInput.tsx
+++ b/src/components/ui/FileDropInput.tsx
@@ -26,12 +26,14 @@ export const FileDropInput = ({
type="file"
accept={accept}
aria-label={ariaLabel}
- className="sr-only"
+ className="peer sr-only"
onChange={(event) => onSelect(event.currentTarget.files?.[0] ?? null)}
/>
+ {/* The input is visually hidden but still tabbable, so the dropzone has to show
+ its focus ring, or a keyboard user lands on nothing they can see. */}
{fileName === null ? (
{prompt}
diff --git a/src/components/ui/OptionList.tsx b/src/components/ui/OptionList.tsx
index b883bfe..a3f1189 100644
--- a/src/components/ui/OptionList.tsx
+++ b/src/components/ui/OptionList.tsx
@@ -26,7 +26,7 @@ export const OptionList = ({
className={cn(
MENU_ROW_BASE_CLASS,
'flex items-center justify-between text-left text-foreground',
- active && 'border-primary text-primary',
+ active && 'border-primary text-primary-text',
)}
>
{opt.label}
diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx
index ccff92a..541cd53 100644
--- a/src/components/ui/Select.tsx
+++ b/src/components/ui/Select.tsx
@@ -64,7 +64,7 @@ export const Select = ({
className={ITEM_CLASS}
>
{option.label}
-
+
{CHECK_ICON}
diff --git a/src/components/ui/Stepper.tsx b/src/components/ui/Stepper.tsx
index bed2665..aa46e07 100644
--- a/src/components/ui/Stepper.tsx
+++ b/src/components/ui/Stepper.tsx
@@ -13,7 +13,7 @@ const stateOf = (index: number, current: number): StepState =>
const NODE_CLASS: Record = {
complete: 'bg-primary border-primary text-primary-foreground',
- active: 'border-primary text-primary bg-primary-soft',
+ active: 'border-primary text-primary-text bg-primary-soft',
upcoming: 'border-border text-muted-foreground bg-surface',
}
diff --git a/src/components/ui/ToastProvider.tsx b/src/components/ui/ToastProvider.tsx
index a1f46f0..a54e40f 100644
--- a/src/components/ui/ToastProvider.tsx
+++ b/src/components/ui/ToastProvider.tsx
@@ -36,7 +36,11 @@ const BASE_TOAST_CLASS = cn(
// Per-variant accent: only the rail and icon badge carry colour; message text stays neutral.
const VARIANT_ACCENT: Record =
{
- info: { rail: 'before:bg-primary', badge: 'bg-primary-soft text-primary', icon: INFO_ICON },
+ info: {
+ rail: 'before:bg-primary',
+ badge: 'bg-primary-soft text-primary-text',
+ icon: INFO_ICON,
+ },
success: { rail: 'before:bg-success', badge: 'bg-success-soft text-success', icon: CHECK_ICON },
warning: {
rail: 'before:bg-warning',
diff --git a/src/components/utils/UtilsList.tsx b/src/components/utils/UtilsList.tsx
index ec98c30..727b764 100644
--- a/src/components/utils/UtilsList.tsx
+++ b/src/components/utils/UtilsList.tsx
@@ -50,7 +50,8 @@ const ROW_CLASS =
'flex w-full items-center gap-3 rounded-md border border-border bg-surface px-3 py-2.5 text-left ' +
'outline-none transition-colors enabled:hover:border-primary/60 enabled:hover:bg-primary-soft/40 ' +
'focus-visible:shadow-focus disabled:opacity-70'
-const ICON_WRAP_CLASS = 'grid size-9 shrink-0 place-items-center rounded-full bg-muted text-primary'
+const ICON_WRAP_CLASS =
+ 'grid size-9 shrink-0 place-items-center rounded-full bg-muted text-primary-text'
interface UtilsListProps {
account: AccountPublic
diff --git a/src/index.css b/src/index.css
index a618bf6..579c256 100644
--- a/src/index.css
+++ b/src/index.css
@@ -15,17 +15,21 @@
--color-muted: #ececf0;
--color-border: #e2e2ea;
- --color-border-strong: #ccccd6;
+ --color-border-strong: #8a8b9c;
--color-foreground: #2c2d40;
--color-muted-foreground: #4b4d60;
- --color-soft: #6b6d80;
+ --color-soft: #5f6175;
--color-primary: #692581;
--color-primary-hover: #8b46a4;
--color-primary-soft: #f3e8f8;
--color-primary-ink: #4a1a5c;
--color-primary-foreground: #ffffff;
+ /* Primary as text or an icon. Split from --color-primary because that one also
+ fills the primary button, and in dark no single purple can both carry a white
+ label (needs to stay dark) and read at 4.5:1 on the surface (needs to be light). */
+ --color-primary-text: #692581;
--logo-shadow-color: rgba(44, 45, 64, 0.5);
@@ -40,7 +44,7 @@
--color-warning: #8a6400;
--color-warning-soft: #f6ecd2;
- --color-form-placeholder: #6f7185;
+ --color-form-placeholder: #666879;
--color-scrim: rgba(20, 21, 43, 0.42);
@@ -69,17 +73,18 @@
--color-muted: #232539;
--color-border: #2c2e46;
- --color-border-strong: #5f6178;
+ --color-border-strong: #6b6d84;
--color-foreground: #fafbfc;
--color-muted-foreground: #c5c2cb;
--color-soft: #e2e0e7;
--color-primary: #9d54b8;
- --color-primary-hover: #b06cc9;
+ --color-primary-hover: #c58ada;
--color-primary-soft: #2a1f3a;
--color-primary-ink: #ecd9f5;
--color-primary-foreground: #ffffff;
+ --color-primary-text: #b87ad2;
--logo-shadow-color: rgba(0, 0, 0, 0.6);
@@ -94,7 +99,7 @@
--color-warning: #d4a85a;
--color-warning-soft: #332a16;
- --color-form-placeholder: #7a7c92;
+ --color-form-placeholder: #8d8fa4;
--color-scrim: rgba(8, 8, 18, 0.65);
@@ -129,6 +134,7 @@
--color-primary-soft: var(--color-primary-soft);
--color-primary-ink: var(--color-primary-ink);
--color-primary-foreground: var(--color-primary-foreground);
+ --color-primary-text: var(--color-primary-text);
--color-accent: var(--color-accent);
--color-success: var(--color-success);
--color-success-soft: var(--color-success-soft);
@@ -351,9 +357,26 @@
display: none;
}
- .animate-drift,
- .animate-logo-shadow {
- animation: none;
+ /*
+ * Everything that moves is cut to a single instant pass, so entry animations still
+ * land on their final frame (Radix reads animationend to unmount a closing sheet)
+ * and nothing loops. These rules sit outside any @layer, which is what lets them
+ * beat the Tailwind animate-* utilities without !important. The spinners are
+ * exempt: they report progress, not decoration.
+ */
+ *,
+ *::before,
+ *::after {
+ animation-delay: 0ms;
+ animation-duration: 1ms;
+ animation-iteration-count: 1;
+ transition-duration: 1ms;
+ }
+
+ .animate-spin,
+ .animate-spin-fast {
+ animation-duration: 900ms;
+ animation-iteration-count: infinite;
}
}
diff --git a/src/views/AddNetworkAccount.tsx b/src/views/AddNetworkAccount.tsx
index 76f9f7e..fd7fa15 100644
--- a/src/views/AddNetworkAccount.tsx
+++ b/src/views/AddNetworkAccount.tsx
@@ -25,7 +25,7 @@ export const AddNetworkAccount = (): JSX.Element => {
})
return (
-
+
{
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 }) })
}