Skip to content

Interface kit

@wamp/ui is the design system the host itself is built from. An extension page that uses it looks like part of the application, follows the user’s theme without any work, and stays correct when the theme changes underneath it. A page that hand-rolls its own styling looks like an embedded website, and breaks the first time someone switches to a light theme.

After this page you know what the kit exports, how to import it, which utility classes are backed by theme tokens, and the three rules that keep a page looking native.

Import it. That is the whole setup.

import { Button, Card, EmptyState, Input } from '@wamp/ui';

There is nothing to install, no stylesheet to import, and no Tailwind config to write. Your extension’s page renders inside the host’s own document, so the tokens and the compiled stylesheet are already there, and the host runs the Tailwind pass over your source with the kit’s preset applied — which is why bg-card and rounded-lg resolve to WAMP’s values rather than stock Tailwind’s.

Icons come from lucide-react, which is available at runtime the same way:

import { Plus, Trash2 } from 'lucide-react';

Import only from this list. Anything else makes the build fail with "X" does not exist in "@wamp/ui".

Group Exports
Buttons and actions Button, IconButton, Chip, SegmentedControl
Inputs Input, Textarea, SearchInput, Label, Checkbox, Switch, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectGroup, SelectLabel, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton
Layout Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Separator, ScrollArea, ScrollBar, Tabs, TabsList, TabsTrigger, TabsContent, Accordion, AccordionItem, AccordionTrigger, AccordionContent, Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption
Overlays Dialog, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, DialogClose, DialogPortal, DialogOverlay, ConfirmDialog, Sheet (+ SheetTrigger, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription, SheetClose, SheetPortal, SheetOverlay), Popover (+ PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose), DropdownMenu (+ 14 parts), Tooltip, TooltipTrigger, TooltipContent, TooltipProvider
Status and feedback Badge, Spinner, Skeleton, Kbd, EmptyState, ErrorState, LoadingCards, LoadingList, SectionLabel, Toast (+ ToastProvider, ToastViewport, ToastTitle, ToastDescription, ToastClose, ToastAction)
App chrome AppShell with AppShell.TitleBar, AppShell.BackButton, AppShell.Title, AppShell.Content
Content Markdown, TextOutput, WebPreview
Chat Chat, ChatThread, ChatComposer, ModelPicker, ProviderIcon
Tool cards ToolCard, ToolCardShell, ToolCardHeader, ToolCardError, ToolBody, ToolDetail, ToolChain, CompactToolRow, StatusIndicator, ExpandableContent, ExpandedToolOverlay, DiffLinesView, ScreenshotCard, ScreenshotImage
Hooks useAISession, useDataQuery, useToggleState, usePageParams
Utilities cn, renderIcon, formatRelative, messageText, preloadHighlightLanguage, buttonVariants, badgeVariants, iconButtonVariants

Names people reach for that are not here: LoadingState, PageHeader, StatCard, NavItem, SectionHeader, FormField. Build those from the primitives above and plain JSX.

cn is the class merger (clsx plus tailwind-merge); use it anywhere you compose conditional classes, so a later class actually overrides an earlier one.

The variant and size values below are the complete sets. A value outside them type-checks in some cases but renders nothing, so copy from here.

Buttonvariant: default, destructive, outline, secondary, ghost, link, success, warning. size: default, md (a synonym for default), sm, xs, lg, icon, icon-sm, icon-xs, menu.

IconButtonvariant: ghost, subtle, outline, primary, destructive. size: sm, md, lg. Always pass aria-label; there is no text to read.

Badgevariant: default, secondary, destructive, outline, success, warning, info.

Input, Textarea, Select — no variants at all. Size and shape come from className. In particular there is no <Input size="sm">.

DialogContent portals itself and renders its own overlay, so do not wrap it in DialogPortal or DialogOverlay. SelectContent does the same.

Every color, radius, shadow, and duration in the kit comes from a CSS custom property that the active theme redefines. You reach them through Tailwind utilities, and the rule is absolute: never write a color literal.

