Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Web app for the Callora API marketplace: developer dashboard, API management, an
- **Global Command Palette**: Instantly jump to views, search APIs by name, cycle/toggle light & dark themes, or trigger vault deposits. Use `Cmd+K` on macOS or `Ctrl+K` on Windows/Linux to open.
- **Pattern-based status badges**: Status indicators now use distinct textures in addition to color so they remain understandable for color-blind users and in grayscale displays.
- **Response diff highlighting**: Pass a `compareWith` prop to `CallHistoryRow` to show a line-by-line diff between two call responses, with added (green), removed (red), and unchanged context lines. Includes a Diff/Raw toggle, before/after call labels, and full WCAG 2.1 AA accessibility. See [docs/ResponseDiff.md](docs/ResponseDiff.md).
- **SLA details card**: The GrantFox Wave Compute API SLA page (`/marketplace/grantfox-wave-compute/sla`) displays all SLA metrics with per-value copy-to-clipboard buttons. Each button shows a 2-second "Copied!" success state (green checkmark + label), announces the copy to screen readers via `aria-live`, and falls back to `execCommand` in non-HTTPS contexts. Powered by the reusable `useCopy` hook. See [docs/SlaCard-CopyToClipboard.md](docs/SlaCard-CopyToClipboard.md).
- **Smooth theme transition**: Light/dark switches animate color tokens (background, text, border) over 240 ms instead of snapping. The transition is gated behind a `theme-transitions-ready` class that ThemeProvider adds after the first paint, preventing any flash on load. Animated elements (toasts, skeletons, spinners) are automatically excluded. Use the `.no-theme-transition` escape hatch on any element that must opt out.
- **Endpoint hover preview**: On the API Detail documentation tab, hovering or focusing an individual endpoint card header reveals a compact floating panel showing the HTTP method badge, endpoint URL, parameter table (name / type / required), and an optional response-shape snippet. Keyboard accessible (Escape dismisses); all colours from design tokens. See `src/components/EndpointPreview.tsx`.

Expand Down Expand Up @@ -90,6 +91,7 @@ The dashboard includes an accessible usage gauge that summarizes API spend for t
| `/theme-playground` | Live theme token playground for designers |
| `/500` | Server error page |
| `*` | 404 not found |
| `/marketplace/grantfox-wave-compute/sla` | GrantFox Wave Compute API SLA details (FWC26) |

## Project layout

Expand Down
148 changes: 148 additions & 0 deletions docs/SlaCard-CopyToClipboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# SlaCard — Copy-to-Clipboard (Issue #545, FWC26 campaign)

Added per-value copy-to-clipboard buttons to the GrantFox Wave Compute API SLA
details page, backed by the new `useCopy` hook.

## Route

```
/marketplace/grantfox-wave-compute/sla
```

## What was added

### `src/hooks/useCopy.ts`

A reusable hook that writes text to the clipboard and drives a transient
"Copied!" feedback state.

#### API

```ts
import useCopy from "../hooks/useCopy";
// or
import { useCopy } from "../hooks/useCopy";

const { copied, supported, handleCopy } = useCopy();
```

| Member | Type | Description |
|--------|------|-------------|
| `copied` | `boolean` | `true` for 2 s after a successful copy. Use to swap labels / icons. |
| `supported` | `boolean` | `true` when the clipboard API *or* the `execCommand` fallback is available. Hide copy buttons when `false`. |
| `handleCopy` | `(text: string) => Promise<boolean>` | Copies `text`; returns `true` on success. |

#### Behaviour

- **Clipboard API first**: uses `navigator.clipboard.writeText` when available
(requires HTTPS or `localhost`).
- **`execCommand` fallback**: creates an off-screen `<textarea>`, selects it,
and calls `document.execCommand("copy")` for older browsers and HTTP contexts.
- **`copied` auto-resets** after 2 000 ms.
- **Timer restart**: clicking again before the 2 s window expires resets the
countdown from the most recent click (no "Copy → Copied!" flicker).
- **Cleanup on unmount**: the pending timer is cancelled so React never updates
state on an unmounted component.
- **`supported` flag**: `false` when neither mechanism is present so consumers
can conditionally hide copy UI.

#### Exports

Both a default export and a named export are provided for flexibility:

```ts
import useCopy from "../hooks/useCopy"; // default
import { useCopy } from "../hooks/useCopy"; // named
```

---

### `src/pages/SlaCard.tsx`

The SLA details page for the GrantFox Wave Compute API – Stellar Edition.

Each of the 8 SLA metrics is displayed as a `<dt>` / `<dd>` pair inside a
`<dl>`. The `<dd>` contains both the value text and a copy button.

**SLA fields:**

