wings-plugin-ui
v0.11.1
Published
React components and the stylesheet for Wings plugin pages, on Base UI like the Wings app's shadcn/ui components.
Readme
wings-plugin-ui
React components for Wings plugin pages, and the stylesheet Wings draws them with. The components are Base UI's, like the Wings app's own shadcn/ui ones, with shadcn's names and variants. Build and pack the plugin with wings-plugin-sdk.
npm install --save-dev wings-plugin-sdk wings-plugin-ui react react-dom @types/reactwings-plugin-ui lets a page say what it shows and where, and Wings decides how it looks. A page built from
it never touches the DOM, writes HTML or lays itself out with CSS, so when Wings moves or redesigns a place,
your page follows without a release.
// src/lib/messages.ts
import { channel } from "wings-plugin-sdk/messages";
export const ch = channel<{}, {}, { usage: { key: void; value: { percent: number } } }>();// src/pages/usage.tsx
import { useQuery } from "wings-plugin-sdk/react";
import { Card, Gauge, Heading } from "wings-plugin-ui";
import { ch } from "../lib/messages";
export const page = { place: "sidebar" };
export default function Usage() {
// The `usage` query your main script serves on `ch`: null until main has sent it, then each change.
const { data: usage } = useQuery(ch, "usage", undefined);
if (!usage) return null;
return (
<>
<Heading>Usage</Heading>
<Card>
<Gauge label="Today" percent={usage.percent} levels={[75, 90]} />
</Card>
</>
);
}Places
A page in src/pages/ default-exports its component and exports page with the place it shows in, and
wings-plugin build writes the mount: it calls showSidebar, showPopover, showView, showTab, showBlock or
showSettings with <Usage />. showBlock is a view's shell that also keeps a block on a Project page as tall as
the page, by asking Wings for its height with wings.setHeight each time it changes. Each mounts a React element in that place's
shell: Wings' padding, spacing and scrolling for a sidebar, a popover, a view (in a modal or a window), a tab or the
settings page. fill: true in page lays the page out edge to edge with no padding, for a page that scrolls on its
own, like a canvas. The functions are exported too, for a page that mounts itself, with { fill: true } as their
second argument.
You don't write the page's HTML. For src/pages/sidebar.tsx, wings-plugin build writes dist/sidebar.html with
a mount point, its CSS and its script, titled like the manifest's page. A dist/sidebar.html you write yourself
wins: the build only writes a page that's missing or that it wrote before (it carries a
Generated by wings-plugin build line). A popover's page takes its sidebar's title, the name Wings gives the
popover too. A page that builds its DOM itself, without React, gets the same shell
from mountShell(place, options) in wings-plugin-sdk/shell, which returns the element to fill.
Talking to main
A page and your main script talk through a channel from wings-plugin-sdk/messages. Main serves data with
serve, and a page reads it with useQuery(ch, name, key) from wings-plugin-sdk/react, whose data is null until
main has sent it. ask puts a question to main, and useBroadcast(listener) gets every raw wings.broadcast
message.
Page hooks
A page about a project, a tab or a block reads these from wings-plugin-ui. Each follows the wings object, so a
page doesn't wire up onProject or tabState itself.
| Hook | Gives |
|---|---|
| useProject() | The project the page is about: undefined until Wings says, then the project, or null when none is open. Follows wings.onProject |
| ProjectGate | Shows children(project) once Wings has said which project, keyed by its id, so a page that holds state starts over for another project. Shows loading until then and an Empty titled "No project" (or your empty) when none is open |
| useTabState(initial, check), useBlockState(initial, check) | [value, set]: the state saved with wings.setTabState or setBlockState. check turns what was saved, which is any JSON, into the value, or null to use initial. The value is null until it is read. A save that fails leaves the page with the value it set |
| useViewArgs(check) | What wings.openView opened this tab with, read by check, or null. Read once: a tab opened with other args reloads |
| useAction() | { run, busy, error, clear }. run(fn) runs an async action and resolves to whether it worked: busy is true while one goes and error has the message of the last one that threw, for a button's busy and an error Notice |
| usePoll(load, ms) | Calls load on mount and when it changes, if Wings is in front, then every ms while it is, and once when Wings is back. Wrap load in useCallback. For data a query doesn't cover, since a useQuery is refetched by main |
| copyText(text) | Puts text on the clipboard, with a fallback for a frame that refuses the clipboard API |
// src/pages/project.tsx
import { Button, Notice, ProjectGate, useAction, useTabState } from "wings-plugin-ui";
export const page = { place: "tab" };
export default function Project() {
return <ProjectGate>{(project) => <Opened name={project.name} />}</ProjectGate>;
}
function Opened({ name }: { name: string }) {
// Saved with the tab, and back after a restart. `null` until it is read.
const [opened, setOpened] = useTabState(0, (raw) => (typeof raw === "number" ? raw : null));
const { run, busy, error } = useAction();
const open = () => run(async () => {
await wings.openUrl("https://example.com");
setOpened((opened ?? 0) + 1);
});
return (
<>
{error && <Notice tone="danger">{error}</Notice>}
<Button busy={busy} onClick={() => void open()}>
{name}: opened {opened ?? 0} times
</Button>
</>
);
}Components
| Component | Props | Renders |
|---|---|---|
| Row, Stack | gap (none, xs, s, m, l), grow; Row also align, justify, wrap; as (form, ul, ol too) | Children side by side or one under the other; as a ul or ol, a list with no bullets. With as, the props and handlers are the element's: <Row as="form" onSubmit={(e) => e.currentTarget.requestSubmit()}> has a HTMLFormElement |
| Text | tone (muted, info, success, warn, danger), size (small, tiny), weight (medium, strong), mono, numeric, truncate, clamp, grow, as | A span, or the element in as |
| Title | as (h1, h2, h3), size (large), truncate | A page's or a card's title, like a sprint's name or a setup step's question |
| Heading | level (2, 3, 4), actions | A small muted section label, h2 by default, with chips or a button on its right. Level 3 is a section title: 14px, bold, in the text colour |
| Card | compact, as (form, ul, ol too) | A card, a card that's a form, or a list. Typed for its as element like Row and Stack |
| Gauge, Meter | label, percent, note, tone or levels | A labelled bar with its percentage, or the bar alone, with the meter role |
| Stats | items: { label, value, tone? }[] | Small figures side by side, label over value |
| Properties | items: { label, value }[] | Label and value pairs |
| List, ListItem, ListButton | ordered and compact on List, align on the rows | A list of rows; a ListButton in a ListItem makes the row a button |
| Page, Toolbar | Page: toolbar, flush; Toolbar: title, actions, children | A page that fills a tab: a toolbar row (title, then filters in children, actions on the right) over a body that scrolls. flush drops the body's padding and scrolling for a body that lays itself out. Show it with showTab(<Page />, { fill: true }) |
| ListDetail | list, detail, empty, listWidth (px, 280), listLabel, detailLabel, onBack, backLabel | A list pane and a detail pane side by side; empty shows until there is a detail. Under 560px wide it shows one pane, the list or the detail, with a back button when onBack is given |
| Board, BoardColumn | Board: label; BoardColumn: title, count, actions | Columns that share the board's width (each at least --w-board-column-min, 220px unless set) and scroll sideways when narrower, each with a header, a count and a scrolling stack of cards (use Card). The board is a focusable region, so the arrow keys scroll it |
| BoardLanes, BoardLane, BoardCell | BoardLanes: label, columns (id, title, count); BoardLane: label, title, count, actions, open, defaultOpen, onOpenChange; BoardCell: label | A board in rows, like Linear's sub-grouping: the column header shows once, and each lane is a row with a header and a button that folds it, over one BoardCell per column, in the columns' order. The lanes scroll down under the header, and the whole board scrolls sideways when the columns don't fit |
| Split | start, end, direction (horizontal, vertical), defaultSize (px, 280), min (120), max, onResize, label | Two panes with a draggable handle, a separator with aria-valuenow in px. Arrow keys (Shift for bigger steps), Home and End resize it. A drag only changes a CSS variable and onResize(size) runs once when it ends, so save it with wings.setTabState() and pass it back as defaultSize |
| Dot, Spinner | tone, size, label; Dot also color | A status dot or a spinner, announced only with a label. A dot's color comes from your data, like a work item type's #cc293d |
| Avatar | name, size (small, large), label | A round avatar with the initials of a name on a colour that depends only on the name. Decoration next to the name; with a label it's announced |
| Badge | variant (default, secondary, destructive, outline, ghost, link), tone (info, success, warn, danger), size (sm: 18px, for a list row) | A small label, like a command or a branch name, drawn as the app's Badge. tone is Wings' own: a colour that says something |
| Loading, Empty | Empty: title, description | Before the first data (a spinner and the text), and nothing to show |
| Button | variant (default, secondary, outline, ghost, destructive, link), size (default, xs, sm, lg, icon, icon-xs, icon-sm, icon-lg), busy | A button, type="button"; default is the filled one. busy shows a spinner and ignores clicks, keeping focus |
| Chip, Chips | pressed; Chips: label, options, value, onChange | A small quiet button, and a group of them that picks one, with aria-pressed, wrapping onto more lines when narrow. An option's other props, like id, go to its chip |
| Tabs | label, items (value, label, title, disabled), value, onChange, variant (default, a pill in a tray; line, an underline), children | Tabs with arrow keys, Home and End, over their panel |
| Field | label, hint, error, id, inline | A labelled field that names and describes the one input in it (a second input goes beside it: Base UI gives every input in a Field the first one's id). id becomes the input's, for a page that puts focus back by id; inline puts the label beside it |
| FieldGroup | label, hint, error | Several controls under one label, like checkboxes: a fieldset with its legend, described by the hint; an error marks each checkbox invalid |
| TextInput, SearchField, Select | label; Select: options, value, onChange | Inputs Wings styles; outside a Field, label names them |
| Checkbox | checked, onChange, children | A checkbox with its label |
| Combobox | label, value, onInput, options: { value, label, detail? }[], onPick, onEnter, onKeyDown, freeText, openOnFocus, open, filter, empty | A text field with suggestions under it: arrows, Enter, Escape and Tab, and a press outside closes it. With freeText it keeps what you type; otherwise it picks one of options. openOnFocus={false} for a page that puts focus back into it after a render. Without freeText the picked label is selected when the field gets focus and after a pick, so typing replaces it. onEnter(text) runs on Enter when no option is highlighted, with the typed text, and stops the Enter from submitting a form around it. An onKeyDown you pass runs first; e.preventDefault() in it skips the combobox's own keys. ArrowDown on a closed combobox opens it and highlights the first option, as in the WAI-ARIA APG combobox pattern. open="whileText" keeps the list shown for as long as the field has text and an option matches, even after Escape, Tab or a press outside (those only clear the highlighted option) and it closes when the text is emptied or an option is picked; aria-expanded follows whether the list is shown. Accessibility caveat: the APG pattern has Escape dismiss the popup and treats it as shown only while the user is working in the combobox, so this mode departs from it. Use it only where the suggestions are the page's main content, such as a people search that is useful to keep in view, and not for a field where a stray list would cover other controls |
| Menu | label, items: { id, label, onSelect, variant? }[], align, variant, size | A button that opens a menu: arrows, Home, End, Enter, Escape. An item's variant is default or destructive; the menu's own variant and size are the Button's |
| Disclosure | summary | More behind a line that opens it |
| Time, ago | Time: iso, prefix; ago(iso) gives the text | A moment as how long ago it was ("5 min ago", "3 h ago", "2 d ago", then the date) in a time element with the exact date and time on hover |
| Summary, SummaryChip | SummaryChip: tone (info, success, warn, danger), onClick | A row of small facts under a page's header, like "2 approved" or "Priority 1"; with onClick a chip is a button that goes to where the fact comes from |
| Skeletons, Skeleton | Skeletons: label; Skeleton: width, height | Blocks that stand in for loading content, announced once as label with aria-busy; they shimmer unless the user asked for less motion |
| Timeline, TimelineCard, TimelineNote | Timeline: label; TimelineCard: author, at, tag, actions, quiet, clamp; TimelineNote: author, at | A conversation or activity feed: a card per entry with the author's Avatar, name, time, an optional tag and actions, and the body as children (Markdown or your own); quiet for bots and the system; clamp cuts a long body with a "Show more" button that only appears when something was cut; a TimelineNote is a one-line entry like "Ada moved it to Active" |
| Markdown | source, inline | Markdown as React: headings, lists with read-only task checkboxes, GFM tables, fenced code, quotes, rules, <details> with its <summary> as a fold (the only HTML it reads, and only as structure), bold, italics, strikethrough, links and bare https URLs, images, and emoji shortcodes (:white_check_mark:, :x:, :bulb:: the GitHub and Slack names, flags left out; unknown names, and anything inside code, stay as typed). Link reference definitions ([KEY]: value lines) are hidden, as Markdown hides them. No other HTML is ever parsed; only https: links open (through wings.openUrl, so the manifest needs permissions.openUrl with an https:// prefix) and images load only from https: or data:image/ png, jpeg, gif or webp URLs up to 8,000 characters (MAX_DATA_IMAGE); an https: image shows as a "Load image from " button and loads only when clicked (no option to turn that off), because the text may come from an agent and the URL could carry data out, and a data: image shows at once; [](url) is a link with the alt as its text. inline renders just the inline parts in a span. Memoized on source |
Every component passes other props, like id, title, className, onClick and aria-*, to its element.
TextInput and SearchField pass ref to the input on React 19.
A button that's working on something is busy: it shows a spinner, ignores clicks and says so with aria-busy
and aria-disabled, and unlike disabled it keeps the keyboard focus:
// src/ui/save-button.tsx
import { Button } from "wings-plugin-ui";
export function SaveButton({ saving, save }: { saving: boolean; save: () => void }) {
return (
<Button busy={saving} onClick={save}>
Save
</Button>
);
}Ours or your own
The components are there for a page that looks like the rest of Wings without effort, not a rule. Write your
own components next to them, and your own CSS: import it from the page, import "./sidebar.css", and the build
bundles it into sidebar.css and links it from the generated page. Match Wings with its variables, which every
page has:
| Variable | What |
|---|---|
| --wings-fg, --wings-muted | Text, and quieter text |
| --wings-surface, --wings-hover, --wings-pressed, --wings-input | Card, hovered, pressed and input backgrounds |
| --wings-hairline, --wings-hairline-strong | Borders |
| --wings-ring | The focus ring |
| --wings-primary, --wings-primary-fg | The primary button, and its text |
| --wings-info, --wings-success, --wings-warning, --wings-danger | Tones |
| --wings-radius | A card's corners |
| --wings-menu-bg, --wings-menu-ring, --wings-menu-active, --wings-shadow | A raised layer like a menu or a list of suggestions, as Wings' own menus draw it: its opaque background, its edge, the highlighted option and its shadow |
| --wings-font, --wings-mono | Fonts |
The colours, radius and font come from the Wings app's theme (apps/desktop/src/index.css), copied into the generated block at the top of plugin-ui.css by pnpm tokens in apps/desktop; edit the theme there, not the block. The file keeps those values, so the package works on its own.
// src/pages/sidebar.tsx
import "./sidebar.css";
export function Banner({ children }: { children: string }) {
return <div className="banner">{children}</div>;
}/* src/pages/sidebar.css */
.banner {
padding: 8px 10px;
border-radius: var(--wings-radius);
background: color-mix(in srgb, var(--wings-info) 14%, transparent);
color: var(--wings-info);
}Wings' base styles cover plain elements (buttons, inputs, selects and the page's font and colours), the w-*
classes and two older helpers, .muted and .card; nothing else, so your other classes are yours. Your CSS
loads after Wings', so it wins where you style a component, like .my-list .w-list-button { height: 24px; }.
The class contract
The components render plain elements with w-* class names and data attributes, like
<div class="w-card" data-size="compact">, and no styles of their own. Wings styles them in its
plugin-ui.css, which ships with the app, so a Wings update restyles every plugin. The names are a versioned
public contract. Meter sets --w-value, how full it is, and a Dot with a color sets --w-color; both are data
rather than styles.
w-shell with data-place (sidebar, popover, view, tab, settings) and data-fill; w-row,
w-stack with data-gap, data-align, data-justify, data-wrap, data-grow; w-text with data-tone,
data-size, data-weight, data-mono, data-numeric, data-truncate, data-clamp, data-grow;
w-page, w-page-body with data-flush; w-toolbar, w-toolbar-title, w-toolbar-main, w-toolbar-actions; w-list-detail with data-has-detail and --w-list-width, w-list-detail-list, w-list-detail-detail, w-list-detail-body, w-list-detail-back; w-board, w-board-column, w-board-column-header, w-board-column-title, w-board-column-count, w-board-column-actions, w-board-column-body; w-split with data-direction, data-dragging and --w-split-size, w-split-pane, w-split-handle;
w-title with data-size; w-heading, w-heading-row; w-card with data-size; w-gauge, w-gauge-label, w-gauge-value,
w-gauge-note, w-meter with data-tone, w-meter-fill; w-stats, w-stat-label, w-stat-value;
w-props, w-prop, w-prop-label, w-prop-value; w-list with data-density, w-list-item,
w-list-button with data-align; w-dot with data-tone or data-color; w-avatar with data-size and --w-hue; w-badge with data-variant and data-tone; w-spinner with data-size;
w-loading, w-empty, w-empty-detail; w-button with data-variant, data-size, aria-disabled; w-chips, w-chip;
w-tabs with data-variant, w-tab; w-field with data-layout, w-field-row, w-field-label, w-field-hint,
w-field-group, w-input, w-search, w-select, w-check; w-combobox, w-listbox, w-option,
w-option-label, w-option-detail, w-option-empty; w-menu-root, w-menu with data-align, w-menu-item
with data-variant; w-disclosure. A ul or ol with w-row, w-stack or w-card loses its bullets.
Tailwind or any CSS
Wings' styles sit in CSS cascade layers, declared before anything else on the page, in this order:
wings-base, theme, base, wings-components, components, utilities. Any CSS a page brings that isn't in a layer
wins over all of Wings'. Tailwind v4 fits in as it is: @import "tailwindcss"; puts its reset in base, under the
components here, and its utilities in utilities, over them. So a page can use Tailwind for its own parts next to
these components, and both look right. Build Tailwind's CSS with its CLI before wings-plugin build and import
it from the page. Rem units mean 16px, the browser's root size, while the page's text is 13px.
Linked packages
With the packages linked from a local folder (npm link, or link: in package.json) rather than installed,
TypeScript looks for React next to their folder. Point it at yours in tsconfig.json; the build already
does:
"paths": {
"react": ["./node_modules/@types/react"],
"react/*": ["./node_modules/@types/react/*"]
}Icons, notices, confirms and text fields
| Component | Props | Renders |
| --- | --- | --- |
| Icon | name (check, chevron-down, trash), label | One of Lucide's icons in the colour of the text around it. Decoration unless it has a label |
| Textarea | label, rows, the textarea's own props | A multi-line input that joins a Field like TextInput does |
| InlineInput | label, value, required, onSave, onDone, className | A one-line input for editing in place. Enter or leaving saves, Escape cancels. If onSave throws, it stays open with the text and shows the message under it |
| Notice | tone (info, success, warn, danger) | A short tinted message. warn and danger are announced at once |
| Confirm | title, actions (id, label, onConfirm, variant, autoFocus), cancelLabel, onCancel | A card in the page that asks before something is done. If an action throws, it stays open and shows the message above its buttons. The page closes it from inside onConfirm once it worked |
Meter and Gauge also take the success tone. Class names: w-icon, w-textarea (next to w-input), w-inline-input,
w-notice with data-tone, w-confirm.
