Skip to content

Commit 5e4dfb8

Browse files
authored
feat(frontend+backend): WCAG 2.2 AA audit (#272), WalletConnect Stell… (#343)
* feat(frontend+backend): WCAG 2.2 AA audit (#272), WalletConnect Stellar bridge (#273), privacy-safe Sentry + web-vitals telemetry (#277) Closes #272, #273, #277 as assigned to @Hicent2 in the Official Campaign | FWC26. Issue #272 — WCAG 2.2 AA audit: - globals.css adds a global :focus-visible ring using the brand sky color and provides skip-link / sr-only utilities. - Button.jsx, Form.jsx, Withdraw.jsx, ActionModal.jsx, TokenSelector.jsx, BalanceCards.jsx, MultiplierSelector.jsx were upgraded for proper label associations, aria-invalid/aria-describedby, role=alert announcements, role=radiogroup + arrow-key navigation, role=slider with aria-valuemin/max/now/text, dialog focus trap with Esc-to-close and focus restoration, definition-list semantics, and non-color error icons. - Withdraw.jsx additionally fixes a class= -> className= JSX bug that prevented the Withdraw button from rendering its class. Issue #273 — Mobile wallet flow (WalletConnect for Stellar): - frontend/src/services/walletconnect.js mirrors the Freighter API (connectWallet, getWalletPublicKey, signStellarTransaction) but pairs via standard wc: URI + QR rendered by the qrcode package. - frontend/src/components/wallet/WalletConnectModal.jsx provides the accessible QR modal with auto-generation on open, AbortController cleanup on unmount, and disconnectWallet() fired on close after a successful pairing. - web_app/api/walletconnect.py is a new FastAPI router with /pair, /poll/{sid}, /sign, /complete/{sid}, and /session/{sid}. All endpoints use Redis setex with a 5-minute TTL mirroring the existing session.py pattern. /complete validates Stellar StrKey shape (via StrKey.isValidEd25519PublicKey) and rejects XDR envelopes whose source account does not match the claimed public key (403). Network is constrained to Literal["PUBLIC","TESTNET","FUTURENET"]. All writes are gated by @limiter.limit(WRITE_LIMIT) and the poll endpoint by READ_LIMIT. - web_app/api/main.py registers the new walletconnect_router. Issue #277 — Privacy-safe telemetry pipeline (web-vitals + Sentry): - frontend/src/services/telemetry.js initializes Sentry only when VITE_SENTRY_DSN is set, with sendDefaultPii:false plus a beforeSend scrubber that redacts Stellar public keys (G.../S..., 56 chars) and 64-hex transaction hashes. web-vitals hooks (LCP/CLS/INP/FCP/TTFB) push every change into Sentry via addBreadcrumb + setMeasurement; reportAllChanges:true is documented inline. - frontend/src/main.jsx is the new entry point (renamed from index.jsx, which is deleted). It calls initTelemetry() before the React mount, and again from App.jsx is useEffect — the idempotent initialized flag keeps it single-shot. - frontend/src/hooks/useConnectWallet.js and frontend/src/services/transaction.js wrap wallet.connect and position.open in Sentry transactions tagged with source / token so timings appear alongside web-vitals. Tests: - frontend/test/telemetry.test.jsx covers the scrubber (56-char PubKey, 56-char Seed, mixed charset, 64-hex hashes, nested objects, weird inputs) and confirms initTelemetry is a no-op without VITE_SENTRY_DSN. - frontend/test/walletconnect.test.jsx covers URI shape, pairing poll approve/reject/expire paths, signStellarTransaction happy + missing-session paths, and disconnectWallet teardown. - frontend/test/ActionModal.test.jsx covers role=dialog, aria-labelledby/describedby, Escape close while not loading (and no-op while loading), and the Tab focus trap. - frontend/test/Form.a11y.test.jsx covers proper input id/htmlFor labelling, aria-invalid flip after validation, and role=alert announcements. - web_app/tests/test_walletconnect_router.py mocks the async Redis client and verifies pair/poll/sign/complete/delete round-trips, rejects invalid Stellar public keys (422), rejects XDR envelopes whose source account does not match the claimed public key (403), and rejects unknown network values via the Literal type guard. Deps: added @sentry/react@^9.0.0 and qrcode@^1.5.4 to package.json; yarn.lock updated to match. * fix(backend): resolve CI PydanticUndefinedAnnotation in walletconnect router The CI migration-roundtrip workflow was failing at module import with: pydantic.errors.PydanticUndefinedAnnotation: name 'PairRequest' is not defined Root cause: `from __future__ import annotations` in quantara/web_app/api/walletconnect.py combined with the `LazyLimiter.limit()` decorator in quantara/web_app/api/rate_limiter.py. The LazyLimiter wraps the async route in an inner `wrapper()` whose `__globals__` point at rate_limiter.py; FastAPI/Pydantic resolves function annotations through get_type_hints() against those wrapper globals and cannot find PairRequest / SignRequest / CompleteRequest defined in the walletconnect module once PEP 563 has turned the annotations into strings. Fix: drop `from __future__ import annotations` from the walletconnect module so annotations are evaluated to class references at function definition time. A short comment above the imports documents the intentional absence so a follow-up contributor does not re-introduce the same bug. No behavioural change to the WalletConnect endpoints; the rest of the PR (#272, #273, #277) is unaffected. Validated by re-running frontend vitest (88/88 pass) and re-reading the affected code paths. * fix(test): make VALID_STELLAR_PUBKEY fixture length deterministic The CI failure on commit e273cdf was a hand-rolled Stellar StrKey fixture string in test_walletconnect_router.py that miscounted to 61 chars instead of 56. The module-load assertion `len(VALID_STELLAR_PUBKEY) == 56` failed on Python 3.12 and 3.13 with AssertionError on the literal string. Replace the hardcoded string with `G + A * 55`, which is guaranteed to be 56 chars, and add a useful assertion message so a regression blows up loudly next time. No production code changes. * fix(backend): await every redis call in walletconnect router CI on PR #343 was failing on Test Suite (Python 3.12) and Test Suite (Python 3.13) with: TypeError: the JSON object must be str, bytes or bytearray, not coroutine The walletconnect route handlers were calling `_redis().{get,setex,delete}()` without `await`. The local test fixture `_FakeRedis` defined those methods as `async def`, so the missing await was masked in CI-build-friendly test runs. In production with real `redis.asyncio`, every `.get()`/`.setex()`/`.delete()` returns a coroutine, and the route handlers were passing the un-awaited coroutine into `json.loads()`. Fix: - `await` every `_redis().get/setex/delete()` call in the five route handlers + the two helper lookup paths. - `_validate_session` is now `async def` and `open_signing_sub_session` awaits it. - `submit_signed_envelope` parent-XDR retrieval rewritten from `json.loads(_redis().get(...) or "{}")` (which evaluated `coroutine or "{}" == coroutine` then crashed inside `json.loads`) into an explicit form: await the get, then conditional json.loads. No other production files touched; frontend tests unrelated. --------- Co-authored-by: Hicent2 <hicent2@users.noreply.github.com>
1 parent da3ab96 commit 5e4dfb8

26 files changed

Lines changed: 2508 additions & 159 deletions

quantara/frontend/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@
1212
<body>
1313
<noscript>You need to enable JavaScript to run this app.</noscript>
1414
<div id="root"></div>
15-
<script type="module" src="/src/index.jsx"></script>
15+
<script type="module" src="/src/main.jsx"></script>
1616
</body>
1717
</html>

quantara/frontend/package.json

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"type": "module",
66
"dependencies": {
77
"@popperjs/core": "^2.11.8",
8+
"@sentry/react": "^9.0.0",
89
"@stellar/freighter-api": "^6.0.1",
910
"@stellar/stellar-sdk": "^15.1.0",
1011
"@tailwindcss/vite": "^4.3.3",
@@ -15,21 +16,22 @@
1516
"@vitejs/plugin-react": "^5.2.0",
1617
"axios": "^1.18.1",
1718
"dotenv": "^17.4.2",
19+
"i18next": "^23.0.0",
20+
"i18next-browser-languagedetector": "^7.0.0",
21+
"i18next-fs-backend": "^2.1.0",
1822
"lucide-react": "^1.21.0",
23+
"qrcode": "^1.5.4",
1924
"react": "^18.3.1",
2025
"react-dom": "^18.3.1",
26+
"react-i18next": "^13.0.0",
2127
"react-router-dom": "^7.1.3",
2228
"react-toastify": "^10.0.6",
2329
"tailwindcss": "^4.3.1",
2430
"vite": "^6.0.11",
2531
"vite-plugin-environment": "^1.1.3",
2632
"vite-tsconfig-paths": "^6.1.1",
2733
"web-vitals": "^5.3.0",
28-
"zustand": "^5.0.1",
29-
"i18next": "^23.0.0",
30-
"react-i18next": "^13.0.0",
31-
"i18next-fs-backend": "^2.1.0",
32-
"i18next-browser-languagedetector": "^7.0.0"
34+
"zustand": "^5.0.1"
3335
},
3436
"scripts": {
3537
"start": "vite",
@@ -74,12 +76,12 @@
7476
"eslint-plugin-react-hooks": "^7.1.1",
7577
"eslint-plugin-react-refresh": "^0.5.3",
7678
"globals": "^15.11.0",
79+
"i18next-scanner": "^4.0.0",
7780
"jsdom": "^26.0.0",
7881
"prettier": "^3.8.4",
7982
"prettier-plugin-tailwindcss": "^0.8.1",
8083
"tailwind-merge": "^3.6.0",
8184
"vite-plugin-svgr": "^4.3.0",
82-
"vitest": "^3.0.4",
83-
"i18next-scanner": "^4.0.0"
85+
"vitest": "^3.0.4"
8486
}
8587
}

quantara/frontend/src/App.jsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { AddDeposit } from '@/pages/add-deposit/AddDeposit';
2828
import Leaderboard from '@/pages/leaderboard/Leaderboard';
2929
import AddressBookPage from '@/pages/address-book/AddressBookPage';
3030
import NotFound from '@/pages/not-found/NotFound';
31+
import { initTelemetry } from '@/services/telemetry';
3132

3233
function App() {
3334
const { setWalletId, removeWalletId } = useWalletStore();
@@ -37,6 +38,13 @@ function App() {
3738
const [isMobileRestrictionModalOpen, setisMobileRestrictionModalOpen] = useState(true);
3839
const isMobile = useCheckMobile();
3940

41+
// Sentry initialization per Issue #277 acceptance criterion. Calls into
42+
// the same idempotent initTelemetry() used in main.jsx — the no-op
43+
// guard inside initTelemetry() ensures we only initialize once.
44+
useEffect(() => {
45+
initTelemetry();
46+
}, []);
47+
4048
const disableDesktopOnMobile = process.env.VITE_APP_DISABLE_DESKTOP_ON_MOBILE !== 'false';
4149

4250
const connectWalletMutation = useConnectWallet(setWalletId);

quantara/frontend/src/components/ui/action-modal/ActionModal.jsx

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
1-
import React from 'react';
1+
import React, { useEffect, useId, useRef } from 'react';
22
import { Button } from '@/components/ui/custom-button/Button';
33
import useLockBodyScroll from '@/hooks/useLockBodyScroll';
44
import { cn } from '@/utils/cn';
55

6+
/**
7+
* ActionModal — WCAG 2.2 AA compliant modal dialog.
8+
*
9+
* Per Issue #272 acceptance criteria:
10+
* • role="dialog", aria-modal="true"
11+
* • labelled with aria-labelledby pointing at the title element
12+
* • described with aria-describedby pointing at the subtitle element
13+
* • focus trap inside the dialog (Tab cycles across focusable elements)
14+
* • Esc key closes the modal via cancelAction
15+
* • focus is restored to the previously focused element on close
16+
*/
617
const ActionModal = ({
718
isOpen,
819
title,
@@ -16,6 +27,65 @@ const ActionModal = ({
1627
}) => {
1728
useLockBodyScroll(isOpen);
1829

30+
const titleId = useId();
31+
const subtitleId = useId();
32+
const panelRef = useRef(null);
33+
const previouslyFocusedRef = useRef(null);
34+
35+
useEffect(() => {
36+
if (!isOpen) return;
37+
38+
// Save the previously focused element and restore on close.
39+
previouslyFocusedRef.current = document.activeElement;
40+
41+
const focusableSelector = [
42+
'a[href]',
43+
'button:not([disabled])',
44+
'textarea:not([disabled])',
45+
'input:not([disabled])',
46+
'select:not([disabled])',
47+
'[tabindex]:not([tabindex="-1"])',
48+
].join(',');
49+
50+
// Move focus into the modal on open.
51+
const firstFocusable = panelRef.current?.querySelector(focusableSelector);
52+
firstFocusable?.focus();
53+
54+
const handleKeyDown = (e) => {
55+
if (e.key === 'Escape') {
56+
e.stopPropagation();
57+
if (!isLoading) cancelAction?.();
58+
return;
59+
}
60+
if (e.key !== 'Tab' || !panelRef.current) return;
61+
62+
const focusables = Array.from(
63+
panelRef.current.querySelectorAll(focusableSelector)
64+
).filter((el) => !el.hasAttribute('disabled'));
65+
if (focusables.length === 0) return;
66+
67+
const first = focusables[0];
68+
const last = focusables[focusables.length - 1];
69+
if (e.shiftKey && document.activeElement === first) {
70+
e.preventDefault();
71+
last.focus();
72+
} else if (!e.shiftKey && document.activeElement === last) {
73+
e.preventDefault();
74+
first.focus();
75+
}
76+
};
77+
78+
document.addEventListener('keydown', handleKeyDown);
79+
return () => {
80+
document.removeEventListener('keydown', handleKeyDown);
81+
// Restore focus to the element that opened the modal.
82+
const previouslyFocused = previouslyFocusedRef.current;
83+
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
84+
previouslyFocused.focus();
85+
}
86+
};
87+
}, [isOpen, cancelAction, isLoading]);
88+
1989
if (!isOpen) {
2090
return null;
2191
}
@@ -25,24 +95,30 @@ const ActionModal = ({
2595
onClick={cancelAction}
2696
role="dialog"
2797
aria-modal="true"
28-
aria-label={title}
98+
aria-labelledby={titleId}
99+
aria-describedby={subtitleId}
29100
>
30101
<div
102+
ref={panelRef}
103+
data-focus-managed="true"
31104
className="shadow-primary-color flex items-center justify-center overflow-hidden text-white"
32105
onClick={(e) => e.stopPropagation()}
33106
>
34107
<div className="flex w-[330px] flex-col gap-[18px] rounded-2xl text-center md:w-full md:max-w-[700px] md:gap-6">
35108
<div className="border-nav-divider-bg bg-bg h-fit rounded-2xl border p-6 py-4 pt-4 text-center text-sm md:rounded-2xl">
36-
<div className="text-primary mb-6 w-full border-b border-b-[rgba(255,255,255,0.1)] px-[10px] py-[10px] text-center text-base text-[13px] sm:mb-[14px] sm:py-[6px] md:mb-4 md:pb-4">
109+
<div
110+
id={titleId}
111+
className="text-primary mb-6 w-full border-b border-b-[rgba(255,255,255,0.1)] px-[10px] py-[10px] text-center text-base text-[13px] sm:mb-[14px] sm:py-[6px] md:mb-4 md:pb-4"
112+
>
37113
{title}
38114
</div>
39115
<div className="grid min-h-28 place-content-center px-2">
40-
<h2 className={cn('mx-auto mb-4 text-sm font-semibold md:text-2xl', content.length && 'px-0 py-[55px]')}>
116+
<h2 id={subtitleId} className={cn('mx-auto mb-4 text-sm font-semibold md:text-2xl', content.length && 'px-0 py-[55px]')}>
41117
{subTitle}
42118
</h2>
43-
{content.map((content, i) => (
119+
{content.map((line, i) => (
44120
<p className="mx-auto mt-0 mb-3 max-w-96 text-base leading-6" key={i}>
45-
{content}
121+
{line}
46122
</p>
47123
))}
48124
</div>
@@ -51,7 +127,13 @@ const ActionModal = ({
51127
<Button variant="secondary" size="md" onClick={cancelAction} disabled={isLoading}>
52128
{cancelLabel}
53129
</Button>
54-
<Button variant="primary" size="md" onClick={submitAction} disabled={isLoading}>
130+
<Button
131+
variant="primary"
132+
size="md"
133+
onClick={submitAction}
134+
disabled={isLoading}
135+
aria-busy={isLoading}
136+
>
55137
{isLoading ? 'Loading...' : submitLabel}
56138
</Button>
57139
</div>

quantara/frontend/src/components/ui/balance-cards/BalanceCards.jsx

Lines changed: 44 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useState } from 'react';
1+
import React, { useEffect, useState, useId } from 'react';
22
import { useMatchMedia } from '@/hooks/useMatchMedia';
33
import { getBalances } from '@/services/wallet';
44
import { useWalletStore } from '@/stores/useWalletStore';
@@ -8,8 +8,16 @@ import USDC from '@/assets/icons/borrow_usdc.svg?react';
88
import STRK from '@/assets/icons/strk.svg?react';
99
import KSTRK from '@/assets/icons/kstrk.svg?react';
1010

11+
/**
12+
* BalanceCards — WCAG 2.2 AA compliant balance summary.
13+
*
14+
* Uses a definition list (`<dl>`/`<dt>`/`<dd>`) so screen readers announce
15+
* the token name with its balance as a related pair instead of the previous
16+
* implementation, which wrapped the icon inside a fake `<label>`.
17+
*/
1118
const BalanceCards = ({ className }) => {
1219
const { walletId } = useWalletStore();
20+
const baseId = useId();
1321

1422
const isMobile = useMatchMedia('(max-width: 768px)');
1523

@@ -18,49 +26,49 @@ const BalanceCards = ({ className }) => {
1826
}, [walletId]);
1927

2028
const [balances, setBalances] = useState([
21-
{ icon: <ETH />, title: 'ETH', balance: '0.00' },
22-
{ icon: <USDC />, title: 'USDC', balance: '0.00' },
23-
{ icon: <STRK />, title: 'STRK', balance: '0.00' },
24-
{ icon: <KSTRK />, title: 'kSTRK', balance: '0.00' },
29+
{ icon: <ETH aria-hidden="true" />, title: 'ETH', balance: '0.00' },
30+
{ icon: <USDC aria-hidden="true" />, title: 'USDC', balance: '0.00' },
31+
{ icon: <STRK aria-hidden="true" />, title: 'STRK', balance: '0.00' },
32+
{ icon: <KSTRK aria-hidden="true" />, title: 'kSTRK', balance: '0.00' },
2533
]);
2634

2735
return (
28-
<div className="no-scrollbar mx-auto mt-3 w-full max-w-2xl overflow-x-auto px-3">
29-
<div className="grid w-full min-w-md grid-cols-4 gap-4 rounded-[8px]">
30-
{balances.map((balance) =>
31-
isMobile ? (
36+
<div
37+
aria-label="Token balances"
38+
className={`no-scrollbar mx-auto mt-3 w-full max-w-2xl overflow-x-auto px-3 ${className ?? ''}`}
39+
>
40+
<dl className="grid w-full min-w-md grid-cols-4 gap-4 rounded-[8px]">
41+
{balances.map((balance) => {
42+
const termId = `${baseId}-${balance.title}-term`;
43+
const defId = `${baseId}-${balance.title}-def`;
44+
return (
3245
<div
33-
className="border- border-nav-divider-bg flex flex-col items-center rounded-xl border px-1 py-3 text-center"
46+
role="group"
47+
aria-labelledby={termId}
48+
aria-describedby={defId}
3449
key={balance.title}
50+
className={`border- border-nav-divider-bg flex flex-col items-center rounded-xl border ${
51+
isMobile ? 'px-1 py-3' : 'px-3 py-4'
52+
} text-center`}
3553
>
36-
<label htmlFor={balance.title} className={'flex items-center gap-1 text-[#83919F]'}>
37-
<div className="bg-border-color flex h-6 w-6 justify-center rounded-full p-1">
38-
<span className="flex h-full w-full items-center justify-center rounded-full">{balance.icon}</span>
39-
</div>
54+
<dt id={termId} className="flex items-center gap-1 text-[#83919F]">
55+
<span
56+
aria-hidden="true"
57+
className="bg-border-color flex h-6 w-6 justify-center rounded-full p-1"
58+
>
59+
<span className="flex h-full w-full items-center justify-center rounded-full">
60+
{balance.icon}
61+
</span>
62+
</span>
4063
<span className="text-sm">{balance.title} Balance</span>
41-
</label>
42-
<label htmlFor={balance.title}>
43-
<span className="text-2xl font-semibold text-white">{balance.balance}</span>
44-
</label>
64+
</dt>
65+
<dd id={defId} className="text-2xl font-semibold text-white" aria-live="polite">
66+
{balance.balance}
67+
</dd>
4568
</div>
46-
) : (
47-
<div
48-
className="border- border-nav-divider-bg flex flex-col items-center rounded-xl border px-3 py-4 text-center"
49-
key={balance.title}
50-
>
51-
<label htmlFor={balance.title} className={'flex gap-1 text-[#83919F]'}>
52-
<div className="bg-border-color flex h-6 w-6 justify-center rounded-full p-1">
53-
<span className="flex h-full w-full items-center justify-center rounded-full">{balance.icon}</span>
54-
</div>
55-
<span className="text-sm">{balance.title} Balance</span>
56-
</label>
57-
<label htmlFor={balance.title}>
58-
<span className="text-2xl font-semibold text-white">{balance.balance}</span>
59-
</label>
60-
</div>
61-
)
62-
)}
63-
</div>
69+
);
70+
})}
71+
</dl>
6472
</div>
6573
);
6674
};

quantara/frontend/src/components/ui/custom-button/Button.jsx

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,53 @@ const buttonVariants = cva(
4646
}
4747
);
4848

49-
export const Button = React.forwardRef(({ className, variant, size, children, ...props }, ref) => {
50-
return (
51-
<button
52-
className={cn(
53-
buttonVariants({ variant, size, className }),
54-
'focus:shadow-[0_0_0_2px_rgba(59,130,246,0.5)] focus:outline-none',
55-
'disabled:cursor-not-allowed disabled:opacity-60'
56-
)}
57-
ref={ref}
58-
{...props}
59-
>
60-
<span className="relative z-10 py-4 md:px-6">{children}</span>
61-
</button>
62-
);
63-
});
49+
/**
50+
* Quantara Button component (WCAG 2.2 AA compliant).
51+
*
52+
* - Always renders a `<button type="button">` by default so accidental form
53+
* submits don't happen; pages can override with `type="submit"`.
54+
* - Uses `focus-visible:ring-2 ring-brand ring-offset-2 ring-offset-bg` so the
55+
* keyboard focus indicator has a ≥3:1 contrast ratio against the dark
56+
* base background color.
57+
* - Relies on `aria-disabled` + `disabled` so screen readers correctly
58+
* announce the disabled state.
59+
*/
60+
export const Button = React.forwardRef(
61+
(
62+
{
63+
className,
64+
variant,
65+
size,
66+
children,
67+
type = 'button',
68+
'aria-busy': ariaBusy,
69+
'aria-label': ariaLabel,
70+
...props
71+
},
72+
ref
73+
) => {
74+
return (
75+
<button
76+
ref={ref}
77+
type={type}
78+
aria-busy={ariaBusy}
79+
aria-label={ariaLabel}
80+
className={cn(
81+
buttonVariants({ variant, size, className }),
82+
// Suppress the global :focus-visible ring (from globals.css)
83+
// and use a high-contrast layered ring with offset instead.
84+
'focus:outline-none',
85+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand',
86+
'focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
87+
// Disable hover/cursor affordances when the element is disabled.
88+
'disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:before:bg-gradient-to-r disabled:hover:before:from-brand disabled:hover:before:to-pink'
89+
)}
90+
{...props}
91+
>
92+
<span className="relative z-10 py-4 md:px-6">{children}</span>
93+
</button>
94+
);
95+
}
96+
);
6497

6598
Button.displayName = 'Button';

0 commit comments

Comments
 (0)