Flutter · A2UI · pub.dev
Annotate a Flutter widget and the catalog an agent composes against is derived from its constructor — schema, builder and examples — so it cannot drift from the widget it describes.
The problem
A genui catalog needs three things per widget: a JSON schema the model composes against, a builder that turns that JSON back into a widget, and example data for tooling and few-shot prompts.
Write them by hand and they drift in silence. Rename a constructor parameter, add a required one, change an enum — the app still compiles, and the model keeps composing against last month's contract. The first symptom is a malformed message in production.
So don't write them. The generator reads the constructor with the analyzer and emits all three from it. The property names are the parameter names; the required list is the set of non-nullable parameters without defaults. There is no second source of truth to keep in sync.
1 · you write this
@GenUiWidget(description: 'A product card with price and image.') class ProductCard extends StatelessWidget { const ProductCard({ super.key, required this.title, required this.price, this.imageUrl, this.onTap, }); /// Product name. final String title; /// Price in USD. final double price; /// Optional image URL. final String? imageUrl; /// Fired when the card is tapped. final VoidCallback? onTap;
2 · build_runner emits this
final CatalogItem productCardCatalogItem = CatalogItem( name: 'ProductCard', dataSchema: S.object( description: 'A product card with price and image.', properties: { 'title': A2uiSchemas.stringReference( description: 'Product name.', ), 'price': A2uiSchemas.numberReference( description: 'Price in USD.', ), 'imageUrl': A2uiSchemas.stringReference(...), 'onTap': A2uiSchemas.action(...), }, required: ['title', 'price'], ), // + widgetBuilder, + exampleData );
3 · the model may send this
{
"id": "root",
"component": "ProductCard",
"title": "Noise-cancelling headphones",
"price": 249.99,
"imageUrl": {
"path": "/cart/0/image"
},
"onTap": {
"event": { "name": "onTap" }
}
}
every property takes a literal or a binding
Change the constructor and the generated part changes in review, or the build fails. The catalog can't fall behind the widget, because the catalog is a function of the widget.
package:genui_gen/tracing.dart
Someone reports that the list came up empty. You open the code and there is no list: a model composed that screen, once, from a context that will not come back.
A trace is that session kept on disk — every message the agent sent, the data model each time it changed, every action the app sent back. It replays with no model and no network, because A2UI describes interfaces as data.
Below is a real recording from the example app, five steps, exactly as GenUiTraceRecorder wrote it. Drag the scrubber.
what was on screen at that step
record
final recorder = GenUiTraceRecorder.attach( controller, catalogId: genUiCatalog.catalogId, redact: const ['/user/email'], ); transport.messages.listen(recorder.handleMessage); // ... await File('bug-4821.a2ui-trace') .writeAsString(recorder.build().encode());
replay, in a test, against today's catalog
final player = GenUiTracePlayer(trace, catalog: genUiCatalog) ..seek(4); await tester.pumpWidget( MaterialApp(home: GenUiTraceView(player: player)), ); expect(find.text('Call the dentist'), findsOneWidget);
That is how last week's session becomes this week's regression test: a catalog change that breaks a real conversation fails here first. redact names the data model paths that must never reach the file — the field holding an email belongs in that list before the first recording, not after the first leak.
@GenUiProp(template: true)
A list of children is normally written out by the agent, one id at a time. That works until the list is the data: five tasks today, nine tomorrow, and a new surface composed every time one is added.
A template property takes the other shape A2UI allows. The agent describes the row once and names a path; one row is built per entry there. A new entry adds a row with nobody asked.
the widget
@GenUiWidget(description: 'A titled list of rows, one per item in the data.') class TaskList extends StatelessWidget { const TaskList({ super.key, required this.title, @GenUiProp(template: true) required this.rows, this.emptyLabel = 'Nothing here yet.', }); final String title; /// One row per task. final List<Widget> rows;
the agent sends the row once
{
"id": "root",
"component": "TaskList",
"title": "Today",
"rows": {
"componentId": "task_row",
"path": "/tasks"
}
},
{
"id": "task_row",
"component": "Text",
"text": { "path": "label" }
}
label against /tasks/2/label, so one component describes every row.package:genui_gen/testing.dart
The schema half of a catalog is checked when it's generated. The other half — what the rendered component says to the person using it, and what the whole document costs to send — has nothing checking it, and it's the half a Dart diff doesn't show.
| Call | What it keeps | What it fails on |
|---|---|---|
| genUiSemanticsGolden | Role, name, value, state and actions of every component, in traversal order, as a reviewable JSON file. | A widget change that quietly alters what a screen reader is told. |
| genUiSemanticsAudit | Runs over that same recording. | A control with nothing to announce, a component that reaches assistive tech as nothing at all, two controls that announce themselves identically. |
| genUiCatalogDiff | Yesterday's published catalog.json against today's. |
A property that disappears, becomes required, changes type, or loses an enum value — all invisible to your compiler, all breaking for the agent. |
| genUiCatalogWeight | How many characters each component takes of the document that travels in every request. | Nothing on its own. It tells you which component is eating the prompt. |
the golden, as it is written
{
"components": {
"PreferenceRow": [
{
"role": "switch",
"name": "Sample label",
"state": { "on": true, "disabled": false },
"actions": ["tap", "focus"]
}
],
"Image": [],
"Icon": []
}
}
two empty arrays: a screen reader is told nothing
the weight of the example's catalog, measured
characters, not tokens — a number that pretends to be exact about someone else's tokenizer is worse than an honest proportion
Filed upstream
The semantics recorder was written for this package's own widgets. Pointed at genui's basic catalog — the components everyone gets for free — it produced a file full of empty arrays and unnamed controls. Each of these was measured with the tooling on this page before it was reported.
| Where | What the recording showed | Status |
|---|---|---|
| a2ui#2697 | Every A2UI component declares accessibility.label and description. Neither reaches the semantics tree — the renderer drops them. |
genui#1035 |
| a2ui#2763 | Sliders, volume controls and playback buttons record a role and no name. There is nothing for a screen reader to say. | genui#1042 |
| a2ui#2740 | Image and Icon record as []. The catalog's own description is never applied as a label. |
Open |
| a2ui#2736 | CheckBox ignores a literal value and renders unchecked, so the model's state and the screen disagree. |
genui#1038 |
| a2ui#2737 | Slider behaves as a two-position switch over its default range, and crashes below it. |
Open |
| a2ui#2738 | Proposal: hold Flutter, SwiftUI, Compose and the web to one semantics tree per component — the shape this recorder already writes. | Proposed |
Role, name, value, state and actions is the only description of a rendered surface that every A2UI renderer can be held to. Which means the same recording says what a second client, in another language, would have to reproduce from your catalog.
Getting started
1 · pubspec.yaml
dependencies: genui: ^0.10.0 genui_gen: ^0.8.0 dev_dependencies: build_runner: ^2.15.0 genui_gen_builder: ^0.7.0
2 · build.yaml
targets:
$default:
builders:
genui_gen_builder:genui_catalog:
options:
catalog_id: com.example.app
The id names your catalog to everything outside the build. A generator can't invent it.
3 · anywhere
dart run build_runner build
Every annotated widget lands in a generated genui_catalog.g.dart, sorted, so registering a catalog stops being a hand-maintained list of names.
and the catalog is one line, whatever the app grows into
import 'genui_catalog.g.dart';
final catalog = genUiCatalog.copyWith(
newItems: BasicCatalogItems.asCatalog().items.toList(),
);