RFC 0012 — @elyra/ui: desktop components that know the app
Status: implemented (2026-10-31) · Target: 0.21.0 · the guide: docs/ui.md · builds on RFC 0001, RFC 0002, RFC 0005 and RFC 0010 · see Decisions
Summary
Elyra's backend half is complete: typed commands, live queries, Laravel's
validation, abilities, offline, files, an assistant. The screens on top are
still hand-written HTML with a few shared classes (.btn, .card). This RFC
adds a component library for Svelte 5, @elyra/ui, in the spirit of
Laravel's Flux. It has a small, careful API, good defaults, keyboard first,
and a calm look. It is written for desktop apps, and it knows the rest of
Elyra.
<script>
import { live } from "./bindings";
import { Table, Button, Field, Input, StatusBar } from "@elyra/ui";
let query = $state({ search: "", page: 1 });
const customers = $derived(live.customers_index(query));
</script>
<Table page={customers} bind:query columns={["name", "email", "credit"]} sortable>
{#snippet actions(row)}
<Button size="sm" href={`/customers/${row.id}`}>Open</Button>
<Button size="sm" variant="danger" can="customers.delete" confirm="Delete {row.name}?"
onclick={() => api.customers_destroy(row.id)}>Delete</Button>
{/snippet}
</Table>
<StatusBar /> <!-- online / offline, the outbox, transfers, the assistant -->
- Svelte 5 components with no other dependency, themed by one set of design tokens, dark and light.
- They know Elyra.
- A
<Table>takes alive.*store of aPage<T>, the shapemake:resourcelists already use. - A
<Field>shows Laravel's422andValidationErrorsper field. can="…"hides what the signed-in user can't do (RFC 0010).<StatusBar>shows the connection, the outbox, transfers and the assistant.
- A
- A package, and yours when you need it.
@elyra/uiis updated with Elyra.rata make:ui Tablecopies a component into the app to change, the wayFileField.svelteis written once and then is the app's. - The generated screens use it.
make:resource --viewwrites views from these components, so every app starts from the same quality, with far less generated markup.
Motivation
-
Desktop apps are judged by their controls. A list that can't be driven from the keyboard, a select that isn't searchable, or a dialog that loses focus all read as "a web page in a window". Avalonia and Flux look right because they ship real controls with real keyboard behaviour. Elyra should have them, in Svelte, without leaving the WebView that makes everything else possible.
-
Every app writes the same glue.
- Unpacking
validationErrors(err)into fields. - Paging and sorting a
Page<T>. - Hiding buttons with
$can. - A banner for
$connection.
The framework knows these shapes, so the components should.
- Unpacking
-
The generated views carry their own CSS. Each
make:resourceview writes its styles inline, so a fix to the look means regenerating, and apps drift. -
The theme has drifted already.
- The scaffold's
app.cssnames--paneland--text-dim. - The runtime's dialogs, toasts, palette and assistant panel read
--surfaceand--muted, with their own dark fallbacks.
Two vocabularies for one theme.
- The scaffold's
Current state (0.20)
rata newwritesapp/src/app.css: the Tokyo Night palette as CSS variables (--bg,--bg-2,--bg-3,--panel,--border,--text,--text-dim,--accent,--accent-2,--green,--red,--amber), a light theme under[data-theme="light"], and.toolbar,.nav,.card,.btn,.row,inputstyles.@elyra/runtimedraws its built-ins in plain DOM:confirm/alert/prompt,toast, the ⌘K palette,contextMenu, the About dialog, the update toast and the assistant panel. It has no framework dependency.make:resource --viewwritesIndex.svelte,Form.svelteandShow.svelteper resource, each with its own<style>, andFileField.svelteonce.- The stores a component would use exist:
live.*givesLive<T>({ value, error, loading }).validationErrors(err)gives the field-to-messages bag.canandabilities,connection,outbox,transfersandassistantare all stores.
Design
The package
@elyra/ui is a second package in this repository (ui/), with svelte
as a peer dependency and @elyra/runtime as its only dependency. It ships
.svelte sources (compiled by the app's own Vite, as Svelte libraries do)
and is attached to each release as elyra-ui-<version>.tgz, beside the
runtime's. rata new adds it, and rata new --elyra <path> links it with
file:, as it does the runtime.
@elyra/runtime stays framework-free: an app in another framework, or none,
keeps everything it has today.
Design tokens
One vocabulary, defined once, in @elyra/ui/tokens.css. The app's app.css
imports it and may override any token:
| Token | For | Replaces |
|---|---|---|
--elyra-bg, --elyra-surface, --elyra-raised |
window, panels, menus and popovers | --bg/--bg-2/--bg-3, --panel, --surface |
--elyra-text, --elyra-muted |
text, secondary text | --text, --text-dim, --muted |
--elyra-border, --elyra-focus |
lines, the focus ring | --border |
--elyra-accent, --elyra-accent-soft |
primary actions, selection | --accent, --accent-2 |
--elyra-danger, --elyra-success, --elyra-warning |
states | --red, --green, --amber |
--elyra-radius, --elyra-space, --elyra-font, --elyra-font-mono |
shape, rhythm, type | — |
- Dark and light. Dark is the default, light comes under
[data-theme="light"], andautofollows the OS, as the scaffold'stheme.jsdoes today. - The old names stay as aliases for a release, so existing apps keep their look.
- The runtime reads the same tokens. Its built-ins (dialogs, toasts, the
palette, the assistant panel) switch to them, so a
confirm()and a<Modal>look the same.
The look is Elyra's own, built on the palette apps already have. Flux is the inspiration for restraint and craft, not something to copy: Flux is a commercial product, and no markup, styles or icons are taken from it.
Components (first set)
| Group | Components |
|---|---|
| Actions | Button (variants primary / ghost / danger, sizes, loading, can, confirm), ButtonGroup, Dropdown + MenuItem |
| Forms | Form, Field, Input, Textarea, Select (searchable), Checkbox, Switch, Radio |
| Data | Table (a Page<T> or a plain array), Pagination, Badge, Empty |
| Overlays | Modal, Drawer, Tooltip, Popover |
| Layout | AppShell, Sidebar + NavItem, Toolbar, StatusBar, Tabs, Card, Separator |
| Feedback | Callout, Spinner, Skeleton |
Later, once the first set is used in real apps: DataGrid (virtual
scrolling, column resize, cell editing), Combobox (async options),
DatePicker, TreeView, SplitPane, Kbd, Avatar.
(Built: Combobox, SplitPane in 0.23, Kbd in 0.24, DatePicker and
Avatar in 0.25. DataGrid and TreeView wait for an app that needs
them.)
Each component:
- Works from the keyboard. Tab order, arrow keys in lists, menus and tabs, Enter and Space, and Esc closes. Focus is trapped in a modal and restored when it closes.
- Carries ARIA roles and states. A
Selectis a combobox with a listbox, aTablesort header says its direction, and aSwitchisrole="switch". - Takes
classand passes the rest on, so an app can adjust it without a fork. - Uses snippets for slots (
{#snippet actions(row)}), never named-slot APIs Svelte 5 is leaving behind.
Knowing Elyra
This is what a general-purpose library can't do:
can="ability"onButton,MenuItem,NavItemandTabhides the control when the user lacks the ability ($can).can-mode="disable"shows it disabled instead. The server still decides; this is for not offering what it would refuse.<Form onsubmit>runs the submit, catches aValidationError(a Laravel422, orValidationErrorsfrom Rust), and puts each message on the<Field name="…">it belongs to. Any other error goes in aCalloutat the top of the form. While it runs, the form is busy.<Table page={live.x(query)} bind:query>shows aLive<Page<T>>.- Its loading state, error and empty state.
- Sorting by the columns the command allows.
- Search and paging written into
query, so the live query re-runs. - A plain array works too, sorted and paged on the client.
<StatusBar>shows$connection(offline since …),$outbox(3 changes waiting, one refused),$transfers(one upload, 40 %) and, with an assistant, a button foropenAssistant(). Each item can be turned off.<Button confirm="…">asks with the runtime'sconfirm()beforeonclick, anddangermakes the dialog's button red.AppShellis the scaffold's toolbar, navigation and content, as a component that the router (route,href) and the resources'navregistry plug into.
Owning a component: rata make:ui
rata make:ui Table copies Table.svelte, and the internal helpers it
uses, into app/src/lib/ui/. It prints the import to change:
wrote app/src/lib/ui/Table.svelte
import Table from "./lib/ui/Table.svelte"; // instead of "@elyra/ui", relative to the view
The copy is the app's: rata never overwrites it (--force to replace).
rata make:ui --list shows the components that can be copied.
make:resource --view
The generated Index.svelte, Form.svelte and Show.svelte are built from
@elyra/ui:
Table,FormandField, andButton can="…"for each action.- No per-view
<style>. FileFieldbecomes@elyra/ui'sFileField.
Views generated before stay as they are: they're the app's. The smoke harness checks the new views as it checks today's.
Testing
- Unit tests in a DOM. vitest with
happy-domand@testing-library/sveltefor each component: rendering, props, keyboard behaviour, focus, ARIA, and the Elyra stores stubbed throughfetchas the runtime's tests do. - A browser check per group in the smoke harness, against a scaffolded app in headless Chrome, as the assistant panel has.
- A gallery page in the example app, every component in both themes, for a person to look at before a release.
Alternatives considered
- A native toolkit (FerroUI, an Avalonia port; or egui, Slint). It
draws its own pixels and has real desktop controls. But it would replace
the WebView and Svelte, and with them everything built on them: the
runtime's built-ins,
make:resource's views, the assistant panel, and the web skills a team already has. Two UI worlds can't share a component. - Build on an existing Svelte library (shadcn-svelte, Bits UI, Skeleton, Flowbite). They're faster to start with, but they know nothing of Elyra's stores. They bring their own dependencies (Tailwind, a headless layer) and update on their own schedule. A desktop app's keyboard and focus details are what we'd end up overriding.
- Copy-in only (shadcn's model: no package, every component in the app). Each app owns everything, and no fix reaches anyone. Flux, the model this RFC follows, ships a package and lets you publish a component when you need to.
- Package only. An app that needs one change waits for a release or forks the package.
- Put the components in
@elyra/runtime. It would make the runtime depend on Svelte, and an app without Svelte would carry it. - Tailwind classes instead of components. That isn't what's missing: the behaviour is (keyboard, focus, the Elyra stores), not the styling.
Implementation plan
- Tokens.
ui/tokens.css, dark and light. The scaffold'sapp.cssimports it, with the old names as aliases, and the runtime's built-ins read the new tokens. The package skeleton, build, tests and release asset. Visible on its own: one look for the whole app. - Actions, forms and overlays.
Button,ButtonGroup,Dropdown,Form,Field,Input,Textarea,Select,Checkbox,Switch,Radio,Modal,Drawer,Tooltip,Popover, withcanandconfirm. Tests in a DOM. - Data and layout.
TableoverLive<Page<T>>,Pagination,Badge,Empty,AppShell,Sidebar,NavItem,Toolbar,StatusBar,Tabs,Card,Callout,Spinner,Skeleton. The gallery page. rata make:ui, andrata newadding@elyra/ui.make:resource --viewon@elyra/ui. The smoke harness follows.- Docs (
docs/ui.md, one section per component, with the Elyra-aware props first), the example app on@elyra/ui, and the guidelines thatrata ai:installwrites for coding agents.
Each step is a PR; step 1 is useful on its own.
As built
Step 1:
- The token set grew by what the built-ins needed:
--elyra-inset, for inputs and hovered rows (the scaffold's--bg-3);--elyra-on-accent, for text on an accent fill;--elyra-overlayand--elyra-shadow, so dialogs suit a light theme;--elyra-radius-lgand--elyra-font-size.
- The aliases live in
tokens.css, after the tokens.--surfaceand--muted, the names the runtime read, are aliases too. - The runtime reads
var(--elyra-x, var(<old name>, <default>)), so an app that never imports the tokens looks as it did. A test keeps every colour in the built-ins on a token. @elyra/uiships sources (src/), with no build step, so a checkout links it withfile:as is.
Step 2:
RadioisRadioGroup: it takes the choices asoptions, with native radios inside.can-modeiscanMode: a Svelte prop can't have a hyphen.confirmLabelnames the confirm dialog's button.@elyra/runtimeis a peer dependency (>=0.20.0): the components use itscan,confirm,hrefandvalidationErrors.- The gallery came early, as
ui/gallery/(npm run gallery), for the design pass on the first components; it is also the page step 3 asked for. The reference replaced it before the release: a page per component indocs/ui/, served with its examples running bynpm run docs. - Overlays sit in the component's place in the page, not portalled to
<body>. A popup opens below its button, without collision handling yet.
Step 3:
Tabletakesrowstoo: a plain array is searched, sorted and paged in the table, for lists that aren't a command's page.StatusBaris quiet when all is well: online, nothing waiting and nothing running show nothing. Only the assistant's button stays.- The scaffold dedupes
svelteand@elyra/runtimein its Vite config. A linked@elyra/ui(rata new --elyra) would otherwise resolve them from the checkout, and two runtimes keep two sets of stores. Card's heading row is adiv, so the window's toolbar is the only banner.
Step 4:
- The copy comes from
app/node_modules/@elyra/ui, the version the app has installed, not one built into rata. A copy then matches the package the rest of the app uses. - A copy imports the package's internals, which
@elyra/uiexports as@elyra/ui/internal/*, instead of copying them too. A copiedFieldand the package'sFormthen share one form context; two copies of the context's key would keep them apart. rata newadding@elyra/uicame in step 1.
Step 5:
- The table's columns are the database's, what the index command sorts by; a cell shows the field. The first one links to the record.
TableandPaginationtakelabels, so a generated view's words (search, the total, Previous, Next) stay$tkeys in an app with translations.FileFieldstays the app's own (FileField.svelte, written once):@elyra/uihas no file field yet. (0.23:@elyra/uihas aFileFieldnow, andmake:resourceuses it.)make:resource --viewstops without@elyra/uiinapp/package.json, saying what to add, as it does forCargo.toml.
Step 6:
- The agents' skill is
elyra-ui, written byrata ai:installwith the others, and the guidelines point at it. - The example app's todos are on
@elyra/ui, and its own styles read the tokens. - A gap the example showed: without the
backendfeature,/__auth/abilitiesdidn't exist, so thecanstore never loaded andcanhid every control. It answers["*"]now, and CI tests the shell without features.
Open questions
- Distribution: a package with
rata make:uito copy a component (recommended), the package only, or copy-in only? - Where it lives: a new package
@elyra/uiwith Svelte as a peer (recommended), or inside@elyra/runtime? - The token names: a new
--elyra-*set with today's names as aliases for one release (recommended), or keep today's names (--panel,--text-dim) and add only what's missing? - The first set: the list above (recommended), smaller (only what
make:resourceuses), or larger (DataGridandDatePickernow)? make:resource --view: generate from@elyra/uifrom step 5 on (recommended), or behind a flag (--ui) for a release?- Icons: a small built-in set as inline SVG for what the components need (chevrons, close, check, sort; recommended), or a dependency such as Lucide?
canon a control the user can't use: hide by default (recommended, as RFC 0010's generated views do), or disable by default?- The look: Elyra's own on the current palette (recommended), with a short design pass on the first components before step 2 is merged?
Decisions
Settled on 2026-10-31, all as recommended:
- A package, and
rata make:uito copy a component into the app. - A new package,
@elyra/ui, with Svelte as a peer dependency;@elyra/runtimestays framework-free. - New
--elyra-*tokens, with today's names (--panel,--text-dim,--surface,--muted, …) as aliases for one release. - The first set as listed;
DataGrid,Combobox,DatePicker,TreeViewandSplitPanelater. make:resource --viewgenerates from@elyra/uifrom step 5 on, without a flag.- A small built-in icon set, inline SVG, for what the components need.
canhides by default;can-mode="disable"shows the control disabled.- Elyra's own look on the current palette, with a short design pass on the first components before step 2 is merged.