Skip to main content

ns-ui

Tacho Disc

Today's security activity as one rotating polar disc under a fixed stylus: sign-ins, permission grants and revocations scribed at their true time-of-day angle, radius by category, older days stacked behind as dimmer discs — dragging or arrowing it scrubs a real, bound event list.

Use when a sign-in / key-usage / permission-change log where the reader's actual question is time-of-day shaped ("was last night normal?") and event-level detail at true angular time must survive — a credential-stuffing run needs to visibly read as a dense arc, not get smoothed away. Reach for hero-chart-recorder instead for a decorative ambient strip chart with no real bound list and no per-event semantics; reach for heatmap-calendar-tide instead when you want time-of-day intentionally aggregated away into a coarse tide pattern across many days rather than kept at individual-event resolution for one day.

Install

npx shadcn add https://design.helpmarq.com/r/tacho-disc.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first — paste it in once the chat opens.

Source
registry/core/tacho-disc/component.tsx
"use client";

import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";

// ---------------------------------------------------------------------------
// TachoDisc — a day of security activity (sign-ins, permission grants,
// revocations) drawn as one polar disc: 24 hours mapped to 360 degrees under
// a fixed stylus at 12 o'clock, every event scribed at its true time-of-day
// angle. Radius encodes category (sign-ins outer, grants middle, revocations
// inner, the last drawn as a punched hole rather than a line). Older days sit
// behind as smaller, dimmer discs sharing the same rotation, so the same
// time-of-day lines up radially across nights. A faint hour graduation —
// ticks every hour, numerals at 00/06/12/18 — shares that same rotation
// rather than sitting fixed under the stylus, so a tick always sits at its
// own true hour angle and reads as chart paper under the event ink.
//
// ONE governing scalar drives the whole disc: `discAngle`, the real
// time-of-day angle of "now". Every mark's screen position is its true
// timestamp angle plus that single rotation — nothing is laid out by index,
// so a credential-stuffing run (many failures seconds apart) piles into a
// visibly dense arc instead of being spread evenly like ordinary traffic.
// That's the whole point: even-spacing-by-index would hide exactly the
// pattern this exists to show.
//
// The disc is an index onto a real list, not a decoration standing alone:
// - The event list (time / actor / action / result, all text) is the primary
//   semantic surface. All disc marks are aria-hidden.
// - Hovering or arrow-stepping a list row rotates a faint sector highlight to
//   that event's angle on the disc.
// - The disc itself is role="slider": dragging it (or arrowing it while
//   focused, event-to-event, not minute-by-minute) finds the nearest event
//   and scrolls the list to it, updating aria-valuetext with a live readout.
// - Live-arriving failures draw in with a 90ms scribe and announce through a
//   single coalesced aria-live region ("N failed sign-ins in the last
//   minute"), never one announcement per mark. A revocation is a true hole
//   (background fill + border ring) that appears whole in one frame — no
//   scribe, it isn't an arrival, it's an absence.
// ---------------------------------------------------------------------------

export interface SecurityEvent {
  id: string;
  /** ms since epoch */
  time: number;
  category: "sign-in" | "grant" | "revocation";
  actor: string;
  action: string;
  result: "success" | "failed" | "revoked";
}

export interface TachoDiscProps {
  /** every event in the visible window — today plus however many prior days feed the dimmer stacked discs. */
  events: SecurityEvent[];
  /** how many prior days render as dimmer discs behind today's. Default 2. */
  historyDays?: number;
  /** current instant; defaults to an internally ticking clock. Pass to control/test. */
  now?: number;
  /** accessible label stem, e.g. "Security activity". */
  label?: string;
  className?: string;
}

const DAY_MS = 86_400_000;
const MIN_PER_DAY = 1440;

const BANDS: Record<SecurityEvent["category"], { r: number; seg: number }> = {
  "sign-in": { r: 118, seg: 15 },
  grant: { r: 87, seg: 13 },
  revocation: { r: 55, seg: 0 },
};
const CX = 140;
const CY = 140;
const DISC_R = 132;
const HOLE_R = 5;

