# Stat (Lumen Halo): prompt.md (v1.0.0)

- id: `lumen-stat` · version 1.0.0 · component · free
- category: Charts
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/lumen-stat.json`
- npm dependencies: none
- registry dependencies: utils, https://beautiful-ui.dev/r/lumen-foundation.json
- docs: https://beautiful-ui.dev/components/lumen-stat
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

The number, whether it's good news, and where it's heading, in one glance. Digits roll like an odometer, the change takes its tone, and a spark of cells lights its last column to match. The KPI every Lumen chart and dashboard is built on.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: none beyond React.
- Files: `components/ui/lumen/stat.tsx`; shared code: `lib/beautiful-ui/lumen/stat-format.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-foundation`.
- Builds: one set of files for both, but its dependencies come in Base UI and Radix builds. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `Stat`, `StatLabel`, `StatValue`, `StatDelta`, `StatSpark`, `StatNote`, `formatStatValue / statChange / statCells`, and every exported type.
- CSS: the install merges this item's rules (the registry `css` field) into your global stylesheet, in `@layer components`, and adds the lumen foundation (tokens, keyframes, motion levels) once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--lumen-*` custom properties (`--lumen-accent`, `--lumen-accent-text`, `--lumen-bad`, `--lumen-bad-text`, `--lumen-focus`, `--lumen-good`, `--lumen-good-text`, `--lumen-hairline`, `--lumen-ink`, `--lumen-muted-ink`, `--lumen-series-1`, `--lumen-series-2`, `--lumen-series-3`, `--lumen-series-4`, `--lumen-series-5`, `--lumen-series-6`, `--lumen-warn`, `--lumen-warn-text`); this item's CSS also reads `--lumen-font-mono`, `--lumen-font-sans`, `--lumen-radius-k`. Never add Tailwind colour classes inside the component.

```tsx
import { Stat, StatDelta, StatLabel, StatNote, StatSpark, StatValue } from "@/components/ui/lumen/stat";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `Stat` | `stat` | The tile (or a plain group) that carries the tone. |
| `StatLabel` | `stat-label` | The mono eyebrow. |
| `StatValue` | `stat-value` | The number on rolling reels. |
| `StatDelta` | `stat-delta` | The change in its tone, with context. |
| `StatSpark` | `stat-spark` | Twelve columns of cells, the last in the tone. |
| `StatNote` | `stat-note` | Freshness: updated, stale, missing. |
| `formatStatValue / statChange / statCells` | — | The maths, server-safe, for your own parts. |

Style a part with `[data-slot="<slot>"]` selectors or its `className`; keep the attributes when editing.

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/lumen/stat.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- stat, KPI, metric, stat card, number tile, delta, change, sparkline, dashboard number, Lumen
- A KPI row on a dashboard: revenue, active teams, churn, response time
- The number above a chart: variant="plain" inside a Card or a chart header
- Server Components: every part takes plain data and renders without JavaScript

### Not when
- A table of many numbers: use a table
- A number that changes many times a second: the roll is for updates, not tickers

## Mistakes
- Pass percents as fractions (0.018 is 1.8%), as Intl does
- Set goodDirection="down" for churn, latency and cost, or a fall reads red
- Don't format the value yourself and pass a number: pass the number and a format, or the formatted text as children

## Usage

```tsx
import { Stat, StatDelta, StatLabel, StatNote, StatSpark, StatValue } from "@/components/ui/lumen/stat";

<Stat>
  <StatLabel>Revenue</StatLabel>
  <StatValue value={2412000} format={{ style: "currency", currency: "USD" }} />
  <StatDelta value={2412000} previous={2181000}>vs last 30 days</StatDelta>
  <StatSpark data={revenueByWeek} />
  <StatNote>Updated 2 min ago</StatNote>
</Stat>

