A catalog item binds model data to a widget. A story binds args to the same widget. Structure the widget so both can reach it.
Write the catalog widget as a plain presentational widget.
No DataModel, no schema.
class OptionTiles extends StatelessWidget {
const OptionTiles({
super.key,
required this.question,
required this.options,
required this.selectedId,
required this.onSelected,
});
final String question;
final List<TileOption> options;
final String? selectedId;
final ValueChanged<String> onSelected;
// ...
}Keep GenUI awareness in the CatalogItem:
final optionTiles = CatalogItem(
name: 'OptionTiles',
dataSchema: _optionTilesSchema,
widgetBuilder: (context) {
final data = _OptionTilesData.fromMap(context.data as Map<String, Object?>);
return BoundString(
dataContext: context.dataContext,
value: data.selection,
builder: (builderContext, selectedId) => OptionTiles(
question: data.question,
options: data.options,
selectedId: selectedId,
onSelected: (id) => context.dataContext.update(
DataPath(data.selectionPath),
id,
),
),
);
},
);Selections write to the local DataModel.
Tapping a tile does not call the model again.
OptionTiles is controlled: it renders selectedId and reports taps through onSelected.
The DataModel holds that state in the app. Nothing holds it in a story, so taps would do nothing.
Add a stateful host:
class OptionTilesHost extends StatefulWidget {
const OptionTilesHost({
super.key,
required this.question,
required this.options,
this.initialSelectionId,
});
final String question;
final List<TileOption> options;
final String? initialSelectionId;
@override
State<OptionTilesHost> createState() => _OptionTilesHostState();
}
class _OptionTilesHostState extends State<OptionTilesHost> {
late String? _selectedId = widget.initialSelectionId;
@override
Widget build(BuildContext context) {
return OptionTiles(
question: widget.question,
options: widget.options,
selectedId: _selectedId,
onSelected: (id) => setState(() => _selectedId = id),
);
}
}The host stands in for the DataModel, making selection testable in a scenario.
Widgetbook rebuilds a story with a new key when an arg changes, so editing initialSelectionId resets the host.
See Stateful Widgets and Arg Updates.
Meta points at the host, so args come from its constructor:
import 'package:design_system/option_tiles.dart';
import 'package:widgetbook/widgetbook.dart';
import 'option_tiles_host.dart';
part 'option_tiles.stories.g.dart';
const meta = Meta(OptionTilesHost.new);
const _seasons = [
TileOption(id: 'spring', label: 'Spring'),
TileOption(id: 'summer', label: 'Summer'),
TileOption(id: 'autumn', label: 'Autumn'),
TileOption(id: 'winter', label: 'Winter'),
];
final $Default = _Story(
args: _Args(
question: StringArg('Which season?'),
options: Arg.fixed(_seasons),
initialSelectionId: NullableStringArg(null),
),
);options is not a primitive, so the generator produces Arg<List<TileOption>> and you supply it with Arg.fixed.
For an editable control, write a custom arg.
onSelected never appears in the story.
Functions are not model-authored data, so they are neither args nor schema properties.
When the model should influence behavior, it emits data and the app maps it.
A variant enum or showsClearAction boolean is model-authored.
The callback it triggers is yours.
See Best Practices.
No generator required.
A .stories.dart file is a typed spec of the widget: every parameter, allowed enum value, default, and the states worth a scenario.
Hand it to a coding agent and ask for the dataSchema.
| Widgetbook | json_schema_builder |
|---|---|
StringArg | S.string() |
IntArg / DoubleArg | S.integer() / S.number() |
BoolArg | S.boolean() |
EnumArg<T>(T.someValue, values: T.values) | S.string(enumValues: [...]) |
Arg.fixed(List<T>) | S.list(items: S.object(...)) |
| Non-nullable arg | Listed in required |
NullableStringArg and friends | Omitted from required |
Arg.fixed(callback) | Nothing. App-owned |
For the story above:
final _optionTilesSchema = S.object(
description: 'A compact tile selector for a single choice along one axis.',
properties: {
'question': S.string(description: 'The question shown above the tiles.'),
'options': S.list(
description: 'Between two and six choices.',
items: S.object(
properties: {
'id': S.string(description: 'Stable identifier for the choice.'),
'label': S.string(description: 'Short display label, 1-3 words.'),
},
required: ['id', 'label'],
),
),
'selection': A2uiSchemas.stringReference(
description: 'Path under /scene/* where the selection is stored.',
),
},
required: ['question', 'options', 'selection'],
);Two things an agent cannot infer from types:
- Descriptions are prompt text. The model reads them to decide when to use the widget and what to put in it.
'Short display label, 1-3 words'prevents overflow that no type signature could. - Design constraints. If the layout breaks past six tiles, the schema must say so. The Dart signature accepts any list length. Your design does not.
Regeneration is the only thing keeping schema and widget aligned. Add a constructor parameter and no test fails, the model simply never emits it. Regenerate whenever a catalog widget's API changes.

