diff --git a/src/components/HelpPopover.test.tsx b/src/components/HelpPopover.test.tsx
new file mode 100644
index 0000000..5d34028
--- /dev/null
+++ b/src/components/HelpPopover.test.tsx
@@ -0,0 +1,112 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import HelpPopover from "./HelpPopover";
+
+afterEach(cleanup);
+
+describe("HelpPopover", () => {
+ it("renders an info icon button with the default accessible label", () => {
+ render();
+
+ const btn = screen.getByRole("button", { name: "Help" });
+ expect(btn).toBeTruthy();
+ });
+
+ it("renders an info icon button with a custom accessible label", () => {
+ render(
+
+ );
+
+ const btn = screen.getByRole("button", { name: "What does subscription mean?" });
+ expect(btn).toBeTruthy();
+ });
+
+ it("shows the tooltip/popover content on hover via the Tooltip component", async () => {
+ render();
+
+ // Popover should not be visible initially
+ expect(screen.queryByRole("tooltip")).toBeNull();
+
+ // Hover the trigger button
+ const btn = screen.getByRole("button", { name: "Help" });
+ fireEvent.mouseEnter(btn);
+
+ // The default hover delay is 300ms, so wait for the tooltip to appear
+ await waitFor(() => {
+ expect(screen.getByRole("tooltip")).toBeTruthy();
+ });
+
+ expect(screen.getByText("Helpful explanation for new users")).toBeTruthy();
+ });
+
+ it("shows the tooltip on keyboard focus", async () => {
+ render();
+
+ expect(screen.queryByRole("tooltip")).toBeNull();
+
+ // Focus the trigger button
+ const btn = screen.getByRole("button", { name: "Help" });
+ fireEvent.focus(btn);
+
+ // Tooltip should appear immediately on focus (Tooltip behaviour)
+ await waitFor(() => {
+ expect(screen.getByRole("tooltip")).toBeTruthy();
+ });
+
+ expect(screen.getByText("Focus-revealed content")).toBeTruthy();
+ });
+
+ it("dismisses the tooltip when the trigger loses focus", async () => {
+ render();
+
+ const btn = screen.getByRole("button", { name: "Help" });
+ fireEvent.focus(btn);
+
+ await waitFor(() => {
+ expect(screen.getByRole("tooltip")).toBeTruthy();
+ });
+
+ fireEvent.blur(btn);
+
+ await waitFor(() => {
+ expect(screen.queryByRole("tooltip")).toBeNull();
+ });
+ });
+
+ it("dismisses the tooltip on Escape key press", async () => {
+ render();
+
+ const btn = screen.getByRole("button", { name: "Help" });
+ fireEvent.mouseEnter(btn);
+
+ await waitFor(() => {
+ expect(screen.getByRole("tooltip")).toBeTruthy();
+ });
+
+ fireEvent.keyDown(document, { key: "Escape" });
+
+ await waitFor(() => {
+ expect(screen.queryByRole("tooltip")).toBeNull();
+ });
+ });
+
+ it("has a visible InfoIcon SVG inside the trigger button", () => {
+ const { container } = render();
+
+ const svg = container.querySelector("svg");
+ expect(svg).toBeTruthy();
+ expect(svg?.getAttribute("aria-hidden")).toBe("true");
+ });
+
+ it("applies the help-popover-trigger class to the icon button", () => {
+ render();
+
+ const btn = screen.getByRole("button", { name: "Help" });
+ expect(btn.className).toContain("help-popover-trigger");
+ });
+});
diff --git a/src/components/HelpPopover.tsx b/src/components/HelpPopover.tsx
new file mode 100644
index 0000000..601beed
--- /dev/null
+++ b/src/components/HelpPopover.tsx
@@ -0,0 +1,94 @@
+import { useId } from "react";
+import Tooltip from "./Tooltip";
+import { InfoIcon } from "./icons/InfoIcon";
+
+/**
+ * HelpPopover
+ *
+ * An accessible info popover (ⓘ icon) that explains a metric or field to
+ * first-time users. Built on top of the existing {@link Tooltip} component
+ * so it inherits WCAG 2.1 AA keyboard/ touch/ screen-reader support and
+ * design-token colour theming.
+ *
+ * **When to use**
+ * Place next to a UI control or label whose meaning may not be obvious to
+ * first-time users — e.g. a subscription price, a rate limit, or a data
+ * metric.
+ *
+ * **Accessibility (WCAG 2.1 AA)**
+ * - The trigger button receives a unique `aria-label` passed via the
+ * `ariaLabel` prop (default: "Help").
+ * - The tooltip content is connected to the trigger via `aria-describedby`
+ * (handled by the inner `Tooltip`).
+ * - `Escape` dismisses the open popover.
+ * - Focus management: the icon button is keyboard-focusable and receives the
+ * theme's global `:focus-visible` ring.
+ *
+ * **Responsive**
+ * - The popover content wraps naturally; `Tooltip` caps width at 240 px with
+ * `max-width: calc(100vw - 32px)` so it never overflows small viewports.
+ * - On touch devices a long-press (≥500 ms) reveals the popover.
+ *
+ * **Theme & tokens**
+ * Uses `--tooltip-bg`, `--tooltip-text`, `--border-subtle` tokens defined by
+ * `Tooltip`, which resolve to theme-appropriate values in light and dark
+ * mode.
+ *
+ * @param content – The explanatory text / node shown inside the popover.
+ * @param ariaLabel – Accessible label for the info icon button.
+ * Default: "Help".
+ * @param hoverDelayMs – Hover delay before the popover appears.
+ * Default: 300 ms (avoids flashing during cursor travel).
+ */
+
+type HelpPopoverProps = {
+ /** Explanatory content shown inside the popover. */
+ content: React.ReactNode;
+ /**
+ * Accessible label for the info-icon trigger button.
+ * @default "Help"
+ */
+ ariaLabel?: string;
+ /**
+ * Milliseconds to wait before showing the popover on hover.
+ * @default 300
+ */
+ hoverDelayMs?: number;
+};
+
+export default function HelpPopover({
+ content,
+ ariaLabel = "Help",
+ hoverDelayMs = 300,
+}: HelpPopoverProps): JSX.Element {
+ const labelId = useId();
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/SubscribeButton.test.tsx b/src/components/SubscribeButton.test.tsx
index 0f83f50..357b13d 100644
--- a/src/components/SubscribeButton.test.tsx
+++ b/src/components/SubscribeButton.test.tsx
@@ -7,12 +7,17 @@ import SubscribeButton from "./SubscribeButton";
afterEach(cleanup);
describe("SubscribeButton", () => {
- it("renders an idle subscribe button with correct accessible label", () => {
+ it("renders an idle subscribe button with correct accessible label and help popover", () => {
render();
const btn = screen.getByRole("button", { name: "Subscribe to Weather API" });
expect(btn).toBeTruthy();
expect(btn.textContent).toBe("Subscribe");
+
+ // Help popover trigger should also be present
+ expect(
+ screen.getByRole("button", { name: /Help: What does subscribing to Weather API mean\?/i })
+ ).toBeTruthy();
});
it("shows a confirmation dialog when the subscribe button is clicked", () => {
@@ -74,4 +79,24 @@ describe("SubscribeButton", () => {
const cancelBtn = screen.getByRole("button", { name: "Cancel subscription" });
expect(cancelBtn.className).toContain("subscribe-button-cancel");
});
+
+ it("renders the help popover with correct subscription explanation content", async () => {
+ render();
+
+ // The help popover should be visible in idle state
+ const helpBtn = screen.getByRole("button", { name: /Help: What does subscribing to Weather API mean\?/i });
+ expect(helpBtn).toBeTruthy();
+
+ // Hover the help button to reveal the tooltip
+ fireEvent.mouseEnter(helpBtn);
+
+ // Wait for the 300ms hover delay to elapse and the tooltip to appear
+ const tooltip = await screen.findByRole("tooltip");
+ expect(tooltip).toBeTruthy();
+
+ // Verify the tooltip contains the explanation text
+ expect(tooltip.textContent).toContain("What is");
+ expect(tooltip.textContent).toContain("Subscribing");
+ expect(tooltip.textContent).toContain("charged per successful API request");
+ });
});
diff --git a/src/components/SubscribeButton.tsx b/src/components/SubscribeButton.tsx
index 2587d19..907f792 100644
--- a/src/components/SubscribeButton.tsx
+++ b/src/components/SubscribeButton.tsx
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { CheckIcon } from "./icons/CheckIcon";
+import HelpPopover from "./HelpPopover";
type SubscribeStatus = "idle" | "confirming" | "subscribed";
@@ -118,12 +119,46 @@ export default function SubscribeButton({ apiName, onSubscribe, className }: Pro
// idle state
return (
-
+
+
+
+
+ What is "Subscribing"?
+
+
+ Subscribing gives you access to this API’s endpoints. You are
+ charged per successful API request — you only pay for what you
+ use.
+
+
+ Your subscription can be managed or cancelled at any time.
+
+
+ }
+ ariaLabel={`Help: What does subscribing to ${apiName} mean?`}
+ />
+
);
}
diff --git a/src/components/icons/InfoIcon.tsx b/src/components/icons/InfoIcon.tsx
new file mode 100644
index 0000000..d9573c0
--- /dev/null
+++ b/src/components/icons/InfoIcon.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+
+interface IconProps extends React.SVGProps {
+ size?: 16 | 20;
+}
+
+export function InfoIcon({ size = 16, className, ...props }: IconProps) {
+ const ariaHidden = props["aria-label"] ? undefined : "true";
+
+ return (
+
+ );
+}
diff --git a/src/components/icons/icons.test.tsx b/src/components/icons/icons.test.tsx
index b7206e5..fa38ec6 100644
--- a/src/components/icons/icons.test.tsx
+++ b/src/components/icons/icons.test.tsx
@@ -2,7 +2,7 @@
import { render, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
-import { TagIcon, CheckIcon, WarningIcon, BoltIcon, ClockIcon } from "./index";
+import { TagIcon, CheckIcon, WarningIcon, BoltIcon, ClockIcon, InfoIcon } from "./index";
const iconComponents = [
{ name: "TagIcon", Component: TagIcon },
@@ -10,6 +10,7 @@ const iconComponents = [
{ name: "WarningIcon", Component: WarningIcon },
{ name: "BoltIcon", Component: BoltIcon },
{ name: "ClockIcon", Component: ClockIcon },
+ { name: "InfoIcon", Component: InfoIcon },
];
describe("Theme-Aware SVG Icon Set", () => {
diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx
index 5591c74..abed065 100644
--- a/src/components/icons/index.tsx
+++ b/src/components/icons/index.tsx
@@ -5,3 +5,4 @@ export { BoltIcon } from "./BoltIcon";
export { ClockIcon } from "./ClockIcon";
export { ChevronIcon } from "./ChevronIcon";
export { LinkIcon } from "./LinkIcon";
+export { InfoIcon } from "./InfoIcon";
diff --git a/src/styles/focus.css b/src/styles/focus.css
index bbe6bc8..6b4eadc 100644
--- a/src/styles/focus.css
+++ b/src/styles/focus.css
@@ -97,6 +97,19 @@
outline-offset: 3px;
}
+ /* ── HelpPopover trigger (info icon) ──────────────────────────────────── */
+ .help-popover-trigger {
+ color: var(--muted);
+ transition: color 180ms ease;
+ }
+
+ .help-popover-trigger:hover,
+ .help-popover-trigger:focus-visible {
+ color: var(--accent);
+ outline: 2px solid var(--accent);
+ outline-offset: 3px;
+ }
+
/* ── ApiDetailPage: icon buttons (Postman / Insomnia / Save) ────────────── */
.api-detail-page .icon-button:focus-visible,
.endpoint-client-buttons .icon-button:focus-visible {
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo
index 090286d..7f3bdb8 100644
--- a/tsconfig.tsbuildinfo
+++ b/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/App.tsx","./src/ThemeContext.tsx","./src/ThemeToggle.tsx","./src/main.tsx","./src/setupTests.ts","./src/vite-env.d.ts","./src/components/ActiveFilterChips.tsx","./src/components/ApiCard.tsx","./src/components/ApiCardHoverPreview.tsx","./src/components/ApiDetailStickyTOC.tsx","./src/components/Breadcrumb.tsx","./src/components/CallHistoryRow.tsx","./src/components/CallsHeatmap.tsx","./src/components/CategoryPills.tsx","./src/components/CodeExample.tsx","./src/components/CollectionsMenu.tsx","./src/components/CommandPalette.tsx","./src/components/CompareDrawer.tsx","./src/components/CompareTray.tsx","./src/components/ContextMenu.tsx","./src/components/CopyCurlButton.tsx","./src/components/Dashboard.tsx","./src/components/DateRangePicker.tsx","./src/components/DepositPreview.tsx","./src/components/Dropdown.tsx","./src/components/EmbedPreview.tsx","./src/components/EmptyState.tsx","./src/components/EndpointGroupHover.tsx","./src/components/EndpointPreview.tsx","./src/components/ExternalLink.tsx","./src/components/FiltersBottomSheet.tsx","./src/components/FiltersSidebar.tsx","./src/components/FormField.tsx","./src/components/HealthTimeline.tsx","./src/components/JsonViewer.tsx","./src/components/KbdHint.tsx","./src/components/LiveRegion.tsx","./src/components/LowBalanceBanner.tsx","./src/components/MethodChip.tsx","./src/components/NotFound.tsx","./src/components/OpenAPIImport.tsx","./src/components/Pagination.tsx","./src/components/ParamsBuilder.tsx","./src/components/PlanBadge.tsx","./src/components/PlanNudge.tsx","./src/components/PricingTierTable.tsx","./src/components/RatingHistogram.tsx","./src/components/RecentlyActiveRail.tsx","./src/components/RelatedApisRail.tsx","./src/components/RequestBodyEditor.tsx","./src/components/RequestHistoryPanel.tsx","./src/components/RouteProgressBar.tsx","./src/components/RunExampleButton.tsx","./src/components/SearchBar.tsx","./src/components/ServerError.tsx","./src/components/ServerErrorDemo.tsx","./src/components/ShortcutsModal.tsx","./src/components/Skeleton.tsx","./src/components/SortDropdown.tsx","./src/components/Sparkline.tsx","./src/components/StarRating.tsx","./src/components/StatusBadge.tsx","./src/components/SubscribeButton.tsx","./src/components/Tabs.tsx","./src/components/TagChip.tsx","./src/components/TestInBrowser.tsx","./src/components/Toast.tsx","./src/components/TokenEditor.tsx","./src/components/Tooltip.tsx","./src/components/TopProgressBar.tsx","./src/components/UsageGauge.tsx","./src/components/WhyApi.tsx","./src/components/icons/BoltIcon.tsx","./src/components/icons/CheckIcon.tsx","./src/components/icons/ChevronIcon.tsx","./src/components/icons/ClockIcon.tsx","./src/components/icons/LinkIcon.tsx","./src/components/icons/TagIcon.tsx","./src/components/icons/WarningIcon.tsx","./src/components/icons/index.tsx","./src/config/constants.ts","./src/data/mockApis.ts","./src/hooks/useDebounce.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useFavorites.ts","./src/hooks/useFetchTracker.tsx","./src/hooks/useGlobalShortcuts.ts","./src/hooks/useLocalStorage.ts","./src/hooks/usePersistedState.ts","./src/hooks/useQuota.ts","./src/hooks/useRouteLoading.ts","./src/pages/A11yAudit.tsx","./src/pages/ApiDetailPage.skeleton.tsx","./src/pages/ApiDetailPage.tsx","./src/pages/ApiReviews.tsx","./src/pages/ApiTagFilter.tsx","./src/pages/ApiUsage.tsx","./src/pages/CallHistoryRow.tsx","./src/pages/DashboardPage.tsx","./src/pages/DesignSystemDocs.tsx","./src/pages/EndpointSummary.tsx","./src/pages/FiltersSidebar.tsx","./src/pages/InvoiceCard.tsx","./src/pages/MarketplacePage.skeleton.tsx","./src/pages/MarketplacePage.tsx","./src/pages/MyApis.tsx","./src/pages/PlanBadge.tsx","./src/pages/PricingTable.tsx","./src/pages/PublishApi.tsx","./src/pages/QuotaBanner.tsx","./src/pages/RateLimitCard.tsx","./src/pages/ReviewsTab.tsx","./src/pages/RiskGauge.tsx","./src/pages/SearchInput.tsx","./src/pages/StickyToc.tsx","./src/pages/SubscribeCTA.tsx","./src/pages/ThemePlayground.tsx","./src/state/collectionsStore.tsx","./src/state/compareStore.ts","./src/state/pinnedApis.ts","./src/state/testCallHistory.ts","./src/state/uiPrefs.ts","./src/state/userPrefs.ts","./src/utils/colorFromId.ts","./src/utils/density.ts","./src/utils/diff.ts","./src/utils/format.ts","./src/utils/icons.ts","./src/utils/openapi-parse.ts","./src/utils/postman.ts","./src/utils/schema-validate.ts","./src/utils/snapshotUrl.ts","./src/utils/toCurl.ts","./src/utils/userPrefs.ts"],"errors":true,"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/App.tsx","./src/ThemeContext.tsx","./src/ThemeToggle.tsx","./src/main.tsx","./src/setupTests.ts","./src/vite-env.d.ts","./src/components/ActiveFilterChips.tsx","./src/components/ApiCard.tsx","./src/components/ApiCardHoverPreview.tsx","./src/components/ApiDetailStickyTOC.tsx","./src/components/Breadcrumb.tsx","./src/components/CallHistoryRow.tsx","./src/components/CallsHeatmap.tsx","./src/components/CategoryPills.tsx","./src/components/CodeExample.tsx","./src/components/CollectionsMenu.tsx","./src/components/CommandPalette.tsx","./src/components/CompareDrawer.tsx","./src/components/CompareTray.tsx","./src/components/ContextMenu.tsx","./src/components/CopyCurlButton.tsx","./src/components/Dashboard.tsx","./src/components/DateRangePicker.tsx","./src/components/DepositPreview.tsx","./src/components/Dropdown.tsx","./src/components/EmbedPreview.tsx","./src/components/EmptyState.tsx","./src/components/EndpointGroupHover.tsx","./src/components/EndpointPreview.tsx","./src/components/ExternalLink.tsx","./src/components/FiltersBottomSheet.tsx","./src/components/FiltersSidebar.tsx","./src/components/FormField.tsx","./src/components/HealthTimeline.tsx","./src/components/JsonViewer.tsx","./src/components/KbdHint.tsx","./src/components/LiveRegion.tsx","./src/components/LowBalanceBanner.tsx","./src/components/MethodChip.tsx","./src/components/NotFound.tsx","./src/components/OpenAPIImport.tsx","./src/components/Pagination.tsx","./src/components/ParamsBuilder.tsx","./src/components/PlanBadge.tsx","./src/components/PlanNudge.tsx","./src/components/PreviewCard.tsx","./src/components/PricingTierTable.tsx","./src/components/RatingHistogram.tsx","./src/components/RecentlyActiveRail.tsx","./src/components/RelatedApisRail.tsx","./src/components/RequestBodyEditor.tsx","./src/components/RequestHistoryPanel.tsx","./src/components/RouteProgressBar.tsx","./src/components/RunExampleButton.tsx","./src/components/SearchBar.tsx","./src/components/ServerError.tsx","./src/components/ServerErrorDemo.tsx","./src/components/ShortcutsModal.tsx","./src/components/Skeleton.tsx","./src/components/SortDropdown.tsx","./src/components/Sparkline.tsx","./src/components/StarRating.tsx","./src/components/StatusBadge.tsx","./src/components/SubscribeButton.tsx","./src/components/Tabs.tsx","./src/components/TagChip.tsx","./src/components/TestInBrowser.tsx","./src/components/Toast.tsx","./src/components/TokenEditor.tsx","./src/components/Tooltip.tsx","./src/components/TopProgressBar.tsx","./src/components/UsageGauge.tsx","./src/components/WhyApi.tsx","./src/components/icons/BoltIcon.tsx","./src/components/icons/CheckIcon.tsx","./src/components/icons/ChevronIcon.tsx","./src/components/icons/ClockIcon.tsx","./src/components/icons/LinkIcon.tsx","./src/components/icons/TagIcon.tsx","./src/components/icons/WarningIcon.tsx","./src/components/icons/index.tsx","./src/config/constants.ts","./src/data/mockApis.ts","./src/hooks/useDebounce.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useFavorites.ts","./src/hooks/useFetchTracker.tsx","./src/hooks/useGlobalShortcuts.ts","./src/hooks/useLocalStorage.ts","./src/hooks/usePersistedState.ts","./src/hooks/useQuota.ts","./src/hooks/useRouteLoading.ts","./src/pages/A11yAudit.tsx","./src/pages/ApiDetailPage.skeleton.tsx","./src/pages/ApiDetailPage.tsx","./src/pages/ApiReviews.tsx","./src/pages/ApiTagFilter.tsx","./src/pages/ApiUsage.tsx","./src/pages/CallHistoryRow.tsx","./src/pages/DashboardOverview.tsx","./src/pages/DashboardPage.tsx","./src/pages/DesignSystemDocs.tsx","./src/pages/EndpointSummary.tsx","./src/pages/FiltersSidebar.tsx","./src/pages/InvoiceCard.tsx","./src/pages/MarketplacePage.skeleton.tsx","./src/pages/MarketplacePage.tsx","./src/pages/MyApis.tsx","./src/pages/PlanBadge.tsx","./src/pages/PricingTable.tsx","./src/pages/PublishApi.tsx","./src/pages/QuotaBanner.tsx","./src/pages/RateLimitCard.tsx","./src/pages/ReviewsTab.tsx","./src/pages/RiskGauge.tsx","./src/pages/SearchInput.tsx","./src/pages/StickyToc.tsx","./src/pages/SubscribeCTA.tsx","./src/pages/ThemePlayground.tsx","./src/pages/ThemeToggle.tsx","./src/state/collectionsStore.tsx","./src/state/compareStore.ts","./src/state/pinnedApis.ts","./src/state/testCallHistory.ts","./src/state/uiPrefs.ts","./src/state/userPrefs.ts","./src/utils/colorFromId.ts","./src/utils/density.ts","./src/utils/diff.ts","./src/utils/format.ts","./src/utils/icons.ts","./src/utils/openapi-parse.ts","./src/utils/postman.ts","./src/utils/schema-validate.ts","./src/utils/snapshotUrl.ts","./src/utils/toCurl.ts","./src/utils/userPrefs.ts"],"errors":true,"version":"5.9.3"}
\ No newline at end of file