// A fall is good news: churn, latency, cost.
<StatDelta value={1.2} previous={1.5} format={{ style: "percent" }} goodDirection="down" />
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `Stat variant / size / tone / motion` | `"card" \| "plain" / "sm" \| "default" \| "lg" / StatTone / "full" \| "subtle" \| "off"` | `"card" / "default" / from StatDelta / "full"` | The tile (or none), the scale, the spark and glow tone (default: the StatDelta's), and the motion level. |
| `StatValue value / format` | `number \| null / StatFormat` |  | Formats with Intl: { style: "currency" \| "number" \| "percent" \| "unit" \| "duration", currency, unit, notation, maximumFractionDigits, locale }. null shows "—". Or pass preformatted text as children. |
| `StatDelta value / previous / goodDirection` | `number / number / "up" \| "down"` | `— / — / "up"` | The change and its tone: relative for counts and money, points for percents, the difference for durations. "down" when a fall is good (churn, latency). |
| `StatDelta mode / change / children` | `"relative" \| "absolute" / string / ReactNode` |  | Absolute change (+$231K); a preformatted change; context after it ("vs last 30 days"). |
| `StatSpark data / rows / columns` | `(number \| null)[] / number / number` | `— / 5 / 12` | Oldest first; the last point is now. Nulls are gaps. Give it an aria-label to be announced. |
| `StatNote stale` | `boolean` |  | Amber with a dot: the data is old or missing. |

Full docs: https://beautiful-ui.dev/components/lumen-stat

## Customising
- Colours: set the `--lumen-*` tokens on `:root`, or on a container with the `lumen-scope` class to retheme one area. Add the `lumen-inherit` class to follow your shadcn palette instead (`--chart-N`, `--destructive`).
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| — | Not focusable; wrap it in a link or button to make it one |

## Motion inventory

| Interaction | What moves |
|---|---|
| Value change | Each digit rolls on its reel to the new value (.9s spring, 35ms apart from the right); never counts up from zero |
| First paint | The spark's cells rise column by column |
| Tone change | The glow, the change and the last column fade to the new tone |

## Accessibility contract (preserve when editing)
- The value is read as text ("$2.41M"); the rolling reels are hidden from screen readers
- The change says its direction and verdict: "Up +10.6%, on track"; tone is never colour alone
- The spark is decoration unless you give it an aria-label
- Reduced motion: the digits and cells change in place

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/lumen-stat.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/lumen-stat.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-stat`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/lumen/stat.tsx

```tsx
/**
 * Stat (Lumen Halo) v1.0.0 · Beautiful UI
 * Docs: https://beautiful-ui.dev/components/lumen-stat · Agent prompt: https://beautiful-ui.dev/md/lumen-stat.md
 * Licensed to the purchaser under the Beautiful UI license: https://beautiful-ui.dev/license
 */
import type * as React from "react";
import { cn } from "@/lib/utils";
import { formatStatValue, statCells, statChange, statDigits, type StatFormat, type StatTone } from "@/lib/beautiful-ui/lumen/stat-format";

/*
 * Lumen Stat. The KPI in parts: a label, a value whose digits roll like an odometer when it changes,
 * the change in its tone, and a 12-column spark of cells whose last column lights in that tone.
 * Every part is server-safe and takes plain data, so a Server Component can render a whole row of
 * KPIs; the motion is CSS transitions only, so nothing re-renders per frame.
 */

type Motion = "full" | "subtle" | "off";

function Stat({
  className,
  variant = "card",
  size = "default",
  tone,
  motion,
  ...props
}: React.ComponentProps<"div"> & {
  /** "card": its own glass tile (default). "plain": no tile, for a Card or a chart header. */
  variant?: "card" | "plain" | undefined;
  size?: "sm" | "default" | "lg" | undefined;
  /** The spark and glow tone. Default: the tone of the StatDelta inside. */
  tone?: StatTone | undefined;
  /** "full" (default) · "subtle" · "off". Reduced motion always wins. */
  motion?: Motion | undefined;
}) {
  return <div data-slot="stat" data-variant={variant} data-size={size} data-tone={tone} data-motion={motion} className={cn(`lumen-stat`, className)} {...props} />;
}

function StatLabel({ className, ...props }: React.ComponentProps<"div">) {
  return <div data-slot="stat-label" className={cn(`lumen-stat-label`, className)} {...props} />;
}

/** One-line windows over 0–9 reels (in the locale's digits); keyed from the right, so 999 → 1,000 rolls the digits that stay. */
function Roll({ text, digits }: { text: string; digits: string[] }) {
  const chars = Array.from(text);
  return (
    <span aria-hidden className={`lumen-stat-roll`}>
      {chars.map((ch, i) => {
        const pos = chars.length - i;
        const d = digits.indexOf(ch);
        const n = d >= 0 ? d : /[0-9]/.test(ch) ? Number(ch) : -1;
        if (n < 0)
          return (
            <span key={`c${pos}`} className={`lumen-stat-char`}>
              {ch}
            </span>
          );
        return (
          <span key={`d${pos}`} className={`lumen-stat-digit`} style={{ "--d": n, "--i": pos - 1 } as React.CSSProperties}>
            <span className={`lumen-stat-reel`}>
              {(d >= 0 ? digits : "0123456789".split("")).map((g, j) => (
                <span key={j}>{g}</span>
              ))}
            </span>
          </span>
        );
      })}
    </span>
  );
}

function StatValue({
  className,
  value,
  format,
  children,
  ...props
}: Omit<React.ComponentProps<"div">, "children"> & {
  /** null or undefined shows "—". */
  value?: number | null | undefined;
  format?: StatFormat | undefined;
  /** Preformatted text instead of value and format (it still rolls). */
  children?: string | undefined;
}) {
  const text = children ?? formatStatValue(value, format);
  return (
    <div data-slot="stat-value" data-empty={text === "—" ? "" : undefined} className={cn(`lumen-stat-value`, className)} {...props}>
      <span className="sr-only">{text === "—" ? "No value" : text}</span>
      <Roll text={text} digits={statDigits(format?.locale)} />
    </div>
  );
}

const ARROW: Record<string, string> = { up: "M5 8.5V1.5M1.8 4.7 5 1.5l3.2 3.2", down: "M5 1.5v7M1.8 5.3 5 8.5l3.2-3.2", flat: "M1.5 5h7" };

export type StatDeltaLabels = { up?: string | undefined; down?: string | undefined; flat?: string | undefined; good?: string | undefined; bad?: string | undefined };
const DELTA_LABELS: Required<StatDeltaLabels> = { up: "Up", down: "Down", flat: "No change", good: "on track", bad: "needs a look" };

function StatDelta({
  className,
  value,
  previous,
  format,
  goodDirection = "up",
  mode,
  tone,
  change,
  labels,
  children,
  ...props
}: React.ComponentProps<"div"> & {
  value?: number | null | undefined;
  previous?: number | null | undefined;
  /** The value's format: percents change in points, durations by the difference. */
  format?: StatFormat | undefined;
  /** "down" for churn, latency, cost: a fall reads good. Default "up". */
  goodDirection?: "up" | "down" | undefined;
  /** "relative" (default: +10.6%) or "absolute" (+$231K). */
  mode?: "relative" | "absolute" | undefined;
  /** Overrides the computed tone. */
  tone?: StatTone | undefined;
  /** Preformatted change text ("+12 seats") instead of value and previous. */
  change?: string | undefined;
  /** Screen-reader words, for i18n. */
  labels?: StatDeltaLabels | undefined;
  /** Context after the change, e.g. "vs last 30 days". */
  children?: React.ReactNode;
}) {
  const c = statChange(value, previous, { format, goodDirection, mode });
  const text = change ?? c.text;
  const t = tone ?? c.tone;
  // A preformatted change points by its sign.
  const dir = change != null && c.direction === "none" ? (/^\s*\+/.test(change) ? "up" : /^\s*[-−]/.test(change) ? "down" : t === "flat" ? "flat" : "none") : c.direction;
  const L = { ...DELTA_LABELS, ...labels };
  const spoken = [dir === "up" ? L.up : dir === "down" ? L.down : dir === "flat" ? L.flat : "", t === "good" ? L.good : t === "bad" ? L.bad : ""].filter(Boolean);
  return (
    <div data-slot="stat-delta" data-tone={t} data-direction={dir} className={cn(`lumen-stat-delta`, className)} {...props}>
      <span className={`lumen-stat-delta-change`}>
        {ARROW[dir] && (
          <svg aria-hidden viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
            <path d={ARROW[dir]} />
          </svg>
        )}
        {spoken.length > 0 && <span className="sr-only">{`${spoken[0]} `}</span>}
        {text}
        {spoken.length > 1 && <span className="sr-only">{`, ${spoken[1]}`}</span>}
      </span>
      {children != null && <span className={`lumen-stat-delta-context`}>{children}</span>}
    </div>
  );
}

function StatSpark({
  className,
  data,
  rows = 5,
  columns = 12,
  tone,
  style,
  ...props
}: React.ComponentProps<"div"> & {
  /** Oldest first; the last point is now. Longer series keep the last `columns`; nulls are gaps. */
  data?: readonly (number | null | undefined)[] | undefined;
  /** Cells per column. Default 5. */
  rows?: number | undefined;
  /** Default 12. */
  columns?: number | undefined;
  /** Standalone: the last column's tone. Inside a Stat it follows the Stat. */
  tone?: StatTone | undefined;
}) {
  const cells = statCells(data, rows, columns);
  // Nothing to draw: one row, the ink ramp, so the tile keeps its shape.
  const n = cells.length ? rows : 1;
  const lit = cells.length ? cells : Array<number>(columns).fill(1);
  const labelled = props["aria-label"] != null || props["aria-labelledby"] != null;
  return (
    <div
      data-slot="stat-spark"
      data-tone={tone}
      data-empty={cells.length ? undefined : ""}
      role={labelled ? "img" : undefined}
      aria-hidden={labelled ? undefined : true}
      className={cn(`lumen-stat-spark`, className)}
      style={{ "--cols": columns, ...style } as React.CSSProperties}
      {...props}
    >
      {lit.map((on, c) => (
        <span key={c} className={`lumen-stat-spark-col`} style={{ "--c": c } as React.CSSProperties}>
          {Array.from({ length: n }, (_, r) => (
            <span key={r} className={`lumen-stat-spark-cell`} data-on={r < on ? "" : undefined} style={{ "--r": r } as React.CSSProperties} />
          ))}
        </span>
      ))}
    </div>
  );
}

function StatNote({ className, stale, ...props }: React.ComponentProps<"div"> & { /** Amber, with a dot: the data is old or missing. */ stale?: boolean | undefined }) {
  return <div data-slot="stat-note" data-stale={stale ? "" : undefined} className={cn(`lumen-stat-note`, className)} {...props} />;
}

export { Stat, StatLabel, StatValue, StatDelta, StatSpark, StatNote };
export { formatStatValue, statChange, statCells, statDigits, type StatFormat, type StatTone };
```

### lib/beautiful-ui/lumen/stat-format.ts

```tsx
/*
 * Lumen Stat's maths: number formats, the change and its tone, and the spark's cells. Plain,
 * serialisable inputs and no React, so a Server Component can format a KPI and pass the result on.
 */

/** How a value is written. Plain data, so it crosses the server/client boundary. */
export type StatFormat = {
  /**
   * "number" (default) · "currency" · "percent" (a fraction: 0.018 → "1.8%") · "unit" (with
   * `unit`, e.g. "millisecond") · "duration" (seconds: 252 → "4m 12s").
   */
  style?: "number" | "currency" | "percent" | "unit" | "duration" | undefined;
  /** ISO code for "currency". Default "USD". */
  currency?: string | undefined;
  /** An Intl unit for "unit", e.g. "millisecond", "gigabyte". */
  unit?: string | undefined;
  /** How a unit reads: "short" (default: "12 mths"), "long" ("12 months") or "narrow" ("12m"). */
  unitDisplay?: "short" | "long" | "narrow" | undefined;
  /** "compact" (default for currency and numbers of 10,000 and up: "$2.41M", "18.2K") or "standard". */
  notation?: "compact" | "standard" | undefined;
  minimumFractionDigits?: number | undefined;
  maximumFractionDigits?: number | undefined;
  /** Default "en-US". */
  locale?: string | undefined;
};

/** good / bad from the direction of the change and whether up is good; flat when it rounds to zero; none without a comparison. */
export type StatTone = "good" | "bad" | "flat" | "none";

const MINUS = "−";
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);

