Pattern gallery

Claimable peek panel

One shared drawer slot: claiming it always evicts whatever was there, so two peeks can never sit side by side. Six real shapes below, from a one-button retry to a tabbed part preview.

What this solves

Interaction. Without a shared slot, opening a second peek could stack it beside or over the first, leaving the reader unsure which one is live. Claim-and-evict semantics guarantee exactly one occupant, so opening any peek is always safe: it never has to fight another peek for space, no matter how different the two peeks' content is.

Use it when
  • A quick preview that must not compete with another quick preview for the same drawerspeedway's part-type preview peek and its Activity rail, which claim and evict each other through the same PeekPanel shell
  • A default-resting surface that can still be evictedspeedway's Activity rail, the slot's passive tenant until a product or part-type peek claims it
  • A log or status view a user opens mid-task without leaving the pagewalmart-mvp's per-stage job logs, today a modal but an equally natural peek: the surrounding job list stays visible
  • An editor for one field that needs its evidence shown alongside itwalmart-mvp's review-cell editor, which pairs a manual override with the scraped quote it is overriding

Rendered

Fixture data; check both themes.

Claim log

Newest first

Nothing has claimed the slot yet.

patterns/peek-panel/example.tsxCopy this into a fresh route and it renders as above.
"use client";
import { useState, type ReactNode } from "react";
import {
Button,
Card,
CopyButton,
Input,
Markdown,
RenderIcon,
SidePanel,
StatusPill,
Table,
Tabs,
Timestamp,
type IconRender,
type StatusKind,
type TableColumn,
type TabItem,
} from "@versable-git/ui";
// One drawer slot, many claimants: opening a peek evicts whatever held the
// slot, so two sidebars can never sit side by side. Six shapes below, each
// modelled on a real peek in walmart-mvp or speedway, share the same slot
// so the claim/evict/close sequence stays visible instead of implicit.
type Claim = "basic" | "logging" | "data" | "info" | "tabbed" | "form" | null;
function claimLabel(claim: Claim): string {
switch (claim) {
case "basic":
return "Retry panel";
case "logging":
return "Log panel";
case "data":
return "Record panel";
case "info":
return "About panel";
case "tabbed":
return "Preview panel";
case "form":
return "Edit panel";
default:
return "Nothing";
}
}
const TRIGGERS: { claim: Exclude<Claim, null>; label: string; icon: IconRender }[] = [
{ claim: "basic", label: "Retry stage", icon: "Refresh" },
{ claim: "logging", label: "View logs", icon: "Document" },
{ claim: "data", label: "Part record", icon: "Table" },
{ claim: "info", label: "About this slot", icon: "Info" },
{ claim: "tabbed", label: "Preview part", icon: "Image" },
{ claim: "form", label: "Edit a field", icon: "Pencil" },
];
// A part number every shape reuses, the way one real record shows up across
// several peeks in the same app.
const PART_SKU = "BRK-2214";
// Combined title/subtitle node, after speedway/app/components/PeekPanel.tsx:62-77 —
// every real peek composes its own richer header inside SidePanel's title
// slot, since the kit component itself carries no separate subtitle prop.
function PanelTitle({ title, subtitle }: { title: ReactNode; subtitle?: ReactNode }) {
return (
<span className="flex min-w-0 flex-col gap-0.5 leading-tight">
<span className="min-w-0 truncate text-sm font-semibold">{title}</span>
{subtitle != null && (
<span className="text-base-content/65 flex min-w-0 items-center gap-2 text-xs font-normal">{subtitle}</span>
)}
</span>
);
}
// After speedway/app/components/PeekPanel.tsx:62-77 (title, subtitle, one action —
// the shared header every real peek composes, boiled down to its plainest case).
function BasicPanelBody({ onRetry }: { onRetry: () => void }) {
return (
<div className="flex flex-col gap-3 px-4 py-4">
<p className="text-base-content/70 text-sm">
The enrich stage stopped at the image-download step. Retrying resumes from the manufacturer feed —
nothing already matched is lost.
</p>
<Button size="sm" content="Retry now" onClick={onRetry} />
</div>
);
}
type LogLevel = "info" | "warn" | "error";
const LOG_LINES: { level: LogLevel; message: string }[] = [
{ level: "info", message: "Fetched manufacturer spec sheet" },
{ level: "info", message: "Parsed 42 candidate attributes" },
{ level: "warn", message: "Weight has no unit, assumed lb" },
{ level: "info", message: "Matched 38 attributes to schema" },
{ level: "error", message: "Image 3 of 5 failed to download" },
];
// Mirrors STAGE_LOG_LEVEL_KIND in the real modal: level -> StatusKind.
const LOG_LEVEL_KIND: Record<LogLevel, StatusKind> = { info: "neutral", warn: "warn", error: "err" };
const LOG_FLAGGED_COUNT = LOG_LINES.filter((l) => l.level !== "info").length;
// After walmart-mvp/frontend/src/pages/Jobs.tsx:283-357 (StageLogsModal: a status
// summary bar, leveled log lines, a copy affordance, and the "keeps its last N
// lines" caveat — recast here as a peek instead of a modal).
function LoggingPanelBody() {
const copyText = LOG_LINES.map((l) => `[${l.level.toUpperCase()}] ${l.message}`).join("\n");
return (
<div className="flex flex-col gap-3 px-4 py-4">
<div className="flex items-center justify-between gap-2">
<span className="text-base-content/65 flex items-center gap-1.5 text-xs">
<span aria-hidden="true" className="bg-success size-1.5 animate-pulse rounded-full" />
Tailing live
</span>
<CopyButton value={copyText} size="xs" variant="ghost" copyLabel="Copy log" />
</div>
<div className="flex max-h-40 flex-col gap-1.5 overflow-auto">
{LOG_LINES.map((l, i) => (
<div key={i} className="flex items-start gap-2 text-xs">
<StatusPill kind={LOG_LEVEL_KIND[l.level]} size="sm">
{l.level}
</StatusPill>
<span className="text-base-content/70 min-w-0 flex-1 font-mono">{l.message}</span>
</div>
))}
</div>
<span className="text-base-content/45 text-xs">A stage keeps its last 20 log lines.</span>
</div>
);
}
type PartAttribute = { attribute: string; value: string; critical: boolean };
const PART_ATTRIBUTES: PartAttribute[] = [
{ attribute: "Weight", value: "4.2 lb", critical: true },
{ attribute: "Material", value: "Cast iron", critical: false },
{ attribute: "Finish", value: "Powder-coated black", critical: false },
{ attribute: "Compatible years", value: "2016–2022", critical: true },
];
const PART_CRITICAL_COUNT = PART_ATTRIBUTES.filter((a) => a.critical).length;
// Fixture instant, not live data — fixed so the demo renders the same every time.
const PART_UPDATED_ISO = "2026-08-10T09:14:00Z";
// After speedway/app/routes/workspaces/schemas/taxonomy.tsx:154-177 (TypePeekPanel:
// a mono title, a computed "N attributes · M critical" subtitle, a kit Table of
// the record) and speedway/app/components/ProductPeekPanel.tsx:41-49 (mono SKU title).
function DataPanelBody() {
const columns: TableColumn<PartAttribute>[] = [
{ key: "attribute", header: "Attribute" },
{ key: "value", header: "Value" },
{
key: "critical",
header: "",
align: "right",
render: (row) =>
row.critical ? (
<StatusPill kind="warn" size="sm">
critical
</StatusPill>
) : null,
},
];
return (
<div className="flex flex-col gap-3 px-4 py-4">
<div className="flex items-center gap-2">
<StatusPill kind="ok" size="sm">
In stock
</StatusPill>
<Timestamp iso={PART_UPDATED_ISO} className="text-base-content/65 text-xs" />
</div>
<Table<PartAttribute> columns={columns} rows={PART_ATTRIBUTES} rowKey={(row) => row.attribute} density="compact" />
</div>
);
}
// After speedway/app/components/ActivityRail.tsx:66-71 (the empty-state explainer's
// plain, two-line tone — no action, just what this surface is for).
const INFO_CONTENT = `This drawer holds one occupant at a time. Claiming it always evicts whatever was there — two peeks never sit side by side.
Every real caller wraps the kit \`SidePanel\` in an app-level slot so unrelated triggers, like a notification bell, a row preview, or a stage's logs, can all share one drawer without knowing about each other. See the [SidePanel contract](packages/ui/docs/side-panel.md).`;
function InfoPanelBody() {
return (
<div className="px-4 py-4">
<Markdown content={INFO_CONTENT} className="text-sm" animate={false} />
</div>
);
}
type PreviewTab = "overview" | "attributes" | "images";
const PREVIEW_TABS: TabItem<PreviewTab>[] = [
{ value: "overview", label: "Overview" },
{ value: "attributes", label: "Attributes" },
{ value: "images", label: "Images" },
];
const OVERVIEW_FIELDS: { label: string; value: string }[] = [
{ label: "Name", value: "Front brake bracket" },
{ label: "Category", value: "Brakes" },
{ label: "Weight", value: "4.2 lb" },
];
const TAB_ATTRIBUTES = PART_ATTRIBUTES.slice(0, 2);
const PART_IMAGES = ["front.jpg", "mount-detail.jpg", "packaging.jpg"];
// After walmart-mvp/frontend/src/features/parts/PartPreviewModal.tsx:741-856 (a
// TabItem array switching Overview/Attributes/Content/Images sections inside one
// preview shell — trimmed here to three of its four real sections).
function TabbedPanelBody() {
const [tab, setTab] = useState<PreviewTab>("overview");
const attrColumns: TableColumn<PartAttribute>[] = [
{ key: "attribute", header: "Attribute" },
{ key: "value", header: "Value" },
];
return (
<div className="flex flex-col gap-3 px-4 py-3">
<Tabs items={PREVIEW_TABS} value={tab} onChange={setTab} size="sm" />
{tab === "overview" && (
<dl className="flex flex-col gap-2 text-sm">
{OVERVIEW_FIELDS.map((f) => (
<div key={f.label} className="flex items-baseline justify-between gap-3">
<dt className="text-base-content/65 text-xs">{f.label}</dt>
<dd>{f.value}</dd>
</div>
))}
</dl>
)}
{tab === "attributes" && (
<Table<PartAttribute> columns={attrColumns} rows={TAB_ATTRIBUTES} rowKey={(row) => row.attribute} density="compact" />
)}
{tab === "images" && (
<div className="flex flex-wrap gap-3">
{PART_IMAGES.map((name) => (
<div key={name} className="border-base-300 flex w-20 flex-col items-center gap-1 rounded-md border p-2">
<RenderIcon Icon="Image" size={20} className="text-base-content/45" />
<span className="text-base-content/65 w-full truncate text-center text-[10px]">{name}</span>
</div>
))}
</div>
)}
</div>
);
}
const FIELD_NAME = "Weight (lb)";
const FIELD_EVIDENCE = "Net weight: 4.2 lbs (1.9 kg)";
const FIELD_SOURCE = "https://example.com/manufacturer-spec.pdf";
// After walmart-mvp/frontend/src/features/catalog/ReviewTab.tsx:1097-1140 (the
// review-cell editor: a manual value, the scraped evidence quote and source, and
// column-fill actions — a locally named "SidePanel" in that file, not the kit
// component; only the editing shape is borrowed here).
function FormPanelBody() {
const [manual, setManual] = useState("");
const [applied, setApplied] = useState<"cell" | "column" | null>(null);
return (
<div className="flex flex-col gap-4 px-4 py-4">
<Input size="sm" top="Manual value" placeholder="4.2 lb" value={manual} onChange={(e) => setManual(e.target.value)} />
<div className="border-base-300 bg-base-200 flex flex-col gap-1 rounded-md border px-3 py-2 text-xs">
<span className="text-base-content/65">Scraped evidence</span>
<p className="text-base-content/70">{`“${FIELD_EVIDENCE}”`}</p>
<a href={FIELD_SOURCE} target="_blank" rel="noreferrer" className="text-primary w-fit hover:underline">
Source ↗
</a>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" content="Fill this cell" onClick={() => setApplied("cell")} />
<Button size="sm" variant="outline" content="Fill entire column" onClick={() => setApplied("column")} />
</div>
{applied != null && (
<span className="text-success text-xs">
{applied === "cell" ? "Applied to this cell." : "Applied to every empty cell in the column."}
</span>
)}
</div>
);
}
function panelTitle(claim: Claim): ReactNode {
switch (claim) {
case "basic":
return <PanelTitle title="Enrich stage" subtitle={`Part ${PART_SKU}`} />;
case "logging":
return (
<PanelTitle
title="Enrich logs"
subtitle={
<>
<StatusPill kind="ok" size="sm">
done
</StatusPill>
<span>{LOG_LINES.length} examined</span>
{LOG_FLAGGED_COUNT > 0 && <span className="text-warning">{LOG_FLAGGED_COUNT} flagged</span>}
</>
}
/>
);
case "data":
return (
<PanelTitle
title={<span className="font-mono">{PART_SKU}</span>}
subtitle={`${PART_ATTRIBUTES.length} attributes · ${PART_CRITICAL_COUNT} critical`}
/>
);
case "tabbed":
return <PanelTitle title={<span className="font-mono">{PART_SKU}</span>} />;
case "info":
return <PanelTitle title="About the peek slot" />;
case "form":
return <PanelTitle title="Edit field" subtitle={FIELD_NAME} />;
default:
return null;
}
}
export function PeekPanel() {
const [claim, setClaim] = useState<Claim>(null);
const [log, setLog] = useState<string[]>([]);
function claimSlot(next: Exclude<Claim, null>) {
setClaim((prev) => {
if (prev === next) return prev;
const message = prev == null ? `${claimLabel(next)} claimed the slot` : `${claimLabel(next)} evicted ${claimLabel(prev)}`;
setLog((l) => [message, ...l].slice(0, 5));
return next;
});
}
function closeSlot() {
setClaim((prev) => {
if (prev == null) return prev;
setLog((l) => [`${claimLabel(prev)} closed the slot`, ...l].slice(0, 5));
return null;
});
}
function pushLog(message: string) {
setLog((l) => [message, ...l].slice(0, 5));
}
return (
<div className="flex flex-col gap-4">
<div className="border-base-300 bg-base-200 relative h-[440px] overflow-hidden rounded-lg border">
{/* The trigger row keeps clear of the panel's lane, so no trigger ever sits under an open peek. */}
<div className="flex max-w-[calc(100%-21rem)] flex-wrap gap-2 p-4">
{TRIGGERS.map((t) => (
<Button key={t.claim} size="sm" Icon={t.icon} content={t.label} onClick={() => claimSlot(t.claim)} />
))}
<Button size="sm" variant="ghost" content="Close" onClick={closeSlot} disabled={claim == null} />
</div>
<SidePanel open={claim != null} onClose={closeSlot} title={panelTitle(claim)} width={320} className="absolute! inset-y-0 right-0 h-full">
{claim === "basic" && <BasicPanelBody onRetry={() => pushLog(`Retry queued for ${PART_SKU}`)} />}
{claim === "logging" && <LoggingPanelBody />}
{claim === "data" && <DataPanelBody />}
{claim === "tabbed" && <TabbedPanelBody />}
{claim === "info" && <InfoPanelBody />}
{claim === "form" && <FormPanelBody />}
</SidePanel>
</div>
<Card title="Claim log" subtitle="Newest first" noAnimate>
{log.length === 0 ? (
<p className="text-base-content/50 text-sm">Nothing has claimed the slot yet.</p>
) : (
<ul className="flex flex-col gap-1.5">
{log.map((entry, i) => (
<li key={i} className="text-sm">
{entry}
</li>
))}
</ul>
)}
</Card>
</div>
);
}