// hour graduation — chart paper under the event ink, drawn once at true,
// unrotated hour angles and sharing the same group rotation as every event
// mark (see discAngle below): a tick sits exactly where a same-hour event
// would land, at every rotation, not just when "now" happens to be that
// hour. Faint minor ticks every hour, a slightly longer major tick plus a
// numeral at the four cardinal hours.
const GRAD_R_OUTER = DISC_R;
const GRAD_R_MINOR = DISC_R - 4;
const GRAD_R_MAJOR = DISC_R - 8;
const GRAD_LABEL_R = DISC_R + 11;
const HOUR_TICKS = Array.from({ length: 24 }, (_, h) => ({
  hour: h,
  angle: (h / 24) * 360,
  major: h % 6 === 0,
}));

function clamp(v: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, v));
}

function startOfDay(ms: number): number {
  const d = new Date(ms);
  d.setHours(0, 0, 0, 0);
  return d.getTime();
}

function minutesOfDay(ms: number): number {
  const d = new Date(ms);
  return d.getHours() * 60 + d.getMinutes() + d.getSeconds() / 60;
}

function angleForMs(ms: number): number {
  return (minutesOfDay(ms) / MIN_PER_DAY) * 360;
}

function polar(r: number, angleDeg: number): { x: number; y: number } {
  const rad = (angleDeg * Math.PI) / 180;
  return { x: CX + r * Math.sin(rad), y: CY - r * Math.cos(rad) };
}

function angDist(a: number, b: number): number {
  const d = Math.abs(a - b) % 360;
  return d > 180 ? 360 - d : d;
}

function formatClock(ms: number): string {
  const d = new Date(ms);
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}

function nearbyFailedCount(events: SecurityEvent[], atMinutes: number, windowMin: number): number {
  let n = 0;
  for (const e of events) {
    if (e.result !== "failed") continue;
    if (angDist((minutesOfDay(e.time) / MIN_PER_DAY) * 360, (atMinutes / MIN_PER_DAY) * 360) <= (windowMin / MIN_PER_DAY) * 360) {
      n++;
    }
  }
  return n;
}

// -- draw-in mark for a live-arriving failure: undrawn -> drawn over 90ms,
// via an imperative stroke-dashoffset transition (never React state on the
// per-mark hot path, matching the ref-write idiom used across the registry).
function ScribeLine({
  x1,
  y1,
  x2,
  y2,
  live,
  reduced,
  width,
}: {
  x1: number;
  y1: number;
  x2: number;
  y2: number;
  live: boolean;
  reduced: boolean;
  width: number;
}) {
  const ref = useRef<SVGLineElement | null>(null);
  const scribedRef = useRef(false);
  useEffect(() => {
    const el = ref.current;
    if (!el || !live || reduced || scribedRef.current) return;
    scribedRef.current = true;
    const len = Math.hypot(x2 - x1, y2 - y1) || 1;
    el.style.strokeDasharray = `${len}`;
    el.style.strokeDashoffset = `${len}`;
    const id = requestAnimationFrame(() => {
      el.style.transition = "stroke-dashoffset 90ms ease-out";
      el.style.strokeDashoffset = "0";
    });
    return () => cancelAnimationFrame(id);
  }, [live, reduced, x1, y1, x2, y2]);
  return <line ref={ref} x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--foreground)" strokeWidth={width} strokeLinecap="round" />;
}

