{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "eased-saas-core",
  "title": "SaaS core primitive",
  "description": "Shared pieces for Eased SaaS-admin blocks: a rolling delta chip that falls in warning, a usage meter that turns warning at 80%, relative times and compact money formats.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-number-roll.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/_primitives/saas-core/saas-core.tsx",
      "content": "\"use client\";\n\n/**\n * saas-core: the small pieces every Eased SaaS-admin block shares, so a dashboard, a drilldown\n * and a billing page read as one product: the delta chip, the usage meter, relative times and\n * metric formats. Import from here.\n */\nimport { motion } from \"motion/react\";\nimport { minusFormatter } from \"../../lib/format\";\nimport { smooth, snappy, useSafeReducedMotion } from \"../../lib/motion\";\nimport { NumberRoll } from \"../number-roll/number-roll\";\n\n/** Default locale for every SaaS format. Pass your own. */\nexport const SAAS_LOCALE = \"en-US\";\n\n/* ---------- Formats ---------------------------------------------------------------------- */\n\n/** Compact money for tiles and axes: `$207.9K`, `$1.2M`. */\nexport const compactMoney = (currency = \"USD\"): Intl.NumberFormatOptions => ({\n  style: \"currency\",\n  currency,\n  currencyDisplay: \"narrowSymbol\",\n  notation: \"compact\",\n  maximumFractionDigits: 1,\n});\n\n/** `12 min ago`, `3 hr ago`, `yesterday`, `Oct 14`: short, for feeds and audit rows. */\nexport function relativeTime(at: number, now: number, locale: Intl.LocalesArgument = SAAS_LOCALE) {\n  const min = Math.round((now - at) / 60_000);\n  if (min < 1) return \"Just now\";\n  if (min < 60) return `${min} min ago`;\n  const hr = Math.round(min / 60);\n  if (hr < 24) return `${hr} hr ago`;\n  const day = Math.round(hr / 24);\n  if (day < 2) return \"Yesterday\";\n  if (day < 7) return new Intl.DateTimeFormat(locale, { weekday: \"long\", timeZone: \"UTC\" }).format(at);\n  return new Intl.DateTimeFormat(locale, { month: \"short\", day: \"numeric\", timeZone: \"UTC\" }).format(at);\n}\n\n/** \"up 4.9%\" / \"down 0.6%\" / \"flat\", for screen readers. */\nexport function deltaText(delta: number, locale: Intl.LocalesArgument = SAAS_LOCALE) {\n  if (Math.abs(delta) < 0.0005) return \"flat\";\n  const pct = new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1 }).format(Math.abs(delta));\n  return `${delta > 0 ? \"up\" : \"down\"} ${pct}`;\n}\n\n/* ---------- Delta chip ------------------------------------------------------------------- */\n\nexport type DeltaChipProps = {\n  /** Change as a ratio: 0.049 is +4.9%. */\n  delta: number;\n  /** Lower is better (churn, latency, cost): a fall reads as good. */\n  invert?: boolean;\n  locale?: Intl.LocalesArgument;\n  /** Spoken after the change, e.g. \"vs. prior 30 days\". */\n  caption?: string;\n  className?: string;\n};\n\n/**\n * The change chip Stat Tile uses, on its own: the arrow turns on the snappy spring and the\n * percentage rolls. A metric the user owns falls in `warning`, never `danger` (rule 119).\n */\nexport function DeltaChip({ delta, invert = false, locale = SAAS_LOCALE, caption, className = \"\" }: DeltaChipProps) {\n  const reduced = useSafeReducedMotion();\n  const flat = Math.abs(delta) < 0.0005;\n  const up = delta > 0;\n  const good = invert ? !up : up;\n  const tone = flat ? \"bg-surface-hover text-text-secondary\" : good ? \"bg-success-subtle text-success-text\" : \"bg-warning-subtle text-warning-text\";\n  return (\n    <span\n      role=\"img\"\n      aria-label={`${deltaText(delta, locale)}${caption ? ` ${caption}` : \"\"}`}\n      className={`inline-flex h-5 shrink-0 items-center gap-0.5 rounded-control-xs pr-1.5 pl-1 text-ui-xs font-medium tabular-nums transition-colors duration-150 ease-standard ${tone} ${className}`}\n    >\n      <motion.svg aria-hidden viewBox=\"0 0 16 16\" className=\"size-3.5\" initial={false} animate={{ rotate: flat ? 90 : up ? 0 : 180 }} transition={reduced ? { duration: 0 } : snappy}>\n        <path d=\"M8 12.5V3.5M4 7.5l4-4 4 4\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n      </motion.svg>\n      <NumberRoll value={Math.abs(delta)} locale={locale} format={{ style: \"percent\", maximumFractionDigits: 1 }} animated={!reduced} aria-hidden />\n    </span>\n  );\n}\n\n/* ---------- Usage meter ------------------------------------------------------------------ */\n\nexport type UsageMeterProps = {\n  label: string;\n  used: number;\n  limit: number;\n  /** Formats `used` and `limit`. Default compact numbers. */\n  format?: (value: number) => string;\n  /** Share at which the meter turns warning. Default 0.8. */\n  warnAt?: number;\n  locale?: Intl.LocalesArgument;\n  className?: string;\n};\n\n/**\n * Used against a limit: a sunken 6px track whose fill slides in to the share (a transform,\n * never width, so its rounded end keeps its shape) and turns warning at `warnAt`. Past the limit it stays full and says how far over.\n * A `meter` role with a spoken value.\n */\nexport function UsageMeter({ label, used, limit, format, warnAt = 0.8, locale = SAAS_LOCALE, className = \"\" }: UsageMeterProps) {\n  const reduced = useSafeReducedMotion();\n  const fmt = format ?? ((v: number) => new Intl.NumberFormat(locale, { notation: \"compact\", maximumFractionDigits: 2 }).format(v));\n  const share = limit > 0 ? used / limit : 0;\n  const warn = share >= warnAt;\n  const pct = new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 0 });\n  return (\n    <div className={`flex min-w-0 flex-col gap-2 ${className}`}>\n      <div className=\"flex min-w-0 items-baseline justify-between gap-3\">\n        <span className=\"truncate text-ui-sm text-text\">{label}</span>\n        <span className=\"shrink-0 text-ui-sm text-text-secondary tabular-nums\">\n          <span className=\"text-text\">{fmt(used)}</span>\n          <span className=\"text-text-muted\"> of {fmt(limit)}</span>\n        </span>\n      </div>\n      <div\n        role=\"meter\"\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={limit}\n        aria-valuenow={Math.min(used, limit)}\n        aria-valuetext={`${fmt(used)} of ${fmt(limit)}, ${pct.format(share)}`}\n        className=\"relative h-1.5 overflow-hidden rounded-pill bg-surface-sunken inset-shadow-well\"\n      >\n        <motion.span\n          aria-hidden\n          className={`absolute inset-0 rounded-pill transition-colors duration-200 ease-standard ${warn ? \"bg-warning\" : \"bg-brand\"}`}\n          initial={false}\n          animate={{ x: `${(Math.min(1, Math.max(0, share)) - 1) * 100}%` }}\n          transition={reduced ? { duration: 0 } : smooth}\n        />\n      </div>\n    </div>\n  );\n}\n\n/** A signed currency formatter with the true minus, for invoice lines and credits. */\nexport const signedMoney = (currency = \"USD\", locale: Intl.LocalesArgument = SAAS_LOCALE) =>\n  minusFormatter(locale, { style: \"currency\", currency, currencyDisplay: \"narrowSymbol\" });\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/saas-core/saas-core.tsx"
    }
  ],
  "type": "registry:ui"
}