Skip to content
Open
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
177 changes: 177 additions & 0 deletions src/pages/ApiDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -661,4 +661,181 @@ describe("ApiDetailPage", () => {
expect(liveRegion?.textContent).toBe("Reviews sorted by highest rated");
});
});

// ── tabular-nums (Issue #466) ─────────────────────────────────────────────
//
// Verifies that every numeric display that can vary over time carries the
// `tabular-nums` class (font-variant-numeric: tabular-nums) so digits use
// fixed-width glyphs and the layout does not shift as values change.
//
// Tests check the *className* contract rather than the rendered CSS value
// because jsdom does not evaluate stylesheets. The CSS rule that backs the
// class is in src/styles/typography.css inside @layer typography.

describe("tabular-nums numeric display (Issue #466)", () => {
// ── Hero panel ──────────────────────────────────────────────────────────

it("hero price panel carries tabular-nums class", () => {
renderWithProviders(<ApiDetailPage />);
settleLoadingState();

// .api-detail-price is the large price shown in the right-rail hero panel.
const priceEl = document.querySelector(".api-detail-price");
expect(priceEl).toBeTruthy();
expect(priceEl?.classList.contains("tabular-nums")).toBe(true);
});

it("hero meta price (provider line) carries tabular-nums class", () => {
renderWithProviders(<ApiDetailPage />);
settleLoadingState();

// The <strong> with the price in "Provider · $X per request" line.
const metaPrice = document.querySelector(".api-detail-meta strong.tabular-nums");
expect(metaPrice).toBeTruthy();
});

// ── Overview — Performance Metrics ──────────────────────────────────────

it("all three performance metric stat values carry tabular-nums class", () => {
renderWithProviders(<ApiDetailPage />);
settleLoadingState();

// Default tab is Overview — stat cards are visible immediately.
const statValues = document.querySelectorAll(".stat-card .tabular-nums");
// Three metrics: Total Requests, Latency P95, System Uptime.
expect(statValues.length).toBeGreaterThanOrEqual(3);
});

it("each performance metric stat value also carries the stat-card__value class", () => {
renderWithProviders(<ApiDetailPage />);
settleLoadingState();

const statValues = document.querySelectorAll(".stat-card__value");
// Three metrics expected.
expect(statValues.length).toBeGreaterThanOrEqual(3);
statValues.forEach((el) => {
expect(el.classList.contains("tabular-nums")).toBe(true);
});
});

it("Total Requests stat value text is rendered inside a tabular-nums element", () => {
renderWithProviders(<ApiDetailPage />);
settleLoadingState();

// The first .stat-card__value element corresponds to Total Requests.
const statCards = document.querySelectorAll(".stat-card");
const totalRequestsCard = Array.from(statCards).find((card) =>
card.textContent?.includes("Total Requests"),
);
expect(totalRequestsCard).toBeTruthy();

const valueEl = totalRequestsCard?.querySelector(".tabular-nums");
expect(valueEl).toBeTruthy();
});

it("Latency P95 stat value is rendered inside a tabular-nums element", () => {
renderWithProviders(<ApiDetailPage />);
settleLoadingState();

const statCards = document.querySelectorAll(".stat-card");
const latencyCard = Array.from(statCards).find((card) =>
card.textContent?.includes("Latency"),
);
expect(latencyCard).toBeTruthy();

const valueEl = latencyCard?.querySelector(".tabular-nums");
expect(valueEl).toBeTruthy();
});

it("System Uptime stat value is rendered inside a tabular-nums element", () => {
renderWithProviders(<ApiDetailPage />);
settleLoadingState();

const statCards = document.querySelectorAll(".stat-card");
const uptimeCard = Array.from(statCards).find((card) =>
card.textContent?.includes("System Uptime"),
);
expect(uptimeCard).toBeTruthy();

const valueEl = uptimeCard?.querySelector(".tabular-nums");
expect(valueEl).toBeTruthy();
});

// ── Pricing tab — plan price ────────────────────────────────────────────

it("standard plan price carries tabular-nums class on pricing tab", () => {
renderWithProviders(<ApiDetailPage />);
settleLoadingState();

fireEvent.click(screen.getByRole("tab", { name: "Pricing" }));

// .api-detail-plan-price is the large per-call price in the pro plan card.
const planPrices = document.querySelectorAll(".api-detail-plan-price");
expect(planPrices.length).toBeGreaterThanOrEqual(1);

// The standard (pro) plan price element must have tabular-nums.
const standardPrice = Array.from(planPrices).find((el) =>
el.textContent?.includes("$"),
);
expect(standardPrice).toBeTruthy();
expect(standardPrice?.classList.contains("tabular-nums")).toBe(true);
});

// ── Pricing tab — cost calculator ───────────────────────────────────────

it("cost calculator monthly volume span carries tabular-nums class", () => {
renderWithProviders(<ApiDetailPage />);
settleLoadingState();

fireEvent.click(screen.getByRole("tab", { name: "Pricing" }));

// The "X Requests" span next to the Monthly Volume label.
const volumeSpan = document.querySelector(".api-detail-calculator-total")
?.closest(".preview-card")
?.querySelector("span.tabular-nums");
// Fallback: search in the entire pricing section.
const anyVolumeSpan = document.querySelector("span.tabular-nums");
expect(anyVolumeSpan).toBeTruthy();
});

it("cost calculator estimated total carries tabular-nums class", () => {
renderWithProviders(<ApiDetailPage />);
settleLoadingState();

fireEvent.click(screen.getByRole("tab", { name: "Pricing" }));

// The large estimated-total amount element.
const totalEl = document.querySelector(".api-detail-calculator-total__amount");
expect(totalEl).toBeTruthy();
expect(totalEl?.classList.contains("tabular-nums")).toBe(true);
});

it("estimated total carries the api-detail-calculator-total__amount class", () => {
renderWithProviders(<ApiDetailPage />);
settleLoadingState();

fireEvent.click(screen.getByRole("tab", { name: "Pricing" }));

expect(document.querySelector(".api-detail-calculator-total__amount")).toBeTruthy();
});

it("cost calculator estimated total updates as the slider moves and stays tabular", () => {
renderWithProviders(<ApiDetailPage />);
settleLoadingState();

fireEvent.click(screen.getByRole("tab", { name: "Pricing" }));

const slider = screen.getByRole("slider");

// Move slider to 10,000 requests
fireEvent.change(slider, { target: { value: "10000" } });

const totalEl = document.querySelector(".api-detail-calculator-total__amount");
expect(totalEl).toBeTruthy();
// The element must still carry tabular-nums after re-render.
expect(totalEl?.classList.contains("tabular-nums")).toBe(true);
// Displayed amount must start with "$" (numeric, not empty).
expect(totalEl?.textContent?.startsWith("$")).toBe(true);
});
});
});
9 changes: 6 additions & 3 deletions src/pages/ApiDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,8 @@ print(response.json())`;
].map(({ label, value, color }) => (
<div key={label} className="stat-card">
<div style={{ fontSize: "var(--mkt-font-size-micro)", color: "var(--muted)", textTransform: "uppercase" }}>{label}</div>
<div style={{ fontSize: "var(--mkt-font-size-xl)", fontWeight: 700, marginTop: "var(--mkt-space-md)", color }}>{value}</div>
{/* tabular-nums prevents digit-width jitter on live-updating stats (#466) */}
<div className="tabular-nums stat-card__value" style={{ fontSize: "var(--mkt-font-size-xl)", fontWeight: 700, marginTop: "var(--mkt-space-md)", color }}>{value}</div>
</div>
))}
</div>
Expand Down Expand Up @@ -813,7 +814,8 @@ print(response.json())`;
{/* Standard plan */}
<div className="preview-card" style={{ padding: "var(--mkt-space-3xl)", border: "2px solid var(--accent)" }}>
<PlanBadge tier="pro" />
<div className="api-detail-plan-price">
{/* tabular-nums prevents digit-width jitter on formatted prices (#466) */}
<div className="api-detail-plan-price tabular-nums">
{`$${formatPrice(api.pricePerRequest ?? 0)}`} <span style={{ fontSize: "var(--mkt-font-size-tag)", color: "var(--muted)" }}>/ call</span>
</div>
<p style={{ fontSize: "var(--mkt-font-size-tag)", color: "var(--muted)" }}>Perfect for startups and scaling applications. Pay only for what you use.</p>
Expand Down Expand Up @@ -869,7 +871,8 @@ print(response.json())`;
<div className="api-detail-calculator-total">
<div>
<div style={{ fontSize: "var(--mkt-font-size-micro)", color: "var(--muted)" }}>Estimated Monthly Total</div>
<div style={{ fontSize: "var(--mkt-font-size-2xl)", fontWeight: 800, color: "var(--text)" }}>{estimatedCost(requests)}</div>
{/* tabular-nums prevents layout shift as the slider moves (#466) */}
<div className="tabular-nums api-detail-calculator-total__amount" style={{ fontSize: "var(--mkt-font-size-2xl)", fontWeight: 800, color: "var(--text)" }}>{estimatedCost(requests)}</div>
</div>
<div style={{ textAlign: "right", fontSize: "var(--mkt-font-size-sm)", color: "var(--muted)" }}>
* Volume discounts apply automatically
Expand Down
107 changes: 76 additions & 31 deletions src/styles/typography.css
Original file line number Diff line number Diff line change
@@ -1,35 +1,80 @@
/* Numeric typography utilities
numeric-tabular / tabular-nums — fixed-width numerals so digits (especially
in tables, stats, and prices) don't shift as values change. This meets a
common data-display requirement and improves readability of financial and
metric readouts across light and dark themes.
/* src/styles/typography.css
*
* Numeric typography utilities for the Callora design system.
*
* numeric-tabular / tabular-nums — fixed-width numerals so digits (especially
* in tables, stats, and prices) don't shift as values change. This meets a
* common data-display requirement and improves readability of financial and
* metric readouts across light and dark themes.
*
* All rules live inside @layer typography so they are lower-priority than
* intentional component overrides but higher-priority than browser defaults.
*
* Usage:
* • Add className="tabular-nums" (or the canonical "numeric-tabular") to
* any element that displays a number that may change over time: prices,
* counters, percentages, latency values, etc.
* • Container rules (.stat-card__value, .api-detail-plan-price, etc.) act
* as a defensive fallback — the explicit className remains canonical.
*
* GrantFox FWC26 (Stellar Wave) — Issue #466: ApiDetailPage tabular-nums v7
*/

Use `.numeric-tabular` (canonical name, matches the design system doc and
ApiCard). `.tabular-nums` is kept as an alias for backwards compatibility. */
.numeric-tabular,
.tabular-nums {
font-variant-numeric: tabular-nums;
}
@layer typography {
/* ── Core utility classes ─────────────────────────────────────────────────
.numeric-tabular is the canonical design-system name (matches the
UI-Design-System.md docs and ApiCard). .tabular-nums is kept as an
alias for backwards compatibility with existing JSX. */
.numeric-tabular,
.tabular-nums {
font-variant-numeric: tabular-nums;
}

/* Ensure amounts/counts inside TagChip use tabular-nums */
.tag-chip {
font-variant-numeric: tabular-nums;
}
/* ── TagChip amounts ──────────────────────────────────────────────────────
Ensure amounts/counts inside TagChip use tabular-nums. */
.tag-chip {
font-variant-numeric: tabular-nums;
}

/* ── MarketplacePage count bar ──────────────────────────────────────────────
Belt-and-suspenders container rule so every numeric descendant inside the
"Showing X–Y of N APIs" label is automatically tabular, even if a span is
added in the future without the explicit .numeric-tabular class.
Individual <span class="numeric-tabular"> wrappers remain the canonical
mechanism — this rule is a defensive fallback (GrantFox FWC26). */
.marketplace-count {
font-variant-numeric: tabular-nums;
}
/* ── MarketplacePage count bar ──────────────────────────────────────────
Belt-and-suspenders container rule so every numeric descendant inside the
"Showing X–Y of N APIs" label is automatically tabular, even if a span is
added in the future without the explicit .numeric-tabular class.
Individual <span class="numeric-tabular"> wrappers remain the canonical
mechanism — this rule is a defensive fallback (GrantFox FWC26). */
.marketplace-count {
font-variant-numeric: tabular-nums;
}

/* ── Marketplace filter-active badge ────────────────────────────────────
The numeric count of active filters shown on the mobile "Filters" button
must also use tabular numerals so single-digit and double-digit counts
don't shift the badge width unexpectedly. */
.marketplace-filter-badge {
font-variant-numeric: tabular-nums;
}

/* ── Marketplace filter-active badge ────────────────────────────────────────
The numeric count of active filters shown on the mobile "Filters" button
must also use tabular numerals so single-digit and double-digit counts
don't shift the badge width unexpectedly. */
.marketplace-filter-badge {
font-variant-numeric: tabular-nums;
}
/* ── ApiDetailPage: performance metric stat values ───────────────────────
Applied to the value <div> inside each .stat-card on the Overview tab
(Total Requests, Latency P95, System Uptime). The explicit
className="tabular-nums" on each element is the canonical approach;
this container rule is a defensive fallback. */
.stat-card__value {
font-variant-numeric: tabular-nums;
}

/* ── ApiDetailPage: plan price ────────────────────────────────────────────
Applied to the large price display inside pricing plan cards
(.api-detail-plan-price). Prevents digit-width jitter when prices
include varying digit counts (e.g. $0.001 vs $0.0001). */
.api-detail-plan-price {
font-variant-numeric: tabular-nums;
}

/* ── ApiDetailPage: cost calculator estimated total ─────────────────────
The large estimated-total amount in the cost calculator updates live as
the range slider moves. Tabular numerals prevent layout shift. */
.api-detail-calculator-total__amount {
font-variant-numeric: tabular-nums;
}
}
Loading