export function TachoDisc({ events, historyDays = 2, now: controlledNow, label = "Security activity", className = "" }: TachoDiscProps) {
  const [internalNow, setInternalNow] = useState(() => Date.now());
  const nowMs = controlledNow ?? internalNow;

  const reducedRef = useRef(false);
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    reducedRef.current = mq.matches;
    setReduced(mq.matches);
    const onChange = () => {
      reducedRef.current = mq.matches;
      setReduced(mq.matches);
    };
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  useEffect(() => {
    if (controlledNow !== undefined) return;
    const id = window.setInterval(() => setInternalNow(Date.now()), 60_000);
    return () => window.clearInterval(id);
  }, [controlledNow]);

  const todayStart = useMemo(() => startOfDay(nowMs), [nowMs]);

  // group by day offset (0 = today) once; drawn oldest-first so today paints on top.
  const layers = useMemo(() => {
    const byOffset = new Map<number, SecurityEvent[]>();
    for (const e of events) {
      const offset = Math.floor((todayStart - startOfDay(e.time)) / DAY_MS);
      if (offset < 0 || offset > historyDays) continue;
      const arr = byOffset.get(offset) ?? [];
      arr.push(e);
      byOffset.set(offset, arr);
    }
    const out: { offset: number; events: SecurityEvent[] }[] = [];
    for (let o = historyDays; o >= 0; o--) {
      out.push({ offset: o, events: (byOffset.get(o) ?? []).slice().sort((a, b) => a.time - b.time) });
    }
    return out;
  }, [events, todayStart, historyDays]);

  const todayEvents = layers[layers.length - 1]?.events ?? [];

  // -- shared index: the disc IS the list's index, not a separate widget --
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const [scrubbing, setScrubbing] = useState(false);
  const [tabStop, setTabStop] = useState(0);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const scrollToIndex = useCallback((i: number, immediate = false) => {
    itemRefs.current[i]?.scrollIntoView({
      block: "nearest",
      behavior: reducedRef.current || immediate ? "auto" : "smooth",
    });
  }, []);

  // -- live arrivals: track which ids have been seen so only genuinely new
  // failure marks scribe in; the initial mount never animates. --
  const seenIdsRef = useRef<Set<string> | null>(null);
  const [liveIds, setLiveIds] = useState<Set<string>>(new Set());
  const [liveMessage, setLiveMessage] = useState("");
  const pendingFailuresRef = useRef(0);
  const announceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const announceParityRef = useRef(false);

  useEffect(() => {
    const seen = seenIdsRef.current;
    if (seen === null) {
      seenIdsRef.current = new Set(events.map((e) => e.id));
      return;
    }
    const fresh: string[] = [];
    for (const e of events) {
      if (!seen.has(e.id)) {
        seen.add(e.id);
        fresh.push(e.id);
        if (e.result === "failed") pendingFailuresRef.current += 1;
      }
    }
    if (fresh.length > 0) {
      setLiveIds((prev) => {
        const next = new Set(prev);
        for (const id of fresh) next.add(id);
        return next;
      });
    }
    // clear-and-reschedule: every arrival pushes the window out, so one burst
    // (however long it runs) resolves to exactly one announcement, not one
    // per arrival and not a truncated first window plus a trailing one.
    if (pendingFailuresRef.current > 0) {
      if (announceTimerRef.current) clearTimeout(announceTimerRef.current);
      announceTimerRef.current = setTimeout(() => {
        const n = pendingFailuresRef.current;
        pendingFailuresRef.current = 0;
        announceTimerRef.current = null;
        // a zero-width toggle forces a real text-node change even when the
        // message repeats verbatim across bursts — that's what actually
        // re-triggers the announcement (see autosave-ratchet).
        announceParityRef.current = !announceParityRef.current;
        setLiveMessage(`${n} failed sign-in${n === 1 ? "" : "s"} in the last minute${announceParityRef.current ? "​" : ""}`);
      }, 700);
    }
  }, [events]);

  useEffect(() => {
    return () => {
      if (announceTimerRef.current) clearTimeout(announceTimerRef.current);
    };
  }, []);

  const discAngle = useMemo(() => -angleForMs(nowMs), [nowMs]);

  const activeEvent = activeIndex !== null ? todayEvents[activeIndex] : undefined;

  const failuresLastHour = useMemo(() => nearbyFailedCount(todayEvents, minutesOfDay(nowMs), 60), [todayEvents, nowMs]);

  const valueNow = activeEvent ? Math.round(minutesOfDay(activeEvent.time)) : Math.round(minutesOfDay(nowMs));
  const valueText = activeEvent
    ? scrubbing
      ? `scrubbing ${formatClock(activeEvent.time)}, ${nearbyFailedCount(todayEvents, minutesOfDay(activeEvent.time), 30)} failed sign-ins nearby`
      : `${formatClock(activeEvent.time)}, ${activeEvent.actor}, ${activeEvent.action}, ${activeEvent.result}`
    : `now ${formatClock(nowMs)}, ${failuresLastHour} failed sign-in${failuresLastHour === 1 ? "" : "s"} in the last hour`;

  const setActive = useCallback(
    (i: number | null, scroll = true, immediate = false) => {
      setActiveIndex(i);
      if (i !== null && scroll) scrollToIndex(i, immediate);
    },
    [scrollToIndex]
  );

  const nearestIndexAtAngle = useCallback(
    (screenAngle: number, radius: number) => {
      if (todayEvents.length === 0) return null;
      const trueAngle = (((screenAngle - discAngle) % 360) + 360) % 360;
      let bestCategory: SecurityEvent["category"] = "sign-in";
      let bestDr = Infinity;
      (Object.keys(BANDS) as SecurityEvent["category"][]).forEach((cat) => {
        const dr = Math.abs(radius - BANDS[cat].r);
        if (dr < bestDr) {
          bestDr = dr;
          bestCategory = cat;
        }
      });
      let candidates = todayEvents.map((e, i) => ({ e, i })).filter(({ e }) => e.category === bestCategory);
      if (candidates.length === 0) candidates = todayEvents.map((e, i) => ({ e, i }));
      let best = candidates[0]!;
      let bestDist = Infinity;
      for (const c of candidates) {
        const d = angDist(angleForMs(c.e.time), trueAngle);
        if (d < bestDist) {
          bestDist = d;
          best = c;
        }
      }
      return best.i;
    },
    [todayEvents, discAngle]
  );

  // The interactive box: role="slider" and every pointer/keyboard handler
  // live on this plain <div>, not the <svg> — a focus-visible outline on an
  // SVG root is unverifiable across browsers, so the SVG below is purely
  // aria-hidden decoration sized to fill this box.
  const discBoxRef = useRef<HTMLDivElement | null>(null);
  const draggingRef = useRef(false);

  const angleFromPointer = (clientX: number, clientY: number) => {
    const rect = discBoxRef.current?.getBoundingClientRect();
    if (!rect) return null;
    const scale = 280 / rect.width;
    const x = (clientX - rect.left) * scale - CX;
    const y = (clientY - rect.top) * scale - CY;
    let deg = (Math.atan2(x, -y) * 180) / Math.PI;
    if (deg < 0) deg += 360;
    return { deg, radius: Math.hypot(x, y) };
  };

  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    discBoxRef.current?.setPointerCapture(e.pointerId);
    draggingRef.current = true;
    setScrubbing(true);
    const p = angleFromPointer(e.clientX, e.clientY);
    if (p) {
      const idx = nearestIndexAtAngle(p.deg, p.radius);
      if (idx !== null) setActive(idx, true, true);
    }
  };
  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    // live hover preview (not just active drags) — the disc is an index, so
    // sweeping it with the pointer should rotate the highlight the same way
    // hovering the list does, even before a drag starts. But a hover sweep
    // (not dragging) must only move the highlight, never re-target the list's
    // scroll position on every ~30Hz sample — only an actual drag scrolls,
    // and it scrolls immediately (no smooth re-target chase mid-gesture).
    const p = angleFromPointer(e.clientX, e.clientY);
    if (p) {
      const idx = nearestIndexAtAngle(p.deg, p.radius);
      if (idx !== null) setActive(idx, draggingRef.current, true);
    }
  };
  const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (!draggingRef.current) return;
    draggingRef.current = false;
    setScrubbing(false);
    discBoxRef.current?.releasePointerCapture(e.pointerId);
  };

  const onDiscKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (todayEvents.length === 0) return;
    const step = (delta: number) => {
      e.preventDefault();
      const base = activeIndex ?? 0;
      const next = clamp(base + delta, 0, todayEvents.length - 1);
      setActive(next);
    };
    switch (e.key) {
      case "ArrowLeft":
      case "ArrowDown":
        step(-1);
        break;
      case "ArrowRight":
      case "ArrowUp":
        step(1);
        break;
      case "Home":
        e.preventDefault();
        setActive(0);
        break;
      case "End":
        e.preventDefault();
        setActive(todayEvents.length - 1);
        break;
      default:
        break;
    }
  };

  const onListKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>, i: number) => {
    const move = (next: number) => {
      e.preventDefault();
      const clamped = clamp(next, 0, todayEvents.length - 1);
      setTabStop(clamped);
      setActiveIndex(clamped);
      itemRefs.current[clamped]?.focus();
    };
    switch (e.key) {
      case "ArrowDown":
        move(i + 1);
        break;
      case "ArrowUp":
        move(i - 1);
        break;
      case "Home":
        move(0);
        break;
      case "End":
        move(todayEvents.length - 1);
        break;
      default:
        break;
    }
  };

  return (
    <div className={`flex flex-col gap-6 sm:flex-row sm:items-start ${className}`}>
      <style>{CSS}</style>

      <div
        ref={discBoxRef}
        data-tacho-disc=""
        role="slider"
        tabIndex={0}
        aria-label={`${label} disc`}
        aria-valuemin={0}
        aria-valuemax={MIN_PER_DAY - 1}
        aria-valuenow={valueNow}
        aria-valuetext={valueText}
        className="ns-tacho-disc h-64 w-64 shrink-0 touch-none select-none rounded-full focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ns-accent"
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
        onKeyDown={onDiscKeyDown}
      >
      <svg viewBox="-16 -16 312 312" aria-hidden="true" className="block h-full w-full">
        <circle cx={CX} cy={CY} r={DISC_R} fill="none" stroke="var(--border)" strokeWidth={1} aria-hidden="true" />

        {/* fixed stylus at 12 o'clock — outside the rotating group, never moves */}
        <g aria-hidden="true">
          <polygon points={`${CX - 5},4 ${CX + 5},4 ${CX},15`} fill="var(--foreground)" />
          <line x1={CX} y1={15} x2={CX} y2={28} stroke="var(--foreground)" strokeWidth={2} />
        </g>

        {/* the one governing scalar: discAngle. every layer below shares this rotation. */}
        <g transform={`rotate(${discAngle} ${CX} ${CY})`} className={reduced ? "" : "ns-tacho-rotate"} aria-hidden="true">
          {/* hour graduation — chart paper, drawn under the event ink and rotating
              with it so a tick always sits at its own true hour angle. */}
          <g>
            {HOUR_TICKS.map(({ hour, angle, major }) => {
              const inner = polar(major ? GRAD_R_MAJOR : GRAD_R_MINOR, angle);
              const outer = polar(GRAD_R_OUTER, angle);
              return (
                <g key={hour}>
                  <line
                    x1={inner.x}
                    y1={inner.y}
                    x2={outer.x}
                    y2={outer.y}
                    stroke="var(--ns-muted)"
                    strokeWidth={major ? 1 : 0.5}
                    strokeOpacity={major ? 0.6 : 0.35}
                  />
                  {major && (
                    <g transform={`rotate(${-discAngle} ${polar(GRAD_LABEL_R, angle).x} ${polar(GRAD_LABEL_R, angle).y})`}>
                      <text
                        x={polar(GRAD_LABEL_R, angle).x}
                        y={polar(GRAD_LABEL_R, angle).y}
                        textAnchor="middle"
                        dominantBaseline="middle"
                        fill="var(--ns-muted)"
                        fontSize={9}
                        style={{ fontFamily: "var(--font-mono)" }}
                      >
                        {String(hour).padStart(2, "0")}
                      </text>
                    </g>
                  )}
                </g>
              );
            })}
          </g>

          {layers.map(({ offset, events: dayEvents }) => {
            const isToday = offset === 0;
            const layerScale = 1 - offset * 0.13;
            const opacity = isToday ? 1 : Math.max(0.16, 0.5 - offset * 0.15);
            return (
              <g key={offset} opacity={opacity}>
                {dayEvents.map((ev) => {
                  const angle = angleForMs(ev.time);
                  if (ev.category === "revocation") {
                    const r = BANDS.revocation.r * layerScale;
                    const p = polar(r, angle);
                    return (
                      <circle
                        key={ev.id}
                        cx={p.x}
                        cy={p.y}
                        r={HOLE_R * layerScale}
                        fill="var(--background)"
                        stroke="var(--border)"
                        strokeWidth={1.5}
                      />
                    );
                  }
                  const band = BANDS[ev.category];
                  const r = band.r * layerScale;
                  const seg = band.seg * layerScale;
                  const p1 = polar(r - seg / 2, angle);
                  const p2 = polar(r + seg / 2, angle);
                  const failed = ev.result === "failed";
                  if (isToday && failed) {
                    return (
                      <ScribeLine
                        key={ev.id}
                        x1={p1.x}
                        y1={p1.y}
                        x2={p2.x}
                        y2={p2.y}
                        width={8}
                        live={liveIds.has(ev.id)}
                        reduced={reduced}
                      />
                    );
                  }
                  return (
                    <line
                      key={ev.id}
                      x1={p1.x}
                      y1={p1.y}
                      x2={p2.x}
                      y2={p2.y}
                      stroke="var(--foreground)"
                      strokeWidth={failed ? 8 : 3}
                      strokeLinecap="round"
                    />
                  );
                })}
                {isToday && activeEvent && (
                  <line
                    x1={CX}
                    y1={CY}
                    x2={polar(DISC_R - 4, angleForMs(activeEvent.time)).x}
                    y2={polar(DISC_R - 4, angleForMs(activeEvent.time)).y}
                    stroke="var(--ns-accent)"
                    strokeWidth={16}
                    strokeOpacity={0.1}
                    strokeLinecap="round"
                  />
                )}
              </g>
            );
          })}
        </g>
      </svg>
      </div>

      <div className="flex min-w-0 flex-1 flex-col gap-2">
        <div className="flex items-baseline justify-between gap-3">
          <h3 className="text-sm font-medium text-foreground">{label}</h3>
          <span className="font-mono text-xs tabular-nums text-ns-muted">last 24h · now {formatClock(nowMs)}</span>
        </div>

        <ul
          role="list"
          aria-label={`${label} events, last 24 hours`}
          className="ns-tacho-list flex max-h-72 flex-col gap-1 overflow-y-auto rounded-[12px] border border-border bg-background p-1"
        >
          {todayEvents.length === 0 && <li className="px-3 py-4 text-sm text-ns-muted">No activity in the last 24 hours.</li>}
          {todayEvents.map((ev, i) => {
            const isActive = activeIndex === i;
            return (
              <li key={ev.id}>
                <button
                  type="button"
                  ref={(el) => {
                    itemRefs.current[i] = el;
                  }}
                  tabIndex={tabStop === i ? 0 : -1}
                  aria-current={isActive ? "true" : undefined}
                  aria-label={`${formatClock(ev.time)}, ${ev.actor}, ${ev.action}, ${ev.result}`}
                  className={`ns-tacho-row flex w-full items-center gap-3 rounded-[6px] px-2 py-1.5 text-left text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent ${
                    isActive ? "ns-tacho-row-active" : ""
                  }`}
                  onMouseEnter={() => setActive(i)}
                  onFocus={() => {
                    setTabStop(i);
                    setActiveIndex(i);
                  }}
                  onKeyDown={(e) => onListKeyDown(e, i)}
                >
                  <span aria-hidden="true" className="w-3 shrink-0 text-center text-foreground">
                    {isActive ? "›" : ""}
                  </span>
                  <time className={`w-12 shrink-0 font-mono text-xs tabular-nums text-ns-muted ${isActive ? "font-semibold" : ""}`}>
                    {formatClock(ev.time)}
                  </time>
                  <span className={`w-44 shrink-0 truncate text-foreground ${isActive ? "font-semibold" : ""}`}>{ev.actor}</span>
                  <span className="min-w-0 flex-1 truncate text-ns-muted">{ev.action}</span>
                  <span
                    className={`shrink-0 font-mono text-xs uppercase tracking-wide ${
                      ev.result === "failed" ? "text-foreground" : "text-ns-muted"
                    }`}
                  >
                    {ev.result}
                  </span>
                </button>
              </li>
            );
          })}
        </ul>

        <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
          {liveMessage}
        </div>
      </div>
    </div>
  );
}

