RequestReel

A scripted HTTP request/response pane that plays a sequence of API calls — each lands, holds pending, then flashes its status pill and latency, resting on the full resolved transcript.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/request-reel.json
Demo · DOMscripted
request log · live
api.example.com8 calls
GET/api/users••• 
POST/api/users••• 
GET/api/users/42••• 
PATCH/api/users/42••• 
GET/api/orders?limit=20••• 
DELETE/api/sessions/x9f2••• 
POST/api/webhooks/stripe••• 
GET/api/reports/q3••• 

RequestReelesc or double-click to exit full screendouble-tap to exit

demo/request-reel.tsx
"use client";

import * as React from "react";
import { cx, useReducedMotion, useScriptedSequence } from "@pouriahlabs/wisp-ui";

/**
 * RequestReel — a scripted HTTP request/response pane.
 *
 * Every API product, gateway and SDK landing page has to show traffic moving,
 * and everyone hand-rolls it. Hand this an array of calls and it plays them:
 * a request line lands, sits *pending* for a beat, then the status pill and
 * latency flash in as the response arrives.
 *
 * The two-beat lifecycle is the reason each call is modelled as **two** scripted
 * steps rather than one — a pending hold, then a settled hold. `shown` from
 * `useScriptedSequence` counts those sub-steps, so a call at row `i` occupies
 * indices `2i` (in flight) and `2i+1` (resolved). A single step per call could
 * only reveal a row; it could never let the reader watch a request hang and then
 * resolve, which is the one thing that makes the pane read as live traffic
 * instead of a static list fading in.
 *
 * Structural rule shared with the rest of the tier: every row is laid out up
 * front at a fixed height and revealed by opacity, and each cell sits in a
 * fixed-width grid column. A pending pill becoming a `200`, an empty latency
 * cell filling with `142 ms` — none of it changes a column's width, so the pane
 * settles its height before the first frame and nothing below it ever reflows.
 *
 * Because the still frame is `steps.length` and the last sub-step of every call
 * is its *resolved* one, the reduced-motion frame — and the resting frame before
 * each loop — is the full transcript with every call showing its final status,
 * never a box caught mid-flight. `useScriptedSequence` gives us that for free,
 * along with pausing off-screen and in hidden tabs.
 *
 * Colour is carried by the semantic direction tokens (`--wisp-up`/`--wisp-down`)
 * plus `--lantern`, applied inline so the component ships no palette of its own
 * and re-tunes with the theme. Status is never colour-only: the numeric code is
 * rendered as text and the pill carries an `aria-label` with the reason phrase,
 * and the method sits beside it as a plain label.
 */

export interface ReeledRequest {
  /** HTTP method, e.g. `GET`, `POST`, `DELETE`. */
  method: string;
  /** Request path, e.g. `/api/users`. */
  path: string;
  /** Response status code, e.g. `200`, `404`, `502`. */
  status: number;
  /** Round-trip latency in ms, shown once the call resolves. */
  latency: number;
}

export interface RequestReelProps extends React.HTMLAttributes<HTMLDivElement> {
  requests?: readonly ReeledRequest[];
  /** Pause before replaying, ms. `0` runs once and rests on the finished transcript. */
  loop?: number;
}

/** How long a call sits pending, and how long its resolved state holds, in ms. */
const PENDING_HOLD = 460;
const SETTLED_HOLD = 820;

export const DEFAULT_REQUESTS: readonly ReeledRequest[] = [
  { method: "GET", path: "/api/users", status: 200, latency: 142 },
  { method: "POST", path: "/api/users", status: 201, latency: 88 },
  { method: "GET", path: "/api/users/42", status: 200, latency: 63 },
  { method: "PATCH", path: "/api/users/42", status: 200, latency: 111 },
  { method: "GET", path: "/api/orders?limit=20", status: 200, latency: 97 },
  { method: "DELETE", path: "/api/sessions/x9f2", status: 204, latency: 54 },
  { method: "POST", path: "/api/webhooks/stripe", status: 502, latency: 1204 },
  { method: "GET", path: "/api/reports/q3", status: 404, latency: 39 },
];

/**
 * Status class as a *direction*, not a brand: a 5xx is a failure, not "off
 * palette". Each maps to a token so a product re-tunes it with the theme.
 */
type Tone = "up" | "warn" | "info" | "down";

function toneOf(status: number): Tone {
  if (status >= 500) return "down"; // server error — red
  if (status >= 400) return "warn"; // client error — amber
  if (status >= 300) return "info"; // redirect — neutral
  return "up"; // 2xx — green
}

const TONE: Record<Tone, string> = {
  up: "var(--wisp-up, 162 72% 52%)",
  down: "var(--wisp-down, 356 64% 60%)",
  warn: "var(--lantern, 42 88% 62%)",
  info: "var(--wisp-ink-faint, 198 9% 44%)",
};

