Overview
Project the typed descriptor graph into UI — forms, tables, pickers, and reactive components, all bound to a descriptor with near-zero glue.
You already own a typed schema on both sides of the wire: the entity DSL
(*.entity.ts) and the descriptor schemas (defineQuery / defineMutation
carry effect/Schema for input, output, and target). Schema-driven UI
projects that into the frontend — so a form, a table, or a picker is a
binding to a descriptor the framework already has, not hand-written glue.
Here's that idea live — a form bound to a mutation and a table bound to a query, sharing one reactive backend. Add a row and it appears instantly, pushed from the server (it's your own throwaway sandbox; it resets on refresh):
<AutoForm api="app" mutation="todos.create" submitLabel="Add todo" />
<DataTable api="app" query="todos.list" />
Why this isn't "yet another admin generator"
Auto-generated CRUD UIs fail because they generate against a dead REST/SQL
schema and hit a wall the moment you need anything custom. Voltro's version is
different on one axis: the generated UI binds to a reactive, auto-optimistic
backend. A <DataTable> is not a dead grid you refetch — it's a live
subscription that updates on any write, with optimistic patches derived from
the mutation descriptor's target, for free. The generation is the delivery;
the reactivity is the value.
Binding symmetry — everything binds to a descriptor
| UI piece | binds to | gives you |
|---|---|---|
| a form | a mutation (todos.create) |
fields from its input Schema, validation, submit, op-correct optimistic |
| a field / picker | a query (users.search) |
live options, debounce, pagination |
| a table | a query (todos.list) |
live rows, sort, pagination, row-action mutations |
| a filter panel | a query's input | faceted controls + a live result count |
| a detail view | a query + relations | a live record + its eager-loaded relations |
There's no new data layer to learn. Customization is only ever choosing how a binding renders — never re-plumbing the binding.
The customization ladder (never lose the binding)
This is where 99% of schema→UI generators die: they nail the happy path, then force a config DSL until it's worse than hand-written JSX — or you eject and lose everything. The one rule that avoids both:
Customization is a ladder of ever-smaller overrides, and on no rung do you lose the binding (schema validation + mutation submit + optimistic; for a picker, the live query). Eject from the rendering is never eject from the binding.
| Rung | How | You lose |
|---|---|---|
| 0 — schema annotation | widget / label on the field |
nothing (zero form code) |
| 1 — per-field render-prop | <Field name="x">{f => <MyWidget .../>}</Field> |
nothing — state/validation/submit stay |
| 2 — widget registry | map a widget-kind → component app-wide | nothing |
| 3 — layout override | arrange <Field/>s in your own JSX |
nothing |
| eject — headless | useFormBinding / useDataTable + your JSX |
only the auto-rendering — the binding stays |
<AutoForm> / <DataTable> are sugar over a headless core
(useFormBinding / useDataTable) — which is exactly why ejecting from the
rendering keeps everything underneath.
Localizing the built-in strings
The components render a handful of user-facing labels — Clear, Actions,
Search…, the workflow status words (Running / Succeeded / …), presence's
"X is editing" sentence, and so on — with English defaults. Localize them
all at once by wrapping the app in UiStringsProvider, instead of threading a
prop through every component:
import { UiStringsProvider } from '@voltro/web'
<UiStringsProvider
strings={{
filters: { clear: 'Zurücksetzen', resultCount: (n) => `${n} Ergebnisse` },
dataTable: { actions: 'Aktionen' },
workflow: { status: { running: 'Läuft' } },
presence: { editingOne: (name, verb) => `${name} ist am ${verb}` },
}}
>
<App />
</UiStringsProvider>Overrides deep-merge onto the defaults — supply only the sections and keys
you change; everything else stays English. Interpolated strings (counts, names)
are functions so a locale can reorder or pluralize. Providers nest (an inner one
merges onto the outer), and a per-component prop (e.g. <AgentChat placeholder>)
still wins over the context for a one-off tweak. useUiStrings() reads the
active strings; defaultUiStrings is the English baseline.
Packaging
Three layers, all re-exported by @voltro/web so apps import everything from
the framework's web entry:
- Headless bindings (
useFormBinding,useDataTable,useQueryField, …) →@voltro/client— pure logic, no kit dependency; works with any presentation (incl. an MUI/Mantine adapter). - The widget seam + components (
<AutoForm>/<Field>/<DataTable>/<AsyncSelect>+ the registry + accessible HTML defaults) →@voltro/ui. - The styled kit (Tailwind
shadcnWidgets+ tokens) →@voltro/ui-shadcn, which plugs into the seam via<WidgetRegistryProvider widgets={shadcnWidgets}>.
In this section
- Forms & tables —
<AutoForm>,<DataTable>, query-bound pickers, the widget seam, skeletons, filters. - Reactive components — workflow progress, presence / multiplayer, AI chat — UI over the framework's durable + reactive backend.
- Client utilities —
useCan,useDerived,useProvenance,useUndo,usePreview, async validation,<RecordView>, the typed analytics catalog, the offline outbox, windowed subscriptions.