| Field | Value |
|-------|-------|
| Uptime SLA | `99.95%` |
| P99 Response Time | `≤ 250 ms` |
| Incident Response | `< 15 minutes` |
| Maintenance Window | `Sundays 02:00–04:00 UTC` |
| Support Tier | `Priority (24/7)` |
| Credit Threshold | `< 99.5% triggers SLA credit` |
| API Version | `v2.4.1` |
| Contract ID | `FWC26-SLA-0042` |

**Copy interaction:**

1. User clicks the "Copy" button next to a value.
2. The button label changes to "Copied!" and the copy icon swaps to a green
`CheckIcon`.
3. An `aria-live="polite"` region announces "<Field label> copied to
clipboard" to screen readers (WCAG 2.1 AA SC 4.1.3).
4. After 2 seconds both the button label and the live region revert to their
default state.
5. Each row's copy state is independent — clicking one button does not affect
the others.

---

## Accessibility

| WCAG criterion | Implementation |
|----------------|----------------|
| 1.3.1 Info and Relationships | Values in a `<dl>` semantic structure. |
| 2.4.7 Focus Visible | `:focus-visible` outline uses `--accent` design token (2 px, 3 px offset). |
| 2.5.5 Target Size | Copy buttons have `min-height: 36px`; touch area ≥ 44 px via `padding`. |
| 4.1.2 Name, Role, Value | Each button has `aria-label="Copy <Field>: <Value>"`, changing to `"<Field> copied"` after success. |
| 4.1.3 Status Messages | `aria-live="polite" aria-atomic="true"` region per row announces copy outcome. |

---

## Design tokens used

| Token | Purpose |
|-------|---------|
| `--text` | Value text, page heading |
| `--muted` | Label text, idle copy button |
| `--surface` | Card background |
| `--border` | Card and row separator |
| `--accent` | Focus ring |
| `--success` | "Copied!" button / CheckIcon colour |

No hardcoded hex values — dark and light themes work automatically.

---

## Tests

| File | Count | What is covered |
|------|-------|-----------------|
| `src/hooks/__tests__/useCopy.test.ts` | 7 | Initial state, clipboard write, `copied` true after copy, 2 s reset, `execCommand` fallback, unmount cleanup, rapid re-copy timer restart |
| `src/pages/SlaCard.test.tsx` | 22 | Static rendering (heading, all 8 labels and values, 8 copy buttons, subtitle, region landmark), aria-labels, copy interaction, aria-live announcements, rapid re-copy, independent state per button |

Run with:

```bash
npx vitest run src/hooks/__tests__/useCopy.test.ts src/pages/SlaCard.test.tsx
```

---

## `src/App.tsx` changes

- Added `import SlaCard from "./pages/SlaCard"`.
- Added `slaCard: "/marketplace/grantfox-wave-compute/sla"` to `APP_ROUTES`.
- Added `<Route path={APP_ROUTES.slaCard} element={<SlaCard />} />`.

---

## `src/components/EmptyState.tsx` changes

`EmptyState` previously used the old `useCopy` API (`copy` + `supported`).
Updated to use `handleCopy` (renamed) while keeping the `supported` guard so
the copy button is hidden when neither clipboard mechanism is available.
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import PublishApi from "./pages/PublishApi";
import { startRouteLoading, stopRouteLoading } from "./hooks/useRouteLoading";
import { formatUsdc, formatUsdShortcut } from "./utils/format";
import DepositPreview from "./components/DepositPreview";

Check failure on line 15 in src/App.tsx

View workflow job for this annotation

GitHub Actions / TypeScript check & production build

'DepositPreview' is declared but its value is never read.
import { EXPLORER_BASE_URL, MIN_DEPOSIT, NETWORK_FEE, PRESET_AMOUNTS } from "./config/constants";
import CompareDrawer from "./components/CompareDrawer";
import CompareTray from "./components/CompareTray";
Expand All @@ -25,6 +25,7 @@
import { ShortcutsModal } from "./components/ShortcutsModal";
import { ToastProvider } from "./components/Toast";
import { InvoiceCard } from "./pages/InvoiceCard";
import SlaCard from "./pages/SlaCard";

type DepositStage = "input" | "approving" | "pending" | "confirmed" | "failed";
type DemoOutcome = "confirmed" | "failed";
Expand Down Expand Up @@ -123,6 +124,7 @@
designSystem: "/design-system/docs",
serverError: "/500",
rateLimitCard: "/rate-limit",
slaCard: "/marketplace/grantfox-wave-compute/sla",
} as const;

