Press the card to open the panel.
"use client";
import { useState } from "react";Installation
$ pnpm dlx shadcn@latest add https://motif-ui.vercel.app/r/skill-detail.json
Usage
import { SkillCard, SkillDetailDialog } from "@/components/agents/skill-detail";
export function Library({ skills }: { skills: Skill[] }) {
const [openId, setOpenId] = useState<string | null>(null);
const open = skills.find((skill) => skill.id === openId);
return (
<>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{skills.map((skill) => (
<SkillCard
cover={skill.cover}
description={skill.summary}
key={skill.id}
onOpen={() => setOpenId(skill.id)}
title={skill.name}
/>
))}
</div>
{open ? (
<SkillDetailDialog
author={open.author}
cover={open.cover}
description={open.description}
files={open.files}
folder={open.slug}
onOpenChange={(next) => setOpenId(next ? open.id : null)}
open
prompts={open.prompts}
renderPreview={(file) => (
<Markdown source={read(open.id, file.path)} />
)}
tags={open.tags}
title={open.name}
/>
) : null}
</>
);
}Card, panel, dialog
The panel is not a modal and the card is not a button the panel knows about: three pieces, three jobs, and you can leave any of them out.
SkillCardis the summary and nothing else — cover, name, one clamped line. It holds no actions of its own, because every action a skill has belongs in the panel, where there is room to explain it. Passhrefinstead ofonOpenand the card navigates to a page of its own rather than opening in place. Its cover is drawn at 16:9 whatever the art is, so a portrait poster loses its top and its tail.SkillDetailis the surface: a rail of identity, examples and description, next to a workspace of files and their contents. It fills whatever box you give it — give it a height and it behaves like a sheet, leave the height off and it grows with its contents. The cover heads the identity column rather than spanning the panel: it is the only colour in an otherwise monochrome surface, and keeping it a thumbnail spends 63px of height on it instead of 300.SkillDetailDialogis the handful of lines of plumbing: it sizes the panel atmin(46rem, 88dvh)andmax-w-5xl, names the dialog for assistive tech, and wires the panel's close control to closing. Leave it out and the panel sits in a page just as happily.
The dialog leans on the panel rather than the other way around: onClose is dropped from
SkillDetailDialog because the dialog owns it. Everything else passes straight through,
and open works controlled or uncontrolled with defaultOpen, like the panel's own
selection.
The head is two-up
The head is the only part of the panel that is read, and it is two columns: the skill on the left, what it is for on the right. The left column is the identity — cover, name, who made it, tags — and it holds nothing else, because an action wedged under a byline reads as one more line of metadata. The name opens that column rather than the tags: a row of chips is metadata, and metadata does not get the first line of the panel. It also decides where the summary sits — with the name at the top, the paragraph opposite shares its first line and reads as the name's expansion, where a paragraph opposite a row of chips reads as their caption. The paragraph opposite is the summary: the one place in the panel where somebody tells you what the skill does, instead of handing you a file that says it. The action follows that sentence — right-aligned, 16px under the last line — because proximity is how the eye decides what a button belongs to. Pushed to the foot of the column instead, it sat under a paragraph of empty space and belonged to nothing.
Two columns rather than one stack because the two halves answer different questions and
neither of them needs the full width. Stacked, the paragraph sat under the identity, so the
first screen was a cover, a name, a button and a wall of prose before anything else appeared,
and the right half of a 64rem panel stayed empty the whole way down. Now the identity column
is a fixed 24rem, the prose takes what is left and is held to a max-w-2xl measure — a
paragraph is not more readable at 1000px — and the examples get the full width underneath
both.
The split happens at 48rem of the panel's own width, with a container query rather than a viewport one, and below it the two stack identity-first, which is the only order that fits in a column. The same panel is a dialog here, a sheet there, a route of its own somewhere else; only its own box knows whether there is room.
The cover is a block, not a thumbnail: it fills the height of the head, so the left edge of the panel is one flush rectangle instead of a picture floating in a corner with a byline running past it. That means the art is cropped to the block's height rather than letterboxed — a cover that keeps its own ratio cannot also line up with a block whose height comes from the text beside it. Ship two crops of the art: 16:9 for the card, portrait for the panel.
Two more things keep the head short: the toolbar is a header rather than a sticky bar over the body, and the examples are one ruled strip of three cells rather than three stacked rows or three cards.
One gutter, and no grid the panel cannot keep
Every line in the panel starts on the same 24px gutter: the cover, the tags, the heading, the section label, the example cells, and the first level of the file tree — the tree's pane is inset 16px and its rows carry the last 8px, so a folder's chevron lands on the same edge as the heading above it. A panel whose left edge zigzags by four pixels reads as unfinished even when nobody can say why.
The example strip has no vertical rules. It cannot: three columns and a two-column head have no common divisor, so a rule under one would never continue the rule above it — and a divider that lines up with nothing is a grid claim the layout cannot keep. The cells are separated by the head's own 40px gap instead, which still ties the two bands together without pretending they share columns.
Examples, not example cards
An example prompt is a line you might have typed, so it is drawn as one rather than boxed like a card: a number to scan by, two clamped lines of text, and the whole cell as the button. Three of them sit across the panel under a single rule, which is the shape that matches the content — each example is a line, and a line does not need its own bordered panel to be understood. The arrow is there at rest, dimmed, because a hit target that only admits it is one under a pointer does not admit it at all on a touch screen.
Side by side also spends the width instead of the height. Stacked, three examples are three rows of mostly empty space and 120px of the panel's first screen; across, they are 60px. Below 48rem of panel width the strip stacks back into rows, which is where a column of lines is the only thing that fits.
The arrow only appears on the cell the pointer is on, and the text lifts to the foreground
with it, so three examples read as things to try rather than three competing panels.
Without onPrompt the cells are not buttons at all and render as plain text, because an
example that cannot be sent anywhere is just documentation.
Files are paths
files is a list of paths — references/api.md — and folders are implied by the
slashes. There is no nested model to build and nothing is sorted: the order you wrote the
paths in is the order the tree draws them, because a skill folder has an intended reading
order and a generic sort (SKILL.md above LICENSE.txt) is a guess.
Pass folder and that name becomes the root row, expanded, with everything nested under
it collapsed to start — a deep tree opens as a short list. The first file is selected
until told otherwise: a panel that opens on an empty preview pane reads as broken rather
than as unselected.
A folder row carries the disclosure chevron and a folder glyph — open while it is open — and a file row carries a glyph picked from its extension. Files keep the chevron's width as an empty slot, so every glyph in a level sits in one column and the labels never stagger.
Selection and expansion are uncontrolled by default. Pass selectedPath and you own the
selection; pass onFileSelect and you get told about it either way.
The preview is yours
The panel hands renderPreview the selected SkillFile and draws whatever comes back, so
a markdown pipeline, a syntax highlighter, a diff or an iframe all sit behind one prop
without the panel knowing what a file is. Omit it and the tree stands on its own as a
read-only manifest, in a single column.
SkillCode is exported for the common case — a labelled, read-only block with a copy
button that confirms in place — and it is deliberately uncoloured. Highlighting a skill's
front matter would mean pulling a highlighter into the bundle to colour four lines that
read fine in the foreground.
<div className="flex flex-col gap-6">
<SkillCode code={frontMatter} language="YAML" />
<Markdown source={body} />
</div>Anatomy
The toolbar is a header, not a sticky bar over the scroller: the body is its own scroll
box, so nothing ever passes underneath the controls and there is no translucent corner to
get wrong. It draws a control only when you pass the handler that makes it do something —
no onClose means no close button, and the panel never offers an action it cannot
perform. menu is where a "…" dropdown goes when you have one.
The tree is one tab stop with a roving focus, and the arrow keys walk it the way a file
tree is expected to: down and up move a row, right opens a folder (or steps into an open
one), left closes it (or steps back out to the folder that owns the row), Home and End
jump to the ends. Enter and Space are the row's own click, so a folder toggles and a file
selects.
Because the tree is drawn flat — one list of rows, indented — a collapsing folder animates
every row it owns at once instead of one nested block, and the levels are named in ARIA
(aria-level, aria-posinset, aria-setsize) rather than implied by real nesting. The
enable switch is a real switch with a label of its own (Enable {title}), not a
decoration beside the heading.
| Region | Prop | Notes |
|---|---|---|
| Card | cover, title, description | The summary: one line, clamped to two, cover at 16:9 |
| Card | onOpen / href | Opens in place, or navigates when the skill has a page |
| Identity | tags, title, author | The heading is the only required prop |
| Identity | cover | Fills the height of the head, cropped; portrait crop |
| Identity | updatedAt | Already formatted — the panel does not touch dates |
| Action | onTry / tryLabel | Closes the summary column, right-aligned; needs a handler |
| Prose | description | The summary opposite the identity, held to a reading measure |
| Prompts | prompts / promptsLabel | Rows become buttons once onPrompt is passed |
| Toolbar | enabled / defaultEnabled | Passing either draws the switch, controlled or not |
| Toolbar | menu | Slot for a "…" dropdown; the panel ships no menu of its own |
| Files | files, folder | Paths in; the tree is built for you |
| Files | renderPreview | The only part of the tree the panel does not draw |
| Dialog | open / onOpenChange | Uncontrolled with defaultOpen, like the panel's selection |
Motion
Expanding a folder is a layout change, so it stays short and eases out, with a fade that keeps rows from landing at full strength while their height is still near zero. A file's contents are a place rather than a direction, so the incoming preview fades instead of sliding in from one side — and only the incoming one animates, so half the duration never lands between the click and the contents.
Under prefers-reduced-motion the rows still open and close, but without the height
animation, and the preview swaps without the fade.
Component source
"use client";
import {
ArrowUpRight,
Check,
ChevronRight,
Copy,
File,
FileCode,
FileText,
Folder,
FolderOpen,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent, ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
import type {
SkillAuthor,
SkillCardProps,
SkillCodeProps,
SkillDetailDialogProps,
SkillDetailProps,
SkillFile,
} from "./types";
export type {
SkillAuthor,
SkillCardProps,
SkillCodeProps,
SkillDetailDialogProps,
SkillDetailProps,
SkillFile,
} from "./types";
/** The toolbar's icon buttons and the copy button on a code block are the same control,
* so they share one plate. */
const ICON_BUTTON =
"grid size-8 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50";
/** The root of the tree is a row of its own, so it needs a key no path can take. */
const ROOT = "";
/** Expanding a folder is a layout change, so it stays short and eases out. The fade
* keeps a row from landing at full strength while its height is still near zero. */
const ROW_SLIDE = { duration: 0.18, ease: EASE_OUT } as const;
/** A file is a place, not a direction: the incoming preview fades rather than sliding in
* from one side, and only the incoming one animates so half the duration never lands
* between the click and the contents. */
const PREVIEW_FADE = { duration: 0.14, ease: EASE_OUT } as const;
const CODE_EXTENSIONS = new Set([
"json",
"js",
"jsx",
"py",
"sh",
"toml",
"ts",
"tsx",
"yaml",
"yml",
]);
interface TreeNode {
children?: TreeNode[];
file?: SkillFile;
label: string;
name: string;
path: string;
}
interface TreeRow {
depth: number;
expandable: boolean;
file?: SkillFile;
key: string;
label: string;
parentKey?: string;
posinset: number;
setsize: number;
}
/** Paths in, tree out: `a/b.md` and `a/c.md` share one `a` folder, and everything stays
* in the order `files` was given — nothing here sorts. */
const buildTree = (files: SkillFile[]) => {
const roots: TreeNode[] = [];
for (const file of files) {
const segments = file.path.split("/").filter(Boolean);
let level = roots;
const walked: string[] = [];
for (const [index, name] of segments.entries()) {
walked.push(name);
const last = index === segments.length - 1;
let node = level.find((candidate) => candidate.name === name);
if (!node) {
node = { label: name, name, path: walked.join("/") };
level.push(node);
}
if (last) {
node.file = file;
node.label = file.label ?? name;
} else {
node.children ??= [];
level = node.children;
}
}
}
return roots;
};
/** Rows in the order they are drawn, which is the order the arrow keys walk them. The
* tree is flattened rather than nested so a collapsing folder can shrink every row it
* owns at once, and the levels are named with `aria-level` instead of real nesting. */
const flatten = (
nodes: TreeNode[],
folder: string | undefined,
expanded: ReadonlySet<string>
): TreeRow[] => {
const rows: TreeRow[] = [];
const walk = (level: TreeNode[], depth: number, parentKey?: string) => {
for (const [index, node] of level.entries()) {
const expandable = Boolean(node.children);
const open = expandable && expanded.has(node.path);
rows.push({
depth,
expandable,
file: node.file,
key: node.path,
label: node.label,
parentKey,
posinset: index + 1,
setsize: level.length,
});
if (open && node.children) {
walk(node.children, depth + 1, node.path);
}
}
};
if (folder === undefined) {
walk(nodes, 0);
return rows;
}
const open = expanded.has(ROOT);
rows.push({
depth: 0,
expandable: true,
key: ROOT,
label: folder,
posinset: 1,
setsize: 1,
});
if (open) {
walk(nodes, 1, ROOT);
}
return rows;
};
const glyphFor = (path: string) => {
const extension = path.split(".").pop()?.toLowerCase() ?? "";
if (CODE_EXTENSIONS.has(extension)) {
return FileCode;
}
return extension === "md" || extension === "txt" ? FileText : File;
};
/** Chevron for a folder, a glyph for a file, and the row's own override above both.
* Files keep the chevron's width so every glyph in a level lines up in one column. */
const RowIcon = ({
expandable,
file,
open,
}: {
expandable: boolean;
file?: SkillFile;
open: boolean;
}) => {
if (expandable) {
return (
<>
<ChevronRight
className={cn(
"size-4 shrink-0 transition-transform duration-150",
open && "rotate-90"
)}
/>
{open ? (
<FolderOpen className="size-4 shrink-0" />
) : (
<Folder className="size-4 shrink-0" />
)}
</>
);
}
if (file?.icon) {
return (
<>
<span className="size-4 shrink-0" />
{file.icon}
</>
);
}
const Glyph = file ? glyphFor(file.path) : undefined;
return (
<>
<span className="size-4 shrink-0" />
{Glyph ? <Glyph className="size-4 shrink-0" /> : null}
</>
);
};
const IconButton = ({
className,
label,
onClick,
children,
}: {
className?: string;
label: string;
onClick?: () => void;
children: ReactNode;
}) => (
<button
aria-label={label}
className={cn(ICON_BUTTON, className)}
onClick={onClick}
type="button"
>
{children}
</button>
);
/** The line under a heading: who made it, when it last moved. A real avatar is drawn
* when there is one and nothing stands in for it when there is not — a letter in a
* rounded square is a logo pretending to be a face. */
const MetaLine = ({
author,
updatedAt,
updatedLabel,
}: {
author?: SkillAuthor;
updatedAt?: string;
updatedLabel: string;
}) => (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted-foreground">
{author ? (
<span className="flex items-center gap-1.5">
{author.avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img
alt=""
className="size-4 shrink-0 rounded-full object-cover"
src={author.avatar}
/>
) : null}
<span className="font-medium text-foreground/80">{author.name}</span>
</span>
) : null}
{author && updatedAt ? <span aria-hidden="true">·</span> : null}
{updatedAt ? (
<span>
{updatedLabel} {updatedAt}
</span>
) : null}
</div>
);
/**
* The cover fills the height of the head rather than sitting at its top corner, so the left
* edge of the panel is one block and nothing has to line up with a thumbnail's bottom.
*
* The height is why the art is cropped rather than letterboxed: a cover that keeps its own
* ratio cannot also be flush with a block whose height comes from the text beside it. Ship a
* portrait crop for this and a 16:9 one for the card — they are different boxes.
*/
const Cover = ({ src }: { src: string }) => (
<div className="w-28 shrink-0 self-start overflow-hidden rounded-lg ring-1 ring-border @3xl:self-stretch">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img alt="" className="size-full object-cover" src={src} />
</div>
);
/** An example is a line you might have typed, not a card: a number to scan by, the text,
* and an arrow that only shows up when the cell is live. Three of them sit across the
* panel as one ruled strip — stacked, they were three rows of mostly empty width. */
const PromptCell = ({
index,
onSelect,
prompt,
}: {
index: number;
onSelect?: (prompt: string) => void;
prompt: string;
}) => {
const body = (
<>
<span className="shrink-0 pt-px font-mono text-[11px] text-muted-foreground/70 tabular-nums">
{String(index + 1).padStart(2, "0")}
</span>
<span className="line-clamp-2 min-w-0 flex-1 text-sm text-pretty text-muted-foreground transition-colors group-hover:text-foreground">
{prompt}
</span>
{onSelect ? (
<ArrowUpRight className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-40 transition-opacity group-hover:opacity-100" />
) : null}
</>
);
if (!onSelect) {
return <div className="flex items-start gap-2.5 py-3">{body}</div>;
}
return (
<button
className="group flex cursor-pointer items-start gap-2.5 py-3 text-left outline-none transition-colors hover:bg-muted/40 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
onClick={() => onSelect(prompt)}
type="button"
>
{body}
</button>
);
};
/**
* The way in: a wide cover, the name, one line of what it does.
*
* A card is the summary and nothing else — it holds no actions of its own, because every
* action a skill has belongs in the panel, where there is room to explain it. Pass `href`
* if the skill lives on a page of its own and the card should navigate; pass `onOpen` if
* it opens in place.
*
* The cover is drawn at 16:9 whatever the art is, so a portrait poster loses its top and
* its tail; ship a wide crop with it when the art carries a title.
*/
export const SkillCard = ({
className,
cover,
description,
href,
onOpen,
title,
}: SkillCardProps) => {
const body = (
<>
{cover ? (
<span className="relative block aspect-video overflow-hidden bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
className="size-full object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:ease-out motion-safe:group-hover:scale-[1.03]"
src={cover}
/>
</span>
) : null}
<span className="flex flex-col gap-1.5 p-4">
<span className="font-medium">{title}</span>
{description ? (
<span className="line-clamp-2 text-sm text-muted-foreground">
{description}
</span>
) : null}
</span>
</>
);
const shell = cn(
"group flex w-full flex-col overflow-hidden rounded-2xl border border-border bg-card text-left outline-none transition-colors hover:bg-muted/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
className
);
if (href) {
return (
<a className={shell} href={href}>
{body}
</a>
);
}
return (
<button
className={cn(shell, "cursor-pointer")}
onClick={onOpen}
type="button"
>
{body}
</button>
);
};
/** A read-only code block with its language in the corner. Kept deliberately uncoloured:
* highlighting would mean a syntax highlighter in the bundle, and a skill's front matter
* is four lines that read fine in the foreground. */
export const SkillCode = ({ className, code, language }: SkillCodeProps) => {
const reduceMotion = useReducedMotion() ?? false;
const [copied, setCopied] = useState(false);
// 0 is the "nothing scheduled" handle; clearing it is a no-op.
const timer = useRef(0);
useEffect(() => () => window.clearTimeout(timer.current), []);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard?.writeText(code);
} catch {
return;
}
setCopied(true);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), 1600);
}, [code]);
const icon = copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
);
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-muted/40",
className
)}
>
<div className="flex items-center justify-between gap-2 py-1.5 pr-1.5 pl-3">
<span className="text-xs font-medium text-muted-foreground">
{language}
</span>
<IconButton
className="size-7"
label={copied ? "Copied" : "Copy code"}
onClick={handleCopy}
>
{reduceMotion ? (
icon
) : (
<motion.span
animate={{ opacity: 1, scale: 1 }}
initial={{ opacity: 0, scale: 0.7 }}
key={copied ? "copied" : "copy"}
transition={SPRING_SWAP}
>
{icon}
</motion.span>
)}
</IconButton>
</div>
<pre className="overflow-x-auto px-3 pb-3 text-xs leading-relaxed">
<code>{code}</code>
</pre>
</div>
);
};
/**
* A skill's detail surface: what it is, and what is inside it.
*
* Two columns above 56rem of the panel's own width — a rail that answers the first
* question and a workspace that answers the second — and one column below it. Nothing
* about the skill is hardcoded beyond that shape: the toolbar draws only the controls you
* pass a handler for, and the file preview is a render prop, so the panel never has to
* know what a `.md` is.
*/
export const SkillDetail = ({
author,
className,
cover,
defaultEnabled,
defaultSelectedPath,
description,
enabled,
files,
folder,
menu,
onClose,
onEnabledChange,
onFileSelect,
onPrompt,
onTry,
prompts,
promptsLabel = "Start with",
renderPreview,
selectedPath: selectedPathProp,
tags,
title,
tryLabel = "Try it",
updatedAt,
updatedLabel = "Updated",
}: SkillDetailProps) => {
const reduceMotion = useReducedMotion() ?? false;
const rowRefs = useRef(new Map<string, HTMLButtonElement>());
const list = useMemo(() => files ?? [], [files]);
const tree = useMemo(() => buildTree(list), [list]);
const [expanded, setExpanded] = useState<ReadonlySet<string>>(
() => new Set([ROOT])
);
const [enabledState, setEnabledState] = useState(defaultEnabled ?? false);
const [selectedState, setSelectedState] = useState(defaultSelectedPath ?? "");
// The first file is selected until told otherwise: a panel that opens with an empty
// preview pane looks broken rather than unselected.
const currentPath =
selectedPathProp ?? (selectedState || list[0]?.path) ?? "";
const currentFile = list.find((file) => file.path === currentPath);
const rows = useMemo(
() => flatten(tree, folder, expanded),
[tree, folder, expanded]
);
const [activeKey, setActiveKey] = useState(currentPath || ROOT);
const showSwitch = enabled !== undefined || defaultEnabled !== undefined;
const isEnabled = enabled ?? enabledState;
const hasPreview = Boolean(renderPreview && currentFile);
const registerRow = useCallback(
(key: string, node: HTMLButtonElement | null) => {
if (node) {
rowRefs.current.set(key, node);
return;
}
rowRefs.current.delete(key);
},
[]
);
const select = useCallback(
(path: string) => {
if (selectedPathProp === undefined) {
setSelectedState(path);
}
onFileSelect?.(path);
},
[onFileSelect, selectedPathProp]
);
const toggle = useCallback((key: string) => {
setExpanded((current) => {
const next = new Set(current);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const focusRow = useCallback((target: TreeRow) => {
setActiveKey(target.key);
rowRefs.current.get(target.key)?.focus();
}, []);
/** The tree is one tab stop with a roving focus, so the arrows walk it: down and up
* move a row at a time, right opens a folder (or steps into it), left closes it (or
* steps back out to the folder that owns the row). A key that moves nothing is left
* to the page rather than swallowed here. */
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
const index = rows.findIndex((row) => row.key === activeKey);
if (index === -1) {
return;
}
const row = rows[index];
let target: TreeRow | undefined;
switch (event.key) {
case "ArrowDown": {
target = rows[index + 1];
break;
}
case "ArrowUp": {
target = rows[index - 1];
break;
}
case "ArrowRight": {
if (row.expandable && !expanded.has(row.key)) {
event.preventDefault();
toggle(row.key);
return;
}
target = rows[index + 1];
break;
}
case "ArrowLeft": {
if (row.expandable && expanded.has(row.key)) {
event.preventDefault();
toggle(row.key);
return;
}
target = rows.find((candidate) => candidate.key === row.parentKey);
break;
}
case "End": {
target = rows.at(-1);
break;
}
case "Home": {
target = rows.at(0);
break;
}
default: {
return;
}
}
if (!target) {
return;
}
event.preventDefault();
focusRow(target);
},
[activeKey, expanded, focusRow, rows, toggle]
);
const setEnabled = (next: boolean) => {
if (enabled === undefined) {
setEnabledState(next);
}
onEnabledChange?.(next);
};
const toolbar = showSwitch || menu || onClose;
const workspace = rows.length > 0 || hasPreview;
return (
<div
className={cn(
// A container, because where this panel breaks into two columns is about the
// panel's own width: the same panel sits in a page, a sheet or a dialog.
"@container relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-2xl border border-border bg-card",
className
)}
>
{toolbar ? (
// A header, not a sticky bar: the body is its own scroll box, so nothing passes
// underneath the controls and there is no translucent corner to get wrong.
<div className="flex shrink-0 items-center justify-end gap-1 px-4 py-3">
{showSwitch ? (
<Switch
aria-label={`Enable ${title}`}
checked={isEnabled}
className="mr-1"
onCheckedChange={setEnabled}
/>
) : null}
{menu}
{onClose ? (
<IconButton label="Close" onClick={onClose}>
<X className="size-4" />
</IconButton>
) : null}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<div
className={cn(
"flex flex-col gap-5 px-6 pb-6",
// The header carries its own padding, so the identity only needs air above
// it when there is no header to sit under.
toolbar ? "pt-1" : "pt-6"
)}
>
{/* Two-up: the skill on the left, what it is for on the right. Below 48rem of
panel width the two stack, identity first, because a column of prose beside a
column of identity needs width that neither of them has on a phone. */}
<div className="flex flex-col gap-5 @3xl:flex-row @3xl:gap-10">
<div className="flex min-w-0 gap-4 @3xl:w-[24rem] @3xl:shrink-0">
{cover ? <Cover src={cover} /> : null}
{/* Name first, metadata after it. The tags used to open the column, which put
a row of chips on the line where the name belongs — and it put the summary
opposite the chips, so it read as their subtitle. With the name at the top,
the paragraph beside it shares its first line and reads as the name's
expansion, which is what it is. */}
<div className="flex min-w-0 flex-col items-start gap-2">
<h2 className="text-xl font-semibold tracking-tight text-balance">
{title}
</h2>
{author || updatedAt ? (
<MetaLine
author={author}
updatedAt={updatedAt}
updatedLabel={updatedLabel}
/>
) : null}
{tags?.length ? (
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span
className="rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
key={tag}
>
{tag}
</span>
))}
</div>
) : null}
</div>
</div>
{description || onTry ? (
// The summary column: the sentence and the one thing to do about it, in that
// order and 16px apart. The action is not pushed to the column's floor — a
// button with a paragraph of empty space above it belongs to nothing, and the
// eye reads proximity as ownership.
<div className="flex min-w-0 flex-1 flex-col items-start gap-4">
{description ? (
<p className="max-w-2xl text-sm text-pretty text-muted-foreground">
{description}
</p>
) : null}
{onTry ? (
<Button className="self-end" onClick={onTry} type="button">
{tryLabel}
</Button>
) : null}
</div>
) : null}
</div>
{prompts?.length ? (
<div className="flex flex-col gap-3">
<h3 className="text-[11px] font-medium tracking-[0.12em] text-muted-foreground uppercase">
{promptsLabel}
</h3>
{/* No vertical rules: a three-column strip cannot line up with a two-column
head, and a rule that lines up with nothing is a grid the panel cannot
keep. The gap is the head's own gap, so the two bands still read as one. */}
<div className="divide-border/60 border-border/60 grid grid-cols-1 divide-y border-t @3xl:grid-cols-3 @3xl:gap-x-10 @3xl:divide-y-0">
{prompts.map((prompt, index) => (
<PromptCell
index={index}
key={prompt}
onSelect={onPrompt}
prompt={prompt}
/>
))}
</div>
</div>
) : null}
</div>
{workspace ? (
<div
className={cn(
"border-t border-border/60",
hasPreview && "@3xl:grid @3xl:grid-cols-[14rem_minmax(0,1fr)]",
"@3xl:min-h-0 @3xl:overflow-hidden"
)}
>
<div
aria-label={folder ?? "Skill files"}
className={cn(
"flex flex-col gap-0.5 p-4",
hasPreview &&
"border-border/60 border-b @3xl:border-r @3xl:border-b-0",
"@3xl:min-h-0 @3xl:overflow-y-auto @3xl:overscroll-contain"
)}
onKeyDown={handleKeyDown}
role="tree"
>
<AnimatePresence initial={false}>
{rows.map((row) => {
const open = row.expandable && expanded.has(row.key);
const selected = !row.expandable && row.key === currentPath;
return (
<motion.div
animate={{ height: "auto", opacity: 1 }}
className="overflow-hidden"
exit={{ height: 0, opacity: 0 }}
initial={reduceMotion ? false : { height: 0, opacity: 0 }}
key={row.key}
role="none"
transition={ROW_SLIDE}
>
<button
aria-expanded={row.expandable ? open : undefined}
aria-level={row.depth + 1}
aria-posinset={row.posinset}
aria-selected={row.expandable ? undefined : selected}
aria-setsize={row.setsize}
className={cn(
// The ring is inset: a row is clipped top and bottom by the
// height its folder animates, and an outside ring would lose
// its two horizontal runs the moment the wrapper clipped.
"flex w-full cursor-pointer items-center gap-1.5 rounded-lg py-1.5 pr-2 text-left text-sm outline-none transition-colors focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
selected
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
onClick={() =>
row.expandable ? toggle(row.key) : select(row.key)
}
onFocus={() => setActiveKey(row.key)}
ref={(node) => registerRow(row.key, node)}
role="treeitem"
style={{ paddingLeft: 8 + row.depth * 14 }}
tabIndex={row.key === activeKey ? 0 : -1}
type="button"
>
<RowIcon
expandable={row.expandable}
file={row.file}
open={open}
/>
<span className="truncate">{row.label}</span>
</button>
</motion.div>
);
})}
</AnimatePresence>
</div>
{hasPreview && renderPreview && currentFile ? (
<div className="min-w-0 p-6 @3xl:min-h-0 @3xl:overflow-y-auto @3xl:overscroll-contain">
{reduceMotion ? (
<div>{renderPreview(currentFile)}</div>
) : (
<motion.div
animate={{ opacity: 1, y: 0 }}
initial={{ opacity: 0, y: 4 }}
key={currentFile.path}
transition={PREVIEW_FADE}
>
{renderPreview(currentFile)}
</motion.div>
)}
</div>
) : null}
</div>
) : null}
</div>
</div>
);
};
/**
* The panel with a dialog around it: the box it is sized into, and the title it is
* announced by.
*
* The panel is not a modal, so this is where modal behaviour is added rather than
* assumed — leave it out and `SkillDetail` sits in the page just as happily. `SkillCard`
* is the other half: the summary that opens this.
*/
export const SkillDetailDialog = ({
className,
contentClassName,
defaultOpen,
onOpenChange,
open: openProp,
title,
...panel
}: SkillDetailDialogProps) => {
const [uncontrolled, setUncontrolled] = useState(defaultOpen ?? false);
const isOpen = openProp ?? uncontrolled;
const setOpen = useCallback(
(next: boolean) => {
if (openProp === undefined) {
setUncontrolled(next);
}
onOpenChange?.(next);
},
[onOpenChange, openProp]
);
return (
<Dialog onOpenChange={setOpen} open={isOpen}>
<DialogContent
aria-describedby={undefined}
className={cn(
// The panel draws its own chrome, so the dialog is only a box: the padding,
// the gap, the background and the default close button all come off. The width
// is what buys the panel its two columns.
"h-[min(46rem,88dvh)] gap-0 overflow-hidden rounded-2xl border-border bg-card p-0 sm:max-w-5xl",
contentClassName
)}
showCloseButton={false}
>
{/* The panel's own heading is the visible one; this names the dialog. */}
<DialogTitle className="sr-only">{title}</DialogTitle>
<SkillDetail
{...panel}
className={cn("h-full", className)}
onClose={() => setOpen(false)}
title={title}
/>
</DialogContent>
</Dialog>
);
};