// Shared UI pieces of the design system (styles.css): chips, store badges, app icons, KPI tiles, bars, sparklines // (visx, charts.tsx), stars (Phosphor), notices, skeletons, empty states, segmented controls, cards. import { StarIcon } from '@phosphor-icons/react/dist/csr/Star'; import type { ReactNode } from 'react'; import type { ReleasePhase, StoreRelease } from '../api'; import { t } from '../i18n'; import { ago, share } from '../lib/format'; import { hueOf, monogram, type Tone } from '../lib/model'; import { fixHint } from '../lib/views'; import { Icon, type IconName, StoreGlyph } from './icons'; import { Illo, type IlloName } from './illustrations'; export function Chip({ tone = 'neutral', children, dot = true, title, icon }: { tone?: Tone | 'outline'; children: ReactNode; dot?: boolean; title?: string; icon?: IconName }) { return {icon ? : dot && tone !== 'outline' && }{children}; } const PHASE_TONE: Record = { live: 'ok', rollout: 'accent', halted: 'bad', released: 'info', approved: 'ok', review: 'warn', sent: 'warn', rejected: 'bad', testing: 'info', draft: 'neutral', other: 'neutral' }; /** Chip of a store release: "Live", "Rollout 20%", "In review" ... (iOS phased release: "Phased 50%", "Paused"). */ export function phaseLabel(r: StoreRelease | null | undefined): { text: string; tone: Tone } { if (!r) return { text: t('phase.none'), tone: 'neutral' }; const pct = r.fraction !== null && r.fraction !== undefined && r.fraction < 1 ? ` ${share(r.fraction)}` : ''; if (r.phase === 'rollout') return { text: (r.store === 'ios' ? t('phase.phased') : t('phase.rollout')) + pct, tone: 'accent' }; if (r.phase === 'halted') return { text: (r.store === 'ios' ? t('phase.paused') : t('phase.halted')) + pct, tone: 'bad' }; const stale = r.pageStaleAt ? ` · ${t('phase.pageStale', { when: ago(r.pageStaleAt) })}` : ''; if (r.store === 'android' && r.phase === 'released') return { text: t('phase.pageUnknown') + stale, tone: 'neutral' }; if (r.store === 'android' && (r.phase === 'review' || r.phase === 'sent')) return { text: (r.managed ? t('phase.reviewManaged') : r.liveVersion ? t('phase.reviewLive', { version: r.liveVersion }) : t('phase.review')) + stale, tone: 'warn' }; if (r.store === 'android' && r.phase === 'live' && stale) return { text: t('phase.live') + stale, tone: 'ok' }; if (r.phase === 'testing' && r.track) return { text: `${t('phase.testing')} · ${r.track}`, tone: 'info' }; return { text: t(`phase.${r.phase}`), tone: PHASE_TONE[r.phase] }; } export function PhaseChip({ r }: { r: StoreRelease | null | undefined }) { const p = phaseLabel(r); return {p.text}; } export const storeName = (s: 'ios' | 'android'): string => (s === 'ios' ? 'App Store' : 'Google Play'); /** "App Store · com.example.app" badge, dashed when the app is not on that store. */ export function StoreBadge({ store, id, off, children }: { store: 'ios' | 'android'; id?: string | null; off?: boolean; children?: ReactNode }) { return ( {storeName(store)} {id && <>{id}} {off && · {t('app.notSetUp')}} {children && <>{children}} ); } /** The app's own icon when the project has one, else a monogram on a gradient picked from the name. */ export function AppIcon({ name, src, className = '' }: { name: string; src?: string | null; className?: string }) { if (src) return ; const h = hueOf(name); return ( ); } export function Kpi({ label, value, sub, icon, tone }: { label: string; value: ReactNode; sub?: ReactNode; icon?: IconName; tone?: Tone }) { return (
{icon && }{label}
{value}
{sub !== undefined &&
{sub}
}
); } export function Bar({ value, tone, thin, label }: { value: number; tone?: Tone; thin?: boolean; label?: string }) { const v = Math.max(0, Math.min(1, value)); return (
); } /** Crash / ANR sparkline (visx): see charts.tsx. */ export { Sparkline } from './charts'; export function Stars({ n, label }: { n: number; label?: string }) { return ( {[1, 2, 3, 4, 5].map(i => ); } export function Notice({ tone, children, icon, action }: { tone: 'bad' | 'warn' | 'ok' | 'info'; children: ReactNode; icon?: IconName; action?: ReactNode }) { const ic: IconName = icon ?? (tone === 'bad' ? 'alertCircle' : tone === 'warn' ? 'alert' : tone === 'ok' ? 'check' : 'info'); return
{children}
{action}
; } /** A failed read or write in words, with how to fix it when the error is a known one ("errors that say how to fix"). */ export function ErrorNotice({ error, tone = 'warn', keep, action }: { error: string; tone?: 'bad' | 'warn'; keep?: boolean; action?: ReactNode }) { const hint = fixHint(error); return (
{keep ? t('common.readFailedKeep', { error }) : error} {hint && {t(hint)}}
); } /** A card footer with a failed read and, when known, how to fix it. */ export function ErrorFoot({ error }: { error: string }) { const hint = fixHint(error); return
{error}{hint && {t(hint)}}
; } export function Skeleton({ lines = 3, block }: { lines?: number; block?: boolean }) { return (
{block && } {Array.from({ length: lines }, (_, i) => )}
); } export function Empty({ illo, title, children, actions, compact }: { illo: IlloName; title: string; children?: ReactNode; actions?: ReactNode; compact?: boolean }) { return (

{title}

{children &&

{children}

} {actions &&
{actions}
}
); } export function Seg({ value, options, onChange, label }: { value: T; options: [T, string, IconName?][]; onChange: (v: T) => void; label: string }) { return (
{options.map(([v, text, icon]) => )}
); } export function Card({ title, icon, sub, actions, children, foot, className = '', pad = true, id }: { title?: ReactNode; icon?: IconName; sub?: ReactNode; actions?: ReactNode; children: ReactNode; foot?: ReactNode; className?: string; pad?: boolean; id?: string }) { return (
{title !== undefined && (

{icon && }{title}

{sub &&
{sub}
}
{actions &&
{actions}
}
)} {pad ?
{children}
: children} {foot &&
{foot}
}
); } export function Kbd({ keys }: { keys: string[] }) { return <>{keys.map(k => {k})}; } /** Platform modifier label (⌘ on macOS, Ctrl elsewhere). */ export const MOD = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'; /** A studio's logo, or its monogram on the studio colour. `null` studio: "No studio" (dashed). */ export function StudioMark({ studio, size = 'md' }: { studio: { name: string; color: string; logo?: string | null } | null; size?: 'sm' | 'md' | 'lg' }) { if (!studio) return