TypeLog

A scripted terminal that lands lines in sequence, shimmers while it thinks and flashes the beats that carry the story.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/type-log.json
Demo · DOMscripted
$wisp add packet-field
✓resolved registry · 2 files
✓components/wisp/packet-field.tsx
✓lib/wisp/use-canvas-scene.ts
reading design tokens…
✓bound --wisp, --lantern
✓obstacles: 4 boxes measured
✓protected area painted: 0.000%
✓off-screen: raf released
✓dpr clamped to 2
!reduced-motion: still frame verified
✓ready in 412ms

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

demo/type-log.tsx
"use client";

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

/**
 * TypeLog — a scripted terminal.
 *
 * Every dev-tool landing page has to show the product working, and everyone
 * hand-rolls it. Hand this an array of steps and it plays: lines land in
 * sequence, a thinking step shimmers, and the beats that carry the story flash
 * once as they arrive so they read louder than the routine lines around them.
 *
 * Every line is rendered up front and revealed by opacity — the structural
 * rule shared with CodeReel. A pane that appends lines as they land grows
 * while it plays, which pushes whatever sits below it down the page on every
 * step; laying the transcript out first settles the pane's height before the
 * first frame; and the reveal is then pure compositing, not layout.
 *
 * When the transcript outgrows its pane — a long script, or a full-screen
 * viewer playing extra lines through it — the lines ride a track that
 * translates to keep the newest one in view, the same compositor-only follow
 * CodeReel and ChatReel use, so the terminal scrolls past the fold without the
 * pane's height ever moving.
 *
 * Colour comes from custom properties so the component carries no palette of
 * its own; see `.wisp-log` in `wisp-ui/styles.css` for the defaults.
 */

export type TypeLogKind = "command" | "ok" | "thinking" | "warn" | "done" | "plain";

export interface TypeLogStep {
  kind?: TypeLogKind;
  text: string;
  /** Override the default hold for this step, ms. */
  hold?: number;
}

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

const DEFAULT_HOLD: Record<TypeLogKind, number> = {
  command: 900,
  thinking: 1250,
  ok: 480,
  warn: 620,
  done: 700,
  plain: 480,
};

const MARK: Record<TypeLogKind, string> = {
  command: "$",
  ok: "✓",
  thinking: " ",
  warn: "!",
  done: "✓",
  plain: " ",
};

export function TypeLog({ steps, loop = 2400, className, ...props }: TypeLogProps) {
  const host = React.useRef<HTMLDivElement>(null);
  const track = React.useRef<HTMLDivElement>(null);
  const still = useReducedMotion();

  const timings = React.useMemo(
    () => steps.map((step) => ({ hold: step.hold ?? DEFAULT_HOLD[step.kind ?? "plain"] })),
    [steps],
  );

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

  // Follow the newest revealed line past the pane's bottom edge — the same
  // measure ChatReel and CodeReel run. Held in a ref so a resize re-runs the
  // current version without rebuilding the observer on every reveal.
  const measure = React.useCallback(() => {
    const hostEl = host.current;
    const trackEl = track.current;
    if (!hostEl || !trackEl) return;

    const last = trackEl.children[shown - 1] as HTMLElement | undefined;
    if (!last) {
      setOffset(0);
      return;
    }

    const padBottom = Number.parseFloat(getComputedStyle(hostEl).paddingBottom) || 0;
    const bottom = last.offsetTop + last.offsetHeight;
    setOffset(Math.max(0, bottom - (hostEl.clientHeight - padBottom)));
  }, [shown]);

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

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

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

  return (
    <div ref={host} className={cx("wisp-log", offset > 0 && "is-scrolled", className)} {...props}>
      <div
        ref={track}
        className="wisp-log-track"
        style={{
          transform: offset ? `translateY(${-offset}px)` : undefined,
          transition: still ? "none" : "transform .42s cubic-bezier(.2,.8,.2,1)",
        }}
      >
        {steps.map((step, i) => {
          const kind = step.kind ?? "plain";
          return (
            <div
              key={`${i}-${step.text}`}
              className={cx("wisp-log-line", `is-${kind}`, i < shown && "is-shown")}
            >
              <span className="wisp-log-mark">{MARK[kind]}</span>
              <span className="wisp-log-text">{step.text}</span>
              {kind === "command" ? <span className="wisp-caret" /> : null}
            </div>
          );
        })}
      </div>
    </div>
  );
}

Certified against the contract