refactor(desktop): extract shared Progress primitive; billing uses it

Add components/ui/progress.tsx — one rounded track + animated fill that owns
role="progressbar" + aria. Migrate the hand-rolled bars (updates overlay,
onboarding, install overlay, billing usage, pet hatch) onto it.

- Pet's bar was never a color variant (--primary and --ui-accent are the same
  brand color); its only real difference is the sliding indeterminate, now an
  `animated` prop. Color stays a normal `fillClassName` override.
- Billing usage now uses the plain primitive — dropped the bespoke dither
  track, inset shadow, and danger nub; tone rides `destructive`/`fillClassName`.
- Drop the redundant emoji "no saved card" row description; the page-level
  warn banner is the single explainer.
- Rename pet CSS .pet-progress* -> generic .progress-slide.
This commit is contained in:
Brooklyn Nicholson 2026-07-22 18:53:26 -05:00
parent 066b53b282
commit e4b2b77852
10 changed files with 146 additions and 127 deletions

View file

@ -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 () => {

View file

@ -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 (
<div className="min-w-0">
@ -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 (
<div
<Progress
aria-label={resolvedBar.label}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={width}
className={cn(
// Radius follows the app-wide rounded-full progress-bar idiom.
'relative h-2 w-full overflow-hidden rounded-full',
resolvedBar.track === 'danger'
? 'dither text-destructive/60 bg-destructive/10'
: isEmpty
? // Muted currentColor so the hatch reads as a faint placeholder, not solid ink.
'dither text-(--ui-text-quaternary) bg-(--ui-bg-elevated)'
: 'bg-(--ui-bg-tertiary) shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)]'
)}
role="progressbar"
>
{showDangerNub && <div className="absolute inset-y-0 left-0 z-10 w-2 rounded-full bg-destructive" />}
<div
className={cn(
'relative h-full rounded-full transition-[width] duration-300 ease-out',
resolvedBar.state === 'danger'
? 'bg-destructive'
: resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup')
? 'bg-(--ui-green)'
: 'bg-muted-foreground/45'
)}
style={{
minWidth: resolvedBar.value > 0 ? 4 : undefined,
width: `${width}%`
}}
/>
</div>
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 => (
<UsageRow key={row.id} row={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 && <UsageRow row={DEV_PREVIEW_USAGE_ROW} />}
</div>
</SettingsSection>
)}

View file

@ -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', () => {

View file

@ -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'
}

View file

@ -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}
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
percent === null && 'w-1/3 animate-pulse'
)}
style={percent !== null ? { width: `${percent}%` } : undefined}
/>
</div>
<Progress aria-label={label} indeterminate={percent === null} size="lg" value={percent === null ? 0 : percent / 100} />
{recentLog.length > 1 ? (
<div className="max-h-24 overflow-hidden rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-left font-mono text-[11px] leading-4 text-muted-foreground">

View file

@ -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
</span>
<span className="tabular-nums">{progressPct}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
<div
className={cn('h-full transition-all duration-300', failed ? 'bg-destructive' : 'bg-primary')}
style={{ width: `${progressPct}%` }}
/>
</div>
<Progress
aria-label={copy.progress(completedCount, totalCount)}
className="bg-(--ui-bg-tertiary)"
destructive={failed}
value={progressPct / 100}
/>
</div>
)}

View file

@ -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 }) {
<p className="text-sm text-muted-foreground">
{installing ? t.onboarding.preparingInstall : t.onboarding.starting}
</p>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
hasError && 'bg-destructive'
)}
style={{ width: `${progress}%` }}
/>
</div>
<Progress
aria-label={installing ? t.onboarding.preparingInstall : t.onboarding.starting}
destructive={hasError}
size="lg"
value={progress / 100}
/>
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span className="truncate">{boot.message}</span>
<span>{progress}%</span>

View file

@ -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 (
<div
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={determinate ? pct : undefined}
className="pet-progress"
role="progressbar"
>
{determinate ? (
<div className="pet-progress__fill" style={{ width: `${pct}%` }} />
) : (
<div className="pet-progress__indeterminate" />
)}
</div>
<Progress
animated
aria-label="Hatching progress"
className="bg-[color-mix(in_srgb,var(--ui-accent)_15%,transparent)]"
fillClassName="bg-(--ui-accent)"
indeterminate={!determinate}
size="sm"
value={determinate ? done / total : 0}
/>
)
}

View file

@ -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<React.ComponentProps<'div'>, 'children'> {
/** Completion as a 01 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 (
<div
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={indeterminate ? undefined : pct}
className={cn('relative w-full overflow-hidden rounded-full bg-muted', TRACK_HEIGHT[size], className)}
data-slot="progress"
role="progressbar"
{...props}
>
{children}
{indeterminate && animated ? (
// Sliding block — width/animation come from `.progress-slide` (styles.css),
// which also honors prefers-reduced-motion.
<div className={cn('progress-slide absolute inset-y-0 rounded-full', fillColor, fillClassName)} />
) : (
<div
className={cn(
'relative h-full rounded-full transition-[width] duration-300 ease-out',
fillColor,
indeterminate && 'w-1/3 animate-pulse',
fillClassName
)}
style={indeterminate ? fillStyle : { width: `${pct}%`, ...fillStyle }}
/>
)}
</div>
)
}

View file

@ -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%;