Skip to content

Interface kit

Build extension UI from @wamp/ui components and theme tokens. The host supplies the runtime module; the SDK supplies authoring types. Desktop also exposes composites tied to its own state, so choose shared primitives for Cloud panels.

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';

The table groups the established Desktop authoring surface. The SDK typecheck is authoritative for a particular export; a runtime export alone does not mean it is available to typed extensions. The Cloud host provides shared primitives and Markdown, but not Desktop composites such as ModelPicker, useDataQuery, or usePageParams. An unsupported Cloud import produces a named placeholder rather than the Desktop feature. Portable views can use pluginAPI directly and manage their own React state.

Group Exports
Buttons and actions Button, IconButton, Chip, SegmentedControl
Inputs Input, Textarea, SearchInput, Label, Checkbox, Switch, EffortSlider, 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, ListSkeleton, SectionLabel, Toast (+ ToastProvider, ToastViewport, ToastTitle, ToastDescription, ToastClose, ToastAction)
App chrome AppShell with AppShell.TitleBar, AppShell.Title, AppShell.Content
Content Markdown, TextOutput, WebPreview
AI ModelPicker, ProviderIcon
Tool cards ToolCard, ToolCardHeader, ToolCardError, ToolBody, ToolDetail, ToolChain, CompactToolRow, StatusIndicator, DiffLinesView, ScreenshotCard, ScreenshotImage
Hooks useDataQuery, useToggleState, usePageParams
Utilities cn, renderIcon, formatRelative, 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.

Button — variant: default, destructive, outline, secondary, ghost, link, warning. size: default, md (a synonym for default), sm, xs, lg, icon, icon-sm, icon-xs.

IconButton — variant: ghost, subtle, outline, primary, destructive, solid. size: sm, md, lg, chrome (a pane-header control: the sm box with a smaller 22px hover fill centred in it). Always pass aria-label; there is no text to read.

Badge — variant: default, secondary, destructive, outline, success, warning, info.

Input, Textarea, Select — no variant. Input size is sm (h-7) or md (h-8); the default is sm. Textarea and Select take their size from className.

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-1 … bg-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-ui-micro, text-xs, text-sm, text-base, text-md, text-lg, text-xl, text-2xl, text-3xl, text-4xl, with the semantic roles text-ui-micro, text-ui-caption, text-ui-label, text-ui-body, text-content-body, and text-content-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 eight built-ins:

dark (the default), light, charcoal, paper, midnight, ember, dim, forest — plus whatever custom themes the user has saved.

Two of them are light themes (light, paper). That is the practical reason a hardcoded grey is a bug and not a style preference: #1f2937 text is invisible on paper.

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 every built-in.

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.Title>My app</AppShell.Title>
</AppShell.TitleBar>
<AppShell.Content>{/* body */}</AppShell.Content>
</AppShell>
);
}

Leave an app page with pluginAPI.ui.exitAppMode(), or close the window.

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.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;

This page keeps items in React state; closing it loses the list. Use typed data to persist them. Check the finished layout in both light and dark themes before distributing it.

  • 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.
  • Pages and windows — where a page mounts and what presentation: 'app' changes.
  • Typed data — useDataQuery and the store behind it.
  • Agents and skills — ship a configured agent a page can run with pluginAPI.agents.delegate.