function createMockHash() {
Expand Down Expand Up @@ -279,7 +281,7 @@
[APP_ROUTES.billing]: "Billing – Callora",
"/api-usage": "API Usage – Callora",
[APP_ROUTES.landing]: "Callora",
[APP_ROUTES.endpointSummary]: "Endpoint Summary – Callora",

Check failure on line 284 in src/App.tsx

View workflow job for this annotation

GitHub Actions / TypeScript check & production build

Property 'endpointSummary' does not exist on type '{ readonly landing: "/"; readonly dashboard: "/dashboard"; readonly marketplace: "/marketplace"; readonly publish: "/publish"; readonly myApis: "/apis/my-apis"; readonly planBadge: "/apis/plan-badge"; ... 8 more ...; readonly slaCard: "/marketplace/grantfox-wave-compute/sla"; }'.
};
const routeDescriptionMap: Record<string, string> = {
[APP_ROUTES.marketplace]: "Explore APIs on the Callora marketplace, discover and integrate APIs for your applications.",
Expand All @@ -287,7 +289,7 @@
[APP_ROUTES.billing]: "Manage your USDC vault, deposit funds, and view transaction status.",
"/api-usage": "Monitor API usage, request stats, and view call history.",
[APP_ROUTES.landing]: "Callora - Programmable API Access, pay-per-call billing, and on-chain settlement.",
[APP_ROUTES.endpointSummary]: "Quick reference list of all API endpoints on Callora.",

Check failure on line 292 in src/App.tsx

View workflow job for this annotation

GitHub Actions / TypeScript check & production build

Property 'endpointSummary' does not exist on type '{ readonly landing: "/"; readonly dashboard: "/dashboard"; readonly marketplace: "/marketplace"; readonly publish: "/publish"; readonly myApis: "/apis/my-apis"; readonly planBadge: "/apis/plan-badge"; ... 8 more ...; readonly slaCard: "/marketplace/grantfox-wave-compute/sla"; }'.
};
const currentTitle = routeTitleMap[location.pathname] ?? "Callora";
const currentDescription = routeDescriptionMap[location.pathname];
Expand All @@ -300,7 +302,7 @@
const [amountInput, setAmountInput] = useState("50");
const [selectedPreset, setSelectedPreset] = useState<number | "custom">(50);

const [isTouchDevice, setIsTouchDevice] = useState(false);

Check failure on line 305 in src/App.tsx

View workflow job for this annotation

GitHub Actions / TypeScript check & production build

'isTouchDevice' is declared but its value is never read.
useEffect(() => {
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0);
}, []);
Expand Down Expand Up @@ -595,7 +597,7 @@
<p className="eyebrow">Deposit USDC to Vault</p>
<h1>Review every number before you approve.</h1>
</div>
<button className="primary-button no-print" onClick={openDeposit}>

Check failure on line 600 in src/App.tsx

View workflow job for this annotation

GitHub Actions / TypeScript check & production build

Type '(presetAmount?: number) => void' is not assignable to type 'MouseEventHandler<HTMLButtonElement>'.
Open deposit modal
</button>
</div>
Expand Down Expand Up @@ -685,6 +687,9 @@

<Route path={APP_ROUTES.rateLimitCard} element={<RateLimitCard />} />

{/* ── SLA Details (FWC26 campaign, Issue #545) ─────────────── */}
<Route path={APP_ROUTES.slaCard} element={<SlaCard />} />

