Claimable peek panel
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.
- 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 firstNothing has claimed the slot yet.
"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 exclusionspeedway/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 shapewalmart-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 peekspeedway/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 shapespeedway/app/components/ProductPeekPanel.tsx:41-49the mono SKU title every product peek carriesspeedway/app/components/ActivityRail.tsx:66-71the empty-state explainer's plain, two-line, no-action tone. The source for the informational panel's shapewalmart-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 sectionswalmart-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.