# Noise Gate: Before and After Section for React

> A before-and-after section. Alerts, pings and complaints drift into the seam between two panels and slide under the calm one, and each card that goes through lights the seam and ticks a counter, or lands as a tidy line in a log.

Before and After Section for React: noisy alerts drift under a calm panel. Built with Tailwind CSS and Motion; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/noise-gate
- Tags: Section, Comparison, Counter, Marketing
- npm dependencies: motion, clsx, tailwind-merge
- Inspired by: [Cloudflare](https://www.cloudflare.com/)

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/noise-gate.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/noise-gate
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/noise-gate.json
```

## Usage

The main demo shows typical usage.

### Support Inbox

A help desk's pitch. Overdue tickets, angry follow-ups and a refund that fired three times drift into the seam, and every one that goes through ticks Harbor's resolved count. Point at the noise to stop it and read a card.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/noise-gate-demo
```

```tsx title="components/noise-gate-demo.tsx"
import { NoiseGate, type NoiseGateItem } from "@/components/ui/noise-gate";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
      <path d={d} />
    </svg>
  );
}

function Avatar({ initials, className }: { initials: string; className: string }) {
  return (
    <span className={`grid size-5 place-items-center rounded-full text-[9px] font-semibold text-white ${className}`}>
      {initials}
    </span>
  );
}

const clock = "M8 4.5V8l2.5 1.5M14 8A6 6 0 1 1 2 8a6 6 0 0 1 12 0Z";
const queue = "M2.5 4h11M2.5 8h11M2.5 12h6";
const trendDown = "M2 4.5l4.5 4.5 2.5-2.5L14 11.5M14 8v3.5h-3.5";
const reply = "M6 4 2.5 7.5 6 11M2.5 7.5H10a3.5 3.5 0 0 1 3.5 3.5v1";

const tickets: NoiseGateItem[] = [
  {
    tag: "SLA",
    tone: "critical",
    icon: <Icon d={clock} />,
    title: "Order #48213 hasn't moved in 9 days",
    detail: "First reply overdue by 3h 12m",
  },
  {
    icon: <Avatar initials="MK" className="bg-violet-500" />,
    title: "“Is anyone actually reading these?”",
    detail: "Maya K. · 4th follow-up",
  },
  { tag: "Queue", tone: "warning", icon: <Icon d={queue} />, title: "214 chats waiting", detail: "Longest wait 38 min" },
  {
    tag: "Refund",
    tone: "critical",
    repeat: 3,
    title: "Refund still not processed",
    detail: "Customer wrote 3 times in 10 min",
  },
  { icon: <Icon d={reply} />, title: "“Sorry for the delay” sent 1,980×", detail: "Most used macro this week" },
  { tag: "Escalated", tone: "warning", title: "Chargeback threatened on #47790", detail: "Assigned to nobody" },
  {
    icon: <Avatar initials="JD" className="bg-sky-500" />,
    title: "“Fine, I'll just cancel then.”",
    detail: "Jordan D. · live chat",
  },
  {
    tag: "CSAT",
    tone: "critical",
    icon: <Icon d={trendDown} />,
    title: "Satisfaction down to 61%",
    detail: "14 points lower than last month",
  },
  { mono: true, title: "urgent, URGENT, urgent!!", detail: "Three tags, one meaning" },
  { tag: "Handoff", tone: "warning", title: "Night shift notes missing", detail: "92 tickets carried over" },
];

export function NoiseGateDemo() {
  return (
    <NoiseGate
      className="max-w-5xl"
      items={tickets}
      noiseTitle={
        <>
          Answering tickets with <span className="text-zinc-500 dark:text-zinc-400">“canned replies”</span>
        </>
      }
      calmTitle="Answering with Harbor"
      count={1284}
      countPrefix="Resolved"
      countSuffix="tickets today"
    />
  );
}
```

## Examples

Finished sections built with Noise Gate. Each one installs whole, with the component included.

### Month-End Close

The log display. Missing receipts, a duplicate AWS charge and a spreadsheet four people are editing go through the gate and come out as reconciled lines with their amounts, newest on top.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/noise-gate-ledger-demo
```

```tsx title="components/noise-gate-ledger-demo.tsx"
import { NoiseGate, type NoiseGateItem } from "@/components/ui/noise-gate";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
      <path d={d} />
    </svg>
  );
}

const receipt = "M4 2h8v12l-2-1.25L8 14l-2-1.25L4 14V2ZM6 5.5h4M6 8.5h4";
const copy = "M5.5 5.5V3a1 1 0 0 1 1-1H13a1 1 0 0 1 1 1v6.5a1 1 0 0 1-1 1h-2.5M3 5.5h6.5a1 1 0 0 1 1 1V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V6.5a1 1 0 0 1 1-1Z";
const file = "M9 2H4.5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V5.5L9 2ZM9 2v3.5h3.5";
const bank = "M2 6h12L8 2.5 2 6ZM3.5 6v5.5M6.5 6v5.5M9.5 6v5.5M12.5 6v5.5M2 13.5h12";

const close: NoiseGateItem[] = [
  {
    tag: "Missing",
    tone: "critical",
    icon: <Icon d={receipt} />,
    title: "No receipt for Uber",
    detail: "$23.40 · Oct 3",
    resolved: "Uber receipt matched",
    resolvedDetail: "$23.40",
  },
  {
    tag: "Duplicate?",
    tone: "warning",
    icon: <Icon d={copy} />,
    title: "AWS charged twice",
    detail: "$1,204.88 · Oct 1",
    resolved: "AWS duplicate refunded",
    resolvedDetail: "$1,204.88",
  },
  {
    tag: "Overdue",
    tone: "critical",
    repeat: 3,
    title: "Invoice #2291 unpaid",
    detail: "Northwind · 14 days late",
    resolved: "Invoice #2291 collected",
    resolvedDetail: "$8,400.00",
  },
  {
    mono: true,
    icon: <Icon d={file} />,
    title: "Q3_recon_v7_FINAL(2).xlsx",
    detail: "Edited by 4 people today",
    resolved: "Q3 reconciled",
    resolvedDetail: "1,284 lines",
  },
  {
    icon: <span className="grid size-5 place-items-center rounded-full bg-teal-600 text-[9px] font-semibold text-white">AL</span>,
    title: "“Why is cash off by $0.03?”",
    detail: "Ana L. · #finance",
    resolved: "Rounding difference booked",
    resolvedDetail: "$0.03",
  },
  {
    tag: "Unmatched",
    tone: "warning",
    icon: <Icon d={bank} />,
    title: "312 bank lines uncategorized",
    detail: "Operating account ••4410",
    resolved: "Bank feed categorized",
    resolvedDetail: "312 lines",
  },
  {
    tag: "Accrual",
    tone: "critical",
    title: "Payroll accrual not booked",
    detail: "Due before close",
    resolved: "Payroll accrued",
    resolvedDetail: "$64,210.00",
  },
  {
    title: "Which FX rate did we use?",
    detail: "EUR invoices · 9 open",
    resolved: "FX revalued",
    resolvedDetail: "9 invoices",
  },
];

export function NoiseGateLedgerDemo() {
  return (
    <NoiseGate
      className="max-w-5xl"
      display="log"
      items={close}
      noiseTitle={
        <>
          Closing the books with <span className="text-zinc-500 dark:text-zinc-400">“one more spreadsheet”</span>
        </>
      }
      calmTitle="Closing with Ledgerline"
      calmClassName="bg-[#0b6b4f] text-white"
      calmFooter={<p className="text-sm text-white/85">October closed on the 2nd.</p>}
    />
  );
}
```

### Focus Section

A whole landing section: heading, subhead, and a calmer, slower gate with a call to action pinned under the counter. The calm panel is ink in light mode and turns white in dark mode.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/noise-gate-focus-demo
```