// Correct — follows every theme
<div className="bg-card text-card-foreground border border-border rounded-lg p-4">
// Wrong — unreadable the moment the user picks a light theme
<div style={{ background: '#1f2937', color: '#e5e7eb' }}>
<div className="bg-slate-900 text-slate-100">

The token-backed color utilities:

Utility Use for
bg-background / text-foreground The page surface and its default text
bg-card / text-card-foreground A raised panel, list, or card. bg-surface is an alias of bg-card
bg-popover / text-popover-foreground Floating surfaces
bg-primary / text-primary-foreground The one accent action
bg-secondary / text-secondary-foreground A quiet filled surface
bg-muted / text-muted-foreground De-emphasized surface and secondary text
bg-accent / text-accent-foreground Hover and selected states
border-border, ring-ring Borders and focus rings
bg-destructive / text-destructive-foreground Danger
bg-success, bg-warning, bg-info (+ -foreground) Semantic states
bg-overlay-1, bg-overlay-2, bg-overlay-3 Translucent hover and pressed tints, in increasing strength
text-fg, text-fg-secondary, text-fg-tertiary Foreground at full, 60%, and 45%
bg-chart-1bg-chart-5 Categorical series

The non-color scales are also overridden, and not to Tailwind’s defaults — text-base is 13px, text-sm is 12px, rounded-md is 8px, rounded-lg is 10px. Arriving with stock-Tailwind muscle memory will make a page a size too big. The type scale runs text-3xs, text-2xs, text-xs, text-sm, text-base, text-md, text-lg, text-xl, text-2xl, text-3xl, text-4xl, with the semantic aliases text-heading, text-subheading, text-body, text-caption, text-code. Radii run rounded-xs through rounded-2xl plus rounded-full. Shadows are shadow-surface, shadow-elevated, shadow-overlay, shadow-modal, shadow-glow-accent. Motion is duration-fast (120ms), duration-normal (200ms), duration-slow (300ms), with ease-smooth-out, ease-smooth-in, ease-material.

The user picks a theme, and the host writes it as a data-theme attribute on the document’s root element. There are thirteen built-ins:

dark (the default), charcoal, midnight, daylight, ember, solar, forest, olive, blossom, neon, lavender, crimson, meadow — plus whatever custom themes the user has saved.

Five of them are light themes. That is the practical reason a hardcoded grey is a bug and not a style preference: #1f2937 text is invisible on solar.

Two mechanical consequences:

Never use Tailwind’s dark: variant. It compiles, but it keys off the operating system’s prefers-color-scheme while WAMP themes key off data-theme. It will fire on the wrong signal. The tokens already re-point per theme, so one set of classes covers all thirteen.

Tokens are bare HSL triplets, so inline CSS needs the hsl() wrapper. color: var(--foreground) evaluates to 220 6% 96%, which is not a color, and the declaration is dropped. Write color: hsl(var(--foreground)) — or better, use the utility class.

Your page renders inside a host container that paints --background and carries data-extension-id="<your-extension-id>". That container sits between body and your page, so rules on body or html compile fine and have no visible effect — a successful-looking edit that changes nothing.

To retint your whole app, override tokens on that scope hook:

[data-extension-id="my-crm"] {
--background: 220 12% 8%;
--primary: 265 70% 60%;
}

Everything inside that reads the token follows, including the kit’s components. Override the inputs--background-{h,s,l}, --foreground-{h,s,l}, --primary, --primary-foreground — rather than the derived surfaces, since borders, overlays, and muted text are all computed from those.

A docked page renders at fluid width inside the workspace pane, typically 500–1200px depending on what else the user has open. Never assume a fixed width. Make the root h-full and a flex container:

<div className="h-full flex flex-col bg-background text-foreground">
<header className="flex items-center justify-between border-b border-border p-3">
<h1 className="text-sm font-semibold">My app</h1>
<Button size="sm">Action</Button>
</header>
<ScrollArea className="flex-1">
<div className="p-4 space-y-4">{/* body */}</div>
</ScrollArea>
</div>

