diff --git a/eslint.config.mjs b/eslint.config.mjs
index 88eeea84..df4b1203 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -182,19 +182,18 @@ export default defineConfig([
// that needs it, and read by a plain mapper function via `readInlineData`
// (`device-{row,selector,}-fields.ts` compose into a ladder this way). The
// consumer is a function in another file by construction.
- // - `subscription-settings-view` spreads the device-plan-picker's fragments
- // and hands the refs down through PaywallBody and DeviceManagementCard to
- // the picker that reads them. Passing a fragment ref through an intermediate
- // component is ordinary Relay; the rule only recognises a direct render.
+ // - `use-plan-checkout` (the paywall form's query, shared by the lock screen
+ // and the Activate Subscription modal) spreads the device-plan-picker's
+ // fragments and hands the refs down through PlanCheckoutCards and
+ // DeviceManagementCard to the picker that reads them. Passing a fragment
+ // ref through an intermediate component is ordinary Relay; the rule only
+ // recognises a direct render.
//
// Everywhere else the rule stays on, which is where it earns its place: a
// component-owned fragment spread far from the component that reads it is how
// Relay codebases rot.
name: 'openframe-frontend/fragment-definitions-render-nothing',
- files: [
- 'src/graphql/**/*.ts',
- 'src/app/(app)/settings/billing-usage/subscription/components/subscription-settings-view.tsx',
- ],
+ files: ['src/graphql/**/*.ts', 'src/app/(app)/settings/billing-usage/subscription/hooks/use-plan-checkout.ts'],
rules: { 'relay/must-colocate-fragment-spreads': 'off' },
},
diff --git a/schema.graphql b/schema.graphql
index 5ffda165..97441335 100644
--- a/schema.graphql
+++ b/schema.graphql
@@ -1,6 +1,6 @@
# Auto-generated by scripts/fetch-schema.mjs via introspection
-# Source: https://tst-notifications.dev.openframe.build/api/graphql
-# Generated: 2026-09-14T16:34:07.687Z
+# Source: https://bnk-tkn.dev4.openframe.build/api/graphql
+# Generated: 2026-09-14T19:34:36.356Z
#
# Do not edit manually. Re-run: npm run fetch-schema
directive @extends on OBJECT | INTERFACE
@@ -137,6 +137,12 @@ enum BillingMetricType {
enum BillingPeriod {
MONTHLY
YEARLY
+
+ """
+ Bought outright rather than on a cycle: the period of a prepaid package, which is paid for once and
+ lasts until it is spent. A plan that holds one cannot be serialized at all without this value.
+ """
+ UNLIMITED
}
""" --- Billing Plan (detailed) ---"""
@@ -235,6 +241,14 @@ input CancelSubscriptionInput {
input CheckoutInput {
products: [ProductCheckoutInput!]!
discountCode: String
+
+ """
+ Money to put towards AI tokens as part of this checkout. Required, and at least the configured
+ minimum, unless the requirement is switched off by configuration: a subscription that starts with an
+ empty bank can use AI only until its free grant runs out. The amount is charged on the checkout's own
+ invoice, and the tokens land when it is paid.
+ """
+ tokenAmountUsd: Float
}
""" --- Subscription / Checkout ---"""
@@ -1277,6 +1291,19 @@ type Mutation {
"""
updateAiSpendCap(amountUsd: Float): SubscriptionDetail!
+ """
+ Buy AI tokens for the given amount in USD. The amount must be a whole number of dollars and at
+ least the configured minimum; the backend converts it to a quantity at the current tiered price
+ and raises an invoice for it. Tokens are credited only once that invoice is paid, so the value
+ returned is where the customer goes to pay it.
+
+ Available to a tenant that has run out of tokens: hitting the AI budget stops AI, not the purchase
+ that lifts it. Not available while the subscription itself is blocked — past due, suspended,
+ cancelled, or an expired trial — because we do not sell more to a tenant that already owes: settle
+ first, then buy.
+ """
+ purchaseTokens(amountUsd: Float!): TokenPurchase!
+
"""
Update the current authenticated tenant's name and/or website. saas-api only.
"""
@@ -1878,8 +1905,12 @@ enum PriceModel {
}
type PriceTier {
- from: Int!
- upTo: Int
+ """
+ First quantity this rate applies to. A Long because a token tier is counted in tokens: at ten dollars a
+ million, a ten-thousand-dollar tier already stands at a billion, well past what an Int can hold.
+ """
+ from: Long!
+ upTo: Long
unitPrice: Float!
}
@@ -1901,7 +1932,14 @@ input ProductCheckoutInput {
productName: OpenframeProduct!
packageOptionId: String
quantity: Int
- payAsYouGoEnabled: Boolean = true
+
+ """
+ Whether the product is billed by the meter beyond its allowance. Leave it out and the product decides:
+ on for a metered product, off for one sold in advance. There is no default here because a single one
+ could only ever be right for one kind, and asking for the meter on a product sold in advance is
+ refused rather than quietly ignored.
+ """
+ payAsYouGoEnabled: Boolean
enabled: Boolean = true
}
@@ -2982,6 +3020,19 @@ type SubscriptionUsage {
What the AI overage accrued so far costs, in USD. This is what aiSpendCapUsd is measured against.
"""
aiSpendUsd: Float!
+
+ """
+ Prepaid tokens still available, net of anything owed, and never below zero: a tenant that kept working
+ through an automatic top-up has nothing left to spend rather than a negative amount of it. What it owes
+ is settled by the next purchase and is not reported here.
+ """
+ purchasedTokensRemaining: Long!
+
+ """
+ What those tokens are worth, priced at the rate each pack was bought at and excluding tax. Zero
+ whenever purchasedTokensRemaining is, so the two never contradict each other.
+ """
+ purchasedTokensRemainingUsd: Float!
}
""" Tag key definition (shared across all entity types)"""
@@ -3178,6 +3229,18 @@ enum TimerState {
COMPLETED
}
+"""What a token purchase left the customer with: an invoice to pay."""
+type TokenPurchase {
+ """Tokens the amount bought at the price in force when it was quoted."""
+ tokens: Long!
+
+ """Amount charged, in USD, excluding tax."""
+ amountUsd: Float!
+
+ """Where the customer pays the invoice. Tokens land once it is paid."""
+ paymentUrl: String!
+}
+
type ToolConnection implements Node {
id: ID!
machineId: String!
diff --git a/src/app/(app)/checkout/cancel/page.tsx b/src/app/(app)/checkout/cancel/page.tsx
index 525484e6..34b983a9 100644
--- a/src/app/(app)/checkout/cancel/page.tsx
+++ b/src/app/(app)/checkout/cancel/page.tsx
@@ -22,7 +22,7 @@ export default function CheckoutCancelPage() {
iconWrapperClassName="bg-ods-error-secondary text-ods-error"
title="Payment Cancelled"
description="No charges were made. You can pick a plan whenever you're ready."
- primaryCta={{ label: 'Back to Billing', href: routes.settings.billingUsage }}
+ primaryCta={{ label: 'Back to Billing', href: routes.settings.billingUsage() }}
secondaryCta={{ label: 'Go to Dashboard', href: routes.dashboard }}
pending={gate === 'loading'}
/>
diff --git a/src/app/(app)/checkout/success/page.tsx b/src/app/(app)/checkout/success/page.tsx
index 6cbdcf99..8810fc21 100644
--- a/src/app/(app)/checkout/success/page.tsx
+++ b/src/app/(app)/checkout/success/page.tsx
@@ -33,7 +33,7 @@ export default function CheckoutSuccessPage() {
title="Payment Successful"
description="Thanks for subscribing. Your plan is activating now — it may take a moment to show up across the app."
primaryCta={{ label: 'Continue to Dashboard', href: routes.dashboard }}
- secondaryCta={{ label: 'View Subscription', href: routes.settings.billingUsage }}
+ secondaryCta={{ label: 'View Subscription', href: routes.settings.billingUsage() }}
pending={gate === 'loading'}
/>
);
diff --git a/src/app/(app)/settings/billing-usage/components/ai-spend-limit-fields.tsx b/src/app/(app)/settings/billing-usage/components/ai-spend-limit-fields.tsx
deleted file mode 100644
index a261e570..00000000
--- a/src/app/(app)/settings/billing-usage/components/ai-spend-limit-fields.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-'use client';
-
-import { CheckCircleIcon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2';
-import { CheckboxBlock, Input, Skeleton } from '@flamingo-stack/openframe-frontend-core/components/ui';
-import { cn } from '@flamingo-stack/openframe-frontend-core/utils';
-import { type ReactNode, useId } from 'react';
-import { type AiSpendLimit, CUSTOM_LIMIT, PRESET_TOKEN_LIMITS } from '../hooks/use-ai-spend-limit';
-import { formatCompactCount, formatCurrency } from '../lib/format';
-
-/**
- * What a limit does once it is reached. Stated wherever the limit is edited, but
- * in different places — above the fields in the modal, below them on the paywall
- * card — so it is a constant rather than part of the fields.
- */
-export const AI_LIMIT_EXPLANATION =
- "When the limit is reached, Fae and Mingo pause until the next cycle. You'll be notified before that happens, and can raise the limit anytime in Settings.";
-
-interface AiSpendLimitFieldsProps {
- limit: AiSpendLimit;
- /** Blocks every control — a save in flight, or a catalog that has not landed. */
- disabled?: boolean;
- /**
- * A choice was settled: a preset clicked, a custom amount finished, or the
- * limit switched off (`null`). Surfaces that write immediately hang the
- * mutation here; the modal leaves it out and saves from its own button.
- */
-
- onCommit?: (capUsd: number | null) => void;
-}
-
-/**
- * The AI spending limit, as controls: the switch, the four amounts, and the
- * custom figure.
- *
- * Shared because the billing page's modal and the paywall's AI card ask the
- * exact same question of the same subscription field. They differ only in when
- * they write the answer, which is what `onCommit` is for — everything the user
- * sees and does is here, once.
- */
-export function AiSpendLimitFields({ limit, disabled = false, onCommit }: AiSpendLimitFieldsProps) {
- const customInputId = useId();
- const customUsd = limit.selection === CUSTOM_LIMIT && limit.tokens != null ? limit.tokensToUsd(limit.tokens) : null;
- /**
- * Every amount here is priced from the metered rate, so without one there is
- * nothing to choose between. The switch stays live regardless — a tenant whose
- * catalog is unavailable must still be able to lift its own limit.
- */
- const hasRate = limit.tokensToUsd(1) != null;
-
- const handleToggle = (checked: boolean) => {
- limit.setEnabled(checked);
- // Switching it off is a complete answer on its own; switching it on is not,
- // so nothing is written until an amount is picked.
- if (!checked) onCommit?.(null);
- };
-
- const handlePreset = (tokens: number) => {
- const usd = limit.tokensToUsd(tokens);
- if (usd == null) return;
- limit.selectPreset(tokens);
- onCommit?.(usd);
- };
-
- // On blur / Enter rather than per keystroke: every intermediate number a user
- // types past ("1", "10", "100") would otherwise be saved as their limit.
- const handleCustomCommit = () => {
- if (limit.selection !== CUSTOM_LIMIT || limit.capUsd == null) return;
- onCommit?.(limit.capUsd);
- };
-
- return (
-
- million tokens}
- onChange={event => limit.setCustomMillions(event.target.value.replace(/[^\d.]/g, ''))}
- onBlur={handleCustomCommit}
- onKeyDown={event => {
- if (event.key === 'Enter') handleCustomCommit();
- }}
- />
- {/* Holds its line whether or not there is an amount yet, so typing
- one does not push the rest of the form down. */}
-
- );
-}
-
-interface LimitTileProps {
- selected: boolean;
- disabled: boolean;
- title: ReactNode;
- subtitle: ReactNode;
- onSelect: () => void;
-}
-
-/**
- * One limit, as a button rather than a radio: the grid is four independent
- * choices of the same shape, and the check mark on the chosen one is the whole
- * selected state — there is no list semantics here for a radio group to carry.
- */
-function LimitTile({ selected, disabled, title, subtitle, onSelect }: LimitTileProps) {
- return (
-
- );
-}
diff --git a/src/app/(app)/settings/billing-usage/components/ai-tokens-limit-modal.tsx b/src/app/(app)/settings/billing-usage/components/ai-tokens-limit-modal.tsx
deleted file mode 100644
index 41955993..00000000
--- a/src/app/(app)/settings/billing-usage/components/ai-tokens-limit-modal.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-'use client';
-
-import { Button } from '@flamingo-stack/openframe-frontend-core/components/ui';
-import { SimpleModal } from '@/app/components/shared/simple-modal';
-import { useAiSpendLimit } from '../hooks/use-ai-spend-limit';
-import { useUpdateAiSpendCap } from '../hooks/use-update-ai-spend-cap';
-import { AI_LIMIT_EXPLANATION, AiSpendLimitFields } from './ai-spend-limit-fields';
-
-interface AiTokensLimitModalProps {
- isOpen: boolean;
- onClose: () => void;
- /** $ per token from the AI product's metered option. `null` locks the fields. */
- tokenPrice: number | null;
- /** The cap stored on the subscription, in USD. `null` = uncapped. */
- capUsd: number | null;
-}
-
-/**
- * Setting the ceiling on a month's AI spend, from the billing page.
- *
- * Unlike the paywall's AI card — which writes every choice as it is made,
- * because the user is mid-purchase and may never press anything else — this
- * saves once, on its own button. The cap is a live setting on a live
- * subscription here, and opening the dialog to look at it must not be able to
- * change it.
- *
- * The mutation answers with the subscription's `id` and new `aiSpendCapUsd`, so
- * Relay normalises the value into the record the page reads. Nothing refetches.
- */
-export function AiTokensLimitModal({ isOpen, onClose, tokenPrice, capUsd }: AiTokensLimitModalProps) {
- // Unmounted while closed, so every opening starts from what is stored rather
- // than from the edits of a dialog that was dismissed.
- if (!isOpen) return null;
-
- return ;
-}
-
-function AiTokensLimitModalBody({ onClose, tokenPrice, capUsd }: Omit) {
- const limit = useAiSpendLimit({ capUsd, tokenPrice });
- const updateCap = useUpdateAiSpendCap();
-
- const handleSave = () => {
- if (!limit.isComplete) return;
- updateCap.mutate(limit.capUsd, { onSuccess: onClose });
- };
-
- return (
-
- {/* Figma splits the footer into two halves and leaves the left one
- empty, so the button fills the right half rather than hugging its
- label. `ModalV2Footer` is a bare `flex`, so the spacer is ours. */}
-
-
- >
- }
- >
-
-
{AI_LIMIT_EXPLANATION}
- {/* Only the save gates the fields: the amounts disable themselves when
- the metered rate is missing, so switching the limit off stays
- possible even then. */}
-
-
-
- );
-}
diff --git a/src/app/(app)/settings/billing-usage/components/ai-top-up-fields.tsx b/src/app/(app)/settings/billing-usage/components/ai-top-up-fields.tsx
new file mode 100644
index 00000000..2a01ff3a
--- /dev/null
+++ b/src/app/(app)/settings/billing-usage/components/ai-top-up-fields.tsx
@@ -0,0 +1,132 @@
+'use client';
+
+import { CheckCircleIcon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2';
+import { Input, Skeleton } from '@flamingo-stack/openframe-frontend-core/components/ui';
+import { cn } from '@flamingo-stack/openframe-frontend-core/utils';
+import { type ReactNode, useId } from 'react';
+import { type AiTopUp, CUSTOM_TOP_UP, TOP_UP_PRESETS_USD } from '../hooks/use-ai-top-up';
+import { formatCompactCount, formatWholeCurrency } from '../lib/format';
+
+/**
+ * What the balance is. Stated wherever it is topped up — the billing page's
+ * modal and the paywall's card — so it is a constant rather than part of the
+ * fields.
+ */
+export const AI_BALANCE_EXPLANATION = 'One unified balance powers AI assistants across all supported models.';
+
+interface AiTopUpFieldsProps {
+ topUp: AiTopUp;
+ /** The uppercase label over the amounts — what this top-up is for on this surface. */
+ label: string;
+ /** Blocks every control — a purchase in flight, or a catalog that has not landed. */
+ disabled?: boolean;
+}
+
+/**
+ * The top-up amount, as controls: three presets, a custom choice, and the field
+ * behind it.
+ *
+ * Shared because the billing page's Manage AI Balance modal and the paywall's
+ * AI Token Balance card ask the exact same question of the same catalog rate.
+ * They differ only in what they do with the answer — everything the user sees
+ * and does is here, once.
+ */
+export function AiTopUpFields({ topUp, label, disabled = false }: AiTopUpFieldsProps) {
+ const customInputId = useId();
+
+ return (
+
+ {/* Nothing picked yet, said once a submit has asked for it. A custom
+ figure's problems go under its own field instead. */}
+ {topUp.error != null && topUp.selection !== CUSTOM_TOP_UP && (
+
{topUp.error}
+ )}
+
+
+ {topUp.selection === CUSTOM_TOP_UP && (
+
+ USD}
+ onChange={event => topUp.setCustomUsd(event.target.value)}
+ />
+ {/* Holds its line whether or not there is an amount yet, so typing
+ one does not push the rest of the form down. */}
+
+ );
+}
+
+interface TopUpTileProps {
+ selected: boolean;
+ disabled: boolean;
+ title: ReactNode;
+ subtitle: ReactNode;
+ onSelect: () => void;
+}
+
+/**
+ * One amount, as a button rather than a radio: the grid is four independent
+ * choices of the same shape, and the check mark on the chosen one is the whole
+ * selected state — there is no list semantics here for a radio group to carry.
+ */
+function TopUpTile({ selected, disabled, title, subtitle, onSelect }: TopUpTileProps) {
+ return (
+
+ );
+}
diff --git a/src/app/(app)/settings/billing-usage/components/auto-top-up-checkbox.tsx b/src/app/(app)/settings/billing-usage/components/auto-top-up-checkbox.tsx
new file mode 100644
index 00000000..b21c278e
--- /dev/null
+++ b/src/app/(app)/settings/billing-usage/components/auto-top-up-checkbox.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { CheckboxBlock, Tag } from '@flamingo-stack/openframe-frontend-core/components/ui';
+import { useId } from 'react';
+import { AUTO_TOP_UP } from '../lib/auto-top-up';
+
+interface AutoTopUpCheckboxProps {
+ /** Locks the control on top of its own lock — a purchase in flight, a catalog still loading. */
+ disabled?: boolean;
+}
+
+/**
+ * "Enable Auto Top-up", wherever the mockups put it: under the amounts in the
+ * Manage AI Balance modal, and under them again on the paywall's AI Token
+ * Balance card.
+ *
+ * Controlled and never toggled: the value is the backend's answer, and today
+ * the backend has none (see `auto-top-up.ts`). The tag says why the box will
+ * not tick, so a locked control does not read as a broken one.
+ */
+export function AutoTopUpCheckbox({ disabled = false }: AutoTopUpCheckboxProps) {
+ const id = useId();
+
+ return (
+ }
+ />
+ );
+}
diff --git a/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx b/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx
index 1f5fa3b5..56958ea5 100644
--- a/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx
+++ b/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx
@@ -3,34 +3,35 @@
import {
AlertTriangleIcon,
ExternalLinkIcon,
- InfoCircleIcon,
- PlusCircleIcon,
- Settings02Icon,
+ Refresh02VrIcon,
TagPercentIcon,
} from '@flamingo-stack/openframe-frontend-core/components/icons-v2';
import { type ActionsMenuGroup, Button, PageLayout } from '@flamingo-stack/openframe-frontend-core/components/ui';
+import { useApiParams } from '@flamingo-stack/openframe-frontend-core/hooks';
import { cn } from '@flamingo-stack/openframe-frontend-core/utils';
import { useState } from 'react';
import { graphql, useLazyLoadQuery } from 'react-relay';
import type { billingUsageContentQuery as BillingUsageContentQueryType } from '@/__generated__/billingUsageContentQuery.graphql';
import { LockedScreen } from '@/app/components/shared/locked-screen';
-import { SubscriptionStatus } from '@/app/components/subscription-lock/subscription-status';
+import { resolveSubscriptionStatus, SubscriptionStatus } from '@/app/components/subscription-lock/subscription-status';
import { useFeatureFlag } from '@/app/hooks/use-feature-flag';
import { useSafeBack } from '@/app/hooks/use-safe-back';
-import { routes } from '@/lib/routes';
-import { TOKENS_PER_MILLION } from '../hooks/use-ai-spend-limit';
+import { MANAGE_AI_BALANCE_ACTION, routes } from '@/lib/routes';
import { useBillingPortalSession } from '../hooks/use-billing-portal-session';
-import { useBillingSummary } from '../hooks/use-billing-summary';
+import { type AiAlert, useBillingSummary } from '../hooks/use-billing-summary';
import { useCancelSubscription } from '../hooks/use-cancel-subscription';
import { useCancellationImpact } from '../hooks/use-cancellation-impact';
import { useResumeSubscription } from '../hooks/use-resume-subscription';
+import { AUTO_TOP_UP } from '../lib/auto-top-up';
import { formatCompactCount, formatCount, formatCurrency, formatDateOrDash } from '../lib/format';
import { openExternalTab } from '../lib/stripe-window';
-import { AiTokensLimitModal } from './ai-tokens-limit-modal';
+import { ActivateSubscriptionModal } from '../subscription/components/activate-subscription-modal';
+import { ModelTokenRatesPopover } from '../subscription/components/model-token-rates';
import { BillingRow, SectionBlock, TestModeBanner } from './billing-section';
import { CancelOfferModal } from './cancel-offer-modal';
import { type CancelReason, CancelSubscriptionModal } from './cancel-subscription-modal';
import { InvoicesHistory } from './invoices-history';
+import { ManageAiBalanceModal } from './manage-ai-balance-modal';
import { SubscriptionCancelledModal } from './subscription-cancelled-modal';
import { TestClockPanel } from './test-clock-panel';
import { UpgradePlanModal } from './upgrade-plan-modal';
@@ -114,17 +115,13 @@ const billingUsageContentQuery = graphql`
devicesUsed
activeDevices
# The AI counters the top row is built from: the period's free grant and
- # how much of it is gone, then the tokens billed past it and what they
- # have cost so far. aiSpendUsd is what aiSpendCapUsd is measured against
- # — the page compares those two and nothing else.
+ # how much of it is gone, then the prepaid balance AI draws from once
+ # the grant is spent — in tokens, and what those are worth.
aiTokensFree
aiTokensFreeUsed
- aiTokensOverage
- aiSpendUsd
+ purchasedTokensRemaining
+ purchasedTokensRemainingUsd
}
- # Customer-set ceiling on the AI overage one period may accrue, in USD.
- # Null means uncapped; 0 is a real cap.
- aiSpendCapUsd
currentInvoice {
estimatedOverage
}
@@ -136,6 +133,25 @@ const billingUsageContentQuery = graphql`
}
`;
+/**
+ * The block under the AI cards, by what it is about. Titles are the mockups'
+ * verbatim; the reset date is appended by the page when the period has one.
+ */
+const AI_ALERT_COPY: Record, { title: string; description: string }> = {
+ 'trial-exhausted': {
+ title: 'AI agents are paused.',
+ description: 'Activate your subscription to keep Mingo and Fae running.',
+ },
+ low: {
+ title: 'AI agents will pause soon.',
+ description: 'Your AI balance is running low. Mingo and Fae stop responding when it hits zero.',
+ },
+ empty: {
+ title: 'AI agents are paused. Your AI balance is empty.',
+ description: 'Mingo and Fae stopped responding until you top up.',
+ },
+};
+
export function BillingUsageContent() {
const handleBack = useSafeBack(routes.settings.root());
// Bumped after a resume so the billing query refetches from the network — the
@@ -151,7 +167,16 @@ export function BillingUsageContent() {
const resumeSubscription = useResumeSubscription();
const billingPortal = useBillingPortalSession();
const [planModalOpen, setPlanModalOpen] = useState(false);
- const [aiLimitModalOpen, setAiLimitModalOpen] = useState(false);
+ const [activateModalOpen, setActivateModalOpen] = useState(false);
+ /**
+ * The Manage AI Balance modal's open state IS the URL (`?action=`): the
+ * app-wide balance bar deep-links to it from any page, and one owner of the
+ * state is what keeps that link and the header button from disagreeing.
+ * Closing clears the param, so a reload does not reopen a dismissed dialog.
+ */
+ const { params: pageParams, setParam: setPageParam } = useApiParams({ action: { type: 'string', default: '' } });
+ const openAiBalanceModal = () => setPageParam('action', MANAGE_AI_BALANCE_ACTION);
+ const closeAiBalanceModal = () => setPageParam('action', '');
const [cancelStep, setCancelStep] = useState<'idle' | 'reason' | 'offer' | 'cancelled'>('idle');
const [cancelReason, setCancelReason] = useState(null);
const [cancelComment, setCancelComment] = useState('');
@@ -170,56 +195,44 @@ export function BillingUsageContent() {
const cancelSubscriptionEnabled = useFeatureFlag('cancel-subscription');
// Nothing to update in place: these three states have no live paid
- // subscription, so a plan change has to go through Stripe Checkout. PAST_DUE
- // and SUSPENDED are deliberately NOT here — those subscriptions still exist.
+ // subscription, so the plan is STARTED, through Stripe Checkout — the
+ // Activate Subscription modal, not the plan change. PAST_DUE and SUSPENDED
+ // are deliberately NOT here — those subscriptions still exist.
const needsCheckout =
status === SubscriptionStatus.TRIAL ||
status === SubscriptionStatus.TRIAL_EXPIRED ||
status === SubscriptionStatus.CANCELED;
+ /**
+ * A trial has no balance to manage: its AI runs on the grant, and what a
+ * paused assistant needs is the subscription, not a top-up. So the modal is
+ * not offered — and not reachable through the URL either, which the app-wide
+ * bar never writes on a trial.
+ */
+ const aiBalanceOffered = flags.hasAi && !flags.isTrial;
+ const aiBalanceModalOpen = aiBalanceOffered && pageParams.action === MANAGE_AI_BALANCE_ACTION;
+
// A committed package is the only thing that gives the device counter a
// denominator, so the same condition decides the caption — the card cannot end
// up reading "247/300" over "Pay as you go", or a bare count over "Prepaid".
const devicePrepaid = !flags.isTrial && device.allocation > 0;
- /**
- * The header carries at most one of these two, and the menu carries the other
- * — never both in both places. Repeating an action in the overflow menu that
- * is already a button beside it makes the menu read as a different, second
- * thing to do.
- *
- * The AI limit wins the header whenever one is set: it is the thing most
- * likely to be in the user's way, and the only one of the two a paused
- * assistant depends on. The plan gets the header only when there is something
- * to move UP to — a monthly plan has the annual one; an annual plan has
- * nothing above it, so its plan change (really a device-count change) belongs
- * in the menu.
- */
- const aiLimitInHeader = flags.hasAi && ai.capUsd != null;
/**
* A scheduled cancellation drops the plan offer everywhere. The subscription
* is already on its way out, so a change would be bought into something that
* ends anyway; renewing is the move that makes the rest meaningful again.
+ * Nor is there a plan to change before one has been bought: on a trial the
+ * header's Activate Subscription is the whole offer.
*/
- const planOffered = !flags.isPendingCancellation;
- const planInHeader = planOffered && !aiLimitInHeader && !plan.isAnnual;
+ const planOffered = !flags.isPendingCancellation && !needsCheckout;
const menuActions: ActionsMenuGroup[] = [
{
items: [
- // Only when the header does not already offer it — which is exactly when
- // there is no limit yet, hence the label.
- ...(flags.hasAi && !aiLimitInHeader
- ? [
- {
- id: 'ai-limit',
- label: 'Set AI Limit',
- icon: ,
- onClick: () => setAiLimitModalOpen(true),
- },
- ]
- : []),
- ...(planOffered && !planInHeader
+ // The plan change lives in the menu whatever the plan: the header's
+ // quieter slot is the AI balance, which is the thing most likely to be
+ // in the user's way (see `secondaryAction`).
+ ...(planOffered
? [
{
id: 'change-plan',
@@ -262,10 +275,10 @@ export function BillingUsageContent() {
* into a subscription. Rendered alongside the plan change rather than instead
* of it — a trial can both be activated and have its device plan chosen.
*
- * All of them end in the same modal — there is no plan page to send anyone to
- * any more. Activation from a trial is the checkout branch of it, which is why
- * the modal folds every other product in as pay-as-you-go: a checkout session
- * describes the whole plan, not just the part the modal edits.
+ * There is no plan page to send anyone to any more: a live plan is changed in
+ * the Upgrade Plan modal, and a trial is turned into a subscription in the
+ * Activate Subscription modal — the paywall's form, which buys the whole plan
+ * (devices, the AI product and the first top-up) on one checkout.
*/
const statusAction = flags.isPendingCancellation
? {
@@ -293,31 +306,24 @@ export function BillingUsageContent() {
: flags.isTrial
? {
label: 'Activate Subscription',
- onClick: () => setPlanModalOpen(true),
+ onClick: () => setActivateModalOpen(true),
variant: 'accent' as const,
}
: null;
- /** The header's second, quieter action — see `aiLimitInHeader` for which one and why. */
- const secondaryAction = aiLimitInHeader
+ /**
+ * The header's second, quieter action: the balance, not the plan. It is the
+ * one of the two a paused assistant depends on, and it is what the app-wide
+ * balance bar deep-links to. Absent without the AI product — there is no
+ * balance to manage — and on a trial, where Activate Subscription stands alone.
+ */
+ const secondaryAction = aiBalanceOffered
? {
- label: 'Expand AI Limit',
- // Secondary, like the menu's icons: the label carries the action, and a
- // white glyph beside a white label reads as two emphases in one button.
- icon: ,
- onClick: () => setAiLimitModalOpen(true),
+ label: 'Manage AI Balance',
+ onClick: openAiBalanceModal,
variant: 'outline' as const,
}
- : planInHeader
- ? {
- // Named for what it does: the only plan above a monthly one is the
- // annual one. Changing the device count is not an upgrade and is
- // offered as "Change Plan" in the menu instead.
- label: 'Upgrade to Annual Plan',
- onClick: () => setPlanModalOpen(true),
- variant: 'outline' as const,
- }
- : null;
+ : null;
/** Rightmost is the accent one: the status action, when there is something to settle. */
const actions = [...(secondaryAction ? [secondaryAction] : []), ...(statusAction ? [statusAction] : [])];
@@ -379,71 +385,71 @@ export function BillingUsageContent() {
/>
}
/>
- {/* Two counters, because AI is metered in two parts: what the period
- gives away, and what is billed past it. Both are server figures — the
- free grant and the tokens beyond it come from `usage`, and the paid
- counter's denominator is the customer's own cap converted at the
- metered rate. With no cap it has none, and none is invented. */}
+ {/* Two counters, because AI runs on two figures: what the period gives
+ away, and the balance it draws from once that is spent. Both are
+ server figures from `usage`; the paid card's colour is the balance's
+ state, the free card's is a trial's (which has no balance to colour). */}
{flags.hasAi && (
<>
{formatCompactCount(ai.freeUsed)}
/{formatCompactCount(ai.free)}
>
}
- caption="Updated monthly"
+ caption={flags.isTrial ? 'Included with trial' : 'Updated monthly'}
/>
{formatCompactCount(ai.paid)}
- {ai.capTokens != null && /{formatCompactCount(ai.capTokens)}}
+ {/* The mockup's mark for a balance that refills itself; the
+ popover beside it spells the same state out in words. */}
+ {AUTO_TOP_UP.enabled && (
+
+ )}
>
}
caption={
<>
- {formatCurrency(ai.spendUsd)} on next invoice
+ {formatCurrency(ai.paidUsd)} balance
>
}
+ trailing={}
/>
>
)}
- {/* The cap the user set is being reached, so AI is about to stop — or has.
- Only the icon carries the colour: the card above already states the
- figure in full, and this block is the sentence explaining it.
-
- The fix is in the header, where the primary button becomes "Expand AI
- Limit" for exactly these two states. */}
- {ai.tone !== 'default' && ai.capUsd != null && (
+ {/* The one sentence the page owes about AI right now: the balance is
+ running low, it is empty, or a trial has spent its grant. Only the icon
+ carries the colour — the card above already states the figure in full,
+ and this block is the sentence explaining it. The fix is in the header:
+ Manage AI Balance, or Activate Subscription on a trial. */}
+ {ai.alert && (
-
- {ai.capReached ? 'AI agents are paused. Your AI balance is empty.' : 'AI agents will pause soon.'}
-
- {/* No figures: the card above states the spend and the limit in full,
- and a block that repeats them turns one fact into two to compare.
- The "balance" here is the headroom left under the cap the user set
- — not the token bank this product stopped selling. */}
+
{AI_ALERT_COPY[ai.alert].title}
- {ai.capReached
- ? // Not "top up": nothing can be bought to resume. The cap is
- // self-imposed and raising it is the only way out, which is
- // exactly what the header button does.
- 'Mingo and Fae stopped responding until you raise the limit.'
- : 'Your AI balance is running low. Mingo and Fae stop responding when it hits zero.'}
+ {AI_ALERT_COPY[ai.alert].description}
{/* Only when the period has a known end — the reset date is that
- date, not a separate fact this can guess at. */}
- {billing.nextBillingDate && ` Free tokens reset on ${formatDateOrDash(billing.nextBillingDate)}.`}
+ date, not a separate fact this can guess at. A trial resets
+ nothing: activation is what refills it. */}
+ {ai.alert !== 'trial-exhausted' &&
+ billing.nextBillingDate &&
+ ` Free tokens reset on ${formatDateOrDash(billing.nextBillingDate)}.`}
@@ -488,121 +494,71 @@ export function BillingUsageContent() {
)}
{/* Side by side once there is a second block to read against the plan —
- the plan it is changing to, or what its metered AI is costing. On its
- own, Current Plan takes the full width. */}
-
0) && 'md:grid-cols-2',
- )}
- >
-
-
- {plan.deviceRate != null && (
- } />
- )}
- {ai.tokenPrice != null && (
- } />
- )}
- {/* The grant the tenant is actually on this period, served by the
- backend — unlike the Updated Plan's, which has to be derived. */}
- {flags.hasAi && } />}
- {!flags.isTrial && nextPaymentAmount > 0 && (
-
- )}
- {/* Independent rows, not one slot fought over by several dates: each is
- present exactly when its own field is (see `useBillingSummary`). A
- plan that is ending still has a billing date, and Figma shows both —
- they land on the same day because the subscription runs to the end
- of the paid period and stops there, which is two facts, not one
- repeated. A trial has no `currentPeriodEnd`, so its row simply never
- appears beside "Trial ends on". */}
- {billing.nextBillingDate && (
-
- )}
- {billing.cancellationEffectiveAt && (
- } />
+ the plan it is changing to. On its own, Current Plan takes the full
+ width. A trial has no plan to state: its one date is on the device
+ card, and the header offers activation. */}
+ {!flags.isTrial && (
+ } />
- )}
- {flags.isTrial && billing.trialExpirationDate && (
- } />
- )}
-
-
- {/* The plan that takes over — a scheduled package, or the metered
- billing a lapsing commitment falls back to. It answers the left
- column's questions in the left column's order, so the two read as one
- comparison; the AI rate is the same either way and is stated on both
- sides rather than left to be assumed unchanged. */}
- {flags.hasPendingPlan && (
-
-
- {updatedPlan.deviceRate != null && (
- } />
+ >
+
+
+ {plan.deviceRate != null && (
+ } />
)}
- {ai.tokenPrice != null && (
- } />
+ {/* The grant the tenant is actually on this period, served by the
+ backend — unlike the Updated Plan's, which has to be derived. */}
+ {flags.hasAi && } />}
+ {nextPaymentAmount > 0 && }
+ {/* Independent rows, not one slot fought over by several dates: each is
+ present exactly when its own field is (see `useBillingSummary`). A
+ plan that is ending still has a billing date, and Figma shows both —
+ they land on the same day because the subscription runs to the end
+ of the paid period and stops there, which is two facts, not one
+ repeated. */}
+ {billing.nextBillingDate && (
+
)}
- {flags.hasAi && (
- } />
+ {billing.cancellationEffectiveAt && (
+ } />
)}
- {updatedPlan.startsOn && (
- } />
+ {billing.currentPlanEndsOn && (
+ } />
)}
- )}
- {/* Metered AI, once any of it has actually been billed. Beside the plan
- rather than under the cards: it is the plan's fine print — what the
- surplus costs, what ceiling it is running into, and when the meter
- resets — and the top card states only the count. */}
- {ai.paid > 0 && (
-
-
-
-
- Extra token usage continues at pay-as-you-go rates and appears on your next invoice.
-
-
-
-
- {/* Stated in both units, because the limit is chosen in tokens and
- charged in dollars — see the AI Tokens Limit modal. */}
- {ai.capUsd != null && (
-
- {ai.capTokens != null && formatCompactCount(ai.capTokens)}
- ({formatCurrency(ai.capUsd)})
- >
- }
- />
- )}
- {billing.nextBillingDate && (
-
- )}
-
- )}
-
+ {/* The plan that takes over — a scheduled package, or the metered
+ billing a lapsing commitment falls back to. It answers the left
+ column's questions in the left column's order, so the two read as
+ one comparison. */}
+ {flags.hasPendingPlan && (
+
+
+ {updatedPlan.deviceRate != null && (
+ } />
+ )}
+ {flags.hasAi && (
+ } />
+ )}
+ {updatedPlan.startsOn && (
+ } />
+ )}
+
+ )}
+
+ )}
- {/* Writes through `updateAiSpendCap`, whose response carries the new cap
- into the same subscription record this page reads — so nothing here
- refetches when it saves. */}
- setAiLimitModalOpen(false)}
- tokenPrice={ai.tokenPrice}
- capUsd={ai.capUsd}
- />
+ {/* Raises an invoice and opens it; the balance moves once that is paid,
+ which is when the page's next fetch reads it. Nothing to refetch here. */}
+ setPlanModalOpen(false)}
onUpdated={() => {
setPlanModalOpen(false);
@@ -610,6 +566,16 @@ export function BillingUsageContent() {
}}
/>
+ {/* Leaves for Stripe in a new tab; the subscription it activates lands on
+ the page's next fetch, not through this modal. */}
+ setActivateModalOpen(false)}
+ />
+
- {formatCurrency(amount)}
- / 1M tokens
- >
- );
-}
-
/** A monthly token grant: the count, with its cadence trailing. */
function MonthlyTokens({ tokens }: { tokens: number }) {
return (
diff --git a/src/app/(app)/settings/billing-usage/components/billing-usage-skeleton.tsx b/src/app/(app)/settings/billing-usage/components/billing-usage-skeleton.tsx
index a573a18a..5298bc49 100644
--- a/src/app/(app)/settings/billing-usage/components/billing-usage-skeleton.tsx
+++ b/src/app/(app)/settings/billing-usage/components/billing-usage-skeleton.tsx
@@ -110,7 +110,6 @@ export function BillingUsageSkeleton() {
} />
} />
- } />
} />
} />
} />
diff --git a/src/app/(app)/settings/billing-usage/components/manage-ai-balance-modal.tsx b/src/app/(app)/settings/billing-usage/components/manage-ai-balance-modal.tsx
new file mode 100644
index 00000000..d84f3a3b
--- /dev/null
+++ b/src/app/(app)/settings/billing-usage/components/manage-ai-balance-modal.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import { Button } from '@flamingo-stack/openframe-frontend-core/components/ui';
+import { SimpleModal } from '@/app/components/shared/simple-modal';
+import { useAiTopUp } from '../hooks/use-ai-top-up';
+import { usePurchaseTokens } from '../hooks/use-purchase-tokens';
+import { AUTO_TOP_UP } from '../lib/auto-top-up';
+import { AI_BALANCE_EXPLANATION, AiTopUpFields } from './ai-top-up-fields';
+import { AutoTopUpCheckbox } from './auto-top-up-checkbox';
+
+interface ManageAiBalanceModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ /** $ per token from the AI product's metered option. `null` until it loads. */
+ tokenPrice: number | null;
+}
+
+/**
+ * Buying AI tokens from the billing page.
+ *
+ * The mockup's "Enable Auto Top-up" — refill the balance with the chosen amount
+ * whenever it runs out — is drawn but locked: the API has nothing to store that
+ * choice in yet (see `auto-top-up.ts`), so the checkbox stays off and says so,
+ * and the amounts below it are a one-time purchase. Once it can be switched on,
+ * the same amounts become the refill amount, as the mockup relabels them.
+ *
+ * The purchase raises an invoice rather than charging on the spot, so the
+ * button reads "Proceed to Payment" rather than the mockup's "Save": nothing is
+ * saved here, and the tokens land once the invoice is paid.
+ *
+ * The button is never locked over the amount. Pressed with nothing chosen or a
+ * figure under the floor, it says so under the fields (`AiTopUp.validate`) — a
+ * disabled button explains nothing, and the user is left guessing what to fix.
+ */
+export function ManageAiBalanceModal({ isOpen, onClose, tokenPrice }: ManageAiBalanceModalProps) {
+ // Unmounted while closed, so every opening starts from nothing chosen rather
+ // than from the edits of a dialog that was dismissed.
+ if (!isOpen) return null;
+
+ return ;
+}
+
+function ManageAiBalanceModalBody({ onClose, tokenPrice }: Omit) {
+ const topUp = useAiTopUp({ tokenPrice });
+ const purchase = usePurchaseTokens();
+
+ const handleSubmit = () => {
+ if (topUp.validate() != null || topUp.amountUsd == null) return;
+ purchase.mutate(topUp.amountUsd, { onSuccess: onClose });
+ };
+
+ return (
+
+ {/* Figma splits the footer into two halves and leaves the left one
+ empty, so the button fills the right half rather than hugging its
+ label. `ModalV2Footer` is a bare `flex`, so the spacer is ours. */}
+
+
+ >
+ }
+ >
+
+
{AI_BALANCE_EXPLANATION}
+
+
+
+
+ );
+}
diff --git a/src/app/(app)/settings/billing-usage/components/upgrade-plan-modal.tsx b/src/app/(app)/settings/billing-usage/components/upgrade-plan-modal.tsx
index 1229df3a..bde4c40f 100644
--- a/src/app/(app)/settings/billing-usage/components/upgrade-plan-modal.tsx
+++ b/src/app/(app)/settings/billing-usage/components/upgrade-plan-modal.tsx
@@ -1,13 +1,12 @@
'use client';
-import { Suspense, useCallback, useMemo, useState } from 'react';
+import { Suspense, useState } from 'react';
import { graphql, useLazyLoadQuery } from 'react-relay';
import type { upgradePlanModalQuery as UpgradePlanModalQueryType } from '@/__generated__/upgradePlanModalQuery.graphql';
import { SimpleModal } from '@/app/components/shared/simple-modal';
import { OpenframeProduct } from '@/generated/schema-enums';
import { DevicePlanPicker } from '../subscription/components/device-plan-picker';
import { SubscriptionSubmitButton } from '../subscription/components/subscription-submit-button';
-import type { ProductCheckoutInput } from '../subscription/hooks/use-create-checkout-session';
import type { ProductUpdates } from '../subscription/types/subscription.types';
const upgradePlanModalQuery = graphql`
@@ -30,32 +29,11 @@ const upgradePlanModalQuery = graphql`
}
`;
-/** What the picker reports up, plus what has to ride along into a checkout session. */
-interface PlanSelection {
- updates: ProductUpdates;
- /**
- * Every non-device product, as pay-as-you-go. A checkout session describes the
- * WHOLE target plan, not a diff, so leaving these out would activate a
- * subscription with the AI assistants switched off. They are metered-only, so
- * "pay as you go" is their entire configuration — the same entry the paywall's
- * AI card contributes.
- */
- otherProducts: ProductCheckoutInput[];
-}
-
/** The loading frame reports nothing: there is no selection to submit yet. */
const NOOP_UPDATES = () => {};
interface UpgradePlanModalProps {
isOpen: boolean;
- /**
- * No active paid subscription (trial, expired trial, canceled) → the submit
- * creates one through Stripe Checkout instead of updating in place. A boolean
- * rather than the status itself: the caller already reads the status, and the
- * two enums in play here (Relay's, with its `%future added value`, and the
- * generated one) do not agree on a type.
- */
- needsCheckout: boolean;
onClose: () => void;
/**
* The plan change was applied. The page has to refetch: the mutation answers
@@ -68,21 +46,22 @@ interface UpgradePlanModalProps {
/**
* Changing the device plan, in place on the billing page.
*
- * The only place a plan is changed. `/settings/billing-usage/subscription` is
- * gone, so this is not a shortcut to a page that also exists — the same
+ * The only place a LIVE plan is changed. `/settings/billing-usage/subscription`
+ * is gone, so this is not a shortcut to a page that also exists — the same
* `DevicePlanPicker` the subscription lock screen shows, in a modal, over its
* own query. The query lives here rather than on the billing page because the
- * catalog, its prices and the device count are only worth fetching once someone
- * opens this.
+ * catalog and its prices are only worth fetching once someone opens this.
+ *
+ * Update flow only. A workspace with no paid subscription yet (a trial) does
+ * not change a plan, it starts one — with the AI top-up the checkout requires,
+ * which this picker has no control for. That is `ActivateSubscriptionModal`.
*/
-export function UpgradePlanModal({ isOpen, needsCheckout, onClose, onUpdated }: UpgradePlanModalProps) {
- const [selection, setSelection] = useState(null);
+export function UpgradePlanModal({ isOpen, onClose, onUpdated }: UpgradePlanModalProps) {
+ const [updates, setUpdates] = useState(null);
// Nothing mounts — and no query runs — until the modal is actually opened.
if (!isOpen) return null;
- const checkoutProducts = selection ? [selection.updates.checkout, ...selection.otherProducts] : [];
-
return (
@@ -114,45 +93,30 @@ export function UpgradePlanModal({ isOpen, needsCheckout, onClose, onUpdated }:
}
>
-
+
);
}
-function UpgradePlanBody({ onSelectionChange }: { onSelectionChange: (selection: PlanSelection) => void }) {
+function UpgradePlanBody({ onUpdatesChange }: { onUpdatesChange: (updates: ProductUpdates) => void }) {
const data = useLazyLoadQuery(
upgradePlanModalQuery,
{},
{ fetchPolicy: 'store-and-network' },
);
- // Memoized: the `?? []` fallback is a new array on every render, and
- // `otherProducts` below depends on it.
- const products = useMemo(() => data.billingPlan?.products ?? [], [data.billingPlan]);
+ const products = data.billingPlan?.products ?? [];
const deviceProduct = products.find(p => p.name === OpenframeProduct.MANAGED_DEVICES) ?? null;
const deviceSubscriptionProduct =
data.subscription?.products.find(p => p.name === OpenframeProduct.MANAGED_DEVICES) ?? null;
- const otherProducts = useMemo(
- () =>
- products
- .filter(p => p.name !== OpenframeProduct.MANAGED_DEVICES)
- .map(p => ({ productName: p.name, payAsYouGoEnabled: true })),
- [products],
- );
-
- const handleUpdatesChange = useCallback(
- (updates: ProductUpdates) => onSelectionChange({ updates, otherProducts }),
- [onSelectionChange, otherProducts],
- );
-
return (
);
}
diff --git a/src/app/(app)/settings/billing-usage/components/usage-stat-card.tsx b/src/app/(app)/settings/billing-usage/components/usage-stat-card.tsx
index 03841da4..e304074c 100644
--- a/src/app/(app)/settings/billing-usage/components/usage-stat-card.tsx
+++ b/src/app/(app)/settings/billing-usage/components/usage-stat-card.tsx
@@ -6,8 +6,9 @@ import type { ReactNode } from 'react';
/**
* How the counter is doing against whatever bounds it.
*
- * - `warning` — heading for the limit: devices past the package, AI spend
- * approaching its cap. It costs more, or it is about to stop.
+ * - `warning` — heading for the limit: devices past the package, an AI balance
+ * running low, a trial that has spent its grant. It costs more, or it is
+ * about to stop.
* - `error` — the limit is reached and something has actually stopped.
*/
export type UsageStatTone = 'default' | 'warning' | 'error';
@@ -24,6 +25,8 @@ interface UsageStatCardProps {
* out what it costs.
*/
tone?: UsageStatTone;
+ /** Pinned to the card's top-right corner, outside the text flow (e.g. a help popover). */
+ trailing?: ReactNode;
}
const TONE_CARD: Record = {
@@ -53,7 +56,7 @@ const TONE_VALUE: Record = {
* no ring, which it has no variant for. So the composition lives here, in the
* page that needs it, on the same ODS tokens the card is built from.
*/
-export function UsageStatCard({ title, value, caption, tone = 'default' }: UsageStatCardProps) {
+export function UsageStatCard({ title, value, caption, tone = 'default', trailing }: UsageStatCardProps) {
return (
// The state travels to the value/caption slots as an attribute rather than a
// prop: both are nodes built by the caller, so the card cannot pass anything
@@ -62,7 +65,7 @@ export function UsageStatCard({ title, value, caption, tone = 'default' }: Usage