const CSS = `
.ns-tacho-rotate{ transition: transform 60s linear; }
.ns-tacho-row-active{ background-color: var(--border); }
@media (prefers-reduced-motion: reduce){
  .ns-tacho-rotate{ transition: none !important; }
}
`;
Build spec

Build <TachoDisc events historyDays? now? label? className?> where `events: SecurityEvent[]` is `{ id, time /* ms epoch */, category: "sign-in"|"grant"|"revocation", actor, action, result: "success"|"failed"|"revoked" }[]`. STRUCTURE: a flex row (column on narrow viewports) of an SVG disc on one side and a real event list on the other — the list is the primary semantic surface, the disc is aria-hidden geometry plus one role="slider" control. GEOMETRY / THE ONE GOVERNING SCALAR: 24 hours maps to 360 degrees (angleForMs = minutesOfDay/1440*360, clockwise from 12 o'clock). Compute exactly one rotation value, `discAngle = -angleForMs(now)`, and apply it as a single SVG <g transform="rotate(discAngle cx cy)"> wrapping every day's marks — every mark inside is positioned at its OWN true, unrotated timestamp angle; the shared group rotation is what makes "now" line up under a fixed stylus glyph drawn OUTSIDE that group at 12 o'clock, which itself never moves. This is the falsifiable constraint: marks sit at true angular time on the shared clock. Do NOT lay marks out evenly by array index — a burst of failures seconds apart must pile into a visibly dense arc because it geometrically is one; index-based spacing would hide exactly the pattern the component exists to reveal. HOUR GRADUATION: inside that same rotated group, under every event layer, draw a faint hour ring — a tick at each of the 24 true hour angles, a slightly longer major tick plus a `00`/`06`/`12`/`18` numeral (Geist Mono, var(--ns-muted), counter-rotated in place so the glyph itself stays upright as the disc turns) at the cardinal hours — so the dial reads as a day at a glance instead of a bare fan of event ticks; it shares discAngle rather than sitting fixed under the stylus, because a fixed graduation would drift out of true-hour alignment with the events the moment "now" moved off the hour. It is chart paper: thinner and dimmer than every event mark, never var(--foreground), never mistakable for one. RADIUS BY CATEGORY: sign-ins draw as a radial line segment at the outer radius band, grants at a middle radius band, revocations at the innermost radius but as a punched HOLE — a small circle filled var(--background) with a var(--border) ring stroke, appearing whole in a single frame with no draw-in animation (it is an absence being marked, not an arrival). Ordinary sign-in/grant events (result "success") draw as a 3px hairline (var(--foreground), round cap). Any event with result "failed" draws at 8px stroke width regardless of category. OLDER DAYS: group events by calendar day (0 = today); render days oldest-first inside the SAME rotated group so they share discAngle, each day's own sub-group scaled slightly smaller (~13% per day back, applied to that day's radius numbers, not a DOM-level CSS scale/transform-origin trick) and faded down toward ~0.16 opacity, so older nights sit behind today as visibly smaller, dimmer, concentric discs, with the same time-of-day landing at the same angle across all of them for at-a-glance night-over-night comparison. LIVE ARRIVALS: track which event ids were present at first mount; any id that appears afterward on a TODAY, failed, sign-in/grant mark gets a 90ms stroke-dashoffset draw-in (dasharray = own line length, offset animated length\u21920 via an imperative ref effect on mount of that mark, one real transition, never a CSS keyframe percentage hack) — skip the animation entirely under prefers-reduced-motion, drawing it fully formed instead. Revocations never scribe. THE DISC AS INDEX, NOT DECORATION: keep one shared piece of state, `activeIndex` into today's chronologically-sorted events, plus a transient `scrubbing` boolean. Hovering a list row, or arrow/Home/End-ing within the list (roving tabindex, one row's button has tabIndex 0 at a time, ArrowUp/Down moves both the DOM focus and activeIndex, Home/End jump to first/last), sets activeIndex and — inside the disc's rotated "today" sub-group only — draws a low-opacity var(--ns-accent) wedge (fillOpacity ~0.1, a wide radial line from center outward) at that event's angle: this is the one and only place --ns-accent appears, purely interaction-driven, never a resting/decorative color. The disc SVG itself is role="slider" tabIndex 0, aria-valuemin 0, aria-valuemax 1439 (minutes in a day), aria-valuenow the active (or, if none, "now") event's minute-of-day, aria-valuetext formatted `scrubbing HH:MM, N failed sign-ins nearby` while a pointer drag is live, `HH:MM, actor, action, result` when a row/step is focused but not dragging, or `now HH:MM, N failed sign-ins in the last hour` at rest. ArrowLeft/Down and ArrowRight/Up on the focused disc step activeIndex to the previous/next event chronologically (event-to-event, never a fixed-minute increment) and Home/End jump to the day's first/last event; every activeIndex change (from either the list or the disc) calls scrollIntoView({block:'nearest'}) on the corresponding list row, so scrubbing the disc scrolls the list to the nearest event and vice versa. Dragging directly on the disc (pointerdown/move/up, pointer capture held for the gesture) converts the pointer's angle-from-center back through the current discAngle into a true time-of-day, picks the category band whose radius the pointer is nearest, and sets activeIndex to the angularly-nearest event within that band (falling back to nearest across all categories if that band is empty for the day) — this is a live scrub, not a discrete click. LIVE-FAILURE ANNOUNCEMENT: a visually-hidden role="status" aria-live="polite" aria-atomic="true" node is the ONLY place new failures are spoken. Batch every failed arrival behind a ~700ms debounce and announce once per batch — "4 failed sign-ins in the last minute", pluralized — never one live-region update per mark; this is a hard constraint, not a nicety, since a real credential-stuffing run would otherwise spam a screen reader with dozens of individual announcements. TOKENS: every disc mark, ring, and hole uses var(--foreground)/var(--border)/var(--background) only; var(--ns-accent) is reserved solely for the interaction-driven highlight wedge and the slider/list focus-visible rings — never a resting color, never the failure or revocation encoding itself (severity is stroke weight and hole shape, not color). REDUCED MOTION: the rotating group's 60s linear CSS transition (the mechanism that makes discAngle glide smoothly as the internal clock ticks once a minute) is disabled outright, so the disc jumps to each new minute's angle rather than sweeping — the update cadence itself doesn't need to change, only whether it's animated; the scribe draw-in is likewise skipped, marks simply appear complete. DEMO: three days of baseline sign-ins/grants, today seeded with a tight nine-failure credential-stuffing run (~95s apart) followed by a session-key revocation around 03:10\u201303:27, then a real setInterval appends a fresh 4-failure burst to the SAME today array a beat after mount and again every ~9s, driving the live scribe-in and coalesced announcement through actual state updates rather than a scripted replay.

Props

PropTypeDefaultDescription
eventsSecurityEvent[]every event in the visible window — today plus however many prior days feed the dimmer stacked discs.
historyDays?number2how many prior days render as dimmer discs behind today's. Default 2.
now?numbercurrent instant; defaults to an internally ticking clock. Pass to control/test.
label?string"Security activity"accessible label stem, e.g. "Security activity".
className?string