A page declared "presentation": "app" takes over the window, and AppShell gives it the matching chrome:

import { AppShell } from '@wamp/ui';
function MyApp() {
return (
<AppShell>
<AppShell.TitleBar>
<AppShell.BackButton />
<AppShell.Title>My app</AppShell.Title>
</AppShell.TitleBar>
<AppShell.Content>{/* body */}</AppShell.Content>
</AppShell>
);
}

AppShell.BackButton returns the user to the previous docked view. Pass onBack to intercept it — for instance to close an open modal first.

ui/index.tsx
import { useState } from 'react';
import {
AppShell,
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
EmptyState,
Input,
Label,
cn,
} from '@wamp/ui';
import { ListChecks, Plus } from 'lucide-react';
type Item = { id: number; title: string; done: boolean };
function ChecklistPage() {
const [items, setItems] = useState<Item[]>([]);
const [adding, setAdding] = useState(false);
const [title, setTitle] = useState('');
const add = () => {
if (!title.trim()) return;
setItems((prev) => [...prev, { id: Date.now(), title: title.trim(), done: false }]);
setTitle('');
setAdding(false);
};
return (
<AppShell>
<AppShell.TitleBar>
<AppShell.BackButton />
<AppShell.Title>
<span className="flex items-center gap-2 text-sm font-semibold">
<ListChecks className="w-4 h-4" />
Checklist
</span>
</AppShell.Title>
<div className="ml-auto">
<Button size="sm" onClick={() => setAdding(true)}>
<Plus className="w-4 h-4 mr-1.5" />
New item
</Button>
</div>
</AppShell.TitleBar>
<AppShell.Content className="overflow-y-auto">
<div className="mx-auto max-w-3xl px-6 py-8">
{items.length === 0 ? (
<EmptyState
icon={<ListChecks className="w-8 h-8" />}
title="Nothing here yet"
description="Add an item to get started."
action={<Button onClick={() => setAdding(true)}>New item</Button>}
/>
) : (
<Card>
<CardHeader>
<CardTitle>{items.filter((i) => !i.done).length} open</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ul className="divide-y divide-border">
{items.map((item) => (
<li
key={item.id}
className="flex items-center gap-3 px-4 py-3 hover:bg-overlay-1 transition-colors"
>
<button
onClick={() =>
setItems((prev) =>
prev.map((i) => (i.id === item.id ? { ...i, done: !i.done } : i)),
)
}
aria-label={item.done ? 'Mark as open' : 'Mark as done'}
>
<Badge variant={item.done ? 'success' : 'outline'}>
{item.done ? 'done' : 'open'}
</Badge>
</button>
<span
className={cn(
'truncate',
item.done && 'text-muted-foreground line-through',
)}
>
{item.title}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
</AppShell.Content>
<Dialog open={adding} onOpenChange={setAdding}>
<DialogContent>
<DialogHeader>
<DialogTitle>New item</DialogTitle>
</DialogHeader>
<div className="space-y-1.5">
<Label htmlFor="item-title">Title</Label>
<Input
id="item-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && add()}
placeholder="What needs doing?"
autoFocus
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setAdding(false)}>
Cancel
</Button>
<Button onClick={add} disabled={!title.trim()}>
Add
</Button>
</div>
</DialogContent>
</Dialog>
</AppShell>
);
}
export const views = { checklist: ChecklistPage };
export default ChecklistPage;

Every color in it is a token, every control is a kit component, and it renders correctly on all thirteen themes without a single conditional.

  • No color literals — no hex, no rgb(), no bg-slate-*.
  • No invented CSS variables. If var(--color-surface) looks plausible, check the token names above; a name that does not exist fails silently at paint time.
  • No dark: variants.
  • No raw <button>, <input>, or <select> where the kit has a primitive — those are the elements that stop matching the theme first.
  • No body or html rules; use the data-extension-id scope hook.
  • Root is h-full and flexes; nothing assumes a fixed width.
  • Every icon-only control has an aria-label.