diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx
index 3555b7db..42e107df 100644
--- a/frontend/src/components/layout/TopBar.tsx
+++ b/frontend/src/components/layout/TopBar.tsx
@@ -6,6 +6,7 @@ import { usePermissions } from '@/hooks/usePermissions'
import { BrandLogo } from '@/components/common/BrandLogo'
import { CommandPalette } from './CommandPalette'
import { NotificationsPanel } from './NotificationsPanel'
+import { TrialPill } from './TrialPill'
import { cn } from '@/lib/utils'
export function TopBar() {
@@ -110,6 +111,9 @@ export function TopBar() {
{/* Spacer - push actions to right */}
+ {/* Billing-state pill (trial countdown / paid tier / past_due / etc.) */}
+
+
{/* Action buttons */}
`s; static variants render as ``.
+ *
+ * Mobile: when the topbar is too narrow, the label collapses to a clock icon
+ * with a `title` tooltip carrying the full text.
+ */
+
+interface PillContent {
+ /** Full label shown on >= sm. */
+ label: string
+ /** Short label for mobile (sm:hidden); typically a single token / icon. */
+ shortLabel?: string
+ /** Tailwind classes applied to the pill (color tokens). */
+ toneClass: string
+ /** When set, render as a clickable Link to this route. */
+ href?: string
+ /** Extra emphasis (used by `urgent` to differentiate from `warning`). */
+ emphasized?: boolean
+}
+
+const BASE_CLASS =
+ 'trial-pill inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors whitespace-nowrap'
+
+export function TrialPill() {
+ const { stage, daysRemaining } = useTrialBanner()
+ const planBilling = useBillingStore((s) => s.planBilling)
+
+ const content = resolveContent(stage, daysRemaining, planBilling?.display_name ?? null)
+ if (!content) return null
+
+ const className = cn(
+ BASE_CLASS,
+ content.toneClass,
+ content.emphasized && 'font-semibold',
+ content.href &&
+ 'cursor-pointer hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-bg-sidebar',
+ )
+
+ const inner = (
+ <>
+ {content.label}
+
+
+
+ >
+ )
+
+ if (content.href) {
+ return (
+
+ {inner}
+
+ )
+ }
+
+ return (
+
+ {inner}
+
+ )
+}
+
+function resolveContent(
+ stage: ReturnType['stage'],
+ daysRemaining: number | null,
+ paidDisplayName: string | null,
+): PillContent | null {
+ switch (stage) {
+ case null:
+ return null
+ case 'pristine': {
+ const days = daysRemaining ?? 0
+ return {
+ label: `Pro trial · ${days}d`,
+ toneClass: 'text-info bg-info-dim',
+ }
+ }
+ case 'warning': {
+ const days = daysRemaining ?? 0
+ return {
+ label: `Pro trial · ${days}d`,
+ toneClass: 'text-warning bg-warning-dim',
+ }
+ }
+ case 'urgent':
+ return {
+ label: 'Pro trial · today',
+ toneClass: 'text-warning bg-warning-dim',
+ emphasized: true,
+ }
+ case 'expired':
+ return {
+ label: 'Trial expired — pick a plan',
+ toneClass: 'text-danger bg-danger-dim',
+ href: '/account/billing/select-plan',
+ }
+ case 'paid':
+ return {
+ label: paidDisplayName ?? 'Pro',
+ toneClass: 'text-muted-foreground bg-elevated',
+ }
+ case 'complimentary':
+ return {
+ label: 'Complimentary Pro',
+ toneClass: 'text-accent bg-accent-dim',
+ }
+ case 'past_due':
+ return {
+ label: 'Payment failed — update card',
+ toneClass: 'text-warning bg-warning-dim',
+ href: '/account/billing',
+ }
+ case 'canceled':
+ return {
+ label: 'Reactivate',
+ toneClass: 'text-warning bg-warning-dim',
+ href: '/account/billing/select-plan',
+ }
+ default: {
+ const _exhaustive: never = stage
+ void _exhaustive
+ return null
+ }
+ }
+}
+
+export default TrialPill
diff --git a/frontend/src/components/layout/__tests__/TrialPill.test.tsx b/frontend/src/components/layout/__tests__/TrialPill.test.tsx
new file mode 100644
index 00000000..cfc83328
--- /dev/null
+++ b/frontend/src/components/layout/__tests__/TrialPill.test.tsx
@@ -0,0 +1,155 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+
+import { TrialPill } from '../TrialPill'
+import { useBillingStore } from '@/store/billingStore'
+import type { SubscriptionState, PlanBillingState } from '@/types/billing'
+
+const FROZEN_NOW = new Date('2026-05-06T12:00:00Z')
+
+function renderPill() {
+ return render(
+
+
+ ,
+ )
+}
+
+function setBilling(opts: {
+ subscription: SubscriptionState | null
+ planBilling?: PlanBillingState | null
+}) {
+ useBillingStore.setState({
+ subscription: opts.subscription,
+ planBilling: opts.planBilling ?? null,
+ planLimits: {},
+ enabledFeatures: {},
+ isLoading: false,
+ error: null,
+ })
+}
+
+function isoDaysFromNow(days: number): string {
+ const d = new Date(FROZEN_NOW.getTime() + days * 24 * 60 * 60 * 1000)
+ return d.toISOString()
+}
+
+describe('TrialPill', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(FROZEN_NOW)
+ useBillingStore.setState({
+ subscription: null,
+ planBilling: null,
+ planLimits: {},
+ enabledFeatures: {},
+ isLoading: false,
+ error: null,
+ })
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('renders Pro trial · Nd for pristine stage', () => {
+ setBilling({
+ subscription: {
+ status: 'trialing',
+ plan: 'pro',
+ current_period_start: FROZEN_NOW.toISOString(),
+ current_period_end: isoDaysFromNow(12),
+ cancel_at_period_end: false,
+ seat_limit: 5,
+ has_pro_entitlement: true,
+ is_paid: false,
+ },
+ })
+
+ renderPill()
+
+ const pill = screen.getByTestId('trial-pill')
+ expect(pill).toHaveTextContent(/Pro trial · 12d/)
+ // Pristine uses info tone tokens.
+ expect(pill.className).toContain('text-info')
+ expect(pill.className).toContain('bg-info-dim')
+ })
+
+ it('renders Trial expired CTA for expired stage', () => {
+ setBilling({
+ subscription: {
+ status: 'trialing',
+ plan: 'pro',
+ current_period_start: isoDaysFromNow(-14),
+ current_period_end: isoDaysFromNow(-1), // already past
+ cancel_at_period_end: false,
+ seat_limit: 5,
+ has_pro_entitlement: false,
+ is_paid: false,
+ },
+ })
+
+ renderPill()
+
+ const pill = screen.getByTestId('trial-pill')
+ expect(pill).toHaveTextContent(/Trial expired — pick a plan/)
+ // Clickable: rendered as anchor/link.
+ expect(pill.tagName).toBe('A')
+ expect(pill.getAttribute('href')).toBe('/account/billing/select-plan')
+ })
+
+ it('renders Complimentary Pro tag for complimentary subscription', () => {
+ setBilling({
+ subscription: {
+ status: 'complimentary',
+ plan: 'pro',
+ current_period_start: null,
+ current_period_end: null,
+ cancel_at_period_end: false,
+ seat_limit: null,
+ has_pro_entitlement: true,
+ is_paid: true,
+ },
+ })
+
+ renderPill()
+
+ const pill = screen.getByTestId('trial-pill')
+ expect(pill).toHaveTextContent(/Complimentary Pro/)
+ // Friendly tag, not clickable.
+ expect(pill.tagName).toBe('SPAN')
+ expect(pill.className).toContain('text-accent')
+ })
+
+ it('is hidden when subscription is null', () => {
+ setBilling({ subscription: null })
+
+ const { container } = renderPill()
+
+ expect(screen.queryByTestId('trial-pill')).not.toBeInTheDocument()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('past_due variant is clickable and links to /account/billing', () => {
+ setBilling({
+ subscription: {
+ status: 'past_due',
+ plan: 'pro',
+ current_period_start: isoDaysFromNow(-30),
+ current_period_end: isoDaysFromNow(-2),
+ cancel_at_period_end: false,
+ seat_limit: 5,
+ has_pro_entitlement: false,
+ is_paid: true,
+ },
+ })
+
+ renderPill()
+
+ const pill = screen.getByTestId('trial-pill')
+ expect(pill).toHaveTextContent(/Payment failed — update card/)
+ expect(pill.tagName).toBe('A')
+ expect(pill.getAttribute('href')).toBe('/account/billing')
+ })
+})