diff --git a/packages/ui/src/components/callout.tsx b/packages/ui/src/components/callout.tsx
index 771d0d8..aab4ebb 100644
--- a/packages/ui/src/components/callout.tsx
+++ b/packages/ui/src/components/callout.tsx
@@ -1,11 +1,12 @@
import * as React from "react";
-import { Info, Lightbulb, TriangleAlert, OctagonAlert } from "lucide-react";
+import { Info, Lightbulb, MessageSquareWarning, TriangleAlert, OctagonAlert } from "lucide-react";
import { cn } from "../lib/utils";
/**
- * Callout — composite. GFM-style admonition matching the Elide docs content
- * (Note / Tip / Important / Warning / Caution). Each tone carries its own hue,
- * left accent rule, and icon. Body accepts arbitrary rich content.
+ * Callout — composite. GitHub-style admonition (Note / Tip / Important /
+ * Warning / Caution): a plain left rule with the icon + label line tinted in
+ * the tone color, no surface fill or outer border. Body accepts arbitrary
+ * rich content and renders in the regular foreground color.
*/
type CalloutTone = "note" | "tip" | "important" | "warning" | "caution";
@@ -15,7 +16,7 @@ const TONES: Record<
> = {
note: { label: "Note", color: "var(--eld-info)", icon: Info },
tip: { label: "Tip", color: "var(--eld-success)", icon: Lightbulb },
- important: { label: "Important", color: "var(--eld-magenta-500)", icon: Info },
+ important: { label: "Important", color: "var(--eld-magenta-500)", icon: MessageSquareWarning },
warning: { label: "Warning", color: "var(--eld-warning)", icon: TriangleAlert },
caution: { label: "Caution", color: "var(--eld-danger)", icon: OctagonAlert },
};
@@ -32,21 +33,19 @@ export function Callout({ tone = "note", title, className, children, ...props }:
return (
-
-
-
{title ?? t.label}
-
- {children}
-
+
+
+ {title ?? t.label}
+
+
+ {children}
);
diff --git a/packages/ui/src/components/command-palette.tsx b/packages/ui/src/components/command-palette.tsx
new file mode 100644
index 0000000..4f9900c
--- /dev/null
+++ b/packages/ui/src/components/command-palette.tsx
@@ -0,0 +1,173 @@
+import * as React from "react";
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
+import { Search, CornerDownLeft } from "lucide-react";
+import { cn } from "../lib/utils";
+
+/**
+ * CommandPalette — the ⌘K spotlight search overlay (direction 1b in the docs
+ * mockups). Built on the Base UI Dialog primitive (the repo's overlay base) and
+ * themed from `@elide/tokens`. Presentational + keyboard-navigable: it filters,
+ * arrow-keys through a flat list, and selects on Enter/click. Wiring a global
+ * ⌘K shortcut is left to the consumer so the primitive stays reusable.
+ */
+export interface CommandItem {
+ id: string;
+ title: React.ReactNode;
+ subtitle?: string;
+ icon?: React.ReactNode;
+ /** Plain text used for filtering (falls back to `title` when it's a string). */
+ keywords?: string;
+ onSelect?: () => void;
+}
+
+export interface CommandGroup {
+ heading: string;
+ items: CommandItem[];
+}
+
+export interface CommandPaletteProps {
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ groups: CommandGroup[];
+ placeholder?: string;
+ emptyMessage?: string;
+ /** Filter items by the query against title/keywords/subtitle. Default true. */
+ filter?: boolean;
+}
+
+const groupHeadingCls =
+ "px-2.5 pt-1.5 pb-1 font-mono text-[10.5px] font-semibold uppercase tracking-[0.08em] text-subtle-foreground";
+
+export function CommandPalette({
+ open,
+ onOpenChange,
+ groups,
+ placeholder = "Search docs, commands, APIs…",
+ emptyMessage = "No results found.",
+ filter = true,
+}: CommandPaletteProps) {
+ const [query, setQuery] = React.useState("");
+
+ const filtered = React.useMemo(() => {
+ if (!filter || !query.trim()) return groups;
+ const q = query.toLowerCase();
+ const match = (i: CommandItem) => {
+ const hay = i.keywords ?? (typeof i.title === "string" ? i.title : "");
+ return `${hay} ${i.subtitle ?? ""}`.toLowerCase().includes(q);
+ };
+ return groups
+ .map((g) => ({ ...g, items: g.items.filter(match) }))
+ .filter((g) => g.items.length > 0);
+ }, [groups, query, filter]);
+
+ // Flatten for keyboard navigation across groups.
+ const flat = React.useMemo(() => filtered.flatMap((g) => g.items), [filtered]);
+ const [active, setActive] = React.useState(0);
+ React.useEffect(() => setActive(0), [query, open]);
+
+ const select = (item: CommandItem | undefined) => {
+ if (!item) return;
+ item.onSelect?.();
+ onOpenChange?.(false);
+ };
+
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ if (flat.length === 0) return;
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setActive((i) => (i + 1) % flat.length);
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setActive((i) => (i - 1 + flat.length) % flat.length);
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ select(flat[active]);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ setQuery(e.target.value)}
+ placeholder={placeholder}
+ aria-label={placeholder}
+ className="flex-1 bg-transparent text-[15px] text-foreground outline-none placeholder:text-subtle-foreground"
+ />
+
+ esc
+
+
+
+
+ {flat.length === 0 ? (
+
+ {emptyMessage}
+
+ ) : (
+ filtered.map((group) => (
+
+
{group.heading}
+ {group.items.map((item) => {
+ const idx = flat.indexOf(item);
+ const isActive = idx === active;
+ return (
+
+ );
+ })}
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 66ee118..009fd88 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -2,6 +2,12 @@ export { Button, buttonVariants, type ButtonProps } from "./components/button";
export { Badge, badgeVariants, type BadgeProps } from "./components/badge";
export { Callout, type CalloutProps } from "./components/callout";
export { CodeBlock, CopyButton, type CodeBlockProps } from "./components/code-block";
+export {
+ CommandPalette,
+ type CommandPaletteProps,
+ type CommandGroup,
+ type CommandItem,
+} from "./components/command-palette";
export { cn } from "./lib/utils";
export { ThemeProvider, useTheme, type Theme, type ThemeProviderProps } from "./components/theme-provider";
export { Breadcrumbs, type BreadcrumbSegment, type BreadcrumbsProps } from "./components/breadcrumbs";
diff --git a/packages/ui/src/stories/CommandPalette.stories.tsx b/packages/ui/src/stories/CommandPalette.stories.tsx
new file mode 100644
index 0000000..b7c6814
--- /dev/null
+++ b/packages/ui/src/stories/CommandPalette.stories.tsx
@@ -0,0 +1,106 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import * as React from "react";
+import { expect, screen, userEvent } from "storybook/test";
+import { FileText, FileCode, Sparkles } from "lucide-react";
+import { CommandPalette, type CommandGroup } from "../components/command-palette";
+
+const groups: CommandGroup[] = [
+ {
+ heading: "Pages",
+ items: [
+ {
+ id: "sqlite-js",
+ title: "SQLite in JavaScript",
+ subtitle: "API Reference · Database Modules",
+ icon:
,
+ keywords: "sqlite javascript database",
+ },
+ {
+ id: "sqlite-host",
+ title: "SQLite host module — elide:sqlite",
+ subtitle: "API Reference · Host APIs",
+ icon:
,
+ keywords: "sqlite host module",
+ },
+ {
+ id: "sqlite-ex",
+ title: "Example: JavaScript with SQLite",
+ subtitle: "Code Samples",
+ icon:
,
+ keywords: "sqlite example javascript",
+ },
+ ],
+ },
+ {
+ heading: "Actions",
+ items: [
+ { id: "ask-ai", title: "Ask AI about “sqlite”", icon:
, keywords: "ask ai sqlite" },
+ ],
+ },
+];
+
+const meta = {
+ title: "Docs/CommandPalette",
+ component: CommandPalette,
+ parameters: { layout: "fullscreen" },
+} satisfies Meta
;
+
+export default meta;
+type Story = StoryObj;
+
+/** Open by default, matching the mockup's ⌘K overlay state. */
+export const Default: Story = {
+ render: () => {
+ const [open, setOpen] = React.useState(true);
+ return (
+
+
+
+
+ );
+ },
+ // a11y regression guard: the overlay must be a labelled dialog and the search
+ // field must have an accessible name (both required for screen-reader use).
+ play: async () => {
+ const dialog = await screen.findByRole("dialog", { name: "Command palette" });
+ await expect(dialog).toBeInTheDocument();
+ const input = screen.getByRole("textbox");
+ await expect(input).toHaveAccessibleName();
+ },
+};
+
+/** Wires a global ⌘K / Ctrl+K listener the way a real app would. */
+export const WithGlobalShortcut: Story = {
+ render: () => {
+ const [open, setOpen] = React.useState(false);
+ React.useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
+ e.preventDefault();
+ setOpen((o) => !o);
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, []);
+ return (
+
+
+ Press ⌘K (or Ctrl+K) to toggle the palette.
+
+
+
+ );
+ },
+ play: async () => {
+ await userEvent.keyboard("{Meta>}k{/Meta}");
+ const dialog = await screen.findByRole("dialog", { name: "Command palette" });
+ await expect(dialog).toBeInTheDocument();
+ },
+};