diff --git a/CLAUDE.md b/CLAUDE.md index a80bd0d..34f0261 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 | @@ -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/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 && (
           {value == null ? '' : String(value)}
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 && }
-
+ ) } 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 }) }) } 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,