LAB/ Tanishk Interface Lab
AgenticstableReactTypeScript

Trace Trail

One line of provenance that opens into the whole action history.

Live preview
DocsIncident 412 — postmortem draft
Autosaved

Summarise incident 412 from my notes and keep the timeline.

Incident 412 started at 08:52 when the upload queue stalled, and cleared at 09:40 once the retry budget was raised. No data was lost; two exports ran about forty minutes late.

This answer’s provenance

Activity

Pick a step to inspect the chain.

TraceExpandedTimestampsSource links

Open the trail to inspect each step

01What it does

Automated work should be able to say where it came from. Collapsed, it is a single breadcrumb — source, transformations, result — with a status dot. Expanded, each step gets an actor, what it did, when, and the source it used, so a person can audit the chain without leaving the sentence they were reading.

In this previewThree traces — a summarise run, an export, and a failed run — with realistic steps. Expand to inspect the chain.

02Install

Registry install

Works with the shadcn CLI in any project that has a components.json. The component lands in components/lab/trace-trail/ with its stylesheet beside it.

npx shadcn@latest add <lab-origin>/r/trace-trail.json

No npm packages are installed — the component is self-contained. Prefer manual copying? Every file is below under Source.

  • components/lab/trace-trail/trace-trail.tsxcomponent
  • components/lab/trace-trail/trace-trail.cssstyles

Machine-readable item: /r/trace-trail.json ↗

03Usage

trace-trail.usage.tsx
const steps = [
{ id: "s1", actor: "You", action: "Asked for a summary", at: "09:12" },
{
id: "s2",
actor: "retriever",
action: "Pulled 4 passages",
detail: "22 documents scanned",
source: { label: "notes/incident-412.md", href: "/files/412" },
at: "09:12",
},
{ id: "s3", actor: "draft-1", action: "Wrote the summary", status: "ok", at: "09:13" },
];

<TraceTrail steps={steps} summary="Asked → retrieved → drafted" />

04Props

Props accepted by Trace Trail
PropTypeDefaultNotes
stepsTraceStep[]—{ id, actor, action, detail?, at?, source?, status } — actor is “you”, a tool name or a model.
summarystring—One-line collapsed text; defaults to the step actions joined by arrows.
defaultOpenbooleanfalseStart expanded.
showTimesbooleantrueShow each step’s timestamp.
onStepSelect(step) => void—Called when a step is focused or activated.

05Accessibility

  • The collapsed line is a button with aria-expanded; the expanded trail is an ordered list, because it is a sequence.
  • Status is written (“Failed at step 3”), not only coloured.
  • Source links are real links with distinguishable names, never icon-only.
  • Timestamps render in title and visually, so they are readable in both modes.

06Source

2 files, 557 lines. Nothing private, no configuration required.

components/lab/trace-trail/trace-trail.tsx— press enter to show or hide the source
tsx
"use client";

import { useId, useState } from "react";
import "./trace-trail.css";

export type TraceStatus = "ok" | "warn" | "failed";

export type TraceStep = {
id: string;
actor: string;
action: string;
detail?: string;
at?: string;
source?: { label: string; href?: string };
status?: TraceStatus;
};

type TraceTrailProps = {
/** The ordered chain: who did what, in order. */
steps: TraceStep[];
/**
* One-line collapsed text. Without it the step actions are joined
* with a quiet arrow, which is the honest default for a trail.
*/
summary?: string;
/** Start expanded. */
defaultOpen?: boolean;
/** Show each step's timestamp. */
showTimes?: boolean;
/** Called when a step's actor + action are activated. */
onStepSelect?: (step: TraceStep) => void;
className?: string;
};

const STATUS_WORD: Record<TraceStatus, string> = {
ok: "ok",
warn: "warning",
failed: "failed",
};

/** failed beats warn beats ok; a missing status counts as ok. */
function worstStatus(steps: TraceStep[]): TraceStatus {
let worst: TraceStatus = "ok";
for (const step of steps) {
const status = step.status ?? "ok";
if (status === "failed") return "failed";
if (status === "warn") worst = "warn";
}
return worst;
}