```tsx title="components/noise-gate-focus-demo.tsx"
import { NoiseGate, type NoiseGateItem } from "@/components/ui/noise-gate";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
      <path d={d} />
    </svg>
  );
}

function Avatar({ initials, className }: { initials: string; className: string }) {
  return (
    <span className={`grid size-5 place-items-center rounded-full text-[9px] font-semibold text-white ${className}`}>
      {initials}
    </span>
  );
}

const hash = "M6 2.5 4.5 13.5M11.5 2.5 10 13.5M2.5 6h11M2 10h11";
const calendar = "M2.5 4.5a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-8ZM2.5 7h11M5.5 2v2.5M10.5 2v2.5";
const bell = "M4 11V7a4 4 0 0 1 8 0v4l1 1.5H3L4 11ZM6.5 14h3";
const mail = "M2.5 4.5h11v7a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-7ZM2.5 4.5 8 9l5.5-4.5";

const pings: NoiseGateItem[] = [
  { icon: <Icon d={hash} />, title: "@here standup moved to 9:15 (again)", detail: "#general · 214 people" },
  {
    tag: "DM",
    tone: "warning",
    icon: <Avatar initials="PS" className="bg-fuchsia-500" />,
    title: "quick q, got 5 min?",
    detail: "Priya · just now",
  },
  {
    tag: "Conflict",
    tone: "critical",
    icon: <Icon d={calendar} />,
    title: "3 meetings at 2:00 PM",
    detail: "Thursday · you accepted all three",
  },
  {
    icon: <Avatar initials="SR" className="bg-orange-500" />,
    title: "“Can you hop on a call real quick?”",
    detail: "Sam R. · huddle",
  },
  { icon: <Icon d={bell} />, title: "48 unread in #design-feedback", detail: "Since you looked away" },
  {
    tag: "Mention",
    tone: "warning",
    repeat: 3,
    title: "You were mentioned in #launch",
    detail: "Three threads, same question",
  },
  { icon: <Icon d={mail} />, title: "Re: Re: Fwd: Re: Q4 offsite", detail: "17 replies" },
  { tag: "Reminder", tone: "critical", title: "Reply to Dana's thread", detail: "Snoozed 4 times" },
  { icon: <Icon d={calendar} />, title: "This week is 86% meetings", detail: "Up from 71%" },
];

export function NoiseGateFocusDemo() {
  return (
    <section className="w-full max-w-5xl">
      <div className="mx-auto max-w-xl text-center">
        <h2 className="text-[clamp(2rem,5vw,3rem)] leading-[1.02] font-semibold tracking-[-0.04em] text-balance text-zinc-950 dark:text-zinc-50">
          Your attention, back.
        </h2>
        <p className="mx-auto mt-4 max-w-md text-base leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">
          Quietly holds pings, threads and invites until they matter, then hands you one calm digest.
        </p>
      </div>

      <NoiseGate
        className="mt-10"
        items={pings}
        density={6}
        duration={18}
        noiseTitle={
          <>
            Getting work done with <span className="text-zinc-500 dark:text-zinc-400">“do not disturb”</span>
          </>
        }
        calmTitle="Focusing with Quietly"
        count={312}
        countPrefix="Held back"
        countSuffix="pings today"
        calmClassName="bg-zinc-950 text-white dark:bg-zinc-50 dark:text-zinc-950"
        calmFooter={
          <a
            href="#early-access"
            className="inline-flex h-10 items-center gap-1.5 rounded-full bg-white px-5 text-sm font-medium text-zinc-950 transition-[transform,background-color] duration-150 ease-out hover:bg-zinc-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white active:scale-[0.97] dark:bg-zinc-950 dark:text-white dark:hover:bg-zinc-800 dark:focus-visible:outline-zinc-950"
          >
            Get early access
            <svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className="size-4">
              <path d="M3.5 8h9M8.5 4l4 4-4 4" />
            </svg>
          </a>
        }
      />
    </section>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` (required) | `NoiseGateItem[]` |  | The cards that drift into the gate, cycled in order. Each card is rendered once and reused, so 8 to 12 give enough variety. Their fields are listed below as `item.*`. |
