SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
5.3 KB · 116 lines tsx
Raw Blame History
1import Link from 'next/link';2import type { ReactNode } from 'react';3import { cn } from '@/lib/cn';45/** Page-width wrapper. Pages render inside <main> without padding: wrap content in Container (1280) or wide (1600). */6export function Container({ children, className, wide = false }: { children: ReactNode; className?: string; wide?: boolean }) {7  return <div className={cn('container-x mx-auto w-full', wide ? 'max-w-[1600px]' : 'max-w-[1280px]', className)}>{children}</div>;8}910/** Section with eyebrow + title + optional action link. Spatial composition with a hairline — not a card. */11export function Section({12  eyebrow,13  title,14  lede,15  action,16  children,17  className,18  id,19  hairline = true,20  aside,21}: {22  eyebrow?: string;23  title?: ReactNode;24  lede?: ReactNode;25  action?: { href: string; label: string };26  children: ReactNode;27  className?: string;28  id?: string;29  hairline?: boolean;30  aside?: ReactNode;31}) {32  return (33    <section id={id} className={cn('section-y scroll-mt-20', hairline && 'hairline', className)}>34      {(eyebrow || title) && (35        <div className="mb-4 flex items-end justify-between gap-4 md:mb-5">36          <div className="min-w-0">37            {eyebrow && <p className="eyebrow">{eyebrow}</p>}38            {title && <h2 className="mt-1 text-lg font-semibold tracking-tight md:text-xl">{title}</h2>}39            {lede && <p className="mt-1 max-w-2xl text-sm text-ink-2">{lede}</p>}40          </div>41          {aside}42          {action && (43            <Link href={action.href} className="link shrink-0 py-1 text-sm">44              {action.label} →45            </Link>46          )}47        </div>48      )}49      {children}50    </section>51  );52}5354export function PageHeader({ eyebrow, title, lede, children, className, aside }: { eyebrow?: ReactNode; title: ReactNode; lede?: ReactNode; children?: ReactNode; className?: string; aside?: ReactNode }) {55  return (56    <div className={cn('pb-5 pt-6 md:pb-7 md:pt-10', className)}>57      <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">58        <div className="min-w-0">59          {eyebrow && <div className="eyebrow flex flex-wrap items-center gap-2">{eyebrow}</div>}60          <h1 className="display mt-2 text-[26px] md:text-[38px]">{title}</h1>61          {lede && <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-ink-2 md:text-base">{lede}</p>}62        </div>63        {aside && <div className="shrink-0">{aside}</div>}64      </div>65      {children}66    </div>67  );68}6970/** Stat tile: big tabular number + label + optional delta/hint. Composes in a hairline grid, never in a card. */71export function Stat({ label, value, hint, delta, className, accent = false, href, size = 'md' }: { label: ReactNode; value: ReactNode; hint?: ReactNode; delta?: { value: string; tone?: 'positive' | 'negative' | 'neutral' }; className?: string; accent?: boolean; href?: string; size?: 'sm' | 'md' | 'lg' }) {72  const body = (73    <>74      <p className="eyebrow">{label}</p>75      <p className={cn('tnum mt-1 font-semibold leading-none tracking-tight', size === 'lg' && 'text-[30px] md:text-[38px]', size === 'md' && 'text-[24px] md:text-[30px]', size === 'sm' && 'text-[20px] md:text-[22px]', accent && 'text-accent')}>{value}</p>76      {(hint || delta) && (77        <p className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-ink-3">78          {delta && <span className={cn('tnum font-medium', delta.tone === 'positive' && 'text-positive', delta.tone === 'negative' && 'text-danger', (!delta.tone || delta.tone === 'neutral') && 'text-ink-2')}>{delta.value}</span>}79          {hint}80        </p>81      )}82    </>83  );84  if (href)85    return (86      <Link href={href} className={cn('block min-w-0 py-3 hover:text-accent md:py-4', className)}>87        {body}88      </Link>89    );90  return <div className={cn('min-w-0 py-3 md:py-4', className)}>{body}</div>;91}9293export function StatGrid({ children, cols = 4, className }: { children: ReactNode; cols?: 2 | 3 | 4 | 5 | 6; className?: string }) {94  const desktop = { 2: 'md:grid-cols-2', 3: 'md:grid-cols-3', 4: 'md:grid-cols-4', 5: 'md:grid-cols-5', 6: 'md:grid-cols-3 lg:grid-cols-6' }[cols];95  return <div className={cn('grid grid-cols-2 gap-x-6 border-y border-rule [&>*]:border-b [&>*]:border-rule md:[&>*]:border-b-0', desktop, className)}>{children}</div>;96}9798/** Small honest note. */99export function Note({ children, className }: { children: ReactNode; className?: string }) {100  return <p className={cn('text-xs leading-relaxed text-ink-3', className)}>{children}</p>;101}102103/** Honest empty state — spec §165 wording. */104export function Empty({ title = 'No monitored evidence available yet.', children, className, compact = false }: { title?: string; children?: ReactNode; className?: string; compact?: boolean }) {105  return (106    <div className={cn('border border-dashed border-rule-strong text-center text-ink-3', compact ? 'px-3 py-4 text-xs' : 'px-5 py-10 text-sm', className)} role="status">107      <p className="text-ink-2">{title}</p>108      {children && <div className="mt-1 text-xs">{children}</div>}109    </div>110  );111}112113export function Unavailable({ what = 'This panel', className, compact = false }: { what?: string; className?: string; compact?: boolean }) {114  return <Empty title={`${what} is temporarily unavailable.`} className={className} compact={compact}>The dataset itself is intact — the API did not answer in time.</Empty>;115}116