function durationText(sec: number) {
  const s = Math.round(sec);
  if (s < 60) return `${s}s`;
  const m = Math.floor(s / 60);
  if (m < 60) return `${m}m ${s % 60}s`;
  const h = Math.floor(m / 60);
  if (h < 24) return `${h}h ${m % 60}m`;
  return `${Math.floor(h / 24)}d ${h % 24}h`;
}

const clampDigits = (n: number | undefined) => (n == null || !Number.isFinite(n) ? undefined : Math.min(20, Math.max(0, Math.round(n))));

function intlOptions(abs: number, f: StatFormat): Intl.NumberFormatOptions {
  const style = f.style ?? "number";
  const compact = f.notation ? f.notation === "compact" : style === "currency" ? abs >= 1000 : style === "number" && abs >= 10000;
  const opts: Intl.NumberFormatOptions = { style: style === "duration" || style === "number" ? "decimal" : style, notation: compact ? "compact" : "standard" };
  if (style === "currency") {
    opts.currency = f.currency ?? "USD";
    opts.currencyDisplay = "narrowSymbol";
  }
  if (style === "unit") {
    opts.unit = f.unit ?? "percent";
    if (f.unitDisplay) opts.unitDisplay = f.unitDisplay;
  }
  const min = clampDigits(f.minimumFractionDigits);
  const maxSet = clampDigits(f.maximumFractionDigits);
  // The design's precision: compact figures keep three significant digits ("$2.41M", "$612K", "18.2K").
  if (compact && min == null && maxSet == null) opts.maximumSignificantDigits = 3;
  else {
    const max = Math.max(maxSet ?? (style === "percent" ? 1 : style === "currency" ? (abs < 100 ? 2 : 0) : abs < 10 ? 2 : abs < 100 ? 1 : 0), min ?? 0);
    opts.maximumFractionDigits = max;
    opts.minimumFractionDigits = min ?? 0;
  }
  return opts;
}