| `noiseTitle` (required) | `React.ReactNode` |  | Heading of the noise side. Cards stay below it, so it's never covered. Quoting the old way works well: Answering tickets with “canned replies”. |
| `calmTitle` (required) | `React.ReactNode` |  | Heading of the calm side, on the same line as the noise heading. |
| `display` | `"count" \| "log"` | `"count"` | What the calm side shows on its horizon: a pill with a counter, or a log in which each card that goes through becomes its `resolved` line, newest on top. |
| `count` | `number` | `0` | The counter's starting value. Each card adds its `weight` when it reaches the gate. |
| `countPrefix` | `React.ReactNode` |  | Text before the number in the pill, e.g. "Resolved". |
| `countSuffix` | `React.ReactNode` |  | Text after the number, e.g. "tickets today". Screen readers get the whole line, without the rolling digits. |
| `calmFooter` | `React.ReactNode` |  | Pinned to the bottom of the calm side: a call to action or a footnote. |
| `density` | `number` | `9` | Most cards on screen at once. Fewer show when the panel is small, so it never turns into a wall. |
| `duration` | `number` | `15` | Seconds a mid-depth card takes to cross the noise panel. Far cards are slower and near ones quicker. |
| `bob` | `number` | `3` | How far each card floats across its lane as it drifts, in px. `0` keeps every card on a straight line. |
| `settle` | `number` | `0.32` | How quickly the stream eases to a stop under the pointer (or with `paused`) and gets going again, as a time constant in seconds. Smaller stops more sharply. |
| `rollDuration` | `number` | `0.42` | Seconds the counter's changed digits take to roll in when a card reaches the gate. |
| `paused` | `boolean` | `false` | Eases the stream to a stop. It also stops while a mouse is over the noise, so a card can be read, and the card under the pointer comes forward. |
| `titleAs` | `"h2" \| "h3" \| "h4" \| "p"` | `"h3"` | Element for both headings. |
| `height` | `string` | `"28rem"` | Height while the panels sit side by side. Below a 40rem container they stack, and each panel sizes itself. |
| `className` | `string` |  | Classes for the root, e.g. a max width. Set `[--noise-gate-radius:0px]` for square corners. |
| `noiseClassName` | `string` | `"bg-white dark:bg-zinc-950"` | Classes for the noise panel. |
| `calmClassName` | `string` | `"bg-[#3245ff] text-white"` | Classes for the calm panel: its background and text color. The pill, the dotted horizon, the seam light and the dots are all drawn in its text color. |
| `item.title` (required) | `React.ReactNode` |  | `NoiseGateItem`: the card's main line, up to two lines. |
| `item.detail` | `React.ReactNode` |  | `NoiseGateItem`: a smaller, muted second line. |
| `item.tag` | `React.ReactNode` |  | `NoiseGateItem`: a short label in a pill before the title, e.g. "SLA" or "Overdue". |
| `item.tone` | `"critical" \| "warning" \| "neutral"` | `"neutral"` | `NoiseGateItem`: colors the tag and the icon. |
| `item.icon` | `React.ReactNode` |  | `NoiseGateItem`: a leading icon or avatar, about 20px. SVG icons take the tone's color. |
| `item.mono` | `boolean` | `false` | `NoiseGateItem`: sets the title in monospace, for log lines, file names and codes. |
| `item.repeat` | `number` | `1` | `NoiseGateItem`: stacks up to 4 copies behind the card, like an alert that keeps firing, with a ×n count. |
| `item.weight` | `number` | `1` | `NoiseGateItem`: how much the counter goes up when this card reaches the gate. |
| `item.resolved` | `React.ReactNode` |  | `NoiseGateItem`: with `display="log"`, the line this card becomes once it's through. Falls back to `title`. |
| `item.resolvedDetail` | `React.ReactNode` |  | `NoiseGateItem`: with `display="log"`, a right-aligned value beside `resolved`, in tabular figures. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/noise-gate.tsx`, keep these, which make it feel and behave right:

- One rAF loop moves every card by writing `transform` and `opacity` straight to the DOM and redraws the traces; the stream never re-renders, and the loop sleeps when stopped or off screen.
- Arrivals are scheduled: each new card starts where it reaches the seam about one beat after the card before it, give or take the jitter, so ticks keep a rhythm instead of bunching.
- Hover and `paused` ease the whole stream to a stop through one shared factor, never a snap, and the card under the pointer comes forward on top of the rest.
- Digits are keyed from the right and roll in `popLayout`, so a new leading digit doesn't re-roll the rest; the count reads from an `sr-only` span while the moving parts are `aria-hidden`.
- Reduced motion shows a seeded, still scatter of cards and traces: no stream and no ticks.

Its timing (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="components/ui/noise-gate.tsx"
"use client";

import * as React from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useReducedMotion,
} from "motion/react";

import { cn } from "@/lib/utils";

/**
 * Keep:
 * - One rAF loop moves every card by writing `transform` and `opacity` straight to the DOM and
 *   redraws the traces; the stream never re-renders, and the loop sleeps when stopped or off
 *   screen.
 * - Arrivals are scheduled: each new card starts where it reaches the seam about one beat after
 *   the card before it, give or take the jitter, so ticks keep a rhythm instead of bunching.
 * - Hover and `paused` ease the whole stream to a stop through one shared factor, never a snap,
 *   and the card under the pointer comes forward on top of the rest.
 * - Digits are keyed from the right and roll in `popLayout`, so a new leading digit doesn't re-roll
 *   the rest; the count reads from an `sr-only` span while the moving parts are `aria-hidden`.
 * - Reduced motion shows a seeded, still scatter of cards and traces: no stream and no ticks.
 */
const TIMING = {
  /** A mid-depth card crossing the noise panel, in seconds (the `duration` prop). */
  duration: 15,
  /** How fast far, mid and near cards drift, as multiples of a mid-depth card's pace. */
  depthSpeed: [0.72, 1, 1.28],
  /** The stream easing to a stop and starting again, a time constant in seconds (the `settle` prop). */
  settle: 0.32,
  /** A new card appearing, in seconds. */
  fadeIn: 0.9,
  /** Between the first cards appearing, in seconds. */
  stagger: 0.11,
  /** A hovered card coming forward, as a time constant in seconds. */
  emphasis: 0.12,
  /** The least a new card drifts before it reaches the seam, in seconds. */
  minRun: 2.2,
  /** Between arrivals: this share of a full crossing, split between the cards. */
  beat: 0.9,
  /** The shortest beat, in seconds. */
  minBeat: 0.35,
  /** ± share of a beat each arrival moves by, so ticks don't land like a metronome. */
  jitter: 0.4,
  /** One bob of a card across its lane: the shortest period in seconds, and up to this many more. */
  bobPeriod: 5,
  bobVary: 3,
  /** Each trace's three waves moving, in rad/s. */
  traceSpeed: [
    [1.1, -1.9, 3.3],
    [-0.8, 2.4, -3.9],
    [1.6, -2.8, 4.6],
  ],
  /** The seam lighting up where a card went through: seconds, keyframe times and curve. */
  flash: { duration: 0.9, times: [0, 0.14, 1], ease: "easeOut" },
  /** The routed dot running along the seam and the horizon, in px/s. */
  route: 460,
  /** The shortest and longest the routed dot takes, in seconds. */
  routeDuration: [0.4, 1.1],
  /** The routed dot speeding up down the seam and easing out along the horizon, per leg. */
  routeEase: ["easeIn", "easeOut"],
  /** From the edge light to the tick when stacked, in seconds. */
  tickDelay: 0.42,
  /** The pill brightening once on a tick: seconds, keyframe times and curve. */
  glow: { duration: 0.9, times: [0, 0.15, 1], ease: "easeOut" },
  /** The fainter dot leaving on the far side: this many times as long as the routed dot would take. */
  exitPace: 1.6,
  /** The shortest and longest the fainter dot takes, in seconds. */
  exitDuration: [0.4, 1.2],
  /** The fainter dot's curve, as a motion easing. */
  exitEase: "easeOut",
  /** A tick with no fainter dot lingers this long before it's cleared, in seconds. */
  linger: 0.6,
  /** The counter's changed digits rolling in, in seconds (the `rollDuration` prop). */
  rollDuration: 0.42,
  /** Counter digits, as a spring over `rollDuration`. */
  roll: { type: "spring", bounce: 0 },
  /** Log rows sliding, as a spring. */
  row: { type: "spring", visualDuration: 0.45, bounce: 0 },
} as const;

/* ─────────────────────────────────────────────────────────
 * NOISE GATE STORYBOARD
 *
 *  enter     cards fade in one after another across the noise
 *            panel, at three depths: far ones small, faint and
 *            slow, near ones crisp and quicker
 *  drift     every card heads for the seam on its own beat,
 *            bobbing a few px across its lane. Near cards pass
 *            over far ones
 *  gate      a card's leading edge touches the seam: the calm
 *            panel's edge lights up along it, and the card slides
 *            on under the calm panel and is gone. A new card
 *            fades in somewhere behind
 *  route     a dot runs along the seam to the horizon and turns
 *            into the counter (or the log)
 *  tick      the counter's changed digits roll up and the pill
 *            brightens once; in the log, the card's resolved line
 *            slides in on top. A fainter dot leaves on the far side
 *  traces    behind the cards three jittery traces converge on
 *            the horizon at the seam, where the calm side's single
 *            dotted line takes over
 *  hover     pointing at the noise eases everything to a stop so
 *            a card can be read; the card under the pointer comes
 *            forward at full strength
 *  stacked   under 40rem the panels stack: cards fall into the
 *            calm panel's top edge, which lights up, and the tick
 *            follows without the routed dot
 *  reduced   a still scatter of cards and traces; nothing ticks
 * ───────────────────────────────────────────────────────── */

const GATE = {
  // far, mid, near
  depths: [
    { scale: 0.86, opacity: 0.38 },
    { scale: 0.94, opacity: 0.72 },
    { scale: 1, opacity: 1 },
  ],
  depthOrder: [2, 0, 1, 2, 1, 0, 2, 1, 0],
  horizon: 0.6, // share of the panel's height the horizon sits at, side by side
  overhang: 0.45, // share of a card that may start past the far edge (under the title, stacked)
  edge: 16, // px cards keep from the panel's outer edges
  crowd: 0.95, // cards on screen per card-sized area of the field
  logRows: 6,
} as const;

// Three traces, each a sum of three waves: wavenumber (rad/px). Their speeds are `TIMING.traceSpeed`.
const TRACES = [
  { amp: 1, k: [0.034, 0.092, 0.21], phase: 0.3, tone: "text-rose-500/30 dark:text-rose-400/25" },
  { amp: 0.74, k: [0.05, 0.13, 0.27], phase: 2.1, tone: "text-amber-500/35 dark:text-amber-400/25" },
  { amp: 0.56, k: [0.028, 0.16, 0.33], phase: 4.4, tone: "text-zinc-400/55 dark:text-zinc-500/55" },
] as const;

const TONE = {
  critical: {
    tag: "bg-rose-500/10 text-rose-700 dark:bg-rose-400/15 dark:text-rose-300",
    icon: "text-rose-500 dark:text-rose-400",
  },
  warning: {
    tag: "bg-amber-500/15 text-amber-800 dark:bg-amber-400/15 dark:text-amber-200",
    icon: "text-amber-500 dark:text-amber-400",
  },
  neutral: {
    tag: "bg-zinc-950/[0.06] text-zinc-600 dark:bg-white/10 dark:text-zinc-300",
    icon: "text-zinc-400 dark:text-zinc-500",
  },
} as const;

const SURFACE =
  "rounded-xl border border-zinc-950/[0.08] bg-white/85 shadow-[0_1px_2px_rgb(9_9_11/0.05),0_12px_28px_-16px_rgb(9_9_11/0.3)] dark:border-white/10 dark:bg-zinc-900/85 dark:shadow-[0_1px_2px_rgb(0_0_0/0.4),0_12px_28px_-16px_rgb(0_0_0/0.8)]";

const DOTTED: React.CSSProperties = {
  backgroundImage: "radial-gradient(circle, currentColor 1px, transparent 1.3px)",
  backgroundSize: "7px 3px",
  backgroundRepeat: "repeat-x",
};

const TAU = Math.PI * 2;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const smoothstep = (a: number, b: number, v: number) => {
  const t = clamp((v - a) / (b - a), 0, 1);
  return t * t * (3 - 2 * t);
};
const easeOut = (t: number) => 1 - (1 - t) ** 3;

/** Small seeded generator, so the still scatter is the same on every visit. */
function seeded(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export type NoiseGateTone = "critical" | "warning" | "neutral";

export interface NoiseGateItem {
  /** The card's main line. */
  title: React.ReactNode;
  /** A smaller, muted second line. */
  detail?: React.ReactNode;
  /** A short label in a pill before the title, e.g. "P1" or "Overdue". */
  tag?: React.ReactNode;
  /** Colors the tag and the icon. */
  tone?: NoiseGateTone;
  /** A leading icon or avatar, about 20px. */
  icon?: React.ReactNode;
  /** Set the title in monospace, for log lines, file names and codes. */
  mono?: boolean;
  /** Stack this many copies, like an alert that keeps firing. */
  repeat?: number;
  /** How much the counter goes up when this card reaches the gate. */
  weight?: number;
  /** With `display="log"`: the line this card becomes once it's through. */
  resolved?: React.ReactNode;
  /** With `display="log"`: a right-aligned value beside `resolved`, in tabular figures. */
  resolvedDetail?: React.ReactNode;
}

export interface NoiseGateProps {
  /** The cards that drift into the gate, cycled in order. */
  items: NoiseGateItem[];
  /** Heading of the noise side. */
  noiseTitle: React.ReactNode;
  /** Heading of the calm side. */
  calmTitle: React.ReactNode;
  /** What the calm side shows: a counter pill, or a log of resolved lines. */
  display?: "count" | "log";
  /** The counter's starting value. Each card adds its `weight` (1) when it reaches the gate. */
  count?: number;
  /** Text before the number in the pill. */
  countPrefix?: React.ReactNode;
  /** Text after the number in the pill. */
  countSuffix?: React.ReactNode;
  /** Pinned to the bottom of the calm side: a call to action or a footnote. */
  calmFooter?: React.ReactNode;
  /** Most cards on screen at once. Fewer show when the panel is small. */
  density?: number;
  /** Seconds a mid-depth card takes to cross the noise panel. */
  duration?: number;
  /** How far each card floats across its lane as it drifts, in px. */
  bob?: number;
  /** How quickly the stream eases to a stop under the pointer and gets going again: a time constant in seconds. */
  settle?: number;
  /** Seconds the counter's changed digits take to roll in. */
  rollDuration?: number;
  /** Ease the stream to a stop. It also stops while the pointer is over the noise. */
  paused?: boolean;
  /** Element for both headings. */
  titleAs?: "h2" | "h3" | "h4" | "p";
  /** Height while the panels sit side by side. Stacked, each panel sizes itself. */
  height?: string;
  /** Classes for the root. Set `[--noise-gate-radius:…]` to change the corner radius. */
  className?: string;
  /** Classes for the noise panel. */
  noiseClassName?: string;
  /** Classes for the calm panel: its background and text color. */
  calmClassName?: string;
}

type Slot = {
  item: number;
  depth: number;
  /** px along the drift, from the far edge toward the seam (the box's start). */
  a: number;
  /** px across the drift (the box's start). */
  c: number;
  /** Stream seconds the card was born at; it fades in from there. */
  born: number;
  phase: number;
  period: number;
  gated: boolean;
  /** 0..1, how far the card has come forward under the pointer. */
  emph: number;
};

type Geometry = {
  stacked: boolean;
  w: number;
  h: number;
  /** Along coordinate of the seam. */
  gate: number;
  /** Along coordinate where cards may start. */
  start: number;
  /** Cross range for a card's box. */
  lo: number;
  hi: number;
  horizon: number;
  /** Per item: along and cross size. */
  sizes: { a: number; c: number }[];
};

type GateEvent = { item: number; from: number; to: number; stacked: boolean };
type CalmApi = { gate: (event: GateEvent) => void };

type Pulse = {
  id: number;
  item: number;
  weight: number;
  stacked: boolean;
  from: number;
  to: number;
  route: { x: number[]; y: number[]; times: number[]; duration: number } | null;
  exit: { x0: number; x1: number; y: number } | null;
};

export function NoiseGate({
  items,
  noiseTitle,
  calmTitle,
  display = "count",
  count = 0,
  countPrefix,
  countSuffix,
  calmFooter,
  density = 9,
  duration = TIMING.duration,
  bob = 3,
  settle = TIMING.settle,
  rollDuration = TIMING.rollDuration,
  paused = false,
  titleAs = "h3",
  height = "28rem",
  className,
  noiseClassName,
  calmClassName,
}: NoiseGateProps) {
  const reduced = useReducedMotion() ?? false;
  const noiseRef = React.useRef<HTMLDivElement>(null);
  const calmRef = React.useRef<HTMLDivElement>(null);
  const titleRef = React.useRef<HTMLHeadingElement>(null);
  const layerRef = React.useRef<HTMLDivElement>(null);
  const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]);
  const traceRefs = React.useRef<(SVGPathElement | null)[]>([]);
  const calmApi = React.useRef<CalmApi>(null);

  const hovering = React.useRef(false);
  const hovered = React.useRef(-1);
  const pausedRef = React.useRef(paused);
  const feel = React.useRef({ bob, settle });
  const wake = React.useRef<() => void>(() => {});

  React.useEffect(() => {
    pausedRef.current = paused;
    wake.current();
  }, [paused]);

  // Read by the frame loop, so changing them doesn't restart the stream.
  React.useEffect(() => {
    feel.current = { bob, settle };
    wake.current();
  }, [bob, settle]);

  const itemCount = items.length;
  const Title = titleAs as React.ElementType;

  // The stream: one frame loop moves every card and redraws the traces.
  React.useEffect(() => {
    const panel = noiseRef.current;
    const calm = calmRef.current;
    const title = titleRef.current;
    const layer = layerRef.current;
    const cards = cardRefs.current;
    const traces = traceRefs.current;
    if (!panel || !calm || !title || !layer || itemCount === 0) return;

    const rand = seeded(7);

    const sizeOf = (el: HTMLDivElement | null | undefined, stacked: boolean) =>
      el
        ? stacked
          ? { a: el.offsetHeight, c: el.offsetWidth }
          : { a: el.offsetWidth, c: el.offsetHeight }
        : { a: 0, c: 0 };

    const measure = (): Geometry => {
      const w = panel.clientWidth;
      const h = panel.clientHeight;
      const stacked = calm.offsetTop > 1;
      const titleBottom = title.offsetTop + title.offsetHeight;
      const sizes = Array.from({ length: itemCount }, (_, i) => sizeOf(cards[i], stacked));
      // Stacked, cards start under the title and come out from beneath it.
      const mask = stacked ? `linear-gradient(to bottom, transparent ${titleBottom - 6}px, #000 ${titleBottom + 30}px)` : "";
      layer.style.setProperty("mask-image", mask);
      layer.style.setProperty("-webkit-mask-image", mask);
      return stacked
        ? { stacked, w, h, gate: h, start: titleBottom + 12, lo: GATE.edge, hi: w - GATE.edge, horizon: 0, sizes }
        : { stacked, w, h, gate: w, start: GATE.edge, lo: titleBottom + 16, hi: h - GATE.edge, horizon: h * GATE.horizon, sizes };
    };

    let geo = measure();

    // How many cards the field holds without turning into a wall.
    const capacity = () => {
      const along = Math.max(1, geo.gate - geo.start);
      const across = Math.max(1, geo.hi - geo.lo);
      const card = geo.sizes.reduce((sum, s) => sum + s.a * s.c, 0) / itemCount || 1;
      return clamp(Math.round(((along * across) / card) * GATE.crowd), Math.min(3, itemCount), Math.min(density, itemCount));
    };

    const slots: Slot[] = [];
    const lanes: number[] = [];
    let cursor = 0;
    let spawned = 0;
    let clock = 0;
    // Arrivals are scheduled, not left to chance: each new card starts where it
    // will reach the seam about one beat after the card before it.
    let lastArrival = 0;
    let beat = 1;

    const nextItem = () => {
      for (let i = 0; i < itemCount; i++) {
        const idx = (cursor + i) % itemCount;
        if (!slots.some((s) => s.item === idx)) {
          cursor = idx + 1;
          return idx;
        }
      }
      return cursor++ % itemCount;
    };

    // Of a few random lanes, take the one furthest from the last few cards.
    const pickLane = (size: number) => {
      const lo = geo.lo;
      const hi = Math.max(lo, geo.hi - size);
      let best = lo;
      let bestScore = -1;
      for (let i = 0; i < 6; i++) {
        const v = lerp(lo, hi, rand());
        const score = lanes.length ? Math.min(...lanes.map((l) => Math.abs(l - v))) : 1;
        if (score > bestScore) {
          bestScore = score;
          best = v;
        }
      }
      lanes.push(best);
      if (lanes.length > 3) lanes.shift();
      return best;
    };

    const show = (slot: Slot) => {
      const el = cards[slot.item];
      if (el) el.style.visibility = "visible";
    };
    const hide = (item: number) => {
      const el = cards[item];
      if (!el) return;
      el.style.visibility = "hidden";
      el.style.opacity = "0";
    };

    const spawn = (slot: Slot, born = clock, along?: number) => {
      slot.item = nextItem();
      slot.depth = GATE.depthOrder[spawned++ % GATE.depthOrder.length];
      geo.sizes[slot.item] = sizeOf(cards[slot.item], geo.stacked);
      const size = geo.sizes[slot.item];
      const d = GATE.depths[slot.depth];
      const speed = (geo.gate / duration) * TIMING.depthSpeed[slot.depth];
      const lead = (size.a * (1 + d.scale)) / 2; // box start to the visible leading edge
      if (along == null) {
        const earliest = Math.max(clock, born) + TIMING.minRun;
        const target = Math.max(earliest, lastArrival + beat * (1 + TIMING.jitter * (2 * rand() - 1)));
        const lo = geo.start - size.a * GATE.overhang;
        const hi = Math.max(lo, geo.gate - lead - speed * (earliest - clock));
        along = clamp(geo.gate - lead - speed * (target - clock), lo, hi);
        lastArrival = Math.max(lastArrival, clock + (geo.gate - lead - along) / speed);
      }
      slot.a = along;
      slot.c = pickLane(size.c);
      slot.born = born;
      slot.phase = rand() * TAU;
      slot.period = TIMING.bobPeriod + rand() * TIMING.bobVary;
      slot.gated = false;
      slot.emph = 0;
      show(slot);
    };

    // Fill the field at once. Live, on the arrival schedule; still, spread
    // evenly from the far edge to just short of the seam.
    const seed = (mode: "enter" | "resume" | "still") => {
      for (const slot of slots) hide(slot.item);
      slots.length = 0;
      lanes.length = 0;
      cursor = 0;
      spawned = 0;
      const n = capacity();
      const along = geo.sizes.reduce((sum, s) => sum + s.a, 0) / itemCount;
      const run = geo.gate - geo.start - along * (1 - GATE.overhang);
      beat = Math.max(TIMING.minBeat, (TIMING.beat * Math.max(1, run) * duration) / geo.gate / n);
      lastArrival = clock;
      const order = Array.from({ length: n }, (_, i) => i).sort(() => rand() - 0.5);
      for (let i = 0; i < n; i++) {
        const slot = { item: -1 } as Slot;
        slots.push(slot);
        if (mode === "still") {
          const size = sizeOf(cards[i % itemCount], geo.stacked);
          spawn(slot, clock - TIMING.fadeIn, lerp(geo.start, geo.gate - size.a * 1.15, (order[i] + 0.5) / n));
        } else {
          spawn(slot, mode === "enter" ? clock + i * TIMING.stagger : clock - TIMING.fadeIn);
        }
      }
    };

    const traceAt = (k: number, x: number, t: number) => {
      const tr = TRACES[k];
      const w = TIMING.traceSpeed[k];
      return (
        tr.amp *
        (0.55 * Math.sin(tr.k[0] * x + w[0] * t + tr.phase) +
          0.3 * Math.sin(tr.k[1] * x + w[1] * t + 2 * tr.phase) +
          0.15 * Math.sin(tr.k[2] * x + w[2] * t + 3 * tr.phase))
      );
    };

    // Side by side the traces converge on the horizon at the seam; stacked
    // they run across the middle of the field and swell in its center.
    const drawTraces = (t: number) => {
      const { w, h } = geo;
      const y0 = geo.stacked ? (geo.start + h) / 2 : geo.horizon;
      const amp = Math.min(geo.stacked ? 48 : 60, (geo.stacked ? h - geo.start : h - geo.lo) * 0.3);
      for (let k = 0; k < TRACES.length; k++) {
        const path = traces[k];
        if (!path) continue;
        let d = "";
        for (let x = 0; ; x = Math.min(w, x + 6)) {
          const env = geo.stacked ? Math.sin((Math.PI * x) / w) ** 0.6 : 1 - smoothstep(w * 0.38, w, x);
          const y = y0 + amp * env * traceAt(k, x, t);
          d += `${x ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`;
          if (x >= w) break;
        }
        path.setAttribute("d", d);
      }
    };

    const place = (slot: Slot, opacityOnly = false) => {
      const el = cards[slot.item];
      if (!el) return;
      const d = GATE.depths[slot.depth];
      const fade = easeOut(clamp((clock - slot.born) / TIMING.fadeIn, 0, 1));
      const scale = lerp(d.scale, Math.max(d.scale, 1), slot.emph) * lerp(0.96, 1, fade);
      const bob = feel.current.bob * Math.sin((clock / slot.period) * TAU + slot.phase);
      el.style.opacity = String(lerp(d.opacity, 1, slot.emph) * fade);
      el.style.zIndex = String(slot.emph > 0.5 ? 100 : slot.depth * 10 + 1);
      if (opacityOnly) return;
      el.style.transform = geo.stacked
        ? `translate3d(${(slot.c + bob).toFixed(2)}px, ${slot.a.toFixed(2)}px, 0) scale(${scale.toFixed(4)})`
        : `translate3d(${slot.a.toFixed(2)}px, ${(slot.c + bob).toFixed(2)}px, 0) scale(${scale.toFixed(4)})`;
    };

    // Reduced motion: a still scatter, no stream, no ticks.
    if (reduced) {
      const still = () => {
        geo = measure();
        clock = 0;
        seed("still");
        for (const slot of slots) place(slot);
        drawTraces(0);
      };
      still();
      const ro = new ResizeObserver(still);
      ro.observe(panel);
      return () => {
        ro.disconnect();
        for (const slot of slots) hide(slot.item);
      };
    }

    seed("enter");
    for (const slot of slots) place(slot);
    drawTraces(0);

    let factor = pausedRef.current ? 0 : 1;
    let raf = 0;
    let last = 0;
    let running = false;
    let visible = false;

    const step = (now: number) => {
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      const target = pausedRef.current || hovering.current ? 0 : 1;
      factor += (target - factor) * (1 - Math.exp(-dt / feel.current.settle));
      if (target === 1 && factor > 0.999) factor = 1;
      if (target === 0 && factor < 0.01) factor = 0;
      clock += dt * factor;

      for (const slot of slots) {
        const d = GATE.depths[slot.depth];
        slot.a += (geo.gate / duration) * TIMING.depthSpeed[slot.depth] * dt * factor;
        slot.emph += ((hovered.current === slot.item ? 1 : 0) - slot.emph) * (1 - Math.exp(-dt / TIMING.emphasis));

        const size = geo.sizes[slot.item];
        const scale = lerp(d.scale, Math.max(d.scale, 1), slot.emph);
        const lead = slot.a + (size.a * (1 + scale)) / 2;
        const trail = slot.a + (size.a * (1 - scale)) / 2;

        if (!slot.gated && lead >= geo.gate) {
          slot.gated = true;
          const bob = feel.current.bob * Math.sin((clock / slot.period) * TAU + slot.phase);
          const mid = slot.c + bob + size.c / 2;
          const half = (size.c * scale) / 2;
          calmApi.current?.gate({ item: slot.item, from: mid - half, to: mid + half, stacked: geo.stacked });
        }

        if (trail >= geo.gate + 12) {
          const old = slot.item;
          spawn(slot);
          hide(old);
        }
        place(slot);
      }
      drawTraces(clock);

      // Fully stopped and nothing coming forward or back: sleep until woken.
      const settling = slots.some((s) => s.emph > 0.002 && s.emph < 0.998);
      if (target === 0 && factor === 0 && !settling) {
        running = false;
        return;
      }
      raf = requestAnimationFrame(step);
    };

    const start = () => {
      if (running || !visible) return;
      running = true;
      last = performance.now();
      raf = requestAnimationFrame(step);
    };
    const stop = () => {
      running = false;
      cancelAnimationFrame(raf);
    };
    wake.current = start;

    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible) start();
      else stop();
    });
    io.observe(panel);

    let lastStacked = geo.stacked;
    let lastGate = geo.gate;
    const ro = new ResizeObserver(() => {
      geo = measure();
      if (geo.stacked !== lastStacked) {
        seed("resume");
      } else {
        // Keep the stream going: rescale along the drift, clamp across it.
        const ratio = geo.gate / (lastGate || geo.gate);
        for (const slot of slots) {
          slot.a *= ratio;
          slot.c = clamp(slot.c, geo.lo, Math.max(geo.lo, geo.hi - geo.sizes[slot.item].c));
        }
      }
      lastStacked = geo.stacked;
      lastGate = geo.gate;
      for (const slot of slots) place(slot);
      drawTraces(clock);
    });
    ro.observe(panel);
    ro.observe(calm);

    return () => {
      stop();
      io.disconnect();
      ro.disconnect();
      wake.current = () => {};
      for (const slot of slots) hide(slot.item);
    };
  }, [itemCount, reduced, density, duration]);

  const titleClass =
    "relative z-20 mx-auto max-w-[17ch] px-6 pt-9 text-center text-[clamp(1.625rem,7.4cqi,2.25rem)] leading-[1.04] font-semibold tracking-[-0.035em] text-balance @[40rem]/noise-gate:pt-12 @[40rem]/noise-gate:text-[clamp(1.75rem,3.9cqi,2.75rem)]";

  return (
    <div
      className={cn("@container/noise-gate w-full [--noise-gate-radius:1.5rem]", className)}
      style={{ "--noise-gate-height": height } as React.CSSProperties}
    >
      <div className="relative isolate grid overflow-hidden rounded-[var(--noise-gate-radius)] @[40rem]/noise-gate:h-[var(--noise-gate-height)] @[40rem]/noise-gate:grid-cols-2">
        <div
          ref={noiseRef}
          className={cn(
            "relative h-[25rem] overflow-hidden bg-white text-zinc-950 @[40rem]/noise-gate:h-auto dark:bg-zinc-950 dark:text-zinc-50",
            noiseClassName,
          )}
          onPointerEnter={(e) => {
            if (e.pointerType !== "mouse") return;
            hovering.current = true;
          }}
          onPointerLeave={() => {
            hovering.current = false;
            hovered.current = -1;
            wake.current();
          }}
        >
          <div
            aria-hidden
            className="absolute inset-0 text-zinc-950/[0.09] [mask-image:linear-gradient(to_bottom,transparent,#000_45%)] dark:text-white/[0.08]"
            style={{
              backgroundImage: "radial-gradient(circle, currentColor 1px, transparent 1.3px)",
              backgroundSize: "16px 16px",
              backgroundPosition: "8px 8px",
            }}
          />
          <svg aria-hidden className="absolute inset-0 size-full overflow-visible">
            {TRACES.map((trace, k) => (
              <path
                key={k}
                ref={(el) => {
                  traceRefs.current[k] = el;
                }}
                className={trace.tone}
                fill="none"
                stroke="currentColor"
                strokeWidth={1}
                strokeLinejoin="round"
                strokeLinecap="round"
              />
            ))}
          </svg>

          <Title ref={titleRef} className={titleClass}>
            {noiseTitle}
          </Title>

          <div ref={layerRef} aria-hidden className="pointer-events-none absolute inset-0">
            {items.map((item, i) => (
              <div
                key={i}
                ref={(el) => {
                  cardRefs.current[i] = el;
                }}
                className="pointer-events-auto absolute top-0 left-0 w-[min(15rem,80%)] will-change-transform"
                style={{ opacity: 0, visibility: "hidden" }}
                onPointerEnter={(e) => {
                  if (e.pointerType !== "mouse") return;
                  hovered.current = i;
                  wake.current();
                }}
                onPointerLeave={() => {
                  if (hovered.current === i) hovered.current = -1;
                  wake.current();
                }}
              >
                <NoiseCard item={item} />
              </div>
            ))}
          </div>

          <div
            aria-hidden
            className="pointer-events-none absolute inset-0 z-30 rounded-t-[var(--noise-gate-radius)] border border-b-0 border-zinc-950/10 @[40rem]/noise-gate:rounded-tr-none @[40rem]/noise-gate:rounded-l-[var(--noise-gate-radius)] @[40rem]/noise-gate:border-r-0 @[40rem]/noise-gate:border-b dark:border-white/10"
          />
        </div>

        <CalmPanel
          ref={calmRef}
          api={calmApi}
          items={items}
          display={display}
          count={count}
          rollDuration={rollDuration}
          countPrefix={countPrefix}
          countSuffix={countSuffix}
          footer={calmFooter}
          className={calmClassName}
        >
          <Title className={titleClass}>{calmTitle}</Title>
        </CalmPanel>
      </div>
    </div>
  );
}

