From e4b2b77852c24d86181fed002d3f31ade35578b7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 18:53:26 -0500 Subject: [PATCH] refactor(desktop): extract shared Progress primitive; billing uses it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/app/settings/billing/index.test.tsx | 16 ++-- .../src/app/settings/billing/index.tsx | 60 +++----------- .../billing/use-billing-state.test.ts | 9 +- .../app/settings/billing/use-billing-state.ts | 9 +- apps/desktop/src/app/updates-overlay.tsx | 11 +-- .../components/desktop-install-overlay.tsx | 13 +-- .../src/components/onboarding/index.tsx | 16 ++-- .../src/components/pet/pet-egg-hatch.tsx | 24 +++--- apps/desktop/src/components/ui/progress.tsx | 82 +++++++++++++++++++ apps/desktop/src/styles.css | 33 ++------ 10 files changed, 146 insertions(+), 127 deletions(-) create mode 100644 apps/desktop/src/components/ui/progress.tsx 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%;