<Route path="*" element={<NotFound onGoHome={() => navigate(APP_ROUTES.dashboard)} />} />
</Routes>
</main>
Expand Down
7 changes: 3 additions & 4 deletions src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -493,18 +493,17 @@ function MessageWithCopy({
message: string;
isCompact: boolean;
}) {
const { copy, copied, supported } = useCopy();
const { handleCopy: copyToClipboard, copied, supported } = useCopy();
const [liveFeedback, setLiveFeedback] = React.useState("");

const handleCopy = useCallback(() => {
if (!supported) return;
copy(message).then((ok) => {
copyToClipboard(message).then((ok) => {
if (ok) {
setLiveFeedback("Message copied.");
setTimeout(() => setLiveFeedback(""), 2000);
}
});
}, [copy, message, supported]);
}, [copyToClipboard, message]);

const containerStyle: React.CSSProperties = {
display: "flex",
Expand Down
113 changes: 89 additions & 24 deletions src/hooks/useCopy.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,133 @@
import { useCallback, useEffect, useRef, useState } from "react";

const FEEDBACK_DURATION_MS = 2000;
/** How long (ms) the "Copied!" success state stays visible. */
const FEEDBACK_DURATION_MS = 2_000;

// ─── Types ────────────────────────────────────────────────────────────────────

export interface UseCopyReturn {
/** Whether the last copy was successful and we're still showing feedback. */
/** `true` for 2 s after a successful copy — use to show "Copied!" feedback. */
copied: boolean;
/** Whether the browser's clipboard API is supported in this environment. */
/**
* `true` when the environment supports copying to the clipboard.
*
* The hook considers copying supported when either:
* - `navigator.clipboard.writeText` is available (modern / HTTPS), or
* - `document.execCommand` is available (legacy fallback).
*
* Use this to conditionally hide copy buttons in environments where neither
* mechanism is present (e.g. some automated test contexts).
*/
supported: boolean;
/** Copy `text` to clipboard. Returns `true` on success. */
copy: (text: string) => Promise<boolean>;
/**
* Copy `text` to the clipboard.
*
* Prefers the async Clipboard API; falls back to the legacy
* `textarea + document.execCommand("copy")` path for older browsers and
* non-HTTPS contexts.
*
* Returns `true` on success, `false` on failure.
*/
handleCopy: (text: string) => Promise<boolean>;
}

// ─── Hook ─────────────────────────────────────────────────────────────────────

/**
* useCopy — a tiny copy-to-clipboard hook with success feedback.
* useCopy — copy-to-clipboard with transient success feedback.
*
* - Wraps `navigator.clipboard.writeText` with a `try/catch` so callers never
* need to handle permission / HTTPS errors.
* - Sets `copied = true` for 2 seconds after a successful copy so the UI can
* show a transient "Copied!" label, icon, or tooltip (WCAG 2.1 SC 2.2.1).
* - Reports `supported` so the consuming component can hide the copy button
* entirely when the Clipboard API is unavailable (e.g. insecure context).
* Features:
* - Wraps `navigator.clipboard.writeText` with an `execCommand` fallback so the
* hook works in HTTP contexts and older browsers.
* - Sets `copied = true` for {@link FEEDBACK_DURATION_MS} ms after a successful
* copy to drive "Copied!" labels / checkmark icons (WCAG 2.1 SC 2.2.1).
* - Cleans up any pending timer on unmount so React never updates unmounted state.
* - Re-clicking before the timer expires resets the 2-second window from scratch
* (no flickering "Copy" → "Copied!" between rapid clicks).
*
* @example
* ```tsx
* const { copy, copied, supported } = useCopy();
* if (!supported) return null;
* return <button onClick={() => copy("hello")}>{copied ? "Copied!" : "Copy"}</button>;
* const { copied, handleCopy } = useCopy();
* return (
* <button onClick={() => handleCopy("hello")}>
* {copied ? "Copied!" : "Copy"}
* </button>
* );
* ```
*/
export function useCopy(): UseCopyReturn {
const [copied, setCopied] = useState(false);
// Keep a stable ref to the reset timer so we can cancel / restart it.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

/**
* Whether this environment can copy to the clipboard at all.
* True when either the async Clipboard API or the legacy execCommand is
* available. Evaluated once on mount — the value won't change during the
* component's lifetime.
*/
const supported =
typeof navigator !== "undefined" &&
typeof navigator.clipboard?.writeText === "function";
(typeof navigator !== "undefined" &&
typeof navigator.clipboard?.writeText === "function") ||
(typeof document !== "undefined" &&
typeof document.execCommand === "function");

// Cancel any outstanding timer — extracted so both the copy path and
// the cleanup effect can share the same logic without a circular ref.
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);

// Ensure we never set state after the component has unmounted.
useEffect(() => {
// Cleanup timer on unmount so we never update state after dismount.
return () => clearTimer();
}, [clearTimer]);

const copy = useCallback(
const handleCopy = useCallback(
async (text: string): Promise<boolean> => {
if (!supported) return false;
try {
await navigator.clipboard.writeText(text);
if (navigator.clipboard?.writeText) {
// Preferred path: async Clipboard API (requires HTTPS or localhost).
await navigator.clipboard.writeText(text);
} else {
// Fallback: create an off-screen textarea, select its content, and
// issue an `execCommand("copy")`. Works in HTTP contexts and older
// browsers (Safari < 13.1, some WebViews).
const textarea = document.createElement("textarea");
textarea.value = text;
// Keep it invisible and out of layout.
textarea.style.cssText =
"position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}

// Show success feedback and reset after FEEDBACK_DURATION_MS.
setCopied(true);
clearTimer();
timerRef.current = setTimeout(() => setCopied(false), FEEDBACK_DURATION_MS);
timerRef.current = setTimeout(
() => setCopied(false),
FEEDBACK_DURATION_MS,
);
return true;
} catch {
// Clipboard access denied, or browser restrictions — fail silently.
return false;
}
},
[supported, clearTimer],
[clearTimer],
);

return { copy, copied, supported };
return { copied, supported, handleCopy };
}

// Default export so callers can use either:
// import useCopy from "../hooks/useCopy"; // default
// import { useCopy } from "../hooks/useCopy"; // named
export default useCopy;
Loading
Loading