function NoiseCard({ item }: { item: NoiseGateItem }) {
  const tone = TONE[item.tone ?? "neutral"];
  const copies = clamp(Math.round(item.repeat ?? 1), 1, 4) - 1;
  return (
    <div className="relative">
      {Array.from({ length: copies }, (_, i) => (
        <div
          key={i}
          className={cn(SURFACE, "absolute inset-0 origin-top")}
          style={{
            transform: `translateY(${-(copies - i) * 6}px) scale(${1 - (copies - i) * 0.05})`,
            opacity: 0.45 + (i / copies) * 0.35,
          }}
        />
      ))}
      <div className={cn(SURFACE, "relative flex items-start gap-2.5 px-3 py-2.5")}>
        {item.icon ? (
          <span className={cn("grid size-5 shrink-0 place-items-center [&_svg]:size-4", tone.icon)}>
            {item.icon}
          </span>
        ) : null}
        <div className="min-w-0 flex-1">
          <div className="flex items-start gap-1.5">
            {item.tag ? (
              <span className={cn("shrink-0 rounded-[5px] px-1.5 text-[11px] leading-5 font-medium", tone.tag)}>
                {item.tag}
              </span>
            ) : null}
            <span
              className={cn(
                "line-clamp-2 min-w-0 flex-1 text-[13px] leading-5 font-medium text-zinc-900 dark:text-zinc-100",
                item.mono && "font-mono text-[12px] tracking-tight",
              )}
            >
              {item.title}
            </span>
            {copies > 0 ? (
              <span className="shrink-0 text-[11px] leading-5 text-zinc-500 tabular-nums dark:text-zinc-400">
                ×{copies + 1}
              </span>
            ) : null}
          </div>
          {item.detail ? (
            <div className="mt-0.5 truncate text-[12px] leading-[18px] text-zinc-500 dark:text-zinc-400">
              {item.detail}
            </div>
          ) : null}
        </div>
      </div>
    </div>
  );
}