function intl(abs: number, f: StatFormat, latin = false) {
  const opts = intlOptions(abs, f);
  if (latin) opts.numberingSystem = "latn";
  try {
    return new Intl.NumberFormat(f.locale ?? "en-US", opts).format(abs);
  } catch {
    // A bad currency, unit or locale never throws in render: the plain number instead.
    return new Intl.NumberFormat("en-US", { maximumFractionDigits: opts.maximumFractionDigits ?? 2 }).format(abs);
  }
}

/** Whether a magnitude shows as zero in this format (checked in Latin digits, whatever the locale's). */
const showsZero = (abs: number, f: StatFormat) => !/[1-9]/.test(f.style === "duration" ? durationText(abs) : intl(abs, f, true));

/** "$2.41M", "8,940", "1.8%", "4m 12s"; "—" when there is no value; a real minus for negatives, never "−0". */
export function formatStatValue(value: number | null | undefined, format: StatFormat = {}) {
  if (!isNum(value)) return "—";
  const abs = Math.abs(value);
  const s = format.style === "duration" ? durationText(abs) : intl(abs, format);
  return value < 0 && !showsZero(abs, format) ? MINUS + s : s;
}

/** The locale's ten digits, 0 to 9 ("٠…٩" for ar-EG), so rolling reels show the same digits as the text. */
export function statDigits(locale?: string): string[] {
  try {
    const nf = new Intl.NumberFormat(locale ?? "en-US", { useGrouping: false });
    const d = Array.from({ length: 10 }, (_, i) => nf.format(i));
    return d.every((x) => Array.from(x).length === 1) ? d : "0123456789".split("");
  } catch {
    return "0123456789".split("");
  }
}

