{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"activity-light","type":"registry:ui","title":"ActivityLight","description":"Activity on an edge between two parties - recency ramp, direction and an optional count.","package":"react-fancy","dependencies":["tailwind-merge"],"registryDependencies":[],"files":[{"path":"components\/fancy\/activity-light\/ActivityLight.tsx","content":"import { forwardRef } from \"react\";\nimport { cn } from \"..\/lib\/utils\/cn\";\nimport type { ActivityLightProps, ActivityLevel } from \".\/ActivityLight.types\";\n\n\/**\n * Activity on an **edge** \u2014 a relationship between two parties \u2014 with a recency\n * ramp and an optional volume.\n *\n * Not a presence dot. `Avatar status` belongs to one party and says whether\n * *they* are online; this belongs to a pair and says when *the link between\n * them* last moved. Use it in a comms column, a collaborator lane, anywhere the\n * subject is the relationship rather than the participant.\n *\n * ## Two kinds of nothing, and they must not look alike\n *\n * - **`level: null`** \u2014 the edge is real, its recency is not observable. Renders\n *   NOTHING: no dot, no dash, no placeholder. A grey dot would assert \"quiet\",\n *   which is a claim we cannot make.\n * - **`count: null`** or omitted \u2014 the edge's recency is known but its volume is\n *   not. Renders the light with no number.\n * - **`count: 0`** \u2014 measured, and none. Renders `0`, because that is a fact.\n *   Suppressing it would be indistinguishable from not having looked.\n *\n * The distinction reads as pedantic until a count is wrong in the direction\n * that flatters: a `0` where the truth is \"unknown\" tells the reader an agent\n * works alone, and they believe it.\n *\n * @example\n * ```tsx\n * <ActivityLight level=\"live\" direction=\"both\" count={12} label=\"Genie and Tynn\" \/>\n * <ActivityLight level=\"quiet\" count={0} label=\"Genie and Weaver\" \/>\n * <ActivityLight level={null} label=\"Genie and an unknown peer\" \/>\n * ```\n *\/\nexport const ActivityLight = forwardRef<HTMLSpanElement, ActivityLightProps>(\n  ({ level, direction, count = null, label, className, ...props }, ref) => {\n    \/\/ The one early return in the component, and the most important line in it.\n    if (level === null) return null;\n\n    const counted = typeof count === \"number\" && Number.isFinite(count);\n\n    return (\n      <span\n        ref={ref}\n        data-react-fancy-activity-light={level}\n        {...(direction ? { \"data-react-fancy-activity-light-direction\": direction } : {})}\n        aria-label={counted ? `${label}: ${level}, ${count}` : `${label}: ${level}`}\n        role=\"img\"\n        className={cn(\n          \"inline-flex items-center gap-1 align-middle\",\n          LEVEL_CLASS[level],\n          className,\n        )}\n        {...props}\n      >\n        <span\n          data-react-fancy-activity-light-dot=\"\"\n          aria-hidden=\"true\"\n          className={cn(\"size-2 shrink-0 rounded-full bg-current\", level === \"live\" && \"animate-pulse\")}\n        \/>\n\n        {direction && (\n          <span\n            data-react-fancy-activity-light-arrow=\"\"\n            aria-hidden=\"true\"\n            className=\"font-mono text-[0.625rem] leading-none\"\n          >\n            {DIRECTION_GLYPH[direction]}\n          <\/span>\n        )}\n\n        {\/* `counted` is the gate, NOT `count` \u2014 `0` is falsy and would vanish. *\/}\n        {counted && (\n          <span\n            data-react-fancy-activity-light-count=\"\"\n            className=\"font-mono text-xs tabular-nums leading-none\"\n          >\n            {count}\n          <\/span>\n        )}\n      <\/span>\n    );\n  },\n);\n\nActivityLight.displayName = \"ActivityLight\";\n\n\/**\n * The ramp. Each level is visually distinct from every other \u2014 a test asserts\n * that, because two levels that look alike make the ramp a lie.\n *\/\nconst LEVEL_CLASS: Record<ActivityLevel, string> = {\n  live: \"text-emerald-500 dark:text-emerald-400\",\n  recent: \"text-sky-500 dark:text-sky-400\",\n  quiet: \"text-[color:var(--fancy-text-faint,var(--color-zinc-400))] dark:text-[color:var(--fancy-text-faint-dark,var(--color-zinc-500))]\",\n  unseen: \"text-amber-500 dark:text-amber-400\",\n};\n\n\/**\n * The direction marks, CHOSEN FOR FONT COVERAGE as much as for meaning.\n *\n * `both` was U+21C4 (\u21c4), which has no coverage in the Liberation fonts \u2014 so in\n * a container carrying only those, `in` and `out` drew arrows and `both` drew a\n * tofu box. One of three states failing is worse than all three failing,\n * because the reader cannot tell a font gap from a state: they see two working\n * indicators and one they have no reason to distrust.\n *\n * U+2194 (\u2194) is in **WGL4**, the repertoire Liberation targets, and that is the\n * line these three sit on \u2014 U+2190\u20132195 are in it, U+21C4 and the whole\n * Dingbats block are not. MEASURED by `claude \u00b7 tynn` in a Liberation-only\n * container (2026-10-10), rasterising each character and comparing it\n * pixel-for-pixel against a Private Use Area codepoint no font covers: `\u2194`\n * draws, and U+21C6 `\u21c6` \u2014 the near neighbour, included as a falsifier \u2014 does\n * not, which is what makes it evidence of the repertoire rather than of\n * \"Liberation covers arrows\".\n *\n * **So do not \"improve\" `both` back to a prettier arrow.** U+21C4, U+21C6 and\n * the Dingbats (`\u2713` `\u2715` `\u2726`) all need a font many lean images do not ship.\n * A test pins this set.\n *\/\nconst DIRECTION_GLYPH: Record<\"in\" | \"out\" | \"both\", string> = {\n  in: \"\u2190\",\n  out: \"\u2192\",\n  both: \"\u2194\",\n};\n","type":"registry:ui","target":"components\/fancy\/activity-light\/ActivityLight.tsx"},{"path":"components\/fancy\/activity-light\/ActivityLight.types.ts","content":"import type { HTMLAttributes } from \"react\";\n\n\/**\n * How recently an edge carried traffic.\n *\n * `null` is not a level \u2014 it means the edge is real but **undatable**, and it\n * renders nothing at all. A grey dot would be the claim \"this edge is quiet\",\n * which is a different statement from \"we cannot see when it last moved\".\n *\/\nexport type ActivityLevel = \"live\" | \"recent\" | \"quiet\" | \"unseen\";\n\n\/** Which way the traffic went. Absent means undirected, not \"both\". *\/\nexport type ActivityDirection = \"in\" | \"out\" | \"both\";\n\nexport interface ActivityLightProps extends Omit<HTMLAttributes<HTMLSpanElement>, \"children\"> {\n  \/**\n   * The recency of this edge, or `null` for a real edge whose recency cannot be\n   * observed \u2014 which renders NOTHING.\n   *\/\n  level: ActivityLevel | null;\n  \/** Which way the traffic went. Omit when it is not known or not meaningful. *\/\n  direction?: ActivityDirection;\n  \/**\n   * How much traffic. `null` (or omitted) means **not counted** and renders no\n   * number \u2014 never a `0`, because a zero claims the other party worked alone.\n   * A measured `0` IS rendered: \"we looked and it was none\" is a fact.\n   *\/\n  count?: number | null;\n  \/** Accessible name for the edge \u2014 \"Genie and Tynn\". Required. *\/\n  label: string;\n}\n","type":"registry:ui","target":"components\/fancy\/activity-light\/ActivityLight.types.ts"},{"path":"components\/fancy\/activity-light\/index.ts","content":"export { ActivityLight } from \".\/ActivityLight\";\nexport type { ActivityLightProps, ActivityLevel, ActivityDirection } from \".\/ActivityLight.types\";\n","type":"registry:ui","target":"components\/fancy\/activity-light\/index.ts"},{"path":"components\/fancy\/lib\/utils\/cn.ts","content":"import { twMerge } from \"tailwind-merge\";\n\n\/**\n * A class value: a string, a number, a conditional map, an array of any of\n * those, or nothing. Mirrors what `clsx` accepted, because every component in\n * this package already passes these shapes.\n *\/\nexport type ClassValue =\n  | string\n  | number\n  | bigint\n  | null\n  | undefined\n  | false\n  | ClassDictionary\n  | ClassValue[];\n\ntype ClassDictionary = Record<string, unknown>;\n\n\/**\n * Flatten class values into a space-separated string.\n *\n * This was `clsx` until 2026-08-19. It is roughly twenty lines of logic, it\n * shipped as a RUNTIME dependency to every consumer of this package, and its\n * last commit was 801 days old \u2014 so it fell to the rule that third-party code\n * must be both approved and actively maintained. Owning it costs less than\n * carrying it.\n *\n * `tailwind-merge` deliberately stays: deciding which of two conflicting\n * Tailwind utilities wins requires modelling the entire utility space, which is\n * emphatically not twenty lines, and it is actively maintained.\n *\n * Falsy values are dropped \u2014 including `0` and `\"\"`, which is what `clsx` did\n * and what `tests\/cn.test.ts` pins. A truthy number is kept, because digits are\n * legal class names. `bigint` is admitted because clsx's signature did and\n * the compiler proved it reachable from two call sites here.\n *\/\nfunction flatten(value: ClassValue): string {\n  if (!value) return \"\";\n\n  if (typeof value === \"string\" || typeof value === \"number\" || typeof value === \"bigint\") {\n    return String(value);\n  }\n\n  if (Array.isArray(value)) {\n    let out = \"\";\n    for (const item of value) {\n      const part = flatten(item);\n      if (part) out = out ? `${out} ${part}` : part;\n    }\n    return out;\n  }\n\n  let out = \"\";\n  for (const key in value) {\n    if (value[key]) out = out ? `${out} ${key}` : key;\n  }\n  return out;\n}\n\nexport function cn(...inputs: ClassValue[]): string {\n  return twMerge(flatten(inputs));\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/utils\/cn.ts"}]}