type Row = { id: number; item: number };

/** The last few items that have a resolved line, newest first, so the log never starts empty. */
const initialRows = (items: NoiseGateItem[]): Row[] =>
  items
    .map((item, i) => ({ id: -1 - i, item: i, ok: item.resolved != null }))
    .filter((row) => row.ok)
    .slice(-3)
    .reverse()
    .map(({ id, item }) => ({ id, item }));

function CalmPanel({
  ref,
  api,
  items,
  display,
  count: start,
  rollDuration,
  countPrefix,
  countSuffix,
  footer,
  className,
  children,
}: {
  ref: React.Ref<HTMLDivElement>;
  api: React.Ref<CalmApi>;
  items: NoiseGateItem[];
  display: "count" | "log";
  count: number;
  rollDuration: number;
  countPrefix?: React.ReactNode;
  countSuffix?: React.ReactNode;
  footer?: React.ReactNode;
  className?: string;
  children: React.ReactNode;
}) {
  const panelRef = React.useRef<HTMLDivElement>(null);
  const anchorRef = React.useRef<HTMLElement>(null);
  const glowRef = React.useRef<HTMLSpanElement>(null);
  const [count, setCount] = React.useState(start);
  const [rows, setRows] = React.useState(() => initialRows(items));
  const [pulses, setPulses] = React.useState<Pulse[]>([]);
  const nextId = React.useRef(0);

  React.useImperativeHandle(ref, () => panelRef.current as HTMLDivElement);

  React.useImperativeHandle(
    api,
    () => ({
      gate({ item, from, to, stacked }) {
        const panel = panelRef.current;
        const anchor = anchorRef.current;
        if (!panel || !anchor) return;
        const p = panel.getBoundingClientRect();
        const a = anchor.getBoundingClientRect();
        let route: Pulse["route"] = null;
        let exit: Pulse["exit"] = null;
        if (!stacked) {
          // Down (or up) the seam to the horizon, then along it to the pill or the log.
          const y = display === "log" ? a.top + 20 - p.top : a.top + a.height / 2 - p.top;
          const x = a.left - p.left;
          const yc = clamp((from + to) / 2, 8, p.height - 8);
          const leg1 = Math.abs(y - yc);
          const leg2 = Math.max(0, x - 4);
          const total = Math.max(1, leg1 + leg2);
          route = {
            x: [4, 4, x],
            y: [yc, y, y],
            times: [0, leg1 / total, 1],
            duration: clamp(total / TIMING.route, TIMING.routeDuration[0], TIMING.routeDuration[1]),
          };
          exit = { x0: a.right - p.left, x1: p.width - 4, y };
        }
        setPulses((list) => [
          ...list,
          { id: nextId.current++, item, weight: items[item]?.weight ?? 1, stacked, from, to, route, exit },
        ]);
      },
    }),
    [display, items],
  );

  const arrive = React.useCallback((pulse: Pulse) => {
    setCount((c) => c + pulse.weight);
    setRows((list) => [{ id: pulse.id, item: pulse.item }, ...list].slice(0, GATE.logRows));
    const glow = glowRef.current;
    if (glow)
      animate(
        glow,
        { opacity: [0, 1, 0] },
        { duration: TIMING.glow.duration, times: [...TIMING.glow.times], ease: TIMING.glow.ease },
      );
  }, []);

  const done = React.useCallback((id: number) => {
    setPulses((list) => list.filter((p) => p.id !== id));
  }, []);

  return (
    <div
      ref={panelRef}
      className={cn(
        "relative z-10 flex flex-col bg-[#3245ff] text-white shadow-[0_-24px_40px_-32px_rgb(9_9_11/0.3)] @[40rem]/noise-gate:shadow-[-24px_0_40px_-32px_rgb(9_9_11/0.3)]",
        className,
      )}
    >
      {children}

      <div
        className={cn(
          "relative mt-7 flex flex-col pb-9 @[40rem]/noise-gate:absolute @[40rem]/noise-gate:inset-x-0 @[40rem]/noise-gate:top-[60%] @[40rem]/noise-gate:bottom-0 @[40rem]/noise-gate:mt-0 @[40rem]/noise-gate:pb-0",
          footer && "pb-8",
        )}
      >
        {display === "count" ? (
          <div className="flex items-center @[40rem]/noise-gate:-mt-[22px]">
            <span aria-hidden className="h-[3px] min-w-6 flex-1 opacity-50" style={DOTTED} />
            <div
              ref={anchorRef as React.Ref<HTMLDivElement>}
              className="relative mx-2 inline-flex h-11 max-w-[calc(100%-4.5rem)] shrink-0 items-center gap-2 rounded-full border border-current/25 bg-current/[0.08] px-4 text-[clamp(0.8125rem,3.9cqi,0.9375rem)] font-medium whitespace-nowrap @[40rem]/noise-gate:text-[clamp(0.8125rem,1.75cqi,0.9375rem)]"
            >
              <span ref={glowRef} aria-hidden className="absolute inset-0 rounded-full bg-current/15 opacity-0" />
              <CheckIcon className="relative size-4 shrink-0" />
              <span aria-hidden className="relative inline-flex min-w-0 items-baseline gap-[0.3em]">
                {countPrefix != null ? <span>{countPrefix}</span> : null}
                <Digits value={count} duration={rollDuration} />
                {countSuffix != null ? <span className="truncate">{countSuffix}</span> : null}
              </span>
              <span className="sr-only">
                {countPrefix} {count.toLocaleString("en-US")} {countSuffix}
              </span>
            </div>
            <span aria-hidden className="h-[3px] min-w-6 flex-1 opacity-50" style={DOTTED} />
          </div>
        ) : (
          <div className="flex items-start @[40rem]/noise-gate:-mt-5 @[40rem]/noise-gate:min-h-0 @[40rem]/noise-gate:flex-1">
            <span aria-hidden className="mt-[18.5px] h-[3px] min-w-6 flex-1 opacity-50" style={DOTTED} />
            <ul
              ref={anchorRef as React.Ref<HTMLUListElement>}
              className="relative mx-2 h-[12.5rem] w-[min(19rem,calc(100%-4.5rem))] shrink-0 overflow-hidden text-left [mask-image:linear-gradient(to_bottom,#000_45%,transparent)] @[40rem]/noise-gate:h-full"
            >
              <AnimatePresence initial={false}>
                {rows.map((row) => {
                  const item = items[row.item];
                  if (!item) return null;
                  return (
                    // A new line opens at the top and pushes the rest down; the oldest fades out under the mask.
                    <motion.li
                      key={row.id}
                      initial={{ height: 0, opacity: 0 }}
                      animate={{ height: 40, opacity: 1 }}
                      exit={{ opacity: 0 }}
                      transition={TIMING.row}
                      className="flex items-center gap-2.5 overflow-hidden border-b border-current/15 text-[14px]"
                    >
                      <CheckIcon className="size-4 shrink-0 opacity-85" />
                      <span className="min-w-0 flex-1 truncate font-medium">{item.resolved ?? item.title}</span>
                      {item.resolvedDetail != null ? (
                        <span className="shrink-0 tabular-nums opacity-85">{item.resolvedDetail}</span>
                      ) : null}
                    </motion.li>
                  );
                })}
              </AnimatePresence>
            </ul>
            <span aria-hidden className="mt-[18.5px] h-[3px] min-w-6 flex-1 opacity-50" style={DOTTED} />
          </div>
        )}

        {footer ? (
          <div className="relative mt-7 px-6 text-center @[40rem]/noise-gate:mt-auto @[40rem]/noise-gate:pt-6 @[40rem]/noise-gate:pb-8">
            {footer}
          </div>
        ) : null}
      </div>

      {pulses.map((pulse) => (
        <PulseView key={pulse.id} pulse={pulse} onArrive={arrive} onDone={done} />
      ))}
    </div>
  );
}