export type StatChange = {
  tone: StatTone;
  /** "+10.6%", "−0.3 pts", "−1m 6s", "+$231K"; "—" or "New" without a comparison. */
  text: string;
  /** "up" · "down" · "flat" · "none". */
  direction: "up" | "down" | "flat" | "none";
  /** value − previous (0 without both). */
  difference: number;
};

const num = (v: number, locale: string | undefined, digits: number) => {
  try {
    return new Intl.NumberFormat(locale ?? "en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(v);
  } catch {
    return v.toFixed(digits);
  }
};

/**
 * The change and its tone. Percents change in points, durations by the difference, everything
 * else relatively (by the difference when the previous value is 0). Under 0.05% is flat. Numbers
 * follow the format's locale.
 */
export function statChange(value: number | null | undefined, previous: number | null | undefined, options: { format?: StatFormat | undefined; goodDirection?: "up" | "down" | undefined; mode?: "relative" | "absolute" | undefined } = {}): StatChange {
  const f = options.format ?? {};
  if (!isNum(value)) return { tone: "none", text: "—", direction: "none", difference: 0 };
  if (!isNum(previous)) return { tone: "none", text: "New", direction: "none", difference: 0 };
  const d = value - previous;
  if (!Number.isFinite(d)) return { tone: "none", text: "—", direction: "none", difference: 0 };
  const pct = previous !== 0 ? (d / Math.abs(previous)) * 100 : null;
  const style = f.style ?? "number";
  const relative = (options.mode ?? "relative") === "relative" && pct != null && style !== "percent" && style !== "duration";
  const ad = Math.abs(d);
  const pts = ad * 100;
  // No change, in the change's own unit: "0%", "0 pts", "0s", "$0".
  const flatText = relative ? `${num(0, f.locale, 0)}%` : style === "percent" ? `${num(0, f.locale, 0)} pts` : formatStatValue(0, f);
  const flat: StatChange = { tone: "flat", text: flatText, direction: "flat", difference: d };
  if (relative ? Math.abs(pct!) < 0.05 : style === "percent" ? pts < 0.005 : d === 0 || showsZero(ad, f)) return flat;
  let core: string;
  if (relative) {
    const p = Math.abs(pct!);
    core = `${num(p, f.locale, p >= 1000 ? 0 : 1)}%`;
  } else if (style === "percent") core = `${num(pts, f.locale, pts < 0.1 ? 2 : 1)} pts`;
  else core = formatStatValue(ad, f);
  const up = d > 0;
  const good = options.goodDirection === "down" ? !up : up;
  return { tone: good ? "good" : "bad", text: (up ? "+" : MINUS) + core, direction: up ? "up" : "down", difference: d };
}

/**
 * The spark's columns: the last `columns` points, padded on the left with gaps, each as a count of
 * lit cells (1…rows, the lowest always 1; 0 for a gap). Empty when there is nothing to draw.
 */
export function statCells(data: readonly (number | null | undefined)[] | undefined, rows = 5, columns = 12): number[] {
  if (!Array.isArray(data)) return [];
  const t = data.slice(-columns).map((v) => (isNum(v) ? v : null));
  const fin = t.filter((v): v is number => v != null);
  if (!fin.length) return [];
  const mn = Math.min(...fin);
  const mx = Math.max(...fin);
  const cells = t.map((v) => (v == null ? 0 : 1 + Math.round(((v - mn) / (mx - mn || 1)) * (rows - 1))));
  return [...Array<number>(columns - cells.length).fill(0), ...cells];
}
```

### CSS (the registry `css` / `cssVars`, merged into the global stylesheet by the shadcn CLI)

```css
@layer components {
  @keyframes lumen-stat-rise {
    from {
      opacity: 0;
      transform: translateY(3px);
    }
  }
  .lumen-stat {
    --st-fs: 30px;
    --st-pad: 20px;
    --st-tone: var(--gc-m36);
    position: relative;
    display: flex;
    flex-direction: column;
    gap: 10px;
    box-sizing: border-box;
    min-width: 0;
    color: var(--gc-ink);
    font: 400 13px/1.45 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    isolation: isolate;
  }
  .lumen-stat[data-size="sm"] {
    --st-fs: 20px;
    --st-pad: 14px;
    gap: 6px;
  }
  .lumen-stat[data-size="lg"] {
    --st-fs: clamp(36px,11vw,52px);
    --st-pad: 24px;
    gap: 12px;
  }
  .lumen-stat[data-motion="subtle"] {
    --gcp-k: .7;
  }
  .lumen-stat[data-motion="off"] {
    --gcp-k: 0;
    --gcp-cm: 0s;
  }
  @media (prefers-reduced-motion: reduce) {
    .lumen-stat:is(*,[data-motion]) {
      --gcp-k: 0;
      --gcp-cm: .15s;
    }
  }
  .lumen-stat[data-variant="card"] {
    padding: var(--st-pad);
    border-radius: calc(18px * var(--lumen-radius-k, 1));
    background: var(--gc-glassB);
    box-shadow: inset 0 0 0 1px var(--gc-glassR),var(--gc-glassX);
    overflow: hidden;
  }
  .lumen-stat[data-variant="card"][data-size="sm"] {
    border-radius: calc(14px * var(--lumen-radius-k, 1));
  }
  .lumen-stat[data-variant="card"]::before {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    top: -70px;
    height: 130px;
    z-index: -1;
    background: radial-gradient(50% 50% at 50% 50%,color-mix(in srgb,var(--st-tone) 16%,transparent),transparent 70%);
    pointer-events: none;
    transition: background min(0.8s, var(--gcp-cm)) ease;
  }
  .lumen-stat[data-tone="good"],.lumen-stat:not([data-tone]):has(.lumen-stat-delta[data-tone="good"]),.lumen-stat-spark[data-tone="good"] {
    --st-tone: var(--gc-good);
  }
  .lumen-stat[data-tone="bad"],.lumen-stat:not([data-tone]):has(.lumen-stat-delta[data-tone="bad"]),.lumen-stat-spark[data-tone="bad"] {
    --st-tone: var(--gc-bad);
  }
  .lumen-stat[data-tone="flat"],.lumen-stat:not([data-tone]):has(.lumen-stat-delta[data-tone="flat"]),.lumen-stat-spark[data-tone="flat"] {
    --st-tone: var(--gc-m50);
  }
  .lumen-stat[data-tone="none"],.lumen-stat:not([data-tone]):has(.lumen-stat-delta[data-tone="none"]),.lumen-stat-spark[data-tone="none"] {
    --st-tone: var(--gc-m36);
  }
  .lumen-stat-label {
    font: 500 11px/1.3 var(--lumen-font-mono, var(--font-mono, var(--font-geist-mono, 'Geist Mono'))), ui-monospace, monospace;
    letter-spacing: .08em;
    text-transform: uppercase;
    color: var(--gc-sec);
  }
  .lumen-stat-value {
    display: flex;
    align-items: baseline;
    max-width: 100%;
    overflow: hidden;
    font: 600 var(--st-fs)/1.1 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    letter-spacing: -.03em;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    color: var(--gc-ink);
  }
  .lumen-stat-value[data-empty] {
    color: var(--gc-m45);
  }
  .lumen-stat-roll {
    display: inline-flex;
  }
  .lumen-stat-char {
    display: inline-block;
    height: 1.1em;
    white-space: pre;
  }
  .lumen-stat-digit {
    display: inline-block;
    height: 1.1em;
    overflow: hidden;
    -webkit-mask-image: linear-gradient(transparent,#000 7%,#000 93%,transparent);
    mask-image: linear-gradient(transparent,#000 7%,#000 93%,transparent);
  }
  .lumen-stat-reel {
    display: flex;
    flex-direction: column;
    transform: translateY(calc(var(--d,0) * -1.1em));
    transition: transform calc(.9s * var(--gcp-k)) var(--gc-sp) calc(var(--i,0) * 35ms * var(--gcp-k));
  }
  .lumen-stat-reel>span {
    display: block;
    height: 1.1em;
    text-align: center;
  }
  .lumen-stat-delta {
    --sd-c: var(--gc-sec);
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 4px 8px;
    font: 400 12.5px/1.3 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    color: var(--gc-sec);
  }
  .lumen-stat-delta[data-tone="good"] {
    --sd-c: var(--gc-good-ui);
  }
  .lumen-stat-delta[data-tone="bad"] {
    --sd-c: var(--gc-bad-ui);
  }
  .lumen-stat-delta[data-tone="flat"] {
    --sd-c: var(--gc-sec);
  }
  .lumen-stat-delta[data-tone="none"] {
    --sd-c: var(--gc-sec);
  }
  .lumen-stat-delta-change {
    display: inline-flex;
    align-items: center;
    gap: 3px;
    padding: 2px 6px;
    border-radius: calc(6px * var(--lumen-radius-k, 1));
    background: color-mix(in srgb,var(--sd-c) 12%,transparent);
    color: var(--sd-c);
    font: 500 12px/1.3 var(--lumen-font-mono, var(--font-mono, var(--font-geist-mono, 'Geist Mono'))), ui-monospace, monospace;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    transition: color min(0.5s, var(--gcp-cm)) ease,background min(0.5s, var(--gcp-cm)) ease;
  }
  .lumen-stat-delta-change>svg {
    width: 10px;
    height: 10px;
    flex: none;
  }
  .lumen-stat-delta[data-tone="none"] .lumen-stat-delta-change {
    padding: 0;
    background: none;
  }
  .lumen-stat-delta-context {
    color: var(--gc-sec);
  }
  .lumen-stat-spark {
    --st-cell: 4px;
    display: grid;
    grid-template-columns: repeat(var(--cols,12),minmax(0,1fr));
    align-items: end;
    gap: 2px;
    width: 100%;
  }
  .lumen-stat[data-size="lg"] .lumen-stat-spark {
    --st-cell: 6px;
    gap: 3px;
  }
  .lumen-stat-spark-col {
    display: flex;
    flex-direction: column-reverse;
    gap: 2px;
  }
  .lumen-stat-spark-cell {
    height: var(--st-cell);
    border-radius: calc(1.5px * var(--lumen-radius-k, 1));
    background: color-mix(in srgb,var(--gc-ink) calc(14% + var(--c,0) * 1.8%),transparent);
    opacity: 0;
    transition: background min(0.5s, var(--gcp-cm)) ease;
  }
  .lumen-stat-spark-cell[data-on] {
    opacity: 1;
    animation: lumen-stat-rise calc(.3s * var(--gcp-k)) ease both;
    animation-delay: calc((var(--c,0) * 30ms + var(--r,0) * 20ms) * var(--gcp-k));
  }
  .lumen-stat-spark-col:last-child>.lumen-stat-spark-cell {
    background: var(--st-tone,var(--gc-m36));
  }
  .lumen-stat:is([data-tone="good"],[data-tone="bad"],:not([data-tone]):has(.lumen-stat-delta:is([data-tone="good"],[data-tone="bad"]))) .lumen-stat-spark-col:last-child>[data-on],.lumen-stat-spark:is([data-tone="good"],[data-tone="bad"]) .lumen-stat-spark-col:last-child>[data-on] {
    box-shadow: 0 0 8px color-mix(in srgb,var(--st-tone) 45%,transparent);
  }
  .lumen-stat-note {
    display: flex;
    align-items: center;
    gap: 6px;
    font: 400 12px/1.4 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    color: var(--gc-sec);
  }
  .lumen-stat-note[data-stale] {
    color: var(--gc-warn-ui);
  }
  .lumen-stat-note[data-stale]::before {
    content: "";
    width: 5px;
    height: 5px;
    border-radius: calc(1px * var(--lumen-radius-k, 1));
    background: var(--gc-warn);
    flex: none;
  }
}
```

The lumen foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://beautiful-ui.dev/r/lumen-foundation.json.