Where it ships

  • speedway/app/components/PeekPanel.tsx:1-131the shared shell over kit SidePanel; three consumers supply title and body, a slot hook enforces mutual exclusion
  • speedway/app/components/PeekPanel.tsx:62-77the combined title/subtitle header every real peek composes inside SidePanel's title slot, since the kit component has no separate subtitle prop. The source for the basic panel's shape
  • walmart-mvp/frontend/src/pages/Jobs.tsx:283-357StageLogsModal: a status summary bar, leveled log lines, a copy affordance, and the last-20-lines caveat. The source for the logging panel's shape, recast from a modal into a peek
  • speedway/app/routes/workspaces/schemas/taxonomy.tsx:154-177TypePeekPanel: a mono title, a computed "N attributes · M critical" subtitle, a kit Table of the record. The source for the data panel's shape
  • speedway/app/components/ProductPeekPanel.tsx:41-49the mono SKU title every product peek carries
  • speedway/app/components/ActivityRail.tsx:66-71the empty-state explainer's plain, two-line, no-action tone. The source for the informational panel's shape
  • walmart-mvp/frontend/src/features/parts/PartPreviewModal.tsx:741-856a TabItem array switching Overview/Attributes/Content/Images sections inside one preview shell. The source for the tabbed panel's shape, trimmed to three of its four real sections
  • walmart-mvp/frontend/src/features/catalog/ReviewTab.tsx:1097-1140the review-cell editor: a manual value, the scraped evidence quote and source, column-fill actions. A locally named "SidePanel" in that file, not the kit component; only the editing shape is borrowed for the form panel

App-specific: The real shell mounts once in the app shell and hands out the slot through a context hook, so the notification bell and any open peek evict each other; the demo keeps the claim in local state and bounds the panel in a frame. The six triggers below stand in for six different real callers rather than one: a retry action, a stage's logs, a data record, an explainer, a tabbed preview, and a field editor, all still bound to the one slot.

@versable-git/ui · composites proven in the apps