/**
* TRACE TRAIL — one line of provenance that opens into the chain.
*
* Collapsed it is a single button: a status dot derived from the worst
* step, the actions joined by arrows, a step count and a chevron.
* Expanded it is the ordered list itself — actor, action, detail, time
* and the source each step used. Automated work should be able to say
* where it came from without leaving the sentence being read.
*
* Expansion animates grid-template-rows and is instant under
* prefers-reduced-motion. The closed panel is inert, so its links and
* buttons never take focus while collapsed.
*/
export function TraceTrail({
steps,
summary,
defaultOpen = false,
showTimes = true,
onStepSelect,
className,
}: TraceTrailProps) {
const [open, setOpen] = useState(defaultOpen);
const [lastDefaultOpen, setLastDefaultOpen] = useState(defaultOpen);
const panelId = useId();

// Uncontrolled, but a host that changes defaultOpen (a preview
// control, a reset to defaults) should see the trail follow.
if (defaultOpen !== lastDefaultOpen) {
setLastDefaultOpen(defaultOpen);
setOpen(defaultOpen);
}

if (steps.length === 0) {
return (
<div className={`tt-trail tt-trail-empty${className ? ` ${className}` : ""}`}>
No recorded steps.
</div>
);
}

const worst = worstStatus(steps);
const breadcrumb =
summary ?? steps.map((step) => step.action).join(" → ");

return (
<div className={`tt-trail${className ? ` ${className}` : ""}`} data-open={open}>
<button
type="button"
className="tt-toggle"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((current) => !current)}
>
<span className="tt-dot" data-status={worst} aria-hidden="true" />
<span className="tt-sr">Status: {STATUS_WORD[worst]}.</span>
<span className="tt-summary" title={breadcrumb}>
{breadcrumb}
</span>
<span className="tt-count">
{steps.length} {steps.length === 1 ? "step" : "steps"}
</span>
<svg
className="tt-chevron"
viewBox="0 0 16 16"
width="12"
height="12"
aria-hidden="true"
focusable="false"
>
<path
d="M4 6.25 8 10.25 12 6.25"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>

<div
className="tt-panel"
id={panelId}
data-open={open}
aria-hidden={!open}
inert={!open}
>
<div className="tt-panel-inner">
<ol className="tt-steps">
{steps.map((step) => {
const status = step.status ?? null;
const statusWord =
status === "failed" || status === "warn"
? STATUS_WORD[status]
: null;
const hasMeta = (showTimes && Boolean(step.at)) || Boolean(step.source);

const text = (
<>
<span className="tt-actor">{step.actor}</span>
<span className="tt-action">{step.action}</span>
</>
);

return (
<li className="tt-step" key={step.id} data-status={status ?? "none"}>
<span className="tt-marker" aria-hidden="true" />
<div className="tt-step-body">
<div className="tt-step-line">
{statusWord ? (
<span className="tt-status" data-status={status}>
{statusWord}
</span>
) : null}
{onStepSelect ? (
<button
type="button"
className="tt-step-button"
onClick={() => onStepSelect(step)}
>
{text}
</button>
) : (
<span className="tt-step-text">{text}</span>
)}
</div>

{step.detail ? <p className="tt-detail">{step.detail}</p> : null}

{hasMeta ? (
<p className="tt-meta">
{showTimes && step.at ? (
<span className="tt-time" title={step.at}>
{step.at}
</span>
) : null}
{step.source ? (
step.source.href ? (
<a
className="tt-source"
href={step.source.href}
target="_blank"
rel="noreferrer"
>
{step.source.label}
</a>
) : (
<span className="tt-source tt-source-static">
{step.source.label}
</span>
)
) : null}
</p>
) : null}
</div>
</li>
);
})}
</ol>
</div>
</div>
</div>
);
}
components/lab/trace-trail/trace-trail.css— press enter to show or hide the source
css
/* ============================================================
TRACE TRAIL — self-contained styles.

Every token is scoped to the root with a fallback, so the
component carries its own look into any codebase that has
never heard of this stylesheet.
============================================================ */

.tt-trail {
--tt-ink: #141418;
--tt-muted: #5f5f68;
--tt-faint: #8c8c94;
--tt-line: rgba(20, 20, 24, 0.1);
--tt-line-soft: rgba(20, 20, 24, 0.06);
--tt-rail: rgba(20, 20, 24, 0.18);
--tt-surface: #ffffff;
--tt-surface-hover: rgba(20, 20, 24, 0.035);
--tt-ok: #137a4e;
--tt-warn: #8a5300;
--tt-danger: #b3261e;
--tt-focus: #4f27e0;
--tt-mono: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace;
--tt-ease: cubic-bezier(0.22, 1, 0.36, 1);

border: 1px solid var(--tt-line);
border-radius: 12px;
background: var(--tt-surface);
color: var(--tt-ink);
font-size: 0.8125rem;
line-height: 1.5;
}

.tt-trail,
.tt-trail *,
.tt-trail *::before,
.tt-trail *::after {
box-sizing: border-box;
}

.tt-trail-empty {
padding: 10px 12px;
color: var(--tt-muted);
}

/* ---- the collapsed line ------------------------------------ */

.tt-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
margin: 0;
padding: 9px 11px;
border: 0;
border-radius: 11px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}

.tt-toggle:hover {
background: var(--tt-surface-hover);
}

.tt-toggle:focus-visible {
outline: 2px solid var(--tt-focus);
outline-offset: 2px;
}