/** The reason phrase behind a code, so a screen reader hears more than a number. */
const REASON: Record<number, string> = {
  200: "OK",
  201: "Created",
  202: "Accepted",
  204: "No Content",
  301: "Moved Permanently",
  302: "Found",
  304: "Not Modified",
  400: "Bad Request",
  401: "Unauthorized",
  403: "Forbidden",
  404: "Not Found",
  409: "Conflict",
  422: "Unprocessable Entity",
  429: "Too Many Requests",
  500: "Internal Server Error",
  502: "Bad Gateway",
  503: "Service Unavailable",
  504: "Gateway Timeout",
};

// One grid template drives the head row and every data row, so the columns line
// up and no cell's content can widen its column mid-play. `minmax(0, 1fr)` lets
// the path ellipsis rather than push the status and latency columns around.
const GRID = "4.7em minmax(0, 1fr) 3.6em 5.4em";

export function RequestReel({
  requests = DEFAULT_REQUESTS,
  loop = 2600,
  className,
  style,
  ...props
}: RequestReelProps) {
  const host = React.useRef<HTMLDivElement>(null);
  const viewport = React.useRef<HTMLDivElement>(null);
  const track = React.useRef<HTMLDivElement>(null);
  const still = useReducedMotion();

  // Two steps per call — pending, then settled — so `shown` can express both
  // beats of a request's life, not just its arrival.
  const timings = React.useMemo(
    () => requests.flatMap(() => [{ hold: PENDING_HOLD }, { hold: SETTLED_HOLD }]),
    [requests],
  );

  const shown = useScriptedSequence(host, { steps: timings, loop });
  const [offset, setOffset] = React.useState(0);

  // The header stays put; only the rows scroll, so the follow measures the
  // newest revealed row against its own clipped viewport rather than the whole
  // pane. A row is "arrived" on its first sub-step, so its index is `shown - 1`
  // halved and floored — the last row with any beat shown.
  const measure = React.useCallback(() => {
    const viewportEl = viewport.current;
    const trackEl = track.current;
    if (!viewportEl || !trackEl) return;

    const arrived = Math.ceil(shown / 2);
    const last = trackEl.children[arrived - 1] as HTMLElement | undefined;
    if (!last) {
      setOffset(0);
      return;
    }
    const bottom = last.offsetTop + last.offsetHeight;
    setOffset(Math.max(0, bottom - viewportEl.clientHeight));
  }, [shown]);

  const latestMeasure = React.useRef(measure);
  latestMeasure.current = measure;

  React.useLayoutEffect(() => {
    measure();
  }, [measure, requests]);

  React.useEffect(() => {
    const viewportEl = viewport.current;
    if (!viewportEl) return;
    const resizeObserver = new ResizeObserver(() => latestMeasure.current());
    resizeObserver.observe(viewportEl);
    return () => resizeObserver.disconnect();
  }, []);

  return (
    <div
      ref={host}
      aria-label="Simulated API request activity"
      className={cx("wisp-req", className)}
      style={{
        display: "flex",
        flexDirection: "column",
        overflow: "hidden",
        fontFamily: "var(--wisp-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
        fontSize: "calc(0.6875rem * var(--wisp-type-scale, 1))",
        lineHeight: 1.5,
        fontVariantNumeric: "tabular-nums",
        color: "var(--wisp-req-muted, currentColor)",
        // Merged last so a caller can hand the pane a bounded height — the
        // full-screen viewer does, which is what lets the rows below scroll
        // rather than grow the box.
        ...style,
      }}
      {...props}
    >
      {/* An endpoint bar with a breathing "live" dot — the difference between a
          pane that is receiving traffic and a screenshot of one. `wisp-pulse` is
          the shared utility, so the reduced-motion contract already stills it. */}
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: "0.5em",
          marginBottom: "0.5rem",
          opacity: 0.75,
        }}
      >
        <span
          className="wisp-pulse"
          aria-hidden
          style={{
            width: "6px",
            height: "6px",
            borderRadius: "50%",
            background: "hsl(var(--wisp-up, 162 72% 52%))",
          }}
        />
        <span style={{ flex: "none" }}>api.example.com</span>
        <span style={{ marginLeft: "auto", opacity: 0.7 }}>{requests.length} calls</span>
      </div>

      {/* Column labels. Fixed to the same grid as the rows so nothing drifts. */}
      <div
        aria-hidden
        style={{
          display: "grid",
          gridTemplateColumns: GRID,
          gap: "0 0.6rem",
          fontSize: "calc(0.5312rem * var(--wisp-type-scale, 1))",
          letterSpacing: "0.13em",
          textTransform: "uppercase",
          opacity: 0.55,
          paddingBottom: "6px",
          marginBottom: "2px",
          borderBottom: "1px solid hsl(var(--wisp-ink-faint, 198 9% 44%) / 0.2)",
        }}
      >
        <span>Method</span>
        <span>Path</span>
        <span>Status</span>
        <span style={{ textAlign: "right" }}>Latency</span>
      </div>

      {/* The rows get their own clipped viewport so a long run — or a full-screen
          viewer's extra calls — scrolls the traffic under the fixed header
          instead of growing the pane. `minHeight: 0` lets this flex child
          actually shrink to the pane so `overflow` has something to clip. */}
      <div
        ref={viewport}
        style={{ position: "relative", flex: "1 1 auto", minHeight: 0, overflow: "hidden" }}
      >
        <div
          ref={track}
          style={{
            transform: offset ? `translateY(${-offset}px)` : undefined,
            transition: still ? "none" : "transform .42s cubic-bezier(.2,.8,.2,1)",
          }}
        >
          {requests.map((req, i) => {
            // Derive both beats from a single counter: the row is in flight the
            // moment its first sub-step lands, and resolved once its second does.
            const revealed = shown >= 2 * i + 1;
            const settled = shown >= 2 * i + 2;
            const channels = TONE[toneOf(req.status)];
            const reason = REASON[req.status] ?? "";

            return (
              <div
                key={`${i}-${req.method}-${req.path}`}
                style={{
                  display: "grid",
                  gridTemplateColumns: GRID,
                  gap: "0 0.6rem",
                  alignItems: "center",
                  minHeight: "1.95em",
                  // Reveal is compositor-only (opacity + a small lift), and off under
                  // reduced motion — where every row is already `revealed` on mount.
                  opacity: revealed ? 1 : 0,
                  transform: revealed ? "none" : "translateY(4px)",
                  transition: still ? "none" : "opacity 0.34s ease, transform 0.34s ease",
                }}
              >
                <span
                  style={{
                    flex: "none",
                    letterSpacing: "0.02em",
                    color: "var(--wisp-req-ink, currentColor)",
                    opacity: 0.95,
                    whiteSpace: "nowrap",
                  }}
                >
                  {req.method}
                </span>
                <span
                  style={{
                    minWidth: 0,
                    overflow: "hidden",
                    textOverflow: "ellipsis",
                    whiteSpace: "nowrap",
                    opacity: 0.8,
                  }}
                >
                  {req.path}
                </span>

                {/* The status cell holds a fixed-metrics pill in both states — a
                pending placeholder and the resolved code are the same box, so
                the flip never nudges the latency column. */}
                <span style={{ justifySelf: "start" }}>
                  {settled ? (
                    // Keyed remount so `wisp-line-in` plays its one-shot pop as the
                    // response lands — the "flash" that says the call resolved.
                    <span
                      key="settled"
                      className="wisp-line-in"
                      aria-label={reason ? `${req.status} ${reason}` : String(req.status)}
                      title={reason ? `${req.status} ${reason}` : String(req.status)}
                      style={pill({
                        color: `hsl(${channels})`,
                        borderColor: `hsl(${channels} / 0.45)`,
                        background: `hsl(${channels} / 0.1)`,
                      })}
                    >
                      {req.status}
                    </span>
                  ) : (
                    <span
                      key="pending"
                      className="wisp-pulse"
                      aria-label="Pending"
                      style={pill({
                        color: "hsl(var(--wisp-ink-faint, 198 9% 44%))",
                        borderColor: "hsl(var(--wisp-ink-faint, 198 9% 44%) / 0.4)",
                        borderStyle: "dashed",
                        background: "transparent",
                        letterSpacing: "0.08em",
                      })}
                    >
                      {"•••"}
                    </span>
                  )}
                </span>

                <span
                  style={{
                    justifySelf: "end",
                    textAlign: "right",
                    opacity: settled ? 0.75 : 0,
                    color: "var(--wisp-req-ink, currentColor)",
                  }}
                >
                  {/* Reserved even when empty — the cell's width comes from the grid,
                  so the value appearing can't shift anything. */}
                  {settled ? `${req.latency} ms` : " "}
                </span>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

/** Shared pill box metrics, so the pending and resolved states never differ in size. */
function pill(extra: React.CSSProperties): React.CSSProperties {
  return {
    display: "inline-flex",
    alignItems: "center",
    padding: "calc(1px * var(--wisp-type-scale, 1)) calc(5px * var(--wisp-type-scale, 1))",
    borderWidth: "1px",
    borderStyle: "solid",
    lineHeight: 1.3,
    ...extra,
  };
}

Certified against the contract