/** The seam lighting up where a card went through, and the dot routed to the counter. */
function PulseView({
  pulse,
  onArrive,
  onDone,
}: {
  pulse: Pulse;
  onArrive: (pulse: Pulse) => void;
  onDone: (id: number) => void;
}) {
  const flashRef = React.useRef<HTMLSpanElement>(null);
  const dotRef = React.useRef<HTMLSpanElement>(null);
  const exitRef = React.useRef<HTMLSpanElement>(null);

  React.useEffect(() => {
    const flash = flashRef.current;
    if (!flash) return;
    let live = true;
    const running: { stop: () => void }[] = [];

    running.push(
      animate(
        flash,
        { opacity: [0, 1, 0] },
        { duration: TIMING.flash.duration, times: [...TIMING.flash.times], ease: TIMING.flash.ease },
      ),
    );

    const settle = (seconds: number) =>
      running.push(animate(0, 1, { duration: seconds, onComplete: () => live && onDone(pulse.id) }));

    const arrived = () => {
      if (!live) return;
      onArrive(pulse);
      const exit = exitRef.current;
      if (pulse.exit && exit && pulse.exit.x1 > pulse.exit.x0) {
        const { x0, x1, y } = pulse.exit;
        running.push(
          animate(
            exit,
            { x: [x0, x1], y: [y, y], opacity: [0.6, 0] },
            {
              duration: clamp(
                ((x1 - x0) / TIMING.route) * TIMING.exitPace,
                TIMING.exitDuration[0],
                TIMING.exitDuration[1],
              ),
              ease: TIMING.exitEase,
              onComplete: () => live && onDone(pulse.id),
            },
          ),
        );
      } else {
        settle(TIMING.linger);
      }
    };

    const dot = dotRef.current;
    if (pulse.route && dot) {
      const { x, y, times, duration } = pulse.route;
      running.push(
        animate(dot, { x, y, opacity: [0, 1, 1] }, { duration, times, ease: [...TIMING.routeEase], onComplete: arrived }),
      );
    } else {
      running.push(animate(0, 1, { duration: TIMING.tickDelay, onComplete: arrived }));
    }

    return () => {
      live = false;
      for (const r of running) r.stop();
    };
  }, [pulse, onArrive, onDone]);

  const dotClass =
    "pointer-events-none absolute top-0 left-0 -mt-[3px] -ml-[3px] size-1.5 rounded-full bg-current opacity-0 ring-[3px] ring-current/20";

  return (
    <>
      <span
        ref={flashRef}
        aria-hidden
        className={cn(
          "pointer-events-none absolute opacity-0",
          pulse.stacked
            ? "top-0 h-8 [mask-image:linear-gradient(to_right,transparent,#000_30%,#000_70%,transparent)]"
            : "left-0 w-8 [mask-image:linear-gradient(to_bottom,transparent,#000_30%,#000_70%,transparent)]",
        )}
        style={pulse.stacked ? { left: pulse.from, width: pulse.to - pulse.from } : { top: pulse.from, height: pulse.to - pulse.from }}
      >
        <span
          className={cn(
            "absolute bg-current",
            pulse.stacked ? "inset-x-0 top-0 h-0.5" : "inset-y-0 left-0 w-0.5",
          )}
        />
        <span
          className={cn(
            "absolute inset-0 from-current/20 to-transparent",
            pulse.stacked ? "bg-linear-to-b" : "bg-linear-to-r",
          )}
        />
      </span>
      {pulse.route ? <span ref={dotRef} aria-hidden className={dotClass} /> : null}
      {pulse.exit ? <span ref={exitRef} aria-hidden className={dotClass} /> : null}
    </>
  );
}

function Digits({ value, duration }: { value: number; duration: number }) {
  const chars = value.toLocaleString("en-US").split("");
  return (
    <span className="inline-flex font-semibold tabular-nums">
      {chars.map((char, i) => (
        // Keyed from the right, so a new leading digit doesn't re-roll the rest.
        <Digit key={chars.length - i} char={char} duration={duration} />
      ))}
    </span>
  );
}

function Digit({ char, duration }: { char: string; duration: number }) {
  return (
    <span className="relative inline-flex overflow-hidden">
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span
          key={char}
          initial={{ y: "100%", opacity: 0 }}
          animate={{ y: "0%", opacity: 1 }}
          exit={{ y: "-100%", opacity: 0 }}
          transition={{ ...TIMING.roll, visualDuration: duration }}
          className="inline-block"
        >
          {char}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg
      aria-hidden
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
    >
      <path d="M3.5 8.5 6.5 11.5 12.5 4.5" />
    </svg>
  );
}
```