.tt-trail[data-open="true"] .tt-toggle {
border-radius: 11px 11px 0 0;
}

.tt-dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--tt-faint);
box-shadow: 0 0 0 3px rgba(20, 20, 24, 0.05);
}

.tt-dot[data-status="ok"] {
background: var(--tt-ok);
box-shadow: 0 0 0 3px rgba(19, 122, 78, 0.12);
}

.tt-dot[data-status="warn"] {
background: var(--tt-warn);
box-shadow: 0 0 0 3px rgba(138, 83, 0, 0.14);
}

.tt-dot[data-status="failed"] {
background: var(--tt-danger);
box-shadow: 0 0 0 3px rgba(179, 38, 30, 0.14);
}

.tt-summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.tt-count {
flex: none;
font-family: var(--tt-mono);
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
color: var(--tt-faint);
white-space: nowrap;
}

.tt-chevron {
flex: none;
color: var(--tt-faint);
transition: transform 200ms var(--tt-ease);
}

.tt-trail[data-open="true"] .tt-chevron {
transform: rotate(180deg);
}

/* ---- the expanded trail ------------------------------------ */

.tt-panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 240ms var(--tt-ease);
}

.tt-panel[data-open="true"] {
grid-template-rows: 1fr;
}

.tt-panel-inner {
min-height: 0;
overflow: hidden;
}

.tt-panel[data-open="true"] .tt-panel-inner {
border-top: 1px solid var(--tt-line-soft);
}

.tt-steps {
margin: 0;
padding: 6px 12px 10px 10px;
list-style: none;
}

.tt-step {
position: relative;
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 10px;
padding: 8px 0 10px;
}

/* the rail: a marker per step, joined downwards to the next one */
.tt-step::before {
content: "";
position: absolute;
left: 8px;
top: 21px;
bottom: -3px;
width: 1px;
background: var(--tt-rail);
}

.tt-step:last-child::before {
display: none;
}

.tt-marker {
position: relative;
z-index: 1;
justify-self: center;
align-self: start;
width: 9px;
height: 9px;
margin-top: 5px;
border: 1px solid var(--tt-rail);
border-radius: 999px;
background: var(--tt-faint);
box-shadow: 0 0 0 2.5px var(--tt-surface);
}

.tt-step[data-status="ok"] .tt-marker {
background: var(--tt-ok);
border-color: var(--tt-ok);
}

.tt-step[data-status="warn"] .tt-marker {
background: var(--tt-warn);
border-color: var(--tt-warn);
}

.tt-step[data-status="failed"] .tt-marker {
background: var(--tt-danger);
border-color: var(--tt-danger);
}

.tt-step-body {
min-width: 0;
}

.tt-step-line {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 7px;
}

.tt-step-text {
min-width: 0;
}

.tt-actor {
font-weight: 600;
}

.tt-status {
flex: none;
padding: 1px 6px;
border: 1px solid currentColor;
border-radius: 999px;
font-family: var(--tt-mono);
font-size: 0.625rem;
line-height: 1.5;
letter-spacing: 0.08em;
text-transform: uppercase;
}

.tt-step[data-status="failed"] .tt-status {
color: var(--tt-danger);
}

.tt-step[data-status="warn"] .tt-status {
color: var(--tt-warn);
}

/* When a host wants steps to act, actor + action become one button. */
.tt-step-button {
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
gap: 5px;
margin: 0;
padding: 0;
border: 0;
border-radius: 4px;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}

.tt-step-button:hover .tt-actor,
.tt-step-button:hover .tt-action {
text-decoration: underline;
text-decoration-color: var(--tt-faint);
text-underline-offset: 2px;
}

.tt-step-button:focus-visible {
outline: 2px solid var(--tt-focus);
outline-offset: 2px;
}

.tt-detail {
margin: 3px 0 0;
color: var(--tt-muted);
overflow-wrap: anywhere;
}

.tt-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 3px 10px;
margin: 4px 0 0;
}

.tt-time {
font-family: var(--tt-mono);
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--tt-faint);
white-space: nowrap;
}

.tt-source {
font-family: var(--tt-mono);
font-size: 0.6875rem;
color: var(--tt-muted);
overflow-wrap: anywhere;
}

a.tt-source {
color: var(--tt-focus);
text-decoration: underline;
text-decoration-color: rgba(79, 39, 224, 0.35);
text-underline-offset: 2px;
}

a.tt-source:hover {
text-decoration-color: currentColor;
}

a.tt-source:focus-visible {
outline: 2px solid var(--tt-focus);
outline-offset: 2px;
border-radius: 2px;
}

/* The component ships its own sr-only, so it never depends on the host. */
.tt-sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}

@media (prefers-reduced-motion: reduce) {
.tt-panel,
.tt-chevron {
transition: none;
}
}