diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx
index b111d9f1d8e5..6ba5f99a4f90 100644
--- a/apps/desktop/src/app/settings/billing/index.test.tsx
+++ b/apps/desktop/src/app/settings/billing/index.test.tsx
@@ -618,9 +618,11 @@ describe('BillingSettings', () => {
expect((await screen.findByText('$0 of $220 left ยท $0.79 over')).classList.contains('text-destructive')).toBe(true)
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits remaining' })
- expect(subscriptionTrack.classList.contains('dither')).toBe(true)
- expect(subscriptionTrack.classList.contains('text-destructive/60')).toBe(true)
- expect(subscriptionTrack.classList.contains('bg-destructive/10')).toBe(true)
+ // Plain shared primitive track (no bespoke dither/tinted chrome); the
+ // over-limit signal rides the destructive fill instead.
+ expect(subscriptionTrack.classList.contains('dither')).toBe(false)
+ expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
+ expect(subscriptionTrack.querySelector('.bg-destructive')).toBeTruthy()
})
it('renders an empty neutral usage track when a row has no bar data', async () => {
@@ -645,13 +647,15 @@ describe('BillingSettings', () => {
expect(subscriptionTrack.getAttribute('aria-valuenow')).toBe('0')
expect(subscriptionTrack.classList.contains('text-destructive')).toBe(false)
- expect(subscriptionTrack.classList.contains('dither')).toBe(true)
+ // Empty tracks are the plain shared primitive now โ no hatched placeholder.
+ expect(subscriptionTrack.classList.contains('dither')).toBe(false)
+ expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
const monthlyCapTrack = screen.getByRole('progressbar', { name: 'Monthly spend cap used' })
expect(monthlyCapTrack.getAttribute('aria-valuenow')).toBe('0')
- expect(monthlyCapTrack.classList.contains('dither')).toBe(true)
- expect(monthlyCapTrack.classList.contains('bg-(--ui-bg-elevated)')).toBe(true)
+ expect(monthlyCapTrack.classList.contains('dither')).toBe(false)
+ expect(monthlyCapTrack.classList.contains('bg-muted')).toBe(true)
})
it('shows a warn notice that names the no-card blocker with a portal link', async () => {
diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx
index e64fba89c886..4bd8e70a9688 100644
--- a/apps/desktop/src/app/settings/billing/index.tsx
+++ b/apps/desktop/src/app/settings/billing/index.tsx
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
+import { Progress } from '@/components/ui/progress'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { BarChart3, CreditCard, ExternalLink, Package, Wrench } from '@/lib/icons'
@@ -46,16 +47,6 @@ const BILLING_DEV_FIXTURE_NAMES = import.meta.env.DEV
type BillingFixtureSelection = 'live' | BillingDevFixtureName
-// DEV-only: a canned ~60%-full "ok" bar so the active progress-bar treatment can be
-// eyeballed on any account (a live/empty account only ever shows the hatch track).
-const DEV_PREVIEW_USAGE_ROW: BillingUsageRowView = {
- bar: { label: 'Example usage (dev preview)', state: 'ok', tone: 'subscription', value: 0.62 },
- caption: 'Preview only โ dev build',
- id: 'subscription_credits',
- title: 'Example bar (dev preview)',
- value: '$62 of $100 left'
-}
-
function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | 'primary'; value: string }) {
return (
@@ -320,44 +311,20 @@ function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fa
value: 0
}
- const width = Math.round(resolvedBar.value * 100)
- const isEmpty = resolvedBar.value === 0
- const showDangerNub = resolvedBar.track === 'danger' && resolvedBar.state === 'danger' && width === 0
+ // Plain shared primitive โ no bespoke track chrome. Only the fill tone carries
+ // billing meaning: destructive when over-limit, green for healthy remaining
+ // credits, muted otherwise. Color rides the sanctioned `fillClassName` override.
+ const isOk = resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup')
return (
-
- {showDangerNub &&
}
-
0 ? 4 : undefined,
- width: `${width}%`
- }}
- />
-
+ destructive={resolvedBar.state === 'danger'}
+ fillClassName={resolvedBar.state === 'danger' ? undefined : isOk ? 'bg-(--ui-green)' : 'bg-muted-foreground/45'}
+ fillStyle={{ minWidth: resolvedBar.value > 0 ? 4 : undefined }}
+ size="lg"
+ value={resolvedBar.value}
+ />
)
}
@@ -523,9 +490,6 @@ function BillingSettingsContent({
{view.usageRows.map(row => (
))}
- {/* DEV-only: a fully-filled example so the active (non-disabled) bar can be
- reviewed on any account. Compiled out of production. */}
- {import.meta.env.DEV &&
}
)}
diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts
index c36ca4033284..a132e057db96 100644
--- a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts
+++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts
@@ -147,11 +147,14 @@ describe('deriveBillingView', () => {
expect(buyCredits).toMatchObject({
action: { disabled: true, label: 'Buy' },
- description:
- '๐ณ No saved card for terminal charges yet. Set one up on the portal ' +
- "(one-time credit buys don't save a reusable card)."
+ // The no-card blocker is explained once by the page-level notice, not
+ // duplicated (emoji and all) into the row description.
+ description: 'A single charge on your card, added to your balance today.'
})
+ expect(buyCredits?.description).not.toContain('๐ณ')
expect(buyCredits?.chips?.map(chip => chip.disabled)).toEqual([true, true, true])
+ // The page still leads with the warn banner naming the blocker + fix.
+ expect(view.notice).toMatchObject({ title: 'No payment method on file', tone: 'warn' })
})
it('derives a calm logged-out card with no account or usage rows', () => {
diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts
index e1af94db1feb..7611a055508c 100644
--- a/apps/desktop/src/app/settings/billing/use-billing-state.ts
+++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts
@@ -556,14 +556,13 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
if (!billing.card) {
+ // The no-card blocker is already spelled out by the page-level warn banner
+ // (noCardNotice); repeating it here โ emoji and all โ just clutters the row,
+ // so keep the plain "what buying does" line and let the controls sit disabled.
return {
action: { disabled: true, label: 'Buy' },
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
- description: resolveRefusal({
- kind: 'no_payment_method',
- message: '',
- portalUrl: billing.portal_url ?? undefined
- }).message,
+ description: 'A single charge on your card, added to your balance today.',
id: 'buy_credits',
title: 'Buy credits now'
}
diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx
index 0ae671535b0a..be2d4c9f8237 100644
--- a/apps/desktop/src/app/updates-overlay.tsx
+++ b/apps/desktop/src/app/updates-overlay.tsx
@@ -13,6 +13,7 @@ import {
} from '@/components/ui/dialog'
import { ErrorIcon, ErrorState } from '@/components/ui/error-state'
import { Loader } from '@/components/ui/loader'
+import { Progress } from '@/components/ui/progress'
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
import { useI18n } from '@/i18n'
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
@@ -396,15 +397,7 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend
) : null}
-
+
{recentLog.length > 1 ? (
diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx
index f15bd79cdc2d..b701fa9ff6a1 100644
--- a/apps/desktop/src/components/desktop-install-overlay.tsx
+++ b/apps/desktop/src/components/desktop-install-overlay.tsx
@@ -6,6 +6,7 @@ import { Codicon } from '@/components/ui/codicon'
import { ErrorIcon } from '@/components/ui/error-state'
import { Loader } from '@/components/ui/loader'
import { LogView } from '@/components/ui/log-view'
+import { Progress } from '@/components/ui/progress'
import type {
DesktopBootstrapEvent,
DesktopBootstrapStageDescriptor,
@@ -435,12 +436,12 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
{progressPct}%
-
+
)}
diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx
index 208efcb962f9..0acc4c044d8c 100644
--- a/apps/desktop/src/components/onboarding/index.tsx
+++ b/apps/desktop/src/components/onboarding/index.tsx
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
+import { Progress } from '@/components/ui/progress'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, ChevronDown, ChevronLeft, KeyRound, Loader2 } from '@/lib/icons'
@@ -372,15 +373,12 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
{installing ? t.onboarding.preparingInstall : t.onboarding.starting}
-
+
{boot.message}
{progress}%
diff --git a/apps/desktop/src/components/pet/pet-egg-hatch.tsx b/apps/desktop/src/components/pet/pet-egg-hatch.tsx
index f542a5a04880..4f3806d9d05f 100644
--- a/apps/desktop/src/components/pet/pet-egg-hatch.tsx
+++ b/apps/desktop/src/components/pet/pet-egg-hatch.tsx
@@ -9,6 +9,7 @@
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
import { Button } from '@/components/ui/button'
+import { Progress } from '@/components/ui/progress'
interface PetEggHatchProps {
subtitle?: string
@@ -23,22 +24,17 @@ interface PetEggHatchProps {
*/
export function PetProgress({ done, total }: { done?: number; total?: number }) {
const determinate = typeof done === 'number' && typeof total === 'number' && total > 0
- const pct = determinate ? Math.min(100, Math.round((done / total) * 100)) : 0
return (
-
- {determinate ? (
-
- ) : (
-
- )}
-
+
)
}
diff --git a/apps/desktop/src/components/ui/progress.tsx b/apps/desktop/src/components/ui/progress.tsx
new file mode 100644
index 000000000000..dee39646ded1
--- /dev/null
+++ b/apps/desktop/src/components/ui/progress.tsx
@@ -0,0 +1,82 @@
+import * as React from 'react'
+
+import { cn } from '@/lib/utils'
+
+const TRACK_HEIGHT = {
+ sm: 'h-1',
+ default: 'h-1.5',
+ lg: 'h-2'
+} as const
+
+export interface ProgressProps extends Omit
, 'children'> {
+ /** Completion as a 0โ1 fraction; clamped. Ignored when `indeterminate`. */
+ value?: number
+ /** No known endpoint โ animate instead of showing a fixed width. */
+ indeterminate?: boolean
+ /**
+ * Use the continuous sliding animation for the indeterminate state (the pet
+ * hatch look) instead of the default pulse. No effect when determinate.
+ */
+ animated?: boolean
+ /** Swap the fill to the destructive color for error / over-limit states. */
+ destructive?: boolean
+ size?: keyof typeof TRACK_HEIGHT
+ /** Override the fill color/shape (e.g. billing's tone mapping, pet's accent). */
+ fillClassName?: string
+ fillStyle?: React.CSSProperties
+ /** Extra content inside the track, behind the fill (e.g. a threshold marker). */
+ children?: React.ReactNode
+}
+
+/**
+ * The app's one progress/meter bar: a rounded track with an animated fill. The
+ * track owns `role="progressbar"` and its aria values; the fill is width-driven
+ * (determinate) or animated (indeterminate). Consumers needing a bespoke look โ
+ * billing's hatched empty state, tone-mapped or accent fills โ override
+ * `className` (track) and `fillClassName` while inheriting the structure,
+ * sizing, and accessibility.
+ */
+export function Progress({
+ value = 0,
+ indeterminate = false,
+ animated = false,
+ destructive = false,
+ size = 'default',
+ className,
+ fillClassName,
+ fillStyle,
+ children,
+ ...props
+}: ProgressProps) {
+ const pct = Math.round(Math.min(1, Math.max(0, value)) * 100)
+ const fillColor = destructive ? 'bg-destructive' : 'bg-primary'
+
+ return (
+
+ {children}
+ {indeterminate && animated ? (
+ // Sliding block โ width/animation come from `.progress-slide` (styles.css),
+ // which also honors prefers-reduced-motion.
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index f0ec6bbf0233..6216e94a9f64 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -1806,35 +1806,14 @@ text-* variant utilities. */ .btn-arc {
/* Pet generation progress bar โ determinate (hatch rows: done/total) or */
/* indeterminate (drafts, which return together so a % would just snap). */
-.pet-progress {
- position: relative;
- height: 0.25rem;
- width: 100%;
- overflow: hidden;
- border-radius: 9999px;
- background: color-mix(in srgb, var(--ui-accent) 15%, transparent);
-}
-
-.pet-progress__fill {
- position: absolute;
- inset: 0 auto 0 0;
- height: 100%;
- border-radius: 9999px;
- background: var(--ui-accent);
- transition: width 320ms ease;
-}
-
-.pet-progress__indeterminate {
- position: absolute;
- top: 0;
- bottom: 0;
+/* Sliding indeterminate block for the `accent` Progress variant (pet hatch
+ flow). Color/positioning come from the primitive; this owns the animation. */
+.progress-slide {
width: 40%;
- border-radius: 9999px;
- background: var(--ui-accent);
- animation: pet-progress-slide 1.15s ease-in-out infinite;
+ animation: progress-slide 1.15s ease-in-out infinite;
}
-@keyframes pet-progress-slide {
+@keyframes progress-slide {
0% {
left: -42%;
}
@@ -1844,7 +1823,7 @@ text-* variant utilities. */ .btn-arc {
}
@media (prefers-reduced-motion: reduce) {
- .pet-progress__indeterminate {
+ .progress-slide {
animation: none;
left: 0;
width: 100%;