{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"performance-report","type":"registry:block","title":"Performance Report","description":"Dashboard report with a date range picker and previous-period compare switch, rolling KPI cards with delta badges, an area trend chart that adds the previous period as a muted series, a breakdown table with inline growing bars, and auto-generated highlights that stagger in, with skeletons holding the layout while a new range loads.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/performance-report.tsx","type":"registry:component","target":"@components/motion/performance-report.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/performance-report\n\nimport { Lightbulb, TrendingDown, TrendingUp } from \"lucide-react\";\nimport { motion, useReducedMotion, type Variants } from \"motion/react\";\nimport { type ReactNode, useId, useMemo, useState } from \"react\";\nimport type { DateRange } from \"@/components/motion/calendar\";\nimport { AreaChart } from \"@/components/motion/chart/area-chart\";\nimport { seriesColor } from \"@/components/motion/chart/shared\";\nimport { DateRangePicker, type DateRangePreset } from \"@/components/motion/date-range-picker\";\nimport { Skeleton, SkeletonSwap } from \"@/components/motion/skeleton\";\nimport { StatCard, type StatFormat } from \"@/components/motion/stat-card\";\nimport { Switch } from \"@/components/motion/switch\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ReportKpi {\n  id: string;\n  label: string;\n  value: number;\n  /** Same metric for the comparison period; drives the delta badge. */\n  previous?: number;\n  format?: StatFormat;\n  /** Which way is good news. Default \"up\". */\n  goodDirection?: \"up\" | \"down\";\n  icon?: ReactNode;\n}\n\nexport interface ReportTrendPoint {\n  /** ISO date (YYYY-MM-DD), read as UTC. */\n  date: string;\n  current: number;\n  previous?: number;\n}\n\nexport interface ReportBreakdownRow {\n  id: string;\n  label: string;\n  value: number;\n  previous?: number;\n}\n\nexport interface PerformanceReportData {\n  kpis: ReportKpi[];\n  trend: ReportTrendPoint[];\n  /** Name of the trended metric, e.g. \"Sessions\". */\n  trendLabel?: string;\n  breakdown: ReportBreakdownRow[];\n  /** Name of the breakdown dimension, e.g. \"Channel\". */\n  breakdownLabel?: string;\n}\n\nexport type InsightTone = \"positive\" | \"negative\" | \"neutral\";\n\nexport interface ReportInsight {\n  id: string;\n  tone: InsightTone;\n  text: ReactNode;\n}\n\nexport interface PerformanceReportProps {\n  /** Report contents for the current range. `null` renders the loading state. */\n  data: PerformanceReportData | null;\n  /** Show skeletons (e.g. while a new range loads). */\n  loading?: boolean;\n  range?: DateRange | null;\n  defaultRange?: DateRange | null;\n  onRangeChange?: (range: { from: Date; to: Date }) => void;\n  presets?: DateRangePreset[];\n  /** Controlled previous-period comparison. */\n  compare?: boolean;\n  defaultCompare?: boolean;\n  onCompareChange?: (compare: boolean) => void;\n  /** Override the auto-generated highlights. */\n  insights?: ReportInsight[];\n  title?: ReactNode;\n  /** Default \"en-US\". */\n  locale?: string;\n  /** Trend chart plot height in px. Default 110. */\n  chartHeight?: number;\n  className?: string;\n}\n\nconst UTC_DAY = new Intl.DateTimeFormat(\"en-US\", { month: \"short\", day: \"numeric\", timeZone: \"UTC\" });\nconst formatDay = (iso: string) => {\n  const ms = Date.parse(`${iso}T00:00:00Z`);\n  return Number.isNaN(ms) ? iso : UTC_DAY.format(ms);\n};\n\nconst TONE_TEXT: Record<InsightTone, string> = {\n  positive: \"text-[color-mix(in_oklab,var(--success)_60%,var(--foreground))]\",\n  negative: \"text-[color-mix(in_oklab,var(--destructive)_75%,var(--foreground))]\",\n  neutral: \"text-muted-foreground\",\n};\nconst TONE_BG: Record<InsightTone, string> = {\n  positive: \"bg-[color-mix(in_oklab,var(--success)_14%,transparent)]\",\n  negative: \"bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)]\",\n  neutral: \"bg-foreground/5\",\n};\n\nconst change = (value: number, previous?: number) =>\n  previous === undefined || previous === 0 ? undefined : (value - previous) / Math.abs(previous);\n\n/**\n * Plain-language highlights derived from the report: the biggest KPI move vs\n * the previous period, the top and fastest-growing breakdown rows, and the\n * peak day of the trend. Exported so it can feed other surfaces.\n */\nexport function generateInsights(\n  data: PerformanceReportData,\n  { compare = true, locale = \"en-US\", max = 4 }: { compare?: boolean; locale?: string; max?: number } = {},\n): ReportInsight[] {\n  const pct = new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1 });\n  const num = new Intl.NumberFormat(locale, { notation: \"compact\", maximumFractionDigits: 1 });\n  const out: ReportInsight[] = [];\n\n  if (compare) {\n    const moves = data.kpis\n      .map((k) => ({ k, d: change(k.value, k.previous) }))\n      .filter((m): m is { k: ReportKpi; d: number } => m.d !== undefined && m.d !== 0)\n      .sort((a, b) => Math.abs(b.d) - Math.abs(a.d));\n    const best = moves.find((m) => (m.d > 0) === ((m.k.goodDirection ?? \"up\") === \"up\"));\n    const worst = moves.find((m) => (m.d > 0) !== ((m.k.goodDirection ?? \"up\") === \"up\"));\n    if (best)\n      out.push({\n        id: `kpi-${best.k.id}`,\n        tone: \"positive\",\n        text: `${best.k.label} ${best.d > 0 ? \"up\" : \"down\"} ${pct.format(Math.abs(best.d))} vs the previous period.`,\n      });\n    if (worst)\n      out.push({\n        id: `kpi-${worst.k.id}`,\n        tone: \"negative\",\n        text: `${worst.k.label} ${worst.d > 0 ? \"rose\" : \"slipped\"} ${pct.format(Math.abs(worst.d))} — worth a look.`,\n      });\n  }\n\n  const total = data.breakdown.reduce((s, r) => s + r.value, 0);\n  const top = [...data.breakdown].sort((a, b) => b.value - a.value)[0];\n  if (top && total > 0)\n    out.push({\n      id: `top-${top.id}`,\n      tone: \"neutral\",\n      text: `${top.label} drove ${pct.format(top.value / total)} of ${(data.trendLabel ?? \"volume\").toLowerCase()}.`,\n    });\n\n  if (compare) {\n    const fastest = data.breakdown\n      .map((r) => ({ r, d: change(r.value, r.previous) }))\n      .filter((m): m is { r: ReportBreakdownRow; d: number } => m.d !== undefined)\n      .sort((a, b) => b.d - a.d)[0];\n    if (fastest && fastest.d > 0 && fastest.r.id !== top?.id)\n      out.push({\n        id: `fast-${fastest.r.id}`,\n        tone: \"positive\",\n        text: `${fastest.r.label} grew fastest, +${pct.format(fastest.d)}.`,\n      });\n  }\n\n  const peak = data.trend.reduce<ReportTrendPoint | null>((p, c) => (!p || c.current > p.current ? c : p), null);\n  if (peak)\n    out.push({\n      id: `peak-${peak.date}`,\n      tone: \"neutral\",\n      text: `Peak day was ${formatDay(peak.date)} with ${num.format(peak.current)} ${(data.trendLabel ?? \"\").toLowerCase()}.`,\n    });\n\n  return out.slice(0, max);\n}\n\n/**\n * Dashboard report: a range picker and previous-period toggle head a row of\n * rolling KPI cards, a trend chart (current vs previous as a second, muted\n * series), a breakdown table with inline bars, and auto-generated highlights\n * that stagger in. While a range loads every section holds its footprint as\n * a skeleton, then cross-fades back.\n */\nexport function PerformanceReport({\n  data,\n  loading = false,\n  range,\n  defaultRange,\n  onRangeChange,\n  presets,\n  compare: compareProp,\n  defaultCompare = true,\n  onCompareChange,\n  insights: insightsProp,\n  title = \"Performance\",\n  locale = \"en-US\",\n  chartHeight = 110,\n  className,\n}: PerformanceReportProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const [compareState, setCompareState] = useState(defaultCompare);\n  const compare = compareProp ?? compareState;\n  const setCompare = (next: boolean) => {\n    if (compareProp === undefined) setCompareState(next);\n    onCompareChange?.(next);\n  };\n  const busy = loading || data === null;\n\n  const compact = useMemo(\n    () => new Intl.NumberFormat(locale, { notation: \"compact\", maximumFractionDigits: 1 }),\n    [locale],\n  );\n  const full = useMemo(() => new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }), [locale]);\n  const pct = useMemo(\n    () => new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1, signDisplay: \"exceptZero\" }),\n    [locale],\n  );\n\n  const insights = useMemo(\n    () => insightsProp ?? (data ? generateInsights(data, { compare, locale }) : []),\n    [insightsProp, data, compare, locale],\n  );\n\n  const trendLabel = data?.trendLabel ?? \"Value\";\n  const series = [\n    { key: \"current\", label: \"This period\" },\n    ...(compare ? [{ key: \"previous\", label: \"Previous\", color: \"var(--muted-foreground)\" }] : []),\n  ];\n  const chartData = (data?.trend ?? []).map((p) => ({\n    date: p.date,\n    current: p.current,\n    previous: p.previous ?? 0,\n  }));\n\n  const maxRow = Math.max(1, ...(data?.breakdown ?? []).map((r) => r.value));\n  const dataKey = data ? `${data.trend[0]?.date ?? \"\"}-${data.trend.length}` : \"none\";\n\n  const listVariants: Variants = {\n    hidden: {},\n    shown: { transition: { staggerChildren: reduce ? 0 : 0.07, delayChildren: 0.15 } },\n  };\n  const itemVariants: Variants = {\n    hidden: { opacity: 0, y: reduce ? 0 : 6 },\n    shown: { opacity: 1, y: 0, transition: { duration: 0.28, ease: EASE_OUT } },\n  };\n\n  return (\n    <section\n      aria-labelledby={`${baseId}-title`}\n      className={cn(\"@container flex w-full flex-col gap-3 text-card-foreground\", className)}\n    >\n      <header className=\"flex flex-wrap items-center justify-between gap-2\">\n        <h3 id={`${baseId}-title`} className=\"text-base font-semibold tracking-tight\">\n          {title}\n        </h3>\n        <div className=\"flex flex-wrap items-center gap-3\">\n          <Switch checked={compare} onCheckedChange={setCompare} label=\"Compare\" className=\"gap-2 text-sm\" />\n          <DateRangePicker\n            value={range}\n            defaultValue={defaultRange}\n            onValueChange={onRangeChange}\n            presets={presets}\n            align=\"end\"\n            className=\"h-9 min-w-0\"\n          />\n        </div>\n      </header>\n\n      <div className=\"grid grid-cols-2 gap-2 @lg:grid-cols-4\">\n        {(data?.kpis ?? PLACEHOLDER_KPIS).map((k) => (\n          <StatCard\n            key={k.id}\n            label={k.label}\n            icon={k.icon}\n            value={k.value}\n            format={k.format}\n            goodDirection={k.goodDirection}\n            delta={compare ? change(k.value, k.previous) : undefined}\n            caption={compare ? \"vs previous\" : undefined}\n            loading={busy}\n            locale={locale}\n            className=\"rounded-xl p-3\"\n          />\n        ))}\n      </div>\n\n      <div className=\"rounded-xl border border-border bg-card p-3\">\n        <div className=\"mb-2 flex items-center justify-between gap-2\">\n          <h4 className=\"text-xs font-medium text-muted-foreground\">{trendLabel} over time</h4>\n          <ul className=\"flex items-center gap-3 text-[11px] text-muted-foreground\">\n            {series.map((s, i) => (\n              <li key={s.key} className=\"flex items-center gap-1.5\">\n                <span aria-hidden className=\"h-0.5 w-3 rounded-full\" style={{ background: seriesColor(s, i) }} />\n                {s.label}\n              </li>\n            ))}\n          </ul>\n        </div>\n        <SkeletonSwap\n          loading={busy}\n          loadingLabel=\"Loading report\"\n          skeleton={<Skeleton className=\"w-full rounded-lg\" height={chartHeight + 28} />}\n        >\n          <AreaChart\n            key={`${dataKey}-${compare}`}\n            data={chartData}\n            index=\"date\"\n            series={series}\n            height={chartHeight}\n            legend={false}\n            indicator=\"line\"\n            formatIndex={formatDay}\n            formatValue={(v) => full.format(v)}\n            title={`${trendLabel} per day${compare ? \", with previous period\" : \"\"}`}\n          />\n        </SkeletonSwap>\n      </div>\n\n      <div className=\"grid gap-3 @lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]\">\n        <div className=\"rounded-xl border border-border bg-card p-3\">\n          <SkeletonSwap\n            loading={busy}\n            skeleton={\n              <div className=\"flex flex-col gap-2.5\">\n                {[0, 1, 2, 3, 4].map((i) => (\n                  <Skeleton key={i} shape=\"text\" />\n                ))}\n              </div>\n            }\n          >\n            <table className=\"w-full text-xs\">\n              <caption className=\"sr-only\">\n                {trendLabel} by {(data?.breakdownLabel ?? \"segment\").toLowerCase()}\n              </caption>\n              <thead>\n                <tr className=\"text-left text-[11px] text-muted-foreground\">\n                  <th scope=\"col\" className=\"pb-1.5 font-medium\">\n                    {data?.breakdownLabel ?? \"Segment\"}\n                  </th>\n                  <th scope=\"col\" className=\"pb-1.5 text-right font-medium\">\n                    {trendLabel}\n                  </th>\n                  <th scope=\"col\" className=\"w-[30%] pb-1.5 pl-3 font-medium\">\n                    <span className=\"sr-only\">Share</span>\n                  </th>\n                  {compare ? (\n                    <th scope=\"col\" className=\"pb-1.5 text-right font-medium\">\n                      Δ\n                    </th>\n                  ) : null}\n                </tr>\n              </thead>\n              <tbody key={dataKey}>\n                {(data?.breakdown ?? []).map((r, i) => {\n                  const d = change(r.value, r.previous);\n                  return (\n                    <tr key={r.id} className=\"border-t border-border\">\n                      <th scope=\"row\" className=\"py-1.5 pr-2 text-left font-medium\">\n                        {r.label}\n                      </th>\n                      <td className=\"py-1.5 text-right tabular-nums\">{compact.format(r.value)}</td>\n                      <td className=\"py-1.5 pl-3\">\n                        <span className=\"block h-1.5 w-full overflow-hidden rounded-full bg-foreground/5\">\n                          {/* Fill grows from the left edge: scaleX on an HTML element. */}\n                          <motion.span\n                            className=\"block h-full w-full origin-left rounded-full\"\n                            style={{ background: seriesColor({}, 0) }}\n                            initial={{ scaleX: reduce ? r.value / maxRow : 0 }}\n                            animate={{ scaleX: r.value / maxRow }}\n                            transition={{ duration: 0.6, ease: EASE_OUT, delay: reduce ? 0 : 0.05 * i }}\n                          />\n                        </span>\n                      </td>\n                      {compare ? (\n                        <td\n                          className={cn(\n                            \"py-1.5 pl-2 text-right tabular-nums\",\n                            d === undefined ? \"text-muted-foreground\" : TONE_TEXT[d >= 0 ? \"positive\" : \"negative\"],\n                          )}\n                        >\n                          {d === undefined ? \"—\" : pct.format(d)}\n                        </td>\n                      ) : null}\n                    </tr>\n                  );\n                })}\n              </tbody>\n            </table>\n          </SkeletonSwap>\n        </div>\n\n        <div className=\"rounded-xl border border-border bg-card p-3\">\n          <h4 className=\"mb-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground\">\n            <Lightbulb className=\"size-3.5\" aria-hidden />\n            Highlights\n          </h4>\n          <SkeletonSwap\n            loading={busy}\n            skeleton={\n              <div className=\"flex flex-col gap-3\">\n                {[0, 1, 2].map((i) => (\n                  <div key={i} className=\"flex items-center gap-2\">\n                    <Skeleton shape=\"circle\" width={20} />\n                    <Skeleton shape=\"text\" className=\"flex-1\" />\n                  </div>\n                ))}\n              </div>\n            }\n          >\n            <motion.ul\n              key={`${dataKey}-${compare}`}\n              variants={listVariants}\n              initial=\"hidden\"\n              animate=\"shown\"\n              className=\"flex flex-col gap-2\"\n            >\n              {insights.map((ins) => {\n                const Icon = ins.tone === \"negative\" ? TrendingDown : ins.tone === \"positive\" ? TrendingUp : Lightbulb;\n                return (\n                  <motion.li key={ins.id} variants={itemVariants} className=\"flex items-start gap-2 text-xs leading-snug\">\n                    <span\n                      aria-hidden\n                      className={cn(\"grid size-5 shrink-0 place-items-center rounded-full\", TONE_BG[ins.tone], TONE_TEXT[ins.tone])}\n                    >\n                      <Icon className=\"size-3\" />\n                    </span>\n                    <span className=\"pt-0.5\">{ins.text}</span>\n                  </motion.li>\n                );\n              })}\n            </motion.ul>\n          </SkeletonSwap>\n        </div>\n      </div>\n    </section>\n  );\n}\n\n// Footprint for the first load, before any data arrives.\nconst PLACEHOLDER_KPIS: ReportKpi[] = [0, 1, 2, 3].map((i) => ({\n  id: `placeholder-${i}`,\n  label: \"Loading\",\n  value: 0,\n}));\n"},{"path":"components/motion/calendar.tsx","type":"registry:component","target":"@components/motion/calendar.tsx","content":"\"use client\";\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DateRange = { from: Date; to?: Date };\n/** A whole week row: `from` is its first day, `to` its seventh. */\nexport type WeekRange = { from: Date; to: Date };\n\n/** Per-day extras from `getDayMeta`. */\nexport type CalendarDayMeta = {\n  /** Short text under the day number (a price, a count). Cells grow taller\n   * when any day has meta; the label is also read after the date. */\n  label?: string;\n  /** \"accent\" tints the label (deals, highlights); \"muted\" fades it. */\n  tone?: \"muted\" | \"accent\";\n  /** Blocks the day and strikes it through, like `isDateDisabled`. */\n  disabled?: boolean;\n};\n\ntype CalendarBaseProps = {\n  /** Earliest selectable day (inclusive). */\n  minDate?: Date;\n  /** Latest selectable day (inclusive). */\n  maxDate?: Date;\n  /** Extra per-day rule, e.g. weekends or booked dates. */\n  isDateDisabled?: (date: Date) => boolean;\n  /** 0 = Sunday … 6 = Saturday. Default 0. */\n  weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n  /** BCP 47 locale for month, weekday and day labels. Default \"en-US\" — a\n   * fixed default keeps server and client output identical for hydration. */\n  locale?: string;\n  /** Controlled visible month (any day inside it). */\n  month?: Date;\n  /** Uncontrolled initial visible month. Default: the selection or today. */\n  defaultMonth?: Date;\n  onMonthChange?: (month: Date) => void;\n  /** Months shown side by side (stacked below `sm`). Default 1. With more\n   * than one, days outside each month are left blank so none repeats. */\n  numberOfMonths?: number;\n  /** Adds an ISO 8601 week-number column (\"Wk\") before each row. */\n  showWeekNumber?: boolean;\n  /** Per-day label, tone and blocking, e.g. nightly prices or booked days.\n   * Passing it switches the grid to taller cells that fit the label. */\n  getDayMeta?: (date: Date) => CalendarDayMeta | null | undefined;\n  className?: string;\n  \"aria-label\"?: string;\n};\n\nexport type CalendarSingleProps = CalendarBaseProps & {\n  mode?: \"single\";\n  value?: Date | null;\n  defaultValue?: Date | null;\n  onValueChange?: (date: Date) => void;\n};\n\nexport type CalendarRangeProps = CalendarBaseProps & {\n  mode: \"range\";\n  value?: DateRange | null;\n  defaultValue?: DateRange | null;\n  onValueChange?: (range: DateRange) => void;\n  /** Fewest nights (days between start and end) a finished range may span.\n   * While the end is being chosen, days too close are blocked. */\n  minNights?: number;\n  /** Most nights a finished range may span; farther days are blocked. */\n  maxNights?: number;\n};\n\nexport type CalendarWeekProps = CalendarBaseProps & {\n  mode: \"week\";\n  value?: WeekRange | null;\n  defaultValue?: WeekRange | null;\n  /** Called with the picked day's whole row, honouring `weekStartsOn`. */\n  onValueChange?: (week: WeekRange) => void;\n};\n\nexport type CalendarMultipleProps = CalendarBaseProps & {\n  mode: \"multiple\";\n  value?: Date[] | null;\n  defaultValue?: Date[] | null;\n  /** Called with the selection sorted by date. */\n  onValueChange?: (dates: Date[]) => void;\n  /** Most days that can be picked; further picks are ignored. */\n  max?: number;\n};\n\nexport type CalendarProps =\n  | CalendarSingleProps\n  | CalendarRangeProps\n  | CalendarWeekProps\n  | CalendarMultipleProps;\n\n// ── date helpers (local time, day precision) ──────────────────────────────\n\nconst startOfDay = (d: Date) =>\n  new Date(d.getFullYear(), d.getMonth(), d.getDate());\nconst startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);\nconst addDays = (d: Date, n: number) =>\n  new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);\n/** Adds months, clamping the day so Jan 31 + 1 month lands on Feb 28/29. */\nfunction addMonths(d: Date, n: number) {\n  const target = new Date(d.getFullYear(), d.getMonth() + n, 1);\n  const lastDay = new Date(\n    target.getFullYear(),\n    target.getMonth() + 1,\n    0,\n  ).getDate();\n  target.setDate(Math.min(d.getDate(), lastDay));\n  return target;\n}\nconst sameDay = (a?: Date | null, b?: Date | null) =>\n  !!a &&\n  !!b &&\n  a.getFullYear() === b.getFullYear() &&\n  a.getMonth() === b.getMonth() &&\n  a.getDate() === b.getDate();\nconst sameMonth = (a: Date, b: Date) =>\n  a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();\nconst dayKey = (d: Date) =>\n  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, \"0\")}-${String(d.getDate()).padStart(2, \"0\")}`;\nconst monthKey = (d: Date) =>\n  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, \"0\")}`;\n/** Whole days from `a` to `b`; rounding absorbs DST hour shifts. */\nconst daysBetween = (a: Date, b: Date) =>\n  Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / 86_400_000);\n/** ISO 8601 week number: weeks start Monday, week 1 holds Jan 4. */\nfunction isoWeek(d: Date) {\n  const thursday = addDays(d, 3 - ((d.getDay() + 6) % 7));\n  const jan4 = new Date(thursday.getFullYear(), 0, 4);\n  const firstThursday = addDays(jan4, 3 - ((jan4.getDay() + 6) % 7));\n  return 1 + Math.round(daysBetween(firstThursday, thursday) / 7);\n}\n\n/** Always 6 weeks, so the panel never changes height between months. */\nfunction buildWeeks(month: Date, weekStartsOn: number) {\n  const first = startOfMonth(month);\n  const offset = (first.getDay() - weekStartsOn + 7) % 7;\n  const start = addDays(first, -offset);\n  return Array.from({ length: 6 }, (_, w) =>\n    Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)),\n  );\n}\n\n// Month swap is a short directional slide; the exit is quicker than the entry.\nconst MONTH_ENTER = { duration: 0.26, ease: EASE_OUT } as const;\nconst MONTH_EXIT = { duration: 0.16, ease: EASE_OUT } as const;\n\n/**\n * Month-grid calendar for single dates, ranges, whole weeks or several\n * separate days, one or more months wide. The selected day is a pill that\n * glides between days on a shared-layout spring; a picked week is one pill\n * across its row; months slide in from the direction you travel. Optional ISO\n * week numbers, per-day labels (`getDayMeta`) and night limits for ranges.\n * Full keyboard grid navigation (arrows, Home/End, PageUp/PageDown,\n * Shift+Page for years). Controlled or uncontrolled for both the selection\n * and the visible month; reduced-motion safe.\n */\nexport function Calendar(props: CalendarProps) {\n  const {\n    minDate,\n    maxDate,\n    isDateDisabled,\n    weekStartsOn = 0,\n    locale = \"en-US\",\n    month: controlledMonth,\n    defaultMonth,\n    onMonthChange,\n    numberOfMonths = 1,\n    showWeekNumber = false,\n    getDayMeta,\n    className,\n    \"aria-label\": ariaLabel,\n  } = props;\n  const isRange = props.mode === \"range\";\n  const isWeek = props.mode === \"week\";\n  const isMultiple = props.mode === \"multiple\";\n  const count = Math.max(1, Math.floor(numberOfMonths));\n  const minNights = isRange ? ((props as CalendarRangeProps).minNights ?? 0) : 0;\n  const maxNights = isRange ? (props as CalendarRangeProps).maxNights : undefined;\n  // Taller cells only when there is something to put under the number.\n  const tall = !!getDayMeta;\n\n  const uid = useId();\n  const reduce = useReducedMotion();\n\n  // ── selection ──\n  const [internalValue, setInternalValue] = useState<\n    Date | DateRange | WeekRange | Date[] | null\n  >(props.defaultValue ?? null);\n  const valueControlled = props.value !== undefined;\n  const value = valueControlled ? (props.value ?? null) : internalValue;\n  const single =\n    isRange || isWeek || isMultiple ? null : (value as Date | null);\n  const range = isRange ? (value as DateRange | null) : null;\n  const week = isWeek ? (value as WeekRange | null) : null;\n  const picked = isMultiple ? ((value as Date[] | null) ?? []) : [];\n  const isPicked = (d: Date) => picked.some((p) => sameDay(p, d));\n\n  // ── visible months ──\n  const anchor = single ?? range?.from ?? week?.from ?? picked[0] ?? null;\n  const [internalMonth, setInternalMonth] = useState(() =>\n    startOfMonth(defaultMonth ?? anchor ?? new Date()),\n  );\n  const month = controlledMonth\n    ? startOfMonth(controlledMonth)\n    : internalMonth;\n  const [direction, setDirection] = useState(1);\n  const lastMonth = addMonths(month, count - 1);\n  const inWindow = (d: Date) => {\n    const m = startOfMonth(d);\n    return m >= month && m <= lastMonth;\n  };\n\n  const setMonth = useCallback(\n    (next: Date) => {\n      const m = startOfMonth(next);\n      if (sameMonth(m, month)) return;\n      setDirection(m > month ? 1 : -1);\n      if (!controlledMonth) setInternalMonth(m);\n      onMonthChange?.(m);\n    },\n    [month, controlledMonth, onMonthChange],\n  );\n\n  // \"Today\" is read after mount: the server's clock and timezone can disagree\n  // with the viewer's, and a mismatched marker would break hydration.\n  const [today, setToday] = useState<Date | null>(null);\n  useEffect(() => setToday(startOfDay(new Date())), []);\n\n  // ── roving focus ──\n  const [focused, setFocused] = useState<Date>(() =>\n    startOfDay(anchor ?? defaultMonth ?? new Date()),\n  );\n  const gridRef = useRef<HTMLDivElement>(null);\n  const shouldFocus = useRef(false);\n  // Range hover preview: the band follows the pointer until the end is picked.\n  // In week mode it is the row under the pointer or keyboard focus.\n  const [hovered, setHovered] = useState<Date | null>(null);\n\n  // Keep the tab stop inside the visible months when they change by buttons.\n  const focusInMonth = inWindow(focused)\n    ? focused\n    : anchor && inWindow(anchor)\n      ? anchor\n      : month;\n\n  useEffect(() => {\n    if (!shouldFocus.current) return;\n    shouldFocus.current = false;\n    gridRef.current\n      ?.querySelector<HTMLButtonElement>(\n        `[data-month=\"${monthKey(focusInMonth)}\"] [data-date=\"${dayKey(focusInMonth)}\"]`,\n      )\n      ?.focus();\n  }, [focusInMonth]);\n\n  const isDisabled = useCallback(\n    (d: Date) =>\n      (minDate && d < startOfDay(minDate)) ||\n      (maxDate && d > startOfDay(maxDate)) ||\n      !!isDateDisabled?.(d) ||\n      !!getDayMeta?.(d)?.disabled,\n    [minDate, maxDate, isDateDisabled, getDayMeta],\n  );\n\n  // While a range end is being chosen, days that would make it too short or\n  // too long are blocked (earlier days stay open: they restart the range).\n  const choosingEnd = !!range && !range.to;\n  const nightsAllowed = (n: number) =>\n    n >= minNights && (maxNights === undefined || n <= maxNights);\n  const blockedByNights = (d: Date) =>\n    choosingEnd &&\n    !!range &&\n    d > range.from &&\n    !nightsAllowed(daysBetween(range.from, d));\n\n  const weekStart = (d: Date) =>\n    addDays(d, -((d.getDay() - weekStartsOn + 7) % 7));\n\n  const select = (d: Date) => {\n    if (isDisabled(d) || blockedByNights(d)) return;\n    if (!inWindow(d)) setMonth(d);\n    setFocused(d);\n\n    if (isWeek) {\n      const from = weekStart(d);\n      const next: WeekRange = { from, to: addDays(from, 6) };\n      if (!valueControlled) setInternalValue(next);\n      (props as CalendarWeekProps).onValueChange?.(next);\n      return;\n    }\n\n    if (isMultiple) {\n      const max = (props as CalendarMultipleProps).max;\n      const next = isPicked(d)\n        ? picked.filter((p) => !sameDay(p, d))\n        : max !== undefined && picked.length >= max\n          ? null\n          : [...picked, d].sort((a, b) => a.getTime() - b.getTime());\n      if (!next) return;\n      if (!valueControlled) setInternalValue(next);\n      (props as CalendarMultipleProps).onValueChange?.(next);\n      return;\n    }\n    if (!isRange) {\n      if (!valueControlled) setInternalValue(d);\n      (props as CalendarSingleProps).onValueChange?.(d);\n      return;\n    }\n    // First click starts a range; a later day closes it; an earlier day, the\n    // start again when it would be too short, or a click on a finished range\n    // starts over.\n    const next: DateRange =\n      range &&\n      !range.to &&\n      d >= range.from &&\n      nightsAllowed(daysBetween(range.from, d))\n        ? { from: range.from, to: d }\n        : { from: d };\n    if (!valueControlled) setInternalValue(next);\n    (props as CalendarRangeProps).onValueChange?.(next);\n  };\n\n  const onGridKeyDown = (e: KeyboardEvent) => {\n    const base = focusInMonth;\n    let next: Date | null = null;\n    switch (e.key) {\n      case \"ArrowLeft\":\n        next = addDays(base, -1);\n        break;\n      case \"ArrowRight\":\n        next = addDays(base, 1);\n        break;\n      case \"ArrowUp\":\n        next = addDays(base, -7);\n        break;\n      case \"ArrowDown\":\n        next = addDays(base, 7);\n        break;\n      case \"Home\":\n        next = addDays(base, -((base.getDay() - weekStartsOn + 7) % 7));\n        break;\n      case \"End\":\n        next = addDays(base, 6 - ((base.getDay() - weekStartsOn + 7) % 7));\n        break;\n      case \"PageUp\":\n        next = addMonths(base, e.shiftKey ? -12 : -1);\n        break;\n      case \"PageDown\":\n        next = addMonths(base, e.shiftKey ? 12 : 1);\n        break;\n      default:\n        return;\n    }\n    e.preventDefault();\n    shouldFocus.current = true;\n    setFocused(next);\n    // Scroll the window only when focus leaves it, by as little as needed.\n    if (!inWindow(next))\n      setMonth(next < month ? next : addMonths(startOfMonth(next), 1 - count));\n  };\n\n  // ── labels ──\n  const monthFormat = useMemo(\n    () => new Intl.DateTimeFormat(locale, { month: \"long\", year: \"numeric\" }),\n    [locale],\n  );\n  const weekdays = useMemo(() => {\n    const short = new Intl.DateTimeFormat(locale, { weekday: \"short\" });\n    const long = new Intl.DateTimeFormat(locale, { weekday: \"long\" });\n    // 2023-01-01 was a Sunday.\n    return Array.from({ length: 7 }, (_, i) => {\n      const d = new Date(2023, 0, 1 + ((i + weekStartsOn) % 7));\n      return { short: short.format(d).slice(0, 2), long: long.format(d) };\n    });\n  }, [locale, weekStartsOn]);\n  const dayLabel = useMemo(\n    () =>\n      new Intl.DateTimeFormat(locale, {\n        weekday: \"long\",\n        day: \"numeric\",\n        month: \"long\",\n        year: \"numeric\",\n      }),\n    [locale],\n  );\n\n  const prevDisabled =\n    !!minDate && startOfMonth(minDate) >= month;\n  const nextDisabled =\n    !!maxDate && startOfMonth(maxDate) <= lastMonth;\n\n  // Range band end: the picked end, or the hovered day while choosing one.\n  // A day that can't close the range doesn't stretch the band to it.\n  const bandEnd =\n    range &&\n    !range.to &&\n    hovered &&\n    hovered > range.from &&\n    !isDisabled(hovered) &&\n    !blockedByNights(hovered)\n      ? hovered\n      : range?.to;\n  // Week mode: the row being previewed (pointer or keyboard focus).\n  const previewWeek = isWeek && hovered ? weekStart(hovered) : null;\n\n  const navButton =\n    \"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40\";\n\n  const months = Array.from({ length: count }, (_, i) => addMonths(month, i));\n  // Weekday index of the column, so connecting bands stop at the row ends.\n  const column = (d: Date) => (d.getDay() - weekStartsOn + 7) % 7;\n  const cellSize = tall ? \"h-12 w-10\" : \"size-9\";\n\n  return (\n    <div\n      className={cn(\n        \"w-fit rounded-2xl border border-border bg-card p-3 text-foreground\",\n        className,\n      )}\n    >\n      <div\n        ref={gridRef}\n        onPointerLeave={() => setHovered(null)}\n        className=\"flex flex-col gap-x-6 gap-y-4 sm:flex-row\"\n      >\n        {months.map((m, i) => {\n          const mk = monthKey(m);\n          const weeks = buildWeeks(m, weekStartsOn);\n          const labelId = `${uid}-label-${i}`;\n          return (\n            <div\n              // biome-ignore lint/suspicious/noArrayIndexKey: columns are positional; each keeps its own month transition.\n              key={i}\n              className=\"min-w-0\"\n            >\n              <div className=\"mb-2 flex items-center justify-between gap-2 px-1\">\n                {i === 0 ? (\n                  <button\n                    type=\"button\"\n                    aria-label=\"Previous month\"\n                    disabled={prevDisabled}\n                    onClick={() => setMonth(addMonths(month, -1))}\n                    className={navButton}\n                  >\n                    <ChevronLeft className=\"h-4 w-4\" />\n                  </button>\n                ) : (\n                  <span aria-hidden className=\"size-8\" />\n                )}\n                <h2\n                  id={labelId}\n                  aria-live={i === 0 ? \"polite\" : undefined}\n                  className=\"text-sm font-medium tabular-nums\"\n                >\n                  {monthFormat.format(m)}\n                </h2>\n                {i === count - 1 ? (\n                  <button\n                    type=\"button\"\n                    aria-label=\"Next month\"\n                    disabled={nextDisabled}\n                    onClick={() => setMonth(addMonths(month, 1))}\n                    className={navButton}\n                  >\n                    <ChevronRight className=\"h-4 w-4\" />\n                  </button>\n                ) : (\n                  <span aria-hidden className=\"size-8\" />\n                )}\n              </div>\n\n              <div className=\"relative overflow-hidden\">\n                <AnimatePresence\n                  initial={false}\n                  mode=\"popLayout\"\n                  custom={direction}\n                >\n                  <motion.table\n                    key={mk}\n                    data-month={mk}\n                    custom={direction}\n                    aria-labelledby={ariaLabel ? undefined : labelId}\n                    aria-label={ariaLabel ? `${ariaLabel}, ${monthFormat.format(m)}` : undefined}\n                    onKeyDown={onGridKeyDown}\n                    variants={{\n                      enter: (dir: number) => ({\n                        opacity: 0,\n                        x: reduce ? 0 : dir * 28,\n                        filter: reduce ? \"blur(0px)\" : \"blur(4px)\",\n                      }),\n                      center: {\n                        opacity: 1,\n                        x: 0,\n                        filter: \"blur(0px)\",\n                        transition: MONTH_ENTER,\n                      },\n                      exit: (dir: number) => ({\n                        opacity: 0,\n                        x: reduce ? 0 : dir * -28,\n                        filter: reduce ? \"blur(0px)\" : \"blur(4px)\",\n                        transition: MONTH_EXIT,\n                      }),\n                    }}\n                    initial=\"enter\"\n                    animate=\"center\"\n                    exit=\"exit\"\n                    className=\"border-collapse\"\n                  >\n                    <thead>\n                      <tr>\n                        {showWeekNumber && (\n                          <th\n                            scope=\"col\"\n                            abbr=\"Week\"\n                            className=\"h-8 w-8 text-[11px] font-medium text-muted-foreground/70\"\n                          >\n                            Wk\n                          </th>\n                        )}\n                        {weekdays.map((w) => (\n                          <th\n                            key={w.long}\n                            scope=\"col\"\n                            abbr={w.long}\n                            className={cn(\n                              \"h-8 text-[11px] font-medium text-muted-foreground\",\n                              tall ? \"w-10\" : \"w-9\",\n                            )}\n                          >\n                            {w.short}\n                          </th>\n                        ))}\n                      </tr>\n                    </thead>\n                    <tbody>\n                      {weeks.map((row) => {\n                        // Days this row actually draws: side-by-side months\n                        // leave the other month's days blank.\n                        const shown =\n                          count > 1 ? row.filter((d) => sameMonth(d, m)) : row;\n                        const weekSelected =\n                          !!week && sameDay(row[0], weekStart(week.from));\n                        const weekPreview =\n                          !weekSelected && sameDay(row[0], previewWeek);\n                        // ISO weeks run Monday–Sunday; the row's Thursday\n                        // decides which one a differently started row is.\n                        const thursday =\n                          row.find((d) => d.getDay() === 4) ?? row[0];\n                        return (\n                          <tr key={dayKey(row[0])}>\n                            {showWeekNumber && (\n                              <th\n                                scope=\"row\"\n                                className={cn(\n                                  \"p-0 text-center text-[11px] font-normal tabular-nums transition-colors\",\n                                  weekSelected\n                                    ? \"font-medium text-foreground\"\n                                    : \"text-muted-foreground/70\",\n                                )}\n                              >\n                                {shown.length > 0 ? isoWeek(thursday) : null}\n                              </th>\n                            )}\n                            {row.map((d) => {\n                              const outside = !sameMonth(d, m);\n                              // Side-by-side months would show these days twice.\n                              if (outside && count > 1)\n                                return (\n                                  <td key={dayKey(d)} className=\"p-0\">\n                                    <span className={cn(\"block\", cellSize)} />\n                                  </td>\n                                );\n                              const meta = getDayMeta?.(d) ?? null;\n                              const disabled = isDisabled(d);\n                              const nightsBlocked =\n                                !disabled && blockedByNights(d);\n                              const isToday = sameDay(d, today);\n                              const isFrom = sameDay(d, range?.from);\n                              const isTo = sameDay(d, bandEnd);\n                              const inBand =\n                                !!range?.from &&\n                                !!bandEnd &&\n                                d > range.from &&\n                                d < bandEnd;\n                              const chosen = isMultiple && isPicked(d);\n                              const inWeek =\n                                !!week &&\n                                d >= startOfDay(week.from) &&\n                                d <= startOfDay(week.to);\n                              const selected = isRange\n                                ? isFrom || (isTo && !!range?.to)\n                                : isWeek\n                                  ? inWeek\n                                  : isMultiple\n                                    ? chosen\n                                    : sameDay(d, single);\n                              const endpoint = isFrom || isTo;\n                              // Picked neighbours in the same row merge into one\n                              // pill, so a run of days reads as a block.\n                              const joinPrev =\n                                chosen &&\n                                column(d) > 0 &&\n                                sameMonth(addDays(d, -1), m) &&\n                                isPicked(addDays(d, -1));\n                              const joinNext =\n                                chosen &&\n                                column(d) < 6 &&\n                                sameMonth(addDays(d, 1), m) &&\n                                isPicked(addDays(d, 1));\n                              // The week pill lives in the row's first drawn\n                              // cell and spans every drawn day after it.\n                              const weekPill =\n                                isWeek &&\n                                (weekSelected || weekPreview) &&\n                                sameDay(d, shown[0]);\n\n                              return (\n                                <td key={dayKey(d)} className=\"relative p-0\">\n                                  {(inBand ||\n                                    (endpoint &&\n                                      bandEnd &&\n                                      !sameDay(range?.from, bandEnd))) && (\n                                    <span\n                                      aria-hidden\n                                      className={cn(\n                                        \"absolute inset-y-0.5 bg-primary/10\",\n                                        inBand && \"inset-x-0\",\n                                        isFrom && \"left-1/2 right-0\",\n                                        isTo && \"left-0 right-1/2\",\n                                      )}\n                                    />\n                                  )}\n                                  {weekPill && (\n                                    <motion.span\n                                      aria-hidden\n                                      // Selected and previewed rows each glide\n                                      // between weeks, scoped per month.\n                                      layoutId={`${uid}-${mk}-${weekSelected ? \"week\" : \"week-preview\"}`}\n                                      transition={\n                                        reduce ? { duration: 0 } : SPRING_LAYOUT\n                                      }\n                                      style={{ width: `${shown.length * 100}%` }}\n                                      className={cn(\n                                        \"pointer-events-none absolute inset-y-0 left-0 rounded-lg\",\n                                        weekSelected ? \"bg-primary\" : \"bg-muted\",\n                                      )}\n                                    />\n                                  )}\n                                  <button\n                                    type=\"button\"\n                                    data-date={dayKey(d)}\n                                    tabIndex={\n                                      sameDay(d, focusInMonth) && !outside\n                                        ? 0\n                                        : -1\n                                    }\n                                    aria-label={\n                                      meta?.label\n                                        ? `${dayLabel.format(d)}, ${meta.label}`\n                                        : dayLabel.format(d)\n                                    }\n                                    aria-pressed={selected}\n                                    aria-disabled={\n                                      disabled || nightsBlocked || undefined\n                                    }\n                                    aria-current={isToday ? \"date\" : undefined}\n                                    title={\n                                      nightsBlocked\n                                        ? daysBetween(range?.from ?? d, d) <\n                                          minNights\n                                          ? `Minimum ${minNights} nights`\n                                          : `Maximum ${maxNights} nights`\n                                        : undefined\n                                    }\n                                    onClick={() => select(d)}\n                                    onFocus={() => {\n                                      setFocused(d);\n                                      if (isWeek) setHovered(d);\n                                    }}\n                                    onBlur={() => isWeek && setHovered(null)}\n                                    onPointerEnter={() =>\n                                      (isRange || isWeek) && setHovered(d)\n                                    }\n                                    className={cn(\n                                      \"relative isolate flex items-center justify-center rounded-lg text-sm tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring\",\n                                      cellSize,\n                                      tall && \"flex-col gap-0.5\",\n                                      outside\n                                        ? \"text-muted-foreground/50\"\n                                        : \"text-foreground\",\n                                      !selected &&\n                                        !disabled &&\n                                        !nightsBlocked &&\n                                        !isWeek &&\n                                        \"hover:bg-muted\",\n                                      selected && \"text-primary-foreground\",\n                                      selected &&\n                                        outside &&\n                                        \"text-primary-foreground/60\",\n                                      isTo && !range?.to && \"text-foreground\",\n                                      nightsBlocked &&\n                                        \"cursor-not-allowed text-muted-foreground/40\",\n                                      disabled &&\n                                        \"cursor-not-allowed text-muted-foreground/40 line-through\",\n                                    )}\n                                  >\n                                    {selected &&\n                                      !isWeek &&\n                                      (isRange || isMultiple ? (\n                                        <motion.span\n                                          aria-hidden\n                                          initial={\n                                            reduce ? false : { scale: 0.7 }\n                                          }\n                                          animate={{ scale: 1 }}\n                                          transition={SPRING_PRESS}\n                                          className={cn(\n                                            \"absolute inset-0 -z-10 rounded-lg bg-primary\",\n                                            joinPrev && \"rounded-l-none\",\n                                            joinNext && \"rounded-r-none\",\n                                          )}\n                                        />\n                                      ) : (\n                                        <motion.span\n                                          aria-hidden\n                                          // Scoped per month so the pill glides\n                                          // between days but never across the\n                                          // sliding grids.\n                                          layoutId={`${uid}-${mk}-selected`}\n                                          transition={\n                                            reduce\n                                              ? { duration: 0 }\n                                              : SPRING_LAYOUT\n                                          }\n                                          className=\"absolute inset-0 -z-10 rounded-lg bg-primary\"\n                                        />\n                                      ))}\n                                    {isTo && !range?.to && (\n                                      <span\n                                        aria-hidden\n                                        className=\"absolute inset-0 -z-10 rounded-lg border border-primary/40\"\n                                      />\n                                    )}\n                                    {tall ? (\n                                      <span className=\"leading-none\">\n                                        {d.getDate()}\n                                      </span>\n                                    ) : (\n                                      d.getDate()\n                                    )}\n                                    {tall && (\n                                      <span\n                                        aria-hidden\n                                        className={cn(\n                                          \"h-2.5 text-[10px] leading-none\",\n                                          meta?.tone === \"accent\"\n                                            ? // Tinted toward the foreground so\n                                              // the green keeps contrast on\n                                              // light and dark cards.\n                                              \"font-medium text-[color:color-mix(in_oklab,var(--chart-3)_70%,var(--foreground))]\"\n                                            : meta?.tone === \"muted\"\n                                              ? \"text-muted-foreground/60\"\n                                              : \"text-muted-foreground\",\n                                          selected &&\n                                            \"text-primary-foreground/75\",\n                                          (disabled || nightsBlocked) &&\n                                            \"text-muted-foreground/40\",\n                                        )}\n                                      >\n                                        {meta?.label}\n                                      </span>\n                                    )}\n                                    {isToday && (\n                                      <span\n                                        aria-hidden\n                                        className={cn(\n                                          \"absolute size-1 rounded-full\",\n                                          tall ? \"top-1.5 right-1.5\" : \"bottom-1\",\n                                          selected\n                                            ? \"bg-primary-foreground\"\n                                            : \"bg-primary\",\n                                        )}\n                                      />\n                                    )}\n                                  </button>\n                                </td>\n                              );\n                            })}\n                          </tr>\n                        );\n                      })}\n                    </tbody>\n                  </motion.table>\n                </AnimatePresence>\n              </div>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/motion/chart/area-chart.tsx","type":"registry:component","target":"@components/motion/chart/area-chart.tsx","content":"\"use client\";\n\nimport {\n  CartesianChart,\n  type CartesianChartProps,\n} from \"@/components/motion/chart/cartesian\";\n\nexport interface AreaChartProps extends CartesianChartProps {\n  /** Gradient fades toward the baseline; solid keeps one translucent tone. */\n  fill?: \"gradient\" | \"solid\";\n}\n\n/**\n * Volume over time: filled areas (overlapping or stacked) that rise from the\n * baseline as their lines draw in, on scroll and whenever the range changes,\n * with a snapping cursor and one tooltip for every series.\n */\nexport function AreaChart(props: AreaChartProps) {\n  return <CartesianChart type=\"area\" {...props} />;\n}\n"},{"path":"components/motion/chart/shared.tsx","type":"registry:component","target":"@components/motion/chart/shared.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ChartSeries = {\n  /** Key of the value in each datum. */\n  key: string;\n  label: string;\n  /** Any CSS color. Default: the next `--chart-N` slot, in fixed order. */\n  color?: string;\n};\n\nexport type ChartDatum = Record<string, string | number>;\n\n/** \"natural\" is a monotone curve: smooth, but never overshoots a data point. */\nexport type ChartCurve = \"natural\" | \"linear\" | \"step\";\n\nexport type TooltipIndicator = \"dot\" | \"line\" | \"dashed\";\n\n// Fixed categorical order, validated for colorblind and normal-vision\n// separation (light and dark steps live in the theme's --chart-* tokens).\n// Fallbacks are the light steps, for projects without the theme layer.\nconst SLOT_FALLBACKS = [\"#2a78d6\", \"#eb6834\", \"#1baf7a\", \"#eda100\", \"#e87ba4\"];\n\n/** Color for slot `index`; color follows the series, never its rank. */\nexport function seriesColor(series: { color?: string }, index: number) {\n  if (series.color) return series.color;\n  const slot = index % SLOT_FALLBACKS.length;\n  return `var(--chart-${slot + 1}, ${SLOT_FALLBACKS[slot]})`;\n}\n\n/**\n * Layout width of the element given the returned ref. ResizeObserver reports\n * the untransformed box, unlike getBoundingClientRect (react-use-measure), so a\n * chart inside a scaled container (thumbnails, zoomed stages) still fills it.\n */\nexport function useChartWidth<T extends Element = HTMLDivElement>() {\n  const [el, setEl] = useState<T | null>(null);\n  const [width, setWidth] = useState(0);\n  useEffect(() => {\n    if (!el) return;\n    const observer = new ResizeObserver(([entry]) =>\n      setWidth(entry.contentRect.width),\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [el]);\n  return [setEl, width] as const;\n}\n\n// A fixed locale keeps server and client output identical for hydration.\nconst FULL = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 2 });\nconst COMPACT = new Intl.NumberFormat(\"en-US\", {\n  notation: \"compact\",\n  maximumFractionDigits: 1,\n});\nexport const formatFull = (n: number) => FULL.format(n);\nexport const formatCompact = (n: number) => COMPACT.format(n);\n\n/** Round tick values covering [min, max], about `count` of them. */\nexport function niceTicks(min: number, max: number, count = 4) {\n  if (min === max) max = min + 1;\n  const raw = (max - min) / count;\n  const mag = 10 ** Math.floor(Math.log10(raw));\n  const step =\n    [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? 10 * mag;\n  const start = Math.floor(min / step) * step;\n  const end = Math.ceil(max / step) * step;\n  const ticks: number[] = [];\n  for (let v = start; v <= end + step / 2; v += step) {\n    ticks.push(Number(v.toFixed(10)));\n  }\n  return ticks;\n}\n\n/** Keep every k-th label so neighbours stay at least `minGap` px apart. */\nexport function labelStride(count: number, width: number, minGap = 56) {\n  return Math.max(1, Math.ceil(count / Math.max(1, Math.floor(width / minGap))));\n}\n\n/** Approximate gutter for axis labels at 12px. */\nexport function gutterFor(labels: string[]) {\n  return Math.max(...labels.map((l) => l.length), 1) * 7 + 12;\n}\n\ntype Point = [number, number];\n\n/** SVG path through `points` (sorted by x) with the given curve. */\nexport function curvePath(points: Point[], curve: ChartCurve) {\n  if (points.length === 0) return \"\";\n  const [x0, y0] = points[0];\n  if (points.length === 1) return `M${x0},${y0}`;\n  if (curve === \"step\") {\n    // Changes value halfway between points, like a stepped series.\n    let d = `M${x0},${y0}`;\n    for (let i = 1; i < points.length; i++) {\n      const [px] = points[i - 1];\n      const [x, y] = points[i];\n      const mid = (px + x) / 2;\n      d += `H${mid}V${y}H${x}`;\n    }\n    return d;\n  }\n  if (curve === \"linear\" || points.length < 3) {\n    return points.map(([x, y], i) => `${i ? \"L\" : \"M\"}${x},${y}`).join(\"\");\n  }\n  // Monotone cubic (Fritsch–Carlson): tangents are clamped so the curve stays\n  // between its neighbours and never invents a peak the data doesn't have.\n  const n = points.length;\n  const slopes: number[] = [];\n  for (let i = 0; i < n - 1; i++) {\n    const dx = points[i + 1][0] - points[i][0];\n    slopes.push(dx ? (points[i + 1][1] - points[i][1]) / dx : 0);\n  }\n  const tangents = points.map((_, i) => {\n    if (i === 0) return slopes[0];\n    if (i === n - 1) return slopes[n - 2];\n    const a = slopes[i - 1];\n    const b = slopes[i];\n    return a * b <= 0 ? 0 : (a + b) / 2;\n  });\n  for (let i = 0; i < n - 1; i++) {\n    const m = slopes[i];\n    if (m === 0) {\n      tangents[i] = 0;\n      tangents[i + 1] = 0;\n      continue;\n    }\n    const a = tangents[i] / m;\n    const b = tangents[i + 1] / m;\n    const s = a * a + b * b;\n    if (s > 9) {\n      const t = 3 / Math.sqrt(s);\n      tangents[i] = t * a * m;\n      tangents[i + 1] = t * b * m;\n    }\n  }\n  let d = `M${x0},${y0}`;\n  for (let i = 0; i < n - 1; i++) {\n    const [xa, ya] = points[i];\n    const [xb, yb] = points[i + 1];\n    const h = (xb - xa) / 3;\n    d += `C${xa + h},${ya + tangents[i] * h} ${xb - h},${yb - tangents[i + 1] * h} ${xb},${yb}`;\n  }\n  return d;\n}\n\n// ── tooltip ────────────────────────────────────────────────────────────────\n\nexport type TooltipRow = { label: string; value: string; color: string };\n\nfunction Indicator({ kind, color }: { kind: TooltipIndicator; color: string }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"shrink-0 rounded-[2px]\",\n        kind === \"dot\" && \"size-2.5\",\n        kind === \"line\" && \"w-1 self-stretch\",\n        kind === \"dashed\" && \"w-0 self-stretch border-[1.5px] border-dashed\",\n      )}\n      style={\n        kind === \"dashed\" ? { borderColor: color } : { background: color }\n      }\n    />\n  );\n}\n\n/**\n * Compact readout that springs between data positions: an optional label,\n * then one row per series with a colored indicator, the name and the value.\n */\nexport function ChartTooltip({\n  open,\n  x,\n  y,\n  width,\n  label,\n  rows,\n  indicator = \"dot\",\n}: {\n  open: boolean;\n  /** Anchor in px, relative to the chart's positioned wrapper. */\n  x: number;\n  y: number;\n  /** Wrapper width, to flip the tooltip before it overflows the edge. */\n  width: number;\n  label?: string;\n  rows: TooltipRow[];\n  indicator?: TooltipIndicator;\n}) {\n  const reduce = useReducedMotion();\n  const flip = x > width / 2;\n  return (\n    <AnimatePresence>\n      {open && (\n        <motion.div\n          aria-hidden\n          initial={{ opacity: 0, x, y, scale: reduce ? 1 : 0.96 }}\n          animate={{ opacity: 1, x, y, scale: 1 }}\n          exit={{ opacity: 0, scale: reduce ? 1 : 0.96, transition: { duration: 0.1 } }}\n          transition={\n            reduce\n              ? { duration: 0 }\n              : {\n                  opacity: { duration: 0.12 },\n                  scale: { duration: 0.15, ease: EASE_OUT },\n                  // Tracks the pointer between data positions; firm enough to\n                  // never trail behind a quick sweep.\n                  default: { type: \"spring\", stiffness: 520, damping: 42 },\n                }\n          }\n          className=\"pointer-events-none absolute top-0 left-0 z-10\"\n        >\n          <div\n            className={cn(\n              \"grid min-w-32 -translate-y-1/2 gap-1.5 rounded-lg border border-border bg-background px-2.5 py-1.5 text-xs shadow-xl\",\n              flip ? \"-translate-x-[calc(100%+12px)]\" : \"translate-x-3\",\n            )}\n          >\n            {label ? (\n              <p className=\"font-medium text-foreground\">{label}</p>\n            ) : null}\n            {rows.map((row) => (\n              <div key={row.label} className=\"flex items-stretch gap-2\">\n                <Indicator kind={indicator} color={row.color} />\n                <div className=\"flex flex-1 items-center justify-between gap-4 leading-none\">\n                  <span className=\"text-muted-foreground\">{row.label}</span>\n                  <span className=\"font-mono font-medium tabular-nums text-foreground\">\n                    {row.value}\n                  </span>\n                </div>\n              </div>\n            ))}\n          </div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\n// ── legend & table ─────────────────────────────────────────────────────────\n\nexport function ChartLegend({\n  items,\n  className,\n}: {\n  items: { label: string; color: string }[];\n  className?: string;\n}) {\n  return (\n    <ul\n      className={cn(\n        \"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3 text-xs text-muted-foreground\",\n        className,\n      )}\n    >\n      {items.map((item) => (\n        <li key={item.label} className=\"flex items-center gap-1.5\">\n          <span\n            aria-hidden\n            className=\"size-2 shrink-0 rounded-[2px]\"\n            style={{ background: item.color }}\n          />\n          {item.label}\n        </li>\n      ))}\n    </ul>\n  );\n}\n\n/** The table view: every value reachable without hovering or seeing color. */\nexport function ChartTable({\n  caption,\n  head,\n  rows,\n}: {\n  caption: string;\n  head: string[];\n  rows: ReactNode[][];\n}) {\n  // The wrapper is what hides it: a <caption> escapes a visually hidden\n  // table's clip and would otherwise paint over the chart.\n  return (\n    <div className=\"sr-only\">\n      <table>\n        <caption>{caption}</caption>\n        <thead>\n          <tr>\n            {head.map((h) => (\n              <th key={h} scope=\"col\">\n                {h}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {rows.map((row, r) => (\n            // biome-ignore lint/suspicious/noArrayIndexKey: rows mirror data order and never reorder.\n            <tr key={r}>\n              {row.map((cell, c) =>\n                c === 0 ? (\n                  // biome-ignore lint/suspicious/noArrayIndexKey: fixed column order.\n                  <th key={c} scope=\"row\">\n                    {cell}\n                  </th>\n                ) : (\n                  // biome-ignore lint/suspicious/noArrayIndexKey: fixed column order.\n                  <td key={c}>{cell}</td>\n                ),\n              )}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n\n/** Shared entrance timing: marks grow once the chart scrolls into view. */\nexport const GROW = { duration: 0.7, ease: EASE_OUT } as const;\n\n/** Arrow-key stepping shared by every chart that reads positions. */\nexport function stepIndex(\n  key: string,\n  current: number | null,\n  count: number,\n  wrap = false,\n) {\n  const forward = key === \"ArrowRight\" || key === \"ArrowDown\";\n  const back = key === \"ArrowLeft\" || key === \"ArrowUp\";\n  if (!forward && !back) return undefined;\n  if (current === null) return forward ? 0 : count - 1;\n  const next = current + (forward ? 1 : -1);\n  if (wrap) return (next + count) % count;\n  return Math.min(count - 1, Math.max(0, next));\n}\n"},{"path":"components/motion/date-range-picker.tsx","type":"registry:component","target":"@components/motion/date-range-picker.tsx","content":"\"use client\";\n\nimport { CalendarRange } from \"lucide-react\";\nimport { useMemo, useState } from \"react\";\nimport { Calendar, type DateRange } from \"@/components/motion/calendar\";\nimport {\n  MorphPopover,\n  MorphPopoverContent,\n  MorphPopoverTrigger,\n} from \"@/components/motion/popover-morph\";\nimport { cn } from \"@/lib/utils\";\n\nconst SHORT_DATE: Intl.DateTimeFormatOptions = {\n  month: \"short\",\n  day: \"numeric\",\n  year: \"numeric\",\n};\n\nexport type DateRangePreset = {\n  label: string;\n  /** Called on click, so \"today\" is read when the preset is used. */\n  range: () => { from: Date; to: Date };\n};\n\nexport interface DateRangePickerProps {\n  value?: DateRange | null;\n  defaultValue?: DateRange | null;\n  /** Fires once both ends are picked (or a preset is chosen). */\n  onValueChange?: (range: { from: Date; to: Date }) => void;\n  placeholder?: string;\n  /** Quick picks listed beside the calendar. */\n  presets?: DateRangePreset[];\n  /** Months side by side. Default 2 (stacked below `sm`). */\n  numberOfMonths?: number;\n  /** Intl options for the trigger label. Default: \"Oct 14 – 20, 2026\". */\n  formatOptions?: Intl.DateTimeFormatOptions;\n  /** Default \"en-US\". */\n  locale?: string;\n  minDate?: Date;\n  maxDate?: Date;\n  isDateDisabled?: (date: Date) => boolean;\n  weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n  align?: \"start\" | \"end\";\n  disabled?: boolean;\n  className?: string;\n  \"aria-label\"?: string;\n}\n\n/**\n * Range field whose two-month calendar morphs open from the trigger. The band\n * follows the pointer until the end day is picked, then the popover closes\n * and commits; optional presets commit in one click. Picking only a start\n * and closing keeps the previous range. Controlled or uncontrolled.\n */\nexport function DateRangePicker({\n  value: controlledValue,\n  defaultValue = null,\n  onValueChange,\n  placeholder = \"Pick a date range\",\n  presets,\n  numberOfMonths = 2,\n  formatOptions = SHORT_DATE,\n  locale = \"en-US\",\n  minDate,\n  maxDate,\n  isDateDisabled,\n  weekStartsOn,\n  align = \"start\",\n  disabled,\n  className,\n  \"aria-label\": ariaLabel = \"Choose date range\",\n}: DateRangePickerProps) {\n  const [open, setOpen] = useState(false);\n  const [internalValue, setInternalValue] = useState<DateRange | null>(\n    defaultValue,\n  );\n  const value =\n    controlledValue !== undefined ? controlledValue : internalValue;\n  // In-progress pick; only a finished range reaches `value`.\n  const [draft, setDraft] = useState<DateRange | null>(value);\n\n  const format = useMemo(\n    () => new Intl.DateTimeFormat(locale, formatOptions),\n    [locale, formatOptions],\n  );\n  const label =\n    value?.to != null\n      ? // Not formatRange: its separators differ between ICU builds (Node on\n        // the server vs the browser), which breaks hydration.\n        `${format.format(value.from)} – ${format.format(value.to)}`\n      : value\n        ? format.format(value.from)\n        : null;\n\n  const commit = (range: { from: Date; to: Date }) => {\n    if (controlledValue === undefined) setInternalValue(range);\n    onValueChange?.(range);\n    setOpen(false);\n  };\n\n  const onOpenChange = (next: boolean) => {\n    // Each opening starts from the committed range.\n    if (next) setDraft(value);\n    setOpen(next);\n  };\n\n  return (\n    <MorphPopover open={open} onOpenChange={onOpenChange}>\n      <MorphPopoverTrigger>\n        <button\n          type=\"button\"\n          disabled={disabled}\n          aria-label={label ? `${ariaLabel}, ${label}` : ariaLabel}\n          className={cn(\n            \"inline-flex h-10 min-w-60 items-center gap-2 rounded-xl border border-border bg-background px-3 text-left text-sm outline-none transition-colors hover:border-border-strong focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\",\n            label ? \"text-foreground\" : \"text-muted-foreground\",\n            className,\n          )}\n        >\n          <CalendarRange className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n          <span className=\"flex-1 truncate tabular-nums\">\n            {label ?? placeholder}\n          </span>\n        </button>\n      </MorphPopoverTrigger>\n\n      <MorphPopoverContent align={align} className=\"p-0\">\n        <div className=\"flex flex-col sm:flex-row\">\n          {presets && presets.length > 0 && (\n            <ul className=\"flex gap-1 overflow-x-auto border-b border-border p-2 [scrollbar-width:none] sm:flex-col sm:overflow-visible sm:border-r sm:border-b-0\">\n              {presets.map((p) => (\n                <li key={p.label} className=\"shrink-0\">\n                  <button\n                    type=\"button\"\n                    onClick={() => commit(p.range())}\n                    className=\"w-full whitespace-nowrap rounded-lg px-3 py-1.5 text-left text-sm text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n                  >\n                    {p.label}\n                  </button>\n                </li>\n              ))}\n            </ul>\n          )}\n          <Calendar\n            mode=\"range\"\n            numberOfMonths={numberOfMonths}\n            value={draft}\n            onValueChange={(r) => {\n              setDraft(r);\n              if (r.to) commit({ from: r.from, to: r.to });\n            }}\n            locale={locale}\n            minDate={minDate}\n            maxDate={maxDate}\n            isDateDisabled={isDateDisabled}\n            weekStartsOn={weekStartsOn}\n            className=\"border-0 bg-transparent\"\n          />\n        </div>\n      </MorphPopoverContent>\n    </MorphPopover>\n  );\n}\n"},{"path":"components/motion/skeleton.tsx","type":"registry:component","target":"@components/motion/skeleton.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type CSSProperties,\n  type ReactNode,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_IN_OUT, EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type SkeletonShape = \"rect\" | \"circle\" | \"text\";\n\nexport interface SkeletonProps {\n  shape?: SkeletonShape;\n  /** CSS width (number = px). Text defaults to 100%, circles to 40px. */\n  width?: number | string;\n  /** CSS height (number = px). Circles default to their width. */\n  height?: number | string;\n  /** Sweep a highlight across the block. Reduced motion swaps it for a pulse. */\n  shimmer?: boolean;\n  className?: string;\n  style?: CSSProperties;\n}\n\nconst SHAPE_CLASS: Record<SkeletonShape, string> = {\n  rect: \"rounded-lg\",\n  circle: \"rounded-full shrink-0\",\n  text: \"h-3.5 rounded-md\",\n};\n\n// Reduced motion: no sweep; the block breathes in opacity only.\nconst PULSE = {\n  animate: { opacity: [1, 0.55, 1] },\n  transition: { duration: 1.6, ease: EASE_IN_OUT, repeat: Infinity },\n};\n\n/**\n * A placeholder block. The shimmer is a gradient strip translated across the\n * block (transform only), clipped by the block's own overflow.\n */\nexport function Skeleton({\n  shape = \"rect\",\n  width,\n  height,\n  shimmer = true,\n  className,\n  style,\n}: SkeletonProps) {\n  const reduce = useReducedMotion();\n  const resolvedWidth = width ?? (shape === \"circle\" ? 40 : undefined);\n  const resolvedHeight = height ?? (shape === \"circle\" ? resolvedWidth : undefined);\n\n  return (\n    <motion.div\n      aria-hidden=\"true\"\n      className={cn(\n        \"relative overflow-hidden bg-muted\",\n        SHAPE_CLASS[shape],\n        shape === \"text\" && \"w-full\",\n        className,\n      )}\n      style={{ width: resolvedWidth, height: resolvedHeight, ...style }}\n      animate={reduce && shimmer ? PULSE.animate : undefined}\n      transition={reduce && shimmer ? PULSE.transition : undefined}\n    >\n      {shimmer && !reduce ? (\n        <motion.span\n          className=\"absolute inset-y-0 left-0 w-full bg-[linear-gradient(90deg,transparent,color-mix(in_oklab,var(--foreground)_9%,transparent),transparent)]\"\n          initial={{ x: \"-100%\" }}\n          animate={{ x: \"100%\" }}\n          transition={{\n            duration: 1.5,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            repeatDelay: 0.3,\n          }}\n        />\n      ) : null}\n    </motion.div>\n  );\n}\n\nexport interface SkeletonTextProps {\n  /** Number of lines. */\n  lines?: number;\n  /** Width of the final line, so the block reads like a real paragraph. */\n  lastLineWidth?: number | string;\n  /** Gap between lines (Tailwind class). */\n  gapClassName?: string;\n  lineClassName?: string;\n  shimmer?: boolean;\n  className?: string;\n}\n\n/** A paragraph of text lines with a shorter, natural-looking last line. */\nexport function SkeletonText({\n  lines = 3,\n  lastLineWidth = \"62%\",\n  gapClassName = \"gap-2\",\n  lineClassName,\n  shimmer = true,\n  className,\n}: SkeletonTextProps) {\n  const count = Math.max(1, lines);\n  return (\n    <div aria-hidden=\"true\" className={cn(\"flex w-full flex-col\", gapClassName, className)}>\n      {Array.from({ length: count }, (_, i) => (\n        <Skeleton\n          // biome-ignore lint/suspicious/noArrayIndexKey: lines are positional and never reorder\n          key={i}\n          shape=\"text\"\n          shimmer={shimmer}\n          className={lineClassName}\n          width={count > 1 && i === count - 1 ? lastLineWidth : \"100%\"}\n        />\n      ))}\n    </div>\n  );\n}\n\nexport interface SkeletonSwapProps {\n  /** While true the skeleton shows; flip to false to cross-fade to children. */\n  loading: boolean;\n  /** Placeholder rendered while loading — mirror the real content's shape. */\n  skeleton: ReactNode;\n  /** Real content. May be absent while loading. */\n  children?: ReactNode;\n  /**\n   * Keep the last measured content height while reloading, so a reload never\n   * collapses the box to the skeleton's height. Default true.\n   */\n  keepHeight?: boolean;\n  /** Announced to screen readers while loading. */\n  loadingLabel?: string;\n  className?: string;\n}\n\n/**\n * Cross-fades from a skeleton to real content without layout shift: both\n * layers share one grid cell during the fade (the box takes the taller of the\n * two), and the last measured content height is held as a min-height while a\n * reload shows the skeleton again.\n */\nexport function SkeletonSwap({\n  loading,\n  skeleton,\n  children,\n  keepHeight = true,\n  loadingLabel = \"Loading\",\n  className,\n}: SkeletonSwapProps) {\n  const reduce = useReducedMotion();\n  const contentRef = useRef<HTMLDivElement>(null);\n  const [measured, setMeasured] = useState<number | null>(null);\n\n  useEffect(() => {\n    const node = contentRef.current;\n    if (loading || !node || typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(([entry]) => {\n      if (entry) setMeasured(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight);\n    });\n    ro.observe(node);\n    return () => ro.disconnect();\n  }, [loading]);\n\n  return (\n    <div\n      aria-busy={loading}\n      className={cn(\"relative grid\", className)}\n      style={keepHeight && loading && measured != null ? { minHeight: measured } : undefined}\n    >\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {loading ? loadingLabel : \"\"}\n      </span>\n      <AnimatePresence initial={false}>\n        {loading ? (\n          <motion.div\n            key=\"skeleton\"\n            className=\"[grid-area:1/1]\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.18, ease: EASE_OUT } }}\n            transition={{ duration: 0.2, ease: EASE_OUT }}\n          >\n            {skeleton}\n          </motion.div>\n        ) : (\n          <motion.div\n            key=\"content\"\n            ref={contentRef}\n            className=\"[grid-area:1/1]\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, transition: { duration: 0.12, ease: EASE_OUT } }}\n            transition={{ duration: 0.28, ease: EASE_OUT }}\n          >\n            {children}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n"},{"path":"components/motion/stat-card.tsx","type":"registry:component","target":"@components/motion/stat-card.tsx","content":"\"use client\";\n\nimport { ArrowDownRight, ArrowUpRight } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useMemo } from \"react\";\nimport { Sparkline } from \"@/components/motion/chart/sparkline\";\nimport { NumberTicker } from \"@/components/motion/number-ticker\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type StatFormat = \"number\" | \"currency\" | \"percent\" | \"compact\";\nexport type StatTone = \"positive\" | \"negative\" | \"neutral\";\n\nexport interface StatCardProps {\n  label: ReactNode;\n  /** Headline value. For `format=\"percent\"` pass a fraction (0.034 = 3.4%). */\n  value: number;\n  /** Preset formatting. Default \"number\". */\n  format?: StatFormat;\n  /** ISO 4217 code for `format=\"currency\"`. Default \"USD\". */\n  currency?: string;\n  /** Formatting locale. Default \"en-US\" so server and client agree. */\n  locale?: string;\n  /** Extra Intl options merged over the preset (e.g. fraction digits). */\n  formatOptions?: Intl.NumberFormatOptions;\n  /** Relative change as a fraction (0.12 = +12%). Omit to hide the badge. */\n  delta?: number;\n  /** Which direction is good news. Default \"up\"; use \"down\" for churn, latency… */\n  goodDirection?: \"up\" | \"down\";\n  /** Force the badge tone instead of deriving it from delta and goodDirection. */\n  deltaTone?: StatTone;\n  /** Trend values for the inline sparkline. */\n  sparkline?: number[];\n  /** Sparkline color (any CSS color). Default follows the delta tone. */\n  sparklineColor?: string;\n  /** Small caption under the value, e.g. \"vs previous 30 days\". */\n  caption?: ReactNode;\n  /** Optional icon beside the label. */\n  icon?: ReactNode;\n  /** Render a skeleton instead of the value. */\n  loading?: boolean;\n  className?: string;\n}\n\nconst TONE_CLASS: Record<StatTone, string> = {\n  positive: \"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400\",\n  negative: \"bg-destructive/10 text-destructive\",\n  neutral: \"bg-muted text-muted-foreground\",\n};\nconst TONE_COLOR: Record<StatTone, string> = {\n  positive: \"var(--chart-3, #1baf7a)\",\n  negative: \"var(--destructive, #e5484d)\",\n  neutral: \"var(--chart-1, #2a78d6)\",\n};\n\nfunction presetOptions(\n  format: StatFormat,\n  currency: string,\n): Intl.NumberFormatOptions {\n  switch (format) {\n    case \"currency\":\n      return { style: \"currency\", currency, maximumFractionDigits: 0 };\n    case \"percent\":\n      return { style: \"percent\", maximumFractionDigits: 1 };\n    case \"compact\":\n      return { notation: \"compact\", maximumFractionDigits: 1 };\n    default:\n      return { maximumFractionDigits: 0 };\n  }\n}\n\n/**\n * KPI tile: label, a headline number whose digits roll to each new value, a\n * delta badge toned by whether the move is good news, an optional sparkline\n * that redraws when its data changes, and a caption. `loading` swaps in a\n * skeleton of the same footprint so the grid never jumps.\n */\nexport function StatCard({\n  label,\n  value,\n  format = \"number\",\n  currency = \"USD\",\n  locale = \"en-US\",\n  formatOptions,\n  delta,\n  goodDirection = \"up\",\n  deltaTone,\n  sparkline,\n  sparklineColor,\n  caption,\n  icon,\n  loading = false,\n  className,\n}: StatCardProps) {\n  const reduce = useReducedMotion();\n  const formatter = useMemo(\n    () =>\n      new Intl.NumberFormat(locale, {\n        ...presetOptions(format, currency),\n        ...formatOptions,\n      }),\n    [locale, format, currency, formatOptions],\n  );\n  const deltaFormatter = useMemo(\n    () =>\n      new Intl.NumberFormat(locale, {\n        style: \"percent\",\n        maximumFractionDigits: 1,\n        signDisplay: \"exceptZero\",\n      }),\n    [locale],\n  );\n\n  // NumberTicker rolls integers, so feed it the value scaled by the shown\n  // precision and divide back in the formatter — decimals survive the roll.\n  const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 0;\n  const scale = 10 ** (fractionDigits + (format === \"percent\" ? 2 : 0));\n  const tickerValue = Math.round(value * scale);\n  const formatTicker = (n: number) => formatter.format(n / scale);\n\n  const tone: StatTone =\n    deltaTone ??\n    (delta === undefined || delta === 0\n      ? \"neutral\"\n      : (delta > 0) === (goodDirection === \"up\")\n        ? \"positive\"\n        : \"negative\");\n  const DeltaIcon = (delta ?? 0) < 0 ? ArrowDownRight : ArrowUpRight;\n  const deltaText = delta === undefined ? \"\" : deltaFormatter.format(delta);\n  const sparkLabel = typeof label === \"string\" ? label : \"Trend\";\n\n  return (\n    <section\n      aria-busy={loading || undefined}\n      className={cn(\n        \"flex min-w-0 flex-col rounded-2xl border border-border bg-card p-4 text-card-foreground\",\n        className,\n      )}\n    >\n      <div className=\"flex items-center gap-2 text-xs font-medium text-muted-foreground\">\n        {icon ? <span className=\"[&_svg]:size-3.5\">{icon}</span> : null}\n        <span className=\"truncate\">{label}</span>\n      </div>\n\n      {loading ? (\n        <div className=\"mt-2 flex flex-col gap-2\" aria-hidden>\n          <span className=\"h-7 w-28 animate-pulse rounded-md bg-muted\" />\n          <span className=\"h-4 w-16 animate-pulse rounded-full bg-muted\" />\n          {sparkline ? <span className=\"mt-1 h-9 w-full animate-pulse rounded-md bg-muted\" /> : null}\n        </div>\n      ) : (\n        <>\n          <div className=\"mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1\">\n            <NumberTicker\n              value={tickerValue}\n              format={formatTicker}\n              duration={0.7}\n              stagger={0.03}\n              className=\"text-2xl font-semibold tracking-tight\"\n            />\n            {delta !== undefined ? (\n              <span\n                className={cn(\n                  \"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[11px] font-semibold tabular-nums transition-colors\",\n                  TONE_CLASS[tone],\n                )}\n              >\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.span\n                    key={DeltaIcon === ArrowUpRight ? \"up\" : \"down\"}\n                    initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={{ opacity: 0, y: reduce ? 0 : -4 }}\n                    transition={{ duration: 0.18, ease: EASE_OUT }}\n                    className=\"inline-flex\"\n                  >\n                    <DeltaIcon aria-hidden className=\"size-3\" />\n                  </motion.span>\n                </AnimatePresence>\n                <span className=\"sr-only\">\n                  {tone === \"positive\" ? \"Improved \" : tone === \"negative\" ? \"Worsened \" : \"Changed \"}\n                </span>\n                {deltaText}\n              </span>\n            ) : null}\n          </div>\n          {caption ? (\n            <p className=\"mt-0.5 text-[11px] text-muted-foreground\">{caption}</p>\n          ) : null}\n          {sparkline && sparkline.length > 1 ? (\n            <Sparkline\n              // Keyed by the data so a new range remounts and draws in again.\n              key={sparkline.join(\",\")}\n              data={sparkline}\n              height={36}\n              label={sparkLabel}\n              color={sparklineColor ?? TONE_COLOR[tone]}\n              formatValue={(v) => formatter.format(v)}\n              className=\"mt-3\"\n            />\n          ) : null}\n        </>\n      )}\n    </section>\n  );\n}\n\nexport interface StatGridProps {\n  children: ReactNode;\n  className?: string;\n}\n\n/**\n * Container-query grid for StatCards: one column when narrow, two from\n * 28rem, four from 48rem of its own width.\n */\nexport function StatGrid({ children, className }: StatGridProps) {\n  return (\n    <div className={cn(\"@container w-full\", className)}>\n      <div className=\"grid grid-cols-1 gap-3 @md:grid-cols-2 @3xl:grid-cols-4\">\n        {children}\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/motion/switch.tsx","type":"registry:component","target":"@components/motion/switch.tsx","content":"\"use client\";\n\nimport { animate, motion, MotionConfig, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\n// Heavy, deliberate thumb — high mass keeps the travel weighty without wobble.\nconst THUMB_SPRING = { type: \"spring\", stiffness: 800, damping: 80, mass: 4 } as const;\n\nexport interface SwitchProps {\n  checked: boolean;\n  onCheckedChange: (checked: boolean) => void;\n  disabled?: boolean;\n  label?: string;\n  ariaLabel?: string;\n  className?: string;\n}\n\nexport function Switch({\n  checked,\n  onCheckedChange,\n  disabled,\n  label,\n  ariaLabel,\n  className,\n}: SwitchProps) {\n  const id = useId();\n  const thumbRef = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const [isPressed, setIsPressed] = useState(false);\n  const [isPointer, setIsPointer] = useState(false);\n\n  // Disabled shake feedback when pressed.\n  useEffect(() => {\n    if (!thumbRef.current || reduce) return;\n    if (disabled && isPressed) {\n      animate(\n        thumbRef.current,\n        { x: [0, -2, 2, -1, 0] },\n        { delay: 0.2, duration: 0.6 },\n      );\n    }\n  }, [disabled, isPressed, reduce]);\n\n  const squish = !disabled && isPointer && isPressed && !reduce;\n\n  return (\n    <MotionConfig transition={reduce ? { duration: 0 } : THUMB_SPRING}>\n      <span className={cn(\"inline-flex items-center gap-3\", className)}>\n        <motion.button\n          id={id}\n          type=\"button\"\n          role=\"switch\"\n          aria-checked={checked}\n          aria-label={ariaLabel}\n          disabled={disabled}\n          onClick={() => !disabled && onCheckedChange(!checked)}\n          onPointerDown={(e) => {\n            setIsPressed(true);\n            setIsPointer(e.type.startsWith(\"pointer\"));\n          }}\n          onPointerUp={() => setIsPressed(false)}\n          onPointerLeave={() => setIsPressed(false)}\n          initial={false}\n          data-state={checked ? \"checked\" : \"unchecked\"}\n          className={cn(\n            \"group peer relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center px-1 rounded-full outline-none transition-colors duration-200\",\n            TOUCH_TARGET_CLASS,\n            \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n            \"disabled:cursor-not-allowed disabled:opacity-60\",\n            checked ? \"justify-end bg-primary\" : \"justify-start bg-muted-foreground/60\",\n          )}\n        >\n          <motion.div\n            ref={thumbRef}\n            layout\n            animate={{ scale: squish ? 0.9 : 1 }}\n            className=\"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-md\"\n          >\n            {/* Stretch toward the destination while active. */}\n            <div\n              className={cn(\n                \"size-5\",\n                squish && (checked ? \"ml-1\" : \"mr-1\"),\n              )}\n            />\n          </motion.div>\n        </motion.button>\n        {label ? (\n          <label htmlFor={id} className=\"cursor-pointer text-sm text-foreground\">\n            {label}\n          </label>\n        ) : null}\n      </span>\n    </MotionConfig>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/chart/cartesian.tsx","type":"registry:component","target":"@components/motion/chart/cartesian.tsx","content":"\"use client\";\n\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\nimport { type PointerEvent, useId, useRef, useState } from \"react\";\nimport {\n  type ChartCurve,\n  type ChartDatum,\n  ChartLegend,\n  type ChartSeries,\n  ChartTable,\n  ChartTooltip,\n  curvePath,\n  formatCompact,\n  formatFull,\n  GROW,\n  gutterFor,\n  labelStride,\n  niceTicks,\n  seriesColor,\n  stepIndex,\n  type TooltipIndicator,\n  useChartWidth,\n} from \"@/components/motion/chart/shared\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CartesianChartProps {\n  data: ChartDatum[];\n  /** Key of the x value in each datum (dates, months…). */\n  index: string;\n  series: ChartSeries[];\n  /** Plot height in px. Default 250. */\n  height?: number;\n  /** Default \"natural\" (smooth, never overshoots a point). */\n  curve?: ChartCurve;\n  /** Stack series on top of each other instead of overlapping them. */\n  stacked?: boolean;\n  /** Draw a dot on every point. */\n  dots?: boolean;\n  /** Print each point's value above it. Best for short series. */\n  showValues?: boolean;\n  /** Show the y axis labels. Default false: gridlines carry the scale. */\n  yAxis?: boolean;\n  /** Show the legend. Default: when there are two or more series. */\n  legend?: boolean;\n  indicator?: TooltipIndicator;\n  /** Formats x labels and the tooltip title. */\n  formatIndex?: (value: string) => string;\n  formatValue?: (value: number) => string;\n  formatAxis?: (value: number) => string;\n  /** Names the chart for assistive tech and the table view. */\n  title: string;\n  className?: string;\n}\n\nconst AXIS = 28; // x labels\nconst PAD_X = 12;\nconst PAD_TOP = 12;\n\n/**\n * Shared engine for line and area charts: monotone, linear or stepped curves,\n * optional stacking and gradient fills; lines draw in left to right and areas\n * rise from the baseline when scrolled into view (replayed when the range changes), a hairline cursor that\n * snaps to the nearest x, and one tooltip listing every series there.\n */\nexport function CartesianChart({\n  type,\n  fill = \"gradient\",\n  data,\n  index,\n  series,\n  height = 250,\n  curve = \"natural\",\n  stacked = false,\n  dots = false,\n  showValues = false,\n  yAxis = false,\n  legend,\n  indicator = \"dot\",\n  formatIndex = (v) => v,\n  formatValue = formatFull,\n  formatAxis = formatCompact,\n  title,\n  className,\n}: CartesianChartProps & {\n  type: \"line\" | \"area\";\n  fill?: \"gradient\" | \"solid\";\n}) {\n  const uid = useId().replace(/:/g, \"\");\n  const reduce = useReducedMotion();\n  const [measureRef, width] = useChartWidth();\n  const viewRef = useRef<HTMLDivElement>(null);\n  const inView = useInView(viewRef, { once: true, amount: 0.3 });\n  const [active, setActive] = useState<number | null>(null);\n  const shown = reduce || inView;\n\n  const value = (d: ChartDatum, key: string) => Number(d[key]) || 0;\n\n  // Per-series [lower, upper] for every point; lower is 0 unless stacked.\n  const bands = series.map((_, si) =>\n    data.map((d) => {\n      const lower = stacked\n        ? series.slice(0, si).reduce((sum, s) => sum + value(d, s.key), 0)\n        : 0;\n      return [lower, lower + value(d, series[si].key)] as const;\n    }),\n  );\n  const tops = bands.flatMap((b) => b.map(([, up]) => up));\n  const ticks = niceTicks(Math.min(0, ...tops), Math.max(...tops, 0));\n  const lo = ticks[0];\n  const hi = ticks[ticks.length - 1];\n\n  const left = yAxis ? gutterFor(ticks.map(formatAxis)) : PAD_X;\n  const plotW = Math.max(0, width - left - PAD_X);\n  const step = data.length > 1 ? plotW / (data.length - 1) : 0;\n  const x = (i: number) => left + i * step;\n  const y = (v: number) => PAD_TOP + (1 - (v - lo) / (hi - lo)) * (height - PAD_TOP);\n  const stride = labelStride(data.length, plotW, 64);\n  const last = data.length - 1;\n  const showLegend = legend ?? series.length > 1;\n\n  const linePts = (si: number) =>\n    bands[si].map(([, up], i) => [x(i), y(up)] as [number, number]);\n  const areaPath = (si: number) => {\n    const top = curvePath(linePts(si), curve);\n    const bottom = curvePath(\n      bands[si].map(([low], i) => [x(i), y(low)] as [number, number]).reverse(),\n      curve,\n    ).replace(/^M/, \"L\");\n    return `${top}${bottom}Z`;\n  };\n\n  // Replays the reveal when the visible range changes (e.g. 7d → 90d).\n  const revealKey = `${data.length}-${String(data[0]?.[index] ?? \"\")}`;\n\n  const onPointerMove = (e: PointerEvent<SVGSVGElement>) => {\n    const box = e.currentTarget.getBoundingClientRect();\n    const i = Math.round((e.clientX - box.left - left) / (step || 1));\n    setActive(Math.min(last, Math.max(0, i)));\n  };\n\n  const activeTop =\n    active === null\n      ? 0\n      : Math.min(...series.map((_, si) => y(bands[si][active][1])));\n\n  return (\n    <div ref={viewRef} className={cn(\"w-full\", className)}>\n      <div ref={measureRef} className=\"relative w-full\">\n        {width > 0 && data.length > 0 && (\n          // biome-ignore lint/a11y/useSemanticElements: a chart is not a form; group labels the plot\n          <svg\n            width={width}\n            height={height + AXIS}\n            role=\"group\"\n            aria-label={`${title}. Use arrow keys to read each point.`}\n            // biome-ignore lint/a11y/noNoninteractiveTabindex: focusable chart widget; arrow keys read values\n            tabIndex={0}\n            onKeyDown={(e) => {\n              const next = stepIndex(e.key, active, data.length);\n              if (next === undefined) return;\n              e.preventDefault();\n              setActive(next);\n            }}\n            onPointerMove={onPointerMove}\n            onPointerLeave={() => setActive(null)}\n            onBlur={() => setActive(null)}\n            className=\"block touch-pan-y overflow-visible rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <defs>\n              {type === \"area\" &&\n                series.map((s, si) => (\n                  <linearGradient\n                    key={s.key}\n                    id={`${uid}-fill-${si}`}\n                    x1=\"0\"\n                    x2=\"0\"\n                    y1=\"0\"\n                    y2=\"1\"\n                  >\n                    <stop\n                      offset=\"5%\"\n                      stopColor={seriesColor(s, si)}\n                      stopOpacity={fill === \"gradient\" ? 0.6 : 0.4}\n                    />\n                    <stop\n                      offset=\"95%\"\n                      stopColor={seriesColor(s, si)}\n                      stopOpacity={fill === \"gradient\" ? 0.05 : 0.4}\n                    />\n                  </linearGradient>\n                ))}\n            </defs>\n\n            {ticks.map((t) => (\n              <g key={t}>\n                <line\n                  x1={yAxis ? left : 0}\n                  x2={width}\n                  y1={y(t)}\n                  y2={y(t)}\n                  stroke=\"var(--border-strong)\"\n                  shapeRendering=\"crispEdges\"\n                />\n                {yAxis && (\n                  <text\n                    x={left - 8}\n                    y={y(t)}\n                    dy=\"0.32em\"\n                    textAnchor=\"end\"\n                    className=\"fill-muted-foreground text-xs tabular-nums\"\n                  >\n                    {formatAxis(t)}\n                  </text>\n                )}\n              </g>\n            ))}\n\n            {data.map((d, i) =>\n              // Every stride-th label plus the last, minus a strided one that\n              // would crowd the last.\n              i === last || (i % stride === 0 && (last - i) * step >= 64) ? (\n                <text\n                  // biome-ignore lint/suspicious/noArrayIndexKey: x positions keep their order.\n                  key={i}\n                  x={x(i)}\n                  y={height + 20}\n                  textAnchor={i === 0 ? \"start\" : i === last ? \"end\" : \"middle\"}\n                  className=\"fill-muted-foreground text-xs\"\n                >\n                  {formatIndex(String(d[index]))}\n                </text>\n              ) : null,\n            )}\n\n            {active !== null && (\n              <line\n                x1={x(active)}\n                x2={x(active)}\n                y1={PAD_TOP}\n                y2={height}\n                stroke=\"var(--muted-foreground)\"\n                strokeOpacity={0.4}\n                shapeRendering=\"crispEdges\"\n              />\n            )}\n\n            {/* Remounted per range so the entrance replays when the data\n                window changes (e.g. 7 days → 3 months). */}\n            <g key={revealKey}>\n              {type === \"area\" &&\n                series.map((s, si) => (\n                  <motion.path\n                    key={s.key}\n                    d={areaPath(si)}\n                    fill={`url(#${uid}-fill-${si})`}\n                    initial={reduce ? false : { opacity: 0, scaleY: 0.4 }}\n                    animate={shown ? { opacity: 1, scaleY: 1 } : { opacity: 0, scaleY: 0.4 }}\n                    transition={reduce ? { duration: 0 } : { ...GROW, duration: 0.9, delay: 0.15 }}\n                    style={{ transformBox: \"view-box\", transformOrigin: `0px ${y(lo)}px` }}\n                  />\n                ))}\n              {series.map((s, si) => (\n                <motion.path\n                  key={s.key}\n                  d={curvePath(linePts(si), curve)}\n                  fill=\"none\"\n                  stroke={seriesColor(s, si)}\n                  strokeWidth={2}\n                  strokeLinejoin=\"round\"\n                  strokeLinecap=\"round\"\n                  initial={reduce ? false : { pathLength: 0 }}\n                  animate={{ pathLength: shown ? 1 : 0 }}\n                  transition={reduce ? { duration: 0 } : { duration: 1.1, ease: GROW.ease, delay: si * 0.08 }}\n                />\n              ))}\n              {(dots || showValues) && (\n                <motion.g\n                  initial={reduce ? false : { opacity: 0 }}\n                  animate={{ opacity: shown ? 1 : 0 }}\n                  transition={reduce ? { duration: 0 } : { duration: 0.3, delay: 0.9 }}\n                >\n                  {dots &&\n                    series.map((s, si) =>\n                      linePts(si).map(([cx, cy], i) => (\n                        <circle\n                          // biome-ignore lint/suspicious/noArrayIndexKey: points keep their order.\n                          key={`${s.key}-${i}`}\n                          cx={cx}\n                          cy={cy}\n                          r={3.5}\n                          fill={seriesColor(s, si)}\n                          stroke=\"var(--card)\"\n                          strokeWidth={2}\n                        />\n                      )),\n                    )}\n                  {showValues &&\n                    series.map((s, si) =>\n                      linePts(si).map(([cx, cy], i) => (\n                        <text\n                          // biome-ignore lint/suspicious/noArrayIndexKey: points keep their order.\n                          key={`${s.key}-v${i}`}\n                          x={cx}\n                          y={cy - 12}\n                          textAnchor=\"middle\"\n                          className=\"fill-foreground text-xs font-medium tabular-nums\"\n                        >\n                          {formatValue(value(data[i], s.key))}\n                        </text>\n                      )),\n                    )}\n                </motion.g>\n              )}\n            </g>\n\n            {active !== null &&\n              series.map((s, si) => (\n                <motion.circle\n                  key={s.key}\n                  initial={reduce ? false : { scale: 0.4 }}\n                  animate={{ scale: 1, cx: x(active), cy: y(bands[si][active][1]) }}\n                  transition={\n                    reduce\n                      ? { duration: 0 }\n                      : { type: \"spring\", stiffness: 520, damping: 40 }\n                  }\n                  r={5}\n                  fill={seriesColor(s, si)}\n                  stroke=\"var(--card)\"\n                  strokeWidth={2}\n                  style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n                />\n              ))}\n          </svg>\n        )}\n        <ChartTooltip\n          open={active !== null}\n          x={active === null ? 0 : x(active)}\n          y={activeTop}\n          width={width}\n          indicator={indicator}\n          label={active === null ? \"\" : formatIndex(String(data[active][index]))}\n          rows={\n            active === null\n              ? []\n              : (() => {\n                  const rows = series.map((s, si) => ({\n                    label: s.label,\n                    value: formatValue(value(data[active], s.key)),\n                    color: seriesColor(s, si),\n                  }));\n                  // Stacked: read top-down, the way the bands sit.\n                  return stacked ? rows.reverse() : rows;\n                })()\n          }\n        />\n      </div>\n      {showLegend && (\n        <ChartLegend\n          items={series.map((s, si) => ({ label: s.label, color: seriesColor(s, si) }))}\n        />\n      )}\n      <ChartTable\n        caption={title}\n        head={[index, ...series.map((s) => s.label)]}\n        rows={data.map((d) => [\n          formatIndex(String(d[index])),\n          ...series.map((s) => formatValue(value(d, s.key))),\n        ])}\n      />\n    </div>\n  );\n}\n"},{"path":"components/motion/popover-morph.tsx","type":"registry:component","target":"@components/motion/popover-morph.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { usePopoverPortalPosition } from \"@/components/motion/popover-position\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"bottom\";\ntype Align = \"start\" | \"end\";\n\ntype MorphContextValue = {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  toggle: () => void;\n  triggerId: string;\n  contentId: string;\n  /** The element the panel measures against — see `registerTrigger`. */\n  triggerRef: React.MutableRefObject<HTMLElement | null>;\n  registerTrigger: (node: HTMLElement | null) => void;\n  contentRef: React.MutableRefObject<HTMLDivElement | null>;\n};\n\nconst MorphContext = createContext<MorphContextValue | null>(null);\n\nfunction useMorphContext(component: string) {\n  const ctx = useContext(MorphContext);\n  if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);\n  return ctx;\n}\n\nexport interface MorphPopoverProps {\n  children: ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Uncontrolled initial open state. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n}\n\n/**\n * A popover whose panel morphs open from the trigger corner: it's laid out at\n * full size but clipped to the corner nearest the trigger, then unclips as one\n * piece. Closes on outside pointer / Escape. Controlled or uncontrolled.\n */\nexport function MorphPopover({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n}: MorphPopoverProps) {\n  const baseId = useId();\n  const [root, setRoot] = useState<HTMLDivElement | null>(null);\n  const [trigger, setTrigger] = useState<HTMLElement | null>(null);\n  const contentRef = useRef<HTMLDivElement | null>(null);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const controlled = controlledOpen !== undefined;\n  const open = controlled ? controlledOpen : internalOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlled, onOpenChange],\n  );\n  const toggle = useCallback(() => setOpen(!open), [setOpen, open]);\n\n  // A trigger normally registers itself through MorphPopoverTrigger. It can't\n  // when something else already clones the element — a Tooltip wrapping the\n  // button, say — and an unregistered trigger leaves the panel with nothing to\n  // measure against, so it renders permanently invisible. The root boxes the\n  // trigger exactly (the content portals out of it), so it stands in until a\n  // real trigger registers, and stands in again if that one unmounts. Both are\n  // state, so a trigger arriving while the panel is open re-anchors it.\n  const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(\n    () => ({ current: trigger ?? root }),\n    [root, trigger],\n  );\n\n  // The panel is a `role=\"dialog\"` and goes inert the moment it closes, so\n  // focus cannot be left sitting inside it: a dismissal hands it back to the\n  // trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes\n  // the focus onward itself when it lands on something focusable — this only\n  // catches the case where it would otherwise be stranded. When no trigger has\n  // registered, the root anchor stands in only if it can actually hold focus;\n  // there is nowhere better than where the keyboard already is, so leave it.\n  const close = useCallback(() => {\n    setOpen(false);\n    const focused = document.activeElement;\n    const inPanel =\n      focused instanceof HTMLElement && contentRef.current?.contains(focused);\n    if (!inPanel) return;\n    const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);\n    restore?.focus();\n  }, [root, setOpen, trigger]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && close();\n    const onPointer = (e: PointerEvent) => {\n      const target = e.target as Node;\n      if (\n        root &&\n        !root.contains(target) &&\n        !contentRef.current?.contains(target)\n      )\n        close();\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open, root, close]);\n\n  const ctx = useMemo<MorphContextValue>(\n    () => ({\n      open,\n      setOpen,\n      toggle,\n      triggerId: `${baseId}-trigger`,\n      contentId: `${baseId}-content`,\n      triggerRef: anchorRef,\n      registerTrigger: setTrigger,\n      contentRef,\n    }),\n    [open, setOpen, toggle, baseId, anchorRef],\n  );\n\n  return (\n    <MorphContext.Provider value={ctx}>\n      <div ref={setRoot} className={cn(\"relative inline-flex\", className)}>\n        {children}\n      </div>\n    </MorphContext.Provider>\n  );\n}\n\nexport interface MorphPopoverTriggerProps {\n  children: ReactElement;\n}\n\nfunction mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref && typeof ref === \"object\")\n        (ref as React.MutableRefObject<T | null>).current = node;\n    }\n  };\n}\n\n/** Wraps a single element, toggling the popover on click. */\nexport function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {\n  const ctx = useMorphContext(\"MorphPopoverTrigger\");\n  if (!isValidElement(children)) return children;\n\n  const child = children as ReactElement<Record<string, unknown>>;\n  const childOnClick = child.props.onClick as\n    | ((e: unknown) => void)\n    | undefined;\n  const childRef = (child.props as { ref?: Ref<HTMLElement> }).ref;\n\n  return cloneElement(child, {\n    id: ctx.triggerId,\n    ref: mergeRefs(childRef, ctx.registerTrigger),\n    onClick: (e: unknown) => {\n      childOnClick?.(e);\n      ctx.toggle();\n    },\n    \"aria-haspopup\": \"dialog\",\n    \"aria-expanded\": ctx.open,\n    \"aria-controls\": ctx.open ? ctx.contentId : undefined,\n  });\n}\n\nconst originFor = (side: Side, align: Align) =>\n  `${side === \"bottom\" ? \"top\" : \"bottom\"} ${align === \"end\" ? \"right\" : \"left\"}`;\n\n// A clip that hides everything but the corner nearest the trigger, so the\n// panel appears to grow out of it. inset(top right bottom left).\nfunction clipHidden(side: Side, align: Align, radius: number) {\n  const top = side === \"bottom\" ? \"0%\" : \"92%\";\n  const bottom = side === \"bottom\" ? \"92%\" : \"0%\";\n  const right = align === \"end\" ? \"0%\" : \"92%\";\n  const left = align === \"end\" ? \"92%\" : \"0%\";\n  return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;\n}\nconst clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;\n\n// Preserve the original spring character on the wrapper, but tween the complex\n// clip-path so it cannot snap when the spring resolves its final distance.\nconst MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;\n\nexport interface MorphPopoverContentProps {\n  children: ReactNode;\n  side?: Side;\n  align?: Align;\n  /** Gap between trigger and panel, in px. Default 8. */\n  sideOffset?: number;\n  /** Panel corner radius, in px. Default 16. */\n  radius?: number;\n  className?: string;\n}\n\nexport function MorphPopoverContent({\n  children,\n  side = \"bottom\",\n  align = \"end\",\n  sideOffset = 8,\n  radius = 16,\n  className,\n}: MorphPopoverContentProps) {\n  const ctx = useMorphContext(\"MorphPopoverContent\");\n  const reduce = useReducedMotion() ?? false;\n  const [portalReady, setPortalReady] = useState(false);\n  const layout = usePopoverPortalPosition(\n    ctx.triggerRef,\n    ctx.contentRef,\n    portalReady && ctx.open,\n  );\n\n  useEffect(() => setPortalReady(true), []);\n  const left = layout\n    ? align === \"end\"\n      ? layout.trigger.left + layout.trigger.width - layout.content.width\n      : layout.trigger.left\n    : 0;\n  const top = layout\n    ? side === \"bottom\"\n      ? layout.trigger.top + layout.trigger.height + sideOffset\n      : layout.trigger.top - layout.content.height - sideOffset\n    : 0;\n\n  // Both directions travel between the exact same hidden/show states. Exit\n  // targets \"hidden\" directly instead of introducing separate choreography.\n  const wrap = reduce\n    ? undefined\n    : {\n        hidden: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },\n        show: { opacity: 1, scale: 1, transition: SPRING_PANEL },\n      };\n  const clip = reduce\n    ? undefined\n    : {\n        hidden: {\n          clipPath: clipHidden(side, align, radius),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n        show: {\n          clipPath: clipShown(radius),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n      };\n\n  // Keep the server and first client render identical, then mount the portal.\n  if (!portalReady) return null;\n\n  return createPortal(\n    <AnimatePresence>\n      {ctx.open ? (\n        <motion.div\n          data-morph-popover-portal=\"\"\n          // Wrapper carries the shadow as a drop-shadow filter, which hugs the\n          // clipped shape below (box-shadow would just get clipped away).\n          variants={wrap}\n          initial={reduce ? { opacity: 0 } : \"hidden\"}\n          animate={reduce ? { opacity: 1 } : \"show\"}\n          exit={reduce ? { opacity: 0 } : \"hidden\"}\n          transition={reduce ? { duration: 0.12 } : undefined}\n          style={{\n            left,\n            top,\n            visibility: layout ? \"visible\" : \"hidden\",\n            transformOrigin: originFor(side, align),\n          }}\n          className=\"fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]\"\n        >\n          <motion.div\n            ref={ctx.contentRef}\n            id={ctx.contentId}\n            role=\"dialog\"\n            aria-labelledby={ctx.triggerId}\n            variants={clip}\n            style={{ borderRadius: radius }}\n            className={cn(\n              \"overflow-hidden border border-border bg-background\",\n              className,\n            )}\n          >\n            {children}\n          </motion.div>\n        </motion.div>\n      ) : null}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n"},{"path":"components/motion/chart/sparkline.tsx","type":"registry:component","target":"@components/motion/chart/sparkline.tsx","content":"\"use client\";\n\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useId, useRef } from \"react\";\nimport {\n  formatFull,\n  GROW,\n  useChartWidth,\n} from \"@/components/motion/chart/shared\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SparklineProps {\n  /** Values in time order. */\n  data: number[];\n  /** Height in px; width fills the container. Default 40. */\n  height?: number;\n  /** Any CSS color. Default: the first `--chart-*` slot. */\n  color?: string;\n  /** Soft fill under the line. Default true. */\n  area?: boolean;\n  formatValue?: (value: number) => string;\n  /** What the values are, e.g. \"Weekly revenue\". Used in the spoken summary. */\n  label: string;\n  className?: string;\n}\n\nconst PAD = 5; // room for the end dot and its ring\n\n/**\n * Word-sized trend line for stat tiles. Draws in when scrolled into view and\n * ends on a ringed dot at the latest value. It has no hover layer: the tile's\n * headline number carries the value, and the summary below is spoken.\n */\nexport function Sparkline({\n  data,\n  height = 40,\n  color = \"var(--chart-1, #2a78d6)\",\n  area = true,\n  formatValue = formatFull,\n  label,\n  className,\n}: SparklineProps) {\n  const uid = useId();\n  const reduce = useReducedMotion();\n  const [measureRef, width] = useChartWidth();\n  const viewRef = useRef<HTMLDivElement>(null);\n  const inView = useInView(viewRef, { once: true, amount: 0.5 });\n  const shown = reduce || inView;\n\n  const min = Math.min(...data);\n  const max = Math.max(...data);\n  const span = max - min || 1;\n  const x = (i: number) =>\n    PAD + (data.length > 1 ? (i / (data.length - 1)) * (width - PAD * 2) : 0);\n  const y = (v: number) => PAD + (1 - (v - min) / span) * (height - PAD * 2);\n  const line = data.map((v, i) => `${i ? \"L\" : \"M\"}${x(i)},${y(v)}`).join(\"\");\n  const last = data.length - 1;\n\n  const first = data[0] ?? 0;\n  const end = data[last] ?? 0;\n  const change = first ? ((end - first) / Math.abs(first)) * 100 : 0;\n  const summary = `${label}: ${formatValue(first)} to ${formatValue(end)}, ${\n    change >= 0 ? \"up\" : \"down\"\n  } ${Math.abs(change).toFixed(1)}% over ${data.length} points`;\n\n  return (\n    <div ref={viewRef} className={cn(\"w-full\", className)}>\n      <div ref={measureRef} style={{ height }} className=\"w-full\">\n        {width > 0 && data.length > 1 && (\n          <svg width={width} height={height} role=\"img\" aria-label={summary}>\n            {area && (\n              <>\n                <defs>\n                  <linearGradient id={`${uid}-fill`} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n                    <stop offset=\"0%\" stopColor={color} stopOpacity={0.2} />\n                    <stop offset=\"100%\" stopColor={color} stopOpacity={0} />\n                  </linearGradient>\n                </defs>\n                <motion.path\n                  d={`${line}L${x(last)},${height}L${x(0)},${height}Z`}\n                  fill={`url(#${uid}-fill)`}\n                  initial={false}\n                  animate={{ opacity: shown ? 1 : 0 }}\n                  transition={reduce ? { duration: 0 } : { ...GROW, delay: 0.3 }}\n                />\n              </>\n            )}\n            <motion.path\n              d={line}\n              fill=\"none\"\n              stroke={color}\n              strokeWidth={2}\n              strokeLinejoin=\"round\"\n              strokeLinecap=\"round\"\n              initial={false}\n              animate={{ pathLength: shown ? 1 : 0 }}\n              transition={reduce ? { duration: 0 } : { duration: 0.8, ease: GROW.ease }}\n            />\n            <motion.circle\n              cx={x(last)}\n              cy={y(end)}\n              r={3.5}\n              fill={color}\n              stroke=\"var(--card)\"\n              strokeWidth={2}\n              initial={false}\n              animate={{ opacity: shown ? 1 : 0, scale: shown ? 1 : 0.4 }}\n              transition={reduce ? { duration: 0 } : { duration: 0.2, delay: 0.7 }}\n              style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n            />\n          </svg>\n        )}\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/motion/number-ticker.tsx","type":"registry:component","target":"@components/motion/number-ticker.tsx","content":"\"use client\";\n\nimport { animate, motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NumberTickerProps {\n  value: number;\n  /** Digits to pad to (left). */\n  pad?: number;\n  /** Per-digit roll duration in seconds. */\n  duration?: number;\n  /** Stagger between digits. */\n  stagger?: number;\n  /** Render only after the element enters the viewport. */\n  startOnView?: boolean;\n  prefix?: string;\n  suffix?: string;\n  /** Add a small blur during digit rolls. */\n  blur?: boolean;\n  className?: string;\n  digitClassName?: string;\n  /** Insert en-US group separators (commas); fixed so server and client match. Server-component safe. */\n  locale?: boolean;\n  /** Custom formatter. Client-only — server components must use `locale` instead. */\n  format?: (value: number) => string;\n}\n\nconst DIGIT_HEIGHT_EM = 1.1;\nconst DIGITS = Array.from({ length: 10 }, (_, n) => n);\n\nexport function NumberTicker({\n  value,\n  pad,\n  duration = 0.9,\n  stagger = 0.04,\n  startOnView = true,\n  prefix,\n  suffix,\n  blur = false,\n  className,\n  digitClassName,\n  locale,\n  format,\n}: NumberTickerProps) {\n  const containerRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(containerRef, { once: true, amount: 0.6 });\n  const [armed, setArmed] = useState(!startOnView);\n\n  useEffect(() => {\n    if (startOnView && inView) setArmed(true);\n  }, [startOnView, inView]);\n\n  const text = useMemo(() => {\n    const rounded = Math.round(value);\n    const formatted = format\n      ? format(rounded)\n      : locale\n        ? rounded.toLocaleString(\"en-US\")\n        : rounded.toString();\n    return pad ? formatted.padStart(pad, \"0\") : formatted;\n  }, [value, pad, format, locale]);\n  const glyphs = useMemo(() => {\n    const chars = text.split(\"\");\n    // Key by place value (position from the right): a changing digit keeps its\n    // identity and rolls to the new value instead of remounting and replaying\n    // from 0. Growing numbers add glyphs on the left without re-keying the\n    // ones, tens, hundreds already on screen.\n    return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));\n  }, [text]);\n  const readableText = `${prefix ?? \"\"}${text}${suffix ?? \"\"}`;\n\n  // Stagger is an entrance flourish. Once the reveal has played, value\n  // changes roll every digit immediately — a per-digit delay on live updates\n  // reads as lag.\n  const [entered, setEntered] = useState(false);\n  useEffect(() => {\n    if (!armed || entered) return;\n    const total = (duration + glyphs.length * stagger) * 1000;\n    const t = window.setTimeout(() => setEntered(true), total);\n    return () => window.clearTimeout(t);\n  }, [armed, entered, duration, stagger, glyphs.length]);\n\n  return (\n    <span\n      ref={containerRef}\n      className={cn(\"inline-flex items-center tabular-nums\", className)}\n    >\n      <span className=\"sr-only\">{readableText}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-center\">\n        {prefix ? <span>{prefix}</span> : null}\n        {glyphs.map(({ char, id }, i) => {\n          const isDigit = /\\d/.test(char);\n          if (!isDigit) {\n            return (\n              <span key={id} className=\"inline-block\">\n                {char}\n              </span>\n            );\n          }\n          const digit = Number(char);\n          return (\n            <Digit\n              key={id}\n              digit={armed ? digit : 0}\n              delay={entered ? 0 : i * stagger}\n              duration={duration}\n              blur={blur}\n              className={digitClassName}\n            />\n          );\n        })}\n        {suffix ? <span>{suffix}</span> : null}\n      </span>\n    </span>\n  );\n}\n\nfunction Digit({\n  digit,\n  delay,\n  duration,\n  blur,\n  className,\n}: {\n  digit: number;\n  delay: number;\n  duration: number;\n  blur: boolean;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  const columnRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {\n      return;\n    }\n\n    const node = columnRef.current;\n    const controls = animate(\n      node,\n      { filter: [\"blur(10px)\", \"blur(0px)\"] },\n      {\n        duration: Math.min(duration * 0.75, 0.32),\n        delay,\n        ease: EASE_OUT,\n      },\n    );\n\n    return () => {\n      controls.stop();\n      node.style.filter = \"blur(0px)\";\n    };\n  }, [blur, delay, digit, duration, reduce]);\n\n  return (\n    <span\n      className={cn(\"relative inline-block overflow-hidden\", className)}\n      style={{ height: `${DIGIT_HEIGHT_EM}em`, width: \"1ch\" }}\n    >\n      <motion.span\n        ref={columnRef}\n        initial={{ y: 0 }}\n        animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : { duration, delay, ease: EASE_OUT }\n        }\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]\"\n      >\n        {DIGITS.map((n) => (\n          <span\n            key={n}\n            className=\"flex h-[1.1em] items-center justify-center leading-none\"\n          >\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"components/motion/popover-position.ts","type":"registry:component","target":"@components/motion/popover-position.ts","content":"\"use client\";\n\nimport {\n  type MutableRefObject,\n  useCallback,\n  useLayoutEffect,\n  useState,\n} from \"react\";\n\nexport type PortalLayout = {\n  trigger: {\n    left: number;\n    top: number;\n    width: number;\n    height: number;\n  };\n  content: {\n    width: number;\n    height: number;\n  };\n};\n\nfunction sameLayout(a: PortalLayout | null, b: PortalLayout) {\n  return (\n    a?.trigger.left === b.trigger.left &&\n    a.trigger.top === b.trigger.top &&\n    a.trigger.width === b.trigger.width &&\n    a.trigger.height === b.trigger.height &&\n    a.content.width === b.content.width &&\n    a.content.height === b.content.height\n  );\n}\n\n/** Measures a trigger and portalled panel in viewport coordinates. */\nexport function usePopoverPortalPosition<\n  TriggerElement extends HTMLElement,\n  ContentElement extends HTMLElement,\n>(\n  triggerRef: MutableRefObject<TriggerElement | null>,\n  contentRef: MutableRefObject<ContentElement | null>,\n  active: boolean,\n) {\n  const [layout, setLayout] = useState<PortalLayout | null>(null);\n\n  const update = useCallback(() => {\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    if (!trigger || !content) return;\n\n    const rect = trigger.getBoundingClientRect();\n    const next: PortalLayout = {\n      trigger: {\n        left: rect.left,\n        top: rect.top,\n        width: rect.width,\n        height: rect.height,\n      },\n      content: {\n        width: content.offsetWidth,\n        height: content.offsetHeight,\n      },\n    };\n    setLayout((current) => (sameLayout(current, next) ? current : next));\n  }, [contentRef, triggerRef]);\n\n  useLayoutEffect(() => {\n    update();\n    if (!active) return;\n\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    const observer = new ResizeObserver(update);\n    if (trigger) observer.observe(trigger);\n    if (content) observer.observe(content);\n\n    window.addEventListener(\"scroll\", update, true);\n    window.addEventListener(\"resize\", update);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"scroll\", update, true);\n      window.removeEventListener(\"resize\", update);\n    };\n  }, [active, contentRef, triggerRef, update]);\n\n  return layout;\n}\n"}]}